## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The restart chaos harness (`tests/restart-chaos/`) restarts the control plane during real agent tasks and grades each task on rules R1–R6 > - One known product bug (RC-06) leaves a completed run "awaiting accounting". The company hard stop then refuses all new work, so one stuck row fails every later task in the wave > - That masks every other restart bug in the wave. Waves also had no record of what they spent > - This pull request adds an opt-in RC-06 mask, records wave spend, and lets a replay keep or add the mask > - The benefit is that chaos waves can find new restart bugs while RC-06 is open, and stay inside a known spend ## Linked Issues or Issue Description Refs #19477 (the Phase 1 harness this extends). **What existing behavior does this improve?** Restart chaos waves. One stuck accounting row (RC-06) hard-stops the isolated Chaos Lab company. All later tasks then rest blocked "over budget", and a Retry is refused with "Company cannot start work while completed runs await accounting". Two waves went 2/6 and 0/6 for this one reason. **Subsystem affected** Test harness only: `tests/restart-chaos/` (config, seed, scheduler, ledger, supervisor, main, README). **Current behavior** - No way to see past RC-06 without disabling the budget. - Wave spend is not recorded. - `--replay` rejected any mask flag, and it was not documented that the saved plan carries the mask. **Proposed behavior** - `--rc06-masked` keeps the budget amount and turns off only the hard stop on the isolated company. Masked tasks are labeled in the ledger and summary. - Each wave records its spend. - A replay keeps the source plan's masks and may add the RC-06 mask. ## What Changed - `--rc06-masked` (wave mode): after the company is created, the seed upserts the company budget policy with the same amount and `hardStopEnabled: false`. It writes `seed_mask_rc06` to the timeline. The plan stores `options.masks = ["rc06"]`. - The accounting gate in `budgets.ts` (`observedBlocks`) applies only when `hardStopEnabled` is true, so the mask lifts it. No product code changes. - Ledger lines carry `masks`. `SUMMARY.md` labels the cell and the latest-wave row `rc06-masked`. - Wave spend: before the sweep, `main.ts` reads `/api/companies/:id/costs/summary?period=all` (each wave owns its company), writes `wave_spend` to the timeline and prints it. If the wave stops early, cleanup records it once, best effort, before the server stops. - Load: #19513 already records `loadAvg1m` on each fault receipt. This branch first added the same value under another name, and the rebase removed it. The README now documents `loadAvg1m` and the load-suspect threshold. - Replay: `--replay <wave>/<task>` keeps the masks saved in the source `plan.json`. `--replay … --rc06-masked` adds the RC-06 mask. A replay never drops a mask. - README documents the flag, spend, load and the replay rule. - New unit tests: replay keeps saved masks and the replay flag adds the mask; the masked seed posts the company policy with the same amount and `hardStopEnabled: false` and records `seed_mask_rc06`, and an unmasked seed makes no policy call. ## Verification - `pnpm exec vitest run --config tests/restart-chaos/vitest.config.ts`: 71/71 pass (after rebase onto master with #19513). - `node scripts/typecheck-test-projects.mjs tests/restart-chaos`: pass. - Live: 4 masked local waves ran with this code (seeds 3307, 4409, 5501, 6619; Codex, ACPX Claude and OpenCode). - `seed_mask_rc06` appeared and no task was blocked by the accounting gate, even though `pendingRunCount` was 1–8 at wave end. - `wave_spend` was recorded each time (38¢–127¢ per wave). - The 1-minute load average was recorded on every fault (15–58). - Before the mask, the same harness gave 2/6 and 0/6, every failure RC-06. With the mask, the waves found other restart failures, all since fixed on master: RC-14, RC-16, RC-20 and RC-21. ## Risks - Low risk. Test harness only, and the mask is opt-in. - A masked wave has no product hard stop. The harness still records spend for each wave, and the operator must watch it. The mask applies only to the isolated, temporary Chaos Lab company. It never touches another instance. - Masked results do not count as RC-06 coverage. The ledger labels them so they are not mixed with unmasked cells. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with tool use (shell, file edit). Extended reasoning on. ## 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 <noreply@anthropic.com>
Restart chaos harness (Phase 1 MVP)
Plan: doc/plans/2026-11-06-control-plane-restart-chaos.md.
A script, not an LLM. It starts an isolated Paperclip control plane (temporary
PAPERCLIP_HOME, wrapper-owned embedded Postgres, its own port), seeds a
"Chaos Lab" company, launches real agent tasks, kills or restarts the control
plane during those tasks, and grades every task on R1–R6 plus a deterministic
T-oracle.
Run
pnpm install --frozen-lockfile
pnpm --filter @paperclipai/paperclip-runner run build:runner-binaries # native runner (debug)
set -a; source ~/.secrets; set +a # provider keys, process env only
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 w-20261106T101112-s42/t03
pnpm exec vitest run --config tests/restart-chaos/vitest.config.ts # credential-free unit tests
node scripts/typecheck-test-projects.mjs tests/restart-chaos # typecheck
Options: --triggers time,comment,retry,queued,tool (fault timing, below),
--workloads A1,B1,B5,C1, --budget-cents (company hard cap,
default 3000), --poll-ms (2000–5000), --keep (keep the temporary instance).
--rc06-masked keeps the company budget amount but disables its hard stop on
the isolated Chaos Lab company, so one stuck accounting row (RC-06) cannot
wedge every later task. Masked tasks are marked rc06-masked in the ledger and
summary, and each wave still records its spend (wave_spend in the timeline).
A replay inherits the masks saved in the source wave's plan.json;
--replay <wave>/<task> --rc06-masked adds the mask to a replay of an unmasked wave.
Each fault receipt records the host 1-minute load average (loadAvg1m); treat
timeout failures above about 16 as load-suspect.
Daytona (--env daytona)
The control plane stays local; agent runs execute in Daytona sandboxes from an immutable runner image. No Docker is needed.
set -a; source ~/.secrets; set +a # DAYTONA_API_KEY plus provider keys, process env only
node cli/node_modules/tsx/dist/cli.mjs tests/restart-chaos/main.ts --env daytona \
--daytona-image ghcr.io/paperclipai/paperclip-daytona-runner@sha256:<digest> \
--adapters codex,claude --tasks 3 --concurrency 2 --faults K1 --seed 11
PAPERCLIP_E2E_DAYTONA_IMAGE can replace --daytona-image. B5 is local-only
(its oracle reads the live workspace, which is inside the sandbox).
vCPU budget. The Daytona org is shared with the live Cloud fleet. Chaos
sandboxes use --daytona-cpu vCPU (default 4, with 4 GiB memory and 10 GiB
disk), and --concurrency × --daytona-cpu must stay within 40 vCPU
(RESTART_CHAOS_DAYTONA_VCPU_BUDGET). On a small org, set the budget below the
org limit with room for verification probes: a 10 vCPU org fails creates with
HTTP 400 "Total CPU limit exceeded", so use 8 there and --daytona-cpu 2.
Fleet safety. The harness never stops, archives or deletes a Daytona
sandbox. Its Daytona client is a frozen wrapper that exposes only a
label-filtered list. assertHarnessMayMutate refuses paperclip-create-*
names (every plugin-created sandbox has one) and fleet-sbx-* snapshots, so a
future mutating path cannot act on fleet sandboxes either. Cleanup goes only
through Paperclip, by deleting the wave's own environment.
Image matching. The controller needs the image's own Linux amd64
/usr/local/bin/paperclip-runnerd (PAPERCLIP_RUNNER_REMOTE_BINARY_PATH) and
/opt/paperclip-runner/provider-pack/ (PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH).
daytona-image.ts gets them without a daemon: skopeo copy --override-os linux --override-arch amd64 docker://<image> dir:<cache>, then walks the layers from
top to bottom (honouring .wh. and .wh..wh..opq whiteouts), extracts only
those two paths, checks the ELF header, the pack manifest and the image labels,
records their digests in receipt.json, and deletes the layer blobs. The result
is cached in results/daytona-images/<digest>/, so it runs once per image.
Run it alone with node cli/node_modules/tsx/dist/cli.mjs tests/restart-chaos/daytona-image.ts <image>.
Before starting, the harness compares the image's io.paperclip.runner.content-id
label with the content ID of this checkout (pnpm test:e2e:runner:image-id,
which hashes every runner input) and refuses to run on a mismatch. To get an
image for the current master, dispatch a narrow image build and reject its paid
gate:
gh workflow run runner-full-stack-e2e.yml --ref master -f cadence=manual -f all=false \
-f id=core-compatibility.runner-codex.daytona.message-marker
# when the run waits on the paid environment:
gh api -X POST repos/paperclipai/paperclip/actions/runs/<run>/pending_deployments \
-F "environment_ids[]=20786721735" -f state=rejected -f comment="Image build only"
The digest is in the "Publish verified Daytona image" job log (pushing manifest for …@sha256:…), or check out the image's org.opencontainers.image.revision.
Setup reuses runner-e2e: the bundled Daytona plugin (installFixtureDaytonaProvider,
prepared outside the root workspace as CI does), the daytona environment
fixture from catalog.ts, and the native Codex platform-qualification probe.
R6 sweep. After the wave, daytona-sweep.ts lists (read-only) the
sandboxes whose paperclip-company-id and paperclip-environment-id labels
both equal the UUIDs this wave's isolated instance minted, re-checks every row,
and reconciles them with the environment's leases. A sandbox archived after its
lease was released is expected (the plugin archives on release and Daytona
deletes it after 60 minutes). Any other live sandbox without a held lease, or
an active/pending_cleanup lease, is a leak: it fails R6 for the
task whose run owns it, or the wave when no task does. The harness then deletes
the wave's environment through Paperclip and lists again: a wave sandbox that
is still neither archived nor destroyed after 90 seconds fails the wave and is
reported for a person to act on (the environment's 60-minute ttl also expires
it). The result is in <wave>/sandbox-sweep.json.
Fault timing (--triggers)
Each task samples one trigger with its cell. time (default) fires at the
seeded offset. The others fire relative to a user action or run state seen by
the observer, after a seeded delay:
| Trigger | Fires | Plan |
|---|---|---|
comment |
0–5 s after the C1 follow-up was posted to a live run (C1 only) | C1 + K1, B8 |
retry |
0–2 s after an accepted Retry click, as an extra K1 after the planned fault | K8 |
queued |
on the first poll whose run is still queued with no start |
B1 |
tool |
15–60 s after bench.sh progress first appears (B5 only) |
B6 |
A trigger whose condition can no longer hold (comment posted at rest, run
already started, no Retry offered) is recorded as triggerMissed in the
ledger; the task is still graded. --faults K6 is the crash loop: after the
first kill, the replacement is killed again as soon as /api/ready reports
recovering (or ready, if recovery was too quick to see), and once more just
after ready. The receipt's crashLoop lists the phase each kill hit. A replay
re-arms the task's own triggered faults instead of using their offsets.
Pieces
| File | Role |
|---|---|
supervisor.ts |
Owns the server. Reuses tests/runner-e2e process code (config, ports, env isolation, wrapper-owned Postgres, process-tree stopper, replaceController). Faults: K1 SIGKILL + immediate restart, K2 SIGTERM + restart, K4 SIGKILL + 2–5 min hold, K6 crash loop. The controller runtime cache is kept (discarding it is K11). |
seed.ts |
Chaos Lab company, encrypted provider secrets, native Codex / ACPX Claude / OpenCode agents from the runner-e2e catalog, and the chaos-sandbox project (git repo: python CLI, one failing test, bench.sh ≈ 4 min) with isolated git-worktree workspaces. It first turns on the instance enableIsolatedWorkspaces setting; without it the server ignores the project policy and every repo task shares the primary checkout. |
scheduler.ts |
Seeded plan: workload × adapter × fault cells and a random fire time per task. Saved to results/<wave>/plan.json. --replay reruns one task alone with the same choices and fires the recorded sequence of faults that crossed it (other tasks' faults included) at the same offsets from launch. |
observer.ts |
Polls each issue and its runs. When a task rests needing Retry it calls POST /api/issues/:id/retry once (once more only if a later fault started after that click), with the body useTaskRetry.ts sends. Collects T-oracle artifacts and the R6 sweep. |
grader.ts |
Pure R1–R6 grading over the samples. |
workloads.ts |
A1 brief document, B1 python CLI with tests, B5 long bench, C1 comment while running, with their T-oracles. |
evidence.ts, ledger.ts |
Evidence bundle for each failure and the JSONL ledger plus SUMMARY.md. |
daytona-image.ts |
Docker-free extraction of the image's runnerd and provider pack, cached per digest, and the controller/image content-ID check. |
daytona-sweep.ts |
Daytona R6: read-only reconciliation of this wave's labelled sandboxes with leases; cleanup only through Paperclip. |
Output (gitignored)
tests/restart-chaos/results/ (override with RESTART_CHAOS_RESULTS_DIR):
ledger.jsonl,SUMMARY.md— every graded task, per-cell pass rate, open signatures.<wave>/plan.json,timeline.jsonl,server.log,faults.json,<task>.grade.json.<wave>/evidence/<task>/on failure — run ids and statuses, last 200 events per run, workspace operations, server log window around each fault, startup phase timings, fault timeline, seed, git SHA and the replay command. Known provider key values are redacted.
Grading notes
- A task is restart-crossing when a run overlaps a fault window or the task was not yet at rest when the fault started. Non-crossing tasks are still graded.
- R2 also checks that workspace files present before the first fault still exist at rest.
- B5 passes only when
bench.shwrote its own completion record showing it ran, and the harness itself saw at least three.bench/progressvalues while polling. This is best-effort against a deliberately adversarial agent. - R4 allows at most one active (queued, running or scheduled-retry) run per sample. A wake comment may be served again only after a run that did not succeed.
- T-oracle commands (B1 tests and CLI) run agent-editable code. They run under
a macOS Seatbelt sandbox with a scratch
HOME, no network and no read access to the operator's home directory. On other platforms they are refused unlessRESTART_CHAOS_ALLOW_UNSANDBOXED_ORACLE=1is set inside a disposable VM. - R1 allows the workload budget plus outage time. When a Retry was needed R1 is
n/aand R3 decides; a task that rests unfinished with no Retry fails both. - Faults are serialized; each one hits every task running at that moment.
- A
todoorin_progresstask with no live run and no named wait is not a rest. The observer keeps watching it until it resumes or passes the R5 stall limit. - R5 also fails a run that stays
runningwith no live evidence (no new run event,lastUsefulActionAtorexecution.lastConfirmedActivityAt, and no start) for more than 3 minutes after the server is ready. Arunningstatus alone is not proof of life. - A fault that fails to complete fails the wave as a harness error (exit 2), even when recovery brings the server back.