Files
PaperClipAI/tests/restart-chaos/triggers.test.ts
T
DottaandClaude Opus 5.5 1634f8b66b test(restart-chaos): run chaos waves against Daytona sandboxes without Docker (#19544)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The restart chaos harness (`tests/restart-chaos/`) restarts a local
control plane on purpose while agent tasks run, and grades every task on
rules R1 to R6
> - The plan targets agent runs in Daytona sandboxes, but the harness
only supported `--env local`
> - A local controller needs two inputs that match the sandbox image
exactly: the image's Linux `paperclip-runnerd` and its provider pack.
The documented way to get them uses Docker, and Docker is not available
on the operator machine
> - The Daytona org that chaos uses also runs the live Cloud fleet, so
the harness must never change a sandbox it did not create through its
own Paperclip environment
> - This pull request adds `--env daytona`. It extracts both inputs from
the image without Docker, and refuses a controller whose runner source
differs from the image. It seeds a Daytona environment from the
runner-e2e fixtures and adds a read-only Daytona R6 sweep
> - The benefit is that chaos waves can now cross real Daytona sandboxes
with a local, killable control plane

## Linked Issues or Issue Description

**Feature description**
Run restart chaos waves with a local control plane and agent runs in
Daytona sandboxes (plan
`doc/plans/2026-11-06-control-plane-restart-chaos.md`, Phase 1b).

**Problem it solves**
`--env daytona` threw "not wired yet". The controller-side runnerd and
provider pack had to come from `docker cp`, which needs a Docker daemon.

**Proposed solution**
Use `skopeo copy` (no daemon) to fetch the linux/amd64 image into a
directory, walk the OCI layers from top to bottom, and extract only the
two paths. Before the wave starts, compare the image's content-ID label
with this checkout.

Refs #19477

## What Changed

- `tests/restart-chaos/daytona-image.ts`: a streaming tar reader and an
OCI layer walk (top to bottom, `.wh.<name>` and `.wh..wh..opq`
whiteouts, hard links, symlinks written last, no `..` paths). It
extracts `/usr/local/bin/paperclip-runnerd` and
`/opt/paperclip-runner/provider-pack/`. It checks the layer blob
digests, the ELF header (x86-64), the provider-pack manifest schema, and
its `runnerSourceRevision` against the image revision label. It records
digests in `receipt.json`, caches the result by image digest under
`results/daytona-images/`, and deletes the layer blobs. It also runs
standalone.
- Controller/image check: `assertControllerMatchesImage` compares the
image's `io.paperclip.runner.content-id` label with
`computeDaytonaImageContentId` for this checkout. On a mismatch it fails
and prints both IDs and the image's source revision.
- `seed.ts`: for `--env daytona`, it installs the bundled Daytona plugin
(`installFixtureDaytonaProvider`) and waits for provider readiness. It
creates the `daytona` environment fixture from `catalog.ts` with a
`DAYTONA_API_KEY` company secret and runs the native Codex
platform-qualification probe. Then it assigns the agents and the project
to that environment. `--daytona-cpu` sets the sandbox vCPU (default 4, 4
GiB, 10 GiB).
- `daytona-sweep.ts`: a read-only R6 for Daytona. The Daytona client is
a frozen wrapper that exposes only a label-filtered `list`, and no stop,
archive or delete exists. The sweep keeps only rows whose
`paperclip-company-id` and `paperclip-environment-id` both equal the
wave's own UUIDs, and it re-checks every row. It reconciles sandboxes
with the environment's leases:
  - A sandbox archived after its lease was released is expected.
- An orphaned sandbox or an `active`/`pending_cleanup` lease fails R6
for the owning task, or the wave.
- `assertHarnessMayMutate` refuses `paperclip-create-*` names and
`fleet-sbx-*` snapshots.
- Cleanup only deletes the wave's environment through Paperclip and
reports what is still running.
- `config.ts`: `--daytona-image` (or `PAPERCLIP_E2E_DAYTONA_IMAGE`)
needs an immutable digest. `--concurrency × --daytona-cpu` must stay
within `RESTART_CHAOS_DAYTONA_VCPU_BUDGET` (default 40). B5 is
local-only, because its oracle reads the live workspace.
- `main.ts`: Daytona preflight (credentials, image extraction,
content-ID check, Daytona plugin build as CI does). It passes
`PAPERCLIP_RUNNER_REMOTE_BINARY_PATH` and
`PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH` to every server start and
writes `sandbox-sweep.json`. Cleanup errors never skip stopping the
controller.
- README: a Daytona section (image matching, image-only CI dispatch,
vCPU budget, fleet safety, R6).
- Unit tests: synthetic tar layers for the tar reader, whiteouts, opaque
directories, overrides, hard links, symlinks and parent replacement.
Further tests cover the content-ID check, sweep reconciliation, exact
wave selection (unlabelled rows and other labels are never selected),
the read-only client, the fleet guard, and the CLI options.

## Verification

- `pnpm exec vitest run --config tests/restart-chaos/vitest.config.ts`:
62/62 pass, credential-free.
- `node scripts/typecheck-test-projects.mjs tests/restart-chaos`: clean.
- Real extraction without Docker, image `…@sha256:b0c71f4d…`: 42 layers,
about 8 minutes, a 3 GB pack.
- The content-ID check refused the current checkout. The image is
`b8b16e15…` from `7e6d882558`; the checkout is `aec45589…`.
- A checkout of `7e6d882558` computes `b8b16e15…`, so the check compares
like with like.
- Current image:
`ghcr.io/paperclipai/paperclip-daytona-runner@sha256:a84579522192de0393ae24d09e67c4e752df4c701bd315ff54ad9bbca42e2dc0`
(source `5d25117ffa`, content `4e9a221f…`). It matches this branch, and
the receipt shows the content ID match.
- Paid waves, `--env daytona --adapters codex,claude --tasks 3
--concurrency 2 --faults K1 --seed 11`:
- Wave 1 (10 vCPU org, 4 vCPU sandboxes): 0/3. Every create returned
HTTP 400 "Total CPU limit exceeded. Maximum allowed: 10", reproduced
directly with the SDK. This led to the vCPU budget, and the
archived-sandbox fix in R6.
- Wave 2 (same org, 2 vCPU sandboxes): **3/3 PASS**, all
restart-crossing. Codex completed through one working Retry; both Claude
tasks continued automatically. R6: 4 sandboxes archived after release, 0
orphans, 0 held leases.
- Wave 3 (company org, 4 vCPU sandboxes): 0/3. Two product failures were
found and reported to the campaign ledger: an ACPX Claude recovery
blocked on `ambiguous_remote_recovery_evidence` with no Retry, and a
false budget hard stop at 10.75¢ of 3000¢ that paused the company. The
harness changed no sandbox. R6 reported 2 held leases, and the
environment delete returned 409 `pending_sandbox_cleanup` because of
them.
- Fleet-org key check: one sandbox with a unique non-`paperclip-create`
name and label was created, then deleted by the exact ID that create
returned.

## Risks

- This change touches the test harness only, with no product code.
- The extracted provider pack is about 3 GB per image in the gitignored
results directory. Delete old `results/daytona-images/<digest>`
directories by hand.
- The harness never changes Daytona sandboxes. If Paperclip's
environment cleanup is blocked, sandboxes stay until their 60-minute ttl
or a person acts. The harness reports them in `sandbox-sweep.json` and
fails the wave.

> 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 (`claude-opus-5-5`), Anthropic, in Claude Code with
tool use (shell, file edits, GitHub CLI). Default context window,
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
- [ ] All Paperclip CI gates are green
- [ ] 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>
2026-11-06 14:26:45 -06:00

355 lines
20 KiB
TypeScript

import { readFileSync } from "node:fs";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { isSocketTypeOfServiceRace, type ChaosApi } from "./api.js";
import { parseCommand } from "./config.js";
import { gradeR5 } from "./grader.js";
import { latestIso, QUEUED_TRIGGER_MAX_EMPTY_SAMPLES, TaskObserver, TRIGGER_LATE_MS } from "./observer.js";
import { observedFaultSchedule, planTasks, taskFaultSchedule, TRIGGER_DELAY_RANGE_MS } from "./scheduler.js";
import type { ChaosLab } from "./seed.js";
import { crashLoopShouldFire } from "./supervisor.js";
import { Timeline } from "./timeline.js";
import { ledgerLine } from "./ledger.js";
import type { TaskGrade, TaskPlan, TaskRecord, TaskSample, WaveOptions, WavePlan } from "./types.js";
process.env.RESTART_CHAOS_QUIET = "1";
const lab: ChaosLab = { companyId: "c1", environmentId: "e1", environmentKind: "local", projectId: "p1", fixtureRepositoryPath: "/tmp/none",
agents: { codex: { id: "a1", name: "Chaos codex" }, claude: { id: "a2", name: "Chaos claude" }, opencode: { id: "a3", name: "Chaos opencode" } } };
const ready = { serverReady: true, faultInProgress: false };
const base: TaskPlan = { taskId: "t01", index: 0, workload: "A1", adapter: "codex", fault: "K1", fireAfterMs: 999_999,
holdMs: 0, followUpAfterMs: null, nonce: "abcd1234" };
const options: WaveOptions = { env: "local", adapters: ["codex", "claude", "opencode"], workloads: ["A1", "B1", "B5", "C1"],
faults: ["K1", "K2", "K4", "K6"], concurrency: 3, tasks: 60, seed: 9, budgetCents: 3_000, pollMs: 3_000,
triggers: ["time", "comment", "retry", "queued", "tool"] };
type Json = Record<string, unknown>;
function scriptedApi(state: { runs: Json[]; issue: Json; retry?: Json }) {
const api = {
async get(route: string) { return route.endsWith("/runs") ? state.runs : route.endsWith("/comments") ? [] : state.issue; },
async post(route: string) {
if (route.endsWith("/issues")) return { id: "i1", identifier: "CHA-1" };
if (route.endsWith("/retry")) return state.retry ?? { accepted: true };
return { id: "comment-1" };
},
};
return api as unknown as ChaosApi;
}
describe("trigger planning", () => {
it("pairs comment only with C1 and tool only with B5, with delays inside each range", () => {
const tasks = planTasks(options);
for (const task of tasks) {
if (task.trigger === "comment") expect(task.workload).toBe("C1");
if (task.trigger === "tool") expect(task.workload).toBe("B5");
if (task.trigger && task.trigger !== "time") {
const [min, max] = TRIGGER_DELAY_RANGE_MS[task.trigger];
expect(task.triggerDelayMs).toBeGreaterThanOrEqual(min);
expect(task.triggerDelayMs).toBeLessThanOrEqual(max);
} else expect(task.triggerDelayMs).toBeUndefined();
}
expect(new Set(tasks.map(task => task.trigger ?? "time")).size).toBe(5);
expect(planTasks(options)).toEqual(tasks);
});
it("keeps time-only plans free of trigger fields and rejects a trigger with no fitting workload", () => {
expect(planTasks({ ...options, triggers: undefined }).every(task => task.trigger === undefined)).toBe(true);
expect(() => planTasks({ ...options, workloads: ["A1"], triggers: ["tool"] })).toThrow(/needs workload B5/);
});
it("schedules the K8 retry fault as an extra K1 after the planned fault", () => {
expect(taskFaultSchedule({ ...base, fireAfterMs: 30_000, trigger: "retry", triggerDelayMs: 500 })).toEqual([
{ kind: "K1", holdMs: 0, atMs: 30_000 }, { kind: "K1", holdMs: 0, atMs: 0, trigger: "retry", delayMs: 500 }]);
expect(taskFaultSchedule({ ...base, fault: "K6", trigger: "queued", triggerDelayMs: 0 }))
.toEqual([{ kind: "K6", holdMs: 0, atMs: 999_999, trigger: "queued", delayMs: 0 }]);
});
it("re-arms only the replayed task's own triggered faults", () => {
const faults = [
{ faultId: "f01", kind: "K1" as const, holdMs: 0, startedAt: 2_000, requestedBy: "t01", trigger: "queued" as const, triggerDelayMs: 0 },
{ faultId: "f02", kind: "K1" as const, holdMs: 0, startedAt: 9_000, requestedBy: "t02", trigger: "comment" as const, triggerDelayMs: 3_000 },
];
expect(observedFaultSchedule(1_000, ["f01", "f02"], faults, "t01")).toEqual([
{ kind: "K1", holdMs: 0, atMs: 1_000, trigger: "queued", delayMs: 0 }, { kind: "K1", holdMs: 0, atMs: 8_000 }]);
});
it("parses --triggers and refuses it with --replay", () => {
expect(parseCommand(["--triggers", "comment,queued"])).toMatchObject({ options: { triggers: ["comment", "queued"] } });
expect(parseCommand([])).toMatchObject({ options: { triggers: ["time"] } });
expect(() => parseCommand(["--triggers", "later"])).toThrow(/Unknown triggers/);
expect(() => parseCommand(["--replay", "w-20261106T101112-s42/t03", "--triggers", "comment"])).toThrow(/reproduces the saved plan/);
});
});
describe("trigger firing", () => {
it("queued: fires on the first run that is still queued, and misses once a run has started", async () => {
const state = { runs: [{ runId: "r1", status: "queued", createdAt: new Date().toISOString() }] as Json[], issue: { id: "i1", status: "todo" } };
const observer = new TaskObserver({ ...base, trigger: "queued", triggerDelayMs: 0 }, scriptedApi(state), lab, new Timeline(null), ready);
await observer.launch();
const start = observer.record.createdAt!;
expect(observer.faultDue(start)).toBeNull();
await observer.poll(start + 10);
expect(observer.faultDue(start + 10)).toMatchObject({ trigger: "queued", kind: "K1" });
const late = { runs: [{ runId: "r1", status: "running", startedAt: new Date().toISOString() }] as Json[], issue: { id: "i1", status: "in_progress" } };
const missed = new TaskObserver({ ...base, trigger: "queued", triggerDelayMs: 0 }, scriptedApi(late), lab, new Timeline(null), ready);
await missed.launch();
await missed.poll(missed.record.createdAt! + 10);
expect(missed.faultDue(missed.record.createdAt! + 10)).toBeNull();
expect(missed.record.triggerMissed).toEqual(["queued"]);
});
it("queued: gives up after several polls with no run at all", async () => {
const state = { runs: [] as Json[], issue: { id: "i1", status: "todo" } };
const observer = new TaskObserver({ ...base, trigger: "queued", triggerDelayMs: 0 }, scriptedApi(state), lab, new Timeline(null), ready);
await observer.launch();
const start = observer.record.createdAt!;
for (let index = 1; index < QUEUED_TRIGGER_MAX_EMPTY_SAMPLES; index += 1) {
await observer.poll(start + index);
expect(observer.faultDue(start + index)).toBeNull();
}
expect(observer.record.triggerMissed).toBeUndefined();
await observer.poll(start + 100);
observer.faultDue(start + 100);
expect(observer.record.triggerMissed).toEqual(["queued"]);
});
it("comment: fires its delay after the follow-up reaches a live run", async () => {
const state = { runs: [{ runId: "r1", status: "running", startedAt: new Date().toISOString() }] as Json[], issue: { id: "i1", status: "in_progress" } };
const observer = new TaskObserver({ ...base, workload: "C1", followUpAfterMs: 1_000, trigger: "comment", triggerDelayMs: 2_000 },
scriptedApi(state), lab, new Timeline(null), ready);
await observer.launch();
const start = observer.record.createdAt!;
await observer.poll(start + 500);
expect(observer.faultDue(start + 500)).toBeNull();
await observer.poll(start + 1_500);
expect(observer.record.followUp).toMatchObject({ commentId: "comment-1", duringRun: true });
expect(observer.faultDue(start + 3_499)).toBeNull();
expect(observer.faultDue(start + 3_500)).toMatchObject({ trigger: "comment" });
});
it("comment: a follow-up posted to a resting task does not fire", async () => {
const state = { runs: [{ runId: "r1", status: "succeeded", finishedAt: new Date().toISOString() }] as Json[], issue: { id: "i1", status: "done" } };
const observer = new TaskObserver({ ...base, workload: "C1", followUpAfterMs: 0, trigger: "comment", triggerDelayMs: 0 },
scriptedApi(state), lab, new Timeline(null), ready);
await observer.launch();
await observer.poll(observer.record.createdAt! + 10);
expect(observer.faultDue(observer.record.createdAt! + 10)).toBeNull();
expect(observer.record.triggerMissed).toEqual(["comment"]);
});
it("retry: fires the extra K1 right after an accepted Retry click, and closes unfired triggers at sweep", async () => {
const state = { runs: [{ runId: "r1", status: "failed", finishedAt: new Date().toISOString() }] as Json[],
issue: { id: "i1", status: "blocked", interruption: { state: "needs_retry", canRetry: true, observedRunId: "r1" } } };
const observer = new TaskObserver({ ...base, fireAfterMs: 1_000, trigger: "retry", triggerDelayMs: 0 }, scriptedApi(state), lab, new Timeline(null), ready);
await observer.launch();
const start = observer.record.createdAt!;
expect(observer.faultDue(start + 1_000)).toEqual({ kind: "K1", holdMs: 0, atMs: 1_000 });
await observer.poll(start + 2_000);
expect(observer.faultDue(start + 2_000)).toBeNull();
await observer.poll(start + 2_000 + 12_001);
expect(observer.record.retry).toMatchObject({ accepted: true });
expect(observer.faultDue(start + 14_002)).toMatchObject({ kind: "K1", trigger: "retry" });
const unclicked = new TaskObserver({ ...base, trigger: "retry", triggerDelayMs: 0 }, scriptedApi(state), lab, new Timeline(null), ready);
await unclicked.launch();
await unclicked.sweep();
expect(unclicked.record.triggerMissed).toEqual(["retry"]);
});
it("clicks Retry once per fault: again only after a fault that started after the last click", async () => {
const state = { runs: [{ runId: "r1", status: "failed", finishedAt: new Date().toISOString() }] as Json[],
issue: { id: "i1", status: "blocked", interruption: { state: "failed", canRetry: true, observedRunId: "r1" } } };
const observer = new TaskObserver({ ...base }, scriptedApi(state), lab, new Timeline(null), ready);
await observer.launch();
const start = observer.record.createdAt!;
await observer.poll(start + 1_000);
await observer.poll(start + 1_000 + 12_001);
const first = observer.record.retry!;
expect(first.accepted).toBe(true);
await observer.poll(start + 20_000);
await observer.poll(start + 20_000 + 12_001);
expect(observer.done).toBe(true);
expect(observer.record.retry).toBe(first);
const again = new TaskObserver({ ...base }, scriptedApi(state), lab, new Timeline(null), ready);
await again.launch();
const begin = again.record.createdAt!;
await again.poll(begin + 1_000);
await again.poll(begin + 1_000 + 12_001);
const clicked = again.record.retry!;
again.noteFault("f01", clicked.at + 1);
await again.poll(begin + 20_000);
await again.poll(begin + 20_000 + 12_001);
expect(again.done).toBe(false);
expect(again.record.priorRetries).toEqual([clicked]);
expect(again.record.retry).not.toBe(clicked);
});
it("tool: fires its delay after bench.sh progress is first seen", async () => {
const observer = new TaskObserver({ ...base, workload: "B5", trigger: "tool", triggerDelayMs: 20_000 },
scriptedApi({ runs: [], issue: { id: "i1", status: "in_progress" } }), lab, new Timeline(null), ready);
await observer.launch();
const start = observer.record.createdAt!;
expect(observer.faultDue(start + 60_000)).toBeNull();
observer.record.benchFirstSeenAt = start + 30_000;
expect(observer.faultDue(start + 49_999)).toBeNull();
expect(observer.faultDue(start + 50_000)).toMatchObject({ trigger: "tool" });
});
it("records a miss instead of firing a triggered fault after its window has passed", async () => {
const observer = new TaskObserver({ ...base, workload: "B5", trigger: "tool", triggerDelayMs: 1_000 },
scriptedApi({ runs: [], issue: { id: "i1", status: "in_progress" } }), lab, new Timeline(null), ready);
await observer.launch();
const start = observer.record.createdAt!;
observer.record.benchFirstSeenAt = start;
expect(observer.faultDue(start + 1_000 + TRIGGER_LATE_MS + 1)).toBeNull();
expect(observer.record.triggerMissed).toEqual(["tool"]);
});
it("queued: misses when the run started before the fault could fire", async () => {
const state = { runs: [{ runId: "r1", status: "queued" }] as Json[], issue: { id: "i1", status: "todo" } };
const observer = new TaskObserver({ ...base, trigger: "queued", triggerDelayMs: 0 }, scriptedApi(state), lab, new Timeline(null), ready);
await observer.launch();
const start = observer.record.createdAt!;
await observer.poll(start + 10);
state.runs = [{ runId: "r1", status: "running", startedAt: new Date().toISOString() }];
await observer.poll(start + 20);
expect(observer.faultDue(start + 20)).toBeNull();
expect(observer.record.triggerMissed).toEqual(["queued"]);
});
it("keeps replayed fixed-offset faults on time while a re-armed trigger waits", async () => {
const observer = new TaskObserver({ ...base, workload: "B5", replayFaults: [
{ kind: "K1", holdMs: 0, atMs: 0, trigger: "tool", delayMs: 0 }, { kind: "K2", holdMs: 0, atMs: 5_000 }] },
scriptedApi({ runs: [], issue: { id: "i1", status: "in_progress" } }), lab, new Timeline(null), ready);
await observer.launch();
const start = observer.record.createdAt!;
expect(observer.faultDue(start + 5_000)).toEqual({ kind: "K2", holdMs: 0, atMs: 5_000 });
observer.record.benchFirstSeenAt = start + 6_000;
expect(observer.faultDue(start + 6_000)).toMatchObject({ kind: "K1", trigger: "tool" });
});
});
describe("ledger load", () => {
it("records the highest load among the task's crossing faults", () => {
const plan = { waveId: "w", seed: 1, options: { env: "local" } } as unknown as WavePlan;
const record = { plan: base, identifier: "CHA-1", issueId: "i1", retry: null } as unknown as TaskRecord;
const grade = { rules: {}, T: { verdict: "pass" }, crossed: true, crossingFaults: ["f01", "f02"], pass: true, signature: null } as unknown as TaskGrade;
const line = ledgerLine(plan, record, grade, "sha", null,
[{ faultId: "f01", loadAvg1m: 12.5 }, { faultId: "f02", loadAvg1m: 21 }, { faultId: "f03", loadAvg1m: 40 }]);
expect(line.maxLoadAvg1m).toBe(21);
expect(ledgerLine(plan, record, grade, "sha", null).maxLoadAvg1m).toBeUndefined();
});
});
describe("pre-fault files", () => {
it("checks lost files in the workspace the snapshot was taken from, and records a move", async () => {
const before = await mkdtemp(path.join(os.tmpdir(), "restart-chaos-before-"));
const after = await mkdtemp(path.join(os.tmpdir(), "restart-chaos-after-"));
try {
await writeFile(path.join(before, "kept.txt"), "x");
await writeFile(path.join(before, "other-task.txt"), "x");
const state = { runs: [] as Json[], issue: { id: "i1", status: "in_progress", currentExecutionWorkspace: { cwd: before } } as Json };
const observer = new TaskObserver({ ...base, workload: "B5" }, scriptedApi(state), lab, new Timeline(null), ready);
await observer.launch();
await observer.poll(observer.record.createdAt! + 10);
await observer.snapshotBeforeFault();
await rm(path.join(before, "other-task.txt"));
state.issue = { id: "i1", status: "done", currentExecutionWorkspace: { cwd: after } };
await observer.collectArtifacts();
expect(observer.record.lostFiles).toEqual(["other-task.txt"]);
expect(observer.record.workspaceMovedTo).toBe(after);
} finally {
await rm(before, { recursive: true, force: true });
await rm(after, { recursive: true, force: true });
}
});
});
describe("dispatch checks", () => {
it("confirmTrigger rechecks a queued run right before the kill", async () => {
const state = { runs: [{ runId: "r1", status: "queued" }] as Json[], issue: { id: "i1", status: "todo" } };
const observer = new TaskObserver({ ...base, trigger: "queued", triggerDelayMs: 0 }, scriptedApi(state), lab, new Timeline(null), ready);
await observer.launch();
const start = observer.record.createdAt!;
await observer.poll(start + 10);
const due = observer.faultDue(start + 10)!;
expect(await observer.confirmTrigger(due, start + 20)).toBe(true);
state.runs = [{ runId: "r1", status: "running", startedAt: new Date().toISOString() }];
expect(await observer.confirmTrigger(due, start + 30)).toBe(false);
expect(observer.record.triggerMissed).toEqual(["queued"]);
expect(await observer.confirmTrigger({ kind: "K1", holdMs: 0, atMs: 0 }, start)).toBe(true);
});
it("records an unreadable pre-fault workspace at rest instead of reporting every file lost", async () => {
const before = await mkdtemp(path.join(os.tmpdir(), "restart-chaos-gone-"));
await writeFile(path.join(before, "a.txt"), "x");
const state = { runs: [] as Json[], issue: { id: "i1", status: "in_progress", currentExecutionWorkspace: { cwd: before } } as Json };
const observer = new TaskObserver({ ...base, workload: "B5" }, scriptedApi(state), lab, new Timeline(null), ready);
await observer.launch();
await observer.poll(observer.record.createdAt! + 10);
await observer.snapshotBeforeFault();
await rm(before, { recursive: true, force: true });
state.issue = { id: "i1", status: "done" };
await observer.collectArtifacts();
expect(observer.record.lostFiles).toBeNull();
expect(observer.record.restSnapshotError).toMatch(/ENOENT/);
});
});
describe("R5 live evidence", () => {
const fixture = JSON.parse(readFileSync(path.join(import.meta.dirname, "fixtures/rc30-terminal-then-k2.json"), "utf8")) as { samples: TaskSample[] };
const record = (samples: TaskSample[]) => ({ samples } as unknown as TaskRecord);
it("fails a run that stays running with no event evidence after the server is ready (RC-30, w-20261106T190257-s707/t05)", () => {
expect(gradeR5(record(fixture.samples))).toMatchObject({ verdict: "fail", detail: expect.stringMatching(/^silent_dead_run b57890ae/) });
});
it("passes the same timeline while the run keeps producing events", () => {
const live = fixture.samples.map(sample => ({ ...sample,
runs: sample.runs?.map(run => ({ ...run, lastEvidenceAt: new Date(sample.at - 30_000).toISOString() })) }));
expect(gradeR5(record(live)).verdict).toBe("pass");
});
it("counts only time since the server became ready", () => {
const start = Date.parse("2026-11-06T10:00:00Z");
const run = { id: "r1", status: "running", createdAt: null, startedAt: new Date(start - 600_000).toISOString(), finishedAt: null,
errorCode: null, retryOfRunId: null, wakeCommentIds: [], livenessState: null, lastEvidenceAt: null };
const sample = (at: number, ready = true): TaskSample => ({ at, reachable: ready, serverReady: ready, issueStatus: "in_progress", runs: [run] });
expect(gradeR5(record([sample(start, false), sample(start + 1_000), sample(start + 120_000)])).verdict).toBe("pass");
expect(gradeR5(record([sample(start, false), sample(start + 1_000), sample(start + 200_000)])).verdict).toBe("fail");
});
it("keeps the latest evidence timestamp", () => {
expect(latestIso([null, "2026-11-06T10:00:00Z", "2026-11-06T09:00:00Z", "bad"])).toBe("2026-11-06T10:00:00.000Z");
expect(latestIso([undefined])).toBeNull();
});
});
describe("socket race", () => {
it("matches only the undici setTypeOfService EINVAL race", () => {
expect(isSocketTypeOfServiceRace(Object.assign(new Error("setTypeOfService EINVAL"), { code: "EINVAL", syscall: "setTypeOfService" }))).toBe(true);
expect(isSocketTypeOfServiceRace(Object.assign(new Error("x"), { code: "EINVAL", syscall: "write" }))).toBe(false);
expect(isSocketTypeOfServiceRace(null)).toBe(false);
});
});
describe("timeline", () => {
it("keeps the event kind when event data has a kind field", () => {
expect(new Timeline(null).add("fault_started", { kind: "K1" }).kind).toBe("fault_started");
});
});
describe("K6 crash loop", () => {
it("kills in recovering (or ready when recovering was skipped), then just after ready", () => {
expect(crashLoopShouldFire("recovering", "starting")).toBe(false);
expect(crashLoopShouldFire("recovering", "unreachable")).toBe(false);
expect(crashLoopShouldFire("recovering", "recovering")).toBe(true);
expect(crashLoopShouldFire("recovering", "ready")).toBe(true);
expect(crashLoopShouldFire("ready", "recovering")).toBe(false);
expect(crashLoopShouldFire("ready", "ready")).toBe(true);
});
});