mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-06 14:02:36 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Automatic idle sleep must preserve accepted work, cleanup and recovery. > - Saved AI login connections are configuration without independent workers. > - Failed ephemeral leases can remain after their resources have stopped. > - Detached subscription observations and quota refreshes can outlive a response. > - This pull request distinguishes inert configuration and verified history from unfinished work. > - The benefit is safe idle eligibility while keeping credentials and audit history. ## Linked Issues or Issue Description **What happened?** An active saved AI login blocks idle sleep after its login and agent runs finish. A failed ephemeral lease also blocks after successful cleanup and provider termination. The connection guard currently hides detached quota and subscription work that can outlive the request that started it. **Expected behavior** An idle instance can sleep with saved AI credentials and verified failed-lease history. Accepted refresh work, recovery journals, unfinished login sessions, pending cleanup and run accounting must still block. **Steps to reproduce** 1. Save an AI connection with purpose `ai`, transport `runtime_auth` and status `active`. 2. Finish login and all agent work, then acquire an owned idle drain. 3. Read the idle safety report. The saved connection alone reports background work. 4. Repeat with a failed ephemeral lease whose exact provider termination and finished run are recorded. Its history also reports background work. **Paperclip version or commit** Based on `0bbd586a2f6`, after updates from master. **Deployment mode** Single-process managed or self-hosted server using the owned idle-drain protocol. Related work: #15522, #15761 and #15851. Searched open and closed pull requests for related idle-sleep changes. The open accounting recovery work in #15879 is separate; this change does not settle or discard accounting debt. ## What Changed - Exempt active saved AI runtime-auth configuration from the connection sleep check. Other connection types and unknown states keep their existing checks. - Track detached subscription discovery and provider observations until their actual promises settle. - Track underlying quota work through an HTTP timeout, including an OAuth exchange and the save of its replacement credential. - Inspect the credential-recovery directory without reading credential bytes. Journals, probes, temporary files, unexpected entries and read failures keep sleep blocked. Empty company directories left by successful recovery permit sleep. - Exempt a failed ephemeral lease only after successful cleanup, a release timestamp, an exact company/run/lease/resource termination receipt, and a finished run without retry or accounting debt. Export and cleanup intents still block. - Use the same failed-lease proof when checking finished login-session cleanup. - Add PostgreSQL and filesystem regression tests and document the behavior. ## Verification - Four focused suites passed: 186 tests covering durable guard decisions, local recovery, detached subscription work and quota timeouts. - `pnpm build` passed. - `pnpm -r typecheck` passed. - `pnpm --filter @paperclipai/server build` and a final server TypeScript check passed after the directory-scanner cleanup. - The final directory-scan recheck passed all 18 tests. - The original-base `pnpm test:run` attempt found three local failures. It was stopped before completion to update the branch after two master changes. All three also reproduce on unchanged master `e733bddc45e`: two Git default-branch fixtures in `workspace-runtime.test.ts` and one native runner recovery fixture in `native-session-resume.test.ts`. - Both Git fixtures pass with a command-scoped `init.defaultBranch=master` setting. The native fixture fails with the same result on both branches. No global Git setting was changed. - All CI gates passed on the final head `ff63497d9bef40f5f00567bc729fb17536cfe136`: [complete CI run](https://github.com/paperclipai/paperclip/actions/runs/38156312722). The only skipped checks are the two path-gated Storybook jobs. - Greptile Apex scored the final head 5/5 with no actionable findings and no unresolved review threads. The branch has no merge conflicts with master. - After the master update, `pnpm build` and `pnpm -r typecheck` passed again. All four focused suites passed again: 186 tests. - Staging deployment and live sleep/wake verification are pending the Core merge and the verified private Cloud build. No production rollout has been started. ## Risks - The failed-lease exception is deliberately narrow. Older records without exact termination evidence or a complete run receipt continue to block. - Pending OAuth recovery blocks sleep until it is recovered. No credential, history, cleanup obligation or accounting receipt is deleted. - The existing owned drain, ingress fence, local-work generation checks, plugin drain and backup checkpoint remain required. - No schema migration or provider mutation is introduced. Roll back to the previous application release to restore the conservative guard behavior. ## Model Used OpenAI GPT-6 (Codex), with repository inspection, code editing, test execution and read-only provider analysis. No additional model version or context-window size was supplied by the runtime. ## 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 issues or described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket id or instance-derived details - [x] I have run focused tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented risks - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no unresolved comments - [x] I will address all reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing>
105 lines
5.7 KiB
TypeScript
105 lines
5.7 KiB
TypeScript
import { promises as fs } from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { inspectIdleSpool, inspectIdleQuotaRecovery, idleQuotaRecoveryPath, readIdleLocalWork, markIdleIngressTracked, markIdleStartupComplete, registerIdleSpoolDirectory } from "../services/idle-local-work.js";
|
|
|
|
let root: string;
|
|
beforeEach(async () => { root = await fs.mkdtemp(path.join(os.tmpdir(), "idle-spool-")); });
|
|
afterEach(async () => { vi.restoreAllMocks(); vi.unstubAllEnvs(); await fs.rm(root, { recursive: true, force: true }); });
|
|
describe("idle cleanup inspection", () => {
|
|
it("refuses to authorize sleep until startup and ingress tracking are ready", async () => {
|
|
expect(await readIdleLocalWork()).toBe("unknown");
|
|
});
|
|
it("accepts absent and empty directories", async () => {
|
|
expect(await inspectIdleSpool(path.join(root, "absent"))).toBe("none");
|
|
expect(await inspectIdleSpool(root)).toBe("none");
|
|
});
|
|
it.each(["receipt.json", "malformed.json", "partial.tmp", "write.probe", "unexpected"])("retains every spool entry: %s", async name => {
|
|
await fs.writeFile(path.join(root, name), "not valid JSON");
|
|
expect(await inspectIdleSpool(root)).toBe("present");
|
|
await fs.rm(path.join(root, name));
|
|
expect(await inspectIdleSpool(root)).toBe("none");
|
|
});
|
|
it("fails closed on a non-directory, dangling symlink, or read failure", async () => {
|
|
const file = path.join(root, "file"), link = path.join(root, "link");
|
|
await fs.writeFile(file, "");
|
|
await fs.symlink(path.join(root, "missing"), link);
|
|
expect(await inspectIdleSpool(file)).toBe("unknown");
|
|
expect(await inspectIdleSpool(path.join(file, "nested"))).toBe("unknown");
|
|
expect(await inspectIdleSpool(link)).toBe("unknown");
|
|
vi.spyOn(fs, "opendir").mockRejectedValue(Object.assign(new Error("private path"), { code: "EACCES" }));
|
|
expect(await inspectIdleSpool(root)).toBe("unknown");
|
|
});
|
|
it("checks custom spool directories as well as the default paths", async () => {
|
|
vi.stubEnv("PAPERCLIP_HOME", root);
|
|
vi.stubEnv("SANDBOX_ORPHAN_CLEANUP_SPOOL_DIR", path.join(root, "orphans"));
|
|
const custom = path.join(root, "custom-accounting");
|
|
registerIdleSpoolDirectory(custom);
|
|
markIdleIngressTracked(); markIdleStartupComplete({ scheduledBackups: false });
|
|
expect(await readIdleLocalWork()).toBe("none");
|
|
markIdleStartupComplete({ scheduledBackups: true });
|
|
expect(await readIdleLocalWork()).toBe("present");
|
|
expect(await readIdleLocalWork({ backupCheckpoint: true })).toBe("none");
|
|
markIdleStartupComplete({ scheduledBackups: false });
|
|
await fs.mkdir(custom);
|
|
await fs.writeFile(path.join(custom, "receipt.tmp"), "incomplete");
|
|
expect(await readIdleLocalWork()).toBe("present");
|
|
expect(await readIdleLocalWork({ backupCheckpoint: true })).toBe("present");
|
|
await fs.rm(custom, { recursive: true });
|
|
expect(await readIdleLocalWork()).toBe("none");
|
|
});
|
|
|
|
});
|
|
|
|
describe("idle credential recovery inspection", () => {
|
|
const company = "7238bc31-f3ca-42ba-b487-4122e77b8867";
|
|
it("allows missing roots and empty company directories after successful recovery", async () => {
|
|
expect(await inspectIdleQuotaRecovery(path.join(root, "missing"))).toBe("none");
|
|
expect(await inspectIdleQuotaRecovery(root)).toBe("none");
|
|
await fs.mkdir(path.join(root, company));
|
|
expect(await inspectIdleQuotaRecovery(root)).toBe("none");
|
|
});
|
|
it.each(["receipt.json", "malformed.json", "partial.tmp", "write.probe", "unexpected"])(
|
|
"retains every pending company entry without reading its contents: %s", async name => {
|
|
await fs.mkdir(path.join(root, company));
|
|
const file = path.join(root, company, name);
|
|
await fs.writeFile(file, "unreadable recovery fixture");
|
|
const read = vi.spyOn(fs, "readFile");
|
|
expect(await inspectIdleQuotaRecovery(root)).toBe("present");
|
|
expect(read).not.toHaveBeenCalled();
|
|
await fs.rm(file);
|
|
expect(await inspectIdleQuotaRecovery(root)).toBe("none");
|
|
},
|
|
);
|
|
it("retains unexpected root entries, nested directories and directory symlinks", async () => {
|
|
await fs.mkdir(path.join(root, "unexpected"));
|
|
expect(await inspectIdleQuotaRecovery(root)).toBe("present");
|
|
await fs.rm(path.join(root, "unexpected"), { recursive: true });
|
|
await fs.symlink(path.join(root, "missing"), path.join(root, company));
|
|
expect(await inspectIdleQuotaRecovery(root)).toBe("present");
|
|
await fs.rm(path.join(root, company));
|
|
await fs.mkdir(path.join(root, company, "nested"), { recursive: true });
|
|
expect(await inspectIdleQuotaRecovery(root)).toBe("present");
|
|
});
|
|
it("fails closed on invalid roots and directory read errors", async () => {
|
|
const file = path.join(root, "file"), link = path.join(root, "link");
|
|
await fs.writeFile(file, ""); await fs.symlink(root, link);
|
|
expect(await inspectIdleQuotaRecovery(file)).toBe("unknown");
|
|
expect(await inspectIdleQuotaRecovery(link)).toBe("unknown");
|
|
vi.spyOn(fs, "opendir").mockRejectedValue(Object.assign(new Error("private path"), { code: "EACCES" }));
|
|
expect(await inspectIdleQuotaRecovery(root)).toBe("unknown");
|
|
});
|
|
it("keeps persisted credential recovery awake after the request and process work have ended", async () => {
|
|
vi.stubEnv("PAPERCLIP_HOME", root);
|
|
markIdleIngressTracked(); markIdleStartupComplete({ scheduledBackups: false });
|
|
const directory = path.join(idleQuotaRecoveryPath(), company);
|
|
await fs.mkdir(directory, { recursive: true });
|
|
const file = path.join(directory, "pending.json");
|
|
await fs.writeFile(file, "encrypted journal fixture");
|
|
expect(await readIdleLocalWork()).toBe("present");
|
|
await fs.rm(file);
|
|
expect(await readIdleLocalWork()).toBe("none");
|
|
});
|
|
});
|