mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 17:22:00 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The restart chaos harness (`tests/restart-chaos/`) runs real agent tasks against an isolated control plane and injects faults > - Cloud servers run in the volume-free mode (`PAPERCLIP_EPHEMERAL_SERVER=true`, durable agent files, run logs in object storage) > - The harness never set that mode, so every wave ran a different server mode from staging. Paths that exist only in that mode were never tested. One of them is the per-agent directory operation lock, so `--fail-recovery-step directory-lock` could never reach its step with any workload > - The re-wake workloads W2 and W2C also lost coverage: an agent that reported phase 1 with disposition `done` closed the task, and the harness never sent the re-wake (four of eight Claude cells on 2026-11-11) > - This pull request adds `--ephemeral-server` and makes the W2/W2C brief ask for a `yielded` phase-1 report > - The benefit is that chaos waves can run in staging's server mode and the re-wake cells test the re-wake on every provider ## Linked Issues or Issue Description **What is the problem?** The restart chaos harness cannot run the control plane in the volume-free mode that Cloud and staging use. The `directory-lock` recovery step (`agent_directory.operation_lock.before_acquire`) is reached only for disposable remote copies, which need `PAPERCLIP_EPHEMERAL_SERVER=true`. W2 and W2C cells also stop before the re-wake when the agent reports phase 1 as `done`. **What is the expected behavior?** A wave can opt in to the volume-free mode on Daytona, and every restart of the wave keeps that mode. The W2 and W2C cells reach the re-wake. **Steps to reproduce** Run `--fail-recovery-step agent_directory.operation_lock.before_acquire:1` on Daytona with any workload: the check `recovery_step_failed_once` reports "never reached". Run W2 on Claude: the first run ends with the task `done` and `rewake_sent` is "never sent". ## What Changed - `assertIsolatedServerEnvironment` (`tests/runner-e2e/harness-env.ts`) takes `expected.generatedSecretsMasterKey`. It admits `PAPERCLIP_SECRETS_MASTER_KEY` only when that parameter is set and equals the value exactly. `PAPERCLIP_SECRETS_MASTER_KEY_FILE` stays refused. The check runs over the final server environment. - `--k15-fresh-home` implies `--ephemeral-server` and defaults `--storage` to `s3`. A fresh instance home without the volume-free mode loses the agents' instruction bundles, which staging keeps in Postgres and object storage. - `--ephemeral-server` (`config.ts`, `types.ts`, `main.ts`, `supervisor.ts`, new `ephemeral-server.ts` with its unit test `ephemeral-server.test.ts`): it needs `--storage s3` and `--env daytona` and refuses K14. Every server of the wave gets `PAPERCLIP_EPHEMERAL_SERVER`, `PAPERCLIP_DURABLE_AGENT_FILES`, `PAPERCLIP_SHARED_UI_ASSETS`, the wave's Postgres as `PAPERCLIP_DATABASE_LISTEN_URL`, one stable `PAPERCLIP_SECRETS_MASTER_KEY` (also written to the config key file) and `RUN_LOG_S3_*` in the wave's object store. - `rewake.ts`: the W2/W2C phase-1 brief asks for `paperclip_finish` with disposition `yielded` and a `response_wake` continuation. - W2/W2C tasks are created in `backlog` with their assignee, and a board comment starts phase 1 (`startComment` in `workloads.ts`, used by `observer.ts`). A passive board wait needs a comment-started run; without it the yield queues an immediate continuation and phase 1 wakes itself. The start comment is posted once (saved-comment check plus a stable `clientRequestId`), and the brief keeps phase 2 out of blocking `remainingWork`. - Volume-free mode drops the repository workloads (B1, B5, F1, R1) from the default list and refuses them when listed: their fixture is a `local_path` project source, which a volume-free server refuses. - `README.md`: a section for the new flag and a note on the brief. - `shape-cells.test.ts`: option parsing and refusals for the new flag. ## Verification - `pnpm exec vitest run --config tests/restart-chaos/vitest.config.ts tests/restart-chaos/shape-cells.test.ts` passes (16 tests). - `node scripts/typecheck-test-projects.mjs tests/restart-chaos` passes. - Live proof wave (Daytona, Codex, W2, K1 and K15 with a fresh home, `--storage s3 --ephemeral-server`): wave `w-20261111T205259-s6301` (2 tasks). The first server and both replacements (K15 with a fresh home, then K1) started and reached ready in the volume-free mode. The server process had `PAPERCLIP_EPHEMERAL_SERVER=true`, `PAPERCLIP_DURABLE_AGENT_FILES=true` and `RUN_LOG_S3_BUCKET` set. Both W2 tasks left phase 1 `in_progress` (yielded), received the re-wake and ended `done`. The wave also found product faults (reported separately, not fixed here): a re-wake after K1 failed with `runner_harness_state_mismatch: backup_without_reusable_lease` and needed Retry. - `pnpm exec vitest run --config tests/runner-e2e/vitest.config.ts tests/runner-e2e/support.test.ts` passes, including the new master-key admission test (a matching wave key passes; an inherited key, a mismatched key and `PAPERCLIP_SECRETS_MASTER_KEY_FILE` are refused). ## Risks - Low risk. The flag is opt-in and the harness is test-only. The brief change alters the W2/W2C prompt, so results from older W2/W2C seeds are not directly comparable. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use and code execution. ## 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>
1131 lines
64 KiB
TypeScript
1131 lines
64 KiB
TypeScript
import { spawnSync } from "node:child_process";
|
||
import { createHash, randomUUID } from "node:crypto";
|
||
import { access, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||
import os from "node:os";
|
||
import path from "node:path";
|
||
import { setTimeout as delay } from "node:timers/promises";
|
||
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 {
|
||
AdapterId, GovernanceRecord, InterruptionSample, OracleArtifacts, RestSweep, RetryAttempt, RunSample, ScheduledFault, TaskPlan, TaskRecord,
|
||
TaskSample, TriggerId,
|
||
} from "./types.js";
|
||
import { restartProcessStartMatches } from "../runner-e2e/process-tree-owner.js";
|
||
import type { UiRetryClick } from "./board-ui.js";
|
||
import { runOracleCommand } from "./oracle-sandbox.js";
|
||
import { awaitingQuestionAnswer, collectQuestionArtifacts, questionStep } from "./question-handoff.js";
|
||
import { isSessionFault, rotateAgentSession } from "./session-faults.js";
|
||
import { awaitingRewake, collectRewakeArtifacts, isRewakeWorkload, rewakeStep } from "./rewake.js";
|
||
import { withLongTurn } from "./long-turn.js";
|
||
import { captureRemoteWorkspaceFiles, readRemoteWorkspaceFile } from "./remote-workspace-files.js";
|
||
import { taskFaultSchedule } from "./scheduler.js";
|
||
import { g1ChildTitle, g1Decision, workloadDefinitions, type WorkloadAgents } from "./workloads.js";
|
||
import { databaseUrlFromConfig, journeyDriverFor, type JourneyDriver } from "./journey-driver.js";
|
||
import { withLiveStatusEvidence } from "./live-status-evidence.js";
|
||
import { diskFullRest, diskFullRestReady, isDiskFullFault, type DiskFullFaultSeam } from "./disk-full-faults.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 function stableRequestId(key: string): string {
|
||
const hex = createHash("sha256").update(key).digest("hex");
|
||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-5${hex.slice(13, 16)}-${(8 + (Number.parseInt(hex[16]!, 16) & 3)).toString(16)}${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
||
}
|
||
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;
|
||
/** The run settles soon after acceptance; arming on the proposal keeps the window reachable. */
|
||
const RESULT_TRIGGER_EVENTS = new Set(["run.result.proposed", "run.result.accepted"]);
|
||
|
||
/** Rest must hold for this long before the observer acts on it. */
|
||
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;
|
||
|
||
type ChildSpeedTask = { issueId: string; adapter: AdapterId; samples: Array<{ runs?: RunSample[] }> };
|
||
|
||
export async function childSpeedTasksInTurn(observers: ReadonlyArray<{ childSpeedTasks(): Promise<ChildSpeedTask[]> }>): Promise<ChildSpeedTask[]> {
|
||
const tasks: ChildSpeedTask[] = [];
|
||
for (const observer of observers) tasks.push(...await observer.childSpeedTasks());
|
||
return tasks;
|
||
}
|
||
|
||
export function childrenPending(children: ReadonlyArray<{ status: string | null }> | null): boolean {
|
||
return !children || children.some(child => !["done", "cancelled", "blocked"].includes(child.status ?? ""));
|
||
}
|
||
/** Extra time granted after a Retry click before R3 is graded as timed out. */
|
||
export const RETRY_BUDGET_MS = 15 * 60_000;
|
||
/** H1: how long the pause must hold with the server ready before the board resumes. */
|
||
export const BUDGET_PAUSE_HOLD_MS = 30_000;
|
||
/** H1: the raised budget's headroom above observed spend, in cents. */
|
||
export const BUDGET_RESUME_HEADROOM_CENTS = 3_000;
|
||
const DECISION_NOTE = "Restart chaos harness: board decision after a control-plane restart.";
|
||
|
||
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,
|
||
...(typeof run.failureCause === "string" && run.failureCause ? { failureCause: run.failureCause } : {}),
|
||
retryOfRunId: run.retryOfRunId ?? null,
|
||
wakeCommentIds: [...wake], livenessState: run.livenessState ?? null,
|
||
lastEvidenceAt: latestIso([run.startedAt, run.lastUsefulActionAt, run.execution?.lastConfirmedActivityAt]),
|
||
};
|
||
}
|
||
|
||
interface RunEventCursor {
|
||
seq: number;
|
||
lastAt: string | null;
|
||
/** Tool starts minus tool ends. PRP item ids are not unique per tool call, so this counts. */
|
||
openTools: number;
|
||
/** The run's latest recorded local runner process, until recovery reports it stopped. */
|
||
process: { pid: number; startedAt: string } | null;
|
||
/** A provider turn started and has no turn terminal yet. */
|
||
turnActive?: boolean;
|
||
}
|
||
|
||
/** R5 liveness: a tool in flight, or a recorded local runner process. */
|
||
export function noteLivenessEvent(cursor: RunEventCursor, event: Json) {
|
||
const payload = (event.payload ?? {}) as Json;
|
||
switch (event.eventType) {
|
||
case "turn.started": cursor.turnActive = true; break;
|
||
case "turn.completed": case "turn.failed": case "turn.cancelled": case "turn.interrupted":
|
||
cursor.turnActive = false; break;
|
||
case "tool.execution.started": cursor.openTools += 1; break;
|
||
case "tool.execution.completed": cursor.openTools = Math.max(0, cursor.openTools - 1); break;
|
||
case "native.process_identity_recorded":
|
||
if (payload.targetKind === "local" && Number.isSafeInteger(payload.processPid) && typeof payload.processStartedAt === "string") {
|
||
// A replacement runner cannot finish a tool or turn its predecessor started.
|
||
if (cursor.process && cursor.process.pid !== payload.processPid) { cursor.openTools = 0; cursor.turnActive = false; }
|
||
cursor.process = { pid: payload.processPid, startedAt: payload.processStartedAt };
|
||
}
|
||
break;
|
||
case "native.local_process_stopped":
|
||
// A stopped runner's in-flight tools and turn can never send their completion.
|
||
if (cursor.process && payload.processPid === cursor.process.pid) { cursor.process = null; cursor.openTools = 0; cursor.turnActive = false; }
|
||
break;
|
||
}
|
||
}
|
||
|
||
/** Alive only when the PID still runs with the recorded start time, so a recycled PID never counts. */
|
||
export function localProcessAlive(pid: number, startedAt: string): boolean {
|
||
if (process.platform === "win32") return false;
|
||
const result = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", env: { PATH: "/usr/bin:/bin", LANG: "C", LC_ALL: "C" }, timeout: 5_000 });
|
||
const started = Date.parse(result.stdout?.trim() ?? "");
|
||
return Number.isFinite(started) && restartProcessStartMatches(new Date(started).toISOString(), startedAt);
|
||
}
|
||
|
||
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;
|
||
|
||
/** G1: an approval this task asked for (its title names the nonce; a redacted title still counts). */
|
||
export function approvalTitleMatches(approval: Json, nonce: string): boolean {
|
||
const title = approval.payload?.title;
|
||
return typeof title !== "string" || title.includes(nonce);
|
||
}
|
||
|
||
/** H1: runs that started at or after `since` and before `until` (epoch ms), across every sample. */
|
||
export function runsStartedAfter(samples: readonly TaskSample[], since: number, until = Number.POSITIVE_INFINITY): string[] {
|
||
const started = new Set<string>();
|
||
for (const sample of samples) for (const run of sample.runs ?? []) {
|
||
const at = run.startedAt ? Date.parse(run.startedAt) : Number.NaN;
|
||
if (Number.isFinite(at) && at >= since && at < until) started.add(run.id);
|
||
}
|
||
return [...started];
|
||
}
|
||
|
||
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. */
|
||
/**
|
||
* When a board Stop (S1) fires. A provider message fault (PV) needs the Stop's
|
||
* interrupt to reach a live provider turn, so it fires on the first sample that
|
||
* shows one, not after the seeded wall-clock window: a fast agent can finish
|
||
* before that window and leave the fault uninjected. Other tasks keep the window.
|
||
*/
|
||
export function stopArmed(input: { now: number; windowAt: number; providerFault: boolean; running: RunSample | undefined }): boolean {
|
||
if (input.providerFault) return Boolean(input.running?.providerTurnActive);
|
||
return input.now >= input.windowAt;
|
||
}
|
||
|
||
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 childrenSettledAt: number | null = null;
|
||
private childStallCheckedAt: number | null = null;
|
||
private stallSince: number | null = null;
|
||
/** First sample of the current stretch with the server ready (H1 holds the pause across it). */
|
||
private readySince: number | null = null;
|
||
private readonly faultSchedule: ScheduledFault[];
|
||
private readonly pendingFaults: ScheduledFault[];
|
||
done = false;
|
||
/** DF1 (disk-full-faults.ts): arms this task's saves at launch and clears them before its Retry. */
|
||
diskFullSeam: Pick<DiskFullFaultSeam, "arm" | "clear"> | null = null;
|
||
|
||
/** Replaceable in tests. A Daytona run's process is remote, so only local runs are checked. */
|
||
processAlive = (pid: number, startedAt: string) => this.lab.environmentKind === "local" && localProcessAlive(pid, startedAt);
|
||
|
||
constructor(readonly plan: TaskPlan, private readonly api: ChaosApi, private readonly lab: ChaosLab,
|
||
private readonly timeline: Timeline,
|
||
private readonly supervisor: { serverReady: boolean; faultInProgress: boolean; serverBuild?: "upgrade_from" | "current"; paths?: { configPath: string } },
|
||
/** 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()],
|
||
private readonly board: { clickRetry(identifier: string, issueId: string): Promise<UiRetryClick> } | null = null) {
|
||
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];
|
||
this.journey = journeyDriverFor({ plan, api, lab, timeline, record: this.record,
|
||
databaseUrl: () => databaseUrlFromConfig(supervisor.paths?.configPath) });
|
||
}
|
||
|
||
/** Q1–Q3: the board's side of the journey (journey-driver.ts). */
|
||
readonly journey: JourneyDriver | null;
|
||
|
||
/** The workload, with `--long-turn-minutes` tool work in front of its first turn (long-turn.ts). */
|
||
get definition() { return withLongTurn(workloadDefinitions[this.plan.workload], this.plan.longTurnMinutes); }
|
||
|
||
/**
|
||
* Crash-point suite: create the issue without an assignee, so no wake is
|
||
* queued yet. `release` assigns it once the batch's point is armed.
|
||
*/
|
||
async stage(): Promise<void> { await this.launch({ assign: false }); }
|
||
|
||
/**
|
||
* Assigns a staged issue, which queues its wake. An earlier task of the same
|
||
* batch can crash the server meanwhile, so retry until the replacement
|
||
* answers; setting the same assignee again is idempotent.
|
||
*/
|
||
async release(deadlineMs = 10 * 60_000): Promise<void> {
|
||
if (this.done || !this.record.issueId) return;
|
||
const until = Date.now() + deadlineMs;
|
||
for (;;) {
|
||
try {
|
||
await this.api.patch(`/api/issues/${this.record.issueId}`, { assigneeAgentId: this.journey?.assigneeAgentId ?? this.lab.agents[this.plan.adapter].id });
|
||
await this.postStartComment();
|
||
break;
|
||
} catch (error) {
|
||
const transient = error instanceof ChaosApiError && (error.status === null || error.status >= 500);
|
||
if (!transient || Date.now() > until) {
|
||
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 });
|
||
return;
|
||
}
|
||
await delay(1_000);
|
||
}
|
||
}
|
||
this.record.createdAt = Date.now();
|
||
this.record.deadlineAt = this.record.createdAt + this.definition.budgetMs;
|
||
this.timeline.add("task_released", { taskId: this.plan.taskId, issueId: this.record.issueId });
|
||
}
|
||
|
||
private async postStartComment(): Promise<void> {
|
||
if (!this.definition.startComment || !this.record.issueId) return;
|
||
const body = this.definition.startComment(this.plan.nonce);
|
||
const existing = await this.api.get<Json[]>(`/api/issues/${this.record.issueId}/comments`);
|
||
if (existing.some(comment => comment.body === body)) return;
|
||
await this.api.post<Json>(`/api/issues/${this.record.issueId}/comments`, { body, clientRequestId: stableRequestId(`start-${this.plan.taskId}-${this.plan.nonce}`) });
|
||
}
|
||
|
||
async launch(options: { assign?: boolean } = {}): Promise<void> {
|
||
const definition = this.definition;
|
||
const assign = options.assign !== false;
|
||
if (this.plan.workload === "H1") await this.lowerBudgetToSpend();
|
||
try {
|
||
// Q3: connect the harness's app and hire its agent; a failed setup is a failed launch.
|
||
await this.journey?.prepare();
|
||
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, this.workloadAgents()),
|
||
status: definition.startComment ? "backlog" : "todo",
|
||
priority: "medium",
|
||
...(assign ? { assigneeAgentId: this.journey?.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;
|
||
if (assign) await this.postStartComment();
|
||
// K14: which build launched the task, so a replay starts it on the same one.
|
||
if (this.supervisor.serverBuild) this.record.serverBuildAtLaunch = this.supervisor.serverBuild;
|
||
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 });
|
||
await this.armDiskFull();
|
||
} 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 === "result") return this.resultRunStillRunning(this.latestReachableRuns());
|
||
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 === "result") {
|
||
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 && this.resultRunStillRunning(runs);
|
||
}
|
||
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 latestReachableRuns(): RunSample[] {
|
||
return [...this.record.samples].reverse().find(sample => sample.reachable)?.runs ?? [];
|
||
}
|
||
|
||
/** A `result` fault outside the accepted-but-unsettled window would test a different boundary. */
|
||
private resultRunStillRunning(runs: readonly RunSample[]): boolean {
|
||
return runs.some(run => run.id === this.record.resultRunId && run.status === "running");
|
||
}
|
||
|
||
/** A due fault the wave decided not to fire (for example, the storm ended during its checks). */
|
||
markTriggerMissed(fault: ScheduledFault) {
|
||
if (fault.trigger && fault.trigger !== "time") this.noteTriggerMissed(fault);
|
||
}
|
||
|
||
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;
|
||
case "provider": return this.record.providerFaultAppliedAt ?? null;
|
||
case "approval": return this.record.governance?.approvalSeenAt ?? null;
|
||
case "paused": return this.record.governance?.budgetPausedAt ?? null;
|
||
// Q1 keeps its own record (question-handoff.ts); Q2 and Q3 use the journey driver.
|
||
case "asked": return this.journey ? this.journey.armedAt("asked") : this.record.question?.seenAt ?? null;
|
||
case "answered": {
|
||
if (this.journey) return this.journey.armedAt("answered");
|
||
const answer = this.record.question?.answer;
|
||
return answer ? (answer.error ? "missed" : answer.at) : null;
|
||
}
|
||
case "stop": {
|
||
const stop = this.record.stop;
|
||
if (!stop) return null;
|
||
return stop.error || stop.missed ? "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.
|
||
const followUp = this.record.followUp;
|
||
if (!followUp) return null;
|
||
if (!followUp.commentId || !followUp.duringRun) return "missed";
|
||
return this.record.resultAcceptedAt ?? 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.lab.environmentKind !== "local" && !await access(workspace).then(() => true, () => false)) {
|
||
this.record.remoteWorkspace = true;
|
||
} else 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);
|
||
}
|
||
|
||
markUnfinished() {
|
||
if (this.done) return;
|
||
const last = this.record.samples.at(-1);
|
||
this.record.timedOut = true;
|
||
this.record.finalStatus = last?.issueStatus ?? this.record.finalStatus;
|
||
this.done = true;
|
||
this.timeline.add("task_unfinished_at_interrupt", { taskId: this.plan.taskId, status: this.record.finalStatus });
|
||
}
|
||
|
||
/** 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.captureRemoteFilesAtDone(sample);
|
||
this.readySince = sample.reachable && sample.serverReady ? (this.readySince ?? now) : null;
|
||
await this.observeBenchProgress();
|
||
await this.governanceStep(sample, now);
|
||
if (this.journey && await this.journey.step({ sample, now, pendingFaults: this.pendingFaults,
|
||
quiet: sample.reachable && sample.serverReady && !this.supervisor.faultInProgress })) this.restCandidateSince = null;
|
||
if (await this.observeChildStalls(now)) {
|
||
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;
|
||
return;
|
||
}
|
||
// A task waiting on the board (G1's approval, H1's paused company) is in a named wait, not a stall.
|
||
this.stallSince = isStalledSample(sample) && !this.awaitingBoard() ? (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);
|
||
// A task still waiting (for the board, or on a Retry it just got) keeps its deadline.
|
||
if (this.done) 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, RunEventCursor>();
|
||
|
||
private async runEvidence(run: RunSample): Promise<RunSample> {
|
||
if (run.status !== "running") return run;
|
||
const cursor = this.runEventCursors.get(run.id) ?? { seq: 0, lastAt: null, openTools: 0, process: 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;
|
||
if (RESULT_TRIGGER_EVENTS.has(String(event.eventType)) && this.record.followUp?.duringRun && !this.record.resultAcceptedAt) {
|
||
this.record.resultAcceptedAt = Date.now();
|
||
this.record.resultRunId = run.id;
|
||
}
|
||
cursor.lastAt = latestIso([cursor.lastAt, event.createdAt]);
|
||
noteLivenessEvent(cursor, event);
|
||
}
|
||
if (events.length < RUN_EVENT_PAGE) break;
|
||
}
|
||
this.runEventCursors.set(run.id, cursor);
|
||
return { ...run, lastEvidenceAt: latestIso([run.lastEvidenceAt, cursor.lastAt]),
|
||
...(cursor.openTools > 0 ? { toolInFlight: true } : {}),
|
||
...(cursor.turnActive ? { providerTurnActive: true } : {}),
|
||
...(cursor.process && this.processAlive(cursor.process.pid, cursor.process.startedAt) ? { processAlive: true } : {}) };
|
||
}
|
||
|
||
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 withLiveStatusEvidence(this.api, this.record.issueId!, await Promise.all((runs ?? []).map(toRunSample).map(run => this.runEvidence(run)))) };
|
||
} catch {
|
||
return { at: now, reachable: false, serverReady: false };
|
||
}
|
||
}
|
||
|
||
private async maybeStop(sample: TaskSample, now: number): Promise<boolean> {
|
||
if (!this.definition.stopBeforeFollowUp || this.record.stop || this.plan.followUpAfterMs === null || !sample.serverReady) return false;
|
||
const windowAt = this.record.createdAt! + this.plan.followUpAfterMs;
|
||
const running = (sample.runs ?? []).find(run => run.status === "running");
|
||
if (!stopArmed({ now, windowAt, providerFault: Boolean(this.plan.providerFault), running })) {
|
||
// A PV Stop still waits for a live turn; it counts as missed only once the task rests after the window.
|
||
if (!this.plan.providerFault || now < windowAt || running || !isResting(sample)) return false;
|
||
}
|
||
if (!running) {
|
||
if (!isResting(sample)) return false;
|
||
this.record.stop = { at: now, runId: null, httpStatus: null, error: null, missed: true };
|
||
this.timeline.add("task_stop_missed", { taskId: this.plan.taskId, status: sample.issueStatus ?? null });
|
||
return true;
|
||
}
|
||
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 && !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, sentAt, postedAt: Math.max(now, Date.now()), commentId: comment?.id ?? null, error: null, duringRun: running };
|
||
} catch (error) {
|
||
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;
|
||
}
|
||
|
||
/** DF1: the task's final saves find the sandbox disk full until the fault is cleared. */
|
||
private async armDiskFull(): Promise<void> {
|
||
if (!isDiskFullFault(this.plan.fault) || !this.record.issueId) return;
|
||
const record = this.record.diskFull = { kind: this.plan.fault, armedAt: null as number | null, clearedAt: null as number | null, rest: null };
|
||
try {
|
||
if (!this.diskFullSeam) throw new Error("disk-full seam not installed");
|
||
await this.diskFullSeam.arm(this.record.issueId);
|
||
record.armedAt = Date.now();
|
||
this.timeline.add("disk_full_fault_armed", { taskId: this.plan.taskId, issueId: this.record.issueId });
|
||
} catch (error) {
|
||
(record as NonNullable<TaskRecord["diskFull"]>).armError = error instanceof Error ? error.message : String(error);
|
||
this.timeline.add("disk_full_fault_arm_failed", { taskId: this.plan.taskId, error: (record as NonNullable<TaskRecord["diskFull"]>).armError });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* DF1: once the rest names the full disk (or the task is done, or the settle bound passed), record
|
||
* what the board shows, then clear the fault, as a person who freed space. Returns false to keep watching.
|
||
*/
|
||
private async clearDiskFullAtRest(now: number): Promise<boolean> {
|
||
const record = this.record.diskFull;
|
||
if (!record || record.armedAt === null || record.clearedAt !== null || !this.record.issueId) return true;
|
||
const rest = diskFullRest(this.issue, now);
|
||
if (!diskFullRestReady(record, rest, now)) return false;
|
||
record.rest = rest;
|
||
await this.diskFullSeam!.clear(this.record.issueId);
|
||
record.clearedAt = Date.now();
|
||
this.timeline.add("disk_full_fault_cleared", { taskId: this.plan.taskId, rest: record.rest });
|
||
return true;
|
||
}
|
||
|
||
private async onRest(sample: TaskSample, now: number) {
|
||
const status = sample.issueStatus ?? null;
|
||
// The board has not acted yet: the task waits on it, so it has not rested (and gets no Retry).
|
||
if (this.awaitingBoard()) { this.restCandidateSince = null; return; }
|
||
if (this.plan.workload === "H1") {
|
||
const company = await this.api.get<Json>(`/api/companies/${this.lab.companyId}`, 5_000).catch(() => null);
|
||
(this.record.governance ??= {}).companyActiveAtRest = company?.status === "active";
|
||
}
|
||
// 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.definition.collect.childDocuments && this.record.issueId) {
|
||
const children = await this.collectChildren(this.record.issueId).catch(() => null);
|
||
const pending = childrenPending(children);
|
||
const blocked = (children ?? []).filter(child => child.status === "blocked").map(child => child.id);
|
||
if (!pending && blocked.length && this.childrenSettledAt === null) {
|
||
this.timeline.add("task_child_blocked", { taskId: this.plan.taskId, childIssueIds: blocked });
|
||
}
|
||
this.childrenSettledAt = pending ? null : (this.childrenSettledAt ?? now);
|
||
if (pending || now - this.childrenSettledAt! < CHILD_WAKE_GRACE_MS) {
|
||
this.restCandidateSince = null;
|
||
return;
|
||
}
|
||
}
|
||
if (!await this.clearDiskFullAtRest(now)) { this.restCandidateSince = null; return; }
|
||
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 observeChildStalls(now: number): Promise<boolean> {
|
||
if (this.plan.workload !== "M1" || !this.record.issueId) return false;
|
||
if (this.childStallCheckedAt !== null && now - this.childStallCheckedAt < CHILD_STALL_CHECK_MS) return false;
|
||
this.childStallCheckedAt = now;
|
||
const rows = await this.api.get<Json[]>(`/api/companies/${this.lab.companyId}/issues?view=index&parentIds=${encodeURIComponent(this.record.issueId)}&limit=20`)
|
||
.catch(() => null);
|
||
const blocked = (Array.isArray(rows) ? rows : []).filter(row => row.parentId === this.record.issueId && row.status === "blocked").map(row => String(row.id));
|
||
const parent = await this.api.get<Json>(`/api/issues/${this.record.issueId}`).catch(() => null);
|
||
if (!parent) this.childStallCheckedAt = null;
|
||
const stalled = Array.isArray(parent?.stalledDependencies) ? parent.stalledDependencies as Json[] : [];
|
||
for (const dependency of stalled) {
|
||
if (dependency.relation !== "child" || typeof dependency.stalledIssueId !== "string") continue;
|
||
const action = (dependency.recoveryAction ?? null) as Json | null;
|
||
const childIssueId = dependency.stalledIssueId;
|
||
const recoveryActionId = typeof action?.id === "string" ? action.id : null;
|
||
const stalls = this.record.childStalls ??= [];
|
||
if (!stalls.some(stall => stall.childIssueId === childIssueId && stall.recoveryActionId === recoveryActionId)) {
|
||
stalls.push({ childIssueId, recoveryActionId });
|
||
this.timeline.add("task_child_stalled", { taskId: this.plan.taskId, childIssueId, recoveryActionId });
|
||
}
|
||
}
|
||
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: stop.runId ? runs.get(stop.runId)?.status ?? null : null, runsStartedBeforeContinue: startedBeforeContinue,
|
||
continuePosted: Boolean(this.record.followUp?.commentId), ...(stop.missed ? { missed: true } : {}) };
|
||
}
|
||
|
||
private async retryBlockedChildren(childIds: readonly string[], now: number): Promise<boolean> {
|
||
let clicked = false;
|
||
for (const childIssueId of childIds) {
|
||
if ((this.record.childRetries ?? []).some(attempt => attempt.childIssueId === childIssueId && attempt.accepted)) continue;
|
||
const child = await this.api.get<Json>(`/api/issues/${childIssueId}`).catch(() => null);
|
||
const interruption = child?.interruption as Json | null | undefined;
|
||
if (interruption?.canRetry !== true) continue;
|
||
const observedRunId = typeof interruption.observedRunId === "string" ? interruption.observedRunId : null;
|
||
const childRef = typeof child?.identifier === "string" ? child.identifier : childIssueId;
|
||
const attempt: RetryAttempt & { childIssueId: string } = { childIssueId, ...await this.clickRetry(childRef, childIssueId, observedRunId, now) };
|
||
(this.record.childRetries ??= []).push(attempt);
|
||
this.timeline.add("task_child_retry_clicked", { taskId: this.plan.taskId, ...attempt });
|
||
clicked = clicked || attempt.accepted === true;
|
||
}
|
||
return clicked;
|
||
}
|
||
|
||
private async collectStallWakes(issueId: string): Promise<NonNullable<OracleArtifacts["stallWakes"]>> {
|
||
const rows = await this.api.get<Json[]>(`/api/issues/${issueId}/activity`).catch(() => []);
|
||
return (rows ?? []).filter(row => row.action === "issue.blocker_stalled_wake").map(row => {
|
||
const details = (row.details ?? {}) as Json;
|
||
return { stalledIssueId: typeof details.stalledIssueId === "string" ? details.stalledIssueId : null,
|
||
recoveryActionId: typeof details.recoveryActionId === "string" ? details.recoveryActionId : null };
|
||
});
|
||
}
|
||
|
||
private async retry(sample: TaskSample, now: number): Promise<RetryAttempt> {
|
||
const observedRunId = sample.interruption?.observedRunId ?? (this.issue?.executionBlocker?.runId as string | undefined) ?? null;
|
||
const attempt = await this.clickRetry(this.record.identifier ?? this.record.issueId!, this.record.issueId!, observedRunId, now);
|
||
this.timeline.add("task_retry_clicked", { taskId: this.plan.taskId, ...attempt });
|
||
return attempt;
|
||
}
|
||
|
||
private async clickRetry(issueRef: string, issueId: string, observedRunId: string | null, now: number): Promise<RetryAttempt> {
|
||
const attempt: RetryAttempt = { at: now, observedRunId, httpStatus: null, accepted: null, error: null };
|
||
if (this.board) {
|
||
const click = await this.board.clickRetry(issueRef, issueId);
|
||
Object.assign(attempt, { via: "ui", httpStatus: click.httpStatus, accepted: click.accepted, error: click.error, bannerText: click.bannerText,
|
||
...(click.diagnostics !== undefined ? { diagnostics: click.diagnostics } : {}),
|
||
...(click.dismissedDialogs?.length ? { dismissedDialogs: click.dismissedDialogs } : {}) });
|
||
if (click.accepted) attempt.acceptedAt = Math.max(now, Date.now());
|
||
return attempt;
|
||
}
|
||
attempt.via = "api";
|
||
try {
|
||
const result = await this.api.post<{ accepted?: boolean }>(`/api/issues/${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);
|
||
}
|
||
return attempt;
|
||
}
|
||
|
||
private async remoteWorkspaceFile(issueId: string, relative: string): Promise<string | null> {
|
||
const captured = this.record.remoteFiles?.[relative];
|
||
const text = typeof captured === "string" ? captured
|
||
: await readRemoteWorkspaceFile(this.api, issueId, relative, { onError: message => this.noteRemoteFileError(relative, message) });
|
||
if (text !== null) delete this.record.remoteFileErrors?.[relative];
|
||
return text;
|
||
}
|
||
|
||
private async captureRemoteFilesAtDone(sample: TaskSample): Promise<void> {
|
||
const wanted = this.definition.collect.files(this.plan.nonce);
|
||
if (!wanted.length || this.lab.environmentKind === "local" || sample.issueStatus !== "done" || !this.record.issueId) return;
|
||
await captureRemoteWorkspaceFiles(this.api, this.record.issueId, wanted, this.record.remoteFiles ??= {},
|
||
(relative, message) => this.noteRemoteFileError(relative, message));
|
||
}
|
||
|
||
private noteRemoteFileError(relative: string, message: string): void {
|
||
const errors = this.record.remoteFileErrors ??= {};
|
||
if (errors[relative] === message) return;
|
||
errors[relative] = message;
|
||
this.timeline.add("remote_file_read_failed", { taskId: this.plan.taskId, path: relative, error: message });
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
workloadAgents(): WorkloadAgents {
|
||
const self = this.lab.agents[this.plan.adapter];
|
||
const other = Object.values(this.lab.agents).find(agent => agent.id !== self.id) ?? self;
|
||
return { self, other, companyId: this.lab.companyId };
|
||
}
|
||
|
||
/** G1 before the board's decision, or H1 while its company is paused and not yet resumed. */
|
||
private awaitingBoard(): boolean {
|
||
const governance = this.record.governance;
|
||
if (this.journey) return this.journey.awaitingBoard();
|
||
if (this.plan.workload === "G1") return Boolean(governance?.approvalSeenAt) && !governance?.decision;
|
||
if (this.plan.workload === "H1") return Boolean(governance?.budgetPausedAt) && !governance?.resume && !governance?.pauseLostAt;
|
||
if (this.plan.workload === "Q1" || this.plan.workload === "Q4") return awaitingQuestionAnswer(this.record);
|
||
if (isRewakeWorkload(this.plan.workload)) return awaitingRewake(this.record);
|
||
return false;
|
||
}
|
||
|
||
/** The board acts only after every fault this task planned has fired (or was missed), with the server ready. */
|
||
private boardMayAct(sample: TaskSample, waitsForThisAction?: TriggerId): boolean {
|
||
return sample.reachable && sample.serverReady && !this.supervisor.faultInProgress
|
||
&& this.pendingFaults.every(fault => waitsForThisAction !== undefined && fault.trigger === waitsForThisAction);
|
||
}
|
||
|
||
/**
|
||
* H1: set the company budget to its observed spend plus one cent through the board's budget
|
||
* route, so the task's first priced usage crosses the hard stop mid-run.
|
||
*/
|
||
private async lowerBudgetToSpend(): Promise<void> {
|
||
const governance = this.record.governance ??= {};
|
||
try {
|
||
const overview = await this.api.get<Json>(`/api/companies/${this.lab.companyId}/budgets/overview`, 10_000);
|
||
const policy = ((overview?.policies ?? []) as Json[]).find(row => row.scopeType === "company");
|
||
const observed = typeof policy?.observedAmount === "number" ? policy.observedAmount : 0;
|
||
// The budget routes take whole cents; observed spend can be fractional.
|
||
governance.budgetSetCents = Math.ceil(observed) + 1;
|
||
await this.api.patch(`/api/companies/${this.lab.companyId}/budgets`, { budgetMonthlyCents: governance.budgetSetCents });
|
||
this.timeline.add("governance_budget_set", { taskId: this.plan.taskId, budgetCents: governance.budgetSetCents, observedCents: observed });
|
||
} catch (error) {
|
||
governance.budgetSetError = error instanceof Error ? error.message : String(error);
|
||
this.timeline.add("governance_budget_set_failed", { taskId: this.plan.taskId, error: governance.budgetSetError });
|
||
}
|
||
}
|
||
|
||
/** G1 and H1: what the board sees and does, through the same routes the board UI uses. */
|
||
private async governanceStep(sample: TaskSample, now: number): Promise<void> {
|
||
if (!sample.reachable || !this.record.issueId) return;
|
||
if (this.plan.workload === "G1") await this.approvalStep(sample, now);
|
||
if (this.plan.workload === "H1") await this.budgetStep(sample, now);
|
||
if (this.plan.workload === "Q1" || this.plan.workload === "Q4") {
|
||
const sessionFault = isSessionFault(this.plan.fault) ? this.plan.fault : null;
|
||
await questionStep({ api: this.api, issueId: this.record.issueId, nonce: this.plan.nonce, record: this.record, sample, now,
|
||
// An `answered` fault waits for the answer, so the board may answer before it fires.
|
||
boardMayAct: this.boardMayAct(sample, "answered"), sequence: this.plan.workload === "Q4",
|
||
note: (kind, data) => this.timeline.add(kind, { taskId: this.plan.taskId, ...data }),
|
||
...(sessionFault ? { beforeAnswer: () => rotateAgentSession(this.api, this.lab.agents[this.plan.adapter].id, this.plan.index, now, sessionFault) } : {}) });
|
||
}
|
||
if (isRewakeWorkload(this.plan.workload)) {
|
||
await rewakeStep({ api: this.api, record: this.record, plan: this.plan, issueId: this.record.issueId, agentId: this.lab.agents[this.plan.adapter].id,
|
||
sample, now, boardMayAct: this.boardMayAct(sample), note: (kind, data) => this.timeline.add(kind, { taskId: this.plan.taskId, ...data }) });
|
||
}
|
||
}
|
||
|
||
private async approvalStep(sample: TaskSample, now: number): Promise<void> {
|
||
const governance = this.record.governance ??= {};
|
||
if (governance.decision) return;
|
||
const approvals = await this.api.get<Json[]>(`/api/issues/${this.record.issueId}/approvals`, 5_000).catch(() => null);
|
||
const pending = (approvals ?? []).find(approval => approval.status === "pending" && approvalTitleMatches(approval, this.plan.nonce));
|
||
if (!pending) return;
|
||
if (governance.approvalSeenAt == null) {
|
||
governance.approvalSeenAt = now;
|
||
governance.approvalId = String(pending.id);
|
||
this.timeline.add("governance_approval_pending", { taskId: this.plan.taskId, approvalId: pending.id });
|
||
}
|
||
if (!this.boardMayAct(sample)) return;
|
||
// The gated action must not exist before the board decides.
|
||
const before = await this.collectChildren(this.record.issueId!).catch(() => null);
|
||
governance.childrenBeforeDecision = before === null ? null
|
||
: before.filter(child => (child.title ?? "").includes(g1ChildTitle(this.plan.nonce))).length;
|
||
const kind = g1Decision(this.plan.nonce);
|
||
const decision: NonNullable<GovernanceRecord["decision"]> = { kind, at: now, httpStatus: null, error: null,
|
||
pendingWhenDecided: true, afterFault: (this.record.lastFaultAt ?? 0) > governance.approvalSeenAt };
|
||
try {
|
||
await this.api.post(`/api/approvals/${pending.id}/${kind}`, { decisionNote: DECISION_NOTE });
|
||
// A rejected request also closes the task: the board cancels it.
|
||
if (kind === "reject") await this.api.patch(`/api/issues/${this.record.issueId}`, { status: "cancelled" });
|
||
decision.httpStatus = 200;
|
||
} catch (error) {
|
||
decision.httpStatus = error instanceof ChaosApiError ? error.status : null;
|
||
decision.error = error instanceof Error ? error.message : String(error);
|
||
}
|
||
governance.decision = decision;
|
||
this.restCandidateSince = null;
|
||
this.timeline.add("governance_approval_decided", { taskId: this.plan.taskId, approvalId: pending.id, ...decision });
|
||
}
|
||
|
||
private async budgetStep(sample: TaskSample, now: number): Promise<void> {
|
||
const governance = this.record.governance ??= {};
|
||
if (governance.resume) return;
|
||
const company = await this.api.get<Json>(`/api/companies/${this.lab.companyId}`, 5_000).catch(() => null);
|
||
if (!company) return;
|
||
const paused = company.status === "paused" && company.pauseReason === "budget";
|
||
// A pause must hold until the board resumes it: one that clears on its own (a restart lost it)
|
||
// is recorded, and the board does not resume what is no longer paused.
|
||
if (!paused && governance.budgetPausedAt != null) {
|
||
governance.pauseLostAt ??= now;
|
||
this.timeline.add("governance_budget_pause_lost", { taskId: this.plan.taskId, status: company.status ?? null,
|
||
pauseReason: company.pauseReason ?? null });
|
||
return;
|
||
}
|
||
if (paused && governance.budgetPausedAt == null) {
|
||
const recorded = typeof company.pausedAt === "string" ? Date.parse(company.pausedAt) : Number.NaN;
|
||
governance.budgetPausedAt = Number.isFinite(recorded) ? Math.min(recorded, now) : now;
|
||
this.timeline.add("governance_budget_paused", { taskId: this.plan.taskId, pausedAt: company.pausedAt ?? null });
|
||
}
|
||
if (governance.budgetPausedAt == null || !this.boardMayAct(sample)) return;
|
||
if (this.readySince === null || now - this.readySince < BUDGET_PAUSE_HOLD_MS || now - governance.budgetPausedAt < BUDGET_PAUSE_HOLD_MS) return;
|
||
// While paused nothing may start: every run that started after the pause is a violation.
|
||
governance.runsStartedWhilePaused = runsStartedAfter(this.record.samples, governance.budgetPausedAt);
|
||
const resume: NonNullable<GovernanceRecord["resume"]> = { at: now, amount: null, incidentId: null, httpStatus: null, error: null,
|
||
afterFault: (this.record.lastFaultAt ?? 0) > governance.budgetPausedAt };
|
||
try {
|
||
const overview = await this.api.get<Json>(`/api/companies/${this.lab.companyId}/budgets/overview`, 10_000);
|
||
const incident = ((overview?.activeIncidents ?? []) as Json[]).find(row => row.scopeType === "company");
|
||
if (!incident) throw new Error("no active company budget incident to resolve");
|
||
const policy = ((overview?.policies ?? []) as Json[]).find(row => row.scopeType === "company");
|
||
const observed = Math.max(Number(incident.amountObserved) || 0, Number(policy?.observedAmount) || 0);
|
||
resume.incidentId = String(incident.id);
|
||
resume.amount = Math.ceil(observed) + BUDGET_RESUME_HEADROOM_CENTS;
|
||
await this.api.post(`/api/companies/${this.lab.companyId}/budget-incidents/${incident.id}/resolve`,
|
||
{ action: "raise_budget_and_resume", amount: resume.amount, decisionNote: DECISION_NOTE });
|
||
resume.httpStatus = 200;
|
||
} catch (error) {
|
||
resume.httpStatus = error instanceof ChaosApiError ? error.status : null;
|
||
resume.error = error instanceof Error ? error.message : String(error);
|
||
}
|
||
// Never before the poll that decided to resume (the pause window cannot end before it starts).
|
||
resume.doneAt = Math.max(Date.now(), now);
|
||
governance.resume = resume;
|
||
this.restCandidateSince = null;
|
||
this.timeline.add("governance_budget_resumed", { taskId: this.plan.taskId, ...resume,
|
||
runsStartedWhilePaused: governance.runsStartedWhilePaused });
|
||
}
|
||
|
||
async childSpeedTasks(): Promise<ChildSpeedTask[]> {
|
||
if (!this.definition.collect.childDocuments || !this.record.issueId) return [];
|
||
const rows = await this.api.get<Json[]>(`/api/companies/${this.lab.companyId}/issues?view=index&parentIds=${encodeURIComponent(this.record.issueId)}&limit=20`);
|
||
const tasks: Array<{ issueId: string; adapter: AdapterId; samples: Array<{ runs?: RunSample[] }> }> = [];
|
||
for (const row of (rows ?? []).filter(child => child.parentId === this.record.issueId)) {
|
||
const adapter = (Object.entries(this.lab.agents) as Array<[AdapterId, { id: string }]>)
|
||
.find(([, agent]) => agent.id === row.assigneeAgentId)?.[0];
|
||
if (!adapter) continue;
|
||
const runs = await this.api.get<Json[]>(`/api/issues/${row.id}/runs`, 15_000);
|
||
tasks.push({ issueId: String(row.id), adapter, samples: [{ runs: (runs ?? []).map(toRunSample) }] });
|
||
}
|
||
return tasks;
|
||
}
|
||
|
||
async collectChildren(issueId: string): Promise<NonNullable<OracleArtifacts["children"]>> {
|
||
const keys = this.definition.collect.childDocuments;
|
||
if (!keys) return [];
|
||
const rows = await this.api.get<Json[]>(`/api/companies/${this.lab.companyId}/issues?view=index&parentIds=${encodeURIComponent(issueId)}&limit=20`)
|
||
.catch(() => []);
|
||
const children: NonNullable<OracleArtifacts["children"]> = [];
|
||
for (const row of (rows ?? []).filter(child => child.parentId === issueId)) {
|
||
const documents: Record<string, string | null> = {};
|
||
for (const key of keys) {
|
||
const document = await this.api.get<Json>(`/api/issues/${row.id}/documents/${key}`).catch(() => null);
|
||
documents[key] = typeof document?.body === "string" ? document.body : null;
|
||
}
|
||
children.push({ id: String(row.id), title: typeof row.title === "string" ? row.title : null,
|
||
status: typeof row.status === "string" ? row.status : null,
|
||
assigneeAgentId: typeof row.assigneeAgentId === "string" ? row.assigneeAgentId : null, documents });
|
||
}
|
||
return children;
|
||
}
|
||
|
||
/** H2: the owner at rest and every run's terminal state (an `issue_reassigned` cancel is the staging failure). */
|
||
private async collectHandoff(issueId: string, issue: Json | null | undefined): Promise<NonNullable<OracleArtifacts["handoff"]>> {
|
||
const runs = await this.api.get<Json[]>(`/api/issues/${issueId}/runs`).catch(() => null);
|
||
const text = (value: unknown) => typeof value === "string" ? value : null;
|
||
return {
|
||
assigneeAgentId: text(issue?.assigneeAgentId), assigneeUserId: text(issue?.assigneeUserId),
|
||
createdByUserId: text(issue?.createdByUserId),
|
||
executionBlocker: Boolean(issue?.executionBlocker), activeRecoveryAction: Boolean(issue?.activeRecoveryAction),
|
||
runs: Array.isArray(runs) ? runs.map(toRunSample).map(run => ({ id: run.id, status: run.status, errorCode: run.errorCode })) : 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"] = {};
|
||
const runCommands = async (cwd: string) => {
|
||
for (const command of definition.collect.commands) {
|
||
const argv = typeof command.argv === "function" ? command.argv(this.plan.nonce) : command.argv;
|
||
commands[command.name] = await runOracleCommand(argv, cwd, { protectedRoots: this.protectedRoots,
|
||
readableFiles: argv[0] === process.execPath ? [process.execPath] : [] });
|
||
}
|
||
};
|
||
const wanted = definition.collect.files(this.plan.nonce);
|
||
if (workspacePath && await access(workspacePath).then(() => true, () => false)) {
|
||
for (const relative of wanted) files[relative] = await readFile(path.join(workspacePath, relative), "utf8").catch(() => null);
|
||
await runCommands(workspacePath);
|
||
} else if (workspacePath && wanted.length) {
|
||
const staged = await mkdtemp(path.join(os.tmpdir(), "restart-chaos-oracle-"));
|
||
try {
|
||
for (const relative of wanted) {
|
||
files[relative] = await this.remoteWorkspaceFile(issueId, relative);
|
||
if (files[relative] === null) continue;
|
||
await mkdir(path.dirname(path.join(staged, relative)), { recursive: true });
|
||
await writeFile(path.join(staged, relative), files[relative]!);
|
||
}
|
||
await runCommands(staged);
|
||
} finally {
|
||
await rm(staged, { recursive: true, force: true });
|
||
}
|
||
}
|
||
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 } : {}),
|
||
...(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) } : {}),
|
||
...(this.plan.workload === "H1" ? await this.finalPauseCheck(issueId) : {}),
|
||
...(this.record.governance ? { governance: this.record.governance } : {}),
|
||
...(this.plan.workload === "Q1" || this.plan.workload === "Q4" || this.plan.workload === "W2Q" ? { question: await collectQuestionArtifacts(this.api, issueId, this.record) } : {}),
|
||
...(isRewakeWorkload(this.plan.workload) ? { rewake: await collectRewakeArtifacts(this.api, issueId, this.record) } : {}),
|
||
// The task's runs, for the shape checks (`no_failed_attempt`, the long turn's own evidence).
|
||
runs: await this.api.get<Json[]>(`/api/issues/${issueId}/runs`, 10_000).then(rows => (rows ?? []).map(run => ({ id: String(run.runId ?? run.id),
|
||
status: String(run.status), errorCode: run.errorCode ?? null, createdAt: run.createdAt ?? null, finishedAt: run.finishedAt ?? null }))).catch(() => []),
|
||
...(this.journey ? { journey: await this.journey.collect(issueId).finally(() => this.journey!.close()) } : {}),
|
||
...(this.plan.workload === "H2" ? { handoff: await this.collectHandoff(issueId, issue) } : {}),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* H1: the pause window ends when the resume request returned, after the last poll before it. Read
|
||
* the task's whole run history again, so a run that started in that gap is still caught.
|
||
*/
|
||
private async finalPauseCheck(issueId: string): Promise<Record<string, never>> {
|
||
const governance = this.record.governance;
|
||
if (!governance?.budgetPausedAt) return {};
|
||
const runs = await this.api.get<Json[]>(`/api/issues/${issueId}/runs`).then(rows => (rows ?? []).map(toRunSample)).catch(() => []);
|
||
const history: TaskSample[] = [...this.record.samples, { at: Date.now(), reachable: true, serverReady: true, runs }];
|
||
governance.runsStartedWhilePaused = runsStartedAfter(history, governance.budgetPausedAt,
|
||
governance.resume?.doneAt ?? Number.POSITIVE_INFINITY);
|
||
return {};
|
||
}
|
||
|
||
/** G1: every approval linked to the task, whatever its status. */
|
||
private async collectApprovals(issueId: string): Promise<NonNullable<OracleArtifacts["approvals"]>> {
|
||
const rows = await this.api.get<Json[]>(`/api/issues/${issueId}/approvals`).catch(() => []);
|
||
return (rows ?? []).map(row => ({ id: String(row.id), status: String(row.status),
|
||
title: typeof row.payload?.title === "string" ? row.payload.title : null }));
|
||
}
|
||
|
||
/** 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) };
|
||
}
|
||
}
|
||
}
|