diff --git a/nerve/agent/sessions.py b/nerve/agent/sessions.py index aa6734f..74bd07b 100644 --- a/nerve/agent/sessions.py +++ b/nerve/agent/sessions.py @@ -205,9 +205,9 @@ async def get_active_session( ) -> str: """Get or create the active session for a channel. - If the channel has a mapped session with activity within the sticky - period, reuse it. Otherwise, create a fresh session and map the - channel to it. + Reuses the channel's mapped session when it is currently active + (a turn is in flight) or when its last activity falls within the + sticky period. Otherwise creates a fresh session and remaps. """ row = await self.db.get_channel_session(channel_key) if row: @@ -222,7 +222,25 @@ async def get_active_session( return session_id def _is_within_sticky_period(self, session: dict) -> bool: - """Check if a session had activity within the sticky period.""" + """Check whether a session is still the channel's owner. + + Active sessions are always sticky regardless of the timestamp. + A turn that hangs never reaches mark_active() at engine.run's + end, so last_activity_at freezes at turn-start; without this + carve-out, a hang lasting longer than sticky_period_minutes + would orphan the session and route the user's follow-up + message into a fresh, empty one. The engine's idle-message + timeout in receive_response (cli_idle_timeout_seconds) bounds + how long a truly hung session can hold the channel before it + flips to idle/error and the next message can mint a fresh + session. + + Idle sessions fall back to the time-based check so channels + that have been quiet for longer than sticky_period_minutes + roll over to a new session as before. + """ + if session.get("status") == SessionStatus.ACTIVE.value: + return True ts = session.get("last_activity_at") or session.get("updated_at") if not ts: return False diff --git a/tests/test_sessions.py b/tests/test_sessions.py index 48295a7..59d7a8d 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -127,6 +127,55 @@ async def test_auto_session_rotated_after_sticky_period(self, sm: SessionManager sid2 = await sm.get_active_session("telegram:222", source="telegram") assert sid1 != sid2 + async def test_auto_session_reused_when_active_despite_old_timestamp( + self, sm: SessionManager, db: Database, + ): + """An active session keeps the channel even if last_activity_at is stale. + + A hung turn never reaches mark_active() at engine.run's end, so + last_activity_at freezes at turn-start. Without the active-status + carve-out in _is_within_sticky_period, a hang lasting longer than + sticky_period_minutes would orphan the session and route the + user's follow-up message into a fresh, empty one. + """ + sid1 = await sm.get_active_session("telegram:333", source="telegram") + # Mark active and back-date timestamps to look like a hung turn that + # started long before the sticky-period cutoff. + await sm.mark_active(sid1, sdk_session_id="sdk-stuck") + await db.update_session_fields(sid1, { + "last_activity_at": "2020-01-01T00:00:00+00:00", + }) + await db.db.execute( + "UPDATE sessions SET updated_at = '2020-01-01T00:00:00' WHERE id = ?", + (sid1,), + ) + await db.db.commit() + sid2 = await sm.get_active_session("telegram:333", source="telegram") + assert sid1 == sid2 + + async def test_auto_session_rotated_when_idle_after_sticky_period( + self, sm: SessionManager, db: Database, + ): + """Idle sessions still roll over after the sticky period. + + Once a hung session has been recovered (status flipped to idle by + the engine's exception path), the time-based cutoff applies again + and a new follow-up message mints a fresh session. + """ + sid1 = await sm.get_active_session("telegram:444", source="telegram") + await sm.mark_active(sid1, sdk_session_id="sdk-x") + await sm.mark_idle(sid1) + await db.update_session_fields(sid1, { + "last_activity_at": "2020-01-01T00:00:00+00:00", + }) + await db.db.execute( + "UPDATE sessions SET updated_at = '2020-01-01T00:00:00' WHERE id = ?", + (sid1,), + ) + await db.db.commit() + sid2 = await sm.get_active_session("telegram:444", source="telegram") + assert sid1 != sid2 + async def test_set_and_get_active_session(self, sm: SessionManager, db: Database): await sm.get_or_create("ch-1") await sm.set_active_session("telegram:123", "ch-1")