railmux 0.3.5.dev1__tar.gz → 0.3.6.dev1__tar.gz
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.
- {railmux-0.3.5.dev1/src/railmux.egg-info → railmux-0.3.6.dev1}/PKG-INFO +13 -1
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/README.md +12 -0
- railmux-0.3.6.dev1/src/railmux/__init__.py +1 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/fast_display_history.py +11 -9
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/models.py +3 -0
- railmux-0.3.6.dev1/src/railmux/session_lease.py +554 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/ui/app.py +458 -45
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/ui/running_pane.py +4 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/ui/sessions_pane.py +10 -3
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1/src/railmux.egg-info}/PKG-INFO +13 -1
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux.egg-info/SOURCES.txt +2 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_codex_delete.py +4 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_fast_full_window.py +43 -11
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_focus_highlight.py +4 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_orphan_recovery.py +40 -1
- railmux-0.3.6.dev1/tests/test_session_lease.py +491 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_sessions_pane.py +15 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_terminal_size.py +12 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_tmux_status.py +57 -8
- railmux-0.3.5.dev1/src/railmux/__init__.py +0 -1
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/LICENSE +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/MANIFEST.in +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/pyproject.toml +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/setup.cfg +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/__main__.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/atomic_file.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/background_index.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/cli.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/codex_index.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/config.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/config_cli.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/diagnostics.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/discovery.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/display_transport.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/fast_display_client.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/fast_display_input.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/fast_display_protocol.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/fast_display_server.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/favorites.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/fuzzy.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/help_workspace.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/launcher.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/legacy_sessions.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/local_clipboard.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/local_open.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/modes.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/mouse_manager.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/orphan_marker.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/pane_surface.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/path_codec.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/remote_config.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/renames.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/restart_state.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/runtime_config.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/scroll_agent.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/scroll_manager.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/selection_isolation.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/self_update.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/session_cache.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/session_index.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/setting_contracts.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/settings.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/ssh_args.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/ssh_compat.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/ssh_display_diagnostics.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/ssh_doctor.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/system_deps.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/terminal_status.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/tmux_binding_manager.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/tmux_ctl.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/tmux_health.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/tmux_server.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/tool_panes.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/transcript.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/ui/__init__.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/ui/_widgets.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/ui/keymap.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/ui/modals.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/ui/projects_pane.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/ui/sidebar.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/ui/statusbar.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/ui/workspace.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux.egg-info/dependency_links.txt +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux.egg-info/entry_points.txt +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux.egg-info/requires.txt +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux.egg-info/top_level.txt +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_agent_workspace.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_atomic_file.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_background_index.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_cli.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_codex_index.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_codex_lineage.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_codex_transcript.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_config.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_config_cli.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_denested_transport_benchmark.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_diagnostics.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_discovery.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_display_transport.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_fullscreen_binding.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_fuzzy.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_help_agent.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_help_workspace.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_history_cleanup.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_keymap.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_launcher.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_layout_profile.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_legacy_sessions.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_local_clipboard.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_local_open.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_modals.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_mode_selection.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_modes.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_mouse_manager.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_new_project.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_options.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_pane_surface.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_path_codec.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_projects_pane.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_release_notes.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_release_workflow.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_remote_config.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_renames.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_restart_state.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_running_pane.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_running_sort.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_runtime_config.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_scroll_agent.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_scroll_manager.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_selection_isolation.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_self_update.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_session_cache.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_session_index.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_setting_contracts.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_settings.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_sidebar.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_soft_quit.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_ssh_args.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_ssh_compat.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_ssh_display_benchmark.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_ssh_display_diagnostics.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_ssh_doctor.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_status.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_statusbar.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_system_deps.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_terminal_status.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_termux_local_touch.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_tmux_binding_manager.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_tmux_ctl.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_tmux_health.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_tmux_integration.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_tmux_server.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_tool_panes.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_transcript.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_utf8_state.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_web_demo_recorder.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_wheel_smoke.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_widgets.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_yolo_prompt.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tools/denested_transport_benchmark.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tools/release_notes.py +0 -0
- {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tools/wheel_smoke.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: railmux
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.6.dev1
|
|
4
4
|
Summary: Terminal UI to navigate, resume, and start Claude Code and Codex sessions
|
|
5
5
|
Author-email: Rightglow <zhang.taian@foxmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -427,6 +427,18 @@ Railmux also indexes resumable Claude Code and Codex sessions started outside
|
|
|
427
427
|
Railmux, so existing conversations appear in the sidebar and resume the same
|
|
428
428
|
way. Non-resumable one-off runs such as `codex exec` are filtered out.
|
|
429
429
|
|
|
430
|
+
If the same Claude Code or Codex history root is shared across hosts, Railmux
|
|
431
|
+
uses a provider-session lease to prevent two machines from resuming the same
|
|
432
|
+
conversation concurrently. A remotely owned conversation remains in Sessions
|
|
433
|
+
as **running on HOST**, but is not presented as a locally attachable Running
|
|
434
|
+
entry; resume fails safely until that host's exact provider pane exits. The
|
|
435
|
+
lease follows detached agents across Soft Quit and covers every known Codex
|
|
436
|
+
rewind-lineage ID at resume; later rewinds retain a stable locked lineage
|
|
437
|
+
anchor. Delete also checks the shared lease immediately before
|
|
438
|
+
starting and refuses remotely owned history. The shared filesystem must provide
|
|
439
|
+
cross-host POSIX advisory-lock semantics; mounts configured with local-only or
|
|
440
|
+
disabled locking cannot provide this guarantee.
|
|
441
|
+
|
|
430
442
|
### Restarts and quitting
|
|
431
443
|
|
|
432
444
|
Each opened agent runs in a detached tmux session, so switching sessions does
|
|
@@ -385,6 +385,18 @@ Railmux also indexes resumable Claude Code and Codex sessions started outside
|
|
|
385
385
|
Railmux, so existing conversations appear in the sidebar and resume the same
|
|
386
386
|
way. Non-resumable one-off runs such as `codex exec` are filtered out.
|
|
387
387
|
|
|
388
|
+
If the same Claude Code or Codex history root is shared across hosts, Railmux
|
|
389
|
+
uses a provider-session lease to prevent two machines from resuming the same
|
|
390
|
+
conversation concurrently. A remotely owned conversation remains in Sessions
|
|
391
|
+
as **running on HOST**, but is not presented as a locally attachable Running
|
|
392
|
+
entry; resume fails safely until that host's exact provider pane exits. The
|
|
393
|
+
lease follows detached agents across Soft Quit and covers every known Codex
|
|
394
|
+
rewind-lineage ID at resume; later rewinds retain a stable locked lineage
|
|
395
|
+
anchor. Delete also checks the shared lease immediately before
|
|
396
|
+
starting and refuses remotely owned history. The shared filesystem must provide
|
|
397
|
+
cross-host POSIX advisory-lock semantics; mounts configured with local-only or
|
|
398
|
+
disabled locking cannot provide this guarantee.
|
|
399
|
+
|
|
388
400
|
### Restarts and quitting
|
|
389
401
|
|
|
390
402
|
Each opened agent runs in a detached tmux session, so switching sessions does
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.6.dev1"
|
|
@@ -287,15 +287,17 @@ class LocalHistoryView:
|
|
|
287
287
|
# intermediate output above the current screen.
|
|
288
288
|
lines = incoming.lines
|
|
289
289
|
else:
|
|
290
|
-
#
|
|
291
|
-
#
|
|
292
|
-
#
|
|
293
|
-
#
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
290
|
+
# Never splice two captures whose timelines cannot be aligned.
|
|
291
|
+
# Retaining old history and replacing only the live viewport
|
|
292
|
+
# looked conservative, but it silently omitted every row
|
|
293
|
+
# produced between those two pieces. That false seam appeared
|
|
294
|
+
# as discontinuous SSH history until returning to the bottom
|
|
295
|
+
# forced a fresh deep capture. Keep the newest internally
|
|
296
|
+
# contiguous hot capture instead; the first wheel-up requests
|
|
297
|
+
# a cumulative deep page and can recover older rows safely.
|
|
298
|
+
# An already-frozen viewport owns its immutable snapshot and
|
|
299
|
+
# therefore remains undisturbed by this cache replacement.
|
|
300
|
+
lines = incoming.lines
|
|
299
301
|
else:
|
|
300
302
|
start = min(0, delta)
|
|
301
303
|
incoming_end = delta + len(incoming.lines)
|
|
@@ -75,6 +75,9 @@ class SessionMeta:
|
|
|
75
75
|
# conversation without changing the provider-owned UUID used for resume.
|
|
76
76
|
# Always None for Claude sessions and older Codex rollouts.
|
|
77
77
|
forked_from_id: str | None = None
|
|
78
|
+
# A Railmux lease observed in the same shared provider history root. This
|
|
79
|
+
# is a read-time UI overlay, never provider transcript metadata.
|
|
80
|
+
remote_owner: str | None = None
|
|
78
81
|
|
|
79
82
|
@property
|
|
80
83
|
def display_title(self) -> str:
|
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
"""Cross-host provider-session leases for shared Codex/Claude homes.
|
|
2
|
+
|
|
3
|
+
The provider history root is the sharing authority: if two hosts see the same
|
|
4
|
+
rollout/transcript files, they also see the same Railmux lease files. Advisory
|
|
5
|
+
locks are held by a small process tied to the exact provider pane lifetime, so
|
|
6
|
+
Soft Quit can leave the provider running without silently releasing its lease.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import errno
|
|
12
|
+
import fcntl
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import selectors
|
|
17
|
+
import socket
|
|
18
|
+
import stat
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
import uuid
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Sequence
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
SCHEMA_VERSION = 1
|
|
30
|
+
LEASE_DIRECTORY = "railmux-session-leases-v1"
|
|
31
|
+
_MAX_OWNER_BYTES = 4096
|
|
32
|
+
_READY_TIMEOUT_S = 3.0
|
|
33
|
+
_PROCFS_POLL_INTERVAL_S = 0.25
|
|
34
|
+
_FALLBACK_POLL_INTERVAL_S = 2.0
|
|
35
|
+
_PROBE_RETRY_WINDOW_S = 0.2
|
|
36
|
+
_STALE_LOCAL_RETRY_WINDOW_S = 0.75
|
|
37
|
+
_RETRY_INTERVAL_S = 0.05
|
|
38
|
+
_MODE_MASKING_ERRNOS = frozenset(
|
|
39
|
+
{errno.EPERM, errno.EOPNOTSUPP, getattr(errno, "ENOTSUP", errno.EOPNOTSUPP)}
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class LeaseOwner:
|
|
45
|
+
provider: str
|
|
46
|
+
session_id: str
|
|
47
|
+
host: str
|
|
48
|
+
instance: str
|
|
49
|
+
pane_id: str | None = None
|
|
50
|
+
pane_pid: int | None = None
|
|
51
|
+
process_start: str | None = None
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def display_host(self) -> str:
|
|
55
|
+
return self.host or "another host"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class LeaseError(RuntimeError):
|
|
59
|
+
"""The shared lease authority could not be used safely."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class LeaseConflict(LeaseError):
|
|
63
|
+
def __init__(self, owner: LeaseOwner | None) -> None:
|
|
64
|
+
self.owner = owner
|
|
65
|
+
host = owner.display_host if owner is not None else "another host"
|
|
66
|
+
super().__init__(f"session is already running on {host}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class LeaseClaim:
|
|
71
|
+
root: Path
|
|
72
|
+
provider: str
|
|
73
|
+
session_ids: tuple[str, ...]
|
|
74
|
+
instance: str
|
|
75
|
+
files: tuple[tuple[str, Path, int], ...]
|
|
76
|
+
_detached: bool = False
|
|
77
|
+
|
|
78
|
+
def close(self) -> None:
|
|
79
|
+
if self._detached:
|
|
80
|
+
return
|
|
81
|
+
for _session_id, _path, fd in self.files:
|
|
82
|
+
# Lease paths are persistent rendezvous points. Unlinking a
|
|
83
|
+
# locked file creates a second-inode race on shared filesystems:
|
|
84
|
+
# another host can create and lock the same pathname before this
|
|
85
|
+
# fd is closed. An unlocked stale record is explicitly inactive.
|
|
86
|
+
try:
|
|
87
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
88
|
+
except OSError:
|
|
89
|
+
pass
|
|
90
|
+
try:
|
|
91
|
+
os.close(fd)
|
|
92
|
+
except OSError:
|
|
93
|
+
pass
|
|
94
|
+
self._detached = True
|
|
95
|
+
|
|
96
|
+
def detach(self) -> None:
|
|
97
|
+
"""Close parent copies after a holder inherited the locked fds."""
|
|
98
|
+
if self._detached:
|
|
99
|
+
return
|
|
100
|
+
for _session_id, _path, fd in self.files:
|
|
101
|
+
try:
|
|
102
|
+
os.close(fd)
|
|
103
|
+
except OSError:
|
|
104
|
+
pass
|
|
105
|
+
self._detached = True
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _bounded_host() -> str:
|
|
109
|
+
try:
|
|
110
|
+
host = socket.gethostname().strip()
|
|
111
|
+
except OSError:
|
|
112
|
+
host = ""
|
|
113
|
+
host = "".join(c if c.isalnum() or c in ".-_" else "-" for c in host)
|
|
114
|
+
return host[:128] or "unknown-host"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _lease_directory(provider_root: Path) -> Path:
|
|
118
|
+
return provider_root / LEASE_DIRECTORY
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _lease_path(provider_root: Path, provider: str, session_id: str) -> Path:
|
|
122
|
+
material = f"{provider}\0{session_id}".encode("utf-8")
|
|
123
|
+
digest = hashlib.sha256(material).hexdigest()
|
|
124
|
+
return _lease_directory(provider_root) / f"{provider}-{digest}.lock"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def process_start_token(pid: int) -> str | None:
|
|
128
|
+
"""Best available immutable birth token for one local process."""
|
|
129
|
+
if pid <= 0:
|
|
130
|
+
return None
|
|
131
|
+
try:
|
|
132
|
+
raw = Path(f"/proc/{pid}/stat").read_text(encoding="ascii")
|
|
133
|
+
_head, separator, tail = raw.rpartition(")")
|
|
134
|
+
fields = tail.strip().split() if separator else []
|
|
135
|
+
start_ticks = fields[19]
|
|
136
|
+
if start_ticks.isdecimal():
|
|
137
|
+
return f"proc:{start_ticks}"
|
|
138
|
+
except (OSError, UnicodeError, IndexError):
|
|
139
|
+
pass
|
|
140
|
+
try:
|
|
141
|
+
started = subprocess.check_output(
|
|
142
|
+
["ps", "-o", "lstart=", "-o", "comm=", "-p", str(pid)],
|
|
143
|
+
stderr=subprocess.DEVNULL,
|
|
144
|
+
text=True,
|
|
145
|
+
timeout=1.0,
|
|
146
|
+
).strip()
|
|
147
|
+
except (OSError, subprocess.SubprocessError, UnicodeError):
|
|
148
|
+
return None
|
|
149
|
+
return (
|
|
150
|
+
f"ps:{hashlib.sha256(started.encode('utf-8')).hexdigest()}"
|
|
151
|
+
if started else None
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _process_matches(pid: int, token: str | None) -> bool:
|
|
156
|
+
if token is None:
|
|
157
|
+
return False
|
|
158
|
+
return process_start_token(pid) == token
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def owner_is_stale_local(owner: LeaseOwner | None) -> bool:
|
|
162
|
+
"""Whether an owner record proves its exact same-host pane has exited."""
|
|
163
|
+
return bool(
|
|
164
|
+
owner is not None
|
|
165
|
+
and owner.host == _bounded_host()
|
|
166
|
+
and owner.pane_pid is not None
|
|
167
|
+
and owner.process_start is not None
|
|
168
|
+
and not _process_matches(owner.pane_pid, owner.process_start)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _ensure_lease_directory(provider_root: Path) -> Path:
|
|
173
|
+
directory = _lease_directory(provider_root)
|
|
174
|
+
try:
|
|
175
|
+
directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
176
|
+
# lstat keeps a pre-created symlink from redirecting the predictable
|
|
177
|
+
# rendezvous directory outside the provider root.
|
|
178
|
+
info = directory.lstat()
|
|
179
|
+
except OSError as exc:
|
|
180
|
+
raise LeaseError(
|
|
181
|
+
"shared session lease directory is unavailable") from exc
|
|
182
|
+
if (
|
|
183
|
+
not stat.S_ISDIR(info.st_mode)
|
|
184
|
+
or info.st_uid != os.getuid()
|
|
185
|
+
):
|
|
186
|
+
raise LeaseError("shared session lease directory is unsafe")
|
|
187
|
+
try:
|
|
188
|
+
# Repair a permissive directory on ordinary POSIX filesystems. DrvFs,
|
|
189
|
+
# CIFS, and other mode-masking stores may accept chmod while continuing
|
|
190
|
+
# to report their mount-wide mode; type/owner/no-symlink checks still
|
|
191
|
+
# apply there instead of disabling the lease entirely.
|
|
192
|
+
directory.chmod(0o700)
|
|
193
|
+
except OSError as exc:
|
|
194
|
+
if exc.errno not in _MODE_MASKING_ERRNOS:
|
|
195
|
+
raise LeaseError(
|
|
196
|
+
"shared session lease directory is unavailable") from exc
|
|
197
|
+
return directory
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _decode_owner(raw: bytes) -> LeaseOwner | None:
|
|
201
|
+
if not raw or len(raw) > _MAX_OWNER_BYTES:
|
|
202
|
+
return None
|
|
203
|
+
try:
|
|
204
|
+
value = json.loads(raw.decode("utf-8"))
|
|
205
|
+
except (UnicodeError, json.JSONDecodeError, ValueError):
|
|
206
|
+
return None
|
|
207
|
+
if not isinstance(value, dict) or value.get("version") != SCHEMA_VERSION:
|
|
208
|
+
return None
|
|
209
|
+
provider = value.get("provider")
|
|
210
|
+
session_id = value.get("session_id")
|
|
211
|
+
host = value.get("host")
|
|
212
|
+
instance = value.get("instance")
|
|
213
|
+
if not all(isinstance(item, str) and item for item in (
|
|
214
|
+
provider, session_id, host, instance,
|
|
215
|
+
)):
|
|
216
|
+
return None
|
|
217
|
+
pane_id = value.get("pane_id")
|
|
218
|
+
pane_pid = value.get("pane_pid")
|
|
219
|
+
process_start = value.get("process_start")
|
|
220
|
+
return LeaseOwner(
|
|
221
|
+
provider=provider[:32],
|
|
222
|
+
session_id=session_id[:128],
|
|
223
|
+
host=host[:128],
|
|
224
|
+
instance=instance[:128],
|
|
225
|
+
pane_id=pane_id[:64] if isinstance(pane_id, str) else None,
|
|
226
|
+
pane_pid=(pane_pid if isinstance(pane_pid, int)
|
|
227
|
+
and not isinstance(pane_pid, bool) and pane_pid > 0 else None),
|
|
228
|
+
process_start=(process_start[:128]
|
|
229
|
+
if isinstance(process_start, str) else None),
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _read_owner_fd(fd: int) -> LeaseOwner | None:
|
|
234
|
+
try:
|
|
235
|
+
return _decode_owner(os.pread(fd, _MAX_OWNER_BYTES + 1, 0))
|
|
236
|
+
except OSError:
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _write_owner_fd(fd: int, payload: dict) -> None:
|
|
241
|
+
data = json.dumps(
|
|
242
|
+
payload, separators=(",", ":"), sort_keys=True,
|
|
243
|
+
).encode("utf-8")
|
|
244
|
+
if len(data) > _MAX_OWNER_BYTES:
|
|
245
|
+
raise LeaseError("session lease owner record is too large")
|
|
246
|
+
try:
|
|
247
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
248
|
+
os.ftruncate(fd, 0)
|
|
249
|
+
if os.write(fd, data) != len(data):
|
|
250
|
+
raise OSError("short session lease owner write")
|
|
251
|
+
# The descriptor remains open for the provider lifetime. Flush the
|
|
252
|
+
# diagnostic record now so another NFS client that observes the lock
|
|
253
|
+
# does not have to wait for close before it can name the owner.
|
|
254
|
+
os.fsync(fd)
|
|
255
|
+
except OSError as exc:
|
|
256
|
+
raise LeaseError("could not publish session lease owner") from exc
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _open_lease(path: Path) -> int:
|
|
260
|
+
flags = os.O_CREAT | os.O_RDWR
|
|
261
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
262
|
+
flags |= os.O_NOFOLLOW
|
|
263
|
+
try:
|
|
264
|
+
fd = os.open(path, flags, 0o600)
|
|
265
|
+
info = os.fstat(fd)
|
|
266
|
+
except OSError as exc:
|
|
267
|
+
raise LeaseError("could not open shared session lease") from exc
|
|
268
|
+
if (
|
|
269
|
+
not stat.S_ISREG(info.st_mode)
|
|
270
|
+
or info.st_uid != os.getuid()
|
|
271
|
+
):
|
|
272
|
+
os.close(fd)
|
|
273
|
+
raise LeaseError("shared session lease file is unsafe")
|
|
274
|
+
try:
|
|
275
|
+
os.fchmod(fd, 0o600)
|
|
276
|
+
except OSError as exc:
|
|
277
|
+
if exc.errno not in _MODE_MASKING_ERRNOS:
|
|
278
|
+
os.close(fd)
|
|
279
|
+
raise LeaseError("shared session lease file is unavailable") from exc
|
|
280
|
+
return fd
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _acquire_once(
|
|
284
|
+
provider_root: Path,
|
|
285
|
+
provider: str,
|
|
286
|
+
ids: tuple[str, ...],
|
|
287
|
+
) -> LeaseClaim:
|
|
288
|
+
instance = uuid.uuid4().hex
|
|
289
|
+
files: list[tuple[str, Path, int]] = []
|
|
290
|
+
try:
|
|
291
|
+
for session_id in ids:
|
|
292
|
+
path = _lease_path(provider_root, provider, session_id)
|
|
293
|
+
fd = _open_lease(path)
|
|
294
|
+
try:
|
|
295
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
296
|
+
except BlockingIOError as exc:
|
|
297
|
+
owner = _read_owner_fd(fd)
|
|
298
|
+
os.close(fd)
|
|
299
|
+
raise LeaseConflict(owner) from exc
|
|
300
|
+
except OSError as exc:
|
|
301
|
+
os.close(fd)
|
|
302
|
+
raise LeaseError(
|
|
303
|
+
"shared session locking is unavailable") from exc
|
|
304
|
+
files.append((session_id, path, fd))
|
|
305
|
+
for session_id, _path, fd in files:
|
|
306
|
+
_write_owner_fd(fd, {
|
|
307
|
+
"version": SCHEMA_VERSION,
|
|
308
|
+
"provider": provider,
|
|
309
|
+
"session_id": session_id,
|
|
310
|
+
"host": _bounded_host(),
|
|
311
|
+
"instance": instance,
|
|
312
|
+
"phase": "reserving",
|
|
313
|
+
})
|
|
314
|
+
return LeaseClaim(
|
|
315
|
+
provider_root, provider, ids, instance, tuple(files))
|
|
316
|
+
except BaseException:
|
|
317
|
+
for _session_id, _path, fd in files:
|
|
318
|
+
try:
|
|
319
|
+
os.close(fd)
|
|
320
|
+
except OSError:
|
|
321
|
+
pass
|
|
322
|
+
raise
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def acquire(
|
|
326
|
+
provider_root: Path,
|
|
327
|
+
provider: str,
|
|
328
|
+
session_ids: Sequence[str],
|
|
329
|
+
) -> LeaseClaim:
|
|
330
|
+
"""Atomically reserve every alias needed for one logical conversation.
|
|
331
|
+
|
|
332
|
+
Read-only owner probes briefly take shared advisory locks. Retry a bounded
|
|
333
|
+
contention window so one such probe cannot be mistaken for a durable
|
|
334
|
+
writer; a proven dead same-host pane receives the slightly longer window
|
|
335
|
+
needed for its independent holder to observe process exit.
|
|
336
|
+
"""
|
|
337
|
+
ids = tuple(sorted(set(session_ids)))
|
|
338
|
+
if provider not in {"claude", "codex"} or not ids:
|
|
339
|
+
raise LeaseError("invalid provider session lease identity")
|
|
340
|
+
if any(not session_id or len(session_id) > 128 for session_id in ids):
|
|
341
|
+
raise LeaseError("invalid provider session id for lease")
|
|
342
|
+
_ensure_lease_directory(provider_root)
|
|
343
|
+
started = time.monotonic()
|
|
344
|
+
while True:
|
|
345
|
+
try:
|
|
346
|
+
return _acquire_once(provider_root, provider, ids)
|
|
347
|
+
except LeaseConflict as exc:
|
|
348
|
+
retry_window = (
|
|
349
|
+
_STALE_LOCAL_RETRY_WINDOW_S
|
|
350
|
+
if owner_is_stale_local(exc.owner)
|
|
351
|
+
else _PROBE_RETRY_WINDOW_S
|
|
352
|
+
)
|
|
353
|
+
if time.monotonic() - started >= retry_window:
|
|
354
|
+
raise
|
|
355
|
+
time.sleep(_RETRY_INTERVAL_S)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def active_owner(
|
|
359
|
+
provider_root: Path,
|
|
360
|
+
provider: str,
|
|
361
|
+
session_ids: Sequence[str],
|
|
362
|
+
) -> LeaseOwner | None:
|
|
363
|
+
"""Return a currently locked owner for any alias, without mutation."""
|
|
364
|
+
for session_id in sorted(set(session_ids)):
|
|
365
|
+
path = _lease_path(provider_root, provider, session_id)
|
|
366
|
+
# NFS flock emulation may require write access even for a shared probe.
|
|
367
|
+
# O_RDWR does not mutate the file; the non-blocking lock result remains
|
|
368
|
+
# the sole active-owner authority. Shared probes do not serialize each
|
|
369
|
+
# other, while acquire retries their very short collision window.
|
|
370
|
+
flags = os.O_RDWR
|
|
371
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
372
|
+
flags |= os.O_NOFOLLOW
|
|
373
|
+
try:
|
|
374
|
+
fd = os.open(path, flags)
|
|
375
|
+
except FileNotFoundError:
|
|
376
|
+
continue
|
|
377
|
+
except OSError as exc:
|
|
378
|
+
raise LeaseError("shared session lease state is unavailable") from exc
|
|
379
|
+
try:
|
|
380
|
+
try:
|
|
381
|
+
fcntl.flock(fd, fcntl.LOCK_SH | fcntl.LOCK_NB)
|
|
382
|
+
except BlockingIOError:
|
|
383
|
+
return _read_owner_fd(fd) or LeaseOwner(
|
|
384
|
+
provider, session_id, "another host", "unknown")
|
|
385
|
+
except OSError as exc:
|
|
386
|
+
raise LeaseError(
|
|
387
|
+
"shared session locking is unavailable") from exc
|
|
388
|
+
else:
|
|
389
|
+
try:
|
|
390
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
391
|
+
except OSError as exc:
|
|
392
|
+
raise LeaseError(
|
|
393
|
+
"shared session locking is unavailable") from exc
|
|
394
|
+
finally:
|
|
395
|
+
os.close(fd)
|
|
396
|
+
return None
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def owner_matches_pane(
|
|
400
|
+
owner: LeaseOwner,
|
|
401
|
+
pane_id: str,
|
|
402
|
+
pane_pid: int,
|
|
403
|
+
*,
|
|
404
|
+
process_start: str | None = None,
|
|
405
|
+
) -> bool:
|
|
406
|
+
current_start = process_start or process_start_token(pane_pid)
|
|
407
|
+
return bool(
|
|
408
|
+
owner.host == _bounded_host()
|
|
409
|
+
and owner.pane_id == pane_id
|
|
410
|
+
and owner.pane_pid == pane_pid
|
|
411
|
+
and owner.process_start == current_start
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def start_holder(claim: LeaseClaim, *, pane_id: str, pane_pid: int) -> bool:
|
|
416
|
+
"""Transfer one acquired claim to an independent pane-lifetime holder."""
|
|
417
|
+
token = process_start_token(pane_pid)
|
|
418
|
+
if not pane_id.startswith("%") or token is None:
|
|
419
|
+
claim.close()
|
|
420
|
+
return False
|
|
421
|
+
argv = [
|
|
422
|
+
sys.executable,
|
|
423
|
+
"-m",
|
|
424
|
+
"railmux.session_lease",
|
|
425
|
+
"hold",
|
|
426
|
+
"--provider",
|
|
427
|
+
claim.provider,
|
|
428
|
+
"--instance",
|
|
429
|
+
claim.instance,
|
|
430
|
+
"--pane-id",
|
|
431
|
+
pane_id,
|
|
432
|
+
"--pane-pid",
|
|
433
|
+
str(pane_pid),
|
|
434
|
+
"--process-start",
|
|
435
|
+
token,
|
|
436
|
+
]
|
|
437
|
+
for session_id, _path, fd in claim.files:
|
|
438
|
+
argv.extend(("--lease", f"{session_id}:{fd}"))
|
|
439
|
+
try:
|
|
440
|
+
process = subprocess.Popen(
|
|
441
|
+
argv,
|
|
442
|
+
stdin=subprocess.DEVNULL,
|
|
443
|
+
stdout=subprocess.PIPE,
|
|
444
|
+
stderr=subprocess.DEVNULL,
|
|
445
|
+
pass_fds=tuple(fd for _session_id, _path, fd in claim.files),
|
|
446
|
+
start_new_session=True,
|
|
447
|
+
)
|
|
448
|
+
except OSError:
|
|
449
|
+
claim.close()
|
|
450
|
+
return False
|
|
451
|
+
assert process.stdout is not None
|
|
452
|
+
selector = selectors.DefaultSelector()
|
|
453
|
+
selector.register(process.stdout, selectors.EVENT_READ)
|
|
454
|
+
ready = False
|
|
455
|
+
try:
|
|
456
|
+
if selector.select(_READY_TIMEOUT_S):
|
|
457
|
+
ready = process.stdout.readline().strip() == b"ready"
|
|
458
|
+
finally:
|
|
459
|
+
selector.close()
|
|
460
|
+
process.stdout.close()
|
|
461
|
+
if not ready:
|
|
462
|
+
try:
|
|
463
|
+
process.terminate()
|
|
464
|
+
except OSError:
|
|
465
|
+
pass
|
|
466
|
+
try:
|
|
467
|
+
process.wait(timeout=1.0)
|
|
468
|
+
except subprocess.TimeoutExpired:
|
|
469
|
+
try:
|
|
470
|
+
process.kill()
|
|
471
|
+
except OSError:
|
|
472
|
+
pass
|
|
473
|
+
try:
|
|
474
|
+
process.wait(timeout=1.0)
|
|
475
|
+
except subprocess.TimeoutExpired:
|
|
476
|
+
pass
|
|
477
|
+
claim.close()
|
|
478
|
+
return False
|
|
479
|
+
claim.detach()
|
|
480
|
+
# The holder may outlive Railmux after Soft Quit. While Railmux remains
|
|
481
|
+
# alive, reap it asynchronously when its provider pane eventually exits.
|
|
482
|
+
threading.Thread(
|
|
483
|
+
target=process.wait,
|
|
484
|
+
name="railmux-session-lease-reaper",
|
|
485
|
+
daemon=True,
|
|
486
|
+
).start()
|
|
487
|
+
return True
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _hold(args: argparse.Namespace) -> int:
|
|
491
|
+
files: list[tuple[str, Path | None, int]] = []
|
|
492
|
+
try:
|
|
493
|
+
for raw in args.lease:
|
|
494
|
+
session_id, separator, raw_fd = raw.rpartition(":")
|
|
495
|
+
if not separator or not raw_fd.isdecimal() or not session_id:
|
|
496
|
+
return 2
|
|
497
|
+
fd = int(raw_fd)
|
|
498
|
+
os.fstat(fd)
|
|
499
|
+
files.append((session_id, None, fd))
|
|
500
|
+
if not files or not _process_matches(args.pane_pid, args.process_start):
|
|
501
|
+
return 2
|
|
502
|
+
for session_id, _path, fd in files:
|
|
503
|
+
_write_owner_fd(fd, {
|
|
504
|
+
"version": SCHEMA_VERSION,
|
|
505
|
+
"provider": args.provider,
|
|
506
|
+
"session_id": session_id,
|
|
507
|
+
"host": _bounded_host(),
|
|
508
|
+
"instance": args.instance,
|
|
509
|
+
"phase": "running",
|
|
510
|
+
"pane_id": args.pane_id,
|
|
511
|
+
"pane_pid": args.pane_pid,
|
|
512
|
+
"process_start": args.process_start,
|
|
513
|
+
})
|
|
514
|
+
print("ready", flush=True)
|
|
515
|
+
poll_interval = (
|
|
516
|
+
_PROCFS_POLL_INTERVAL_S
|
|
517
|
+
if args.process_start.startswith("proc:")
|
|
518
|
+
else _FALLBACK_POLL_INTERVAL_S
|
|
519
|
+
)
|
|
520
|
+
while _process_matches(args.pane_pid, args.process_start):
|
|
521
|
+
time.sleep(poll_interval)
|
|
522
|
+
return 0
|
|
523
|
+
except (OSError, LeaseError):
|
|
524
|
+
return 1
|
|
525
|
+
finally:
|
|
526
|
+
# Persistent empty files avoid directory churn on NFS. The advisory
|
|
527
|
+
# lock, not stale JSON content, is the active-lease authority.
|
|
528
|
+
for _session_id, _path, fd in files:
|
|
529
|
+
try:
|
|
530
|
+
os.close(fd)
|
|
531
|
+
except OSError:
|
|
532
|
+
pass
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
|
536
|
+
parser = argparse.ArgumentParser(add_help=False)
|
|
537
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
538
|
+
hold = sub.add_parser("hold", add_help=False)
|
|
539
|
+
hold.add_argument("--provider", choices=("claude", "codex"), required=True)
|
|
540
|
+
hold.add_argument("--instance", required=True)
|
|
541
|
+
hold.add_argument("--pane-id", required=True)
|
|
542
|
+
hold.add_argument("--pane-pid", type=int, required=True)
|
|
543
|
+
hold.add_argument("--process-start", required=True)
|
|
544
|
+
hold.add_argument("--lease", action="append", default=[])
|
|
545
|
+
return parser.parse_args(argv)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
549
|
+
args = parse_args(argv)
|
|
550
|
+
return _hold(args) if args.command == "hold" else 2
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
if __name__ == "__main__":
|
|
554
|
+
raise SystemExit(main())
|