From ec1820bfde22a6344cd4b9b27b5a78442bcbf14e Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 13 Aug 2026 07:47:00 +0000 Subject: [PATCH] fix(tmux): launch the server with exit-empty off (harness-control#845) By default tmux terminates its whole server process the instant the last session closes. During a mass teardown (many sessions ending near-simultaneously), a transient zero-session moment tears the entire server down -- taking every other session's panes with it at once (the whole-server-death incident). Set the server-wide 'exit-empty off' option on session-create (before new_session; server.cmd starts the server if needed), so the server survives an empty moment. Backend belt, HOME-independent (applies to whatever uid runs cao-server); a HOME .tmux.conf set is the ops-side complement. Set on every create_session rather than cached so it survives an externally-killed-and-recreated server; best-effort, so a failure never blocks a launch. Tests: create_session issues set-option -s exit-empty off; a set-option failure does not abort the launch. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit 6f13d9fa29f70d94fa7d97e8c3c7cfeb92940084) --- src/cli_agent_orchestrator/clients/tmux.py | 30 ++++++++++++++++++++++ test/clients/test_tmux_client.py | 27 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/src/cli_agent_orchestrator/clients/tmux.py b/src/cli_agent_orchestrator/clients/tmux.py index 8afffe06c..fc56ebef1 100644 --- a/src/cli_agent_orchestrator/clients/tmux.py +++ b/src/cli_agent_orchestrator/clients/tmux.py @@ -60,6 +60,31 @@ class TmuxClient: def __init__(self) -> None: self.server = libtmux.Server() + def _set_server_exit_empty_off(self) -> None: + """Keep the tmux server alive across a transient zero-session moment. + + By default tmux terminates its whole server process the instant the last + session closes (``exit-empty on``). During a mass teardown — many sessions + ending near-simultaneously — that races CAO creating the next session + against the server vanishing: a momentary "no sessions" window tears the + entire server down, taking every other session's panes with it at once + (harness-control#845, the whole-server-death incident). ``exit-empty off`` + keeps the server up through an empty moment. + + This is the backend belt (HOME-independent, applies to whatever uid runs + cao-server); a HOME ``.tmux.conf`` set is the ops-side complement. Set on + every session-create rather than cached on the client: it is a cheap, + idempotent server option, and setting it each time means it survives even + if the tmux server is ever externally killed and recreated. ``server.cmd`` + starts the server if it is not already running, so this also runs before + the very first session exists. Best-effort: a failure here must never block + a session launch. + """ + try: + self.server.cmd("set-option", "-s", "exit-empty", "off") + except Exception: + logger.warning("failed to set tmux server option 'exit-empty off'", exc_info=True) + # ── libtmux listing boundary ───────────────────────────────────────── # # Every read that makes libtmux shell out to `list-sessions` / @@ -350,6 +375,11 @@ def create_session( ) -> str: """Create detached tmux session with initial window and return window name.""" try: + # Ensure the server won't die on a transient empty moment during a + # mass teardown (harness-control#845). Runs before new_session, and + # starts the server if it isn't up yet. + self._set_server_exit_empty_off() + working_directory = self._resolve_and_validate_working_directory(working_directory) # Only pass essential env vars to avoid tmux "command too long" diff --git a/test/clients/test_tmux_client.py b/test/clients/test_tmux_client.py index 28e816941..3541dc566 100644 --- a/test/clients/test_tmux_client.py +++ b/test/clients/test_tmux_client.py @@ -62,6 +62,33 @@ def test_create_session_success(self, tmux, tmp_path): assert result == "my-window" tmux.server.new_session.assert_called_once() + def test_create_session_disables_exit_empty(self, tmux, tmp_path): + """harness-control#845: creating a session must set the server-wide + 'exit-empty off' option (before new_session) so a transient empty moment + during a mass teardown can't take the whole tmux server down.""" + mock_window = MagicMock() + mock_window.name = "my-window" + mock_session = MagicMock() + mock_session.windows = [mock_window] + tmux.server.new_session.return_value = mock_session + + tmux.create_session("ses", "my-window", "tid1", str(tmp_path)) + + tmux.server.cmd.assert_any_call("set-option", "-s", "exit-empty", "off") + + def test_exit_empty_failure_does_not_block_launch(self, tmux, tmp_path): + """Setting exit-empty is best-effort: a failure must NOT abort the launch.""" + mock_window = MagicMock() + mock_window.name = "my-window" + mock_session = MagicMock() + mock_session.windows = [mock_window] + tmux.server.new_session.return_value = mock_session + tmux.server.cmd.side_effect = RuntimeError("tmux unavailable") + + # Must still succeed despite the set-option failure. + result = tmux.create_session("ses", "my-window", "tid1", str(tmp_path)) + assert result == "my-window" + def test_create_session_window_name_none(self, tmux, tmp_path): mock_window = MagicMock() mock_window.name = None