docs/design/daemon-multi-workspace-phase2a-sessions.md
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 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,
settings, voice, or channel-worker migration. PR 1 does not add capabilities
workspaces[], multi_workspace_sessions, route dispatch, or non-primary
runtime construction.
--workspace is repeatable at the CLI parser layer so yargs preserves array
input instead of collapsing it.workspaceCwd / app.locals.boundWorkspace compatibility fields.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: runtime-local env source metadata. In single-workspace production,
the primary runtime now receives a computed effective env snapshot and a
mutable env source that can be refreshed after daemon env reload. Direct
createServeApp fallback remains parent-process metadata.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.
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.
The first ungated Phase 2a milestone must classify all /session/:id/* routes
before enabling multiple explicit workspaces.
Phase 2a-dispatched routes:
POST /sessionGET /session/:id/eventsPOST /session/:id/promptPOST /session/:id/cancelPOST /session/:id/permission/:requestIdPOST /session/:id/heartbeatPOST /session/:id/detachGET /session/:id/pending-promptsDELETE /session/:id/pending-prompts/:promptIdDELETE /session/:idGET /session/:id/statusLater or primary-only routes:
POST /session/:id/loadPOST /session/:id/resumeGET /session/:id/exportPOST /sessions/deletePOST /sessions/archivePOST /sessions/unarchivePATCH /session/:id/organizationPOST /permission/:requestId/acpAdditional 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.
404 session_not_found; never fall back to primary.maxTotalSessions admission at every future fresh-creation seam
so REST and primary /acp cannot bypass it, while attach still bypasses
admission.workspaces[] and multi_workspace_sessions only after the
live session dispatch loop is complete..env, settings
env, and Cloud Shell defaults without mutating parent process.env during
runtime initialization.QWEN_HOME, Storage, or
global config routing. Those remain daemon boot/base-env responsibilities.sourceEnv, and low-cost
workspace-scoped status/config readers use injected env instead of direct
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. 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./daemon/status.limits.maxTotalSessions is additive. /capabilities and SDK
capability types remain unchanged until PR 2 ungates multi-workspace
sessions.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/eventsPOST /session/:id/promptPOST /session/:id/cancelPOST /session/:id/permission/:requestIdPOST /session/:id/heartbeatPOST /session/:id/detachGET /session/:id/pending-promptsDELETE /session/:id/pending-prompts/:promptIdDELETE /session/:idGET /session/:id/statusGET /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.
app.locals.boundWorkspace and app.locals.fsFactory remain
primary-only compatibility locals.routeFileSystemFactory remains distinct from bridge filesystem
factories; it must not be used to represent non-primary bridge boundaries.