diff --git a/src/ccgram/session.py b/src/ccgram/session.py index 1aa357dd..63235c97 100644 --- a/src/ccgram/session.py +++ b/src/ccgram/session.py @@ -206,7 +206,11 @@ async def resolve_stale_ids(self) -> None: # Lazy: window_resolver imports session-state types; hoisting forms # session → window_resolver → session.WindowState cycle. # Lazy: window_resolver pulls back into session manager - from .window_resolver import LiveWindow, resolve_stale_ids as _resolve + from .window_resolver import ( + LiveWindow, + is_suspicious_empty_window_list, + resolve_stale_ids as _resolve, + ) windows = await tmux_manager.list_windows() live = [ @@ -240,9 +244,19 @@ async def resolve_stale_ids(self) -> None: self._save_state() logger.info("Startup re-resolution complete") - # Prune session_map.json entries for dead windows + # Prune session_map.json entries for dead windows — unless the + # empty result looks like a transient multiplexer failure rather + # than every window actually closing (see is_suspicious_empty_window_list). live_ids = {w.window_id for w in live} - session_map_sync.prune_session_map(live_ids) + if is_suspicious_empty_window_list(live_ids, len(self.window_states)): + logger.warning( + "Startup: list_windows() returned no windows while %d " + "window_state(s) are persisted; skipping session_map prune " + "(likely transient multiplexer failure)", + len(self.window_states), + ) + else: + session_map_sync.prune_session_map(live_ids) # Sync display names from live tmux windows (detect external renames) live_pairs = [(w.window_id, w.window_name) for w in live] diff --git a/src/ccgram/session_monitor.py b/src/ccgram/session_monitor.py index c7d136f5..11fb2b0a 100644 --- a/src/ccgram/session_monitor.py +++ b/src/ccgram/session_monitor.py @@ -36,6 +36,7 @@ from .monitor_events import NewMessage, NewWindowEvent, SessionInfo from .transcript_reader import TranscriptReader from .utils import task_done_callback +from .window_resolver import is_suspicious_empty_window_list import json @@ -430,7 +431,20 @@ async def _monitor_loop(self) -> None: all_windows = await tmux_manager.list_windows() live_window_ids = {w.window_id for w in all_windows} - session_map_sync.prune_session_map(live_window_ids) + if is_suspicious_empty_window_list(live_window_ids, len(current_map)): + # See is_suspicious_empty_window_list: an empty result here + # is more likely a transient multiplexer failure than every + # window closing simultaneously. Skip pruning this cycle + # and retry on the next poll instead of permanently + # breaking Telegram routing for those windows. + logger.warning( + "list_windows() returned no windows while session_map " + "still tracks %d; skipping prune this cycle " + "(likely transient multiplexer failure)", + len(current_map), + ) + else: + session_map_sync.prune_session_map(live_window_ids) known_window_ids = set(current_map.keys()) await self._emit_unbound_window_events(all_windows, known_window_ids) await self._emit_known_unbound_window_events( diff --git a/src/ccgram/window_resolver.py b/src/ccgram/window_resolver.py index 8222c921..c83ceb57 100644 --- a/src/ccgram/window_resolver.py +++ b/src/ccgram/window_resolver.py @@ -27,6 +27,22 @@ def is_window_id(key: str) -> bool: return key.startswith("@") and len(key) > 1 and key[1:].isdigit() +def is_suspicious_empty_window_list(live_window_ids: set, known_count: int) -> bool: + """True if a live-window poll came back empty while state expects windows. + + ``multiplexer.list_windows()`` returns an empty list both when the + session genuinely has no windows and when the underlying tmux/herdr + call failed transiently (``get_session()`` swallows the error into + ``None``, and the caller can't tell the two apart from the return + value alone). When ``known_count`` (session_map entries or persisted + window_states) is non-zero, "every window closed at once" is far less + likely than "the multiplexer call hiccuped" — callers should treat + this as a transient failure and skip destructive pruning for this + cycle rather than trusting the empty result. + """ + return not live_window_ids and known_count > 0 + + def session_map_prefix_for(mux_name: str, session_name: str) -> str: """Return the session_map key prefix for a given multiplexer backend. diff --git a/tests/ccgram/test_session.py b/tests/ccgram/test_session.py index 751f7463..0f363264 100644 --- a/tests/ccgram/test_session.py +++ b/tests/ccgram/test_session.py @@ -1046,6 +1046,36 @@ async def test_dead_window_state_preserved(self, mgr: SessionManager) -> None: assert mgr.window_states["@1"].cwd == "/my/project" assert mgr.window_states["@1"].provider_name == "codex" + async def test_empty_list_windows_does_not_wipe_session_map( + self, mgr: SessionManager, tmp_path, monkeypatch + ) -> None: + """A transient list_windows() failure must not prune live bindings. + + Regression test: list_windows() returns [] both when the session + genuinely has no windows and when the underlying tmux/herdr call + failed transiently (get_session() swallows the error). Previously + resolve_stale_ids() trusted an empty result unconditionally and + pruned session_map.json for every known window, permanently + breaking Telegram routing until the next SessionStart. + """ + session_map_file = tmp_path / "session_map.json" + session_map_file.write_text( + json.dumps({"ccgram:@1": {"session_id": "sid-1", "cwd": "/a"}}) + ) + monkeypatch.setattr("ccgram.session.config.session_map_file", session_map_file) + monkeypatch.setattr("ccgram.session.config.tmux_session_name", "ccgram") + + thread_router.bind_thread(100, 1, "@1", window_name="proj") + mgr.window_states["@1"] = WindowState(cwd="/a", provider_name="claude") + from ccgram.multiplexer.tmux import tmux_manager + + with patch.object(tmux_manager, "list_windows", AsyncMock(return_value=[])): + await mgr.resolve_stale_ids() + + result = json.loads(session_map_file.read_text()) + assert "ccgram:@1" in result + assert "@1" in mgr.window_states + class _FakeMux: """Minimal multiplexer stand-in: capability flag + live window list.""" diff --git a/tests/ccgram/test_window_resolver.py b/tests/ccgram/test_window_resolver.py index 99434bc8..049ea20a 100644 --- a/tests/ccgram/test_window_resolver.py +++ b/tests/ccgram/test_window_resolver.py @@ -8,6 +8,7 @@ from ccgram.window_resolver import ( LiveWindow, + is_suspicious_empty_window_list, is_window_id, resolve_stale_ids, ) @@ -29,6 +30,25 @@ def test_is_window_id(self, key: str, expected: bool) -> None: assert is_window_id(key) == expected +class TestIsSuspiciousEmptyWindowList: + def test_empty_result_with_known_windows_is_suspicious(self) -> None: + # list_windows() failing transiently and returning [] while state + # still expects windows is exactly the case that should be flagged + # so callers don't prune every live binding on a single hiccup. + assert is_suspicious_empty_window_list(set(), known_count=3) is True + + def test_empty_result_with_no_known_windows_is_not_suspicious(self) -> None: + # Genuinely nothing tracked yet (e.g. fresh install) — an empty + # live-window list is expected, not evidence of a failed poll. + assert is_suspicious_empty_window_list(set(), known_count=0) is False + + def test_nonempty_result_is_never_suspicious(self) -> None: + # The multiplexer call clearly succeeded, so trust it regardless + # of how many windows are currently known. + assert is_suspicious_empty_window_list({"@1"}, known_count=5) is False + assert is_suspicious_empty_window_list({"@1"}, known_count=0) is False + + def _ws(name: str) -> SimpleNamespace: """Minimal WindowState stand-in with mutable window_name.""" return SimpleNamespace(window_name=name)