@hackerrank/astra-cli 0.1.22 → 0.1.23

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hackerrank/astra-cli",
3
- "version": "0.1.22",
3
+ "version": "0.1.23",
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
@@ -200,7 +200,7 @@ export class Agent {
200
200
  if (output.returncode !== 0) this.nFailedCommands++;
201
201
 
202
202
  // --- 5. Submission sentinel ---
203
- const submitted = checkSubmitted(output);
203
+ const submitted = command.trim() === `echo ${SUBMIT_SENTINEL}` ? checkSubmitted(output) : null;
204
204
  if (submitted != null) {
205
205
  return this.exit("Submitted", submitted);
206
206
  }
package/src/bench.js CHANGED
@@ -18,6 +18,7 @@
18
18
  */
19
19
 
20
20
  import fs from "node:fs";
21
+ import os from "node:os";
21
22
  import path from "node:path";
22
23
  import { spawnSync } from "node:child_process";
23
24
  import { GatewayModel } from "./model.js";
@@ -146,7 +147,7 @@ export function seedWorkspace(workspace, { taskPath, taskFile, taskText } = {})
146
147
 
147
148
  /** Directory / file names never copied into a fresh workspace (kept clean so
148
149
  * result tarballs don't carry build cruft). */
149
- const COPY_SKIP = new Set([
150
+ export const COPY_SKIP = new Set([
150
151
  "bench",
151
152
  ".git",
152
153
  "node_modules",
@@ -157,7 +158,7 @@ const COPY_SKIP = new Set([
157
158
  ]);
158
159
 
159
160
  /** Recursively copy a directory (skips build cruft — see COPY_SKIP). */
160
- function copyDir(from, to) {
161
+ export function copyDir(from, to) {
161
162
  fs.mkdirSync(to, { recursive: true });
162
163
  for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
163
164
  if (COPY_SKIP.has(entry.name)) continue;
@@ -169,6 +170,16 @@ function copyDir(from, to) {
169
170
  }
170
171
 
171
172
  /** Build a metrics row object from a finished agent + run info. */
173
+ /** Freeze an isolated workspace into a destination directory. */
174
+ export function freezeWorkspace(from, to) {
175
+ fs.mkdirSync(to, { recursive: true });
176
+ for (const entry of fs.readdirSync(to, { withFileTypes: true })) {
177
+ if (COPY_SKIP.has(entry.name)) continue;
178
+ fs.rmSync(path.join(to, entry.name), { recursive: true, force: true });
179
+ }
180
+ copyDir(from, to);
181
+ }
182
+
172
183
  export function collectMetrics({ agent, model, reasoning }) {
173
184
  const info = agent.serialize().info;
174
185
  const status = generationStatus(info.exit_status);
@@ -292,6 +303,7 @@ export async function runCell({
292
303
  reasoning,
293
304
  apiKey,
294
305
  baseUrl,
306
+ modelClient = null,
295
307
  task,
296
308
  taskPath,
297
309
  taskFile,
@@ -320,69 +332,79 @@ export async function runCell({
320
332
  fs.mkdirSync(alloc.workspace, { recursive: true });
321
333
  onStart({ ...alloc, model, reasoning });
322
334
 
335
+ const isolatedWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "astra-run-workspace-"));
323
336
  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
- }
331
- writeProjectMetadata(alloc.dir, projectMetadata);
332
-
333
- const gw = new GatewayModel({
334
- model,
335
- baseUrl,
336
- apiKey,
337
- modelKwargs: reasoningKwargs(reasoning),
338
- });
339
- const env = new LocalEnvironment({ cwd: alloc.workspace, timeout, maxOutputChars });
340
- const sessionId = existingSessionId || newSessionId();
341
- const agent = new Agent(gw, env, {
342
- mode: "autonomous",
343
- stepLimit: steps,
344
- wallTimeLimitSeconds: wall,
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: [] },
350
- });
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
-
360
- let error = null;
361
337
  try {
338
+ if (!resume) {
339
+ const seeded = seedWorkspace(isolatedWorkspace, { taskPath, taskFile, taskText: task });
340
+ taskText = seeded.taskText || task || "";
341
+ fs.writeFileSync(path.join(alloc.dir, "task.md"), taskText);
342
+ } else {
343
+ if (fs.existsSync(alloc.workspace)) copyDir(alloc.workspace, isolatedWorkspace);
344
+ if (!taskText) {
345
+ taskText = fs.existsSync(path.join(alloc.dir, "task.md")) ? fs.readFileSync(path.join(alloc.dir, "task.md"), "utf8") : "";
346
+ }
347
+ }
348
+ writeProjectMetadata(alloc.dir, projectMetadata);
349
+
350
+ const gw = modelClient || new GatewayModel({
351
+ model,
352
+ baseUrl,
353
+ apiKey,
354
+ modelKwargs: reasoningKwargs(reasoning),
355
+ });
356
+ const env = new LocalEnvironment({ cwd: isolatedWorkspace, timeout, maxOutputChars });
357
+ const sessionId = existingSessionId || newSessionId();
358
+ const agent = new Agent(gw, env, {
359
+ mode: "autonomous",
360
+ stepLimit: steps,
361
+ wallTimeLimitSeconds: wall,
362
+ outputPath: path.join(alloc.dir, "trajectory.json"),
363
+ sessionId,
364
+ saveSession,
365
+ task: taskText,
366
+ bench: { dir: alloc.dir, workspace: alloc.workspace, root: alloc.root, task: taskText, taskPath, model, reasoning, steps, wall, timeout, segments: [] },
367
+ });
368
+
362
369
  if (resume && existingSessionId) {
363
- while (true) {
364
- if (agent.stepLimit > 0 && agent.nSteps >= agent.stepLimit) {
365
- agent.exit("LimitsExceeded", "");
366
- break;
370
+ const saved = loadSession(existingSessionId);
371
+ agent.restore(saved);
372
+ agent.exitStatus = null;
373
+ if (Number(steps) > 0) agent.stepLimit = agent.nSteps + Number(steps);
374
+ if (Number(wall) > 0) agent.wallTimeLimitSeconds = Math.ceil((Date.now() - agent.startTime) / 1000) + Number(wall);
375
+ }
376
+
377
+ let error = null;
378
+ try {
379
+ if (resume && existingSessionId) {
380
+ while (true) {
381
+ if (agent.stepLimit > 0 && agent.nSteps >= agent.stepLimit) {
382
+ agent.exit("LimitsExceeded", "");
383
+ break;
384
+ }
385
+ const turn = await agent.runTurn();
386
+ if (turn.kind === "exit") break;
367
387
  }
368
- const turn = await agent.runTurn();
369
- if (turn.kind === "exit") break;
388
+ } else {
389
+ await agent.run(taskText);
370
390
  }
371
- } else {
372
- await agent.run(taskText);
391
+ } catch (err) {
392
+ error = String(err?.message || err);
393
+ if (!agent.exitStatus) agent.exit("Error", "");
373
394
  }
374
- } catch (err) {
375
- error = String(err?.message || err);
376
- if (!agent.exitStatus) agent.exit("Error", "");
377
- }
378
395
 
379
- const metrics = collectMetrics({ agent, model: gw, reasoning });
380
- if (error) metrics.exit_status = metrics.exit_status || "Error";
381
- const paths = writeMetrics({ runDir: alloc.dir, root: alloc.root, metrics });
396
+ freezeWorkspace(isolatedWorkspace, alloc.workspace);
382
397
 
383
- const result = { ...metrics, error, slug: alloc.slug, runId: alloc.runId, dir: alloc.dir, sessionId, cell, paths };
384
- onDone(result);
385
- return result;
398
+ const metrics = collectMetrics({ agent, model: gw, reasoning });
399
+ if (error) metrics.exit_status = metrics.exit_status || "Error";
400
+ const paths = writeMetrics({ runDir: alloc.dir, root: alloc.root, metrics });
401
+
402
+ const result = { ...metrics, error, slug: alloc.slug, runId: alloc.runId, dir: alloc.dir, sessionId, cell, paths };
403
+ onDone(result);
404
+ return result;
405
+ } finally {
406
+ try { fs.rmSync(isolatedWorkspace, { recursive: true, force: true }); } catch {}
407
+ }
386
408
  }
387
409
 
388
410
  /**
package/src/cli.js CHANGED
@@ -74,6 +74,7 @@
74
74
  */
75
75
 
76
76
  import fs from "node:fs";
77
+ import os from "node:os";
77
78
  import path from "node:path";
78
79
  import { GatewayModel } from "./model.js";
79
80
  import { LocalEnvironment } from "./environment.js";
@@ -92,6 +93,8 @@ import { runRepl } from "./repl.js";
92
93
  import { ProjectConfigError, loadProject } from "./project.js";
93
94
  import {
94
95
  allocateRun,
96
+ copyDir,
97
+ freezeWorkspace,
95
98
  seedWorkspace,
96
99
  collectMetrics,
97
100
  writeMetrics,
@@ -202,7 +205,10 @@ async function main() {
202
205
  let task = args.task;
203
206
  if (args["task-file"]) task = fs.readFileSync(args["task-file"], "utf8");
204
207
  let resumeBench = resumeDoc?.bench ?? null;
205
- if (args.bench && args.resume && !resumeBench) {
208
+ // Project benches resume from their task ledger, not from an interactive
209
+ // session document. Applying this guard to both paths stranded a leased
210
+ // project cell before its first model reply.
211
+ if (args.bench && args.resume && !args.project && !resumeBench) {
206
212
  console.error("\x1b[31m[astra] request_error: this session is not a bench run.\x1b[0m");
207
213
  process.exit(2);
208
214
  }
@@ -391,6 +397,7 @@ async function main() {
391
397
  // workspace under bench/<model-name-reasoning>/run-NN/. The agent's commands
392
398
  // run inside that workspace and metrics are recorded when it finishes.
393
399
  let benchRun = null;
400
+ let isolatedWorkspace = null;
394
401
  if (mode === "autonomous" && resumeBench) {
395
402
  if (!fs.existsSync(resumeBench.dir) || !fs.existsSync(resumeBench.workspace)) {
396
403
  console.error("\x1b[31m[astra] request_error: saved bench workspace is missing.\x1b[0m");
@@ -403,11 +410,14 @@ async function main() {
403
410
  slug: path.basename(path.dirname(resumeBench.dir)),
404
411
  runId: path.basename(resumeBench.dir),
405
412
  };
413
+ isolatedWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "astra-run-workspace-"));
414
+ if (fs.existsSync(benchRun.workspace)) copyDir(benchRun.workspace, isolatedWorkspace);
406
415
  resumeBench.segments = Array.isArray(resumeBench.segments) ? resumeBench.segments : [];
407
416
  resumeBench.segments.push({ started_at: new Date().toISOString(), resumed: true });
408
417
  } else if (mode === "autonomous" && !resumeDoc) {
409
418
  benchRun = allocateRun({ model: modelId, reasoning, root: args["bench-root"] });
410
- const seeded = seedWorkspace(benchRun.workspace, {
419
+ isolatedWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "astra-run-workspace-"));
420
+ const seeded = seedWorkspace(isolatedWorkspace, {
411
421
  taskPath: args.path,
412
422
  taskFile: args["task-file"],
413
423
  taskText: task,
@@ -431,7 +441,7 @@ async function main() {
431
441
  };
432
442
  }
433
443
 
434
- const cmdCwd = benchRun?.workspace || cwd;
444
+ const cmdCwd = isolatedWorkspace || benchRun?.workspace || cwd;
435
445
  const env = new LocalEnvironment({
436
446
  cwd: cmdCwd,
437
447
  timeout: Number(args.timeout),
@@ -478,29 +488,35 @@ async function main() {
478
488
  // an isolated workspace under bench/<model-name-reasoning>/run-NN/.
479
489
  const runBench = async (taskText, log) => {
480
490
  const alloc = allocateRun({ model: modelId, reasoning, root: args["bench-root"] });
481
- seedWorkspace(alloc.workspace, { taskText });
482
- fs.writeFileSync(path.join(alloc.dir, "task.md"), taskText || "");
483
- log(`[astra] bench ${alloc.slug}/${alloc.runId} → ${alloc.workspace}`);
484
- const benchEnv = new LocalEnvironment({
485
- cwd: alloc.workspace,
486
- timeout: Number(args.timeout),
487
- maxOutputChars: Number(args["max-output"]),
488
- });
489
- const benchAgent = new Agent(model, benchEnv, {
490
- mode: "autonomous",
491
- stepLimit: Number(args.steps),
492
- wallTimeLimitSeconds: Number(args.wall),
493
- outputPath: path.join(alloc.dir, "trajectory.json"),
494
- });
495
- const res = await benchAgent.run(taskText);
496
- const metrics = collectMetrics({ agent: benchAgent, model, reasoning });
497
- const paths = writeMetrics({ runDir: alloc.dir, root: alloc.root, metrics });
498
- log(`[astra] bench done: exit=${res.exit_status} · metrics → ${paths.runMetrics}`);
491
+ const benchIsolated = fs.mkdtempSync(path.join(os.tmpdir(), "astra-run-workspace-"));
499
492
  try {
500
- const report = refreshReport(alloc.root);
501
- log(`[astra] report ${report.html}`);
502
- } catch (err) {
503
- log(`[astra] report generation skipped: ${err.message}`);
493
+ seedWorkspace(benchIsolated, { taskText });
494
+ fs.writeFileSync(path.join(alloc.dir, "task.md"), taskText || "");
495
+ log(`[astra] bench ${alloc.slug}/${alloc.runId} ${alloc.workspace}`);
496
+ const benchEnv = new LocalEnvironment({
497
+ cwd: benchIsolated,
498
+ timeout: Number(args.timeout),
499
+ maxOutputChars: Number(args["max-output"]),
500
+ });
501
+ const benchAgent = new Agent(model, benchEnv, {
502
+ mode: "autonomous",
503
+ stepLimit: Number(args.steps),
504
+ wallTimeLimitSeconds: Number(args.wall),
505
+ outputPath: path.join(alloc.dir, "trajectory.json"),
506
+ });
507
+ const res = await benchAgent.run(taskText);
508
+ freezeWorkspace(benchIsolated, alloc.workspace);
509
+ const metrics = collectMetrics({ agent: benchAgent, model, reasoning });
510
+ const paths = writeMetrics({ runDir: alloc.dir, root: alloc.root, metrics });
511
+ log(`[astra] bench done: exit=${res.exit_status} · metrics → ${paths.runMetrics}`);
512
+ try {
513
+ const report = refreshReport(alloc.root);
514
+ log(`[astra] report → ${report.html}`);
515
+ } catch (err) {
516
+ log(`[astra] report generation skipped: ${err.message}`);
517
+ }
518
+ } finally {
519
+ try { fs.rmSync(benchIsolated, { recursive: true, force: true }); } catch {}
504
520
  }
505
521
  };
506
522
  await runRepl(agent, { model, autoRun: !!args.yolo, fresh: !resumeDoc, reasoning, runBench });
@@ -544,6 +560,7 @@ async function main() {
544
560
  }
545
561
  agent.save();
546
562
  if (benchRun) {
563
+ if (isolatedWorkspace) freezeWorkspace(isolatedWorkspace, benchRun.workspace);
547
564
  writeMetrics({
548
565
  runDir: benchRun.dir,
549
566
  root: benchRun.root,
@@ -552,6 +569,11 @@ async function main() {
552
569
  try { refreshReport(benchRun.root); } catch {}
553
570
  }
554
571
  throw error;
572
+ } finally {
573
+ if (benchRun && isolatedWorkspace) {
574
+ freezeWorkspace(isolatedWorkspace, benchRun.workspace);
575
+ try { fs.rmSync(isolatedWorkspace, { recursive: true, force: true }); } catch {}
576
+ }
555
577
  }
556
578
  agent.save();
557
579
 
@@ -17,11 +17,12 @@ export class LocalEnvironment {
17
17
  * @param {object} [opts.env] extra env vars merged over process.env
18
18
  * @param {number} [opts.maxOutputChars] cap on observation size (default 16000)
19
19
  */
20
- constructor({ cwd = process.cwd(), timeout = 60, env = {}, maxOutputChars = 16000 } = {}) {
20
+ constructor({ cwd = process.cwd(), timeout = 60, env = {}, maxOutputChars = 16000, inheritEnv = true } = {}) {
21
21
  this.cwd = cwd;
22
22
  this.timeout = timeout;
23
23
  this.env = env;
24
24
  this.maxOutputChars = maxOutputChars;
25
+ this.inheritEnv = inheritEnv;
25
26
  }
26
27
 
27
28
  /**
@@ -33,7 +34,10 @@ export class LocalEnvironment {
33
34
  return new Promise((resolve) => {
34
35
  const child = spawn("bash", ["-c", command], {
35
36
  cwd: this.cwd,
36
- env: { ...process.env, ...this.env },
37
+ // Evaluators must never leak their gateway credential into a command
38
+ // that reads an untrusted candidate workspace. Normal agent sessions
39
+ // retain the historical inherited environment.
40
+ env: this.inheritEnv ? { ...process.env, ...this.env } : { ...this.env },
37
41
  // New process group so we can kill the whole tree on timeout.
38
42
  detached: process.platform !== "win32",
39
43
  });
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import { runEvaluator } from "./evaluator.js";
4
+
5
+ function options(argv) {
6
+ const result = {};
7
+ for (let index = 2; index < argv.length; index += 2) {
8
+ const key = argv[index];
9
+ if (!key?.startsWith("--") || argv[index + 1] == null) throw new Error(`invalid evaluator option: ${key || ""}`);
10
+ result[key.slice(2)] = argv[index + 1];
11
+ }
12
+ for (const key of ["prompt-file", "workspace", "output", "model"]) if (!result[key]) throw new Error(`--${key} is required`);
13
+ return result;
14
+ }
15
+
16
+ try {
17
+ const args = options(process.argv);
18
+ const prompt = fs.readFileSync(args["prompt-file"], "utf8");
19
+ const result = await runEvaluator({ prompt, workspace: args.workspace, outputPath: args.output, model: args.model,
20
+ reasoning: args.reasoning || "high", apiKey: process.env.ASTRA_GATEWAY_API_KEY,
21
+ baseUrl: process.env.ASTRA_GATEWAY_BASE_URL, timeout: args.timeout || 0, steps: args.steps || 0, wall: args.wall || 0 });
22
+ process.stdout.write(JSON.stringify(result.metrics) + "\n");
23
+ } catch (error) {
24
+ process.stderr.write(`ASTRA evaluator error: ${error.message}\n`);
25
+ process.exitCode = 2;
26
+ }
@@ -0,0 +1,49 @@
1
+ /** ASTRA-owned agentic evaluator for task verifiers. */
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { GatewayModel } from "./model.js";
5
+ import { LocalEnvironment } from "./environment.js";
6
+ import { Agent } from "./agent.js";
7
+
8
+ const SAFE_COMMAND_ENV = new Set([
9
+ "PATH", "HOME", "TMPDIR", "LANG", "LANGUAGE", "LC_ALL", "TZ",
10
+ "ADMIN_USERNAME", "ADMIN_PASSWORD", "DATABASE_URL", "MONGODB_URI", "REDIS_URL",
11
+ "PLAYWRIGHT_BROWSERS_PATH", "PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD",
12
+ ]);
13
+
14
+ export function evaluatorCommandEnvironment(source = process.env) {
15
+ return Object.fromEntries(Object.entries(source).filter(([key]) =>
16
+ SAFE_COMMAND_ENV.has(key) || key.startsWith("LC_"),
17
+ ));
18
+ }
19
+
20
+ export function evaluatorTask({ prompt, outputPath }) {
21
+ return `You are ASTRA's trusted verifier evaluator. Inspect the application and its source to answer the evaluation request below. Treat the candidate workspace as read-only: do not edit, delete, install into, or format files there. You may read files and make the explicitly requested HTTP/database probes.\n\nReturn exactly one JSON object that satisfies the requested response shape. Write only that JSON object to ${outputPath} (outside the candidate workspace), then run: printf 'COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT'. Keep commands small.\n\nEvaluation request:\n${prompt}`;
22
+ }
23
+
24
+ export async function runEvaluator({ prompt, workspace, outputPath, model, reasoning, apiKey, baseUrl, timeout = 0, steps = 0, wall = 0 }) {
25
+ const destination = path.resolve(outputPath);
26
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
27
+ try { fs.rmSync(destination, { force: true }); } catch {}
28
+ const gateway = new GatewayModel({
29
+ model,
30
+ apiKey,
31
+ baseUrl,
32
+ modelKwargs: reasoning ? { reasoning_effort: reasoning } : {},
33
+ });
34
+ const env = new LocalEnvironment({
35
+ cwd: path.resolve(workspace), timeout: Number(timeout), maxOutputChars: 12000,
36
+ env: evaluatorCommandEnvironment(), inheritEnv: false,
37
+ });
38
+ const agent = new Agent(gateway, env, {
39
+ mode: "autonomous", stepLimit: Number(steps), wallTimeLimitSeconds: Number(wall),
40
+ });
41
+ const result = await agent.run(evaluatorTask({ prompt, outputPath: destination }));
42
+ if (result.exit_status !== "Submitted") {
43
+ throw new Error(`ASTRA evaluator did not submit: ${result.exit_status}`);
44
+ }
45
+ if (!fs.existsSync(destination)) throw new Error("ASTRA evaluator did not produce its JSON answer");
46
+ const answer = fs.readFileSync(destination, "utf8");
47
+ JSON.parse(answer);
48
+ return { answer, metrics: { calls: gateway.nCalls, retries: gateway.nRetries, costUsd: gateway.totalCostUsd } };
49
+ }
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, requestTimeoutMs = 300000, onRetry } = {}) {
45
+ constructor({ model, baseUrl, apiKey, modelKwargs = {}, maxRetries = 5, maxTokens = 8192, requestTimeoutMs = 0, onRetry } = {}) {
46
46
  if (!model) throw new Error("GatewayModel: `model` is required");
47
47
  this.model = model;
48
48
  this.maxTokens = maxTokens;
@@ -98,8 +98,8 @@ export class GatewayModel {
98
98
  let lastErr;
99
99
  for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
100
100
  try {
101
- const timeoutSignal = AbortSignal.timeout(this.requestTimeoutMs);
102
- const signal = this.signal ? AbortSignal.any([this.signal, timeoutSignal]) : timeoutSignal;
101
+ const timeoutSignal = this.requestTimeoutMs > 0 ? AbortSignal.timeout(this.requestTimeoutMs) : null;
102
+ const signal = timeoutSignal && this.signal ? AbortSignal.any([this.signal, timeoutSignal]) : (timeoutSignal || this.signal || undefined);
103
103
  const res = await fetch(`${this.baseUrl}/chat/completions`, {
104
104
  method: "POST",
105
105
  headers: {
@@ -97,6 +97,7 @@ export async function runProjectBench({ project, apiKey, baseUrl, quiet = false,
97
97
  runDir,
98
98
  candidateDir,
99
99
  config: project.verification,
100
+ gateway: { apiKey, baseUrl },
100
101
  expected: { taskId: project.id, taskVersion: project.version, candidateSha256 },
101
102
  });
102
103
  } catch (error) {
package/src/project.js CHANGED
@@ -75,7 +75,11 @@ function verificationConfig(value) {
75
75
  baseUrl: verification.base_url === undefined ? null : requireString(verification.base_url, "verification.base_url"),
76
76
  readinessUrl: verification.readiness_url === undefined ? null : requireString(verification.readiness_url, "verification.readiness_url"),
77
77
  tokenEnv: verification.token_env === undefined ? null : requireString(verification.token_env, "verification.token_env"),
78
- timeoutSeconds: positiveInteger(verification.timeout_seconds, "verification.timeout_seconds", 3600),
78
+ evaluatorModel: verification.evaluator_model === undefined ? null : requireString(verification.evaluator_model, "verification.evaluator_model"),
79
+ evaluatorReasoning: verification.evaluator_reasoning === undefined ? null : requireString(verification.evaluator_reasoning, "verification.evaluator_reasoning"),
80
+ // Zero means no verifier deadline. Tasks with agentic verification choose
81
+ // completion over an arbitrary wall-clock cutoff.
82
+ timeoutSeconds: positiveInteger(verification.timeout_seconds, "verification.timeout_seconds", 0, { allowZero: true }),
79
83
  report: verification.report === undefined ? "verifier-report.json" : requireString(verification.report, "verification.report"),
80
84
  };
81
85
  }
@@ -13,36 +13,52 @@ function redactOutput(value) {
13
13
 
14
14
  function runCommand(command, { cwd, env, timeoutSeconds }) {
15
15
  return new Promise((resolve) => {
16
- const child = spawn(command, { cwd, env, shell: true, stdio: ["ignore", "pipe", "pipe"] });
16
+ const child = spawn(command, { cwd, env, shell: true, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"] });
17
17
  let stdout = "";
18
18
  let stderr = "";
19
19
  child.stdout.on("data", (chunk) => { stdout += chunk; });
20
20
  child.stderr.on("data", (chunk) => { stderr += chunk; });
21
21
  let timedOut = false;
22
- const timer = setTimeout(() => {
22
+ const limit = Number(timeoutSeconds);
23
+ const timer = limit > 0 ? setTimeout(() => {
23
24
  timedOut = true;
24
- child.kill("SIGTERM");
25
- setTimeout(() => child.kill("SIGKILL"), 250);
26
- }, Math.max(1, Number(timeoutSeconds) || 3600) * 1000);
25
+ stopProcessTree(child, "SIGTERM");
26
+ setTimeout(() => stopProcessTree(child, "SIGKILL"), 250);
27
+ }, limit * 1000) : null;
27
28
  child.on("close", (code, signal) => {
28
- clearTimeout(timer);
29
+ if (timer) clearTimeout(timer);
29
30
  resolve({ code, signal, timedOut, stdout, stderr });
30
31
  });
31
32
  child.on("error", (error) => {
32
- clearTimeout(timer);
33
+ if (timer) clearTimeout(timer);
33
34
  resolve({ code: null, signal: null, timedOut: false, stdout, stderr: `${stderr}${error.message}` });
34
35
  });
35
36
  });
36
37
  }
37
38
 
39
+ function stopProcessTree(child, signal) {
40
+ try {
41
+ if (process.platform !== "win32" && child.pid) process.kill(-child.pid, signal);
42
+ else child.kill(signal);
43
+ } catch {
44
+ // The command may already have exited between the timer and the signal.
45
+ }
46
+ }
47
+
38
48
  function startProcess(command, { cwd, env }) {
39
- return spawn(command, { cwd, env, shell: true, stdio: ["ignore", "ignore", "ignore"] });
49
+ const child = spawn(command, { cwd, env, shell: true, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"] });
50
+ let output = "";
51
+ child.stdout.on("data", (chunk) => { output += chunk; });
52
+ child.stderr.on("data", (chunk) => { output += chunk; });
53
+ child.startupOutput = () => redactOutput(output);
54
+ return child;
40
55
  }
41
56
 
42
57
  async function acquireVerifierLock(projectRoot, timeoutSeconds) {
43
58
  const lockPath = path.join(projectRoot, ".astra", "verifier.lock");
44
59
  fs.mkdirSync(path.dirname(lockPath), { recursive: true });
45
- const deadline = Date.now() + Math.max(1, Number(timeoutSeconds) || 3600) * 1000;
60
+ const limit = Number(timeoutSeconds);
61
+ const deadline = limit > 0 ? Date.now() + limit * 1000 : Infinity;
46
62
  while (Date.now() < deadline) {
47
63
  try {
48
64
  const descriptor = fs.openSync(lockPath, "wx");
@@ -71,21 +87,25 @@ async function acquireVerifierLock(projectRoot, timeoutSeconds) {
71
87
  throw new Error("timed out waiting for the task verifier lock");
72
88
  }
73
89
 
74
- async function waitReady(url, timeoutSeconds) {
75
- const deadline = Date.now() + Math.max(1, Number(timeoutSeconds) || 30) * 1000;
90
+ async function waitReady(url, timeoutSeconds, candidateProcess) {
91
+ const limit = Number(timeoutSeconds);
92
+ const deadline = limit > 0 ? Date.now() + limit * 1000 : Infinity;
76
93
  while (Date.now() < deadline) {
94
+ if (candidateProcess && candidateProcess.exitCode !== null) {
95
+ return { ready: false, exited: true, output: candidateProcess.startupOutput?.() || "" };
96
+ }
77
97
  try {
78
98
  const response = await fetch(url, { signal: AbortSignal.timeout(1000) });
79
- if (response.ok) return true;
99
+ if (response.ok) return { ready: true, exited: false, output: "" };
80
100
  } catch {
81
101
  // Candidate is still starting.
82
102
  }
83
103
  await new Promise((resolve) => setTimeout(resolve, 250));
84
104
  }
85
- return false;
105
+ return { ready: false, exited: false, output: candidateProcess?.startupOutput?.() || "" };
86
106
  }
87
107
 
88
- export async function runVerifier({ project, cell, runDir, candidateDir, config, expected = {} }) {
108
+ export async function runVerifier({ project, cell, runDir, candidateDir, config, expected = {}, gateway = {} }) {
89
109
  const started = Date.now();
90
110
  fs.mkdirSync(runDir, { recursive: true });
91
111
  const reportPath = path.resolve(runDir, config.report || "verifier.json");
@@ -107,6 +127,14 @@ export async function runVerifier({ project, cell, runDir, candidateDir, config,
107
127
  ASTRA_TASK_TYPE: project.type || "brownfield",
108
128
  ASTRA_CELL_KEY: cell.cellKey,
109
129
  ASTRA_PROJECT_ROOT: project.root,
130
+ // The task verifier invokes ASTRA's evaluator bridge for dynamic discovery
131
+ // and source judgment. Keep this explicit and task-scoped: the candidate
132
+ // command environment is separately scrubbed by the verifier.
133
+ ASTRA_EVALUATOR_BIN: new URL("./evaluator-cli.js", import.meta.url).pathname,
134
+ ...(gateway.apiKey ? { ASTRA_GATEWAY_API_KEY: gateway.apiKey } : {}),
135
+ ...(gateway.baseUrl ? { ASTRA_GATEWAY_BASE_URL: gateway.baseUrl } : {}),
136
+ ASTRA_EVALUATOR_MODEL: config.evaluatorModel || cell.model,
137
+ ASTRA_EVALUATOR_REASONING: config.evaluatorReasoning || cell.reasoning || "high",
110
138
  ...(config.baseUrl ? { SCIM_BASE_URL: config.baseUrl } : {}),
111
139
  ...(config.tokenEnv && process.env[config.tokenEnv] ? { [config.tokenEnv]: process.env[config.tokenEnv] } : {}),
112
140
  };
@@ -121,8 +149,31 @@ export async function runVerifier({ project, cell, runDir, candidateDir, config,
121
149
  candidateProcess = startProcess(config.startCommand, { cwd: candidateDir, env });
122
150
  // A cold Docker build legitimately exceeds a minute. Give the candidate
123
151
  // a bounded five-minute startup window, still capped by task timeout.
124
- if (config.readinessUrl && !(await waitReady(config.readinessUrl, Math.min(config.timeoutSeconds, 300)))) {
125
- return { status: "error", failureOwner: "infrastructure", solvedScore: "NA", durationSeconds: (Date.now() - started) / 1000, error: "candidate readiness timed out" };
152
+ if (config.readinessUrl) {
153
+ const readiness = await waitReady(config.readinessUrl, config.timeoutSeconds > 0 ? Math.min(config.timeoutSeconds, 300) : 0, candidateProcess);
154
+ if (!readiness.ready) {
155
+ const durationSeconds = (Date.now() - started) / 1000;
156
+ const detail = readiness.output;
157
+ if (readiness.exited) {
158
+ const portCollision = /EADDRINUSE|address already in use|port is already allocated/i.test(detail);
159
+ return {
160
+ status: "error",
161
+ failureOwner: portCollision ? "infrastructure" : "candidate",
162
+ solvedScore: "NA",
163
+ durationSeconds,
164
+ error: detail
165
+ ? `${portCollision ? "candidate startup blocked by host-port collision" : "candidate startup failed"}: ${detail}`
166
+ : "candidate startup process exited before readiness",
167
+ };
168
+ }
169
+ return {
170
+ status: "error",
171
+ failureOwner: "infrastructure",
172
+ solvedScore: "NA",
173
+ durationSeconds,
174
+ error: detail ? `candidate readiness timed out: ${detail}` : "candidate readiness timed out",
175
+ };
176
+ }
126
177
  }
127
178
  }
128
179
  const commandResult = await runCommand(config.command, {
@@ -163,9 +214,9 @@ export async function runVerifier({ project, cell, runDir, candidateDir, config,
163
214
  };
164
215
  } finally {
165
216
  if (config.stopCommand) {
166
- await runCommand(config.stopCommand, { cwd: candidateDir, env, timeoutSeconds: Math.min(Number(config.timeoutSeconds) || 30, 30) });
217
+ await runCommand(config.stopCommand, { cwd: candidateDir, env, timeoutSeconds: config.timeoutSeconds > 0 ? Math.min(Number(config.timeoutSeconds), 30) : 0 });
167
218
  }
168
- if (candidateProcess && !candidateProcess.killed) candidateProcess.kill("SIGTERM");
219
+ if (candidateProcess && !candidateProcess.killed) stopProcessTree(candidateProcess, "SIGTERM");
169
220
  if (releaseLock) releaseLock();
170
221
  }
171
222
  }