Skip to content
Closed
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
c7f52e7
feat(serve): establish workspace runtime ownership
Jul 30, 2026
d3ac4c3
Merge branch 'main' into codex/workspace-runtime-foundation-v2
qwen-code-dev-bot Jul 31, 2026
59ec610
Merge branch 'main' into codex/workspace-runtime-foundation-v2
wenshao Jul 31, 2026
d9d491c
Merge branch 'main' into codex/workspace-runtime-foundation-v2
wenshao Jul 31, 2026
95216ea
Merge remote-tracking branch 'origin/main' into codex/workspace-runti…
qwen-code-ci-bot Jul 31, 2026
c4a9ccf
fix(acp-bridge): remove unreachable idle-timer branches (#8213)
qwen-code-ci-bot Jul 31, 2026
230ea2e
Merge branch 'main' into codex/workspace-runtime-foundation-v2
qwen-code-dev-bot Jul 31, 2026
d2641c4
test(cli): cover WorkspaceRuntimeStillStartingError 503 mapping (#8213)
Jul 31, 2026
57268b4
Merge branch 'main' into codex/workspace-runtime-foundation-v2
wenshao Aug 1, 2026
40771a3
Merge branch 'main' into codex/workspace-runtime-foundation-v2
wenshao Aug 1, 2026
b0d9f3d
Merge branch 'main' into codex/workspace-runtime-foundation-v2
wenshao Aug 2, 2026
7755079
chore: merge main into codex/workspace-runtime-foundation-v2
qwen-code-dev-bot Aug 2, 2026
90bbec5
Merge branch 'main' into codex/workspace-runtime-foundation-v2
wenshao Aug 2, 2026
82fee67
Merge branch 'main' into codex/workspace-runtime-foundation-v2
wenshao Aug 2, 2026
350741a
Merge branch 'main' into codex/workspace-runtime-foundation-v2
wenshao Aug 3, 2026
67d66c9
Merge branch 'main' into codex/workspace-runtime-foundation-v2
qwen-code-dev-bot Aug 3, 2026
c8c7782
fix(serve): address workspace-runtime review findings (#8213)
Aug 3, 2026
15c30e3
Merge branch 'main' into codex/workspace-runtime-foundation-v2
qwen-code-dev-bot Aug 3, 2026
c02bd06
Merge branch 'main' into codex/workspace-runtime-foundation-v2
wenshao Aug 3, 2026
8d07696
Merge branch 'main' into codex/workspace-runtime-foundation-v2
wenshao Aug 4, 2026
bc32ca1
Merge branch 'main' into codex/workspace-runtime-foundation-v2
Aug 4, 2026
d634ae6
Merge branch 'main' into codex/workspace-runtime-foundation-v2
qwen-code-dev-bot Aug 4, 2026
e34cce8
chore: merge main into workspace-runtime-foundation-v2
qwen-code-dev-bot Aug 5, 2026
1c3d08a
chore: merge main into workspace-runtime-foundation-v2
qwen-code-dev-bot Aug 12, 2026
b39737e
chore: merge main into workspace-runtime-foundation-v2
qwen-code-dev-bot Aug 12, 2026
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
26 changes: 13 additions & 13 deletions docs/design/daemon-multi-workspace-hardening.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,14 @@ fallback when resolution fails.

## Failure semantics

| State | Required behavior |
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Unknown workspace or session | Fail closed with the route's stable mismatch/not-found response. Do not probe or execute against primary. |
| Untrusted workspace | Reject runtime-backed execution and mutation. An untrusted secondary may use only explicitly documented read-only surfaces, including bounded filesystem and persisted catalog/transcript reads, without starting ACP or writing repair state. Legacy primary preheat does not authorize requests. |
| Ambiguous live-session owner | Return a server error because dispatch cannot be made safely. Execute on no bridge. |
| Bootstrapping runtime | Keep process-global liveness responsive; runtime-backed work waits for or reports the declared startup failure. Deep health returns `503` with a reason while aggregation is unavailable. |
| Draining runtime | Refuse new work with the stable draining response. A non-forced removal rolls back with `workspace_busy` if activity exists; a forced removal requests termination and bounded cleanup of active resources. The runtime remains in daemon-global accounting until removal completes. |
| Removed runtime | Treat it as unknown. It must disappear from capabilities, routing, and health aggregation before the same workspace can be re-added. Cleanup after the persistence commit point is best-effort; failures are logged and do not restore routing. |
| State | Required behavior |
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Unknown workspace or session | Fail closed with the route's stable mismatch/not-found response. Do not probe or execute against primary. |
| Untrusted workspace | Reject runtime-backed execution and mutation. An untrusted secondary may use only explicitly documented read-only surfaces, including bounded filesystem and persisted catalog/transcript reads, without starting ACP or writing repair state. Primary compatibility routing does not authorize requests. |
| Ambiguous live-session owner | Return a server error because dispatch cannot be made safely. Execute on no bridge. |
| Bootstrapping runtime | Keep process-global liveness responsive; runtime-backed work waits for or reports the declared startup failure. Deep health returns `503` with a reason while aggregation is unavailable. |
| Draining runtime | Refuse new work with the stable draining response. A non-forced removal rolls back with `workspace_busy` if activity exists; a forced removal requests termination and bounded cleanup of active resources. The runtime remains in daemon-global accounting until removal completes. |
| Removed runtime | Treat it as unknown. It must disappear from capabilities, routing, and health aggregation before the same workspace can be re-added. Cleanup after the persistence commit point is best-effort; failures are logged and do not restore routing. |

## Invariants

Expand All @@ -40,11 +40,11 @@ fallback when resolution fails.
be absolute and canonicalize to a registered runtime.
- Each active workspace runtime owns its environment snapshot, bridge, workspace
services, filesystem/trust boundary, Voice state, and ACP/MCP resource
boundary. Production attempts to preheat the primary bridge for compatibility
and retries on first use after a preheat failure. A trusted secondary starts
its ACP child on demand and, when `mcp_workspace_pool` is enabled, owns the
pool inside that child; an untrusted secondary must not start either. Primary
preheat does not bypass route trust gates. A
boundary. Production may preheat the trusted primary child for compatibility;
trusted secondaries start on first runtime-backed use. When
`mcp_workspace_pool` is enabled, each started child owns its pool; an untrusted
workspace must not start either. Primary compatibility routing does not bypass
route trust gates. A
process-global Voice coordinator enforces the shared admission cap while
tracking leases by owning runtime. Same-named environment keys must not cross
runtimes, and a workspace overlay must not mutate the parent process
Expand Down
34 changes: 18 additions & 16 deletions docs/design/session-idle-reaper/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ Bridge closure (createHttpAcpBridge)

| Mechanism | Scope | What it manages |
| ----------------------------------------- | ------------------------- | -------------------------------------------------------------------------------- |
| `channelIdleTimeoutMs` + `startIdleTimer` | Channel (child process) | Kills the `qwen --acp` child when ALL sessions are gone |
| `channelIdleTimeoutMs` + `startIdleTimer` | Channel (child process) | Unset or `0` reaps immediately; a positive value delays reap |
| **Session reaper** (this design) | Session (in-memory entry) | Closes individual sessions when idle |
| `ConnectionRegistry` sweep | ACP-over-HTTP connection | Reaps `/acp` transport-layer connections (different layer) |
| `writerIdleTimeoutMs` | SSE subscriber | Evicts a single stuck SSE subscriber |
Expand Down Expand Up @@ -309,6 +309,8 @@ function.

- `startSessionReaper()` is called at bridge construction time (after
option validation, alongside the existing `channelIdleTimeoutMs` setup).
Omitting that channel option or setting it to `0` reaps an idle Workspace
Runtime immediately; configured values must be non-negative.
- `stopSessionReaper()` is called in both `shutdown()` and `killAllSync()`.

### 4.6 Interaction with existing `closeSession` callers
Expand Down Expand Up @@ -367,20 +369,20 @@ generic terminal-frame handler (`isTerminalLifecycleEvent`) already handles

### 5.1 Unit tests (`bridge.test.ts`)

| # | Test | Description |
| --- | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | Idle session is reaped after timeout | Create a session, advance time past `sessionIdleTimeoutMs`, trigger reaper tick, verify session removed from `byId` and `session_closed` event published with `reason: 'idle_timeout'` |
| 2 | Session with active prompt is NOT reaped | Create a session, start a prompt, advance time, verify session survives reaper tick |
| 3 | Session with live SSE subscriber is NOT reaped | Create a session, subscribe to its EventBus, advance time, verify session survives |
| 4 | Session with registered client is NOT reaped | Create a session, register a clientId, advance time, verify session survives |
| 5 | Reaper disabled when interval = 0 | Pass `sessionReapIntervalMs: 0`, verify no `setInterval` is armed |
| 6 | Reaper disabled when timeout = 0 | Pass `sessionIdleTimeoutMs: 0`, verify no `setInterval` is armed |
| 7 | Reaper stopped on shutdown | Call `shutdown()`, verify `clearInterval` was called |
| 8 | closeSession reason defaults to 'client_close' | Call `closeSession` without explicit reason, verify published event has `reason: 'client_close'` |
| 9 | closeSession with explicit reason | Call `closeSession` with `reason: 'idle_timeout'`, verify published event |
| 10 | Multiple idle sessions reaped in one tick | Create 3 idle sessions, advance time, trigger tick, verify all 3 reaped |
| 11 | Session with heartbeat within TTL survives | Create a session, record heartbeat, advance time to just under TTL, verify session survives |
| 12 | Channel idle timer triggered after last session reaped | Create 1 session (last on channel), reap it, verify `startIdleTimer` is called on the channel |
| # | Test | Description |
| --- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | Idle session is reaped after timeout | Create a session, advance time past `sessionIdleTimeoutMs`, trigger reaper tick, verify session removed from `byId` and `session_closed` event published with `reason: 'idle_timeout'` |
| 2 | Session with active prompt is NOT reaped | Create a session, start a prompt, advance time, verify session survives reaper tick |
| 3 | Session with live SSE subscriber is NOT reaped | Create a session, subscribe to its EventBus, advance time, verify session survives |
| 4 | Session with registered client is NOT reaped | Create a session, register a clientId, advance time, verify session survives |
| 5 | Reaper disabled when interval = 0 | Pass `sessionReapIntervalMs: 0`, verify no `setInterval` is armed |
| 6 | Reaper disabled when timeout = 0 | Pass `sessionIdleTimeoutMs: 0`, verify no `setInterval` is armed |
| 7 | Reaper stopped on shutdown | Call `shutdown()`, verify `clearInterval` was called |
| 8 | closeSession reason defaults to 'client_close' | Call `closeSession` without explicit reason, verify published event has `reason: 'client_close'` |
| 9 | closeSession with explicit reason | Call `closeSession` with `reason: 'idle_timeout'`, verify published event |
| 10 | Multiple idle sessions reaped in one tick | Create 3 idle sessions, advance time, trigger tick, verify all 3 reaped |
| 11 | Session with heartbeat within TTL survives | Create a session, record heartbeat, advance time to just under TTL, verify session survives |
| 12 | Channel idle policy evaluated after last session reaped | Create 1 session (last on channel), reap it, verify an unset timeout reaps the channel and an explicit positive timeout arms the compatibility timer |

### 5.2 Integration tests (`server.test.ts`)

Expand Down Expand Up @@ -431,4 +433,4 @@ generic terminal-frame handler (`isTerminalLifecycleEvent`) already handles
| `closeSession` inside reaper throws, poisoning the scan loop | Each close is in its own `.catch()` — one failure doesn't block others |
| Reaper iteration over `byId` during concurrent `closeSession` from another path | ES2015 Map iteration tolerates deletion of current/previous keys. Double-close is idempotent (`byId.get` returns undefined → `SessionNotFoundError` caught by reaper's `.catch`). |
| Performance of scanning 20 sessions every 60s | Trivial — 20 Map reads + 4 field checks each. No I/O. |
| Channel idle timer interaction | When the last session is reaped, `closeSession` already calls `startIdleTimer` on the channel. No additional logic needed. |
| Channel idle timer interaction | When the last session is reaped, `closeSession` calls `startIdleTimer`; an unset or zero timeout reaps immediately, while an explicit positive timeout arms the timer. |
Loading