Files
PaperClipAI/tests/restart-chaos/storage-trigger.ts
T
DottaandClaude Opus 5.5 2f59b9c67c test(chaos): fire a planned restart while the server is mid S3 transfer (#20451)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The restart-chaos harness restarts the control plane while agent
runs are in flight, and `--storage s3` runs the server against a
loopback MinIO behind a fault proxy
> - Planned restarts fire by time or by run events. A time-based restart
seldom lands while the server is moving bytes to or from the store
> - The riskiest storage moments are a workspace snapshot upload, a
snapshot restore, and a run-log journal write. A restart in the middle
of one must not lose or corrupt the workspace or the log
> - This pull request adds a storage-triggered restart: the planned
fault fires the moment the proxy sees a matching S3 request, and the
proxy holds that request so the restart interrupts it
> - The benefit is that restart-during-transfer becomes a repeatable
wave with receipts that name the interrupted request

## Linked Issues or Issue Description

**Subsystem affected**
Restart-chaos test harness (`tests/restart-chaos`), cloud storage mode.

**Problem or motivation**
A K1/K2/K4 restart fires on a timer or on a run trigger. With `--storage
s3`, the timer almost never lands during a snapshot upload, a snapshot
download, or a run-log write. So the harness does not prove that a
restart in the middle of a transfer is safe.

**Proposed solution**
Add `--storage-trigger <target>`. At its planned time, each task's fault
is armed. The next server request that matches the target fires it. The
proxy holds the request open (up to 60 s) while the harness snapshots
the tasks and restarts the server.

**Alternatives considered**
A new `--triggers storage` id. It needs edits to the observer (near its
1,000-line limit), the trigger registry and the delay table. The storage
trigger is a wave-level mode in a new module instead.

## What Changed

- New `tests/restart-chaos/storage-trigger.ts`: targets
`snapshot-upload`, `snapshot-part` (multipart `UploadPart`),
`snapshot-download` and `run-log`; the `StorageTrigger` that arms
faults, fires on a match, holds the request and records receipts.
- `storage-proxy.ts`: an `observe(listener)` hook. The listener sees
every request and can hold it (the existing latency path).
- `main.ts`, `config.ts`, `types.ts`: registry lines only.
`--storage-trigger` (needs `--storage s3` and `--triggers time`); a due
fault is armed instead of fired; the wave writes `storage-trigger.json`.
- `storage-trigger.json` records the matching request (method, path with
upload ids hidden, declared bytes, outcome `aborted` or `completed`,
status), every other request in flight, and the fault id. A fault that
never matched is recorded with `missedReason`.
- README: "Restart during a transfer".

## Verification

- `PAPERCLIP_IN_WORKTREE=false npx vitest run --config
tests/restart-chaos/vitest.config.ts` (25 files, 304 tests pass).
- Red/green: `storage-trigger.test.ts` against the master
`storage-proxy.ts` fails 3 of 6 tests (no hook, so nothing is held or
fired); it passes with this change.
- `node scripts/typecheck-test-projects.mjs tests/restart-chaos` passes.
- Live waves (local run-log, Daytona snapshot upload and download) are
recorded in a PR comment as they finish.

## Risks

- Low risk. Test harness only. Without `--storage-trigger`, the proxy
has no listener and behaves as before.
- A replay re-arms its faults at their observed offsets. It fires on the
next matching request, not on the exact same request.

## 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>
2026-11-10 02:40:54 -06:00

168 lines
9.4 KiB
TypeScript

import type { IncomingMessage, ServerResponse } from "node:http";
import type { FaultKind, FaultReceipt, WaveOptions, WorkloadId } from "./types.js";
import type { StorageRequestListener } from "./storage-proxy.js";
import type { RestartStep } from "./repeated-restarts.js";
export const STORAGE_TRIGGER_TARGETS = ["snapshot-upload", "snapshot-part", "snapshot-download", "run-log"] as const;
export type StorageTriggerTarget = typeof STORAGE_TRIGGER_TARGETS[number];
export const STORAGE_TRIGGER_HOLD_MS = 60_000;
export const STORAGE_TRIGGER_BOARD_WORKLOADS: readonly WorkloadId[] = ["G1", "H1", "Q1"];
export function storageTriggerMatches(target: StorageTriggerTarget, method: string, url: string, declaredBytes: number | null = null): boolean {
const [route = "", query = ""] = url.split("?", 2);
switch (target) {
case "snapshot-upload": return method === "PUT" && route.includes("/workspace-snapshots/") && (declaredBytes ?? 0) > 0;
case "snapshot-part": return method === "PUT" && route.includes("/workspace-snapshots/") && /(^|&)partNumber=/.test(query);
case "snapshot-download": return method === "GET" && route.includes("/workspace-snapshots/");
case "run-log": return (method === "PUT" || method === "POST") && route.includes("/run-logs/");
}
}
export function parseStorageTrigger(value: string, options: Pick<WaveOptions, "storage" | "stormMinutes" | "crashPoint" | "triggers" | "workloads" | "matrix">): StorageTriggerTarget {
if (options.storage !== "s3") throw new Error("--storage-trigger needs --storage s3");
if (options.stormMinutes || options.crashPoint) throw new Error("--storage-trigger arms each task's planned fault; a storm or crash-point wave has none");
if (options.matrix) throw new Error("--storage-trigger arms faults by time; --matrix plans trigger cells whose windows it would skip");
if ((options.triggers ?? []).some(trigger => trigger !== "time")) throw new Error("--storage-trigger replaces the fault's trigger; use it with --triggers time");
const board = options.workloads.filter(workload => STORAGE_TRIGGER_BOARD_WORKLOADS.includes(workload));
if (board.length) throw new Error(`--storage-trigger keeps a fault armed after its planned time; board-acting workloads (${board.join(", ")}) would act before the restart`);
if (!(STORAGE_TRIGGER_TARGETS as readonly string[]).includes(value)) {
throw new Error(`--storage-trigger takes one of ${STORAGE_TRIGGER_TARGETS.join(", ")}`);
}
return value as StorageTriggerTarget;
}
export function redactStorageUrl(url: string): string {
const [route = "", query] = url.split("?", 2);
if (query === undefined) return route;
const kept = query.split("&").filter(part => part && !/^x-amz-/i.test(part)).map(part => {
const [name = "", raw] = part.split("=", 2);
return name === "partNumber" || name === "x-id" || raw === undefined ? part : `${name}=…`;
});
return kept.length ? `${route}?${kept.join("&")}` : route;
}
export type StorageRequestOutcome = "in_flight" | "completed" | "aborted";
export interface StorageRequestRecord {
id: number; method: string; path: string; startedAt: number; declaredBytes: number | null;
held: boolean; outcome: StorageRequestOutcome; status: number | null; settledAt: number | null;
}
export interface ArmedStorageFault { taskId: string; kind: FaultKind; holdMs: number; armedAt: number; restartSteps?: RestartStep[] }
export interface StorageTriggerReceipt {
target: StorageTriggerTarget; taskId: string; kind: FaultKind; armedAt: number;
firedAt: number | null; missedAt: number | null; missedReason?: string;
request: StorageRequestRecord | null; inFlight: StorageRequestRecord[]; faultId?: string;
}
export interface StorageTriggerHooks {
canFire(): boolean;
isOpen(taskId: string): boolean;
fire(armed: ArmedStorageFault, request: StorageRequestRecord): Promise<void>;
record(kind: string, data: Record<string, unknown>): void;
}
export class StorageTrigger {
readonly receipts: StorageTriggerReceipt[] = [];
private readonly armed: Array<{ fault: ArmedStorageFault; receipt: StorageTriggerReceipt }> = [];
private readonly open = new Map<number, StorageRequestRecord>();
private nextId = 1;
private firing = false;
constructor(readonly target: StorageTriggerTarget, private readonly hooks: StorageTriggerHooks, private readonly holdMs = STORAGE_TRIGGER_HOLD_MS) {}
get busy(): boolean { return this.firing; }
arm(fault: ArmedStorageFault): void {
const receipt: StorageTriggerReceipt = { target: this.target, taskId: fault.taskId, kind: fault.kind, armedAt: fault.armedAt,
firedAt: null, missedAt: null, request: null, inFlight: [] };
this.receipts.push(receipt);
this.armed.push({ fault, receipt });
this.hooks.record("storage_trigger_armed", { taskId: fault.taskId, fault: fault.kind, target: this.target });
}
readonly listener: StorageRequestListener = (incoming: IncomingMessage, outgoing: ServerResponse) => {
const method = incoming.method ?? "GET";
const url = incoming.url ?? "/";
const length = Number(incoming.headers["content-length"]);
const request: StorageRequestRecord = { id: this.nextId++, method, path: redactStorageUrl(url), startedAt: Date.now(),
declaredBytes: Number.isFinite(length) ? length : null, held: false, outcome: "in_flight", status: null, settledAt: null };
this.open.set(request.id, request);
outgoing.on("close", () => {
this.open.delete(request.id);
request.outcome = outgoing.writableFinished ? "completed" : "aborted";
request.status = outgoing.headersSent ? outgoing.statusCode : null;
request.settledAt = Date.now();
});
if (!storageTriggerMatches(this.target, method, url, request.declaredBytes)) return;
const next = this.take(request.startedAt);
if (!next) return;
request.held = true;
this.firing = true;
next.receipt.firedAt = request.startedAt;
next.receipt.request = request;
next.receipt.inFlight = [...this.open.values()];
this.hooks.record("storage_trigger_fired", { taskId: next.fault.taskId, fault: next.fault.kind, target: this.target,
request: `${method} ${request.path}`, inFlight: next.receipt.inFlight.length });
void this.hooks.fire(next.fault, request).catch(error => {
this.hooks.record("storage_trigger_fire_failed", { taskId: next.fault.taskId, error: String(error) });
}).finally(() => { this.firing = false; });
return this.holdMs;
};
private take(now: number) {
if (this.firing || !this.armed.length || !this.hooks.canFire()) return null;
while (this.armed.length) {
const next = this.armed.shift()!;
if (this.hooks.isOpen(next.fault.taskId)) return next;
this.miss(next.receipt, now, "task_rested");
}
return null;
}
private miss(receipt: StorageTriggerReceipt, at: number, reason: string) {
receipt.missedAt = at;
receipt.missedReason = reason;
this.hooks.record("storage_trigger_missed", { taskId: receipt.taskId, fault: receipt.kind, target: this.target, reason });
}
finish(faults: readonly FaultReceipt[], now = Date.now()): StorageTriggerReceipt[] {
for (const { receipt } of this.armed.splice(0)) this.miss(receipt, now, this.hooks.isOpen(receipt.taskId) ? "no_matching_request" : "task_rested");
for (const receipt of this.receipts) {
if (receipt.firedAt === null) continue;
const fault = faults.filter(candidate => candidate.requestedBy === receipt.taskId && candidate.requestedAt >= receipt.firedAt!)
.sort((left, right) => left.requestedAt - right.requestedAt)[0];
if (fault) receipt.faultId = fault.faultId;
}
return this.receipts;
}
}
export function storageTriggerShortfall(receipts: readonly StorageTriggerReceipt[]): string | null {
const fired = receipts.filter(receipt => receipt.firedAt !== null);
if (!fired.length) return `--storage-trigger: no restart interrupted a matching transfer (${receipts.length} armed, none fired)`;
const unproven = fired.filter(receipt => !receipt.faultId || receipt.request?.outcome !== "aborted");
if (!unproven.length) return null;
return `--storage-trigger: ${unproven.length} fired restart(s) did not provably interrupt the held request: ${unproven
.map(receipt => `${receipt.taskId} fault=${receipt.faultId ?? "none"} outcome=${receipt.request?.outcome ?? "none"}`).join(", ")}`;
}
export function storageTriggerMarkdown(receipts: readonly StorageTriggerReceipt[]): string {
const fired = receipts.filter(receipt => receipt.firedAt !== null);
const proven = fired.filter(receipt => receipt.faultId && receipt.request?.outcome === "aborted");
const lines = [`## Storage trigger (${receipts[0]?.target ?? "none"})`, "",
`${proven.length} of ${receipts.length} armed faults interrupted a transfer; ${receipts.length - fired.length} not reached.`, "",
"| Task | Fault | Fault id | Result | Request |", "| --- | --- | --- | --- | --- |",
...receipts.map(receipt => `| ${receipt.taskId} | ${receipt.kind} | ${receipt.faultId ?? "-"} | ${receipt.firedAt === null
? `not reached (${receipt.missedReason ?? "unknown"})` : receipt.request?.outcome ?? "unknown"} | ${receipt.request
? `\`${receipt.request.method} ${receipt.request.path.split("/").slice(-3).join("/")}\`` : "-"} |`)];
return `${lines.join("\n")}\n`;
}
export async function exposedAfterLaunch<T extends { done: boolean }>(launching: Promise<unknown> | null, launched: readonly T[]): Promise<T[]> {
await launching;
return launched.filter(candidate => !candidate.done);
}