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
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { describe, it } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import {
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { once } from 'node:events';
|
|
4
5
|
import { existsSync } from 'node:fs';
|
|
6
|
+
import { link, lstat, mkdir, mkdtemp, readFile, readdir, readlink, rename, rm, symlink, writeFile } from 'node:fs/promises';
|
|
5
7
|
import { tmpdir } from 'node:os';
|
|
6
8
|
import { join } from 'node:path';
|
|
7
|
-
import { __createDefaultPidProbeForTests, appendPromptSessionProvenanceRejection, __resetSessionPointerTransactionDependenciesForTests, __setSessionPointerTransactionDependenciesForTests, isSessionPointerLaunchAbort, isSessionStale, readSessionPointer, readSessionState, readUsableSessionState, reconcileNativeSessionStart, resetSessionMetrics, resolveSessionPointerContext, writeSessionEnd, writeSessionStart, } from '../session.js';
|
|
9
|
+
import { __createDefaultPidProbeForTests, __releasePointerLockForTests, appendPromptSessionProvenanceRejection, closeLaunchSessionBindingOnce, establishLaunchSessionBinding, finalizeBoundOnce, __resetSessionPointerTransactionDependenciesForTests, __setSessionPointerTransactionDependenciesForTests, inspectSessionPointerLock, isSessionPointerLaunchAbort, isSessionStale, readSessionPointer, readSessionState, readUsableSessionState, readNativeSessionOwner, readNativeSessionOwnerEvidence, reconcileNativeSessionStart, recoverSessionPointerLock, resetSessionMetrics, resolveSessionPointerContext, writeSessionEnd, writeNativeSessionOwner, updateDetachedSessionMetadata, writeSessionStart, } from '../session.js';
|
|
8
10
|
function todayIsoDate() {
|
|
9
11
|
return new Date().toISOString().slice(0, 10);
|
|
10
12
|
}
|
|
@@ -23,8 +25,17 @@ const SUCCESSOR_TOKEN = 'successor_token_123456789';
|
|
|
23
25
|
function codedError(code) {
|
|
24
26
|
return Object.assign(new Error(code), { code });
|
|
25
27
|
}
|
|
28
|
+
function isOwnerConflict(error) {
|
|
29
|
+
return isSessionPointerLaunchAbort(error)
|
|
30
|
+
&& error.code === 'session_pointer_owner_conflict';
|
|
31
|
+
}
|
|
26
32
|
async function withPointerDependencies(overrides, run) {
|
|
27
|
-
|
|
33
|
+
const runtimePlatform = overrides.runtimePlatform ?? process.platform;
|
|
34
|
+
__setSessionPointerTransactionDependenciesForTests({
|
|
35
|
+
atomicRenameNoReplace: defaultTestAtomicRenameNoReplace,
|
|
36
|
+
...overrides,
|
|
37
|
+
runtimePlatform,
|
|
38
|
+
});
|
|
28
39
|
try {
|
|
29
40
|
await run();
|
|
30
41
|
}
|
|
@@ -48,8 +59,11 @@ async function withOwnerEnvironment(sessionId, run) {
|
|
|
48
59
|
process.env.OMX_SESSION_ID = previous;
|
|
49
60
|
}
|
|
50
61
|
}
|
|
51
|
-
function
|
|
52
|
-
return {
|
|
62
|
+
function matchingObservation(platform = process.platform) {
|
|
63
|
+
return { kind: 'identity', identity: { platform, birth: '1' } };
|
|
64
|
+
}
|
|
65
|
+
function matchingWin32Observation() {
|
|
66
|
+
return { kind: 'identity', identity: { platform: 'win32', birth: '1' } };
|
|
53
67
|
}
|
|
54
68
|
async function writeLockOwner(cwd, owner) {
|
|
55
69
|
const context = resolveSessionPointerContext(cwd);
|
|
@@ -58,16 +72,73 @@ async function writeLockOwner(cwd, owner) {
|
|
|
58
72
|
return context.lockPath;
|
|
59
73
|
}
|
|
60
74
|
function validLockOwner(overrides = {}) {
|
|
75
|
+
const platform = (overrides.platform ?? 'linux');
|
|
76
|
+
const version = overrides.version === 2 || overrides.process_identity !== undefined ? 2 : 1;
|
|
77
|
+
const identity = overrides.process_identity ?? (version === 2 ? { platform, birth: '1' } : undefined);
|
|
61
78
|
return {
|
|
62
|
-
version
|
|
79
|
+
version,
|
|
63
80
|
token: TEST_TOKEN,
|
|
64
81
|
pid: process.pid,
|
|
65
|
-
platform
|
|
66
|
-
|
|
82
|
+
platform,
|
|
83
|
+
...(version === 2 && identity ? { process_identity: identity } : {}),
|
|
84
|
+
...(version === 1 ? { pid_start_ticks: 1 } : {}),
|
|
67
85
|
created_at: '2026-07-14T00:00:00.000Z',
|
|
68
86
|
...overrides,
|
|
69
87
|
};
|
|
70
88
|
}
|
|
89
|
+
/** Safe test seam for capability probes: no-replace rename without overwriting destinations. */
|
|
90
|
+
async function defaultTestAtomicRenameNoReplace(from, to) {
|
|
91
|
+
if (existsSync(to))
|
|
92
|
+
return 'not-moved';
|
|
93
|
+
await rename(from, to);
|
|
94
|
+
return 'moved';
|
|
95
|
+
}
|
|
96
|
+
/** Wrap a lock-directory-focused seam so capability probes keep working. */
|
|
97
|
+
function withProbeFallback(contextLockPath, move) {
|
|
98
|
+
return async (from, to) => {
|
|
99
|
+
if (from === contextLockPath
|
|
100
|
+
|| to === contextLockPath
|
|
101
|
+
|| from.startsWith(`${contextLockPath}.`)
|
|
102
|
+
|| to.startsWith(`${contextLockPath}.`)) {
|
|
103
|
+
return await move(from, to);
|
|
104
|
+
}
|
|
105
|
+
return await defaultTestAtomicRenameNoReplace(from, to);
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
async function assertDurableDirectoryRecovery(cwd, options = {}) {
|
|
109
|
+
const context = resolveSessionPointerContext(cwd);
|
|
110
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
111
|
+
await withPointerDependencies({
|
|
112
|
+
token: () => SUCCESSOR_TOKEN,
|
|
113
|
+
probePid: () => 'dead',
|
|
114
|
+
atomicRenameNoReplace: options.atomicRenameNoReplace ?? defaultTestAtomicRenameNoReplace,
|
|
115
|
+
}, async () => {
|
|
116
|
+
const recovered = await recoverSessionPointerLock(cwd);
|
|
117
|
+
assert.equal(recovered.recovered, true, recovered.reason);
|
|
118
|
+
assert.equal(recovered.action, 'quarantined');
|
|
119
|
+
const parked = `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`;
|
|
120
|
+
const completed = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.completed`;
|
|
121
|
+
assert.equal(existsSync(context.lockPath), false);
|
|
122
|
+
assert.equal(existsSync(parked), true);
|
|
123
|
+
assert.equal(existsSync(completed), true);
|
|
124
|
+
assert.equal(existsSync(recovered.quarantinePath), true);
|
|
125
|
+
const quarantine = await lstat(recovered.quarantinePath);
|
|
126
|
+
const parkedOwner = await lstat(join(parked, 'owner.json'));
|
|
127
|
+
assert.deepEqual({ dev: quarantine.dev, ino: quarantine.ino }, { dev: parkedOwner.dev, ino: parkedOwner.ino });
|
|
128
|
+
const repeated = await recoverSessionPointerLock(cwd);
|
|
129
|
+
assert.equal(repeated.recovered, false);
|
|
130
|
+
assert.equal(repeated.status, 'absent');
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
async function linuxProcessStartTicks(pid) {
|
|
134
|
+
const stat = await readFile(`/proc/${pid}/stat`, 'utf-8');
|
|
135
|
+
const closingParen = stat.lastIndexOf(')');
|
|
136
|
+
assert.notEqual(closingParen, -1);
|
|
137
|
+
const fieldsAfterCommand = stat.slice(closingParen + 2).trim().split(/\s+/);
|
|
138
|
+
const startTicks = Number.parseInt(fieldsAfterCommand[19] ?? '', 10);
|
|
139
|
+
assert.equal(Number.isInteger(startTicks), true);
|
|
140
|
+
return startTicks;
|
|
141
|
+
}
|
|
71
142
|
describe('session lifecycle manager', () => {
|
|
72
143
|
it('resets session metrics files with zeroed counters', async () => {
|
|
73
144
|
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-metrics-'));
|
|
@@ -121,8 +192,13 @@ describe('session lifecycle manager', () => {
|
|
|
121
192
|
it('writes session start/end lifecycle artifacts and archives session history', async () => {
|
|
122
193
|
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lifecycle-'));
|
|
123
194
|
const sessionId = 'sess-lifecycle-1';
|
|
195
|
+
let binding;
|
|
124
196
|
try {
|
|
125
|
-
await
|
|
197
|
+
const established = await establishLaunchSessionBinding(cwd, sessionId);
|
|
198
|
+
assert.equal(established.kind, 'committed-released');
|
|
199
|
+
if (established.kind !== 'committed-released')
|
|
200
|
+
return;
|
|
201
|
+
binding = established.binding;
|
|
126
202
|
const state = await readSessionState(cwd);
|
|
127
203
|
assert.ok(state);
|
|
128
204
|
assert.equal(state.session_id, sessionId);
|
|
@@ -131,7 +207,7 @@ describe('session lifecycle manager', () => {
|
|
|
131
207
|
assert.equal(isSessionStale(state), false);
|
|
132
208
|
const sessionPath = join(cwd, '.omx', 'state', 'session.json');
|
|
133
209
|
assert.equal(existsSync(sessionPath), true);
|
|
134
|
-
await
|
|
210
|
+
await finalizeBoundOnce(binding, 'test');
|
|
135
211
|
assert.equal(existsSync(sessionPath), false);
|
|
136
212
|
const historyPath = join(cwd, '.omx', 'logs', 'session-history.jsonl');
|
|
137
213
|
assert.equal(existsSync(historyPath), true);
|
|
@@ -152,6 +228,78 @@ describe('session lifecycle manager', () => {
|
|
|
152
228
|
assert.match(dailyLog, /"event":"session_end"/);
|
|
153
229
|
}
|
|
154
230
|
finally {
|
|
231
|
+
if (binding)
|
|
232
|
+
await closeLaunchSessionBindingOnce(binding).catch(() => { });
|
|
233
|
+
await rm(cwd, { recursive: true, force: true });
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
it('emits the session-end warning only after successful end finalization', async () => {
|
|
237
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-end-durability-'));
|
|
238
|
+
const originalWrite = process.stderr.write;
|
|
239
|
+
const warnings = [];
|
|
240
|
+
process.stderr.write = ((value) => {
|
|
241
|
+
warnings.push(value);
|
|
242
|
+
return true;
|
|
243
|
+
});
|
|
244
|
+
let binding;
|
|
245
|
+
try {
|
|
246
|
+
const established = await establishLaunchSessionBinding(cwd, 'sess-end-durability');
|
|
247
|
+
assert.equal(established.kind, 'committed-released');
|
|
248
|
+
if (established.kind !== 'committed-released')
|
|
249
|
+
return;
|
|
250
|
+
binding = established.binding;
|
|
251
|
+
await writeSessionEnd(cwd, 'sess-end-durability', {
|
|
252
|
+
binding,
|
|
253
|
+
context: binding.context,
|
|
254
|
+
platform: 'win32',
|
|
255
|
+
regularFileSync: async () => { throw codedError('EPERM'); },
|
|
256
|
+
});
|
|
257
|
+
assert.deepEqual(warnings, [
|
|
258
|
+
'[omx] warning: Windows EPERM regular-file fsync unsupported in session pointer end; operation succeeded with degraded durability.\n',
|
|
259
|
+
]);
|
|
260
|
+
}
|
|
261
|
+
finally {
|
|
262
|
+
if (binding)
|
|
263
|
+
await closeLaunchSessionBindingOnce(binding).catch(() => { });
|
|
264
|
+
process.stderr.write = originalWrite;
|
|
265
|
+
await rm(cwd, { recursive: true, force: true });
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
it('keeps session-end durability warnings silent when finalization or release fails', async () => {
|
|
269
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-end-durability-failure-'));
|
|
270
|
+
const originalWrite = process.stderr.write;
|
|
271
|
+
const warnings = [];
|
|
272
|
+
process.stderr.write = ((value) => {
|
|
273
|
+
warnings.push(value);
|
|
274
|
+
return true;
|
|
275
|
+
});
|
|
276
|
+
const regularFileSync = async () => { throw codedError('EPERM'); };
|
|
277
|
+
let binding;
|
|
278
|
+
try {
|
|
279
|
+
const established = await establishLaunchSessionBinding(cwd, 'sess-end-release-failure');
|
|
280
|
+
assert.equal(established.kind, 'committed-released');
|
|
281
|
+
if (established.kind !== 'committed-released')
|
|
282
|
+
return;
|
|
283
|
+
binding = established.binding;
|
|
284
|
+
const establishedBinding = binding;
|
|
285
|
+
await withPointerDependencies({
|
|
286
|
+
fs: {
|
|
287
|
+
rmdir: async () => { throw new Error('release failure'); },
|
|
288
|
+
},
|
|
289
|
+
}, async () => {
|
|
290
|
+
await assert.rejects(writeSessionEnd(cwd, 'sess-end-release-failure', {
|
|
291
|
+
binding: establishedBinding,
|
|
292
|
+
context: establishedBinding.context,
|
|
293
|
+
platform: 'win32',
|
|
294
|
+
regularFileSync,
|
|
295
|
+
}));
|
|
296
|
+
});
|
|
297
|
+
assert.deepEqual(warnings, []);
|
|
298
|
+
}
|
|
299
|
+
finally {
|
|
300
|
+
if (binding)
|
|
301
|
+
await closeLaunchSessionBindingOnce(binding).catch(() => { });
|
|
302
|
+
process.stderr.write = originalWrite;
|
|
155
303
|
await rm(cwd, { recursive: true, force: true });
|
|
156
304
|
}
|
|
157
305
|
});
|
|
@@ -159,6 +307,7 @@ describe('session lifecycle manager', () => {
|
|
|
159
307
|
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-end-owner-'));
|
|
160
308
|
try {
|
|
161
309
|
await writeSessionStart(cwd, 'sess-current');
|
|
310
|
+
const beforePointer = await readFile(resolveSessionPointerContext(cwd).sessionPath, 'utf-8');
|
|
162
311
|
const stateDir = join(cwd, '.omx', 'state');
|
|
163
312
|
const sessionPath = join(stateDir, 'session.json');
|
|
164
313
|
const currentHudPath = join(stateDir, 'sessions', 'sess-current', 'hud-state.json');
|
|
@@ -167,21 +316,13 @@ describe('session lifecycle manager', () => {
|
|
|
167
316
|
await mkdir(join(stateDir, 'sessions', 'sess-ending'), { recursive: true });
|
|
168
317
|
await writeFile(currentHudPath, JSON.stringify({ turn_count: 2 }), 'utf-8');
|
|
169
318
|
await writeFile(endingHudPath, JSON.stringify({ turn_count: 1 }), 'utf-8');
|
|
170
|
-
await writeSessionEnd(cwd, 'sess-ending');
|
|
319
|
+
await assert.rejects(() => writeSessionEnd(cwd, 'sess-ending'), isSessionPointerLaunchAbort);
|
|
171
320
|
const state = await readSessionState(cwd);
|
|
172
321
|
assert.equal(state?.session_id, 'sess-current');
|
|
173
|
-
assert.equal(
|
|
322
|
+
assert.equal(await readFile(sessionPath, 'utf-8'), beforePointer);
|
|
174
323
|
assert.equal(existsSync(currentHudPath), true);
|
|
175
|
-
assert.equal(existsSync(endingHudPath),
|
|
176
|
-
|
|
177
|
-
.trim()
|
|
178
|
-
.split('\n')
|
|
179
|
-
.filter(Boolean);
|
|
180
|
-
assert.equal(historyLines.length, 1);
|
|
181
|
-
const historyEntry = JSON.parse(historyLines[0]);
|
|
182
|
-
assert.equal(historyEntry.session_id, 'sess-ending');
|
|
183
|
-
assert.equal(historyEntry.started_at, 'unknown');
|
|
184
|
-
assert.equal(historyEntry.preserved_active_session_id, 'sess-current');
|
|
324
|
+
assert.equal(existsSync(endingHudPath), true);
|
|
325
|
+
assert.equal(existsSync(join(cwd, '.omx', 'logs', 'session-history.jsonl')), false);
|
|
185
326
|
}
|
|
186
327
|
finally {
|
|
187
328
|
await rm(cwd, { recursive: true, force: true });
|
|
@@ -191,8 +332,13 @@ describe('session lifecycle manager', () => {
|
|
|
191
332
|
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-end-hud-cleanup-'));
|
|
192
333
|
const canonicalSessionId = 'omx-launch-hud';
|
|
193
334
|
const nativeSessionId = 'codex-native-hud';
|
|
335
|
+
let binding;
|
|
194
336
|
try {
|
|
195
|
-
await
|
|
337
|
+
const established = await establishLaunchSessionBinding(cwd, canonicalSessionId, { nativeSessionId });
|
|
338
|
+
assert.equal(established.kind, 'committed-released');
|
|
339
|
+
if (established.kind !== 'committed-released')
|
|
340
|
+
return;
|
|
341
|
+
binding = established.binding;
|
|
196
342
|
const stateDir = join(cwd, '.omx', 'state');
|
|
197
343
|
const rootHudPath = join(stateDir, 'hud-state.json');
|
|
198
344
|
const canonicalHudPath = join(stateDir, 'sessions', canonicalSessionId, 'hud-state.json');
|
|
@@ -202,19 +348,21 @@ describe('session lifecycle manager', () => {
|
|
|
202
348
|
await writeFile(rootHudPath, JSON.stringify({ last_turn_at: 'root', turn_count: 1 }), 'utf-8');
|
|
203
349
|
await writeFile(canonicalHudPath, JSON.stringify({ last_turn_at: 'canonical', turn_count: 2 }), 'utf-8');
|
|
204
350
|
await writeFile(nativeHudPath, JSON.stringify({ last_turn_at: 'native', turn_count: 9 }), 'utf-8');
|
|
205
|
-
await
|
|
351
|
+
await finalizeBoundOnce(binding, 'test');
|
|
206
352
|
assert.equal(existsSync(rootHudPath), false);
|
|
207
353
|
assert.equal(existsSync(canonicalHudPath), false);
|
|
208
354
|
assert.equal(existsSync(nativeHudPath), false);
|
|
209
355
|
}
|
|
210
356
|
finally {
|
|
357
|
+
if (binding)
|
|
358
|
+
await closeLaunchSessionBindingOnce(binding).catch(() => { });
|
|
211
359
|
await rm(cwd, { recursive: true, force: true });
|
|
212
360
|
}
|
|
213
361
|
});
|
|
214
362
|
it('preserves canonical session id while reconciling native SessionStart metadata', async () => {
|
|
215
363
|
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-native-reconcile-'));
|
|
216
364
|
try {
|
|
217
|
-
await writeSessionStart(cwd, 'omx-launch-1');
|
|
365
|
+
const established = await writeSessionStart(cwd, 'omx-launch-1');
|
|
218
366
|
const reconciled = await reconcileNativeSessionStart(cwd, 'codex-native-1', {
|
|
219
367
|
pid: 54321,
|
|
220
368
|
platform: 'win32',
|
|
@@ -223,10 +371,12 @@ describe('session lifecycle manager', () => {
|
|
|
223
371
|
assert.equal(reconciled.native_session_id, 'codex-native-1');
|
|
224
372
|
assert.equal(reconciled.pid, 54321);
|
|
225
373
|
assert.equal(reconciled.platform, 'win32');
|
|
374
|
+
assert.equal(reconciled.launch_lineage_token, established.launch_lineage_token);
|
|
226
375
|
const persisted = await readSessionState(cwd);
|
|
227
376
|
assert.equal(persisted?.session_id, 'omx-launch-1');
|
|
228
377
|
assert.equal(persisted?.native_session_id, 'codex-native-1');
|
|
229
378
|
assert.equal(persisted?.pid, 54321);
|
|
379
|
+
assert.equal(persisted?.launch_lineage_token, established.launch_lineage_token);
|
|
230
380
|
const dailyLogPath = join(cwd, '.omx', 'logs', `omx-${todayIsoDate()}.jsonl`);
|
|
231
381
|
const dailyLog = await readFile(dailyLogPath, 'utf-8');
|
|
232
382
|
assert.match(dailyLog, /"event":"session_start_reconciled"/);
|
|
@@ -236,74 +386,76 @@ describe('session lifecycle manager', () => {
|
|
|
236
386
|
await rm(cwd, { recursive: true, force: true });
|
|
237
387
|
}
|
|
238
388
|
});
|
|
239
|
-
it('
|
|
389
|
+
it('rejects replacing a live native session pointer with another native SessionStart', async () => {
|
|
240
390
|
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-native-fresh-'));
|
|
241
391
|
try {
|
|
242
392
|
await writeSessionStart(cwd, 'omx-old-session', {
|
|
243
393
|
nativeSessionId: 'codex-native-old',
|
|
244
394
|
});
|
|
245
|
-
|
|
395
|
+
await assert.rejects(reconcileNativeSessionStart(cwd, 'codex-native-new', {
|
|
246
396
|
pid: 54321,
|
|
247
397
|
platform: 'win32',
|
|
248
|
-
});
|
|
249
|
-
assert.equal(reconciled.session_id, 'codex-native-new');
|
|
250
|
-
assert.equal(reconciled.native_session_id, 'codex-native-new');
|
|
251
|
-
assert.equal(reconciled.previous_native_session_id, 'codex-native-old');
|
|
252
|
-
assert.equal(reconciled.owner_omx_session_id, 'omx-old-session');
|
|
253
|
-
assert.match(reconciled.native_session_switched_at ?? '', /^\d{4}-\d{2}-\d{2}T/);
|
|
254
|
-
assert.equal(reconciled.pid, 54321);
|
|
398
|
+
}), isOwnerConflict);
|
|
255
399
|
const persisted = await readSessionState(cwd);
|
|
256
|
-
assert.equal(persisted?.session_id, '
|
|
257
|
-
assert.equal(persisted?.native_session_id, 'codex-native-
|
|
258
|
-
assert.equal(persisted?.previous_native_session_id, 'codex-native-old');
|
|
259
|
-
assert.equal(persisted?.owner_omx_session_id, 'omx-old-session');
|
|
260
|
-
const dailyLogPath = join(cwd, '.omx', 'logs', `omx-${todayIsoDate()}.jsonl`);
|
|
261
|
-
const dailyLog = await readFile(dailyLogPath, 'utf-8');
|
|
262
|
-
assert.match(dailyLog, /"event":"native_session_replaced"/);
|
|
263
|
-
assert.match(dailyLog, /"event":"session_start"/);
|
|
264
|
-
assert.match(dailyLog, /"previous_native_session_id":"codex-native-old"/);
|
|
265
|
-
assert.match(dailyLog, /"native_session_id":"codex-native-new"/);
|
|
400
|
+
assert.equal(persisted?.session_id, 'omx-old-session');
|
|
401
|
+
assert.equal(persisted?.native_session_id, 'codex-native-old');
|
|
266
402
|
}
|
|
267
403
|
finally {
|
|
268
404
|
await rm(cwd, { recursive: true, force: true });
|
|
269
405
|
}
|
|
270
406
|
});
|
|
271
|
-
it('
|
|
407
|
+
it('routes existing detached metadata updates through the exact binding', async () => {
|
|
272
408
|
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-preserve-'));
|
|
409
|
+
let binding;
|
|
273
410
|
try {
|
|
274
|
-
await
|
|
411
|
+
const established = await establishLaunchSessionBinding(cwd, 'omx-launch-1', {
|
|
275
412
|
nativeSessionId: 'codex-native-1',
|
|
276
413
|
tmuxSessionName: 'omx-detached-demo',
|
|
277
414
|
});
|
|
278
|
-
|
|
415
|
+
assert.equal(established.kind, 'committed-released');
|
|
416
|
+
if (established.kind !== 'committed-released')
|
|
417
|
+
return;
|
|
418
|
+
binding = established.binding;
|
|
419
|
+
const withPaneResult = await updateDetachedSessionMetadata(binding, {
|
|
279
420
|
tmuxSessionName: 'omx-detached-demo',
|
|
280
421
|
tmuxPaneId: '%42',
|
|
281
422
|
});
|
|
282
|
-
assert.equal(
|
|
283
|
-
|
|
284
|
-
assert.equal(withPane
|
|
285
|
-
|
|
423
|
+
assert.equal(withPaneResult.kind, 'committed-released');
|
|
424
|
+
const withPane = await readSessionState(cwd);
|
|
425
|
+
assert.equal(withPane?.native_session_id, 'codex-native-1');
|
|
426
|
+
assert.equal(withPane?.tmux_session_name, 'omx-detached-demo');
|
|
427
|
+
assert.equal(withPane?.tmux_pane_id, '%42');
|
|
428
|
+
const withoutPaneResult = await updateDetachedSessionMetadata(binding, {
|
|
286
429
|
tmuxSessionName: 'omx-detached-demo',
|
|
287
430
|
});
|
|
288
|
-
assert.equal(
|
|
289
|
-
|
|
290
|
-
assert.equal(withoutPane
|
|
431
|
+
assert.equal(withoutPaneResult.kind, 'committed-released');
|
|
432
|
+
const withoutPane = await readSessionState(cwd);
|
|
433
|
+
assert.equal(withoutPane?.native_session_id, 'codex-native-1');
|
|
434
|
+
assert.equal(withoutPane?.tmux_session_name, 'omx-detached-demo');
|
|
435
|
+
assert.equal(withoutPane?.tmux_pane_id, '%42');
|
|
291
436
|
}
|
|
292
437
|
finally {
|
|
438
|
+
if (binding)
|
|
439
|
+
await closeLaunchSessionBindingOnce(binding).catch(() => { });
|
|
293
440
|
await rm(cwd, { recursive: true, force: true });
|
|
294
441
|
}
|
|
295
442
|
});
|
|
296
|
-
it('lets an owner OMX launch session end
|
|
443
|
+
it('lets an owner OMX launch session end after rejecting an unrelated native replacement', async () => {
|
|
297
444
|
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-native-owner-end-'));
|
|
445
|
+
let binding;
|
|
298
446
|
try {
|
|
299
|
-
await
|
|
447
|
+
const established = await establishLaunchSessionBinding(cwd, 'omx-owner-session', {
|
|
300
448
|
nativeSessionId: 'codex-native-old',
|
|
301
449
|
});
|
|
302
|
-
|
|
450
|
+
assert.equal(established.kind, 'committed-released');
|
|
451
|
+
if (established.kind !== 'committed-released')
|
|
452
|
+
return;
|
|
453
|
+
binding = established.binding;
|
|
454
|
+
await assert.rejects(reconcileNativeSessionStart(cwd, 'codex-native-new', {
|
|
303
455
|
pid: process.pid,
|
|
304
456
|
platform: 'win32',
|
|
305
|
-
});
|
|
306
|
-
await
|
|
457
|
+
}), isOwnerConflict);
|
|
458
|
+
await finalizeBoundOnce(binding, 'test');
|
|
307
459
|
assert.equal(await readSessionState(cwd), null);
|
|
308
460
|
const historyLines = (await readFile(join(cwd, '.omx', 'logs', 'session-history.jsonl'), 'utf-8'))
|
|
309
461
|
.trim()
|
|
@@ -312,64 +464,73 @@ describe('session lifecycle manager', () => {
|
|
|
312
464
|
assert.equal(historyLines.length, 1);
|
|
313
465
|
const historyEntry = JSON.parse(historyLines[0]);
|
|
314
466
|
assert.equal(historyEntry.session_id, 'omx-owner-session');
|
|
315
|
-
assert.equal(historyEntry.native_session_id, 'codex-native-
|
|
316
|
-
assert.equal(historyEntry.active_session_id,
|
|
467
|
+
assert.equal(historyEntry.native_session_id, 'codex-native-old');
|
|
468
|
+
assert.equal(historyEntry.active_session_id, undefined);
|
|
317
469
|
}
|
|
318
470
|
finally {
|
|
471
|
+
if (binding)
|
|
472
|
+
await closeLaunchSessionBindingOnce(binding).catch(() => { });
|
|
319
473
|
await rm(cwd, { recursive: true, force: true });
|
|
320
474
|
}
|
|
321
475
|
});
|
|
322
|
-
it('preserves
|
|
476
|
+
it('preserves canonical session metadata when reconciling the same native session', async () => {
|
|
323
477
|
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-native-owner-reconcile-'));
|
|
478
|
+
let binding;
|
|
324
479
|
try {
|
|
325
|
-
await
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
480
|
+
await withPointerDependencies({
|
|
481
|
+
runtimePlatform: 'win32',
|
|
482
|
+
observeProcess: () => matchingWin32Observation(),
|
|
483
|
+
}, async () => {
|
|
484
|
+
const established = await establishLaunchSessionBinding(cwd, 'omx-owner-session', {
|
|
485
|
+
nativeSessionId: 'codex-native-old',
|
|
486
|
+
platform: 'win32',
|
|
487
|
+
});
|
|
488
|
+
assert.equal(established.kind, 'committed-released');
|
|
489
|
+
if (established.kind !== 'committed-released')
|
|
490
|
+
return;
|
|
491
|
+
binding = established.binding;
|
|
492
|
+
const reconciled = await reconcileNativeSessionStart(cwd, 'codex-native-old', {
|
|
493
|
+
pid: process.pid,
|
|
494
|
+
platform: 'win32',
|
|
495
|
+
});
|
|
496
|
+
assert.equal(reconciled.session_id, 'omx-owner-session');
|
|
497
|
+
assert.equal(reconciled.native_session_id, 'codex-native-old');
|
|
498
|
+
assert.equal(reconciled.previous_native_session_id, undefined);
|
|
499
|
+
assert.equal(reconciled.owner_omx_session_id, undefined);
|
|
500
|
+
assert.equal(reconciled.pid, process.pid);
|
|
501
|
+
await finalizeBoundOnce(binding, 'test');
|
|
502
|
+
assert.equal(await readSessionState(cwd), null);
|
|
335
503
|
});
|
|
336
|
-
assert.equal(reconciled.session_id, 'codex-native-new');
|
|
337
|
-
assert.equal(reconciled.native_session_id, 'codex-native-new');
|
|
338
|
-
assert.equal(reconciled.previous_native_session_id, 'codex-native-old');
|
|
339
|
-
assert.equal(reconciled.owner_omx_session_id, 'omx-owner-session');
|
|
340
|
-
assert.equal(reconciled.pid, process.pid);
|
|
341
|
-
await writeSessionEnd(cwd, 'omx-owner-session');
|
|
342
|
-
assert.equal(await readSessionState(cwd), null);
|
|
343
504
|
}
|
|
344
505
|
finally {
|
|
506
|
+
if (binding)
|
|
507
|
+
await closeLaunchSessionBindingOnce(binding).catch(() => { });
|
|
345
508
|
await rm(cwd, { recursive: true, force: true });
|
|
346
509
|
}
|
|
347
510
|
});
|
|
348
|
-
it('
|
|
511
|
+
it('rejects chained native SessionStart replacements and preserves the original pointer', async () => {
|
|
349
512
|
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-native-owner-chain-'));
|
|
513
|
+
let binding;
|
|
350
514
|
try {
|
|
351
|
-
await
|
|
515
|
+
const established = await establishLaunchSessionBinding(cwd, 'omx-owner-session', {
|
|
352
516
|
nativeSessionId: 'codex-native-a',
|
|
353
517
|
});
|
|
354
|
-
|
|
518
|
+
assert.equal(established.kind, 'committed-released');
|
|
519
|
+
if (established.kind !== 'committed-released')
|
|
520
|
+
return;
|
|
521
|
+
binding = established.binding;
|
|
522
|
+
await assert.rejects(reconcileNativeSessionStart(cwd, 'codex-native-b', {
|
|
355
523
|
pid: process.pid,
|
|
356
524
|
platform: 'win32',
|
|
357
|
-
});
|
|
358
|
-
|
|
525
|
+
}), isOwnerConflict);
|
|
526
|
+
await assert.rejects(reconcileNativeSessionStart(cwd, 'codex-native-c', {
|
|
359
527
|
pid: process.pid,
|
|
360
528
|
platform: 'win32',
|
|
361
|
-
});
|
|
362
|
-
|
|
363
|
-
assert.equal(
|
|
364
|
-
assert.equal(
|
|
365
|
-
|
|
366
|
-
const dailyLogPath = join(cwd, '.omx', 'logs', `omx-${todayIsoDate()}.jsonl`);
|
|
367
|
-
const dailyLog = await readFile(dailyLogPath, 'utf-8');
|
|
368
|
-
assert.match(dailyLog, /"session_id":"omx-owner-session"/);
|
|
369
|
-
assert.match(dailyLog, /"active_session_id":"codex-native-b"/);
|
|
370
|
-
assert.match(dailyLog, /"previous_native_session_id":"codex-native-b"/);
|
|
371
|
-
assert.match(dailyLog, /"replaced_by_native_session_id":"codex-native-c"/);
|
|
372
|
-
await writeSessionEnd(cwd, 'omx-owner-session');
|
|
529
|
+
}), isOwnerConflict);
|
|
530
|
+
const persisted = await readSessionState(cwd);
|
|
531
|
+
assert.equal(persisted?.session_id, 'omx-owner-session');
|
|
532
|
+
assert.equal(persisted?.native_session_id, 'codex-native-a');
|
|
533
|
+
await finalizeBoundOnce(binding, 'test');
|
|
373
534
|
assert.equal(await readSessionState(cwd), null);
|
|
374
535
|
const historyLines = (await readFile(join(cwd, '.omx', 'logs', 'session-history.jsonl'), 'utf-8'))
|
|
375
536
|
.trim()
|
|
@@ -377,30 +538,28 @@ describe('session lifecycle manager', () => {
|
|
|
377
538
|
.filter(Boolean);
|
|
378
539
|
const historyEntry = JSON.parse(historyLines.at(-1) ?? '{}');
|
|
379
540
|
assert.equal(historyEntry.session_id, 'omx-owner-session');
|
|
380
|
-
assert.equal(historyEntry.native_session_id, 'codex-native-
|
|
381
|
-
assert.equal(historyEntry.active_session_id,
|
|
541
|
+
assert.equal(historyEntry.native_session_id, 'codex-native-a');
|
|
542
|
+
assert.equal(historyEntry.active_session_id, undefined);
|
|
382
543
|
}
|
|
383
544
|
finally {
|
|
545
|
+
if (binding)
|
|
546
|
+
await closeLaunchSessionBindingOnce(binding).catch(() => { });
|
|
384
547
|
await rm(cwd, { recursive: true, force: true });
|
|
385
548
|
}
|
|
386
549
|
});
|
|
387
|
-
it('
|
|
550
|
+
it('rejects replacing a live wrapper-owned native session', async () => {
|
|
388
551
|
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-native-non-omx-fresh-'));
|
|
389
552
|
try {
|
|
390
553
|
await writeSessionStart(cwd, 'codex-native-old', {
|
|
391
554
|
nativeSessionId: 'codex-native-old',
|
|
392
555
|
});
|
|
393
|
-
|
|
556
|
+
await assert.rejects(reconcileNativeSessionStart(cwd, 'codex-native-new', {
|
|
394
557
|
pid: 54321,
|
|
395
558
|
platform: 'win32',
|
|
396
|
-
});
|
|
397
|
-
assert.equal(reconciled.session_id, 'codex-native-new');
|
|
398
|
-
assert.equal(reconciled.native_session_id, 'codex-native-new');
|
|
399
|
-
assert.equal(reconciled.previous_native_session_id, undefined);
|
|
400
|
-
assert.equal(reconciled.pid, 54321);
|
|
559
|
+
}), isOwnerConflict);
|
|
401
560
|
const persisted = await readSessionState(cwd);
|
|
402
|
-
assert.equal(persisted?.session_id, 'codex-native-
|
|
403
|
-
assert.equal(persisted?.native_session_id, 'codex-native-
|
|
561
|
+
assert.equal(persisted?.session_id, 'codex-native-old');
|
|
562
|
+
assert.equal(persisted?.native_session_id, 'codex-native-old');
|
|
404
563
|
assert.equal(persisted?.previous_native_session_id, undefined);
|
|
405
564
|
}
|
|
406
565
|
finally {
|
|
@@ -546,14 +705,29 @@ describe('isSessionStale', () => {
|
|
|
546
705
|
});
|
|
547
706
|
assert.equal(stale, true);
|
|
548
707
|
});
|
|
549
|
-
it('
|
|
550
|
-
const
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
708
|
+
it('classifies identity-less live non-Linux pointers as identity-indeterminate', async () => {
|
|
709
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-non-linux-identityless-'));
|
|
710
|
+
try {
|
|
711
|
+
const context = resolveSessionPointerContext(cwd);
|
|
712
|
+
await mkdir(context.baseStateDir, { recursive: true });
|
|
713
|
+
await writeFile(context.sessionPath, JSON.stringify({
|
|
714
|
+
session_id: 'sess-darwin-identityless',
|
|
715
|
+
started_at: '2026-07-14T00:00:00.000Z',
|
|
716
|
+
cwd,
|
|
717
|
+
pid: process.pid,
|
|
718
|
+
platform: 'darwin',
|
|
719
|
+
}), 'utf-8');
|
|
720
|
+
await withPointerDependencies({
|
|
721
|
+
runtimePlatform: 'darwin',
|
|
722
|
+
probePid: () => 'alive',
|
|
723
|
+
observeProcess: () => ({ kind: 'unsupported' }),
|
|
724
|
+
}, async () => {
|
|
725
|
+
assert.equal((await readSessionPointer(context)).status, 'identity-indeterminate');
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
finally {
|
|
729
|
+
await rm(cwd, { recursive: true, force: true });
|
|
730
|
+
}
|
|
557
731
|
});
|
|
558
732
|
});
|
|
559
733
|
describe('session pointer transaction', () => {
|
|
@@ -563,13 +737,39 @@ describe('session pointer transaction', () => {
|
|
|
563
737
|
&& error.operation === 'pointer-context-resolve'
|
|
564
738
|
&& !('pointerPath' in error));
|
|
565
739
|
});
|
|
740
|
+
it('keeps the default synthetic identity aligned with the host transaction platform', async () => {
|
|
741
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-platform-fixture-'));
|
|
742
|
+
try {
|
|
743
|
+
const context = resolveSessionPointerContext(cwd);
|
|
744
|
+
await mkdir(context.baseStateDir, { recursive: true });
|
|
745
|
+
await writeFile(context.sessionPath, JSON.stringify({
|
|
746
|
+
session_id: 'sess-platform-fixture',
|
|
747
|
+
started_at: '2026-07-14T00:00:00.000Z',
|
|
748
|
+
cwd,
|
|
749
|
+
pid: process.pid,
|
|
750
|
+
platform: process.platform,
|
|
751
|
+
identity_schema_version: 2,
|
|
752
|
+
process_identity: { platform: process.platform, birth: '1' },
|
|
753
|
+
}), 'utf-8');
|
|
754
|
+
await withPointerDependencies({
|
|
755
|
+
probePid: () => 'alive',
|
|
756
|
+
observeProcess: () => matchingObservation(),
|
|
757
|
+
}, async () => {
|
|
758
|
+
assert.equal((await readSessionPointer(context)).status, 'usable');
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
finally {
|
|
762
|
+
__resetSessionPointerTransactionDependenciesForTests();
|
|
763
|
+
await rm(cwd, { recursive: true, force: true });
|
|
764
|
+
}
|
|
765
|
+
});
|
|
566
766
|
it('classifies only the exact selected pointer as absent, usable, stale, indeterminate, malformed, or foreign', async () => {
|
|
567
767
|
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-pointer-status-'));
|
|
568
768
|
try {
|
|
569
769
|
const context = resolveSessionPointerContext(cwd);
|
|
570
770
|
await withPointerDependencies({
|
|
571
771
|
probePid: () => 'alive',
|
|
572
|
-
|
|
772
|
+
observeProcess: () => matchingObservation(),
|
|
573
773
|
}, async () => {
|
|
574
774
|
assert.equal((await readSessionPointer(context)).status, 'absent');
|
|
575
775
|
await mkdir(context.baseStateDir, { recursive: true });
|
|
@@ -584,12 +784,12 @@ describe('session pointer transaction', () => {
|
|
|
584
784
|
assert.equal((await readSessionPointer(context)).status, 'usable');
|
|
585
785
|
__setSessionPointerTransactionDependenciesForTests({
|
|
586
786
|
probePid: () => 'dead',
|
|
587
|
-
|
|
787
|
+
observeProcess: () => matchingObservation(),
|
|
588
788
|
});
|
|
589
789
|
assert.equal((await readSessionPointer(context)).status, 'stale-dead');
|
|
590
790
|
__setSessionPointerTransactionDependenciesForTests({
|
|
591
791
|
probePid: () => 'indeterminate',
|
|
592
|
-
|
|
792
|
+
observeProcess: () => matchingObservation(),
|
|
593
793
|
});
|
|
594
794
|
assert.equal((await readSessionPointer(context)).status, 'identity-indeterminate');
|
|
595
795
|
await writeFile(context.sessionPath, '{ not-json', 'utf-8');
|
|
@@ -609,6 +809,277 @@ describe('session pointer transaction', () => {
|
|
|
609
809
|
await rm(cwd, { recursive: true, force: true });
|
|
610
810
|
}
|
|
611
811
|
});
|
|
812
|
+
it('covers the process identity decision table through pointer and lock inspection', async () => {
|
|
813
|
+
const hashA = 'aaa'.repeat(21) + 'a';
|
|
814
|
+
const hashB = 'bbb'.repeat(21) + 'b';
|
|
815
|
+
const identityOwner = (identity) => validLockOwner({
|
|
816
|
+
pid: 4242,
|
|
817
|
+
version: 2,
|
|
818
|
+
platform: identity.platform,
|
|
819
|
+
process_identity: identity,
|
|
820
|
+
});
|
|
821
|
+
const legacyOwner = (platform = 'linux', startTicks = 1) => validLockOwner({
|
|
822
|
+
pid: 4242,
|
|
823
|
+
platform,
|
|
824
|
+
pid_start_ticks: startTicks,
|
|
825
|
+
});
|
|
826
|
+
const identitylessOwner = (platform) => validLockOwner({
|
|
827
|
+
pid: 4242,
|
|
828
|
+
platform,
|
|
829
|
+
pid_start_ticks: undefined,
|
|
830
|
+
});
|
|
831
|
+
const identity = (platform, birth, cmdline_hash) => ({
|
|
832
|
+
kind: 'identity',
|
|
833
|
+
identity: { platform, birth, ...(cmdline_hash ? { cmdline_hash } : {}) },
|
|
834
|
+
});
|
|
835
|
+
const classifierCases = [
|
|
836
|
+
{
|
|
837
|
+
name: 'usable match',
|
|
838
|
+
recordedState: {
|
|
839
|
+
platform: 'linux', pid_start_ticks: 1, identity_schema_version: 2,
|
|
840
|
+
process_identity: { platform: 'linux', birth: '1' },
|
|
841
|
+
},
|
|
842
|
+
runtimePlatform: 'linux', probePid: 'alive', observations: [identity('linux', '1')],
|
|
843
|
+
lockOwner: identityOwner({ platform: 'linux', birth: '1' }),
|
|
844
|
+
expectedPointerStatus: 'usable', expectedLockStatus: 'live',
|
|
845
|
+
},
|
|
846
|
+
{
|
|
847
|
+
name: 'darwin match',
|
|
848
|
+
recordedState: { identity_schema_version: 2, process_identity: { platform: 'darwin', birth: '1234.567' } },
|
|
849
|
+
runtimePlatform: 'darwin', probePid: 'alive', observations: [identity('darwin', '1234.567')],
|
|
850
|
+
lockOwner: identityOwner({ platform: 'darwin', birth: '1234.567' }),
|
|
851
|
+
expectedPointerStatus: 'usable', expectedLockStatus: 'live',
|
|
852
|
+
},
|
|
853
|
+
{
|
|
854
|
+
name: 'windows exact FILETIME',
|
|
855
|
+
recordedState: { identity_schema_version: 2, process_identity: { platform: 'win32', birth: '132580896000000000' } },
|
|
856
|
+
runtimePlatform: 'win32', probePid: 'alive', observations: [identity('win32', '132580896000000000')],
|
|
857
|
+
lockOwner: identityOwner({ platform: 'win32', birth: '132580896000000000' }),
|
|
858
|
+
expectedPointerStatus: 'usable', expectedLockStatus: 'live',
|
|
859
|
+
},
|
|
860
|
+
{
|
|
861
|
+
name: 'dead PID',
|
|
862
|
+
recordedState: { platform: 'linux', pid_start_ticks: 1 },
|
|
863
|
+
runtimePlatform: 'linux', probePid: 'dead', observations: [],
|
|
864
|
+
lockOwner: legacyOwner(),
|
|
865
|
+
expectedPointerStatus: 'stale-dead', expectedLockStatus: 'dead',
|
|
866
|
+
},
|
|
867
|
+
{
|
|
868
|
+
name: 'birth mismatch',
|
|
869
|
+
recordedState: { platform: 'linux', pid_start_ticks: 1 },
|
|
870
|
+
runtimePlatform: 'linux', probePid: 'alive', observations: [identity('linux', '2'), identity('linux', '2')],
|
|
871
|
+
lockOwner: legacyOwner(),
|
|
872
|
+
expectedPointerStatus: 'stale-dead', expectedLockStatus: 'reused',
|
|
873
|
+
},
|
|
874
|
+
{
|
|
875
|
+
name: 'TOCTOU retry agree',
|
|
876
|
+
recordedState: { platform: 'linux', pid_start_ticks: 1 },
|
|
877
|
+
runtimePlatform: 'linux', probePid: 'alive', observations: [identity('linux', '2'), identity('linux', '1')],
|
|
878
|
+
lockOwner: legacyOwner(),
|
|
879
|
+
expectedPointerStatus: 'usable', expectedLockStatus: 'live',
|
|
880
|
+
},
|
|
881
|
+
{
|
|
882
|
+
name: 'TOCTOU retry disagree',
|
|
883
|
+
recordedState: { platform: 'linux', pid_start_ticks: 1 },
|
|
884
|
+
runtimePlatform: 'linux', probePid: 'alive', observations: [identity('linux', '2'), identity('linux', '3')],
|
|
885
|
+
lockOwner: legacyOwner(),
|
|
886
|
+
expectedPointerStatus: 'stale-dead', expectedLockStatus: 'reused',
|
|
887
|
+
},
|
|
888
|
+
{
|
|
889
|
+
name: 'EPERM probePid',
|
|
890
|
+
recordedState: { platform: 'linux', pid_start_ticks: 1 },
|
|
891
|
+
runtimePlatform: 'linux', probePid: 'indeterminate', observations: [],
|
|
892
|
+
lockOwner: legacyOwner(),
|
|
893
|
+
expectedPointerStatus: 'identity-indeterminate', expectedLockStatus: 'identity-indeterminate',
|
|
894
|
+
},
|
|
895
|
+
{
|
|
896
|
+
name: 'provider denied',
|
|
897
|
+
recordedState: { platform: 'linux', pid_start_ticks: 1 },
|
|
898
|
+
runtimePlatform: 'linux', probePid: 'alive', observations: [{ kind: 'denied' }],
|
|
899
|
+
lockOwner: legacyOwner(),
|
|
900
|
+
expectedPointerStatus: 'identity-indeterminate', expectedLockStatus: 'identity-indeterminate',
|
|
901
|
+
},
|
|
902
|
+
{
|
|
903
|
+
name: 'provider error',
|
|
904
|
+
recordedState: { platform: 'linux', pid_start_ticks: 1 },
|
|
905
|
+
runtimePlatform: 'linux', probePid: 'alive', observations: [{ kind: 'error' }],
|
|
906
|
+
lockOwner: legacyOwner(),
|
|
907
|
+
expectedPointerStatus: 'identity-indeterminate', expectedLockStatus: 'identity-indeterminate',
|
|
908
|
+
},
|
|
909
|
+
{
|
|
910
|
+
name: 'provider unsupported',
|
|
911
|
+
recordedState: { identity_schema_version: 2, process_identity: { platform: 'freebsd', birth: '1' } },
|
|
912
|
+
runtimePlatform: 'freebsd', probePid: 'alive', observations: [{ kind: 'unsupported' }],
|
|
913
|
+
lockOwner: identityOwner({ platform: 'freebsd', birth: '1' }),
|
|
914
|
+
expectedPointerStatus: 'identity-indeterminate', expectedLockStatus: 'identity-indeterminate',
|
|
915
|
+
},
|
|
916
|
+
{
|
|
917
|
+
name: 'foreign platform',
|
|
918
|
+
recordedState: { identity_schema_version: 2, process_identity: { platform: 'darwin', birth: '1' } },
|
|
919
|
+
runtimePlatform: 'linux', probePid: 'alive', observations: [identity('linux', '1')],
|
|
920
|
+
lockOwner: identityOwner({ platform: 'darwin', birth: '1' }),
|
|
921
|
+
expectedPointerStatus: 'identity-indeterminate', expectedLockStatus: 'identity-indeterminate',
|
|
922
|
+
},
|
|
923
|
+
{
|
|
924
|
+
name: 'cmdline hash match',
|
|
925
|
+
recordedState: {
|
|
926
|
+
identity_schema_version: 2,
|
|
927
|
+
process_identity: { platform: 'linux', birth: '1', cmdline_hash: hashA },
|
|
928
|
+
},
|
|
929
|
+
runtimePlatform: 'linux', probePid: 'alive', observations: [identity('linux', '1', hashA)],
|
|
930
|
+
lockOwner: identityOwner({ platform: 'linux', birth: '1', cmdline_hash: hashA }),
|
|
931
|
+
expectedPointerStatus: 'usable', expectedLockStatus: 'live',
|
|
932
|
+
},
|
|
933
|
+
{
|
|
934
|
+
name: 'cmdline hash mismatch',
|
|
935
|
+
recordedState: {
|
|
936
|
+
identity_schema_version: 2,
|
|
937
|
+
process_identity: { platform: 'linux', birth: '1', cmdline_hash: hashA },
|
|
938
|
+
},
|
|
939
|
+
runtimePlatform: 'linux', probePid: 'alive', observations: [identity('linux', '1', hashB)],
|
|
940
|
+
lockOwner: identityOwner({ platform: 'linux', birth: '1', cmdline_hash: hashA }),
|
|
941
|
+
expectedPointerStatus: 'identity-indeterminate', expectedLockStatus: 'identity-indeterminate',
|
|
942
|
+
},
|
|
943
|
+
{
|
|
944
|
+
name: 'legacy v1 upgrade Linux',
|
|
945
|
+
recordedState: { platform: 'linux', pid_start_ticks: 111 },
|
|
946
|
+
runtimePlatform: 'linux', probePid: 'alive', observations: [identity('linux', '111')],
|
|
947
|
+
lockOwner: legacyOwner('linux', 111),
|
|
948
|
+
expectedPointerStatus: 'usable', expectedLockStatus: 'live',
|
|
949
|
+
},
|
|
950
|
+
{
|
|
951
|
+
name: 'legacy v1 mismatch Linux',
|
|
952
|
+
recordedState: { platform: 'linux', pid_start_ticks: 111 },
|
|
953
|
+
runtimePlatform: 'linux', probePid: 'alive', observations: [identity('linux', '222'), identity('linux', '222')],
|
|
954
|
+
lockOwner: legacyOwner('linux', 111),
|
|
955
|
+
expectedPointerStatus: 'stale-dead', expectedLockStatus: 'reused',
|
|
956
|
+
},
|
|
957
|
+
{
|
|
958
|
+
name: 'legacy identity-less live non-Linux',
|
|
959
|
+
recordedState: { platform: 'darwin' },
|
|
960
|
+
runtimePlatform: 'darwin', probePid: 'alive', observations: [{ kind: 'unsupported' }],
|
|
961
|
+
lockOwner: identitylessOwner('darwin'),
|
|
962
|
+
expectedPointerStatus: 'identity-indeterminate', expectedLockStatus: 'identity-indeterminate',
|
|
963
|
+
},
|
|
964
|
+
{
|
|
965
|
+
name: 'future schema v3',
|
|
966
|
+
recordedState: { identity_schema_version: 3, process_identity: { platform: 'linux', birth: '1' } },
|
|
967
|
+
runtimePlatform: 'linux', probePid: 'alive', observations: [identity('linux', '1')],
|
|
968
|
+
lockOwner: identitylessOwner('darwin'),
|
|
969
|
+
expectedPointerStatus: 'identity-indeterminate', expectedLockStatus: 'identity-indeterminate',
|
|
970
|
+
},
|
|
971
|
+
{
|
|
972
|
+
name: 'malformed JSON',
|
|
973
|
+
rawPointer: '{not-json',
|
|
974
|
+
runtimePlatform: 'linux', probePid: 'alive', observations: [], lockOwner: 'malformed',
|
|
975
|
+
expectedPointerStatus: 'malformed', expectedLockStatus: 'malformed',
|
|
976
|
+
},
|
|
977
|
+
{
|
|
978
|
+
name: 'same-second Darwin collision',
|
|
979
|
+
recordedState: { identity_schema_version: 2, process_identity: { platform: 'darwin', birth: '1000.500' } },
|
|
980
|
+
runtimePlatform: 'darwin', probePid: 'alive', observations: [identity('darwin', '1000.999'), identity('darwin', '1000.999')],
|
|
981
|
+
lockOwner: identityOwner({ platform: 'darwin', birth: '1000.500' }),
|
|
982
|
+
expectedPointerStatus: 'stale-dead', expectedLockStatus: 'reused',
|
|
983
|
+
},
|
|
984
|
+
{
|
|
985
|
+
name: 'absent pointer',
|
|
986
|
+
runtimePlatform: 'linux', probePid: 'alive', observations: [],
|
|
987
|
+
expectedPointerStatus: 'absent', expectedLockStatus: 'absent',
|
|
988
|
+
},
|
|
989
|
+
];
|
|
990
|
+
for (const testCase of classifierCases) {
|
|
991
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-classifier-'));
|
|
992
|
+
try {
|
|
993
|
+
const context = resolveSessionPointerContext(cwd);
|
|
994
|
+
await mkdir(context.baseStateDir, { recursive: true });
|
|
995
|
+
if (testCase.rawPointer !== undefined) {
|
|
996
|
+
await writeFile(context.sessionPath, testCase.rawPointer, 'utf-8');
|
|
997
|
+
}
|
|
998
|
+
else if (testCase.recordedState) {
|
|
999
|
+
await writeFile(context.sessionPath, JSON.stringify({
|
|
1000
|
+
session_id: 'sess-classifier',
|
|
1001
|
+
started_at: '2026-07-14T00:00:00.000Z',
|
|
1002
|
+
cwd,
|
|
1003
|
+
pid: 4242,
|
|
1004
|
+
...testCase.recordedState,
|
|
1005
|
+
}), 'utf-8');
|
|
1006
|
+
}
|
|
1007
|
+
if (testCase.lockOwner !== undefined) {
|
|
1008
|
+
await mkdir(context.lockPath, { recursive: true });
|
|
1009
|
+
await writeFile(join(context.lockPath, 'owner.json'), testCase.lockOwner === 'malformed' ? '{not-json' : JSON.stringify(testCase.lockOwner), 'utf-8');
|
|
1010
|
+
}
|
|
1011
|
+
let observerCalls = 0;
|
|
1012
|
+
const observer = () => {
|
|
1013
|
+
const index = observerCalls++;
|
|
1014
|
+
return testCase.observations[Math.min(index, Math.max(testCase.observations.length - 1, 0))] ?? { kind: 'error' };
|
|
1015
|
+
};
|
|
1016
|
+
await withPointerDependencies({
|
|
1017
|
+
runtimePlatform: testCase.runtimePlatform,
|
|
1018
|
+
probePid: () => testCase.probePid,
|
|
1019
|
+
observeProcess: observer,
|
|
1020
|
+
}, async () => {
|
|
1021
|
+
assert.equal((await readSessionPointer(context)).status, testCase.expectedPointerStatus, testCase.name);
|
|
1022
|
+
});
|
|
1023
|
+
let lockObserverCalls = 0;
|
|
1024
|
+
const lockObserver = () => {
|
|
1025
|
+
const index = lockObserverCalls++;
|
|
1026
|
+
return testCase.observations[Math.min(index, Math.max(testCase.observations.length - 1, 0))] ?? { kind: 'error' };
|
|
1027
|
+
};
|
|
1028
|
+
await withPointerDependencies({
|
|
1029
|
+
runtimePlatform: testCase.runtimePlatform,
|
|
1030
|
+
probePid: () => testCase.probePid,
|
|
1031
|
+
observeProcess: lockObserver,
|
|
1032
|
+
}, async () => {
|
|
1033
|
+
assert.equal((await inspectSessionPointerLock(cwd)).status, testCase.expectedLockStatus, testCase.name);
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
finally {
|
|
1037
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
});
|
|
1041
|
+
it('rejects every schema version other than v2 as identity-indeterminate', async () => {
|
|
1042
|
+
const schemaVersions = [0, 1, -1, null, '2', 'bad', 3];
|
|
1043
|
+
for (const schemaVersion of schemaVersions) {
|
|
1044
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-identity-schema-rejection-'));
|
|
1045
|
+
try {
|
|
1046
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1047
|
+
await mkdir(context.baseStateDir, { recursive: true });
|
|
1048
|
+
await writeFile(context.sessionPath, JSON.stringify({
|
|
1049
|
+
session_id: 'sess-schema-rejection',
|
|
1050
|
+
started_at: '2026-07-14T00:00:00.000Z',
|
|
1051
|
+
cwd,
|
|
1052
|
+
pid: 4242,
|
|
1053
|
+
platform: 'linux',
|
|
1054
|
+
pid_start_ticks: 1,
|
|
1055
|
+
identity_schema_version: schemaVersion,
|
|
1056
|
+
}), 'utf-8');
|
|
1057
|
+
await mkdir(context.lockPath, { recursive: true });
|
|
1058
|
+
await writeFile(join(context.lockPath, 'owner.json'), JSON.stringify(validLockOwner({
|
|
1059
|
+
pid: 4242,
|
|
1060
|
+
platform: 'darwin',
|
|
1061
|
+
pid_start_ticks: undefined,
|
|
1062
|
+
})), 'utf-8');
|
|
1063
|
+
await withPointerDependencies({
|
|
1064
|
+
runtimePlatform: 'linux',
|
|
1065
|
+
probePid: () => 'alive',
|
|
1066
|
+
observeProcess: () => matchingObservation('linux'),
|
|
1067
|
+
}, async () => {
|
|
1068
|
+
assert.equal((await readSessionPointer(context)).status, 'identity-indeterminate', String(schemaVersion));
|
|
1069
|
+
});
|
|
1070
|
+
await withPointerDependencies({
|
|
1071
|
+
runtimePlatform: 'linux',
|
|
1072
|
+
probePid: () => 'alive',
|
|
1073
|
+
observeProcess: () => matchingObservation('linux'),
|
|
1074
|
+
}, async () => {
|
|
1075
|
+
assert.equal((await inspectSessionPointerLock(cwd)).status, 'identity-indeterminate', String(schemaVersion));
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
finally {
|
|
1079
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
});
|
|
612
1083
|
it('maps only ESRCH to a definitely dead default PID probe', () => {
|
|
613
1084
|
const alive = __createDefaultPidProbeForTests(() => { });
|
|
614
1085
|
const esrch = __createDefaultPidProbeForTests(() => { throw codedError('ESRCH'); });
|
|
@@ -626,11 +1097,11 @@ describe('session pointer transaction', () => {
|
|
|
626
1097
|
it('never reaps dead, reused, indeterminate, missing, or malformed lock evidence', async () => {
|
|
627
1098
|
const cases = [
|
|
628
1099
|
{ name: 'dead', owner: validLockOwner(), probePid: 'dead', expected: 'dead' },
|
|
629
|
-
{ name: 'reused', owner: validLockOwner(), probePid: 'alive', identity: {
|
|
1100
|
+
{ name: 'reused', owner: validLockOwner(), probePid: 'alive', observation: { kind: 'identity', identity: { platform: 'linux', birth: '2' } }, expected: 'reused' },
|
|
630
1101
|
{ name: 'indeterminate-probe', owner: validLockOwner(), probePid: 'indeterminate', expected: 'identity-indeterminate' },
|
|
631
|
-
{ name: 'indeterminate-identity', owner: validLockOwner(), probePid: 'alive',
|
|
632
|
-
{ name: '
|
|
633
|
-
{ name: 'missing-required-hash', owner: validLockOwner({ pid_cmdline_hash: 'a'.repeat(64) }), probePid: 'alive',
|
|
1102
|
+
{ name: 'indeterminate-identity', owner: validLockOwner(), probePid: 'alive', observation: { kind: 'error' }, expected: 'identity-indeterminate' },
|
|
1103
|
+
{ name: 'foreign-identity', owner: validLockOwner(), probePid: 'alive', observation: { kind: 'identity', identity: { platform: 'darwin', birth: '1' } }, expected: 'identity-indeterminate' },
|
|
1104
|
+
{ name: 'missing-required-hash', owner: validLockOwner({ pid_cmdline_hash: 'a'.repeat(64) }), probePid: 'alive', observation: matchingObservation(), expected: 'identity-indeterminate' },
|
|
634
1105
|
{ name: 'missing', probePid: 'alive', expected: 'missing' },
|
|
635
1106
|
{ name: 'malformed', owner: { version: 1 }, probePid: 'alive', expected: 'malformed' },
|
|
636
1107
|
];
|
|
@@ -646,7 +1117,7 @@ describe('session pointer transaction', () => {
|
|
|
646
1117
|
await withPointerDependencies({
|
|
647
1118
|
token: () => TEST_TOKEN,
|
|
648
1119
|
probePid: () => testCase.probePid,
|
|
649
|
-
|
|
1120
|
+
observeProcess: () => testCase.observation ?? matchingObservation(),
|
|
650
1121
|
}, async () => {
|
|
651
1122
|
await assert.rejects(writeSessionStart(cwd, 'sess-lock', { platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
|
|
652
1123
|
&& error.code === 'session_pointer_lock_recovery_required'
|
|
@@ -659,335 +1130,2629 @@ describe('session pointer transaction', () => {
|
|
|
659
1130
|
}
|
|
660
1131
|
}
|
|
661
1132
|
});
|
|
662
|
-
it('
|
|
663
|
-
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-
|
|
1133
|
+
it('atomically quarantines dead pre-rename temporary owner evidence and stays idempotent', async () => {
|
|
1134
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-dead-temp-'));
|
|
1135
|
+
try {
|
|
1136
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1137
|
+
await mkdir(context.lockPath, { recursive: true });
|
|
1138
|
+
await writeFile(join(context.lockPath, `owner.${TEST_TOKEN}.tmp`), JSON.stringify(validLockOwner()), 'utf-8');
|
|
1139
|
+
await withPointerDependencies({ token: () => SUCCESSOR_TOKEN, probePid: () => 'dead' }, async () => {
|
|
1140
|
+
const inspected = await inspectSessionPointerLock(cwd);
|
|
1141
|
+
assert.equal(inspected.status, 'dead');
|
|
1142
|
+
assert.equal(inspected.evidenceSource, 'owner-temp');
|
|
1143
|
+
assert.equal(inspected.safeToRecover, true);
|
|
1144
|
+
const recovered = await recoverSessionPointerLock(cwd);
|
|
1145
|
+
assert.equal(recovered.recovered, true);
|
|
1146
|
+
assert.equal(recovered.action, 'quarantined');
|
|
1147
|
+
assert.equal(existsSync(context.lockPath), false);
|
|
1148
|
+
assert.equal(existsSync(recovered.quarantinePath), true);
|
|
1149
|
+
const repeated = await recoverSessionPointerLock(cwd);
|
|
1150
|
+
assert.equal(repeated.recovered, false);
|
|
1151
|
+
assert.equal(repeated.action, 'none');
|
|
1152
|
+
assert.equal(repeated.status, 'absent');
|
|
1153
|
+
});
|
|
1154
|
+
}
|
|
1155
|
+
finally {
|
|
1156
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1157
|
+
}
|
|
1158
|
+
});
|
|
1159
|
+
it('atomically quarantines dead canonical owner evidence and stays idempotent', async () => {
|
|
1160
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-preserved-'));
|
|
1161
|
+
try {
|
|
1162
|
+
await assertDurableDirectoryRecovery(cwd);
|
|
1163
|
+
}
|
|
1164
|
+
finally {
|
|
1165
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1166
|
+
}
|
|
1167
|
+
});
|
|
1168
|
+
it('launches only after explicit recovery of a dead canonical owner lock', async () => {
|
|
1169
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recover-launch-'));
|
|
664
1170
|
try {
|
|
1171
|
+
const context = resolveSessionPointerContext(cwd);
|
|
665
1172
|
await writeLockOwner(cwd, validLockOwner());
|
|
666
|
-
|
|
667
|
-
|
|
1173
|
+
await withPointerDependencies({ token: () => SUCCESSOR_TOKEN, probePid: () => 'dead' }, async () => {
|
|
1174
|
+
await assert.rejects(writeSessionStart(cwd, 'sess-blocked', { platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
|
|
1175
|
+
&& error.code === 'session_pointer_lock_recovery_required'
|
|
1176
|
+
&& error.lockOwnerStatus === 'dead');
|
|
1177
|
+
assert.equal(existsSync(context.lockPath), true);
|
|
1178
|
+
const recovered = await recoverSessionPointerLock(cwd);
|
|
1179
|
+
assert.equal(recovered.recovered, true);
|
|
1180
|
+
assert.equal(recovered.action, 'quarantined');
|
|
1181
|
+
await writeSessionStart(cwd, 'sess-after-recovery', { platform: 'win32' });
|
|
1182
|
+
assert.equal((await readSessionState(cwd))?.session_id, 'sess-after-recovery');
|
|
1183
|
+
assert.equal(existsSync(context.lockPath), false);
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
finally {
|
|
1187
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1188
|
+
}
|
|
1189
|
+
});
|
|
1190
|
+
it('does not claim a successor lock when the inspected dead canonical owner is displaced before the exact rename', async () => {
|
|
1191
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-canonical-race-'));
|
|
1192
|
+
try {
|
|
1193
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1194
|
+
const ownerPath = join(context.lockPath, 'owner.json');
|
|
1195
|
+
const successorTemp = join(context.lockPath, `owner.${SUCCESSOR_TOKEN}.tmp`);
|
|
1196
|
+
const displacedPath = `${context.lockPath}.displaced`;
|
|
1197
|
+
const staleOwner = JSON.stringify(validLockOwner());
|
|
1198
|
+
const successorOwner = JSON.stringify(validLockOwner({ token: SUCCESSOR_TOKEN }));
|
|
1199
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1200
|
+
let displaced = false;
|
|
668
1201
|
await withPointerDependencies({
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
1202
|
+
token: () => SUCCESSOR_TOKEN,
|
|
1203
|
+
probePid: () => 'dead',
|
|
1204
|
+
atomicRenameNoReplace: async (from, to) => {
|
|
1205
|
+
if (from === context.lockPath && to === `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`) {
|
|
1206
|
+
displaced = true;
|
|
1207
|
+
await rename(from, displacedPath);
|
|
1208
|
+
await mkdir(context.lockPath);
|
|
1209
|
+
await writeFile(successorTemp, successorOwner, 'utf-8');
|
|
1210
|
+
return 'not-moved';
|
|
1211
|
+
}
|
|
1212
|
+
if (existsSync(to))
|
|
1213
|
+
return 'not-moved';
|
|
1214
|
+
await rename(from, to);
|
|
1215
|
+
return 'moved';
|
|
1216
|
+
},
|
|
674
1217
|
}, async () => {
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
1218
|
+
const recovered = await recoverSessionPointerLock(cwd);
|
|
1219
|
+
assert.equal(recovered.recovered, false);
|
|
1220
|
+
assert.equal(recovered.action, 'none');
|
|
1221
|
+
assert.match(recovered.reason, /Atomic recovery move|recovery checkpoint/i);
|
|
678
1222
|
});
|
|
679
|
-
assert.
|
|
680
|
-
assert.equal(
|
|
681
|
-
assert.
|
|
1223
|
+
assert.equal(displaced, true);
|
|
1224
|
+
assert.equal(await readFile(join(displacedPath, 'owner.json'), 'utf-8'), staleOwner);
|
|
1225
|
+
assert.deepEqual(await readdir(context.lockPath), [`owner.${SUCCESSOR_TOKEN}.tmp`]);
|
|
1226
|
+
assert.equal(await readFile(successorTemp, 'utf-8'), successorOwner);
|
|
1227
|
+
assert.equal(existsSync(ownerPath), false);
|
|
682
1228
|
}
|
|
683
1229
|
finally {
|
|
684
1230
|
await rm(cwd, { recursive: true, force: true });
|
|
685
1231
|
}
|
|
686
1232
|
});
|
|
687
|
-
it('rolls back
|
|
688
|
-
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-
|
|
1233
|
+
it('rolls back a claim that lands on a displaced successor canonical owner', async () => {
|
|
1234
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-canonical-claim-race-'));
|
|
689
1235
|
try {
|
|
690
1236
|
const context = resolveSessionPointerContext(cwd);
|
|
1237
|
+
const ownerPath = join(context.lockPath, 'owner.json');
|
|
1238
|
+
const parkedPath = `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`;
|
|
1239
|
+
const quarantinePath = `${context.lockPath}.quarantine.${TEST_TOKEN}.${SUCCESSOR_TOKEN}`;
|
|
1240
|
+
const checkpointPath = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
|
|
1241
|
+
const staleOwner = JSON.stringify(validLockOwner());
|
|
1242
|
+
const successorPid = process.pid + 100_000;
|
|
1243
|
+
const successorOwner = JSON.stringify(validLockOwner({ token: SUCCESSOR_TOKEN, pid: successorPid }));
|
|
1244
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1245
|
+
let moved = false;
|
|
1246
|
+
let rollbackAttempted = false;
|
|
691
1247
|
await withPointerDependencies({
|
|
692
|
-
token: () =>
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
await
|
|
698
|
-
|
|
1248
|
+
token: () => SUCCESSOR_TOKEN,
|
|
1249
|
+
probePid: () => 'dead',
|
|
1250
|
+
atomicRenameNoReplace: async (from, to) => {
|
|
1251
|
+
if (from === context.lockPath && to === parkedPath) {
|
|
1252
|
+
await rename(from, to);
|
|
1253
|
+
await mkdir(context.lockPath);
|
|
1254
|
+
await writeFile(ownerPath, successorOwner, 'utf-8');
|
|
1255
|
+
moved = true;
|
|
1256
|
+
return 'moved';
|
|
1257
|
+
}
|
|
1258
|
+
if (from === parkedPath && to === context.lockPath) {
|
|
1259
|
+
rollbackAttempted = true;
|
|
1260
|
+
return 'not-moved';
|
|
1261
|
+
}
|
|
1262
|
+
if (existsSync(to))
|
|
1263
|
+
return 'not-moved';
|
|
1264
|
+
await rename(from, to);
|
|
1265
|
+
return 'moved';
|
|
699
1266
|
},
|
|
700
1267
|
}, async () => {
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
1268
|
+
const recovered = await recoverSessionPointerLock(cwd);
|
|
1269
|
+
assert.equal(recovered.recovered, false);
|
|
1270
|
+
assert.equal(recovered.action, 'none');
|
|
1271
|
+
assert.match(recovered.reason, /captured object|token-bound|source pathname/i);
|
|
1272
|
+
});
|
|
1273
|
+
assert.equal(moved, true);
|
|
1274
|
+
assert.equal(rollbackAttempted, true);
|
|
1275
|
+
assert.equal(await readFile(ownerPath, 'utf-8'), successorOwner);
|
|
1276
|
+
assert.equal(await readFile(join(parkedPath, 'owner.json'), 'utf-8'), staleOwner);
|
|
1277
|
+
assert.equal(await readFile(quarantinePath, 'utf-8'), staleOwner);
|
|
1278
|
+
assert.equal(existsSync(checkpointPath), true);
|
|
1279
|
+
}
|
|
1280
|
+
finally {
|
|
1281
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1282
|
+
}
|
|
1283
|
+
});
|
|
1284
|
+
it('live canonical successor survives a refused recovery claim and stays recognizable, releasable, and acquirable', async () => {
|
|
1285
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-live-successor-'));
|
|
1286
|
+
try {
|
|
1287
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1288
|
+
const ownerPath = join(context.lockPath, 'owner.json');
|
|
1289
|
+
const parkedPath = `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`;
|
|
1290
|
+
const successorPid = process.pid + 100_000;
|
|
1291
|
+
const successorOwner = JSON.stringify(validLockOwner({ token: SUCCESSOR_TOKEN, pid: successorPid }));
|
|
1292
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1293
|
+
let rollbackAttempted = false;
|
|
1294
|
+
await withPointerDependencies({
|
|
1295
|
+
token: () => SUCCESSOR_TOKEN,
|
|
1296
|
+
probePid: (pid) => pid === successorPid ? 'alive' : 'dead',
|
|
1297
|
+
observeProcess: () => matchingObservation(),
|
|
1298
|
+
atomicRenameNoReplace: async (from, to) => {
|
|
1299
|
+
if (from === context.lockPath && to === parkedPath) {
|
|
1300
|
+
await rename(from, to);
|
|
1301
|
+
await mkdir(context.lockPath);
|
|
1302
|
+
await writeFile(ownerPath, successorOwner, 'utf-8');
|
|
1303
|
+
return 'moved';
|
|
1304
|
+
}
|
|
1305
|
+
if (from === parkedPath && to === context.lockPath) {
|
|
1306
|
+
rollbackAttempted = true;
|
|
1307
|
+
return 'not-moved';
|
|
1308
|
+
}
|
|
1309
|
+
if (existsSync(to))
|
|
1310
|
+
return 'not-moved';
|
|
1311
|
+
await rename(from, to);
|
|
1312
|
+
return 'moved';
|
|
1313
|
+
},
|
|
1314
|
+
}, async () => {
|
|
1315
|
+
const refused = await recoverSessionPointerLock(cwd);
|
|
1316
|
+
assert.equal(refused.recovered, false);
|
|
1317
|
+
assert.equal(refused.action, 'none');
|
|
1318
|
+
const inspected = await inspectSessionPointerLock(cwd);
|
|
1319
|
+
assert.equal(inspected.status, 'live');
|
|
1320
|
+
assert.equal(inspected.safeToRecover, false);
|
|
1321
|
+
assert.equal(await readFile(ownerPath, 'utf-8'), successorOwner);
|
|
1322
|
+
assert.deepEqual(await __releasePointerLockForTests(cwd, SUCCESSOR_TOKEN), []);
|
|
1323
|
+
assert.equal(existsSync(context.lockPath), false);
|
|
1324
|
+
const started = await writeSessionStart(cwd, 'sess-after-survival', { platform: 'win32' });
|
|
1325
|
+
assert.equal(started.session_id, 'sess-after-survival');
|
|
1326
|
+
assert.equal(existsSync(context.lockPath), false);
|
|
1327
|
+
});
|
|
1328
|
+
assert.equal(rollbackAttempted, true);
|
|
1329
|
+
assert.equal(existsSync(parkedPath), true);
|
|
1330
|
+
}
|
|
1331
|
+
finally {
|
|
1332
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1333
|
+
}
|
|
1334
|
+
});
|
|
1335
|
+
it('fails closed without mutating a foreign target when the lock directory is replaced by a symlink before the claim rename', async () => {
|
|
1336
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-lock-symlink-'));
|
|
1337
|
+
const foreign = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-foreign-'));
|
|
1338
|
+
try {
|
|
1339
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1340
|
+
const ownerPath = join(context.lockPath, 'owner.json');
|
|
1341
|
+
const foreignOwner = join(foreign, 'owner.json');
|
|
1342
|
+
const marker = JSON.stringify(validLockOwner({ token: SUCCESSOR_TOKEN }));
|
|
1343
|
+
let replaced = false;
|
|
1344
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1345
|
+
await withPointerDependencies({ token: () => SUCCESSOR_TOKEN, probePid: () => 'dead', fs: { link: async (from, to) => {
|
|
1346
|
+
if (!replaced && from === ownerPath) {
|
|
1347
|
+
replaced = true;
|
|
1348
|
+
await rm(context.lockPath, { recursive: true });
|
|
1349
|
+
await writeFile(foreignOwner, marker);
|
|
1350
|
+
await symlink(foreign, context.lockPath);
|
|
1351
|
+
}
|
|
1352
|
+
await link(from, to);
|
|
1353
|
+
} } }, async () => assert.equal((await recoverSessionPointerLock(cwd)).recovered, false));
|
|
1354
|
+
assert.equal(await readFile(foreignOwner, 'utf-8'), marker);
|
|
1355
|
+
assert.equal((await readdir(foreign)).some((entry) => entry.includes('.quarantine.')), false);
|
|
1356
|
+
}
|
|
1357
|
+
finally {
|
|
1358
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1359
|
+
await rm(foreign, { recursive: true, force: true });
|
|
1360
|
+
}
|
|
1361
|
+
});
|
|
1362
|
+
it('does not follow an evidence symlink swapped in before the claim rename', async () => {
|
|
1363
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-evidence-symlink-'));
|
|
1364
|
+
try {
|
|
1365
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1366
|
+
const ownerPath = join(context.lockPath, 'owner.json');
|
|
1367
|
+
const foreign = join(cwd, 'foreign-owner');
|
|
1368
|
+
const marker = 'foreign evidence marker';
|
|
1369
|
+
let replaced = false;
|
|
1370
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1371
|
+
await writeFile(foreign, marker);
|
|
1372
|
+
await withPointerDependencies({ token: () => SUCCESSOR_TOKEN, probePid: () => 'dead', fs: { link: async (from, to) => {
|
|
1373
|
+
if (!replaced && from === ownerPath) {
|
|
1374
|
+
replaced = true;
|
|
1375
|
+
await rm(ownerPath);
|
|
1376
|
+
await symlink(foreign, ownerPath);
|
|
1377
|
+
}
|
|
1378
|
+
await link(from, to);
|
|
1379
|
+
} } }, async () => assert.equal((await recoverSessionPointerLock(cwd)).recovered, false));
|
|
1380
|
+
assert.equal(await readFile(foreign, 'utf-8'), marker);
|
|
1381
|
+
assert.equal(await readlink(ownerPath), foreign);
|
|
1382
|
+
}
|
|
1383
|
+
finally {
|
|
1384
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1385
|
+
}
|
|
1386
|
+
});
|
|
1387
|
+
it('preserves a non-file evidence replacement as exact recovery residue without clobbering', async () => {
|
|
1388
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-evidence-directory-'));
|
|
1389
|
+
try {
|
|
1390
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1391
|
+
const ownerPath = join(context.lockPath, 'owner.json');
|
|
1392
|
+
const marker = join(ownerPath, 'marker');
|
|
1393
|
+
let replaced = false;
|
|
1394
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1395
|
+
await withPointerDependencies({ token: () => SUCCESSOR_TOKEN, probePid: () => 'dead', fs: { link: async (from, to) => {
|
|
1396
|
+
if (!replaced && from === ownerPath) {
|
|
1397
|
+
replaced = true;
|
|
1398
|
+
await rm(ownerPath);
|
|
1399
|
+
await mkdir(ownerPath);
|
|
1400
|
+
await writeFile(marker, 'directory marker');
|
|
1401
|
+
}
|
|
1402
|
+
await link(from, to);
|
|
1403
|
+
} } }, async () => assert.equal((await recoverSessionPointerLock(cwd)).recovered, false));
|
|
1404
|
+
assert.equal(await readFile(marker, 'utf-8'), 'directory marker');
|
|
1405
|
+
}
|
|
1406
|
+
finally {
|
|
1407
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1408
|
+
}
|
|
1409
|
+
});
|
|
1410
|
+
it('retries a transient token-bound park collision without allocating a second parked name', async () => {
|
|
1411
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-park-retry-'));
|
|
1412
|
+
try {
|
|
1413
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1414
|
+
const parkedPath = `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`;
|
|
1415
|
+
const quarantinePath = `${context.lockPath}.quarantine.${TEST_TOKEN}.${SUCCESSOR_TOKEN}`;
|
|
1416
|
+
const checkpointPath = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
|
|
1417
|
+
const foreignMarker = 'foreign transient park destination';
|
|
1418
|
+
const parkAttempts = [];
|
|
1419
|
+
let tokenCalls = 0;
|
|
1420
|
+
let collision = true;
|
|
1421
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1422
|
+
await withPointerDependencies({
|
|
1423
|
+
token: () => {
|
|
1424
|
+
tokenCalls += 1;
|
|
1425
|
+
return SUCCESSOR_TOKEN;
|
|
1426
|
+
},
|
|
1427
|
+
probePid: () => 'dead',
|
|
1428
|
+
atomicRenameNoReplace: async (from, to) => {
|
|
1429
|
+
if (from === context.lockPath && to === parkedPath) {
|
|
1430
|
+
parkAttempts.push(to);
|
|
1431
|
+
if (collision) {
|
|
1432
|
+
collision = false;
|
|
1433
|
+
await mkdir(to);
|
|
1434
|
+
await writeFile(join(to, 'foreign'), foreignMarker, 'utf-8');
|
|
1435
|
+
return 'not-moved';
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
if (existsSync(to))
|
|
1439
|
+
return 'not-moved';
|
|
1440
|
+
await rename(from, to);
|
|
1441
|
+
return 'moved';
|
|
1442
|
+
},
|
|
1443
|
+
}, async () => {
|
|
1444
|
+
const refused = await recoverSessionPointerLock(cwd);
|
|
1445
|
+
assert.equal(refused.recovered, false);
|
|
1446
|
+
assert.equal(refused.action, 'none');
|
|
1447
|
+
assert.equal(existsSync(checkpointPath), true);
|
|
1448
|
+
assert.equal(existsSync(context.lockPath), true);
|
|
1449
|
+
assert.equal(await readFile(join(parkedPath, 'foreign'), 'utf-8'), foreignMarker);
|
|
1450
|
+
await rm(parkedPath, { recursive: true, force: true });
|
|
1451
|
+
const recovered = await recoverSessionPointerLock(cwd);
|
|
1452
|
+
assert.equal(recovered.recovered, true, recovered.reason);
|
|
1453
|
+
assert.equal(recovered.action, 'quarantined');
|
|
1454
|
+
});
|
|
1455
|
+
assert.equal(tokenCalls, 1);
|
|
1456
|
+
assert.deepEqual(parkAttempts, [parkedPath, parkedPath]);
|
|
1457
|
+
assert.equal(existsSync(context.lockPath), false);
|
|
1458
|
+
assert.equal(existsSync(parkedPath), true);
|
|
1459
|
+
assert.equal(existsSync(quarantinePath), true);
|
|
1460
|
+
assert.equal(existsSync(checkpointPath), false);
|
|
1461
|
+
assert.equal(existsSync(checkpointPath.replace(/\.json$/, '.completed')), true);
|
|
1462
|
+
}
|
|
1463
|
+
finally {
|
|
1464
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1465
|
+
}
|
|
1466
|
+
});
|
|
1467
|
+
it('rolls back a claim when checkpoint creation fails', async () => {
|
|
1468
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-checkpoint-write-fail-'));
|
|
1469
|
+
try {
|
|
1470
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1471
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1472
|
+
let fail = true;
|
|
1473
|
+
await withPointerDependencies({ token: () => SUCCESSOR_TOKEN, probePid: () => 'dead', fs: { writeFile: async (path, data, options) => { if (fail && path.includes('.recovery.')) {
|
|
1474
|
+
fail = false;
|
|
1475
|
+
throw codedError('EBUSY');
|
|
1476
|
+
} await writeFile(path, data, options); } } }, async () => {
|
|
1477
|
+
assert.equal((await recoverSessionPointerLock(cwd)).recovered, false);
|
|
1478
|
+
assert.deepEqual(await readdir(context.lockPath), ['owner.json']);
|
|
1479
|
+
assert.equal((await recoverSessionPointerLock(cwd)).recovered, true);
|
|
1480
|
+
});
|
|
1481
|
+
}
|
|
1482
|
+
finally {
|
|
1483
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1484
|
+
}
|
|
1485
|
+
});
|
|
1486
|
+
it('rolls back a double-EBUSY evidence park failure so a normal retry succeeds', async () => {
|
|
1487
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-checkpoint-'));
|
|
1488
|
+
try {
|
|
1489
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1490
|
+
const ownerPath = join(context.lockPath, 'owner.json');
|
|
1491
|
+
const quarantinePath = `${context.lockPath}.quarantine.${TEST_TOKEN}.${SUCCESSOR_TOKEN}`;
|
|
1492
|
+
const checkpointPath = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
|
|
1493
|
+
const staleOwner = JSON.stringify(validLockOwner());
|
|
1494
|
+
let failures = 2;
|
|
1495
|
+
let tokenCalls = 0;
|
|
1496
|
+
let linkAttempts = 0;
|
|
1497
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1498
|
+
await withPointerDependencies({
|
|
1499
|
+
token: () => {
|
|
1500
|
+
tokenCalls += 1;
|
|
1501
|
+
return SUCCESSOR_TOKEN;
|
|
1502
|
+
},
|
|
1503
|
+
probePid: () => 'dead',
|
|
1504
|
+
fs: {
|
|
1505
|
+
link: async (from, to) => {
|
|
1506
|
+
if (to === quarantinePath && failures > 0) {
|
|
1507
|
+
failures -= 1;
|
|
1508
|
+
linkAttempts += 1;
|
|
1509
|
+
throw codedError('EBUSY');
|
|
1510
|
+
}
|
|
1511
|
+
await link(from, to);
|
|
1512
|
+
},
|
|
1513
|
+
},
|
|
1514
|
+
}, async () => {
|
|
1515
|
+
const first = await recoverSessionPointerLock(cwd);
|
|
1516
|
+
assert.equal(first.recovered, false);
|
|
1517
|
+
assert.equal(first.action, 'none');
|
|
1518
|
+
assert.equal(existsSync(checkpointPath), true);
|
|
1519
|
+
assert.equal(await readFile(ownerPath, 'utf-8'), staleOwner);
|
|
1520
|
+
assert.equal(existsSync(quarantinePath), false);
|
|
1521
|
+
const second = await recoverSessionPointerLock(cwd);
|
|
1522
|
+
assert.equal(second.recovered, false);
|
|
1523
|
+
assert.equal(second.action, 'none');
|
|
1524
|
+
assert.equal(existsSync(checkpointPath), true);
|
|
1525
|
+
assert.equal(await readFile(ownerPath, 'utf-8'), staleOwner);
|
|
1526
|
+
assert.equal(existsSync(quarantinePath), false);
|
|
1527
|
+
const recovered = await recoverSessionPointerLock(cwd);
|
|
1528
|
+
assert.equal(recovered.recovered, true, recovered.reason);
|
|
1529
|
+
assert.equal(recovered.action, 'quarantined');
|
|
1530
|
+
});
|
|
1531
|
+
assert.equal(tokenCalls, 1);
|
|
1532
|
+
assert.equal(linkAttempts, 2);
|
|
1533
|
+
assert.equal(existsSync(context.lockPath), false);
|
|
1534
|
+
assert.equal(await readFile(quarantinePath, 'utf-8'), staleOwner);
|
|
1535
|
+
assert.equal(existsSync(checkpointPath), false);
|
|
1536
|
+
assert.equal(existsSync(checkpointPath.replace(/\.json$/, '.completed')), true);
|
|
1537
|
+
}
|
|
1538
|
+
finally {
|
|
1539
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1540
|
+
}
|
|
1541
|
+
});
|
|
1542
|
+
it('resumes an evidence-pending checkpoint without minting another recovery token', async () => {
|
|
1543
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-resume-'));
|
|
1544
|
+
try {
|
|
1545
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1546
|
+
const ownerPath = join(context.lockPath, 'owner.json');
|
|
1547
|
+
const claimPath = join(context.lockPath, `owner.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.recovery`);
|
|
1548
|
+
const parkPath = `${context.lockPath}.parked-${SUCCESSOR_TOKEN}`;
|
|
1549
|
+
const owner = JSON.stringify(validLockOwner());
|
|
1550
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1551
|
+
await link(ownerPath, claimPath);
|
|
1552
|
+
await rename(ownerPath, parkPath);
|
|
1553
|
+
const parked = await lstat(parkPath);
|
|
1554
|
+
const lock = await lstat(context.lockPath);
|
|
1555
|
+
await writeFile(`${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`, JSON.stringify({ version: 1, sourcePath: ownerPath, parkPath, lockParkPath: `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`, identity: { dev: parked.dev, ino: parked.ino }, lockIdentity: { dev: lock.dev, ino: lock.ino }, phase: 'evidence-pending' }));
|
|
1556
|
+
await withPointerDependencies({ token: () => { throw new Error('resume must reuse checkpoint token'); }, probePid: () => 'dead' }, async () => {
|
|
1557
|
+
const resumed = await recoverSessionPointerLock(cwd);
|
|
1558
|
+
assert.equal(resumed.recovered, true);
|
|
1559
|
+
});
|
|
1560
|
+
assert.equal(await readFile(`${context.lockPath}.quarantine.${TEST_TOKEN}.${SUCCESSOR_TOKEN}`, 'utf-8'), owner);
|
|
1561
|
+
assert.equal(existsSync(`${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`), false);
|
|
1562
|
+
}
|
|
1563
|
+
finally {
|
|
1564
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1565
|
+
}
|
|
1566
|
+
});
|
|
1567
|
+
it('resumes the actual distinct-token evidence park path after an interruption', async () => {
|
|
1568
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-distinct-park-token-'));
|
|
1569
|
+
try {
|
|
1570
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1571
|
+
const ownerPath = join(context.lockPath, 'owner.json');
|
|
1572
|
+
const claimToken = 'claim_token_123456789';
|
|
1573
|
+
const parkToken = 'park_token_123456789';
|
|
1574
|
+
const claimPath = join(context.lockPath, `owner.${TEST_TOKEN}.${claimToken}.recovery`);
|
|
1575
|
+
const parkPath = `${context.lockPath}.parked-${parkToken}`;
|
|
1576
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1577
|
+
await link(ownerPath, claimPath);
|
|
1578
|
+
await rename(ownerPath, parkPath);
|
|
1579
|
+
const parked = await lstat(parkPath);
|
|
1580
|
+
const lock = await lstat(context.lockPath);
|
|
1581
|
+
await writeFile(`${context.lockPath}.recovery.${TEST_TOKEN}.${claimToken}.json`, JSON.stringify({ version: 1, sourcePath: ownerPath, parkPath, lockParkPath: `${context.lockPath}.parked-lock-${claimToken}`, identity: { dev: parked.dev, ino: parked.ino }, lockIdentity: { dev: lock.dev, ino: lock.ino }, phase: 'evidence-pending' }));
|
|
1582
|
+
await withPointerDependencies({ token: () => { throw new Error('resume must not mint a replacement token'); }, probePid: () => 'dead' }, async () => assert.equal((await recoverSessionPointerLock(cwd)).recovered, true));
|
|
1583
|
+
assert.equal(await readFile(`${context.lockPath}.quarantine.${TEST_TOKEN}.${claimToken}`, 'utf-8'), JSON.stringify(validLockOwner()));
|
|
1584
|
+
}
|
|
1585
|
+
finally {
|
|
1586
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1587
|
+
}
|
|
1588
|
+
});
|
|
1589
|
+
it('resumes a checkpoint written before evidence parking', async () => {
|
|
1590
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-source-checkpoint-'));
|
|
1591
|
+
try {
|
|
1592
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1593
|
+
const ownerPath = join(context.lockPath, 'owner.json');
|
|
1594
|
+
const claimPath = join(context.lockPath, `owner.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.recovery`);
|
|
1595
|
+
const parkPath = `${context.lockPath}.parked-${SUCCESSOR_TOKEN}`;
|
|
1596
|
+
const checkpointPath = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
|
|
1597
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1598
|
+
await link(ownerPath, claimPath);
|
|
1599
|
+
const owner = await lstat(ownerPath);
|
|
1600
|
+
const lock = await lstat(context.lockPath);
|
|
1601
|
+
await writeFile(checkpointPath, JSON.stringify({ version: 1, sourcePath: ownerPath, parkPath, lockParkPath: `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`, identity: { dev: owner.dev, ino: owner.ino }, lockIdentity: { dev: lock.dev, ino: lock.ino }, phase: 'evidence-pending' }));
|
|
1602
|
+
await withPointerDependencies({ token: () => { throw new Error('resume must not mint token'); }, probePid: () => 'dead' }, async () => assert.equal((await recoverSessionPointerLock(cwd)).recovered, true));
|
|
1603
|
+
assert.equal(existsSync(context.lockPath), false);
|
|
1604
|
+
assert.equal(existsSync(checkpointPath), false);
|
|
1605
|
+
}
|
|
1606
|
+
finally {
|
|
1607
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1608
|
+
}
|
|
1609
|
+
});
|
|
1610
|
+
it('resumes after quarantine hard-link creation but before claim and park cleanup', async () => {
|
|
1611
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-dual-evidence-checkpoint-'));
|
|
1612
|
+
try {
|
|
1613
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1614
|
+
const ownerPath = join(context.lockPath, 'owner.json');
|
|
1615
|
+
const claimPath = join(context.lockPath, `owner.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.recovery`);
|
|
1616
|
+
const parkPath = `${context.lockPath}.parked-${SUCCESSOR_TOKEN}`;
|
|
1617
|
+
const quarantinePath = `${context.lockPath}.quarantine.${TEST_TOKEN}.${SUCCESSOR_TOKEN}`;
|
|
1618
|
+
const checkpointPath = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
|
|
1619
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1620
|
+
await link(ownerPath, claimPath);
|
|
1621
|
+
await rename(ownerPath, parkPath);
|
|
1622
|
+
await link(parkPath, quarantinePath);
|
|
1623
|
+
const parked = await lstat(parkPath);
|
|
1624
|
+
const lock = await lstat(context.lockPath);
|
|
1625
|
+
await writeFile(checkpointPath, JSON.stringify({ version: 1, sourcePath: ownerPath, parkPath, lockParkPath: `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`, identity: { dev: parked.dev, ino: parked.ino }, lockIdentity: { dev: lock.dev, ino: lock.ino }, phase: 'evidence-pending' }));
|
|
1626
|
+
await withPointerDependencies({ token: () => { throw new Error('resume must not mint token'); }, probePid: () => 'dead' }, async () => assert.equal((await recoverSessionPointerLock(cwd)).recovered, true));
|
|
1627
|
+
assert.equal(await readFile(quarantinePath, 'utf-8'), JSON.stringify(validLockOwner()));
|
|
1628
|
+
assert.equal(existsSync(checkpointPath), false);
|
|
1629
|
+
}
|
|
1630
|
+
finally {
|
|
1631
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1632
|
+
}
|
|
1633
|
+
});
|
|
1634
|
+
it('reports absent recovery on a fresh workspace without a state directory', async () => {
|
|
1635
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-fresh-'));
|
|
1636
|
+
try {
|
|
1637
|
+
const recovered = await recoverSessionPointerLock(cwd);
|
|
1638
|
+
assert.equal(recovered.recovered, false);
|
|
1639
|
+
assert.equal(recovered.status, 'absent');
|
|
1640
|
+
assert.match(recovered.reason, /No session pointer lock exists/);
|
|
1641
|
+
}
|
|
1642
|
+
finally {
|
|
1643
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1644
|
+
}
|
|
1645
|
+
});
|
|
1646
|
+
it('fails closed when recovery checkpoint discovery cannot be enumerated', async () => {
|
|
1647
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-checkpoint-readdir-error-'));
|
|
1648
|
+
try {
|
|
1649
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1650
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1651
|
+
await withPointerDependencies({ probePid: () => 'dead', fs: { readdir: async () => { throw codedError('EACCES'); } } }, async () => {
|
|
1652
|
+
const recovered = await recoverSessionPointerLock(cwd);
|
|
1653
|
+
assert.equal(recovered.recovered, false);
|
|
1654
|
+
assert.equal(recovered.status, 'io-error');
|
|
1655
|
+
assert.match(recovered.reason, /enumerate.*checkpoints/i);
|
|
1656
|
+
});
|
|
1657
|
+
assert.deepEqual(await readdir(context.lockPath), ['owner.json']);
|
|
1658
|
+
}
|
|
1659
|
+
finally {
|
|
1660
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1661
|
+
}
|
|
1662
|
+
});
|
|
1663
|
+
it('fails closed for malformed checkpoint JSON without minting a recovery token', async () => {
|
|
1664
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-malformed-checkpoint-'));
|
|
1665
|
+
try {
|
|
1666
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1667
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1668
|
+
const checkpointPath = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
|
|
1669
|
+
await writeFile(checkpointPath, '{not json', 'utf-8');
|
|
1670
|
+
await withPointerDependencies({ token: () => { throw new Error('must not mint token'); }, probePid: () => 'dead' }, async () => {
|
|
1671
|
+
const recovered = await recoverSessionPointerLock(cwd);
|
|
1672
|
+
assert.equal(recovered.recovered, false);
|
|
1673
|
+
assert.equal(recovered.safeToRecover, false);
|
|
1674
|
+
});
|
|
1675
|
+
assert.equal(await readFile(checkpointPath, 'utf-8'), '{not json');
|
|
1676
|
+
assert.equal(await readFile(join(context.lockPath, 'owner.json'), 'utf-8'), JSON.stringify(validLockOwner()));
|
|
1677
|
+
}
|
|
1678
|
+
finally {
|
|
1679
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1680
|
+
}
|
|
1681
|
+
});
|
|
1682
|
+
for (const [label, checkpoint] of [
|
|
1683
|
+
['wrong version', { version: 2, phase: 'evidence-pending', sourcePath: 'x', parkPath: 'x', identity: { dev: 0, ino: 0 } }],
|
|
1684
|
+
['wrong phase', { version: 1, phase: 'claim-pending', sourcePath: 'x', parkPath: 'x', identity: { dev: 0, ino: 0 } }],
|
|
1685
|
+
['out-of-root path', { version: 1, phase: 'evidence-pending', sourcePath: '/foreign/owner.json', parkPath: '/foreign/parked', identity: { dev: 0, ino: 0 } }],
|
|
1686
|
+
])
|
|
1687
|
+
it(`fails closed for ${label} checkpoint`, async () => {
|
|
1688
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-invalid-checkpoint-'));
|
|
1689
|
+
try {
|
|
1690
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1691
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1692
|
+
const path = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
|
|
1693
|
+
await writeFile(path, JSON.stringify(checkpoint), 'utf-8');
|
|
1694
|
+
await withPointerDependencies({ token: () => { throw new Error('must not mint token'); }, probePid: () => 'dead' }, async () => assert.equal((await recoverSessionPointerLock(cwd)).recovered, false));
|
|
1695
|
+
assert.equal(await readFile(path, 'utf-8'), JSON.stringify(checkpoint));
|
|
1696
|
+
assert.equal(await readFile(join(context.lockPath, 'owner.json'), 'utf-8'), JSON.stringify(validLockOwner()));
|
|
1697
|
+
}
|
|
1698
|
+
finally {
|
|
1699
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1700
|
+
}
|
|
1701
|
+
});
|
|
1702
|
+
it('refuses multiple checkpoints without mutating either', async () => {
|
|
1703
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-multiple-checkpoints-'));
|
|
1704
|
+
try {
|
|
1705
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1706
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1707
|
+
for (const token of [SUCCESSOR_TOKEN, FOREIGN_TOKEN])
|
|
1708
|
+
await writeFile(`${context.lockPath}.recovery.${TEST_TOKEN}.${token}.json`, '{}', 'utf-8');
|
|
1709
|
+
await withPointerDependencies({ token: () => { throw new Error('must not mint token'); }, probePid: () => 'dead' }, async () => assert.match((await recoverSessionPointerLock(cwd)).reason, /Multiple recovery checkpoints/));
|
|
1710
|
+
assert.equal(existsSync(join(context.lockPath, 'owner.json')), true);
|
|
1711
|
+
}
|
|
1712
|
+
finally {
|
|
1713
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1714
|
+
}
|
|
1715
|
+
});
|
|
1716
|
+
it('preserves a checkpoint when its claim is missing', async () => {
|
|
1717
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-missing-claim-'));
|
|
1718
|
+
try {
|
|
1719
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1720
|
+
await mkdir(context.lockPath, { recursive: true });
|
|
1721
|
+
const parkPath = `${context.lockPath}.parked-${SUCCESSOR_TOKEN}`;
|
|
1722
|
+
await writeFile(parkPath, 'stale', 'utf-8');
|
|
1723
|
+
const stat = await lstat(parkPath);
|
|
1724
|
+
const checkpointPath = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
|
|
1725
|
+
const lock = await lstat(context.lockPath);
|
|
1726
|
+
await writeFile(checkpointPath, JSON.stringify({ version: 1, sourcePath: join(context.lockPath, 'owner.json'), parkPath, lockParkPath: `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`, identity: { dev: stat.dev, ino: stat.ino }, lockIdentity: { dev: lock.dev, ino: lock.ino }, phase: 'evidence-pending' }));
|
|
1727
|
+
await withPointerDependencies({ token: () => { throw new Error('must not mint token'); }, probePid: () => 'dead' }, async () => assert.equal((await recoverSessionPointerLock(cwd)).recovered, false));
|
|
1728
|
+
assert.equal(await readFile(checkpointPath, 'utf-8').then(Boolean), true);
|
|
1729
|
+
assert.equal(await readFile(parkPath, 'utf-8'), 'stale');
|
|
1730
|
+
}
|
|
1731
|
+
finally {
|
|
1732
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1733
|
+
}
|
|
1734
|
+
});
|
|
1735
|
+
it('refuses an advanced checkpoint when a live successor replaces the bound lock directory', async () => {
|
|
1736
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-checkpoint-successor-swap-'));
|
|
1737
|
+
try {
|
|
1738
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1739
|
+
const ownerPath = join(context.lockPath, 'owner.json');
|
|
1740
|
+
const claimPath = join(context.lockPath, `owner.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.recovery`);
|
|
1741
|
+
const parkPath = `${context.lockPath}.parked-${SUCCESSOR_TOKEN}`;
|
|
1742
|
+
const checkpointPath = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
|
|
1743
|
+
const quarantinePath = `${context.lockPath}.quarantine.${TEST_TOKEN}.${SUCCESSOR_TOKEN}`;
|
|
1744
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1745
|
+
await link(ownerPath, claimPath);
|
|
1746
|
+
await rename(ownerPath, parkPath);
|
|
1747
|
+
const parked = await lstat(parkPath);
|
|
1748
|
+
const lock = await lstat(context.lockPath);
|
|
1749
|
+
await writeFile(checkpointPath, JSON.stringify({ version: 1, sourcePath: ownerPath, parkPath, lockParkPath: `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`, identity: { dev: parked.dev, ino: parked.ino }, lockIdentity: { dev: lock.dev, ino: lock.ino }, phase: 'evidence-pending' }));
|
|
1750
|
+
await rename(parkPath, quarantinePath);
|
|
1751
|
+
await rm(claimPath);
|
|
1752
|
+
await rename(context.lockPath, `${context.lockPath}.displaced`);
|
|
1753
|
+
const successorPid = process.pid + 100_000;
|
|
1754
|
+
const successor = JSON.stringify(validLockOwner({ token: FOREIGN_TOKEN, pid: successorPid }));
|
|
1755
|
+
await mkdir(context.lockPath);
|
|
1756
|
+
await writeFile(ownerPath, successor, 'utf-8');
|
|
1757
|
+
await withPointerDependencies({ token: () => { throw new Error('resume must not mint'); }, probePid: (pid) => pid === successorPid ? 'alive' : 'dead', observeProcess: () => matchingObservation() }, async () => {
|
|
1758
|
+
const refused = await recoverSessionPointerLock(cwd);
|
|
1759
|
+
assert.equal(refused.recovered, false);
|
|
1760
|
+
assert.match(refused.reason, /recovery state/i);
|
|
1761
|
+
assert.deepEqual(await readdir(context.lockPath), ['owner.json']);
|
|
1762
|
+
assert.equal(await readFile(ownerPath, 'utf-8'), successor);
|
|
1763
|
+
assert.deepEqual(await __releasePointerLockForTests(cwd, FOREIGN_TOKEN), []);
|
|
1764
|
+
assert.equal(existsSync(context.lockPath), false);
|
|
1765
|
+
});
|
|
1766
|
+
assert.equal(await readFile(quarantinePath, 'utf-8'), JSON.stringify(validLockOwner()));
|
|
1767
|
+
assert.equal(existsSync(checkpointPath), true);
|
|
1768
|
+
}
|
|
1769
|
+
finally {
|
|
1770
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1771
|
+
}
|
|
1772
|
+
});
|
|
1773
|
+
it('does not remove a live successor swapped in before final checkpoint lock removal', async () => {
|
|
1774
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-final-successor-'));
|
|
1775
|
+
try {
|
|
1776
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1777
|
+
const ownerPath = join(context.lockPath, 'owner.json');
|
|
1778
|
+
const checkpointPath = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
|
|
1779
|
+
const lockPark = `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`;
|
|
1780
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1781
|
+
const owner = await lstat(ownerPath);
|
|
1782
|
+
const lock = await lstat(context.lockPath);
|
|
1783
|
+
await writeFile(checkpointPath, JSON.stringify({ version: 3, sourcePath: ownerPath, identity: { dev: owner.dev, ino: owner.ino }, evidenceIdentity: { dev: owner.dev, ino: owner.ino }, evidenceBytes: JSON.stringify(validLockOwner()), lockIdentity: { dev: lock.dev, ino: lock.ino }, lockParkPath: lockPark, phase: 'evidence-pending' }));
|
|
1784
|
+
const successor = JSON.stringify(validLockOwner({ token: FOREIGN_TOKEN, pid: process.pid + 100_000 }));
|
|
1785
|
+
let moved = false;
|
|
1786
|
+
await withPointerDependencies({ token: () => { throw new Error('resume must not mint'); }, probePid: () => 'dead', atomicRenameNoReplace: async (from, to) => {
|
|
1787
|
+
if (from === context.lockPath && to === lockPark) {
|
|
1788
|
+
await rename(from, to);
|
|
1789
|
+
await mkdir(context.lockPath);
|
|
1790
|
+
await writeFile(ownerPath, successor);
|
|
1791
|
+
moved = true;
|
|
1792
|
+
return 'moved';
|
|
1793
|
+
}
|
|
1794
|
+
return 'not-moved';
|
|
1795
|
+
} }, async () => assert.equal((await recoverSessionPointerLock(cwd)).recovered, false));
|
|
1796
|
+
assert.equal(moved, true);
|
|
1797
|
+
assert.equal(await readFile(ownerPath, 'utf8'), successor);
|
|
1798
|
+
assert.deepEqual(await __releasePointerLockForTests(cwd, FOREIGN_TOKEN), []);
|
|
1799
|
+
assert.equal(existsSync(context.lockPath), false);
|
|
1800
|
+
assert.equal(existsSync(lockPark), true);
|
|
1801
|
+
}
|
|
1802
|
+
finally {
|
|
1803
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1804
|
+
}
|
|
1805
|
+
});
|
|
1806
|
+
it('refuses a quarantine path that appears after its preflight check without overwriting it', async () => {
|
|
1807
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-quarantine-race-'));
|
|
1808
|
+
try {
|
|
1809
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1810
|
+
const ownerPath = join(context.lockPath, 'owner.json');
|
|
1811
|
+
const quarantine = `${context.lockPath}.quarantine.${TEST_TOKEN}.${SUCCESSOR_TOKEN}`;
|
|
1812
|
+
const foreign = 'foreign quarantine marker';
|
|
1813
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1814
|
+
await withPointerDependencies({ token: () => SUCCESSOR_TOKEN, probePid: () => 'dead', fs: { link: async (from, to) => { if (to === quarantine)
|
|
1815
|
+
await writeFile(to, foreign); await link(from, to); } } }, async () => {
|
|
1816
|
+
const result = await recoverSessionPointerLock(cwd);
|
|
1817
|
+
assert.equal(result.recovered, false);
|
|
1818
|
+
assert.match(result.reason, /EEXIST|quarantine/i);
|
|
1819
|
+
});
|
|
1820
|
+
assert.equal(await readFile(ownerPath, 'utf8'), JSON.stringify(validLockOwner()));
|
|
1821
|
+
assert.equal(await readFile(quarantine, 'utf8'), foreign);
|
|
1822
|
+
}
|
|
1823
|
+
finally {
|
|
1824
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1825
|
+
}
|
|
1826
|
+
});
|
|
1827
|
+
it('fails closed without mutation when a recovery claim destination appears before the no-clobber claim link', async () => {
|
|
1828
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-claim-exist-'));
|
|
1829
|
+
try {
|
|
1830
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1831
|
+
const ownerPath = join(context.lockPath, 'owner.json');
|
|
1832
|
+
const claim = join(context.lockPath, `owner.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.recovery`);
|
|
1833
|
+
let moved = false;
|
|
1834
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1835
|
+
await withPointerDependencies({
|
|
1836
|
+
token: () => SUCCESSOR_TOKEN,
|
|
1837
|
+
probePid: () => 'dead',
|
|
1838
|
+
fs: {
|
|
1839
|
+
writeFile: async (path, data, options) => {
|
|
1840
|
+
await writeFile(path, data, options);
|
|
1841
|
+
if (path.includes('.recovery.') && path.endsWith('.json'))
|
|
1842
|
+
await writeFile(claim, 'foreign claim');
|
|
1843
|
+
},
|
|
1844
|
+
},
|
|
1845
|
+
atomicRenameNoReplace: withProbeFallback(context.lockPath, async () => {
|
|
1846
|
+
moved = true;
|
|
1847
|
+
return 'moved';
|
|
1848
|
+
}),
|
|
1849
|
+
}, async () => {
|
|
1850
|
+
const recovered = await recoverSessionPointerLock(cwd);
|
|
1851
|
+
assert.equal(recovered.recovered, false, recovered.reason);
|
|
1852
|
+
assert.match(recovered.reason, /foreign|changed|claim/i);
|
|
1853
|
+
});
|
|
1854
|
+
assert.equal(moved, false);
|
|
1855
|
+
assert.equal(await readFile(ownerPath, 'utf8'), JSON.stringify(validLockOwner()));
|
|
1856
|
+
assert.equal(await readFile(claim, 'utf8'), 'foreign claim');
|
|
1857
|
+
}
|
|
1858
|
+
finally {
|
|
1859
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1860
|
+
}
|
|
1861
|
+
});
|
|
1862
|
+
it('preserves a swapped parked quarantine claim rather than unlinking it', async () => {
|
|
1863
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-parked-claim-swap-'));
|
|
1864
|
+
try {
|
|
1865
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1866
|
+
const ownerPath = join(context.lockPath, 'owner.json');
|
|
1867
|
+
const lockPark = `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`;
|
|
1868
|
+
const claim = join(context.lockPath, `owner.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.recovery`);
|
|
1869
|
+
const quarantine = `${context.lockPath}.quarantine.${TEST_TOKEN}.${SUCCESSOR_TOKEN}`;
|
|
1870
|
+
const checkpoint = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
|
|
1871
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1872
|
+
const owner = await lstat(ownerPath);
|
|
1873
|
+
const lock = await lstat(context.lockPath);
|
|
1874
|
+
await link(ownerPath, claim);
|
|
1875
|
+
await link(ownerPath, quarantine);
|
|
1876
|
+
await rm(claim);
|
|
1877
|
+
await writeFile(claim, 'foreign parked claim');
|
|
1878
|
+
await rename(context.lockPath, lockPark);
|
|
1879
|
+
await writeFile(checkpoint, JSON.stringify({ version: 2, sourcePath: ownerPath, identity: { dev: owner.dev, ino: owner.ino }, lockIdentity: { dev: lock.dev, ino: lock.ino }, lockParkPath: lockPark, phase: 'evidence-quarantined' }));
|
|
1880
|
+
await withPointerDependencies({ token: () => { throw new Error('resume must not mint'); }, probePid: () => 'dead', atomicRenameNoReplace: async (from, to) => { if (from === checkpoint) {
|
|
1881
|
+
await rename(from, to);
|
|
1882
|
+
return 'moved';
|
|
1883
|
+
} throw new Error('directory move must not occur'); } }, async () => assert.equal((await recoverSessionPointerLock(cwd)).recovered, true));
|
|
1884
|
+
assert.equal(await readFile(join(lockPark, `owner.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.recovery`), 'utf8'), 'foreign parked claim');
|
|
1885
|
+
assert.equal(await readFile(quarantine, 'utf8'), JSON.stringify(validLockOwner()));
|
|
1886
|
+
assert.equal(existsSync(lockPark), true);
|
|
1887
|
+
}
|
|
1888
|
+
finally {
|
|
1889
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1890
|
+
}
|
|
1891
|
+
});
|
|
1892
|
+
it('fails closed without mutation when evidence lstat fails non-ENOENT before the claim', async () => {
|
|
1893
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-evidence-eacces-'));
|
|
1894
|
+
try {
|
|
1895
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1896
|
+
const ownerPath = join(context.lockPath, 'owner.json');
|
|
1897
|
+
const quarantine = `${context.lockPath}.quarantine.${TEST_TOKEN}.${SUCCESSOR_TOKEN}`;
|
|
1898
|
+
let armed = false;
|
|
1899
|
+
let moved = false;
|
|
1900
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1901
|
+
await withPointerDependencies({
|
|
1902
|
+
token: () => SUCCESSOR_TOKEN,
|
|
1903
|
+
probePid: () => 'dead',
|
|
1904
|
+
fs: {
|
|
1905
|
+
link: async (from, to) => {
|
|
1906
|
+
await link(from, to);
|
|
1907
|
+
if (to === quarantine)
|
|
1908
|
+
armed = true;
|
|
1909
|
+
},
|
|
1910
|
+
lstat: async (path) => {
|
|
1911
|
+
if (armed && path === ownerPath)
|
|
1912
|
+
throw codedError('EACCES');
|
|
1913
|
+
return await lstat(path);
|
|
1914
|
+
},
|
|
1915
|
+
},
|
|
1916
|
+
atomicRenameNoReplace: withProbeFallback(context.lockPath, async () => {
|
|
1917
|
+
moved = true;
|
|
1918
|
+
return 'moved';
|
|
1919
|
+
}),
|
|
1920
|
+
}, async () => {
|
|
1921
|
+
const recovered = await recoverSessionPointerLock(cwd);
|
|
1922
|
+
assert.equal(recovered.recovered, false, recovered.reason);
|
|
1923
|
+
});
|
|
1924
|
+
assert.equal(moved, false);
|
|
1925
|
+
assert.equal(await readFile(ownerPath, 'utf8'), JSON.stringify(validLockOwner()));
|
|
1926
|
+
}
|
|
1927
|
+
finally {
|
|
1928
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1929
|
+
}
|
|
1930
|
+
});
|
|
1931
|
+
it('restores evidence and keeps the exact quarantine inspect diagnostic when quarantine lstat fails non-ENOENT', async () => {
|
|
1932
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-quarantine-eacces-'));
|
|
1933
|
+
try {
|
|
1934
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1935
|
+
const ownerPath = join(context.lockPath, 'owner.json');
|
|
1936
|
+
const quarantine = `${context.lockPath}.quarantine.${TEST_TOKEN}.${SUCCESSOR_TOKEN}`;
|
|
1937
|
+
const checkpoint = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
|
|
1938
|
+
let armed = false;
|
|
1939
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1940
|
+
await withPointerDependencies({ token: () => SUCCESSOR_TOKEN, probePid: () => 'dead', fs: { link: async (from, to) => { await link(from, to); if (to === quarantine)
|
|
1941
|
+
armed = true; }, lstat: async (path) => { if (armed && path === quarantine)
|
|
1942
|
+
throw codedError('EACCES'); return await lstat(path); } } }, async () => { const result = await recoverSessionPointerLock(cwd); assert.equal(result.recovered, false); assert.match(result.reason, /EACCES/); });
|
|
1943
|
+
assert.equal(await readFile(ownerPath, 'utf8'), JSON.stringify(validLockOwner()));
|
|
1944
|
+
assert.equal(await readFile(quarantine, 'utf8'), JSON.stringify(validLockOwner()));
|
|
1945
|
+
assert.equal(existsSync(checkpoint), true);
|
|
1946
|
+
}
|
|
1947
|
+
finally {
|
|
1948
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1949
|
+
}
|
|
1950
|
+
});
|
|
1951
|
+
it('preserves a swapped parked lock directory rather than removing it', async () => {
|
|
1952
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-directory-swap-'));
|
|
1953
|
+
try {
|
|
1954
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1955
|
+
const parked = `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`;
|
|
1956
|
+
const original = `${parked}.original`;
|
|
1957
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1958
|
+
await withPointerDependencies({
|
|
1959
|
+
token: () => SUCCESSOR_TOKEN,
|
|
1960
|
+
probePid: () => 'dead',
|
|
1961
|
+
atomicRenameNoReplace: withProbeFallback(context.lockPath, async (from, to) => {
|
|
1962
|
+
if (from === context.lockPath && to === parked) {
|
|
1963
|
+
await rename(from, original);
|
|
1964
|
+
await mkdir(from);
|
|
1965
|
+
await writeFile(join(from, 'foreign'), 'foreign');
|
|
1966
|
+
await rename(from, to);
|
|
1967
|
+
return 'moved';
|
|
1968
|
+
}
|
|
1969
|
+
return await defaultTestAtomicRenameNoReplace(from, to);
|
|
1970
|
+
}),
|
|
1971
|
+
}, async () => {
|
|
1972
|
+
assert.equal((await recoverSessionPointerLock(cwd)).recovered, false);
|
|
1973
|
+
});
|
|
1974
|
+
assert.equal(await readFile(join(parked, 'foreign'), 'utf8'), 'foreign');
|
|
1975
|
+
assert.equal(existsSync(join(original, 'owner.json')), true);
|
|
1976
|
+
}
|
|
1977
|
+
finally {
|
|
1978
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1979
|
+
}
|
|
1980
|
+
});
|
|
1981
|
+
for (const [label, checkpoint] of [
|
|
1982
|
+
['wrong version', { version: 99, phase: 'evidence-pending', sourcePath: 'x', identity: { dev: 0, ino: 0 } }],
|
|
1983
|
+
['wrong phase', { version: 1, phase: 'claim-pending', sourcePath: 'x', identity: { dev: 0, ino: 0 } }],
|
|
1984
|
+
['out-of-root path', { version: 1, phase: 'evidence-pending', sourcePath: '/foreign/owner.json', identity: { dev: 0, ino: 0 } }],
|
|
1985
|
+
])
|
|
1986
|
+
it(`fails closed for ${label} checkpoint`, async () => {
|
|
1987
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-invalid-checkpoint-'));
|
|
1988
|
+
try {
|
|
1989
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1990
|
+
const path = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
|
|
1991
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1992
|
+
await writeFile(path, JSON.stringify(checkpoint));
|
|
1993
|
+
await withPointerDependencies({ token: () => { throw new Error('must not mint'); }, probePid: () => 'dead' }, async () => assert.equal((await recoverSessionPointerLock(cwd)).recovered, false));
|
|
1994
|
+
assert.equal(await readFile(path, 'utf8'), JSON.stringify(checkpoint));
|
|
1995
|
+
assert.equal(await readFile(join(context.lockPath, 'owner.json'), 'utf8'), JSON.stringify(validLockOwner()));
|
|
1996
|
+
}
|
|
1997
|
+
finally {
|
|
1998
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1999
|
+
}
|
|
2000
|
+
});
|
|
2001
|
+
for (const fault of ['EBUSY', 'ENOTEMPTY'])
|
|
2002
|
+
it(`completes a checkpoint resume retry after one-shot atomic directory move failure ${fault}`, async () => {
|
|
2003
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-atomic-retry-'));
|
|
2004
|
+
try {
|
|
2005
|
+
const context = resolveSessionPointerContext(cwd);
|
|
2006
|
+
const checkpoint = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
|
|
2007
|
+
let fail = true;
|
|
2008
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
2009
|
+
await withPointerDependencies({ token: () => SUCCESSOR_TOKEN, probePid: () => 'dead', atomicRenameNoReplace: async (from, to) => { if (from === context.lockPath && fail) {
|
|
2010
|
+
fail = false;
|
|
2011
|
+
throw codedError(fault);
|
|
2012
|
+
} if (existsSync(to))
|
|
2013
|
+
return 'not-moved'; await rename(from, to); return 'moved'; } }, async () => { assert.equal((await recoverSessionPointerLock(cwd)).recovered, false); assert.equal(existsSync(checkpoint), true); assert.equal(existsSync(context.lockPath), true); assert.equal((await recoverSessionPointerLock(cwd)).recovered, true); });
|
|
2014
|
+
}
|
|
2015
|
+
finally {
|
|
2016
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2017
|
+
}
|
|
2018
|
+
});
|
|
2019
|
+
it('keeps a foreign atomic destination and the source lock when no directory move occurs', async () => {
|
|
2020
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-recovery-atomic-destination-'));
|
|
2021
|
+
try {
|
|
2022
|
+
const context = resolveSessionPointerContext(cwd);
|
|
2023
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
2024
|
+
const parked = `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`;
|
|
2025
|
+
await withPointerDependencies({ token: () => SUCCESSOR_TOKEN, probePid: () => 'dead', atomicRenameNoReplace: async (_from, to) => {
|
|
2026
|
+
await mkdir(to);
|
|
2027
|
+
await writeFile(join(to, 'foreign'), 'foreign');
|
|
2028
|
+
return 'not-moved';
|
|
2029
|
+
} }, async () => assert.equal((await recoverSessionPointerLock(cwd)).recovered, false));
|
|
2030
|
+
assert.equal(await readFile(join(parked, 'foreign'), 'utf8'), 'foreign');
|
|
2031
|
+
assert.equal(existsSync(context.lockPath), true);
|
|
2032
|
+
}
|
|
2033
|
+
finally {
|
|
2034
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2035
|
+
}
|
|
2036
|
+
});
|
|
2037
|
+
it('preserves a foreign directory swapped into the atomic move and rollback residue', async () => {
|
|
2038
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-recovery-atomic-source-swap-'));
|
|
2039
|
+
try {
|
|
2040
|
+
const context = resolveSessionPointerContext(cwd);
|
|
2041
|
+
const original = `${context.lockPath}.original`;
|
|
2042
|
+
const parked = `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`;
|
|
2043
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
2044
|
+
await withPointerDependencies({
|
|
2045
|
+
token: () => SUCCESSOR_TOKEN,
|
|
2046
|
+
probePid: () => 'dead',
|
|
2047
|
+
atomicRenameNoReplace: withProbeFallback(context.lockPath, async (from, to) => {
|
|
2048
|
+
if (from === context.lockPath && to === parked) {
|
|
2049
|
+
await rename(from, original);
|
|
2050
|
+
await mkdir(from);
|
|
2051
|
+
await writeFile(join(from, 'foreign'), 'foreign');
|
|
2052
|
+
await rename(from, to);
|
|
2053
|
+
return 'moved';
|
|
2054
|
+
}
|
|
2055
|
+
if (from === parked && to === context.lockPath)
|
|
2056
|
+
return 'not-moved';
|
|
2057
|
+
return await defaultTestAtomicRenameNoReplace(from, to);
|
|
2058
|
+
}),
|
|
2059
|
+
}, async () => {
|
|
2060
|
+
assert.equal((await recoverSessionPointerLock(cwd)).recovered, false);
|
|
2061
|
+
});
|
|
2062
|
+
assert.equal(existsSync(join(original, 'owner.json')), true);
|
|
2063
|
+
assert.equal(await readFile(join(parked, 'foreign'), 'utf8'), 'foreign');
|
|
2064
|
+
}
|
|
2065
|
+
finally {
|
|
2066
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2067
|
+
}
|
|
2068
|
+
});
|
|
2069
|
+
it('does not overwrite a rollback destination that appears before the no-clobber rollback link', async () => {
|
|
2070
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-recovery-rollback-destination-race-'));
|
|
2071
|
+
try {
|
|
2072
|
+
const context = resolveSessionPointerContext(cwd);
|
|
2073
|
+
const parked = `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`;
|
|
2074
|
+
const foreign = 'foreign rollback destination';
|
|
2075
|
+
let rollback = false;
|
|
2076
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
2077
|
+
await withPointerDependencies({
|
|
2078
|
+
token: () => SUCCESSOR_TOKEN,
|
|
2079
|
+
probePid: () => 'dead',
|
|
2080
|
+
atomicRenameNoReplace: async (from, to) => {
|
|
2081
|
+
if (from === context.lockPath) {
|
|
2082
|
+
await rename(from, to);
|
|
2083
|
+
await mkdir(context.lockPath);
|
|
2084
|
+
await writeFile(join(context.lockPath, 'foreign'), foreign);
|
|
2085
|
+
return 'moved';
|
|
2086
|
+
}
|
|
2087
|
+
if (from === parked && to === context.lockPath) {
|
|
2088
|
+
rollback = true;
|
|
2089
|
+
return 'not-moved';
|
|
2090
|
+
}
|
|
2091
|
+
if (existsSync(to))
|
|
2092
|
+
return 'not-moved';
|
|
2093
|
+
await rename(from, to);
|
|
2094
|
+
return 'moved';
|
|
2095
|
+
},
|
|
2096
|
+
}, async () => {
|
|
2097
|
+
const recovered = await recoverSessionPointerLock(cwd);
|
|
2098
|
+
assert.equal(recovered.recovered, false);
|
|
2099
|
+
assert.equal(recovered.action, 'none');
|
|
2100
|
+
assert.match(recovered.reason, /captured object|token-bound|source pathname/i);
|
|
2101
|
+
});
|
|
2102
|
+
assert.equal(rollback, true);
|
|
2103
|
+
assert.equal(await readFile(join(context.lockPath, 'foreign'), 'utf-8'), foreign);
|
|
2104
|
+
assert.equal(existsSync(join(parked, 'owner.json')), true);
|
|
2105
|
+
}
|
|
2106
|
+
finally {
|
|
2107
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2108
|
+
}
|
|
2109
|
+
});
|
|
2110
|
+
it('preserves a checkpoint replacement and foreign completed receipt collision', async () => {
|
|
2111
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-recovery-completion-collision-'));
|
|
2112
|
+
try {
|
|
2113
|
+
const context = resolveSessionPointerContext(cwd);
|
|
2114
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
2115
|
+
const checkpoint = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
|
|
2116
|
+
await withPointerDependencies({ token: () => SUCCESSOR_TOKEN, probePid: () => 'dead', atomicRenameNoReplace: async (from, to) => {
|
|
2117
|
+
if (from === checkpoint) {
|
|
2118
|
+
await writeFile(to, 'foreign receipt');
|
|
2119
|
+
return 'not-moved';
|
|
2120
|
+
}
|
|
2121
|
+
await rename(from, to);
|
|
2122
|
+
return 'moved';
|
|
2123
|
+
} }, async () => assert.equal((await recoverSessionPointerLock(cwd)).recovered, false));
|
|
2124
|
+
assert.equal(existsSync(checkpoint), true);
|
|
2125
|
+
assert.equal(await readFile(checkpoint.replace(/\.json$/, '.completed'), 'utf8'), 'foreign receipt');
|
|
2126
|
+
}
|
|
2127
|
+
finally {
|
|
2128
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2129
|
+
}
|
|
2130
|
+
});
|
|
2131
|
+
it('fails closed before moving a lock directory with a foreign extra entry', async () => {
|
|
2132
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-recovery-foreign-entry-'));
|
|
2133
|
+
try {
|
|
2134
|
+
const context = resolveSessionPointerContext(cwd);
|
|
2135
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
2136
|
+
await writeFile(join(context.lockPath, 'foreign'), 'foreign');
|
|
2137
|
+
let moved = false;
|
|
2138
|
+
await withPointerDependencies({ token: () => SUCCESSOR_TOKEN, probePid: () => 'dead', atomicRenameNoReplace: async () => { moved = true; return 'moved'; } }, async () => assert.equal((await recoverSessionPointerLock(cwd)).recovered, false));
|
|
2139
|
+
assert.equal(moved, false);
|
|
2140
|
+
assert.equal(await readFile(join(context.lockPath, 'foreign'), 'utf8'), 'foreign');
|
|
2141
|
+
}
|
|
2142
|
+
finally {
|
|
2143
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2144
|
+
}
|
|
2145
|
+
});
|
|
2146
|
+
it('resumes legacy base-v1 recovery and v2 source and parked partial recovery', async () => {
|
|
2147
|
+
for (const state of ['v1-source', 'v2-source', 'v2-parked']) {
|
|
2148
|
+
const cwd = await mkdtemp(join(tmpdir(), `omx-session-recovery-legacy-${state}-`));
|
|
2149
|
+
try {
|
|
2150
|
+
const context = resolveSessionPointerContext(cwd);
|
|
2151
|
+
const ownerPath = join(context.lockPath, 'owner.json');
|
|
2152
|
+
const checkpoint = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
|
|
2153
|
+
const lockPark = `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`;
|
|
2154
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
2155
|
+
const owner = await lstat(ownerPath);
|
|
2156
|
+
const lock = await lstat(context.lockPath);
|
|
2157
|
+
const base = { version: state === 'v1-source' ? 1 : 2, sourcePath: ownerPath, identity: { dev: owner.dev, ino: owner.ino }, phase: 'evidence-pending' };
|
|
2158
|
+
if (state === 'v2-parked') {
|
|
2159
|
+
await link(ownerPath, join(context.lockPath, `owner.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.recovery`));
|
|
2160
|
+
await link(ownerPath, `${context.lockPath}.quarantine.${TEST_TOKEN}.${SUCCESSOR_TOKEN}`);
|
|
2161
|
+
await rename(context.lockPath, lockPark);
|
|
2162
|
+
}
|
|
2163
|
+
await writeFile(checkpoint, JSON.stringify(state === 'v1-source' ? base : { ...base, lockIdentity: { dev: lock.dev, ino: lock.ino }, lockParkPath: lockPark, phase: state === 'v2-parked' ? 'evidence-quarantined' : 'evidence-pending' }));
|
|
2164
|
+
await withPointerDependencies({ token: () => { throw new Error('legacy resume must not mint'); }, probePid: () => 'dead', atomicRenameNoReplace: async (from, to) => { if (existsSync(to))
|
|
2165
|
+
return 'not-moved'; await rename(from, to); return 'moved'; } }, async () => {
|
|
2166
|
+
const recovered = await recoverSessionPointerLock(cwd);
|
|
2167
|
+
assert.equal(recovered.recovered, true, recovered.reason);
|
|
2168
|
+
});
|
|
2169
|
+
assert.equal(existsSync(checkpoint.replace(/\.json$/, '.completed')), true);
|
|
2170
|
+
assert.equal(existsSync(lockPark), true);
|
|
2171
|
+
}
|
|
2172
|
+
finally {
|
|
2173
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
});
|
|
2177
|
+
it('preserves recovery evidence when atomic directory quarantine is unsupported', async () => {
|
|
2178
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-recovery-unsupported-'));
|
|
2179
|
+
try {
|
|
2180
|
+
const context = resolveSessionPointerContext(cwd);
|
|
2181
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
2182
|
+
await withPointerDependencies({
|
|
2183
|
+
token: () => SUCCESSOR_TOKEN,
|
|
2184
|
+
probePid: () => 'dead',
|
|
2185
|
+
atomicRenameNoReplace: async () => 'unsupported',
|
|
2186
|
+
}, async () => {
|
|
2187
|
+
const recovered = await recoverSessionPointerLock(cwd);
|
|
2188
|
+
assert.equal(recovered.recovered, false);
|
|
2189
|
+
assert.match(recovered.reason, /unsupported/i);
|
|
2190
|
+
});
|
|
2191
|
+
// Capability is probed before checkpoint/quarantine mutations.
|
|
2192
|
+
assert.equal(existsSync(context.lockPath), true);
|
|
2193
|
+
assert.equal(await readFile(join(context.lockPath, 'owner.json'), 'utf8'), JSON.stringify(validLockOwner()));
|
|
2194
|
+
assert.equal(existsSync(`${context.lockPath}.quarantine.${TEST_TOKEN}.${SUCCESSOR_TOKEN}`), false);
|
|
2195
|
+
assert.equal(existsSync(`${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`), false);
|
|
2196
|
+
}
|
|
2197
|
+
finally {
|
|
2198
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2199
|
+
}
|
|
2200
|
+
});
|
|
2201
|
+
it('distinguishes a paused live pre-rename owner from the same owner after SIGKILL', async (t) => {
|
|
2202
|
+
if (process.platform !== 'linux') {
|
|
2203
|
+
t.skip('Linux process start identity is required for deterministic PID reuse protection.');
|
|
2204
|
+
return;
|
|
2205
|
+
}
|
|
2206
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-sigkill-'));
|
|
2207
|
+
const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)'], { stdio: 'ignore' });
|
|
2208
|
+
try {
|
|
2209
|
+
assert.ok(child.pid);
|
|
2210
|
+
await once(child, 'spawn');
|
|
2211
|
+
const context = resolveSessionPointerContext(cwd);
|
|
2212
|
+
await mkdir(context.lockPath, { recursive: true });
|
|
2213
|
+
await writeFile(join(context.lockPath, `owner.${TEST_TOKEN}.tmp`), JSON.stringify(validLockOwner({
|
|
2214
|
+
pid: child.pid,
|
|
2215
|
+
pid_start_ticks: await linuxProcessStartTicks(child.pid),
|
|
2216
|
+
})), 'utf-8');
|
|
2217
|
+
const paused = await recoverSessionPointerLock(cwd);
|
|
2218
|
+
assert.equal(paused.status, 'live');
|
|
2219
|
+
assert.equal(paused.recovered, false);
|
|
2220
|
+
assert.equal(paused.action, 'none');
|
|
2221
|
+
assert.equal(existsSync(context.lockPath), true);
|
|
2222
|
+
child.kill('SIGKILL');
|
|
2223
|
+
const [exitCode, signal] = await once(child, 'exit');
|
|
2224
|
+
assert.equal(exitCode, null);
|
|
2225
|
+
assert.equal(signal, 'SIGKILL');
|
|
2226
|
+
let orphaned;
|
|
2227
|
+
await withPointerDependencies({
|
|
2228
|
+
atomicRenameNoReplace: async (from, to) => {
|
|
2229
|
+
await rename(from, to);
|
|
2230
|
+
return 'moved';
|
|
2231
|
+
},
|
|
2232
|
+
}, async () => { orphaned = await recoverSessionPointerLock(cwd); });
|
|
2233
|
+
assert.equal(orphaned.status, 'dead');
|
|
2234
|
+
assert.equal(orphaned.recovered, true);
|
|
2235
|
+
assert.equal(orphaned.action, 'quarantined');
|
|
2236
|
+
assert.equal(existsSync(context.lockPath), false);
|
|
2237
|
+
}
|
|
2238
|
+
finally {
|
|
2239
|
+
if (child.exitCode === null && child.signalCode === null)
|
|
2240
|
+
child.kill('SIGKILL');
|
|
2241
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2242
|
+
}
|
|
2243
|
+
});
|
|
2244
|
+
it('fails closed for live pre-rename, PID reuse, malformed, and ambiguous lock evidence', async () => {
|
|
2245
|
+
const cases = [
|
|
2246
|
+
{ name: 'live-temp', files: [[`owner.${TEST_TOKEN}.tmp`, JSON.stringify(validLockOwner())]], probePid: 'alive', observation: matchingObservation() },
|
|
2247
|
+
{ name: 'reused', files: [['owner.json', JSON.stringify(validLockOwner())]], probePid: 'alive', observation: { kind: 'identity', identity: { platform: 'linux', birth: '2' } } },
|
|
2248
|
+
{ name: 'malformed', files: [['owner.json', '{']], probePid: 'dead' },
|
|
2249
|
+
{ name: 'ambiguous', files: [['owner.json', JSON.stringify(validLockOwner())], [`owner.${SUCCESSOR_TOKEN}.tmp`, JSON.stringify(validLockOwner({ token: SUCCESSOR_TOKEN }))]], probePid: 'dead' },
|
|
2250
|
+
];
|
|
2251
|
+
for (const testCase of cases) {
|
|
2252
|
+
const cwd = await mkdtemp(join(tmpdir(), `omx-session-lock-recovery-${testCase.name}-`));
|
|
2253
|
+
try {
|
|
2254
|
+
const context = resolveSessionPointerContext(cwd);
|
|
2255
|
+
await mkdir(context.lockPath, { recursive: true });
|
|
2256
|
+
await Promise.all(testCase.files.map(async ([name, contents]) => await writeFile(join(context.lockPath, name), contents, 'utf-8')));
|
|
2257
|
+
await withPointerDependencies({
|
|
2258
|
+
token: () => SUCCESSOR_TOKEN,
|
|
2259
|
+
probePid: () => testCase.probePid,
|
|
2260
|
+
observeProcess: () => testCase.observation ?? matchingObservation(),
|
|
2261
|
+
}, async () => {
|
|
2262
|
+
const before = await readdir(context.lockPath);
|
|
2263
|
+
const recovered = await recoverSessionPointerLock(cwd);
|
|
2264
|
+
assert.equal(recovered.recovered, false);
|
|
2265
|
+
assert.equal(recovered.action, 'none');
|
|
2266
|
+
assert.equal(recovered.safeToRecover, false);
|
|
2267
|
+
assert.deepEqual(await readdir(context.lockPath), before);
|
|
2268
|
+
});
|
|
2269
|
+
}
|
|
2270
|
+
finally {
|
|
2271
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
});
|
|
2275
|
+
it('claims stale evidence before quarantine so a successor cannot be mistaken for it', async () => {
|
|
2276
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-race-'));
|
|
2277
|
+
try {
|
|
2278
|
+
const context = resolveSessionPointerContext(cwd);
|
|
2279
|
+
await mkdir(context.lockPath, { recursive: true });
|
|
2280
|
+
await writeFile(join(context.lockPath, `owner.${TEST_TOKEN}.tmp`), JSON.stringify(validLockOwner()), 'utf-8');
|
|
2281
|
+
const links = [];
|
|
2282
|
+
let successorBlocked = false;
|
|
2283
|
+
await withPointerDependencies({
|
|
2284
|
+
token: () => SUCCESSOR_TOKEN,
|
|
2285
|
+
probePid: () => 'dead',
|
|
2286
|
+
fs: {
|
|
2287
|
+
link: async (from, to) => {
|
|
2288
|
+
links.push([from, to]);
|
|
2289
|
+
await link(from, to);
|
|
2290
|
+
},
|
|
2291
|
+
},
|
|
2292
|
+
atomicRenameNoReplace: async (from, to) => {
|
|
2293
|
+
if (from === context.lockPath) {
|
|
2294
|
+
await assert.rejects(mkdir(context.lockPath), { code: 'EEXIST' });
|
|
2295
|
+
successorBlocked = true;
|
|
2296
|
+
}
|
|
2297
|
+
if (existsSync(to))
|
|
2298
|
+
return 'not-moved';
|
|
2299
|
+
await rename(from, to);
|
|
2300
|
+
return 'moved';
|
|
2301
|
+
},
|
|
2302
|
+
}, async () => {
|
|
2303
|
+
const recovered = await recoverSessionPointerLock(cwd);
|
|
2304
|
+
assert.equal(recovered.recovered, true);
|
|
2305
|
+
});
|
|
2306
|
+
assert.equal(successorBlocked, true);
|
|
2307
|
+
assert.equal(links.length, 1);
|
|
2308
|
+
assert.match(links[0][0], /owner\.transaction_token_123456\.tmp$/);
|
|
2309
|
+
assert.equal(links[0][1].includes('.quarantine.'), true);
|
|
2310
|
+
assert.equal(existsSync(`${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`), true);
|
|
2311
|
+
}
|
|
2312
|
+
finally {
|
|
2313
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2314
|
+
}
|
|
2315
|
+
});
|
|
2316
|
+
it('does not claim a successor lock when the inspected orphan is displaced before the exact temp rename', async () => {
|
|
2317
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-successor-race-'));
|
|
2318
|
+
try {
|
|
2319
|
+
const context = resolveSessionPointerContext(cwd);
|
|
2320
|
+
const staleTemp = join(context.lockPath, `owner.${TEST_TOKEN}.tmp`);
|
|
2321
|
+
const successorTemp = join(context.lockPath, `owner.${SUCCESSOR_TOKEN}.tmp`);
|
|
2322
|
+
const displacedPath = `${context.lockPath}.displaced`;
|
|
2323
|
+
await mkdir(context.lockPath, { recursive: true });
|
|
2324
|
+
await writeFile(staleTemp, JSON.stringify(validLockOwner()), 'utf-8');
|
|
2325
|
+
let displaced = false;
|
|
2326
|
+
await withPointerDependencies({
|
|
2327
|
+
token: () => SUCCESSOR_TOKEN,
|
|
2328
|
+
probePid: () => 'dead',
|
|
2329
|
+
fs: {
|
|
2330
|
+
link: async (from, to) => {
|
|
2331
|
+
if (!displaced && from === staleTemp) {
|
|
2332
|
+
displaced = true;
|
|
2333
|
+
await rename(context.lockPath, displacedPath);
|
|
2334
|
+
await mkdir(context.lockPath);
|
|
2335
|
+
await writeFile(successorTemp, JSON.stringify(validLockOwner({ token: SUCCESSOR_TOKEN })), 'utf-8');
|
|
2336
|
+
}
|
|
2337
|
+
await link(from, to);
|
|
2338
|
+
},
|
|
2339
|
+
},
|
|
2340
|
+
}, async () => {
|
|
2341
|
+
const recovered = await recoverSessionPointerLock(cwd);
|
|
2342
|
+
assert.equal(recovered.recovered, false);
|
|
2343
|
+
assert.equal(recovered.action, 'none');
|
|
2344
|
+
assert.match(recovered.reason, /Recovery checkpoint.*ENOENT/i);
|
|
2345
|
+
});
|
|
2346
|
+
assert.equal(displaced, true);
|
|
2347
|
+
assert.deepEqual(await readdir(context.lockPath), [`owner.${SUCCESSOR_TOKEN}.tmp`]);
|
|
2348
|
+
assert.equal(await readFile(successorTemp, 'utf-8'), JSON.stringify(validLockOwner({ token: SUCCESSOR_TOKEN })));
|
|
2349
|
+
}
|
|
2350
|
+
finally {
|
|
2351
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2352
|
+
}
|
|
2353
|
+
});
|
|
2354
|
+
it('does not report recovery after a successor live lock appears during parked-lock removal', async () => {
|
|
2355
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-post-claim-race-'));
|
|
2356
|
+
try {
|
|
2357
|
+
const context = resolveSessionPointerContext(cwd);
|
|
2358
|
+
const staleTemp = join(context.lockPath, `owner.${TEST_TOKEN}.tmp`);
|
|
2359
|
+
const successorTemp = join(context.lockPath, `owner.${SUCCESSOR_TOKEN}.tmp`);
|
|
2360
|
+
await mkdir(context.lockPath, { recursive: true });
|
|
2361
|
+
await writeFile(staleTemp, JSON.stringify(validLockOwner()), 'utf-8');
|
|
2362
|
+
let displaced = false;
|
|
2363
|
+
await withPointerDependencies({
|
|
2364
|
+
token: () => SUCCESSOR_TOKEN,
|
|
2365
|
+
probePid: () => 'dead',
|
|
2366
|
+
atomicRenameNoReplace: async (from, to) => {
|
|
2367
|
+
if (existsSync(to))
|
|
2368
|
+
return 'not-moved';
|
|
2369
|
+
await rename(from, to);
|
|
2370
|
+
if (!displaced && from === context.lockPath) {
|
|
2371
|
+
displaced = true;
|
|
2372
|
+
await mkdir(context.lockPath);
|
|
2373
|
+
await writeFile(successorTemp, JSON.stringify(validLockOwner({ token: SUCCESSOR_TOKEN })), 'utf-8');
|
|
2374
|
+
}
|
|
2375
|
+
return 'moved';
|
|
2376
|
+
},
|
|
2377
|
+
}, async () => {
|
|
2378
|
+
const recovered = await recoverSessionPointerLock(cwd);
|
|
2379
|
+
assert.equal(recovered.recovered, false);
|
|
2380
|
+
assert.equal(recovered.action, 'none');
|
|
2381
|
+
assert.match(recovered.reason, /did not leave the captured object/i);
|
|
2382
|
+
});
|
|
2383
|
+
assert.equal(displaced, true);
|
|
2384
|
+
assert.deepEqual(await readdir(context.lockPath), [`owner.${SUCCESSOR_TOKEN}.tmp`]);
|
|
2385
|
+
assert.equal(await readFile(successorTemp, 'utf-8'), JSON.stringify(validLockOwner({ token: SUCCESSOR_TOKEN })));
|
|
2386
|
+
}
|
|
2387
|
+
finally {
|
|
2388
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2389
|
+
}
|
|
2390
|
+
});
|
|
2391
|
+
it('retries a positively matching live owner with the bounded 25/50/100 schedule before timing out', async () => {
|
|
2392
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-timeout-'));
|
|
2393
|
+
try {
|
|
2394
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
2395
|
+
const delays = [];
|
|
2396
|
+
let now = 0;
|
|
2397
|
+
await withPointerDependencies({
|
|
2398
|
+
nowMs: () => now,
|
|
2399
|
+
sleep: async (ms) => { delays.push(ms); now += ms; },
|
|
2400
|
+
token: () => TEST_TOKEN,
|
|
2401
|
+
probePid: () => 'alive',
|
|
2402
|
+
observeProcess: () => matchingObservation(),
|
|
2403
|
+
}, async () => {
|
|
2404
|
+
await assert.rejects(writeSessionStart(cwd, 'sess-timeout', { platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
|
|
2405
|
+
&& error.code === 'session_pointer_lock_timeout'
|
|
2406
|
+
&& error.lockOwnerStatus === 'live');
|
|
2407
|
+
});
|
|
2408
|
+
assert.deepEqual(delays.slice(0, 3), [25, 50, 100]);
|
|
2409
|
+
assert.equal(delays.at(-1), 25);
|
|
2410
|
+
assert.equal(existsSync(resolveSessionPointerContext(cwd).lockPath), true);
|
|
2411
|
+
}
|
|
2412
|
+
finally {
|
|
2413
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2414
|
+
}
|
|
2415
|
+
});
|
|
2416
|
+
it('rolls back only its unpublished owner artifacts and reports rollback ambiguity', async () => {
|
|
2417
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-owner-publish-'));
|
|
2418
|
+
try {
|
|
2419
|
+
const context = resolveSessionPointerContext(cwd);
|
|
2420
|
+
await withPointerDependencies({
|
|
2421
|
+
token: () => TEST_TOKEN,
|
|
2422
|
+
fs: {
|
|
2423
|
+
writeFile: async (path, data, options) => {
|
|
2424
|
+
if (path.endsWith(`owner.${TEST_TOKEN}.tmp`))
|
|
2425
|
+
throw new Error('owner write failure');
|
|
2426
|
+
await writeFile(path, data, options);
|
|
2427
|
+
},
|
|
2428
|
+
},
|
|
2429
|
+
}, async () => {
|
|
2430
|
+
await assert.rejects(writeSessionStart(cwd, 'sess-owner-publish', { platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
|
|
2431
|
+
&& error.code === 'session_pointer_io_failure'
|
|
2432
|
+
&& error.operation === 'lock-owner-publish');
|
|
2433
|
+
});
|
|
2434
|
+
assert.equal(existsSync(context.lockPath), false);
|
|
2435
|
+
await withPointerDependencies({
|
|
2436
|
+
token: () => TEST_TOKEN,
|
|
2437
|
+
fs: {
|
|
2438
|
+
writeFile: async (path, data, options) => {
|
|
2439
|
+
if (path.endsWith(`owner.${TEST_TOKEN}.tmp`))
|
|
2440
|
+
throw new Error('owner write failure');
|
|
2441
|
+
await writeFile(path, data, options);
|
|
2442
|
+
},
|
|
2443
|
+
rmdir: async (path) => {
|
|
2444
|
+
if (path === context.lockPath)
|
|
2445
|
+
throw new Error('keep lock evidence');
|
|
2446
|
+
await rm(path, { recursive: false });
|
|
2447
|
+
},
|
|
2448
|
+
},
|
|
2449
|
+
}, async () => {
|
|
2450
|
+
await assert.rejects(writeSessionStart(cwd, 'sess-owner-residue', { platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
|
|
2451
|
+
&& error.code === 'session_pointer_lock_recovery_required'
|
|
2452
|
+
&& error.primaryOperation === 'lock-owner-publish'
|
|
2453
|
+
&& error.secondaryFailures?.[0]?.phase === 'remove-unpublished-lock');
|
|
2454
|
+
});
|
|
2455
|
+
assert.equal(existsSync(context.lockPath), true);
|
|
2456
|
+
}
|
|
2457
|
+
finally {
|
|
2458
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2459
|
+
}
|
|
2460
|
+
});
|
|
2461
|
+
it('publishes a legacy-like Linux state when process identity capture fails', async (t) => {
|
|
2462
|
+
if (process.platform !== 'linux') {
|
|
2463
|
+
t.skip('Linux identity publication semantics are under test.');
|
|
2464
|
+
return;
|
|
2465
|
+
}
|
|
2466
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-identity-capture-failure-'));
|
|
2467
|
+
try {
|
|
2468
|
+
await withPointerDependencies({
|
|
2469
|
+
runtimePlatform: 'linux',
|
|
2470
|
+
observeProcess: () => ({ kind: 'error' }),
|
|
2471
|
+
}, async () => {
|
|
2472
|
+
const state = await writeSessionStart(cwd, 'sess-identity-capture-failure', { platform: 'linux' });
|
|
2473
|
+
assert.equal(state.platform, 'linux');
|
|
2474
|
+
assert.equal(Object.hasOwn(state, 'identity_schema_version'), false);
|
|
2475
|
+
assert.equal(Object.hasOwn(state, 'process_identity'), false);
|
|
2476
|
+
assert.equal(Object.hasOwn(state, 'pid_start_ticks'), false);
|
|
2477
|
+
const persisted = await readSessionState(cwd);
|
|
2478
|
+
assert.ok(persisted);
|
|
2479
|
+
assert.equal(Object.hasOwn(persisted, 'identity_schema_version'), false);
|
|
2480
|
+
assert.equal(Object.hasOwn(persisted, 'process_identity'), false);
|
|
2481
|
+
});
|
|
2482
|
+
}
|
|
2483
|
+
finally {
|
|
2484
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2485
|
+
}
|
|
2486
|
+
});
|
|
2487
|
+
it('publishes the owner and pointer on Windows when regular-file fsync reports EPERM', async () => {
|
|
2488
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-windows-eperm-'));
|
|
2489
|
+
let syncCalls = 0;
|
|
2490
|
+
const regularFileSync = async (platform) => {
|
|
2491
|
+
assert.equal(platform, 'win32');
|
|
2492
|
+
syncCalls += 1;
|
|
2493
|
+
throw codedError('EPERM');
|
|
2494
|
+
};
|
|
2495
|
+
try {
|
|
2496
|
+
const state = await writeSessionStart(cwd, 'sess-windows-eperm', {
|
|
2497
|
+
platform: 'win32',
|
|
2498
|
+
regularFileSync,
|
|
2499
|
+
});
|
|
2500
|
+
const context = resolveSessionPointerContext(cwd);
|
|
2501
|
+
assert.equal(state.session_id, 'sess-windows-eperm');
|
|
2502
|
+
assert.equal(syncCalls, 2, 'owner publication and pointer publication must both attempt fsync');
|
|
2503
|
+
assert.equal(existsSync(join(context.lockPath, 'owner.json')), false, 'lock owner is removed after commit');
|
|
2504
|
+
assert.equal(JSON.parse(await readFile(context.sessionPath, 'utf8')).session_id, 'sess-windows-eperm');
|
|
2505
|
+
}
|
|
2506
|
+
finally {
|
|
2507
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2508
|
+
}
|
|
2509
|
+
});
|
|
2510
|
+
it('emits one post-release warning for a degraded session start and stays silent for synced or failed transactions', async () => {
|
|
2511
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-durability-warning-'));
|
|
2512
|
+
const originalWrite = process.stderr.write;
|
|
2513
|
+
const warnings = [];
|
|
2514
|
+
let warningCwd = cwd;
|
|
2515
|
+
process.stderr.write = ((value) => {
|
|
2516
|
+
warnings.push({ value, lockExists: existsSync(resolveSessionPointerContext(warningCwd).lockPath) });
|
|
2517
|
+
return true;
|
|
2518
|
+
});
|
|
2519
|
+
const regularFileSync = async () => { throw codedError('EPERM'); };
|
|
2520
|
+
try {
|
|
2521
|
+
await writeSessionStart(cwd, 'sess-degraded-start', { platform: 'win32', regularFileSync });
|
|
2522
|
+
assert.deepEqual(warnings, [{
|
|
2523
|
+
value: '[omx] warning: Windows EPERM regular-file fsync unsupported in session pointer start/reconcile; operation succeeded with degraded durability.\n',
|
|
2524
|
+
lockExists: false,
|
|
2525
|
+
}]);
|
|
2526
|
+
warnings.length = 0;
|
|
2527
|
+
const syncedCwd = join(cwd, 'synced');
|
|
2528
|
+
await mkdir(syncedCwd);
|
|
2529
|
+
warningCwd = syncedCwd;
|
|
2530
|
+
await writeSessionStart(syncedCwd, 'sess-synced-start', {
|
|
2531
|
+
platform: 'win32',
|
|
2532
|
+
regularFileSync: async () => { },
|
|
2533
|
+
});
|
|
2534
|
+
assert.deepEqual(warnings, []);
|
|
2535
|
+
const failedCwd = join(cwd, 'failed');
|
|
2536
|
+
await mkdir(failedCwd);
|
|
2537
|
+
warningCwd = failedCwd;
|
|
2538
|
+
await withPointerDependencies({
|
|
2539
|
+
fs: {
|
|
2540
|
+
rename: async (from, to) => {
|
|
2541
|
+
if (to === resolveSessionPointerContext(failedCwd).sessionPath)
|
|
2542
|
+
throw new Error('rename failure');
|
|
2543
|
+
await rename(from, to);
|
|
2544
|
+
},
|
|
2545
|
+
},
|
|
2546
|
+
}, async () => {
|
|
2547
|
+
await assert.rejects(writeSessionStart(failedCwd, 'sess-failed-start', { platform: 'win32', regularFileSync }));
|
|
2548
|
+
});
|
|
2549
|
+
assert.deepEqual(warnings, []);
|
|
2550
|
+
}
|
|
2551
|
+
finally {
|
|
2552
|
+
process.stderr.write = originalWrite;
|
|
2553
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2554
|
+
}
|
|
2555
|
+
});
|
|
2556
|
+
it('types state-directory, pointer-read, owner-sync, owner-rename, and invalid-token failures before commit', async () => {
|
|
2557
|
+
const failures = [
|
|
2558
|
+
{
|
|
2559
|
+
name: 'state-dir',
|
|
2560
|
+
operation: 'state-dir-create',
|
|
2561
|
+
dependencies: (context) => ({
|
|
2562
|
+
fs: {
|
|
2563
|
+
mkdir: async (path, options) => {
|
|
2564
|
+
if (path === context.baseStateDir)
|
|
2565
|
+
throw new Error('state directory failure');
|
|
2566
|
+
await mkdir(path, options);
|
|
2567
|
+
},
|
|
2568
|
+
},
|
|
2569
|
+
}),
|
|
2570
|
+
},
|
|
2571
|
+
{
|
|
2572
|
+
name: 'pointer-read',
|
|
2573
|
+
operation: 'pointer-read',
|
|
2574
|
+
dependencies: (context) => ({
|
|
2575
|
+
token: () => TEST_TOKEN,
|
|
2576
|
+
fs: {
|
|
2577
|
+
readFile: async (path, encoding) => {
|
|
2578
|
+
if (path === context.sessionPath)
|
|
2579
|
+
throw new Error('pointer read failure');
|
|
2580
|
+
return await readFile(path, encoding);
|
|
2581
|
+
},
|
|
2582
|
+
},
|
|
2583
|
+
}),
|
|
2584
|
+
},
|
|
2585
|
+
{
|
|
2586
|
+
name: 'owner-sync',
|
|
2587
|
+
operation: 'lock-owner-publish',
|
|
2588
|
+
dependencies: (context) => ({
|
|
2589
|
+
token: () => TEST_TOKEN,
|
|
2590
|
+
fs: {
|
|
2591
|
+
openAndSync: async (path) => {
|
|
2592
|
+
if (path === join(context.lockPath, `owner.${TEST_TOKEN}.tmp`))
|
|
2593
|
+
throw new Error('owner sync failure');
|
|
2594
|
+
return 'synced';
|
|
2595
|
+
},
|
|
2596
|
+
},
|
|
2597
|
+
}),
|
|
2598
|
+
},
|
|
2599
|
+
{
|
|
2600
|
+
name: 'owner-rename',
|
|
2601
|
+
operation: 'lock-owner-publish',
|
|
2602
|
+
dependencies: (context) => ({
|
|
2603
|
+
token: () => TEST_TOKEN,
|
|
2604
|
+
fs: {
|
|
2605
|
+
rename: async (from, to) => {
|
|
2606
|
+
if (from === join(context.lockPath, `owner.${TEST_TOKEN}.tmp`))
|
|
2607
|
+
throw new Error('owner rename failure');
|
|
2608
|
+
await rename(from, to);
|
|
2609
|
+
},
|
|
2610
|
+
},
|
|
2611
|
+
}),
|
|
2612
|
+
},
|
|
2613
|
+
];
|
|
2614
|
+
for (const failure of failures) {
|
|
2615
|
+
const cwd = await mkdtemp(join(tmpdir(), `omx-session-${failure.name}-`));
|
|
2616
|
+
try {
|
|
2617
|
+
const context = resolveSessionPointerContext(cwd);
|
|
2618
|
+
await withPointerDependencies(failure.dependencies(context), async () => {
|
|
2619
|
+
await assert.rejects(writeSessionStart(cwd, 'sess-precommit', { platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
|
|
2620
|
+
&& error.code === 'session_pointer_io_failure'
|
|
2621
|
+
&& error.operation === failure.operation);
|
|
2622
|
+
});
|
|
2623
|
+
assert.equal(existsSync(context.lockPath), false);
|
|
2624
|
+
}
|
|
2625
|
+
finally {
|
|
2626
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2627
|
+
}
|
|
2628
|
+
}
|
|
2629
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-invalid-token-'));
|
|
2630
|
+
try {
|
|
2631
|
+
const context = resolveSessionPointerContext(cwd);
|
|
2632
|
+
await withPointerDependencies({ token: () => 'bad' }, async () => {
|
|
2633
|
+
await assert.rejects(writeSessionStart(cwd, 'sess-invalid-token', { platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
|
|
2634
|
+
&& error.code === 'session_pointer_io_failure'
|
|
2635
|
+
&& error.operation === 'lock-owner-publish');
|
|
704
2636
|
});
|
|
705
2637
|
assert.equal(existsSync(context.lockPath), false);
|
|
2638
|
+
assert.equal(existsSync(`${context.sessionPath}.tmp-bad`), false);
|
|
2639
|
+
}
|
|
2640
|
+
finally {
|
|
2641
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2642
|
+
}
|
|
2643
|
+
});
|
|
2644
|
+
it('types pointer write, sync, and rename failures and removes only the owned temporary file', async () => {
|
|
2645
|
+
const phases = [
|
|
2646
|
+
{ operation: 'pointer-temp-write', fail: 'write' },
|
|
2647
|
+
{ operation: 'pointer-fsync', fail: 'sync' },
|
|
2648
|
+
{ operation: 'pointer-rename', fail: 'rename' },
|
|
2649
|
+
];
|
|
2650
|
+
for (const phase of phases) {
|
|
2651
|
+
const cwd = await mkdtemp(join(tmpdir(), `omx-session-${phase.fail}-`));
|
|
2652
|
+
try {
|
|
2653
|
+
const context = resolveSessionPointerContext(cwd);
|
|
2654
|
+
const tempPath = `${context.sessionPath}.tmp-${TEST_TOKEN}`;
|
|
2655
|
+
await withPointerDependencies({
|
|
2656
|
+
token: () => TEST_TOKEN,
|
|
2657
|
+
fs: {
|
|
2658
|
+
writeFile: async (path, data, options) => {
|
|
2659
|
+
await writeFile(path, data, options);
|
|
2660
|
+
if (phase.fail === 'write' && path === tempPath)
|
|
2661
|
+
throw new Error('pointer write failure');
|
|
2662
|
+
},
|
|
2663
|
+
openAndSync: async (path) => {
|
|
2664
|
+
if (phase.fail === 'sync' && path === tempPath)
|
|
2665
|
+
throw new Error('pointer sync failure');
|
|
2666
|
+
return 'synced';
|
|
2667
|
+
},
|
|
2668
|
+
rename: async (from, to) => {
|
|
2669
|
+
if (phase.fail === 'rename' && from === tempPath && to === context.sessionPath) {
|
|
2670
|
+
throw new Error('pointer rename failure');
|
|
2671
|
+
}
|
|
2672
|
+
await rename(from, to);
|
|
2673
|
+
},
|
|
2674
|
+
},
|
|
2675
|
+
}, async () => {
|
|
2676
|
+
await assert.rejects(writeSessionStart(cwd, 'sess-failure', { platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
|
|
2677
|
+
&& error.code === 'session_pointer_io_failure'
|
|
2678
|
+
&& error.operation === phase.operation);
|
|
2679
|
+
});
|
|
2680
|
+
assert.equal(existsSync(tempPath), false);
|
|
2681
|
+
assert.equal(existsSync(context.lockPath), false);
|
|
2682
|
+
}
|
|
2683
|
+
finally {
|
|
2684
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
});
|
|
2688
|
+
it('preserves owned pointer residue and ordered cleanup evidence when cleanup or release cannot complete', async () => {
|
|
2689
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-pointer-residue-'));
|
|
2690
|
+
try {
|
|
2691
|
+
const context = resolveSessionPointerContext(cwd);
|
|
2692
|
+
const tempPath = `${context.sessionPath}.tmp-${TEST_TOKEN}`;
|
|
2693
|
+
await withPointerDependencies({
|
|
2694
|
+
token: () => TEST_TOKEN,
|
|
2695
|
+
fs: {
|
|
2696
|
+
writeFile: async (path, data, options) => {
|
|
2697
|
+
await writeFile(path, data, options);
|
|
2698
|
+
if (path === tempPath)
|
|
2699
|
+
throw new Error('pointer write failure');
|
|
2700
|
+
},
|
|
2701
|
+
unlink: async (path) => {
|
|
2702
|
+
if (path === tempPath)
|
|
2703
|
+
throw new Error('pointer temp cleanup failure');
|
|
2704
|
+
await rm(path, { force: false });
|
|
2705
|
+
},
|
|
2706
|
+
rmdir: async (path) => {
|
|
2707
|
+
if (path.endsWith(`.release-${TEST_TOKEN}`))
|
|
2708
|
+
throw new Error('release cleanup failure');
|
|
2709
|
+
await rm(path, { recursive: false });
|
|
2710
|
+
},
|
|
2711
|
+
},
|
|
2712
|
+
}, async () => {
|
|
2713
|
+
await assert.rejects(writeSessionStart(cwd, 'sess-residue', { platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
|
|
2714
|
+
&& error.code === 'session_pointer_lock_recovery_required'
|
|
2715
|
+
&& error.primaryOperation === 'pointer-temp-write'
|
|
2716
|
+
&& error.secondaryFailures?.map((failure) => failure.phase).join(',') === 'remove-pointer-temp,remove-release-dir'
|
|
2717
|
+
&& error.secondaryFailures?.[0]?.evidencePath === tempPath);
|
|
2718
|
+
});
|
|
2719
|
+
assert.equal(existsSync(tempPath), true);
|
|
2720
|
+
assert.equal(existsSync(`${context.lockPath}.release-${TEST_TOKEN}`), true);
|
|
2721
|
+
}
|
|
2722
|
+
finally {
|
|
2723
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2724
|
+
}
|
|
2725
|
+
});
|
|
2726
|
+
it('leaves foreign pointer temps inert and a successor commits after explicit lock recovery', async () => {
|
|
2727
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-pointer-successor-'));
|
|
2728
|
+
try {
|
|
2729
|
+
const context = resolveSessionPointerContext(cwd);
|
|
2730
|
+
const ownTempPath = `${context.sessionPath}.tmp-${TEST_TOKEN}`;
|
|
2731
|
+
const foreignTempPath = `${context.sessionPath}.tmp-${FOREIGN_TOKEN}`;
|
|
2732
|
+
await mkdir(context.baseStateDir, { recursive: true });
|
|
2733
|
+
await writeFile(foreignTempPath, 'foreign transaction evidence', 'utf-8');
|
|
706
2734
|
await withPointerDependencies({
|
|
707
2735
|
token: () => TEST_TOKEN,
|
|
708
2736
|
fs: {
|
|
709
2737
|
writeFile: async (path, data, options) => {
|
|
710
|
-
if (path.endsWith(`owner.${TEST_TOKEN}.tmp`))
|
|
711
|
-
throw new Error('owner write failure');
|
|
712
2738
|
await writeFile(path, data, options);
|
|
2739
|
+
if (path === ownTempPath)
|
|
2740
|
+
throw new Error('pointer write failure');
|
|
713
2741
|
},
|
|
714
|
-
|
|
715
|
-
if (path ===
|
|
716
|
-
throw new Error('keep
|
|
717
|
-
await rm(path, {
|
|
2742
|
+
unlink: async (path) => {
|
|
2743
|
+
if (path === ownTempPath)
|
|
2744
|
+
throw new Error('keep own residue');
|
|
2745
|
+
await rm(path, { force: false });
|
|
2746
|
+
},
|
|
2747
|
+
rename: async (from, to) => {
|
|
2748
|
+
if (from === context.lockPath)
|
|
2749
|
+
throw new Error('preserve canonical lock');
|
|
2750
|
+
await rename(from, to);
|
|
718
2751
|
},
|
|
719
2752
|
},
|
|
720
2753
|
}, async () => {
|
|
721
|
-
await assert.rejects(writeSessionStart(cwd, 'sess-
|
|
722
|
-
&& error.code === 'session_pointer_lock_recovery_required'
|
|
723
|
-
&& error.primaryOperation === 'lock-owner-publish'
|
|
724
|
-
&& error.secondaryFailures?.[0]?.phase === 'remove-unpublished-lock');
|
|
2754
|
+
await assert.rejects(writeSessionStart(cwd, 'sess-first', { platform: 'win32' }), isSessionPointerLaunchAbort);
|
|
725
2755
|
});
|
|
2756
|
+
assert.equal(existsSync(foreignTempPath), true);
|
|
726
2757
|
assert.equal(existsSync(context.lockPath), true);
|
|
2758
|
+
// This is documented stopped-session operator recovery, deliberately not
|
|
2759
|
+
// product transaction behavior.
|
|
2760
|
+
await rm(context.lockPath, { recursive: true, force: true });
|
|
2761
|
+
await withPointerDependencies({ token: () => SUCCESSOR_TOKEN }, async () => {
|
|
2762
|
+
await writeSessionStart(cwd, 'sess-successor', { platform: 'win32' });
|
|
2763
|
+
});
|
|
2764
|
+
assert.equal((await readSessionState(cwd))?.session_id, 'sess-successor');
|
|
2765
|
+
assert.equal(existsSync(ownTempPath), true);
|
|
2766
|
+
assert.equal(existsSync(foreignTempPath), true);
|
|
2767
|
+
assert.equal(existsSync(context.lockPath), false);
|
|
2768
|
+
}
|
|
2769
|
+
finally {
|
|
2770
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2771
|
+
}
|
|
2772
|
+
});
|
|
2773
|
+
it('binds an owner alias only on a verified same-native/absent transition and never during replacement', async () => {
|
|
2774
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-owner-alias-'));
|
|
2775
|
+
try {
|
|
2776
|
+
await withPointerDependencies({
|
|
2777
|
+
runtimePlatform: 'win32',
|
|
2778
|
+
observeProcess: () => matchingWin32Observation(),
|
|
2779
|
+
}, async () => {
|
|
2780
|
+
const nativeOnly = await reconcileNativeSessionStart(cwd, 'native-first', {
|
|
2781
|
+
platform: 'win32',
|
|
2782
|
+
ownerOmxSessionId: 'omx-owner',
|
|
2783
|
+
});
|
|
2784
|
+
assert.equal(nativeOnly.owner_omx_session_id, undefined);
|
|
2785
|
+
await withOwnerEnvironment('omx-owner', async () => {
|
|
2786
|
+
const bound = await reconcileNativeSessionStart(cwd, 'native-first', {
|
|
2787
|
+
platform: 'win32',
|
|
2788
|
+
ownerAliasVerified: true,
|
|
2789
|
+
});
|
|
2790
|
+
assert.equal(bound.session_id, 'native-first');
|
|
2791
|
+
assert.equal(bound.owner_omx_session_id, 'omx-owner');
|
|
2792
|
+
await assert.rejects(reconcileNativeSessionStart(cwd, 'native-second', {
|
|
2793
|
+
platform: 'win32',
|
|
2794
|
+
ownerAliasVerified: true,
|
|
2795
|
+
}), isOwnerConflict);
|
|
2796
|
+
const persisted = await readSessionState(cwd);
|
|
2797
|
+
assert.equal(persisted?.session_id, 'native-first');
|
|
2798
|
+
assert.equal(persisted?.owner_omx_session_id, 'omx-owner');
|
|
2799
|
+
});
|
|
2800
|
+
});
|
|
2801
|
+
}
|
|
2802
|
+
finally {
|
|
2803
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2804
|
+
}
|
|
2805
|
+
});
|
|
2806
|
+
it('keeps native session owner sidecars isolated and rejects live cross-process reuse', async () => {
|
|
2807
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-owner-sidecar-'));
|
|
2808
|
+
try {
|
|
2809
|
+
await withPointerDependencies({
|
|
2810
|
+
runtimePlatform: 'win32',
|
|
2811
|
+
observeProcess: () => matchingWin32Observation(),
|
|
2812
|
+
probePid: () => 'alive',
|
|
2813
|
+
}, async () => {
|
|
2814
|
+
const first = await writeNativeSessionOwner(cwd, 'native-owner-a', { pid: 11, platform: 'win32' });
|
|
2815
|
+
const second = await writeNativeSessionOwner(cwd, 'native-owner-b', { pid: 22, platform: 'win32' });
|
|
2816
|
+
assert.equal(first.pid, 11);
|
|
2817
|
+
assert.equal(second.pid, 22);
|
|
2818
|
+
const firstPath = join(cwd, '.omx', 'state', 'sessions', 'native-owner-a', 'session-owner.json');
|
|
2819
|
+
const secondPath = join(cwd, '.omx', 'state', 'sessions', 'native-owner-b', 'session-owner.json');
|
|
2820
|
+
assert.equal(JSON.parse(await readFile(firstPath, 'utf-8')).pid, 11);
|
|
2821
|
+
assert.equal(JSON.parse(await readFile(secondPath, 'utf-8')).pid, 22);
|
|
2822
|
+
await writeNativeSessionOwner(cwd, 'native-owner-current', {
|
|
2823
|
+
pid: process.pid,
|
|
2824
|
+
platform: 'win32',
|
|
2825
|
+
});
|
|
2826
|
+
assert.equal((await readNativeSessionOwner(cwd, 'native-owner-current'))?.pid, process.pid);
|
|
2827
|
+
await assert.rejects(writeNativeSessionOwner(cwd, 'native-owner-a', { pid: 22, platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
|
|
2828
|
+
&& error.code === 'session_pointer_owner_conflict');
|
|
2829
|
+
});
|
|
2830
|
+
}
|
|
2831
|
+
finally {
|
|
2832
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2833
|
+
}
|
|
2834
|
+
});
|
|
2835
|
+
it('rejects a symlinked native owner state root', async () => {
|
|
2836
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-owner-root-symlink-'));
|
|
2837
|
+
try {
|
|
2838
|
+
const externalState = join(cwd, 'external-state');
|
|
2839
|
+
const ownerDir = join(externalState, 'sessions', 'native-owner-root-link');
|
|
2840
|
+
await mkdir(ownerDir, { recursive: true });
|
|
2841
|
+
await writeFile(join(ownerDir, 'session-owner.json'), JSON.stringify({
|
|
2842
|
+
session_id: 'native-owner-root-link',
|
|
2843
|
+
native_session_id: 'native-owner-root-link',
|
|
2844
|
+
cwd,
|
|
2845
|
+
platform: process.platform,
|
|
2846
|
+
}));
|
|
2847
|
+
await mkdir(join(cwd, '.omx'), { recursive: true });
|
|
2848
|
+
await symlink(externalState, join(cwd, '.omx', 'state'));
|
|
2849
|
+
assert.equal((await readNativeSessionOwnerEvidence(cwd, 'native-owner-root-link')).status, 'malformed');
|
|
2850
|
+
}
|
|
2851
|
+
finally {
|
|
2852
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2853
|
+
}
|
|
2854
|
+
});
|
|
2855
|
+
it('rejects symlinked native owner directories and sidecars', async () => {
|
|
2856
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-owner-symlink-'));
|
|
2857
|
+
try {
|
|
2858
|
+
const stateDir = join(cwd, '.omx', 'state');
|
|
2859
|
+
const sessionsDir = join(stateDir, 'sessions');
|
|
2860
|
+
const externalDir = join(cwd, 'external-owner');
|
|
2861
|
+
await mkdir(sessionsDir, { recursive: true });
|
|
2862
|
+
await mkdir(externalDir, { recursive: true });
|
|
2863
|
+
await writeFile(join(externalDir, 'session-owner.json'), JSON.stringify({
|
|
2864
|
+
session_id: 'native-owner-dir-link',
|
|
2865
|
+
native_session_id: 'native-owner-dir-link',
|
|
2866
|
+
cwd,
|
|
2867
|
+
platform: process.platform,
|
|
2868
|
+
}));
|
|
2869
|
+
await symlink(externalDir, join(sessionsDir, 'native-owner-dir-link'));
|
|
2870
|
+
assert.equal((await readNativeSessionOwnerEvidence(cwd, 'native-owner-dir-link')).status, 'malformed');
|
|
2871
|
+
const fileLinkDir = join(sessionsDir, 'native-owner-file-link');
|
|
2872
|
+
await mkdir(fileLinkDir, { recursive: true });
|
|
2873
|
+
const externalFile = join(cwd, 'external-session-owner.json');
|
|
2874
|
+
await writeFile(externalFile, JSON.stringify({
|
|
2875
|
+
session_id: 'native-owner-file-link',
|
|
2876
|
+
native_session_id: 'native-owner-file-link',
|
|
2877
|
+
cwd,
|
|
2878
|
+
platform: process.platform,
|
|
2879
|
+
}));
|
|
2880
|
+
await symlink(externalFile, join(fileLinkDir, 'session-owner.json'));
|
|
2881
|
+
assert.equal((await readNativeSessionOwnerEvidence(cwd, 'native-owner-file-link')).status, 'malformed');
|
|
2882
|
+
const swappedOwnerId = 'native-owner-swapped-dir';
|
|
2883
|
+
const swappedOwnerDir = join(sessionsDir, swappedOwnerId);
|
|
2884
|
+
await mkdir(swappedOwnerDir, { recursive: true });
|
|
2885
|
+
await writeFile(join(swappedOwnerDir, 'session-owner.json'), JSON.stringify({
|
|
2886
|
+
session_id: swappedOwnerId,
|
|
2887
|
+
native_session_id: swappedOwnerId,
|
|
2888
|
+
cwd,
|
|
2889
|
+
platform: process.platform,
|
|
2890
|
+
}));
|
|
2891
|
+
let swappedOwnerLstatCalls = 0;
|
|
2892
|
+
await withPointerDependencies({
|
|
2893
|
+
fs: {
|
|
2894
|
+
lstat: async (path) => {
|
|
2895
|
+
const stat = await lstat(path);
|
|
2896
|
+
const simulateSwap = path === swappedOwnerDir && ++swappedOwnerLstatCalls === 2;
|
|
2897
|
+
return {
|
|
2898
|
+
dev: Number(stat.dev) + (simulateSwap ? 1 : 0),
|
|
2899
|
+
ino: Number(stat.ino),
|
|
2900
|
+
isDirectory: () => stat.isDirectory(),
|
|
2901
|
+
isFile: () => stat.isFile(),
|
|
2902
|
+
isSymbolicLink: () => stat.isSymbolicLink(),
|
|
2903
|
+
};
|
|
2904
|
+
},
|
|
2905
|
+
},
|
|
2906
|
+
}, async () => {
|
|
2907
|
+
assert.equal((await readNativeSessionOwnerEvidence(cwd, swappedOwnerId)).status, 'malformed');
|
|
2908
|
+
});
|
|
2909
|
+
}
|
|
2910
|
+
finally {
|
|
2911
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2912
|
+
}
|
|
2913
|
+
});
|
|
2914
|
+
it('rejects owner sidecars recorded for another platform', async () => {
|
|
2915
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-owner-platform-'));
|
|
2916
|
+
try {
|
|
2917
|
+
const ownerDir = join(cwd, '.omx', 'state', 'sessions', 'native-owner-platform');
|
|
2918
|
+
await mkdir(ownerDir, { recursive: true });
|
|
2919
|
+
const ownerPath = join(ownerDir, 'session-owner.json');
|
|
2920
|
+
const forgedPlatform = process.platform === 'win32'
|
|
2921
|
+
? 'darwin'
|
|
2922
|
+
: 'win32';
|
|
2923
|
+
await writeFile(ownerPath, JSON.stringify({
|
|
2924
|
+
session_id: 'native-owner-platform',
|
|
2925
|
+
native_session_id: 'native-owner-platform',
|
|
2926
|
+
started_at: new Date().toISOString(),
|
|
2927
|
+
cwd,
|
|
2928
|
+
pid: process.pid,
|
|
2929
|
+
platform: forgedPlatform,
|
|
2930
|
+
}), 'utf-8');
|
|
2931
|
+
const before = await readFile(ownerPath, 'utf-8');
|
|
2932
|
+
assert.equal((await readNativeSessionOwnerEvidence(cwd, 'native-owner-platform')).status, 'identity-indeterminate');
|
|
2933
|
+
assert.equal(await readNativeSessionOwner(cwd, 'native-owner-platform'), null);
|
|
2934
|
+
await assert.rejects(writeNativeSessionOwner(cwd, 'native-owner-platform', {
|
|
2935
|
+
pid: process.pid,
|
|
2936
|
+
}), (error) => isSessionPointerLaunchAbort(error)
|
|
2937
|
+
&& error.code === 'session_pointer_unusable');
|
|
2938
|
+
assert.equal(await readFile(ownerPath, 'utf-8'), before);
|
|
2939
|
+
}
|
|
2940
|
+
finally {
|
|
2941
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2942
|
+
}
|
|
2943
|
+
});
|
|
2944
|
+
it('replaces only stale-dead owner evidence and preserves malformed evidence', async () => {
|
|
2945
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-owner-recovery-'));
|
|
2946
|
+
try {
|
|
2947
|
+
assert.equal((await readNativeSessionOwnerEvidence(cwd, 'native-owner-absent')).status, 'absent');
|
|
2948
|
+
await withPointerDependencies({
|
|
2949
|
+
probePid: (pid) => pid === 11 ? 'dead' : 'alive',
|
|
2950
|
+
observeProcess: () => ({ kind: 'identity', identity: { platform: 'linux', birth: '1' } }),
|
|
2951
|
+
}, async () => {
|
|
2952
|
+
await writeNativeSessionOwner(cwd, 'native-owner-recovery', { pid: 11, platform: 'linux' });
|
|
2953
|
+
assert.equal((await readNativeSessionOwnerEvidence(cwd, 'native-owner-recovery')).status, 'stale-dead');
|
|
2954
|
+
const recovered = await writeNativeSessionOwner(cwd, 'native-owner-recovery', { pid: 22, platform: 'linux' });
|
|
2955
|
+
assert.equal(recovered.pid, 22);
|
|
2956
|
+
const malformedDir = join(cwd, '.omx', 'state', 'sessions', 'native-owner-malformed');
|
|
2957
|
+
await mkdir(malformedDir, { recursive: true });
|
|
2958
|
+
const malformedPath = join(malformedDir, 'session-owner.json');
|
|
2959
|
+
await writeFile(malformedPath, '{ malformed', 'utf-8');
|
|
2960
|
+
assert.equal(await readNativeSessionOwner(cwd, 'native-owner-malformed'), null);
|
|
2961
|
+
assert.equal((await readNativeSessionOwnerEvidence(cwd, 'native-owner-malformed')).status, 'malformed');
|
|
2962
|
+
await assert.rejects(writeNativeSessionOwner(cwd, 'native-owner-malformed', { pid: 22, platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
|
|
2963
|
+
&& error.code === 'session_pointer_unusable'
|
|
2964
|
+
&& error.pointerStatus === 'malformed');
|
|
2965
|
+
const forgedDir = join(cwd, '.omx', 'state', 'sessions', 'native-owner-forged');
|
|
2966
|
+
await mkdir(forgedDir, { recursive: true });
|
|
2967
|
+
const forgedPath = join(forgedDir, 'session-owner.json');
|
|
2968
|
+
await writeFile(forgedPath, JSON.stringify({
|
|
2969
|
+
session_id: 'native-owner-other',
|
|
2970
|
+
native_session_id: 'native-owner-other',
|
|
2971
|
+
started_at: new Date().toISOString(),
|
|
2972
|
+
cwd,
|
|
2973
|
+
pid: 22,
|
|
2974
|
+
platform: 'linux',
|
|
2975
|
+
pid_start_ticks: 1,
|
|
2976
|
+
}), 'utf-8');
|
|
2977
|
+
const forgedBefore = await readFile(forgedPath, 'utf-8');
|
|
2978
|
+
assert.equal(await readNativeSessionOwner(cwd, 'native-owner-forged'), null);
|
|
2979
|
+
await assert.rejects(writeNativeSessionOwner(cwd, 'native-owner-forged', { pid: 22, platform: 'linux' }), (error) => isSessionPointerLaunchAbort(error)
|
|
2980
|
+
&& error.code === 'session_pointer_owner_conflict');
|
|
2981
|
+
assert.equal(await readFile(forgedPath, 'utf-8'), forgedBefore);
|
|
2982
|
+
const missingCwdDir = join(cwd, '.omx', 'state', 'sessions', 'native-owner-missing-cwd');
|
|
2983
|
+
await mkdir(missingCwdDir, { recursive: true });
|
|
2984
|
+
const missingCwdPath = join(missingCwdDir, 'session-owner.json');
|
|
2985
|
+
await writeFile(missingCwdPath, JSON.stringify({
|
|
2986
|
+
session_id: 'native-owner-missing-cwd',
|
|
2987
|
+
native_session_id: 'native-owner-missing-cwd',
|
|
2988
|
+
started_at: new Date().toISOString(),
|
|
2989
|
+
pid: 22,
|
|
2990
|
+
platform: 'linux',
|
|
2991
|
+
pid_start_ticks: 1,
|
|
2992
|
+
}), 'utf-8');
|
|
2993
|
+
const missingCwdBefore = await readFile(missingCwdPath, 'utf-8');
|
|
2994
|
+
assert.equal(await readNativeSessionOwner(cwd, 'native-owner-missing-cwd'), null);
|
|
2995
|
+
await assert.rejects(writeNativeSessionOwner(cwd, 'native-owner-missing-cwd', { pid: 22, platform: 'linux' }), (error) => isSessionPointerLaunchAbort(error)
|
|
2996
|
+
&& error.code === 'session_pointer_owner_conflict');
|
|
2997
|
+
assert.equal(await readFile(missingCwdPath, 'utf-8'), missingCwdBefore);
|
|
2998
|
+
});
|
|
2999
|
+
}
|
|
3000
|
+
finally {
|
|
3001
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3002
|
+
}
|
|
3003
|
+
});
|
|
3004
|
+
it('rejects cross-process native reconciliation while preserving the live selected pointer', async () => {
|
|
3005
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-cross-process-selected-'));
|
|
3006
|
+
try {
|
|
3007
|
+
await withPointerDependencies({
|
|
3008
|
+
runtimePlatform: 'win32',
|
|
3009
|
+
observeProcess: () => matchingWin32Observation(),
|
|
3010
|
+
probePid: () => 'alive',
|
|
3011
|
+
}, async () => {
|
|
3012
|
+
await writeSessionStart(cwd, 'native-selected-a', {
|
|
3013
|
+
nativeSessionId: 'native-selected-a',
|
|
3014
|
+
pid: 11,
|
|
3015
|
+
platform: 'win32',
|
|
3016
|
+
});
|
|
3017
|
+
const context = resolveSessionPointerContext(cwd);
|
|
3018
|
+
const before = await readFile(context.sessionPath, 'utf-8');
|
|
3019
|
+
await assert.rejects(reconcileNativeSessionStart(cwd, 'native-selected-b', { pid: 22, platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
|
|
3020
|
+
&& error.code === 'session_pointer_owner_conflict');
|
|
3021
|
+
assert.equal(await readFile(context.sessionPath, 'utf-8'), before);
|
|
3022
|
+
});
|
|
3023
|
+
}
|
|
3024
|
+
finally {
|
|
3025
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3026
|
+
}
|
|
3027
|
+
});
|
|
3028
|
+
it('preserves pointer evidence when history cannot be appended and rejects unusable end states before history or HUD cleanup', async () => {
|
|
3029
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-end-preserve-'));
|
|
3030
|
+
try {
|
|
3031
|
+
await withPointerDependencies({
|
|
3032
|
+
runtimePlatform: 'win32',
|
|
3033
|
+
observeProcess: () => matchingWin32Observation(),
|
|
3034
|
+
}, async () => {
|
|
3035
|
+
const context = resolveSessionPointerContext(cwd);
|
|
3036
|
+
const established = await establishLaunchSessionBinding(cwd, 'sess-history', { platform: 'win32' });
|
|
3037
|
+
assert.equal(established.kind, 'committed-released');
|
|
3038
|
+
if (established.kind !== 'committed-released')
|
|
3039
|
+
return;
|
|
3040
|
+
await mkdir(join(cwd, '.omx', 'logs'), { recursive: true });
|
|
3041
|
+
await rm(join(cwd, '.omx', 'logs'), { recursive: true, force: true });
|
|
3042
|
+
await writeFile(join(cwd, '.omx', 'logs'), 'not-a-directory', 'utf-8');
|
|
3043
|
+
await assert.rejects(finalizeBoundOnce(established.binding, 'history-failure'));
|
|
3044
|
+
assert.equal((await readSessionState(cwd))?.session_id, 'sess-history');
|
|
3045
|
+
await rm(join(cwd, '.omx', 'logs'), { force: true });
|
|
3046
|
+
await writeFile(context.sessionPath, '{ malformed', 'utf-8');
|
|
3047
|
+
await assert.rejects(writeSessionEnd(cwd, 'sess-history', { binding: established.binding }), (error) => isSessionPointerLaunchAbort(error)
|
|
3048
|
+
&& error.code === 'session_pointer_unusable'
|
|
3049
|
+
&& error.pointerStatus === 'malformed');
|
|
3050
|
+
assert.equal(await readFile(context.sessionPath, 'utf-8'), '{ malformed');
|
|
3051
|
+
assert.equal(existsSync(join(cwd, '.omx', 'logs', 'session-history.jsonl')), false);
|
|
3052
|
+
});
|
|
3053
|
+
}
|
|
3054
|
+
finally {
|
|
3055
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3056
|
+
}
|
|
3057
|
+
});
|
|
3058
|
+
});
|
|
3059
|
+
describe('bound launch authority', () => {
|
|
3060
|
+
it('returns one private binding and only capability-free metadata updates', async () => {
|
|
3061
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-authority-'));
|
|
3062
|
+
try {
|
|
3063
|
+
const established = await establishLaunchSessionBinding(cwd, 'sess-binding-authority');
|
|
3064
|
+
assert.equal(established.kind, 'committed-released');
|
|
3065
|
+
if (established.kind !== 'committed-released')
|
|
3066
|
+
return;
|
|
3067
|
+
assert.match(established.binding.launchLineageToken, /^[A-Za-z0-9_-]{16,128}$/);
|
|
3068
|
+
assert.equal('handle' in established.binding, false);
|
|
3069
|
+
const metadata = await updateDetachedSessionMetadata(established.binding, {
|
|
3070
|
+
tmuxSessionName: 'omx-authority', tmuxPaneId: '%42',
|
|
3071
|
+
});
|
|
3072
|
+
assert.equal(metadata.kind, 'committed-released');
|
|
3073
|
+
assert.equal((await readSessionState(cwd))?.launch_lineage_token, established.binding.launchLineageToken);
|
|
3074
|
+
const firstFinalization = finalizeBoundOnce(established.binding, 'test');
|
|
3075
|
+
const secondFinalization = finalizeBoundOnce(established.binding, 'duplicate');
|
|
3076
|
+
assert.equal(firstFinalization, secondFinalization);
|
|
3077
|
+
const finalized = await firstFinalization;
|
|
3078
|
+
assert.equal(finalized.finalized, true);
|
|
3079
|
+
assert.equal(finalized.cleanup.comparison?.status, 'matched');
|
|
3080
|
+
assert.equal(existsSync(resolveSessionPointerContext(cwd).sessionPath), false);
|
|
3081
|
+
assert.equal((await closeLaunchSessionBindingOnce(established.binding)).status, 'closed');
|
|
3082
|
+
assert.equal((await closeLaunchSessionBindingOnce(established.binding)).status, 'closed');
|
|
3083
|
+
}
|
|
3084
|
+
finally {
|
|
3085
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3086
|
+
}
|
|
3087
|
+
});
|
|
3088
|
+
it('preserves process_identity across detached metadata updates', async (t) => {
|
|
3089
|
+
if (process.platform !== 'linux') {
|
|
3090
|
+
t.skip('Linux v2 process identity publication semantics are under test.');
|
|
3091
|
+
return;
|
|
3092
|
+
}
|
|
3093
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-identity-preserve-'));
|
|
3094
|
+
let binding;
|
|
3095
|
+
try {
|
|
3096
|
+
await withPointerDependencies({
|
|
3097
|
+
runtimePlatform: 'linux',
|
|
3098
|
+
observeProcess: () => matchingObservation('linux'),
|
|
3099
|
+
}, async () => {
|
|
3100
|
+
const established = await establishLaunchSessionBinding(cwd, 'sess-binding-identity-preserve', { platform: 'linux' });
|
|
3101
|
+
assert.equal(established.kind, 'committed-released');
|
|
3102
|
+
if (established.kind !== 'committed-released')
|
|
3103
|
+
return;
|
|
3104
|
+
const establishedBinding = established.binding;
|
|
3105
|
+
binding = establishedBinding;
|
|
3106
|
+
const before = await readSessionState(cwd);
|
|
3107
|
+
assert.ok(before?.process_identity);
|
|
3108
|
+
const metadata = await updateDetachedSessionMetadata(establishedBinding, { tmuxPaneId: '%identity-preserved' });
|
|
3109
|
+
assert.equal(metadata.kind, 'committed-released');
|
|
3110
|
+
const after = await readSessionState(cwd);
|
|
3111
|
+
assert.deepEqual(after?.process_identity, before?.process_identity);
|
|
3112
|
+
assert.equal(after?.identity_schema_version, 2);
|
|
3113
|
+
assert.equal(after?.tmux_pane_id, '%identity-preserved');
|
|
3114
|
+
assert.equal((await finalizeBoundOnce(establishedBinding, 'identity-preserved')).finalized, true);
|
|
3115
|
+
});
|
|
3116
|
+
}
|
|
3117
|
+
finally {
|
|
3118
|
+
if (binding)
|
|
3119
|
+
await closeLaunchSessionBindingOnce(binding).catch(() => { });
|
|
3120
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3121
|
+
}
|
|
3122
|
+
});
|
|
3123
|
+
it('anchors capability acquisition and finalization to context.baseStateDir rather than cwd', async () => {
|
|
3124
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-selected-root-'));
|
|
3125
|
+
let binding;
|
|
3126
|
+
try {
|
|
3127
|
+
const baseStateDir = join(cwd, 'selected', 'state');
|
|
3128
|
+
const context = {
|
|
3129
|
+
cwd,
|
|
3130
|
+
baseStateDir,
|
|
3131
|
+
rootSource: 'cwd-default',
|
|
3132
|
+
sessionPath: join(baseStateDir, 'session.json'),
|
|
3133
|
+
lockPath: join(baseStateDir, 'session.json.lock'),
|
|
3134
|
+
};
|
|
3135
|
+
const established = await establishLaunchSessionBinding(cwd, 'sess-selected-root', { context });
|
|
3136
|
+
assert.equal(established.kind, 'committed-released');
|
|
3137
|
+
if (established.kind !== 'committed-released')
|
|
3138
|
+
return;
|
|
3139
|
+
binding = established.binding;
|
|
3140
|
+
assert.equal(binding.context.baseStateDir, baseStateDir);
|
|
3141
|
+
assert.equal(existsSync(context.sessionPath), true);
|
|
3142
|
+
assert.equal(existsSync(join(cwd, '.omx', 'state', 'session.json')), false);
|
|
3143
|
+
assert.equal((await finalizeBoundOnce(binding, 'selected-root')).finalized, true);
|
|
3144
|
+
assert.equal(existsSync(context.sessionPath), false);
|
|
3145
|
+
}
|
|
3146
|
+
finally {
|
|
3147
|
+
if (binding)
|
|
3148
|
+
await closeLaunchSessionBindingOnce(binding).catch(() => { });
|
|
3149
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3150
|
+
}
|
|
3151
|
+
});
|
|
3152
|
+
it('rejects forged and changed-incarnation detached metadata before lifecycle mutation', async () => {
|
|
3153
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-metadata-hostile-'));
|
|
3154
|
+
let binding;
|
|
3155
|
+
try {
|
|
3156
|
+
const established = await establishLaunchSessionBinding(cwd, 'sess-binding-metadata-hostile');
|
|
3157
|
+
assert.equal(established.kind, 'committed-released');
|
|
3158
|
+
if (established.kind !== 'committed-released')
|
|
3159
|
+
return;
|
|
3160
|
+
binding = established.binding;
|
|
3161
|
+
const context = resolveSessionPointerContext(cwd);
|
|
3162
|
+
const original = await readFile(context.sessionPath, 'utf-8');
|
|
3163
|
+
let mkdirCalls = 0;
|
|
3164
|
+
await withPointerDependencies({
|
|
3165
|
+
fs: { mkdir: async (path, options) => { mkdirCalls += 1; await mkdir(path, options); } },
|
|
3166
|
+
}, async () => {
|
|
3167
|
+
const forged = await updateDetachedSessionMetadata({ ...binding }, { tmuxPaneId: '%forged' });
|
|
3168
|
+
assert.equal(forged.kind, 'precommit-aborted');
|
|
3169
|
+
});
|
|
3170
|
+
assert.equal(mkdirCalls, 0);
|
|
3171
|
+
assert.equal(await readFile(context.sessionPath, 'utf-8'), original);
|
|
3172
|
+
assert.equal(existsSync(context.lockPath), false);
|
|
3173
|
+
const parsed = JSON.parse(original);
|
|
3174
|
+
const state = { ...parsed, session_id: binding.canonicalSessionId };
|
|
3175
|
+
const changed = JSON.stringify({ ...state, started_at: '1999-01-01T00:00:00.000Z' }, null, 2);
|
|
3176
|
+
await writeFile(context.sessionPath, changed, 'utf-8');
|
|
3177
|
+
const denied = await updateDetachedSessionMetadata(binding, { tmuxPaneId: '%changed' });
|
|
3178
|
+
assert.equal(denied.kind, 'precommit-aborted');
|
|
3179
|
+
assert.equal(await readFile(context.sessionPath, 'utf-8'), changed);
|
|
3180
|
+
assert.equal(existsSync(context.lockPath), false);
|
|
3181
|
+
}
|
|
3182
|
+
finally {
|
|
3183
|
+
if (binding)
|
|
3184
|
+
await closeLaunchSessionBindingOnce(binding).catch(() => { });
|
|
3185
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3186
|
+
}
|
|
3187
|
+
});
|
|
3188
|
+
it('rechecks supported directory identity under the metadata lock before pointer mutation', async () => {
|
|
3189
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-metadata-race-'));
|
|
3190
|
+
const retainedCwd = `${cwd}-retained`;
|
|
3191
|
+
let binding;
|
|
3192
|
+
try {
|
|
3193
|
+
const established = await establishLaunchSessionBinding(cwd, 'sess-binding-metadata-race');
|
|
3194
|
+
assert.equal(established.kind, 'committed-released');
|
|
3195
|
+
if (established.kind !== 'committed-released')
|
|
3196
|
+
return;
|
|
3197
|
+
binding = established.binding;
|
|
3198
|
+
const context = resolveSessionPointerContext(cwd);
|
|
3199
|
+
const original = await readFile(context.sessionPath, 'utf-8');
|
|
3200
|
+
let replaced = false;
|
|
3201
|
+
let result;
|
|
3202
|
+
await withPointerDependencies({
|
|
3203
|
+
fs: {
|
|
3204
|
+
mkdir: async (path, options) => {
|
|
3205
|
+
if (!replaced && String(path) === context.baseStateDir) {
|
|
3206
|
+
replaced = true;
|
|
3207
|
+
await rename(cwd, retainedCwd);
|
|
3208
|
+
await mkdir(context.baseStateDir, { recursive: true });
|
|
3209
|
+
await writeFile(context.sessionPath, original, 'utf-8');
|
|
3210
|
+
return;
|
|
3211
|
+
}
|
|
3212
|
+
await mkdir(path, options);
|
|
3213
|
+
},
|
|
3214
|
+
},
|
|
3215
|
+
}, async () => { result = await updateDetachedSessionMetadata(binding, { tmuxPaneId: '%race' }); });
|
|
3216
|
+
assert.equal(result?.kind, 'precommit-aborted');
|
|
3217
|
+
assert.equal(await readFile(context.sessionPath, 'utf-8'), original);
|
|
3218
|
+
assert.equal(await readFile(join(retainedCwd, '.omx', 'state', 'session.json'), 'utf-8'), original);
|
|
3219
|
+
assert.equal(existsSync(context.lockPath), false);
|
|
3220
|
+
}
|
|
3221
|
+
finally {
|
|
3222
|
+
if (binding)
|
|
3223
|
+
await closeLaunchSessionBindingOnce(binding).catch(() => { });
|
|
3224
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3225
|
+
await rm(retainedCwd, { recursive: true, force: true });
|
|
3226
|
+
}
|
|
3227
|
+
});
|
|
3228
|
+
it('permits an ordinary absent launch when directory identity is unsupported', async () => {
|
|
3229
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-unsupported-'));
|
|
3230
|
+
let binding;
|
|
3231
|
+
try {
|
|
3232
|
+
const established = await establishLaunchSessionBinding(cwd, 'sess-binding-unsupported', { platform: 'win32' });
|
|
3233
|
+
assert.equal(established.kind, 'committed-released');
|
|
3234
|
+
if (established.kind !== 'committed-released')
|
|
3235
|
+
return;
|
|
3236
|
+
binding = established.binding;
|
|
3237
|
+
assert.deepEqual(binding.directoryIdentity, { kind: 'unsupported', reason: 'platform' });
|
|
3238
|
+
assert.equal((await readSessionState(cwd))?.session_id, 'sess-binding-unsupported');
|
|
3239
|
+
}
|
|
3240
|
+
finally {
|
|
3241
|
+
if (binding)
|
|
3242
|
+
await closeLaunchSessionBindingOnce(binding).catch(() => { });
|
|
3243
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3244
|
+
}
|
|
3245
|
+
});
|
|
3246
|
+
it('establishment preflight is absent-only under the selected pointer lock and preserves every existing pointer byte', async () => {
|
|
3247
|
+
const root = await mkdtemp(join(tmpdir(), 'omx-session-binding-preflight-'));
|
|
3248
|
+
try {
|
|
3249
|
+
const cases = [
|
|
3250
|
+
{ name: 'usable', raw: JSON.stringify(makeState({ cwd: join(root, 'usable'), pid: process.pid, platform: 'linux', pid_start_ticks: 1 })) },
|
|
3251
|
+
{ name: 'stale-dead', raw: JSON.stringify(makeState({ cwd: join(root, 'stale-dead'), pid: 424242, platform: 'linux', pid_start_ticks: 1 })), probePid: 'dead' },
|
|
3252
|
+
{ name: 'malformed', raw: '{ malformed pointer' },
|
|
3253
|
+
{ name: 'foreign', raw: JSON.stringify(makeState({ cwd: join(root, 'foreign', 'other'), platform: 'win32' })) },
|
|
3254
|
+
{ name: 'indeterminate', raw: JSON.stringify(makeState({ cwd: join(root, 'indeterminate'), pid: 424242, platform: 'linux', pid_start_ticks: 1 })), probePid: 'indeterminate' },
|
|
3255
|
+
];
|
|
3256
|
+
for (const testCase of cases) {
|
|
3257
|
+
const cwd = join(root, testCase.name);
|
|
3258
|
+
const context = resolveSessionPointerContext(cwd);
|
|
3259
|
+
const order = [];
|
|
3260
|
+
await mkdir(context.baseStateDir, { recursive: true });
|
|
3261
|
+
await writeFile(context.sessionPath, testCase.raw, 'utf-8');
|
|
3262
|
+
await withPointerDependencies({
|
|
3263
|
+
probePid: () => testCase.probePid ?? 'alive',
|
|
3264
|
+
observeProcess: () => matchingObservation(),
|
|
3265
|
+
fs: {
|
|
3266
|
+
mkdir: async (path, options) => { order.push(`mkdir:${path}`); await mkdir(path, options); },
|
|
3267
|
+
readFile: async (path, encoding) => { order.push(`read:${path}`); return await readFile(path, encoding); },
|
|
3268
|
+
rename: async (from, to) => { order.push(`rename:${from}:${to}`); await rename(from, to); },
|
|
3269
|
+
unlink: async (path) => { order.push(`unlink:${path}`); await rm(path, { force: false }); },
|
|
3270
|
+
},
|
|
3271
|
+
}, async () => {
|
|
3272
|
+
const established = await establishLaunchSessionBinding(cwd, `sess-preflight-${testCase.name}`);
|
|
3273
|
+
assert.equal(established.kind, 'precommit-aborted', testCase.name);
|
|
3274
|
+
});
|
|
3275
|
+
assert.equal(await readFile(context.sessionPath, 'utf-8'), testCase.raw, testCase.name);
|
|
3276
|
+
assert.ok(order.indexOf(`mkdir:${context.lockPath}`) < order.indexOf(`read:${context.sessionPath}`), testCase.name);
|
|
3277
|
+
assert.equal(order.filter((entry) => entry === `rename:${context.sessionPath}`).length, 0, testCase.name);
|
|
3278
|
+
assert.equal(order.filter((entry) => entry === `unlink:${context.sessionPath}`).length, 0, testCase.name);
|
|
3279
|
+
assert.equal(existsSync(context.lockPath), false, testCase.name);
|
|
3280
|
+
}
|
|
3281
|
+
}
|
|
3282
|
+
finally {
|
|
3283
|
+
await rm(root, { recursive: true, force: true });
|
|
3284
|
+
}
|
|
3285
|
+
});
|
|
3286
|
+
it('denies an absent bound pointer without creating synthetic terminal history', async () => {
|
|
3287
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-absent-finalization-'));
|
|
3288
|
+
let binding;
|
|
3289
|
+
try {
|
|
3290
|
+
const established = await establishLaunchSessionBinding(cwd, 'sess-binding-absent-finalization');
|
|
3291
|
+
assert.equal(established.kind, 'committed-released');
|
|
3292
|
+
if (established.kind !== 'committed-released')
|
|
3293
|
+
return;
|
|
3294
|
+
binding = established.binding;
|
|
3295
|
+
const context = resolveSessionPointerContext(cwd);
|
|
3296
|
+
await rm(context.sessionPath);
|
|
3297
|
+
const finalized = await finalizeBoundOnce(binding, 'absent');
|
|
3298
|
+
assert.equal(finalized.finalized, false);
|
|
3299
|
+
assert.equal(existsSync(join(cwd, '.omx', 'logs', 'session-history.jsonl')), false);
|
|
3300
|
+
assert.equal(existsSync(join(cwd, '.omx', 'logs', `omx-${todayIsoDate()}.jsonl`)), true);
|
|
3301
|
+
}
|
|
3302
|
+
finally {
|
|
3303
|
+
if (binding)
|
|
3304
|
+
await closeLaunchSessionBindingOnce(binding).catch(() => { });
|
|
3305
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3306
|
+
}
|
|
3307
|
+
});
|
|
3308
|
+
it('rejects unbound usable finalization before changing any lifecycle bytes', async () => {
|
|
3309
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-unbound-'));
|
|
3310
|
+
let binding;
|
|
3311
|
+
try {
|
|
3312
|
+
const established = await establishLaunchSessionBinding(cwd, 'sess-binding-unbound');
|
|
3313
|
+
assert.equal(established.kind, 'committed-released');
|
|
3314
|
+
if (established.kind !== 'committed-released')
|
|
3315
|
+
return;
|
|
3316
|
+
binding = established.binding;
|
|
3317
|
+
const context = resolveSessionPointerContext(cwd);
|
|
3318
|
+
const historyPath = join(cwd, '.omx', 'logs', 'session-history.jsonl');
|
|
3319
|
+
const dailyPath = join(cwd, '.omx', 'logs', `omx-${todayIsoDate()}.jsonl`);
|
|
3320
|
+
const hudPath = join(cwd, '.omx', 'state', 'hud-state.json');
|
|
3321
|
+
const metricsPath = join(cwd, '.omx', 'metrics.json');
|
|
3322
|
+
await writeFile(historyPath, 'history-sentinel\n', 'utf-8');
|
|
3323
|
+
await writeFile(hudPath, 'hud-sentinel\n', 'utf-8');
|
|
3324
|
+
await writeFile(metricsPath, 'metrics-sentinel\n', 'utf-8');
|
|
3325
|
+
const before = {
|
|
3326
|
+
pointer: await readFile(context.sessionPath, 'utf-8'),
|
|
3327
|
+
history: await readFile(historyPath, 'utf-8'),
|
|
3328
|
+
daily: await readFile(dailyPath, 'utf-8'),
|
|
3329
|
+
hud: await readFile(hudPath, 'utf-8'),
|
|
3330
|
+
metrics: await readFile(metricsPath, 'utf-8'),
|
|
3331
|
+
};
|
|
3332
|
+
await assert.rejects(() => writeSessionEnd(cwd, 'sess-binding-unbound'), isSessionPointerLaunchAbort);
|
|
3333
|
+
assert.equal(await readFile(context.sessionPath, 'utf-8'), before.pointer);
|
|
3334
|
+
assert.equal(await readFile(historyPath, 'utf-8'), before.history);
|
|
3335
|
+
assert.equal(await readFile(dailyPath, 'utf-8'), before.daily);
|
|
3336
|
+
assert.equal(await readFile(hudPath, 'utf-8'), before.hud);
|
|
3337
|
+
assert.equal(await readFile(metricsPath, 'utf-8'), before.metrics);
|
|
3338
|
+
assert.equal(existsSync(context.lockPath), false);
|
|
3339
|
+
}
|
|
3340
|
+
finally {
|
|
3341
|
+
if (binding)
|
|
3342
|
+
await closeLaunchSessionBindingOnce(binding).catch(() => { });
|
|
3343
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3344
|
+
}
|
|
3345
|
+
});
|
|
3346
|
+
it('preserves a compatible tokenless pointer and refuses to establish cleanup authority from it', async () => {
|
|
3347
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-tokenless-'));
|
|
3348
|
+
try {
|
|
3349
|
+
const context = resolveSessionPointerContext(cwd);
|
|
3350
|
+
await reconcileNativeSessionStart(cwd, 'native-compatible-tokenless');
|
|
3351
|
+
const beforeUpdate = await readFile(context.sessionPath, 'utf-8');
|
|
3352
|
+
await assert.rejects(() => writeSessionStart(cwd, 'native-compatible-tokenless', { tmuxPaneId: '%42' }), isSessionPointerLaunchAbort);
|
|
3353
|
+
assert.equal((await readSessionState(cwd))?.launch_lineage_token, undefined);
|
|
3354
|
+
const afterUpdate = await readFile(context.sessionPath, 'utf-8');
|
|
3355
|
+
assert.equal(afterUpdate, beforeUpdate);
|
|
3356
|
+
const established = await establishLaunchSessionBinding(cwd, 'native-compatible-tokenless');
|
|
3357
|
+
assert.equal(established.kind, 'precommit-aborted');
|
|
3358
|
+
if (established.kind !== 'precommit-aborted')
|
|
3359
|
+
return;
|
|
3360
|
+
assert.equal(established.abort.committed, false);
|
|
3361
|
+
assert.equal(established.abort.code, 'session_pointer_owner_conflict');
|
|
3362
|
+
assert.equal((await readSessionState(cwd))?.launch_lineage_token, undefined);
|
|
3363
|
+
assert.equal(afterUpdate.includes('launch_lineage_token'), false);
|
|
3364
|
+
assert.equal(await readFile(context.sessionPath, 'utf-8'), afterUpdate);
|
|
3365
|
+
}
|
|
3366
|
+
finally {
|
|
3367
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3368
|
+
}
|
|
3369
|
+
});
|
|
3370
|
+
it('preserves a compatible malformed lineage token without granting cleanup authority', async () => {
|
|
3371
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-malformed-token-'));
|
|
3372
|
+
try {
|
|
3373
|
+
const context = resolveSessionPointerContext(cwd);
|
|
3374
|
+
const tokenless = await reconcileNativeSessionStart(cwd, 'native-compatible-malformed-token');
|
|
3375
|
+
await writeFile(context.sessionPath, JSON.stringify({ ...tokenless, launch_lineage_token: 'bad' }), 'utf-8');
|
|
3376
|
+
const beforeUpdate = await readFile(context.sessionPath, 'utf-8');
|
|
3377
|
+
await assert.rejects(() => writeSessionStart(cwd, 'native-compatible-malformed-token', { tmuxPaneId: '%42' }), isSessionPointerLaunchAbort);
|
|
3378
|
+
const afterUpdate = await readFile(context.sessionPath, 'utf-8');
|
|
3379
|
+
assert.equal(afterUpdate, beforeUpdate);
|
|
3380
|
+
assert.match(afterUpdate, /"launch_lineage_token":"bad"|"launch_lineage_token": "bad"/);
|
|
3381
|
+
assert.equal((await reconcileNativeSessionStart(cwd, 'native-compatible-malformed-token')).launch_lineage_token, 'bad');
|
|
3382
|
+
const beforeEstablishment = await readFile(context.sessionPath, 'utf-8');
|
|
3383
|
+
const established = await establishLaunchSessionBinding(cwd, 'native-compatible-malformed-token');
|
|
3384
|
+
assert.equal(established.kind, 'precommit-aborted');
|
|
3385
|
+
if (established.kind !== 'precommit-aborted')
|
|
3386
|
+
return;
|
|
3387
|
+
assert.equal(established.abort.committed, false);
|
|
3388
|
+
assert.equal(await readFile(context.sessionPath, 'utf-8'), beforeEstablishment);
|
|
3389
|
+
}
|
|
3390
|
+
finally {
|
|
3391
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3392
|
+
}
|
|
3393
|
+
});
|
|
3394
|
+
it('preserves malformed pointer bytes when binding establishment aborts before commit', async () => {
|
|
3395
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-malformed-'));
|
|
3396
|
+
try {
|
|
3397
|
+
const context = resolveSessionPointerContext(cwd);
|
|
3398
|
+
const malformed = '{ malformed pointer';
|
|
3399
|
+
await mkdir(context.baseStateDir, { recursive: true });
|
|
3400
|
+
await writeFile(context.sessionPath, malformed, 'utf-8');
|
|
3401
|
+
const established = await establishLaunchSessionBinding(cwd, 'sess-binding-malformed');
|
|
3402
|
+
assert.equal(established.kind, 'precommit-aborted');
|
|
3403
|
+
if (established.kind !== 'precommit-aborted')
|
|
3404
|
+
return;
|
|
3405
|
+
assert.equal(established.abort.committed, false);
|
|
3406
|
+
assert.equal(established.abort.code, 'session_pointer_unusable');
|
|
3407
|
+
assert.equal(established.abort.pointerStatus, 'malformed');
|
|
3408
|
+
assert.equal(await readFile(context.sessionPath, 'utf-8'), malformed);
|
|
727
3409
|
}
|
|
728
3410
|
finally {
|
|
729
3411
|
await rm(cwd, { recursive: true, force: true });
|
|
730
3412
|
}
|
|
731
3413
|
});
|
|
732
|
-
it('
|
|
733
|
-
const
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
name: 'pointer-read',
|
|
749
|
-
operation: 'pointer-read',
|
|
750
|
-
dependencies: (context) => ({
|
|
751
|
-
token: () => TEST_TOKEN,
|
|
752
|
-
fs: {
|
|
753
|
-
readFile: async (path, encoding) => {
|
|
754
|
-
if (path === context.sessionPath)
|
|
755
|
-
throw new Error('pointer read failure');
|
|
756
|
-
return await readFile(path, encoding);
|
|
757
|
-
},
|
|
758
|
-
},
|
|
759
|
-
}),
|
|
760
|
-
},
|
|
761
|
-
{
|
|
762
|
-
name: 'owner-sync',
|
|
763
|
-
operation: 'lock-owner-publish',
|
|
764
|
-
dependencies: (context) => ({
|
|
765
|
-
token: () => TEST_TOKEN,
|
|
766
|
-
fs: {
|
|
767
|
-
openAndSync: async (path) => {
|
|
768
|
-
if (path === join(context.lockPath, `owner.${TEST_TOKEN}.tmp`))
|
|
769
|
-
throw new Error('owner sync failure');
|
|
770
|
-
},
|
|
771
|
-
},
|
|
772
|
-
}),
|
|
773
|
-
},
|
|
774
|
-
{
|
|
775
|
-
name: 'owner-rename',
|
|
776
|
-
operation: 'lock-owner-publish',
|
|
777
|
-
dependencies: (context) => ({
|
|
778
|
-
token: () => TEST_TOKEN,
|
|
779
|
-
fs: {
|
|
780
|
-
rename: async (from, to) => {
|
|
781
|
-
if (from === join(context.lockPath, `owner.${TEST_TOKEN}.tmp`))
|
|
782
|
-
throw new Error('owner rename failure');
|
|
783
|
-
await rename(from, to);
|
|
784
|
-
},
|
|
785
|
-
},
|
|
786
|
-
}),
|
|
787
|
-
},
|
|
788
|
-
];
|
|
789
|
-
for (const failure of failures) {
|
|
790
|
-
const cwd = await mkdtemp(join(tmpdir(), `omx-session-${failure.name}-`));
|
|
791
|
-
try {
|
|
792
|
-
const context = resolveSessionPointerContext(cwd);
|
|
793
|
-
await withPointerDependencies(failure.dependencies(context), async () => {
|
|
794
|
-
await assert.rejects(writeSessionStart(cwd, 'sess-precommit', { platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
|
|
795
|
-
&& error.code === 'session_pointer_io_failure'
|
|
796
|
-
&& error.operation === failure.operation);
|
|
797
|
-
});
|
|
798
|
-
assert.equal(existsSync(context.lockPath), false);
|
|
799
|
-
}
|
|
800
|
-
finally {
|
|
801
|
-
await rm(cwd, { recursive: true, force: true });
|
|
802
|
-
}
|
|
3414
|
+
it('finalizes only its exact stale-dead native pointer after history is retained', async () => {
|
|
3415
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-stale-dead-'));
|
|
3416
|
+
try {
|
|
3417
|
+
const established = await establishLaunchSessionBinding(cwd, 'sess-binding-stale-dead');
|
|
3418
|
+
assert.equal(established.kind, 'committed-released');
|
|
3419
|
+
if (established.kind !== 'committed-released')
|
|
3420
|
+
return;
|
|
3421
|
+
await reconcileNativeSessionStart(cwd, 'native-binding-stale-dead', { pid: 424242 });
|
|
3422
|
+
await withPointerDependencies({ probePid: () => 'dead' }, async () => {
|
|
3423
|
+
const finalized = await finalizeBoundOnce(established.binding, 'test');
|
|
3424
|
+
assert.equal(finalized.finalized, true);
|
|
3425
|
+
});
|
|
3426
|
+
await closeLaunchSessionBindingOnce(established.binding);
|
|
3427
|
+
assert.equal(existsSync(resolveSessionPointerContext(cwd).sessionPath), false);
|
|
3428
|
+
const history = await readFile(join(cwd, '.omx', 'logs', 'session-history.jsonl'), 'utf-8');
|
|
3429
|
+
assert.match(history, /native-binding-stale-dead/);
|
|
803
3430
|
}
|
|
804
|
-
|
|
3431
|
+
finally {
|
|
3432
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3433
|
+
}
|
|
3434
|
+
});
|
|
3435
|
+
it('preserves a stale-dead pointer whose bound lineage token does not match', async () => {
|
|
3436
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-stale-mismatch-'));
|
|
3437
|
+
let binding;
|
|
805
3438
|
try {
|
|
3439
|
+
const established = await establishLaunchSessionBinding(cwd, 'sess-binding-stale-mismatch');
|
|
3440
|
+
assert.equal(established.kind, 'committed-released');
|
|
3441
|
+
if (established.kind !== 'committed-released')
|
|
3442
|
+
return;
|
|
3443
|
+
binding = established.binding;
|
|
806
3444
|
const context = resolveSessionPointerContext(cwd);
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
3445
|
+
const state = await reconcileNativeSessionStart(cwd, 'native-binding-stale-mismatch', { pid: 424242 });
|
|
3446
|
+
await writeFile(context.sessionPath, JSON.stringify({ ...state, launch_lineage_token: FOREIGN_TOKEN }), 'utf-8');
|
|
3447
|
+
await withPointerDependencies({ probePid: () => 'dead' }, async () => {
|
|
3448
|
+
const denied = await finalizeBoundOnce(binding, 'test');
|
|
3449
|
+
assert.equal(denied.finalized, false);
|
|
811
3450
|
});
|
|
812
|
-
assert.equal(existsSync(context.
|
|
813
|
-
assert.equal(existsSync(
|
|
3451
|
+
assert.equal(existsSync(context.sessionPath), true);
|
|
3452
|
+
assert.equal(existsSync(join(cwd, '.omx', 'logs', 'session-history.jsonl')), false);
|
|
814
3453
|
}
|
|
815
3454
|
finally {
|
|
3455
|
+
if (binding)
|
|
3456
|
+
await closeLaunchSessionBindingOnce(binding).catch(() => { });
|
|
816
3457
|
await rm(cwd, { recursive: true, force: true });
|
|
817
3458
|
}
|
|
818
3459
|
});
|
|
819
|
-
it('
|
|
820
|
-
const
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
3460
|
+
it('finalizes an exact bound current process when identity observation is unavailable', async () => {
|
|
3461
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-indeterminate-current-'));
|
|
3462
|
+
let binding;
|
|
3463
|
+
try {
|
|
3464
|
+
const established = await establishLaunchSessionBinding(cwd, 'sess-binding-indeterminate-current');
|
|
3465
|
+
assert.equal(established.kind, 'committed-released');
|
|
3466
|
+
if (established.kind !== 'committed-released')
|
|
3467
|
+
return;
|
|
3468
|
+
binding = established.binding;
|
|
3469
|
+
await withPointerDependencies({
|
|
3470
|
+
probePid: () => 'indeterminate',
|
|
3471
|
+
observeProcess: () => ({ kind: 'error' }),
|
|
3472
|
+
}, async () => {
|
|
3473
|
+
await writeSessionEnd(cwd, binding.canonicalSessionId, { binding });
|
|
3474
|
+
});
|
|
3475
|
+
assert.equal(await readSessionState(cwd), null);
|
|
3476
|
+
}
|
|
3477
|
+
finally {
|
|
3478
|
+
if (binding)
|
|
3479
|
+
await closeLaunchSessionBindingOnce(binding).catch(() => { });
|
|
3480
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3481
|
+
}
|
|
3482
|
+
});
|
|
3483
|
+
it('preserves usable and stale bound pointers without the exact valid lineage token before any mutation', async () => {
|
|
3484
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-token-gate-'));
|
|
3485
|
+
let binding;
|
|
3486
|
+
try {
|
|
3487
|
+
const established = await establishLaunchSessionBinding(cwd, 'sess-binding-token-gate');
|
|
3488
|
+
assert.equal(established.kind, 'committed-released');
|
|
3489
|
+
if (established.kind !== 'committed-released')
|
|
3490
|
+
return;
|
|
3491
|
+
binding = established.binding;
|
|
3492
|
+
const context = resolveSessionPointerContext(cwd);
|
|
3493
|
+
const state = await readSessionState(cwd);
|
|
3494
|
+
assert.ok(state);
|
|
3495
|
+
const historyPath = join(cwd, '.omx', 'logs', 'session-history.jsonl');
|
|
3496
|
+
const dailyPath = join(cwd, '.omx', 'logs', `omx-${todayIsoDate()}.jsonl`);
|
|
3497
|
+
const hudPath = join(cwd, '.omx', 'state', 'hud-state.json');
|
|
3498
|
+
const metricsPath = join(cwd, '.omx', 'metrics.json');
|
|
3499
|
+
await writeFile(historyPath, 'history-sentinel\n', 'utf-8');
|
|
3500
|
+
await writeFile(hudPath, 'hud-sentinel\n', 'utf-8');
|
|
3501
|
+
await writeFile(metricsPath, 'metrics-sentinel\n', 'utf-8');
|
|
3502
|
+
const lifecycleBytes = {
|
|
3503
|
+
history: await readFile(historyPath, 'utf-8'),
|
|
3504
|
+
daily: await readFile(dailyPath, 'utf-8'),
|
|
3505
|
+
hud: await readFile(hudPath, 'utf-8'),
|
|
3506
|
+
metrics: await readFile(metricsPath, 'utf-8'),
|
|
3507
|
+
};
|
|
3508
|
+
const cases = [
|
|
3509
|
+
{ name: 'tokenless usable', state: (() => { const { launch_lineage_token: _, ...tokenless } = state; return tokenless; })() },
|
|
3510
|
+
{ name: 'malformed usable', state: { ...state, launch_lineage_token: 'bad' } },
|
|
3511
|
+
{ name: 'mismatched usable', state: { ...state, launch_lineage_token: FOREIGN_TOKEN } },
|
|
3512
|
+
{ name: 'replaced usable', state: { ...state, launch_lineage_token: SUCCESSOR_TOKEN } },
|
|
3513
|
+
{ name: 'same-token changed incarnation', state: { ...state, started_at: '1999-01-01T00:00:00.000Z' } },
|
|
3514
|
+
{ name: 'unknown identity', state: { ...state, pid: 424242 }, probePid: 'indeterminate' },
|
|
3515
|
+
{
|
|
3516
|
+
name: 'tokenless stale native',
|
|
3517
|
+
state: (() => {
|
|
3518
|
+
const { launch_lineage_token: _, ...tokenless } = state;
|
|
3519
|
+
return { ...tokenless, native_session_id: 'native-tokenless-stale', pid: 424242 };
|
|
3520
|
+
})(),
|
|
3521
|
+
probePid: 'dead',
|
|
3522
|
+
},
|
|
3523
|
+
];
|
|
3524
|
+
for (const testCase of cases) {
|
|
3525
|
+
const raw = JSON.stringify(testCase.state);
|
|
3526
|
+
let mkdirCalls = 0;
|
|
3527
|
+
let unlinkCalls = 0;
|
|
3528
|
+
await writeFile(context.sessionPath, raw, 'utf-8');
|
|
830
3529
|
await withPointerDependencies({
|
|
831
|
-
|
|
3530
|
+
probePid: () => testCase.probePid ?? 'alive',
|
|
832
3531
|
fs: {
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
throw new Error('pointer write failure');
|
|
837
|
-
},
|
|
838
|
-
openAndSync: async (path) => {
|
|
839
|
-
if (phase.fail === 'sync' && path === tempPath)
|
|
840
|
-
throw new Error('pointer sync failure');
|
|
841
|
-
},
|
|
842
|
-
rename: async (from, to) => {
|
|
843
|
-
if (phase.fail === 'rename' && from === tempPath && to === context.sessionPath) {
|
|
844
|
-
throw new Error('pointer rename failure');
|
|
845
|
-
}
|
|
846
|
-
await rename(from, to);
|
|
847
|
-
},
|
|
3532
|
+
mkdir: async (path, options) => { mkdirCalls += 1; await mkdir(path, options); },
|
|
3533
|
+
unlink: async (path) => { if (path === context.sessionPath)
|
|
3534
|
+
unlinkCalls += 1; await rm(path, { force: false }); },
|
|
848
3535
|
},
|
|
849
3536
|
}, async () => {
|
|
850
|
-
await assert.rejects(
|
|
851
|
-
&& error.code === 'session_pointer_io_failure'
|
|
852
|
-
&& error.operation === phase.operation);
|
|
3537
|
+
await assert.rejects(writeSessionEnd(cwd, binding.canonicalSessionId, { binding }), isSessionPointerLaunchAbort);
|
|
853
3538
|
});
|
|
854
|
-
assert.equal(
|
|
855
|
-
assert.
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
await
|
|
3539
|
+
assert.equal(await readFile(context.sessionPath, 'utf-8'), raw, testCase.name);
|
|
3540
|
+
assert.ok(mkdirCalls <= 1, testCase.name);
|
|
3541
|
+
assert.equal(unlinkCalls, 0, testCase.name);
|
|
3542
|
+
assert.equal(existsSync(context.lockPath), false, testCase.name);
|
|
3543
|
+
assert.equal(await readFile(historyPath, 'utf-8'), lifecycleBytes.history, testCase.name);
|
|
3544
|
+
assert.equal(await readFile(dailyPath, 'utf-8'), lifecycleBytes.daily, testCase.name);
|
|
3545
|
+
assert.equal(await readFile(hudPath, 'utf-8'), lifecycleBytes.hud, testCase.name);
|
|
3546
|
+
assert.equal(await readFile(metricsPath, 'utf-8'), lifecycleBytes.metrics, testCase.name);
|
|
859
3547
|
}
|
|
860
3548
|
}
|
|
3549
|
+
finally {
|
|
3550
|
+
if (binding)
|
|
3551
|
+
await closeLaunchSessionBindingOnce(binding).catch(() => { });
|
|
3552
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3553
|
+
}
|
|
861
3554
|
});
|
|
862
|
-
it('
|
|
863
|
-
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-
|
|
3555
|
+
it('denies selected-root replacement under lock before pointer publication', async () => {
|
|
3556
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-establish-root-race-'));
|
|
3557
|
+
const displacedRoot = join(cwd, 'displaced-state');
|
|
864
3558
|
try {
|
|
865
3559
|
const context = resolveSessionPointerContext(cwd);
|
|
866
|
-
|
|
3560
|
+
let replaced = false;
|
|
3561
|
+
let established;
|
|
867
3562
|
await withPointerDependencies({
|
|
868
|
-
token: () => TEST_TOKEN,
|
|
869
3563
|
fs: {
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
throw new Error('pointer temp cleanup failure');
|
|
878
|
-
await rm(path, { force: false });
|
|
879
|
-
},
|
|
880
|
-
rmdir: async (path) => {
|
|
881
|
-
if (path.endsWith(`.release-${TEST_TOKEN}`))
|
|
882
|
-
throw new Error('release cleanup failure');
|
|
883
|
-
await rm(path, { recursive: false });
|
|
3564
|
+
openAndSync: async (path) => {
|
|
3565
|
+
if (!replaced && path.startsWith(`${context.sessionPath}.tmp-`)) {
|
|
3566
|
+
replaced = true;
|
|
3567
|
+
await rename(context.baseStateDir, displacedRoot);
|
|
3568
|
+
await mkdir(context.baseStateDir, { recursive: true });
|
|
3569
|
+
}
|
|
3570
|
+
return 'synced';
|
|
884
3571
|
},
|
|
885
3572
|
},
|
|
886
|
-
}, async () => {
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
&& error.secondaryFailures?.[0]?.evidencePath === tempPath);
|
|
892
|
-
});
|
|
893
|
-
assert.equal(existsSync(tempPath), true);
|
|
894
|
-
assert.equal(existsSync(`${context.lockPath}.release-${TEST_TOKEN}`), true);
|
|
3573
|
+
}, async () => { established = await establishLaunchSessionBinding(cwd, 'sess-establish-root-race'); });
|
|
3574
|
+
assert.ok(established);
|
|
3575
|
+
assert.notEqual(established.kind, 'committed-released');
|
|
3576
|
+
assert.equal(existsSync(context.sessionPath), false);
|
|
3577
|
+
assert.equal(existsSync(join(displacedRoot, 'session.json')), false);
|
|
895
3578
|
}
|
|
896
3579
|
finally {
|
|
897
3580
|
await rm(cwd, { recursive: true, force: true });
|
|
898
3581
|
}
|
|
899
3582
|
});
|
|
900
|
-
it('
|
|
901
|
-
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-
|
|
3583
|
+
it('returns committed blocked evidence when selected root changes after pointer rename', async () => {
|
|
3584
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-establish-post-rename-race-'));
|
|
3585
|
+
const displacedRoot = join(cwd, 'displaced-after-rename');
|
|
902
3586
|
try {
|
|
903
3587
|
const context = resolveSessionPointerContext(cwd);
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
await mkdir(context.baseStateDir, { recursive: true });
|
|
907
|
-
await writeFile(foreignTempPath, 'foreign transaction evidence', 'utf-8');
|
|
3588
|
+
let replaced = false;
|
|
3589
|
+
let established;
|
|
908
3590
|
await withPointerDependencies({
|
|
909
|
-
token: () => TEST_TOKEN,
|
|
910
3591
|
fs: {
|
|
911
|
-
writeFile: async (path, data, options) => {
|
|
912
|
-
await writeFile(path, data, options);
|
|
913
|
-
if (path === ownTempPath)
|
|
914
|
-
throw new Error('pointer write failure');
|
|
915
|
-
},
|
|
916
|
-
unlink: async (path) => {
|
|
917
|
-
if (path === ownTempPath)
|
|
918
|
-
throw new Error('keep own residue');
|
|
919
|
-
await rm(path, { force: false });
|
|
920
|
-
},
|
|
921
3592
|
rename: async (from, to) => {
|
|
922
|
-
if (from === context.lockPath)
|
|
923
|
-
throw new Error('preserve canonical lock');
|
|
924
3593
|
await rename(from, to);
|
|
3594
|
+
if (!replaced && to === context.sessionPath) {
|
|
3595
|
+
replaced = true;
|
|
3596
|
+
await rename(context.baseStateDir, displacedRoot);
|
|
3597
|
+
await mkdir(context.baseStateDir, { recursive: true });
|
|
3598
|
+
}
|
|
925
3599
|
},
|
|
926
3600
|
},
|
|
927
|
-
}, async () => {
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
assert.equal(existsSync(
|
|
931
|
-
assert.equal(existsSync(
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
await rm(
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
3601
|
+
}, async () => { established = await establishLaunchSessionBinding(cwd, 'sess-establish-post-rename-race'); });
|
|
3602
|
+
assert.ok(established);
|
|
3603
|
+
assert.equal(established.kind, 'committed-release-failed');
|
|
3604
|
+
assert.equal(existsSync(context.sessionPath), false);
|
|
3605
|
+
assert.equal(existsSync(join(displacedRoot, 'session.json')), true);
|
|
3606
|
+
}
|
|
3607
|
+
finally {
|
|
3608
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3609
|
+
}
|
|
3610
|
+
});
|
|
3611
|
+
it('denies same-path replacement before creating state, lock, history, or HUD artifacts', async () => {
|
|
3612
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-replaced-path-'));
|
|
3613
|
+
const retainedCwd = `${cwd}-retained`;
|
|
3614
|
+
let binding;
|
|
3615
|
+
try {
|
|
3616
|
+
const established = await establishLaunchSessionBinding(cwd, 'sess-binding-replaced-path');
|
|
3617
|
+
assert.equal(established.kind, 'committed-released');
|
|
3618
|
+
if (established.kind !== 'committed-released')
|
|
3619
|
+
return;
|
|
3620
|
+
binding = established.binding;
|
|
3621
|
+
await rename(cwd, retainedCwd);
|
|
3622
|
+
await mkdir(cwd);
|
|
3623
|
+
const context = resolveSessionPointerContext(cwd);
|
|
3624
|
+
const finalized = await finalizeBoundOnce(binding, 'test');
|
|
3625
|
+
assert.equal(finalized.finalized, false);
|
|
3626
|
+
assert.equal(finalized.cleanup.comparison?.status, 'denied');
|
|
3627
|
+
assert.equal(existsSync(context.baseStateDir), false);
|
|
941
3628
|
assert.equal(existsSync(context.lockPath), false);
|
|
3629
|
+
assert.equal(existsSync(join(cwd, '.omx', 'logs', 'session-history.jsonl')), false);
|
|
3630
|
+
assert.equal(existsSync(join(cwd, '.omx', 'state', 'hud-state.json')), false);
|
|
942
3631
|
}
|
|
943
3632
|
finally {
|
|
3633
|
+
if (binding)
|
|
3634
|
+
await closeLaunchSessionBindingOnce(binding).catch(() => { });
|
|
944
3635
|
await rm(cwd, { recursive: true, force: true });
|
|
3636
|
+
await rm(retainedCwd, { recursive: true, force: true });
|
|
945
3637
|
}
|
|
946
3638
|
});
|
|
947
|
-
it('
|
|
948
|
-
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-
|
|
3639
|
+
it('keeps native absent reconciliation tokenless without backfill', async () => {
|
|
3640
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-native-tokenless-'));
|
|
949
3641
|
try {
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
await
|
|
956
|
-
|
|
3642
|
+
assert.equal((await reconcileNativeSessionStart(cwd, 'native-tokenless')).launch_lineage_token, undefined);
|
|
3643
|
+
assert.equal((await reconcileNativeSessionStart(cwd, 'native-tokenless')).launch_lineage_token, undefined);
|
|
3644
|
+
assert.equal((await readSessionState(cwd))?.launch_lineage_token, undefined);
|
|
3645
|
+
}
|
|
3646
|
+
finally {
|
|
3647
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3648
|
+
}
|
|
3649
|
+
});
|
|
3650
|
+
it('allows authorized unsupported-capability stale-dead finalization without weakening bound lineage checks', async () => {
|
|
3651
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-unsupported-lifecycle-'));
|
|
3652
|
+
let ordinary;
|
|
3653
|
+
let stale;
|
|
3654
|
+
try {
|
|
3655
|
+
await withPointerDependencies({
|
|
3656
|
+
runtimePlatform: 'win32',
|
|
3657
|
+
observeProcess: () => matchingWin32Observation(),
|
|
3658
|
+
}, async () => {
|
|
3659
|
+
const ordinaryEstablished = await establishLaunchSessionBinding(cwd, 'sess-unsupported-ordinary', { platform: 'win32' });
|
|
3660
|
+
assert.equal(ordinaryEstablished.kind, 'committed-released');
|
|
3661
|
+
if (ordinaryEstablished.kind !== 'committed-released')
|
|
3662
|
+
return;
|
|
3663
|
+
ordinary = ordinaryEstablished.binding;
|
|
3664
|
+
assert.deepEqual(ordinary.directoryIdentity, { kind: 'unsupported', reason: 'platform' });
|
|
3665
|
+
const ordinaryFinalized = await finalizeBoundOnce(ordinary, 'ordinary-unsupported');
|
|
3666
|
+
assert.equal(ordinaryFinalized.finalized, true);
|
|
3667
|
+
assert.equal(existsSync(resolveSessionPointerContext(cwd).sessionPath), false);
|
|
3668
|
+
assert.equal((await closeLaunchSessionBindingOnce(ordinary)).status, 'closed');
|
|
3669
|
+
const staleEstablished = await establishLaunchSessionBinding(cwd, 'sess-unsupported-stale', {
|
|
3670
|
+
nativeSessionId: 'native-unsupported-stale',
|
|
957
3671
|
platform: 'win32',
|
|
958
|
-
ownerAliasVerified: true,
|
|
959
3672
|
});
|
|
960
|
-
assert.equal(
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
3673
|
+
assert.equal(staleEstablished.kind, 'committed-released');
|
|
3674
|
+
if (staleEstablished.kind !== 'committed-released')
|
|
3675
|
+
return;
|
|
3676
|
+
stale = staleEstablished.binding;
|
|
3677
|
+
await reconcileNativeSessionStart(cwd, 'native-unsupported-stale', { pid: 424242, platform: 'win32' });
|
|
3678
|
+
const context = resolveSessionPointerContext(cwd);
|
|
3679
|
+
const historyPath = join(cwd, '.omx', 'logs', 'session-history.jsonl');
|
|
3680
|
+
await withPointerDependencies({
|
|
3681
|
+
runtimePlatform: 'win32',
|
|
3682
|
+
observeProcess: () => matchingWin32Observation(),
|
|
3683
|
+
probePid: () => 'dead',
|
|
3684
|
+
}, async () => {
|
|
3685
|
+
const finalized = await finalizeBoundOnce(stale, 'stale-unsupported');
|
|
3686
|
+
assert.equal(finalized.finalized, true);
|
|
3687
|
+
assert.equal(finalized.cleanup.comparison?.status, 'matched');
|
|
3688
|
+
assert.match(finalized.cleanup.comparison?.reason ?? '', /unsupported-directory-capability:platform/);
|
|
965
3689
|
});
|
|
966
|
-
assert.equal(
|
|
967
|
-
|
|
3690
|
+
assert.equal(existsSync(context.sessionPath), false);
|
|
3691
|
+
const history = await readFile(historyPath, 'utf-8');
|
|
3692
|
+
assert.match(history, /native-unsupported-stale/);
|
|
968
3693
|
});
|
|
969
3694
|
}
|
|
970
3695
|
finally {
|
|
3696
|
+
if (ordinary)
|
|
3697
|
+
await closeLaunchSessionBindingOnce(ordinary).catch(() => { });
|
|
3698
|
+
if (stale)
|
|
3699
|
+
await closeLaunchSessionBindingOnce(stale).catch(() => { });
|
|
971
3700
|
await rm(cwd, { recursive: true, force: true });
|
|
972
3701
|
}
|
|
973
3702
|
});
|
|
974
|
-
it('preserves
|
|
975
|
-
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-
|
|
3703
|
+
it('preserves native lineage and tmux metadata when rejecting a live native-ID replacement', async () => {
|
|
3704
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-native-lineage-metadata-'));
|
|
3705
|
+
let binding;
|
|
3706
|
+
try {
|
|
3707
|
+
const established = await establishLaunchSessionBinding(cwd, 'sess-native-lineage-metadata', {
|
|
3708
|
+
nativeSessionId: 'native-before',
|
|
3709
|
+
tmuxSessionName: 'omx-native-lineage',
|
|
3710
|
+
tmuxPaneId: '%88',
|
|
3711
|
+
});
|
|
3712
|
+
assert.equal(established.kind, 'committed-released');
|
|
3713
|
+
if (established.kind !== 'committed-released')
|
|
3714
|
+
return;
|
|
3715
|
+
binding = established.binding;
|
|
3716
|
+
await assert.rejects(reconcileNativeSessionStart(cwd, 'native-after', { pid: process.pid }), isOwnerConflict);
|
|
3717
|
+
const persisted = await readSessionState(cwd);
|
|
3718
|
+
assert.equal(persisted?.session_id, binding.canonicalSessionId);
|
|
3719
|
+
assert.equal(persisted?.native_session_id, 'native-before');
|
|
3720
|
+
assert.equal(persisted?.previous_native_session_id, undefined);
|
|
3721
|
+
assert.equal(persisted?.launch_lineage_token, binding.launchLineageToken);
|
|
3722
|
+
assert.equal(persisted?.tmux_session_name, 'omx-native-lineage');
|
|
3723
|
+
assert.equal(persisted?.tmux_pane_id, '%88');
|
|
3724
|
+
}
|
|
3725
|
+
finally {
|
|
3726
|
+
if (binding)
|
|
3727
|
+
await closeLaunchSessionBindingOnce(binding).catch(() => { });
|
|
3728
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3729
|
+
}
|
|
3730
|
+
});
|
|
3731
|
+
it('reports owner unlink residue and does not rmdir it', async () => {
|
|
3732
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-owner-unlink-'));
|
|
976
3733
|
try {
|
|
977
3734
|
const context = resolveSessionPointerContext(cwd);
|
|
978
|
-
|
|
979
|
-
await
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
3735
|
+
let rmdirCalls = 0;
|
|
3736
|
+
await withPointerDependencies({
|
|
3737
|
+
token: () => TEST_TOKEN,
|
|
3738
|
+
fs: {
|
|
3739
|
+
unlink: async (path) => {
|
|
3740
|
+
if (path.endsWith(`.release-${TEST_TOKEN}/owner.json`))
|
|
3741
|
+
throw new Error('owner unlink failure');
|
|
3742
|
+
await rm(path, { force: false });
|
|
3743
|
+
},
|
|
3744
|
+
rmdir: async (path) => { rmdirCalls += 1; await rm(path, { recursive: false }); },
|
|
3745
|
+
},
|
|
3746
|
+
}, async () => {
|
|
3747
|
+
const result = await establishLaunchSessionBinding(cwd, 'sess-owner-unlink');
|
|
3748
|
+
assert.equal(result.kind, 'committed-release-failed');
|
|
3749
|
+
if (result.kind === 'committed-release-failed') {
|
|
3750
|
+
assert.equal(result.secondaryFailures[0]?.phase, 'remove-release-owner');
|
|
3751
|
+
assert.equal(result.lockDisposition, 'released-with-residue');
|
|
3752
|
+
}
|
|
3753
|
+
});
|
|
3754
|
+
assert.equal(rmdirCalls, 0);
|
|
3755
|
+
assert.equal(existsSync(`${context.lockPath}.release-${TEST_TOKEN}`), true);
|
|
991
3756
|
}
|
|
992
3757
|
finally {
|
|
993
3758
|
await rm(cwd, { recursive: true, force: true });
|