Files
PaperClipAI/tests/restart-chaos/scheduler.ts
T
DottaandClaude Opus 5.5 61fa13c6d8 test(chaos): add S1, a board Stop across a restart then a continue comment (#20161)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The restart chaos harness drives real user actions across
control-plane restarts: comments, Retry, approvals and budget resumes
> - The board's Stop button on a running task was not exercised. The
cancel route stamps the stop as operator-initiated so that recovery
stands down instead of re-waking the agent
> - A restart right after a Stop is where recovery could wrongly restart
work the operator stopped, or lose the operator's later "continue"
direction
> - This pull request adds S1: stop the running run, fire a fault right
after, then comment to continue
> - The benefit is a graded check that a Stop holds across a restart and
that a later continue comment finishes the task

## Linked Issues or Issue Description

Refs #20071

**What is missing?**
The campaign's scenario list includes mid-run stops. C1 covers a mid-run
comment (a steer), but no workload clicks Stop. Without one, a
regression where recovery restarts operator-stopped work, or drops the
continue comment, would go unseen.

**Expected behavior**
- After the board stops a run and a restart follows, the stopped run
stays cancelled.
- Nothing runs until the operator comments.
- The continue comment then finishes the task.

## What Changed

- S1 workload ("Stop while running, then continue"): a brief task. At
its follow-up time, while a run is running, the observer posts `POST
/api/heartbeat-runs/:id/cancel` (the board's Stop) and records `stop`.
- New `stop` trigger, for S1 only: the planned fault fires 0–2 s after
the stop.
- The continue comment is posted only after the stop has settled: at
least 20 s after the stop, with no run running or queued and no fault in
progress. It asks the agent to continue and add a `CONTINUE-<nonce>`
line.
- The oracle requires:
  - status done;
  - the brief with the chaos and continue markers;
  - `stop_took_effect`: the stopped run ended `cancelled`;
- `nothing_ran_after_stop`: no run was created between the stop and the
continue comment.
- S1 is not a default workload. There is a README section.

## Verification

- `npx vitest run` in `tests/restart-chaos`: 205 passed. New tests
cover:
- the S1 grade (pass, a run restarted after the stop, the stop not
taking effect, no stop);
  - the trigger-to-workload pairing;
- an observer test that clicks Stop once and posts the continue comment
only after the 20 s settle.
- `node scripts/typecheck-test-projects.mjs tests/restart-chaos`: clean.
- A live S1 wave runs after the current chaos wave. Its result will be
posted here before merge.

## Risks

- If the product moves the stopped run to a status other than
`cancelled`, `stop_took_effect` fails. That check is intentional.
- The continue comment waits for the stop to settle, so a run stuck
`queued` delays it. R5 and the wave deadline still bound the wait.

## 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-08 22:52:48 -06:00

304 lines
14 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 { workloadDefinitions } from "./workloads.js";
import type { AdapterId, FaultKind, ScheduledFault, StormFault, StormPlan, TaskFaultKind, TaskPlan, TriggerId, WavePlan, WaveOptions, WorkloadId } from "./types.js";
import type { ReplayRef } from "./config.js";
/** mulberry32: small, fast and fully determined by its 32-bit seed. */
export function createRng(seed: number) {
let state = seed >>> 0;
const next = () => {
state = (state + 0x6d2b79f5) >>> 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296;
};
return {
next,
int(min: number, max: number) { return min + Math.floor(next() * (max - min + 1)); },
hex(length: number) { let out = ""; while (out.length < length) out += Math.floor(next() * 16).toString(16); return out; },
shuffle<T>(values: readonly T[]): T[] {
const copy = [...values];
for (let index = copy.length - 1; index > 0; index -= 1) {
const swap = Math.floor(next() * (index + 1));
[copy[index], copy[swap]] = [copy[swap]!, copy[index]!];
}
return copy;
},
};
}
export const K4_HOLD_RANGE_MS: readonly [number, number] = [120_000, 300_000];
/** The seeded K4 hold range; `--k4-hold-max-seconds` raises its upper end
* (up to the 600 s controller-outage cap) to reach the 7-minute reconnect
* grace and longer outages. The default range is unchanged. */
export function k4HoldRange(maxMs?: number): readonly [number, number] {
return maxMs === undefined ? K4_HOLD_RANGE_MS : [K4_HOLD_RANGE_MS[0], Math.max(K4_HOLD_RANGE_MS[0], maxMs)];
}
export const TRIGGER_DELAY_RANGE_MS: Record<Exclude<TriggerId, "time">, readonly [number, number]> = {
comment: [0, 5_000],
retry: [0, 2_000],
queued: [0, 0],
tool: [15_000, 60_000],
result: [0, 3_000],
approval: [0, 3_000],
paused: [0, 3_000],
stop: [0, 2_000],
};
export function triggerFitsWorkload(trigger: TriggerId, workload: WorkloadId): boolean {
if (trigger === "comment" || trigger === "result") return workload === "C1";
if (trigger === "tool") return workload === "B5";
if (trigger === "approval") return workload === "G1";
if (trigger === "paused") return workload === "H1";
if (trigger === "stop") return workload === "S1";
return true;
}
/** The workload a trigger needs, for the error when none is selected. */
const TRIGGER_WORKLOAD: Partial<Record<TriggerId, WorkloadId>> = { comment: "C1", result: "C1", tool: "B5", approval: "G1", paused: "H1", stop: "S1" };
/** Faults a `result` task may fire inside a storm: a fast restart that lands on the boundary. */
const STORM_RESULT_FAULTS: readonly FaultKind[] = ["K1", "K2"];
/**
* A storm task has no fault of its own (`K13`): the wave's storm crosses it.
* A `result` task in a storm also fires its own K1 or K2 just after its run
* accepts a result, the window of RC-30, RC-33 and RC-41.
*/
function stormCellFaults(options: Pick<WaveOptions, "faults" | "stormMinutes">, trigger: TriggerId): readonly TaskFaultKind[] {
if (!options.stormMinutes) return options.faults;
if (trigger === "time") return ["K13"];
if (trigger !== "result") throw new Error(`A K13 storm fires on its own clock; --triggers ${trigger} does not combine with it (use time or result)`);
const faults = options.faults.filter(fault => STORM_RESULT_FAULTS.includes(fault));
if (!faults.length) throw new Error("--triggers result in a storm needs K1 or K2 in --faults");
return faults;
}
/**
* Pairwise-friendly sampling: cycle a seeded shuffle of the full
* workload × adapter × fault product so small waves still spread across cells.
*/
export function planTasks(options: Pick<WaveOptions, "seed" | "tasks" | "workloads" | "adapters" | "faults" | "triggers" | "stormMinutes" | "k4HoldMaxMs">): TaskPlan[] {
const rng = createRng(options.seed);
const triggers = options.triggers?.length ? options.triggers : ["time" as const];
for (const trigger of triggers) {
if (!options.workloads.some(workload => triggerFitsWorkload(trigger, workload))) {
throw new Error(`Trigger "${trigger}" needs workload ${TRIGGER_WORKLOAD[trigger] ?? "?"} in --workloads`);
}
}
const cells: Array<{ workload: WorkloadId; adapter: AdapterId; fault: TaskFaultKind; trigger: TriggerId }> = [];
// Same cell order as before storms took triggers, so a seed keeps its plan.
const allowed = new Map(triggers.map(trigger => [trigger, stormCellFaults(options, trigger)]));
const faults = [...new Set([...allowed.values()].flat())];
for (const workload of options.workloads) for (const adapter of options.adapters) for (const fault of faults) {
for (const trigger of triggers) {
if (triggerFitsWorkload(trigger, workload) && allowed.get(trigger)!.includes(fault)) cells.push({ workload, adapter, fault, trigger });
}
}
const tasks: TaskPlan[] = [];
let order = rng.shuffle(cells);
for (let index = 0; index < options.tasks; index += 1) {
if (index > 0 && index % cells.length === 0) order = rng.shuffle(cells);
const cell = order[index % cells.length]!;
const definition = workloadDefinitions[cell.workload];
const [fireMin, fireMax] = definition.fireWindowMs;
const { trigger, ...cellRest } = cell;
tasks.push({
taskId: `t${String(index + 1).padStart(2, "0")}`,
index,
...cellRest,
...(trigger === "time" ? {} : { trigger, triggerDelayMs: rng.int(...TRIGGER_DELAY_RANGE_MS[trigger]) }),
fireAfterMs: cell.fault === "K13" ? 0 : rng.int(fireMin, fireMax),
holdMs: cell.fault === "K4" ? rng.int(...k4HoldRange(options.k4HoldMaxMs)) : 0,
followUpAfterMs: definition.followUpWindowMs ? rng.int(definition.followUpWindowMs[0], definition.followUpWindowMs[1]) : null,
nonce: rng.hex(8),
});
}
return tasks;
}
function withoutStorm(options: WaveOptions): WaveOptions {
const { stormMinutes: _storm, ...rest } = options;
return rest;
}
/** A replay keeps the source wave's masks and may add more; it never drops one. */
function replayMasks(source: readonly string[] | undefined, added: readonly string[] | undefined) {
const masks = [...new Set([...(source ?? []), ...(added ?? [])])];
return masks.length ? { masks } : {};
}
export function planWave(waveId: string, options: WaveOptions, createdAt = new Date()): WavePlan {
return { schema: "paperclip.restart-chaos.wave-plan/v1", waveId, seed: options.seed,
createdAt: createdAt.toISOString(), options, tasks: planTasks(options),
...(options.stormMinutes ? { storm: planStorm(options.seed, options.stormMinutes, options.faults, options.k4HoldMaxMs) } : {}) };
}
/** K13: the gap between one storm fault settling and the next one firing. */
export const STORM_GAP_RANGE_MS: readonly [number, number] = [120_000, 240_000];
/**
* K13 restart storm: a seeded sequence of faults drawn from `faults`, each
* after a 2-4 minute gap, long enough to fill `minutes`. It uses its own RNG
* stream so adding a storm does not change the task cells for a seed.
*/
export function planStorm(seed: number, minutes: number, faults: readonly FaultKind[], k4HoldMaxMs?: number): StormPlan {
if (!faults.length) throw new Error("A K13 storm needs at least one fault kind");
const rng = createRng((seed ^ 0x6b313353) >>> 0);
const durationMs = minutes * 60_000;
const sequence: StormFault[] = [];
for (let planned = 0; planned < durationMs;) {
const gapMs = rng.int(...STORM_GAP_RANGE_MS);
const kind = faults[rng.int(0, faults.length - 1)]!;
sequence.push({ kind, gapMs, holdMs: kind === "K4" ? rng.int(...k4HoldRange(k4HoldMaxMs)) : 0 });
planned += gapMs;
}
return { durationMs, faults: sequence };
}
export interface StormState {
startedAt: number;
/** Storm faults fired so far. */
fired: number;
/** When the previous storm fault finished (server ready, or the fault failed); null before the first. */
lastSettledAt: number | null;
}
/** New tasks launch and storm faults fire only while the storm lasts; then it drains. */
export function stormActive(storm: StormPlan, state: StormState, now: number): boolean {
return now < state.startedAt + storm.durationMs;
}
/**
* The storm clock the wave loop consults. Every decision takes a fresh `now`,
* because the loop awaits slow work (server recovery, launches, pre-fault
* snapshots) between choosing an action and taking it.
*/
export class StormRun {
readonly state: StormState;
ended = false;
constructor(readonly plan: StormPlan, startedAt: number) {
this.state = { startedAt, fired: 0, lastSettledAt: null };
}
/** True once, on the transition to the drain. */
endIfDue(now: number): boolean {
if (this.ended || stormActive(this.plan, this.state, now)) return false;
this.ended = true;
return true;
}
/** Ask immediately before each launch. */
mayLaunch(now: number): boolean {
return !this.ended && stormActive(this.plan, this.state, now);
}
/** The next fault, never while the previous one is still unsettled: each gap runs from its settle. */
due(now: number): StormFault | null {
return this.ended || this.unsettled ? null : stormFaultDue(this.plan, this.state, now);
}
/** Claim a fault chosen before slow work. False when the storm ended meanwhile; nothing is consumed. */
commit(fault: StormFault, now: number): boolean {
if (this.due(now) !== fault) return false;
this.state.fired += 1;
this.unsettled = true;
return true;
}
settled(now: number) { this.state.lastSettledAt = now; this.unsettled = false; }
private unsettled = false;
/** The task pool ran dry with storm time left: the wave did not cover its storm. */
exhaustedEarly(now: number): boolean {
return !this.ended && stormActive(this.plan, this.state, now);
}
}
/**
* The next storm fault once its gap has passed. Gaps run from the previous
* fault settling, so a K4 hold never eats the next gap. The caller must not
* ask while a fault is still in progress.
*/
export function stormFaultDue(storm: StormPlan, state: StormState, now: number): StormFault | null {
if (!stormActive(storm, state, now)) return null;
const next = storm.faults[state.fired];
if (!next) return null;
return now >= (state.lastSettledAt ?? state.startedAt) + next.gapMs ? next : null;
}
/** Written after every fault: each task's launch time and the faults that crossed it so far. */
export const TASK_FAULT_LOG = "task-faults.json";
export type TaskFaultLog = Record<string, { createdAt: number | null; faultIds: string[]; serverBuildAtLaunch?: "upgrade_from" | "current" }>;
/**
* A replay of a K14 wave task that launched on the current build: it starts on the current build
* too (no `--upgrade-from`), and its recorded K14 restarts were current-build restarts (K2).
*/
export function replayOnCurrentBuild(plan: WavePlan): WavePlan {
const asK2 = <T extends { kind?: FaultKind; fault?: TaskFaultKind }>(item: T): T => ({ ...item,
...(item.kind === "K14" ? { kind: "K2" as const } : {}), ...(item.fault === "K14" ? { fault: "K2" as const } : {}) });
const { upgradeFrom: _upgradeFrom, ...options } = plan.options;
return { ...plan, options: { ...options, faults: options.faults.map(kind => kind === "K14" ? "K2" : kind) },
tasks: plan.tasks.map(task => ({ ...asK2(task), ...(task.replayFaults ? { replayFaults: task.replayFaults.map(asK2) } : {}) })) };
}
/** The crossing record for a replay: the graded record, else the live fault log (interrupted or wedged waves). */
export function replayRecordFor(taskId: string, graded: { createdAt: number | null; faultIds: string[] } | null,
logged: TaskFaultLog | null): { createdAt: number | null; faultIds: string[] } | null {
if (graded?.faultIds.length) return graded;
return logged?.[taskId] ?? graded;
}
/** Faults that actually crossed a task, as offsets from its launch. */
export function observedFaultSchedule(createdAt: number | null, faultIds: readonly string[],
faults: ReadonlyArray<{ faultId: string; kind: FaultKind; holdMs: number; startedAt: number; requestedBy?: string;
trigger?: TriggerId; triggerDelayMs?: number }>, taskId?: string): ScheduledFault[] {
if (createdAt === null) return [];
return faults.filter(fault => faultIds.includes(fault.faultId))
.map((fault): ScheduledFault => ({ kind: fault.kind, holdMs: fault.holdMs, atMs: Math.max(0, fault.startedAt - createdAt),
...(fault.trigger && fault.trigger !== "time" && fault.requestedBy === taskId
? { trigger: fault.trigger, delayMs: fault.triggerDelayMs ?? 0 } : {}) }))
.sort((a, b) => a.atMs - b.atMs);
}
/** The faults a task schedules: its replayed sequence, or its one planned fault. */
export function taskFaultSchedule(task: TaskPlan): ScheduledFault[] {
if (task.replayFaults?.length) return task.replayFaults;
if (task.fault === "K13") return [];
const planned: ScheduledFault = { kind: task.fault, holdMs: task.holdMs, atMs: task.fireAfterMs };
const trigger = task.trigger ?? "time";
if (trigger === "time") return [planned];
const delayMs = task.triggerDelayMs ?? 0;
if (trigger === "retry") return [planned, { kind: "K1", holdMs: 0, atMs: 0, trigger, delayMs }];
return [{ ...planned, trigger, delayMs }];
}
/**
* A replay keeps the saved cell, hold and nonce and runs alone. When the source
* wave recorded which faults crossed the task (including other tasks' faults),
* the replay fires that same sequence at the same offsets from launch.
*/
export function replayWave(source: WavePlan, ref: ReplayRef, now = Date.now(),
overrides: Partial<Pick<WaveOptions, "pollMs" | "budgetCents" | "masks">> = {}, observed: ScheduledFault[] = []): WavePlan {
if (source.schema !== "paperclip.restart-chaos.wave-plan/v1") throw new Error("Unsupported wave plan schema");
if (source.waveId !== ref.waveId) throw new Error(`Plan is for ${source.waveId}, not ${ref.waveId}`);
const task = source.tasks.find(candidate => candidate.taskId === ref.taskId);
if (!task) throw new Error(`Wave ${ref.waveId} has no task ${ref.taskId}`);
const base = source.waveId.replace(/-replay-t[0-9]{2,3}-[0-9]+$/, "");
return {
schema: source.schema,
waveId: `${base}-replay-${task.taskId}-${now}`,
seed: source.seed,
createdAt: new Date(now).toISOString(),
// A replay fires only the faults that crossed the task, never a new storm.
options: { ...withoutStorm(source.options), ...overrides, tasks: 1, concurrency: 1, ...replayMasks(source.options.masks, overrides.masks) },
tasks: [{ ...task, ...(observed.length ? { replayFaults: observed.map(fault => ({ ...fault })) } : {}) }],
replayOf: { ...ref },
};
}