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>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-08 22:52:48 -06:00
1 parent b54317d3fb
commit 61fa13c6d8
9 files changed
+189 -9

No files matched your search

+15
View File
@@ -102,6 +102,21 @@ Neither G1 nor H1 is in the default workloads. The board's actions and what the
observer saw are in the timeline (`governance_*`) and in each task's grade
(`governance`).
## Stop while running, then continue (S1, `--triggers stop`)
```sh
node cli/node_modules/tsx/dist/cli.mjs tests/restart-chaos/main.ts --env local \
--adapters codex,claude --workloads S1 --faults K1,K2,K4 --triggers stop --concurrency 4 --tasks 6 --seed 23
```
S1 is the board's Stop button. While the task's run is running, the harness cancels it through
`POST /api/heartbeat-runs/:id/cancel`. The `stop` trigger fires the fault 0–2 s later. At least
20 s after the stop, once no run is running or queued and no fault is in progress, the harness
comments that it stopped the run on purpose and asks the agent to continue with an extra marker
line. The oracle checks that the stopped run ended `cancelled`, that nothing started between the
stop and the continue comment (recovery must not restart work the operator stopped), and that the
finished brief has both markers. S1 is not a default workload.
## Code change in a real repository (R1, `--workspace-repo`)
```sh
+1 -1
View File
@@ -140,7 +140,7 @@ export function parseCommand(argv: readonly string[], now = Date.now()): ParsedC
env,
adapters: list<AdapterId>(values.adapters, ADAPTER_IDS, "adapters", ["codex", "claude"]),
// The governance scenarios (G1, H1) are opt-in: H1 needs a wave of its own.
workloads: list<WorkloadId>(values.workloads, workloadChoices, "workloads", workloadChoices.filter(id => id !== "G1" && id !== "H1" && id !== "R1")),
workloads: list<WorkloadId>(values.workloads, workloadChoices, "workloads", workloadChoices.filter(id => id !== "G1" && id !== "H1" && id !== "R1" && id !== "S1")),
// A crash-point wave schedules no fault; K1 is the restart its self-kill gets.
faults: list<FaultKind>(values.faults, FAULT_KINDS, "faults", stormMinutes ? STORM_DEFAULT_FAULTS : crashPoint ? ["K1"] : ["K1", "K2", "K4"]),
triggers: list<TriggerId>(values.triggers, TRIGGER_IDS, "triggers", ["time"]),
+2
View File
@@ -260,6 +260,8 @@ describe("governance wave options", () => {
expect(command.options.workloads).not.toContain("G1");
expect(command.options.workloads).not.toContain("H1");
expect(triggerFitsWorkload("approval", "G1")).toBe(true);
expect(triggerFitsWorkload("stop", "S1")).toBe(true);
expect(triggerFitsWorkload("stop", "C1")).toBe(false);
expect(triggerFitsWorkload("approval", "A1")).toBe(false);
expect(triggerFitsWorkload("paused", "H1")).toBe(true);
expect(() => planTasks({ seed: 1, tasks: 2, workloads: ["A1"], adapters: ["codex"], faults: ["K1"], triggers: ["approval"] }))
+11
View File
@@ -238,6 +238,17 @@ describe("T-oracles", () => {
expect(stallWakeChecks({ ...stalled, childStalls: [] } as never)).toEqual([]);
});
it("grades S1: stopped run cancelled, nothing ran before the continue comment, brief has both markers", () => {
const base: OracleArtifacts = { issueStatus: "done", documents: { brief: "CHAOS-abcd1234\nCONTINUE-abcd1234\n- one\n- two\n- three\n" },
agentComments: [], files: {}, commands: {}, workspacePath: null,
stop: { runId: "r1", runStatus: "cancelled", runsStartedBeforeContinue: [], continuePosted: true } };
expect(workloadDefinitions.S1.grade("abcd1234", base).verdict).toBe("pass");
const restarted = workloadDefinitions.S1.grade("abcd1234", { ...base, stop: { ...base.stop!, runsStartedBeforeContinue: ["r2"] } });
expect(restarted.checks.find(check => check.name === "nothing_ran_after_stop")).toMatchObject({ ok: false, detail: "continue=true runs=r2" });
expect(workloadDefinitions.S1.grade("abcd1234", { ...base, stop: { ...base.stop!, runStatus: "succeeded" } }).verdict).toBe("fail");
expect(workloadDefinitions.S1.grade("abcd1234", { ...base, stop: undefined }).verdict).toBe("fail");
});
it("grades B1 by running the tests and the CLI", () => {
const base: OracleArtifacts = { issueStatus: "done", documents: {}, agentComments: [], workspacePath: "/tmp/ws",
files: { "NOTES-abcd1234.md": "B1-abcd1234\n" },
+77 -1
View File
@@ -4,7 +4,7 @@ import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { ChaosApi } from "./api.js";
import { CHILD_WAKE_GRACE_MS, REST_CONFIRM_MS, STALL_GIVE_UP_MS, TaskObserver, childrenPending, listWorkspaceFiles } from "./observer.js";
import { CHILD_WAKE_GRACE_MS, REST_CONFIRM_MS, STALL_GIVE_UP_MS, STOP_CONTINUE_AFTER_MS, TaskObserver, childrenPending, listWorkspaceFiles } from "./observer.js";
import { gradeTask } from "./grader.js";
import { createFixtureRepository, type ChaosLab } from "./seed.js";
import { Timeline } from "./timeline.js";
@@ -138,6 +138,82 @@ describe("TaskObserver", () => {
expect(childRetries).toHaveLength(1);
});
it("S1: stops the running run as the board would, then posts the continue comment only after it settles", async () => {
let runStatus = "running";
const base = fakeApi({ "/runs": () => [{ id: "r1", status: runStatus, createdAt: new Date(0).toISOString(), finishedAt: null }] }, "in_progress");
const posted: string[] = [];
const api = { ...base.api, get: base.api.get.bind(base.api), async post(route: string) {
posted.push(route);
if (route === "/api/heartbeat-runs/r1/cancel") { runStatus = "cancelled"; return { id: "r1", status: "cancelled" }; }
return route.endsWith("/issues") ? { id: "i1", identifier: "CHA-1" } : { id: "comment-1" };
} } as unknown as ChaosApi;
const observer = new TaskObserver({ ...plan, workload: "S1", followUpAfterMs: 1_000 }, api, lab, new Timeline(null), ready);
await observer.launch();
const start = observer.record.createdAt!;
await observer.poll(start + 2_000);
expect(posted).toContain("/api/heartbeat-runs/r1/cancel");
expect(observer.record.stop).toMatchObject({ runId: "r1", httpStatus: 200, error: null });
await observer.poll(start + 2_000 + STOP_CONTINUE_AFTER_MS - 1);
expect(observer.record.followUp).toBeNull();
await observer.poll(start + 2_000 + STOP_CONTINUE_AFTER_MS + 1);
expect(observer.record.followUp).toMatchObject({ commentId: "comment-1", duringRun: false });
expect(posted.filter(route => route.endsWith("/cancel"))).toHaveLength(1);
});
it("S1: does not count the continue comment's own run as a run after the stop, even when it starts before the comment returns", async () => {
let runs: Array<Record<string, unknown>> = [{ id: "r1", status: "running", createdAt: new Date(0).toISOString(), finishedAt: null }];
const base = fakeApi({ "/runs": () => runs, "/comments": () => [], "/documents/": () => null, "/workspace-operations": () => [] }, "in_progress");
let clock = 0;
const api = { ...base.api, get: base.api.get.bind(base.api), async post(route: string) {
if (route === "/api/heartbeat-runs/r1/cancel") { runs = [{ ...runs[0]!, status: "cancelled" }]; return { id: "r1" }; }
if (route.endsWith("/comments")) {
runs = [...runs, { id: "r2", status: "running", createdAt: new Date(clock - 10).toISOString(), finishedAt: null, wakeCommentIds: ["continue-1"] }];
return { id: "continue-1" };
}
return { id: "i1", identifier: "CHA-1" };
} } as unknown as ChaosApi;
const observer = new TaskObserver({ ...plan, workload: "S1", followUpAfterMs: 1_000 }, api, lab, new Timeline(null), ready);
await observer.launch();
const start = observer.record.createdAt!;
await observer.poll(start + 2_000);
clock = start + 2_000 + STOP_CONTINUE_AFTER_MS + 1;
await observer.poll(clock);
await observer.poll(clock + 3_000);
const artifacts = await observer.collectArtifacts();
expect(artifacts.stop).toMatchObject({ runId: "r1", runStatus: "cancelled", runsStartedBeforeContinue: [], continuePosted: true });
});
it("S1: counts a run recovery started between the poll's reads and the continue request", async () => {
let runs: Array<Record<string, unknown>> = [{ id: "r1", status: "running", createdAt: new Date(0).toISOString(), finishedAt: null }];
let stopped = false;
const base = fakeApi({ "/runs": () => runs, "/comments": () => [], "/documents/": () => null, "/workspace-operations": () => [] }, "in_progress");
let sentAt = 0;
const api = { ...base.api, async get(route: string) {
const value = await base.api.get(route);
if (stopped && route.endsWith("/runs") && runs.length === 1) {
runs = [...runs, { id: "r2", status: "queued", createdAt: new Date(sentAt - 5).toISOString(), finishedAt: null }];
}
return value;
}, async post(route: string) {
if (route === "/api/heartbeat-runs/r1/cancel") { stopped = true; runs = [{ ...runs[0]!, status: "cancelled" }]; return { id: "r1" }; }
if (route.endsWith("/comments")) return { id: "continue-1" };
return { id: "i1", identifier: "CHA-1" };
} } as unknown as ChaosApi;
const observer = new TaskObserver({ ...plan, workload: "S1", followUpAfterMs: 1_000 }, api, lab, new Timeline(null), ready);
await observer.launch();
const start = observer.record.createdAt!;
await observer.poll(start + 2_000);
const at = start + 2_000 + STOP_CONTINUE_AFTER_MS + 1;
sentAt = at + 100;
runs = [runs[0]!];
observer.record.followUp = { at, sentAt, postedAt: sentAt + 50, commentId: "continue-1", error: null, duringRun: false };
observer.record.samples.push({ at: sentAt + 200, reachable: true, serverReady: true, issueStatus: "in_progress", interruption: null,
activeRecoveryAction: false, recoveryOwner: null, executionBlocker: false, scheduledRetry: false,
runs: [runs[0], { id: "r2", status: "queued", createdAt: new Date(sentAt - 5).toISOString(), finishedAt: null }] } as never);
const artifacts = await observer.collectArtifacts();
expect(artifacts.stop?.runsStartedBeforeContinue).toEqual(["r2"]);
});
it("closes an open task as unfinished at interrupt, so the wave grades it as a failure", async () => {
const { api } = fakeApi({}, "in_progress");
const timeline = new Timeline(null);
+50 -3
View File
@@ -50,6 +50,7 @@ const RESULT_TRIGGER_EVENTS = new Set(["run.result.proposed", "run.result.accept
export const REST_CONFIRM_MS = 12_000;
export const CHILD_WAKE_GRACE_MS = 60_000;
export const CHILD_STALL_CHECK_MS = 10_000;
export const STOP_CONTINUE_AFTER_MS = 20_000;
export function childrenPending(children: ReadonlyArray<{ status: string | null }> | null): boolean {
return !children || children.some(child => !["done", "cancelled", "blocked"].includes(child.status ?? ""));
@@ -344,6 +345,11 @@ export class TaskObserver {
case "tool": return this.record.benchFirstSeenAt ?? null;
case "approval": return this.record.governance?.approvalSeenAt ?? null;
case "paused": return this.record.governance?.budgetPausedAt ?? null;
case "stop": {
const stop = this.record.stop;
if (!stop) return null;
return stop.error ? "missed" : stop.at;
}
case "result": {
// B8: the run that received the follow-up has accepted its result but
// has not settled yet. A follow-up posted at rest cannot cross it.
@@ -415,6 +421,10 @@ export class TaskObserver {
this.restCandidateSince = null;
this.childrenSettledAt = null;
}
if (await this.maybeStop(sample, now)) {
this.restCandidateSince = null;
return;
}
if (await this.maybeFollowUp(sample, now)) {
// The comment response is not the agent's answer: require fresh samples.
this.restCandidateSince = null;
@@ -490,18 +500,42 @@ export class TaskObserver {
}
}
private async maybeStop(sample: TaskSample, now: number): Promise<boolean> {
if (!this.definition.stopBeforeFollowUp || this.record.stop || this.plan.followUpAfterMs === null || !sample.reachable) return false;
if (now < this.record.createdAt! + this.plan.followUpAfterMs) return false;
const running = (sample.runs ?? []).find(run => run.status === "running");
if (!running) return false;
const stop: NonNullable<TaskRecord["stop"]> = { at: now, runId: running.id, httpStatus: null, error: null };
try {
await this.api.post(`/api/heartbeat-runs/${running.id}/cancel`, { cancellationRequestId: randomUUID() });
stop.httpStatus = 200;
} catch (error) {
stop.httpStatus = error instanceof ChaosApiError ? error.status : null;
stop.error = error instanceof Error ? error.message : String(error);
}
this.record.stop = stop;
this.timeline.add("task_stop_clicked", { taskId: this.plan.taskId, ...stop });
return true;
}
private async maybeFollowUp(sample: TaskSample, now: number): Promise<boolean> {
const definition = this.definition;
if (!definition.followUp || this.record.followUp || this.plan.followUpAfterMs === null || !sample.reachable) return false;
if (now < this.record.createdAt! + this.plan.followUpAfterMs) return false;
if (definition.stopBeforeFollowUp) {
const stop = this.record.stop;
if (!stop || now - stop.at < STOP_CONTINUE_AFTER_MS || this.supervisor.faultInProgress) return false;
if ((sample.runs ?? []).some(run => run.status === "running" || run.status === "queued")) return false;
}
// C1 targets a live run (B8); wait for one unless the task already rests.
const running = (sample.runs ?? []).some(run => run.status === "running");
if (!running && !isResting(sample)) return false;
if (!running && !definition.stopBeforeFollowUp && !isResting(sample)) return false;
const sentAt = Math.max(now, Date.now());
try {
const comment = await this.api.post<Json>(`/api/issues/${this.record.issueId}/comments`, { body: definition.followUp(this.plan.nonce) });
this.record.followUp = { at: now, postedAt: Math.max(now, Date.now()), commentId: comment?.id ?? null, error: null, duringRun: running };
this.record.followUp = { at: now, sentAt, postedAt: Math.max(now, Date.now()), commentId: comment?.id ?? null, error: null, duringRun: running };
} catch (error) {
this.record.followUp = { at: now, commentId: null, error: error instanceof Error ? error.message : String(error), duringRun: running };
this.record.followUp = { at: now, sentAt, commentId: null, error: error instanceof Error ? error.message : String(error), duringRun: running };
}
this.timeline.add("task_follow_up", { taskId: this.plan.taskId, ...this.record.followUp });
return true;
@@ -567,6 +601,18 @@ export class TaskObserver {
return this.retryBlockedChildren(blocked, now);
}
private stopArtifacts(stop: NonNullable<TaskRecord["stop"]>): NonNullable<OracleArtifacts["stop"]> {
const runs = new Map<string, NonNullable<TaskSample["runs"]>[number]>();
for (const sample of this.record.samples) for (const run of sample.runs ?? []) runs.set(run.id, run);
const continueAt = this.record.followUp?.sentAt ?? this.record.followUp?.at ?? null;
const continueCommentId = this.record.followUp?.commentId ?? null;
const startedBeforeContinue = [...runs.values()].filter(run => run.id !== stop.runId && run.createdAt
&& !(continueCommentId && (run.wakeCommentIds ?? []).includes(continueCommentId))
&& Date.parse(run.createdAt) > stop.at && (continueAt === null || Date.parse(run.createdAt) < continueAt)).map(run => run.id);
return { runId: stop.runId, runStatus: runs.get(stop.runId)?.status ?? null, runsStartedBeforeContinue: startedBeforeContinue,
continuePosted: Boolean(this.record.followUp?.commentId) };
}
private async retryBlockedChildren(childIds: readonly string[], now: number): Promise<boolean> {
let clicked = false;
for (const childIssueId of childIds) {
@@ -831,6 +877,7 @@ export class TaskObserver {
agentComments: (comments ?? []).filter(comment => comment.authorAgentId).map(comment => String(comment.body ?? "")),
files, commands, workspacePath,
...(this.plan.workload === "B5" ? { benchProgressObserved: this.record.benchProgressSeen?.length ?? 0 } : {}),
...(this.record.stop ? { stop: this.stopArtifacts(this.record.stop) } : {}),
...(definition.collect.childDocuments ? { children: await this.collectChildren(issueId),
childStalls: this.record.childStalls ?? [], stallWakes: await this.collectStallWakes(issueId) } : {}),
...(this.plan.workload === "G1" ? { approvals: await this.collectApprovals(issueId) } : {}),
+3 -1
View File
@@ -44,6 +44,7 @@ export const TRIGGER_DELAY_RANGE_MS: Record<Exclude<TriggerId, "time">, readonly
result: [0, 3_000],
approval: [0, 3_000],
paused: [0, 3_000],
stop: [0, 2_000],
};
export function triggerFitsWorkload(trigger: TriggerId, workload: WorkloadId): boolean {
@@ -51,11 +52,12 @@ export function triggerFitsWorkload(trigger: TriggerId, workload: WorkloadId): b
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" };
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"];
+5 -3
View File
@@ -7,13 +7,13 @@ export type FaultKind = (typeof FAULT_KINDS)[number];
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"] as const;
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"] as const;
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;
@@ -221,6 +221,7 @@ export interface OracleArtifacts {
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 }>;
@@ -281,7 +282,8 @@ export interface TaskRecord {
finalStatus: string | null;
deadlineAt: number | null;
timedOut: boolean;
followUp: { at: number; postedAt?: number; commentId: string | null; error: string | null; duringRun?: boolean } | null;
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). */
+25
View File
@@ -16,6 +16,7 @@ export interface WorkloadDefinition {
title(nonce: string): string;
description(nonce: string, agents?: WorkloadAgents): string;
followUp?(nonce: string): string;
stopBeforeFollowUp?: boolean;
/** Files and commands the observer must collect for the T-oracle. */
collect: { documents: string[]; files(nonce: string): string[]; commands: Array<{ name: string; argv: string[] | ((nonce: string) => string[]) }>;
childDocuments?: string[] };
@@ -35,6 +36,7 @@ const partMarker = (nonce: string, part: number) => `PART${part}-${nonce}`;
const briefMarker = (nonce: string) => `CHAOS-${nonce}`;
const followUpMarker = (nonce: string) => `FOLLOWUP-${nonce}`;
const continueMarker = (nonce: string) => `CONTINUE-${nonce}`;
const notesFile = (nonce: string) => `NOTES-${nonce}.md`;
const benchFile = (nonce: string) => `bench-result-${nonce}.txt`;
const benchRecord = (nonce: string) => `.bench/run-${nonce}.txt`;
@@ -251,6 +253,29 @@ export const workloadDefinitions: Record<WorkloadId, WorkloadDefinition> = {
return result([statusCheck(artifacts), ...briefChecks(artifacts.documents.brief, [briefMarker(nonce), followUpMarker(nonce)])]);
},
},
S1: {
id: "S1",
label: "Stop while running, then continue",
usesRepository: false,
fireWindowMs: [20_000, 60_000],
followUpWindowMs: [8_000, 25_000],
budgetMs: 12 * 60_000,
title: nonce => `Chaos S1 brief stopped and continued ${nonce}`,
description: briefDescription,
stopBeforeFollowUp: true,
followUp: nonce => `I stopped your previous run on purpose. Please continue the task now: finish the \`brief\` document and also include the line \`${continueMarker(nonce)}\` on its own line before you mark the task done.`,
collect: { documents: ["brief"], files: () => [], commands: [] },
grade(nonce, artifacts) {
const stop = artifacts.stop;
return result([
statusCheck(artifacts),
...briefChecks(artifacts.documents.brief, [briefMarker(nonce), continueMarker(nonce)]),
{ name: "stop_took_effect", ok: stop?.runStatus === "cancelled", detail: `run=${stop?.runId ?? "none"} status=${stop?.runStatus ?? "none"}` },
{ name: "nothing_ran_after_stop", ok: Boolean(stop) && stop!.continuePosted && stop!.runsStartedBeforeContinue.length === 0,
detail: stop ? `continue=${stop.continuePosted} runs=${stop.runsStartedBeforeContinue.join(",") || "none"}` : "not stopped" },
]);
},
},
B1: {
id: "B1",
label: "Python CLI with tests",