Files
PaperClipAI/tests/restart-chaos/observer.ts
T
DottaandClaude Opus 5.5 e8f07b2a39 test(restart-chaos): fire faults relative to user actions and run state, add K6 crash loop (#19513)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The control plane must keep tasks moving when the server restarts
> - The restart chaos harness (#19477) kills the server only at a random
time after a task starts
> - Many restart bugs depend on what the user or the run was doing at
that moment: a comment just sent, a Retry just clicked, a run still
queued, a long tool call, or a second crash during startup recovery
> - The harness cannot aim at these moments yet, so these cases occur
only by chance
> - This pull request adds fault triggers that fire relative to a user
action or a run state, and the K6 crash loop
> - The benefit is that chaos waves can target these boundaries on
purpose and replay them

## Linked Issues or Issue Description

Refs #19477

**What is the problem?**
The restart chaos harness fires each fault at a seeded random offset
from task launch. It cannot fire a fault right after the harness posts a
comment to an active run, right after a Retry click, while a run is
queued, during a long tool call, or a second time while the server is in
the `recovering` startup phase.

**Why is it a problem?**
These are the boundaries where restart bugs hide (plan faults K6 and K8,
boundaries B1, B6 and B8). Random timing seldom hits them, and a replay
cannot repeat them.

**What is the proposed solution?**
Add a `--triggers` option. Each task samples a trigger with its cell.
The observer arms the fault when the trigger condition holds, then fires
it after a seeded delay. Add the K6 crash-loop fault to the supervisor.

## What Changed

- `types.ts`, `config.ts`: add `TRIGGER_IDS` (`time`, `comment`,
`retry`, `queued`, `tool`), the `--triggers` option, and the `K6` fault
kind.
- `scheduler.ts`: sample a trigger per task. `comment` pairs only with
C1 and `tool` only with B5. Each trigger has a seeded delay range.
`retry` adds an extra K1 after the planned fault. A replay re-arms the
task's own triggered faults.
- `observer.ts`: arm each triggered fault from the observed state: the
follow-up reached a running run, an accepted Retry, the first run still
queued, or the first `bench.sh` progress. A trigger that can no longer
hold is recorded as `triggerMissed`.
- `supervisor.ts`: K6 kills the replacement again as soon as
`/api/ready` reports `recovering` (or `ready`, if recovery was too fast
to see), then once more just after `ready`. The receipt records the
phase that each kill hit.
- `main.ts`, `ledger.ts`: pass the trigger to the fault receipt, the
timeline and the ledger. The summary shows the trigger in each cell.
- `triggers.test.ts`: unit tests for planning, firing, misses, replay
and the crash-loop phase rule.
- `README.md`: document the triggers and K6.

## Verification

- `pnpm exec vitest run --config tests/restart-chaos/vitest.config.ts`
(52 tests pass).
- `node scripts/typecheck-test-projects.mjs tests/restart-chaos` passes.
- Live: `node cli/node_modules/tsx/dist/cli.mjs
tests/restart-chaos/main.ts --env local --adapters codex,claude,opencode
--faults K1,K6 --triggers comment,retry,queued,tool --concurrency 3
--tasks 6` runs waves with the new triggers.

## Risks

- Low risk. The change is in the test harness only. The default trigger
is `time`, so existing waves and replays keep their behavior.
- A trigger can miss its window (for example, a run starts before the
first poll). The ledger records the miss, so the task is not counted as
a crossing of that boundary.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use (shell, file edits).

## 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-06 13:49:52 -06:00

452 lines
23 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { readdir, readFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { ChaosApiError, type ChaosApi } from "./api.js";
import { ACTIVE_RUN_STATUSES, isStalledSample, STALL_LIMIT_MS } from "./grader.js";
import type { ChaosLab } from "./seed.js";
import type { Timeline } from "./timeline.js";
import type {
InterruptionSample, OracleArtifacts, RestSweep, RetryAttempt, RunSample, ScheduledFault, TaskPlan, TaskRecord, TaskSample,
} from "./types.js";
import { runOracleCommand } from "./oracle-sandbox.js";
import { taskFaultSchedule } from "./scheduler.js";
import { workloadDefinitions } from "./workloads.js";
const IGNORED_DIRECTORIES = new Set([".git", ".bench", "__pycache__", "node_modules"]);
const MAX_SNAPSHOT_FILES = 2_000;
/** Relative paths of workspace files, excluding VCS and cache directories. */
export async function listWorkspaceFiles(root: string): Promise<string[]> {
const out: string[] = [];
const walk = async (relative: string) => {
if (out.length >= MAX_SNAPSHOT_FILES) return;
const entries = await readdir(path.join(root, relative), { withFileTypes: true });
for (const entry of entries) {
const child = relative ? `${relative}/${entry.name}` : entry.name;
if (entry.isDirectory()) { if (!IGNORED_DIRECTORIES.has(entry.name)) await walk(child); }
else if (entry.isFile()) out.push(child);
if (out.length >= MAX_SNAPSHOT_FILES) return;
}
};
await walk("");
return out.sort();
}
export const QUEUED_TRIGGER_MAX_EMPTY_SAMPLES = 3;
function stillQueued(runs: readonly RunSample[]): boolean {
return runs.some(run => run.status === "queued" && !run.startedAt) && !runs.some(run => run.startedAt || run.status === "running");
}
export const TRIGGER_LATE_MS = 10_000;
/** Rest must hold for this long before the observer acts on it. */
export const REST_CONFIRM_MS = 12_000;
/** Extra time granted after a Retry click before R3 is graded as timed out. */
export const RETRY_BUDGET_MS = 15 * 60_000;
type Json = Record<string, any>;
export function toRunSample(run: Json): RunSample {
const list = (value: unknown) => (Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []);
const wake = new Set([...list(run.wakeCommentIds), ...(typeof run.wakeCommentId === "string" ? [run.wakeCommentId] : [])]);
return {
id: String(run.runId ?? run.id), status: String(run.status), createdAt: run.createdAt ?? null, startedAt: run.startedAt ?? null,
finishedAt: run.finishedAt ?? null, errorCode: run.errorCode ?? null, retryOfRunId: run.retryOfRunId ?? null,
wakeCommentIds: [...wake], livenessState: run.livenessState ?? null,
lastEvidenceAt: latestIso([run.startedAt, run.lastUsefulActionAt, run.execution?.lastConfirmedActivityAt]),
};
}
export function latestIso(values: ReadonlyArray<unknown>): string | null {
let best: number | null = null;
for (const value of values) {
const at = typeof value === "string" ? Date.parse(value) : NaN;
if (Number.isFinite(at) && (best === null || at > best)) best = at;
}
return best === null ? null : new Date(best).toISOString();
}
export const RUN_EVENT_PAGE = 500;
export function toInterruption(value: Json | null | undefined): InterruptionSample | null {
if (!value) return null;
return { state: String(value.state), canRetry: value.canRetry === true, retryWait: Boolean(value.retryWait),
workspaceFailure: value.workspaceFailure?.message ?? null, observedRunId: value.observedRunId ?? null, userAction: value.userAction ?? null };
}
/** Rest = no live run and nothing the system itself is still doing. */
export function isResting(sample: TaskSample): boolean {
if (!sample.reachable || !sample.serverReady) return false;
if ((sample.runs ?? []).some(run => ACTIVE_RUN_STATUSES.has(run.status))) return false;
if (sample.scheduledRetry) return false;
// A board-owned recovery waits on a person: that is a resting state.
if (sample.activeRecoveryAction && sample.recoveryOwner !== "board") return false;
const interruption = sample.interruption;
if (interruption && (interruption.state === "working" || interruption.state === "retry_scheduled" || interruption.retryWait)) return false;
// Unexplained idle work is a possible stall, not a rest: keep watching it so
// the task can still resume and R5 can measure the stall.
if (isStalledSample(sample)) return false;
return true;
}
/** Stop watching an unexplained idle task once it exceeds the R5 stall limit. */
export const STALL_GIVE_UP_MS = STALL_LIMIT_MS + 15_000;
export class TaskObserver {
readonly record: TaskRecord;
issue: Json | null = null;
private restCandidateSince: number | null = null;
private stallSince: number | null = null;
private readonly faultSchedule: ScheduledFault[];
private readonly pendingFaults: ScheduledFault[];
done = false;
constructor(readonly plan: TaskPlan, private readonly api: ChaosApi, private readonly lab: ChaosLab,
private readonly timeline: Timeline, private readonly supervisor: { serverReady: boolean; faultInProgress: boolean },
/** Paths oracle commands may not read: the operator's home, the instance root (secrets key, database) and results. */
private readonly protectedRoots: readonly string[] = [os.homedir()]) {
this.record = { plan, issueId: null, identifier: null, createdAt: null, launchError: null, samples: [], retry: null,
restAt: null, finalStatus: null, deadlineAt: null, timedOut: false, followUp: null, faultIds: [], sweep: null,
preFaultFiles: null, lostFiles: null };
this.faultSchedule = taskFaultSchedule(plan);
this.pendingFaults = [...this.faultSchedule];
}
get definition() { return workloadDefinitions[this.plan.workload]; }
async launch(): Promise<void> {
const definition = this.definition;
try {
const issue = await this.api.post<Json>(`/api/companies/${this.lab.companyId}/issues`, {
title: definition.title(this.plan.nonce),
description: definition.description(this.plan.nonce),
status: "todo",
priority: "medium",
assigneeAgentId: this.lab.agents[this.plan.adapter].id,
...(definition.usesRepository ? { projectId: this.lab.projectId } : {}),
});
this.record.issueId = String(issue.id);
this.record.identifier = issue.identifier ?? null;
this.record.createdAt = Date.now();
this.record.deadlineAt = this.record.createdAt + definition.budgetMs;
this.timeline.add("task_launched", { taskId: this.plan.taskId, issueId: issue.id, identifier: issue.identifier,
workload: this.plan.workload, adapter: this.plan.adapter, faults: this.faultSchedule });
} catch (error) {
this.record.launchError = error instanceof Error ? error.message : String(error);
this.done = true;
this.timeline.add("task_launch_failed", { taskId: this.plan.taskId, error: this.record.launchError });
}
}
/** The next scheduled fault once it is due and the task has not rested yet. */
faultDue(now: number): ScheduledFault | null {
if (this.done || this.record.createdAt === null) return null;
for (const fault of [...this.pendingFaults]) {
const armedAt = this.triggerArmedAt(fault);
if (armedAt === null) continue;
const dueAt = armedAt === "missed" ? 0 : armedAt + (fault.delayMs ?? 0);
if (armedAt !== "missed" && now < dueAt) continue;
this.pendingFaults.splice(this.pendingFaults.indexOf(fault), 1);
const triggered = fault.trigger !== undefined && fault.trigger !== "time";
if (armedAt === "missed" || (triggered && !this.triggerWindowOpen(fault, now, dueAt))) {
this.noteTriggerMissed(fault);
continue;
}
return fault;
}
return null;
}
private triggerWindowOpen(fault: ScheduledFault, now: number, dueAt: number): boolean {
if (now - dueAt > TRIGGER_LATE_MS) return false;
if (fault.trigger !== "queued") return true;
const latest = [...this.record.samples].reverse().find(sample => sample.reachable);
return stillQueued(latest?.runs ?? []);
}
async confirmTrigger(fault: ScheduledFault, now: number): Promise<boolean> {
if (!fault.trigger || fault.trigger === "time") return true;
const armedAt = this.triggerArmedAt(fault);
let open = typeof armedAt === "number" && now - (armedAt + (fault.delayMs ?? 0)) <= TRIGGER_LATE_MS;
if (open && fault.trigger === "queued") {
const runs = await this.api.get<Json[]>(`/api/issues/${this.record.issueId}/runs`, 5_000)
.then(rows => (rows ?? []).map(toRunSample)).catch(() => null);
open = runs !== null && stillQueued(runs);
}
if (!open) this.noteTriggerMissed(fault);
return open;
}
private noteTriggerMissed(fault: ScheduledFault) {
const trigger = fault.trigger ?? "time";
(this.record.triggerMissed ??= []).push(trigger);
this.timeline.add("trigger_missed", { taskId: this.plan.taskId, trigger, fault: fault.kind });
}
private triggerArmedAt(fault: ScheduledFault): number | null | "missed" {
const createdAt = this.record.createdAt!;
switch (fault.trigger ?? "time") {
case "time": return createdAt + fault.atMs;
case "comment": {
const followUp = this.record.followUp;
if (!followUp) return null;
return followUp.commentId && followUp.duringRun ? followUp.postedAt ?? followUp.at : "missed";
}
case "retry": {
const retry = this.record.retry;
if (!retry) return null;
return retry.accepted ? retry.acceptedAt ?? retry.at : "missed";
}
case "queued": {
const first = this.record.samples.find(sample => sample.reachable && (sample.runs ?? []).length > 0);
if (!first) return this.record.samples.filter(sample => sample.reachable).length >= QUEUED_TRIGGER_MAX_EMPTY_SAMPLES ? "missed" : null;
const queued = (first.runs ?? []).some(run => run.status === "queued" && !run.startedAt);
const started = (first.runs ?? []).some(run => run.startedAt || run.status === "running");
return queued && !started ? first.at : "missed";
}
case "tool": return this.record.benchFirstSeenAt ?? null;
}
}
private closeUnfiredTriggers() {
for (const fault of this.pendingFaults.splice(0)) if (fault.trigger && fault.trigger !== "time") this.noteTriggerMissed(fault);
}
/**
* B5: read bench.sh's progress file while the task is live. The harness sees
* the values change over minutes, which a computed answer cannot produce.
*/
private async observeBenchProgress(): Promise<void> {
if (this.plan.workload !== "B5") return;
const cwd = (this.issue?.currentExecutionWorkspace as Json | null | undefined)?.cwd;
if (typeof cwd !== "string") return;
const value = (await readFile(path.join(cwd, ".bench", "progress"), "utf8").catch(() => "")).trim();
if (!value) return;
const seen = this.record.benchProgressSeen ??= [];
if (!seen.includes(value)) seen.push(value);
this.record.benchFirstSeenAt ??= Date.now();
}
/** R2: remember which workspace files existed before the first fault hit this task. */
async snapshotBeforeFault(): Promise<void> {
if (this.record.preFaultFiles !== null || this.done) return;
// One task's snapshot failure must not end the wave; R2 fails with the reason.
try {
const workspace = await this.workspacePath();
if (workspace) { this.record.preFaultFiles = await listWorkspaceFiles(workspace); this.record.preFaultWorkspacePath = workspace; }
} catch (error) {
this.record.preFaultSnapshotError = error instanceof Error ? error.message : String(error);
this.timeline.add("pre_fault_snapshot_failed", { taskId: this.plan.taskId, error: this.record.preFaultSnapshotError });
}
}
noteFault(faultId: string, startedAt?: number) {
if (!this.record.faultIds.includes(faultId)) this.record.faultIds.push(faultId);
if (startedAt !== undefined) this.record.lastFaultAt = Math.max(this.record.lastFaultAt ?? 0, startedAt);
}
/** Outages extend the deadline: R1 allows budget plus outage time. */
extendDeadline(ms: number) { if (this.record.deadlineAt !== null) this.record.deadlineAt += ms; }
async poll(now = Date.now()): Promise<void> {
if (this.done || !this.record.issueId) return;
const sample = await this.sample(now);
this.record.samples.push(sample);
await this.observeBenchProgress();
if (await this.maybeFollowUp(sample, now)) {
// The comment response is not the agent's answer: require fresh samples.
this.restCandidateSince = null;
return;
}
this.stallSince = isStalledSample(sample) ? (this.stallSince ?? now) : null;
if (this.stallSince !== null && now - this.stallSince > STALL_GIVE_UP_MS) {
this.record.restAt = this.stallSince;
this.record.finalStatus = sample.issueStatus ?? null;
this.done = true;
this.timeline.add("task_stalled", { taskId: this.plan.taskId, status: sample.issueStatus, idleMs: now - this.stallSince });
return;
}
if (!isResting(sample) || this.supervisor.faultInProgress) {
this.restCandidateSince = null;
} else {
this.restCandidateSince ??= now;
if (now - this.restCandidateSince >= REST_CONFIRM_MS) {
await this.onRest(sample, now);
return;
}
}
if (this.record.deadlineAt !== null && now > this.record.deadlineAt && !this.supervisor.faultInProgress) {
this.record.timedOut = true;
this.record.finalStatus = sample.issueStatus ?? this.record.finalStatus;
this.done = true;
this.timeline.add("task_timed_out", { taskId: this.plan.taskId, status: sample.issueStatus });
}
}
private readonly runEventCursors = new Map<string, { seq: number; lastAt: string | null }>();
private async runEvidence(run: RunSample): Promise<RunSample> {
if (run.status !== "running") return run;
const cursor = this.runEventCursors.get(run.id) ?? { seq: 0, lastAt: null };
for (let page = 0; page < 3; page += 1) {
const events = await this.api.get<Json[]>(`/api/heartbeat-runs/${run.id}/events?afterSeq=${cursor.seq}&limit=${RUN_EVENT_PAGE}`, 5_000).catch(() => null);
if (!events?.length) break;
for (const event of events) {
if (typeof event.seq === "number" && event.seq > cursor.seq) cursor.seq = event.seq;
cursor.lastAt = latestIso([cursor.lastAt, event.createdAt]);
}
if (events.length < RUN_EVENT_PAGE) break;
}
this.runEventCursors.set(run.id, cursor);
return { ...run, lastEvidenceAt: latestIso([run.lastEvidenceAt, cursor.lastAt]) };
}
private async sample(now: number): Promise<TaskSample> {
const serverReady = this.supervisor.serverReady && !this.supervisor.faultInProgress;
try {
const [issue, runs] = await Promise.all([
this.api.get<Json>(`/api/issues/${this.record.issueId}`, 5_000),
this.api.get<Json[]>(`/api/issues/${this.record.issueId}/runs`, 5_000),
]);
this.issue = issue;
return { at: now, reachable: true, serverReady, issueStatus: String(issue.status),
interruption: toInterruption(issue.interruption), activeRecoveryAction: Boolean(issue.activeRecoveryAction),
recoveryOwner: issue.activeRecoveryAction?.ownerType ?? null,
executionBlocker: Boolean(issue.executionBlocker), scheduledRetry: Boolean(issue.scheduledRetry),
runs: await Promise.all((runs ?? []).map(toRunSample).map(run => this.runEvidence(run))) };
} catch {
return { at: now, reachable: false, serverReady: false };
}
}
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;
// 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;
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 };
} catch (error) {
this.record.followUp = { at: now, 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;
}
private async onRest(sample: TaskSample, now: number) {
const status = sample.issueStatus ?? null;
// C1: a follow-up posted after rest must be served before the task counts as resting.
if (this.definition.followUp && !this.record.followUp) return;
const faultSinceRetry = this.record.retry !== null && (this.record.lastFaultAt ?? 0) > this.record.retry.at;
if (status !== "done" && (!this.record.retry || faultSinceRetry) && sample.interruption?.canRetry) {
if (this.record.retry) (this.record.priorRetries ??= []).push(this.record.retry);
this.record.retry = await this.retry(sample, now);
this.restCandidateSince = null;
if (this.record.retry.accepted && this.record.deadlineAt !== null) this.record.deadlineAt = Math.max(this.record.deadlineAt, now + RETRY_BUDGET_MS);
return;
}
this.record.restAt = this.restCandidateSince ?? now;
this.record.finalStatus = status;
this.done = true;
this.timeline.add("task_rested", { taskId: this.plan.taskId, status, interruption: sample.interruption?.state ?? null,
canRetry: sample.interruption?.canRetry ?? false, retried: Boolean(this.record.retry) });
}
/** Exactly one click, with the same body ui/src/hooks/useTaskRetry.ts sends. */
private async retry(sample: TaskSample, now: number): Promise<RetryAttempt> {
const observedRunId = sample.interruption?.observedRunId ?? (this.issue?.executionBlocker?.runId as string | undefined) ?? null;
const attempt: RetryAttempt = { at: now, observedRunId, httpStatus: null, accepted: null, error: null };
try {
const result = await this.api.post<{ accepted?: boolean }>(`/api/issues/${this.record.issueId}/retry`,
{ idempotencyKey: randomUUID(), observedRunId });
attempt.httpStatus = 200;
attempt.accepted = result?.accepted === true;
attempt.acceptedAt = Math.max(now, Date.now());
} catch (error) {
attempt.httpStatus = error instanceof ChaosApiError ? error.status : null;
attempt.error = error instanceof Error ? error.message : String(error);
}
this.timeline.add("task_retry_clicked", { taskId: this.plan.taskId, ...attempt });
return attempt;
}
async workspacePath(): Promise<string | null> {
if (!this.definition.usesRepository) return null;
const current = this.issue?.currentExecutionWorkspace as Json | null | undefined;
if (typeof current?.cwd === "string") return current.cwd;
const workspaceId = this.issue?.executionWorkspaceId;
if (typeof workspaceId === "string") {
const workspace = await this.api.get<Json>(`/api/execution-workspaces/${workspaceId}`).catch(() => null);
if (typeof workspace?.cwd === "string") return workspace.cwd;
}
return null;
}
async collectArtifacts(): Promise<OracleArtifacts> {
const definition = this.definition;
const issueId = this.record.issueId!;
const issue = await this.api.get<Json>(`/api/issues/${issueId}`).catch(() => this.issue);
if (issue) this.issue = issue;
const documents: Record<string, string | null> = {};
for (const key of definition.collect.documents) {
const document = await this.api.get<Json>(`/api/issues/${issueId}/documents/${key}`).catch(() => null);
documents[key] = typeof document?.body === "string" ? document.body : null;
}
const comments = await this.api.get<Json[]>(`/api/issues/${issueId}/comments`).catch(() => []);
const workspacePath = await this.workspacePath();
const snapshotPath = this.record.preFaultWorkspacePath ?? workspacePath;
if (snapshotPath && this.record.preFaultFiles) {
if (workspacePath && snapshotPath !== workspacePath) this.record.workspaceMovedTo = workspacePath;
try {
const present = new Set(await listWorkspaceFiles(snapshotPath));
this.record.lostFiles = this.record.preFaultFiles.filter(file => !present.has(file));
} catch (error) {
this.record.restSnapshotError = error instanceof Error ? error.message : String(error);
}
}
const files: Record<string, string | null> = {};
const commands: OracleArtifacts["commands"] = {};
if (workspacePath) {
for (const relative of definition.collect.files(this.plan.nonce)) {
files[relative] = await readFile(path.join(workspacePath, relative), "utf8").catch(() => null);
}
// Agent-editable code: run it sandboxed, away from the operator's home directory.
for (const command of definition.collect.commands) commands[command.name] = await runOracleCommand(command.argv, workspacePath, { protectedRoots: this.protectedRoots });
}
return {
issueStatus: issue?.status ?? this.record.finalStatus,
documents,
agentComments: (comments ?? []).filter(comment => comment.authorAgentId).map(comment => String(comment.body ?? "")),
files, commands, workspacePath,
...(this.plan.workload === "B5" ? { benchProgressObserved: this.record.benchProgressSeen?.length ?? 0 } : {}),
};
}
/** R6 sweep: nothing still pending once the wave is quiet. */
async sweep(): Promise<RestSweep> {
this.closeUnfiredTriggers();
if (!this.record.issueId) return { activeRuns: [], runningWorkspaceOperations: [], activeRecoveryAction: false, error: null };
try {
const [issue, runs] = await Promise.all([
this.api.get<Json>(`/api/issues/${this.record.issueId}`),
this.api.get<Json[]>(`/api/issues/${this.record.issueId}/runs`),
]);
const samples = runs.map(toRunSample);
const operations: string[] = [];
for (const run of samples) {
// A failed read must not look like a clean rest: let it reach sweep.error.
const rows = await this.api.get<Json[]>(`/api/heartbeat-runs/${run.id}/workspace-operations`);
for (const row of rows ?? []) if (row.status === "running") operations.push(String(row.id));
}
return { activeRuns: samples.filter(run => ACTIVE_RUN_STATUSES.has(run.status)).map(run => run.id),
runningWorkspaceOperations: operations, activeRecoveryAction: Boolean(issue.activeRecoveryAction), error: null };
} catch (error) {
return { activeRuns: [], runningWorkspaceOperations: [], activeRecoveryAction: false, error: error instanceof Error ? error.message : String(error) };
}
}
}