Files
PaperClipAI/packages/shared/src/crash-points.ts
T
DottaandClaude Opus 5.5 683fd1e9a5 test(restart-chaos): cells for staging's no-restart failures (re-wake, Nth recovery step, long turn, K15 without volume) (#20893)
## 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>
2026-11-11 12:58:41 -06:00

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;
}