mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 06:10:44 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The restart chaos harness collects each wave's speed from the board API after the tasks rest, including the M1 subtasks' runs > - The server rate-limits concurrent issue-list requests per client and answers 429 with `retryAfterSeconds` > - After a 95-task storm, the harness requested every M1 parent's subtask list at once. The server returned 429, speed collection failed, and the wave exited 1 with no speed grade > - This pull request makes harness GET requests honor a 429's retry delay with a bounded number of attempts, and collects subtask speed one parent at a time > - The benefit is that a busy wave keeps its speed grade, and the harness behaves like a polite API client ## Linked Issues or Issue Description Refs #20071, #20147 **What happened?** Wave `w-20261109T055902-s9402` (K13 storm, A1/C1/M1, 95 tasks) finished its tasks. Speed collection then failed with: `GET /api/companies/…/issues?view=index&parentIds=…&limit=20 returned 429: {"error":"Too many concurrent issue-list requests for this actor/client","retryAfterSeconds":1}`. `main.ts` ran `childSpeedTasks()` for every observer in parallel. **Expected behavior** - The harness waits and retries a rate-limited read. - It does not flood the issue-list endpoint while collecting speed. - A busy wave still gets graded. ## What Changed - `api.ts`: `ChaosApi` retries a GET that returns 429. - It waits for the delay from `Retry-After` or the body's `retryAfterSeconds` (default 1 s, capped at 10 s), scaled by the attempt number. - It makes at most 5 attempts by default. - Writes (POST/PUT/PATCH/DELETE) are never retried, so a user action is never repeated. - `rateLimitDelayMs` is exported for tests. - `main.ts`: subtask speed is collected one observer at a time instead of with `Promise.all`. - New `api.test.ts` against a real local HTTP server: - a GET that gets 429 twice, then 200, succeeds; - a POST that gets 429 is sent once; - retries stop at the bound; - delay parsing (header, body, cap, default). ## Verification - `npx vitest run` in `tests/restart-chaos`: 216 passed. - `node scripts/typecheck-test-projects.mjs tests/restart-chaos`: clean. ## Risks - A persistently rate-limited read now takes up to about 15 s before it fails, instead of failing at once. - Collecting subtask speed one parent at a time adds a few seconds to the end of large M1 waves. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use and code execution in Claude Code. ## 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>
568 lines
37 KiB
TypeScript
568 lines
37 KiB
TypeScript
/**
|
|
* Control-plane restart chaos harness, Phase 1 MVP.
|
|
* See doc/plans/2026-11-06-control-plane-restart-chaos.md and ./README.md.
|
|
*
|
|
* 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>
|
|
*/
|
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import { ChaosApi, isSocketTypeOfServiceRace } from "./api.js";
|
|
import { CREDENTIAL_FOR_ADAPTER, parseCommand, replayCommand, waveIdFor } from "./config.js";
|
|
import { assertControllerMatchesImage, defaultDaytonaImageCacheRoot, prepareDaytonaImageArtifacts } from "./daytona-image.js";
|
|
import { PLUGIN_BUILD_STAMP, pluginBuildState } from "./plugin-build.js";
|
|
import { DaytonaWaveSweeper, leaksForTask, unattributedLeaks, type DaytonaSweepResult } from "./daytona-sweep.js";
|
|
import { knownSecretValues, redactKnownSecrets, writeEvidenceBundle } from "./evidence.js";
|
|
import { gradeTask } from "./grader.js";
|
|
import { checkPlanInvariants, summarizeViolations, type InvariantCheck } from "./invariants.js";
|
|
import { appendLedger, ledgerLine, writeSummary, type LedgerLine } from "./ledger.js";
|
|
import { collectWaveSpeed, speedMarkdown } from "./wave-speed.js";
|
|
import { TaskObserver } from "./observer.js";
|
|
import { StormRun, TASK_FAULT_LOG, observedFaultSchedule, planWave, replayOnCurrentBuild, replayRecordFor, replayWave, type TaskFaultLog } from "./scheduler.js";
|
|
import { chaosLifecycleOverrides, seedChaosLab } from "./seed.js";
|
|
import { ChaosSupervisor } from "./supervisor.js";
|
|
import { isBrokenStdoutError, markTimelineEchoBroken, Timeline } from "./timeline.js";
|
|
import { guardedRunnerOutputs, worktreeFingerprint } from "./wave-guard.js";
|
|
import type { FaultKind, FaultReceipt, OracleArtifacts, TriggerId, WavePlan } from "./types.js";
|
|
|
|
const repositoryRoot = path.resolve(import.meta.dirname, "../..");
|
|
const resultsRoot = path.resolve(process.env.RESTART_CHAOS_RESULTS_DIR ?? path.join(import.meta.dirname, "results"));
|
|
const QUIET_BEFORE_SWEEP_MS = 15_000;
|
|
|
|
function git(args: string[]) {
|
|
try { return execFileSync("git", args, { cwd: repositoryRoot, encoding: "utf8" }).trim(); } catch { return ""; }
|
|
}
|
|
|
|
async function loadPlan(): Promise<{ plan: WavePlan; keep: boolean }> {
|
|
const command = parseCommand(process.argv.slice(2));
|
|
if (command.mode === "replay") {
|
|
const sourceDir = path.join(resultsRoot, command.ref.waveId);
|
|
const source = JSON.parse(await readFile(path.join(sourceDir, "plan.json"), "utf8")) as WavePlan;
|
|
// Replay the faults that actually crossed the task, including other tasks' faults.
|
|
const graded = await readFile(path.join(sourceDir, `${command.ref.taskId}.grade.json`), "utf8")
|
|
.then(text => JSON.parse(text) as { record: { createdAt: number | null; faultIds: string[]; serverBuildAtLaunch?: "upgrade_from" | "current" } })
|
|
.catch(() => null);
|
|
// An interrupted or wedged wave has no grade files; its fault log was written after every fault.
|
|
const logged = await readFile(path.join(sourceDir, TASK_FAULT_LOG), "utf8").then(text => JSON.parse(text) as TaskFaultLog).catch(() => null);
|
|
const crossed = replayRecordFor(command.ref.taskId, graded?.record ?? null, logged);
|
|
const faults = await readFile(path.join(sourceDir, "faults.json"), "utf8").then(text => JSON.parse(text) as FaultReceipt[]).catch(() => []);
|
|
const observed = crossed ? observedFaultSchedule(crossed.createdAt, crossed.faultIds, faults, command.ref.taskId) : [];
|
|
if (!observed.length) console.warn("Replay: no recorded fault sequence for this task; using its planned fault only.");
|
|
let plan = replayWave(source, command.ref, Date.now(), command.overrides, observed);
|
|
// K14: a task that launched after the upgrade replays on the current build.
|
|
const launchBuild = graded?.record.serverBuildAtLaunch ?? logged?.[command.ref.taskId]?.serverBuildAtLaunch;
|
|
if (plan.options.upgradeFrom && launchBuild === "current") plan = replayOnCurrentBuild(plan);
|
|
// H1 needs the hard stop that the RC-06 mask turns off.
|
|
if (plan.tasks.some(task => task.workload === "H1") && plan.options.masks?.includes("rc06")) {
|
|
throw new Error("An H1 replay needs the budget hard stop; do not combine it with --rc06-masked");
|
|
}
|
|
return { plan, keep: command.keep };
|
|
}
|
|
return { plan: planWave(waveIdFor(command.options.seed), command.options), keep: command.keep };
|
|
}
|
|
|
|
function run(command: string, args: string[], cwd: string, label: string) {
|
|
const result = spawnSync(command, args, { cwd, stdio: "inherit" });
|
|
if (result.status !== 0) throw new Error(`${label} failed (${command} ${args.join(" ")})`);
|
|
}
|
|
|
|
/**
|
|
* The bundled Daytona plugin is outside the root workspace; prepare it as CI
|
|
* does, and rebuild it whenever its sources (or the plugin SDK's) changed since
|
|
* the last build, so a worktree that moved to a new master tests that master's
|
|
* plugin rather than an older build.
|
|
*/
|
|
function ensureDaytonaPlugin(timeline: Timeline) {
|
|
const root = path.join(repositoryRoot, "packages/plugins/sandbox-providers/daytona");
|
|
// Inputs outside the plugin directory: the linked plugin SDK and the tsconfig chain both packages extend.
|
|
const extraInputs = [
|
|
...["src", "package.json", "tsconfig.json"].map(file => path.join(repositoryRoot, "packages/plugins/sdk", file)),
|
|
...["tsconfig.json", "tsconfig.base.json"].map(file => path.join(repositoryRoot, file)),
|
|
];
|
|
const state = pluginBuildState(root, extraInputs);
|
|
timeline.add("daytona_plugin_build_state", { reason: state.reason, inputHash: state.inputHash });
|
|
if (!state.needsBuild) return;
|
|
run("pnpm", ["install", "--ignore-workspace", "--frozen-lockfile", "--ignore-scripts"], root, "Daytona plugin install");
|
|
run(process.execPath, [path.join(repositoryRoot, "scripts/link-plugin-dev-sdk.mjs")], repositoryRoot, "Plugin SDK link");
|
|
run("pnpm", ["--dir", root, "build"], repositoryRoot, "Daytona plugin build");
|
|
writeFileSync(path.join(root, PLUGIN_BUILD_STAMP), `${state.inputHash}\n`);
|
|
timeline.add("daytona_plugin_built", { inputHash: state.inputHash });
|
|
}
|
|
|
|
/**
|
|
* `--env daytona`: extract the image's Linux runnerd and provider pack (cached
|
|
* per image digest, no Docker) and refuse to run a controller whose runner
|
|
* source differs from the image.
|
|
*/
|
|
async function prepareDaytona(plan: WavePlan, timeline: Timeline): Promise<Record<string, string>> {
|
|
const image = plan.options.daytonaImage!;
|
|
const artifacts = await prepareDaytonaImageArtifacts({ image, cacheRoot: defaultDaytonaImageCacheRoot(),
|
|
log: line => console.info(`[daytona-image] ${line}`) });
|
|
const { localContentId } = await assertControllerMatchesImage(artifacts, repositoryRoot);
|
|
timeline.add("daytona_image_matched", { image, contentId: artifacts.contentId, localContentId, sourceRevision: artifacts.sourceRevision,
|
|
runnerdSha256: artifacts.runnerdSha256, providerPackDigest: artifacts.providerPackDigest, platformManifestDigest: artifacts.platformManifestDigest });
|
|
ensureDaytonaPlugin(timeline);
|
|
timeline.add("daytona_vcpu_plan", { cpuPerSandbox: plan.options.daytonaCpu, concurrency: plan.options.concurrency,
|
|
taskVcpu: plan.options.concurrency * (plan.options.daytonaCpu ?? 0) });
|
|
return { PAPERCLIP_RUNNER_REMOTE_BINARY_PATH: artifacts.runnerdPath, PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH: artifacts.providerPackPath };
|
|
}
|
|
|
|
function preflight(plan: WavePlan) {
|
|
const missing = [...new Set([...plan.tasks.map(task => CREDENTIAL_FOR_ADAPTER[task.adapter]),
|
|
...(plan.options.env === "daytona" ? ["DAYTONA_API_KEY"] : [])])].filter(key => !process.env[key]);
|
|
if (missing.length) throw new Error(`Missing credentials ${missing.join(", ")}. Run: set -a; source ~/.secrets; set +a`);
|
|
const runner = process.env.PAPERCLIP_RUNNER_BINARY
|
|
?? path.join(repositoryRoot, "packages/paperclip-runner/runner/target/debug/paperclip-runnerd");
|
|
if (!existsSync(runner)) throw new Error(`Runner binary missing at ${runner}. Run: pnpm --filter @paperclipai/paperclip-runner run build:runner-binaries`);
|
|
// K14: the older checkout must be prepared (installed and built) before the wave, like this one.
|
|
const upgradeFrom = plan.options.upgradeFrom;
|
|
// One runner binary for both builds would not test the older runner's saved state.
|
|
if (upgradeFrom && process.env.PAPERCLIP_RUNNER_BINARY) {
|
|
throw new Error("--upgrade-from runs each build's own runner; unset PAPERCLIP_RUNNER_BINARY");
|
|
}
|
|
if (upgradeFrom) {
|
|
for (const required of ["tests/runner-e2e/server-entry.ts", "node_modules", "packages/paperclip-runner/runner/target/debug/paperclip-runnerd"]) {
|
|
if (!existsSync(path.join(upgradeFrom, required))) {
|
|
throw new Error(`--upgrade-from ${upgradeFrom} is not a prepared checkout (missing ${required}). `
|
|
+ "Create it with `git worktree add <dir> <commit>`, then run `node scripts/agent-preflight.mjs` in it.");
|
|
}
|
|
}
|
|
if (path.resolve(upgradeFrom) === repositoryRoot) throw new Error("--upgrade-from must be a different checkout than the one running the harness");
|
|
const oldBuild = spawnSync(process.execPath, [path.join(upgradeFrom, "packages/paperclip-runner/scripts/build-verified-provider-entrypoints.mjs")],
|
|
{ cwd: upgradeFrom, stdio: "inherit" });
|
|
if (oldBuild.status !== 0) throw new Error("Verified provider entrypoint build failed in the --upgrade-from checkout");
|
|
}
|
|
// Same provider bundle preparation as tests/runner-e2e/verified-provider-bundles.setup.mjs.
|
|
const build = spawnSync(process.execPath, [path.join(repositoryRoot, "packages/paperclip-runner/scripts/build-verified-provider-entrypoints.mjs")],
|
|
{ cwd: repositoryRoot, stdio: "inherit" });
|
|
if (build.status !== 0) throw new Error("Verified provider entrypoint build failed");
|
|
}
|
|
|
|
async function main(): Promise<number> {
|
|
const { plan, keep } = await loadPlan();
|
|
const waveDir = path.join(resultsRoot, plan.waveId);
|
|
await mkdir(waveDir, { recursive: true, mode: 0o700 });
|
|
await writeFile(path.join(waveDir, "plan.json"), `${JSON.stringify(plan, null, 2)}\n`, { mode: 0o600 });
|
|
const timeline = new Timeline(path.join(waveDir, "timeline.jsonl"));
|
|
const gitSha = git(["rev-parse", "HEAD"]);
|
|
const gitDirty = git(["status", "--porcelain"]).length > 0;
|
|
// Builds and branch switches must happen before the wave, never during it.
|
|
const guardedOutputs = guardedRunnerOutputs(repositoryRoot);
|
|
let startFingerprint: string | null = null;
|
|
let worktreeChanged = false;
|
|
const checkWorktree = (at: string) => {
|
|
if (worktreeChanged || startFingerprint === null || worktreeFingerprint(repositoryRoot, guardedOutputs) === startFingerprint) return;
|
|
worktreeChanged = true;
|
|
timeline.add("worktree_changed_mid_wave", { at, gitSha: git(["rev-parse", "HEAD"]) });
|
|
};
|
|
timeline.add("wave_planned", { waveId: plan.waveId, seed: plan.seed, gitSha, gitDirty, replayOf: plan.replayOf ?? null,
|
|
tasks: plan.tasks.map(task => `${task.taskId}:${task.adapter}/${task.workload}/${task.fault}${task.trigger ? `^${task.trigger}+${task.triggerDelayMs ?? 0}ms` : ""}@${task.fireAfterMs}ms${task.holdMs ? ` hold ${task.holdMs}ms` : ""}`),
|
|
...(plan.storm ? { storm: { durationMs: plan.storm.durationMs, faults: plan.storm.faults.map(fault => `${fault.kind}+${fault.gapMs}ms${fault.holdMs ? ` hold ${fault.holdMs}ms` : ""}`) } } : {}) });
|
|
preflight(plan);
|
|
const serverEnvironment: Record<string, string> = plan.options.env === "daytona" ? await prepareDaytona(plan, timeline) : {};
|
|
// The suite drives the API only; the Vite dev UI only slows every boot and restart.
|
|
if (plan.options.crashPoint && !plan.replayOf) serverEnvironment.SERVE_UI = "false";
|
|
// Baseline only after the harness's own build steps (preflight rewrites the runner's dist).
|
|
startFingerprint = worktreeFingerprint(repositoryRoot, guardedOutputs);
|
|
|
|
const supervisor = await ChaosSupervisor.create({ repositoryRoot, waveDir, timeline,
|
|
...(plan.options.upgradeFrom ? { upgradeFrom: plan.options.upgradeFrom } : {}) });
|
|
// Crash-point suite: one booted server, one point at a time. Each point gets
|
|
// its own batch of tasks; the point is armed once the batch launched and the
|
|
// server kills itself at its nth hit (restarted like K1). Invariants are
|
|
// checked when the batch rests, as violations new since the point was armed.
|
|
// A replay fires the recorded faults (the crash point's K1 restart included)
|
|
// at their offsets; re-arming the point could miss its nth hit with fewer tasks.
|
|
const crashPoint = plan.replayOf ? null : plan.options.crashPoint ?? null;
|
|
const crashPoints = crashPoint ? crashPoint.split(",") : [];
|
|
const batchSize = crashPoints.length ? Math.max(1, Math.floor(plan.tasks.length / crashPoints.length)) : 0;
|
|
type PointResult = { point: string; token: string; taskIds: string[]; armedAt: number; restedAt: number | null;
|
|
fault: FaultReceipt | null; invariants: InvariantCheck | null };
|
|
const pointResults: Array<PointResult & { baseline?: Set<string> }> = [];
|
|
let currentPoint: (PointResult & { baseline: Set<string> }) | null = null;
|
|
let databaseUrl: string | null = null;
|
|
const violationKey = (violation: { rule: string; kind: string; recordId: string }) => `${violation.rule}:${violation.kind}:${violation.recordId}`;
|
|
if (crashPoint) {
|
|
if (plan.tasks.length !== batchSize * crashPoints.length) throw new Error(`--tasks must be a multiple of the ${crashPoints.length} crash point(s)`);
|
|
supervisor.crashPointHoldMs = plan.options.crashPointHoldMs ?? 0;
|
|
supervisor.useCrashPointControl({ controlPath: path.join(supervisor.paths.temporaryRoot, "crash-point.control"),
|
|
receiptPath: path.join(waveDir, "crash-point-receipts.jsonl") });
|
|
}
|
|
let sweeper: DaytonaWaveSweeper | null = null;
|
|
let sandboxSweep: DaytonaSweepResult | null = null;
|
|
let interrupted = false;
|
|
const onSignal = (signal: NodeJS.Signals) => { if (!interrupted) { interrupted = true; timeline.add("harness_interrupted", { signal }); } };
|
|
process.on("SIGINT", onSignal); process.on("SIGTERM", onSignal);
|
|
const faults: FaultReceipt[] = [];
|
|
// Written after every fault, so an interrupted or wedged storm can still replay what crossed each task.
|
|
const taskFaultLog: TaskFaultLog = {};
|
|
const persistFaultLog = async () => {
|
|
await writeFile(path.join(waveDir, "faults.json"), `${JSON.stringify(faults, null, 2)}\n`, { mode: 0o600 }).catch(() => undefined);
|
|
await writeFile(path.join(waveDir, TASK_FAULT_LOG), `${JSON.stringify(taskFaultLog, null, 2)}\n`, { mode: 0o600 }).catch(() => undefined);
|
|
};
|
|
const faultWork: Promise<void>[] = [];
|
|
const lines: LedgerLine[] = [];
|
|
let anyFailure = false;
|
|
let speedSection: string | null = null;
|
|
let harnessError: unknown;
|
|
process.on("uncaughtException", error => {
|
|
if (isSocketTypeOfServiceRace(error)) { timeline.add("harness_socket_race_ignored", { error: String(error) }); return; }
|
|
// A reader of our stdout went away. Keep running (results go to files); the
|
|
// timeline records harness_stdout_lost once on its next entry.
|
|
if (isBrokenStdoutError(error)) { markTimelineEchoBroken(error); return; }
|
|
timeline.add("harness_uncaught_exception", { error: error.stack ?? String(error) });
|
|
harnessError ??= error;
|
|
interrupted = true;
|
|
});
|
|
// Spend is read once per wave: after the tasks, or during cleanup when the wave stopped early.
|
|
let spendTarget: { api: ChaosApi; companyId: string } | null = null;
|
|
let spendRecorded = false;
|
|
const recordWaveSpend = async () => {
|
|
if (!spendTarget || spendRecorded) return;
|
|
spendRecorded = true;
|
|
// Each wave owns its company, so all-time spend is exactly this wave, even across a month boundary.
|
|
const spend = await spendTarget.api.get<Record<string, unknown>>(`/api/companies/${spendTarget.companyId}/costs/summary?period=all`)
|
|
.catch(error => ({ error: String(error) }));
|
|
timeline.add("wave_spend", { spend });
|
|
console.info(`Wave spend: ${JSON.stringify(spend)}`);
|
|
};
|
|
let faultFailures = 0;
|
|
let faultsQueued = 0;
|
|
try {
|
|
await supervisor.start(serverEnvironment);
|
|
const api = new ChaosApi(supervisor.baseUrl);
|
|
const lab = await seedChaosLab({ api, env: plan.options.env, waitForReadyAgents: true, cancelled: () => interrupted, daytonaImage: plan.options.daytonaImage, daytonaCpu: plan.options.daytonaCpu,
|
|
daytonaReuseLease: plan.options.daytonaReuseLease,
|
|
adapters: [...new Set([...plan.tasks.map(task => task.adapter),
|
|
...(plan.tasks.some(task => task.workload === "M1") ? plan.options.adapters : [])])],
|
|
concurrency: plan.options.concurrency, budgetCents: plan.options.budgetCents, masks: plan.options.masks,
|
|
fixtureRoot: path.join(supervisor.paths.temporaryRoot, "fixtures"), workspaceRepo: plan.options.workspaceRepo,
|
|
workspaceCommit: plan.options.workspaceCommit, waveId: plan.waveId, timeline,
|
|
adapterConfigOverrides: chaosLifecycleOverrides(plan.options.lifecycle),
|
|
onEnvironment: owned => {
|
|
sweeper = new DaytonaWaveSweeper({ apiKey: process.env.DAYTONA_API_KEY!, companyId: owned.companyId, environmentId: owned.environmentId, api });
|
|
} });
|
|
spendTarget = { api, companyId: lab.companyId };
|
|
// The instance root holds the secrets master key and the database: oracle code may not read it.
|
|
const protectedRoots = [os.homedir(), supervisor.paths.temporaryRoot, resultsRoot];
|
|
const observers = plan.tasks.map(task => new TaskObserver(task, api, lab, timeline, supervisor, protectedRoots));
|
|
const pending = [...observers];
|
|
const launched: TaskObserver[] = [];
|
|
const active: TaskObserver[] = [];
|
|
const lastPoll = new Map<TaskObserver, number>();
|
|
let lastDoneAt = Date.now();
|
|
// K13: storm faults fire on their own clock while tasks keep launching; then the storm drains.
|
|
const storm = plan.storm ? new StormRun(plan.storm, Date.now()) : null;
|
|
if (storm) timeline.add("storm_started", { durationMs: storm.plan.durationMs, plannedFaults: storm.plan.faults.length });
|
|
// Faults are serialized; each one hits every task in flight when it starts.
|
|
const fireFault = (kind: FaultKind, holdMs: number, requestedBy: string, exposed: readonly TaskObserver[],
|
|
trigger?: { trigger: TriggerId; delayMs: number }, onSettled?: () => void) => {
|
|
faultsQueued += 1;
|
|
faultWork.push(supervisor.fault(kind, holdMs, requestedBy, trigger).finally(() => { faultsQueued -= 1; onSettled?.(); }).then(receipt => {
|
|
faults.push(receipt);
|
|
checkWorktree(receipt.faultId);
|
|
for (const candidate of exposed) {
|
|
candidate.noteFault(receipt.faultId, receipt.startedAt);
|
|
candidate.extendDeadline((receipt.readyAt ?? Date.now()) - receipt.startedAt);
|
|
taskFaultLog[candidate.plan.taskId] = { createdAt: candidate.record.createdAt, faultIds: [...candidate.record.faultIds],
|
|
...(candidate.record.serverBuildAtLaunch ? { serverBuildAtLaunch: candidate.record.serverBuildAtLaunch } : {}) };
|
|
}
|
|
void persistFaultLog();
|
|
}, (error: Error & { receipt?: FaultReceipt }) => {
|
|
if (error.receipt) {
|
|
// A failed fault still crossed the tasks in flight; replay must fire it too.
|
|
const receipt = error.receipt;
|
|
faults.push(receipt);
|
|
for (const candidate of exposed) {
|
|
candidate.noteFault(receipt.faultId, receipt.startedAt);
|
|
taskFaultLog[candidate.plan.taskId] = { createdAt: candidate.record.createdAt, faultIds: [...candidate.record.faultIds],
|
|
...(candidate.record.serverBuildAtLaunch ? { serverBuildAtLaunch: candidate.record.serverBuildAtLaunch } : {}) };
|
|
}
|
|
void persistFaultLog();
|
|
}
|
|
faultFailures += 1;
|
|
timeline.add("fault_error", { taskId: requestedBy, error: error.message });
|
|
}));
|
|
};
|
|
|
|
supervisor.onCrashPoint = receipt => {
|
|
const owner = pointResults.find(result => result.token === receipt.crashPoint?.token);
|
|
if (owner) owner.fault = receipt;
|
|
faults.push(receipt);
|
|
if (receipt.error) { faultFailures += 1; timeline.add("fault_error", { taskId: "crash-point", error: receipt.error }); }
|
|
for (const candidate of active.filter(observer => !observer.done)) {
|
|
candidate.noteFault(receipt.faultId, receipt.startedAt);
|
|
candidate.extendDeadline((receipt.readyAt ?? Date.now()) - receipt.startedAt);
|
|
taskFaultLog[candidate.plan.taskId] = { createdAt: candidate.record.createdAt, faultIds: [...candidate.record.faultIds],
|
|
...(candidate.record.serverBuildAtLaunch ? { serverBuildAtLaunch: candidate.record.serverBuildAtLaunch } : {}) };
|
|
}
|
|
checkWorktree(receipt.faultId);
|
|
void persistFaultLog();
|
|
};
|
|
|
|
const invariantsNow = async () => {
|
|
if (!databaseUrl) {
|
|
const config = JSON.parse(readFileSync(supervisor.paths.configPath, "utf8")) as { database?: { connectionString?: string } };
|
|
databaseUrl = config.database?.connectionString ?? null;
|
|
if (!databaseUrl) throw new Error("Crash-point wave: the instance config has no database connection string");
|
|
}
|
|
return checkPlanInvariants({ connectionString: databaseUrl, companyId: lab.companyId,
|
|
paperclipHome: supervisor.paths.home, instanceId: "default" });
|
|
};
|
|
const startCrashPoint = async (batch: TaskObserver[]) => {
|
|
const index = pointResults.length;
|
|
const spec = crashPoints[index]!;
|
|
// Violations already present belong to earlier points, not this one.
|
|
const before = await invariantsNow();
|
|
const baseline = new Set(before.violations.map(violationKey));
|
|
// Stage the batch unassigned (no wake yet), arm the point, then assign:
|
|
// no task's work can start before the point is armed, and an early
|
|
// crash cannot fail a later task's creation. The previous batch has
|
|
// rested, so only this batch's work can hit the point.
|
|
for (const observer of batch) await observer.stage();
|
|
const result = { point: spec, token: `p${String(index + 1).padStart(2, "0")}`, taskIds: batch.map(observer => observer.plan.taskId),
|
|
armedAt: Date.now(), restedAt: null, fault: null, invariants: null, baseline };
|
|
pointResults.push(result);
|
|
supervisor.armCrashPoint(result.token, spec);
|
|
for (const observer of batch) {
|
|
launched.push(observer);
|
|
await observer.release();
|
|
if (!observer.done) active.push(observer);
|
|
}
|
|
return result;
|
|
};
|
|
const finishCrashPoint = async (point: PointResult & { baseline: Set<string> }) => {
|
|
supervisor.disarmCrashPoint();
|
|
point.restedAt = Date.now();
|
|
const check = await invariantsNow();
|
|
const fresh = check.violations.filter(violation => !point.baseline.has(violationKey(violation)));
|
|
point.invariants = { ...check, violations: fresh, byInvariant: summarizeViolations(fresh) };
|
|
timeline.add("crash_point_settled", { point: point.point, token: point.token, reached: point.fault?.crashPoint ?? null,
|
|
seconds: Math.round((point.restedAt - point.armedAt) / 1000), byInvariant: point.invariants.byInvariant,
|
|
errors: check.errors.map(error => redactKnownSecrets(error, knownSecretValues())) });
|
|
};
|
|
|
|
while (!interrupted && (pending.length || active.length)) {
|
|
const now = Date.now();
|
|
if (storm?.endIfDue(now)) {
|
|
// Unlaunched pool tasks are not part of the wave; the in-flight ones drain without new faults.
|
|
timeline.add("storm_ended", { faultsFired: storm.state.fired, inFlight: active.length, unlaunched: pending.length });
|
|
pending.length = 0;
|
|
}
|
|
// A failed fault or an unexpected exit leaves the server not ready.
|
|
// Recover within a bound, or fail the wave instead of waiting forever.
|
|
if (!supervisor.serverReady && !supervisor.faultInProgress) await supervisor.ensureServing();
|
|
if (crashPoint) {
|
|
// One point at a time: settle the previous batch, then launch and arm the next.
|
|
if (!active.length && supervisor.serverReady && !supervisor.faultInProgress) {
|
|
if (currentPoint) { await finishCrashPoint(currentPoint); currentPoint = null; }
|
|
if (pending.length) currentPoint = await startCrashPoint(pending.splice(0, batchSize));
|
|
}
|
|
}
|
|
// Recheck the storm clock per launch: ensureServing or a slow launch can cross the storm's end.
|
|
while (!crashPoint && active.length < plan.options.concurrency && pending.length && supervisor.serverReady && !supervisor.faultInProgress
|
|
&& (!storm || storm.mayLaunch(Date.now()))) {
|
|
const next = pending.shift()!;
|
|
launched.push(next);
|
|
await next.launch();
|
|
if (!next.done) active.push(next);
|
|
}
|
|
await Promise.all(active.filter(observer => now - (lastPoll.get(observer) ?? 0) >= plan.options.pollMs).map(async observer => {
|
|
lastPoll.set(observer, now);
|
|
await observer.poll(now);
|
|
}));
|
|
for (const observer of active) {
|
|
// A crash-point wave has no scheduled faults: the armed point is the fault.
|
|
if (crashPoint) break;
|
|
if (faultsQueued || supervisor.faultInProgress || !supervisor.serverReady) break;
|
|
// A storm's drain gets no new fault; an unfired trigger is recorded as missed at rest.
|
|
if (storm && !storm.mayLaunch(Date.now())) break;
|
|
const due = observer.faultDue(Date.now());
|
|
if (!due) continue;
|
|
if (observer.record.restAt !== null) { timeline.add("fault_skipped_task_resting", { taskId: observer.plan.taskId, fault: due.kind }); continue; }
|
|
const exposed = active.filter(candidate => !candidate.done);
|
|
await Promise.all(exposed.map(candidate => candidate.snapshotBeforeFault()));
|
|
if (!(await observer.confirmTrigger(due, Date.now()))) continue;
|
|
if (storm && !storm.mayLaunch(Date.now())) {
|
|
observer.markTriggerMissed(due);
|
|
timeline.add("fault_skipped_storm_ended", { taskId: observer.plan.taskId, fault: due.kind });
|
|
break;
|
|
}
|
|
const trigger = due.trigger && due.trigger !== "time" ? { trigger: due.trigger, delayMs: due.delayMs ?? 0 } : undefined;
|
|
if (trigger) timeline.add("trigger_fired", { taskId: observer.plan.taskId, fault: due.kind, ...trigger });
|
|
// Inside a storm a triggered fault is a restart too: the next storm gap runs from its settle.
|
|
fireFault(due.kind, due.holdMs, observer.plan.taskId, exposed, trigger, storm ? () => storm.settled(Date.now()) : undefined);
|
|
}
|
|
if (storm && !faultsQueued && !supervisor.faultInProgress && supervisor.serverReady) {
|
|
const due = storm.due(Date.now());
|
|
if (due) {
|
|
const exposed = active.filter(candidate => !candidate.done);
|
|
await Promise.all(exposed.map(candidate => candidate.snapshotBeforeFault()));
|
|
// The snapshots take time; a storm that ended meanwhile is draining and gets no new fault.
|
|
if (storm.commit(due, Date.now())) {
|
|
timeline.add("storm_fault", { index: storm.state.fired, kind: due.kind, gapMs: due.gapMs, holdMs: due.holdMs, exposed: exposed.map(candidate => candidate.plan.taskId) });
|
|
fireFault(due.kind, due.holdMs, "storm", exposed, undefined, () => storm.settled(Date.now()));
|
|
}
|
|
}
|
|
}
|
|
for (let index = active.length - 1; index >= 0; index -= 1) {
|
|
if (active[index]!.done) { active.splice(index, 1); lastDoneAt = Date.now(); }
|
|
}
|
|
await delay(1_000);
|
|
}
|
|
await Promise.all(faultWork);
|
|
checkWorktree("wave_end");
|
|
if (worktreeChanged) harnessError ??= new Error("The worktree changed during the wave (build, install or branch switch); later restarts may have run different code");
|
|
// A storm whose task pool ran dry early did not cover its storm; it is not a passing wave.
|
|
if (storm && !interrupted && storm.exhaustedEarly(Date.now())) {
|
|
timeline.add("storm_pool_exhausted", { faultsFired: storm.state.fired, launched: launched.length });
|
|
harnessError ??= new Error(`Storm task pool (--tasks ${plan.options.tasks}) ran out before the storm ended; raise --tasks`);
|
|
}
|
|
// An injected fault that did not complete invalidates the wave, even when
|
|
// bounded recovery brought the server back and the tasks passed.
|
|
if (faultFailures) harnessError ??= new Error(`${faultFailures} injected fault(s) failed; see fault_error in the timeline`);
|
|
if (interrupted) {
|
|
harnessError ??= new Error("Harness interrupted before the wave finished");
|
|
for (const observer of launched) observer.markUnfinished();
|
|
}
|
|
|
|
// R6: let finalizers settle, then sweep for anything still pending.
|
|
const quietFor = Date.now() - lastDoneAt;
|
|
if (quietFor < QUIET_BEFORE_SWEEP_MS) await delay(QUIET_BEFORE_SWEEP_MS - quietFor);
|
|
await recordWaveSpend();
|
|
timeline.add("wave_sweep", {});
|
|
if (sweeper) {
|
|
sandboxSweep = await (sweeper as DaytonaWaveSweeper).sweep();
|
|
timeline.add("daytona_sweep", { ...sandboxSweep });
|
|
}
|
|
const attributed = new Set<string>();
|
|
const secrets = knownSecretValues();
|
|
for (const observer of launched) {
|
|
observer.record.sweep = await observer.sweep();
|
|
if (sandboxSweep) {
|
|
const runIds = new Set(observer.record.samples.flatMap(sample => (sample.runs ?? []).map(run => run.id)));
|
|
const leaks = leaksForTask(sandboxSweep, runIds, observer.record.issueId);
|
|
for (const leak of leaks) attributed.add(leak);
|
|
observer.record.sweep.daytonaLeaks = leaks;
|
|
}
|
|
let artifacts: OracleArtifacts | null = null;
|
|
if (observer.record.issueId) artifacts = await observer.collectArtifacts().catch(() => null);
|
|
const oracle = artifacts ? observer.definition.grade(observer.plan.nonce, artifacts, observer.workloadAgents())
|
|
: { verdict: "fail" as const, checks: [{ name: "artifacts_collected", ok: false, detail: observer.record.launchError ?? "collection failed" }] };
|
|
const grade = gradeTask(observer.record, faults, observer.definition.budgetMs, oracle);
|
|
let evidence: string | null = null;
|
|
if (!grade.pass) {
|
|
anyFailure = true;
|
|
evidence = await writeEvidenceBundle({ directory: path.join(waveDir, "evidence", observer.plan.taskId), api, plan,
|
|
record: observer.record, grade, artifacts, faults, timeline: timeline.entries, serverLogPath: supervisor.paths.serverLogPath,
|
|
gitSha, gitDirty, issue: observer.issue, secrets }).catch(error => { timeline.add("evidence_failed", { taskId: observer.plan.taskId, error: String(error) }); return null; });
|
|
}
|
|
await writeFile(path.join(waveDir, `${observer.plan.taskId}.grade.json`), `${JSON.stringify({ record: observer.record, grade }, null, 2)}\n`, { mode: 0o600 });
|
|
lines.push(ledgerLine(plan, observer.record, grade, gitSha, evidence, faults));
|
|
timeline.add("task_graded", { taskId: observer.plan.taskId, pass: grade.pass, crossed: grade.crossed, signature: grade.signature,
|
|
rules: Object.fromEntries(Object.entries(grade.rules).map(([id, rule]) => [id, rule.verdict])), T: grade.T.verdict });
|
|
}
|
|
await writeFile(path.join(waveDir, "faults.json"), `${JSON.stringify(faults, null, 2)}\n`, { mode: 0o600 });
|
|
const speed = await (async () => {
|
|
const childTasks: Awaited<ReturnType<TaskObserver["childSpeedTasks"]>> = [];
|
|
for (const observer of launched) childTasks.push(...await observer.childSpeedTasks());
|
|
return collectWaveSpeed(api, plan.options.env, [...launched.flatMap(observer => observer.record.issueId
|
|
? [{ issueId: observer.record.issueId, adapter: observer.plan.adapter, samples: observer.record.samples }] : []), ...childTasks], faults);
|
|
})().catch(error => { timeline.add("wave_speed_failed", { error: String(error) }); anyFailure = true; return null; });
|
|
if (speed) {
|
|
speedSection = speedMarkdown(speed);
|
|
await writeFile(path.join(waveDir, "speed.json"), `${JSON.stringify(speed, null, 2)}\n`, { mode: 0o600 });
|
|
timeline.add("wave_speed", { breaches: speed.breaches, runBudgetExceeded: speed.runBudgetExceeded, singleRunBreaches: speed.singleRunBreaches,
|
|
rows: speed.rows.map(row => ({ adapter: row.adapter, metric: row.metric, start: row.start, n: row.measured.n,
|
|
p50: row.measured.p50, p95: row.measured.p95, breaches: row.breaches })) });
|
|
if (speed.breaches > 0 || speed.errors.length > 0) anyFailure = true;
|
|
}
|
|
if (crashPoint) {
|
|
if (currentPoint) { await finishCrashPoint(currentPoint); currentPoint = null; }
|
|
const byTask = new Map(lines.map(line => [line.taskId, line]));
|
|
for (const point of pointResults) {
|
|
if (point.invariants?.violations.length) anyFailure = true;
|
|
if (point.invariants?.errors.length) harnessError ??= new Error(`Invariant check incomplete at ${point.point}: ${point.invariants.errors.join("; ")}`);
|
|
}
|
|
// Evidence carries run and wake error text; CI uploads this file.
|
|
await writeFile(path.join(waveDir, "crash-point.json"), `${redactKnownSecrets(JSON.stringify({
|
|
schema: "paperclip.restart-chaos.crash-point/v2", gitSha, gitDirty,
|
|
// The server kills itself without warning, so no pre-fault workspace
|
|
// snapshot exists: R2 checks workspace failures, not lost files.
|
|
notMeasured: { R2_lost_files: "no pre-fault workspace snapshot: the crash point fires inside the server" },
|
|
points: pointResults.map(({ baseline: _baseline, ...point }) => ({
|
|
...(point as PointResult), reached: point.fault?.crashPoint ?? null,
|
|
tasks: point.taskIds.flatMap(id => { const line = byTask.get(id); return line ? [{ taskId: id, adapter: line.adapter,
|
|
workload: line.workload, pass: line.pass, signature: line.signature, rules: line.rules, ruleDetails: line.ruleDetails, T: line.T }] : []; }),
|
|
})),
|
|
}, null, 2), knownSecretValues())}\n`, { mode: 0o600 });
|
|
}
|
|
// R6 at wave level: a leak no task's runs account for (e.g. the setup probe) still fails the wave.
|
|
if (sandboxSweep) {
|
|
const unattributed = unattributedLeaks(sandboxSweep, attributed);
|
|
if (sandboxSweep.error || unattributed.length) {
|
|
anyFailure = true;
|
|
timeline.add("daytona_sweep_wave_leak", { error: sandboxSweep.error, unattributed });
|
|
}
|
|
}
|
|
} catch (error) {
|
|
harnessError = error;
|
|
timeline.add("harness_error", { error: error instanceof Error ? error.stack ?? error.message : String(error) });
|
|
} finally {
|
|
// Best effort for an interrupted or failed wave; the server may already be down.
|
|
if (supervisor.serverReady) await recordWaveSpend().catch(() => undefined);
|
|
// A failure here (for example writing the report) must not skip stopping the controller and Postgres.
|
|
try { if (sweeper) try {
|
|
const owned = sweeper as DaytonaWaveSweeper;
|
|
// An interrupted wave still reports what it leaked before cleanup.
|
|
sandboxSweep ??= await owned.sweep(0);
|
|
// Cleanup only through Paperclip; the harness never changes a sandbox.
|
|
const cleanup = await owned.cleanUp();
|
|
timeline.add("daytona_cleanup", cleanup);
|
|
console.info(`Daytona R6 sweep: ${sandboxSweep.liveSandboxes.length} live, ${sandboxSweep.archivedSandboxes.length} archived after release, `
|
|
+ `${sandboxSweep.orphanSandboxes.length} orphaned, `
|
|
+ `${sandboxSweep.heldLeases.length} held leases${sandboxSweep.error ? `, error ${sandboxSweep.error}` : ""}; `
|
|
+ `environment deleted: ${cleanup.environmentDeleted}${cleanup.environmentDeleteBlocked ? " (still blocked by a pending sandbox cleanup)" : ""}; still running after cleanup: ${cleanup.remainingSandboxes.join(", ") || "none"}`);
|
|
// A sandbox neither archived nor destroyed after cleanup is a leak for a person to act on.
|
|
if (cleanup.remainingSandboxes.length) anyFailure = true;
|
|
// A delete still blocked by pending sandbox cleanup after the settle window
|
|
// is a leak, not a harness error: fail the wave and keep the instance, whose
|
|
// database owns that cleanup work.
|
|
if (cleanup.environmentDeleteBlocked) {
|
|
anyFailure = true;
|
|
timeline.add("daytona_environment_delete_blocked", { detail: cleanup.environmentDeleteBlocked });
|
|
}
|
|
if (cleanup.errors.length) harnessError ??= new Error(`Daytona cleanup incomplete: ${cleanup.errors.join("; ")}`);
|
|
await writeFile(path.join(waveDir, "sandbox-sweep.json"), `${JSON.stringify({ sweep: sandboxSweep, cleanup }, null, 2)}\n`, { mode: 0o600 });
|
|
} catch (error) {
|
|
harnessError ??= error;
|
|
timeline.add("daytona_cleanup_failed", { error: String(error) });
|
|
} } finally {
|
|
try { await supervisor.stop(); } catch (error) {
|
|
// Incomplete cleanup is a harness failure; keep the instance for inspection.
|
|
harnessError ??= error;
|
|
timeline.add("supervisor_stop_failed", { error: String(error) });
|
|
}
|
|
}
|
|
if (lines.length) {
|
|
await appendLedger(resultsRoot, lines);
|
|
const summary = await writeSummary(resultsRoot, plan.waveId);
|
|
await writeFile(path.join(waveDir, "SUMMARY.md"), speedSection ? `${summary}\n${speedSection}` : summary, { mode: 0o600 });
|
|
console.info(`\n${summary}`);
|
|
}
|
|
if (keep || anyFailure || harnessError) timeline.add("instance_kept", { temporaryRoot: supervisor.paths.temporaryRoot });
|
|
else await rm(supervisor.paths.temporaryRoot, { recursive: true, force: true }).catch(() => undefined);
|
|
}
|
|
console.info(`Wave ${plan.waveId}: results ${waveDir}`);
|
|
for (const line of lines.filter(candidate => !candidate.pass)) console.info(` FAIL ${line.taskId} ${line.signature} → ${line.evidence ?? "no evidence"}\n replay: ${replayCommand(plan.waveId, line.taskId)}`);
|
|
if (harnessError) { console.error(harnessError); return 2; }
|
|
return anyFailure ? 1 : 0;
|
|
}
|
|
|
|
process.exit(await main().catch(error => { console.error(error); return 2; }));
|