From 9be6412d91149db0bbf7c52c7828ac847ad076be Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 02:23:59 +0000 Subject: [PATCH] fix(tmux): post-spawn liveness check for silent in-pane exit (issue #513) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tmux new-session returns 0 as long as the child shell + command launched. If the in-pane command exits immediately (bad flag, auth error, missing binary, etc.) the detached session reaps itself silently and the state machine ends up CONNECTED against nothing, queuing messages forever. Adds a ~150ms window + has_session() check after new_session() succeeds in _spawn_tmux_repl(). A dead session at that point raises RuntimeError so the existing BOOTING → DEAD via BOOT_FAILED transition fires correctly. The delay is tunable via _SPAWN_LIVENESS_DELAY_SEC (patched to 0 in tests). Also updates _make_mock_tmux() to return True for post-spawn liveness checks while keeping the pre-spawn stale-detection call returning has_session_initial (uses itertools.chain for precise call sequencing). 95 tmux_session tests pass; 208 across the tmux/agent_status/transport suite. Closes #513. --- src/pinky_daemon/tmux_session.py | 14 ++++++++++++++ tests/test_tmux_session.py | 30 +++++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/pinky_daemon/tmux_session.py b/src/pinky_daemon/tmux_session.py index 612b9062..d5b7930a 100644 --- a/src/pinky_daemon/tmux_session.py +++ b/src/pinky_daemon/tmux_session.py @@ -661,6 +661,8 @@ class _InflightMeta: # Generous (60s) because tmux startup is cheap but the claude REPL may need # to authenticate / fetch first turn / load CLAUDE.md. _COLD_START_TIMEOUT_SEC = 60.0 +# Brief window to catch "spawn returns 0 but REPL dies immediately" — see #513. +_SPAWN_LIVENESS_DELAY_SEC = 0.15 # Per-turn timeout: how long ANY single in-flight turn can be at the # HEAD of ``_inflight_metas`` without its ``stop_hook_summary`` landing @@ -1696,6 +1698,18 @@ async def _spawn(): f"{_COLD_START_TIMEOUT_SEC}s" ) from None + # Defense-in-depth: tmux new-session returns 0 as long as the shell + + # command launched, but if the in-pane command exits immediately (bad + # flag, auth error, missing binary, etc.) the detached session reaps + # itself silently. Without this check the state machine ends up + # CONNECTED against nothing and queued messages stack forever. See #513. + await asyncio.sleep(_SPAWN_LIVENESS_DELAY_SEC) + if not await self._tmux.has_session(): + raise RuntimeError( + f"tmux[{self.agent_name}]: session died immediately after spawn " + f"(in-pane command likely exited; check claude invocation and logs)" + ) + # NOTE: ``force_fresh_context_once`` consumption is deferred to # the end of this method (after tailer startup also succeeds), # NOT here — see the load-bearing comment at the consume site diff --git a/tests/test_tmux_session.py b/tests/test_tmux_session.py index b228e024..60edafe4 100644 --- a/tests/test_tmux_session.py +++ b/tests/test_tmux_session.py @@ -16,6 +16,7 @@ from __future__ import annotations import asyncio +import itertools import json as _json import re import time as _time @@ -96,7 +97,11 @@ def _make_mock_tmux(*, has_session_initial: bool = False) -> MagicMock: """ tmux = MagicMock(spec=_TmuxControl) tmux.session_name = "pinky-test" - tmux.has_session = AsyncMock(return_value=has_session_initial) + # First call: has_session_initial (stale-session check before spawn). + # Subsequent calls: True (post-spawn liveness check — session is alive). + tmux.has_session = AsyncMock( + side_effect=itertools.chain([has_session_initial], itertools.repeat(True)) + ) tmux.new_session = AsyncMock(return_value=_ok()) tmux.kill_session = AsyncMock(return_value=_ok()) tmux.send_keys = AsyncMock(return_value=_ok()) @@ -204,6 +209,29 @@ async def test_cold_start_failure_drives_to_dead_via_boot_failed() -> None: assert ss.state == SessionState.DEAD +@pytest.mark.asyncio +async def test_cold_start_session_dies_immediately_drives_to_dead() -> None: + """If tmux new-session returns 0 but the in-pane command exits immediately + (bad flag, auth error, missing binary), the detached session auto-reaps. + The post-spawn liveness check must catch this and land BOOTING → DEAD via + BOOT_FAILED rather than leaving the state machine CONNECTED against nothing. + See issue #513.""" + tmux = _make_mock_tmux() + # Spawn succeeds; both liveness calls see the session as gone. + tmux.has_session = AsyncMock(side_effect=[False, False]) + # No-op the liveness sleep so the test runs at full speed. + original_sleep = tmux_session.asyncio.sleep + tmux_session.asyncio.sleep = AsyncMock(return_value=None) + ss, _ = _make_session(tmux=tmux) + try: + with pytest.raises(RuntimeError, match="session died immediately after spawn"): + await ss.connect() + finally: + tmux_session.asyncio.sleep = original_sleep + assert ss.state == SessionState.DEAD + assert tmux.new_session.await_count == 1 + + @pytest.mark.asyncio async def test_cold_start_reaps_stale_session_before_spawn() -> None: """If a stale tmux session is found at cold-start time (e.g. previous