From cd2274d29592f545e799b6b17c9d261e52e5c17a Mon Sep 17 00:00:00 2001 From: Time4Mind <119820237+Time4Mind@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:12:44 +0300 Subject: [PATCH] fix: stop startup watcher for archived windows --- src/ccbot/bot/commands/lifecycle.py | 1 + src/ccbot/handlers/archive.py | 1 + src/ccbot/session.py | 23 +++++++++++++++++------ tests/ccbot/test_idle_archive_setting.py | 24 ++++++++++++++++++++++++ tests/ccbot/test_session.py | 17 +++++++++++++++++ tests/e2e/test_archive_and_recovery.py | 4 ++++ 6 files changed, 64 insertions(+), 6 deletions(-) diff --git a/src/ccbot/bot/commands/lifecycle.py b/src/ccbot/bot/commands/lifecycle.py index ad60a0df..ca9133a3 100644 --- a/src/ccbot/bot/commands/lifecycle.py +++ b/src/ccbot/bot/commands/lifecycle.py @@ -150,6 +150,7 @@ async def archive_session( """ wid = sess.window_id if wid: + session_manager.cancel_window_startup(wid) w = await tmux_manager.find_window_by_id(wid) if w: await tmux_manager.kill_window(w.window_id) diff --git a/src/ccbot/handlers/archive.py b/src/ccbot/handlers/archive.py index 30637a15..ac1a58bf 100644 --- a/src/ccbot/handlers/archive.py +++ b/src/ccbot/handlers/archive.py @@ -510,6 +510,7 @@ async def idle_archive_sweep(bot: Bot, user_id: int) -> int: for sess in candidates: wid = sess.window_id if wid: + session_manager.cancel_window_startup(wid) w = await tmux_manager.find_window_by_id(wid) if w: await tmux_manager.kill_window(w.window_id) diff --git a/src/ccbot/session.py b/src/ccbot/session.py index 5b01a948..0d7e715a 100644 --- a/src/ccbot/session.py +++ b/src/ccbot/session.py @@ -389,12 +389,18 @@ async def _typing_keepalive() -> None: _typing_keepalive(), name=f"resume-settle-typing:{window_id}" ) try: - settled = False - while not settled: + settled: bool | None = False + while settled is False: settled = await self._wait_for_resume_settle( window_id, backend=backend, resume=resume ) - if not settled: + if settled is None: + logger.warning( + "startup gate abandoned for vanished window %s", + window_id, + ) + return + if settled is False: logger.error( "startup gate remains closed for window %s; " "TUI readiness is still unproven", @@ -526,7 +532,7 @@ async def _wait_for_resume_settle( *, backend: str = "claude", resume: bool = True, - ) -> bool: + ) -> bool | None: """Block until a just-resumed window is safe to type into. A ``claude --resume`` of a near-limit transcript auto-compacts before @@ -539,8 +545,9 @@ async def _wait_for_resume_settle( * no busy spinner appeared within ``_RESUME_SETTLE_BUSY_GRACE`` seconds (small session — nothing to compact). - Returns True when settled, False on timeout (caller sends anyway — - best-effort, never worse than the old blind send). + Returns True when settled, False on timeout (the watcher retries so + queued startup messages are not lost), and None when the tmux window + vanished while it was being watched. """ loop = asyncio.get_event_loop() started = loop.time() @@ -549,6 +556,10 @@ async def _wait_for_resume_settle( idle_since: float | None = None while loop.time() < deadline: pane = await tmux_manager.capture_pane(window_id) + if pane is None: + window = await tmux_manager.find_window_by_id(window_id) + if window is None: + return None now = loop.time() busy = bool(pane) and parse_status_line(pane) is not None ready = bool(pane) and self._pane_has_ready_input(pane or "", backend) diff --git a/tests/ccbot/test_idle_archive_setting.py b/tests/ccbot/test_idle_archive_setting.py index 22f2911d..2a27c4ab 100644 --- a/tests/ccbot/test_idle_archive_setting.py +++ b/tests/ccbot/test_idle_archive_setting.py @@ -80,3 +80,27 @@ async def test_idle_archive_sweep_uses_user_setting() -> None: assert archived == 0 find_idle.assert_called_once_with(12 * 3600.0) + + +@pytest.mark.asyncio +async def test_idle_archive_cancels_startup_watcher() -> None: + sess = SimpleNamespace(window_id="@9", claude_session_id="", id="deadbeef") + with ( + patch.object( + session_manager, + "get_user_settings", + return_value={"session_idle_hours": 12}, + ), + patch.object(session_manager, "find_idle_to_archive", return_value=[sess]), + patch.object(session_manager, "cancel_window_startup") as cancel_startup, + patch( + "ccbot.handlers.archive.tmux_manager.find_window_by_id", + new=AsyncMock(return_value=None), + ), + patch("ccbot.handlers.archive.clear_session_state", new=AsyncMock()), + patch.object(session_manager, "mark_session_archived"), + ): + archived = await idle_archive_sweep(MagicMock(), 42) + + assert archived == 1 + cancel_startup.assert_called_once_with("@9") diff --git a/tests/ccbot/test_session.py b/tests/ccbot/test_session.py index fb3e234b..1bff71bf 100644 --- a/tests/ccbot/test_session.py +++ b/tests/ccbot/test_session.py @@ -371,6 +371,23 @@ async def test_non_resuming_window_sends_immediately( mock_tmux.send_keys.assert_awaited_once() mock_tmux.capture_pane.assert_not_called() + @pytest.mark.asyncio + async def test_watcher_stops_when_window_disappears( + self, mgr: SessionManager, monkeypatch, fast_gate + ) -> None: + """A killed startup window must not leave a retrying watcher behind.""" + mock_tmux = self._mock_tmux(monkeypatch, lambda _w: None) + mock_tmux.find_window_by_id.return_value = None + mgr.mark_window_starting("@1", backend="claude", resume=False) + + task = mgr._resume_settle_tasks["@1"] + await task + + assert "@1" not in mgr._resuming_windows + assert "@1" not in mgr._resume_settle_tasks + assert mock_tmux.capture_pane.await_count == 1 + mock_tmux.send_keys.assert_not_awaited() + def test_mark_noop_when_disabled(self, mgr: SessionManager, monkeypatch) -> None: """resume_settle_timeout=0 disables the gate — nothing is flagged.""" monkeypatch.setattr(config, "resume_settle_timeout", 0.0) diff --git a/tests/e2e/test_archive_and_recovery.py b/tests/e2e/test_archive_and_recovery.py index ebd9e6bd..a3debe67 100644 --- a/tests/e2e/test_archive_and_recovery.py +++ b/tests/e2e/test_archive_and_recovery.py @@ -38,12 +38,16 @@ async def test_archive_session_kills_window_and_orphans(fake_tmux, fake_bot): claude_session_id=CLAUDE_SID, active_for=USER_ID, ) + session_manager._resuming_windows.add(WINDOW_ID) + session_manager._pending_sends[WINDOW_ID] = ["queued startup prompt"] await archive_session(USER_ID, fake_bot, sess, completed=False) # tmux window killed, then orphan claude --resume processes mopped up. assert WINDOW_ID in fake_tmux.killed assert CLAUDE_SID in fake_tmux.orphans_killed + assert WINDOW_ID not in session_manager._resuming_windows + assert WINDOW_ID not in session_manager._pending_sends # Session record flipped to archived; active pointer dropped (no # replacement available).