feat(cli): Enable multi-workspace session routing (#6511)

* feat(cli): Enable multi-workspace session routing

Implement the Phase 2a sessions closed loop for qwen serve multi-workspace mode. Multiple explicit workspaces now create registered runtimes while legacy workspace surfaces remain primary-only, and live session routes dispatch by owning runtime.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): address phase2a session review feedback

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): cover remaining phase2a review gaps

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): address phase2a session review feedback

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): satisfy phase2a lint checks

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): align multi-workspace status test limits

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): address phase2a session review feedback

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address PR review feedback (#6511)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
jinye 2026-07-08 18:06:31 +08:00 committed by GitHub
parent a07fdc6042
commit 43e6a9300a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 2918 additions and 353 deletions

View file

@ -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,
@ -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:
@ -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
@ -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.
@ -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
@ -117,7 +118,10 @@ 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.
@ -125,6 +129,65 @@ after tests prove they depend solely on the owning live bridge.
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
@ -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.

View file

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

View file

@ -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.
@ -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)"]

View file

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

View file

@ -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`.
@ -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`

View file

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

View file

@ -178,14 +178,14 @@ sequenceDiagram
## Configuration
| Knob | Where | Effect |
| ---------------------------------------------------- | --------------------------------- | ----------------------------------------------------------------- |
| `baseUrl` | `connect(options)` | Daemon URL; must be loopback. |
| `token` | `connect(options)` | Bearer token (stamped via SDK). |
| `workspaceCwd` | `connect(options)` | Used on `POST /session`; must match the daemon's bound workspace. |
| `modelServiceId` | `connect(options)` / `setModel()` | Initial model. |
| `lastEventId` | `connect(options)` | Resume cursor (typically restored from host state). |
| VS Code setting `qwen.ide.daemonUrl` (or equivalent) | Workspace settings | Operator-configured daemon URL. |
| Knob | Where | Effect |
| ---------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `baseUrl` | `connect(options)` | Daemon URL; must be loopback. |
| `token` | `connect(options)` | Bearer token (stamped via SDK). |
| `workspaceCwd` | `connect(options)` | Used on `POST /session`; must match the daemon's primary workspace or a registered multi-workspace session runtime. |
| `modelServiceId` | `connect(options)` / `setModel()` | Initial model. |
| `lastEventId` | `connect(options)` | Resume cursor (typically restored from host state). |
| VS Code setting `qwen.ide.daemonUrl` (or equivalent) | Workspace settings | Operator-configured daemon URL. |
## Caveats & Known Limits
@ -193,7 +193,7 @@ sequenceDiagram
- **The legacy `AcpConnectionState` path is still primary** in the IDE companion (stdio child). This adapter is the sibling-transport for Mode-B migration; see [`../daemon-client-adapters/ide.md`](../daemon-client-adapters/ide.md) for the migration blockers and the planned `BridgeFileSystem` parity work.
- **No reverse RPC or editor-affordance surface yet over HTTP.** Features that require the agent to call back into the IDE (e.g. read-only buffer access, diff preview integration) currently live only on the stdio path.
- **Webview ↔ connection coupling is host-owned**, not in this adapter. Do not push webview-specific logic into `DaemonIdeConnection`.
- **`workspaceCwd` mismatch** with the daemon's bound workspace returns `400 workspace_mismatch` — surface this as a clear setup error rather than retrying.
- **`workspaceCwd` mismatch** with the daemon's registered workspaces returns `400 workspace_mismatch` — surface this as a clear setup error rather than retrying.
## References

View file

@ -12,7 +12,7 @@ This page collects every setting that affects the `qwen serve` daemon and its ad
| `--port <n>` | number | `4170` | Listen port; `0` means ephemeral. |
| `--token <s>` | string | env | Bearer token. Overrides `QWEN_SERVER_TOKEN` and is trimmed at boot. It appears in the process command line, so prefer env in deployments. |
| `--require-auth` | boolean | `false` | Extends bearer auth to loopback and `/health`; boot refuses to start without a token. |
| `--workspace <dir>` | absolute path | `process.cwd()` | Bound workspace. Must be absolute and a directory; canonicalized once at boot. |
| `--workspace <dir>` | absolute path / repeatable | `process.cwd()` | Primary workspace when supplied once; repeat to register additional sessions-only workspaces. Every value must be absolute and a directory; canonicalized at boot. |
| `--max-sessions <n>` | number | `20` | Active session cap. `0` / `Infinity` means unlimited; `NaN` / negative values throw. |
| `--max-pending-prompts-per-session <n>` | number | `5` | Accepted but pending/running prompt cap per session. Excess prompt returns 503. `0` / `Infinity` means unlimited; negative or non-integer values throw. |
| `--max-connections <n>` | number | `256` | HTTP listener `server.maxConnections`; `0` / `Infinity` means unlimited. |

View file

@ -73,33 +73,33 @@ With the hardened loopback recipe (3), `/demo` is registered after `bearerAuth`.
The CLI is defined in **`packages/cli/src/commands/serve.ts`**:
| Flag | Type | Default | Required when | Effect |
| --------------------------------------- | ------------------------------ | -------------------------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--port <n>` | number | `4170` | - | TCP port; `0` means OS-assigned ephemeral port. |
| `--hostname <host>` | string | `127.0.0.1` | Non-loopback requires token | Bind address. Loopback values: `127.0.0.1`, `localhost`, `::1`, `[::1]`. `[::1]` brackets are stripped automatically; `host:port` input is rejected with guidance to use `--port`. |
| `--token <s>` | string | env / none | Non-loopback and `--require-auth` | Bearer token; trimmed once. **It appears in `/proc/<pid>/cmdline`, so prefer `QWEN_SERVER_TOKEN`**. Boot stderr also warns about this. |
| `--max-sessions <n>` | number | `20` | - | Active session cap. Excess spawn returns 503. `0` means unlimited. `NaN` / negative values throw. |
| `--max-pending-prompts-per-session <n>` | number | `5` | - | Accepted but pending/running prompt cap per session. Excess prompt returns 503. `0` / `Infinity` means unlimited. Negative or non-integer values throw. |
| `--workspace <dir>` | string | `process.cwd()` | - | Bound workspace. **Must be an absolute path, must exist, and must be a directory**. Boot canonicalizes it once via `canonicalizeWorkspace`. `POST /session` with a mismatched `cwd` returns `400 workspace_mismatch`. |
| `--max-connections <n>` | number | `256` | - | Listener-level `server.maxConnections`. `0` / `Infinity` means unlimited. `NaN` / negative values fail boot to avoid fail-open behavior. |
| `--require-auth` | boolean | `false` | Token required | Extends bearer auth to loopback **and** `/health`. Boot refuses to start without a token. |
| `--enable-session-shell` | boolean | `false` | Token required | Enables direct `POST /session/:id/shell` execution. Callers must also send a session-bound `X-Qwen-Client-Id`. |
| `--event-ring-size <n>` | number | `8000` | - | Per-session SSE replay ring depth. Soft cap is `MAX_EVENT_RING_SIZE = 1_000_000`; out-of-range values throw during bridge construction. |
| `--http-bridge` | boolean | `true` | - | Stage 1 bridge mode: one `qwen --acp` child multiplexed by the daemon. Stage 2 in-process mode is not implemented yet; `--no-http-bridge` falls back and prints to stderr. |
| `--mcp-client-budget <n>` | number | none | Required for `mcp-budget-mode=enforce` | Workspace MCP client cap. Must be a positive integer. |
| `--mcp-budget-mode <m>` | `'enforce' \| 'warn' \| 'off'` | `warn` when a budget is set, otherwise `off` | `enforce` requires `--mcp-client-budget` | `enforce` refuses, `warn` only warns at 75%, `off` is observation only. |
| `--allow-origin <pattern>` | repeatable string | none | - | CORS allowlist that replaces the default Origin denial. `*` requires a token. |
| `--allow-private-auth-base-url` | boolean | `false` | - | Allows localhost / private-network auth provider `baseUrl` installation. Use only for trusted local development. |
| `--prompt-deadline-ms <n>` | number | none | - | Server-side prompt wallclock limit in ms; timeout aborts the prompt. |
| `--writer-idle-timeout-ms <n>` | number | none | - | Per-SSE-connection idle timeout in ms. |
| `--channel-idle-timeout-ms <n>` | number | `0` | - | Keeps the ACP child alive after the last session closes. `0` means reclaim immediately. |
| `--session-reap-interval-ms <n>` | number | `60000` | - | Session reaper scan interval. `0` disables it. |
| `--session-idle-timeout-ms <n>` | number | `1800000` | - | Disconnected-session idle timeout. `0` disables it. |
| `--rate-limit` / `--no-rate-limit` | boolean | env / off | - | Enables or disables per-tier HTTP rate limiting. |
| `--rate-limit-prompt <n>` | number | `10` | `--rate-limit` | Prompt requests per window. |
| `--rate-limit-mutation <n>` | number | `30` | `--rate-limit` | Mutation requests per window. |
| `--rate-limit-read <n>` | number | `120` | `--rate-limit` | Read requests per window. |
| `--rate-limit-window-ms <n>` | number | `60000` | `--rate-limit` | Rate limit window length; must be `>= 1000`. |
| Flag | Type | Default | Required when | Effect |
| --------------------------------------- | ------------------------------ | -------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--port <n>` | number | `4170` | - | TCP port; `0` means OS-assigned ephemeral port. |
| `--hostname <host>` | string | `127.0.0.1` | Non-loopback requires token | Bind address. Loopback values: `127.0.0.1`, `localhost`, `::1`, `[::1]`. `[::1]` brackets are stripped automatically; `host:port` input is rejected with guidance to use `--port`. |
| `--token <s>` | string | env / none | Non-loopback and `--require-auth` | Bearer token; trimmed once. **It appears in `/proc/<pid>/cmdline`, so prefer `QWEN_SERVER_TOKEN`**. Boot stderr also warns about this. |
| `--max-sessions <n>` | number | `20` | - | Active session cap. Excess spawn returns 503. `0` means unlimited. `NaN` / negative values throw. |
| `--max-pending-prompts-per-session <n>` | number | `5` | - | Accepted but pending/running prompt cap per session. Excess prompt returns 503. `0` / `Infinity` means unlimited. Negative or non-integer values throw. |
| `--workspace <dir>` | string / repeatable | `process.cwd()` | - | Primary workspace when supplied once; repeat to register sessions-only additional workspaces. Each value **must be an absolute path, must exist, and must be a directory**. Boot canonicalizes every value via `canonicalizeWorkspace`. `POST /session` with a mismatched `cwd` returns `400 workspace_mismatch`. |
| `--max-connections <n>` | number | `256` | - | Listener-level `server.maxConnections`. `0` / `Infinity` means unlimited. `NaN` / negative values fail boot to avoid fail-open behavior. |
| `--require-auth` | boolean | `false` | Token required | Extends bearer auth to loopback **and** `/health`. Boot refuses to start without a token. |
| `--enable-session-shell` | boolean | `false` | Token required | Enables direct `POST /session/:id/shell` execution. Callers must also send a session-bound `X-Qwen-Client-Id`. |
| `--event-ring-size <n>` | number | `8000` | - | Per-session SSE replay ring depth. Soft cap is `MAX_EVENT_RING_SIZE = 1_000_000`; out-of-range values throw during bridge construction. |
| `--http-bridge` | boolean | `true` | - | Bridge mode: one `qwen --acp` child for the primary workspace, plus one child per additional registered workspace in multi-workspace session mode. Stage 2 in-process mode is not implemented yet; `--no-http-bridge` falls back and prints to stderr. |
| `--mcp-client-budget <n>` | number | none | Required for `mcp-budget-mode=enforce` | Workspace MCP client cap. Must be a positive integer. |
| `--mcp-budget-mode <m>` | `'enforce' \| 'warn' \| 'off'` | `warn` when a budget is set, otherwise `off` | `enforce` requires `--mcp-client-budget` | `enforce` refuses, `warn` only warns at 75%, `off` is observation only. |
| `--allow-origin <pattern>` | repeatable string | none | - | CORS allowlist that replaces the default Origin denial. `*` requires a token. |
| `--allow-private-auth-base-url` | boolean | `false` | - | Allows localhost / private-network auth provider `baseUrl` installation. Use only for trusted local development. |
| `--prompt-deadline-ms <n>` | number | none | - | Server-side prompt wallclock limit in ms; timeout aborts the prompt. |
| `--writer-idle-timeout-ms <n>` | number | none | - | Per-SSE-connection idle timeout in ms. |
| `--channel-idle-timeout-ms <n>` | number | `0` | - | Keeps the ACP child alive after the last session closes. `0` means reclaim immediately. |
| `--session-reap-interval-ms <n>` | number | `60000` | - | Session reaper scan interval. `0` disables it. |
| `--session-idle-timeout-ms <n>` | number | `1800000` | - | Disconnected-session idle timeout. `0` disables it. |
| `--rate-limit` / `--no-rate-limit` | boolean | env / off | - | Enables or disables per-tier HTTP rate limiting. |
| `--rate-limit-prompt <n>` | number | `10` | `--rate-limit` | Prompt requests per window. |
| `--rate-limit-mutation <n>` | number | `30` | `--rate-limit` | Mutation requests per window. |
| `--rate-limit-read <n>` | number | `120` | `--rate-limit` | Read requests per window. |
| `--rate-limit-window-ms <n>` | number | `60000` | `--rate-limit` | Rate limit window length; must be `>= 1000`. |
## 4. Environment variables

View file

@ -12,7 +12,7 @@ qwen serve --port 4170
# → qwen serve listening on http://127.0.0.1:4170 (mode=http-bridge, workspace=/path/to/your-project)
```
Per [#3803](https://github.com/QwenLM/qwen-code/issues/3803) §02 each daemon binds to one workspace at boot (the current `cwd`, or override with `--workspace /path/to/dir`). The daemon's bound path is advertised on `/capabilities.workspaceCwd` so clients can pre-flight check + omit `cwd` from `POST /session`.
By default the daemon binds to the current directory (or `--workspace /path/to/dir`). The primary path is advertised on `/capabilities.workspaceCwd` so clients can omit `cwd` from `POST /session`. Daemons that advertise `multi_workspace_sessions` also include `workspaces[]`; pass one of those trusted `cwd` values to create a session in a non-primary workspace.
In another:
@ -38,19 +38,20 @@ const client = new DaemonClient({
});
// 1. Confirm we can reach the daemon, gate UI on its features, and
// read back the daemon's bound workspace (#3803 §02).
// read back the daemon's primary workspace.
const caps = await client.capabilities();
console.log('Daemon features:', caps.features);
console.log('Daemon workspace:', caps.workspaceCwd); // canonical bound path
console.log('Daemon workspace:', caps.workspaceCwd); // canonical primary path
// 2. Spawn-or-attach a session. Two equally-valid shapes:
// (a) pass `workspaceCwd: caps.workspaceCwd` to be explicit, or
// (b) omit `workspaceCwd` entirely — the SDK then sends no `cwd`
// field and the daemon route falls back to its bound
// field and the daemon route falls back to its primary
// workspace. The (b) shape is concise but assumes you trust
// `caps.workspaceCwd` to be whatever you intended.
// A non-empty `workspaceCwd` that doesn't canonicalize to the
// daemon's bound path yields `400 workspace_mismatch` (see
// primary path, or to one of `caps.workspaces[].cwd` on a daemon with
// `multi_workspace_sessions`, yields `400 workspace_mismatch` (see
// "Workspace mismatch" below).
const session = await client.createOrAttachSession({
workspaceCwd: caps.workspaceCwd,
@ -182,7 +183,7 @@ case 'permission_request': {
## Shared-session collaboration
Two clients pointed at the **same daemon** end up on the same session. Per #3803 §02 each daemon is bound to ONE workspace at boot, so the daemon launched as `qwen serve --workspace /work/repo` (or `cd /work/repo && qwen serve`) is what both clients connect to:
Two clients pointed at the **same daemon workspace** end up on the same session when they use the default `sessionScope: 'single'`. For a single-workspace daemon launched as `qwen serve --workspace /work/repo` (or `cd /work/repo && qwen serve`), both clients connect to that primary workspace:
```ts
// Daemon was launched as `qwen serve --workspace /work/repo` so
@ -202,7 +203,7 @@ Both clients see the same `session_update` / `permission_request` stream. Either
## Workspace mismatch
If `workspaceCwd` doesn't match the daemon's bound workspace, `createOrAttachSession` rejects with `DaemonHttpError` carrying status `400` and a structured body:
If `workspaceCwd` doesn't match the daemon's primary workspace, or any trusted `workspaces[].cwd` on a daemon that advertises `multi_workspace_sessions`, `createOrAttachSession` rejects with `DaemonHttpError` carrying status `400` and a structured body:
```ts
import { DaemonHttpError } from '@qwen-code/sdk';

View file

@ -72,18 +72,18 @@ with status `400`.
with status `404`.
`WorkspaceMismatchError` for a `POST /session` whose `cwd` doesn't canonicalize to the daemon's bound workspace (#3803 §02 — 1 daemon = 1 workspace) returns `400` with:
`WorkspaceMismatchError` for a `POST /session` whose `cwd` doesn't canonicalize to a registered workspace returns `400` with:
```json
{
"error": "Workspace mismatch: daemon is bound to \"…\" but request asked for \"…\". …",
"error": "Workspace mismatch: daemon is bound to \"…\"",
"code": "workspace_mismatch",
"boundWorkspace": "/path/the/daemon/binds",
"boundWorkspace": "/path/the/daemon/uses/as-primary",
"requestedWorkspace": "/path/in/the/request"
}
```
Use this to detect mismatch pre-flight: read `workspaceCwd` off `/capabilities` and omit `cwd` from `POST /session` (it falls back to the bound workspace), or route the request to a daemon bound to `requestedWorkspace`.
Use this to detect mismatch pre-flight: read `workspaceCwd` off `/capabilities` and omit `cwd` from `POST /session` (it falls back to the primary workspace), or when `multi_workspace_sessions` is advertised choose one of `workspaces[].cwd`.
`POST /session` past the daemon's `--max-sessions` cap returns `503` with a `Retry-After: 5` header and:
@ -369,7 +369,7 @@ runtime routes return `503`.
`runtime.activity` reports daemon-wide prompt activity. `activePrompts` counts sessions with an in-flight prompt. `pendingPrompts` counts all accepted prompts that have not settled yet, including the running prompt and FIFO-waiting prompts. `queuedPrompts` counts FIFO-waiting prompts that have been accepted but not dispatched. `lastActivityAt` is the ISO 8601 timestamp of the last prompt start/end or session spawn; `null` when the daemon has never processed any activity since boot. `idleSinceMs` is computed from `lastActivityAt` at response generation time.
`limits.maxTotalSessions` is additive. `null` means the daemon-wide fresh-session cap is disabled. When set, it limits fresh session creation across the daemon and reports total-limit failures with the existing `session_limit_exceeded` error shape plus `scope: "total"`. It does not change `/capabilities`, does not advertise `workspaces[]`, and does not enable multi-workspace routing by itself.
`limits.maxTotalSessions` is additive. `null` means the effective daemon-wide fresh-session cap is disabled. In multi-workspace mode, when `--max-total-sessions` is omitted and `maxSessionsPerWorkspace` is finite, the daemon derives the effective total cap as `maxSessionsPerWorkspace * workspaces.length`. When set, it limits fresh session creation across the daemon and reports total-limit failures with the existing `session_limit_exceeded` error shape plus `scope: "total"`.
`runtime.channel.live` reports the ACP bridge channel inside the daemon. It is
not the channel-adapter worker. Daemon-managed channels use
@ -415,9 +415,28 @@ the daemon log path; `full` may include it for authenticated operators.
"supported": ["v1"]
},
"mode": "http-bridge",
"features": ["health", "daemon_status", "capabilities", "..."],
"features": ["health", "daemon_status", "capabilities", "multi_workspace_sessions", "..."],
"limits": {
"maxPendingPromptsPerSession": 5,
"maxSessionsPerWorkspace": 20,
"maxTotalSessions": 40
},
"modelServices": [],
"workspaceCwd": "/canonical/path/to/workspace"
"workspaceCwd": "/canonical/path/to/primary-workspace",
"workspaces": [
{
"id": "stable-workspace-id",
"cwd": "/canonical/path/to/primary-workspace",
"primary": true,
"trusted": true
},
{
"id": "stable-secondary-workspace-id",
"cwd": "/canonical/path/to/secondary-workspace",
"primary": false,
"trusted": true
}
]
}
```
@ -427,7 +446,9 @@ Stable contract: when `v` increments the frame layout has changed in a backwards
> **`modelServices` is always `[]` in Stage 1.** The agent uses its single default model service and doesn't enumerate it over the wire. Stage 2 will populate this from registered model adapters so SDK clients can build service-pickers; until then, do NOT rely on this field being non-empty.
> **`workspaceCwd`** is the canonical absolute path this daemon binds to (#3803 §02 — 1 daemon = 1 workspace). Use it to (a) detect mismatch before posting `/session` and (b) omit `cwd` on `POST /session` (the route falls back to this path). Multi-workspace deployments expose multiple daemons on different ports, each with its own `workspaceCwd`. Additive to v=1: pre-§02 v=1 daemons omit the field — clients that target older builds should null-check before consuming it.
> **`workspaceCwd`** is the canonical absolute path for the daemon's primary workspace. Use it to omit `cwd` on `POST /session` (the route falls back to this primary path) and to keep old single-workspace clients compatible. Additive to v=1: pre-§02 v=1 daemons omit the field — clients that target older builds should null-check before consuming it.
> **`workspaces[]`** is present only when `features` contains `multi_workspace_sessions`. Each entry is `{ id, cwd, primary, trusted }`. The first/primary workspace remains mirrored by `workspaceCwd`; new clients choose a non-primary runtime by passing that entry's `cwd` to `POST /session`. Untrusted workspaces are advertised for diagnostics but reject fresh session creation with `403 untrusted_workspace` until trust changes.
### Read-only runtime status routes
@ -875,7 +896,7 @@ returned.
### Workspace file routes
All file paths are resolved through the daemon's bound workspace. Responses use
All file paths are resolved through the daemon's primary workspace. Responses use
workspace-relative paths and never return absolute filesystem paths for normal
success cases. Successful file responses include:
@ -1187,7 +1208,7 @@ Request:
| Field | Required | Notes |
| ---------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `cwd` | no | Absolute path matching the daemon's bound workspace. If omitted, the route falls back to `boundWorkspace` (read it off `/capabilities.workspaceCwd`). A mismatched non-empty `cwd` returns `400 workspace_mismatch` (#3803 §02 — 1 daemon = 1 workspace). Workspace paths are canonicalized via `realpathSync.native` (with a resolve-only fallback for non-existent paths) so case-insensitive filesystems don't reject sessions per spelling. |
| `cwd` | no | Absolute path matching one registered workspace. If omitted, the route falls back to the primary workspace (read it off `/capabilities.workspaceCwd`). A mismatched non-empty `cwd` returns `400 workspace_mismatch`. When `features` contains `multi_workspace_sessions`, clients may pass any trusted `workspaces[].cwd`; otherwise only the primary workspace is accepted. Workspace paths are canonicalized via `realpathSync.native` (with a resolve-only fallback for non-existent paths) so case-insensitive filesystems don't reject sessions per spelling. |
| `modelServiceId` | no | Selects which configured _model service_ the agent will route through (the back-end provider — Alibaba ModelStudio, OpenRouter, etc). If omitted the agent uses its default. If the workspace already has a session, this calls `setSessionModel` on the existing one and broadcasts `model_switched`. Distinct from `modelId` on `POST /session/:id/model`, which selects the model **within** an already-bound service. The `modelServices` array on `/capabilities` is reserved for advertising configured services; in Stage 1 it is always `[]` (the agent's default service is used and not enumerated over HTTP). |
| `sessionScope` | no | Per-request override for session sharing. `'single'` (the daemon-wide default) makes a second same-workspace `POST /session` reuse the existing session (`attached: true`); `'thread'` forces a fresh distinct session every call. Omit to inherit the daemon-wide default. Values outside the enum return `400 { code: 'invalid_session_scope' }`. Old daemons (pre-#4175 PR 5) silently ignore the field — pre-flight `caps.features.session_scope_override` before sending. The daemon-wide default is hardcoded to `'single'` in production today; #4175 may add a `--sessionScope` CLI flag in a follow-up. |
@ -1739,7 +1760,7 @@ SSE event (workspace-scoped): `tool_toggled` with `{toolName, enabled, originato
Capability tag: `workspace_init`. Pure file IO — no ACP roundtrip, **no LLM invocation**.
Scaffold an empty `QWEN.md` (or whatever `getCurrentGeminiMdFilename()` returns under `--memory-file-name` overrides) at the daemon's bound workspace root. Mechanical only — for AI-driven content fill, follow up with `POST /session/:id/prompt`.
Scaffold an empty `QWEN.md` (or whatever `getCurrentGeminiMdFilename()` returns under `--memory-file-name` overrides) at the daemon's primary workspace root. Mechanical only — for AI-driven content fill, follow up with `POST /session/:id/prompt`.
Default refuses to overwrite when the target file exists with non-whitespace content. Whitespace-only files are treated as absent (matches the local `/init` slash command).

View file

@ -189,8 +189,8 @@ export const serveCommand: CommandModule<unknown, ServeArgs> = {
'Absolute workspace path this daemon binds to. ' +
'POST /session requests with a mismatched cwd return 400 workspace_mismatch. ' +
'Defaults to process.cwd() when omitted. ' +
'For multi-workspace deployments, run one `qwen serve` per workspace ' +
'on separate ports (or behind an external orchestrator).',
'Repeat for sessions-only multi-workspace mode; legacy workspace APIs ' +
'remain primary-workspace only.',
})
.option('max-connections', {
type: 'number',
@ -281,10 +281,10 @@ export const serveCommand: CommandModule<unknown, ServeArgs> = {
type: 'boolean',
default: true,
description:
'Stage 1 mode: one `qwen --acp` child per daemon (the daemon binds to ' +
'one workspace at boot, multiplexing N sessions onto that child via ' +
"the agent's native `newSession()`). Stage 2 native in-process mode " +
'is not yet implemented; this flag will become opt-in then.',
'HTTP bridge mode: one `qwen --acp` child per registered workspace ' +
'(sessions-only multi-workspace routing is enabled when multiple ' +
'--workspace values are supplied). Stage 2 native in-process mode is ' +
'not yet implemented; this flag will become opt-in then.',
})
.option('mcp-client-budget', {
type: 'number',

View file

@ -258,6 +258,9 @@ export const SERVE_CAPABILITY_REGISTRY = {
session_branch: { since: 'v1' },
rate_limit: { since: 'v1' },
workspace_reload: { since: 'v1' },
// Multi-workspace sessions closed loop (issue #6378 Phase 2a). Advertised
// only when one daemon hosts more than one registered workspace runtime.
multi_workspace_sessions: { since: 'v1' },
// Phase 2 "reverse tool channel" (issue #5626). A connected WS client (e.g.
// the Chrome extension) can host an MCP server that the daemon's agent
// calls by carrying `mcp_message` JSON-RPC frames over the daemon WS,
@ -315,6 +318,7 @@ export interface AdvertiseFeatureToggles {
*/
cdpTunnelOverWsEnabled?: boolean;
voiceWsAvailable?: boolean;
multiWorkspaceSessionsEnabled?: boolean;
}
/**
@ -381,6 +385,10 @@ export const CONDITIONAL_SERVE_FEATURES: ReadonlyMap<
],
['rate_limit', (toggles) => toggles.rateLimit === true],
['workspace_reload', (toggles) => toggles.reloadAvailable === true],
[
'multi_workspace_sessions',
(toggles) => toggles.multiWorkspaceSessionsEnabled === true,
],
['client_mcp_over_ws', (toggles) => toggles.clientMcpOverWsEnabled === true],
['cdp_tunnel_over_ws', (toggles) => toggles.cdpTunnelOverWsEnabled === true],
[

View file

@ -104,6 +104,58 @@ describe('buildDaemonStatusResponse', () => {
});
});
it('reuses the primary bridge snapshot when a workspace registry is installed', async () => {
const primarySnapshot = vi.fn(() => ({
...BASE_BRIDGE_SNAPSHOT,
sessionCount: 1,
}));
const secondarySnapshot = vi.fn(() => ({
...BASE_BRIDGE_SNAPSHOT,
sessionCount: 2,
}));
const primaryBridge = {
getDaemonStatusSnapshot: primarySnapshot,
lastActivityAt: null,
} as unknown as AcpSessionBridge;
const secondaryBridge = {
getDaemonStatusSnapshot: secondarySnapshot,
lastActivityAt: null,
} as unknown as AcpSessionBridge;
const options = makeOptions();
options.bridge = primaryBridge;
options.workspaceRegistry = {
primary: {
workspaceId: 'primary',
workspaceCwd: BASE_WORKSPACE,
primary: true,
trusted: true,
bridge: primaryBridge,
},
list: () => [
{
workspaceId: 'primary',
workspaceCwd: BASE_WORKSPACE,
primary: true,
trusted: true,
bridge: primaryBridge,
},
{
workspaceId: 'secondary',
workspaceCwd: '/work/secondary',
primary: false,
trusted: true,
bridge: secondaryBridge,
},
],
} as unknown as BuildDaemonStatusOptions['workspaceRegistry'];
const response = await buildDaemonStatusResponse('summary', options);
expect(primarySnapshot).toHaveBeenCalledTimes(1);
expect(secondarySnapshot).toHaveBeenCalledTimes(1);
expect(response.runtime.sessions.active).toBe(3);
});
it('reports every runtime issue code from daemon counters', async () => {
const response = await buildDaemonStatusResponse(
'summary',
@ -604,6 +656,87 @@ describe('buildDaemonStatusResponse', () => {
});
});
it('derives queued prompts per runtime when pendingPromptTotal is unavailable', async () => {
const primarySnapshot = {
...BASE_BRIDGE_SNAPSHOT,
sessionCount: 1,
sessions: [
{
sessionId: 'primary',
workspaceCwd: BASE_WORKSPACE,
createdAt: '2026-07-01T00:00:00.000Z',
clientCount: 1,
subscriberCount: 1,
attachCount: 1,
pendingPromptCount: 3,
pendingPermissionCount: 0,
hasActivePrompt: true,
lastEventId: 1,
},
],
};
const secondarySnapshot = {
...BASE_BRIDGE_SNAPSHOT,
sessionCount: 1,
sessions: [
{
sessionId: 'secondary',
workspaceCwd: '/work/secondary',
createdAt: '2026-07-01T00:00:00.000Z',
clientCount: 1,
subscriberCount: 1,
attachCount: 1,
pendingPromptCount: 2,
pendingPermissionCount: 0,
hasActivePrompt: false,
lastEventId: 1,
},
],
};
const primaryBridge = {
getDaemonStatusSnapshot: () => primarySnapshot,
lastActivityAt: null,
} as unknown as AcpSessionBridge;
const secondaryBridge = {
getDaemonStatusSnapshot: () => secondarySnapshot,
lastActivityAt: null,
} as unknown as AcpSessionBridge;
const options = makeOptions();
options.bridge = primaryBridge;
options.workspaceRegistry = {
primary: {
workspaceId: 'primary',
workspaceCwd: BASE_WORKSPACE,
primary: true,
trusted: true,
bridge: primaryBridge,
},
list: () => [
{
workspaceId: 'primary',
workspaceCwd: BASE_WORKSPACE,
primary: true,
trusted: true,
bridge: primaryBridge,
},
{
workspaceId: 'secondary',
workspaceCwd: '/work/secondary',
primary: false,
trusted: true,
bridge: secondaryBridge,
},
],
} as unknown as BuildDaemonStatusOptions['workspaceRegistry'];
const response = await buildDaemonStatusResponse('summary', options);
expect(response.runtime.activity).toMatchObject({
pendingPrompts: 5,
queuedPrompts: 4,
});
});
it('does not report negative queued prompts from inconsistent snapshots', async () => {
const response = await buildDaemonStatusResponse(
'summary',

View file

@ -22,6 +22,7 @@ import type {
WorkspaceRequestContext,
} from './workspace-service/index.js';
import type { TotalSessionAdmissionSnapshot } from './total-session-admission.js';
import type { WorkspaceRegistry } from './workspace-registry.js';
// Re-export so downstream consumers (server.ts, routes, the SDK type mirror)
// import the bucket shape from the status module alongside the rest of the
@ -89,6 +90,7 @@ export interface BuildDaemonStatusOptions {
opts: ServeOptions;
boundWorkspace: string;
bridge: AcpSessionBridge;
workspaceRegistry?: WorkspaceRegistry;
workspace: DaemonWorkspaceService;
daemonLog?: DaemonLogger;
qwenCodeVersion?: string;
@ -132,6 +134,12 @@ interface FullDaemonStatus {
};
}
interface WorkspaceBridgeStatusSnapshot {
workspaceCwd: string;
snapshot: BridgeDaemonStatusSnapshot;
lastActivity: number | null;
}
interface DaemonStatusSecurity {
tokenConfigured: boolean;
requireAuth: boolean;
@ -238,6 +246,12 @@ export interface DaemonStatusResponse {
};
security: DaemonStatusSecurity;
limits: DaemonStatusLimits;
workspaces?: Array<{
id: string;
cwd: string;
primary: boolean;
trusted: boolean;
}>;
capabilities: {
protocolVersions: ServeProtocolVersions;
features: string[];
@ -272,18 +286,73 @@ export async function buildDaemonStatusResponse(
): Promise<DaemonStatusResponse> {
const bridgeSnapshot = input.bridge.getDaemonStatusSnapshot();
const lastActivity = input.bridge.lastActivityAt ?? null;
const workspaceRuntimes = input.workspaceRegistry?.list();
const workspaceSnapshots: WorkspaceBridgeStatusSnapshot[] =
workspaceRuntimes?.map((runtime) => ({
workspaceCwd: runtime.workspaceCwd,
snapshot:
runtime.bridge === input.bridge
? bridgeSnapshot
: runtime.bridge.getDaemonStatusSnapshot(),
lastActivity:
runtime.bridge === input.bridge
? lastActivity
: (runtime.bridge.lastActivityAt ?? null),
})) ?? [
{
workspaceCwd: input.boundWorkspace,
snapshot: bridgeSnapshot,
lastActivity,
},
];
const aggregatedSessionCount = workspaceSnapshots.reduce(
(sum, item) => sum + item.snapshot.sessionCount,
0,
);
const aggregatedPendingPermissionCount = workspaceSnapshots.reduce(
(sum, item) => sum + item.snapshot.pendingPermissionCount,
0,
);
const aggregatedChannelLive = workspaceSnapshots.some(
(item) => item.snapshot.channelLive,
);
const aggregatedLastActivity = workspaceSnapshots.reduce<number | null>(
(latest, item) =>
item.lastActivity !== null &&
(latest === null || item.lastActivity > latest)
? item.lastActivity
: latest,
null,
);
const acpSnapshot = input.acpHandle?.registry.getSnapshot();
const rateLimitHits = input.rateLimiter?.getHitCounts() ?? zeroRateHits();
let pendingPrompts = 0;
let derivedQueuedPrompts = 0;
for (const session of bridgeSnapshot.sessions) {
pendingPrompts += session.pendingPromptCount;
derivedQueuedPrompts += Math.max(
0,
session.pendingPromptCount - (session.hasActivePrompt ? 1 : 0),
);
const derivedQueuedPromptsByWorkspace: number[] = [];
for (const [index, { snapshot }] of workspaceSnapshots.entries()) {
let derivedQueuedPromptsForWorkspace = 0;
for (const session of snapshot.sessions) {
pendingPrompts += session.pendingPromptCount;
const sessionQueuedPrompts = Math.max(
0,
session.pendingPromptCount - (session.hasActivePrompt ? 1 : 0),
);
derivedQueuedPrompts += sessionQueuedPrompts;
derivedQueuedPromptsForWorkspace += sessionQueuedPrompts;
}
derivedQueuedPromptsByWorkspace[index] = derivedQueuedPromptsForWorkspace;
}
const queuedPrompts = input.bridge.pendingPromptTotal ?? derivedQueuedPrompts;
const queuedPrompts =
workspaceRuntimes?.reduce(
(sum, runtime, index) =>
sum +
(runtime.bridge.pendingPromptTotal ??
derivedQueuedPromptsByWorkspace[index] ??
0),
0,
) ??
input.bridge.pendingPromptTotal ??
derivedQueuedPrompts;
const channelWorker = input.getChannelWorkerSnapshot?.() ?? {
enabled: false,
state: 'disabled',
@ -295,16 +364,20 @@ export async function buildDaemonStatusResponse(
pushRuntimeIssues(
issues,
bridgeSnapshot,
acpSnapshot,
rateLimitHits,
input,
channelWorker,
totalAdmissionSnapshot,
workspaceSnapshots,
);
if (detail === 'full') {
full = await buildFullStatus(input, bridgeSnapshot, acpSnapshot);
full = await buildFullStatus(
input,
acpSnapshot,
workspaceSnapshots.flatMap((item) => item.snapshot.sessions),
);
pushFullIssues(issues, full);
}
@ -354,22 +427,32 @@ export async function buildDaemonStatusResponse(
sessionIdleTimeoutMs: bridgeSnapshot.limits.sessionIdleTimeoutMs,
acpConnectionCap: acpSnapshot?.connectionCap ?? null,
},
...(workspaceRuntimes && workspaceRuntimes.length > 1
? {
workspaces: workspaceRuntimes.map((runtime) => ({
id: runtime.workspaceId,
cwd: runtime.workspaceCwd,
primary: runtime.primary,
trusted: runtime.trusted,
})),
}
: {}),
capabilities: {
protocolVersions: input.protocolVersions,
features: [...input.features],
},
runtime: {
sessions: {
active: bridgeSnapshot.sessionCount,
active: aggregatedSessionCount,
...(totalAdmissionSnapshot
? { admissionInFlight: totalAdmissionSnapshot.inFlight }
: {}),
},
permissions: {
pending: bridgeSnapshot.pendingPermissionCount,
pending: aggregatedPendingPermissionCount,
policy: bridgeSnapshot.permissionPolicy,
},
channel: { live: bridgeSnapshot.channelLive },
channel: { live: aggregatedChannelLive },
channelWorker,
transport: {
restSseActive: input.getRestSseActive(),
@ -392,12 +475,23 @@ export async function buildDaemonStatusResponse(
? { metrics: { series: input.getMetricsSeries() } }
: {}),
activity: {
activePrompts: input.bridge.activePromptCount ?? 0,
activePrompts:
workspaceRuntimes?.reduce(
(sum, runtime) => sum + (runtime.bridge.activePromptCount ?? 0),
0,
) ??
input.bridge.activePromptCount ??
0,
pendingPrompts,
queuedPrompts,
lastActivityAt:
lastActivity !== null ? new Date(lastActivity).toISOString() : null,
idleSinceMs: lastActivity !== null ? Date.now() - lastActivity : null,
aggregatedLastActivity !== null
? new Date(aggregatedLastActivity).toISOString()
: null,
idleSinceMs:
aggregatedLastActivity !== null
? Date.now() - aggregatedLastActivity
: null,
},
process: process.memoryUsage(),
},
@ -429,8 +523,8 @@ function cloneStartup(startup: DaemonStartupSnapshot): DaemonStartupSnapshot {
async function buildFullStatus(
input: BuildDaemonStatusOptions,
bridgeSnapshot: BridgeDaemonStatusSnapshot,
acpSnapshot: ReturnType<AcpHttpHandle['registry']['getSnapshot']> | undefined,
sessions: BridgeDaemonStatusSnapshot['sessions'],
): Promise<FullDaemonStatus> {
const ctx: WorkspaceRequestContext = {
route: 'GET /daemon/status',
@ -465,7 +559,7 @@ async function buildFullStatus(
]);
return {
sessions: bridgeSnapshot.sessions,
sessions,
acpConnections: acpSnapshot?.connections ?? [],
workspace: {
mcp,
@ -532,30 +626,39 @@ async function withTimeout<T>(
function pushRuntimeIssues(
issues: DaemonStatusIssue[],
bridgeSnapshot: BridgeDaemonStatusSnapshot,
acpSnapshot: ReturnType<AcpHttpHandle['registry']['getSnapshot']> | undefined,
rateLimitHits: Record<RateLimitTier, number>,
input: BuildDaemonStatusOptions,
channelWorker: ChannelWorkerSnapshot,
totalAdmissionSnapshot: TotalSessionAdmissionSnapshot | undefined,
workspaceSnapshots: readonly WorkspaceBridgeStatusSnapshot[],
): void {
if (
bridgeSnapshot.limits.maxSessions !== null &&
bridgeSnapshot.limits.maxSessions > 0 &&
bridgeSnapshot.sessionCount / bridgeSnapshot.limits.maxSessions >=
CAPACITY_WARNING_RATIO
) {
issues.push({
code: 'session_capacity_high',
severity: 'warning',
message: `Active sessions are at ${bridgeSnapshot.sessionCount}/${bridgeSnapshot.limits.maxSessions}.`,
});
for (const { workspaceCwd, snapshot } of workspaceSnapshots) {
if (
snapshot.limits.maxSessions !== null &&
snapshot.limits.maxSessions > 0 &&
snapshot.sessionCount / snapshot.limits.maxSessions >=
CAPACITY_WARNING_RATIO
) {
issues.push({
code: 'session_capacity_high',
severity: 'warning',
message:
workspaceSnapshots.length > 1
? `Workspace ${workspaceCwd} active sessions are at ${snapshot.sessionCount}/${snapshot.limits.maxSessions}.`
: `Active sessions are at ${snapshot.sessionCount}/${snapshot.limits.maxSessions}.`,
});
}
}
const maxTotalSessions = positiveFiniteOrNull(input.opts.maxTotalSessions);
if (maxTotalSessions !== null) {
const fallbackLiveCount = workspaceSnapshots.reduce(
(sum, item) => sum + item.snapshot.sessionCount,
0,
);
const totalActive =
(totalAdmissionSnapshot?.liveCount ?? bridgeSnapshot.sessionCount) +
(totalAdmissionSnapshot?.liveCount ?? fallbackLiveCount) +
(totalAdmissionSnapshot?.inFlight ?? 0);
if (totalActive / maxTotalSessions >= CAPACITY_WARNING_RATIO) {
issues.push({
@ -580,19 +683,29 @@ function pushRuntimeIssues(
});
}
if (bridgeSnapshot.pendingPermissionCount > 0) {
const pendingPermissionCount = workspaceSnapshots.reduce(
(sum, item) => sum + item.snapshot.pendingPermissionCount,
0,
);
if (pendingPermissionCount > 0) {
issues.push({
code: 'pending_permissions',
severity: 'warning',
message: `${bridgeSnapshot.pendingPermissionCount} permission request(s) are pending.`,
message: `${pendingPermissionCount} permission request(s) are pending.`,
});
}
if (bridgeSnapshot.sessionCount > 0 && !bridgeSnapshot.channelLive) {
const downWorkspaces = workspaceSnapshots.filter(
(item) => item.snapshot.sessionCount > 0 && !item.snapshot.channelLive,
);
if (downWorkspaces.length > 0) {
issues.push({
code: 'acp_channel_down',
severity: 'error',
message: 'Active sessions exist but the ACP channel is not live.',
message:
downWorkspaces.length === 1
? `Active sessions exist but the ACP channel is not live for ${downWorkspaces[0]!.workspaceCwd}.`
: `Active sessions exist but the ACP channel is not live for ${downWorkspaces.length} workspace(s).`,
});
}

View file

@ -0,0 +1,736 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as path from 'node:path';
import { describe, expect, it, vi } from 'vitest';
import request from 'supertest';
import {
SessionNotFoundError,
type AcpSessionBridge,
type BridgeClientRequestContext,
type BridgeDaemonStatusSnapshot,
type BridgeRestoreSessionRequest,
type BridgeSessionSummary,
type BridgeSpawnRequest,
} from './acp-session-bridge.js';
import { ClientMcpSenderRegistry } from './acp-http/client-mcp-sender-registry.js';
import type { DaemonLogger } from './daemon-logger.js';
import { createServeApp } from './server.js';
import type { WorkspaceFileSystemFactory } from './fs/index.js';
import type { ServeOptions } from './types.js';
import type { DaemonWorkspaceService } from './workspace-service/types.js';
import {
createWorkspaceRegistry,
type WorkspaceRuntime,
} from './workspace-registry.js';
const PRIMARY_CWD = path.resolve(path.sep, 'work', 'primary');
const SECONDARY_CWD = path.resolve(path.sep, 'work', 'secondary');
const UNKNOWN_CWD = path.resolve(path.sep, 'work', 'unknown');
const baseOpts: ServeOptions = {
hostname: '127.0.0.1',
port: 4170,
mode: 'http-bridge',
};
interface FakeBridge extends AcpSessionBridge {
readonly spawnCalls: BridgeSpawnRequest[];
readonly promptCalls: Array<{
sessionId: string;
context?: BridgeClientRequestContext;
}>;
readonly cancelCalls: string[];
readonly closeCalls: string[];
readonly heartbeatCalls: string[];
readonly detachCalls: string[];
readonly eventsCalls: Array<{ sessionId: string; options?: unknown }>;
readonly permissionCalls: Array<{
sessionId: string;
requestId: string;
response: unknown;
context?: unknown;
}>;
readonly pendingPromptCalls: string[];
readonly removePendingPromptCalls: Array<{
sessionId: string;
promptId: string;
}>;
readonly restoreCalls: Array<{
action: 'load' | 'resume';
req: BridgeRestoreSessionRequest;
}>;
}
function makeSummary(
sessionId: string,
workspaceCwd: string,
overrides: Partial<BridgeSessionSummary> = {},
): BridgeSessionSummary {
return {
sessionId,
workspaceCwd,
createdAt: '2026-07-08T00:00:00.000Z',
updatedAt: '2026-07-08T00:01:00.000Z',
displayName: sessionId,
clientCount: 1,
hasActivePrompt: false,
...overrides,
};
}
function makeBridge(
workspaceCwd: string,
summaries: BridgeSessionSummary[] = [],
options: { channelLive?: boolean } = {},
): FakeBridge {
const live = new Map(
summaries.map((summary) => [summary.sessionId, summary]),
);
const spawnCalls: BridgeSpawnRequest[] = [];
const promptCalls: FakeBridge['promptCalls'] = [];
const cancelCalls: string[] = [];
const closeCalls: string[] = [];
const heartbeatCalls: string[] = [];
const detachCalls: string[] = [];
const eventsCalls: FakeBridge['eventsCalls'] = [];
const permissionCalls: FakeBridge['permissionCalls'] = [];
const pendingPromptCalls: string[] = [];
const removePendingPromptCalls: FakeBridge['removePendingPromptCalls'] = [];
const restoreCalls: FakeBridge['restoreCalls'] = [];
const bridge = {
permissionPolicy: 'first-responder' as const,
spawnCalls,
promptCalls,
cancelCalls,
closeCalls,
heartbeatCalls,
detachCalls,
eventsCalls,
permissionCalls,
pendingPromptCalls,
removePendingPromptCalls,
restoreCalls,
get sessionCount() {
return live.size;
},
get activePromptCount() {
return 0;
},
get pendingPromptTotal() {
return 0;
},
get lastActivityAt() {
return null;
},
getDaemonStatusSnapshot(): BridgeDaemonStatusSnapshot {
return {
limits: {
maxSessions: 20,
maxPendingPromptsPerSession: 5,
eventRingSize: 8000,
compactedReplayMaxBytes: 4 * 1024 * 1024,
channelIdleTimeoutMs: 0,
sessionIdleTimeoutMs: 1_800_000,
},
sessionCount: live.size,
pendingPermissionCount: 0,
channelLive: options.channelLive ?? false,
permissionPolicy: 'first-responder',
sessions: [...live.values()].map((summary) => ({
sessionId: summary.sessionId,
workspaceCwd: summary.workspaceCwd,
createdAt: summary.createdAt,
displayName: summary.displayName,
clientCount: summary.clientCount,
subscriberCount: 0,
attachCount: summary.clientCount,
pendingPromptCount: 0,
pendingPermissionCount: 0,
hasActivePrompt: summary.hasActivePrompt,
lastEventId: 0,
})),
};
},
async spawnOrAttach(req: BridgeSpawnRequest) {
spawnCalls.push(req);
const sessionId = `${workspaceCwd}-spawned-${spawnCalls.length}`;
const summary = makeSummary(sessionId, req.workspaceCwd);
live.set(sessionId, summary);
return {
sessionId,
workspaceCwd: req.workspaceCwd,
attached: false,
clientId: `client-${spawnCalls.length}`,
};
},
async loadSession(req: BridgeRestoreSessionRequest) {
restoreCalls.push({ action: 'load', req });
return {
sessionId: req.sessionId,
workspaceCwd: req.workspaceCwd,
attached: false,
clientId: 'restore-client',
};
},
async resumeSession(req: BridgeRestoreSessionRequest) {
restoreCalls.push({ action: 'resume', req });
return {
sessionId: req.sessionId,
workspaceCwd: req.workspaceCwd,
attached: false,
clientId: 'restore-client',
};
},
listWorkspaceSessions(cwd: string) {
return [...live.values()].filter(
(summary) => summary.workspaceCwd === cwd,
);
},
getSessionSummary(sessionId: string) {
const summary = live.get(sessionId);
if (!summary) throw new SessionNotFoundError(sessionId);
return summary;
},
getSessionLastEventId() {
return 41;
},
sendPrompt(
sessionId: string,
_req: unknown,
_signal?: AbortSignal,
context?: BridgeClientRequestContext,
) {
promptCalls.push({ sessionId, ...(context ? { context } : {}) });
return Promise.resolve({ stopReason: 'end_turn' });
},
async cancelSession(sessionId: string) {
cancelCalls.push(sessionId);
},
recordHeartbeat(sessionId: string) {
heartbeatCalls.push(sessionId);
return { sessionId, lastSeenAt: 1_782_921_600_000 };
},
async detachClient(sessionId: string) {
detachCalls.push(sessionId);
},
async closeSession(sessionId: string) {
closeCalls.push(sessionId);
live.delete(sessionId);
},
getPendingPrompts(sessionId: string) {
if (!live.has(sessionId)) throw new SessionNotFoundError(sessionId);
pendingPromptCalls.push(sessionId);
return [
{
promptId: 'prompt-1',
text: 'queued',
queuedAt: 1,
state: 'queued' as const,
},
];
},
removePendingPrompt(sessionId: string, promptId: string) {
if (!live.has(sessionId)) throw new SessionNotFoundError(sessionId);
removePendingPromptCalls.push({ sessionId, promptId });
return { removed: promptId === 'prompt-1' };
},
respondToSessionPermission(
sessionId: string,
requestId: string,
response: unknown,
context?: unknown,
) {
if (!live.has(sessionId)) throw new SessionNotFoundError(sessionId);
permissionCalls.push({ sessionId, requestId, response, context });
return true;
},
respondToPermission() {
return false;
},
subscribeEvents(sessionId: string, options?: unknown) {
if (!live.has(sessionId)) throw new SessionNotFoundError(sessionId);
eventsCalls.push({ sessionId, options });
return (async function* () {})();
},
isChannelLive() {
return options.channelLive ?? false;
},
knownClientIds() {
return new Set<string>();
},
async shutdown() {},
killAllSync() {},
async preheat() {},
};
return bridge as unknown as FakeBridge;
}
function makeRuntime(input: {
workspaceId: string;
workspaceCwd: string;
primary: boolean;
trusted: boolean;
bridge: AcpSessionBridge;
}): WorkspaceRuntime {
return {
...input,
env: { mode: 'parent-process', overlayKeys: [] },
workspaceService: {} as DaemonWorkspaceService,
routeFileSystemFactory: {
forRequest: vi.fn(() => ({})),
} as unknown as WorkspaceFileSystemFactory,
clientMcpSenderRegistry: new ClientMcpSenderRegistry(),
};
}
function makeDaemonLog(): DaemonLogger {
return {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
raw: vi.fn(),
getLogPath: () => '',
getDaemonId: () => 'test-daemon',
flush: vi.fn(async () => {}),
};
}
function makeHarness(opts?: {
secondaryTrusted?: boolean;
secondaryChannelLive?: boolean;
daemonLog?: DaemonLogger;
secondarySummaries?: BridgeSessionSummary[];
}) {
const primaryBridge = makeBridge(
PRIMARY_CWD,
[makeSummary('primary-session', PRIMARY_CWD)],
{ channelLive: true },
);
const secondaryBridge = makeBridge(
SECONDARY_CWD,
opts?.secondarySummaries ?? [
makeSummary('secondary-session', SECONDARY_CWD),
],
{ channelLive: opts?.secondaryChannelLive ?? true },
);
const registry = createWorkspaceRegistry([
makeRuntime({
workspaceId: 'primary-id',
workspaceCwd: PRIMARY_CWD,
primary: true,
trusted: true,
bridge: primaryBridge,
}),
makeRuntime({
workspaceId: 'secondary-id',
workspaceCwd: SECONDARY_CWD,
primary: false,
trusted: opts?.secondaryTrusted ?? true,
bridge: secondaryBridge,
}),
]);
const app = createServeApp(
{ ...baseOpts, workspace: PRIMARY_CWD },
undefined,
{
workspaceRegistry: registry,
...(opts?.daemonLog ? { daemonLog: opts.daemonLog } : {}),
},
);
return { app, registry, primaryBridge, secondaryBridge };
}
function host() {
return `127.0.0.1:${baseOpts.port}`;
}
describe('multi-workspace session dispatch', () => {
it('advertises workspaces and multi_workspace_sessions only when multiple runtimes are registered', async () => {
const { app } = makeHarness();
const res = await request(app).get('/capabilities').set('Host', host());
expect(res.status).toBe(200);
expect(res.body.workspaceCwd).toBe(PRIMARY_CWD);
expect(res.body.features).toContain('multi_workspace_sessions');
expect(res.body.workspaces).toEqual([
{ id: 'primary-id', cwd: PRIMARY_CWD, primary: true, trusted: true },
{
id: 'secondary-id',
cwd: SECONDARY_CWD,
primary: false,
trusted: true,
},
]);
expect(res.body.limits.maxSessionsPerWorkspace).toBe(20);
expect(res.body.limits.maxTotalSessions).toBeNull();
});
it('aggregates daemon status session count and exposes workspace metadata', async () => {
const { app } = makeHarness();
const res = await request(app).get('/daemon/status').set('Host', host());
expect(res.status).toBe(200);
expect(res.body.daemon.workspaceCwd).toBe(PRIMARY_CWD);
expect(res.body.workspaces).toEqual([
{ id: 'primary-id', cwd: PRIMARY_CWD, primary: true, trusted: true },
{
id: 'secondary-id',
cwd: SECONDARY_CWD,
primary: false,
trusted: true,
},
]);
expect(res.body.runtime.sessions.active).toBe(2);
expect(res.body.runtime.channel.live).toBe(true);
const full = await request(app)
.get('/daemon/status?detail=full')
.set('Host', host());
expect(full.status).toBe(200);
expect(
full.body.full.sessions
.map((session: { sessionId: string }) => session.sessionId)
.sort(),
).toEqual(['primary-session', 'secondary-session']);
});
it('rolls up secondary runtime channel issues in daemon status', async () => {
const { app } = makeHarness({ secondaryChannelLive: false });
const res = await request(app).get('/daemon/status').set('Host', host());
expect(res.status).toBe(200);
expect(res.body.status).toBe('error');
expect(res.body.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: 'acp_channel_down',
severity: 'error',
}),
]),
);
});
it('creates a session on the runtime matching the explicit cwd', async () => {
const { app, primaryBridge, secondaryBridge } = makeHarness();
const res = await request(app)
.post('/session')
.set('Host', host())
.send({ cwd: SECONDARY_CWD });
expect(res.status).toBe(200);
expect(primaryBridge.spawnCalls).toEqual([]);
expect(secondaryBridge.spawnCalls).toHaveLength(1);
expect(secondaryBridge.spawnCalls[0]).toMatchObject({
workspaceCwd: SECONDARY_CWD,
});
expect(res.body.workspaceCwd).toBe(SECONDARY_CWD);
});
it('rejects unknown and untrusted workspace session creation before touching a bridge', async () => {
const unknown = makeHarness();
const unknownRes = await request(unknown.app)
.post('/session')
.set('Host', host())
.send({ cwd: UNKNOWN_CWD });
expect(unknownRes.status).toBe(400);
expect(unknownRes.body.code).toBe('workspace_mismatch');
expect(unknownRes.body.workspaceCount).toBe(2);
expect(unknown.primaryBridge.spawnCalls).toEqual([]);
expect(unknown.secondaryBridge.spawnCalls).toEqual([]);
const daemonLog = makeDaemonLog();
const untrusted = makeHarness({ secondaryTrusted: false, daemonLog });
const untrustedRes = await request(untrusted.app)
.post('/session')
.set('Host', host())
.send({ cwd: SECONDARY_CWD });
expect(untrustedRes.status).toBe(403);
expect(untrustedRes.body.code).toBe('untrusted_workspace');
expect(untrusted.secondaryBridge.spawnCalls).toEqual([]);
expect(daemonLog.warn).toHaveBeenCalledWith(
'session routing failed',
expect.objectContaining({
route: 'POST /session',
resolutionKind: 'untrusted_workspace',
workspaceCwd: SECONDARY_CWD,
}),
);
});
it('revalidates runtime trust before dispatching live secondary session routes', async () => {
const { app, secondaryBridge } = makeHarness({ secondaryTrusted: false });
const res = await request(app)
.get('/session/secondary-session/status')
.set('Host', host());
expect(res.status).toBe(403);
expect(res.body.code).toBe('untrusted_workspace');
expect(res.body.workspaceCwd).toBe(SECONDARY_CWD);
expect(secondaryBridge.promptCalls).toEqual([]);
});
it('dispatches live session routes by owner runtime', async () => {
const { app, primaryBridge, secondaryBridge } = makeHarness();
await request(app)
.post('/session/secondary-session/prompt')
.set('Host', host())
.set('X-Qwen-Client-Id', 'client-2')
.send({ prompt: [{ type: 'text', text: 'hello' }] })
.expect(202);
expect(primaryBridge.promptCalls).toEqual([]);
expect(secondaryBridge.promptCalls).toMatchObject([
{ sessionId: 'secondary-session', context: { clientId: 'client-2' } },
]);
const status = await request(app)
.get('/session/secondary-session/status')
.set('Host', host())
.expect(200);
expect(status.body.workspaceCwd).toBe(SECONDARY_CWD);
await request(app)
.post('/session/secondary-session/cancel')
.set('Host', host())
.send({})
.expect(204);
await request(app)
.post('/session/secondary-session/heartbeat')
.set('Host', host())
.send({})
.expect(200);
await request(app)
.post('/session/secondary-session/detach')
.set('Host', host())
.send({})
.expect(204);
expect(secondaryBridge.cancelCalls).toEqual(['secondary-session']);
expect(secondaryBridge.heartbeatCalls).toEqual(['secondary-session']);
expect(secondaryBridge.detachCalls).toEqual(['secondary-session']);
});
it('dispatches secondary events, permissions, pending prompts, and close', async () => {
const { app, primaryBridge, secondaryBridge } = makeHarness();
await request(app)
.get('/session/secondary-session/events?snapshot=1&maxQueued=16')
.set('Host', host())
.expect(200);
expect(primaryBridge.eventsCalls).toEqual([]);
expect(secondaryBridge.eventsCalls).toEqual([
expect.objectContaining({ sessionId: 'secondary-session' }),
]);
await request(app)
.post('/session/secondary-session/permission/perm-1')
.set('Host', host())
.set('X-Qwen-Client-Id', 'client-2')
.send({ outcome: { outcome: 'cancelled' } })
.expect(200);
expect(primaryBridge.permissionCalls).toEqual([]);
expect(secondaryBridge.permissionCalls).toEqual([
expect.objectContaining({
sessionId: 'secondary-session',
requestId: 'perm-1',
}),
]);
const pending = await request(app)
.get('/session/secondary-session/pending-prompts')
.set('Host', host())
.set('X-Qwen-Client-Id', 'client-2')
.expect(200);
expect(pending.body.pendingPrompts).toEqual([
expect.objectContaining({ promptId: 'prompt-1' }),
]);
await request(app)
.delete('/session/secondary-session/pending-prompts/prompt-1')
.set('Host', host())
.set('X-Qwen-Client-Id', 'client-2')
.expect(200);
expect(primaryBridge.pendingPromptCalls).toEqual([]);
expect(primaryBridge.removePendingPromptCalls).toEqual([]);
expect(secondaryBridge.pendingPromptCalls).toEqual(['secondary-session']);
expect(secondaryBridge.removePendingPromptCalls).toEqual([
{ sessionId: 'secondary-session', promptId: 'prompt-1' },
]);
await request(app)
.delete('/session/secondary-session')
.set('Host', host())
.set('X-Qwen-Client-Id', 'client-2')
.expect(204);
expect(primaryBridge.closeCalls).toEqual([]);
expect(secondaryBridge.closeCalls).toEqual(['secondary-session']);
});
it('returns session_not_found instead of falling back to primary on live owner miss', async () => {
const { app } = makeHarness();
const res = await request(app)
.post('/session/missing-session/prompt')
.set('Host', host())
.send({ prompt: [{ type: 'text', text: 'hello' }] });
expect(res.status).toBe(404);
expect(res.body.code).toBe('session_not_found');
});
it('logs live session owner misses for session, SSE, and permission routes', async () => {
const daemonLog = makeDaemonLog();
const { app } = makeHarness({ daemonLog });
await request(app)
.post('/session/missing-session/prompt')
.set('Host', host())
.send({ prompt: [{ type: 'text', text: 'hello' }] })
.expect(404);
await request(app)
.get('/session/missing-session/events')
.set('Host', host())
.expect(404);
await request(app)
.post('/session/missing-session/permission/perm-1')
.set('Host', host())
.send({ outcome: { outcome: 'cancelled' } })
.expect(404);
expect(daemonLog.warn).toHaveBeenCalledWith(
'session routing failed',
expect.objectContaining({
route: 'POST /session/:id/prompt',
resolutionKind: 'not_found',
sessionId: 'missing-session',
}),
);
expect(daemonLog.warn).toHaveBeenCalledWith(
'session routing failed',
expect.objectContaining({
route: 'GET /session/:id/events',
resolutionKind: 'not_found',
sessionId: 'missing-session',
}),
);
expect(daemonLog.warn).toHaveBeenCalledWith(
'session routing failed',
expect.objectContaining({
route: 'POST /session/:id/permission/:requestId',
resolutionKind: 'not_found',
sessionId: 'missing-session',
requestId: 'perm-1',
}),
);
});
it('keeps persisted load and resume primary-only before touching a bridge', async () => {
const { app, primaryBridge, secondaryBridge } = makeHarness();
for (const action of ['load', 'resume'] as const) {
const res = await request(app)
.post(`/session/secondary-session/${action}`)
.set('Host', host())
.send({ cwd: SECONDARY_CWD });
expect(res.status).toBe(400);
expect(res.body.code).toBe('secondary_workspace_load_not_supported');
expect(res.body.workspaceCwd).toBe(SECONDARY_CWD);
}
expect(primaryBridge.restoreCalls).toEqual([]);
expect(secondaryBridge.restoreCalls).toEqual([]);
});
it('returns a clear Phase 2a error for non-primary sessions on primary-only routes', async () => {
const { app, primaryBridge, secondaryBridge } = makeHarness();
const res = await request(app)
.post('/session/secondary-session/branch')
.set('Host', host())
.send({ name: 'next' });
expect(res.status).toBe(400);
expect(res.body.code).toBe('non_primary_session_route_not_supported');
expect(res.body.workspaceCwd).toBe(SECONDARY_CWD);
expect(primaryBridge.restoreCalls).toEqual([]);
expect(secondaryBridge.restoreCalls).toEqual([]);
});
it('lists non-primary workspace sessions live-only by workspace id', async () => {
const { app } = makeHarness();
const res = await request(app)
.get('/workspace/secondary-id/sessions')
.set('Host', host());
expect(res.status).toBe(200);
expect(res.body.sessions).toEqual([
expect.objectContaining({
sessionId: 'secondary-session',
workspaceCwd: SECONDARY_CWD,
}),
]);
const archived = await request(app)
.get('/workspace/secondary-id/sessions?archiveState=archived')
.set('Host', host());
expect(archived.status).toBe(400);
expect(archived.body.code).toBe('non_primary_live_sessions_only');
const unknown = await request(app)
.get(`/workspace/${encodeURIComponent(UNKNOWN_CWD)}/sessions`)
.set('Host', host());
expect(unknown.status).toBe(400);
expect(unknown.body.code).toBe('workspace_mismatch');
expect(unknown.body.workspaceCount).toBe(2);
});
it('pages live non-primary workspace sessions with a stable cursor', async () => {
const { app } = makeHarness({
secondarySummaries: [
makeSummary('secondary-b', SECONDARY_CWD, {
updatedAt: '2026-07-08T00:03:00.000Z',
}),
makeSummary('secondary-a', SECONDARY_CWD, {
updatedAt: '2026-07-08T00:03:00.000Z',
}),
makeSummary('secondary-c', SECONDARY_CWD, {
updatedAt: '2026-07-08T00:02:00.000Z',
}),
],
});
const first = await request(app)
.get('/workspace/secondary-id/sessions?size=2')
.set('Host', host())
.expect(200);
expect(
first.body.sessions.map(
(session: { sessionId: string }) => session.sessionId,
),
).toEqual(['secondary-a', 'secondary-b']);
expect(first.body.nextCursor).toEqual(expect.any(String));
const second = await request(app)
.get(
`/workspace/secondary-id/sessions?size=2&cursor=${encodeURIComponent(
first.body.nextCursor as string,
)}`,
)
.set('Host', host())
.expect(200);
expect(
second.body.sessions.map(
(session: { sessionId: string }) => session.sessionId,
),
).toEqual(['secondary-c']);
expect(second.body.nextCursor).toBeUndefined();
});
});

View file

@ -8,19 +8,26 @@ import type { Application } from 'express';
import type { AcpSessionBridge } from '../acp-session-bridge.js';
import { getServeProtocolVersions } from '../capabilities.js';
import type { getAdvertisedServeFeatures } from '../capabilities.js';
import { advertisedMaxPendingPromptsPerSession } from '../server/serve-features.js';
import {
advertisedMaxPendingPromptsPerSession,
advertisedMaxSessions,
} from '../server/serve-features.js';
import {
CAPABILITIES_SCHEMA_VERSION,
type CapabilitiesEnvelope,
type ServeOptions,
} from '../types.js';
import type { WorkspaceRegistry } from '../workspace-registry.js';
interface RegisterCapabilitiesRoutesDeps {
qwenCodeVersion?: string;
mode: ServeOptions['mode'];
currentServeFeatures: () => ReturnType<typeof getAdvertisedServeFeatures>;
boundWorkspace: string;
workspaceRegistry: WorkspaceRegistry;
permissionPolicy: AcpSessionBridge['permissionPolicy'];
maxSessionsPerWorkspace: ServeOptions['maxSessions'];
maxTotalSessions: ServeOptions['maxTotalSessions'];
maxPendingPromptsPerSession: ServeOptions['maxPendingPromptsPerSession'];
languageCodes: string[];
}
@ -30,6 +37,8 @@ export function registerCapabilitiesRoutes(
deps: RegisterCapabilitiesRoutesDeps,
): void {
app.get('/capabilities', (_req, res) => {
const runtimes = deps.workspaceRegistry.list();
const multiWorkspace = runtimes.length > 1;
const envelope: CapabilitiesEnvelope = {
v: CAPABILITIES_SCHEMA_VERSION,
protocolVersions: getServeProtocolVersions(),
@ -39,8 +48,8 @@ export function registerCapabilitiesRoutes(
mode: deps.mode,
features: deps.currentServeFeatures(),
modelServices: [],
// Surface the bound workspace so clients can detect mismatch pre-flight
// and omit `cwd` on `POST /session`.
// Surface the primary workspace so clients can omit `cwd` on
// `POST /session`; multi-workspace clients use `workspaces[]`.
workspaceCwd: deps.boundWorkspace,
// Advertise supported transport families so SDK clients can
// auto-negotiate the best available transport via negotiateTransport().
@ -51,7 +60,30 @@ export function registerCapabilitiesRoutes(
maxPendingPromptsPerSession: advertisedMaxPendingPromptsPerSession(
deps.maxPendingPromptsPerSession,
),
...(multiWorkspace
? {
maxSessionsPerWorkspace: advertisedMaxSessions(
deps.maxSessionsPerWorkspace,
),
maxTotalSessions:
deps.maxTotalSessions === undefined ||
deps.maxTotalSessions === 0 ||
deps.maxTotalSessions === Number.POSITIVE_INFINITY
? null
: deps.maxTotalSessions,
}
: {}),
},
...(multiWorkspace
? {
workspaces: runtimes.map((runtime) => ({
id: runtime.workspaceId,
cwd: runtime.workspaceCwd,
primary: runtime.primary,
trusted: runtime.trusted,
})),
}
: {}),
supportedLanguages: deps.languageCodes,
};
res.status(200).json(envelope);

View file

@ -26,11 +26,13 @@ import type { ChannelWorkerSnapshot } from '../channel-worker-supervisor.js';
import type { DaemonWorkspaceService } from '../workspace-service/index.js';
import { getServeProtocolVersions } from '../capabilities.js';
import type { TotalSessionAdmissionSnapshot } from '../total-session-admission.js';
import type { WorkspaceRegistry } from '../workspace-registry.js';
interface RegisterDaemonStatusRoutesDeps {
opts: ServeOptions;
boundWorkspace: string;
bridge: AcpSessionBridge;
workspaceRegistry: WorkspaceRegistry;
workspace: DaemonWorkspaceService;
daemonLog?: DaemonLogger;
startup?: DaemonStartupSnapshot;
@ -69,6 +71,7 @@ export function registerDaemonStatusRoutes(
opts: deps.opts,
boundWorkspace: deps.boundWorkspace,
bridge: deps.bridge,
workspaceRegistry: deps.workspaceRegistry,
workspace: deps.workspace,
daemonLog: deps.daemonLog,
startup: deps.startup,

View file

@ -6,11 +6,14 @@
import type { Application, RequestHandler, Response } from 'express';
import type { AcpSessionBridge } from '../acp-session-bridge.js';
import type { DaemonLogger } from '../daemon-logger.js';
import {
detectFromLoopback,
parseClientIdHeader,
parsePermissionVoteBody,
} from '../server/request-helpers.js';
import type { WorkspaceRegistry } from '../workspace-registry.js';
import { requireSessionRuntime } from './session-runtime.js';
type SendPermissionVoteError = (
res: Response,
@ -20,6 +23,8 @@ type SendPermissionVoteError = (
interface RegisterPermissionRoutesDeps {
bridge: AcpSessionBridge;
workspaceRegistry: WorkspaceRegistry;
daemonLog?: DaemonLogger;
mutate: (opts?: { strict?: boolean }) => RequestHandler;
sendPermissionVoteError: SendPermissionVoteError;
}
@ -28,7 +33,13 @@ export function registerPermissionRoutes(
app: Application,
deps: RegisterPermissionRoutesDeps,
): void {
const { bridge, mutate, sendPermissionVoteError } = deps;
const {
bridge,
workspaceRegistry,
daemonLog,
mutate,
sendPermissionVoteError,
} = deps;
app.post('/session/:id/permission/:requestId', mutate(), (req, res) => {
const sessionId = req.params['id'];
@ -46,7 +57,16 @@ export function registerPermissionRoutes(
};
let accepted: boolean;
try {
accepted = bridge.respondToSessionPermission(
const runtime = requireSessionRuntime({
sessionId,
route: 'POST /session/:id/permission/:requestId',
res,
workspaceRegistry,
daemonLog,
details: { requestId },
});
if (!runtime) return;
accepted = runtime.bridge.respondToSessionPermission(
sessionId,
requestId,
response,

View file

@ -0,0 +1,91 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { Response } from 'express';
import type { DaemonLogger } from '../daemon-logger.js';
import type {
WorkspaceRegistry,
WorkspaceRuntime,
} from '../workspace-registry.js';
export function requireSessionRuntime(opts: {
sessionId: string;
route: string;
res: Response;
workspaceRegistry: WorkspaceRegistry;
daemonLog?: DaemonLogger;
details?: Record<string, unknown>;
}): WorkspaceRuntime | undefined {
const {
sessionId,
route,
res,
workspaceRegistry,
daemonLog,
details = {},
} = opts;
if (workspaceRegistry.list().length === 1) {
return workspaceRegistry.primary;
}
const resolution = workspaceRegistry.resolveLiveSessionOwner(sessionId);
if (resolution.kind === 'found') {
const runtime = resolution.runtime;
if (!runtime.primary && !runtime.trusted) {
daemonLog?.warn('session routing failed', {
route,
resolutionKind: 'untrusted_workspace',
sessionId,
workspaceId: runtime.workspaceId,
workspaceCwd: runtime.workspaceCwd,
...details,
});
res.status(403).json({
error: `Workspace "${runtime.workspaceCwd}" is not trusted.`,
code: 'untrusted_workspace',
sessionId,
workspaceId: runtime.workspaceId,
workspaceCwd: runtime.workspaceCwd,
});
return undefined;
}
return runtime;
}
if (resolution.kind === 'not_found') {
daemonLog?.warn('session routing failed', {
route,
resolutionKind: 'not_found',
sessionId,
...details,
});
res.status(404).json({
error: `No session with id "${sessionId}"`,
code: 'session_not_found',
sessionId,
});
return undefined;
}
const workspaceIds = resolution.runtimes.map(
(runtime) => runtime.workspaceId,
);
daemonLog?.warn('session routing failed', {
route,
resolutionKind: 'ambiguous',
sessionId,
workspaceIds,
...details,
});
res.status(500).json({
error: `Session owner is ambiguous for "${sessionId}"`,
code: 'ambiguous_session_owner',
sessionId,
route,
workspaceIds,
});
return undefined;
}

View file

@ -44,6 +44,7 @@ import {
} from '../server/request-helpers.js';
import {
InvalidCursorError,
listLiveWorkspaceSessionsForResponse,
listWorkspaceSessionsForResponse,
parseSessionPageSizeQuery,
} from '../server/session-list.js';
@ -61,10 +62,16 @@ import {
sessionExportFormatValues,
} from '../server/session-export.js';
import { createSessionOrganizationService } from '../session-organization-helpers.js';
import { requireSessionRuntime } from './session-runtime.js';
import type {
WorkspaceRegistry,
WorkspaceRuntime,
} from '../workspace-registry.js';
interface RegisterSessionRoutesDeps {
boundWorkspace: string;
bridge: AcpSessionBridge;
workspaceRegistry: WorkspaceRegistry;
archiveCoordinator: SessionArchiveCoordinator;
mutate: (opts?: { strict?: boolean }) => RequestHandler;
sendBridgeError: SendBridgeError;
@ -116,6 +123,7 @@ export function registerSessionRoutes(
const {
boundWorkspace,
bridge,
workspaceRegistry,
archiveCoordinator,
mutate,
sendBridgeError,
@ -125,6 +133,205 @@ export function registerSessionRoutes(
} = deps;
const LANGUAGE_CODES = deps.languageCodes;
const logSessionRoutingFailure = (
route: string,
resolutionKind: string,
details: Record<string, unknown> = {},
): void => {
daemonLog?.warn('session routing failed', {
route,
resolutionKind,
...details,
});
};
const sendWorkspaceMismatch = (
res: Response,
requestedWorkspace: string,
): void => {
const runtimes = workspaceRegistry.list();
if (runtimes.length > 1) {
res.status(400).json({
error: `Workspace mismatch: daemon is bound to ${runtimes.length} workspaces; none matched the requested workspace.`,
code: 'workspace_mismatch',
boundWorkspace,
workspaceCount: runtimes.length,
requestedWorkspace,
});
return;
}
res.status(400).json({
error: `Workspace mismatch: daemon is bound to "${boundWorkspace}"`,
code: 'workspace_mismatch',
boundWorkspace,
requestedWorkspace,
});
};
const resolveRuntimeForSessionCreation = (
body: Record<string, unknown>,
res: Response,
): { runtime: WorkspaceRuntime; workspaceCwd: string } | undefined => {
const cwd = parseOptionalWorkspaceCwd(body, boundWorkspace, res);
if (cwd === undefined) return undefined;
let key: string;
try {
key = canonicalizeWorkspace(cwd);
} catch (err) {
if (workspaceRegistry.list().length > 1 && 'cwd' in body) {
logSessionRoutingFailure('POST /session', 'workspace_mismatch', {
requestedWorkspace: cwd,
});
sendWorkspaceMismatch(res, cwd);
return undefined;
}
sendBridgeError(res, err, { route: 'POST /session' });
return undefined;
}
if (workspaceRegistry.list().length === 1) {
return {
runtime: workspaceRegistry.primary,
workspaceCwd:
'cwd' in body ? key : workspaceRegistry.primary.workspaceCwd,
};
}
const runtime = workspaceRegistry.resolveWorkspaceCwd(
'cwd' in body ? key : undefined,
);
if (!runtime) {
logSessionRoutingFailure('POST /session', 'workspace_mismatch', {
requestedWorkspace: key,
});
sendWorkspaceMismatch(res, key);
return undefined;
}
if (!runtime.primary && !runtime.trusted) {
logSessionRoutingFailure('POST /session', 'untrusted_workspace', {
workspaceId: runtime.workspaceId,
workspaceCwd: runtime.workspaceCwd,
});
res.status(403).json({
error: `Workspace "${runtime.workspaceCwd}" is not trusted.`,
code: 'untrusted_workspace',
workspaceCwd: runtime.workspaceCwd,
});
return undefined;
}
return { runtime, workspaceCwd: runtime.workspaceCwd };
};
const resolveRuntimeFromWorkspaceParam = (
req: Request,
res: Response,
): WorkspaceRuntime | null => {
const workspaceParam = req.params['id'] ?? '';
const byId = workspaceRegistry.getByWorkspaceId(workspaceParam);
if (byId) return byId;
if (!path.isAbsolute(workspaceParam)) {
res.status(400).json({
error: '`:id` must decode to a workspace id or absolute path',
});
return null;
}
let key: string;
try {
key = canonicalizeWorkspace(workspaceParam);
} catch {
sendWorkspaceMismatch(res, workspaceParam);
return null;
}
const runtime = workspaceRegistry.getByWorkspaceCwd(key);
if (!runtime) {
sendWorkspaceMismatch(res, key);
return null;
}
return runtime;
};
const resolvePrimaryOnlyWorkspaceCwd = (
cwd: string,
res: Response,
route: string,
): string | undefined => {
let key: string;
try {
key = canonicalizeWorkspace(cwd);
} catch (err) {
sendBridgeError(res, err, { route });
return undefined;
}
if (key !== boundWorkspace) {
const runtime = workspaceRegistry.getByWorkspaceCwd(key);
if (runtime && !runtime.primary) {
res.status(400).json({
error:
'Non-primary persisted session load/resume is not supported in Phase 2a.',
code: 'secondary_workspace_load_not_supported',
workspaceCwd: runtime.workspaceCwd,
workspaceId: runtime.workspaceId,
route,
});
return undefined;
}
sendWorkspaceMismatch(res, key);
return undefined;
}
return key;
};
const resolveLiveSessionRuntime = (
sessionId: string,
res: Response,
route: string,
): WorkspaceRuntime | undefined =>
requireSessionRuntime({
sessionId,
route,
res,
workspaceRegistry,
daemonLog,
});
const sendNonPrimarySessionRouteUnsupported = (
res: Response,
route: string,
sessionId: string,
runtime: WorkspaceRuntime,
): void => {
res.status(400).json({
error: `Route "${route}" is primary-only for non-primary workspace sessions in Phase 2a.`,
code: 'non_primary_session_route_not_supported',
sessionId,
workspaceId: runtime.workspaceId,
workspaceCwd: runtime.workspaceCwd,
route,
});
};
const withOwnerMutableSession =
(
route: string,
handler: (
req: Request,
res: Response,
sessionId: string,
runtime: WorkspaceRuntime,
) => Promise<void> | void,
): RequestHandler =>
async (req, res) => {
const sessionId = requireSessionId(req, res);
if (sessionId === null) return;
const runtime = resolveLiveSessionRuntime(sessionId, res, route);
if (!runtime) return;
try {
await archiveCoordinator.runSharedMany([sessionId], async () => {
await handler(req, res, sessionId, runtime);
});
} catch (err) {
sendBridgeError(res, err, { route, sessionId });
}
};
const parseSessionIdsBody = (
req: Request,
res: Response,
@ -190,6 +397,12 @@ export function registerSessionRoutes(
async (req, res) => {
const sessionId = requireSessionId(req, res);
if (sessionId === null) return;
const runtime = resolveLiveSessionRuntime(sessionId, res, route);
if (!runtime) return;
if (!runtime.primary) {
sendNonPrimarySessionRouteUnsupported(res, route, sessionId, runtime);
return;
}
try {
await archiveCoordinator.runSharedMany([sessionId], async () => {
await handler(req, res, sessionId);
@ -201,8 +414,9 @@ export function registerSessionRoutes(
app.post('/session', mutate(), async (req, res) => {
const body = safeBody(req);
const cwd = parseOptionalWorkspaceCwd(body, boundWorkspace, res);
if (cwd === undefined) return;
const resolvedRuntime = resolveRuntimeForSessionCreation(body, res);
if (resolvedRuntime === undefined) return;
const { runtime, workspaceCwd } = resolvedRuntime;
const modelServiceId =
typeof body['modelServiceId'] === 'string'
? (body['modelServiceId'] as string)
@ -224,8 +438,8 @@ export function registerSessionRoutes(
const clientId = parseClientIdHeader(req, res);
if (clientId === null) return;
try {
const session = await bridge.spawnOrAttach({
workspaceCwd: cwd,
const session = await runtime.bridge.spawnOrAttach({
workspaceCwd,
modelServiceId,
...(clientId !== undefined ? { clientId } : {}),
...(sessionScope !== undefined ? { sessionScope } : {}),
@ -275,7 +489,7 @@ export function registerSessionRoutes(
// dispatching, the bridge will see `attachCount > 0` and
// skip the kill. Without the flag, that second client's
// session would die mid-prompt.
bridge
runtime.bridge
.killSession(session.sessionId, { requireZeroAttaches: true })
.catch(() => {
// Best-effort cleanup; channel.exited will eventually reap.
@ -290,9 +504,11 @@ export function registerSessionRoutes(
// subscribers). Without this, both-coalesced-callers-
// disconnect leaves an orphan agent child no client knows
// the id of.
bridge.detachClient(session.sessionId, session.clientId).catch(() => {
// Best-effort cleanup; channel.exited will eventually reap.
});
runtime.bridge
.detachClient(session.sessionId, session.clientId)
.catch(() => {
// Best-effort cleanup; channel.exited will eventually reap.
});
}
return;
}
@ -309,23 +525,29 @@ export function registerSessionRoutes(
const body = safeBody(req);
const cwd = parseOptionalWorkspaceCwd(body, boundWorkspace, res);
if (cwd === undefined) return;
const primaryCwd = resolvePrimaryOnlyWorkspaceCwd(
cwd,
res,
`POST /session/:id/${action}`,
);
if (primaryCwd === undefined) return;
const clientId = parseClientIdHeader(req, res);
if (clientId === null) return;
try {
const session = await archiveCoordinator.runSharedMany(
[sessionId],
async () => {
await assertSessionLoadable(cwd, sessionId);
await assertSessionLoadable(primaryCwd, sessionId);
return action === 'load'
? await bridge.loadSession({
sessionId,
workspaceCwd: cwd,
workspaceCwd: primaryCwd,
historyReplay: 'response',
...(clientId !== undefined ? { clientId } : {}),
})
: await bridge.resumeSession({
sessionId,
workspaceCwd: cwd,
workspaceCwd: primaryCwd,
...(clientId !== undefined ? { clientId } : {}),
});
},
@ -472,8 +694,14 @@ export function registerSessionRoutes(
app.get('/session/:id/status', (req, res) => {
const sessionId = requireSessionId(req, res);
if (sessionId === null) return;
const runtime = resolveLiveSessionRuntime(
sessionId,
res,
'GET /session/:id/status',
);
if (!runtime) return;
try {
res.status(200).json(bridge.getSessionSummary(sessionId));
res.status(200).json(runtime.bridge.getSessionSummary(sessionId));
} catch (err) {
sendBridgeError(res, err, {
route: 'GET /session/:id/status',
@ -778,9 +1006,10 @@ export function registerSessionRoutes(
app.post(
'/session/:id/prompt',
mutate(),
withMutableSession(
withOwnerMutableSession(
'POST /session/:id/prompt',
async (req, res, sessionId) => {
async (req, res, sessionId, runtime) => {
const ownerBridge = runtime.bridge;
const body = safeBody(req);
const prompt = body['prompt'];
if (!Array.isArray(prompt) || prompt.length === 0) {
@ -825,7 +1054,7 @@ export function registerSessionRoutes(
const forwardedBody = { ...body };
delete forwardedBody['deadlineMs'];
const lastEventId = bridge.getSessionLastEventId(sessionId);
const lastEventId = ownerBridge.getSessionLastEventId(sessionId);
addDaemonRequestAttribute('qwen-code.prompt_id', promptId);
const abort = new AbortController();
@ -855,7 +1084,7 @@ export function registerSessionRoutes(
let promptPromise: ReturnType<AcpSessionBridge['sendPrompt']>;
try {
promptPromise = bridge.sendPrompt(
promptPromise = ownerBridge.sendPrompt(
sessionId,
{
...forwardedBody,
@ -933,26 +1162,29 @@ export function registerSessionRoutes(
app.post(
'/session/:id/heartbeat',
mutate(),
withMutableSession('POST /session/:id/heartbeat', (req, res, sessionId) => {
const clientId = parseClientIdHeader(req, res);
if (clientId === null) return;
const result = bridge.recordHeartbeat(
sessionId,
clientId !== undefined ? { clientId } : undefined,
);
res.status(200).json(result);
}),
withOwnerMutableSession(
'POST /session/:id/heartbeat',
(req, res, sessionId, runtime) => {
const clientId = parseClientIdHeader(req, res);
if (clientId === null) return;
const result = runtime.bridge.recordHeartbeat(
sessionId,
clientId !== undefined ? { clientId } : undefined,
);
res.status(200).json(result);
},
),
);
app.post(
'/session/:id/detach',
mutate(),
withMutableSession(
withOwnerMutableSession(
'POST /session/:id/detach',
async (req, res, sessionId) => {
async (req, res, sessionId, runtime) => {
const clientId = parseClientIdHeader(req, res);
if (clientId === null) return;
await bridge.detachClient(sessionId, clientId);
await runtime.bridge.detachClient(sessionId, clientId);
res.status(204).end();
},
),
@ -961,13 +1193,13 @@ export function registerSessionRoutes(
app.post(
'/session/:id/cancel',
mutate(),
withMutableSession(
withOwnerMutableSession(
'POST /session/:id/cancel',
async (req, res, sessionId) => {
async (req, res, sessionId, runtime) => {
const body = safeBody(req);
const clientId = parseClientIdHeader(req, res);
if (clientId === null) return;
await bridge.cancelSession(
await runtime.bridge.cancelSession(
sessionId,
{
...(body as object),
@ -987,11 +1219,17 @@ export function registerSessionRoutes(
const sessionId = req.params['id'];
const clientId = parseClientIdHeader(req, res);
if (clientId === null) return;
const runtime = resolveLiveSessionRuntime(
sessionId,
res,
'DELETE /session/:id',
);
if (!runtime) return;
try {
// ACP session/close can fall back to a shared gate because it has
// connection-local promptAbort state; REST close does not.
await archiveCoordinator.runExclusiveMany([sessionId], async () =>
bridge.closeSession(
runtime.bridge.closeSession(
sessionId,
clientId !== undefined ? { clientId } : undefined,
),
@ -1282,25 +1520,9 @@ export function registerSessionRoutes(
// Express decodes URL-encoded path params automatically; clients pass
// the absolute workspace cwd encoded (e.g.
// GET /workspace/%2Fwork%2Fa/sessions).
const workspaceCwd = req.params['id'] ?? '';
if (!path.isAbsolute(workspaceCwd)) {
res
.status(400)
.json({ error: '`:id` must decode to an absolute workspace path' });
return;
}
// Reject cross-workspace queries so orchestrators don't mistake
// "no sessions here" for "workspace is idle".
const key = canonicalizeWorkspace(workspaceCwd);
if (key !== boundWorkspace) {
res.status(400).json({
error: `Workspace mismatch: daemon is bound to "${boundWorkspace}"`,
code: 'workspace_mismatch',
boundWorkspace,
requestedWorkspace: key,
});
return;
}
const runtime = resolveRuntimeFromWorkspaceParam(req, res);
if (runtime === null) return;
const key = runtime.workspaceCwd;
try {
const cursor =
typeof req.query['cursor'] === 'string'
@ -1343,13 +1565,24 @@ export function registerSessionRoutes(
}
archiveState = rawArchiveState;
}
const result = await listWorkspaceSessionsForResponse(bridge, key, {
if (!runtime.primary && (archiveState === 'archived' || view)) {
res.status(400).json({
error:
'Non-primary workspace session listing is live-only in Phase 2a.',
code: 'non_primary_live_sessions_only',
});
return;
}
const options = {
...(cursor !== undefined ? { cursor } : {}),
...(size !== undefined ? { size } : {}),
...(archiveState !== undefined ? { archiveState } : {}),
...(view !== undefined ? { view } : {}),
...(group !== undefined ? { group } : {}),
});
};
const result = runtime.primary
? await listWorkspaceSessionsForResponse(runtime.bridge, key, options)
: listLiveWorkspaceSessionsForResponse(runtime.bridge, key, options);
res.status(200).json({
sessions: result.sessions,
...(result.nextCursor != null ? { nextCursor: result.nextCursor } : {}),
@ -1547,10 +1780,16 @@ export function registerSessionRoutes(
app.get('/session/:id/pending-prompts', (req, res) => {
const sessionId = requireSessionId(req, res);
if (sessionId === null) return;
const runtime = resolveLiveSessionRuntime(
sessionId,
res,
'GET /session/:id/pending-prompts',
);
if (!runtime) return;
const clientId = parseClientIdHeader(req, res);
if (clientId === null) return;
try {
const pendingPrompts = bridge.getPendingPrompts(
const pendingPrompts = runtime.bridge.getPendingPrompts(
sessionId,
clientId !== undefined ? { clientId } : undefined,
);
@ -1566,9 +1805,9 @@ export function registerSessionRoutes(
app.delete(
'/session/:id/pending-prompts/:promptId',
mutate(),
withMutableSession(
withOwnerMutableSession(
'DELETE /session/:id/pending-prompts/:promptId',
(req, res, sessionId) => {
(req, res, sessionId, runtime) => {
const clientId = parseClientIdHeader(req, res);
if (clientId === null) return;
const promptId = req.params['promptId'];
@ -1578,7 +1817,7 @@ export function registerSessionRoutes(
.json({ error: '`promptId` route parameter is required' });
return;
}
const result = bridge.removePendingPrompt(
const result = runtime.bridge.removePendingPrompt(
sessionId,
promptId,
clientId !== undefined ? { clientId } : undefined,

View file

@ -21,6 +21,8 @@ import {
parseLastEventId,
parseMaxQueuedQuery,
} from '../server/request-helpers.js';
import type { WorkspaceRegistry } from '../workspace-registry.js';
import { requireSessionRuntime } from './session-runtime.js';
let activeSseCount = 0;
@ -30,6 +32,7 @@ export function getActiveSseCount(): number {
interface RegisterSseEventsRoutesDeps {
bridge: AcpSessionBridge;
workspaceRegistry: WorkspaceRegistry;
daemonLog?: DaemonLogger;
writerIdleTimeoutMs?: number;
sendBridgeError: SendBridgeError;
@ -76,7 +79,8 @@ export function registerSseEventsRoutes(
app: Application,
deps: RegisterSseEventsRoutesDeps,
): void {
const { bridge, daemonLog, sendBridgeError, writerIdleTimeoutMs } = deps;
const { workspaceRegistry, daemonLog, sendBridgeError, writerIdleTimeoutMs } =
deps;
app.get('/session/:id/events', (req, res) => {
const sessionId = req.params['id'];
@ -91,8 +95,16 @@ export function registerSseEventsRoutes(
let iter: AsyncIterator<BridgeEvent> | undefined;
const abort = new AbortController();
try {
const runtime = requireSessionRuntime({
sessionId,
route: 'GET /session/:id/events',
res,
workspaceRegistry,
daemonLog,
});
if (!runtime) return;
const snapshot = req.query['snapshot'] === '1';
const iterable = bridge.subscribeEvents(sessionId, {
const iterable = runtime.bridge.subscribeEvents(sessionId, {
signal: abort.signal,
lastEventId,
...(maxQueued !== undefined ? { maxQueued } : {}),

View file

@ -412,22 +412,212 @@ describe('runQwenServe telemetry validation', () => {
await expect(run).rejects.toThrow(/Invalid telemetry configuration:/);
});
it('rejects multiple explicit workspace inputs before runtime boot', async () => {
it('accepts multiple explicit workspace inputs and advertises workspaces', async () => {
tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'qws-ws-')));
const primary = path.join(tmpDir, 'primary');
const secondary = path.join(tmpDir, 'secondary');
fs.mkdirSync(primary);
fs.mkdirSync(secondary);
vi.spyOn(qwenCore, 'resolveTelemetrySettings').mockResolvedValue({
enabled: false,
sensitiveSpanAttributeMaxLength: 1024 * 1024,
});
await expect(
runQwenServe({
const handle = await runQwenServe(
{
port: 0,
hostname: '127.0.0.1',
mode: 'http-bridge',
workspace: [primary, secondary],
maxSessions: 1,
}),
).rejects.toThrow(/Multiple --workspace values are not supported yet/);
serveWebShell: false,
},
{
preheatBridge: false,
daemonLogBaseDir: path.join(tmpDir, 'debug'),
},
);
try {
const res = await fetch(`${handle.url}/capabilities`);
expect(res.status).toBe(200);
const body = (await res.json()) as {
workspaceCwd: string;
features: string[];
workspaces: Array<{ cwd: string; primary: boolean }>;
limits: { maxTotalSessions: number | null };
};
expect(body.workspaceCwd).toBe(canonicalizeWorkspace(primary));
expect(body.features).toContain('multi_workspace_sessions');
expect(body.limits.maxTotalSessions).toBe(2);
expect(body.workspaces).toEqual([
expect.objectContaining({
cwd: canonicalizeWorkspace(primary),
primary: true,
}),
expect.objectContaining({
cwd: canonicalizeWorkspace(secondary),
primary: false,
}),
]);
} finally {
await handle.close();
}
});
it('uses the daemon-wide policy and limits when constructing workspace bridges', async () => {
tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'qws-ws-')));
const primary = path.join(tmpDir, 'primary');
const secondary = path.join(tmpDir, 'secondary');
fs.mkdirSync(primary);
fs.mkdirSync(secondary);
const secondaryCwd = canonicalizeWorkspace(secondary);
const primaryBridge = makeRuntimeBridge();
const secondaryBridge = makeRuntimeBridge();
const createBridge = vi
.spyOn(acpBridge, 'createAcpSessionBridge')
.mockReturnValueOnce(
primaryBridge as ReturnType<typeof acpBridge.createAcpSessionBridge>,
)
.mockReturnValueOnce(
secondaryBridge as ReturnType<typeof acpBridge.createAcpSessionBridge>,
);
vi.spyOn(qwenCore, 'resolveTelemetrySettings').mockResolvedValue({
enabled: false,
sensitiveSpanAttributeMaxLength: 1024 * 1024,
});
vi.spyOn(settingsRuntime, 'loadSettings').mockImplementation(
(workspace) => {
const workspaceCwd =
typeof workspace === 'string' ? canonicalizeWorkspace(workspace) : '';
return {
merged:
workspaceCwd === secondaryCwd
? {
policy: {
permissionStrategy: 'consensus',
consensusQuorum: 2,
},
}
: {},
} as unknown as ReturnType<typeof settingsRuntime.loadSettings>;
},
);
vi.spyOn(trustedFoldersRuntime, 'getWorkspaceTrustStatus').mockReturnValue({
effective: { state: 'trusted' },
} as ReturnType<typeof trustedFoldersRuntime.getWorkspaceTrustStatus>);
const handle = await runQwenServe(
{
port: 0,
hostname: '127.0.0.1',
mode: 'http-bridge',
workspace: [primary, secondary],
maxSessions: 1,
eventRingSize: 1234,
compactedReplayMaxBytes: 1024,
serveWebShell: false,
},
{
resolveOnListen: true,
bootSettings: { policy: { permissionStrategy: 'local-only' } },
daemonLogBaseDir: path.join(tmpDir, 'debug'),
},
);
try {
await handle.runtimeReady;
expect(createBridge).toHaveBeenCalledTimes(2);
expect(createBridge.mock.calls[0]?.[0]).toMatchObject({
compactedReplayMaxBytes: 1024,
eventRingSize: 1234,
permissionPolicy: 'local-only',
});
expect(createBridge.mock.calls[1]?.[0]).toMatchObject({
compactedReplayMaxBytes: 1024,
eventRingSize: 1234,
permissionPolicy: 'local-only',
});
expect(createBridge.mock.calls[1]?.[0]).not.toHaveProperty(
'permissionConsensusQuorum',
);
} finally {
await handle.close();
}
});
it('does not validate policy settings for untrusted secondary workspaces', async () => {
tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'qws-ws-')));
const primary = path.join(tmpDir, 'primary');
const secondary = path.join(tmpDir, 'secondary');
fs.mkdirSync(primary);
fs.mkdirSync(secondary);
const secondaryCwd = canonicalizeWorkspace(secondary);
const createBridge = vi
.spyOn(acpBridge, 'createAcpSessionBridge')
.mockReturnValueOnce(
makeRuntimeBridge() as ReturnType<
typeof acpBridge.createAcpSessionBridge
>,
)
.mockReturnValueOnce(
makeRuntimeBridge() as ReturnType<
typeof acpBridge.createAcpSessionBridge
>,
);
vi.spyOn(qwenCore, 'resolveTelemetrySettings').mockResolvedValue({
enabled: false,
sensitiveSpanAttributeMaxLength: 1024 * 1024,
});
vi.spyOn(settingsRuntime, 'loadSettings').mockImplementation(
(workspace) => {
const workspaceCwd =
typeof workspace === 'string' ? canonicalizeWorkspace(workspace) : '';
return {
merged:
workspaceCwd === secondaryCwd
? { policy: { permissionStrategy: 'bogus' } }
: {},
} as unknown as ReturnType<typeof settingsRuntime.loadSettings>;
},
);
vi.spyOn(
trustedFoldersRuntime,
'getWorkspaceTrustStatus',
).mockImplementation(
(_settings, workspace) =>
({
effective: {
state:
canonicalizeWorkspace(workspace) === secondaryCwd
? 'untrusted'
: 'trusted',
},
}) as ReturnType<typeof trustedFoldersRuntime.getWorkspaceTrustStatus>,
);
const handle = await runQwenServe(
{
port: 0,
hostname: '127.0.0.1',
mode: 'http-bridge',
workspace: [primary, secondary],
maxSessions: 1,
serveWebShell: false,
},
{
resolveOnListen: true,
bootSettings: { policy: { permissionStrategy: 'local-only' } },
daemonLogBaseDir: path.join(tmpDir, 'debug'),
},
);
try {
await expect(handle.runtimeReady).resolves.toBeUndefined();
expect(createBridge).toHaveBeenCalledTimes(2);
expect(createBridge.mock.calls[1]?.[0]).toMatchObject({
permissionPolicy: 'local-only',
});
} finally {
await handle.close();
}
});
it('accepts a single workspace array input as the primary workspace', async () => {
@ -1035,6 +1225,34 @@ describe('runQwenServe pre-listen bridge option validation', () => {
expect(stdoutWrites.join('')).not.toContain('qwen serve listening on');
},
);
it('rejects an injected bridge with multiple explicit workspaces before listening', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-bridge-opt-')),
);
const primary = path.join(tmpDir, 'primary');
const secondary = path.join(tmpDir, 'secondary');
fs.mkdirSync(primary);
fs.mkdirSync(secondary);
const stdoutWrites: string[] = [];
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
stdoutWrites.push(String(chunk));
return true;
});
await expect(
runQwenServe(
{
port: 0,
hostname: '127.0.0.1',
mode: 'http-bridge',
workspace: [primary, secondary],
},
{ bridge: makeRuntimeBridge() },
),
).rejects.toThrow(/Injected bridge dependencies/);
expect(stdoutWrites.join('')).not.toContain('qwen serve listening on');
});
});
describe('runQwenServe session reaper timeout validation', () => {
@ -1564,6 +1782,102 @@ describe('runQwenServe runtime startup failures', () => {
}
});
it('updates secondary runtime env metadata in place after workspace reload', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-secondary-env-reload-')),
);
const primary = path.join(tmpDir, 'primary');
const secondary = path.join(tmpDir, 'secondary');
fs.mkdirSync(primary);
fs.mkdirSync(secondary);
vi.spyOn(qwenCore, 'resolveTelemetrySettings').mockResolvedValue({
enabled: false,
sensitiveSpanAttributeMaxLength: 1024 * 1024,
});
vi.spyOn(settingsRuntime, 'loadSettings').mockImplementation(
(...args: Parameters<typeof settingsRuntime.loadSettings>) => {
const workspace = args[0];
const options = args[1];
const isReload =
typeof options === 'object' && options?.skipLoadEnvironment === true;
const isSecondary = workspace === secondary;
return {
merged: {
env: {
[isSecondary
? 'QWEN_TEST_SECONDARY_ENV'
: 'QWEN_TEST_PRIMARY_ENV']: isReload ? 'reloaded' : 'boot',
},
},
} as unknown as ReturnType<typeof settingsRuntime.loadSettings>;
},
);
vi.spyOn(settingsRuntime, 'reloadEnvironment').mockReturnValue({
updatedKeys: ['QWEN_TEST_SECONDARY_ENV'],
removedKeys: [],
});
vi.spyOn(trustedFoldersRuntime, 'getWorkspaceTrustStatus').mockReturnValue({
effective: { state: 'trusted' },
} as ReturnType<typeof trustedFoldersRuntime.getWorkspaceTrustStatus>);
vi.spyOn(acpBridge, 'createAcpSessionBridge')
.mockReturnValueOnce(
makeRuntimeBridge() as ReturnType<
typeof acpBridge.createAcpSessionBridge
>,
)
.mockReturnValueOnce(
makeRuntimeBridge() as ReturnType<
typeof acpBridge.createAcpSessionBridge
>,
);
let workspaceRegistry:
| import('./workspace-registry.js').WorkspaceRegistry
| undefined;
vi.spyOn(serverModule, 'createServeApp').mockImplementation(
(_opts, _getPort, deps) => {
workspaceRegistry = deps?.workspaceRegistry;
return express();
},
);
const handle = await runQwenServe(
{
port: 0,
hostname: '127.0.0.1',
mode: 'http-bridge',
workspace: [primary, secondary],
maxSessions: 1,
serveWebShell: false,
},
{ resolveOnListen: true },
);
try {
await handle.runtimeReady;
const secondaryRuntime = workspaceRegistry
?.list()
.find((runtime) => runtime.workspaceCwd === secondary);
expect(secondaryRuntime).toBeDefined();
const env = secondaryRuntime!.env;
const overlayKeys = env.overlayKeys;
const envFilePaths = env.envFilePaths;
const envFileReadFailures = env.envFileReadFailures;
expect(env.effectiveEnv?.['QWEN_TEST_SECONDARY_ENV']).toBe('boot');
await secondaryRuntime!.workspaceService.reload({
route: 'POST /workspace/reload',
workspaceCwd: secondary,
});
expect(env.overlayKeys).toBe(overlayKeys);
expect(env.envFilePaths).toBe(envFilePaths);
expect(env.envFileReadFailures).toBe(envFileReadFailures);
expect(env.effectiveEnv?.['QWEN_TEST_SECONDARY_ENV']).toBe('reloaded');
} finally {
await handle.close();
}
});
it('filters secondary workspace roots before constructing the bridge filesystem', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-runtime-roots-')),
@ -1967,6 +2281,80 @@ describe('runQwenServe runtime startup failures', () => {
}
});
it('returns bootstrap 503 for multi-workspace capabilities until runtime routes mount', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-runtime-starting-caps-')),
);
const primary = path.join(tmpDir, 'primary');
const secondary = path.join(tmpDir, 'secondary');
fs.mkdirSync(primary);
fs.mkdirSync(secondary);
let resolveTelemetry:
| ((settings: qwenCore.ResolvedTelemetrySettings) => void)
| undefined;
const telemetryPromise = new Promise<qwenCore.ResolvedTelemetrySettings>(
(resolve) => {
resolveTelemetry = resolve;
},
);
vi.spyOn(qwenCore, 'resolveTelemetrySettings').mockReturnValue(
telemetryPromise,
);
const createBridge = vi
.spyOn(acpBridge, 'createAcpSessionBridge')
.mockImplementation(() => makeRuntimeBridge());
vi.spyOn(settingsRuntime, 'loadSettings').mockReturnValue({
merged: {},
} as ReturnType<typeof settingsRuntime.loadSettings>);
vi.spyOn(trustedFoldersRuntime, 'getWorkspaceTrustStatus').mockReturnValue({
effective: { state: 'trusted' },
} as ReturnType<typeof trustedFoldersRuntime.getWorkspaceTrustStatus>);
const handle = await runQwenServe(
{
port: 0,
hostname: '127.0.0.1',
mode: 'http-bridge',
workspace: [primary, secondary],
maxSessions: 1,
serveWebShell: false,
},
{
resolveOnListen: true,
runtimeStartupTimeoutMs: 0,
bootSettings: {},
daemonLogBaseDir: path.join(tmpDir, 'debug'),
},
);
try {
const bootstrapRes = await fetch(`${handle.url}/capabilities`);
expect(bootstrapRes.status).toBe(503);
expect(bootstrapRes.headers.get('retry-after')).toBe('1');
expect(await bootstrapRes.json()).toMatchObject({
error: 'Daemon runtime is still starting',
code: 'daemon_runtime_starting',
});
expect(createBridge).not.toHaveBeenCalled();
resolveTelemetry?.({
enabled: false,
sensitiveSpanAttributeMaxLength: 1024 * 1024,
});
await handle.runtimeReady;
const runtimeRes = await fetch(`${handle.url}/capabilities`);
expect(runtimeRes.status).toBe(200);
const runtimeBody = (await runtimeRes.json()) as { features: string[] };
expect(runtimeBody.features).toContain('multi_workspace_sessions');
} finally {
resolveTelemetry?.({
enabled: false,
sensitiveSpanAttributeMaxLength: 1024 * 1024,
});
await handle.close();
}
});
it('keeps health responsive before starting deferred runtime work', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-health-first-')),
@ -3088,6 +3476,50 @@ describe('runQwenServe runtime startup failures', () => {
expect(bridge.shutdown).toHaveBeenCalledTimes(1);
});
it('shuts down all workspace bridges when multi-workspace runtime mounting fails', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-runtime-partial-fail-')),
);
const primary = path.join(tmpDir, 'primary');
const secondary = path.join(tmpDir, 'secondary');
fs.mkdirSync(primary);
fs.mkdirSync(secondary);
const primaryBridge = makeRuntimeBridge();
const secondaryBridge = makeRuntimeBridge();
vi.spyOn(acpBridge, 'createAcpSessionBridge')
.mockReturnValueOnce(
primaryBridge as ReturnType<typeof acpBridge.createAcpSessionBridge>,
)
.mockReturnValueOnce(
secondaryBridge as ReturnType<typeof acpBridge.createAcpSessionBridge>,
);
vi.spyOn(serverModule, 'createServeApp').mockImplementation(() => {
throw new Error('runtime app boom');
});
const handle = await runQwenServe(
{
port: 0,
hostname: '127.0.0.1',
mode: 'http-bridge',
workspace: [primary, secondary],
maxSessions: 1,
serveWebShell: false,
},
{ resolveOnListen: true },
);
try {
await expect(handle.runtimeReady).rejects.toThrow('runtime app boom');
expect(primaryBridge.shutdown).toHaveBeenCalledTimes(1);
expect(secondaryBridge.shutdown).toHaveBeenCalledTimes(1);
} finally {
await handle.close();
}
expect(primaryBridge.shutdown).toHaveBeenCalledTimes(1);
expect(secondaryBridge.shutdown).toHaveBeenCalledTimes(1);
});
it('cleans up runtime locals when closed immediately after listening', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-runtime-close-')),

View file

@ -31,7 +31,7 @@ import {
preResolveServeFastPathHomeEnvOverrides,
type ServeFastPathSettings,
} from './fast-path-settings.js';
import { resolveSingleWorkspaceInput } from './workspace-inputs.js';
import { resolveWorkspaceInputs } from './workspace-inputs.js';
import type { AcpSessionBridge } from '@qwen-code/acp-bridge/bridgeTypes';
import { canonicalizeWorkspace } from '@qwen-code/acp-bridge/workspacePaths';
import type {
@ -75,6 +75,10 @@ import {
type ServeOptions,
} from './types.js';
import type { WorkspaceFileSystemFactory } from './fs/index.js';
import type {
WorkspaceRegistry,
WorkspaceRuntime,
} from './workspace-registry.js';
import type { PermissionPolicy } from '@qwen-code/acp-bridge';
import { getCliVersion } from '../utils/version.js';
import { getRateLimiter } from './rate-limit.js';
@ -177,6 +181,18 @@ function isNonNegativeIntegerOrInfinity(value: number): boolean {
);
}
function deriveDefaultMaxTotalSessions(
maxSessionsPerWorkspace: number | undefined,
workspaceCount: number,
): number | undefined {
if (workspaceCount <= 1) return undefined;
const perWorkspace = maxSessionsPerWorkspace ?? DEFAULT_MAX_SESSIONS;
if (perWorkspace === 0 || perWorkspace === Number.POSITIVE_INFINITY) {
return undefined;
}
return perWorkspace * workspaceCount;
}
function isNonNegativeIntegerMs(value: number): boolean {
return Number.isFinite(value) && Number.isInteger(value) && value >= 0;
}
@ -748,6 +764,7 @@ async function loadServeRuntimeModules() {
workspaceProvidersStatusModule,
workspaceSkillsStatusModule,
totalSessionAdmissionModule,
workspaceRegistryModule,
] = await Promise.all([
import('./server.js'),
import('@qwen-code/acp-bridge/bridge'),
@ -758,6 +775,7 @@ async function loadServeRuntimeModules() {
import('./workspace-providers-status.js'),
import('./workspace-skills-status.js'),
import('./total-session-admission.js'),
import('./workspace-registry.js'),
]);
return {
createServeApp: serverModule.createServeApp,
@ -778,6 +796,7 @@ async function loadServeRuntimeModules() {
workspaceSkillsStatusModule.createWorkspaceSkillsStatusProvider,
createTotalSessionAdmissionController:
totalSessionAdmissionModule.createTotalSessionAdmissionController,
createWorkspaceRegistry: workspaceRegistryModule.createWorkspaceRegistry,
};
}
@ -1026,6 +1045,7 @@ function createBootstrapServeApp(input: {
qwenCodeVersion?: string;
sessionShellCommandEnabled: boolean;
permissionPolicy: PermissionPolicy | undefined;
multiWorkspaceCapabilitiesRequireRuntime: boolean;
getRuntimeError: () => string | undefined;
getChannelWorkerSnapshot: () => ReturnType<
ChannelWorkerSupervisor['snapshot']
@ -1041,6 +1061,7 @@ function createBootstrapServeApp(input: {
qwenCodeVersion,
sessionShellCommandEnabled,
permissionPolicy,
multiWorkspaceCapabilitiesRequireRuntime,
getRuntimeError,
getChannelWorkerSnapshot,
onHealthServed,
@ -1083,6 +1104,21 @@ function createBootstrapServeApp(input: {
}
app.get(BOOTSTRAP_CAPABILITIES_PATH, (_req: Request, res: Response): void => {
if (multiWorkspaceCapabilitiesRequireRuntime) {
const runtimeError = getRuntimeError();
if (runtimeError === undefined) {
res.setHeader('Retry-After', '1');
}
res.status(503).json({
error: runtimeError
? 'Daemon runtime failed to start'
: 'Daemon runtime is still starting',
code: runtimeError
? 'daemon_runtime_failed'
: 'daemon_runtime_starting',
});
return;
}
res.status(200).json(
createBootstrapCapabilities({
opts,
@ -1406,7 +1442,8 @@ export async function runQwenServe(
const chromeExtensionOriginAllowed = hasChromeExtensionOrigin(
optsIn.allowOrigins,
);
const rawWorkspace = resolveSingleWorkspaceInput(optsIn.workspace);
const rawWorkspaces = resolveWorkspaceInputs(optsIn.workspace);
const rawWorkspace = rawWorkspaces[0]!;
const opts: ServeOptions = {
...optsIn,
token,
@ -1645,49 +1682,102 @@ export async function runQwenServe(
);
}
// Resolve the bound workspace (1 daemon = 1 workspace).
// Explicit `--workspace` wins; otherwise default to process.cwd().
// `POST /session` with a mismatched `cwd` is rejected by the bridge
// with `WorkspaceMismatchError`. Multi-workspace deployments use
// multiple daemon processes, not intra-daemon routing.
//
// Boot-loud validation: absolute path, exists, is a directory.
if (!path.isAbsolute(rawWorkspace)) {
throw new Error(
`Invalid --workspace "${rawWorkspace}": must be an absolute path.`,
);
}
try {
const stats = fs.statSync(rawWorkspace);
if (!stats.isDirectory()) {
const validateAndCanonicalizeWorkspace = (workspace: string): string => {
if (!path.isAbsolute(workspace)) {
throw new Error(
`Invalid --workspace "${rawWorkspace}": exists but is not a directory.`,
`Invalid --workspace "${workspace}": must be an absolute path.`,
);
}
} catch (err) {
if (err && typeof err === 'object' && 'code' in err) {
const code = (err as { code?: unknown }).code;
if (code === 'ENOENT') {
try {
const stats = fs.statSync(workspace);
if (!stats.isDirectory()) {
throw new Error(
`Invalid --workspace "${rawWorkspace}": directory does not exist.`,
`Invalid --workspace "${workspace}": exists but is not a directory.`,
);
}
// EACCES / EPERM: the path exists but the current user can't
// stat it (typical for SIP-protected paths on macOS, root-owned
// dirs the daemon's user can't traverse, etc.). The raw Node
// SystemError has the path AND the syscall but no operator-
// facing breadcrumb that this came from `--workspace`. Wrap
// both codes so the boot failure points at the flag the
// operator actually set.
if (code === 'EACCES' || code === 'EPERM') {
} catch (err) {
if (err && typeof err === 'object' && 'code' in err) {
const code = (err as { code?: unknown }).code;
if (code === 'ENOENT') {
throw new Error(
`Invalid --workspace "${workspace}": directory does not exist.`,
);
}
// EACCES / EPERM: the path exists but the current user can't
// stat it (typical for SIP-protected paths on macOS, root-owned
// dirs the daemon's user can't traverse, etc.). The raw Node
// SystemError has the path AND the syscall but no operator-
// facing breadcrumb that this came from `--workspace`. Wrap
// both codes so the boot failure points at the flag the
// operator actually set.
if (code === 'EACCES' || code === 'EPERM') {
throw new Error(
`Invalid --workspace "${workspace}": permission denied ` +
`(${String(code)}). The path exists but cannot be stat'd ` +
`by the current user.`,
);
}
}
throw err;
}
return canonicalizeWorkspace(workspace);
};
// Resolve the bound workspace list. The first explicit workspace remains the
// primary workspace for legacy APIs; later workspaces are sessions-only
// runtimes.
const workspaceInputs = rawWorkspaces.map((workspace) => ({
raw: workspace,
cwd: validateAndCanonicalizeWorkspace(workspace),
}));
const boundWorkspace = workspaceInputs[0]!.cwd;
// Keep duplicate/nested rejection after realpath canonicalization so symlink
// aliases cannot create two runtimes for one physical workspace.
const seenCanonicalWorkspaces = new Set<string>();
for (const workspace of workspaceInputs) {
if (seenCanonicalWorkspaces.has(workspace.cwd)) {
throw new Error(
`Duplicate --workspace value resolves to ${JSON.stringify(
workspace.cwd,
)}.`,
);
}
seenCanonicalWorkspaces.add(workspace.cwd);
}
for (let i = 0; i < workspaceInputs.length; i++) {
for (let j = i + 1; j < workspaceInputs.length; j++) {
const first = workspaceInputs[i]!.cwd;
const second = workspaceInputs[j]!.cwd;
const firstRel = path.relative(first, second);
const secondRel = path.relative(second, first);
if (
firstRel &&
!firstRel.startsWith('..') &&
!path.isAbsolute(firstRel)
) {
throw new Error(
`Invalid --workspace "${rawWorkspace}": permission denied ` +
`(${String(code)}). The path exists but cannot be stat'd ` +
`by the current user.`,
`Nested --workspace values are not supported: ` +
`${JSON.stringify(second)} is inside ${JSON.stringify(first)}.`,
);
}
if (
secondRel &&
!secondRel.startsWith('..') &&
!path.isAbsolute(secondRel)
) {
throw new Error(
`Nested --workspace values are not supported: ` +
`${JSON.stringify(first)} is inside ${JSON.stringify(second)}.`,
);
}
}
throw err;
}
if (workspaceInputs.length > 1 && deps.bridge) {
throw new Error(
'Injected bridge dependencies are only supported with a single workspace; ' +
'multiple --workspace values require runQwenServe to construct one bridge per workspace.',
);
}
// Canonicalize ONCE here so `/capabilities` and the POST /session
// fallback (both via server.ts) AND the bridge agree on the same
@ -1696,7 +1786,6 @@ export async function runQwenServe(
// filesystems the bridge's `realpathSync.native` form diverges from
// server.ts's raw `opts.workspace` and clients see one path on
// `/capabilities` but another on `POST /session` responses.
const boundWorkspace = canonicalizeWorkspace(rawWorkspace);
// Read a lightweight settings summary once at boot for startup-time fields
// used before the full runtime settings loader is allowed onto the hot path.
@ -1867,6 +1956,10 @@ export async function runQwenServe(
);
}
}
opts.maxTotalSessions ??= deriveDefaultMaxTotalSessions(
opts.maxSessions,
workspaceInputs.length,
);
// Per-handle env overrides: `undefined` value means "scrub this
// var from the child env" — important when a different daemon
// in the same process set the var globally previously. Always
@ -1943,6 +2036,7 @@ export async function runQwenServe(
let runtimeApp: Application | undefined;
let runtimeAppForCleanup: Application | undefined;
let bridgeRef: AcpSessionBridge | undefined = deps.bridge;
const internalRuntimeBridgesForCleanup: AcpSessionBridge[] = [];
let daemonEventLoopMonitor:
| ReturnType<CoreRuntime['startEventLoopLagMonitor']>
| undefined;
@ -2001,6 +2095,19 @@ export async function runQwenServe(
() => bridgeRef,
() => runtimeStartupError,
);
const shutdownBridges = new WeakSet<AcpSessionBridge>();
const getRuntimeBridgesForCleanup = (): AcpSessionBridge[] => {
const appForCleanup = runtimeApp ?? runtimeAppForCleanup;
const registry = appForCleanup?.locals?.['workspaceRegistry'] as
| WorkspaceRegistry
| undefined;
const bridges = [
...(registry ? registry.list().map((runtime) => runtime.bridge) : []),
...(bridgeRef ? [bridgeRef] : []),
...internalRuntimeBridgesForCleanup,
];
return [...new Set(bridges)];
};
const buildRuntime = async (): Promise<{
app: Application;
@ -2185,60 +2292,64 @@ export async function runQwenServe(
promptQueueWaitStats.lastMs = durationMs;
core.recordDaemonPromptQueueWait(durationMs);
};
const daemonTelemetry = core.createDaemonBridgeTelemetry();
daemonTelemetry.metrics = {
sessionLifecycle(action) {
core.recordDaemonSessionLifecycle(action);
core.emitDaemonLog(
`Session ${action}.`,
{
'qwen-code.workspace.hash': daemonWorkspaceHash,
},
{
eventName: `qwen-code.daemon.session.${action}`,
},
);
},
channelLifecycle(action, expected) {
core.recordDaemonChannelLifecycle(action, expected);
core.emitDaemonLog(
action === 'spawn'
? 'ACP channel spawned.'
: `ACP channel exited (expected=${expected ?? true}).`,
{
...(action === 'exit'
? { 'qwen-code.daemon.channel.expected': expected ?? true }
: {}),
},
{
eventName: `qwen-code.daemon.channel.${action}`,
...(expected === false && action === 'exit'
? { severityNumber: 13 }
: {}),
},
);
},
promptQueueWait(durationMs) {
recordPromptQueueWait(durationMs);
metricsRing.recordPromptQueueWait(durationMs);
},
promptDuration(durationMs) {
core.recordDaemonPromptDuration(durationMs);
metricsRing.recordPromptDuration(durationMs);
},
cancelled: core.recordDaemonCancel,
// Per-round model token usage + LLM round-trip time sniffed off
// `agent_message_chunk._meta` (`usage` + `durationMs`) at the bridge's
// single session/update fan-in. Increments (not cumulative), so the ring
// sums tokens per window (token-burn chart) and pools the round-trip times
// for the LLM-latency percentiles.
tokenUsage(inputTokens, outputTokens, durationMs) {
metricsRing.recordTokens(inputTokens, outputTokens);
if (typeof durationMs === 'number') {
metricsRing.recordLlmDuration(durationMs);
}
},
const createRuntimeBridgeTelemetry = (workspaceHash: string) => {
const telemetry = core.createDaemonBridgeTelemetry();
telemetry.metrics = {
sessionLifecycle(action) {
core.recordDaemonSessionLifecycle(action);
core.emitDaemonLog(
`Session ${action}.`,
{
'qwen-code.workspace.hash': workspaceHash,
},
{
eventName: `qwen-code.daemon.session.${action}`,
},
);
},
channelLifecycle(action, expected) {
core.recordDaemonChannelLifecycle(action, expected);
core.emitDaemonLog(
action === 'spawn'
? 'ACP channel spawned.'
: `ACP channel exited (expected=${expected ?? true}).`,
{
...(action === 'exit'
? { 'qwen-code.daemon.channel.expected': expected ?? true }
: {}),
},
{
eventName: `qwen-code.daemon.channel.${action}`,
...(expected === false && action === 'exit'
? { severityNumber: 13 }
: {}),
},
);
},
promptQueueWait(durationMs) {
recordPromptQueueWait(durationMs);
metricsRing.recordPromptQueueWait(durationMs);
},
promptDuration(durationMs) {
core.recordDaemonPromptDuration(durationMs);
metricsRing.recordPromptDuration(durationMs);
},
cancelled: core.recordDaemonCancel,
// Per-round model token usage + LLM round-trip time sniffed off
// `agent_message_chunk._meta` (`usage` + `durationMs`) at the bridge's
// single session/update fan-in. Increments (not cumulative), so the ring
// sums tokens per window (token-burn chart) and pools the round-trip times
// for the LLM-latency percentiles.
tokenUsage(inputTokens, outputTokens, durationMs) {
metricsRing.recordTokens(inputTokens, outputTokens);
if (typeof durationMs === 'number') {
metricsRing.recordLlmDuration(durationMs);
}
},
};
return telemetry;
};
const daemonTelemetry = createRuntimeBridgeTelemetry(daemonWorkspaceHash);
// Allocate the audit ring + publisher in the daemon host (here)
// rather than inside the bridge factory, because the ring is the
// seam for exposing `GET /workspace/permission/audit` in the future.
@ -2290,6 +2401,15 @@ export async function runQwenServe(
pathLocks: sharedPathLocks,
...(customIgnoreFiles !== undefined ? { customIgnoreFiles } : {}),
});
const routeFsFactory = runtime.resolveBridgeFsFactory({
// REST routes still return primary-relative paths, so keep their
// filesystem boundary primary-only until responses carry root IDs.
boundWorkspaces: [boundWorkspace],
trusted: trustedWorkspace,
emit: deps.fsAuditEmit,
pathLocks: sharedPathLocks,
...(customIgnoreFiles !== undefined ? { customIgnoreFiles } : {}),
});
const channelFactory = runtime.createSpawnChannelFactory({
sourceEnv: runtimeEffectiveEnv,
onDiagnosticLine: diagnosticSink,
@ -2322,10 +2442,16 @@ export async function runQwenServe(
// (which registers a per-connection `ClientMcpRegistrar`'s sender on
// `mcp_register`). Inert unless `opts.clientMcpOverWs` is on.
const clientMcpSenderRegistry = new ClientMcpSenderRegistry();
const runtimeBridges: AcpSessionBridge[] = [];
const totalSessionAdmission = runtime.createTotalSessionAdmissionController(
{
maxTotalSessions: opts.maxTotalSessions,
getBridges: () => (bridgeRef ? [bridgeRef] : []),
getBridges: () =>
runtimeBridges.length > 0
? runtimeBridges
: bridgeRef
? [bridgeRef]
: [],
},
);
const persistDisabledToolsFn = (
@ -2450,7 +2576,9 @@ export async function runQwenServe(
});
if (!deps.bridge) {
bridgeRef = bridge;
internalRuntimeBridgesForCleanup.push(bridge);
}
runtimeBridges.push(bridge);
const workspaceService = runtime.createDaemonWorkspaceService({
boundWorkspace,
contextFilename: contextFilenameForInit ?? 'QWEN.md',
@ -2534,8 +2662,282 @@ export async function runQwenServe(
publishWorkspaceEvent: (event) => bridge.publishWorkspaceEvent(event),
});
const workspaceRuntimes: WorkspaceRuntime[] = [
{
workspaceId: daemonWorkspaceHash,
workspaceCwd: boundWorkspace,
primary: true,
trusted: trustedWorkspace,
env: primaryRuntimeEnv,
bridge,
workspaceService,
routeFileSystemFactory: routeFsFactory,
clientMcpSenderRegistry,
},
];
const createRuntimeEnvMetadata = (
workspace: string,
settings: ReturnType<SettingsRuntime['loadSettings']> | undefined,
): {
metadata: {
mode: 'runtime-overlay';
overlayKeys: string[];
envFilePaths: string[];
effectiveEnv: NodeJS.ProcessEnv;
envFileReadFailed: boolean;
envFileReadFailures: Array<{ path: string; error: string }>;
fallbackReason?: string;
};
effectiveEnv: NodeJS.ProcessEnv;
replace: (nextEnv: Readonly<NodeJS.ProcessEnv>) => void;
} => {
const snapshot = settings
? settingsRuntime.environment.buildRuntimeEnvironment(
settings.merged,
workspace,
daemonRuntimeBaseEnv,
)
: {
effectiveEnv: { ...daemonRuntimeBaseEnv },
overlayKeys: Object.freeze([] as string[]),
envFilePaths: Object.freeze([] as string[]),
envFileReadFailed: false,
envFileReadFailures: Object.freeze([]),
};
logRuntimeEnvFileReadFailures(workspace, snapshot);
const effectiveEnv: NodeJS.ProcessEnv = { ...snapshot.effectiveEnv };
const metadata: {
mode: 'runtime-overlay';
overlayKeys: string[];
envFilePaths: string[];
effectiveEnv: NodeJS.ProcessEnv;
envFileReadFailed: boolean;
envFileReadFailures: Array<{ path: string; error: string }>;
fallbackReason?: string;
} = {
mode: 'runtime-overlay',
overlayKeys: [...snapshot.overlayKeys],
effectiveEnv,
envFilePaths: [...snapshot.envFilePaths],
envFileReadFailed: snapshot.envFileReadFailed,
envFileReadFailures: [...snapshot.envFileReadFailures],
};
return {
metadata,
effectiveEnv,
replace(nextEnv) {
for (const key of Object.keys(effectiveEnv)) {
delete effectiveEnv[key];
}
Object.assign(effectiveEnv, nextEnv);
},
};
};
for (const workspaceInput of workspaceInputs.slice(1)) {
let secondarySettings:
| ReturnType<SettingsRuntime['loadSettings']>
| undefined;
try {
secondarySettings = settingsRuntime.settings.loadSettings(
workspaceInput.cwd,
);
} catch (err) {
writeStderrLine(
`qwen serve: could not read full settings for secondary workspace ` +
`${workspaceInput.cwd} (${err instanceof Error ? err.message : String(err)}); ` +
`falling back to defaults.`,
);
}
const secondaryTrusted = secondarySettings
? settingsRuntime.trustedFolders.getWorkspaceTrustStatus(
secondarySettings.merged,
workspaceInput.cwd,
).effective.state === 'trusted'
: false;
if (!secondaryTrusted) {
daemonLog.warn('secondary workspace is not trusted', {
workspace: workspaceInput.cwd,
trustSettingsAvailable: secondarySettings !== undefined,
});
}
const secondaryEnv = createRuntimeEnvMetadata(
workspaceInput.cwd,
secondarySettings,
);
const secondaryWorkspaceHash = core.hashDaemonWorkspace(
workspaceInput.cwd,
);
const secondaryStatusProvider = runtime.createDaemonStatusProvider({
env: secondaryEnv.effectiveEnv,
});
const secondaryBridgeFsFactory = runtime.resolveBridgeFsFactory({
boundWorkspaces: [workspaceInput.cwd],
trusted: secondaryTrusted,
emit: deps.fsAuditEmit,
pathLocks: sharedPathLocks,
...(customIgnoreFiles !== undefined ? { customIgnoreFiles } : {}),
});
const secondaryChannelFactory = runtime.createSpawnChannelFactory({
sourceEnv: secondaryEnv.effectiveEnv,
onDiagnosticLine: diagnosticSink,
pipeHooks: {
onMessageSent: (bytes) => recordPipeMessage('outbound', bytes),
onMessageReceived: (bytes) => recordPipeMessage('inbound', bytes),
onMessageObserved: ({ direction, bytes, message }) =>
observeLargePipeFrame({
direction: daemonPipeDirection(direction),
bytes,
message,
}),
},
...(opts.experimentalLsp === true
? { extraArgs: ['--experimental-lsp'] }
: {}),
});
const secondaryClientMcpSenderRegistry = new ClientMcpSenderRegistry();
const secondaryBridge = runtime.createAcpSessionBridge({
clientMcpSender: secondaryClientMcpSenderRegistry.lookup,
maxSessions: opts.maxSessions,
freshSessionAdmission: totalSessionAdmission.admit,
...(opts.maxPendingPromptsPerSession !== undefined
? { maxPendingPromptsPerSession: opts.maxPendingPromptsPerSession }
: {}),
...(opts.eventRingSize !== undefined
? { eventRingSize: opts.eventRingSize }
: {}),
...(opts.compactedReplayMaxBytes !== undefined
? { compactedReplayMaxBytes: opts.compactedReplayMaxBytes }
: {}),
...(opts.channelIdleTimeoutMs !== undefined
? { channelIdleTimeoutMs: opts.channelIdleTimeoutMs }
: {}),
...(opts.sessionReapIntervalMs !== undefined
? { sessionReapIntervalMs: opts.sessionReapIntervalMs }
: {}),
...(opts.sessionIdleTimeoutMs !== undefined
? { sessionIdleTimeoutMs: opts.sessionIdleTimeoutMs }
: {}),
...(opts.permissionResponseTimeoutMs !== undefined
? { permissionResponseTimeoutMs: opts.permissionResponseTimeoutMs }
: {}),
boundWorkspace: workspaceInput.cwd,
sessionShellCommandEnabled,
childEnvOverrides,
channelFactory: secondaryChannelFactory,
onDiagnosticLine: diagnosticSink,
telemetry: createRuntimeBridgeTelemetry(secondaryWorkspaceHash),
...(permissionPolicy !== undefined ? { permissionPolicy } : {}),
...(permissionConsensusQuorum !== undefined
? {
permissionConsensusQuorum,
}
: {}),
permissionAudit: permissionAuditPublisher,
statusProvider: secondaryStatusProvider,
fileSystem: createBridgeFileSystemAdapter(secondaryBridgeFsFactory),
persistApprovalMode: (workspace, mode) =>
withSettingsLock(workspace, async () => {
const fresh = settingsRuntime.settings.loadSettings(workspace);
fresh.setValue(WORKSPACE_SETTING_SCOPE, 'tools.approvalMode', mode);
}),
});
runtimeBridges.push(secondaryBridge);
internalRuntimeBridgesForCleanup.push(secondaryBridge);
const secondaryWorkspaceService = runtime.createDaemonWorkspaceService({
boundWorkspace: workspaceInput.cwd,
contextFilename: contextFilenameForInit ?? 'QWEN.md',
statusProvider: secondaryStatusProvider,
workspaceProvidersStatusProvider:
runtime.createWorkspaceProvidersStatusProvider({
env: secondaryEnv.effectiveEnv,
}),
workspaceSkillsStatusProvider:
runtime.createWorkspaceSkillsStatusProvider(),
isChannelLive: () => secondaryBridge.isChannelLive(),
persistDisabledTools: persistDisabledToolsFn,
persistSetting: persistSettingFn,
persistSettings: persistSettingsFn,
reloadDaemonEnv: (workspace) =>
withSettingsLock(workspace, async () => {
const fresh = settingsRuntime.settings.loadSettings(workspace, {
skipLoadEnvironment: true,
});
const result = settingsRuntime.settings.reloadEnvironment(
fresh.merged,
workspace,
);
try {
const refreshedRuntimeEnv =
settingsRuntime.environment.buildRuntimeEnvironment(
fresh.merged,
workspace,
daemonRuntimeBaseEnv,
);
logRuntimeEnvFileReadFailures(workspace, refreshedRuntimeEnv);
secondaryEnv.replace(refreshedRuntimeEnv.effectiveEnv);
secondaryEnv.metadata.envFileReadFailed =
refreshedRuntimeEnv.envFileReadFailed;
secondaryEnv.metadata.envFileReadFailures.splice(
0,
secondaryEnv.metadata.envFileReadFailures.length,
...refreshedRuntimeEnv.envFileReadFailures,
);
secondaryEnv.metadata.overlayKeys.splice(
0,
secondaryEnv.metadata.overlayKeys.length,
...refreshedRuntimeEnv.overlayKeys,
);
secondaryEnv.metadata.envFilePaths.splice(
0,
secondaryEnv.metadata.envFilePaths.length,
...refreshedRuntimeEnv.envFilePaths,
);
delete secondaryEnv.metadata.fallbackReason;
} catch (err) {
secondaryEnv.metadata.fallbackReason =
err instanceof Error ? err.message : String(err);
daemonLog.warn(
'failed to rebuild secondary runtime env snapshot after daemon env reload; preserving previous runtime env',
{
workspace,
error: secondaryEnv.metadata.fallbackReason,
},
);
}
return result;
}),
queryWorkspaceStatus: (method, idle) =>
secondaryBridge.queryWorkspaceStatus(method, idle),
invokeWorkspaceCommand: (method, params, invokeOpts) =>
secondaryBridge.invokeWorkspaceCommand(method, params, invokeOpts),
refreshExtensionsForAllSessions: () =>
secondaryBridge.refreshExtensionsForAllSessions(),
publishWorkspaceEvent: (event) =>
secondaryBridge.publishWorkspaceEvent(event),
});
workspaceRuntimes.push({
workspaceId: secondaryWorkspaceHash,
workspaceCwd: workspaceInput.cwd,
primary: false,
trusted: secondaryTrusted,
env: secondaryEnv.metadata,
bridge: secondaryBridge,
workspaceService: secondaryWorkspaceService,
routeFileSystemFactory: secondaryBridgeFsFactory,
clientMcpSenderRegistry: secondaryClientMcpSenderRegistry,
});
}
const workspaceRegistry: WorkspaceRegistry =
runtime.createWorkspaceRegistry(workspaceRuntimes);
core.registerDaemonGaugeCallbacks({
sessionCount: () => bridge.sessionCount,
sessionCount: () =>
workspaceRegistry
.list()
.reduce((sum, item) => sum + item.bridge.sessionCount, 0),
sseCount: () => runtime.getActiveSseCount(),
heapUsed: () => process.memoryUsage().heapUsed,
});
@ -2617,9 +3019,21 @@ export async function runQwenServe(
cpuPercent,
rssBytes: mem.rss,
heapUsedBytes: mem.heapUsed,
activeSessions: bridge.sessionCount,
activePrompts: bridge.activePromptCount,
queuedPrompts: bridge.pendingPromptTotal ?? 0,
activeSessions: workspaceRegistry
.list()
.reduce((sum, item) => sum + item.bridge.sessionCount, 0),
activePrompts: workspaceRegistry
.list()
.reduce(
(sum, item) => sum + (item.bridge.activePromptCount ?? 0),
0,
),
queuedPrompts: workspaceRegistry
.list()
.reduce(
(sum, item) => sum + (item.bridge.pendingPromptTotal ?? 0),
0,
),
eventLoopLagP99Ms,
sseConnections: runtime.getActiveSseCount(),
wsConnections: acp?.wsStreams ?? 0,
@ -2675,6 +3089,7 @@ export async function runQwenServe(
};
const app = runtime.createServeApp(opts, () => actualPort, {
workspaceRegistry,
bridge,
webShellDir,
boundWorkspace,
@ -2684,15 +3099,7 @@ export async function runQwenServe(
// (keepalive) and reloads them on boot (rehydration). Off by default so
// direct createServeApp embeds/tests don't spawn sessions.
manageScheduledTaskSessions: true,
fsFactory: runtime.resolveBridgeFsFactory({
// REST routes still return primary-relative paths, so keep their
// filesystem boundary primary-only until responses carry root IDs.
boundWorkspaces: [boundWorkspace],
trusted: trustedWorkspace,
emit: deps.fsAuditEmit,
pathLocks: sharedPathLocks,
...(customIgnoreFiles !== undefined ? { customIgnoreFiles } : {}),
}),
fsFactory: routeFsFactory,
primaryWorkspaceTrusted: trustedWorkspace,
primaryRuntimeEnv,
daemonLog,
@ -2794,6 +3201,7 @@ export async function runQwenServe(
qwenCodeVersion: cliVersion,
sessionShellCommandEnabled,
permissionPolicy,
multiWorkspaceCapabilitiesRequireRuntime: workspaceInputs.length > 1,
getRuntimeError: () => runtimeStartupError,
getChannelWorkerSnapshot,
onHealthServed: deferRuntimeUntilFirstHealth
@ -3043,6 +3451,8 @@ export async function runQwenServe(
bridge: AcpSessionBridge | undefined,
): Promise<void> => {
if (!bridge || deps.bridge) return;
if (shutdownBridges.has(bridge)) return;
shutdownBridges.add(bridge);
try {
await bridge.shutdown();
} catch (shutdownErr) {
@ -3089,7 +3499,12 @@ export async function runQwenServe(
await stopChannelWorkerAfterFailedStartup();
disposeDaemonEventLoopMonitor();
removeCurrentServePidfile();
await shutdownBridgeAfterFailedStartup(bridgeForCleanup ?? bridgeRef);
const bridgesForCleanup = bridgeForCleanup
? [bridgeForCleanup, ...getRuntimeBridgesForCleanup()]
: getRuntimeBridgesForCleanup();
for (const bridge of [...new Set(bridgesForCleanup)]) {
await shutdownBridgeAfterFailedStartup(bridge);
}
markRuntimeFailed(error);
};
const armRuntimeStartupTimer = (): void => {
@ -3219,7 +3634,9 @@ export async function runQwenServe(
daemonLog.warn(`received ${signal} during drain — forcing exit`);
try {
channelWorker.killAllSync();
bridgeRef?.killAllSync();
for (const runtimeBridge of getRuntimeBridgesForCleanup()) {
runtimeBridge.killAllSync();
}
removeCurrentServePidfile();
} catch (err) {
daemonLog.error(
@ -3413,8 +3830,9 @@ export async function runQwenServe(
);
});
removeCurrentServePidfile();
const bridgeForShutdown = bridgeRef;
if (bridgeForShutdown) {
for (const bridgeForShutdown of getRuntimeBridgesForCleanup()) {
if (shutdownBridges.has(bridgeForShutdown)) continue;
shutdownBridges.add(bridgeForShutdown);
await bridgeForShutdown.shutdown().catch((err) => {
daemonLog.error(
'bridge shutdown error',

View file

@ -360,6 +360,7 @@ const EXPECTED_REGISTERED_FEATURES = [
'session_branch',
'rate_limit',
'workspace_reload',
'multi_workspace_sessions',
'client_mcp_over_ws',
'cdp_tunnel_over_ws',
'voice_transcribe',
@ -2066,6 +2067,22 @@ describe('createServeApp', () => {
);
continue;
}
if (feature === 'multi_workspace_sessions') {
expect(predicate({ multiWorkspaceSessionsEnabled: true })).toBe(true);
expect(predicate({ multiWorkspaceSessionsEnabled: false })).toBe(
false,
);
expect(predicate({})).toBe(false);
expect(
getAdvertisedServeFeatures(undefined, {
multiWorkspaceSessionsEnabled: true,
}),
).toContain(feature);
expect(getAdvertisedServeFeatures(undefined, {})).not.toContain(
feature,
);
continue;
}
if (feature === 'client_mcp_over_ws') {
expect(predicate({ clientMcpOverWsEnabled: true })).toBe(true);
expect(predicate({ clientMcpOverWsEnabled: false })).toBe(false);
@ -5925,7 +5942,7 @@ describe('createServeApp', () => {
}
});
it('passes explicit cwd through to the bridge', async () => {
it('passes explicit primary cwd through to the bridge', async () => {
const bridge = fakeBridge({
loadImpl: async (req) => ({
sessionId: req.sessionId,
@ -5935,18 +5952,22 @@ describe('createServeApp', () => {
state: { configOptions: [] },
}),
});
const app = createServeApp(baseOpts, undefined, { bridge });
const app = createServeApp(
{ ...baseOpts, workspace: WS_BOUND },
undefined,
{ bridge },
);
const res = await request(app)
.post('/session/persisted-2/load')
.set('Host', `127.0.0.1:${baseOpts.port}`)
.send({ cwd: '/work/a' });
.send({ cwd: WS_BOUND });
expect(res.status).toBe(200);
expect(res.body.state).toEqual({ configOptions: [] });
expect(bridge.loadCalls).toEqual([
{
sessionId: 'persisted-2',
workspaceCwd: '/work/a',
workspaceCwd: WS_BOUND,
historyReplay: 'response',
},
]);
@ -6102,7 +6123,7 @@ describe('createServeApp', () => {
});
});
it('400 workspace_mismatch when the bridge throws WorkspaceMismatchError', async () => {
it('400 workspace_mismatch before touching the bridge for non-primary cwd', async () => {
const bridge = fakeBridge({
loadImpl: async () => {
throw new WorkspaceMismatchError(WS_BOUND, WS_DIFFERENT);
@ -6124,6 +6145,7 @@ describe('createServeApp', () => {
boundWorkspace: WS_BOUND,
requestedWorkspace: WS_DIFFERENT,
});
expect(bridge.loadCalls).toHaveLength(0);
});
it('503 + Retry-After: 5 when the bridge throws SessionLimitExceededError', async () => {

View file

@ -371,9 +371,9 @@ export function createServeApp(
// bridge silently fell back to `DEFAULT_MAX_SESSIONS` (20) and
// only the `runQwenServe` path piped the option through.
//
// The daemon is bound to exactly one workspace. The value advertised
// on `/capabilities`, used for the `POST /session` cwd fallback,
// AND passed into the bridge must be the SAME canonical form.
// The primary workspace value advertised on `/capabilities`, used for the
// `POST /session` cwd fallback, AND passed into the primary bridge must be
// the SAME canonical form.
// `deps.boundWorkspace` is the pre-canonicalized fast-path from
// `runQwenServe`; when omitted we canonicalize ourselves.
const injectedWorkspaceRegistry = deps.workspaceRegistry;
@ -503,6 +503,8 @@ export function createServeApp(
reloadAvailable:
deps.workspace !== undefined || injectedWorkspaceRegistry !== undefined,
sessionShellCommandEnabled,
multiWorkspaceSessionsEnabled:
(injectedWorkspaceRegistry?.list().length ?? 1) > 1,
});
const statusProvider =
deps.statusProvider ??
@ -754,6 +756,7 @@ export function createServeApp(
opts,
boundWorkspace: primaryBoundWorkspace,
bridge: primaryBridge,
workspaceRegistry,
workspace: primaryWorkspace,
daemonLog,
startup: deps.startup,
@ -777,7 +780,10 @@ export function createServeApp(
mode: opts.mode,
currentServeFeatures,
boundWorkspace: primaryBoundWorkspace,
workspaceRegistry,
permissionPolicy: primaryBridge.permissionPolicy,
maxSessionsPerWorkspace: opts.maxSessions,
maxTotalSessions: opts.maxTotalSessions,
maxPendingPromptsPerSession: opts.maxPendingPromptsPerSession,
languageCodes,
});
@ -939,6 +945,7 @@ export function createServeApp(
registerSessionRoutes(app, {
boundWorkspace: primaryBoundWorkspace,
bridge: primaryBridge,
workspaceRegistry,
archiveCoordinator,
mutate,
sendBridgeError,
@ -1055,12 +1062,15 @@ export function createServeApp(
registerPermissionRoutes(app, {
bridge: primaryBridge,
workspaceRegistry,
daemonLog,
mutate,
sendPermissionVoteError,
});
registerSseEventsRoutes(app, {
bridge: primaryBridge,
workspaceRegistry,
daemonLog,
writerIdleTimeoutMs: opts.writerIdleTimeoutMs,
sendBridgeError,

View file

@ -12,6 +12,7 @@ import { getAdvertisedServeFeatures } from '../capabilities.js';
import type { ServeOptions } from '../types.js';
// Keep in sync with acp-bridge bridge.ts and SDK DaemonClient.ts.
const DEFAULT_MAX_SESSIONS = 20;
const DEFAULT_MAX_PENDING_PROMPTS_PER_SESSION = 5;
export const SERVE_LANGUAGE_CODES = [
@ -27,12 +28,21 @@ export function advertisedMaxPendingPromptsPerSession(
return value;
}
export function advertisedMaxSessions(
value: number | undefined,
): number | null {
if (value === undefined) return DEFAULT_MAX_SESSIONS;
if (value === 0 || value === Number.POSITIVE_INFINITY) return null;
return value;
}
interface CreateServeFeaturesDeps {
opts: ServeOptions;
boundWorkspace: string;
persistSettingAvailable: boolean;
reloadAvailable: boolean;
sessionShellCommandEnabled: boolean;
multiWorkspaceSessionsEnabled: boolean;
}
export interface ServeFeaturesRuntime {
@ -50,6 +60,7 @@ export function createServeFeatures(
persistSettingAvailable,
reloadAvailable,
sessionShellCommandEnabled,
multiWorkspaceSessionsEnabled,
} = deps;
let cachedVoiceTranscriptionAvailable: boolean | undefined;
const invalidateServeFeaturesCache = () => {
@ -80,6 +91,7 @@ export function createServeFeatures(
sessionShellCommandEnabled,
rateLimit: opts.rateLimit === true,
reloadAvailable,
multiWorkspaceSessionsEnabled,
clientMcpOverWsEnabled: opts.clientMcpOverWs === true,
cdpTunnelOverWsEnabled: opts.cdpTunnelOverWs === true,
voiceTranscriptionAvailable: getCachedVoiceTranscriptionAvailable(),

View file

@ -37,7 +37,10 @@ export interface ListWorkspaceSessionsResult {
}
export class InvalidCursorError extends Error {
constructor(cursor: string, kind: 'numeric' | 'organized' = 'numeric') {
constructor(
cursor: string,
kind: 'numeric' | 'organized' | 'live' = 'numeric',
) {
super(`Invalid cursor: "${cursor}" is not a valid ${kind} cursor`);
this.name = 'InvalidCursorError';
}
@ -70,6 +73,11 @@ interface OrganizedCursorKey {
sessionId: string;
}
interface LiveSessionCursorKey {
activityTime: number;
sessionId: string;
}
function parseOrganizedCursor(
cursor: string,
expected: { group: string; archiveState: SessionArchiveState },
@ -113,6 +121,35 @@ function encodeOrganizedCursor(
).toString('base64url');
}
function parseLiveSessionCursor(
cursor: string,
): LiveSessionCursorKey | undefined {
if (cursor === '') return undefined;
try {
const parsed = JSON.parse(
Buffer.from(cursor, 'base64url').toString('utf8'),
) as unknown;
if (
typeof parsed !== 'object' ||
parsed === null ||
Array.isArray(parsed) ||
typeof (parsed as LiveSessionCursorKey).activityTime !== 'number' ||
!Number.isFinite((parsed as LiveSessionCursorKey).activityTime) ||
typeof (parsed as LiveSessionCursorKey).sessionId !== 'string' ||
(parsed as LiveSessionCursorKey).sessionId.length === 0
) {
throw new Error('invalid live cursor');
}
return parsed as LiveSessionCursorKey;
} catch {
throw new InvalidCursorError(cursor, 'live');
}
}
function encodeLiveSessionCursor(last: LiveSessionCursorKey): string {
return Buffer.from(JSON.stringify(last), 'utf8').toString('base64url');
}
function toSummary(item: {
sessionId: string;
cwd: string;
@ -174,6 +211,24 @@ function getSummaryActivityTime(session: BridgeSessionSummary): number {
return Number.isFinite(time) ? time : 0;
}
function getLiveSessionCursorKey(
session: BridgeSessionSummary,
): LiveSessionCursorKey {
return {
activityTime: getSummaryActivityTime(session),
sessionId: session.sessionId,
};
}
function compareLiveSessionCursorKeys(
a: LiveSessionCursorKey,
b: LiveSessionCursorKey,
): number {
const byTime = b.activityTime - a.activityTime;
if (byTime !== 0) return byTime;
return a.sessionId.localeCompare(b.sessionId);
}
function compareOrganizedSessions(
activityTimeById: ReadonlyMap<string, number>,
a: BridgeSessionSummary,
@ -453,6 +508,50 @@ export async function listWorkspaceSessionsForResponse(
return { sessions, nextCursor };
}
export function listLiveWorkspaceSessionsForResponse(
bridge: AcpSessionBridge,
workspaceCwd: string,
options?: Pick<ListWorkspaceSessionsOptions, 'cursor' | 'size'>,
): ListWorkspaceSessionsResult {
const rawSize = options?.size;
const requestedSize =
typeof rawSize === 'number' && Number.isSafeInteger(rawSize)
? rawSize
: DEFAULT_SESSION_PAGE_SIZE;
const pageSize = Math.min(Math.max(requestedSize, 1), MAX_SESSION_PAGE_SIZE);
const cursorKey =
options?.cursor !== undefined
? parseLiveSessionCursor(options.cursor)
: undefined;
const sessions = bridge
.listWorkspaceSessions(workspaceCwd)
.sort((a, b) =>
compareLiveSessionCursorKeys(
getLiveSessionCursorKey(a),
getLiveSessionCursorKey(b),
),
);
const afterCursor =
cursorKey === undefined
? sessions
: sessions.filter(
(session) =>
compareLiveSessionCursorKeys(
cursorKey,
getLiveSessionCursorKey(session),
) < 0,
);
const page = afterCursor.slice(0, pageSize);
const nextCursor =
page.length < afterCursor.length
? encodeLiveSessionCursor(getLiveSessionCursorKey(page[page.length - 1]!))
: undefined;
return {
sessions: page,
...(nextCursor !== undefined ? { nextCursor } : {}),
};
}
export function parseSessionPageSizeQuery(raw: unknown): number | undefined {
if (typeof raw !== 'string') return undefined;
const trimmed = raw.trim();

View file

@ -99,21 +99,16 @@ export interface ServeOptions {
*/
compactedReplayMaxBytes?: number;
/**
* Absolute workspace path this daemon binds to. The daemon is
* **1 daemon = 1 workspace × N sessions**: one bound
* workspace at boot, sessions multiplexed on the single
* `qwen --acp` child via `connection.newSession()`.
* Absolute workspace path this daemon binds as its primary workspace.
* The CLI parser accepts repeated `--workspace` values for
* sessions-only multi-workspace mode, but this public option remains the
* primary workspace string so existing embeds do not need to understand
* the internal runtime registry.
*
* `POST /session` calls whose `cwd` doesn't canonicalize to this
* path are rejected with `400 workspace_mismatch`. Clients may
* also omit `cwd` the route falls back to this bound path.
*
* Multi-workspace deployments use **multiple daemon processes**
* (one per workspace, each on its own port), supervised by
* systemd / docker-compose / k8s / `qwen-coordinator` reference
* orchestrator. There is no intra-daemon multi-workspace mode
* (the previous Stage 1 `byWorkspaceChannel` routing layer was
* removed in the design revision).
* path, or to another registered runtime's canonical workspace, are
* rejected with `400 workspace_mismatch`. Clients may also omit `cwd`
* the route falls back to this primary path.
*
* Defaults to `process.cwd()` when omitted.
*/
@ -291,13 +286,15 @@ export interface CapabilitiesEnvelope {
*/
modelServices: string[];
/**
* Absolute workspace path this daemon is bound to
* (`1 daemon = 1 workspace`). Clients use this to:
* - Detect mismatch before posting `/session` (vs. waiting for
* 400 workspace_mismatch from the bridge).
* Absolute primary workspace path. Clients use this to:
* - Preserve single-workspace compatibility.
* - Omit `cwd` on `POST /session` the route falls back to this
* path when the body has no `cwd` field.
*
* When `features` contains `multi_workspace_sessions`, `workspaces[]`
* lists every registered runtime. `workspaceCwd` remains the primary
* entry for old clients.
*
* Optional at the type level (matches the SDK's `DaemonCapabilities`
* type) because the field is an additive extension of the v=1
* envelope. Older daemons may omit this field; additive optionality
@ -305,6 +302,13 @@ export interface CapabilitiesEnvelope {
* current server code always populates it.
*/
workspaceCwd?: string;
/** Registered session runtimes, present only for multi-workspace daemons. */
workspaces?: Array<{
id: string;
cwd: string;
primary: boolean;
trusted: boolean;
}>;
/**
* Transport families this daemon supports. Always includes `'rest'`;
* future builds may add `'acp-http'` and/or `'acp-ws'`. SDK clients
@ -336,6 +340,8 @@ export interface CapabilitiesEnvelope {
*/
limits?: {
maxPendingPromptsPerSession?: number | null;
maxSessionsPerWorkspace?: number | null;
maxTotalSessions?: number | null;
};
/**
* Language codes accepted by `POST /session/:id/language`.

View file

@ -14,6 +14,7 @@ import {
MultipleWorkspaceInputError,
NestedWorkspaceInputError,
resolveSingleWorkspaceInput,
resolveWorkspaceInputs,
} from './workspace-inputs.js';
let scratch: string | undefined;
@ -76,7 +77,7 @@ describe('resolveSingleWorkspaceInput', () => {
).toThrow(NestedWorkspaceInputError);
});
it('rejects distinct non-nested explicit workspaces while Phase 2a is gated', () => {
it('rejects distinct non-nested explicit workspaces for single-workspace callers', () => {
const root = makeScratch();
const primary = path.join(root, 'primary');
const secondary = path.join(root, 'secondary');
@ -88,7 +89,7 @@ describe('resolveSingleWorkspaceInput', () => {
);
});
it('keeps canonicalization failures on the gated multi-workspace error path', async () => {
it('lets single-workspace callers reject multiple inputs even if early canonicalization fails', async () => {
const canonicalizationError = Object.assign(
new Error('permission denied'),
{ code: 'EACCES' },
@ -115,3 +116,32 @@ describe('resolveSingleWorkspaceInput', () => {
}
});
});
describe('resolveWorkspaceInputs', () => {
it('keeps distinct non-nested explicit workspaces in input order', () => {
const root = makeScratch();
const primary = path.join(root, 'primary');
const secondary = path.join(root, 'secondary');
fs.mkdirSync(primary);
fs.mkdirSync(secondary);
expect(resolveWorkspaceInputs([primary, secondary])).toEqual([
primary,
secondary,
]);
});
it('still rejects duplicate and nested explicit workspaces', () => {
const root = makeScratch();
const parent = path.join(root, 'parent');
const child = path.join(parent, 'child');
fs.mkdirSync(child, { recursive: true });
expect(() => resolveWorkspaceInputs([parent, parent])).toThrow(
DuplicateWorkspaceInputError,
);
expect(() => resolveWorkspaceInputs([parent, child])).toThrow(
NestedWorkspaceInputError,
);
});
});

View file

@ -11,7 +11,7 @@ export class DuplicateWorkspaceInputError extends Error {
constructor(workspace: string) {
super(
`Duplicate --workspace value resolves to ${JSON.stringify(workspace)}. ` +
'Multi-workspace serve is not enabled; pass one --workspace.',
'Pass distinct --workspace values.',
);
this.name = 'DuplicateWorkspaceInputError';
}
@ -22,7 +22,7 @@ export class NestedWorkspaceInputError extends Error {
super(
`Nested --workspace values are not supported yet: ` +
`${JSON.stringify(child)} is inside ${JSON.stringify(parent)}. ` +
'Multi-workspace serve is not enabled; pass one --workspace.',
'Pass non-nested --workspace values.',
);
this.name = 'NestedWorkspaceInputError';
}
@ -31,8 +31,8 @@ export class NestedWorkspaceInputError extends Error {
export class MultipleWorkspaceInputError extends Error {
constructor() {
super(
'Multiple --workspace values are not supported yet. ' +
'Multi-workspace serve is not enabled; pass one --workspace.',
'Multiple --workspace values are not supported by this single-workspace caller. ' +
'Pass one --workspace.',
);
this.name = 'MultipleWorkspaceInputError';
}
@ -60,7 +60,7 @@ function isNestedWorkspace(parent: string, child: string): boolean {
return parent !== child && isWithinRoot(child, parent);
}
function rejectUnsupportedMultiWorkspaceInputs(
function rejectDuplicateOrNestedWorkspaceInputs(
workspaces: readonly string[],
): void {
if (workspaces.length <= 1) return;
@ -71,7 +71,7 @@ function rejectUnsupportedMultiWorkspaceInputs(
canonicalizeWorkspace(workspace),
);
} catch {
throw new MultipleWorkspaceInputError();
return;
}
const seen = new Set<string>();
for (const workspace of canonicalWorkspaces) {
@ -93,12 +93,18 @@ function rejectUnsupportedMultiWorkspaceInputs(
}
}
}
}
throw new MultipleWorkspaceInputError();
export function resolveWorkspaceInputs(workspace: unknown): string[] {
const workspaces = normalizeWorkspaceInputs(workspace);
rejectDuplicateOrNestedWorkspaceInputs(workspaces);
return workspaces;
}
export function resolveSingleWorkspaceInput(workspace: unknown): string {
const workspaces = normalizeWorkspaceInputs(workspace);
rejectUnsupportedMultiWorkspaceInputs(workspaces);
const workspaces = resolveWorkspaceInputs(workspace);
if (workspaces.length > 1) {
throw new MultipleWorkspaceInputError();
}
return workspaces[0]!;
}

View file

@ -21,7 +21,7 @@ export function sessionTools(state: BridgeState): any[] {
workspace_cwd: z
.string()
.optional()
.describe('Workspace path. Defaults to daemon bound workspace.'),
.describe('Workspace path. Defaults to daemon primary workspace.'),
model_service_id: z
.string()
.optional()

View file

@ -322,13 +322,14 @@ export function isDaemonTurnError(error: unknown): error is DaemonTurnError {
export interface CreateSessionRequest {
/**
* Workspace path the daemon must be bound to. When
* Workspace path the daemon must have registered. When
* omitted, the SDK sends no `cwd` field and the daemon route falls
* back to its boot-time `boundWorkspace`. Pass `caps.workspaceCwd`
* to be explicit, or omit it for the daemon-knows-best path. A
* non-empty `workspaceCwd` that doesn't canonicalize to the
* daemon's bound path yields a `400 workspace_mismatch`
* `DaemonHttpError`.
* back to its primary workspace. Pass `caps.workspaceCwd` to be
* explicit, pass a trusted `caps.workspaces[].cwd` when
* `multi_workspace_sessions` is advertised, or omit it for the
* daemon-knows-best path. A non-empty `workspaceCwd` that doesn't
* canonicalize to a registered workspace yields a
* `400 workspace_mismatch` `DaemonHttpError`.
*/
workspaceCwd?: string;
modelServiceId?: string;
@ -352,8 +353,8 @@ export interface CreateSessionRequest {
export interface RestoreSessionRequest {
/**
* Workspace path the daemon must be bound to. Omit to let the daemon use
* its advertised bound workspace, mirroring `createOrAttachSession`.
* Workspace path the daemon must have registered. Omit to let the daemon use
* its advertised primary workspace, mirroring `createOrAttachSession`.
*/
workspaceCwd?: string;
}
@ -1394,7 +1395,7 @@ export class DaemonClient {
clientId?: string,
): Promise<DaemonSession> {
// Omitting `cwd` lets the daemon fall back to its
// bound workspace. JSON.stringify strips `undefined` values, so
// primary workspace. JSON.stringify strips `undefined` values, so
// `cwd: undefined` becomes "no `cwd` key" on the wire — and the
// server then takes the documented fallback path.
//

View file

@ -364,6 +364,7 @@ export type {
DaemonWorkspaceTrustSource,
DaemonWorkspaceTrustState,
DaemonWorkspaceTrustStatus,
DaemonWorkspaceCapability,
DaemonAvailableCommand,
DaemonArchiveSessionsResult,
DaemonCapabilities,

View file

@ -22,6 +22,15 @@ export interface DaemonProtocolVersions {
export interface DaemonCapabilitiesLimits {
maxPendingPromptsPerSession?: number | null;
maxSessionsPerWorkspace?: number | null;
maxTotalSessions?: number | null;
}
export interface DaemonWorkspaceCapability {
id: string;
cwd: string;
primary: boolean;
trusted: boolean;
}
/** Capabilities envelope returned from `GET /capabilities`. */
@ -58,13 +67,13 @@ export interface DaemonCapabilities {
transports?: readonly string[];
/**
* Absolute canonical workspace path this daemon is bound to
* (1 daemon = 1 workspace). Clients use this to
* (a) detect mismatch before posting `/session` (vs. waiting for
* a 400 `workspace_mismatch` response), and (b) omit `cwd` on
* `POST /session` the route falls back to this path when the
* body has no `cwd` field. Multi-workspace deployments expose
* multiple daemons on different ports, each advertising its own
* `workspaceCwd`.
* as its primary workspace. Clients use this to (a) detect mismatch
* before posting `/session` on old single-workspace daemons, and (b)
* omit `cwd` on `POST /session` the route falls back to this path
* when the body has no `cwd` field. Newer daemons that advertise
* `multi_workspace_sessions` keep this field as the primary workspace
* compatibility value and expose every accepted runtime in
* `workspaces[]`.
*
* Optional at the type level because the field is an additive
* extension to v=1 envelopes. Daemons
@ -82,6 +91,12 @@ export interface DaemonCapabilities {
* "Cannot read properties of undefined".
*/
workspaceCwd?: string;
/**
* Registered workspace runtimes. Present only when the daemon advertises
* `multi_workspace_sessions`; `workspaceCwd` remains the primary cwd for
* old clients.
*/
workspaces?: DaemonWorkspaceCapability[];
}
/**
@ -285,6 +300,7 @@ export interface DaemonStatusReport {
};
limits: {
maxSessions: number | null;
maxTotalSessions: number | null;
maxPendingPromptsPerSession: number | null;
listenerMaxConnections: number | null;
eventRingSize: number;
@ -299,6 +315,8 @@ export interface DaemonStatusReport {
protocolVersions: DaemonProtocolVersions;
features: string[];
};
/** Present only when one daemon hosts multiple workspace runtimes. */
workspaces?: DaemonWorkspaceCapability[];
runtime: {
/** Present while the daemon runtime is still starting up. */
loading?: boolean;

View file

@ -63,6 +63,7 @@ export {
type DaemonTrustChangeRequestedEvent,
type DaemonWorkspaceInitializedData,
type DaemonWorkspaceInitializedEvent,
type DaemonWorkspaceCapability,
type DaemonAvailableCommand,
type DaemonCapabilities,
type DaemonEnvCell,

View file

@ -227,6 +227,38 @@ describe('DaemonClient', () => {
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
await expect(client.capabilities()).resolves.toEqual(envelope);
});
it('preserves multi-workspace capabilities metadata', async () => {
const envelope: DaemonCapabilities = {
v: 1,
mode: 'http-bridge',
features: ['health', 'capabilities', 'multi_workspace_sessions'],
limits: {
maxPendingPromptsPerSession: 5,
maxSessionsPerWorkspace: 20,
maxTotalSessions: null,
},
modelServices: [],
workspaceCwd: '/work/primary',
workspaces: [
{
id: 'primary-id',
cwd: '/work/primary',
primary: true,
trusted: true,
},
{
id: 'secondary-id',
cwd: '/work/secondary',
primary: false,
trusted: true,
},
],
};
const { fetch } = recordingFetch(() => jsonResponse(200, envelope));
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
await expect(client.capabilities()).resolves.toEqual(envelope);
});
});
describe('session artifacts', () => {