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.
- package/Cargo.lock +36 -18
- package/Cargo.toml +1 -1
- package/README.md +28 -2
- package/crates/omx-runtime/Cargo.toml +4 -0
- package/crates/omx-runtime/src/main.rs +415 -2
- package/crates/omx-runtime/tests/execution.rs +483 -0
- package/crates/omx-runtime-core/src/dispatch.rs +96 -10
- package/crates/omx-runtime-core/src/engine.rs +839 -29
- package/crates/omx-runtime-core/src/lib.rs +8 -0
- package/dist/adapt/__tests__/herdr-target.test.d.ts +2 -0
- package/dist/adapt/__tests__/herdr-target.test.d.ts.map +1 -0
- package/dist/adapt/__tests__/herdr-target.test.js +72 -0
- package/dist/adapt/__tests__/herdr-target.test.js.map +1 -0
- package/dist/adapt/contracts.d.ts +1 -1
- package/dist/adapt/contracts.d.ts.map +1 -1
- package/dist/adapt/contracts.js +1 -1
- package/dist/adapt/contracts.js.map +1 -1
- package/dist/adapt/herdr/__tests__/authority.test.d.ts +2 -0
- package/dist/adapt/herdr/__tests__/authority.test.d.ts.map +1 -0
- package/dist/adapt/herdr/__tests__/authority.test.js +49 -0
- package/dist/adapt/herdr/__tests__/authority.test.js.map +1 -0
- package/dist/adapt/herdr/__tests__/bridge.test.d.ts +2 -0
- package/dist/adapt/herdr/__tests__/bridge.test.d.ts.map +1 -0
- package/dist/adapt/herdr/__tests__/bridge.test.js +176 -0
- package/dist/adapt/herdr/__tests__/bridge.test.js.map +1 -0
- package/dist/adapt/herdr/__tests__/sanitize.test.d.ts +2 -0
- package/dist/adapt/herdr/__tests__/sanitize.test.d.ts.map +1 -0
- package/dist/adapt/herdr/__tests__/sanitize.test.js +45 -0
- package/dist/adapt/herdr/__tests__/sanitize.test.js.map +1 -0
- package/dist/adapt/herdr/__tests__/semantic.test.d.ts +2 -0
- package/dist/adapt/herdr/__tests__/semantic.test.d.ts.map +1 -0
- package/dist/adapt/herdr/__tests__/semantic.test.js +130 -0
- package/dist/adapt/herdr/__tests__/semantic.test.js.map +1 -0
- package/dist/adapt/herdr/__tests__/seq-store.test.d.ts +2 -0
- package/dist/adapt/herdr/__tests__/seq-store.test.d.ts.map +1 -0
- package/dist/adapt/herdr/__tests__/seq-store.test.js +54 -0
- package/dist/adapt/herdr/__tests__/seq-store.test.js.map +1 -0
- package/dist/adapt/herdr/__tests__/socket-live.test.d.ts +2 -0
- package/dist/adapt/herdr/__tests__/socket-live.test.d.ts.map +1 -0
- package/dist/adapt/herdr/__tests__/socket-live.test.js +104 -0
- package/dist/adapt/herdr/__tests__/socket-live.test.js.map +1 -0
- package/dist/adapt/herdr/__tests__/team-map.test.d.ts +2 -0
- package/dist/adapt/herdr/__tests__/team-map.test.d.ts.map +1 -0
- package/dist/adapt/herdr/__tests__/team-map.test.js +46 -0
- package/dist/adapt/herdr/__tests__/team-map.test.js.map +1 -0
- package/dist/adapt/herdr/__tests__/transport.test.d.ts +2 -0
- package/dist/adapt/herdr/__tests__/transport.test.d.ts.map +1 -0
- package/dist/adapt/herdr/__tests__/transport.test.js +160 -0
- package/dist/adapt/herdr/__tests__/transport.test.js.map +1 -0
- package/dist/adapt/herdr/__tests__/wiring.test.d.ts +2 -0
- package/dist/adapt/herdr/__tests__/wiring.test.d.ts.map +1 -0
- package/dist/adapt/herdr/__tests__/wiring.test.js +118 -0
- package/dist/adapt/herdr/__tests__/wiring.test.js.map +1 -0
- package/dist/adapt/herdr/authority.d.ts +34 -0
- package/dist/adapt/herdr/authority.d.ts.map +1 -0
- package/dist/adapt/herdr/authority.js +71 -0
- package/dist/adapt/herdr/authority.js.map +1 -0
- package/dist/adapt/herdr/bridge.d.ts +84 -0
- package/dist/adapt/herdr/bridge.d.ts.map +1 -0
- package/dist/adapt/herdr/bridge.js +222 -0
- package/dist/adapt/herdr/bridge.js.map +1 -0
- package/dist/adapt/herdr/sanitize.d.ts +20 -0
- package/dist/adapt/herdr/sanitize.d.ts.map +1 -0
- package/dist/adapt/herdr/sanitize.js +70 -0
- package/dist/adapt/herdr/sanitize.js.map +1 -0
- package/dist/adapt/herdr/semantic.d.ts +67 -0
- package/dist/adapt/herdr/semantic.d.ts.map +1 -0
- package/dist/adapt/herdr/semantic.js +170 -0
- package/dist/adapt/herdr/semantic.js.map +1 -0
- package/dist/adapt/herdr/seq-store.d.ts +32 -0
- package/dist/adapt/herdr/seq-store.d.ts.map +1 -0
- package/dist/adapt/herdr/seq-store.js +126 -0
- package/dist/adapt/herdr/seq-store.js.map +1 -0
- package/dist/adapt/herdr/team-map.d.ts +30 -0
- package/dist/adapt/herdr/team-map.d.ts.map +1 -0
- package/dist/adapt/herdr/team-map.js +37 -0
- package/dist/adapt/herdr/team-map.js.map +1 -0
- package/dist/adapt/herdr/transport.d.ts +115 -0
- package/dist/adapt/herdr/transport.d.ts.map +1 -0
- package/dist/adapt/herdr/transport.js +260 -0
- package/dist/adapt/herdr/transport.js.map +1 -0
- package/dist/adapt/herdr/wiring.d.ts +24 -0
- package/dist/adapt/herdr/wiring.d.ts.map +1 -0
- package/dist/adapt/herdr/wiring.js +62 -0
- package/dist/adapt/herdr/wiring.js.map +1 -0
- package/dist/adapt/herdr.d.ts +19 -0
- package/dist/adapt/herdr.d.ts.map +1 -0
- package/dist/adapt/herdr.js +115 -0
- package/dist/adapt/herdr.js.map +1 -0
- package/dist/adapt/index.d.ts +4 -0
- package/dist/adapt/index.d.ts.map +1 -1
- package/dist/adapt/index.js +48 -0
- package/dist/adapt/index.js.map +1 -1
- package/dist/adapt/registry.d.ts.map +1 -1
- package/dist/adapt/registry.js +11 -0
- package/dist/adapt/registry.js.map +1 -1
- package/dist/agents/__tests__/definitions.test.js +81 -0
- package/dist/agents/__tests__/definitions.test.js.map +1 -1
- package/dist/agents/__tests__/native-config.test.js +25 -0
- package/dist/agents/__tests__/native-config.test.js.map +1 -1
- package/dist/agents/native-config.d.ts +2 -1
- package/dist/agents/native-config.d.ts.map +1 -1
- package/dist/agents/native-config.js.map +1 -1
- package/dist/auth/__tests__/hotswap.test.js +60 -1
- package/dist/auth/__tests__/hotswap.test.js.map +1 -1
- package/dist/auth/__tests__/storage.test.js +33 -1
- package/dist/auth/__tests__/storage.test.js.map +1 -1
- package/dist/auth/hotswap.d.ts +22 -2
- package/dist/auth/hotswap.d.ts.map +1 -1
- package/dist/auth/hotswap.js +107 -22
- package/dist/auth/hotswap.js.map +1 -1
- package/dist/auth/storage.js +1 -1
- package/dist/auth/storage.js.map +1 -1
- package/dist/autopilot/__tests__/ralplan-gate.test.js +86 -1333
- package/dist/autopilot/__tests__/ralplan-gate.test.js.map +1 -1
- package/dist/autopilot/ralplan-gate.d.ts.map +1 -1
- package/dist/autopilot/ralplan-gate.js +7 -6
- package/dist/autopilot/ralplan-gate.js.map +1 -1
- package/dist/cli/__tests__/agents-init.test.js +4 -23
- package/dist/cli/__tests__/agents-init.test.js.map +1 -1
- package/dist/cli/__tests__/api.test.js +61 -24
- package/dist/cli/__tests__/api.test.js.map +1 -1
- package/dist/cli/__tests__/auth.test.js +63 -7
- package/dist/cli/__tests__/auth.test.js.map +1 -1
- package/dist/cli/__tests__/codex-feature-probe.test.js +66 -1
- package/dist/cli/__tests__/codex-feature-probe.test.js.map +1 -1
- package/dist/cli/__tests__/codex-plugin-layout.test.js +150 -2
- package/dist/cli/__tests__/codex-plugin-layout.test.js.map +1 -1
- package/dist/cli/__tests__/detached-leader-teardown.test.d.ts +2 -0
- package/dist/cli/__tests__/detached-leader-teardown.test.d.ts.map +1 -0
- package/dist/cli/__tests__/detached-leader-teardown.test.js +473 -0
- package/dist/cli/__tests__/detached-leader-teardown.test.js.map +1 -0
- package/dist/cli/__tests__/doctor-process-identity-readiness.test.d.ts +2 -0
- package/dist/cli/__tests__/doctor-process-identity-readiness.test.d.ts.map +1 -0
- package/dist/cli/__tests__/doctor-process-identity-readiness.test.js +103 -0
- package/dist/cli/__tests__/doctor-process-identity-readiness.test.js.map +1 -0
- package/dist/cli/__tests__/doctor-state-root-binding.test.d.ts +2 -0
- package/dist/cli/__tests__/doctor-state-root-binding.test.d.ts.map +1 -0
- package/dist/cli/__tests__/doctor-state-root-binding.test.js +139 -0
- package/dist/cli/__tests__/doctor-state-root-binding.test.js.map +1 -0
- package/dist/cli/__tests__/doctor-warning-copy.test.js +135 -1
- package/dist/cli/__tests__/doctor-warning-copy.test.js.map +1 -1
- package/dist/cli/__tests__/exec.test.js +211 -0
- package/dist/cli/__tests__/exec.test.js.map +1 -1
- package/dist/cli/__tests__/index.test.js +878 -463
- package/dist/cli/__tests__/index.test.js.map +1 -1
- package/dist/cli/__tests__/launch-fallback.test.js +1290 -127
- package/dist/cli/__tests__/launch-fallback.test.js.map +1 -1
- package/dist/cli/__tests__/mcp-parity.test.js +8 -4
- package/dist/cli/__tests__/mcp-parity.test.js.map +1 -1
- package/dist/cli/__tests__/native-assets.test.js +398 -16
- package/dist/cli/__tests__/native-assets.test.js.map +1 -1
- package/dist/cli/__tests__/native-hook-claim-journal.test.js +132 -6
- package/dist/cli/__tests__/native-hook-claim-journal.test.js.map +1 -1
- package/dist/cli/__tests__/ralph-goal-mode-contract.test.js +3 -3
- package/dist/cli/__tests__/ralph-goal-mode-contract.test.js.map +1 -1
- package/dist/cli/__tests__/ralph.test.js +3 -3
- package/dist/cli/__tests__/ralph.test.js.map +1 -1
- package/dist/cli/__tests__/ralplan-bootstrap-3181.test.js +49 -164
- package/dist/cli/__tests__/ralplan-bootstrap-3181.test.js.map +1 -1
- package/dist/cli/__tests__/ralplan-child-binding-3212.test.d.ts +2 -0
- package/dist/cli/__tests__/ralplan-child-binding-3212.test.d.ts.map +1 -0
- package/dist/cli/__tests__/ralplan-child-binding-3212.test.js +56 -0
- package/dist/cli/__tests__/ralplan-child-binding-3212.test.js.map +1 -0
- package/dist/cli/__tests__/ralplan.test.js +107 -109
- package/dist/cli/__tests__/ralplan.test.js.map +1 -1
- package/dist/cli/__tests__/resume.test.js +11 -10
- package/dist/cli/__tests__/resume.test.js.map +1 -1
- package/dist/cli/__tests__/session-scoped-runtime.test.js +1118 -43
- package/dist/cli/__tests__/session-scoped-runtime.test.js.map +1 -1
- package/dist/cli/__tests__/session-search-help.test.js +25 -1
- package/dist/cli/__tests__/session-search-help.test.js.map +1 -1
- package/dist/cli/__tests__/setup-agents-overwrite.test.js +8 -55
- package/dist/cli/__tests__/setup-agents-overwrite.test.js.map +1 -1
- package/dist/cli/__tests__/setup-install-mode.test.js +224 -7
- package/dist/cli/__tests__/setup-install-mode.test.js.map +1 -1
- package/dist/cli/__tests__/sparkshell-cli.test.js +93 -41
- package/dist/cli/__tests__/sparkshell-cli.test.js.map +1 -1
- package/dist/cli/__tests__/team.test.js +150 -8
- package/dist/cli/__tests__/team.test.js.map +1 -1
- package/dist/cli/__tests__/ultragoal.test.js +117 -2
- package/dist/cli/__tests__/ultragoal.test.js.map +1 -1
- package/dist/cli/__tests__/uninstall.test.js +95 -4
- package/dist/cli/__tests__/uninstall.test.js.map +1 -1
- package/dist/cli/__tests__/update.test.js +669 -177
- package/dist/cli/__tests__/update.test.js.map +1 -1
- package/dist/cli/__tests__/windows-popup-loop-contract.test.js +33 -2
- package/dist/cli/__tests__/windows-popup-loop-contract.test.js.map +1 -1
- package/dist/cli/api.d.ts.map +1 -1
- package/dist/cli/api.js +9 -10
- package/dist/cli/api.js.map +1 -1
- package/dist/cli/codex-feature-probe.d.ts +16 -0
- package/dist/cli/codex-feature-probe.d.ts.map +1 -1
- package/dist/cli/codex-feature-probe.js +44 -0
- package/dist/cli/codex-feature-probe.js.map +1 -1
- package/dist/cli/codex-home.d.ts.map +1 -1
- package/dist/cli/codex-home.js +7 -4
- package/dist/cli/codex-home.js.map +1 -1
- package/dist/cli/doctor.d.ts +20 -0
- package/dist/cli/doctor.d.ts.map +1 -1
- package/dist/cli/doctor.js +290 -3
- package/dist/cli/doctor.js.map +1 -1
- package/dist/cli/index.d.ts +234 -12
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +2409 -746
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/mcp-parity.js +2 -2
- package/dist/cli/mcp-parity.js.map +1 -1
- package/dist/cli/native-assets.d.ts +25 -0
- package/dist/cli/native-assets.d.ts.map +1 -1
- package/dist/cli/native-assets.js +448 -51
- package/dist/cli/native-assets.js.map +1 -1
- package/dist/cli/native-hook-claim-journal.d.ts +20 -5
- package/dist/cli/native-hook-claim-journal.d.ts.map +1 -1
- package/dist/cli/native-hook-claim-journal.js +67 -32
- package/dist/cli/native-hook-claim-journal.js.map +1 -1
- package/dist/cli/package-manager-ownership.d.ts +63 -0
- package/dist/cli/package-manager-ownership.d.ts.map +1 -0
- package/dist/cli/package-manager-ownership.js +323 -0
- package/dist/cli/package-manager-ownership.js.map +1 -0
- package/dist/cli/ralph.js +2 -2
- package/dist/cli/ralph.js.map +1 -1
- package/dist/cli/ralplan.d.ts +5 -7
- package/dist/cli/ralplan.d.ts.map +1 -1
- package/dist/cli/ralplan.js +83 -134
- package/dist/cli/ralplan.js.map +1 -1
- package/dist/cli/session-search.d.ts +5 -0
- package/dist/cli/session-search.d.ts.map +1 -1
- package/dist/cli/session-search.js +87 -0
- package/dist/cli/session-search.js.map +1 -1
- package/dist/cli/setup-preferences.d.ts +15 -0
- package/dist/cli/setup-preferences.d.ts.map +1 -1
- package/dist/cli/setup-preferences.js +27 -1
- package/dist/cli/setup-preferences.js.map +1 -1
- package/dist/cli/setup.d.ts +8 -0
- package/dist/cli/setup.d.ts.map +1 -1
- package/dist/cli/setup.js +119 -47
- package/dist/cli/setup.js.map +1 -1
- package/dist/cli/sparkshell.d.ts.map +1 -1
- package/dist/cli/sparkshell.js +52 -9
- package/dist/cli/sparkshell.js.map +1 -1
- package/dist/cli/ultragoal.d.ts.map +1 -1
- package/dist/cli/ultragoal.js +2 -2
- package/dist/cli/ultragoal.js.map +1 -1
- package/dist/cli/uninstall.d.ts +9 -0
- package/dist/cli/uninstall.d.ts.map +1 -1
- package/dist/cli/uninstall.js +71 -30
- package/dist/cli/uninstall.js.map +1 -1
- package/dist/cli/update-worker.d.ts +2 -0
- package/dist/cli/update-worker.d.ts.map +1 -0
- package/dist/cli/update-worker.js +142 -0
- package/dist/cli/update-worker.js.map +1 -0
- package/dist/cli/update.d.ts +14 -22
- package/dist/cli/update.d.ts.map +1 -1
- package/dist/cli/update.js +192 -241
- package/dist/cli/update.js.map +1 -1
- package/dist/compat/__tests__/rust-runtime-compat.test.js +1 -1
- package/dist/config/__tests__/generator-idempotent.test.js +503 -30
- package/dist/config/__tests__/generator-idempotent.test.js.map +1 -1
- package/dist/config/__tests__/generator-notify.test.js +5 -3
- package/dist/config/__tests__/generator-notify.test.js.map +1 -1
- package/dist/config/__tests__/models.test.js +67 -3
- package/dist/config/__tests__/models.test.js.map +1 -1
- package/dist/config/generator.d.ts +27 -21
- package/dist/config/generator.d.ts.map +1 -1
- package/dist/config/generator.js +610 -152
- package/dist/config/generator.js.map +1 -1
- package/dist/config/models.d.ts +12 -2
- package/dist/config/models.d.ts.map +1 -1
- package/dist/config/models.js +15 -1
- package/dist/config/models.js.map +1 -1
- package/dist/goal-workflows/__tests__/codex-goal-snapshot.test.js +25 -0
- package/dist/goal-workflows/__tests__/codex-goal-snapshot.test.js.map +1 -1
- package/dist/goal-workflows/codex-goal-snapshot.d.ts +1 -1
- package/dist/goal-workflows/codex-goal-snapshot.d.ts.map +1 -1
- package/dist/goal-workflows/codex-goal-snapshot.js +2 -0
- package/dist/goal-workflows/codex-goal-snapshot.js.map +1 -1
- package/dist/hooks/__tests__/agents-overlay.test.js +5 -3
- package/dist/hooks/__tests__/agents-overlay.test.js.map +1 -1
- package/dist/hooks/__tests__/autopilot-skill-contract.test.js +27 -4
- package/dist/hooks/__tests__/autopilot-skill-contract.test.js.map +1 -1
- package/dist/hooks/__tests__/issue-3293-autopilot-handoff.test.d.ts +2 -0
- package/dist/hooks/__tests__/issue-3293-autopilot-handoff.test.d.ts.map +1 -0
- package/dist/hooks/__tests__/issue-3293-autopilot-handoff.test.js +172 -0
- package/dist/hooks/__tests__/issue-3293-autopilot-handoff.test.js.map +1 -0
- package/dist/hooks/__tests__/keyword-detector.test.js +176 -37
- package/dist/hooks/__tests__/keyword-detector.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-fallback-watcher.test.js +402 -545
- package/dist/hooks/__tests__/notify-fallback-watcher.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-all-workers-idle.test.js +39 -4
- package/dist/hooks/__tests__/notify-hook-all-workers-idle.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-auto-nudge.test.js +161 -26
- package/dist/hooks/__tests__/notify-hook-auto-nudge.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-cross-worktree-heartbeat.test.js +27 -0
- package/dist/hooks/__tests__/notify-hook-cross-worktree-heartbeat.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-managed-tmux.test.js +10 -118
- package/dist/hooks/__tests__/notify-hook-managed-tmux.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-regression-205.test.js +7 -0
- package/dist/hooks/__tests__/notify-hook-regression-205.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-team-dispatch.test.js +533 -14
- package/dist/hooks/__tests__/notify-hook-team-dispatch.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js +159 -24
- package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-team-tmux-guard.test.js +482 -32
- package/dist/hooks/__tests__/notify-hook-team-tmux-guard.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-team-worker-fail-closed.test.js +2 -1
- package/dist/hooks/__tests__/notify-hook-team-worker-fail-closed.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-tmux-heal.test.js +68 -1
- package/dist/hooks/__tests__/notify-hook-tmux-heal.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-tmux-scrollback.test.js +7 -1
- package/dist/hooks/__tests__/notify-hook-tmux-scrollback.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-worker-idle.test.js +44 -4
- package/dist/hooks/__tests__/notify-hook-worker-idle.test.js.map +1 -1
- package/dist/hooks/__tests__/prompt-guidance-contract.test.js +9 -0
- package/dist/hooks/__tests__/prompt-guidance-contract.test.js.map +1 -1
- package/dist/hooks/__tests__/session.test.js +3130 -365
- package/dist/hooks/__tests__/session.test.js.map +1 -1
- package/dist/hooks/agents-overlay.js +1 -1
- package/dist/hooks/agents-overlay.js.map +1 -1
- package/dist/hooks/extensibility/__tests__/dispatcher.test.js +108 -2
- package/dist/hooks/extensibility/__tests__/dispatcher.test.js.map +1 -1
- package/dist/hooks/extensibility/__tests__/sdk.test.js +151 -1
- package/dist/hooks/extensibility/__tests__/sdk.test.js.map +1 -1
- package/dist/hooks/extensibility/dispatcher.d.ts.map +1 -1
- package/dist/hooks/extensibility/dispatcher.js +20 -3
- package/dist/hooks/extensibility/dispatcher.js.map +1 -1
- package/dist/hooks/extensibility/plugin-runner.js +1 -0
- package/dist/hooks/extensibility/plugin-runner.js.map +1 -1
- package/dist/hooks/extensibility/runtime.d.ts.map +1 -1
- package/dist/hooks/extensibility/runtime.js.map +1 -1
- package/dist/hooks/extensibility/sdk/paths.d.ts +1 -1
- package/dist/hooks/extensibility/sdk/paths.d.ts.map +1 -1
- package/dist/hooks/extensibility/sdk/paths.js +2 -2
- package/dist/hooks/extensibility/sdk/paths.js.map +1 -1
- package/dist/hooks/extensibility/sdk/runtime-state.d.ts +1 -1
- package/dist/hooks/extensibility/sdk/runtime-state.d.ts.map +1 -1
- package/dist/hooks/extensibility/sdk/runtime-state.js +55 -15
- package/dist/hooks/extensibility/sdk/runtime-state.js.map +1 -1
- package/dist/hooks/extensibility/sdk.d.ts +1 -0
- package/dist/hooks/extensibility/sdk.d.ts.map +1 -1
- package/dist/hooks/extensibility/sdk.js +1 -1
- package/dist/hooks/extensibility/sdk.js.map +1 -1
- package/dist/hooks/extensibility/types.d.ts +7 -0
- package/dist/hooks/extensibility/types.d.ts.map +1 -1
- package/dist/hooks/keyword-detector.d.ts +10 -3
- package/dist/hooks/keyword-detector.d.ts.map +1 -1
- package/dist/hooks/keyword-detector.js +116 -20
- package/dist/hooks/keyword-detector.js.map +1 -1
- package/dist/hooks/session.d.ts +204 -14
- package/dist/hooks/session.d.ts.map +1 -1
- package/dist/hooks/session.js +1500 -223
- package/dist/hooks/session.js.map +1 -1
- package/dist/hud/__tests__/hud-tmux-injection.test.js +6 -6
- package/dist/hud/__tests__/hud-tmux-injection.test.js.map +1 -1
- package/dist/hud/__tests__/index.test.js +26 -16
- package/dist/hud/__tests__/index.test.js.map +1 -1
- package/dist/hud/__tests__/reconcile.test.js +83 -78
- package/dist/hud/__tests__/reconcile.test.js.map +1 -1
- package/dist/hud/__tests__/state.test.js +36 -31
- package/dist/hud/__tests__/state.test.js.map +1 -1
- package/dist/hud/__tests__/tmux.test.js +155 -198
- package/dist/hud/__tests__/tmux.test.js.map +1 -1
- package/dist/hud/tmux.d.ts +42 -4
- package/dist/hud/tmux.d.ts.map +1 -1
- package/dist/hud/tmux.js +1017 -139
- package/dist/hud/tmux.js.map +1 -1
- package/dist/leader/__tests__/contract.test.js +124 -37
- package/dist/leader/__tests__/contract.test.js.map +1 -1
- package/dist/leader/contract.d.ts +17 -6
- package/dist/leader/contract.d.ts.map +1 -1
- package/dist/leader/contract.js +102 -26
- package/dist/leader/contract.js.map +1 -1
- package/dist/mcp/__tests__/code-intel-server.test.js +31 -3
- package/dist/mcp/__tests__/code-intel-server.test.js.map +1 -1
- package/dist/mcp/__tests__/hermes-bridge.test.js +17 -0
- package/dist/mcp/__tests__/hermes-bridge.test.js.map +1 -1
- package/dist/mcp/__tests__/server-lifecycle.test.js +4 -4
- package/dist/mcp/__tests__/state-paths.test.js +735 -4
- package/dist/mcp/__tests__/state-paths.test.js.map +1 -1
- package/dist/mcp/__tests__/state-server.test.js +1 -1
- package/dist/mcp/__tests__/state-server.test.js.map +1 -1
- package/dist/mcp/__tests__/trace-server.test.js +2 -2
- package/dist/mcp/__tests__/trace-server.test.js.map +1 -1
- package/dist/mcp/__tests__/wiki-server.test.js +18 -0
- package/dist/mcp/__tests__/wiki-server.test.js.map +1 -1
- package/dist/mcp/code-intel-server.d.ts.map +1 -1
- package/dist/mcp/code-intel-server.js +13 -3
- package/dist/mcp/code-intel-server.js.map +1 -1
- package/dist/mcp/hermes-bridge.d.ts.map +1 -1
- package/dist/mcp/hermes-bridge.js +2 -2
- package/dist/mcp/hermes-bridge.js.map +1 -1
- package/dist/mcp/state-paths.d.ts +78 -4
- package/dist/mcp/state-paths.d.ts.map +1 -1
- package/dist/mcp/state-paths.js +543 -40
- package/dist/mcp/state-paths.js.map +1 -1
- package/dist/mcp/state-server.d.ts +4 -4
- package/dist/mcp/state-server.d.ts.map +1 -1
- package/dist/mcp/wiki-server.d.ts +11 -0
- package/dist/mcp/wiki-server.d.ts.map +1 -1
- package/dist/mcp/wiki-server.js +9 -2
- package/dist/mcp/wiki-server.js.map +1 -1
- package/dist/modes/__tests__/base-autopilot-gates.test.js +36 -7
- package/dist/modes/__tests__/base-autopilot-gates.test.js.map +1 -1
- package/dist/modes/__tests__/base-multi-state-compat.test.js +1 -1
- package/dist/modes/__tests__/base-multi-state-compat.test.js.map +1 -1
- package/dist/modes/__tests__/base-session-scope.test.js +17 -8
- package/dist/modes/__tests__/base-session-scope.test.js.map +1 -1
- package/dist/modes/__tests__/base-tmux-pane.test.js +4 -4
- package/dist/modes/__tests__/base-tmux-pane.test.js.map +1 -1
- package/dist/modes/base.d.ts +5 -5
- package/dist/modes/base.d.ts.map +1 -1
- package/dist/modes/base.js +76 -21
- package/dist/modes/base.js.map +1 -1
- package/dist/native-assets/archive.d.ts +17 -0
- package/dist/native-assets/archive.d.ts.map +1 -0
- package/dist/native-assets/archive.js +238 -0
- package/dist/native-assets/archive.js.map +1 -0
- package/dist/native-assets/policy.d.ts +42 -0
- package/dist/native-assets/policy.d.ts.map +1 -0
- package/dist/native-assets/policy.js +155 -0
- package/dist/native-assets/policy.js.map +1 -0
- package/dist/notifications/temp-contract.d.ts.map +1 -1
- package/dist/notifications/temp-contract.js +4 -0
- package/dist/notifications/temp-contract.js.map +1 -1
- package/dist/pipeline/__tests__/orchestrator.test.js +164 -42
- package/dist/pipeline/__tests__/orchestrator.test.js.map +1 -1
- package/dist/pipeline/__tests__/stages.test.js +72 -155
- package/dist/pipeline/__tests__/stages.test.js.map +1 -1
- package/dist/pipeline/orchestrator.d.ts.map +1 -1
- package/dist/pipeline/orchestrator.js +124 -18
- package/dist/pipeline/orchestrator.js.map +1 -1
- package/dist/pipeline/stages/ralplan.d.ts.map +1 -1
- package/dist/pipeline/stages/ralplan.js +36 -8
- package/dist/pipeline/stages/ralplan.js.map +1 -1
- package/dist/ralplan/__tests__/consensus-gate.test.js +73 -1316
- package/dist/ralplan/__tests__/consensus-gate.test.js.map +1 -1
- package/dist/ralplan/__tests__/documented-leader-preflight.test.d.ts +2 -0
- package/dist/ralplan/__tests__/documented-leader-preflight.test.d.ts.map +1 -0
- package/dist/ralplan/__tests__/documented-leader-preflight.test.js +335 -0
- package/dist/ralplan/__tests__/documented-leader-preflight.test.js.map +1 -0
- package/dist/ralplan/__tests__/runtime.test.js +85 -101
- package/dist/ralplan/__tests__/runtime.test.js.map +1 -1
- package/dist/ralplan/consensus-gate.d.ts +9 -1
- package/dist/ralplan/consensus-gate.d.ts.map +1 -1
- package/dist/ralplan/consensus-gate.js +82 -338
- package/dist/ralplan/consensus-gate.js.map +1 -1
- package/dist/ralplan/documented-leader-preflight.d.ts +41 -0
- package/dist/ralplan/documented-leader-preflight.d.ts.map +1 -0
- package/dist/ralplan/documented-leader-preflight.js +360 -0
- package/dist/ralplan/documented-leader-preflight.js.map +1 -0
- package/dist/ralplan/runtime.d.ts +2 -1
- package/dist/ralplan/runtime.d.ts.map +1 -1
- package/dist/ralplan/runtime.js +67 -168
- package/dist/ralplan/runtime.js.map +1 -1
- package/dist/runtime/__tests__/bridge.test.js +42 -1
- package/dist/runtime/__tests__/bridge.test.js.map +1 -1
- package/dist/runtime/bridge.d.ts +12 -0
- package/dist/runtime/bridge.d.ts.map +1 -1
- package/dist/runtime/bridge.js +52 -1
- package/dist/runtime/bridge.js.map +1 -1
- package/dist/runtime/run-state.d.ts +5 -1
- package/dist/runtime/run-state.d.ts.map +1 -1
- package/dist/runtime/run-state.js +15 -8
- package/dist/runtime/run-state.js.map +1 -1
- package/dist/scripts/__tests__/codex-native-hook.test.js +16323 -8978
- package/dist/scripts/__tests__/codex-native-hook.test.js.map +1 -1
- package/dist/scripts/__tests__/issue-3293-callsite-parity.test.d.ts +2 -0
- package/dist/scripts/__tests__/issue-3293-callsite-parity.test.d.ts.map +1 -0
- package/dist/scripts/__tests__/issue-3293-callsite-parity.test.js +225 -0
- package/dist/scripts/__tests__/issue-3293-callsite-parity.test.js.map +1 -0
- package/dist/scripts/__tests__/issue-3293-cancel-transaction.test.d.ts +2 -0
- package/dist/scripts/__tests__/issue-3293-cancel-transaction.test.d.ts.map +1 -0
- package/dist/scripts/__tests__/issue-3293-cancel-transaction.test.js +188 -0
- package/dist/scripts/__tests__/issue-3293-cancel-transaction.test.js.map +1 -0
- package/dist/scripts/__tests__/issue-3293-hook-owned-cancel.test.d.ts +2 -0
- package/dist/scripts/__tests__/issue-3293-hook-owned-cancel.test.d.ts.map +1 -0
- package/dist/scripts/__tests__/issue-3293-hook-owned-cancel.test.js +259 -0
- package/dist/scripts/__tests__/issue-3293-hook-owned-cancel.test.js.map +1 -0
- package/dist/scripts/__tests__/issue-3311-ultragoal-native-outside-tmux.test.d.ts +2 -0
- package/dist/scripts/__tests__/issue-3311-ultragoal-native-outside-tmux.test.d.ts.map +1 -0
- package/dist/scripts/__tests__/issue-3311-ultragoal-native-outside-tmux.test.js +491 -0
- package/dist/scripts/__tests__/issue-3311-ultragoal-native-outside-tmux.test.js.map +1 -0
- package/dist/scripts/__tests__/issue-3358-ultragoal-cancel-lifecycle.test.d.ts +2 -0
- package/dist/scripts/__tests__/issue-3358-ultragoal-cancel-lifecycle.test.d.ts.map +1 -0
- package/dist/scripts/__tests__/issue-3358-ultragoal-cancel-lifecycle.test.js +708 -0
- package/dist/scripts/__tests__/issue-3358-ultragoal-cancel-lifecycle.test.js.map +1 -0
- package/dist/scripts/__tests__/issue-3369-autopilot-ralplan-state-machine.test.d.ts +2 -0
- package/dist/scripts/__tests__/issue-3369-autopilot-ralplan-state-machine.test.d.ts.map +1 -0
- package/dist/scripts/__tests__/issue-3369-autopilot-ralplan-state-machine.test.js +208 -0
- package/dist/scripts/__tests__/issue-3369-autopilot-ralplan-state-machine.test.js.map +1 -0
- package/dist/scripts/__tests__/notify-state-io.test.js +26 -0
- package/dist/scripts/__tests__/notify-state-io.test.js.map +1 -1
- package/dist/scripts/__tests__/notify-tmux-injection.test.js +23 -1
- package/dist/scripts/__tests__/notify-tmux-injection.test.js.map +1 -1
- package/dist/scripts/__tests__/postinstall.test.js +19 -1
- package/dist/scripts/__tests__/postinstall.test.js.map +1 -1
- package/dist/scripts/__tests__/reasoning-artifact-contract.test.d.ts +2 -0
- package/dist/scripts/__tests__/reasoning-artifact-contract.test.d.ts.map +1 -0
- package/dist/scripts/__tests__/reasoning-artifact-contract.test.js +108 -0
- package/dist/scripts/__tests__/reasoning-artifact-contract.test.js.map +1 -0
- package/dist/scripts/__tests__/role-intent-bootstrap-e2e-3181.test.js +63 -173
- package/dist/scripts/__tests__/role-intent-bootstrap-e2e-3181.test.js.map +1 -1
- package/dist/scripts/__tests__/role-intent-durable-recovery-3181.test.js +74 -159
- package/dist/scripts/__tests__/role-intent-durable-recovery-3181.test.js.map +1 -1
- package/dist/scripts/__tests__/smoke-packed-install.test.js +404 -13
- package/dist/scripts/__tests__/smoke-packed-install.test.js.map +1 -1
- package/dist/scripts/__tests__/verify-native-release-assets.test.d.ts +2 -0
- package/dist/scripts/__tests__/verify-native-release-assets.test.d.ts.map +1 -0
- package/dist/scripts/__tests__/verify-native-release-assets.test.js +235 -0
- package/dist/scripts/__tests__/verify-native-release-assets.test.js.map +1 -0
- package/dist/scripts/codex-native-hook.d.ts +53 -1
- package/dist/scripts/codex-native-hook.d.ts.map +1 -1
- package/dist/scripts/codex-native-hook.js +14448 -1480
- package/dist/scripts/codex-native-hook.js.map +1 -1
- package/dist/scripts/codex-native-pre-post.d.ts +9 -0
- package/dist/scripts/codex-native-pre-post.d.ts.map +1 -1
- package/dist/scripts/codex-native-pre-post.js +35 -12
- package/dist/scripts/codex-native-pre-post.js.map +1 -1
- package/dist/scripts/generate-native-release-manifest.js +80 -51
- package/dist/scripts/generate-native-release-manifest.js.map +1 -1
- package/dist/scripts/notify-fallback-watcher.js +78 -115
- package/dist/scripts/notify-fallback-watcher.js.map +1 -1
- package/dist/scripts/notify-hook/auto-nudge.d.ts.map +1 -1
- package/dist/scripts/notify-hook/auto-nudge.js +74 -3
- package/dist/scripts/notify-hook/auto-nudge.js.map +1 -1
- package/dist/scripts/notify-hook/state-io.d.ts.map +1 -1
- package/dist/scripts/notify-hook/state-io.js +5 -0
- package/dist/scripts/notify-hook/state-io.js.map +1 -1
- package/dist/scripts/notify-hook/team-dispatch.d.ts +9 -0
- package/dist/scripts/notify-hook/team-dispatch.d.ts.map +1 -1
- package/dist/scripts/notify-hook/team-dispatch.js +234 -79
- package/dist/scripts/notify-hook/team-dispatch.js.map +1 -1
- package/dist/scripts/notify-hook/team-leader-nudge.d.ts.map +1 -1
- package/dist/scripts/notify-hook/team-leader-nudge.js +71 -16
- package/dist/scripts/notify-hook/team-leader-nudge.js.map +1 -1
- package/dist/scripts/notify-hook/team-tmux-guard.d.ts +12 -4
- package/dist/scripts/notify-hook/team-tmux-guard.d.ts.map +1 -1
- package/dist/scripts/notify-hook/team-tmux-guard.js +365 -44
- package/dist/scripts/notify-hook/team-tmux-guard.js.map +1 -1
- package/dist/scripts/notify-hook/team-worker-stop.d.ts.map +1 -1
- package/dist/scripts/notify-hook/team-worker-stop.js +42 -21
- package/dist/scripts/notify-hook/team-worker-stop.js.map +1 -1
- package/dist/scripts/notify-hook/team-worker.d.ts +3 -0
- package/dist/scripts/notify-hook/team-worker.d.ts.map +1 -1
- package/dist/scripts/notify-hook/team-worker.js +82 -33
- package/dist/scripts/notify-hook/team-worker.js.map +1 -1
- package/dist/scripts/notify-hook/tmux-injection.d.ts +1 -0
- package/dist/scripts/notify-hook/tmux-injection.d.ts.map +1 -1
- package/dist/scripts/notify-hook/tmux-injection.js +32 -3
- package/dist/scripts/notify-hook/tmux-injection.js.map +1 -1
- package/dist/scripts/notify-hook.js +79 -82
- package/dist/scripts/notify-hook.js.map +1 -1
- package/dist/scripts/postinstall-advisory.d.ts +14 -0
- package/dist/scripts/postinstall-advisory.d.ts.map +1 -0
- package/dist/scripts/postinstall-advisory.js +49 -0
- package/dist/scripts/postinstall-advisory.js.map +1 -0
- package/dist/scripts/postinstall.d.ts +1 -1
- package/dist/scripts/postinstall.d.ts.map +1 -1
- package/dist/scripts/postinstall.js +1 -1
- package/dist/scripts/postinstall.js.map +1 -1
- package/dist/scripts/smoke-packed-install.d.ts +80 -4
- package/dist/scripts/smoke-packed-install.d.ts.map +1 -1
- package/dist/scripts/smoke-packed-install.js +3162 -26
- package/dist/scripts/smoke-packed-install.js.map +1 -1
- package/dist/scripts/sync-plugin-mirror.d.ts.map +1 -1
- package/dist/scripts/sync-plugin-mirror.js +16 -1
- package/dist/scripts/sync-plugin-mirror.js.map +1 -1
- package/dist/scripts/verify-native-release-assets.js +37 -23
- package/dist/scripts/verify-native-release-assets.js.map +1 -1
- package/dist/state/__tests__/mode-state-context.test.js +61 -0
- package/dist/state/__tests__/mode-state-context.test.js.map +1 -1
- package/dist/state/__tests__/operations.test.js +462 -850
- package/dist/state/__tests__/operations.test.js.map +1 -1
- package/dist/state/__tests__/skill-active.test.js +400 -2
- package/dist/state/__tests__/skill-active.test.js.map +1 -1
- package/dist/state/mode-state-context.d.ts +8 -0
- package/dist/state/mode-state-context.d.ts.map +1 -1
- package/dist/state/mode-state-context.js +73 -0
- package/dist/state/mode-state-context.js.map +1 -1
- package/dist/state/operations.d.ts +3 -0
- package/dist/state/operations.d.ts.map +1 -1
- package/dist/state/operations.js +144 -40
- package/dist/state/operations.js.map +1 -1
- package/dist/state/skill-active.d.ts +18 -1
- package/dist/state/skill-active.d.ts.map +1 -1
- package/dist/state/skill-active.js +420 -23
- package/dist/state/skill-active.js.map +1 -1
- package/dist/state/workflow-transition-reconcile.d.ts +3 -0
- package/dist/state/workflow-transition-reconcile.d.ts.map +1 -1
- package/dist/state/workflow-transition-reconcile.js +10 -4
- package/dist/state/workflow-transition-reconcile.js.map +1 -1
- package/dist/state/workflow-transition.d.ts.map +1 -1
- package/dist/state/workflow-transition.js +3 -1
- package/dist/state/workflow-transition.js.map +1 -1
- package/dist/subagents/__tests__/leader-bootstrap-3181.test.js +53 -247
- package/dist/subagents/__tests__/leader-bootstrap-3181.test.js.map +1 -1
- package/dist/subagents/__tests__/tracker.test.js +837 -1048
- package/dist/subagents/__tests__/tracker.test.js.map +1 -1
- package/dist/subagents/tracker.d.ts +58 -135
- package/dist/subagents/tracker.d.ts.map +1 -1
- package/dist/subagents/tracker.js +811 -632
- package/dist/subagents/tracker.js.map +1 -1
- package/dist/team/__tests__/api-interop.test.js +91 -1
- package/dist/team/__tests__/api-interop.test.js.map +1 -1
- package/dist/team/__tests__/delivery-e2e-smoke.test.js +58 -17
- package/dist/team/__tests__/delivery-e2e-smoke.test.js.map +1 -1
- package/dist/team/__tests__/idle-nudge.test.js +45 -15
- package/dist/team/__tests__/idle-nudge.test.js.map +1 -1
- package/dist/team/__tests__/mcp-comm.test.js +197 -30
- package/dist/team/__tests__/mcp-comm.test.js.map +1 -1
- package/dist/team/__tests__/model-contract.test.js +110 -3
- package/dist/team/__tests__/model-contract.test.js.map +1 -1
- package/dist/team/__tests__/notice-ledger.test.d.ts +2 -0
- package/dist/team/__tests__/notice-ledger.test.d.ts.map +1 -0
- package/dist/team/__tests__/notice-ledger.test.js +131 -0
- package/dist/team/__tests__/notice-ledger.test.js.map +1 -0
- package/dist/team/__tests__/pane-teardown-residue-e2e.test.d.ts +2 -0
- package/dist/team/__tests__/pane-teardown-residue-e2e.test.d.ts.map +1 -0
- package/dist/team/__tests__/pane-teardown-residue-e2e.test.js +410 -0
- package/dist/team/__tests__/pane-teardown-residue-e2e.test.js.map +1 -0
- package/dist/team/__tests__/phase-controller.test.js +20 -0
- package/dist/team/__tests__/phase-controller.test.js.map +1 -1
- package/dist/team/__tests__/runtime-cli.test.js +9 -0
- package/dist/team/__tests__/runtime-cli.test.js.map +1 -1
- package/dist/team/__tests__/runtime.test.js +3963 -428
- package/dist/team/__tests__/runtime.test.js.map +1 -1
- package/dist/team/__tests__/scaling.test.js +2405 -203
- package/dist/team/__tests__/scaling.test.js.map +1 -1
- package/dist/team/__tests__/state-root.test.js +117 -2
- package/dist/team/__tests__/state-root.test.js.map +1 -1
- package/dist/team/__tests__/state.test.js +804 -36
- package/dist/team/__tests__/state.test.js.map +1 -1
- package/dist/team/__tests__/team-ops-contract.test.js +6 -0
- package/dist/team/__tests__/team-ops-contract.test.js.map +1 -1
- package/dist/team/__tests__/tmux-session.test.js +4099 -1039
- package/dist/team/__tests__/tmux-session.test.js.map +1 -1
- package/dist/team/__tests__/tmux-source-authority-realtmux.test.d.ts +2 -0
- package/dist/team/__tests__/tmux-source-authority-realtmux.test.d.ts.map +1 -0
- package/dist/team/__tests__/tmux-source-authority-realtmux.test.js +149 -0
- package/dist/team/__tests__/tmux-source-authority-realtmux.test.js.map +1 -0
- package/dist/team/__tests__/tmux-test-fixture.d.ts +69 -0
- package/dist/team/__tests__/tmux-test-fixture.d.ts.map +1 -1
- package/dist/team/__tests__/tmux-test-fixture.js +242 -15
- package/dist/team/__tests__/tmux-test-fixture.js.map +1 -1
- package/dist/team/__tests__/tmux-test-fixture.test.js +177 -1
- package/dist/team/__tests__/tmux-test-fixture.test.js.map +1 -1
- package/dist/team/__tests__/worker-runtime-identity.test.js +53 -44
- package/dist/team/__tests__/worker-runtime-identity.test.js.map +1 -1
- package/dist/team/__tests__/worktree.test.js +6 -0
- package/dist/team/__tests__/worktree.test.js.map +1 -1
- package/dist/team/api-interop.d.ts.map +1 -1
- package/dist/team/api-interop.js +15 -9
- package/dist/team/api-interop.js.map +1 -1
- package/dist/team/exact-pane.d.ts +23 -0
- package/dist/team/exact-pane.d.ts.map +1 -0
- package/dist/team/exact-pane.js +133 -0
- package/dist/team/exact-pane.js.map +1 -0
- package/dist/team/idle-nudge.d.ts +12 -1
- package/dist/team/idle-nudge.d.ts.map +1 -1
- package/dist/team/idle-nudge.js +41 -14
- package/dist/team/idle-nudge.js.map +1 -1
- package/dist/team/model-contract.d.ts +2 -5
- package/dist/team/model-contract.d.ts.map +1 -1
- package/dist/team/model-contract.js +59 -17
- package/dist/team/model-contract.js.map +1 -1
- package/dist/team/notice-ledger.d.ts +54 -0
- package/dist/team/notice-ledger.d.ts.map +1 -0
- package/dist/team/notice-ledger.js +259 -0
- package/dist/team/notice-ledger.js.map +1 -0
- package/dist/team/phase-controller.d.ts.map +1 -1
- package/dist/team/phase-controller.js +2 -0
- package/dist/team/phase-controller.js.map +1 -1
- package/dist/team/runtime.d.ts +4 -1
- package/dist/team/runtime.d.ts.map +1 -1
- package/dist/team/runtime.js +1527 -291
- package/dist/team/runtime.js.map +1 -1
- package/dist/team/scaling.d.ts +13 -1
- package/dist/team/scaling.d.ts.map +1 -1
- package/dist/team/scaling.js +1503 -387
- package/dist/team/scaling.js.map +1 -1
- package/dist/team/state/dispatch.d.ts.map +1 -1
- package/dist/team/state/dispatch.js +26 -8
- package/dist/team/state/dispatch.js.map +1 -1
- package/dist/team/state/locks.d.ts.map +1 -1
- package/dist/team/state/locks.js +61 -13
- package/dist/team/state/locks.js.map +1 -1
- package/dist/team/state/monitor.d.ts +10 -0
- package/dist/team/state/monitor.d.ts.map +1 -1
- package/dist/team/state/monitor.js +13 -0
- package/dist/team/state/monitor.js.map +1 -1
- package/dist/team/state/tasks.d.ts.map +1 -1
- package/dist/team/state/tasks.js +4 -3
- package/dist/team/state/tasks.js.map +1 -1
- package/dist/team/state/types.d.ts +15 -0
- package/dist/team/state/types.d.ts.map +1 -1
- package/dist/team/state/types.js.map +1 -1
- package/dist/team/state-root.d.ts +12 -0
- package/dist/team/state-root.d.ts.map +1 -1
- package/dist/team/state-root.js +177 -0
- package/dist/team/state-root.js.map +1 -1
- package/dist/team/state.d.ts +85 -1
- package/dist/team/state.d.ts.map +1 -1
- package/dist/team/state.js +672 -129
- package/dist/team/state.js.map +1 -1
- package/dist/team/team-ops.d.ts +5 -0
- package/dist/team/team-ops.d.ts.map +1 -1
- package/dist/team/team-ops.js +4 -0
- package/dist/team/team-ops.js.map +1 -1
- package/dist/team/tmux-session.d.ts +163 -27
- package/dist/team/tmux-session.d.ts.map +1 -1
- package/dist/team/tmux-session.js +1682 -376
- package/dist/team/tmux-session.js.map +1 -1
- package/dist/team/worktree.d.ts.map +1 -1
- package/dist/team/worktree.js +4 -0
- package/dist/team/worktree.js.map +1 -1
- package/dist/ultragoal/__tests__/artifacts.test.js +950 -2
- package/dist/ultragoal/__tests__/artifacts.test.js.map +1 -1
- package/dist/ultragoal/__tests__/docs-contract.test.js +1 -0
- package/dist/ultragoal/__tests__/docs-contract.test.js.map +1 -1
- package/dist/ultragoal/artifacts.d.ts +35 -1
- package/dist/ultragoal/artifacts.d.ts.map +1 -1
- package/dist/ultragoal/artifacts.js +232 -46
- package/dist/ultragoal/artifacts.js.map +1 -1
- package/dist/utils/__tests__/file-durability.test.d.ts +2 -0
- package/dist/utils/__tests__/file-durability.test.d.ts.map +1 -0
- package/dist/utils/__tests__/file-durability.test.js +114 -0
- package/dist/utils/__tests__/file-durability.test.js.map +1 -0
- package/dist/utils/__tests__/paths.test.js +21 -1
- package/dist/utils/__tests__/paths.test.js.map +1 -1
- package/dist/utils/file-durability.d.ts +25 -0
- package/dist/utils/file-durability.d.ts.map +1 -0
- package/dist/utils/file-durability.js +67 -0
- package/dist/utils/file-durability.js.map +1 -0
- package/dist/utils/paths.d.ts.map +1 -1
- package/dist/utils/paths.js +14 -6
- package/dist/utils/paths.js.map +1 -1
- package/dist/verification/__tests__/ci-rust-gates.test.js +49 -4
- package/dist/verification/__tests__/ci-rust-gates.test.js.map +1 -1
- package/dist/verification/__tests__/dev-merge-issue-close-workflow.test.js +3 -0
- package/dist/verification/__tests__/dev-merge-issue-close-workflow.test.js.map +1 -1
- package/dist/verification/__tests__/explore-harness-release-workflow.test.js +60 -0
- package/dist/verification/__tests__/explore-harness-release-workflow.test.js.map +1 -1
- package/dist/verification/__tests__/native-release-manifest.test.js +157 -4
- package/dist/verification/__tests__/native-release-manifest.test.js.map +1 -1
- package/dist/wiki/lint.d.ts +4 -1
- package/dist/wiki/lint.d.ts.map +1 -1
- package/dist/wiki/lint.js +3 -2
- package/dist/wiki/lint.js.map +1 -1
- package/package.json +13 -5
- package/plugins/oh-my-codex/.codex-plugin/plugin.json +1 -1
- package/plugins/oh-my-codex/hooks/codex-native-hook.mjs +105 -17
- package/plugins/oh-my-codex/skills/autopilot/SKILL.md +13 -5
- package/plugins/oh-my-codex/skills/cancel/SKILL.md +30 -85
- package/plugins/oh-my-codex/skills/ralph/SKILL.md +6 -4
- package/plugins/oh-my-codex/skills/ralplan/SKILL.md +16 -17
- package/plugins/oh-my-codex/skills/team/SKILL.md +13 -9
- package/plugins/oh-my-codex/skills/ultragoal/SKILL.md +3 -2
- package/skills/autopilot/SKILL.md +13 -5
- package/skills/cancel/SKILL.md +30 -85
- package/skills/ralph/SKILL.md +6 -4
- package/skills/ralplan/SKILL.md +16 -17
- package/skills/team/SKILL.md +13 -9
- package/skills/ultragoal/SKILL.md +3 -2
- package/src/scripts/__tests__/codex-native-hook.test.ts +8831 -951
- package/src/scripts/__tests__/issue-3293-callsite-parity.test.ts +187 -0
- package/src/scripts/__tests__/issue-3293-cancel-transaction.test.ts +189 -0
- package/src/scripts/__tests__/issue-3293-hook-owned-cancel.test.ts +187 -0
- package/src/scripts/__tests__/issue-3311-ultragoal-native-outside-tmux.test.ts +591 -0
- package/src/scripts/__tests__/issue-3358-ultragoal-cancel-lifecycle.test.ts +701 -0
- package/src/scripts/__tests__/issue-3369-autopilot-ralplan-state-machine.test.ts +211 -0
- package/src/scripts/__tests__/notify-state-io.test.ts +29 -0
- package/src/scripts/__tests__/notify-tmux-injection.test.ts +26 -1
- package/src/scripts/__tests__/postinstall.test.ts +23 -1
- package/src/scripts/__tests__/reasoning-artifact-contract.test.ts +129 -0
- package/src/scripts/__tests__/role-intent-bootstrap-e2e-3181.test.ts +67 -185
- package/src/scripts/__tests__/role-intent-durable-recovery-3181.test.ts +79 -178
- package/src/scripts/__tests__/smoke-packed-install.test.ts +525 -16
- package/src/scripts/__tests__/verify-native-release-assets.test.ts +240 -0
- package/src/scripts/codex-native-hook.ts +14238 -1429
- package/src/scripts/codex-native-pre-post.ts +43 -12
- package/src/scripts/generate-native-release-manifest.ts +83 -79
- package/src/scripts/notify-fallback-watcher.ts +91 -122
- package/src/scripts/notify-hook/auto-nudge.ts +71 -3
- package/src/scripts/notify-hook/state-io.ts +11 -0
- package/src/scripts/notify-hook/team-dispatch.ts +242 -75
- package/src/scripts/notify-hook/team-leader-nudge.ts +72 -22
- package/src/scripts/notify-hook/team-tmux-guard.ts +366 -39
- package/src/scripts/notify-hook/team-worker-stop.ts +43 -20
- package/src/scripts/notify-hook/team-worker.ts +82 -31
- package/src/scripts/notify-hook/tmux-injection.ts +33 -3
- package/src/scripts/notify-hook.ts +84 -82
- package/src/scripts/postinstall-advisory.ts +67 -0
- package/src/scripts/postinstall.ts +1 -1
- package/src/scripts/smoke-packed-install.ts +3608 -39
- package/src/scripts/sync-plugin-mirror.ts +17 -1
- package/src/scripts/verify-native-release-assets.ts +39 -31
- package/templates/AGENTS.md +1 -0
- package/dist/subagents/__tests__/adapted-role-binding.test.d.ts +0 -2
- package/dist/subagents/__tests__/adapted-role-binding.test.d.ts.map +0 -1
- package/dist/subagents/__tests__/adapted-role-binding.test.js +0 -988
- package/dist/subagents/__tests__/adapted-role-binding.test.js.map +0 -1
- package/dist/subagents/adapted-role-binding.d.ts +0 -17
- package/dist/subagents/adapted-role-binding.d.ts.map +0 -1
- package/dist/subagents/adapted-role-binding.js +0 -75
- 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
|
-
'
|
|
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
|
-
|
|
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
|
-
'
|
|
215
|
+
' show-option)',
|
|
216
|
+
' case "$*" in',
|
|
217
|
+
' *"@omx_team_pane_owner_id"*) echo "team:scale-up" ;;',
|
|
218
|
+
' *) exit 1 ;;',
|
|
219
|
+
' esac',
|
|
160
220
|
' ;;',
|
|
161
|
-
'
|
|
162
|
-
'
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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('
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
804
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
'
|
|
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
|
|
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
|
|
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-
|
|
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.
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
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-
|
|
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
|
|
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
|
|
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
|
|
1566
|
-
|
|
1567
|
-
assert.
|
|
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
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
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
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
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 = '
|
|
1610
|
-
await
|
|
1611
|
-
await
|
|
1612
|
-
await
|
|
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.
|
|
1623
|
-
config
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
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
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
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
|
-
'
|
|
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 =
|
|
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
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
const
|
|
1742
|
-
|
|
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
|
|
1757
|
-
|
|
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 (
|
|
1760
|
-
|
|
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('
|
|
1768
|
-
const cwd = await
|
|
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
|
|
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
|
|
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 = '%
|
|
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,
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
assert.
|
|
1896
|
-
|
|
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')
|