@haiyangbg/buildbeat 3.0.0 → 3.1.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 (50) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.en.md +4 -4
  3. package/README.md +1 -1
  4. package/SKILL.md +8 -5
  5. package/docs/README.md +3 -2
  6. package/docs/RELEASING.md +3 -1
  7. package/docs/v2/guide/01-quickstart.en.md +165 -0
  8. package/docs/v2/guide/01-quickstart.md +7 -3
  9. package/docs/v2/guide/02-workflow-guide.md +11 -0
  10. package/docs/v2/guide/06-evidence-guide.en.md +51 -0
  11. package/docs/v2/guide/06-evidence-guide.md +2 -0
  12. package/docs/v2/guide/07-approval-guide.en.md +117 -0
  13. package/docs/v2/guide/07-approval-guide.md +16 -2
  14. package/docs/v2/guide/10-recovery.en.md +99 -0
  15. package/docs/v2/guide/10-recovery.md +22 -4
  16. package/docs/v2/guide/README.md +4 -4
  17. package/example/.buildbeat/notify.yaml +13 -0
  18. package/example/.buildbeat/observe.yaml +31 -0
  19. package/example/AGENTS.md +73 -0
  20. package/example/BUILDBEAT.md +14 -0
  21. package/example/CLAUDE.md +7 -0
  22. package/example/README.md +25 -0
  23. package/example/delivery/envelope/prompts/builder.md +10 -0
  24. package/example/delivery/envelope/prompts/fixer.md +10 -0
  25. package/example/delivery/envelope/prompts/reviewer.md +13 -0
  26. package/example/delivery/envelope/worker.sh +70 -0
  27. package/example/delivery/work/WORK-EXPORT-DATE-FILTER/decisions.jsonl +3 -0
  28. package/example/delivery/work/WORK-EXPORT-DATE-FILTER/intent.md +24 -0
  29. package/example/delivery/work/WORK-EXPORT-DATE-FILTER/plan.md +20 -0
  30. package/example/delivery/work/WORK-EXPORT-DATE-FILTER/run-config.yaml +66 -0
  31. package/example/delivery/work/WORK-EXPORT-DATE-FILTER/runs/RUN-EXPORT-01/run-record.json +108 -0
  32. package/example/delivery/work/WORK-EXPORT-DATE-FILTER/workflow.yaml +44 -0
  33. package/example/gitignore.template +20 -0
  34. package/example/package.json +13 -0
  35. package/example/pm/decisions.md +8 -0
  36. package/example/src/export.js +25 -0
  37. package/example/src/ledger.js +16 -0
  38. package/example/tests/export.test.js +35 -0
  39. package/example//346/214/207/346/214/245/345/217/260.md +40 -0
  40. package/package.json +2 -1
  41. package/src/v2/cli/run-config-check.js +223 -0
  42. package/src/v2/cli/run.js +74 -14
  43. package/src/v2/engine/reducer.js +6 -0
  44. package/src/v2/engine/yaml-subset.js +44 -9
  45. package/src/v2/runtime/decisions.js +32 -30
  46. package/src/v2/runtime/gc.js +28 -3
  47. package/src/v2/runtime/orchestrator.js +223 -96
  48. package/src/v2/storage/event-ledger.js +22 -3
  49. package/src/v2/workspace/workspace-manager.js +182 -13
  50. package/templates/v2/run-config.example.yaml +3 -1
@@ -59,9 +59,13 @@ const ACTIVE_LOCK = "active-run";
59
59
  function lockActive(repoRoot) {
60
60
  try {
61
61
  acquireLock(repoRoot, ACTIVE_LOCK);
62
- } catch {
62
+ } catch (error) {
63
+ // A lock whose owner is gone was already reclaimed inside acquireLock;
64
+ // what reaches here is held (or unreadable), and the owner is the one
65
+ // fact a blocked caller needs.
66
+ const detail = error.lock?.detail;
63
67
  throw new OrchestratorError(
64
- "another run is active in this repository (MVP allows a single active run)",
68
+ `another run is active in this repository (MVP allows a single active run)${detail ? `; ${detail}` : ""}`,
65
69
  );
66
70
  }
67
71
  }
@@ -149,12 +153,17 @@ function makeContext(options, ledger, workspace) {
149
153
  // review rounds could not be raised from the run config, and approving
150
154
  // resume-review re-asked the same question forever.
151
155
  context.runBudgets = options.budgets ?? {};
152
- context.maxAttemptsFor = (step) =>
156
+ context.budgetLimitFor = (step) =>
153
157
  (context.runBudgets.maxAttempts?.[step] ??
154
158
  workflow.budgets?.maxAttempts?.[step] ??
155
159
  maxAttemptsPerStep) +
156
- (ledger.state.budgetExtensions?.[step] ?? 0) +
157
- (ledger.state.steps[step]?.infraAttempts ?? 0);
160
+ (ledger.state.budgetExtensions?.[step] ?? 0);
161
+ context.maxAttemptsFor = (step) => context.budgetLimitFor(step) +
162
+ (ledger.state.steps[step]?.infraAttempts ?? 0) +
163
+ (ledger.state.steps[step]?.freeAttempts ?? 0);
164
+ // Refunds must not move this ceiling: otherwise a success-only loop has
165
+ // an ever-growing limit. Human extensions deliberately raise it.
166
+ context.totalAttemptsFor = (step) => context.budgetLimitFor(step) * 3;
158
167
  context.policies = options.policies ?? [];
159
168
  context.allowedPaths = options.allowedPaths ?? null;
160
169
  context.reviewTriage = options.reviewTriage ?? null;
@@ -179,17 +188,64 @@ function makeContext(options, ledger, workspace) {
179
188
  evidenceDigest: lastEvidence?.digest ?? "UNVERIFIED",
180
189
  };
181
190
  };
182
- context.waitHuman = (transition, reasons, kind = "boundary") => {
191
+ context.waitHuman = (transition, reasons, kind = "boundary", grants = []) => {
183
192
  ledger.append({
184
193
  type: "HUMAN_REQUESTED",
185
194
  actor: KERNEL,
186
195
  ts: context.now(),
187
- data: { transition, subject: context.subjectNow(), reasons, kind },
196
+ data: { transition, subject: context.subjectNow(), reasons, kind, ...(grants.length ? { grants } : {}) },
188
197
  });
189
198
  };
190
199
  return context;
191
200
  }
192
201
 
202
+ // Keep the charged budget distinct from the worker invocation number.
203
+ function budgetUsage(context, step) {
204
+ const state = context.ledger.state.steps[step];
205
+ const attempts = state?.attempts ?? 0;
206
+ const used = attempts - (state?.infraAttempts ?? 0) - (state?.freeAttempts ?? 0);
207
+ const failures = context.ledger.events.filter((event) =>
208
+ event.type === "STEP_FINISHED" && event.data.step === step &&
209
+ event.data.status !== "succeeded" && event.data.infra !== true).length;
210
+ return { attempts, used, failures, limit: context.budgetLimitFor(step) };
211
+ }
212
+
213
+ function budgetReasons(context, step, safeguard = false) {
214
+ const { attempts, used, failures, limit } = budgetUsage(context, step);
215
+ const charging = context.workflow.steps.find((item) => item.id === step)?.readonly
216
+ ? "each round is charged" : "successful attempts are not charged";
217
+ return [
218
+ safeguard
219
+ ? `${step} budget exhausted (runaway safeguard): ${attempts}/${context.totalAttemptsFor(step)} total attempt(s), ${failures} real failure(s)`
220
+ : `${step} budget exhausted: ${used}/${limit} charged attempt(s) used, ${failures} real failure(s) (${charging})`,
221
+ `approve resume-${step} = ${safeguard ? "raise the safeguard and continue" : "one more attempt"}; reject = end this run and decide the merge on the evidence you have`,
222
+ ];
223
+ }
224
+
225
+ function workReviewBudget(context, step) {
226
+ const stepDef = context.workflow.steps.find((item) => item.id === step);
227
+ const cap = context.runBudgets.reviewRoundsPerWork;
228
+ if (cap === undefined || !(step === "review" || stepDef?.worker === "reviewer")) return null;
229
+ const prior = computeWorkCost(context.repoRoot, context.ledger.state.run.work, {
230
+ excludeRun: context.ledger.state.run.id,
231
+ });
232
+ const rounds = prior.reviewRounds + (context.ledger.state.steps[step]?.attempts ?? 0);
233
+ const allowed = cap + (context.ledger.state.workReviewGrants ?? 0);
234
+ return { rounds, allowed, exhausted: rounds >= allowed };
235
+ }
236
+
237
+ // Every cap the next attempt of `step` would hit. Recorded on the request
238
+ // at stop time, so one approval lifts both layers (run and work) at once.
239
+ function budgetGrants(context, step) {
240
+ const grants = [];
241
+ if ((context.ledger.state.steps[step]?.attempts ?? 0) >= context.maxAttemptsFor(step) ||
242
+ (context.ledger.state.steps[step]?.attempts ?? 0) >= context.totalAttemptsFor(step)) {
243
+ grants.push({ step, scope: "run" });
244
+ }
245
+ if (workReviewBudget(context, step)?.exhausted) grants.push({ step, scope: "work" });
246
+ return grants;
247
+ }
248
+
193
249
  // Evaluates configured policies of `type` for `appliesTo`, records every
194
250
  // verdict as a POLICY_EVALUATED event, and reports what the kernel must do.
195
251
  // ADVISORY failures are recorded but never gate (doctor reports the gap).
@@ -249,10 +305,7 @@ function settleOutcome(context, step, outcome, tree, exec) {
249
305
  // A step that failed its final attempt can never run again, so routing
250
306
  // to fix would spend a worker on a candidate nothing can verify.
251
307
  if ((ledger.state.steps[step]?.attempts ?? 0) >= context.maxAttemptsFor(step)) {
252
- context.waitHuman(`resume-${step}`, [
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`,
255
- ]);
308
+ context.waitHuman(`resume-${step}`, budgetReasons(context, step), "budget", budgetGrants(context, step));
256
309
  return null;
257
310
  }
258
311
  }
@@ -336,24 +389,19 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
336
389
  // every run of the work, superseded ones included, so "one run per
337
390
  // round" cannot slip past the per-run budget. Reaching the cap is a
338
391
  // 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
- }
392
+ const workBudget = workReviewBudget(context, step);
393
+ if (workBudget?.exhausted) {
394
+ const { failures } = budgetUsage(context, step);
395
+ context.waitHuman(
396
+ `enter-${step}`,
397
+ [
398
+ `${step} work review budget exhausted: ${workBudget.rounds}/${workBudget.allowed} review round(s) across the work, ${failures} real failure(s) in this run`,
399
+ `approve enter-${step} = one more review round (also lifts this run's review cap if it is spent); reject = end this run and decide the merge on the evidence you have`,
400
+ ],
401
+ "work-review-cap",
402
+ budgetGrants(context, step),
403
+ );
404
+ return;
357
405
  }
358
406
 
359
407
  const preGate = runPolicyGate(context, "pre", step);
@@ -375,10 +423,12 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
375
423
  const attempt = (ledger.state.steps[step]?.attempts ?? 0) + 1;
376
424
  const maxAttempts = context.maxAttemptsFor(step);
377
425
  if (attempt > maxAttempts) {
378
- context.waitHuman(`resume-${step}`, [
379
- `budget exhausted: ${step} would exceed maxAttempts=${maxAttempts}`,
380
- `approving resume-${step} grants one more attempt; rejecting ends the run`,
381
- ]);
426
+ context.waitHuman(`resume-${step}`, budgetReasons(context, step), "budget", budgetGrants(context, step));
427
+ return;
428
+ }
429
+
430
+ if (attempt > context.totalAttemptsFor(step)) {
431
+ context.waitHuman(`resume-${step}`, budgetReasons(context, step, true), "budget", budgetGrants(context, step));
382
432
  return;
383
433
  }
384
434
 
@@ -581,11 +631,13 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
581
631
  stepStatus === "crashed" ||
582
632
  stepStatus === "invalid-output" ||
583
633
  (stepStatus === "failed" && exec.exitCode === 75);
634
+ const free = stepStatus === "succeeded" && stepDef.readonly !== true;
584
635
  ledger.append({
585
636
  type: "STEP_FINISHED",
586
637
  actor: KERNEL,
587
638
  ts: now(),
588
- data: { step, attempt, status: stepStatus, exitCode: exec.exitCode, ...(infra ? { infra: true } : {}) },
639
+ data: { step, attempt, status: stepStatus, exitCode: exec.exitCode,
640
+ ...(infra ? { infra: true } : {}), ...(free ? { free: true } : {}) },
589
641
  });
590
642
  ledger.append({
591
643
  type: "BUDGET_CONSUMED",
@@ -593,7 +645,7 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
593
645
  ts: now(),
594
646
  data: {
595
647
  kind: "attempts",
596
- amount: infra ? 0 : 1,
648
+ amount: infra || free ? 0 : 1,
597
649
  remaining: context.maxAttemptsFor(step) - attempt,
598
650
  },
599
651
  });
@@ -736,26 +788,31 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
736
788
  outcome = "succeeded";
737
789
  }
738
790
  const routed = settleOutcome(context, step, outcome, tree, exec);
739
- // Finding triage gate (reviewTriage: required): blocking findings stop
740
- // for a human verdict before any fixer runs. Findings are prescriptions,
741
- // not facts — auto-routing them to a fixer burned four oscillation
742
- // rounds in the deploy campaign before a human stopped the loop.
743
- if (routed && outcome === "findings-blocking" && context.reviewTriage === "required") {
744
- context.waitHuman(
745
- `enter-${routed}`,
746
- [
747
- `review found ${blockingFindings.length} blocking finding(s); triage before ${routed} runs`,
748
- ...blockingFindings
749
- .slice(0, 5)
750
- .map(
751
- (finding) =>
752
- `[${finding.severity} ${fingerprintFinding(finding)}] ${finding.summary.slice(0, 200)}`,
753
- ),
754
- `adjudicate fingerprints (findings adjudicate), then approve enter-${routed} or reject the run`,
755
- ],
756
- "finding-triage",
757
- );
758
- return;
791
+ // Ask before spending fix/verify workers: one approval covers the next
792
+ // round and both review caps, with the grant bound to this request.
793
+ if (routed && outcome === "findings-blocking") {
794
+ const isReview = step === "review" || stepDef.worker === "reviewer";
795
+ const grants = isReview ? budgetGrants(context, step) : [];
796
+ const triage = context.reviewTriage === "required";
797
+ if (triage || grants.length) {
798
+ const { used, limit, failures } = budgetUsage(context, step);
799
+ const workBudget = workReviewBudget(context, step);
800
+ context.waitHuman(
801
+ `enter-${routed}`,
802
+ [
803
+ ...(grants.length ? [
804
+ `${step} budget exhausted: ${used}/${limit} review round(s) used in this run${workBudget ? `, ${workBudget.rounds}/${workBudget.allowed} across the work` : ""}, ${failures} real failure(s); approve enter-${routed} = fix + re-verify + one more review round; reject = end this run and decide the merge on the evidence you have`,
805
+ ] : []),
806
+ `review found ${blockingFindings.length} blocking finding(s); ${triage ? "triage" : "approve another round"} before ${routed} runs`,
807
+ ...blockingFindings.slice(0, 5).map((finding) =>
808
+ `[${finding.severity} ${fingerprintFinding(finding)}] ${finding.summary.slice(0, 200)}`),
809
+ `adjudicate fingerprints (findings adjudicate), then approve enter-${routed} or reject the run`,
810
+ ],
811
+ triage ? "finding-triage" : "budget",
812
+ grants,
813
+ );
814
+ return;
815
+ }
759
816
  }
760
817
  step = routed;
761
818
  }
@@ -797,13 +854,26 @@ function supersedeWaitingRuns(repoRoot, workId, newRunId, now) {
797
854
  continue;
798
855
  }
799
856
  try {
800
- ledger.append({
857
+ // Re-read under the lock: the run may have been approved, resumed or
858
+ // stopped since the scan, and the scan's read must not be written to.
859
+ const locked = EventLedger.open(ledgerPath);
860
+ const fresh = locked.state;
861
+ if (
862
+ locked.corruption ||
863
+ !fresh.run ||
864
+ fresh.run.work !== workId ||
865
+ fresh.terminal ||
866
+ fresh.run.status !== "WAITING_HUMAN"
867
+ ) {
868
+ continue;
869
+ }
870
+ locked.append({
801
871
  type: "RUN_TERMINAL",
802
872
  actor: KERNEL,
803
873
  ts: now(),
804
874
  data: { status: "SUPERSEDED", reason: `superseded by ${newRunId} (same work ${workId})` },
805
875
  });
806
- writeRunRecord({ repoRoot, ledger, ts: now() });
876
+ writeRunRecord({ repoRoot, ledger: locked, ts: now() });
807
877
  superseded.push(entry);
808
878
  } finally {
809
879
  releaseLock(repoRoot, entry);
@@ -917,15 +987,8 @@ function resumeStepFromTransition(transition) {
917
987
  return null;
918
988
  }
919
989
 
920
- export function resumeRun(options) {
921
- const { repoRoot, workflow, workflowDigest, runId, planDigest } = options;
922
- if (!repoRoot || !runId) {
923
- throw new OrchestratorError("repoRoot and runId are required");
924
- }
925
- if (options.requires?.length) {
926
- assertRequires(options.requires);
927
- }
928
- const { ledger, ledgerPath } = openLedgerFor(repoRoot, runId);
990
+ function resumeTarget(options, { ledger, ledgerPath }) {
991
+ const { repoRoot, workflowDigest, runId } = options;
929
992
  const state = ledger.state;
930
993
  if (!state.run) {
931
994
  throw new OrchestratorError(`no ledger for run ${runId}; use startRun`);
@@ -936,12 +999,11 @@ export function resumeRun(options) {
936
999
  );
937
1000
  }
938
1001
  if (state.terminal) {
939
- return { runId, ledgerPath, state, resumed: false, reason: "run is terminal" };
1002
+ return { early: { runId, ledgerPath, state, resumed: false, reason: "run is terminal" } };
940
1003
  }
941
1004
  if (state.run.status === "WAITING_HUMAN" && state.pendingHuman) {
942
- return { runId, ledgerPath, state, resumed: false, reason: "waiting on a human decision" };
1005
+ return { early: { runId, ledgerPath, state, resumed: false, reason: "waiting on a human decision" } };
943
1006
  }
944
-
945
1007
  const bound = state.workspaces[runId];
946
1008
  if (!bound) {
947
1009
  throw new OrchestratorError(`run ${runId} has no bound workspace; cannot resume`);
@@ -952,15 +1014,43 @@ export function resumeRun(options) {
952
1014
  "worktree missing; recovery requires a human decision",
953
1015
  );
954
1016
  }
955
- const workspace = {
956
- workspaceId: runId,
957
- repoRoot,
958
- worktreePath,
959
- branch: bound.branch,
960
- base: bound.base,
1017
+ return {
1018
+ workspace: {
1019
+ workspaceId: runId,
1020
+ repoRoot,
1021
+ worktreePath,
1022
+ branch: bound.branch,
1023
+ base: bound.base,
1024
+ },
961
1025
  };
1026
+ }
1027
+
1028
+ export function resumeRun(options) {
1029
+ const { repoRoot, runId, planDigest } = options;
1030
+ if (!repoRoot || !runId) {
1031
+ throw new OrchestratorError("repoRoot and runId are required");
1032
+ }
1033
+ if (options.requires?.length) {
1034
+ assertRequires(options.requires);
1035
+ }
1036
+ // The read before the locks only answers early (terminal, waiting on a
1037
+ // human) without taking the repository lock. Everything resume decides is
1038
+ // decided again on a ledger read under the locks: another session may
1039
+ // have approved, resumed or stopped the run in between, and writing
1040
+ // through the earlier read would fork the hash chain.
1041
+ const outside = resumeTarget(options, openLedgerFor(repoRoot, runId));
1042
+ if (outside.early) {
1043
+ return outside.early;
1044
+ }
962
1045
 
963
1046
  return withRunLocks(repoRoot, runId, () => {
1047
+ const { ledger, ledgerPath } = openLedgerFor(repoRoot, runId);
1048
+ const target = resumeTarget(options, { ledger, ledgerPath });
1049
+ if (target.early) {
1050
+ return target.early;
1051
+ }
1052
+ const { workspace } = target;
1053
+ const state = ledger.state;
964
1054
  const context = makeContext(options, ledger, workspace);
965
1055
  const now = context.now;
966
1056
 
@@ -1004,36 +1094,73 @@ export function resumeRun(options) {
1004
1094
  // "one more"; record the grant before driving or the same request
1005
1095
  // comes straight back (the pilot's app-login runs ended CANCELLED
1006
1096
  // 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
- });
1097
+ const decisionIndex = ledger.events.findIndex((event) =>
1098
+ event.type === "DECISION_RECORDED" && event.data.decisionRef === approval.decisionRef);
1099
+ // APPROVAL_STALE is itself a new request, with no inherited grants.
1100
+ // Restrict lookup to this decision, rather than an older request for
1101
+ // the same transition (or one whose subject was refreshed).
1102
+ const request = ledger.events.slice(0, decisionIndex).reverse().find((event) =>
1103
+ event.type === "HUMAN_REQUESTED" || event.type === "APPROVAL_STALE");
1104
+ const requestData = request?.type === "HUMAN_REQUESTED" &&
1105
+ request.data.transition === approval.transition ? request.data : null;
1106
+ // The grant plan is fixed once, then pinned on the first
1107
+ // BUDGET_EXTENDED it produces; a resume after a crash between two
1108
+ // grants replays that plan instead of re-deriving it from a state the
1109
+ // first grant already raised.
1110
+ const applied = ledger.events.filter((event) =>
1111
+ event.type === "BUDGET_EXTENDED" && event.data.approvalRef === approval.decisionRef);
1112
+ let grants;
1113
+ if (Array.isArray(applied[0]?.data.grants)) {
1114
+ grants = applied[0].data.grants;
1024
1115
  } else if (
1025
- approval.transition.startsWith("resume-") &&
1026
- (state.steps[step]?.attempts ?? 0) >= context.maxAttemptsFor(step)
1116
+ Array.isArray(requestData?.grants) &&
1117
+ (canonicalJson(requestData.subject) === canonicalJson(approval.subject) ||
1118
+ // resume --adopt answers this very request with a new candidate by
1119
+ // design; the grant belongs to the round, not to a candidate. Real
1120
+ // incident: the session's hand fix was adopted and the run stopped
1121
+ // again at resume-review for the round the human had just granted.
1122
+ (ledger.events[decisionIndex]?.data.adopted &&
1123
+ approval.subject.planDigest === requestData.subject.planDigest))
1027
1124
  ) {
1125
+ grants = requestData.grants;
1126
+ } else {
1127
+ // Requests without grants: ledgers written before grants existed,
1128
+ // or a request refreshed by APPROVAL_STALE.
1129
+ grants = [];
1130
+ const attempts = state.steps[step]?.attempts ?? 0;
1131
+ const runCapApproval = approval.transition.startsWith("resume-") &&
1132
+ (attempts >= context.maxAttemptsFor(step) || attempts >= context.totalAttemptsFor(step));
1133
+ if (requestData?.kind === "work-review-cap") grants.push({ step, scope: "work" });
1134
+ if (runCapApproval) grants.push({ step, scope: "run" });
1135
+ if (grants.length > 0) grants.push(...budgetGrants(context, step));
1136
+ }
1137
+ const plan = [];
1138
+ const planned = new Set();
1139
+ for (const grant of grants) {
1140
+ const key = `${grant.step}:${grant.scope}`;
1141
+ if (!planned.has(key)) {
1142
+ planned.add(key);
1143
+ plan.push({ step: grant.step, scope: grant.scope });
1144
+ }
1145
+ }
1146
+ const extended = new Set(applied.map((event) => `${event.data.step}:${event.data.scope ?? "run"}`));
1147
+ for (const grant of plan) {
1148
+ const key = `${grant.step}:${grant.scope}`;
1149
+ if (extended.has(key)) continue;
1150
+ extended.add(key);
1028
1151
  ledger.append({
1029
1152
  type: "BUDGET_EXTENDED",
1030
1153
  actor: KERNEL,
1031
1154
  ts: now(),
1032
1155
  data: {
1033
- step,
1156
+ step: grant.step,
1034
1157
  amount: 1,
1035
- maxAttempts: context.maxAttemptsFor(step) + 1,
1158
+ ...(grant.scope === "work" ? { scope: "work" } : {}),
1159
+ maxAttempts: grant.scope === "work"
1160
+ ? (context.runBudgets.reviewRoundsPerWork ?? 0) + (ledger.state.workReviewGrants ?? 0) + 1
1161
+ : context.maxAttemptsFor(grant.step) + 1,
1036
1162
  approvalRef: approval.decisionRef,
1163
+ grants: plan,
1037
1164
  },
1038
1165
  });
1039
1166
  }
@@ -4,7 +4,7 @@
4
4
  // further appends — recovery is a human decision, never a silent repair.
5
5
 
6
6
  import { createHash } from "node:crypto";
7
- import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
7
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
8
8
  import { dirname } from "node:path";
9
9
 
10
10
  import { ENVELOPE_VERSION, GENESIS_DIGEST, validateEventInput } from "../domain/event-registry.js";
@@ -49,6 +49,9 @@ export class EventLedger {
49
49
  #run = null;
50
50
  #work = null;
51
51
  #reducer;
52
+ // Bytes of the file this instance has read or written. A writer whose
53
+ // count no longer matches the file is stale: someone else appended since.
54
+ #size = 0;
52
55
 
53
56
  constructor(filePath, reducer = RUN_REDUCER) {
54
57
  this.path = filePath;
@@ -72,7 +75,10 @@ export class EventLedger {
72
75
  }
73
76
 
74
77
  #load() {
75
- const lines = readFileSync(this.path, "utf8")
78
+ const raw = readFileSync(this.path);
79
+ this.#size = raw.length;
80
+ const lines = raw
81
+ .toString("utf8")
76
82
  .split("\n")
77
83
  .filter((line) => line.length > 0);
78
84
  for (const [index, line] of lines.entries()) {
@@ -142,8 +148,21 @@ export class EventLedger {
142
148
  };
143
149
  event.digest = eventDigest(event);
144
150
  const nextState = this.#reducer.applyEvent(this.state, event);
151
+ // Guard against a stale writer: every event carries seq and prev from
152
+ // this instance's memory, so appending after another writer would fork
153
+ // the hash chain and leave the ledger corrupted for good. Refuse
154
+ // instead; re-reading and retrying is always safe. Writers re-read under
155
+ // the run lock, so this only fires on a writer that forgot to.
156
+ const onDisk = existsSync(this.path) ? statSync(this.path).size : 0;
157
+ if (onDisk !== this.#size) {
158
+ throw new LedgerError(
159
+ `ledger for ${runId} changed on disk since it was read (another writer); re-read it and retry`,
160
+ );
161
+ }
162
+ const line = `${JSON.stringify(event)}\n`;
145
163
  mkdirSync(dirname(this.path), { recursive: true });
146
- appendFileSync(this.path, `${JSON.stringify(event)}\n`, "utf8");
164
+ appendFileSync(this.path, line, "utf8");
165
+ this.#size += Buffer.byteLength(line, "utf8");
147
166
  this.events.push(event);
148
167
  this.state = nextState;
149
168
  this.lastDigest = event.digest;