akm-cli 0.9.1 → 0.9.2-alpha.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 (350) hide show
  1. package/CHANGELOG.md +103 -28
  2. package/README.md +3 -1
  3. package/SECURITY.md +1 -1
  4. package/STABILITY.md +1 -1
  5. package/dist/akm +2 -2
  6. package/dist/akm-migrate +2 -2
  7. package/dist/assets/hints/cli-hints-full.md +14 -9
  8. package/dist/assets/improve-strategies/proactive-maintenance.json +1 -1
  9. package/dist/assets/improve-strategies/reflect-distill.json +1 -1
  10. package/dist/assets/models.json +35 -0
  11. package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +3 -4
  12. package/dist/assets/stash-skeleton/facts/conventions/organization.md +1 -3
  13. package/dist/assets/tasks/core/extract.yml +6 -5
  14. package/dist/assets/tasks/core/improve.yml +6 -5
  15. package/dist/assets/tasks/core/index-refresh.yml +6 -5
  16. package/dist/assets/tasks/core/sync.yml +6 -5
  17. package/dist/assets/tasks/core/version-check.yml +6 -5
  18. package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +6 -5
  19. package/dist/assets/tasks/improve/akm-improve-catchup.yml +6 -5
  20. package/dist/assets/tasks/improve/akm-improve-consolidate.yml +6 -5
  21. package/dist/assets/tasks/improve/akm-improve-frequent.yml +6 -5
  22. package/dist/assets/tasks/improve/akm-improve-nightly.yml +6 -5
  23. package/dist/cli/confirm.js +2 -2
  24. package/dist/cli/parse-args.js +3 -24
  25. package/dist/cli/retired-commands.js +1 -1
  26. package/dist/cli/shared.js +2 -2
  27. package/dist/cli.js +11 -9
  28. package/dist/commands/agent/agent-dispatch.js +55 -89
  29. package/dist/commands/agent/contribute-cli.js +12 -45
  30. package/dist/commands/command/builtin-action.js +32 -0
  31. package/dist/commands/command/command-cli.js +99 -0
  32. package/dist/commands/command/command-execution.js +308 -0
  33. package/dist/commands/command/execution-source-loader.js +176 -0
  34. package/dist/commands/command/portable-template.js +60 -0
  35. package/dist/commands/config-cli.js +10 -4
  36. package/dist/commands/env/env.js +4 -2
  37. package/dist/commands/feedback-cli.js +1 -1
  38. package/dist/commands/health/checks.js +241 -29
  39. package/dist/commands/health/html-report.js +0 -14
  40. package/dist/commands/health/report-view-model.js +0 -1
  41. package/dist/commands/health/surfaces.js +6 -7
  42. package/dist/commands/health/types.js +0 -2
  43. package/dist/commands/health.js +63 -18
  44. package/dist/commands/improve/collapse-detector.js +5 -6
  45. package/dist/commands/improve/consolidate.js +251 -214
  46. package/dist/commands/improve/distill/promote-memory.js +71 -34
  47. package/dist/commands/improve/distill/quality-gate.js +17 -5
  48. package/dist/commands/improve/distill.js +232 -155
  49. package/dist/commands/improve/eligibility.js +112 -79
  50. package/dist/commands/improve/execution.js +57 -0
  51. package/dist/commands/improve/extract-cli.js +5 -5
  52. package/dist/commands/improve/extract-prompt.js +64 -22
  53. package/dist/commands/improve/extract.js +608 -360
  54. package/dist/commands/improve/improve-strategies.js +43 -14
  55. package/dist/commands/improve/improve.js +249 -29
  56. package/dist/commands/improve/loop-stages.js +11 -17
  57. package/dist/commands/improve/memory/memory-contradiction-detect.js +90 -66
  58. package/dist/commands/improve/outcome-loop.js +22 -38
  59. package/dist/commands/improve/planner.js +134 -0
  60. package/dist/commands/improve/preparation.js +730 -409
  61. package/dist/commands/improve/reflect.js +386 -223
  62. package/dist/commands/improve/run-context.js +3 -4
  63. package/dist/commands/improve/salience.js +6 -58
  64. package/dist/commands/improve/session-asset.js +12 -12
  65. package/dist/commands/lint/index.js +101 -29
  66. package/dist/commands/migrate-cli.js +11 -69
  67. package/dist/commands/migration-tool.js +6 -9
  68. package/dist/commands/models-cli.js +27 -0
  69. package/dist/commands/proposal/drain.js +258 -186
  70. package/dist/commands/proposal/proposal-cli.js +32 -10
  71. package/dist/commands/proposal/proposal.js +2 -5
  72. package/dist/commands/proposal/propose.js +192 -172
  73. package/dist/commands/proposal/repository.js +54 -91
  74. package/dist/commands/proposal/validators/proposal-validators.js +9 -7
  75. package/dist/commands/read/curate.js +53 -22
  76. package/dist/commands/read/registry-search.js +25 -9
  77. package/dist/commands/read/remember-cli.js +14 -2
  78. package/dist/commands/read/search.js +10 -4
  79. package/dist/commands/read/show.js +139 -153
  80. package/dist/commands/registry-cli.js +16 -7
  81. package/dist/commands/remember.js +33 -18
  82. package/dist/commands/sources/add-cli.js +19 -178
  83. package/dist/commands/sources/bundle-cli.js +15 -3
  84. package/dist/commands/sources/dangerous-env-audit.js +135 -0
  85. package/dist/commands/sources/info.js +2 -1
  86. package/dist/commands/sources/installed-stashes.js +901 -177
  87. package/dist/commands/sources/schema-repair.js +174 -95
  88. package/dist/commands/sources/self-update.js +30 -74
  89. package/dist/commands/sources/source-add.js +3 -5
  90. package/dist/commands/sources/sources-cli.js +2 -15
  91. package/dist/commands/sources/update-transaction.js +220 -0
  92. package/dist/commands/tasks/tasks-cli.js +3 -3
  93. package/dist/commands/tasks/tasks.js +736 -317
  94. package/dist/commands/workflow-cli.js +2 -2
  95. package/dist/core/adapter/adapters/agent-skills-adapter.js +3 -0
  96. package/dist/core/adapter/adapters/akm-adapter.js +85 -35
  97. package/dist/core/adapter/adapters/akm-lint.js +54 -39
  98. package/dist/core/adapter/adapters/akm-metadata.js +45 -45
  99. package/dist/core/adapter/adapters/akm-task-adapter.js +32 -49
  100. package/dist/core/adapter/adapters/akm-workflow-adapter.js +38 -23
  101. package/dist/core/adapter/adapters/dotenv-adapter.js +30 -1
  102. package/dist/core/adapter/adapters/generic-files-adapter.js +11 -0
  103. package/dist/core/adapter/adapters/index.js +0 -9
  104. package/dist/core/adapter/adapters/llm-wiki-adapter.js +4 -0
  105. package/dist/core/adapter/adapters/okf-adapter.js +4 -0
  106. package/dist/core/adapter/adapters/opencode-adapter.js +5 -8
  107. package/dist/core/adapter/adapters/tool-dir-shared.js +63 -6
  108. package/dist/core/adapter/adapters/website-snapshot-adapter.js +4 -0
  109. package/dist/core/adapter/execution-source.js +308 -0
  110. package/dist/core/adapter/recognize-match.js +36 -13
  111. package/dist/core/adapter/registry.js +0 -9
  112. package/dist/core/asset/stash-meta.js +94 -4
  113. package/dist/core/common.js +6 -11
  114. package/dist/core/config/config-io.js +3 -3
  115. package/dist/core/config/config-schema.js +18 -40
  116. package/dist/core/config/config-sources.js +11 -21
  117. package/dist/core/config/config-walker.js +31 -13
  118. package/dist/core/config/config.js +23 -26
  119. package/dist/core/config/schema/engines.js +8 -7
  120. package/dist/core/config/schema/improve-processes.js +29 -5
  121. package/dist/core/config/schema/index-config.js +0 -27
  122. package/dist/core/config/schema/primitives.js +1 -23
  123. package/dist/core/config/schema/sources-bundles.js +13 -16
  124. package/dist/core/errors.js +2 -0
  125. package/dist/core/events.js +68 -32
  126. package/dist/core/extra-params.js +1 -0
  127. package/dist/core/improve-result.js +315 -0
  128. package/dist/core/lesson-lint.js +0 -6
  129. package/dist/core/maintenance-barrier.js +4 -4
  130. package/dist/core/network-policy.js +152 -0
  131. package/dist/core/paths.js +1 -1
  132. package/dist/core/recognition-util.js +4 -4
  133. package/dist/core/registry-url.js +456 -0
  134. package/dist/core/state/migrations.js +161 -47
  135. package/dist/core/state-db.js +453 -80
  136. package/dist/core/system-error.js +32 -0
  137. package/dist/core/time.js +2 -12
  138. package/dist/core/write-source.js +0 -18
  139. package/dist/execution/directory-identity.js +52 -0
  140. package/dist/execution/executable-identity.js +107 -0
  141. package/dist/execution/guarded-source.js +398 -0
  142. package/dist/execution/json.js +95 -0
  143. package/dist/{commands/health/types-session-log.js → execution/limits.js} +2 -1
  144. package/dist/execution/record.js +55 -0
  145. package/dist/execution/resolved-request.js +730 -0
  146. package/dist/execution/source.js +320 -0
  147. package/dist/indexer/bundle-identity-guard.js +5 -4
  148. package/dist/indexer/db/graph-db.js +33 -0
  149. package/dist/indexer/graph/graph-boost.js +3 -4
  150. package/dist/indexer/graph/graph-extraction.js +562 -373
  151. package/dist/indexer/index-written-assets.js +78 -39
  152. package/dist/indexer/indexer.js +471 -432
  153. package/dist/indexer/installations.js +6 -0
  154. package/dist/indexer/lookup/adapter-concept-owner.js +283 -0
  155. package/dist/indexer/materialize-embeddings.js +155 -0
  156. package/dist/indexer/passes/memory-inference.js +227 -174
  157. package/dist/indexer/passes/metadata.js +263 -118
  158. package/dist/indexer/scan/doc-to-entry.js +7 -10
  159. package/dist/indexer/scan/drain-dir.js +51 -23
  160. package/dist/indexer/search/db-search.js +156 -50
  161. package/dist/indexer/search/fts-query.js +40 -40
  162. package/dist/indexer/search/ranking.js +36 -1
  163. package/dist/indexer/search/search-attribution.js +3 -1
  164. package/dist/indexer/search/search-fields.js +23 -14
  165. package/dist/indexer/search/search-hit-enrichers.js +1 -1
  166. package/dist/indexer/search/search-source.js +7 -16
  167. package/dist/indexer/search/semantic-status.js +10 -1
  168. package/dist/indexer/usage/show-usage.js +105 -0
  169. package/dist/indexer/usage/usage-events.js +7 -2
  170. package/dist/indexer/walk/matchers.js +40 -10
  171. package/dist/indexer/walk/path-resolver.js +5 -2
  172. package/dist/indexer/walk/walker.js +20 -2
  173. package/dist/integrations/agent/builder-shared.js +3 -6
  174. package/dist/integrations/agent/conversation-fallback.js +16 -0
  175. package/dist/integrations/agent/engine-resolution.js +87 -87
  176. package/dist/integrations/agent/execution-cascade.js +566 -0
  177. package/dist/integrations/agent/execution-definitions.js +211 -0
  178. package/dist/integrations/agent/execution-lowering.js +811 -0
  179. package/dist/integrations/agent/execution-preparation.js +67 -0
  180. package/dist/integrations/agent/index.js +0 -2
  181. package/dist/integrations/agent/inline-execution.js +74 -0
  182. package/dist/integrations/agent/model-map.js +515 -0
  183. package/dist/integrations/agent/persona-fallback.js +30 -0
  184. package/dist/integrations/agent/request-lowering.js +186 -0
  185. package/dist/integrations/agent/runner-dispatch.js +230 -37
  186. package/dist/integrations/agent/runner.js +12 -83
  187. package/dist/integrations/harnesses/aider/agent-builder.js +8 -0
  188. package/dist/integrations/harnesses/aider/index.js +0 -1
  189. package/dist/integrations/harnesses/amazonq/agent-builder.js +8 -0
  190. package/dist/integrations/harnesses/amazonq/index.js +0 -1
  191. package/dist/integrations/harnesses/claude/agent-builder.js +14 -1
  192. package/dist/integrations/harnesses/claude/index.js +1 -5
  193. package/dist/integrations/harnesses/claude/session-log.js +3 -33
  194. package/dist/integrations/harnesses/codex/agent-builder.js +8 -0
  195. package/dist/integrations/harnesses/codex/index.js +0 -1
  196. package/dist/integrations/harnesses/copilot/agent-builder.js +8 -0
  197. package/dist/integrations/harnesses/copilot/index.js +0 -1
  198. package/dist/integrations/harnesses/gemini/agent-builder.js +8 -0
  199. package/dist/integrations/harnesses/gemini/index.js +0 -1
  200. package/dist/integrations/harnesses/index.js +4 -44
  201. package/dist/integrations/harnesses/opencode/agent-builder.js +16 -9
  202. package/dist/integrations/harnesses/opencode/index.js +0 -2
  203. package/dist/integrations/harnesses/opencode/session-log.js +14 -204
  204. package/dist/integrations/harnesses/opencode-sdk/harness.js +12 -1
  205. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +40 -42
  206. package/dist/integrations/harnesses/openhands/agent-builder.js +8 -0
  207. package/dist/integrations/harnesses/openhands/index.js +0 -1
  208. package/dist/integrations/harnesses/pi/agent-builder.js +8 -0
  209. package/dist/integrations/harnesses/pi/index.js +0 -1
  210. package/dist/integrations/harnesses/shared.js +0 -1
  211. package/dist/integrations/harnesses/types.js +1 -3
  212. package/dist/integrations/lockfile.js +82 -79
  213. package/dist/integrations/session-logs/index.js +6 -17
  214. package/dist/integrations/session-logs/provider-base.js +1 -29
  215. package/dist/llm/client.js +10 -5
  216. package/dist/llm/embedder.js +6 -7
  217. package/dist/llm/embedders/local.js +37 -88
  218. package/dist/llm/embedders/types.js +1 -1
  219. package/dist/llm/graph-extract.js +75 -50
  220. package/dist/llm/index-passes.js +43 -5
  221. package/dist/llm/memory-infer.js +8 -6
  222. package/dist/llm/metadata-enhance.js +5 -3
  223. package/dist/llm/structured-call.js +122 -25
  224. package/dist/output/format-exempt.js +1 -1
  225. package/dist/output/render-registry.js +0 -16
  226. package/dist/output/renderers.js +12 -7
  227. package/dist/output/shapes/curate.js +1 -0
  228. package/dist/output/shapes/helpers.js +10 -2
  229. package/dist/output/shapes/passthrough.js +2 -0
  230. package/dist/output/text/command-format.js +31 -33
  231. package/dist/output/text/health-format.js +1 -29
  232. package/dist/output/text/migrate.js +6 -56
  233. package/dist/output/text/proposal-format.js +16 -1
  234. package/dist/output/text/workflow-format.js +16 -0
  235. package/dist/registry/network.js +279 -0
  236. package/dist/registry/pinned-request-helper.js +247 -0
  237. package/dist/registry/pinned-transport.js +717 -0
  238. package/dist/registry/providers/skills-sh.js +18 -6
  239. package/dist/registry/providers/static-index.js +20 -7
  240. package/dist/registry/resolve.js +53 -28
  241. package/dist/scripts/akm-migrate-node.js +19334 -52269
  242. package/dist/scripts/akm-migrate.js +19270 -51612
  243. package/dist/setup/registry-stash-loader.js +64 -20
  244. package/dist/setup/semantic-assets.js +9 -34
  245. package/dist/setup/setup.js +12 -30
  246. package/dist/setup/source-identity.js +17 -0
  247. package/dist/setup/steps/sources.js +36 -15
  248. package/dist/setup/steps/tasks.js +39 -11
  249. package/dist/sources/providers/git-provider.js +3 -3
  250. package/dist/sources/providers/npm.js +2 -2
  251. package/dist/sources/providers/provider-utils.js +4 -3
  252. package/dist/sources/providers/website.js +11 -7
  253. package/dist/sources/snapshot-fetchers/host-guard.js +9 -136
  254. package/dist/sources/snapshot-fetchers/website-ingest.js +25 -109
  255. package/dist/sources/website-url.js +73 -0
  256. package/dist/storage/engines/sqlite-migrations.js +81 -26
  257. package/dist/storage/managed-db.js +27 -24
  258. package/dist/storage/repositories/events-repository.js +3 -0
  259. package/dist/storage/repositories/index-connection.js +42 -10
  260. package/dist/storage/repositories/index-entries-repository.js +203 -229
  261. package/dist/storage/repositories/index-entry-mapper.js +8 -12
  262. package/dist/storage/repositories/index-entry-schema.js +255 -0
  263. package/dist/storage/repositories/index-fts-repository.js +64 -71
  264. package/dist/storage/repositories/index-llm-cache-repository.js +8 -13
  265. package/dist/storage/repositories/index-meta-repository.js +0 -11
  266. package/dist/storage/repositories/index-schema.js +74 -350
  267. package/dist/storage/repositories/index-utility-repository.js +12 -17
  268. package/dist/storage/repositories/index-vec-repository.js +56 -7
  269. package/dist/storage/repositories/proposals-repository.js +4 -127
  270. package/dist/storage/repositories/registry-cache.js +2 -1
  271. package/dist/storage/repositories/task-history-repository.js +20 -40
  272. package/dist/storage/repositories/workflow-runs-repository.js +228 -129
  273. package/dist/storage/sqlite-read-snapshot.js +148 -0
  274. package/dist/tasks/backends/cron.js +170 -42
  275. package/dist/tasks/backends/index.js +1 -1
  276. package/dist/tasks/backends/launchd.js +787 -202
  277. package/dist/tasks/backends/schtasks.js +282 -83
  278. package/dist/tasks/embedded.js +7 -7
  279. package/dist/tasks/frozen-script.js +50 -0
  280. package/dist/tasks/resolve-akm-bin.js +5 -1
  281. package/dist/tasks/runner.js +239 -251
  282. package/dist/tasks/runtime-v3.js +281 -0
  283. package/dist/tasks/scheduler-binding.js +272 -0
  284. package/dist/tasks/scheduler-invocation.js +57 -43
  285. package/dist/tasks/scheduler-sync.js +654 -0
  286. package/dist/tasks/source-v3.js +752 -0
  287. package/dist/tasks/standalone-script-entry.js +5 -0
  288. package/dist/tasks/task-id.js +29 -0
  289. package/dist/workflows/authoring/authoring.js +15 -32
  290. package/dist/workflows/exec/dispatch-redaction.js +14 -8
  291. package/dist/workflows/exec/exec-unit.js +7 -28
  292. package/dist/workflows/exec/frozen-judge.js +57 -89
  293. package/dist/workflows/exec/lowering-notices.js +23 -0
  294. package/dist/workflows/exec/native-executor.js +301 -458
  295. package/dist/workflows/exec/param-secrets.js +4 -3
  296. package/dist/workflows/exec/run-workflow.js +26 -32
  297. package/dist/workflows/exec/step-work.js +105 -109
  298. package/dist/workflows/exec/unit-dispatch.js +103 -27
  299. package/dist/workflows/exec/unit-writer.js +3 -3
  300. package/dist/workflows/exec/worktree.js +2 -2
  301. package/dist/workflows/ir/compile.js +86 -72
  302. package/dist/workflows/ir/environment-v4.js +328 -0
  303. package/dist/workflows/ir/freeze-v4.js +122 -0
  304. package/dist/workflows/ir/plan-hash.js +13 -7
  305. package/dist/workflows/ir/schema-v4.js +525 -0
  306. package/dist/workflows/ir/schema.js +25 -284
  307. package/dist/workflows/ir/source-freeze-v4.js +506 -0
  308. package/dist/workflows/parser.js +27 -24
  309. package/dist/workflows/program/schema.js +1 -2
  310. package/dist/workflows/renderer.js +42 -29
  311. package/dist/workflows/resource-limits.js +4 -5
  312. package/dist/workflows/runtime/agent-identity.js +11 -13
  313. package/dist/workflows/runtime/plan-classifier.js +8 -8
  314. package/dist/workflows/runtime/runs.js +27 -43
  315. package/dist/workflows/runtime/workflow-asset-loader.js +45 -205
  316. package/dist/workflows/source-files.js +373 -0
  317. package/dist/workflows/source-ir/compile.js +196 -0
  318. package/dist/workflows/source-ir/github-yaml.js +577 -0
  319. package/dist/workflows/source-ir/ordering.js +38 -0
  320. package/dist/workflows/source-ir/program.js +50 -0
  321. package/dist/workflows/source-ir/result.js +26 -0
  322. package/dist/workflows/source-ir/schema.js +772 -0
  323. package/dist/workflows/source-ir/semantics.js +242 -0
  324. package/dist/workflows/source-ir/uses.js +14 -0
  325. package/docs/README.md +2 -0
  326. package/docs/migration/README.md +3 -1
  327. package/docs/migration/release-notes/0.9.2.md +55 -0
  328. package/docs/migration/release-notes/README.md +5 -0
  329. package/docs/migration/v0.8-to-v0.9.md +76 -1077
  330. package/docs/migration/v0.9.0-troubleshooting.md +104 -516
  331. package/docs/migration/v0.9.1-to-v0.9.2.md +150 -0
  332. package/docs/reference/README.md +1 -0
  333. package/docs/reference/cli.md +230 -98
  334. package/docs/reference/configuration.md +159 -36
  335. package/docs/reference/data-and-telemetry.md +19 -1
  336. package/docs/reference/supported-formats.md +23 -3
  337. package/docs/reference/tasks.md +182 -0
  338. package/docs/reference/workflow-schema.md +91 -40
  339. package/docs/reference/workflows.md +33 -6
  340. package/package.json +10 -6
  341. package/schemas/akm-config.json +372 -224
  342. package/schemas/akm-task.json +324 -80
  343. package/schemas/akm-workflow.json +6 -9
  344. package/dist/core/migration-operation.js +0 -75
  345. package/dist/integrations/agent/model-aliases.js +0 -74
  346. package/dist/tasks/parser.js +0 -380
  347. package/dist/tasks/schema.js +0 -123
  348. package/dist/tasks/validator.js +0 -80
  349. package/dist/workflows/ir/freeze.js +0 -320
  350. package/dist/workflows/runtime/document-cache.js +0 -13
@@ -36,7 +36,7 @@
36
36
  *
37
37
  * Empty free-text outputs (peer review): a SUCCESSFUL schemaless unit that
38
38
  * returns the empty string is normalized to "no output" — {@link dispatchUnit}
39
- * drops the falsy `text`, `finishUnit` journals `result_json = NULL`, and
39
+ * drops the falsy `text`, `finishUnitAttempt` journals `result_json = NULL`, and
40
40
  * durable-row reuse rehydrates the same absence (`unitOutcomeFromRow`). This is
41
41
  * the ONLY empty-output resolution: `''` never survives into the journal, so the
42
42
  * live artifact cannot diverge from the artifact a resume rebuilds from the same
@@ -123,18 +123,21 @@
123
123
  *
124
124
  * Layering (see the plan's *Reconciliation* section):
125
125
  * - Dispatch goes through ONE injected {@link UnitDispatcher} seam. The
126
- * default dispatcher composes the EXISTING substrate `executeRunner`
127
- * (agent/sdk) and `chatCompletion` (llm, lazily imported so the engine
128
- * stays offline-capable until a workflow actually declares an llm unit).
126
+ * default dispatcher adapts the frozen snapshot into the common resolved
127
+ * request, lowers it through the registered harness/direct-LLM adapter,
128
+ * and reaches transport only through the central lowered-dispatch seam.
129
129
  * - This module NEVER writes step rows: advancing the gated spine is the
130
130
  * engine loop's job (`run-workflow.ts`) via `completeWorkflowStep`.
131
131
  */
132
+ import { randomUUID } from "node:crypto";
132
133
  import { appendEvent } from "../../core/events.js";
133
134
  import { validateJsonSchemaSubset } from "../../core/json-schema.js";
134
135
  import { runStructured } from "../../core/structured.js";
135
136
  import { warn } from "../../core/warn.js";
136
- import { insertEventStrict } from "../../storage/repositories/events-repository.js";
137
+ import { assertFrozenDirectoryIdentity } from "../../execution/directory-identity.js";
138
+ import { assertFrozenExecutableIdentity } from "../../execution/executable-identity.js";
137
139
  import { withWorkflowRunsConnection, withWorkflowRunsRepo, } from "../../storage/repositories/workflow-runs-repository.js";
140
+ import { materializeFrozenWorkflowEnvironment } from "../ir/environment-v4.js";
138
141
  import { WORKFLOW_UNIT_DIAGNOSTIC_CLIP } from "../resource-limits.js";
139
142
  // The ONE dispatch redaction contract, shared with the gate-judge path
140
143
  // (exec/frozen-judge.ts). Consumers import the leaf directly — this module is
@@ -143,12 +146,14 @@ import { collectWorkflowDispatchSensitiveValues, redactUnitOutcome } from "./dis
143
146
  // The exec (shell) unit runner — a leaf that owns argv spawning, containment,
144
147
  // and the process-outcome → failure-reason mapping.
145
148
  import { runExecUnit } from "./exec-unit.js";
149
+ import { mergeLoweringNotices } from "./lowering-notices.js";
146
150
  import { LIFETIME_UNIT_CAP, scheduleUnits, UnitCapExceededError } from "./scheduler.js";
147
151
  // Shared step semantics — the ONE implementation consumed by the engine
148
152
  // (this module + run-workflow.ts) on both the fresh-execution and the resume
149
153
  // path. This module dispatches; step-work.ts owns the pure decisions.
150
154
  import { clip, computeStepWorkList, reduceEmptyStep, reduceStepOutcomes, stepOutputsFromEvidence, unitOutcomeFromRow, } from "./step-work.js";
151
- import { materializeFrozenLlm, } from "./unit-dispatch.js";
155
+ import { dispatchWorkflowExecution, } from "./unit-dispatch.js";
156
+ import { cleanupFrozenScript, frozenScriptCommand, materializeFrozenScript } from "../../tasks/frozen-script.js";
152
157
  import { enqueueUnitWrite } from "./unit-writer.js";
153
158
  import { assertGitWorkTree, cleanupUnitWorktree, createUnitWorktree } from "./worktree.js";
154
159
  /**
@@ -315,6 +320,63 @@ function openDispatchBudget(ctx, dispatched) {
315
320
  });
316
321
  return { signal, budget, unchainSignal };
317
322
  }
323
+ /** Resolve the live-at-dispatch prerequisites once, after durable-row reuse is known. */
324
+ async function prepareStepDispatchPrerequisites(input) {
325
+ const { plan, template, workUnits, ctx, willDispatch, dispatched } = input;
326
+ let env;
327
+ let sensitiveValues;
328
+ const frozenEnvironment = workUnits[0]?.environment;
329
+ if (willDispatch && frozenEnvironment && frozenEnvironment.length > 0) {
330
+ try {
331
+ const materialized = materializeFrozenWorkflowEnvironment(frozenEnvironment);
332
+ env = materialized.values;
333
+ sensitiveValues = materialized.sensitiveValues;
334
+ for (const audit of materialized.audits) {
335
+ appendEvent({
336
+ eventType: audit.eventType,
337
+ ref: audit.ref,
338
+ metadata: { keys: audit.keys, secretNames: audit.secretNames },
339
+ });
340
+ }
341
+ }
342
+ catch (err) {
343
+ return {
344
+ ok: false,
345
+ result: failedStep(dispatched, `Step "${plan.stepId}" frozen environment preflight failed: ${message(err)}`),
346
+ };
347
+ }
348
+ }
349
+ let worktreeBase;
350
+ if (willDispatch && template.isolation === "worktree") {
351
+ if (template.frozenTarget.kind === "command" && template.frozenTarget.runner.kind === "llm") {
352
+ return {
353
+ ok: false,
354
+ result: failedStep(dispatched, `Step "${plan.stepId}" declares isolation: worktree on an llm unit — the llm runner has no ` +
355
+ `working directory to isolate. Use the agent or sdk runner for worktree-isolated units.`),
356
+ };
357
+ }
358
+ const target = workUnits[0]?.frozenTarget;
359
+ const frozenCwd = target && "cwdIdentity" in target ? target.cwdIdentity : undefined;
360
+ if (frozenCwd)
361
+ assertFrozenDirectoryIdentity(frozenCwd);
362
+ const base = frozenCwd?.realRoot ?? ctx.workDir ?? process.cwd();
363
+ const preflightWorktree = ctx.preflightWorktree ?? assertGitWorkTree;
364
+ const gitError = preflightWorktree(base);
365
+ if (gitError !== undefined) {
366
+ return {
367
+ ok: false,
368
+ result: failedStep(dispatched, `Step "${plan.stepId}" cannot use isolation: worktree: ${gitError}`),
369
+ };
370
+ }
371
+ worktreeBase = base;
372
+ }
373
+ return {
374
+ ok: true,
375
+ ...(env ? { env } : {}),
376
+ ...(sensitiveValues ? { sensitiveValues } : {}),
377
+ ...(worktreeBase !== undefined ? { worktreeBase } : {}),
378
+ };
379
+ }
318
380
  async function executeStepPlanInConnection(plan, ctx) {
319
381
  const dispatched = ctx.unitsDispatched ?? 0;
320
382
  // Work-list computation is the SHARED, PURE decision (step-work.ts): resolve
@@ -328,7 +390,6 @@ async function executeStepPlanInConnection(plan, ctx) {
328
390
  runId: ctx.runId,
329
391
  params: ctx.params,
330
392
  stepOutputs: stepOutputsFromEvidence(ctx.evidence),
331
- engines: ctx.engines ?? {},
332
393
  ...(ctx.gateLoop !== undefined ? { gateLoop: ctx.gateLoop } : {}),
333
394
  ...(ctx.gateFeedback ? { gateFeedback: ctx.gateFeedback } : {}),
334
395
  });
@@ -365,47 +426,17 @@ async function executeStepPlanInConnection(plan, ctx) {
365
426
  // two call sites have to keep re-establishing.
366
427
  const reuseDecisions = workUnits.map((unit) => classifyUnitReuse(unit, completedRows, gateLoop));
367
428
  const willDispatch = reuseDecisions.some((decision) => decision.kind === "dispatch");
368
- // Env bindings resolve once per step, before any dispatch; a binding error
369
- // fails the whole step cleanly rather than N units racing into it. Skipped
370
- // entirely when nothing will dispatch.
371
- let env;
372
- if (willDispatch && template.env && template.env.length > 0) {
373
- const resolveEnv = ctx.resolveEnv ?? resolveEnvBindings;
374
- try {
375
- env = await resolveEnv(template.env);
376
- }
377
- catch (err) {
378
- return failedStep(dispatched, `Step "${plan.stepId}" env binding failed: ${message(err)}`);
379
- }
380
- }
381
- // Worktree isolation preflight (addendum R2), once per step, before any
382
- // dispatch — and ONLY when a unit will dispatch: llm units have no working
383
- // directory to isolate (fail loudly), and a non-git base directory (or a
384
- // missing git binary) fails the step cleanly instead of N units racing into
385
- // identical git errors. The actual worktrees are minted per journaled
386
- // attempt in dispatchJournaledAttempt.
387
- let worktreeBase;
388
- if (willDispatch && template.isolation === "worktree") {
389
- const engine = template.invocation ? ctx.engines?.[template.invocation.engine] : undefined;
390
- if (engine?.kind === "llm") {
391
- return failedStep(dispatched, `Step "${plan.stepId}" declares isolation: worktree on an llm unit — the llm runner has no ` +
392
- `working directory to isolate. Use the agent or sdk runner for worktree-isolated units.`);
393
- }
394
- const base = ctx.workDir ?? process.cwd();
395
- const preflightWorktree = ctx.preflightWorktree ?? assertGitWorkTree;
396
- const gitError = preflightWorktree(base);
397
- if (gitError !== undefined) {
398
- return failedStep(dispatched, `Step "${plan.stepId}" cannot use isolation: worktree: ${gitError}`);
399
- }
400
- worktreeBase = base;
401
- }
402
- // The values that must never survive into the journal. Collected once per
403
- // step, not once per unit: the frozen engine and its SDK fallback are
404
- // resolved from the step template and handed unchanged to every unit, and
405
- // `env` is step-wide, so a fan-out was re-scanning process.env — and
406
- // re-inspecting every passthrough value — once per unit, including for units
407
- // that go on to reuse a journaled row and dispatch nothing at all.
408
- const sensitiveValues = willDispatch && workUnits[0] ? collectWorkflowDispatchSensitiveValues(workUnits[0], env) : [];
429
+ const prerequisites = await prepareStepDispatchPrerequisites({
430
+ plan,
431
+ template,
432
+ workUnits,
433
+ ctx,
434
+ willDispatch,
435
+ dispatched,
436
+ });
437
+ if (!prerequisites.ok)
438
+ return prerequisites.result;
439
+ const { env, sensitiveValues, worktreeBase } = prerequisites;
409
440
  // Budget ceilings + lifetime-cap accounting, and the budget-chained abort
410
441
  // signal they trip. Extracted verbatim (behavior-identical) — see
411
442
  // {@link openDispatchBudget}.
@@ -416,30 +447,25 @@ async function executeStepPlanInConnection(plan, ctx) {
416
447
  // still mean "its clean worktrees are gone" — only the WAIT moves off the
417
448
  // unit's scheduler slot, not the guarantee.
418
449
  const pendingWorktreeCleanups = [];
419
- const selectedEngine = template.invocation ? ctx.engines?.[template.invocation.engine] : undefined;
420
- const selectedLlmEngine = selectedEngine?.kind === "llm"
421
- ? selectedEngine
422
- : selectedEngine?.kind === "agent" && selectedEngine.fallbackLlmEngine
423
- ? ctx.engines?.[selectedEngine.fallbackLlmEngine]
424
- : undefined;
450
+ const frozenTargetConcurrency = template.frozenTarget.kind === "command" ? template.frozenTarget.concurrency : undefined;
425
451
  try {
426
452
  outcomes = await scheduleUnits(workUnits, (workUnit, index) => runUnit({
427
453
  plan,
428
454
  workUnit,
429
455
  env,
456
+ sensitiveValues,
430
457
  ...(worktreeBase !== undefined ? { worktreeBase } : {}),
431
458
  ctx,
432
459
  signal,
433
460
  dispatcher,
434
461
  reuse: reuseDecisions[index],
435
- sensitiveValues,
436
462
  pendingWorktreeCleanups,
437
463
  budget,
438
464
  }), {
439
465
  concurrency: workList.list.concurrency,
440
466
  signal,
441
467
  maxConcurrency: ctx.maxConcurrency,
442
- ...(selectedLlmEngine?.kind === "llm" ? { llmConcurrency: selectedLlmEngine.concurrency } : {}),
468
+ ...(frozenTargetConcurrency !== undefined ? { llmConcurrency: frozenTargetConcurrency } : {}),
443
469
  });
444
470
  }
445
471
  finally {
@@ -448,14 +474,19 @@ async function executeStepPlanInConnection(plan, ctx) {
448
474
  // failure into a warn — nothing here can fail the step.
449
475
  await Promise.allSettled(pendingWorktreeCleanups);
450
476
  }
477
+ // Capture live-only diagnostics BEFORE any hard reduction replaces the unit
478
+ // list with a failed-step envelope. Budget/cap, replay divergence, and
479
+ // journal-write failures must not erase notices already observed from real
480
+ // dispatches; durable row reuses naturally contribute none.
481
+ const notices = mergeLoweringNotices(...outcomes.map((outcome) => outcome?.notices));
451
482
  // Declared budget ceilings and the lifetime cap are hard backstops: a step
452
483
  // that hit one FAILS regardless of on_error policy (a capped run must never
453
484
  // quietly pass its gate). The budget message names WHICH ceiling tripped.
454
485
  if (budget.budgetMessage) {
455
- return { ...failedStep(budget.used, budget.budgetMessage), tokensUsed: budget.tokens };
486
+ return { ...failedStep(budget.used, budget.budgetMessage, notices), tokensUsed: budget.tokens };
456
487
  }
457
488
  if (budget.capMessage) {
458
- return { ...failedStep(budget.used, budget.capMessage), tokensUsed: budget.tokens };
489
+ return { ...failedStep(budget.used, budget.capMessage, notices), tokensUsed: budget.tokens };
459
490
  }
460
491
  const units = outcomes.map((outcome, index) => outcome ?? {
461
492
  unitId: workUnits[index].unitId,
@@ -470,7 +501,7 @@ async function executeStepPlanInConnection(plan, ctx) {
470
501
  if (diverged.length > 0) {
471
502
  return failedStep(budget.used, diverged
472
503
  .map((u) => u.error ?? `replay divergence: unit "${u.unitId}" was journaled with different inputs`)
473
- .join(" "));
504
+ .join(" "), notices);
474
505
  }
475
506
  // A journal-write failure is likewise HARD regardless of on_error: the
476
507
  // unit dispatched (spent tokens, ran side effects) but its result could not
@@ -481,7 +512,7 @@ async function executeStepPlanInConnection(plan, ctx) {
481
512
  const unjournaled = units.filter((u) => u.failureReason === "journal_write_failed");
482
513
  if (unjournaled.length > 0) {
483
514
  return {
484
- ...failedStep(budget.used, unjournaled.map((u) => u.error ?? `unit "${u.unitId}" result could not be journaled`).join(" ")),
515
+ ...failedStep(budget.used, unjournaled.map((u) => u.error ?? `unit "${u.unitId}" result could not be journaled`).join(" "), notices),
485
516
  tokensUsed: budget.tokens,
486
517
  };
487
518
  }
@@ -497,57 +528,48 @@ async function executeStepPlanInConnection(plan, ctx) {
497
528
  const reduced = reduceStepOutcomes(plan, reducer, isFanOut, template.onError, units);
498
529
  return {
499
530
  ...reduced,
531
+ ...(notices ? { notices } : {}),
500
532
  unitsDispatched: budget.used,
501
533
  tokensUsed: budget.tokens,
502
534
  };
503
535
  }
504
536
  async function runUnit(input) {
505
- const { plan, workUnit, env, ctx, dispatcher } = input;
537
+ const { plan, workUnit, env, sensitiveValues, ctx, dispatcher } = input;
506
538
  const unitId = workUnit.unitId;
507
- // Engine/exec presence is a WHOLE-LIST invariant, never a per-unit condition:
508
- // computeStepWorkList fails the entire step before it builds any unit when a
509
- // template carries neither an `exec` spec nor an `invocation`, and when an
510
- // `invocation` names an engine absent from the frozen catalog — and
511
- // executeStepPlanInConnection returns on `!workList.ok` without reaching here.
512
- // So every unit below carries either `exec` (a child process, naming no
513
- // engine) or `engine` + `invocation`.
539
+ // Target validity is a WHOLE-LIST invariant, never a per-unit condition:
540
+ // computeStepWorkList rejects a step before building any unit when its sole
541
+ // frozen target is invalid, so every unit below carries one executable
542
+ // command/agent/SDK/direct-LLM target.
514
543
  // The prompt (and therefore the input hash) was built once with the BASE
515
544
  // unit id by computeStepWorkList: a retry re-dispatches the SAME input, the
516
545
  // `~r<n>` suffix is journal bookkeeping only.
517
546
  const { prompt, inputHash } = workUnit;
518
- const sensitiveValues = input.sensitiveValues;
519
547
  const request = {
520
548
  runId: ctx.runId,
521
549
  stepId: plan.stepId,
522
550
  unitId,
523
551
  nodeId: workUnit.nodeId,
524
552
  prompt,
525
- ...(workUnit.engine ? { engine: workUnit.engine } : {}),
526
- ...(workUnit.fallbackEngine ? { fallbackEngine: workUnit.fallbackEngine } : {}),
527
- ...(workUnit.invocation ? { invocation: workUnit.invocation } : {}),
528
- ...(workUnit.exec ? { exec: workUnit.exec } : {}),
553
+ frozenTarget: workUnit.frozenTarget,
529
554
  ...(workUnit.execContext ? { execContext: workUnit.execContext } : {}),
530
555
  // A NON-isolated exec unit spawns in the engine invocation's working
531
556
  // directory. `dispatchJournaledAttempt` overwrites this with the unit's
532
557
  // fresh worktree when `isolation: worktree` is in play. Only exec units get
533
558
  // it: handing an agent unit a cwd it never had would change harness
534
559
  // behavior, and the agent path already takes its cwd from its profile.
535
- ...(workUnit.exec && ctx.workDir !== undefined ? { cwd: ctx.workDir } : {}),
560
+ ...(workUnit.frozenTarget.kind !== "command" && ctx.workDir !== undefined ? { cwd: ctx.workDir } : {}),
536
561
  timeoutMs: workUnit.timeoutMs,
537
562
  ...(workUnit.schema ? { schema: workUnit.schema } : {}),
538
563
  ...(env ? { env } : {}),
539
- ...(sensitiveValues.length > 0 ? { sensitiveValues } : {}),
564
+ ...(sensitiveValues ? { sensitiveValues } : {}),
540
565
  ...(input.signal ? { signal: input.signal } : {}),
541
566
  };
542
- // Bounded retry: attempt 0 journals under the base
543
- // journal id (`<unitId>`, or `<unitId>~l<loop>` in a gate loop — computed by
544
- // the shared work-list), retry attempt N under `<baseId>~r<N>`. Every attempt
545
- // keeps its own row. Retries only fire when the failure reason is in
546
- // `retry.on`.
567
+ // One content-derived unit id is retained across every retry; the append-only
568
+ // attempt table supplies the 1-based attempt identity.
547
569
  const retry = workUnit.retry;
548
570
  const maxAttempts = 1 + Math.max(0, retry?.max ?? 0);
549
571
  const journalBaseId = workUnit.journalBaseId;
550
- const attemptIdFor = (attempt) => (attempt === 0 ? journalBaseId : `${journalBaseId}~r${attempt}`);
572
+ const attemptIdFor = (_attempt) => journalBaseId;
551
573
  // Durable-row reuse — literally the decision executeStepPlan's preflight gate
552
574
  // counted, handed down rather than recomputed, so the gate cannot disagree
553
575
  // with what happens here. A completed row with the matching input hash IS the
@@ -609,11 +631,8 @@ async function runUnit(input) {
609
631
  ...(input.worktreeBase !== undefined ? { worktreeBase: input.worktreeBase } : {}),
610
632
  pendingWorktreeCleanups: input.pendingWorktreeCleanups,
611
633
  });
612
- // The journal ROW keeps the `~r<n>`/`~l<loop>` attempt id (dispatchJournaledAttempt
613
- // wrote it), but the returned outcome's identity in the DURABLE step evidence is
614
- // the content-derived BASE id — the suffix is journal bookkeeping the report
615
- // surface never sees, so leaking it into evidence.units would diverge the two
616
- // surfaces (R4 parity, exposed once the conformance graph compares evidence.units).
634
+ // Attempts use `~r<n>` journal suffixes while durable step evidence remains
635
+ // attached to the content-derived base identity.
617
636
  outcome.unitId = unitId;
618
637
  // Budget token accounting (addendum R2): every actual dispatch's reported
619
638
  // usage counts against the run's max_tokens ceiling; crossing it aborts
@@ -678,50 +697,119 @@ function journaledUnitResultJson(outcome) {
678
697
  return null;
679
698
  return JSON.stringify(clip(parts.join("\n--- unit output ---\n"), WORKFLOW_UNIT_DIAGNOSTIC_CLIP));
680
699
  }
681
- /** Journal one dispatch attempt: insert row, events, dispatch, finish row. */
682
- async function dispatchJournaledAttempt(input) {
683
- const { plan, workUnit, ctx, dispatcher, attemptId, inputHash } = input;
684
- let request = input.request;
685
- // Worktree isolation (addendum R2): a FRESH detached worktree per journaled
686
- // attempt, minted before the row is inserted so worktree_path is journaled
687
- // with the dispatch. A creation failure fails the unit WITHOUT journaling a
688
- // row — nothing was dispatched (same contract as an expression failure).
689
- let worktreePath;
690
- if (input.worktreeBase !== undefined) {
691
- const created = await createUnitWorktree(input.worktreeBase, ctx.runId, attemptId);
692
- // Reported BEFORE the failure check: when the worktree could not be minted,
693
- // the leftover moved aside is the only copy of the prior attempt's
694
- // uncollected work, so that is exactly when its path must not be swallowed.
695
- if (created.preservedLeftover !== undefined) {
696
- // Never destroy a dirty (or unverifiable) leftover from a prior
697
- // invocation of the same attempt — it was moved aside instead.
698
- warn(`Workflow unit ${attemptId}: a previous attempt left uncollected work in its isolation worktree; ` +
699
- `preserved at ${created.preservedLeftover}`);
700
- }
701
- if (!created.ok) {
702
- return { unitId: request.unitId, ok: false, failureReason: "worktree_failed", error: created.error };
703
- }
704
- worktreePath = created.path;
705
- request = { ...request, cwd: worktreePath };
700
+ async function prepareAttemptWorktree(input) {
701
+ if (input.worktreeBase === undefined)
702
+ return { ok: true, request: input.request };
703
+ const created = await createUnitWorktree(input.worktreeBase, input.ctx.runId, input.attemptId, input.workUnit.frozenTarget.gitCommitOid);
704
+ if (created.preservedLeftover !== undefined) {
705
+ warn(`Workflow unit ${input.attemptId}: a previous attempt left uncollected work in its isolation worktree; ` +
706
+ `preserved at ${created.preservedLeftover}`);
706
707
  }
707
- const startedAt = new Date().toISOString();
708
- try {
709
- await enqueueUnitWrite(async () => {
710
- await withWorkflowRunsRepo((repo) => repo.insertUnit({
708
+ if (!created.ok) {
709
+ return {
710
+ ok: false,
711
+ outcome: {
712
+ unitId: input.request.unitId,
713
+ ok: false,
714
+ failureReason: "worktree_failed",
715
+ error: created.error,
716
+ },
717
+ };
718
+ }
719
+ return { ok: true, request: { ...input.request, cwd: created.path }, worktreePath: created.path };
720
+ }
721
+ async function reserveJournaledDispatch(input, request, worktreePath, startedAt) {
722
+ const { plan, workUnit, ctx, attemptId, inputHash } = input;
723
+ let durableAttempt;
724
+ await enqueueUnitWrite(async () => {
725
+ await withWorkflowRunsRepo((repo) => {
726
+ const target = workUnit.frozenTarget;
727
+ const holder = ctx.leaseHolder ?? `direct:${randomUUID()}`;
728
+ const reserved = repo.reserveUnitAttempt({
711
729
  runId: ctx.runId,
712
730
  unitId: attemptId,
713
731
  stepId: plan.stepId,
714
732
  nodeId: workUnit.nodeId,
715
733
  parentUnitId: workUnit.isFanOut ? `${plan.stepId}.map` : null,
716
- phase: null,
734
+ phase: "unit",
717
735
  runner: workUnit.runner,
718
- engine: request.engine?.name ?? null,
719
- model: request.invocation?.model ?? null,
736
+ engine: target.kind === "command" ? target.request.engine.name : null,
737
+ model: target.kind === "command" ? (target.request.model?.resolved ?? null) : null,
720
738
  inputHash,
721
739
  worktreePath: worktreePath ?? null,
722
- startedAt,
723
- }));
740
+ claimHolder: holder,
741
+ claimExpiresAt: new Date(Date.parse(startedAt) + 90_000).toISOString(),
742
+ now: startedAt,
743
+ leaseMode: ctx.leaseHolder === undefined ? "direct" : "engine",
744
+ });
745
+ if (reserved.kind === "busy") {
746
+ throw new Error(`unit "${attemptId}" already has a live durable attempt held by ${reserved.attempt.claim_holder}`);
747
+ }
748
+ durableAttempt = reserved.attempt;
749
+ });
750
+ });
751
+ if (!durableAttempt)
752
+ throw new Error(`unit "${attemptId}" did not reserve a durable attempt`);
753
+ return durableAttempt;
754
+ }
755
+ async function finishJournaledDispatch(input) {
756
+ const { attempt: source, durableAttempt, finishedAt, outcome } = input;
757
+ const { ctx, attemptId } = source;
758
+ await enqueueUnitWrite(() => withWorkflowRunsRepo((repo) => {
759
+ const finished = repo.finishUnitAttempt({
760
+ runId: ctx.runId,
761
+ unitId: attemptId,
762
+ attempt: durableAttempt.attempt,
763
+ dispatchId: durableAttempt.dispatch_id,
764
+ claimHolder: durableAttempt.claim_holder,
765
+ status: outcome.ok ? "completed" : "failed",
766
+ resultJson: journaledUnitResultJson(outcome),
767
+ tokens: outcome.tokens ?? null,
768
+ failureReason: outcome.failureReason ?? null,
769
+ sessionId: outcome.sessionId ?? null,
770
+ finishedAt,
724
771
  });
772
+ if (!finished) {
773
+ if (repo.getUnitAttempts(ctx.runId, attemptId).length === 0) {
774
+ throw new Error(`finishUnitAttempt updated no row: no durable attempt "${attemptId}" exists for run "${ctx.runId}".`);
775
+ }
776
+ warn(`Workflow unit ${attemptId} (run ${ctx.runId}) ${outcome.ok ? "completed" : `failed (${outcome.failureReason ?? "error"})`}, ` +
777
+ `but its durable attempt was reclaimed or finished by another engine invocation — refusing to overwrite ` +
778
+ `the CAS winner. This dispatch's result is not journaled.`);
779
+ }
780
+ }));
781
+ }
782
+ function queueAttemptWorktreeCleanup(input, worktreePath) {
783
+ if (worktreePath === undefined || input.worktreeBase === undefined)
784
+ return;
785
+ const worktreeBase = input.worktreeBase;
786
+ input.pendingWorktreeCleanups.push((async () => {
787
+ try {
788
+ const cleanup = await cleanupUnitWorktree(worktreeBase, worktreePath);
789
+ if (cleanup.dirty) {
790
+ warn(`Workflow unit ${input.attemptId} left uncommitted changes in its isolation worktree; retained at ${worktreePath}`);
791
+ }
792
+ else if (!cleanup.removed) {
793
+ warn(`Workflow unit ${input.attemptId}: could not clean up isolation worktree ${worktreePath}: ${cleanup.error}`);
794
+ }
795
+ }
796
+ catch (err) {
797
+ warn(`Workflow unit ${input.attemptId}: could not clean up isolation worktree ${worktreePath}: ${message(err)}`);
798
+ }
799
+ })());
800
+ }
801
+ /** Journal one dispatch attempt: insert row, events, dispatch, finish row. */
802
+ async function dispatchJournaledAttempt(input) {
803
+ const { workUnit, ctx, dispatcher, attemptId } = input;
804
+ const prepared = await prepareAttemptWorktree(input);
805
+ if (!prepared.ok)
806
+ return prepared.outcome;
807
+ let { request } = prepared;
808
+ const { worktreePath } = prepared;
809
+ const startedAt = new Date().toISOString();
810
+ let durableAttempt;
811
+ try {
812
+ durableAttempt = await reserveJournaledDispatch(input, request, worktreePath, startedAt);
725
813
  }
726
814
  catch (err) {
727
815
  // A failed dispatch-row insert means NOTHING dispatched (the row is the
@@ -729,9 +817,8 @@ async function dispatchJournaledAttempt(input) {
729
817
  // letting the throw escape into the scheduler, where a swallowed worker
730
818
  // error is indistinguishable from "never claimed" and used to be
731
819
  // misreported as an aborted, never-dispatched unit.
732
- if (worktreePath !== undefined && input.worktreeBase !== undefined) {
820
+ if (worktreePath !== undefined && input.worktreeBase !== undefined)
733
821
  await cleanupUnitWorktree(input.worktreeBase, worktreePath);
734
- }
735
822
  return {
736
823
  unitId: request.unitId,
737
824
  ok: false,
@@ -739,21 +826,27 @@ async function dispatchJournaledAttempt(input) {
739
826
  error: `unit "${attemptId}" could not journal its dispatch row (nothing was dispatched): ${message(err)}`,
740
827
  };
741
828
  }
742
- // Ids/status only — instructions and results are workflow-authored content
743
- // and stay out of the events stream (07 P1-B).
744
- appendEvent({
745
- eventType: "workflow_unit_started",
746
- ref: ctx.workflowRef,
747
- metadata: { runId: ctx.runId, stepId: plan.stepId, unitId: attemptId },
748
- });
749
- const outcome = redactUnitOutcome(await dispatchUnit(request, dispatcher), request.sensitiveValues ?? []);
829
+ request = {
830
+ ...request,
831
+ attempt: durableAttempt.attempt,
832
+ dispatchId: durableAttempt.dispatch_id,
833
+ };
834
+ const dispatched = await dispatchUnit(request, dispatcher);
835
+ // Credential and passthrough values are intentionally sampled only AFTER
836
+ // the default dispatcher has authorized/lowered the frozen request and
837
+ // materialized credentials at its terminal dispatch boundary. Custom test
838
+ // dispatchers receive the same post-dispatch journal scrub.
839
+ const sensitiveValues = collectWorkflowDispatchSensitiveValues({
840
+ ...(request.frozenTarget.kind === "command" ? { runner: request.frozenTarget.runner } : {}),
841
+ ...(request.sensitiveValues ? { sensitiveValues: request.sensitiveValues } : {}),
842
+ }, request.env);
843
+ const outcome = redactUnitOutcome(dispatched, sensitiveValues);
750
844
  const finishedAt = new Date().toISOString();
751
845
  // A dispatched unit's outcome is NEVER silently discarded. The single-driver
752
- // guard lives at the ROW level (`finishUnitFromDispatch`: still `running`,
753
- // still this dispatch's `started_at`): a stolen run's new driver re-dispatches
754
- // through insertUnit, which REPLACES the row with a fresh started_at, so the
755
- // stale driver's finish matches nothing and can never clobber the new
756
- // driver's live dispatch. A row that IS still ours is finished with the real
846
+ // guard lives on the append-only attempt row: attempt number, dispatch id,
847
+ // claim holder, and running status must all match. A stale driver's finish
848
+ // therefore cannot clobber a reclaimed or retried dispatch. An attempt that
849
+ // IS still ours is finished with the real
757
850
  // result even when the run went non-active or the lease moved mid-flight —
758
851
  // dropping it would leave the row `running` and make a later resume
759
852
  // re-dispatch side-effecting work that already ran and already spent tokens.
@@ -761,59 +854,12 @@ async function dispatchJournaledAttempt(input) {
761
854
  // lease-guarded in completeWorkflowStep.
762
855
  let journalError;
763
856
  try {
764
- await enqueueUnitWrite(() => withWorkflowRunsRepo((repo) => repo.immediateTransaction((db) => {
765
- const finished = repo.finishUnitFromDispatch({
766
- runId: ctx.runId,
767
- unitId: attemptId,
768
- status: outcome.ok ? "completed" : "failed",
769
- resultJson: journaledUnitResultJson(outcome),
770
- tokens: outcome.tokens ?? null,
771
- failureReason: outcome.failureReason ?? null,
772
- // Harness-native session id (P2): journaled so resume can replay the
773
- // harness's own context cache (e.g. `codex exec resume <id>`).
774
- sessionId: outcome.sessionId ?? null,
775
- finishedAt,
776
- dispatchStartedAt: startedAt,
777
- });
778
- if (!finished) {
779
- if (!repo.getUnit(ctx.runId, attemptId)) {
780
- // The dispatch row vanished (run deleted mid-flight, journal
781
- // tampered): the same journaling-bug contract finishUnit throws
782
- // for — surfaced below as a journal-write failure, never a no-op.
783
- throw new Error(`finishUnit updated no row: no unit "${attemptId}" exists for run "${ctx.runId}". ` +
784
- `The dispatch row this invocation inserted is gone, so the unit's terminal state cannot be journaled.`);
785
- }
786
- // The row exists but is no longer this dispatch's `running` row:
787
- // another engine invocation re-dispatched (or finished) the unit
788
- // after taking the run. Its journal owns the unit now — writing
789
- // would clobber a live dispatch — so the outcome is surfaced
790
- // loudly instead of silently dropped.
791
- warn(`Workflow unit ${attemptId} (run ${ctx.runId}) ${outcome.ok ? "completed" : `failed (${outcome.failureReason ?? "error"})`}, ` +
792
- `but its journal row was re-dispatched by another engine invocation mid-flight — refusing to overwrite ` +
793
- `the new driver's row. This dispatch's result is not journaled.`);
794
- return;
795
- }
796
- const run = repo.getRunById(ctx.runId);
797
- if (run?.status !== "active" ||
798
- (ctx.leaseHolder !== undefined && run.engine_lease_holder !== ctx.leaseHolder)) {
799
- warn(`Workflow unit ${attemptId} (run ${ctx.runId}): the run ` +
800
- `${run?.status !== "active" ? `is now ${run?.status ?? "gone"}` : `lease moved to ${run?.engine_lease_holder ?? "(nobody)"}`} ` +
801
- `while the unit was in flight; its result was journaled so a resume reuses it instead of re-dispatching.`);
802
- }
803
- insertEventStrict(db, {
804
- eventType: "workflow_unit_finished",
805
- ts: finishedAt,
806
- ref: ctx.workflowRef,
807
- metadata: {
808
- runId: ctx.runId,
809
- stepId: plan.stepId,
810
- unitId: attemptId,
811
- status: outcome.ok ? "completed" : "failed",
812
- ...(outcome.failureReason ? { failureReason: outcome.failureReason } : {}),
813
- ...(outcome.tokens !== undefined ? { tokens: outcome.tokens } : {}),
814
- },
815
- });
816
- })));
857
+ await finishJournaledDispatch({
858
+ attempt: input,
859
+ durableAttempt,
860
+ finishedAt,
861
+ outcome,
862
+ });
817
863
  }
818
864
  catch (err) {
819
865
  journalError = err;
@@ -826,23 +872,7 @@ async function dispatchJournaledAttempt(input) {
826
872
  // `git worktree add`, and awaiting it in this unit's scheduler slot made a
827
873
  // finished unit wait out other units' full checkouts before its worker could
828
874
  // claim the next item.
829
- if (worktreePath !== undefined && input.worktreeBase !== undefined) {
830
- const worktreeBase = input.worktreeBase;
831
- input.pendingWorktreeCleanups.push((async () => {
832
- try {
833
- const cleanup = await cleanupUnitWorktree(worktreeBase, worktreePath);
834
- if (cleanup.dirty) {
835
- warn(`Workflow unit ${attemptId} left uncommitted changes in its isolation worktree; retained at ${worktreePath}`);
836
- }
837
- else if (!cleanup.removed) {
838
- warn(`Workflow unit ${attemptId}: could not clean up isolation worktree ${worktreePath}: ${cleanup.error}`);
839
- }
840
- }
841
- catch (err) {
842
- warn(`Workflow unit ${attemptId}: could not clean up isolation worktree ${worktreePath}: ${message(err)}`);
843
- }
844
- })());
845
- }
875
+ queueAttemptWorktreeCleanup(input, worktreePath);
846
876
  // A journal-write failure AFTER a successful dispatch is its own loud
847
877
  // failure class: the unit's work ran (and may have succeeded), but its
848
878
  // terminal state could not be recorded, so the row may be stuck `running`.
@@ -858,6 +888,7 @@ async function dispatchJournaledAttempt(input) {
858
888
  `but its result could not be journaled: ${message(journalError)}`,
859
889
  ...(outcome.tokens !== undefined ? { tokens: outcome.tokens } : {}),
860
890
  ...(outcome.sessionId !== undefined ? { sessionId: outcome.sessionId } : {}),
891
+ ...(outcome.notices ? { notices: outcome.notices } : {}),
861
892
  };
862
893
  }
863
894
  return outcome;
@@ -896,9 +927,10 @@ class UnitTransportError extends Error {
896
927
  async function dispatchUnit(request, dispatcher) {
897
928
  let tokens = 0;
898
929
  let sawUsage = false;
930
+ let loweringNotices;
899
931
  // Harness-native session id revealed by dispatch (P2). Captured across
900
932
  // structured-output retries (last one wins) so it survives into the
901
- // UnitOutcome and gets journaled on the unit row by finishUnit — the seam's
933
+ // UnitOutcome and gets journaled by finishUnitAttempt — the seam's
902
934
  // contract ("stored opportunistically on the unit row for resume").
903
935
  let sessionId;
904
936
  const dispatchOnce = async (feedback) => {
@@ -908,6 +940,7 @@ async function dispatchUnit(request, dispatcher) {
908
940
  tokens +=
909
941
  (result.usage.inputTokens ?? 0) + (result.usage.outputTokens ?? 0) + (result.usage.reasoningTokens ?? 0);
910
942
  }
943
+ loweringNotices = mergeLoweringNotices(loweringNotices, result.notices);
911
944
  // Capture before the ok-check: a failed attempt can still have configured
912
945
  // a session (e.g. codex `session_configured` then a tool crash).
913
946
  if (result.sessionId !== undefined)
@@ -919,6 +952,7 @@ async function dispatchUnit(request, dispatcher) {
919
952
  const captured = () => ({
920
953
  ...(sawUsage ? { tokens } : {}),
921
954
  ...(sessionId !== undefined ? { sessionId } : {}),
955
+ ...(loweringNotices ? { notices: loweringNotices } : {}),
922
956
  });
923
957
  try {
924
958
  if (request.schema) {
@@ -936,7 +970,7 @@ async function dispatchUnit(request, dispatcher) {
936
970
  // can produce a second deployment. Declared `retry:` still applies (the
937
971
  // executor's own loop), because that is a policy the author opted into
938
972
  // per failure reason.
939
- ...(request.exec ? { parse: parseExecJson, maxAttempts: 1 } : {}),
973
+ ...(request.frozenTarget.kind !== "command" ? { parse: parseExecJson, maxAttempts: 1 } : {}),
940
974
  validate: (candidate) => {
941
975
  const errors = validateJsonSchemaSubset(candidate, schema);
942
976
  return errors.length === 0 ? { ok: true, value: candidate } : { ok: false, errors };
@@ -963,7 +997,7 @@ async function dispatchUnit(request, dispatcher) {
963
997
  };
964
998
  }
965
999
  const text = await dispatchOnce();
966
- // Normalize an EMPTY successful output to "no text". `finishUnit` journals
1000
+ // Normalize an EMPTY successful output to "no text". `finishUnitAttempt` journals
967
1001
  // result_json = NULL for a falsy text, so durable-reuse rehydrates NO text
968
1002
  // from the row (unitOutcomeFromRow). Preserving `text: ""` only in this live
969
1003
  // outcome would make the LIVE step artifact ("") diverge from the artifact a
@@ -992,270 +1026,78 @@ async function dispatchUnit(request, dispatcher) {
992
1026
  };
993
1027
  }
994
1028
  }
995
- // ── Env bindings ─────────────────────────────────────────────────────────────
996
- /**
997
- * Resolve every unit `env` ref through the extracted `akm env run` core
998
- * (loadEnv + secret tokens + dangerous-key policy + keys-only audit event).
999
- * Lazily imported so the engine has no env/secret dependency until a
1000
- * workflow actually declares bindings.
1001
- */
1002
- async function resolveEnvBindings(refs) {
1003
- const { resolveEnvBinding } = await import("../../commands/env/env-binding.js");
1004
- const merged = {};
1005
- for (const ref of refs) {
1006
- Object.assign(merged, resolveEnvBinding(ref).values);
1007
- }
1008
- return merged;
1009
- }
1010
1029
  // ── Default dispatcher (production substrate) ───────────────────────────────
1011
1030
  /**
1012
- * Dispatch through akm's existing execution substrate:
1013
- * llm → `chatCompletion` on the profile/default LLM connection
1014
- * agent `executeRunner` `runAgent` (per-harness AgentCommandBuilder)
1015
- * sdk → `executeRunner` → `runOpencodeSdk`
1016
- *
1017
- * Every v3 invocation names a frozen engine; no live profile/default fallback
1018
- * is consulted during dispatch.
1031
+ * Dispatch a frozen engine through the common prepare → lower → dispatch seam.
1032
+ * No live profile/default/model map is consulted, and credentials remain
1033
+ * symbolic until the final dispatch boundary.
1019
1034
  */
1020
- /**
1021
- * Build the platform-agnostic {@link import("../../integrations/agent/builder-shared.js").AgentDispatchRequest}
1022
- * for an agent (CLI) unit from the resolved dispatch request and its final
1023
- * (feedback-augmented) prompt.
1024
- *
1025
- * Threading the unit's output `schema` here is what activates each harness's
1026
- * native structured-output path (plan §"Structured-output normalization"):
1027
- * - Codex (native-schema tier) writes it to a temp file and passes
1028
- * `--output-schema <file>`.
1029
- * - Copilot / Gemini switch stdout to their documented JSON envelope
1030
- * (`--output-format json`) and append their schema-aware prompt directive.
1031
- * - Pi switches to its JSONL event stream (`--mode json`) and appends its
1032
- * directive.
1033
- * Without the schema the argv is byte-identical to the pre-fix plain-prompt
1034
- * shape. The engine's post-hoc `runStructured` validation runs regardless — the
1035
- * harness path constrains/hints, the engine still verifies (constrained output
1036
- * is trusted but verified). The `model` is passed raw so the builder resolves
1037
- * aliases per-harness. Only `prompt` (with any gate feedback already folded in),
1038
- * `model`, and `schema` are engine-derived; `systemPrompt`/`tools`/`cwd` come
1039
- * from the profile/asset, not the workflow unit.
1040
- */
1041
- export function buildAgentDispatchRequest(request, prompt) {
1042
- return {
1043
- prompt,
1044
- ...(request.systemPrompt ? { systemPrompt: request.systemPrompt } : {}),
1045
- ...(request.invocation?.model ? { model: request.invocation.model } : {}),
1046
- ...(request.invocation?.model ? { modelIsExact: true } : {}),
1047
- ...(request.schema ? { schema: request.schema } : {}),
1048
- };
1049
- }
1050
1035
  export const defaultUnitDispatcher = async (request, feedback) => {
1051
- // `exec` units are dispatched FIRST and separately: they name no engine, so
1052
- // none of the engine-resolution below applies. `feedback` is deliberately
1053
- // ignored — a corrective re-prompt is meaningless to a fixed argv, and
1054
- // `dispatchUnit` already pins exec structured output to a single attempt.
1055
- if (request.exec) {
1056
- return runExecUnit({
1057
- unitId: request.unitId,
1058
- exec: request.exec,
1059
- // Worktree isolation supplies `cwd`; otherwise the unit runs in the
1060
- // engine invocation's own working directory.
1061
- baseDir: request.cwd ?? process.cwd(),
1062
- ...(request.env ? { env: request.env } : {}),
1063
- ...(request.execContext ? { context: request.execContext } : {}),
1064
- // A declared `output:` schema is what makes an output-cap overflow fatal:
1065
- // stdout must then parse as exactly one JSON value, which a truncated
1066
- // prefix cannot. Without one, overflow is marked in the artifact and the
1067
- // command's own exit code decides the unit. See `exec-unit.ts`.
1068
- ...(request.schema ? { hasOutputSchema: true } : {}),
1069
- timeoutMs: request.timeoutMs,
1070
- ...(request.signal ? { signal: request.signal } : {}),
1036
+ const frozenTarget = request.frozenTarget;
1037
+ if (frozenTarget.kind === "script") {
1038
+ assertFrozenDirectoryIdentity(frozenTarget.cwdIdentity);
1039
+ if (frozenTarget.executable) {
1040
+ assertFrozenExecutableIdentity(frozenTarget.executable, `unit ${request.unitId} executable`);
1041
+ }
1042
+ const materialized = materializeFrozenScript({
1043
+ sourceRef: frozenTarget.ref,
1044
+ interpreter: frozenTarget.interpreter,
1045
+ extension: frozenTarget.extension,
1046
+ bytesBase64: frozenTarget.bytesBase64,
1047
+ byteLength: frozenTarget.byteLength,
1048
+ sha256: frozenTarget.contentHash,
1071
1049
  });
1072
- }
1073
- if (!request.engine || !request.invocation) {
1074
- return {
1075
- ok: false,
1076
- text: "",
1077
- failureReason: "dispatch_error",
1078
- error: `unit "${request.unitId}" has neither a frozen engine snapshot nor an exec command to dispatch.`,
1079
- };
1080
- }
1081
- const engineRequest = request;
1082
- const prompt = feedback ? `${request.prompt}\n\n${feedback}` : request.prompt;
1083
- const resolved = frozenUnitRunner(engineRequest);
1084
- // `env` bindings can only reach a child process. The agent (CLI) runner
1085
- // spawns one per call, and the sdk runner now injects them for real via the
1086
- // env-keyed opencode server registry (sdk-runner.ts module doc, open seam
1087
- // decision 1 resolved in R2) — but the llm runner has no child at all, so
1088
- // it still fails loudly: an audit event claiming an injection that never
1089
- // reached the unit would be a lie.
1090
- if (request.env && Object.keys(request.env).length > 0 && resolved.kind === "llm") {
1091
- return {
1092
- ok: false,
1093
- text: "",
1094
- failureReason: "env_unsupported",
1095
- error: `unit "${request.unitId}" declares env bindings, which require a child process (agent or sdk runner) — ` +
1096
- `the "llm" runner cannot inject a per-unit child environment.`,
1097
- };
1098
- }
1099
- // Same shape for worktree isolation resolved onto llm through `inherit`:
1100
- // the executor already rejects an EXPLICIT llm+isolation pairing before
1101
- // dispatch, but an inherit unit only reveals its runner here.
1102
- if (request.cwd && resolved.kind === "llm") {
1103
- return {
1104
- ok: false,
1105
- text: "",
1106
- failureReason: "isolation_unsupported",
1107
- error: `unit "${request.unitId}" declares isolation: worktree but resolved to the "llm" runner, ` +
1108
- `which has no working directory to isolate. Use the agent or sdk runner for isolated units.`,
1109
- };
1110
- }
1111
- if (resolved.kind === "llm") {
1112
- const { chatCompletion, LlmCallError } = await import("../../llm/client.js");
1113
- const connection = resolved.connection;
1114
1050
  try {
1115
- const messages = request.systemPrompt
1116
- ? [
1117
- { role: "system", content: request.systemPrompt },
1118
- { role: "user", content: prompt },
1119
- ]
1120
- : [{ role: "user", content: prompt }];
1121
- const text = await chatCompletion(connection, messages, {
1051
+ const command = frozenScriptCommand({
1052
+ sourceRef: frozenTarget.ref,
1053
+ interpreter: frozenTarget.interpreter,
1054
+ extension: frozenTarget.extension,
1055
+ bytesBase64: frozenTarget.bytesBase64,
1056
+ byteLength: frozenTarget.byteLength,
1057
+ sha256: frozenTarget.contentHash,
1058
+ }, materialized.file);
1059
+ if (frozenTarget.executable)
1060
+ command[0] = frozenTarget.executable.absolutePath;
1061
+ return await runExecUnit({
1062
+ unitId: request.unitId,
1063
+ exec: {
1064
+ ...frozenTarget.exec,
1065
+ command: command,
1066
+ },
1067
+ baseDir: request.cwd ?? frozenTarget.cwdIdentity.realCwd,
1068
+ ...(request.env ? { env: request.env } : {}),
1069
+ ...(request.execContext ? { context: request.execContext } : {}),
1070
+ ...(request.schema ? { hasOutputSchema: true } : {}),
1122
1071
  timeoutMs: request.timeoutMs,
1123
1072
  ...(request.signal ? { signal: request.signal } : {}),
1124
- // Native structured output where the connection supports it; the
1125
- // executor's subset validator still runs downstream either way.
1126
- ...(request.schema ? { responseSchema: request.schema } : {}),
1127
1073
  });
1128
- return { ok: true, text };
1129
1074
  }
1130
- catch (err) {
1131
- // Map typed LlmCallError codes into the persisted AgentFailureReason
1132
- // taxonomy — the vocabulary `retry.on` is validated against (program
1133
- // schema PROGRAM_RETRY_REASONS) and the journal's failure_reason column
1134
- // speaks. A collapsed out-of-taxonomy value ("llm_error") made the
1135
- // declared failure policy dead for the entire llm runner.
1136
- const failureReason = err instanceof LlmCallError ? llmFailureReasonFor(err.code) : "dispatch_error";
1137
- return { ok: false, text: "", failureReason, error: message(err) };
1075
+ finally {
1076
+ cleanupFrozenScript(materialized);
1138
1077
  }
1139
1078
  }
1140
- const { executeRunner } = await import("../../integrations/agent/runner-dispatch.js");
1141
- const profile = request.invocation.model
1142
- ? { ...resolved.profile, model: request.invocation.model, modelIsExact: true }
1143
- : resolved.profile;
1144
- const result = await executeRunner(resolved.kind === "sdk"
1145
- ? {
1146
- kind: "sdk",
1147
- profile,
1148
- ...(resolved.fallbackConnection ? { fallbackConnection: resolved.fallbackConnection } : {}),
1149
- }
1150
- : { kind: "agent", profile }, prompt, {
1151
- stdio: "captured",
1152
- parseOutput: "text",
1153
- timeoutMs: request.timeoutMs,
1154
- ...(request.env ? { env: request.env } : {}),
1155
- // Worktree isolation: the unit's fresh checkout is the child's cwd —
1156
- // runAgent spawns there; the sdk runner scopes the session to it.
1157
- ...(request.cwd ? { cwd: request.cwd } : {}),
1158
- ...(request.signal ? { signal: request.signal } : {}),
1159
- // Route CLI dispatch through the platform AgentCommandBuilder so model
1160
- // aliases resolve per-harness (P0.5 model routing) AND the unit's output
1161
- // schema reaches the harness's structured-output path (see
1162
- // buildAgentDispatchRequest).
1163
- ...(resolved.kind === "agent" ? { dispatch: buildAgentDispatchRequest(request, prompt) } : {}),
1164
- });
1165
- // Harness result extraction (P2, plan §"The adapter contract" step 3):
1166
- // when the profile's harness declares a `resultExtractor`, normalize the
1167
- // raw stdout into the final answer (+ opportunistic session id) BEFORE the
1168
- // engine's schema validation / retry loop sees it. Only successful agent
1169
- // (CLI) runs are normalized — failures keep the raw stdout for diagnostics,
1170
- // and the default path is byte-identical when no extractor is registered.
1171
- let text = result.stdout;
1172
- let sessionId = result.sessionId;
1173
- if (resolved.kind === "agent" && result.ok) {
1174
- const extractor = await resolveHarnessExtractor(resolved.profile);
1175
- if (extractor) {
1176
- const extraction = extractor(result);
1177
- text = extraction.text;
1178
- if (extraction.sessionId !== undefined)
1179
- sessionId = extraction.sessionId;
1079
+ if (frozenTarget.kind === "shell") {
1080
+ if (frozenTarget.cwdIdentity)
1081
+ assertFrozenDirectoryIdentity(frozenTarget.cwdIdentity);
1082
+ if (frozenTarget.executable) {
1083
+ assertFrozenExecutableIdentity(frozenTarget.executable, `unit ${request.unitId} executable`);
1180
1084
  }
1085
+ const command = [...frozenTarget.exec.command];
1086
+ if (frozenTarget.executable)
1087
+ command[0] = frozenTarget.executable.absolutePath;
1088
+ return runExecUnit({
1089
+ unitId: request.unitId,
1090
+ exec: { ...frozenTarget.exec, command: command },
1091
+ baseDir: request.cwd ?? frozenTarget.cwdIdentity?.realCwd ?? process.cwd(),
1092
+ ...(request.env ? { env: request.env } : {}),
1093
+ ...(request.execContext ? { context: request.execContext } : {}),
1094
+ ...(request.schema ? { hasOutputSchema: true } : {}),
1095
+ timeoutMs: request.timeoutMs,
1096
+ ...(request.signal ? { signal: request.signal } : {}),
1097
+ });
1181
1098
  }
1182
- return {
1183
- ok: result.ok,
1184
- text,
1185
- ...(sessionId !== undefined ? { sessionId } : {}),
1186
- ...(result.reason ? { failureReason: result.reason } : {}),
1187
- ...(result.error ? { error: result.error } : {}),
1188
- ...(result.usage ? { usage: result.usage } : {}),
1189
- };
1099
+ return dispatchWorkflowExecution(request, feedback);
1190
1100
  };
1191
- /**
1192
- * Map a typed {@link import("../../llm/client.js").LlmCallErrorCode} into the
1193
- * persisted `AgentFailureReason` taxonomy (agent/spawn.ts) — the ONLY
1194
- * vocabulary `retry.on` accepts and the journal's `failure_reason` column
1195
- * carries. Exhaustive over the code union (typecheck fails on drift):
1196
- *
1197
- * - `timeout` → `timeout` (wall-clock expiry)
1198
- * - `aborted` → `aborted` (caller/budget cancellation)
1199
- * - `rate_limited` → `llm_rate_limit` (HTTP 429 — the canonical transient)
1200
- * - `parse_error` / `provider_html_error`
1201
- * → `parse_error` (a response arrived but was not the
1202
- * promised JSON)
1203
- * - `network_error` / `provider_error`
1204
- * → `spawn_failed` (the backend could not be reached or
1205
- * could not do the work — the LLM
1206
- * analog of failing to start the
1207
- * child; retryable as a transient)
1208
- */
1209
- export function llmFailureReasonFor(code) {
1210
- switch (code) {
1211
- case "aborted":
1212
- return "aborted";
1213
- case "timeout":
1214
- return "timeout";
1215
- case "rate_limited":
1216
- return "llm_rate_limit";
1217
- case "parse_error":
1218
- case "provider_html_error":
1219
- return "parse_error";
1220
- case "network_error":
1221
- case "provider_error":
1222
- return "spawn_failed";
1223
- }
1224
- }
1225
- /**
1226
- * Resolve the harness `resultExtractor` from the canonical platform frozen
1227
- * from the named engine. Unknown platforms pass raw stdout through unchanged.
1228
- */
1229
- async function resolveHarnessExtractor(profile) {
1230
- const { getHarness } = await import("../../integrations/harnesses/index.js");
1231
- const harness = getHarness(profile.platform ?? profile.name);
1232
- return harness?.resultExtractor;
1233
- }
1234
- /** Reconstruct the existing RunnerSpec substrate from the frozen allowlist only. */
1235
- function frozenUnitRunner(request) {
1236
- const snapshot = request.engine;
1237
- if (snapshot.kind === "llm") {
1238
- return { kind: "llm", connection: materializeFrozenLlm(snapshot, request.invocation) };
1239
- }
1240
- const profile = {
1241
- name: snapshot.name,
1242
- platform: snapshot.platform,
1243
- bin: snapshot.bin,
1244
- args: snapshot.args,
1245
- stdio: "captured",
1246
- envPassthrough: snapshot.envPassthrough,
1247
- parseOutput: "text",
1248
- ...(snapshot.workspace ? { workspace: snapshot.workspace } : {}),
1249
- ...(request.invocation?.model ? { model: request.invocation.model } : {}),
1250
- ...(request.invocation?.model ? { modelIsExact: true } : {}),
1251
- };
1252
- if (snapshot.runnerKind === "agent")
1253
- return { kind: "agent", profile };
1254
- // The catalog is supplied transitively by the work-list only for hashing; the
1255
- // SDK runner receives a frozen fallback copied into the request by its caller.
1256
- const fallback = request.fallbackEngine ? materializeFrozenLlm(request.fallbackEngine, undefined) : undefined;
1257
- return { kind: "sdk", profile, ...(fallback ? { fallbackConnection: fallback } : {}) };
1258
- }
1259
1101
  // ── Small helpers ────────────────────────────────────────────────────────────
1260
1102
  /**
1261
1103
  * Rehydrate a journaled completed unit row into a UnitOutcome (durable-row
@@ -1267,10 +1109,11 @@ function frozenUnitRunner(request) {
1267
1109
  function reuseCompletedUnit(unitId, row, hasSchema) {
1268
1110
  return unitOutcomeFromRow(unitId, row, hasSchema);
1269
1111
  }
1270
- function failedStep(dispatched, reason) {
1112
+ function failedStep(dispatched, reason, notices) {
1271
1113
  return {
1272
1114
  ok: false,
1273
1115
  units: [],
1116
+ ...(notices ? { notices } : {}),
1274
1117
  evidence: { error: reason },
1275
1118
  summary: reason,
1276
1119
  unitsDispatched: dispatched,