Files
deepseek-harness/docs/rfc/011-acp-multi-session.md
T
Tianyi Cui b3ea13749c feat(acp): multiplex N concurrent ACP sessions + bash task ownership (RFC 011)
Lifts the RFC 010 single-session-per-connection cap: the bridge now runs N
concurrent sessions over one connection, each mapped to its own LoopAgent.

- packages/acp: live sessions held in a Map<sessionId, SessionRecord> with an
  agent→sessionId reverse WeakMap so agent/* events (which carry only the
  Agent) demux in O(1). Every session/event and agent/status is routed strictly
  to its owning record — concurrent sessions never cross-settle or interleave
  their session/update notifications. Per-session state: one in-flight prompt
  each, session/cancel aborts+settles only its own agent/prompt, session/load
  reserves a per-id load slot (distinct ids load concurrently; re-loading a live
  id is rejected), and disposal drains every live session in parallel to
  quiescence.
- packages/tool-bash: record each background task's owning agent at spawn and
  keep it for the executor's lifetime (NOT cleared on completion).
  bash_output/bash_kill reject a task owned by a different agent (a task with no
  owner is open; a no-agent caller can't access an owned task). Task ids are
  global and predictable, so this is the fence that stops one session's agent
  from reading/killing another session's background task.
- Per-session permission ownership and a per-agent disposer seam stay deferred
  (depend on the deferred permission gate); the reverse map the gate will route
  through is in place. RFC 011 stays `proposed`.
- Tests: two sessions stream concurrently without interleave; cross-session
  cancel isolation; per-session in-flight enforcement; dispose-all-to-quiescence;
  bash cross-session read/kill rejected (+ no-agent and unowned-task cases).
- Docs: RFC 011 implementation-status note; acp + tool-bash READMEs; example
  MVP-limitations updated. 100% per-file coverage maintained.
2026-06-16 19:47:13 +08:00

35 lines
4.9 KiB
Markdown

# RFC 011: Multiplex concurrent ACP sessions over one connection
Status: proposed
> **Implementation status:** the multi-session bridge (steps 1, 3, 4) and the bash task-ownership isolation are implemented in `packages/acp` + `packages/tool-bash`. **Per-session *permission* ownership is deferred** — it depends on the RFC 010 permission gate (`TODO(rfc010-permission-gate)`), which is itself deferred; the `agent→sessionId` reverse map the gate will route through is in place. Step 2's "real per-session disposer scope" is also deferred (`TODO(rfc010-agent-disposal)`): the bridge demuxes via id-keyed maps and global `ctx.on` listeners (correct and leak-free — disposal drains every session in parallel to quiescence), and a per-agent disposer seam is the follow-up. Status stays `proposed` until per-session permission ownership lands.
## Problem
RFC 010 ships ACP support with a single active session per connection: a second `session/new` is rejected. Editors expect to run several conversations over one agent subprocess — a user opens multiple threads, or a client pre-warms sessions. The single-session guard is a deliberate MVP scope cut, not an architectural limit; this RFC lifts it.
## Proposal
The harness core already supports many agents (`AgentRegistry.list()` and `AgentLoop.create` impose no count limit), so multiplexing is a bridge-layer change in `@deepseek-ai/dsh-acp`, not a loop or core change.
- Lift the single-session guard in `session/new`; allow N live sessions, each mapped to its own `LoopAgent`.
- The bridge's `sessionId→agent` and `Session→sessionId` maps (introduced single-entry in RFC 010) become true multi-entry, plus a third `agent→sessionId` reverse map: the `tools/execute` permission gate receives only `exec.agent` (no sessionId), so it needs an O(1) reverse lookup to find the owning session. Every `agent/*` event and every `session/event` is demuxed strictly by id, so two sessions streaming at once never interleave their `session/update` notifications.
- Per-session prompt queues: RFC 010's single-entry in-flight-prompt state becomes multi-entry — one in-flight prompt *per session*, tracked per `sessionId`.
- Per-session cancel routing: `session/cancel` aborts only its own session's agent and settles only that session's in-flight prompt. `agent.abort()` drives a per-agent `AbortController`, so the per-session `exec.signal` is the natural isolation fence.
- Per-session permission ownership: a `session/request_permission` and its outcome are bound to the originating session via the reverse map, so a permission prompt or a cancel in one session can never resolve another session's pending permission.
## Plan
1. Generalize the two id maps to multi-entry and add the `agent→sessionId` reverse map; add a per-session record holding the agent, the in-flight-prompt state, the pending-permission registry, and the session's disposer scope (see step 2).
2. Give each session a real per-session disposer scope, NOT `ctx.extend()` — in Cordis `ctx.extend()` only creates a child context/prototype, but `ctx.on()` registered on it is still owned by the current plugin fiber, so disposing it would not remove that session's listeners. Use a genuine child fiber (load a per-session sub-plugin, e.g. `ctx.plugin(...)` returning a fork, or collect each session's `ctx.on` disposers in its session record and call them on teardown). Demux every `agent/*` and `session/event` by id into the right session record. Note the single global `tools/execute` listener stays on the bridge root (it must see all agents) and routes via the reverse map.
3. Lift the `session/new` guard; keep `session/load` (RFC 010) working per session.
4. Tests for cross-session isolation: two sessions streaming and permission-prompting concurrently never interleave; a cancel/abort in one session leaves the other's stream and pending permission untouched; per-session in-flight-prompt enforcement holds independently; disposing one session leaves the others running.
## Risks
Listener fan-out cost: each session adds listeners; ensure disposal of one session removes exactly its own and the connection teardown (RFC 010) still reaches quiescence across all sessions.
The subtle correctness trap is cross-session leakage — a cancel or abort on one session settling another session's pending permission. The per-session permission ownership rule (routed via the `agent→sessionId` reverse map) and its isolation test are the guard.
Shared background-task state: the bash executor's task ids are global and predictable (`bash-1`, `bash-2`, …), and `bash_output`/`bash_kill` look up by id without checking the caller. Under one session this is benign; under N sessions one session's agent could read or kill another's background task. This is a pre-existing `tool-bash` gap that multi-session turns into a real isolation hole — fixing it (validate the caller against the task owner) belongs with this RFC or a companion `tool-bash` change.