mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 08:53:19 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The restart-chaos harness (`tests/restart-chaos/`) tests that agent work survives control-plane faults > - In one 8-hour staging window, 20 of 25 failed runs happened with no restart at all, so the harness did not see them > - The harness had blind spots: one short task per agent, no wake right after a successful run, no fault in a later recovery step, short turns, and a redeploy that always keeps its instance home > - This pull request adds cells for those shapes, so the harness reproduces these failure classes on master > - The benefit is that fixes for these classes can be proven red and green with the harness, without staging ## Linked Issues or Issue Description **What happened?** On staging, an agent ran 10–13 tasks at once on reused sandboxes. 30–60 s after a long run succeeded, an ordinary wake (a comment or an issue monitor) started the next run of the same task. That run or its recovery failed before any provider work, with errors such as `runner_remote_process_identity_unavailable`, `Controller journal drain timed out` and `journal_tool_input_unanswered`. Other failures happened in a later recovery step (a held agent-directory lock, a missing runtime asset after a deploy without a volume). **Expected behavior** The chaos harness can reproduce each of these shapes on demand, and grades them: no failed attempt, every task `done`, and no task left blocked without a working Retry. **Steps to reproduce** See the commands in the README sections "Re-wake after success", "Fail the Nth recovery step", "Long turn" and "Redeploy without a volume, with overlap". Related: #20844 (session fault SR1). W2C is the configuration change after a successful run; SR1 is the same change while a question is parked. This PR does not add or change `session-faults.ts`. ## What Changed - New workloads W2, W2Q, W2C (`rewake.ts`): phase 1 ends with the task left open; the harness, as the board, wakes the same task the seeded 0–60 s after its run settled. W2 uses a comment or a board-scheduled issue monitor (by nonce). W2Q parks on an unanswered `request_human_input` question and is woken by a comment before the answer. W2C changes the agent's `adapterConfig.idleTimeoutMs` before the wake. Oracle: phase 1 succeeded, the wake was served by a run that succeeded, `no_failed_attempt`, `done`, markers. - `--faults K0`: cells with no fault, for workloads whose shape is the fault. - `--fail-recovery-step <step>[:<nth>]` (`recovery-step-faults.ts`): when the wave's first restart fault starts its replacement, the harness arms one crash point in a new fail mode; the nth hit throws the error of the real step, once. Steps: `runnerd-start` (`spawn paperclip-runnerd EAGAIN`), `teardown` (provider destroy did not finish), `directory-lock` (`Agent directory operation is already active`). Crossed tasks get `recovery_step_failed_once` and `no_failed_attempt`. - Crash points (`packages/shared/src/crash-points.ts`): fail mode in the control file (`<token> <name>:<nth> fail [pid=<n>]`), with an optional call-site error; `pid=` binds the line to one server process. The harness arms the step for the replacement only and disarms it once its receipt lands. New point `agent_directory.operation_lock.before_acquire`. The teardown point moved into its `try`, so fail mode behaves like a failed provider destroy; kill mode is unchanged. All points stay disarmed under `NODE_ENV=production`. The runner package's own crash-point copy is not changed (runner image content ID unchanged). - `--long-turn-minutes <n>` (`long-turn.ts`, 15–60, opt-in): every task's first turn runs rounds of real tool work for n minutes before its workload. Checks `long_turn_rounds` and `long_turn_run_observed`. - K15 `--k15-fresh-home` (Daytona) and `--k15-overlap-seconds <min>-<max>` (`redeploy.ts`, `supervisor.ts`, `edge-proxy.ts`): each deploy generation gets a new, empty instance home and no volume variables; the next generation boots beside the old one behind an edge proxy on the wave's port, takes traffic once ready, and the old one gets SIGTERM after the overlap. - `tests/runner-e2e/source-sdk-public-inputs.json`: refreshed pins for the changed shared crash-point files. - README: one section per new cell, with what it covers. ## Verification - `pnpm exec vitest run --config tests/restart-chaos/vitest.config.ts` (48 files, 581 tests after rebasing on master, includes the new `shape-cells.test.ts`). - `pnpm exec vitest run packages/shared/src/crash-points.test.ts server/src/__tests__/crash-point-call-sites.test.ts` (fail mode, call sites). - `node scripts/typecheck-test-projects.mjs tests/restart-chaos`. - Chaos waves on master `1d67e309e0` server code (Daytona image `ghcr.io/paperclipai/paperclip-daytona-runner@sha256:99fc2671fc320816407f63cffd3acbd27471621febcd26494fe25bb5d7c5dd77`, content ID `1836c515…`), all RED unless noted: | Cell | Wave | Result on master | Staging class | | --- | --- | --- | --- | | W2/W2Q/W2C, 10 tasks on one Codex agent, `--daytona-warm`, K0 | `w-20261111T163206-s5201` | 9/10 fail. 3 tasks (W2 t02, W2C t03, t06): first fault `native_runner_process_exited`, same-run reattach, `native.remote_runner_recovery_failed` with `stage: process_verification`, `reason: process_not_running`, `runnerLifecycle: ready`, `runnerSettled: false`, terminal `runner_remote_process_identity_unavailable`, "Workspace repair required". 3 tasks (t04, t08, t10): `native_restart_recovery_blocked` (`native_retry_source_superseded`) while its successor ran (R4 `concurrent_runs`). 3 W2Q tasks: the answer's continuation was cancelled `issue_not_in_progress` (status `blocked`), and the task needed Retry. 2 W2C tasks then looped: 60+ succeeded runs in 20 minutes on an `in_progress` task. | D′/T terminal step reproduced with no restart, no governed wait and no PRP timeout (same error code and refusal fields). C+ retry outcome (ownership ambiguous) reproduced as `native_restart_recovery_blocked`. Not reproduced: `Controller journal drain timed out`, `did not durably suspend`, `journal_tool_input_unanswered`. | | Same, local `--lifecycle warm` | `w-20261111T160103-s5101` | 4/10 fail. 3/3 W2Q: continuation cancelled `issue_not_in_progress`, Retry needed. 1 W2C: the same `in_progress` run loop (39+ runs). | The remote-runner classes need a remote runner; locally they do not show. | | `--fail-recovery-step runnerd-start:1`, K1 at 10 s, local A1 | `w-20261111T162432-s5303` | Fail. After K1 the first runnerd start of recovery failed (`spawn paperclip-runnerd EAGAIN`); the run ended `native_provider_terminal_failed` ("previous turn is no longer active", "Automatic recovery stopped") and needed Retry. Control, same seed and K1 without the step fault (`w-20261111T171114-s5303`): pass. | A later recovery step that fails once ends the run instead of trying again (the A-chain shape). | | `runnerd-start:2`, local | `w-20261111T170909-s5304` | Step never reached: recovery started the runner once and succeeded. Grade fails `recovery_step_failed_once` (tested nothing). | — | | `teardown:1`, K1, Daytona A1 | `w-20261111T172048-s5312` | Pass. The teardown failed once and the sweep settled it. | Not reproduced. | | `directory-lock:1`, K2, Daytona A1 | `w-20261111T171247-s5311` | Step never reached by A1 after K2. | Not reproduced; A1 recovery does not collect remote agent files. | | K15 `--k15-fresh-home --k15-overlap-seconds 30-150`, `--storage s3`, Codex and OpenCode, T1/C1 | `w-20261111T173618-s5322` | 1/4 fail (OpenCode T1): `native_continuation_requires_reconciliation` with "Agent directory belongs to a different execution environment"; the Retry runs failed `adapter_failed` "Configured instruction entry is missing"; the task rests `blocked` on a board recovery action, and two sandboxes were left (R6). One Codex T1 task finished before its fault (`fault_crossed_tool`). | A-deploy: a controller-local file missing after a deploy without a volume. Here the missing file is the agent's instruction bundle, not a runtime-context asset; whether staging keeps instruction bundles outside the instance home is not verified. | | `--long-turn-minutes 15`, K1 at 600 s, local A1 | `w-20261111T175505-s5401` | Task pass (10/10 rounds, 992 s span, run 1019 s, K1 crossed mid-turn). The wave exited 2 only because I switched branches in the worktree during the wave. | Shape check only. | ## Risks - Product code changes are test instrumentation only: three `crashPoint` call sites take a fail-mode error, and one new call site was added. Disarmed (always in production) each call is one comparison. Moving the teardown point into its `try` does not change kill mode: the process dies at the same instant. - The new cells are opt-in. Default waves plan the same tasks as before for the same seed. - The W2 cells depend on model behaviour (phase 1 leaves the task open). The oracle records the task status at the wake. - `--k15-fresh-home` drops everything under the instance home. Whether staging keeps agent instruction bundles outside the instance home is not verified (see the K15 result). ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, agentic coding with tool use (shell, file edits, test and chaos runs) in Claude Code. ## 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 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
233 lines
11 KiB
TypeScript
233 lines
11 KiB
TypeScript
/**
|
|
* Named crash points (durable ownership plan, "Testing: enumerate crash points").
|
|
*
|
|
* `crashPoint(name)` marks a durable write boundary: the gap between two durable
|
|
* writes, or between an outside effect and the record of its outcome. A restart
|
|
* that lands in that gap leaves an in-progress record owned by a dead process.
|
|
* The crash-point suite (`pnpm crash-points`) kills the control plane at each
|
|
* named gap on purpose instead of hoping random chaos timing finds it.
|
|
*
|
|
* Arming (test mode only):
|
|
*
|
|
* PAPERCLIP_CRASH_POINT=<name>[:<nth>] SIGKILL this process at the nth hit (default 1)
|
|
* PAPERCLIP_CRASH_POINT_RECEIPT=<file> optional; one JSON line is appended before the kill
|
|
* PAPERCLIP_CRASH_POINT_GATE=<file> optional; hits before this file exists are not counted,
|
|
* so setup work (seeding, probes) cannot fire the point
|
|
*
|
|
* Runtime arming (one booted server, many points), instead of PAPERCLIP_CRASH_POINT:
|
|
*
|
|
* PAPERCLIP_CRASH_POINT_CONTROL=<file> each hit reads "<token> <name>[:<nth>] [fail]" from the file;
|
|
* no file or an empty one means disarmed. Hits count per
|
|
* token, so writing a new token re-arms with a fresh count.
|
|
*
|
|
* Fail mode (control file only): a trailing `fail` makes the nth hit throw instead of
|
|
* killing the process, once. An optional `pid=<n>` binds a line to one process, so a
|
|
* server started earlier (still draining) never counts its hits. The call site may name the error its real step throws
|
|
* (`crashPoint(name, { fail: () => conflict("...") })`); otherwise it throws
|
|
* `CrashPointInjectedFailure`. The restart-chaos harness uses it to fail the Nth
|
|
* attempt of a recovery step (`--fail-recovery-step`), so recovery chains are exercised.
|
|
*
|
|
* The variable is read once, when this module is first imported. It is ignored
|
|
* when `NODE_ENV=production`, and an unknown name throws at import so a typo
|
|
* cannot silently arm nothing. Disarmed, `crashPoint` is a single comparison
|
|
* against a module constant (see crash-points.test.ts for the cost check).
|
|
*
|
|
* In-process tests use `armCrashPointForTest`, which throws `CrashPointReached`
|
|
* at the hit instead of killing the test runner. A throw still runs `finally`
|
|
* blocks and rejects the caller's promise, so it only approximates a crash: a
|
|
* test that needs real process death runs the suite instead.
|
|
*
|
|
* This is test instrumentation, not Telemetry or Observability (rules/data-paths.mdc):
|
|
* nothing is recorded or sent anywhere unless a test arms a point.
|
|
*/
|
|
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
|
import { CRASH_POINTS, isCrashPointName, type CrashPointName } from "./crash-point-registry.js";
|
|
|
|
export { CRASH_POINTS, CRASH_POINT_NAME_PATTERN, isCrashPointName } from "./crash-point-registry.js";
|
|
export type { CrashPointDefinition, CrashPointInvariant, CrashPointName } from "./crash-point-registry.js";
|
|
|
|
export const CRASH_POINT_ENV = "PAPERCLIP_CRASH_POINT";
|
|
export const CRASH_POINT_RECEIPT_ENV = "PAPERCLIP_CRASH_POINT_RECEIPT";
|
|
export const CRASH_POINT_GATE_ENV = "PAPERCLIP_CRASH_POINT_GATE";
|
|
export const CRASH_POINT_CONTROL_ENV = "PAPERCLIP_CRASH_POINT_CONTROL";
|
|
/** Printed to stderr just before the kill; the suite greps server logs for it. */
|
|
export const CRASH_POINT_LOG_PREFIX = "[crash-point]";
|
|
|
|
export type CrashPointSpec = { name: CrashPointName; nth: number };
|
|
/** `kill` (default) SIGKILLs the process at the hit; `fail` throws once (control file only). */
|
|
export type CrashPointMode = "kill" | "fail";
|
|
/** What the call site's real step throws when it fails; used in fail mode. */
|
|
export type CrashPointOptions = { fail?: () => Error };
|
|
|
|
export type CrashPointReceipt = {
|
|
point: CrashPointName;
|
|
hit: number;
|
|
pid: number;
|
|
at: string;
|
|
/** Runtime arming only: the control file token the hit counted under. */
|
|
token?: string;
|
|
/** Present for fail mode: the hit threw instead of killing the process. */
|
|
mode?: "fail";
|
|
};
|
|
|
|
/** Thrown at a point armed in fail mode whose call site names no error of its own. */
|
|
export class CrashPointInjectedFailure extends Error {
|
|
constructor(readonly point: CrashPointName, readonly hit: number) {
|
|
super(`injected failure at ${point} (hit ${hit})`);
|
|
this.name = "CrashPointInjectedFailure";
|
|
}
|
|
}
|
|
|
|
/** Thrown by a point armed with `armCrashPointForTest`. */
|
|
export class CrashPointReached extends Error {
|
|
constructor(readonly point: CrashPointName, readonly hit: number) {
|
|
super(`crash point ${point} reached (hit ${hit})`);
|
|
this.name = "CrashPointReached";
|
|
}
|
|
}
|
|
|
|
/** Parses `<name>[:<nth>]`. Returns null for an empty value; throws on a bad one. */
|
|
export function parseCrashPointSpec(raw: string | undefined): CrashPointSpec | null {
|
|
const value = raw?.trim();
|
|
if (!value) return null;
|
|
const separator = value.lastIndexOf(":");
|
|
const name = separator === -1 ? value : value.slice(0, separator);
|
|
const nthText = separator === -1 ? "1" : value.slice(separator + 1);
|
|
if (!/^[1-9]\d{0,5}$/.test(nthText)) {
|
|
throw new Error(`${CRASH_POINT_ENV}=${value}: the hit count after ':' must be a positive integer`);
|
|
}
|
|
if (!isCrashPointName(name)) {
|
|
throw new Error(`${CRASH_POINT_ENV}=${value}: unknown crash point "${name}" (see packages/shared/src/crash-point-registry.ts)`);
|
|
}
|
|
return { name, nth: Number(nthText) };
|
|
}
|
|
|
|
type Armed = {
|
|
spec: CrashPointSpec;
|
|
hits: number;
|
|
mode: "kill" | "throw" | "fail";
|
|
receiptPath: string | null;
|
|
/** Hits count only once this file exists; null when ungated. */
|
|
gatePath: string | null;
|
|
/** Runtime arming: the control file read at each hit; null for a fixed spec. */
|
|
controlPath?: string | null;
|
|
token?: string | null;
|
|
};
|
|
|
|
/** Parses a control file line, "<token> <name>[:<nth>] [fail] [pid=<n>]"; null when empty or malformed. */
|
|
export function parseCrashPointControl(text: string): { token: string; spec: CrashPointSpec; mode: CrashPointMode; pid?: number } | null {
|
|
const [token, raw, ...rest] = text.trim().split(/\s+/);
|
|
if (!token || !raw || rest.length > 2) return null;
|
|
let mode: CrashPointMode = "kill";
|
|
let pid: number | undefined;
|
|
for (const option of rest) {
|
|
const match = /^pid=([1-9][0-9]{0,9})$/.exec(option);
|
|
if (option === "fail" && mode === "kill") mode = "fail";
|
|
else if (match && pid === undefined) pid = Number(match[1]);
|
|
else return null;
|
|
}
|
|
try {
|
|
const spec = parseCrashPointSpec(raw);
|
|
return spec ? { token, spec, mode, ...(pid !== undefined ? { pid } : {}) } : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function armFromEnvironment(): Armed | null {
|
|
const env = typeof process === "undefined" ? undefined : process.env;
|
|
if (!env || env.NODE_ENV === "production") return null;
|
|
const controlPath = env[CRASH_POINT_CONTROL_ENV]?.trim();
|
|
if (controlPath) {
|
|
process.stderr.write(`${CRASH_POINT_LOG_PREFIX} runtime arming from ${controlPath} pid=${process.pid}\n`);
|
|
// The spec is a placeholder; each hit reads the real one from the control file.
|
|
return { spec: { name: "" as CrashPointName, nth: 1 }, hits: 0, mode: "kill",
|
|
receiptPath: env[CRASH_POINT_RECEIPT_ENV]?.trim() || null, gatePath: null, controlPath, token: null };
|
|
}
|
|
const spec = parseCrashPointSpec(env[CRASH_POINT_ENV]);
|
|
if (!spec) return null;
|
|
process.stderr.write(`${CRASH_POINT_LOG_PREFIX} armed ${spec.name}:${spec.nth} pid=${process.pid}\n`);
|
|
return {
|
|
spec, hits: 0, mode: "kill",
|
|
receiptPath: env[CRASH_POINT_RECEIPT_ENV]?.trim() || null,
|
|
gatePath: env[CRASH_POINT_GATE_ENV]?.trim() || null,
|
|
};
|
|
}
|
|
|
|
let armed: Armed | null = armFromEnvironment();
|
|
|
|
/**
|
|
* Marks a durable write boundary. Disarmed (always in production) it returns
|
|
* immediately. Armed for this name, the nth hit kills the process with SIGKILL
|
|
* (or throws `CrashPointReached` under `armCrashPointForTest`).
|
|
*/
|
|
export function crashPoint(name: CrashPointName, options?: CrashPointOptions): void {
|
|
if (armed === null) return;
|
|
if (armed.controlPath) {
|
|
if (!readControl(armed)) return;
|
|
}
|
|
if (armed.spec.name !== name) return;
|
|
reach(armed, options);
|
|
}
|
|
|
|
/** Runtime arming: loads the control file's spec; a new token resets the count. */
|
|
function readControl(current: Armed): boolean {
|
|
let text: string;
|
|
try { text = readFileSync(current.controlPath!, "utf8"); } catch { return false; }
|
|
const control = parseCrashPointControl(text);
|
|
if (!control) return false;
|
|
// A line bound to another process is disarmed here.
|
|
if (control.pid !== undefined && control.pid !== process.pid) return false;
|
|
if (control.token !== current.token) {
|
|
current.token = control.token;
|
|
current.spec = control.spec;
|
|
current.mode = control.mode;
|
|
current.hits = 0;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function reach(current: Armed, options?: CrashPointOptions): void {
|
|
if (current.gatePath !== null) {
|
|
if (!existsSync(current.gatePath)) return;
|
|
current.gatePath = null;
|
|
}
|
|
current.hits += 1;
|
|
if (current.hits !== current.spec.nth) return;
|
|
if (current.mode === "throw") throw new CrashPointReached(current.spec.name, current.hits);
|
|
const receipt: CrashPointReceipt = {
|
|
point: current.spec.name, hit: current.hits, pid: process.pid, at: new Date().toISOString(),
|
|
...(current.token ? { token: current.token } : {}),
|
|
...(current.mode === "fail" ? { mode: "fail" as const } : {}),
|
|
};
|
|
if (current.receiptPath) {
|
|
try { appendFileSync(current.receiptPath, `${JSON.stringify(receipt)}\n`); } catch { /* the log line below still records it */ }
|
|
}
|
|
if (current.mode === "fail") {
|
|
try { process.stderr.write(`${CRASH_POINT_LOG_PREFIX} FAIL ${JSON.stringify(receipt)}\n`); } catch { /* ignore */ }
|
|
throw options?.fail?.() ?? new CrashPointInjectedFailure(current.spec.name, current.hits);
|
|
}
|
|
try { process.stderr.write(`${CRASH_POINT_LOG_PREFIX} SIGKILL ${JSON.stringify(receipt)}\n`); } catch { /* ignore */ }
|
|
process.kill(process.pid, "SIGKILL");
|
|
}
|
|
|
|
/**
|
|
* Arms one point inside a test process: the nth hit throws `CrashPointReached`.
|
|
* Returns a disarm function; call it in `afterEach`. Replaces any armed point.
|
|
*/
|
|
export function armCrashPointForTest(spec: string | CrashPointSpec): () => void {
|
|
// One parser for both forms, so an object with a bad name or count is refused
|
|
// instead of arming a point that can never fire.
|
|
const parsed = parseCrashPointSpec(typeof spec === "string" ? spec : `${spec.name}:${spec.nth}`);
|
|
if (!parsed) throw new Error("armCrashPointForTest needs a crash point name");
|
|
const previous = armed;
|
|
const current: Armed = { spec: parsed, hits: 0, mode: "throw", receiptPath: null, gatePath: null };
|
|
armed = current;
|
|
return () => { if (armed === current) armed = previous; };
|
|
}
|
|
|
|
/** The armed point and its hit count so far, or null. For tests and diagnostics. */
|
|
export function armedCrashPoint(): { name: CrashPointName; nth: number; hits: number } | null {
|
|
return armed && armed.spec.name ? { name: armed.spec.name, nth: armed.spec.nth, hits: armed.hits } : null;
|
|
}
|