Skip to content

feat: cancel RLM subagent trees when the parent request aborts - #912

Merged
rynfar merged 1 commit into
mainfrom
feat/session-tree-cancellation
Aug 31, 2026
Merged

feat: cancel RLM subagent trees when the parent request aborts#912
rynfar merged 1 commit into
mainfrom
feat/session-tree-cancellation

Conversation

@rynfar

@rynfar rynfar commented Aug 31, 2026

Copy link
Copy Markdown
Owner

Problem

Cancellation in Meridian was strictly per-HTTP-request: linkRequestAbort forwards 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.ts is 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.ts registers in handleWithQueue (before the turn lease is acquired, so a child queued behind its own session's running turn is reachable too) and releases in finishRequest, 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>"}. extractClaudeCodeSessionIdentity parses both fields in one pass; extractClaudeCodeSessionId is now a thin wrapper over it, so key derivation is byte-identical to before — the key remains exactly the session_id value. Parent links form a forest, and cancelling a node walks it transitively (visited-set plus a depth cap, because parent_session_id comes 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 forceAbortInFlight use — so the mapping eviction (session.interrupted_mapping_evicted, reason: request_abort), the SDK semaphore release in runSdkQueryAttempt's finally, and the turn-lease release in finishRequest are 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

  • Turn completion does not cascade. Only an actual abort propagates; a subagent routinely outlives the parent turn that spawned it. The shutdown path already aborts every request directly, and the lease watchdog is a proxy-side fence rather than a user intent, so neither cascades either.
  • Live requests only. There is no persistent tree. A session that was seen once but has nothing in flight is not a cancellation target, because there is nothing to cancel; the registry stays bounded by concurrency instead of by conversation history.
  • No config flag. Propagation can only reach a request that declared a parent, so the feature is inert for every client that does not stamp linkage. That self-gating is the gate — a separate env var would only add a way to get it wrong. primeAdapter.getParentSessionId additionally reports linkage only when the key itself came from the same envelope, so an orchestrator that overrides identity with x-session-affinity (a different key scheme) is never handed a parent id from a scheme that never produced it.
  • Explicit endpoint: yes. POST /v1/sessions/:key/cancel fell 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 reports requests: 0.

Telemetry

GET /telemetry/summary gains a sessionTree block — tracked and linked gauges plus cumulative propagations and cancelledDescendants — injected into createTelemetryRoutes so 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 emits session.tree_cancel_propagated and 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, and cancelSubtree vs cancelDescendants.

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.

npm test          → 3201 pass, 0 fail (2953 in the main pass across 178 files, plus the 12 isolated files)
npm run typecheck → clean

Fixes #902


Claude Opus via Claude Agent SDK

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

rynfar commented Aug 31, 2026

Copy link
Copy Markdown
Owner Author

Reviewed against #902 with an independent re-run of session-tree-unit.test.ts + proxy-session-tree-cancellation.test.ts (28/28) and a read of the registry and parser.

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 extractClaudeCodeSessionIdentity. Key derivation staying byte-identical to session_id was the compatibility invariant I most wanted to see preserved, and the wrapper preserves it. Reusing each child's own per-request abort controller so eviction, permit release, and lease release all run the pre-existing paths — rather than a parallel teardown implementation — is exactly the right shape, and catching the SSE cancel() trigger (the path Prime actually exercises, since it streams everything) closed a real gap. Registering before turn-lease acquisition so queued children are cancellable is a subtle correctness point handled well.

With this, the parent→child cancellation loop is closed end to end: Prime-side requestAbort() cascade (pylon-code/prime-agent#35) kills the children's client sockets, and this registry reaps any child request the proxy is still serving, keyed by the parent_session_id linkage from pylon-code/prime-agent#38.

Reviewed by Fable 5 via Claude Agent SDK.

@rynfar
rynfar merged commit 99bedee into main Aug 31, 2026
5 checks passed
@rynfar
rynfar deleted the feat/session-tree-cancellation branch August 31, 2026 15:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Parent-to-child cancellation: session-tree registry and abort propagation for RLM subagent trees

1 participant