mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 22:56:58 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - When an agent run ends, Paperclip saves the files that the run changed in its remote workspace. Until that save settles, the run holds the workspace, so no other task can write it > - When the save fails, a bounded system recovery proves that the agent stopped (a provider stop receipt) and then saves the files again > - If the stop cannot be proven before the 30-minute recovery window ends, the recovery is exhausted with `agent_stop_unconfirmed`. Exhaustion was terminal: Paperclip never proved the stop again, even after the run ended and its sandbox had a stop receipt > - The hold then stayed forever. On staging, a child task's save was exhausted this way on Nov 5. Its parent and its two sibling tasks share that workspace, and all of them stayed blocked for four days > - This pull request re-arms such an exhausted save automatically, on a bounded backoff, once the run is provably over. After the bounded re-arms, the hold continues from the last save with the unsaved files attached > - The benefit is that a dirty workspace hold always settles without a person, and no unsaved work is discarded silently ## Linked Issues or Issue Description Refs #20273 (the stall sweep stops failing wakes against this kind of hold) Refs #20278 (a settled restore hold returns its warm sandbox) Refs #19860 (subtasks get their own workspace) **What happened?** A child task's run wrote its remote workspace and failed to save it (`native_workspace_sync_out_retry_exhausted`). The save recovery made one attempt. That attempt failed with `agent_stop_unconfirmed`, and the recovery window ended before a second attempt. The recovery became `exhausted`. Ten minutes after the first attempt, the sandbox lease got a provider stop receipt, so the agent was provably gone. But nothing looked at the exhausted recovery again. The run kept the workspace's writer hold. The parent task and every other task on that workspace could not start ("The workspace still has an unfinished writer or save operation."). The blocker told a person to press Retry. **Expected behavior** An unproven stop is a wait, not a refusal of the saved files. When the run is over, recovery must prove the stop again and finish the save, so the hold settles automatically. If the stop still cannot be proven, Paperclip must keep the unsaved files and then release the hold. A person must not need to act. **Steps to reproduce** 1. Run a task in a remote (sandbox) workspace. Make its final save fail. 2. Keep the workspace recovery from proving the agent stop until its 30-minute window ends (for example, keep the finalization ownership busy). 3. See that the recovery action is `exhausted` with `agent_stop_unconfirmed`, and that it stays so after the lease gets a stop receipt. 4. Wake another task bound to the same workspace. See that its setup fails because the workspace is held. **Root cause** - `server/src/services/workspace-recovery.ts` (`recoverRetainedWorkspace`): an `exhausted` state only goes to the sibling settlement and to the retained-source fallback. The fallback accepts only permanent source refusals (`isUnverifiableSourceFailure`). Every other exhausted state is handed to the board and never attempted again. - `recheckStoppedWorkspaceRecovery` skips `exhausted` states, and it only accepts acknowledged cancellations. A failed run whose lease got a stop receipt later is never re-examined. - `retainedWorkspaceCandidates(…, obligationsOnly)` and `nextWorkspaceRecoveryAt` do not select exhausted `agent_stop_unconfirmed` holds, so the recovery worker never wakes for them. ## What Changed - New `server/src/services/workspace-recovery-stop-proof.ts` (`rearmExhaustedStopProof`). For an exhausted save whose last failure is `agent_stop_unconfirmed` (`recheck_evidence`), it starts a new bounded system recovery generation. It does this only when the source run is provably over: the run is terminal, no local execution remains, and no controller lease or finalizer lease is live. The new generation repeats the strict stop proof (a provider stop receipt) and the save. It records an `issue.workspace_recovery_rearmed` system activity. - Re-arms are bounded: at most 4. The first is due at once. The next ones are due 30 minutes, 2 hours and 8 hours after the previous re-arm. The `stopProofRecovery` marker in the recovery action evidence records the count and the next due time. - `workspace-recovery-fallback-discovery.ts`: new discovery conditions (`stopProofRearmPendingCondition`, `stopProofRearmDueAt`, `stopProofRearmDueCondition`) and `retainedHoldFallbackEligible`. `retainedWorkspaceCandidates` (worker pass) and `nextWorkspaceRecoveryAt` use them, so the recovery worker wakes when a re-arm is due. - `workspace-recovery-fallback.ts`: when every re-arm is spent and the stop is still unproven, the hold takes the existing retained-source fallback. The unsaved files are attached to the task (unapplied, secrets omitted), the unverified epoch is abandoned, and the task continues from the last save. - `system-recovery-state.ts`: the exhausted `agent_stop_unconfirmed` text now says that Paperclip tries again automatically. It no longer asks a person to press Retry. Retry stays available. - Review follow-ups: the re-arm applies to every workspace recovery the worker owns (restore and historical holds too). A due re-arm is scheduled no earlier than the source run's controller or finalizer lease expiry, so the worker is not woken every 15 seconds while a lease is live. A fallback whose bounded checks are spent is reconsidered once when an allocation of the source run changes, and `settleStopOnlyCleanup` wakes the workspace recovery worker when it records a stop receipt. The automatic-retry text is shown only while a re-arm remains. - `doc/execution-semantics.md`: documents the re-arm, its bounds, the fallback, and the upgrade behavior. ### Previous-release story (`rules/upgrade-safe-run-state.mdc`) - **Carried forward, no migration.** A hold exhausted by an older release has no `stopProofRecovery` marker. The recovery worker treats it as due at once and re-arms it on its first pass after deploy. The new regression test saves the exact Nov 5 evidence shape (`attempts: 1`, `retriesUsed: 0`, `state: exhausted`, no marker) and resumes it with the new code. - **Mid-run deploy.** A run that is still running, or whose controller lease or finalizer lease is live, is not re-armed. The test covers a live controller lease. The re-arm uses the same CAS (generation and revision) and the same row locks as the other recovery writers, so an old worker's late write cannot overwrite the new generation. - **No new refusal codes.** No saved run identity changes. The re-arm writes only recovery-action evidence. ## Verification - Red (before the fix), `npx vitest run src/services/workspace-recovery-stop-proof.test.ts`: 3 failed, 1 passed. The staging-shape test failed because the worker did not wake (`nextWorkspaceRecoveryAt` returned `null`) and the hold stayed. The backoff and fallback tests failed for the same discovery gap. - Green (after the fix): 4 of 4 pass. - Staging shape: a parent and 3 children that share one remote workspace. One child's save is exhausted with `agent_stop_unconfirmed`, and its run ended days ago with a stop receipt. Before recovery, the hold refuses the parent (`remoteWorkspaceHoldRefusesStart` is `true`). The recovery worker discovers the hold, re-arms it, proves the stop, saves the files and completes. The workspace is not dirty, it has a new snapshot, and the hold is `free` for the parent. The action stays system-owned and its text asks no person to act. - A live controller lease keeps the hold. No re-arm happens and no lease is resumed. - A re-arm that is not yet due is not discovered. The worker's next wake is in the future, and the text says that Paperclip tries again automatically. - After 4 re-arms, the hold continues from the last save. The unsaved files are captured as a task attachment, and the hold is `free`. - A live controller lease defers the worker's next wake to the lease expiry, and the hold is not selected before it. - A spent fallback is not selected, and a later lease change (a late stop receipt) makes it selected again; the worker then settles it. - `system-recovery-state.test.ts`: the automatic-retry text appears only while a re-arm remains. - Related suites, all pass: `src/services/workspace-recovery*`, `src/services/remote-workspace*`, `src/services/system-recovery-state.test.ts`, `src/services/task-retry.test.ts`, `src/__tests__/heartbeat-observed-workspace-preservation.test.ts` (34 files, 1510 tests), plus the stop-cleanup suites (`environment-retained-orphan-sandbox`, `live-recovery-work`, `retained-native-session-cleanup-work`, `legacy-workspace-restore-recovery`, native workspace warm-reuse, cancelled preservation, reconstruction and failed recovery; 11 files, 622 tests). - `pnpm verify`: exit 2 ("broader verification required", because `doc/execution-semantics.md` is a global CI input). The focused suites above cover the change, and required CI supplies the broad coverage. - `npx tsc --noEmit -p server/tsconfig.json`: passes. ## Risks - A re-armed generation resumes the stopped sandbox to save its files. This is the same physical path as a manual Retry. It runs only when the source run is provably over. - Re-arms are bounded (4) and spaced, so a stop that can never be proven does not loop. After that, the existing retained-source fallback settles the hold. If the retained files cannot be read, the fallback records why. This is the same behavior as for a refused source. - The parent's workspace receives the child's saved files, because the child (bound by an older release) wrote that workspace. This is the existing #19860 behavior: the files that a legacy child left in the shared workspace stay there. #20314 moves the stranded legacy children to their own workspace. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude Code with tool use (shell, file edits, browser read-only API checks) and extended reasoning. ## 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 - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
194 lines
11 KiB
TypeScript
194 lines
11 KiB
TypeScript
import { createHash, randomUUID } from "node:crypto";
|
|
import { z } from "zod";
|
|
import { ENVIRONMENT_LEASE_POLICIES, WORKSPACE_SOURCE_VERIFICATION_REASONS,
|
|
type WorkspaceSourceVerificationFailure } from "@paperclipai/shared";
|
|
|
|
export const SYSTEM_RECOVERY_POLICY_VERSION = 1;
|
|
export const SYSTEM_RECOVERY_MAX_RETRIES = 5;
|
|
export const SYSTEM_RECOVERY_WINDOW_MS = 30 * 60_000;
|
|
export function infrastructureRetryDelayMs(retryNumber: number, sample: number) {
|
|
return Math.round(30_000 * 2 ** (retryNumber - 1) * (0.8 + Math.min(1, Math.max(0, sample)) * 0.4));
|
|
}
|
|
/** Assumed provider limit cooldown when the provider reports no reset time. */
|
|
export const PROVIDER_LIMIT_DEFAULT_COOLDOWN_MS = 60 * 60_000;
|
|
|
|
export const recoveryFailureCodes = [
|
|
"historical_source_missing", "source_binding_invalid", "allocation_ownership_ambiguous",
|
|
"agent_stop_unconfirmed", "copyback_stop_unconfirmed", "workspace_writer_active",
|
|
"provider_unavailable", "export_interrupted", "archive_unsafe", "snapshot_verification_failed",
|
|
"source_unavailable", "effect_unresolved",
|
|
] as const;
|
|
export type RecoveryFailureCode = typeof recoveryFailureCodes[number];
|
|
export const recoveryRetryClasses = [
|
|
"retry_transient", "recheck_evidence", "use_alternative_strategy", "service_required", "user_action_required",
|
|
] as const;
|
|
export type RecoveryRetryClass = typeof recoveryRetryClasses[number];
|
|
|
|
export class SystemRecoveryFailure extends Error {
|
|
constructor(readonly code: RecoveryFailureCode, readonly retryClass: RecoveryRetryClass, message = code,
|
|
readonly sourceVerification?: WorkspaceSourceVerificationFailure) {
|
|
super(message);
|
|
}
|
|
}
|
|
|
|
export const workspaceSourceVerificationFailureSchema = z.object({
|
|
reason: z.enum(WORKSPACE_SOURCE_VERIFICATION_REASONS),
|
|
observedLeasePolicy: z.enum([...ENVIRONMENT_LEASE_POLICIES, "unrecognized"]).optional(),
|
|
});
|
|
|
|
/** Why an attempt failed, in closed labels only. Provider text never appears. */
|
|
export const recoveryFailureCauseSchema = z.object({
|
|
label: z.string().regex(/^[a-z][a-z0-9_]{0,47}$/),
|
|
code: z.string().regex(/^[A-Z][A-Z0-9_]{1,31}$/).nullable(),
|
|
status: z.number().int().min(100).max(599).nullable(),
|
|
});
|
|
export type RecoveryFailureCause = z.infer<typeof recoveryFailureCauseSchema>;
|
|
|
|
export const systemRecoverySchema = z.object({
|
|
schema: z.literal("paperclip.system-recovery.v1"),
|
|
policyVersion: z.literal(SYSTEM_RECOVERY_POLICY_VERSION),
|
|
sourceRunId: z.string().uuid(),
|
|
revision: z.number().int().nonnegative(),
|
|
generationId: z.string().uuid(),
|
|
intentId: z.string().uuid().nullable(),
|
|
phase: z.enum(["verify_execution", "preserve_files", "reconcile_effects", "settle_result_or_dispatch"]),
|
|
state: z.enum(["scheduled", "running", "awaiting_evidence", "exhausted", "complete"]),
|
|
attemptId: z.string().uuid().nullable(),
|
|
attempts: z.number().int().nonnegative(),
|
|
retriesUsed: z.number().int().min(0).max(SYSTEM_RECOVERY_MAX_RETRIES),
|
|
firstFailureAt: z.number().nullable(),
|
|
nextAttemptAt: z.number().nullable(),
|
|
deadlineAt: z.number().nullable(),
|
|
providerCooldownAt: z.number().nullable().default(null),
|
|
failure: z.object({
|
|
code: z.enum(recoveryFailureCodes),
|
|
retryClass: z.enum(recoveryRetryClasses),
|
|
evidenceFingerprint: z.string(),
|
|
sourceVerification: workspaceSourceVerificationFailureSchema.optional(),
|
|
cause: recoveryFailureCauseSchema.optional(),
|
|
}).nullable(),
|
|
preservationOperationIds: z.array(z.string().uuid()),
|
|
freshAttemptRunId: z.string().uuid().nullable(),
|
|
});
|
|
export type SystemRecoveryState = z.infer<typeof systemRecoverySchema>;
|
|
|
|
export function readSystemRecovery(evidence: Record<string, unknown> | null | undefined): SystemRecoveryState | null {
|
|
const parsed = systemRecoverySchema.safeParse(evidence?.systemRecovery);
|
|
return parsed.success ? parsed.data : null;
|
|
}
|
|
|
|
/** New generations require new intent or one-time policy adoption, never a sweep. */
|
|
export function createSystemRecovery(sourceRunId: string, intentId: string | null = null): SystemRecoveryState {
|
|
return {
|
|
schema: "paperclip.system-recovery.v1", policyVersion: SYSTEM_RECOVERY_POLICY_VERSION,
|
|
sourceRunId, revision: 0, generationId: intentId ?? randomUUID(), intentId,
|
|
phase: "verify_execution", state: "scheduled", attemptId: null, attempts: 0, retriesUsed: 0,
|
|
firstFailureAt: null, nextAttemptAt: null, deadlineAt: null, providerCooldownAt: null, failure: null,
|
|
preservationOperationIds: [], freshAttemptRunId: null,
|
|
};
|
|
}
|
|
|
|
export function reserveSystemRecoveryAttempt(state: SystemRecoveryState, now = Date.now()): SystemRecoveryState | null {
|
|
if (state.state === "complete" || state.state === "exhausted" ||
|
|
(state.nextAttemptAt !== null && state.nextAttemptAt > now)) return null;
|
|
// A reclaimed physical owner first reconciles the same interrupted attempt.
|
|
// Starting another call is not evidence that the prior call failed.
|
|
if (state.state === "running" && state.attemptId) return { ...state, revision: state.revision + 1 };
|
|
if ((state.deadlineAt !== null && now >= state.deadlineAt) ||
|
|
(state.attempts > 0 && state.retriesUsed >= SYSTEM_RECOVERY_MAX_RETRIES)) return null;
|
|
return { ...state, revision: state.revision + 1, state: "running", attemptId: randomUUID(),
|
|
attempts: state.attempts + 1, retriesUsed: state.retriesUsed + (state.attempts > 0 ? 1 : 0), nextAttemptAt: null };
|
|
}
|
|
|
|
/** The caller persists this timestamp. A restart must not recalculate jitter. */
|
|
export function failSystemRecovery(
|
|
state: SystemRecoveryState, failure: SystemRecoveryFailure,
|
|
options: { now?: number; retryAfterAt?: number; evidenceFingerprint?: string; cause?: RecoveryFailureCause } = {},
|
|
): SystemRecoveryState {
|
|
const now = options.now ?? Date.now();
|
|
const firstFailureAt = state.firstFailureAt ?? now;
|
|
const deadlineAt = state.deadlineAt ?? firstFailureAt + SYSTEM_RECOVERY_WINDOW_MS;
|
|
const retryNumber = state.retriesUsed + 1;
|
|
const entropy = createHash("sha256").update(`${state.generationId}:${retryNumber}`).digest().readUInt32BE(0) / 0xffffffff;
|
|
const delay = infrastructureRetryDelayMs(retryNumber, entropy);
|
|
const nextAttemptAt = Math.max(now + delay, options.retryAfterAt ?? 0);
|
|
const retryable = ["retry_transient", "recheck_evidence"].includes(failure.retryClass);
|
|
const retry = retryable && retryNumber <= SYSTEM_RECOVERY_MAX_RETRIES && nextAttemptAt < deadlineAt;
|
|
const sourceVerification = workspaceSourceVerificationFailureSchema.safeParse(failure.sourceVerification);
|
|
const cause = recoveryFailureCauseSchema.safeParse(options.cause);
|
|
return { ...state, revision: state.revision + 1, firstFailureAt, deadlineAt,
|
|
providerCooldownAt: options.retryAfterAt ?? null,
|
|
state: retry ? failure.retryClass === "recheck_evidence" ? "awaiting_evidence" : "scheduled" : "exhausted",
|
|
nextAttemptAt: retry ? nextAttemptAt : null,
|
|
failure: { code: failure.code, retryClass: failure.retryClass,
|
|
...(sourceVerification.success ? { sourceVerification: sourceVerification.data } : {}),
|
|
...(cause.success ? { cause: cause.data } : {}),
|
|
evidenceFingerprint: options.evidenceFingerprint ?? createHash("sha256").update(`${state.sourceRunId}:${failure.code}`).digest("hex") },
|
|
};
|
|
}
|
|
|
|
/**
|
|
* An exhausted save whose last failure was an unproven agent stop is not a
|
|
* permanent refusal: once the source run has ended, a later attempt can prove
|
|
* the stop. Recovery re-arms it a bounded number of times on a backoff
|
|
* (workspace-recovery-stop-proof), then an export hold continues from the last
|
|
* save with the unsaved files attached, like a refused source.
|
|
*/
|
|
export const STOP_PROOF_RECOVERY_KEY = "stopProofRecovery";
|
|
export const STOP_PROOF_RECOVERY_SCHEMA = "paperclip.stop-proof-recovery.v1";
|
|
export const STOP_PROOF_MAX_REARMS = 4;
|
|
export const STOP_PROOF_WAIT_CODE = "agent_stop_unconfirmed";
|
|
|
|
export function readStopProofRecovery(evidence: Record<string, unknown> | null | undefined): { rearms: number; nextRearmAt: number | null } | null {
|
|
const marker = evidence?.[STOP_PROOF_RECOVERY_KEY] as Record<string, unknown> | undefined;
|
|
if (!marker || marker.schema !== STOP_PROOF_RECOVERY_SCHEMA || typeof marker.rearms !== "number") return null;
|
|
return { rearms: marker.rearms, nextRearmAt: typeof marker.nextRearmAt === "number" ? marker.nextRearmAt : null };
|
|
}
|
|
|
|
export function isExhaustedStopProofWait(state: Pick<SystemRecoveryState, "state" | "failure"> | null | undefined) {
|
|
return state?.state === "exhausted" && state.failure?.code === STOP_PROOF_WAIT_CODE
|
|
&& state.failure.retryClass === "recheck_evidence";
|
|
}
|
|
|
|
/** Every bounded re-arm of an unproven stop is spent. */
|
|
export function stopProofRearmsSpent(evidence: Record<string, unknown> | null | undefined) {
|
|
return (readStopProofRecovery(evidence)?.rearms ?? 0) >= STOP_PROOF_MAX_REARMS;
|
|
}
|
|
|
|
/** User-facing text for a spent recovery budget. It names what happens next and
|
|
* never asks a person to inspect a sandbox or environment. The code stays for support. */
|
|
export function exhaustedSystemRecoveryAction(state: SystemRecoveryState, evidence?: Record<string, unknown> | null): string {
|
|
// Another Retry cannot release a copyback owner that stopped without reporting.
|
|
if (state.failure?.code === "copyback_stop_unconfirmed")
|
|
return "Recovery of saved work is waiting for the previous server's file copy to stop. "
|
|
+ "Recovery resumes once Paperclip can confirm that it stopped. No provider turn is submitted.";
|
|
// An unproven agent stop is re-proven automatically once the run is over
|
|
// (workspace-recovery-stop-proof), while a bounded re-arm remains.
|
|
if (evidence !== undefined && isExhaustedStopProofWait(state) && !stopProofRearmsSpent(evidence))
|
|
return "Recovery of saved work is waiting to confirm that the previous run stopped. "
|
|
+ "Paperclip tries again automatically, and the agent will not repeat its work.";
|
|
const cause = state.failure?.cause ? `, cause: ${state.failure.cause.label}` : "";
|
|
if (isDeterministicRecoveryRefusal(state)) {
|
|
const code = state.failure!.code;
|
|
return `Paperclip refused to recover the saved files: ${recoveryRefusalReasons[code] ?? "a safety check on the saved files did not pass"} `
|
|
+ `(${code}${cause}). Automatic recovery does not repeat this check, because the same saved state gives the same result. `
|
|
+ "Retry runs the check again and succeeds only after Paperclip is updated. The agent's result is saved, and the agent will not repeat its work.";
|
|
}
|
|
const reason = state.failure?.code ? ` (last failure: ${state.failure.code}${cause})` : "";
|
|
const outcome = state.attempts > 1 ? `stopped after ${state.attempts} attempts` : "failed";
|
|
return `Automatic recovery of saved work ${outcome}${reason}. `
|
|
+ "Retry to try again; the agent will not repeat its work.";
|
|
}
|
|
|
|
export function isDeterministicRecoveryRefusal(state: Pick<SystemRecoveryState, "state" | "failure"> | null | undefined) {
|
|
return state?.state === "exhausted" && state.failure?.retryClass === "service_required";
|
|
}
|
|
|
|
const recoveryRefusalReasons: Partial<Record<RecoveryFailureCode, string>> = {
|
|
allocation_ownership_ambiguous: "Paperclip cannot prove which runs used the sandbox that holds the saved files",
|
|
source_binding_invalid: "the recorded source of the saved files does not match the run that saved them",
|
|
snapshot_verification_failed: "the saved snapshot of the files did not pass verification",
|
|
archive_unsafe: "the saved file archive contains entries that are not safe to apply",
|
|
source_unavailable: "the saved files are no longer available where the run recorded them",
|
|
};
|