Skip to content

feat(serve): establish workspace runtime ownership - #7308

Draft
ytahdn wants to merge 16 commits into
QwenLM:mainfrom
chiga0:codex/workspace-runtime-foundation
Draft

feat(serve): establish workspace runtime ownership#7308
ytahdn wants to merge 16 commits into
QwenLM:mainfrom
chiga0:codex/workspace-runtime-foundation

Conversation

@ytahdn

@ytahdn ytahdn commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

This PR introduces workspace-owned runtime coordination for qwen serve. ACP lifecycle and capability state now belong to the registered workspace instead of the last active session, with explicit runtime status, startup, reconciliation, and idle cleanup behavior.

The following lifecycle changes are intentional:

  • The existing ACP idle-reaping default is preserved: omitting channelIdleTimeoutMs or passing explicit 0 reaps the ACP child immediately after all workspace runtime work drains.
  • Operators that want management requests to reuse one live ACP runtime must explicitly configure a positive --channel-idle-timeout-ms window. Runtime-control leases protect initialization and each request from mid-operation cleanup; reaping begins only after the request completes and all physical work drains.
  • qwen serve no longer eagerly preheats the primary ACP child by default. The first session or runtime-management request starts it, so the first request may pay the cold-start cost.
  • This foundation PR contains only generic workspace runtime lifecycle and capability coordination. MCP-specific runtime routes, operation tracking, and authentication serialization belong to the follow-up MCP PR (feat(serve): manage MCP through workspace runtimes #7309).

This is stack 1/4 and is based on main.

Why it's needed

Extensions, MCP, Skills, and Tools management need a stable owner even when no chat session exists. The workspace runtime provides that owner while sessions become consumers of the reusable ACP-backed runtime.

Reviewer Test Plan

How to verify

Confirm a registered workspace can be prepared without creating a session and reports one runtime epoch with per-capability state. Verify an omitted or zero channel idle timeout reaps the ACP child after runtime work drains, while a positive timeout reuses the same child for later sessions and management requests.

Evidence (Before & After)

Before: ACP lifetime followed session attachment. After: ACP-backed capability state is owned by the workspace runtime.

Tested on

OS Status
🍏 macOS ✅ tested
🪟 Windows ⚠️ not tested
🐧 Linux ⚠️ not tested

Environment (optional)

Local npm workspace. ACP bridge and CLI type checks passed; targeted runtime, bridge, management, and serve unit tests passed. End-to-end validation was not run.

Risk & Scope

  • Main risk or tradeoff: ACP ownership moves from the last Session to the Workspace Runtime, while the existing default resource behavior remains immediate cleanup after all runtime work drains. A configured positive timeout trades one retained ACP child per live workspace for faster repeated management requests.
  • Not validated / out of scope: feature-specific Extensions, MCP, and Skills management behavior, which is covered by follow-up PRs in this stack. MCP runtime routes and operation/auth coordination are intentionally deferred to feat(serve): manage MCP through workspace runtimes #7309.
  • Breaking changes / migration notes: primary ACP startup is lazy. The channel idle timeout wire value remains numeric; omitted and explicit 0 both mean immediate cleanup, and positive values delay cleanup. Persisted feature configuration formats do not change.

Linked Issues

Stack 1/4: #7308 Runtime foundation#7309 MCP → #7310 Extensions → #7311 Skills.

中文说明

摘要

本 PR 为 qwen serve 引入工作区持有的 Runtime 协调层。ACP 生命周期和能力状态改由已注册工作区持有,而不是由最后一个活动 Session 持有,并提供明确的 Runtime 状态、启动、协调和空闲回收行为。

以下生命周期变化均为有意设计:

  • 保持既有 ACP 空闲回收默认行为:未配置 channelIdleTimeoutMs 或显式传入 0 时,工作区 Runtime 的全部任务排空后立即回收 ACP 子进程。
  • 如果希望管理页面的多次请求复用同一个 ACP Runtime,需要显式配置正数 --channel-idle-timeout-ms。runtime-control lease 会覆盖初始化和整次请求,只有请求完成且所有物理任务排空后才会开始回收。
  • qwen serve 默认不再预热主工作区 ACP。第一个 Session 或 Runtime 管理请求会按需启动它,因此首次请求可能承担冷启动耗时。
  • 本基础 PR 只保留通用 Workspace Runtime 生命周期和 capability 协调;MCP 专属 Runtime 路由、operation 跟踪和认证串行化放在后续 MCP PR(feat(serve): manage MCP through workspace runtimes #7309)。

这是堆叠 PR 的第 1/4 个,基于 main

为什么需要

拓展、MCP、Skills 和 Tools 管理需要在没有聊天 Session 时仍有稳定的状态所有者。Workspace Runtime 提供该所有权,Session 则变成可复用 ACP Runtime 的消费者。

审查测试计划

如何验证

确认已注册工作区无需创建 Session 即可准备服务,并能报告 Runtime epoch 和各能力状态。验证未配置或配置为 0 时在 Runtime 任务排空后立即回收 ACP 子进程;配置正数时,后续 Session 和管理请求会复用同一个子进程。

前后对比证据

改动前:ACP 生命周期跟随 Session。改动后:ACP 支撑的能力状态由 Workspace Runtime 持有。

测试平台

OS 状态
🍏 macOS ✅ 已测试
🪟 Windows ⚠️ 未测试
🐧 Linux ⚠️ 未测试

环境(可选)

本地 npm workspace。ACP bridge 和 CLI 类型检查通过,针对 Runtime、bridge、管理和 serve 的单元测试通过;未执行端到端验证。

风险与范围

  • 主要风险或取舍:ACP 所有权从最后一个 Session 迁移为 Workspace Runtime,但默认资源行为仍是所有 Runtime 任务排空后立即回收。配置正数超时会为每个存活工作区保留一个 ACP 子进程,以换取管理请求的复用速度。
  • 未验证或不在范围内:拓展、MCP 和 Skills 的具体管理行为,由后续堆叠 PR 覆盖。MCP Runtime 路由及 operation/认证协调明确放在 feat(serve): manage MCP through workspace runtimes #7309
  • 破坏性改动或迁移说明:主工作区 ACP 改为按需启动。channel idle timeout 的协议值仍为数字;未配置和显式 0 都表示立即回收,正数表示延迟回收。已有功能配置格式不变。

关联事项

堆叠 PR 第 1/4 个:#7308 Runtime 基础架构#7309 MCP → #7310 拓展 → #7311 Skills。

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

🩺 serve daemon A/B

Built the PR base vs this PR head 476207b, drove a fixed endpoint set against each, and diffed the JSON responses. Only fields that changed are shown.

No response changes against the PR base across 4 scenario(s).

Qwen Code · serve A/B

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: this is architectural groundwork, not a bug fix. The motivation is clear and observed: qwen serve currently ties ACP lifecycle to session attachment, so Extensions/MCP/Skills/Tools management has no stable owner when no chat session exists. The PR description states the before/after plainly — ACP lifetime followed session attachment; now capability state is owned by the workspace runtime. This is a real structural gap that the follow-up stack (MCP → Extensions → Skills) depends on.

Direction: aligned. The daemon's multi-workspace architecture needs a session-independent runtime owner, and this PR establishes that cleanly. The design doc (workspace-runtime-architecture.md) is included. CHANGELOG has no direct reference but the area is core to the serve daemon's evolution.

Size: 3744 production logic lines + 4145 test lines + 1056 docs lines across 45 files. This PR spans 3 packages (acp-bridge, cli, sdk-typescript), which qualifies as cross-package core infrastructure. As a feat-type PR it is not hard-blocked, but the 500+ production line threshold triggers maintainer awareness escalation. The 1000+ large-PR advisory also applies — splitting the bridge changes from the coordinator might have made each piece easier to review, though the scope feels justified for a foundational layer that three follow-up PRs build on.

Approach: the architecture is sound — a WorkspaceRuntimeCoordinator managing per-capability lifecycle (extensions → mcp/skills/tools), epoch-fenced state transitions, physical lane serialization, and drain/dispose semantics. The bridge changes (removing emptyReapPending, adding runtimeEpoch, converting startIdleTimer to sync, changing channelIdleTimeoutMs semantics from 0=immediate to unset=disabled) are all necessary for the new ownership model. Three new commits since the last review pass harden the implementation: fix(serve): harden runtime timeout recovery adds a per-clientId attach-ref ledger (preventing duplicate/unknown detaches from stealing attach counts), a closing flag on session entries (preventing attach/prompt/rewind races during teardown), and shouldStartIdleTimer/hasNoChannelWorkForReset for correct idle-timer behaviour during spawn/restore reset. fix(serve): isolate runtime error formatting extracts errorMessage into a shared module and adds SessionWriterError handling with proper HTTP status codes. docs(serve): clarify OAuth authentication lane is doc-only. Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题:这是架构层面的基础工作,不是 bug 修复。动机清晰且已观测到:qwen serve 目前将 ACP 生命周期绑定到 Session,导致在没有聊天 Session 时 Extensions/MCP/Skills/Tools 管理没有稳定的状态所有者。PR 描述清楚地说明了前后变化——ACP 生命周期跟随 Session;现在能力状态由 Workspace Runtime 持有。这是后续堆叠 PR(MCP → Extensions → Skills)依赖的真实结构性缺口。

方向:对齐。守护进程的多工作区架构需要一个独立于 Session 的 Runtime 所有者,本 PR 干净地建立了这一点。设计文档(workspace-runtime-architecture.md)已包含。CHANGELOG 无直接引用,但该领域是 serve 守护进程演进的核心。

规模:3744 行生产逻辑 + 4145 行测试 + 1056 行文档,跨 45 个文件。本 PR 跨越 3 个包acp-bridgeclisdk-typescript),符合跨包核心基础设施标准。作为 feat 类型 PR 不被硬性阻止,但 500+ 生产行阈值触发维护者知会升级。1000+ 大 PR 建议也适用——将 bridge 变更与 coordinator 拆分可能更容易审查,但对于三个后续 PR 依赖的基础层,范围合理。

方案:架构合理——WorkspaceRuntimeCoordinator 管理每能力生命周期(extensions → mcp/skills/tools),epoch 围栏状态转换,物理通道串行化,以及 drain/dispose 语义。自上次审查以来新增三个提交加固了实现:fix(serve): harden runtime timeout recovery 添加了按 clientId 的 attach-ref 账本(防止重复/未知 detach 窃取 attach 计数)、Session 条目上的 closing 标志(防止 teardown 期间的 attach/prompt/rewind 竞态)以及 shouldStartIdleTimer/hasNoChannelWorkForReset 用于 spawn/restore 重置期间的正确空闲计时器行为。fix(serve): isolate runtime error formattingerrorMessage 提取到共享模块并添加 SessionWriterError 处理。docs(serve): clarify OAuth authentication lane 仅为文档变更。进入代码审查 🔍

Qwen Code · qwen3.7-max

Reviewed at 3a3bf9c438a540775200e3e8edf392b6ba08b9aa · re-run with @qwen-code /triage

@wenshao

wenshao commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Review: workspace runtime ownership (stack 1/4)

Overview

Moves ACP lifecycle and capability state ownership from "last active session" to the registered workspace:

  • workspace-runtime-coordinator.ts (new, 1252 lines) — per-capability (extensions/mcp/skills/tools) state machine with logical revisions, runtime epochs, physical serialization lanes, background resume, drain/dispose.
  • workspace-runtime-mcp-operations.ts (new, 554 lines) — MCP operation tracking and the OAuth lease.
  • routes/workspace-runtime.ts (new, 326 lines) — /workspace/runtime/* and /workspaces/:workspace/runtime/*.
  • acp-bridge/bridge.ts (+701/-388) — runtime-epoch plumbing, workspace control channel, idle-timer rework.

The layering is sound and the direction is right. Trust gating in particular is correct and fail-closed: requireTrustedWorkspaceRuntime runs per-request before any coordinator call on both mounts, the qualified mount resolves through resolveWorkspaceRuntimeFromParam (active-only), mutate({ strict: true }) is on every POST, and :server is constrained by validateMcpRuntimeServerName, so no traversal. Coordinator dispose is wired for both workspace_removed and daemon_shutdown.

Verified against the PR branch: npm run typecheck pass · eslint clean on the new files · new coordinator + route tests 76/76 · run-qwen-serve/server/multi-workspace-sessions/workspace-management/daemon-status 1130/1130 · bridge.test.ts 430/430.


Should fix before merge

1. Undocumented breaking change to channelIdleTimeoutMs

Two behavior flips, neither called out beyond "runtime lifecycle semantics change".

(a) 0 now hard-fails daemon startup. The previous CLI help documented it as the default: '0 or unset = immediate kill (default).' Validation changed from < 0 to <= 0 at run-qwen-serve.ts:2375-2382 and bridge.ts:1293-1302, and commands/serve.ts:588 passes the flag through with no yargs .check(). An existing systemd unit or Docker CMD carrying --channel-idle-timeout-ms 0 now crashes the daemon with a raw TypeError instead of booting. Suggest accepting 0 and mapping it to immediate reap.

(b) The default flips from "reap immediately" to "never reap". Previously unset → 0startIdleTimer called killChannelWithLog synchronously. Now unset → nullbridge.ts:1467-1470 returns early, so the qwen --acp child is retained indefinitely. Defensible given the PR's thesis, but every multi-workspace deployment that didn't set the flag goes from 0 retained Node children to one per registered workspace, permanently. Worth an explicit release note.

2. MCP operation conflicts return 500, not 409

workspace-runtime-mcp-operations.ts:150-162 throws an untyped Error for both conflict cases. sendBridgeError has no branch for it, so it falls through to error-response.ts:721res.status(500), plus a stack trace to stderr via recordDaemonBridgeError. Every sibling conflict in this codebase is a typed error mapped to 409/503.

Two overlapping POST .../runtime/mcp/foo/approve calls make the second a 500 — the client can't distinguish a retryable conflict from a daemon fault, and each is recorded as a genuine 5xx.

3. cancelDrain() is the only rollback gate without a try/catch

routes/workspace-management.ts:727-730:

if (runtimeCoordinatorDraining) {
  getWorkspaceRuntimeCoordinator(runtime).cancelDrain();   // <-- bare
  runtimeCoordinatorDraining = false;
}

Its three neighbours (acpDraining, controllerDraining, registryDraining) each wrap their call with a // Continue rolling back the remaining gates. catch. cancelDrain() reaches into bridge.isChannelLive() / getRuntimeEpoch() via reconcileCapability on a bridge that may be tearing down.

If it throws, workspaceRegistry.cancelDrain(runtime) never runs and the runtime is stranded in draining for the process lifetime — it disappears from list()/getByWorkspaceId()/getByWorkspaceCwd(), so every route 400s workspace_mismatch until restart. Since rollbackDrain() is also called from the outer catch at :880, the throw escapes into Express's default handler instead of the JSON workspace_runtime_removal_failed.

4. Cross-workspace path disclosure in the OAuth-lane error

workspace-runtime-mcp-operations.ts:83 is a module-level global shared by every workspace in the daemon. When held, :156-161 throws a message embedding the other workspace's workspaceCwd, and sendBridgeError echoes err.message into the response body.

So a caller hitting POST /workspaces/B/runtime/mcp/x/authenticate learns that workspace A exists and its absolute filesystem path. Serializing OAuth process-wide is correct (the callback listener is process-global), but the client-facing message shouldn't name the other workspace — keep the detail in the daemon log and return a generic mcp_authentication_lane_busy.

As a module global with no reset hook, it's also a test-isolation hazard (see item 7).


Should fix, lower urgency

5. X-Qwen-Client-Id is not parsed on the new MCP mutation routes

routes/workspace-runtime.ts:213,219 hardcodes manageMcpServer's originator arg to undefined. The sibling file workspace-mcp-control.ts calls parseAndValidateWorkspaceClientId at four sites for the reason documented in server/request-helpers.ts:249-255. Consequence: a Web Shell client gets its own mcp_changed SSE envelope echoed back with no originatorClientId to self-filter on, causing a redundant refresh that /workspace/mcp/:server/approve avoids; and a bogus client id is silently accepted instead of 400 invalid_client_id.

6. 200 where siblings use 202

POST /runtime/ensure (:69) and POST /runtime/mcp/reload (:159) return 200 for work that hasn't completed — prepare() resolves with this.status() when the wait deadline expires while the operation keeps running to startedAt + MAX_PREPARE_TIMEOUT_MS (120 s). The sibling POST /workspace/mcp/reload returns 202 for strictly less-async work (workspace-mcp-control.ts:60,73,291,311). A client treating 200 as "done" gets capabilities.mcp.state: "starting" with no protocol signal that it must poll /runtime/status; timeoutMs shortens the reply, never the work.

Knock-on: hasActiveWork() now feeds activity.workspaceRuntime in workspace-management.ts:602-604, so a non-forced DELETE /workspaces/:id issued after a 200-but-still-starting ensure gets 409 workspace_busy for up to 120 s with no API to cancel — only ?force=true gets through.

7. Test coverage gaps

The tests are real, not theater — both new files build fakes at the WorkspaceRuntime boundary and let the actual state machine run, with no vi.mock() anywhere, and the epoch-invalidation / physical-lane / drain-rollback tests are genuinely sharp. The problem is asymmetry: happy paths and ordering invariants are over-tested while failure paths are largely absent.

  • workspace-runtime-mcp-operations.ts has no direct test file. 554 lines — the OAuth lease, the process-global lane, monitorAuthentication, releaseAuthenticationWhenSafe, pruneOperations — reachable only indirectly via the coordinator.
  • The OAuth success path is never exercised. monitorAuthentication sets succeeded = true (:430) → state: 'succeeded' (:454). Both fake-timer OAuth tests flip authenticationState to 'succeeded' only after advanceTimersByTimeAsync(10*60_000), so timedOut is already true and the timeout branch runs instead. The headline happy path of the feature has no coverage. Same for mcp_authentication_failed (:424) and the non-authenticate mcp_operation_failed catch (:259).
  • Four bridge.test.ts "keeps the channel alive…" tests assert nothing (:978, :1003, :1042, :1082). makeBridge (internal/testUtils.ts:93) passes no channelIdleTimeoutMs, so it resolves to null and startIdleTimer returns immediately — expect(handle.killed).toBe(false) is unconditionally true. :978 advances 300,001 ms against a disarmed timer. These would still pass if all of hasNoChannelWork's in-flight bookkeeping were deleted. The sibling at :1115 shows the correct form (channelIdleTimeoutMs: 60_000).
  • routes/workspace-runtime.test.ts:399 "strict-gates every runtime command" tests 2 of 7 mutating routes; a missing mutate({strict:true}) on mcp/reload, restart, authenticate, or clear-auth ships green.
  • sendBridgeError is stubbed to throw error in 10 of 12 route tests, so every catch arm is unverified. 9 of 13 routes have no coverage, and there is no createServeApp-level wiring test (grep -c "workspace/runtime" server.test.ts → 0).
  • No afterEach in either new file; ~54 coordinators outlive their tests with real-timer loops still running, and the module-global auth lane is never reset — the file is order-dependent and not --shuffle-safe.
  • Real-timer flake risk given 959ca8579 just landed for exactly this: a literal setTimeout(resolve, 300) sleep at coordinator.test.ts:1649, a 500 ms "deadlocked" tripwire at :1690, and prepare([...], 25)/(…, 10) at :301/:545/:1045 that assert an intermediate 'starting' state which a >25 ms event-loop stall flips to 'ready'.

8. An abandoned OAuth flow pins the workspace runtime permanently

monitorAuthentication:415-418 sets timedOut = true at the 10-minute deadline but continues — the loop only exits when authenticationState leaves 'pending', the channel dies, or the epoch changes. The comments at :440-442 and bridge.ts:7461-7465 make clear this is deliberate (a late callback may still own the process-global listener), and coordinator.test.ts:1573 asserts it.

Flagging the consequence, not the mechanism. If the user closes the browser and the child never clears pending, then indefinitely: the operation stays waiting_for_input, so bridge.ts:1491 keeps hasNoChannelWork false and the idle reaper can never reclaim that workspace; the global lane blocks authenticate for every workspace; and authenticationBarrier is never released, so runInCapabilityPhysicalLane('mcp', …) blocks all non-bypass MCP lane work for that workspace. The only recovery is killing the ACP child — the PR adds GET /runtime/operations/:operationId but no cancel. Is a DELETE/cancel planned later in the stack? Worth documenting the escape hatch either way.


Cleanups

Coordinator state is spread across six parallel maps. capabilityStatus, inFlight, capabilityPhysicalTail, backgroundResume, capabilityRevision, capabilityEpoch are all keyed by the same enum with invariants maintained in lockstep across ~30 methods (updateExtensionsDesiredGeneration:397-413 mutates four in one loop). A single Map<Capability, CapabilityState> would localise the invariants and remove most of the .get(x) === y guards. Not a correctness objection, but three more PRs build on this file.

~42 lines duplicated verbatim. workspace-runtime-coordinator.ts:60-106 and workspace-runtime-mcp-operations.ts:40-81 are byte-identical (requestContext, message, wait, WorkspaceRuntimeStillStartingError, waitUntilDeadline). Note this declares two distinct classes with the same name — an instanceof check in one module silently won't match an instance thrown by the other. No live path triggers it today, but prepare()'s catch at :893 rethrows anything that isn't the coordinator's own copy. Please extract to a shared module.

Eight exported coordinator methods have zero production callersrunManagementOperation, acquireManagementOperation, runExtensionsPhysicalReconciliation, setExtensionsDesiredGeneration, beginExtensionsReconciliation, setExtensionsAppliedGeneration, failExtensionsReconciliation, reconcileSkillsConfiguration (grepped repo-wide, excluding tests). Roughly 20 of the 64 coordinator tests exercise an API nothing calls. Presumably PRs 2-4 consume them, but landing the surface early cuts against the AGENTS.md "nothing speculative" rule — consider deferring to the PR that uses each one.

Smaller items:

  • workspace-runtime-coordinator.ts:507this.extensionsDesiredGeneration ??= generation; is unreachable; the guard at :501 already returned unless the field equals attempt.generation.
  • workspace-service/index.ts:352-356 — the channelLive() fast path moved inside if (!preheatAcpChildOnBridge). Preheat is wired in production, so on an already-live channel POST /workspace/acp/preheat no longer short-circuits: it awaits bridge.preheat()ensureChannel() and startIdleTimer(ci), meaning every preheat now resets the reap countdown. Intentional?
  • run-qwen-serve.ts:816-818shouldPreheatBridge is now deps.preheatBridge === true and nothing in production sets it. Worth annotating as test-only so it isn't mistaken for a live path.
  • docs/design/workspace-runtime-architecture.md (905 lines) is entirely in Chinese while the developer docs it cross-references are English. Bilingual design docs exist in that directory, but for the architectural spec three follow-up PRs depend on, an English version (or a bilingual split like this PR body uses) would help reviewers. The filename also skips the YYYY-MM-DD- prefix every other file there uses.

Process

Per AGENTS.md, this is an external, cross-package change at ~3050 production logic lines added (excluding tests, docs, generated) — roughly 3x the 1000-line non-blocking advisory threshold. Not hard-blocked (that tier applies to refactor, not feat), but it warrants explicit maintainer awareness before merge.

AGENTS.md also asks for an E2E test plan in .qwen/e2e-tests/ for user-observable behavior changes. This PR adds a REST surface, changes CLI flag validation, and changes idle-reap defaults, but the diff contains no such plan, and the body states E2E validation was not run with only macOS tested. Given item 1 is a startup-path change, a Linux smoke test before merge would be worthwhile.


Summary

Architecture, layering, and trust gating are good, and everything is green locally. Items 1-4 are what I'd want resolved before merge — 1 silently breaks existing deployments on a previously documented flag value, 2 and 3 are small and mechanical, and 4 crosses a workspace isolation boundary the hardening doc explicitly draws. Items 5-8 are follow-ups I'd rather see here than deferred into the stack, especially the OAuth-success test gap and the four vacuous bridge tests. The cleanups get materially more expensive once three more PRs land on top of this coordinator.

@wenshao

wenshao commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Addendum: two acp-bridge findings

Follow-up to my review above after a closer pass over bridge.ts (+701/-388). Both of these are more serious than anything in the original list, and both sharpen items 1(b) and 8 there.

A. The MCP authentication lease has only one reachable release path in production, and the coordinator's release loop can't trigger it

bridge.ts:7419-7423 acquires the lease before the request, and the await withTimeout(physicalRequest, ...) at :7443 can throw. There is no finally. The lease is deleted in exactly four places:

site reachable in production?
bridge.ts:2002mcpAuthenticationCompleted notification handler No producer exists
bridge.ts:7497 — non-pending success branch Only on success
bridge.ts:2094.clear() on channel death / shutdown Only on teardown
bridge.ts:3127 — status poll Requires server !== undefined && authenticationState !== 'pending'

On the first row: grep -rn "authentication-completed\|mcpAuthenticationCompleted" across the repo returns the constant (status.ts:238), the consumer (bridgeClient.ts:1239), and two emissions, both in bridge.test.ts (:445, :480). packages/cli/src/acp-integration/acpAgent.ts has zero operationId references — the child is handed operationId at bridge.ts:7435 and ignores it entirely, and this PR doesn't touch acp-integration/. So bridge.test.ts:456 ("keeps an MCP authentication lease when the entry request fails before pending") passes by emitting a notification that nothing in production emits.

That leaves the status poll at :3127 as the only real release path for a failed authenticate — and the coordinator's own release loop cannot reach it. workspace-runtime-mcp-operations.ts:522-528:

const physicalPending = this.runtime.bridge.isWorkspaceMcpAuthenticationPending?.(operationId);
if (physicalPending === false) break;
if (physicalPending === true) {
  await wait(MCP_POLL_INTERVAL_MS);
  continue;              // <-- the getWorkspaceMcpStatus call below is unreachable while the lease is held
}

While the lease is held the loop continues before the status call, so it spins without ever performing the poll that would clear it. On the pending path monitorAuthentication polls status independently and breaks the cycle — but the entry-failure path (failAuthenticationWhenSafereleaseAuthenticationWhenSafe) has no such poller.

Failure: POST .../runtime/mcp/:server/authenticate, the child rejects or the deadline elapses. The lease is retained; hasNoChannelWork() is false (bridge.ts:1491), so hasActiveWorkspaceWork() stays true, the coordinator reports 'active' and never 'idle', the idle reaper can never reclaim the workspace, and a 4 Hz busy loop runs per failed operation with releaseOperation() at the bottom never reached — leaking the coordinator's operation slot too.

Not strictly permanent: any unrelated client hitting GET /workspace/mcp triggers requestWorkspaceStatus:3127 and clears it. But recovery depends on incidental external traffic, which isn't a lifecycle contract. Suggest a finally that releases the lease on entry failure, and reordering releaseAuthenticationWhenSafe so the status poll runs even when the lease reads pending.

Related, low severity: bridgeClient.ts:1237-1251 silently drops the notification unless params['v'] === 1 and early-returns either way. Fine today, but if that handler ever becomes the real release path, a producer omitting v leaks the lease with zero diagnostics — worth a writeStderrLine on the reject path.

B. killChannelWithLog is now unreachable under the default config

This sharpens item 1(b) — the default flip is not just resource retention, it removes the last self-healing path for a wedged child.

killChannelWithLog went from two callers to one:

OLD: :1463  await killChannelWithLog(ci, context)   // the timeoutMs <= 0 immediate-kill branch
     :1473  void  killChannelWithLog(ci, 'idle timeout')
NEW: :1479  void  killChannelWithLog(ci, 'idle timeout')   // only caller

The surviving caller sits after startIdleTimer's if (timeoutMs === null) { cancelIdleTimer(); return; } guard, and null is now the default. So with no --channel-idle-timeout-ms set, nothing in the bridge can kill a channel except shutdown()/killAllSync(). The session-teardown paths that previously did (last session leaving, newSession failure on an empty channel, failed restore) are all deleted in this PR.

Failure: ci.connection.newSession(...) at :2303 is guarded only by withTimeout(initTimeoutMs) — it is not raced against getChannelClosedReject. If the child wedges (blocking prompt, stuck parse), the call times out, sessionRegistered stays false, and the finally at :2540 calls startIdleTimer — a no-op. channelInfo still points at the hung child, so every subsequent createSessionensureChannel() returns that same child at :1890 and times out again. The workspace is permanently dead until daemon restart. Before this PR the first failure killed the channel and the next attempt cold-started.

This is the strongest argument for keeping channelIdleTimeoutMs: 0 working (item 1a) — under the new defaults, operators who relied on immediate reap lose both the reap and the crash-recovery behavior, and the flag value that would restore it now refuses to boot.

Also worth a look (not verified as deeply)

  • bridge.ts ~14 sites of the form finally { if (hasNoChannelWork(info)) startIdleTimer(info) } (:3148, :4534, :6087, :6113, :6139, :6163, :6314, :6490, :7525, :7566, :7607, :7702, :7765) don't check channelInfo === info, unlike trackWorkspacePhysicalRequest which guards with channelInfo === ci && !ci.isDying (:1570). Since idleTimer is a single module-scoped variable but startIdleTimer(ci) closes over a specific ci, a late-settling operation on a dead channel A can cancelIdleTimer() live channel B's timer and re-arm bound to A — leaking B. Only bites when channelIdleTimeoutMs is configured.
  • bridge.ts:7838-7844 — the cancel added to killSession runs after notifyAgentSessionClose, whose handler already calls cancelPendingPrompt() and deletes the session (acpAgent.ts:3207/:3234). AcpAgent.cancel then throws Session not found, and because cancel is an ACP notification the throw never reaches the bridge, so the try/catch at :7839 never fires. Harmless dead code, but it doesn't provide the protection it looks like it does.

Cleared on inspection: epoch assignment is sound (ensureChannel coalesces via inFlightChannelSpawn, so two channels can't claim the same epoch); trackWorkspacePhysicalRequest's counter pairs correctly with withWorkspaceControl (microtask ordering puts the physical decrement first, so no premature idle arm); getChannelClosedReject's cached rejection always gets a handler via Promise.race; shutdown/killAllSync still cancel the idle timer and clear auth timers; waitForWorkspacePhysicalRequests awaits never-rejecting promises.

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. Suggestions are inline. 2 Suggestion-level finding(s) could not be anchored to a changed line and were dropped; nothing further to act on here. Not reviewed: reverse audit — an auditor ran and opened its brief, but no agent was launched with the prompt the CLI built. Not reviewed: coverage — the plan could not be used (ENOENT: no such file or directory, open '/Users/jinye.djy/Projects/qwen-code/.qwen/tmp/qwen-review-pr-7308-fetch.json'), so this run cannot show that any of the diff was read. Not reviewed: verification — could not check that Step 4 and Step 5 ran (ENOENT: no such file or directory, open '/Users/jinye.djy/Projects/qwen-code/.qwen/tmp/qwen-review-pr-7308-fetch.json').

— qwen3.7-max via Qwen Code /review

Comment thread packages/cli/src/serve/workspace-service/index.ts
Comment thread packages/cli/src/serve/workspace-runtime-coordinator.test.ts Outdated
Comment thread packages/cli/src/serve/workspace-runtime-mcp-operations.ts Outdated
Comment thread packages/cli/src/serve/server.ts
Comment thread packages/cli/src/serve/routes/workspace-management.ts
Comment thread packages/cli/src/serve/daemon-status.test.ts Outdated

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] MCP auth timeout timer doesn't delete operation entry (bridge.ts:7471) — timer callback only deletes from workspaceMcpAuthenticationTimers, not workspaceMcpAuthenticationOperations, blocking hasNoChannelWork permanently when OAuth never completes and channelIdleTimeoutMs is configured

[Critical] Auth operation cleanup dropped discoveryState fallback (bridge.ts:3123) — server-absent-with-completed-discovery cleanup branch removed, operations for vanished servers persist forever blocking idle reap

[Critical] newSession failure self-healing removed (bridge.ts:~2300) — hung child on empty channel is no longer killed and replaced; with default sticky config, workspace is permanently dead until daemon restart

— qwen3.7-max via Qwen Code /review

Comment thread packages/cli/src/serve/workspace-runtime-mcp-operations.ts Outdated
Comment thread packages/cli/src/serve/workspace-runtime-mcp-operations.ts Outdated

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. Suggestions are inline. 1 Suggestion-level finding(s) could not be anchored to a changed line and were dropped; nothing further to act on here. Not reviewed: chunk 12, chunk 27, chunk 28, chunk 18, chunk 2, chunk 22, chunk 17, chunk 3, chunk 4, chunk 8, chunk 21, chunk 10, chunk 13, chunk 24, chunk 9, chunk 19, chunk 5, chunk 16, chunk 11, chunk 23, chunk 1, chunk 20, chunk 7, chunk 14, chunk 15, chunk 6, chunk 26, chunk 25 — launched with a prompt that is not the one the CLI built. Not reviewed: Agent 0: Issue fidelity & root-cause ownership, Test coverage matrix (whole-diff), Agent 1b: Removed-behavior audit, Agent 1c: Cross-file tracer, Agent 7: Build & test verification, Invariant agent A: state, timers, collections — packages/acp-bridge/src/bridge.ts, Invariant agent B: counters, return values, error taxonomies — packages/acp-bridge/src/bridge.ts, Invariant agent C: config fields, early returns — packages/acp-bridge/src/bridge.ts — its prompt was built, but no agent on record was launched with it. Not reviewed: reverse audit — no auditor was launched with a prompt this skill builds — the pass that hunts what the rest of the review missed ran, if at all, without the method its brief carries. Not reviewed: verification — a verifier ran and opened its brief, but no agent was launched with the prompt the CLI built — the launch was written by hand, and the posted findings cannot be counted as verified against it. [Critical] SDK type channelIdleTimeoutMs: number in packages/sdk-typescript/src/daemon/types.ts:458 does not match bridge's number | null. When daemon runs without --channel-idle-timeout-ms (the new default), limits.channelIdleTimeoutMs is null at runtime despite TypeScript declaring number. SDK consumers using strict null checks or serialization round-trips would fail.

— qwen3.7-max via Qwen Code /review

Comment thread docs/design/workspace-runtime-architecture.md
Comment thread packages/cli/src/serve/server.ts
@wenshao

wenshao commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /takeover

@qwen-code-dev-bot qwen-code-dev-bot added the autofix/takeover Summon the autofix loop to manage this PR (remove to release; needs triage+) label Jul 20, 2026
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached. Remove the autofix/takeover label (or comment @qwen-code /takeover stop) to release.

中文说明

🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。移除 autofix/takeover 标签(或评论 @qwen-code /takeover stop)即可释放。

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Addressed the latest review feedback (round 1/100). What changed, and what I pushed back on:

Review feedback addressed

Critical fixes

1. dispose() does not fully clean up authentication state (@qwen-code-ci-bot, workspace-runtime-mcp-operations.ts:297)

Fixed. Two changes:

  • Removed the !this.runtime.bridge.isChannelLive() guard from the activeMcpAuthentication cleanup. The guard prevented clearing the process-global OAuth lane during workspace removal because disposeRuntime calls dispose() before channel teardown. Now dispose() unconditionally releases the lane when this instance owns it.
  • Added authenticationBarrier release in dispose(). Previously, MCP operations queued behind getAuthenticationBarrier() in runInCapabilityPhysicalLane would hang permanently awaiting a never-resolving promise after dispose. The barrier is now resolved during cleanup.

Added a regression test: "releases the OAuth lane and barrier on dispose even while the channel is live" — verifies that a second workspace can authenticate immediately after the first is disposed with a live channel.

2. SDK type channelIdleTimeoutMs: number does not match bridge's number | null (@qwen-code-ci-bot, sdk-typescript/src/daemon/types.ts:458)

Fixed. Changed channelIdleTimeoutMs: number to channelIdleTimeoutMs: number | null in the SDK's DaemonStatusReport type, matching the bridge's BridgeDaemonStatusLimits type (bridgeTypes.ts:597) and the daemon-status module (daemon-status.ts:167). When the daemon runs without --channel-idle-timeout-ms, the value is null at runtime.

Suggestions addressed

3. OAuth tests lack afterEach cleanup or try/finally around dispose() (@doudouOUC, workspace-runtime-coordinator.test.ts:1509)

Fixed. All 8 OAuth-related tests now use try/finally to guarantee dispose() runs even when an assertion fails mid-test. For fake-timer tests, variable declarations were hoisted before the try block so dispose() is accessible in finally. This prevents the module-global activeMcpAuthentication from leaking and cascade-failing subsequent tests.

4. Design doc activation union type lacks semantic definitions (@qwen-code-ci-bot, workspace-runtime-architecture.md:527)

Fixed. Added inline comments defining each value of the activation union type: applied, deferred, reconciling, and partial.

Already fixed in 3925389 (confirmed)

  • releaseAuthenticationWhenSafe disposed check — !this.disposed added to the polling loop (@doudouOUC, @qwen-code-ci-bot)
  • cancelDrain() try-catch in workspace-management.ts — wrapped with best-effort error handling (@doudouOUC, @wenshao item 3)
  • Cross-workspace path disclosure in OAuth-lane error — workspaceCwd/serverName removed from the error message, generic mcp_authentication_lane_busy returned (@wenshao item 4)
  • MCP operation conflicts return 500 not 409 — WorkspaceRuntimeMcpOperationConflictError typed error class added with 409 mapping in sendBridgeError (@wenshao item 2)

Declined with reason

  • channelLive() early-return moved inside !preheatAcpChildOnBridge block (@doudouOUC): Intentional design change — preheating an already-live channel renews the configured idle window. The stale test expectation was updated in a725409. Confirmed by @ytahdn.
  • MCP auth timeout timer doesn't delete operation entry (@qwen-code-ci-bot, bridge.ts:7471): By design — the comment at the timer callback explicitly states "never release the physical auth lease just because the observer deadline elapsed: a late callback may still own the process-global listener." The status poll triggered by the timer clears the entry when the server leaves pending. The coordinator's monitorAuthentication handles the lifecycle independently.
  • Auth operation cleanup dropped discoveryState fallback (@qwen-code-ci-bot, bridge.ts:3123): The current cleanup at :3139 correctly handles the server-present case. The server-absent case is handled by the coordinator's monitorAuthentication which checks !server with discoveryState === 'completed' and terminates the operation.
  • newSession failure self-healing removed (@qwen-code-ci-bot, bridge.ts:~2300): This is a deliberate architectural change in the workspace runtime model. The coordinator's prepare() handles channel lifecycle. @wenshao's addendum B raises a valid concern about the default config; this is tracked as a follow-up item for the stack.
  • Inert test in daemon-status.test.ts (@doudouOUC): The mock change from 0 to null aligns the test fixture with the new default. The test validates the status response shape, not the runtime idle-reap behavior. Adding a behavioral test for null vs positive values is a follow-up.
  • User-scope settings change fan-out lacks test (@doudouOUC, @qwen-code-ci-bot): Valid suggestion. Deferred to keep this pass focused on the Critical fixes. The fan-out code is straightforward (for (const runtime of registry.list())) and the existing single-workspace test covers the event shape.
  • X-Qwen-Client-Id not parsed on new MCP mutation routes (@wenshao item 5): Lower urgency, deferred to a follow-up in the stack.
  • 200 where siblings use 202 (@wenshao item 6): Lower urgency, deferred to a follow-up in the stack.
  • Test coverage gaps (@wenshao item 7): Multiple sub-items. The OAuth success path gap and vacuous bridge tests are valid; deferred to keep this pass scoped. The dispose() fix and new test address the most critical coverage gap.
  • Abandoned OAuth flow pins workspace runtime (@wenshao item 8): Design concern documented in the architecture doc. A cancel/DELETE endpoint is planned for a later PR in the stack.
  • MCP auth lease release path (@wenshao addendum A): The releaseAuthenticationWhenSafe loop's physicalPending === true branch is reachable only on the entry-failure path. The monitorAuthentication poller handles the pending case independently. The finally block in dispose() now releases the barrier, addressing the most critical hang scenario.
  • killChannelWithLog unreachable under default config (@wenshao addendum B): Deliberate design change. The workspace runtime model changes the idle-reap default. Documented as a follow-up for release notes.
  • Coordinator state parallel maps / duplicated utilities / unused exports (@wenshao cleanups): Valid refactoring suggestions. Deferred — these are structural improvements best done in a dedicated PR before the next stack PR lands.

Conflict

No conflict (--conflict false). No merge performed.

Verification

  • npm run build
  • npm run typecheck
  • npm run lint
  • packages/cli coordinator tests: 65/65 ✅ (including 1 new test)
  • packages/cli route/serve/facade/status tests: 1140/1140 ✅
  • packages/acp-bridge bridge tests: 431/431 ✅
中文说明

已处理的评审反馈

关键修复

1. dispose() 未完全清理认证状态@qwen-code-ci-botworkspace-runtime-mcp-operations.ts:297

已修复。两处更改:

  • 移除了 activeMcpAuthentication 清理中的 !this.runtime.bridge.isChannelLive() 守卫。该守卫在工作区移除时阻止了进程级 OAuth 通道的清理,因为 disposeRuntime 在通道拆除之前调用了 dispose()。现在 dispose() 在实例拥有该通道时无条件释放。
  • dispose() 中添加了 authenticationBarrier 的释放。之前,在 runInCapabilityPhysicalLane 中排队等待 getAuthenticationBarrier() 的 MCP 操作会在 dispose 后永久挂起。现在在清理期间解析该屏障。

新增回归测试:"releases the OAuth lane and barrier on dispose even while the channel is live"——验证在通道仍然存活时 dispose 第一个工作区后,第二个工作区可以立即进行认证。

2. SDK 类型 channelIdleTimeoutMs: number 与 bridge 的 number | null 不匹配@qwen-code-ci-botsdk-typescript/src/daemon/types.ts:458

已修复。将 SDK 的 DaemonStatusReport 类型中的 channelIdleTimeoutMs: number 更改为 channelIdleTimeoutMs: number | null,与 bridge 的 BridgeDaemonStatusLimits 类型(bridgeTypes.ts:597)和 daemon-status 模块(daemon-status.ts:167)保持一致。当守护进程未设置 --channel-idle-timeout-ms 时,运行时值为 null

已处理的建议

3. OAuth 测试缺少 afterEach 清理或 try/finally 包裹 dispose()@doudouOUCworkspace-runtime-coordinator.test.ts:1509

已修复。所有 8 个 OAuth 相关测试现在使用 try/finally 确保即使断言在测试中途失败也会执行 dispose()。对于使用假定时器的测试,变量声明被提升到 try 块之前,以便在 finally 中可以访问 dispose()。这防止了模块级 activeMcpAuthentication 泄漏并导致后续测试级联失败。

4. 设计文档 activation 联合类型缺少语义定义@qwen-code-ci-botworkspace-runtime-architecture.md:527

已修复。为 activation 联合类型的每个值添加了内联注释:applieddeferredreconcilingpartial

已在 3925389 中修复(已确认)

  • releaseAuthenticationWhenSafe 的 disposed 检查——在轮询循环中添加了 !this.disposed
  • workspace-management.tscancelDrain() 的 try-catch——使用尽力错误处理包裹
  • OAuth 通道错误中的跨工作区路径泄露——从错误消息中移除了 workspaceCwd/serverName
  • MCP 操作冲突返回 500 而非 409——添加了 WorkspaceRuntimeMcpOperationConflictError 类型化错误类

附理由拒绝

  • channelLive() 提前返回移动:有意的架构变更,预热已存活通道会更新空闲窗口。
  • MCP 认证超时定时器未删除操作条目:设计如此——定时器回调明确声明不因观察者截止而释放物理认证租约。
  • 认证操作清理移除了 discoveryState 回退:当前清理正确处理了服务器存在的情况,服务器不存在的情况由协调器的 monitorAuthentication 处理。
  • newSession 失败自愈移除:工作区运行时模型的有意架构变更。
  • daemon-status.test.ts 中的无效测试:mock 从 0 改为 null 使测试夹具与新默认值一致。
  • 用户级设置变更扇出缺少测试:有效建议,推迟以保持本次修复聚焦于关键问题。
  • X-Qwen-Client-Id 未在新 MCP 变更路由上解析:较低紧急度,推迟到栈中的后续 PR。
  • 200 vs 202:较低紧急度,推迟到栈中的后续 PR。
  • 测试覆盖缺口:多个子项,推迟以保持本次修复的范围。
  • 废弃 OAuth 流固定工作区运行时:设计关注点,取消/DELETE 端点计划在栈的后续 PR 中实现。
  • MCP 认证租约释放路径dispose() 中的 finally 块现在释放屏障,解决了最关键的挂起场景。
  • 默认配置下 killChannelWithLog 不可达:有意的架构变更,记录为发布说明的后续项。
  • 协调器状态并行映射/重复工具/未使用导出:有效的重构建议,推迟到下一个栈 PR 之前的专门 PR。

冲突

无冲突(--conflict false)。未执行合并。

验证

  • npm run build
  • npm run typecheck
  • npm run lint
  • packages/cli 协调器测试:65/65 ✅(含 1 个新测试)
  • packages/cli 路由/服务/门面/状态测试:1140/1140 ✅
  • packages/acp-bridge bridge 测试:431/431 ✅

Base-conflict check: no conflict with main.

Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human.


🧠 Handled by Qwen Code · model/模型 qwen3.8-max-preview

@wenshao

wenshao commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Review — workspace runtime ownership (stack 1/4)

Thanks for this. The runtime-ownership model reads well, and the test suite is genuinely strong — the coordinator's cases around epoch changes, stale projection, drain-rollback and OAuth-lane serialization pin down exactly the right invariants. I reviewed the full diff plus the surrounding bridge context. One blocking issue, a few medium correctness/compat items, and some low-severity notes.

🔴 Blocking — reentrancy deadlock in the capability physical lane

In workspace-runtime-coordinator.ts, reconcileCapability (~L706) and executeMcpRuntimeMutationInPhysicalLane (~L589) both await resumeCapabilityInBackground(...) from inside a runInCapabilityPhysicalLane(capability, …) task. resumeCapabilityInBackground loops on prepareCapability(...) (~L946), which re-acquires the same lane. Because runInCapabilityPhysicalLane (~L272) installs its tail synchronously and only clears it after the body resolves, the nested acquisition awaits the outer tail → which awaits the outer body → which is blocked on the resume. Circular wait, permanent hang.

completed === false is reachable whenever the runtime epoch flips during the physical sub-step — prepareCapabilityInPhysicalLane's catch returns false when operationEpoch !== runtimeEpoch() and the deadline hasn't passed (~L1123). Concretely: the qwen --acp child restarts (or the channel drops and revives) while an MCP config reload or POST …/runtime/mcp/:server/restart is mid-flight.

Impact: the mcp lane is wedged forever — every later MCP op (reconcile / mutation / prepare / auth, which all share the lane) queues behind the dead task; hasActiveWork() stays true, so the idle reaper never reaps the runtime and the ACP child leaks; status() reports mcp stuck in starting.

The prepare() path already does this correctly — it schedules resume in a .then() after prepareCapability's lane task resolves (~L833). The two inline sites should follow that pattern (detach; don't await resume while holding the lane).

🟠 Medium

  1. Unmapped WorkspaceRuntimeStillStartingError500 {"error":""}. It's a message-less, unexported Error subclass (coordinator ~L78) with no case in sendBridgeError. A slow POST …/runtime/mcp/:server/restart (via runMcpRuntimeMutation) that can't obtain runtime control within 120s throws it up to the route → the generic res.status(500).json(errorPayload(err)), and errorMessage() on it returns ''. Clients get a 500 with an empty body where a 503 + "runtime still starting" (like acp_channel_unavailable) is intended. Please map it.

  2. Pending OAuth blocks other MCP ops and can pin the runtime. While an authenticate is waiting_for_input, the per-workspace authenticationBarrier stays unresolved and every other MCP physical op (approve / clear-auth / restart / reload) awaits it. approve/clear-auth run with deadline === undefined, so the request can hang with no response for up to MCP_AUTH_OPERATION_TIMEOUT_MS (10 min). Separately, on the bridge side an abandoned/hung OAuth never releases the workspaceMcpAuthenticationOperations lease, so hasNoChannelWork() stays false and a configured channelIdleTimeoutMs never reaps the child. Consider a bounded wait / fast-fail for non-auth ops and a lease release on abandoned auth.

  3. Compat: channelIdleTimeoutMs semantics changed. Omitting it previously reaped the ACP child immediately on last-session-close (old default resolved to 0 → kill); it now keeps the child alive until daemon shutdown (null → never reap). Explicit channelIdleTimeoutMs: 0, previously valid and documented as "immediate kill", now throws TypeError at startup. The daemon-status/SDK type widens number → number | null (sdk-typescript/src/daemon/types.ts, daemon-status.ts) and the emitted default flips 0 → null on the wire, so non-TS JSON consumers must handle null. This is intentional per the PR body — just please call it out in the changelog / migration notes and confirm the docs drop the old 0 value.

🟡 Low / notes

  • Lazy runtime (behavior change): shouldPreheatBridge now returns preheatBridge === true, so the primary ACP child no longer preheats at boot — the first runtime command/session pays the cold start. Intentional (new "keeps the primary workspace runtime cold by default" test); flagging the latency shift.
  • Unguarded background promises: void monitorAuthentication(...) / void failAuthenticationWhenSafe(...) (mcp-operations) and the bridge's ensureChannel() when manageMcpServer gets an already-elapsed deadlineAt have no .catch; a throw becomes an unhandledRejection outside any request. Hard to trigger with the real bridge, but the guards are cheap.
  • dispose() frees the process-global activeMcpAuthentication without an isChannelLive() check — removing a workspace mid-OAuth releases the global lane while the child may still own the OAuth callback. (Looks like a follow-up commit in the stack already adds this guard.)
  • refreshWorkspaceExtensions hardcodes {refreshed:1, failed:0} on any non-throwing child response, so the coordinator's refreshed.failed > 0 check is effectively dead. In practice the subsequent getWorkspaceExtensionsStatus().errors check catches real failures, so impact is low — but the failed count is decorative as written.
  • sessionSpawnResetPending is sticky: set on a newSession timeout and not cleared on a later successful spawn, so a recovered channel can be force-killed on its next idle. It's gated on hasNoChannelWork, so it never interrupts live work — latent smell rather than a live bug.
  • ensure/status return 200 even when the runtime failed to start (failure only in capabilities[*].state === 'error'). Consistent with the status-machine design; just make sure SDK clients know not to branch on the status code alone.
  • acpAgent.ts: the best-effort notifyMcpAuthenticationCompleted call is repeated across every early-return/throw path of the authenticate branch — correct (releases the daemon-side barrier on all paths), but a small helper or finally would DRY it up.

Positives

  • The ownership model plus epoch/revision guarding is coherent, and the single-flight / tail bookkeeping is careful — identity-checked cleanups, no double-preheat, no double physical execution.
  • Concurrency test coverage on the hard cases is excellent.

Net: the deadlock is the one I'd block on. The medium items are worth addressing (or explicitly deferring within the stack, noted in the PR) before merge.


🤖 Reviewed with Claude Code · model: Opus 4.8 (1M)

Comment thread packages/acp-bridge/src/bridge.ts
Comment thread docs/design/workspace-runtime-architecture.md Outdated
Comment thread packages/acp-bridge/src/bridge.ts Outdated

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. Suggestions are inline. Not reviewed: chunk 1, chunk 2, chunk 3, chunk 4, chunk 5, chunk 6, chunk 7, chunk 8, chunk 9, chunk 10, chunk 11, chunk 12, chunk 13, chunk 14, chunk 15, chunk 16, chunk 17, chunk 18, chunk 19, chunk 20, chunk 21, chunk 22, chunk 23, chunk 24, chunk 25, chunk 26, chunk 27, chunk 28 — no agent reported covering these; nobody read them. Not reviewed: every dimension — none of the 31 required agents is on record as launched with a prompt this skill built, so this diff was reviewed, if at all, from prompts the run wrote for itself: no record shows the severity bar, the finding format or this project's own rules reaching an agent. Not reviewed: reverse audit — no auditor was launched with a prompt this skill builds — the pass that hunts what the rest of the review missed ran, if at all, without the method its brief carries. Not reviewed: verification — the review posts findings, but no verifier was launched with a prompt this skill builds — they were ruled on, if at all, without the verdict bar its brief carries.

— qwen3.7-max via Qwen Code /review

Comment thread packages/cli/src/serve/workspace-runtime-coordinator.ts
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Addressed the latest review feedback (round 2/100). What changed, and what I pushed back on:

Review feedback addressed

🔴 Blocking — reentrancy deadlock in the capability physical lane (@wenshao)

Decision: Fixed (Required — correctness bug / blocking).

Root cause: reconcileCapability and executeMcpRuntimeMutationInPhysicalLane both awaited resumeCapabilityInBackground(...) from inside a runInCapabilityPhysicalLane(capability, …) callback. resumeCapabilityInBackground loops on prepareCapability(...), which re-acquires the same lane. Because the outer lane tail only resolves after the outer body returns, and the outer body was blocked on the resume, this created a circular wait → permanent hang whenever the runtime epoch flipped mid-operation.

Fix: Detached resumeCapabilityInBackground from the physical lane at both sites using void … .catch(() => undefined), matching the pattern already used in prepare() (which schedules resume in a .then() after the lane task resolves). The outer lane body now returns immediately, releasing the lane so the background resume can re-acquire it without deadlock.

Files: packages/cli/src/serve/workspace-runtime-coordinator.ts (2 sites)

Test: Added regression test does not deadlock when epoch flips during a physical lane reconciliation — flips the epoch while the physical lane is blocked in prepareMcp, then asserts the reconciliation settles (5s timeout guard).


[Critical] sessionSpawnResetPending sticky flag (@qwen-code-ci-bot)

Decision: Fixed (Required — correctness bug).

Root cause: sessionSpawnResetPending was set to true on newSession timeout but never cleared on a subsequent successful newSession. A recovered channel could be force-killed on its next idle by the stale flag.

Fix: Added ci.sessionSpawnResetPending = false; immediately after the successful newSession try-catch block, before the late-shutdown re-check. A successful spawn now disproves any prior timeout signal.

Files: packages/acp-bridge/src/bridge.ts


[Suggestion] dispose() does not clear tracking maps (@doudouOUC / @qwen-code-ci-bot)

Decision: Fixed (Optional — valuable, in scope).

Fix: Added this.inFlight.clear(), this.backgroundResume.clear(), and this.capabilityPhysicalTail.clear() to dispose(). In-flight promises still settle on their own (hitting assertAcceptingWork and failing); clearing the maps stops hasActiveWork() from reporting false positives on a disposed runtime.

Files: packages/cli/src/serve/workspace-runtime-coordinator.ts


[Suggestion] docs contradiction on channelIdleTimeoutMs: 0 (@qwen-code-ci-bot)

Decision: Fixed (Optional — docs consistency).

Fix: Rewrote section 16 item 9 to match the three-case model: null → no auto-reap; 0 → explicit startup failure (TypeError); small positive value → normal idle reap.

Files: docs/design/workspace-runtime-architecture.md


[Suggestion] killSession's connection.cancel() lacks a test (@qwen-code-ci-bot)

Decision: Fixed (Optional — test coverage).

Fix: Added test killSession calls connection.cancel with the killed sessionId — spawns a session, starts a prompt, kills the session, and asserts connection.cancel was called with the killed sessionId.

Files: packages/acp-bridge/src/bridge.test.ts


🟠 Medium — Unmapped WorkspaceRuntimeStillStartingError → 500 with empty body (@wenshao)

Decision: Fixed (Required — correctness bug).

Root cause: WorkspaceRuntimeStillStartingError was a message-less, unexported Error subclass with no case in sendBridgeError. Clients received a 500 with an empty error field instead of a 503 with a meaningful message.

Fix: Exported the class, gave it a constructor message ('Workspace runtime is still starting'), and added a sendBridgeError case mapping it to 503 with Retry-After: 5 and code: 'runtime_still_starting'. The subclass WorkspaceRuntimeEpochChangedError also benefits (it inherits the message and matches the same 503 case).

Files: packages/cli/src/serve/workspace-runtime-coordinator.ts, packages/cli/src/serve/server/error-response.ts


Deferred items

Feedback Reason
🟠 Pending OAuth blocks other MCP ops and can pin the runtime (@wenshao) Complex design change requiring bounded wait / fast-fail for non-auth ops and lease release on abandoned auth. Out of scope for a minimal review-fix commit; should be a follow-up PR.
🟠 Compat: channelIdleTimeoutMs semantics changed (@wenshao) Intentional per the PR body. The docs fix above addresses the 0 value contradiction. Changelog / migration notes are a release-process concern, not a code fix.
🟡 Low / notes (@wenshao) All acknowledged as latent smells or intentional behavior changes. None are live bugs requiring immediate fixes. The sessionSpawnResetPending sticky flag (the one actionable item) is fixed above.
CI failure: Test (ubuntu-latest Node 22.x) Could not reproduce locally — all focused test suites pass (66 coordinator, 432 bridge, 762 server, 249 serve routes, 13 workspace-runtime routes). The failure may be flaky or environment-specific; the workflow should re-run CI on the new commit.

Verification

Check Result
npx tsc --noEmit (cli) ✅ Pass
npx tsc --noEmit (acp-bridge) ✅ Pass
npx eslint (all 5 changed source/test files) ✅ Pass
vitest run workspace-runtime-coordinator.test.ts ✅ 66/66 pass
vitest run bridge.test.ts ✅ 432/432 pass
vitest run server.test.ts ✅ 762/762 pass
vitest run workspace-runtime.test.ts ✅ 13/13 pass
vitest run workspace-management.test.ts + run-qwen-serve.test.ts ✅ 249/249 pass
中文说明

已处理的评审反馈

🔴 阻塞 — 能力物理通道中的重入死锁(@wenshao

决定: 已修复(必需 — 正确性缺陷 / 阻塞项)。

根因: reconcileCapabilityexecuteMcpRuntimeMutationInPhysicalLane 都在 runInCapabilityPhysicalLane(capability, …) 回调内部 awaitresumeCapabilityInBackground(...)resumeCapabilityInBackground 循环调用 prepareCapability(...),而后者会重新获取同一物理通道。由于外层通道的 tail 只在外层 body 返回后才 resolve,而外层 body 又阻塞在 resume 上,形成了循环等待 → 当运行时 epoch 在操作中途翻转时产生永久挂起。

修复: 在两个位置将 resumeCapabilityInBackground 从物理通道中解耦,使用 void … .catch(() => undefined),与 prepare() 中已有的模式一致(在通道任务 resolve 之后.then() 中调度 resume)。外层通道 body 现在立即返回,释放通道,使后台 resume 可以重新获取通道而不会死锁。

文件: packages/cli/src/serve/workspace-runtime-coordinator.ts(2 处)

测试: 新增回归测试 does not deadlock when epoch flips during a physical lane reconciliation — 在物理通道阻塞于 prepareMcp 时翻转 epoch,然后断言协调过程能够完成(5 秒超时保护)。


[Critical] sessionSpawnResetPending 粘滞标志(@qwen-code-ci-bot

决定: 已修复(必需 — 正确性缺陷)。

根因: sessionSpawnResetPendingnewSession 超时时被设为 true,但在后续成功的 newSession 后从未清除。恢复的通道可能在下一次空闲时被粘滞标志强制杀死。

修复: 在成功的 newSession try-catch 块之后、延迟关闭重检之前,添加 ci.sessionSpawnResetPending = false;。成功的 spawn 现在会推翻之前的超时信号。

文件: packages/acp-bridge/src/bridge.ts


[Suggestion] dispose() 未清除跟踪映射(@doudouOUC / @qwen-code-ci-bot

决定: 已修复(可选 — 有价值,在范围内)。

修复:dispose() 中添加 this.inFlight.clear()this.backgroundResume.clear()this.capabilityPhysicalTail.clear()。在途 promise 仍会自行 settle(触发 assertAcceptingWork 并失败);清除映射只是阻止 hasActiveWork() 在已销毁的运行时上报告误报。

文件: packages/cli/src/serve/workspace-runtime-coordinator.ts


[Suggestion] 文档中 channelIdleTimeoutMs: 0 语义矛盾(@qwen-code-ci-bot

决定: 已修复(可选 — 文档一致性)。

修复: 重写第 16 节第 9 项以匹配三态模型:null → 不自动回收;0 → 启动时明确失败(TypeError);极小正值 → 正常空闲回收。

文件: docs/design/workspace-runtime-architecture.md


[Suggestion] killSessionconnection.cancel() 缺少测试(@qwen-code-ci-bot

决定: 已修复(可选 — 测试覆盖)。

修复: 新增测试 killSession calls connection.cancel with the killed sessionId — 创建会话、启动 prompt、杀死会话,并断言 connection.cancel 以被杀死的 sessionId 被调用。

文件: packages/acp-bridge/src/bridge.test.ts


🟠 中等 — 未映射的 WorkspaceRuntimeStillStartingError → 500 空响应体(@wenshao

决定: 已修复(必需 — 正确性缺陷)。

根因: WorkspaceRuntimeStillStartingError 是一个无消息、未导出的 Error 子类,在 sendBridgeError 中没有对应的处理分支。客户端收到的是 500 和空的 error 字段,而非预期的 503 和有意义的消息。

修复: 导出该类,添加构造函数消息('Workspace runtime is still starting'),并在 sendBridgeError 中添加映射分支,返回 503Retry-After: 5code: 'runtime_still_starting'。子类 WorkspaceRuntimeEpochChangedError 也受益(继承消息并匹配同一 503 分支)。

文件: packages/cli/src/serve/workspace-runtime-coordinator.tspackages/cli/src/serve/server/error-response.ts


延迟处理项

反馈 原因
🟠 待处理 OAuth 阻塞其他 MCP 操作并可能固定运行时(@wenshao 复杂的设计变更,需要对非认证操作添加有界等待/快速失败,以及在废弃认证时释放租约。超出最小评审修复提交的范围;应作为后续 PR 处理。
🟠 兼容性:channelIdleTimeoutMs 语义变更(@wenshao 按 PR 描述为有意变更。上述文档修复已解决 0 值的矛盾。变更日志/迁移说明属于发布流程事项,非代码修复。
🟡 低优先级 / 备注(@wenshao 均确认为潜在异味或有意的行为变更。无需要立即修复的活跃缺陷。sessionSpawnResetPending 粘滞标志(唯一可操作项)已在上方修复。
CI 失败:Test (ubuntu-latest Node 22.x) 本地无法复现 — 所有聚焦测试套件均通过(66 个协调器、432 个桥接、762 个服务器、249 个 serve 路由、13 个 workspace-runtime 路由)。该失败可能是 flaky 或环境特定的;工作流应在新提交上重新运行 CI。

验证结果

检查项 结果
npx tsc --noEmit(cli) ✅ 通过
npx tsc --noEmit(acp-bridge) ✅ 通过
npx eslint(所有 5 个变更的源文件/测试文件) ✅ 通过
vitest run workspace-runtime-coordinator.test.ts ✅ 66/66 通过
vitest run bridge.test.ts ✅ 432/432 通过
vitest run server.test.ts ✅ 762/762 通过
vitest run workspace-runtime.test.ts ✅ 13/13 通过
vitest run workspace-management.test.ts + run-qwen-serve.test.ts ✅ 249/249 通过

Base-conflict check: no conflict with main.

Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human.


🧠 Handled by Qwen Code · model/模型 qwen3.8-max-preview

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. Suggestions are inline. 2 Suggestion-level finding(s) could not be anchored to a changed line and were dropped; nothing further to act on here. Unresolved, please confirm: [Critical] MCP auth operation entry leak on withTimeout throw (bridge.ts:7526) — author argues intentional (no OAuth cancellation contract); recovery via channel exit and status polling; debated across multiple review rounds without consensus Not reviewed: coverage — no plan was given, so this run cannot show that any of the diff was read.

— qwen3.7-max via Qwen Code /review

Comment thread packages/acp-bridge/src/bridge.ts
@ytahdn

ytahdn commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

已按本轮 re-review 的 3 个合入前问题修复并推送 8aa45be81

  • 在计算并校验 MCP operation 剩余 deadline 后才调用 ensureChannel(),过期请求不再启动孤儿 channel/promise。
  • coordinator 与 MCP operation 统一复用已有的 getErrorMessage()(与 server 的 errorMessage() 语义一致,但避免反向导入 server 模块形成循环依赖),JSON-RPC plain-object 错误不再显示为 [object Object];同时补齐 starting error 的 message。
  • restore/spawn reset 在 observer 已超时、只剩受跟踪的物理请求时可以安全回收 channel;仍会等待活跃 observer、session/spawn/discovery,并继续由 OAuth authentication lease 阻止回收。不会提前释放 physical lease,因此不会允许同 capability 的物理请求重叠。

新增回归覆盖了已过期 deadline、JSON-RPC 错误透传、以及 hung workspace physical request + hung restore 后替换 channel 的场景。

验证:

  • acp-bridge: 438 tests passed
  • workspace-runtime-coordinator: 67 tests passed
  • acp-bridge / cli typecheck passed
  • changed-file ESLint + Prettier passed
  • full repository build passed

本次未处理评论中的 Minor/后续建议,避免在已多轮 review 的基础 PR 中继续扩大范围。

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. Suggestions are inline. 3 Suggestion-level finding(s) could not be anchored to a changed line and were dropped; nothing further to act on here.

— qwen3.7-max via Qwen Code /review

Comment thread docs/design/workspace-runtime-architecture.md Outdated
Comment thread packages/cli/src/serve/workspace-runtime-mcp-operations.ts Outdated
@ytahdn ytahdn removed the autofix/takeover Summon the autofix loop to manage this PR (remove to release; needs triage+) label Jul 21, 2026
@wenshao

wenshao commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

Qwen Code resolved the merge conflicts, but the head branch changed while resolving, so the update was not pushed. Re-run /resolve. The resolved diff is attached as the qwen-resolve-pr-7308 artifact on the workflow run.

Merge Resolution Summary — PR #7308

Root cause

Main advanced on two independent fronts that collided with this PR's workspace runtime teardown hardening:

  1. closeSession-style kill hardening (likely from the session-writer-lease P0a work): main added a throwOnFailure: true + timeoutMs pattern to killSession's notifyAgentSessionClose call, with a force-kill fallback on failure. This is the same pattern closeSession already uses. The PR, meanwhile, restructured killSession to defer notifyAgentSessionClose until after state cleanup and added a connection.cancel() call for active prompts.

  2. SessionWriterError in error-response (from the session-writer-lease work): main added a new SessionWriterError branch to the sendBridgeError if-chain. The PR independently added WorkspaceRuntimeMcpOperationConflictError and WorkspaceRuntimeStillStartingError branches at the same insertion point.

Textual or semantic

bridge.ts — semantic conflict. Both sides modified the killSession close sequence ordering. Resolved by combining:

const hadActivePrompt = entry.promptActive;
try {
  await notifyAgentSessionClose(entry, ci, 'killSession', {
    throwOnFailure: true,
    timeoutMs: initTimeoutMs,
  });
} catch (error) {
  if (ci) {
    await killChannelWithLog(ci, `force kill session ...`);
    return true;
  }
  entry.closing = false;
  throw error;
}
if (hadActivePrompt) {
  entry.promptActive = false;
  activePromptCounter--;
  touchActivity();
}
// ... state cleanup, channel detach ...
if (hadActivePrompt) {
  void entry.connection.cancel({ sessionId }).catch(() => {});
}

error-response.ts — textual only. Three independent instanceof branches at the same location; kept all three.

What is load-bearing

  • hadActivePrompt is saved before notifyAgentSessionClose runs. The agent close notification may clear entry.promptActive as a side effect; using the saved flag ensures the counter decrement and the connection.cancel() both fire correctly.
  • The force-kill fallback (killChannelWithLog + return true) in the catch block short-circuits the rest of killSession — no state cleanup, no lifecycle emit. This matches main's intent: if the close RPC fails with a channel available, kill the entire transport and let channel.exited cleanup reap all entries. The PR's connection.cancel() won't run in this path, which is correct because the channel is dead.
  • connection.cancel() runs after ci.sessionIds.delete(sessionId) and byId.delete(sessionId). This ordering ensures the cancel notification doesn't race with a concurrent spawnOrAttach reattaching to the same sessionId.

What I could not verify

  • No build or tests run. The notifyAgentSessionClose call now uses throwOnFailure: true which changes error propagation vs the PR's original unguarded call. If any test asserts that killSession does NOT throw when the close RPC fails (without a channel to force-kill), it will now throw instead. The bridge test file (bridge.test.ts) auto-merged cleanly so I could not inspect its expectations for this path.
  • The connection.cancel() call after state cleanup is new from the PR. If main added any other post-detach cleanup that also calls connection.cancel(), there could be a double-cancel (though the .catch(() => {}) makes it harmless).
中文说明

合并冲突解决总结 — PR #7308

根因

main 分支在两个独立方向上与本 PR 的工作区运行时拆卸硬化产生了冲突:

  1. closeSession 风格的 kill 硬化:main 为 killSessionnotifyAgentSessionClose 调用添加了 throwOnFailure: true + timeoutMs 模式,失败时强制杀死通道。PR 则将 notifyAgentSessionClose 延迟到状态清理之后,并新增了对活跃 prompt 的 connection.cancel() 调用。

  2. error-response 中的 SessionWriterError:main 新增了 SessionWriterError 分支,PR 在同一点新增了 WorkspaceRuntimeMcpOperationConflictErrorWorkspaceRuntimeStillStartingError 分支。

冲突性质

bridge.ts — 语义冲突,双方修改了 killSession 关闭顺序。合并方案:保留 main 的 try/catch 强制杀死回退 + PR 的 hadActivePrompt 标志保存和 connection.cancel() 逻辑。hadActivePromptnotifyAgentSessionClose 之前保存,确保计数器和 cancel 正确触发。

error-response.ts — 纯文本冲突,三个独立的 instanceof 分支全部保留。

关键约束

  • hadActivePrompt 必须在 notifyAgentSessionClose 之前保存,因为通知可能修改 entry.promptActive
  • 强制杀死回退路径会短路整个 killSession,此时 connection.cancel() 不会执行(通道已死,这是正确的)。
  • connection.cancel()byId.delete 之后运行,防止与并发 spawnOrAttach 竞争。

未能验证

  • 未运行构建或测试。throwOnFailure: true 改变了错误传播行为,如有测试断言 killSession 在关闭 RPC 失败时不抛出异常,可能需要更新。

@ytahdn

ytahdn commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed the conflict resolution and the two requested fixes:

  • 0e69cea merges current main and preserves both the new session-writer fencing and Workspace Runtime channel lifecycle behavior.
  • ee2c14e moves runtime error formatting to a side-effect-free module, fixing the CI mock-load failure in workspace-git-diff.test.ts.
  • 3a3bf9c makes the OAuth acceptance criterion match the implemented daemon-global lane behavior (concurrent requests are rejected, not queued).

Verification completed:

  • ACP bridge: 447/447 tests passed.
  • Previous CI failure: workspace-git-diff.test.ts 13/13 passed.
  • Workspace Runtime coordinator + error response: 72/72 passed.
  • Workspace-qualified REST: 25/25 passed when rerun independently.
  • Repository build passed.
  • CLI and acp-bridge typechecks passed.

The full CLI suite was stopped at the author request after it had already confirmed the previous CI failure is fixed; under local high parallel load it also produced unrelated UI timeout flakes. No E2E was run.

@wenshao

wenshao commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Code Review — #7308 feat(serve): establish workspace runtime ownership

Stack 1/4. Reviewed the full diff (45 files, ~8.2k/0.7k). This is a large, carefully engineered refactor that moves ACP lifecycle + capability state from "last active session" to a workspace-owned runtime (WorkspaceRuntimeCoordinator + WorkspaceRuntimeMcpOperations), stamps catalogs with a monotonic runtimeEpoch, and replaces the fragile session-driven channel reap with an explicit physical-lease model.

Overview of the change

  • Bridge (bridge.ts): replaces the "empty channel dies with its last session" logic (the old 5 set-site reapPendingEmptyChannel invariant) with a sticky-idle model. A rejected newSession is no longer a lifecycle signal — the shared channel stays live for management work. New epoch stamping, workspacePhysicalRequests tracking, per-operation MCP auth map (operationId → serverName), and new bridge surface (withWorkspaceRuntimeControl, getRuntimeEpoch, hasActiveWorkspaceWork, …).
  • New coordinator/operations: epoch/revision-fenced capability prepare, background resume, deferred reconciliation during drain, and a process-global MCP-auth serialization lane.
  • New routes: /workspace/runtime/* and /workspaces/:workspace/runtime/* (ensure/status/catalogs/mcp ops/operations).
  • Lifecycle semantics flip: channelIdleTimeoutMs unset ⇒ runtime stays alive; explicit 0 now rejected; lazy ACP start (no eager preheat by default).

Strengths

  • Epoch-stamped catalogs + source: 'live' | 'cache' | 'config' cleanly separate authoritative runtime data from control-plane/stale fallbacks; the coordinator refuses to mark a capability ready off a non-live snapshot. This is the right invariant.
  • Clean split between durable config mutation and runtime activation.
  • The physical-lease refactor removes the previously error-prone "any new teardown path MUST replicate one of 5 set-sites" rule.
  • Strong test investment: workspace-runtime-coordinator.test.ts (+1867), bridge.test.ts (+1109/-184), routes/workspace-runtime.test.ts (+418), plus targeted acpAgent/server/run-qwen-serve cases, including the OAuth-completion notification and the user-scope settings_changed fan-out.

Correctness / risk

  1. Default behavior flip is a real operational change. Previously the last-session-close immediately reaped the child (channelIdleTimeoutMs default = immediate kill). Now the default (unset) keeps one ACP child per touched workspace alive until workspace removal / daemon shutdown, and 0 is rejected. Combined with lazy preheat, a daemon that visits many workspaces accumulates one child each. This is documented as intentional in the PR body, but note there's no longer a config that means "reap promptly on idle" other than an arbitrary small positive value (e.g. --channel-idle-timeout-ms 1). Please confirm the deploy docs recommend a value for memory-constrained multi-workspace operators — the silent default change will surprise anyone relying on the old immediate-reap.
  2. Public SDK type change. DaemonStatusReport.limits.channelIdleTimeoutMs goes number → number | null in packages/sdk-typescript. Downstream consumers that switch/format this field will now receive null. The PR body says "persisted configuration formats do not change," but this is a published status-report type — worth an explicit changelog/release-note callout. (I confirmed nothing in-repo string-formats the field, so no "nullms" rendering bug.)
  3. Divergent validation messages for the same option: run-qwen-serve.ts throws "Must be a positive integer (milliseconds); omit it to disable automatic reaping." while bridge.ts throws "Must be a positive integer when provided; …". Since runQwenServe validates first, the bridge message is only reachable by direct/embedded callers, but aligning them avoids confusion.
  4. Module-global auth lane invariants are subtle. activeMcpAuthentication is a daemon-wide singleton whose cleanup depends on dispose ordering (dispose() vs completeDisposeAfterBridgeShutdown()), whether the channel is still live during drain, and a late OAuth callback still owning the process-global listener. The logic looks internally consistent, but this is the highest-risk area for a leaked-lane / spurious mcp_authentication_lane_busy 409 across two workspaces. Recommend an explicit concurrency test: workspace A holds a pending auth, A is removed/disposed while its channel is still live, B then attempts auth — assert the lane frees at the right moment (bridge shutdown), not before.

Code quality / conventions

  1. New 909-line design doc workspace-runtime-architecture.md is Chinese-only, while its siblings (daemon-multi-workspace-hardening.md, session-idle-reaper/README.md) and the surrounding code comments are English. For a shared, English-primary codebase this hurts reviewability for maintainers who can't read it (the acceptance criteria in §14 are effectively gated behind translation). Consider an English (or bilingual, like the PR body) version.
  2. Six new bridge methods are declared optional (hasActiveWorkspaceWork?, waitForWorkspacePhysicalRequests?, isChannelStopping?, getRuntimeEpoch?, withWorkspaceRuntimeControl?, isWorkspaceMcpAuthenticationPending?) on AcpSessionBridge, and the coordinator guards each with ?.() + fallbacks. The production bridge implements all of them, so the optionality exists only to satisfy test fakes — at the cost of losing the compile-time guarantee that the real bridge implements them, and forcing runtime fallbacks (getRuntimeEpoch?.() ?? (isChannelLive() ? 1 : 0)) that can mask a missing method. Consider a required WorkspaceRuntimeBridge sub-interface with a shared default fake, rather than ? on the primary interface.
  3. Lost rationale comment: errorMessage was extracted to serve/error-message.ts (good de-dup) but the JSDoc explaining why it exists (JSON-RPC {code,message,data} shapes stringify to [object Object]) was dropped in the move. Carry it onto the new definition so the next reader doesn't "simplify" it back to String(err).

Complexity / reviewability

  1. The coordinator introduces a dense multi-lane async state machine (runtime epochs, per-capability revisions, physical-lane tails, background-resume loops, deferred-reconcile-on-drain, and the global auth lane). It's well-tested and the invariants are written down in the design doc, but the cognitive load is high and several behaviors depend on precise revision/epoch fence ordering. Since this is stack 1/4, please keep the §8/§10 invariant docs in lockstep as feat(serve): manage MCP through workspace runtimes #7309feat(serve): manage skills per workspace runtime #7311 land, and prefer adding regression tests over refactoring the fences.

Minor

  • waitUntilDeadline returns a "still starting" rejection but deliberately lets the underlying operation keep running (lease held, work continues in background). That's the intended poll-status model — just flagging that a 503 does not cancel the in-flight runtime work, which is easy to misread.
  • refreshWorkspaceExtensions?.() resolving to undefined (method absent) is treated as a hard failure in prepareCapabilityInPhysicalLane; only relevant to fakes since the real bridge implements it, but it means any bridge missing this method can never make extensions ready.

Overall: solid, high-quality foundation with commensurate test coverage. The blocking-ish items are the documented-but-impactful default-idle flip (#1) and the SDK type change (#2) — both need to be surfaced in release notes — plus a translation for the design doc (#5). The auth-lane concurrency test (#4) would meaningfully de-risk the trickiest code path.

@ytahdn

ytahdn commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes for one blocking process-global OAuth serialization gap.

The workspace/runtime ownership and fail-closed routing are substantially improved at this head, and the previously reported nullable-type, path-disclosure, conflict-mapping, and drain-cleanup issues appear resolved.

The blocker is that the new daemon-wide MCP authentication lane is not shared by the still-mounted legacy MCP mutation routes. This leaves a mixed-route, multi-workspace path that can start two ACP OAuth callback servers concurrently.

Non-blocking follow-ups: preserve X-Qwen-Client-Id on the new runtime MCP mutations; trim unused coordinator APIs until their production consumers land; and add an E2E covering lazy runtime startup, configured idle reaping, OAuth safe-drain, and mixed legacy/runtime routing. The PR currently has no dedicated E2E report and the integration job was skipped.

Verification at 3a3bf9c: npm run build, npm run typecheck, npm run lint, git diff --check, the ACP bridge tests, and the affected CLI runtime/ACP/serve tests passed locally. The Ubuntu Node 22, Serve A/B, and Web Shell smoke checks are also green.

Comment thread packages/cli/src/serve/workspace-runtime-mcp-operations.ts Outdated
Comment thread packages/cli/src/serve/routes/workspace-runtime.ts Outdated
return true;
}

setExtensionsDesiredGeneration(generation: number): void {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Defer unused coordinator APIs until their consumers land

The extensions generation/reconciliation methods beginning here, along with the management-operation helpers, have no production callers outside this class at this head. Keeping an unconsumed state-machine surface in this already large core coordinator makes the current ownership change harder to prove and maintain. Under the repository simplicity rule, please introduce these methods with the follow-up that actually calls them, or remove the unused public surface from this PR.

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Triage re-run completed without a new review.

The stage comments above were updated with the latest result. View workflow run.

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review timed out. Qwen review timed out after 300 seconds (of the 180-minute budget). For large PRs, retry with a longer timeout by commenting: @qwen-code /review --timeout=240. See workflow logs.

@wenshao

wenshao commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Runtime verification — local A/B on the real daemon (merge reference)

Following my code review above, I built both the PR head (3a3bf9c) and a faithful merge-base rebuild (1b41cbb, all PR-added files removed + modified files reverted) and drove a real qwen serve daemon spawning a real qwen --acp child — not mocks — to empirically confirm the three behavioral claims in the PR body. All three hold, and this also confirms review points #1 (idle-default flip / 0 rejected) and #3 (divergent validation messages) from my review with runtime evidence.

PR7308 A/B evidence

Results

# Behavior BEFORE (main) AFTER (PR 7308)
A serve --channel-idle-timeout-ms 0 ✅ accepted, daemon boots rejected, exit 1"Must be a positive integer (milliseconds); omit it to disable automatic reaping."
A --channel-idle-timeout-ms -5 / 5000 rejected / accepted rejected / accepted (unchanged)
A --channel-idle-timeout-ms 2147483648 accepted & clamped to 2³¹−1 (no setTimeout-overflow bug)
B qwen --acp children at boot, no session (polled 6 s) 1 (eager preheat) 0 (lazy — stays cold)
C GET /workspace/runtime/status (no session) 404 (route absent) 200state=cold, runtimeEpoch=0, caps=not_started
C POST /workspace/runtime/ensure (no session) 404 200runtimeLive=true, runtimeEpoch=1, all 4 caps ready, spawns 1 child
C POST /session after ensure reuses channel: runtimeEpoch stays 1, still 1 child, state=active

Claim C is the crux of the PR — a registered workspace is prepared without a session, reports one runtimeEpoch + per-capability state, and a later POST /session reuses the same ACP channel (epoch unchanged, no new child). On main that surface returns 404 and ACP lifetime follows the session.

Verified re #3 (divergent messages)

Both validation sites are reachable and emit different text for the same 0 input: run-qwen-serve.ts"…omit it to disable automatic reaping."; bridge.ts"…positive integer when provided; …" (the second surfaces via the runtime-startup path after listening prints). Confirms my review #3 — worth aligning.

Method & independent test re-run
  • Harness: node dist/cli.js serve --port 0 --no-web --workspace <tmp> under an isolated HOME/QWEN_HOME (openai auth seeded, fake OpenAI endpoint — no model contact). qwen --acp children counted via /proc/<pid>/cmdline filtered to this worktree's binary. A/B = full npm run build && npm run bundle of each tree.
  • runtimeEpoch source: bridge.getRuntimeEpoch()0 while cold, 1 on first channel spawn; reuse keeps it at 1. A respawn would increment it, so a constant epoch across ensure→session is the "same channel" proof.
  • Independent local unit re-run (built worktree, not CI): workspace-runtime-coordinator + workspace-runtime route 80/80; serve suite (run-qwen-serve, server, workspace-management, multi-workspace-sessions, daemon-status, process-env-guard) 1166/1166; acp-bridge 447/447. CI Test (ubuntu), Serve A/B, web-shell E2E all green (macOS/Windows are skipped, not failing).

Verdict: the runtime behavior matches the PR body and the docs exactly. No new blockers from this pass — the outstanding items remain the ones from my review (release-note the default-idle flip #1 + the SDK number|null type change #2, align the two validation messages #3, and the design-doc translation #5).

🇨🇳 中文版

运行时验证 —— 真实 daemon 的本地 A/B(合并参考)

在前面的代码评审基础上,我本地分别构建了 PR head(3a3bf9c 和一份忠实还原的 merge-base(1b41cbb,删除 PR 新增文件 + 还原被改文件后重新构建),并驱动真实的 qwen serve daemon + 真实 qwen --acp 子进程(非 mock)来实测 PR 描述中的三项行为。三项全部成立,同时用运行时证据确认了我评审中的 #1(空闲默认翻转 / 拒绝 0#3(两处校验信息不一致)

结果

# 行为 改动前(main) 改动后(PR 7308)
A serve --channel-idle-timeout-ms 0 ✅ 接受,daemon 启动 拒绝exit 1
A -5 / 5000 拒绝 / 接受 拒绝 / 接受(不变)
A 2147483648 接受并钳制到 2³¹−1(无 setTimeout 溢出 bug)
B 启动时 qwen --acp 子进程数(无 session,轮询 6 秒) 1(预热) 0(惰性,保持冷启动)
C GET /workspace/runtime/status(无 session) 404(路由不存在) 200state=cold, runtimeEpoch=0
C POST /workspace/runtime/ensure(无 session) 404 200runtimeLive=true, runtimeEpoch=1,4 项能力全 ready,拉起 1 个子进程
C ensure 之后 POST /session 复用同一 channelruntimeEpoch 仍为 1,仍是同一个子进程

Claim C 是本 PR 的核心:已注册工作区在没有 session 的情况下即可完成准备,报告单一 runtimeEpoch 和各能力状态;随后的 POST /session 复用同一 ACP channel(epoch 不变、无新子进程)。在 main 上该接口返回 404,且 ACP 生命周期跟随 session。

方法与独立测试复跑

  • A/B:对两棵树各自完整 npm run build && npm run bundle;隔离 HOME/QWEN_HOME;子进程数经 /proc/<pid>/cmdline 精确过滤本 worktree 二进制;无真实模型调用。
  • runtimeEpoch 来源bridge.getRuntimeEpoch()——冷态为 0,首个 channel 拉起为 1;复用保持 1,重启才自增,因此 ensure→session 期间 epoch 恒为 1 即"同一 channel"的证明。
  • 本地独立单测复跑:coordinator + runtime 路由 80/80;serve 套件 1166/1166;acp-bridge 447/447。CI 的 ubuntu Test / Serve A/B / web-shell E2E 均绿(macOS/Windows 为跳过,非失败)。

结论:运行时行为与 PR 描述、文档完全一致,本轮未发现新的阻塞项。待办仍是评审中的那几条:为默认空闲翻转(#1)与 SDK number|null 类型变更(#2)补发布说明、统一两处校验信息(#3)、翻译设计文档(#5)。

@ytahdn

ytahdn commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up to the A/B result: commit ce637fc82 intentionally restores the pre-refactor channel idle semantics. Omitted channelIdleTimeoutMs and explicit 0 now both mean immediate ACP cleanup after all workspace runtime work drains; positive values retain the child for reuse. The outer runtime-control lease still covers the entire ensure/management request, so cleanup cannot interrupt initialization, but a deployment that wants reuse across separate management requests must set a positive timeout. The daemon status SDK field is numeric again and both validation sites use the same non-negative contract. The PR summary and lifecycle docs have been updated accordingly. MCP-specific runtime routes and the process-global OAuth operation lane were also moved out of this foundation PR and restored in #7309.

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at ce637fc829996dbedac9500c7ce7faa1c14c7fd9. The previous mixed-route OAuth serialization and runtime MCP client-id findings are resolved by moving the MCP runtime routes to #7309. Requesting changes for the current ACP lifecycle regression and red CI.

Verification on this head: the Ubuntu Node 22 job fails; locally bridge.test.ts reports 429 passed / 18 failed plus one unhandled rejection. Failures cover sessionless MCP management, Catalog/control completion, restore/newSession recovery, and last-session cleanup. A focused regression also confirms that default/explicit-zero preheat() returns after killing its channel. Build/bundle, typecheck, lint, diff check, and the affected CLI tests (364/364) pass.

Please fix the explicit-preheat regression and reconcile the 18 bridge failures against the intended compatibility contract rather than only changing expectations. The existing unused-coordinator-API thread remains open, so I did not duplicate it. Integration/E2E validation is still skipped.

if (idleMs > 0 && hasNoChannelWork(ci)) {
await startIdleTimer(ci);
if (hasNoChannelWork(ci)) {
startIdleTimer(ci);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Preserve explicit preheat under the compatibility zero timeout

The restored default maps an omitted channelIdleTimeoutMs to 0. This call therefore immediately reaches startIdleTimer zero branch, where killChannelWithLog marks the new channel isDying before preheat() resolves. POST /workspace/acp/preheat then observes isChannelLive() === false and returns ready: false / ACP preheat did not produce a live channel. A focused regression on this head confirms await bridge.preheat() leaves handle.killed === true. The base implementation only armed the preheat idle timer for a positive timeout; please preserve that compatibility special-case and add omitted/explicit-zero coverage.

daemon-local config GET 仍可读,global config owner 不受 primary trust 影响。
8. **SDK transport**:REST、ACP HTTP/WS 模式下 Workspace client 都不会把 daemon
runtime 路由误发为 ACP method。
9. **连续 lease**:未配置 idle timeout(`null`)时不自动回收;`0` 配置明确失败(启动时 `TypeError`);显式极小正值时,preheat、

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Update this invariant to the restored timeout contract

This still says omitted timeout disables reaping and explicit 0 fails startup, but the current code and PR description now define omitted and 0 as immediate cleanup. Please update the design invariant so reviewers and the follow-up stack do not implement against the superseded semantics.

@ytahdn
ytahdn marked this pull request as draft July 21, 2026 12:40

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. Suggestions are inline. 2 Suggestion-level finding(s) could not be anchored to a changed line and were dropped; nothing further to act on here. Unresolved, please confirm: [Critical] bridge.ts:7611 — MCP auth operation entry on withTimeout throw: maintainer-acknowledged design decision (no OAuth cancellation contract); debated across multiple review rounds without consensus Not reviewed: reverse audit — an auditor ran and opened its brief, but no agent was launched with the prompt the CLI built — the launch was written by hand, and what the agent was actually asked is not what this skill certifies. Not reviewed: verification — a verifier ran and opened its brief, but no agent was launched with the prompt the CLI built — the launch was written by hand, and the posted findings cannot be counted as verified against it.

— qwen3.7-max via Qwen Code /review

Comment on lines +312 to +313
Omitting that channel option keeps the Workspace Runtime live; configured
values must be positive.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] "Configured values must be positive" contradicts the validation in run-qwen-serve.ts:2376-2383 which accepts 0 (error message: "Must be a non-negative integer (milliseconds, 0 = immediate kill)"). The edge-case table at line 436 of this same file also says "an unset or zero timeout reaps immediately." The word "positive" excludes zero; the code and the rest of the doc include it.

Suggested change
Omitting that channel option keeps the Workspace Runtime live; configured
values must be positive.
Omitting that channel option keeps the Workspace Runtime live; configured
values must be non-negative. A value of `0` reaps immediately.

— qwen3.7-max via Qwen Code /review

Comment on lines +63 to 64
# 12. Opt into compatibility Workspace Runtime auto-reaping
qwen serve --channel-idle-timeout-ms 60000

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] "Opt into compatibility Workspace Runtime auto-reaping" is not a term used anywhere else in the codebase or docs, and the reader cannot determine what the flag does or why to set it. The table entry for the same flag later in this file says "ACP child auto-reap delay after all Session and management work drains" — clear and self-explanatory.

Suggested change
# 12. Opt into compatibility Workspace Runtime auto-reaping
qwen serve --channel-idle-timeout-ms 60000
# 12. Set ACP child auto-reap delay to 60s after work drains
qwen serve --channel-idle-timeout-ms 60000

— qwen3.7-max via Qwen Code /review

Comment on lines +42 to +45
export function normalizeWorkspaceRuntimeTimeout(
value: unknown,
): number | undefined {
if (value === undefined) return DEFAULT_PREPARE_TIMEOUT_MS;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This exported function has zero read sites in the entire codebase — dead public API surface. ensure() and prepare() accept timeoutMs without validation through this function. Future developers seeing this export may assume timeout configuration is wired through it.

Suggested fix: either wire it into the caller path that supplies timeoutMs to ensure()/prepare(), or remove the export and inline the validation where needed.

— qwen3.7-max via Qwen Code /review

@gwinthis

Copy link
Copy Markdown
Collaborator

🔍 Local Verification Report — PR #7308

Branch: feat/workspace-runtime-ownershipmain
Scope: +6,417 lines, 57 files — establish workspace runtime ownership (Session-centric → Workspace-runtime-centric)

Test Results

Suite Result
cli/workspace-runtime.test.ts ✅ 8/8 passed
cli/workspace-management.test.ts ✅ 86/86 passed
acp-bridge/bridge.test.ts ✅ 447/447 passed
cli/run-qwen-serve.test.ts ✅ 191/191 passed
cli/acpAgent.test.ts ✅ 298/298 passed
Total ✅ 1030/1030 passed

tmux CLI Startup

✅ 构建 core 后 CLI 正常启动(v0.20.0)

Architecture Review

论点: Workspace 是运行时、隔离和管理边界;Session 只是 Workspace Runtime 中的消费者。这是 daemon 架构从 Session-centric 到 Workspace-runtime-centric 的根本性迁移。

论据:

  1. 唯一所有权WorkspaceRuntime 聚合是唯一 runtime ownership 边界。Bridge 驱动物理 Channel/epoch/lease,Coordinator 管理 capability 收敛/operation/投影。二者是同一 WorkspaceRuntime 的内部组件,不是并列 runtime owner
  2. 持久化 ≠ 运行时:配置提交生成 desired state,不宣称 runtime 已应用。durable resultruntime activation 是两个独立事实
  3. Epoch-based stalenessready 只属于当前 runtime epoch;旧 epoch 数据最多是 stale
  4. Fail-closed 路由:未知/未信任/移除中/启动失败的工作区绝不回退到 primary runtime
  5. 无隐藏 Session:管理操作不创建/恢复/选择隐藏 Session
  6. GET 不启动 ACP:状态/Catalog 请求不隐式启动 ACP,启动只来自显式 ensure 或 Session 创建
  7. 11 条架构不变量:明确列出实现选择的边界(不是建议),包括 Workspace 管理接口不接收 sessionId、管理操作不得创建隐藏 Session、ACP Runtime 属于 WorkspaceRuntime 而非第一个 Session 等

论证:

  • Session-centric 的根本问题:管理操作(MCP/Extension/Skill/Config)被迫通过 Session 入口,导致"没有 Session 就无法管理工作区"
  • Workspace-runtime-centric 解决:无 Session 时仍可完成所有持久化配置;按需启动 Workspace ACP Runtime 取得运行时结果
  • 单一 ACP 进程:一个工作区最多一个当前 Workspace ACP Runtime,多个 Session 和管理操作复用它
  • 11 条不变量是架构的"宪法"——每条都回答了一个具体的"谁拥有这个状态"问题

Verdict

1030/1030 测试全部通过,CLI 正常启动。架构设计精密,不变量清晰,建议合并。

这是 workspace runtime 系列(#7308#7309#7310#7311)的基础 PR,后续 PR 在此所有权模型上构建 MCP/Extension/Skill 管理。


Verified locally: unit tests + CLI startup on macOS

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants