@haiyangbg/buildbeat 2.0.0-beta.4 → 2.0.0-beta.5

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.
@@ -23,6 +23,8 @@ export function initialState() {
23
23
  fingerprints: [],
24
24
  consecutiveSameFailure: 0,
25
25
  budgets: {},
26
+ budgetExtensions: {},
27
+ workReviewGrants: 0,
26
28
  pendingHuman: null,
27
29
  decisions: [],
28
30
  approvals: [],
@@ -107,7 +109,12 @@ export function applyEvent(state, event) {
107
109
  `step ${data.step} expected attempt ${expectedAttempt}, got ${data.attempt}`,
108
110
  );
109
111
  }
110
- next.steps[data.step] = { status: "RUNNING", attempts: data.attempt, detail: null };
112
+ next.steps[data.step] = {
113
+ status: "RUNNING",
114
+ attempts: data.attempt,
115
+ detail: null,
116
+ infraAttempts: state.steps[data.step]?.infraAttempts ?? 0,
117
+ };
111
118
  next.currentStep = data.step;
112
119
  break;
113
120
  }
@@ -120,6 +127,11 @@ export function applyEvent(state, event) {
120
127
  }
121
128
  next.steps[data.step].status = data.status === "succeeded" ? "SUCCEEDED" : "FAILED";
122
129
  next.steps[data.step].detail = data.status;
130
+ if (data.infra === true) {
131
+ // A worker-infrastructure failure (backend outage, timeout, garbage
132
+ // output, exit 75) is not charged to the step's budget.
133
+ next.steps[data.step].infraAttempts = (step.infraAttempts ?? 0) + 1;
134
+ }
123
135
  next.currentStep = null;
124
136
  break;
125
137
  }
@@ -168,6 +180,19 @@ export function applyEvent(state, event) {
168
180
  next.budgets[data.kind] = { consumed: consumed + data.amount, remaining: data.remaining };
169
181
  break;
170
182
  }
183
+ case "BUDGET_EXTENDED": {
184
+ // A human approving resume-<step> after its budget ran out grants
185
+ // exactly one more attempt; the grant is a ledger fact, not a config
186
+ // edit, so the effective cap is replayable.
187
+ if (data.scope === "work") {
188
+ // Work-level review cap (budgets.reviewRoundsPerWork) lifted once by
189
+ // a human for this run.
190
+ next.workReviewGrants = (state.workReviewGrants ?? 0) + data.amount;
191
+ } else {
192
+ next.budgetExtensions[data.step] = (state.budgetExtensions?.[data.step] ?? 0) + data.amount;
193
+ }
194
+ break;
195
+ }
171
196
  case "HUMAN_REQUESTED": {
172
197
  next.pendingHuman = {
173
198
  transition: data.transition,
@@ -194,6 +219,7 @@ export function applyEvent(state, event) {
194
219
  transition: data.transition,
195
220
  subject: data.subject,
196
221
  stale: false,
222
+ ...(data.resumeAt ? { resumeAt: data.resumeAt } : {}),
197
223
  });
198
224
  }
199
225
  next.pendingHuman = null;
@@ -185,6 +185,86 @@ export function approveRun(repoRoot, runId, { by = "human", transition, ts, poli
185
185
  }
186
186
  }
187
187
 
188
+ // A human (or the driving session) supplies the candidate: the fix was made
189
+ // by hand in the run's worktree and committed, so the fixer step has nothing
190
+ // to do. The pending request is answered with the adopted commit as its
191
+ // subject and the run resumes at `resumeAt` (verify, by convention). Real
192
+ // incident: hand fixes inside a run cost a no-op fixer and an extra verify
193
+ // each time (a frontend run reached verify #5 and fix #3 for three hand
194
+ // fixes). The commit must already be the worktree HEAD: git is read back,
195
+ // the claim is not trusted.
196
+ export function adoptCandidate(repoRoot, runId, { sha, by = "human", resumeAt, ts } = {}) {
197
+ if (!sha || typeof sha !== "string" || sha.length < 7) {
198
+ throw new DecisionError("adopt requires a commit sha (at least 7 characters)");
199
+ }
200
+ if (!resumeAt) {
201
+ throw new DecisionError("adopt requires the step to resume at (resumeAt)");
202
+ }
203
+ const ledger = openWaiting(repoRoot, runId);
204
+ const pending = ledger.state.pendingHuman;
205
+ if (pending.kind === "final-decision") {
206
+ throw new DecisionError("adopt is for a run waiting before fix/verify, not at the merge decision");
207
+ }
208
+ acquireLock(repoRoot, runId);
209
+ try {
210
+ const bound = ledger.state.workspaces[runId];
211
+ const worktreePath = bound ? resolveRepoRef(repoRoot, bound.worktreePath) : null;
212
+ if (!bound || !existsSync(worktreePath)) {
213
+ throw new DecisionError(`worktree missing for ${runId}; cannot adopt a candidate`);
214
+ }
215
+ const tree = readback(worktreePath);
216
+ if (tree.dirty) {
217
+ throw new DecisionError("worktree is dirty; commit the hand fix before adopting it");
218
+ }
219
+ if (!tree.head.startsWith(sha)) {
220
+ throw new DecisionError(`worktree HEAD is ${tree.head}, not ${sha}; adopt what git reads back`);
221
+ }
222
+ const when = ts ?? new Date().toISOString();
223
+ const lastEvidence = ledger.state.evidence[ledger.state.evidence.length - 1];
224
+ if (bound.candidate !== tree.head) {
225
+ ledger.append({
226
+ type: "CANDIDATE_PINNED",
227
+ actor: { kind: "human", id: by },
228
+ ts: when,
229
+ data: { workspaceId: runId, base: bound.base, candidate: tree.head, adopted: true },
230
+ });
231
+ }
232
+ const subject = {
233
+ candidate: tree.head,
234
+ planDigest: ledger.state.run.planDigest,
235
+ evidenceDigest: lastEvidence?.digest ?? "UNVERIFIED",
236
+ };
237
+ const decisionRef = `D-${runId}-${ledger.state.decisions.length + 1}`;
238
+ ledger.append({
239
+ type: "DECISION_RECORDED",
240
+ actor: { kind: "human", id: by },
241
+ ts: when,
242
+ data: {
243
+ decision: "approved",
244
+ transition: pending.transition,
245
+ subject,
246
+ decisionRef,
247
+ adopted: tree.head,
248
+ resumeAt,
249
+ },
250
+ });
251
+ recordDecisionFile(repoRoot, ledger.state.run.work, {
252
+ ts: when,
253
+ run: runId,
254
+ decisionRef,
255
+ decision: "approved",
256
+ transition: pending.transition,
257
+ subject,
258
+ by,
259
+ adopted: tree.head,
260
+ resumeAt,
261
+ });
262
+ return { adopted: tree.head, decisionRef, transition: pending.transition, resumeAt, state: ledger.state };
263
+ } finally {
264
+ releaseLock(repoRoot, runId);
265
+ }
266
+ }
267
+
188
268
  // Accepts a work artifact (plan, intent, spec) by binding a decision to the
189
269
  // file's current digest in the Git plane. If the file changes afterwards,
190
270
  // artifact.accepted evaluates false again — acceptance cannot go stale
@@ -26,6 +26,7 @@ import {
26
26
  releaseLock,
27
27
  } from "../workspace/workspace-manager.js";
28
28
  import { writeRunRecord } from "./run-record.js";
29
+ import { computeWorkCost } from "./work-cost.js";
29
30
  import { assertRequires } from "./env-contract.js";
30
31
  import { materialisePrompt } from "./envelope.js";
31
32
  import { cacheKey, findReusableEvidence, lastReviewedCandidate, treeHash } from "./cache.js";
@@ -142,8 +143,18 @@ function makeContext(options, ledger, workspace) {
142
143
  ledger,
143
144
  workspace,
144
145
  };
146
+ // Effective cap per step: run config `budgets:` beats the preset, the
147
+ // preset beats the global default, and every BUDGET_EXTENDED a human
148
+ // granted on this ledger adds to it. Real incident: the preset's two
149
+ // review rounds could not be raised from the run config, and approving
150
+ // resume-review re-asked the same question forever.
151
+ context.runBudgets = options.budgets ?? {};
145
152
  context.maxAttemptsFor = (step) =>
146
- workflow.budgets?.maxAttempts?.[step] ?? maxAttemptsPerStep;
153
+ (context.runBudgets.maxAttempts?.[step] ??
154
+ workflow.budgets?.maxAttempts?.[step] ??
155
+ maxAttemptsPerStep) +
156
+ (ledger.state.budgetExtensions?.[step] ?? 0) +
157
+ (ledger.state.steps[step]?.infraAttempts ?? 0);
147
158
  context.policies = options.policies ?? [];
148
159
  context.allowedPaths = options.allowedPaths ?? null;
149
160
  context.reviewTriage = options.reviewTriage ?? null;
@@ -240,6 +251,7 @@ function settleOutcome(context, step, outcome, tree, exec) {
240
251
  if ((ledger.state.steps[step]?.attempts ?? 0) >= context.maxAttemptsFor(step)) {
241
252
  context.waitHuman(`resume-${step}`, [
242
253
  `budget exhausted: ${step} failed its final attempt (maxAttempts=${context.maxAttemptsFor(step)}); not routing to fix`,
254
+ `approving resume-${step} grants one more attempt; rejecting ends the run`,
243
255
  ]);
244
256
  return null;
245
257
  }
@@ -266,13 +278,13 @@ function settleOutcome(context, step, outcome, tree, exec) {
266
278
  },
267
279
  });
268
280
  if (!to) {
269
- ledger.append({
270
- type: "RUN_TERMINAL",
271
- actor: KERNEL,
272
- ts: now(),
273
- data: { status: "FAILED", reason: `no transition for (${step}, ${outcome})` },
274
- });
275
- writeRunRecord({ repoRoot: context.repoRoot, ledger, ts: now() });
281
+ // A workflow without an edge for this outcome is not a verdict on the
282
+ // candidate; the person decides whether to rerun the step or end the
283
+ // run. Terminal FAILED here used to kill runs whose reviewer had merely
284
+ // errored out.
285
+ context.waitHuman(`resume-${step}`, [
286
+ `no transition for (${step}, ${outcome}); approve resume-${step} to rerun the step, reject to end the run`,
287
+ ]);
276
288
  return null;
277
289
  }
278
290
  ledger.append({
@@ -320,6 +332,30 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
320
332
  return;
321
333
  }
322
334
 
335
+ // Work-level review cap (iteration 09): review rounds are counted across
336
+ // every run of the work, superseded ones included, so "one run per
337
+ // round" cannot slip past the per-run budget. Reaching the cap is a
338
+ // human decision (review once more, or merge/close as-is), not a stop.
339
+ const workCap = context.runBudgets.reviewRoundsPerWork;
340
+ if (workCap !== undefined && (stepDef.worker === "reviewer" || step === "review")) {
341
+ const prior = computeWorkCost(context.repoRoot, ledger.state.run.work, {
342
+ excludeRun: ledger.state.run.id,
343
+ });
344
+ const rounds = prior.reviewRounds + (ledger.state.steps[step]?.attempts ?? 0);
345
+ const allowed = workCap + (ledger.state.workReviewGrants ?? 0);
346
+ if (rounds >= allowed) {
347
+ context.waitHuman(
348
+ `enter-${step}`,
349
+ [
350
+ `work review cap reached: ${rounds} review round(s) across ${prior.runs + 1} run(s) of ${ledger.state.run.work} (budgets.reviewRoundsPerWork=${workCap})`,
351
+ `approve enter-${step} to review once more, or reject and merge/close the work on the evidence you have`,
352
+ ],
353
+ "work-review-cap",
354
+ );
355
+ return;
356
+ }
357
+ }
358
+
323
359
  const preGate = runPolicyGate(context, "pre", step);
324
360
  if (preGate.action === "block") {
325
361
  ledger.append({
@@ -341,6 +377,7 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
341
377
  if (attempt > maxAttempts) {
342
378
  context.waitHuman(`resume-${step}`, [
343
379
  `budget exhausted: ${step} would exceed maxAttempts=${maxAttempts}`,
380
+ `approving resume-${step} grants one more attempt; rejecting ends the run`,
344
381
  ]);
345
382
  return;
346
383
  }
@@ -531,18 +568,53 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
531
568
  } else {
532
569
  stepStatus = "succeeded";
533
570
  }
571
+ // Infrastructure failure vs candidate failure. A timeout, a crash,
572
+ // garbage output or the worker's own "environment unavailable" signal
573
+ // (exit 75, EX_TEMPFAIL) says nothing about the candidate: no failure
574
+ // fingerprint, no fixer, the attempt is refunded, and a human decides
575
+ // when the backend is back. Real incidents: a worker backend outage
576
+ // (review exit 97) and non-JSON reviewer output killed five runs in two
577
+ // days as "no transition for (review, failed)"; PATH, port and host-load
578
+ // verify failures dispatched fixers five times.
579
+ const infra =
580
+ stepStatus === "timeout" ||
581
+ stepStatus === "crashed" ||
582
+ stepStatus === "invalid-output" ||
583
+ (stepStatus === "failed" && exec.exitCode === 75);
534
584
  ledger.append({
535
585
  type: "STEP_FINISHED",
536
586
  actor: KERNEL,
537
587
  ts: now(),
538
- data: { step, attempt, status: stepStatus, exitCode: exec.exitCode },
588
+ data: { step, attempt, status: stepStatus, exitCode: exec.exitCode, ...(infra ? { infra: true } : {}) },
539
589
  });
540
590
  ledger.append({
541
591
  type: "BUDGET_CONSUMED",
542
592
  actor: KERNEL,
543
593
  ts: now(),
544
- data: { kind: "attempts", amount: 1, remaining: maxAttempts - attempt },
594
+ data: {
595
+ kind: "attempts",
596
+ amount: infra ? 0 : 1,
597
+ remaining: context.maxAttemptsFor(step) - attempt,
598
+ },
545
599
  });
600
+ if (infra) {
601
+ const cause =
602
+ stepStatus === "failed"
603
+ ? "exit 75 (worker reports its environment unavailable)"
604
+ : stepStatus === "invalid-output"
605
+ ? "output is not a worker envelope"
606
+ : stepStatus;
607
+ context.waitHuman(
608
+ `resume-${step}`,
609
+ [
610
+ `worker infrastructure failure at ${step}: ${cause}; not a candidate defect, attempt not charged`,
611
+ ...(tree.dirty ? [`the failed worker left the worktree dirty; inspect before rerunning`] : []),
612
+ `approve resume-${step} to rerun once the backend/environment is back; reject to end the run`,
613
+ ],
614
+ "infra",
615
+ );
616
+ return;
617
+ }
546
618
 
547
619
  let blockingFindings = [];
548
620
  if (envelope?.findings) {
@@ -920,12 +992,51 @@ export function resumeRun(options) {
920
992
  });
921
993
  return { runId, ledgerPath, state: ledger.state, resumed: true, stale: true, reason: null };
922
994
  }
923
- const step = resumeStepFromTransition(approval.transition);
995
+ // An adopted candidate names where to resume (verify, by convention):
996
+ // the fixer step the request was waiting on has nothing left to do.
997
+ const step = approval.resumeAt ?? resumeStepFromTransition(approval.transition);
924
998
  if (!step || !context.workflow.stepIds.has(step)) {
925
999
  throw new OrchestratorError(
926
1000
  `cannot derive a resume step from approved transition ${approval.transition}`,
927
1001
  );
928
1002
  }
1003
+ // An approved resume-<step> on an exhausted budget is the human saying
1004
+ // "one more"; record the grant before driving or the same request
1005
+ // comes straight back (the pilot's app-login runs ended CANCELLED
1006
+ // with their candidates in production because of exactly that).
1007
+ const requestKind = [...ledger.events]
1008
+ .reverse()
1009
+ .find((event) => event.type === "HUMAN_REQUESTED" && event.data.transition === approval.transition)
1010
+ ?.data.kind;
1011
+ if (requestKind === "work-review-cap") {
1012
+ ledger.append({
1013
+ type: "BUDGET_EXTENDED",
1014
+ actor: KERNEL,
1015
+ ts: now(),
1016
+ data: {
1017
+ step,
1018
+ amount: 1,
1019
+ scope: "work",
1020
+ maxAttempts: (context.runBudgets.reviewRoundsPerWork ?? 0) + (state.workReviewGrants ?? 0) + 1,
1021
+ approvalRef: approval.decisionRef,
1022
+ },
1023
+ });
1024
+ } else if (
1025
+ approval.transition.startsWith("resume-") &&
1026
+ (state.steps[step]?.attempts ?? 0) >= context.maxAttemptsFor(step)
1027
+ ) {
1028
+ ledger.append({
1029
+ type: "BUDGET_EXTENDED",
1030
+ actor: KERNEL,
1031
+ ts: now(),
1032
+ data: {
1033
+ step,
1034
+ amount: 1,
1035
+ maxAttempts: context.maxAttemptsFor(step) + 1,
1036
+ approvalRef: approval.decisionRef,
1037
+ },
1038
+ });
1039
+ }
929
1040
  ledger.append({ type: "RUN_STARTED", actor: KERNEL, ts: now(), data: {} });
930
1041
  drive(context, step, { skipBoundaryOnce: true });
931
1042
  return { runId, ledgerPath, state: ledger.state, resumed: true, reason: null };
@@ -16,12 +16,13 @@ import { join } from "node:path";
16
16
  import { EventLedger } from "../storage/event-ledger.js";
17
17
  import { latestAdjudications, readFindingsAccount } from "./findings.js";
18
18
  import { nextReply } from "./notify.js";
19
+ import { computeWorkCost, renderWorkCost } from "./work-cost.js";
19
20
 
20
21
  function sha256File(path) {
21
22
  return `sha256:${createHash("sha256").update(readFileSync(path, "utf8"), "utf8").digest("hex")}`;
22
23
  }
23
24
 
24
- function readJsonl(path) {
25
+ export function readJsonl(path) {
25
26
  if (!existsSync(path)) {
26
27
  return [];
27
28
  }
@@ -38,7 +39,7 @@ function readJsonl(path) {
38
39
  .filter(Boolean);
39
40
  }
40
41
 
41
- function artifactStatus(workDir, decisions, artifact) {
42
+ export function artifactStatus(workDir, decisions, artifact) {
42
43
  const path = join(workDir, `${artifact}.md`);
43
44
  if (!existsSync(path)) {
44
45
  return { exists: false, accepted: false, stale: false };
@@ -93,6 +94,8 @@ function runsFor(repoRoot, workId) {
93
94
  candidate: state.workspaces[state.run.id]?.candidate ?? null,
94
95
  createdAt: ledger.events[0]?.ts ?? null,
95
96
  lastAt: ledger.events[ledger.events.length - 1]?.ts ?? null,
97
+ workflow: state.run.workflowRef ?? null,
98
+ steps: Object.keys(state.steps),
96
99
  state,
97
100
  source: "runtime",
98
101
  });
@@ -118,6 +121,8 @@ function runsFor(repoRoot, workId) {
118
121
  candidate: record.workspaces?.[entry]?.candidate ?? null,
119
122
  createdAt: record.startedAt ?? null,
120
123
  lastAt: record.finishedAt ?? null,
124
+ workflow: record.workflow ?? null,
125
+ steps: Object.keys(record.attempts ?? {}),
121
126
  state: null,
122
127
  source: "run-record",
123
128
  });
@@ -156,14 +161,31 @@ export function computeOverview(repoRoot, { work = null, repoLabel = "." } = {})
156
161
  const runs = runsFor(repoRoot, workId);
157
162
  const live = runs.filter((run) => run.status !== "SUPERSEDED");
158
163
  const latest = live[live.length - 1] ?? null;
159
- let merged = false;
160
- if (latest?.candidate) {
161
- merged = isAncestor(repoRoot, latest.candidate, mainRef);
162
- }
164
+ // "Merged" is a fact about any candidate of the work, not only the
165
+ // latest run's: a pilot's shipped candidate sat behind a CANCELLED run
166
+ // (its in-run review budget ran out and closure happened elsewhere) and
167
+ // overview reported the shipped work as STOPPED_CANCELLED.
168
+ const mergedRun =
169
+ [...runs].reverse().find((run) => run.candidate && isAncestor(repoRoot, run.candidate, mainRef)) ?? null;
170
+ const merged = Boolean(mergedRun);
171
+ const RELEASE_STEPS = ["preflight", "apply-readback", "observe"];
172
+ const isReleaseLane = (run) =>
173
+ Boolean(run) && (run.workflow === "release-readback" || run.steps.some((step) => RELEASE_STEPS.includes(step)));
174
+
175
+ // A Work is closed by an explicit row in decisions.jsonl:
176
+ // {"transition":"close-work","decision":"closed"|"cancelled","subject":{"result":"..."}}
177
+ // A live run (RUNNING / WAITING_HUMAN) contradicts a closure and wins, so a
178
+ // stale close row can never hide something that still needs a human.
179
+ const closure = [...decisions].reverse().find((row) => row.transition === "close-work");
180
+ const liveRun = latest && (latest.status === "RUNNING" || latest.status === "WAITING_HUMAN");
163
181
 
164
182
  let stage;
165
183
  let next;
166
- if (!intent.exists) {
184
+ if (closure && !liveRun) {
185
+ stage = closure.decision === "cancelled" ? "CANCELLED" : "CLOSED";
186
+ const result = typeof closure.subject?.result === "string" && closure.subject.result.length > 0 ? closure.subject.result : "see decisions.jsonl";
187
+ next = `${stage.toLowerCase()} @ ${closure.ts ?? "?"}: ${result.slice(0, 160)}`;
188
+ } else if (!intent.exists) {
167
189
  stage = "NO_INTENT";
168
190
  next = `write delivery/work/${workId}/intent.md (what and why), then plan.md`;
169
191
  } else if (!latest) {
@@ -181,7 +203,7 @@ export function computeOverview(repoRoot, { work = null, repoLabel = "." } = {})
181
203
  next =
182
204
  configs.length > 0
183
205
  ? `buildbeat-v2 start --config delivery/work/${workId}/${configs[0]} --attempt new`
184
- : `no run-config in delivery/work/${workId}: write one, or record the work as closed in decisions.jsonl if it was doc-only`;
206
+ : `no run-config in delivery/work/${workId}: write one, or close it with a decisions.jsonl row {"transition":"close-work","decision":"closed","subject":{"result":"..."}} if it was doc-only`;
185
207
  }
186
208
  } else if (latest.status === "RUNNING") {
187
209
  stage = "RUNNING";
@@ -190,16 +212,21 @@ export function computeOverview(repoRoot, { work = null, repoLabel = "." } = {})
190
212
  stage = latest.pendingHuman?.kind === "final-decision" ? "MERGE_DECISION" : "WAITING_HUMAN";
191
213
  const replies = latest.state ? nextReply({ repoLabel, state: latest.state }) : [];
192
214
  next = replies[0] ?? `buildbeat-v2 inbox --repo ${repoLabel}`;
215
+ } else if (latest.status === "SUCCEEDED" && isReleaseLane(latest)) {
216
+ // A release-readback lane that reached wait-close and was approved is
217
+ // a closed release window, not "nothing to merge".
218
+ stage = "RELEASED";
219
+ next = `release window closed by ${latest.id}; close the work with a decisions.jsonl row {"transition":"close-work","decision":"closed","subject":{"result":"released"}}`;
220
+ } else if (merged) {
221
+ stage = "MERGED";
222
+ next =
223
+ `candidate ${mergedRun.candidate.slice(0, 7)} (${mergedRun.id}) is on ${mainRef}; release/deploy stays a human action; then buildbeat-v2 gc --repo ${repoLabel}` +
224
+ (latest.status !== "SUCCEEDED" ? ` # latest run ${latest.id} ended ${latest.status} after the merge` : "");
193
225
  } else if (latest.status === "SUCCEEDED") {
194
- if (merged) {
195
- stage = "MERGED";
196
- next = `release/deploy stays a human action; then buildbeat-v2 gc --repo ${repoLabel}`;
197
- } else {
198
- stage = "MERGE_READY";
199
- next = latest.candidate
200
- ? `merge ${latest.candidate.slice(0, 7)} (run/${latest.id}) into ${mainRef} — manual, then push`
201
- : "run succeeded without a candidate; nothing to merge";
202
- }
226
+ stage = "MERGE_READY";
227
+ next = latest.candidate
228
+ ? `merge ${latest.candidate.slice(0, 7)} (run/${latest.id}) into ${mainRef} — manual, then push`
229
+ : "run succeeded without a candidate; nothing to merge";
203
230
  } else {
204
231
  stage = `STOPPED_${latest.status}`;
205
232
  next = plan.accepted
@@ -214,10 +241,12 @@ export function computeOverview(repoRoot, { work = null, repoLabel = "." } = {})
214
241
  envFacts,
215
242
  openFindings,
216
243
  runs: runs.length,
244
+ cost: runs.length > 0 ? computeWorkCost(repoRoot, workId) : null,
217
245
  latest: latest
218
246
  ? { id: latest.id, status: latest.status, candidate: latest.candidate, at: latest.lastAt, source: latest.source, terminalReason: latest.terminal?.reason ?? null, waiting: latest.pendingHuman?.transition ?? null }
219
247
  : null,
220
248
  merged,
249
+ mergedCandidate: mergedRun?.candidate ?? null,
221
250
  next,
222
251
  });
223
252
  }
@@ -242,7 +271,8 @@ export function renderOverview(rows) {
242
271
  for (const row of rows) {
243
272
  lines.push(`${row.work} ${row.stage}`);
244
273
  const parts = [`intent ${mark(row.intent)}`, `plan ${mark(row.plan)}`, `runs ${row.runs}`];
245
- if (row.openFindings > 0) {
274
+ const settled = ["MERGED", "RELEASED", "CLOSED", "CANCELLED"].includes(row.stage);
275
+ if (row.openFindings > 0 && !settled) {
246
276
  // Unadjudicated, not necessarily unresolved: a fixer may have closed
247
277
  // them without anyone recording a verdict. The number says "nobody
248
278
  // ruled on these", which is exactly what a human should know.
@@ -252,8 +282,15 @@ export function renderOverview(rows) {
252
282
  parts.push("env-facts ✓");
253
283
  }
254
284
  lines.push(` ${parts.join(" · ")}`);
285
+ if (row.cost) {
286
+ // What this work has already consumed across every run, superseded
287
+ // ones included: the number a "continue or cut" decision needs.
288
+ lines.push(` cost: ${renderWorkCost(row.cost)}`);
289
+ }
255
290
  if (row.latest) {
256
- const cand = row.latest.candidate ? ` candidate ${row.latest.candidate.slice(0, 7)}${row.merged ? " (merged)" : ""}` : "";
291
+ const cand = row.latest.candidate
292
+ ? ` candidate ${row.latest.candidate.slice(0, 7)}${row.latest.candidate === row.mergedCandidate ? " (merged)" : ""}`
293
+ : "";
257
294
  const wait = row.latest.waiting ? ` waiting ${row.latest.waiting}` : "";
258
295
  const why = row.latest.terminalReason ? ` — ${row.latest.terminalReason.slice(0, 100)}` : "";
259
296
  lines.push(` latest ${row.latest.id} ${row.latest.status}${cand}${wait} @ ${row.latest.at ?? "?"}${why}`);
@@ -9,6 +9,7 @@ import { join, relative } from "node:path";
9
9
 
10
10
  import { canonicalJson } from "../storage/event-ledger.js";
11
11
  import { normalizeRepoRef } from "./repo-ref.js";
12
+ import { ledgerCost } from "./work-cost.js";
12
13
 
13
14
  const KERNEL = { kind: "kernel", id: "orchestrator" };
14
15
 
@@ -40,12 +41,14 @@ export function writeRunRecord({ repoRoot, ledger, ts }) {
40
41
  const record = {
41
42
  run: first.run,
42
43
  work: first.work,
44
+ workflow: state.run?.workflowRef ?? null,
43
45
  terminal: state.terminal,
44
46
  events: { from: first.seq, to: last.seq, lastDigest: last.digest },
45
47
  startedAt: first.ts,
46
48
  finishedAt: last.ts,
47
49
  attempts,
48
50
  budgets: state.budgets,
51
+ cost: ledgerCost(ledger),
49
52
  workspaces,
50
53
  evidence,
51
54
  decisions: state.decisions,
@@ -0,0 +1,147 @@
1
+ // Work-level cost (iteration 09): what a work has already consumed across
2
+ // every run of it, superseded ones included. Per-run budgets were bypassed
3
+ // by "one run per review round" (a pilot work ran 21 runs and 9 review
4
+ // rounds while the preset's two-round cap never fired), and a work that ate
5
+ // ten runs and a day was cut by the owner as "cost > benefit" with no
6
+ // number in front of them. Derived only: runtime ledgers first, run-records
7
+ // for runs whose runtime was wiped. Nothing is written.
8
+
9
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
10
+ import { join } from "node:path";
11
+
12
+ import { EventLedger } from "../storage/event-ledger.js";
13
+ import { readFindingsAccount } from "./findings.js";
14
+
15
+ function emptyCost() {
16
+ return {
17
+ runs: 0,
18
+ reviewRounds: 0,
19
+ findings: 0,
20
+ humanWaits: 0,
21
+ infraFailures: 0,
22
+ workerMs: 0,
23
+ firstAt: null,
24
+ lastAt: null,
25
+ };
26
+ }
27
+
28
+ // Cost facts of one ledger: review rounds, human waits, infra failures and
29
+ // worker wall time (STEP_STARTED → STEP_FINISHED per attempt).
30
+ export function ledgerCost(ledger) {
31
+ const cost = { reviewRounds: 0, humanWaits: 0, infraFailures: 0, workerMs: 0 };
32
+ const open = new Map();
33
+ for (const event of ledger.events) {
34
+ if (event.type === "STEP_STARTED") {
35
+ open.set(`${event.data.step}#${event.data.attempt}`, Date.parse(event.ts));
36
+ } else if (event.type === "STEP_FINISHED") {
37
+ const key = `${event.data.step}#${event.data.attempt}`;
38
+ const startedAt = open.get(key);
39
+ if (startedAt !== undefined) {
40
+ const ms = Date.parse(event.ts) - startedAt;
41
+ if (Number.isFinite(ms) && ms > 0) {
42
+ cost.workerMs += ms;
43
+ }
44
+ open.delete(key);
45
+ }
46
+ if (event.data.step === "review") {
47
+ cost.reviewRounds += 1;
48
+ }
49
+ if (event.data.infra === true) {
50
+ cost.infraFailures += 1;
51
+ }
52
+ } else if (event.type === "HUMAN_REQUESTED") {
53
+ cost.humanWaits += 1;
54
+ }
55
+ }
56
+ return cost;
57
+ }
58
+
59
+ export function computeWorkCost(repoRoot, workId, { excludeRun = null } = {}) {
60
+ const cost = emptyCost();
61
+ const seen = new Set();
62
+ const runsDir = join(repoRoot, ".buildbeat", "runtime", "runs");
63
+ if (existsSync(runsDir)) {
64
+ for (const entry of readdirSync(runsDir)) {
65
+ const path = join(runsDir, entry, "events.jsonl");
66
+ if (!existsSync(path)) {
67
+ continue;
68
+ }
69
+ const ledger = EventLedger.open(path);
70
+ const state = ledger.state;
71
+ if (ledger.corruption || !state.run || state.run.work !== workId) {
72
+ continue;
73
+ }
74
+ seen.add(state.run.id);
75
+ if (state.run.id === excludeRun) {
76
+ continue;
77
+ }
78
+ const row = ledgerCost(ledger);
79
+ cost.runs += 1;
80
+ cost.reviewRounds += row.reviewRounds;
81
+ cost.humanWaits += row.humanWaits;
82
+ cost.infraFailures += row.infraFailures;
83
+ cost.workerMs += row.workerMs;
84
+ track(cost, ledger.events[0]?.ts, ledger.events[ledger.events.length - 1]?.ts);
85
+ }
86
+ }
87
+ const recordsDir = join(repoRoot, "delivery", "work", workId, "runs");
88
+ if (existsSync(recordsDir)) {
89
+ for (const entry of readdirSync(recordsDir)) {
90
+ if (seen.has(entry) || entry === excludeRun) {
91
+ continue;
92
+ }
93
+ const path = join(recordsDir, entry, "run-record.json");
94
+ if (!existsSync(path)) {
95
+ continue;
96
+ }
97
+ let record;
98
+ try {
99
+ record = JSON.parse(readFileSync(path, "utf8"));
100
+ } catch {
101
+ continue;
102
+ }
103
+ cost.runs += 1;
104
+ // Records written before iteration 09 carry attempts and decisions
105
+ // only; the cost block is preferred when present.
106
+ cost.reviewRounds += record.cost?.reviewRounds ?? record.attempts?.review ?? 0;
107
+ cost.humanWaits += record.cost?.humanWaits ?? record.decisions?.length ?? 0;
108
+ cost.infraFailures += record.cost?.infraFailures ?? 0;
109
+ cost.workerMs += record.cost?.workerMs ?? 0;
110
+ track(cost, record.startedAt, record.finishedAt);
111
+ }
112
+ }
113
+ cost.findings = readFindingsAccount(repoRoot, workId).filter((row) => row.kind === "finding").length;
114
+ return cost;
115
+ }
116
+
117
+ function track(cost, firstAt, lastAt) {
118
+ if (firstAt && (!cost.firstAt || firstAt < cost.firstAt)) {
119
+ cost.firstAt = firstAt;
120
+ }
121
+ if (lastAt && (!cost.lastAt || lastAt > cost.lastAt)) {
122
+ cost.lastAt = lastAt;
123
+ }
124
+ }
125
+
126
+ export function formatWorkerMs(ms) {
127
+ if (!ms || ms < 1000) {
128
+ return "0s";
129
+ }
130
+ if (ms < 60000) {
131
+ return `${Math.round(ms / 1000)}s`;
132
+ }
133
+ const totalMinutes = Math.round(ms / 60000);
134
+ const hours = Math.floor(totalMinutes / 60);
135
+ const minutes = totalMinutes % 60;
136
+ return hours > 0 ? `${hours}h${String(minutes).padStart(2, "0")}m` : `${minutes}m`;
137
+ }
138
+
139
+ export function renderWorkCost(cost) {
140
+ return [
141
+ `review rounds ${cost.reviewRounds}`,
142
+ `findings ${cost.findings}`,
143
+ `human waits ${cost.humanWaits}`,
144
+ ...(cost.infraFailures > 0 ? [`infra failures ${cost.infraFailures}`] : []),
145
+ `worker ${formatWorkerMs(cost.workerMs)}`,
146
+ ].join(" · ");
147
+ }