mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
fix(tui): scope session.interrupt pending-prompt release to the calling session (#12441)
session.interrupt on session A was blast-resolving pending clarify/sudo/secret prompts on ALL sessions sharing the same tui_gateway process. Other sessions' agent threads unblocked with empty-string answers as if the user had cancelled — silent cross-session corruption. Root cause: _pending and _answers were globals keyed by random rid with no record of the owning session. _clear_pending() iterated every entry, so the session.interrupt handler had no way to limit the release to its own sid. Fix: - tui_gateway/server.py: _pending now maps rid to (sid, Event) tuples. _clear_pending takes an optional sid argument and filters by owner_sid when provided. session.interrupt passes the calling sid so unrelated sessions are untouched. _clear_pending(None) remains the shutdown path for completeness. - _block and _respond updated to pack/unpack the new tuple format. Tests (tests/test_tui_gateway_server.py): 4 new cases. - test_interrupt_only_clears_own_session_pending: two sessions with pending prompts, interrupting one must not release the other. - test_interrupt_clears_multiple_own_pending: same-sid multi-prompt release works. - test_clear_pending_without_sid_clears_all: shutdown path preserved. - test_respond_unpacks_sid_tuple_correctly: _respond handles the tuple format. Also updated tests/tui_gateway/test_protocol.py to use the new tuple format for test_block_and_respond and test_clear_pending. Live E2E against the live Python environment confirmed cross-session isolation: interrupting sid_a released its own pending prompt without touching sid_b's. All 78 related tests pass.
This commit is contained in:
parent
ce410521b3
commit
dca439fe92
3 changed files with 144 additions and 11 deletions
|
|
@ -27,7 +27,7 @@ from tui_gateway.render import make_stream_renderer, render_diff, render_message
|
|||
|
||||
_sessions: dict[str, dict] = {}
|
||||
_methods: dict[str, callable] = {}
|
||||
_pending: dict[str, threading.Event] = {}
|
||||
_pending: dict[str, tuple[str, threading.Event]] = {}
|
||||
_answers: dict[str, str] = {}
|
||||
_db = None
|
||||
_stdout_lock = threading.Lock()
|
||||
|
|
@ -296,7 +296,7 @@ def _enable_gateway_prompts() -> None:
|
|||
def _block(event: str, sid: str, payload: dict, timeout: int = 300) -> str:
|
||||
rid = uuid.uuid4().hex[:8]
|
||||
ev = threading.Event()
|
||||
_pending[rid] = ev
|
||||
_pending[rid] = (sid, ev)
|
||||
payload["request_id"] = rid
|
||||
_emit(event, sid, payload)
|
||||
ev.wait(timeout=timeout)
|
||||
|
|
@ -304,10 +304,19 @@ def _block(event: str, sid: str, payload: dict, timeout: int = 300) -> str:
|
|||
return _answers.pop(rid, "")
|
||||
|
||||
|
||||
def _clear_pending():
|
||||
for rid, ev in list(_pending.items()):
|
||||
_answers[rid] = ""
|
||||
ev.set()
|
||||
def _clear_pending(sid: str | None = None) -> None:
|
||||
"""Release pending prompts with an empty answer.
|
||||
|
||||
When *sid* is provided, only prompts owned by that session are
|
||||
released — critical for session.interrupt, which must not
|
||||
collaterally cancel clarify/sudo/secret prompts on unrelated
|
||||
sessions sharing the same tui_gateway process. When *sid* is
|
||||
None, every pending prompt is released (used during shutdown).
|
||||
"""
|
||||
for rid, (owner_sid, ev) in list(_pending.items()):
|
||||
if sid is None or owner_sid == sid:
|
||||
_answers[rid] = ""
|
||||
ev.set()
|
||||
|
||||
|
||||
# ── Agent factory ────────────────────────────────────────────────────
|
||||
|
|
@ -1345,7 +1354,11 @@ def _(rid, params: dict) -> dict:
|
|||
return err
|
||||
if hasattr(session["agent"], "interrupt"):
|
||||
session["agent"].interrupt()
|
||||
_clear_pending()
|
||||
# Scope the pending-prompt release to THIS session. A global
|
||||
# _clear_pending() would collaterally cancel clarify/sudo/secret
|
||||
# prompts on unrelated sessions sharing the same tui_gateway
|
||||
# process, silently resolving them to empty strings.
|
||||
_clear_pending(params.get("session_id", ""))
|
||||
try:
|
||||
from tools.approval import resolve_gateway_approval
|
||||
resolve_gateway_approval(session["session_key"], "deny", resolve_all=True)
|
||||
|
|
@ -1684,9 +1697,10 @@ def _(rid, params: dict) -> dict:
|
|||
|
||||
def _respond(rid, params, key):
|
||||
r = params.get("request_id", "")
|
||||
ev = _pending.get(r)
|
||||
if not ev:
|
||||
entry = _pending.get(r)
|
||||
if not entry:
|
||||
return _err(rid, 4009, f"no pending {key} request")
|
||||
_, ev = entry
|
||||
_answers[r] = params.get(key, "")
|
||||
ev.set()
|
||||
return _ok(rid, {"status": "ok"})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue