akm-cli 0.9.17-alpha.3 → 0.9.17-alpha.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (346) hide show
  1. package/CHANGELOG.md +760 -0
  2. package/dist/akm +94 -196
  3. package/dist/cli/shared.js +6 -2
  4. package/dist/cli.js +22 -9
  5. package/dist/commands/agent/agent-dispatch.js +1 -1
  6. package/dist/commands/command/command-execution.js +24 -62
  7. package/dist/commands/feedback-cli.js +0 -1
  8. package/dist/commands/health/accept-rate.js +2 -2
  9. package/dist/commands/health/checks.js +30 -75
  10. package/dist/commands/health/config-skew.js +38 -0
  11. package/dist/commands/health/egress.js +54 -0
  12. package/dist/commands/health/html-report.js +0 -38
  13. package/dist/commands/health/improve-metrics.js +123 -562
  14. package/dist/commands/health/plugin-staleness.js +53 -3
  15. package/dist/commands/health/renderers.js +12 -4
  16. package/dist/commands/health/report-view-model.js +11 -106
  17. package/dist/commands/health/types-improve.js +4 -19
  18. package/dist/commands/health/windows.js +64 -73
  19. package/dist/commands/health.js +122 -143
  20. package/dist/commands/improve/consolidate/chunking.js +25 -100
  21. package/dist/commands/improve/consolidate/sanitize.js +54 -149
  22. package/dist/commands/improve/consolidate.js +538 -1075
  23. package/dist/commands/improve/content-hash.js +16 -24
  24. package/dist/commands/improve/distill/content-repair.js +18 -100
  25. package/dist/commands/improve/distill-guards.js +20 -81
  26. package/dist/commands/improve/distill-promotion-policy.js +23 -243
  27. package/dist/commands/improve/distill.js +608 -1075
  28. package/dist/commands/improve/eligibility.js +126 -400
  29. package/dist/commands/improve/execution.js +3 -5
  30. package/dist/commands/improve/extract.js +487 -1046
  31. package/dist/commands/improve/feedback-valence.js +0 -25
  32. package/dist/commands/improve/improve-cli.js +29 -166
  33. package/dist/commands/improve/improve-result-file.js +10 -66
  34. package/dist/commands/improve/improve-strategies.js +12 -7
  35. package/dist/commands/improve/improve-usage-report.js +18 -64
  36. package/dist/commands/improve/improve.js +443 -1063
  37. package/dist/commands/improve/ledger.js +114 -0
  38. package/dist/commands/improve/locks.js +2 -8
  39. package/dist/commands/improve/loop-stages.js +459 -1172
  40. package/dist/commands/improve/memory/derived-ref.js +12 -77
  41. package/dist/commands/improve/memory/memory-belief.js +14 -118
  42. package/dist/commands/improve/memory/memory-improve.js +4 -3
  43. package/dist/commands/improve/outcome-loop.js +28 -156
  44. package/dist/commands/improve/planner.js +5 -10
  45. package/dist/commands/improve/preparation.js +851 -2339
  46. package/dist/commands/improve/proactive-maintenance.js +34 -101
  47. package/dist/commands/improve/reflect-noise.js +104 -280
  48. package/dist/commands/improve/reflect.js +621 -1367
  49. package/dist/commands/improve/salience.js +46 -232
  50. package/dist/commands/improve/session-asset.js +19 -100
  51. package/dist/commands/improve/stage.js +323 -0
  52. package/dist/commands/lint/base-linter.js +19 -5
  53. package/dist/commands/proposal/drain.js +251 -644
  54. package/dist/commands/proposal/proposal-cli.js +3 -18
  55. package/dist/commands/proposal/proposal-types.js +20 -41
  56. package/dist/commands/proposal/proposal.js +1 -2
  57. package/dist/commands/proposal/propose.js +134 -160
  58. package/dist/commands/proposal/repository.js +502 -1487
  59. package/dist/commands/proposal/validators/proposal-quality-validators.js +71 -174
  60. package/dist/commands/proposal/validators/proposal-validators.js +1 -1
  61. package/dist/commands/proposal/validators/proposals.js +13 -89
  62. package/dist/commands/read/curate.js +63 -413
  63. package/dist/commands/read/search-cli.js +16 -33
  64. package/dist/commands/read/search.js +17 -23
  65. package/dist/commands/read/show.js +2 -13
  66. package/dist/commands/sources/bundle-cli.js +25 -2
  67. package/dist/commands/sources/bundle-config-ops.js +4 -0
  68. package/dist/commands/sources/dangerous-env-audit.js +1 -2
  69. package/dist/commands/sources/info.js +2 -11
  70. package/dist/commands/sources/installed-stashes.js +197 -746
  71. package/dist/commands/sources/schema-repair.js +98 -129
  72. package/dist/commands/sources/source-add.js +62 -12
  73. package/dist/commands/sources/source-manage.js +9 -2
  74. package/dist/commands/sources/stash-cli.js +1 -1
  75. package/dist/commands/tasks/explain.js +10 -13
  76. package/dist/commands/tasks/tasks-cli.js +9 -8
  77. package/dist/commands/tasks/tasks.js +326 -930
  78. package/dist/commands/tasks/validate.js +42 -21
  79. package/dist/commands/workflow/plan.js +22 -29
  80. package/dist/commands/workflow-cli.js +4 -4
  81. package/dist/core/adapter/adapters/akm-adapter.js +0 -1
  82. package/dist/core/adapter/adapters/akm-lint.js +2 -3
  83. package/dist/core/adapter/adapters/akm-metadata.js +11 -12
  84. package/dist/core/adapter/adapters/akm-workflow-adapter.js +1 -1
  85. package/dist/core/adapter/execution-source.js +17 -29
  86. package/dist/core/asset/asset-placement.js +4 -13
  87. package/dist/core/asset/resolve-ref.js +1 -1
  88. package/dist/core/bundle-id.js +42 -5
  89. package/dist/core/bundle-rename.js +291 -0
  90. package/dist/core/config/config-io.js +1 -2
  91. package/dist/core/config/config-schema.js +1 -33
  92. package/dist/core/config/config-walker.js +1 -1
  93. package/dist/core/config/config.js +163 -68
  94. package/dist/core/config/legacy-source-shape-shim.js +38 -9
  95. package/dist/core/config/schema/embedding.js +20 -5
  96. package/dist/core/config/schema/engines.js +5 -0
  97. package/dist/core/config/schema/execution.js +1 -1
  98. package/dist/core/config/schema/experimental.js +1 -1
  99. package/dist/core/config/schema/improve-processes.js +21 -95
  100. package/dist/core/config/schema/improve.js +4 -42
  101. package/dist/core/config/schema/scheduler.js +12 -12
  102. package/dist/core/config/schema/search.js +6 -22
  103. package/dist/core/env-secret-ref.js +0 -1
  104. package/dist/core/errors.js +8 -9
  105. package/dist/core/file-lock.js +76 -173
  106. package/dist/core/logs-db.js +2 -2
  107. package/dist/core/paths.js +0 -27
  108. package/dist/core/redaction.js +109 -2
  109. package/dist/core/run-lock.js +2 -5
  110. package/dist/core/spawn-env.js +1 -1
  111. package/dist/core/state/migrations.js +108 -61
  112. package/dist/core/state-db-scope.js +2 -4
  113. package/dist/core/state-db.js +126 -692
  114. package/dist/core/type-presentation.js +1 -9
  115. package/dist/core/write-source.js +293 -1012
  116. package/dist/execution/input-contract.js +1 -1
  117. package/dist/execution/resolved-request.js +135 -689
  118. package/dist/execution/source.js +63 -257
  119. package/dist/execution/target-ref.js +1 -1
  120. package/dist/indexer/bundle-identity-guard.js +2 -2
  121. package/dist/indexer/db/graph-db.js +106 -46
  122. package/dist/indexer/ensure-index.js +44 -85
  123. package/dist/indexer/graph/graph-extraction.js +340 -562
  124. package/dist/indexer/graph/graph-related.js +130 -0
  125. package/dist/indexer/index-rebuild-lock.js +3 -11
  126. package/dist/indexer/index-writer-lock.js +8 -17
  127. package/dist/indexer/index-written-assets.js +139 -151
  128. package/dist/indexer/indexer.js +524 -846
  129. package/dist/indexer/materialize-embeddings.js +60 -397
  130. package/dist/indexer/passes/memory-inference.js +81 -90
  131. package/dist/indexer/passes/metadata.js +132 -200
  132. package/dist/indexer/read-preflight.js +0 -7
  133. package/dist/indexer/scan/doc-to-entry.js +1 -3
  134. package/dist/indexer/scan/drain-dir.js +1 -1
  135. package/dist/indexer/search/db-search.js +181 -590
  136. package/dist/indexer/search/fts-query.js +30 -41
  137. package/dist/indexer/search/ranking.js +28 -154
  138. package/dist/indexer/search/search-attribution.js +12 -32
  139. package/dist/indexer/search/search-fields.js +11 -15
  140. package/dist/indexer/search/search-hit-enrichers.js +54 -85
  141. package/dist/indexer/search/search-source.js +1 -4
  142. package/dist/indexer/usage/usage-events.js +2 -7
  143. package/dist/integrations/agent/engine-fallback.js +23 -40
  144. package/dist/integrations/agent/engine-resolution.js +93 -183
  145. package/dist/integrations/agent/execution.js +507 -0
  146. package/dist/integrations/agent/model-map.js +28 -156
  147. package/dist/integrations/agent/request-lowering.js +66 -141
  148. package/dist/integrations/agent/runner-dispatch.js +143 -321
  149. package/dist/integrations/agent/runner.js +54 -14
  150. package/dist/integrations/lockfile.js +53 -101
  151. package/dist/llm/embedders/deterministic.js +2 -3
  152. package/dist/llm/embedders/profile.js +71 -0
  153. package/dist/llm/embedders/remote.js +10 -15
  154. package/dist/llm/graph-extract.js +3 -12
  155. package/dist/llm/index-passes.js +3 -5
  156. package/dist/llm/memory-infer.js +1 -2
  157. package/dist/llm/metadata-enhance.js +1 -2
  158. package/dist/llm/structured-call.js +5 -24
  159. package/dist/output/generic-render.js +23 -11
  160. package/dist/output/html-render.js +13 -10
  161. package/dist/output/render-registry.js +3 -32
  162. package/dist/output/shapes/helpers.js +2 -34
  163. package/dist/output/shapes/passthrough.js +1 -9
  164. package/dist/{indexer/search/ranking-types.js → output/text/bundle-rename.js} +4 -1
  165. package/dist/output/text/command-format.js +60 -23
  166. package/dist/output/text/helpers.js +1 -1
  167. package/dist/output/text/migrate.js +5 -14
  168. package/dist/output/text/proposal-format.js +1 -2
  169. package/dist/output/text/workflow-format.js +0 -32
  170. package/dist/output/text.js +2 -0
  171. package/dist/registry/factory.js +4 -19
  172. package/dist/registry/network.js +66 -220
  173. package/dist/registry/providers/index.js +0 -2
  174. package/dist/registry/providers/skills-sh.js +3 -14
  175. package/dist/registry/providers/static-index.js +24 -26
  176. package/dist/registry/resolve.js +55 -131
  177. package/dist/scripts/akm-migrate-node.js +43940 -93320
  178. package/dist/scripts/akm-migrate.js +43700 -93078
  179. package/dist/setup/registry-stash-loader.js +4 -13
  180. package/dist/setup/semantic-assets.js +3 -44
  181. package/dist/setup/setup.js +1 -1
  182. package/dist/setup/steps/tasks.js +25 -15
  183. package/dist/sources/provider-factory.js +17 -18
  184. package/dist/sources/providers/filesystem.js +2 -3
  185. package/dist/sources/providers/git-install.js +7 -1
  186. package/dist/sources/providers/git-provider.js +0 -3
  187. package/dist/sources/providers/git-stash.js +0 -17
  188. package/dist/sources/providers/npm.js +2 -4
  189. package/dist/sources/providers/provider-utils.js +5 -10
  190. package/dist/sources/providers/website.js +0 -2
  191. package/dist/sources/snapshot-fetchers/website-ingest.js +1 -1
  192. package/dist/sources/website-url.js +2 -2
  193. package/dist/storage/database.js +9 -35
  194. package/dist/storage/repositories/improve-ledger-repository.js +168 -0
  195. package/dist/storage/repositories/index-connection.js +34 -70
  196. package/dist/storage/repositories/index-entries-repository.js +69 -111
  197. package/dist/storage/repositories/index-entry-mapper.js +1 -2
  198. package/dist/storage/repositories/index-entry-schema.js +83 -269
  199. package/dist/storage/repositories/index-fts-repository.js +86 -256
  200. package/dist/storage/repositories/index-llm-cache-repository.js +17 -0
  201. package/dist/storage/repositories/index-meta-repository.js +6 -4
  202. package/dist/storage/repositories/index-schema.js +192 -220
  203. package/dist/storage/repositories/index-utility-repository.js +8 -29
  204. package/dist/storage/repositories/index-vec-repository.js +133 -414
  205. package/dist/storage/repositories/outcome-repository.js +2 -1
  206. package/dist/storage/repositories/proposals-repository.js +35 -0
  207. package/dist/storage/repositories/registry-index-cache-repository.js +100 -0
  208. package/dist/storage/repositories/task-history-repository.js +26 -4
  209. package/dist/storage/repositories/workflow-runs-repository.js +53 -244
  210. package/dist/storage/sqlite-migrations.js +136 -0
  211. package/dist/storage/sqlite-pragmas.js +11 -9
  212. package/dist/storage/sqlite-transaction.js +170 -0
  213. package/dist/storage/state-db-integrity.js +34 -27
  214. package/dist/tasks/activation-config.js +134 -62
  215. package/dist/tasks/backends/cron.js +129 -277
  216. package/dist/tasks/backends/exec-utils.js +2 -5
  217. package/dist/tasks/backends/launchd.js +125 -745
  218. package/dist/tasks/backends/schtasks.js +101 -620
  219. package/dist/tasks/prepare/prepare-support.js +5 -15
  220. package/dist/tasks/prepare/prepare.js +0 -2
  221. package/dist/tasks/resolve-akm-bin.js +20 -79
  222. package/dist/tasks/run/attempt-lifecycle.js +0 -1
  223. package/dist/tasks/scheduler-binding.js +18 -238
  224. package/dist/tasks/scheduler-invocation.js +52 -52
  225. package/dist/tasks/scheduler-lock.js +53 -0
  226. package/dist/tasks/scheduler-sync.js +361 -751
  227. package/dist/tasks/source/parse-task-source.js +160 -10
  228. package/dist/tasks/source/task-source-v3-frozen.js +3 -4
  229. package/dist/tasks/source/task-to-v4.js +2 -2
  230. package/dist/workflows/authoring/authoring.js +3 -12
  231. package/dist/workflows/compile.js +211 -0
  232. package/dist/workflows/concurrency-policy.js +13 -74
  233. package/dist/workflows/exec/child-invocation.js +3 -17
  234. package/dist/workflows/exec/child-workflow.js +32 -141
  235. package/dist/workflows/exec/dispatch-redaction.js +13 -53
  236. package/dist/workflows/exec/environment.js +98 -0
  237. package/dist/workflows/exec/exec-unit.js +33 -140
  238. package/dist/workflows/exec/frozen-judge.js +7 -59
  239. package/dist/workflows/exec/native-executor.js +82 -341
  240. package/dist/workflows/exec/param-secrets.js +29 -47
  241. package/dist/workflows/exec/run-workflow.js +154 -387
  242. package/dist/workflows/exec/scheduler.js +9 -36
  243. package/dist/workflows/exec/step-work.js +127 -430
  244. package/dist/workflows/exec/unit-dispatch.js +11 -63
  245. package/dist/workflows/exec/unit-writer.js +8 -52
  246. package/dist/workflows/exec/worktree.js +39 -273
  247. package/dist/workflows/freeze/child-output-references.js +4 -15
  248. package/dist/workflows/freeze/environment.js +99 -92
  249. package/dist/workflows/freeze/freeze.js +172 -0
  250. package/dist/workflows/freeze/step-values.js +19 -21
  251. package/dist/workflows/freeze/targets/child-workflow.js +23 -92
  252. package/dist/workflows/freeze/targets/command.js +10 -33
  253. package/dist/workflows/freeze/targets/script.js +5 -12
  254. package/dist/workflows/freeze/targets/shell.js +3 -6
  255. package/dist/workflows/freeze/targets/task.js +25 -80
  256. package/dist/workflows/freeze/task-bindings.js +20 -67
  257. package/dist/workflows/{source-ir/github-yaml.js → github-yaml.js} +88 -206
  258. package/dist/workflows/ir/params.js +6 -51
  259. package/dist/workflows/ir/plan-hash.js +2 -34
  260. package/dist/workflows/parser.js +140 -43
  261. package/dist/{commands/improve/consolidate/types.js → workflows/plan.js} +2 -1
  262. package/dist/workflows/renderer.js +36 -69
  263. package/dist/workflows/resource-limits.js +12 -120
  264. package/dist/workflows/runtime/agent-identity.js +8 -40
  265. package/dist/workflows/runtime/run-outputs.js +3 -6
  266. package/dist/workflows/runtime/run-plan.js +316 -0
  267. package/dist/workflows/runtime/runs.js +48 -200
  268. package/dist/workflows/runtime/workflow-asset-loader.js +24 -57
  269. package/dist/workflows/{source-ir/semantics.js → source-semantics.js} +16 -20
  270. package/dist/workflows/validate-summary.js +2 -7
  271. package/docs/integration/bundling-akm.md +49 -42
  272. package/docs/migration/README.md +1 -0
  273. package/docs/migration/release-notes/0.9.17.md +41 -0
  274. package/docs/migration/v0.9.1-to-v0.9.2.md +19 -7
  275. package/docs/reference/cli.md +182 -125
  276. package/docs/reference/configuration.md +49 -56
  277. package/docs/reference/data-and-telemetry.md +19 -20
  278. package/docs/reference/tasks.md +86 -38
  279. package/docs/reference/workflow-schema.md +14 -18
  280. package/docs/reference/workflows.md +6 -9
  281. package/package.json +1 -1
  282. package/schemas/akm-config.json +87 -406
  283. package/dist/commands/health/advisories.js +0 -150
  284. package/dist/commands/health/metrics.js +0 -329
  285. package/dist/commands/health/surfaces.js +0 -102
  286. package/dist/commands/improve/anti-collapse.js +0 -83
  287. package/dist/commands/improve/collapse-detector.js +0 -432
  288. package/dist/commands/improve/consolidate/eligibility.js +0 -48
  289. package/dist/commands/improve/consolidate/merge.js +0 -146
  290. package/dist/commands/improve/distill/promote-memory.js +0 -329
  291. package/dist/commands/improve/distill/quality-gate.js +0 -500
  292. package/dist/commands/improve/memory/memory-contradiction-detect.js +0 -291
  293. package/dist/commands/improve/proposal-envelope.js +0 -31
  294. package/dist/commands/improve/run-context.js +0 -123
  295. package/dist/commands/improve/shared.js +0 -21
  296. package/dist/commands/improve/source-identity.js +0 -28
  297. package/dist/commands/improve/triage.js +0 -96
  298. package/dist/commands/proposal/drain-policies.js +0 -151
  299. package/dist/commands/sources/update-transaction.js +0 -220
  300. package/dist/core/action-contributors.js +0 -28
  301. package/dist/core/config/config-version-shim.js +0 -101
  302. package/dist/core/config/retired-experimental-keys-shim.js +0 -62
  303. package/dist/core/fs-txn.js +0 -405
  304. package/dist/core/lexical-score.js +0 -25
  305. package/dist/core/maintenance-barrier.js +0 -167
  306. package/dist/execution/executable-identity.js +0 -105
  307. package/dist/execution/guarded-source.js +0 -441
  308. package/dist/indexer/graph/graph-boost.js +0 -427
  309. package/dist/indexer/graph/graph-dedup.js +0 -95
  310. package/dist/indexer/search/name-match.js +0 -35
  311. package/dist/indexer/search/ranking-contributors.js +0 -515
  312. package/dist/indexer/walk/project-context.js +0 -192
  313. package/dist/integrations/agent/execution-cascade.js +0 -566
  314. package/dist/integrations/agent/execution-definitions.js +0 -202
  315. package/dist/integrations/agent/execution-lowering.js +0 -841
  316. package/dist/integrations/agent/execution-preparation.js +0 -98
  317. package/dist/integrations/agent/inline-execution.js +0 -74
  318. package/dist/registry/create-provider-registry.js +0 -29
  319. package/dist/registry/pinned-request-helper.js +0 -247
  320. package/dist/registry/pinned-transport.js +0 -717
  321. package/dist/sources/providers/index.js +0 -14
  322. package/dist/storage/engines/sqlite-migrations.js +0 -271
  323. package/dist/storage/repositories/canaries-repository.js +0 -107
  324. package/dist/storage/repositories/embedding-salvage-repository.js +0 -184
  325. package/dist/storage/repositories/registry-cache.js +0 -113
  326. package/dist/tasks/scheduler-sync-preview.js +0 -52
  327. package/dist/workflows/freeze/resolve-steps.js +0 -86
  328. package/dist/workflows/freeze/source-freeze.js +0 -64
  329. package/dist/workflows/ir/compile.js +0 -321
  330. package/dist/workflows/ir/environment-v4.js +0 -330
  331. package/dist/workflows/ir/freeze-v4.js +0 -153
  332. package/dist/workflows/ir/schema-v4.js +0 -745
  333. package/dist/workflows/ir/schema.js +0 -354
  334. package/dist/workflows/program/schema.js +0 -78
  335. package/dist/workflows/runtime/checkin.js +0 -57
  336. package/dist/workflows/runtime/plan-classifier.js +0 -196
  337. package/dist/workflows/runtime/unit-checkin.js +0 -45
  338. package/dist/workflows/runtime/unit-phases.js +0 -20
  339. package/dist/workflows/schema.js +0 -4
  340. package/dist/workflows/source-ir/compile.js +0 -200
  341. package/dist/workflows/source-ir/program.js +0 -50
  342. package/dist/workflows/source-ir/result.js +0 -26
  343. package/dist/workflows/source-ir/schema.js +0 -786
  344. package/dist/workflows/source-ir/triggers.js +0 -79
  345. package/dist/workflows/source-ir/uses.js +0 -40
  346. package/dist/workflows/validator.js +0 -60
@@ -2,28 +2,27 @@
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  /**
5
- * Engine-driven workflow execution — the `akm workflow run`
6
- * start/resume/execute path, and the single execution surface for a run: akm
7
- * walks the frozen plan and dispatches every unit itself. Every step
8
- * advances through `completeWorkflowStep` (never a direct step-row write),
9
- * the plan is read from its frozen `plan_json` row rather than live source,
10
- * a run lease enforces one driving engine invocation at a time, gate loops
11
- * are bounded, and the SDK dispatch registry is drained in a `finally` on
12
- * every exit path so no child process keeps the event loop open.
13
- *
14
- * See docs/architecture/decisions/0011-engine-run-loop-invariants.md for the
15
- * full design history behind each of these invariants.
5
+ * Engine-driven workflow execution — the `akm workflow run` start/resume/
6
+ * execute path. akm walks the frozen plan (read from `plan_json`, never live
7
+ * source) and dispatches every unit itself; every step advances through
8
+ * `completeWorkflowStep`; one O_EXCL lock file per run keeps a second driver
9
+ * off (a dead holder's lock is reclaimed); gate loops are bounded; and the
10
+ * SDK dispatch registry is drained on every exit so the process can exit.
11
+ * See docs/architecture/decisions/0011-engine-run-loop-invariants.md.
16
12
  */
17
- import { randomUUID } from "node:crypto";
13
+ import { createHash } from "node:crypto";
14
+ import fs from "node:fs";
15
+ import path from "node:path";
18
16
  import { TransientError, UsageError } from "../../core/errors.js";
19
- import { withMaintenanceStartBarrierAsync } from "../../core/maintenance-barrier.js";
17
+ import { releaseLock } from "../../core/file-lock.js";
18
+ import { formatLockHolderPid, tryAcquireRunLock } from "../../core/run-lock.js";
20
19
  import { disposeDispatchResources } from "../../integrations/agent/runner-dispatch.js";
20
+ import { resolveStorageLocations } from "../../storage/locations.js";
21
21
  import { withWorkflowRunsConnection, withWorkflowRunsRepo } from "../../storage/repositories/workflow-runs-repository.js";
22
- import { assertRunParamsSatisfyPlan } from "../ir/params.js";
23
22
  import { computePlanHash } from "../ir/plan-hash.js";
24
- import { decodeWorkflowPlanV4 } from "../ir/schema-v4.js";
25
- import { requireExecutableWorkflowPlan } from "../runtime/plan-classifier.js";
26
- import { completeWorkflowStep, getNextWorkflowStep, resumeWorkflowRun } from "../runtime/runs.js";
23
+ import { decodeWorkflowPlan, readRunPlan } from "../runtime/run-plan.js";
24
+ import { abandonWorkflowRun, completeWorkflowStep, getNextWorkflowStep, resumeWorkflowRun, } from "../runtime/runs.js";
25
+ import { loadWorkflowAsset } from "../runtime/workflow-asset-loader.js";
27
26
  import { frozenSummaryJudge } from "./frozen-judge.js";
28
27
  import { mergeLoweringNotices } from "./lowering-notices.js";
29
28
  import { defaultUnitDispatcher, executeStepPlan, } from "./native-executor.js";
@@ -62,7 +61,13 @@ export async function runWorkflowSteps(options) {
62
61
  stepsProcessed += result.stepsProcessed;
63
62
  const notices = mergeLoweringNotices(...executed.map((step) => step.notices));
64
63
  const aggregate = { ...result, executed, stepsProcessed, ...(notices ? { notices } : {}) };
65
- if (result.run.status !== "failed" || result.aborted || result.gateRejection || remainingRetries <= 0) {
64
+ // An attempt that executed nothing (an abandoned, undecodable plan) has no
65
+ // failed step for a retry to re-open.
66
+ if (result.run.status !== "failed" ||
67
+ result.aborted ||
68
+ result.gateRejection ||
69
+ result.executed.length === 0 ||
70
+ remainingRetries <= 0) {
66
71
  return aggregate;
67
72
  }
68
73
  if (remainingSteps !== undefined) {
@@ -86,16 +91,6 @@ async function runWorkflowAttempt(options, liveEvidence) {
86
91
  parameterFlags: options.parameterFlags,
87
92
  newRun: options.newRun,
88
93
  });
89
- // Version/canonical/hash validation precedes every executable mutation,
90
- // including lease acquisition. Historical rows remain inspectable/abandonable.
91
- if (!next.done) {
92
- await withWorkflowRunsRepo((repo) => {
93
- const row = repo.getRunById(next.run.id);
94
- if (!row)
95
- throw new UsageError(`Workflow run ${next.run.id} was not found.`);
96
- requireExecutableWorkflowPlan(row);
97
- });
98
- }
99
94
  // Refuse non-active runs BEFORE any dispatch — completeWorkflowStep would
100
95
  // reject the completion anyway, but only after the units already ran (and
101
96
  // cost money). Mirror its preflight up front.
@@ -103,70 +98,68 @@ async function runWorkflowAttempt(options, liveEvidence) {
103
98
  throw new UsageError(`Workflow run ${next.run.id} is ${next.run.status} and cannot be executed. ` +
104
99
  `Use \`akm workflow resume ${next.run.id}\` to reopen it first.`);
105
100
  }
106
- // Run lease (R2 single-driver enforcement): claim the run BEFORE any
107
- // dispatch — a second `akm workflow run` on a live-leased run refuses up
108
- // front instead of racing the first engine's spine. An expired lease is
109
- // claimable (crash recovery). Released in the finally below; renewed
110
- // between steps inside the loop. A done run takes no lease: nothing will
111
- // dispatch, and the status re-read below must stay a pure no-op.
101
+ // One driver per run: the per-run lock file is taken BEFORE the plan is
102
+ // read or anything dispatches, so a second `akm workflow run` on a run
103
+ // another process is driving refuses up front (exit 75) instead of racing
104
+ // its spine. A done run takes no lock: nothing will dispatch.
112
105
  const runId = next.run.id;
113
- const leaseHolder = randomUUID();
114
- const leased = !next.done;
115
- if (leased) {
116
- await acquireRunLease(runId, leaseHolder);
117
- }
118
- // Lease heartbeat (P1 fix): the lease TTL is renewed BETWEEN steps, but a
119
- // single unit's dispatch can outlive the TTL (the default unit timeout is 10
120
- // minutes, > the 90s lease). An unheartbeated lease would silently expire
121
- // mid-dispatch, letting a second `akm workflow run` claim the run and
122
- // re-dispatch the same units — the two engines clobber each other's journal
123
- // rows and double-run side effects. A timer INSIDE this invocation renews the
124
- // lease while dispatch is in flight; it is cleared in the `finally`, so it
125
- // dies with the process — exactly when the lease SHOULD become claimable
126
- // after TTL. A renewal that fails (the lease was genuinely stolen after an
127
- // expiry, e.g. the process was suspended) aborts dispatch and fails the run
128
- // loudly rather than keep double-driving.
129
- const heartbeat = leased
130
- ? new LeaseHeartbeat(runId, leaseHolder, options.heartbeatScheduler, options.signal)
131
- : undefined;
132
- heartbeat?.start();
106
+ const lock = next.done ? undefined : acquireWorkflowRunLock(runId);
133
107
  try {
134
- // Run-wide state.db connection scope: `executeStepPlan` already opens one
135
- // per STEP, so widening it to the whole drive loop additionally folds the
136
- // spine writes (`completeWorkflowStep`), the per-step lease renewals, the
137
- // journal reads, and `finalizeExecutedStep`'s gate-row journaling onto that
138
- // one handle — `openStateDatabase` costs a maintenance-activity lockfile
139
- // plus a read-only ledger preflight on EVERY call. Nesting is an idempotent
140
- // join (`core/state-db-scope.ts`): the inner per-step scope reuses this
141
- // handle and does not close it, and this scope's own `finally` closes on
142
- // every exit path (return, throw, abort), after which escaped async work
143
- // transparently falls back to opening its own connection.
144
- const result = await withWorkflowRunsConnection(() => driveRun(options, next, leaseHolder, heartbeat, liveEvidence));
145
- // Creation-time notices reach the caller only here: the run row has no
146
- // warnings column, and a later invocation of the same run must stay silent
147
- // about a decision it did not make. `driveRun` never sets `warnings` or
148
- // `resumed` — both are properties of THIS resolution of `target`, not of
149
- // the run row (#919).
108
+ let plan;
109
+ const warnings = [...(next.startWarnings ?? [])];
110
+ if (!next.done) {
111
+ const row = await withWorkflowRunsRepo((repo) => repo.getRunById(runId));
112
+ if (!row)
113
+ throw new UsageError(`Workflow run ${runId} was not found.`);
114
+ const read = readRunPlan(row);
115
+ if (!read.ok) {
116
+ // A newer akm's run is left untouched for that akm.
117
+ if (read.newer)
118
+ throw new UsageError(read.problem);
119
+ // Otherwise a frozen plan this akm cannot decode is a status change,
120
+ // not an exception: the run is abandoned and the message names how to
121
+ // start afresh.
122
+ const abandoned = await abandonWorkflowRun(runId);
123
+ const message = `${read.problem} The run was abandoned; start a new run with 'akm workflow run ${row.workflow_ref}'.`;
124
+ return {
125
+ run: abandoned.run,
126
+ executed: [],
127
+ stepsProcessed: 0,
128
+ warnings: [...warnings, message],
129
+ ...(next.resumed ? { resumed: true } : {}),
130
+ };
131
+ }
132
+ plan = read.plan;
133
+ if (!next.autoStarted) {
134
+ const drift = await workflowSourceDriftWarning(runId, row.workflow_ref, plan);
135
+ if (drift)
136
+ warnings.push(drift);
137
+ }
138
+ }
139
+ // One state.db connection scope for the whole drive loop (the per-step
140
+ // scopes nest into it); each `openStateDatabase` is not free.
141
+ const result = await withWorkflowRunsConnection(() => driveRun(options, next, plan, liveEvidence));
142
+ // Creation/resume-time notices reach the caller only here: the run row
143
+ // has no warnings column, and a later invocation of the same run must
144
+ // stay silent about a decision it did not make. `driveRun` never sets
145
+ // `warnings` or `resumed` — both are properties of THIS resolution of
146
+ // `target`, not of the run row (#919).
150
147
  return {
151
148
  ...result,
152
149
  ...(next.resumed ? { resumed: true } : {}),
153
- ...(next.startWarnings?.length ? { warnings: next.startWarnings } : {}),
150
+ ...(warnings.length > 0 ? { warnings } : {}),
154
151
  };
155
152
  }
156
153
  finally {
157
- heartbeat?.stop();
158
154
  try {
159
- if (leased) {
160
- await withWorkflowRunsRepo((repo) => {
161
- repo.releaseEngineLease(runId, leaseHolder);
162
- });
163
- }
155
+ if (lock)
156
+ releaseLock(lock);
164
157
  }
165
158
  finally {
166
159
  // Process-lifecycle drain (owner finding 4): release any cached SDK server
167
160
  // child processes so a one-shot CLI invocation exits cleanly instead of
168
- // hanging on the leaked handle. Runs even if lease release itself fails;
169
- // a teardown-time repository error must not skip dispatch cleanup.
161
+ // hanging on the leaked handle. Runs even if the lock release itself
162
+ // fails; a teardown-time error must not skip dispatch cleanup.
170
163
  try {
171
164
  await (options.disposeDispatchResources ?? disposeDispatchResources)();
172
165
  }
@@ -176,150 +169,54 @@ async function runWorkflowAttempt(options, liveEvidence) {
176
169
  }
177
170
  }
178
171
  }
179
- /** Lease lifetime: long enough to survive slow steps between renewals, short
180
- * enough that a crashed engine frees the run quickly. Renewed per step. */
181
- const RUN_LEASE_TTL_MS = 90_000;
182
- function leaseExpiry() {
183
- return new Date(Date.now() + RUN_LEASE_TTL_MS).toISOString();
184
- }
185
- /**
186
- * Atomically claim the run lease or refuse with a TransientError naming the
187
- * current holder + expiry (#948 addendum — moved off UsageError, exit 75).
188
- * The single-UPDATE claim in the repository is the arbiter — two racing
189
- * invocations cannot both win.
190
- */
191
- async function acquireRunLease(runId, holder) {
192
- await withMaintenanceStartBarrierAsync(() => withWorkflowRunsRepo((repo) => {
193
- if (repo.acquireEngineLease(runId, holder, leaseExpiry(), new Date().toISOString()))
194
- return;
195
- const row = repo.getRunById(runId);
196
- throw new TransientError(`Workflow run ${runId} is already being driven by engine ${row?.engine_lease_holder ?? "(unknown)"} ` +
197
- `(run lease expires ${row?.engine_lease_until ?? "(unknown)"}). A second \`akm workflow run\` would race it — ` +
198
- `wait for that invocation to finish or for the lease to expire.`, "RUN_LEASE_HELD");
199
- }));
172
+ /** The per-run lock file: `<data dir>/workflow-run-locks/<run id>.lock`, next to `state.db`. */
173
+ export function workflowRunLockPath(runId) {
174
+ return path.join(path.dirname(resolveStorageLocations().stateDb), "workflow-run-locks", `${runId}.lock`);
200
175
  }
201
176
  /**
202
- * Renew the lease between steps. Losing the lease mid-run (it expired during
203
- * a long step and another engine claimed it) is a hard stop: the new owner
204
- * drives the spine now, and continuing would race it.
177
+ * Take the run's O_EXCL lock file, or refuse with `RUN_LEASE_HELD` (exit 75)
178
+ * naming the live holder. A lock whose holder pid is dead is reclaimed by
179
+ * `tryAcquireRunLock` before this refuses, so a crashed engine never wedges
180
+ * a run; nothing here expires by age.
205
181
  */
206
- async function renewRunLease(runId, holder) {
207
- await withWorkflowRunsRepo((repo) => {
208
- if (repo.renewEngineLease(runId, holder, leaseExpiry()))
209
- return;
210
- const row = repo.getRunById(runId);
211
- throw new UsageError(`Workflow run ${runId} lost its run lease (now held by ${row?.engine_lease_holder ?? "(nobody)"}). ` +
212
- `Another engine invocation claimed the run after this one's lease expired — stopping to avoid racing it.`);
213
- });
214
- }
215
- /** Renew mid-dispatch this often. Well under the TTL so a slow/skipped tick
216
- * still leaves ample margin before the lease would expire. */
217
- const HEARTBEAT_INTERVAL_MS = RUN_LEASE_TTL_MS / 3;
218
- /** Real timer: an unref'd interval so a live heartbeat never keeps the process alive. */
219
- function defaultHeartbeatScheduler(tick) {
220
- const id = setInterval(() => void tick(), HEARTBEAT_INTERVAL_MS);
221
- id.unref?.();
222
- return () => clearInterval(id);
182
+ function acquireWorkflowRunLock(runId) {
183
+ const result = tryAcquireRunLock(workflowRunLockPath(runId), { label: `workflow run ${runId}` });
184
+ if (result.state === "acquired")
185
+ return result.ownership;
186
+ const since = result.holder.startedAt ? `, since ${result.holder.startedAt}` : "";
187
+ throw new TransientError(`Workflow run ${runId} is already being driven by another akm process ` +
188
+ `(pid ${formatLockHolderPid(result.holder)}${since}). A second \`akm workflow run\` would race it — ` +
189
+ "wait for that invocation to finish.", "RUN_LEASE_HELD");
223
190
  }
224
191
  /**
225
- * Keeps the run lease alive while a step dispatches (P1 fix — the between-step
226
- * renewal cannot cover a unit that runs longer than the TTL). A timer inside
227
- * the engine invocation renews the lease through the holder-guarded
228
- * {@link renewEngineLease}; the heartbeat owns an {@link AbortController}
229
- * (chained onto the caller's signal) that becomes the effective DISPATCH
230
- * signal, so a lost lease aborts in-flight dispatch PROMPTLY. After the abort,
231
- * {@link assertAlive} throws a loud UsageError, so the engine stops instead of
232
- * continuing to drive a run another engine now owns. No background daemon: the
233
- * timer is cleared in the caller's `finally` and dies with the process.
192
+ * Resume re-reads the authored workflow source and says so, once, when it no
193
+ * longer matches the bytes the plan was frozen from. The run keeps executing
194
+ * the frozen plan either way — that is the whole safety the freeze buys — and
195
+ * the warning tells the operator a fresh run is what picks up the edit.
234
196
  */
235
- class LeaseHeartbeat {
236
- runId;
237
- holder;
238
- controller = new AbortController();
239
- detachUpstream;
240
- schedule;
241
- cancel;
242
- renewing = false;
243
- /** Set once a renewal failed — the lease was stolen after a genuine expiry. */
244
- lost = false;
245
- /** The holder that stole the lease, captured for the loud error. */
246
- stolenBy = null;
247
- constructor(runId, holder, scheduler, upstream) {
248
- this.runId = runId;
249
- this.holder = holder;
250
- this.schedule = scheduler ?? defaultHeartbeatScheduler;
251
- // A caller abort (Ctrl-C, budget) must abort dispatch too; chain it into
252
- // the effective signal. Distinct from a lost lease: a caller abort does
253
- // NOT set `lost`, so `assertAlive` stays quiet and the existing graceful
254
- // break on `options.signal` handles it.
255
- if (upstream) {
256
- if (upstream.aborted) {
257
- this.controller.abort();
258
- }
259
- else {
260
- const onAbort = () => this.controller.abort();
261
- upstream.addEventListener("abort", onAbort, { once: true });
262
- this.detachUpstream = () => upstream.removeEventListener("abort", onAbort);
263
- }
264
- }
265
- }
266
- /** The effective dispatch signal: aborts on a lost lease OR a caller abort. */
267
- get signal() {
268
- return this.controller.signal;
269
- }
270
- start() {
271
- this.cancel ??= this.schedule(() => this.tick());
272
- }
273
- /** One renewal attempt. A failure marks the lease lost and aborts dispatch. */
274
- async tick() {
275
- if (this.lost || this.renewing || this.controller.signal.aborted)
276
- return;
277
- this.renewing = true;
278
- try {
279
- const renewed = await withWorkflowRunsRepo((repo) => repo.renewEngineLease(this.runId, this.holder, leaseExpiry()));
280
- if (!renewed) {
281
- this.stolenBy = await withWorkflowRunsRepo((repo) => repo.getRunById(this.runId)?.engine_lease_holder ?? null);
282
- this.loseLease();
283
- }
284
- }
285
- catch {
286
- // A renewal that THREW (a DB error / connection failure, or the follow-up
287
- // getRunById itself throwing) is treated exactly like a stolen lease: we
288
- // can no longer PROVE we still hold it, so abort in-flight dispatch and let
289
- // `assertAlive` stop the engine loudly. Swallowing the error here is what
290
- // keeps the fire-and-forget `void tick()` in the default scheduler from
291
- // leaking an unhandled promise rejection.
292
- this.loseLease();
293
- }
294
- finally {
295
- this.renewing = false;
296
- }
197
+ async function workflowSourceDriftWarning(runId, workflowRef, plan) {
198
+ if (!plan.sourceHash)
199
+ return undefined;
200
+ let sourcePath;
201
+ try {
202
+ sourcePath = (await loadWorkflowAsset(workflowRef)).path;
297
203
  }
298
- /** Mark the lease lost, stop the timer, and abort in-flight dispatch — the new
299
- * owner drives the spine now (or, on a renewal error, we can no longer prove we
300
- * do). Idempotent: repeated calls are harmless. */
301
- loseLease() {
302
- this.lost = true;
303
- this.stop();
304
- this.controller.abort();
204
+ catch (error) {
205
+ const detail = error instanceof Error ? error.message : String(error);
206
+ return (`Workflow run ${runId}: the authored source ${workflowRef} could not be re-read (${detail}); ` +
207
+ "continuing with the frozen plan.");
305
208
  }
306
- /**
307
- * Throw loudly if a heartbeat renewal failed. Called at dispatch boundaries:
308
- * a lost lease means another engine claimed the run mid-step, so continuing
309
- * (completing steps, dispatching more units) would double-drive it.
310
- */
311
- assertAlive() {
312
- if (!this.lost)
313
- return;
314
- throw new UsageError(`Workflow run ${this.runId} lost its run lease mid-dispatch (heartbeat renewal failed; lease now held by ` +
315
- `${this.stolenBy ?? "(nobody)"}). Another engine invocation claimed the run after this one's lease expired — ` +
316
- `aborting to avoid double-driving it.`);
209
+ let current;
210
+ try {
211
+ current = createHash("sha256").update(fs.readFileSync(sourcePath)).digest("hex");
317
212
  }
318
- stop() {
319
- this.cancel?.();
320
- this.cancel = undefined;
321
- this.detachUpstream?.();
213
+ catch {
214
+ return `Workflow run ${runId}: ${sourcePath} is no longer readable; continuing with the frozen plan.`;
322
215
  }
216
+ if (current === plan.sourceHash)
217
+ return undefined;
218
+ return (`Workflow run ${runId}: ${workflowRef} (${sourcePath}) has changed since this run was frozen; ` +
219
+ `continuing with the frozen plan. Start a new run with 'akm workflow run ${workflowRef} --new' to pick up the edit.`);
323
220
  }
324
221
  /**
325
222
  * A terminal run is a pure no-op: do not load or integrity-check its frozen
@@ -337,31 +234,14 @@ async function completedRunResult(runId) {
337
234
  function workflowSummaryJudge(options, stepPlan, signal, owner) {
338
235
  if (options.summaryJudge !== undefined)
339
236
  return options.summaryJudge;
340
- // The judge dispatches under the REAL run/step identity; the per-loop gate row
341
- // identity is threaded in per call by the completion path that journals it.
342
- // eventSource (gap closed, code review): the judge's dispatch is a "command"
343
- // request like any other exec/agent/sdk unit, so it goes through the same
344
- // provenance thread `executeStepSubgraph` uses below — undefined for every
345
- // non-task caller, byte-identical.
237
+ // The judge dispatches under the real run/step identity and the same event source as units.
346
238
  return frozenSummaryJudge(stepPlan.gate.frozenJudge, signal, options.dispatcher ?? defaultUnitDispatcher, owner, options.eventSource);
347
239
  }
348
240
  /**
349
- * Seed the declared budget ceilings from the journal so they are truly
350
- * per-RUN: a resumed or re-invoked run must not restart a declared `budget`
351
- * at zero. The append-only attempt journal is authoritative: dispatch
352
- * attempts count against `budget.max_units`, and their known tokens count
353
- * against `budget.max_tokens`. Durable result reuse is free.
354
- *
355
- * Gate-evaluation rows (`phase = "gate"`, journaled by the completion-gate
356
- * judge) are EXCLUDED from the seed: the live path never consumes
357
- * DispatchBudget for a judge call, so counting its journal row on resume
358
- * would make an interrupted run hit `max_units` (and the lifetime cap)
359
- * earlier than the identical uninterrupted run — a spurious hard failure
360
- * that `on_error` cannot soften. The seed must reproduce exactly what live
361
- * accounting would have accumulated.
362
- *
363
- * Attempt rows are append-only, so retries cannot collapse or erase prior
364
- * dispatch accounting.
241
+ * Seed the run's budget from the append-only attempt journal so a resumed run
242
+ * does not restart it at zero: dispatch attempts count against `max_units`,
243
+ * their tokens against `max_tokens`. Gate-evaluation rows are excluded, as the
244
+ * live path never charges a judge call.
365
245
  */
366
246
  async function seedRunAccountingFromJournal(runId) {
367
247
  const accounting = await withWorkflowRunsRepo((repo) => repo.getAttemptAccounting(runId));
@@ -371,22 +251,15 @@ async function seedRunAccountingFromJournal(runId) {
371
251
  };
372
252
  }
373
253
  /**
374
- * The decoded/hash-verified row plan is the sole execution authority. The
375
- * loader seam may assert an expected plan in tests, but can never replace it.
376
- *
377
- * Reviewer #12: the journaled params row must still satisfy the frozen param
378
- * schemas before the engine resolves any unit prompt from it, so
379
- * schema-violating params — post-start corruption — fail loudly BEFORE any
380
- * unit is dispatched (start already validated the params it stored).
254
+ * The row plan is the sole execution authority. The loader seam may assert an
255
+ * expected plan in tests, but can never replace it.
381
256
  */
382
- async function loadAuthoritativeRunPlan(options, next) {
383
- const stored = await loadStoredPlan(next.run.id);
257
+ async function loadAuthoritativeRunPlan(options, next, stored) {
384
258
  if (options.loadPlan) {
385
- const expected = decodeWorkflowPlanV4(await options.loadPlan(next.run.workflowRef));
259
+ const expected = decodeWorkflowPlan(await options.loadPlan(next.run.workflowRef));
386
260
  if (computePlanHash(expected) !== computePlanHash(stored))
387
261
  throw new UsageError(`Injected workflow plan for run ${next.run.id} differs from its frozen plan.`);
388
262
  }
389
- assertRunParamsSatisfyPlan(next.run.id, stored, next.run.params ?? {});
390
263
  return stored;
391
264
  }
392
265
  /**
@@ -395,8 +268,8 @@ async function loadAuthoritativeRunPlan(options, next) {
395
268
  * Returns the re-read spine state so the caller can continue its walk.
396
269
  */
397
270
  async function skipUnselectedRouteTarget(input) {
398
- const { runId, stepId, stepPlan, skipInfo, routeUnselected, executed, leaseHolder } = input;
399
- // Cascade (peer review R1): a skipped step that is ITSELF a router
271
+ const { runId, stepId, stepPlan, skipInfo, routeUnselected, executed } = input;
272
+ // Cascade: a skipped step that is ITSELF a router
400
273
  // never evaluates its route, so none of its declared targets were
401
274
  // selected — mark them all skip-on-reach too (a target another
402
275
  // completed router selects stays protected via routeSelected). Without
@@ -408,27 +281,13 @@ async function skipUnselectedRouteTarget(input) {
408
281
  ? `Skipped by route: step "${skipInfo.router}" was itself skipped, so none of its branch targets run.`
409
282
  : `Skipped by route: step "${skipInfo.router}" selected "${skipInfo.selected}".`;
410
283
  executed.push({ stepId, ok: true, unitCount: 0, failedUnits: 0, summary: notes });
411
- await completeWorkflowStep({ runId, stepId, status: "skipped", notes, leaseHolder });
284
+ await completeWorkflowStep({ runId, stepId, status: "skipped", notes });
412
285
  return getNextWorkflowStep(runId);
413
286
  }
414
287
  /**
415
- * Crash-resume gate state (Codex P1): SEED the starting gate loop from the
416
- * journal through the SAME shared helpers the first pass used — no fork.
417
- * A run interrupted after a rejected gate was journaled
418
- * (`<step>.gate:l<n>`, complete:false) must resume at loop n+1 with the
419
- * stored corrective feedback threaded into the unit prompts; without this
420
- * the engine restarts at loop 1, reuses the rejected loop-1 rows, overwrites
421
- * `<step>.gate:l1`, and re-judges the stale artifact — breaking journaled
422
- * replay and making the resumed run diverge from the interrupted one. The rows
423
- * are re-read per step (NOT the once-at-start budget seed) so a step reached
424
- * later within THIS same invocation still starts fresh at loop 1.
425
- *
426
- * Only the STEP's rows are read (index-backed on `(run_id, step_id)`): both
427
- * helpers already discard every row carrying a different `step_id`, and gate
428
- * rows are journaled under the step's own id, so the narrow query returns a
429
- * superset of what they read. Re-reading the whole run journal here would
430
- * re-materialize every earlier step's `result_json` — synchronously, blocking
431
- * the event loop the lease heartbeat and abort handling share — once per step.
288
+ * Seed a step's starting gate loop and feedback from its journaled gate rows,
289
+ * so a run interrupted after a rejection resumes at the next loop with the
290
+ * stored feedback instead of re-judging loop 1. Reads only this step's rows.
432
291
  */
433
292
  async function recoverGateLoopState(runId, stepPlan) {
434
293
  // A step with no effective completion criteria never reaches a judge
@@ -443,13 +302,8 @@ async function recoverGateLoopState(runId, stepPlan) {
443
302
  return { startLoop, seededFeedback: recoverGateFeedback(stepJournal, stepId, startLoop) };
444
303
  }
445
304
  /**
446
- * The kinds that FINISHED the step (completed / failed / gate-exhausted) — the
447
- * ONE `maxSteps` consumption for its whole gate loop. An abort and a judge
448
- * outage leave the step unfinished and consume nothing: the next invocation
449
- * still owes the work. A blocked child is the SAME shape as a judge outage
450
- * (P3b §3.4) — a gate is a gate for a child workflow too, so it is likewise
451
- * NOT in this set: `akm workflow resume` is what clears it, never an
452
- * automatic in-step re-dispatch.
305
+ * The kinds that finished the step — its one `maxSteps` consumption. An abort,
306
+ * a judge outage, and a blocked child consume nothing: the work is still owed.
453
307
  */
454
308
  const STEP_FINISHED_KINDS = new Set(["advanced", "failed", "gate-exhausted"]);
455
309
  /**
@@ -459,7 +313,7 @@ const STEP_FINISHED_KINDS = new Set(["advanced", "failed", "gate-exhausted"]);
459
313
  * native executor.
460
314
  */
461
315
  async function executeStepSubgraph(ctx, loop) {
462
- const { options, next, plan, stepPlan, step, evidence, leaseHolder, dispatchSignal } = ctx;
316
+ const { options, next, plan, stepPlan, step, evidence } = ctx;
463
317
  const { gateLoop, gateFeedback, unitsDispatched, tokensUsed } = loop;
464
318
  return !stepPlan.root && stepPlan.route
465
319
  ? {
@@ -471,42 +325,32 @@ async function executeStepSubgraph(ctx, loop) {
471
325
  }
472
326
  : await executeStepPlan(stepPlan, {
473
327
  runId: next.run.id,
474
- leaseHolder,
475
328
  workflowRef: next.run.workflowRef,
476
329
  params: next.run.params ?? {},
477
330
  evidence,
478
331
  unitsDispatched,
479
332
  tokensUsed,
480
- // Budget ceilings ride the FROZEN plan (addendum R2): a mid-run
333
+ // Budget ceilings ride the FROZEN plan: a mid-run
481
334
  // asset edit can never loosen or tighten a run's budget.
482
335
  ...(plan.budget ? { budget: plan.budget } : {}),
483
336
  gateLoop,
484
337
  ...(gateFeedback ? { gateFeedback } : {}),
485
- // F-1 (spec §5.2 point 2): threaded to an exec unit's child env;
338
+ // F-1: threaded to an exec unit's child env;
486
339
  // undefined for every non-task caller (byte-identical, RunWorkflowOptions doc).
487
340
  ...(options.eventSource !== undefined ? { eventSource: options.eventSource } : {}),
488
- // The heartbeat's signal is the effective dispatch signal: a lost
489
- // lease (or a caller abort) aborts in-flight units promptly.
490
- ...(dispatchSignal ? { signal: dispatchSignal } : {}),
341
+ ...(options.signal ? { signal: options.signal } : {}),
491
342
  ...(options.dispatcher ? { dispatcher: options.dispatcher } : {}),
492
343
  maxConcurrency: Math.min(options.maxConcurrency ?? Number.POSITIVE_INFINITY, plan.execution?.maxConcurrency ?? 1),
493
344
  });
494
345
  }
495
346
  /**
496
- * Drive ONE step's bounded gate loop (addendum R2, `gate.max_loops`): loop 1 is
497
- * the normal execution; a gate rejection with attempts left re-executes the
498
- * subgraph with the judge's feedback threaded into unit prompts.
499
- *
500
- * The engine owns only the loop control the shared completion path
501
- * (`finalizeExecutedStep`) maps onto — retry re-executes; advanced moves on;
502
- * failure/judge-failure/exhaustion stops this invocation — and returns that
503
- * decision plus the running budget totals to {@link driveRun}. `ctx.executed`
504
- * is appended in place (one report per iteration); everything else the caller
505
- * must observe travels back through {@link StepGateLoopOutcome}.
347
+ * Drive one step's bounded gate loop: a rejection with loops left re-executes
348
+ * the subgraph with the judge's feedback in the unit prompts. Returns the loop
349
+ * decision and running budget totals; `ctx.executed` is appended in place.
506
350
  */
507
351
  async function runStepGateLoop(ctx, gate, totals) {
508
352
  const { options, next, stepPlan, step, evidence, executed, routeSelected, routeUnselected } = ctx;
509
- const { summaryJudge, leaseHolder, heartbeat } = ctx;
353
+ const { summaryJudge } = ctx;
510
354
  const { startLoop, maxLoops } = gate;
511
355
  let { unitsDispatched, tokensUsed } = totals;
512
356
  let gateFeedback = gate.seededFeedback;
@@ -518,15 +362,7 @@ async function runStepGateLoop(ctx, gate, totals) {
518
362
  tokensUsed,
519
363
  });
520
364
  for (let gateLoop = startLoop; gateLoop <= maxLoops; gateLoop++) {
521
- // A loop re-execution dispatches a fresh round of units — renew the
522
- // lease so a long evaluator-optimizer cycle cannot outlive the TTL.
523
- if (gateLoop > 1)
524
- await renewRunLease(next.run.id, leaseHolder);
525
365
  const result = await executeStepSubgraph(ctx, { gateLoop, gateFeedback, unitsDispatched, tokensUsed });
526
- // If the heartbeat lost the lease WHILE this step dispatched, another
527
- // engine now owns the run — stop loudly BEFORE finalizing the step
528
- // (completeWorkflowStep would race the new owner's spine).
529
- heartbeat?.assertAlive();
530
366
  unitsDispatched = result.unitsDispatched;
531
367
  if (result.tokensUsed !== undefined)
532
368
  tokensUsed = result.tokensUsed;
@@ -540,12 +376,7 @@ async function runStepGateLoop(ctx, gate, totals) {
540
376
  summary: result.summary,
541
377
  ...(result.notices ? { notices: result.notices } : {}),
542
378
  });
543
- // Route evaluation + artifact-judged completion gate + gate-row
544
- // journaling + the bounded-loop rejection contract are the SHARED
545
- // completion path (`finalizeExecutedStep`): every step advances through
546
- // that one sequence, whether its units were just dispatched or rehydrated
547
- // from the journal on resume, so the same frozen plan always promotes the
548
- // same artifact and advances (or rejects) the spine identically.
379
+ // Route, gate, and advance: the shared completion path, live or resumed.
549
380
  let finalize;
550
381
  try {
551
382
  finalize = await finalizeExecutedStep({
@@ -563,20 +394,13 @@ async function runStepGateLoop(ctx, gate, totals) {
563
394
  routeUnselected,
564
395
  summaryJudge,
565
396
  signal: options.signal,
566
- // The judge runs under the DISPATCH signal, so the completion path must
567
- // see it too: an abort delivered there (a lost lease, a caller Ctrl-C)
568
- // is an interruption, not a verifier outage.
569
- ...(ctx.dispatchSignal ? { dispatchSignal: ctx.dispatchSignal } : {}),
570
- leaseHolder,
571
397
  });
572
398
  }
573
399
  catch (error) {
574
- heartbeat?.assertAlive();
575
400
  if (options.signal?.aborted)
576
401
  return outcome({ kind: "aborted" });
577
402
  throw error;
578
403
  }
579
- heartbeat?.assertAlive();
580
404
  if (finalize.kind === "retry") {
581
405
  // Re-execute the subgraph with the judge/validation feedback threaded
582
406
  // into unit prompts — the changed prompt changes each unit's input
@@ -585,13 +409,8 @@ async function runStepGateLoop(ctx, gate, totals) {
585
409
  continue;
586
410
  }
587
411
  if (finalize.kind === "advanced") {
588
- // Hand the rest of this invocation the COMPLETE artifact — but only when
589
- // some LATER step's frozen references can actually read it (set-time
590
- // retention, see `referencedStepIds`). `finalize` has already journaled
591
- // the step (and stamped any route decision onto `result.evidence`), and
592
- // the persisted row may carry a truncation envelope in place of an
593
- // over-cap value — the row bound must not change what the very next step
594
- // reads.
412
+ // Keep the complete artifact for later steps of this invocation, but only
413
+ // when some later reference can read it (`referencedStepIds`).
595
414
  if (ctx.liveEvidenceConsumers.has(step.id))
596
415
  ctx.liveEvidence.set(step.id, result.evidence);
597
416
  // A route-only step's summary IS its decision (finalize surfaces it).
@@ -609,7 +428,7 @@ async function runStepGateLoop(ctx, gate, totals) {
609
428
  return outcome({ kind: "judge-failed", judgeFailure: { stepId: step.id, message: finalize.summary } });
610
429
  }
611
430
  if (finalize.kind === "child-blocked") {
612
- // P3b §3.4: a composed child workflow is blocked. Like judge-failed,
431
+ // a composed child workflow is blocked. Like judge-failed,
613
432
  // NO gate loop was consumed and the step does not count against
614
433
  // maxSteps. `result.childBlocked` (set by reduceStepOutcomes off the
615
434
  // failed unit's live-only childRun field) carries the identity
@@ -648,28 +467,18 @@ async function runStepGateLoop(ctx, gate, totals) {
648
467
  // here would mean those two bounds disagree — a bug, not a run outcome.
649
468
  throw new Error(`Workflow run ${next.run.id} step "${step.id}" left its gate loop with no terminal outcome (loop bounds disagree).`);
650
469
  }
651
- /** The engine loop proper — runs under the lease held by `runWorkflowSteps`. */
652
- async function driveRun(options, initial, leaseHolder, heartbeat,
470
+ /** The engine loop proper — runs under the per-run lock `runWorkflowAttempt` holds. */
471
+ async function driveRun(options, initial,
472
+ /** The run row's decoded frozen plan; undefined only for a done run, which drives nothing. */
473
+ storedPlan,
653
474
  /**
654
- * The COMPLETE in-memory evidence of every step THIS call has completed,
655
- * keyed by step id, preferred over the re-read row when the downstream scope
656
- * is rebuilt below — avoiding a re-parse of a row this same invocation just
657
- * wrote (step artifacts are persisted whole, so the two values agree; this
658
- * is purely an avoided round trip, not a correctness dependency). A LATER
659
- * `akm workflow run` starts with an empty map and reads the rows directly.
660
- *
661
- * Only steps some OTHER step's references NAME are stored (`referencedStepIds`
662
- * — the set-time filter): a step nothing downstream reads has no consumer to
663
- * keep it complete for, so retaining it would buy nothing and cost its bytes
664
- * for the rest of the invocation.
475
+ * In-memory evidence of steps this call completed that a later step reads,
476
+ * preferred over re-parsing their rows (the values agree).
665
477
  */
666
478
  liveEvidence) {
667
479
  let next = initial;
668
- if (initial.done)
480
+ if (initial.done || !storedPlan)
669
481
  return completedRunResult(initial.run.id);
670
- // The effective dispatch signal: the heartbeat's controller (a lost lease or
671
- // a caller abort aborts it) while leased, else the raw caller signal.
672
- const dispatchSignal = heartbeat?.signal ?? options.signal;
673
482
  const executed = [];
674
483
  let gateRejection;
675
484
  let judgeFailure;
@@ -682,7 +491,7 @@ liveEvidence) {
682
491
  // a route-skipped step consumes NOTHING (no work was dispatched for it).
683
492
  let stepsProcessed = 0;
684
493
  let { unitsDispatched, tokensUsed } = await seedRunAccountingFromJournal(next.run.id);
685
- const plan = await loadAuthoritativeRunPlan(options, next);
494
+ const plan = await loadAuthoritativeRunPlan(options, next, storedPlan);
686
495
  // Live-evidence retention is decided at SET time, from the frozen plan alone:
687
496
  // a completed step's complete artifact is held only while some other step's
688
497
  // references can still read it. An exec unit's promoted stdout can be 8 MiB,
@@ -694,31 +503,17 @@ liveEvidence) {
694
503
  // (two routers may share a target).
695
504
  const routeSelected = new Set();
696
505
  const routeUnselected = new Map();
697
- // Resume contract: route decisions are journaled in the route step's
698
- // evidence (`evidence.route.selected`) and must be REPLAYED into the
699
- // bookkeeping before the spine advances — a re-invoked run (crash, Ctrl-C,
700
- // maxSteps, gate rejection after the route completed) would otherwise reach
701
- // the unselected targets with empty in-memory state and execute the wrong
702
- // branch. Decisions stay pure functions of (frozen plan, params, journaled
703
- // results) — the addendum determinism bar. A done run skips the seeding:
704
- // nothing will dispatch, so an unrecoverable prior decision must not
705
- // block the no-op status return below.
506
+ // Replay journaled route decisions before the spine advances, so a
507
+ // re-invoked run skips the unselected branches. A done run skips this.
706
508
  if (!next.done) {
707
509
  seedJournaledRouteDecisions(plan, next, routeSelected, routeUnselected);
708
510
  }
709
511
  while (!next.done && next.step && next.run.status === "active" && stepsProcessed < maxSteps) {
710
- // A LOST lease (the heartbeat's renewal failed mid-step) is a loud stop —
711
- // another engine owns the spine now. A caller abort (options.signal) is a
712
- // graceful break, distinct from a lost lease.
713
- heartbeat?.assertAlive();
512
+ // A caller abort (options.signal) is a graceful break.
714
513
  if (options.signal?.aborted) {
715
514
  aborted = true;
716
515
  break;
717
516
  }
718
- // Renew the run lease between steps (a fresh 90s window per iteration).
719
- // Losing it (expired mid-step + claimed by another engine) throws — the
720
- // new owner drives the spine now.
721
- await renewRunLease(next.run.id, leaseHolder);
722
517
  const step = next.step;
723
518
  const stepPlan = plan.steps.find((s) => s.stepId === step.id);
724
519
  if (!stepPlan) {
@@ -735,26 +530,21 @@ liveEvidence) {
735
530
  skipInfo,
736
531
  routeUnselected,
737
532
  executed,
738
- leaseHolder,
739
533
  });
740
534
  continue;
741
535
  }
742
536
  const evidence = {};
743
537
  for (const s of next.workflow.steps)
744
538
  evidence[s.id] = liveEvidence.get(s.id) ?? s.evidence;
745
- // Bounded gate loop (addendum R2, `gate.max_loops`): loop 1 is the normal
539
+ // Bounded gate loop: loop 1 is the normal
746
540
  // execution; a gate rejection with attempts left re-executes the subgraph
747
541
  // with the judge's feedback threaded into unit prompts. The bound comes
748
542
  // from the shared derivation, which holds an exec step to a single
749
543
  // execution — its argv cannot answer feedback (see effectiveGateMaxLoops).
750
544
  const maxLoops = effectiveGateMaxLoops(stepPlan);
751
545
  const { startLoop, seededFeedback } = await recoverGateLoopState(next.run.id, stepPlan);
752
- // Resume AFTER the FINAL rejection (`startLoop` past the loop bound): the
753
- // gate was already exhausted before the crash, so there is NO fresh loop to
754
- // run — reproduce the documented gateRejection outcome from the stored
755
- // final-loop feedback instead of re-dispatching a spurious extra loop. The
756
- // l1..l<maxLoops> rows stay untouched and the step stays active, exactly as
757
- // when the engine first exhausted the gate.
546
+ // Resumed after the final rejection: reproduce the gate-exhausted outcome
547
+ // from the stored feedback instead of running an extra loop.
758
548
  if (startLoop > maxLoops) {
759
549
  gateRejection = {
760
550
  stepId: step.id,
@@ -770,7 +560,7 @@ liveEvidence) {
770
560
  // verify. No gate loop is consumed and nothing is dispatched.
771
561
  let summaryJudge;
772
562
  try {
773
- summaryJudge = workflowSummaryJudge(options, stepPlan, dispatchSignal, {
563
+ summaryJudge = workflowSummaryJudge(options, stepPlan, options.signal, {
774
564
  runId: next.run.id,
775
565
  stepId: step.id,
776
566
  });
@@ -784,7 +574,6 @@ liveEvidence) {
784
574
  runId: next.run.id,
785
575
  stepId: step.id,
786
576
  cause: `the verification judge could not be resolved from the frozen plan${detail}`,
787
- leaseHolder,
788
577
  });
789
578
  executed.push({ stepId: step.id, ok: false, unitCount: 0, failedUnits: 0, summary: notes });
790
579
  judgeFailure = { stepId: step.id, message: notes };
@@ -803,9 +592,6 @@ liveEvidence) {
803
592
  routeSelected,
804
593
  routeUnselected,
805
594
  summaryJudge,
806
- leaseHolder,
807
- heartbeat,
808
- dispatchSignal,
809
595
  }, { startLoop, maxLoops, seededFeedback }, { unitsDispatched, tokensUsed });
810
596
  unitsDispatched = outcome.unitsDispatched;
811
597
  tokensUsed = outcome.tokensUsed;
@@ -840,22 +626,3 @@ liveEvidence) {
840
626
  ...(aborted ? { aborted: true } : {}),
841
627
  };
842
628
  }
843
- /**
844
- * Load the plan a run executes (frozen-plan contract, migration 006):
845
- *
846
- * - `plan_json` present → parse it and verify `plan_hash` (sha256 of the
847
- * canonical JSON). A mismatch means the journaled plan was tampered with
848
- * or corrupted — fail loudly, never silently recompile. The workflow
849
- * asset file is NEVER touched on this path.
850
- * Missing and non-current plans fail validation and are never rebuilt from a
851
- * mutable source asset.
852
- */
853
- async function loadStoredPlan(runId) {
854
- const row = await withWorkflowRunsRepo((repo) => {
855
- const run = repo.getRunById(runId);
856
- return run;
857
- });
858
- if (!row)
859
- throw new UsageError(`Workflow run ${runId} was not found.`);
860
- return requireExecutableWorkflowPlan(row);
861
- }