Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ src/
├── proxy/
│ ├── server.ts ← HTTP layer: routes, SSE streaming, concurrency, request orchestration
│ ├── concurrency.ts ← Abortable SDK query semaphore and concurrency config parsing
│ ├── requestAbort.ts ← HTTP request abort → SDK query abort bridge
│ ├── sessionTree.ts ← Live parent→child request registry; subtree cancellation (PURE bookkeeping)
│ ├── shutdown.ts ← Bounded HTTP drain and connection tracking
│ ├── adapter.ts ← AgentAdapter interface (extensibility point for multi-agent support)
│ ├── adapters/
Expand Down Expand Up @@ -97,6 +99,8 @@ server.ts (HTTP layer)
├── query.ts ──► adapter.ts, mcpTools.ts, passthroughTools.ts
├── errors.ts
├── retryAfter.ts
├── requestAbort.ts
├── sessionTree.ts
├── models.ts
├── tools.ts
├── messages.ts
Expand Down Expand Up @@ -129,6 +133,8 @@ server.ts (HTTP layer)

7. **`query.ts` builds SDK options through the adapter interface**, never importing tool constants directly.

8. **`sessionTree.ts` holds only live-request bookkeeping.** No HTTP, no I/O, no logging: the caller supplies each entry's abort handle and owns the eviction and telemetry discipline that follows an abort. It must not import from `server.ts`, `session/`, or `adapter.ts`.

## Agent Adapter Pattern

Agent-specific behavior is isolated behind the `AgentAdapter` interface (`adapter.ts`). The proxy calls adapter methods instead of hardcoding agent logic.
Expand Down Expand Up @@ -225,6 +231,41 @@ downgraded every concurrent sibling at once, and the model switch cold-caches
each of them — their cached prefixes were built on the 1M model. Clients with no
session identity still bench profile-wide; there is nothing narrower to use.

## Cancellation Contract

Cancellation is per-HTTP-request: `requestAbort.ts` forwards one socket's abort
into that request's SDK abort controller, and the abort path evicts the session
mapping so no interrupted tail stays resumable.

That is not enough for a client whose subagents are separate requests. Prime
Agent's RLM children arrive on their own session keys, so cancelling the parent
left every child running — holding an SDK permit and a turn lease, billing the
subscription until its own socket closed or the lease watchdog tripped.

`sessionTree.ts` closes the gap. A client that knows its own tree stamps the
immediate parent alongside the child's session id (`metadata.user_id` →
`{ session_id, parent_session_id }`); `server.ts` registers that link for the
lifetime of the request and, on a client abort, aborts every live request whose
ancestry reaches the aborted key — through each child's own request abort
controller, so the eviction, permit release, and lease release that follow are
the existing abort path's rather than a second implementation.

Three properties bound it:

- **Abort, not completion.** A parent turn that finishes normally does not
cancel children; a subagent routinely outlives the 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.
- **Live requests only.** An entry exists between "admitted" and "settled". A
session that was seen once but has nothing in flight is not a cancellation
target, so the registry is bounded by concurrency, not by history.
- **Self-gating.** Propagation can only reach a request that declared a parent,
so every client that does not stamp linkage is unaffected with no flag to set.

`POST /v1/sessions/:key/cancel` cancels a subtree explicitly, and
`GET /telemetry/summary` reports the live gauges and cumulative counts under
`sessionTree`.

## Testing Strategy

Three tiers, each catching different classes of bugs:
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ OpenCode-specific behavior is documented in `ARCHITECTURE.md` under "Agent-Speci
```
server.ts → HTTP routes, SSE streaming, concurrency (orchestration only)
concurrency.ts → Abortable SDK query semaphore, max-concurrency config
requestAbort.ts → HTTP request abort → SDK query abort bridge
sessionTree.ts → Live parent→child request registry, subtree cancellation (PURE bookkeeping)
shutdown.ts → Bounded HTTP drain, socket tracking, forced close
adapter.ts → AgentAdapter interface (extensibility point)
adapters/
Expand Down
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,15 @@ Prime Agent is reliable through Meridian with one active agent. RLM children hav
separate session identities and can execute successfully, but concurrent subagent
orchestration is not yet production-safe. Observed failure modes include overload
amplification, expensive cache churn after fresh-session replay, loss of child-task
context during recovery, undelivered tool envelopes, and incomplete parent-to-child
cancellation. Use a single active Prime Agent for unattended or usage-sensitive work
until coordinated fixes land in Prime Agent and Meridian.
context during recovery, and undelivered tool envelopes. Use a single active Prime
Agent for unattended or usage-sensitive work until coordinated fixes land in Prime
Agent and Meridian.

Parent-to-child cancellation is handled on the Meridian side: when the extension
stamps `parent_session_id` alongside the child's session id, aborting a parent's
in-flight request aborts every live request in the subtree below it and evicts
each one's session mapping. See
[Subagent cancellation](docs/agents.md#prime-agent).

Prime Agent can keep Opus on the root session while selecting Sol for an individual
child. A child inherits its parent's model unless the `rlm` call supplies an exact
Expand Down
26 changes: 25 additions & 1 deletion docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,9 +364,16 @@ export default function (pi: ExtensionAPI) {
if (ctx?.model?.provider !== MERIDIAN_PROVIDER) return undefined
const sessionId = ctx?.sessionManager?.getSessionId?.()
if (typeof sessionId !== "string" || !sessionId) return undefined
const identity: Record<string, string> = { session_id: sessionId }
// Optional, and only present on newer Prime Agent builds. It is what lets
// Meridian cancel a whole subagent tree — see "Subagent cancellation".
const parentSessionId = ctx?.sessionManager?.getParentSessionId?.()
if (typeof parentSessionId === "string" && parentSessionId) {
identity.parent_session_id = parentSessionId
}
return {
...(event.payload as Record<string, unknown>),
metadata: { user_id: JSON.stringify({ session_id: sessionId }) },
metadata: { user_id: JSON.stringify(identity) },
}
})
}
Expand Down Expand Up @@ -405,6 +412,23 @@ design, the model loses track of what it has already run. Stamping
real tool calls in its own session state. `getSessionId()` is distinct per
agent, so RLM children get their own keys rather than colliding with the parent.

**Subagent cancellation.** RLM children reach Meridian as independent requests
on their own session keys, so cancelling the parent used to leave every child
running — holding an SDK permit and billing the subscription until its own
socket closed. `parent_session_id` closes that: it names the child's *immediate*
parent, Meridian keeps a registry of in-flight requests and their parent links,
and aborting a parent's request aborts every live request in the subtree below
it, evicting each one's session mapping exactly as a direct cancel does.

Three limits are deliberate. Only an actual abort propagates — a parent turn
that merely finishes leaves its children alone, because a child routinely
outlives the turn that spawned it. Only *live* requests are tracked; a session
with nothing in flight is not remembered. And a client that omits
`parent_session_id` is unaffected, which is the whole gate — there is no config
flag. `POST /v1/sessions/<key>/cancel` cancels a subtree explicitly if you want
to stop one without dropping sockets, and `/telemetry/summary` reports the
counts under `sessionTree`.

Detection is by the `x-meridian-agent: prime` header above, or
`MERIDIAN_DEFAULT_AGENT=prime`. There is deliberately no User-Agent rule: in
API-key mode Prime Agent sends the generic `Anthropic/JS <version>` that every
Expand Down
18 changes: 18 additions & 0 deletions src/__tests__/claude-code-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,24 @@ describe("claudeCodeAdapter.getSessionId", () => {
expect(claudeCodeAdapter.getSessionId(ctx as any, body)).toBe("object-session")
})

it("keeps the key equal to session_id when parent linkage is present", () => {
// parent_session_id is additive (#902): it must never change the key a
// client's cached mappings are already stored under.
const ctx = { req: { header: () => undefined } }
const body = {
metadata: { user_id: JSON.stringify({ session_id: "child", parent_session_id: "parent" }) },
}
expect(claudeCodeAdapter.getSessionId(ctx as any, body)).toBe("child")
expect(claudeCodeAdapter.getParentSessionId!(ctx as any, body)).toBe("parent")
})

it("reports no parent for a root session", () => {
const ctx = { req: { header: () => undefined } }
expect(claudeCodeAdapter.getParentSessionId!(ctx as any, {
metadata: { user_id: JSON.stringify({ session_id: "root" }) },
})).toBeUndefined()
})

it("falls back to fingerprinting when metadata is absent or malformed", () => {
const ctx = { req: { header: () => undefined } }
expect(claudeCodeAdapter.getSessionId(ctx as any, {})).toBeUndefined()
Expand Down
52 changes: 52 additions & 0 deletions src/__tests__/prime-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,58 @@ describe("primeAdapter.getSessionId", () => {
})
})

describe("primeAdapter.getParentSessionId", () => {
it("reads the immediate parent out of the same metadata envelope", () => {
// The extension stamps ctx.sessionManager.getParentSessionId() alongside
// the child's own id; the proxy uses it to cancel a live subtree (#902).
const body = {
metadata: {
user_id: JSON.stringify({
session_id: "019ff7d8-ace2-7060-91dd-0212014a849e",
parent_session_id: "019ff7d8-a616-745d-8cb2-97544a6accac",
}),
},
}
expect(primeAdapter.getParentSessionId!(ctxWith(), body))
.toBe("019ff7d8-a616-745d-8cb2-97544a6accac")
// Key derivation is untouched: the child's key is still its own session_id.
expect(primeAdapter.getSessionId(ctxWith(), body))
.toBe("019ff7d8-ace2-7060-91dd-0212014a849e")
})

it("returns undefined for a root session, which carries only session_id", () => {
const body = { metadata: { user_id: JSON.stringify({ session_id: "root-session" }) } }
expect(primeAdapter.getParentSessionId!(ctxWith(), body)).toBeUndefined()
})

it("ignores body linkage when an orchestrator owns identity via header", () => {
// x-session-affinity names keys under a different scheme, so a parent id
// read out of the body would point at a key that scheme never produced.
const c = ctxWith({ "x-session-affinity": "orchestrator-key" })
const body = {
metadata: { user_id: JSON.stringify({ session_id: "child", parent_session_id: "parent" }) },
}
expect(primeAdapter.getParentSessionId!(c, body)).toBeUndefined()
})

it("ignores a self-referential parent", () => {
const body = {
metadata: { user_id: JSON.stringify({ session_id: "same", parent_session_id: "same" }) },
}
expect(primeAdapter.getParentSessionId!(ctxWith(), body)).toBeUndefined()
})

it("ignores malformed linkage without losing the session key", () => {
for (const parent of [null, 42, "", {}, []]) {
const body = {
metadata: { user_id: JSON.stringify({ session_id: "child", parent_session_id: parent }) },
}
expect(primeAdapter.getParentSessionId!(ctxWith(), body)).toBeUndefined()
expect(primeAdapter.getSessionId(ctxWith(), body)).toBe("child")
}
})
})

describe("prime adapter configuration", () => {
it("uses its own MCP server name", () => {
expect(primeAdapter.getMcpServerName()).toBe("prime")
Expand Down
Loading
Loading