Item 2 of #112548: a Desktop/dashboard build that predates server→client
requests has no response path, so every clarify/approval/sudo/secret/vault/
connection/bridge request sat for the full deadline (clarify: 300s). Only the
tour probed. Clients now advertise once per connection
(`client.capabilities {server_requests: true}`, sent by the shared TypeScript
channel on `gateway.ready`); `send()` / `send_async()` return the
error-response shape (None) at once when every WebSocket peer of the session
is a build that never advertised. Sessions with no client attached still wait
so the reconnect replay (`open_requests`) keeps working; the stdio TUI ships
with the backend and is not gated. The advertisement is dropped on disconnect.
Reviewer minors from #113227:
- tools/approval_gateway_wait.py: the verdict is the choice committed under
the approval lock while leaving the queue, so an /approve that lands after
the deadline check but before the entry is dropped is an answer, not a
timeout (the client was already acked "ok").
- tests/tui_gateway/test_protocol.py: the error-fails-fast test that only
restated pre-existing behaviour is replaced by the two capability
invariants (never advertised → fails fast; advertised → frame written,
waits, forgotten on disconnect).
- server_requests.send try/finally around event.wait already landed on main
(4371ed34a9); nothing to change.
Docs: programmatic-integration.md (advertise once per connection; method
list), tui_gateway/AGENTS.md; contracts regenerated.
131 lines
5.8 KiB
Python
131 lines
5.8 KiB
Python
"""Additive transport membership for shared live sessions."""
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
from tui_gateway.method_ctx import bind_module
|
|
|
|
# Leaf lock: callers may hold sessions/history locks, never acquire them here.
|
|
_session_transport_lock = threading.RLock()
|
|
|
|
|
|
def _transport_is_live_peer(transport) -> bool:
|
|
"""Exclude the process fallback sink, parked sentinel, and closed peers."""
|
|
return (transport is not None
|
|
and transport is not _detached_ws_transport
|
|
and transport is not _stdio_transport
|
|
and not isinstance(transport, (_DropTransport, StdioTransport))
|
|
and not _transport_is_dead(transport))
|
|
|
|
|
|
def _session_transport_contains(session: dict | None, transport) -> bool:
|
|
if not session or transport is None or _transport_is_dead(transport):
|
|
return False
|
|
existing = session.get("transport")
|
|
return existing is transport or (
|
|
isinstance(existing, FanoutTransport) and existing.contains(transport))
|
|
|
|
|
|
def _session_live_transports(session: dict | None) -> list:
|
|
existing = (session or {}).get("transport")
|
|
peers = existing.transports() if isinstance(existing, FanoutTransport) else [existing]
|
|
return [peer for peer in peers if _transport_is_live_peer(peer)]
|
|
|
|
|
|
def _session_has_live_transport(session: dict | None, *, excluding=None) -> bool:
|
|
return any(peer is not excluding for peer in _session_live_transports(session))
|
|
|
|
|
|
def _session_client_answers_requests(sid: str) -> bool:
|
|
"""Whether a server→client request for *sid* can be answered: False only when every live WebSocket
|
|
client attached to the session is a build that never sent ``client.capabilities`` (Desktop / dashboard
|
|
update separately from this backend; the stdio TUI ships with it). No attached client is still True — the
|
|
question waits in ``open_requests`` for the reconnect replay. Compute-host relays and other non-client
|
|
transports never count."""
|
|
from tui_gateway import server_requests
|
|
from tui_gateway.ws import WSTransport
|
|
clients = [peer for peer in _session_live_transports(_sessions.get(sid)) if isinstance(peer, WSTransport)]
|
|
return not clients or any(server_requests.answers_requests(peer) for peer in clients)
|
|
|
|
|
|
def _warn_foreign_login(session: dict, transport) -> None:
|
|
"""Ownership is not enforced; a second login sharing a session is only logged, and the agent keeps the
|
|
creator's user id."""
|
|
attaching = _transport_auth_user_id(transport)
|
|
if attaching is None:
|
|
return
|
|
creator = _session_auth_user_id(session)
|
|
if creator != attaching:
|
|
logger.warning("Session %s keeps the user id %s it was created with; a client logged in as %s attached",
|
|
session.get("session_key"), creator or "(none)", attaching)
|
|
|
|
|
|
def _attach_session_transport(session: dict | None, transport) -> bool:
|
|
"""Add live peers; flatten captured queued fanouts without nesting authority."""
|
|
if not session or transport is None:
|
|
return False
|
|
with _session_transport_lock:
|
|
if isinstance(transport, FanoutTransport):
|
|
# Snapshot and attach share detach's lock: a queued fanout cannot
|
|
# resurrect a still-open peer removed during flattening.
|
|
attached = [_attach_session_transport(session, peer) for peer in transport.transports()]
|
|
return any(attached)
|
|
existing = session.get("transport")
|
|
if _transport_is_dead(transport):
|
|
if isinstance(existing, FanoutTransport):
|
|
existing.detach(transport)
|
|
return False
|
|
if not _transport_is_live_peer(transport):
|
|
if _session_has_live_transport(session):
|
|
return False
|
|
session["transport"] = transport
|
|
return True
|
|
if existing is transport:
|
|
return True
|
|
if isinstance(existing, FanoutTransport):
|
|
if not existing.contains(transport):
|
|
_warn_foreign_login(session, transport)
|
|
existing.attach(transport)
|
|
return existing.contains(transport)
|
|
_warn_foreign_login(session, transport)
|
|
if _transport_is_live_peer(existing):
|
|
session["transport"] = FanoutTransport(existing, transport)
|
|
else:
|
|
session["transport"] = transport
|
|
return True
|
|
|
|
|
|
def _detach_session_transport(session: dict | None, transport) -> bool:
|
|
"""Remove membership; return whether another live client prevents parking."""
|
|
if not session:
|
|
return False
|
|
with _session_transport_lock:
|
|
(session.get("viewers") or {}).pop(transport, None)
|
|
existing = session.get("transport")
|
|
if isinstance(existing, FanoutTransport):
|
|
existing.detach(transport)
|
|
viewers = session.get("viewers") or {}
|
|
for viewer in list(viewers):
|
|
if not existing.contains(viewer) or _transport_is_dead(viewer):
|
|
viewers.pop(viewer, None)
|
|
# Keep the surviving mailbox: collapsing to a bare transport lets
|
|
# new frames overtake its already queued terminal/control events.
|
|
return _session_has_live_transport(session, excluding=transport)
|
|
|
|
|
|
def _detach_transport_from_sessions(transport) -> list[tuple[str, dict]]:
|
|
"""Remove even closed/pruned peers' viewer entries; return clientless slots."""
|
|
with _sessions_lock:
|
|
attached = []
|
|
for sid, session in _sessions.items():
|
|
existing = session.get("transport")
|
|
if (existing is transport
|
|
or isinstance(existing, FanoutTransport) and existing.contains(transport)
|
|
or transport in (session.get("viewers") or {})):
|
|
attached.append((sid, session))
|
|
return [(sid, session) for sid, session in attached
|
|
if not _detach_session_transport(session, transport)]
|
|
|
|
|
|
def register(server) -> None:
|
|
bind_module(globals(), server)
|