## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The control plane owns task execution, recovery and Retry for every agent run > - Server restarts were the largest remaining source of stalled tasks in the staging soak campaign > - The soak could only observe restarts that staging happened to perform, so a failure could not be repeated > - This pull request adds a script harness that causes the restarts on purpose and grades each task on fixed rules > - The benefit is a repeatable restart failure with a replay command and an evidence bundle for each fixer ## Linked Issues or Issue Description **What existing behavior does this improve?** The restart test coverage. Today the runner-e2e restart cells test one fixed restart point per cell. They cannot run many tasks at once, cannot choose a random fault time, and cannot replay a failure. **Subsystem affected** Test tooling (`tests/restart-chaos/`). It reuses the runner-e2e process code in `tests/runner-e2e/`. Product code does not change. **Current behavior** Restart robustness is checked through a small set of fixed cells. A random restart during a real task has no defined pass rule. **Proposed behavior** A harness starts an isolated control plane and seeds a "Chaos Lab" company. It launches seeded workloads, injects K1, K2 or K4 faults at seeded times, and grades each task on rules R1 to R6 plus a deterministic task oracle. `--replay <wave>/<task>` repeats one task. **Reason and benefit** Fixers get a replay command and an evidence bundle for each failure signature. The first smoke wave already found three product signatures (dead Retry after a restart, a false over-budget stop after restarts, and an OpenCode turn killed mid-turn that ends as a provider failure). **Breaking changes** None. The change adds new test files, one entry in the test-project typecheck list and a gitignore entry. ## What Changed - Add `tests/restart-chaos/`: - `supervisor.ts` owns one isolated server. It reuses `replaceController`, the process-tree stopper, the environment isolation helpers, the config seed, the port reservation, the wrapper-owned Postgres and `server-entry.ts` from `tests/runner-e2e/`. It does not copy them. Faults: K1 (SIGKILL, immediate restart), K2 (SIGTERM, restart) and K4 (SIGKILL, 2 to 5 minute hold). Postgres and the controller runtime cache stay across a fault. - `seed.ts` creates the company, encrypted provider secrets, native Codex, ACPX Claude and OpenCode agents from the runner-e2e catalog, and the `chaos-sandbox` project. The project is a git repository with a Python CLI, one failing test and a four-minute `bench.sh`. - `scheduler.ts` makes a seeded plan of workload, adapter, fault, fire time, hold and nonce for each task. It saves the plan so that `--replay` can run one task again. - `observer.ts` polls each issue and its runs. When a task stops and offers Retry, it sends `POST /api/issues/:id/retry` one time, with the same body as `useTaskRetry.ts`. - `grader.ts` grades R1 to R6 as pure functions. `workloads.ts` defines workloads A1, B1, B5 and C1 and their oracles. - `evidence.ts` and `ledger.ts` write an evidence bundle for each failure, a JSONL ledger and `SUMMARY.md`. All results go to the gitignored `tests/restart-chaos/results/`. Known provider key values are redacted. - Unit tests: `scheduler.test.ts` (determinism, replay and CLI parsing) and `grader.test.ts` (R1 to R6 and the oracles on fixture timelines). These tests need no credentials. - Add `tests/restart-chaos` to the test-project typecheck list (`scripts/typecheck-test-projects.mjs`), with a `tsconfig.layers.json` like `tests/runner-e2e`. The harness runs through direct script invocations, not `package.json` scripts. - After the first Greptile review: - A failed fault or an unexpected server exit now gets one bounded recovery. If the recovery fails, the wave fails and does not hang. - Incomplete cleanup is now a harness error, and the harness keeps the instance. - After the C1 follow-up comment, the observer needs a fresh rest window before it grades the task. - `--replay` now fires the recorded faults that crossed the task, at the same offsets, including the faults that other tasks requested. - A failed workspace-operation read is now a sweep error. It cannot give a clean rest. - R2 now checks that workspace files from before the first fault still exist at rest. - B5 now needs the completion record that `bench.sh` writes for each task. - After the second round of reviews: - An idle `todo` or `in_progress` task with no live run and no named wait is no longer treated as resting. The observer keeps watching it until the task resumes or passes the R5 stall limit. - A failed injected fault now fails the wave with exit 2. - T-oracle commands run agent-editable code. They now run under a macOS Seatbelt sandbox with a scratch `HOME` and no network, and they cannot read the operator's home directory. On other platforms these commands are refused unless the operator explicitly allows them. - R4 now counts every active run. A wake comment may be served again only after a run that did not succeed. - Evidence redaction now covers every environment variable name that ends in `KEY`, `TOKEN`, `SECRET` or `PASSWORD`. - If the pre-fault snapshot fails, only R2 for that task is left unchecked. The wave continues. - B5 also needs bench progress that the harness observed while it polled. - Add `doc/plans/2026-11-06-control-plane-restart-chaos.md` and its campaign ledger. ## Verification - `pnpm exec vitest run --config tests/restart-chaos/vitest.config.ts`: 38 tests pass. The tests include observer tests with a fake API, a real `bench.sh` run, and a sandbox test that cannot read a canary secret. - `node scripts/typecheck-test-projects.mjs tests/restart-chaos`: passes. - `server/scripts/typecheck-layers.test.mjs`: 45 tests pass, including "covers every project under tests/". - A supervisor check with no credentials passed. K1 was ready again in about 6 seconds. K2 with a 3-second hold was ready again in about 9 seconds. Company data stayed readable. - One paid smoke wave ran: `--env local --adapters codex,claude,opencode --workloads A1,B1,C1 --faults K1 --tasks 3 --concurrency 2 --seed 7`. The harness worked from start to end, and all three tasks crossed a restart. The 0/3 result comes from product failures, which the campaign ledger records. A scan found no provider key in the results. - To run a wave: ```sh pnpm --filter @paperclipai/paperclip-runner build:runner-binaries set -a; source ~/.secrets; set +a node cli/node_modules/tsx/dist/cli.mjs tests/restart-chaos/main.ts --env local --adapters codex,claude,opencode --concurrency 4 --tasks 8 --faults K1,K2,K4 --seed 42 node cli/node_modules/tsx/dist/cli.mjs tests/restart-chaos/main.ts --replay <wave>/<task> ``` ## Risks - Low product risk. The change adds test tooling only and does not change product code. - A wave spends provider money. The company budget hard cap (`--budget-cents`, default 3000) is the only spend limit. The plan's per-wave and per-night refusal is not implemented yet. - Known gaps: - `--env daytona` is reserved. It fails with a TODO in `seed.ts`. The Daytona sandbox sweep for R6 is not implemented yet. - The project requests isolated git-worktree workspaces. A B1 task still ran in the shared fixture repository. Concurrent B1 and B5 tasks can share files. The oracles use marker files for each task. - Only K1, K2 and K4 at seeded random times are available. Boundary triggers (B1 to B16), other faults and the UI probe come in Phase 2. - The paid smoke did not exercise B5 or K4. - On macOS, B1 oracle commands need `python3` outside the operator's home directory, for example `/usr/bin` or Homebrew. On Linux they are refused until a sandbox is added. - The B5 proof is best-effort against a deliberately adversarial agent, because that agent can write to the workspace. - Startup phase timings come from polling `/api/ready`. The server records its own timings only as OpenTelemetry spans. - `pnpm verify` and the CI Vitest jobs do not select these unit tests. The root Vitest projects cover workspace packages only. CI typechecks the files, but the tests run only through the vitest command above. - After the smoke, rest detection and the review fixes changed. A recovery action that the board owns now counts as a rest, and R6 reports it. A paid wave did not run again after these changes. > 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 (Anthropic), model ID `claude-opus-5-5`, used in Claude Code as an agent with tool use (shell, file edits) and extended thinking. ## 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 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
18 KiB
Control-plane restart chaos loop: make tasks survive server restarts
Date: 2026-11-06
Target: a local, isolated Paperclip control plane with agent runs in Daytona sandboxes
Fix workflow: fix-failed-paperclip-run (one session per failure signature)
Predecessor: 2026-11-04-staging-soak-loop.md (staging soak campaign, PR worktree paperclip-staging-test-plan-24ca37)
1. Why this plan exists
The staging soak campaign (2026-11-04 to 2026-11-06) ran about 430 graded tasks
in waves of about 10. It used a fixed task library (sections A to H), mechanical
oracles (U1 to U9), and a dedupe-by-signature ledger. Each new failure signature
went to its own fix-failed-paperclip-run session. The campaign merged 42 fixes,
and the hard-failure rate on test tasks fell from 43% to about 0%.
The largest remaining source of failures was restarts. Staging restarted up to about five times an hour. The campaign summary says "frequent restarts stall in-flight work". Three fixes in this area merged late in the campaign:
- #19399: lost runner after a restart continues automatically, and Retry is offered.
- #19426: a declined Retry no longer swallows later clicks (the "dead Retry" bug).
- #19431: queued runs start promptly after a restart, and the wait shows a reason.
The campaign could only observe restarts that staging happened to perform. We could not choose when the restart came, what kind it was, or what each run was doing at that moment. This plan reverses that: we cause the restarts on purpose, against a local control plane whose runs execute in Daytona. We run many tasks at once and repeat until nothing breaks.
2. Success rule (the oracle)
A run is restart-crossing if any part of it overlaps a control-plane fault: its queued wake, preparation, provider turn, finalization, or a follow-up. Every restart-crossing task must meet all of the rules below.
| ID | Rule | Failure means |
|---|---|---|
| R1 | Automatic continuation. If continuing is safe, the task resumes after the server is back, with no user action, and reaches its expected resting state within the task's time budget plus the outage time. | Not robust to restart |
| R2 | Durable workspace. No workspace, durability, or recovery error stays visible at rest. This includes restore, snapshot, finalize, writer-pending, lease mismatch, and stranded lock errors. If such an error appears during recovery, it clears by itself. Files the agent wrote before the fault are present afterwards, and nothing is silently rolled back. | Durability bug |
| R3 | Working Retry. If the task cannot safely continue on its own, it shows exactly one Retry or continue affordance. One click (POST /api/issues/:id/retry, and the button in the UI) starts a run that completes the task. "Send a message to continue", a Retry that does nothing, a missing Retry, or a Retry that needs several clicks all fail this rule. |
Dead or missing Retry |
| R4 | No duplicate execution. There is at most one active run per issue, no side effect is repeated, and no user message is consumed twice. This is soak oracle U4 and U5. | Replay or duplicate run |
| R5 | No silent stall. A task is never in_progress or queued with no live run, no queued wake, and no named wait reason for more than 3 minutes after /api/liveness reports ready. |
Stall |
| R6 | Clean rest. At the end of the wave there is no pending workspace operation, recovery action, orphaned lease, or orphaned Daytona sandbox. | Leak |
The paid "kill at an arbitrary point" probes (recover-runner*, see
tests/runner-recovery/README.md) were retired because they demanded
automatic completion after any kill. For a turn whose outcome is unknown,
automatic completion is not always safe. R1 and R3 resolve this: when automatic
continuation is not provably safe, the correct result is a Retry that works on
the first click. A random kill therefore always has a defined pass condition. It
is never graded "environment, ignore".
3. Topology
chaos harness (Node script, no LLM)
├─ supervisor: owns the server process (start, SIGKILL, SIGTERM, hot restart, outage hold)
├─ launcher: creates tasks through the board API
├─ observer: polls issues, runs and run events; grades R1–R6; captures evidence
└─ ledger: one JSONL line per task execution, plus the fault schedule seed
local control plane (isolated PAPERCLIP_HOME, embedded Postgres, one fixed port)
└─ dials out to runnerd in each Daytona sandbox (runner ingress, port 43127)
Daytona: one sandbox per run or lease, holding the real runner and provider (Claude, Codex)
- Use an isolated instance, not
~/.paperclip/instances/default. Chaos kills and the leftover Postgres state must never touch Dotta's working data. - Own the server process. Reuse the existing supervisor in
tests/runner-e2e/server.tsandcontroller-outage.ts(replaceController: stop → wait for unhealthy → hold → start → ready, with a receipt). It already handles process groups, SIGKILL escalation, and embedded-Postgres ownership. Do not usepnpm dev(watch mode): when its child dies, the dev runner exits too. - Readiness. Use
/api/livenessand the startup phases (starting → recovering → ready) to time the outage and the recovery window.startup-recovery-timing.tslogs each phase. - Keep across restarts: the Postgres data and the instance secret key. A restart that loses either one is a different fault (K11) and is graded on its own.
- Concurrency. Start with 6 concurrent tasks and raise to 12, then 20. The limits are the Daytona sandbox quota and provider spend. Settle the quota in Phase 0.
4. Fault menu
4a. What the fault does (K)
| ID | Fault | How |
|---|---|---|
| K1 | Hard kill, fast return | SIGKILL the server, restart immediately. Hot-restart reconciliation reports not_requested. |
| K2 | Graceful stop | SIGTERM (shutdown drain and finalizers run), then restart. |
| K3 | Hot restart | scripts/request-hot-restart.ts, then SIGTERM. Runs should be adopted. |
| K4 | Medium outage | Hard kill and hold for 2–5 minutes. This exceeds the 60-second controller lease but stays within the 7-minute runner reconnect grace. |
| K5 | Long outage | Hard kill and hold for 8–20 minutes. This exceeds the reconnect grace, and past 15 minutes Daytona auto-stops idle sandboxes. The runner gives up, so recovery must go through saved work. |
| K6 | Crash loop | Kill again during recovering, then a third time just after ready. |
| K7 | Kill during the automatic retry | Kill again while the automatic retry or continuation started by R1 is running. |
| K8 | Kill during a user's Retry | Kill between the Retry click (receipt committed) and the start of the new run. |
| K9 | Postgres dies too | SIGKILL embedded Postgres along with the server. |
| K10 | Network partition, no restart | Block the server's outbound traffic to Daytona for 30 seconds to 5 minutes. |
| K11 | Lost local state | Restart with the controller cache and scoped state directory deleted, as on a new container (remote_reattach_root_missing). The database is kept. |
| K12 | Rolling deploy | Start a second server on the same database before stopping the first, as in rolling-deployment-supervisor.ts. |
| K13 | Restart storm | Restart every 2–4 minutes for an hour with 20 tasks running. |
4b. When the fault hits (B, run boundary)
Some faults fire at a random time: a seeded random generator chooses the time, so a failure can be replayed. Others are boundary-triggered: the observer watches run events and status and fires the fault at a chosen transition. Each boundary below lists the run event or state it fires on.
| ID | Boundary | Fires on |
|---|---|---|
| B1 | Queued, not yet dispatched | Run queued |
| B2 | Sandbox acquire or provision | Lease acquire started, sandbox not ready |
| B3 | Workspace restore or prepare | Restore or prepare operation in progress |
| B4 | Runner bootstrap or session open | Native bootstrap events |
| B5 | Mid model turn, streaming | First assistant output event |
| B6 | Mid long tool call | Tool-start event without a tool-end, for example bench.sh running about 4 minutes |
| B7 | Waiting on a question or approval | Pending interaction |
| B8 | User message or steering queued during the run | Queued comment exists |
| B9 | Turn done, finalization or sync-out in progress | workspace_finalize or sync operation running |
| B10 | Snapshot or archive upload | Snapshot upload begun |
| B11 | Done, before publication | Durable done, reply not yet published (as in the chat-restart cell) |
| B12 | Scheduled-retry backoff | Run scheduled_retry |
| B13 | Stop or interrupt settling | Cancel requested, not settled |
| B14 | Parent waiting on delegated children | Parent blocked on children, children running |
| B15 | Routine fire or timer wake | Routine run being created |
| B16 | Reassignment in flight | Assignee changed, old run not ended |
The finalization-crash-boundary.ts and controller-recovery-observer.ts
modules in tests/runner-e2e/ already bind kills to some of these boundaries.
Reuse them; do not write them again.
4c. Workload (W)
Use a smaller subset of the soak library, chosen so the boundaries above occur naturally:
- A1: brief written to an issue document. A short run with no repository.
- A3: CSV analysis whose answer can be checked exactly.
- B1: Python CLI with tests. Workspace files must survive the fault. The harness runs the tests itself.
- B2: fix a failing test, plus a revision follow-up. This exercises workspace reuse across the fault.
- B5: a long
bench.shrun, which reaches boundary B6. - C1: a comment sent while the run is active (B8).
- C2: Stop, then a new direction (B13).
- A5: the agent asks a clarifying question (B7).
- D1: CEO delegates to children (B14).
- E1 or H10: a routine (B15).
- C6: reassignment (B16).
Each task has a deterministic T-oracle, so a restart cannot hide wrong output.
4d. Adapter (A)
- Primary: native Claude and native Codex on Daytona.
- Control: the same tasks with a local runner. This tells apart failures that are Daytona-specific from failures in control-plane recovery.
- Later: other adapters, once the primary pair is green.
The full cross product is about 13 × 16 × 11 × 2. That is too large to run exhaustively. Waves therefore sample pairwise coverage: every fault paired with every boundary, and every boundary on every adapter, at least N times. The remaining slots go to open signatures and random K1 and K13 storms.
5. Harness (tests/restart-chaos/)
The harness is a script, not an LLM, for the same reasons as the soak harness: deterministic grading and cheap polling. It reuses the runner-e2e process code. It does not copy that code.
config: isolated instance path, port,DAYTONA_API_KEY, company, agents, budget, and concurrency.seed: creates the "Chaos Lab" company, Claude and Codex agents on Daytona, and thechaos-sandboxproject with a fixture repository. This includesbench.shand one failing test. It sets hard budgets.supervisor: wrapsserver.tsandcontroller-outage.ts, exposingkill(kind),hold(ms),start(),waitReady(),partition(ms)(for example a pf or iptables rule, or a proxy in front of runner ingress), andwipeLocalState(). Every action is written to the timeline.scheduler: a seeded plan of faults and boundaries for each wave. The seed and the chosen cells are saved, so--replay <wave>/<task>repeats the same fault at the same boundary.observer: polls every 2–5 seconds and evaluates R1–R6 and the T-oracles. When a task ends in the R3 state, the observer acts as a user would: it calls Retry once through the API, the same call the UI hook makes inuseTaskRetry.ts, and grades the result.ui-probe: every few waves, a Playwright pass opens one task that is in the R3 state, checks that the Retry button is visible, clicks it once, and confirms the task continues. The dead-Retry bug (#19426) was in the UI path, so checking the API alone is not enough.evidence bundle(captured at failure time): run IDs and statuses, the last 200 run events for each run, the server log around the fault, the startup-phase timings, the recovery claim dispositions (claimNativeRestartRecoveries), workspace operations, leases and their Daytona sandbox state, the fault timeline, the seed, and the SHA.ledgerand summary: the soak ledger format, plus the fault, boundary and adapter fields. A small dashboard artifact shows pass rate per cell and the open signatures.sweep: at the end of each wave, reconcile Daytona sandboxes against leases (R6). Then destroy whatever the harness created.
6. Triage and the fix loop
wave (N tasks × sampled K/B/A cells)
→ grade R1–R6 and the T-oracles
→ classify failures; signature = component + first causal error + boundary
→ new P signature → fixer session running /fix-failed-paperclip-run with the bundle and a replay command
→ known signature → add the bundle to the existing record
→ fix PR merged → rebase the harness worktree on master, replay that signature 5× plus its neighboring cells
- Classes: the same as the soak campaign. P is a product bug, Q is agent quality, T is a test or harness defect. E (environment) never applies to the fault we injected. Only an unrelated provider or Daytona outage counts as E.
- Fixers start from the replay. Each fixer gets
--replay, so the first step offix-failed-paperclip-run(establish the incident) is already done. The fixer then writes the smallest red test at the right layer. Usually that is a server integration test on embedded Postgres, like the six in #19399, or anative-runner-restart-*.integration.test.ts. The fix makes it green. - Merge authority: the same as the soak campaign. Fixer sessions merge their
own PR at Greptile 5/5 with all required checks green, and run
check-pr. - Concurrency: at most 4 fixer sessions at once.
- No deploy lag. Fixes are retested locally as soon as they merge, which is the main speed advantage over the staging loop. Staging stays the final confirmation (Phase 4).
7. Phases
| Phase | Output | Exit |
|---|---|---|
| 0. Baseline | Isolated instance, Daytona key and quota confirmed, Chaos Lab seeded. Run the existing everyday-workflows.runner-codex.daytona.recover-controller cell and controller-replacement-smoke.ts as they are today. Also run 10 tasks by hand with a K1 kill each, to measure the starting failure rate. |
The baseline failure rate is recorded and the first signatures are filed. |
| 1. Harness MVP | Supervisor, launcher, observer with R1–R6, ledger and replay. Faults K1, K2 and K4 at random times. Workloads A1, B1, B5 and C1 on Claude and Codex. 6 concurrent tasks. | Two waves run without supervision, and the evidence bundles are good enough for a fixer to start from. |
| 2. Boundary matrix | Boundary triggers B1–B16 and the full K list except K12 and K13. Pairwise sampling. UI probe. | Every K×B pair has been run at least 3 times. |
| 3. Stress | Concurrency 12, then 20. K13 storms (an hour of restarts every 2–4 minutes). K12 rolling deploys. Overnight runs. | Finish line (§8). |
| 4. Lock in | Each fixed signature has a red/green regression test in its PR. Stable cells are promoted to explicit runner-e2e cells (recover-controller style) for release qualification. Then one confirmation wave on staging. |
Recurring regressions are caught in CI, not by chaos. |
8. Finish line
At one pinned master SHA:
- 500 consecutive restart-crossing task executions across the sampled matrix with zero R1–R6 failures. At least 20 of them cross K5 (the long outage) and at least 20 cross K6 or K7 (repeat kills).
- Every K×B cell has at least 5 passes on both Claude and Codex.
- Two K13 storm hours at concurrency 20 in which every task reaches its resting state, and the R3 Retry works on the first click every time.
- No open P signature remains in the ledger.
- One staging wave after deploy confirms the result. There, U9 sees no restart failure across all restarts in a 24-hour window.
9. Cost and safety
- Hard budget per wave (start at $30) and per night (start at $150). The harness refuses to start a wave above 80% of the cap.
- The harness signals only processes it started, using the PID and start-time
checks in
process-tree-owner.ts. It never touches the default instance or any other dev server. - The Daytona cleanup sweep runs after every wave and on harness exit. Orphaned sandboxes are counted as R6 failures before they are deleted.
- No real credentials are placed in fixtures. The canary secret check (soak U7) stays on, because recovery paths replay logs.
10. Decisions needed from Dotta
- Daytona quota and concurrency ceiling: is 20 concurrent sandboxes OK, or is the cap lower?
- Budget: are $30 per wave and $150 per night acceptable caps?
- K5 bar (outage longer than the 7-minute reconnect grace): is a working Retry an acceptable outcome, or must the task continue automatically from saved work? The plan assumes automatic continuation is required when the last provider turn settled, and Retry is acceptable when the turn was in flight. This matches the #19399 rule.
- Adapters for the finish line: Claude and Codex native only, or also the ACPX adapters?
- K12 rolling deploys: include them now, or wait until zero-downtime web
deploys (
2026-10-31-zero-downtime-web-deploys.md) is the production path?