akm-cli 0.9.0 → 0.9.1-beta.2

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 (140) hide show
  1. package/CHANGELOG.md +724 -0
  2. package/README.md +28 -63
  3. package/STABILITY.md +4 -2
  4. package/dist/cli/parse-args.js +7 -1
  5. package/dist/commands/agent/contribute-cli.js +1 -1
  6. package/dist/commands/env/child-env.js +14 -0
  7. package/dist/commands/feedback-cli.js +7 -1
  8. package/dist/commands/health/llm-usage.js +2 -1
  9. package/dist/commands/health/surfaces.js +4 -77
  10. package/dist/commands/health.js +65 -11
  11. package/dist/commands/improve/distill/quality-gate.js +6 -1
  12. package/dist/commands/improve/eligibility.js +7 -1
  13. package/dist/commands/improve/eval-cases.js +2 -0
  14. package/dist/commands/improve/improve.js +126 -10
  15. package/dist/commands/improve/locks.js +7 -0
  16. package/dist/commands/improve/memory/memory-improve.js +9 -0
  17. package/dist/commands/improve/run-context.js +5 -0
  18. package/dist/commands/improve/session-asset.js +4 -0
  19. package/dist/commands/lint/base-linter.js +31 -7
  20. package/dist/commands/lint/index.js +205 -51
  21. package/dist/commands/lint/types.js +22 -1
  22. package/dist/commands/proposal/repository.js +17 -1
  23. package/dist/commands/sources/add-cli.js +8 -2
  24. package/dist/commands/sources/info.js +12 -2
  25. package/dist/commands/sources/installed-stashes.js +6 -1
  26. package/dist/commands/sources/migration-help.js +12 -3
  27. package/dist/commands/sources/self-update.js +9 -1
  28. package/dist/commands/tasks/tasks.js +8 -2
  29. package/dist/commands/workflow-cli.js +17 -11
  30. package/dist/core/abort-deadline.js +28 -0
  31. package/dist/core/adapter/adapters/agent-skills-adapter.js +83 -5
  32. package/dist/core/adapter/adapters/akm-adapter.js +13 -10
  33. package/dist/core/adapter/adapters/akm-lint.js +78 -22
  34. package/dist/core/adapter/adapters/akm-task-adapter.js +43 -20
  35. package/dist/core/adapter/adapters/dotenv-adapter.js +21 -0
  36. package/dist/core/adapter/adapters/tool-dir-shared.js +5 -3
  37. package/dist/core/asset/frontmatter.js +10 -1
  38. package/dist/core/common.js +147 -9
  39. package/dist/core/concurrent.js +32 -0
  40. package/dist/core/config/config-io.js +5 -45
  41. package/dist/core/config/schema/engines.js +14 -3
  42. package/dist/core/config/schema/workflow.js +11 -0
  43. package/dist/core/errors.js +25 -0
  44. package/dist/core/events.js +30 -24
  45. package/dist/core/extra-params.js +11 -0
  46. package/dist/core/file-lock.js +7 -1
  47. package/dist/core/fs-txn.js +15 -2
  48. package/dist/core/improve-result.js +5 -0
  49. package/dist/core/json-schema.js +344 -9
  50. package/dist/core/loopback.js +89 -0
  51. package/dist/core/migration-operation.js +17 -2
  52. package/dist/core/path-access.js +107 -0
  53. package/dist/core/paths.js +16 -2
  54. package/dist/core/redaction.js +86 -18
  55. package/dist/core/spawn-env.js +234 -0
  56. package/dist/core/state-db-scope.js +134 -0
  57. package/dist/core/state-db.js +1 -0
  58. package/dist/core/subprocess.js +181 -37
  59. package/dist/core/write-provenance.js +85 -0
  60. package/dist/core/write-source.js +33 -2
  61. package/dist/indexer/db/graph-db.js +17 -6
  62. package/dist/indexer/ensure-index.js +10 -3
  63. package/dist/indexer/index-written-assets.js +17 -2
  64. package/dist/indexer/indexer.js +86 -21
  65. package/dist/indexer/passes/memory-inference.js +4 -0
  66. package/dist/indexer/search/db-search.js +25 -17
  67. package/dist/indexer/walk/walker.js +6 -1
  68. package/dist/integrations/agent/detect.js +13 -1
  69. package/dist/integrations/agent/engine-resolution.js +24 -11
  70. package/dist/integrations/agent/model-aliases.js +1 -1
  71. package/dist/integrations/agent/profiles.js +9 -1
  72. package/dist/integrations/agent/spawn.js +15 -87
  73. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +21 -0
  74. package/dist/integrations/lockfile.js +55 -2
  75. package/dist/llm/client.js +14 -19
  76. package/dist/llm/embedder.js +23 -3
  77. package/dist/llm/embedders/remote.js +27 -2
  78. package/dist/output/html-render.js +40 -1
  79. package/dist/output/text/lint-format.js +17 -4
  80. package/dist/runtime.js +23 -1
  81. package/dist/scripts/akm-migrate-node.js +1714 -836
  82. package/dist/scripts/akm-migrate.js +1682 -804
  83. package/dist/setup/setup.js +22 -7
  84. package/dist/sources/providers/git-install.js +25 -2
  85. package/dist/sources/providers/git-stash.js +19 -0
  86. package/dist/sources/providers/git.js +1 -1
  87. package/dist/sources/snapshot-fetchers/content-extract.js +63 -1
  88. package/dist/sources/snapshot-fetchers/website-ingest.js +126 -20
  89. package/dist/storage/database.js +71 -7
  90. package/dist/storage/engines/sqlite-migrations.js +61 -2
  91. package/dist/storage/managed-db.js +19 -0
  92. package/dist/storage/repositories/index-connection.js +39 -4
  93. package/dist/storage/repositories/index-entries-repository.js +6 -1
  94. package/dist/storage/repositories/index-meta-repository.js +11 -0
  95. package/dist/storage/repositories/index-schema.js +17 -2
  96. package/dist/storage/repositories/index-vec-repository.js +43 -5
  97. package/dist/storage/repositories/workflow-runs-repository.js +66 -13
  98. package/dist/storage/sqlite-pragmas.js +12 -1
  99. package/dist/tasks/log-redaction.js +156 -0
  100. package/dist/tasks/parser.js +82 -5
  101. package/dist/tasks/runner.js +222 -17
  102. package/dist/tasks/scheduler-invocation.js +19 -0
  103. package/dist/tasks/schema.js +86 -1
  104. package/dist/text-import-hook.mjs +1 -1
  105. package/dist/workflows/concurrency-policy.js +95 -1
  106. package/dist/workflows/exec/dispatch-redaction.js +114 -0
  107. package/dist/workflows/exec/exec-unit.js +542 -0
  108. package/dist/workflows/exec/frozen-judge.js +114 -42
  109. package/dist/workflows/exec/native-executor.js +465 -238
  110. package/dist/workflows/exec/param-secrets.js +4 -3
  111. package/dist/workflows/exec/run-workflow.js +424 -219
  112. package/dist/workflows/exec/step-work.js +506 -167
  113. package/dist/workflows/exec/unit-dispatch.js +31 -1
  114. package/dist/workflows/exec/unit-writer.js +53 -13
  115. package/dist/workflows/exec/worktree.js +454 -41
  116. package/dist/workflows/ir/compile.js +26 -2
  117. package/dist/workflows/ir/freeze.js +82 -15
  118. package/dist/workflows/ir/schema.js +105 -20
  119. package/dist/workflows/parser.js +242 -19
  120. package/dist/workflows/program/schema.js +24 -0
  121. package/dist/workflows/renderer.js +32 -4
  122. package/dist/workflows/resource-limits.js +182 -0
  123. package/dist/workflows/runtime/runs.js +146 -6
  124. package/dist/workflows/validate-summary.js +17 -2
  125. package/docs/README.md +74 -32
  126. package/docs/migration/release-notes/0.9.0.md +2 -1
  127. package/docs/migration/v0.7-to-v0.8.md +2 -1
  128. package/docs/migration/v0.8-to-v0.9.md +3 -1
  129. package/docs/reference/README.md +11 -4
  130. package/docs/reference/bundle-types.md +19 -0
  131. package/docs/reference/cli.md +105 -16
  132. package/docs/reference/configuration.md +15 -2
  133. package/docs/reference/data-and-telemetry.md +30 -10
  134. package/docs/reference/supported-formats.md +50 -0
  135. package/docs/reference/workflow-schema.md +1014 -0
  136. package/docs/reference/workflows.md +37 -633
  137. package/package.json +13 -6
  138. package/schemas/akm-config.json +18 -5
  139. package/schemas/akm-task.json +27 -5
  140. package/schemas/akm-workflow.json +92 -13
@@ -31,6 +31,9 @@
31
31
  * the one-shot case. A typed-artifact schema mismatch feeds the same loop
32
32
  * (the validation errors are the feedback; no judge ran, so no gate unit is
33
33
  * journaled for that attempt) — only the FINAL loop's mismatch fails the run.
34
+ * A step whose subgraph is an `exec` unit is judged but NEVER looped
35
+ * (`effectiveGateMaxLoops`): its argv cannot read the feedback, so a second
36
+ * loop would only re-run the identical side effect.
34
37
  *
35
38
  * Frozen plan (redesign addendum, R1): the plan graph is read from the run
36
39
  * row (`plan_json`, persisted by `startWorkflowRun` under migration 006) with
@@ -63,7 +66,7 @@ import { randomUUID } from "node:crypto";
63
66
  import { UsageError } from "../../core/errors.js";
64
67
  import { withMaintenanceStartBarrierAsync } from "../../core/maintenance-barrier.js";
65
68
  import { disposeDispatchResources } from "../../integrations/agent/runner-dispatch.js";
66
- import { withWorkflowRunsRepo } from "../../storage/repositories/workflow-runs-repository.js";
69
+ import { withWorkflowRunsConnection, withWorkflowRunsRepo } from "../../storage/repositories/workflow-runs-repository.js";
67
70
  import { assertRunParamsSatisfyPlan } from "../ir/params.js";
68
71
  import { computePlanHash } from "../ir/plan-hash.js";
69
72
  import { decodeWorkflowPlanV3 } from "../ir/schema.js";
@@ -76,7 +79,7 @@ import { defaultUnitDispatcher, executeStepPlan, } from "./native-executor.js";
76
79
  // gate-evaluation journaling, and the whole step-completion path
77
80
  // (`finalizeExecutedStep`) live in step-work.ts as ONE implementation, so the
78
81
  // fresh-execution and resume paths cannot drift from each other.
79
- import { activeGateLoop, cascadeSkippedRouter, finalizeExecutedStep, recoverGateFeedback, seedJournaledRouteDecisions, } from "./step-work.js";
82
+ import { activeGateLoop, blockStepForJudgeFailure, cascadeSkippedRouter, effectiveGateMaxLoops, finalizeExecutedStep, recoverGateFeedback, referencedStepIds, seedJournaledRouteDecisions, } from "./step-work.js";
80
83
  export async function runWorkflowSteps(options) {
81
84
  let target = options.target;
82
85
  let params = options.params;
@@ -84,6 +87,12 @@ export async function runWorkflowSteps(options) {
84
87
  let remainingRetries = options.maxRetries ?? 0;
85
88
  let remainingSteps = options.maxSteps;
86
89
  const executed = [];
90
+ let stepsProcessed = 0;
91
+ // Spans the retry loop: a retry re-opens only the ONE failed step, so every
92
+ // step this call already completed keeps handing its complete artifact
93
+ // downstream instead of falling back to the (possibly clipped) row. See the
94
+ // {@link driveRun} declaration.
95
+ const liveEvidence = new Map();
87
96
  for (;;) {
88
97
  const result = await runWorkflowAttempt({
89
98
  ...options,
@@ -91,14 +100,18 @@ export async function runWorkflowSteps(options) {
91
100
  ...(params !== undefined ? { params } : { params: undefined }),
92
101
  ...(parameterFlags !== undefined ? { parameterFlags } : { parameterFlags: undefined }),
93
102
  ...(remainingSteps !== undefined ? { maxSteps: remainingSteps } : { maxSteps: undefined }),
94
- });
103
+ }, liveEvidence);
95
104
  executed.push(...result.executed);
96
- const aggregate = { ...result, executed };
105
+ stepsProcessed += result.stepsProcessed;
106
+ const aggregate = { ...result, executed, stepsProcessed };
97
107
  if (result.run.status !== "failed" || result.aborted || result.gateRejection || remainingRetries <= 0) {
98
108
  return aggregate;
99
109
  }
100
110
  if (remainingSteps !== undefined) {
101
- remainingSteps -= result.executed.length;
111
+ // The step budget is DISTINCT PROCESSED STEPS, not `executed` entries:
112
+ // gate loops of one step and route-skips must not shrink a retry's
113
+ // remaining budget (they never counted against maxSteps either).
114
+ remainingSteps -= result.stepsProcessed;
102
115
  if (remainingSteps <= 0)
103
116
  return aggregate;
104
117
  }
@@ -109,7 +122,7 @@ export async function runWorkflowSteps(options) {
109
122
  remainingRetries -= 1;
110
123
  }
111
124
  }
112
- async function runWorkflowAttempt(options) {
125
+ async function runWorkflowAttempt(options, liveEvidence) {
113
126
  const next = await getNextWorkflowStep(options.target, options.params, {
114
127
  parameterFlags: options.parameterFlags,
115
128
  });
@@ -158,7 +171,17 @@ async function runWorkflowAttempt(options) {
158
171
  : undefined;
159
172
  heartbeat?.start();
160
173
  try {
161
- const result = await driveRun(options, next, leaseHolder, heartbeat);
174
+ // Run-wide state.db connection scope: `executeStepPlan` already opens one
175
+ // per STEP, so widening it to the whole drive loop additionally folds the
176
+ // spine writes (`completeWorkflowStep`), the per-step lease renewals, the
177
+ // journal reads, and `finalizeExecutedStep`'s gate-row journaling onto that
178
+ // one handle — `openStateDatabase` costs a maintenance-activity lockfile
179
+ // plus a read-only ledger preflight on EVERY call. Nesting is an idempotent
180
+ // join (`core/state-db-scope.ts`): the inner per-step scope reuses this
181
+ // handle and does not close it, and this scope's own `finally` closes on
182
+ // every exit path (return, throw, abort), after which escaped async work
183
+ // transparently falls back to opening its own connection.
184
+ const result = await withWorkflowRunsConnection(() => driveRun(options, next, leaseHolder, heartbeat, liveEvidence));
162
185
  // Creation-time notices reach the caller only here: the run row has no
163
186
  // warnings column, and a later invocation of the same run must stay silent
164
187
  // about a decision it did not make. `driveRun` never sets `warnings`.
@@ -340,16 +363,321 @@ async function completedRunResult(runId) {
340
363
  return {
341
364
  run: doneState.run,
342
365
  executed: [],
366
+ stepsProcessed: 0,
343
367
  ...(doneState.run.status === "completed" ? { done: true } : {}),
344
368
  };
345
369
  }
346
- function workflowSummaryJudge(options, plan, stepPlan, signal) {
370
+ function workflowSummaryJudge(options, plan, stepPlan, signal, owner) {
347
371
  if (options.summaryJudge !== undefined)
348
372
  return options.summaryJudge;
349
- return frozenSummaryJudge(plan, stepPlan.gate.judge, signal, options.dispatcher ?? defaultUnitDispatcher);
373
+ // The judge dispatches under the REAL run/step identity; the per-loop gate row
374
+ // identity is threaded in per call by the completion path that journals it.
375
+ return frozenSummaryJudge(plan, stepPlan.gate.judge, signal, options.dispatcher ?? defaultUnitDispatcher, owner);
376
+ }
377
+ /**
378
+ * Seed the lifetime unit cap AND the budget ceilings from the journal so
379
+ * both are truly per-RUN: a resumed or re-invoked run must not restart the
380
+ * runaway backstop — or a declared `budget` — at zero. Journal rows = past
381
+ * dispatch ATTEMPTS (counted against `budget.max_units`); their summed
382
+ * `tokens` column is the run's spend so far (counted against
383
+ * `budget.max_tokens`). The executor consumes both only on new dispatches
384
+ * (durable-row reuses are free), so a large partially-completed fan-out
385
+ * stays resumable.
386
+ *
387
+ * Gate-evaluation rows (`phase = "gate"`, journaled by the completion-gate
388
+ * judge) are EXCLUDED from the seed: the live path never consumes
389
+ * DispatchBudget for a judge call, so counting its journal row on resume
390
+ * would make an interrupted run hit `max_units` (and the lifetime cap)
391
+ * earlier than the identical uninterrupted run — a spurious hard failure
392
+ * that `on_error` cannot soften. The seed must reproduce exactly what live
393
+ * accounting would have accumulated.
394
+ *
395
+ * The seed sums each dispatch row's `attempts` (migration 008), NOT the row
396
+ * COUNT: a crash between a unit's dispatch and its finish leaves a `running`
397
+ * row that resume re-dispatches under the SAME content-derived unit_id, and
398
+ * `insertUnit` REPLACES that one row while bumping `attempts`. Counting rows
399
+ * would erase every prior crash-retried dispatch from budget/lifetime
400
+ * accounting, letting the run spend past its declared ceiling; summing
401
+ * `attempts` charges each dispatch exactly once.
402
+ */
403
+ async function seedRunAccountingFromJournal(runId) {
404
+ const journaledUnits = await withWorkflowRunsRepo((repo) => repo.getUnitsForRun(runId));
405
+ const journaledDispatches = journaledUnits.filter((row) => row.phase !== GATE_EVALUATION_PHASE);
406
+ return {
407
+ unitsDispatched: journaledDispatches.reduce((sum, row) => sum + row.attempts, 0),
408
+ tokensUsed: journaledDispatches.reduce((sum, row) => sum + (row.tokens ?? 0), 0),
409
+ };
410
+ }
411
+ /**
412
+ * The decoded/hash-verified row plan is the sole execution authority. The
413
+ * loader seam may assert an expected plan in tests, but can never replace it.
414
+ *
415
+ * Reviewer #12: the journaled params row must still satisfy the frozen param
416
+ * schemas before the engine resolves any unit prompt from it, so
417
+ * schema-violating params — post-start corruption — fail loudly BEFORE any
418
+ * unit is dispatched (start already validated the params it stored).
419
+ */
420
+ async function loadAuthoritativeRunPlan(options, next) {
421
+ const plan = await loadFrozenPlan(next.run.id);
422
+ if (options.loadPlan) {
423
+ const expected = decodeWorkflowPlanV3(await options.loadPlan(next.run.workflowRef));
424
+ if (computePlanHash(expected) !== computePlanHash(plan))
425
+ throw new UsageError(`Injected workflow plan for run ${next.run.id} differs from its frozen plan.`);
426
+ }
427
+ assertRunParamsSatisfyPlan(next.run.id, plan, next.run.params ?? {});
428
+ return plan;
429
+ }
430
+ /**
431
+ * Complete a branch target no completed router selected as `skipped` — no
432
+ * dispatch, no gate loop, and (per the `maxSteps` contract) no step consumed.
433
+ * Returns the re-read spine state so the caller can continue its walk.
434
+ */
435
+ async function skipUnselectedRouteTarget(input) {
436
+ const { runId, stepId, stepPlan, skipInfo, routeUnselected, executed, leaseHolder } = input;
437
+ // Cascade (peer review R1): a skipped step that is ITSELF a router
438
+ // never evaluates its route, so none of its declared targets were
439
+ // selected — mark them all skip-on-reach too (a target another
440
+ // completed router selects stays protected via routeSelected). Without
441
+ // this, every branch of the skipped router would run unconditionally.
442
+ if (stepPlan.route) {
443
+ cascadeSkippedRouter(stepPlan.route, stepId, routeUnselected);
444
+ }
445
+ const notes = skipInfo.selected === null
446
+ ? `Skipped by route: step "${skipInfo.router}" was itself skipped, so none of its branch targets run.`
447
+ : `Skipped by route: step "${skipInfo.router}" selected "${skipInfo.selected}".`;
448
+ executed.push({ stepId, ok: true, unitCount: 0, failedUnits: 0, summary: notes });
449
+ await completeWorkflowStep({ runId, stepId, status: "skipped", notes, leaseHolder });
450
+ return getNextWorkflowStep(runId);
451
+ }
452
+ /**
453
+ * Crash-resume gate state (Codex P1): SEED the starting gate loop from the
454
+ * journal through the SAME shared helpers the first pass used — no fork.
455
+ * A run interrupted after a rejected gate was journaled
456
+ * (`<step>.gate:l<n>`, complete:false) must resume at loop n+1 with the
457
+ * stored corrective feedback threaded into the unit prompts; without this
458
+ * the engine restarts at loop 1, reuses the rejected loop-1 rows, overwrites
459
+ * `<step>.gate:l1`, and re-judges the stale artifact — breaking journaled
460
+ * replay and making the resumed run diverge from the interrupted one. The rows
461
+ * are re-read per step (NOT the once-at-start budget seed) so a step reached
462
+ * later within THIS same invocation still starts fresh at loop 1.
463
+ *
464
+ * Only the STEP's rows are read (index-backed on `(run_id, step_id)`): both
465
+ * helpers already discard every row carrying a different `step_id`, and gate
466
+ * rows are journaled under the step's own id, so the narrow query returns a
467
+ * superset of what they read. Re-reading the whole run journal here would
468
+ * re-materialize every earlier step's `result_json` — synchronously, blocking
469
+ * the event loop the lease heartbeat and abort handling share — once per step.
470
+ */
471
+ async function recoverGateLoopState(runId, stepPlan) {
472
+ // A step with no effective completion criteria never reaches a judge
473
+ // (`validateStepSummary` short-circuits before the gate-journaling wrapper),
474
+ // so it can have no gate rows and needs no query at all.
475
+ if (!stepPlan.gate.criteria.some((criterion) => criterion.trim().length > 0)) {
476
+ return { startLoop: 1, seededFeedback: undefined };
477
+ }
478
+ const stepId = stepPlan.stepId;
479
+ const stepJournal = await withWorkflowRunsRepo((repo) => repo.getUnitsForStep(runId, stepId));
480
+ const startLoop = activeGateLoop(stepJournal, stepId);
481
+ return { startLoop, seededFeedback: recoverGateFeedback(stepJournal, stepId, startLoop) };
482
+ }
483
+ /**
484
+ * The kinds that FINISHED the step (completed / failed / gate-exhausted) — the
485
+ * ONE `maxSteps` consumption for its whole gate loop. An abort and a judge
486
+ * outage leave the step unfinished and consume nothing: the next invocation
487
+ * still owes the work.
488
+ */
489
+ const STEP_FINISHED_KINDS = new Set(["advanced", "failed", "gate-exhausted"]);
490
+ /**
491
+ * One attempt at a step's work. Route-only steps (YAML `route:` — no execution
492
+ * subgraph) dispatch no units; they only decide the spine's path in
493
+ * `finalizeExecutedStep`. Everything else executes its subgraph through the
494
+ * native executor.
495
+ */
496
+ async function executeStepSubgraph(ctx, loop) {
497
+ const { options, next, plan, stepPlan, step, evidence, leaseHolder, dispatchSignal } = ctx;
498
+ const { gateLoop, gateFeedback, unitsDispatched, tokensUsed } = loop;
499
+ return !stepPlan.root && stepPlan.route
500
+ ? {
501
+ ok: true,
502
+ units: [],
503
+ evidence: {},
504
+ summary: `Step "${step.id}" is a route step — no units dispatched.`,
505
+ unitsDispatched,
506
+ }
507
+ : await executeStepPlan(stepPlan, {
508
+ runId: next.run.id,
509
+ leaseHolder,
510
+ workflowRef: next.run.workflowRef,
511
+ params: next.run.params ?? {},
512
+ evidence,
513
+ unitsDispatched,
514
+ tokensUsed,
515
+ // Budget ceilings ride the FROZEN plan (addendum R2): a mid-run
516
+ // asset edit can never loosen or tighten a run's budget.
517
+ ...(plan.budget ? { budget: plan.budget } : {}),
518
+ ...(plan.execution ? { engines: plan.execution.engines } : {}),
519
+ gateLoop,
520
+ ...(gateFeedback ? { gateFeedback } : {}),
521
+ // The heartbeat's signal is the effective dispatch signal: a lost
522
+ // lease (or a caller abort) aborts in-flight units promptly.
523
+ ...(dispatchSignal ? { signal: dispatchSignal } : {}),
524
+ ...(options.dispatcher ? { dispatcher: options.dispatcher } : {}),
525
+ maxConcurrency: Math.min(options.maxConcurrency ?? Number.POSITIVE_INFINITY, plan.execution?.maxConcurrency ?? 1),
526
+ });
527
+ }
528
+ /**
529
+ * Drive ONE step's bounded gate loop (addendum R2, `gate.max_loops`): loop 1 is
530
+ * the normal execution; a gate rejection with attempts left re-executes the
531
+ * subgraph with the judge's feedback threaded into unit prompts.
532
+ *
533
+ * The engine owns only the loop control the shared completion path
534
+ * (`finalizeExecutedStep`) maps onto — retry re-executes; advanced moves on;
535
+ * failure/judge-failure/exhaustion stops this invocation — and returns that
536
+ * decision plus the running budget totals to {@link driveRun}. `ctx.executed`
537
+ * is appended in place (one report per iteration); everything else the caller
538
+ * must observe travels back through {@link StepGateLoopOutcome}.
539
+ */
540
+ async function runStepGateLoop(ctx, gate, totals) {
541
+ const { options, next, stepPlan, step, evidence, executed, routeSelected, routeUnselected } = ctx;
542
+ const { summaryJudge, leaseHolder, heartbeat } = ctx;
543
+ const { startLoop, maxLoops } = gate;
544
+ let { unitsDispatched, tokensUsed } = totals;
545
+ let gateFeedback = gate.seededFeedback;
546
+ // Every exit carries the running totals back to the engine loop; naming the
547
+ // kind is the whole decision an exit point has to make.
548
+ const outcome = (rest) => ({
549
+ ...rest,
550
+ unitsDispatched,
551
+ tokensUsed,
552
+ });
553
+ for (let gateLoop = startLoop; gateLoop <= maxLoops; gateLoop++) {
554
+ // A loop re-execution dispatches a fresh round of units — renew the
555
+ // lease so a long evaluator-optimizer cycle cannot outlive the TTL.
556
+ if (gateLoop > 1)
557
+ await renewRunLease(next.run.id, leaseHolder);
558
+ const result = await executeStepSubgraph(ctx, { gateLoop, gateFeedback, unitsDispatched, tokensUsed });
559
+ // If the heartbeat lost the lease WHILE this step dispatched, another
560
+ // engine now owns the run — stop loudly BEFORE finalizing the step
561
+ // (completeWorkflowStep would race the new owner's spine).
562
+ heartbeat?.assertAlive();
563
+ unitsDispatched = result.unitsDispatched;
564
+ if (result.tokensUsed !== undefined)
565
+ tokensUsed = result.tokensUsed;
566
+ if (options.signal?.aborted)
567
+ return outcome({ kind: "aborted" });
568
+ executed.push({
569
+ stepId: step.id,
570
+ ok: result.ok,
571
+ unitCount: result.units.length,
572
+ failedUnits: result.units.filter((u) => !u.ok).length,
573
+ summary: result.summary,
574
+ });
575
+ // Route evaluation + artifact-judged completion gate + gate-row
576
+ // journaling + the bounded-loop rejection contract are the SHARED
577
+ // completion path (`finalizeExecutedStep`): every step advances through
578
+ // that one sequence, whether its units were just dispatched or rehydrated
579
+ // from the journal on resume, so the same frozen plan always promotes the
580
+ // same artifact and advances (or rejects) the spine identically.
581
+ let finalize;
582
+ try {
583
+ finalize = await finalizeExecutedStep({
584
+ runId: next.run.id,
585
+ workflowRef: next.run.workflowRef,
586
+ stepId: step.id,
587
+ stepPlan,
588
+ completionCriteria: stepPlan.gate.criteria,
589
+ gateLoop,
590
+ loopsRemaining: gateLoop < maxLoops,
591
+ result,
592
+ priorEvidence: evidence,
593
+ params: next.run.params ?? {},
594
+ routeSelected,
595
+ routeUnselected,
596
+ summaryJudge,
597
+ ...(ctx.plan.execution ? { engines: ctx.plan.execution.engines } : {}),
598
+ signal: options.signal,
599
+ // The judge runs under the DISPATCH signal, so the completion path must
600
+ // see it too: an abort delivered there (a lost lease, a caller Ctrl-C)
601
+ // is an interruption, not a verifier outage.
602
+ ...(ctx.dispatchSignal ? { dispatchSignal: ctx.dispatchSignal } : {}),
603
+ leaseHolder,
604
+ });
605
+ }
606
+ catch (error) {
607
+ heartbeat?.assertAlive();
608
+ if (options.signal?.aborted)
609
+ return outcome({ kind: "aborted" });
610
+ throw error;
611
+ }
612
+ heartbeat?.assertAlive();
613
+ if (finalize.kind === "retry") {
614
+ // Re-execute the subgraph with the judge/validation feedback threaded
615
+ // into unit prompts — the changed prompt changes each unit's input
616
+ // hash, so the re-run dispatches fresh work instead of reusing rows.
617
+ gateFeedback = finalize.gateFeedback;
618
+ continue;
619
+ }
620
+ if (finalize.kind === "advanced") {
621
+ // Hand the rest of this invocation the COMPLETE artifact — but only when
622
+ // some LATER step's frozen references can actually read it (set-time
623
+ // retention, see `referencedStepIds`). `finalize` has already journaled
624
+ // the step (and stamped any route decision onto `result.evidence`), and
625
+ // the persisted row may carry a truncation envelope in place of an
626
+ // over-cap value — the row bound must not change what the very next step
627
+ // reads.
628
+ if (ctx.liveEvidenceConsumers.has(step.id))
629
+ ctx.liveEvidence.set(step.id, result.evidence);
630
+ // A route-only step's summary IS its decision (finalize surfaces it).
631
+ if (finalize.summaryOverride !== undefined) {
632
+ executed[executed.length - 1] = { ...executed[executed.length - 1], summary: finalize.summaryOverride };
633
+ }
634
+ return outcome({ kind: "advanced" });
635
+ }
636
+ if (finalize.kind === "judge-failed") {
637
+ // Verifier infrastructure failure (thrown judge / malformed verdict /
638
+ // missing judge): the step is blocked for resume, NO gate loop was
639
+ // consumed, and the step does not count against maxSteps. Surface the
640
+ // resume instruction in the step report so every output mode shows it.
641
+ executed[executed.length - 1] = { ...executed[executed.length - 1], summary: finalize.summary };
642
+ return outcome({ kind: "judge-failed", judgeFailure: { stepId: step.id, message: finalize.summary } });
643
+ }
644
+ if (finalize.kind === "failed") {
645
+ // A route-failure was pushed as ok:true (the units succeeded); reflect
646
+ // the deterministic route failure in the executed report.
647
+ if (finalize.routeFailure) {
648
+ executed[executed.length - 1] = { ...executed[executed.length - 1], ok: false, summary: finalize.summary };
649
+ }
650
+ return outcome({ kind: "failed" });
651
+ }
652
+ // gate-exhausted: rejected with no loop budget left — stop with feedback.
653
+ return outcome({ kind: "gate-exhausted", gateRejection: finalize.gateRejection });
654
+ }
655
+ // Unreachable: `retry` is the ONLY path that continues the loop, and
656
+ // `finalizeExecutedStep` returns it exclusively while `gateLoop < maxLoops`,
657
+ // so the final iteration always exits through a terminal kind. Falling out
658
+ // here would mean those two bounds disagree — a bug, not a run outcome.
659
+ throw new Error(`Workflow run ${next.run.id} step "${step.id}" left its gate loop with no terminal outcome (loop bounds disagree).`);
350
660
  }
351
661
  /** The engine loop proper — runs under the lease held by `runWorkflowSteps`. */
352
- async function driveRun(options, initial, leaseHolder, heartbeat) {
662
+ async function driveRun(options, initial, leaseHolder, heartbeat,
663
+ /**
664
+ * The COMPLETE in-memory evidence of every step THIS call has completed,
665
+ * keyed by step id, preferred over the re-read row when the downstream scope
666
+ * is rebuilt below. The spine rows are re-read between steps, and
667
+ * `clipStepEvidenceForPersistence` (runtime/runs.ts) may have replaced an
668
+ * over-cap artifact with a truncation envelope on the way in — a bound on ONE
669
+ * SQLite row, not on what a run may promote (the exec per-pipe cap alone
670
+ * retains 8 MiB). Preferring the live value keeps the persistence bound
671
+ * invisible to the run that produced it. A LATER `akm workflow run` starts
672
+ * with an empty map and reads the rows, where a reference into a truncated
673
+ * artifact fails loudly by name (`isTruncatedEvidence`).
674
+ *
675
+ * Only steps some OTHER step's references NAME are stored (`referencedStepIds`
676
+ * — the set-time filter): a step nothing downstream reads has no consumer to
677
+ * keep it complete for, so retaining it would buy nothing and cost its bytes
678
+ * for the rest of the invocation.
679
+ */
680
+ liveEvidence) {
353
681
  let next = initial;
354
682
  if (initial.done)
355
683
  return completedRunResult(initial.run.id);
@@ -358,49 +686,22 @@ async function driveRun(options, initial, leaseHolder, heartbeat) {
358
686
  const dispatchSignal = heartbeat?.signal ?? options.signal;
359
687
  const executed = [];
360
688
  let gateRejection;
689
+ let judgeFailure;
361
690
  let aborted = false;
362
691
  const maxSteps = options.maxSteps ?? Number.POSITIVE_INFINITY;
363
- // Seed the lifetime unit cap AND the budget ceilings from the journal so
364
- // both are truly per-RUN: a resumed or re-invoked run must not restart the
365
- // runaway backstop or a declared `budget` at zero. Journal rows = past
366
- // dispatch ATTEMPTS (counted against `budget.max_units`); their summed
367
- // `tokens` column is the run's spend so far (counted against
368
- // `budget.max_tokens`). The executor consumes both only on new dispatches
369
- // (durable-row reuses are free), so a large partially-completed fan-out
370
- // stays resumable.
371
- //
372
- // Gate-evaluation rows (`phase = "gate"`, journaled by the completion-gate
373
- // judge below) are EXCLUDED from the seed: the live path never consumes
374
- // DispatchBudget for a judge call, so counting its journal row on resume
375
- // would make an interrupted run hit `max_units` (and the lifetime cap)
376
- // earlier than the identical uninterrupted run — a spurious hard failure
377
- // that `on_error` cannot soften. The seed must reproduce exactly what live
378
- // accounting would have accumulated.
379
- //
380
- // The seed sums each dispatch row's `attempts` (migration 008), NOT the row
381
- // COUNT: a crash between a unit's dispatch and its finish leaves a `running`
382
- // row that resume re-dispatches under the SAME content-derived unit_id, and
383
- // `insertUnit` REPLACES that one row while bumping `attempts`. Counting rows
384
- // would erase every prior crash-retried dispatch from budget/lifetime
385
- // accounting, letting the run spend past its declared ceiling; summing
386
- // `attempts` charges each dispatch exactly once.
387
- const journaledUnits = await withWorkflowRunsRepo((repo) => repo.getUnitsForRun(next.run.id));
388
- const journaledDispatches = journaledUnits.filter((row) => row.phase !== GATE_EVALUATION_PHASE);
389
- let unitsDispatched = journaledDispatches.reduce((sum, row) => sum + row.attempts, 0);
390
- let tokensUsed = journaledDispatches.reduce((sum, row) => sum + (row.tokens ?? 0), 0);
391
- // The decoded/hash-verified row plan is the sole execution authority. The
392
- // loader seam may assert an expected plan in tests, but can never replace it.
393
- const plan = await loadFrozenPlan(next.run.id);
394
- if (options.loadPlan) {
395
- const expected = decodeWorkflowPlanV3(await options.loadPlan(next.run.workflowRef));
396
- if (computePlanHash(expected) !== computePlanHash(plan))
397
- throw new UsageError(`Injected workflow plan for run ${next.run.id} differs from its frozen plan.`);
398
- }
399
- // Reviewer #12: the journaled params row must still satisfy the frozen param
400
- // schemas before the engine resolves any unit prompt from it, so
401
- // schema-violating params — post-start corruption — fail loudly BEFORE any
402
- // unit is dispatched (start already validated the params it stored).
403
- assertRunParamsSatisfyPlan(next.run.id, plan, next.run.params ?? {});
692
+ // The `maxSteps` budget counts DISTINCT spine steps that finished processing
693
+ // never `executed.length`, which grows once per gate-loop iteration and
694
+ // once per route-skip. A step's whole bounded gate loop consumes ONE step;
695
+ // a route-skipped step consumes NOTHING (no work was dispatched for it).
696
+ let stepsProcessed = 0;
697
+ let { unitsDispatched, tokensUsed } = await seedRunAccountingFromJournal(next.run.id);
698
+ const plan = await loadAuthoritativeRunPlan(options, next);
699
+ // Live-evidence retention is decided at SET time, from the frozen plan alone:
700
+ // a completed step's complete artifact is held only while some other step's
701
+ // references can still read it. An exec unit's promoted stdout can be 8 MiB,
702
+ // and holding every step's for the whole invocation is pure ballast when
703
+ // nothing downstream names it.
704
+ const liveEvidenceConsumers = referencedStepIds(plan);
404
705
  // Route bookkeeping: targets a completed router did NOT select are skipped
405
706
  // when the spine reaches them; a target ANY router selected is protected
406
707
  // (two routers may share a target).
@@ -418,7 +719,7 @@ async function driveRun(options, initial, leaseHolder, heartbeat) {
418
719
  if (!next.done) {
419
720
  seedJournaledRouteDecisions(plan, next, routeSelected, routeUnselected);
420
721
  }
421
- while (!next.done && next.step && next.run.status === "active" && executed.length < maxSteps) {
722
+ while (!next.done && next.step && next.run.status === "active" && stepsProcessed < maxSteps) {
422
723
  // A LOST lease (the heartbeat's renewal failed mid-step) is a loud stop —
423
724
  // another engine owns the spine now. A caller abort (options.signal) is a
424
725
  // graceful break, distinct from a lost lease.
@@ -440,57 +741,27 @@ async function driveRun(options, initial, leaseHolder, heartbeat) {
440
741
  // A branch target no completed router selected → auto-skip, no dispatch.
441
742
  const skipInfo = routeUnselected.get(step.id);
442
743
  if (skipInfo && !routeSelected.has(step.id)) {
443
- // Cascade (peer review R1): a skipped step that is ITSELF a router
444
- // never evaluates its route, so none of its declared targets were
445
- // selected — mark them all skip-on-reach too (a target another
446
- // completed router selects stays protected via routeSelected). Without
447
- // this, every branch of the skipped router would run unconditionally.
448
- if (stepPlan.route) {
449
- cascadeSkippedRouter(stepPlan.route, step.id, routeUnselected);
450
- }
451
- const notes = skipInfo.selected === null
452
- ? `Skipped by route: step "${skipInfo.router}" was itself skipped, so none of its branch targets run.`
453
- : `Skipped by route: step "${skipInfo.router}" selected "${skipInfo.selected}".`;
454
- executed.push({ stepId: step.id, ok: true, unitCount: 0, failedUnits: 0, summary: notes });
455
- await completeWorkflowStep({ runId: next.run.id, stepId: step.id, status: "skipped", notes, leaseHolder });
456
- next = await getNextWorkflowStep(next.run.id);
744
+ next = await skipUnselectedRouteTarget({
745
+ runId: next.run.id,
746
+ stepId: step.id,
747
+ stepPlan,
748
+ skipInfo,
749
+ routeUnselected,
750
+ executed,
751
+ leaseHolder,
752
+ });
457
753
  continue;
458
754
  }
459
- // `dependsOn` edges (no frontend emits them today,
460
- // but a frozen plan may carry them) are a declared ordering contract:
461
- // every dependency must already be resolved before this step dispatches.
462
- // Execution is sequential (spine order), so a violation means the plan
463
- // ordered steps inconsistently with its declared edges — fail fast,
464
- // before spending.
465
- for (const dep of stepPlan.dependsOn ?? []) {
466
- const depState = next.workflow.steps.find((s) => s.id === dep);
467
- if (!depState || (depState.status !== "completed" && depState.status !== "skipped")) {
468
- throw new UsageError(`Step "${step.id}" depends on step "${dep}", which is ${depState?.status ?? "missing"}. ` +
469
- `Reorder the workflow so dependencies come first (execution is sequential in step order).`);
470
- }
471
- }
472
755
  const evidence = {};
473
756
  for (const s of next.workflow.steps)
474
- evidence[s.id] = s.evidence;
757
+ evidence[s.id] = liveEvidence.get(s.id) ?? s.evidence;
475
758
  // Bounded gate loop (addendum R2, `gate.max_loops`): loop 1 is the normal
476
759
  // execution; a gate rejection with attempts left re-executes the subgraph
477
- // with the judge's feedback threaded into unit prompts. `advanced` = the
478
- // step completed and the spine may move on; `stopEngine` = failure or
479
- // final rejection this invocation is done.
480
- const maxLoops = Math.max(1, stepPlan.gate.maxLoops ?? 1);
481
- // Crash-resume gate state (Codex P1): SEED the starting gate loop from the
482
- // journal through the SAME shared helpers the first pass used — no fork.
483
- // A run interrupted after a rejected gate was journaled
484
- // (`<step>.gate:l<n>`, complete:false) must resume at loop n+1 with the
485
- // stored corrective feedback threaded into the unit prompts; without this
486
- // the engine restarts at loop 1, reuses the rejected loop-1 rows, overwrites
487
- // `<step>.gate:l1`, and re-judges the stale artifact — breaking journaled
488
- // replay and making the resumed run diverge from the interrupted one. The rows
489
- // are re-read here (NOT the once-at-start `journaledUnits` budget seed) so a
490
- // step reached later within THIS same invocation still starts fresh at loop 1.
491
- const stepJournal = await withWorkflowRunsRepo((repo) => repo.getUnitsForRun(next.run.id));
492
- const startLoop = activeGateLoop(stepJournal, step.id);
493
- const seededFeedback = recoverGateFeedback(stepJournal, step.id, startLoop);
760
+ // with the judge's feedback threaded into unit prompts. The bound comes
761
+ // from the shared derivation, which holds an exec step to a single
762
+ // executionits argv cannot answer feedback (see effectiveGateMaxLoops).
763
+ const maxLoops = effectiveGateMaxLoops(stepPlan);
764
+ const { startLoop, seededFeedback } = await recoverGateLoopState(next.run.id, stepPlan);
494
765
  // Resume AFTER the FINAL rejection (`startLoop` past the loop bound): the
495
766
  // gate was already exhausted before the crash, so there is NO fresh loop to
496
767
  // run — reproduce the documented gateRejection outcome from the stored
@@ -505,131 +776,63 @@ async function driveRun(options, initial, leaseHolder, heartbeat) {
505
776
  };
506
777
  break;
507
778
  }
508
- let gateFeedback = seededFeedback;
509
- let advanced = false;
510
- let stopEngine = false;
511
- for (let gateLoop = startLoop; gateLoop <= maxLoops; gateLoop++) {
512
- // A loop re-execution dispatches a fresh round of units — renew the
513
- // lease so a long evaluator-optimizer cycle cannot outlive the TTL.
514
- if (gateLoop > 1)
515
- await renewRunLease(next.run.id, leaseHolder);
516
- // Route-only steps (YAML `route:` — no execution subgraph) dispatch no
517
- // units; they only decide the spine's path below. Everything else
518
- // executes its subgraph through the native executor.
519
- const result = !stepPlan.root && stepPlan.route
520
- ? {
521
- ok: true,
522
- units: [],
523
- evidence: {},
524
- summary: `Step "${step.id}" is a route step — no units dispatched.`,
525
- unitsDispatched,
526
- }
527
- : await executeStepPlan(stepPlan, {
528
- runId: next.run.id,
529
- leaseHolder,
530
- workflowRef: next.run.workflowRef,
531
- params: next.run.params ?? {},
532
- evidence,
533
- unitsDispatched,
534
- tokensUsed,
535
- // Budget ceilings ride the FROZEN plan (addendum R2): a mid-run
536
- // asset edit can never loosen or tighten a run's budget.
537
- ...(plan.budget ? { budget: plan.budget } : {}),
538
- ...(plan.execution ? { engines: plan.execution.engines } : {}),
539
- gateLoop,
540
- ...(gateFeedback ? { gateFeedback } : {}),
541
- // The heartbeat's signal is the effective dispatch signal: a lost
542
- // lease (or a caller abort) aborts in-flight units promptly.
543
- ...(dispatchSignal ? { signal: dispatchSignal } : {}),
544
- ...(options.dispatcher ? { dispatcher: options.dispatcher } : {}),
545
- maxConcurrency: Math.min(options.maxConcurrency ?? Number.POSITIVE_INFINITY, plan.execution?.maxConcurrency ?? 1),
546
- });
547
- // If the heartbeat lost the lease WHILE this step dispatched, another
548
- // engine now owns the run — stop loudly BEFORE finalizing the step
549
- // (completeWorkflowStep would race the new owner's spine).
550
- heartbeat?.assertAlive();
551
- unitsDispatched = result.unitsDispatched;
552
- if (result.tokensUsed !== undefined)
553
- tokensUsed = result.tokensUsed;
554
- if (options.signal?.aborted) {
555
- aborted = true;
556
- stopEngine = true;
557
- break;
558
- }
559
- executed.push({
779
+ // Judge-outage contract: resolve the step's frozen completion judge BEFORE
780
+ // any dispatch. An unresolvable judge (missing frozen engine, no dispatcher
781
+ // for an agent judge) is verifier INFRASTRUCTURE failure — block the step
782
+ // for `akm workflow resume` instead of spending on units the gate can never
783
+ // verify. No gate loop is consumed and nothing is dispatched.
784
+ let summaryJudge;
785
+ try {
786
+ summaryJudge = workflowSummaryJudge(options, plan, stepPlan, dispatchSignal, {
787
+ runId: next.run.id,
560
788
  stepId: step.id,
561
- ok: result.ok,
562
- unitCount: result.units.length,
563
- failedUnits: result.units.filter((u) => !u.ok).length,
564
- summary: result.summary,
565
789
  });
566
- // Route evaluation + artifact-judged completion gate + gate-row
567
- // journaling + the bounded-loop rejection contract are the SHARED
568
- // completion path (`finalizeExecutedStep`): every step advances through
569
- // that one sequence, whether its units were just dispatched or rehydrated
570
- // from the journal on resume, so the same frozen plan always promotes the
571
- // same artifact and advances (or rejects) the spine identically. The
572
- // engine owns only the loop control the result maps onto (retry
573
- // re-executes; advanced moves on; failure/exhaustion stops this invocation).
574
- let finalize;
575
- try {
576
- finalize = await finalizeExecutedStep({
577
- runId: next.run.id,
578
- workflowRef: next.run.workflowRef,
579
- stepId: step.id,
580
- stepPlan,
581
- completionCriteria: stepPlan.gate.criteria,
582
- gateLoop,
583
- loopsRemaining: gateLoop < maxLoops,
584
- result,
585
- priorEvidence: evidence,
586
- params: next.run.params ?? {},
587
- routeSelected,
588
- routeUnselected,
589
- summaryJudge: workflowSummaryJudge(options, plan, stepPlan, dispatchSignal),
590
- signal: options.signal,
591
- leaseHolder,
592
- });
593
- }
594
- catch (error) {
595
- heartbeat?.assertAlive();
596
- if (options.signal?.aborted) {
597
- aborted = true;
598
- stopEngine = true;
599
- break;
600
- }
601
- throw error;
602
- }
603
- heartbeat?.assertAlive();
604
- if (finalize.kind === "retry") {
605
- // Re-execute the subgraph with the judge/validation feedback threaded
606
- // into unit prompts — the changed prompt changes each unit's input
607
- // hash, so the re-run dispatches fresh work instead of reusing rows.
608
- gateFeedback = finalize.gateFeedback;
609
- continue;
610
- }
611
- if (finalize.kind === "advanced") {
612
- // A route-only step's summary IS its decision (finalize surfaces it).
613
- if (finalize.summaryOverride !== undefined) {
614
- executed[executed.length - 1] = { ...executed[executed.length - 1], summary: finalize.summaryOverride };
615
- }
616
- advanced = true;
617
- break;
618
- }
619
- if (finalize.kind === "failed") {
620
- // A route-failure was pushed as ok:true (the units succeeded); reflect
621
- // the deterministic route failure in the executed report.
622
- if (finalize.routeFailure) {
623
- executed[executed.length - 1] = { ...executed[executed.length - 1], ok: false, summary: finalize.summary };
624
- }
625
- stopEngine = true;
626
- break;
627
- }
628
- // gate-exhausted: rejected with no loop budget left — stop with feedback.
629
- gateRejection = finalize.gateRejection;
630
- stopEngine = true;
631
790
  }
632
- if (stopEngine || !advanced)
791
+ catch (error) {
792
+ const detail = error instanceof Error && error.message ? ` (${error.message})` : "";
793
+ // Nothing was dispatched, so there is no evidence to preserve — the same
794
+ // blocked write the post-execution path uses, minus the results it does
795
+ // not have.
796
+ const notes = await blockStepForJudgeFailure({
797
+ runId: next.run.id,
798
+ stepId: step.id,
799
+ cause: `the verification judge could not be resolved from the frozen plan${detail}`,
800
+ leaseHolder,
801
+ });
802
+ executed.push({ stepId: step.id, ok: false, unitCount: 0, failedUnits: 0, summary: notes });
803
+ judgeFailure = { stepId: step.id, message: notes };
804
+ break;
805
+ }
806
+ const outcome = await runStepGateLoop({
807
+ options,
808
+ next,
809
+ plan,
810
+ stepPlan,
811
+ step,
812
+ evidence,
813
+ liveEvidence,
814
+ liveEvidenceConsumers,
815
+ executed,
816
+ routeSelected,
817
+ routeUnselected,
818
+ summaryJudge,
819
+ leaseHolder,
820
+ heartbeat,
821
+ dispatchSignal,
822
+ }, { startLoop, maxLoops, seededFeedback }, { unitsDispatched, tokensUsed });
823
+ unitsDispatched = outcome.unitsDispatched;
824
+ tokensUsed = outcome.tokensUsed;
825
+ if (outcome.kind === "aborted")
826
+ aborted = true;
827
+ if (outcome.gateRejection)
828
+ gateRejection = outcome.gateRejection;
829
+ if (outcome.judgeFailure)
830
+ judgeFailure = outcome.judgeFailure;
831
+ if (STEP_FINISHED_KINDS.has(outcome.kind))
832
+ stepsProcessed += 1;
833
+ // Only an advance leaves the spine walkable; every other kind ends this
834
+ // invocation (failure, exhausted gate, judge outage, abort).
835
+ if (outcome.kind !== "advanced")
633
836
  break;
634
837
  next = await getNextWorkflowStep(next.run.id);
635
838
  }
@@ -638,8 +841,10 @@ async function driveRun(options, initial, leaseHolder, heartbeat) {
638
841
  return {
639
842
  run: finalState.run,
640
843
  executed,
844
+ stepsProcessed,
641
845
  ...(finalState.run.status === "completed" ? { done: true } : {}),
642
846
  ...(gateRejection ? { gateRejection } : {}),
847
+ ...(judgeFailure ? { judgeFailure } : {}),
643
848
  ...(aborted ? { aborted: true } : {}),
644
849
  };
645
850
  }