mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 06:10:44 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - A cloud instance keeps run logs and evidence in object storage, so a run touches the store as soon as it starts > - In storage chaos wave `w-20261109T145628-s9902` (ST1: 75–176 s windows where every S3 call returns 503), only 1 of 8 tasks finished. A run starting or resuming during an outage failed within a second as `adapter_failed`, generic recovery burned its attempts seconds apart, the task was escalated to the board, and the user saw the raw S3 error > - This pull request treats a transient storage error as an infrastructure wait: the run is retried on a minute-scale schedule, rapid recovery is suppressed, the board shows it as working, and the run error says storage was unavailable > - The benefit is that a store outage of several minutes delays work instead of failing and stranding it ## Linked Issues or Issue Description Refs #19831. Storage chaos wave `w-20261109T145628-s9902` (master `610b54f551`, `--storage s3`, ST1). **Where the run fails.** At run start or resume, `run-output` `begin` opens the run log: `getRunLogStore().begin` → object run log store → object byte journal `claim` → S3 conditional PUT. The journal retries only 3 times within about 300 ms, so a 503 escapes and fails the run as `adapter_failed`. The async stack ends in the SDK middleware with no app frame. `log_store` stays null on the failed run, which pins the claim. **What followed.** - `failRun` released the issue into generic immediate recovery. Recovery retried 1–2 s apart inside the outage (t01: 14:58:33, :35, :59, 14:59:01), then stopped and opened a board-owned `stranded_assigned_issue` action. These are the open recovery actions on CHA-1, CHA-4, CHA-7 and CHA-8; a later Retry hit the outage again and escalated the same way. - The run error was the raw S3 text ("Injected storage outage"). **Slow store (ST2, `w-20261109T151946-s9903`).** With about 9.7 s of added latency per call, the same claim hit the journal's 15 s per-operation deadline (`AbortSignal.timeout(15_000)`). The run failed after about 36 s with `AbortError: Request aborted` and burned 3 runs the same way; 4 of 8 tasks needed a Retry. t02/t03 (Retry parked with `process_identity_missing`) are a separate native-recovery class and are handled in a follow-up. **Daytona storage chaos ST1 (`w-20261109T161202-s9912`, 1/8):** with S3 returning 503 for 72 to 154 s at a time: - Workspace restore failed at once (`remote_workspace_restore_failed: step=snapshot_download; storage=provider_error`), three times in 40 s, then `setup_failed`. It left an active recovery action behind. - Runs reattached after a restart died within 2 s on the run-log claim, then became reconciliation-required. - Setup failed on other storage reads. - 6 of 7 failures needed a UI Retry. So the rule here is one class rule: a transient store error on a run's critical path waits, it never fails the run. ## What Changed - New `server/src/storage/storage-unavailable.ts`: - `isStorageUnavailableError` reads the recorded storage failure diagnostics first, then the SDK error shape. Unavailable means 429/500/502/503/504, the S3 throttling and timeout names, network codes (`ECONNRESET`, `ECONNREFUSED`, `ETIMEDOUT`, `EPIPE`, `EAI_AGAIN`, `ENOTFOUND`), or an `AbortError` whose cause is a `TimeoutError` (a per-operation deadline). 403, 404 and 412 are not outages, and neither is an abort caused by the run's own Stop. - It defines the `storage_unavailable` code, the `storage_unavailable_wait` retry reason, the retry policy (60 s × 10 attempts, about 10 minutes) and the user-facing message. - `server/src/services/heartbeat/run-completion.ts`: - `failRun` classifies such a failure as `storage_unavailable` with the plain message. - Unless native recovery owns the run, it schedules the storage wait with `scheduleBoundedRetryForRun`, skips the interaction-continuation retry, and releases the issue with `suppressImmediateRecovery`. - `failRunSetup` uses the same policy for this code. - `server/src/services/heartbeat/retries.ts`: `storage_unavailable` joins the transient infrastructure codes. It gets its own retry budget and policy, like `runner_revision_mismatch`. - `server/src/services/task-interruption-presentation.ts`: `storage_unavailable_wait` is a resource wait, so the board shows the task as working with no Retry while it waits. - **One shared outage wait for the run's critical path** (`server/src/storage/storage-unavailable.ts`): - `retryThroughStorageOutage` retries a transient store failure with backoff (2 s doubling to 30 s) until that storage operation's outage has lasted 5 minutes, then gives the error back. The outermost wait of an operation records its first failure, and nested and parallel waits inside it share that start; no other request's success extends it. Shared probes and shared reads run under their own scope, so one run's Stop never cancels work another run waits on. A stop signal ends the wait, and non-outage errors are never retried. - Inside `waitOutStorageOutages`, every replayable S3 request waits instead of failing: get, head, delete, buffered and file puts, and multipart create, file parts and complete. A consumed upload stream is never resent, and requests outside the scope keep failing fast. - The scope covers run execution (`heartbeat.ts` `executeRun`: setup, workspace restore, run-log claim and the run itself), plus `loadWorkspaceSnapshotArchive` and `uploadWorkspaceSnapshot`, which copy-back and recovery workers use too. - The run-log claim retries as a whole, because the journal bounds each operation with a 15 s deadline. - Past the budget, the run takes the bounded `storage_unavailable` wait below. - The classifier follows the error's `cause` chain, so a restore failure wrapping an outage counts. `failRunSetup` checks storage before restore failures, so that case takes the storage wait instead of two quick retries and an escalation (the R6 leak). - `object-byte-journal.ts`: when its deadline expires, the journal rethrows the recorded storage error instead of a bare `TimeoutError`. ## Verification Red/green (each new test fails against master and passes with this PR): - `run-completion.test.ts` "waits a minute and suppresses rapid recovery when a run fails because storage is unavailable (storage chaos ST1)". An S3-shaped 503 records `storage_unavailable` with the plain message (no raw S3 text), schedules `STORAGE_UNAVAILABLE_RETRY`, releases with `suppressImmediateRecovery: true`, and skips the continuation retry. "keeps an ordinary adapter failure on the normal recovery path" guards the default. - `task-interruption-query.test.ts` "does not present an overdue storage_unavailable_wait wait as a failed attempt": working, `canRetry: false`. - `storage-unavailable.test.ts`: which errors count as an outage and which do not, including recorded diagnostics, a deadline-aborted call (ST2), and a Stop-aborted call that must not count. Static checks: - `tsc --noEmit` passes for `server`. Live verification: Phase 3 will rerun ST1 (seed 9902) with the MinIO fault proxy after merge. - `server/src/__tests__/storage-outage-wait.test.ts` runs against a local S3 that answers 503 for 3 s: - Inside the scope, a snapshot `getObject` and a run-log conditional PUT succeed once the store is back. - Outside it, a request fails fast. - A stop ends the wait. - Unit cases cover the budget, non-outage errors, and a `RemoteWorkspaceRestoreError` wrapping an outage. - `run-completion.test.ts`: a setup failure from a restore that wrapped an outage takes the storage wait (red before). - `object-byte-journal.test.ts`: a real `claim` whose head PUT hits its deadline is classified as a storage outage (red before). ## Risks - A storage outage now holds a task for up to about 10 minutes of automatic waits instead of escalating within seconds. A longer outage still ends in the existing board recovery. - A run whose failure is misclassified as storage-unavailable waits instead of using fast recovery. The classifier accepts only server-side or network failures from the storage SDK, never 4xx answers. - No schema change. - A run can now wait until a storage outage has lasted 5 minutes before it fails over to the bounded wait. It shows as working throughout, and Stop ends the wait. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with tool use (shell, file edits). ## 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>
115 lines
5.9 KiB
TypeScript
115 lines
5.9 KiB
TypeScript
import { RUNNER_REVISION_WAIT_RETRY_REASON } from "./runner-revision-mismatch.js";
|
|
import { STORAGE_UNAVAILABLE_WAIT_RETRY_REASON } from "../storage/storage-unavailable.js";
|
|
import { PROVIDER_ATTEMPT_CREDIT_GENERATION_LIMIT } from "./native-runtime/provider-attempt-outcome.js";
|
|
|
|
/**
|
|
* A preparation that a restart interrupted before any provider work started
|
|
* (its certificate records `providerWorkStarted: false`) spent no provider
|
|
* work, so it never charges the failure budget (durable-ownership plan,
|
|
* section F). Its own counter keeps a crash loop bounded, at the same limit
|
|
* as the native controller-generation cap.
|
|
*/
|
|
export const INTERRUPTED_PREPARATION_RETRY_REASON = "interrupted_preparation";
|
|
export const INTERRUPTED_PREPARATION_RETRY_MAX_ATTEMPTS = PROVIDER_ATTEMPT_CREDIT_GENERATION_LIMIT;
|
|
|
|
/** Retry lanes that keep their own counter and never spend failures. */
|
|
const UNCHARGED_RETRY_LANES: ReadonlySet<string> = new Set([
|
|
"workspace_busy", "ai_connection_busy", "ai_connection_pool_wait", RUNNER_REVISION_WAIT_RETRY_REASON,
|
|
INTERRUPTED_PREPARATION_RETRY_REASON, STORAGE_UNAVAILABLE_WAIT_RETRY_REASON,
|
|
]);
|
|
|
|
type RetryRun = {
|
|
scheduledRetryAttempt?: number | null;
|
|
scheduledRetryReason?: string | null;
|
|
contextSnapshot?: Record<string, unknown> | null;
|
|
};
|
|
|
|
/** Server-owned counts for one automatic continuation chain. Repair slots live
|
|
* in legacyDispositionEpisode and are never charged to either counter here. */
|
|
export interface ExecutionRetryAccounting {
|
|
version: 1;
|
|
failureRetries: number;
|
|
maxTurnContinuations: number;
|
|
/**
|
|
* Interrupted-preparation retries in this chain. Saved with the chain, so a
|
|
* wait retry in between never resets it. Omitted while zero.
|
|
*/
|
|
interruptedPreparations?: number;
|
|
/** Storage-outage waits in this chain, kept across intervening resource waits. Omitted while zero. */
|
|
storageWaits?: number;
|
|
}
|
|
|
|
function count(value: unknown): number | null {
|
|
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
}
|
|
|
|
function savedAccounting(run: RetryRun): ExecutionRetryAccounting | null {
|
|
const value = run.contextSnapshot?.executionRetryAccounting;
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
const saved = value as Record<string, unknown>;
|
|
const failureRetries = count(saved.failureRetries);
|
|
const maxTurnContinuations = count(saved.maxTurnContinuations);
|
|
if (saved.version !== 1 || failureRetries === null || maxTurnContinuations === null) return null;
|
|
const interruptedPreparations = count(saved.interruptedPreparations) ?? 0;
|
|
const storageWaits = count(saved.storageWaits) ?? 0;
|
|
return { version: 1, failureRetries, maxTurnContinuations,
|
|
...(interruptedPreparations > 0 ? { interruptedPreparations } : {}),
|
|
...(storageWaits > 0 ? { storageWaits } : {}) };
|
|
}
|
|
|
|
function historicalFailureCount(run: RetryRun): number {
|
|
if (run.scheduledRetryReason === "max_turns_continuation" || run.scheduledRetryReason === "issue_disposition_repair") return 0;
|
|
if (run.scheduledRetryReason === "ai_connection_busy" || run.scheduledRetryReason === "ai_connection_pool_wait") {
|
|
const saved = count(run.contextSnapshot?.failureRetriesBeforeAiConnectionWait);
|
|
if (saved !== null) return saved;
|
|
}
|
|
if (run.scheduledRetryReason === "workspace_busy") {
|
|
const saved = count(run.contextSnapshot?.failureRetriesBeforeWorkspaceWait);
|
|
if (saved !== null) return saved;
|
|
}
|
|
// Historical ambiguous counters remain conservative rather than resetting.
|
|
return count(run.scheduledRetryAttempt) ?? 0;
|
|
}
|
|
|
|
export function executionRetryAccounting(run: RetryRun): ExecutionRetryAccounting {
|
|
const saved = savedAccounting(run);
|
|
const reason = run.scheduledRetryReason ?? "";
|
|
const nonFailureLane = reason === "max_turns_continuation" || reason === "issue_disposition_repair" || UNCHARGED_RETRY_LANES.has(reason);
|
|
const interruptedPreparations = Math.max(saved?.interruptedPreparations ?? 0,
|
|
reason === INTERRUPTED_PREPARATION_RETRY_REASON ? count(run.scheduledRetryAttempt) ?? 0 : 0);
|
|
const storageWaits = Math.max(saved?.storageWaits ?? 0,
|
|
reason === STORAGE_UNAVAILABLE_WAIT_RETRY_REASON ? count(run.scheduledRetryAttempt) ?? 0 : 0);
|
|
return {
|
|
version: 1,
|
|
failureRetries: Math.max(saved?.failureRetries ?? 0, saved && nonFailureLane ? 0 : historicalFailureCount(run)),
|
|
maxTurnContinuations: Math.max(saved?.maxTurnContinuations ?? 0,
|
|
run.scheduledRetryReason === "max_turns_continuation" ? count(run.scheduledRetryAttempt) ?? 0 : 0),
|
|
...(interruptedPreparations > 0 ? { interruptedPreparations } : {}),
|
|
...(storageWaits > 0 ? { storageWaits } : {}),
|
|
};
|
|
}
|
|
|
|
/** Resource waits, repairs and productive continuations do not spend failures. */
|
|
export function executionFailureRetryCount(run: RetryRun): number {
|
|
return executionRetryAccounting(run).failureRetries;
|
|
}
|
|
|
|
export function executionRetryAttemptCount(run: RetryRun, reason: string): number {
|
|
if (reason === INTERRUPTED_PREPARATION_RETRY_REASON) return executionRetryAccounting(run).interruptedPreparations ?? 0;
|
|
if (reason === STORAGE_UNAVAILABLE_WAIT_RETRY_REASON) return executionRetryAccounting(run).storageWaits ?? 0;
|
|
if (UNCHARGED_RETRY_LANES.has(reason)) {
|
|
return run.scheduledRetryReason === reason ? count(run.scheduledRetryAttempt) ?? 0 : 0;
|
|
}
|
|
const accounting = executionRetryAccounting(run);
|
|
return reason === "max_turns_continuation" ? accounting.maxTurnContinuations : accounting.failureRetries;
|
|
}
|
|
|
|
export function accountingForScheduledRetry(run: RetryRun, reason: string, attempt: number): ExecutionRetryAccounting {
|
|
const accounting = executionRetryAccounting(run);
|
|
if (reason === "max_turns_continuation") accounting.maxTurnContinuations = attempt;
|
|
else if (reason === INTERRUPTED_PREPARATION_RETRY_REASON) accounting.interruptedPreparations = attempt;
|
|
else if (reason === STORAGE_UNAVAILABLE_WAIT_RETRY_REASON) accounting.storageWaits = attempt;
|
|
else if (!UNCHARGED_RETRY_LANES.has(reason)) accounting.failureRetries = attempt;
|
|
return accounting;
|
|
}
|