mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 01:50:13 +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/`) 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>
145 lines
8.4 KiB
TypeScript
145 lines
8.4 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
import { parseCommand, parseReplayRef, waveIdFor } from "./config.js";
|
||
import { K4_HOLD_RANGE_MS, createRng, observedFaultSchedule, planTasks, planWave, replayWave, taskFaultSchedule } from "./scheduler.js";
|
||
import type { WaveOptions } from "./types.js";
|
||
import { workloadDefinitions } from "./workloads.js";
|
||
|
||
const options: WaveOptions = { env: "local", adapters: ["codex", "claude", "opencode"], workloads: ["A1", "B1", "B5", "C1"],
|
||
faults: ["K1", "K2", "K4"], concurrency: 4, tasks: 8, seed: 42, budgetCents: 3_000, pollMs: 3_000 };
|
||
|
||
describe("scheduler determinism", () => {
|
||
it("produces the same plan for the same seed", () => {
|
||
expect(planTasks(options)).toEqual(planTasks(options));
|
||
});
|
||
|
||
it("produces a different plan for a different seed", () => {
|
||
expect(planTasks({ ...options, seed: 43 })).not.toEqual(planTasks(options));
|
||
});
|
||
|
||
it("keeps fire times, holds and follow-ups inside each workload's window", () => {
|
||
for (const task of planTasks({ ...options, tasks: 60 })) {
|
||
const definition = workloadDefinitions[task.workload];
|
||
expect(task.fireAfterMs).toBeGreaterThanOrEqual(definition.fireWindowMs[0]);
|
||
expect(task.fireAfterMs).toBeLessThanOrEqual(definition.fireWindowMs[1]);
|
||
if (task.fault === "K4") {
|
||
expect(task.holdMs).toBeGreaterThanOrEqual(K4_HOLD_RANGE_MS[0]);
|
||
expect(task.holdMs).toBeLessThanOrEqual(K4_HOLD_RANGE_MS[1]);
|
||
} else expect(task.holdMs).toBe(0);
|
||
expect(task.followUpAfterMs === null).toBe(task.workload !== "C1");
|
||
expect(task.nonce).toMatch(/^[0-9a-f]{8}$/);
|
||
}
|
||
});
|
||
|
||
it("covers every workload × adapter × fault cell once per cycle", () => {
|
||
const cells = options.workloads.length * options.adapters.length * options.faults.length;
|
||
const tasks = planTasks({ ...options, tasks: cells });
|
||
expect(new Set(tasks.map(task => `${task.workload}/${task.adapter}/${task.fault}`)).size).toBe(cells);
|
||
expect(tasks.map(task => task.taskId).slice(0, 3)).toEqual(["t01", "t02", "t03"]);
|
||
});
|
||
|
||
it("respects narrowed selections", () => {
|
||
const tasks = planTasks({ ...options, adapters: ["codex"], workloads: ["A1"], faults: ["K1"], tasks: 3 });
|
||
expect(tasks.every(task => task.adapter === "codex" && task.workload === "A1" && task.fault === "K1")).toBe(true);
|
||
});
|
||
|
||
it("has a stable RNG sequence", () => {
|
||
const first = createRng(7);
|
||
const second = createRng(7);
|
||
expect(Array.from({ length: 5 }, () => first.next())).toEqual(Array.from({ length: 5 }, () => second.next()));
|
||
});
|
||
});
|
||
|
||
describe("replay", () => {
|
||
const plan = planWave(waveIdFor(42, new Date("2026-11-06T10:11:12Z")), options, new Date("2026-11-06T10:11:12Z"));
|
||
|
||
it("names waves with the timestamp and seed", () => {
|
||
expect(plan.waveId).toBe("w-20261106T101112-s42");
|
||
});
|
||
|
||
it("reproduces exactly one saved task", () => {
|
||
const replay = replayWave(plan, { waveId: plan.waveId, taskId: "t03" }, 1_700_000_000_000);
|
||
expect(replay.tasks).toEqual([plan.tasks[2]]);
|
||
expect(replay.options.concurrency).toBe(1);
|
||
expect(replay.replayOf).toEqual({ waveId: plan.waveId, taskId: "t03" });
|
||
expect(replay.waveId).toBe(`${plan.waveId}-replay-t03-1700000000000`);
|
||
// A replay of a replay names the original wave, and its id parses again.
|
||
const again = replayWave(replay, parseReplayRef(`${replay.waveId}/t03`), 1_700_000_000_001);
|
||
expect(again.waveId).toBe(`${plan.waveId}-replay-t03-1700000000001`);
|
||
});
|
||
|
||
it("keeps the source wave's masks on replay and lets --rc06-masked add the RC-06 mask", () => {
|
||
const masked = planWave("w-20261106T101112-s42", { ...plan.options, masks: ["rc06"] });
|
||
expect(replayWave(masked, { waveId: masked.waveId, taskId: "t01" }, 1).options.masks).toEqual(["rc06"]);
|
||
expect(replayWave(plan, { waveId: plan.waveId, taskId: "t01" }, 1).options.masks).toBeUndefined();
|
||
const command = parseCommand(["--replay", `${plan.waveId}/t01`, "--rc06-masked"]);
|
||
if (command.mode !== "replay") throw new Error("expected replay");
|
||
expect(command.overrides.masks).toEqual(["rc06"]);
|
||
expect(replayWave(plan, { waveId: plan.waveId, taskId: "t01" }, 1, command.overrides).options.masks).toEqual(["rc06"]);
|
||
expect(replayWave(masked, { waveId: masked.waveId, taskId: "t01" }, 1, command.overrides).options.masks).toEqual(["rc06"]);
|
||
const wave = parseCommand(["--rc06-masked", "--seed", "3"]);
|
||
if (wave.mode !== "wave") throw new Error("expected wave");
|
||
expect(wave.options.masks).toEqual(["rc06"]);
|
||
});
|
||
|
||
it("replays the recorded fault sequence that crossed the task, other tasks' faults included", () => {
|
||
const createdAt = 1_000_000;
|
||
const faults = [
|
||
{ faultId: "f01", kind: "K1" as const, holdMs: 0, startedAt: createdAt + 42_000 },
|
||
{ faultId: "f02", kind: "K4" as const, holdMs: 150_000, startedAt: createdAt + 60_000 },
|
||
{ faultId: "f03", kind: "K2" as const, holdMs: 0, startedAt: createdAt + 900_000 },
|
||
];
|
||
const observed = observedFaultSchedule(createdAt, ["f02", "f01"], faults);
|
||
expect(observed).toEqual([{ kind: "K1", holdMs: 0, atMs: 42_000 }, { kind: "K4", holdMs: 150_000, atMs: 60_000 }]);
|
||
const replay = replayWave(plan, { waveId: plan.waveId, taskId: "t02" }, 1, {}, observed);
|
||
expect(taskFaultSchedule(replay.tasks[0]!)).toEqual(observed);
|
||
// Without a recorded sequence the replay falls back to the planned fault.
|
||
const planned = replayWave(plan, { waveId: plan.waveId, taskId: "t02" }, 1);
|
||
expect(taskFaultSchedule(planned.tasks[0]!)).toEqual([{ kind: plan.tasks[1]!.fault, holdMs: plan.tasks[1]!.holdMs, atMs: plan.tasks[1]!.fireAfterMs }]);
|
||
});
|
||
|
||
it("rejects unknown tasks and mismatched waves", () => {
|
||
expect(() => replayWave(plan, { waveId: plan.waveId, taskId: "t99" })).toThrow(/no task t99/);
|
||
expect(() => replayWave(plan, { waveId: "w-20261106T101112-s1", taskId: "t01" })).toThrow(/not w-/);
|
||
});
|
||
});
|
||
|
||
describe("CLI parsing", () => {
|
||
it("parses a wave command including pnpm's forwarded --", () => {
|
||
const parsed = parseCommand(["--", "--env", "local", "--adapters", "codex,claude,opencode", "--concurrency", "4", "--tasks", "8", "--faults", "K1,K2,K4", "--seed", "17"]);
|
||
expect(parsed).toMatchObject({ mode: "wave", options: { env: "local", adapters: ["codex", "claude", "opencode"], concurrency: 4, tasks: 8, faults: ["K1", "K2", "K4"], seed: 17 } });
|
||
});
|
||
|
||
it("parses --replay", () => {
|
||
expect(parseCommand(["--replay", "w-20261106T101112-s42/t03"])).toMatchObject({ mode: "replay", ref: { waveId: "w-20261106T101112-s42", taskId: "t03" } });
|
||
});
|
||
|
||
it("rejects replay mixed with plan options and malformed refs", () => {
|
||
expect(() => parseCommand(["--replay", "w-20261106T101112-s42/t03", "--seed", "1"])).toThrow(/reproduces the saved plan/);
|
||
expect(() => parseReplayRef("w-20261106T101112-s42")).toThrow(/<wave>\/<task>/);
|
||
expect(() => parseReplayRef("..%2Fetc/t01")).toThrow(/Invalid wave id/);
|
||
expect(() => parseReplayRef("../etc/t01")).toThrow(/<wave>\/<task>/);
|
||
expect(() => parseReplayRef("w-20261106T101112-s42/t1")).toThrow(/Invalid task id/);
|
||
});
|
||
|
||
it("rejects unknown faults, adapters and out-of-range numbers", () => {
|
||
expect(() => parseCommand(["--faults", "K9"])).toThrow(/Unknown faults/);
|
||
expect(() => parseCommand(["--adapters", "gemini"])).toThrow(/Unknown adapters/);
|
||
expect(() => parseCommand(["--concurrency", "0"])).toThrow(/between 1 and 20/);
|
||
expect(() => parseCommand(["--poll-ms", "500"])).toThrow(/between 2000 and 5000/);
|
||
});
|
||
});
|
||
|
||
describe("Daytona options", () => {
|
||
const image = `ghcr.io/paperclipai/paperclip-daytona-runner@sha256:${"a".repeat(64)}`;
|
||
it("defaults to 4 vCPU sandboxes, excludes B5, and keeps the image in the plan", () => {
|
||
const parsed = parseCommand(["--env", "daytona", "--daytona-image", image, "--concurrency", "10"]);
|
||
expect(parsed).toMatchObject({ mode: "wave", options: { daytonaImage: image, daytonaCpu: 4, concurrency: 10 } });
|
||
if (parsed.mode === "wave") expect(parsed.options.workloads).not.toContain("B5");
|
||
});
|
||
it("refuses waves over the 40 vCPU chaos budget, mutable images, and Daytona flags without Daytona", () => {
|
||
expect(() => parseCommand(["--env", "daytona", "--daytona-image", image, "--concurrency", "11"])).toThrow(/exceeds the Daytona vCPU budget of 40/);
|
||
expect(() => parseCommand(["--env", "daytona", "--daytona-image", "ghcr.io/x/y:latest"])).toThrow(/immutable runner image/);
|
||
expect(() => parseCommand(["--env", "local", "--daytona-cpu", "2"])).toThrow(/only to --env daytona/);
|
||
});
|
||
});
|