From 43e6a9300ad06dda12bee5d1c8efdadb99f62dd6 Mon Sep 17 00:00:00 2001 From: jinye Date: Wed, 8 Jul 2026 18:06:31 +0800 Subject: [PATCH] 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 * fix(cli): address phase2a session review feedback Co-authored-by: Qwen-Coder * fix(cli): cover remaining phase2a review gaps Co-authored-by: Qwen-Coder * fix(cli): address phase2a session review feedback Co-authored-by: Qwen-Coder * fix(cli): satisfy phase2a lint checks Co-authored-by: Qwen-Coder * fix(cli): align multi-workspace status test limits Co-authored-by: Qwen-Coder * fix(cli): address phase2a session review feedback Co-authored-by: Qwen-Coder * codex: address PR review feedback (#6511) Co-authored-by: Qwen-Coder --------- Co-authored-by: Qwen-Coder --- ...daemon-multi-workspace-phase2a-sessions.md | 105 ++- docs/developers/daemon/00-index.md | 2 +- docs/developers/daemon/01-architecture.md | 4 +- docs/developers/daemon/02-serve-runtime.md | 4 +- .../daemon/11-capabilities-versioning.md | 7 +- docs/developers/daemon/15-channel-adapters.md | 2 +- .../daemon/16-vscode-ide-adapter.md | 18 +- docs/developers/daemon/17-configuration.md | 2 +- .../daemon/20-quickstart-operations.md | 54 +- .../examples/daemon-client-quickstart.md | 15 +- docs/developers/qwen-serve-protocol.md | 43 +- packages/cli/src/commands/serve.ts | 12 +- packages/cli/src/serve/capabilities.ts | 8 + packages/cli/src/serve/daemon-status.test.ts | 133 ++++ packages/cli/src/serve/daemon-status.ts | 181 ++++- .../serve/multi-workspace-sessions.test.ts | 736 ++++++++++++++++++ packages/cli/src/serve/routes/capabilities.ts | 38 +- .../cli/src/serve/routes/daemon-status.ts | 3 + packages/cli/src/serve/routes/permission.ts | 24 +- .../cli/src/serve/routes/session-runtime.ts | 91 +++ packages/cli/src/serve/routes/session.ts | 353 +++++++-- packages/cli/src/serve/routes/sse-events.ts | 16 +- packages/cli/src/serve/run-qwen-serve.test.ts | 442 ++++++++++- packages/cli/src/serve/run-qwen-serve.ts | 632 ++++++++++++--- packages/cli/src/serve/server.test.ts | 32 +- packages/cli/src/serve/server.ts | 16 +- .../cli/src/serve/server/serve-features.ts | 12 + packages/cli/src/serve/server/session-list.ts | 101 ++- packages/cli/src/serve/types.ts | 40 +- .../cli/src/serve/workspace-inputs.test.ts | 34 +- packages/cli/src/serve/workspace-inputs.ts | 24 +- .../daemon-mcp/serve-bridge/tools/session.ts | 2 +- .../sdk-typescript/src/daemon/DaemonClient.ts | 19 +- packages/sdk-typescript/src/daemon/index.ts | 1 + packages/sdk-typescript/src/daemon/types.ts | 32 +- packages/sdk-typescript/src/index.ts | 1 + .../test/unit/DaemonClient.test.ts | 32 + 37 files changed, 2918 insertions(+), 353 deletions(-) create mode 100644 packages/cli/src/serve/multi-workspace-sessions.test.ts create mode 100644 packages/cli/src/serve/routes/session-runtime.ts diff --git a/docs/design/daemon-multi-workspace-phase2a-sessions.md b/docs/design/daemon-multi-workspace-phase2a-sessions.md index 5ce013a919..3297f07953 100644 --- a/docs/design/daemon-multi-workspace-phase2a-sessions.md +++ b/docs/design/daemon-multi-workspace-phase2a-sessions.md @@ -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. diff --git a/docs/developers/daemon/00-index.md b/docs/developers/daemon/00-index.md index 5fed437a66..4c206b5640 100644 --- a/docs/developers/daemon/00-index.md +++ b/docs/developers/daemon/00-index.md @@ -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 diff --git a/docs/developers/daemon/01-architecture.md b/docs/developers/daemon/01-architecture.md index aad97e62a5..a2223c3916 100644 --- a/docs/developers/daemon/01-architecture.md +++ b/docs/developers/daemon/01-architecture.md @@ -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
(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
(packages/cli/src/serve/server.ts)"] BR["AcpBridge
(packages/acp-bridge/src/bridge.ts)"] MED["MultiClientPermissionMediator
(F3)"] diff --git a/docs/developers/daemon/02-serve-runtime.md b/docs/developers/daemon/02-serve-runtime.md index 6f99e6f9c5..d3adc3954c 100644 --- a/docs/developers/daemon/02-serve-runtime.md +++ b/docs/developers/daemon/02-serve-runtime.md @@ -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. | diff --git a/docs/developers/daemon/11-capabilities-versioning.md b/docs/developers/daemon/11-capabilities-versioning.md index 3e32b212a7..7969e5d822 100644 --- a/docs/developers/daemon/11-capabilities-versioning.md +++ b/docs/developers/daemon/11-capabilities-versioning.md @@ -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` diff --git a/docs/developers/daemon/15-channel-adapters.md b/docs/developers/daemon/15-channel-adapters.md index 28a0b487c0..298078f703 100644 --- a/docs/developers/daemon/15-channel-adapters.md +++ b/docs/developers/daemon/15-channel-adapters.md @@ -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 ` 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 diff --git a/docs/developers/daemon/16-vscode-ide-adapter.md b/docs/developers/daemon/16-vscode-ide-adapter.md index 743fb0f03d..98b6ff0ee3 100644 --- a/docs/developers/daemon/16-vscode-ide-adapter.md +++ b/docs/developers/daemon/16-vscode-ide-adapter.md @@ -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 diff --git a/docs/developers/daemon/17-configuration.md b/docs/developers/daemon/17-configuration.md index 4a3c5bfd39..47b1786095 100644 --- a/docs/developers/daemon/17-configuration.md +++ b/docs/developers/daemon/17-configuration.md @@ -12,7 +12,7 @@ This page collects every setting that affects the `qwen serve` daemon and its ad | `--port ` | number | `4170` | Listen port; `0` means ephemeral. | | `--token ` | 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 ` | absolute path | `process.cwd()` | Bound workspace. Must be absolute and a directory; canonicalized once at boot. | +| `--workspace ` | 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 ` | number | `20` | Active session cap. `0` / `Infinity` means unlimited; `NaN` / negative values throw. | | `--max-pending-prompts-per-session ` | 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 ` | number | `256` | HTTP listener `server.maxConnections`; `0` / `Infinity` means unlimited. | diff --git a/docs/developers/daemon/20-quickstart-operations.md b/docs/developers/daemon/20-quickstart-operations.md index 2b88b09565..18306e443c 100644 --- a/docs/developers/daemon/20-quickstart-operations.md +++ b/docs/developers/daemon/20-quickstart-operations.md @@ -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 ` | number | `4170` | - | TCP port; `0` means OS-assigned ephemeral port. | -| `--hostname ` | 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 ` | string | env / none | Non-loopback and `--require-auth` | Bearer token; trimmed once. **It appears in `/proc//cmdline`, so prefer `QWEN_SERVER_TOKEN`**. Boot stderr also warns about this. | -| `--max-sessions ` | number | `20` | - | Active session cap. Excess spawn returns 503. `0` means unlimited. `NaN` / negative values throw. | -| `--max-pending-prompts-per-session ` | 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 ` | 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 ` | 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 ` | 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 ` | number | none | Required for `mcp-budget-mode=enforce` | Workspace MCP client cap. Must be a positive integer. | -| `--mcp-budget-mode ` | `'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 ` | 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 ` | number | none | - | Server-side prompt wallclock limit in ms; timeout aborts the prompt. | -| `--writer-idle-timeout-ms ` | number | none | - | Per-SSE-connection idle timeout in ms. | -| `--channel-idle-timeout-ms ` | number | `0` | - | Keeps the ACP child alive after the last session closes. `0` means reclaim immediately. | -| `--session-reap-interval-ms ` | number | `60000` | - | Session reaper scan interval. `0` disables it. | -| `--session-idle-timeout-ms ` | 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 ` | number | `10` | `--rate-limit` | Prompt requests per window. | -| `--rate-limit-mutation ` | number | `30` | `--rate-limit` | Mutation requests per window. | -| `--rate-limit-read ` | number | `120` | `--rate-limit` | Read requests per window. | -| `--rate-limit-window-ms ` | number | `60000` | `--rate-limit` | Rate limit window length; must be `>= 1000`. | +| Flag | Type | Default | Required when | Effect | +| --------------------------------------- | ------------------------------ | -------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--port ` | number | `4170` | - | TCP port; `0` means OS-assigned ephemeral port. | +| `--hostname ` | 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 ` | string | env / none | Non-loopback and `--require-auth` | Bearer token; trimmed once. **It appears in `/proc//cmdline`, so prefer `QWEN_SERVER_TOKEN`**. Boot stderr also warns about this. | +| `--max-sessions ` | number | `20` | - | Active session cap. Excess spawn returns 503. `0` means unlimited. `NaN` / negative values throw. | +| `--max-pending-prompts-per-session ` | 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 ` | 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 ` | 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 ` | 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 ` | number | none | Required for `mcp-budget-mode=enforce` | Workspace MCP client cap. Must be a positive integer. | +| `--mcp-budget-mode ` | `'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 ` | 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 ` | number | none | - | Server-side prompt wallclock limit in ms; timeout aborts the prompt. | +| `--writer-idle-timeout-ms ` | number | none | - | Per-SSE-connection idle timeout in ms. | +| `--channel-idle-timeout-ms ` | number | `0` | - | Keeps the ACP child alive after the last session closes. `0` means reclaim immediately. | +| `--session-reap-interval-ms ` | number | `60000` | - | Session reaper scan interval. `0` disables it. | +| `--session-idle-timeout-ms ` | 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 ` | number | `10` | `--rate-limit` | Prompt requests per window. | +| `--rate-limit-mutation ` | number | `30` | `--rate-limit` | Mutation requests per window. | +| `--rate-limit-read ` | number | `120` | `--rate-limit` | Read requests per window. | +| `--rate-limit-window-ms ` | number | `60000` | `--rate-limit` | Rate limit window length; must be `>= 1000`. | ## 4. Environment variables diff --git a/docs/developers/examples/daemon-client-quickstart.md b/docs/developers/examples/daemon-client-quickstart.md index b7e245873a..27f4e46d7b 100644 --- a/docs/developers/examples/daemon-client-quickstart.md +++ b/docs/developers/examples/daemon-client-quickstart.md @@ -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'; diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index 0b710a7e37..15cfa4d2a4 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -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). diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index c48a553ff1..d94b182f2d 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -189,8 +189,8 @@ export const serveCommand: CommandModule = { '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 = { 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', diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index ea9b449ff0..dafb41f686 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -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], [ diff --git a/packages/cli/src/serve/daemon-status.test.ts b/packages/cli/src/serve/daemon-status.test.ts index e03233a200..3e013449dd 100644 --- a/packages/cli/src/serve/daemon-status.test.ts +++ b/packages/cli/src/serve/daemon-status.test.ts @@ -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', diff --git a/packages/cli/src/serve/daemon-status.ts b/packages/cli/src/serve/daemon-status.ts index 253b446aa9..fae04b71d6 100644 --- a/packages/cli/src/serve/daemon-status.ts +++ b/packages/cli/src/serve/daemon-status.ts @@ -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 { 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( + (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 | undefined, + sessions: BridgeDaemonStatusSnapshot['sessions'], ): Promise { 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( function pushRuntimeIssues( issues: DaemonStatusIssue[], - bridgeSnapshot: BridgeDaemonStatusSnapshot, acpSnapshot: ReturnType | undefined, rateLimitHits: Record, 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).`, }); } diff --git a/packages/cli/src/serve/multi-workspace-sessions.test.ts b/packages/cli/src/serve/multi-workspace-sessions.test.ts new file mode 100644 index 0000000000..b4cf9775d7 --- /dev/null +++ b/packages/cli/src/serve/multi-workspace-sessions.test.ts @@ -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 { + 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(); + }, + 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(); + }); +}); diff --git a/packages/cli/src/serve/routes/capabilities.ts b/packages/cli/src/serve/routes/capabilities.ts index 59c47b7e4e..a74c4a3c00 100644 --- a/packages/cli/src/serve/routes/capabilities.ts +++ b/packages/cli/src/serve/routes/capabilities.ts @@ -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; 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); diff --git a/packages/cli/src/serve/routes/daemon-status.ts b/packages/cli/src/serve/routes/daemon-status.ts index 750c2d9fec..468cd54964 100644 --- a/packages/cli/src/serve/routes/daemon-status.ts +++ b/packages/cli/src/serve/routes/daemon-status.ts @@ -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, diff --git a/packages/cli/src/serve/routes/permission.ts b/packages/cli/src/serve/routes/permission.ts index e724df59e4..9cfc343292 100644 --- a/packages/cli/src/serve/routes/permission.ts +++ b/packages/cli/src/serve/routes/permission.ts @@ -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, diff --git a/packages/cli/src/serve/routes/session-runtime.ts b/packages/cli/src/serve/routes/session-runtime.ts new file mode 100644 index 0000000000..27d973b31f --- /dev/null +++ b/packages/cli/src/serve/routes/session-runtime.ts @@ -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; +}): 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; +} diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 075e54b363..5a75da304b 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -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 = {}, + ): 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, + 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, + ): 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; 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, diff --git a/packages/cli/src/serve/routes/sse-events.ts b/packages/cli/src/serve/routes/sse-events.ts index d8857e01a5..3b8790119b 100644 --- a/packages/cli/src/serve/routes/sse-events.ts +++ b/packages/cli/src/serve/routes/sse-events.ts @@ -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 | 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 } : {}), diff --git a/packages/cli/src/serve/run-qwen-serve.test.ts b/packages/cli/src/serve/run-qwen-serve.test.ts index 60ca942d62..14179d0f96 100644 --- a/packages/cli/src/serve/run-qwen-serve.test.ts +++ b/packages/cli/src/serve/run-qwen-serve.test.ts @@ -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, + ) + .mockReturnValueOnce( + secondaryBridge as ReturnType, + ); + 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; + }, + ); + vi.spyOn(trustedFoldersRuntime, 'getWorkspaceTrustStatus').mockReturnValue({ + effective: { state: 'trusted' }, + } as ReturnType); + + 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; + }, + ); + vi.spyOn( + trustedFoldersRuntime, + 'getWorkspaceTrustStatus', + ).mockImplementation( + (_settings, workspace) => + ({ + effective: { + state: + canonicalizeWorkspace(workspace) === secondaryCwd + ? 'untrusted' + : 'trusted', + }, + }) as ReturnType, + ); + + 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) => { + 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; + }, + ); + vi.spyOn(settingsRuntime, 'reloadEnvironment').mockReturnValue({ + updatedKeys: ['QWEN_TEST_SECONDARY_ENV'], + removedKeys: [], + }); + vi.spyOn(trustedFoldersRuntime, 'getWorkspaceTrustStatus').mockReturnValue({ + effective: { state: 'trusted' }, + } as ReturnType); + 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( + (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); + vi.spyOn(trustedFoldersRuntime, 'getWorkspaceTrustStatus').mockReturnValue({ + effective: { state: 'trusted' }, + } as ReturnType); + + 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, + ) + .mockReturnValueOnce( + secondaryBridge as ReturnType, + ); + 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-')), diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index 5901655a7a..a2d8c77bbd 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -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(); + 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 | undefined; @@ -2001,6 +2095,19 @@ export async function runQwenServe( () => bridgeRef, () => runtimeStartupError, ); + const shutdownBridges = new WeakSet(); + 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 | 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) => 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 + | 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 => { 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', diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 9346cdc413..79b91e1afb 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -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 () => { diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index ac9d197493..252828b8ba 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -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, diff --git a/packages/cli/src/serve/server/serve-features.ts b/packages/cli/src/serve/server/serve-features.ts index a804f1b22c..c957db8822 100644 --- a/packages/cli/src/serve/server/serve-features.ts +++ b/packages/cli/src/serve/server/serve-features.ts @@ -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(), diff --git a/packages/cli/src/serve/server/session-list.ts b/packages/cli/src/serve/server/session-list.ts index db52851991..15d7d0454c 100644 --- a/packages/cli/src/serve/server/session-list.ts +++ b/packages/cli/src/serve/server/session-list.ts @@ -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, a: BridgeSessionSummary, @@ -453,6 +508,50 @@ export async function listWorkspaceSessionsForResponse( return { sessions, nextCursor }; } +export function listLiveWorkspaceSessionsForResponse( + bridge: AcpSessionBridge, + workspaceCwd: string, + options?: Pick, +): 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(); diff --git a/packages/cli/src/serve/types.ts b/packages/cli/src/serve/types.ts index eb40b9528d..d9c9535d58 100644 --- a/packages/cli/src/serve/types.ts +++ b/packages/cli/src/serve/types.ts @@ -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`. diff --git a/packages/cli/src/serve/workspace-inputs.test.ts b/packages/cli/src/serve/workspace-inputs.test.ts index cd30c06242..1c8577d51e 100644 --- a/packages/cli/src/serve/workspace-inputs.test.ts +++ b/packages/cli/src/serve/workspace-inputs.test.ts @@ -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, + ); + }); +}); diff --git a/packages/cli/src/serve/workspace-inputs.ts b/packages/cli/src/serve/workspace-inputs.ts index 632537fd99..93afcba431 100644 --- a/packages/cli/src/serve/workspace-inputs.ts +++ b/packages/cli/src/serve/workspace-inputs.ts @@ -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(); 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]!; } diff --git a/packages/sdk-typescript/src/daemon-mcp/serve-bridge/tools/session.ts b/packages/sdk-typescript/src/daemon-mcp/serve-bridge/tools/session.ts index f50bc09ff2..4336c4c523 100644 --- a/packages/sdk-typescript/src/daemon-mcp/serve-bridge/tools/session.ts +++ b/packages/sdk-typescript/src/daemon-mcp/serve-bridge/tools/session.ts @@ -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() diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index 27b2bf4221..f0d101ec94 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -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 { // 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. // diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index 7e98acbf85..0b5c856347 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -364,6 +364,7 @@ export type { DaemonWorkspaceTrustSource, DaemonWorkspaceTrustState, DaemonWorkspaceTrustStatus, + DaemonWorkspaceCapability, DaemonAvailableCommand, DaemonArchiveSessionsResult, DaemonCapabilities, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 791a962f3f..708d0ebfcd 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -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; diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index 1433cd4374..aea123677d 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -63,6 +63,7 @@ export { type DaemonTrustChangeRequestedEvent, type DaemonWorkspaceInitializedData, type DaemonWorkspaceInitializedEvent, + type DaemonWorkspaceCapability, type DaemonAvailableCommand, type DaemonCapabilities, type DaemonEnvCell, diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index 555132521c..8995a2f815 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -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', () => {