oh-my-codex 0.20.2 → 0.20.3

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 (367) hide show
  1. package/Cargo.lock +7 -6
  2. package/Cargo.toml +1 -1
  3. package/README.md +26 -0
  4. package/crates/omx-runtime/Cargo.toml +1 -0
  5. package/crates/omx-runtime/src/main.rs +25 -2
  6. package/crates/omx-runtime/tests/execution.rs +219 -0
  7. package/crates/omx-runtime-core/src/dispatch.rs +96 -10
  8. package/crates/omx-runtime-core/src/engine.rs +839 -29
  9. package/crates/omx-runtime-core/src/lib.rs +8 -0
  10. package/dist/agents/__tests__/definitions.test.js +81 -0
  11. package/dist/agents/__tests__/definitions.test.js.map +1 -1
  12. package/dist/agents/__tests__/native-config.test.js +25 -0
  13. package/dist/agents/__tests__/native-config.test.js.map +1 -1
  14. package/dist/agents/native-config.d.ts +2 -1
  15. package/dist/agents/native-config.d.ts.map +1 -1
  16. package/dist/agents/native-config.js.map +1 -1
  17. package/dist/auth/hotswap.d.ts +6 -0
  18. package/dist/auth/hotswap.d.ts.map +1 -1
  19. package/dist/auth/hotswap.js +85 -15
  20. package/dist/auth/hotswap.js.map +1 -1
  21. package/dist/autopilot/__tests__/ralplan-gate.test.js +1 -1
  22. package/dist/autopilot/__tests__/ralplan-gate.test.js.map +1 -1
  23. package/dist/cli/__tests__/auth.test.js +60 -6
  24. package/dist/cli/__tests__/auth.test.js.map +1 -1
  25. package/dist/cli/__tests__/codex-plugin-layout.test.js +80 -0
  26. package/dist/cli/__tests__/codex-plugin-layout.test.js.map +1 -1
  27. package/dist/cli/__tests__/doctor-warning-copy.test.js +103 -1
  28. package/dist/cli/__tests__/doctor-warning-copy.test.js.map +1 -1
  29. package/dist/cli/__tests__/exec.test.js +136 -0
  30. package/dist/cli/__tests__/exec.test.js.map +1 -1
  31. package/dist/cli/__tests__/index.test.js +258 -4
  32. package/dist/cli/__tests__/index.test.js.map +1 -1
  33. package/dist/cli/__tests__/launch-fallback.test.js +287 -1
  34. package/dist/cli/__tests__/launch-fallback.test.js.map +1 -1
  35. package/dist/cli/__tests__/mcp-parity.test.js +4 -1
  36. package/dist/cli/__tests__/mcp-parity.test.js.map +1 -1
  37. package/dist/cli/__tests__/native-hook-claim-journal.test.js +84 -6
  38. package/dist/cli/__tests__/native-hook-claim-journal.test.js.map +1 -1
  39. package/dist/cli/__tests__/ralph-goal-mode-contract.test.js +3 -3
  40. package/dist/cli/__tests__/ralph-goal-mode-contract.test.js.map +1 -1
  41. package/dist/cli/__tests__/ralph.test.js +3 -3
  42. package/dist/cli/__tests__/ralph.test.js.map +1 -1
  43. package/dist/cli/__tests__/ralplan-bootstrap-3181.test.js +56 -171
  44. package/dist/cli/__tests__/ralplan-bootstrap-3181.test.js.map +1 -1
  45. package/dist/cli/__tests__/ralplan.test.js +37 -111
  46. package/dist/cli/__tests__/ralplan.test.js.map +1 -1
  47. package/dist/cli/__tests__/resume.test.js +11 -10
  48. package/dist/cli/__tests__/resume.test.js.map +1 -1
  49. package/dist/cli/__tests__/session-search-help.test.js +25 -1
  50. package/dist/cli/__tests__/session-search-help.test.js.map +1 -1
  51. package/dist/cli/__tests__/setup-install-mode.test.js +42 -4
  52. package/dist/cli/__tests__/setup-install-mode.test.js.map +1 -1
  53. package/dist/cli/__tests__/team.test.js +105 -7
  54. package/dist/cli/__tests__/team.test.js.map +1 -1
  55. package/dist/cli/__tests__/uninstall.test.js +46 -0
  56. package/dist/cli/__tests__/uninstall.test.js.map +1 -1
  57. package/dist/cli/__tests__/windows-popup-loop-contract.test.js +2 -1
  58. package/dist/cli/__tests__/windows-popup-loop-contract.test.js.map +1 -1
  59. package/dist/cli/doctor.d.ts +3 -0
  60. package/dist/cli/doctor.d.ts.map +1 -1
  61. package/dist/cli/doctor.js +15 -2
  62. package/dist/cli/doctor.js.map +1 -1
  63. package/dist/cli/index.d.ts +4 -1
  64. package/dist/cli/index.d.ts.map +1 -1
  65. package/dist/cli/index.js +138 -38
  66. package/dist/cli/index.js.map +1 -1
  67. package/dist/cli/mcp-parity.js +2 -2
  68. package/dist/cli/mcp-parity.js.map +1 -1
  69. package/dist/cli/native-hook-claim-journal.d.ts +16 -5
  70. package/dist/cli/native-hook-claim-journal.d.ts.map +1 -1
  71. package/dist/cli/native-hook-claim-journal.js +42 -29
  72. package/dist/cli/native-hook-claim-journal.js.map +1 -1
  73. package/dist/cli/ralph.js +2 -2
  74. package/dist/cli/ralph.js.map +1 -1
  75. package/dist/cli/ralplan.d.ts +4 -8
  76. package/dist/cli/ralplan.d.ts.map +1 -1
  77. package/dist/cli/ralplan.js +19 -112
  78. package/dist/cli/ralplan.js.map +1 -1
  79. package/dist/cli/session-search.d.ts +5 -0
  80. package/dist/cli/session-search.d.ts.map +1 -1
  81. package/dist/cli/session-search.js +87 -0
  82. package/dist/cli/session-search.js.map +1 -1
  83. package/dist/cli/setup.d.ts +5 -0
  84. package/dist/cli/setup.d.ts.map +1 -1
  85. package/dist/cli/setup.js +80 -31
  86. package/dist/cli/setup.js.map +1 -1
  87. package/dist/cli/uninstall.d.ts +9 -0
  88. package/dist/cli/uninstall.d.ts.map +1 -1
  89. package/dist/cli/uninstall.js +61 -19
  90. package/dist/cli/uninstall.js.map +1 -1
  91. package/dist/compat/__tests__/rust-runtime-compat.test.js +1 -1
  92. package/dist/config/__tests__/generator-idempotent.test.js +330 -1
  93. package/dist/config/__tests__/generator-idempotent.test.js.map +1 -1
  94. package/dist/config/__tests__/generator-notify.test.js +2 -2
  95. package/dist/config/__tests__/generator-notify.test.js.map +1 -1
  96. package/dist/config/__tests__/models.test.js +67 -3
  97. package/dist/config/__tests__/models.test.js.map +1 -1
  98. package/dist/config/generator.d.ts +4 -20
  99. package/dist/config/generator.d.ts.map +1 -1
  100. package/dist/config/generator.js +499 -119
  101. package/dist/config/generator.js.map +1 -1
  102. package/dist/config/models.d.ts +12 -2
  103. package/dist/config/models.d.ts.map +1 -1
  104. package/dist/config/models.js +15 -1
  105. package/dist/config/models.js.map +1 -1
  106. package/dist/hooks/__tests__/agents-overlay.test.js +2 -2
  107. package/dist/hooks/__tests__/agents-overlay.test.js.map +1 -1
  108. package/dist/hooks/__tests__/keyword-detector.test.js +4 -4
  109. package/dist/hooks/__tests__/keyword-detector.test.js.map +1 -1
  110. package/dist/hooks/__tests__/notify-fallback-watcher.test.js +238 -543
  111. package/dist/hooks/__tests__/notify-fallback-watcher.test.js.map +1 -1
  112. package/dist/hooks/__tests__/notify-hook-all-workers-idle.test.js +38 -4
  113. package/dist/hooks/__tests__/notify-hook-all-workers-idle.test.js.map +1 -1
  114. package/dist/hooks/__tests__/notify-hook-auto-nudge.test.js +96 -9
  115. package/dist/hooks/__tests__/notify-hook-auto-nudge.test.js.map +1 -1
  116. package/dist/hooks/__tests__/notify-hook-team-dispatch.test.js +531 -12
  117. package/dist/hooks/__tests__/notify-hook-team-dispatch.test.js.map +1 -1
  118. package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js +155 -23
  119. package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js.map +1 -1
  120. package/dist/hooks/__tests__/notify-hook-team-tmux-guard.test.js +482 -32
  121. package/dist/hooks/__tests__/notify-hook-team-tmux-guard.test.js.map +1 -1
  122. package/dist/hooks/__tests__/notify-hook-tmux-heal.test.js +68 -1
  123. package/dist/hooks/__tests__/notify-hook-tmux-heal.test.js.map +1 -1
  124. package/dist/hooks/__tests__/notify-hook-tmux-scrollback.test.js +7 -1
  125. package/dist/hooks/__tests__/notify-hook-tmux-scrollback.test.js.map +1 -1
  126. package/dist/hooks/__tests__/notify-hook-worker-idle.test.js +43 -4
  127. package/dist/hooks/__tests__/notify-hook-worker-idle.test.js.map +1 -1
  128. package/dist/hooks/__tests__/session.test.js +348 -2
  129. package/dist/hooks/__tests__/session.test.js.map +1 -1
  130. package/dist/hooks/agents-overlay.js +1 -1
  131. package/dist/hooks/session.d.ts +26 -2
  132. package/dist/hooks/session.d.ts.map +1 -1
  133. package/dist/hooks/session.js +169 -13
  134. package/dist/hooks/session.js.map +1 -1
  135. package/dist/leader/__tests__/contract.test.js +57 -7
  136. package/dist/leader/__tests__/contract.test.js.map +1 -1
  137. package/dist/leader/contract.d.ts +16 -0
  138. package/dist/leader/contract.d.ts.map +1 -1
  139. package/dist/leader/contract.js +79 -4
  140. package/dist/leader/contract.js.map +1 -1
  141. package/dist/mcp/__tests__/code-intel-server.test.js +31 -3
  142. package/dist/mcp/__tests__/code-intel-server.test.js.map +1 -1
  143. package/dist/mcp/__tests__/wiki-server.test.js +18 -0
  144. package/dist/mcp/__tests__/wiki-server.test.js.map +1 -1
  145. package/dist/mcp/code-intel-server.d.ts.map +1 -1
  146. package/dist/mcp/code-intel-server.js +13 -3
  147. package/dist/mcp/code-intel-server.js.map +1 -1
  148. package/dist/mcp/wiki-server.d.ts +11 -0
  149. package/dist/mcp/wiki-server.d.ts.map +1 -1
  150. package/dist/mcp/wiki-server.js +9 -2
  151. package/dist/mcp/wiki-server.js.map +1 -1
  152. package/dist/modes/__tests__/base-tmux-pane.test.js +4 -4
  153. package/dist/modes/__tests__/base-tmux-pane.test.js.map +1 -1
  154. package/dist/notifications/temp-contract.d.ts.map +1 -1
  155. package/dist/notifications/temp-contract.js +4 -0
  156. package/dist/notifications/temp-contract.js.map +1 -1
  157. package/dist/pipeline/__tests__/orchestrator.test.js +4 -4
  158. package/dist/pipeline/__tests__/orchestrator.test.js.map +1 -1
  159. package/dist/pipeline/__tests__/stages.test.js +8 -8
  160. package/dist/pipeline/__tests__/stages.test.js.map +1 -1
  161. package/dist/ralplan/__tests__/consensus-gate.test.js +134 -10
  162. package/dist/ralplan/__tests__/consensus-gate.test.js.map +1 -1
  163. package/dist/ralplan/__tests__/documented-leader-preflight.test.d.ts +2 -0
  164. package/dist/ralplan/__tests__/documented-leader-preflight.test.d.ts.map +1 -0
  165. package/dist/ralplan/__tests__/documented-leader-preflight.test.js +46 -0
  166. package/dist/ralplan/__tests__/documented-leader-preflight.test.js.map +1 -0
  167. package/dist/ralplan/__tests__/runtime.test.js +8 -4
  168. package/dist/ralplan/__tests__/runtime.test.js.map +1 -1
  169. package/dist/ralplan/consensus-gate.d.ts.map +1 -1
  170. package/dist/ralplan/consensus-gate.js +58 -50
  171. package/dist/ralplan/consensus-gate.js.map +1 -1
  172. package/dist/ralplan/documented-leader-preflight.d.ts +34 -0
  173. package/dist/ralplan/documented-leader-preflight.d.ts.map +1 -0
  174. package/dist/ralplan/documented-leader-preflight.js +58 -0
  175. package/dist/ralplan/documented-leader-preflight.js.map +1 -0
  176. package/dist/ralplan/runtime.d.ts +1 -0
  177. package/dist/ralplan/runtime.d.ts.map +1 -1
  178. package/dist/ralplan/runtime.js +11 -7
  179. package/dist/ralplan/runtime.js.map +1 -1
  180. package/dist/runtime/__tests__/bridge.test.js +42 -1
  181. package/dist/runtime/__tests__/bridge.test.js.map +1 -1
  182. package/dist/runtime/bridge.d.ts +12 -0
  183. package/dist/runtime/bridge.d.ts.map +1 -1
  184. package/dist/runtime/bridge.js +52 -1
  185. package/dist/runtime/bridge.js.map +1 -1
  186. package/dist/scripts/__tests__/codex-native-hook.test.js +2620 -487
  187. package/dist/scripts/__tests__/codex-native-hook.test.js.map +1 -1
  188. package/dist/scripts/__tests__/reasoning-artifact-contract.test.d.ts +2 -0
  189. package/dist/scripts/__tests__/reasoning-artifact-contract.test.d.ts.map +1 -0
  190. package/dist/scripts/__tests__/reasoning-artifact-contract.test.js +108 -0
  191. package/dist/scripts/__tests__/reasoning-artifact-contract.test.js.map +1 -0
  192. package/dist/scripts/__tests__/role-intent-bootstrap-e2e-3181.test.js +46 -173
  193. package/dist/scripts/__tests__/role-intent-bootstrap-e2e-3181.test.js.map +1 -1
  194. package/dist/scripts/__tests__/role-intent-durable-recovery-3181.test.js +39 -159
  195. package/dist/scripts/__tests__/role-intent-durable-recovery-3181.test.js.map +1 -1
  196. package/dist/scripts/__tests__/smoke-packed-install.test.js +254 -2
  197. package/dist/scripts/__tests__/smoke-packed-install.test.js.map +1 -1
  198. package/dist/scripts/codex-native-hook.d.ts +4 -1
  199. package/dist/scripts/codex-native-hook.d.ts.map +1 -1
  200. package/dist/scripts/codex-native-hook.js +11013 -1153
  201. package/dist/scripts/codex-native-hook.js.map +1 -1
  202. package/dist/scripts/notify-fallback-watcher.js +82 -108
  203. package/dist/scripts/notify-fallback-watcher.js.map +1 -1
  204. package/dist/scripts/notify-hook/auto-nudge.d.ts.map +1 -1
  205. package/dist/scripts/notify-hook/auto-nudge.js +74 -3
  206. package/dist/scripts/notify-hook/auto-nudge.js.map +1 -1
  207. package/dist/scripts/notify-hook/team-dispatch.d.ts +9 -0
  208. package/dist/scripts/notify-hook/team-dispatch.d.ts.map +1 -1
  209. package/dist/scripts/notify-hook/team-dispatch.js +234 -79
  210. package/dist/scripts/notify-hook/team-dispatch.js.map +1 -1
  211. package/dist/scripts/notify-hook/team-leader-nudge.d.ts.map +1 -1
  212. package/dist/scripts/notify-hook/team-leader-nudge.js +39 -13
  213. package/dist/scripts/notify-hook/team-leader-nudge.js.map +1 -1
  214. package/dist/scripts/notify-hook/team-tmux-guard.d.ts +12 -4
  215. package/dist/scripts/notify-hook/team-tmux-guard.d.ts.map +1 -1
  216. package/dist/scripts/notify-hook/team-tmux-guard.js +365 -44
  217. package/dist/scripts/notify-hook/team-tmux-guard.js.map +1 -1
  218. package/dist/scripts/notify-hook/team-worker-stop.d.ts.map +1 -1
  219. package/dist/scripts/notify-hook/team-worker-stop.js +19 -18
  220. package/dist/scripts/notify-hook/team-worker-stop.js.map +1 -1
  221. package/dist/scripts/notify-hook/team-worker.d.ts +3 -0
  222. package/dist/scripts/notify-hook/team-worker.d.ts.map +1 -1
  223. package/dist/scripts/notify-hook/team-worker.js +37 -28
  224. package/dist/scripts/notify-hook/team-worker.js.map +1 -1
  225. package/dist/scripts/notify-hook/tmux-injection.d.ts.map +1 -1
  226. package/dist/scripts/notify-hook/tmux-injection.js +28 -2
  227. package/dist/scripts/notify-hook/tmux-injection.js.map +1 -1
  228. package/dist/scripts/notify-hook.js +1 -0
  229. package/dist/scripts/notify-hook.js.map +1 -1
  230. package/dist/scripts/smoke-packed-install.d.ts +48 -3
  231. package/dist/scripts/smoke-packed-install.d.ts.map +1 -1
  232. package/dist/scripts/smoke-packed-install.js +2796 -13
  233. package/dist/scripts/smoke-packed-install.js.map +1 -1
  234. package/dist/state/__tests__/mode-state-context.test.js +54 -0
  235. package/dist/state/__tests__/mode-state-context.test.js.map +1 -1
  236. package/dist/state/__tests__/operations.test.js +2 -0
  237. package/dist/state/__tests__/operations.test.js.map +1 -1
  238. package/dist/state/mode-state-context.d.ts +5 -0
  239. package/dist/state/mode-state-context.d.ts.map +1 -1
  240. package/dist/state/mode-state-context.js +59 -0
  241. package/dist/state/mode-state-context.js.map +1 -1
  242. package/dist/subagents/__tests__/leader-bootstrap-3181.test.js +24 -245
  243. package/dist/subagents/__tests__/leader-bootstrap-3181.test.js.map +1 -1
  244. package/dist/subagents/tracker.d.ts +1 -82
  245. package/dist/subagents/tracker.d.ts.map +1 -1
  246. package/dist/subagents/tracker.js +3 -275
  247. package/dist/subagents/tracker.js.map +1 -1
  248. package/dist/team/__tests__/api-interop.test.js +91 -1
  249. package/dist/team/__tests__/api-interop.test.js.map +1 -1
  250. package/dist/team/__tests__/delivery-e2e-smoke.test.js +52 -10
  251. package/dist/team/__tests__/delivery-e2e-smoke.test.js.map +1 -1
  252. package/dist/team/__tests__/idle-nudge.test.js +45 -15
  253. package/dist/team/__tests__/idle-nudge.test.js.map +1 -1
  254. package/dist/team/__tests__/mcp-comm.test.js +93 -27
  255. package/dist/team/__tests__/mcp-comm.test.js.map +1 -1
  256. package/dist/team/__tests__/model-contract.test.js +110 -3
  257. package/dist/team/__tests__/model-contract.test.js.map +1 -1
  258. package/dist/team/__tests__/runtime-cli.test.js +9 -0
  259. package/dist/team/__tests__/runtime-cli.test.js.map +1 -1
  260. package/dist/team/__tests__/runtime.test.js +3204 -335
  261. package/dist/team/__tests__/runtime.test.js.map +1 -1
  262. package/dist/team/__tests__/scaling.test.js +2359 -142
  263. package/dist/team/__tests__/scaling.test.js.map +1 -1
  264. package/dist/team/__tests__/state.test.js +804 -36
  265. package/dist/team/__tests__/state.test.js.map +1 -1
  266. package/dist/team/__tests__/team-ops-contract.test.js +6 -0
  267. package/dist/team/__tests__/team-ops-contract.test.js.map +1 -1
  268. package/dist/team/__tests__/tmux-session.test.js +3049 -784
  269. package/dist/team/__tests__/tmux-session.test.js.map +1 -1
  270. package/dist/team/__tests__/worker-runtime-identity.test.js +38 -2
  271. package/dist/team/__tests__/worker-runtime-identity.test.js.map +1 -1
  272. package/dist/team/__tests__/worktree.test.js +6 -0
  273. package/dist/team/__tests__/worktree.test.js.map +1 -1
  274. package/dist/team/api-interop.d.ts.map +1 -1
  275. package/dist/team/api-interop.js +15 -9
  276. package/dist/team/api-interop.js.map +1 -1
  277. package/dist/team/exact-pane.d.ts +23 -0
  278. package/dist/team/exact-pane.d.ts.map +1 -0
  279. package/dist/team/exact-pane.js +133 -0
  280. package/dist/team/exact-pane.js.map +1 -0
  281. package/dist/team/idle-nudge.d.ts +12 -1
  282. package/dist/team/idle-nudge.d.ts.map +1 -1
  283. package/dist/team/idle-nudge.js +41 -14
  284. package/dist/team/idle-nudge.js.map +1 -1
  285. package/dist/team/model-contract.d.ts +2 -5
  286. package/dist/team/model-contract.d.ts.map +1 -1
  287. package/dist/team/model-contract.js +59 -17
  288. package/dist/team/model-contract.js.map +1 -1
  289. package/dist/team/runtime.d.ts +1 -0
  290. package/dist/team/runtime.d.ts.map +1 -1
  291. package/dist/team/runtime.js +1244 -180
  292. package/dist/team/runtime.js.map +1 -1
  293. package/dist/team/scaling.d.ts +13 -1
  294. package/dist/team/scaling.d.ts.map +1 -1
  295. package/dist/team/scaling.js +1430 -384
  296. package/dist/team/scaling.js.map +1 -1
  297. package/dist/team/state/dispatch.d.ts.map +1 -1
  298. package/dist/team/state/dispatch.js +26 -8
  299. package/dist/team/state/dispatch.js.map +1 -1
  300. package/dist/team/state/locks.d.ts.map +1 -1
  301. package/dist/team/state/locks.js +61 -13
  302. package/dist/team/state/locks.js.map +1 -1
  303. package/dist/team/state/tasks.d.ts.map +1 -1
  304. package/dist/team/state/tasks.js +4 -3
  305. package/dist/team/state/tasks.js.map +1 -1
  306. package/dist/team/state.d.ts +67 -1
  307. package/dist/team/state.d.ts.map +1 -1
  308. package/dist/team/state.js +636 -129
  309. package/dist/team/state.js.map +1 -1
  310. package/dist/team/team-ops.d.ts +5 -0
  311. package/dist/team/team-ops.d.ts.map +1 -1
  312. package/dist/team/team-ops.js +4 -0
  313. package/dist/team/team-ops.js.map +1 -1
  314. package/dist/team/tmux-session.d.ts +123 -23
  315. package/dist/team/tmux-session.d.ts.map +1 -1
  316. package/dist/team/tmux-session.js +1250 -291
  317. package/dist/team/tmux-session.js.map +1 -1
  318. package/dist/team/worktree.d.ts.map +1 -1
  319. package/dist/team/worktree.js +4 -0
  320. package/dist/team/worktree.js.map +1 -1
  321. package/dist/utils/__tests__/file-durability.test.d.ts +2 -0
  322. package/dist/utils/__tests__/file-durability.test.d.ts.map +1 -0
  323. package/dist/utils/__tests__/file-durability.test.js +57 -0
  324. package/dist/utils/__tests__/file-durability.test.js.map +1 -0
  325. package/dist/utils/file-durability.d.ts +15 -0
  326. package/dist/utils/file-durability.d.ts.map +1 -0
  327. package/dist/utils/file-durability.js +37 -0
  328. package/dist/utils/file-durability.js.map +1 -0
  329. package/dist/verification/__tests__/dev-merge-issue-close-workflow.test.js +3 -0
  330. package/dist/verification/__tests__/dev-merge-issue-close-workflow.test.js.map +1 -1
  331. package/dist/wiki/lint.d.ts +4 -1
  332. package/dist/wiki/lint.d.ts.map +1 -1
  333. package/dist/wiki/lint.js +3 -2
  334. package/dist/wiki/lint.js.map +1 -1
  335. package/package.json +1 -1
  336. package/plugins/oh-my-codex/.codex-plugin/plugin.json +1 -1
  337. package/plugins/oh-my-codex/hooks/codex-native-hook.mjs +24 -0
  338. package/plugins/oh-my-codex/skills/ralph/SKILL.md +6 -4
  339. package/plugins/oh-my-codex/skills/ralplan/SKILL.md +3 -2
  340. package/plugins/oh-my-codex/skills/team/SKILL.md +13 -9
  341. package/skills/ralph/SKILL.md +6 -4
  342. package/skills/ralplan/SKILL.md +3 -2
  343. package/skills/team/SKILL.md +13 -9
  344. package/src/scripts/__tests__/codex-native-hook.test.ts +2826 -497
  345. package/src/scripts/__tests__/reasoning-artifact-contract.test.ts +129 -0
  346. package/src/scripts/__tests__/role-intent-bootstrap-e2e-3181.test.ts +48 -185
  347. package/src/scripts/__tests__/role-intent-durable-recovery-3181.test.ts +44 -177
  348. package/src/scripts/__tests__/smoke-packed-install.test.ts +320 -2
  349. package/src/scripts/codex-native-hook.ts +10715 -1069
  350. package/src/scripts/notify-fallback-watcher.ts +84 -115
  351. package/src/scripts/notify-hook/auto-nudge.ts +71 -3
  352. package/src/scripts/notify-hook/team-dispatch.ts +242 -75
  353. package/src/scripts/notify-hook/team-leader-nudge.ts +40 -18
  354. package/src/scripts/notify-hook/team-tmux-guard.ts +366 -39
  355. package/src/scripts/notify-hook/team-worker-stop.ts +20 -16
  356. package/src/scripts/notify-hook/team-worker.ts +39 -26
  357. package/src/scripts/notify-hook/tmux-injection.ts +28 -2
  358. package/src/scripts/notify-hook.ts +1 -0
  359. package/src/scripts/smoke-packed-install.ts +3196 -14
  360. package/dist/subagents/__tests__/adapted-role-binding.test.d.ts +0 -2
  361. package/dist/subagents/__tests__/adapted-role-binding.test.d.ts.map +0 -1
  362. package/dist/subagents/__tests__/adapted-role-binding.test.js +0 -988
  363. package/dist/subagents/__tests__/adapted-role-binding.test.js.map +0 -1
  364. package/dist/subagents/adapted-role-binding.d.ts +0 -17
  365. package/dist/subagents/adapted-role-binding.d.ts.map +0 -1
  366. package/dist/subagents/adapted-role-binding.js +0 -75
  367. package/dist/subagents/adapted-role-binding.js.map +0 -1
@@ -9,11 +9,13 @@
9
9
  * - File-based scaling lock prevents concurrent scale operations
10
10
  * - 'draining' worker status for graceful transitions during scale_down
11
11
  */
12
- import { join, resolve } from 'path';
13
- import { mkdir, rm } from 'fs/promises';
14
- import { sanitizeTeamName, isTmuxAvailable, waitForWorkerReady, dismissTrustPromptIfPresent, sendToWorker, isWorkerAlive, getWorkerPanePid, teardownWorkerPanes, buildWorkerStartupCommand, trustWorkerMiseConfigIfAvailable, writeWorkerStartupScriptCommand, resolveTeamWorkerCliForResolvedLaunchArgs, assertTeamWorkerCliPolicyCompatibility, tagPaneTeamOwner, } from './tmux-session.js';
15
- import { execFileSync, spawnSync } from 'child_process';
16
- import { teamReadConfig as readTeamConfig, teamSaveConfig as saveTeamConfig, teamWriteWorkerIdentity as writeWorkerIdentity, teamReadManifest as readTeamManifestV2, teamNormalizePolicy as normalizeTeamPolicy, teamReadWorkerStatus as readWorkerStatus, teamWriteWorkerStatus as writeWorkerStatus, teamWithScalingLock as withScalingLock, teamAppendEvent as appendTeamEvent, teamCreateTask as createStateTask, teamListTasks as listTasks, teamMarkDispatchRequestNotified as markDispatchRequestNotified, teamReadDispatchRequest as readDispatchRequest, teamTransitionDispatchRequest as transitionDispatchRequest, } from './team-ops.js';
12
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'path';
13
+ import { mkdir, readFile, realpath, rm, writeFile } from 'fs/promises';
14
+ import { existsSync } from 'fs';
15
+ import { sanitizeTeamName, isTmuxAvailable, waitForWorkerReady, dismissTrustPromptIfPresent, sendToWorker, isWorkerAlive, teardownWorkerPanes, buildWorkerStartupCommand, trustWorkerMiseConfigIfAvailable, writeWorkerStartupScriptCommand, resolveTeamWorkerCliForResolvedLaunchArgs, assertTeamWorkerCliPolicyCompatibility, tagPaneTeamOwner, readPaneTeamOwnerTagResult, } from './tmux-session.js';
16
+ import { readExactPaneProofSync } from './exact-pane.js';
17
+ import { spawnSync } from 'child_process';
18
+ import { teamReadConfig as readTeamConfig, teamSaveConfig as saveTeamConfig, teamWriteWorkerIdentity as writeWorkerIdentity, teamReadManifest as readTeamManifestV2, teamNormalizePolicy as normalizeTeamPolicy, teamReadWorkerStatus as readWorkerStatus, teamWriteWorkerStatus as writeWorkerStatus, teamWithScalingLock as withScalingLock, teamWithTaskMembershipBarrier as withTaskMembershipBarrier, recoverTeamMembershipTaskTransaction, commitTeamMembershipTaskTransaction, finalizeTeamMembershipTaskTransaction, writeAtomic, teamRemoveDurableFile as removeDurableFile, teamAppendEvent as appendTeamEvent, teamCreateTask as createStateTask, teamListTasks as listTasks, teamReadTask as readTask, teamMarkDispatchRequestNotified as markDispatchRequestNotified, teamReadDispatchRequest as readDispatchRequest, teamTransitionDispatchRequest as transitionDispatchRequest, teamRemoveDispatchRequestsForWorkers as removeDispatchRequestsForWorkers, } from './team-ops.js';
17
19
  import { queueInboxInstruction, waitForDispatchReceipt, } from './mcp-comm.js';
18
20
  import { generateInitialInbox, buildTriggerDirective, writeWorkerRoleInstructionsFile, writeWorkerWorktreeRootAgentsFile, removeWorkerWorktreeRootAgentsFile, } from './worker-bootstrap.js';
19
21
  import { buildTeamWorkerGoalInstruction } from './goal-workflow.js';
@@ -24,8 +26,29 @@ import { resolveCodexHomeForLaunch } from '../cli/codex-home.js';
24
26
  import { parseTeamWorkerLaunchArgs, resolveTeamWorkerLaunchArgs, resolveAgentDefaultModel, resolveAgentReasoningEffort, shouldHonorAgentExactModel, TEAM_WORKER_INHERITED_MODEL_ENV, } from './model-contract.js';
25
27
  import { resolveCanonicalTeamStateRoot } from './state-root.js';
26
28
  import { ensureWorktree, planWorktreeTarget, rollbackProvisionedWorktrees, } from './worktree.js';
29
+ import { withTaskClaimLock } from './state/locks.js';
27
30
  import { buildApprovedTeamHandoffSection, resolvePersistedApprovedTeamExecutionContinuityState, } from './approved-execution.js';
28
31
  import { readPersistedTeamUltragoalContext, renderLeaderOwnedUltragoalContextSection, } from './ultragoal-context.js';
32
+ const TASK_CLAIM_LOCK_STALE_MS = 5 * 60 * 1000;
33
+ async function withTaskClaimLocks(teamName, taskIds, cwd, fn) {
34
+ const uniqueTaskIds = [...new Set(taskIds)].sort((left, right) => Number(left) - Number(right));
35
+ const teamDir = (name, lockCwd) => join(resolveCanonicalTeamStateRoot(lockCwd), 'team', name);
36
+ const locks = {
37
+ teamDir,
38
+ taskClaimLockDir: (name, taskId, lockCwd) => (join(teamDir(name, lockCwd), 'claims', `task-${taskId}.lock`)),
39
+ mailboxLockDir: (name, workerName, lockCwd) => (join(teamDir(name, lockCwd), 'mailbox', `.lock-${workerName}`)),
40
+ };
41
+ const acquire = async (index) => {
42
+ if (index >= uniqueTaskIds.length)
43
+ return await fn();
44
+ const taskId = uniqueTaskIds[index];
45
+ const locked = await withTaskClaimLock(teamName, taskId, cwd, TASK_CLAIM_LOCK_STALE_MS, locks, async () => await acquire(index + 1));
46
+ if (!locked.ok)
47
+ throw new Error(`Timed out acquiring task claim lock for ${teamName}/${taskId}`);
48
+ return locked.value;
49
+ };
50
+ return await acquire(0);
51
+ }
29
52
  // ── Environment gate ──────────────────────────────────────────────────────────
30
53
  const OMX_TEAM_SCALING_ENABLED_ENV = 'OMX_TEAM_SCALING_ENABLED';
31
54
  const WORKTREE_TRIGGER_STATE_ROOT = '$OMX_TEAM_STATE_ROOT';
@@ -80,6 +103,67 @@ function buildScaleUpWorkerLaunchPlans(params) {
80
103
  function resolveInstructionStateRoot(worktreePath) {
81
104
  return worktreePath ? WORKTREE_TRIGGER_STATE_ROOT : undefined;
82
105
  }
106
+ function hasScaleUpFailureInjection(env, phase) {
107
+ return env.OMX_TEAM_SCALE_UP_INJECT_FAILURE?.split(',').map((value) => value.trim()).includes(phase) ?? false;
108
+ }
109
+ function throwIfScaleUpFailureInjected(env, phase) {
110
+ if (hasScaleUpFailureInjection(env, phase)) {
111
+ throw new Error(`injected_scale_up_failure:${phase}`);
112
+ }
113
+ }
114
+ function recoverCreatedWorktreeAfterEnsureFailure(plan, worktreePathExistedBeforeEnsure, branchExistedBeforeEnsure) {
115
+ if (worktreePathExistedBeforeEnsure || !existsSync(plan.worktreePath))
116
+ return null;
117
+ const commonDir = spawnSync('git', ['rev-parse', '--git-common-dir'], {
118
+ cwd: plan.worktreePath,
119
+ encoding: 'utf-8',
120
+ windowsHide: true,
121
+ });
122
+ const repoCommonDir = spawnSync('git', ['rev-parse', '--git-common-dir'], {
123
+ cwd: plan.repoRoot,
124
+ encoding: 'utf-8',
125
+ windowsHide: true,
126
+ });
127
+ if (commonDir.status !== 0
128
+ || repoCommonDir.status !== 0
129
+ || resolve(plan.worktreePath, (commonDir.stdout || '').trim())
130
+ !== resolve(plan.repoRoot, (repoCommonDir.stdout || '').trim())) {
131
+ return null;
132
+ }
133
+ if (plan.branchName) {
134
+ const branch = spawnSync('git', ['symbolic-ref', '-q', 'HEAD'], {
135
+ cwd: plan.worktreePath,
136
+ encoding: 'utf-8',
137
+ windowsHide: true,
138
+ });
139
+ if (branch.status !== 0 || (branch.stdout || '').trim() !== `refs/heads/${plan.branchName}`)
140
+ return null;
141
+ }
142
+ else {
143
+ const head = spawnSync('git', ['rev-parse', 'HEAD'], {
144
+ cwd: plan.worktreePath,
145
+ encoding: 'utf-8',
146
+ windowsHide: true,
147
+ });
148
+ const branch = spawnSync('git', ['symbolic-ref', '-q', 'HEAD'], {
149
+ cwd: plan.worktreePath,
150
+ encoding: 'utf-8',
151
+ windowsHide: true,
152
+ });
153
+ if (head.status !== 0 || branch.status === 0 || (head.stdout || '').trim() !== plan.baseRef)
154
+ return null;
155
+ }
156
+ return {
157
+ enabled: true,
158
+ repoRoot: plan.repoRoot,
159
+ worktreePath: plan.worktreePath,
160
+ detached: plan.detached,
161
+ branchName: plan.branchName,
162
+ created: true,
163
+ reused: false,
164
+ createdBranch: Boolean(plan.branchName && !branchExistedBeforeEnsure),
165
+ };
166
+ }
83
167
  function assertUnreachableApprovedExecutionState(state) {
84
168
  throw new Error(`unreachable_scale_up_approved_execution_state:${JSON.stringify(state)}`);
85
169
  }
@@ -146,9 +230,9 @@ function resolveScaleUpWorktreeMode(config) {
146
230
  throw error;
147
231
  }
148
232
  }
149
- async function notifyWorkerPaneOutcome(sessionName, workerIndex, message, paneId, workerCli) {
233
+ async function notifyWorkerPaneOutcome(sessionName, workerIndex, message, paneId, workerCli, expectedPanePid, expectedTeamOwnerId, hudPaneId) {
150
234
  try {
151
- await sendToWorker(sessionName, workerIndex, message, paneId, workerCli);
235
+ await sendToWorker(sessionName, workerIndex, message, paneId, workerCli, expectedPanePid, expectedTeamOwnerId, hudPaneId);
152
236
  return { ok: true, transport: 'tmux_send_keys', reason: 'tmux_send_keys_sent' };
153
237
  }
154
238
  catch (error) {
@@ -177,356 +261,1114 @@ export async function scaleUp(teamName, count, agentType, tasks, cwd, env = proc
177
261
  const sanitized = sanitizeTeamName(teamName);
178
262
  const leaderCwd = resolve(cwd);
179
263
  return await withScalingLock(sanitized, leaderCwd, async () => {
180
- const config = await readTeamConfig(sanitized, leaderCwd);
181
- if (!config) {
182
- return { ok: false, error: `Team ${sanitized} not found` };
183
- }
184
- const maxWorkers = config.max_workers;
185
- const currentCount = config.workers.length;
186
- if (currentCount + count > maxWorkers) {
187
- return {
188
- ok: false,
189
- error: `Cannot add ${count} workers: would exceed max_workers (${currentCount} + ${count} > ${maxWorkers})`,
190
- };
191
- }
192
- const teamStateRoot = config.team_state_root ?? resolveCanonicalTeamStateRoot(leaderCwd);
193
- const codexHomeOverride = resolveCodexHomeForLaunch(leaderCwd, env);
194
- const launchEnv = codexHomeOverride
195
- ? { ...env, CODEX_HOME: codexHomeOverride }
196
- : env;
197
- // Build and validate every launch plan before any task, directory, worktree,
198
- // pane, process, or config mutation. The plan is the sole source of launch
199
- // policy; later task materialization must not alter it.
200
- const initialNextIndex = config.next_worker_index ?? (currentCount + 1);
201
- let workerLaunchPlans;
202
- try {
203
- const existingTasks = await listTasks(sanitized, leaderCwd);
204
- workerLaunchPlans = buildScaleUpWorkerLaunchPlans({
205
- count,
206
- nextWorkerIndex: initialNextIndex,
207
- agentType,
208
- existingTasks,
209
- incomingTasks: tasks,
210
- launchEnv,
211
- codexHomeOverride,
264
+ return await withTaskMembershipBarrier(sanitized, leaderCwd, async () => {
265
+ const config = await readTeamConfig(sanitized, leaderCwd);
266
+ if (!config) {
267
+ return { ok: false, error: `Team ${sanitized} not found` };
268
+ }
269
+ const originalConfig = JSON.parse(JSON.stringify(config));
270
+ const configPath = join(resolveCanonicalTeamStateRoot(leaderCwd), 'team', sanitized, 'config.json');
271
+ const manifestPath = join(resolveCanonicalTeamStateRoot(leaderCwd), 'team', sanitized, 'manifest.v2.json');
272
+ const originalManifestBytes = existsSync(manifestPath) ? await readFile(manifestPath, 'utf8') : null;
273
+ const maxWorkers = config.max_workers;
274
+ const currentCount = config.workers.length;
275
+ if (currentCount + count > maxWorkers) {
276
+ return {
277
+ ok: false,
278
+ error: `Cannot add ${count} workers: would exceed max_workers (${currentCount} + ${count} > ${maxWorkers})`,
279
+ };
280
+ }
281
+ const teamStateRoot = config.team_state_root ?? resolveCanonicalTeamStateRoot(leaderCwd);
282
+ const codexHomeOverride = resolveCodexHomeForLaunch(leaderCwd, env);
283
+ const launchEnv = codexHomeOverride
284
+ ? { ...env, CODEX_HOME: codexHomeOverride }
285
+ : env;
286
+ // Build and validate every launch plan before any task, directory, worktree,
287
+ // pane, process, or config mutation. The plan is the sole source of launch
288
+ // policy; later task materialization must not alter it.
289
+ const initialNextIndex = config.next_worker_index ?? (currentCount + 1);
290
+ let workerLaunchPlans;
291
+ try {
292
+ const existingTasks = await listTasks(sanitized, leaderCwd);
293
+ workerLaunchPlans = buildScaleUpWorkerLaunchPlans({
294
+ count,
295
+ nextWorkerIndex: initialNextIndex,
296
+ agentType,
297
+ existingTasks,
298
+ incomingTasks: tasks,
299
+ launchEnv,
300
+ codexHomeOverride,
301
+ });
302
+ }
303
+ catch (error) {
304
+ return {
305
+ ok: false,
306
+ error: error instanceof Error ? error.message : String(error),
307
+ };
308
+ }
309
+ let nextIndex = initialNextIndex;
310
+ const sessionName = config.tmux_session;
311
+ const manifest = await readTeamManifestV2(sanitized, leaderCwd);
312
+ const dispatchPolicy = normalizeTeamPolicy(manifest?.policy, {
313
+ display_mode: manifest?.policy?.display_mode === 'split_pane' ? 'split_pane' : 'auto',
314
+ worker_launch_mode: config.worker_launch_mode,
212
315
  });
213
- }
214
- catch (error) {
215
- return {
216
- ok: false,
217
- error: error instanceof Error ? error.message : String(error),
218
- };
219
- }
220
- let nextIndex = initialNextIndex;
221
- const sessionName = config.tmux_session;
222
- const manifest = await readTeamManifestV2(sanitized, leaderCwd);
223
- const dispatchPolicy = normalizeTeamPolicy(manifest?.policy, {
224
- display_mode: manifest?.policy?.display_mode === 'split_pane' ? 'split_pane' : 'auto',
225
- worker_launch_mode: config.worker_launch_mode,
226
- });
227
- const approvedExecutionState = await resolvePersistedApprovedTeamExecutionContinuityState(sanitized, config.leader_cwd ?? leaderCwd, config.team_state_root ?? teamStateRoot);
228
- const approvedExecutionGate = resolveScaleUpApprovedExecutionGate(sanitized, approvedExecutionState);
229
- if (!approvedExecutionGate.ok) {
230
- return approvedExecutionGate;
231
- }
232
- const persistedUltragoalContext = await readPersistedTeamUltragoalContext(sanitized, config.leader_cwd ?? leaderCwd, config.team_state_root ?? teamStateRoot);
233
- const approvedContextSection = joinContextSections(approvedExecutionGate.approvedContextSection, renderLeaderOwnedUltragoalContextSection(persistedUltragoalContext));
234
- const effectiveWorktreeMode = config.worktree_mode ?? resolveScaleUpWorktreeMode(config);
235
- if (!config.worktree_mode && effectiveWorktreeMode.enabled) {
236
- config.worktree_mode = effectiveWorktreeMode;
237
- await saveTeamConfig(config, leaderCwd);
238
- }
239
- const addedWorkers = [];
240
- const createdTaskIds = [];
241
- const rollbackScaleUp = async (error, context = {}) => {
242
- for (const w of addedWorkers) {
243
- const idx = config.workers.findIndex((worker) => worker.name === w.name);
244
- if (idx >= 0) {
245
- config.workers.splice(idx, 1);
316
+ const approvedExecutionState = await resolvePersistedApprovedTeamExecutionContinuityState(sanitized, config.leader_cwd ?? leaderCwd, config.team_state_root ?? teamStateRoot);
317
+ const approvedExecutionGate = resolveScaleUpApprovedExecutionGate(sanitized, approvedExecutionState);
318
+ if (!approvedExecutionGate.ok) {
319
+ return approvedExecutionGate;
320
+ }
321
+ const persistedUltragoalContext = await readPersistedTeamUltragoalContext(sanitized, config.leader_cwd ?? leaderCwd, config.team_state_root ?? teamStateRoot);
322
+ const approvedContextSection = joinContextSections(approvedExecutionGate.approvedContextSection, renderLeaderOwnedUltragoalContextSection(persistedUltragoalContext));
323
+ const initialSplitTargetCandidate = config.workers[config.workers.length - 1];
324
+ const initialSplitTargetWorker = initialSplitTargetCandidate?.pane_id?.trim()
325
+ ? initialSplitTargetCandidate
326
+ : undefined;
327
+ const initialSplitTarget = initialSplitTargetWorker?.pane_id ?? config.leader_pane_id ?? '';
328
+ const initialSplitTargetOwnerId = typeof config.tmux_pane_owner_id === 'string'
329
+ ? config.tmux_pane_owner_id.trim()
330
+ : '';
331
+ if (!initialSplitTargetOwnerId) {
332
+ return {
333
+ ok: false,
334
+ error: `scale_up_split_target_owner_unavailable:${initialSplitTarget}`,
335
+ };
336
+ }
337
+ let initialExpectedSplitTargetPid = initialSplitTargetWorker
338
+ ? initialSplitTargetWorker.pid
339
+ : config.leader_pane_pid;
340
+ if (typeof initialExpectedSplitTargetPid !== 'number'
341
+ || !Number.isSafeInteger(initialExpectedSplitTargetPid)
342
+ || initialExpectedSplitTargetPid <= 0) {
343
+ // Legacy configs may lack a persisted PID, but the persisted pane ID and
344
+ // canonical owner remain mandatory. Pin only the exact live pane proven
345
+ // from one global snapshot, then re-prove it after reading the owner tag
346
+ // so a recycled pane ID cannot be backfilled.
347
+ const migrationProof = readExactPaneProofSync(initialSplitTarget);
348
+ if (migrationProof.status !== 'live') {
349
+ return {
350
+ ok: false,
351
+ error: `scale_up_split_target_proof_unavailable:${initialSplitTarget}:${migrationProof.reason}`,
352
+ };
353
+ }
354
+ const migrationOwner = readPaneTeamOwnerTagResult(migrationProof.paneId);
355
+ if (migrationOwner.status !== 'value' || migrationOwner.value !== initialSplitTargetOwnerId) {
356
+ return {
357
+ ok: false,
358
+ error: `scale_up_split_target_owner_changed:${migrationProof.paneId}`,
359
+ };
360
+ }
361
+ const finalMigrationProof = readExactPaneProofSync(migrationProof.paneId);
362
+ if (finalMigrationProof.status !== 'live') {
363
+ return {
364
+ ok: false,
365
+ error: `scale_up_split_target_proof_unavailable:${migrationProof.paneId}:${finalMigrationProof.reason}`,
366
+ };
367
+ }
368
+ if (finalMigrationProof.pid !== migrationProof.pid) {
369
+ return {
370
+ ok: false,
371
+ error: `scale_up_split_target_pid_changed:${migrationProof.paneId}:${migrationProof.pid}:${finalMigrationProof.pid}`,
372
+ };
373
+ }
374
+ if (initialSplitTargetWorker) {
375
+ initialSplitTargetWorker.pid = finalMigrationProof.pid;
376
+ }
377
+ else {
378
+ config.leader_pane_pid = finalMigrationProof.pid;
246
379
  }
247
380
  try {
248
- if (w.pane_id) {
249
- execFileSync('tmux', ['kill-pane', '-t', w.pane_id], { stdio: 'pipe',
250
- windowsHide: true,
251
- });
252
- }
381
+ await saveTeamConfig(config, leaderCwd);
253
382
  }
254
- catch { }
255
- if (w.worktree_path) {
256
- await removeWorkerWorktreeRootAgentsFile(sanitized, w.name, teamStateRoot, w.worktree_path).catch(() => { });
383
+ catch (error) {
384
+ return {
385
+ ok: false,
386
+ error: `scale_up_split_target_pid_persistence_failed:${initialSplitTarget}:${error instanceof Error ? error.message : String(error)}`,
387
+ };
257
388
  }
389
+ Object.assign(originalConfig, JSON.parse(JSON.stringify(config)));
390
+ initialExpectedSplitTargetPid = finalMigrationProof.pid;
258
391
  }
259
- if (context.workerName &&
260
- context.worktreePath &&
261
- !addedWorkers.some((worker) => worker.name === context.workerName)) {
262
- await removeWorkerWorktreeRootAgentsFile(sanitized, context.workerName, teamStateRoot, context.worktreePath).catch(() => { });
392
+ const initialSplitTargetProof = readExactPaneProofSync(initialSplitTarget);
393
+ if (initialSplitTargetProof.status !== 'live') {
394
+ return {
395
+ ok: false,
396
+ error: `scale_up_split_target_proof_unavailable:${initialSplitTarget}:${initialSplitTargetProof.reason}`,
397
+ };
398
+ }
399
+ if (initialSplitTargetProof.pid !== initialExpectedSplitTargetPid) {
400
+ return {
401
+ ok: false,
402
+ error: `scale_up_split_target_pid_changed:${initialSplitTarget}:${initialExpectedSplitTargetPid}:${initialSplitTargetProof.pid}`,
403
+ };
404
+ }
405
+ const effectiveWorktreeMode = config.worktree_mode ?? resolveScaleUpWorktreeMode(config);
406
+ if (!config.worktree_mode && effectiveWorktreeMode.enabled) {
407
+ config.worktree_mode = effectiveWorktreeMode;
263
408
  }
264
- if (context.paneId) {
409
+ const addedWorkers = [];
410
+ // A scale-up pane becomes killable during rollback only after its exact
411
+ // process identity was pinned and, when Team ownership is configured, its
412
+ // owner tag command completed. A returned pane ID alone is cleanup debt,
413
+ // never authority to affect a potentially recycled pane.
414
+ const rollbackTaggedPaneOwnerIds = new Map();
415
+ const createdTaskIds = [];
416
+ const provisionedWorktrees = [];
417
+ const preparedWorkerDirectoryOwner = new Map();
418
+ const preparedStartupScriptOwner = new Map();
419
+ const runtimeDirectoryPath = join(teamStateRoot, 'team', sanitized, 'runtime');
420
+ const runtimeDirectoryExisted = existsSync(runtimeDirectoryPath);
421
+ const rollbackScaleUp = async (error, context = {}) => {
422
+ const rollbackWorkers = [...new Map([...addedWorkers, context.worker]
423
+ .filter((worker) => Boolean(worker))
424
+ .map((worker) => [worker.name, worker])).values()];
425
+ const rollbackWorkerNames = new Set(rollbackWorkers.map((worker) => worker.name));
426
+ // Persist every concrete rollback worker before pane teardown. If the
427
+ // process exits after this point, canonical config/manifest state still
428
+ // identifies each live pane and its retryable resources.
265
429
  try {
266
- execFileSync('tmux', ['kill-pane', '-t', context.paneId], { stdio: 'pipe',
267
- windowsHide: true,
430
+ await withTaskMembershipBarrier(sanitized, leaderCwd, async () => {
431
+ await recoverTeamMembershipTaskTransaction(sanitized, leaderCwd);
432
+ const currentConfigBytes = await readFile(configPath, 'utf8');
433
+ const currentManifestBytes = existsSync(manifestPath) ? await readFile(manifestPath, 'utf8') : null;
434
+ const currentConfig = JSON.parse(currentConfigBytes);
435
+ const trackedByName = new Map(currentConfig.workers.map((worker) => [worker.name, worker]));
436
+ for (const worker of rollbackWorkers)
437
+ trackedByName.set(worker.name, worker);
438
+ const trackedConfig = {
439
+ ...currentConfig,
440
+ workers: [...trackedByName.values()],
441
+ worker_count: trackedByName.size,
442
+ next_worker_index: Math.max(currentConfig.next_worker_index ?? (currentConfig.workers.length + 1), ...rollbackWorkers.map((worker) => worker.index + 1)),
443
+ };
444
+ const trackedManifestBytes = currentManifestBytes === null
445
+ ? null
446
+ : JSON.stringify({
447
+ ...JSON.parse(currentManifestBytes),
448
+ workers: trackedConfig.workers,
449
+ worker_count: trackedConfig.worker_count,
450
+ next_worker_index: trackedConfig.next_worker_index,
451
+ }, null, 2);
452
+ await commitTeamMembershipTaskTransaction(sanitized, leaderCwd, {
453
+ baseGeneration: currentConfig.config_generation ?? 0,
454
+ tasks: [],
455
+ config: { oldBytes: currentConfigBytes, newBytes: JSON.stringify(trackedConfig, null, 2) },
456
+ manifest: { oldBytes: currentManifestBytes, newBytes: trackedManifestBytes },
457
+ recoverToNewOnFailure: true,
458
+ });
459
+ const verified = await readTeamConfig(sanitized, leaderCwd);
460
+ if (!verified || rollbackWorkers.some((worker) => !verified.workers.some((entry) => entry.name === worker.name && entry.pane_id === worker.pane_id))) {
461
+ throw new Error('canonical_scale_up_rollback_tracking_verification_failed');
462
+ }
463
+ Object.assign(config, verified);
268
464
  });
269
465
  }
270
- catch { }
271
- }
272
- for (const taskId of createdTaskIds) {
273
- await rm(join(leaderCwd, '.omx', 'state', 'team', sanitized, 'tasks', `task-${taskId}.json`), { force: true }).catch(() => { });
274
- }
275
- config.worker_count = config.workers.length;
276
- config.next_worker_index = initialNextIndex;
277
- await saveTeamConfig(config, leaderCwd);
278
- return { ok: false, error };
279
- };
280
- // Persist incoming tasks only after launch policy is frozen; the resulting
281
- // task listing is used for inbox and task materialization, never launch policy.
282
- for (const task of tasks) {
283
- const createdTask = await createStateTask(sanitized, {
284
- subject: task.subject,
285
- description: task.description,
286
- status: 'pending',
287
- owner: task.owner,
288
- blocked_by: task.blocked_by,
289
- role: task.role,
290
- }, leaderCwd);
291
- createdTaskIds.push(createdTask.id);
292
- }
293
- const materializedTasks = await listTasks(sanitized, leaderCwd);
294
- for (const workerLaunchPlan of workerLaunchPlans) {
295
- const { workerIndex, workerName, runtimeRole, workerLaunchArgs, workerCli, } = workerLaunchPlan;
296
- nextIndex = workerIndex + 1;
297
- if (workerLaunchPlan.mixedTaskRoles.length > 1) {
298
- console.log(`[omx:scaling] ${workerName}: mixed task roles [${workerLaunchPlan.mixedTaskRoles.join(', ')}], falling back to ${agentType}`);
299
- }
300
- // Create worker directory
301
- const workerDirPath = join(leaderCwd, '.omx', 'state', 'team', sanitized, 'workers', workerName);
302
- await mkdir(workerDirPath, { recursive: true });
303
- const worktreeMode = effectiveWorktreeMode;
304
- const workerWorkspaceResult = worktreeMode.enabled
305
- ? ensureWorktree(planWorktreeTarget({
306
- cwd: leaderCwd,
307
- scope: 'team',
308
- mode: worktreeMode,
309
- teamName: sanitized,
310
- workerName,
311
- }))
312
- : { enabled: false };
313
- const workerWorkspace = workerWorkspaceResult.enabled ? workerWorkspaceResult : null;
314
- const workerCwd = workerWorkspace ? workerWorkspace.worktreePath : leaderCwd;
315
- // Build startup command and create tmux pane
316
- const rawRolePromptContent = await loadRolePrompt(runtimeRole, join(leaderCwd, '.codex', 'prompts'))
317
- ?? await loadRolePrompt(runtimeRole, codexPromptsDir());
318
- const resolvedWorkerModel = parseTeamWorkerLaunchArgs(workerLaunchArgs).modelOverride ?? undefined;
319
- const rolePromptContent = rawRolePromptContent
320
- ? composeRoleInstructionsForRole(runtimeRole, rawRolePromptContent, resolvedWorkerModel)
321
- : null;
322
- const teamInstructionsPath = join(leaderCwd, '.omx', 'state', 'team', sanitized, 'worker-agents.md');
323
- const instructionsFilePath = workerWorkspace
324
- ? await writeWorkerWorktreeRootAgentsFile({
325
- teamName: sanitized,
326
- workerName,
327
- workerRole: runtimeRole,
328
- rolePromptContent: rolePromptContent ?? '',
329
- teamStateRoot,
330
- leaderCwd,
331
- worktreePath: workerWorkspace.worktreePath,
332
- })
333
- : rolePromptContent
334
- ? await writeWorkerRoleInstructionsFile(sanitized, workerName, leaderCwd, teamInstructionsPath, runtimeRole, rolePromptContent)
335
- : teamInstructionsPath;
336
- const extraEnv = {
337
- OMX_TEAM_STATE_ROOT: teamStateRoot,
338
- OMX_TEAM_LEADER_CWD: leaderCwd,
339
- OMX_MODEL_INSTRUCTIONS_FILE: instructionsFilePath,
340
- ...(codexHomeOverride ? { CODEX_HOME: codexHomeOverride } : {}),
466
+ catch (trackingError) {
467
+ return { ok: false, error: `scale_up_rollback_membership_persistence_failed:${String(trackingError)}` };
468
+ }
469
+ const cleanupDebt = [];
470
+ try {
471
+ await removeDispatchRequestsForWorkers(sanitized, [...rollbackWorkerNames], leaderCwd);
472
+ }
473
+ catch (rollbackError) {
474
+ cleanupDebt.push(`authoritative_dispatch_cleanup_failed:${String(rollbackError)}`);
475
+ }
476
+ const rollbackPaneIds = [...new Set([
477
+ ...rollbackWorkers.map((worker) => worker.pane_id),
478
+ context.paneId,
479
+ ].filter((paneId) => typeof paneId === 'string' && paneId.trim().startsWith('%')))];
480
+ const resolvedPaneIds = new Set();
481
+ const unresolvedPaneIds = new Set();
482
+ const expectedPanePids = {};
483
+ const requiredTeamOwnerId = config.tmux_pane_owner_id?.trim();
484
+ const authorizedRollbackPaneIds = new Set();
485
+ for (const paneId of rollbackPaneIds) {
486
+ const matchingWorkers = rollbackWorkers.filter((worker) => worker.pane_id === paneId);
487
+ const worker = matchingWorkers.length === 1 ? matchingWorkers[0] : undefined;
488
+ const expectedPanePid = worker?.pid;
489
+ if (!worker || typeof expectedPanePid !== 'number' || !Number.isInteger(expectedPanePid) || expectedPanePid <= 0) {
490
+ unresolvedPaneIds.add(paneId);
491
+ cleanupDebt.push(`pane_pid_unpinned:${paneId}`);
492
+ continue;
493
+ }
494
+ if (requiredTeamOwnerId && rollbackTaggedPaneOwnerIds.get(paneId) !== requiredTeamOwnerId) {
495
+ unresolvedPaneIds.add(paneId);
496
+ cleanupDebt.push(`pane_owner_unverified:${paneId}`);
497
+ continue;
498
+ }
499
+ expectedPanePids[paneId] = expectedPanePid;
500
+ authorizedRollbackPaneIds.add(paneId);
501
+ }
502
+ try {
503
+ const paneTeardown = await teardownWorkerPanes([...authorizedRollbackPaneIds], {
504
+ leaderPaneId: config.leader_pane_id,
505
+ hudPaneId: config.hud_pane_id,
506
+ expectedPanePids,
507
+ authorizePaneKill: (paneId) => {
508
+ if (!requiredTeamOwnerId)
509
+ return true;
510
+ const expectedOwnerId = rollbackTaggedPaneOwnerIds.get(paneId);
511
+ if (expectedOwnerId !== requiredTeamOwnerId)
512
+ return false;
513
+ const currentOwner = readPaneTeamOwnerTagResult(paneId);
514
+ return currentOwner.status === 'value' && currentOwner.value === expectedOwnerId;
515
+ },
516
+ });
517
+ for (const paneId of [...paneTeardown.provenGonePaneIds, ...paneTeardown.killedPaneIds])
518
+ resolvedPaneIds.add(paneId);
519
+ for (const paneId of paneTeardown.kill.failedPaneIds)
520
+ unresolvedPaneIds.add(paneId);
521
+ for (const proof of paneTeardown.proofUnavailable)
522
+ unresolvedPaneIds.add(proof.paneId);
523
+ if (paneTeardown.kill.failedPaneIds.length > 0)
524
+ cleanupDebt.push(`pane_teardown_failed:${paneTeardown.kill.failedPaneIds.join(',')}`);
525
+ if (paneTeardown.proofUnavailable.length > 0) {
526
+ cleanupDebt.push(`pane_proof_unavailable:${paneTeardown.proofUnavailable.map((proof) => `${proof.paneId}:${proof.reason}`).join(',')}`);
527
+ }
528
+ if (paneTeardown.kill.failedPaneIds.length > 0 || paneTeardown.proofUnavailable.length > 0) {
529
+ for (const paneId of rollbackPaneIds) {
530
+ if (!resolvedPaneIds.has(paneId))
531
+ unresolvedPaneIds.add(paneId);
532
+ }
533
+ const unresolvedWithoutDirectFailure = [...unresolvedPaneIds].filter((paneId) => !paneTeardown.kill.failedPaneIds.includes(paneId)
534
+ && !paneTeardown.proofUnavailable.some((proof) => proof.paneId === paneId));
535
+ if (unresolvedWithoutDirectFailure.length > 0) {
536
+ cleanupDebt.push(`pane_teardown_unresolved:${unresolvedWithoutDirectFailure.join(',')}`);
537
+ }
538
+ }
539
+ }
540
+ catch (cleanupError) {
541
+ for (const paneId of rollbackPaneIds)
542
+ unresolvedPaneIds.add(paneId);
543
+ cleanupDebt.push(`pane_cleanup_exception:${String(cleanupError)}`);
544
+ }
545
+ const unresolvedWorkerNames = new Set(rollbackWorkers
546
+ .filter((worker) => typeof worker.pane_id === 'string' && unresolvedPaneIds.has(worker.pane_id))
547
+ .map((worker) => worker.name));
548
+ if (context.workerName && context.paneId && unresolvedPaneIds.has(context.paneId)) {
549
+ unresolvedWorkerNames.add(context.workerName);
550
+ }
551
+ try {
552
+ await withTaskMembershipBarrier(sanitized, leaderCwd, async () => {
553
+ await recoverTeamMembershipTaskTransaction(sanitized, leaderCwd);
554
+ const currentConfigBytes = await readFile(configPath, 'utf8');
555
+ const currentConfig = JSON.parse(currentConfigBytes);
556
+ const currentManifestBytes = existsSync(manifestPath) ? await readFile(manifestPath, 'utf8') : null;
557
+ const retainedWorkers = rollbackWorkers.filter((worker) => unresolvedWorkerNames.has(worker.name));
558
+ const desiredByName = new Map(originalConfig.workers.map((worker) => [worker.name, worker]));
559
+ for (const worker of retainedWorkers)
560
+ desiredByName.set(worker.name, worker);
561
+ const desiredConfig = {
562
+ ...originalConfig,
563
+ workers: [...desiredByName.values()],
564
+ worker_count: desiredByName.size,
565
+ next_worker_index: Math.max(originalConfig.next_worker_index ?? (originalConfig.workers.length + 1), ...retainedWorkers.map((worker) => worker.index + 1)),
566
+ };
567
+ const desiredManifestBytes = originalManifestBytes === null
568
+ ? null
569
+ : JSON.stringify({
570
+ ...JSON.parse(originalManifestBytes),
571
+ workers: desiredConfig.workers,
572
+ worker_count: desiredConfig.worker_count,
573
+ next_worker_index: desiredConfig.next_worker_index,
574
+ }, null, 2);
575
+ const taskChanges = [];
576
+ for (const taskId of createdTaskIds) {
577
+ const taskPath = join(teamStateRoot, 'team', sanitized, 'tasks', `task-${taskId}.json`);
578
+ const task = await readTask(sanitized, taskId, leaderCwd);
579
+ taskChanges.push({
580
+ taskId,
581
+ oldBytes: existsSync(taskPath) ? await readFile(taskPath, 'utf8') : null,
582
+ newBytes: task && unresolvedWorkerNames.has(task.owner ?? '')
583
+ ? JSON.stringify(task, null, 2)
584
+ : null,
585
+ });
586
+ }
587
+ await commitTeamMembershipTaskTransaction(sanitized, leaderCwd, {
588
+ baseGeneration: currentConfig.config_generation ?? 0,
589
+ tasks: taskChanges,
590
+ config: { oldBytes: currentConfigBytes, newBytes: JSON.stringify(desiredConfig, null, 2) },
591
+ manifest: { oldBytes: currentManifestBytes, newBytes: desiredManifestBytes },
592
+ recoverToNewOnFailure: true,
593
+ retainJournalOnSuccess: true,
594
+ failRollbackPersistence: hasScaleUpFailureInjection(env, 'rollback-membership-persistence'),
595
+ failRollbackPersistenceAfter: hasScaleUpFailureInjection(env, 'rollback-membership-config-persistence')
596
+ ? 'config'
597
+ : hasScaleUpFailureInjection(env, 'rollback-membership-manifest-persistence')
598
+ ? 'manifest'
599
+ : undefined,
600
+ });
601
+ const verified = JSON.parse(await readFile(configPath, 'utf8'));
602
+ if (!verified || retainedWorkers.some((worker) => !verified.workers.some((entry) => entry.name === worker.name && entry.pane_id === worker.pane_id))) {
603
+ throw new Error('canonical_scale_up_rollback_membership_verification_failed');
604
+ }
605
+ if (verified.workers.some((worker) => rollbackWorkerNames.has(worker.name) && !unresolvedWorkerNames.has(worker.name))) {
606
+ throw new Error('canonical_scale_up_rollback_resolved_membership_verification_failed');
607
+ }
608
+ await finalizeTeamMembershipTaskTransaction(sanitized, leaderCwd);
609
+ Object.assign(config, verified);
610
+ });
611
+ }
612
+ catch (rollbackError) {
613
+ const journalPath = join(teamStateRoot, 'team', sanitized, '.membership-task-transaction.json');
614
+ const suffix = existsSync(journalPath) ? String(rollbackError) : `no_recoverable_journal:${String(rollbackError)}`;
615
+ return { ok: false, error: `scale_up_rollback_membership_persistence_failed:${suffix}` };
616
+ }
617
+ const cleanupWorkerNames = new Set([
618
+ ...rollbackWorkers
619
+ .filter((worker) => typeof worker.pane_id !== 'string' || resolvedPaneIds.has(worker.pane_id))
620
+ .map((worker) => worker.name),
621
+ ...(context.workerName && !unresolvedWorkerNames.has(context.workerName) ? [context.workerName] : []),
622
+ ]);
623
+ try {
624
+ for (const taskId of createdTaskIds) {
625
+ const task = await readTask(sanitized, taskId, leaderCwd);
626
+ if (task && unresolvedWorkerNames.has(task.owner ?? ''))
627
+ continue;
628
+ await rm(join(teamStateRoot, 'team', sanitized, 'tasks', `task-${taskId}.json`), { force: true });
629
+ }
630
+ await Promise.all([...cleanupWorkerNames].map(async (workerName) => {
631
+ await rm(join(teamStateRoot, 'team', sanitized, 'workers', workerName), { recursive: true, force: true });
632
+ }));
633
+ for (const taskId of createdTaskIds) {
634
+ const task = await readTask(sanitized, taskId, leaderCwd);
635
+ if (task && unresolvedWorkerNames.has(task.owner ?? ''))
636
+ continue;
637
+ if (task)
638
+ throw new Error(`canonical_scale_up_rollback_task_verification_failed:${taskId}`);
639
+ }
640
+ for (const workerName of cleanupWorkerNames) {
641
+ if (existsSync(join(teamStateRoot, 'team', sanitized, 'workers', workerName))) {
642
+ throw new Error(`canonical_scale_up_rollback_worker_verification_failed:${workerName}`);
643
+ }
644
+ }
645
+ }
646
+ catch (rollbackError) {
647
+ cleanupDebt.push(`canonical_cleanup_failed:${String(rollbackError)}`);
648
+ }
649
+ try {
650
+ const contextWorkerName = context.worker?.name ?? context.workerName;
651
+ const contextWorktreePath = context.worker?.worktree_path ?? context.worktreePath;
652
+ if (contextWorkerName && contextWorktreePath && !unresolvedWorkerNames.has(contextWorkerName)) {
653
+ await removeWorkerWorktreeRootAgentsFile(sanitized, contextWorkerName, teamStateRoot, contextWorktreePath);
654
+ }
655
+ const unresolvedWorktreePaths = new Set(rollbackWorkers
656
+ .filter((worker) => unresolvedWorkerNames.has(worker.name) && typeof worker.worktree_path === 'string')
657
+ .map((worker) => resolve(worker.worktree_path)));
658
+ await rollbackProvisionedWorktrees(provisionedWorktrees.filter((worktree) => !unresolvedWorktreePaths.has(resolve(worktree.worktreePath))));
659
+ await Promise.all([...preparedWorkerDirectoryOwner.entries()]
660
+ .filter(([, workerName]) => !unresolvedWorkerNames.has(workerName))
661
+ .map(async ([workerDirPath]) => {
662
+ await rm(workerDirPath, { recursive: true, force: true });
663
+ }));
664
+ await Promise.all([...preparedStartupScriptOwner.entries()]
665
+ .filter(([, workerName]) => !unresolvedWorkerNames.has(workerName))
666
+ .map(async ([startupScriptPath]) => {
667
+ await rm(startupScriptPath, { force: true });
668
+ }));
669
+ if (!runtimeDirectoryExisted && unresolvedWorkerNames.size === 0)
670
+ await rm(runtimeDirectoryPath, { recursive: true, force: true });
671
+ }
672
+ catch (cleanupError) {
673
+ cleanupDebt.push(`resource_cleanup_failed:${String(cleanupError)}`);
674
+ }
675
+ if (cleanupDebt.length > 0) {
676
+ const reason = `scale_up_rollback_cleanup_debt:${cleanupDebt.join(';')}; retry cleanup for [${[...rollbackWorkerNames].join(',')}]`;
677
+ try {
678
+ await appendTeamEvent(sanitized, { type: 'team_leader_nudge', worker: 'leader-fixed', reason }, leaderCwd);
679
+ }
680
+ catch (eventError) {
681
+ cleanupDebt.push(`cleanup_debt_event_failed:${String(eventError)}`);
682
+ }
683
+ return { ok: false, error: `scale_up_rollback_cleanup_debt:${cleanupDebt.join(';')}` };
684
+ }
685
+ return { ok: false, error };
341
686
  };
342
- if (workerWorkspace) {
343
- extraEnv.OMX_TEAM_WORKTREE_PATH = workerWorkspace.worktreePath;
344
- if (workerWorkspace.branchName) {
345
- extraEnv.OMX_TEAM_WORKTREE_BRANCH = workerWorkspace.branchName;
346
- }
347
- extraEnv.OMX_TEAM_WORKTREE_DETACHED = workerWorkspace.detached ? '1' : '0';
348
- }
349
- trustWorkerMiseConfigIfAvailable(workerCwd);
350
- const cmd = writeWorkerStartupScriptCommand(sanitized, workerIndex, workerLaunchArgs, workerCwd, extraEnv, workerCli, undefined, runtimeRole) ?? buildWorkerStartupCommand(sanitized, workerIndex, workerLaunchArgs, workerCwd, extraEnv, workerCli, undefined, runtimeRole);
351
- // Find the right-most worker pane to split from, or fall back to leader pane.
352
- // Keep the initial split from leader horizontal to preserve the leader-left
353
- // / workers-right composition.
354
- const splitTarget = config.workers.length > 0
355
- ? (config.workers[config.workers.length - 1]?.pane_id ?? config.leader_pane_id ?? '')
356
- : (config.leader_pane_id ?? '');
357
- const splitDirection = splitTarget === (config.leader_pane_id ?? '') ? '-h' : '-v';
358
- const result = spawnSync('tmux', [
359
- 'split-window', splitDirection, '-t', splitTarget, '-d', '-P', '-F', '#{pane_id}', '-c', workerCwd, cmd,
360
- ], { encoding: 'utf-8' });
361
- if (result.status !== 0) {
362
- return await rollbackScaleUp(`Failed to create tmux pane for ${workerName}: ${(result.stderr || '').trim()}`, { workerName, worktreePath: workerWorkspace?.worktreePath });
363
- }
364
- const paneId = (result.stdout || '').trim().split('\n')[0]?.trim();
365
- if (!paneId || !paneId.startsWith('%')) {
366
- return await rollbackScaleUp(`Failed to capture pane ID for ${workerName}`, {
367
- paneId,
368
- workerName,
369
- worktreePath: workerWorkspace?.worktreePath,
370
- });
687
+ // Persist incoming tasks only after launch policy is frozen; the resulting
688
+ // task listing is used for inbox and task materialization, never launch policy.
689
+ let materializedTasks;
690
+ try {
691
+ for (const task of tasks) {
692
+ const createdTask = await createStateTask(sanitized, {
693
+ subject: task.subject,
694
+ description: task.description,
695
+ status: 'pending',
696
+ owner: task.owner,
697
+ blocked_by: task.blocked_by,
698
+ role: task.role,
699
+ }, leaderCwd);
700
+ createdTaskIds.push(createdTask.id);
701
+ }
702
+ materializedTasks = await listTasks(sanitized, leaderCwd);
703
+ }
704
+ catch (error) {
705
+ return await rollbackScaleUp(`scale_up_task_materialization_failed:${error instanceof Error ? error.message : String(error)}`);
371
706
  }
372
- if (config.tmux_pane_owner_id) {
707
+ for (const workerLaunchPlan of workerLaunchPlans) {
708
+ const { workerIndex, workerName, runtimeRole, workerLaunchArgs, workerCli, } = workerLaunchPlan;
709
+ // Freeze the split target and its identity before preparing any artifacts.
710
+ // Later proof must establish that this exact pane process still owns the
711
+ // target; a recycled pane ID must never become split authority.
712
+ const splitTargetCandidate = config.workers[config.workers.length - 1];
713
+ const splitTargetWorker = splitTargetCandidate?.pane_id?.trim() ? splitTargetCandidate : undefined;
714
+ const splitTarget = splitTargetWorker?.pane_id ?? config.leader_pane_id ?? '';
715
+ const expectedSplitTargetPid = splitTargetWorker
716
+ ? splitTargetWorker.pid
717
+ : config.leader_pane_pid;
718
+ const expectedSplitTargetOwnerId = typeof config.tmux_pane_owner_id === 'string'
719
+ ? config.tmux_pane_owner_id.trim()
720
+ : '';
721
+ if (typeof expectedSplitTargetPid !== 'number'
722
+ || !Number.isSafeInteger(expectedSplitTargetPid)
723
+ || expectedSplitTargetPid <= 0) {
724
+ return await rollbackScaleUp(`scale_up_split_target_pid_missing:${splitTarget}`);
725
+ }
726
+ if (!expectedSplitTargetOwnerId) {
727
+ return await rollbackScaleUp(`scale_up_split_target_owner_unavailable:${splitTarget}`);
728
+ }
729
+ const splitDirection = splitTarget === (config.leader_pane_id ?? '') ? '-h' : '-v';
730
+ nextIndex = workerIndex + 1;
731
+ if (workerLaunchPlan.mixedTaskRoles.length > 1) {
732
+ console.log(`[omx:scaling] ${workerName}: mixed task roles [${workerLaunchPlan.mixedTaskRoles.join(', ')}], falling back to ${agentType}`);
733
+ }
734
+ // Prepare every pre-pane artifact under the rollback boundary. A pane is
735
+ // not required for ownership: workerName and any created worktree are
736
+ // sufficient for rollback to clean a failed preparation.
737
+ const workerDirPath = join(leaderCwd, '.omx', 'state', 'team', sanitized, 'workers', workerName);
738
+ const startupScriptPath = join(teamStateRoot, 'team', sanitized, 'runtime', `worker-${workerIndex}-startup.sh`);
739
+ let workerWorkspace = null;
740
+ let workerCwd = leaderCwd;
741
+ let cmd;
742
+ let rawRolePromptContent = null;
373
743
  try {
374
- tagPaneTeamOwner(paneId, config.tmux_pane_owner_id);
744
+ preparedWorkerDirectoryOwner.set(workerDirPath, workerName);
745
+ await mkdir(workerDirPath, { recursive: true });
746
+ if (effectiveWorktreeMode.enabled) {
747
+ const worktreePlan = planWorktreeTarget({
748
+ cwd: leaderCwd,
749
+ scope: 'team',
750
+ mode: effectiveWorktreeMode,
751
+ teamName: sanitized,
752
+ workerName,
753
+ });
754
+ if (!worktreePlan.enabled)
755
+ throw new Error(`worktree_not_planned:${workerName}`);
756
+ const worktreePathExistedBeforeEnsure = existsSync(worktreePlan.worktreePath);
757
+ const branchExistedBeforeEnsure = worktreePlan.branchName
758
+ ? spawnSync('git', ['show-ref', '--verify', '--quiet', `refs/heads/${worktreePlan.branchName}`], {
759
+ cwd: worktreePlan.repoRoot,
760
+ encoding: 'utf-8',
761
+ windowsHide: true,
762
+ }).status === 0
763
+ : false;
764
+ try {
765
+ const ensuredWorkspace = ensureWorktree(worktreePlan);
766
+ throwIfScaleUpFailureInjected(env, 'worktree-ensure-post-create');
767
+ if (!ensuredWorkspace.enabled)
768
+ throw new Error(`worktree_not_provisioned:${workerName}`);
769
+ workerWorkspace = ensuredWorkspace;
770
+ }
771
+ catch (error) {
772
+ const recoveredWorkspace = recoverCreatedWorktreeAfterEnsureFailure(worktreePlan, worktreePathExistedBeforeEnsure, branchExistedBeforeEnsure);
773
+ if (recoveredWorkspace) {
774
+ workerWorkspace = recoveredWorkspace;
775
+ provisionedWorktrees.push(recoveredWorkspace);
776
+ }
777
+ throw error;
778
+ }
779
+ if (!workerWorkspace)
780
+ throw new Error(`worktree_not_provisioned:${workerName}`);
781
+ provisionedWorktrees.push(workerWorkspace);
782
+ throwIfScaleUpFailureInjected(env, 'worktree-post-create');
783
+ workerCwd = workerWorkspace.worktreePath;
784
+ }
785
+ rawRolePromptContent = await loadRolePrompt(runtimeRole, join(leaderCwd, '.codex', 'prompts'))
786
+ ?? await loadRolePrompt(runtimeRole, codexPromptsDir());
787
+ const resolvedWorkerModel = parseTeamWorkerLaunchArgs(workerLaunchArgs).modelOverride ?? undefined;
788
+ const rolePromptContent = rawRolePromptContent
789
+ ? composeRoleInstructionsForRole(runtimeRole, rawRolePromptContent, resolvedWorkerModel)
790
+ : null;
791
+ const teamInstructionsPath = join(leaderCwd, '.omx', 'state', 'team', sanitized, 'worker-agents.md');
792
+ const instructionsFilePath = workerWorkspace
793
+ ? await writeWorkerWorktreeRootAgentsFile({
794
+ teamName: sanitized,
795
+ workerName,
796
+ workerRole: runtimeRole,
797
+ rolePromptContent: rolePromptContent ?? '',
798
+ teamStateRoot,
799
+ leaderCwd,
800
+ worktreePath: workerWorkspace.worktreePath,
801
+ })
802
+ : rolePromptContent
803
+ ? await writeWorkerRoleInstructionsFile(sanitized, workerName, leaderCwd, teamInstructionsPath, runtimeRole, rolePromptContent)
804
+ : teamInstructionsPath;
805
+ const extraEnv = {
806
+ OMX_TEAM_STATE_ROOT: teamStateRoot,
807
+ OMX_TEAM_LEADER_CWD: leaderCwd,
808
+ OMX_MODEL_INSTRUCTIONS_FILE: instructionsFilePath,
809
+ ...(codexHomeOverride ? { CODEX_HOME: codexHomeOverride } : {}),
810
+ };
811
+ if (workerWorkspace) {
812
+ extraEnv.OMX_TEAM_WORKTREE_PATH = workerWorkspace.worktreePath;
813
+ if (workerWorkspace.branchName) {
814
+ extraEnv.OMX_TEAM_WORKTREE_BRANCH = workerWorkspace.branchName;
815
+ }
816
+ extraEnv.OMX_TEAM_WORKTREE_DETACHED = workerWorkspace.detached ? '1' : '0';
817
+ }
818
+ trustWorkerMiseConfigIfAvailable(workerCwd);
819
+ preparedStartupScriptOwner.set(startupScriptPath, workerName);
820
+ const startupCommand = writeWorkerStartupScriptCommand(sanitized, workerIndex, workerLaunchArgs, workerCwd, extraEnv, workerCli, undefined, runtimeRole);
821
+ cmd = startupCommand ?? buildWorkerStartupCommand(sanitized, workerIndex, workerLaunchArgs, workerCwd, extraEnv, workerCli, undefined, runtimeRole);
375
822
  }
376
823
  catch (error) {
377
- return await rollbackScaleUp(`Failed to tag tmux pane for ${workerName}: ${error instanceof Error ? error.message : String(error)}`, { paneId, workerName, worktreePath: workerWorkspace?.worktreePath });
378
- }
379
- }
380
- // Intentionally avoid forcing `select-layout tiled` here.
381
- // Tiled relayout reflows leader/HUD panes and breaks team window layout.
382
- // Get PID
383
- const panePid = getWorkerPanePid(sessionName, workerIndex, paneId);
384
- const workerInfo = {
385
- name: workerName,
386
- index: workerIndex,
387
- role: runtimeRole,
388
- worker_cli: workerCli,
389
- assigned_tasks: [],
390
- pid: panePid ?? undefined,
391
- pane_id: paneId,
392
- working_dir: workerCwd,
393
- worktree_repo_root: workerWorkspace ? workerWorkspace.repoRoot : undefined,
394
- worktree_path: workerWorkspace ? workerWorkspace.worktreePath : undefined,
395
- worktree_branch: workerWorkspace ? (workerWorkspace.branchName ?? undefined) : undefined,
396
- worktree_detached: workerWorkspace ? workerWorkspace.detached : undefined,
397
- worktree_created: workerWorkspace ? workerWorkspace.created : undefined,
398
- team_state_root: teamStateRoot,
399
- };
400
- await writeWorkerIdentity(sanitized, workerName, workerInfo, leaderCwd);
401
- // Wait for worker readiness
402
- const readyTimeoutMs = resolveWorkerReadyTimeoutMs(env);
403
- const skipReadyWait = env.OMX_TEAM_SKIP_READY_WAIT === '1';
404
- if (!skipReadyWait) {
405
- const ready = waitForWorkerReady(sessionName, workerIndex, readyTimeoutMs, paneId);
406
- if (!ready) {
407
- console.log(`[omx:scaling] Warning: worker ${workerName} did not become ready within timeout`);
408
- }
409
- }
410
- // Get assigned tasks for this worker
411
- const workerTasks = materializedTasks.filter(t => t.owner === workerName);
412
- const inbox = generateInitialInbox(workerName, sanitized, agentType, workerTasks, {
413
- teamStateRoot,
414
- leaderCwd,
415
- workerRole: runtimeRole,
416
- rolePromptContent: rawRolePromptContent ?? undefined,
417
- worktreeRootAgentsCanonical: Boolean(workerWorkspace?.worktreePath),
418
- approvedContextSection,
419
- workerGoalInstruction: buildTeamWorkerGoalInstruction(sanitized, workerName, workerTasks, { teamStateRoot }),
420
- });
421
- const triggerDirective = buildTriggerDirective(workerName, sanitized, resolveInstructionStateRoot(workerInfo.worktree_path));
422
- const queued = await queueInboxInstruction({
423
- teamName: sanitized,
424
- workerName,
425
- workerIndex,
426
- paneId,
427
- inbox,
428
- triggerMessage: triggerDirective.text,
429
- intent: triggerDirective.intent,
430
- cwd: leaderCwd,
431
- transportPreference: dispatchPolicy.dispatch_mode,
432
- fallbackAllowed: true,
433
- inboxCorrelationKey: `scale_up:${workerName}`,
434
- notify: async (_target, message) => {
435
- if (dispatchPolicy.dispatch_mode === 'hook_preferred_with_fallback') {
436
- return { ok: true, transport: 'hook', reason: 'queued_for_hook_dispatch' };
824
+ return await rollbackScaleUp(`scale_up_worker_preparation_failed:${workerName}:${error instanceof Error ? error.message : String(error)}`, { workerName, worktreePath: workerWorkspace?.worktreePath });
825
+ }
826
+ // The target and its expected PID were frozen before preparation. Prove
827
+ // the same identity immediately before the split-window side effect.
828
+ const splitTargetProof = readExactPaneProofSync(splitTarget);
829
+ if (splitTargetProof.status !== 'live') {
830
+ return await rollbackScaleUp(`scale_up_split_target_proof_unavailable:${splitTarget}:${splitTargetProof.reason}`, { workerName, worktreePath: workerWorkspace?.worktreePath });
831
+ }
832
+ if (splitTargetProof.pid !== expectedSplitTargetPid) {
833
+ return await rollbackScaleUp(`scale_up_split_target_pid_changed:${splitTarget}:${expectedSplitTargetPid}:${splitTargetProof.pid}`, { workerName, worktreePath: workerWorkspace?.worktreePath });
834
+ }
835
+ const currentSplitTargetOwner = readPaneTeamOwnerTagResult(splitTargetProof.paneId);
836
+ if (currentSplitTargetOwner.status !== 'value' || currentSplitTargetOwner.value !== expectedSplitTargetOwnerId) {
837
+ return await rollbackScaleUp(`scale_up_split_target_owner_changed:${splitTargetProof.paneId}`, { workerName, worktreePath: workerWorkspace?.worktreePath });
838
+ }
839
+ // The owner read is untrusted and may race pane-ID reuse. Bind the split
840
+ // to the same persisted process again immediately before the effect.
841
+ const finalSplitTargetProof = readExactPaneProofSync(splitTargetProof.paneId);
842
+ if (finalSplitTargetProof.status !== 'live') {
843
+ return await rollbackScaleUp(`scale_up_split_target_proof_unavailable:${splitTargetProof.paneId}:${finalSplitTargetProof.reason}`, { workerName, worktreePath: workerWorkspace?.worktreePath });
844
+ }
845
+ if (finalSplitTargetProof.pid !== expectedSplitTargetPid) {
846
+ return await rollbackScaleUp(`scale_up_split_target_pid_changed:${splitTargetProof.paneId}:${expectedSplitTargetPid}:${finalSplitTargetProof.pid}`, { workerName, worktreePath: workerWorkspace?.worktreePath });
847
+ }
848
+ const result = spawnSync('tmux', [
849
+ 'split-window', splitDirection, '-t', finalSplitTargetProof.paneId, '-d', '-P', '-F', '#{pane_id}', '-c', workerCwd, cmd,
850
+ ], { encoding: 'utf-8' });
851
+ if (result.status !== 0) {
852
+ return await rollbackScaleUp(`Failed to create tmux pane for ${workerName}: ${(result.stderr || '').trim()}`, { workerName, worktreePath: workerWorkspace?.worktreePath });
853
+ }
854
+ const paneId = (result.stdout || '').trim().split('\n')[0]?.trim();
855
+ if (!paneId || !paneId.startsWith('%')) {
856
+ return await rollbackScaleUp(`Failed to capture pane ID for ${workerName}`, {
857
+ paneId,
858
+ workerName,
859
+ worktreePath: workerWorkspace?.worktreePath,
860
+ });
861
+ }
862
+ const paneProof = readExactPaneProofSync(paneId);
863
+ const workerInfo = {
864
+ name: workerName,
865
+ index: workerIndex,
866
+ role: runtimeRole,
867
+ worker_cli: workerCli,
868
+ assigned_tasks: [],
869
+ pid: paneProof.status === 'live' ? paneProof.pid : undefined,
870
+ pane_id: paneId,
871
+ working_dir: workerCwd,
872
+ worktree_repo_root: workerWorkspace ? workerWorkspace.repoRoot : undefined,
873
+ worktree_path: workerWorkspace ? workerWorkspace.worktreePath : undefined,
874
+ worktree_branch: workerWorkspace ? (workerWorkspace.branchName ?? undefined) : undefined,
875
+ worktree_detached: workerWorkspace ? workerWorkspace.detached : undefined,
876
+ worktree_created: workerWorkspace ? workerWorkspace.created : undefined,
877
+ team_state_root: teamStateRoot,
878
+ };
879
+ if (paneProof.status !== 'live') {
880
+ return await rollbackScaleUp(`Failed to prove tmux pane for ${workerName}`, { paneId, worker: workerInfo });
881
+ }
882
+ if (config.tmux_pane_owner_id) {
883
+ try {
884
+ tagPaneTeamOwner(paneProof.paneId, config.tmux_pane_owner_id, paneProof.pid);
885
+ rollbackTaggedPaneOwnerIds.set(paneProof.paneId, config.tmux_pane_owner_id.trim());
886
+ }
887
+ catch (error) {
888
+ return await rollbackScaleUp(`Failed to tag tmux pane for ${workerName}: ${error instanceof Error ? error.message : String(error)}`, { paneId, worker: workerInfo });
437
889
  }
438
- return await notifyWorkerPaneOutcome(sessionName, workerIndex, message, paneId, workerCli);
439
- },
440
- });
441
- let outcome = queued;
442
- if (dispatchPolicy.dispatch_mode === 'hook_preferred_with_fallback' && queued.request_id) {
443
- const receipt = await waitForDispatchReceipt(sanitized, queued.request_id, leaderCwd, {
444
- timeoutMs: dispatchPolicy.dispatch_ack_timeout_ms,
445
- pollMs: 50,
446
- });
447
- if (receipt && (receipt.status === 'notified' || receipt.status === 'delivered')) {
448
- outcome = { ok: true, transport: 'hook', reason: `hook_receipt_${receipt.status}`, request_id: queued.request_id };
449
890
  }
450
- else {
451
- const fallback = await notifyWorkerPaneOutcome(sessionName, workerIndex, triggerDirective.text, paneId, workerCli);
452
- if (receipt?.status === 'failed') {
453
- if (fallback.ok) {
454
- await transitionDispatchRequest(sanitized, queued.request_id, 'failed', 'failed', { last_reason: `fallback_confirmed_after_failed_receipt:${fallback.reason}` }, leaderCwd).catch(() => { });
455
- outcome = {
456
- ok: true,
457
- transport: fallback.transport,
458
- reason: `fallback_confirmed_after_failed_receipt:${fallback.reason}`,
459
- request_id: queued.request_id,
460
- };
891
+ // Intentionally avoid forcing `select-layout tiled` here.
892
+ // Tiled relayout reflows leader/HUD panes and breaks team window layout.
893
+ try {
894
+ await writeWorkerIdentity(sanitized, workerName, workerInfo, leaderCwd);
895
+ throwIfScaleUpFailureInjected(env, 'identity');
896
+ // Wait for worker readiness
897
+ const readyTimeoutMs = resolveWorkerReadyTimeoutMs(env);
898
+ const skipReadyWait = env.OMX_TEAM_SKIP_READY_WAIT === '1';
899
+ if (!skipReadyWait) {
900
+ const ready = waitForWorkerReady(sessionName, workerIndex, readyTimeoutMs, paneId, workerInfo.pid, config.tmux_pane_owner_id ?? undefined, config.hud_pane_id ?? undefined);
901
+ if (!ready) {
902
+ console.log(`[omx:scaling] Warning: worker ${workerName} did not become ready within timeout`);
903
+ }
904
+ }
905
+ // Get assigned tasks for this worker
906
+ const workerTasks = materializedTasks.filter(t => t.owner === workerName);
907
+ const inbox = generateInitialInbox(workerName, sanitized, agentType, workerTasks, {
908
+ teamStateRoot,
909
+ leaderCwd,
910
+ workerRole: runtimeRole,
911
+ rolePromptContent: rawRolePromptContent ?? undefined,
912
+ worktreeRootAgentsCanonical: Boolean(workerWorkspace?.worktreePath),
913
+ approvedContextSection,
914
+ workerGoalInstruction: buildTeamWorkerGoalInstruction(sanitized, workerName, workerTasks, { teamStateRoot }),
915
+ });
916
+ throwIfScaleUpFailureInjected(env, 'inbox');
917
+ const triggerDirective = buildTriggerDirective(workerName, sanitized, resolveInstructionStateRoot(workerInfo.worktree_path));
918
+ const queued = await queueInboxInstruction({
919
+ teamName: sanitized,
920
+ workerName,
921
+ workerIndex,
922
+ paneId,
923
+ inbox,
924
+ triggerMessage: triggerDirective.text,
925
+ intent: triggerDirective.intent,
926
+ cwd: leaderCwd,
927
+ transportPreference: dispatchPolicy.dispatch_mode,
928
+ fallbackAllowed: true,
929
+ inboxCorrelationKey: `scale_up:${workerName}`,
930
+ notify: async (_target, message) => {
931
+ if (dispatchPolicy.dispatch_mode === 'hook_preferred_with_fallback') {
932
+ return { ok: true, transport: 'hook', reason: 'queued_for_hook_dispatch' };
933
+ }
934
+ return await notifyWorkerPaneOutcome(sessionName, workerIndex, message, paneId, workerCli, workerInfo.pid, config.tmux_pane_owner_id ?? undefined, config.hud_pane_id ?? undefined);
935
+ },
936
+ });
937
+ let outcome = queued;
938
+ if (dispatchPolicy.dispatch_mode === 'hook_preferred_with_fallback' && queued.request_id) {
939
+ const receipt = await waitForDispatchReceipt(sanitized, queued.request_id, leaderCwd, {
940
+ timeoutMs: dispatchPolicy.dispatch_ack_timeout_ms,
941
+ pollMs: 50,
942
+ });
943
+ if (receipt && (receipt.status === 'notified' || receipt.status === 'delivered')) {
944
+ outcome = { ok: true, transport: 'hook', reason: `hook_receipt_${receipt.status}`, request_id: queued.request_id };
461
945
  }
462
946
  else {
463
- await transitionDispatchRequest(sanitized, queued.request_id, 'failed', 'failed', { last_reason: `fallback_attempted_but_unconfirmed:${fallback.reason}` }, leaderCwd).catch(() => { });
464
- outcome = {
465
- ok: false,
466
- transport: fallback.transport,
467
- reason: `fallback_attempted_but_unconfirmed:${fallback.reason}`,
468
- request_id: queued.request_id,
469
- };
947
+ const fallback = await notifyWorkerPaneOutcome(sessionName, workerIndex, triggerDirective.text, paneId, workerCli, workerInfo.pid, config.tmux_pane_owner_id ?? undefined, config.hud_pane_id ?? undefined);
948
+ if (receipt?.status === 'failed') {
949
+ if (fallback.ok) {
950
+ await transitionDispatchRequest(sanitized, queued.request_id, 'failed', 'failed', { last_reason: `fallback_confirmed_after_failed_receipt:${fallback.reason}` }, leaderCwd).catch(() => { });
951
+ outcome = {
952
+ ok: true,
953
+ transport: fallback.transport,
954
+ reason: `fallback_confirmed_after_failed_receipt:${fallback.reason}`,
955
+ request_id: queued.request_id,
956
+ };
957
+ }
958
+ else {
959
+ await transitionDispatchRequest(sanitized, queued.request_id, 'failed', 'failed', { last_reason: `fallback_attempted_but_unconfirmed:${fallback.reason}` }, leaderCwd).catch(() => { });
960
+ outcome = {
961
+ ok: false,
962
+ transport: fallback.transport,
963
+ reason: `fallback_attempted_but_unconfirmed:${fallback.reason}`,
964
+ request_id: queued.request_id,
965
+ };
966
+ }
967
+ }
968
+ else if (fallback.ok) {
969
+ const marked = await markDispatchRequestNotified(sanitized, queued.request_id, { last_reason: `fallback_confirmed:${fallback.reason}` }, leaderCwd);
970
+ if (!marked) {
971
+ await transitionDispatchRequest(sanitized, queued.request_id, 'failed', 'failed', { last_reason: `fallback_confirmed_after_failed_receipt:${fallback.reason}` }, leaderCwd).catch(() => { });
972
+ }
973
+ outcome = {
974
+ ok: true,
975
+ transport: fallback.transport,
976
+ reason: `hook_timeout_fallback_confirmed:${fallback.reason}`,
977
+ request_id: queued.request_id,
978
+ };
979
+ }
980
+ else {
981
+ const current = await readDispatchRequest(sanitized, queued.request_id, leaderCwd);
982
+ if (current) {
983
+ await transitionDispatchRequest(sanitized, queued.request_id, current.status, 'failed', { last_reason: `fallback_attempted_but_unconfirmed:${fallback.reason}` }, leaderCwd).catch(() => { });
984
+ }
985
+ outcome = {
986
+ ok: false,
987
+ transport: fallback.transport,
988
+ reason: `fallback_attempted_but_unconfirmed:${fallback.reason}`,
989
+ request_id: queued.request_id,
990
+ };
991
+ }
470
992
  }
471
993
  }
472
- else if (fallback.ok) {
473
- const marked = await markDispatchRequestNotified(sanitized, queued.request_id, { last_reason: `fallback_confirmed:${fallback.reason}` }, leaderCwd);
474
- if (!marked) {
475
- await transitionDispatchRequest(sanitized, queued.request_id, 'failed', 'failed', { last_reason: `fallback_confirmed_after_failed_receipt:${fallback.reason}` }, leaderCwd).catch(() => { });
994
+ // Retry dispatch once if a trust prompt is blocking the worker pane (fixes #393).
995
+ if (!outcome.ok && dismissTrustPromptIfPresent(sessionName, workerIndex, paneId, workerInfo.pid, config.tmux_pane_owner_id ?? undefined, config.hud_pane_id ?? undefined)) {
996
+ waitForWorkerReady(sessionName, workerIndex, readyTimeoutMs, paneId, workerInfo.pid, config.tmux_pane_owner_id ?? undefined, config.hud_pane_id ?? undefined);
997
+ const retry = await notifyWorkerPaneOutcome(sessionName, workerIndex, triggerDirective.text, paneId, workerCli, workerInfo.pid, config.tmux_pane_owner_id ?? undefined, config.hud_pane_id ?? undefined);
998
+ if (retry.ok) {
999
+ outcome = retry;
476
1000
  }
477
- outcome = {
478
- ok: true,
479
- transport: fallback.transport,
480
- reason: `hook_timeout_fallback_confirmed:${fallback.reason}`,
481
- request_id: queued.request_id,
482
- };
483
1001
  }
484
- else {
485
- const current = await readDispatchRequest(sanitized, queued.request_id, leaderCwd);
486
- if (current) {
487
- await transitionDispatchRequest(sanitized, queued.request_id, current.status, 'failed', { last_reason: `fallback_attempted_but_unconfirmed:${fallback.reason}` }, leaderCwd).catch(() => { });
488
- }
489
- outcome = {
490
- ok: false,
491
- transport: fallback.transport,
492
- reason: `fallback_attempted_but_unconfirmed:${fallback.reason}`,
493
- request_id: queued.request_id,
494
- };
1002
+ if (!outcome.ok) {
1003
+ return await rollbackScaleUp(`scale_up_dispatch_failed:${workerName}:${outcome.reason}`, {
1004
+ paneId,
1005
+ worker: workerInfo,
1006
+ });
495
1007
  }
1008
+ throwIfScaleUpFailureInjected(env, 'post-dispatch-rollback');
1009
+ addedWorkers.push(workerInfo);
1010
+ config.workers.push(workerInfo);
1011
+ config.worker_count = config.workers.length;
1012
+ config.next_worker_index = nextIndex;
1013
+ throwIfScaleUpFailureInjected(env, 'config');
1014
+ await saveTeamConfig(config, leaderCwd);
1015
+ throwIfScaleUpFailureInjected(env, 'finalization');
496
1016
  }
497
- }
498
- // Retry dispatch once if a trust prompt is blocking the worker pane (fixes #393).
499
- if (!outcome.ok && dismissTrustPromptIfPresent(sessionName, workerIndex, paneId)) {
500
- waitForWorkerReady(sessionName, workerIndex, readyTimeoutMs, paneId);
501
- const retry = await notifyWorkerPaneOutcome(sessionName, workerIndex, triggerDirective.text, paneId, workerCli);
502
- if (retry.ok) {
503
- outcome = retry;
1017
+ catch (error) {
1018
+ return await rollbackScaleUp(`scale_up_worker_materialization_failed:${workerName}:${error instanceof Error ? error.message : String(error)}`, { paneId, worker: workerInfo });
504
1019
  }
505
1020
  }
506
- if (!outcome.ok) {
507
- return await rollbackScaleUp(`scale_up_dispatch_failed:${workerName}:${outcome.reason}`, {
508
- paneId,
509
- workerName,
510
- worktreePath: workerWorkspace?.worktreePath,
511
- });
1021
+ try {
1022
+ await appendTeamEvent(sanitized, {
1023
+ type: 'team_leader_nudge',
1024
+ worker: 'leader-fixed',
1025
+ reason: `scale_up: added ${count} worker(s), new count=${config.worker_count}`,
1026
+ }, leaderCwd);
1027
+ }
1028
+ catch (error) {
1029
+ return await rollbackScaleUp(`scale_up_finalization_failed:${error instanceof Error ? error.message : String(error)}`);
1030
+ }
1031
+ return {
1032
+ ok: true,
1033
+ addedWorkers,
1034
+ newWorkerCount: config.worker_count,
1035
+ nextWorkerIndex: nextIndex,
1036
+ };
1037
+ });
1038
+ });
1039
+ }
1040
+ export function isSameOrInsidePath(path, root, pathSemantics = { relative, isAbsolute, sep }) {
1041
+ const relativePath = pathSemantics.relative(root, path);
1042
+ return relativePath === ''
1043
+ || (!pathSemantics.isAbsolute(relativePath)
1044
+ && relativePath !== '..'
1045
+ && !relativePath.startsWith(`..${pathSemantics.sep}`));
1046
+ }
1047
+ async function assertExistingPathParentContained(path, root) {
1048
+ const canonicalRoot = await realpath(root);
1049
+ let candidate = resolve(path);
1050
+ while (!existsSync(candidate)) {
1051
+ const parent = dirname(candidate);
1052
+ if (parent === candidate)
1053
+ throw new Error(`scale_down_cleanup_debt_path_missing_parent:${path}`);
1054
+ candidate = parent;
1055
+ }
1056
+ const canonicalExistingPath = await realpath(candidate);
1057
+ if (!isSameOrInsidePath(canonicalExistingPath, canonicalRoot)) {
1058
+ throw new Error(`scale_down_cleanup_debt_path_escape:${path}`);
1059
+ }
1060
+ }
1061
+ async function validateScaleDownCleanupResources(teamName, leaderCwd, teamStateRoot, workers) {
1062
+ const canonicalLeaderCwd = await realpath(leaderCwd);
1063
+ let repoRoot = canonicalLeaderCwd;
1064
+ const repoRootResult = spawnSync('git', ['rev-parse', '--show-toplevel'], {
1065
+ cwd: leaderCwd,
1066
+ encoding: 'utf-8',
1067
+ windowsHide: true,
1068
+ });
1069
+ const reportedRepoRoot = (repoRootResult.stdout || '').trim();
1070
+ if (repoRootResult.status === 0 && reportedRepoRoot) {
1071
+ repoRoot = await realpath(resolve(reportedRepoRoot));
1072
+ }
1073
+ const canonicalTeamStateRoot = await realpath(resolve(teamStateRoot));
1074
+ const expectedWorkerRoot = join(canonicalTeamStateRoot, 'team', teamName, 'workers');
1075
+ for (const worker of workers) {
1076
+ const expectedWorkerDirectory = join(expectedWorkerRoot, worker.name);
1077
+ await assertExistingPathParentContained(expectedWorkerDirectory, canonicalTeamStateRoot);
1078
+ if (worker.team_state_root !== undefined
1079
+ && (!isAbsolute(worker.team_state_root) || worker.team_state_root !== resolve(worker.team_state_root)
1080
+ || resolve(worker.team_state_root) !== canonicalTeamStateRoot)) {
1081
+ throw new Error(`scale_down_cleanup_debt_invalid_team_state_root:${worker.name}`);
1082
+ }
1083
+ if (worker.worktree_path === undefined) {
1084
+ if (worker.worktree_repo_root !== undefined || worker.worktree_branch !== undefined
1085
+ || worker.worktree_detached !== undefined || worker.worktree_created !== undefined) {
1086
+ throw new Error(`scale_down_cleanup_debt_invalid_worktree_metadata:${worker.name}`);
512
1087
  }
513
- addedWorkers.push(workerInfo);
514
- config.workers.push(workerInfo);
515
- config.worker_count = config.workers.length;
516
- config.next_worker_index = nextIndex;
517
- await saveTeamConfig(config, leaderCwd);
1088
+ continue;
518
1089
  }
519
- await appendTeamEvent(sanitized, {
520
- type: 'team_leader_nudge',
521
- worker: 'leader-fixed',
522
- reason: `scale_up: added ${count} worker(s), new count=${config.worker_count}`,
523
- }, leaderCwd);
524
- return {
525
- ok: true,
526
- addedWorkers,
527
- newWorkerCount: config.worker_count,
528
- nextWorkerIndex: nextIndex,
529
- };
1090
+ const expectedWorktreePath = join(repoRoot, '.omx', 'team', teamName, 'worktrees', worker.name);
1091
+ if (!isAbsolute(worker.worktree_path) || worker.worktree_path !== resolve(worker.worktree_path)
1092
+ || resolve(worker.worktree_path) !== expectedWorktreePath
1093
+ || (worker.worktree_repo_root !== undefined && (!isAbsolute(worker.worktree_repo_root)
1094
+ || worker.worktree_repo_root !== resolve(worker.worktree_repo_root)
1095
+ || resolve(worker.worktree_repo_root) !== repoRoot))
1096
+ || (worker.worktree_detached !== undefined && typeof worker.worktree_detached !== 'boolean')
1097
+ || (worker.worktree_created !== undefined && typeof worker.worktree_created !== 'boolean')
1098
+ || (worker.worktree_created === true && (typeof worker.worktree_repo_root !== 'string' || typeof worker.worktree_detached !== 'boolean'))) {
1099
+ throw new Error(`scale_down_cleanup_debt_invalid_worktree_target:${worker.name}`);
1100
+ }
1101
+ await assertExistingPathParentContained(expectedWorktreePath, repoRoot);
1102
+ if (existsSync(expectedWorktreePath) && await realpath(expectedWorktreePath) !== expectedWorktreePath) {
1103
+ throw new Error(`scale_down_cleanup_debt_worktree_symlink:${worker.name}`);
1104
+ }
1105
+ }
1106
+ }
1107
+ async function cleanupScaleDownResources(teamName, teamStateRoot, workers) {
1108
+ for (const worker of workers) {
1109
+ // An absent exact worktree means a prior cleanup completed before crashing.
1110
+ // Do not replay a restoration or git removal against a replacement path.
1111
+ if (worker.worktree_path && existsSync(worker.worktree_path)) {
1112
+ await removeWorkerWorktreeRootAgentsFile(teamName, worker.name, worker.team_state_root ?? teamStateRoot, worker.worktree_path);
1113
+ }
1114
+ }
1115
+ const worktrees = workers
1116
+ .filter((worker) => worker.worktree_created === true
1117
+ && typeof worker.worktree_path === 'string'
1118
+ && existsSync(worker.worktree_path)
1119
+ && typeof worker.worktree_repo_root === 'string'
1120
+ && typeof worker.worktree_detached === 'boolean')
1121
+ .map((worker) => ({
1122
+ enabled: true,
1123
+ repoRoot: worker.worktree_repo_root,
1124
+ worktreePath: worker.worktree_path,
1125
+ detached: worker.worktree_detached,
1126
+ branchName: worker.worktree_branch ?? null,
1127
+ created: true,
1128
+ reused: false,
1129
+ // Membership records do not establish branch ownership. Preserve a named
1130
+ // branch rather than deleting one that may have predated this worker.
1131
+ createdBranch: false,
1132
+ }));
1133
+ if (worktrees.length > 0)
1134
+ await rollbackProvisionedWorktrees(worktrees);
1135
+ await Promise.all(workers.map(async (worker) => {
1136
+ await rm(join(teamStateRoot, 'team', teamName, 'workers', worker.name), { recursive: true, force: true });
1137
+ }));
1138
+ }
1139
+ function asScaleDownDebtResources(debt, removedWorkerNames) {
1140
+ const resources = debt.resource_workers;
1141
+ if (!Array.isArray(resources) || resources.length !== removedWorkerNames.length)
1142
+ return null;
1143
+ const expected = new Set(removedWorkerNames);
1144
+ const seen = new Set();
1145
+ for (const worker of resources) {
1146
+ if (!worker || typeof worker.name !== 'string' || !expected.has(worker.name) || seen.has(worker.name)
1147
+ || (worker.worktree_path !== undefined && typeof worker.worktree_path !== 'string')
1148
+ || (worker.worktree_repo_root !== undefined && typeof worker.worktree_repo_root !== 'string')
1149
+ || (worker.worktree_branch !== undefined && typeof worker.worktree_branch !== 'string')
1150
+ || (worker.worktree_detached !== undefined && typeof worker.worktree_detached !== 'boolean')
1151
+ || (worker.worktree_created !== undefined && typeof worker.worktree_created !== 'boolean')
1152
+ || (worker.team_state_root !== undefined && typeof worker.team_state_root !== 'string'))
1153
+ return null;
1154
+ seen.add(worker.name);
1155
+ }
1156
+ return resources;
1157
+ }
1158
+ function asScaleDownDebtPanes(debt, removedWorkerNames, resources, leaderPaneId, hudPaneId) {
1159
+ const workers = debt.workers;
1160
+ const unresolved = debt.unresolved_panes;
1161
+ if (!Array.isArray(workers) || (unresolved !== undefined && !Array.isArray(unresolved)))
1162
+ return null;
1163
+ const expectedNames = new Set(removedWorkerNames);
1164
+ const resourceNames = new Set(resources.map((resource) => resource.name));
1165
+ const canonicalByName = new Map();
1166
+ const allPaneIds = new Set();
1167
+ const normalizedLeaderPaneId = typeof leaderPaneId === 'string' ? leaderPaneId.trim() : '';
1168
+ const normalizedHudPaneId = typeof hudPaneId === 'string' ? hudPaneId.trim() : '';
1169
+ const validate = (pane, requireCanonicalMatch) => {
1170
+ if (!pane || typeof pane.name !== 'string' || !expectedNames.has(pane.name) || !resourceNames.has(pane.name)
1171
+ || typeof pane.index !== 'number' || !Number.isInteger(pane.index) || pane.index <= 0
1172
+ || pane.index !== Number(pane.name.slice('worker-'.length))
1173
+ || typeof pane.pane_id !== 'string' || !/^%\d+$/.test(pane.pane_id)
1174
+ || pane.pane_id === normalizedLeaderPaneId || pane.pane_id === normalizedHudPaneId
1175
+ || (pane.pid !== null && (!Number.isSafeInteger(pane.pid) || pane.pid <= 0)))
1176
+ return false;
1177
+ const canonical = canonicalByName.get(pane.name);
1178
+ if (requireCanonicalMatch) {
1179
+ return canonical !== undefined
1180
+ && canonical.index === pane.index
1181
+ && canonical.pane_id === pane.pane_id
1182
+ && canonical.pid === pane.pid;
1183
+ }
1184
+ if (canonical !== undefined || allPaneIds.has(pane.pane_id))
1185
+ return false;
1186
+ canonicalByName.set(pane.name, pane);
1187
+ allPaneIds.add(pane.pane_id);
1188
+ return true;
1189
+ };
1190
+ for (const worker of workers) {
1191
+ if (!validate(worker, false))
1192
+ return null;
1193
+ }
1194
+ const unresolvedNames = new Set();
1195
+ const unresolvedPaneIds = new Set();
1196
+ for (const pane of unresolved ?? []) {
1197
+ if (unresolvedNames.has(pane.name ?? '') || unresolvedPaneIds.has(pane.pane_id)
1198
+ || !validate(pane, true))
1199
+ return null;
1200
+ unresolvedNames.add(pane.name);
1201
+ unresolvedPaneIds.add(pane.pane_id);
1202
+ }
1203
+ return unresolved && unresolved.length > 0 ? unresolved : workers;
1204
+ }
1205
+ async function hasMatchingScaleDownDebtWorkerIdentity(teamStateRoot, teamName, pane) {
1206
+ try {
1207
+ const identity = JSON.parse(await readFile(join(teamStateRoot, 'team', teamName, 'workers', pane.name ?? '', 'identity.json'), 'utf8'));
1208
+ return identity.name === pane.name
1209
+ && identity.index === pane.index
1210
+ && identity.pane_id === pane.pane_id
1211
+ && identity.pid === pane.pid;
1212
+ }
1213
+ catch {
1214
+ return false;
1215
+ }
1216
+ }
1217
+ export async function reconcileScaleDownCleanupDebt(teamName, cwd, _callerConfig) {
1218
+ const sanitized = sanitizeTeamName(teamName);
1219
+ const leaderCwd = resolve(cwd);
1220
+ return await withTaskMembershipBarrier(sanitized, leaderCwd, async () => {
1221
+ await recoverTeamMembershipTaskTransaction(sanitized, leaderCwd);
1222
+ // Authorization is derived only from the canonical state observed under the
1223
+ // membership barrier; callers may hold a stale pre-transaction config.
1224
+ const config = await readTeamConfig(sanitized, leaderCwd);
1225
+ if (!config)
1226
+ return { ok: false, error: `Team ${sanitized} not found` };
1227
+ const teamStateRoot = config.team_state_root ?? resolveCanonicalTeamStateRoot(leaderCwd);
1228
+ const cleanupDebtPath = join(teamStateRoot, 'team', sanitized, '.scale-down-cleanup-debt.json');
1229
+ if (!existsSync(cleanupDebtPath))
1230
+ return { ok: true };
1231
+ let debt;
1232
+ try {
1233
+ debt = JSON.parse(await readFile(cleanupDebtPath, 'utf8'));
1234
+ }
1235
+ catch (error) {
1236
+ return { ok: false, error: `scale_down_cleanup_debt_unreadable:${String(error)}` };
1237
+ }
1238
+ if (debt.schema_version !== 1 || debt.operation !== 'scale_down') {
1239
+ return { ok: false, error: 'scale_down_cleanup_debt_malformed' };
1240
+ }
1241
+ const removedWorkerNames = Array.isArray(debt.removed_worker_names)
1242
+ ? debt.removed_worker_names
1243
+ : (Array.isArray(debt.workers) ? debt.workers.map((worker) => worker?.name).filter((name) => typeof name === 'string') : []);
1244
+ if (removedWorkerNames.length === 0
1245
+ || removedWorkerNames.some((name) => !/^worker-\d+$/.test(name))
1246
+ || new Set(removedWorkerNames).size !== removedWorkerNames.length) {
1247
+ return { ok: false, error: 'scale_down_cleanup_debt_malformed' };
1248
+ }
1249
+ const resources = asScaleDownDebtResources(debt, removedWorkerNames);
1250
+ if (!resources)
1251
+ return { ok: false, error: 'scale_down_cleanup_debt_malformed' };
1252
+ try {
1253
+ await validateScaleDownCleanupResources(sanitized, leaderCwd, teamStateRoot, resources);
1254
+ }
1255
+ catch {
1256
+ return { ok: false, error: 'scale_down_cleanup_debt_malformed' };
1257
+ }
1258
+ const panes = asScaleDownDebtPanes(debt, removedWorkerNames, resources, config.leader_pane_id, config.hud_pane_id);
1259
+ // Validate all journal bindings before classifying or mutating recovery state.
1260
+ // A journal is evidence, never authority to target an arbitrary global pane.
1261
+ if (!panes)
1262
+ return { ok: false, error: 'scale_down_cleanup_debt_malformed' };
1263
+ const stillCanonical = removedWorkerNames.filter((name) => config.workers.some((worker) => worker.name === name));
1264
+ // A pre-commit journal is not authorization to kill or remove anything.
1265
+ if (stillCanonical.length === removedWorkerNames.length) {
1266
+ await removeDurableFile(cleanupDebtPath);
1267
+ return { ok: true };
1268
+ }
1269
+ if (stillCanonical.length > 0)
1270
+ return { ok: false, error: 'scale_down_cleanup_debt_membership_inconsistent' };
1271
+ // A cleanup journal may never reuse a pane identity held by a worker that
1272
+ // survived the committed membership update. This is checked before any
1273
+ // liveness or owner probes so malformed debt cannot cause a tmux effect.
1274
+ const survivingWorkers = config.workers.filter((worker) => !removedWorkerNames.includes(worker.name));
1275
+ if (panes.some((pane) => survivingWorkers.some((worker) => (worker.pane_id === pane.pane_id
1276
+ || (pane.pid !== null && worker.pid === pane.pid))))) {
1277
+ return { ok: false, error: 'scale_down_cleanup_debt_malformed' };
1278
+ }
1279
+ const resolvedPaneIds = new Set();
1280
+ const reasons = [];
1281
+ const expectedOwnerId = typeof config.tmux_pane_owner_id === 'string'
1282
+ ? config.tmux_pane_owner_id.trim()
1283
+ : '';
1284
+ for (const pane of panes) {
1285
+ // Legacy PID-less records can establish convergence from an authoritative
1286
+ // global absence/dead proof, but can never authorize a live-pane effect.
1287
+ const freshProof = readExactPaneProofSync(pane.pane_id);
1288
+ if (freshProof.status === 'gone') {
1289
+ resolvedPaneIds.add(pane.pane_id);
1290
+ continue;
1291
+ }
1292
+ if (freshProof.status === 'unavailable') {
1293
+ reasons.push(`${pane.pane_id}:${freshProof.reason}`);
1294
+ continue;
1295
+ }
1296
+ if (pane.pid === null) {
1297
+ reasons.push(`${pane.pane_id}:legacy_pid_missing_live`);
1298
+ continue;
1299
+ }
1300
+ if (freshProof.pid !== pane.pid) {
1301
+ reasons.push(`${pane.pane_id}:pane_pid_changed`);
1302
+ continue;
1303
+ }
1304
+ // A live debt pane must remain bound to the removed worker's recorded
1305
+ // identity; the team-wide owner token alone only proves team membership.
1306
+ if (!await hasMatchingScaleDownDebtWorkerIdentity(teamStateRoot, sanitized, pane)) {
1307
+ reasons.push(`${pane.pane_id}:worker_identity_unavailable`);
1308
+ continue;
1309
+ }
1310
+ // Owner reads are authoritative only for this canonical Team token. Missing,
1311
+ // mismatched, and unavailable tags all fail closed for a live pane effect.
1312
+ if (!expectedOwnerId) {
1313
+ reasons.push(`${pane.pane_id}:team_owner_unavailable`);
1314
+ continue;
1315
+ }
1316
+ const owner = readPaneTeamOwnerTagResult(pane.pane_id);
1317
+ if (owner.status === 'error') {
1318
+ reasons.push(`${pane.pane_id}:team_owner_unavailable`);
1319
+ continue;
1320
+ }
1321
+ if (owner.status !== 'value' || owner.value !== expectedOwnerId) {
1322
+ reasons.push(`${pane.pane_id}:team_owner_mismatch`);
1323
+ continue;
1324
+ }
1325
+ // teardownWorkerPanes re-proves this exact pane immediately before the kill
1326
+ // and pins the same durable PID through the kill confirmation.
1327
+ const teardown = await teardownWorkerPanes([pane.pane_id], {
1328
+ leaderPaneId: config.leader_pane_id,
1329
+ hudPaneId: config.hud_pane_id,
1330
+ expectedPanePids: { [pane.pane_id]: pane.pid },
1331
+ authorizePaneKill: (paneId) => {
1332
+ const currentOwner = readPaneTeamOwnerTagResult(paneId);
1333
+ return currentOwner.status === 'value' && currentOwner.value === expectedOwnerId;
1334
+ },
1335
+ });
1336
+ if (teardown.provenGonePaneIds.includes(pane.pane_id) || teardown.killedPaneIds.includes(pane.pane_id)) {
1337
+ resolvedPaneIds.add(pane.pane_id);
1338
+ continue;
1339
+ }
1340
+ reasons.push(...teardown.proofUnavailable.map((proof) => `${proof.paneId}:${proof.reason}`));
1341
+ reasons.push(...teardown.kill.failedPaneIds.map((paneId) => `${paneId}:kill_failed`));
1342
+ if (teardown.proofUnavailable.length === 0 && teardown.kill.failedPaneIds.length === 0) {
1343
+ reasons.push(`${pane.pane_id}:teardown_unresolved`);
1344
+ }
1345
+ }
1346
+ const unresolvedPanes = panes.filter((pane) => !resolvedPaneIds.has(pane.pane_id));
1347
+ if (unresolvedPanes.length > 0) {
1348
+ await writeAtomic(cleanupDebtPath, JSON.stringify({
1349
+ ...debt,
1350
+ status: 'unresolved',
1351
+ updated_at: new Date().toISOString(),
1352
+ unresolved_panes: unresolvedPanes,
1353
+ reasons,
1354
+ }, null, 2));
1355
+ return { ok: false, error: `scale_down_cleanup_debt_unresolved:${unresolvedPanes.map((pane) => pane.pane_id).join(',')}` };
1356
+ }
1357
+ try {
1358
+ await cleanupScaleDownResources(sanitized, teamStateRoot, resources);
1359
+ }
1360
+ catch (error) {
1361
+ await writeAtomic(cleanupDebtPath, JSON.stringify({
1362
+ ...debt,
1363
+ status: 'resource_cleanup_pending',
1364
+ updated_at: new Date().toISOString(),
1365
+ unresolved_panes: [],
1366
+ reasons: [`resource_cleanup_failed:${String(error)}`],
1367
+ }, null, 2));
1368
+ return { ok: false, error: `scale_down_cleanup_debt_resource_cleanup_failed:${String(error)}` };
1369
+ }
1370
+ await removeDurableFile(cleanupDebtPath);
1371
+ return { ok: true };
530
1372
  });
531
1373
  }
532
1374
  /**
@@ -542,10 +1384,17 @@ export async function scaleDown(teamName, cwd, options = {}, env = process.env)
542
1384
  const force = options.force === true;
543
1385
  const drainTimeoutMs = options.drainTimeoutMs ?? 30_000;
544
1386
  return await withScalingLock(sanitized, leaderCwd, async () => {
545
- const config = await readTeamConfig(sanitized, leaderCwd);
1387
+ const config = await withTaskMembershipBarrier(sanitized, leaderCwd, async () => {
1388
+ await recoverTeamMembershipTaskTransaction(sanitized, leaderCwd);
1389
+ return await readTeamConfig(sanitized, leaderCwd);
1390
+ });
546
1391
  if (!config) {
547
1392
  return { ok: false, error: `Team ${sanitized} not found` };
548
1393
  }
1394
+ let teamStateRoot = config.team_state_root ?? resolveCanonicalTeamStateRoot(leaderCwd);
1395
+ const priorCleanup = await reconcileScaleDownCleanupDebt(sanitized, leaderCwd, config);
1396
+ if (!priorCleanup.ok)
1397
+ return priorCleanup;
549
1398
  // Determine which workers to remove
550
1399
  let targetWorkers;
551
1400
  if (options.workerNames && options.workerNames.length > 0) {
@@ -595,6 +1444,33 @@ export async function scaleDown(teamName, cwd, options = {}, env = process.env)
595
1444
  }
596
1445
  const sessionName = config.tmux_session;
597
1446
  const removedNames = [];
1447
+ const priorWorkerStatusArtifacts = new Map();
1448
+ for (const worker of targetWorkers) {
1449
+ const statusPath = join(teamStateRoot, 'team', sanitized, 'workers', worker.name, 'status.json');
1450
+ try {
1451
+ priorWorkerStatusArtifacts.set(worker.name, { exists: true, raw: await readFile(statusPath) });
1452
+ }
1453
+ catch (error) {
1454
+ if (error.code === 'ENOENT') {
1455
+ priorWorkerStatusArtifacts.set(worker.name, { exists: false });
1456
+ }
1457
+ else {
1458
+ throw error;
1459
+ }
1460
+ }
1461
+ }
1462
+ const restorePriorWorkerStatuses = async (workers) => {
1463
+ await Promise.all(workers.map(async (worker) => {
1464
+ const statusPath = join(teamStateRoot, 'team', sanitized, 'workers', worker.name, 'status.json');
1465
+ const priorArtifact = priorWorkerStatusArtifacts.get(worker.name);
1466
+ if (priorArtifact?.exists) {
1467
+ await writeFile(statusPath, priorArtifact.raw);
1468
+ }
1469
+ else {
1470
+ await rm(statusPath, { force: true });
1471
+ }
1472
+ }));
1473
+ };
598
1474
  // Phase 1: Set workers to 'draining' status
599
1475
  for (const w of targetWorkers) {
600
1476
  const drainingStatus = {
@@ -611,69 +1487,239 @@ export async function scaleDown(teamName, cwd, options = {}, env = process.env)
611
1487
  const allDrained = await Promise.all(targetWorkers.map(async (w) => {
612
1488
  const status = await readWorkerStatus(sanitized, w.name, leaderCwd);
613
1489
  return status.state === 'idle' || status.state === 'done' ||
614
- status.state === 'draining' || !isWorkerAlive(sessionName, w.index, w.pane_id);
1490
+ status.state === 'draining' || !isWorkerAlive(sessionName, w.index, w.pane_id, w.pid, config.tmux_pane_owner_id ?? undefined, config.hud_pane_id ?? undefined);
615
1491
  }));
616
1492
  if (allDrained.every(Boolean))
617
1493
  break;
618
1494
  await new Promise(r => setTimeout(r, 2_000));
619
1495
  }
620
1496
  }
621
- // Phase 3: Kill tmux panes and remove from config
622
- const leaderPaneId = config.leader_pane_id;
623
- const hudPaneId = config.hud_pane_id;
624
- const targetPaneIds = targetWorkers
625
- .map((w) => w.pane_id)
626
- .filter((paneId) => typeof paneId === 'string' && paneId.trim().length > 0);
627
- await teardownWorkerPanes(targetPaneIds, {
628
- leaderPaneId,
629
- hudPaneId,
630
- });
631
- const detachedWorktreesToRollback = targetWorkers
632
- .filter((worker) => worker.worktree_created === true
633
- && worker.worktree_detached === true
634
- && typeof worker.worktree_repo_root === 'string'
635
- && worker.worktree_repo_root.length > 0
636
- && typeof worker.worktree_path === 'string'
637
- && worker.worktree_path.length > 0)
638
- .map((worker) => ({
639
- enabled: true,
640
- repoRoot: worker.worktree_repo_root,
641
- worktreePath: resolve(worker.worktree_path),
642
- detached: true,
643
- branchName: null,
644
- created: true,
645
- reused: false,
646
- createdBranch: false,
647
- }));
648
- if (detachedWorktreesToRollback.length > 0) {
649
- try {
650
- await rollbackProvisionedWorktrees(detachedWorktreesToRollback);
651
- }
652
- catch (error) {
653
- return { ok: false, error: `scale_down_worktree_cleanup_failed:${String(error)}` };
654
- }
1497
+ // Phase 3: acquire the membership barrier and task claim locks before any
1498
+ // pane effect. The barrier and locks remain held through the canonical
1499
+ // snapshot, exact pane teardown, and forward-recoverable commit.
1500
+ let removableWorkers = targetWorkers;
1501
+ let removableWorkerNames = new Set(removableWorkers.map((worker) => worker.name));
1502
+ let teardownFailure = null;
1503
+ try {
1504
+ await withTaskMembershipBarrier(sanitized, leaderCwd, async () => {
1505
+ await recoverTeamMembershipTaskTransaction(sanitized, leaderCwd);
1506
+ const authoritativeConfig = await readTeamConfig(sanitized, leaderCwd);
1507
+ if (!authoritativeConfig)
1508
+ throw new Error('canonical_scale_down_config_missing');
1509
+ const authoritativeWorkers = removableWorkers.map((worker) => authoritativeConfig.workers.find((candidate) => candidate.name === worker.name));
1510
+ if (authoritativeWorkers.some((worker) => !worker)) {
1511
+ throw new Error('canonical_scale_down_membership_changed');
1512
+ }
1513
+ // Capture targets from the canonical generation while the membership
1514
+ // authority is held; no caller-owned worker record authorizes a pane effect.
1515
+ removableWorkers = authoritativeWorkers;
1516
+ removableWorkerNames = new Set(removableWorkers.map((worker) => worker.name));
1517
+ teamStateRoot = authoritativeConfig.team_state_root ?? resolveCanonicalTeamStateRoot(leaderCwd);
1518
+ Object.assign(config, authoritativeConfig);
1519
+ const candidateTaskIds = (await listTasks(sanitized, leaderCwd))
1520
+ .filter((task) => task.status !== 'completed' && task.status !== 'failed')
1521
+ .map((task) => task.id);
1522
+ await withTaskClaimLocks(sanitized, candidateTaskIds, leaderCwd, async () => {
1523
+ const lockedTasks = await listTasks(sanitized, leaderCwd);
1524
+ const configPath = join(teamStateRoot, 'team', sanitized, 'config.json');
1525
+ const configSnapshot = await readFile(configPath);
1526
+ const manifestPath = join(teamStateRoot, 'team', sanitized, 'manifest.v2.json');
1527
+ const manifestSnapshot = existsSync(manifestPath) ? await readFile(manifestPath) : null;
1528
+ const reconciledTasks = lockedTasks.filter((task) => task.status !== 'completed' && task.status !== 'failed'
1529
+ && (removableWorkerNames.has(task.owner ?? '') || removableWorkerNames.has(task.claim?.owner ?? '')));
1530
+ const taskSnapshots = new Map();
1531
+ for (const task of reconciledTasks) {
1532
+ taskSnapshots.set(task.id, await readFile(join(teamStateRoot, 'team', sanitized, 'tasks', `task-${task.id}.json`)));
1533
+ }
1534
+ const postSnapshotMarker = env.OMX_TEAM_SCALE_DOWN_POST_SNAPSHOT_MARKER;
1535
+ if (postSnapshotMarker)
1536
+ await writeFile(postSnapshotMarker, 'snapshot-held\n');
1537
+ const boundaryHoldMs = Number.parseInt(env.OMX_TEAM_SCALE_DOWN_BOUNDARY_HOLD_MS ?? '', 10);
1538
+ if (Number.isFinite(boundaryHoldMs) && boundaryHoldMs > 0)
1539
+ await new Promise((resolve) => setTimeout(resolve, boundaryHoldMs));
1540
+ const currentConfig = JSON.parse(configSnapshot.toString('utf8'));
1541
+ const nextConfig = {
1542
+ ...currentConfig,
1543
+ workers: currentConfig.workers.filter((worker) => !removableWorkerNames.has(worker.name)),
1544
+ };
1545
+ nextConfig.worker_count = nextConfig.workers.length;
1546
+ const nextManifest = manifestSnapshot
1547
+ ? JSON.stringify({
1548
+ ...JSON.parse(manifestSnapshot.toString('utf8')),
1549
+ workers: nextConfig.workers,
1550
+ worker_count: nextConfig.worker_count,
1551
+ }, null, 2)
1552
+ : null;
1553
+ const membershipTransaction = {
1554
+ baseGeneration: currentConfig.config_generation ?? 0,
1555
+ tasks: reconciledTasks.map((task) => ({
1556
+ taskId: task.id,
1557
+ oldBytes: taskSnapshots.get(task.id)?.toString('utf8') ?? null,
1558
+ newBytes: JSON.stringify({
1559
+ ...task,
1560
+ owner: undefined,
1561
+ claim: undefined,
1562
+ status: task.status === 'in_progress' ? 'pending' : task.status,
1563
+ version: Math.max(1, task.version ?? 1) + 1,
1564
+ }, null, 2),
1565
+ })),
1566
+ config: {
1567
+ oldBytes: configSnapshot.toString('utf8'),
1568
+ newBytes: JSON.stringify(nextConfig, null, 2),
1569
+ },
1570
+ manifest: {
1571
+ oldBytes: manifestSnapshot?.toString('utf8') ?? null,
1572
+ newBytes: nextManifest,
1573
+ },
1574
+ interruptAfterFirstTaskWrite: env.OMX_TEAM_SCALE_DOWN_INJECT_FAILURE === 'after-first-task-write',
1575
+ failRollbackPersistence: env.OMX_TEAM_SCALE_DOWN_INJECT_FAILURE === 'rollback-persistence-failure',
1576
+ recoverToNewOnFailure: true,
1577
+ };
1578
+ const cleanupDebtPath = join(teamStateRoot, 'team', sanitized, '.scale-down-cleanup-debt.json');
1579
+ const cleanupDebtBase = {
1580
+ schema_version: 1,
1581
+ operation: 'scale_down',
1582
+ status: 'pending_teardown',
1583
+ created_at: new Date().toISOString(),
1584
+ removed_worker_names: removableWorkers.map((worker) => worker.name),
1585
+ workers: removableWorkers
1586
+ .filter((worker) => (typeof worker.pane_id === 'string' && /^%\d+$/.test(worker.pane_id)))
1587
+ .map((worker) => ({
1588
+ name: worker.name,
1589
+ index: worker.index,
1590
+ pane_id: worker.pane_id,
1591
+ pid: worker.pid ?? null,
1592
+ })),
1593
+ resource_workers: removableWorkers.map((worker) => ({
1594
+ name: worker.name,
1595
+ worktree_path: worker.worktree_path,
1596
+ worktree_repo_root: worker.worktree_repo_root,
1597
+ worktree_branch: worker.worktree_branch,
1598
+ worktree_detached: worker.worktree_detached,
1599
+ worktree_created: worker.worktree_created,
1600
+ team_state_root: worker.team_state_root,
1601
+ })),
1602
+ };
1603
+ await writeAtomic(cleanupDebtPath, JSON.stringify(cleanupDebtBase, null, 2));
1604
+ await commitTeamMembershipTaskTransaction(sanitized, leaderCwd, membershipTransaction);
1605
+ const committed = await readTeamConfig(sanitized, leaderCwd);
1606
+ if (!committed || committed.workers.some((worker) => removableWorkerNames.has(worker.name))) {
1607
+ throw new Error('canonical_scale_down_config_verification_failed');
1608
+ }
1609
+ config.workers = committed.workers;
1610
+ config.worker_count = committed.worker_count;
1611
+ for (const task of reconciledTasks) {
1612
+ const reconciled = await readTask(sanitized, task.id, leaderCwd);
1613
+ if (reconciled && (removableWorkerNames.has(reconciled.owner ?? '') || removableWorkerNames.has(reconciled.claim?.owner ?? ''))) {
1614
+ throw new Error(`canonical_scale_down_task_verification_failed:${task.id}`);
1615
+ }
1616
+ }
1617
+ // PID-less rollback records are cleanup debt, not effect authority. A
1618
+ // fresh gone proof may resolve one, but a live or unavailable proof
1619
+ // must remain debt rather than letting teardown adopt its current PID.
1620
+ const pinnedPaneWorkers = removableWorkers.filter((worker) => (typeof worker.pane_id === 'string'
1621
+ && /^%\d+$/.test(worker.pane_id)
1622
+ && typeof worker.pid === 'number'
1623
+ && Number.isSafeInteger(worker.pid)
1624
+ && worker.pid > 0));
1625
+ const unpinnedPaneWorkers = removableWorkers.filter((worker) => (typeof worker.pane_id === 'string'
1626
+ && /^%\d+$/.test(worker.pane_id)
1627
+ && !pinnedPaneWorkers.some((pinnedWorker) => pinnedWorker.name === worker.name)));
1628
+ const unpinnedResolvedPaneIds = new Set();
1629
+ const unpinnedProofDebt = [];
1630
+ for (const worker of unpinnedPaneWorkers) {
1631
+ const proof = readExactPaneProofSync(worker.pane_id);
1632
+ if (proof.status === 'gone') {
1633
+ unpinnedResolvedPaneIds.add(worker.pane_id);
1634
+ }
1635
+ else if (proof.status === 'unavailable') {
1636
+ unpinnedProofDebt.push(`${worker.pane_id}:${proof.reason}`);
1637
+ }
1638
+ else {
1639
+ unpinnedProofDebt.push(`${worker.pane_id}:legacy_pid_missing_live`);
1640
+ }
1641
+ }
1642
+ const targetPaneIds = pinnedPaneWorkers.map((worker) => worker.pane_id);
1643
+ const expectedScaleDownOwnerId = typeof config.tmux_pane_owner_id === 'string'
1644
+ ? config.tmux_pane_owner_id.trim()
1645
+ : '';
1646
+ const paneTeardown = await teardownWorkerPanes(targetPaneIds, {
1647
+ leaderPaneId: config.leader_pane_id,
1648
+ hudPaneId: config.hud_pane_id,
1649
+ expectedPanePids: Object.fromEntries(pinnedPaneWorkers.map((worker) => [worker.pane_id, worker.pid])),
1650
+ authorizePaneKill: (paneId) => {
1651
+ const currentOwner = readPaneTeamOwnerTagResult(paneId);
1652
+ return Boolean(expectedScaleDownOwnerId)
1653
+ && currentOwner.status === 'value'
1654
+ && currentOwner.value === expectedScaleDownOwnerId;
1655
+ },
1656
+ });
1657
+ const resolvedPaneIds = new Set([
1658
+ ...unpinnedResolvedPaneIds,
1659
+ ...paneTeardown.provenGonePaneIds,
1660
+ ...paneTeardown.killedPaneIds,
1661
+ ]);
1662
+ const unresolvedWorkers = removableWorkers.filter((worker) => (typeof worker.pane_id === 'string' && !resolvedPaneIds.has(worker.pane_id)));
1663
+ if (unresolvedWorkers.length > 0) {
1664
+ await restorePriorWorkerStatuses(unresolvedWorkers);
1665
+ const proofDebt = [...unpinnedProofDebt, ...paneTeardown.proofUnavailable.map((proof) => `${proof.paneId}:${proof.reason}`)];
1666
+ const killDebt = paneTeardown.kill.failedPaneIds.map((paneId) => `${paneId}:kill_failed`);
1667
+ await writeAtomic(cleanupDebtPath, JSON.stringify({
1668
+ ...cleanupDebtBase,
1669
+ status: 'unresolved',
1670
+ updated_at: new Date().toISOString(),
1671
+ unresolved_panes: unresolvedWorkers.map((worker) => ({
1672
+ name: worker.name,
1673
+ index: worker.index,
1674
+ pane_id: worker.pane_id,
1675
+ pid: worker.pid ?? null,
1676
+ })),
1677
+ reasons: [...proofDebt, ...killDebt],
1678
+ }, null, 2));
1679
+ if (paneTeardown.proofUnavailable.length > 0) {
1680
+ teardownFailure = { ok: false, error: `scale_down_pane_proof_unavailable:${proofDebt.join(',')}` };
1681
+ return;
1682
+ }
1683
+ const unresolvedDebt = unresolvedWorkers
1684
+ .map((worker) => `pane_teardown_unresolved:${worker.pane_id}`)
1685
+ .join(';');
1686
+ const failedDebt = paneTeardown.kill.failedPaneIds.length > 0
1687
+ ? `pane_teardown_failed:${paneTeardown.kill.failedPaneIds.join(',')};`
1688
+ : '';
1689
+ teardownFailure = { ok: false, error: `scale_down_cleanup_debt:${failedDebt}${unresolvedDebt}` };
1690
+ return;
1691
+ }
1692
+ await writeAtomic(cleanupDebtPath, JSON.stringify({
1693
+ ...cleanupDebtBase,
1694
+ status: 'resource_cleanup_pending',
1695
+ updated_at: new Date().toISOString(),
1696
+ unresolved_panes: [],
1697
+ }, null, 2));
1698
+ });
1699
+ });
655
1700
  }
656
- for (const w of targetWorkers) {
657
- if (w.worktree_path) {
658
- await removeWorkerWorktreeRootAgentsFile(sanitized, w.name, w.team_state_root ?? config.team_state_root ?? resolveCanonicalTeamStateRoot(leaderCwd), w.worktree_path).catch(() => { });
659
- }
660
- removedNames.push(w.name);
1701
+ catch (error) {
1702
+ await restorePriorWorkerStatuses(removableWorkers);
1703
+ return { ok: false, error: `scale_down_task_reconciliation_failed:${String(error)}` };
661
1704
  }
662
- // Phase 4: Update config
663
- const removedSet = new Set(removedNames);
664
- config.workers = config.workers.filter(w => !removedSet.has(w.name));
665
- config.worker_count = config.workers.length;
666
- await saveTeamConfig(config, leaderCwd);
667
- await appendTeamEvent(sanitized, {
668
- type: 'team_leader_nudge',
669
- worker: 'leader-fixed',
670
- reason: `scale_down: removed ${removedNames.length} worker(s) [${removedNames.join(', ')}], new count=${config.worker_count}`,
671
- }, leaderCwd);
672
- return {
673
- ok: true,
674
- removedWorkers: removedNames,
675
- newWorkerCount: config.worker_count,
676
- };
1705
+ if (teardownFailure)
1706
+ return teardownFailure;
1707
+ targetWorkers = removableWorkers;
1708
+ // Resource cleanup remains represented by the committed debt until every
1709
+ // worker directory, status, generated AGENTS file, and provisioned worktree
1710
+ // has converged. Reconciliation reacquires authority before consuming it.
1711
+ removedNames.push(...targetWorkers.map((worker) => worker.name));
1712
+ const resourceCleanup = await reconcileScaleDownCleanupDebt(sanitized, leaderCwd, config);
1713
+ if (!resourceCleanup.ok)
1714
+ return resourceCleanup;
1715
+ const reason = `scale_down: removed ${removedNames.length} worker(s) [${removedNames.join(', ')}], new count=${config.worker_count}`;
1716
+ try {
1717
+ await appendTeamEvent(sanitized, { type: 'team_leader_nudge', worker: 'leader-fixed', reason }, leaderCwd);
1718
+ }
1719
+ catch {
1720
+ // The cleanup transaction has already converged; event delivery is advisory.
1721
+ }
1722
+ return { ok: true, removedWorkers: removedNames, newWorkerCount: config.worker_count };
677
1723
  });
678
1724
  }
679
1725
  // ── Helpers ───────────────────────────────────────────────────────────────────