mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 04:02:18 +01:00
## 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 on6ffb682894, 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 on6ffb682894with no open findings - [x] I will address all review comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
68 lines
2.4 KiB
TypeScript
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); } };
|
|
}
|