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:
teknium1
2026-09-19 21:26:07 -07:00
committed by Teknium
parent 8ae0a21a4c
commit 6406be0f97
3 changed files with 74 additions and 4 deletions

View File

@@ -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()

View File

@@ -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

View File

@@ -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": [],