@alignfirst/openclaw-test 0.20.0

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.
Files changed (53) hide show
  1. package/Dockerfile.base +45 -0
  2. package/LICENSE +21 -0
  3. package/README.md +132 -0
  4. package/bin/cli.mjs +7 -0
  5. package/dist/bus.d.ts +1 -0
  6. package/dist/bus.js +20 -0
  7. package/dist/cell-result.d.ts +25 -0
  8. package/dist/cell-result.js +32 -0
  9. package/dist/cli.d.ts +5 -0
  10. package/dist/cli.js +110 -0
  11. package/dist/context.d.ts +220 -0
  12. package/dist/context.js +479 -0
  13. package/dist/cost.d.ts +2 -0
  14. package/dist/cost.js +19 -0
  15. package/dist/env-cli.d.ts +5 -0
  16. package/dist/env-cli.js +645 -0
  17. package/dist/exec-rpc.d.ts +13 -0
  18. package/dist/exec-rpc.js +46 -0
  19. package/dist/index.d.ts +3 -0
  20. package/dist/index.js +1 -0
  21. package/dist/judge.d.ts +47 -0
  22. package/dist/judge.js +183 -0
  23. package/dist/loop.d.ts +62 -0
  24. package/dist/loop.js +316 -0
  25. package/dist/mock-cli-server.d.ts +40 -0
  26. package/dist/mock-cli-server.js +194 -0
  27. package/dist/mock-cli-shim.d.ts +7 -0
  28. package/dist/mock-cli-shim.js +89 -0
  29. package/dist/models.d.ts +23 -0
  30. package/dist/models.js +78 -0
  31. package/dist/parse-tagged-json.d.ts +1 -0
  32. package/dist/parse-tagged-json.js +16 -0
  33. package/dist/report.d.ts +215 -0
  34. package/dist/report.js +19 -0
  35. package/dist/runner-args.d.ts +11 -0
  36. package/dist/runner-args.js +84 -0
  37. package/dist/runner.d.ts +2 -0
  38. package/dist/runner.js +371 -0
  39. package/dist/summary.d.ts +3 -0
  40. package/dist/summary.js +64 -0
  41. package/dist/transcript-dump.d.ts +1 -0
  42. package/dist/transcript-dump.js +50 -0
  43. package/dist/transcript-log.d.ts +67 -0
  44. package/dist/transcript-log.js +199 -0
  45. package/dist/transcript-store.d.ts +6 -0
  46. package/dist/transcript-store.js +69 -0
  47. package/docker-compose.yml +83 -0
  48. package/exec-watcher.mjs +155 -0
  49. package/package.json +60 -0
  50. package/templates/.env.local.example +28 -0
  51. package/templates/Dockerfile +47 -0
  52. package/templates/docker-compose.yml +10 -0
  53. package/templates/openclaw.json +40 -0
@@ -0,0 +1,46 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ export const IPC_DIR = "/var/run/openclaw-test-ipc";
4
+ // Wait this much longer than the requested timeout before declaring the
5
+ // host-side poll dead. Gives the watcher time to kill the child on its own
6
+ // timeout (exitCode 124) and write the truncated response file.
7
+ const WATCHER_DEADLINE_HEADROOM_MS = 5_000;
8
+ const EXEC_POLL_INTERVAL_MS = 100;
9
+ const WATCHER_TIMEOUT_EXIT_CODE = 124;
10
+ export async function execInGateway(argv, opts = {}) {
11
+ const id = randomUUID();
12
+ const timeoutMs = opts.timeoutMs ?? 30_000;
13
+ const payload = { id, argv, timeoutMs };
14
+ if (opts.cwd !== undefined)
15
+ payload.cwd = opts.cwd;
16
+ if (opts.env !== undefined)
17
+ payload.env = opts.env;
18
+ if (opts.stdin !== undefined)
19
+ payload.stdin = opts.stdin;
20
+ const reqPath = `${IPC_DIR}/${id}.req.json`;
21
+ const reqTmp = `${reqPath}.tmp`;
22
+ const resPath = `${IPC_DIR}/${id}.res.json`;
23
+ writeFileSync(reqTmp, JSON.stringify(payload));
24
+ renameSync(reqTmp, reqPath);
25
+ const deadline = Date.now() + timeoutMs + WATCHER_DEADLINE_HEADROOM_MS;
26
+ while (Date.now() < deadline) {
27
+ if (existsSync(resPath)) {
28
+ const raw = readFileSync(resPath, "utf8");
29
+ rmSync(resPath, { force: true });
30
+ rmSync(reqPath, { force: true });
31
+ const parsed = JSON.parse(raw);
32
+ if (parsed.exitCode === WATCHER_TIMEOUT_EXIT_CODE) {
33
+ console.warn(`execInGateway: watcher killed child after ${timeoutMs}ms (id ${id}, argv ${JSON.stringify(argv)})`);
34
+ }
35
+ return parsed;
36
+ }
37
+ await new Promise((r) => setTimeout(r, EXEC_POLL_INTERVAL_MS));
38
+ }
39
+ rmSync(reqPath, { force: true });
40
+ rmSync(`${reqPath}.processing`, { force: true });
41
+ // The watcher can still write `<id>.res.json` after this deadline (its own
42
+ // timeout kill plus the write can trail it). Nobody will read it, and the
43
+ // IPC volume is persistent — sweep it once it had time to land.
44
+ setTimeout(() => rmSync(resPath, { force: true }), WATCHER_DEADLINE_HEADROOM_MS * 2).unref();
45
+ throw new Error(`execInGateway timed out waiting for response (request id ${id})`);
46
+ }
@@ -0,0 +1,3 @@
1
+ export type { BusMessage, ChannelId, MockCliRegisterMode, MockCliRegisterOptions, ScenarioContext, SendInboundResult, WaitForOutboundOptions, WaitForOutboundResult, } from "./context.js";
2
+ export type { JudgeUsage, JudgeVerdict, JudgeVerdictJson, JudgeVerdictRaw, } from "./judge.js";
3
+ export type { ActionEntry, ActionEntryBase, AgentToolCall, AgentToolCallEntry, AssertionRecord, CliMockCall, CliMockEntry, CliMockHandler, CliMockHandlerArgs, CostBreakdown, EmitSink, ErrorScenarioResult, FailedEntryScenarioResult, FailScenarioResult, InboundSentEntry, OutboundReceivedEntry, PassScenarioResult, ReportEntry, ReportEntryBase, ScenarioFailure, ScenarioLogEntry, ScenarioLogNote, ScenarioReport, ScenarioResult, } from "./report.js";
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,47 @@
1
+ export interface JudgeUsage {
2
+ model: string;
3
+ inputTokens: number;
4
+ outputTokens: number;
5
+ }
6
+ export interface JudgeVerdict {
7
+ verdict: "pass" | "fail";
8
+ reasoning: string;
9
+ raw: string;
10
+ usage: JudgeUsage;
11
+ }
12
+ export interface JudgeVerdictJson<T> {
13
+ parsed: T;
14
+ raw: string;
15
+ usage: JudgeUsage;
16
+ }
17
+ export interface JudgeVerdictRaw {
18
+ raw: string;
19
+ usage: JudgeUsage;
20
+ }
21
+ export declare function judgeLLM(params: {
22
+ message: string;
23
+ rubric: string;
24
+ maxTokens?: number;
25
+ }): Promise<JudgeVerdict>;
26
+ export declare function judgeLLMJson<T>(params: {
27
+ message: string;
28
+ prompt: string;
29
+ returnType: string;
30
+ maxTokens?: number;
31
+ }): Promise<JudgeVerdictJson<T>>;
32
+ export declare function judgeLLMRaw(prompt: string, opts?: {
33
+ maxTokens?: number;
34
+ }): Promise<JudgeVerdictRaw>;
35
+ export declare function buildVerdictPrompt(params: {
36
+ message: string;
37
+ rubric: string;
38
+ }): string;
39
+ export declare function buildJsonPrompt(params: {
40
+ message: string;
41
+ prompt: string;
42
+ returnType: string;
43
+ }): string;
44
+ export declare function parseJudgeModelRef(ref: string): {
45
+ provider: "anthropic" | "openrouter";
46
+ bareModel: string;
47
+ };
package/dist/judge.js ADDED
@@ -0,0 +1,183 @@
1
+ import Anthropic from "@anthropic-ai/sdk";
2
+ import OpenAI from "openai";
3
+ import { extractTaggedBlock } from "./parse-tagged-json.js";
4
+ const DEFAULT_JUDGE_MODEL = "anthropic/claude-haiku-4-5";
5
+ const DEFAULT_MAX_TOKENS = 1024;
6
+ const RESULT_TAG = "result-json";
7
+ let cached;
8
+ export async function judgeLLM(params) {
9
+ const prompt = buildVerdictPrompt({ message: params.message, rubric: params.rubric });
10
+ const { raw, usage } = await callJudge(prompt, params.maxTokens ?? DEFAULT_MAX_TOKENS);
11
+ const body = extractTaggedBlock(raw, RESULT_TAG);
12
+ const parsed = parseJson(body, raw);
13
+ const obj = parsed;
14
+ if (obj.verdict !== "pass" && obj.verdict !== "fail") {
15
+ throw new Error(`judge verdict invalid: ${JSON.stringify(obj.verdict)}. raw=${JSON.stringify(raw)}`);
16
+ }
17
+ if (typeof obj.reasoning !== "string") {
18
+ throw new Error(`judge reasoning not a string: ${JSON.stringify(obj.reasoning)}. raw=${JSON.stringify(raw)}`);
19
+ }
20
+ return { verdict: obj.verdict, reasoning: obj.reasoning, raw, usage };
21
+ }
22
+ export async function judgeLLMJson(params) {
23
+ const prompt = buildJsonPrompt({
24
+ message: params.message,
25
+ prompt: params.prompt,
26
+ returnType: params.returnType,
27
+ });
28
+ const { raw, usage } = await callJudge(prompt, params.maxTokens ?? DEFAULT_MAX_TOKENS);
29
+ const body = extractTaggedBlock(raw, RESULT_TAG);
30
+ const parsed = parseJson(body, raw);
31
+ return { parsed, raw, usage };
32
+ }
33
+ export async function judgeLLMRaw(prompt, opts) {
34
+ return await callJudge(prompt, opts?.maxTokens ?? DEFAULT_MAX_TOKENS);
35
+ }
36
+ export function buildVerdictPrompt(params) {
37
+ return `You are a strict QA judge. Evaluate the message below against the rubric. Return "pass" only when the message satisfies the rubric without violations; otherwise return "fail".
38
+
39
+ <rubric>
40
+ ${params.rubric}
41
+ </rubric>
42
+
43
+ <message-to-evaluate>
44
+ ${params.message}
45
+ </message-to-evaluate>
46
+
47
+ Here is the return type we expect:
48
+
49
+ <return-type>
50
+ interface JudgeResult {
51
+ reasoning?: string; // concise, <=20 words
52
+ verdict: "pass" | "fail";
53
+ }
54
+ </return-type>
55
+
56
+ You may write free-form prose first if useful. Then, emit the result JSON value in a \`<${RESULT_TAG}>\` tag.
57
+ `;
58
+ }
59
+ export function buildJsonPrompt(params) {
60
+ return `${params.prompt}
61
+
62
+ Message under evaluation:
63
+
64
+ <message-to-evaluate>
65
+ ${params.message}
66
+ </message-to-evaluate>
67
+
68
+ Here is the return type we expect:
69
+
70
+ <return-type>
71
+ ${params.returnType};
72
+ </return-type>
73
+
74
+ You may write free-form prose first if useful. Then, emit the result JSON value in a \`<${RESULT_TAG}>\` tag.
75
+ `;
76
+ }
77
+ // The SDK's own retries (see `maxRetries` below) cap out around 30s of backoff;
78
+ // observed container DNS/network outages last minutes and have killed most of a
79
+ // batch at once. Between them, these outer delays hold a cell alive ~2 minutes.
80
+ const CONNECTION_RETRY_DELAYS_MS = [15_000, 30_000, 60_000];
81
+ async function callJudge(prompt, maxTokens) {
82
+ for (let attempt = 0;; ++attempt) {
83
+ try {
84
+ return await callJudgeOnce(prompt, maxTokens);
85
+ }
86
+ catch (err) {
87
+ const delayMs = CONNECTION_RETRY_DELAYS_MS[attempt];
88
+ if (delayMs === undefined || !isConnectionError(err))
89
+ throw err;
90
+ console.warn(`judge connection error, retrying in ${delayMs / 1000}s: ${String(err)}`);
91
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
92
+ }
93
+ }
94
+ }
95
+ async function callJudgeOnce(prompt, maxTokens) {
96
+ const judge = getJudge();
97
+ if (judge.provider === "openrouter") {
98
+ return callOpenRouterOnce(judge, prompt, maxTokens);
99
+ }
100
+ const resp = await judge.client.messages.create({
101
+ model: judge.bareModel,
102
+ max_tokens: maxTokens,
103
+ temperature: 0,
104
+ messages: [{ role: "user", content: prompt }],
105
+ });
106
+ const raw = resp.content.map((b) => (b.type === "text" ? b.text : "")).join("");
107
+ if (!resp.usage)
108
+ throw new Error("judge response missing usage");
109
+ const usage = {
110
+ model: judge.model,
111
+ inputTokens: resp.usage.input_tokens,
112
+ outputTokens: resp.usage.output_tokens,
113
+ };
114
+ return { raw, usage };
115
+ }
116
+ async function callOpenRouterOnce(judge, prompt, maxTokens) {
117
+ const response = await judge.client.chat.completions.create({
118
+ model: judge.bareModel,
119
+ max_tokens: maxTokens,
120
+ temperature: 0,
121
+ messages: [{ role: "user", content: prompt }],
122
+ });
123
+ const raw = response.choices.map((choice) => choice.message.content ?? "").join("");
124
+ if (!response.usage)
125
+ throw new Error("judge response missing usage");
126
+ const usage = {
127
+ model: judge.model,
128
+ inputTokens: response.usage.prompt_tokens,
129
+ outputTokens: response.usage.completion_tokens,
130
+ };
131
+ return { raw, usage };
132
+ }
133
+ function parseJson(body, raw) {
134
+ try {
135
+ return JSON.parse(body);
136
+ }
137
+ catch (err) {
138
+ throw new Error(`judge JSON parse failed: ${err.message}. body=${JSON.stringify(body)} raw=${JSON.stringify(raw)}`);
139
+ }
140
+ }
141
+ function getJudge() {
142
+ if (cached)
143
+ return cached;
144
+ const model = process.env.OPENCLAW_TEST_JUDGE_MODEL ?? DEFAULT_JUDGE_MODEL;
145
+ const { provider, bareModel } = parseJudgeModelRef(model);
146
+ // Container DNS blips (EAI_AGAIN) outlast the SDK's default 2 retries and have
147
+ // killed whole cells at once; 5 retries ride out a multi-second outage.
148
+ if (provider === "openrouter") {
149
+ const apiKey = process.env.OPENROUTER_API_KEY;
150
+ if (!apiKey) {
151
+ throw new Error("OPENROUTER_API_KEY is not set — fill in your project's .env.local");
152
+ }
153
+ cached = {
154
+ provider,
155
+ client: new OpenAI({ apiKey, baseURL: "https://openrouter.ai/api/v1", maxRetries: 5 }),
156
+ model,
157
+ bareModel,
158
+ };
159
+ return cached;
160
+ }
161
+ const apiKey = process.env.ANTHROPIC_API_KEY;
162
+ if (!apiKey)
163
+ throw new Error("ANTHROPIC_API_KEY is not set — fill in your project's .env.local");
164
+ cached = { provider, client: new Anthropic({ apiKey, maxRetries: 5 }), model, bareModel };
165
+ return cached;
166
+ }
167
+ export function parseJudgeModelRef(ref) {
168
+ const slash = ref.indexOf("/");
169
+ if (slash < 0) {
170
+ throw new Error(`OPENCLAW_TEST_JUDGE_MODEL must be a LiteLLM-style "provider/model" reference (for example, "anthropic/claude-haiku-4-5" or "openrouter/anthropic/claude-haiku-4.5"); got ${JSON.stringify(ref)}`);
171
+ }
172
+ const provider = ref.slice(0, slash);
173
+ const bareModel = ref.slice(slash + 1);
174
+ if (provider !== "anthropic" && provider !== "openrouter") {
175
+ throw new Error(`OPENCLAW_TEST_JUDGE_MODEL provider ${JSON.stringify(provider)} is not supported; use "anthropic/" or "openrouter/"`);
176
+ }
177
+ if (!bareModel)
178
+ throw new Error(`OPENCLAW_TEST_JUDGE_MODEL model name is empty after the "${provider}/" prefix`);
179
+ return { provider, bareModel };
180
+ }
181
+ function isConnectionError(error) {
182
+ return (error instanceof Anthropic.APIConnectionError || error instanceof OpenAI.APIConnectionError);
183
+ }
package/dist/loop.d.ts ADDED
@@ -0,0 +1,62 @@
1
+ import { type ChildProcess } from "node:child_process";
2
+ import { type CellResult } from "./cell-result.js";
3
+ import type { SelectedModel } from "./models.js";
4
+ export declare function expandChannelSelection(raw: string, openclawConfigPath: string): string[];
5
+ export declare function discoverScenarios(scenariosDir: string): string[];
6
+ export interface MatrixOptions {
7
+ scenarios: string[];
8
+ channels: string[];
9
+ models: SelectedModel[];
10
+ /**
11
+ * Renders the chosen model's ref into a run-scoped config and returns its path
12
+ * (memoized per model). Worker loops hand it to every spawn via per-spawn env.
13
+ */
14
+ renderConfigPath: (model: SelectedModel) => string;
15
+ iterations: number;
16
+ maxFailures: number;
17
+ /** Stop dispatching at the first failing cell; in-flight cells drain. */
18
+ stopOnFail: boolean;
19
+ reuseStack: boolean;
20
+ /** Pool size; a serial run is a pool of 1 (inherited stdio, no status lines). */
21
+ parallel: number;
22
+ workers: WorkerContext[];
23
+ /** Refresh the worker's private workspace copy; called before every cell. */
24
+ refreshWorkspace: (worker: WorkerContext) => void;
25
+ artifactsDir: string;
26
+ /** Host-side path to the results dir; the host reads `*.json` from here. */
27
+ resultsDir: string;
28
+ /** Container-side path passed to the runner via `--results-dir`. */
29
+ runnerResultsDir: string;
30
+ baseStamp: string;
31
+ spawnRunner?: SpawnFn;
32
+ spawnRecreate?: SpawnFn;
33
+ readResult?: (path: string) => CellResult | undefined;
34
+ }
35
+ export interface WorkerContext {
36
+ /** 1-based. */
37
+ index: number;
38
+ /** Compose base args + `["-p", "<base>-w<i>"]`. */
39
+ composeArgs: string[];
40
+ /** Host-side per-worker gateway logs dir, bind-mounted into the worker's gateway. */
41
+ gatewayLogsDir: string;
42
+ /** Host-side per-worker workspace copy, bind-mounted into the worker's gateway. */
43
+ workspaceDir: string;
44
+ /** True when the worker's bus+gateway were already running before this command. */
45
+ wasRunningBefore: boolean;
46
+ }
47
+ export type SpawnFn = (args: SpawnRequest) => Promise<number>;
48
+ export interface SpawnRequest {
49
+ command: string;
50
+ argv: string[];
51
+ /** Extra env merged over `process.env` for this spawn only. */
52
+ env?: Record<string, string>;
53
+ /** When set, stdout+stderr are appended to this file instead of inherited. */
54
+ logFile?: string;
55
+ onChild?: (child: ChildProcess) => void;
56
+ }
57
+ /**
58
+ * Per-worker env for a compose spawn. Compose re-interpolates bind mounts on
59
+ * every invocation, so recreate, `run runner` and `up` must all carry it.
60
+ */
61
+ export declare function workerSpawnEnv(worker: WorkerContext, configPath: string): Record<string, string>;
62
+ export declare function runMatrix(opts: MatrixOptions): Promise<number>;
package/dist/loop.js ADDED
@@ -0,0 +1,316 @@
1
+ import { spawn } from "node:child_process";
2
+ import { closeSync, openSync, readdirSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { cellLeafName, readCellResult } from "./cell-result.js";
5
+ import { judgeCostUsd } from "./cost.js";
6
+ import { printSummary, printTotalCost } from "./summary.js";
7
+ export function expandChannelSelection(raw, openclawConfigPath) {
8
+ if (!raw)
9
+ throw new Error("run: --channel expects a non-empty value");
10
+ const cfg = JSON.parse(readFileSync(openclawConfigPath, "utf8"));
11
+ const allowed = Object.keys(cfg.channels ?? {});
12
+ if (allowed.length === 0)
13
+ throw new Error(`no channels declared in ${openclawConfigPath}`);
14
+ if (raw === "all")
15
+ return [...allowed].sort();
16
+ const ids = raw
17
+ .split(",")
18
+ .map((s) => s.trim())
19
+ .filter((s) => s.length > 0);
20
+ if (ids.length === 0) {
21
+ throw new Error(`run: --channel expects a non-empty list, got ${JSON.stringify(raw)}`);
22
+ }
23
+ const unknown = ids.filter((c) => !allowed.includes(c));
24
+ if (unknown.length > 0) {
25
+ throw new Error(`unknown channel(s): ${unknown.join(", ")} — allowed: ${allowed.join(", ")}`);
26
+ }
27
+ return [...new Set(ids)];
28
+ }
29
+ export function discoverScenarios(scenariosDir) {
30
+ return readdirSync(scenariosDir)
31
+ .filter((f) => f.endsWith(".ts"))
32
+ .map((f) => f.slice(0, -".ts".length))
33
+ .sort();
34
+ }
35
+ /**
36
+ * Per-worker env for a compose spawn. Compose re-interpolates bind mounts on
37
+ * every invocation, so recreate, `run runner` and `up` must all carry it.
38
+ */
39
+ export function workerSpawnEnv(worker, configPath) {
40
+ return {
41
+ OPENCLAW_CONFIG_PATH: configPath,
42
+ OPENCLAW_TEST_GATEWAY_LOGS_DIR: worker.gatewayLogsDir,
43
+ OPENCLAW_WORKSPACE_DIR: worker.workspaceDir,
44
+ };
45
+ }
46
+ export async function runMatrix(opts) {
47
+ const state = {
48
+ pending: expandCells(opts),
49
+ runningPerModel: new Map(),
50
+ pairFailures: new Map(),
51
+ bailedPairs: new Set(),
52
+ stopped: false,
53
+ signalled: false,
54
+ liveChildren: new Set(),
55
+ records: [],
56
+ exitCode: 0,
57
+ };
58
+ const ctx = {
59
+ opts,
60
+ state,
61
+ spawnRunner: opts.spawnRunner ?? defaultSpawn,
62
+ spawnRecreate: opts.spawnRecreate ?? defaultSpawn,
63
+ readResult: opts.readResult ?? readCellResult,
64
+ iterationWidth: opts.iterations > 1 ? String(opts.iterations).length : 0,
65
+ };
66
+ const onSignal = (sig) => {
67
+ state.stopped = true;
68
+ state.signalled = true;
69
+ for (const child of state.liveChildren)
70
+ child.kill(sig);
71
+ };
72
+ process.on("SIGINT", onSignal);
73
+ process.on("SIGTERM", onSignal);
74
+ try {
75
+ await Promise.all(opts.workers.map((worker) => workerLoop(ctx, worker)));
76
+ }
77
+ finally {
78
+ process.off("SIGINT", onSignal);
79
+ process.off("SIGTERM", onSignal);
80
+ }
81
+ const results = [...state.records]
82
+ .sort((a, b) => a.cell.index - b.cell.index)
83
+ .map((r) => r.result);
84
+ printSummary(results, opts.baseStamp);
85
+ printTotalCost(results);
86
+ if (state.exitCode !== 0)
87
+ return state.exitCode;
88
+ return results.some((r) => r.verdict === "fail") ? 1 : 0;
89
+ }
90
+ /** Full expansion in `model × scenario × channel × iteration` nesting order. */
91
+ function expandCells(opts) {
92
+ const cells = [];
93
+ for (const model of opts.models) {
94
+ for (const scenario of opts.scenarios) {
95
+ for (const channel of opts.channels) {
96
+ for (let iter = 1; iter <= opts.iterations; ++iter) {
97
+ cells.push({
98
+ index: cells.length,
99
+ model,
100
+ scenario,
101
+ channel,
102
+ iteration: iter,
103
+ pairKey: `${model.id}|${scenario}|${channel}`,
104
+ });
105
+ }
106
+ }
107
+ }
108
+ }
109
+ return cells;
110
+ }
111
+ async function workerLoop(ctx, worker) {
112
+ const { opts, state } = ctx;
113
+ // The model the worker's gateway booted on. Workers are created lazily: the
114
+ // first cell's `up -d --force-recreate` also creates a stack that isn't running.
115
+ let loadedModelId;
116
+ for (;;) {
117
+ const cell = takeNextCell(state, opts.reuseStack ? loadedModelId : undefined);
118
+ if (!cell)
119
+ return;
120
+ changeRunningCount(state, cell.model.id, 1);
121
+ try {
122
+ const recreated = await runCell(ctx, worker, cell, loadedModelId);
123
+ if (recreated)
124
+ loadedModelId = cell.model.id;
125
+ }
126
+ finally {
127
+ changeRunningCount(state, cell.model.id, -1);
128
+ }
129
+ }
130
+ }
131
+ /**
132
+ * Skip bailed pairs. Under `--reuse-stack` (`loadedModelId` given), first look for
133
+ * a pending cell matching the loaded model to avoid the recreate; otherwise pick
134
+ * the pending cell whose model has the fewest running cells (spreads providers,
135
+ * softens rate-limit bursts; tie → expansion order).
136
+ */
137
+ function takeNextCell(state, loadedModelId) {
138
+ if (state.stopped)
139
+ return;
140
+ state.pending = state.pending.filter((c) => !state.bailedPairs.has(c.pairKey));
141
+ if (state.pending.length === 0)
142
+ return;
143
+ const affinity = loadedModelId !== undefined
144
+ ? state.pending.find((c) => c.model.id === loadedModelId)
145
+ : undefined;
146
+ const cell = affinity ?? leastLoadedModelCell(state);
147
+ state.pending.splice(state.pending.indexOf(cell), 1);
148
+ return cell;
149
+ }
150
+ // Linear scan is fine: `pending` is small and shrinks per take.
151
+ function leastLoadedModelCell(state) {
152
+ let best = state.pending[0];
153
+ let bestCount = state.runningPerModel.get(best.model.id) ?? 0;
154
+ for (const cell of state.pending) {
155
+ const count = state.runningPerModel.get(cell.model.id) ?? 0;
156
+ if (count < bestCount) {
157
+ best = cell;
158
+ bestCount = count;
159
+ }
160
+ }
161
+ return best;
162
+ }
163
+ function changeRunningCount(state, modelId, delta) {
164
+ state.runningPerModel.set(modelId, (state.runningPerModel.get(modelId) ?? 0) + delta);
165
+ }
166
+ /** Returns true when the worker's stack was (re)created for this cell. */
167
+ async function runCell(ctx, worker, cell, loadedModelId) {
168
+ const { opts, state } = ctx;
169
+ const leaf = cellLeafName({
170
+ scenarioId: cell.scenario,
171
+ modelId: cell.model.id,
172
+ channel: cell.channel,
173
+ iterationIndex: cell.iteration,
174
+ iterationWidth: ctx.iterationWidth,
175
+ });
176
+ const env = workerSpawnEnv(worker, opts.renderConfigPath(cell.model));
177
+ const logFile = opts.parallel > 1 ? join(opts.resultsDir, `${leaf}.log`) : undefined;
178
+ if (opts.parallel > 1)
179
+ console.log(`[w${worker.index}] ${leaf} started`);
180
+ // Refresh even on the reuse-stack no-recreate path: the live gateway reads the
181
+ // workspace lazily, so this is intentional despite the reuse-stack state-leak caveat.
182
+ opts.refreshWorkspace(worker);
183
+ const needRecreate = !opts.reuseStack || loadedModelId !== cell.model.id;
184
+ if (needRecreate) {
185
+ const code = await spawnTracked(ctx.spawnRecreate, state, {
186
+ command: "docker",
187
+ argv: [...worker.composeArgs, "up", "-d", "--force-recreate", "--wait", "bus", "gateway"],
188
+ env,
189
+ logFile,
190
+ });
191
+ if (state.signalled)
192
+ return false;
193
+ if (code !== 0) {
194
+ state.stopped = true;
195
+ state.exitCode = code;
196
+ if (opts.parallel > 1) {
197
+ console.error(`[w${worker.index}] ${leaf} bus+gateway recreate failed (exit ${code})`);
198
+ }
199
+ return false;
200
+ }
201
+ }
202
+ const resultsPath = join(opts.resultsDir, `${leaf}.json`);
203
+ const childExit = await spawnTracked(ctx.spawnRunner, state, {
204
+ command: "docker",
205
+ argv: [
206
+ ...worker.composeArgs,
207
+ "run",
208
+ "--rm",
209
+ "--use-aliases",
210
+ "runner",
211
+ "--scenario",
212
+ cell.scenario,
213
+ "--channel",
214
+ cell.channel,
215
+ "--model-id",
216
+ cell.model.id,
217
+ "--model-ref",
218
+ cell.model.ref,
219
+ "--iteration-index",
220
+ String(cell.iteration),
221
+ "--iteration-width",
222
+ String(ctx.iterationWidth),
223
+ "--base-stamp",
224
+ opts.baseStamp,
225
+ "--results-dir",
226
+ opts.runnerResultsDir,
227
+ ],
228
+ env,
229
+ logFile,
230
+ });
231
+ const result = ctx.readResult(resultsPath) ??
232
+ synthesizeFailedResult({
233
+ scenario: cell.scenario,
234
+ channel: cell.channel,
235
+ model: cell.model.id,
236
+ iter: cell.iteration,
237
+ resultsPath,
238
+ childExit,
239
+ });
240
+ state.records.push({ cell, result });
241
+ if (opts.parallel > 1)
242
+ console.log(formatCellDone(worker.index, leaf, result));
243
+ if (result.verdict === "fail") {
244
+ if (opts.stopOnFail)
245
+ state.stopped = true;
246
+ const failures = (state.pairFailures.get(cell.pairKey) ?? 0) + 1;
247
+ state.pairFailures.set(cell.pairKey, failures);
248
+ if (failures > opts.maxFailures)
249
+ state.bailedPairs.add(cell.pairKey);
250
+ }
251
+ return needRecreate;
252
+ }
253
+ async function spawnTracked(fn, state, req) {
254
+ return fn({
255
+ ...req,
256
+ onChild: (child) => {
257
+ state.liveChildren.add(child);
258
+ child.on("exit", () => state.liveChildren.delete(child));
259
+ },
260
+ });
261
+ }
262
+ function formatCellDone(workerIndex, leaf, r) {
263
+ const verdict = r.verdict === "pass" ? "PASS" : "FAIL";
264
+ const judgeCost = r.judgeUsages.reduce((sum, u) => sum + judgeCostUsd(u), 0);
265
+ const cost = r.agentCostUsd + judgeCost;
266
+ return `[w${workerIndex}] ${leaf} ${verdict} (${Math.round(r.durationMs / 1000)}s, $${cost.toFixed(4)})`;
267
+ }
268
+ function defaultSpawn(req) {
269
+ return new Promise((resolve) => {
270
+ const env = req.env ? { ...process.env, ...req.env } : process.env;
271
+ const logFd = req.logFile === undefined ? undefined : openSync(req.logFile, "a");
272
+ const child = spawn(req.command, req.argv, {
273
+ env,
274
+ stdio: logFd === undefined ? "inherit" : ["ignore", logFd, logFd],
275
+ });
276
+ // The child holds dups of the fd; safe to close ours right away.
277
+ if (logFd !== undefined)
278
+ closeSync(logFd);
279
+ req.onChild?.(child);
280
+ child.on("exit", (code, signal) => {
281
+ if (signal)
282
+ resolve(128 + signalNumber(signal));
283
+ else
284
+ resolve(code ?? 1);
285
+ });
286
+ child.on("error", (err) => {
287
+ console.error(err.message);
288
+ resolve(1);
289
+ });
290
+ });
291
+ }
292
+ function signalNumber(signal) {
293
+ if (signal === "SIGINT")
294
+ return 2;
295
+ if (signal === "SIGTERM")
296
+ return 15;
297
+ return 1;
298
+ }
299
+ function synthesizeFailedResult(params) {
300
+ console.warn(`run: missing or invalid cell record at ${params.resultsPath} (runner exit ${params.childExit}) — counting as fail`);
301
+ return {
302
+ schemaVersion: 3,
303
+ scenarioId: params.scenario,
304
+ channel: params.channel,
305
+ model: params.model,
306
+ iterationIndex: params.iter,
307
+ verdict: "fail",
308
+ durationMs: 0,
309
+ conversationId: "",
310
+ artifactDirName: "",
311
+ agentCostUsd: 0,
312
+ agentTurns: 0,
313
+ judgeUsd: 0,
314
+ judgeUsages: [],
315
+ };
316
+ }