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 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>
323 lines
13 KiB
TypeScript
323 lines
13 KiB
TypeScript
/** 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;
|
||
}
|