fix(tui_gateway): subagent.list follows the conversation across UI-sid and compression rotation
`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 <aydnoktay@users.noreply.github.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
Reference in New Issue
Block a user