static-fire 0.0.0-stage → 0.44.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 (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +230 -2
  3. package/bin/static-fire.js +46 -0
  4. package/package.json +29 -4
  5. package/pricing.yaml +20 -0
  6. package/src/adapters/index.ts +153 -0
  7. package/src/api.ts +218 -0
  8. package/src/cli.ts +297 -0
  9. package/src/core/apkit-install.ts +47 -0
  10. package/src/core/authored.ts +47 -0
  11. package/src/core/batch-state.ts +25 -0
  12. package/src/core/batch.ts +435 -0
  13. package/src/core/batches.ts +221 -0
  14. package/src/core/checks.ts +63 -0
  15. package/src/core/commands.ts +42 -0
  16. package/src/core/compare.ts +203 -0
  17. package/src/core/config.ts +25 -0
  18. package/src/core/copying.ts +18 -0
  19. package/src/core/db.ts +572 -0
  20. package/src/core/derived.ts +74 -0
  21. package/src/core/engine.ts +85 -0
  22. package/src/core/facets.ts +128 -0
  23. package/src/core/grade.ts +197 -0
  24. package/src/core/grading-versions.ts +281 -0
  25. package/src/core/identity.ts +263 -0
  26. package/src/core/init.ts +231 -0
  27. package/src/core/judge-schema.ts +37 -0
  28. package/src/core/judge.ts +166 -0
  29. package/src/core/label.ts +43 -0
  30. package/src/core/lock.ts +40 -0
  31. package/src/core/migrate.ts +288 -0
  32. package/src/core/new-task.ts +85 -0
  33. package/src/core/outside-tools.ts +129 -0
  34. package/src/core/profiles.ts +42 -0
  35. package/src/core/project.ts +54 -0
  36. package/src/core/regrade.ts +233 -0
  37. package/src/core/score.ts +148 -0
  38. package/src/core/settings.ts +44 -0
  39. package/src/core/task-file.ts +95 -0
  40. package/src/core/trial-detail.ts +73 -0
  41. package/src/core/types.ts +195 -0
  42. package/src/core/view-query.ts +85 -0
  43. package/src/core/view.ts +498 -0
  44. package/src/guide.md +547 -0
  45. package/src/server.ts +24 -0
  46. package/web/api.ts +6 -0
  47. package/web/app.tsx +1019 -0
  48. package/web/brand/offset-tokens.css +126 -0
  49. package/web/charts.tsx +190 -0
  50. package/web/compare.tsx +317 -0
  51. package/web/encoding.tsx +55 -0
  52. package/web/filter-menu.ts +164 -0
  53. package/web/index.html +17 -0
  54. package/web/pages.ts +16 -0
  55. package/web/run-status.ts +51 -0
  56. package/web/styles.css +333 -0
  57. package/web/suite-notes.ts +20 -0
  58. package/web/wording.ts +38 -0
@@ -0,0 +1,435 @@
1
+ // A batch is one `run`: what was asked for, and its trials. Every trial is its own record in the store, with
2
+ // its task hash, config key and run conditions, and zero or more gradings. Artifacts go to the trial's own
3
+ // folder (db.ts trialDir): workspace/, agent.log and gradings/<grading-id>/ for judge logs.
4
+ //
5
+ // Running a trial and grading it are two separate steps. A trial's row ends (done, failed or invalid)
6
+ // before any grading starts and never changes after that; a grading is a new row. So a crash between the
7
+ // two leaves a done trial with no finished grading, which regrade can grade later.
8
+ //
9
+ // The agent works in a workspace in the OS temp folder, outside any repo (ADR-0004); it is copied to the
10
+ // trial's folder before the row ends, so an ended trial always has its artifacts.
11
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { adapters } from "../adapters";
15
+ import { COPY_LIMIT, copiedShare } from "./copying";
16
+ import { trialDir, type BatchRecord, type GradingRecord, type GradingResult, type Store, type TrialRecord } from "./db";
17
+ import { grade } from "./grade";
18
+ import { canonicalJson, configKey, folderHash, gradingIdentity, sha256, taskIdentity } from "./identity";
19
+ import { profileSnapshot } from "./profiles";
20
+ import { familyOf } from "./score";
21
+ import { loadSettings, userSetupFiles } from "./settings";
22
+ import { loadConfigs, loadJudges, loadSuites, loadTask } from "./authored";
23
+ import { projectPaths, projectRoot } from "./project";
24
+ import type { AgentConfig, AgentHarness, AgentResult, JudgeConfig, Task } from "./types";
25
+
26
+ export const DEFAULT_VOTES = 3;
27
+ const AGENT_TIMEOUT_MS = 20 * 60_000;
28
+ const VERSION_TIMEOUT_MS = 10_000;
29
+
30
+ // ---- Planning: what was asked for, checked before anything is written ----------------------------
31
+
32
+ /** What `run` was asked for. Give `tasks` or `suites`, not both; neither means every suite. */
33
+ export interface RunRequest {
34
+ tasks?: string[];
35
+ suites?: string[];
36
+ configs?: string[]; // left out or ["all"]: every config
37
+ trials?: number; // per agent config and task (a human config gets one); default 3
38
+ judge?: string; // judge id; default the first in judges.yaml
39
+ votes?: number; // default DEFAULT_VOTES
40
+ }
41
+
42
+ export interface BatchPlan {
43
+ /** Stored as the batch's `request`: what was asked for, with the task list it resolved to. */
44
+ request: { tasks?: string[]; suites?: string[]; configs: string[]; trials: number; judge: string; votes: number; taskIds: string[] };
45
+ tasks: Task[];
46
+ configs: AgentConfig[];
47
+ judge: JudgeConfig;
48
+ votes: number;
49
+ trials: number;
50
+ }
51
+
52
+ const positiveInt = (n: number, what: string) => {
53
+ if (!Number.isInteger(n) || n < 1) throw new Error(`${what} must be a whole number of 1 or more (got ${JSON.stringify(n)})`);
54
+ return n;
55
+ };
56
+
57
+ const REQUEST_FIELDS = ["tasks", "suites", "configs", "trials", "judge", "votes"];
58
+
59
+ /**
60
+ * Checks the shape of a request that came from outside the type checker (the JSON body of POST /api/runs):
61
+ * a JSON object with only RunRequest's fields, each list a list of strings, each count a whole number of 1 or
62
+ * more. A field left out or undefined takes its default. Throws a plain message; returns the request as typed.
63
+ */
64
+ export function parseRunRequest(body: unknown): RunRequest {
65
+ if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("the request body must be a JSON object");
66
+ const r = body as Record<string, unknown>;
67
+ const unknown = Object.keys(r).filter((k) => !REQUEST_FIELDS.includes(k));
68
+ if (unknown.length) throw new Error(`unknown request field(s): ${unknown.join(", ")} (known: ${REQUEST_FIELDS.join(", ")})`);
69
+ for (const k of ["tasks", "suites", "configs"]) {
70
+ const v = r[k];
71
+ if (v !== undefined && !(Array.isArray(v) && v.every((x) => typeof x === "string"))) throw new Error(`${k} must be a list of ids (strings), got ${JSON.stringify(v)}`);
72
+ }
73
+ for (const k of ["trials", "votes"]) if (r[k] !== undefined) positiveInt(r[k] as number, k);
74
+ if (r.judge !== undefined && typeof r.judge !== "string") throw new Error(`judge must be a judge id (a string), got ${JSON.stringify(r.judge)}`);
75
+ return r as RunRequest;
76
+ }
77
+
78
+ /** Resolves and checks a request against the project at `root`. Throws on anything unknown, so no batch is started. */
79
+ export function planBatch(req: RunRequest, root = projectRoot()): BatchPlan {
80
+ parseRunRequest(req);
81
+ if (req.tasks && req.suites) throw new Error("give --tasks or --suites, not both");
82
+ let taskIds: string[];
83
+ if (req.tasks) {
84
+ taskIds = [...new Set(req.tasks)];
85
+ const unknown = taskIds.filter((id) => !existsSync(join(root, "evals", id, "task.yaml")));
86
+ if (!taskIds.length || unknown.length) throw new Error(`unknown or no task(s): ${unknown.join(", ") || "none given"}`);
87
+ } else {
88
+ const suites = Object.fromEntries(loadSuites(root).map((s) => [s.id, s]));
89
+ const suiteIds = req.suites ?? Object.keys(suites);
90
+ const unknown = suiteIds.filter((id) => !suites[id]);
91
+ if (!suiteIds.length || unknown.length) throw new Error(`unknown or no suite(s): ${unknown.join(", ") || "none given"}`);
92
+ taskIds = [...new Set(suiteIds.flatMap((s) => suites[s].tasks))];
93
+ }
94
+ const all = loadConfigs(root);
95
+ const byId = Object.fromEntries(all.map((c) => [c.id, c]));
96
+ const configIds = !req.configs || req.configs.includes("all") ? all.map((c) => c.id) : [...new Set(req.configs)];
97
+ const missing = configIds.filter((id) => !byId[id]);
98
+ if (!configIds.length || missing.length) throw new Error(`unknown or no config(s): ${missing.join(", ") || "none given"}`);
99
+ const judges = loadJudges(root);
100
+ const judge = req.judge ? judges.find((j) => j.id === req.judge) : judges[0];
101
+ if (!judge) throw new Error(`unknown judge ${req.judge ?? "(judges.yaml lists none)"}`);
102
+ const trials = positiveInt(req.trials ?? 3, "trials");
103
+ const votes = positiveInt(req.votes ?? DEFAULT_VOTES, "votes");
104
+ const tasks = taskIds.map((id) => loadTask(id, root)); // throws on an invalid task
105
+ return {
106
+ // Neither --tasks nor --suites asked for every suite: recorded as suites ["all"].
107
+ request: { ...(req.tasks ? { tasks: req.tasks } : { suites: req.suites ?? ["all"] }), configs: configIds, trials, judge: judge.id, votes, taskIds },
108
+ tasks, configs: configIds.map((id) => byId[id]), judge, votes, trials,
109
+ };
110
+ }
111
+
112
+ // ---- Starting: the batch record, its hashes and run conditions, and every trial queued ------------
113
+
114
+ /** What running and grading the trials of one batch needs. */
115
+ export interface BatchContext {
116
+ batchId: string;
117
+ dbPath: string; // trial folders are beside the database
118
+ tasks: Record<string, { task: Task; taskHash: string; gradingHash: string }>;
119
+ configs: Record<string, AgentConfig>;
120
+ judge: JudgeConfig;
121
+ votes: number;
122
+ setupHash: string | null; // the user setup agent trials run with
123
+ blockedTools: Partial<Record<AgentHarness, string[]>>;
124
+ harnessVersions: Record<string, string | null>;
125
+ cacheDir: string; // Profile snapshots and the apkit lock
126
+ }
127
+
128
+ export interface StartOptions {
129
+ dbPath: string;
130
+ root?: string;
131
+ cacheDir?: string;
132
+ /** Reads `<harness> --version`; tests replace it. */
133
+ readVersion?: (harness: AgentHarness) => Promise<string | null>;
134
+ /** The user setup file list (path → hash); tests replace it. */
135
+ userSetup?: () => Record<string, string>;
136
+ now?: () => Date;
137
+ }
138
+
139
+ export interface StartedBatch { batch: BatchRecord; trials: TrialRecord[]; ctx: BatchContext }
140
+
141
+ /** UTC YYYYMMDD-HHMMSS. */
142
+ export const batchStamp = (d: Date) => d.toISOString().replace(/[-:]/g, "").replace(/\..+/, "").replace("T", "-");
143
+
144
+ /** The first line `<harness> --version` prints, or null when it cannot be read. */
145
+ export async function readHarnessVersion(harness: AgentHarness): Promise<string | null> {
146
+ try {
147
+ const p = Bun.spawn([harness, "--version"], { stdin: "ignore", stdout: "pipe", stderr: "pipe" });
148
+ const timer = setTimeout(() => p.kill(), VERSION_TIMEOUT_MS);
149
+ const [out, code] = await Promise.all([new Response(p.stdout).text(), p.exited]);
150
+ clearTimeout(timer);
151
+ const line = out.split("\n").map((l) => l.trim()).find(Boolean);
152
+ return code === 0 && line ? line : null;
153
+ } catch {
154
+ return null; // not installed
155
+ }
156
+ }
157
+
158
+ /** Each agent harness's CLI version, read once. */
159
+ export async function harnessVersions(harnesses: AgentHarness[], read = readHarnessVersion): Promise<Record<string, string | null>> {
160
+ const unique = [...new Set(harnesses)].sort();
161
+ return Object.fromEntries(await Promise.all(unique.map(async (h) => [h, await read(h)] as const)));
162
+ }
163
+
164
+ /** A human wrote one file per task, so it gets one trial, and only for tasks it has a file for. */
165
+ const trialCount = (c: AgentConfig, taskId: string, n: number) => (c.harness !== "human" ? n : c.outputs?.[taskId] ? 1 : 0);
166
+
167
+ /**
168
+ * Records a new batch in one transaction: the batch (id = UTC timestamp, with -2, -3, ... when a batch
169
+ * started in the same second has it), each task version and grading setup when first seen, the user setup,
170
+ * and every planned trial as queued. Reads the harness versions and the user setup first.
171
+ */
172
+ export async function startBatch(store: Store, plan: BatchPlan, o: StartOptions): Promise<StartedBatch> {
173
+ const root = o.root ?? projectRoot();
174
+ const now = o.now ?? (() => new Date());
175
+ const agentHarnesses = plan.configs.flatMap((c) => (c.harness === "human" ? [] : [c.harness]));
176
+ const versions = await harnessVersions(agentHarnesses, o.readVersion);
177
+ const files = agentHarnesses.length ? (o.userSetup ?? userSetupFiles)() : null;
178
+ const setupHash = files ? sha256(canonicalJson(files)) : null;
179
+ const settings = loadSettings(root);
180
+ const ids = plan.tasks.map((task) => ({ task, t: taskIdentity(task), g: gradingIdentity(task, plan.judge) }));
181
+
182
+ const started = now();
183
+ const at = started.toISOString();
184
+ const { batch, trials } = store.transaction(() => {
185
+ const stamp = batchStamp(started);
186
+ let id = stamp;
187
+ for (let k = 2; store.getBatch(id); k++) id = `${stamp}-${k}`;
188
+ const batch: BatchRecord = { id, startedAt: at, endedAt: null, request: plan.request, harnessVersions: versions };
189
+ store.createBatch(batch);
190
+ store.startBatchRun(id);
191
+ for (const { task, t, g } of ids) {
192
+ store.putTaskVersion({ hash: t.hash, taskId: task.id, label: task.version ?? null, snapshot: t.snapshot as unknown as Record<string, unknown>, firstSeen: at });
193
+ store.putGradingSetup({
194
+ hash: g.hash, taskId: task.id, taskHash: t.hash,
195
+ judgeHarness: plan.judge.harness, judgeModel: plan.judge.model, judgeEffort: plan.judge.effort ?? null, judgeLabel: plan.judge.label,
196
+ snapshot: g.snapshot as unknown as Record<string, unknown>, firstSeen: at,
197
+ });
198
+ }
199
+ if (files && setupHash) store.putUserSetup({ hash: setupHash, files });
200
+ const trials: TrialRecord[] = [];
201
+ for (const { task, t } of ids) for (const c of plan.configs) for (let n = 1; n <= trialCount(c, task.id, plan.trials); n++) {
202
+ const tid = `${id}--${task.id}--${c.id}--${n}`;
203
+ const r: TrialRecord = {
204
+ id: tid, batchId: id, taskId: task.id, taskHash: t.hash, configKey: configKey(c), configId: c.id,
205
+ harness: c.harness, model: c.model, effort: c.effort ?? null, profile: c.profile ?? null, provider: c.provider ?? null,
206
+ family: c.family ?? familyOf(c.model), n, status: "queued",
207
+ startedAt: null, endedAt: null, durationMs: null,
208
+ inputTokens: null, outputTokens: null, cacheReadTokens: null, cacheWriteTokens: null, costUsd: null, costSource: null, turns: null,
209
+ setupHash: null, profileHash: null, blockedTools: null, harnessVersion: null, copiedShare: null, error: null,
210
+ dir: `trials/${tid}`,
211
+ };
212
+ store.insertTrial(r);
213
+ trials.push(r);
214
+ }
215
+ return { batch, trials };
216
+ });
217
+
218
+ const ctx: BatchContext = {
219
+ batchId: batch.id, dbPath: o.dbPath,
220
+ tasks: Object.fromEntries(ids.map(({ task, t, g }) => [task.id, { task, taskHash: t.hash, gradingHash: g.hash }])),
221
+ configs: Object.fromEntries(plan.configs.map((c) => [c.id, c])),
222
+ judge: plan.judge, votes: plan.votes, setupHash, blockedTools: settings.blockedTools, harnessVersions: versions,
223
+ cacheDir: o.cacheDir ?? projectPaths(root).cache,
224
+ };
225
+ return { batch, trials, ctx };
226
+ }
227
+
228
+ // ---- Running a batch ------------------------------------------------------------------------------
229
+
230
+ export interface RunHooks { concurrency?: number; onEvent?: (msg: string) => void }
231
+
232
+ /**
233
+ * Runs every trial of a started batch and grades each one that ends done. Then this run of the batch is over: the
234
+ * batch ends unless another run of it (a rerun, in this process or another) is still in flight, or a trial this
235
+ * run did not start is still queued or running.
236
+ */
237
+ export async function runBatch(store: Store, started: StartedBatch, o: RunHooks = {}) {
238
+ const log = o.onEvent ?? (() => {});
239
+ const { ctx } = started;
240
+ const queue = started.trials.map((t) => t.id);
241
+ const worker = async () => {
242
+ for (let id = queue.shift(); id; id = queue.shift()) {
243
+ try {
244
+ const t = await runTrial(store, ctx, id, log);
245
+ if (t.status !== "done") continue;
246
+ const { task, gradingHash } = ctx.tasks[t.taskId];
247
+ const g = await gradeTrial(store, ctx.dbPath, t, { task, judge: ctx.judge, votes: ctx.votes, gradingHash });
248
+ log(`${g.status === "done" ? "✓" : "!"} ${t.id} score ${g.score?.toFixed(1) ?? "—"}${g.error ? ` (${g.error})` : ""} cost $${t.costUsd?.toFixed(3) ?? "?"}`);
249
+ } catch (e) {
250
+ log(`✗ ${id}: ${e instanceof Error ? e.message : String(e)}`); // a store error: the other trials go on
251
+ }
252
+ }
253
+ };
254
+ await Promise.all(Array.from({ length: Math.max(1, o.concurrency ?? 4) }, worker));
255
+ // Another run of this batch may still run or grade its trials, here or in another process: then the last one
256
+ // to finish ends it. A run whose process died is never finished, so its batch stays not ended.
257
+ const mine = new Set(started.trials.map((t) => t.id));
258
+ const ended = store.transaction(() => {
259
+ const others = store.finishBatchRun(ctx.batchId);
260
+ const busy = others > 0 || store.listTrials({ batchId: ctx.batchId }).some((t) => !mine.has(t.id) && (t.status === "queued" || t.status === "running"));
261
+ if (!busy) store.endBatch(ctx.batchId, new Date().toISOString());
262
+ return !busy;
263
+ });
264
+ log(ended ? `batch ${ctx.batchId} done` : `batch ${ctx.batchId}: these trials are done and graded; another run of it is still running or grading trials`);
265
+ }
266
+
267
+ const message = (e: unknown) => (e instanceof Error ? e.message : String(e));
268
+
269
+ /**
270
+ * Runs one queued trial: workspace, agent run (or the human's file), held-out check, artifacts saved, then
271
+ * the row ends once as done, failed (the harness could not run) or invalid (copied the held-out original).
272
+ * It never grades. Returns the ended row.
273
+ */
274
+ export async function runTrial(store: Store, ctx: BatchContext, trialId: string, log: (m: string) => void = () => {}): Promise<TrialRecord> {
275
+ // Claim it: queued -> running is a compare-and-set in one write transaction (BEGIN IMMEDIATE), so two
276
+ // callers, in this process or another, can never both start the same trial.
277
+ const { trial, task, config, human } = store.transaction(() => {
278
+ const trial = store.getTrial(trialId);
279
+ if (!trial) throw new Error(`no trial ${trialId}`);
280
+ if (trial.status !== "queued") throw new Error(`trial ${trialId} is ${trial.status}; only a queued trial can run`);
281
+ const entry = ctx.tasks[trial.taskId], config = ctx.configs[trial.configId];
282
+ if (!entry || !config) throw new Error(`trial ${trialId}: its task or config is not part of this batch`);
283
+ const human = config.harness === "human";
284
+ // Run conditions. A human trial had no agent setup, Profile, blocked tools or harness.
285
+ store.updateTrial(trial.id, {
286
+ status: "running", startedAt: new Date().toISOString(),
287
+ setupHash: human ? null : ctx.setupHash,
288
+ blockedTools: human ? null : (ctx.blockedTools[config.harness as AgentHarness] ?? []),
289
+ harnessVersion: human ? null : (ctx.harnessVersions[config.harness] ?? null),
290
+ });
291
+ return { trial, task: entry.task, config, human };
292
+ });
293
+ const dir = trialDir(ctx.dbPath, trial.id);
294
+ let ws = ""; // a new folder in the OS temp folder, so no two trials (or two stores) share one
295
+ log(`▶ ${trial.id}`);
296
+
297
+ const end: Partial<TrialRecord> = {};
298
+ let status: "done" | "failed" | "invalid";
299
+ let error: string | null = null;
300
+ try {
301
+ ws = mkdtempSync(join(tmpdir(), "static-fire-trial-"));
302
+ mkdirSync(join(ws, "out"), { recursive: true });
303
+ mkdirSync(dir, { recursive: true });
304
+ fillWorkspace(task, trial.taskHash, ws);
305
+ if (config.profile && !human) {
306
+ const snap = await profileSnapshot(config.profile, config.harness as AgentHarness, ctx.batchId, ctx.cacheDir);
307
+ end.profileHash = folderHash(snap);
308
+ cpSync(snap, ws, { recursive: true });
309
+ }
310
+
311
+ const t0 = Date.now();
312
+ const result: AgentResult = human
313
+ ? copyHumanOutput(config, trial.taskId, join(ws, task.output))
314
+ : await adapters[config.harness as AgentHarness]({
315
+ config, prompt: task.prompt, cwd: ws, logPath: join(dir, "agent.log"), timeoutMs: AGENT_TIMEOUT_MS,
316
+ guard: { blockedTools: ctx.blockedTools[config.harness as AgentHarness] ?? [] },
317
+ });
318
+ if (!human) end.durationMs = Date.now() - t0;
319
+ Object.assign(end, {
320
+ inputTokens: result.usage.inputTokens, outputTokens: result.usage.outputTokens,
321
+ cacheReadTokens: result.usage.cacheReadTokens, cacheWriteTokens: result.usage.cacheWriteTokens,
322
+ costUsd: result.costUsd, costSource: result.costSource, turns: result.turns ?? null,
323
+ });
324
+
325
+ const outPath = join(ws, task.output);
326
+ const ran = result.ok || (result.turns ?? 0) > 0 || existsSync(outPath);
327
+ if (!ran) throw new Error(result.error ?? "the harness did not run");
328
+ if (!result.ok) error = result.error ?? null;
329
+ // An agent that found the published original (for example on the web) and copied it did not do the work.
330
+ if (task.heldOut && !human) {
331
+ end.copiedShare = existsSync(outPath) ? copiedShare(readFileSync(outPath, "utf8"), readFileSync(join(task.dir, task.heldOut), "utf8")) : 0;
332
+ }
333
+ if ((end.copiedShare ?? 0) > COPY_LIMIT) {
334
+ status = "invalid";
335
+ error = `copied the held-out original (${Math.round(end.copiedShare! * 100)}% of its 8-word runs)`;
336
+ } else status = "done";
337
+ } catch (e) {
338
+ status = "failed";
339
+ error = message(e);
340
+ }
341
+
342
+ // The artifacts are saved before the row ends, so an ended trial always has them.
343
+ try {
344
+ if (ws && existsSync(ws)) {
345
+ cpSync(ws, join(dir, "workspace"), { recursive: true });
346
+ rmSync(ws, { recursive: true, force: true });
347
+ }
348
+ } catch (e) {
349
+ if (status === "done") { status = "failed"; error = `could not save the workspace: ${message(e)}`; }
350
+ }
351
+
352
+ error = error?.trim() || null; // harness messages often end with a newline
353
+ store.updateTrial(trial.id, { ...end, status, error, endedAt: new Date().toISOString() });
354
+ if (status !== "done") log(`${status === "invalid" ? "!" : "✗"} ${trial.id}: ${error}`);
355
+ return store.getTrial(trial.id)!;
356
+ }
357
+
358
+ /**
359
+ * Copies the task's inputs and project into the workspace, first to a private folder whose files are hashed:
360
+ * when they no longer give the trial's recorded task hash (a file changed after the batch started), it throws
361
+ * and the workspace stays empty. The workspace gets the copy that was hashed, so the record names what the agent got.
362
+ */
363
+ function fillWorkspace(task: Task, want: string, ws: string) {
364
+ const stage = mkdtempSync(join(tmpdir(), "static-fire-task-"));
365
+ try {
366
+ cpSync(join(task.dir, task.inputs), join(stage, task.inputs), { recursive: true });
367
+ if (task.project) cpSync(join(task.dir, task.project), join(stage, task.project), { recursive: true });
368
+ const now = taskIdentity({ ...task, dir: stage }).hash;
369
+ if (now !== want) {
370
+ throw new Error(`task ${task.id}: its files changed after the batch started (task hash ${want.slice(0, 8)} then, ${now.slice(0, 8)} now), `
371
+ + "so this trial did not run. Run the task again in a new batch.");
372
+ }
373
+ cpSync(join(stage, task.inputs), join(ws, task.inputs), { recursive: true });
374
+ if (task.project) cpSync(join(stage, task.project), ws, { recursive: true });
375
+ } finally {
376
+ rmSync(stage, { recursive: true, force: true });
377
+ }
378
+ }
379
+
380
+ export function copyHumanOutput(config: AgentConfig, taskId: string, out: string): AgentResult {
381
+ const src = config.outputs?.[taskId]?.path;
382
+ const none = { usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }, costUsd: null, costSource: "unknown" as const };
383
+ if (!src || !existsSync(src)) return { ok: false, error: `no file for ${taskId} at ${src ?? "(none)"}`, ...none };
384
+ mkdirSync(join(out, ".."), { recursive: true });
385
+ cpSync(src, out);
386
+ return { ok: true, ...none };
387
+ }
388
+
389
+ // ---- Grading one trial ----------------------------------------------------------------------------
390
+
391
+ /** One grading setup: the task and judge whose grading hash it is, and the vote count. */
392
+ export interface GradingSetup { task: Task; judge: JudgeConfig; votes: number; gradingHash: string }
393
+
394
+ /**
395
+ * Grades a done trial's saved output under one grading setup, with no agent run: adds a grading row, runs the
396
+ * graders on a hashed private copy of the task folder with judge logs in the trial's gradings/<grading-id>/
397
+ * folder, and finishes the row with its verdicts (invalid when a grader returned an error, or when the copy
398
+ * does not give the grading hash). Never changes the trial row or an earlier grading.
399
+ */
400
+ export async function gradeTrial(store: Store, dbPath: string, trial: TrialRecord, s: GradingSetup): Promise<GradingRecord> {
401
+ if (trial.status !== "done") throw new Error(`trial ${trial.id} is ${trial.status}; only a done trial is graded`);
402
+ const gradingId = store.startGrading({ trialId: trial.id, gradingHash: s.gradingHash, votes: s.votes, gradedAt: new Date().toISOString() });
403
+ const dir = trialDir(dbPath, trial.id);
404
+ const ws = join(dir, "workspace");
405
+ const logs = join(dir, "gradings", String(gradingId));
406
+ let result: GradingResult;
407
+ // The grader reads a private copy of the task folder, made now and hashed: when it no longer gives the
408
+ // recorded grading hash, nothing is graded under it (the row ends invalid, and a regrade grades the trial
409
+ // under the current hash). An edit to the task folder while grading runs does not reach the grader.
410
+ let stage = "";
411
+ try {
412
+ mkdirSync(logs, { recursive: true });
413
+ stage = mkdtempSync(join(tmpdir(), "static-fire-grading-"));
414
+ cpSync(s.task.dir, stage, { recursive: true });
415
+ const task: Task = { ...s.task, dir: stage };
416
+ const now = gradingIdentity(task, s.judge).hash;
417
+ if (now !== s.gradingHash) {
418
+ throw new Error(`task ${s.task.id}: its grading files changed after this grading was planned (grading hash ${s.gradingHash.slice(0, 8)} then, `
419
+ + `${now.slice(0, 8)} now), so it was not graded. Regrade to grade it under the current files.`);
420
+ }
421
+ const g = await grade({ task, outputPath: join(ws, s.task.output), workspace: ws, judge: { ...s.judge, votes: s.votes }, dir: logs });
422
+ result = {
423
+ status: g.errors.length ? "invalid" : "done",
424
+ score: g.score, gatesPassed: g.gatesPassed, measures: g.measures, judgeCostUsd: g.judgeCostUsd,
425
+ error: g.errors.length ? `grader errors: ${g.errors.join(", ")}` : null,
426
+ verdicts: Object.entries(g.verdicts).map(([criterionId, v]) => ({ criterionId, score: v.score, error: v.error ?? null, votes: v.votes })),
427
+ };
428
+ } catch (e) {
429
+ result = { status: "invalid", score: null, gatesPassed: null, measures: null, judgeCostUsd: null, error: `grading failed: ${message(e)}`, verdicts: [] };
430
+ } finally {
431
+ if (stage) rmSync(stage, { recursive: true, force: true });
432
+ }
433
+ store.finishGrading(gradingId, result);
434
+ return store.listGradings(trial.id, s.gradingHash).find((g) => g.id === gradingId)!;
435
+ }
@@ -0,0 +1,221 @@
1
+ // Batches after they start: what `list` prints for each one, and `rerun <batch>`, which runs a batch's failed
2
+ // trials again in the same batch.
3
+ //
4
+ // A failed trial's row never changes. A rerun adds a new trial with the next free n for that task and config.
5
+ // Which failed trials still need a rerun is worked out from the rows, with no link column:
6
+ // - the batch asked for `want` trials of each task and config (request.trials for an agent, 1 for a human);
7
+ // - every trial that did not fail (queued, running, done, invalid) fills one of them;
8
+ // - each later trial replaces the earliest failed trial not yet replaced, so the failed trials still to rerun
9
+ // are the latest ones. A trial that failed and then passed on a rerun is never offered again.
10
+ // A queued or running trial fills its place and is never touched: the store holds no evidence that its process
11
+ // died (no pid, no heartbeat), and a long time alone is not proof (a laptop that sleeps keeps a live run going).
12
+ import { existsSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { harnessVersions, type StartOptions, type StartedBatch, type BatchContext } from "./batch";
15
+ import type { BatchRecord, Store, TrialRecord, TrialRecordStatus } from "./db";
16
+ import { canonicalJson, configKey, gradingIdentity, sha256, taskIdentity } from "./identity";
17
+ import { loadSettings, userSetupFiles } from "./settings";
18
+ import { loadConfigs, loadJudges, loadTask } from "./authored";
19
+ import { projectPaths, projectRoot } from "./project";
20
+ import type { AgentConfig, JudgeConfig, Task } from "./types";
21
+
22
+ /** What the batch stored as its request (batch.ts BatchPlan.request). */
23
+ export interface BatchRequest {
24
+ tasks?: string[]; suites?: string[]; configs: string[]; trials: number; judge: string; votes: number; taskIds: string[];
25
+ }
26
+
27
+ export interface BatchSummary {
28
+ batch: BatchRecord;
29
+ request: BatchRequest;
30
+ counts: Record<TrialRecordStatus, number>;
31
+ /** Failed trials that no later trial has replaced yet: what `rerun` would run. */
32
+ toRerun: number;
33
+ ended: boolean;
34
+ }
35
+
36
+ /** One failed trial still to rerun. */
37
+ export interface RerunSlot { taskId: string; configId: string; replaces: string }
38
+
39
+ const ENDED_OK: TrialRecordStatus[] = ["queued", "running", "done", "invalid"]; // every status but failed fills a place
40
+
41
+ /** The failed trials of a batch that no later trial replaced, oldest task/config first. */
42
+ export function rerunSlots(request: BatchRequest, trials: TrialRecord[]): RerunSlot[] {
43
+ const groups = new Map<string, TrialRecord[]>();
44
+ for (const t of trials) {
45
+ const k = `${t.taskId}\0${t.configId}`;
46
+ groups.set(k, [...(groups.get(k) ?? []), t]);
47
+ }
48
+ const slots: RerunSlot[] = [];
49
+ for (const group of groups.values()) {
50
+ const want = group[0]!.harness === "human" ? 1 : request.trials;
51
+ const filled = group.filter((t) => ENDED_OK.includes(t.status)).length;
52
+ const failed = group.filter((t) => t.status === "failed").sort((a, b) => a.n - b.n);
53
+ const short = Math.min(Math.max(0, want - filled), failed.length);
54
+ for (const t of failed.slice(failed.length - short)) slots.push({ taskId: t.taskId, configId: t.configId, replaces: t.id });
55
+ }
56
+ return slots.sort((a, b) => (a.replaces < b.replaces ? -1 : a.replaces > b.replaces ? 1 : 0));
57
+ }
58
+
59
+ export function summarizeBatch(store: Store, batch: BatchRecord): BatchSummary {
60
+ const trials = store.listTrials({ batchId: batch.id });
61
+ const counts: Record<TrialRecordStatus, number> = { queued: 0, running: 0, done: 0, failed: 0, invalid: 0 };
62
+ for (const t of trials) counts[t.status]++;
63
+ const request = batch.request as unknown as BatchRequest;
64
+ return { batch, request, counts, toRerun: rerunSlots(request, trials).length, ended: batch.endedAt !== null };
65
+ }
66
+
67
+ /** Every batch, newest first. */
68
+ export const listBatchSummaries = (store: Store): BatchSummary[] => store.listBatches().map((b) => summarizeBatch(store, b));
69
+
70
+ // ---- rerun ----------------------------------------------------------------------------------------
71
+
72
+ /** `rerun` will not run this batch's failed trials as they were: something they ran with has changed or is gone. */
73
+ export class RerunRefused extends Error {}
74
+
75
+ export interface RerunPlan {
76
+ batch: BatchRecord;
77
+ request: BatchRequest;
78
+ slots: RerunSlot[];
79
+ /** Trials still queued or running: never rerun, never changed. */
80
+ busy: string[];
81
+ tasks: Record<string, { task: Task; taskHash: string }>;
82
+ configs: Record<string, AgentConfig>;
83
+ judge: JudgeConfig | null;
84
+ }
85
+
86
+ const short = (h: string) => h.slice(0, 8);
87
+
88
+ /**
89
+ * Works out what `rerun <batch>` would run and checks it against today's files. Writes nothing. Throws
90
+ * RerunRefused when a task's hash or a config's key differs from what the batch ran with, or the batch's judge
91
+ * is gone. With no failed trial to rerun, returns no slots and checks nothing.
92
+ */
93
+ export function planRerun(store: Store, batchId: string, root = projectRoot()): RerunPlan {
94
+ const batch = store.getBatch(batchId);
95
+ if (!batch) throw new RerunRefused(`no batch ${batchId}`);
96
+ const request = batch.request as unknown as BatchRequest;
97
+ const trials = store.listTrials({ batchId });
98
+ const slots = rerunSlots(request, trials);
99
+ const busy = trials.filter((t) => t.status === "queued" || t.status === "running").map((t) => t.id);
100
+ const plan: RerunPlan = { batch, request, slots, busy, tasks: {}, configs: {}, judge: null };
101
+ if (!slots.length) return plan;
102
+
103
+ const byId = new Map(trials.map((t) => [t.id, t]));
104
+ const configIds = [...new Set(slots.map((s) => s.configId))];
105
+ const useRun = (taskIds: string[]) =>
106
+ `Use \`static-fire run --tasks ${taskIds.join(",")} --configs ${configIds.join(",")}\` to run it on the current files.`;
107
+ for (const taskId of [...new Set(slots.map((s) => s.taskId))]) {
108
+ const was = byId.get(slots.find((s) => s.taskId === taskId)!.replaces)!.taskHash;
109
+ if (!existsSync(join(root, "evals", taskId, "task.yaml"))) {
110
+ throw new RerunRefused(`task ${taskId} is gone since batch ${batchId} (no evals/${taskId}/task.yaml), so its failed trials cannot run again as they were.`);
111
+ }
112
+ let task: Task;
113
+ try { task = loadTask(taskId, root); } catch (e) {
114
+ throw new RerunRefused(`task ${taskId} cannot be read: ${e instanceof Error ? e.message : String(e)}`);
115
+ }
116
+ const now = taskIdentity(task).hash;
117
+ if (now !== was) {
118
+ throw new RerunRefused(`task ${taskId} changed since batch ${batchId} (task hash ${short(was)} then, ${short(now)} now). `
119
+ + `A rerun keeps a batch on the task version it ran. ${useRun([taskId])}`);
120
+ }
121
+ plan.tasks[taskId] = { task, taskHash: now };
122
+ }
123
+ const configs = new Map(loadConfigs(root).map((c) => [c.id, c]));
124
+ const taskIds = Object.keys(plan.tasks);
125
+ for (const configId of configIds) {
126
+ const was = byId.get(slots.find((s) => s.configId === configId)!.replaces)!.configKey;
127
+ const c = configs.get(configId);
128
+ if (!c) throw new RerunRefused(`config ${configId} is gone since batch ${batchId} (no configs/${configId}.yaml). ${useRun(taskIds)}`);
129
+ if (configKey(c) !== was) {
130
+ throw new RerunRefused(`config ${configId} changed since batch ${batchId} (${was} then, ${configKey(c)} now). `
131
+ + `A rerun uses the configs the batch recorded. ${useRun(taskIds)}`);
132
+ }
133
+ plan.configs[configId] = c;
134
+ }
135
+ const judge = loadJudges(root).find((j) => j.id === request.judge);
136
+ if (!judge) throw new RerunRefused(`judge ${request.judge}, which batch ${batchId} used, is no longer in judges.yaml. ${useRun(taskIds)}`);
137
+ plan.judge = judge;
138
+ return plan;
139
+ }
140
+
141
+ export interface StartedRerun extends StartedBatch {
142
+ /** New trial id → the failed trial it replaces. */
143
+ replaces: Record<string, string>;
144
+ }
145
+
146
+ /**
147
+ * Adds one queued trial per slot to the batch, with the next free n for its task and config, counts this rerun
148
+ * as a run of the batch in flight (which clears its end time), and builds the BatchContext to run them (pass the
149
+ * result to runBatch). The slots are worked out again inside one write transaction, so two reruns of one batch at the same time never both add a
150
+ * trial for the same failure; one that finds no slot left throws and writes nothing.
151
+ * Run conditions (user setup, blocked tools, harness version) are today's, as each trial records its own.
152
+ */
153
+ export async function startRerun(store: Store, plan: RerunPlan, o: StartOptions): Promise<StartedRerun> {
154
+ const root = o.root ?? projectRoot();
155
+ const judge = plan.judge;
156
+ if (!judge || !plan.slots.length) throw new Error(`batch ${plan.batch.id}: nothing to rerun`);
157
+ const configs = Object.values(plan.configs);
158
+ const agentHarnesses = configs.flatMap((c) => (c.harness === "human" ? [] : [c.harness]));
159
+ const versions = await harnessVersions(agentHarnesses, o.readVersion);
160
+ const files = agentHarnesses.length ? (o.userSetup ?? userSetupFiles)() : null;
161
+ const setupHash = files ? sha256(canonicalJson(files)) : null;
162
+ const settings = loadSettings(root);
163
+ const grading = Object.fromEntries(Object.entries(plan.tasks).map(([id, { task }]) => [id, gradingIdentity(task, judge)]));
164
+ const at = (o.now ?? (() => new Date()))().toISOString();
165
+
166
+ const { trials, replaces } = store.transaction(() => {
167
+ const rows = store.listTrials({ batchId: plan.batch.id });
168
+ const slots = rerunSlots(plan.request, rows).filter((s) => plan.tasks[s.taskId] && plan.configs[s.configId]);
169
+ // Another rerun took them since the plan: nothing is written, and no runBatch follows to end the batch.
170
+ if (!slots.length) throw new Error(`batch ${plan.batch.id}: nothing to rerun`);
171
+ for (const [id, { task, taskHash }] of Object.entries(plan.tasks)) {
172
+ const g = grading[id]!;
173
+ store.putTaskVersion({ hash: taskHash, taskId: id, label: task.version ?? null, snapshot: taskIdentity(task).snapshot as unknown as Record<string, unknown>, firstSeen: at });
174
+ store.putGradingSetup({
175
+ hash: g.hash, taskId: id, taskHash,
176
+ judgeHarness: judge.harness, judgeModel: judge.model, judgeEffort: judge.effort ?? null, judgeLabel: judge.label,
177
+ snapshot: g.snapshot as unknown as Record<string, unknown>, firstSeen: at,
178
+ });
179
+ }
180
+ if (files && setupHash) store.putUserSetup({ hash: setupHash, files });
181
+ const next = new Map<string, number>();
182
+ for (const t of rows) {
183
+ const k = `${t.taskId}\0${t.configId}`;
184
+ next.set(k, Math.max(next.get(k) ?? 1, t.n + 1));
185
+ }
186
+ const byId = new Map(rows.map((t) => [t.id, t]));
187
+ const trials: TrialRecord[] = [];
188
+ const replaces: Record<string, string> = {};
189
+ for (const s of slots) {
190
+ const k = `${s.taskId}\0${s.configId}`;
191
+ const n = next.get(k)!;
192
+ next.set(k, n + 1);
193
+ const failed = byId.get(s.replaces)!;
194
+ const id = `${plan.batch.id}--${s.taskId}--${s.configId}--${n}`;
195
+ const r: TrialRecord = {
196
+ id, batchId: plan.batch.id, taskId: s.taskId, taskHash: failed.taskHash, configKey: failed.configKey, configId: s.configId,
197
+ harness: failed.harness, model: failed.model, effort: failed.effort, profile: failed.profile, provider: failed.provider,
198
+ family: failed.family, n, status: "queued",
199
+ startedAt: null, endedAt: null, durationMs: null,
200
+ inputTokens: null, outputTokens: null, cacheReadTokens: null, cacheWriteTokens: null, costUsd: null, costSource: null, turns: null,
201
+ setupHash: null, profileHash: null, blockedTools: null, harnessVersion: null, copiedShare: null, error: null,
202
+ dir: `trials/${id}`,
203
+ };
204
+ store.insertTrial(r);
205
+ trials.push(r);
206
+ replaces[id] = s.replaces;
207
+ }
208
+ store.startBatchRun(plan.batch.id); // not ended while this run's trials run and are graded; runBatch ends it again
209
+ return { trials, replaces };
210
+ });
211
+
212
+ const ctx: BatchContext = {
213
+ batchId: plan.batch.id, dbPath: o.dbPath,
214
+ tasks: Object.fromEntries(Object.entries(plan.tasks).map(([id, { task, taskHash }]) => [id, { task, taskHash, gradingHash: grading[id]!.hash }])),
215
+ configs: plan.configs,
216
+ judge, votes: plan.request.votes, setupHash, blockedTools: settings.blockedTools, harnessVersions: versions,
217
+ cacheDir: o.cacheDir ?? projectPaths(root).cache,
218
+ };
219
+ return { batch: plan.batch, trials, ctx, replaces };
220
+ }
221
+