mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
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:
1 parent
b54317d3fb
commit
61fa13c6d8
9 files changed
+189
-9
No files matched your search
@@ -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
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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"] }))
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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,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) } : {}),
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user