Skip to content
Closed
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
20 changes: 17 additions & 3 deletions src/ccgram/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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]
Expand Down
16 changes: 15 additions & 1 deletion src/ccgram/session_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand Down
16 changes: 16 additions & 0 deletions src/ccgram/window_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
30 changes: 30 additions & 0 deletions tests/ccgram/test_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
20 changes: 20 additions & 0 deletions tests/ccgram/test_window_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from ccgram.window_resolver import (
LiveWindow,
is_suspicious_empty_window_list,
is_window_id,
resolve_stale_ids,
)
Expand All @@ -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)
Expand Down