@lchase/kiln 0.8.0 → 0.10.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.
package/dist/driver.js CHANGED
@@ -4,14 +4,20 @@ import { closeSync, existsSync, linkSync, mkdirSync, openSync, readdirSync, read
4
4
  import { basename, dirname, isAbsolute, join } from "node:path";
5
5
  import { claudeCredentialPreflight } from "./auth.js";
6
6
  import { buildAttention } from "./attention.js";
7
+ import { attemptsFor, fixRoundsFor } from "./config.js";
7
8
  import { clearLaunchError, deliverJob, plannedRemoteBranch, readDelivery, readLaunchError, resolveLaunchBase, writeDelivery, writeLaunchError, isMerged, } from "./delivery.js";
8
9
  import { feedbackSpec, openFeedbackRound, planFeedback, readFeedbackLedger, writeFeedbackLedger, } from "./feedback.js";
10
+ import { appendEvent, readEvents } from "./events.js";
11
+ import { costFromJournal } from "./failure.js";
12
+ import { ensureFixTrailers } from "./fix-commit.js";
13
+ import { holdDetail, pausedFrom, recordDailyCap, reviseNotesSinceRun, spendSnapshot, stopReasonFromEvents, withReviseNotes, } from "./rails.js";
9
14
  import { appendInvestigationFindings, existingFindingsPath, findingsFilePath, INVESTIGATION_WORKFLOW, readFindingsMarkdown, writeFindings, } from "./findings.js";
10
15
  import { defaultHost } from "./host.js";
11
16
  import { readJobSpec, resolveJobSpec } from "./jobs.js";
12
17
  import { jobBasename, packageRoot } from "./paths.js";
13
18
  import { decide, isLanded, jobState, } from "./reconcile.js";
14
19
  import { fileMtimeMs, isPidAlive, readAttemptLedger, recoverRuns, resetAttemptLedger, } from "./reliability.js";
20
+ import { projectRunJournal, promptsHash } from "./project.js";
15
21
  import { gateAnswerCommand, renderStatus, renderTick, statusView, yakCliPath, sandboxFailureDetail, } from "./report.js";
16
22
  import { AGENT_SANDBOX_IMAGE, injectAgentSandbox, jobIsSandboxed, probeSandboxEnvironment, PROXY_SANDBOX_IMAGE, requiredSandboxImages, resolveSandbox, sandboxIsEnabled, sandboxPreflight, sandboxPreflightFor, } from "./sandbox.js";
17
23
  import { JobSchema } from "./schemas.js";
@@ -40,16 +46,19 @@ export function runTick(config, host = defaultHost(), options = {}) {
40
46
  const startup = options.startup ?? true;
41
47
  const now = options.now ?? new Date();
42
48
  return withTickLock(config.kilnDir, () => {
43
- const notes = recoverRuns(config, observe(config, host).observation.jobs, { startup, now });
49
+ const notes = recoverRuns(config, observe(config, host, now).observation.jobs, { startup, now });
44
50
  const facts = sandboxIsEnabled(config)
45
51
  ? (options.sandboxFacts ?? probeSandboxEnvironment(process.env, requiredSandboxImages(config)))
46
52
  : undefined;
47
- const midway = observe(config, host);
53
+ const midway = observe(config, host, now);
48
54
  bindOpenFixRuns(config, midway);
49
55
  const published = publishReadyFixes(config, host, midway);
50
- const world = observe(config, host);
56
+ const world = observe(config, host, now);
51
57
  const feedbackNotes = watchPullRequests(config, world, host);
52
58
  const actions = decide(world.observation, now);
59
+ if (world.observation.spendBlocked) {
60
+ recordDailyCap(config.kilnDir, spendSnapshot(readEvents(config.kilnDir, { warn: () => { } }), config.dailyDollarCap, now), now);
61
+ }
53
62
  const body = apply(config, world, actions, host, notes, now, [...published, ...feedbackNotes], facts);
54
63
  const heads = [
55
64
  claudeCredentialPreflight(process.env, config.adapter),
@@ -61,7 +70,7 @@ export function runTick(config, host = defaultHost(), options = {}) {
61
70
  /** Startup pass shared by `kiln tick` (via `runTick`) and `kiln serve`. Idempotent. */
62
71
  export function startupRecovery(config, host = defaultHost(), now = new Date()) {
63
72
  withTickLock(config.kilnDir, () => {
64
- recoverRuns(config, observe(config, host).observation.jobs, { startup: true, now });
73
+ recoverRuns(config, observe(config, host, now).observation.jobs, { startup: true, now });
65
74
  });
66
75
  }
67
76
  export function answerJob(config, jobId, choice, host = defaultHost()) {
@@ -87,6 +96,20 @@ export function answerJob(config, jobId, choice, host = defaultHost()) {
87
96
  if (result.status !== 0) {
88
97
  throw new Error(output.trim() || `Could not ${choice} job ${jobId}.`);
89
98
  }
99
+ const noted = gateEdit(job.run.checkout, job.run.runId, gate.stepId);
100
+ appendEvent(config.kilnDir, {
101
+ job_id: jobId,
102
+ run_id: job.run.runId,
103
+ event: "gate.resolved",
104
+ step: gate.stepId,
105
+ properties: {
106
+ decision: choice,
107
+ by: "human",
108
+ repo: job.repo,
109
+ ...noted,
110
+ once: `gate.resolved:${gate.stepId}`,
111
+ },
112
+ });
90
113
  if (choice === "approve") {
91
114
  recordInvestigationFindings(config, observe(config, host), jobId);
92
115
  }
@@ -110,8 +133,8 @@ export function retryJob(config, jobId, host = defaultHost()) {
110
133
  if (observed.error)
111
134
  throw new Error(`Job ${jobId} is invalid: ${observed.error}`);
112
135
  const state = jobState(observed, world.observation);
113
- if (state !== "failed" && state !== "dead") {
114
- throw new Error(`Job ${jobId} is ${state}, not failed or dead.`);
136
+ if (!canManualRetry(state, observed)) {
137
+ throw new Error(`Job ${jobId} is ${state}, not failed, dead, or stopped for a person.`);
115
138
  }
116
139
  const job = world.jobs.get(jobId);
117
140
  if (!job)
@@ -120,6 +143,11 @@ export function retryJob(config, jobId, host = defaultHost()) {
120
143
  if (crumb && pidAlive(crumb.pid)) {
121
144
  throw new Error(`Job ${jobId} is already launching.`);
122
145
  }
146
+ appendEvent(config.kilnDir, {
147
+ job_id: jobId,
148
+ event: "job.retried",
149
+ properties: { by: "human", repo: job.repo },
150
+ });
123
151
  const cleared = observed.attemptCount ?? 0;
124
152
  resetAttemptLedger(config.kilnDir, jobId, observed.run?.runId);
125
153
  rmSync(breadcrumbPath(config.kilnDir, jobId), { force: true });
@@ -133,7 +161,7 @@ export function retryJob(config, jobId, host = defaultHost()) {
133
161
  const detail = report.detail.endsWith("\n") || report.detail.length === 0 ? report.detail : `${report.detail}\n`;
134
162
  return `retry ${jobId} ${job.repo}\n${clearedLine}${detail}`;
135
163
  }
136
- if (state === "dead" && observed.run) {
164
+ if ((state === "dead" || observed.stopReason) && observed.run) {
137
165
  try {
138
166
  spawnSync(process.execPath, [yakCliPath(), "cancel", observed.run.runId], {
139
167
  cwd: observed.run.checkout,
@@ -153,6 +181,64 @@ export function retryJob(config, jobId, host = defaultHost()) {
153
181
  return `retry ${report.jobId} ${report.repo}\n${clearedLine}${output}`;
154
182
  });
155
183
  }
184
+ function canManualRetry(state, job) {
185
+ if (state === "failed" || state === "dead")
186
+ return true;
187
+ return state === "needs-human" && Boolean(job.stopReason) && (job.run?.gates.length ?? 0) === 0;
188
+ }
189
+ /**
190
+ * Send a gated job back for another round. The note is stored on a
191
+ * `gate.resolved` event (redacted, otherwise verbatim) and appended to the
192
+ * spec the next launch passes into the prompt.
193
+ */
194
+ export function reviseJob(config, jobId, note, host = defaultHost()) {
195
+ const trimmed = note.trim();
196
+ if (!trimmed)
197
+ throw new Error("A revise note is required.");
198
+ if (trimmed.length > 8_000)
199
+ throw new Error("A revise note must be 8000 characters or fewer.");
200
+ return withTickLock(config.kilnDir, () => {
201
+ const world = observe(config, host);
202
+ const observed = world.observation.jobs.find((candidate) => candidate.jobId === jobId);
203
+ if (!observed)
204
+ throw new Error(`No job ${jobId}.`);
205
+ if (observed.error)
206
+ throw new Error(`Job ${jobId} is invalid: ${observed.error}`);
207
+ const state = jobState(observed, world.observation);
208
+ const gate = observed.run?.gates[0];
209
+ if (state !== "needs-human" || !observed.run || !gate) {
210
+ throw new Error(`Job ${jobId} is ${state}, not waiting on a gate.`);
211
+ }
212
+ const job = world.jobs.get(jobId);
213
+ if (!job)
214
+ throw new Error(`No job ${jobId}.`);
215
+ appendEvent(config.kilnDir, {
216
+ job_id: jobId,
217
+ run_id: observed.run.runId,
218
+ event: "gate.resolved",
219
+ step: gate.stepId,
220
+ properties: {
221
+ decision: "revise",
222
+ by: "human",
223
+ repo: job.repo,
224
+ note: trimmed,
225
+ },
226
+ });
227
+ resetAttemptLedger(config.kilnDir, jobId, observed.run.runId);
228
+ rmSync(breadcrumbPath(config.kilnDir, jobId), { force: true });
229
+ clearLaunchError(config.kilnDir, jobId);
230
+ const events = readEvents(config.kilnDir, { warn: () => { } });
231
+ const spend = spendSnapshot(events, config.dailyDollarCap);
232
+ const paused = pausedFrom(events);
233
+ const hold = holdDetail(paused, spend);
234
+ if (hold)
235
+ return `revised ${jobId}\n${hold}\n`;
236
+ const again = observe(config, host);
237
+ const report = launchJob(config, again, job, host);
238
+ const output = report.output.endsWith("\n") || report.output.length === 0 ? report.output : `${report.output}\n`;
239
+ return `revised ${jobId} ${report.repo}\n${output}`;
240
+ });
241
+ }
156
242
  function shouldRedeliver(job) {
157
243
  if (!isLanded(job))
158
244
  return false;
@@ -164,9 +250,13 @@ function gateForChoice(gates, choice) {
164
250
  return (gates.find((gate) => gate.choices?.includes(choice)) ??
165
251
  gates.find((gate) => !gate.choices?.length));
166
252
  }
167
- function observe(config, host) {
253
+ function observe(config, host, now = new Date()) {
168
254
  const loaded = loadJobs(config);
169
255
  const runs = scanRuns(config);
256
+ const events = readEvents(config.kilnDir, { warn: () => { } });
257
+ const paused = pausedFrom(events);
258
+ const spend = spendSnapshot(events, config.dailyDollarCap, now);
259
+ const waiting = holdDetail(paused, spend);
170
260
  const breadcrumbs = readBreadcrumbs(config.kilnDir);
171
261
  const jobs = [];
172
262
  const records = new Map();
@@ -213,10 +303,13 @@ function observe(config, host) {
213
303
  const launchActivityMs = later(crumb ? fileMtimeMs(crumb.path) : undefined, logPath ? fileMtimeMs(logPath) : undefined);
214
304
  const attemptCount = ledger?.attempts.length ?? 0;
215
305
  const lastFailure = ledger?.attempts.at(-1);
216
- const exhausted = attemptCount >= config.maxAttempts &&
306
+ const attemptCap = attemptsFor(config, entry.job?.repo ?? "");
307
+ const exhausted = attemptCount >= attemptCap &&
217
308
  attemptCount > 0 &&
218
309
  !launching &&
219
310
  (picked.retired || !run || run.status === "dead" || run.status === "failed");
311
+ const eventStop = entry.job ? stopReasonFromEvents(events, entry.job.id) : undefined;
312
+ const stopReason = eventStop ?? (exhausted ? "attempt-cap" : undefined);
220
313
  const specPath = entry.job ? resolveJobSpec(config.kilnDir, entry.job.spec) : undefined;
221
314
  const findingsPath = entry.job?.workflow === INVESTIGATION_WORKFLOW
222
315
  ? existingFindingsPath(config.kilnDir, entry.job.id)
@@ -255,6 +348,8 @@ function observe(config, host) {
255
348
  ...(lastFailure ? { lastFailureReason: lastFailure.reason } : {}),
256
349
  ...(ledger?.nextRetryAt && !exhausted ? { nextRetryAt: ledger.nextRetryAt } : {}),
257
350
  ...(exhausted ? { retriesExhausted: true } : {}),
351
+ ...(stopReason ? { stopReason } : {}),
352
+ attemptCap,
258
353
  ...(picked.retired ? { runRetired: true } : {}),
259
354
  ...(ledgerResult === "corrupt" ? { ledgerError: "attempt ledger is unreadable" } : {}),
260
355
  ...(entry.job ? { sandboxed: jobIsSandboxed(config, entry.job.id, entry.job.workflow) } : {}),
@@ -275,6 +370,9 @@ function observe(config, host) {
275
370
  maxAttempts: config.maxAttempts,
276
371
  jobs,
277
372
  breadcrumbsToClear,
373
+ ...(paused ? { paused: true } : {}),
374
+ ...(spend.blocked ? { spendBlocked: true } : {}),
375
+ ...(waiting ? { holdDetail: waiting } : {}),
278
376
  },
279
377
  jobs: records,
280
378
  };
@@ -417,7 +515,8 @@ function specHandedToYak(config, world, job) {
417
515
  }
418
516
  sections.push({ title: dep.title, markdown: readFileSync(path, "utf8") });
419
517
  }
420
- return appendInvestigationFindings(base, sections);
518
+ const noted = withReviseNotes(appendInvestigationFindings(base, sections), reviseNotesSinceRun(readEvents(config.kilnDir, { warn: () => { } }), job.id));
519
+ return noted;
421
520
  }
422
521
  /** Remote name the stacked pull request targets. Falls back to the local yak ref. */
423
522
  function upstreamBranch(world, job) {
@@ -529,6 +628,15 @@ function publishReadyFixes(config, host, world) {
529
628
  const record = readDelivery(config.kilnDir, job.jobId);
530
629
  const checkout = job.run.checkout;
531
630
  const source = `yak/${job.run.runId}`;
631
+ const worktree = join(checkout, ".yak", "worktrees", job.run.runId);
632
+ if (existsSync(worktree)) {
633
+ ensureFixTrailers(worktree, {
634
+ jobId: job.jobId,
635
+ round: open.round,
636
+ promptsHash: hashForRun(config.kilnDir, job.jobId, job.run.runId),
637
+ title: job.title,
638
+ }, open.headSha);
639
+ }
532
640
  if (!record?.branch) {
533
641
  notes.push(`feedback ${job.jobId} failed: no pull request branch to publish`);
534
642
  continue;
@@ -545,6 +653,7 @@ function publishReadyFixes(config, host, world) {
545
653
  if (sha === open.headSha) {
546
654
  open.outcome = "fixed";
547
655
  writeFeedbackLedger(config.kilnDir, ledger);
656
+ recordFeedbackOutcome(config.kilnDir, job.jobId, job.run.runId, open, "fixed");
548
657
  notes.push(`fixed ${job.jobId} ${sha}`);
549
658
  continue;
550
659
  }
@@ -580,13 +689,62 @@ function publishReadyFixes(config, host, world) {
580
689
  });
581
690
  open.outcome = "pushed";
582
691
  writeFeedbackLedger(config.kilnDir, ledger);
692
+ recordFeedbackOutcome(config.kilnDir, job.jobId, job.run.runId, open, "pushed");
583
693
  notes.push(`pushed ${job.jobId} ${record.branch} ${sha}`);
584
694
  }
585
695
  return notes;
586
696
  }
697
+ function hashForRun(kilnDir, jobId, runId) {
698
+ for (const event of readEvents(kilnDir, { warn: () => { } })) {
699
+ if (event.job_id === jobId && event.run_id === runId && event.event === "run.started") {
700
+ return event.properties.prompts_hash;
701
+ }
702
+ }
703
+ return promptsHash("fix", kilnDir, jobId);
704
+ }
587
705
  function isNonFastForward(message) {
588
706
  return /non-fast-forward|\[rejected\]|failed to push some refs/i.test(message);
589
707
  }
708
+ function recordFeedbackOutcome(kilnDir, jobId, runId, round, outcome) {
709
+ appendEvent(kilnDir, {
710
+ job_id: jobId,
711
+ run_id: runId,
712
+ event: "pr.feedback",
713
+ properties: {
714
+ round: round.round,
715
+ trigger: round.trigger,
716
+ outcome,
717
+ items: round.items.length,
718
+ once: `pr.feedback:${round.round}:${outcome}`,
719
+ },
720
+ });
721
+ }
722
+ /** An edit note the human left on the gate answer, when the answer file has one. */
723
+ function gateEdit(checkout, runId, stepId) {
724
+ const paths = [
725
+ join(checkout, ".runs", runId, "pending", `${stepId}.answer.json`),
726
+ join(checkout, ".runs", runId, "artifacts", "approval.json"),
727
+ ];
728
+ for (const path of paths) {
729
+ if (!existsSync(path))
730
+ continue;
731
+ try {
732
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
733
+ const note = typeof parsed.note === "string" ? parsed.note.trim() : "";
734
+ const edit = typeof parsed.edit === "string" ? parsed.edit.trim() : "";
735
+ if (!note && !edit)
736
+ continue;
737
+ return {
738
+ ...(note ? { note: note.slice(0, 240) } : {}),
739
+ ...(edit ? { edit: edit.slice(0, 240) } : {}),
740
+ };
741
+ }
742
+ catch {
743
+ // An unreadable answer is not an edit note.
744
+ }
745
+ }
746
+ return {};
747
+ }
590
748
  /**
591
749
  * Read each open pull request once per tick and attach a plan.
592
750
  * `readStatus` does not call this. A host with no feedback reader does not watch.
@@ -613,7 +771,7 @@ function watchPullRequests(config, world, host) {
613
771
  continue;
614
772
  }
615
773
  job.feedbackPlan = planFeedback({
616
- maxFixRounds: config.maxFixRounds,
774
+ maxFixRounds: fixRoundsFor(config, job.repo),
617
775
  ledger,
618
776
  snapshot,
619
777
  });
@@ -641,6 +799,11 @@ function markFeedback(kilnDir, jobId, reason) {
641
799
  else
642
800
  ledger.ambiguous = true;
643
801
  writeFeedbackLedger(kilnDir, ledger);
802
+ appendEvent(kilnDir, {
803
+ job_id: jobId,
804
+ event: "needs-human",
805
+ properties: { reason, once: `needs-human:${reason}` },
806
+ });
644
807
  }
645
808
  /**
646
809
  * Open one feedback round and start the fix workflow on the pull request head.
@@ -678,6 +841,16 @@ function startFix(config, world, job, host, now, sandboxFacts) {
678
841
  at: now.toISOString(),
679
842
  });
680
843
  writeFeedbackLedger(config.kilnDir, ledger);
844
+ appendEvent(config.kilnDir, {
845
+ job_id: job.id,
846
+ event: "pr.feedback",
847
+ properties: {
848
+ round,
849
+ trigger: plan.trigger,
850
+ items: plan.items.length,
851
+ once: `pr.feedback:${round}:open`,
852
+ },
853
+ });
681
854
  let specText;
682
855
  try {
683
856
  specText = feedbackSpec(specHandedToYak(config, world, job), plan.items);
@@ -691,7 +864,7 @@ function startFix(config, world, job, host, now, sandboxFacts) {
691
864
  writeBreadcrumb(breadcrumbPath(config.kilnDir, job.id), { jobId: job.id, pid: 0, logPath });
692
865
  return { jobId: job.id, repo: job.repo, kind: "fix", output: message };
693
866
  }
694
- const report = launchFixRun(config, job, host, repo.path, headSha, specText, sandboxFacts);
867
+ const report = launchFixRun(config, job, host, repo.path, headSha, specText, round, sandboxFacts);
695
868
  return { ...report, output: `round ${round}\n${report.output}` };
696
869
  }
697
870
  /** Relaunch the open fix round. Does not append a round. Failures become a dead launch so retry can see them. */
@@ -712,7 +885,7 @@ function launchOpenFix(config, world, job, host, checkout, open, sandboxFacts) {
712
885
  writeBreadcrumb(breadcrumbPath(config.kilnDir, job.id), { jobId: job.id, pid: 0, logPath });
713
886
  return { jobId: job.id, repo: job.repo, kind: "fix", output: message };
714
887
  }
715
- return launchFixRun(config, job, host, checkout, headSha, specText, sandboxFacts);
888
+ return launchFixRun(config, job, host, checkout, headSha, specText, open.round, sandboxFacts);
716
889
  }
717
890
  function resolveCommit(host, checkout, sha) {
718
891
  try {
@@ -724,7 +897,7 @@ function resolveCommit(host, checkout, sha) {
724
897
  }
725
898
  }
726
899
  /** `workflows/fix.yaml` on `headSha`. Commits on `yak/<run-id>`. Does not push. */
727
- function launchFixRun(config, job, host, checkout, headSha, specText, sandboxFacts) {
900
+ function launchFixRun(config, job, host, checkout, headSha, specText, round, sandboxFacts) {
728
901
  const refused = claudeCredentialPreflight(process.env, config.adapter);
729
902
  if (refused)
730
903
  return { jobId: job.id, repo: job.repo, kind: "fix", output: refused };
@@ -736,6 +909,7 @@ function launchFixRun(config, job, host, checkout, headSha, specText, sandboxFac
736
909
  const blocked = commitAgentSandbox(config, job.id, "fix", workflow, logPath, sandboxFacts);
737
910
  if (blocked)
738
911
  return { jobId: job.id, repo: job.repo, kind: "fix", output: blocked };
912
+ const hash = promptsHash("fix", config.kilnDir, job.id);
739
913
  ensureCheckoutExcludes(checkout);
740
914
  const input = workflowInputs(sourceWorkflow, {
741
915
  title: job.title,
@@ -748,6 +922,9 @@ function launchFixRun(config, job, host, checkout, headSha, specText, sandboxFac
748
922
  ...process.env,
749
923
  ...(repo?.test ? { KILN_TEST: repo.test } : {}),
750
924
  KILN_TASK_TITLE: job.title,
925
+ KILN_JOB: job.id,
926
+ KILN_ROUND: String(round),
927
+ KILN_PROMPTS_HASH: hash,
751
928
  KILN_VERIFY_WRAPPER: join(packageRoot(), "dist", "truncate-output.js"),
752
929
  };
753
930
  return spawnLogged(config, host, {
@@ -943,6 +1120,11 @@ function loadJobFile(file) {
943
1120
  return { meta: { jobId: job.id }, job };
944
1121
  }
945
1122
  function scanRuns(config) {
1123
+ const jobs = new Map();
1124
+ for (const entry of loadJobs(config)) {
1125
+ if (entry.job)
1126
+ jobs.set(entry.job.id, entry.job);
1127
+ }
946
1128
  const byTag = new Map();
947
1129
  for (const repo of Object.values(config.repos)) {
948
1130
  for (const runDir of listRunDirs(repo.path)) {
@@ -951,6 +1133,21 @@ function scanRuns(config) {
951
1133
  if (!started?.tag || !parseJobTag(started.tag))
952
1134
  continue;
953
1135
  const runId = basename(runDir);
1136
+ const jobId = parseJobTag(started.tag);
1137
+ if (jobId) {
1138
+ const job = jobs.get(jobId);
1139
+ const testCommand = job ? config.repos[job.repo]?.test : undefined;
1140
+ projectRunJournal({
1141
+ kilnDir: config.kilnDir,
1142
+ jobId,
1143
+ runId,
1144
+ runDir,
1145
+ workflow: job?.workflow ?? "",
1146
+ repo: job?.repo ?? "",
1147
+ ...(testCommand ? { testCommand } : {}),
1148
+ events,
1149
+ });
1150
+ }
954
1151
  const status = classify(events);
955
1152
  const gateDecision = readApproveDecision(runDir);
956
1153
  const title = readRunTitle(runDir);
@@ -958,6 +1155,7 @@ function scanRuns(config) {
958
1155
  const stepPlan = readRunStepPlan(runDir);
959
1156
  const stepEvents = stepEventsFrom(events);
960
1157
  const sandboxError = sandboxFailureDetail(stepEvents);
1158
+ const cost = costFromJournal(events);
961
1159
  const pid = livePid(events);
962
1160
  const activityMs = eventActivityMs(events) ?? fileMtimeMs(join(runDir, "journal.jsonl"));
963
1161
  const finishedReason = finished && typeof finished.reason === "string" ? finished.reason : undefined;
@@ -976,6 +1174,7 @@ function scanRuns(config) {
976
1174
  ...(activityMs !== undefined ? { activityMs } : {}),
977
1175
  ...(finishedReason ? { finishedReason } : {}),
978
1176
  ...(sandboxError ? { sandboxError } : {}),
1177
+ cost,
979
1178
  };
980
1179
  const list = byTag.get(started.tag) ?? [];
981
1180
  list.push(run);