Skip to content
Open
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
26 changes: 22 additions & 4 deletions nerve/agent/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
49 changes: 49 additions & 0 deletions tests/test_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down