@haiyangbg/buildbeat 3.3.0 → 4.0.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 (101) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.en.md +20 -124
  3. package/README.md +20 -124
  4. package/SKILL.md +73 -84
  5. package/docs/CAPABILITY-MATRIX.md +30 -39
  6. package/docs/MIGRATION.md +93 -0
  7. package/docs/README.md +8 -37
  8. package/docs/RELEASING.md +7 -3
  9. package/docs/v2/RFC-0001-product-definition.md +3 -0
  10. package/docs/v2/RFC-0002-domain-model.md +3 -0
  11. package/docs/v2/RFC-0003-workflow-policy.md +3 -0
  12. package/docs/v2/SPEC-0001-events-v1.md +3 -0
  13. package/docs/v2/guide/00-how-to-talk.en.md +12 -58
  14. package/docs/v2/guide/00-how-to-talk.md +12 -58
  15. package/docs/v2/guide/01-quickstart.en.md +41 -112
  16. package/docs/v2/guide/01-quickstart.md +41 -112
  17. package/docs/v2/guide/02-workflow-guide.en.md +13 -123
  18. package/docs/v2/guide/02-workflow-guide.md +14 -122
  19. package/docs/v2/guide/03-policy-guide.en.md +6 -50
  20. package/docs/v2/guide/03-policy-guide.md +6 -50
  21. package/docs/v2/guide/04-adapter-guide.en.md +7 -60
  22. package/docs/v2/guide/04-adapter-guide.md +7 -60
  23. package/docs/v2/guide/05-worker-contract.en.md +6 -53
  24. package/docs/v2/guide/05-worker-contract.md +7 -52
  25. package/docs/v2/guide/06-evidence-guide.en.md +8 -44
  26. package/docs/v2/guide/06-evidence-guide.md +9 -43
  27. package/docs/v2/guide/07-approval-guide.en.md +8 -110
  28. package/docs/v2/guide/07-approval-guide.md +8 -121
  29. package/docs/v2/guide/09-security-boundaries.en.md +13 -37
  30. package/docs/v2/guide/09-security-boundaries.md +13 -37
  31. package/docs/v2/guide/10-recovery.en.md +12 -96
  32. package/docs/v2/guide/10-recovery.md +12 -96
  33. package/docs/v2/guide/11-session-handoff.en.md +7 -78
  34. package/docs/v2/guide/11-session-handoff.md +7 -78
  35. package/docs/v2/guide/12-without-runtime.en.md +46 -0
  36. package/docs/v2/guide/12-without-runtime.md +46 -0
  37. package/docs/v2/guide/README.en.md +16 -36
  38. package/docs/v2/guide/README.md +16 -36
  39. package/example/AGENTS.md +15 -69
  40. package/example/README.md +8 -23
  41. package/example/delivery/envelope/prompts/builder.md +3 -1
  42. package/example/delivery/envelope/prompts/fixer.md +3 -1
  43. package/example/delivery/envelope/prompts/reviewer.md +3 -1
  44. package/example//346/214/207/346/214/245/345/217/260.md +8 -40
  45. package/lessons.md +7 -1
  46. package/package.json +2 -1
  47. package/src/v2/adapters/notification-payload.js +40 -0
  48. package/src/v2/adapters/shell.js +3 -1
  49. package/src/v2/cli/run-config-check.js +129 -45
  50. package/src/v2/cli/run.js +824 -255
  51. package/src/v2/engine/reducer.js +2 -0
  52. package/src/v2/engine/risk-preset.js +14 -47
  53. package/src/v2/engine/workflow.js +177 -185
  54. package/src/v2/evidence/collector.js +12 -1
  55. package/src/v2/evidence/image.js +346 -0
  56. package/src/v2/policy/policy.js +219 -286
  57. package/src/v2/presets/software-delivery.yaml +1 -16
  58. package/src/v2/runtime/cache.js +22 -1
  59. package/src/v2/runtime/decisions.js +185 -31
  60. package/src/v2/runtime/findings.js +74 -1
  61. package/src/v2/runtime/notify.js +42 -36
  62. package/src/v2/runtime/orchestrator.js +823 -301
  63. package/src/v2/runtime/overview.js +163 -34
  64. package/src/v2/runtime/release.js +233 -0
  65. package/src/v2/runtime/run-record.js +1 -0
  66. package/src/v2/workspace/workspace-manager.js +8 -0
  67. package/templates/v2/AGENTS.md +15 -69
  68. package/templates/v2/BUILDBEAT.md +2 -13
  69. package/templates/v2/envelope/prompts/builder.md +3 -1
  70. package/templates/v2/envelope/prompts/fixer.md +3 -1
  71. package/templates/v2/envelope/prompts/reviewer.md +3 -1
  72. package/templates/v2/run-config.example.yaml +7 -5
  73. package/templates/v2/work.example.md +16 -0
  74. package/templates/v2//346/214/207/346/214/245/345/217/260.md +8 -40
  75. package/docs/v2/skill/01-principles.md +0 -26
  76. package/docs/v2/skill/02-project-layout.md +0 -31
  77. package/docs/v2/skill/03-collaboration-rules.md +0 -45
  78. package/docs/v2/skill/04-rhythm-and-rituals.md +0 -104
  79. package/docs/v2/skill/05-red-lines.md +0 -13
  80. package/docs/v2/skill/06-bootstrap-and-takeover.md +0 -84
  81. package/docs/v2/skill/07-templates-and-lessons.md +0 -24
  82. package/example/.buildbeat/observe.yaml +0 -31
  83. package/src/v2/observe/observe-config.js +0 -194
  84. package/src/v2/observe/observe-reducer.js +0 -117
  85. package/src/v2/observe/observe.js +0 -427
  86. package/src/v2/presets/observe.yaml +0 -45
  87. package/src/v2/presets/policies/ui-render-gate.yaml +0 -13
  88. package/src/v2/presets/release-readback.yaml +0 -36
  89. package/src/v2/presets/risk/controlled.yaml +0 -39
  90. package/src/v2/presets/risk/fast.yaml +0 -19
  91. package/src/v2/presets/risk/release.yaml +0 -21
  92. package/src/v2/presets/risk/standard.yaml +0 -28
  93. package/templates/ARCHITECTURE.md +0 -50
  94. package/templates/contracts/PROTOCOL.md +0 -28
  95. package/templates/pm/adr/ADR-0000-template.md +0 -25
  96. package/templates/pm/adr/README.md +0 -15
  97. package/templates/pm/decisions.md +0 -11
  98. package/templates/standards/CODE.md +0 -23
  99. package/templates/standards/DESIGN.md +0 -36
  100. package/templates/standards/REVIEW.md +0 -20
  101. package/templates/standards/STACK.md +0 -31
@@ -11,17 +11,26 @@
11
11
  // anything changed goes APPROVAL_STALE and back to WAITING_HUMAN.
12
12
 
13
13
  import { createHash } from "node:crypto";
14
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
14
+ import {
15
+ existsSync,
16
+ mkdirSync,
17
+ readdirSync,
18
+ readFileSync,
19
+ rmSync,
20
+ writeFileSync,
21
+ } from "node:fs";
15
22
  import { join } from "node:path";
16
23
 
17
24
  import { nextStep } from "../engine/workflow.js";
18
25
  import { collectCommandEvidence } from "../evidence/collector.js";
19
- import { evaluatePolicies } from "../policy/policy.js";
26
+ import { checkScreenshotFile, screenshotFormat } from "../evidence/image.js";
27
+ import { currentScreenshots, evaluatePolicies } from "../policy/policy.js";
20
28
  import { EventLedger, canonicalJson } from "../storage/event-ledger.js";
21
29
  import {
22
30
  acquireLock,
23
31
  createWorkspace,
24
32
  describeLockOwner,
33
+ discardWorkspace,
25
34
  listChangedPaths,
26
35
  liveParallelMarkers,
27
36
  readback,
@@ -32,13 +41,20 @@ import { writeRunRecord } from "./run-record.js";
32
41
  import { computeWorkCost } from "./work-cost.js";
33
42
  import { assertRequires } from "./env-contract.js";
34
43
  import { materialisePrompt } from "./envelope.js";
35
- import { cacheKey, findReusableEvidence, lastReviewedCandidate, treeHash } from "./cache.js";
44
+ import {
45
+ cacheKey,
46
+ findReusableEvidence,
47
+ lastReviewedCandidate,
48
+ linkedScreenshots,
49
+ treeHash,
50
+ } from "./cache.js";
36
51
  import {
37
52
  buildAnchor,
38
53
  fingerprintFinding,
39
54
  latestAdjudications,
40
55
  readFindingsAccount,
41
56
  recordReviewFindings,
57
+ sameIssue,
42
58
  } from "./findings.js";
43
59
  import { resolveRepoRef, toRepoRef } from "./repo-ref.js";
44
60
 
@@ -105,7 +121,12 @@ function holdRunLock(repoRoot, runId, fn) {
105
121
  }
106
122
  }
107
123
 
108
- function withRunLocks(repoRoot, runId, fn, { workId = null, parallel = false } = {}) {
124
+ function withRunLocks(
125
+ repoRoot,
126
+ runId,
127
+ fn,
128
+ { workId = null, parallel = false } = {},
129
+ ) {
109
130
  if (!parallel) {
110
131
  lockActive(repoRoot);
111
132
  try {
@@ -113,8 +134,14 @@ function withRunLocks(repoRoot, runId, fn, { workId = null, parallel = false } =
113
134
  if (running.length > 0) {
114
135
  throw new OrchestratorError(
115
136
  `another run is active in this repository (parallel run(s) ${running
116
- .map((marker) => (marker.owner ? `${marker.run}, ${describeLockOwner(marker.owner)}` : marker.run))
117
- .join("; ")}); this run is exclusive (set parallel: true in its run config to drive alongside other works)`,
137
+ .map((marker) =>
138
+ marker.owner
139
+ ? `${marker.run}, ${describeLockOwner(marker.owner)}`
140
+ : marker.run,
141
+ )
142
+ .join(
143
+ "; ",
144
+ )}); this run is exclusive (set parallel: true in its run config to drive alongside other works)`,
118
145
  );
119
146
  }
120
147
  return holdRunLock(repoRoot, runId, fn);
@@ -191,7 +218,10 @@ export function parseEnvelope(raw) {
191
218
  !/^P[0-3]$/.test(finding.severity ?? "") ||
192
219
  typeof finding.summary !== "string"
193
220
  ) {
194
- return { envelope: null, error: "each finding needs severity P0-P3 and a summary" };
221
+ return {
222
+ envelope: null,
223
+ error: "each finding needs severity P0-P3 and a summary",
224
+ };
195
225
  }
196
226
  }
197
227
  }
@@ -229,15 +259,19 @@ function makeContext(options, ledger, workspace) {
229
259
  // A review step without an explicit cap takes the work-level cap as its run
230
260
  // cap, so review rounds are counted in one unit: the work.
231
261
  context.runBudgets = options.budgets ?? {};
232
- context.reviewRoundsPerWork = context.runBudgets.reviewRoundsPerWork ?? DEFAULT_REVIEW_ROUNDS_PER_WORK;
262
+ context.reviewRoundsPerWork =
263
+ context.runBudgets.reviewRoundsPerWork ?? DEFAULT_REVIEW_ROUNDS_PER_WORK;
233
264
  context.budgetLimitFor = (step) =>
234
265
  (context.runBudgets.maxAttempts?.[step] ??
235
266
  workflow.budgets?.maxAttempts?.[step] ??
236
- (isReviewStep(step, workflow.steps.find((item) => item.id === step))
267
+ (isReviewStep(
268
+ step,
269
+ workflow.steps.find((item) => item.id === step),
270
+ )
237
271
  ? context.reviewRoundsPerWork
238
- : maxAttemptsPerStep)) +
239
- (ledger.state.budgetExtensions?.[step] ?? 0);
240
- context.maxAttemptsFor = (step) => context.budgetLimitFor(step) +
272
+ : maxAttemptsPerStep)) + (ledger.state.budgetExtensions?.[step] ?? 0);
273
+ context.maxAttemptsFor = (step) =>
274
+ context.budgetLimitFor(step) +
241
275
  (ledger.state.steps[step]?.infraAttempts ?? 0) +
242
276
  (ledger.state.steps[step]?.freeAttempts ?? 0);
243
277
  // Refunds must not move this ceiling: otherwise a success-only loop has
@@ -251,16 +285,23 @@ function makeContext(options, ledger, workspace) {
251
285
  context.redact = options.redact ?? [];
252
286
  context.adapterConfigs = options.adapterConfigs ?? {};
253
287
  context.policyCtx = () => ({
288
+ repoRoot,
254
289
  state: ledger.state,
255
- candidate: ledger.state.workspaces[workspace.workspaceId]?.candidate ?? null,
290
+ candidate:
291
+ ledger.state.workspaces[workspace.workspaceId]?.candidate ?? null,
256
292
  workDir: join(repoRoot, "delivery", "work", ledger.state.run?.work ?? ""),
257
293
  worktreePath: workspace.worktreePath,
258
294
  readWorktree: () =>
259
- existsSync(workspace.worktreePath) ? readback(workspace.worktreePath) : null,
295
+ existsSync(workspace.worktreePath)
296
+ ? readback(workspace.worktreePath)
297
+ : null,
260
298
  });
261
299
  context.subjectNow = () => {
262
- const candidate = ledger.state.workspaces[workspace.workspaceId]?.candidate ?? workspace.base;
263
- const lastEvidence = ledger.state.evidence[ledger.state.evidence.length - 1];
300
+ const candidate =
301
+ ledger.state.workspaces[workspace.workspaceId]?.candidate ??
302
+ workspace.base;
303
+ const lastEvidence =
304
+ ledger.state.evidence[ledger.state.evidence.length - 1];
264
305
  return {
265
306
  candidate,
266
307
  planDigest: ledger.state.run?.planDigest ?? "UNVERIFIED",
@@ -272,7 +313,13 @@ function makeContext(options, ledger, workspace) {
272
313
  type: "HUMAN_REQUESTED",
273
314
  actor: KERNEL,
274
315
  ts: context.now(),
275
- data: { transition, subject: context.subjectNow(), reasons, kind, ...(grants.length ? { grants } : {}) },
316
+ data: {
317
+ transition,
318
+ subject: context.subjectNow(),
319
+ reasons,
320
+ kind,
321
+ ...(grants.length ? { grants } : {}),
322
+ },
276
323
  });
277
324
  };
278
325
  return context;
@@ -282,17 +329,24 @@ function makeContext(options, ledger, workspace) {
282
329
  function budgetUsage(context, step) {
283
330
  const state = context.ledger.state.steps[step];
284
331
  const attempts = state?.attempts ?? 0;
285
- const used = attempts - (state?.infraAttempts ?? 0) - (state?.freeAttempts ?? 0);
286
- const failures = context.ledger.events.filter((event) =>
287
- event.type === "STEP_FINISHED" && event.data.step === step &&
288
- event.data.status !== "succeeded" && event.data.infra !== true).length;
332
+ const used =
333
+ attempts - (state?.infraAttempts ?? 0) - (state?.freeAttempts ?? 0);
334
+ const failures = context.ledger.events.filter(
335
+ (event) =>
336
+ event.type === "STEP_FINISHED" &&
337
+ event.data.step === step &&
338
+ event.data.status !== "succeeded" &&
339
+ event.data.infra !== true,
340
+ ).length;
289
341
  return { attempts, used, failures, limit: context.budgetLimitFor(step) };
290
342
  }
291
343
 
292
344
  function budgetReasons(context, step, safeguard = false) {
293
345
  const { attempts, used, failures, limit } = budgetUsage(context, step);
294
- const charging = context.workflow.steps.find((item) => item.id === step)?.readonly
295
- ? "each round is charged" : "successful attempts are not charged";
346
+ const charging = context.workflow.steps.find((item) => item.id === step)
347
+ ?.readonly
348
+ ? "each round is charged"
349
+ : "successful attempts are not charged";
296
350
  return [
297
351
  safeguard
298
352
  ? `${step} budget exhausted (runaway safeguard): ${attempts}/${context.totalAttemptsFor(step)} total attempt(s), ${failures} real failure(s)`
@@ -305,10 +359,15 @@ function workReviewBudget(context, step) {
305
359
  const stepDef = context.workflow.steps.find((item) => item.id === step);
306
360
  const cap = context.reviewRoundsPerWork;
307
361
  if (!isReviewStep(step, stepDef)) return null;
308
- const prior = computeWorkCost(context.repoRoot, context.ledger.state.run.work, {
309
- excludeRun: context.ledger.state.run.id,
310
- });
311
- const rounds = prior.reviewRounds + (context.ledger.state.steps[step]?.attempts ?? 0);
362
+ const prior = computeWorkCost(
363
+ context.repoRoot,
364
+ context.ledger.state.run.work,
365
+ {
366
+ excludeRun: context.ledger.state.run.id,
367
+ },
368
+ );
369
+ const rounds =
370
+ prior.reviewRounds + (context.ledger.state.steps[step]?.attempts ?? 0);
312
371
  const allowed = cap + (context.ledger.state.workReviewGrants ?? 0);
313
372
  return { rounds, allowed, exhausted: rounds >= allowed };
314
373
  }
@@ -317,11 +376,16 @@ function workReviewBudget(context, step) {
317
376
  // at stop time, so one approval lifts both layers (run and work) at once.
318
377
  function budgetGrants(context, step) {
319
378
  const grants = [];
320
- if ((context.ledger.state.steps[step]?.attempts ?? 0) >= context.maxAttemptsFor(step) ||
321
- (context.ledger.state.steps[step]?.attempts ?? 0) >= context.totalAttemptsFor(step)) {
379
+ if (
380
+ (context.ledger.state.steps[step]?.attempts ?? 0) >=
381
+ context.maxAttemptsFor(step) ||
382
+ (context.ledger.state.steps[step]?.attempts ?? 0) >=
383
+ context.totalAttemptsFor(step)
384
+ ) {
322
385
  grants.push({ step, scope: "run" });
323
386
  }
324
- if (workReviewBudget(context, step)?.exhausted) grants.push({ step, scope: "work" });
387
+ if (workReviewBudget(context, step)?.exhausted)
388
+ grants.push({ step, scope: "work" });
325
389
  return grants;
326
390
  }
327
391
 
@@ -333,25 +397,51 @@ function reviewRoundAttempt(ref, step) {
333
397
  }
334
398
 
335
399
  // Compares this round's blocking findings with the earlier review rounds of
336
- // the same step in this run. A finding a fixer was already sent after that
337
- // comes back, or more blocking findings than last round, means another
338
- // automatic round would not converge; a shrinking set of new findings keeps
339
- // going without asking anyone. Returns the stop reason, or null.
400
+ // the same step in this run. The same problem coming back after a fixer was
401
+ // sent (same fingerprint, or a restatement: sameIssue), or more blocking
402
+ // findings than last round, means another automatic round would not
403
+ // converge; a shrinking set of new findings keeps going without asking
404
+ // anyone. Returns the stop reason, or null.
340
405
  function reviewNotConverging(context, step, blockingFindings) {
341
- const rounds = context.ledger.state.evidence.filter((item) =>
342
- item.kind === "review" && reviewRoundAttempt(item.ref ?? "", step) !== null);
406
+ const rounds = context.ledger.state.evidence.filter(
407
+ (item) =>
408
+ item.kind === "review" &&
409
+ reviewRoundAttempt(item.ref ?? "", step) !== null,
410
+ );
343
411
  if (rounds.length < 2) return null;
344
412
  const adjudicated = latestAdjudications(
345
413
  readFindingsAccount(context.repoRoot, context.ledger.state.run.work),
346
414
  );
347
- const blocking = (round) => (round.findings ?? [])
348
- .filter((finding) => finding.severity === "P0" || finding.severity === "P1")
349
- .map(fingerprintFinding)
350
- .filter((fingerprint) => adjudicated.get(fingerprint)?.action !== "dismiss");
351
- const earlier = new Set(rounds.slice(0, -1).flatMap(blocking));
352
- const lastCount = new Set(blocking(rounds.at(-2))).size;
353
- const current = [...new Set(blockingFindings.map(fingerprintFinding))];
354
- const repeated = current.filter((fingerprint) => earlier.has(fingerprint));
415
+ const blocking = (round) =>
416
+ (round.findings ?? [])
417
+ .filter(
418
+ (finding) => finding.severity === "P0" || finding.severity === "P1",
419
+ )
420
+ .filter(
421
+ (finding) =>
422
+ adjudicated.get(fingerprintFinding(finding))?.action !== "dismiss",
423
+ );
424
+ const earlier = rounds
425
+ .slice(0, -1)
426
+ .flatMap((round, index) =>
427
+ blocking(round).map((finding) => ({ finding, round: index + 1 })),
428
+ );
429
+ const lastCount = new Set(blocking(rounds.at(-2)).map(fingerprintFinding))
430
+ .size;
431
+ const current = [
432
+ ...new Map(
433
+ blockingFindings.map((finding) => [fingerprintFinding(finding), finding]),
434
+ ).values(),
435
+ ];
436
+ const repeated = current.flatMap((finding) => {
437
+ const match = earlier.find((item) => sameIssue(finding, item.finding));
438
+ if (!match) return [];
439
+ const now = fingerprintFinding(finding);
440
+ const before = fingerprintFinding(match.finding);
441
+ return [
442
+ now === before ? now : `${now} restates round ${match.round} ${before}`,
443
+ ];
444
+ });
355
445
  if (repeated.length > 0) {
356
446
  return `${step} is not converging: ${repeated.length} blocking finding(s) came back after fix (${repeated.join(", ")}); approving runs fix + re-verify + one more review round, dismissing a finding stops it blocking`;
357
447
  }
@@ -365,7 +455,11 @@ function reviewNotConverging(context, step, blockingFindings) {
365
455
  // verdict as a POLICY_EVALUATED event, and reports what the kernel must do.
366
456
  // ADVISORY failures are recorded but never gate (doctor reports the gap).
367
457
  function runPolicyGate(context, type, appliesTo) {
368
- const rows = evaluatePolicies(context.policies, { type, appliesTo }, context.policyCtx());
458
+ const rows = evaluatePolicies(
459
+ context.policies,
460
+ { type, appliesTo },
461
+ context.policyCtx(),
462
+ );
369
463
  for (const row of rows) {
370
464
  context.ledger.append({
371
465
  type: "POLICY_EVALUATED",
@@ -380,7 +474,9 @@ function runPolicyGate(context, type, appliesTo) {
380
474
  },
381
475
  });
382
476
  }
383
- const enforced = rows.filter((row) => row.enforcement !== "ADVISORY" && row.result !== "PASS");
477
+ const enforced = rows.filter(
478
+ (row) => row.enforcement !== "ADVISORY" && row.result !== "PASS",
479
+ );
384
480
  if (enforced.length === 0) {
385
481
  return { action: "continue", rows };
386
482
  }
@@ -419,8 +515,15 @@ function settleOutcome(context, step, outcome, tree, exec) {
419
515
  }
420
516
  // A step that failed its final attempt can never run again, so routing
421
517
  // to fix would spend a worker on a candidate nothing can verify.
422
- if ((ledger.state.steps[step]?.attempts ?? 0) >= context.maxAttemptsFor(step)) {
423
- context.waitHuman(`resume-${step}`, budgetReasons(context, step), "budget", budgetGrants(context, step));
518
+ if (
519
+ (ledger.state.steps[step]?.attempts ?? 0) >= context.maxAttemptsFor(step)
520
+ ) {
521
+ context.waitHuman(
522
+ `resume-${step}`,
523
+ budgetReasons(context, step),
524
+ "budget",
525
+ budgetGrants(context, step),
526
+ );
424
527
  return null;
425
528
  }
426
529
  }
@@ -442,7 +545,9 @@ function settleOutcome(context, step, outcome, tree, exec) {
442
545
  phase: "transition",
443
546
  result,
444
547
  enforcement: "LOCAL_ENFORCED",
445
- reason: to ? `(${step}, ${outcome}) -> ${to}` : `no transition for (${step}, ${outcome})`,
548
+ reason: to
549
+ ? `(${step}, ${outcome}) -> ${to}`
550
+ : `no transition for (${step}, ${outcome})`,
446
551
  },
447
552
  });
448
553
  if (!to) {
@@ -466,8 +571,17 @@ function settleOutcome(context, step, outcome, tree, exec) {
466
571
  actor: KERNEL,
467
572
  ts: now(),
468
573
  data: {
469
- resumePoint: { step: to, attempt: (ledger.state.steps[to]?.attempts ?? 0) + 1 },
470
- workspaceStates: [{ workspaceId: workspace.workspaceId, head: tree.head, dirty: tree.dirty }],
574
+ resumePoint: {
575
+ step: to,
576
+ attempt: (ledger.state.steps[to]?.attempts ?? 0) + 1,
577
+ },
578
+ workspaceStates: [
579
+ {
580
+ workspaceId: workspace.workspaceId,
581
+ head: tree.head,
582
+ dirty: tree.dirty,
583
+ },
584
+ ],
471
585
  },
472
586
  });
473
587
  return to;
@@ -493,7 +607,9 @@ function checkBeforeStep(context, step, { skipBoundary }) {
493
607
  return null;
494
608
  }
495
609
  if (context.stopAt.includes(step) && !skipBoundary) {
496
- context.waitHuman(`enter-${step}`, [`automation boundary: stopAt includes ${step}`]);
610
+ context.waitHuman(`enter-${step}`, [
611
+ `automation boundary: stopAt includes ${step}`,
612
+ ]);
497
613
  return null;
498
614
  }
499
615
  const stepDef = workflow.steps.find((candidate) => candidate.id === step);
@@ -543,12 +659,22 @@ function checkBeforeStep(context, step, { skipBoundary }) {
543
659
  const attempt = (ledger.state.steps[step]?.attempts ?? 0) + 1;
544
660
  const maxAttempts = context.maxAttemptsFor(step);
545
661
  if (attempt > maxAttempts) {
546
- context.waitHuman(`resume-${step}`, budgetReasons(context, step), "budget", budgetGrants(context, step));
662
+ context.waitHuman(
663
+ `resume-${step}`,
664
+ budgetReasons(context, step),
665
+ "budget",
666
+ budgetGrants(context, step),
667
+ );
547
668
  return null;
548
669
  }
549
670
 
550
671
  if (attempt > context.totalAttemptsFor(step)) {
551
- context.waitHuman(`resume-${step}`, budgetReasons(context, step, true), "budget", budgetGrants(context, step));
672
+ context.waitHuman(
673
+ `resume-${step}`,
674
+ budgetReasons(context, step, true),
675
+ "budget",
676
+ budgetGrants(context, step),
677
+ );
552
678
  return null;
553
679
  }
554
680
 
@@ -574,14 +700,30 @@ function beginStep(context, step, stepDef, attempt) {
574
700
  },
575
701
  });
576
702
  const before = stepDef.readonly ? readback(workspace.worktreePath) : null;
577
- const outputsDir = join(context.runtimeDir, "runs", ledger.state.run.id, "outputs");
703
+ const outputsDir = join(
704
+ context.runtimeDir,
705
+ "runs",
706
+ ledger.state.run.id,
707
+ "outputs",
708
+ );
578
709
  mkdirSync(outputsDir, { recursive: true });
579
710
  const outputPath = join(outputsDir, `${step}-${attempt}.json`);
580
711
  // Anchored review: readonly (reviewer) steps receive the adjudicated
581
712
  // findings history so a fresh reviewer inherits settled verdicts instead
582
713
  // of re-litigating them; writing steps get the latest review findings
583
714
  // with their adjudication status (the fixer's worklist).
584
- const input = { workId: ledger.state.run.work, runId: ledger.state.run.id, step, attempt };
715
+ const input = {
716
+ workId: ledger.state.run.work,
717
+ runId: ledger.state.run.id,
718
+ step,
719
+ attempt,
720
+ };
721
+ const artifact = ledger.state.run.deliveryChecks?.artifact;
722
+ if (artifact)
723
+ input.workArtifact = {
724
+ ref: `delivery/work/${ledger.state.run.work}/${artifact}.md`,
725
+ digest: ledger.state.run.planDigest,
726
+ };
585
727
  const anchor = buildAnchor(context.repoRoot, ledger.state.run.work);
586
728
  if (anchor && stepDef.readonly) {
587
729
  input.anchor = anchor;
@@ -597,7 +739,8 @@ function beginStep(context, step, stepDef, attempt) {
597
739
  severity: finding.severity,
598
740
  summary: finding.summary,
599
741
  fingerprint: fingerprintFinding(finding),
600
- adjudication: adjudicated.get(fingerprintFinding(finding))?.action ?? "open",
742
+ adjudication:
743
+ adjudicated.get(fingerprintFinding(finding))?.action ?? "open",
601
744
  }));
602
745
  }
603
746
  }
@@ -613,24 +756,75 @@ function beginStep(context, step, stepDef, attempt) {
613
756
  repoRoot: context.repoRoot,
614
757
  });
615
758
  if (prompt) {
616
- input.envelope = { promptRef: prompt.ref, file: prompt.file, digest: context.envelope.digest, vars: context.envelope.vars };
759
+ input.envelope = {
760
+ promptRef: prompt.ref,
761
+ file: prompt.file,
762
+ digest: context.envelope.digest,
763
+ vars: context.envelope.vars,
764
+ };
617
765
  }
618
766
  // Incremental review (C7): tell a reviewer which candidate the last
619
767
  // review saw when it is an ancestor of this one.
620
768
  if (stepDef.readonly) {
621
769
  const head = before.head;
622
- const lastReviewed = lastReviewedCandidate(context.repoRoot, ledger.state.run.work, workspace.worktreePath, head);
770
+ const lastReviewed = lastReviewedCandidate(
771
+ context.repoRoot,
772
+ ledger.state.run.work,
773
+ workspace.worktreePath,
774
+ head,
775
+ );
623
776
  if (lastReviewed) {
624
777
  input.lastReviewed = lastReviewed;
625
778
  }
779
+ // The reviewer judges the real render it is shown, not a description.
780
+ const screenshots = currentScreenshots(ledger.state.evidence, head);
781
+ if (screenshots.length > 0) {
782
+ input.screenshots = screenshots.map((item) => ({
783
+ path: resolveRepoRef(context.repoRoot, item.ref),
784
+ digest: item.digest,
785
+ }));
786
+ }
626
787
  }
627
788
  return { before, outputPath, input, prompt };
628
789
  }
629
790
 
791
+ // An image counts only as a regular file (no symlink that could point at
792
+ // something removed later) that is structurally complete for its format.
793
+ function screenshotFiles(dir) {
794
+ const accepted = [];
795
+ const rejected = [];
796
+ for (const name of readdirSync(dir).sort()) {
797
+ if (!screenshotFormat(name)) {
798
+ continue;
799
+ }
800
+ const path = join(dir, name);
801
+ const checked = checkScreenshotFile(path);
802
+ if (checked.ok) {
803
+ accepted.push({ file: path, digest: checked.digest });
804
+ } else {
805
+ rejected.push(checked.reason);
806
+ }
807
+ }
808
+ return { accepted, rejected };
809
+ }
810
+
630
811
  // Runs the worker, or references identical passed evidence (verification
631
812
  // reuse), then records the command evidence for the tree git reads back.
632
- function executeOrReuse(context, step, stepDef, adapter, attempt, { before, outputPath, input, prompt }) {
813
+ function executeOrReuse(
814
+ context,
815
+ step,
816
+ stepDef,
817
+ adapter,
818
+ attempt,
819
+ { before, outputPath, input, prompt },
820
+ ) {
633
821
  const { ledger, workspace, now } = context;
822
+ // UI work (requireScreenshot): verify leaves screenshots of the real
823
+ // render in a fresh directory outside the worktree; each image becomes
824
+ // screenshot evidence for this candidate.
825
+ const wantsScreenshots =
826
+ step === "verify" &&
827
+ ledger.state.run.deliveryChecks?.requireScreenshot === true;
634
828
  // Verification reuse (C7): same tree + same worker + same envelope that
635
829
  // already passed is referenced, not re-run. Failures always re-run.
636
830
  let stepCacheKey = null;
@@ -644,10 +838,44 @@ function executeOrReuse(context, step, stepDef, adapter, attempt, { before, outp
644
838
  adapterSpec: context.adapterConfigs[stepDef.worker] ?? null,
645
839
  adapterName: adapter.name,
646
840
  envelopeDigest: context.envelope?.digest ?? null,
841
+ screenshots: wantsScreenshots,
647
842
  });
648
843
  reused = findReusableEvidence(context.repoRoot, stepCacheKey);
649
844
  }
650
845
  }
846
+ // A reused verify carries its screenshots; one without any runs again.
847
+ let reusedScreenshots = [];
848
+ if (reused && wantsScreenshots) {
849
+ reusedScreenshots = linkedScreenshots(
850
+ context.repoRoot,
851
+ reused.run,
852
+ reused.evidenceRef,
853
+ );
854
+ // Only files that still pass today's screenshot check with their
855
+ // recorded bytes carry over; anything else means verify runs again.
856
+ const intact = reusedScreenshots.every((item) => {
857
+ const checked = checkScreenshotFile(
858
+ resolveRepoRef(context.repoRoot, item.evidenceRef),
859
+ );
860
+ return checked.ok && checked.digest === item.digest;
861
+ });
862
+ if (reusedScreenshots.length === 0 || !intact) {
863
+ reused = null;
864
+ reusedScreenshots = [];
865
+ }
866
+ }
867
+ let screenshotDir = null;
868
+ if (wantsScreenshots && !reused) {
869
+ screenshotDir = join(
870
+ context.runtimeDir,
871
+ "runs",
872
+ ledger.state.run.id,
873
+ "screenshots",
874
+ `${step}-${attempt}`,
875
+ );
876
+ rmSync(screenshotDir, { recursive: true, force: true });
877
+ mkdirSync(screenshotDir, { recursive: true });
878
+ }
651
879
  let exec;
652
880
  if (reused) {
653
881
  const at = now();
@@ -676,8 +904,36 @@ function executeOrReuse(context, step, stepDef, adapter, attempt, { before, outp
676
904
  liveDir: join(context.runtimeDir, "runs", ledger.state.run.id),
677
905
  promptPath: prompt?.path ?? null,
678
906
  vars: context.envelope?.vars ?? null,
907
+ extraEnv: screenshotDir
908
+ ? { BUILDBEAT_SCREENSHOT_DIR: screenshotDir }
909
+ : undefined,
679
910
  });
680
911
  }
912
+ const found = screenshotDir
913
+ ? screenshotFiles(screenshotDir)
914
+ : { accepted: [], rejected: [] };
915
+ const shots = found.accepted;
916
+ // Rejections are reported whether or not another screenshot passed.
917
+ if (found.rejected.length > 0) {
918
+ exec = { ...exec, screenshotsRejected: found.rejected };
919
+ }
920
+ if (
921
+ screenshotDir &&
922
+ shots.length === 0 &&
923
+ exec.exitCode === 0 &&
924
+ !exec.timedOut &&
925
+ !exec.signal &&
926
+ !exec.spawnError
927
+ ) {
928
+ exec = {
929
+ ...exec,
930
+ requirementFailure:
931
+ "requireScreenshot is on, but verify left no PNG screenshot in BUILDBEAT_SCREENSHOT_DIR" +
932
+ (found.rejected.length > 0
933
+ ? `; rejected: ${found.rejected.join(", ")}`
934
+ : ""),
935
+ };
936
+ }
681
937
  const tree = readback(workspace.worktreePath);
682
938
  const evidence = collectCommandEvidence({
683
939
  runtimeDir: context.runtimeDir,
@@ -686,7 +942,7 @@ function executeOrReuse(context, step, stepDef, adapter, attempt, { before, outp
686
942
  attempt,
687
943
  execResult: exec,
688
944
  subject: tree.head,
689
- grade: reused ? reused.grade : stepDef.grade ?? "L2",
945
+ grade: reused ? reused.grade : (stepDef.grade ?? "L2"),
690
946
  redact: context.redact,
691
947
  });
692
948
  ledger.append({
@@ -701,9 +957,49 @@ function executeOrReuse(context, step, stepDef, adapter, attempt, { before, outp
701
957
  status: evidence.status,
702
958
  grade: evidence.grade,
703
959
  ...(stepCacheKey ? { cacheKey: stepCacheKey } : {}),
704
- ...(reused ? { reused: { run: reused.run, evidenceRef: reused.evidenceRef, digest: reused.digest } } : {}),
960
+ ...(reused
961
+ ? {
962
+ reused: {
963
+ run: reused.run,
964
+ evidenceRef: reused.evidenceRef,
965
+ digest: reused.digest,
966
+ },
967
+ }
968
+ : {}),
705
969
  },
706
970
  });
971
+ if (evidence.status === "passed") {
972
+ const source = toRepoRef(context.repoRoot, evidence.location);
973
+ const recorded = reused
974
+ ? reusedScreenshots.map((item) => ({
975
+ ref: item.evidenceRef,
976
+ digest: item.digest,
977
+ }))
978
+ : shots.map((shot) => ({
979
+ ref: toRepoRef(context.repoRoot, shot.file),
980
+ digest: shot.digest,
981
+ }));
982
+ for (const shot of recorded) {
983
+ ledger.append({
984
+ type: "EVIDENCE_RECORDED",
985
+ actor: KERNEL,
986
+ ts: now(),
987
+ data: {
988
+ evidenceRef: shot.ref,
989
+ kind: "screenshot",
990
+ subject: tree.head,
991
+ digest: shot.digest,
992
+ status: "passed",
993
+ grade: "L2",
994
+ source,
995
+ ...(stepCacheKey ? { cacheKey: stepCacheKey } : {}),
996
+ ...(reused
997
+ ? { reused: { run: reused.run, evidenceRef: shot.ref } }
998
+ : {}),
999
+ },
1000
+ });
1001
+ }
1002
+ }
707
1003
 
708
1004
  return { exec, tree };
709
1005
  }
@@ -712,11 +1008,21 @@ function executeOrReuse(context, step, stepDef, adapter, attempt, { before, outp
712
1008
  // worker envelope, status and infrastructure failures, review findings,
713
1009
  // scope, the pinned candidate and post policies. Returns the result to
714
1010
  // route, or null when the run stopped.
715
- function recordStepResult(context, step, stepDef, attempt, { before, outputPath }, { exec, tree }) {
1011
+ function recordStepResult(
1012
+ context,
1013
+ step,
1014
+ stepDef,
1015
+ attempt,
1016
+ { before, outputPath },
1017
+ { exec, tree },
1018
+ ) {
716
1019
  const { ledger, workspace, now } = context;
717
1020
  // Read-only enforcement: a reviewer that changed the workspace is a
718
1021
  // policy violation, not a candidate (invariants 9/17).
719
- if (stepDef.readonly && (tree.head !== before.head || tree.dirty !== before.dirty)) {
1022
+ if (
1023
+ stepDef.readonly &&
1024
+ (tree.head !== before.head || tree.dirty !== before.dirty)
1025
+ ) {
720
1026
  ledger.append({
721
1027
  type: "POLICY_EVALUATED",
722
1028
  actor: KERNEL,
@@ -743,7 +1049,11 @@ function recordStepResult(context, step, stepDef, attempt, { before, outputPath
743
1049
 
744
1050
  let envelopeRaw = exec.envelope;
745
1051
  if (exec.envelope !== undefined && exec.envelope !== null) {
746
- writeFileSync(outputPath, `${JSON.stringify(exec.envelope, null, 2)}\n`, "utf8");
1052
+ writeFileSync(
1053
+ outputPath,
1054
+ `${JSON.stringify(exec.envelope, null, 2)}\n`,
1055
+ "utf8",
1056
+ );
747
1057
  } else if (existsSync(outputPath)) {
748
1058
  envelopeRaw = readFileSync(outputPath, "utf8");
749
1059
  }
@@ -758,6 +1068,10 @@ function recordStepResult(context, step, stepDef, attempt, { before, outputPath
758
1068
  stepStatus = "crashed";
759
1069
  } else if (exec.exitCode !== 0) {
760
1070
  stepStatus = "failed";
1071
+ } else if (exec.requirementFailure) {
1072
+ // The command passed but a requirement the runner checks did not
1073
+ // (no screenshots): a candidate failure, routed like any other.
1074
+ stepStatus = "failed";
761
1075
  } else if (envelopeError) {
762
1076
  stepStatus = "invalid-output";
763
1077
  } else {
@@ -781,8 +1095,18 @@ function recordStepResult(context, step, stepDef, attempt, { before, outputPath
781
1095
  type: "STEP_FINISHED",
782
1096
  actor: KERNEL,
783
1097
  ts: now(),
784
- data: { step, attempt, status: stepStatus, exitCode: exec.exitCode,
785
- ...(infra ? { infra: true } : {}), ...(free ? { free: true } : {}) },
1098
+ data: {
1099
+ step,
1100
+ attempt,
1101
+ status: stepStatus,
1102
+ exitCode: exec.exitCode,
1103
+ ...(exec.requirementFailure ? { reason: exec.requirementFailure } : {}),
1104
+ ...(exec.screenshotsRejected
1105
+ ? { screenshotsRejected: exec.screenshotsRejected }
1106
+ : {}),
1107
+ ...(infra ? { infra: true } : {}),
1108
+ ...(free ? { free: true } : {}),
1109
+ },
786
1110
  });
787
1111
  ledger.append({
788
1112
  type: "BUDGET_CONSUMED",
@@ -805,7 +1129,11 @@ function recordStepResult(context, step, stepDef, attempt, { before, outputPath
805
1129
  `resume-${step}`,
806
1130
  [
807
1131
  `worker infrastructure failure at ${step}: ${cause}; not a candidate defect, attempt not charged`,
808
- ...(tree.dirty ? [`the failed worker left the worktree dirty; inspect before rerunning`] : []),
1132
+ ...(tree.dirty
1133
+ ? [
1134
+ `the failed worker left the worktree dirty; inspect before rerunning`,
1135
+ ]
1136
+ : []),
809
1137
  `approve resume-${step} to rerun once the backend/environment is back; reject to end the run`,
810
1138
  ],
811
1139
  "infra",
@@ -847,7 +1175,9 @@ function recordStepResult(context, step, stepDef, attempt, { before, outputPath
847
1175
  status: blockingFindings.length > 0 ? "failed" : "passed",
848
1176
  grade: "L2",
849
1177
  findings: envelope.findings,
850
- ...(suppressed.length > 0 ? { suppressedFingerprints: suppressed } : {}),
1178
+ ...(suppressed.length > 0
1179
+ ? { suppressedFingerprints: suppressed }
1180
+ : {}),
851
1181
  },
852
1182
  });
853
1183
  }
@@ -861,7 +1191,8 @@ function recordStepResult(context, step, stepDef, attempt, { before, outputPath
861
1191
  (path) =>
862
1192
  !context.allowedPaths.some(
863
1193
  (prefix) =>
864
- path === prefix || path.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`),
1194
+ path === prefix ||
1195
+ path.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`),
865
1196
  ),
866
1197
  );
867
1198
  if (violations.length > 0) {
@@ -930,7 +1261,12 @@ function recordStepResult(context, step, stepDef, attempt, { before, outputPath
930
1261
  // Settles the outcome and picks the next step; blocking findings stop once
931
1262
  // for triage, the review budget or a review that is not converging before
932
1263
  // any fixer runs. Returns the next step, or null when the run stopped.
933
- function routeAfterStep(context, step, stepDef, { stepStatus, blockingFindings, tree, exec }) {
1264
+ function routeAfterStep(
1265
+ context,
1266
+ step,
1267
+ stepDef,
1268
+ { stepStatus, blockingFindings, tree, exec },
1269
+ ) {
934
1270
  let outcome;
935
1271
  if (stepStatus !== "succeeded") {
936
1272
  outcome = "failed";
@@ -945,7 +1281,9 @@ function routeAfterStep(context, step, stepDef, { stepStatus, blockingFindings,
945
1281
  if (routed && outcome === "findings-blocking") {
946
1282
  const review = isReviewStep(step, stepDef);
947
1283
  const grants = review ? budgetGrants(context, step) : [];
948
- const stalled = review ? reviewNotConverging(context, step, blockingFindings) : null;
1284
+ const stalled = review
1285
+ ? reviewNotConverging(context, step, blockingFindings)
1286
+ : null;
949
1287
  const triage = context.reviewTriage === "required";
950
1288
  if (triage || grants.length || stalled) {
951
1289
  const { used, limit, failures } = budgetUsage(context, step);
@@ -953,16 +1291,26 @@ function routeAfterStep(context, step, stepDef, { stepStatus, blockingFindings,
953
1291
  context.waitHuman(
954
1292
  `enter-${routed}`,
955
1293
  [
956
- ...(grants.length ? [
957
- `${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`,
958
- ] : []),
1294
+ ...(grants.length
1295
+ ? [
1296
+ `${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`,
1297
+ ]
1298
+ : []),
959
1299
  ...(stalled ? [stalled] : []),
960
1300
  `review found ${blockingFindings.length} blocking finding(s); ${triage ? "triage" : "approve another round"} before ${routed} runs`,
961
- ...blockingFindings.slice(0, 5).map((finding) =>
962
- `[${finding.severity} ${fingerprintFinding(finding)}] ${finding.summary.slice(0, 200)}`),
1301
+ ...blockingFindings
1302
+ .slice(0, 5)
1303
+ .map(
1304
+ (finding) =>
1305
+ `[${finding.severity} ${fingerprintFinding(finding)}] ${finding.summary.slice(0, 200)}`,
1306
+ ),
963
1307
  `adjudicate fingerprints (findings adjudicate), then approve enter-${routed} or reject the run`,
964
1308
  ],
965
- triage ? "finding-triage" : grants.length ? "budget" : "review-not-converging",
1309
+ triage
1310
+ ? "finding-triage"
1311
+ : grants.length
1312
+ ? "budget"
1313
+ : "review-not-converging",
966
1314
  grants,
967
1315
  );
968
1316
  return null;
@@ -975,15 +1323,31 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
975
1323
  let step = startStep;
976
1324
  let firstStep = true;
977
1325
  while (step) {
978
- const entry = checkBeforeStep(context, step, { skipBoundary: skipBoundaryOnce && firstStep });
1326
+ const entry = checkBeforeStep(context, step, {
1327
+ skipBoundary: skipBoundaryOnce && firstStep,
1328
+ });
979
1329
  firstStep = false;
980
1330
  if (!entry) {
981
1331
  return;
982
1332
  }
983
1333
  const { stepDef, adapter, attempt } = entry;
984
1334
  const started = beginStep(context, step, stepDef, attempt);
985
- const ran = executeOrReuse(context, step, stepDef, adapter, attempt, started);
986
- const result = recordStepResult(context, step, stepDef, attempt, started, ran);
1335
+ const ran = executeOrReuse(
1336
+ context,
1337
+ step,
1338
+ stepDef,
1339
+ adapter,
1340
+ attempt,
1341
+ started,
1342
+ );
1343
+ const result = recordStepResult(
1344
+ context,
1345
+ step,
1346
+ stepDef,
1347
+ attempt,
1348
+ started,
1349
+ ran,
1350
+ );
987
1351
  if (!result) {
988
1352
  return;
989
1353
  }
@@ -1044,7 +1408,10 @@ function supersedeWaitingRuns(repoRoot, workId, newRunId, now) {
1044
1408
  type: "RUN_TERMINAL",
1045
1409
  actor: KERNEL,
1046
1410
  ts: now(),
1047
- data: { status: "SUPERSEDED", reason: `superseded by ${newRunId} (same work ${workId})` },
1411
+ data: {
1412
+ status: "SUPERSEDED",
1413
+ reason: `superseded by ${newRunId} (same work ${workId})`,
1414
+ },
1048
1415
  });
1049
1416
  writeRunRecord({ repoRoot, ledger: locked, ts: now() });
1050
1417
  superseded.push(entry);
@@ -1056,7 +1423,14 @@ function supersedeWaitingRuns(repoRoot, workId, newRunId, now) {
1056
1423
  }
1057
1424
 
1058
1425
  function openLedgerFor(repoRoot, runId) {
1059
- const ledgerPath = join(repoRoot, ".buildbeat", "runtime", "runs", runId, "events.jsonl");
1426
+ const ledgerPath = join(
1427
+ repoRoot,
1428
+ ".buildbeat",
1429
+ "runtime",
1430
+ "runs",
1431
+ runId,
1432
+ "events.jsonl",
1433
+ );
1060
1434
  const ledger = EventLedger.open(ledgerPath);
1061
1435
  if (ledger.corruption) {
1062
1436
  throw new OrchestratorError(
@@ -1086,7 +1460,9 @@ export function startRun(options) {
1086
1460
  throw new OrchestratorError(`entry step not in workflow: ${entry}`);
1087
1461
  }
1088
1462
  if (!workflowDigest) {
1089
- throw new OrchestratorError("workflowDigest is required (pin what you run)");
1463
+ throw new OrchestratorError(
1464
+ "workflowDigest is required (pin what you run)",
1465
+ );
1090
1466
  }
1091
1467
  // Environment contract first: a missing or too-old binary fails the start
1092
1468
  // with a readable cause instead of burning a run on an implicit PATH fact.
@@ -1095,59 +1471,93 @@ export function startRun(options) {
1095
1471
  }
1096
1472
  const { ledger, ledgerPath } = openLedgerFor(repoRoot, runId);
1097
1473
  if (ledger.events.length > 0) {
1098
- throw new OrchestratorError(`run ${runId} already has a ledger; use resumeRun`);
1474
+ throw new OrchestratorError(
1475
+ `run ${runId} already has a ledger; use resumeRun`,
1476
+ );
1099
1477
  }
1100
1478
 
1101
- return withRunLocks(repoRoot, runId, () => {
1102
- const workspace = withRepoGitLock(repoRoot, () => createWorkspace({ repoRoot, runId, base }));
1103
- const context = makeContext(options, ledger, workspace);
1104
- const now = context.now;
1105
- const supersession =
1106
- options.supersede === "off"
1107
- ? { superseded: [], skipped: [] }
1108
- : supersedeWaitingRuns(repoRoot, workId, runId, now);
1109
- ledger.append({
1110
- type: "RUN_CREATED",
1111
- actor: KERNEL,
1112
- ts: now(),
1113
- run: runId,
1114
- work: workId,
1115
- data: {
1116
- workflowRef: workflow.name,
1117
- workflowDigest,
1118
- base: workspace.base,
1119
- riskPreset,
1120
- entry,
1121
- planDigest: planDigest ?? "UNVERIFIED",
1122
- intentDigest: intentDigest ?? "UNVERIFIED",
1123
- ...(supersession.superseded.length > 0 ? { supersedes: supersession.superseded } : {}),
1124
- ...(options.envelope ? { envelopeDigest: options.envelope.digest, envelopeSource: options.envelope.source } : {}),
1125
- },
1126
- });
1127
- ledger.append({ type: "RUN_STARTED", actor: KERNEL, ts: now(), data: {} });
1128
- ledger.append({
1129
- type: "WORKSPACE_BOUND",
1130
- actor: KERNEL,
1131
- ts: now(),
1132
- data: {
1133
- workspaceId: workspace.workspaceId,
1134
- repo: toRepoRef(repoRoot, repoRoot),
1135
- branch: workspace.branch,
1136
- worktreePath: toRepoRef(repoRoot, workspace.worktreePath),
1137
- base: workspace.base,
1138
- },
1139
- });
1140
- drive(context, entry);
1141
- return {
1142
- runId,
1143
- workId,
1144
- ledgerPath,
1145
- state: ledger.state,
1146
- workspace,
1147
- superseded: supersession.superseded,
1148
- supersedeSkipped: supersession.skipped,
1149
- };
1150
- }, { workId, parallel: options.parallel === true });
1479
+ return withRunLocks(
1480
+ repoRoot,
1481
+ runId,
1482
+ () => {
1483
+ const workspace = withRepoGitLock(repoRoot, () =>
1484
+ createWorkspace({ repoRoot, runId, base }),
1485
+ );
1486
+ // The checkout workers will read is the final word on what they were
1487
+ // given; a run whose checkout fails the caller's check never starts.
1488
+ if (options.verifyCheckout) {
1489
+ try {
1490
+ options.verifyCheckout(workspace.worktreePath);
1491
+ } catch (error) {
1492
+ withRepoGitLock(repoRoot, () => discardWorkspace(workspace));
1493
+ throw error;
1494
+ }
1495
+ }
1496
+ const context = makeContext(options, ledger, workspace);
1497
+ const now = context.now;
1498
+ const supersession =
1499
+ options.supersede === "off"
1500
+ ? { superseded: [], skipped: [] }
1501
+ : supersedeWaitingRuns(repoRoot, workId, runId, now);
1502
+ ledger.append({
1503
+ type: "RUN_CREATED",
1504
+ actor: KERNEL,
1505
+ ts: now(),
1506
+ run: runId,
1507
+ work: workId,
1508
+ data: {
1509
+ workflowRef: workflow.name,
1510
+ workflowDigest,
1511
+ base: workspace.base,
1512
+ riskPreset,
1513
+ entry,
1514
+ planDigest: planDigest ?? "UNVERIFIED",
1515
+ intentDigest: intentDigest ?? "UNVERIFIED",
1516
+ ...(options.deliveryChecks
1517
+ ? { deliveryChecks: options.deliveryChecks }
1518
+ : {}),
1519
+ ...(supersession.superseded.length > 0
1520
+ ? { supersedes: supersession.superseded }
1521
+ : {}),
1522
+ ...(options.envelope
1523
+ ? {
1524
+ envelopeDigest: options.envelope.digest,
1525
+ envelopeSource: options.envelope.source,
1526
+ }
1527
+ : {}),
1528
+ },
1529
+ });
1530
+ ledger.append({
1531
+ type: "RUN_STARTED",
1532
+ actor: KERNEL,
1533
+ ts: now(),
1534
+ data: {},
1535
+ });
1536
+ ledger.append({
1537
+ type: "WORKSPACE_BOUND",
1538
+ actor: KERNEL,
1539
+ ts: now(),
1540
+ data: {
1541
+ workspaceId: workspace.workspaceId,
1542
+ repo: toRepoRef(repoRoot, repoRoot),
1543
+ branch: workspace.branch,
1544
+ worktreePath: toRepoRef(repoRoot, workspace.worktreePath),
1545
+ base: workspace.base,
1546
+ },
1547
+ });
1548
+ drive(context, entry);
1549
+ return {
1550
+ runId,
1551
+ workId,
1552
+ ledgerPath,
1553
+ state: ledger.state,
1554
+ workspace,
1555
+ superseded: supersession.superseded,
1556
+ supersedeSkipped: supersession.skipped,
1557
+ };
1558
+ },
1559
+ { workId, parallel: options.parallel === true },
1560
+ );
1151
1561
  }
1152
1562
 
1153
1563
  function resumeStepFromTransition(transition) {
@@ -1160,26 +1570,61 @@ function resumeStepFromTransition(transition) {
1160
1570
  return null;
1161
1571
  }
1162
1572
 
1163
- function resumeTarget(options, { ledger, ledgerPath }) {
1164
- const { repoRoot, workflowDigest, runId } = options;
1165
- const state = ledger.state;
1166
- if (!state.run) {
1167
- throw new OrchestratorError(`no ledger for run ${runId}; use startRun`);
1573
+ export function assertRunConfiguration(state, options) {
1574
+ if (!state.run) throw new OrchestratorError("run has no creation record");
1575
+ if (options.workId && options.workId !== state.run.work)
1576
+ throw new OrchestratorError("run belongs to a different work");
1577
+ const { workflowDigest } = options;
1578
+ if (
1579
+ state.run.deliveryChecks &&
1580
+ canonicalJson(state.run.deliveryChecks) !==
1581
+ canonicalJson(options.deliveryChecks ?? null)
1582
+ ) {
1583
+ throw new OrchestratorError(
1584
+ "delivery safeguards changed since the run was created; refusing to resume (docs/MIGRATION.md)",
1585
+ );
1168
1586
  }
1169
1587
  if (workflowDigest && state.run.workflowDigest !== workflowDigest) {
1170
1588
  throw new OrchestratorError(
1171
1589
  `workflow changed since the run was created (${state.run.workflowDigest} != ${workflowDigest}); refusing to resume`,
1172
1590
  );
1173
1591
  }
1592
+ }
1593
+
1594
+ function resumeTarget(options, { ledger, ledgerPath }) {
1595
+ const { repoRoot, runId } = options;
1596
+ const state = ledger.state;
1597
+ if (!state.run) {
1598
+ throw new OrchestratorError(`no ledger for run ${runId}; use startRun`);
1599
+ }
1174
1600
  if (state.terminal) {
1175
- return { early: { runId, ledgerPath, state, resumed: false, reason: "run is terminal" } };
1601
+ return {
1602
+ early: {
1603
+ runId,
1604
+ ledgerPath,
1605
+ state,
1606
+ resumed: false,
1607
+ reason: "run is terminal",
1608
+ },
1609
+ };
1176
1610
  }
1611
+ assertRunConfiguration(state, options);
1177
1612
  if (state.run.status === "WAITING_HUMAN" && state.pendingHuman) {
1178
- return { early: { runId, ledgerPath, state, resumed: false, reason: "waiting on a human decision" } };
1613
+ return {
1614
+ early: {
1615
+ runId,
1616
+ ledgerPath,
1617
+ state,
1618
+ resumed: false,
1619
+ reason: "waiting on a human decision",
1620
+ },
1621
+ };
1179
1622
  }
1180
1623
  const bound = state.workspaces[runId];
1181
1624
  if (!bound) {
1182
- throw new OrchestratorError(`run ${runId} has no bound workspace; cannot resume`);
1625
+ throw new OrchestratorError(
1626
+ `run ${runId} has no bound workspace; cannot resume`,
1627
+ );
1183
1628
  }
1184
1629
  const worktreePath = resolveRepoRef(repoRoot, bound.worktreePath);
1185
1630
  if (!existsSync(worktreePath)) {
@@ -1218,178 +1663,255 @@ export function resumeRun(options) {
1218
1663
  return outside.early;
1219
1664
  }
1220
1665
 
1221
- return withRunLocks(repoRoot, runId, () => {
1222
- const { ledger, ledgerPath } = openLedgerFor(repoRoot, runId);
1223
- const target = resumeTarget(options, { ledger, ledgerPath });
1224
- if (target.early) {
1225
- return target.early;
1226
- }
1227
- const { workspace } = target;
1228
- const state = ledger.state;
1229
- const context = makeContext(options, ledger, workspace);
1230
- const now = context.now;
1231
-
1232
- if (state.run.status === "WAITING_HUMAN") {
1233
- // Pending request already resolved: continue only if the approval's
1234
- // subject is still exactly what the human saw (F6 machine closure).
1235
- const approval = [...state.approvals].reverse().find((entry) => !entry.stale);
1236
- if (!approval) {
1237
- return { runId, ledgerPath, state, resumed: false, reason: "no active approval to act on" };
1238
- }
1239
- const tree = readback(workspace.worktreePath);
1240
- const changed = [];
1241
- if (tree.head !== approval.subject.candidate) {
1242
- changed.push("candidate");
1243
- }
1244
- if (
1245
- planDigest &&
1246
- approval.subject.planDigest !== "UNVERIFIED" &&
1247
- planDigest !== approval.subject.planDigest
1248
- ) {
1249
- changed.push("plan");
1250
- }
1251
- if (changed.length > 0) {
1252
- ledger.append({
1253
- type: "APPROVAL_STALE",
1254
- actor: KERNEL,
1255
- ts: now(),
1256
- data: { approvalRef: approval.decisionRef, changed },
1257
- });
1258
- return { runId, ledgerPath, state: ledger.state, resumed: true, stale: true, reason: null };
1666
+ return withRunLocks(
1667
+ repoRoot,
1668
+ runId,
1669
+ () => {
1670
+ const { ledger, ledgerPath } = openLedgerFor(repoRoot, runId);
1671
+ const target = resumeTarget(options, { ledger, ledgerPath });
1672
+ if (target.early) {
1673
+ return target.early;
1259
1674
  }
1260
- // An adopted candidate names where to resume (verify, by convention):
1261
- // the fixer step the request was waiting on has nothing left to do.
1262
- const step = approval.resumeAt ?? resumeStepFromTransition(approval.transition);
1263
- if (!step || !context.workflow.stepIds.has(step)) {
1264
- throw new OrchestratorError(
1265
- `cannot derive a resume step from approved transition ${approval.transition}`,
1675
+ const { workspace } = target;
1676
+ const state = ledger.state;
1677
+ const context = makeContext(options, ledger, workspace);
1678
+ const now = context.now;
1679
+
1680
+ if (state.run.status === "WAITING_HUMAN") {
1681
+ // Pending request already resolved: continue only if the approval's
1682
+ // subject is still exactly what the human saw (F6 machine closure).
1683
+ const approval = [...state.approvals]
1684
+ .reverse()
1685
+ .find((entry) => !entry.stale);
1686
+ if (!approval) {
1687
+ return {
1688
+ runId,
1689
+ ledgerPath,
1690
+ state,
1691
+ resumed: false,
1692
+ reason: "no active approval to act on",
1693
+ };
1694
+ }
1695
+ const tree = readback(workspace.worktreePath);
1696
+ const changed = [];
1697
+ if (tree.head !== approval.subject.candidate) {
1698
+ changed.push("candidate");
1699
+ }
1700
+ if (
1701
+ planDigest &&
1702
+ approval.subject.planDigest !== "UNVERIFIED" &&
1703
+ planDigest !== approval.subject.planDigest
1704
+ ) {
1705
+ changed.push("plan");
1706
+ }
1707
+ if (changed.length > 0) {
1708
+ ledger.append({
1709
+ type: "APPROVAL_STALE",
1710
+ actor: KERNEL,
1711
+ ts: now(),
1712
+ data: { approvalRef: approval.decisionRef, changed },
1713
+ });
1714
+ return {
1715
+ runId,
1716
+ ledgerPath,
1717
+ state: ledger.state,
1718
+ resumed: true,
1719
+ stale: true,
1720
+ reason: null,
1721
+ };
1722
+ }
1723
+ // An adopted candidate names where to resume (verify, by convention):
1724
+ // the fixer step the request was waiting on has nothing left to do.
1725
+ const step =
1726
+ approval.resumeAt ?? resumeStepFromTransition(approval.transition);
1727
+ if (!step || !context.workflow.stepIds.has(step)) {
1728
+ throw new OrchestratorError(
1729
+ `cannot derive a resume step from approved transition ${approval.transition}`,
1730
+ );
1731
+ }
1732
+ // An approved resume-<step> on an exhausted budget is the human saying
1733
+ // "one more"; record the grant before driving or the same request
1734
+ // comes straight back (the pilot's app-login runs ended CANCELLED
1735
+ // with their candidates in production because of exactly that).
1736
+ const decisionIndex = ledger.events.findIndex(
1737
+ (event) =>
1738
+ event.type === "DECISION_RECORDED" &&
1739
+ event.data.decisionRef === approval.decisionRef,
1266
1740
  );
1267
- }
1268
- // An approved resume-<step> on an exhausted budget is the human saying
1269
- // "one more"; record the grant before driving or the same request
1270
- // comes straight back (the pilot's app-login runs ended CANCELLED
1271
- // with their candidates in production because of exactly that).
1272
- const decisionIndex = ledger.events.findIndex((event) =>
1273
- event.type === "DECISION_RECORDED" && event.data.decisionRef === approval.decisionRef);
1274
- // APPROVAL_STALE is itself a new request, with no inherited grants.
1275
- // Restrict lookup to this decision, rather than an older request for
1276
- // the same transition (or one whose subject was refreshed).
1277
- const request = ledger.events.slice(0, decisionIndex).reverse().find((event) =>
1278
- event.type === "HUMAN_REQUESTED" || event.type === "APPROVAL_STALE");
1279
- const requestData = request?.type === "HUMAN_REQUESTED" &&
1280
- request.data.transition === approval.transition ? request.data : null;
1281
- // The grant plan is fixed once, then pinned on the first
1282
- // BUDGET_EXTENDED it produces; a resume after a crash between two
1283
- // grants replays that plan instead of re-deriving it from a state the
1284
- // first grant already raised.
1285
- const applied = ledger.events.filter((event) =>
1286
- event.type === "BUDGET_EXTENDED" && event.data.approvalRef === approval.decisionRef);
1287
- let grants;
1288
- if (Array.isArray(applied[0]?.data.grants)) {
1289
- grants = applied[0].data.grants;
1290
- } else if (
1291
- Array.isArray(requestData?.grants) &&
1292
- (canonicalJson(requestData.subject) === canonicalJson(approval.subject) ||
1293
- // resume --adopt answers this very request with a new candidate by
1294
- // design; the grant belongs to the round, not to a candidate. Real
1295
- // incident: the session's hand fix was adopted and the run stopped
1296
- // again at resume-review for the round the human had just granted.
1297
- (ledger.events[decisionIndex]?.data.adopted &&
1298
- approval.subject.planDigest === requestData.subject.planDigest))
1299
- ) {
1300
- grants = requestData.grants;
1301
- } else {
1302
- // Requests without grants: ledgers written before grants existed,
1303
- // or a request refreshed by APPROVAL_STALE.
1304
- grants = [];
1305
- const attempts = state.steps[step]?.attempts ?? 0;
1306
- const runCapApproval = approval.transition.startsWith("resume-") &&
1307
- (attempts >= context.maxAttemptsFor(step) || attempts >= context.totalAttemptsFor(step));
1308
- if (requestData?.kind === "work-review-cap") grants.push({ step, scope: "work" });
1309
- if (runCapApproval) grants.push({ step, scope: "run" });
1310
- if (grants.length > 0) grants.push(...budgetGrants(context, step));
1311
- }
1312
- const plan = [];
1313
- const planned = new Set();
1314
- for (const grant of grants) {
1315
- const key = `${grant.step}:${grant.scope}`;
1316
- if (!planned.has(key)) {
1317
- planned.add(key);
1318
- plan.push({ step: grant.step, scope: grant.scope });
1741
+ // APPROVAL_STALE is itself a new request, with no inherited grants.
1742
+ // Restrict lookup to this decision, rather than an older request for
1743
+ // the same transition (or one whose subject was refreshed).
1744
+ const request = ledger.events
1745
+ .slice(0, decisionIndex)
1746
+ .reverse()
1747
+ .find(
1748
+ (event) =>
1749
+ event.type === "HUMAN_REQUESTED" ||
1750
+ event.type === "APPROVAL_STALE",
1751
+ );
1752
+ const requestData =
1753
+ request?.type === "HUMAN_REQUESTED" &&
1754
+ request.data.transition === approval.transition
1755
+ ? request.data
1756
+ : null;
1757
+ // The grant plan is fixed once, then pinned on the first
1758
+ // BUDGET_EXTENDED it produces; a resume after a crash between two
1759
+ // grants replays that plan instead of re-deriving it from a state the
1760
+ // first grant already raised.
1761
+ const applied = ledger.events.filter(
1762
+ (event) =>
1763
+ event.type === "BUDGET_EXTENDED" &&
1764
+ event.data.approvalRef === approval.decisionRef,
1765
+ );
1766
+ let grants;
1767
+ if (Array.isArray(applied[0]?.data.grants)) {
1768
+ grants = applied[0].data.grants;
1769
+ } else if (
1770
+ Array.isArray(requestData?.grants) &&
1771
+ (canonicalJson(requestData.subject) ===
1772
+ canonicalJson(approval.subject) ||
1773
+ // resume --adopt answers this very request with a new candidate by
1774
+ // design; the grant belongs to the round, not to a candidate. Real
1775
+ // incident: the session's hand fix was adopted and the run stopped
1776
+ // again at resume-review for the round the human had just granted.
1777
+ (ledger.events[decisionIndex]?.data.adopted &&
1778
+ approval.subject.planDigest === requestData.subject.planDigest))
1779
+ ) {
1780
+ grants = requestData.grants;
1781
+ } else {
1782
+ // Requests without grants: ledgers written before grants existed,
1783
+ // or a request refreshed by APPROVAL_STALE.
1784
+ grants = [];
1785
+ const attempts = state.steps[step]?.attempts ?? 0;
1786
+ const runCapApproval =
1787
+ approval.transition.startsWith("resume-") &&
1788
+ (attempts >= context.maxAttemptsFor(step) ||
1789
+ attempts >= context.totalAttemptsFor(step));
1790
+ if (requestData?.kind === "work-review-cap")
1791
+ grants.push({ step, scope: "work" });
1792
+ if (runCapApproval) grants.push({ step, scope: "run" });
1793
+ if (grants.length > 0) grants.push(...budgetGrants(context, step));
1794
+ }
1795
+ const plan = [];
1796
+ const planned = new Set();
1797
+ for (const grant of grants) {
1798
+ const key = `${grant.step}:${grant.scope}`;
1799
+ if (!planned.has(key)) {
1800
+ planned.add(key);
1801
+ plan.push({ step: grant.step, scope: grant.scope });
1802
+ }
1803
+ }
1804
+ const extended = new Set(
1805
+ applied.map(
1806
+ (event) => `${event.data.step}:${event.data.scope ?? "run"}`,
1807
+ ),
1808
+ );
1809
+ for (const grant of plan) {
1810
+ const key = `${grant.step}:${grant.scope}`;
1811
+ if (extended.has(key)) continue;
1812
+ extended.add(key);
1813
+ ledger.append({
1814
+ type: "BUDGET_EXTENDED",
1815
+ actor: KERNEL,
1816
+ ts: now(),
1817
+ data: {
1818
+ step: grant.step,
1819
+ amount: 1,
1820
+ ...(grant.scope === "work" ? { scope: "work" } : {}),
1821
+ maxAttempts:
1822
+ grant.scope === "work"
1823
+ ? context.reviewRoundsPerWork +
1824
+ (ledger.state.workReviewGrants ?? 0) +
1825
+ 1
1826
+ : context.maxAttemptsFor(grant.step) + 1,
1827
+ approvalRef: approval.decisionRef,
1828
+ grants: plan,
1829
+ },
1830
+ });
1319
1831
  }
1320
- }
1321
- const extended = new Set(applied.map((event) => `${event.data.step}:${event.data.scope ?? "run"}`));
1322
- for (const grant of plan) {
1323
- const key = `${grant.step}:${grant.scope}`;
1324
- if (extended.has(key)) continue;
1325
- extended.add(key);
1326
1832
  ledger.append({
1327
- type: "BUDGET_EXTENDED",
1833
+ type: "RUN_STARTED",
1328
1834
  actor: KERNEL,
1329
1835
  ts: now(),
1330
- data: {
1331
- step: grant.step,
1332
- amount: 1,
1333
- ...(grant.scope === "work" ? { scope: "work" } : {}),
1334
- maxAttempts: grant.scope === "work"
1335
- ? context.reviewRoundsPerWork + (ledger.state.workReviewGrants ?? 0) + 1
1336
- : context.maxAttemptsFor(grant.step) + 1,
1337
- approvalRef: approval.decisionRef,
1338
- grants: plan,
1339
- },
1836
+ data: {},
1340
1837
  });
1838
+ drive(context, step, { skipBoundaryOnce: true });
1839
+ return {
1840
+ runId,
1841
+ ledgerPath,
1842
+ state: ledger.state,
1843
+ resumed: true,
1844
+ reason: null,
1845
+ };
1341
1846
  }
1342
- ledger.append({ type: "RUN_STARTED", actor: KERNEL, ts: now(), data: {} });
1343
- drive(context, step, { skipBoundaryOnce: true });
1344
- return { runId, ledgerPath, state: ledger.state, resumed: true, reason: null };
1345
- }
1346
1847
 
1347
- // Crash recovery: the process died while RUNNING.
1348
- ledger.append({
1349
- type: "RUN_INTERRUPTED",
1350
- actor: KERNEL,
1351
- ts: now(),
1352
- data: { cause: "resume after process loss" },
1353
- });
1354
- const tree = readback(workspace.worktreePath);
1355
- let startStep = null;
1356
- if (state.currentStep) {
1357
- const step = state.currentStep;
1358
- const attempt = state.steps[step].attempts;
1848
+ // Crash recovery: the process died while RUNNING.
1359
1849
  ledger.append({
1360
- type: "STEP_FINISHED",
1850
+ type: "RUN_INTERRUPTED",
1361
1851
  actor: KERNEL,
1362
1852
  ts: now(),
1363
- data: { step, attempt, status: "crashed" },
1853
+ data: { cause: "resume after process loss" },
1364
1854
  });
1365
- if (tree.dirty) {
1366
- context.waitHuman(`resume-${step}`, [
1367
- `interrupted step ${step} left a dirty worktree; decide whether to keep or discard before rerunning`,
1855
+ const tree = readback(workspace.worktreePath);
1856
+ let startStep = null;
1857
+ if (state.currentStep) {
1858
+ const step = state.currentStep;
1859
+ const attempt = state.steps[step].attempts;
1860
+ ledger.append({
1861
+ type: "STEP_FINISHED",
1862
+ actor: KERNEL,
1863
+ ts: now(),
1864
+ data: { step, attempt, status: "crashed" },
1865
+ });
1866
+ if (tree.dirty) {
1867
+ context.waitHuman(`resume-${step}`, [
1868
+ `interrupted step ${step} left a dirty worktree; decide whether to keep or discard before rerunning`,
1869
+ ]);
1870
+ return {
1871
+ runId,
1872
+ ledgerPath,
1873
+ state: ledger.state,
1874
+ resumed: true,
1875
+ reason: null,
1876
+ };
1877
+ }
1878
+ // An interrupted attempt says nothing about the candidate, so the step
1879
+ // itself reruns (the lost attempt still counts against its budget)
1880
+ // instead of settling as a step failure — routing a crash through the
1881
+ // failure edge dispatched a fixer with no verifier evidence (real
1882
+ // incident: deploy-18's verify worker was killed by a host timeout).
1883
+ startStep = step;
1884
+ } else if (tree.dirty) {
1885
+ context.waitHuman("resume-run", [
1886
+ "worktree is dirty at resume with no step in flight; human triage required",
1368
1887
  ]);
1369
- return { runId, ledgerPath, state: ledger.state, resumed: true, reason: null };
1888
+ return {
1889
+ runId,
1890
+ ledgerPath,
1891
+ state: ledger.state,
1892
+ resumed: true,
1893
+ reason: null,
1894
+ };
1895
+ } else {
1896
+ startStep =
1897
+ state.lastCheckpoint?.resumePoint?.step ?? state.run.entry ?? null;
1898
+ if (!startStep) {
1899
+ throw new OrchestratorError(
1900
+ "no checkpoint and no recorded entry; cannot derive a safe resume point",
1901
+ );
1902
+ }
1370
1903
  }
1371
- // An interrupted attempt says nothing about the candidate, so the step
1372
- // itself reruns (the lost attempt still counts against its budget)
1373
- // instead of settling as a step failure — routing a crash through the
1374
- // failure edge dispatched a fixer with no verifier evidence (real
1375
- // incident: deploy-18's verify worker was killed by a host timeout).
1376
- startStep = step;
1377
- } else if (tree.dirty) {
1378
- context.waitHuman("resume-run", [
1379
- "worktree is dirty at resume with no step in flight; human triage required",
1380
- ]);
1381
- return { runId, ledgerPath, state: ledger.state, resumed: true, reason: null };
1382
- } else {
1383
- startStep = state.lastCheckpoint?.resumePoint?.step ?? state.run.entry ?? null;
1384
- if (!startStep) {
1385
- throw new OrchestratorError(
1386
- "no checkpoint and no recorded entry; cannot derive a safe resume point",
1387
- );
1904
+ if (startStep) {
1905
+ drive(context, startStep);
1388
1906
  }
1389
- }
1390
- if (startStep) {
1391
- drive(context, startStep);
1392
- }
1393
- return { runId, ledgerPath, state: ledger.state, resumed: true, reason: null };
1394
- }, { workId: outerLedger.state.run.work, parallel: options.parallel === true });
1907
+ return {
1908
+ runId,
1909
+ ledgerPath,
1910
+ state: ledger.state,
1911
+ resumed: true,
1912
+ reason: null,
1913
+ };
1914
+ },
1915
+ { workId: outerLedger.state.run.work, parallel: options.parallel === true },
1916
+ );
1395
1917
  }