mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 12:59:13 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agents use execution workspaces to retain their files and Git branches. > - A reaper closes eligible workspaces after their task tree is terminal. > - The reaper scanned every heartbeat tick and read Git before cheap eligibility checks. > - Permanent scans also query idle databases when no cleanup work exists. > - This pull request wakes cleanup after transaction settlement and restores bounded work at startup. > - The benefit is fewer scans while fresh cleanup guards continue to protect retained work. ## Linked Issues or Issue Description Related work: Refs #13619, Refs #11753. Those PRs bound scans or back off unchanged candidates. This PR uses the transaction work coordinator to remove the permanent reaper tick. It overlaps their early terminal-state filtering but does not change their runtime restart behavior or introduce a second Git scheduler cache. **What existing behavior does this improve?** Terminal workspace cleanup and Git inspection for workspace detail reads. **Subsystem affected** Server orchestration, execution workspaces, and idle sleep admission. **Current behavior** The heartbeat tick scans workspace candidates every thirty seconds. It runs Git before rejecting nonterminal trees, cooldowns, and active runs. Display inspections expire after five seconds. **Proposed behavior** Recover workspace rows in bounded startup pages. Wake after relevant issue, workspace, and pull-request work-product transactions settle. Schedule the terminal tree cooldown or a five-minute eligibility retry. Back off unchanged candidates across unrelated wakes, and invalidate their backoff after relevant committed changes. Park empty and nonterminal-only lanes. Reuse display inspections for thirty seconds. Read fresh Git before destructive cleanup. **Reason and benefit** Avoid recurring database and Git work when no cleanup is possible. Retain transaction admission holds, restart recovery, and fresh deletion checks. Retention eligibility can wait for the next host wake; active and failed recovery work still blocks sleep. **Breaking changes** No schema or API shape changes. Display Git state can lag by thirty seconds. Cleanup eligibility retries can take five minutes. ## What Changed - Add a terminal-workspace worker to the existing transaction work coordinator and scheduler. - Signal relevant writes before mutation on the owning transaction. Outer transaction settlement wakes cleanup. - Derive cooldown deadlines from the full company-scoped terminal task tree. Continue bounded recovery pages without a permanent sweep. - Bound unchanged candidate inspections to five minutes, retain at most 256 entries, and invalidate affected entries with scoped transaction hints. Repeated wakes preserve the earliest inspection deadline. - Collect deadlines from task trees already read by bounded pages. Restore the cursor after a failed page and rescan commits received during a rotation. - Check terminal state, cooldown, and active runs before Git. Preserve lifecycle locks, generation fences, and fresh final checks. - Keep failed or incomplete recovery visible to idle admission. Preserve the passive retention exception. - Extend the bounded display cache to thirty seconds and document the scheduling and wake limits. ## Verification - Focused workspace, coordinator, cache, and local sleep suites: 111 tests passed with real local PostgreSQL and Git worktrees where applicable. - Final worker, display cache, and local sleep suites: 28 tests passed. - The regressions prove three ineligible workspaces invoke zero Git scans, cached clean display state cannot remove new untracked files, and real producer notifications wait for outer transaction settlement. - `pnpm -r typecheck` and `pnpm build` passed locally. Map and module-boundary checks passed. - Work-product and sleep-safety suites: 221 tests passed. Reopen and issue-tree control suites: 46 tests passed. Final backoff, workspace, worker, and work-product suites: 87 tests passed. - Final workspace and worker suites: 81 tests passed. Deadline selection performs zero database reads. A failed full page retries its unread terminal workspace before recovery can park. - Full CI coverage passed on head `2c397970f7`: general tests, serialized server suites, runner verification, E2E shards, typecheck, build, and the final verification gate. The duplicate serial local `pnpm test:run` was stopped after full CI coverage passed; it is not reported as a completed local pass. - Greptile: 5/5 with both findings resolved. The security scan also passed. No failing or pending PR checks remain. ## Risks - Display state can be stale for thirty seconds. Destructive guards bypass the display cache. - Undelivered or otherwise ineligible terminal work retries after five minutes instead of each heartbeat tick. - Transaction signals cover the owning `createDb` scope. Direct SQL and independent server processes require startup recovery. - Local cooldown timers are maintenance hints. They do not establish an external wake alarm or whole-instance sleep safety. Existing blockers remain in force; failed bounded recovery keeps the host awake. ## Model Used OpenAI GPT-6, model ID `gpt-6`, through Codex. Reasoning, repository inspection, shell execution, and browser tools assisted the change. The runtime does not expose the 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 (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run 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 any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
82 lines
4.0 KiB
TypeScript
82 lines
4.0 KiB
TypeScript
import { promises as fs } from "node:fs";
|
|
import path from "node:path";
|
|
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
|
|
import { idleWorkSnapshot } from "./task-admission.js";
|
|
|
|
export type IdleLocalWork = "none" | "present" | "unknown";
|
|
const spoolDirectories = new Set<string>();
|
|
const localWorkProbes = new Set<() => IdleLocalWork>();
|
|
export function registerIdleLocalWorkProbe(probe: () => IdleLocalWork): () => void {
|
|
localWorkProbes.add(probe);
|
|
return () => { localWorkProbes.delete(probe); };
|
|
}
|
|
export function registerIdleSpoolDirectory(directory: string): void { spoolDirectories.add(path.resolve(directory)); }
|
|
export const idleAccountingSpoolPath = () => path.join(resolvePaperclipInstanceRoot(), "accounting-receipts");
|
|
export const idleOrphanSpoolPath = () => process.env.SANDBOX_ORPHAN_CLEANUP_SPOOL_DIR ??
|
|
path.join(resolvePaperclipInstanceRoot(), "data", "sandbox-orphan-cleanup");
|
|
export const idleQuotaRecoveryPath = () => path.join(resolvePaperclipInstanceRoot(), "quota-credential-recovery");
|
|
let startupComplete = false;
|
|
let ingressTracked = false;
|
|
let scheduledBackups = false;
|
|
export function markIdleIngressTracked() { ingressTracked = true; }
|
|
export function markIdleStartupComplete(options: { scheduledBackups: boolean }) {
|
|
scheduledBackups = options.scheduledBackups;
|
|
startupComplete = true;
|
|
}
|
|
|
|
/** Any entry, including malformed JSON, a temp file or a failed probe, is
|
|
* evidence to retain. Recovery readers intentionally skip some such files;
|
|
* they cannot certify that stopping the process is safe. */
|
|
export async function inspectIdleSpool(directory: string): Promise<IdleLocalWork> {
|
|
try {
|
|
const info = await fs.lstat(directory);
|
|
if (!info.isDirectory() || info.isSymbolicLink()) return "unknown";
|
|
const dir = await fs.opendir(directory);
|
|
try { return (await dir.read()) === null ? "none" : "present"; }
|
|
finally { await dir.close(); }
|
|
} catch (error) {
|
|
return (error as NodeJS.ErrnoException).code === "ENOENT" ? "none" : "unknown";
|
|
}
|
|
}
|
|
|
|
/** Credential journals are grouped by company. Successful recovery leaves its
|
|
* empty company directory behind; every file, probe or unexpected entry still
|
|
* blocks. Never decrypt journals or follow directory symlinks during a scan. */
|
|
export async function inspectIdleQuotaRecovery(directory: string): Promise<IdleLocalWork> {
|
|
try {
|
|
const info = await fs.lstat(directory);
|
|
if (!info.isDirectory() || info.isSymbolicLink()) return "unknown";
|
|
const dir = await fs.opendir(directory);
|
|
try {
|
|
for (let entry = await dir.read(); entry !== null; entry = await dir.read()) {
|
|
if (!entry.isDirectory() || entry.isSymbolicLink()
|
|
|| !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(entry.name)) return "present";
|
|
const state = await inspectIdleSpool(path.join(directory, entry.name));
|
|
if (state !== "none") return state;
|
|
}
|
|
return "none";
|
|
} finally { await dir.close(); }
|
|
} catch (error) {
|
|
return (error as NodeJS.ErrnoException).code === "ENOENT" ? "none" : "unknown";
|
|
}
|
|
}
|
|
|
|
export async function readIdleLocalWork(options: { backupCheckpoint?: boolean } = {}): Promise<IdleLocalWork> {
|
|
if (!startupComplete || !ingressTracked) return "unknown";
|
|
// This exemption belongs only to a scan which will finish a new backup
|
|
// under the same admission hold before it can return an empty report.
|
|
if ((scheduledBackups && !options.backupCheckpoint) || idleWorkSnapshot().active !== 0) return "present";
|
|
try {
|
|
const local = [...localWorkProbes].map(probe => probe());
|
|
if (local.includes("unknown")) return "unknown";
|
|
if (local.includes("present")) return "present";
|
|
} catch { return "unknown"; }
|
|
const directories = new Set([...spoolDirectories, idleAccountingSpoolPath(), idleOrphanSpoolPath()]);
|
|
const results = await Promise.all([
|
|
...[...directories].map(inspectIdleSpool),
|
|
inspectIdleQuotaRecovery(idleQuotaRecoveryPath()),
|
|
]);
|
|
if (results.includes("unknown")) return "unknown";
|
|
return results.includes("present") ? "present" : "none";
|
|
}
|