@hackerrank/astra-cli 0.1.6 → 0.1.8

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/README.md CHANGED
@@ -29,7 +29,7 @@ Every run is a resumable session, with exact token accounting and USD cost track
29
29
  | `src/model.js` | Gateway client (OpenAI-compatible `/chat/completions`). Handles retries, the `max_tokens` vs `max_completion_tokens` difference, and cost accounting. |
30
30
  | `src/environment.js` | Runs one command per action in a fresh subshell, with a per-command timeout that kills the whole process group and output truncation. |
31
31
  | `src/prompts.js` | System / instance / format-error / observation templates + a tiny `{{var}}` renderer, split into interactive vs autonomous rules. |
32
- | `src/agent.js` | The turn-based engine: query → parse one command → execute → observe → repeat. Handles submission, step/time limits, and format errors. |
32
+ | `src/agent.js` | The turn-based engine: query → parse shell blocks → execute → observe → repeat. Autonomous runs can combine related blocks; interactive runs remain strict. |
33
33
  | `src/repl.js` | The interactive assistant loop: prompts, command approval, slash commands. |
34
34
  | `src/session.js` | Resumable sessions stored under `~/.astra/sessions/`. |
35
35
  | `src/config.js` | Dedicated `~/.astra/config.json`, credential resolution, interactive key prompt. |
@@ -141,19 +141,26 @@ it through the existing bench persona:
141
141
  astra bench --project ./task
142
142
  ```
143
143
 
144
- `./task/.astra/project.toml` selects the public workspace, instruction,
145
- models, limits, output root, and optional bench template/extensions. CLI flags
146
- still override bench limits and output paths. Astra copies only the configured
147
- workspace; container lifecycle, services, secrets, verification, and scoring
148
- remain harness responsibilities. Run artifacts include `project.json` with the
149
- project and input hashes. `completed` means the agent emitted its completion
150
- sentinel—not that the candidate is correct; `model_error` and `request_error`
151
- distinguish generation/provider failures from invalid invocation/configuration.
144
+ `./task/.astra/project.toml` selects the task type (`brownfield` or
145
+ `greenfield`), public workspace, instruction, models, limits, output root, and
146
+ optional context extensions. A task-owned `verifier/run_verifier.py` is
147
+ discovered automatically; an explicit `[verification]` entry may override its
148
+ command and lifecycle settings. CLI flags still override bench limits and
149
+ output paths.
150
+
151
+ A project-configured run copies only the public workspace, freezes each
152
+ candidate, invokes the task-owned verifier outside that workspace, reads its
153
+ versioned JSON report, and writes a merged result. Astra contains no task
154
+ business logic. If no verifier exists, the run is still recorded with
155
+ `solved_score = "NA"`; if a verifier is configured but fails to produce a
156
+ valid report, the result is an infrastructure failure. `model_error`,
157
+ `candidate`, `verifier`, and `infrastructure` failures remain distinct.
152
158
 
153
159
  ```toml
154
160
  [project]
155
161
  id = "peoplecore-scim"
156
162
  version = 1
163
+ type = "brownfield"
157
164
  instruction = "instruction.md"
158
165
  workspace = "public/codebase"
159
166
 
@@ -165,12 +172,26 @@ steps = 60
165
172
  wall_seconds = 3600
166
173
  command_timeout_seconds = 120
167
174
 
175
+ [verification]
176
+ command = "python3 verifier/run_verifier.py --base-url $SCIM_BASE_URL --token $SCIM_BEARER_TOKEN --report $ASTRA_VERIFIER_REPORT"
177
+ base_url = "http://127.0.0.1:3000/api/v1/scim"
178
+ readiness_url = "http://127.0.0.1:3000/health"
179
+ timeout_seconds = 3600
180
+ report = "verifier-report.json"
181
+ token_env = "SCIM_BEARER_TOKEN"
182
+
168
183
  [templates]
169
184
  bench = ".astra/templates/bench.md"
170
185
  [extensions]
171
186
  directories = [".astra/extensions"]
172
187
  ```
173
188
 
189
+ Project runs are appendable. The durable cell key is task/version/model/
190
+ reasoning/repeat. The report display key is `model-reasoning`, so adding a
191
+ model, reasoning effort, or a higher per-cell repeat count preserves completed
192
+ cells and processes only missing cells. If a process stops, rerunning the same
193
+ project resumes the cell/session or verifier stage from the ledger.
194
+
174
195
  ### HTML report
175
196
 
176
197
  Every matrix run (and every single `-p`/`-t`/`-f` bench run) rebuilds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hackerrank/astra-cli",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Minimal zero-dependency AI coding agent for the HackerRank AI Gateway.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent.js CHANGED
@@ -150,7 +150,7 @@ export class Agent {
150
150
  // --- 2. Parse a command (may be absent) ---
151
151
  let command;
152
152
  try {
153
- command = parseCommand(content);
153
+ command = parseCommand(content, { allowMultiple: this.mode === "autonomous" });
154
154
  this.formatErrorStreak = 0;
155
155
  } catch (err) {
156
156
  // In interactive mode, "no command" is a normal chat reply that yields
@@ -320,9 +320,11 @@ export class Agent {
320
320
  }
321
321
 
322
322
  /** Extract exactly one command from a fenced bash block. */
323
- export function parseCommand(text) {
324
- // Accept ```bash, ```sh, or a bare ``` block.
325
- const re = /```(?:bash|sh)?\s*\n([\s\S]*?)```/g;
323
+ export function parseCommand(text, { allowMultiple = false } = {}) {
324
+ // Accept common shell fence labels. Autonomous mode can combine several
325
+ // fenced blocks into one script; interactive mode retains the strict one
326
+ // command contract.
327
+ const re = /```(?:bash|sh|shell|zsh)?[ \t]*\r?\n([\s\S]*?)```/gi;
326
328
  const blocks = [];
327
329
  let m;
328
330
  while ((m = re.exec(text)) !== null) blocks.push(m[1].trim());
@@ -332,12 +334,12 @@ export function parseCommand(text) {
332
334
  err.code = "NO_COMMAND";
333
335
  throw err;
334
336
  }
335
- if (blocks.length > 1) {
337
+ if (blocks.length > 1 && !allowMultiple) {
336
338
  const err = new Error(`Found ${blocks.length} code blocks; provide exactly one.`);
337
339
  err.code = "MULTI_COMMAND";
338
340
  throw err;
339
341
  }
340
- const cmd = blocks[0].trim();
342
+ const cmd = blocks.join("\n").trim();
341
343
  if (!cmd) {
342
344
  const err = new Error("The bash code block was empty.");
343
345
  err.code = "EMPTY_COMMAND";
package/src/bench.js CHANGED
@@ -23,6 +23,7 @@ import { spawnSync } from "node:child_process";
23
23
  import { GatewayModel } from "./model.js";
24
24
  import { LocalEnvironment } from "./environment.js";
25
25
  import { Agent } from "./agent.js";
26
+ import { loadSession, newSessionId, saveSession } from "./session.js";
26
27
 
27
28
  /** CSV columns, in order, for the metrics table. */
28
29
  export const METRIC_COLUMNS = [
@@ -49,6 +50,11 @@ export const METRIC_COLUMNS = [
49
50
  "last_context_tokens",
50
51
  "cost_usd",
51
52
  "cost_source",
53
+ "verification_status",
54
+ "verification_score",
55
+ "solved_score",
56
+ "verification_hard_pass",
57
+ "failure_owner",
52
58
  ];
53
59
 
54
60
  /**
@@ -295,15 +301,33 @@ export async function runCell({
295
301
  wall = 0,
296
302
  timeout = 60,
297
303
  maxOutputChars = 16000,
304
+ runDir: existingRunDir = null,
305
+ sessionId: existingSessionId = null,
306
+ resume = false,
307
+ cell = null,
298
308
  onStart = () => {},
299
309
  onDone = () => {},
300
310
  } = {}) {
301
- const alloc = allocateRun({ model, reasoning, root });
311
+ const alloc = existingRunDir
312
+ ? {
313
+ dir: path.resolve(existingRunDir),
314
+ workspace: path.join(path.resolve(existingRunDir), "workspace"),
315
+ root: benchRoot(root),
316
+ slug: benchSlug(model, reasoning),
317
+ runId: path.basename(path.resolve(existingRunDir)),
318
+ }
319
+ : allocateRun({ model, reasoning, root });
320
+ fs.mkdirSync(alloc.workspace, { recursive: true });
302
321
  onStart({ ...alloc, model, reasoning });
303
322
 
304
- const seeded = seedWorkspace(alloc.workspace, { taskPath, taskFile, taskText: task });
305
- const taskText = seeded.taskText || task || "";
306
- fs.writeFileSync(path.join(alloc.dir, "task.md"), taskText);
323
+ let taskText = task || "";
324
+ if (!resume) {
325
+ const seeded = seedWorkspace(alloc.workspace, { taskPath, taskFile, taskText: task });
326
+ taskText = seeded.taskText || task || "";
327
+ fs.writeFileSync(path.join(alloc.dir, "task.md"), taskText);
328
+ } else if (!taskText) {
329
+ taskText = fs.existsSync(path.join(alloc.dir, "task.md")) ? fs.readFileSync(path.join(alloc.dir, "task.md"), "utf8") : "";
330
+ }
307
331
  writeProjectMetadata(alloc.dir, projectMetadata);
308
332
 
309
333
  const gw = new GatewayModel({
@@ -313,16 +337,40 @@ export async function runCell({
313
337
  modelKwargs: reasoningKwargs(reasoning),
314
338
  });
315
339
  const env = new LocalEnvironment({ cwd: alloc.workspace, timeout, maxOutputChars });
340
+ const sessionId = existingSessionId || newSessionId();
316
341
  const agent = new Agent(gw, env, {
317
342
  mode: "autonomous",
318
343
  stepLimit: steps,
319
344
  wallTimeLimitSeconds: wall,
320
345
  outputPath: path.join(alloc.dir, "trajectory.json"),
346
+ sessionId,
347
+ saveSession,
348
+ task: taskText,
349
+ bench: { dir: alloc.dir, workspace: alloc.workspace, root: alloc.root, task: taskText, taskPath, model, reasoning, steps, wall, timeout, segments: [] },
321
350
  });
322
351
 
352
+ if (resume && existingSessionId) {
353
+ const saved = loadSession(existingSessionId);
354
+ agent.restore(saved);
355
+ agent.exitStatus = null;
356
+ if (Number(steps) > 0) agent.stepLimit = agent.nSteps + Number(steps);
357
+ if (Number(wall) > 0) agent.wallTimeLimitSeconds = Math.ceil((Date.now() - agent.startTime) / 1000) + Number(wall);
358
+ }
359
+
323
360
  let error = null;
324
361
  try {
325
- await agent.run(taskText);
362
+ if (resume && existingSessionId) {
363
+ while (true) {
364
+ if (agent.stepLimit > 0 && agent.nSteps >= agent.stepLimit) {
365
+ agent.exit("LimitsExceeded", "");
366
+ break;
367
+ }
368
+ const turn = await agent.runTurn();
369
+ if (turn.kind === "exit") break;
370
+ }
371
+ } else {
372
+ await agent.run(taskText);
373
+ }
326
374
  } catch (err) {
327
375
  error = String(err?.message || err);
328
376
  if (!agent.exitStatus) agent.exit("Error", "");
@@ -332,7 +380,7 @@ export async function runCell({
332
380
  if (error) metrics.exit_status = metrics.exit_status || "Error";
333
381
  const paths = writeMetrics({ runDir: alloc.dir, root: alloc.root, metrics });
334
382
 
335
- const result = { ...metrics, error, slug: alloc.slug, runId: alloc.runId, dir: alloc.dir, paths };
383
+ const result = { ...metrics, error, slug: alloc.slug, runId: alloc.runId, dir: alloc.dir, sessionId, cell, paths };
336
384
  onDone(result);
337
385
  return result;
338
386
  }
package/src/cli.js CHANGED
@@ -104,6 +104,8 @@ import {
104
104
  benchRoot,
105
105
  } from "./bench.js";
106
106
  import { refreshReport } from "./report.js";
107
+ import { runProjectBench } from "./project-bench.js";
108
+ import { loadLedger } from "./ledger.js";
107
109
 
108
110
  /** Map a reasoning level to gateway modelKwargs. Empty for off/none/unset. */
109
111
  function reasoningKwargs(level) {
@@ -187,7 +189,7 @@ async function main() {
187
189
 
188
190
  // Load a session to resume (if any) to infer defaults.
189
191
  let resumeDoc = null;
190
- if (args.resume) {
192
+ if (args.resume && !args.project) {
191
193
  if (!sessionExists(args.resume)) {
192
194
  console.error(`\x1b[31m[astra] no such session: ${args.resume}\x1b[0m`);
193
195
  process.exit(2);
@@ -263,6 +265,51 @@ async function main() {
263
265
  }
264
266
  }
265
267
 
268
+ if (project) {
269
+ try {
270
+ let resumeCell = null;
271
+ if (args.resume) {
272
+ const ledgerPath = path.join(project.outputRoot, "benchmark.json");
273
+ if (fs.existsSync(ledgerPath)) {
274
+ const ledger = loadLedger(ledgerPath);
275
+ resumeCell = Object.values(ledger.cells).find((cell) => cell.sessionId === args.resume) || null;
276
+ }
277
+ if (!resumeCell) {
278
+ throw new Error(`no project benchmark cell found for resume session ${args.resume}`);
279
+ }
280
+ }
281
+ const effectiveProject = {
282
+ ...project,
283
+ outputRoot: args._provided.has("bench-root") ? path.resolve(args["bench-root"]) : project.outputRoot,
284
+ bench: {
285
+ ...project.bench,
286
+ models: args._provided.has("model") ? String(args.model).split(",").map((value) => value.trim()).filter(Boolean) : resumeCell ? [resumeCell.model] : project.bench.models,
287
+ reasoning: args._provided.has("reasoning") ? String(args.reasoning).split(",").map((value) => value.trim()).filter(Boolean) : resumeCell ? [resumeCell.reasoning] : project.bench.reasoning,
288
+ repeat: args._provided.has("repeat") ? Math.max(1, Number(args.repeat) || 1) : resumeCell ? resumeCell.repeatIndex : project.bench.repeat,
289
+ steps: args._provided.has("steps") ? Number(args.steps) : project.bench.steps,
290
+ wall: args._provided.has("wall") ? Number(args.wall) : project.bench.wall,
291
+ timeout: args._provided.has("timeout") ? Number(args.timeout) : project.bench.timeout,
292
+ },
293
+ };
294
+ const results = await runProjectBench({
295
+ project: effectiveProject,
296
+ apiKey,
297
+ baseUrl: args["base-url"],
298
+ resume: Boolean(args.resume),
299
+ resumeSessionId: args.resume || null,
300
+ quiet: !!args.quiet,
301
+ onCell: ({ phase, cell }) => {
302
+ if (!args.quiet) console.error(`\x1b[2m[astra] ${phase} · ${cell.displayKey} repeat=${cell.repeatIndex}\x1b[0m`);
303
+ },
304
+ });
305
+ console.error(`\x1b[1m[astra] project bench complete · ${results.length} cell(s) processed\x1b[0m`);
306
+ } catch (error) {
307
+ console.error(`\x1b[31m[astra] project bench failed: ${error.message}\x1b[0m`);
308
+ process.exit(1);
309
+ }
310
+ process.exit(0);
311
+ }
312
+
266
313
  // -------------------- MODEL + REASONING RESOLUTION --------------------
267
314
  // Bench (autonomous) mode: an explicit --model is required so runs are
268
315
  // reproducible and never silently pick a cached/default model.
package/src/ledger.js ADDED
@@ -0,0 +1,68 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ const LEDGER_VERSION = 1;
5
+ const STALE_AFTER_MS = 10 * 60 * 1000;
6
+
7
+ function slug(value) {
8
+ return String(value ?? "none").trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "none";
9
+ }
10
+
11
+ function atomicWrite(file, value) {
12
+ const temp = `${file}.tmp-${process.pid}`;
13
+ fs.mkdirSync(path.dirname(file), { recursive: true });
14
+ fs.writeFileSync(temp, JSON.stringify(value, null, 2) + "\n");
15
+ fs.renameSync(temp, file);
16
+ }
17
+
18
+ export function loadLedger(file) {
19
+ if (!fs.existsSync(file)) return { version: LEDGER_VERSION, cells: {}, path: file };
20
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
21
+ if (value.version !== LEDGER_VERSION || !value.cells || typeof value.cells !== "object") throw new Error("invalid benchmark ledger");
22
+ return { ...value, path: file };
23
+ }
24
+
25
+ function nextRunPath(ledger, cell) {
26
+ const prefix = `${slug(cell.displayKey)}/run-`;
27
+ const used = Object.values(ledger.cells).filter((item) => item.runPath?.startsWith(prefix));
28
+ const next = used.reduce((max, item) => Math.max(max, Number(item.runPath.slice(prefix.length)) || 0), 0) + 1;
29
+ return `${prefix}${String(next).padStart(2, "0")}`;
30
+ }
31
+
32
+ export function claimCell(ledger, cell) {
33
+ const existing = ledger.cells[cell.cellKey];
34
+ if (existing) return existing;
35
+ const record = {
36
+ ...cell,
37
+ status: "pending",
38
+ runPath: nextRunPath(ledger, cell),
39
+ createdAt: new Date().toISOString(),
40
+ leaseAt: null,
41
+ };
42
+ ledger.cells[cell.cellKey] = record;
43
+ atomicWrite(ledger.path, { version: LEDGER_VERSION, cells: ledger.cells });
44
+ return record;
45
+ }
46
+
47
+ export function transitionCell(ledger, cellKey, status, patch = {}) {
48
+ const cell = ledger.cells[cellKey];
49
+ if (!cell) throw new Error(`unknown benchmark cell: ${cellKey}`);
50
+ const next = { ...cell, ...patch, status, updatedAt: new Date().toISOString() };
51
+ if (["generating", "verifying"].includes(status) && patch.leaseAt === undefined) next.leaseAt = next.updatedAt;
52
+ ledger.cells[cellKey] = next;
53
+ atomicWrite(ledger.path, { version: LEDGER_VERSION, cells: ledger.cells });
54
+ return next;
55
+ }
56
+
57
+ export function selectWork(ledger, desiredCells, now = Date.now(), { force = false, sessionId = null } = {}) {
58
+ return desiredCells.filter((cell) => {
59
+ const existing = ledger.cells[cell.cellKey];
60
+ if (!existing) return true;
61
+ if (["completed", "failed"].includes(existing.status)) return false;
62
+ if (sessionId && existing.sessionId !== sessionId) return false;
63
+ if (["generating", "verifying"].includes(existing.status)) {
64
+ return force || !existing.leaseAt || now - Date.parse(existing.leaseAt) > STALE_AFTER_MS;
65
+ }
66
+ return !sessionId || existing.sessionId === sessionId;
67
+ });
68
+ }
package/src/model.js CHANGED
@@ -42,7 +42,7 @@ export class GatewayModel {
42
42
  * @param {number} [opts.maxRetries]
43
43
  * @param {(info:object)=>void} [opts.onRetry] called before each retry sleep
44
44
  */
45
- constructor({ model, baseUrl, apiKey, modelKwargs = {}, maxRetries = 5, maxTokens = 8192, onRetry } = {}) {
45
+ constructor({ model, baseUrl, apiKey, modelKwargs = {}, maxRetries = 5, maxTokens = 8192, requestTimeoutMs = 300000, onRetry } = {}) {
46
46
  if (!model) throw new Error("GatewayModel: `model` is required");
47
47
  this.model = model;
48
48
  this.maxTokens = maxTokens;
@@ -50,6 +50,7 @@ export class GatewayModel {
50
50
  this.apiKey = apiKey || process.env.ASTRA_GATEWAY_API_KEY || "";
51
51
  this.modelKwargs = modelKwargs;
52
52
  this.maxRetries = maxRetries;
53
+ this.requestTimeoutMs = requestTimeoutMs;
53
54
  this.onRetry = onRetry || (() => {});
54
55
  this.nCalls = 0;
55
56
  // Cumulative token usage across all calls (exact, from the API).
@@ -89,13 +90,15 @@ export class GatewayModel {
89
90
  const body = {
90
91
  model: this.model,
91
92
  messages: messages.map((m) => ({ role: m.role, content: m.content })),
92
- [this._tokenParam]: this.maxTokens,
93
93
  ...this.modelKwargs,
94
94
  };
95
+ if (this._tokenParam) body[this._tokenParam] = this.maxTokens;
95
96
 
96
97
  let lastErr;
97
98
  for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
98
99
  try {
100
+ const timeoutSignal = AbortSignal.timeout(this.requestTimeoutMs);
101
+ const signal = this.signal ? AbortSignal.any([this.signal, timeoutSignal]) : timeoutSignal;
99
102
  const res = await fetch(`${this.baseUrl}/chat/completions`, {
100
103
  method: "POST",
101
104
  headers: {
@@ -103,7 +106,7 @@ export class GatewayModel {
103
106
  Authorization: `Bearer ${this.apiKey}`,
104
107
  },
105
108
  body: JSON.stringify(body),
106
- signal: this.signal || undefined,
109
+ signal,
107
110
  });
108
111
 
109
112
  if (!res.ok) {
@@ -226,6 +229,13 @@ export class GatewayModel {
226
229
  this._tokenParam = "max_tokens";
227
230
  return true;
228
231
  }
232
+ // Some gateway routes reject either token-cap parameter. Retry once
233
+ // without one rather than making the model unavailable for benchmarking.
234
+ if (/max_tokens/.test(errText) && /not supported|unsupported/.test(errText) && "max_tokens" in body) {
235
+ delete body.max_tokens;
236
+ this._tokenParam = null;
237
+ return true;
238
+ }
229
239
  return false;
230
240
  }
231
241
  }
package/src/models.js CHANGED
@@ -17,6 +17,7 @@ export const AVAILABLE_MODELS = [
17
17
  "deepseek-v4-pro",
18
18
  "glm-5.2",
19
19
  "kimi-k3",
20
+ "minimax-m3",
20
21
  "qwen-3.8",
21
22
  ];
22
23
 
@@ -0,0 +1,154 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { matrixCells } from "./project.js";
5
+ import { runCell as defaultRunCell, writeMetrics } from "./bench.js";
6
+ import { candidateTreeHash, mergeRunResult } from "./result-contract.js";
7
+ import { loadLedger, claimCell, selectWork, transitionCell } from "./ledger.js";
8
+ import { runVerifier as defaultRunVerifier } from "./verifier-runner.js";
9
+ import { newSessionId } from "./session.js";
10
+ import { refreshReport } from "./report.js";
11
+
12
+ function writeJson(file, value) {
13
+ fs.mkdirSync(path.dirname(file), { recursive: true });
14
+ fs.writeFileSync(file, JSON.stringify(value, null, 2) + "\n");
15
+ }
16
+
17
+ export async function runProjectBench({ project, apiKey, baseUrl, quiet = false, resume = false, resumeSessionId = null, runCellFn = defaultRunCell, verifierFn = defaultRunVerifier, onCell = () => {} } = {}) {
18
+ const root = project.outputRoot;
19
+ const ledger = loadLedger(path.join(root, "benchmark.json"));
20
+ const desired = matrixCells({ project });
21
+ const work = selectWork(ledger, desired, Date.now(), { force: resume, sessionId: resumeSessionId });
22
+ const results = [];
23
+
24
+ for (const cell of work) {
25
+ const record = claimCell(ledger, cell);
26
+ const runDir = path.join(root, record.runPath);
27
+ const candidateDir = path.join(runDir, "workspace");
28
+ const reuseFrozen = ["candidate-frozen", "verifying"].includes(record.status);
29
+ const isResume = Boolean(record.sessionId && ["generating", "interrupted"].includes(record.status));
30
+ const sessionId = record.sessionId || newSessionId();
31
+
32
+ let generation;
33
+ if (reuseFrozen) {
34
+ const generationPath = path.join(runDir, "generation.json");
35
+ try {
36
+ generation = JSON.parse(fs.readFileSync(generationPath, "utf8"));
37
+ if (!fs.existsSync(candidateDir)) throw new Error("frozen candidate workspace is missing");
38
+ const actualHash = candidateTreeHash(candidateDir);
39
+ if (record.candidateSha256 && actualHash !== record.candidateSha256) {
40
+ throw new Error("frozen candidate workspace hash does not match the ledger");
41
+ }
42
+ } catch (error) {
43
+ const failure = { status: "error", failureOwner: "infrastructure", error: String(error?.message || error) };
44
+ transitionCell(ledger, cell.cellKey, "failed", { failureOwner: "infrastructure", error: failure.error });
45
+ results.push({ cell, generation: { status: "error" }, verification: failure });
46
+ continue;
47
+ }
48
+ onCell({ phase: "candidate-frozen", cell, record });
49
+ } else {
50
+ transitionCell(ledger, cell.cellKey, "generating", { sessionId, leaseAt: new Date().toISOString() });
51
+ onCell({ phase: "generating", cell, record });
52
+ try {
53
+ generation = await runCellFn({
54
+ model: cell.model,
55
+ reasoning: cell.reasoning,
56
+ apiKey,
57
+ baseUrl,
58
+ task: project.taskText,
59
+ taskPath: project.workspace,
60
+ projectMetadata: project.metadata,
61
+ root,
62
+ runDir,
63
+ sessionId,
64
+ resume: isResume,
65
+ cell,
66
+ steps: project.bench.steps,
67
+ wall: project.bench.wall,
68
+ timeout: project.bench.timeout,
69
+ });
70
+ } catch (error) {
71
+ const failure = { status: "error", failureOwner: "infrastructure", error: String(error?.message || error) };
72
+ transitionCell(ledger, cell.cellKey, "failed", { failureOwner: "infrastructure", error: failure.error });
73
+ results.push({ cell, generation: { status: "error" }, verification: failure });
74
+ continue;
75
+ }
76
+
77
+ const candidateSha256 = candidateTreeHash(candidateDir);
78
+ writeJson(path.join(runDir, "generation.json"), generation);
79
+ transitionCell(ledger, cell.cellKey, "candidate-frozen", { candidateSha256, sessionId });
80
+ }
81
+
82
+ const candidateSha256 = record.candidateSha256 || candidateTreeHash(candidateDir);
83
+
84
+ let verification = {
85
+ status: "not_configured",
86
+ failureOwner: null,
87
+ score: null,
88
+ solvedScore: "NA",
89
+ reason: "task verifier is not present or not configured",
90
+ };
91
+ if (project.verification && generation.status === "completed") {
92
+ transitionCell(ledger, cell.cellKey, "verifying", { candidateSha256 });
93
+ try {
94
+ verification = await verifierFn({
95
+ project,
96
+ cell,
97
+ runDir,
98
+ candidateDir,
99
+ config: project.verification,
100
+ expected: { taskId: project.id, taskVersion: project.version, candidateSha256 },
101
+ });
102
+ } catch (error) {
103
+ verification = {
104
+ status: "error",
105
+ failureOwner: "infrastructure",
106
+ score: null,
107
+ solvedScore: "NA",
108
+ error: String(error?.message || error),
109
+ };
110
+ }
111
+ if (verification.score && typeof verification.score.percentage === "number") {
112
+ verification.solvedScore = verification.score.percentage;
113
+ }
114
+ writeJson(path.join(runDir, "verifier.json"), verification);
115
+ } else if (generation.status !== "completed") {
116
+ verification = {
117
+ status: "not_run",
118
+ failureOwner: "candidate",
119
+ score: null,
120
+ solvedScore: "NA",
121
+ reason: "generation did not submit a candidate; verification was skipped",
122
+ };
123
+ }
124
+
125
+ const merged = mergeRunResult({
126
+ task: { id: project.id, version: project.version, type: project.type || "brownfield" },
127
+ cell,
128
+ generation: { ...generation, candidateSha256 },
129
+ verification,
130
+ artifacts: { runPath: record.runPath, candidateSha256 },
131
+ });
132
+ writeMetrics({
133
+ runDir,
134
+ root,
135
+ metrics: {
136
+ ...generation,
137
+ model: cell.model,
138
+ reasoning: cell.reasoning,
139
+ verification_status: verification.status,
140
+ verification_score: verification.score?.percentage ?? "",
141
+ solved_score: verification.solvedScore ?? "NA",
142
+ verification_hard_pass: verification.score?.hardFailPassed ?? "",
143
+ failure_owner: verification.failureOwner ?? "",
144
+ },
145
+ });
146
+ writeJson(path.join(runDir, "result.json"), merged);
147
+ transitionCell(ledger, cell.cellKey, "completed", { candidateSha256, resultPath: path.join(record.runPath, "result.json"), failureOwner: verification.failureOwner });
148
+ results.push(merged);
149
+ try { refreshReport(root); } catch { /* report regeneration is best-effort per cell */ }
150
+ onCell({ phase: "completed", cell, record, result: merged });
151
+ }
152
+ try { refreshReport(root); } catch { /* report regeneration is best-effort */ }
153
+ return results;
154
+ }