Files
PaperClipAI/packages/plugins/sdk/src/idle-drain.ts
T
Devin FoleyandPaperclip 3d1d5294d9 Drain idle plugin workers before automatic sleep (#15599)
## Thinking Path

> - Paperclip runs autonomous work through agents, schedules, and
plugins.
> - Automatic idle sleep must preserve accepted work and unfinished
cleanup.
> - Every enabled plugin currently blocks sleep, including unused
providers.
> - An enabled flag or package version cannot prove that a worker is
idle.
> - This change adds a live worker drain under the existing owned hold.
> - Idle workers can allow sleep while unknown work stays protected.

## Linked Issues or Issue Description

Refs #15522. Related: #15391 adds plugin readiness for agent admission;
this change concerns instance sleep and does not replace that contract.

**Subsystem affected**

Plugin worker lifecycle and automatic idle sleep.

**Problem or motivation**

A workspace with no pending work cannot sleep when any plugin is
enabled. Removing that check alone would lose accepted RPCs, background
tasks, or cleanup after a caller timeout.

**Proposed solution**

Require a live `onIdleDrain` handshake from each worker. Close admission
in both processes for the exact owner and expiry. Count accepted work
until completion. Continue checking durable work separately.

## What Changed

- Add bounded worker holds, exact-owner release, automatic expiry, and
an abort signal for plugin-owned background work.
- Count host and worker requests through their real completion receipts.
Keep timed-out work counted. Check active notifications and terminal
routes.
- Accept enabled plugins only when their current workers provide
matching runtime receipts. Missing workers, old SDKs, crashes, invalid
replies, and unknown cleanup still prevent sleep.
- Let unused Daytona workers opt in. Once a worker contacts the
provider, it remains a blocker for that process lifetime. This
restriction avoids treating its existing timeout and terminal-close
behavior as a cleanup receipt.
- Document the plugin author contract. No schema or user-facing API is
added.

## Verification

- All hosted CI checks passed on 6ffb682894, including all server,
runner, workspace, build, typecheck, and end-to-end checks. One
unrelated OpenCode transport test hit a five-second timeout and passed
on rerun.
- `pnpm -r typecheck` passed.
- `pnpm build` passed.
- Focused Vitest: 293 tests passed across worker admission, real
child-process RPC, durable idle checks, and Daytona.
- Adjacent worker manager and duplex tests: 119 passed.
- Real child-process tests cover late worker completion, a host write
after caller timeout, stale owners, closed admission, and release.
- SDK tests cover hook failures, busy workers, expiry without another
request, and late acknowledgements after expiry.
- Initial long local run: 17,202 passed, 10 failed. Five failures
involved parent-directory skill fixtures; four of those were already
reproduced and passed in an isolated worktree. One HTTP test had a
socket hang-up; all 66 tests in that file passed on rerun. The other
four used new tests with runtime modules loaded before the follow-up
edits; all pass in the fresh focused run above. A clean build and
full-suite rerun at the final commit are running in an isolated
worktree. No full-suite pass is claimed.
- Follow-up: 44 focused tests passed, including repeated owned holds
without an intervening normal request. Worker status changes now
invalidate an in-progress scan.
- Apex follow-up: 13 idle tests, 119 adjacent worker tests, 71
instance-settings route tests, and server typecheck passed. A real
queued notification completes its write while preparation is in flight;
synchronous and async session callback failures are logged.
- Diff whitespace and added-line secret/private-reference scans passed.

## Risks

- Plugins are trusted code. A hook must account for work outside SDK
RPCs and keep it quiescent until its signal aborts. There is no
package-name or manifest-only exemption.
- This first change targets unused workers. Used Daytona workers remain
awake until remote cleanup receipts are complete. Restarting does not
bypass durable lease and recovery checks.
- Unknown completion remains a blocker, including after a worker crash.
This can retain cost but cannot authorize sleep from a timeout alone.
- Enabled jobs, schedules, credentials, integrations, and other durable
work remain blockers. Durable wake ownership is separate follow-up work.
- Rollback restores the blanket plugin blocker. No data migration is
required.

## Model Used

OpenAI Codex, GPT-6. Used repository inspection, reasoning, code
editing, and command execution. The runtime did not report an exact
model revision or context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either linked existing public issues or described the issue
in this PR
- [x] I have not referenced internal or instance-local issues or links
- [x] My branch name describes the change and contains no internal
identifiers
- [x] Focused local tests pass; full CI is green (long local rerun
status is recorded above)
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation
- [x] I have considered and documented risks
- [x] All Paperclip CI gates are green
- [x] Greptile Apex is 5/5 on 6ffb682894 with no open findings
- [x] I will address all review comments before requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-08 12:57:32 -07:00

68 lines
2.4 KiB
TypeScript

export type PluginIdleHold = { ownerId: string; expiresAt: number };
export type PluginIdleWork = "none" | "present" | "unknown";
/** Worker admission stays closed for the exact bounded hold. A caller timeout
* never releases a token: only the actual operation's settlement does. */
export function createPluginIdleDrain() {
let active = 0;
let preparing = 0;
let hold: (PluginIdleHold & { controller: AbortController; ready: boolean }) | null = null;
let timer: ReturnType<typeof setTimeout> | undefined;
function release(ownerId: string): void {
if (hold?.ownerId !== ownerId) return;
const prior = hold;
hold = null;
clearTimeout(timer);
prior.controller.abort();
}
function current() {
if (hold && hold.expiresAt <= Date.now()) release(hold.ownerId);
return hold;
}
function begin(): () => void {
if (current()) throw new Error("Plugin is held for idle sleep");
active++;
let done = false;
return () => { if (!done) { done = true; active--; } };
}
async function prepare(
requested: PluginIdleHold,
inspect?: (signal: AbortSignal) => Promise<PluginIdleWork>,
): Promise<PluginIdleWork> {
if (!requested || typeof requested.ownerId !== "string" || !requested.ownerId ||
!Number.isFinite(requested.expiresAt) || requested.expiresAt <= Date.now() ||
requested.expiresAt > Date.now() + 300_000) return "unknown";
if (!inspect) return "present";
const prior = current();
if (prior) return prior.ownerId === requested.ownerId && prior.expiresAt === requested.expiresAt && prior.ready
? "none" : "unknown";
if (active !== 0 || preparing !== 0) return "present";
const candidate = { ...requested, controller: new AbortController(), ready: false };
hold = candidate;
timer = setTimeout(() => release(candidate.ownerId), requested.expiresAt - Date.now());
timer.unref?.();
preparing++;
try {
const result = await inspect(candidate.controller.signal);
if (current() !== candidate || active !== 0) return "unknown";
if (result !== "none") {
release(candidate.ownerId);
return result === "present" ? "present" : "unknown";
}
candidate.ready = true;
return "none";
} catch {
if (hold === candidate) release(candidate.ownerId);
return "unknown";
} finally {
preparing--;
}
}
return { begin, prepare, release, close: () => { if (hold) release(hold.ownerId); } };
}