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

323 lines
13 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.
/** Shared types for the control-plane restart chaos harness (plan §5). */
/** K14 (upgrade mid-run): a graceful stop, then the replacement starts from the current build (`--upgrade-from`). */
export const FAULT_KINDS = ["K1", "K2", "K4", "K6", "K14"] as const;
export type FaultKind = (typeof FAULT_KINDS)[number];
/** A task's planned fault. "K13" (restart storm) tasks have none of their own; the wave's storm crosses them. */
export type TaskFaultKind = FaultKind | "K13";
/** G1: an approval-gated action across a restart. H1: a budget hard stop crossed mid-run with a restart (its own wave). */
export const WORKLOAD_IDS = ["A1", "B1", "B5", "C1", "M1", "G1", "H1", "R1", "S1"] as const;
export type WorkloadId = (typeof WORKLOAD_IDS)[number];
export const ADAPTER_IDS = ["codex", "claude", "opencode"] as const;
export type AdapterId = (typeof ADAPTER_IDS)[number];
export const TRIGGER_IDS = ["time", "comment", "retry", "queued", "tool", "result", "approval", "paused", "stop"] as const;
export type TriggerId = (typeof TRIGGER_IDS)[number];
export const ENVIRONMENT_KINDS = ["local", "daytona"] as const;
export type EnvironmentKind = (typeof ENVIRONMENT_KINDS)[number];
export interface WaveOptions {
env: EnvironmentKind;
adapters: AdapterId[];
workloads: WorkloadId[];
faults: FaultKind[];
triggers?: TriggerId[];
concurrency: number;
tasks: number;
seed: number;
/** Company budget hard cap in cents; the product hard-stops at this value. */
budgetCents: number;
/** Poll interval for the observer, 2–5 s per plan §5. */
pollMs: number;
/** `--env daytona`: the immutable runner image (registry/repository@sha256:…). */
daytonaImage?: string;
/** `--env daytona`: vCPU per sandbox (see seed.ts chaosDaytonaShape). */
daytonaCpu?: number;
/** `--daytona-reuse-lease`: the chaos environment keeps its sandboxes between turns (a per-turn run stops and keeps its sandbox). */
daytonaReuseLease?: boolean;
/** `--k4-hold-max-seconds`: upper end of the seeded K4 hold (default 300 s, cap 600 s). */
k4HoldMaxMs?: number;
/**
* Known product signatures this wave deliberately masks. "rc06" keeps the
* company budget amount but disables its hard stop, so a stuck
* accounting row (RC-06) cannot wedge every later task. Spend is still
* recorded per wave. Absent on plans written before masks existed.
*/
masks?: string[];
/**
* K13 restart storm: fire faults drawn from `faults` every 2–4 minutes for
* this many minutes, keeping `concurrency` tasks in flight, then drain.
*/
stormMinutes?: number;
/**
* Runner lifecycle forced onto every seeded agent (`--lifecycle`). Absent
* keeps the catalog profile's own setting.
*/
lifecycle?: "per_turn" | "warm" | "default";
/**
* Crash-point suite (`--crash-point <name>[:<nth>][,...]`): no external
* fault is scheduled. One server serves every listed point in turn: each
* point's batch of tasks launches, then the point is armed and the server
* SIGKILLs itself at its nth hit and is restarted like K1.
*/
crashPoint?: string;
/** Crash-point suite: hold the control plane down this long after each self-kill (K4-style). */
crashPointHoldMs?: number;
/**
* K14 upgrade mid-run (`--upgrade-from <dir>`): an older, prepared checkout. The wave's server starts
* from it; the first K14 stops it gracefully and starts the current checkout on the same database
* and instance directory. Later K14 faults restart the current build, like K2.
*/
upgradeFrom?: string;
workspaceRepo?: string;
workspaceCommit?: string;
}
export interface StormFault {
kind: FaultKind;
holdMs: number;
/** Wait this long after the previous storm fault settled (or the storm started). */
gapMs: number;
}
export interface StormPlan {
durationMs: number;
faults: StormFault[];
}
export interface TaskPlan {
taskId: string;
index: number;
workload: WorkloadId;
adapter: AdapterId;
fault: TaskFaultKind;
/** Fire the fault this long after the task's issue is created. */
fireAfterMs: number;
/** Outage hold between stop and restart. 0 for K1/K2. */
holdMs: number;
trigger?: TriggerId;
triggerDelayMs?: number;
/** C1 only: post the follow-up comment this long after creation. */
followUpAfterMs: number | null;
/** Unique marker used by the deterministic T-oracle. */
nonce: string;
/**
* Replay only: every fault that crossed this task in the source wave, with
* its offset from the task's launch. Replaces the single planned fault.
*/
replayFaults?: ScheduledFault[];
}
export interface ScheduledFault {
kind: FaultKind; holdMs: number;
atMs: number;
trigger?: TriggerId;
delayMs?: number;
}
export interface WavePlan {
schema: "paperclip.restart-chaos.wave-plan/v1";
waveId: string;
seed: number;
createdAt: string;
options: WaveOptions;
tasks: TaskPlan[];
/** K13 only: the seeded storm fault sequence. */
storm?: StormPlan;
/** Present when this wave replays one task of an earlier wave. */
replayOf?: { waveId: string; taskId: string };
}
export type Verdict = "pass" | "fail" | "n/a";
export interface RuleResult { verdict: Verdict; detail: string }
export type RuleId = "R1" | "R2" | "R3" | "R4" | "R5" | "R6";
export interface RunSample {
id: string;
status: string;
createdAt: string | null;
startedAt: string | null;
finishedAt: string | null;
errorCode: string | null;
retryOfRunId: string | null;
wakeCommentIds: string[];
livenessState: string | null;
lastEvidenceAt?: string | null;
/** A tool started with no matching end yet (a long `bench.sh` is quiet but live). */
toolInFlight?: boolean;
/** The run's recorded local runner process is still running with the same start time. */
processAlive?: boolean;
}
export interface InterruptionSample {
state: string;
canRetry: boolean;
retryWait: boolean;
workspaceFailure: string | null;
observedRunId: string | null;
userAction: string | null;
}
/** One observer poll. Unreachable polls keep only the timestamp. */
export interface TaskSample {
at: number;
reachable: boolean;
serverReady: boolean;
issueStatus?: string;
interruption?: InterruptionSample | null;
activeRecoveryAction?: boolean;
/** Owner of the active recovery action; "board" means it waits on a person. */
recoveryOwner?: string | null;
executionBlocker?: boolean;
scheduledRetry?: boolean;
runs?: RunSample[];
}
export interface RetryAttempt {
at: number;
acceptedAt?: number;
observedRunId: string | null;
httpStatus: number | null;
accepted: boolean | null;
error: string | null;
}
export interface FaultReceipt {
faultId: string;
kind: FaultKind;
/** Task that scheduled the fault; every other running task is also exposed. */
requestedBy: string;
signal: "SIGKILL" | "SIGTERM";
holdMs: number;
requestedAt: number;
startedAt: number;
stoppedAt: number | null;
restartedAt: number | null;
healthyAt: number | null;
readyAt: number | null;
phases: Array<{ phase: string; at: number }>;
trigger?: TriggerId;
triggerDelayMs?: number;
loadAvg1m?: number;
crashLoop?: Array<{ pid: number | null; target: string; phase: string; at: number }>;
/** Crash-point suite: the server killed itself at this named point (no external signal). */
crashPoint?: { name: string; hit: number; pid: number; token?: string };
/** K14: the build the stopped server ran and the build its replacement runs. */
upgrade?: { fromRoot: string; fromSha: string | null; toRoot: string; toSha: string | null; switched: boolean };
serverLogOffsetAtStart: number;
serverLogOffsetAtReady: number | null;
error: string | null;
}
export interface OracleArtifacts {
issueStatus: string | null;
documents: Record<string, string | null>;
agentComments: string[];
files: Record<string, string | null>;
commands: Record<string, { exitCode: number | null; stdout: string; stderr: string }>;
workspacePath: string | null;
/** B5: distinct `.bench/progress` values the harness itself saw while the task was live. */
benchProgressObserved?: number;
stop?: { runId: string; runStatus: string | null; runsStartedBeforeContinue: string[]; continuePosted: boolean };
children?: Array<{ id: string; title?: string | null; status: string | null; assigneeAgentId: string | null; documents: Record<string, string | null> }>;
childStalls?: Array<{ childIssueId: string; recoveryActionId: string | null }>;
stallWakes?: Array<{ stalledIssueId: string | null; recoveryActionId: string | null }>;
/** G1: approvals linked to the task. */
approvals?: Array<{ id: string; status: string; title: string | null }>;
/** G1 and H1: the board's actions and what the observer saw around them. */
governance?: GovernanceRecord;
}
/** What the board did for a governance task (G1, H1), and what the observer saw. */
export interface GovernanceRecord {
/** G1: first time the observer saw the task's approval pending. */
approvalSeenAt?: number | null;
approvalId?: string | null;
/** G1: the board's decision, made once the task's faults fired and the server was ready. */
decision?: { kind: "approve" | "reject"; at: number; httpStatus: number | null; error: string | null;
/** The approval was still pending when the board decided (it survived the restart). */
pendingWhenDecided: boolean; afterFault: boolean };
/** H1: the company budget the harness set before launch, in cents (observed spend + 1). */
budgetSetCents?: number | null;
budgetSetError?: string | null;
/** H1: first time the observer saw the company paused for its budget. */
budgetPausedAt?: number | null;
/** H1: runs of the task that started while the company was paused (must be none). */
runsStartedWhilePaused?: string[];
/** H1: the board's raise-and-resume, made once the task's faults fired and the pause held. */
resume?: { at: number; amount: number | null; incidentId: string | null; httpStatus: number | null; error: string | null; afterFault: boolean;
/** When the resume request returned: the pause window ends here. */
doneAt?: number };
/** H1: the company was active again at rest. */
companyActiveAtRest?: boolean;
/** H1: the pause cleared on its own (before the board resumed it). */
pauseLostAt?: number | null;
/** G1: subtasks named after the nonce that existed before the board decided (must be 0). */
childrenBeforeDecision?: number | null;
}
export interface OracleResult { verdict: Exclude<Verdict, "n/a">; checks: Array<{ name: string; ok: boolean; detail?: string }> }
export interface RestSweep {
activeRuns: string[];
runningWorkspaceOperations: string[];
activeRecoveryAction: boolean;
/** `--env daytona`: orphaned sandboxes and held leases belonging to this task's runs. */
daytonaLeaks?: string[];
error: string | null;
}
export interface TaskRecord {
plan: TaskPlan;
issueId: string | null;
identifier: string | null;
createdAt: number | null;
launchError: string | null;
samples: TaskSample[];
retry: RetryAttempt | null;
restAt: number | null;
finalStatus: string | null;
deadlineAt: number | null;
timedOut: boolean;
followUp: { at: number; sentAt?: number; postedAt?: number; commentId: string | null; error: string | null; duringRun?: boolean } | null;
stop?: { at: number; runId: string; httpStatus: number | null; error: string | null };
faultIds: string[];
sweep: RestSweep | null;
/** Workspace files present just before the first fault that hit this task (R2). */
preFaultFiles: string[] | null;
preFaultWorkspacePath?: string;
lastFaultAt?: number;
restSnapshotError?: string;
priorRetries?: RetryAttempt[];
childStalls?: Array<{ childIssueId: string; recoveryActionId: string | null }>;
childRetries?: Array<RetryAttempt & { childIssueId: string }>;
workspaceMovedTo?: string;
/** Set when the pre-fault snapshot could not read the workspace; R2 then fails visibly. */
preFaultSnapshotError?: string | null;
/** Pre-fault files missing at rest; null when not checked. */
lostFiles: string[] | null;
/** B5: distinct bench progress values the observer read during polling. */
benchProgressSeen?: string[];
benchFirstSeenAt?: number | null;
/** C1: first time the observer saw the run that received the follow-up propose or accept its result. */
resultAcceptedAt?: number | null;
/** The run whose result armed the `result` trigger; the fault fires only while it is still running. */
resultRunId?: string | null;
triggerMissed?: TriggerId[];
governance?: GovernanceRecord;
/** K14 waves: the build serving when the task launched. */
serverBuildAtLaunch?: "upgrade_from" | "current";
}
export interface TaskGrade {
crossed: boolean;
crossingFaults: string[];
rules: Record<RuleId, RuleResult>;
T: OracleResult;
pass: boolean;
signature: string | null;
}