mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 00:26:28 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - The restart-chaos harness (`tests/restart-chaos`) restarts the
server during real agent runs and grades the recovery.
> - Several native recovery fixes only apply when a restart lands after
the run has started its native runner. Examples: a restart claim whose
lease lapses in a storage outage (#20529), and a K2 seconds after native
start on a remote sandbox.
> - On Daytona R1, workspace preparation takes 100-250 s and varies a
lot under storage faults. A time-based fault and `--storage-trigger
run-log` both land while the run is still preparing. In three waves,
none reached native start.
> - This pull request adds `--native-trigger`, which fires the planned
fault a set delay after the run's own `native.process_start_requested`
event or first provider event.
> - The benefit is that a wave can reliably restart the server at native
start and prove those recovery paths live.
## Linked Issues or Issue Description
Refs #20529, Refs #20514
**What happened?**
Waves `w-20261110T113240-s11936`, `w-20261110T120901-s11937` and
`w-20261110T124555-s11936` (Daytona R1, K2) were meant to restart the
server after native start. Every K2 landed during remote workspace
preparation, before native runtime selection. The drain took the legacy
interrupt path, so the native recovery path under test never ran.
**Expected behavior**
A wave can fire its planned fault a known number of seconds after the
run starts its native runner.
**Steps to reproduce**
1. Run a Daytona R1 wave with `--faults K2 --fault-after-seconds 110`,
optionally with `--storage-faults ST1`.
2. Read `server.log`. The graceful drain lists the runs under
`interruptedRunIds`, not `restartSuspendedRunIds`.
**Paperclip version or commit**
`master` at adb0e77a4c.
## What Changed
- New `tests/restart-chaos/native-trigger.ts`: `--native-trigger
native-start|provider-event[+<seconds>]` parsing and a `NativeTrigger`.
The trigger arms each task's planned fault at its planned time. Each
wave loop pass, it reads the task's running runs' events through the
board API with a per-run cursor. It fires the fault the given delay
after the first matching event created after arming. It records
receipts, misses (`task_rested`, `no_matching_event`), the fault id, a
summary section and a shortfall check.
- `config.ts`, `types.ts`, `main.ts`: registry lines only. They add the
option, construct the trigger next to `--storage-trigger`, arm faults
instead of firing them by time, tick the trigger in the wave loop, keep
the loop alive while it fires, and write `native-trigger.json` plus the
summary section.
- New `tests/restart-chaos/native-trigger.test.ts`: parsing and
conflicts, event matching, firing after the delay, ignoring events from
before arming and from non-running runs, waiting while another fault
runs, misses, and fault linking.
- `tests/restart-chaos/README.md`: a "Restart at native start" section.
## Verification
- `PAPERCLIP_IN_WORKTREE=false npx vitest run --config
tests/restart-chaos/vitest.config.ts
tests/restart-chaos/native-trigger.test.ts
tests/restart-chaos/storage-trigger.test.ts`: 16 passed. The new tests
fail on `master`, where the option and module do not exist.
- `node scripts/typecheck-test-projects.mjs tests/restart-chaos`:
passed.
- Test-only harness change; no product code changes. A live wave with
this trigger follows as the gate wave for #20529.
## Risks
- Low risk. This is test harness only. A wave without `--native-trigger`
takes no new code path except a no-op `tick` call.
- The trigger polls the board API once per wave loop pass (1 s) for at
most the armed tasks' running runs. That adds a little load on the
server under test.
## 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>
183 lines
10 KiB
TypeScript
183 lines
10 KiB
TypeScript
import type { FaultKind, FaultReceipt, WaveOptions, WorkloadId } from "./types.js";
|
|
import type { RestartStep } from "./repeated-restarts.js";
|
|
import { storageFaultRules, type StorageFaultWindow } from "./storage-chaos.js";
|
|
|
|
export const NATIVE_TRIGGER_TARGETS = ["native-start", "provider-event"] as const;
|
|
export type NativeTriggerTarget = typeof NATIVE_TRIGGER_TARGETS[number];
|
|
export interface NativeTriggerSpec { target: NativeTriggerTarget; delayMs: number; outageMs?: number }
|
|
|
|
export const NATIVE_TRIGGER_BOARD_WORKLOADS: readonly WorkloadId[] = ["G1", "H1", "Q1"];
|
|
export const NATIVE_START_EVENT = "native.process_start_requested";
|
|
const PROVIDER_EVENT_TYPES = new Set(["session.started", "turn.submitted", "turn.started", "turn.accepted"]);
|
|
const EVENT_PAGE = 200;
|
|
const EVENT_PAGES_PER_LOOK = 3;
|
|
|
|
export function nativeTriggerMatches(target: NativeTriggerTarget, eventType: string): boolean {
|
|
return target === "native-start" ? eventType === NATIVE_START_EVENT : PROVIDER_EVENT_TYPES.has(eventType);
|
|
}
|
|
|
|
export function parseNativeTrigger(value: string, options: Pick<WaveOptions, "storageTrigger" | "stormMinutes" | "crashPoint" | "triggers" | "workloads" | "matrix">): NativeTriggerSpec {
|
|
if (options.storageTrigger) throw new Error("--native-trigger and --storage-trigger both arm the planned fault; pick one");
|
|
if (options.stormMinutes || options.crashPoint) throw new Error("--native-trigger arms each task's planned fault; a storm or crash-point wave has none");
|
|
if (options.matrix) throw new Error("--native-trigger arms faults by time; --matrix plans trigger cells whose windows it would skip");
|
|
if ((options.triggers ?? []).some(trigger => trigger !== "time")) throw new Error("--native-trigger replaces the fault's trigger; use it with --triggers time");
|
|
const board = options.workloads.filter(workload => NATIVE_TRIGGER_BOARD_WORKLOADS.includes(workload));
|
|
if (board.length) throw new Error(`--native-trigger keeps a fault armed after its planned time; board-acting workloads (${board.join(", ")}) would act before the restart`);
|
|
const match = /^([a-z-]+)(?:\+(\d+))?$/.exec(value);
|
|
const target = match?.[1];
|
|
const seconds = match?.[2] === undefined ? 0 : Number(match[2]);
|
|
if (!target || !(NATIVE_TRIGGER_TARGETS as readonly string[]).includes(target) || seconds > 600) {
|
|
throw new Error(`--native-trigger takes ${NATIVE_TRIGGER_TARGETS.join(" or ")}, optionally +<seconds> (0-600), for example native-start+5`);
|
|
}
|
|
return { target: target as NativeTriggerTarget, delayMs: seconds * 1000 };
|
|
}
|
|
|
|
export function parseNativeTriggerOutage(value: string, options: Pick<WaveOptions, "nativeTrigger" | "storage" | "storageFaults">): number {
|
|
if (!options.nativeTrigger) throw new Error("--native-trigger-outage needs --native-trigger");
|
|
if (options.storage !== "s3") throw new Error("--native-trigger-outage needs --storage s3");
|
|
if (options.storageFaults?.length) throw new Error("--native-trigger-outage starts its own ST1 window; drop --storage-faults");
|
|
const seconds = /^\d+$/.test(value) ? Number(value) : NaN;
|
|
if (!(seconds >= 10 && seconds <= 900)) throw new Error("--native-trigger-outage takes 10 to 900 seconds");
|
|
return seconds * 1000;
|
|
}
|
|
|
|
export function triggeredOutageWindow(startMs: number, durationMs: number): StorageFaultWindow {
|
|
return { kind: "ST1", startMs, durationMs, rules: storageFaultRules("ST1", () => 0) };
|
|
}
|
|
|
|
export interface ArmedNativeFault { taskId: string; kind: FaultKind; holdMs: number; armedAt: number; restartSteps?: RestartStep[] }
|
|
|
|
export interface NativeTriggerReceipt {
|
|
target: NativeTriggerTarget; delayMs: number; taskId: string; kind: FaultKind; armedAt: number;
|
|
seenAt: number | null; runId: string | null; eventType: string | null;
|
|
firedAt: number | null; missedAt: number | null; missedReason?: string; faultId?: string;
|
|
}
|
|
|
|
export function runningRunIds(rows: readonly unknown[] | null): string[] {
|
|
return (rows ?? []).flatMap(row => {
|
|
const run = row as { runId?: unknown; id?: unknown; status?: unknown } | null;
|
|
const id = run?.runId ?? run?.id;
|
|
return typeof id === "string" && run?.status === "running" ? [id] : [];
|
|
});
|
|
}
|
|
|
|
export interface NativeTriggerEvent { seq: number; eventType: string; createdAt: string }
|
|
|
|
export interface NativeTriggerHooks {
|
|
canFire(): boolean;
|
|
isOpen(taskId: string): boolean;
|
|
issueId(taskId: string): string | null;
|
|
runs(issueId: string): Promise<unknown[] | null>;
|
|
events(runId: string, afterSeq: number, limit: number): Promise<NativeTriggerEvent[] | null>;
|
|
fire(armed: ArmedNativeFault): Promise<void>;
|
|
record(kind: string, data: Record<string, unknown>): void;
|
|
}
|
|
|
|
export class NativeTrigger {
|
|
readonly receipts: NativeTriggerReceipt[] = [];
|
|
private readonly armed: Array<{ fault: ArmedNativeFault; receipt: NativeTriggerReceipt }> = [];
|
|
private readonly cursors = new Map<string, number>();
|
|
private firing = false;
|
|
|
|
constructor(readonly spec: NativeTriggerSpec, private readonly hooks: NativeTriggerHooks) {}
|
|
|
|
get busy(): boolean { return this.firing || this.armed.some(entry => entry.receipt.seenAt !== null); }
|
|
|
|
arm(fault: ArmedNativeFault): void {
|
|
const receipt: NativeTriggerReceipt = { target: this.spec.target, delayMs: this.spec.delayMs, taskId: fault.taskId,
|
|
kind: fault.kind, armedAt: fault.armedAt, seenAt: null, runId: null, eventType: null, firedAt: null, missedAt: null };
|
|
this.receipts.push(receipt);
|
|
this.armed.push({ fault, receipt });
|
|
this.hooks.record("native_trigger_armed", { taskId: fault.taskId, fault: fault.kind, target: this.spec.target, delayMs: this.spec.delayMs });
|
|
}
|
|
|
|
async tick(now = Date.now()): Promise<void> {
|
|
if (this.firing) return;
|
|
for (const entry of [...this.armed]) {
|
|
if (!this.hooks.isOpen(entry.fault.taskId)) {
|
|
this.armed.splice(this.armed.indexOf(entry), 1);
|
|
this.miss(entry.receipt, now, entry.receipt.seenAt === null ? "task_rested" : "task_rested_after_event");
|
|
continue;
|
|
}
|
|
if (entry.receipt.seenAt === null) await this.look(entry.receipt);
|
|
const { seenAt } = entry.receipt;
|
|
if (seenAt === null || now < seenAt + this.spec.delayMs || !this.hooks.canFire()) continue;
|
|
this.armed.splice(this.armed.indexOf(entry), 1);
|
|
entry.receipt.firedAt = now;
|
|
this.firing = true;
|
|
this.hooks.record("native_trigger_fired", { taskId: entry.fault.taskId, fault: entry.fault.kind, target: this.spec.target,
|
|
runId: entry.receipt.runId, eventType: entry.receipt.eventType, afterEventMs: now - seenAt });
|
|
void this.hooks.fire(entry.fault).catch(error => {
|
|
this.hooks.record("native_trigger_fire_failed", { taskId: entry.fault.taskId, error: String(error) });
|
|
}).finally(() => { this.firing = false; });
|
|
return;
|
|
}
|
|
}
|
|
|
|
private async look(receipt: NativeTriggerReceipt): Promise<void> {
|
|
const issueId = this.hooks.issueId(receipt.taskId);
|
|
if (!issueId) return;
|
|
const runs = await this.hooks.runs(issueId).catch(() => null);
|
|
for (const runId of runningRunIds(runs)) {
|
|
let afterSeq = this.cursors.get(runId) ?? 0;
|
|
for (let page = 0; page < EVENT_PAGES_PER_LOOK; page += 1) {
|
|
const events = await this.hooks.events(runId, afterSeq, EVENT_PAGE).catch(() => null);
|
|
if (!events?.length) break;
|
|
for (const event of events) {
|
|
afterSeq = Math.max(afterSeq, event.seq);
|
|
const at = Date.parse(event.createdAt);
|
|
if (receipt.seenAt === null && Number.isFinite(at) && at >= receipt.armedAt && nativeTriggerMatches(this.spec.target, event.eventType)) {
|
|
receipt.seenAt = at;
|
|
receipt.runId = runId;
|
|
receipt.eventType = event.eventType;
|
|
}
|
|
}
|
|
if (events.length < EVENT_PAGE) break;
|
|
}
|
|
this.cursors.set(runId, afterSeq);
|
|
if (receipt.seenAt !== null) return;
|
|
}
|
|
}
|
|
|
|
private miss(receipt: NativeTriggerReceipt, at: number, reason: string) {
|
|
receipt.missedAt = at;
|
|
receipt.missedReason = reason;
|
|
this.hooks.record("native_trigger_missed", { taskId: receipt.taskId, fault: receipt.kind, target: this.spec.target, reason });
|
|
}
|
|
|
|
finish(faults: readonly FaultReceipt[], now = Date.now()): NativeTriggerReceipt[] {
|
|
for (const { receipt } of this.armed.splice(0)) {
|
|
this.miss(receipt, now, receipt.seenAt !== null ? "task_rested_after_event"
|
|
: this.hooks.isOpen(receipt.taskId) ? "no_matching_event" : "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 nativeTriggerShortfall(receipts: readonly NativeTriggerReceipt[]): string | null {
|
|
const fired = receipts.filter(receipt => receipt.firedAt !== null);
|
|
if (!fired.length) {
|
|
const seen = receipts.filter(receipt => receipt.seenAt !== null).length;
|
|
return `--native-trigger: no restart followed a matching run event (${receipts.length} armed, ${seen} saw the event, none fired)`;
|
|
}
|
|
const unproven = fired.filter(receipt => !receipt.faultId);
|
|
return unproven.length ? `--native-trigger: ${unproven.length} fired trigger(s) produced no fault: ${unproven.map(receipt => receipt.taskId).join(", ")}` : null;
|
|
}
|
|
|
|
export function nativeTriggerMarkdown(receipts: readonly NativeTriggerReceipt[]): string {
|
|
const first = receipts[0];
|
|
const lines = [`## Native trigger (${first ? `${first.target}+${first.delayMs / 1000}s` : "none"})`, "",
|
|
`${receipts.filter(receipt => receipt.faultId).length} of ${receipts.length} armed faults fired after a matching run event.`, "",
|
|
"| Task | Fault | Fault id | Run | Event | Fired after event |", "| --- | --- | --- | --- | --- | --- |",
|
|
...receipts.map(receipt => `| ${receipt.taskId} | ${receipt.kind} | ${receipt.faultId ?? "-"} | ${receipt.runId?.slice(0, 8) ?? "-"} | ${
|
|
receipt.eventType ?? `not reached (${receipt.missedReason ?? "unknown"})`} | ${receipt.firedAt !== null && receipt.seenAt !== null
|
|
? `${((receipt.firedAt - receipt.seenAt) / 1000).toFixed(1)} s` : "-"} |`)];
|
|
return `${lines.join("\n")}\n`;
|
|
}
|