akm-cli 0.9.0-rc.0 → 0.9.0-rc.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 (262) hide show
  1. package/CHANGELOG.md +339 -2
  2. package/SECURITY.md +23 -24
  3. package/dist/assets/help/help-improve.md +10 -10
  4. package/dist/assets/hints/cli-hints-full.md +44 -10
  5. package/dist/assets/hints/cli-hints-short.md +6 -2
  6. package/dist/assets/{profiles → improve-strategies}/default.json +1 -0
  7. package/dist/assets/{profiles → improve-strategies}/graph-refresh.json +1 -1
  8. package/dist/assets/{profiles → improve-strategies}/proactive-maintenance.json +2 -3
  9. package/dist/assets/{profiles → improve-strategies}/reflect-distill.json +3 -4
  10. package/dist/assets/prompts/workflow-unit-preamble.md +26 -0
  11. package/dist/assets/stash-skeleton/README.md +28 -0
  12. package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +6 -0
  13. package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +6 -0
  14. package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +12 -1
  15. package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +11 -1
  16. package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +6 -0
  17. package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +9 -0
  18. package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +7 -0
  19. package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +7 -0
  20. package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +6 -0
  21. package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +98 -0
  22. package/dist/assets/stash-skeleton/facts/conventions/domains.md +63 -0
  23. package/dist/assets/stash-skeleton/facts/conventions/organization.md +127 -0
  24. package/dist/assets/tasks/core/backup.yml +1 -0
  25. package/dist/assets/tasks/core/extract.yml +1 -0
  26. package/dist/assets/tasks/core/improve.yml +1 -0
  27. package/dist/assets/tasks/core/index-refresh.yml +1 -0
  28. package/dist/assets/tasks/core/sync.yml +1 -0
  29. package/dist/assets/tasks/core/version-check.yml +1 -0
  30. package/dist/assets/tasks/graph-refresh-weekly.yml +4 -4
  31. package/dist/assets/templates/html/health.html +5 -1
  32. package/dist/cli/config-migrate.js +31 -138
  33. package/dist/cli/config-validate.js +10 -8
  34. package/dist/cli.js +48 -14
  35. package/dist/commands/agent/agent-dispatch.js +17 -16
  36. package/dist/commands/agent/agent-support.js +0 -24
  37. package/dist/commands/agent/contribute-cli.js +5 -15
  38. package/dist/commands/backup-cli.js +54 -0
  39. package/dist/commands/config-cli.js +45 -159
  40. package/dist/commands/env/env-binding.js +95 -0
  41. package/dist/commands/env/env-cli.js +8 -65
  42. package/dist/commands/env/secret.js +8 -5
  43. package/dist/commands/health/checks.js +130 -83
  44. package/dist/commands/health/html-report.js +4 -0
  45. package/dist/commands/health/improve-metrics.js +30 -32
  46. package/dist/commands/health/llm-usage.js +19 -19
  47. package/dist/commands/health/md-report.js +4 -0
  48. package/dist/commands/health/metrics.js +2 -1
  49. package/dist/commands/health/surfaces.js +5 -4
  50. package/dist/commands/health.js +1 -1
  51. package/dist/commands/improve/consolidate/chunking.js +2 -2
  52. package/dist/commands/improve/consolidate.js +28 -25
  53. package/dist/commands/improve/distill/promote-memory.js +5 -12
  54. package/dist/commands/improve/distill/quality-gate.js +5 -7
  55. package/dist/commands/improve/distill.js +16 -5
  56. package/dist/commands/improve/eligibility.js +22 -12
  57. package/dist/commands/improve/extract-cli.js +47 -19
  58. package/dist/commands/improve/extract.js +110 -62
  59. package/dist/commands/improve/improve-cli.js +38 -16
  60. package/dist/commands/improve/improve-result-file.js +30 -24
  61. package/dist/commands/improve/improve-strategies.js +137 -0
  62. package/dist/commands/improve/improve.js +60 -30
  63. package/dist/commands/improve/locks.js +66 -45
  64. package/dist/commands/improve/loop-stages.js +75 -33
  65. package/dist/commands/improve/memory/memory-belief.js +79 -7
  66. package/dist/commands/improve/memory/memory-contradiction-detect.js +12 -4
  67. package/dist/commands/improve/preparation.js +71 -73
  68. package/dist/commands/improve/procedural.js +3 -2
  69. package/dist/commands/improve/recombine.js +2 -1
  70. package/dist/commands/improve/reflect.js +119 -214
  71. package/dist/commands/improve/shared.js +11 -5
  72. package/dist/commands/lint/base-linter.js +152 -42
  73. package/dist/commands/mv-cli.js +809 -0
  74. package/dist/commands/proposal/proposal-cli.js +18 -8
  75. package/dist/commands/proposal/propose.js +64 -69
  76. package/dist/commands/read/knowledge.js +436 -4
  77. package/dist/commands/read/remember-cli.js +39 -2
  78. package/dist/commands/read/search-cli.js +6 -1
  79. package/dist/commands/registry-cli.js +29 -14
  80. package/dist/commands/remember.js +2 -0
  81. package/dist/commands/sources/init.js +13 -14
  82. package/dist/commands/sources/migration-help.js +7 -4
  83. package/dist/commands/sources/schema-repair.js +2 -4
  84. package/dist/commands/sources/source-add.js +62 -73
  85. package/dist/commands/sources/source-manage.js +50 -46
  86. package/dist/commands/sources/stash-cli.js +41 -4
  87. package/dist/commands/tasks/default-tasks.js +12 -12
  88. package/dist/commands/tasks/tasks-cli.js +7 -3
  89. package/dist/commands/tasks/tasks.js +113 -18
  90. package/dist/commands/wiki-cli.js +9 -10
  91. package/dist/commands/workflow-cli.js +276 -12
  92. package/dist/core/asset/asset-spec.js +58 -1
  93. package/dist/core/asset/frontmatter.js +12 -2
  94. package/dist/core/common.js +5 -3
  95. package/dist/core/config/config-io.js +28 -17
  96. package/dist/core/config/config-schema.js +379 -66
  97. package/dist/core/config/config-types.js +3 -3
  98. package/dist/core/config/config-version.js +29 -0
  99. package/dist/core/config/config-walker.js +98 -27
  100. package/dist/core/config/config.js +132 -266
  101. package/dist/core/config/deep-merge.js +41 -0
  102. package/dist/core/config/engine-semantics.js +32 -0
  103. package/dist/core/errors.js +2 -2
  104. package/dist/core/extra-params.js +61 -0
  105. package/dist/core/file-lock.js +201 -56
  106. package/dist/core/improve-result.js +178 -0
  107. package/dist/core/json-schema.js +142 -0
  108. package/dist/core/maintenance-barrier.js +119 -0
  109. package/dist/core/migration-backup.js +416 -0
  110. package/dist/core/paths.js +3 -0
  111. package/dist/core/redaction.js +358 -0
  112. package/dist/core/state/migrations.js +17 -2
  113. package/dist/core/state-db.js +44 -1
  114. package/dist/indexer/db/db.js +118 -2
  115. package/dist/indexer/graph/graph-extraction.js +28 -16
  116. package/dist/indexer/index-writer-lock.js +31 -24
  117. package/dist/indexer/index-written-assets.js +15 -6
  118. package/dist/indexer/indexer.js +47 -2
  119. package/dist/indexer/passes/memory-inference.js +10 -6
  120. package/dist/indexer/passes/metadata.js +250 -0
  121. package/dist/indexer/search/db-search.js +111 -44
  122. package/dist/indexer/search/fts-query.js +41 -0
  123. package/dist/indexer/search/ranking-contributors.js +48 -0
  124. package/dist/indexer/search/ranking.js +36 -23
  125. package/dist/indexer/search/search-fields.js +11 -1
  126. package/dist/indexer/walk/matchers.js +39 -0
  127. package/dist/integrations/agent/builder-shared.js +7 -0
  128. package/dist/integrations/agent/builders.js +5 -50
  129. package/dist/integrations/agent/config.js +3 -143
  130. package/dist/integrations/agent/detect.js +17 -2
  131. package/dist/integrations/agent/engine-resolution.js +202 -0
  132. package/dist/integrations/agent/index.js +1 -2
  133. package/dist/integrations/agent/model-aliases.js +16 -2
  134. package/dist/integrations/agent/profiles.js +36 -62
  135. package/dist/integrations/agent/runner-dispatch.js +91 -4
  136. package/dist/integrations/agent/runner.js +76 -207
  137. package/dist/integrations/agent/spawn.js +141 -20
  138. package/dist/integrations/harnesses/aider/agent-builder.js +112 -0
  139. package/dist/integrations/harnesses/aider/index.js +57 -0
  140. package/dist/integrations/harnesses/aider/result-extractor.js +53 -0
  141. package/dist/integrations/harnesses/amazonq/agent-builder.js +152 -0
  142. package/dist/integrations/harnesses/amazonq/index.js +58 -0
  143. package/dist/integrations/harnesses/amazonq/result-extractor.js +48 -0
  144. package/dist/integrations/harnesses/claude/agent-builder.js +46 -8
  145. package/dist/integrations/harnesses/claude/index.js +25 -25
  146. package/dist/integrations/harnesses/claude/result-extractor.js +52 -0
  147. package/dist/integrations/harnesses/codex/agent-builder.js +136 -0
  148. package/dist/integrations/harnesses/codex/index.js +62 -0
  149. package/dist/integrations/harnesses/codex/result-extractor.js +73 -0
  150. package/dist/integrations/harnesses/copilot/agent-builder.js +121 -0
  151. package/dist/integrations/harnesses/copilot/index.js +59 -0
  152. package/dist/integrations/harnesses/copilot/result-extractor.js +151 -0
  153. package/dist/integrations/harnesses/gemini/agent-builder.js +120 -0
  154. package/dist/integrations/harnesses/gemini/index.js +59 -0
  155. package/dist/integrations/harnesses/gemini/result-extractor.js +121 -0
  156. package/dist/integrations/harnesses/index.js +27 -28
  157. package/dist/integrations/harnesses/opencode/agent-builder.js +2 -3
  158. package/dist/integrations/harnesses/opencode/index.js +15 -22
  159. package/dist/integrations/harnesses/opencode-sdk/harness.js +60 -0
  160. package/dist/integrations/harnesses/opencode-sdk/index.js +8 -32
  161. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +681 -108
  162. package/dist/integrations/harnesses/openhands/agent-builder.js +125 -0
  163. package/dist/integrations/harnesses/openhands/index.js +57 -0
  164. package/dist/integrations/harnesses/openhands/result-extractor.js +103 -0
  165. package/dist/integrations/harnesses/pi/agent-builder.js +103 -0
  166. package/dist/integrations/harnesses/pi/index.js +57 -0
  167. package/dist/integrations/harnesses/pi/result-extractor.js +135 -0
  168. package/dist/integrations/harnesses/types.js +8 -32
  169. package/dist/integrations/lockfile.js +32 -21
  170. package/dist/integrations/session-logs/index.js +24 -11
  171. package/dist/llm/client.js +48 -14
  172. package/dist/llm/feature-gate.js +15 -47
  173. package/dist/llm/graph-extract.js +1 -1
  174. package/dist/llm/index-passes.js +8 -42
  175. package/dist/llm/memory-infer-impl.js +1 -1
  176. package/dist/llm/usage-persist.js +4 -0
  177. package/dist/llm/usage-telemetry.js +35 -5
  178. package/dist/output/renderers.js +3 -2
  179. package/dist/output/shapes/helpers.js +2 -1
  180. package/dist/output/shapes/passthrough.js +6 -0
  181. package/dist/output/text/helpers.js +215 -2
  182. package/dist/output/text/workflow.js +3 -1
  183. package/dist/schemas/akm-config.json +16638 -0
  184. package/dist/schemas/akm-task.json +87 -0
  185. package/dist/schemas/akm-workflow.json +372 -0
  186. package/dist/scripts/migrate-storage.js +10944 -8801
  187. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +9247 -350
  188. package/dist/setup/detected-engines.js +142 -0
  189. package/dist/setup/engine-config.js +89 -0
  190. package/dist/setup/setup.js +236 -132
  191. package/dist/setup/steps/connection.js +61 -32
  192. package/dist/setup/steps/platforms.js +4 -4
  193. package/dist/setup/steps.js +3 -2
  194. package/dist/storage/database.js +13 -1
  195. package/dist/storage/engines/sqlite-migrations.js +1 -0
  196. package/dist/storage/repositories/improve-runs-repository.js +5 -5
  197. package/dist/storage/repositories/task-history-repository.js +78 -0
  198. package/dist/storage/repositories/workflow-runs-repository.js +190 -1
  199. package/dist/tasks/parser.js +138 -52
  200. package/dist/tasks/runner.js +71 -75
  201. package/dist/tasks/schema.js +1 -1
  202. package/dist/tasks/validator.js +11 -6
  203. package/dist/text-import-hook.mjs +1 -1
  204. package/dist/wiki/wiki.js +9 -8
  205. package/dist/workflows/authoring/authoring.js +123 -10
  206. package/dist/workflows/authoring/workflow-program-template.yaml +31 -0
  207. package/dist/workflows/cli.js +4 -0
  208. package/dist/workflows/concurrency-policy.js +15 -0
  209. package/dist/workflows/db.js +200 -13
  210. package/dist/workflows/exec/brief.js +478 -0
  211. package/dist/workflows/exec/frozen-judge.js +47 -0
  212. package/dist/workflows/exec/native-executor.js +1034 -0
  213. package/dist/workflows/exec/param-secrets.js +115 -0
  214. package/dist/workflows/exec/report.js +1355 -0
  215. package/dist/workflows/exec/run-workflow.js +609 -0
  216. package/dist/workflows/exec/scheduler.js +71 -0
  217. package/dist/workflows/exec/step-work.js +1212 -0
  218. package/dist/workflows/exec/unit-writer.js +23 -0
  219. package/dist/workflows/exec/watch.js +116 -0
  220. package/dist/workflows/exec/worktree.js +171 -0
  221. package/dist/workflows/ir/compile.js +375 -0
  222. package/dist/workflows/ir/freeze.js +243 -0
  223. package/dist/workflows/ir/params.js +54 -0
  224. package/dist/workflows/ir/plan-hash.js +68 -0
  225. package/dist/workflows/ir/schema.js +545 -0
  226. package/dist/workflows/parser.js +10 -1
  227. package/dist/workflows/program/expressions.js +369 -0
  228. package/dist/workflows/program/parser.js +869 -0
  229. package/dist/workflows/program/project.js +104 -0
  230. package/dist/workflows/program/schema.js +54 -0
  231. package/dist/workflows/renderer.js +82 -5
  232. package/dist/workflows/resource-limits.js +20 -0
  233. package/dist/workflows/runtime/agent-identity.js +59 -14
  234. package/dist/workflows/runtime/plan-classifier.js +187 -0
  235. package/dist/workflows/runtime/runs.js +246 -69
  236. package/dist/workflows/runtime/unit-checkin.js +45 -0
  237. package/dist/workflows/runtime/workflow-asset-loader.js +42 -1
  238. package/dist/workflows/validate-summary.js +24 -3
  239. package/dist/workflows/validator.js +26 -1
  240. package/docs/data-and-telemetry.md +4 -3
  241. package/docs/migration/release-notes/0.6.0.md +1 -1
  242. package/docs/migration/release-notes/0.7.0.md +5 -4
  243. package/docs/migration/release-notes/0.9.0-beta.60.md +19 -0
  244. package/docs/migration/v0.8-to-v0.9.md +401 -0
  245. package/package.json +4 -2
  246. package/schemas/akm-config.json +16638 -0
  247. package/schemas/akm-task.json +87 -0
  248. package/schemas/akm-workflow.json +372 -0
  249. package/dist/commands/improve/improve-profiles.js +0 -168
  250. package/dist/core/config/config-migration.js +0 -602
  251. package/dist/core/deep-merge.js +0 -38
  252. package/dist/llm/call-ai.js +0 -62
  253. package/dist/setup/legacy-config.js +0 -106
  254. package/docs/README.md +0 -104
  255. /package/dist/assets/{profiles → improve-strategies}/catchup.json +0 -0
  256. /package/dist/assets/{profiles → improve-strategies}/consolidate.json +0 -0
  257. /package/dist/assets/{profiles → improve-strategies}/frequent.json +0 -0
  258. /package/dist/assets/{profiles → improve-strategies}/memory-focus.json +0 -0
  259. /package/dist/assets/{profiles → improve-strategies}/quick.json +0 -0
  260. /package/dist/assets/{profiles → improve-strategies}/recombine-only.json +0 -0
  261. /package/dist/assets/{profiles → improve-strategies}/synthesize.json +0 -0
  262. /package/dist/assets/{profiles → improve-strategies}/thorough.json +0 -0
@@ -0,0 +1,1355 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * `akm workflow report <run>` — the MUTATING half of the harness-neutral driver
6
+ * protocol (redesign addendum R3). A driver that ran a unit from `akm workflow
7
+ * brief` reports its result back through THIS path, which ingests the result
8
+ * through the SAME shared step semantics the native engine uses
9
+ * (`step-work.ts`), so an engine-driven run and a brief/report-driven run of the
10
+ * same frozen plan produce byte-identical unit graphs (the invariant R4
11
+ * asserts).
12
+ *
13
+ * ## No duplicated semantics (the cardinal rule)
14
+ *
15
+ * Every decision report makes is a shared function:
16
+ * - the expected work-list (item resolution + content-derived unit ids + input
17
+ * hashes + prompt assembly with recovered gate feedback) — the SAME
18
+ * {@link computeStepWorkList} / {@link activeGateLoop} /
19
+ * {@link recoverGateFeedback} `brief` and the executor call;
20
+ * - the input hash stored on the unit row is the engine's
21
+ * ({@link StepWorkUnit.resolved.inputHash});
22
+ * - reducer / artifact promotion / output-schema validation via
23
+ * {@link reduceStepOutcomes} (the executor's post-dispatch reduction);
24
+ * - route evaluation + artifact-judged gate completion + gate-row journaling +
25
+ * the bounded-loop rejection contract via {@link finalizeExecutedStep} (the
26
+ * engine loop's completion path).
27
+ *
28
+ * ## The ONE mutating verb, guarded
29
+ *
30
+ * A report is REFUSED unless the run is active AND no live engine lease is held
31
+ * (the engine owns the spine while driving). The reported unit must belong to
32
+ * the active step's recomputed work-list. A COMPLETED unit re-reported with the
33
+ * same input hash is an idempotent no-op; a different hash is replay divergence.
34
+ * Declared budget ceilings are enforced (journal-seeded, same rule as the
35
+ * engine). When a report makes the active step's work-list fully terminal it
36
+ * runs the identical completion path — reducer → artifact promotion → schema
37
+ * validation → artifact-judged gate → `completeWorkflowStep` — honoring
38
+ * `on_error` and `gate.max_loops`.
39
+ */
40
+ import { randomUUID } from "node:crypto";
41
+ import { UsageError } from "../../core/errors.js";
42
+ import { appendEvent } from "../../core/events.js";
43
+ import { validateJsonSchemaSubset } from "../../core/json-schema.js";
44
+ import { acquireMaintenanceActivity } from "../../core/maintenance-barrier.js";
45
+ import { collectSensitiveValues, isEnvPassthroughValueSafeToExpose, redactSensitiveText, redactSensitiveValue, } from "../../core/redaction.js";
46
+ import { withWorkflowRunsRepo, } from "../../storage/repositories/workflow-runs-repository.js";
47
+ import { assertRunParamsSatisfyPlan } from "../ir/params.js";
48
+ import { PROGRAM_RETRY_REASONS } from "../program/schema.js";
49
+ import { requireExecutableWorkflowPlan } from "../runtime/plan-classifier.js";
50
+ import { completeWorkflowStep, getNextWorkflowStep, snapshotRunForDriver, } from "../runtime/runs.js";
51
+ import { UNIT_STALE_MS } from "../runtime/unit-checkin.js";
52
+ import { buildLease, resolveRunId } from "./brief.js";
53
+ import { frozenSummaryJudge } from "./frozen-judge.js";
54
+ import { activeGateLoop, cascadeSkippedRouter, computeStepWorkList, finalizeExecutedStep, isRetryEligibleFailure, recoverGateFeedback, reduceEmptyStep, reduceStepOutcomes, seedJournaledRouteDecisions, selectUnitAttemptRow, stepOutputsFromEvidence, unitOutcomeFromRow, unitStillNeedsReport, } from "./step-work.js";
55
+ /**
56
+ * Claim time-to-live. Equal to the unit-checkin stale window
57
+ * ({@link UNIT_STALE_MS}) so a claim expires at exactly the moment
58
+ * `workflow brief` starts surfacing the unit as stale — an expired claim is,
59
+ * by construction, a stale-driver claim another driver may reclaim.
60
+ */
61
+ const CLAIM_TTL_MS = UNIT_STALE_MS;
62
+ /**
63
+ * Finalization-lock TTL (review round 2, #5). The finalize path claims the run's
64
+ * engine lease as a short-lived lock so exactly ONE reporter runs a step's
65
+ * completion. Sized like the run lease; the loser never runs the judge (it
66
+ * returns idempotent success on a failed acquire), so the TTL only bounds crash
67
+ * recovery — and `completeWorkflowStep`'s own transactional CAS remains the true
68
+ * arbiter of the single spine advance regardless.
69
+ */
70
+ const FINALIZE_LOCK_TTL_MS = 90_000;
71
+ // ── Entry point ──────────────────────────────────────────────────────────────
72
+ export async function reportWorkflowUnit(input) {
73
+ const release = await acquireMaintenanceActivity("workflow-report");
74
+ try {
75
+ return await reportWorkflowUnitWithBarrier(input);
76
+ }
77
+ finally {
78
+ release();
79
+ }
80
+ }
81
+ async function reportWorkflowUnitWithBarrier(input) {
82
+ const nowFn = input.now ?? (() => new Date());
83
+ const nowIso = nowFn().toISOString();
84
+ const runId = await resolveRunId(input.target);
85
+ // #14: read the spine, run row, and unit journal in ONE snapshot so the guards
86
+ // below see a consistent point-in-time state (same fix as `brief`).
87
+ const { next, run: runRow, units } = await snapshotRunForDriver(runId);
88
+ const leaseHolder = runRow.engine_lease_holder;
89
+ const leaseUntil = runRow.engine_lease_until;
90
+ // Refuse a non-active run: there is no work-list to report against.
91
+ if (next.run.status !== "active" || next.done) {
92
+ throw new UsageError(`Workflow run ${runId} is ${next.run.status} — \`akm workflow report\` only records results for an ACTIVE ` +
93
+ `run. ${next.run.status === "completed" ? "The run is already done." : `Reopen it first: \`akm workflow resume ${runId}\`.`}`);
94
+ }
95
+ // Refuse while a LIVE engine lease is held — the engine owns the spine while
96
+ // driving; a report would race its completion.
97
+ const lease = buildLease(leaseHolder, leaseUntil);
98
+ if (lease?.live) {
99
+ throw new UsageError(`Workflow run ${runId} is being driven by engine ${lease.holder} (run lease expires ${lease.until}). ` +
100
+ `\`akm workflow report\` is refused while the engine lease is live — wait for the engine to finish or for ` +
101
+ `the lease to expire before reporting units.`);
102
+ }
103
+ if (!next.step) {
104
+ throw new UsageError(`Workflow run ${runId} is active but has no current step to report against.`);
105
+ }
106
+ // #14: the optional spine guard. `brief` embeds `--expect-step <activeStep>` in
107
+ // every report command; if the active step has since changed, refuse rather
108
+ // than record against a step the driver did not plan against.
109
+ if (input.expectStep !== undefined && next.step.id !== input.expectStep) {
110
+ throw new UsageError(`Workflow run ${runId} is now on step "${next.step.id}", not "${input.expectStep}" (--expect-step). The spine ` +
111
+ `moved since you briefed it — a concurrent report/run/manual completion advanced the run. Re-run ` +
112
+ `\`akm workflow brief ${runId}\` and report against the current step.`, "INVALID_FLAG_VALUE");
113
+ }
114
+ const plan = requireExecutableWorkflowPlan(runRow);
115
+ // Reviewer #12: the journaled params row must still satisfy the frozen param
116
+ // schemas before report resolves any unit prompt from it — a violation is
117
+ // post-start corruption, refused loudly (mirrors the frozen-plan hash check
118
+ // and the tampered-params replay-divergence path).
119
+ assertRunParamsSatisfyPlan(runId, plan, next.run.params ?? {});
120
+ // Resolve the step the driver should actually report against. If the spine is
121
+ // parked on a NON-DISPATCHING step — a route-only step, an empty fan-out
122
+ // (`over: []`), a step whose every unit is an unresolvable expression, or a
123
+ // whole-list resolution failure — the engine auto-completes or fails it: there
124
+ // is no `report --unit` that could ever advance it. So `report` first settles
125
+ // the spine past such steps (mutating exactly as the engine would, through the
126
+ // SAME shared completion path), then resolves the reported unit against the
127
+ // resting step. This is a no-op when the active step already has real
128
+ // reportable work (the common case) — no settle runs, nothing mutates.
129
+ let ctx = buildStepContext(runId, plan, next, units);
130
+ if (!ctx.dispatching) {
131
+ const settled = await settleSpine({ plan, runId, summaryJudge: input.summaryJudge });
132
+ if (settled.done || settled.run.status !== "active" || !settled.step) {
133
+ return settledTerminalResult(input, settled);
134
+ }
135
+ const freshUnits = await withWorkflowRunsRepo((repo) => repo.getUnitsForRun(runId));
136
+ ctx = buildStepContext(runId, plan, settled, freshUnits);
137
+ }
138
+ const { next: state, stepState, stepPlan, workList, gateLoop, priorEvidence, units: unitRows } = ctx;
139
+ if (!workList) {
140
+ throw new UsageError(`Active step "${stepState.id}" of run ${runId} dispatches no reportable units` +
141
+ `${ctx.computeError ? ` (${ctx.computeError})` : " (route-only or empty)"}. There is nothing to ` +
142
+ `\`report --unit\` for it; run \`akm workflow brief ${runId}\` to see the current state.`);
143
+ }
144
+ const workUnit = workList.units.find((u) => u.unitId === input.unitId);
145
+ if (!workUnit) {
146
+ const valid = workList.units.map((u) => u.unitId).join(", ") || "(none)";
147
+ throw new UsageError(`Unit "${input.unitId}" does not belong to the active step "${stepState.id}" of run ${runId}. ` +
148
+ `Valid unit ids for this step: ${valid}. Run \`akm workflow brief ${runId}\` for the current work-list.`, "INVALID_FLAG_VALUE");
149
+ }
150
+ if (!workUnit.resolved.ok) {
151
+ throw new UsageError(`Unit "${input.unitId}" cannot be resolved (${workUnit.resolved.error}) — it has no dispatchable input to ` +
152
+ `report a result against. This is an authoring/data error in the workflow; fix it and start a new run.`);
153
+ }
154
+ if (!workUnit.engine || !workUnit.invocation) {
155
+ throw new UsageError(`Unit "${workUnit.unitId}" has no complete frozen engine attribution.`);
156
+ }
157
+ const engineName = workUnit.engine.name;
158
+ const exactModel = workUnit.invocation.model;
159
+ const inputHash = workUnit.resolved.inputHash;
160
+ const journalId = workUnit.journalBaseId;
161
+ const sensitiveValues = await collectReportedUnitSensitiveValues(workUnit);
162
+ const sessionId = input.sessionId === undefined ? undefined : redactSensitiveText(input.sessionId, sensitiveValues);
163
+ // ── running: claim / heartbeat, never advances the spine ───────────────────
164
+ if (input.status === "running") {
165
+ // The claim holder: the driver's --session-id, else a token we mint and
166
+ // return so the driver can reuse it (as --session-id) to heartbeat and
167
+ // finish the SAME claim. First unexpired claim wins.
168
+ const holder = sessionId ?? `claim:${randomUUID()}`;
169
+ const claimExpiresAt = new Date(nowFn().getTime() + CLAIM_TTL_MS).toISOString();
170
+ const claimed = await withWorkflowRunsRepo((repo) => repo.transaction(() => {
171
+ const existing = repo.getUnit(runId, journalId);
172
+ if (existing && (existing.status === "completed" || existing.status === "failed")) {
173
+ throw new UsageError(`Unit "${journalId}" of run ${runId} is already ${existing.status} — cannot claim a terminal unit as ` +
174
+ `running. Report a fresh result with --status completed|failed, or start a new run to redo it.`);
175
+ }
176
+ if (existing) {
177
+ // Stale-hash guard (#3): a running row whose recorded input_hash no
178
+ // longer matches the recomputed one is a replay divergence (a
179
+ // tampered/stale claim under a frozen plan) — refuse to heartbeat it.
180
+ assertNoHashDivergence(existing, inputHash, runId, journalId);
181
+ // Compare-and-set the claim owner: a LIVE claim held by a DIFFERENT
182
+ // holder blocks reclaim; an expired one (or a claim already ours) is
183
+ // (re)claimable. First unexpired claim wins (crash recovery on expiry).
184
+ assertClaimHeldByOrFree(existing, holder, nowIso, runId, journalId, "heartbeat");
185
+ repo.updateUnitClaim(runId, journalId, holder, claimExpiresAt, nowIso);
186
+ return "heartbeat";
187
+ }
188
+ repo.insertUnit({
189
+ runId,
190
+ unitId: journalId,
191
+ stepId: stepState.id,
192
+ nodeId: workUnit.nodeId,
193
+ parentUnitId: workUnit.isFanOut ? `${stepState.id}.map` : null,
194
+ phase: null,
195
+ runner: workUnit.runner,
196
+ engine: engineName,
197
+ model: exactModel,
198
+ inputHash,
199
+ startedAt: nowIso,
200
+ claimHolder: holder,
201
+ claimExpiresAt,
202
+ });
203
+ repo.updateUnitClaim(runId, journalId, holder, claimExpiresAt, nowIso);
204
+ return "claim";
205
+ }));
206
+ appendEvent({
207
+ eventType: "workflow_unit_started",
208
+ ref: state.run.workflowRef,
209
+ metadata: { runId, stepId: stepState.id, unitId: journalId, status: "running" },
210
+ });
211
+ const remaining = countRemaining(workList.units, unitRows, journalId, "running");
212
+ return {
213
+ ok: true,
214
+ runId,
215
+ stepId: stepState.id,
216
+ unitId: journalId,
217
+ status: "running",
218
+ gateLoop,
219
+ recorded: "heartbeat",
220
+ remainingUnits: remaining,
221
+ runStatus: state.run.status,
222
+ claim: { holder, expiresAt: claimExpiresAt },
223
+ message: claimed === "claim"
224
+ ? `Claimed unit "${journalId}" of step "${stepState.id}" as ${holder} (until ${claimExpiresAt}). ` +
225
+ `Reuse --session-id ${holder} to heartbeat, then report the result.`
226
+ : `Heartbeat recorded for unit "${journalId}" of step "${stepState.id}" (claim ${holder} extended to ${claimExpiresAt}).`,
227
+ };
228
+ }
229
+ // ── completed / failed: validate, guard, write, maybe finalize ─────────────
230
+ const { resultJson, failureReason } = prepareResult(input, workUnit, sensitiveValues);
231
+ const thisTokens = input.tokens ?? 0;
232
+ // Guarded write: the idempotent re-report / replay-divergence check and the
233
+ // budget ceiling are evaluated INSIDE the same SQLite transaction as the
234
+ // insert+finish, so two concurrent reports (same unit, or different units of
235
+ // one budgeted step) serialize on the write lock — each sees the other's row
236
+ // and the row is always internally consistent.
237
+ const status = input.status;
238
+ const holder = sessionId ?? null;
239
+ const writeResult = await withWorkflowRunsRepo((repo) => repo.transaction(() => {
240
+ const existing = repo.getUnit(runId, journalId);
241
+ if (existing?.status === "completed") {
242
+ if (existing.input_hash === inputHash)
243
+ return { kind: "idempotent" };
244
+ throw new UsageError(`Replay divergence: unit "${journalId}" of run ${runId} is already recorded COMPLETED with a different ` +
245
+ `input hash than this report's. Under a frozen plan the same unit identity must reproduce the same ` +
246
+ `inputs — refusing to overwrite. Start a new run to re-execute this work.`);
247
+ }
248
+ // A FAILED terminal row is idempotence-protected (#25): the same content
249
+ // re-reported is a no-op; a DIFFERING re-report is refused unless --rerun
250
+ // is set, which records a NEW attempt (insertUnit REPLACE bumps attempts,
251
+ // budget admission re-applies below). A frozen-plan hash mismatch is a hard
252
+ // replay divergence regardless of --rerun.
253
+ if (existing?.status === "failed") {
254
+ assertNoHashDivergence(existing, inputHash, runId, journalId);
255
+ if (!input.rerun) {
256
+ const sameOutcome = status === "failed" && existing.result_json === resultJson && existing.failure_reason === failureReason;
257
+ if (sameOutcome)
258
+ return { kind: "idempotent" };
259
+ throw new UsageError(`Unit "${journalId}" of run ${runId} is already recorded FAILED. Re-reporting it with a different result ` +
260
+ `requires an explicit --rerun (which records a NEW attempt and re-applies the declared budget). Without ` +
261
+ `--rerun a differing re-report is refused; a same-content re-report is an idempotent no-op.`);
262
+ }
263
+ }
264
+ // A `running` row being finalized: the stale-hash guard (#3) refuses a
265
+ // tampered/stale claim whose input_hash diverges, and the claim
266
+ // compare-and-set requires the matching holder while the claim is live (a
267
+ // claimless row, or an expired claim, finishes freely — simple drivers).
268
+ if (existing?.status === "running") {
269
+ assertNoHashDivergence(existing, inputHash, runId, journalId);
270
+ assertClaimHeldByOrFree(existing, holder, nowIso, runId, journalId, "finish");
271
+ }
272
+ // Budget ceilings, journal-seeded exactly as the engine seeds
273
+ // `DispatchBudget` (dispatch rows only — gate rows excluded). The existing
274
+ // row's already-spent attempts are counted before admission (#4), then this
275
+ // write's own increment is added, so overwriting a prior attempt never
276
+ // erases it from the ceiling.
277
+ const verdict = assessBudget(plan, repo.getUnitsForRun(runId), journalId, thisTokens, existing);
278
+ // A `refuse` verdict crosses a ceiling on ADMISSION: the engine would fail
279
+ // this dispatch WITHOUT journaling it. Write nothing; the caller fails the
280
+ // step (matching the engine's terminal state, not a stuck run).
281
+ if (verdict.kind === "refuse")
282
+ return { kind: "budget-refused", message: verdict.message };
283
+ // Journal the dispatch row, then finalize it. When this unit was already
284
+ // claimed with `--status running`, its row exists with the SAME dispatch
285
+ // metadata this insert would write; re-inserting would bump `attempts`
286
+ // (migration 008) and count the claim+report of ONE execution as two
287
+ // dispatches — inflating budget accounting relative to the engine, which
288
+ // does a single `insertUnit` per dispatch. So skip the insert for a live
289
+ // `running` claim (finishUnit below finalizes it, `attempts` unchanged);
290
+ // a fresh report with no claim, or a re-dispatch over a prior FAILED row,
291
+ // still (re)inserts and is charged an attempt.
292
+ if (existing?.status !== "running") {
293
+ repo.insertUnit({
294
+ runId,
295
+ unitId: journalId,
296
+ stepId: stepState.id,
297
+ nodeId: workUnit.nodeId,
298
+ parentUnitId: workUnit.isFanOut ? `${stepState.id}.map` : null,
299
+ phase: null,
300
+ runner: workUnit.runner,
301
+ engine: engineName,
302
+ model: exactModel,
303
+ inputHash,
304
+ startedAt: existing?.started_at ?? nowIso,
305
+ });
306
+ }
307
+ // Run-lifetime token accounting (Codex round-3 finding A). `finishUnit`
308
+ // OVERWRITES the row's `tokens` column, so a `--rerun` over a prior FAILED
309
+ // attempt would otherwise ERASE that attempt's already-spent tokens from
310
+ // the run total — a budgeted run could spend 80 tokens on a failed report,
311
+ // rerun for 30 more under `max_tokens: 100`, and pass because only 30 were
312
+ // retained. The engine never hits this (its retries journal SEPARATE `~r`
313
+ // rows, each keeping its own tokens). To match, the `tokens` column on a
314
+ // re-dispatched row carries the CUMULATIVE spend across the row's attempts,
315
+ // mirroring how `attempts` accumulates: budget seeds that sum this column
316
+ // then see ALL tokens ever spent on the unit. A fresh write or a live
317
+ // `running`-claim finish (no prior tokens on the row) keeps the plain
318
+ // per-attempt value — and NULL when no tokens were reported — so the engine
319
+ // parity graph (which journals NULL for a usage-less unit) is unchanged.
320
+ const priorTokens = existing?.tokens ?? 0;
321
+ const carriedTokens = priorTokens > 0 ? priorTokens + thisTokens : (input.tokens ?? null);
322
+ repo.finishUnit({
323
+ runId,
324
+ unitId: journalId,
325
+ status,
326
+ resultJson,
327
+ tokens: carriedTokens,
328
+ failureReason,
329
+ sessionId: sessionId ?? null,
330
+ finishedAt: nowIso,
331
+ });
332
+ // A `tokens-cross` verdict: this unit's OWN tokens push the run total over
333
+ // `max_tokens`. The engine journals the unit (it dispatched), then aborts
334
+ // and fails the step. The row is written above; the caller fails the step.
335
+ if (verdict.kind === "tokens-cross")
336
+ return { kind: "budget-tokens", message: verdict.message };
337
+ return { kind: "written" };
338
+ }));
339
+ // Budget REFUSAL: no row written — fail the step hard, naming the ceiling
340
+ // (budget ceilings ignore on_error), so the run reaches the engine's terminal
341
+ // FAILED state rather than getting permanently stuck (peer review R3).
342
+ if (writeResult.kind === "budget-refused") {
343
+ return failStepOnBudget(runId, stepState.id, journalId, status, gateLoop, writeResult.message, false);
344
+ }
345
+ // Events carry ids/status only — never workflow-authored content. Emitted only
346
+ // when a row was actually written (idempotent/normal/token-crossing).
347
+ appendEvent({
348
+ eventType: "workflow_unit_started",
349
+ ref: state.run.workflowRef,
350
+ metadata: { runId, stepId: stepState.id, unitId: journalId, status: "running" },
351
+ });
352
+ appendEvent({
353
+ eventType: "workflow_unit_finished",
354
+ ref: state.run.workflowRef,
355
+ metadata: {
356
+ runId,
357
+ stepId: stepState.id,
358
+ unitId: journalId,
359
+ status,
360
+ ...(failureReason ? { failureReason } : {}),
361
+ ...(input.tokens !== undefined ? { tokens: input.tokens } : {}),
362
+ },
363
+ });
364
+ // Budget TOKEN crossing: the row is written; fail the step hard naming the
365
+ // ceiling (same terminal state as the engine's addTokens abort).
366
+ if (writeResult.kind === "budget-tokens") {
367
+ return failStepOnBudget(runId, stepState.id, journalId, status, gateLoop, writeResult.message, true);
368
+ }
369
+ const idempotent = writeResult.kind === "idempotent";
370
+ // Is the step's work-list now fully terminal? Re-read the journal so a
371
+ // concurrent report of a sibling unit is observed. Unresolvable units are
372
+ // never reportable and count as terminally failed (the engine's
373
+ // expression_error), so they never keep a step outstanding.
374
+ const rowsAfter = await withWorkflowRunsRepo((repo) => repo.getUnitsForRun(runId));
375
+ const byUnit = indexDispatchRows(rowsAfter);
376
+ const remaining = remainingReportableUnits(workList, byUnit);
377
+ if (remaining > 0) {
378
+ // Fail-fast (Codex round-3 finding B). Under the default `on_error: fail` a
379
+ // single TERMINAL unit failure already fixes the step's verdict: the shared
380
+ // reducer maps ANY unit failure to a failed step, so the outstanding siblings
381
+ // cannot change the outcome. Waiting for them (the pre-fix behavior) only
382
+ // strands the run forever when a driver stops after its first failure. So a
383
+ // failed report finalizes the step NOW — through the SAME completion path +
384
+ // finalize CAS as a fully-terminal work-list — instead of returning as
385
+ // outstanding. Exceptions kept identical to the engine: `on_error: continue`
386
+ // always waits for the full work-list; a RETRY-ELIGIBLE failure is not yet
387
+ // terminal (the unit may still be re-run under its retry budget — the
388
+ // `--rerun` form brief advertises, mirroring the engine's `~r<n>` retry), so
389
+ // it too keeps waiting; and an idempotent re-report changed nothing to act on.
390
+ if (status === "failed" &&
391
+ !idempotent &&
392
+ workList.template.onError === "fail" &&
393
+ !isRetryEligibleFailure(workUnit, byUnit.get(journalId), failureReason)) {
394
+ return finalizeStep({
395
+ runId,
396
+ next: state,
397
+ plan,
398
+ stepPlan,
399
+ stepState,
400
+ workList,
401
+ byUnit,
402
+ gateLoop,
403
+ priorEvidence,
404
+ summaryJudge: input.summaryJudge === undefined ? frozenSummaryJudge(plan, stepPlan.gate.judge) : input.summaryJudge,
405
+ now: nowFn,
406
+ written: { unitId: journalId, status },
407
+ recorded: "written",
408
+ });
409
+ }
410
+ // Not fully terminal → nothing to finalize. A written report advanced the
411
+ // work-list by one; an idempotent re-report changed nothing.
412
+ return {
413
+ ok: true,
414
+ runId,
415
+ stepId: stepState.id,
416
+ unitId: journalId,
417
+ status,
418
+ gateLoop,
419
+ recorded: idempotent ? "idempotent" : "written",
420
+ remainingUnits: remaining,
421
+ runStatus: state.run.status,
422
+ message: idempotent
423
+ ? `Unit "${journalId}" was already recorded — no change (idempotent re-report). ${remaining} unit(s) still outstanding for step "${stepState.id}".`
424
+ : `Recorded unit "${journalId}" (${status}). ${remaining} unit(s) still outstanding for step "${stepState.id}".`,
425
+ };
426
+ }
427
+ // The work-list is fully terminal. For an idempotent re-report this is EITHER
428
+ // the common case (the step's completion already ran on the first report and
429
+ // the spine has since moved off this step) OR crash recovery: the process died
430
+ // between the last unit write and `completeWorkflowStep`, so every unit is
431
+ // journaled-terminal but the step never advanced. Re-read the spine: only when
432
+ // this step is STILL the active, pending step is there completion left to run —
433
+ // and then ANY driver (not just the engine) must be able to finalize it, or the
434
+ // step is permanently un-advanceable through brief/report (peer review R3,
435
+ // engine/driver crash-recovery symmetry). If the spine already moved past this
436
+ // step, a concurrent report finalized it first and this is a true no-op.
437
+ if (idempotent) {
438
+ const fresh = await getNextWorkflowStep(runId);
439
+ if (fresh.run.status !== "active" || fresh.step?.id !== stepState.id) {
440
+ return {
441
+ ok: true,
442
+ runId,
443
+ stepId: stepState.id,
444
+ unitId: journalId,
445
+ status,
446
+ gateLoop,
447
+ recorded: "idempotent",
448
+ remainingUnits: 0,
449
+ runStatus: fresh.run.status,
450
+ message: `Unit "${journalId}" was already recorded — no change (idempotent re-report).`,
451
+ };
452
+ }
453
+ }
454
+ // Fully terminal AND this step still needs completion → run the SHARED
455
+ // completion path (identical for a first report and a crash-recovering
456
+ // idempotent re-report).
457
+ return finalizeStep({
458
+ runId,
459
+ next: state,
460
+ plan,
461
+ stepPlan,
462
+ stepState,
463
+ workList,
464
+ byUnit,
465
+ gateLoop,
466
+ priorEvidence,
467
+ summaryJudge: input.summaryJudge === undefined ? frozenSummaryJudge(plan, stepPlan.gate.judge) : input.summaryJudge,
468
+ now: nowFn,
469
+ written: { unitId: journalId, status },
470
+ recorded: idempotent ? "idempotent" : "written",
471
+ });
472
+ }
473
+ // ── The settle verb (finding D) ──────────────────────────────────────────────
474
+ /**
475
+ * `akm workflow report <run> --settle` — the mutating verb that advances a run
476
+ * parked on a NON-DISPATCHING step (Codex round-3 finding D).
477
+ *
478
+ * A driver following the documented `brief → execute → report` loop has no
479
+ * `report --unit` it can run when the active step dispatches nothing: a
480
+ * params-based route step, an empty fan-out (`over: []`), or a step whose every
481
+ * unit is unresolvable. The engine auto-advances such steps, but a pure
482
+ * brief/report driver — one whose FIRST step is a params-routed route, with no
483
+ * preceding unit report to trigger the internal `settleSpine` — would get stuck.
484
+ *
485
+ * `--settle` runs the EXISTING deterministic settle path ({@link settleSpine} —
486
+ * route decision journaled, cascaded skips, spine advance) under the SAME guards
487
+ * as `report --unit`: it refuses a non-active run, refuses while a LIVE engine
488
+ * lease is held, honors `--expect-step`, and REFUSES when the active step has
489
+ * reportable units (a driver must `report --unit` those, not settle them). The
490
+ * settle runs under a short-lived engine-lease finalize lock (the same CAS
491
+ * `finalizeStep` uses) so two concurrent settles/reports cannot double-journal.
492
+ */
493
+ export async function settleWorkflowSpine(input) {
494
+ const release = await acquireMaintenanceActivity("workflow-report-settle");
495
+ try {
496
+ return await settleWorkflowSpineWithBarrier(input);
497
+ }
498
+ finally {
499
+ release();
500
+ }
501
+ }
502
+ async function settleWorkflowSpineWithBarrier(input) {
503
+ const nowFn = input.now ?? (() => new Date());
504
+ const runId = await resolveRunId(input.target);
505
+ const { next, run: runRow, units } = await snapshotRunForDriver(runId);
506
+ if (next.run.status !== "active" || next.done) {
507
+ throw new UsageError(`Workflow run ${runId} is ${next.run.status} — \`akm workflow report --settle\` only advances an ACTIVE run. ` +
508
+ `${next.run.status === "completed" ? "The run is already done." : `Reopen it first: \`akm workflow resume ${runId}\`.`}`);
509
+ }
510
+ const lease = buildLease(runRow.engine_lease_holder, runRow.engine_lease_until);
511
+ if (lease?.live) {
512
+ throw new UsageError(`Workflow run ${runId} is being driven by engine ${lease.holder} (run lease expires ${lease.until}). ` +
513
+ `\`akm workflow report --settle\` is refused while the engine lease is live — the engine settles its own ` +
514
+ `non-dispatching steps.`);
515
+ }
516
+ if (!next.step) {
517
+ throw new UsageError(`Workflow run ${runId} is active but has no current step to settle.`);
518
+ }
519
+ if (input.expectStep !== undefined && next.step.id !== input.expectStep) {
520
+ throw new UsageError(`Workflow run ${runId} is now on step "${next.step.id}", not "${input.expectStep}" (--expect-step). The spine ` +
521
+ `moved since you briefed it — re-run \`akm workflow brief ${runId}\` and settle against the current step.`, "INVALID_FLAG_VALUE");
522
+ }
523
+ const plan = requireExecutableWorkflowPlan(runRow);
524
+ assertRunParamsSatisfyPlan(runId, plan, next.run.params ?? {});
525
+ // A step with resolvable units is settled ONLY when its work-list is FULLY
526
+ // TERMINAL — every resolvable unit run to a terminal state, nothing left to
527
+ // `report --unit` — yet still un-finalized (a required-gate block that was
528
+ // resumed, or a crash between the last unit write and completion; owner
529
+ // manual-validation finding 3). Such a list has no pending unit to report, so
530
+ // `--settle` runs the SAME shared completion path a report would
531
+ // (reducer → gate → advance / re-block). A step with GENUINELY PENDING units
532
+ // (pending, in-flight, or retry-eligible failed) is still refused — those
533
+ // advance via `report --unit`.
534
+ const ctx = buildStepContext(runId, plan, next, units);
535
+ if (ctx.dispatching) {
536
+ const workList = ctx.workList;
537
+ if (!workList) {
538
+ throw new UsageError(`Active step "${ctx.stepState.id}" of run ${runId} has no work-list to settle.`);
539
+ }
540
+ const byUnit = indexDispatchRows(units);
541
+ const outstanding = workList.units.filter((u) => unitStillNeedsReport(u, byUnit));
542
+ if (outstanding.length > 0) {
543
+ const valid = outstanding.filter((u) => u.resolved.ok).map((u) => u.unitId);
544
+ throw new UsageError(`Active step "${ctx.stepState.id}" of run ${runId} has reportable units — advance it with ` +
545
+ `\`akm workflow report ${runId} --unit <id> ...\`, not --settle. Unit ids: ${valid.join(", ") || "(none)"}.`, "INVALID_FLAG_VALUE");
546
+ }
547
+ // Fully terminal: finalize through the shared completion path. `finalizeStep`
548
+ // runs its OWN finalize CAS (claims the engine lease as a lock), so we must
549
+ // NOT also hold the settle lock here — call it directly.
550
+ return finalizeStep({
551
+ runId,
552
+ next,
553
+ plan,
554
+ stepPlan: ctx.stepPlan,
555
+ stepState: ctx.stepState,
556
+ workList,
557
+ byUnit,
558
+ gateLoop: ctx.gateLoop,
559
+ priorEvidence: ctx.priorEvidence,
560
+ summaryJudge: input.summaryJudge,
561
+ now: nowFn,
562
+ written: { unitId: "(settle)", status: "skipped" },
563
+ recorded: "not-recorded",
564
+ });
565
+ }
566
+ // Finalize CAS: claim the engine lease as a short-lived settle lock, thread the
567
+ // holder through the settle so its `completeWorkflowStep` calls pass the
568
+ // single-driver guard, and release it in `finally`.
569
+ const holder = `report-settle:${randomUUID()}`;
570
+ const nowIso = nowFn().toISOString();
571
+ const lockExpiry = new Date(nowFn().getTime() + FINALIZE_LOCK_TTL_MS).toISOString();
572
+ const acquired = await withWorkflowRunsRepo((repo) => repo.acquireEngineLease(runId, holder, lockExpiry, nowIso));
573
+ if (!acquired) {
574
+ // A concurrent finalizer/settler holds the lock; report the fresh spine state
575
+ // as idempotent success rather than racing it.
576
+ const fresh = await getNextWorkflowStep(runId);
577
+ return settleVerbResult(runId, fresh, `A concurrent driver is settling run ${runId}`);
578
+ }
579
+ let settled;
580
+ try {
581
+ settled = await settleSpine({ plan, runId, summaryJudge: input.summaryJudge, leaseHolder: holder });
582
+ }
583
+ finally {
584
+ await withWorkflowRunsRepo((repo) => repo.releaseEngineLease(runId, holder));
585
+ }
586
+ return settleVerbResult(runId, settled);
587
+ }
588
+ /** Shape a `--settle` outcome as a (unit-less) report result. */
589
+ function settleVerbResult(runId, state, contendedNote) {
590
+ const runStatus = state.run.status;
591
+ const message = runStatus === "completed"
592
+ ? `Settled non-dispatching steps — the workflow run is now DONE.`
593
+ : state.step
594
+ ? `Settled non-dispatching steps. Next: run \`akm workflow brief ${runId}\` for step "${state.step.id}".`
595
+ : `Run ${runId} is ${runStatus} after settling.`;
596
+ return {
597
+ ok: true,
598
+ runId,
599
+ stepId: state.run.currentStepId ?? "(none)",
600
+ // No unit was reported — the settle verb advances the deterministic spine.
601
+ unitId: "(settle)",
602
+ status: "skipped",
603
+ gateLoop: 1,
604
+ recorded: "not-recorded",
605
+ remainingUnits: 0,
606
+ ...(runStatus === "completed" ? { stepOutcome: { kind: "advanced" } } : {}),
607
+ runStatus,
608
+ message: contendedNote ? `${contendedNote} (idempotent success). ${message}` : message,
609
+ };
610
+ }
611
+ /**
612
+ * Compute the report context for a run's active step: find the frozen step plan,
613
+ * project prior evidence, recover the gate loop + feedback, and compute the
614
+ * SHARED work-list (same ids/hashes/prompts the engine and `brief` compute).
615
+ * `dispatching` is false for the non-dispatching steps the engine auto-advances
616
+ * (route-only, empty fan-out, all-unresolvable, whole-list failure) — the report
617
+ * path settles past those rather than getting stuck at a step no `report --unit`
618
+ * can complete.
619
+ */
620
+ function buildStepContext(runId, plan, next, units) {
621
+ const stepState = next.step;
622
+ if (!stepState) {
623
+ throw new UsageError(`Workflow run ${runId} is active but has no current step to report against.`);
624
+ }
625
+ const stepPlan = plan.steps.find((s) => s.stepId === stepState.id);
626
+ if (!stepPlan) {
627
+ throw new UsageError(`Step "${stepState.id}" of run ${runId} is not present in the run's frozen plan — cannot report against it.`);
628
+ }
629
+ const priorEvidence = {};
630
+ for (const s of next.workflow.steps)
631
+ priorEvidence[s.id] = s.evidence;
632
+ const stepOutputs = stepOutputsFromEvidence(priorEvidence);
633
+ const gateLoop = activeGateLoop(units, stepState.id);
634
+ const gateFeedback = recoverGateFeedback(units, stepState.id, gateLoop);
635
+ // Route-only steps carry no execution subgraph — nothing to report.
636
+ if (!stepPlan.root) {
637
+ return { next, stepState, stepPlan, workList: null, gateLoop, priorEvidence, units, dispatching: false };
638
+ }
639
+ const computed = computeStepWorkList(stepPlan, {
640
+ runId,
641
+ params: next.run.params ?? {},
642
+ stepOutputs,
643
+ engines: plan.execution.engines,
644
+ gateLoop,
645
+ ...(gateFeedback ? { gateFeedback } : {}),
646
+ });
647
+ if (!computed.ok) {
648
+ return {
649
+ next,
650
+ stepState,
651
+ stepPlan,
652
+ workList: null,
653
+ computeError: computed.error,
654
+ gateLoop,
655
+ priorEvidence,
656
+ units,
657
+ dispatching: false,
658
+ };
659
+ }
660
+ const workList = computed.list;
661
+ // A step is dispatching only if a driver can actually report ≥1 unit: an empty
662
+ // fan-out or an all-unresolvable work-list has nothing to report.
663
+ const dispatching = workList.units.some((u) => u.resolved.ok);
664
+ return { next, stepState, stepPlan, workList, gateLoop, priorEvidence, units, dispatching };
665
+ }
666
+ /**
667
+ * Rebuild a step's unit outcomes from the journal and reduce them through the
668
+ * SAME functions the executor uses. An EMPTY work-list promotes the degenerate
669
+ * empty artifact ({@link reduceEmptyStep}); otherwise each unit is rehydrated
670
+ * from its journal row, and an UNRESOLVABLE unit (a bad `item.<path>` reference)
671
+ * is treated as the engine's immediate `expression_error` failure — never
672
+ * journaled, always reduced as a failed outcome — so a partially- or fully-
673
+ * unresolvable step reduces identically on both surfaces.
674
+ */
675
+ function reduceWorkListOutcomes(stepPlan, workList, byUnit) {
676
+ if (workList.units.length === 0) {
677
+ return reduceEmptyStep(stepPlan, workList.reducer);
678
+ }
679
+ const outcomes = [];
680
+ for (const u of workList.units) {
681
+ if (!u.resolved.ok) {
682
+ outcomes.push({ unitId: u.unitId, ok: false, failureReason: "expression_error", error: u.resolved.error });
683
+ continue;
684
+ }
685
+ // Reduce each unit by its BEST terminal attempt (base + `~r<n>` retries), the
686
+ // SAME reuse the engine applies (shared {@link selectUnitAttemptRow}). A unit
687
+ // whose base attempt failed but whose retry completed reduces as COMPLETED,
688
+ // exactly like engine resume (finding C). A still-outstanding sibling with no
689
+ // terminal row is excluded rather than reduced against a missing row — this
690
+ // only arises on the fail-fast path (a single failed unit under
691
+ // `on_error: fail` already fixes the step's verdict; unreported siblings play
692
+ // no part in it). On the normal finalize path every resolvable unit is
693
+ // terminal, so nothing is excluded and the reduction is unchanged.
694
+ const row = selectUnitAttemptRow(u, byUnit);
695
+ if (!row || (row.status !== "completed" && row.status !== "failed"))
696
+ continue;
697
+ outcomes.push(unitOutcomeFromRow(u.unitId, row, u.schema !== undefined));
698
+ }
699
+ return reduceStepOutcomes(stepPlan, workList.reducer, workList.isFanOut, workList.template.onError, outcomes);
700
+ }
701
+ /**
702
+ * Run ONE completion attempt for a reduced step outcome through the SHARED
703
+ * {@link finalizeExecutedStep} (route eval → artifact-judged gate → gate-row
704
+ * journaling → `completeWorkflowStep`), normalizing its result. A typed-artifact
705
+ * schema mismatch is a HARD failure on the report surface (documented call:
706
+ * the ENGINE recovers it from in-invocation memory, but no gate row is
707
+ * journaled, so the stateless report path cannot recover the feedback across
708
+ * invocations — and synthesizing a gate row would break engine/report unit-graph
709
+ * parity). A GATE rejection journals its `<stepId>.gate:l<loop>` row, so its
710
+ * feedback IS recoverable and stays a real bounded loop.
711
+ */
712
+ async function runStepCompletion(args) {
713
+ const maxLoops = Math.max(1, args.stepPlan.gate.maxLoops ?? 1);
714
+ const lease = args.leaseHolder !== undefined ? { leaseHolder: args.leaseHolder } : {};
715
+ const finalize = await finalizeExecutedStep({
716
+ runId: args.runId,
717
+ workflowRef: args.workflowRef,
718
+ stepId: args.stepId,
719
+ stepPlan: args.stepPlan,
720
+ completionCriteria: args.completionCriteria,
721
+ gateLoop: args.gateLoop,
722
+ loopsRemaining: args.gateLoop < maxLoops,
723
+ result: args.reduced,
724
+ priorEvidence: args.priorEvidence,
725
+ params: args.params,
726
+ routeSelected: args.routeSelected,
727
+ routeUnselected: args.routeUnselected,
728
+ summaryJudge: args.summaryJudge,
729
+ ...lease,
730
+ });
731
+ if (finalize.kind === "retry") {
732
+ if (!args.reduced.ok) {
733
+ // Typed-artifact schema mismatch → hard failure on the report surface.
734
+ await completeWorkflowStep({
735
+ runId: args.runId,
736
+ stepId: args.stepId,
737
+ status: "failed",
738
+ notes: args.reduced.summary,
739
+ evidence: args.reduced.evidence,
740
+ ...lease,
741
+ });
742
+ return { kind: "failed", summary: args.reduced.summary };
743
+ }
744
+ return {
745
+ kind: "gate-rejected",
746
+ loopsRemaining: true,
747
+ missing: finalize.gateFeedback.missing,
748
+ feedback: finalize.gateFeedback.feedback,
749
+ };
750
+ }
751
+ if (finalize.kind === "gate-exhausted") {
752
+ return {
753
+ kind: "gate-rejected",
754
+ loopsRemaining: false,
755
+ missing: finalize.gateRejection.missing,
756
+ feedback: finalize.gateRejection.feedback,
757
+ };
758
+ }
759
+ if (finalize.kind === "failed") {
760
+ return { kind: "failed", summary: finalize.summary };
761
+ }
762
+ if (finalize.kind === "blocked") {
763
+ // Reviewer #18: a required gate with no judge available. The frozen plan's
764
+ // `gate.required` rides both surfaces, so the report path blocks identically.
765
+ return { kind: "blocked", summary: finalize.summary };
766
+ }
767
+ return { kind: "advanced" };
768
+ }
769
+ async function finalizeStep(args) {
770
+ const { runId, next, plan, stepPlan, stepState, workList, byUnit, gateLoop, recorded } = args;
771
+ // ── Finalization CAS (#5) ──────────────────────────────────────────────────
772
+ // Two reporters that both observe the work-list fully terminal would otherwise
773
+ // BOTH run the completion path — double-judging the gate, journaling duplicate
774
+ // gate rows, racing `completeWorkflowStep`. Claim the run's engine lease as a
775
+ // short-lived finalize lock (the same atomic single-UPDATE primitive the engine
776
+ // uses): exactly one reporter wins. The loser (failed acquire) never runs the
777
+ // judge; it re-reads the spine and returns the winner's outcome as idempotent
778
+ // success — never a raw throw after useful work. `completeWorkflowStep`'s own
779
+ // transactional CAS remains the ultimate arbiter of the single spine advance.
780
+ const finalizeHolder = `report-finalize:${randomUUID()}`;
781
+ const nowIso = args.now().toISOString();
782
+ const lockExpiry = new Date(args.now().getTime() + FINALIZE_LOCK_TTL_MS).toISOString();
783
+ const acquired = await withWorkflowRunsRepo((repo) => repo.acquireEngineLease(runId, finalizeHolder, lockExpiry, nowIso));
784
+ if (!acquired) {
785
+ // A concurrent finalizer holds the lock; it will advance the step exactly
786
+ // once. Return idempotent success reflecting the freshest spine state.
787
+ return contendedFinalizeResult(runId, stepState, args.written, gateLoop, recorded);
788
+ }
789
+ let completion;
790
+ const maxLoops = Math.max(1, stepPlan.gate.maxLoops ?? 1);
791
+ try {
792
+ // Under the lock, re-read the spine: a PRIOR finalizer may have already
793
+ // advanced this step (a sequential race — both reporters saw the work-list
794
+ // terminal, the first finished before this one acquired). If so, skip
795
+ // re-completion and report idempotent success — the step is done.
796
+ const fresh = await getNextWorkflowStep(runId);
797
+ if (fresh.run.status !== "active" || fresh.step?.id !== stepState.id) {
798
+ return contendedFinalizeResult(runId, stepState, args.written, gateLoop, recorded, fresh);
799
+ }
800
+ const reduced = reduceWorkListOutcomes(stepPlan, workList, byUnit);
801
+ // Route/skip bookkeeping seeded from the journal so cascaded skips survive
802
+ // (identical to the engine's resume seeding).
803
+ const routeSelected = new Set();
804
+ const routeUnselected = new Map();
805
+ seedJournaledRouteDecisions(plan, next, routeSelected, routeUnselected);
806
+ completion = await runStepCompletion({
807
+ runId,
808
+ workflowRef: next.run.workflowRef,
809
+ stepPlan,
810
+ stepId: stepState.id,
811
+ completionCriteria: stepPlan.gate.criteria,
812
+ gateLoop,
813
+ reduced,
814
+ priorEvidence: args.priorEvidence,
815
+ params: next.run.params ?? {},
816
+ routeSelected,
817
+ routeUnselected,
818
+ summaryJudge: args.summaryJudge,
819
+ leaseHolder: finalizeHolder,
820
+ });
821
+ }
822
+ finally {
823
+ // Release the finalize lock before the trailing settle/messaging below runs
824
+ // its own (unlocked) `completeWorkflowStep` calls for downstream
825
+ // non-dispatching steps.
826
+ await withWorkflowRunsRepo((repo) => repo.releaseEngineLease(runId, finalizeHolder));
827
+ }
828
+ if (completion.kind === "failed") {
829
+ const state = await getNextWorkflowStep(runId);
830
+ return reportResult(runId, stepState.id, args.written, gateLoop, state.run.status, { kind: "failed", summary: completion.summary }, `Step "${stepState.id}" failed: ${completion.summary}`, recorded);
831
+ }
832
+ if (completion.kind === "blocked") {
833
+ // Reviewer #18: a required gate with no judge available blocked the step.
834
+ // The run is now `blocked`; a human resolves it via `akm workflow resume`.
835
+ const state = await getNextWorkflowStep(runId);
836
+ return reportResult(runId, stepState.id, args.written, gateLoop, state.run.status, { kind: "blocked", summary: completion.summary }, `Step "${stepState.id}" is BLOCKED: ${completion.summary}`, recorded);
837
+ }
838
+ if (completion.kind === "gate-rejected") {
839
+ if (completion.loopsRemaining) {
840
+ const nextLoop = gateLoop + 1;
841
+ return reportResult(runId, stepState.id, args.written, gateLoop, "active", {
842
+ kind: "gate-rejected",
843
+ loopsRemaining: true,
844
+ missing: completion.missing,
845
+ feedback: completion.feedback,
846
+ summary: completion.feedback,
847
+ }, `Step "${stepState.id}" was rejected — run \`akm workflow brief ${runId}\` for loop ${nextLoop}'s work-list (feedback threaded in).`, recorded);
848
+ }
849
+ return reportResult(runId, stepState.id, args.written, gateLoop, "active", {
850
+ kind: "gate-rejected",
851
+ loopsRemaining: false,
852
+ missing: completion.missing,
853
+ feedback: completion.feedback,
854
+ summary: completion.feedback,
855
+ }, `Step "${stepState.id}" was rejected and its ${maxLoops}-loop gate budget is exhausted. Resolve it manually (\`akm workflow complete\`/\`resume\`/\`abandon\`).`, recorded);
856
+ }
857
+ // advanced — the spine moved. Settle forward over any non-dispatching steps
858
+ // (route-only / skipped / empty fan-out / all-unresolvable) so the run never
859
+ // gets stuck at a step no driver could report, then surface the resting state.
860
+ const state = await settleSpine({ plan, runId, summaryJudge: args.summaryJudge });
861
+ const message = state.run.status === "completed"
862
+ ? `Step "${stepState.id}" completed — the workflow run is now DONE.`
863
+ : state.step
864
+ ? `Step "${stepState.id}" completed. Next: run \`akm workflow brief ${runId}\` for step "${state.step.id}".`
865
+ : `Step "${stepState.id}" completed; run is ${state.run.status}.`;
866
+ return reportResult(runId, stepState.id, args.written, gateLoop, state.run.status, { kind: "advanced" }, message, recorded);
867
+ }
868
+ /**
869
+ * Settle the spine forward over every NON-DISPATCHING step the engine would
870
+ * auto-advance but no `report --unit` could ever complete: a route-skipped
871
+ * target, a route-only step, an empty fan-out (`over: []`), a step whose every
872
+ * unit is unresolvable, and a whole-list resolution failure. Each is completed
873
+ * (or failed) through the SAME shared helpers the engine uses, so the run does
874
+ * not get stuck — the exact gap peer review R3 flagged. Stops at the first step
875
+ * with real reportable work, or when the run leaves `active`.
876
+ */
877
+ async function settleSpine(args) {
878
+ const { plan, runId, summaryJudge, leaseHolder } = args;
879
+ const leaseArg = leaseHolder !== undefined ? { leaseHolder } : {};
880
+ let state = await getNextWorkflowStep(runId);
881
+ const routeSelected = new Set();
882
+ const routeUnselected = new Map();
883
+ seedJournaledRouteDecisions(plan, state, routeSelected, routeUnselected);
884
+ // Bounded: each iteration advances the spine by one step OR advances one gate
885
+ // loop of a stuck gated step (which journals a gate row and is capped by that
886
+ // step's max_loops). The sum-of-loops bound cannot be exceeded.
887
+ const cap = plan.steps.reduce((n, s) => n + Math.max(1, s.gate.maxLoops ?? 1) + 2, 1);
888
+ for (let guard = 0; guard < cap && state.run.status === "active" && state.step; guard++) {
889
+ const step = state.step;
890
+ const sp = plan.steps.find((s) => s.stepId === step.id);
891
+ if (!sp)
892
+ break;
893
+ const stepJudge = summaryJudge === undefined ? frozenSummaryJudge(plan, sp.gate.judge) : summaryJudge;
894
+ // A route-skipped target: complete it as skipped, cascading if it is itself
895
+ // a router (identical to the engine loop's skip handling).
896
+ const skipInfo = routeUnselected.get(step.id);
897
+ if (skipInfo && !routeSelected.has(step.id)) {
898
+ if (sp.route)
899
+ cascadeSkippedRouter(sp.route, step.id, routeUnselected);
900
+ const notes = skipInfo.selected === null
901
+ ? `Skipped by route: step "${skipInfo.router}" was itself skipped, so none of its branch targets run.`
902
+ : `Skipped by route: step "${skipInfo.router}" selected "${skipInfo.selected}".`;
903
+ await completeWorkflowStep({ runId, stepId: step.id, status: "skipped", notes, ...leaseArg });
904
+ state = await getNextWorkflowStep(runId);
905
+ continue;
906
+ }
907
+ // A route-only step (no execution subgraph): evaluate + complete it here.
908
+ if (!sp.root && sp.route) {
909
+ const priorEvidence = {};
910
+ for (const s of state.workflow.steps)
911
+ priorEvidence[s.id] = s.evidence;
912
+ const fin = await finalizeExecutedStep({
913
+ runId,
914
+ workflowRef: state.run.workflowRef,
915
+ stepId: step.id,
916
+ stepPlan: sp,
917
+ completionCriteria: sp.gate.criteria,
918
+ gateLoop: 1,
919
+ loopsRemaining: false,
920
+ result: {
921
+ ok: true,
922
+ units: [],
923
+ evidence: {},
924
+ summary: `Step "${step.id}" is a route step — no units dispatched.`,
925
+ },
926
+ priorEvidence,
927
+ params: state.run.params ?? {},
928
+ routeSelected,
929
+ routeUnselected,
930
+ summaryJudge: stepJudge,
931
+ ...leaseArg,
932
+ });
933
+ if (fin.kind !== "advanced")
934
+ break; // a route failure stops the walk
935
+ state = await getNextWorkflowStep(runId);
936
+ continue;
937
+ }
938
+ // An executing step. Settle it ONLY when it dispatches no reportable units
939
+ // (empty fan-out, all-unresolvable, or a whole-list failure); otherwise the
940
+ // driver briefs and reports it, so stop here.
941
+ if (sp.root) {
942
+ const freshUnits = await withWorkflowRunsRepo((repo) => repo.getUnitsForRun(runId));
943
+ const priorEvidence = {};
944
+ for (const s of state.workflow.steps)
945
+ priorEvidence[s.id] = s.evidence;
946
+ const stepOutputs = stepOutputsFromEvidence(priorEvidence);
947
+ const gateLoop = activeGateLoop(freshUnits, step.id);
948
+ const gateFeedback = recoverGateFeedback(freshUnits, step.id, gateLoop);
949
+ const computed = computeStepWorkList(sp, {
950
+ runId,
951
+ params: state.run.params ?? {},
952
+ stepOutputs,
953
+ engines: plan.execution.engines,
954
+ gateLoop,
955
+ ...(gateFeedback ? { gateFeedback } : {}),
956
+ });
957
+ if (!computed.ok) {
958
+ // Whole-list resolution failure → the engine fails the step (failedStep).
959
+ await completeWorkflowStep({
960
+ runId,
961
+ stepId: step.id,
962
+ status: "failed",
963
+ notes: computed.error,
964
+ evidence: { error: computed.error },
965
+ ...leaseArg,
966
+ });
967
+ break; // run failed
968
+ }
969
+ const list = computed.list;
970
+ if (list.units.some((u) => u.resolved.ok))
971
+ break; // real reportable work — stop.
972
+ // No reportable units (empty fan-out OR all-unresolvable). Auto-complete
973
+ // it exactly as the engine would, through the SAME completion path.
974
+ const byUnit = indexDispatchRows(freshUnits);
975
+ const reduced = reduceWorkListOutcomes(sp, list, byUnit);
976
+ const completion = await runStepCompletion({
977
+ runId,
978
+ workflowRef: state.run.workflowRef,
979
+ stepPlan: sp,
980
+ stepId: step.id,
981
+ completionCriteria: sp.gate.criteria,
982
+ gateLoop,
983
+ reduced,
984
+ priorEvidence,
985
+ params: state.run.params ?? {},
986
+ routeSelected,
987
+ routeUnselected,
988
+ summaryJudge: stepJudge,
989
+ ...leaseArg,
990
+ });
991
+ if (completion.kind === "advanced") {
992
+ state = await getNextWorkflowStep(runId);
993
+ continue;
994
+ }
995
+ // A gate rejection on a zero-unit step re-runs the (unchanged) empty
996
+ // artifact, but the journaled gate row advances the loop, so the next
997
+ // iteration re-evaluates at gateLoop+1, bounded by max_loops. Loop the
998
+ // SAME step WITHOUT advancing the spine.
999
+ if (completion.kind === "gate-rejected" && completion.loopsRemaining)
1000
+ continue;
1001
+ // failed / gate-exhausted → nothing more to auto-advance.
1002
+ break;
1003
+ }
1004
+ break;
1005
+ }
1006
+ // Re-read the freshest run state: a terminal-break path (a failed step, an
1007
+ // exhausted gate) left `state` reflecting the pre-completion snapshot from the
1008
+ // top of the loop iteration, but the DB now holds the true resting state.
1009
+ return getNextWorkflowStep(runId);
1010
+ }
1011
+ // ── Helpers ──────────────────────────────────────────────────────────────────
1012
+ /**
1013
+ * Normalize a driver-supplied `--failure-reason` to the persisted taxonomy (PR
1014
+ * #714 review round 2, #16). An external driver can type ANY string; storing it
1015
+ * verbatim would let an arbitrary token masquerade as a first-class failure
1016
+ * vocabulary and (worse) accidentally match a workflow's `retry.on`. So:
1017
+ *
1018
+ * - an EMPTY/absent reason → the neutral default `reported_failure`;
1019
+ * - a reason IN the canonical taxonomy ({@link PROGRAM_RETRY_REASONS}, the
1020
+ * exact `AgentFailureReason` set `retry.on` accepts) → stored VERBATIM, so a
1021
+ * driver reporting e.g. `timeout` participates in retry semantics identically
1022
+ * to an engine-dispatched unit;
1023
+ * - anything ELSE → namespaced under `external:<slug>` (lowercase, `[a-z0-9_-]`,
1024
+ * clipped). An `external:*` value is BY CONSTRUCTION outside the taxonomy, so
1025
+ * `retry.on` (which only lists taxonomy reasons) can never fire on it — an
1026
+ * unknown external reason is recorded for observability without ever
1027
+ * triggering retry.
1028
+ */
1029
+ export function normalizeFailureReason(raw, sensitiveValues = []) {
1030
+ const trimmed = raw?.trim();
1031
+ if (!trimmed)
1032
+ return "reported_failure";
1033
+ if (redactSensitiveText(trimmed, sensitiveValues) !== trimmed)
1034
+ return "reported_failure";
1035
+ if (PROGRAM_RETRY_REASONS.includes(trimmed))
1036
+ return trimmed;
1037
+ const slug = trimmed
1038
+ .toLowerCase()
1039
+ .replace(/[^a-z0-9_-]+/g, "-")
1040
+ .replace(/^-+|-+$/g, "")
1041
+ .slice(0, 48);
1042
+ return `external:${slug || "unknown"}`;
1043
+ }
1044
+ /** Validate + shape the reported result into what `finishUnit` persists. */
1045
+ function prepareResult(input, workUnit, sensitiveValues) {
1046
+ if (input.status === "failed") {
1047
+ return {
1048
+ resultJson: input.resultRaw !== undefined && input.resultRaw !== ""
1049
+ ? JSON.stringify(redactSensitiveText(input.resultRaw, sensitiveValues))
1050
+ : null,
1051
+ failureReason: normalizeFailureReason(input.failureReason, sensitiveValues),
1052
+ };
1053
+ }
1054
+ // completed
1055
+ if (workUnit.schema) {
1056
+ const raw = input.resultRaw;
1057
+ if (raw === undefined || raw.trim() === "") {
1058
+ throw new UsageError(`Unit "${input.unitId}" declares an output schema — its --result must be a JSON value matching that schema, ` +
1059
+ `but no result was provided.`, "MISSING_REQUIRED_ARGUMENT");
1060
+ }
1061
+ let parsed;
1062
+ try {
1063
+ parsed = JSON.parse(raw);
1064
+ }
1065
+ catch (err) {
1066
+ throw new UsageError(`Unit "${input.unitId}" result is not valid JSON (its output schema requires a JSON value): ${redactSensitiveText(err instanceof Error ? err.message : String(err), sensitiveValues)}`, "INVALID_FLAG_VALUE");
1067
+ }
1068
+ const errors = validateJsonSchemaSubset(parsed, workUnit.schema);
1069
+ if (errors.length > 0) {
1070
+ throw new UsageError(`Unit "${input.unitId}" result failed validation against its declared output schema: ${redactSensitiveText(errors.join("; "), sensitiveValues)}.`, "INVALID_FLAG_VALUE");
1071
+ }
1072
+ return { resultJson: JSON.stringify(redactSensitiveValue(parsed, sensitiveValues)), failureReason: null };
1073
+ }
1074
+ // Free-text unit: journal the text as a JSON string EXACTLY as the executor
1075
+ // does — `native-executor.ts` finishUnit uses `outcome.text ? JSON.stringify… :
1076
+ // null`, so an empty (or absent) output journals result_json = NULL, not '""'.
1077
+ // Matching that keeps the promoted artifact and the dispatch row byte-identical
1078
+ // across the engine and report surfaces (the cardinal graph-parity rule), and
1079
+ // stays consistent with the FAILED branch above which also maps ""→null.
1080
+ return {
1081
+ resultJson: input.resultRaw ? JSON.stringify(redactSensitiveText(input.resultRaw, sensitiveValues)) : null,
1082
+ failureReason: null,
1083
+ };
1084
+ }
1085
+ async function collectReportedUnitSensitiveValues(workUnit) {
1086
+ const values = new Set();
1087
+ for (const ref of workUnit.env ?? []) {
1088
+ const { resolveEnvBinding } = await import("../../commands/env/env-binding.js");
1089
+ for (const value of Object.values(resolveEnvBinding(ref).values))
1090
+ values.add(value);
1091
+ }
1092
+ const collectEngine = (engine) => {
1093
+ if (!engine)
1094
+ return;
1095
+ if (engine.kind === "llm") {
1096
+ for (const name of engine.credential?.names ?? []) {
1097
+ const value = process.env[name]?.trim();
1098
+ if (value)
1099
+ values.add(value);
1100
+ }
1101
+ return;
1102
+ }
1103
+ for (const name of engine.envPassthrough) {
1104
+ const value = process.env[name];
1105
+ if (!isEnvPassthroughValueSafeToExpose(name, value) && value)
1106
+ values.add(value);
1107
+ }
1108
+ };
1109
+ collectEngine(workUnit.engine);
1110
+ collectEngine(workUnit.fallbackEngine);
1111
+ return collectSensitiveValues(values);
1112
+ }
1113
+ /**
1114
+ * Assess the frozen plan's declared budget ceilings for ONE report, seeded from
1115
+ * the journal exactly as the engine seeds `DispatchBudget`: dispatch rows
1116
+ * (phase = null) OTHER than the one being written count against `max_units`, and
1117
+ * their token sum against `max_tokens`. Unlike a simple admission check, this
1118
+ * mirrors the engine's TWO enforcement points so the report path reaches the
1119
+ * engine's terminal state (a HARD step failure naming the ceiling) instead of
1120
+ * throwing and leaving the run stuck (peer review R3, finding 1):
1121
+ *
1122
+ * - `refuse` — the engine's `tryConsume` refuses the (maxUnits+1)-th dispatch,
1123
+ * or a dispatch whose run token total is already at/over `max_tokens`,
1124
+ * WITHOUT journaling it. The report writes no row and fails the step.
1125
+ * - `tokens-cross` — the engine's `addTokens` crosses `max_tokens` AFTER a
1126
+ * dispatch: the unit IS journaled, then pending dispatches abort and the
1127
+ * step fails. The report writes the row, then fails the step.
1128
+ *
1129
+ * Budget ceilings fail the step regardless of `on_error` — a capped run must
1130
+ * never quietly pass its gate.
1131
+ */
1132
+ function assessBudget(plan, rows, journalId, thisTokens, existing) {
1133
+ const budget = plan.budget;
1134
+ if (!budget || (budget.maxUnits === undefined && budget.maxTokens === undefined))
1135
+ return { kind: "ok" };
1136
+ // The row being (re)written carries CUMULATIVE tokens across its attempts
1137
+ // (finding A): `finishUnit` will preserve them (prior spend + this write), so
1138
+ // they are part of the run's committed token total and count against
1139
+ // `max_tokens` here just like every other row's tokens. NULL ⇒ 0.
1140
+ const existingTokens = existing?.tokens ?? 0;
1141
+ let othersDispatched = 0;
1142
+ let othersTokens = 0;
1143
+ let existingAttempts = 0;
1144
+ for (const row of rows) {
1145
+ if (row.phase !== null)
1146
+ continue; // gate rows excluded
1147
+ if (row.unit_id === journalId) {
1148
+ // The row being (re)written. Its ALREADY-SPENT attempts must be counted
1149
+ // before admission (#4): excluding them let a re-report of a non-terminal
1150
+ // or failed unit erase the prior attempt from the ceiling. Its tokens are
1151
+ // accounted via `existingTokens` (the finish carries them forward, not
1152
+ // overwrites), so a rerun never drops a failed attempt's spend (finding A).
1153
+ existingAttempts = row.attempts;
1154
+ continue;
1155
+ }
1156
+ // Sum `attempts` (migration 008), not a per-row +1: a crash-retried unit
1157
+ // occupies ONE row whose `attempts` records every re-dispatch, so this
1158
+ // mirrors the engine seed (`run-workflow.ts`) and charges each dispatch.
1159
+ othersDispatched += row.attempts;
1160
+ othersTokens += row.tokens ?? 0;
1161
+ }
1162
+ // The attempts this write adds: a live `running` claim being finalized reuses
1163
+ // the claim's already-charged attempt (finishUnit, no insert ⇒ +0); every
1164
+ // other write (fresh, or a FAILED --rerun re-dispatch) inserts and bumps
1165
+ // `attempts` by one, mirroring the engine's charge-before-dispatch. The
1166
+ // projected total after this write must not exceed `max_units`.
1167
+ const increment = existing?.status === "running" ? 0 : 1;
1168
+ const projectedUnits = othersDispatched + existingAttempts + increment;
1169
+ if (budget.maxUnits !== undefined && projectedUnits > budget.maxUnits) {
1170
+ return {
1171
+ kind: "refuse",
1172
+ message: `budget exceeded (max_units ceiling): ${projectedUnits} unit dispatch(es) would be charged for this run ` +
1173
+ `against the workflow's declared budget.max_units of ${budget.maxUnits} — the step fails hard (budget ` +
1174
+ `ceilings ignore on_error).`,
1175
+ };
1176
+ }
1177
+ const committedTokens = othersTokens + existingTokens;
1178
+ if (budget.maxTokens !== undefined && committedTokens >= budget.maxTokens) {
1179
+ return {
1180
+ kind: "refuse",
1181
+ message: `budget exceeded (max_tokens ceiling): ${committedTokens} token(s) already spent for this run against the ` +
1182
+ `workflow's declared budget.max_tokens of ${budget.maxTokens} — the step fails hard (budget ceilings ignore on_error).`,
1183
+ };
1184
+ }
1185
+ if (budget.maxTokens !== undefined && committedTokens + thisTokens >= budget.maxTokens) {
1186
+ return {
1187
+ kind: "tokens-cross",
1188
+ message: `budget exceeded (max_tokens ceiling): ${committedTokens + thisTokens} token(s) spent for this run, reaching the ` +
1189
+ `workflow's declared budget.max_tokens of ${budget.maxTokens} — the step fails hard (budget ceilings ignore on_error).`,
1190
+ };
1191
+ }
1192
+ return { kind: "ok" };
1193
+ }
1194
+ // ── Claim + hash guards (shared by the running + finish transactions) ─────────
1195
+ /**
1196
+ * Stale-hash guard (review round 2, #3): under a frozen plan the same unit
1197
+ * identity must reproduce the same input hash, so an existing row whose recorded
1198
+ * `input_hash` differs from the recomputed one is a hard replay divergence — a
1199
+ * stale or tampered row. A NULL recorded hash (never seen in practice for a
1200
+ * report-claimed row) is treated as non-divergent.
1201
+ */
1202
+ function assertNoHashDivergence(existing, inputHash, runId, journalId) {
1203
+ if (existing.input_hash !== null && existing.input_hash !== inputHash) {
1204
+ throw new UsageError(`Replay divergence: unit "${journalId}" of run ${runId} has a journaled ${existing.status} row whose input ` +
1205
+ `hash differs from this report's. Under a frozen plan the same unit identity must reproduce the same inputs ` +
1206
+ `— refusing to heartbeat or finalize a stale/tampered row. Start a new run to re-execute this work.`);
1207
+ }
1208
+ }
1209
+ /**
1210
+ * Claim compare-and-set (review round 2, #3): a LIVE claim (holder set, expiry
1211
+ * in the future) held by a DIFFERENT holder blocks a reclaim/heartbeat/finish;
1212
+ * a free row, an EXPIRED claim (crash recovery), or a claim already ours passes.
1213
+ * A claimless row always passes — simple drivers skip claims entirely.
1214
+ */
1215
+ function assertClaimHeldByOrFree(existing, holder, nowIso, runId, journalId, action) {
1216
+ const claimLive = existing.claim_expires_at !== null && existing.claim_expires_at >= nowIso;
1217
+ if (existing.claim_holder !== null && claimLive && existing.claim_holder !== holder) {
1218
+ throw new UsageError(`Unit "${journalId}" of run ${runId} is claimed by ${existing.claim_holder} until ${existing.claim_expires_at}; ` +
1219
+ `only that holder can ${action} it while the claim is live. Pass --session-id ${existing.claim_holder} if you ` +
1220
+ `own the claim, or wait for it to expire (then it is reclaimable — crash recovery).`);
1221
+ }
1222
+ }
1223
+ /**
1224
+ * Fail the active step because a declared budget ceiling was crossed — the same
1225
+ * terminal state the engine reaches (`failedStep` → a FAILED step and run). The
1226
+ * failure goes through `completeWorkflowStep` (the gate spine is never bypassed)
1227
+ * with the ceiling-naming message as notes/evidence. `wroteRow` reflects whether
1228
+ * the crossing unit's row was journaled (a `tokens-cross`) or not (a `refuse`).
1229
+ */
1230
+ async function failStepOnBudget(runId, stepId, journalId, status, gateLoop, message, wroteRow) {
1231
+ await completeWorkflowStep({ runId, stepId, status: "failed", notes: message, evidence: { error: message } });
1232
+ const state = await getNextWorkflowStep(runId);
1233
+ return {
1234
+ ok: true,
1235
+ runId,
1236
+ stepId,
1237
+ unitId: journalId,
1238
+ status,
1239
+ gateLoop,
1240
+ recorded: wroteRow ? "written" : "not-recorded",
1241
+ remainingUnits: 0,
1242
+ stepOutcome: { kind: "failed", summary: message },
1243
+ runStatus: state.run.status,
1244
+ message: `Step "${stepId}" failed: ${message}`,
1245
+ };
1246
+ }
1247
+ /**
1248
+ * The result surfaced to the LOSER of the finalization CAS (#5): a concurrent
1249
+ * reporter holds (or already finished) the step's finalization. This report's
1250
+ * unit row IS durably journaled; the step advance is (being) done exactly once
1251
+ * by the winner, so this is idempotent success — never a raw throw. Re-reads the
1252
+ * freshest spine so the surfaced run status / advance is accurate.
1253
+ */
1254
+ async function contendedFinalizeResult(runId, stepState, written, gateLoop, recorded, fresh) {
1255
+ const state = fresh ?? (await getNextWorkflowStep(runId));
1256
+ const advancedPast = state.run.status !== "active" || state.step?.id !== stepState.id;
1257
+ return {
1258
+ ok: true,
1259
+ runId,
1260
+ stepId: stepState.id,
1261
+ unitId: written.unitId,
1262
+ status: written.status,
1263
+ gateLoop,
1264
+ // The UNIT write resolved as `recorded` (a real write, or an idempotent
1265
+ // re-report); only the STEP finalization was contended (a concurrent
1266
+ // reporter owns it), which the message conveys.
1267
+ recorded,
1268
+ remainingUnits: 0,
1269
+ ...(advancedPast ? { stepOutcome: { kind: "advanced" } } : {}),
1270
+ runStatus: state.run.status,
1271
+ message: advancedPast
1272
+ ? `Unit "${written.unitId}" recorded; step "${stepState.id}" was finalized by a concurrent reporter (idempotent success).`
1273
+ : `Unit "${written.unitId}" recorded; step "${stepState.id}" is being finalized by a concurrent reporter (idempotent success).`,
1274
+ };
1275
+ }
1276
+ /**
1277
+ * The result surfaced when settling non-dispatching steps drove the run to a
1278
+ * terminal (or step-less) state before the reported unit could be recorded —
1279
+ * the run advanced/failed on its own, so there is nothing left to journal.
1280
+ */
1281
+ function settledTerminalResult(input, settled) {
1282
+ const runStatus = settled.run.status;
1283
+ const message = runStatus === "completed"
1284
+ ? `The run advanced to completion while settling steps with no reportable work; unit "${input.unitId}" needed no report.`
1285
+ : `The run is ${runStatus} after settling steps with no reportable work; unit "${input.unitId}" could not be recorded.`;
1286
+ return {
1287
+ ok: true,
1288
+ runId: settled.run.id,
1289
+ stepId: settled.run.currentStepId ?? "(none)",
1290
+ unitId: input.unitId,
1291
+ status: input.status,
1292
+ gateLoop: 1,
1293
+ recorded: "not-recorded",
1294
+ remainingUnits: 0,
1295
+ stepOutcome: runStatus === "completed" ? { kind: "advanced" } : { kind: "failed", summary: message },
1296
+ runStatus,
1297
+ message,
1298
+ };
1299
+ }
1300
+ /**
1301
+ * Count the step's units that are still OUTSTANDING: resolvable units without a
1302
+ * terminal journal row. Unresolvable units are never reportable (the engine's
1303
+ * immediate `expression_error`), so they never keep a step outstanding — the
1304
+ * caller's reduction treats them as failed outcomes.
1305
+ */
1306
+ function remainingReportableUnits(workList, byUnit) {
1307
+ return workList.units.filter((u) => {
1308
+ if (!u.resolved.ok)
1309
+ return false;
1310
+ // A unit is terminal when its best attempt (base OR a completed `~r<n>`
1311
+ // retry) is terminal — the SAME reuse the reducer applies (finding C), so a
1312
+ // base-failed unit rescued by a completed retry is NOT counted outstanding.
1313
+ const row = selectUnitAttemptRow(u, byUnit);
1314
+ return !(row && (row.status === "completed" || row.status === "failed"));
1315
+ }).length;
1316
+ }
1317
+ /** Index the run's DISPATCH unit rows (phase != gate) by unit id. */
1318
+ function indexDispatchRows(rows) {
1319
+ const map = new Map();
1320
+ for (const row of rows) {
1321
+ if (row.phase === null)
1322
+ map.set(row.unit_id, row);
1323
+ }
1324
+ return map;
1325
+ }
1326
+ /** Count units whose journal row is not yet terminal (for progress messaging). */
1327
+ function countRemaining(workUnits, rows, justClaimed, claimedStatus) {
1328
+ const byUnit = indexDispatchRows(rows);
1329
+ return workUnits.filter((u) => {
1330
+ if (u.journalBaseId === justClaimed)
1331
+ return claimedStatus !== "completed" && claimedStatus !== "failed";
1332
+ // Unresolvable units are never reportable (the engine's expression_error), so
1333
+ // they never count as outstanding.
1334
+ if (!u.resolved.ok)
1335
+ return false;
1336
+ // Best terminal attempt (base + `~r<n>` retries), the shared reuse (finding C).
1337
+ const row = selectUnitAttemptRow(u, byUnit);
1338
+ return !(row && (row.status === "completed" || row.status === "failed"));
1339
+ }).length;
1340
+ }
1341
+ function reportResult(runId, stepId, written, gateLoop, runStatus, stepOutcome, message, recorded = "written") {
1342
+ return {
1343
+ ok: true,
1344
+ runId,
1345
+ stepId,
1346
+ unitId: written.unitId,
1347
+ status: written.status,
1348
+ gateLoop,
1349
+ recorded,
1350
+ remainingUnits: 0,
1351
+ stepOutcome,
1352
+ runStatus,
1353
+ message,
1354
+ };
1355
+ }