Skip to content
105 changes: 86 additions & 19 deletions docs/design/daemon-multi-workspace-phase2a-sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@

This document records the Phase 2a contract for issue #6378 after the Phase 1
`WorkspaceRegistry` PR and the Phase 2a foundation PR. Phase 2a is now split
into two implementation PRs: PR 1 lands env isolation and total-admission
guardrails while multi-workspace remains gated; PR 2 will wire non-primary live
session dispatch and publish the additive capabilities/status schema.
into two implementation PRs: PR 1 landed env isolation and total-admission
guardrails while multi-workspace remained gated; PR 2 wires non-primary live
session dispatch and publishes the additive capabilities/status schema.

Phase 2a remains sessions-only. It does not add plural routes, a
`WorkspaceDaemonClient`, workspace-qualified ACP/WebSocket, file, memory, MCP,
Expand All @@ -22,13 +22,13 @@ runtime construction.
values are present.
- A single-item workspace array is treated as the primary workspace and keeps
the existing single-workspace behavior.
- Multiple explicit workspaces remain gated and fail before runtime boot.
- Duplicate canonical workspace inputs fail explicitly.
- Nested workspace inputs fail explicitly.
- Distinct non-nested multiple workspace inputs fail with the generic
"multi-workspace serve is not enabled" boot error.
- The first explicit workspace is the future primary workspace once the gate is
removed; this foundation batch does not expose that list publicly.
- PR 1 kept multiple explicit workspaces gated before runtime boot.
- PR 2 accepts distinct non-nested explicit workspaces for sessions-only
multi-workspace mode.
- Duplicate canonical workspace inputs still fail explicitly.
- Nested workspace inputs still fail explicitly.
- The first explicit workspace is the primary workspace and remains mirrored by
legacy `workspaceCwd` / `app.locals.boundWorkspace` compatibility fields.

The internal `WorkspaceRuntime` contract now carries stable metadata for later
Phase 2a work:
Expand All @@ -49,10 +49,11 @@ resolution. Live owner resolution scans runtime bridge summaries only; it does
not scan persisted storage, create children, or route any request yet. Duplicate
live owners fail closed as an ambiguous result.

`createServeApp` may accept an injected registry for tests and future assembly,
but route modules still receive the primary runtime only. Existing legacy
`app.locals.boundWorkspace` and `app.locals.fsFactory` remain primary-only
compatibility locals.
`createServeApp` may accept an injected registry for tests and future assembly.
The foundation PR kept route modules on primary-runtime inputs; PR 2 extends
only the live session, SSE, and session-permission route wiring with the
registry needed for owner dispatch. Existing legacy `app.locals.boundWorkspace`
and `app.locals.fsFactory` remain primary-only compatibility locals.

## Phase 2a Route Classification

Expand Down Expand Up @@ -90,7 +91,7 @@ Later or primary-only routes:
Additional live read routes may be owner-routed in a later Phase 2a slice only
after tests prove they depend solely on the owning live bridge.

## Later Phase 2a Requirements
## Phase 2a Cross-PR Requirements

- Keep scan misses as `404 session_not_found`; never fall back to primary.
- Fail closed if more than one runtime reports the same live session id.
Expand All @@ -100,10 +101,10 @@ after tests prove they depend solely on the owning live bridge.
- Reuse PR 1 `maxTotalSessions` admission at every future fresh-creation seam
so REST and primary `/acp` cannot bypass it, while attach still bypasses
admission.
- Publish `workspaces[]` and `multi_workspace_sessions` only in PR 2 when the
- PR 2 publishes `workspaces[]` and `multi_workspace_sessions` only after the
live session dispatch loop is complete.
- Update SDK capability types when the additive capabilities schema ships, but
do not add a workspace client in Phase 2a.
- PR 2 updates SDK capability types for the additive capabilities schema, but
Phase 2a still does not add a workspace client.

## PR 1 Guardrails

Expand All @@ -117,14 +118,76 @@ after tests prove they depend solely on the owning live bridge.
`process.env` reads.
- `maxTotalSessions` is an optional daemon-wide fresh-session cap. It covers
spawn, persisted load/resume restore, and branch/fork session creation;
attach bypasses it.
attach bypasses it. In multi-workspace mode, when the operator leaves it
unset and the per-workspace `maxSessions` cap is finite, PR 2 derives the
effective total cap as `maxSessionsPerWorkspace * workspaceCount`; single
workspace mode keeps the historical unlimited total default.
- The bridge admission seam is a synchronous reservation hook. Failed fresh
creation releases the reservation, preventing concurrent oversell across
runtimes once non-primary bridges exist.
- `/daemon/status.limits.maxTotalSessions` is additive. `/capabilities` and SDK
capability types remain unchanged until PR 2 ungates multi-workspace
sessions.

## PR 2 Sessions Closed Loop

PR 2 removes the explicit multi-workspace boot gate for sessions-only daemon
mode. Multiple explicit `--workspace` values now create one runtime per
canonical workspace, with the first workspace as primary. Duplicate and nested
workspace inputs remain boot errors because they make session ownership
ambiguous before any route-level dispatch can safely resolve a request.

The production assembly keeps the existing primary runtime responsibilities:
daemon identity, log identity, telemetry service id, Web Shell, `/acp`, file,
memory, MCP, settings, voice, channel worker, and legacy workspace-less REST
routes remain primary-only. Non-primary runtimes are bridge/workspace-service
runtimes for live REST sessions only. Their ACP child is still lazy: the bridge
object exists at boot, but no non-primary child is spawned until a trusted
`POST /session { cwd }` request needs a fresh session.

Session creation resolves `cwd` through `WorkspaceRegistry` exact canonical cwd
matching. Omitted `cwd` resolves to the primary runtime. Unknown `cwd` returns
`400 workspace_mismatch`; untrusted non-primary `cwd` returns
`403 untrusted_workspace`; trusted registered runtimes call that runtime's
bridge with its own canonical cwd. This intentionally avoids prefix matching,
nearest-parent matching, or persisted-storage lookup in Phase 2a.

The dispatched live-session routes resolve owner runtime by scanning live bridge
summaries through `WorkspaceRegistry.resolveLiveSessionOwner(sessionId)`.
`not_found` maps to `404 session_not_found`, and `ambiguous` maps to a
fail-closed server error. The scan is synchronous and live-only; it never
spawns a child and never treats a miss as primary fallback. The dispatched
route set is exactly:

- `GET /session/:id/events`
- `POST /session/:id/prompt`
- `POST /session/:id/cancel`
- `POST /session/:id/permission/:requestId`
- `POST /session/:id/heartbeat`
- `POST /session/:id/detach`
- `GET /session/:id/pending-prompts`
- `DELETE /session/:id/pending-prompts/:promptId`
- `DELETE /session/:id`
- `GET /session/:id/status`

`GET /workspace/:id/sessions` resolves by exact workspace id first and exact
canonical cwd second. Primary keeps the existing persisted/live merge and
organized view behavior. Non-primary returns live sessions only, rejects
`archiveState=archived`, and rejects organized/group queries because those are
persisted/organization-backed surfaces reserved for later phases.

`/capabilities` remains backward-compatible: `workspaceCwd` still names the
primary workspace. When more than one runtime is registered, it additionally
publishes `workspaces[]`, `multi_workspace_sessions`, and additive session
limits. `/daemon/status` adds the same `workspaces[]` metadata and aggregates
live session counters across runtime bridges while leaving full workspace
sections primary-only.

Phase 2a PR 2 does not add plural routes, workspace-qualified ACP/WebSocket,
file/memory/MCP/settings/voice/channel-worker migration, dynamic add/remove,
non-primary persisted load/resume/export/archive/delete, branch/fork/cd/rewind,
shell/model/language migration, or SDK workspace client APIs.

## Audit Decisions

- The foundation PR must not create non-primary runtimes or relax any REST
Expand All @@ -137,3 +200,7 @@ after tests prove they depend solely on the owning live bridge.
runtimes.
- Single-workspace parent-env behavior remains compatible until true
multi-workspace mode is ungated.
- PR 2's safe boundary is the live session closed loop plus additive
capabilities/status metadata. If a route needs persisted storage,
organization state, workspace settings, or ACP connection-local state, it
stays primary-only or later.
2 changes: 1 addition & 1 deletion docs/developers/daemon/00-index.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ Pick the path that matches your goal:
- **PoolEntry** - `packages/core/src/tools/mcp-pool-entry.ts`. One entry in `McpTransportPool`: one MCP transport, a refcount of attached sessions, and an idle drain timer.
- **Session scope** - `single` (one ACP session shared by all clients) or `thread` (one session per conversation thread). The default is `single`.
- **SSE** - Server-Sent Events. The daemon outbound event channel (`GET /session/:id/events`).
- **Workspace** - the directory the daemon was bound to at boot (`--workspace` or `cwd`). One daemon process equals one workspace.
- **Workspace** - a directory registered at daemon boot (`--workspace` or `cwd`). `workspaceCwd` is the primary workspace; when `multi_workspace_sessions` is advertised, `workspaces[]` lists additional sessions-only runtimes.

## Implementation source anchors

Expand Down
4 changes: 2 additions & 2 deletions docs/developers/daemon/01-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Overview

A `qwen serve` process is **one daemon = one workspace**. It hosts a single Express HTTP server, owns an `@qwen-code/acp-bridge` instance, and spawns one ACP child process (`qwen --acp`) that runs the actual agent runtime. Multiple clients (CLI TUI, IDE companion, IM channel bots, web BFFs, custom scripts) connect over HTTP + SSE and either share one ACP session (`sessionScope: 'single'`, default) or split sessions by conversation thread (`sessionScope: 'thread'`).
A `qwen serve` process hosts one Express HTTP server and one primary workspace by default. With `multi_workspace_sessions` enabled it may also host additional workspace runtimes for the live session closed loop; each registered workspace owns its own `@qwen-code/acp-bridge` / `qwen --acp` child pair. Multiple clients (CLI TUI, IDE companion, IM channel bots, web BFFs, custom scripts) connect over HTTP + SSE and either share one ACP session (`sessionScope: 'single'`, default) or split sessions by conversation thread (`sessionScope: 'thread'`).

Inside the ACP child, MCP servers are shared workspace-wide through `McpTransportPool` (F2): a single (server-name + config-fingerprint) tuple maps to one MCP transport, regardless of how many sessions discover it. The bridge's `MultiClientPermissionMediator` (F3) coordinates permission votes across all connected clients under one of four policies.

Expand All @@ -20,7 +20,7 @@ flowchart LR
SDK["Any SDK consumer<br/>(packages/sdk-typescript/src/daemon)"]
end

subgraph daemon["qwen serve process (one workspace)"]
subgraph daemon["qwen serve process (primary workspace plus optional session runtimes)"]
EXP["Express app<br/>(packages/cli/src/serve/server.ts)"]
BR["AcpBridge<br/>(packages/acp-bridge/src/bridge.ts)"]
MED["MultiClientPermissionMediator<br/>(F3)"]
Expand Down
4 changes: 2 additions & 2 deletions docs/developers/daemon/02-serve-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
## Responsibilities

- Parse and validate `ServeOptions`: listen address, auth, workspace, session / connection caps, MCP budget / pool, CORS, prompt / SSE / session idle timeouts, rate limit, and related toggles.
- **Canonicalize** the bound workspace exactly once. The same canonical form is shared by `/capabilities`, the `POST /session` fallback, and the bridge.
- **Canonicalize** the primary workspace exactly once, and canonicalize every repeated `--workspace` before registering session runtimes. The primary canonical form is shared by `/capabilities.workspaceCwd`, the `POST /session` fallback, and the primary bridge.
- Reject unsafe or invalid startup configurations: non-loopback bind without token, `--require-auth` without token, `--allow-origin '*'` without token, `mcpBudgetMode='enforce'` without a positive `mcpClientBudget`, a nonexistent or non-directory `--workspace`, and invalid timeout or rate-limit values.
- Construct the `WorkspaceFileSystem` factory, permission audit publisher, `DaemonStatusProvider`, and `acp-bridge`.
- Build the Express app, wire middleware (`denyBrowserOriginCors` / `allowOriginCors` -> `hostAllowlist` -> access log -> `bearerAuth` -> rate limit -> JSON parser -> telemetry -> per-route `mutationGate`), and mount session, workspace CRUD, file, device-flow auth, permission vote, and ACP HTTP routes.
Expand Down Expand Up @@ -123,7 +123,7 @@ Calling `createServeApp` directly returns only an `Application`; the embedder ow
| Env | `QWEN_SERVE_DEBUG=1` | Verbose stderr logs. See [`19-observability.md`](./19-observability.md). |
| Flags | `--hostname`, `--port` | Listen binding. |
| Flags | `--token`, `--require-auth`, `--enable-session-shell` | Bearer token, loopback auth hardening, and explicit shell execution switch. |
| Flag | `--workspace` | Overrides `process.cwd()`. |
| Flag | `--workspace` | Overrides `process.cwd()`; repeat to register additional sessions-only workspaces. |
| Flags | `--max-sessions`, `--max-pending-prompts-per-session`, `--max-connections`, `--event-ring-size` | Bridge / Express caps. |
| Flags | `--mcp-client-budget=N`, `--mcp-budget-mode={off,warn,enforce}` | Forwarded to the ACP child. |
| Flags | `--allow-origin`, `--allow-private-auth-base-url` | Browser CORS allowlist and localhost/private auth provider installation switch. |
Expand Down
7 changes: 4 additions & 3 deletions docs/developers/daemon/11-capabilities-versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

## Overview

`GET /capabilities` is the daemon preflight endpoint. Every SDK client should read it before calling any other route so it can learn which protocol version the daemon speaks, which feature tags are enabled, and which workspace the daemon is bound to. The contract:
`GET /capabilities` is the daemon preflight endpoint. Every SDK client should read it before calling any other route so it can learn which protocol version the daemon speaks, which feature tags are enabled, and which workspace runtimes the daemon accepts. The contract:

- **There is one protocol version: `v1`.** `SERVE_PROTOCOL_VERSION = 'v1'` and `SUPPORTED_SERVE_PROTOCOL_VERSIONS = ['v1']`. v1 is additive internally; breaking frame-shape changes are reserved for v2.
- **Each tag has a `since` version.** Future v2 daemons can advertise both v1 and v2 tags.
- **Some tags are conditional.** Thirteen tags (`require_auth`, `mcp_workspace_pool`, `mcp_pool_restart`, `allow_origin`, `prompt_absolute_deadline`, `writer_idle_timeout`, `workspace_settings`, `workspace_voice`, `workspace_voice_transcription`, `session_shell_command`, `rate_limit`, `workspace_reload`, `voice_transcribe`) are advertised only when the corresponding deployment toggle is enabled. Tag presence means the behavior exists.
- **Some tags are conditional.** Tags listed in `CONDITIONAL_SERVE_FEATURES` are advertised only when the corresponding deployment toggle is enabled. Tag presence means the behavior exists.
- **Capability tag = behavior contract.** Adding new behavior under an existing tag can silently break clients that preflighted the old tag. New behavior needs a new tag.

The complete registry lives in `packages/cli/src/serve/capabilities.ts`.
Expand All @@ -30,12 +30,13 @@ The complete registry lives in `packages/cli/src/serve/capabilities.ts`.
mode: 'http-bridge',
features: ServeFeature[],
workspaceCwd: string,
workspaces?: Array<{ id: string, cwd: string, primary: boolean, trusted: boolean }>,
protocol?: { current: 'v1', supported: ['v1'] },
policy?: { permission: PermissionPolicy },
}
```

`workspaceCwd` is the canonical workspace bound at daemon boot (see [`02-serve-runtime.md`](./02-serve-runtime.md)). `policy.permission` is the active mediator policy.
`workspaceCwd` is the canonical primary workspace path (see [`02-serve-runtime.md`](./02-serve-runtime.md)). When `multi_workspace_sessions` is advertised, `workspaces[]` lists every registered sessions-only runtime. `policy.permission` is the active mediator policy.

### `ServeCapabilityDescriptor`

Expand Down
2 changes: 1 addition & 1 deletion docs/developers/daemon/15-channel-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ There are two current host modes:
- `qwen channel start [name]` is the standalone ACP-backed channel service. It passes adapters an `AcpBridge` implementation of `ChannelAgentBridge`.
- `qwen serve --channel <name>` and `qwen serve --channel all` are experimental daemon-managed modes. `qwen serve` starts one out-of-process channel worker, the worker connects to the daemon through the SDK, and adapters receive a `DaemonChannelBridge`-backed `ChannelAgentBridge` facade.

In daemon-managed mode, each channel maps inbound chat traffic to daemon sessions under a configurable `SessionScope` (`user`, `thread`, or `single`). The adapter delegates to `DaemonChannelBridge`, which delegates to the SDK's `DaemonSessionClient` (see [`13-sdk-daemon-client.md`](./13-sdk-daemon-client.md)). One daemon is bound to one workspace, so every selected channel's `cwd` must resolve to the daemon workspace.
In daemon-managed mode, each channel maps inbound chat traffic to daemon sessions under a configurable `SessionScope` (`user`, `thread`, or `single`). The adapter delegates to `DaemonChannelBridge`, which delegates to the SDK's `DaemonSessionClient` (see [`13-sdk-daemon-client.md`](./13-sdk-daemon-client.md)). Channel workers remain primary-workspace only in Phase 2a, so every selected channel's `cwd` must resolve to the daemon primary workspace.

## Responsibilities

Expand Down
Loading
Loading