fix(tui): heartbeat ownership follows the gateway's live routing index, not the row source

_notif_gateway_owns_heartbeat decided by the immutable sessions.source column,
so a heartbeat on an ARCHIVED gateway-sourced row (Telegram /reset, idle/daily
auto-reset, compression rotation) was skipped by the Desktop poller and never
registered by the gateway either — restore_heartbeat_watches only claims a key
whose current session_id is that row. The tick belonged to nobody and stayed
due forever, where origin/main's Desktop fired it.

Ownership now uses the same predicate the gateway does: a gateway_routing entry
whose current session_id is this session, with an origin and not suspended
(SessionDB.gateway_routing_entry_for_session; both the session's profile store
and the launch store are consulted so multiplexed and per-profile gateways are
covered). No entry is fail-open, as on main. The check runs after the cheap
is_active/is_due gate so idle sessions never touch the DB per poll.

Probe (SessionStore telegram -> force_new, heartbeat on the archived sid):
before: desktop fired False / gateway watches [] / still due True;
after: desktop fired True / still due False; the current gateway sid is still
left to the gateway (desktop fired False) — the hijack fix stays intact.
This commit is contained in:
teknium1
2026-09-15 14:47:13 -07:00
committed by Teknium
parent 037771a692
commit 490ee1607a
3 changed files with 51 additions and 25 deletions
+12
View File
@@ -313,6 +313,18 @@ class SessionGatewayMixin:
)
return [dict(r) for r in rows]
def gateway_routing_entry_for_session(self, session_id: str) -> Optional[Dict[str, Any]]:
"""The routing entry (any scope) whose current owner is *session_id*, or None. The id lives
only inside ``entry_json``, so matching is done in Python; an archived/rotated row has none."""
for row in self._read_all("SELECT entry_json FROM gateway_routing"):
try:
entry = json.loads(row["entry_json"] or "{}")
except Exception:
continue
if isinstance(entry, dict) and entry.get("session_id") == session_id:
return entry
return None
def _delete_routing_entries_for_sessions(self, session_ids: Set[str]) -> int:
"""Drop ``gateway_routing`` rows pointing at any of *session_ids*; the target id
lives only inside ``entry_json``, so matching is done in Python over all scopes."""