okstra 0.173.0 → 0.175.1

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 (402) hide show
  1. package/README.md +18 -9
  2. package/bin/okstra +8 -4
  3. package/dist/cli-registry.d.mts +16 -0
  4. package/dist/cli-registry.mjs +560 -0
  5. package/dist/cli-registry.mjs.map +1 -0
  6. package/dist/commands/execute/agent-prompt.d.mts +1 -0
  7. package/{src → dist}/commands/execute/agent-prompt.mjs +10 -11
  8. package/dist/commands/execute/agent-prompt.mjs.map +1 -0
  9. package/dist/commands/execute/codex-dispatch.d.mts +3 -0
  10. package/dist/commands/execute/codex-dispatch.mjs +6 -0
  11. package/dist/commands/execute/codex-dispatch.mjs.map +1 -0
  12. package/dist/commands/execute/codex-run.d.mts +3 -0
  13. package/{src → dist}/commands/execute/codex-run.mjs +30 -37
  14. package/dist/commands/execute/codex-run.mjs.map +1 -0
  15. package/dist/commands/execute/convergence.d.mts +1 -0
  16. package/{src → dist}/commands/execute/convergence.mjs +15 -16
  17. package/dist/commands/execute/convergence.mjs.map +1 -0
  18. package/dist/commands/execute/error-log.d.mts +1 -0
  19. package/{src → dist}/commands/execute/error-log.mjs +2 -3
  20. package/dist/commands/execute/error-log.mjs.map +1 -0
  21. package/dist/commands/execute/git-reconcile.d.mts +1 -0
  22. package/{src → dist}/commands/execute/git-reconcile.mjs +10 -11
  23. package/dist/commands/execute/git-reconcile.mjs.map +1 -0
  24. package/dist/commands/execute/handoff.d.mts +1 -0
  25. package/{src → dist}/commands/execute/handoff.mjs +10 -11
  26. package/dist/commands/execute/handoff.mjs.map +1 -0
  27. package/dist/commands/execute/incremental-carry.d.mts +1 -0
  28. package/{src → dist}/commands/execute/incremental-carry.mjs +2 -3
  29. package/dist/commands/execute/incremental-carry.mjs.map +1 -0
  30. package/dist/commands/execute/incremental-scope.d.mts +1 -0
  31. package/{src → dist}/commands/execute/incremental-scope.mjs +2 -3
  32. package/dist/commands/execute/incremental-scope.mjs.map +1 -0
  33. package/dist/commands/execute/integrate-stages.d.mts +1 -0
  34. package/{src → dist}/commands/execute/integrate-stages.mjs +10 -11
  35. package/dist/commands/execute/integrate-stages.mjs.map +1 -0
  36. package/dist/commands/execute/pane-title.d.mts +1 -0
  37. package/dist/commands/execute/pane-title.mjs +20 -0
  38. package/dist/commands/execute/pane-title.mjs.map +1 -0
  39. package/dist/commands/execute/plan-items.d.mts +1 -0
  40. package/dist/commands/execute/plan-items.mjs +9 -0
  41. package/dist/commands/execute/plan-items.mjs.map +1 -0
  42. package/dist/commands/execute/plan-validate.d.mts +1 -0
  43. package/dist/commands/execute/plan-validate.mjs +68 -0
  44. package/dist/commands/execute/plan-validate.mjs.map +1 -0
  45. package/dist/commands/execute/plan-verify.d.mts +1 -0
  46. package/{src → dist}/commands/execute/plan-verify.mjs +13 -16
  47. package/dist/commands/execute/plan-verify.mjs.map +1 -0
  48. package/dist/commands/execute/render-bundle.d.mts +3 -0
  49. package/{src → dist}/commands/execute/render-bundle.mjs +65 -61
  50. package/dist/commands/execute/render-bundle.mjs.map +1 -0
  51. package/dist/commands/execute/run.d.mts +24 -0
  52. package/dist/commands/execute/run.mjs +196 -0
  53. package/dist/commands/execute/run.mjs.map +1 -0
  54. package/dist/commands/execute/spawn-followups.d.mts +1 -0
  55. package/{src → dist}/commands/execute/spawn-followups.mjs +6 -7
  56. package/dist/commands/execute/spawn-followups.mjs.map +1 -0
  57. package/dist/commands/execute/team.d.mts +3 -0
  58. package/dist/commands/execute/team.mjs +56 -0
  59. package/dist/commands/execute/team.mjs.map +1 -0
  60. package/dist/commands/execute/token-usage.d.mts +1 -0
  61. package/{src → dist}/commands/execute/token-usage.mjs +2 -3
  62. package/dist/commands/execute/token-usage.mjs.map +1 -0
  63. package/dist/commands/execute/wizard.d.mts +8 -0
  64. package/dist/commands/execute/wizard.mjs +191 -0
  65. package/dist/commands/execute/wizard.mjs.map +1 -0
  66. package/dist/commands/execute/worker-audit-check.d.mts +1 -0
  67. package/{src → dist}/commands/execute/worker-audit-check.mjs +11 -12
  68. package/dist/commands/execute/worker-audit-check.mjs.map +1 -0
  69. package/dist/commands/execute/worker-dispatch.d.mts +7 -0
  70. package/{src → dist}/commands/execute/worker-dispatch.mjs +34 -46
  71. package/dist/commands/execute/worker-dispatch.mjs.map +1 -0
  72. package/dist/commands/execute/worker-state.d.mts +1 -0
  73. package/{src → dist}/commands/execute/worker-state.mjs +15 -16
  74. package/dist/commands/execute/worker-state.mjs.map +1 -0
  75. package/dist/commands/execute/worktree-lookup.d.mts +1 -0
  76. package/{src → dist}/commands/execute/worktree-lookup.mjs +38 -37
  77. package/dist/commands/execute/worktree-lookup.mjs.map +1 -0
  78. package/dist/commands/execute/worktree-status.d.mts +1 -0
  79. package/dist/commands/execute/worktree-status.mjs +115 -0
  80. package/dist/commands/execute/worktree-status.mjs.map +1 -0
  81. package/dist/commands/inspect/code-review.d.mts +1 -0
  82. package/{src → dist}/commands/inspect/code-review.mjs +16 -19
  83. package/dist/commands/inspect/code-review.mjs.map +1 -0
  84. package/dist/commands/inspect/container.d.mts +1 -0
  85. package/{src → dist}/commands/inspect/container.mjs +11 -13
  86. package/dist/commands/inspect/container.mjs.map +1 -0
  87. package/dist/commands/inspect/context-cost.d.mts +1 -0
  88. package/{src → dist}/commands/inspect/context-cost.mjs +11 -13
  89. package/dist/commands/inspect/context-cost.mjs.map +1 -0
  90. package/dist/commands/inspect/design-prep.d.mts +1 -0
  91. package/{src → dist}/commands/inspect/design-prep.mjs +10 -11
  92. package/dist/commands/inspect/design-prep.mjs.map +1 -0
  93. package/dist/commands/inspect/error-report.d.mts +1 -0
  94. package/{src → dist}/commands/inspect/error-report.mjs +11 -13
  95. package/dist/commands/inspect/error-report.mjs.map +1 -0
  96. package/dist/commands/inspect/error-zip.d.mts +1 -0
  97. package/{src → dist}/commands/inspect/error-zip.mjs +11 -12
  98. package/dist/commands/inspect/error-zip.mjs.map +1 -0
  99. package/dist/commands/inspect/log-report.d.mts +1 -0
  100. package/{src → dist}/commands/inspect/log-report.mjs +11 -13
  101. package/dist/commands/inspect/log-report.mjs.map +1 -0
  102. package/dist/commands/inspect/profile-show.d.mts +1 -0
  103. package/{src → dist}/commands/inspect/profile-show.mjs +10 -11
  104. package/dist/commands/inspect/profile-show.mjs.map +1 -0
  105. package/dist/commands/inspect/recap.d.mts +1 -0
  106. package/{src → dist}/commands/inspect/recap.mjs +11 -13
  107. package/dist/commands/inspect/recap.mjs.map +1 -0
  108. package/dist/commands/inspect/resolve-task-key.d.mts +1 -0
  109. package/{src → dist}/commands/inspect/resolve-task-key.mjs +11 -13
  110. package/dist/commands/inspect/resolve-task-key.mjs.map +1 -0
  111. package/dist/commands/inspect/rollup.d.mts +1 -0
  112. package/{src → dist}/commands/inspect/rollup.mjs +11 -13
  113. package/dist/commands/inspect/rollup.mjs.map +1 -0
  114. package/dist/commands/inspect/run-audit.d.mts +1 -0
  115. package/{src → dist}/commands/inspect/run-audit.mjs +11 -12
  116. package/dist/commands/inspect/run-audit.mjs.map +1 -0
  117. package/dist/commands/inspect/set-work-status.d.mts +1 -0
  118. package/{src → dist}/commands/inspect/set-work-status.mjs +11 -13
  119. package/dist/commands/inspect/set-work-status.mjs.map +1 -0
  120. package/dist/commands/inspect/stage-map.d.mts +1 -0
  121. package/dist/commands/inspect/stage-map.mjs +110 -0
  122. package/dist/commands/inspect/stage-map.mjs.map +1 -0
  123. package/dist/commands/inspect/task-list.d.mts +1 -0
  124. package/dist/commands/inspect/task-list.mjs +103 -0
  125. package/dist/commands/inspect/task-list.mjs.map +1 -0
  126. package/dist/commands/inspect/task-show.d.mts +1 -0
  127. package/dist/commands/inspect/task-show.mjs +108 -0
  128. package/dist/commands/inspect/task-show.mjs.map +1 -0
  129. package/dist/commands/inspect/time-report.d.mts +1 -0
  130. package/{src → dist}/commands/inspect/time-report.mjs +11 -13
  131. package/dist/commands/inspect/time-report.mjs.map +1 -0
  132. package/dist/commands/inspect/usage-report.d.mts +1 -0
  133. package/{src → dist}/commands/inspect/usage-report.mjs +11 -13
  134. package/dist/commands/inspect/usage-report.mjs.map +1 -0
  135. package/dist/commands/inspect/user-response.d.mts +1 -0
  136. package/{src → dist}/commands/inspect/user-response.mjs +10 -11
  137. package/dist/commands/inspect/user-response.mjs.map +1 -0
  138. package/dist/commands/inspect/worker-liveness.d.mts +1 -0
  139. package/{src → dist}/commands/inspect/worker-liveness.mjs +11 -13
  140. package/dist/commands/inspect/worker-liveness.mjs.map +1 -0
  141. package/dist/commands/lifecycle/check-project.d.mts +21 -0
  142. package/dist/commands/lifecycle/check-project.mjs +175 -0
  143. package/dist/commands/lifecycle/check-project.mjs.map +1 -0
  144. package/dist/commands/lifecycle/config.d.mts +1 -0
  145. package/dist/commands/lifecycle/config.mjs +369 -0
  146. package/dist/commands/lifecycle/config.mjs.map +1 -0
  147. package/dist/commands/lifecycle/doctor.d.mts +10 -0
  148. package/dist/commands/lifecycle/doctor.mjs +317 -0
  149. package/dist/commands/lifecycle/doctor.mjs.map +1 -0
  150. package/dist/commands/lifecycle/install.d.mts +17 -0
  151. package/dist/commands/lifecycle/install.mjs +1077 -0
  152. package/dist/commands/lifecycle/install.mjs.map +1 -0
  153. package/dist/commands/lifecycle/migrate.d.mts +1 -0
  154. package/{src → dist}/commands/lifecycle/migrate.mjs +10 -11
  155. package/dist/commands/lifecycle/migrate.mjs.map +1 -0
  156. package/dist/commands/lifecycle/model.d.mts +1 -0
  157. package/dist/commands/lifecycle/model.mjs +22 -0
  158. package/dist/commands/lifecycle/model.mjs.map +1 -0
  159. package/dist/commands/lifecycle/paths.d.mts +1 -0
  160. package/dist/commands/lifecycle/paths.mjs +102 -0
  161. package/dist/commands/lifecycle/paths.mjs.map +1 -0
  162. package/dist/commands/lifecycle/preflight.d.mts +1 -0
  163. package/dist/commands/lifecycle/preflight.mjs +124 -0
  164. package/dist/commands/lifecycle/preflight.mjs.map +1 -0
  165. package/dist/commands/lifecycle/setup.d.mts +1 -0
  166. package/dist/commands/lifecycle/setup.mjs +297 -0
  167. package/dist/commands/lifecycle/setup.mjs.map +1 -0
  168. package/dist/commands/lifecycle/uninstall.d.mts +4 -0
  169. package/dist/commands/lifecycle/uninstall.mjs +262 -0
  170. package/dist/commands/lifecycle/uninstall.mjs.map +1 -0
  171. package/dist/commands/manager.d.mts +3 -0
  172. package/{src → dist}/commands/manager.mjs +25 -32
  173. package/dist/commands/manager.mjs.map +1 -0
  174. package/dist/commands/memory/memory.d.mts +1 -0
  175. package/dist/commands/memory/memory.mjs +569 -0
  176. package/dist/commands/memory/memory.mjs.map +1 -0
  177. package/dist/commands/pr/pr.d.mts +33 -0
  178. package/dist/commands/pr/pr.mjs +296 -0
  179. package/dist/commands/pr/pr.mjs.map +1 -0
  180. package/dist/commands/report/agent-activity.d.mts +1 -0
  181. package/{src → dist}/commands/report/agent-activity.mjs +11 -12
  182. package/dist/commands/report/agent-activity.mjs.map +1 -0
  183. package/dist/commands/report/finalize.d.mts +4 -0
  184. package/dist/commands/report/finalize.mjs +64 -0
  185. package/dist/commands/report/finalize.mjs.map +1 -0
  186. package/dist/commands/report/inject-report-index.d.mts +1 -0
  187. package/{src → dist}/commands/report/inject-report-index.mjs +6 -7
  188. package/dist/commands/report/inject-report-index.mjs.map +1 -0
  189. package/dist/commands/report/render-final-report.d.mts +1 -0
  190. package/{src → dist}/commands/report/render-final-report.mjs +6 -7
  191. package/dist/commands/report/render-final-report.mjs.map +1 -0
  192. package/dist/commands/report/render-views.d.mts +1 -0
  193. package/{src → dist}/commands/report/render-views.mjs +6 -7
  194. package/dist/commands/report/render-views.mjs.map +1 -0
  195. package/dist/commands/report/translate.d.mts +1 -0
  196. package/{src → dist}/commands/report/translate.mjs +6 -7
  197. package/dist/commands/report/translate.mjs.map +1 -0
  198. package/dist/lib/helper-scripts.d.mts +3 -0
  199. package/{src → dist}/lib/helper-scripts.mjs +11 -14
  200. package/dist/lib/helper-scripts.mjs.map +1 -0
  201. package/dist/lib/host-registry-client.d.mts +25 -0
  202. package/dist/lib/host-registry-client.mjs +152 -0
  203. package/dist/lib/host-registry-client.mjs.map +1 -0
  204. package/dist/lib/install-assets.d.mts +8 -0
  205. package/{src → dist}/lib/install-assets.mjs +17 -20
  206. package/dist/lib/install-assets.mjs.map +1 -0
  207. package/dist/lib/okstra-dirs.d.mts +4 -0
  208. package/{src → dist}/lib/okstra-dirs.mjs +3 -6
  209. package/dist/lib/okstra-dirs.mjs.map +1 -0
  210. package/dist/lib/paths.d.mts +4 -0
  211. package/dist/lib/paths.mjs +78 -0
  212. package/dist/lib/paths.mjs.map +1 -0
  213. package/dist/lib/proc.d.mts +3 -0
  214. package/dist/lib/proc.mjs +31 -0
  215. package/dist/lib/proc.mjs.map +1 -0
  216. package/dist/lib/python-helper.d.mts +8 -0
  217. package/dist/lib/python-helper.mjs +123 -0
  218. package/dist/lib/python-helper.mjs.map +1 -0
  219. package/dist/lib/runtime-manifest.d.mts +7 -0
  220. package/dist/lib/runtime-manifest.mjs +73 -0
  221. package/dist/lib/runtime-manifest.mjs.map +1 -0
  222. package/dist/lib/skill-catalog.d.mts +4 -0
  223. package/dist/lib/skill-catalog.mjs +50 -0
  224. package/dist/lib/skill-catalog.mjs.map +1 -0
  225. package/dist/lib/types.d.mts +129 -0
  226. package/dist/lib/types.mjs +2 -0
  227. package/dist/lib/types.mjs.map +1 -0
  228. package/dist/lib/version.d.mts +2 -0
  229. package/dist/lib/version.mjs +27 -0
  230. package/dist/lib/version.mjs.map +1 -0
  231. package/docs/architecture/storage-model.md +17 -3
  232. package/docs/architecture.md +12 -22
  233. package/docs/cli.md +29 -8
  234. package/docs/container.md +1 -1
  235. package/docs/contributor-change-matrix.md +3 -3
  236. package/docs/project-structure-overview.md +29 -20
  237. package/docs/task-process/README.md +3 -1
  238. package/docs/task-process/implementation-planning.md +2 -2
  239. package/docs/task-process/implementation.md +1 -1
  240. package/package.json +11 -5
  241. package/runtime/BUILD.json +2 -2
  242. package/runtime/agents/workers/claude-worker.md +3 -2
  243. package/runtime/agents/workers/report-writer-worker.md +3 -1
  244. package/runtime/agents/workers/translator-worker.md +3 -1
  245. package/runtime/bin/lib/okstra/cli.sh +12 -0
  246. package/runtime/bin/lib/okstra/globals.sh +4 -1
  247. package/runtime/bin/lib/okstra/usage.sh +4 -4
  248. package/runtime/bin/okstra-error-log.py +27 -0
  249. package/runtime/bin/okstra-provider-exec.py +245 -16
  250. package/runtime/bin/okstra-trace-cleanup.sh +58 -129
  251. package/runtime/bin/okstra.sh +10 -0
  252. package/runtime/prompts/lead/adapters/cmux.md +2 -0
  253. package/runtime/prompts/lead/convergence.md +27 -18
  254. package/runtime/prompts/lead/okstra-lead-contract.md +4 -2
  255. package/runtime/prompts/lead/plan-body-verification.md +13 -6
  256. package/runtime/prompts/lead/report-writer.md +6 -6
  257. package/runtime/prompts/lead/team-contract.md +1 -1
  258. package/runtime/prompts/profiles/_common-contract.md +2 -2
  259. package/runtime/prompts/profiles/_implementation-executor.md +2 -0
  260. package/runtime/prompts/profiles/_implementation-verifier.md +2 -2
  261. package/runtime/prompts/profiles/change-impact-analysis.md +16 -0
  262. package/runtime/prompts/profiles/error-analysis.md +21 -1
  263. package/runtime/prompts/profiles/feature-analysis.md +16 -0
  264. package/runtime/prompts/profiles/final-verification.md +16 -0
  265. package/runtime/prompts/profiles/implementation-option-selection.md +16 -0
  266. package/runtime/prompts/profiles/implementation-planning.md +32 -9
  267. package/runtime/prompts/profiles/implementation.md +17 -1
  268. package/runtime/prompts/profiles/improvement-discovery.md +16 -0
  269. package/runtime/prompts/profiles/project-analysis.md +16 -0
  270. package/runtime/prompts/profiles/release-handoff.md +5 -1
  271. package/runtime/prompts/profiles/requirements-discovery.md +20 -0
  272. package/runtime/prompts/wizard/prompts.ko.json +18 -0
  273. package/runtime/python/okstra_ctl/adapters/dispatch/__init__.py +1 -6
  274. package/runtime/python/okstra_ctl/adapters/dispatch/cli_wrapper.py +8 -0
  275. package/runtime/python/okstra_ctl/adapters/dispatch/cmux.py +8 -0
  276. package/runtime/python/okstra_ctl/adapters/dispatch/native_team.py +13 -0
  277. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/adapter.py +2 -2
  278. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +25 -5
  279. package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +2 -2
  280. package/runtime/python/okstra_ctl/adapters/hosts/external/adapter.py +4 -1
  281. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +4 -4
  282. package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +2 -2
  283. package/runtime/python/okstra_ctl/adapters/hosts/kimi/adapter.py +2 -2
  284. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +79 -11
  285. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +100 -25
  286. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +59 -14
  287. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +55 -9
  288. package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +51 -12
  289. package/runtime/python/okstra_ctl/agent_activity.py +5 -1
  290. package/runtime/python/okstra_ctl/agent_invocation.py +608 -14
  291. package/runtime/python/okstra_ctl/agent_prompt_cli.py +249 -50
  292. package/runtime/python/okstra_ctl/application/resolve_assignment.py +53 -12
  293. package/runtime/python/okstra_ctl/assignment_environment.py +102 -0
  294. package/runtime/python/okstra_ctl/assignment_resolver.py +790 -0
  295. package/runtime/python/okstra_ctl/attempt_evidence.py +342 -0
  296. package/runtime/python/okstra_ctl/conformance.py +68 -0
  297. package/runtime/python/okstra_ctl/convergence.py +409 -3
  298. package/runtime/python/okstra_ctl/convergence_engine.py +248 -15
  299. package/runtime/python/okstra_ctl/convergence_store.py +170 -1
  300. package/runtime/python/okstra_ctl/dispatch_core.py +1181 -102
  301. package/runtime/python/okstra_ctl/dispatch_state.py +835 -22
  302. package/runtime/python/okstra_ctl/doctor.py +16 -2
  303. package/runtime/python/okstra_ctl/domain/host.py +32 -1
  304. package/runtime/python/okstra_ctl/domain/provider.py +212 -6
  305. package/runtime/python/okstra_ctl/domain/role.py +125 -0
  306. package/runtime/python/okstra_ctl/domain/wizard/interaction.py +13 -0
  307. package/runtime/python/okstra_ctl/domain/worker_exec.py +39 -1
  308. package/runtime/python/okstra_ctl/domain/worker_role.py +13 -3
  309. package/runtime/python/okstra_ctl/domain/worker_stream.py +24 -0
  310. package/runtime/python/okstra_ctl/error_log_write.py +9 -0
  311. package/runtime/python/okstra_ctl/error_report.py +21 -1
  312. package/runtime/python/okstra_ctl/execution_identity.py +529 -0
  313. package/runtime/python/okstra_ctl/execution_manifest.py +1281 -0
  314. package/runtime/python/okstra_ctl/execution_mutation_audit.py +619 -0
  315. package/runtime/python/okstra_ctl/final_report_schema.py +5 -4
  316. package/runtime/python/okstra_ctl/index.py +6 -2
  317. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +43 -3
  318. package/runtime/python/okstra_ctl/legacy_model_selection.py +490 -0
  319. package/runtime/python/okstra_ctl/model_cli.py +278 -0
  320. package/runtime/python/okstra_ctl/model_defaults.py +46 -0
  321. package/runtime/python/okstra_ctl/model_discovery.py +26 -7
  322. package/runtime/python/okstra_ctl/model_pool.py +501 -0
  323. package/runtime/python/okstra_ctl/models.py +47 -27
  324. package/runtime/python/okstra_ctl/mutation_recovery.py +293 -0
  325. package/runtime/python/okstra_ctl/pane_reclaim.py +13 -22
  326. package/runtime/python/okstra_ctl/pane_title.py +151 -0
  327. package/runtime/python/okstra_ctl/ports/host_model.py +48 -17
  328. package/runtime/python/okstra_ctl/ports/worker_dispatch.py +4 -0
  329. package/runtime/python/okstra_ctl/registry/provider_registry.py +8 -4
  330. package/runtime/python/okstra_ctl/render.py +78 -10
  331. package/runtime/python/okstra_ctl/render_final_report.py +34 -19
  332. package/runtime/python/okstra_ctl/report_contract.py +39 -1
  333. package/runtime/python/okstra_ctl/report_finalize.py +84 -9
  334. package/runtime/python/okstra_ctl/report_html/render.py +1 -0
  335. package/runtime/python/okstra_ctl/role_requirements.py +217 -0
  336. package/runtime/python/okstra_ctl/run.py +1250 -260
  337. package/runtime/python/okstra_ctl/run_index_row.py +7 -1
  338. package/runtime/python/okstra_ctl/schema_excerpt.py +1 -1
  339. package/runtime/python/okstra_ctl/scope_provenance.py +1 -1
  340. package/runtime/python/okstra_ctl/session.py +69 -12
  341. package/runtime/python/okstra_ctl/team.py +51 -25
  342. package/runtime/python/okstra_ctl/time_report.py +38 -4
  343. package/runtime/python/okstra_ctl/tmux.py +19 -149
  344. package/runtime/python/okstra_ctl/usage_identity.py +86 -0
  345. package/runtime/python/okstra_ctl/usage_report.py +15 -3
  346. package/runtime/python/okstra_ctl/wizard.py +1253 -62
  347. package/runtime/python/okstra_ctl/worker_audit_ledger.py +68 -65
  348. package/runtime/python/okstra_ctl/worker_request.py +9 -1
  349. package/runtime/python/okstra_ctl/worker_runner.py +130 -5
  350. package/runtime/python/okstra_ctl/workers.py +7 -5
  351. package/runtime/python/okstra_ctl/worktree.py +69 -3
  352. package/runtime/python/okstra_ctl/write_policy.py +571 -0
  353. package/runtime/python/okstra_token_usage/cli.py +1 -1
  354. package/runtime/python/okstra_token_usage/collect.py +66 -6
  355. package/runtime/schemas/convergence-groups-v2.0.schema.json +128 -0
  356. package/runtime/schemas/execution-manifest-v2.schema.json +340 -0
  357. package/runtime/schemas/final-report-v2.0.schema.json +25 -0
  358. package/runtime/skills/okstra-run/SKILL.md +9 -9
  359. package/runtime/skills/okstra-setup/references/project-config.md +11 -0
  360. package/runtime/templates/report-writer-prompt-preamble.md +2 -0
  361. package/runtime/templates/reports/final-report-v2.template.md +7 -1
  362. package/runtime/templates/reports/html/base.template.html +21 -0
  363. package/runtime/templates/reports/html/i18n/en.json +4 -0
  364. package/runtime/templates/reports/html/i18n/ko.json +4 -0
  365. package/runtime/templates/reports/settings.template.json +0 -24
  366. package/runtime/templates/worker-prompt-preamble.md +6 -2
  367. package/runtime/validators/lib/fixtures.sh +98 -33
  368. package/runtime/validators/validate-implementation-plan-stages.py +63 -3
  369. package/runtime/validators/validate-run.py +443 -508
  370. package/runtime/validators/validate_session_conformance.py +26 -8
  371. package/runtime/bin/okstra-subagent-reclaim.sh +0 -26
  372. package/runtime/schemas/final-report-v1.0.schema.json +0 -6366
  373. package/runtime/templates/reports/final-report.template.md +0 -1258
  374. package/src/cli-registry.mjs +0 -532
  375. package/src/commands/execute/codex-dispatch.mjs +0 -10
  376. package/src/commands/execute/plan-items.mjs +0 -9
  377. package/src/commands/execute/plan-validate.mjs +0 -68
  378. package/src/commands/execute/run.mjs +0 -208
  379. package/src/commands/execute/team.mjs +0 -63
  380. package/src/commands/execute/wizard.mjs +0 -167
  381. package/src/commands/inspect/stage-map.mjs +0 -98
  382. package/src/commands/inspect/task-list.mjs +0 -97
  383. package/src/commands/inspect/task-show.mjs +0 -100
  384. package/src/commands/lifecycle/check-project.mjs +0 -187
  385. package/src/commands/lifecycle/config.mjs +0 -385
  386. package/src/commands/lifecycle/doctor.mjs +0 -308
  387. package/src/commands/lifecycle/install.mjs +0 -1172
  388. package/src/commands/lifecycle/paths.mjs +0 -104
  389. package/src/commands/lifecycle/preflight.mjs +0 -125
  390. package/src/commands/lifecycle/setup.mjs +0 -333
  391. package/src/commands/lifecycle/uninstall.mjs +0 -279
  392. package/src/commands/memory/memory.mjs +0 -524
  393. package/src/commands/pr/pr.mjs +0 -261
  394. package/src/commands/report/finalize.mjs +0 -64
  395. package/src/lib/host-registry-client.mjs +0 -176
  396. package/src/lib/paths.mjs +0 -86
  397. package/src/lib/proc.mjs +0 -31
  398. package/src/lib/python-helper.mjs +0 -142
  399. package/src/lib/runtime-manifest.mjs +0 -70
  400. package/src/lib/skill-catalog.mjs +0 -53
  401. package/src/lib/version.mjs +0 -20
  402. /package/{src → dist}/commands/pr/default.md +0 -0
@@ -10,18 +10,18 @@ from pathlib import Path
10
10
  from typing import Any, Mapping, Sequence
11
11
 
12
12
  from . import cmux
13
- from . import tmux
14
13
  from .dispatch_state import (
15
14
  BACKEND_CLI_WRAPPER,
16
15
  BACKEND_CMUX_PANE,
17
16
  BACKEND_MIXED,
18
- BACKEND_TMUX_PANE,
19
17
  append_worker_dispatch as _append_worker_dispatch,
18
+ build_dispatch_id as _build_dispatch_id,
20
19
  DispatchError,
21
20
  WorkerJob,
22
21
  dispatch_completion_paths as _completion_paths,
23
22
  dispatch_mode as _dispatch_mode,
24
23
  dispatch_result_path as _result_path_for_worker,
24
+ dispatch_session_id as _dispatch_session_id,
25
25
  LIVENESS_AUDIT_HEARTBEAT,
26
26
  LIVENESS_WRAPPER_STATUS,
27
27
  load_json_object as _load_json_object,
@@ -39,10 +39,36 @@ from .dispatch_state import (
39
39
  TEARDOWN_BEFORE_TERMINAL_REASON,
40
40
  utc_now as _utc_now,
41
41
  validate_dispatch_prompts as _validate_dispatch_prompts,
42
+ v2_worker_state_key as _v2_worker_state_key,
43
+ worker_execution_identity as _worker_execution_identity,
42
44
  worker_jobs_from_file as _worker_jobs_from_file,
43
45
  worker_state as _worker_state,
44
46
  worktree_path as _worktree_path,
45
47
  )
48
+ from .assignment_resolver import (
49
+ AssignmentEnvironment,
50
+ AssignmentResolutionError,
51
+ RoleInstance,
52
+ resolve_model_assignment,
53
+ )
54
+ from .domain.host import HostSessionContext
55
+ from .domain.provider import HostModelBinding, ServedModelAttestation
56
+ from .domain.worker_exec import SERVED_MODEL_MISMATCH_EXIT_CODE
57
+ from .execution_identity import Attempt, Invocation, RoleExecution, model_spec_digest
58
+ from .execution_manifest import (
59
+ finish_attempt_mutation,
60
+ read_execution_manifest,
61
+ record_invocation_attempt,
62
+ )
63
+ from .execution_mutation_audit import (
64
+ ExecutionMutationAudit,
65
+ MutationAuditResult,
66
+ MutationSnapshot,
67
+ assert_compatible_batch,
68
+ )
69
+ from .convergence_store import write_json_atomic
70
+ from .assignment_environment import load_assignment_context
71
+ from .model_pool import ModelPool
46
72
  from .error_log_write import append_observed
47
73
  from .lead_events import LeadEvent, append_lead_event
48
74
  from .initial_prompt_materialization import (
@@ -52,6 +78,7 @@ from .initial_prompt_materialization import (
52
78
  PromptDeliveryMode,
53
79
  materialize_initial_prompts,
54
80
  )
81
+ from .agent_invocation import AgentInvocationError, materialize_retry_invocation
55
82
  from .path_hints import hydrate_active_run_context
56
83
  from .schema_excerpt import bundle_excerpt_path, excerpt_version_skew
57
84
  from .seeding import installed_version
@@ -72,6 +99,15 @@ from .worker_prompt_headers import (
72
99
  )
73
100
  from .worker_artifact_paths import audit_sidecar_rel
74
101
  from .wrapper_status import read_wrapper_status, status_path_for_prompt
102
+ from .worker_request import verifier_extra_dirs
103
+ from .write_policy import (
104
+ WriteEnforcement,
105
+ WritePolicy,
106
+ build_invocation_write_contract,
107
+ planned_paths_from_run_manifest,
108
+ write_enforcement_from_payload,
109
+ write_policy_from_payload,
110
+ )
75
111
 
76
112
 
77
113
  MAX_WORKER_ATTEMPTS = 2
@@ -84,6 +120,102 @@ _WRAPPER_TIMEOUT_EXIT_CODE = 124
84
120
  _WRAPPER_LOG_TAIL_BYTES = 800
85
121
 
86
122
 
123
+ @dataclass(frozen=True)
124
+ class RevalidatedBinding:
125
+ role_execution_ref: str
126
+ binding: HostModelBinding
127
+ model_spec_digest: str
128
+
129
+
130
+ def revalidate_role_execution(
131
+ role_execution: RoleExecution,
132
+ pool: ModelPool,
133
+ host: AssignmentEnvironment,
134
+ ) -> RevalidatedBinding:
135
+ """Recompute one selected model binding without inspecting unrelated rows."""
136
+ if (
137
+ role_execution.model_ref is None
138
+ or role_execution.binding is None
139
+ or role_execution.model_spec_digest is None
140
+ ):
141
+ raise DispatchError("model binding changed: role execution is unbound")
142
+ try:
143
+ assignment = resolve_model_assignment(
144
+ instance=RoleInstance(
145
+ role_execution.role,
146
+ role_execution.role,
147
+ role_execution.ordinal,
148
+ ),
149
+ model_ref=role_execution.model_ref,
150
+ pool=pool,
151
+ host=HostSessionContext(
152
+ host_id=host.host_descriptor.id,
153
+ entry_mode="spawn-process",
154
+ available_functions=frozenset(),
155
+ interaction_surface="role-execution-dispatch",
156
+ ),
157
+ environment=host,
158
+ )
159
+ binding = assignment.binding
160
+ if binding is None:
161
+ raise AssignmentResolutionError("selected model has no worker binding")
162
+ digest = model_spec_digest(pool.resolve(role_execution.model_ref), binding)
163
+ except (ValueError, RuntimeError) as exc:
164
+ raise DispatchError(f"model binding changed: {exc}") from exc
165
+ identity_matches = (
166
+ assignment.provider_id == role_execution.provider
167
+ and assignment.model_id == role_execution.model_id
168
+ and binding == role_execution.binding
169
+ and digest == role_execution.model_spec_digest
170
+ )
171
+ if not identity_matches:
172
+ raise DispatchError("model binding changed for selected role execution")
173
+ return RevalidatedBinding(role_execution.role_execution_ref, binding, digest)
174
+
175
+
176
+ def dispatch_revalidated_role_execution(
177
+ role_execution: RoleExecution,
178
+ pool: ModelPool,
179
+ host: AssignmentEnvironment,
180
+ dispatch,
181
+ ):
182
+ """Run a dispatch callback only after the pure binding gate succeeds."""
183
+ binding = revalidate_role_execution(role_execution, pool, host)
184
+ return dispatch(binding)
185
+
186
+
187
+ def verify_served_model(
188
+ role_execution: RoleExecution,
189
+ attestation: ServedModelAttestation,
190
+ *,
191
+ pool: ModelPool,
192
+ ) -> ServedModelAttestation:
193
+ """Reject observed substitution while preserving unobservable attempts."""
194
+ if attestation.level == "unknown":
195
+ return attestation
196
+ if role_execution.model_ref is None or not attestation.normalized_model_ref:
197
+ raise DispatchError("served model differs from selected model")
198
+ try:
199
+ selected = pool.resolve(role_execution.model_ref)
200
+ observed = pool.resolve(attestation.normalized_model_ref)
201
+ except ValueError as exc:
202
+ raise DispatchError("served model differs from selected model") from exc
203
+ expected_level = "channel" if observed.version_kind == "channel" else "exact"
204
+ if attestation.level != expected_level:
205
+ raise DispatchError("served model attestation level is inconsistent")
206
+ if selected.version_kind == "pinned":
207
+ if str(selected.model_ref) != str(observed.model_ref):
208
+ raise DispatchError("served model differs from selected model")
209
+ return attestation
210
+ if (
211
+ selected.provider_id != observed.provider_id
212
+ or not selected.channel_family
213
+ or selected.channel_family != observed.channel_family
214
+ ):
215
+ raise DispatchError("served model channel family differs")
216
+ return attestation
217
+
218
+
87
219
  @dataclass(frozen=True)
88
220
  class WorkerHandle:
89
221
  job: WorkerJob
@@ -92,6 +224,11 @@ class WorkerHandle:
92
224
  status_sidecar_path: Path | None
93
225
  degraded_from: str
94
226
  running_process: subprocess.Popen[str] | None = None
227
+ mutation_snapshot: MutationSnapshot | None = None
228
+ mutation_policies: tuple[WritePolicy, ...] = ()
229
+ mutation_retry_allowed: bool = True
230
+ write_policy_digest: str = ""
231
+ write_enforcement: WriteEnforcement | None = None
95
232
 
96
233
 
97
234
  @dataclass(frozen=True)
@@ -203,6 +340,9 @@ def build_dispatch_plan(
203
340
  requested_workers,
204
341
  options,
205
342
  )
343
+ # Ahead of the per-prompt checks: a batch the lead must split is a plan-shape
344
+ # problem, and reporting a prompt-header error for a doomed batch buries it.
345
+ _validate_implementation_phase_order(jobs)
206
346
  _validate_dispatch_prompts(manifest, active_context, jobs)
207
347
  _reject_stale_schema_excerpt(project_root, manifest, jobs)
208
348
  return DispatchPlan(
@@ -219,12 +359,14 @@ def build_dispatch_plan(
219
359
 
220
360
  def dispatch_plan(plan: DispatchPlan, *, wait: bool = True) -> int:
221
361
  _validate_report_writer_isolation(plan.jobs)
362
+ _validate_implementation_phase_order(plan.jobs)
363
+ _revalidate_plan_role_executions(plan)
222
364
  if wait:
223
365
  pane_backends = sorted(
224
366
  {
225
367
  job.backend
226
368
  for job in plan.jobs
227
- if job.backend in (BACKEND_TMUX_PANE, BACKEND_CMUX_PANE)
369
+ if job.backend == BACKEND_CMUX_PANE
228
370
  }
229
371
  )
230
372
  if pane_backends:
@@ -250,12 +392,44 @@ def dispatch_cli_wrapper_plan(plan: DispatchPlan) -> int:
250
392
  if any(job.backend != BACKEND_CLI_WRAPPER for job in plan.jobs):
251
393
  raise DispatchError("concurrent CLI dispatch requires cli-wrapper jobs only")
252
394
  _validate_report_writer_isolation(plan.jobs)
395
+ _validate_implementation_phase_order(plan.jobs)
396
+ _revalidate_plan_role_executions(plan)
253
397
  _record_dispatch_facts(plan.team_state_path, _dispatch_mode(plan.jobs))
254
398
  final_codes = _dispatch_cli_wrapper_batch(plan, plan.jobs)
255
399
  return next((final_codes[job.worker_id] for job in plan.jobs
256
400
  if final_codes.get(job.worker_id, 0) != 0), 0)
257
401
 
258
402
 
403
+ def _revalidate_job_role_execution(plan: DispatchPlan, job: WorkerJob) -> None:
404
+ manifest = read_execution_manifest(plan.manifest_path)
405
+ executions = {
406
+ row.role_execution_ref: row for row in manifest.role_executions
407
+ }
408
+ context = load_assignment_context(
409
+ host_runtime=_require_string(plan.manifest, "leadRuntime"),
410
+ terminal_backend=_require_string(plan.manifest, "terminalBackend"),
411
+ execution_provider_ids=(job.provider,),
412
+ )
413
+ role_execution = executions.get(job.role_execution_ref)
414
+ if (
415
+ role_execution is None
416
+ or role_execution.participant_ref != job.participant_ref
417
+ or role_execution.execution_label != job.execution_label
418
+ ):
419
+ raise DispatchError("worker job role execution does not match run manifest")
420
+ revalidate_role_execution(
421
+ role_execution,
422
+ context.pool,
423
+ context.environment,
424
+ )
425
+
426
+
427
+ def _revalidate_plan_role_executions(plan: DispatchPlan) -> None:
428
+ for job in plan.jobs:
429
+ if job.has_execution_identity:
430
+ _revalidate_job_role_execution(plan, job)
431
+
432
+
259
433
  def _validate_report_writer_isolation(jobs: Sequence[WorkerJob]) -> None:
260
434
  if (
261
435
  any(job.worker_id == REPORT_WRITER_WORKER_ID for job in jobs)
@@ -267,6 +441,55 @@ def _validate_report_writer_isolation(jobs: Sequence[WorkerJob]) -> None:
267
441
  )
268
442
 
269
443
 
444
+ def _validate_implementation_phase_order(jobs: Sequence[WorkerJob]) -> None:
445
+ """Keep the executor and the verifiers in two dispatches, never one batch.
446
+
447
+ A verifier reads the diff the executor wrote, so `implementation.md` binds
448
+ its contract to the window "between Executor stage completion and the first
449
+ verifier dispatch". Nothing enforced that ordering: one
450
+ `okstra team dispatch --workers claude,codex` started both at once and the
451
+ verifier observed base HEAD instead of the stage diff, which is what its
452
+ result then reported on.
453
+
454
+ The refusal names the worker IDs on both sides because the second dispatch
455
+ has to drop the executor's ID: `worker_prompt_policy` re-materializes that
456
+ ID as the executor every time, so a `--workers` list that keeps it is
457
+ refused again and a message saying only "split the batch" would loop.
458
+
459
+ `build_dispatch_plan` is the only call every production dispatch takes, and
460
+ the one `--dry-run` stops at, so it is what makes the preview refuse. The
461
+ two dispatch entry points are public and take a caller-assembled plan, so
462
+ they check it again there rather than trust their input.
463
+ """
464
+ executors = [
465
+ job.worker_id for job in jobs if job.audience == "implementation-executor"
466
+ ]
467
+ verifiers = [
468
+ job.worker_id for job in jobs if job.audience == "implementation-verifier"
469
+ ]
470
+ if executors and verifiers:
471
+ executor_ids = ", ".join(executors)
472
+ raise DispatchError(
473
+ "an implementation batch may not hold the executor and a verifier: "
474
+ f"{_named_role(executors, 'executor')}, "
475
+ f"{_named_role(verifiers, 'verifier')}. "
476
+ "Dispatch the executor alone, settle it with `okstra team await`, "
477
+ f"then dispatch the verifiers with `--workers` omitting {executor_ids} "
478
+ "— that worker ID materializes as the executor again, so a second "
479
+ "batch holding it is refused too. The split is what lets a verifier "
480
+ "observe the stage diff instead of base HEAD"
481
+ )
482
+
483
+
484
+ def _named_role(worker_ids: Sequence[str], role: str) -> str:
485
+ """`verifier codex` for one, `verifiers codex, antigravity` for several.
486
+
487
+ A roster that adds antigravity puts two verifiers in the same refusal, and
488
+ a singular label there reads as one worker named "codex, antigravity".
489
+ """
490
+ return f"{role}{'s' if len(worker_ids) > 1 else ''} {', '.join(worker_ids)}"
491
+
492
+
270
493
  def _dispatch_cli_wrapper_batch(
271
494
  plan: DispatchPlan,
272
495
  jobs: Sequence[WorkerJob],
@@ -274,15 +497,60 @@ def _dispatch_cli_wrapper_batch(
274
497
  """Run one concurrent batch through all retries before the next dependency."""
275
498
  pending = [(job, 1) for job in jobs]
276
499
  final_codes: dict[str, int] = {}
500
+ baseline_snapshot: MutationSnapshot | None = None
277
501
  while pending:
278
502
  active: list[tuple[WorkerHandle, int]] = []
503
+ draft_contracts = tuple(
504
+ _canonical_write_contract(plan, job) if job.has_execution_identity else None
505
+ for job, _attempt in pending
506
+ )
507
+ draft_policies = tuple(
508
+ contract[0] for contract in draft_contracts if contract is not None
509
+ )
510
+ if draft_policies:
511
+ assert_compatible_batch(draft_policies)
512
+ prepared = [
513
+ (_prepare_job_attempt(plan, job, attempt), attempt)
514
+ for job, attempt in pending
515
+ ]
516
+ contracts = tuple(
517
+ _persisted_write_contract(plan, job) for job, _ in prepared
518
+ )
519
+ policies = tuple(
520
+ contract[0] for contract in contracts if contract is not None
521
+ )
522
+ needs_batch = any(
523
+ contract is not None and contract[1].mutation_audit == "batch"
524
+ for contract in contracts
525
+ )
526
+ if needs_batch and baseline_snapshot is None:
527
+ baseline_snapshot = _mutation_snapshot(
528
+ plan, policies, tuple(job for job, _ in prepared)
529
+ )
530
+ snapshot = baseline_snapshot if needs_batch else None
279
531
  starting_job: WorkerJob | None = None
280
532
  starting_attempt = 1
281
533
  try:
282
- for job, attempt in pending:
534
+ for (job, attempt), contract in zip(prepared, contracts, strict=True):
283
535
  starting_job = job
284
536
  starting_attempt = attempt
285
- active.append((_spawn_cli_job_nonblocking(plan, job, attempt), attempt))
537
+ handle = _spawn_cli_job_nonblocking(plan, job, attempt)
538
+ handle = replace(
539
+ handle,
540
+ mutation_snapshot=snapshot,
541
+ mutation_policies=policies,
542
+ write_policy_digest=contract[0].digest if contract else "",
543
+ write_enforcement=contract[1] if contract else None,
544
+ )
545
+ active.append((handle, attempt))
546
+ _record_dispatch(
547
+ plan.team_state_path, handle, attempt, "running", ""
548
+ )
549
+ _append_event(
550
+ plan,
551
+ "worker-dispatched",
552
+ _attempt_details(job, attempt, handle),
553
+ )
286
554
  except (OSError, DispatchError) as exc:
287
555
  if starting_job is not None:
288
556
  _abort_cli_batch_start(
@@ -290,7 +558,9 @@ def _dispatch_cli_wrapper_batch(
290
558
  active,
291
559
  starting_job,
292
560
  starting_attempt,
293
- pending,
561
+ prepared,
562
+ snapshot,
563
+ policies,
294
564
  exc,
295
565
  )
296
566
  raise
@@ -307,17 +577,24 @@ def _dispatch_cli_wrapper_batch(
307
577
  next_round: list[tuple[WorkerJob, int]] = []
308
578
  for handle, attempt, outcome in outcomes:
309
579
  job = handle.job
310
- _finish_attempt(plan, job, attempt, outcome)
311
- if _worker_terminal_status(plan.team_state_path, job.worker_id) == "completed":
580
+ retry_allowed = _finish_attempt(
581
+ plan,
582
+ job,
583
+ attempt,
584
+ outcome,
585
+ snapshot=handle.mutation_snapshot,
586
+ policies=handle.mutation_policies,
587
+ )
588
+ if _job_terminal_status(plan, job, attempt) == "completed":
312
589
  final_codes[job.worker_id] = 0
313
590
  continue
314
- if _should_retry(outcome, attempt):
591
+ if _should_retry(outcome, attempt) and retry_allowed:
315
592
  _append_event(
316
593
  plan,
317
594
  "worker-retry-scheduled",
318
595
  _retry_details(job, attempt, outcome),
319
596
  )
320
- next_round.append((job, attempt + 1))
597
+ next_round.append((_job_for_next_attempt(job), attempt + 1))
321
598
  continue
322
599
  final_codes[job.worker_id] = outcome.returncode or 1
323
600
  pending = next_round
@@ -330,6 +607,8 @@ def _abort_cli_batch_start(
330
607
  failed_job: WorkerJob,
331
608
  failed_attempt: int,
332
609
  pending: Sequence[tuple[WorkerJob, int]],
610
+ snapshot: MutationSnapshot | None,
611
+ policies: Sequence[WritePolicy],
333
612
  error: Exception,
334
613
  ) -> None:
335
614
  reason = f"concurrent CLI batch launch failed: {error}"
@@ -341,15 +620,11 @@ def _abort_cli_batch_start(
341
620
  process = handle.running_process
342
621
  if process is not None:
343
622
  process.wait()
344
- _transition_worker_status(
345
- plan.team_state_path, handle.job.worker_id, "error", reason
346
- )
623
+ _transition_job_status(plan, handle.job, "error", reason)
347
624
  _update_dispatch_status(
348
625
  plan.team_state_path, handle.job, attempt, "error", reason
349
626
  )
350
- _transition_worker_status(
351
- plan.team_state_path, failed_job.worker_id, "error", reason
352
- )
627
+ _transition_job_status(plan, failed_job, "error", reason)
353
628
  if not _update_dispatch_status(
354
629
  plan.team_state_path,
355
630
  failed_job,
@@ -357,12 +632,19 @@ def _abort_cli_batch_start(
357
632
  "error",
358
633
  reason,
359
634
  ):
635
+ failed_contract = _persisted_write_contract(plan, failed_job)
360
636
  failed_handle = WorkerHandle(
361
637
  failed_job,
362
638
  "",
363
639
  None,
364
640
  status_path_for_prompt(failed_job.prompt_path),
365
641
  "",
642
+ mutation_snapshot=snapshot,
643
+ mutation_policies=tuple(policies),
644
+ write_policy_digest=(
645
+ failed_contract[0].digest if failed_contract else ""
646
+ ),
647
+ write_enforcement=(failed_contract[1] if failed_contract else None),
366
648
  )
367
649
  _record_dispatch(
368
650
  plan.team_state_path,
@@ -378,18 +660,21 @@ def _abort_cli_batch_start(
378
660
  for job, attempt in pending:
379
661
  if (job.worker_id, attempt) in closed:
380
662
  continue
381
- _transition_worker_status(
382
- plan.team_state_path, job.worker_id, "error", reason
383
- )
663
+ _transition_job_status(plan, job, "error", reason)
384
664
  if not _update_dispatch_status(
385
665
  plan.team_state_path, job, attempt, "error", reason
386
666
  ):
667
+ contract = _persisted_write_contract(plan, job)
387
668
  handle = WorkerHandle(
388
669
  job,
389
670
  "",
390
671
  None,
391
672
  status_path_for_prompt(job.prompt_path),
392
673
  "",
674
+ mutation_snapshot=snapshot,
675
+ mutation_policies=tuple(policies),
676
+ write_policy_digest=contract[0].digest if contract else "",
677
+ write_enforcement=contract[1] if contract else None,
393
678
  )
394
679
  _record_dispatch(
395
680
  plan.team_state_path,
@@ -398,6 +683,20 @@ def _abort_cli_batch_start(
398
683
  "error",
399
684
  reason,
400
685
  )
686
+ for job, attempt in pending:
687
+ if not job.has_execution_identity:
688
+ continue
689
+ outcome = WorkerOutcome(
690
+ returncode=1,
691
+ missing_completion_paths=_missing_completion_paths(job),
692
+ pane_id="",
693
+ status_sidecar_path=status_path_for_prompt(job.prompt_path),
694
+ timeout=False,
695
+ terminal_stage="launch-failed",
696
+ degraded_from="",
697
+ )
698
+ mutation = _audit_attempt(job, outcome, snapshot, policies)
699
+ _finish_manifest_attempt(plan, job, attempt, mutation, None)
401
700
 
402
701
 
403
702
  def await_dispatches(
@@ -443,6 +742,15 @@ def _jobs_from_roster(
443
742
  options,
444
743
  team_state=team_state,
445
744
  )
745
+ if selected == ["translator"]:
746
+ return [_translator_job_from_reservation(
747
+ project_root,
748
+ workspace_root,
749
+ manifest,
750
+ team_state,
751
+ active_context,
752
+ options,
753
+ )]
446
754
  _mark_roster_skips(project_root, manifest, team_state, selected, requested_workers, options)
447
755
  worker_facts, prompt_paths = _materialize_roster_prompts(
448
756
  project_root,
@@ -479,6 +787,18 @@ def _select_workers(
479
787
  recommended = _string_list(manifest.get("recommendedWorkers"))
480
788
  if not recommended:
481
789
  raise DispatchError("run manifest has no recommendedWorkers entries")
790
+ if tuple(requested_workers) == ("translator",):
791
+ provider = _canonical_translator_provider(manifest)
792
+ if provider is None:
793
+ raise DispatchError(
794
+ "translator dispatch requires a canonical translator role execution"
795
+ )
796
+ if provider not in options.supported_worker_wrappers:
797
+ raise DispatchError(
798
+ f"unsupported {options.unsupported_worker_label} translator provider: "
799
+ f"{provider}"
800
+ )
801
+ return ["translator"]
482
802
  if options.cli_wrapper_assignments_only:
483
803
  return _select_cli_wrapper_assignments(
484
804
  recommended,
@@ -510,6 +830,178 @@ def _select_workers(
510
830
  return selected
511
831
 
512
832
 
833
+ def _canonical_translator_provider(manifest: Mapping[str, Any]) -> str | None:
834
+ execution = _canonical_translator_execution(manifest)
835
+ return _string_value(execution.get("provider")) if execution else None
836
+
837
+
838
+ def _canonical_translator_execution(
839
+ manifest: Mapping[str, Any],
840
+ ) -> Mapping[str, Any] | None:
841
+ assignments = manifest.get("invocationAssignments")
842
+ assignment = (
843
+ assignments.get("translator") if isinstance(assignments, Mapping) else None
844
+ )
845
+ if not isinstance(assignment, Mapping):
846
+ return None
847
+ provider = _string_value(assignment.get("provider"))
848
+ model_execution = _string_value(assignment.get("modelExecutionValue"))
849
+ runner = _string_value(assignment.get("runner"))
850
+ if not provider or not model_execution or runner != BACKEND_CLI_WRAPPER:
851
+ return None
852
+ role_executions = manifest.get("roleExecutions")
853
+ matches = [
854
+ row
855
+ for row in role_executions
856
+ if isinstance(row, Mapping)
857
+ and row.get("role") == "translator"
858
+ and row.get("provider") == provider
859
+ and isinstance(row.get("binding"), Mapping)
860
+ and row["binding"].get("runner") == runner
861
+ and row["binding"].get("resolvedExecutionValue") == model_execution
862
+ ] if isinstance(role_executions, list) else []
863
+ return matches[0] if len(matches) == 1 else None
864
+
865
+
866
+ _TRANSLATOR_RESERVATION_KEYS = {
867
+ "schemaVersion", "executionIdentityVersion", "invocationId",
868
+ "assignmentRef", "audience", "dispatchKind", "promptPath",
869
+ "metadataPath", "invocationRef", "participantRef",
870
+ "roleExecutionRef", "dutyId", "attempt",
871
+ }
872
+
873
+
874
+ def _translator_job_from_reservation(
875
+ project_root: Path,
876
+ workspace_root: Path,
877
+ manifest: Mapping[str, Any],
878
+ team_state: Mapping[str, Any],
879
+ active_context: Mapping[str, Any],
880
+ options: _BuildOptions,
881
+ ) -> WorkerJob:
882
+ execution = _canonical_translator_execution(manifest)
883
+ if execution is None:
884
+ raise DispatchError(
885
+ "translator dispatch requires a canonical translator role execution"
886
+ )
887
+ contract = manifest.get("agentContract")
888
+ if not isinstance(contract, Mapping):
889
+ raise DispatchError("translator dispatch requires an agent contract")
890
+ reservation_root = _resolve_required_path(
891
+ project_root, contract, "invocationReservationRootPath"
892
+ )
893
+ dispatched_ids = {
894
+ str(row.get("dispatchId") or "")
895
+ for collection in (
896
+ team_state.get("workerDispatches") or [],
897
+ team_state.get("agentDispatches") or [],
898
+ )
899
+ for row in collection
900
+ if isinstance(row, Mapping)
901
+ }
902
+ candidates: list[Mapping[str, Any]] = []
903
+ for path in sorted(reservation_root.glob("*.json")):
904
+ reservation = _load_json_object(path, "agent invocation reservation")
905
+ if (
906
+ set(reservation) == _TRANSLATOR_RESERVATION_KEYS
907
+ and reservation.get("schemaVersion") == "2.0"
908
+ and reservation.get("executionIdentityVersion") == 2
909
+ and reservation.get("assignmentRef") == "translator"
910
+ and reservation.get("audience") == "translator"
911
+ and reservation.get("dispatchKind") == "translator"
912
+ and reservation.get("dutyId") == "translator"
913
+ and reservation.get("participantRef") == execution.get("participantRef")
914
+ and reservation.get("roleExecutionRef") == execution.get("roleExecutionRef")
915
+ and _build_dispatch_id(
916
+ str(reservation.get("invocationId") or ""),
917
+ int(reservation.get("attempt") or 0),
918
+ ) not in dispatched_ids
919
+ ):
920
+ candidates.append(reservation)
921
+ if len(candidates) != 1:
922
+ raise DispatchError(
923
+ "translator dispatch requires exactly one canonical invocation reservation"
924
+ )
925
+ reservation = candidates[0]
926
+ prompt_path = _resolve_project_path(
927
+ project_root, _require_string(reservation, "promptPath")
928
+ )
929
+ metadata_path = _resolve_project_path(
930
+ project_root, _require_string(reservation, "metadataPath")
931
+ )
932
+ expected_metadata = prompt_path.with_name(prompt_path.name + ".meta.json")
933
+ if metadata_path != expected_metadata:
934
+ raise DispatchError("translator invocation metadata path is not prompt-adjacent")
935
+ metadata = _load_json_object(metadata_path, "agent invocation metadata")
936
+ invocation = _invocation_job_fields(metadata, prompt_path, manifest)
937
+ expected = {
938
+ "invocationId": invocation["invocation_id"],
939
+ "invocationRef": invocation["invocation_ref"],
940
+ "participantRef": invocation["participant_ref"],
941
+ "roleExecutionRef": invocation["role_execution_ref"],
942
+ "dutyId": invocation["duty_id"],
943
+ "attempt": invocation["attempt"],
944
+ }
945
+ if any(reservation.get(key) != value for key, value in expected.items()):
946
+ raise DispatchError("translator reservation and invocation metadata differ")
947
+ if (
948
+ invocation["audience"] != "translator"
949
+ or invocation["assignment_ref"] != "translator"
950
+ or metadata.get("dispatchKind") != "translator"
951
+ ):
952
+ raise DispatchError("translator invocation identity is invalid")
953
+ result_path, worker_result_path = _translator_prompt_outputs(
954
+ project_root, prompt_path
955
+ )
956
+ if result_path == worker_result_path:
957
+ raise DispatchError(
958
+ "translator result and standard worker result paths must differ"
959
+ )
960
+ provider = _require_string(execution, "provider")
961
+ binding = execution.get("binding")
962
+ if not isinstance(binding, Mapping):
963
+ raise DispatchError("translator role execution has no binding")
964
+ return WorkerJob(
965
+ worker_id="translator",
966
+ provider=provider,
967
+ backend=options.default_backend,
968
+ project_root=project_root,
969
+ model_execution_value=_require_string(binding, "resolvedExecutionValue"),
970
+ wrapper_path=_resolve_wrapper(provider, workspace_root, options),
971
+ prompt_path=prompt_path,
972
+ result_path=result_path,
973
+ worker_result_path=worker_result_path,
974
+ completion_paths=(result_path, worker_result_path),
975
+ worktree_path=_worktree_path(manifest, active_context),
976
+ role="translator",
977
+ idle_timeout_seconds=options.idle_timeout_seconds,
978
+ dispatch_kind="translator",
979
+ **invocation,
980
+ )
981
+
982
+
983
+ def _translator_prompt_outputs(
984
+ project_root: Path, prompt_path: Path,
985
+ ) -> tuple[Path, Path]:
986
+ try:
987
+ lines = prompt_path.read_text(encoding="utf-8").splitlines()
988
+ except OSError as exc:
989
+ raise DispatchError(f"cannot read translator prompt: {exc}") from exc
990
+ values: list[str] = []
991
+ for header in ("Result Path", "Worker Result Path"):
992
+ prefix = f"**{header}:** "
993
+ matches = [line[len(prefix):].strip() for line in lines if line.startswith(prefix)]
994
+ if len(matches) != 1 or not matches[0]:
995
+ raise DispatchError(
996
+ f"translator prompt requires exactly one non-empty {prefix.strip()} header"
997
+ )
998
+ values.append(matches[0])
999
+ return (
1000
+ _resolve_project_path(project_root, values[0]),
1001
+ _resolve_project_path(project_root, values[1]),
1002
+ )
1003
+
1004
+
513
1005
  def _select_cli_wrapper_assignments(
514
1006
  recommended: Sequence[str],
515
1007
  requested_workers: Sequence[str],
@@ -588,7 +1080,7 @@ def _job_from_roster_worker(
588
1080
  ) -> WorkerJob:
589
1081
  result_path = _resolve_project_path(project_root, fact.result_path)
590
1082
  metadata = _agent_metadata(prompt_path)
591
- invocation = _invocation_job_fields(metadata, prompt_path)
1083
+ invocation = _invocation_job_fields(metadata, prompt_path, manifest)
592
1084
  return WorkerJob(
593
1085
  worker_id=fact.worker_id,
594
1086
  provider=fact.provider,
@@ -608,6 +1100,7 @@ def _job_from_roster_worker(
608
1100
  role=fact.role,
609
1101
  idle_timeout_seconds=options.idle_timeout_seconds,
610
1102
  dispatch_kind=options.dispatch_kind,
1103
+ session_id=_dispatch_session_id(fact.provider),
611
1104
  **invocation,
612
1105
  )
613
1106
 
@@ -622,6 +1115,7 @@ def _agent_metadata(prompt_path: Path) -> Mapping[str, Any] | None:
622
1115
  def _invocation_job_fields(
623
1116
  metadata: Mapping[str, Any] | None,
624
1117
  prompt_path: Path,
1118
+ manifest: Mapping[str, Any],
625
1119
  ) -> dict[str, Any]:
626
1120
  if metadata is None:
627
1121
  return {}
@@ -632,7 +1126,7 @@ def _invocation_job_fields(
632
1126
  host_model_value = assignment.get("hostModelValue")
633
1127
  if host_model_value is not None and not isinstance(host_model_value, str):
634
1128
  raise DispatchError("agent invocation hostModelValue is invalid")
635
- return {
1129
+ fields = {
636
1130
  "invocation_id": _require_string(metadata, "invocationId"),
637
1131
  "audience": _require_string(metadata, "audience"),
638
1132
  "assignment_ref": _require_string(metadata, "assignmentRef"),
@@ -647,6 +1141,37 @@ def _invocation_job_fields(
647
1141
  "host_model_value": host_model_value,
648
1142
  "enforcement_mode": "core-pre-dispatch",
649
1143
  }
1144
+ schema = metadata.get("schemaVersion")
1145
+ identity_version = metadata.get("executionIdentityVersion")
1146
+ if schema == 1 and identity_version is None:
1147
+ return fields
1148
+ if schema != "2.0" or identity_version != 2:
1149
+ raise DispatchError("agent invocation metadata mixes v1 and v2 identity")
1150
+ participant_ref = _require_string(metadata, "participantRef")
1151
+ role_execution_ref = _require_string(metadata, "roleExecutionRef")
1152
+ role_executions = manifest.get("roleExecutions")
1153
+ execution = next((
1154
+ row for row in role_executions
1155
+ if isinstance(row, Mapping)
1156
+ and row.get("roleExecutionRef") == role_execution_ref
1157
+ ), None) if isinstance(role_executions, list) else None
1158
+ if execution is None or execution.get("participantRef") != participant_ref:
1159
+ raise DispatchError("agent invocation role execution does not match run manifest")
1160
+ execution_label = _require_string(metadata, "executionLabel")
1161
+ if execution.get("executionLabel") != execution_label:
1162
+ raise DispatchError("agent invocation execution label does not match run manifest")
1163
+ attempt = metadata.get("attempt")
1164
+ if not isinstance(attempt, int) or isinstance(attempt, bool) or attempt < 1:
1165
+ raise DispatchError("agent invocation v2 attempt must be positive")
1166
+ fields.update({
1167
+ "participant_ref": participant_ref,
1168
+ "role_execution_ref": role_execution_ref,
1169
+ "execution_label": execution_label,
1170
+ "duty_id": _require_string(metadata, "dutyId"),
1171
+ "invocation_ref": _require_string(metadata, "invocationRef"),
1172
+ "attempt": attempt,
1173
+ })
1174
+ return fields
650
1175
 
651
1176
 
652
1177
  def _materialize_roster_prompts(
@@ -668,7 +1193,7 @@ def _materialize_roster_prompts(
668
1193
  project_root=project_root,
669
1194
  run_manifest_path=manifest_path,
670
1195
  runtime_root=workspace_root,
671
- delivery_mode=_delivery_mode(options.default_backend),
1196
+ delivery_mode=PromptDeliveryMode.EAGER_INCLUDE,
672
1197
  workers=tuple(
673
1198
  InitialPromptWorkerRequest(fact.worker_id, fact.model)
674
1199
  for fact in worker_facts
@@ -696,12 +1221,6 @@ def _roster_worker_facts(
696
1221
  )
697
1222
 
698
1223
 
699
- def _delivery_mode(default_backend: str) -> PromptDeliveryMode:
700
- if default_backend == BACKEND_TMUX_PANE:
701
- return PromptDeliveryMode.LAZY_PATH_REFERENCE
702
- return PromptDeliveryMode.EAGER_INCLUDE
703
-
704
-
705
1224
  def _jobs_from_file(
706
1225
  project_root: Path,
707
1226
  workspace_root: Path,
@@ -726,34 +1245,50 @@ def _jobs_from_file(
726
1245
 
727
1246
 
728
1247
  def _spawn_job(plan: DispatchPlan, job: WorkerJob, attempt: int) -> WorkerHandle:
729
- _transition_worker_status(
730
- plan.team_state_path, job.worker_id, "in-progress", "",
731
- model_execution_value=job.model_execution_value,
1248
+ job = _prepare_job_attempt(plan, job, attempt)
1249
+ contract = _persisted_write_contract(plan, job)
1250
+ policies = (contract[0],) if contract else ()
1251
+ snapshot = None
1252
+ if contract is not None and contract[1].mutation_audit == "batch":
1253
+ snapshot_path = _mutation_snapshot_path(job)
1254
+ if snapshot_path.is_file():
1255
+ snapshot = MutationSnapshot.from_payload(
1256
+ _load_json_object(snapshot_path, "mutation audit snapshot")
1257
+ )
1258
+ else:
1259
+ snapshot = _mutation_snapshot(plan, policies, (job,))
1260
+ handle = replace(
1261
+ _start_job(plan, job),
1262
+ mutation_snapshot=snapshot,
1263
+ mutation_policies=policies,
1264
+ write_policy_digest=contract[0].digest if contract else "",
1265
+ write_enforcement=contract[1] if contract else None,
732
1266
  )
733
- handle = _start_job(plan, job)
734
1267
  _record_dispatch(plan.team_state_path, handle, attempt, "running", "")
735
1268
  _append_event(plan, "worker-dispatched", _attempt_details(job, attempt, handle))
736
1269
  if handle.completed_process is not None:
737
1270
  outcome = _outcome_from_completed(handle)
738
- _finish_attempt(plan, job, attempt, outcome)
1271
+ retry_allowed = _finish_attempt(
1272
+ plan,
1273
+ job,
1274
+ attempt,
1275
+ outcome,
1276
+ snapshot=snapshot,
1277
+ policies=policies,
1278
+ )
1279
+ handle = replace(handle, mutation_retry_allowed=retry_allowed)
739
1280
  return handle
740
1281
 
741
1282
 
742
1283
  def _spawn_cli_job_nonblocking(
743
1284
  plan: DispatchPlan, job: WorkerJob, attempt: int,
744
1285
  ) -> WorkerHandle:
745
- _transition_worker_status(
746
- plan.team_state_path, job.worker_id, "in-progress", "",
747
- model_execution_value=job.model_execution_value,
748
- )
749
1286
  process: subprocess.Popen[str] | None = None
750
1287
  try:
751
1288
  process = subprocess.Popen(job.command, cwd=plan.project_root, text=True)
752
1289
  handle = WorkerHandle(
753
1290
  job, "", None, status_path_for_prompt(job.prompt_path), "", process
754
1291
  )
755
- _record_dispatch(plan.team_state_path, handle, attempt, "running", "")
756
- _append_event(plan, "worker-dispatched", _attempt_details(job, attempt, handle))
757
1292
  return handle
758
1293
  except (OSError, DispatchError):
759
1294
  if process is not None and process.poll() is None:
@@ -762,16 +1297,219 @@ def _spawn_cli_job_nonblocking(
762
1297
  raise
763
1298
 
764
1299
 
1300
+ def _prepare_job_attempt(
1301
+ plan: DispatchPlan, job: WorkerJob, attempt: int
1302
+ ) -> WorkerJob:
1303
+ if job.has_execution_identity:
1304
+ _revalidate_job_role_execution(plan, job)
1305
+ job = _job_for_attempt(job, attempt)
1306
+ _record_execution_attempt(plan, job)
1307
+ _transition_job_status(
1308
+ plan,
1309
+ job,
1310
+ "in-progress",
1311
+ "",
1312
+ model_execution_value=job.model_execution_value,
1313
+ )
1314
+ return job
1315
+
1316
+
1317
+ def _persisted_write_contract(
1318
+ plan: DispatchPlan, job: WorkerJob
1319
+ ) -> tuple[WritePolicy, WriteEnforcement] | None:
1320
+ if not job.has_execution_identity:
1321
+ return None
1322
+ manifest = read_execution_manifest(plan.manifest_path)
1323
+ invocation = next(
1324
+ (
1325
+ row for row in manifest.invocations
1326
+ if row.invocation_ref == job.invocation_ref
1327
+ ),
1328
+ None,
1329
+ )
1330
+ if invocation is None:
1331
+ raise DispatchError("worker invocation write policy was not persisted")
1332
+ try:
1333
+ enforcement = write_enforcement_from_payload(
1334
+ invocation.write_enforcement
1335
+ )
1336
+ policy = replace(
1337
+ write_policy_from_payload(invocation.write_policy),
1338
+ maximum_boundary_precision=enforcement.boundary_precision,
1339
+ )
1340
+ except (TypeError, ValueError) as exc:
1341
+ raise DispatchError(f"persisted worker write policy is invalid: {exc}") from exc
1342
+ if policy.digest != invocation.write_policy_digest:
1343
+ raise DispatchError("persisted worker write policy digest changed")
1344
+ return policy, enforcement
1345
+
1346
+
1347
+ def _mutation_snapshot(
1348
+ plan: DispatchPlan,
1349
+ policies: Sequence[WritePolicy],
1350
+ jobs: Sequence[WorkerJob],
1351
+ ) -> MutationSnapshot:
1352
+ sidecars = tuple(_mutation_snapshot_path(job) for job in jobs)
1353
+ snapshot = ExecutionMutationAudit().snapshot(
1354
+ policies,
1355
+ orchestrator_paths=(
1356
+ plan.manifest_path,
1357
+ plan.team_state_path,
1358
+ Path(f"{plan.team_state_path}.lock"),
1359
+ plan.lead_events_path,
1360
+ plan.lead_events_path.with_name(
1361
+ f".{plan.lead_events_path.name}.lock"
1362
+ ),
1363
+ *sidecars,
1364
+ ),
1365
+ )
1366
+ for path in sidecars:
1367
+ write_json_atomic(path, snapshot.to_payload())
1368
+ return snapshot
1369
+
1370
+
1371
+ def _mutation_snapshot_path(job: WorkerJob) -> Path:
1372
+ return job.prompt_path.with_suffix(job.prompt_path.suffix + ".mutation-audit.json")
1373
+
1374
+
1375
+ def _record_execution_attempt(plan: DispatchPlan, job: WorkerJob) -> None:
1376
+ task_key = _require_string(plan.manifest, "taskKey")
1377
+ write_policy, write_enforcement = _canonical_write_contract(plan, job)
1378
+ record_invocation_attempt(
1379
+ plan.manifest_path,
1380
+ Invocation(
1381
+ invocation_ref=job.invocation_ref,
1382
+ participant_ref=job.participant_ref,
1383
+ role_execution_ref=job.role_execution_ref,
1384
+ source_invocation_ref=None,
1385
+ recovery_ref=None,
1386
+ duty_id=job.duty_id,
1387
+ dispatch_kind=job.dispatch_kind,
1388
+ round=_dispatch_round(job.dispatch_kind),
1389
+ input_digest=job.prompt_digest,
1390
+ write_policy=write_policy.to_payload(),
1391
+ write_policy_digest=write_policy.digest,
1392
+ write_enforcement=write_enforcement.to_payload(),
1393
+ ),
1394
+ Attempt(
1395
+ invocation_ref=job.invocation_ref,
1396
+ attempt=job.attempt,
1397
+ started_at=_utc_now(),
1398
+ finished_at=None,
1399
+ status="started",
1400
+ result_path=None,
1401
+ error_path=None,
1402
+ change_summary={},
1403
+ evidence_seal_ref=None,
1404
+ ),
1405
+ task_key=task_key,
1406
+ )
1407
+
1408
+
1409
+ def _canonical_write_contract(
1410
+ plan: DispatchPlan, job: WorkerJob
1411
+ ) -> tuple[WritePolicy, WriteEnforcement]:
1412
+ manifest = read_execution_manifest(plan.manifest_path)
1413
+ execution = next(
1414
+ (
1415
+ row for row in manifest.role_executions
1416
+ if row.role_execution_ref == job.role_execution_ref
1417
+ ),
1418
+ None,
1419
+ )
1420
+ if execution is None or execution.binding is None:
1421
+ raise DispatchError("worker write policy has no role execution binding")
1422
+ capability = execution.binding.worker_write_capability
1423
+ if capability is None:
1424
+ raise DispatchError("worker write policy has no runner write capability")
1425
+ artifacts = _worker_artifact_paths(plan, job)
1426
+ worktree = Path(job.worktree_path) if job.worktree_path else None
1427
+ try:
1428
+ return build_invocation_write_contract(
1429
+ role=execution.role,
1430
+ project_root=plan.project_root,
1431
+ worktree=worktree,
1432
+ artifact_paths=artifacts,
1433
+ maximum_precision=capability.max_boundary_precision,
1434
+ planned_paths=(
1435
+ planned_paths_from_run_manifest(plan.project_root, plan.manifest)
1436
+ if execution.role == "implementer"
1437
+ else ()
1438
+ ),
1439
+ auxiliary_roots=verifier_extra_dirs(execution.role),
1440
+ validated_auxiliary_roots=verifier_extra_dirs(execution.role),
1441
+ )
1442
+ except (OSError, ValueError) as exc:
1443
+ raise DispatchError(f"worker write policy is invalid: {exc}") from exc
1444
+
1445
+
1446
+ def _worker_artifact_paths(plan: DispatchPlan, job: WorkerJob) -> tuple[Path, ...]:
1447
+ active_context = _load_optional_json(
1448
+ plan.project_root, plan.manifest.get("activeRunContextPath")
1449
+ )
1450
+ paths = {
1451
+ job.result_path,
1452
+ job.worker_result_path,
1453
+ *job.completion_paths,
1454
+ Path(audit_sidecar_rel(str(job.worker_result_path))),
1455
+ status_path_for_prompt(job.prompt_path),
1456
+ job.prompt_path.with_suffix(job.prompt_path.suffix + ".log"),
1457
+ }
1458
+ error_logs = active_context.get("errorLogs")
1459
+ if isinstance(error_logs, Mapping):
1460
+ sidecars = error_logs.get("sidecarsByWorkerId")
1461
+ if isinstance(sidecars, Mapping):
1462
+ value = _string_value(sidecars.get(job.worker_id))
1463
+ if value:
1464
+ paths.add(_resolve_project_path(plan.project_root, value))
1465
+ return tuple(sorted(paths, key=str))
1466
+
1467
+
1468
+
1469
+
1470
+ def _job_for_attempt(job: WorkerJob, attempt: int) -> WorkerJob:
1471
+ if attempt == job.attempt:
1472
+ return job
1473
+ try:
1474
+ retry = materialize_retry_invocation(
1475
+ job.prompt_metadata_path,
1476
+ project_root=job.project_root,
1477
+ attempt=attempt,
1478
+ )
1479
+ except AgentInvocationError as exc:
1480
+ raise DispatchError(f"cannot materialize retry invocation: {exc}") from exc
1481
+ if retry.invocation_ref != job.invocation_ref:
1482
+ raise DispatchError("retry invocation reference changed")
1483
+ return replace(
1484
+ job,
1485
+ prompt_path=retry.prompt_path,
1486
+ prompt_metadata_path=retry.metadata_path,
1487
+ invocation_id=retry.invocation_id,
1488
+ attempt=retry.attempt,
1489
+ )
1490
+
1491
+
1492
+ def _dispatch_round(dispatch_kind: str) -> int:
1493
+ prefix = "reverify-r"
1494
+ if not dispatch_kind.startswith(prefix):
1495
+ return 1
1496
+ suffix = dispatch_kind[len(prefix):]
1497
+ if not suffix.isdigit() or int(suffix) < 1:
1498
+ raise DispatchError(f"invalid reverify dispatch kind: {dispatch_kind}")
1499
+ return int(suffix)
1500
+
1501
+
765
1502
  def _dispatch_job_with_retry(plan: DispatchPlan, job: WorkerJob) -> int:
766
1503
  for attempt in range(1, MAX_WORKER_ATTEMPTS + 1):
767
1504
  handle = _spawn_job(plan, job, attempt)
768
1505
  if handle.completed_process is None:
769
1506
  return await_dispatches(plan, timeout_seconds=None)
770
1507
  outcome = _outcome_from_completed(handle)
771
- if _worker_terminal_status(plan.team_state_path, job.worker_id) == "completed":
1508
+ if _job_terminal_status(plan, job, attempt) == "completed":
772
1509
  return 0
773
- if _should_retry(outcome, attempt):
1510
+ if _should_retry(outcome, attempt) and handle.mutation_retry_allowed:
774
1511
  _append_event(plan, "worker-retry-scheduled", _retry_details(job, attempt, outcome))
1512
+ job = _job_for_next_attempt(job)
775
1513
  continue
776
1514
  return outcome.returncode or 1
777
1515
  return 1
@@ -780,8 +1518,6 @@ def _dispatch_job_with_retry(plan: DispatchPlan, job: WorkerJob) -> int:
780
1518
  def _start_job(plan: DispatchPlan, job: WorkerJob) -> WorkerHandle:
781
1519
  if job.backend == BACKEND_CLI_WRAPPER:
782
1520
  return _run_cli_wrapper(plan, job, "")
783
- if job.backend == BACKEND_TMUX_PANE:
784
- return _start_tmux_or_degrade(plan, job)
785
1521
  if job.backend == BACKEND_CMUX_PANE:
786
1522
  return _start_cmux_or_degrade(plan, job)
787
1523
  raise DispatchError(f"unsupported worker backend: {job.backend}")
@@ -797,7 +1533,11 @@ def _start_cmux_or_degrade(plan: DispatchPlan, job: WorkerJob) -> WorkerHandle:
797
1533
  workspace=workspace,
798
1534
  cwd=plan.project_root,
799
1535
  command=job.command,
800
- title=f"{job.worker_id}-worker",
1536
+ title=(
1537
+ job.execution_label
1538
+ if job.has_execution_identity
1539
+ else f"{job.worker_id}-worker"
1540
+ ),
801
1541
  owned_surface_ids=_opened_cmux_surfaces(plan.team_state_path),
802
1542
  )
803
1543
  except (RuntimeError, OSError, subprocess.SubprocessError):
@@ -829,23 +1569,6 @@ def _opened_cmux_surfaces(team_state_path: Path) -> tuple[str, ...]:
829
1569
  return tuple(opened)
830
1570
 
831
1571
 
832
- def _start_tmux_or_degrade(plan: DispatchPlan, job: WorkerJob) -> WorkerHandle:
833
- lead_pane = tmux.resolve_caller_pane()
834
- if not lead_pane:
835
- return _run_cli_wrapper(plan, job, BACKEND_TMUX_PANE)
836
- try:
837
- pane_id = tmux.split_worker_pane(
838
- target_pane=lead_pane,
839
- cwd=plan.project_root,
840
- command=job.command,
841
- title=f"{job.worker_id}-worker",
842
- run_dir=_run_dir(plan),
843
- )
844
- except RuntimeError:
845
- return _run_cli_wrapper(plan, job, BACKEND_TMUX_PANE)
846
- return WorkerHandle(job, pane_id, None, status_path_for_prompt(job.prompt_path), "")
847
-
848
-
849
1572
  def _refuse_when_cmux_is_walled_off() -> None:
850
1573
  """Stop rather than degrade when a sandbox stands between okstra and cmux.
851
1574
 
@@ -933,16 +1656,17 @@ def _advance_running_dispatches(plan: DispatchPlan, records: Sequence[Mapping[st
933
1656
  if status is None or not status.is_terminal:
934
1657
  continue
935
1658
  outcome = _outcome_from_status(record, status)
936
- if _should_retry(outcome, int(record.get("attempt", 1))):
1659
+ retry_allowed = _finish_record(plan, record, outcome)
1660
+ if (
1661
+ _should_retry(outcome, int(record.get("attempt", 1)))
1662
+ and retry_allowed
1663
+ ):
937
1664
  _retry_from_record(plan, record, outcome)
938
- continue
939
- _finish_record(plan, record, outcome)
940
1665
 
941
1666
 
942
1667
  def _retry_from_record(
943
1668
  plan: DispatchPlan, record: Mapping[str, Any], outcome: WorkerOutcome
944
1669
  ) -> None:
945
- worker_id = _require_string(record, "workerId")
946
1670
  attempt = int(record.get("attempt", 1))
947
1671
  job = _job_from_record(plan.project_root, record)
948
1672
  reason = "required worker artifact was not produced"
@@ -950,22 +1674,47 @@ def _retry_from_record(
950
1674
  # `team-contract` counts the first attempt's failure as a recorded
951
1675
  # `cli-failure`; a retry that succeeds settles `completed` and would
952
1676
  # otherwise leave no trace that anything had to be re-run.
953
- details: dict[str, Any] = {"workerId": worker_id, "attempt": attempt}
1677
+ details = _event_execution_identity(job, attempt)
954
1678
  details["errorLogAppend"] = _record_wrapper_failure(
955
1679
  plan, job, attempt, outcome, reason
956
1680
  )
957
1681
  _append_event(plan, "worker-retry-scheduled", details)
958
- _spawn_job(plan, job, attempt + 1)
1682
+ # The restored job settles attempt 1 above and must keep its session; only
1683
+ # the attempt being opened here gets a new one.
1684
+ _spawn_job(plan, _job_for_next_attempt(job), attempt + 1)
959
1685
 
960
1686
 
961
- def _finish_attempt(plan: DispatchPlan, job: WorkerJob, attempt: int, outcome: WorkerOutcome) -> None:
1687
+ def _finish_attempt(
1688
+ plan: DispatchPlan,
1689
+ job: WorkerJob,
1690
+ attempt: int,
1691
+ outcome: WorkerOutcome,
1692
+ *,
1693
+ snapshot: MutationSnapshot | None = None,
1694
+ policies: Sequence[WritePolicy] = (),
1695
+ ) -> bool:
1696
+ mutation = _audit_attempt(job, outcome, snapshot, policies)
1697
+ if mutation is not None and mutation.status in {
1698
+ "contract-failed-unattributed",
1699
+ "mutation-present-unresolved",
1700
+ }:
1701
+ reason = "; ".join(mutation.violations) or mutation.status
1702
+ _transition_job_status(plan, job, "error", reason)
1703
+ _update_dispatch_status(
1704
+ plan.team_state_path, job, attempt, "error", reason
1705
+ )
1706
+ details = _failure_details(job, attempt, outcome, reason)
1707
+ details["mutationAudit"] = mutation.change_summary()
1708
+ _append_event(plan, "worker-failed", details)
1709
+ _finish_manifest_attempt(plan, job, attempt, mutation, None)
1710
+ return False
962
1711
  settlement = _settle(plan, job, attempt, outcome)
963
1712
  if settlement.completed:
964
1713
  result_link = _link_result(plan, job, attempt)
965
1714
  post_process = _post_process_report_writer_result(plan, job)
966
1715
  if not post_process["ok"]:
967
1716
  reason = _require_string(post_process, "reason")
968
- _transition_worker_status(plan.team_state_path, job.worker_id, "error", reason)
1717
+ _transition_job_status(plan, job, "error", reason)
969
1718
  _update_dispatch_status(
970
1719
  plan.team_state_path,
971
1720
  job,
@@ -982,10 +1731,9 @@ def _finish_attempt(plan: DispatchPlan, job: WorkerJob, attempt: int, outcome: W
982
1731
  )
983
1732
  details["postProcessing"] = post_process["steps"]
984
1733
  _append_event(plan, "worker-failed", details)
985
- return
986
- _transition_worker_status(
987
- plan.team_state_path, job.worker_id, "completed", ""
988
- )
1734
+ _finish_manifest_attempt(plan, job, attempt, mutation, None)
1735
+ return False
1736
+ _transition_job_status(plan, job, "completed", "")
989
1737
  _update_dispatch_status(
990
1738
  plan.team_state_path,
991
1739
  job,
@@ -998,16 +1746,148 @@ def _finish_attempt(plan: DispatchPlan, job: WorkerJob, attempt: int, outcome: W
998
1746
  details["resultLink"] = result_link
999
1747
  if settlement.error_log_append is not None:
1000
1748
  details["errorLogAppend"] = settlement.error_log_append
1749
+ if mutation is not None:
1750
+ details["mutationAudit"] = mutation.change_summary()
1001
1751
  _append_event(plan, "worker-result-collected", details)
1002
- return
1752
+ _finish_manifest_attempt(
1753
+ plan, job, attempt, mutation, job.worker_result_path
1754
+ )
1755
+ return False
1003
1756
  reason = settlement.reason
1004
1757
  status = "timeout" if outcome.timeout else "error"
1005
- _transition_worker_status(plan.team_state_path, job.worker_id, status, reason)
1758
+ _transition_job_status(plan, job, status, reason)
1006
1759
  _update_dispatch_status(plan.team_state_path, job, attempt, status, reason)
1007
1760
  details = _failure_details(job, attempt, outcome, reason)
1008
1761
  if settlement.error_log_append is not None:
1009
1762
  details["errorLogAppend"] = settlement.error_log_append
1763
+ if mutation is not None:
1764
+ details["mutationAudit"] = mutation.change_summary()
1010
1765
  _append_event(plan, "worker-failed", details)
1766
+ _finish_manifest_attempt(plan, job, attempt, mutation, None)
1767
+ return mutation.retry_allowed if mutation is not None else True
1768
+
1769
+
1770
+ def _audit_attempt(
1771
+ job: WorkerJob,
1772
+ outcome: WorkerOutcome,
1773
+ snapshot: MutationSnapshot | None,
1774
+ policies: Sequence[WritePolicy],
1775
+ ) -> MutationAuditResult | None:
1776
+ if not job.has_execution_identity or not policies:
1777
+ return None
1778
+ succeeded = outcome.returncode == 0 and not outcome.timeout
1779
+ result_present = not outcome.missing_completion_paths
1780
+ if snapshot is None:
1781
+ status = "ok" if succeeded and result_present else "failed-no-mutation"
1782
+ return MutationAuditResult(
1783
+ status=status,
1784
+ changed_paths=(),
1785
+ source_changed=False,
1786
+ git_changed=False,
1787
+ attribution="call",
1788
+ retry_allowed=status == "failed-no-mutation",
1789
+ violations=(),
1790
+ before_digest="",
1791
+ after_digest="",
1792
+ git_projection={},
1793
+ )
1794
+ return ExecutionMutationAudit().compare(
1795
+ snapshot,
1796
+ policies,
1797
+ out_of_plan_edits=_out_of_plan_edit_paths(job.result_path),
1798
+ attempt_succeeded=succeeded,
1799
+ result_present=result_present,
1800
+ )
1801
+
1802
+
1803
+ def _out_of_plan_edit_paths(result_path: Path) -> tuple[str, ...]:
1804
+ if not result_path.is_file() or result_path.suffix != ".json":
1805
+ return ()
1806
+ try:
1807
+ payload = json.loads(result_path.read_text(encoding="utf-8"))
1808
+ except (OSError, UnicodeError, json.JSONDecodeError):
1809
+ return ()
1810
+ implementation = payload.get("implementation") if isinstance(payload, Mapping) else None
1811
+ rows = implementation.get("outOfPlanEdits") if isinstance(implementation, Mapping) else None
1812
+ if not isinstance(rows, list):
1813
+ return ()
1814
+ return tuple(
1815
+ str(row["file"])
1816
+ for row in rows
1817
+ if isinstance(row, Mapping)
1818
+ and isinstance(row.get("file"), str)
1819
+ and row["file"]
1820
+ )
1821
+
1822
+
1823
+ def _finish_manifest_attempt(
1824
+ plan: DispatchPlan,
1825
+ job: WorkerJob,
1826
+ attempt: int,
1827
+ mutation: MutationAuditResult | None,
1828
+ result_path: Path | None,
1829
+ ) -> None:
1830
+ if mutation is None:
1831
+ return
1832
+ manifest = read_execution_manifest(plan.manifest_path)
1833
+ existing = next(
1834
+ (
1835
+ row for row in manifest.attempts
1836
+ if row.invocation_ref == job.invocation_ref
1837
+ and row.attempt == attempt
1838
+ ),
1839
+ None,
1840
+ )
1841
+ expected_result = str(result_path) if result_path is not None else None
1842
+ if existing is not None and existing.finished_at is not None:
1843
+ if (
1844
+ existing.status == mutation.status
1845
+ and existing.result_path == expected_result
1846
+ and dict(existing.change_summary) == mutation.change_summary()
1847
+ ):
1848
+ return
1849
+ raise DispatchError("attempt terminal mutation result drift")
1850
+ finish_attempt_mutation(
1851
+ plan.manifest_path,
1852
+ invocation_ref=job.invocation_ref,
1853
+ attempt=attempt,
1854
+ finished_at=_utc_now(),
1855
+ status=mutation.status,
1856
+ result_path=expected_result,
1857
+ error_path=None,
1858
+ change_summary=mutation.change_summary(),
1859
+ task_key=_require_string(plan.manifest, "taskKey"),
1860
+ )
1861
+ _seal_finished_attempt(plan, job, attempt, mutation)
1862
+
1863
+
1864
+ def _seal_finished_attempt(plan, job, attempt, mutation) -> None:
1865
+ from .attempt_evidence import TerminalObservation, finalize_attempt
1866
+
1867
+ events = job.prompt_path.with_name(job.prompt_path.name + ".host-events.jsonl")
1868
+ if not events.is_file() or not job.has_execution_identity:
1869
+ return
1870
+ artifacts = tuple(
1871
+ path for path in (job.result_path, job.worker_result_path) if path
1872
+ )
1873
+ try:
1874
+ finalize_attempt(
1875
+ plan.manifest_path,
1876
+ _build_dispatch_id(job.invocation_ref, attempt),
1877
+ TerminalObservation(
1878
+ status=mutation.status,
1879
+ source_diff_digest=mutation.change_summary().get("beforeDigest", ""),
1880
+ git_projection_digest=mutation.change_summary().get("afterDigest", ""),
1881
+ changed_paths=tuple(mutation.changed_paths),
1882
+ artifact_paths=artifacts,
1883
+ host_event_stream_path=events,
1884
+ project_root=plan.project_root,
1885
+ worktree=Path(job.worktree_path) if job.worktree_path else None,
1886
+ ),
1887
+ task_key=_require_string(plan.manifest, "taskKey"),
1888
+ )
1889
+ except (OSError, ValueError):
1890
+ return
1011
1891
 
1012
1892
 
1013
1893
  def _link_result(
@@ -1038,7 +1918,7 @@ def _link_result(
1038
1918
  _link_agent_dispatch_result(
1039
1919
  project_root=plan.project_root,
1040
1920
  run_manifest_path=plan.manifest_path,
1041
- dispatch_id=f"{job.invocation_id}:attempt-{attempt}",
1921
+ dispatch_id=_build_dispatch_id(job.invocation_id, attempt),
1042
1922
  result_path=job.worker_result_path,
1043
1923
  )
1044
1924
  except (DispatchError, OSError) as exc:
@@ -1047,9 +1927,34 @@ def _link_result(
1047
1927
  return link
1048
1928
 
1049
1929
 
1050
- def _finish_record(plan: DispatchPlan, record: Mapping[str, Any], outcome: WorkerOutcome) -> None:
1930
+ def _finish_record(
1931
+ plan: DispatchPlan,
1932
+ record: Mapping[str, Any],
1933
+ outcome: WorkerOutcome,
1934
+ ) -> bool:
1051
1935
  job = _job_from_record(plan.project_root, record)
1052
- _finish_attempt(plan, job, int(record.get("attempt", 1)), outcome)
1936
+ snapshot = _snapshot_from_record(record)
1937
+ contract = _persisted_write_contract(plan, job)
1938
+ policies = (contract[0],) if contract is not None else ()
1939
+ return _finish_attempt(
1940
+ plan,
1941
+ job,
1942
+ int(record.get("attempt", 1)),
1943
+ outcome,
1944
+ snapshot=snapshot,
1945
+ policies=policies,
1946
+ )
1947
+
1948
+
1949
+ def _snapshot_from_record(record: Mapping[str, Any]) -> MutationSnapshot | None:
1950
+ value = _string_value(record.get("mutationAuditSnapshotPath"))
1951
+ if not value:
1952
+ return None
1953
+ payload = _load_json_object(Path(value), "mutation audit snapshot")
1954
+ try:
1955
+ return MutationSnapshot.from_payload(payload)
1956
+ except (KeyError, TypeError, ValueError) as exc:
1957
+ raise DispatchError("mutation audit snapshot is invalid") from exc
1053
1958
 
1054
1959
 
1055
1960
  @dataclass(frozen=True)
@@ -1087,7 +1992,11 @@ def _settle(
1087
1992
  reason = _failure_reason(outcome)
1088
1993
  completed = False
1089
1994
  note = ""
1090
- if not outcome.timeout and not outcome.missing_completion_paths:
1995
+ if (
1996
+ outcome.returncode != SERVED_MODEL_MISMATCH_EXIT_CODE
1997
+ and not outcome.timeout
1998
+ and not outcome.missing_completion_paths
1999
+ ):
1091
2000
  audit_failures = _audit_sidecar_failures(job)
1092
2001
  if audit_failures:
1093
2002
  reason = (
@@ -1181,6 +2090,11 @@ def _record_wrapper_failure(
1181
2090
  message=f"attempt {attempt}: {message}",
1182
2091
  stderr_excerpt=_wrapper_log_tail(job),
1183
2092
  context=None,
2093
+ identity=(
2094
+ _job_stored_identity(job, attempt)
2095
+ if job.has_execution_identity
2096
+ else None
2097
+ ),
1184
2098
  )
1185
2099
  except (OSError, ValueError, TypeError, WorkerPromptHeaderError) as exc:
1186
2100
  result["reason"] = f"{type(exc).__name__}: {exc}"
@@ -1189,6 +2103,17 @@ def _record_wrapper_failure(
1189
2103
  return result
1190
2104
 
1191
2105
 
2106
+ def _job_stored_identity(job: WorkerJob, attempt: int) -> dict[str, Any]:
2107
+ """Copy the job's stored execution refs. Do not infer them from labels."""
2108
+ return {
2109
+ "participantRef": job.participant_ref,
2110
+ "roleExecutionRef": job.role_execution_ref,
2111
+ "invocationRef": job.invocation_ref,
2112
+ "attempt": job.attempt or attempt,
2113
+ "executionLabel": job.execution_label,
2114
+ }
2115
+
2116
+
1192
2117
  def _error_log_agent(worker_id: str) -> str:
1193
2118
  """The error log's `--agent` enum value for a worker id.
1194
2119
 
@@ -1267,9 +2192,87 @@ def _post_process_report_writer_result(
1267
2192
  return {"ok": False, "reason": str(exc), "steps": []}
1268
2193
 
1269
2194
 
1270
- def _worker_terminal_status(team_state_path: Path, worker_id: str) -> str:
1271
- team_state = _load_json_object(team_state_path, "team-state")
1272
- return _string_value(_worker_state(team_state, worker_id).get("status"))
2195
+ def _projectionless_translator(plan: DispatchPlan, job: WorkerJob) -> bool:
2196
+ if not (
2197
+ job.has_execution_identity
2198
+ and job.role == "translator"
2199
+ and job.worker_id == "translator"
2200
+ and job.audience == "translator"
2201
+ and job.assignment_ref == "translator"
2202
+ and job.duty_id == "translator"
2203
+ and job.dispatch_kind == "translator"
2204
+ ):
2205
+ return False
2206
+ execution = _canonical_translator_execution(plan.manifest)
2207
+ return bool(
2208
+ execution
2209
+ and execution.get("roleExecutionRef") == job.role_execution_ref
2210
+ and execution.get("participantRef") == job.participant_ref
2211
+ and execution.get("executionLabel") == job.execution_label
2212
+ and execution.get("provider") == job.provider
2213
+ )
2214
+
2215
+
2216
+ def _transition_job_status(
2217
+ plan: DispatchPlan,
2218
+ job: WorkerJob,
2219
+ status: str,
2220
+ reason: str,
2221
+ *,
2222
+ model_execution_value: str = "",
2223
+ ) -> None:
2224
+ if not _projectionless_translator(plan, job):
2225
+ _transition_worker_status(
2226
+ plan.team_state_path,
2227
+ job.worker_id,
2228
+ status,
2229
+ reason,
2230
+ model_execution_value=model_execution_value,
2231
+ )
2232
+ return
2233
+ team_state = _load_json_object(plan.team_state_path, "team-state")
2234
+ workers = team_state.get("workers")
2235
+ if not isinstance(workers, list):
2236
+ raise DispatchError("team-state workers must be an array")
2237
+ if not any(
2238
+ isinstance(row, Mapping) and row.get("workerId") == job.worker_id
2239
+ for row in workers
2240
+ ):
2241
+ return
2242
+ _transition_worker_status(
2243
+ plan.team_state_path,
2244
+ job.worker_id,
2245
+ status,
2246
+ reason,
2247
+ model_execution_value=model_execution_value,
2248
+ )
2249
+
2250
+
2251
+ def _job_terminal_status(
2252
+ plan: DispatchPlan, job: WorkerJob, attempt: int,
2253
+ ) -> str:
2254
+ team_state = _load_json_object(plan.team_state_path, "team-state")
2255
+ workers = team_state.get("workers")
2256
+ if not isinstance(workers, list):
2257
+ raise DispatchError("team-state workers must be an array")
2258
+ matches = [
2259
+ row for row in workers
2260
+ if isinstance(row, Mapping) and row.get("workerId") == job.worker_id
2261
+ ]
2262
+ if len(matches) == 1:
2263
+ return _string_value(matches[0].get("status"))
2264
+ if matches or not _projectionless_translator(plan, job):
2265
+ raise DispatchError(f"team-state has no unique workerId={job.worker_id}")
2266
+ dispatches = team_state.get("workerDispatches")
2267
+ records = [
2268
+ row for row in dispatches
2269
+ if isinstance(row, Mapping)
2270
+ and row.get("promptPath") == str(job.prompt_path)
2271
+ and row.get("attempt") == attempt
2272
+ ] if isinstance(dispatches, list) else []
2273
+ if len(records) != 1:
2274
+ raise DispatchError("translator dispatch has no unique status record")
2275
+ return _string_value(records[0].get("status"))
1273
2276
 
1274
2277
 
1275
2278
  def _record_dispatch(
@@ -1284,15 +2287,31 @@ def _record_dispatch(
1284
2287
  handle.pane_id,
1285
2288
  handle.degraded_from,
1286
2289
  reason,
2290
+ mutation_snapshot_path=(
2291
+ _mutation_snapshot_path(handle.job)
2292
+ if handle.mutation_snapshot is not None
2293
+ else None
2294
+ ),
2295
+ write_policy_digest=handle.write_policy_digest,
2296
+ write_enforcement=handle.write_enforcement,
1287
2297
  ),
1288
2298
  )
1289
2299
 
1290
2300
 
1291
2301
  def _dispatch_record(
1292
- job: WorkerJob, attempt: int, status: str, pane_id: str, degraded_from: str, reason: str = ""
2302
+ job: WorkerJob,
2303
+ attempt: int,
2304
+ status: str,
2305
+ pane_id: str,
2306
+ degraded_from: str,
2307
+ reason: str = "",
2308
+ *,
2309
+ mutation_snapshot_path: Path | None = None,
2310
+ write_policy_digest: str = "",
2311
+ write_enforcement: WriteEnforcement | None = None,
1293
2312
  ) -> dict[str, Any]:
2313
+ has_execution_identity = job.has_execution_identity
1294
2314
  record = {
1295
- "workerId": job.worker_id,
1296
2315
  "role": job.role,
1297
2316
  "kind": job.dispatch_kind,
1298
2317
  "attempt": attempt,
@@ -1300,6 +2319,10 @@ def _dispatch_record(
1300
2319
  "provider": job.provider,
1301
2320
  "status": status,
1302
2321
  "paneId": pane_id,
2322
+ # The only handle on a pane worker's transcript: it runs as its own
2323
+ # `claude -p` process and leaves no `agentName` in its session jsonl, so
2324
+ # token collection resolves its usage by this value or not at all.
2325
+ "sessionId": job.session_id,
1303
2326
  "promptPath": str(job.prompt_path),
1304
2327
  "resultPath": str(job.result_path),
1305
2328
  "workerResultPath": str(job.worker_result_path),
@@ -1317,7 +2340,7 @@ def _dispatch_record(
1317
2340
  }
1318
2341
  if job.invocation_id:
1319
2342
  record.update({
1320
- "dispatchId": f"{job.invocation_id}:attempt-{attempt}",
2343
+ "dispatchId": _build_dispatch_id(job.invocation_id, attempt),
1321
2344
  "invocationId": job.invocation_id,
1322
2345
  "audience": job.audience,
1323
2346
  "assignmentRef": job.assignment_ref,
@@ -1326,11 +2349,32 @@ def _dispatch_record(
1326
2349
  "hostModelValue": job.host_model_value,
1327
2350
  "enforcementMode": job.enforcement_mode,
1328
2351
  })
2352
+ if has_execution_identity:
2353
+ record.update({
2354
+ "schemaVersion": "2.0",
2355
+ "executionIdentityVersion": 2,
2356
+ "participantRef": job.participant_ref,
2357
+ "roleExecutionRef": job.role_execution_ref,
2358
+ "executionLabel": job.execution_label,
2359
+ "dutyId": job.duty_id,
2360
+ "invocationRef": job.invocation_ref,
2361
+ "writePolicyDigest": write_policy_digest,
2362
+ "writeEnforcement": (
2363
+ write_enforcement.to_payload()
2364
+ if write_enforcement is not None
2365
+ else None
2366
+ ),
2367
+ "mutationAuditSnapshotPath": (
2368
+ str(mutation_snapshot_path) if mutation_snapshot_path else ""
2369
+ ),
2370
+ })
2371
+ else:
2372
+ record["workerId"] = job.worker_id
1329
2373
  return record
1330
2374
 
1331
2375
 
1332
2376
  def _liveness_mode(backend: str) -> str:
1333
- if backend in (BACKEND_CLI_WRAPPER, BACKEND_TMUX_PANE, BACKEND_CMUX_PANE):
2377
+ if backend in (BACKEND_CLI_WRAPPER, BACKEND_CMUX_PANE):
1334
2378
  return LIVENESS_WRAPPER_STATUS
1335
2379
  return LIVENESS_AUDIT_HEARTBEAT
1336
2380
 
@@ -1463,7 +2507,9 @@ def _relay_to_sidebar(plan: DispatchPlan, event_type: str, details: Mapping[str,
1463
2507
  if plan.default_backend != BACKEND_CMUX_PANE:
1464
2508
  return
1465
2509
  workspace = cmux.resolve_lead_workspace()
1466
- worker_id = str(details.get("workerId", "") or "worker")
2510
+ worker_id = str(
2511
+ details.get("executionLabel") or details.get("workerId") or "worker"
2512
+ )
1467
2513
  cmux.sidebar_log(
1468
2514
  workspace,
1469
2515
  f"{worker_id}: {event_type.removeprefix('worker-')}",
@@ -1494,13 +2540,16 @@ def _append_event(plan: DispatchPlan, event_type: str, details: Mapping[str, Any
1494
2540
 
1495
2541
 
1496
2542
  def _attempt_details(job: WorkerJob, attempt: int, handle: WorkerHandle) -> dict[str, Any]:
1497
- return {**_event_details(job, handle), "attempt": attempt, "maxAttempts": MAX_WORKER_ATTEMPTS}
2543
+ return {
2544
+ **_event_details(job, handle),
2545
+ **_event_execution_identity(job, attempt),
2546
+ "maxAttempts": MAX_WORKER_ATTEMPTS,
2547
+ }
1498
2548
 
1499
2549
 
1500
2550
  def _event_details(job: WorkerJob, handle: WorkerHandle) -> dict[str, Any]:
1501
- return {
2551
+ details = {
1502
2552
  "dispatchMode": BACKEND_CLI_WRAPPER if handle.degraded_from else job.backend,
1503
- "workerId": job.worker_id,
1504
2553
  "promptPath": str(job.prompt_path),
1505
2554
  "resultPath": str(job.result_path),
1506
2555
  "workerResultPath": str(job.worker_result_path),
@@ -1509,12 +2558,25 @@ def _event_details(job: WorkerJob, handle: WorkerHandle) -> dict[str, Any]:
1509
2558
  "paneId": handle.pane_id,
1510
2559
  "degradedFrom": handle.degraded_from,
1511
2560
  }
2561
+ return details
2562
+
2563
+
2564
+ def _event_execution_identity(job: WorkerJob, attempt: int) -> dict[str, Any]:
2565
+ if job.has_execution_identity:
2566
+ return {
2567
+ "participantRef": job.participant_ref,
2568
+ "roleExecutionRef": job.role_execution_ref,
2569
+ "executionLabel": job.execution_label,
2570
+ "dutyId": job.duty_id,
2571
+ "invocationRef": job.invocation_ref,
2572
+ "attempt": attempt,
2573
+ }
2574
+ return {"workerId": job.worker_id, "attempt": attempt}
1512
2575
 
1513
2576
 
1514
2577
  def _result_details(job: WorkerJob, attempt: int, outcome: WorkerOutcome) -> dict[str, Any]:
1515
2578
  return {
1516
- "workerId": job.worker_id,
1517
- "attempt": attempt,
2579
+ **_event_execution_identity(job, attempt),
1518
2580
  "dispatchMode": BACKEND_CLI_WRAPPER if outcome.degraded_from else job.backend,
1519
2581
  "missingCompletionPaths": [],
1520
2582
  # A collected result can still come from a wrapper that exited non-zero
@@ -1533,8 +2595,7 @@ def _failure_details(
1533
2595
  exit_code: int | None = None,
1534
2596
  ) -> dict[str, Any]:
1535
2597
  return {
1536
- "workerId": job.worker_id,
1537
- "attempt": attempt,
2598
+ **_event_execution_identity(job, attempt),
1538
2599
  "maxAttempts": MAX_WORKER_ATTEMPTS,
1539
2600
  "dispatchMode": BACKEND_CLI_WRAPPER if outcome.degraded_from else job.backend,
1540
2601
  "exitCode": outcome.returncode if exit_code is None else exit_code,
@@ -1550,8 +2611,7 @@ def _failure_details(
1550
2611
 
1551
2612
  def _retry_details(job: WorkerJob, attempt: int, outcome: WorkerOutcome) -> dict[str, Any]:
1552
2613
  return {
1553
- "workerId": job.worker_id,
1554
- "attempt": attempt,
2614
+ **_event_execution_identity(job, attempt),
1555
2615
  "missingCompletionPaths": [str(path) for path in outcome.missing_completion_paths],
1556
2616
  "reason": "required worker artifact was not produced",
1557
2617
  }
@@ -1570,8 +2630,13 @@ def _outcome_from_status(record: Mapping[str, Any], status) -> WorkerOutcome:
1570
2630
 
1571
2631
 
1572
2632
  def _job_from_record(project_root: Path, record: Mapping[str, Any]) -> WorkerJob:
2633
+ identity = _worker_execution_identity(record)
1573
2634
  return WorkerJob(
1574
- worker_id=_require_string(record, "workerId"),
2635
+ worker_id=(
2636
+ _require_string(record, "workerId")
2637
+ if identity is None
2638
+ else _v2_worker_state_key(record)
2639
+ ),
1575
2640
  provider=_require_string(record, "provider"),
1576
2641
  backend=BACKEND_CLI_WRAPPER,
1577
2642
  project_root=project_root,
@@ -1585,6 +2650,10 @@ def _job_from_record(project_root: Path, record: Mapping[str, Any]) -> WorkerJob
1585
2650
  role=_require_string(record, "role"),
1586
2651
  idle_timeout_seconds=600,
1587
2652
  dispatch_kind=_require_string(record, "kind"),
2653
+ # Restored, never re-issued: both callers rebuild a job from its record
2654
+ # to settle a dispatch that already ran, and a fresh id would settle it
2655
+ # against a session that never existed.
2656
+ session_id=_string_value(record.get("sessionId")),
1588
2657
  invocation_id=_string_value(record.get("invocationId")),
1589
2658
  audience=_string_value(record.get("audience")),
1590
2659
  assignment_ref=_string_value(record.get("assignmentRef")),
@@ -1602,6 +2671,7 @@ def _job_from_record(project_root: Path, record: Mapping[str, Any]) -> WorkerJob
1602
2671
  else None
1603
2672
  ),
1604
2673
  enforcement_mode=_string_value(record.get("enforcementMode")),
2674
+ **(identity or {}),
1605
2675
  )
1606
2676
 
1607
2677
 
@@ -1613,6 +2683,20 @@ def _should_retry(outcome: WorkerOutcome, attempt: int) -> bool:
1613
2683
  return outcome.returncode == 0 and bool(outcome.missing_completion_paths) and attempt < MAX_WORKER_ATTEMPTS
1614
2684
 
1615
2685
 
2686
+ def _job_for_next_attempt(job: WorkerJob) -> WorkerJob:
2687
+ """The same work as a new attempt, which means a new session.
2688
+
2689
+ The first attempt's process is dead; a retry starts another one. Carrying
2690
+ the id over would merge two processes into one attribution unit, and it
2691
+ would hand `claude --session-id` a value that is already taken — behaviour
2692
+ the CLI does not document. Every re-dispatch goes through here so no path
2693
+ can quietly keep the old session.
2694
+
2695
+ A provider with no session flag gets '' back, so this is a no-op for it.
2696
+ """
2697
+ return replace(job, session_id=_dispatch_session_id(job.provider))
2698
+
2699
+
1616
2700
  def _failure_reason(outcome: WorkerOutcome) -> str:
1617
2701
  if outcome.timeout:
1618
2702
  return "worker wrapper timed out"
@@ -1709,11 +2793,6 @@ def _reject_stale_schema_excerpt(
1709
2793
  )
1710
2794
 
1711
2795
 
1712
- def _run_dir(plan: DispatchPlan) -> Path:
1713
- value = _string_value(plan.manifest.get("runDirectoryPath"))
1714
- return _resolve_project_path(plan.project_root, value) if value else plan.team_state_path.parent.parent
1715
-
1716
-
1717
2796
  def _load_optional_json(project_root: Path, value: Any) -> dict[str, Any]:
1718
2797
  if not isinstance(value, str) or not value.strip():
1719
2798
  return {}