Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/pinky_daemon/tmux_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
30 changes: 29 additions & 1 deletion tests/test_tmux_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from __future__ import annotations

import asyncio
import itertools
import json as _json
import re
import time as _time
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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
Expand Down
Loading