oh-my-codex 0.20.2 → 0.20.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (805) hide show
  1. package/Cargo.lock +36 -18
  2. package/Cargo.toml +1 -1
  3. package/README.md +28 -2
  4. package/crates/omx-runtime/Cargo.toml +4 -0
  5. package/crates/omx-runtime/src/main.rs +415 -2
  6. package/crates/omx-runtime/tests/execution.rs +483 -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/adapt/__tests__/herdr-target.test.d.ts +2 -0
  11. package/dist/adapt/__tests__/herdr-target.test.d.ts.map +1 -0
  12. package/dist/adapt/__tests__/herdr-target.test.js +72 -0
  13. package/dist/adapt/__tests__/herdr-target.test.js.map +1 -0
  14. package/dist/adapt/contracts.d.ts +1 -1
  15. package/dist/adapt/contracts.d.ts.map +1 -1
  16. package/dist/adapt/contracts.js +1 -1
  17. package/dist/adapt/contracts.js.map +1 -1
  18. package/dist/adapt/herdr/__tests__/authority.test.d.ts +2 -0
  19. package/dist/adapt/herdr/__tests__/authority.test.d.ts.map +1 -0
  20. package/dist/adapt/herdr/__tests__/authority.test.js +49 -0
  21. package/dist/adapt/herdr/__tests__/authority.test.js.map +1 -0
  22. package/dist/adapt/herdr/__tests__/bridge.test.d.ts +2 -0
  23. package/dist/adapt/herdr/__tests__/bridge.test.d.ts.map +1 -0
  24. package/dist/adapt/herdr/__tests__/bridge.test.js +176 -0
  25. package/dist/adapt/herdr/__tests__/bridge.test.js.map +1 -0
  26. package/dist/adapt/herdr/__tests__/sanitize.test.d.ts +2 -0
  27. package/dist/adapt/herdr/__tests__/sanitize.test.d.ts.map +1 -0
  28. package/dist/adapt/herdr/__tests__/sanitize.test.js +45 -0
  29. package/dist/adapt/herdr/__tests__/sanitize.test.js.map +1 -0
  30. package/dist/adapt/herdr/__tests__/semantic.test.d.ts +2 -0
  31. package/dist/adapt/herdr/__tests__/semantic.test.d.ts.map +1 -0
  32. package/dist/adapt/herdr/__tests__/semantic.test.js +130 -0
  33. package/dist/adapt/herdr/__tests__/semantic.test.js.map +1 -0
  34. package/dist/adapt/herdr/__tests__/seq-store.test.d.ts +2 -0
  35. package/dist/adapt/herdr/__tests__/seq-store.test.d.ts.map +1 -0
  36. package/dist/adapt/herdr/__tests__/seq-store.test.js +54 -0
  37. package/dist/adapt/herdr/__tests__/seq-store.test.js.map +1 -0
  38. package/dist/adapt/herdr/__tests__/socket-live.test.d.ts +2 -0
  39. package/dist/adapt/herdr/__tests__/socket-live.test.d.ts.map +1 -0
  40. package/dist/adapt/herdr/__tests__/socket-live.test.js +104 -0
  41. package/dist/adapt/herdr/__tests__/socket-live.test.js.map +1 -0
  42. package/dist/adapt/herdr/__tests__/team-map.test.d.ts +2 -0
  43. package/dist/adapt/herdr/__tests__/team-map.test.d.ts.map +1 -0
  44. package/dist/adapt/herdr/__tests__/team-map.test.js +46 -0
  45. package/dist/adapt/herdr/__tests__/team-map.test.js.map +1 -0
  46. package/dist/adapt/herdr/__tests__/transport.test.d.ts +2 -0
  47. package/dist/adapt/herdr/__tests__/transport.test.d.ts.map +1 -0
  48. package/dist/adapt/herdr/__tests__/transport.test.js +160 -0
  49. package/dist/adapt/herdr/__tests__/transport.test.js.map +1 -0
  50. package/dist/adapt/herdr/__tests__/wiring.test.d.ts +2 -0
  51. package/dist/adapt/herdr/__tests__/wiring.test.d.ts.map +1 -0
  52. package/dist/adapt/herdr/__tests__/wiring.test.js +118 -0
  53. package/dist/adapt/herdr/__tests__/wiring.test.js.map +1 -0
  54. package/dist/adapt/herdr/authority.d.ts +34 -0
  55. package/dist/adapt/herdr/authority.d.ts.map +1 -0
  56. package/dist/adapt/herdr/authority.js +71 -0
  57. package/dist/adapt/herdr/authority.js.map +1 -0
  58. package/dist/adapt/herdr/bridge.d.ts +84 -0
  59. package/dist/adapt/herdr/bridge.d.ts.map +1 -0
  60. package/dist/adapt/herdr/bridge.js +222 -0
  61. package/dist/adapt/herdr/bridge.js.map +1 -0
  62. package/dist/adapt/herdr/sanitize.d.ts +20 -0
  63. package/dist/adapt/herdr/sanitize.d.ts.map +1 -0
  64. package/dist/adapt/herdr/sanitize.js +70 -0
  65. package/dist/adapt/herdr/sanitize.js.map +1 -0
  66. package/dist/adapt/herdr/semantic.d.ts +67 -0
  67. package/dist/adapt/herdr/semantic.d.ts.map +1 -0
  68. package/dist/adapt/herdr/semantic.js +170 -0
  69. package/dist/adapt/herdr/semantic.js.map +1 -0
  70. package/dist/adapt/herdr/seq-store.d.ts +32 -0
  71. package/dist/adapt/herdr/seq-store.d.ts.map +1 -0
  72. package/dist/adapt/herdr/seq-store.js +126 -0
  73. package/dist/adapt/herdr/seq-store.js.map +1 -0
  74. package/dist/adapt/herdr/team-map.d.ts +30 -0
  75. package/dist/adapt/herdr/team-map.d.ts.map +1 -0
  76. package/dist/adapt/herdr/team-map.js +37 -0
  77. package/dist/adapt/herdr/team-map.js.map +1 -0
  78. package/dist/adapt/herdr/transport.d.ts +115 -0
  79. package/dist/adapt/herdr/transport.d.ts.map +1 -0
  80. package/dist/adapt/herdr/transport.js +260 -0
  81. package/dist/adapt/herdr/transport.js.map +1 -0
  82. package/dist/adapt/herdr/wiring.d.ts +24 -0
  83. package/dist/adapt/herdr/wiring.d.ts.map +1 -0
  84. package/dist/adapt/herdr/wiring.js +62 -0
  85. package/dist/adapt/herdr/wiring.js.map +1 -0
  86. package/dist/adapt/herdr.d.ts +19 -0
  87. package/dist/adapt/herdr.d.ts.map +1 -0
  88. package/dist/adapt/herdr.js +115 -0
  89. package/dist/adapt/herdr.js.map +1 -0
  90. package/dist/adapt/index.d.ts +4 -0
  91. package/dist/adapt/index.d.ts.map +1 -1
  92. package/dist/adapt/index.js +48 -0
  93. package/dist/adapt/index.js.map +1 -1
  94. package/dist/adapt/registry.d.ts.map +1 -1
  95. package/dist/adapt/registry.js +11 -0
  96. package/dist/adapt/registry.js.map +1 -1
  97. package/dist/agents/__tests__/definitions.test.js +81 -0
  98. package/dist/agents/__tests__/definitions.test.js.map +1 -1
  99. package/dist/agents/__tests__/native-config.test.js +25 -0
  100. package/dist/agents/__tests__/native-config.test.js.map +1 -1
  101. package/dist/agents/native-config.d.ts +2 -1
  102. package/dist/agents/native-config.d.ts.map +1 -1
  103. package/dist/agents/native-config.js.map +1 -1
  104. package/dist/auth/__tests__/hotswap.test.js +60 -1
  105. package/dist/auth/__tests__/hotswap.test.js.map +1 -1
  106. package/dist/auth/__tests__/storage.test.js +33 -1
  107. package/dist/auth/__tests__/storage.test.js.map +1 -1
  108. package/dist/auth/hotswap.d.ts +22 -2
  109. package/dist/auth/hotswap.d.ts.map +1 -1
  110. package/dist/auth/hotswap.js +107 -22
  111. package/dist/auth/hotswap.js.map +1 -1
  112. package/dist/auth/storage.js +1 -1
  113. package/dist/auth/storage.js.map +1 -1
  114. package/dist/autopilot/__tests__/ralplan-gate.test.js +86 -1333
  115. package/dist/autopilot/__tests__/ralplan-gate.test.js.map +1 -1
  116. package/dist/autopilot/ralplan-gate.d.ts.map +1 -1
  117. package/dist/autopilot/ralplan-gate.js +7 -6
  118. package/dist/autopilot/ralplan-gate.js.map +1 -1
  119. package/dist/cli/__tests__/agents-init.test.js +4 -23
  120. package/dist/cli/__tests__/agents-init.test.js.map +1 -1
  121. package/dist/cli/__tests__/api.test.js +61 -24
  122. package/dist/cli/__tests__/api.test.js.map +1 -1
  123. package/dist/cli/__tests__/auth.test.js +63 -7
  124. package/dist/cli/__tests__/auth.test.js.map +1 -1
  125. package/dist/cli/__tests__/codex-feature-probe.test.js +66 -1
  126. package/dist/cli/__tests__/codex-feature-probe.test.js.map +1 -1
  127. package/dist/cli/__tests__/codex-plugin-layout.test.js +150 -2
  128. package/dist/cli/__tests__/codex-plugin-layout.test.js.map +1 -1
  129. package/dist/cli/__tests__/detached-leader-teardown.test.d.ts +2 -0
  130. package/dist/cli/__tests__/detached-leader-teardown.test.d.ts.map +1 -0
  131. package/dist/cli/__tests__/detached-leader-teardown.test.js +473 -0
  132. package/dist/cli/__tests__/detached-leader-teardown.test.js.map +1 -0
  133. package/dist/cli/__tests__/doctor-process-identity-readiness.test.d.ts +2 -0
  134. package/dist/cli/__tests__/doctor-process-identity-readiness.test.d.ts.map +1 -0
  135. package/dist/cli/__tests__/doctor-process-identity-readiness.test.js +103 -0
  136. package/dist/cli/__tests__/doctor-process-identity-readiness.test.js.map +1 -0
  137. package/dist/cli/__tests__/doctor-state-root-binding.test.d.ts +2 -0
  138. package/dist/cli/__tests__/doctor-state-root-binding.test.d.ts.map +1 -0
  139. package/dist/cli/__tests__/doctor-state-root-binding.test.js +139 -0
  140. package/dist/cli/__tests__/doctor-state-root-binding.test.js.map +1 -0
  141. package/dist/cli/__tests__/doctor-warning-copy.test.js +135 -1
  142. package/dist/cli/__tests__/doctor-warning-copy.test.js.map +1 -1
  143. package/dist/cli/__tests__/exec.test.js +211 -0
  144. package/dist/cli/__tests__/exec.test.js.map +1 -1
  145. package/dist/cli/__tests__/index.test.js +878 -463
  146. package/dist/cli/__tests__/index.test.js.map +1 -1
  147. package/dist/cli/__tests__/launch-fallback.test.js +1290 -127
  148. package/dist/cli/__tests__/launch-fallback.test.js.map +1 -1
  149. package/dist/cli/__tests__/mcp-parity.test.js +8 -4
  150. package/dist/cli/__tests__/mcp-parity.test.js.map +1 -1
  151. package/dist/cli/__tests__/native-assets.test.js +398 -16
  152. package/dist/cli/__tests__/native-assets.test.js.map +1 -1
  153. package/dist/cli/__tests__/native-hook-claim-journal.test.js +132 -6
  154. package/dist/cli/__tests__/native-hook-claim-journal.test.js.map +1 -1
  155. package/dist/cli/__tests__/ralph-goal-mode-contract.test.js +3 -3
  156. package/dist/cli/__tests__/ralph-goal-mode-contract.test.js.map +1 -1
  157. package/dist/cli/__tests__/ralph.test.js +3 -3
  158. package/dist/cli/__tests__/ralph.test.js.map +1 -1
  159. package/dist/cli/__tests__/ralplan-bootstrap-3181.test.js +49 -164
  160. package/dist/cli/__tests__/ralplan-bootstrap-3181.test.js.map +1 -1
  161. package/dist/cli/__tests__/ralplan-child-binding-3212.test.d.ts +2 -0
  162. package/dist/cli/__tests__/ralplan-child-binding-3212.test.d.ts.map +1 -0
  163. package/dist/cli/__tests__/ralplan-child-binding-3212.test.js +56 -0
  164. package/dist/cli/__tests__/ralplan-child-binding-3212.test.js.map +1 -0
  165. package/dist/cli/__tests__/ralplan.test.js +107 -109
  166. package/dist/cli/__tests__/ralplan.test.js.map +1 -1
  167. package/dist/cli/__tests__/resume.test.js +11 -10
  168. package/dist/cli/__tests__/resume.test.js.map +1 -1
  169. package/dist/cli/__tests__/session-scoped-runtime.test.js +1118 -43
  170. package/dist/cli/__tests__/session-scoped-runtime.test.js.map +1 -1
  171. package/dist/cli/__tests__/session-search-help.test.js +25 -1
  172. package/dist/cli/__tests__/session-search-help.test.js.map +1 -1
  173. package/dist/cli/__tests__/setup-agents-overwrite.test.js +8 -55
  174. package/dist/cli/__tests__/setup-agents-overwrite.test.js.map +1 -1
  175. package/dist/cli/__tests__/setup-install-mode.test.js +224 -7
  176. package/dist/cli/__tests__/setup-install-mode.test.js.map +1 -1
  177. package/dist/cli/__tests__/sparkshell-cli.test.js +93 -41
  178. package/dist/cli/__tests__/sparkshell-cli.test.js.map +1 -1
  179. package/dist/cli/__tests__/team.test.js +150 -8
  180. package/dist/cli/__tests__/team.test.js.map +1 -1
  181. package/dist/cli/__tests__/ultragoal.test.js +117 -2
  182. package/dist/cli/__tests__/ultragoal.test.js.map +1 -1
  183. package/dist/cli/__tests__/uninstall.test.js +95 -4
  184. package/dist/cli/__tests__/uninstall.test.js.map +1 -1
  185. package/dist/cli/__tests__/update.test.js +669 -177
  186. package/dist/cli/__tests__/update.test.js.map +1 -1
  187. package/dist/cli/__tests__/windows-popup-loop-contract.test.js +33 -2
  188. package/dist/cli/__tests__/windows-popup-loop-contract.test.js.map +1 -1
  189. package/dist/cli/api.d.ts.map +1 -1
  190. package/dist/cli/api.js +9 -10
  191. package/dist/cli/api.js.map +1 -1
  192. package/dist/cli/codex-feature-probe.d.ts +16 -0
  193. package/dist/cli/codex-feature-probe.d.ts.map +1 -1
  194. package/dist/cli/codex-feature-probe.js +44 -0
  195. package/dist/cli/codex-feature-probe.js.map +1 -1
  196. package/dist/cli/codex-home.d.ts.map +1 -1
  197. package/dist/cli/codex-home.js +7 -4
  198. package/dist/cli/codex-home.js.map +1 -1
  199. package/dist/cli/doctor.d.ts +20 -0
  200. package/dist/cli/doctor.d.ts.map +1 -1
  201. package/dist/cli/doctor.js +290 -3
  202. package/dist/cli/doctor.js.map +1 -1
  203. package/dist/cli/index.d.ts +234 -12
  204. package/dist/cli/index.d.ts.map +1 -1
  205. package/dist/cli/index.js +2409 -746
  206. package/dist/cli/index.js.map +1 -1
  207. package/dist/cli/mcp-parity.js +2 -2
  208. package/dist/cli/mcp-parity.js.map +1 -1
  209. package/dist/cli/native-assets.d.ts +25 -0
  210. package/dist/cli/native-assets.d.ts.map +1 -1
  211. package/dist/cli/native-assets.js +448 -51
  212. package/dist/cli/native-assets.js.map +1 -1
  213. package/dist/cli/native-hook-claim-journal.d.ts +20 -5
  214. package/dist/cli/native-hook-claim-journal.d.ts.map +1 -1
  215. package/dist/cli/native-hook-claim-journal.js +67 -32
  216. package/dist/cli/native-hook-claim-journal.js.map +1 -1
  217. package/dist/cli/package-manager-ownership.d.ts +63 -0
  218. package/dist/cli/package-manager-ownership.d.ts.map +1 -0
  219. package/dist/cli/package-manager-ownership.js +323 -0
  220. package/dist/cli/package-manager-ownership.js.map +1 -0
  221. package/dist/cli/ralph.js +2 -2
  222. package/dist/cli/ralph.js.map +1 -1
  223. package/dist/cli/ralplan.d.ts +5 -7
  224. package/dist/cli/ralplan.d.ts.map +1 -1
  225. package/dist/cli/ralplan.js +83 -134
  226. package/dist/cli/ralplan.js.map +1 -1
  227. package/dist/cli/session-search.d.ts +5 -0
  228. package/dist/cli/session-search.d.ts.map +1 -1
  229. package/dist/cli/session-search.js +87 -0
  230. package/dist/cli/session-search.js.map +1 -1
  231. package/dist/cli/setup-preferences.d.ts +15 -0
  232. package/dist/cli/setup-preferences.d.ts.map +1 -1
  233. package/dist/cli/setup-preferences.js +27 -1
  234. package/dist/cli/setup-preferences.js.map +1 -1
  235. package/dist/cli/setup.d.ts +8 -0
  236. package/dist/cli/setup.d.ts.map +1 -1
  237. package/dist/cli/setup.js +119 -47
  238. package/dist/cli/setup.js.map +1 -1
  239. package/dist/cli/sparkshell.d.ts.map +1 -1
  240. package/dist/cli/sparkshell.js +52 -9
  241. package/dist/cli/sparkshell.js.map +1 -1
  242. package/dist/cli/ultragoal.d.ts.map +1 -1
  243. package/dist/cli/ultragoal.js +2 -2
  244. package/dist/cli/ultragoal.js.map +1 -1
  245. package/dist/cli/uninstall.d.ts +9 -0
  246. package/dist/cli/uninstall.d.ts.map +1 -1
  247. package/dist/cli/uninstall.js +71 -30
  248. package/dist/cli/uninstall.js.map +1 -1
  249. package/dist/cli/update-worker.d.ts +2 -0
  250. package/dist/cli/update-worker.d.ts.map +1 -0
  251. package/dist/cli/update-worker.js +142 -0
  252. package/dist/cli/update-worker.js.map +1 -0
  253. package/dist/cli/update.d.ts +14 -22
  254. package/dist/cli/update.d.ts.map +1 -1
  255. package/dist/cli/update.js +192 -241
  256. package/dist/cli/update.js.map +1 -1
  257. package/dist/compat/__tests__/rust-runtime-compat.test.js +1 -1
  258. package/dist/config/__tests__/generator-idempotent.test.js +503 -30
  259. package/dist/config/__tests__/generator-idempotent.test.js.map +1 -1
  260. package/dist/config/__tests__/generator-notify.test.js +5 -3
  261. package/dist/config/__tests__/generator-notify.test.js.map +1 -1
  262. package/dist/config/__tests__/models.test.js +67 -3
  263. package/dist/config/__tests__/models.test.js.map +1 -1
  264. package/dist/config/generator.d.ts +27 -21
  265. package/dist/config/generator.d.ts.map +1 -1
  266. package/dist/config/generator.js +610 -152
  267. package/dist/config/generator.js.map +1 -1
  268. package/dist/config/models.d.ts +12 -2
  269. package/dist/config/models.d.ts.map +1 -1
  270. package/dist/config/models.js +15 -1
  271. package/dist/config/models.js.map +1 -1
  272. package/dist/goal-workflows/__tests__/codex-goal-snapshot.test.js +25 -0
  273. package/dist/goal-workflows/__tests__/codex-goal-snapshot.test.js.map +1 -1
  274. package/dist/goal-workflows/codex-goal-snapshot.d.ts +1 -1
  275. package/dist/goal-workflows/codex-goal-snapshot.d.ts.map +1 -1
  276. package/dist/goal-workflows/codex-goal-snapshot.js +2 -0
  277. package/dist/goal-workflows/codex-goal-snapshot.js.map +1 -1
  278. package/dist/hooks/__tests__/agents-overlay.test.js +5 -3
  279. package/dist/hooks/__tests__/agents-overlay.test.js.map +1 -1
  280. package/dist/hooks/__tests__/autopilot-skill-contract.test.js +27 -4
  281. package/dist/hooks/__tests__/autopilot-skill-contract.test.js.map +1 -1
  282. package/dist/hooks/__tests__/issue-3293-autopilot-handoff.test.d.ts +2 -0
  283. package/dist/hooks/__tests__/issue-3293-autopilot-handoff.test.d.ts.map +1 -0
  284. package/dist/hooks/__tests__/issue-3293-autopilot-handoff.test.js +172 -0
  285. package/dist/hooks/__tests__/issue-3293-autopilot-handoff.test.js.map +1 -0
  286. package/dist/hooks/__tests__/keyword-detector.test.js +176 -37
  287. package/dist/hooks/__tests__/keyword-detector.test.js.map +1 -1
  288. package/dist/hooks/__tests__/notify-fallback-watcher.test.js +402 -545
  289. package/dist/hooks/__tests__/notify-fallback-watcher.test.js.map +1 -1
  290. package/dist/hooks/__tests__/notify-hook-all-workers-idle.test.js +39 -4
  291. package/dist/hooks/__tests__/notify-hook-all-workers-idle.test.js.map +1 -1
  292. package/dist/hooks/__tests__/notify-hook-auto-nudge.test.js +161 -26
  293. package/dist/hooks/__tests__/notify-hook-auto-nudge.test.js.map +1 -1
  294. package/dist/hooks/__tests__/notify-hook-cross-worktree-heartbeat.test.js +27 -0
  295. package/dist/hooks/__tests__/notify-hook-cross-worktree-heartbeat.test.js.map +1 -1
  296. package/dist/hooks/__tests__/notify-hook-managed-tmux.test.js +10 -118
  297. package/dist/hooks/__tests__/notify-hook-managed-tmux.test.js.map +1 -1
  298. package/dist/hooks/__tests__/notify-hook-regression-205.test.js +7 -0
  299. package/dist/hooks/__tests__/notify-hook-regression-205.test.js.map +1 -1
  300. package/dist/hooks/__tests__/notify-hook-team-dispatch.test.js +533 -14
  301. package/dist/hooks/__tests__/notify-hook-team-dispatch.test.js.map +1 -1
  302. package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js +159 -24
  303. package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js.map +1 -1
  304. package/dist/hooks/__tests__/notify-hook-team-tmux-guard.test.js +482 -32
  305. package/dist/hooks/__tests__/notify-hook-team-tmux-guard.test.js.map +1 -1
  306. package/dist/hooks/__tests__/notify-hook-team-worker-fail-closed.test.js +2 -1
  307. package/dist/hooks/__tests__/notify-hook-team-worker-fail-closed.test.js.map +1 -1
  308. package/dist/hooks/__tests__/notify-hook-tmux-heal.test.js +68 -1
  309. package/dist/hooks/__tests__/notify-hook-tmux-heal.test.js.map +1 -1
  310. package/dist/hooks/__tests__/notify-hook-tmux-scrollback.test.js +7 -1
  311. package/dist/hooks/__tests__/notify-hook-tmux-scrollback.test.js.map +1 -1
  312. package/dist/hooks/__tests__/notify-hook-worker-idle.test.js +44 -4
  313. package/dist/hooks/__tests__/notify-hook-worker-idle.test.js.map +1 -1
  314. package/dist/hooks/__tests__/prompt-guidance-contract.test.js +9 -0
  315. package/dist/hooks/__tests__/prompt-guidance-contract.test.js.map +1 -1
  316. package/dist/hooks/__tests__/session.test.js +3130 -365
  317. package/dist/hooks/__tests__/session.test.js.map +1 -1
  318. package/dist/hooks/agents-overlay.js +1 -1
  319. package/dist/hooks/agents-overlay.js.map +1 -1
  320. package/dist/hooks/extensibility/__tests__/dispatcher.test.js +108 -2
  321. package/dist/hooks/extensibility/__tests__/dispatcher.test.js.map +1 -1
  322. package/dist/hooks/extensibility/__tests__/sdk.test.js +151 -1
  323. package/dist/hooks/extensibility/__tests__/sdk.test.js.map +1 -1
  324. package/dist/hooks/extensibility/dispatcher.d.ts.map +1 -1
  325. package/dist/hooks/extensibility/dispatcher.js +20 -3
  326. package/dist/hooks/extensibility/dispatcher.js.map +1 -1
  327. package/dist/hooks/extensibility/plugin-runner.js +1 -0
  328. package/dist/hooks/extensibility/plugin-runner.js.map +1 -1
  329. package/dist/hooks/extensibility/runtime.d.ts.map +1 -1
  330. package/dist/hooks/extensibility/runtime.js.map +1 -1
  331. package/dist/hooks/extensibility/sdk/paths.d.ts +1 -1
  332. package/dist/hooks/extensibility/sdk/paths.d.ts.map +1 -1
  333. package/dist/hooks/extensibility/sdk/paths.js +2 -2
  334. package/dist/hooks/extensibility/sdk/paths.js.map +1 -1
  335. package/dist/hooks/extensibility/sdk/runtime-state.d.ts +1 -1
  336. package/dist/hooks/extensibility/sdk/runtime-state.d.ts.map +1 -1
  337. package/dist/hooks/extensibility/sdk/runtime-state.js +55 -15
  338. package/dist/hooks/extensibility/sdk/runtime-state.js.map +1 -1
  339. package/dist/hooks/extensibility/sdk.d.ts +1 -0
  340. package/dist/hooks/extensibility/sdk.d.ts.map +1 -1
  341. package/dist/hooks/extensibility/sdk.js +1 -1
  342. package/dist/hooks/extensibility/sdk.js.map +1 -1
  343. package/dist/hooks/extensibility/types.d.ts +7 -0
  344. package/dist/hooks/extensibility/types.d.ts.map +1 -1
  345. package/dist/hooks/keyword-detector.d.ts +10 -3
  346. package/dist/hooks/keyword-detector.d.ts.map +1 -1
  347. package/dist/hooks/keyword-detector.js +116 -20
  348. package/dist/hooks/keyword-detector.js.map +1 -1
  349. package/dist/hooks/session.d.ts +204 -14
  350. package/dist/hooks/session.d.ts.map +1 -1
  351. package/dist/hooks/session.js +1500 -223
  352. package/dist/hooks/session.js.map +1 -1
  353. package/dist/hud/__tests__/hud-tmux-injection.test.js +6 -6
  354. package/dist/hud/__tests__/hud-tmux-injection.test.js.map +1 -1
  355. package/dist/hud/__tests__/index.test.js +26 -16
  356. package/dist/hud/__tests__/index.test.js.map +1 -1
  357. package/dist/hud/__tests__/reconcile.test.js +83 -78
  358. package/dist/hud/__tests__/reconcile.test.js.map +1 -1
  359. package/dist/hud/__tests__/state.test.js +36 -31
  360. package/dist/hud/__tests__/state.test.js.map +1 -1
  361. package/dist/hud/__tests__/tmux.test.js +155 -198
  362. package/dist/hud/__tests__/tmux.test.js.map +1 -1
  363. package/dist/hud/tmux.d.ts +42 -4
  364. package/dist/hud/tmux.d.ts.map +1 -1
  365. package/dist/hud/tmux.js +1017 -139
  366. package/dist/hud/tmux.js.map +1 -1
  367. package/dist/leader/__tests__/contract.test.js +124 -37
  368. package/dist/leader/__tests__/contract.test.js.map +1 -1
  369. package/dist/leader/contract.d.ts +17 -6
  370. package/dist/leader/contract.d.ts.map +1 -1
  371. package/dist/leader/contract.js +102 -26
  372. package/dist/leader/contract.js.map +1 -1
  373. package/dist/mcp/__tests__/code-intel-server.test.js +31 -3
  374. package/dist/mcp/__tests__/code-intel-server.test.js.map +1 -1
  375. package/dist/mcp/__tests__/hermes-bridge.test.js +17 -0
  376. package/dist/mcp/__tests__/hermes-bridge.test.js.map +1 -1
  377. package/dist/mcp/__tests__/server-lifecycle.test.js +4 -4
  378. package/dist/mcp/__tests__/state-paths.test.js +735 -4
  379. package/dist/mcp/__tests__/state-paths.test.js.map +1 -1
  380. package/dist/mcp/__tests__/state-server.test.js +1 -1
  381. package/dist/mcp/__tests__/state-server.test.js.map +1 -1
  382. package/dist/mcp/__tests__/trace-server.test.js +2 -2
  383. package/dist/mcp/__tests__/trace-server.test.js.map +1 -1
  384. package/dist/mcp/__tests__/wiki-server.test.js +18 -0
  385. package/dist/mcp/__tests__/wiki-server.test.js.map +1 -1
  386. package/dist/mcp/code-intel-server.d.ts.map +1 -1
  387. package/dist/mcp/code-intel-server.js +13 -3
  388. package/dist/mcp/code-intel-server.js.map +1 -1
  389. package/dist/mcp/hermes-bridge.d.ts.map +1 -1
  390. package/dist/mcp/hermes-bridge.js +2 -2
  391. package/dist/mcp/hermes-bridge.js.map +1 -1
  392. package/dist/mcp/state-paths.d.ts +78 -4
  393. package/dist/mcp/state-paths.d.ts.map +1 -1
  394. package/dist/mcp/state-paths.js +543 -40
  395. package/dist/mcp/state-paths.js.map +1 -1
  396. package/dist/mcp/state-server.d.ts +4 -4
  397. package/dist/mcp/state-server.d.ts.map +1 -1
  398. package/dist/mcp/wiki-server.d.ts +11 -0
  399. package/dist/mcp/wiki-server.d.ts.map +1 -1
  400. package/dist/mcp/wiki-server.js +9 -2
  401. package/dist/mcp/wiki-server.js.map +1 -1
  402. package/dist/modes/__tests__/base-autopilot-gates.test.js +36 -7
  403. package/dist/modes/__tests__/base-autopilot-gates.test.js.map +1 -1
  404. package/dist/modes/__tests__/base-multi-state-compat.test.js +1 -1
  405. package/dist/modes/__tests__/base-multi-state-compat.test.js.map +1 -1
  406. package/dist/modes/__tests__/base-session-scope.test.js +17 -8
  407. package/dist/modes/__tests__/base-session-scope.test.js.map +1 -1
  408. package/dist/modes/__tests__/base-tmux-pane.test.js +4 -4
  409. package/dist/modes/__tests__/base-tmux-pane.test.js.map +1 -1
  410. package/dist/modes/base.d.ts +5 -5
  411. package/dist/modes/base.d.ts.map +1 -1
  412. package/dist/modes/base.js +76 -21
  413. package/dist/modes/base.js.map +1 -1
  414. package/dist/native-assets/archive.d.ts +17 -0
  415. package/dist/native-assets/archive.d.ts.map +1 -0
  416. package/dist/native-assets/archive.js +238 -0
  417. package/dist/native-assets/archive.js.map +1 -0
  418. package/dist/native-assets/policy.d.ts +42 -0
  419. package/dist/native-assets/policy.d.ts.map +1 -0
  420. package/dist/native-assets/policy.js +155 -0
  421. package/dist/native-assets/policy.js.map +1 -0
  422. package/dist/notifications/temp-contract.d.ts.map +1 -1
  423. package/dist/notifications/temp-contract.js +4 -0
  424. package/dist/notifications/temp-contract.js.map +1 -1
  425. package/dist/pipeline/__tests__/orchestrator.test.js +164 -42
  426. package/dist/pipeline/__tests__/orchestrator.test.js.map +1 -1
  427. package/dist/pipeline/__tests__/stages.test.js +72 -155
  428. package/dist/pipeline/__tests__/stages.test.js.map +1 -1
  429. package/dist/pipeline/orchestrator.d.ts.map +1 -1
  430. package/dist/pipeline/orchestrator.js +124 -18
  431. package/dist/pipeline/orchestrator.js.map +1 -1
  432. package/dist/pipeline/stages/ralplan.d.ts.map +1 -1
  433. package/dist/pipeline/stages/ralplan.js +36 -8
  434. package/dist/pipeline/stages/ralplan.js.map +1 -1
  435. package/dist/ralplan/__tests__/consensus-gate.test.js +73 -1316
  436. package/dist/ralplan/__tests__/consensus-gate.test.js.map +1 -1
  437. package/dist/ralplan/__tests__/documented-leader-preflight.test.d.ts +2 -0
  438. package/dist/ralplan/__tests__/documented-leader-preflight.test.d.ts.map +1 -0
  439. package/dist/ralplan/__tests__/documented-leader-preflight.test.js +335 -0
  440. package/dist/ralplan/__tests__/documented-leader-preflight.test.js.map +1 -0
  441. package/dist/ralplan/__tests__/runtime.test.js +85 -101
  442. package/dist/ralplan/__tests__/runtime.test.js.map +1 -1
  443. package/dist/ralplan/consensus-gate.d.ts +9 -1
  444. package/dist/ralplan/consensus-gate.d.ts.map +1 -1
  445. package/dist/ralplan/consensus-gate.js +82 -338
  446. package/dist/ralplan/consensus-gate.js.map +1 -1
  447. package/dist/ralplan/documented-leader-preflight.d.ts +41 -0
  448. package/dist/ralplan/documented-leader-preflight.d.ts.map +1 -0
  449. package/dist/ralplan/documented-leader-preflight.js +360 -0
  450. package/dist/ralplan/documented-leader-preflight.js.map +1 -0
  451. package/dist/ralplan/runtime.d.ts +2 -1
  452. package/dist/ralplan/runtime.d.ts.map +1 -1
  453. package/dist/ralplan/runtime.js +67 -168
  454. package/dist/ralplan/runtime.js.map +1 -1
  455. package/dist/runtime/__tests__/bridge.test.js +42 -1
  456. package/dist/runtime/__tests__/bridge.test.js.map +1 -1
  457. package/dist/runtime/bridge.d.ts +12 -0
  458. package/dist/runtime/bridge.d.ts.map +1 -1
  459. package/dist/runtime/bridge.js +52 -1
  460. package/dist/runtime/bridge.js.map +1 -1
  461. package/dist/runtime/run-state.d.ts +5 -1
  462. package/dist/runtime/run-state.d.ts.map +1 -1
  463. package/dist/runtime/run-state.js +15 -8
  464. package/dist/runtime/run-state.js.map +1 -1
  465. package/dist/scripts/__tests__/codex-native-hook.test.js +16323 -8978
  466. package/dist/scripts/__tests__/codex-native-hook.test.js.map +1 -1
  467. package/dist/scripts/__tests__/issue-3293-callsite-parity.test.d.ts +2 -0
  468. package/dist/scripts/__tests__/issue-3293-callsite-parity.test.d.ts.map +1 -0
  469. package/dist/scripts/__tests__/issue-3293-callsite-parity.test.js +225 -0
  470. package/dist/scripts/__tests__/issue-3293-callsite-parity.test.js.map +1 -0
  471. package/dist/scripts/__tests__/issue-3293-cancel-transaction.test.d.ts +2 -0
  472. package/dist/scripts/__tests__/issue-3293-cancel-transaction.test.d.ts.map +1 -0
  473. package/dist/scripts/__tests__/issue-3293-cancel-transaction.test.js +188 -0
  474. package/dist/scripts/__tests__/issue-3293-cancel-transaction.test.js.map +1 -0
  475. package/dist/scripts/__tests__/issue-3293-hook-owned-cancel.test.d.ts +2 -0
  476. package/dist/scripts/__tests__/issue-3293-hook-owned-cancel.test.d.ts.map +1 -0
  477. package/dist/scripts/__tests__/issue-3293-hook-owned-cancel.test.js +259 -0
  478. package/dist/scripts/__tests__/issue-3293-hook-owned-cancel.test.js.map +1 -0
  479. package/dist/scripts/__tests__/issue-3311-ultragoal-native-outside-tmux.test.d.ts +2 -0
  480. package/dist/scripts/__tests__/issue-3311-ultragoal-native-outside-tmux.test.d.ts.map +1 -0
  481. package/dist/scripts/__tests__/issue-3311-ultragoal-native-outside-tmux.test.js +491 -0
  482. package/dist/scripts/__tests__/issue-3311-ultragoal-native-outside-tmux.test.js.map +1 -0
  483. package/dist/scripts/__tests__/issue-3358-ultragoal-cancel-lifecycle.test.d.ts +2 -0
  484. package/dist/scripts/__tests__/issue-3358-ultragoal-cancel-lifecycle.test.d.ts.map +1 -0
  485. package/dist/scripts/__tests__/issue-3358-ultragoal-cancel-lifecycle.test.js +708 -0
  486. package/dist/scripts/__tests__/issue-3358-ultragoal-cancel-lifecycle.test.js.map +1 -0
  487. package/dist/scripts/__tests__/issue-3369-autopilot-ralplan-state-machine.test.d.ts +2 -0
  488. package/dist/scripts/__tests__/issue-3369-autopilot-ralplan-state-machine.test.d.ts.map +1 -0
  489. package/dist/scripts/__tests__/issue-3369-autopilot-ralplan-state-machine.test.js +208 -0
  490. package/dist/scripts/__tests__/issue-3369-autopilot-ralplan-state-machine.test.js.map +1 -0
  491. package/dist/scripts/__tests__/notify-state-io.test.js +26 -0
  492. package/dist/scripts/__tests__/notify-state-io.test.js.map +1 -1
  493. package/dist/scripts/__tests__/notify-tmux-injection.test.js +23 -1
  494. package/dist/scripts/__tests__/notify-tmux-injection.test.js.map +1 -1
  495. package/dist/scripts/__tests__/postinstall.test.js +19 -1
  496. package/dist/scripts/__tests__/postinstall.test.js.map +1 -1
  497. package/dist/scripts/__tests__/reasoning-artifact-contract.test.d.ts +2 -0
  498. package/dist/scripts/__tests__/reasoning-artifact-contract.test.d.ts.map +1 -0
  499. package/dist/scripts/__tests__/reasoning-artifact-contract.test.js +108 -0
  500. package/dist/scripts/__tests__/reasoning-artifact-contract.test.js.map +1 -0
  501. package/dist/scripts/__tests__/role-intent-bootstrap-e2e-3181.test.js +63 -173
  502. package/dist/scripts/__tests__/role-intent-bootstrap-e2e-3181.test.js.map +1 -1
  503. package/dist/scripts/__tests__/role-intent-durable-recovery-3181.test.js +74 -159
  504. package/dist/scripts/__tests__/role-intent-durable-recovery-3181.test.js.map +1 -1
  505. package/dist/scripts/__tests__/smoke-packed-install.test.js +404 -13
  506. package/dist/scripts/__tests__/smoke-packed-install.test.js.map +1 -1
  507. package/dist/scripts/__tests__/verify-native-release-assets.test.d.ts +2 -0
  508. package/dist/scripts/__tests__/verify-native-release-assets.test.d.ts.map +1 -0
  509. package/dist/scripts/__tests__/verify-native-release-assets.test.js +235 -0
  510. package/dist/scripts/__tests__/verify-native-release-assets.test.js.map +1 -0
  511. package/dist/scripts/codex-native-hook.d.ts +53 -1
  512. package/dist/scripts/codex-native-hook.d.ts.map +1 -1
  513. package/dist/scripts/codex-native-hook.js +14448 -1480
  514. package/dist/scripts/codex-native-hook.js.map +1 -1
  515. package/dist/scripts/codex-native-pre-post.d.ts +9 -0
  516. package/dist/scripts/codex-native-pre-post.d.ts.map +1 -1
  517. package/dist/scripts/codex-native-pre-post.js +35 -12
  518. package/dist/scripts/codex-native-pre-post.js.map +1 -1
  519. package/dist/scripts/generate-native-release-manifest.js +80 -51
  520. package/dist/scripts/generate-native-release-manifest.js.map +1 -1
  521. package/dist/scripts/notify-fallback-watcher.js +78 -115
  522. package/dist/scripts/notify-fallback-watcher.js.map +1 -1
  523. package/dist/scripts/notify-hook/auto-nudge.d.ts.map +1 -1
  524. package/dist/scripts/notify-hook/auto-nudge.js +74 -3
  525. package/dist/scripts/notify-hook/auto-nudge.js.map +1 -1
  526. package/dist/scripts/notify-hook/state-io.d.ts.map +1 -1
  527. package/dist/scripts/notify-hook/state-io.js +5 -0
  528. package/dist/scripts/notify-hook/state-io.js.map +1 -1
  529. package/dist/scripts/notify-hook/team-dispatch.d.ts +9 -0
  530. package/dist/scripts/notify-hook/team-dispatch.d.ts.map +1 -1
  531. package/dist/scripts/notify-hook/team-dispatch.js +234 -79
  532. package/dist/scripts/notify-hook/team-dispatch.js.map +1 -1
  533. package/dist/scripts/notify-hook/team-leader-nudge.d.ts.map +1 -1
  534. package/dist/scripts/notify-hook/team-leader-nudge.js +71 -16
  535. package/dist/scripts/notify-hook/team-leader-nudge.js.map +1 -1
  536. package/dist/scripts/notify-hook/team-tmux-guard.d.ts +12 -4
  537. package/dist/scripts/notify-hook/team-tmux-guard.d.ts.map +1 -1
  538. package/dist/scripts/notify-hook/team-tmux-guard.js +365 -44
  539. package/dist/scripts/notify-hook/team-tmux-guard.js.map +1 -1
  540. package/dist/scripts/notify-hook/team-worker-stop.d.ts.map +1 -1
  541. package/dist/scripts/notify-hook/team-worker-stop.js +42 -21
  542. package/dist/scripts/notify-hook/team-worker-stop.js.map +1 -1
  543. package/dist/scripts/notify-hook/team-worker.d.ts +3 -0
  544. package/dist/scripts/notify-hook/team-worker.d.ts.map +1 -1
  545. package/dist/scripts/notify-hook/team-worker.js +82 -33
  546. package/dist/scripts/notify-hook/team-worker.js.map +1 -1
  547. package/dist/scripts/notify-hook/tmux-injection.d.ts +1 -0
  548. package/dist/scripts/notify-hook/tmux-injection.d.ts.map +1 -1
  549. package/dist/scripts/notify-hook/tmux-injection.js +32 -3
  550. package/dist/scripts/notify-hook/tmux-injection.js.map +1 -1
  551. package/dist/scripts/notify-hook.js +79 -82
  552. package/dist/scripts/notify-hook.js.map +1 -1
  553. package/dist/scripts/postinstall-advisory.d.ts +14 -0
  554. package/dist/scripts/postinstall-advisory.d.ts.map +1 -0
  555. package/dist/scripts/postinstall-advisory.js +49 -0
  556. package/dist/scripts/postinstall-advisory.js.map +1 -0
  557. package/dist/scripts/postinstall.d.ts +1 -1
  558. package/dist/scripts/postinstall.d.ts.map +1 -1
  559. package/dist/scripts/postinstall.js +1 -1
  560. package/dist/scripts/postinstall.js.map +1 -1
  561. package/dist/scripts/smoke-packed-install.d.ts +80 -4
  562. package/dist/scripts/smoke-packed-install.d.ts.map +1 -1
  563. package/dist/scripts/smoke-packed-install.js +3162 -26
  564. package/dist/scripts/smoke-packed-install.js.map +1 -1
  565. package/dist/scripts/sync-plugin-mirror.d.ts.map +1 -1
  566. package/dist/scripts/sync-plugin-mirror.js +16 -1
  567. package/dist/scripts/sync-plugin-mirror.js.map +1 -1
  568. package/dist/scripts/verify-native-release-assets.js +37 -23
  569. package/dist/scripts/verify-native-release-assets.js.map +1 -1
  570. package/dist/state/__tests__/mode-state-context.test.js +61 -0
  571. package/dist/state/__tests__/mode-state-context.test.js.map +1 -1
  572. package/dist/state/__tests__/operations.test.js +462 -850
  573. package/dist/state/__tests__/operations.test.js.map +1 -1
  574. package/dist/state/__tests__/skill-active.test.js +400 -2
  575. package/dist/state/__tests__/skill-active.test.js.map +1 -1
  576. package/dist/state/mode-state-context.d.ts +8 -0
  577. package/dist/state/mode-state-context.d.ts.map +1 -1
  578. package/dist/state/mode-state-context.js +73 -0
  579. package/dist/state/mode-state-context.js.map +1 -1
  580. package/dist/state/operations.d.ts +3 -0
  581. package/dist/state/operations.d.ts.map +1 -1
  582. package/dist/state/operations.js +144 -40
  583. package/dist/state/operations.js.map +1 -1
  584. package/dist/state/skill-active.d.ts +18 -1
  585. package/dist/state/skill-active.d.ts.map +1 -1
  586. package/dist/state/skill-active.js +420 -23
  587. package/dist/state/skill-active.js.map +1 -1
  588. package/dist/state/workflow-transition-reconcile.d.ts +3 -0
  589. package/dist/state/workflow-transition-reconcile.d.ts.map +1 -1
  590. package/dist/state/workflow-transition-reconcile.js +10 -4
  591. package/dist/state/workflow-transition-reconcile.js.map +1 -1
  592. package/dist/state/workflow-transition.d.ts.map +1 -1
  593. package/dist/state/workflow-transition.js +3 -1
  594. package/dist/state/workflow-transition.js.map +1 -1
  595. package/dist/subagents/__tests__/leader-bootstrap-3181.test.js +53 -247
  596. package/dist/subagents/__tests__/leader-bootstrap-3181.test.js.map +1 -1
  597. package/dist/subagents/__tests__/tracker.test.js +837 -1048
  598. package/dist/subagents/__tests__/tracker.test.js.map +1 -1
  599. package/dist/subagents/tracker.d.ts +58 -135
  600. package/dist/subagents/tracker.d.ts.map +1 -1
  601. package/dist/subagents/tracker.js +811 -632
  602. package/dist/subagents/tracker.js.map +1 -1
  603. package/dist/team/__tests__/api-interop.test.js +91 -1
  604. package/dist/team/__tests__/api-interop.test.js.map +1 -1
  605. package/dist/team/__tests__/delivery-e2e-smoke.test.js +58 -17
  606. package/dist/team/__tests__/delivery-e2e-smoke.test.js.map +1 -1
  607. package/dist/team/__tests__/idle-nudge.test.js +45 -15
  608. package/dist/team/__tests__/idle-nudge.test.js.map +1 -1
  609. package/dist/team/__tests__/mcp-comm.test.js +197 -30
  610. package/dist/team/__tests__/mcp-comm.test.js.map +1 -1
  611. package/dist/team/__tests__/model-contract.test.js +110 -3
  612. package/dist/team/__tests__/model-contract.test.js.map +1 -1
  613. package/dist/team/__tests__/notice-ledger.test.d.ts +2 -0
  614. package/dist/team/__tests__/notice-ledger.test.d.ts.map +1 -0
  615. package/dist/team/__tests__/notice-ledger.test.js +131 -0
  616. package/dist/team/__tests__/notice-ledger.test.js.map +1 -0
  617. package/dist/team/__tests__/pane-teardown-residue-e2e.test.d.ts +2 -0
  618. package/dist/team/__tests__/pane-teardown-residue-e2e.test.d.ts.map +1 -0
  619. package/dist/team/__tests__/pane-teardown-residue-e2e.test.js +410 -0
  620. package/dist/team/__tests__/pane-teardown-residue-e2e.test.js.map +1 -0
  621. package/dist/team/__tests__/phase-controller.test.js +20 -0
  622. package/dist/team/__tests__/phase-controller.test.js.map +1 -1
  623. package/dist/team/__tests__/runtime-cli.test.js +9 -0
  624. package/dist/team/__tests__/runtime-cli.test.js.map +1 -1
  625. package/dist/team/__tests__/runtime.test.js +3963 -428
  626. package/dist/team/__tests__/runtime.test.js.map +1 -1
  627. package/dist/team/__tests__/scaling.test.js +2405 -203
  628. package/dist/team/__tests__/scaling.test.js.map +1 -1
  629. package/dist/team/__tests__/state-root.test.js +117 -2
  630. package/dist/team/__tests__/state-root.test.js.map +1 -1
  631. package/dist/team/__tests__/state.test.js +804 -36
  632. package/dist/team/__tests__/state.test.js.map +1 -1
  633. package/dist/team/__tests__/team-ops-contract.test.js +6 -0
  634. package/dist/team/__tests__/team-ops-contract.test.js.map +1 -1
  635. package/dist/team/__tests__/tmux-session.test.js +4099 -1039
  636. package/dist/team/__tests__/tmux-session.test.js.map +1 -1
  637. package/dist/team/__tests__/tmux-source-authority-realtmux.test.d.ts +2 -0
  638. package/dist/team/__tests__/tmux-source-authority-realtmux.test.d.ts.map +1 -0
  639. package/dist/team/__tests__/tmux-source-authority-realtmux.test.js +149 -0
  640. package/dist/team/__tests__/tmux-source-authority-realtmux.test.js.map +1 -0
  641. package/dist/team/__tests__/tmux-test-fixture.d.ts +69 -0
  642. package/dist/team/__tests__/tmux-test-fixture.d.ts.map +1 -1
  643. package/dist/team/__tests__/tmux-test-fixture.js +242 -15
  644. package/dist/team/__tests__/tmux-test-fixture.js.map +1 -1
  645. package/dist/team/__tests__/tmux-test-fixture.test.js +177 -1
  646. package/dist/team/__tests__/tmux-test-fixture.test.js.map +1 -1
  647. package/dist/team/__tests__/worker-runtime-identity.test.js +53 -44
  648. package/dist/team/__tests__/worker-runtime-identity.test.js.map +1 -1
  649. package/dist/team/__tests__/worktree.test.js +6 -0
  650. package/dist/team/__tests__/worktree.test.js.map +1 -1
  651. package/dist/team/api-interop.d.ts.map +1 -1
  652. package/dist/team/api-interop.js +15 -9
  653. package/dist/team/api-interop.js.map +1 -1
  654. package/dist/team/exact-pane.d.ts +23 -0
  655. package/dist/team/exact-pane.d.ts.map +1 -0
  656. package/dist/team/exact-pane.js +133 -0
  657. package/dist/team/exact-pane.js.map +1 -0
  658. package/dist/team/idle-nudge.d.ts +12 -1
  659. package/dist/team/idle-nudge.d.ts.map +1 -1
  660. package/dist/team/idle-nudge.js +41 -14
  661. package/dist/team/idle-nudge.js.map +1 -1
  662. package/dist/team/model-contract.d.ts +2 -5
  663. package/dist/team/model-contract.d.ts.map +1 -1
  664. package/dist/team/model-contract.js +59 -17
  665. package/dist/team/model-contract.js.map +1 -1
  666. package/dist/team/notice-ledger.d.ts +54 -0
  667. package/dist/team/notice-ledger.d.ts.map +1 -0
  668. package/dist/team/notice-ledger.js +259 -0
  669. package/dist/team/notice-ledger.js.map +1 -0
  670. package/dist/team/phase-controller.d.ts.map +1 -1
  671. package/dist/team/phase-controller.js +2 -0
  672. package/dist/team/phase-controller.js.map +1 -1
  673. package/dist/team/runtime.d.ts +4 -1
  674. package/dist/team/runtime.d.ts.map +1 -1
  675. package/dist/team/runtime.js +1527 -291
  676. package/dist/team/runtime.js.map +1 -1
  677. package/dist/team/scaling.d.ts +13 -1
  678. package/dist/team/scaling.d.ts.map +1 -1
  679. package/dist/team/scaling.js +1503 -387
  680. package/dist/team/scaling.js.map +1 -1
  681. package/dist/team/state/dispatch.d.ts.map +1 -1
  682. package/dist/team/state/dispatch.js +26 -8
  683. package/dist/team/state/dispatch.js.map +1 -1
  684. package/dist/team/state/locks.d.ts.map +1 -1
  685. package/dist/team/state/locks.js +61 -13
  686. package/dist/team/state/locks.js.map +1 -1
  687. package/dist/team/state/monitor.d.ts +10 -0
  688. package/dist/team/state/monitor.d.ts.map +1 -1
  689. package/dist/team/state/monitor.js +13 -0
  690. package/dist/team/state/monitor.js.map +1 -1
  691. package/dist/team/state/tasks.d.ts.map +1 -1
  692. package/dist/team/state/tasks.js +4 -3
  693. package/dist/team/state/tasks.js.map +1 -1
  694. package/dist/team/state/types.d.ts +15 -0
  695. package/dist/team/state/types.d.ts.map +1 -1
  696. package/dist/team/state/types.js.map +1 -1
  697. package/dist/team/state-root.d.ts +12 -0
  698. package/dist/team/state-root.d.ts.map +1 -1
  699. package/dist/team/state-root.js +177 -0
  700. package/dist/team/state-root.js.map +1 -1
  701. package/dist/team/state.d.ts +85 -1
  702. package/dist/team/state.d.ts.map +1 -1
  703. package/dist/team/state.js +672 -129
  704. package/dist/team/state.js.map +1 -1
  705. package/dist/team/team-ops.d.ts +5 -0
  706. package/dist/team/team-ops.d.ts.map +1 -1
  707. package/dist/team/team-ops.js +4 -0
  708. package/dist/team/team-ops.js.map +1 -1
  709. package/dist/team/tmux-session.d.ts +163 -27
  710. package/dist/team/tmux-session.d.ts.map +1 -1
  711. package/dist/team/tmux-session.js +1682 -376
  712. package/dist/team/tmux-session.js.map +1 -1
  713. package/dist/team/worktree.d.ts.map +1 -1
  714. package/dist/team/worktree.js +4 -0
  715. package/dist/team/worktree.js.map +1 -1
  716. package/dist/ultragoal/__tests__/artifacts.test.js +950 -2
  717. package/dist/ultragoal/__tests__/artifacts.test.js.map +1 -1
  718. package/dist/ultragoal/__tests__/docs-contract.test.js +1 -0
  719. package/dist/ultragoal/__tests__/docs-contract.test.js.map +1 -1
  720. package/dist/ultragoal/artifacts.d.ts +35 -1
  721. package/dist/ultragoal/artifacts.d.ts.map +1 -1
  722. package/dist/ultragoal/artifacts.js +232 -46
  723. package/dist/ultragoal/artifacts.js.map +1 -1
  724. package/dist/utils/__tests__/file-durability.test.d.ts +2 -0
  725. package/dist/utils/__tests__/file-durability.test.d.ts.map +1 -0
  726. package/dist/utils/__tests__/file-durability.test.js +114 -0
  727. package/dist/utils/__tests__/file-durability.test.js.map +1 -0
  728. package/dist/utils/__tests__/paths.test.js +21 -1
  729. package/dist/utils/__tests__/paths.test.js.map +1 -1
  730. package/dist/utils/file-durability.d.ts +25 -0
  731. package/dist/utils/file-durability.d.ts.map +1 -0
  732. package/dist/utils/file-durability.js +67 -0
  733. package/dist/utils/file-durability.js.map +1 -0
  734. package/dist/utils/paths.d.ts.map +1 -1
  735. package/dist/utils/paths.js +14 -6
  736. package/dist/utils/paths.js.map +1 -1
  737. package/dist/verification/__tests__/ci-rust-gates.test.js +49 -4
  738. package/dist/verification/__tests__/ci-rust-gates.test.js.map +1 -1
  739. package/dist/verification/__tests__/dev-merge-issue-close-workflow.test.js +3 -0
  740. package/dist/verification/__tests__/dev-merge-issue-close-workflow.test.js.map +1 -1
  741. package/dist/verification/__tests__/explore-harness-release-workflow.test.js +60 -0
  742. package/dist/verification/__tests__/explore-harness-release-workflow.test.js.map +1 -1
  743. package/dist/verification/__tests__/native-release-manifest.test.js +157 -4
  744. package/dist/verification/__tests__/native-release-manifest.test.js.map +1 -1
  745. package/dist/wiki/lint.d.ts +4 -1
  746. package/dist/wiki/lint.d.ts.map +1 -1
  747. package/dist/wiki/lint.js +3 -2
  748. package/dist/wiki/lint.js.map +1 -1
  749. package/package.json +13 -5
  750. package/plugins/oh-my-codex/.codex-plugin/plugin.json +1 -1
  751. package/plugins/oh-my-codex/hooks/codex-native-hook.mjs +105 -17
  752. package/plugins/oh-my-codex/skills/autopilot/SKILL.md +13 -5
  753. package/plugins/oh-my-codex/skills/cancel/SKILL.md +30 -85
  754. package/plugins/oh-my-codex/skills/ralph/SKILL.md +6 -4
  755. package/plugins/oh-my-codex/skills/ralplan/SKILL.md +16 -17
  756. package/plugins/oh-my-codex/skills/team/SKILL.md +13 -9
  757. package/plugins/oh-my-codex/skills/ultragoal/SKILL.md +3 -2
  758. package/skills/autopilot/SKILL.md +13 -5
  759. package/skills/cancel/SKILL.md +30 -85
  760. package/skills/ralph/SKILL.md +6 -4
  761. package/skills/ralplan/SKILL.md +16 -17
  762. package/skills/team/SKILL.md +13 -9
  763. package/skills/ultragoal/SKILL.md +3 -2
  764. package/src/scripts/__tests__/codex-native-hook.test.ts +8831 -951
  765. package/src/scripts/__tests__/issue-3293-callsite-parity.test.ts +187 -0
  766. package/src/scripts/__tests__/issue-3293-cancel-transaction.test.ts +189 -0
  767. package/src/scripts/__tests__/issue-3293-hook-owned-cancel.test.ts +187 -0
  768. package/src/scripts/__tests__/issue-3311-ultragoal-native-outside-tmux.test.ts +591 -0
  769. package/src/scripts/__tests__/issue-3358-ultragoal-cancel-lifecycle.test.ts +701 -0
  770. package/src/scripts/__tests__/issue-3369-autopilot-ralplan-state-machine.test.ts +211 -0
  771. package/src/scripts/__tests__/notify-state-io.test.ts +29 -0
  772. package/src/scripts/__tests__/notify-tmux-injection.test.ts +26 -1
  773. package/src/scripts/__tests__/postinstall.test.ts +23 -1
  774. package/src/scripts/__tests__/reasoning-artifact-contract.test.ts +129 -0
  775. package/src/scripts/__tests__/role-intent-bootstrap-e2e-3181.test.ts +67 -185
  776. package/src/scripts/__tests__/role-intent-durable-recovery-3181.test.ts +79 -178
  777. package/src/scripts/__tests__/smoke-packed-install.test.ts +525 -16
  778. package/src/scripts/__tests__/verify-native-release-assets.test.ts +240 -0
  779. package/src/scripts/codex-native-hook.ts +14238 -1429
  780. package/src/scripts/codex-native-pre-post.ts +43 -12
  781. package/src/scripts/generate-native-release-manifest.ts +83 -79
  782. package/src/scripts/notify-fallback-watcher.ts +91 -122
  783. package/src/scripts/notify-hook/auto-nudge.ts +71 -3
  784. package/src/scripts/notify-hook/state-io.ts +11 -0
  785. package/src/scripts/notify-hook/team-dispatch.ts +242 -75
  786. package/src/scripts/notify-hook/team-leader-nudge.ts +72 -22
  787. package/src/scripts/notify-hook/team-tmux-guard.ts +366 -39
  788. package/src/scripts/notify-hook/team-worker-stop.ts +43 -20
  789. package/src/scripts/notify-hook/team-worker.ts +82 -31
  790. package/src/scripts/notify-hook/tmux-injection.ts +33 -3
  791. package/src/scripts/notify-hook.ts +84 -82
  792. package/src/scripts/postinstall-advisory.ts +67 -0
  793. package/src/scripts/postinstall.ts +1 -1
  794. package/src/scripts/smoke-packed-install.ts +3608 -39
  795. package/src/scripts/sync-plugin-mirror.ts +17 -1
  796. package/src/scripts/verify-native-release-assets.ts +39 -31
  797. package/templates/AGENTS.md +1 -0
  798. package/dist/subagents/__tests__/adapted-role-binding.test.d.ts +0 -2
  799. package/dist/subagents/__tests__/adapted-role-binding.test.d.ts.map +0 -1
  800. package/dist/subagents/__tests__/adapted-role-binding.test.js +0 -988
  801. package/dist/subagents/__tests__/adapted-role-binding.test.js.map +0 -1
  802. package/dist/subagents/adapted-role-binding.d.ts +0 -17
  803. package/dist/subagents/adapted-role-binding.d.ts.map +0 -1
  804. package/dist/subagents/adapted-role-binding.js +0 -75
  805. package/dist/subagents/adapted-role-binding.js.map +0 -1
@@ -2,17 +2,19 @@ import { describe, it } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import { createHash } from 'node:crypto';
4
4
  import { execFileSync } from 'node:child_process';
5
- import { mkdtemp, rm, readFile, writeFile, mkdir, chmod, readdir } from 'fs/promises';
6
- import { join, relative } from 'path';
7
- import { tmpdir } from 'os';
8
- import { existsSync, readFileSync } from 'fs';
9
- import { initTeamState, createTask, readTask, readTeamConfig, saveTeamConfig, readWorkerStatus, writeWorkerStatus, withScalingLock, DEFAULT_MAX_WORKERS, } from '../state.js';
10
- import { isScalingEnabled, scaleUp, scaleDown } from '../scaling.js';
5
+ import { mkdtemp, rm, readFile, writeFile, mkdir, chmod, readdir, symlink } from 'fs/promises';
6
+ import { join, posix, relative, win32 } from 'path';
7
+ import { tmpdir as osTmpdir } from 'os';
8
+ import { existsSync, readFileSync, realpathSync } from 'fs';
9
+ import { initTeamState, createTask, claimTask, readTask, readTeamConfig, saveTeamConfig, readWorkerStatus, writeWorkerStatus, withScalingLock, recoverTeamMembershipTaskTransaction, commitTeamMembershipTaskTransaction, withTeamTaskBarrier, DEFAULT_MAX_WORKERS, listDispatchRequests, readTeamManifestV2, } from '../state.js';
10
+ import { isSameOrInsidePath, isScalingEnabled, reconcileScaleDownCleanupDebt, scaleUp, scaleDown } from '../scaling.js';
11
11
  import { resolveCanonicalTeamStateRoot } from '../state-root.js';
12
12
  import { resolvePersistedApprovedTeamExecutionContinuityState, writePersistedApprovedTeamExecutionBinding, } from '../approved-execution.js';
13
13
  import { TEAM_WORKER_INHERITED_MODEL_ENV } from '../model-contract.js';
14
14
  import { buildWorkerProcessLaunchSpec } from '../tmux-session.js';
15
+ const tmpdir = () => realpathSync(osTmpdir());
15
16
  delete process.env.OMX_TEAM_STATE_ROOT;
17
+ process.env.OMX_RUNTIME_BRIDGE = '0';
16
18
  async function initCommittedGitRepo(cwd) {
17
19
  execFileSync('git', ['init'], { cwd, stdio: 'pipe' });
18
20
  execFileSync('git', ['config', 'user.name', 'OMX Test'], { cwd, stdio: 'pipe' });
@@ -35,6 +37,11 @@ function computeGitBlobSha1(content) {
35
37
  const header = Buffer.from(`blob ${buffer.length}\0`, 'utf-8');
36
38
  return createHash('sha1').update(header).update(buffer).digest('hex');
37
39
  }
40
+ function withoutConfigGeneration(bytes) {
41
+ const value = JSON.parse(bytes);
42
+ delete value.config_generation;
43
+ return value;
44
+ }
38
45
  function canonicalContextPackRelativePath(slug) {
39
46
  return `.omx/context/context-20260507T120000Z-${slug}.json`;
40
47
  }
@@ -140,7 +147,7 @@ async function writeContextPack(cwd, slug, prdPath, testSpecPath, roles) {
140
147
  async function writeReadyContextPack(cwd, slug, prdPath, testSpecPath) {
141
148
  await writeContextPack(cwd, slug, prdPath, testSpecPath, ['scope', 'build', 'verify']);
142
149
  }
143
- async function writeSuccessfulScaleUpTmuxStub(fakeBinDir, tmuxLogPath) {
150
+ async function writeSuccessfulScaleUpTmuxStub(fakeBinDir, tmuxLogPath, failGuardedSendKeys = false) {
144
151
  const tmuxStubPath = join(fakeBinDir, 'tmux');
145
152
  await writeFile(tmuxStubPath, [
146
153
  '#!/bin/sh',
@@ -150,21 +157,78 @@ async function writeSuccessfulScaleUpTmuxStub(fakeBinDir, tmuxLogPath) {
150
157
  ' -V)',
151
158
  ' echo "tmux 3.2a"',
152
159
  ' ;;',
160
+ ' display-message)',
161
+ ' case "$*" in',
162
+ ' *"-t %32 "*) echo "%32\\t42425\\tomx-team-scale-up\\t\\$1\\t@1\\tteam:scale-up" ;;',
163
+ ' *"-t %33 "*) echo "%33\\t42426\\tomx-team-scale-up\\t\\$1\\t@1\\tteam:scale-up" ;;',
164
+ ' *"-t %31 "*) echo "%31\\t42424\\tomx-team-scale-up\\t\\$1\\t@1\\tteam:scale-up" ;;',
165
+ ' *"-t %21 "*) echo "%21\\t42421\\tomx-team-scale-up\\t\\$1\\t@1\\tteam:scale-up" ;;',
166
+ ' *) echo "%11\\t42411\\tomx-team-scale-up\\t\\$1\\t@1\\tteam:scale-up" ;;',
167
+ ' esac',
168
+ ' ;;',
169
+ ' if-shell)',
170
+ ' case "${2:-}" in',
171
+ ' -F)',
172
+ ' if [ "${3:-}" = "-t" ]; then',
173
+ ' target="${4:-}"',
174
+ ' condition="${5:-}"',
175
+ ' success="${6:-}"',
176
+ ' else',
177
+ ' target=""',
178
+ ' condition="${3:-}"',
179
+ ' success="${4:-}"',
180
+ ' fi',
181
+ ' ;;',
182
+ ' *) exit 1 ;;',
183
+ ' esac',
184
+ ' case "$target:$condition" in',
185
+ ' %21:*) ;;',
186
+ ' %31:*) ;;',
187
+ ' %32:*) ;;',
188
+ ' %33:*) ;;',
189
+ ' *) exit 1 ;;',
190
+ ' esac',
191
+ " receipt=\"$(printf '%s' \"$success\" | sed -En 's/.*(omx-scale-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}).*/\\1/p')\"",
192
+ ' [ -n "$receipt" ] || exit 1',
193
+ ' case "$success" in',
194
+ ` *split-window*) case "$target" in %21) : > "${join(fakeBinDir, 'created-%31')}"; printf '%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' '%31' '42424' 'omx-team-scale-up' '$1' '@1' "$receipt" ;; %31) : > "${join(fakeBinDir, 'created-%32')}"; printf '%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' '%32' '42425' 'omx-team-scale-up' '$1' '@1' "$receipt" ;; %32) : > "${join(fakeBinDir, 'created-%33')}"; printf '%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' '%33' '42426' 'omx-team-scale-up' '$1' '@1' "$receipt" ;; *) exit 1 ;; esac ;;`,
195
+ ...(failGuardedSendKeys ? [' *send-keys*) exit 1 ;;'] : []),
196
+ ' *"\'set-option\'"*|*"\'send-keys\'"*|*"\'kill-pane\'"*|*set-option*|*send-keys*|*kill-pane*) printf "%s\\n" "$receipt" ;;',
197
+ ' *) exit 1 ;;',
198
+ ' esac',
199
+ ' ;;',
153
200
  ' split-window)',
154
- ' echo "%31"',
201
+ ' case "$*" in',
202
+ ` *"-t %21 "*) : > "${join(fakeBinDir, 'created-%31')}"; echo "%31\\t42424\\tomx-team-scale-up\\t\\$1\\t@1" ;;`,
203
+ ` *"-t %31 "*) : > "${join(fakeBinDir, 'created-%32')}"; echo "%32\\t42425\\tomx-team-scale-up\\t\\$1\\t@1" ;;`,
204
+ ` *"-t %32 "*) : > "${join(fakeBinDir, 'created-%33')}"; echo "%33\\t42426\\tomx-team-scale-up\\t\\$1\\t@1" ;;`,
205
+ ' *) exit 1 ;;',
206
+ ' esac',
155
207
  ' ;;',
156
208
  ' list-panes)',
157
- ' echo "42424"',
209
+ " printf '%s\\t%s\\t%s\\n' '%11' '0' '42411'",
210
+ " printf '%s\\t%s\\t%s\\n' '%21' '0' '42421'",
211
+ ` if [ -f "${join(fakeBinDir, 'created-%31')}" ] && [ ! -f "${join(fakeBinDir, 'killed-%31')}" ]; then printf '%s\\t%s\\t%s\\n' '%31' '0' '42424'; fi`,
212
+ ` if [ -f "${join(fakeBinDir, 'created-%32')}" ] && [ ! -f "${join(fakeBinDir, 'killed-%32')}" ]; then printf '%s\\t%s\\t%s\\n' '%32' '0' '42425'; fi`,
213
+ ` if [ -f "${join(fakeBinDir, 'created-%33')}" ] && [ ! -f "${join(fakeBinDir, 'killed-%33')}" ]; then printf '%s\\t%s\\t%s\\n' '%33' '0' '42426'; fi`,
158
214
  ' ;;',
159
- ' send-keys)',
215
+ ' show-option)',
216
+ ' case "$*" in',
217
+ ' *"@omx_team_pane_owner_id"*) echo "team:scale-up" ;;',
218
+ ' *) exit 1 ;;',
219
+ ' esac',
160
220
  ' ;;',
161
- ' capture-pane)',
162
- ' echo ""',
221
+ ' set-option) ;;',
222
+ ' send-keys) ;;',
223
+ ' kill-pane)',
224
+ ` : > "${join(fakeBinDir, 'killed-')}$3"`,
163
225
  ' ;;',
226
+ ' capture-pane) echo "" ;;',
164
227
  'esac',
165
228
  'exit 0',
166
229
  '',
167
230
  ].join('\n'));
231
+ execFileSync('sh', ['-n', tmuxStubPath], { stdio: 'pipe' });
168
232
  await chmod(tmuxStubPath, 0o755);
169
233
  await writeFile(tmuxLogPath, '');
170
234
  }
@@ -174,9 +238,13 @@ async function configureScaleUpTeamForDirectDispatch(teamName, cwd) {
174
238
  if (!config) {
175
239
  throw new Error(`missing team config for ${teamName}`);
176
240
  }
177
- config.tmux_session = `omx-team-${teamName}`;
241
+ config.tmux_session = 'omx-team-scale-up';
242
+ config.tmux_pane_owner_id = 'team:scale-up';
178
243
  config.leader_pane_id = '%11';
244
+ config.tmux_pane_owner_id = 'team:scale-up';
245
+ config.leader_pane_pid = 42411;
179
246
  config.workers[0].pane_id = '%21';
247
+ config.workers[0].pid = 42421;
180
248
  await saveTeamConfig(config, cwd);
181
249
  const manifestPath = join(cwd, '.omx', 'state', 'team', teamName, 'manifest.v2.json');
182
250
  if (!existsSync(manifestPath)) {
@@ -195,6 +263,15 @@ async function readScaleUpTmuxLogCommands(tmuxLogPath) {
195
263
  const trimmed = content.trim();
196
264
  return trimmed === '' ? [] : trimmed.split('\n');
197
265
  }
266
+ function isGuardedScaleMutation(command, mutation, target) {
267
+ return command.startsWith('if-shell -F -t ')
268
+ && command.includes(mutation)
269
+ && (target === undefined || command.includes(`-t '${target}'`) || command.includes(`-t ${target}`))
270
+ && /omx-scale-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/.test(command);
271
+ }
272
+ function guardedScaleSplitCount(commands) {
273
+ return commands.filter((command) => isGuardedScaleMutation(command, 'split-window')).length;
274
+ }
198
275
  async function readScaleUpTaskPayloads(teamName, cwd) {
199
276
  const tasksDir = join(cwd, '.omx', 'state', 'team', teamName, 'tasks');
200
277
  if (!existsSync(tasksDir)) {
@@ -317,6 +394,60 @@ describe('isScalingEnabled', () => {
317
394
  assert.equal(isScalingEnabled({ OMX_TEAM_SCALING_ENABLED: ' true ' }), true);
318
395
  });
319
396
  });
397
+ // ── scale-down cleanup debt path containment ──────────────────────────────────
398
+ describe('scale-down cleanup debt path containment', () => {
399
+ const windowsPathSemantics = {
400
+ relative: win32.relative,
401
+ isAbsolute: win32.isAbsolute,
402
+ sep: win32.sep,
403
+ };
404
+ it('accepts same-root and descendant native Windows canonical paths', () => {
405
+ assert.equal(isSameOrInsidePath('C:\\repo', 'C:\\repo', windowsPathSemantics), true);
406
+ assert.equal(isSameOrInsidePath('C:\\repo\\.omx\\team\\alpha', 'C:\\repo', windowsPathSemantics), true);
407
+ });
408
+ it('accepts mixed-separator, drive-letter, and case-equivalent Windows descendants', () => {
409
+ assert.equal(isSameOrInsidePath('c:/REPO/.omx\\team/alpha', 'C:\\repo', windowsPathSemantics), true);
410
+ });
411
+ it('accepts same-share UNC descendants across host and share casing', () => {
412
+ assert.equal(isSameOrInsidePath('\\\\SERVER\\Share\\repo/.omx\\team\\alpha', '\\\\server\\share\\REPO', windowsPathSemantics), true);
413
+ });
414
+ for (const [name, candidate] of [
415
+ ['a sibling prefix collision', 'C:\\repo-other\\worker'],
416
+ ['a parent traversal escape', 'C:\\repo\\..\\foreign\\worker'],
417
+ ['a foreign same-volume root', 'C:\\foreign\\worker'],
418
+ ['an absolute relative result from a foreign drive', 'D:\\foreign\\worker'],
419
+ ['a foreign UNC host', '\\\\foreign-server\\share\\repo\\worker'],
420
+ ['a foreign UNC share', '\\\\server\\foreign-share\\repo\\worker'],
421
+ ['a UNC sibling prefix collision', '\\\\server\\share\\repo-other\\worker'],
422
+ ]) {
423
+ it(`rejects ${name} under native Windows path semantics`, () => {
424
+ assert.equal(isSameOrInsidePath(candidate, 'C:\\repo', windowsPathSemantics), false);
425
+ });
426
+ }
427
+ for (const [name, candidate] of [
428
+ ['a foreign UNC host', '\\\\foreign-server\\share\\repo\\worker'],
429
+ ['a foreign UNC share', '\\\\server\\foreign-share\\repo\\worker'],
430
+ ['a UNC sibling prefix collision', '\\\\server\\share\\repo-other\\worker'],
431
+ ]) {
432
+ it(`rejects ${name} under native Windows UNC path semantics`, () => {
433
+ assert.equal(isSameOrInsidePath(candidate, '\\\\server\\share\\repo', windowsPathSemantics), false);
434
+ });
435
+ }
436
+ const posixPathSemantics = {
437
+ relative: posix.relative,
438
+ isAbsolute: posix.isAbsolute,
439
+ sep: posix.sep,
440
+ };
441
+ it('preserves POSIX same-root and descendant containment', () => {
442
+ assert.equal(isSameOrInsidePath('/repo', '/repo', posixPathSemantics), true);
443
+ assert.equal(isSameOrInsidePath('/repo/.omx/team/alpha', '/repo', posixPathSemantics), true);
444
+ });
445
+ it('rejects POSIX siblings, traversal, and foreign roots', () => {
446
+ assert.equal(isSameOrInsidePath('/repo-other/worker', '/repo', posixPathSemantics), false);
447
+ assert.equal(isSameOrInsidePath('/repo/../foreign/worker', '/repo', posixPathSemantics), false);
448
+ assert.equal(isSameOrInsidePath('/foreign/worker', '/repo', posixPathSemantics), false);
449
+ });
450
+ });
320
451
  // ── WorkerStatus draining state ───────────────────────────────────────────────
321
452
  describe('WorkerStatus draining state', () => {
322
453
  it('writeWorkerStatus writes draining status and readWorkerStatus reads it back', async () => {
@@ -394,7 +525,7 @@ describe('withScalingLock', () => {
394
525
  const cwd = await mkdtemp(join(tmpdir(), 'omx-scale-lock-'));
395
526
  try {
396
527
  await initTeamState('lock-test', 'task', 'executor', 1, cwd);
397
- const lockDir = join(cwd, '.omx', 'state', 'team', 'lock-test', '.lock.scaling');
528
+ const lockDir = join(cwd, '.omx', 'state', '.team-locks', 'lock-test.scaling');
398
529
  const result = await withScalingLock('lock-test', cwd, async () => {
399
530
  // Lock should exist during execution
400
531
  assert.equal(existsSync(lockDir), true);
@@ -412,7 +543,7 @@ describe('withScalingLock', () => {
412
543
  const cwd = await mkdtemp(join(tmpdir(), 'omx-scale-lock-err-'));
413
544
  try {
414
545
  await initTeamState('lock-err', 'task', 'executor', 1, cwd);
415
- const lockDir = join(cwd, '.omx', 'state', 'team', 'lock-err', '.lock.scaling');
546
+ const lockDir = join(cwd, '.omx', 'state', '.team-locks', 'lock-err.scaling');
416
547
  await assert.rejects(withScalingLock('lock-err', cwd, async () => {
417
548
  throw new Error('test error');
418
549
  }), { message: 'test error' });
@@ -581,34 +712,9 @@ exit 0
581
712
  const cwd = await mkdtemp(join(tmpdir(), 'omx-scale-up-role-'));
582
713
  const fakeBinDir = await mkdtemp(join(tmpdir(), 'omx-scale-up-role-bin-'));
583
714
  const tmuxLogPath = join(fakeBinDir, 'tmux.log');
584
- const tmuxStubPath = join(fakeBinDir, 'tmux');
585
715
  const previousPath = process.env.PATH;
586
716
  try {
587
- await writeFile(tmuxStubPath, [
588
- '#!/bin/sh',
589
- 'set -eu',
590
- `printf '\%s\n' "$*" >> "${tmuxLogPath}"`,
591
- 'case "${1:-}" in',
592
- ' -V)',
593
- ' echo "tmux 3.2a"',
594
- ' ;;',
595
- ' split-window)',
596
- ' echo "%31"',
597
- ' ;;',
598
- ' list-panes)',
599
- ' echo "42424"',
600
- ' ;;',
601
- ' send-keys)',
602
- ' ;;',
603
- ' capture-pane)',
604
- ' echo ""',
605
- ' ;;',
606
- 'esac',
607
- 'exit 0',
608
- '',
609
- ].join('\n'));
610
- await chmod(tmuxStubPath, 0o755);
611
- await writeFile(tmuxLogPath, '');
717
+ await writeSuccessfulScaleUpTmuxStub(fakeBinDir, tmuxLogPath);
612
718
  process.env.PATH = `${fakeBinDir}:${previousPath ?? ''}`;
613
719
  await mkdir(join(cwd, '.codex', 'prompts'), { recursive: true });
614
720
  await writeFile(join(cwd, '.codex', 'prompts', 'writer.md'), '<identity>You are Writer.</identity>');
@@ -625,9 +731,11 @@ exit 0
625
731
  assert.ok(config);
626
732
  if (!config)
627
733
  return;
628
- config.tmux_session = 'omx-team-scale-up-role';
734
+ config.tmux_session = 'omx-team-scale-up';
629
735
  config.leader_pane_id = '%11';
736
+ config.tmux_pane_owner_id = 'team:scale-up';
630
737
  config.workers[0].pane_id = '%21';
738
+ config.workers[0].pid = 42421;
631
739
  await saveTeamConfig(config, cwd);
632
740
  const manifestPath = join(cwd, '.omx', 'state', 'team', 'scale-up-role', 'manifest.v2.json');
633
741
  const manifest = JSON.parse(await readFile(manifestPath, 'utf-8'));
@@ -637,7 +745,7 @@ exit 0
637
745
  };
638
746
  await writeFile(manifestPath, JSON.stringify(manifest, null, 2));
639
747
  const result = await scaleUp('scale-up-role', 1, 'executor', [{ subject: 'document routing report only', description: 'document routing report only', owner: 'worker-2', role: 'writer' }], cwd, { OMX_TEAM_SCALING_ENABLED: '1', OMX_TEAM_SKIP_READY_WAIT: '1' });
640
- assert.equal(result.ok, true);
748
+ assert.equal(result.ok, true, result.ok ? 'ok' : result.error);
641
749
  if (!result.ok)
642
750
  return;
643
751
  const createdTask = await readTask('scale-up-role', '2', cwd);
@@ -649,7 +757,8 @@ exit 0
649
757
  assert.match(inbox, /Task 2/);
650
758
  assert.match(inbox, /Role: writer/);
651
759
  const tmuxCommands = await readScaleUpTmuxLogCommands(tmuxLogPath);
652
- assert.ok(tmuxCommands.some((command) => (command === 'set-option -p -t %31 @omx_team_pane_owner_id team:scale-up-role')));
760
+ assert.ok(tmuxCommands.some((command) => (isGuardedScaleMutation(command, 'set-option', '%31')
761
+ && command.includes('@omx_team_pane_owner_id'))));
653
762
  }
654
763
  finally {
655
764
  if (typeof previousPath === 'string')
@@ -751,23 +860,44 @@ printf '%s\\n' "$@" > '${capturePath}'
751
860
  await rm(fakeBinDir, { recursive: true, force: true });
752
861
  }
753
862
  });
754
- it('rolls back a scaled worker pane when team owner tagging fails', async () => {
863
+ it('persists a newly created worker when rollback pane proof is unavailable', async () => {
755
864
  const cwd = await mkdtemp(join(tmpdir(), 'omx-scale-up-owner-tag-rollback-'));
756
865
  const fakeBinDir = await mkdtemp(join(tmpdir(), 'omx-scale-up-owner-tag-rollback-bin-'));
757
866
  const tmuxLogPath = join(fakeBinDir, 'tmux.log');
758
867
  const tmuxStubPath = join(fakeBinDir, 'tmux');
759
868
  const previousPath = process.env.PATH;
869
+ const previousBridge = process.env.OMX_RUNTIME_BRIDGE;
760
870
  try {
761
871
  await writeFile(tmuxStubPath, [
762
872
  '#!/bin/sh',
763
873
  'set -eu',
764
874
  `printf '%s\n' "$*" >> "${tmuxLogPath}"`,
765
875
  'case "${1:-}" in',
876
+ ' show-option) echo "team:scale-up-owner-tag-rollback" ;;',
766
877
  ' -V)',
767
878
  ' echo "tmux 3.2a"',
768
879
  ' ;;',
880
+ ' display-message)',
881
+ ' case "$*" in',
882
+ ' *"-t %31 "*) echo "%31\\t42424\\tomx-team-scale-up\\t\\$1\\t@1\\tteam:scale-up-owner-tag-rollback" ;;',
883
+ ' *"-t %21 "*) echo "%21\\t42421\\tomx-team-scale-up\\t\\$1\\t@1\\tteam:scale-up-owner-tag-rollback" ;;',
884
+ ' *) exit 1 ;;',
885
+ ' esac',
886
+ ' ;;',
887
+ ' if-shell)',
888
+ ' [ "${2:-}" = "-F" ] && [ "${3:-}" = "-t" ] || exit 1',
889
+ ' success="${6:-}"',
890
+ " receipt=\"$(printf '%s' \"$success\" | sed -En 's/.*(omx-scale-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}).*/\\1/p')\"",
891
+ ' [ -n "$receipt" ] || exit 1',
892
+ ' case "$success" in',
893
+ ' *split-window*) printf "%s\\t%s\\n" "%31\\t42424\\tomx-team-scale-up\\t\\$1\\t@1" "$receipt" ;;',
894
+ ' *set-option*) exit 1 ;;',
895
+ ' *kill-pane*) printf "%s\\n" "$receipt" ;;',
896
+ ' *) exit 1 ;;',
897
+ ' esac',
898
+ ' ;;',
769
899
  ' split-window)',
770
- ' echo "%31"',
900
+ ' echo "%31\\t42424\\tomx-team-scale-up\\t\\$1\\t@1"',
771
901
  ' ;;',
772
902
  ' set-option)',
773
903
  ' case "$*" in',
@@ -778,7 +908,18 @@ printf '%s\\n' "$@" > '${capturePath}'
778
908
  ' esac',
779
909
  ' ;;',
780
910
  ' list-panes)',
781
- ' echo "42424"',
911
+ ` count_file="${join(fakeBinDir, 'proof-count')}"`,
912
+ ' count=0',
913
+ ' if [ -f "$count_file" ]; then count=$(cat "$count_file"); fi',
914
+ ' count=$((count + 1))',
915
+ ' printf "%s" "$count" > "$count_file"',
916
+ ' if [ "$count" -le 5 ]; then',
917
+ " printf '%s\\t%s\\t%s\\n' '%11' '0' '42411'",
918
+ " printf '%s\\t%s\\t%s\\n' '%21' '0' '42421'",
919
+ " printf '%s\\t%s\\t%s\\n' '%31' '0' '42424'",
920
+ ' else',
921
+ " printf 'malformed pane snapshot\\n'",
922
+ ' fi',
782
923
  ' ;;',
783
924
  ' kill-pane|send-keys|capture-pane)',
784
925
  ' ;;',
@@ -787,21 +928,167 @@ printf '%s\\n' "$@" > '${capturePath}'
787
928
  '',
788
929
  ].join('\n'));
789
930
  await chmod(tmuxStubPath, 0o755);
931
+ execFileSync('sh', ['-n', tmuxStubPath], { stdio: 'pipe' });
790
932
  await writeFile(tmuxLogPath, '');
791
933
  process.env.PATH = `${fakeBinDir}:${previousPath ?? ''}`;
934
+ process.env.OMX_RUNTIME_BRIDGE = '0';
792
935
  await initTeamState('scale-up-owner-tag-rollback', 'task', 'executor', 1, cwd);
793
936
  await configureScaleUpTeamForDirectDispatch('scale-up-owner-tag-rollback', cwd);
937
+ const ownerConfig = await readTeamConfig('scale-up-owner-tag-rollback', cwd);
938
+ assert.ok(ownerConfig);
939
+ if (!ownerConfig)
940
+ return;
941
+ ownerConfig.tmux_pane_owner_id = 'team:scale-up-owner-tag-rollback';
942
+ await saveTeamConfig(ownerConfig, cwd);
794
943
  const result = await scaleUp('scale-up-owner-tag-rollback', 1, 'executor', [{ subject: 'new work', description: 'new work', owner: 'worker-2' }], cwd, { OMX_TEAM_SCALING_ENABLED: '1', OMX_TEAM_SKIP_READY_WAIT: '1' });
795
944
  assert.equal(result.ok, false);
796
945
  if (result.ok)
797
946
  return;
798
- assert.match(result.error, /Failed to tag tmux pane for worker-2/);
947
+ assert.equal(result.error, 'scale_up_split_target_authority_lost:%21');
799
948
  const config = await readTeamConfig('scale-up-owner-tag-rollback', cwd);
800
- assert.equal(config?.workers.length, 1);
949
+ assert.deepEqual(config?.workers.map((worker) => worker.name), ['worker-1']);
801
950
  assert.equal(await readTask('scale-up-owner-tag-rollback', '1', cwd), null);
802
951
  const tmuxCommands = await readScaleUpTmuxLogCommands(tmuxLogPath);
803
- assert.ok(tmuxCommands.some((command) => (command === 'set-option -p -t %31 @omx_team_pane_owner_id team:scale-up-owner-tag-rollback')));
804
- assert.ok(tmuxCommands.some((command) => command === 'kill-pane -t %31'));
952
+ assert.equal(tmuxCommands.some((command) => command === 'kill-pane -t %31'), false);
953
+ }
954
+ finally {
955
+ if (typeof previousPath === 'string')
956
+ process.env.PATH = previousPath;
957
+ else
958
+ delete process.env.PATH;
959
+ if (typeof previousBridge === 'string')
960
+ process.env.OMX_RUNTIME_BRIDGE = previousBridge;
961
+ else
962
+ delete process.env.OMX_RUNTIME_BRIDGE;
963
+ await rm(cwd, { recursive: true, force: true });
964
+ await rm(fakeBinDir, { recursive: true, force: true });
965
+ }
966
+ });
967
+ it('retains unpinned rollback debt without killing a pane when its first post-split proof is unavailable', async () => {
968
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-scale-up-unpinned-rollback-'));
969
+ const fakeBinDir = await mkdtemp(join(tmpdir(), 'omx-scale-up-unpinned-rollback-bin-'));
970
+ const tmuxLogPath = join(fakeBinDir, 'tmux.log');
971
+ const splitPath = join(fakeBinDir, 'split-created');
972
+ const tmuxStubPath = join(fakeBinDir, 'tmux');
973
+ const previousPath = process.env.PATH;
974
+ try {
975
+ await writeFile(tmuxStubPath, [
976
+ '#!/bin/sh',
977
+ 'set -eu',
978
+ `printf '%s\\n' "$*" >> "${tmuxLogPath}"`,
979
+ 'case "${1:-}" in',
980
+ ' -V) echo "tmux 3.2a" ;;',
981
+ ' show-option) echo "team:scale-up" ;;',
982
+ ' display-message) echo "%21\\t42421\\tomx-team-scale-up\\t\\$1\\t@1\\tteam:scale-up" ;;',
983
+ ' split-window) : > "' + splitPath + '"; echo "%31" ;;',
984
+ ' list-panes)',
985
+ ' if [ -f "' + splitPath + '" ]; then printf "malformed pane snapshot\\n"; else',
986
+ " printf '%s\\t%s\\t%s\\n' '%11' '0' '42411'",
987
+ " printf '%s\\t%s\\t%s\\n' '%21' '0' '42421'",
988
+ ' fi',
989
+ ' ;;',
990
+ 'esac',
991
+ 'exit 0',
992
+ '',
993
+ ].join('\n'));
994
+ await chmod(tmuxStubPath, 0o755);
995
+ await writeFile(tmuxLogPath, '');
996
+ process.env.PATH = `${fakeBinDir}:${previousPath ?? ''}`;
997
+ const teamName = 'scale-up-unpinned-rollback';
998
+ await initTeamState(teamName, 'task', 'executor', 1, cwd);
999
+ await configureScaleUpTeamForDirectDispatch(teamName, cwd);
1000
+ const result = await scaleUp(teamName, 1, 'executor', [], cwd, {
1001
+ OMX_TEAM_SCALING_ENABLED: '1', OMX_TEAM_SKIP_READY_WAIT: '1',
1002
+ });
1003
+ assert.equal(result.ok, false);
1004
+ if (result.ok)
1005
+ return;
1006
+ assert.match(result.error, /scale_up_split_target_authority_lost:%21/);
1007
+ const config = await readTeamConfig(teamName, cwd);
1008
+ assert.equal(config?.workers.length, 1);
1009
+ const tmuxCommands = await readScaleUpTmuxLogCommands(tmuxLogPath);
1010
+ assert.equal(tmuxCommands.some((command) => command === 'kill-pane -t %31'), false);
1011
+ }
1012
+ finally {
1013
+ if (typeof previousPath === 'string')
1014
+ process.env.PATH = previousPath;
1015
+ else
1016
+ delete process.env.PATH;
1017
+ await rm(cwd, { recursive: true, force: true });
1018
+ await rm(fakeBinDir, { recursive: true, force: true });
1019
+ }
1020
+ });
1021
+ it('retains the original pane/PID rollback debt when the new pane ID is reused before owner tagging', async () => {
1022
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-scale-up-owner-tag-reuse-'));
1023
+ const fakeBinDir = await mkdtemp(join(tmpdir(), 'omx-scale-up-owner-tag-reuse-bin-'));
1024
+ const tmuxLogPath = join(fakeBinDir, 'tmux.log');
1025
+ const tmuxStubPath = join(fakeBinDir, 'tmux');
1026
+ const previousPath = process.env.PATH;
1027
+ try {
1028
+ await writeFile(tmuxStubPath, [
1029
+ '#!/bin/sh',
1030
+ 'set -eu',
1031
+ `printf '%s\\n' "$*" >> "${tmuxLogPath}"`,
1032
+ 'case "${1:-}" in',
1033
+ ' show-option) echo "team:scale-up" ;;',
1034
+ ' display-message)',
1035
+ ' case "$*" in',
1036
+ ' *"-t %31 "*) echo "%31\\t42424\\tomx-team-scale-up\\t\\$1\\t@1\\tteam:scale-up" ;;',
1037
+ ' *"-t %21 "*) echo "%21\\t42421\\tomx-team-scale-up\\t\\$1\\t@1\\tteam:scale-up" ;;',
1038
+ ' *) exit 1 ;;',
1039
+ ' esac',
1040
+ ' ;;',
1041
+ ' if-shell)',
1042
+ ' [ "${2:-}" = "-F" ] && [ "${3:-}" = "-t" ] || exit 1',
1043
+ ' success="${6:-}"',
1044
+ " receipt=\"$(printf '%s' \"$success\" | sed -En 's/.*(omx-scale-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}).*/\\1/p')\"",
1045
+ ' [ -n "$receipt" ] || exit 1',
1046
+ ' case "$success" in',
1047
+ ' *set-option*) printf "recycled\\n" ;;',
1048
+ ' *split-window*) printf "%s\\t%s\\n" "%31\\t42424\\tomx-team-scale-up\\t\\$1\\t@1" "$receipt" ;;',
1049
+ ' *kill-pane*) printf "%s\\n" "$receipt" ;;',
1050
+ ' *) exit 1 ;;',
1051
+ ' esac',
1052
+ ' ;;',
1053
+ ' -V) echo "tmux 3.2a" ;;',
1054
+ ' split-window) echo "%31\\t42424\\tomx-team-scale-up\\t\\$1\\t@1" ;;',
1055
+ ' list-panes)',
1056
+ ` count_file="${join(fakeBinDir, 'proof-count')}"`,
1057
+ ' count=0; [ ! -f "$count_file" ] || count=$(cat "$count_file")',
1058
+ ' count=$((count + 1)); printf "%s" "$count" > "$count_file"',
1059
+ ' if [ "$count" -le 4 ]; then',
1060
+ " printf '%s\\t%s\\t%s\\n' '%21' '0' '42421'",
1061
+ " printf '%s\\t%s\\t%s\\n' '%31' '0' '42424'",
1062
+ ' elif [ "$count" -eq 5 ]; then',
1063
+ " printf '%s\\t%s\\t%s\\n' '%21' '0' '42421'",
1064
+ " printf '%s\\t%s\\t%s\\n' '%31' '0' '52424'",
1065
+ ' else',
1066
+ " printf 'malformed pane snapshot\\n'",
1067
+ ' fi',
1068
+ ' ;;',
1069
+ ' kill-pane|send-keys|capture-pane) ;;',
1070
+ 'esac',
1071
+ 'exit 0',
1072
+ '',
1073
+ ].join('\n'));
1074
+ await chmod(tmuxStubPath, 0o755);
1075
+ execFileSync('sh', ['-n', tmuxStubPath], { stdio: 'pipe' });
1076
+ await writeFile(tmuxLogPath, '');
1077
+ process.env.PATH = `${fakeBinDir}:${previousPath ?? ''}`;
1078
+ const teamName = 'scale-up-owner-tag-reuse';
1079
+ await initTeamState(teamName, 'task', 'executor', 1, cwd);
1080
+ await configureScaleUpTeamForDirectDispatch(teamName, cwd);
1081
+ const result = await scaleUp(teamName, 1, 'executor', [], cwd, {
1082
+ OMX_TEAM_SCALING_ENABLED: '1', OMX_TEAM_SKIP_READY_WAIT: '1',
1083
+ });
1084
+ assert.equal(result.ok, false);
1085
+ if (result.ok)
1086
+ return;
1087
+ assert.equal(result.error, 'scale_up_split_target_authority_lost:%21');
1088
+ const config = await readTeamConfig(teamName, cwd);
1089
+ assert.deepEqual(config?.workers.map((worker) => worker.name), ['worker-1']);
1090
+ const tmuxCommands = await readScaleUpTmuxLogCommands(tmuxLogPath);
1091
+ assert.equal(tmuxCommands.some((command) => command === 'kill-pane -t %31'), false);
805
1092
  }
806
1093
  finally {
807
1094
  if (typeof previousPath === 'string')
@@ -846,7 +1133,7 @@ printf '%s\\n' "$@" > '${capturePath}'
846
1133
  assert.match(inbox, /G001-team-runtime-bridge/);
847
1134
  assert.match(inbox, /workers do not own Ultragoal goal state/i);
848
1135
  assert.match(inbox, /omx ultragoal checkpoint --goal-id G001-team-runtime-bridge/);
849
- assert.ok(tmuxCommands.some((command) => command.startsWith('split-window ')));
1136
+ assert.equal(guardedScaleSplitCount(tmuxCommands), 1);
850
1137
  }
851
1138
  finally {
852
1139
  if (typeof previousPath === 'string')
@@ -877,7 +1164,7 @@ printf '%s\\n' "$@" > '${capturePath}'
877
1164
  const tmuxCommands = await readScaleUpTmuxLogCommands(tmuxLogPath);
878
1165
  assert.match(inbox, /Implement generic follow-up/);
879
1166
  assert.doesNotMatch(inbox, /## Approved Handoff Context/);
880
- assert.ok(tmuxCommands.some((command) => command.startsWith('split-window ')));
1167
+ assert.equal(guardedScaleSplitCount(tmuxCommands), 1);
881
1168
  }
882
1169
  finally {
883
1170
  if (typeof previousPath === 'string')
@@ -911,7 +1198,7 @@ printf '%s\\n' "$@" > '${capturePath}'
911
1198
  assert.match(inbox, /## Approved Handoff Context/);
912
1199
  assert.match(inbox, /Use the approved plan and matching test specs as the execution baseline/);
913
1200
  assert.doesNotMatch(inbox, /Approved context pack|Context pack index/);
914
- assert.ok(tmuxCommands.some((command) => command.startsWith('split-window ')));
1201
+ assert.equal(guardedScaleSplitCount(tmuxCommands), 1);
915
1202
  }
916
1203
  finally {
917
1204
  if (typeof previousPath === 'string')
@@ -967,7 +1254,7 @@ printf '%s\\n' "$@" > '${capturePath}'
967
1254
  assert.match(inbox, /Read the approved repository slice first\./);
968
1255
  assert.match(inbox, /Use the approved plan and matching test specs as the execution baseline/);
969
1256
  assert.doesNotMatch(inbox, /Approved context pack|Build refs|Verify refs|Scope refs|query the canonical pack|Context pack index/);
970
- assert.ok(tmuxCommands.some((command) => command.startsWith('split-window ')));
1257
+ assert.equal(guardedScaleSplitCount(tmuxCommands), 1);
971
1258
  }
972
1259
  finally {
973
1260
  if (typeof previousPath === 'string')
@@ -999,7 +1286,7 @@ printf '%s\\n' "$@" > '${capturePath}'
999
1286
  const tasks = buildScaleUpScenarioTasks(state, count);
1000
1287
  const result = await scaleUp(teamName, count, 'executor', tasks, cwd, { OMX_TEAM_SCALING_ENABLED: '1', OMX_TEAM_SKIP_READY_WAIT: '1' });
1001
1288
  const tmuxCommands = await readScaleUpTmuxLogCommands(tmuxLogPath);
1002
- const splitWindowCommands = tmuxCommands.filter((command) => command.startsWith('split-window '));
1289
+ const splitWindowCommands = tmuxCommands.filter((command) => isGuardedScaleMutation(command, 'split-window'));
1003
1290
  const inboxes = await Promise.all(tasks.map(async (task) => {
1004
1291
  const inboxPath = join(cwd, '.omx', 'state', 'team', teamName, 'workers', task.owner, 'inbox.md');
1005
1292
  return existsSync(inboxPath)
@@ -1126,6 +1413,7 @@ printf '%s\\n' "$@" > '${capturePath}'
1126
1413
  'set -eu',
1127
1414
  `printf '%s\n' "$*" >> "${tmuxLogPath}"`,
1128
1415
  'case "${1:-}" in',
1416
+ ' show-option) echo "team:scale-up" ;;',
1129
1417
  ' -V)',
1130
1418
  ' echo "tmux 3.2a"',
1131
1419
  ' ;;',
@@ -1133,7 +1421,16 @@ printf '%s\\n' "$@" > '${capturePath}'
1133
1421
  ' echo "%31"',
1134
1422
  ' ;;',
1135
1423
  ' list-panes)',
1136
- ' echo "42424"',
1424
+ ' case "$*" in',
1425
+ ' *"#{pane_id}\t#{pane_dead}\t#{pane_pid}"*)',
1426
+ " printf '%s\\t%s\\t%s\\n' '%11' '0' '42411'",
1427
+ " printf '%s\\t%s\\t%s\\n' '%21' '0' '42421'",
1428
+ " printf '%s\\t%s\\t%s\\n' '%31' '0' '42424'",
1429
+ ' ;;',
1430
+ ' *)',
1431
+ ' echo "42424"',
1432
+ ' ;;',
1433
+ ' esac',
1137
1434
  ' ;;',
1138
1435
  ' send-keys)',
1139
1436
  ' ;;',
@@ -1145,6 +1442,7 @@ printf '%s\\n' "$@" > '${capturePath}'
1145
1442
  '',
1146
1443
  ].join('\n'));
1147
1444
  await chmod(tmuxStubPath, 0o755);
1445
+ await writeSuccessfulScaleUpTmuxStub(fakeBinDir, tmuxLogPath);
1148
1446
  await writeFile(tmuxLogPath, '');
1149
1447
  process.env.PATH = `${fakeBinDir}:${previousPath ?? ''}`;
1150
1448
  delete process.env.CODEX_HOME;
@@ -1176,9 +1474,11 @@ printf '%s\\n' "$@" > '${capturePath}'
1176
1474
  assert.ok(config);
1177
1475
  if (!config)
1178
1476
  return;
1179
- config.tmux_session = 'omx-team-scale-up-project-reasoning';
1477
+ config.tmux_session = 'omx-team-scale-up';
1180
1478
  config.leader_pane_id = '%11';
1479
+ config.tmux_pane_owner_id = 'team:scale-up';
1181
1480
  config.workers[0].pane_id = '%21';
1481
+ config.workers[0].pid = 42421;
1182
1482
  await saveTeamConfig(config, cwd);
1183
1483
  const manifestPath = join(cwd, '.omx', 'state', 'team', 'scale-up-project-reasoning', 'manifest.v2.json');
1184
1484
  const manifest = JSON.parse(await readFile(manifestPath, 'utf-8'));
@@ -1226,31 +1526,9 @@ printf '%s\\n' "$@" > '${capturePath}'
1226
1526
  it('removes generated worktree-root AGENTS when scale-up rolls back', async () => {
1227
1527
  const cwd = await mkdtemp(join(tmpdir(), 'omx-scale-up-rollback-worktree-'));
1228
1528
  const fakeBinDir = await mkdtemp(join(tmpdir(), 'omx-scale-up-rollback-worktree-bin-'));
1229
- const tmuxStubPath = join(fakeBinDir, 'tmux');
1230
1529
  const previousPath = process.env.PATH;
1231
1530
  try {
1232
- await writeFile(tmuxStubPath, `#!/bin/sh
1233
- set -eu
1234
- case "\${1:-}" in
1235
- -V)
1236
- echo "tmux 3.2a"
1237
- ;;
1238
- split-window)
1239
- echo "%31"
1240
- ;;
1241
- list-panes)
1242
- echo "42424"
1243
- ;;
1244
- send-keys)
1245
- exit 1
1246
- ;;
1247
- capture-pane)
1248
- echo ""
1249
- ;;
1250
- esac
1251
- exit 0
1252
- `);
1253
- await chmod(tmuxStubPath, 0o755);
1531
+ await writeSuccessfulScaleUpTmuxStub(fakeBinDir, join(fakeBinDir, 'tmux.log'), true);
1254
1532
  process.env.PATH = `${fakeBinDir}:${previousPath ?? ''}`;
1255
1533
  await mkdir(join(cwd, '.codex', 'prompts'), { recursive: true });
1256
1534
  await writeFile(join(cwd, '.codex', 'prompts', 'writer.md'), '<identity>You are Writer.</identity>');
@@ -1265,9 +1543,11 @@ exit 0
1265
1543
  assert.ok(config);
1266
1544
  if (!config)
1267
1545
  return;
1268
- config.tmux_session = 'omx-team-rollback-worktree';
1546
+ config.tmux_session = 'omx-team-scale-up';
1269
1547
  config.leader_pane_id = '%11';
1548
+ config.tmux_pane_owner_id = 'team:scale-up';
1270
1549
  config.workers[0].pane_id = '%21';
1550
+ config.workers[0].pid = 42421;
1271
1551
  await saveTeamConfig(config, cwd);
1272
1552
  const manifestPath = join(cwd, '.omx', 'state', 'team', 'rollback-worktree', 'manifest.v2.json');
1273
1553
  const manifest = JSON.parse(await readFile(manifestPath, 'utf-8'));
@@ -1282,7 +1562,7 @@ exit 0
1282
1562
  return;
1283
1563
  assert.match(result.error, /scale_up_dispatch_failed:worker-2/);
1284
1564
  const workerRootAgents = join(cwd, '.omx', 'team', 'rollback-worktree', 'worktrees', 'worker-2', 'AGENTS.md');
1285
- assert.equal(await readFile(workerRootAgents, 'utf-8'), '# Root project instructions\n');
1565
+ await assert.rejects(readFile(workerRootAgents, 'utf-8'), { code: 'ENOENT' });
1286
1566
  const backupPath = join(cwd, '.git', 'worktrees', 'worker-2', 'omx', 'root-agents-backup.json');
1287
1567
  assert.equal(existsSync(backupPath), false);
1288
1568
  }
@@ -1304,6 +1584,7 @@ exit 0
1304
1584
  await writeFile(tmuxStubPath, `#!/bin/sh
1305
1585
  set -eu
1306
1586
  case "\${1:-}" in
1587
+ show-option) echo 'team:scale-up' ;;
1307
1588
  -V)
1308
1589
  echo "tmux 3.2a"
1309
1590
  ;;
@@ -1311,15 +1592,28 @@ set -eu
1311
1592
  echo "%31"
1312
1593
  ;;
1313
1594
  list-panes)
1314
- echo "42424"
1595
+ case "$*" in
1596
+ *"#{pane_id}\t#{pane_dead}\t#{pane_pid}"*)
1597
+ printf '%s\t%s\t%s\n' '%11' '0' '42411'
1598
+ printf '%s\t%s\t%s\n' '%21' '0' '42421'
1599
+ if [ ! -f "${fakeBinDir}/canonical-root-killed-%31" ]; then printf '%s\t%s\t%s\n' '%31' '0' '42424'; fi
1600
+ ;;
1601
+ *)
1602
+ echo "42424"
1603
+ ;;
1604
+ esac
1315
1605
  ;;
1316
1606
  capture-pane)
1317
1607
  echo ""
1318
1608
  ;;
1609
+ kill-pane)
1610
+ : > "${fakeBinDir}/canonical-root-killed-$3"
1611
+ ;;
1319
1612
  esac
1320
1613
  exit 0
1321
1614
  `);
1322
1615
  await chmod(tmuxStubPath, 0o755);
1616
+ await writeSuccessfulScaleUpTmuxStub(fakeBinDir, join(fakeBinDir, 'tmux.log'));
1323
1617
  process.env.PATH = `${fakeBinDir}:${previousPath ?? ''}`;
1324
1618
  await mkdir(join(cwd, '.codex', 'prompts'), { recursive: true });
1325
1619
  await writeFile(join(cwd, '.codex', 'prompts', 'writer.md'), '<identity>You are Writer.</identity>');
@@ -1334,9 +1628,11 @@ exit 0
1334
1628
  assert.ok(config);
1335
1629
  if (!config)
1336
1630
  return;
1337
- config.tmux_session = 'omx-team-canonical-root';
1631
+ config.tmux_session = 'omx-team-scale-up';
1338
1632
  config.leader_pane_id = '%11';
1633
+ config.tmux_pane_owner_id = 'team:scale-up';
1339
1634
  config.workers[0].pane_id = '%21';
1635
+ config.workers[0].pid = 42421;
1340
1636
  await saveTeamConfig(config, cwd);
1341
1637
  const manifestPath = join(cwd, '.omx', 'state', 'team', 'canonical-root', 'manifest.v2.json');
1342
1638
  const manifest = JSON.parse(await readFile(manifestPath, 'utf-8'));
@@ -1380,6 +1676,7 @@ exit 0
1380
1676
  await writeFile(tmuxStubPath, `#!/bin/sh
1381
1677
  set -eu
1382
1678
  case "\${1:-}" in
1679
+ show-option) echo 'team:scale-up' ;;
1383
1680
  -V)
1384
1681
  echo "tmux 3.2a"
1385
1682
  ;;
@@ -1387,15 +1684,28 @@ case "\${1:-}" in
1387
1684
  echo "%31"
1388
1685
  ;;
1389
1686
  list-panes)
1390
- echo "42424"
1687
+ case "$*" in
1688
+ *"#{pane_id}\t#{pane_dead}\t#{pane_pid}"*)
1689
+ printf '%s\t%s\t%s\n' '%11' '0' '42411'
1690
+ printf '%s\t%s\t%s\n' '%21' '0' '42421'
1691
+ if [ ! -f "${fakeBinDir}/frontier-role-killed-%31" ]; then printf '%s\t%s\t%s\n' '%31' '0' '42424'; fi
1692
+ ;;
1693
+ *)
1694
+ echo "42424"
1695
+ ;;
1696
+ esac
1391
1697
  ;;
1392
1698
  capture-pane)
1393
1699
  echo ""
1394
1700
  ;;
1701
+ kill-pane)
1702
+ : > "${fakeBinDir}/frontier-role-killed-$3"
1703
+ ;;
1395
1704
  esac
1396
1705
  exit 0
1397
1706
  `);
1398
1707
  await chmod(tmuxStubPath, 0o755);
1708
+ await writeSuccessfulScaleUpTmuxStub(fakeBinDir, join(fakeBinDir, 'tmux.log'));
1399
1709
  process.env.PATH = `${fakeBinDir}:${previousPath ?? ''}`;
1400
1710
  await mkdir(join(cwd, '.codex', 'prompts'), { recursive: true });
1401
1711
  await writeFile(join(cwd, '.codex', 'prompts', 'test-engineer.md'), '<identity>Test Engineer</identity>');
@@ -1412,9 +1722,11 @@ exit 0
1412
1722
  assert.ok(config);
1413
1723
  if (!config)
1414
1724
  return;
1415
- config.tmux_session = 'omx-team-frontier-role';
1725
+ config.tmux_session = 'omx-team-scale-up';
1416
1726
  config.leader_pane_id = '%11';
1727
+ config.tmux_pane_owner_id = 'team:scale-up';
1417
1728
  config.workers[0].pane_id = '%21';
1729
+ config.workers[0].pid = 42421;
1418
1730
  await saveTeamConfig(config, cwd);
1419
1731
  const manifestPath = join(cwd, '.omx', 'state', 'team', 'frontier-role', 'manifest.v2.json');
1420
1732
  const manifest = JSON.parse(await readFile(manifestPath, 'utf-8'));
@@ -1450,6 +1762,7 @@ exit 0
1450
1762
  await writeFile(tmuxStubPath, `#!/bin/sh
1451
1763
  set -eu
1452
1764
  case "\${1:-}" in
1765
+ show-option) echo 'team:scale-up' ;;
1453
1766
  -V)
1454
1767
  echo "tmux 3.2a"
1455
1768
  ;;
@@ -1457,15 +1770,28 @@ case "\${1:-}" in
1457
1770
  echo "%31"
1458
1771
  ;;
1459
1772
  list-panes)
1460
- echo "42424"
1773
+ case "$*" in
1774
+ *"#{pane_id}\t#{pane_dead}\t#{pane_pid}"*)
1775
+ printf '%s\t%s\t%s\n' '%11' '0' '42411'
1776
+ printf '%s\t%s\t%s\n' '%21' '0' '42421'
1777
+ if [ ! -f "${fakeBinDir}/mini-tuned-killed-%31" ]; then printf '%s\t%s\t%s\n' '%31' '0' '42424'; fi
1778
+ ;;
1779
+ *)
1780
+ echo "42424"
1781
+ ;;
1782
+ esac
1461
1783
  ;;
1462
1784
  capture-pane)
1463
1785
  echo ""
1464
1786
  ;;
1787
+ kill-pane)
1788
+ : > "${fakeBinDir}/mini-tuned-killed-$3"
1789
+ ;;
1465
1790
  esac
1466
1791
  exit 0
1467
1792
  `);
1468
1793
  await chmod(tmuxStubPath, 0o755);
1794
+ await writeSuccessfulScaleUpTmuxStub(fakeBinDir, join(fakeBinDir, 'tmux.log'));
1469
1795
  process.env.PATH = `${fakeBinDir}:${previousPath ?? ''}`;
1470
1796
  await mkdir(join(cwd, '.codex', 'prompts'), { recursive: true });
1471
1797
  await writeFile(join(cwd, '.codex', 'prompts', 'writer.md'), '<identity>You are Writer.</identity>');
@@ -1480,9 +1806,11 @@ exit 0
1480
1806
  assert.ok(config);
1481
1807
  if (!config)
1482
1808
  return;
1483
- config.tmux_session = 'omx-team-mini-tuned-root';
1809
+ config.tmux_session = 'omx-team-scale-up';
1484
1810
  config.leader_pane_id = '%11';
1811
+ config.tmux_pane_owner_id = 'team:scale-up';
1485
1812
  config.workers[0].pane_id = '%21';
1813
+ config.workers[0].pid = 42421;
1486
1814
  await saveTeamConfig(config, cwd);
1487
1815
  const manifestPath = join(cwd, '.omx', 'state', 'team', 'mini-tuned-root', 'manifest.v2.json');
1488
1816
  const manifest = JSON.parse(await readFile(manifestPath, 'utf-8'));
@@ -1517,39 +1845,20 @@ exit 0
1517
1845
  const cwd = await mkdtemp(join(tmpdir(), 'omx-scale-up-layout-'));
1518
1846
  const fakeBinDir = await mkdtemp(join(tmpdir(), 'omx-scale-up-layout-bin-'));
1519
1847
  const tmuxLogPath = join(fakeBinDir, 'tmux.log');
1520
- const tmuxStubPath = join(fakeBinDir, 'tmux');
1521
1848
  const previousPath = process.env.PATH;
1522
1849
  try {
1523
- await writeFile(tmuxStubPath, `#!/bin/sh
1524
- set -eu
1525
- printf '%s\\n' "$*" >> "${tmuxLogPath}"
1526
- case "\${1:-}" in
1527
- -V)
1528
- echo "tmux 3.2a"
1529
- ;;
1530
- split-window)
1531
- echo "%31"
1532
- ;;
1533
- list-panes)
1534
- echo "42424"
1535
- ;;
1536
- capture-pane)
1537
- echo ""
1538
- ;;
1539
- esac
1540
- exit 0
1541
- `);
1542
- await chmod(tmuxStubPath, 0o755);
1543
- await writeFile(tmuxLogPath, '');
1850
+ await writeSuccessfulScaleUpTmuxStub(fakeBinDir, tmuxLogPath);
1544
1851
  process.env.PATH = `${fakeBinDir}:${previousPath ?? ''}`;
1545
1852
  await initTeamState('scale-up-layout', 'task', 'executor', 1, cwd);
1546
1853
  const config = await readTeamConfig('scale-up-layout', cwd);
1547
1854
  assert.ok(config);
1548
1855
  if (!config)
1549
1856
  return;
1550
- config.tmux_session = 'omx-team-scale-up-layout';
1857
+ config.tmux_session = 'omx-team-scale-up';
1551
1858
  config.leader_pane_id = '%11';
1859
+ config.tmux_pane_owner_id = 'team:scale-up';
1552
1860
  config.workers[0].pane_id = '%21';
1861
+ config.workers[0].pid = 42421;
1553
1862
  await saveTeamConfig(config, cwd);
1554
1863
  const manifestPath = join(cwd, '.omx', 'state', 'team', 'scale-up-layout', 'manifest.v2.json');
1555
1864
  const manifest = JSON.parse(await readFile(manifestPath, 'utf-8'));
@@ -1562,9 +1871,20 @@ exit 0
1562
1871
  assert.equal(result.ok, true);
1563
1872
  if (!result.ok)
1564
1873
  return;
1565
- const tmuxLog = await readFile(tmuxLogPath, 'utf-8');
1566
- assert.match(tmuxLog, /split-window -v -t %21/);
1567
- assert.doesNotMatch(tmuxLog, /select-layout .*tiled/);
1874
+ const tmuxCommands = await readScaleUpTmuxLogCommands(tmuxLogPath);
1875
+ const guardedSplit = tmuxCommands.find((command) => command.startsWith('if-shell -F -t %21 '));
1876
+ assert.match(guardedSplit ?? '', /#\{==:#\{pane_id\},%21\}/);
1877
+ assert.match(guardedSplit ?? '', /#\{==:#\{pane_pid\},42421\}/);
1878
+ assert.match(guardedSplit ?? '', /#\{==:#\{session_name\},omx-team-scale-up\}/);
1879
+ assert.match(guardedSplit ?? '', /#\{==:#\{session_id\},\$1\}/);
1880
+ assert.match(guardedSplit ?? '', /#\{==:#\{window_id\},@1\}/);
1881
+ assert.match(guardedSplit ?? '', /#\{==:#\{@omx_team_pane_owner_id\},team:scale-up\}/);
1882
+ assert.match(guardedSplit ?? '', /split-window/);
1883
+ const plainScaleMutations = tmuxCommands.filter((command) => /^(?:split-window|set-option|send-keys|kill-pane)\b/.test(command));
1884
+ assert.deepEqual(plainScaleMutations, []);
1885
+ assert.ok(tmuxCommands.some((command) => isGuardedScaleMutation(command, 'set-option', '%31')));
1886
+ assert.ok(tmuxCommands.some((command) => isGuardedScaleMutation(command, 'send-keys', '%31')));
1887
+ assert.doesNotMatch(tmuxCommands.join('\n'), /select-layout .*tiled/);
1568
1888
  }
1569
1889
  finally {
1570
1890
  if (typeof previousPath === 'string')
@@ -1575,66 +1895,423 @@ exit 0
1575
1895
  await rm(fakeBinDir, { recursive: true, force: true });
1576
1896
  }
1577
1897
  });
1578
- it('provisions detached worktrees for scaled-up workers from persisted team worktree mode', async () => {
1579
- const repo = await initRepo();
1580
- const fakeBinDir = await mkdtemp(join(tmpdir(), 'omx-scale-up-detached-bin-'));
1898
+ for (const [name, proofOutput, proofExitStatus, expectedReason] of [
1899
+ ['dead persisted worker pane', '%21\t1\t42421\n', 0, 'dead'],
1900
+ ['similar persisted worker pane ID', '%210\t0\t424210\n', 0, 'absent'],
1901
+ ['malformed global pane PID', '%21\t0\tnot-a-pid\n', 0, 'malformed_snapshot'],
1902
+ ['duplicate global pane proof rows', '%21\t0\t42421\n%21\t0\t42422\n', 0, 'malformed_snapshot'],
1903
+ ['unavailable global pane query', '', 1, 'query_failed'],
1904
+ ]) {
1905
+ it(`fails closed before split-window when exact proof has ${name}`, async () => {
1906
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-scale-up-split-proof-'));
1907
+ const fakeBinDir = await mkdtemp(join(tmpdir(), 'omx-scale-up-split-proof-bin-'));
1908
+ const tmuxLogPath = join(fakeBinDir, 'tmux.log');
1909
+ const tmuxStubPath = join(fakeBinDir, 'tmux');
1910
+ const previousPath = process.env.PATH;
1911
+ try {
1912
+ await writeFile(tmuxStubPath, [
1913
+ '#!/bin/sh',
1914
+ 'set -eu',
1915
+ `printf '%s\\n' "$*" >> "${tmuxLogPath}"`,
1916
+ 'case "${1:-}" in',
1917
+ ' show-option) echo "team:scale-up" ;;',
1918
+ ' -V)',
1919
+ ' echo "tmux 3.2a"',
1920
+ ' ;;',
1921
+ ' list-panes)',
1922
+ ` printf '%b' ${JSON.stringify(proofOutput)}`,
1923
+ proofExitStatus === 0 ? ' ;;' : ' exit 1',
1924
+ proofExitStatus === 0 ? '' : ' ;;',
1925
+ ' split-window)',
1926
+ ' echo "%31"',
1927
+ ' ;;',
1928
+ 'esac',
1929
+ 'exit 0',
1930
+ '',
1931
+ ].join('\n'));
1932
+ await chmod(tmuxStubPath, 0o755);
1933
+ await writeFile(tmuxLogPath, '');
1934
+ process.env.PATH = `${fakeBinDir}:${previousPath ?? ''}`;
1935
+ const teamName = `split-proof-${expectedReason === 'malformed_snapshot' ? name.includes('duplicate') ? 'duplicate' : 'malformed' : expectedReason.replace('_', '-')}`;
1936
+ await initTeamState(teamName, 'task', 'executor', 1, cwd);
1937
+ await configureScaleUpTeamForDirectDispatch(teamName, cwd);
1938
+ const result = await scaleUp(teamName, 1, 'executor', [], cwd, { OMX_TEAM_SCALING_ENABLED: '1', OMX_TEAM_SKIP_READY_WAIT: '1' });
1939
+ assert.equal(result.ok, false);
1940
+ if (!result.ok) {
1941
+ assert.equal(result.error, `scale_up_split_target_proof_unavailable:%21:${expectedReason}`);
1942
+ }
1943
+ const config = await readTeamConfig(teamName, cwd);
1944
+ assert.equal(config?.workers.length, 1);
1945
+ const tmuxCommands = await readScaleUpTmuxLogCommands(tmuxLogPath);
1946
+ assert.equal(tmuxCommands.some((command) => command.startsWith('split-window ')), false);
1947
+ assert.equal(tmuxCommands.some((command) => command.startsWith('send-keys ')), false);
1948
+ }
1949
+ finally {
1950
+ if (typeof previousPath === 'string')
1951
+ process.env.PATH = previousPath;
1952
+ else
1953
+ delete process.env.PATH;
1954
+ await rm(cwd, { recursive: true, force: true });
1955
+ await rm(fakeBinDir, { recursive: true, force: true });
1956
+ }
1957
+ });
1958
+ }
1959
+ it('migrates a legacy worker PID from an exact owner-bound proof before split-window', async () => {
1960
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-scale-up-legacy-target-pid-'));
1961
+ const fakeBinDir = await mkdtemp(join(tmpdir(), 'omx-scale-up-legacy-target-pid-bin-'));
1581
1962
  const tmuxLogPath = join(fakeBinDir, 'tmux.log');
1582
1963
  const tmuxStubPath = join(fakeBinDir, 'tmux');
1583
1964
  const previousPath = process.env.PATH;
1584
1965
  try {
1585
- await writeFile(tmuxStubPath, [
1586
- '#!/bin/sh',
1587
- 'set -eu',
1588
- `printf '%s\n' "$*" >> "${tmuxLogPath}"`,
1589
- 'case "${1:-}" in',
1590
- ' -V)',
1591
- ' echo "tmux 3.2a"',
1592
- ' ;;',
1593
- ' split-window)',
1594
- ' echo "%41"',
1595
- ' ;;',
1596
- ' list-panes)',
1597
- ' echo "45454"',
1598
- ' ;;',
1599
- ' capture-pane)',
1600
- ' echo ""',
1601
- ' ;;',
1602
- 'esac',
1603
- 'exit 0',
1604
- '',
1605
- ].join('\n'));
1606
- await chmod(tmuxStubPath, 0o755);
1607
- await writeFile(tmuxLogPath, '');
1966
+ await writeSuccessfulScaleUpTmuxStub(fakeBinDir, tmuxLogPath);
1967
+ const stub = await readFile(tmuxStubPath, 'utf-8');
1968
+ const configPath = join(cwd, '.omx', 'state', 'team', 'legacy-target-pid', 'config.json');
1969
+ await writeFile(tmuxStubPath, stub.replace(' split-window)', ` split-window)\n grep -q '"pid": 42421' "${configPath}" || exit 23`));
1608
1970
  process.env.PATH = `${fakeBinDir}:${previousPath ?? ''}`;
1609
- const teamName = 'scale-up-detached-worktree';
1610
- await mkdir(join(repo, '.omx', 'state', 'team', teamName), { recursive: true });
1611
- await writeFile(join(repo, '.omx', 'state', 'team', teamName, 'worker-agents.md'), '# Base worker instructions\n');
1612
- await initTeamState(teamName, 'task', 'executor', 1, repo, DEFAULT_MAX_WORKERS, process.env, {
1613
- leader_cwd: repo,
1614
- team_state_root: join(repo, '.omx', 'state'),
1615
- workspace_mode: 'worktree',
1616
- worktree_mode: { enabled: true, detached: true, name: null },
1617
- });
1618
- const config = await readTeamConfig(teamName, repo);
1971
+ const teamName = 'legacy-target-pid';
1972
+ await initTeamState(teamName, 'task', 'executor', 1, cwd);
1973
+ await configureScaleUpTeamForDirectDispatch(teamName, cwd);
1974
+ const config = await readTeamConfig(teamName, cwd);
1619
1975
  assert.ok(config);
1620
1976
  if (!config)
1621
1977
  return;
1622
- config.tmux_session = `omx-team-${teamName}`;
1623
- config.leader_pane_id = '%11';
1624
- config.workers[0].pane_id = '%21';
1625
- await saveTeamConfig(config, repo);
1626
- const manifestPath = join(repo, '.omx', 'state', 'team', teamName, 'manifest.v2.json');
1627
- const manifest = JSON.parse(await readFile(manifestPath, 'utf-8'));
1628
- manifest.policy = {
1629
- ...(manifest.policy ?? {}),
1630
- dispatch_mode: 'transport_direct',
1631
- };
1632
- await writeFile(manifestPath, JSON.stringify(manifest, null, 2));
1633
- const result = await scaleUp(teamName, 1, 'executor', [], repo, { OMX_TEAM_SCALING_ENABLED: '1', OMX_TEAM_SKIP_READY_WAIT: '1' });
1978
+ delete config.workers[0].pid;
1979
+ await saveTeamConfig(config, cwd);
1980
+ const result = await scaleUp(teamName, 1, 'executor', [], cwd, {
1981
+ OMX_TEAM_SCALING_ENABLED: '1', OMX_TEAM_SKIP_READY_WAIT: '1',
1982
+ });
1634
1983
  assert.equal(result.ok, true);
1635
- if (!result.ok)
1636
- return;
1637
- const updated = await readTeamConfig(teamName, repo);
1984
+ assert.equal((await readTeamConfig(teamName, cwd))?.workers[0]?.pid, 42421);
1985
+ const tmuxCommands = await readScaleUpTmuxLogCommands(tmuxLogPath);
1986
+ assert.equal(guardedScaleSplitCount(tmuxCommands), 1);
1987
+ }
1988
+ finally {
1989
+ if (typeof previousPath === 'string')
1990
+ process.env.PATH = previousPath;
1991
+ else
1992
+ delete process.env.PATH;
1993
+ await rm(cwd, { recursive: true, force: true });
1994
+ await rm(fakeBinDir, { recursive: true, force: true });
1995
+ }
1996
+ });
1997
+ for (const [name, proofOutput, owner, expectedError] of [
1998
+ ['malformed proof', '%21\\t0\\tnot-a-pid\\n', 'team:scale-up', 'scale_up_split_target_proof_unavailable:%21:malformed_snapshot'],
1999
+ ['foreign pane', '%210\\t0\\t424210\\n', 'team:scale-up', 'scale_up_split_target_proof_unavailable:%21:absent'],
2000
+ ['foreign owner tag', '%21\\t0\\t42421\\n', 'team:foreign', 'scale_up_split_target_owner_changed:%21'],
2001
+ ]) {
2002
+ it(`rejects legacy PID migration with ${name} before a scale effect`, async () => {
2003
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-scale-up-legacy-proof-reject-'));
2004
+ const fakeBinDir = await mkdtemp(join(tmpdir(), 'omx-scale-up-legacy-proof-reject-bin-'));
2005
+ const tmuxLogPath = join(fakeBinDir, 'tmux.log');
2006
+ const tmuxStubPath = join(fakeBinDir, 'tmux');
2007
+ const previousPath = process.env.PATH;
2008
+ try {
2009
+ await writeFile(tmuxStubPath, [
2010
+ '#!/bin/sh', 'set -eu', `printf '%s\\n' "$*" >> "${tmuxLogPath}"`, 'case "${1:-}" in',
2011
+ ' -V) echo "tmux 3.2a" ;;',
2012
+ ` list-panes) printf '%b' ${JSON.stringify(proofOutput)} ;;`,
2013
+ ` show-option) echo ${JSON.stringify(owner)} ;;`,
2014
+ 'esac', 'exit 0', '',
2015
+ ].join('\n'));
2016
+ await chmod(tmuxStubPath, 0o755);
2017
+ await writeFile(tmuxLogPath, '');
2018
+ process.env.PATH = `${fakeBinDir}:${previousPath ?? ''}`;
2019
+ const teamName = `legacy-proof-${name.replaceAll(' ', '-')}`;
2020
+ await initTeamState(teamName, 'task', 'executor', 1, cwd);
2021
+ await configureScaleUpTeamForDirectDispatch(teamName, cwd);
2022
+ const config = await readTeamConfig(teamName, cwd);
2023
+ assert.ok(config);
2024
+ if (!config)
2025
+ return;
2026
+ delete config.workers[0].pid;
2027
+ await saveTeamConfig(config, cwd);
2028
+ assert.deepEqual(await scaleUp(teamName, 1, 'executor', [], cwd, {
2029
+ OMX_TEAM_SCALING_ENABLED: '1', OMX_TEAM_SKIP_READY_WAIT: '1',
2030
+ }), { ok: false, error: expectedError });
2031
+ assert.equal((await readTeamConfig(teamName, cwd))?.workers[0]?.pid, undefined);
2032
+ const tmuxCommands = await readScaleUpTmuxLogCommands(tmuxLogPath);
2033
+ assert.equal(tmuxCommands.some((command) => command.startsWith('split-window ') || command.startsWith('set-option ')), false);
2034
+ }
2035
+ finally {
2036
+ if (typeof previousPath === 'string')
2037
+ process.env.PATH = previousPath;
2038
+ else
2039
+ delete process.env.PATH;
2040
+ await rm(cwd, { recursive: true, force: true });
2041
+ await rm(fakeBinDir, { recursive: true, force: true });
2042
+ }
2043
+ });
2044
+ }
2045
+ it('rejects a PID-reused explicit split target at the first proof before any split or owner tag', async () => {
2046
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-scale-up-first-proof-reuse-'));
2047
+ const fakeBinDir = await mkdtemp(join(tmpdir(), 'omx-scale-up-first-proof-reuse-bin-'));
2048
+ const tmuxLogPath = join(fakeBinDir, 'tmux.log');
2049
+ const tmuxStubPath = join(fakeBinDir, 'tmux');
2050
+ const previousPath = process.env.PATH;
2051
+ try {
2052
+ await writeFile(tmuxStubPath, [
2053
+ '#!/bin/sh', 'set -eu', `printf '%s\\n' "$*" >> "${tmuxLogPath}"`, 'case "${1:-}" in', ' show-option) echo "team:scale-up" ;;',
2054
+ ' show-option) echo "team:scale-up" ;;',
2055
+ ' -V) echo "tmux 3.2a" ;;',
2056
+ " list-panes) printf '%s\\t%s\\t%s\\n' '%21' '0' '52421' ;;",
2057
+ 'esac', 'exit 0', '',
2058
+ ].join('\n'));
2059
+ await chmod(tmuxStubPath, 0o755);
2060
+ await writeFile(tmuxLogPath, '');
2061
+ process.env.PATH = `${fakeBinDir}:${previousPath ?? ''}`;
2062
+ const teamName = 'first-proof-reuse';
2063
+ await initTeamState(teamName, 'task', 'executor', 1, cwd);
2064
+ await configureScaleUpTeamForDirectDispatch(teamName, cwd);
2065
+ const result = await scaleUp(teamName, 1, 'executor', [], cwd, { OMX_TEAM_SCALING_ENABLED: '1', OMX_TEAM_SKIP_READY_WAIT: '1' });
2066
+ assert.deepEqual(result, { ok: false, error: 'scale_up_split_target_pid_changed:%21:42421:52421' });
2067
+ const tmuxCommands = await readScaleUpTmuxLogCommands(tmuxLogPath);
2068
+ assert.equal(tmuxCommands.some((command) => command.startsWith('split-window ') || command.startsWith('set-option ')), false);
2069
+ }
2070
+ finally {
2071
+ if (typeof previousPath === 'string')
2072
+ process.env.PATH = previousPath;
2073
+ else
2074
+ delete process.env.PATH;
2075
+ await rm(cwd, { recursive: true, force: true });
2076
+ await rm(fakeBinDir, { recursive: true, force: true });
2077
+ }
2078
+ });
2079
+ it('rolls back all preparation when the split target proof is lost immediately before split-window', async () => {
2080
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-scale-up-second-split-proof-'));
2081
+ const fakeBinDir = await mkdtemp(join(tmpdir(), 'omx-scale-up-second-split-proof-bin-'));
2082
+ const tmuxLogPath = join(fakeBinDir, 'tmux.log');
2083
+ const proofCountPath = join(fakeBinDir, 'proof-count');
2084
+ const tmuxStubPath = join(fakeBinDir, 'tmux');
2085
+ const previousPath = process.env.PATH;
2086
+ try {
2087
+ await writeFile(tmuxStubPath, [
2088
+ '#!/bin/sh',
2089
+ 'set -eu',
2090
+ `printf '%s\\n' "$*" >> "${tmuxLogPath}"`,
2091
+ 'case "${1:-}" in',
2092
+ ' show-option) echo "team:scale-up" ;;',
2093
+ ' -V)',
2094
+ ' echo "tmux 3.2a"',
2095
+ ' ;;',
2096
+ ' display-message) echo "%21\\t42421\\tomx-team-scale-up\\t\\$1\\t@1\\tteam:scale-up" ;;',
2097
+ ' list-panes)',
2098
+ ` count=0; [ ! -f "${proofCountPath}" ] || count=$(cat "${proofCountPath}")`,
2099
+ ' count=$((count + 1))',
2100
+ ` printf '%s' "$count" > "${proofCountPath}"`,
2101
+ ' if [ "$count" -eq 1 ]; then',
2102
+ " printf '%s\\t%s\\t%s\\n' '%21' '0' '42421'",
2103
+ ' else',
2104
+ ' exit 1',
2105
+ ' fi',
2106
+ ' ;;',
2107
+ ' if-shell)',
2108
+ ' [ "${2:-}" = "-F" ] && [ "${3:-}" = "-t" ] || exit 1',
2109
+ ' success="${6:-}"',
2110
+ " receipt=\"$(printf '%s' \"$success\" | sed -En 's/.*(omx-scale-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}).*/\\1/p')\"",
2111
+ ' case "$success" in',
2112
+ ' *split-window*) printf "%s\\t%s\\n" "%31\\t42424\\tomx-team-scale-up\\t$1\\t@1" "$receipt" ;;',
2113
+ ' *) printf "%s\\n" "$receipt" ;;',
2114
+ ' esac',
2115
+ ' ;;',
2116
+ 'esac',
2117
+ 'exit 0',
2118
+ '',
2119
+ ].join('\n'));
2120
+ await chmod(tmuxStubPath, 0o755);
2121
+ await writeFile(tmuxLogPath, '');
2122
+ process.env.PATH = `${fakeBinDir}:${previousPath ?? ''}`;
2123
+ const teamName = 'second-split-proof';
2124
+ await initTeamState(teamName, 'task', 'executor', 1, cwd);
2125
+ await configureScaleUpTeamForDirectDispatch(teamName, cwd);
2126
+ const teamStateDir = join(cwd, '.omx', 'state', 'team', teamName);
2127
+ const configPath = join(teamStateDir, 'config.json');
2128
+ const tasksDir = join(teamStateDir, 'tasks');
2129
+ const workersDir = join(teamStateDir, 'workers');
2130
+ const runtimeDir = join(teamStateDir, 'runtime');
2131
+ const configBefore = await readFile(configPath, 'utf-8');
2132
+ const taskEntriesBefore = (await readdir(tasksDir)).sort();
2133
+ const workerEntriesBefore = (await readdir(workersDir)).sort();
2134
+ const runtimeDirExistedBefore = existsSync(runtimeDir);
2135
+ const result = await scaleUp(teamName, 1, 'executor', [{ subject: 'must not persist', description: 'proof loss rollback', owner: 'worker-2' }], cwd, { OMX_TEAM_SCALING_ENABLED: '1', OMX_TEAM_SKIP_READY_WAIT: '1' });
2136
+ assert.deepEqual(result, {
2137
+ ok: false,
2138
+ error: 'scale_up_split_target_proof_unavailable:%21',
2139
+ });
2140
+ const tmuxCommands = await readScaleUpTmuxLogCommands(tmuxLogPath);
2141
+ assert.deepEqual(tmuxCommands.filter((command) => command.startsWith('list-panes ')), [
2142
+ 'list-panes -a -F #{pane_id}\t#{pane_dead}\t#{pane_pid}',
2143
+ 'list-panes -a -F #{pane_id}\t#{pane_dead}\t#{pane_pid}',
2144
+ ]);
2145
+ assert.equal(guardedScaleSplitCount(tmuxCommands), 0);
2146
+ assert.equal(tmuxCommands.some((command) => command.startsWith('send-keys ')), false);
2147
+ assert.deepEqual(withoutConfigGeneration(await readFile(configPath, 'utf-8')), withoutConfigGeneration(configBefore));
2148
+ assert.deepEqual((await readdir(tasksDir)).sort(), taskEntriesBefore);
2149
+ assert.deepEqual((await readdir(workersDir)).sort(), workerEntriesBefore);
2150
+ assert.equal(existsSync(join(workersDir, 'worker-2')), false);
2151
+ assert.equal(existsSync(join(cwd, '.omx', 'team', teamName, 'worktrees', 'worker-2')), false);
2152
+ assert.equal(existsSync(runtimeDir), runtimeDirExistedBefore);
2153
+ }
2154
+ finally {
2155
+ if (typeof previousPath === 'string')
2156
+ process.env.PATH = previousPath;
2157
+ else
2158
+ delete process.env.PATH;
2159
+ await rm(cwd, { recursive: true, force: true });
2160
+ await rm(fakeBinDir, { recursive: true, force: true });
2161
+ }
2162
+ });
2163
+ it('rejects a split target ID replacement that occurs during owner authorization', async () => {
2164
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-scale-up-split-target-pid-reuse-'));
2165
+ const fakeBinDir = await mkdtemp(join(tmpdir(), 'omx-scale-up-split-target-pid-reuse-bin-'));
2166
+ const tmuxLogPath = join(fakeBinDir, 'tmux.log');
2167
+ const proofCountPath = join(fakeBinDir, 'proof-count');
2168
+ const tmuxStubPath = join(fakeBinDir, 'tmux');
2169
+ const previousPath = process.env.PATH;
2170
+ try {
2171
+ await writeFile(tmuxStubPath, [
2172
+ '#!/bin/sh',
2173
+ 'set -eu',
2174
+ `printf '%s\\n' "$*" >> "${tmuxLogPath}"`,
2175
+ 'case "${1:-}" in',
2176
+ ' show-option) echo "team:scale-up" ;;',
2177
+ ' -V)',
2178
+ ' echo "tmux 3.2a"',
2179
+ ' ;;',
2180
+ ' display-message) echo "%21\\t42421\\tomx-team-scale-up\\t\\$1\\t@1\\tteam:scale-up" ;;',
2181
+ ' list-panes)',
2182
+ ` count=0; [ ! -f "${proofCountPath}" ] || count=$(cat "${proofCountPath}")`,
2183
+ ' count=$((count + 1))',
2184
+ ` printf '%s' "$count" > "${proofCountPath}"`,
2185
+ ' if [ "$count" -eq 1 ]; then',
2186
+ " printf '%s\\t%s\\t%s\\n' '%21' '0' '42421'",
2187
+ ' else',
2188
+ " printf '%s\\t%s\\t%s\\n' '%21' '0' '52421'",
2189
+ ' fi',
2190
+ ' ;;',
2191
+ ' split-window)',
2192
+ ' echo "%31"',
2193
+ ' ;;',
2194
+ 'esac',
2195
+ 'exit 0',
2196
+ '',
2197
+ ].join('\n'));
2198
+ await chmod(tmuxStubPath, 0o755);
2199
+ await writeFile(tmuxLogPath, '');
2200
+ process.env.PATH = `${fakeBinDir}:${previousPath ?? ''}`;
2201
+ const teamName = 'split-target-pid-reuse';
2202
+ await initTeamState(teamName, 'task', 'executor', 1, cwd);
2203
+ await configureScaleUpTeamForDirectDispatch(teamName, cwd);
2204
+ const config = await readTeamConfig(teamName, cwd);
2205
+ assert.ok(config);
2206
+ if (!config)
2207
+ return;
2208
+ config.workers[0].pid = 42421;
2209
+ await saveTeamConfig(config, cwd);
2210
+ const teamStateDir = join(cwd, '.omx', 'state', 'team', teamName);
2211
+ const configPath = join(teamStateDir, 'config.json');
2212
+ const tasksDir = join(teamStateDir, 'tasks');
2213
+ const workersDir = join(teamStateDir, 'workers');
2214
+ const runtimeDir = join(teamStateDir, 'runtime');
2215
+ const configBefore = await readFile(configPath, 'utf-8');
2216
+ const taskEntriesBefore = (await readdir(tasksDir)).sort();
2217
+ const workerEntriesBefore = (await readdir(workersDir)).sort();
2218
+ const runtimeDirExistedBefore = existsSync(runtimeDir);
2219
+ const result = await scaleUp(teamName, 1, 'executor', [{ subject: 'must not persist', description: 'PID reuse rollback', owner: 'worker-2' }], cwd, { OMX_TEAM_SCALING_ENABLED: '1', OMX_TEAM_SKIP_READY_WAIT: '1' });
2220
+ assert.deepEqual(result, {
2221
+ ok: false,
2222
+ error: 'scale_up_split_target_authority_lost:%21',
2223
+ });
2224
+ const tmuxCommands = await readScaleUpTmuxLogCommands(tmuxLogPath);
2225
+ assert.equal(tmuxCommands.filter((command) => command.startsWith('list-panes ')).length, 2);
2226
+ assert.equal(tmuxCommands.some((command) => isGuardedScaleMutation(command, 'send-keys')), false);
2227
+ assert.deepEqual(withoutConfigGeneration(await readFile(configPath, 'utf-8')), withoutConfigGeneration(configBefore));
2228
+ assert.deepEqual((await readdir(tasksDir)).sort(), taskEntriesBefore);
2229
+ assert.deepEqual((await readdir(workersDir)).sort(), workerEntriesBefore);
2230
+ assert.equal(existsSync(join(workersDir, 'worker-2')), false);
2231
+ assert.equal(existsSync(runtimeDir), runtimeDirExistedBefore);
2232
+ }
2233
+ finally {
2234
+ if (typeof previousPath === 'string')
2235
+ process.env.PATH = previousPath;
2236
+ else
2237
+ delete process.env.PATH;
2238
+ await rm(cwd, { recursive: true, force: true });
2239
+ await rm(fakeBinDir, { recursive: true, force: true });
2240
+ }
2241
+ });
2242
+ it('provisions detached worktrees for scaled-up workers from persisted team worktree mode', async () => {
2243
+ const repo = await initRepo();
2244
+ const fakeBinDir = await mkdtemp(join(tmpdir(), 'omx-scale-up-detached-bin-'));
2245
+ const tmuxLogPath = join(fakeBinDir, 'tmux.log');
2246
+ const tmuxStubPath = join(fakeBinDir, 'tmux');
2247
+ const previousPath = process.env.PATH;
2248
+ try {
2249
+ await writeFile(tmuxStubPath, [
2250
+ '#!/bin/sh',
2251
+ 'set -eu',
2252
+ `printf '%s\n' "$*" >> "${tmuxLogPath}"`,
2253
+ 'case "${1:-}" in',
2254
+ ' show-option) echo "team:scale-up" ;;',
2255
+ ' -V)',
2256
+ ' echo "tmux 3.2a"',
2257
+ ' ;;',
2258
+ ' split-window)',
2259
+ ' echo "%41"',
2260
+ ' ;;',
2261
+ ' list-panes)',
2262
+ ' case "$*" in',
2263
+ ' *"#{pane_id}\t#{pane_dead}\t#{pane_pid}"*)',
2264
+ " printf '%s\\t%s\\t%s\\n' '%11' '0' '45451'",
2265
+ " printf '%s\\t%s\\t%s\\n' '%21' '0' '45452'",
2266
+ " printf '%s\\t%s\\t%s\\n' '%41' '0' '45454'",
2267
+ ' ;;',
2268
+ ' *)',
2269
+ ' echo "45454"',
2270
+ ' ;;',
2271
+ ' esac',
2272
+ ' ;;',
2273
+ ' capture-pane)',
2274
+ ' echo ""',
2275
+ ' ;;',
2276
+ 'esac',
2277
+ 'exit 0',
2278
+ '',
2279
+ ].join('\n'));
2280
+ await chmod(tmuxStubPath, 0o755);
2281
+ await writeSuccessfulScaleUpTmuxStub(fakeBinDir, tmuxLogPath);
2282
+ await writeFile(tmuxLogPath, '');
2283
+ process.env.PATH = `${fakeBinDir}:${previousPath ?? ''}`;
2284
+ const teamName = 'scale-up-detached-worktree';
2285
+ await mkdir(join(repo, '.omx', 'state', 'team', teamName), { recursive: true });
2286
+ await writeFile(join(repo, '.omx', 'state', 'team', teamName, 'worker-agents.md'), '# Base worker instructions\n');
2287
+ await initTeamState(teamName, 'task', 'executor', 1, repo, DEFAULT_MAX_WORKERS, process.env, {
2288
+ leader_cwd: repo,
2289
+ team_state_root: join(repo, '.omx', 'state'),
2290
+ workspace_mode: 'worktree',
2291
+ worktree_mode: { enabled: true, detached: true, name: null },
2292
+ });
2293
+ const config = await readTeamConfig(teamName, repo);
2294
+ assert.ok(config);
2295
+ if (!config)
2296
+ return;
2297
+ config.tmux_session = 'omx-team-scale-up';
2298
+ config.leader_pane_id = '%11';
2299
+ config.tmux_pane_owner_id = 'team:scale-up';
2300
+ config.workers[0].pane_id = '%21';
2301
+ config.workers[0].pid = 42421;
2302
+ await saveTeamConfig(config, repo);
2303
+ const manifestPath = join(repo, '.omx', 'state', 'team', teamName, 'manifest.v2.json');
2304
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf-8'));
2305
+ manifest.policy = {
2306
+ ...(manifest.policy ?? {}),
2307
+ dispatch_mode: 'transport_direct',
2308
+ };
2309
+ await writeFile(manifestPath, JSON.stringify(manifest, null, 2));
2310
+ const result = await scaleUp(teamName, 1, 'executor', [], repo, { OMX_TEAM_SCALING_ENABLED: '1', OMX_TEAM_SKIP_READY_WAIT: '1' });
2311
+ assert.equal(result.ok, true);
2312
+ if (!result.ok)
2313
+ return;
2314
+ const updated = await readTeamConfig(teamName, repo);
1638
2315
  const worker = updated?.workers.find((entry) => entry.name === 'worker-2');
1639
2316
  assert.deepEqual(updated?.worktree_mode, { enabled: true, detached: true, name: null });
1640
2317
  assert.ok(worker?.worktree_path, 'scaled worker should have detached worktree path');
@@ -1665,6 +2342,7 @@ exit 0
1665
2342
  'set -eu',
1666
2343
  `printf '%s\n' "$*" >> "${tmuxLogPath}"`,
1667
2344
  'case "${1:-}" in',
2345
+ ' show-option) echo "team:scale-up" ;;',
1668
2346
  ' -V)',
1669
2347
  ' echo "tmux 3.2a"',
1670
2348
  ' ;;',
@@ -1672,7 +2350,16 @@ exit 0
1672
2350
  ' echo "%42"',
1673
2351
  ' ;;',
1674
2352
  ' list-panes)',
1675
- ' echo "46464"',
2353
+ ' case "$*" in',
2354
+ ' *"#{pane_id}\t#{pane_dead}\t#{pane_pid}"*)',
2355
+ " printf '%s\\t%s\\t%s\\n' '%11' '0' '46461'",
2356
+ " printf '%s\\t%s\\t%s\\n' '%21' '0' '46462'",
2357
+ " printf '%s\\t%s\\t%s\\n' '%42' '0' '46464'",
2358
+ ' ;;',
2359
+ ' *)',
2360
+ ' echo "46464"',
2361
+ ' ;;',
2362
+ ' esac',
1676
2363
  ' ;;',
1677
2364
  ' capture-pane)',
1678
2365
  ' echo ""',
@@ -1682,6 +2369,7 @@ exit 0
1682
2369
  '',
1683
2370
  ].join('\n'));
1684
2371
  await chmod(tmuxStubPath, 0o755);
2372
+ await writeSuccessfulScaleUpTmuxStub(fakeBinDir, tmuxLogPath);
1685
2373
  await writeFile(tmuxLogPath, '');
1686
2374
  process.env.PATH = `${fakeBinDir}:${previousPath ?? ''}`;
1687
2375
  const teamName = 'scale-up-named-worktree';
@@ -1698,9 +2386,11 @@ exit 0
1698
2386
  assert.ok(config);
1699
2387
  if (!config)
1700
2388
  return;
1701
- config.tmux_session = `omx-team-${teamName}`;
2389
+ config.tmux_session = 'omx-team-scale-up';
1702
2390
  config.leader_pane_id = '%11';
2391
+ config.tmux_pane_owner_id = 'team:scale-up';
1703
2392
  config.workers[0].pane_id = '%21';
2393
+ config.workers[0].pid = 42421;
1704
2394
  await saveTeamConfig(config, repo);
1705
2395
  const manifestPath = join(repo, '.omx', 'state', 'team', teamName, 'manifest.v2.json');
1706
2396
  const manifest = JSON.parse(await readFile(manifestPath, 'utf-8'));
@@ -1732,42 +2422,344 @@ exit 0
1732
2422
  await rm(fakeBinDir, { recursive: true, force: true });
1733
2423
  }
1734
2424
  });
1735
- });
1736
- describe('scaleDown', () => {
1737
- it('rejects when scaling is disabled', async () => {
1738
- await assert.rejects(scaleDown('test', '/tmp', {}, {}), /Dynamic scaling is disabled/);
1739
- });
1740
- it('returns error when team not found', async () => {
1741
- const cwd = await mkdtemp(join(tmpdir(), 'omx-scale-down-nf-'));
1742
- try {
1743
- const result = await scaleDown('nonexistent', cwd, {}, { OMX_TEAM_SCALING_ENABLED: '1' });
1744
- assert.equal(result.ok, false);
1745
- if (!result.ok) {
1746
- assert.match(result.error, /not found/);
1747
- }
1748
- }
1749
- finally {
1750
- await rm(cwd, { recursive: true, force: true });
1751
- }
1752
- });
1753
- it('returns error when trying to remove all workers', async () => {
1754
- const cwd = await mkdtemp(join(tmpdir(), 'omx-scale-down-all-'));
2425
+ it('rolls back pre-split worker preparation when worktree-root instructions cannot materialize', async () => {
2426
+ const cwd = await initRepo();
2427
+ const fakeBinDir = await mkdtemp(join(tmpdir(), 'omx-scale-up-pre-split-root-failure-bin-'));
2428
+ const tmuxStubPath = join(fakeBinDir, 'tmux');
2429
+ const tmuxLogPath = join(fakeBinDir, 'tmux.log');
2430
+ const previousPath = process.env.PATH;
2431
+ const teamName = 'pre-split-root-failure';
2432
+ const worktreePath = join(cwd, '.omx', 'team', teamName, 'worktrees', 'worker-2');
1755
2433
  try {
1756
- await initTeamState('all-test', 'task', 'executor', 1, cwd);
1757
- const result = await scaleDown('all-test', cwd, { workerNames: ['worker-1'] }, { OMX_TEAM_SCALING_ENABLED: '1' });
2434
+ await writeFile(tmuxStubPath, `#!/bin/sh
2435
+ set -eu
2436
+ printf '%s\\n' "$*" >> "${tmuxLogPath}"
2437
+ case "\${1:-}" in
2438
+ show-option) echo 'team:scale-up' ;;
2439
+ -V) echo 'tmux 3.2a' ;;
2440
+ list-panes) printf '%s\\t%s\\t%s\\n' '%21' '0' '42421' ;;
2441
+ esac
2442
+ `);
2443
+ await chmod(tmuxStubPath, 0o755);
2444
+ process.env.PATH = `${fakeBinDir}:${previousPath ?? ''}`;
2445
+ await initTeamState(teamName, 'task', 'executor', 1, cwd);
2446
+ await configureScaleUpTeamForDirectDispatch(teamName, cwd);
2447
+ const config = await readTeamConfig(teamName, cwd);
2448
+ assert.ok(config);
2449
+ if (!config)
2450
+ return;
2451
+ config.tmux_session = 'omx-team-scale-up';
2452
+ config.workers[0].pane_id = '%21';
2453
+ config.workspace_mode = 'worktree';
2454
+ config.worktree_mode = { enabled: true, detached: true, name: null };
2455
+ await saveTeamConfig(config, cwd);
2456
+ execFileSync('git', ['worktree', 'add', '--detach', worktreePath], { cwd, stdio: 'ignore' });
2457
+ await mkdir(join(worktreePath, 'AGENTS.md'));
2458
+ const result = await scaleUp(teamName, 1, 'executor', [], cwd, { OMX_TEAM_SCALING_ENABLED: '1', OMX_TEAM_SKIP_READY_WAIT: '1' });
1758
2459
  assert.equal(result.ok, false);
1759
- if (!result.ok) {
1760
- assert.match(result.error, /at least 1 must remain/);
1761
- }
2460
+ if (result.ok)
2461
+ return;
2462
+ assert.match(result.error, /^scale_up_worker_preparation_failed:worker-2:/);
2463
+ assert.equal(existsSync(join(cwd, '.omx', 'state', 'team', teamName, 'workers', 'worker-2')), false);
2464
+ assert.equal(existsSync(workerStartupScriptPath(cwd, teamName, 'worker-2')), false);
2465
+ assert.equal(existsSync(join(worktreePath, 'AGENTS.md')), true);
2466
+ assert.deepEqual(await readScaleUpTmuxLogCommands(tmuxLogPath), [
2467
+ '-V',
2468
+ 'list-panes -a -F #{pane_id}\t#{pane_dead}\t#{pane_pid}',
2469
+ ]);
1762
2470
  }
1763
2471
  finally {
2472
+ if (typeof previousPath === 'string')
2473
+ process.env.PATH = previousPath;
2474
+ else
2475
+ delete process.env.PATH;
1764
2476
  await rm(cwd, { recursive: true, force: true });
2477
+ await rm(fakeBinDir, { recursive: true, force: true });
1765
2478
  }
1766
2479
  });
1767
- it('returns error for worker not in team', async () => {
1768
- const cwd = await mkdtemp(join(tmpdir(), 'omx-scale-down-miss-'));
2480
+ it('retains only unresolved rollback workers and deletes never-created tasks after an explicit proof-loss phase', async () => {
2481
+ const cwd = await initRepo();
2482
+ const fakeBinDir = await mkdtemp(join(tmpdir(), 'omx-scale-up-rollback-kill-fail-bin-'));
2483
+ const tmuxStubPath = join(fakeBinDir, 'tmux');
2484
+ const tmuxLogPath = join(fakeBinDir, 'tmux.log');
2485
+ const proofLossUsedPath = join(fakeBinDir, 'proof-loss-used');
2486
+ const splitCountPath = join(fakeBinDir, 'split-count');
2487
+ const previousPath = process.env.PATH;
1769
2488
  try {
1770
- await initTeamState('miss-test', 'task', 'executor', 2, cwd);
2489
+ await writeFile(tmuxStubPath, `#!/bin/sh
2490
+ set -eu
2491
+ printf '%s\n' "$*" >> "${tmuxLogPath}"
2492
+ case "\${1:-}" in
2493
+ show-option) echo 'team:scale-up' ;;
2494
+ -V) echo 'tmux 3.2a' ;;
2495
+ display-message)
2496
+ case "$*" in
2497
+ *"-t %32 "*) echo '%32 42432 omx-team-scale-up $1 @1 team:scale-up' ;;
2498
+ *"-t %31 "*) echo '%31 42431 omx-team-scale-up $1 @1 team:scale-up' ;;
2499
+ *"-t %21 "*) echo '%21 42421 omx-team-scale-up $1 @1 team:scale-up' ;;
2500
+ *) exit 1 ;;
2501
+ esac
2502
+ ;;
2503
+ if-shell)
2504
+ [ "\${2:-}" = '-F' ] && [ "\${3:-}" = '-t' ] || exit 1
2505
+ target="\${4:-}"
2506
+ success="\${6:-}"
2507
+ receipt="$(printf '%s' "$success" | sed -En 's/.*(omx-scale-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}).*/\\1/p')"
2508
+ case "$success" in
2509
+ *split-window*)
2510
+ split_count=0; [ ! -f "${splitCountPath}" ] || split_count=$(cat "${splitCountPath}")
2511
+ split_count=$((split_count + 1)); printf '%s' "$split_count" > "${splitCountPath}"
2512
+ if [ "$split_count" -eq 1 ]; then pane='%31'; pid='42431'; elif [ "$split_count" -eq 2 ]; then pane='%32'; pid='42432'; else pane='%33'; pid='42433'; fi
2513
+ printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$pane" "$pid" 'omx-team-scale-up' '$1' '@1' "$receipt"
2514
+ ;;
2515
+ *kill-pane*)
2516
+ case "$target" in %31) exit 1 ;; %32) printf '%s\n' "$receipt" ;; *) exit 1 ;; esac
2517
+ ;;
2518
+ *) printf '%s\n' "$receipt" ;;
2519
+ esac
2520
+ ;;
2521
+ list-panes)
2522
+ split_count=0; [ ! -f "${splitCountPath}" ] || split_count=$(cat "${splitCountPath}")
2523
+ if [ "$split_count" -ge 2 ] && [ ! -f "${proofLossUsedPath}" ]; then
2524
+ : > "${proofLossUsedPath}"
2525
+ exit 1
2526
+ fi
2527
+ if [ "$split_count" -ge 2 ]; then
2528
+ printf '%s\t%s\t%s\n' '%21' '0' '42421'
2529
+ printf '%s\t%s\t%s\n' '%31' '0' '42431'
2530
+ printf '%s\t%s\t%s\n' '%32' '0' '42432'
2531
+ elif [ "$split_count" -eq 1 ]; then
2532
+ printf '%s\t%s\t%s\n' '%21' '0' '42421'
2533
+ printf '%s\t%s\t%s\n' '%31' '0' '42431'
2534
+ else
2535
+ printf '%s\t%s\t%s\n' '%21' '0' '42421'
2536
+ fi
2537
+ ;;
2538
+ split-window)
2539
+ split_count=0; [ ! -f "${splitCountPath}" ] || split_count=$(cat "${splitCountPath}")
2540
+ split_count=$((split_count + 1)); printf '%s' "$split_count" > "${splitCountPath}"
2541
+ if [ "$split_count" -eq 1 ]; then
2542
+ echo '%31\t42431\tomx-team-scale-up\t$1\t@1'
2543
+ elif [ "$split_count" -eq 2 ]; then
2544
+ echo '%32\t42432\tomx-team-scale-up\t$1\t@1'
2545
+ else
2546
+ echo '%33\t42433\tomx-team-scale-up\t$1\t@1'
2547
+ fi
2548
+ ;;
2549
+ kill-pane)
2550
+ case "$3" in
2551
+ %31|%32) exit 0 ;;
2552
+ *) exit 1 ;;
2553
+ esac
2554
+ ;;
2555
+ esac
2556
+ `);
2557
+ await chmod(tmuxStubPath, 0o755);
2558
+ process.env.PATH = `${fakeBinDir}:${previousPath ?? ''}`;
2559
+ await initTeamState('rollback-kill-fail', 'task', 'executor', 1, cwd);
2560
+ await configureScaleUpTeamForDirectDispatch('rollback-kill-fail', cwd);
2561
+ const rollbackConfig = await readTeamConfig('rollback-kill-fail', cwd);
2562
+ assert.ok(rollbackConfig);
2563
+ if (!rollbackConfig)
2564
+ return;
2565
+ rollbackConfig.workspace_mode = 'worktree';
2566
+ rollbackConfig.worktree_mode = { enabled: true, detached: true, name: null };
2567
+ await saveTeamConfig(rollbackConfig, cwd);
2568
+ const result = await scaleUp('rollback-kill-fail', 3, 'executor', [
2569
+ { subject: 'first', description: 'created before mixed rollback', owner: 'worker-2' },
2570
+ { subject: 'second', description: 'created before mixed rollback', owner: 'worker-3' },
2571
+ { subject: 'third', description: 'never created after proof loss', owner: 'worker-4' },
2572
+ ], cwd, { OMX_TEAM_SCALING_ENABLED: '1', OMX_TEAM_SKIP_READY_WAIT: '1' });
2573
+ assert.equal(result.ok, false);
2574
+ if (!result.ok)
2575
+ assert.match(result.error, /^scale_up_rollback_cleanup_debt:pane_teardown_authority_lost:%31/);
2576
+ assert.equal(await readFile(splitCountPath, 'utf-8'), '2');
2577
+ const config = await readTeamConfig('rollback-kill-fail', cwd);
2578
+ assert.deepEqual(config?.workers.map((worker) => worker.name), ['worker-1', 'worker-2']);
2579
+ assert.equal(config?.worker_count, 2);
2580
+ assert.equal(config?.next_worker_index, 3);
2581
+ assert.equal((await readTask('rollback-kill-fail', '1', cwd))?.owner, 'worker-2');
2582
+ assert.equal(await readTask('rollback-kill-fail', '2', cwd), null);
2583
+ assert.equal(await readTask('rollback-kill-fail', '3', cwd), null);
2584
+ const worker2Dir = join(cwd, '.omx', 'state', 'team', 'rollback-kill-fail', 'workers', 'worker-2');
2585
+ assert.equal(existsSync(workerStartupScriptPath(cwd, 'rollback-kill-fail', 'worker-2')), true);
2586
+ assert.equal(existsSync(join(cwd, '.omx', 'team', 'rollback-kill-fail', 'worktrees', 'worker-2')), true);
2587
+ assert.equal(existsSync(worker2Dir), true, 'worker-2 materialized state must remain retryable');
2588
+ assert.equal(existsSync(join(worker2Dir, 'identity.json')), true, 'worker-2 identity must remain');
2589
+ assert.equal(existsSync(join(worker2Dir, 'inbox.md')), true, 'worker-2 inbox must remain');
2590
+ const tmuxCommands = await readScaleUpTmuxLogCommands(tmuxLogPath);
2591
+ // The first rollback cleanup transaction fails closed on %31 authority; later panes are never touched.
2592
+ assert.ok(tmuxCommands.some((command) => isGuardedScaleMutation(command, 'kill-pane', '%31')));
2593
+ assert.equal(tmuxCommands.some((command) => command === 'kill-pane -t %32'), false);
2594
+ }
2595
+ finally {
2596
+ if (typeof previousPath === 'string')
2597
+ process.env.PATH = previousPath;
2598
+ else
2599
+ delete process.env.PATH;
2600
+ await rm(cwd, { recursive: true, force: true });
2601
+ await rm(fakeBinDir, { recursive: true, force: true });
2602
+ }
2603
+ });
2604
+ for (const rollbackFailurePhase of ['rollback-membership-config-persistence', 'rollback-membership-manifest-persistence']) {
2605
+ it(`recovers and raw-verifies original membership after public scaleUp ${rollbackFailurePhase}`, async () => {
2606
+ const cwd = await mkdtemp(join(tmpdir(), `omx-scale-up-${rollbackFailurePhase}-`));
2607
+ const fakeBinDir = await mkdtemp(join(tmpdir(), `omx-scale-up-${rollbackFailurePhase}-bin-`));
2608
+ const tmuxLogPath = join(fakeBinDir, 'tmux.log');
2609
+ const previousPath = process.env.PATH;
2610
+ const teamName = `scale-up-${rollbackFailurePhase.replace('rollback-membership-', '').replace('-persistence', '')}`;
2611
+ try {
2612
+ await writeSuccessfulScaleUpTmuxStub(fakeBinDir, tmuxLogPath);
2613
+ process.env.PATH = `${fakeBinDir}:${previousPath ?? ''}`;
2614
+ await initTeamState(teamName, 'task', 'executor', 1, cwd);
2615
+ await configureScaleUpTeamForDirectDispatch(teamName, cwd);
2616
+ const teamDir = join(cwd, '.omx', 'state', 'team', teamName);
2617
+ const configPath = join(teamDir, 'config.json');
2618
+ const manifestPath = join(teamDir, 'manifest.v2.json');
2619
+ const originalConfigBytes = await readFile(configPath, 'utf8');
2620
+ const originalManifestBytes = await readFile(manifestPath, 'utf8');
2621
+ const result = await scaleUp(teamName, 1, 'executor', [{ subject: 'public rollback recovery', description: 'must be removed', owner: 'worker-2' }], cwd, {
2622
+ OMX_TEAM_SCALING_ENABLED: '1',
2623
+ OMX_TEAM_SKIP_READY_WAIT: '1',
2624
+ OMX_TEAM_SCALE_UP_INJECT_FAILURE: `finalization,${rollbackFailurePhase}`,
2625
+ });
2626
+ assert.equal(result.ok, false);
2627
+ if (!result.ok)
2628
+ assert.match(result.error, /scale_up_rollback_membership_persistence_failed:.*rollback-persistence-failure/);
2629
+ await readTeamConfig(teamName, cwd);
2630
+ assert.deepEqual(withoutConfigGeneration(await readFile(configPath, 'utf8')), withoutConfigGeneration(originalConfigBytes));
2631
+ assert.deepEqual(withoutConfigGeneration(await readFile(manifestPath, 'utf8')), withoutConfigGeneration(originalManifestBytes));
2632
+ assert.equal(existsSync(join(teamDir, '.membership-task-transaction.json')), false);
2633
+ assert.equal(await readTask(teamName, '1', cwd), null);
2634
+ assert.deepEqual(await listDispatchRequests(teamName, cwd), []);
2635
+ assert.equal(existsSync(join(teamDir, 'workers', 'worker-2')), true);
2636
+ assert.equal(existsSync(workerStartupScriptPath(cwd, teamName, 'worker-2')), true);
2637
+ }
2638
+ finally {
2639
+ if (typeof previousPath === 'string')
2640
+ process.env.PATH = previousPath;
2641
+ else
2642
+ delete process.env.PATH;
2643
+ await rm(cwd, { recursive: true, force: true });
2644
+ await rm(fakeBinDir, { recursive: true, force: true });
2645
+ }
2646
+ });
2647
+ }
2648
+ for (const phase of ['identity', 'inbox', 'config']) {
2649
+ it(`rolls back live pane and materialized state when ${phase} materialization fails`, async () => {
2650
+ const cwd = await mkdtemp(join(tmpdir(), `omx-scale-up-${phase}-failure-`));
2651
+ const fakeBinDir = await mkdtemp(join(tmpdir(), `omx-scale-up-${phase}-failure-bin-`));
2652
+ const tmuxLogPath = join(fakeBinDir, 'tmux.log');
2653
+ const previousPath = process.env.PATH;
2654
+ try {
2655
+ await writeSuccessfulScaleUpTmuxStub(fakeBinDir, tmuxLogPath);
2656
+ process.env.PATH = `${fakeBinDir}:${previousPath ?? ''}`;
2657
+ const teamName = `scale-up-${phase}-failure`;
2658
+ await initTeamState(teamName, 'task', 'executor', 1, cwd);
2659
+ await configureScaleUpTeamForDirectDispatch(teamName, cwd);
2660
+ const result = await scaleUp(teamName, 1, 'executor', [{ subject: `${phase} rollback`, description: 'must be removed', owner: 'worker-2' }], cwd, {
2661
+ OMX_TEAM_SCALING_ENABLED: '1',
2662
+ OMX_TEAM_SKIP_READY_WAIT: '1',
2663
+ OMX_TEAM_SCALE_UP_INJECT_FAILURE: phase,
2664
+ });
2665
+ assert.equal(result.ok, false);
2666
+ if (!result.ok)
2667
+ assert.match(result.error, new RegExp(`scale_up_worker_materialization_failed:worker-2:.*${phase}`));
2668
+ assert.equal((await readTeamConfig(teamName, cwd))?.workers.length, 1);
2669
+ assert.equal(await readTask(teamName, '1', cwd), null);
2670
+ assert.equal(existsSync(join(cwd, '.omx', 'state', 'team', teamName, 'workers', 'worker-2')), false);
2671
+ assert.equal(existsSync(workerStartupScriptPath(cwd, teamName, 'worker-2')), false);
2672
+ const tmuxCommands = await readScaleUpTmuxLogCommands(tmuxLogPath);
2673
+ assert.equal(guardedScaleSplitCount(tmuxCommands), 1);
2674
+ assert.ok(tmuxCommands.some((command) => isGuardedScaleMutation(command, 'kill-pane', '%31')));
2675
+ }
2676
+ finally {
2677
+ if (typeof previousPath === 'string')
2678
+ process.env.PATH = previousPath;
2679
+ else
2680
+ delete process.env.PATH;
2681
+ await rm(cwd, { recursive: true, force: true });
2682
+ await rm(fakeBinDir, { recursive: true, force: true });
2683
+ }
2684
+ });
2685
+ }
2686
+ it('rolls back a named worktree created before injected ensure bookkeeping fails', async () => {
2687
+ const repo = await initRepo();
2688
+ const fakeBinDir = await mkdtemp(join(tmpdir(), 'omx-scale-up-named-post-create-bin-'));
2689
+ const tmuxLogPath = join(fakeBinDir, 'tmux.log');
2690
+ const previousPath = process.env.PATH;
2691
+ const teamName = 'named-post-create-failure';
2692
+ const branchName = 'feature/named-post-create/worker-2';
2693
+ const worktreePath = join(repo, '.omx', 'team', teamName, 'worktrees', 'worker-2');
2694
+ try {
2695
+ await writeSuccessfulScaleUpTmuxStub(fakeBinDir, tmuxLogPath);
2696
+ process.env.PATH = `${fakeBinDir}:${previousPath ?? ''}`;
2697
+ await initTeamState(teamName, 'task', 'executor', 1, repo, undefined, process.env, {
2698
+ workspace_mode: 'worktree',
2699
+ leader_cwd: repo,
2700
+ team_state_root: join(repo, '.omx', 'state'),
2701
+ worktree_mode: { enabled: true, detached: false, name: 'feature/named-post-create' },
2702
+ });
2703
+ await configureScaleUpTeamForDirectDispatch(teamName, repo);
2704
+ const result = await scaleUp(teamName, 1, 'executor', [], repo, {
2705
+ OMX_TEAM_SCALING_ENABLED: '1',
2706
+ OMX_TEAM_SKIP_READY_WAIT: '1',
2707
+ OMX_TEAM_SCALE_UP_INJECT_FAILURE: 'worktree-ensure-post-create',
2708
+ });
2709
+ assert.equal(result.ok, false);
2710
+ if (!result.ok)
2711
+ assert.match(result.error, /scale_up_worker_preparation_failed:worker-2:.*worktree-ensure-post-create/);
2712
+ assert.equal(existsSync(worktreePath), false);
2713
+ assert.throws(() => execFileSync('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`], { cwd: repo }));
2714
+ assert.equal((await readTeamConfig(teamName, repo))?.workers.length, 1);
2715
+ assert.equal(existsSync(join(repo, '.omx', 'state', 'team', teamName, 'workers', 'worker-2')), false);
2716
+ assert.deepEqual((await readScaleUpTmuxLogCommands(tmuxLogPath)).filter((command) => isGuardedScaleMutation(command, 'split-window')), []);
2717
+ }
2718
+ finally {
2719
+ if (typeof previousPath === 'string')
2720
+ process.env.PATH = previousPath;
2721
+ else
2722
+ delete process.env.PATH;
2723
+ await rm(repo, { recursive: true, force: true });
2724
+ await rm(fakeBinDir, { recursive: true, force: true });
2725
+ }
2726
+ });
2727
+ });
2728
+ describe('scaleDown', () => {
2729
+ it('rejects when scaling is disabled', async () => {
2730
+ await assert.rejects(scaleDown('test', '/tmp', {}, {}), /Dynamic scaling is disabled/);
2731
+ });
2732
+ it('returns error when team not found', async () => {
2733
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-scale-down-nf-'));
2734
+ try {
2735
+ const result = await scaleDown('nonexistent', cwd, {}, { OMX_TEAM_SCALING_ENABLED: '1' });
2736
+ assert.equal(result.ok, false);
2737
+ if (!result.ok) {
2738
+ assert.match(result.error, /not found/);
2739
+ }
2740
+ }
2741
+ finally {
2742
+ await rm(cwd, { recursive: true, force: true });
2743
+ }
2744
+ });
2745
+ it('returns error when trying to remove all workers', async () => {
2746
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-scale-down-all-'));
2747
+ try {
2748
+ await initTeamState('all-test', 'task', 'executor', 1, cwd);
2749
+ const result = await scaleDown('all-test', cwd, { workerNames: ['worker-1'] }, { OMX_TEAM_SCALING_ENABLED: '1' });
2750
+ assert.equal(result.ok, false);
2751
+ if (!result.ok) {
2752
+ assert.match(result.error, /at least 1 must remain/);
2753
+ }
2754
+ }
2755
+ finally {
2756
+ await rm(cwd, { recursive: true, force: true });
2757
+ }
2758
+ });
2759
+ it('returns error for worker not in team', async () => {
2760
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-scale-down-miss-'));
2761
+ try {
2762
+ await initTeamState('miss-test', 'task', 'executor', 2, cwd);
1771
2763
  const result = await scaleDown('miss-test', cwd, { workerNames: ['worker-99'] }, { OMX_TEAM_SCALING_ENABLED: '1' });
1772
2764
  assert.equal(result.ok, false);
1773
2765
  if (!result.ok) {
@@ -1803,6 +2795,71 @@ describe('scaleDown', () => {
1803
2795
  await rm(cwd, { recursive: true, force: true });
1804
2796
  }
1805
2797
  });
2798
+ it('serializes a concurrent claim across the canonical scale-down boundary', async () => {
2799
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-scale-down-claim-boundary-'));
2800
+ try {
2801
+ await initTeamState('claim-boundary', 'task', 'executor', 2, cwd);
2802
+ const task = await createTask('claim-boundary', {
2803
+ subject: 'boundary task', description: 'must not be claimed by a removed worker', status: 'pending', owner: 'worker-2',
2804
+ }, cwd);
2805
+ const down = scaleDown('claim-boundary', cwd, { workerNames: ['worker-2'], force: true }, {
2806
+ OMX_TEAM_SCALING_ENABLED: '1',
2807
+ OMX_TEAM_SCALE_DOWN_BOUNDARY_HOLD_MS: '100',
2808
+ });
2809
+ const lockPath = join(cwd, '.omx', 'state', 'team', 'claim-boundary', 'claims', `task-${task.id}.lock`);
2810
+ for (let attempt = 0; attempt < 50 && !existsSync(lockPath); attempt += 1) {
2811
+ await new Promise((resolve) => setTimeout(resolve, 5));
2812
+ }
2813
+ assert.equal(existsSync(lockPath), true);
2814
+ const claim = claimTask('claim-boundary', task.id, 'worker-2', task.version ?? 1, cwd);
2815
+ assert.deepEqual(await down, { ok: true, removedWorkers: ['worker-2'], newWorkerCount: 1 });
2816
+ assert.deepEqual(await claim, { ok: false, error: 'claim_conflict' });
2817
+ const reconciled = await readTask('claim-boundary', task.id, cwd);
2818
+ assert.equal(reconciled?.owner, undefined);
2819
+ assert.equal(reconciled?.claim, undefined);
2820
+ }
2821
+ finally {
2822
+ await rm(cwd, { recursive: true, force: true });
2823
+ }
2824
+ });
2825
+ it('blocks post-snapshot task creation and null-version claims until removed membership is committed', async () => {
2826
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-scale-down-create-claim-boundary-'));
2827
+ try {
2828
+ await initTeamState('create-claim-boundary', 'task', 'executor', 2, cwd);
2829
+ const seed = await createTask('create-claim-boundary', {
2830
+ subject: 'snapshot marker', description: 'holds the canonical task lock', status: 'pending', owner: 'worker-2',
2831
+ }, cwd);
2832
+ const postSnapshotMarker = join(cwd, 'post-snapshot-held');
2833
+ const down = scaleDown('create-claim-boundary', cwd, { workerNames: ['worker-2'], force: true }, {
2834
+ OMX_TEAM_SCALING_ENABLED: '1',
2835
+ OMX_TEAM_SCALE_DOWN_BOUNDARY_HOLD_MS: '250',
2836
+ OMX_TEAM_SCALE_DOWN_POST_SNAPSHOT_MARKER: postSnapshotMarker,
2837
+ });
2838
+ for (let attempt = 0; attempt < 50 && !existsSync(postSnapshotMarker); attempt += 1) {
2839
+ await new Promise((resolve) => setTimeout(resolve, 5));
2840
+ }
2841
+ assert.equal(existsSync(postSnapshotMarker), true);
2842
+ let createSettled = false;
2843
+ let claimSettled = false;
2844
+ const created = createTask('create-claim-boundary', {
2845
+ subject: 'created after snapshot', description: 'must not restore removed membership', status: 'pending', owner: 'worker-2',
2846
+ }, cwd).finally(() => { createSettled = true; });
2847
+ const claim = claimTask('create-claim-boundary', seed.id, 'worker-2', null, cwd)
2848
+ .finally(() => { claimSettled = true; });
2849
+ await new Promise((resolve) => setTimeout(resolve, 25));
2850
+ assert.equal(createSettled, false);
2851
+ assert.equal(claimSettled, false);
2852
+ assert.deepEqual(await down, { ok: true, removedWorkers: ['worker-2'], newWorkerCount: 1 });
2853
+ assert.deepEqual(await claim, { ok: false, error: 'worker_not_found' });
2854
+ const task = await created;
2855
+ assert.equal(task.owner, 'worker-2');
2856
+ assert.deepEqual((await readTeamConfig('create-claim-boundary', cwd))?.workers.map((worker) => worker.name), ['worker-1']);
2857
+ assert.equal((await readTask('create-claim-boundary', task.id, cwd))?.owner, 'worker-2');
2858
+ }
2859
+ finally {
2860
+ await rm(cwd, { recursive: true, force: true });
2861
+ }
2862
+ });
1806
2863
  });
1807
2864
  describe('scaleDown worktree AGENTS cleanup', () => {
1808
2865
  it('removes generated worktree-root AGENTS during scale-down', async () => {
@@ -1838,15 +2895,47 @@ describe('scaleDown worktree AGENTS cleanup', () => {
1838
2895
  });
1839
2896
  });
1840
2897
  describe('scaleDown teardown hardening', () => {
1841
- it('scaleDown removes workers when pane is already dead or missing', async () => {
2898
+ it('scaleDown removes a worker with a pane proven dead without targeting it', async () => {
1842
2899
  const cwd = await mkdtemp(join(tmpdir(), 'omx-scale-down-dead-'));
2900
+ const fakeBinDir = await mkdtemp(join(tmpdir(), 'omx-scale-down-dead-bin-'));
2901
+ const tmuxLogPath = join(fakeBinDir, 'tmux.log');
2902
+ const tmuxStubPath = join(fakeBinDir, 'tmux');
2903
+ const previousPath = process.env.PATH;
1843
2904
  try {
2905
+ await writeFile(tmuxStubPath, `#!/bin/sh
2906
+ set -eu
2907
+ printf '%s\n' "$*" >> "${tmuxLogPath}"
2908
+ case "\${1:-}" in
2909
+ show-option) echo 'team:scale-down' ;;
2910
+ list-panes)
2911
+ case "$*" in
2912
+ *"-a -F #{pane_id}"*)
2913
+ printf '%%405\t1\t4050\n'
2914
+ ;;
2915
+ *)
2916
+ exit 1
2917
+ ;;
2918
+ esac
2919
+ ;;
2920
+ kill-pane)
2921
+ echo "a proven-dead pane must not be targeted" >&2
2922
+ exit 1
2923
+ ;;
2924
+ *)
2925
+ exit 0
2926
+ ;;
2927
+ esac
2928
+ `);
2929
+ await chmod(tmuxStubPath, 0o755);
2930
+ process.env.PATH = `${fakeBinDir}:${previousPath ?? ''}`;
1844
2931
  await initTeamState('dead-pane', 'task', 'executor', 2, cwd);
1845
2932
  const config = await readTeamConfig('dead-pane', cwd);
1846
2933
  assert.ok(config);
1847
2934
  if (!config)
1848
2935
  return;
1849
- config.workers[1].pane_id = '%404';
2936
+ config.workers[1].pane_id = '%405';
2937
+ config.workers[1].pid = 424405;
2938
+ config.tmux_pane_owner_id = 'team:scale-down';
1850
2939
  await saveTeamConfig(config, cwd);
1851
2940
  const result = await scaleDown('dead-pane', cwd, { workerNames: ['worker-2'], force: true }, { OMX_TEAM_SCALING_ENABLED: '1' });
1852
2941
  assert.equal(result.ok, true);
@@ -1856,9 +2945,81 @@ describe('scaleDown teardown hardening', () => {
1856
2945
  const updated = await readTeamConfig('dead-pane', cwd);
1857
2946
  assert.ok(updated);
1858
2947
  assert.equal(updated?.workers.some((worker) => worker.name === 'worker-2'), false);
2948
+ const tmuxCommands = await readScaleUpTmuxLogCommands(tmuxLogPath);
2949
+ assert.deepEqual(tmuxCommands, [
2950
+ 'list-panes -a -F #{pane_id}\t#{pane_dead}\t#{pane_pid}',
2951
+ 'list-panes -a -F #{pane_id}\t#{pane_dead}\t#{pane_pid}',
2952
+ ]);
2953
+ }
2954
+ finally {
2955
+ if (typeof previousPath === 'string')
2956
+ process.env.PATH = previousPath;
2957
+ else
2958
+ delete process.env.PATH;
2959
+ await rm(cwd, { recursive: true, force: true });
2960
+ await rm(fakeBinDir, { recursive: true, force: true });
2961
+ }
2962
+ });
2963
+ it('retains PID-less rollback debt when a recycled same-Team pane remains live', async () => {
2964
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-scale-down-unpinned-recycled-'));
2965
+ const fakeBinDir = await mkdtemp(join(tmpdir(), 'omx-scale-down-unpinned-recycled-bin-'));
2966
+ const tmuxLogPath = join(fakeBinDir, 'tmux.log');
2967
+ const tmuxStubPath = join(fakeBinDir, 'tmux');
2968
+ const previousPath = process.env.PATH;
2969
+ try {
2970
+ await writeFile(tmuxStubPath, `#!/bin/sh
2971
+ set -eu
2972
+ printf '%s\\n' "$*" >> "${tmuxLogPath}"
2973
+ case "\${1:-}" in
2974
+ list-panes) printf '%%405\\t0\\t999405\\n' ;;
2975
+ show-option|kill-pane) echo "PID-less debt must not authorize an effect" >&2; exit 99 ;;
2976
+ esac
2977
+ `);
2978
+ await chmod(tmuxStubPath, 0o755);
2979
+ process.env.PATH = `${fakeBinDir}:${previousPath ?? ''}`;
2980
+ await initTeamState('unpinned-recycled', 'task', 'executor', 2, cwd);
2981
+ const config = await readTeamConfig('unpinned-recycled', cwd);
2982
+ assert.ok(config);
2983
+ if (!config)
2984
+ return;
2985
+ config.workers[1].pane_id = '%405';
2986
+ config.workers[1].pid = undefined;
2987
+ config.tmux_pane_owner_id = 'team:scale-down';
2988
+ await saveTeamConfig(config, cwd);
2989
+ const initial = await scaleDown('unpinned-recycled', cwd, { workerNames: ['worker-2'], force: true }, {
2990
+ OMX_TEAM_SCALING_ENABLED: '1',
2991
+ });
2992
+ assert.deepEqual(initial, {
2993
+ ok: false,
2994
+ error: 'scale_down_cleanup_debt:pane_teardown_unresolved:%405',
2995
+ });
2996
+ const debtPath = join(cwd, '.omx', 'state', 'team', 'unpinned-recycled', '.scale-down-cleanup-debt.json');
2997
+ const initialDebt = JSON.parse(await readFile(debtPath, 'utf8'));
2998
+ assert.deepEqual(initialDebt.unresolved_panes, [{ name: 'worker-2', index: 2, pane_id: '%405', pid: null }]);
2999
+ assert.ok(initialDebt.reasons.includes('%405:legacy_pid_missing_live'));
3000
+ const canonical = await readTeamConfig('unpinned-recycled', cwd);
3001
+ assert.ok(canonical);
3002
+ if (!canonical)
3003
+ return;
3004
+ assert.deepEqual(await reconcileScaleDownCleanupDebt('unpinned-recycled', cwd, canonical), {
3005
+ ok: false,
3006
+ error: 'scale_down_cleanup_debt_unresolved:%405',
3007
+ });
3008
+ const reconciledDebt = JSON.parse(await readFile(debtPath, 'utf8'));
3009
+ assert.ok(reconciledDebt.reasons.includes('%405:legacy_pid_missing_live'));
3010
+ const commands = await readScaleUpTmuxLogCommands(tmuxLogPath);
3011
+ assert.deepEqual(commands, [
3012
+ 'list-panes -a -F #{pane_id}\t#{pane_dead}\t#{pane_pid}',
3013
+ 'list-panes -a -F #{pane_id}\t#{pane_dead}\t#{pane_pid}',
3014
+ ]);
1859
3015
  }
1860
3016
  finally {
3017
+ if (typeof previousPath === 'string')
3018
+ process.env.PATH = previousPath;
3019
+ else
3020
+ delete process.env.PATH;
1861
3021
  await rm(cwd, { recursive: true, force: true });
3022
+ await rm(fakeBinDir, { recursive: true, force: true });
1862
3023
  }
1863
3024
  });
1864
3025
  it('scaleDown never targets leader or hud panes during teardown', async () => {
@@ -1871,6 +3032,22 @@ describe('scaleDown teardown hardening', () => {
1871
3032
  await writeFile(tmuxStubPath, `#!/bin/sh
1872
3033
  set -eu
1873
3034
  printf '%s\\n' "$*" >> "${tmuxLogPath}"
3035
+ case "\${1:-}" in
3036
+ show-option) echo 'team:scale-down' ;;
3037
+ list-panes)
3038
+ case "$*" in
3039
+ *"#{pane_id}\t#{pane_dead}\t#{pane_pid}"*)
3040
+ printf '%s\t%s\t%s\n' '%11' '0' '42411'
3041
+ printf '%s\t%s\t%s\n' '%12' '0' '42412'
3042
+ if [ ! -f "${tmuxLogPath}.killed-%13" ]; then printf '%s\t%s\t%s\n' '%13' '0' '42413'; fi
3043
+ printf '%s\t%s\t%s\n' '%14' '0' '42414'
3044
+ ;;
3045
+ esac
3046
+ ;;
3047
+ kill-pane)
3048
+ : > "${tmuxLogPath}.killed-$3"
3049
+ ;;
3050
+ esac
1874
3051
  exit 0
1875
3052
  `);
1876
3053
  await writeFile(tmuxLogPath, '');
@@ -1884,16 +3061,1041 @@ exit 0
1884
3061
  config.leader_pane_id = '%11';
1885
3062
  config.hud_pane_id = '%12';
1886
3063
  config.workers[0].pane_id = '%11';
3064
+ config.workers[0].pid = 42411;
1887
3065
  config.workers[1].pane_id = '%12';
3066
+ config.workers[1].pid = 42412;
1888
3067
  config.workers[2].pane_id = '%13';
3068
+ config.workers[2].pid = 42413;
1889
3069
  config.workers[3].pane_id = '%14';
3070
+ config.workers[3].pid = 42414;
3071
+ config.tmux_pane_owner_id = 'team:scale-down';
1890
3072
  await saveTeamConfig(config, cwd);
1891
3073
  const result = await scaleDown('exclusions', cwd, { workerNames: ['worker-1', 'worker-2', 'worker-3'], force: true }, { OMX_TEAM_SCALING_ENABLED: '1' });
1892
- assert.equal(result.ok, true);
1893
- const tmuxLog = await readFile(tmuxLogPath, 'utf-8');
1894
- assert.doesNotMatch(tmuxLog, /kill-pane -t %11/);
1895
- assert.doesNotMatch(tmuxLog, /kill-pane -t %12/);
1896
- assert.match(tmuxLog, /kill-pane -t %13/);
3074
+ assert.equal(result.ok, false);
3075
+ if (!result.ok)
3076
+ assert.match(result.error, /pane_teardown_unresolved:%11.*pane_teardown_unresolved:%12/);
3077
+ assert.deepEqual((await readTeamConfig('exclusions', cwd))?.workers.map((worker) => worker.name), ['worker-4']);
3078
+ const tmuxCommands = (await readFile(tmuxLogPath, 'utf-8')).trim().split('\n');
3079
+ assert.deepEqual(tmuxCommands, [
3080
+ 'list-panes -a -F #{pane_id}\t#{pane_dead}\t#{pane_pid}',
3081
+ 'show-option -qv -p -t %13 @omx_team_pane_owner_id',
3082
+ 'list-panes -a -F #{pane_id}\t#{pane_dead}\t#{pane_pid}',
3083
+ 'kill-pane -t %13',
3084
+ 'list-panes -a -F #{pane_id}\t#{pane_dead}\t#{pane_pid}',
3085
+ ]);
3086
+ }
3087
+ finally {
3088
+ if (typeof previousPath === 'string')
3089
+ process.env.PATH = previousPath;
3090
+ else
3091
+ delete process.env.PATH;
3092
+ await rm(cwd, { recursive: true, force: true });
3093
+ await rm(fakeBinDir, { recursive: true, force: true });
3094
+ }
3095
+ });
3096
+ it('scaleDown commits forward membership and retains artifacts/debt when pane ID is reused', async () => {
3097
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-scale-down-kill-fail-'));
3098
+ const fakeBinDir = await mkdtemp(join(tmpdir(), 'omx-scale-down-kill-fail-bin-'));
3099
+ const tmuxLogPath = join(fakeBinDir, 'tmux.log');
3100
+ const tmuxStubPath = join(fakeBinDir, 'tmux');
3101
+ const previousPath = process.env.PATH;
3102
+ try {
3103
+ await writeFile(tmuxStubPath, `#!/bin/sh
3104
+ set -eu
3105
+ printf '%s\\n' "$*" >> "${tmuxLogPath}"
3106
+ case "\${1:-}" in
3107
+ show-option) echo 'team:scale-down' ;;
3108
+ list-panes)
3109
+ if [ -f "${cwd}/pane-reused" ]; then
3110
+ printf '%s\t%s\t%s\n' '%13' '0' '42414'
3111
+ else
3112
+ printf '%s\t%s\t%s\n' '%13' '0' '42413'
3113
+ fi
3114
+ ;;
3115
+ kill-pane)
3116
+ : > "${cwd}/pane-reused"
3117
+ exit 0
3118
+ ;;
3119
+ esac
3120
+ exit 0
3121
+ `);
3122
+ await chmod(tmuxStubPath, 0o755);
3123
+ process.env.PATH = `${fakeBinDir}:${previousPath ?? ''}`;
3124
+ await initTeamState('kill-fail', 'task', 'executor', 2, cwd);
3125
+ const config = await readTeamConfig('kill-fail', cwd);
3126
+ assert.ok(config);
3127
+ if (!config)
3128
+ return;
3129
+ config.workers[1].pane_id = '%13';
3130
+ config.workers[1].pid = 42413;
3131
+ const worktreePath = join(cwd, '.omx', 'team', 'kill-fail', 'worktrees', 'worker-2');
3132
+ await mkdir(worktreePath, { recursive: true });
3133
+ await writeFile(join(worktreePath, 'keep.txt'), 'retryable worktree');
3134
+ config.workers[1].worktree_path = worktreePath;
3135
+ const workerDir = join(cwd, '.omx', 'state', 'team', 'kill-fail', 'workers', 'worker-2');
3136
+ await writeFile(join(workerDir, 'identity.json'), '{"worker":"worker-2"}');
3137
+ await writeFile(join(workerDir, 'inbox.md'), 'retryable inbox');
3138
+ await mkdir(join(cwd, '.omx', 'state', 'team', 'kill-fail', 'runtime'), { recursive: true });
3139
+ const startupScriptPath = workerStartupScriptPath(cwd, 'kill-fail', 'worker-2');
3140
+ await writeFile(startupScriptPath, '#!/bin/sh\n');
3141
+ config.tmux_pane_owner_id = 'team:scale-down';
3142
+ await saveTeamConfig(config, cwd);
3143
+ const priorStatus = {
3144
+ state: 'idle',
3145
+ reason: 'waiting for assignment',
3146
+ updated_at: '2026-07-14T00:00:00.000Z',
3147
+ };
3148
+ await writeWorkerStatus('kill-fail', 'worker-2', priorStatus, cwd);
3149
+ const result = await scaleDown('kill-fail', cwd, { workerNames: ['worker-2'], force: true }, { OMX_TEAM_SCALING_ENABLED: '1' });
3150
+ assert.equal(result.ok, false);
3151
+ if (!result.ok)
3152
+ assert.match(result.error, /^scale_down_cleanup_debt:pane_teardown_failed:%13/);
3153
+ const committed = await readTeamConfig('kill-fail', cwd);
3154
+ assert.deepEqual(committed?.workers.map((worker) => worker.name), ['worker-1']);
3155
+ assert.equal(existsSync(join(cwd, '.omx', 'state', 'team', 'kill-fail', 'workers', 'worker-2')), true);
3156
+ assert.equal(await readFile(join(workerDir, 'identity.json'), 'utf8'), '{"worker":"worker-2"}');
3157
+ assert.equal(await readFile(join(workerDir, 'inbox.md'), 'utf8'), 'retryable inbox');
3158
+ assert.equal(await readFile(join(worktreePath, 'keep.txt'), 'utf8'), 'retryable worktree');
3159
+ assert.equal(await readFile(startupScriptPath, 'utf8'), '#!/bin/sh\n');
3160
+ assert.deepEqual(await readWorkerStatus('kill-fail', 'worker-2', cwd), priorStatus);
3161
+ const debt = JSON.parse(await readFile(join(cwd, '.omx', 'state', 'team', 'kill-fail', '.scale-down-cleanup-debt.json'), 'utf8'));
3162
+ assert.equal(debt.status, 'unresolved');
3163
+ assert.equal(debt.unresolved_panes[0]?.pane_id, '%13');
3164
+ const tmuxCommands = await readScaleUpTmuxLogCommands(tmuxLogPath);
3165
+ assert.deepEqual(tmuxCommands, [
3166
+ 'list-panes -a -F #{pane_id}\t#{pane_dead}\t#{pane_pid}',
3167
+ 'show-option -qv -p -t %13 @omx_team_pane_owner_id',
3168
+ 'list-panes -a -F #{pane_id}\t#{pane_dead}\t#{pane_pid}',
3169
+ 'kill-pane -t %13',
3170
+ 'list-panes -a -F #{pane_id}\t#{pane_dead}\t#{pane_pid}',
3171
+ ]);
3172
+ }
3173
+ finally {
3174
+ if (typeof previousPath === 'string')
3175
+ process.env.PATH = previousPath;
3176
+ else
3177
+ delete process.env.PATH;
3178
+ await rm(cwd, { recursive: true, force: true });
3179
+ await rm(fakeBinDir, { recursive: true, force: true });
3180
+ }
3181
+ });
3182
+ it('aborts before pane teardown and preserves exact state when task reconciliation cannot acquire a lock', async () => {
3183
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-scale-down-task-reconcile-fail-'));
3184
+ const fakeBinDir = await mkdtemp(join(tmpdir(), 'omx-scale-down-task-reconcile-fail-bin-'));
3185
+ const tmuxStubPath = join(fakeBinDir, 'tmux');
3186
+ const tmuxLogPath = join(fakeBinDir, 'tmux.log');
3187
+ const previousPath = process.env.PATH;
3188
+ try {
3189
+ await writeFile(tmuxStubPath, `#!/bin/sh
3190
+ set -eu
3191
+ printf '%s\\n' "$*" >> "${tmuxLogPath}"
3192
+ case "\${1:-}" in
3193
+ show-option) echo 'team:scale-down' ;;
3194
+ list-panes) printf '%s\\t%s\\t%s\\n' '%11' '0' '42411' ;;
3195
+ esac
3196
+ `);
3197
+ await chmod(tmuxStubPath, 0o755);
3198
+ await writeFile(tmuxLogPath, '');
3199
+ process.env.PATH = `${fakeBinDir}:${previousPath ?? ''}`;
3200
+ await initTeamState('task-reconcile-fail', 'task', 'executor', 2, cwd);
3201
+ const config = await readTeamConfig('task-reconcile-fail', cwd);
3202
+ assert.ok(config);
3203
+ if (!config)
3204
+ return;
3205
+ config.workers[1].pane_id = '%13';
3206
+ config.workers[1].pid = 42413;
3207
+ config.tmux_pane_owner_id = 'team:scale-down';
3208
+ await saveTeamConfig(config, cwd);
3209
+ const firstTask = await createTask('task-reconcile-fail', {
3210
+ subject: 'first reconciliation', description: 'must be restored after later failure', status: 'pending', owner: 'worker-2',
3211
+ }, cwd);
3212
+ const firstTaskPath = join(cwd, '.omx', 'state', 'team', 'task-reconcile-fail', 'tasks', `task-${firstTask.id}.json`);
3213
+ const firstTaskRaw = await readFile(firstTaskPath, 'utf-8');
3214
+ const task = await createTask('task-reconcile-fail', {
3215
+ subject: 'locked reconciliation', description: 'must remain exact', status: 'pending', owner: 'worker-2',
3216
+ }, cwd);
3217
+ const taskPath = join(cwd, '.omx', 'state', 'team', 'task-reconcile-fail', 'tasks', `task-${task.id}.json`);
3218
+ const taskRaw = await readFile(taskPath, 'utf-8');
3219
+ const priorConfig = structuredClone(await readTeamConfig('task-reconcile-fail', cwd));
3220
+ await mkdir(join(cwd, '.omx', 'state', 'team', 'task-reconcile-fail', 'claims', `task-${task.id}.lock`));
3221
+ const result = await scaleDown('task-reconcile-fail', cwd, { workerNames: ['worker-2'], force: true }, { OMX_TEAM_SCALING_ENABLED: '1' });
3222
+ assert.deepEqual(result, { ok: false, error: 'scale_down_task_reconciliation_failed:Error: Timed out acquiring task claim lock for task-reconcile-fail/2' });
3223
+ assert.deepEqual(await readTeamConfig('task-reconcile-fail', cwd), priorConfig);
3224
+ assert.equal(await readFile(taskPath, 'utf-8'), taskRaw);
3225
+ assert.equal(await readFile(firstTaskPath, 'utf-8'), firstTaskRaw);
3226
+ assert.equal(existsSync(join(cwd, '.omx', 'state', 'team', 'task-reconcile-fail', 'workers', 'worker-2')), true);
3227
+ assert.deepEqual(await readScaleUpTmuxLogCommands(tmuxLogPath), []);
3228
+ }
3229
+ finally {
3230
+ if (typeof previousPath === 'string')
3231
+ process.env.PATH = previousPath;
3232
+ else
3233
+ delete process.env.PATH;
3234
+ await rm(cwd, { recursive: true, force: true });
3235
+ await rm(fakeBinDir, { recursive: true, force: true });
3236
+ }
3237
+ });
3238
+ it('commits forward membership and durable debt when scale-down pane proof is unavailable', async () => {
3239
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-scale-down-proof-unavailable-'));
3240
+ const fakeBinDir = await mkdtemp(join(tmpdir(), 'omx-scale-down-proof-unavailable-bin-'));
3241
+ const tmuxStubPath = join(fakeBinDir, 'tmux');
3242
+ const previousPath = process.env.PATH;
3243
+ try {
3244
+ await writeFile(tmuxStubPath, '#!/bin/sh\n[ "$1" = list-panes ] && exit 1\nexit 0\n');
3245
+ await chmod(tmuxStubPath, 0o755);
3246
+ process.env.PATH = `${fakeBinDir}:${previousPath ?? ''}`;
3247
+ await initTeamState('proof-unavailable', 'task', 'executor', 2, cwd);
3248
+ const config = await readTeamConfig('proof-unavailable', cwd);
3249
+ assert.ok(config);
3250
+ if (!config)
3251
+ return;
3252
+ config.workers[1].pane_id = '%13';
3253
+ config.workers[1].pid = 42413;
3254
+ config.tmux_pane_owner_id = 'team:scale-down';
3255
+ await saveTeamConfig(config, cwd);
3256
+ const task = await createTask('proof-unavailable', {
3257
+ subject: 'preserved', description: 'exact proof is unavailable', status: 'pending', owner: 'worker-2',
3258
+ }, cwd);
3259
+ const statusPath = join(cwd, '.omx', 'state', 'team', 'proof-unavailable', 'workers', 'worker-2', 'status.json');
3260
+ const statusRaw = '{"state":"idle", "reason":"preserve bytes", "updated_at":"2026-07-14T00:00:00.000Z"}\n';
3261
+ await writeFile(statusPath, statusRaw);
3262
+ const result = await scaleDown('proof-unavailable', cwd, { workerNames: ['worker-2'], force: true }, {
3263
+ OMX_TEAM_SCALING_ENABLED: '1',
3264
+ });
3265
+ assert.equal(result.ok, false);
3266
+ assert.deepEqual((await readTeamConfig('proof-unavailable', cwd))?.workers.map((worker) => worker.name), ['worker-1']);
3267
+ assert.equal((await readTask('proof-unavailable', task.id, cwd))?.owner, undefined);
3268
+ assert.equal(await readFile(statusPath, 'utf8'), statusRaw);
3269
+ const debt = JSON.parse(await readFile(join(cwd, '.omx', 'state', 'team', 'proof-unavailable', '.scale-down-cleanup-debt.json'), 'utf8'));
3270
+ assert.equal(debt.status, 'unresolved');
3271
+ assert.equal(debt.unresolved_panes[0]?.pane_id, '%13');
3272
+ }
3273
+ finally {
3274
+ if (typeof previousPath === 'string')
3275
+ process.env.PATH = previousPath;
3276
+ else
3277
+ delete process.env.PATH;
3278
+ await rm(cwd, { recursive: true, force: true });
3279
+ await rm(fakeBinDir, { recursive: true, force: true });
3280
+ }
3281
+ });
3282
+ it('converges entirely old or entirely new after a mid-commit scale-down interruption', async () => {
3283
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-scale-down-atomic-'));
3284
+ const fakeBinDir = await mkdtemp(join(tmpdir(), 'omx-scale-down-atomic-bin-'));
3285
+ const tmuxStubPath = join(fakeBinDir, 'tmux');
3286
+ const previousPath = process.env.PATH;
3287
+ try {
3288
+ await writeFile(tmuxStubPath, `#!/bin/sh
3289
+ marker="${tmuxStubPath}.killed"
3290
+ if [ "$1" = list-panes ]; then
3291
+ [ -f "$marker" ] || printf '%s\t%s\t%s\n' '%13' '0' '42413'
3292
+ exit 0
3293
+ fi
3294
+ if [ "$1" = kill-pane ]; then : > "$marker"; exit 0; fi
3295
+ exit 0
3296
+ `);
3297
+ await chmod(tmuxStubPath, 0o755);
3298
+ process.env.PATH = `${fakeBinDir}:${previousPath ?? ''}`;
3299
+ await initTeamState('atomic-down', 'task', 'executor', 2, cwd);
3300
+ const config = await readTeamConfig('atomic-down', cwd);
3301
+ assert.ok(config);
3302
+ if (!config)
3303
+ return;
3304
+ config.workers[1].pane_id = '%13';
3305
+ config.workers[1].pid = 42413;
3306
+ config.tmux_pane_owner_id = 'team:scale-down';
3307
+ await saveTeamConfig(config, cwd);
3308
+ const first = await createTask('atomic-down', { subject: 'first', description: 'first', status: 'pending', owner: 'worker-2' }, cwd);
3309
+ const second = await createTask('atomic-down', { subject: 'second', description: 'second', status: 'pending', owner: 'worker-2' }, cwd);
3310
+ const result = await scaleDown('atomic-down', cwd, { workerNames: ['worker-2'], force: true }, {
3311
+ OMX_TEAM_SCALING_ENABLED: '1', OMX_TEAM_SCALE_DOWN_INJECT_FAILURE: 'after-first-task-write',
3312
+ });
3313
+ assert.match(result.ok ? '' : result.error, /injected_scale_down_interruption:after-first-task-write/);
3314
+ await recoverTeamMembershipTaskTransaction('atomic-down', cwd);
3315
+ const recoveredConfig = await readTeamConfig('atomic-down', cwd);
3316
+ const recoveredFirst = await readTask('atomic-down', first.id, cwd);
3317
+ const recoveredSecond = await readTask('atomic-down', second.id, cwd);
3318
+ const convergedOld = recoveredConfig?.workers.some((worker) => worker.name === 'worker-2') === true
3319
+ && recoveredFirst?.owner === 'worker-2'
3320
+ && recoveredSecond?.owner === 'worker-2';
3321
+ const convergedNew = recoveredConfig?.workers.some((worker) => worker.name === 'worker-2') === false
3322
+ && recoveredFirst?.owner === undefined
3323
+ && recoveredSecond?.owner === undefined;
3324
+ assert.equal(convergedOld || convergedNew, true);
3325
+ }
3326
+ finally {
3327
+ if (typeof previousPath === 'string')
3328
+ process.env.PATH = previousPath;
3329
+ else
3330
+ delete process.env.PATH;
3331
+ await rm(cwd, { recursive: true, force: true });
3332
+ await rm(fakeBinDir, { recursive: true, force: true });
3333
+ }
3334
+ });
3335
+ it('recovers rollback-persistence failure through public config reads with config, manifest, and tasks converged', async () => {
3336
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-scale-down-rollback-recovery-'));
3337
+ const fakeBinDir = await mkdtemp(join(tmpdir(), 'omx-scale-down-rollback-recovery-bin-'));
3338
+ const tmuxStubPath = join(fakeBinDir, 'tmux');
3339
+ const previousPath = process.env.PATH;
3340
+ try {
3341
+ await writeFile(tmuxStubPath, `#!/bin/sh
3342
+ marker="${tmuxStubPath}.killed"
3343
+ if [ "$1" = list-panes ]; then
3344
+ [ -f "$marker" ] || printf '%s\t%s\t%s\n' '%13' '0' '42413'
3345
+ exit 0
3346
+ fi
3347
+ if [ "$1" = kill-pane ]; then : > "$marker"; exit 0; fi
3348
+ exit 0
3349
+ `);
3350
+ await chmod(tmuxStubPath, 0o755);
3351
+ process.env.PATH = `${fakeBinDir}:${previousPath ?? ''}`;
3352
+ await initTeamState('rollback-recovery', 'task', 'executor', 2, cwd);
3353
+ const config = await readTeamConfig('rollback-recovery', cwd);
3354
+ assert.ok(config);
3355
+ if (!config)
3356
+ return;
3357
+ config.workers[1].pane_id = '%13';
3358
+ config.workers[1].pid = 42413;
3359
+ config.tmux_pane_owner_id = 'team:scale-down';
3360
+ await saveTeamConfig(config, cwd);
3361
+ const task = await createTask('rollback-recovery', { subject: 'owned', description: 'owned', status: 'pending', owner: 'worker-2' }, cwd);
3362
+ const failed = await scaleDown('rollback-recovery', cwd, { workerNames: ['worker-2'], force: true }, {
3363
+ OMX_TEAM_SCALING_ENABLED: '1', OMX_TEAM_SCALE_DOWN_INJECT_FAILURE: 'rollback-persistence-failure',
3364
+ });
3365
+ assert.match(failed.ok ? '' : failed.error, /rollback-persistence-failure/);
3366
+ const recoveredConfig = await readTeamConfig('rollback-recovery', cwd);
3367
+ const recoveredManifest = await readTeamManifestV2('rollback-recovery', cwd);
3368
+ const recoveredTask = await readTask('rollback-recovery', task.id, cwd);
3369
+ assert.equal(recoveredConfig?.workers.some((worker) => worker.name === 'worker-2'), false);
3370
+ assert.equal(recoveredManifest?.workers.some((worker) => worker.name === 'worker-2'), false);
3371
+ assert.equal(recoveredTask?.owner, undefined);
3372
+ }
3373
+ finally {
3374
+ if (typeof previousPath === 'string')
3375
+ process.env.PATH = previousPath;
3376
+ else
3377
+ delete process.env.PATH;
3378
+ await rm(cwd, { recursive: true, force: true });
3379
+ await rm(fakeBinDir, { recursive: true, force: true });
3380
+ }
3381
+ });
3382
+ for (const target of ['config', 'manifest']) {
3383
+ it(`publicly recovers a committed scale-up membership rollback after partial ${target} old-generation persistence`, async () => {
3384
+ const cwd = await mkdtemp(join(tmpdir(), `omx-scale-up-rollback-${target}-`));
3385
+ try {
3386
+ const teamName = `rollback-${target}`;
3387
+ await initTeamState(teamName, 'task', 'executor', 2, cwd);
3388
+ const task = await createTask(teamName, {
3389
+ subject: 'rollback owner', description: 'must converge with membership', status: 'pending', owner: 'worker-2',
3390
+ }, cwd);
3391
+ const teamDir = join(cwd, '.omx', 'state', 'team', teamName);
3392
+ const configPath = join(teamDir, 'config.json');
3393
+ const manifestPath = join(teamDir, 'manifest.v2.json');
3394
+ const taskPath = join(teamDir, 'tasks', `task-${task.id}.json`);
3395
+ const oldConfig = await readFile(configPath, 'utf8');
3396
+ const oldManifest = await readFile(manifestPath, 'utf8');
3397
+ const oldTask = await readFile(taskPath, 'utf8');
3398
+ const oldConfigValue = JSON.parse(oldConfig);
3399
+ const nextConfig = { ...oldConfigValue, workers: oldConfigValue.workers.filter((worker) => worker.name !== 'worker-2') };
3400
+ const nextManifest = { ...JSON.parse(oldManifest), workers: nextConfig.workers, worker_count: nextConfig.workers.length };
3401
+ const nextTask = { ...JSON.parse(oldTask), owner: undefined, claim: undefined };
3402
+ await assert.rejects(withTeamTaskBarrier(teamName, cwd, () => commitTeamMembershipTaskTransaction(teamName, cwd, {
3403
+ baseGeneration: 0,
3404
+ tasks: [{ taskId: task.id, oldBytes: oldTask, newBytes: JSON.stringify(nextTask, null, 2) }],
3405
+ config: { oldBytes: oldConfig, newBytes: JSON.stringify(nextConfig, null, 2) },
3406
+ manifest: { oldBytes: oldManifest, newBytes: JSON.stringify(nextManifest, null, 2) },
3407
+ recoverToNewOnFailure: true,
3408
+ failRollbackPersistenceAfter: target,
3409
+ })), /rollback-persistence-failure/);
3410
+ assert.equal(existsSync(join(teamDir, '.membership-task-transaction.json')), true);
3411
+ await recoverTeamMembershipTaskTransaction(teamName, cwd);
3412
+ assert.equal((await readTeamConfig(teamName, cwd))?.workers.some((worker) => worker.name === 'worker-2'), false);
3413
+ assert.equal((await readTeamManifestV2(teamName, cwd))?.workers.some((worker) => worker.name === 'worker-2'), false);
3414
+ assert.equal((await readTask(teamName, task.id, cwd))?.owner, undefined);
3415
+ }
3416
+ finally {
3417
+ await rm(cwd, { recursive: true, force: true });
3418
+ }
3419
+ });
3420
+ }
3421
+ it('discards precommit cleanup debt while removed workers remain canonical', async () => {
3422
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-scale-down-precommit-debt-'));
3423
+ try {
3424
+ await initTeamState('precommit-debt', 'task', 'executor', 2, cwd);
3425
+ const config = await readTeamConfig('precommit-debt', cwd);
3426
+ assert.ok(config);
3427
+ if (!config)
3428
+ return;
3429
+ const debtPath = join(cwd, '.omx', 'state', 'team', 'precommit-debt', '.scale-down-cleanup-debt.json');
3430
+ await writeFile(debtPath, JSON.stringify({
3431
+ schema_version: 1,
3432
+ operation: 'scale_down',
3433
+ status: 'pending_teardown',
3434
+ removed_worker_names: ['worker-2'],
3435
+ workers: [{ name: 'worker-2', index: 2, pane_id: '%13', pid: 42413 }],
3436
+ resource_workers: [{ name: 'worker-2' }],
3437
+ }));
3438
+ const recovered = await reconcileScaleDownCleanupDebt('precommit-debt', cwd, config);
3439
+ assert.deepEqual(recovered, { ok: true });
3440
+ assert.equal(existsSync(debtPath), false);
3441
+ assert.equal((await readTeamConfig('precommit-debt', cwd))?.workers.some((worker) => worker.name === 'worker-2'), true);
3442
+ }
3443
+ finally {
3444
+ await rm(cwd, { recursive: true, force: true });
3445
+ }
3446
+ });
3447
+ it('commits forward state and records unresolved debt when proof is lost after a pane kill', async () => {
3448
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-scale-down-success-proof-loss-'));
3449
+ const fakeBinDir = await mkdtemp(join(tmpdir(), 'omx-scale-down-success-proof-loss-bin-'));
3450
+ const tmuxStubPath = join(fakeBinDir, 'tmux');
3451
+ const tmuxLogPath = join(fakeBinDir, 'tmux.log');
3452
+ const proofLossMarkerPath = join(fakeBinDir, 'proof-loss');
3453
+ const previousPath = process.env.PATH;
3454
+ try {
3455
+ await writeFile(tmuxStubPath, `#!/bin/sh
3456
+ set -eu
3457
+ printf '%s\\n' "$*" >> "${tmuxLogPath}"
3458
+ case "\${1:-}" in
3459
+ show-option) echo 'team:scale-down' ;;
3460
+ list-panes)
3461
+ if [ -f "${proofLossMarkerPath}.recovery" ]; then
3462
+ if [ ! -f "${proofLossMarkerPath}.killed-13" ]; then printf '%s\t%s\t%s\n' '%13' '0' '42413'; fi
3463
+ if [ ! -f "${proofLossMarkerPath}.killed-14" ]; then printf '%s\t%s\t%s\n' '%14' '0' '42414'; fi
3464
+ else
3465
+ if [ -f "${proofLossMarkerPath}.killed" ]; then exit 1; fi
3466
+ printf '%s\t%s\t%s\n' '%13' '0' '42413'
3467
+ printf '%s\t%s\t%s\n' '%14' '0' '42414'
3468
+ fi
3469
+ ;;
3470
+ show-option) printf '%s\n' 'team:success-proof-loss' ;;
3471
+ kill-pane)
3472
+ if [ -f "${proofLossMarkerPath}.recovery" ]; then
3473
+ if [ "$3" = '%13' ]; then : > "${proofLossMarkerPath}.killed-13"; fi
3474
+ if [ "$3" = '%14' ]; then : > "${proofLossMarkerPath}.killed-14"; fi
3475
+ elif [ "$3" = '%13' ]; then
3476
+ : > "${proofLossMarkerPath}.killed"
3477
+ fi
3478
+ ;;
3479
+ esac
3480
+ exit 0
3481
+ `);
3482
+ await chmod(tmuxStubPath, 0o755);
3483
+ process.env.PATH = `${fakeBinDir}:${previousPath ?? ''}`;
3484
+ await initTeamState('success-proof-loss', 'task', 'executor', 3, cwd);
3485
+ const config = await readTeamConfig('success-proof-loss', cwd);
3486
+ assert.ok(config);
3487
+ if (!config)
3488
+ return;
3489
+ config.workers[1].pane_id = '%13';
3490
+ config.workers[1].pid = 42413;
3491
+ config.workers[2].pane_id = '%14';
3492
+ config.workers[2].pid = 42414;
3493
+ config.tmux_pane_owner_id = 'team:scale-down';
3494
+ await saveTeamConfig(config, cwd);
3495
+ await writeFile(join(cwd, '.omx', 'state', 'team', 'success-proof-loss', 'workers', 'worker-2', 'identity.json'), JSON.stringify({ name: 'worker-2', index: 2, pane_id: '%13', pid: 42413 }));
3496
+ await writeFile(join(cwd, '.omx', 'state', 'team', 'success-proof-loss', 'workers', 'worker-3', 'identity.json'), JSON.stringify({ name: 'worker-3', index: 3, pane_id: '%14', pid: 42414 }));
3497
+ const resolvedTask = await createTask('success-proof-loss', {
3498
+ subject: 'resolved owner task', description: 'must be reclaimed', status: 'pending', owner: 'worker-2',
3499
+ }, cwd);
3500
+ const unresolvedTask = await createTask('success-proof-loss', {
3501
+ subject: 'unresolved owner task', description: 'must be preserved', status: 'pending', owner: 'worker-3',
3502
+ }, cwd);
3503
+ const resolvedClaim = await claimTask('success-proof-loss', resolvedTask.id, 'worker-2', resolvedTask.version ?? 1, cwd);
3504
+ assert.equal(resolvedClaim.ok, true);
3505
+ const worker3StatusPath = join(cwd, '.omx', 'state', 'team', 'success-proof-loss', 'workers', 'worker-3', 'status.json');
3506
+ const worker3Raw = '{"reason":"three",\n"updated_at":"2026-07-14T00:00:01.000Z", "state":"idle"}\n';
3507
+ await writeFile(worker3StatusPath, worker3Raw);
3508
+ const result = await scaleDown('success-proof-loss', cwd, { workerNames: ['worker-2', 'worker-3'], force: true }, { OMX_TEAM_SCALING_ENABLED: '1' });
3509
+ assert.equal(result.ok, false);
3510
+ if (!result.ok)
3511
+ assert.match(result.error, /^scale_down_pane_proof_unavailable:%13:query_failed/);
3512
+ assert.deepEqual((await readTeamConfig('success-proof-loss', cwd))?.workers.map((worker) => worker.name), ['worker-1']);
3513
+ assert.equal(existsSync(worker3StatusPath), true);
3514
+ assert.equal(await readFile(worker3StatusPath, 'utf8'), worker3Raw);
3515
+ assert.equal((await readTask('success-proof-loss', resolvedTask.id, cwd))?.owner, undefined);
3516
+ assert.equal((await readTask('success-proof-loss', unresolvedTask.id, cwd))?.owner, undefined);
3517
+ const retainedTask = await readTask('success-proof-loss', resolvedTask.id, cwd);
3518
+ assert.equal(retainedTask?.status, 'pending');
3519
+ assert.equal(retainedTask?.claim, undefined);
3520
+ const debt = JSON.parse(await readFile(join(cwd, '.omx', 'state', 'team', 'success-proof-loss', '.scale-down-cleanup-debt.json'), 'utf8'));
3521
+ assert.deepEqual(debt.unresolved_panes.map((pane) => pane.pane_id), ['%13', '%14']);
3522
+ const tmuxCommands = await readScaleUpTmuxLogCommands(tmuxLogPath);
3523
+ assert.deepEqual(tmuxCommands.filter((command) => command.startsWith('kill-pane ')), ['kill-pane -t %13']);
3524
+ const killIndex = tmuxCommands.indexOf('kill-pane -t %13');
3525
+ assert.ok(killIndex > 2);
3526
+ assert.equal(tmuxCommands[killIndex - 3], 'list-panes -a -F #{pane_id}\t#{pane_dead}\t#{pane_pid}');
3527
+ assert.equal(tmuxCommands[killIndex - 2], 'show-option -qv -p -t %13 @omx_team_pane_owner_id');
3528
+ assert.equal(tmuxCommands[killIndex - 1], 'list-panes -a -F #{pane_id}\t#{pane_dead}\t#{pane_pid}');
3529
+ assert.equal(tmuxCommands[killIndex + 1], 'list-panes -a -F #{pane_id}\t#{pane_dead}\t#{pane_pid}');
3530
+ await writeFile(`${proofLossMarkerPath}.recovery`, '1');
3531
+ await rm(`${proofLossMarkerPath}.killed`, { force: true });
3532
+ const committedConfig = await readTeamConfig('success-proof-loss', cwd);
3533
+ assert.ok(committedConfig);
3534
+ if (!committedConfig)
3535
+ return;
3536
+ const recovered = await reconcileScaleDownCleanupDebt('success-proof-loss', cwd, committedConfig);
3537
+ assert.deepEqual(recovered, { ok: true });
3538
+ assert.equal(existsSync(join(cwd, '.omx', 'state', 'team', 'success-proof-loss', '.scale-down-cleanup-debt.json')), false);
3539
+ const recoveredCommands = await readScaleUpTmuxLogCommands(tmuxLogPath);
3540
+ assert.deepEqual(recoveredCommands.filter((command) => command.startsWith('kill-pane ')), [
3541
+ 'kill-pane -t %13',
3542
+ 'kill-pane -t %13',
3543
+ 'kill-pane -t %14',
3544
+ ]);
3545
+ }
3546
+ finally {
3547
+ if (typeof previousPath === 'string')
3548
+ process.env.PATH = previousPath;
3549
+ else
3550
+ delete process.env.PATH;
3551
+ await rm(cwd, { recursive: true, force: true });
3552
+ await rm(fakeBinDir, { recursive: true, force: true });
3553
+ }
3554
+ });
3555
+ it('commits forward membership and retains exact debt when a later pane teardown fails', async () => {
3556
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-scale-down-gone-kill-fail-'));
3557
+ const fakeBinDir = await mkdtemp(join(tmpdir(), 'omx-scale-down-gone-kill-fail-bin-'));
3558
+ const tmuxStubPath = join(fakeBinDir, 'tmux');
3559
+ const tmuxLogPath = join(fakeBinDir, 'tmux.log');
3560
+ const previousPath = process.env.PATH;
3561
+ try {
3562
+ await writeFile(tmuxStubPath, `#!/bin/sh
3563
+ set -eu
3564
+ printf '%s\\n' "$*" >> "${tmuxLogPath}"
3565
+ case "\${1:-}" in
3566
+ show-option) echo 'team:scale-down' ;;
3567
+ list-panes)
3568
+ if [ -f "${tmuxLogPath}.killed-%13" ]; then
3569
+ printf '%s\t%s\t%s\n' '%14' '0' '42414'
3570
+ else
3571
+ printf '%s\t%s\t%s\n' '%13' '0' '42413'
3572
+ printf '%s\t%s\t%s\n' '%14' '0' '42414'
3573
+ fi
3574
+ ;;
3575
+ kill-pane)
3576
+ if [ "$3" = '%13' ]; then : > "${tmuxLogPath}.killed-%13"; exit 0; fi
3577
+ exit 1
3578
+ ;;
3579
+ esac
3580
+ `);
3581
+ await chmod(tmuxStubPath, 0o755);
3582
+ process.env.PATH = `${fakeBinDir}:${previousPath ?? ''}`;
3583
+ await initTeamState('gone-kill-fail', 'task', 'executor', 3, cwd);
3584
+ const config = await readTeamConfig('gone-kill-fail', cwd);
3585
+ assert.ok(config);
3586
+ if (!config)
3587
+ return;
3588
+ config.workers[1].pane_id = '%13';
3589
+ config.workers[1].pid = 42413;
3590
+ config.workers[2].pane_id = '%14';
3591
+ config.workers[2].pid = 42414;
3592
+ config.tmux_pane_owner_id = 'team:scale-down';
3593
+ await saveTeamConfig(config, cwd);
3594
+ const resolvedTask = await createTask('gone-kill-fail', {
3595
+ subject: 'gone owner task', description: 'must be reclaimed', status: 'pending', owner: 'worker-2',
3596
+ }, cwd);
3597
+ const unresolvedTask = await createTask('gone-kill-fail', {
3598
+ subject: 'failed owner task', description: 'must be preserved', status: 'pending', owner: 'worker-3',
3599
+ }, cwd);
3600
+ const resolvedClaim = await claimTask('gone-kill-fail', resolvedTask.id, 'worker-2', resolvedTask.version ?? 1, cwd);
3601
+ assert.equal(resolvedClaim.ok, true);
3602
+ const worker3StatusPath = join(cwd, '.omx', 'state', 'team', 'gone-kill-fail', 'workers', 'worker-3', 'status.json');
3603
+ await writeFile(worker3StatusPath, '{\n "state" : "idle", "reason":"three", "updated_at":"2026-07-14T00:00:01.000Z"\n}\n');
3604
+ const result = await scaleDown('gone-kill-fail', cwd, { workerNames: ['worker-2', 'worker-3'], force: true }, { OMX_TEAM_SCALING_ENABLED: '1' });
3605
+ assert.equal(result.ok, false);
3606
+ if (!result.ok)
3607
+ assert.match(result.error, /^scale_down_cleanup_debt:pane_teardown_failed:%14/);
3608
+ assert.deepEqual((await readTeamConfig('gone-kill-fail', cwd))?.workers.map((worker) => worker.name), ['worker-1']);
3609
+ assert.equal(existsSync(worker3StatusPath), true);
3610
+ assert.equal(await readFile(worker3StatusPath, 'utf8'), '{\n "state" : "idle", "reason":"three", "updated_at":"2026-07-14T00:00:01.000Z"\n}\n');
3611
+ assert.equal((await readTask('gone-kill-fail', resolvedTask.id, cwd))?.owner, undefined);
3612
+ assert.equal((await readTask('gone-kill-fail', unresolvedTask.id, cwd))?.owner, undefined);
3613
+ const retainedTask = await readTask('gone-kill-fail', resolvedTask.id, cwd);
3614
+ assert.equal(retainedTask?.status, 'pending');
3615
+ assert.equal(retainedTask?.claim, undefined);
3616
+ const debt = JSON.parse(await readFile(join(cwd, '.omx', 'state', 'team', 'gone-kill-fail', '.scale-down-cleanup-debt.json'), 'utf8'));
3617
+ assert.deepEqual(debt.unresolved_panes.map((pane) => pane.pane_id), ['%14']);
3618
+ assert.deepEqual(await readScaleUpTmuxLogCommands(tmuxLogPath), [
3619
+ 'list-panes -a -F #{pane_id}\t#{pane_dead}\t#{pane_pid}',
3620
+ 'show-option -qv -p -t %13 @omx_team_pane_owner_id',
3621
+ 'list-panes -a -F #{pane_id}\t#{pane_dead}\t#{pane_pid}',
3622
+ 'kill-pane -t %13',
3623
+ 'list-panes -a -F #{pane_id}\t#{pane_dead}\t#{pane_pid}',
3624
+ 'list-panes -a -F #{pane_id}\t#{pane_dead}\t#{pane_pid}',
3625
+ 'show-option -qv -p -t %14 @omx_team_pane_owner_id',
3626
+ 'list-panes -a -F #{pane_id}\t#{pane_dead}\t#{pane_pid}',
3627
+ 'kill-pane -t %14',
3628
+ ]);
3629
+ }
3630
+ finally {
3631
+ if (typeof previousPath === 'string')
3632
+ process.env.PATH = previousPath;
3633
+ else
3634
+ delete process.env.PATH;
3635
+ await rm(cwd, { recursive: true, force: true });
3636
+ await rm(fakeBinDir, { recursive: true, force: true });
3637
+ }
3638
+ });
3639
+ for (const [name, priorRaw] of [
3640
+ ['absent status artifact', undefined],
3641
+ ['malformed status artifact', '{"state":\n'],
3642
+ ['noncanonical valid status artifact', '{\n "updated_at" : "2026-07-14T00:00:00.000Z",\n "reason":"waiting for assignment", "state" : "idle"\n}\n'],
3643
+ ]) {
3644
+ it(`scaleDown restores the exact ${name} after kill-pane failure`, async () => {
3645
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-scale-down-status-artifact-'));
3646
+ const fakeBinDir = await mkdtemp(join(tmpdir(), 'omx-scale-down-status-artifact-bin-'));
3647
+ const tmuxStubPath = join(fakeBinDir, 'tmux');
3648
+ const previousPath = process.env.PATH;
3649
+ try {
3650
+ await writeFile(tmuxStubPath, `#!/bin/sh
3651
+ case "\${1:-}" in
3652
+ show-option) echo 'team:scale-down' ;;
3653
+ list-panes) printf '%s\\t%s\\t%s\\n' '%13' '0' '42413' ;;
3654
+ kill-pane) exit 1 ;;
3655
+ esac
3656
+ `);
3657
+ await chmod(tmuxStubPath, 0o755);
3658
+ process.env.PATH = `${fakeBinDir}:${previousPath ?? ''}`;
3659
+ await initTeamState(`status-${name.startsWith('absent') ? 'absent' : 'malformed'}`, 'task', 'executor', 2, cwd);
3660
+ const teamName = `status-${name.startsWith('absent') ? 'absent' : 'malformed'}`;
3661
+ const config = await readTeamConfig(teamName, cwd);
3662
+ assert.ok(config);
3663
+ if (!config)
3664
+ return;
3665
+ config.workers[1].pane_id = '%13';
3666
+ config.workers[1].pid = 42413;
3667
+ config.tmux_pane_owner_id = 'team:scale-down';
3668
+ await saveTeamConfig(config, cwd);
3669
+ const statusPath = join(cwd, '.omx', 'state', 'team', teamName, 'workers', 'worker-2', 'status.json');
3670
+ if (priorRaw === undefined) {
3671
+ await rm(statusPath, { force: true });
3672
+ }
3673
+ else {
3674
+ await writeFile(statusPath, priorRaw);
3675
+ }
3676
+ const result = await scaleDown(teamName, cwd, { workerNames: ['worker-2'], force: true }, { OMX_TEAM_SCALING_ENABLED: '1' });
3677
+ assert.equal(result.ok, false);
3678
+ if (!result.ok)
3679
+ assert.match(result.error, /^scale_down_cleanup_debt:pane_teardown_failed:%13/);
3680
+ assert.equal(existsSync(statusPath), priorRaw !== undefined);
3681
+ if (priorRaw !== undefined)
3682
+ assert.equal(await readFile(statusPath, 'utf8'), priorRaw);
3683
+ }
3684
+ finally {
3685
+ if (typeof previousPath === 'string')
3686
+ process.env.PATH = previousPath;
3687
+ else
3688
+ delete process.env.PATH;
3689
+ await rm(cwd, { recursive: true, force: true });
3690
+ await rm(fakeBinDir, { recursive: true, force: true });
3691
+ }
3692
+ });
3693
+ }
3694
+ it('uses canonical membership rather than a stale caller config before consuming precommit debt', async () => {
3695
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-scale-down-canonical-debt-'));
3696
+ try {
3697
+ await initTeamState('canonical-debt', 'task', 'executor', 2, cwd);
3698
+ const canonical = await readTeamConfig('canonical-debt', cwd);
3699
+ assert.ok(canonical);
3700
+ if (!canonical)
3701
+ return;
3702
+ const staleCaller = structuredClone(canonical);
3703
+ staleCaller.workers = staleCaller.workers.filter((worker) => worker.name !== 'worker-2');
3704
+ staleCaller.worker_count = staleCaller.workers.length;
3705
+ const workerDir = join(cwd, '.omx', 'state', 'team', 'canonical-debt', 'workers', 'worker-2');
3706
+ const sentinelPath = join(workerDir, 'caller-must-not-authorize');
3707
+ await writeFile(sentinelPath, 'preserve');
3708
+ const debtPath = join(cwd, '.omx', 'state', 'team', 'canonical-debt', '.scale-down-cleanup-debt.json');
3709
+ await writeFile(debtPath, JSON.stringify({
3710
+ schema_version: 1,
3711
+ operation: 'scale_down',
3712
+ status: 'pending_teardown',
3713
+ removed_worker_names: ['worker-2'],
3714
+ workers: [{ name: 'worker-2', index: 2, pane_id: '%13', pid: 42413 }],
3715
+ resource_workers: [{ name: 'worker-2' }],
3716
+ }));
3717
+ assert.deepEqual(await reconcileScaleDownCleanupDebt('canonical-debt', cwd, staleCaller), { ok: true });
3718
+ assert.equal(existsSync(debtPath), false);
3719
+ assert.equal(await readFile(sentinelPath, 'utf8'), 'preserve');
3720
+ assert.equal((await readTeamConfig('canonical-debt', cwd))?.workers.some((worker) => worker.name === 'worker-2'), true);
3721
+ }
3722
+ finally {
3723
+ await rm(cwd, { recursive: true, force: true });
3724
+ }
3725
+ });
3726
+ it('converges pane-less crash debt through worker status and directory cleanup', async () => {
3727
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-scale-down-zero-pane-debt-'));
3728
+ try {
3729
+ await initTeamState('zero-pane-debt', 'task', 'executor', 2, cwd);
3730
+ const config = await readTeamConfig('zero-pane-debt', cwd);
3731
+ assert.ok(config);
3732
+ if (!config)
3733
+ return;
3734
+ config.workers = config.workers.filter((worker) => worker.name !== 'worker-2');
3735
+ config.worker_count = config.workers.length;
3736
+ config.tmux_pane_owner_id = 'team:scale-down';
3737
+ await saveTeamConfig(config, cwd);
3738
+ const workerDir = join(cwd, '.omx', 'state', 'team', 'zero-pane-debt', 'workers', 'worker-2');
3739
+ await mkdir(workerDir, { recursive: true });
3740
+ await writeFile(join(workerDir, 'status.json'), '{"state":"draining"}');
3741
+ const debtPath = join(cwd, '.omx', 'state', 'team', 'zero-pane-debt', '.scale-down-cleanup-debt.json');
3742
+ await writeFile(debtPath, JSON.stringify({
3743
+ schema_version: 1,
3744
+ operation: 'scale_down',
3745
+ status: 'resource_cleanup_pending',
3746
+ removed_worker_names: ['worker-2'],
3747
+ workers: [],
3748
+ resource_workers: [{ name: 'worker-2' }],
3749
+ }));
3750
+ assert.deepEqual(await reconcileScaleDownCleanupDebt('zero-pane-debt', cwd, config), { ok: true });
3751
+ assert.equal(existsSync(workerDir), false);
3752
+ assert.equal(existsSync(debtPath), false);
3753
+ }
3754
+ finally {
3755
+ await rm(cwd, { recursive: true, force: true });
3756
+ }
3757
+ });
3758
+ it('converges resource debt after a crash following successful worktree cleanup', async () => {
3759
+ const repo = await initRepo();
3760
+ try {
3761
+ await initTeamState('resource-retry', 'task', 'executor', 2, repo);
3762
+ const config = await readTeamConfig('resource-retry', repo);
3763
+ assert.ok(config);
3764
+ if (!config)
3765
+ return;
3766
+ config.workers = config.workers.filter((worker) => worker.name !== 'worker-2');
3767
+ config.worker_count = config.workers.length;
3768
+ await saveTeamConfig(config, repo);
3769
+ const worktreePath = join(repo, '.omx', 'team', 'resource-retry', 'worktrees', 'worker-2');
3770
+ const workerDir = join(repo, '.omx', 'state', 'team', 'resource-retry', 'workers', 'worker-2');
3771
+ await mkdir(workerDir, { recursive: true });
3772
+ execFileSync('git', ['worktree', 'add', '--detach', worktreePath], { cwd: repo, stdio: 'ignore' });
3773
+ const debtPath = join(repo, '.omx', 'state', 'team', 'resource-retry', '.scale-down-cleanup-debt.json');
3774
+ await writeFile(debtPath, JSON.stringify({
3775
+ schema_version: 1,
3776
+ operation: 'scale_down',
3777
+ status: 'resource_cleanup_pending',
3778
+ removed_worker_names: ['worker-2'],
3779
+ workers: [],
3780
+ resource_workers: [{
3781
+ name: 'worker-2', worktree_path: worktreePath, worktree_repo_root: repo,
3782
+ worktree_detached: true, worktree_created: true,
3783
+ }],
3784
+ }));
3785
+ // Simulate a crash after git worktree removal but before worker-state
3786
+ // cleanup and debt deletion. The retry must treat the exact absent target
3787
+ // as converged rather than attempting to remove a replacement.
3788
+ execFileSync('git', ['worktree', 'remove', '--force', worktreePath], { cwd: repo, stdio: 'ignore' });
3789
+ assert.equal(existsSync(worktreePath), false);
3790
+ assert.equal(existsSync(workerDir), true);
3791
+ assert.deepEqual(await reconcileScaleDownCleanupDebt('resource-retry', repo, config), { ok: true });
3792
+ assert.equal(existsSync(worktreePath), false);
3793
+ assert.equal(existsSync(workerDir), false);
3794
+ assert.equal(existsSync(debtPath), false);
3795
+ }
3796
+ finally {
3797
+ await rm(repo, { recursive: true, force: true });
3798
+ }
3799
+ });
3800
+ it('rejects malformed worktree debt without mutating worker resources', async () => {
3801
+ const repo = await initRepo();
3802
+ try {
3803
+ await initTeamState('malformed-resource-debt', 'task', 'executor', 2, repo);
3804
+ const config = await readTeamConfig('malformed-resource-debt', repo);
3805
+ assert.ok(config);
3806
+ if (!config)
3807
+ return;
3808
+ config.workers = config.workers.filter((worker) => worker.name !== 'worker-2');
3809
+ config.worker_count = config.workers.length;
3810
+ await saveTeamConfig(config, repo);
3811
+ const workerDir = join(repo, '.omx', 'state', 'team', 'malformed-resource-debt', 'workers', 'worker-2');
3812
+ const sentinelPath = join(workerDir, 'sentinel');
3813
+ await mkdir(workerDir, { recursive: true });
3814
+ await writeFile(sentinelPath, 'preserve');
3815
+ const debtPath = join(repo, '.omx', 'state', 'team', 'malformed-resource-debt', '.scale-down-cleanup-debt.json');
3816
+ await writeFile(debtPath, JSON.stringify({
3817
+ schema_version: 1,
3818
+ operation: 'scale_down',
3819
+ status: 'resource_cleanup_pending',
3820
+ removed_worker_names: ['worker-2'],
3821
+ workers: [],
3822
+ resource_workers: [{
3823
+ name: 'worker-2',
3824
+ worktree_path: join(repo, '.omx', 'team', 'malformed-resource-debt', 'worktrees', '..', 'worker-2'),
3825
+ worktree_repo_root: repo,
3826
+ worktree_detached: true,
3827
+ worktree_created: true,
3828
+ }],
3829
+ }));
3830
+ assert.deepEqual(await reconcileScaleDownCleanupDebt('malformed-resource-debt', repo, config), {
3831
+ ok: false,
3832
+ error: 'scale_down_cleanup_debt_malformed',
3833
+ });
3834
+ assert.equal(await readFile(sentinelPath, 'utf8'), 'preserve');
3835
+ assert.equal(existsSync(debtPath), true);
3836
+ }
3837
+ finally {
3838
+ await rm(repo, { recursive: true, force: true });
3839
+ }
3840
+ });
3841
+ it('rejects foreign worktree debt without mutating the foreign location', async () => {
3842
+ const repo = await initRepo();
3843
+ const foreign = await mkdtemp(join(tmpdir(), 'omx-scale-down-foreign-'));
3844
+ try {
3845
+ await initTeamState('foreign-resource-debt', 'task', 'executor', 2, repo);
3846
+ const config = await readTeamConfig('foreign-resource-debt', repo);
3847
+ assert.ok(config);
3848
+ if (!config)
3849
+ return;
3850
+ config.workers = config.workers.filter((worker) => worker.name !== 'worker-2');
3851
+ config.worker_count = config.workers.length;
3852
+ await saveTeamConfig(config, repo);
3853
+ const sentinelPath = join(foreign, 'AGENTS.md');
3854
+ await writeFile(sentinelPath, 'foreign');
3855
+ const debtPath = join(repo, '.omx', 'state', 'team', 'foreign-resource-debt', '.scale-down-cleanup-debt.json');
3856
+ await writeFile(debtPath, JSON.stringify({
3857
+ schema_version: 1,
3858
+ operation: 'scale_down',
3859
+ status: 'resource_cleanup_pending',
3860
+ removed_worker_names: ['worker-2'],
3861
+ workers: [],
3862
+ resource_workers: [{
3863
+ name: 'worker-2', worktree_path: foreign, worktree_repo_root: repo,
3864
+ worktree_detached: true, worktree_created: true,
3865
+ }],
3866
+ }));
3867
+ assert.deepEqual(await reconcileScaleDownCleanupDebt('foreign-resource-debt', repo, config), {
3868
+ ok: false,
3869
+ error: 'scale_down_cleanup_debt_malformed',
3870
+ });
3871
+ assert.equal(await readFile(sentinelPath, 'utf8'), 'foreign');
3872
+ assert.equal(existsSync(debtPath), true);
3873
+ }
3874
+ finally {
3875
+ await rm(repo, { recursive: true, force: true });
3876
+ await rm(foreign, { recursive: true, force: true });
3877
+ }
3878
+ });
3879
+ it('rejects symlink-parent worktree debt without mutating the linked location', async () => {
3880
+ const repo = await initRepo();
3881
+ const foreign = await mkdtemp(join(tmpdir(), 'omx-scale-down-symlink-'));
3882
+ try {
3883
+ await initTeamState('symlink-resource-debt', 'task', 'executor', 2, repo);
3884
+ const config = await readTeamConfig('symlink-resource-debt', repo);
3885
+ assert.ok(config);
3886
+ if (!config)
3887
+ return;
3888
+ config.workers = config.workers.filter((worker) => worker.name !== 'worker-2');
3889
+ config.worker_count = config.workers.length;
3890
+ await saveTeamConfig(config, repo);
3891
+ const expectedWorktreeRoot = join(repo, '.omx', 'team', 'symlink-resource-debt', 'worktrees');
3892
+ await mkdir(join(repo, '.omx', 'team', 'symlink-resource-debt'), { recursive: true });
3893
+ await symlink(foreign, expectedWorktreeRoot);
3894
+ const sentinelPath = join(foreign, 'AGENTS.md');
3895
+ await writeFile(sentinelPath, 'foreign');
3896
+ const debtPath = join(repo, '.omx', 'state', 'team', 'symlink-resource-debt', '.scale-down-cleanup-debt.json');
3897
+ await writeFile(debtPath, JSON.stringify({
3898
+ schema_version: 1,
3899
+ operation: 'scale_down',
3900
+ status: 'resource_cleanup_pending',
3901
+ removed_worker_names: ['worker-2'],
3902
+ workers: [],
3903
+ resource_workers: [{
3904
+ name: 'worker-2', worktree_path: join(expectedWorktreeRoot, 'worker-2'), worktree_repo_root: repo,
3905
+ worktree_detached: true, worktree_created: true,
3906
+ }],
3907
+ }));
3908
+ assert.deepEqual(await reconcileScaleDownCleanupDebt('symlink-resource-debt', repo, config), {
3909
+ ok: false,
3910
+ error: 'scale_down_cleanup_debt_malformed',
3911
+ });
3912
+ assert.equal(await readFile(sentinelPath, 'utf8'), 'foreign');
3913
+ assert.equal(existsSync(debtPath), true);
3914
+ }
3915
+ finally {
3916
+ await rm(repo, { recursive: true, force: true });
3917
+ await rm(foreign, { recursive: true, force: true });
3918
+ }
3919
+ });
3920
+ it('rejects cleanup debt that collides with a surviving canonical worker before any tmux effect', async () => {
3921
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-scale-down-debt-survivor-collision-'));
3922
+ const fakeBinDir = await mkdtemp(join(tmpdir(), 'omx-scale-down-debt-survivor-collision-bin-'));
3923
+ const tmuxStubPath = join(fakeBinDir, 'tmux');
3924
+ const tmuxLogPath = join(fakeBinDir, 'tmux.log');
3925
+ const previousPath = process.env.PATH;
3926
+ try {
3927
+ await writeFile(tmuxStubPath, `#!/bin/sh
3928
+ printf '%s\\n' "$*" >> "${tmuxLogPath}"
3929
+ exit 99
3930
+ `);
3931
+ await chmod(tmuxStubPath, 0o755);
3932
+ process.env.PATH = `${fakeBinDir}:${previousPath ?? ''}`;
3933
+ await initTeamState('debt-survivor-collision', 'task', 'executor', 3, cwd);
3934
+ const config = await readTeamConfig('debt-survivor-collision', cwd);
3935
+ assert.ok(config);
3936
+ if (!config)
3937
+ return;
3938
+ config.workers[0].pane_id = '%13';
3939
+ config.workers[0].pid = 42413;
3940
+ config.workers = config.workers.filter((worker) => worker.name !== 'worker-2' && worker.name !== 'worker-3');
3941
+ config.worker_count = config.workers.length;
3942
+ config.tmux_pane_owner_id = 'team:scale-down';
3943
+ await saveTeamConfig(config, cwd);
3944
+ const debtPath = join(cwd, '.omx', 'state', 'team', 'debt-survivor-collision', '.scale-down-cleanup-debt.json');
3945
+ const debt = {
3946
+ schema_version: 1,
3947
+ operation: 'scale_down',
3948
+ status: 'pending_teardown',
3949
+ removed_worker_names: ['worker-2', 'worker-3'],
3950
+ workers: [
3951
+ { name: 'worker-2', index: 2, pane_id: '%13', pid: 42414 },
3952
+ { name: 'worker-3', index: 3, pane_id: '%14', pid: 42413 },
3953
+ ],
3954
+ resource_workers: [{ name: 'worker-2' }, { name: 'worker-3' }],
3955
+ };
3956
+ await writeFile(debtPath, JSON.stringify(debt));
3957
+ assert.deepEqual(await reconcileScaleDownCleanupDebt('debt-survivor-collision', cwd, config), {
3958
+ ok: false,
3959
+ error: 'scale_down_cleanup_debt_malformed',
3960
+ });
3961
+ assert.deepEqual(JSON.parse(await readFile(debtPath, 'utf8')), debt);
3962
+ assert.equal(existsSync(tmuxLogPath), false);
3963
+ }
3964
+ finally {
3965
+ if (typeof previousPath === 'string')
3966
+ process.env.PATH = previousPath;
3967
+ else
3968
+ delete process.env.PATH;
3969
+ await rm(cwd, { recursive: true, force: true });
3970
+ await rm(fakeBinDir, { recursive: true, force: true });
3971
+ }
3972
+ });
3973
+ for (const [name, livePid, ownerMode, expectedReason] of [
3974
+ ['refuses an unrelated current pane occupant', 42413, 'foreign', 'team_owner_mismatch'],
3975
+ ['refuses a PID-reused pane even with the Team owner tag', 52413, 'team', 'pane_pid_changed'],
3976
+ ['fails closed when Team owner authorization is unavailable', 42413, 'unavailable', 'team_owner_unavailable'],
3977
+ ]) {
3978
+ it(name, async () => {
3979
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-scale-down-debt-pane-authority-'));
3980
+ const fakeBinDir = await mkdtemp(join(tmpdir(), 'omx-scale-down-debt-pane-authority-bin-'));
3981
+ const tmuxStubPath = join(fakeBinDir, 'tmux');
3982
+ const tmuxLogPath = join(fakeBinDir, 'tmux.log');
3983
+ const previousPath = process.env.PATH;
3984
+ try {
3985
+ const ownerScript = ownerMode === 'unavailable'
3986
+ ? 'echo owner-query-failed >&2; exit 2'
3987
+ : `printf '%s\\n' '${ownerMode === 'team' ? 'team:debt-pane-authority' : 'team:another-team'}'`;
3988
+ await writeFile(tmuxStubPath, `#!/bin/sh
3989
+ printf '%s\\n' "$*" >> "${tmuxLogPath}"
3990
+ case "$1" in
3991
+ list-panes) printf '%s\\t%s\\t%s\\n' '%13' '0' '${livePid}' ;;
3992
+ show-option) ${ownerScript} ;;
3993
+ kill-pane) echo unexpected-kill >&2; exit 99 ;;
3994
+ esac
3995
+ `);
3996
+ await chmod(tmuxStubPath, 0o755);
3997
+ process.env.PATH = `${fakeBinDir}:${previousPath ?? ''}`;
3998
+ await initTeamState('debt-pane-authority', 'task', 'executor', 2, cwd);
3999
+ const config = await readTeamConfig('debt-pane-authority', cwd);
4000
+ assert.ok(config);
4001
+ if (!config)
4002
+ return;
4003
+ config.workers = config.workers.filter((worker) => worker.name !== 'worker-2');
4004
+ config.worker_count = config.workers.length;
4005
+ config.tmux_pane_owner_id = 'team:scale-down';
4006
+ await saveTeamConfig(config, cwd);
4007
+ await writeFile(join(cwd, '.omx', 'state', 'team', 'debt-pane-authority', 'workers', 'worker-2', 'identity.json'), JSON.stringify({ name: 'worker-2', index: 2, pane_id: '%13', pid: 42413 }));
4008
+ const debtPath = join(cwd, '.omx', 'state', 'team', 'debt-pane-authority', '.scale-down-cleanup-debt.json');
4009
+ await writeFile(debtPath, JSON.stringify({
4010
+ schema_version: 1, operation: 'scale_down', status: 'pending_teardown',
4011
+ removed_worker_names: ['worker-2'],
4012
+ workers: [{ name: 'worker-2', index: 2, pane_id: '%13', pid: 42413 }],
4013
+ resource_workers: [{ name: 'worker-2' }],
4014
+ }));
4015
+ assert.deepEqual(await reconcileScaleDownCleanupDebt('debt-pane-authority', cwd, config), {
4016
+ ok: false, error: 'scale_down_cleanup_debt_unresolved:%13',
4017
+ });
4018
+ const debt = JSON.parse(await readFile(debtPath, 'utf8'));
4019
+ assert.ok(debt.reasons.includes(`%13:${expectedReason}`));
4020
+ const commands = existsSync(tmuxLogPath) ? await readFile(tmuxLogPath, 'utf8') : '';
4021
+ assert.doesNotMatch(commands, /kill-pane/);
4022
+ }
4023
+ finally {
4024
+ if (typeof previousPath === 'string')
4025
+ process.env.PATH = previousPath;
4026
+ else
4027
+ delete process.env.PATH;
4028
+ await rm(cwd, { recursive: true, force: true });
4029
+ await rm(fakeBinDir, { recursive: true, force: true });
4030
+ }
4031
+ });
4032
+ }
4033
+ it('rejects mismatched worker pane bindings before any recovery mutation', async () => {
4034
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-scale-down-debt-mismatched-binding-'));
4035
+ try {
4036
+ await initTeamState('debt-mismatched-binding', 'task', 'executor', 2, cwd);
4037
+ const config = await readTeamConfig('debt-mismatched-binding', cwd);
4038
+ assert.ok(config);
4039
+ if (!config)
4040
+ return;
4041
+ config.workers = config.workers.filter((worker) => worker.name !== 'worker-2');
4042
+ config.worker_count = config.workers.length;
4043
+ config.tmux_pane_owner_id = 'team:scale-down';
4044
+ await saveTeamConfig(config, cwd);
4045
+ const workerDir = join(cwd, '.omx', 'state', 'team', 'debt-mismatched-binding', 'workers', 'worker-2');
4046
+ const sentinel = join(workerDir, 'must-remain');
4047
+ await writeFile(sentinel, 'preserve');
4048
+ const debtPath = join(cwd, '.omx', 'state', 'team', 'debt-mismatched-binding', '.scale-down-cleanup-debt.json');
4049
+ await writeFile(debtPath, JSON.stringify({
4050
+ schema_version: 1, operation: 'scale_down', status: 'unresolved',
4051
+ removed_worker_names: ['worker-2'],
4052
+ workers: [{ name: 'worker-2', index: 2, pane_id: '%13', pid: 42413 }],
4053
+ unresolved_panes: [{ name: 'worker-2', index: 2, pane_id: '%14', pid: 42413 }],
4054
+ resource_workers: [{ name: 'worker-2' }],
4055
+ }));
4056
+ assert.deepEqual(await reconcileScaleDownCleanupDebt('debt-mismatched-binding', cwd, config), {
4057
+ ok: false, error: 'scale_down_cleanup_debt_malformed',
4058
+ });
4059
+ assert.equal(await readFile(sentinel, 'utf8'), 'preserve');
4060
+ assert.equal(existsSync(debtPath), true);
4061
+ }
4062
+ finally {
4063
+ await rm(cwd, { recursive: true, force: true });
4064
+ }
4065
+ });
4066
+ it('converges a PID-less legacy pane record only after a fresh global gone proof', async () => {
4067
+ const cwd = await mkdtemp(join(tmpdir(), 'omx-scale-down-debt-legacy-gone-'));
4068
+ const fakeBinDir = await mkdtemp(join(tmpdir(), 'omx-scale-down-debt-legacy-gone-bin-'));
4069
+ const tmuxStubPath = join(fakeBinDir, 'tmux');
4070
+ const tmuxLogPath = join(fakeBinDir, 'tmux.log');
4071
+ const previousPath = process.env.PATH;
4072
+ try {
4073
+ await writeFile(tmuxStubPath, `#!/bin/sh
4074
+ printf '%s\\n' "$*" >> "${tmuxLogPath}"
4075
+ case "$1" in list-panes) exit 0 ;; kill-pane) exit 99 ;; esac
4076
+ `);
4077
+ await chmod(tmuxStubPath, 0o755);
4078
+ process.env.PATH = `${fakeBinDir}:${previousPath ?? ''}`;
4079
+ await initTeamState('debt-legacy-gone', 'task', 'executor', 2, cwd);
4080
+ const config = await readTeamConfig('debt-legacy-gone', cwd);
4081
+ assert.ok(config);
4082
+ if (!config)
4083
+ return;
4084
+ config.workers = config.workers.filter((worker) => worker.name !== 'worker-2');
4085
+ config.worker_count = config.workers.length;
4086
+ config.tmux_pane_owner_id = 'team:scale-down';
4087
+ await saveTeamConfig(config, cwd);
4088
+ const debtPath = join(cwd, '.omx', 'state', 'team', 'debt-legacy-gone', '.scale-down-cleanup-debt.json');
4089
+ await writeFile(debtPath, JSON.stringify({
4090
+ schema_version: 1, operation: 'scale_down', status: 'unresolved',
4091
+ removed_worker_names: ['worker-2'],
4092
+ workers: [{ name: 'worker-2', index: 2, pane_id: '%13', pid: null }],
4093
+ unresolved_panes: [{ name: 'worker-2', index: 2, pane_id: '%13', pid: null }],
4094
+ resource_workers: [{ name: 'worker-2' }],
4095
+ }));
4096
+ assert.deepEqual(await reconcileScaleDownCleanupDebt('debt-legacy-gone', cwd, config), { ok: true });
4097
+ assert.equal(existsSync(debtPath), false);
4098
+ assert.doesNotMatch(await readFile(tmuxLogPath, 'utf8'), /kill-pane|show-option/);
1897
4099
  }
1898
4100
  finally {
1899
4101
  if (typeof previousPath === 'string')