Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions .qwen/design/daemon-multi-workspace-phase2a-sessions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# Phase 2a Multi-Workspace Sessions Foundation

## Summary

This document records the Phase 2a foundation contract for issue #6378 after
the Phase 1 `WorkspaceRegistry` PR. The current implementation batch combines
the Phase 1 repeated `--workspace` follow-up, the Phase 2a prep guardrails, and
the first internal registry/runtime contract needed by later multi-workspace
session work.

Phase 2a remains sessions-only. It does not add plural routes, a
`WorkspaceDaemonClient`, workspace-qualified ACP/WebSocket, file, memory, MCP,
settings, voice, channel-worker migration, env overlays, total-session
admission, capabilities `workspaces[]`, `multi_workspace_sessions`, route
dispatch, or non-primary runtime construction in this foundation batch.

## Foundation Contract

- `--workspace` is repeatable at the CLI parser layer so yargs preserves array
input instead of collapsing it.
- The serve fast path falls back to the full parser when repeated workspace
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.

The internal `WorkspaceRuntime` contract now carries stable metadata for later
Phase 2a work:

- `workspaceId`: stable hash of the canonical workspace cwd.
- `workspaceCwd`: canonical workspace cwd.
- `primary`: true for the primary runtime.
- `trusted`: boot-time trust metadata; direct `createServeApp` fallback remains
false unless production passes an explicit trusted value.
- `env`: metadata only. This foundation batch records parent-process mode and
empty overlay keys; it does not compute runtime-local env overlays.

The internal `WorkspaceRegistry` supports exact cwd lookup, exact id lookup,
`resolveWorkspaceCwd(undefined)` primary fallback, and live session owner
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.

## Phase 2a Route Classification

The first ungated Phase 2a milestone must classify all `/session/:id/*` routes
before enabling multiple explicit workspaces.

Phase 2a-dispatched routes:

- `POST /session`
- `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`

Later or primary-only routes:

- non-primary `POST /session/:id/load`
- non-primary `POST /session/:id/resume`
- `GET /session/:id/export`
- `POST /sessions/delete`
- `POST /sessions/archive`
- `POST /sessions/unarchive`
- `PATCH /session/:id/organization`
- session-group mutations
- branch, fork, cd, rewind, shell, model, and language session mutations
- non-session `POST /permission/:requestId`
- `/acp`

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

- 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.
- Keep non-primary session listing live-only unless persisted entries are
explicitly marked non-resumable.
- Add runtime-local env overlays before non-primary child spawn.
- Add `maxTotalSessions` at the bridge fresh-creation seam so REST and primary
`/acp` cannot bypass it, while attach still bypasses admission.
- Publish `workspaces[]`, total limits, and `multi_workspace_sessions` only in
the final ungate PR.
- Update SDK capability types when the additive capabilities schema ships, but
do not add a workspace client in Phase 2a.

## Audit Decisions

- The foundation PR must not create non-primary runtimes or relax any REST
route.
- Existing `app.locals.boundWorkspace` and `app.locals.fsFactory` remain
primary-only compatibility locals.
- The REST `routeFileSystemFactory` remains distinct from bridge filesystem
factories; it must not be used to represent non-primary bridge boundaries.
- IDE secondary filesystem roots must not be promoted into explicit workspace
runtimes.
- Single-workspace parent-env behavior remains compatible until true
multi-workspace mode is ungated.
33 changes: 31 additions & 2 deletions packages/cli/src/commands/serve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,10 @@ describe('serve command args', () => {
expect(parsed['channel']).toEqual(['telegram', 'feishu']);
});

it('parses a single --workspace value as a string', () => {
it('parses a single --workspace value as a single-element array', () => {
const parsed = buildParser().parseSync('--workspace /tmp/primary');

expect(parsed['workspace']).toBe('/tmp/primary');
expect(parsed['workspace']).toEqual(['/tmp/primary']);
});

it('parses repeatable --workspace values as an array', () => {
Expand All @@ -107,6 +107,35 @@ describe('serve command args', () => {

expect(parsed['workspace']).toEqual(['/tmp/primary', '/tmp/secondary']);
});

it('rejects valueless --workspace forms', () => {
for (const input of [
'--workspace',
'--workspace=',
'--workspace /tmp/primary --workspace',
]) {
expect(() => buildParser().parseSync(input)).toThrow(
/Not enough arguments following: workspace/,
);
}
});

it('preserves repeatable --workspace values in command mode', () => {
let captured: unknown;
yargs([])
.exitProcess(false)
.fail(false)
.locale('en')
.command({
...serveCommand,
handler: (argv) => {
captured = argv.workspace;
},
})
.parseSync('serve --workspace /tmp/primary --workspace /tmp/secondary');

expect(captured).toEqual(['/tmp/primary', '/tmp/secondary']);
});
});

describe('serve rate limit env parsing', () => {
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@ export const serveCommand: CommandModule<unknown, ServeArgs> = {
})
.option('workspace', {
type: 'string',
array: true,
Comment thread
doudouOUC marked this conversation as resolved.
requiresArg: true,
description:
'Absolute workspace path this daemon binds to. ' +
'POST /session requests with a mismatched cwd return 400 workspace_mismatch. ' +
Expand Down
21 changes: 21 additions & 0 deletions packages/cli/src/serve/fast-path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,27 @@ describe('serve fast path argument parsing', () => {
});
});

it('falls back to the full parser for repeatable --workspace values', () => {
expect(
parseServeFastPathArgs([
'serve',
'--workspace',
'/tmp/primary',
'--workspace',
'/tmp/secondary',
]),
).toEqual({ kind: 'fallback' });
});

it('falls back to the full parser for empty --workspace values', () => {
expect(parseServeFastPathArgs(['serve', '--workspace='])).toEqual({
kind: 'fallback',
});
expect(parseServeFastPathArgs(['serve', '--workspace', ''])).toEqual({
kind: 'fallback',
});
});

it('parses Windows bundled entrypoint argv before serve', () => {
const parsed = parseServeFastPathArgs([
'C:\\repo\\dist\\cli.js',
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/serve/fast-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,12 @@ export function parseServeFastPathArgs(
const read = readOptionValue(argv, i, inlineValue);
if (!read) return { kind: 'fallback' };
i = read.nextIndex;
if (
stringTarget === 'workspace' &&
(options.workspace !== undefined || read.value === '')
) {
return { kind: 'fallback' };
}
setServeOption(options, stringTarget, read.value);
continue;
}
Expand Down
17 changes: 2 additions & 15 deletions packages/cli/src/serve/run-qwen-serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
preResolveServeFastPathHomeEnvOverrides,
type ServeFastPathSettings,
} from './fast-path-settings.js';
import { resolveSingleWorkspaceInput } from './workspace-inputs.js';
import type { AcpSessionBridge } from '@qwen-code/acp-bridge/bridgeTypes';
import { canonicalizeWorkspace } from '@qwen-code/acp-bridge/workspacePaths';
import type {
Expand Down Expand Up @@ -221,21 +222,6 @@ function envFlagDisabled(raw: string | undefined): boolean {
return normalized === '0' || normalized === 'false';
}

function resolveSingleWorkspaceInput(workspace: unknown): string {
if (Array.isArray(workspace)) {
if (workspace.length === 0) return process.cwd();
if (workspace.length > 1) {
throw new Error(
'Multiple --workspace values are not supported yet. ' +
'Multi-workspace serve is not enabled; pass one --workspace.',
);
}
return String(workspace[0]);
}
if (workspace === undefined) return process.cwd();
return String(workspace);
}

function hasChromeExtensionOrigin(origins: readonly string[] | undefined) {
return (
origins?.some((origin) =>
Expand Down Expand Up @@ -2546,6 +2532,7 @@ export async function runQwenServe(
pathLocks: sharedPathLocks,
...(customIgnoreFiles !== undefined ? { customIgnoreFiles } : {}),
}),
primaryWorkspaceTrusted: trustedWorkspace,
daemonLog,
getChannelWorkerSnapshot,
getPerfSnapshot: () => ({
Expand Down
Loading
Loading