Skip to content
Merged
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
1 change: 1 addition & 0 deletions src/ccbot/bot/commands/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/ccbot/handlers/archive.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
23 changes: 17 additions & 6 deletions src/ccbot/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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)
Expand Down
24 changes: 24 additions & 0 deletions tests/ccbot/test_idle_archive_setting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
17 changes: 17 additions & 0 deletions tests/ccbot/test_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions tests/e2e/test_archive_and_recovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading