@staix/agent-research 0.0.0-stage → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/LICENSE +21 -0
- package/README.md +666 -2
- package/bin/ares.js +11 -0
- package/package.json +37 -4
- package/src/autonomy.ts +50 -0
- package/src/cli.ts +308 -0
- package/src/commands-table.ts +75 -0
- package/src/commands.ts +561 -0
- package/src/console/registry.ts +97 -0
- package/src/copilot.ts +91 -0
- package/src/exp/ablate.ts +140 -0
- package/src/exp/artifacts.ts +229 -0
- package/src/exp/attempts.ts +483 -0
- package/src/exp/executor.ts +207 -0
- package/src/exp/final.ts +197 -0
- package/src/exp/ideas.ts +117 -0
- package/src/exp/investigate.ts +187 -0
- package/src/exp/logs.ts +63 -0
- package/src/exp/metrics.ts +132 -0
- package/src/exp/protocol.ts +148 -0
- package/src/exp/selection.ts +53 -0
- package/src/exp/snapshot.ts +147 -0
- package/src/http.ts +106 -0
- package/src/index.ts +4 -0
- package/src/lit/bibtex.ts +61 -0
- package/src/lit/candidates.ts +148 -0
- package/src/lit/cards.ts +21 -0
- package/src/lit/cite.ts +26 -0
- package/src/lit/sources.ts +122 -0
- package/src/lit/verify.ts +110 -0
- package/src/lit/watch.ts +122 -0
- package/src/manifest.ts +39 -0
- package/src/mcp-tools.ts +210 -0
- package/src/mcp.ts +113 -0
- package/src/memory/amem.ts +97 -0
- package/src/memory/archive.ts +66 -0
- package/src/memory/lessons.ts +88 -0
- package/src/memory/skills.ts +99 -0
- package/src/paper/build.ts +73 -0
- package/src/paper/check.ts +181 -0
- package/src/paper/placeholders.ts +118 -0
- package/src/paper/review.ts +127 -0
- package/src/plan/controller.ts +222 -0
- package/src/plan/definition.ts +168 -0
- package/src/plan/hub.ts +56 -0
- package/src/plan/state.ts +270 -0
- package/src/playbook.ts +141 -0
- package/src/scope.ts +53 -0
- package/src/setup.ts +296 -0
- package/src/study-export.ts +254 -0
- package/src/study.ts +324 -0
- package/src/ui/panel.css +11 -0
- package/src/ui/panel.js +161 -0
- package/src/ui/plugin.ts +375 -0
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
import { requireCopilotAccept } from "../copilot.ts";
|
|
2
|
+
import { resolveAutonomy } from "../autonomy.ts";
|
|
3
|
+
import { readFinalSeeds, authorizeFinalAttempt, finalGateBinding, requirePosthocClosed } from "./final.ts";
|
|
4
|
+
import { closeSync, existsSync, lstatSync, openSync, opendirSync, statSync, unlinkSync, writeSync } from "node:fs";
|
|
5
|
+
import { hostname } from "node:os";
|
|
6
|
+
import { join, posix } from "node:path";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
9
|
+
import { boundedText, commit, openStudy, plain, readJsonFile, readTextBounded, requireExperiment, requireGate, sha256, withLock, type Actor, type PendingEvent } from "../study.ts";
|
|
10
|
+
import { designBinding, protocolHash, readDesign, type Protocol } from "./protocol.ts";
|
|
11
|
+
import { buildContainerArgs, checkExecutorInputs, containerName, readExecutor, runOnExecutor, shellQuote, spawnRunner, startWatchdog, type ContainerPaths, type Executor, type ProcessRunner, type Stage } from "./executor.ts";
|
|
12
|
+
import { resolveSnapshot, transferSnapshot, transferTask } from "./snapshot.ts";
|
|
13
|
+
import { collectEvaluation as defaultEvaluationCollector, collectStageEvidence as defaultStageCollector, EvidenceError, MetricsError, outputBudget, type StageEvidence } from "./metrics.ts";
|
|
14
|
+
|
|
15
|
+
export type AttemptStatus = "running" | "succeeded" | "failed" | "cancelled" | "unknown";
|
|
16
|
+
export interface AttemptStage { image: string; argv: string[]; mounts: { target: string; readOnly: boolean }[]; network: "none"; limits: Protocol["limits"]; user: string; containerId?: string; startedAt: string; endedAt?: string; exitCode?: number; evidence?: StageEvidence; evidenceError?: { reason: string; message: string } }
|
|
17
|
+
export interface Evaluation { metrics: { name: string; unit: string; value: number; provenance: "ares-observed" }[]; outputs: { path: string; sha256: string; bytes: number }[] }
|
|
18
|
+
export interface Attempt {
|
|
19
|
+
v: 1; id: string; studyId: string; run: string; protocolHash: string; sealBinding: string; commit: string; treeId: string; snapshotSha256?: string; seed: number; label: string; retryOf?: string;
|
|
20
|
+
executor: Executor; protocol: Protocol; paths: ContainerPaths; status: AttemptStatus; stage: Stage; reason?: string; error?: string; startedAt: string; endedAt?: string;
|
|
21
|
+
stages: Partial<Record<Stage, AttemptStage>>; metrics: Evaluation["metrics"]; outputs: Evaluation["outputs"];
|
|
22
|
+
sealing: { separateContainers: boolean; evaluatorReadOnly: boolean; candidateHasNoEvaluator: boolean; verifiedBy: "ares-observed" };
|
|
23
|
+
launching?: { pid: number; host: string; token: string; claimedAt: string; expiresAt: string };
|
|
24
|
+
pendingAutoApproval?: string;
|
|
25
|
+
finalBinding?: string; finalWinner?: string; pendingFinalApproval?: string;
|
|
26
|
+
}
|
|
27
|
+
export interface AttemptOptions { repo: string; commit: string; seed?: number; run?: string; label?: string; retry?: string; detach?: boolean; expectedId?: string }
|
|
28
|
+
/** collectEvaluation is T4's validating adapter; T3 does not parse or validate metric schemas. */
|
|
29
|
+
export interface AttemptContext { actor: Actor; runner?: ProcessRunner; now?: () => number; sleep?: (ms: number) => Promise<void>; collectEvaluation?: (attempt: Attempt, runner: ProcessRunner) => Promise<Evaluation>; collectStageEvidence?: (attempt: Attempt, stage: Stage, runner: ProcessRunner) => Promise<StageEvidence> }
|
|
30
|
+
export const ATTEMPT_BYTES = 1024 * 1024;
|
|
31
|
+
// Startup includes bounded image pulls and two 5-minute transfers; claims never survive indefinitely after a crash.
|
|
32
|
+
export const CONTROL_STALE_MS = 30 * 60_000;
|
|
33
|
+
// Filesystem mtime has sub-ms precision; Date.now is integral. Tolerate small future skew on live locks.
|
|
34
|
+
export const CONTROL_FUTURE_SKEW_MS = 60_000;
|
|
35
|
+
const controlScope = new AsyncLocalStorage<{ dir: string; token: string }>();
|
|
36
|
+
const STATUSES: AttemptStatus[] = ["running", "succeeded", "failed", "cancelled", "unknown"];
|
|
37
|
+
const inspectFields = (prefix: string, fields: string[]) => fields.map(field => `${JSON.stringify(field)}:{{json ${prefix}.${field}}}`).join(",");
|
|
38
|
+
// Exclude inherited image labels/env and other unbounded Docker metadata from the observation.
|
|
39
|
+
const INSPECT_FORMAT = `{{$seed := ""}}{{range .Config.Env}}{{if and (ge (len .) 10) (eq (slice . 0 10) "ARES_SEED=")}}{{$seed = .}}{{end}}{{end}}[{"Id":{{json .Id}},"State":{${inspectFields(".State", ["Running", "ExitCode", "StartedAt", "FinishedAt"])}},"Config":{"Seed":{{json $seed}},${inspectFields(".Config", ["Image", "User", "Cmd"])},"Labels":{${["ares.study", "ares.attempt", "ares.stage"].map(key => `${JSON.stringify(key)}:{{json (index .Config.Labels ${JSON.stringify(key)})}}`).join(",")}}},"HostConfig":{${inspectFields(".HostConfig", ["NetworkMode", "ReadonlyRootfs", "CapDrop", "SecurityOpt", "Tmpfs", "Memory", "PidsLimit", "NanoCpus", "DeviceRequests"])}},"Mounts":{{json .Mounts}}}]`;
|
|
40
|
+
const now = (ctx: AttemptContext) => ctx.now?.() ?? Date.now();
|
|
41
|
+
const at = (ctx: AttemptContext) => new Date(now(ctx)).toISOString();
|
|
42
|
+
function runName(value: string): string {
|
|
43
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(value)) throw new Error("run: invalid name; use 1..64 letters, digits, underscores or hyphens, starting with a letter or digit");
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
function id(value: string): string {
|
|
47
|
+
if (!/^[a-f0-9]{16}$/.test(value)) throw new Error("attempt id: invalid; use a 16-hex id from ares attempt list");
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
export function attemptId(hash: string, commit: string, treeId: string, seed: number, run: string, retryOf?: string): string {
|
|
51
|
+
return sha256([hash, commit, treeId, seed, run, retryOf ?? ""].join("|")).slice(0, 16);
|
|
52
|
+
}
|
|
53
|
+
function file(dir: string, attempt: Pick<Attempt, "run" | "id">): string { return join(dir, "runs", runName(attempt.run), "attempts", `${id(attempt.id)}.json`); }
|
|
54
|
+
function directoryEntries(path: string): string[] {
|
|
55
|
+
if (!existsSync(path)) return [];
|
|
56
|
+
const stat = lstatSync(path);
|
|
57
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("attempt store: invalid directory; restore regular runs/ and attempts/ directories without symlinks");
|
|
58
|
+
const directory = opendirSync(path), entries: string[] = [];
|
|
59
|
+
try { for (let entry = directory.readSync(); entry; entry = directory.readSync()) { if (entries.length >= 1_000_000) throw new Error("attempt store: too many entries; archive the study and start a new one"); entries.push(entry.name); } }
|
|
60
|
+
finally { directory.closeSync(); }
|
|
61
|
+
return entries.sort();
|
|
62
|
+
}
|
|
63
|
+
function* records(dir: string): Generator<Attempt> {
|
|
64
|
+
let count = 0;
|
|
65
|
+
for (const run of directoryEntries(join(dir, "runs"))) {
|
|
66
|
+
runName(run);
|
|
67
|
+
for (const name of directoryEntries(join(dir, "runs", run, "attempts"))) {
|
|
68
|
+
if (!/^[a-f0-9]{16}\.json$/.test(name)) throw new Error("attempt store: unexpected file; keep only attempt-id.json records under runs/<run>/attempts/");
|
|
69
|
+
const path = join(dir, "runs", run, "attempts", name), stat = lstatSync(path);
|
|
70
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("attempt record: regular file required; restore the record without symlinks");
|
|
71
|
+
const raw = readJsonFile(path, ATTEMPT_BYTES);
|
|
72
|
+
if (plain(raw) && (raw.v !== 1 || (typeof raw.status === "string" && !STATUSES.includes(raw.status as AttemptStatus)) || (typeof raw.stage === "string" && !["candidate", "evaluator"].includes(raw.stage)))) throw new Error("attempt record: unsupported schema version, status or stage; use an ares version that supports this record, keeping the original file unchanged");
|
|
73
|
+
if (!plain(raw) || raw.v !== 1 || raw.run !== run || raw.id !== name.slice(0, 16) || !STATUSES.includes(raw.status as AttemptStatus) || !["candidate", "evaluator"].includes(raw.stage as string) || !plain(raw.stages) || !plain(raw.protocol) || !plain(raw.executor) || typeof raw.startedAt !== "string" || !Number.isFinite(Date.parse(raw.startedAt))) throw new Error("attempt record: malformed; restore its last valid version before retrying");
|
|
74
|
+
if (++count > 1_000_000) throw new Error("attempt store: more than 1000000 records; archive the study and start a new one");
|
|
75
|
+
yield raw as unknown as Attempt;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function load(dir: string): Attempt[] { return [...records(dir)]; }
|
|
80
|
+
function studyDir(dirArg: string): string { const { dir, study } = openStudy(dirArg); requireExperiment(study); return dir; }
|
|
81
|
+
/** One pass of regular, byte-bounded records; callers retain only the projections they need. */
|
|
82
|
+
export function* readAttemptRecords(dirArg: string): Generator<Attempt> { yield* records(studyDir(dirArg)); }
|
|
83
|
+
export function listAttempts(dirArg: string, options: { run?: string; status?: string } = {}) {
|
|
84
|
+
const dir = studyDir(dirArg), all = load(dir), totals = Object.fromEntries(STATUSES.map(status => [status, all.filter(a => a.status === status).length])) as Record<AttemptStatus, number>;
|
|
85
|
+
if (options.run !== undefined) runName(options.run);
|
|
86
|
+
if (options.status !== undefined && !STATUSES.includes(options.status as AttemptStatus)) throw new Error("status: unknown attempt status; use running, succeeded, failed, cancelled or unknown");
|
|
87
|
+
return { attempts: all.filter(a => (!options.run || a.run === options.run) && (!options.status || a.status === options.status)), totals };
|
|
88
|
+
}
|
|
89
|
+
export interface AttemptListOptions { run?: string; status?: string; limit?: number; offset?: number }
|
|
90
|
+
export interface AttemptSummary { id: string; run: string; commit: string; seed: number; label: string; status: AttemptStatus; stage: Stage; reason?: string; startedAt: string; endedAt?: string; metricCount: number; outputCount: number }
|
|
91
|
+
/** Page summaries while reading one bounded record at a time; totals always cover the whole study. */
|
|
92
|
+
export function listAttemptSummaries(dirArg: string, options: AttemptListOptions = {}) {
|
|
93
|
+
const dir = studyDir(dirArg), limit = options.limit ?? 50, offset = options.offset ?? 0;
|
|
94
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 200) throw new Error("attempt list: invalid limit; use --limit 1..200");
|
|
95
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || offset > 1_000_000) throw new Error("attempt list: invalid offset; use --offset 0..1000000");
|
|
96
|
+
if (options.run !== undefined) runName(options.run);
|
|
97
|
+
if (options.status !== undefined && !STATUSES.includes(options.status as AttemptStatus)) throw new Error("attempt list: invalid status; use running, succeeded, failed, cancelled or unknown");
|
|
98
|
+
const totals = Object.fromEntries(STATUSES.map(status => [status, 0])) as Record<AttemptStatus, number>, attempts: AttemptSummary[] = [];
|
|
99
|
+
let total = 0;
|
|
100
|
+
for (const attempt of records(dir)) {
|
|
101
|
+
totals[attempt.status]++;
|
|
102
|
+
if ((options.run && attempt.run !== options.run) || (options.status && attempt.status !== options.status)) continue;
|
|
103
|
+
const position = total++;
|
|
104
|
+
if (position < offset || attempts.length >= limit) continue;
|
|
105
|
+
if (typeof attempt.label !== "string" || attempt.label.length > 128 || typeof attempt.commit !== "string" || !/^[a-f0-9]{40}$/.test(attempt.commit) || !Number.isSafeInteger(attempt.seed) || !Array.isArray(attempt.metrics) || !Array.isArray(attempt.outputs) || attempt.startedAt.length > 64 || (attempt.reason !== undefined && (typeof attempt.reason !== "string" || attempt.reason.length > 500)) || (attempt.endedAt !== undefined && (typeof attempt.endedAt !== "string" || attempt.endedAt.length > 64 || !Number.isFinite(Date.parse(attempt.endedAt))))) throw new Error("attempt list: malformed summary fields; restore the last valid attempt record before retrying");
|
|
106
|
+
attempts.push({ id: attempt.id, run: attempt.run, commit: attempt.commit, seed: attempt.seed, label: attempt.label, status: attempt.status, stage: attempt.stage, ...(attempt.reason !== undefined ? { reason: attempt.reason } : {}), startedAt: attempt.startedAt, ...(attempt.endedAt ? { endedAt: attempt.endedAt } : {}), metricCount: attempt.metrics.length, outputCount: attempt.outputs.length });
|
|
107
|
+
}
|
|
108
|
+
return { attempts, totals, total, offset, limit, remaining: Math.max(0, total - offset - attempts.length) };
|
|
109
|
+
}
|
|
110
|
+
export function showAttempt(dirArg: string, attemptId: string): Attempt {
|
|
111
|
+
const dir = studyDir(dirArg), found = load(dir).find(a => a.id === id(attemptId));
|
|
112
|
+
if (!found) throw new Error("attempt: not found; choose an id from ares attempt list");
|
|
113
|
+
return found;
|
|
114
|
+
}
|
|
115
|
+
function save(dir: string, attempt: Attempt, ctx: AttemptContext, event: string, approvals: PendingEvent[] = []): void {
|
|
116
|
+
commit(dir, [[file(dir, attempt), JSON.stringify(attempt, null, 2) + "\n", ATTEMPT_BYTES]], [...approvals, { actor: ctx.actor, type: event, data: { id: attempt.id, run: attempt.run, stage: attempt.stage, status: attempt.status, ...(attempt.reason ? { reason: attempt.reason } : {}) } }]);
|
|
117
|
+
}
|
|
118
|
+
export function budgetUsage(attempts: Attempt[], time = Date.now()) {
|
|
119
|
+
let wallHours = 0, gpuHours = 0;
|
|
120
|
+
for (const attempt of attempts) {
|
|
121
|
+
if (Object.keys(attempt.stages).some(name => name !== "candidate" && name !== "evaluator")) throw new Error("budget: unsupported attempt stage kind prevents complete accounting; use an ares version that supports all recorded stages before starting another attempt, keeping the original records unchanged");
|
|
122
|
+
const elapsed = Math.max(0, (attempt.endedAt ? Date.parse(attempt.endedAt) : time) - Date.parse(attempt.startedAt)) / 3_600_000;
|
|
123
|
+
if (!Number.isFinite(elapsed)) throw new Error("attempt timing: malformed; restore valid timestamps before starting an attempt");
|
|
124
|
+
wallHours += elapsed;
|
|
125
|
+
// Unknown kinds cannot be accounted for safely; read-only operations do not call this function.
|
|
126
|
+
for (const name of ["candidate", "evaluator"] as const) {
|
|
127
|
+
const stage = attempt.stages[name];
|
|
128
|
+
if (!stage) continue;
|
|
129
|
+
const end = stage.endedAt ? Date.parse(stage.endedAt) : attempt.endedAt ? Date.parse(attempt.endedAt) : time;
|
|
130
|
+
const hours = Math.max(0, end - Date.parse(stage.startedAt)) / 3_600_000;
|
|
131
|
+
if (!Number.isFinite(hours) || !Number.isFinite(stage.limits.gpus)) throw new Error("attempt timing: malformed; restore valid stage timestamps and limits before starting an attempt");
|
|
132
|
+
gpuHours += hours * stage.limits.gpus;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return { attempts: attempts.length, wallHours, gpuHours };
|
|
136
|
+
}
|
|
137
|
+
function enforceBudget(protocol: Protocol, attempts: Attempt[], time: number): void {
|
|
138
|
+
const usage = budgetUsage(attempts, time);
|
|
139
|
+
for (const [key, used] of [["maxAttempts", usage.attempts], ["maxWallHours", usage.wallHours], ["maxGpuHours", usage.gpuHours]] as const) {
|
|
140
|
+
if (key === "maxGpuHours" && protocol.limits.gpus === 0 && used === 0) continue;
|
|
141
|
+
if (used >= protocol.budget[key]) throw new Error(`budget ${key}: exhausted; raise protocol.budget.${key}, approve design again, and retry`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function owner(ctx: AttemptContext): NonNullable<Attempt["launching"]> {
|
|
145
|
+
return { pid: process.pid, host: hostname(), token: randomUUID(), claimedAt: at(ctx), expiresAt: new Date(now(ctx) + CONTROL_STALE_MS).toISOString() };
|
|
146
|
+
}
|
|
147
|
+
function alive(attempt: Attempt, ctx: AttemptContext): boolean {
|
|
148
|
+
const owner = attempt.launching;
|
|
149
|
+
if (!owner) return false;
|
|
150
|
+
const claimed = Date.parse(owner.claimedAt), expiry = Math.min(Date.parse(owner.expiresAt), claimed + CONTROL_STALE_MS);
|
|
151
|
+
if (!Number.isFinite(expiry) || claimed > now(ctx) || now(ctx) >= expiry) return false;
|
|
152
|
+
if (owner.host !== hostname()) return true;
|
|
153
|
+
try { process.kill(owner.pid, 0); return true; } catch (error) { return (error as NodeJS.ErrnoException).code !== "ESRCH"; }
|
|
154
|
+
}
|
|
155
|
+
/** Serialize short control commands without changing records or ledger on read-only observations. */
|
|
156
|
+
async function withControl<T>(dir: string, fn: () => Promise<T>): Promise<T> {
|
|
157
|
+
const path = join(dir, ".ares.control.lock"), token = randomUUID();
|
|
158
|
+
const waitingSince = performance.now();
|
|
159
|
+
let fd: number;
|
|
160
|
+
for (;;) {
|
|
161
|
+
try { fd = openSync(path, "wx", 0o600); break; }
|
|
162
|
+
catch (error) {
|
|
163
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
164
|
+
let age = 0; try { age = Date.now() - statSync(path).mtimeMs; } catch { /* owner removed it */ }
|
|
165
|
+
if (performance.now() - waitingSince >= 2000) throw new Error("attempt control: another operation is active; retry reconcile or cancel when it finishes (abandoned claims expire after 30 minutes)");
|
|
166
|
+
if (age < -CONTROL_FUTURE_SKEW_MS || age >= CONTROL_STALE_MS) {
|
|
167
|
+
try { unlinkSync(path); }
|
|
168
|
+
catch (failure) { if ((failure as NodeJS.ErrnoException).code !== "ENOENT") throw new Error("attempt control: abandoned lock could not be removed; restore study-directory ownership and permissions, then retry"); }
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
try { writeSync(fd, token); } finally { closeSync(fd); }
|
|
175
|
+
try { return await controlScope.run({ dir, token }, fn); }
|
|
176
|
+
finally { try { if (readTextBounded(path, 128) === token) unlinkSync(path); } catch { /* stale owner must not remove a newer claim */ } }
|
|
177
|
+
}
|
|
178
|
+
async function update(dir: string, attemptId: string, ctx: AttemptContext, fn: (current: Attempt) => string | undefined, approvals: PendingEvent[] = [], completion?: (attempt: Attempt) => PendingEvent[]): Promise<Attempt> {
|
|
179
|
+
return withLock(dir, () => {
|
|
180
|
+
const scope = controlScope.getStore();
|
|
181
|
+
if (scope?.dir === dir) {
|
|
182
|
+
let currentToken: string | undefined;
|
|
183
|
+
try { currentToken = readTextBounded(join(dir, ".ares.control.lock"), 128); } catch { /* expired or replaced */ }
|
|
184
|
+
if (currentToken !== scope.token) throw new Error("attempt control: ownership expired or changed; run ares attempt reconcile before retrying this operation");
|
|
185
|
+
}
|
|
186
|
+
const current = showAttempt(dir, attemptId), event = fn(current); if (event) save(dir, current, ctx, event, [...approvals, ...(completion?.(current) ?? [])]); return current;
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
function finish(attempt: Attempt, status: AttemptStatus, reason: string | undefined, ctx: AttemptContext): void {
|
|
190
|
+
attempt.status = status; if (reason) attempt.reason = reason; else delete attempt.reason;
|
|
191
|
+
if (status === "succeeded") delete attempt.error;
|
|
192
|
+
attempt.endedAt = at(ctx); delete attempt.launching; delete attempt.pendingAutoApproval; delete attempt.pendingFinalApproval;
|
|
193
|
+
const stage = attempt.stages[attempt.stage]; if (stage && !stage.endedAt) stage.endedAt = attempt.endedAt;
|
|
194
|
+
}
|
|
195
|
+
function hostError(attempt: Attempt, step: string): Error {
|
|
196
|
+
return new Error(`executor ${attempt.executor.host ?? "local"}: ${step} failed; check that step on the executor host and run ares attempt reconcile <dir>`);
|
|
197
|
+
}
|
|
198
|
+
function startupError(attempt: Attempt, step: string): string {
|
|
199
|
+
const recovery = step === "private workspace preparation" ? "make the configured executor workdir an owned physical directory with mode 0700" : step === "evaluator transfer" || step === "design recheck" ? "restore the task evaluator files and approve the current design" : step === "snapshot transfer" ? "check the repository commit and executor workspace" : step === "controller lock" ? "wait for the active control operation to finish" : "run ares executor check <dir> and fix the reported host or image setup";
|
|
200
|
+
return `executor ${attempt.executor.host ?? "local"}: ${step} failed; ${recovery}, then retry with the same attempt options and --retry ID`;
|
|
201
|
+
}
|
|
202
|
+
async function command(attempt: Attempt, step: string, executable: string, args: string[], ctx: AttemptContext) {
|
|
203
|
+
try {
|
|
204
|
+
const result = await runOnExecutor(attempt.executor, executable, args, ctx.runner ?? spawnRunner);
|
|
205
|
+
if (result.exitCode !== 0 || result.timedOut || result.truncated) throw hostError(attempt, step);
|
|
206
|
+
return result;
|
|
207
|
+
} catch { throw hostError(attempt, step); }
|
|
208
|
+
}
|
|
209
|
+
function stageRecord(attempt: Attempt, stage: Stage, user: string, ctx: AttemptContext): AttemptStage {
|
|
210
|
+
return { image: attempt.protocol[stage].image, argv: attempt.protocol[stage].command, mounts: stage === "candidate" ? [{ target: "/workspace", readOnly: true }, { target: "/out", readOnly: false }] : [{ target: "/eval", readOnly: true }, { target: "/candidate", readOnly: true }, { target: "/result", readOnly: false }], network: "none", limits: attempt.protocol.limits, user, startedAt: at(ctx) };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Validate observed container settings before describing its sealing as ares-observed. */
|
|
214
|
+
function observed(raw: unknown, attempt: Attempt): { id: string; running: boolean; exitCode: number; finishedAt?: string; startedAt?: string } {
|
|
215
|
+
const value = Array.isArray(raw) && raw.length === 1 ? raw[0] : undefined;
|
|
216
|
+
if (!plain(value) || typeof value.Id !== "string" || !/^[a-f0-9]{64}$/.test(value.Id) || !plain(value.State) || typeof value.State.Running !== "boolean" || !Number.isSafeInteger(value.State.ExitCode) || !plain(value.Config) || !plain(value.HostConfig) || !Array.isArray(value.Mounts)) throw hostError(attempt, "container inspection");
|
|
217
|
+
const expected = attempt.stages[attempt.stage]!, config = value.Config, settings = value.HostConfig;
|
|
218
|
+
const mounts = attempt.stage === "candidate" ? [[attempt.paths.snapshot, "/workspace", false], [attempt.paths.output, "/out", true]] : [[attempt.paths.evaluator, "/eval", false], [attempt.paths.output, "/candidate", false], [attempt.paths.result, "/result", true]];
|
|
219
|
+
const bindMounts = value.Mounts.filter(m => !(plain(m) && m.Type === "tmpfs" && m.Destination === "/tmp"));
|
|
220
|
+
const devices = settings.DeviceRequests;
|
|
221
|
+
const gpuMatches = expected.limits.gpus === 0 ? devices === null || devices === undefined || (Array.isArray(devices) && devices.length === 0) : Array.isArray(devices) && devices.length === 1 && plain(devices[0]) && devices[0].Count === expected.limits.gpus && Array.isArray(devices[0].Capabilities) && devices[0].Capabilities.some(c => Array.isArray(c) && c.includes("gpu"));
|
|
222
|
+
if (!gpuMatches) throw hostError(attempt, "GPU limit verification");
|
|
223
|
+
if (config.Seed !== `ARES_SEED=${attempt.seed}`) throw hostError(attempt, "selected seed verification");
|
|
224
|
+
if (config.Image !== expected.image || config.User !== expected.user || JSON.stringify(config.Cmd) !== JSON.stringify(expected.argv) || !plain(config.Labels) || config.Labels["ares.study"] !== attempt.studyId || config.Labels["ares.attempt"] !== attempt.id || config.Labels["ares.stage"] !== attempt.stage || settings.NetworkMode !== "none" || settings.ReadonlyRootfs !== true || !Array.isArray(settings.CapDrop) || !settings.CapDrop.some(c => c === "ALL" || c === "all") || !Array.isArray(settings.SecurityOpt) || !settings.SecurityOpt.includes("no-new-privileges") || !plain(settings.Tmpfs) || !("/tmp" in settings.Tmpfs) || settings.Memory !== expected.limits.memoryMb * 1024 * 1024 || settings.PidsLimit !== expected.limits.pidsLimit || settings.NanoCpus !== Math.round(expected.limits.cpus * 1e9) || bindMounts.length !== mounts.length || !mounts.every(([source, target, writable]) => bindMounts.some(m => plain(m) && m.Type === "bind" && m.Source === source && m.Destination === target && m.RW === writable))) throw hostError(attempt, "container isolation verification");
|
|
225
|
+
if (expected.containerId && value.Id !== expected.containerId) throw hostError(attempt, "container identity verification");
|
|
226
|
+
const finishedAt = typeof value.State.FinishedAt === "string" && Number.isFinite(Date.parse(value.State.FinishedAt)) ? value.State.FinishedAt : undefined;
|
|
227
|
+
const startedAt = typeof value.State.StartedAt === "string" && Number.isFinite(Date.parse(value.State.StartedAt)) ? value.State.StartedAt : undefined;
|
|
228
|
+
return { id: value.Id, running: value.State.Running, exitCode: value.State.ExitCode as number, ...(finishedAt ? { finishedAt } : {}), ...(startedAt ? { startedAt } : {}) };
|
|
229
|
+
}
|
|
230
|
+
type ObservedStage = ReturnType<typeof observed>;
|
|
231
|
+
function needsReceipt(attempt: Attempt, state: ObservedStage): boolean {
|
|
232
|
+
const stage = attempt.stages[attempt.stage]!;
|
|
233
|
+
return !!attempt.launching || stage.containerId !== state.id || (!!state.startedAt && stage.startedAt !== state.startedAt) || (attempt.stage === "candidate" ? !attempt.sealing.candidateHasNoEvaluator || !!attempt.pendingAutoApproval || !!attempt.pendingFinalApproval : !attempt.sealing.evaluatorReadOnly || !attempt.sealing.separateContainers);
|
|
234
|
+
}
|
|
235
|
+
/** Every verified path persists the same receipt; auto approval and candidate receipt share one commit. */
|
|
236
|
+
async function recordVerifiedStage(dir: string, attempt: Attempt, state: ObservedStage, ctx: AttemptContext, event: string, approveDesign = true): Promise<Attempt> {
|
|
237
|
+
const approvals: PendingEvent[] = [];
|
|
238
|
+
return update(dir, attempt.id, ctx, current => {
|
|
239
|
+
if (current.status !== "running" || current.stage !== attempt.stage) return undefined;
|
|
240
|
+
const stage = current.stages[current.stage]!;
|
|
241
|
+
stage.containerId = state.id; if (state.startedAt) stage.startedAt = state.startedAt;
|
|
242
|
+
if (current.reason === "executor-start" || current.reason === "executor-start-uncertain") { delete current.reason; delete current.error; }
|
|
243
|
+
delete current.launching;
|
|
244
|
+
if (current.stage === "candidate") {
|
|
245
|
+
current.sealing.candidateHasNoEvaluator = true;
|
|
246
|
+
if (approveDesign && current.pendingAutoApproval === current.sealBinding && designBinding(dir) === current.sealBinding) {
|
|
247
|
+
const study = openStudy(dir).study;
|
|
248
|
+
if (resolveAutonomy(study.autonomy, "design") === "auto") approvals.push(...requireGate(dir, study, "design", () => current.sealBinding));
|
|
249
|
+
}
|
|
250
|
+
if (approveDesign && current.pendingFinalApproval === current.finalBinding && current.finalBinding && finalGateBinding(dir) === current.finalBinding) {
|
|
251
|
+
const study = openStudy(dir).study;
|
|
252
|
+
if (resolveAutonomy(study.autonomy, "final") === "auto") approvals.push(...requireGate(dir, study, "final", () => current.finalBinding));
|
|
253
|
+
}
|
|
254
|
+
delete current.pendingAutoApproval; delete current.pendingFinalApproval;
|
|
255
|
+
} else {
|
|
256
|
+
current.sealing.evaluatorReadOnly = true;
|
|
257
|
+
current.sealing.separateContainers = !!current.stages.candidate?.containerId && state.id !== current.stages.candidate.containerId;
|
|
258
|
+
}
|
|
259
|
+
return event;
|
|
260
|
+
}, approvals);
|
|
261
|
+
}
|
|
262
|
+
async function inspect(attempt: Attempt, ctx: AttemptContext) {
|
|
263
|
+
let result;
|
|
264
|
+
try { result = await runOnExecutor(attempt.executor, "docker", ["inspect", "--format", INSPECT_FORMAT, containerName(attempt.id, attempt.stage)], ctx.runner ?? spawnRunner, { maxOutputBytes: ATTEMPT_BYTES }); }
|
|
265
|
+
catch { throw hostError(attempt, "container inspection"); }
|
|
266
|
+
if (result.timedOut || result.truncated) throw hostError(attempt, "container inspection");
|
|
267
|
+
if (result.exitCode === 1) {
|
|
268
|
+
// inspect also exits 1 for daemon outages. Only a successful empty daemon listing confirms absence.
|
|
269
|
+
const probe = await command(attempt, "container absence confirmation", "docker", ["ps", "--all", "--filter", `name=^/${containerName(attempt.id, attempt.stage)}$`, "--format", "{{.ID}}"], ctx);
|
|
270
|
+
if (probe.stdout.trim() === "") return undefined;
|
|
271
|
+
throw hostError(attempt, "container inspection");
|
|
272
|
+
}
|
|
273
|
+
if (result.exitCode !== 0) throw hostError(attempt, "container inspection");
|
|
274
|
+
let raw: unknown; try { raw = JSON.parse(result.stdout); } catch { throw hostError(attempt, "container inspection"); }
|
|
275
|
+
return observed(raw, attempt);
|
|
276
|
+
}
|
|
277
|
+
async function launch(dir: string, attempt: Attempt, user: string, ctx: AttemptContext): Promise<Attempt> {
|
|
278
|
+
let startedId: string | undefined;
|
|
279
|
+
try {
|
|
280
|
+
const args = buildContainerArgs({ protocol: attempt.protocol, stage: attempt.stage, studyId: openStudy(dir).study.id, attemptId: attempt.id, seed: attempt.seed, user, paths: attempt.paths });
|
|
281
|
+
const result = await command(attempt, "container start", "docker", args, ctx), cid = result.stdout.trim();
|
|
282
|
+
if (!/^[a-f0-9]{64}$/.test(cid)) throw hostError(attempt, "container start");
|
|
283
|
+
startedId = cid;
|
|
284
|
+
attempt.stages[attempt.stage]!.containerId = cid;
|
|
285
|
+
await startWatchdog(attempt.executor, attempt.id, attempt.stage, attempt.protocol.limits.wallSeconds, ctx.runner ?? spawnRunner, cid);
|
|
286
|
+
const state = await inspect(attempt, ctx); if (!state) throw hostError(attempt, "container start verification");
|
|
287
|
+
return recordVerifiedStage(dir, attempt, state, ctx, "attempt.stage.started");
|
|
288
|
+
} catch {
|
|
289
|
+
if (!startedId) {
|
|
290
|
+
try {
|
|
291
|
+
// A lost response is not proof that Docker rejected the start. Adopt only a verified owned container.
|
|
292
|
+
const state = await inspect(attempt, ctx);
|
|
293
|
+
if (state) {
|
|
294
|
+
if (state.running) await startWatchdog(attempt.executor, attempt.id, attempt.stage, attempt.protocol.limits.wallSeconds, ctx.runner ?? spawnRunner, state.id);
|
|
295
|
+
return recordVerifiedStage(dir, attempt, state, ctx, "attempt.stage.recovered");
|
|
296
|
+
}
|
|
297
|
+
} catch {
|
|
298
|
+
return update(dir, attempt.id, ctx, current => { delete current.launching; current.reason = "executor-start-uncertain"; current.error = hostError(current, "container start confirmation").message; return "attempt.executor-error"; });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
// A name conflict can belong to another study with the same identity. Only clean up our returned container id.
|
|
302
|
+
if (startedId) {
|
|
303
|
+
try { await command(attempt, "failed launch cleanup", "docker", ["kill", startedId], ctx); }
|
|
304
|
+
catch { return update(dir, attempt.id, ctx, current => { delete current.launching; current.reason = "executor-start"; current.error = hostError(current, "failed start cleanup").message; return "attempt.executor-error"; }); }
|
|
305
|
+
}
|
|
306
|
+
return update(dir, attempt.id, ctx, current => { if (current.status !== "running") return undefined; current.error = startupError(current, "container start"); finish(current, "failed", "executor-start", ctx); return "attempt.failed"; });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function captureStage(dir: string, attempt: Attempt, ctx: AttemptContext, bestEffort: boolean): Promise<Attempt> {
|
|
311
|
+
if (attempt.stages[attempt.stage]?.evidence) return attempt;
|
|
312
|
+
try {
|
|
313
|
+
const collected = await (ctx.collectStageEvidence ?? defaultStageCollector)(attempt, attempt.stage, ctx.runner ?? spawnRunner);
|
|
314
|
+
const metadata = (log: StageEvidence["logs"]["stdout"]) => ({ path: log.path, sha256: log.sha256, bytes: log.bytes, truncated: log.truncated });
|
|
315
|
+
const evidence: StageEvidence = { logs: { stdout: metadata(collected.logs.stdout), stderr: metadata(collected.logs.stderr) }, outputs: collected.outputs.map(output => ({ path: output.path, sha256: output.sha256, bytes: output.bytes })) };
|
|
316
|
+
const outputs = outputBudget([...attempt.outputs, ...evidence.outputs]);
|
|
317
|
+
return update(dir, attempt.id, ctx, current => { current.stages[current.stage]!.evidence = evidence; current.outputs = outputs; return "attempt.stage.evidence"; });
|
|
318
|
+
} catch (error) {
|
|
319
|
+
const failure = error instanceof EvidenceError ? error : new EvidenceError("evidence-read", "stage evidence: collection failed; inspect the executor stage files and retry the attempt");
|
|
320
|
+
if (!bestEffort) throw failure;
|
|
321
|
+
return update(dir, attempt.id, ctx, current => { current.stages[current.stage]!.evidenceError = { reason: failure.reason, message: failure.message }; return "attempt.stage.evidence-failed"; });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export async function runAttempt(dirArg: string, options: AttemptOptions, ctx: AttemptContext): Promise<Attempt> {
|
|
326
|
+
const dir = studyDir(dirArg), { study } = openStudy(dir);
|
|
327
|
+
const frozen = await withLock(dir, () => {
|
|
328
|
+
requireCopilotAccept(dir, "hypotheses");
|
|
329
|
+
const design = readDesign(dir), executor = readExecutor(dir);
|
|
330
|
+
if (!design) throw new Error("protocol: missing; run ares protocol <dir> --file PATH before attempt run");
|
|
331
|
+
if (!executor) throw new Error("executor: missing; run ares executor set <dir> --kind local|ssh --workdir PATH before attempt run");
|
|
332
|
+
return { ...design, executor, finalSeeds: readFinalSeeds(dir, design.protocol) };
|
|
333
|
+
});
|
|
334
|
+
const { protocol, executor } = frozen;
|
|
335
|
+
if (!/^[A-Za-z0-9_-]{1,80}$/.test(study.id)) throw new Error("study id: invalid; restore the id generated by ares init before attempt run");
|
|
336
|
+
const run = runName(options.run ?? "main"), label = boundedText(options.label ?? "baseline", "label", 128), seed = options.seed ?? protocol.seeds[0]!;
|
|
337
|
+
if (!Number.isSafeInteger(seed) || !protocol.seeds.includes(seed)) throw new Error("seed: undeclared; choose a seed from protocol.seeds");
|
|
338
|
+
if (frozen.finalSeeds?.seeds.includes(seed) && run !== "final") throw new Error("attempt seed: reserved for run final; choose a non-reserved exploratory seed");
|
|
339
|
+
if (run === "posthoc") await withLock(dir, () => requirePosthocClosed(dir));
|
|
340
|
+
const finalAuthorization = run === "final" ? await withLock(dir, () => authorizeFinalAttempt(dir, options.commit, seed)) : undefined;
|
|
341
|
+
if (options.retry !== undefined) id(options.retry);
|
|
342
|
+
const initialBinding = frozen.binding;
|
|
343
|
+
const snapshot = await resolveSnapshot(options.repo, options.commit, ctx.runner ?? spawnRunner), hash = protocolHash(protocol);
|
|
344
|
+
const wanted = attemptId(hash, snapshot.commit, snapshot.treeId, seed, run, options.retry);
|
|
345
|
+
if (options.expectedId !== undefined && wanted !== options.expectedId) throw new Error("attempt identity: changed since plan intent; reconcile the plan node before retrying");
|
|
346
|
+
let created = false;
|
|
347
|
+
let attempt = await withLock(dir, () => {
|
|
348
|
+
requireCopilotAccept(dir, "hypotheses");
|
|
349
|
+
if (run === "posthoc") requirePosthocClosed(dir);
|
|
350
|
+
if (finalAuthorization && authorizeFinalAttempt(dir, options.commit, seed).binding !== finalAuthorization.binding) throw new Error("final inputs changed during snapshot resolution; approve the current final gate before retrying");
|
|
351
|
+
const attempts = load(dir), existing = attempts.find(a => a.id === wanted); if (existing) return existing;
|
|
352
|
+
if (designBinding(dir) !== initialBinding) throw new Error("design: changed while resolving the snapshot; approve the current design and retry attempt run");
|
|
353
|
+
if (options.retry && !attempts.some(a => a.id === options.retry)) throw new Error("retry: attempt not found; choose an existing id from ares attempt list");
|
|
354
|
+
if (attempts.some(a => a.status === "running")) throw new Error("attempt: another attempt is running; reconcile or cancel it before starting a new one");
|
|
355
|
+
const binding = designBinding(dir), approval = requireGate(dir, openStudy(dir).study, "design", () => binding);
|
|
356
|
+
enforceBudget(protocol, attempts, now(ctx));
|
|
357
|
+
const root = posix.join(executor.workdir, study.id, wanted);
|
|
358
|
+
const record: Attempt = { v: 1, id: wanted, studyId: study.id, run, protocolHash: hash, sealBinding: binding!, ...snapshot, seed, label, ...(options.retry ? { retryOf: options.retry } : {}), executor, protocol, paths: { snapshot: posix.join(root, "snapshot"), output: posix.join(root, "output"), evaluator: posix.join(root, "task", "evaluator"), result: posix.join(root, "result") }, status: "running", stage: "candidate", startedAt: at(ctx), stages: {}, metrics: [], outputs: [], sealing: { separateContainers: false, evaluatorReadOnly: false, candidateHasNoEvaluator: false, verifiedBy: "ares-observed" }, launching: owner(ctx) };
|
|
359
|
+
if (approval.length) record.pendingAutoApproval = binding!;
|
|
360
|
+
if (finalAuthorization) { record.finalBinding = finalAuthorization.binding; record.finalWinner = finalAuthorization.winner; if (finalAuthorization.auto) record.pendingFinalApproval = finalAuthorization.binding; }
|
|
361
|
+
commit(dir, [[file(dir, record), JSON.stringify(record, null, 2) + "\n", ATTEMPT_BYTES]], [{ actor: ctx.actor, type: "attempt.started", data: { id: wanted, run, seed, commit: record.commit, treeId: record.treeId } }]);
|
|
362
|
+
created = true; return record;
|
|
363
|
+
});
|
|
364
|
+
if (created) {
|
|
365
|
+
let preparationStep = "executor check";
|
|
366
|
+
try {
|
|
367
|
+
const checked = await checkExecutorInputs(attempt.executor, attempt.protocol, ctx.runner ?? spawnRunner);
|
|
368
|
+
const root = posix.dirname(attempt.paths.snapshot), studyRoot = posix.dirname(root), base = executor.workdir;
|
|
369
|
+
// Require a private, owned, physical root. Attempt mkdir is exclusive, never a shared pre-existing directory.
|
|
370
|
+
const script = `set -eu; umask 077; base=${shellQuote(base)}; test -d "$base"; test ! -L "$base"; test -O "$base"; test "$(cd -P "$base" && pwd -P)" = "$base"; mode=$(stat -c %a "$base" 2>/dev/null || stat -f %Lp "$base"); test "$mode" = 700; study=${shellQuote(studyRoot)}; if test ! -e "$study"; then mkdir -m 700 "$study"; fi; test -d "$study"; test ! -L "$study"; test -O "$study"; mode=$(stat -c %a "$study" 2>/dev/null || stat -f %Lp "$study"); test "$mode" = 700; mkdir -m 700 ${shellQuote(root)}; mkdir -m 700 ${[attempt.paths.snapshot, attempt.paths.output, attempt.paths.result, posix.dirname(attempt.paths.evaluator)].map(shellQuote).join(" ")}`;
|
|
371
|
+
preparationStep = "private workspace preparation";
|
|
372
|
+
await command(attempt, "private workspace preparation", "sh", ["-c", script], ctx);
|
|
373
|
+
preparationStep = "snapshot transfer";
|
|
374
|
+
const transferred = await transferSnapshot(options.repo, snapshot.commit, executor, attempt.paths.snapshot, ctx.runner ?? spawnRunner);
|
|
375
|
+
preparationStep = "evaluator transfer";
|
|
376
|
+
await transferTask(join(dir, "task"), executor, posix.dirname(attempt.paths.evaluator), ctx.runner ?? spawnRunner, frozen.treeHash);
|
|
377
|
+
preparationStep = "design recheck";
|
|
378
|
+
if (designBinding(dir) !== attempt.sealBinding) throw new Error("design changed during preparation");
|
|
379
|
+
if (attempt.finalBinding && finalGateBinding(dir) !== attempt.finalBinding) throw new Error("final inputs changed during preparation");
|
|
380
|
+
const claim = attempt.launching!.token;
|
|
381
|
+
attempt = await update(dir, attempt.id, ctx, current => { if (current.status !== "running" || current.launching?.token !== claim) throw new Error("attempt preparation: ownership changed; run ares attempt reconcile before retrying"); current.snapshotSha256 = transferred.sha256; current.stages.candidate = stageRecord(current, "candidate", checked.user, ctx); return "attempt.snapshot"; });
|
|
382
|
+
preparationStep = "controller lock";
|
|
383
|
+
attempt = await withControl(dir, async () => {
|
|
384
|
+
const current = showAttempt(dir, attempt.id);
|
|
385
|
+
if (current.status !== "running" || current.launching?.token !== claim) return current;
|
|
386
|
+
return launch(dir, current, checked.user, ctx);
|
|
387
|
+
});
|
|
388
|
+
} catch {
|
|
389
|
+
attempt = await update(dir, attempt.id, ctx, current => { if (current.status !== "running") return undefined; current.error = startupError(current, preparationStep); finish(current, "failed", "preparation", ctx); return "attempt.failed"; });
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (!options.detach) {
|
|
393
|
+
let delay = 1000;
|
|
394
|
+
while (attempt.status === "running") {
|
|
395
|
+
await (ctx.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms))))(delay);
|
|
396
|
+
const previousStage = attempt.stage;
|
|
397
|
+
await reconcileAttempts(dir, ctx); attempt = showAttempt(dir, wanted);
|
|
398
|
+
delay = attempt.stage === previousStage ? Math.min(delay * 2, 30_000) : 1000;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return attempt;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export async function reconcileAttempts(dirArg: string, ctx: AttemptContext): Promise<Attempt[]> {
|
|
405
|
+
const dir = studyDir(dirArg);
|
|
406
|
+
return withControl(dir, async () => {
|
|
407
|
+
const changed: Attempt[] = [];
|
|
408
|
+
for (let attempt of load(dir).filter(a => a.status === "running")) {
|
|
409
|
+
if (alive(attempt, ctx)) continue;
|
|
410
|
+
try {
|
|
411
|
+
let state = await inspect(attempt, ctx);
|
|
412
|
+
if (!state) { changed.push(await update(dir, attempt.id, ctx, current => { finish(current, "unknown", "lost", ctx); return "attempt.unknown"; })); continue; }
|
|
413
|
+
const recovered = needsReceipt(attempt, state);
|
|
414
|
+
if (recovered) attempt = await recordVerifiedStage(dir, attempt, state, ctx, "attempt.stage.recovered");
|
|
415
|
+
const stage = attempt.stages[attempt.stage];
|
|
416
|
+
if (!stage) throw hostError(attempt, "stage record");
|
|
417
|
+
const deadline = Date.parse(stage.startedAt) + stage.limits.wallSeconds * 1000;
|
|
418
|
+
if ((state.running && now(ctx) >= deadline) || (!state.running && state.finishedAt !== undefined && Date.parse(state.finishedAt) >= deadline)) {
|
|
419
|
+
if (state.running) { await command(attempt, "wall-clock kill", "docker", ["kill", state.id], ctx); const stopped = await inspect(attempt, ctx); if (stopped) state = stopped; }
|
|
420
|
+
attempt = await captureStage(dir, attempt, ctx, true);
|
|
421
|
+
const ended = state;
|
|
422
|
+
changed.push(await update(dir, attempt.id, ctx, current => { if (ended.finishedAt) current.stages[current.stage]!.endedAt = ended.finishedAt; if (!ended.running) current.stages[current.stage]!.exitCode = ended.exitCode; finish(current, "failed", "wall-clock", ctx); return "attempt.failed"; })); continue;
|
|
423
|
+
}
|
|
424
|
+
if (state.running) {
|
|
425
|
+
if (recovered) changed.push(attempt);
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
if (state.exitCode !== 0) { attempt = await captureStage(dir, attempt, ctx, true); const ended = state; changed.push(await update(dir, attempt.id, ctx, current => { current.stages[current.stage]!.exitCode = ended.exitCode; if (ended.finishedAt) current.stages[current.stage]!.endedAt = ended.finishedAt; finish(current, "failed", `${current.stage}-exit`, ctx); return "attempt.failed"; })); continue; }
|
|
429
|
+
try { attempt = await captureStage(dir, attempt, ctx, false); }
|
|
430
|
+
catch (error) {
|
|
431
|
+
const failure = error as EvidenceError, ended = state;
|
|
432
|
+
changed.push(await update(dir, attempt.id, ctx, current => {
|
|
433
|
+
current.stages[current.stage]!.exitCode = ended.exitCode;
|
|
434
|
+
if (ended.finishedAt) current.stages[current.stage]!.endedAt = ended.finishedAt;
|
|
435
|
+
current.error = failure.message; finish(current, "failed", failure.reason, ctx); return "attempt.failed";
|
|
436
|
+
})); continue;
|
|
437
|
+
}
|
|
438
|
+
if (attempt.stage === "candidate") {
|
|
439
|
+
const next = await update(dir, attempt.id, ctx, current => { current.stages.candidate!.exitCode = 0; current.stages.candidate!.endedAt = state.finishedAt ?? at(ctx); current.stage = "evaluator"; current.stages.evaluator = stageRecord(current, "evaluator", stage.user, ctx); current.launching = owner(ctx); return "attempt.stage.changed"; });
|
|
440
|
+
changed.push(await launch(dir, next, stage.user, ctx));
|
|
441
|
+
} else {
|
|
442
|
+
let evaluation: Evaluation | undefined, failure: EvidenceError | undefined;
|
|
443
|
+
try { evaluation = await (ctx.collectEvaluation ?? defaultEvaluationCollector)(attempt, ctx.runner ?? spawnRunner); }
|
|
444
|
+
catch (error) { failure = error instanceof EvidenceError ? error : new MetricsError("invalid-metrics", "evaluator result validation failed"); }
|
|
445
|
+
changed.push(await update(dir, attempt.id, ctx, current => {
|
|
446
|
+
current.stages.evaluator!.exitCode = 0;
|
|
447
|
+
if (state.finishedAt) current.stages.evaluator!.endedAt = state.finishedAt;
|
|
448
|
+
if (!evaluation) { const rejected = failure ?? new MetricsError("invalid-metrics", "the collector returned no result"); current.error = rejected.message; finish(current, "failed", rejected.reason, ctx); return "attempt.failed"; }
|
|
449
|
+
current.metrics = evaluation.metrics; current.outputs = outputBudget([...current.outputs, ...evaluation.outputs]); finish(current, "succeeded", undefined, ctx); return "attempt.succeeded";
|
|
450
|
+
}));
|
|
451
|
+
}
|
|
452
|
+
} catch {
|
|
453
|
+
await update(dir, attempt.id, ctx, current => { delete current.launching; return "attempt.executor-error"; });
|
|
454
|
+
throw hostError(attempt, "reconcile");
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return changed;
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
export async function cancelAttempt(dirArg: string, wanted: string, reason: string | undefined, ctx: AttemptContext, completion?: (attempt: Attempt) => PendingEvent[]): Promise<Attempt> {
|
|
461
|
+
const dir = studyDir(dirArg), why = reason === undefined ? undefined : boundedText(reason, "reason", 500);
|
|
462
|
+
return withControl(dir, async () => {
|
|
463
|
+
let attempt = showAttempt(dir, id(wanted));
|
|
464
|
+
if (attempt.status !== "running") return withLock(dir, () => {
|
|
465
|
+
const current = showAttempt(dir, wanted);
|
|
466
|
+
const pending = completion?.(current) ?? [];
|
|
467
|
+
if (pending.length) commit(dir, [], pending);
|
|
468
|
+
return current;
|
|
469
|
+
});
|
|
470
|
+
if (alive(attempt, ctx)) throw new Error("attempt: another startup operation is active; retry cancel when it finishes (abandoned claims expire after 30 minutes)");
|
|
471
|
+
try {
|
|
472
|
+
let state = await inspect(attempt, ctx);
|
|
473
|
+
if (state && needsReceipt(attempt, state)) attempt = await recordVerifiedStage(dir, attempt, state, ctx, "attempt.stage.recovered", false);
|
|
474
|
+
if (state?.running) { await command(attempt, "cancel kill", "docker", ["kill", state.id], ctx); state = await inspect(attempt, ctx); }
|
|
475
|
+
if (state) attempt = await captureStage(dir, attempt, ctx, true);
|
|
476
|
+
const ended = state;
|
|
477
|
+
return update(dir, attempt.id, ctx, current => { if (ended && !ended.running) { current.stages[current.stage]!.exitCode = ended.exitCode; if (ended.finishedAt) current.stages[current.stage]!.endedAt = ended.finishedAt; } finish(current, "cancelled", why ?? "cancelled", ctx); return "attempt.cancelled"; }, [], completion);
|
|
478
|
+
} catch {
|
|
479
|
+
await update(dir, attempt.id, ctx, current => { delete current.launching; return "attempt.executor-error"; });
|
|
480
|
+
throw hostError(attempt, "cancel");
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
}
|