Files
deepseek-harness/docs/rfc/011-acp-multi-session.md
T

5.1 KiB

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.

This paragraph is historical: the multi-session bridge has landed. The remaining proposed work is per-session permission ownership plus the lifecycle seams now tracked in RFC 014.

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.