static-fire 0.0.0-stage → 0.54.1

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 (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +236 -2
  3. package/bin/static-fire.js +46 -0
  4. package/package.json +30 -4
  5. package/pricing.yaml +20 -0
  6. package/src/adapters/index.ts +153 -0
  7. package/src/api.ts +250 -0
  8. package/src/cli.ts +383 -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 +450 -0
  13. package/src/core/batches.ts +221 -0
  14. package/src/core/checks.ts +63 -0
  15. package/src/core/commands.ts +43 -0
  16. package/src/core/compare.ts +206 -0
  17. package/src/core/config.ts +25 -0
  18. package/src/core/copying.ts +18 -0
  19. package/src/core/coverage.ts +84 -0
  20. package/src/core/db.ts +608 -0
  21. package/src/core/derived.ts +74 -0
  22. package/src/core/engine.ts +85 -0
  23. package/src/core/facets.ts +143 -0
  24. package/src/core/grade.ts +197 -0
  25. package/src/core/grading-versions.ts +281 -0
  26. package/src/core/identity.ts +263 -0
  27. package/src/core/init.ts +321 -0
  28. package/src/core/judge-schema.ts +37 -0
  29. package/src/core/judge.ts +166 -0
  30. package/src/core/label.ts +43 -0
  31. package/src/core/lock.ts +40 -0
  32. package/src/core/migrate.ts +288 -0
  33. package/src/core/new-task.ts +85 -0
  34. package/src/core/outside-tools.ts +129 -0
  35. package/src/core/plan.ts +84 -0
  36. package/src/core/profiles.ts +42 -0
  37. package/src/core/project.ts +140 -0
  38. package/src/core/regrade.ts +242 -0
  39. package/src/core/score.ts +148 -0
  40. package/src/core/settings.ts +44 -0
  41. package/src/core/task-file.ts +95 -0
  42. package/src/core/task-page.ts +54 -0
  43. package/src/core/trial-detail.ts +73 -0
  44. package/src/core/types.ts +195 -0
  45. package/src/core/view-query.ts +102 -0
  46. package/src/core/view.ts +576 -0
  47. package/src/guide.md +624 -0
  48. package/src/server.ts +28 -0
  49. package/web/api.ts +6 -0
  50. package/web/app.tsx +1162 -0
  51. package/web/brand/offset-tokens.css +126 -0
  52. package/web/charts.tsx +190 -0
  53. package/web/compare.tsx +317 -0
  54. package/web/encoding.tsx +55 -0
  55. package/web/filter-menu.ts +195 -0
  56. package/web/how-it-works.tsx +92 -0
  57. package/web/index-switches.ts +29 -0
  58. package/web/index.html +17 -0
  59. package/web/levels.tsx +15 -0
  60. package/web/notices.ts +74 -0
  61. package/web/output-view.ts +61 -0
  62. package/web/pages.ts +42 -0
  63. package/web/run-status.ts +51 -0
  64. package/web/styles.css +368 -0
  65. package/web/task-card.ts +6 -0
  66. package/web/tasks.tsx +122 -0
  67. package/web/wording.ts +38 -0
@@ -0,0 +1,47 @@
1
+ // Builds one Profile snapshot in a process of its own, which holds the apkit lock for the whole sequence:
2
+ // install into a scratch folder, copy it to the snapshot, remove the install. The `run` that starts it
3
+ // (profiles.ts) waits for it. If that `run` is killed, this process goes on to the end of the sequence and only
4
+ // then lets go of the lock, so another install never overlaps a running one.
5
+ // bun src/core/apkit-install.ts <lock file> <profile> <apkit agent> <snapshot folder>
6
+ import { cpSync, existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { basename, dirname, join } from "node:path";
9
+ import { withFileLock } from "./lock";
10
+
11
+ async function apkit(args: string[]) {
12
+ const p = Bun.spawn(["apkit", ...args], { stdout: "pipe", stderr: "pipe", stdin: "ignore" });
13
+ const [out, err, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited]);
14
+ if (code !== 0) throw new Error(`apkit ${args[0]} failed (exit ${code}): ${(err || out).trim().slice(-400)}`);
15
+ }
16
+
17
+ async function build(profile: string, agent: string, snap: string) {
18
+ if (existsSync(snap)) return;
19
+ const scratch = join(tmpdir(), "static-fire-apkit", `${process.pid}`, basename(dirname(snap)), basename(snap));
20
+ rmSync(scratch, { recursive: true, force: true });
21
+ mkdirSync(scratch, { recursive: true });
22
+ try {
23
+ // --yes: apkit asks before creating AGENTS.md; this scratch folder is ours.
24
+ await apkit(["install", profile, scratch, "--agents", agent, "--yes"]);
25
+ // Copied beside the snapshot, then renamed: a snapshot folder is always complete.
26
+ const part = `${snap}.part`;
27
+ rmSync(part, { recursive: true, force: true });
28
+ mkdirSync(part, { recursive: true });
29
+ cpSync(scratch, part, { recursive: true, dereference: true });
30
+ renameSync(part, snap);
31
+ } finally {
32
+ await apkit(["remove", scratch, "--yes"]).catch(() => {});
33
+ rmSync(scratch, { recursive: true, force: true });
34
+ }
35
+ }
36
+
37
+ const [lock, profile, agent, snap] = Bun.argv.slice(2);
38
+ if (!lock || !profile || !agent || !snap) {
39
+ console.error("usage: apkit-install.ts <lock file> <profile> <apkit agent> <snapshot folder>");
40
+ process.exit(2);
41
+ }
42
+ try {
43
+ await withFileLock(lock, () => build(profile, agent, snap));
44
+ } catch (e) {
45
+ console.error(e instanceof Error ? e.message : String(e));
46
+ process.exit(1);
47
+ }
@@ -0,0 +1,47 @@
1
+ // The authored files, read as YAML (records are in the SQLite record store, src/core/db.ts):
2
+ // configs/<id>.yaml · judges.yaml · suites/<id>.yaml · evals/<task>/task.yaml
3
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { normalizeConfig } from "./config";
6
+ import { projectRoot } from "./project";
7
+ import { validateTask } from "./task-file";
8
+ import type { AgentConfig, JudgeConfig, Suite, Task } from "./types";
9
+
10
+ const readYaml = (p: string) => Bun.YAML.parse(readFileSync(p, "utf8")) as any;
11
+ const yamlIds = (dir: string) => (existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith(".yaml")).map((f) => f.slice(0, -5)).sort() : []);
12
+
13
+ // Each loader reads from the project root (`root`: configs/, judges.yaml, suites/, evals/). The default is the
14
+ // project found from the current folder (project.ts); it throws outside a project. Tests pass a temp project.
15
+ export function loadConfigs(root = projectRoot()): AgentConfig[] {
16
+ return yamlIds(join(root, "configs")).map((id) => normalizeConfig(id, readYaml(join(root, "configs", `${id}.yaml`)), root));
17
+ }
18
+
19
+ export function loadJudges(root = projectRoot()): JudgeConfig[] {
20
+ return (readYaml(join(root, "judges.yaml")).judges ?? []) as JudgeConfig[];
21
+ }
22
+
23
+ export function loadSuites(root = projectRoot()): Suite[] {
24
+ return yamlIds(join(root, "suites")).map((id) => ({ id, ...readYaml(join(root, "suites", `${id}.yaml`)) }));
25
+ }
26
+
27
+ export interface TaskListing { id: string; title: string; description: string; errors: string[]; warnings: string[] }
28
+
29
+ /** Loads and validates a task from `<root>/evals/<id>/task.yaml`. Throws when the file has errors. */
30
+ export function loadTask(id: string, root = projectRoot()): Task & { warnings: string[] } {
31
+ const dir = join(root, "evals", id);
32
+ const raw = readYaml(join(dir, "task.yaml"));
33
+ const { errors, warnings } = validateTask(raw, dir);
34
+ if (errors.length) throw new Error(`task ${id} is invalid:\n- ${errors.join("\n- ")}`);
35
+ // YAML `derived:` and `derived: null` mean no derived evidence: drop the key here, so the grader sees one form.
36
+ for (const c of [...(raw.gates ?? []), ...raw.measures.flatMap((m: any) => m.criteria)]) if (c.derived == null) delete c.derived;
37
+ return { gates: [], ...raw, dir, warnings };
38
+ }
39
+
40
+ export function listTasks(root = projectRoot()): TaskListing[] {
41
+ const dir = join(root, "evals");
42
+ return readdirSync(dir).filter((d) => existsSync(join(dir, d, "task.yaml"))).map((id) => {
43
+ const raw = readYaml(join(dir, id, "task.yaml"));
44
+ const v = validateTask(raw, join(dir, id));
45
+ return { id, title: raw.title ?? id, description: raw.description ?? "", ...v };
46
+ });
47
+ }
@@ -0,0 +1,25 @@
1
+ // Whether a batch is still running, for the batches panel. A batch whose process died never gets its end
2
+ // time, so "not ended" alone does not mean "running": a batch also needs a trial that moved recently, or
3
+ // this server process running it.
4
+ import type { BatchRecord, TrialRecord } from "./db";
5
+
6
+ /** The agent timeout (20 min) plus a margin: a long agent trial or a slow grading is not called stopped. */
7
+ export const STALLED_AFTER_MS = 25 * 60_000;
8
+
9
+ export type BatchRunState = "running" | "stalled" | "ended";
10
+
11
+ export interface BatchState {
12
+ total: number;
13
+ /** The latest start or end time of its trials; the batch start when no trial has moved. */
14
+ lastMoveAt: string;
15
+ /** ended: it has its end time. running: this server runs it, or a trial moved in the last 25 min. stalled: neither; its process may have stopped. */
16
+ state: BatchRunState;
17
+ }
18
+
19
+ export function batchState(batch: BatchRecord, trials: TrialRecord[], o: { live: boolean; now: Date }): BatchState {
20
+ let lastMoveAt = batch.startedAt;
21
+ for (const t of trials) for (const at of [t.startedAt, t.endedAt]) if (at && at > lastMoveAt) lastMoveAt = at;
22
+ const state: BatchRunState = batch.endedAt != null ? "ended"
23
+ : o.live || o.now.getTime() - Date.parse(lastMoveAt) < STALLED_AFTER_MS ? "running" : "stalled";
24
+ return { total: trials.length, lastMoveAt, state };
25
+ }
@@ -0,0 +1,450 @@
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
+ /** One trial a batch starts: its task, config and number. */
168
+ export interface PlannedTrial { task: Task; config: AgentConfig; n: number }
169
+
170
+ /**
171
+ * Every trial of a planned batch, in the order startBatch queues them: each task, each config, n = 1..trials (one
172
+ * for a human config with a file for the task, none without). startBatch inserts this list, and the plan in runs
173
+ * (plan.ts) counts it, so the counts are what starts.
174
+ */
175
+ export function plannedTrials(plan: Pick<BatchPlan, "tasks" | "configs" | "trials">): PlannedTrial[] {
176
+ return plan.tasks.flatMap((task) => plan.configs.flatMap((config) =>
177
+ Array.from({ length: trialCount(config, task.id, plan.trials) }, (_, i) => ({ task, config, n: i + 1 }))));
178
+ }
179
+
180
+ /**
181
+ * Records a new batch in one transaction: the batch (id = UTC timestamp, with -2, -3, ... when a batch
182
+ * started in the same second has it), each task version and grading setup when first seen, the user setup,
183
+ * and every planned trial as queued. Reads the harness versions and the user setup first.
184
+ */
185
+ export async function startBatch(store: Store, plan: BatchPlan, o: StartOptions): Promise<StartedBatch> {
186
+ const root = o.root ?? projectRoot();
187
+ const now = o.now ?? (() => new Date());
188
+ const agentHarnesses = plan.configs.flatMap((c) => (c.harness === "human" ? [] : [c.harness]));
189
+ const versions = await harnessVersions(agentHarnesses, o.readVersion);
190
+ const files = agentHarnesses.length ? (o.userSetup ?? userSetupFiles)() : null;
191
+ const setupHash = files ? sha256(canonicalJson(files)) : null;
192
+ const settings = loadSettings(root);
193
+ const ids = plan.tasks.map((task) => ({ task, t: taskIdentity(task), g: gradingIdentity(task, plan.judge) }));
194
+
195
+ const started = now();
196
+ const at = started.toISOString();
197
+ const { batch, trials } = store.transaction(() => {
198
+ const stamp = batchStamp(started);
199
+ let id = stamp;
200
+ for (let k = 2; store.getBatch(id); k++) id = `${stamp}-${k}`;
201
+ const batch: BatchRecord = { id, startedAt: at, endedAt: null, request: plan.request, harnessVersions: versions };
202
+ store.createBatch(batch);
203
+ store.startBatchRun(id);
204
+ for (const { task, t, g } of ids) {
205
+ store.putTaskVersion({ hash: t.hash, taskId: task.id, label: task.version ?? null, snapshot: t.snapshot as unknown as Record<string, unknown>, firstSeen: at });
206
+ store.putGradingSetup({
207
+ hash: g.hash, taskId: task.id, taskHash: t.hash,
208
+ judgeHarness: plan.judge.harness, judgeModel: plan.judge.model, judgeEffort: plan.judge.effort ?? null, judgeLabel: plan.judge.label,
209
+ snapshot: g.snapshot as unknown as Record<string, unknown>, firstSeen: at,
210
+ });
211
+ }
212
+ if (files && setupHash) store.putUserSetup({ hash: setupHash, files });
213
+ const trials: TrialRecord[] = [];
214
+ const hashes = new Map(ids.map(({ task, t }) => [task.id, t]));
215
+ for (const { task, config: c, n } of plannedTrials(plan)) {
216
+ const t = hashes.get(task.id)!;
217
+ const tid = `${id}--${task.id}--${c.id}--${n}`;
218
+ const r: TrialRecord = {
219
+ id: tid, batchId: id, taskId: task.id, taskHash: t.hash, configKey: configKey(c), configId: c.id,
220
+ harness: c.harness, model: c.model, effort: c.effort ?? null, profile: c.profile ?? null, provider: c.provider ?? null,
221
+ family: c.family ?? familyOf(c.model), n, status: "queued",
222
+ startedAt: null, endedAt: null, durationMs: null,
223
+ inputTokens: null, outputTokens: null, cacheReadTokens: null, cacheWriteTokens: null, costUsd: null, costSource: null, turns: null,
224
+ setupHash: null, profileHash: null, blockedTools: null, harnessVersion: null, copiedShare: null, error: null,
225
+ dir: `trials/${tid}`,
226
+ };
227
+ store.insertTrial(r);
228
+ trials.push(r);
229
+ }
230
+ return { batch, trials };
231
+ });
232
+
233
+ const ctx: BatchContext = {
234
+ batchId: batch.id, dbPath: o.dbPath,
235
+ tasks: Object.fromEntries(ids.map(({ task, t, g }) => [task.id, { task, taskHash: t.hash, gradingHash: g.hash }])),
236
+ configs: Object.fromEntries(plan.configs.map((c) => [c.id, c])),
237
+ judge: plan.judge, votes: plan.votes, setupHash, blockedTools: settings.blockedTools, harnessVersions: versions,
238
+ cacheDir: o.cacheDir ?? projectPaths(root).cache,
239
+ };
240
+ return { batch, trials, ctx };
241
+ }
242
+
243
+ // ---- Running a batch ------------------------------------------------------------------------------
244
+
245
+ export interface RunHooks { concurrency?: number; onEvent?: (msg: string) => void }
246
+
247
+ /**
248
+ * Runs every trial of a started batch and grades each one that ends done. Then this run of the batch is over: the
249
+ * batch ends unless another run of it (a rerun, in this process or another) is still in flight, or a trial this
250
+ * run did not start is still queued or running.
251
+ */
252
+ export async function runBatch(store: Store, started: StartedBatch, o: RunHooks = {}) {
253
+ const log = o.onEvent ?? (() => {});
254
+ const { ctx } = started;
255
+ const queue = started.trials.map((t) => t.id);
256
+ const worker = async () => {
257
+ for (let id = queue.shift(); id; id = queue.shift()) {
258
+ try {
259
+ const t = await runTrial(store, ctx, id, log);
260
+ if (t.status !== "done") continue;
261
+ const { task, gradingHash } = ctx.tasks[t.taskId];
262
+ const g = await gradeTrial(store, ctx.dbPath, t, { task, judge: ctx.judge, votes: ctx.votes, gradingHash });
263
+ log(`${g.status === "done" ? "✓" : "!"} ${t.id} score ${g.score?.toFixed(1) ?? "—"}${g.error ? ` (${g.error})` : ""} cost $${t.costUsd?.toFixed(3) ?? "?"}`);
264
+ } catch (e) {
265
+ log(`✗ ${id}: ${e instanceof Error ? e.message : String(e)}`); // a store error: the other trials go on
266
+ }
267
+ }
268
+ };
269
+ await Promise.all(Array.from({ length: Math.max(1, o.concurrency ?? 4) }, worker));
270
+ // Another run of this batch may still run or grade its trials, here or in another process: then the last one
271
+ // to finish ends it. A run whose process died is never finished, so its batch stays not ended.
272
+ const mine = new Set(started.trials.map((t) => t.id));
273
+ const ended = store.transaction(() => {
274
+ const others = store.finishBatchRun(ctx.batchId);
275
+ const busy = others > 0 || store.listTrials({ batchId: ctx.batchId }).some((t) => !mine.has(t.id) && (t.status === "queued" || t.status === "running"));
276
+ if (!busy) store.endBatch(ctx.batchId, new Date().toISOString());
277
+ return !busy;
278
+ });
279
+ 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`);
280
+ }
281
+
282
+ const message = (e: unknown) => (e instanceof Error ? e.message : String(e));
283
+
284
+ /**
285
+ * Runs one queued trial: workspace, agent run (or the human's file), held-out check, artifacts saved, then
286
+ * the row ends once as done, failed (the harness could not run) or invalid (copied the held-out original).
287
+ * It never grades. Returns the ended row.
288
+ */
289
+ export async function runTrial(store: Store, ctx: BatchContext, trialId: string, log: (m: string) => void = () => {}): Promise<TrialRecord> {
290
+ // Claim it: queued -> running is a compare-and-set in one write transaction (BEGIN IMMEDIATE), so two
291
+ // callers, in this process or another, can never both start the same trial.
292
+ const { trial, task, config, human } = store.transaction(() => {
293
+ const trial = store.getTrial(trialId);
294
+ if (!trial) throw new Error(`no trial ${trialId}`);
295
+ if (trial.status !== "queued") throw new Error(`trial ${trialId} is ${trial.status}; only a queued trial can run`);
296
+ const entry = ctx.tasks[trial.taskId], config = ctx.configs[trial.configId];
297
+ if (!entry || !config) throw new Error(`trial ${trialId}: its task or config is not part of this batch`);
298
+ const human = config.harness === "human";
299
+ // Run conditions. A human trial had no agent setup, Profile, blocked tools or harness.
300
+ store.updateTrial(trial.id, {
301
+ status: "running", startedAt: new Date().toISOString(),
302
+ setupHash: human ? null : ctx.setupHash,
303
+ blockedTools: human ? null : (ctx.blockedTools[config.harness as AgentHarness] ?? []),
304
+ harnessVersion: human ? null : (ctx.harnessVersions[config.harness] ?? null),
305
+ });
306
+ return { trial, task: entry.task, config, human };
307
+ });
308
+ const dir = trialDir(ctx.dbPath, trial.id);
309
+ let ws = ""; // a new folder in the OS temp folder, so no two trials (or two stores) share one
310
+ log(`▶ ${trial.id}`);
311
+
312
+ const end: Partial<TrialRecord> = {};
313
+ let status: "done" | "failed" | "invalid";
314
+ let error: string | null = null;
315
+ try {
316
+ ws = mkdtempSync(join(tmpdir(), "static-fire-trial-"));
317
+ mkdirSync(join(ws, "out"), { recursive: true });
318
+ mkdirSync(dir, { recursive: true });
319
+ fillWorkspace(task, trial.taskHash, ws);
320
+ if (config.profile && !human) {
321
+ const snap = await profileSnapshot(config.profile, config.harness as AgentHarness, ctx.batchId, ctx.cacheDir);
322
+ end.profileHash = folderHash(snap);
323
+ cpSync(snap, ws, { recursive: true });
324
+ }
325
+
326
+ const t0 = Date.now();
327
+ const result: AgentResult = human
328
+ ? copyHumanOutput(config, trial.taskId, join(ws, task.output))
329
+ : await adapters[config.harness as AgentHarness]({
330
+ config, prompt: task.prompt, cwd: ws, logPath: join(dir, "agent.log"), timeoutMs: AGENT_TIMEOUT_MS,
331
+ guard: { blockedTools: ctx.blockedTools[config.harness as AgentHarness] ?? [] },
332
+ });
333
+ if (!human) end.durationMs = Date.now() - t0;
334
+ Object.assign(end, {
335
+ inputTokens: result.usage.inputTokens, outputTokens: result.usage.outputTokens,
336
+ cacheReadTokens: result.usage.cacheReadTokens, cacheWriteTokens: result.usage.cacheWriteTokens,
337
+ costUsd: result.costUsd, costSource: result.costSource, turns: result.turns ?? null,
338
+ });
339
+
340
+ const outPath = join(ws, task.output);
341
+ const ran = result.ok || (result.turns ?? 0) > 0 || existsSync(outPath);
342
+ if (!ran) throw new Error(result.error ?? "the harness did not run");
343
+ if (!result.ok) error = result.error ?? null;
344
+ // An agent that found the published original (for example on the web) and copied it did not do the work.
345
+ if (task.heldOut && !human) {
346
+ end.copiedShare = existsSync(outPath) ? copiedShare(readFileSync(outPath, "utf8"), readFileSync(join(task.dir, task.heldOut), "utf8")) : 0;
347
+ }
348
+ if ((end.copiedShare ?? 0) > COPY_LIMIT) {
349
+ status = "invalid";
350
+ error = `copied the held-out original (${Math.round(end.copiedShare! * 100)}% of its 8-word runs)`;
351
+ } else status = "done";
352
+ } catch (e) {
353
+ status = "failed";
354
+ error = message(e);
355
+ }
356
+
357
+ // The artifacts are saved before the row ends, so an ended trial always has them.
358
+ try {
359
+ if (ws && existsSync(ws)) {
360
+ cpSync(ws, join(dir, "workspace"), { recursive: true });
361
+ rmSync(ws, { recursive: true, force: true });
362
+ }
363
+ } catch (e) {
364
+ if (status === "done") { status = "failed"; error = `could not save the workspace: ${message(e)}`; }
365
+ }
366
+
367
+ error = error?.trim() || null; // harness messages often end with a newline
368
+ store.updateTrial(trial.id, { ...end, status, error, endedAt: new Date().toISOString() });
369
+ if (status !== "done") log(`${status === "invalid" ? "!" : "✗"} ${trial.id}: ${error}`);
370
+ return store.getTrial(trial.id)!;
371
+ }
372
+
373
+ /**
374
+ * Copies the task's inputs and project into the workspace, first to a private folder whose files are hashed:
375
+ * when they no longer give the trial's recorded task hash (a file changed after the batch started), it throws
376
+ * and the workspace stays empty. The workspace gets the copy that was hashed, so the record names what the agent got.
377
+ */
378
+ function fillWorkspace(task: Task, want: string, ws: string) {
379
+ const stage = mkdtempSync(join(tmpdir(), "static-fire-task-"));
380
+ try {
381
+ cpSync(join(task.dir, task.inputs), join(stage, task.inputs), { recursive: true });
382
+ if (task.project) cpSync(join(task.dir, task.project), join(stage, task.project), { recursive: true });
383
+ const now = taskIdentity({ ...task, dir: stage }).hash;
384
+ if (now !== want) {
385
+ 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), `
386
+ + "so this trial did not run. Run the task again in a new batch.");
387
+ }
388
+ cpSync(join(stage, task.inputs), join(ws, task.inputs), { recursive: true });
389
+ if (task.project) cpSync(join(stage, task.project), ws, { recursive: true });
390
+ } finally {
391
+ rmSync(stage, { recursive: true, force: true });
392
+ }
393
+ }
394
+
395
+ export function copyHumanOutput(config: AgentConfig, taskId: string, out: string): AgentResult {
396
+ const src = config.outputs?.[taskId]?.path;
397
+ const none = { usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }, costUsd: null, costSource: "unknown" as const };
398
+ if (!src || !existsSync(src)) return { ok: false, error: `no file for ${taskId} at ${src ?? "(none)"}`, ...none };
399
+ mkdirSync(join(out, ".."), { recursive: true });
400
+ cpSync(src, out);
401
+ return { ok: true, ...none };
402
+ }
403
+
404
+ // ---- Grading one trial ----------------------------------------------------------------------------
405
+
406
+ /** One grading setup: the task and judge whose grading hash it is, and the vote count. */
407
+ export interface GradingSetup { task: Task; judge: JudgeConfig; votes: number; gradingHash: string }
408
+
409
+ /**
410
+ * Grades a done trial's saved output under one grading setup, with no agent run: adds a grading row, runs the
411
+ * graders on a hashed private copy of the task folder with judge logs in the trial's gradings/<grading-id>/
412
+ * folder, and finishes the row with its verdicts (invalid when a grader returned an error, or when the copy
413
+ * does not give the grading hash). Never changes the trial row or an earlier grading.
414
+ */
415
+ export async function gradeTrial(store: Store, dbPath: string, trial: TrialRecord, s: GradingSetup): Promise<GradingRecord> {
416
+ if (trial.status !== "done") throw new Error(`trial ${trial.id} is ${trial.status}; only a done trial is graded`);
417
+ const gradingId = store.startGrading({ trialId: trial.id, gradingHash: s.gradingHash, votes: s.votes, gradedAt: new Date().toISOString() });
418
+ const dir = trialDir(dbPath, trial.id);
419
+ const ws = join(dir, "workspace");
420
+ const logs = join(dir, "gradings", String(gradingId));
421
+ let result: GradingResult;
422
+ // The grader reads a private copy of the task folder, made now and hashed: when it no longer gives the
423
+ // recorded grading hash, nothing is graded under it (the row ends invalid, and a regrade grades the trial
424
+ // under the current hash). An edit to the task folder while grading runs does not reach the grader.
425
+ let stage = "";
426
+ try {
427
+ mkdirSync(logs, { recursive: true });
428
+ stage = mkdtempSync(join(tmpdir(), "static-fire-grading-"));
429
+ cpSync(s.task.dir, stage, { recursive: true });
430
+ const task: Task = { ...s.task, dir: stage };
431
+ const now = gradingIdentity(task, s.judge).hash;
432
+ if (now !== s.gradingHash) {
433
+ throw new Error(`task ${s.task.id}: its grading files changed after this grading was planned (grading hash ${s.gradingHash.slice(0, 8)} then, `
434
+ + `${now.slice(0, 8)} now), so it was not graded. Regrade to grade it under the current files.`);
435
+ }
436
+ const g = await grade({ task, outputPath: join(ws, s.task.output), workspace: ws, judge: { ...s.judge, votes: s.votes }, dir: logs });
437
+ result = {
438
+ status: g.errors.length ? "invalid" : "done",
439
+ score: g.score, gatesPassed: g.gatesPassed, measures: g.measures, judgeCostUsd: g.judgeCostUsd,
440
+ error: g.errors.length ? `grader errors: ${g.errors.join(", ")}` : null,
441
+ verdicts: Object.entries(g.verdicts).map(([criterionId, v]) => ({ criterionId, score: v.score, error: v.error ?? null, votes: v.votes })),
442
+ };
443
+ } catch (e) {
444
+ result = { status: "invalid", score: null, gatesPassed: null, measures: null, judgeCostUsd: null, error: `grading failed: ${message(e)}`, verdicts: [] };
445
+ } finally {
446
+ if (stage) rmSync(stage, { recursive: true, force: true });
447
+ }
448
+ store.finishGrading(gradingId, result);
449
+ return store.listGradings(trial.id, s.gradingHash).find((g) => g.id === gradingId)!;
450
+ }