@hackerrank/astra-cli 0.1.5 → 0.1.7

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
@@ -204,6 +225,17 @@ astra --resume <id> # resume (continue an interactive chat,
204
225
  # or inspect/continue an autonomous run)
205
226
  ```
206
227
 
228
+ Resume an interrupted benchmark without repeating its task, model, or output
229
+ arguments:
230
+
231
+ ```bash
232
+ astra bench --resume <session-id>
233
+ ```
234
+
235
+ It reopens the original candidate workspace and updates that same run's
236
+ trajectory, metrics row, and report telemetry. Explicit flags override the
237
+ saved setting when needed.
238
+
207
239
  Verified working models on the gateway include `claude-sonnet-5`, `claude-opus-5`,
208
240
  `gpt-5.6-sol`, `gpt-5.6-terra`, `gemini-3.7-flash`, `grok-4.6`, `kimi-k3`, and others.
209
241
  Some routes (e.g. `glm-5.2`) may return HTTP 402 when the underlying provider is out of
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hackerrank/astra-cli",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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
@@ -64,6 +64,7 @@ export class Agent {
64
64
  this.sessionId = opts.sessionId ?? null;
65
65
  this.sessionCreated = opts.sessionCreated ?? new Date().toISOString();
66
66
  this.title = opts.title ?? "";
67
+ this.bench = opts.bench ?? null;
67
68
  this._saveSession = opts.saveSession ?? null; // (id, doc) => void
68
69
  }
69
70
 
@@ -149,7 +150,7 @@ export class Agent {
149
150
  // --- 2. Parse a command (may be absent) ---
150
151
  let command;
151
152
  try {
152
- command = parseCommand(content);
153
+ command = parseCommand(content, { allowMultiple: this.mode === "autonomous" });
153
154
  this.formatErrorStreak = 0;
154
155
  } catch (err) {
155
156
  // In interactive mode, "no command" is a normal chat reply that yields
@@ -240,6 +241,7 @@ export class Agent {
240
241
  id: this.sessionId ?? undefined,
241
242
  created: this.sessionCreated,
242
243
  title: this.title || undefined,
244
+ bench: this.bench ?? undefined,
243
245
  info: {
244
246
  mode: this.mode,
245
247
  task: this.task || undefined,
@@ -248,10 +250,20 @@ export class Agent {
248
250
  n_steps: this.nSteps,
249
251
  model: this.model.model,
250
252
  n_calls: this.model.nCalls,
253
+ n_retries: this.model.nRetries,
254
+ counters: {
255
+ commands: this.nCommands,
256
+ failed_commands: this.nFailedCommands,
257
+ format_errors: this.nFormatErrors,
258
+ declined: this.nDeclined,
259
+ },
251
260
  elapsed_seconds: Math.round((Date.now() - this.startTime) / 1000),
252
261
  tokens: {
253
262
  prompt: this.model.totalPromptTokens,
254
263
  completion: this.model.totalCompletionTokens,
264
+ cached: this.model.totalCachedTokens,
265
+ cache_write: this.model.totalCacheWriteTokens,
266
+ reasoning: this.model.totalReasoningTokens,
255
267
  total: this.model.totalPromptTokens + this.model.totalCompletionTokens,
256
268
  last_context: this.lastContextTokens,
257
269
  },
@@ -282,8 +294,25 @@ export class Agent {
282
294
  this.task = data?.info?.task ?? this.task;
283
295
  this.mode = data?.info?.mode === "interactive" ? "interactive" : this.mode;
284
296
  this.nSteps = data?.info?.n_steps ?? this.messages.filter((m) => m.role === "assistant").length;
297
+ this.nCommands = data?.info?.counters?.commands ?? 0;
298
+ this.nFailedCommands = data?.info?.counters?.failed_commands ?? 0;
299
+ this.nFormatErrors = data?.info?.counters?.format_errors ?? 0;
300
+ this.nDeclined = data?.info?.counters?.declined ?? 0;
285
301
  this.lastContextTokens = data?.info?.tokens?.last_context ?? 0;
302
+ this.model.nCalls = data?.info?.n_calls ?? this.model.nCalls;
303
+ this.model.nRetries = data?.info?.n_retries ?? this.model.nRetries;
304
+ this.model.totalPromptTokens = data?.info?.tokens?.prompt ?? this.model.totalPromptTokens;
305
+ this.model.totalCompletionTokens = data?.info?.tokens?.completion ?? this.model.totalCompletionTokens;
306
+ this.model.totalCachedTokens = data?.info?.tokens?.cached ?? this.model.totalCachedTokens;
307
+ this.model.totalCacheWriteTokens = data?.info?.tokens?.cache_write ?? this.model.totalCacheWriteTokens;
308
+ this.model.totalReasoningTokens = data?.info?.tokens?.reasoning ?? this.model.totalReasoningTokens;
309
+ this.model.totalCostUsd = data?.info?.cost?.usd ?? this.model.totalCostUsd;
310
+ this.model.reportedCostUsd = data?.info?.cost?.reported_usd ?? this.model.reportedCostUsd;
311
+ this.model.estimatedCostUsd = data?.info?.cost?.estimated_usd ?? this.model.estimatedCostUsd;
312
+ this.model.costSource = data?.info?.cost?.source ?? this.model.costSource;
313
+ this.startTime = Date.now() - Number(data?.info?.elapsed_seconds || 0) * 1000;
286
314
  this.exitStatus = data?.info?.exit_status || null;
315
+ this.bench = data?.bench ?? this.bench;
287
316
  if (data?.id) this.sessionId = data.id;
288
317
  if (data?.created) this.sessionCreated = data.created;
289
318
  if (data?.title) this.title = data.title;
@@ -291,9 +320,11 @@ export class Agent {
291
320
  }
292
321
 
293
322
  /** Extract exactly one command from a fenced bash block. */
294
- export function parseCommand(text) {
295
- // Accept ```bash, ```sh, or a bare ``` block.
296
- 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;
297
328
  const blocks = [];
298
329
  let m;
299
330
  while ((m = re.exec(text)) !== null) blocks.push(m[1].trim());
@@ -303,12 +334,12 @@ export function parseCommand(text) {
303
334
  err.code = "NO_COMMAND";
304
335
  throw err;
305
336
  }
306
- if (blocks.length > 1) {
337
+ if (blocks.length > 1 && !allowMultiple) {
307
338
  const err = new Error(`Found ${blocks.length} code blocks; provide exactly one.`);
308
339
  err.code = "MULTI_COMMAND";
309
340
  throw err;
310
341
  }
311
- const cmd = blocks[0].trim();
342
+ const cmd = blocks.join("\n").trim();
312
343
  if (!cmd) {
313
344
  const err = new Error("The bash code block was empty.");
314
345
  err.code = "EMPTY_COMMAND";
package/src/bench.js CHANGED
@@ -23,9 +23,11 @@ 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 = [
30
+ "run_path",
29
31
  "timestamp",
30
32
  "model",
31
33
  "reasoning",
@@ -48,6 +50,11 @@ export const METRIC_COLUMNS = [
48
50
  "last_context_tokens",
49
51
  "cost_usd",
50
52
  "cost_source",
53
+ "verification_status",
54
+ "verification_score",
55
+ "solved_score",
56
+ "verification_hard_pass",
57
+ "failure_owner",
51
58
  ];
52
59
 
53
60
  /**
@@ -211,8 +218,9 @@ export function metricsHeader() {
211
218
  * the top-level rolled-up index at <root>/metrics.csv.
212
219
  */
213
220
  export function writeMetrics({ runDir, root, metrics }) {
221
+ const rowMetrics = { ...metrics, run_path: path.relative(benchRoot(root), runDir) };
214
222
  const header = metricsHeader();
215
- const row = metricsRow(metrics);
223
+ const row = metricsRow(rowMetrics);
216
224
 
217
225
  // Per-run file.
218
226
  fs.writeFileSync(path.join(runDir, "metrics.csv"), header + "\n" + row + "\n");
@@ -222,7 +230,14 @@ export function writeMetrics({ runDir, root, metrics }) {
222
230
  if (!fs.existsSync(indexPath)) {
223
231
  fs.writeFileSync(indexPath, header + "\n" + row + "\n");
224
232
  } else {
225
- fs.appendFileSync(indexPath, row + "\n");
233
+ const lines = fs.readFileSync(indexPath, "utf8").trimEnd().split("\n");
234
+ const runPathIndex = lines[0].split(",").indexOf("run_path");
235
+ const existing = runPathIndex < 0
236
+ ? -1
237
+ : lines.findIndex((line, index) => index > 0 && line.split(",")[runPathIndex] === rowMetrics.run_path);
238
+ if (existing >= 0) lines[existing] = row;
239
+ else lines.push(row);
240
+ fs.writeFileSync(indexPath, lines.join("\n") + "\n");
226
241
  }
227
242
  return { runMetrics: path.join(runDir, "metrics.csv"), index: indexPath };
228
243
  }
@@ -286,15 +301,33 @@ export async function runCell({
286
301
  wall = 0,
287
302
  timeout = 60,
288
303
  maxOutputChars = 16000,
304
+ runDir: existingRunDir = null,
305
+ sessionId: existingSessionId = null,
306
+ resume = false,
307
+ cell = null,
289
308
  onStart = () => {},
290
309
  onDone = () => {},
291
310
  } = {}) {
292
- 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 });
293
321
  onStart({ ...alloc, model, reasoning });
294
322
 
295
- const seeded = seedWorkspace(alloc.workspace, { taskPath, taskFile, taskText: task });
296
- const taskText = seeded.taskText || task || "";
297
- 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
+ }
298
331
  writeProjectMetadata(alloc.dir, projectMetadata);
299
332
 
300
333
  const gw = new GatewayModel({
@@ -304,16 +337,40 @@ export async function runCell({
304
337
  modelKwargs: reasoningKwargs(reasoning),
305
338
  });
306
339
  const env = new LocalEnvironment({ cwd: alloc.workspace, timeout, maxOutputChars });
340
+ const sessionId = existingSessionId || newSessionId();
307
341
  const agent = new Agent(gw, env, {
308
342
  mode: "autonomous",
309
343
  stepLimit: steps,
310
344
  wallTimeLimitSeconds: wall,
311
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: [] },
312
350
  });
313
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
+
314
360
  let error = null;
315
361
  try {
316
- 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
+ }
317
374
  } catch (err) {
318
375
  error = String(err?.message || err);
319
376
  if (!agent.exitStatus) agent.exit("Error", "");
@@ -323,7 +380,7 @@ export async function runCell({
323
380
  if (error) metrics.exit_status = metrics.exit_status || "Error";
324
381
  const paths = writeMetrics({ runDir: alloc.dir, root: alloc.root, metrics });
325
382
 
326
- 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 };
327
384
  onDone(result);
328
385
  return result;
329
386
  }
package/src/cli.js CHANGED
@@ -17,6 +17,7 @@
17
17
  * astra -m <model> -f path/to/instruction.md
18
18
  * astra -m <model> -p tasks/dummy-slugify bench run from a task directory
19
19
  * astra bench --project ./task project-configured bench run
20
+ * astra bench --resume <session-id> continue the same bench attempt
20
21
  * astra --resume <session-id> resume a saved session
21
22
  * astra --sessions list saved sessions
22
23
  *
@@ -103,6 +104,8 @@ import {
103
104
  benchRoot,
104
105
  } from "./bench.js";
105
106
  import { refreshReport } from "./report.js";
107
+ import { runProjectBench } from "./project-bench.js";
108
+ import { loadLedger } from "./ledger.js";
106
109
 
107
110
  /** Map a reasoning level to gateway modelKwargs. Empty for off/none/unset. */
108
111
  function reasoningKwargs(level) {
@@ -186,7 +189,7 @@ async function main() {
186
189
 
187
190
  // Load a session to resume (if any) to infer defaults.
188
191
  let resumeDoc = null;
189
- if (args.resume) {
192
+ if (args.resume && !args.project) {
190
193
  if (!sessionExists(args.resume)) {
191
194
  console.error(`\x1b[31m[astra] no such session: ${args.resume}\x1b[0m`);
192
195
  process.exit(2);
@@ -198,6 +201,19 @@ async function main() {
198
201
  // Bench mode uses the autonomous engine; agent mode uses the interactive one.
199
202
  let task = args.task;
200
203
  if (args["task-file"]) task = fs.readFileSync(args["task-file"], "utf8");
204
+ let resumeBench = resumeDoc?.bench ?? null;
205
+ if (args.bench && args.resume && !resumeBench) {
206
+ console.error("\x1b[31m[astra] request_error: this session is not a bench run.\x1b[0m");
207
+ process.exit(2);
208
+ }
209
+ if (resumeBench) {
210
+ if (!args._provided.has("task") && !args._provided.has("task-file")) task = resumeBench.task;
211
+ if (!args._provided.has("path")) args.path = resumeBench.taskPath;
212
+ if (!args._provided.has("bench-root")) args["bench-root"] = resumeBench.root;
213
+ if (!args._provided.has("steps")) args.steps = resumeBench.steps;
214
+ if (!args._provided.has("wall")) args.wall = resumeBench.wall;
215
+ if (!args._provided.has("timeout")) args.timeout = resumeBench.timeout;
216
+ }
201
217
  let project = null;
202
218
  if (args.project) {
203
219
  try {
@@ -249,6 +265,51 @@ async function main() {
249
265
  }
250
266
  }
251
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
+
252
313
  // -------------------- MODEL + REASONING RESOLUTION --------------------
253
314
  // Bench (autonomous) mode: an explicit --model is required so runs are
254
315
  // reproducible and never silently pick a cached/default model.
@@ -261,7 +322,7 @@ async function main() {
261
322
  // 5. hard fallback: glm-5.2 with medium reasoning
262
323
  // The chosen values are cached so the next `astra` needs no flags.
263
324
  let modelId = args.model || resumeDoc?.info?.model;
264
- let reasoning = args.reasoning || "";
325
+ let reasoning = args.reasoning || resumeBench?.reasoning || "";
265
326
 
266
327
  if (mode === "autonomous") {
267
328
  if (project && !modelId) modelId = project.bench.models.join(",");
@@ -330,7 +391,21 @@ async function main() {
330
391
  // workspace under bench/<model-name-reasoning>/run-NN/. The agent's commands
331
392
  // run inside that workspace and metrics are recorded when it finishes.
332
393
  let benchRun = null;
333
- if (mode === "autonomous" && !resumeDoc) {
394
+ if (mode === "autonomous" && resumeBench) {
395
+ if (!fs.existsSync(resumeBench.dir) || !fs.existsSync(resumeBench.workspace)) {
396
+ console.error("\x1b[31m[astra] request_error: saved bench workspace is missing.\x1b[0m");
397
+ process.exit(2);
398
+ }
399
+ benchRun = {
400
+ dir: resumeBench.dir,
401
+ workspace: resumeBench.workspace,
402
+ root: resumeBench.root,
403
+ slug: path.basename(path.dirname(resumeBench.dir)),
404
+ runId: path.basename(resumeBench.dir),
405
+ };
406
+ resumeBench.segments = Array.isArray(resumeBench.segments) ? resumeBench.segments : [];
407
+ resumeBench.segments.push({ started_at: new Date().toISOString(), resumed: true });
408
+ } else if (mode === "autonomous" && !resumeDoc) {
334
409
  benchRun = allocateRun({ model: modelId, reasoning, root: args["bench-root"] });
335
410
  const seeded = seedWorkspace(benchRun.workspace, {
336
411
  taskPath: args.path,
@@ -341,9 +416,22 @@ async function main() {
341
416
  // Persist the task text alongside the run for reproducibility.
342
417
  fs.writeFileSync(path.join(benchRun.dir, "task.md"), task || "");
343
418
  writeProjectMetadata(benchRun.dir, project?.metadata);
419
+ resumeBench = {
420
+ dir: benchRun.dir,
421
+ workspace: benchRun.workspace,
422
+ root: benchRun.root,
423
+ task,
424
+ taskPath: args.path,
425
+ model: modelId,
426
+ reasoning,
427
+ steps: Number(args.steps),
428
+ wall: Number(args.wall),
429
+ timeout: Number(args.timeout),
430
+ segments: [{ started_at: new Date().toISOString(), resumed: false }],
431
+ };
344
432
  }
345
433
 
346
- const cmdCwd = benchRun ? benchRun.workspace : cwd;
434
+ const cmdCwd = benchRun?.workspace || cwd;
347
435
  const env = new LocalEnvironment({
348
436
  cwd: cmdCwd,
349
437
  timeout: Number(args.timeout),
@@ -362,12 +450,23 @@ async function main() {
362
450
  : null,
363
451
  sessionId,
364
452
  saveSession,
453
+ bench: resumeBench,
365
454
  onEvent: quiet || mode === "interactive" ? () => {} : (msg) => printEvent(msg),
366
455
  onStep: quiet || mode === "interactive" ? () => {} : (s) => printStep(s),
367
456
  });
368
457
 
369
458
  if (resumeDoc) {
370
459
  agent.restore(resumeDoc);
460
+ if (resumeBench) {
461
+ agent.exitStatus = null;
462
+ if (!args._provided.has("steps") && Number(resumeBench.steps) > 0) {
463
+ agent.stepLimit = agent.nSteps + Number(resumeBench.steps);
464
+ }
465
+ if (!args._provided.has("wall") && Number(resumeBench.wall) > 0) {
466
+ agent.wallTimeLimitSeconds =
467
+ Math.ceil((Date.now() - agent.startTime) / 1000) + Number(resumeBench.wall);
468
+ }
469
+ }
371
470
  console.error(`\x1b[2m[astra] resumed ${sessionId} (${agent.messages.length} messages)\x1b[0m`);
372
471
  }
373
472
 
@@ -423,6 +522,7 @@ async function main() {
423
522
  `${Number(args.steps) > 0 ? ` turns<=${args.steps}` : ""}\x1b[0m`
424
523
  );
425
524
  console.error(`\x1b[2m[astra] workspace -> ${benchRun.workspace}\x1b[0m`);
525
+ console.error(`\x1b[2m[astra] session -> ${sessionId}\x1b[0m`);
426
526
  } else {
427
527
  console.error(
428
528
  `\x1b[2m[astra] bench · model=${modelId} cwd=${cmdCwd}` +
@@ -431,12 +531,38 @@ async function main() {
431
531
  }
432
532
  }
433
533
 
434
- const result =
435
- resumeDoc && !task ? await continueAutonomous(agent) : await agent.run(task);
534
+ let result;
535
+ try {
536
+ result = resumeDoc ? await continueAutonomous(agent) : await agent.run(task);
537
+ } catch (error) {
538
+ agent.exitStatus = "Error";
539
+ const segment = agent.bench?.segments?.at(-1);
540
+ if (segment) {
541
+ segment.finished_at = new Date().toISOString();
542
+ segment.exit_status = "Error";
543
+ segment.error = String(error?.message || error);
544
+ }
545
+ agent.save();
546
+ if (benchRun) {
547
+ writeMetrics({
548
+ runDir: benchRun.dir,
549
+ root: benchRun.root,
550
+ metrics: collectMetrics({ agent, model, reasoning }),
551
+ });
552
+ try { refreshReport(benchRun.root); } catch {}
553
+ }
554
+ throw error;
555
+ }
436
556
  agent.save();
437
557
 
438
558
  // Record benchmark metrics (per-run CSV + rolled-up index).
439
559
  if (benchRun) {
560
+ const segment = agent.bench?.segments?.at(-1);
561
+ if (segment) {
562
+ segment.finished_at = new Date().toISOString();
563
+ segment.exit_status = result.exit_status;
564
+ }
565
+ agent.save();
440
566
  const metrics = collectMetrics({ agent, model, reasoning });
441
567
  const paths = writeMetrics({ runDir: benchRun.dir, root: benchRun.root, metrics });
442
568
  if (!quiet) {
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
@@ -89,9 +89,9 @@ export class GatewayModel {
89
89
  const body = {
90
90
  model: this.model,
91
91
  messages: messages.map((m) => ({ role: m.role, content: m.content })),
92
- [this._tokenParam]: this.maxTokens,
93
92
  ...this.modelKwargs,
94
93
  };
94
+ if (this._tokenParam) body[this._tokenParam] = this.maxTokens;
95
95
 
96
96
  let lastErr;
97
97
  for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
@@ -226,6 +226,13 @@ export class GatewayModel {
226
226
  this._tokenParam = "max_tokens";
227
227
  return true;
228
228
  }
229
+ // Some gateway routes reject either token-cap parameter. Retry once
230
+ // without one rather than making the model unavailable for benchmarking.
231
+ if (/max_tokens/.test(errText) && /not supported|unsupported/.test(errText) && "max_tokens" in body) {
232
+ delete body.max_tokens;
233
+ this._tokenParam = null;
234
+ return true;
235
+ }
229
236
  return false;
230
237
  }
231
238
  }
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