feat: cancel RLM subagent trees when the parent request aborts - #912
Conversation
Cancellation was strictly per-HTTP-request, so Prime Agent's RLM children — independent requests on independent session keys — kept running when the parent was cancelled, holding an SDK permit and a turn lease and billing the subscription until their own sockets closed. A new live registry (sessionTree.ts) records each in-flight keyed request and the immediate parent the client declared in metadata.user_id. A client abort of one request now aborts every live request whose ancestry reaches it, through each child's own request abort controller, so the mapping eviction, permit release, and lease release are the existing abort path's. Scope is deliberate: only an abort propagates (a parent turn that merely completes leaves children running), only live requests are tracked (no persistent tree), and propagation can only reach a request that declared a parent — which is the whole gate, no config flag. Also adds POST /v1/sessions/:key/cancel and sessionTree counters on /telemetry/summary and the dashboard. Fixes #902
|
Reviewed against #902 with an independent re-run of The design decisions are all right: internal monotonic tokens instead of client-supplied request ids (collision-proof release), live-requests-only bounding, abort-not-completion, self-gating on the stamped linkage, depth-capped BFS with a visited set for wire-supplied parent keys, and the self-link guard in With this, the parent→child cancellation loop is closed end to end: Prime-side Reviewed by Fable 5 via Claude Agent SDK. |
Problem
Cancellation in Meridian was strictly per-HTTP-request:
linkRequestAbortforwards one socket's abort into that request's SDK abort controller, and nothing linked one request to another. Prime Agent's RLM children arrive as independent requests on independent session keys, so cancelling the parent left every child running — holding an SDK permit and a turn lease, and billing the Max subscription until its own socket closed or the lease watchdog tripped. This is the proxy half of the "incomplete parent-to-child cancellation" limitation in README's Prime Agent section.Design
src/proxy/sessionTree.tsis a new leaf module holding a live-request registry: for each in-flight request that has a session key, it records the key, the immediate parent key the client declared, and an abort handle.server.tsregisters inhandleWithQueue(before the turn lease is acquired, so a child queued behind its own session's running turn is reachable too) and releases infinishRequest, which runs on success, error, and abort alike.The wire contract is additive and matches pylon-code/prime-agent#38: the extension stamps
metadata.user_id = {"session_id": "<child>", "parent_session_id": "<immediate-parent>"}.extractClaudeCodeSessionIdentityparses both fields in one pass;extractClaudeCodeSessionIdis now a thin wrapper over it, so key derivation is byte-identical to before — the key remains exactly thesession_idvalue. Parent links form a forest, and cancelling a node walks it transitively (visited-set plus a depth cap, becauseparent_session_idcomes off the wire and can name a cycle).Propagation reuses the existing abort path rather than reimplementing it. Each child is aborted through its own per-request abort controller — the same one the lease watchdog and
forceAbortInFlightuse — so the mapping eviction (session.interrupted_mapping_evicted,reason: request_abort), the SDK semaphore release inrunSdkQueryAttempt'sfinally, and the turn-lease release infinishRequestare all the code that already handles a direct client abort.Two client-reachable abort paths trigger it: a request-signal abort and a cancelled response body (
cancel()on the SSE stream — the only one an in-process caller reaches, and Prime Agent streams every request). It latches after the first, so one socket teardown that trips both propagates once.Scope decisions
primeAdapter.getParentSessionIdadditionally reports linkage only when the key itself came from the same envelope, so an orchestrator that overrides identity withx-session-affinity(a different key scheme) is never handed a parent id from a scheme that never produced it.POST /v1/sessions/:key/cancelfell out of the registry for ~15 lines and gives a harness a way to stop a subtree without dropping sockets. It is behind the existing/v1/*auth middleware, and an idle session honestly reportsrequests: 0.Telemetry
GET /telemetry/summarygains asessionTreeblock —trackedandlinkedgauges plus cumulativepropagationsandcancelledDescendants— injected intocreateTelemetryRoutesso the telemetry module keeps depending only on its own store. The dashboard renders a "Subtree Cancels" card once a tree has actually been seen. Each propagation emitssession.tree_cancel_propagatedand a session-level diagnostic line with truncated parent/child keys.Tests
New
session-tree-unit.test.ts(17 tests, no mocks): registration, idempotent release, index drain with no leak, colliding client-supplied request ids, transitive multi-level walk, several live requests on one child key, cycles, self-links, depth bound, counters, a throwing abort handle not stranding siblings, andcancelSubtreevscancelDescendants.New
proxy-session-tree-cancellation.test.ts(11 tests, HTTP layer with mocked SDK): parent + linked child both in flight, parent aborted → child's SDK query aborted and 499; the same for a child's stream, closed with an error frame; a streaming parent's cancelled body; both paths tripped → exactly one propagation; three-level tree; the cancelled child's mapping evicted, proven by a following turn starting fresh instead of resuming; a parent turn completing normally leaving children alone; an unlinked sibling untouched; counters on/telemetry/summary; and the explicit endpoint.Verified non-vacuous: with the cascade wiring removed, 8 of the 11 fail.
Fixes #902
Claude Opus via Claude Agent SDK