From 6406be0f97322eab711224cf90793c42d7af4e96 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 19 Sep 2026 21:26:07 -0700 Subject: [PATCH] fix(tui_gateway): subagent.list follows the conversation across UI-sid and compression rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `subagent.list` matched live child records only on the exact `owner_session_id` and session-record identity frozen at spawn. A Desktop reconnect / resume remints the UI session id and rebuilds the session record, and compression rotates the durable key, so every still-running child vanished from the Desktop subagent panel for good while it kept working in the background (#114909, bug 2). The read-only roster now also admits records whose durable lineage (`owner_agent_session_id` resolved to its compression tip via the parent's SessionDB) is the calling session's agent — the same `_owns_subagent_record` spine the in-process `delegate_task(action="list")` already uses, so no new record field, no registry rewrite at attach/compress sites. Control RPCs (steer / interrupt / tail) keep the exact generation authority: visibility is widened, authority is not. Slimmer redo of the visibility half of #115189 by @aydnOktay, which re-pointed `owner_session_id` / `owner_session_record` on every list/steer call. Co-authored-by: aydnOktay --- tests/tui_gateway/test_subagent_snapshot.py | 42 +++++++++++++++++++++ tui_gateway/AGENTS.md | 10 +++-- tui_gateway/methods_subagents.py | 26 ++++++++++++- 3 files changed, 74 insertions(+), 4 deletions(-) diff --git a/tests/tui_gateway/test_subagent_snapshot.py b/tests/tui_gateway/test_subagent_snapshot.py index 774e36303c..eee4eeff1a 100644 --- a/tests/tui_gateway/test_subagent_snapshot.py +++ b/tests/tui_gateway/test_subagent_snapshot.py @@ -251,3 +251,45 @@ def test_any_attach_path_carries_subagent_authority_without_registry_sync(runtim denied = call("subagent." + method, via=old, subagent_id="child", text="stale") assert "error" in denied or denied["result"].get("status") == "rejected" assert steered == ["go"] and len(stopped) == 1 + + +def test_list_follows_the_conversation_across_ui_sid_and_compression_rotation(runtime, tmp_path): + """#114909: a Desktop reconnect / resume remints the UI session id (new sid, new session record) and + compression rotates the durable key. The read-only roster must keep showing the conversation's still- + running children; a foreign conversation on the same transport sees nothing and control stays exact.""" + from hermes_state import SessionDB + from tools.delegate_tool_child_run import _register_child + from tools.delegate_tool_registry import _unregister_subagent + + server, owner, transport, call = runtime + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session(session_id="conv", source="tui", model="test") + db.append_message(session_id="conv", role="user", content="hi") + db.end_session("conv", end_reason="compression") + db.create_session(session_id="conv2", source="tui", model="test", parent_session_id="conv") + db.append_message(session_id="conv2", role="user", content="continued") + owner["agent"] = parent = SimpleNamespace(session_id="conv", _session_db=db) + stopped = [] + child = SimpleNamespace(_subagent_id="child", _delegate_depth=1, model="test", + hard_interrupt=lambda message: stopped.append(message)) + _register_child(child, parent, "owned task", owner_session_id="ui-owner", + owner_transport=transport, owner_session_record=owner) + try: + def live_session(key): + return {"session_key": key, "history": [], "transport": transport, + "agent": SimpleNamespace(session_id=key, _session_db=db)} + + # Reminted UI sid, rebuilt session record, same durable conversation. + server._sessions = {"ui-new": live_session("conv")} + assert [r["subagent_id"] for r in call("subagent.list", session_id="ui-new")["result"]["subagents"]] == ["child"] + # Compression rotated the durable key as well (conv -> conv2). + server._sessions = {"ui-new2": live_session("conv2"), "ui-other": live_session("unrelated")} + assert [r["subagent_id"] for r in call("subagent.list", session_id="ui-new2")["result"]["subagents"]] == ["child"] + assert call("subagent.list", session_id="ui-other")["result"]["subagents"] == [] + assert "error" in call("subagent.list", session_id="ui-new2", via=SimpleNamespace(write=lambda frame: True)) + # Visibility widened, authority not: control from the rotated sid is still refused. + assert not call("subagent.interrupt", session_id="ui-new2", subagent_id="child")["result"]["found"] + assert stopped == [] + finally: + _unregister_subagent("child") + db.close() diff --git a/tui_gateway/AGENTS.md b/tui_gateway/AGENTS.md index 221144206a..b4e245cbbd 100644 --- a/tui_gateway/AGENTS.md +++ b/tui_gateway/AGENTS.md @@ -91,11 +91,15 @@ profile's does not, and that `os.environ` is unchanged afterwards. ## Shared subagent snapshots `subagent.list({session_id})` returns `{subagents, delegations}` for the calling -transport's live session. Live child records are pinned to the exact session -record and transport. Child authority is resolved at RPC time against the owning session's +transport's live session. The roster is read-only and follows the CONVERSATION: exact-owner +records plus children whose durable lineage (`owner_agent_session_id` → compression tip, the +same spine as in-process `delegate_task(action="list")`) is the session's agent, because a +Desktop reconnect / resume remints the UI session id and compression rotates the key while the +children keep running (#114909). Control (`steer` / `interrupt` / `tail`) stays pinned to the +exact session record and transport. Child authority is resolved at RPC time against the owning session's LIVE transport slot, so every authenticated reattach path (prompt.submit, queued drain, resume, activate, viewer failover) carries it with no registry bookkeeping — never add a -per-record transport sync at an attach site; foreign or retired generations remain inaccessible. `last_tool` is the last started tool, not an in-flight +per-record transport sync at an attach site; foreign or retired generations remain uncontrollable. `last_tool` is the last started tool, not an in-flight indicator. Async completion units are not agents and lack exact generation authority; `delegations` remains an empty array for wire compatibility. No dispatch context, results, callbacks, or routing keys are sent. Clients hydrate from this snapshot diff --git a/tui_gateway/methods_subagents.py b/tui_gateway/methods_subagents.py index c316e7f5a1..66ad0e7023 100644 --- a/tui_gateway/methods_subagents.py +++ b/tui_gateway/methods_subagents.py @@ -26,13 +26,37 @@ def _owned_subagent_records(session_id, transport, owner): and r.get("owner_session_record") is owner] +def _visible_subagent_records(session_id, transport, owner): + """Read-only roster for ``subagent.list``: the exact-owner records PLUS children whose durable + conversation lineage (``owner_agent_session_id`` resolved to its compression tip) is this + session's agent — the same spine ``delegate_task(action="list")`` walks in-process. + + Spawn freezes ``owner_session_id`` to the UI session id of that moment; a Desktop reconnect / + resume remints the id and rebuilds the session record, and compression rotates the durable key, + so the exact match alone hid every still-running child from the panel for good (#114909). + Control RPCs (steer / interrupt / tail) keep the exact generation authority.""" + from tools.delegate_tool_registry import ( + _active_subagents, _active_subagents_lock, _owns_subagent_record, _subagent_transport_matches, + ) + + with _active_subagents_lock: + records = [dict(r) for r in _active_subagents.values()] + agent = owner.get("agent") + # Lineage resolution may read the session DB — evaluated outside the registry lock. + return [r for r in records + if (r.get("owner_session_id") == session_id + and _subagent_transport_matches(r, transport) + and r.get("owner_session_record") is owner) + or _owns_subagent_record(r, agent)] + + @method("subagent.list") def _(rid, params): session_id = _str_param(params, "session_id") transport, owner = _current_session_steer_authority(session_id) if transport is None or owner is None: return _err(rid, 4001, "session not found or not owned by this transport") - live = _owned_subagent_records(session_id, transport, owner) + live = _visible_subagent_records(session_id, transport, owner) return _ok(rid, { "subagents": [{key: r.get(key) for key in _SUBAGENT_SNAPSHOT_FIELDS} for r in live], "delegations": [],