oh-my-codex 0.20.2 → 0.20.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +7 -6
- package/Cargo.toml +1 -1
- package/README.md +26 -0
- package/crates/omx-runtime/Cargo.toml +1 -0
- package/crates/omx-runtime/src/main.rs +25 -2
- package/crates/omx-runtime/tests/execution.rs +219 -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/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/hotswap.d.ts +6 -0
- package/dist/auth/hotswap.d.ts.map +1 -1
- package/dist/auth/hotswap.js +85 -15
- package/dist/auth/hotswap.js.map +1 -1
- package/dist/autopilot/__tests__/ralplan-gate.test.js +1 -1
- package/dist/autopilot/__tests__/ralplan-gate.test.js.map +1 -1
- package/dist/cli/__tests__/auth.test.js +60 -6
- package/dist/cli/__tests__/auth.test.js.map +1 -1
- package/dist/cli/__tests__/codex-plugin-layout.test.js +80 -0
- package/dist/cli/__tests__/codex-plugin-layout.test.js.map +1 -1
- package/dist/cli/__tests__/doctor-warning-copy.test.js +103 -1
- package/dist/cli/__tests__/doctor-warning-copy.test.js.map +1 -1
- package/dist/cli/__tests__/exec.test.js +136 -0
- package/dist/cli/__tests__/exec.test.js.map +1 -1
- package/dist/cli/__tests__/index.test.js +258 -4
- package/dist/cli/__tests__/index.test.js.map +1 -1
- package/dist/cli/__tests__/launch-fallback.test.js +287 -1
- package/dist/cli/__tests__/launch-fallback.test.js.map +1 -1
- package/dist/cli/__tests__/mcp-parity.test.js +4 -1
- package/dist/cli/__tests__/mcp-parity.test.js.map +1 -1
- package/dist/cli/__tests__/native-hook-claim-journal.test.js +84 -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 +56 -171
- package/dist/cli/__tests__/ralplan-bootstrap-3181.test.js.map +1 -1
- package/dist/cli/__tests__/ralplan.test.js +37 -111
- 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-search-help.test.js +25 -1
- package/dist/cli/__tests__/session-search-help.test.js.map +1 -1
- package/dist/cli/__tests__/setup-install-mode.test.js +42 -4
- package/dist/cli/__tests__/setup-install-mode.test.js.map +1 -1
- package/dist/cli/__tests__/team.test.js +105 -7
- package/dist/cli/__tests__/team.test.js.map +1 -1
- package/dist/cli/__tests__/uninstall.test.js +46 -0
- package/dist/cli/__tests__/uninstall.test.js.map +1 -1
- package/dist/cli/__tests__/windows-popup-loop-contract.test.js +2 -1
- package/dist/cli/__tests__/windows-popup-loop-contract.test.js.map +1 -1
- package/dist/cli/doctor.d.ts +3 -0
- package/dist/cli/doctor.d.ts.map +1 -1
- package/dist/cli/doctor.js +15 -2
- package/dist/cli/doctor.js.map +1 -1
- package/dist/cli/index.d.ts +4 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +138 -38
- 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-hook-claim-journal.d.ts +16 -5
- package/dist/cli/native-hook-claim-journal.d.ts.map +1 -1
- package/dist/cli/native-hook-claim-journal.js +42 -29
- package/dist/cli/native-hook-claim-journal.js.map +1 -1
- package/dist/cli/ralph.js +2 -2
- package/dist/cli/ralph.js.map +1 -1
- package/dist/cli/ralplan.d.ts +4 -8
- package/dist/cli/ralplan.d.ts.map +1 -1
- package/dist/cli/ralplan.js +19 -112
- 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.d.ts +5 -0
- package/dist/cli/setup.d.ts.map +1 -1
- package/dist/cli/setup.js +80 -31
- package/dist/cli/setup.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 +61 -19
- package/dist/cli/uninstall.js.map +1 -1
- package/dist/compat/__tests__/rust-runtime-compat.test.js +1 -1
- package/dist/config/__tests__/generator-idempotent.test.js +330 -1
- package/dist/config/__tests__/generator-idempotent.test.js.map +1 -1
- package/dist/config/__tests__/generator-notify.test.js +2 -2
- 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 +4 -20
- package/dist/config/generator.d.ts.map +1 -1
- package/dist/config/generator.js +499 -119
- 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/hooks/__tests__/agents-overlay.test.js +2 -2
- package/dist/hooks/__tests__/agents-overlay.test.js.map +1 -1
- package/dist/hooks/__tests__/keyword-detector.test.js +4 -4
- package/dist/hooks/__tests__/keyword-detector.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-fallback-watcher.test.js +238 -543
- package/dist/hooks/__tests__/notify-fallback-watcher.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-all-workers-idle.test.js +38 -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 +96 -9
- package/dist/hooks/__tests__/notify-hook-auto-nudge.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-team-dispatch.test.js +531 -12
- package/dist/hooks/__tests__/notify-hook-team-dispatch.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js +155 -23
- 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-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 +43 -4
- package/dist/hooks/__tests__/notify-hook-worker-idle.test.js.map +1 -1
- package/dist/hooks/__tests__/session.test.js +348 -2
- package/dist/hooks/__tests__/session.test.js.map +1 -1
- package/dist/hooks/agents-overlay.js +1 -1
- package/dist/hooks/session.d.ts +26 -2
- package/dist/hooks/session.d.ts.map +1 -1
- package/dist/hooks/session.js +169 -13
- package/dist/hooks/session.js.map +1 -1
- package/dist/leader/__tests__/contract.test.js +57 -7
- package/dist/leader/__tests__/contract.test.js.map +1 -1
- package/dist/leader/contract.d.ts +16 -0
- package/dist/leader/contract.d.ts.map +1 -1
- package/dist/leader/contract.js +79 -4
- 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__/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/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-tmux-pane.test.js +4 -4
- package/dist/modes/__tests__/base-tmux-pane.test.js.map +1 -1
- 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 +4 -4
- package/dist/pipeline/__tests__/orchestrator.test.js.map +1 -1
- package/dist/pipeline/__tests__/stages.test.js +8 -8
- package/dist/pipeline/__tests__/stages.test.js.map +1 -1
- package/dist/ralplan/__tests__/consensus-gate.test.js +134 -10
- 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 +46 -0
- package/dist/ralplan/__tests__/documented-leader-preflight.test.js.map +1 -0
- package/dist/ralplan/__tests__/runtime.test.js +8 -4
- package/dist/ralplan/__tests__/runtime.test.js.map +1 -1
- package/dist/ralplan/consensus-gate.d.ts.map +1 -1
- package/dist/ralplan/consensus-gate.js +58 -50
- package/dist/ralplan/consensus-gate.js.map +1 -1
- package/dist/ralplan/documented-leader-preflight.d.ts +34 -0
- package/dist/ralplan/documented-leader-preflight.d.ts.map +1 -0
- package/dist/ralplan/documented-leader-preflight.js +58 -0
- package/dist/ralplan/documented-leader-preflight.js.map +1 -0
- package/dist/ralplan/runtime.d.ts +1 -0
- package/dist/ralplan/runtime.d.ts.map +1 -1
- package/dist/ralplan/runtime.js +11 -7
- 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/scripts/__tests__/codex-native-hook.test.js +2620 -487
- package/dist/scripts/__tests__/codex-native-hook.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 +46 -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 +39 -159
- package/dist/scripts/__tests__/role-intent-durable-recovery-3181.test.js.map +1 -1
- package/dist/scripts/__tests__/smoke-packed-install.test.js +254 -2
- package/dist/scripts/__tests__/smoke-packed-install.test.js.map +1 -1
- package/dist/scripts/codex-native-hook.d.ts +4 -1
- package/dist/scripts/codex-native-hook.d.ts.map +1 -1
- package/dist/scripts/codex-native-hook.js +11013 -1153
- package/dist/scripts/codex-native-hook.js.map +1 -1
- package/dist/scripts/notify-fallback-watcher.js +82 -108
- 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/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 +39 -13
- 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 +19 -18
- 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 +37 -28
- package/dist/scripts/notify-hook/team-worker.js.map +1 -1
- package/dist/scripts/notify-hook/tmux-injection.d.ts.map +1 -1
- package/dist/scripts/notify-hook/tmux-injection.js +28 -2
- package/dist/scripts/notify-hook/tmux-injection.js.map +1 -1
- package/dist/scripts/notify-hook.js +1 -0
- package/dist/scripts/notify-hook.js.map +1 -1
- package/dist/scripts/smoke-packed-install.d.ts +48 -3
- package/dist/scripts/smoke-packed-install.d.ts.map +1 -1
- package/dist/scripts/smoke-packed-install.js +2796 -13
- package/dist/scripts/smoke-packed-install.js.map +1 -1
- package/dist/state/__tests__/mode-state-context.test.js +54 -0
- package/dist/state/__tests__/mode-state-context.test.js.map +1 -1
- package/dist/state/__tests__/operations.test.js +2 -0
- package/dist/state/__tests__/operations.test.js.map +1 -1
- package/dist/state/mode-state-context.d.ts +5 -0
- package/dist/state/mode-state-context.d.ts.map +1 -1
- package/dist/state/mode-state-context.js +59 -0
- package/dist/state/mode-state-context.js.map +1 -1
- package/dist/subagents/__tests__/leader-bootstrap-3181.test.js +24 -245
- package/dist/subagents/__tests__/leader-bootstrap-3181.test.js.map +1 -1
- package/dist/subagents/tracker.d.ts +1 -82
- package/dist/subagents/tracker.d.ts.map +1 -1
- package/dist/subagents/tracker.js +3 -275
- 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 +52 -10
- 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 +93 -27
- 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__/runtime-cli.test.js +9 -0
- package/dist/team/__tests__/runtime-cli.test.js.map +1 -1
- package/dist/team/__tests__/runtime.test.js +3204 -335
- package/dist/team/__tests__/runtime.test.js.map +1 -1
- package/dist/team/__tests__/scaling.test.js +2359 -142
- package/dist/team/__tests__/scaling.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 +3049 -784
- package/dist/team/__tests__/tmux-session.test.js.map +1 -1
- package/dist/team/__tests__/worker-runtime-identity.test.js +38 -2
- 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/runtime.d.ts +1 -0
- package/dist/team/runtime.d.ts.map +1 -1
- package/dist/team/runtime.js +1244 -180
- 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 +1430 -384
- 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/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.d.ts +67 -1
- package/dist/team/state.d.ts.map +1 -1
- package/dist/team/state.js +636 -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 +123 -23
- package/dist/team/tmux-session.d.ts.map +1 -1
- package/dist/team/tmux-session.js +1250 -291
- 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/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 +57 -0
- package/dist/utils/__tests__/file-durability.test.js.map +1 -0
- package/dist/utils/file-durability.d.ts +15 -0
- package/dist/utils/file-durability.d.ts.map +1 -0
- package/dist/utils/file-durability.js +37 -0
- package/dist/utils/file-durability.js.map +1 -0
- 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/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 +1 -1
- package/plugins/oh-my-codex/.codex-plugin/plugin.json +1 -1
- package/plugins/oh-my-codex/hooks/codex-native-hook.mjs +24 -0
- package/plugins/oh-my-codex/skills/ralph/SKILL.md +6 -4
- package/plugins/oh-my-codex/skills/ralplan/SKILL.md +3 -2
- package/plugins/oh-my-codex/skills/team/SKILL.md +13 -9
- package/skills/ralph/SKILL.md +6 -4
- package/skills/ralplan/SKILL.md +3 -2
- package/skills/team/SKILL.md +13 -9
- package/src/scripts/__tests__/codex-native-hook.test.ts +2826 -497
- package/src/scripts/__tests__/reasoning-artifact-contract.test.ts +129 -0
- package/src/scripts/__tests__/role-intent-bootstrap-e2e-3181.test.ts +48 -185
- package/src/scripts/__tests__/role-intent-durable-recovery-3181.test.ts +44 -177
- package/src/scripts/__tests__/smoke-packed-install.test.ts +320 -2
- package/src/scripts/codex-native-hook.ts +10715 -1069
- package/src/scripts/notify-fallback-watcher.ts +84 -115
- package/src/scripts/notify-hook/auto-nudge.ts +71 -3
- package/src/scripts/notify-hook/team-dispatch.ts +242 -75
- package/src/scripts/notify-hook/team-leader-nudge.ts +40 -18
- package/src/scripts/notify-hook/team-tmux-guard.ts +366 -39
- package/src/scripts/notify-hook/team-worker-stop.ts +20 -16
- package/src/scripts/notify-hook/team-worker.ts +39 -26
- package/src/scripts/notify-hook/tmux-injection.ts +28 -2
- package/src/scripts/notify-hook.ts +1 -0
- package/src/scripts/smoke-packed-install.ts +3196 -14
- 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
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, it } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import { chmod, mkdtemp, rm, writeFile, readFile, mkdir, utimes } from 'fs/promises';
|
|
3
|
+
import { chmod, mkdtemp, rm, writeFile, readFile, mkdir, open, rename, utimes, symlink } from 'fs/promises';
|
|
4
4
|
import { join } from 'path';
|
|
5
5
|
import { tmpdir } from 'os';
|
|
6
6
|
import { existsSync, readFileSync } from 'fs';
|
|
7
|
-
import { ABSOLUTE_MAX_WORKERS, DEFAULT_MAX_WORKERS, cleanupTeamState, createTask, claimTask, computeTaskReadiness, getTeamSummary, initTeamState, listTasks, migrateV1ToV2, readTask, readTeamConfig, readTeamManifestV2, transitionTaskStatus, releaseTaskClaim, reclaimExpiredTaskClaim, sendDirectMessage, broadcastMessage, markMessageDelivered, markMessageNotified, listMailboxMessages, writeTaskApproval, readTaskApproval, readWorkerHeartbeat, readWorkerStatus, updateTask, updateWorkerHeartbeat, writeAtomic, setWriteAtomicRenameForTests, resetWriteAtomicRenameForTests, writeWorkerInbox, enqueueDispatchRequest, listDispatchRequests, markDispatchRequestNotified, markDispatchRequestDelivered, transitionDispatchRequest, readDispatchRequest, readMonitorSnapshot, resolveDispatchLockTimeoutMs, writeTeamManifestV2, } from '../state.js';
|
|
7
|
+
import { ABSOLUTE_MAX_WORKERS, DEFAULT_MAX_WORKERS, cleanupTeamState, createTask, claimTask, computeTaskReadiness, getTeamSummary, initTeamState, listTasks, migrateV1ToV2, readTask, readTeamConfig, saveTeamConfig, recoverTeamMembershipTaskTransaction, commitTeamMembershipTaskTransaction, withTeamTaskBarrier, readTeamManifestV2, transitionTaskStatus, releaseTaskClaim, reclaimExpiredTaskClaim, sendDirectMessage, broadcastMessage, markMessageDelivered, markMessageNotified, listMailboxMessages, writeTaskApproval, readTaskApproval, readWorkerHeartbeat, readWorkerStatus, updateTask, updateWorkerHeartbeat, writeAtomic, setWriteAtomicRenameForTests, resetWriteAtomicRenameForTests, setWriteAtomicOpenForTests, resetWriteAtomicOpenForTests, setWriteAtomicPlatformForTests, resetWriteAtomicPlatformForTests, writeWorkerInbox, enqueueDispatchRequest, listDispatchRequests, markDispatchRequestNotified, markDispatchRequestDelivered, transitionDispatchRequest, readDispatchRequest, readMonitorSnapshot, resolveDispatchLockTimeoutMs, writeTeamManifestV2, removeDispatchRequestsForWorkers, withScalingLock, } from '../state.js';
|
|
8
8
|
import { normalizeDispatchRequest } from '../state/dispatch.js';
|
|
9
|
+
import { claimTask as claimTaskWithDeps } from '../state/tasks.js';
|
|
10
|
+
import { withScalingLock as withScalingLeaseLock, withTeamLock } from '../state/locks.js';
|
|
9
11
|
const ORIGINAL_OMX_TEAM_STATE_ROOT = process.env.OMX_TEAM_STATE_ROOT;
|
|
10
12
|
beforeEach(() => {
|
|
11
13
|
delete process.env.OMX_TEAM_STATE_ROOT;
|
|
12
14
|
});
|
|
13
15
|
afterEach(() => {
|
|
14
16
|
resetWriteAtomicRenameForTests();
|
|
17
|
+
resetWriteAtomicOpenForTests();
|
|
18
|
+
resetWriteAtomicPlatformForTests();
|
|
15
19
|
if (typeof ORIGINAL_OMX_TEAM_STATE_ROOT === 'string')
|
|
16
20
|
process.env.OMX_TEAM_STATE_ROOT = ORIGINAL_OMX_TEAM_STATE_ROOT;
|
|
17
21
|
else
|
|
@@ -66,6 +70,8 @@ if (argv[0] === 'schema') {
|
|
|
66
70
|
'mark-delivered',
|
|
67
71
|
'mark-failed',
|
|
68
72
|
'request-replay',
|
|
73
|
+
'remove-dispatch-records',
|
|
74
|
+
|
|
69
75
|
'capture-snapshot',
|
|
70
76
|
],
|
|
71
77
|
events: [],
|
|
@@ -133,6 +139,21 @@ switch (command.command) {
|
|
|
133
139
|
process.stdout.write(JSON.stringify({ event: 'DispatchFailed', request_id: command.request_id, reason: command.reason }) + '\\n');
|
|
134
140
|
process.exit(0);
|
|
135
141
|
}
|
|
142
|
+
case 'RemoveDispatchRecords': {
|
|
143
|
+
const ids = new Set(command.request_ids || []);
|
|
144
|
+
if (process.env.OMX_TEST_ACK_RETAIN_DISPATCH !== '1') {
|
|
145
|
+
dispatch.records = dispatch.records.filter((entry) => !ids.has(entry.request_id));
|
|
146
|
+
writeJson(dispatchPath, dispatch);
|
|
147
|
+
}
|
|
148
|
+
process.stdout.write(JSON.stringify({ event: 'DispatchRecordsRemoved', request_ids: command.request_ids || [] }) + '\\n');
|
|
149
|
+
process.exit(0);
|
|
150
|
+
}
|
|
151
|
+
case 'CaptureSnapshot': {
|
|
152
|
+
if (process.env.OMX_TEST_SKIP_SNAPSHOT_COMPAT_WRITE !== '1') writeJson(dispatchPath, dispatch);
|
|
153
|
+
process.stdout.write(JSON.stringify({ event: 'SnapshotCaptured' }) + '\\n');
|
|
154
|
+
process.exit(0);
|
|
155
|
+
}
|
|
156
|
+
|
|
136
157
|
case 'CreateMailboxMessage': {
|
|
137
158
|
mailbox.records.push({
|
|
138
159
|
message_id: command.message_id,
|
|
@@ -198,6 +219,8 @@ describe('team state', () => {
|
|
|
198
219
|
assert.equal(diskCfg.tmux_session, 'omx-team-team-1');
|
|
199
220
|
assert.equal(diskCfg.lifecycle_profile, 'default');
|
|
200
221
|
assert.equal(diskCfg.leader_pane_id, null);
|
|
222
|
+
assert.equal(diskCfg.leader_pane_pid, null);
|
|
223
|
+
assert.equal(diskCfg.hud_pane_pid, null);
|
|
201
224
|
assert.equal(diskCfg.hud_pane_id, null);
|
|
202
225
|
assert.equal(diskCfg.resize_hook_name, null);
|
|
203
226
|
assert.equal(diskCfg.resize_hook_target, null);
|
|
@@ -209,6 +232,126 @@ describe('team state', () => {
|
|
|
209
232
|
await rm(cwd, { recursive: true, force: true });
|
|
210
233
|
}
|
|
211
234
|
});
|
|
235
|
+
it('rejects a stale full config save without overwriting a newer canonical generation', async () => {
|
|
236
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-team-config-generation-'));
|
|
237
|
+
try {
|
|
238
|
+
await initTeamState('team-generation', 'generation test', 'executor', 1, cwd);
|
|
239
|
+
const first = await readTeamConfig('team-generation', cwd);
|
|
240
|
+
const stale = await readTeamConfig('team-generation', cwd);
|
|
241
|
+
assert.ok(first);
|
|
242
|
+
assert.ok(stale);
|
|
243
|
+
if (!first || !stale)
|
|
244
|
+
throw new Error('missing config');
|
|
245
|
+
first.task = 'newer canonical value';
|
|
246
|
+
await saveTeamConfig(first, cwd);
|
|
247
|
+
stale.task = 'stale overwrite';
|
|
248
|
+
await assert.rejects(() => saveTeamConfig(stale, cwd), /team_config_stale_generation:team-generation:0:1/);
|
|
249
|
+
assert.equal((await readTeamConfig('team-generation', cwd))?.task, 'newer canonical value');
|
|
250
|
+
}
|
|
251
|
+
finally {
|
|
252
|
+
await rm(cwd, { recursive: true, force: true });
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
it('recovers an interrupted config-first save to one old generation before exposing state', async () => {
|
|
256
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-team-config-pair-recovery-'));
|
|
257
|
+
try {
|
|
258
|
+
const teamName = 'config-pair-recovery';
|
|
259
|
+
await initTeamState(teamName, 'old task', 'executor', 1, cwd);
|
|
260
|
+
const teamRoot = join(cwd, '.omx', 'state', 'team', teamName);
|
|
261
|
+
const manifestPath = join(teamRoot, 'manifest.v2.json');
|
|
262
|
+
const config = await readTeamConfig(teamName, cwd);
|
|
263
|
+
assert.ok(config);
|
|
264
|
+
if (!config)
|
|
265
|
+
throw new Error('missing config');
|
|
266
|
+
config.task = 'new task';
|
|
267
|
+
setWriteAtomicRenameForTests(async (from, to) => {
|
|
268
|
+
if (to === manifestPath)
|
|
269
|
+
throw new Error('injected_manifest_publish_failure');
|
|
270
|
+
await rename(from, to);
|
|
271
|
+
});
|
|
272
|
+
await assert.rejects(() => saveTeamConfig(config, cwd), /injected_manifest_publish_failure/);
|
|
273
|
+
assert.equal(existsSync(join(teamRoot, '.membership-task-transaction.json')), true);
|
|
274
|
+
resetWriteAtomicRenameForTests();
|
|
275
|
+
const [recoveredConfig, recoveredManifest] = await Promise.all([
|
|
276
|
+
readTeamConfig(teamName, cwd),
|
|
277
|
+
readTeamManifestV2(teamName, cwd),
|
|
278
|
+
]);
|
|
279
|
+
assert.equal(recoveredConfig?.task, 'old task');
|
|
280
|
+
assert.equal(recoveredConfig?.config_generation ?? 0, 0);
|
|
281
|
+
assert.equal(recoveredManifest?.task, 'old task');
|
|
282
|
+
assert.equal(recoveredManifest?.config_generation ?? 0, 0);
|
|
283
|
+
assert.equal(existsSync(join(teamRoot, '.membership-task-transaction.json')), false);
|
|
284
|
+
}
|
|
285
|
+
finally {
|
|
286
|
+
await rm(cwd, { recursive: true, force: true });
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
it('rejects a stale membership transaction without overwriting the accepted generation', async () => {
|
|
290
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-team-membership-generation-'));
|
|
291
|
+
try {
|
|
292
|
+
await initTeamState('membership-generation', 'generation test', 'executor', 1, cwd);
|
|
293
|
+
const teamRoot = join(cwd, '.omx', 'state', 'team', 'membership-generation');
|
|
294
|
+
const [oldConfig, oldManifest] = await Promise.all([
|
|
295
|
+
readFile(join(teamRoot, 'config.json'), 'utf8'),
|
|
296
|
+
readFile(join(teamRoot, 'manifest.v2.json'), 'utf8'),
|
|
297
|
+
]);
|
|
298
|
+
const accepted = await readTeamConfig('membership-generation', cwd);
|
|
299
|
+
assert.ok(accepted);
|
|
300
|
+
if (!accepted)
|
|
301
|
+
throw new Error('missing config');
|
|
302
|
+
accepted.task = 'accepted update';
|
|
303
|
+
await saveTeamConfig(accepted, cwd);
|
|
304
|
+
const staleConfig = { ...JSON.parse(oldConfig), worker_count: 0, workers: [] };
|
|
305
|
+
const staleManifest = { ...JSON.parse(oldManifest), worker_count: 0, workers: [] };
|
|
306
|
+
await assert.rejects(withTeamTaskBarrier('membership-generation', cwd, () => commitTeamMembershipTaskTransaction('membership-generation', cwd, {
|
|
307
|
+
baseGeneration: 0,
|
|
308
|
+
tasks: [],
|
|
309
|
+
config: { oldBytes: oldConfig, newBytes: JSON.stringify(staleConfig, null, 2) },
|
|
310
|
+
manifest: { oldBytes: oldManifest, newBytes: JSON.stringify(staleManifest, null, 2) },
|
|
311
|
+
})), /team_membership_stale_generation:membership-generation:0:1/);
|
|
312
|
+
const [config, manifest] = await Promise.all([
|
|
313
|
+
readTeamConfig('membership-generation', cwd),
|
|
314
|
+
readTeamManifestV2('membership-generation', cwd),
|
|
315
|
+
]);
|
|
316
|
+
assert.equal(config?.task, 'accepted update');
|
|
317
|
+
assert.equal(config?.config_generation, 1);
|
|
318
|
+
assert.equal(manifest?.config_generation, 1);
|
|
319
|
+
}
|
|
320
|
+
finally {
|
|
321
|
+
await rm(cwd, { recursive: true, force: true });
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
it('serializes concurrent scale and shutdown membership generations', async () => {
|
|
325
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-team-membership-concurrent-generation-'));
|
|
326
|
+
try {
|
|
327
|
+
await initTeamState('membership-concurrent', 'generation test', 'executor', 1, cwd);
|
|
328
|
+
const teamRoot = join(cwd, '.omx', 'state', 'team', 'membership-concurrent');
|
|
329
|
+
const [oldConfig, oldManifest] = await Promise.all([
|
|
330
|
+
readFile(join(teamRoot, 'config.json'), 'utf8'),
|
|
331
|
+
readFile(join(teamRoot, 'manifest.v2.json'), 'utf8'),
|
|
332
|
+
]);
|
|
333
|
+
const commit = (task) => withTeamTaskBarrier('membership-concurrent', cwd, () => commitTeamMembershipTaskTransaction('membership-concurrent', cwd, {
|
|
334
|
+
baseGeneration: 0,
|
|
335
|
+
tasks: [],
|
|
336
|
+
config: { oldBytes: oldConfig, newBytes: JSON.stringify({ ...JSON.parse(oldConfig), task }, null, 2) },
|
|
337
|
+
manifest: { oldBytes: oldManifest, newBytes: JSON.stringify({ ...JSON.parse(oldManifest), task }, null, 2) },
|
|
338
|
+
}));
|
|
339
|
+
const results = await Promise.allSettled([commit('scale update'), commit('shutdown update')]);
|
|
340
|
+
assert.equal(results.filter((result) => result.status === 'fulfilled').length, 1);
|
|
341
|
+
assert.equal(results.filter((result) => result.status === 'rejected').length, 1);
|
|
342
|
+
const [config, manifest] = await Promise.all([
|
|
343
|
+
readTeamConfig('membership-concurrent', cwd),
|
|
344
|
+
readTeamManifestV2('membership-concurrent', cwd),
|
|
345
|
+
]);
|
|
346
|
+
assert.equal(config?.config_generation, 1);
|
|
347
|
+
assert.equal(manifest?.config_generation, 1);
|
|
348
|
+
assert.ok(config?.task === 'scale update' || config?.task === 'shutdown update');
|
|
349
|
+
assert.equal(manifest?.task, config?.task);
|
|
350
|
+
}
|
|
351
|
+
finally {
|
|
352
|
+
await rm(cwd, { recursive: true, force: true });
|
|
353
|
+
}
|
|
354
|
+
});
|
|
212
355
|
it('initTeamState persists the default lifecycle profile', async () => {
|
|
213
356
|
const cwd = await mkdtemp(join(tmpdir(), 'omx-team-lifecycle-profile-'));
|
|
214
357
|
try {
|
|
@@ -242,24 +385,26 @@ describe('team state', () => {
|
|
|
242
385
|
await rm(cwd, { recursive: true, force: true });
|
|
243
386
|
}
|
|
244
387
|
});
|
|
245
|
-
it('
|
|
246
|
-
const cwd = await mkdtemp(join(tmpdir(), 'omx-team-pane-owner-
|
|
388
|
+
it('preserves missing or blank tmux pane owner ids as unavailable in legacy manifests', async () => {
|
|
389
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-team-pane-owner-unavailable-'));
|
|
247
390
|
try {
|
|
248
|
-
await initTeamState('team-pane-owner-
|
|
249
|
-
const manifestPath = join(cwd, '.omx', 'state', 'team', 'team-pane-owner-
|
|
391
|
+
await initTeamState('team-pane-owner-unavailable', 't', 'executor', 1, cwd);
|
|
392
|
+
const manifestPath = join(cwd, '.omx', 'state', 'team', 'team-pane-owner-unavailable', 'manifest.v2.json');
|
|
250
393
|
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
251
394
|
delete manifest.tmux_pane_owner_id;
|
|
252
395
|
await writeFile(manifestPath, JSON.stringify(manifest, null, 2));
|
|
253
|
-
const loadedManifest = await readTeamManifestV2('team-pane-owner-
|
|
254
|
-
const loadedConfig = await readTeamConfig('team-pane-owner-
|
|
255
|
-
assert.equal(loadedManifest?.tmux_pane_owner_id,
|
|
256
|
-
assert.equal(loadedConfig?.tmux_pane_owner_id,
|
|
396
|
+
const loadedManifest = await readTeamManifestV2('team-pane-owner-unavailable', cwd);
|
|
397
|
+
const loadedConfig = await readTeamConfig('team-pane-owner-unavailable', cwd);
|
|
398
|
+
assert.equal(loadedManifest?.tmux_pane_owner_id, undefined);
|
|
399
|
+
assert.equal(loadedConfig?.tmux_pane_owner_id, undefined);
|
|
257
400
|
await writeTeamManifestV2({
|
|
258
401
|
...loadedManifest,
|
|
259
402
|
tmux_pane_owner_id: ' ',
|
|
260
403
|
}, cwd);
|
|
261
|
-
const blankNormalized = await readTeamManifestV2('team-pane-owner-
|
|
262
|
-
assert.equal(blankNormalized?.tmux_pane_owner_id,
|
|
404
|
+
const blankNormalized = await readTeamManifestV2('team-pane-owner-unavailable', cwd);
|
|
405
|
+
assert.equal(blankNormalized?.tmux_pane_owner_id, undefined);
|
|
406
|
+
const persisted = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
407
|
+
assert.equal(persisted.tmux_pane_owner_id, undefined);
|
|
263
408
|
}
|
|
264
409
|
finally {
|
|
265
410
|
await rm(cwd, { recursive: true, force: true });
|
|
@@ -319,15 +464,20 @@ describe('team state', () => {
|
|
|
319
464
|
const fakeBinDir = join(cwd, 'fake-bin');
|
|
320
465
|
const runtimeLogPath = join(cwd, 'runtime.log');
|
|
321
466
|
await mkdir(fakeBinDir, { recursive: true });
|
|
467
|
+
await writeFile(join(cwd, '.omx', 'state', 'dispatch.json'), JSON.stringify({ records: [] }));
|
|
322
468
|
await writeFile(join(fakeBinDir, 'omx-runtime'), `#!/usr/bin/env bash
|
|
323
469
|
set -eu
|
|
324
470
|
printf '%s\n' "$*" >> "${runtimeLogPath}"
|
|
325
471
|
if [[ "\${1:-}" == "schema" ]]; then
|
|
326
|
-
printf '{"schema_version":1,"commands":["acquire-authority","renew-authority","queue-dispatch","mark-notified","mark-delivered","mark-failed","request-replay","capture-snapshot"],"events":[],"transport":"tmux"}\n'
|
|
472
|
+
printf '{"schema_version":1,"commands":["acquire-authority","renew-authority","queue-dispatch","mark-notified","mark-delivered","mark-failed","remove-dispatch-records","request-replay","capture-snapshot"],"events":[],"transport":"tmux"}\n'
|
|
327
473
|
exit 0
|
|
328
474
|
fi
|
|
329
475
|
if [[ "\${1:-}" == "exec" ]]; then
|
|
330
|
-
|
|
476
|
+
if [[ "$*" == *'"command":"CaptureSnapshot"'* ]]; then
|
|
477
|
+
printf '{"event":"SnapshotCaptured"}\n'
|
|
478
|
+
else
|
|
479
|
+
printf '{"event":"DispatchQueued","request_id":"ok","target":"worker-1"}\n'
|
|
480
|
+
fi
|
|
331
481
|
exit 0
|
|
332
482
|
fi
|
|
333
483
|
exit 1
|
|
@@ -358,36 +508,43 @@ exit 1
|
|
|
358
508
|
await rm(cwd, { recursive: true, force: true });
|
|
359
509
|
}
|
|
360
510
|
});
|
|
361
|
-
it('
|
|
511
|
+
it('coalesces mailbox wakes only for an exact target proof and releases on acknowledgement', async () => {
|
|
362
512
|
const cwd = await mkdtemp(join(tmpdir(), 'omx-team-dispatch-store-'));
|
|
363
513
|
try {
|
|
364
|
-
await initTeamState('team-dispatch', 't', 'executor',
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
to_worker: 'worker-1',
|
|
368
|
-
message_id: 'msg-1',
|
|
369
|
-
trigger_message: 'check mailbox',
|
|
370
|
-
intent: 'pending-mailbox-review',
|
|
371
|
-
}, cwd);
|
|
372
|
-
assert.equal(first.deduped, false);
|
|
373
|
-
const dup = await enqueueDispatchRequest('team-dispatch', {
|
|
514
|
+
await initTeamState('team-dispatch', 't', 'executor', 2, cwd);
|
|
515
|
+
await initTeamState('team-dispatch-other', 't', 'executor', 1, cwd);
|
|
516
|
+
const input = (to_worker, message_id, pane_id = 'pane-1', intent = 'pending-mailbox-review') => ({
|
|
374
517
|
kind: 'mailbox',
|
|
375
|
-
to_worker
|
|
376
|
-
|
|
518
|
+
to_worker,
|
|
519
|
+
pane_id,
|
|
520
|
+
message_id,
|
|
377
521
|
trigger_message: 'check mailbox',
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
522
|
+
intent,
|
|
523
|
+
});
|
|
524
|
+
const first = await enqueueDispatchRequest('team-dispatch', input('worker-1', 'msg-1'), cwd);
|
|
525
|
+
const pendingSibling = await enqueueDispatchRequest('team-dispatch', input('worker-1', 'msg-2'), cwd);
|
|
526
|
+
assert.equal(pendingSibling.deduped, true);
|
|
527
|
+
assert.equal(pendingSibling.request.request_id, first.request.request_id);
|
|
381
528
|
const notified = await markDispatchRequestNotified('team-dispatch', first.request.request_id, {}, cwd);
|
|
382
529
|
assert.equal(notified?.status, 'notified');
|
|
383
|
-
const
|
|
384
|
-
assert.equal(
|
|
530
|
+
const notifiedSibling = await enqueueDispatchRequest('team-dispatch', input('worker-1', 'msg-3'), cwd);
|
|
531
|
+
assert.equal(notifiedSibling.deduped, true);
|
|
532
|
+
assert.equal(notifiedSibling.request.request_id, first.request.request_id);
|
|
533
|
+
const differentWorker = await enqueueDispatchRequest('team-dispatch', input('worker-2', 'msg-4'), cwd);
|
|
534
|
+
const differentPane = await enqueueDispatchRequest('team-dispatch', input('worker-1', 'msg-5', 'pane-2'), cwd);
|
|
535
|
+
const differentIntent = await enqueueDispatchRequest('team-dispatch', input('worker-1', 'msg-6', 'pane-1', 'followup-relaunch'), cwd);
|
|
536
|
+
const differentTeam = await enqueueDispatchRequest('team-dispatch-other', input('worker-1', 'msg-7'), cwd);
|
|
537
|
+
assert.equal(differentWorker.deduped, false);
|
|
538
|
+
assert.equal(differentPane.deduped, false);
|
|
539
|
+
assert.equal(differentIntent.deduped, false);
|
|
540
|
+
assert.equal(differentTeam.deduped, false);
|
|
385
541
|
const delivered = await markDispatchRequestDelivered('team-dispatch', first.request.request_id, {}, cwd);
|
|
386
542
|
assert.equal(delivered?.status, 'delivered');
|
|
387
|
-
const
|
|
388
|
-
assert.equal(
|
|
389
|
-
assert.
|
|
390
|
-
|
|
543
|
+
const successor = await enqueueDispatchRequest('team-dispatch', input('worker-1', 'msg-8'), cwd);
|
|
544
|
+
assert.equal(successor.deduped, false);
|
|
545
|
+
assert.notEqual(successor.request.request_id, first.request.request_id);
|
|
546
|
+
const listed = await listDispatchRequests('team-dispatch', cwd, { kind: 'mailbox' });
|
|
547
|
+
assert.equal(listed.length, 5);
|
|
391
548
|
}
|
|
392
549
|
finally {
|
|
393
550
|
await rm(cwd, { recursive: true, force: true });
|
|
@@ -431,9 +588,195 @@ exit 1
|
|
|
431
588
|
await rm(cwd, { recursive: true, force: true });
|
|
432
589
|
}
|
|
433
590
|
});
|
|
591
|
+
it('preserves legacy rollback evidence when authoritative dispatch absence verification is unavailable', async () => {
|
|
592
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-team-dispatch-authority-unavailable-'));
|
|
593
|
+
const previousRuntimeBinary = process.env.OMX_RUNTIME_BINARY;
|
|
594
|
+
const previousRuntimeBridge = process.env.OMX_RUNTIME_BRIDGE;
|
|
595
|
+
const previousSkipSnapshotWrite = process.env.OMX_TEST_SKIP_SNAPSHOT_COMPAT_WRITE;
|
|
596
|
+
try {
|
|
597
|
+
const teamName = 'dispatch-unavailable';
|
|
598
|
+
await initTeamState(teamName, 't', 'executor', 1, cwd);
|
|
599
|
+
const fakeBinDir = join(cwd, 'fake-bin');
|
|
600
|
+
await mkdir(fakeBinDir, { recursive: true });
|
|
601
|
+
const runtimePath = join(fakeBinDir, 'omx-runtime');
|
|
602
|
+
await writeCompatRuntimeFixture(runtimePath, join(cwd, 'runtime.log'));
|
|
603
|
+
process.env.OMX_RUNTIME_BINARY = runtimePath;
|
|
604
|
+
process.env.OMX_RUNTIME_BRIDGE = '1';
|
|
605
|
+
process.env.OMX_TEST_SKIP_SNAPSHOT_COMPAT_WRITE = '1';
|
|
606
|
+
const legacyPath = join(cwd, '.omx', 'state', 'team', teamName, 'dispatch', 'requests.json');
|
|
607
|
+
const legacyBytes = JSON.stringify([{
|
|
608
|
+
request_id: 'legacy-preserved',
|
|
609
|
+
kind: 'inbox',
|
|
610
|
+
team_name: teamName,
|
|
611
|
+
to_worker: 'worker-1',
|
|
612
|
+
trigger_message: 'preserve rollback evidence',
|
|
613
|
+
transport_preference: 'hook_preferred_with_fallback',
|
|
614
|
+
fallback_allowed: true,
|
|
615
|
+
status: 'pending',
|
|
616
|
+
attempt_count: 0,
|
|
617
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
618
|
+
updated_at: '2026-01-01T00:00:00.000Z',
|
|
619
|
+
}], null, 2);
|
|
620
|
+
await writeFile(legacyPath, legacyBytes);
|
|
621
|
+
const dispatchPath = join(cwd, '.omx', 'state', 'dispatch.json');
|
|
622
|
+
const unavailableFixtures = [
|
|
623
|
+
async () => { await rm(dispatchPath, { recursive: true, force: true }); },
|
|
624
|
+
async () => { await rm(dispatchPath, { recursive: true, force: true }); await writeFile(dispatchPath, '{'); },
|
|
625
|
+
async () => { await rm(dispatchPath, { recursive: true, force: true }); await writeFile(dispatchPath, JSON.stringify({ records: [{}] })); },
|
|
626
|
+
async () => { await rm(dispatchPath, { recursive: true, force: true }); await mkdir(dispatchPath); },
|
|
627
|
+
];
|
|
628
|
+
for (const arrange of unavailableFixtures) {
|
|
629
|
+
await arrange();
|
|
630
|
+
await assert.rejects(() => removeDispatchRequestsForWorkers(teamName, ['worker-1'], cwd), /dispatch compatibility output/);
|
|
631
|
+
assert.equal(await readFile(legacyPath, 'utf8'), legacyBytes);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
finally {
|
|
635
|
+
if (typeof previousRuntimeBinary === 'string')
|
|
636
|
+
process.env.OMX_RUNTIME_BINARY = previousRuntimeBinary;
|
|
637
|
+
else
|
|
638
|
+
delete process.env.OMX_RUNTIME_BINARY;
|
|
639
|
+
if (typeof previousRuntimeBridge === 'string')
|
|
640
|
+
process.env.OMX_RUNTIME_BRIDGE = previousRuntimeBridge;
|
|
641
|
+
else
|
|
642
|
+
delete process.env.OMX_RUNTIME_BRIDGE;
|
|
643
|
+
if (typeof previousSkipSnapshotWrite === 'string')
|
|
644
|
+
process.env.OMX_TEST_SKIP_SNAPSHOT_COMPAT_WRITE = previousSkipSnapshotWrite;
|
|
645
|
+
else
|
|
646
|
+
delete process.env.OMX_TEST_SKIP_SNAPSHOT_COMPAT_WRITE;
|
|
647
|
+
await rm(cwd, { recursive: true, force: true });
|
|
648
|
+
}
|
|
649
|
+
});
|
|
650
|
+
it('scopes bridge rollback IDs to the target team legacy file and preserves same-target foreign records', async () => {
|
|
651
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-team-dispatch-rollback-scope-'));
|
|
652
|
+
const previousRuntimeBinary = process.env.OMX_RUNTIME_BINARY;
|
|
653
|
+
const previousRuntimeBridge = process.env.OMX_RUNTIME_BRIDGE;
|
|
654
|
+
try {
|
|
655
|
+
await initTeamState('team-rollback-a', 't', 'executor', 1, cwd);
|
|
656
|
+
await initTeamState('team-rollback-b', 't', 'executor', 1, cwd);
|
|
657
|
+
const fakeBinDir = join(cwd, 'fake-bin');
|
|
658
|
+
const runtimeLogPath = join(cwd, 'runtime.log');
|
|
659
|
+
await mkdir(fakeBinDir, { recursive: true });
|
|
660
|
+
await writeCompatRuntimeFixture(join(fakeBinDir, 'omx-runtime'), runtimeLogPath);
|
|
661
|
+
process.env.OMX_RUNTIME_BINARY = join(fakeBinDir, 'omx-runtime');
|
|
662
|
+
process.env.OMX_RUNTIME_BRIDGE = '1';
|
|
663
|
+
process.env.OMX_RUNTIME_BRIDGE = '0';
|
|
664
|
+
const scopedRequest = await enqueueDispatchRequest('team-rollback-a', {
|
|
665
|
+
kind: 'inbox',
|
|
666
|
+
to_worker: 'worker-1',
|
|
667
|
+
trigger_message: 'rollback scoped request',
|
|
668
|
+
}, cwd);
|
|
669
|
+
process.env.OMX_RUNTIME_BRIDGE = '1';
|
|
670
|
+
const teamRoot = join(cwd, '.omx', 'state', 'team');
|
|
671
|
+
const legacyPath = join(teamRoot, 'team-rollback-a', 'dispatch', 'requests.json');
|
|
672
|
+
const rootDispatchPath = join(cwd, '.omx', 'state', 'dispatch.json');
|
|
673
|
+
const scopedRequestId = scopedRequest.request.request_id;
|
|
674
|
+
await writeFile(rootDispatchPath, JSON.stringify({ records: [
|
|
675
|
+
{ request_id: scopedRequestId, target: 'worker-1', status: 'pending', created_at: new Date().toISOString(), notified_at: null, delivered_at: null, failed_at: null, reason: null, metadata: { team_name: 'team-rollback-a' } },
|
|
676
|
+
{ request_id: 'team-b-worker-1', target: 'worker-1', status: 'pending', created_at: new Date().toISOString(), notified_at: null, delivered_at: null, failed_at: null, reason: null, metadata: { team_name: 'team-rollback-b' } },
|
|
677
|
+
{ request_id: 'unscoped-worker-1', target: 'worker-1', status: 'pending', created_at: new Date().toISOString(), notified_at: null, delivered_at: null, failed_at: null, reason: null, metadata: null },
|
|
678
|
+
] }, null, 2));
|
|
679
|
+
await removeDispatchRequestsForWorkers('team-rollback-a', ['worker-1'], cwd);
|
|
680
|
+
assert.deepEqual(JSON.parse(await readFile(legacyPath, 'utf8')), []);
|
|
681
|
+
const runtimeLog = await readFile(runtimeLogPath, 'utf8');
|
|
682
|
+
const removalLine = runtimeLog.split('\n').find((line) => line.startsWith('exec {"command":"RemoveDispatchRecords"'));
|
|
683
|
+
assert.ok(removalLine);
|
|
684
|
+
const removalPayload = JSON.parse(removalLine.slice(removalLine.indexOf('{'), removalLine.lastIndexOf(' --state-dir=')));
|
|
685
|
+
assert.deepEqual(removalPayload.request_ids, [scopedRequestId]);
|
|
686
|
+
assert.match(runtimeLog, /CaptureSnapshot/);
|
|
687
|
+
}
|
|
688
|
+
finally {
|
|
689
|
+
if (typeof previousRuntimeBinary === 'string')
|
|
690
|
+
process.env.OMX_RUNTIME_BINARY = previousRuntimeBinary;
|
|
691
|
+
else
|
|
692
|
+
delete process.env.OMX_RUNTIME_BINARY;
|
|
693
|
+
if (typeof previousRuntimeBridge === 'string')
|
|
694
|
+
process.env.OMX_RUNTIME_BRIDGE = previousRuntimeBridge;
|
|
695
|
+
else
|
|
696
|
+
delete process.env.OMX_RUNTIME_BRIDGE;
|
|
697
|
+
await rm(cwd, { recursive: true, force: true });
|
|
698
|
+
}
|
|
699
|
+
});
|
|
700
|
+
it('preserves malformed legacy rollback evidence byte-for-byte', async () => {
|
|
701
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-team-dispatch-legacy-malformed-'));
|
|
702
|
+
const previousRuntimeBridge = process.env.OMX_RUNTIME_BRIDGE;
|
|
703
|
+
try {
|
|
704
|
+
const teamName = 'dispatch-legacy-malformed';
|
|
705
|
+
process.env.OMX_RUNTIME_BRIDGE = '0';
|
|
706
|
+
await initTeamState(teamName, 't', 'executor', 1, cwd);
|
|
707
|
+
const legacyPath = join(cwd, '.omx', 'state', 'team', teamName, 'dispatch', 'requests.json');
|
|
708
|
+
const legacyBytes = '{\n "records": "not-an-array"\n}\n';
|
|
709
|
+
await writeFile(legacyPath, legacyBytes);
|
|
710
|
+
await assert.rejects(() => removeDispatchRequestsForWorkers(teamName, ['worker-1'], cwd), /legacy dispatch rollback evidence is malformed or unrecognized/);
|
|
711
|
+
assert.equal(await readFile(legacyPath, 'utf8'), legacyBytes);
|
|
712
|
+
}
|
|
713
|
+
finally {
|
|
714
|
+
if (typeof previousRuntimeBridge === 'string')
|
|
715
|
+
process.env.OMX_RUNTIME_BRIDGE = previousRuntimeBridge;
|
|
716
|
+
else
|
|
717
|
+
delete process.env.OMX_RUNTIME_BRIDGE;
|
|
718
|
+
await rm(cwd, { recursive: true, force: true });
|
|
719
|
+
}
|
|
720
|
+
});
|
|
721
|
+
it('rejects an acknowledged retained dispatch ID even when its scope fields changed', async () => {
|
|
722
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-team-dispatch-retained-id-'));
|
|
723
|
+
const previousRuntimeBinary = process.env.OMX_RUNTIME_BINARY;
|
|
724
|
+
const previousRuntimeBridge = process.env.OMX_RUNTIME_BRIDGE;
|
|
725
|
+
const previousAckRetain = process.env.OMX_TEST_ACK_RETAIN_DISPATCH;
|
|
726
|
+
try {
|
|
727
|
+
const teamName = 'dispatch-retained-id';
|
|
728
|
+
await initTeamState(teamName, 't', 'executor', 1, cwd);
|
|
729
|
+
const fakeBinDir = join(cwd, 'fake-bin');
|
|
730
|
+
await mkdir(fakeBinDir, { recursive: true });
|
|
731
|
+
const runtimePath = join(fakeBinDir, 'omx-runtime');
|
|
732
|
+
await writeCompatRuntimeFixture(runtimePath, join(cwd, 'runtime.log'));
|
|
733
|
+
process.env.OMX_RUNTIME_BINARY = runtimePath;
|
|
734
|
+
process.env.OMX_RUNTIME_BRIDGE = '0';
|
|
735
|
+
const queued = await enqueueDispatchRequest(teamName, {
|
|
736
|
+
kind: 'inbox',
|
|
737
|
+
to_worker: 'worker-1',
|
|
738
|
+
trigger_message: 'remove by exact ID',
|
|
739
|
+
}, cwd);
|
|
740
|
+
const legacyPath = join(cwd, '.omx', 'state', 'team', teamName, 'dispatch', 'requests.json');
|
|
741
|
+
const legacyBytes = await readFile(legacyPath, 'utf8');
|
|
742
|
+
const dispatchPath = join(cwd, '.omx', 'state', 'dispatch.json');
|
|
743
|
+
await writeFile(dispatchPath, JSON.stringify({ records: [{
|
|
744
|
+
request_id: queued.request.request_id,
|
|
745
|
+
target: 'worker-renamed',
|
|
746
|
+
status: 'pending',
|
|
747
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
748
|
+
notified_at: null,
|
|
749
|
+
delivered_at: null,
|
|
750
|
+
failed_at: null,
|
|
751
|
+
reason: null,
|
|
752
|
+
metadata: { team_name: 'different-team' },
|
|
753
|
+
}] }, null, 2));
|
|
754
|
+
process.env.OMX_RUNTIME_BRIDGE = '1';
|
|
755
|
+
process.env.OMX_TEST_ACK_RETAIN_DISPATCH = '1';
|
|
756
|
+
await assert.rejects(() => removeDispatchRequestsForWorkers(teamName, ['worker-1'], cwd), /authoritative_dispatch_rollback_verification_failed/);
|
|
757
|
+
assert.equal(await readFile(legacyPath, 'utf8'), legacyBytes);
|
|
758
|
+
}
|
|
759
|
+
finally {
|
|
760
|
+
if (typeof previousRuntimeBinary === 'string')
|
|
761
|
+
process.env.OMX_RUNTIME_BINARY = previousRuntimeBinary;
|
|
762
|
+
else
|
|
763
|
+
delete process.env.OMX_RUNTIME_BINARY;
|
|
764
|
+
if (typeof previousRuntimeBridge === 'string')
|
|
765
|
+
process.env.OMX_RUNTIME_BRIDGE = previousRuntimeBridge;
|
|
766
|
+
else
|
|
767
|
+
delete process.env.OMX_RUNTIME_BRIDGE;
|
|
768
|
+
if (typeof previousAckRetain === 'string')
|
|
769
|
+
process.env.OMX_TEST_ACK_RETAIN_DISPATCH = previousAckRetain;
|
|
770
|
+
else
|
|
771
|
+
delete process.env.OMX_TEST_ACK_RETAIN_DISPATCH;
|
|
772
|
+
await rm(cwd, { recursive: true, force: true });
|
|
773
|
+
}
|
|
774
|
+
});
|
|
434
775
|
it('dispatch request store keeps failed requests failed while allowing reason patches', async () => {
|
|
435
776
|
const cwd = await mkdtemp(join(tmpdir(), 'omx-team-dispatch-store-failed-'));
|
|
777
|
+
const previousRuntimeBridge = process.env.OMX_RUNTIME_BRIDGE;
|
|
436
778
|
try {
|
|
779
|
+
process.env.OMX_RUNTIME_BRIDGE = '0';
|
|
437
780
|
await initTeamState('team-dispatch-failed', 't', 'executor', 1, cwd);
|
|
438
781
|
const queued = await enqueueDispatchRequest('team-dispatch-failed', {
|
|
439
782
|
kind: 'inbox',
|
|
@@ -452,6 +795,10 @@ exit 1
|
|
|
452
795
|
assert.equal(reread?.last_reason, 'fallback_confirmed_after_failed_receipt:tmux_send_keys_sent');
|
|
453
796
|
}
|
|
454
797
|
finally {
|
|
798
|
+
if (typeof previousRuntimeBridge === 'string')
|
|
799
|
+
process.env.OMX_RUNTIME_BRIDGE = previousRuntimeBridge;
|
|
800
|
+
else
|
|
801
|
+
delete process.env.OMX_RUNTIME_BRIDGE;
|
|
455
802
|
await rm(cwd, { recursive: true, force: true });
|
|
456
803
|
}
|
|
457
804
|
});
|
|
@@ -476,6 +823,8 @@ exit 1
|
|
|
476
823
|
assert.deepEqual(manifest?.worktree_mode, { enabled: true, detached: false, name: 'feature/team-meta' });
|
|
477
824
|
assert.equal(manifest?.lifecycle_profile, 'default');
|
|
478
825
|
assert.equal(manifest?.leader_pane_id, null);
|
|
826
|
+
assert.equal(manifest?.leader_pane_pid, null);
|
|
827
|
+
assert.equal(manifest?.hud_pane_pid, null);
|
|
479
828
|
assert.equal(manifest?.hud_pane_id, null);
|
|
480
829
|
assert.equal(manifest?.resize_hook_name, null);
|
|
481
830
|
assert.equal(manifest?.resize_hook_target, null);
|
|
@@ -605,6 +954,72 @@ exit 1
|
|
|
605
954
|
await rm(cwd, { recursive: true, force: true });
|
|
606
955
|
}
|
|
607
956
|
});
|
|
957
|
+
it('claimTask revalidates a stale member under its claim lock when expectedVersion is null', async () => {
|
|
958
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-team-claim-member-boundary-'));
|
|
959
|
+
try {
|
|
960
|
+
const teamName = 'team-claim-member-boundary';
|
|
961
|
+
await initTeamState(teamName, 't', 'executor', 2, cwd);
|
|
962
|
+
const task = await createTask(teamName, { subject: 'a', description: 'd', status: 'pending' }, cwd);
|
|
963
|
+
const claim = await claimTaskWithDeps(task.id, 'worker-2', null, {
|
|
964
|
+
teamName,
|
|
965
|
+
cwd,
|
|
966
|
+
readTask,
|
|
967
|
+
readTeamConfig,
|
|
968
|
+
withTaskClaimLock: async (_teamName, _taskId, _cwd, fn) => {
|
|
969
|
+
const config = await readTeamConfig(teamName, cwd);
|
|
970
|
+
assert.ok(config);
|
|
971
|
+
config.workers = config.workers.filter((worker) => worker.name !== 'worker-2');
|
|
972
|
+
await saveTeamConfig(config, cwd);
|
|
973
|
+
return { ok: true, value: await fn() };
|
|
974
|
+
},
|
|
975
|
+
normalizeTask: (current) => current,
|
|
976
|
+
isTerminalTaskStatus: (status) => status === 'completed' || status === 'failed',
|
|
977
|
+
taskFilePath: (_teamName, taskId, _cwd) => join(cwd, '.omx', 'state', 'team', teamName, 'tasks', `task-${taskId}.json`),
|
|
978
|
+
writeAtomic,
|
|
979
|
+
});
|
|
980
|
+
assert.deepEqual(claim, { ok: false, error: 'worker_not_found' });
|
|
981
|
+
const persisted = await readTask(teamName, task.id, cwd);
|
|
982
|
+
assert.equal(persisted?.status, 'pending');
|
|
983
|
+
assert.equal(persisted?.owner, undefined);
|
|
984
|
+
}
|
|
985
|
+
finally {
|
|
986
|
+
await rm(cwd, { recursive: true, force: true });
|
|
987
|
+
}
|
|
988
|
+
});
|
|
989
|
+
it('claimTask rejects a removed worker for a task created after the scale-down snapshot', async () => {
|
|
990
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-team-claim-post-snapshot-'));
|
|
991
|
+
try {
|
|
992
|
+
const teamName = 'team-claim-post-snapshot';
|
|
993
|
+
await initTeamState(teamName, 't', 'executor', 2, cwd);
|
|
994
|
+
const scaleDownSnapshot = await readTeamConfig(teamName, cwd);
|
|
995
|
+
assert.ok(scaleDownSnapshot?.workers.some((worker) => worker.name === 'worker-2'));
|
|
996
|
+
const postSnapshotTask = await createTask(teamName, { subject: 'post snapshot', description: 'd', status: 'pending' }, cwd);
|
|
997
|
+
const claim = await claimTaskWithDeps(postSnapshotTask.id, 'worker-2', null, {
|
|
998
|
+
teamName,
|
|
999
|
+
cwd,
|
|
1000
|
+
readTask,
|
|
1001
|
+
readTeamConfig,
|
|
1002
|
+
withTaskClaimLock: async (_teamName, _taskId, _cwd, fn) => {
|
|
1003
|
+
const config = await readTeamConfig(teamName, cwd);
|
|
1004
|
+
assert.ok(config);
|
|
1005
|
+
config.workers = config.workers.filter((worker) => worker.name !== 'worker-2');
|
|
1006
|
+
await saveTeamConfig(config, cwd);
|
|
1007
|
+
return { ok: true, value: await fn() };
|
|
1008
|
+
},
|
|
1009
|
+
normalizeTask: (current) => current,
|
|
1010
|
+
isTerminalTaskStatus: (status) => status === 'completed' || status === 'failed',
|
|
1011
|
+
taskFilePath: (_teamName, taskId, _cwd) => join(cwd, '.omx', 'state', 'team', teamName, 'tasks', `task-${taskId}.json`),
|
|
1012
|
+
writeAtomic,
|
|
1013
|
+
});
|
|
1014
|
+
assert.deepEqual(claim, { ok: false, error: 'worker_not_found' });
|
|
1015
|
+
const persisted = await readTask(teamName, postSnapshotTask.id, cwd);
|
|
1016
|
+
assert.equal(persisted?.status, 'pending');
|
|
1017
|
+
assert.equal(persisted?.owner, undefined);
|
|
1018
|
+
}
|
|
1019
|
+
finally {
|
|
1020
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1021
|
+
}
|
|
1022
|
+
});
|
|
608
1023
|
it('claimTask recovers a stale task claim lock and proceeds', async () => {
|
|
609
1024
|
const cwd = await mkdtemp(join(tmpdir(), 'omx-team-claim-stale-lock-'));
|
|
610
1025
|
try {
|
|
@@ -1653,6 +2068,216 @@ exit 1
|
|
|
1653
2068
|
await rm(cwd, { recursive: true, force: true });
|
|
1654
2069
|
}
|
|
1655
2070
|
});
|
|
2071
|
+
for (const code of ['EINVAL', 'ENOTSUP', 'EISDIR']) {
|
|
2072
|
+
it(`writeAtomic tolerates unsupported parent directory sync ${code} after fsyncing the file`, async () => {
|
|
2073
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-team-state-'));
|
|
2074
|
+
try {
|
|
2075
|
+
const p = join(cwd, `atomic-parent-sync-${code}.txt`);
|
|
2076
|
+
let fileSyncs = 0;
|
|
2077
|
+
setWriteAtomicOpenForTests(async (...args) => {
|
|
2078
|
+
const [path] = args;
|
|
2079
|
+
const handle = await open(...args);
|
|
2080
|
+
const sync = handle.sync.bind(handle);
|
|
2081
|
+
handle.sync = async () => {
|
|
2082
|
+
if (path === cwd) {
|
|
2083
|
+
const error = new Error(`unsupported parent sync: ${code}`);
|
|
2084
|
+
error.code = code;
|
|
2085
|
+
throw error;
|
|
2086
|
+
}
|
|
2087
|
+
fileSyncs += 1;
|
|
2088
|
+
await sync();
|
|
2089
|
+
};
|
|
2090
|
+
return handle;
|
|
2091
|
+
});
|
|
2092
|
+
await writeAtomic(p, 'durable data');
|
|
2093
|
+
assert.equal(fileSyncs, 1);
|
|
2094
|
+
assert.equal(readFileSync(p, 'utf8'), 'durable data');
|
|
2095
|
+
}
|
|
2096
|
+
finally {
|
|
2097
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2098
|
+
}
|
|
2099
|
+
});
|
|
2100
|
+
}
|
|
2101
|
+
it('writeAtomic tolerates Windows EPERM opening the parent directory after fsyncing the file', async () => {
|
|
2102
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-team-state-'));
|
|
2103
|
+
try {
|
|
2104
|
+
const p = join(cwd, 'atomic-parent-open-eperm.txt');
|
|
2105
|
+
let fileSyncs = 0;
|
|
2106
|
+
setWriteAtomicPlatformForTests('win32');
|
|
2107
|
+
setWriteAtomicOpenForTests(async (...args) => {
|
|
2108
|
+
const [path] = args;
|
|
2109
|
+
if (path === cwd) {
|
|
2110
|
+
const error = new Error('Windows cannot open directory for sync');
|
|
2111
|
+
error.code = 'EPERM';
|
|
2112
|
+
throw error;
|
|
2113
|
+
}
|
|
2114
|
+
const handle = await open(...args);
|
|
2115
|
+
const sync = handle.sync.bind(handle);
|
|
2116
|
+
handle.sync = async () => {
|
|
2117
|
+
fileSyncs += 1;
|
|
2118
|
+
await sync();
|
|
2119
|
+
};
|
|
2120
|
+
return handle;
|
|
2121
|
+
});
|
|
2122
|
+
await writeAtomic(p, 'durable data');
|
|
2123
|
+
assert.equal(fileSyncs, 1);
|
|
2124
|
+
assert.equal(readFileSync(p, 'utf8'), 'durable data');
|
|
2125
|
+
}
|
|
2126
|
+
finally {
|
|
2127
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2128
|
+
}
|
|
2129
|
+
});
|
|
2130
|
+
it('writeAtomic tolerates Windows EPERM syncing the parent directory after fsyncing the file', async () => {
|
|
2131
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-team-state-'));
|
|
2132
|
+
try {
|
|
2133
|
+
const p = join(cwd, 'atomic-parent-sync-eperm.txt');
|
|
2134
|
+
let fileSyncs = 0;
|
|
2135
|
+
setWriteAtomicPlatformForTests('win32');
|
|
2136
|
+
setWriteAtomicOpenForTests(async (...args) => {
|
|
2137
|
+
const [path] = args;
|
|
2138
|
+
const handle = await open(...args);
|
|
2139
|
+
const sync = handle.sync.bind(handle);
|
|
2140
|
+
handle.sync = async () => {
|
|
2141
|
+
if (path === cwd) {
|
|
2142
|
+
const error = new Error('Windows cannot sync directory');
|
|
2143
|
+
error.code = 'EPERM';
|
|
2144
|
+
throw error;
|
|
2145
|
+
}
|
|
2146
|
+
fileSyncs += 1;
|
|
2147
|
+
await sync();
|
|
2148
|
+
};
|
|
2149
|
+
return handle;
|
|
2150
|
+
});
|
|
2151
|
+
await writeAtomic(p, 'durable data');
|
|
2152
|
+
assert.equal(fileSyncs, 1);
|
|
2153
|
+
assert.equal(readFileSync(p, 'utf8'), 'durable data');
|
|
2154
|
+
}
|
|
2155
|
+
finally {
|
|
2156
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2157
|
+
}
|
|
2158
|
+
});
|
|
2159
|
+
it('writeAtomic surfaces POSIX EPERM opening the parent directory after fsyncing the file', async () => {
|
|
2160
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-team-state-'));
|
|
2161
|
+
try {
|
|
2162
|
+
const p = join(cwd, 'atomic-parent-open-posix-eperm.txt');
|
|
2163
|
+
let fileSyncs = 0;
|
|
2164
|
+
setWriteAtomicPlatformForTests('linux');
|
|
2165
|
+
setWriteAtomicOpenForTests(async (...args) => {
|
|
2166
|
+
const [path] = args;
|
|
2167
|
+
if (path === cwd) {
|
|
2168
|
+
const error = new Error('POSIX parent open denied');
|
|
2169
|
+
error.code = 'EPERM';
|
|
2170
|
+
throw error;
|
|
2171
|
+
}
|
|
2172
|
+
const handle = await open(...args);
|
|
2173
|
+
const sync = handle.sync.bind(handle);
|
|
2174
|
+
handle.sync = async () => {
|
|
2175
|
+
fileSyncs += 1;
|
|
2176
|
+
await sync();
|
|
2177
|
+
};
|
|
2178
|
+
return handle;
|
|
2179
|
+
});
|
|
2180
|
+
await assert.rejects(() => writeAtomic(p, 'durable data'), (error) => {
|
|
2181
|
+
return error.code === 'EPERM';
|
|
2182
|
+
});
|
|
2183
|
+
assert.equal(fileSyncs, 1);
|
|
2184
|
+
assert.equal(readFileSync(p, 'utf8'), 'durable data');
|
|
2185
|
+
}
|
|
2186
|
+
finally {
|
|
2187
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2188
|
+
}
|
|
2189
|
+
});
|
|
2190
|
+
it('writeAtomic surfaces POSIX EPERM syncing the parent directory after fsyncing the file', async () => {
|
|
2191
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-team-state-'));
|
|
2192
|
+
try {
|
|
2193
|
+
const p = join(cwd, 'atomic-parent-sync-posix-eperm.txt');
|
|
2194
|
+
let fileSyncs = 0;
|
|
2195
|
+
setWriteAtomicPlatformForTests('linux');
|
|
2196
|
+
setWriteAtomicOpenForTests(async (...args) => {
|
|
2197
|
+
const [path] = args;
|
|
2198
|
+
const handle = await open(...args);
|
|
2199
|
+
const sync = handle.sync.bind(handle);
|
|
2200
|
+
handle.sync = async () => {
|
|
2201
|
+
if (path === cwd) {
|
|
2202
|
+
const error = new Error('POSIX parent sync denied');
|
|
2203
|
+
error.code = 'EPERM';
|
|
2204
|
+
throw error;
|
|
2205
|
+
}
|
|
2206
|
+
fileSyncs += 1;
|
|
2207
|
+
await sync();
|
|
2208
|
+
};
|
|
2209
|
+
return handle;
|
|
2210
|
+
});
|
|
2211
|
+
await assert.rejects(() => writeAtomic(p, 'durable data'), (error) => {
|
|
2212
|
+
return error.code === 'EPERM';
|
|
2213
|
+
});
|
|
2214
|
+
assert.equal(fileSyncs, 1);
|
|
2215
|
+
assert.equal(readFileSync(p, 'utf8'), 'durable data');
|
|
2216
|
+
}
|
|
2217
|
+
finally {
|
|
2218
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2219
|
+
}
|
|
2220
|
+
});
|
|
2221
|
+
it('writeAtomic surfaces unexpected parent directory open failures after fsyncing the file', async () => {
|
|
2222
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-team-state-'));
|
|
2223
|
+
try {
|
|
2224
|
+
const p = join(cwd, 'atomic-parent-open-unexpected.txt');
|
|
2225
|
+
let fileSyncs = 0;
|
|
2226
|
+
setWriteAtomicOpenForTests(async (...args) => {
|
|
2227
|
+
const [path] = args;
|
|
2228
|
+
if (path === cwd) {
|
|
2229
|
+
const error = new Error('parent open failed');
|
|
2230
|
+
error.code = 'EIO';
|
|
2231
|
+
throw error;
|
|
2232
|
+
}
|
|
2233
|
+
const handle = await open(...args);
|
|
2234
|
+
const sync = handle.sync.bind(handle);
|
|
2235
|
+
handle.sync = async () => {
|
|
2236
|
+
fileSyncs += 1;
|
|
2237
|
+
await sync();
|
|
2238
|
+
};
|
|
2239
|
+
return handle;
|
|
2240
|
+
});
|
|
2241
|
+
await assert.rejects(() => writeAtomic(p, 'durable data'), (error) => {
|
|
2242
|
+
return error.code === 'EIO';
|
|
2243
|
+
});
|
|
2244
|
+
assert.equal(fileSyncs, 1);
|
|
2245
|
+
assert.equal(readFileSync(p, 'utf8'), 'durable data');
|
|
2246
|
+
}
|
|
2247
|
+
finally {
|
|
2248
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2249
|
+
}
|
|
2250
|
+
});
|
|
2251
|
+
it('writeAtomic surfaces unexpected parent directory sync failures after fsyncing the file', async () => {
|
|
2252
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-team-state-'));
|
|
2253
|
+
try {
|
|
2254
|
+
const p = join(cwd, 'atomic-parent-sync-unexpected.txt');
|
|
2255
|
+
let fileSyncs = 0;
|
|
2256
|
+
setWriteAtomicOpenForTests(async (...args) => {
|
|
2257
|
+
const [path] = args;
|
|
2258
|
+
const handle = await open(...args);
|
|
2259
|
+
const sync = handle.sync.bind(handle);
|
|
2260
|
+
handle.sync = async () => {
|
|
2261
|
+
if (path === cwd) {
|
|
2262
|
+
const error = new Error('parent sync failed');
|
|
2263
|
+
error.code = 'EIO';
|
|
2264
|
+
throw error;
|
|
2265
|
+
}
|
|
2266
|
+
fileSyncs += 1;
|
|
2267
|
+
await sync();
|
|
2268
|
+
};
|
|
2269
|
+
return handle;
|
|
2270
|
+
});
|
|
2271
|
+
await assert.rejects(() => writeAtomic(p, 'durable data'), (error) => {
|
|
2272
|
+
return error.code === 'EIO';
|
|
2273
|
+
});
|
|
2274
|
+
assert.equal(fileSyncs, 1);
|
|
2275
|
+
assert.equal(readFileSync(p, 'utf8'), 'durable data');
|
|
2276
|
+
}
|
|
2277
|
+
finally {
|
|
2278
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2279
|
+
}
|
|
2280
|
+
});
|
|
1656
2281
|
it('readWorkerStatus returns {state:\'unknown\'} on missing file', async () => {
|
|
1657
2282
|
const cwd = await mkdtemp(join(tmpdir(), 'omx-team-state-'));
|
|
1658
2283
|
try {
|
|
@@ -1961,5 +2586,148 @@ exit 1
|
|
|
1961
2586
|
await rm(cwd, { recursive: true, force: true });
|
|
1962
2587
|
}
|
|
1963
2588
|
});
|
|
2589
|
+
it('heartbeats live scaling and membership leases while reclaiming stale crashed locks', async () => {
|
|
2590
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-team-lock-lease-'));
|
|
2591
|
+
const teamName = 'team-lock-lease';
|
|
2592
|
+
const staleMs = 90;
|
|
2593
|
+
const deps = {
|
|
2594
|
+
teamDir: (name, root) => join(root, '.omx', 'state', 'team', name),
|
|
2595
|
+
taskClaimLockDir: (name, taskId, root) => join(root, '.omx', 'state', 'team', name, 'claims', `task-${taskId}.lock`),
|
|
2596
|
+
mailboxLockDir: (name, worker, root) => join(root, '.omx', 'state', 'team', name, 'mailboxes', `${worker}.lock`),
|
|
2597
|
+
};
|
|
2598
|
+
const locks = [
|
|
2599
|
+
{
|
|
2600
|
+
label: 'scaling',
|
|
2601
|
+
lockDir: join(cwd, '.omx', 'state', '.team-locks', `${teamName}.scaling`),
|
|
2602
|
+
acquire: (fn) => withScalingLeaseLock(teamName, cwd, staleMs, deps, fn),
|
|
2603
|
+
},
|
|
2604
|
+
{
|
|
2605
|
+
label: 'membership',
|
|
2606
|
+
lockDir: join(cwd, '.omx', 'state', '.team-locks', `${teamName}.membership`),
|
|
2607
|
+
acquire: (fn) => withTeamLock(teamName, cwd, staleMs, deps, fn),
|
|
2608
|
+
},
|
|
2609
|
+
];
|
|
2610
|
+
try {
|
|
2611
|
+
for (const lock of locks) {
|
|
2612
|
+
let releaseHeld;
|
|
2613
|
+
const heldRelease = new Promise((resolve) => { releaseHeld = resolve; });
|
|
2614
|
+
let heldEntered;
|
|
2615
|
+
const heldEnteredPromise = new Promise((resolve) => { heldEntered = resolve; });
|
|
2616
|
+
const held = lock.acquire(async () => {
|
|
2617
|
+
heldEntered();
|
|
2618
|
+
await heldRelease;
|
|
2619
|
+
});
|
|
2620
|
+
await heldEnteredPromise;
|
|
2621
|
+
let contenderEntered = false;
|
|
2622
|
+
const contender = lock.acquire(async () => { contenderEntered = true; });
|
|
2623
|
+
await new Promise((resolve) => setTimeout(resolve, staleMs * 3));
|
|
2624
|
+
assert.equal(contenderEntered, false, `live ${lock.label} lock must not be reclaimed after its directory mtime becomes stale`);
|
|
2625
|
+
releaseHeld();
|
|
2626
|
+
await Promise.all([held, contender]);
|
|
2627
|
+
await mkdir(lock.lockDir, { recursive: true });
|
|
2628
|
+
await writeFile(join(lock.lockDir, 'owner'), 'crashed-owner');
|
|
2629
|
+
await writeFile(join(lock.lockDir, 'lease'), 'crashed-owner');
|
|
2630
|
+
const staleTime = new Date(Date.now() - staleMs * 2);
|
|
2631
|
+
await utimes(lock.lockDir, staleTime, staleTime);
|
|
2632
|
+
await utimes(join(lock.lockDir, 'lease'), staleTime, staleTime);
|
|
2633
|
+
let reclaimed = false;
|
|
2634
|
+
await lock.acquire(async () => { reclaimed = true; });
|
|
2635
|
+
assert.equal(reclaimed, true, `stale ${lock.label} lock must be reclaimed after its lease expires`);
|
|
2636
|
+
}
|
|
2637
|
+
}
|
|
2638
|
+
finally {
|
|
2639
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2640
|
+
}
|
|
2641
|
+
});
|
|
2642
|
+
it('serializes cleanup with concurrent scaling and claims without recreating team state', async () => {
|
|
2643
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-team-cleanup-lock-race-'));
|
|
2644
|
+
const teamName = 'team-cleanup-lock-race';
|
|
2645
|
+
const teamRoot = join(cwd, '.omx', 'state', 'team', teamName);
|
|
2646
|
+
let releaseScale;
|
|
2647
|
+
const scaleReleased = new Promise((resolve) => { releaseScale = resolve; });
|
|
2648
|
+
let scaleEntered;
|
|
2649
|
+
const scaleEnteredPromise = new Promise((resolve) => { scaleEntered = resolve; });
|
|
2650
|
+
try {
|
|
2651
|
+
await initTeamState(teamName, 't', 'executor', 1, cwd);
|
|
2652
|
+
const task = await createTask(teamName, { subject: 'racy', description: 'd', status: 'pending' }, cwd);
|
|
2653
|
+
const scale = withScalingLock(teamName, cwd, async () => {
|
|
2654
|
+
scaleEntered();
|
|
2655
|
+
await scaleReleased;
|
|
2656
|
+
});
|
|
2657
|
+
await scaleEnteredPromise;
|
|
2658
|
+
const cleanup = cleanupTeamState(teamName, cwd);
|
|
2659
|
+
const claim = await claimTask(teamName, task.id, 'worker-1', task.version, cwd);
|
|
2660
|
+
assert.equal(claim.ok, true, 'claim must finish before the queued cleanup deletes canonical state');
|
|
2661
|
+
releaseScale();
|
|
2662
|
+
await Promise.all([scale, cleanup]);
|
|
2663
|
+
assert.equal(existsSync(teamRoot), false);
|
|
2664
|
+
await assert.rejects(() => createTask(teamName, { subject: 'must not recreate', description: 'd', status: 'pending' }, cwd), /Team team-cleanup-lock-race not found/);
|
|
2665
|
+
const afterCleanupClaim = await claimTask(teamName, task.id, 'worker-1', null, cwd);
|
|
2666
|
+
assert.deepEqual(afterCleanupClaim, { ok: false, error: 'task_not_found' });
|
|
2667
|
+
assert.equal(existsSync(teamRoot), false, 'post-cleanup operations must not recreate a fake team root');
|
|
2668
|
+
}
|
|
2669
|
+
finally {
|
|
2670
|
+
releaseScale?.();
|
|
2671
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2672
|
+
}
|
|
2673
|
+
});
|
|
2674
|
+
});
|
|
2675
|
+
describe('membership transaction recovery path authority', () => {
|
|
2676
|
+
it('rejects foreign absolute paths before mutating any victim or canonical file', async () => {
|
|
2677
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-membership-foreign-path-'));
|
|
2678
|
+
try {
|
|
2679
|
+
const teamName = 'foreign-path';
|
|
2680
|
+
await initTeamState(teamName, 'task', 'executor', 1, cwd);
|
|
2681
|
+
const teamDir = join(cwd, '.omx', 'state', 'team', teamName);
|
|
2682
|
+
const configPath = join(teamDir, 'config.json');
|
|
2683
|
+
const configBytes = await readFile(configPath, 'utf8');
|
|
2684
|
+
const victimPath = join(cwd, 'victim.txt');
|
|
2685
|
+
await writeFile(victimPath, 'ORIGINAL');
|
|
2686
|
+
await writeFile(join(teamDir, '.membership-task-transaction.json'), JSON.stringify({
|
|
2687
|
+
schemaVersion: 1,
|
|
2688
|
+
phase: 'committed',
|
|
2689
|
+
files: [
|
|
2690
|
+
{ path: configPath, oldBytes: configBytes, newBytes: configBytes },
|
|
2691
|
+
{ path: victimPath, oldBytes: 'ORIGINAL', newBytes: 'PWNED' },
|
|
2692
|
+
],
|
|
2693
|
+
}));
|
|
2694
|
+
await assert.rejects(recoverTeamMembershipTaskTransaction(teamName, cwd), /escapes expected team files/);
|
|
2695
|
+
assert.equal(await readFile(victimPath, 'utf8'), 'ORIGINAL');
|
|
2696
|
+
assert.equal(await readFile(configPath, 'utf8'), configBytes);
|
|
2697
|
+
}
|
|
2698
|
+
finally {
|
|
2699
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2700
|
+
}
|
|
2701
|
+
});
|
|
2702
|
+
it('rejects a symlinked tasks directory before writing through it', async () => {
|
|
2703
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-membership-symlink-path-'));
|
|
2704
|
+
try {
|
|
2705
|
+
const teamName = 'symlink-path';
|
|
2706
|
+
await initTeamState(teamName, 'task', 'executor', 1, cwd);
|
|
2707
|
+
const teamDir = join(cwd, '.omx', 'state', 'team', teamName);
|
|
2708
|
+
const configPath = join(teamDir, 'config.json');
|
|
2709
|
+
const configBytes = await readFile(configPath, 'utf8');
|
|
2710
|
+
const tasksDir = join(teamDir, 'tasks');
|
|
2711
|
+
const outsideTasksDir = join(cwd, 'outside-tasks');
|
|
2712
|
+
await rm(tasksDir, { recursive: true, force: true });
|
|
2713
|
+
await mkdir(outsideTasksDir);
|
|
2714
|
+
await writeFile(join(outsideTasksDir, 'task-1.json'), 'ORIGINAL');
|
|
2715
|
+
await symlink(outsideTasksDir, tasksDir, 'dir');
|
|
2716
|
+
await writeFile(join(teamDir, '.membership-task-transaction.json'), JSON.stringify({
|
|
2717
|
+
schemaVersion: 1,
|
|
2718
|
+
phase: 'committed',
|
|
2719
|
+
files: [
|
|
2720
|
+
{ path: configPath, oldBytes: configBytes, newBytes: configBytes },
|
|
2721
|
+
{ path: join(tasksDir, 'task-1.json'), oldBytes: 'ORIGINAL', newBytes: 'PWNED' },
|
|
2722
|
+
],
|
|
2723
|
+
}));
|
|
2724
|
+
await assert.rejects(recoverTeamMembershipTaskTransaction(teamName, cwd), /tasks root escapes canonical team root/);
|
|
2725
|
+
assert.equal(await readFile(join(outsideTasksDir, 'task-1.json'), 'utf8'), 'ORIGINAL');
|
|
2726
|
+
assert.equal(await readFile(configPath, 'utf8'), configBytes);
|
|
2727
|
+
}
|
|
2728
|
+
finally {
|
|
2729
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2730
|
+
}
|
|
2731
|
+
});
|
|
1964
2732
|
});
|
|
1965
2733
|
//# sourceMappingURL=state.test.js.map
|