fix(gateway): never bind a routing key to a delegate subagent session (#92859) - #93208
karanmish92-stack wants to merge 3 commits into
Conversation
|
Following up on the blocking review on #92872, since it names the acceptance bar for this path and I would rather test against it than assert past it. Both named shapes were already refused here, and are now pinned. Pushed
Neither test needed a code change, because this guard never reads the parent edge — it reads the target's own durable provenance ( Discrimination check. "Already correct" is not regression coverage, so I verified these tests actually bite by replacing Exactly the three shapes the review predicted an edge-keyed guard would miss, and nothing else. The tests are bound to the predicate, not to the surrounding logic. On review item 4 (existing rebind + verified compression-continuation cases stay green) — On not landing two competing fixes — agreed, and I don't think this competes with #92620. They sit at different layers and compose:
With both, the resolver resolves to the right owner and cannot mutate the route onto a child even if a future call path bypasses the walk. With only #92620, Provenance over the parent edge, one further reason not in the review: a row that has already been hijacked has had its CI has not reported on this branch yet; I'm not claiming green. Local: 17 in the new file, 125 across the related gateway session/delegation files, and 6207 across |
This is a well-built fix: guarding the single sink (
Minor: subagent-ness now has two homes (Python helper + SQL fragment); the new tests pin both sides independently, which mostly covers the drift risk — item 1 is the residual case they can't catch. |
|
@Enough1122 1. Definition drift (fixed, with a wider predicate than suggested)You proposed NULLIF(TRIM(COALESCE(json_extract(COALESCE({a}.model_config, '{}'), '$._delegate_from'), '')), '') IS NULLVerified the new test actually regresses, rather than assuming it does. With only the SQL reverted to the pre-fix predicate and everything else in place: Restore the fix and it is 21 passed. 2. Caller behavior on refusal (audited + fixed the one path that needed it)Audited all five
So not a silent no-op —
The pre-check is guarded on 3. Fail-open logging (fixed)Agreed on the trade — breaking logger.warning(
"switch_session subagent pre-check failed for %s; allowing the bind "
"(fail-open). A delegate row could be bound to routing key %s if this "
"recurs (#92859).",
target_session_id, session_key, exc_info=True,
)Suite
On the "two homes" point: they now agree by construction on every shape I could find, and |
…NousResearch#92859) A delegate_task batch dispatched from a Discord DM ended with the DM's routing key bound to one of the spawned children. The bind went through SessionStore.switch_session(), which promotes the outgoing session to the un-resurrectable 'session_switch' boundary — so the human parent was killed while its own children were still running, every in-flight delegation from that parent was then classified "terminal" and terminally dropped, and the user's next DM was routed into a leaf subagent that knew nothing about the conversation. Reproduced three times on the reporter's machine; 4/4 batches whose origin was a routing key were dropped. The guard goes at the sink rather than at one caller. switch_session() is the single funnel every route rebind passes through (async-completion pinning at run.py:16357, /resume, CLI handoff, Telegram topic rebinding), so refusing there closes the class instead of the one observed call path, and no future call site can reintroduce it. A subagent row is identified by provenance — source='subagent' OR the durable model_config._delegate_from marker delegate_tool stamps at creation — not by its parent edge: a grandchild's parent_session_id points at another child, and a row that has already been hijacked once has had its source overwritten with the platform name by record_gateway_session_peer (the literal shape of session 20260823_041917_519cdb in the report). Restart recovery is fenced the same way. find_latest_gateway_session_for_peer() ranked the hijacked child above the real conversation on recency, so a gateway restart would have handed the chat straight back to the subagent; both its keyed query and its peer-tuple fallback now exclude subagent rows. create_session already refused to let delegate children inherit routing keys at creation (hermes_state.py:5218) — this extends the same rule to the two paths that could grant one afterwards. Tests: tests/gateway/test_subagent_route_bind_guard.py (15). Sabotage-verified — disabling the switch_session guard and removing the SQL fence fails the 4 behavioral tests and leaves the 11 detection/regression tests green.
…two failure shapes The blocking review on NousResearch#92872 named two shapes a parent-edge predicate cannot cover, both of which apply to any fix on this path: 1. compression (NousResearch#69312) advances the route from P to tip P2 while a delegation spawned by P is still running, so the child's parent edge (P) no longer equals the routed id (P2) and an edge-keyed guard stops firing; 2. nested delegation (role=orchestrator) puts a grandchild one hop further out, so the edge cannot prove internal descent at all. This guard reads the target's own provenance and never the parent edge, so both were already refused — but "already correct" is not regression coverage. These tests fail if the predicate is ever swapped back to an edge comparison: verified by replacing is_internal_subagent_row() with `target_row.parent_session_id == <routed id>`, which fails exactly these two plus the existing nested/rehijacked case and leaves the other 14 green.
Addresses the three items from the review on NousResearch#93208. 1. Definition drift between the Python and SQL halves of one invariant. is_internal_subagent_row treats a blank _delegate_from as absent (str(...).strip()), while _NOT_SUBAGENT_ROW_SQL used a bare json_extract(...) IS NULL, under which an empty string is NOT null. The same row was therefore refused a route bind by switch_session but still returned by restart recovery. Now NULLIF(TRIM(...), '') — TRIM to match .strip() on the whitespace-only case, NULLIF to collapse the result. The reviewer suggested NULLIF(json_extract(...), ''); TRIM is added on top so " " agrees too, which plain NULLIF would still miss. 2. Caller behavior on refusal. Audited all five switch_session call sites: CLI handoff raises RuntimeError, async-completion pinning logs and drops the injection (the desired outcome), Telegram topic rebinding keeps the incumbent entry, and /branch returns its own message — all tolerate None. /resume was the one path where a refusal read as a transient failure ("Failed to switch session."), so it now pre-checks and returns a message naming the actual reason. Pre-check is guarded on self._session_db being present and mirrors the guard's fail-open posture on read error. 3. Fail-open logging. The get_session exception path leaves target_row None and lets the bind proceed — the right trade against breaking /resume on a SQLite hiccup, but it is exactly the shape that silently reintroduces the incident, so it is logged at warning with the routing key, not debug. Tests: TestDetectorHalvesAgree drives BOTH halves over the same rows and asserts they agree — the residual case the per-half tests could not catch, since each only pinned its own side. Verified it genuinely regresses: with only the SQL reverted to the pre-fix predicate, both blank-marker cases fail (assert True is False) and pass again with the fix restored. Suite: 5953 passed in tests/gateway. The 12 failures there and the 6 in tests/state are reproduced with this commit stashed — pre-existing and environment-dependent (systemd, network, FTS), none in the touched files.
57173f1 to
94cd030
Compare
|
Rebased onto current One real conflict, in AND (s.ended_at IS NULL OR s.end_reason IN ({_RECOVERABLE_END_REASONS_SQL}))
AND {_NOT_SUBAGENT_ROW_SQL.format(a='s')}Applied identically to both recovery queries — the session-key path and the peer-tuple fallback — so the keyed and keyless paths stay fenced. Verified after the rebase, not assumed:
Both fences confirmed still present in |
Related issue
Fixes #92859.
Summary
A
delegate_taskbatch dispatched from a Discord DM ended with the DM's routing key bound to one of the spawned children. That bind went throughSessionStore.switch_session(), which promotes the outgoing session to the un-resurrectablesession_switchboundary — so the human parent was killed while its own children were still running, every in-flight delegation from that parent was then classifiedterminaland dropped, and the user's next DM was routed into a leaf subagent that knew nothing about the conversation.Reproduced three times on the reporter's machine (twice after the issue was filed). 4/4 batches whose
origin_sessionwas a gateway routing key were dropped; the only batch that ever reached the user had a plain session id as its origin.Root cause and where the guard goes
The report's own suggested invariant — "a session with a non-empty
parent_session_idshould never be bindable to a gateway routing key" — is right in spirit but wrong in predicate: compression continuations also carryparent_session_idand legitimately continue the route. The stable signal is delegate provenance, whichdelegate_toolalready writes durably at creation (source='subagent'+model_config._delegate_from,tools/delegate_tool.py:2024).The guard is placed at the sink, not at a caller.
switch_session()is the single funnel every route rebind passes through — async-completion pinning (gateway/run.py:16357),/resume, CLI handoff (:13777), Telegram topic rebinding (:19127) — so refusing there closes the class rather than the one observed call path, and no future call site can reintroduce the hijack.Provenance rather than the parent edge, deliberately:
parent_session_idpoints at another child, not at the routed session;sourceoverwritten with the platform name byrecord_gateway_session_peer— that is the literal shape of20260823_041917_519cdbin the report (source='discord', but_delegate_fromstill set). Either signal alone is therefore sufficient.Restart recovery gets the same fence
find_latest_gateway_session_for_peer()ranked the hijacked child above the real conversation on recency, so a gateway restart would have handed the chat straight back to the subagent even after the bind path was fixed. Both its keyed query and its peer-tuple fallback now exclude subagent rows.create_sessionalready refused to let delegate children inherit a routing key at creation (hermes_state.py:5218— "must NOT inherit routing keys, or peer recovery could repoint gateway traffic into a subagent's session"). This PR extends that existing, deliberate rule to the two paths that could grant one afterwards.Relationship to the other open PRs
_delegate_fromprovenance walk in_resolve_async_delegation_session) — same family, complementary rather than overlapping: it canonicalizes the completion resolver's pinned id; this PR refuses the bind itself and also fences restart recovery. They compose cleanly — with this PR the resolver'sswitch_sessioncall can no longer land on a child even if the walk is bypassed.pinned_row.parent_session_id == prior_session_id, which its own blocking review correctly noted is not a stable invariant (compression can advance the route off that exact row). This PR does not use the parent edge at all.Testing
tests/gateway/test_subagent_route_bind_guard.py— 15 tests against realSessionDBrows, not mocks:/resumeto a real prior conversation still switches — the featureswitch_sessionexists for;is_internal_subagent_rowdetection matrix, including a/branchchild (a real, user-addressable conversation) staying out of scope.Sabotage-verified. Disabling the
switch_sessionguard and removing the SQL fence:The 4 failures are exactly the behavioral tests; the 11 detection/regression tests stay green, so each test is bound to the code it claims to pin.
Wider suite:
The 6 failures reproduce identically on unmodified
origin/main(test_scale_to_zero,test_shutdown_forensics,test_systemd_notify,test_wecom_callback— macOS-local baseline, untouched by this diff).Risk
Refusal is fail-closed and logged at WARNING with the routing key and target id, so the failure mode becomes greppable instead of a silent DM hijack. For a normal session there is no behavior change: a real gateway row has neither
source='subagent'nor_delegate_from, and the/resumeregression test pins that.