Files
hermes-agent/gateway/session_local_close.py
Teknium 29abd70464 fix(gateway): a chat's old conversation stays usable from the apps, and its replies stop reaching the chat
Teknium's ruling: "I dont want to lock out desktop / mobile app users from a session. Please just
make it so that telegram doesn't get responses from A if they are on a /new'd convo".

When a messaging chat names another conversation (/new, /resume, /branch, a handoff, its next
inbound), every other live session on its route used to go read-only (session_superseded, cold
mounts since 7ba53f4756, handed-off chats since #144708, warm views in this PR's first commit).
Now they DETACH instead (gateway/session_detached.py): the live session moves off the chat's route
onto a key naming the conversation (agent:<ns>:detached:<sid>), pinned on a copy of its source
(SessionSource.detached_route, wire-invisible). Its turns then run on the ordinary messaging path:

- build_session_key answers the pinned key, so the running slot, agent cache, busy guard and the
  agent a Stop reaches are the conversation's own, never the chat's;
- SessionStore.get_or_create_session resolves it to the conversation itself (its compression tip),
  never creating or persisting a route;
- _delivery_adapter_for answers the profile's LocalSessionAdapter, the local sessions' delivery:
  sends publish nothing, viewers read the canonical event stream (local_viewer_turn covers it, so
  status, notices and approvals go there too). session_local_recovery.local_adapter creates it on
  first use, since a messaging-only gateway has none until a local session restores.

The key has no platform slot, so a key parser (_parse_session_key) finds no chat to deliver to.
The chat naming the conversation again (/resume) moves it back onto its route (name_lane), and
its next turn delivers to the chat again. A cold open of a row the lane moved past mounts detached.
/new, /resume and /branch name the lane at once (_name_lane), not at the chat's next message, and
_publish_route does on every publish, so a warm view detaches the moment its chat moves on.
History mounts keep their own read-only gate (require_history_access). LiveSession.superseded,
history_mount and handed_off are gone (with route_recovery.mount/moved_local, which only fed them);
LiveSession.detached says what it means.
2026-10-27 17:04:02 -07:00

40 lines
1.9 KiB
Python

"""The picker's view of a canonical local session its last viewer closed, as main listed it.
Main's sidecar stamped ``tui_close`` (``/new``, a resume switch) and ``tui_shutdown`` (exit) on the
session row it owned; pickers then listed it as resumable. The stamp itself is the owner's
``session.detach {end_reason}`` (``session_acp_lifecycle.end_idle_viewer_session``); this module only
decides which live rows read as closed.
"""
from gateway.session_history_mount import is_history_mount
from hermes_state_local import local_receipt
from hermes_state_runtime import RuntimeStoreError
def _local_target(authority, session_id):
try:
return local_receipt(authority.db, session_id)['entry']['session_id']
except (RuntimeStoreError, KeyError, TypeError):
return None # not a local session (API binding, malformed receipt): nothing to read as closed
def closed_idle_unviewed(live, execution_state, target_ended_at):
"""The verdict on facts already read: the live listings read ``target_ended_at`` for a whole batch
in one off-loop query (``gateway/session_live_listing``)."""
if live is None or live.subscribers or execution_state != 'idle':
return False
if live.detached or is_history_mount(live):
return True # an old messaging conversation or a stored one: history once no one views it
return target_ended_at is not None
def closed_and_unviewed(authority, session_id, handle):
"""A live-feed row main would list as resumable: closed, idle and with no viewer left."""
live = authority.sessions.get(session_id)
if live is None or live.subscribers or handle.execution_state != 'idle':
return False
if live.detached or is_history_mount(live):
return True
target = _local_target(authority, session_id)
row = authority.db.get_session_list_row(target) if target else None
return closed_idle_unviewed(live, handle.execution_state, row and row.get('ended_at'))