Files
PaperClipAI/tests/restart-chaos/grader.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

204 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { FaultReceipt, OracleResult, RuleId, RuleResult, TaskGrade, TaskRecord, TaskSample } from "./types.js";
/** Pure R1–R6 grading over observer samples (plan §2). No I/O. */
export const ACTIVE_RUN_STATUSES = new Set(["queued", "running", "scheduled_retry"]);
export const STALL_LIMIT_MS = 3 * 60_000;
const REST_STATUSES_WITH_WORK = new Set(["todo", "in_progress"]);
const ms = (iso: string | null | undefined) => (iso ? Date.parse(iso) : NaN);
function faultWindow(fault: FaultReceipt): [number, number] {
return [fault.startedAt, fault.readyAt ?? fault.healthyAt ?? Number.POSITIVE_INFINITY];
}
/**
* Restart-crossing: some run of the task (queued wake through finalization)
* overlaps the fault window, or the task was not yet at rest when it started.
*/
export function crossingFaults(record: TaskRecord, faults: readonly FaultReceipt[]): string[] {
if (record.createdAt === null) return [];
const latest = new Map<string, { run: NonNullable<TaskSample["runs"]>[number]; at: number }>();
for (const sample of record.samples) for (const run of sample.runs ?? []) latest.set(run.id, { run, at: sample.at });
const runs = new Map<string, { start: number; end: number }>();
for (const [id, { run, at }] of latest) {
const start = ms(run.createdAt) || ms(run.startedAt) || at;
const end = ACTIVE_RUN_STATUSES.has(run.status) ? Number.POSITIVE_INFINITY : (ms(run.finishedAt) || at);
runs.set(id, { start, end });
}
const restAt = record.restAt ?? Number.POSITIVE_INFINITY;
return faults.filter(fault => {
const [start, end] = faultWindow(fault);
if (record.createdAt! <= start && restAt > start) return true;
return [...runs.values()].some(run => run.start <= end && run.end >= start);
}).map(fault => fault.faultId);
}
function reachable(samples: readonly TaskSample[]) { return samples.filter(sample => sample.reachable); }
function lastReachable(record: TaskRecord): TaskSample | undefined {
const values = reachable(record.samples);
return values[values.length - 1];
}
export function outageOverlapMs(record: TaskRecord, faults: readonly FaultReceipt[]): number {
if (record.createdAt === null) return 0;
const end = record.restAt ?? Number.POSITIVE_INFINITY;
return faults.reduce((total, fault) => {
const [start, stop] = faultWindow(fault);
const overlap = Math.min(stop, end) - Math.max(start, record.createdAt!);
return total + (Number.isFinite(overlap) && overlap > 0 ? overlap : 0);
}, 0);
}
export function gradeR1(record: TaskRecord, budgetMs: number, faults: readonly FaultReceipt[]): RuleResult {
if (record.launchError) return { verdict: "fail", detail: `launch_failed: ${record.launchError}` };
if (record.retry) return { verdict: "n/a", detail: "task rested needing Retry; graded under R3" };
if (record.finalStatus !== "done") {
return { verdict: "fail", detail: record.timedOut ? `no_rest_within_budget (last status ${record.finalStatus ?? "unknown"})` : `rested_not_done (status ${record.finalStatus ?? "unknown"}, no Retry offered)` };
}
const allowed = budgetMs + outageOverlapMs(record, faults);
const took = (record.restAt ?? Number.POSITIVE_INFINITY) - record.createdAt!;
return took <= allowed
? { verdict: "pass", detail: `done after ${Math.round(took / 1000)}s (allowed ${Math.round(allowed / 1000)}s)` }
: { verdict: "fail", detail: `done_too_late ${Math.round(took / 1000)}s > ${Math.round(allowed / 1000)}s` };
}
export function gradeR2(record: TaskRecord, oracle: OracleResult): RuleResult {
const last = lastReachable(record);
if (!last) return { verdict: "fail", detail: "no_reachable_sample" };
if (last.interruption?.workspaceFailure) return { verdict: "fail", detail: `workspace_failure_visible_at_rest: ${last.interruption.workspaceFailure}` };
if (record.preFaultSnapshotError) {
return { verdict: "fail", detail: `pre_fault_snapshot_failed: durability not checked (${record.preFaultSnapshotError})` };
}
if (record.restSnapshotError) {
return { verdict: "fail", detail: `rest_snapshot_failed: durability not checked (${record.restSnapshotError})` };
}
if (record.lostFiles?.length) {
return { verdict: "fail", detail: `pre_fault_files_lost: ${record.lostFiles.slice(0, 10).join(",")}` };
}
const fileChecks = oracle.checks.filter(check => /file|workspace_located/.test(check.name) && !check.ok);
if (fileChecks.length && record.finalStatus === "done") {
return { verdict: "fail", detail: `workspace_files_missing_after_done: ${fileChecks.map(check => check.name).join(",")}` };
}
const transient = record.samples.some(sample => sample.interruption?.workspaceFailure);
return { verdict: "pass", detail: transient ? "workspace failure appeared during recovery and cleared" : "no workspace failure at rest" };
}
export function gradeR3(record: TaskRecord): RuleResult {
const last = lastReachable(record);
if (!record.retry) {
if (record.finalStatus !== "done" && !record.timedOut && !record.launchError) {
return { verdict: "fail", detail: `missing_retry: rested ${record.finalStatus ?? "unknown"} with canRetry=${String(last?.interruption?.canRetry ?? false)}` };
}
return { verdict: "n/a", detail: "no Retry needed" };
}
if (record.retry.error || record.retry.accepted !== true) {
return { verdict: "fail", detail: `retry_rejected: http=${record.retry.httpStatus ?? "none"} accepted=${String(record.retry.accepted)} ${record.retry.error ?? ""}`.trim() };
}
const retryAt = record.retry.at;
const newRun = record.samples.some(sample => (sample.runs ?? []).some(run => (ms(run.createdAt) || 0) >= retryAt - 1_000));
if (!newRun) return { verdict: "fail", detail: "retry_no_new_run" };
if (record.finalStatus !== "done") return { verdict: "fail", detail: `retry_did_not_complete (status ${record.finalStatus ?? "unknown"}${record.timedOut ? ", timed out" : ""})` };
return { verdict: "pass", detail: "one Retry started a run that completed the task" };
}
/**
* R4: at most one active run (queued, running or scheduled_retry) per issue in
* any sample, and no comment consumed twice. A wake comment may be reused only
* by a run that follows a run which did not succeed (an explicit retry or
* continuation after a failure); reuse after a successful run is a replay.
*/
export function gradeR4(record: TaskRecord): RuleResult {
for (const sample of reachable(record.samples)) {
const active = (sample.runs ?? []).filter(run => ACTIVE_RUN_STATUSES.has(run.status));
if (active.length > 1) return { verdict: "fail", detail: `concurrent_runs: ${active.map(run => `${run.id}(${run.status})`).join(",")}` };
}
const finalRuns = new Map<string, NonNullable<TaskSample["runs"]>[number]>();
for (const sample of reachable(record.samples)) for (const run of sample.runs ?? []) finalRuns.set(run.id, run);
const begun = [...finalRuns.values()].filter(run => run.startedAt)
.sort((a, b) => (ms(a.createdAt) || 0) - (ms(b.createdAt) || 0));
const consumers = new Map<string, typeof begun>();
for (const run of begun) for (const commentId of run.wakeCommentIds) consumers.set(commentId, [...(consumers.get(commentId) ?? []), run]);
for (const [commentId, runs] of consumers) {
const replayed = runs.findIndex((_run, index) => index > 0 && runs.slice(0, index).some(prior => prior.status === "succeeded"));
if (replayed > 0) return { verdict: "fail", detail: `comment_consumed_twice: ${commentId} by ${runs.slice(0, replayed + 1).map(run => run.id).join(",")}` };
}
return { verdict: "pass", detail: "at most one active run per sample; no comment consumed after a successful run" };
}
function namedWait(sample: TaskSample): boolean {
const interruption = sample.interruption;
return Boolean(sample.executionBlocker || sample.scheduledRetry || sample.activeRecoveryAction || interruption?.retryWait
|| interruption?.state === "retry_scheduled" || interruption?.canRetry
|| (interruption && ["failed", "waiting_for_user"].includes(interruption.state)));
}
export function isStalledSample(sample: TaskSample): boolean {
if (!sample.reachable || !sample.serverReady) return false;
if (!REST_STATUSES_WITH_WORK.has(sample.issueStatus ?? "")) return false;
if ((sample.runs ?? []).some(run => ACTIVE_RUN_STATUSES.has(run.status))) return false;
return !namedWait(sample);
}
export function deadRunningRun(sample: TaskSample, readySince: number): { id: string; idleMs: number } | null {
if (!sample.reachable || !sample.serverReady || sample.at - readySince <= STALL_LIMIT_MS) return null;
for (const run of sample.runs ?? []) {
if (run.status !== "running") continue;
const evidence = Math.max(ms(run.lastEvidenceAt) || 0, ms(run.startedAt) || 0, readySince);
if (sample.at - evidence > STALL_LIMIT_MS) return { id: run.id, idleMs: sample.at - Math.max(ms(run.lastEvidenceAt) || 0, ms(run.startedAt) || 0) };
}
return null;
}
export function gradeR5(record: TaskRecord): RuleResult {
let stallStart: number | null = null;
let worst = 0;
let readySince: number | null = null;
for (const sample of record.samples) {
if (!sample.reachable || !sample.serverReady) { stallStart = null; readySince = null; continue; }
readySince ??= sample.at;
const dead = deadRunningRun(sample, readySince);
if (dead) return { verdict: "fail", detail: `silent_dead_run ${dead.id} running with no event, activity or start evidence for ${Math.round(dead.idleMs / 1000)}s` };
if (isStalledSample(sample)) {
stallStart ??= sample.at;
worst = Math.max(worst, sample.at - stallStart);
if (sample.at - stallStart > STALL_LIMIT_MS) {
return { verdict: "fail", detail: `silent_stall ${Math.round((sample.at - stallStart) / 1000)}s in ${sample.issueStatus} with no live run, wake or wait reason` };
}
} else stallStart = null;
}
return { verdict: "pass", detail: worst ? `longest unexplained idle ${Math.round(worst / 1000)}s` : "no unexplained idle" };
}
export function gradeR6(record: TaskRecord): RuleResult {
const sweep = record.sweep;
if (!sweep) return { verdict: "fail", detail: "rest_sweep_missing" };
if (sweep.error) return { verdict: "fail", detail: `rest_sweep_error: ${sweep.error}` };
const leaks = [
...(sweep.activeRuns.length ? [`active_runs=${sweep.activeRuns.join(",")}`] : []),
...(sweep.runningWorkspaceOperations.length ? [`running_workspace_operations=${sweep.runningWorkspaceOperations.join(",")}`] : []),
...(sweep.activeRecoveryAction ? ["active_recovery_action"] : []),
...(sweep.daytonaLeaks ?? []),
];
return leaks.length ? { verdict: "fail", detail: `leak: ${leaks.join("; ")}` } : { verdict: "pass", detail: "clean rest" };
}
export function gradeTask(record: TaskRecord, faults: readonly FaultReceipt[], budgetMs: number, oracle: OracleResult): TaskGrade {
const crossing = crossingFaults(record, faults);
const rules: Record<RuleId, RuleResult> = {
R1: gradeR1(record, budgetMs, faults),
R2: gradeR2(record, oracle),
R3: gradeR3(record),
R4: gradeR4(record),
R5: gradeR5(record),
R6: gradeR6(record),
};
const failed = (Object.entries(rules) as Array<[RuleId, RuleResult]>).find(([, rule]) => rule.verdict === "fail");
const pass = !failed && oracle.verdict === "pass";
let signature: string | null = null;
if (failed) signature = `${failed[0]}:${failed[1].detail.split(/[\s:(]/)[0]}:${record.plan.workload}:${record.plan.fault}`;
else if (oracle.verdict === "fail") signature = `T:${oracle.checks.find(check => !check.ok)?.name ?? "unknown"}:${record.plan.workload}:${record.plan.fault}`;
return { crossed: crossing.length > 0, crossingFaults: crossing, rules, T: oracle, pass, signature };
}