mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 06:16:06 -10:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The control plane owns task execution, recovery and Retry for every agent run > - Server restarts were the largest remaining source of stalled tasks in the staging soak campaign > - The soak could only observe restarts that staging happened to perform, so a failure could not be repeated > - This pull request adds a script harness that causes the restarts on purpose and grades each task on fixed rules > - The benefit is a repeatable restart failure with a replay command and an evidence bundle for each fixer ## Linked Issues or Issue Description **What existing behavior does this improve?** The restart test coverage. Today the runner-e2e restart cells test one fixed restart point per cell. They cannot run many tasks at once, cannot choose a random fault time, and cannot replay a failure. **Subsystem affected** Test tooling (`tests/restart-chaos/`). It reuses the runner-e2e process code in `tests/runner-e2e/`. Product code does not change. **Current behavior** Restart robustness is checked through a small set of fixed cells. A random restart during a real task has no defined pass rule. **Proposed behavior** A harness starts an isolated control plane and seeds a "Chaos Lab" company. It launches seeded workloads, injects K1, K2 or K4 faults at seeded times, and grades each task on rules R1 to R6 plus a deterministic task oracle. `--replay <wave>/<task>` repeats one task. **Reason and benefit** Fixers get a replay command and an evidence bundle for each failure signature. The first smoke wave already found three product signatures (dead Retry after a restart, a false over-budget stop after restarts, and an OpenCode turn killed mid-turn that ends as a provider failure). **Breaking changes** None. The change adds new test files, one entry in the test-project typecheck list and a gitignore entry. ## What Changed - Add `tests/restart-chaos/`: - `supervisor.ts` owns one isolated server. It reuses `replaceController`, the process-tree stopper, the environment isolation helpers, the config seed, the port reservation, the wrapper-owned Postgres and `server-entry.ts` from `tests/runner-e2e/`. It does not copy them. Faults: K1 (SIGKILL, immediate restart), K2 (SIGTERM, restart) and K4 (SIGKILL, 2 to 5 minute hold). Postgres and the controller runtime cache stay across a fault. - `seed.ts` creates the company, encrypted provider secrets, native Codex, ACPX Claude and OpenCode agents from the runner-e2e catalog, and the `chaos-sandbox` project. The project is a git repository with a Python CLI, one failing test and a four-minute `bench.sh`. - `scheduler.ts` makes a seeded plan of workload, adapter, fault, fire time, hold and nonce for each task. It saves the plan so that `--replay` can run one task again. - `observer.ts` polls each issue and its runs. When a task stops and offers Retry, it sends `POST /api/issues/:id/retry` one time, with the same body as `useTaskRetry.ts`. - `grader.ts` grades R1 to R6 as pure functions. `workloads.ts` defines workloads A1, B1, B5 and C1 and their oracles. - `evidence.ts` and `ledger.ts` write an evidence bundle for each failure, a JSONL ledger and `SUMMARY.md`. All results go to the gitignored `tests/restart-chaos/results/`. Known provider key values are redacted. - Unit tests: `scheduler.test.ts` (determinism, replay and CLI parsing) and `grader.test.ts` (R1 to R6 and the oracles on fixture timelines). These tests need no credentials. - Add `tests/restart-chaos` to the test-project typecheck list (`scripts/typecheck-test-projects.mjs`), with a `tsconfig.layers.json` like `tests/runner-e2e`. The harness runs through direct script invocations, not `package.json` scripts. - After the first Greptile review: - A failed fault or an unexpected server exit now gets one bounded recovery. If the recovery fails, the wave fails and does not hang. - Incomplete cleanup is now a harness error, and the harness keeps the instance. - After the C1 follow-up comment, the observer needs a fresh rest window before it grades the task. - `--replay` now fires the recorded faults that crossed the task, at the same offsets, including the faults that other tasks requested. - A failed workspace-operation read is now a sweep error. It cannot give a clean rest. - R2 now checks that workspace files from before the first fault still exist at rest. - B5 now needs the completion record that `bench.sh` writes for each task. - After the second round of reviews: - An idle `todo` or `in_progress` task with no live run and no named wait is no longer treated as resting. The observer keeps watching it until the task resumes or passes the R5 stall limit. - A failed injected fault now fails the wave with exit 2. - T-oracle commands run agent-editable code. They now run under a macOS Seatbelt sandbox with a scratch `HOME` and no network, and they cannot read the operator's home directory. On other platforms these commands are refused unless the operator explicitly allows them. - R4 now counts every active run. A wake comment may be served again only after a run that did not succeed. - Evidence redaction now covers every environment variable name that ends in `KEY`, `TOKEN`, `SECRET` or `PASSWORD`. - If the pre-fault snapshot fails, only R2 for that task is left unchecked. The wave continues. - B5 also needs bench progress that the harness observed while it polled. - Add `doc/plans/2026-11-06-control-plane-restart-chaos.md` and its campaign ledger. ## Verification - `pnpm exec vitest run --config tests/restart-chaos/vitest.config.ts`: 38 tests pass. The tests include observer tests with a fake API, a real `bench.sh` run, and a sandbox test that cannot read a canary secret. - `node scripts/typecheck-test-projects.mjs tests/restart-chaos`: passes. - `server/scripts/typecheck-layers.test.mjs`: 45 tests pass, including "covers every project under tests/". - A supervisor check with no credentials passed. K1 was ready again in about 6 seconds. K2 with a 3-second hold was ready again in about 9 seconds. Company data stayed readable. - One paid smoke wave ran: `--env local --adapters codex,claude,opencode --workloads A1,B1,C1 --faults K1 --tasks 3 --concurrency 2 --seed 7`. The harness worked from start to end, and all three tasks crossed a restart. The 0/3 result comes from product failures, which the campaign ledger records. A scan found no provider key in the results. - To run a wave: ```sh pnpm --filter @paperclipai/paperclip-runner build:runner-binaries set -a; source ~/.secrets; set +a node cli/node_modules/tsx/dist/cli.mjs tests/restart-chaos/main.ts --env local --adapters codex,claude,opencode --concurrency 4 --tasks 8 --faults K1,K2,K4 --seed 42 node cli/node_modules/tsx/dist/cli.mjs tests/restart-chaos/main.ts --replay <wave>/<task> ``` ## Risks - Low product risk. The change adds test tooling only and does not change product code. - A wave spends provider money. The company budget hard cap (`--budget-cents`, default 3000) is the only spend limit. The plan's per-wave and per-night refusal is not implemented yet. - Known gaps: - `--env daytona` is reserved. It fails with a TODO in `seed.ts`. The Daytona sandbox sweep for R6 is not implemented yet. - The project requests isolated git-worktree workspaces. A B1 task still ran in the shared fixture repository. Concurrent B1 and B5 tasks can share files. The oracles use marker files for each task. - Only K1, K2 and K4 at seeded random times are available. Boundary triggers (B1 to B16), other faults and the UI probe come in Phase 2. - The paid smoke did not exercise B5 or K4. - On macOS, B1 oracle commands need `python3` outside the operator's home directory, for example `/usr/bin` or Homebrew. On Linux they are refused until a sandbox is added. - The B5 proof is best-effort against a deliberately adversarial agent, because that agent can write to the workspace. - Startup phase timings come from polling `/api/ready`. The server records its own timings only as OpenTelemetry spans. - `pnpm verify` and the CI Vitest jobs do not select these unit tests. The root Vitest projects cover workspace packages only. CI typechecks the files, but the tests run only through the vitest command above. - After the smoke, rest detection and the review fixes changed. A recovery action that the board owns now counts as a rest, and R6 reports it. A paid wave did not run again after these changes. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Claude Opus 5.5 (Anthropic), model ID `claude-opus-5-5`, used in Claude Code as an agent with tool use (shell, file edits) and extended thinking. ## 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 - [x] All Paperclip CI gates are green - [x] 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 <[email protected]>
335 lines
17 KiB
TypeScript
335 lines
17 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();
|
|
}
|
|
|
|
/** 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,
|
|
};
|
|
}
|
|
|
|
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 nextFault = 0;
|
|
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);
|
|
}
|
|
|
|
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;
|
|
const fault = this.faultSchedule[this.nextFault];
|
|
if (!fault || now < this.record.createdAt + fault.atMs) return null;
|
|
this.nextFault += 1;
|
|
return 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);
|
|
}
|
|
|
|
/** 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);
|
|
} 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) { if (!this.record.faultIds.includes(faultId)) this.record.faultIds.push(faultId); }
|
|
|
|
/** 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 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: (runs ?? []).map(toRunSample) };
|
|
} 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, commentId: comment?.id ?? null, error: null };
|
|
} catch (error) {
|
|
this.record.followUp = { at: now, commentId: null, error: error instanceof Error ? error.message : String(error) };
|
|
}
|
|
this.timeline.add("task_follow_up", { taskId: this.plan.taskId, duringRun: running, ...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;
|
|
if (status !== "done" && !this.record.retry && sample.interruption?.canRetry) {
|
|
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;
|
|
} 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();
|
|
if (workspacePath && this.record.preFaultFiles) {
|
|
const present = new Set(await listWorkspaceFiles(workspacePath));
|
|
this.record.lostFiles = this.record.preFaultFiles.filter(file => !present.has(file));
|
|
}
|
|
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> {
|
|
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) };
|
|
}
|
|
}
|
|
}
|