Skip to content

feat(mesh): add hidden host session launcher - #11225

Merged
yiliang114 merged 9 commits into
codex/multi-agent-mesh-foundationfrom
codex/mesh-step-4-launcher
Sep 7, 2026
Merged

feat(mesh): add hidden host session launcher#11225
yiliang114 merged 9 commits into
codex/multi-agent-mesh-foundationfrom
codex/mesh-step-4-launcher

Conversation

@yiliang114

@yiliang114 yiliang114 commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

This stacked step adds the workspace-scoped hidden mesh host and its private launch route. The host is claimed under the mesh workspace lock, reused across launches, kept alive through the existing scheduled-task resume primitive, restored after the bridge reaper removes it, and excluded before pagination from persisted/live and live-only public session catalogs, including their public aggregate counts.

It also routes configured identities through the typed launcher from #11224 and returns started | capacity_wait | agent_unavailable | launch_failed without adding dispatcher behavior.

Why it's needed

Persistent mesh identities need one reusable runtime owner per workspace before the dispatcher and thread tools can safely bind runs to background agents. The host must survive bridge reaping without becoming a user-visible chat, while runtime launch remains a private daemon-to-child operation.

The acceptance document previously required a daemon-process observation in this step. There is intentionally no server-bootstrap caller until the dispatcher is added, so this PR records the equivalent real-AcpSessionBridge reaper test in-process with a fake ACP child and moves the daemon-process observation to step 6/7 instead of adding unused wiring.

Reviewer Test Plan

How to verify

  1. With the background-agent cap occupied, confirm a launch returns capacity_wait and creates no queued booking.
  2. Confirm a missing or disabled definition returns agent_unavailable without starting a runtime.
  3. Start two host owners concurrently for one workspace and confirm both resolve to one stored session while the losing session is closed.
  4. Reap the host with a 20 ms bridge idle timeout, then confirm a second channel resumes the same session and the next private launch returns started.
  5. Query public session-list paths and confirm persisted mesh hosts are filtered before pagination, including when the requested page would otherwise contain only the hidden host.
  6. Let resume exceed its caller deadline and confirm repeated ticks do not start a second resume.

Evidence (Before & After)

N/A — internal runtime and daemon bridge contracts only.

Tested on

OS Status
🍏 macOS ✅ core 218; host/keepalive 34; bridge 914; ACP child 623
🪟 Windows ⚠️ pending #11206 whole-branch CI
🐧 Linux ⚠️ pending #11206 whole-branch CI

The in-process bridge reaper used a 20 ms idle timeout. Reload completed in 4.3 ms against a 1,000 ms resume deadline, and the next launch returned started without recreating the bridge.

Environment (optional)

Node.js workspace with the existing repository dependencies. Targeted core and ACP bridge package builds passed.

Risk & Scope

  • Main risk or tradeoff: The session catalog now treats sourceType=mesh as internal and removes it from public list results; direct bridge ownership APIs still retain the full internal view.
  • Not validated / out of scope: Server-bootstrap dispatcher wiring, thread tools, live-model execution, and the daemon-process observation remain in step 6/7.
  • Breaking changes / migration notes: None.

Linked Issues

Stacked step 4 for #11206. Runtime prerequisite: #11224.

中文说明

这个 PR 做什么

这个 stacked 步骤增加工作区级隐藏 mesh host 及其私有启动路由。host 在 mesh 工作区锁内完成唯一认领,在多次启动间复用,通过现有 scheduled-task resume primitive 保活,在 bridge reaper 移除后恢复,并从 persisted/live 与 live-only 两条公开 session catalog 路径中隐藏。

它还通过 #11224 的类型化 launcher 启动已配置身份,返回 started | capacity_wait | agent_unavailable | launch_failed,但不提前加入 dispatcher 行为。

为什么需要

持久 mesh 身份需要一个可复用的工作区 runtime owner,dispatcher 和 thread 工具才能安全地把 run 绑定到后台 agent。host 必须能在 bridge 回收后恢复,同时不能显示成用户会话;runtime 启动只允许 daemon 到 child 的私有调用。

验收文档原先要求本步骤做 daemon 进程观测。当前在 dispatcher 加入前刻意没有 server-bootstrap 调用方,因此这个 PR 使用真实 AcpSessionBridge reaper 加 fake ACP child 做等价的进程内测试,并把 daemon 进程观测移到第 6/7 步,避免提前增加无调用方的 wiring。

Reviewer Test Plan

如何验证

  1. 占满后台 agent 容量后,确认启动返回 capacity_wait 且不创建 queued booking。
  2. 确认缺失或禁用的 definition 返回 agent_unavailable,且不启动 runtime。
  3. 同一工作区并发启动两个 host owner,确认二者最终指向同一持久 session,落败 session 被关闭。
  4. 用 20 ms bridge idle timeout 回收 host,确认第二个 channel 恢复同一 session,下一次私有启动返回 started
  5. 查询公开 session-list 路径,确认持久 mesh host 在分页前被过滤,包括请求页原本只包含隐藏 host 的情况。
  6. 让 resume 超过调用方 deadline,确认重复 tick 不会启动第二次 resume。

前后证据

不适用——仅内部 runtime 与 daemon bridge 契约。

测试环境

OS 状态
🍏 macOS ✅ core 218;host/keepalive 34;bridge 914;ACP child 623
🪟 Windows ⚠️ 等待 #11206 整分支 CI
🐧 Linux ⚠️ 等待 #11206 整分支 CI

进程内 bridge reaper 使用 20 ms idle timeout。相对 1,000 ms resume deadline,reload 用时 4.3 ms;下一次启动返回 started,bridge 没有重建。

环境(可选)

Node.js workspace,使用仓库现有依赖。定向 core 与 ACP bridge package build 通过。

风险与范围

  • 主要风险或取舍:session catalog 现在把 sourceType=mesh 视为内部来源并从公开列表结果移除;bridge 的直接 owner API 仍保留完整内部视图。
  • 未验证 / 不在范围:server-bootstrap dispatcher 接线、thread 工具、真实模型执行和 daemon 进程观测留在第 6/7 步。
  • 破坏性变更 / 迁移说明:无。

关联事项

#11206 的 stacked 第 4 步。runtime 前置 PR:#11224

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

Qwen Triage 已完成 —— 查看运行。结果见本线程中的各阶段评论。

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓ — every required heading is filled in, including the Risk & Scope bullets and the Chinese translation.

Problem: this is planned work rather than a bug claim, so there is no reproduction to ask for. It is stacked step 4 of #11206, and the design and acceptance documents it updates are already committed on the foundation branch. The prerequisite it names — one reusable runtime owner per workspace before a dispatcher can bind runs to background agents — is a real ordering constraint, not a hypothetical.

Direction: aligned. Multi-agent mesh is a maintainer-driven initiative with committed design docs under docs/plans/, and this step stays inside that plan instead of expanding it. Notably, the PR removes a previously required daemon-process observation from the acceptance doc and explains why (no server-bootstrap caller exists until the dispatcher lands), rather than adding unused wiring to satisfy the old wording. That is the right call and it is documented honestly.

Size: core paths are touched — this spans packages/core (including src/agents/ and src/services/), packages/cli, and packages/acp-bridge, which counts as cross-package core. Breakdown: 344 production lines, 432 test lines, 117 docs lines (893 total, matching 832 + 61). Under the 500-line threshold, so no size escalation, and well under the 1000-line large-PR advisory. The test-to-production ratio is better than 1:1, which is worth saying out loud.

Approach: scope feels right and genuinely minimal. Every edit earns its place: the new host module, the private launch route, the catalog and count exclusions, and the exports that tie them together. Two things I'd flag as not scope creep but easy to misread — the scheduled-task-keepalive.ts change looks like a drive-by refactor, but it is an extract-to-reuse (the mesh host calls the same resume-with-deadline primitive instead of growing a parallel copy), which is exactly what we want; and the docs/plans/ churn is the step recording its own evidence, which the plan format requires.

I raised two scope-level questions on the first pass, and both were answered by force-pushed follow-ups while the review was running: fix(mesh): filter hidden host before pagination moved the catalog exclusion ahead of paging in every path, and fix(mesh): hide host from session totals extended it to the count aggregate and replaced the scattered string literals with one shared constant. Both came with regression tests. Details in Stage 2.

Risk: elevated on two axes, so review depth is increased rather than skipped:

  • packages/cli/src/acp-integration/acpAgent.ts matches the high-risk path set (acp-integration) from the revert-history analysis. Focus there: the new ext-method route and its trust gate.
  • This PR gets no test signal from its own CI. ci.yml's pull_request trigger is filtered to main and release/**, and this PR targets codex/multi-agent-mesh-foundation, so the unit / lint / typecheck suite never fires. I re-checked at the current head: of the 13 check-runs on it, every one is bot orchestration or TUI parity, and the only pull_request-event run is tui-parity, which does not exercise this change. Stage 2 therefore treats every test number in the description as the author's local claim, not as evidence — the PR itself already labels Windows and Linux as pending feat(mesh): add persistent shared-thread agent collaboration #11206.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓ —— 所有必填小标题都填了,包括 Risk & Scope 和中文翻译。

问题: 这是计划内的工作,不是 bug 报告,所以不需要复现。它是 #11206 的 stacked step 4,其更新的设计文档与验收文档已经提交在 foundation 分支上。它提出的前置条件——dispatcher 把 run 绑定到后台 agent 之前,每个工作区需要一个可复用的 runtime owner——是真实的顺序约束,不是假设。

方向: 对齐。多 agent mesh 是维护者主导、docs/plans/ 下已有设计文档的方向,本步骤停留在该计划内,没有扩张。值得注意的是,这个 PR 删除了验收文档里原先要求的 daemon 进程观测项,并说明了原因(dispatcher 落地前不存在 server-bootstrap 调用方),而不是为了满足旧措辞去加无用接线。这个取舍是对的,也记录得很诚实。

规模: 触及核心路径——横跨 packages/core(含 src/agents/src/services/)、packages/clipackages/acp-bridge,属于跨包核心改动。拆分:生产代码 344 行、测试 432 行、文档 117 行(合计 893,与 832 + 61 一致)。低于 500 行阈值,无需按规模上升处理,也远低于 1000 行大 PR 提醒。测试与生产代码比例优于 1:1,这点值得明确肯定。

方案: 范围合理且确实最小化。每处改动都有必要:新的 host 模块、私有启动路由、catalog 与计数排除,以及把它们串起来的导出。有两点想说明——它们不是范围蔓延,但容易被误读:scheduled-task-keepalive.ts 的改动看起来像顺手重构,实际是抽取复用(mesh host 调用同一个带 deadline 的 resume 原语,而不是再长一份平行副本),这正是我们希望的;docs/plans/ 的改动是本步骤记录自身证据,属于计划文档格式的要求。

我在第一轮提了两个规模层面的疑问,两个都在审查过程中被 force-push 的后续提交回答了:fix(mesh): filter hidden host before pagination 把 catalog 排除移到了每条路径的分页之前,fix(mesh): hide host from session totals 把它扩展到计数聚合,并用一个共享常量替换了散落的字符串字面量。两者都带了回归测试。细节见 Stage 2。

风险: 两个维度都有升级,因此加深审查而不是跳过:

  • packages/cli/src/acp-integration/acpAgent.ts 命中 revert 历史分析里的高风险路径集合(acp-integration)。重点关注:新的 ext-method 路由及其信任门。
  • 这个 PR 从自身 CI 拿不到任何测试信号。 ci.ymlpull_request 触发被限定在 mainrelease/**,而本 PR 的目标分支是 codex/multi-agent-mesh-foundation,所以 unit / lint / typecheck 全都不会触发。我在当前 head 上重新确认过:其上 13 个 check-run 全部是 bot 编排或 TUI parity,唯一的 pull_request 事件运行是 tui-parity,而它不会执行本次改动。因此 Stage 2 会把描述里的所有测试数字视为作者本地声明,而非证据——PR 自己也把 Windows 和 Linux 标为等待 feat(mesh): add persistent shared-thread agent collaboration #11206

进入代码审查 🔍

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 8d9e5d010a3347d6903648b4469cf072f7db69c3 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Reviewed at head 8d9e5d0. The head moved twice while this review was running — 59d2b6bdfb8708 (fix(mesh): filter hidden host before pagination) → 8d9e5d0 (fix(mesh): hide host from session totals) — and both follow-ups answered findings from my first pass. Everything below is re-verified against the current head.

Code review

I wrote my independent proposal before opening the diff: a workspace-scoped host module that claims under the existing mesh lock, reuses a stored session, launches through the typed launcher, keeps alive by reusing the scheduled-task resume primitive with a deadline and a single-flight guard, and excludes sourceType === 'mesh' from the public session surfaces. The PR matches that shape and beats it in one place — rather than duplicating the resume-with-timeout logic, it extracts beginKeepaliveSessionResume out of scheduled-task-keepalive.ts and re-points the existing keepalive caller at it. I verified the extraction is semantics-preserving: the in-flight guard still clears on the resume's true settlement (.finally on completion), not on the timeout, and withTimeout keeps the same label. Constructing the timeout inside the helper instead of at the await site moves it a few statements earlier in the same synchronous tick, which changes nothing.

All four findings from the first pass are now resolved. I re-checked each against the current head rather than taking the commit messages at face value:

1. Count/list asymmetry — fixed. getWorkspaceSessionInfoForResponse now passes excludeSourceType: MESH_HOST_SESSION_SOURCE_TYPE into getSessionInfoCounts, and filters live off the same bridge.listWorkspaceSessions call the list paths use. The important check here was whether the new option is a dead switch: getSessionInfoCounts has exactly one production caller repo-wide — this function — so the option is genuinely populated, and its two consumers (serve/server.ts and the GET .../session-info route in routes/session.ts) are both public surfaces that should exclude the hidden host. The tests are real rather than mocked: one writes two JSONL files (a default and a mesh session) and asserts the disk scan reports active: 1, archived: 0, total: 1, live: 0, which exercises the exclusion end to end.

2. Post-paging filter — fixed. The outer .filter() on result.sessions is gone; exclusion now happens inside all five paths ahead of paging (organized 1053, metadata 1246, the persisted backfill loop 1412, the live merge 1456, the live-only path 1523). The regression test is load-bearing, not decorative: listSessions sorts mtime descending (sessionService.ts:2558-2559) and the test writes the mesh host one minute newer than the visible session, so the hidden one is encountered first and the backfill loop actually has to iterate. It also asserts nextCursor is undefined, pinning that the loop consumed the list instead of leaving a dangling cursor, and asserts an explicit sourceType: 'mesh' query returns empty — settling the question I had about whether that path should stay reachable through the public helper.

3. Stale-host recovery — now explicitly scheduled and gated. This was my "nothing ever clears a stale hostSessionId" finding. Step 8 of the acceptance doc now names it in both Lands ("stale host-session binding replacement after a definitive resume failure") and Gate ("a stored host session that cannot be resumed is replaced once"), which is what I asked for — it is no longer an unpinned gap, it is a committed step-8 deliverable with a named test.

4. String-literal coupling — fixed. The constant moved to a new single-purpose module serve/mesh/mesh-session-source.ts, imported by both mesh-host-session.ts and session-list.ts, so 'mesh' now appears in exactly one place instead of six. No import cycle, since the module has no dependencies of its own.

Things I checked that came out clean, since the missing CI means nobody else will:

  • The trust-gate reword does not break existing tests. Widening the privateParentState !== 'trusted' check to cover the new route changes the message from "Background notifications require a trusted private ACP parent" to "This operation requires a trusted private ACP parent". The three existing assertions on the base branch match the substring trusted private ACP parent, so all three still hold.
  • It should compile. SessionListItem declares sourceType?: string (sessionService.ts:166), so the backfill loop's item.sourceType is valid, and sourceType is an open string in bridgeTypes.ts too, so 'mesh' needs no union widening. Worth stating only because no typecheck lane runs here.
  • The backfill loop is bounded. The loop re-enters only while persistedItems.length < pageSize, so size: pageSize - persistedItems.length is always ≥ 1 and never zero or negative; each iteration pushes at most what it asked for, so a page can never over-collect past pageSize; and the cursor advances monotonically through a finite persisted list until listSessions returns nextCursor: undefined. Since the host is a singleton per workspace, this runs at most twice in practice. One nit I am not asking anyone to change: readOptions.signal?.throwIfAborted() sits after the loop rather than between iterations, so a cancelled request finishes its backfill pages first — moot at two iterations.
  • The count exclusion does not make an already-expensive walk unbounded. extractCreationMetadataFromFile returns immediately when the head records already carry sourceType, and only otherwise falls back to readSessionSourceFromTail, which reads at most TAIL_READ_SIZE bytes from the end of the file. The updated doc comment's "one bounded tail read" claim is accurate. session-info stays expensive: true / cost: 'disk_scan' and still must not be polled tightly.
  • Rejection hygiene in ensure() is correct. completion carries a .catch(() => {}), every path that sets reviving immediately awaits its deadline, and the single-flight guard clears on completion rather than deadline — so a hung resume keeps blocking duplicates instead of leaking a second one. That is what the "does not duplicate a resume that outlives its deadline" test pins.
  • The claim path fails closed. When hostSessionId is set and the heartbeat throws, ensure() awaits the revive deadline and propagates the failure rather than returning a session id it could not confirm alive. The loser of a concurrent claim closes its own session and recurses, and recursion is bounded because hostSessionId is stable once written under withWorkspaceLock.

I have no remaining code-level findings. What I cannot give you is execution evidence — see Testing.

sequenceDiagram
    participant P1 as Caller (step 6 dispatcher)
    participant P2 as startMeshHostSessionOwner
    participant P3 as mesh-store
    participant P4 as AcpSessionBridge
    participant P5 as acpAgent child
    P1->>P2: launch(agent, prompt)
    P2->>P3: readMeshWorkspace(cwd)
    P3-->>P2: hostSessionId or empty
    alt host already claimed
        P2->>P4: recordHeartbeat(hostSessionId)
        P4-->>P2: throws when reaped
        P2->>P4: resumeSession under deadline (single flight)
    else no host yet
        P2->>P4: spawnOrAttach(sourceType mesh)
        P2->>P3: claimMeshHostSession under workspace lock
        P3-->>P2: winner id (loser closes its own session)
    end
    P2->>P4: launchMeshAgent(sessionId, agentId, prompt)
    P4->>P5: ext method session mesh-agent launch
    P5-->>P4: started, capacity_wait, agent_unavailable, or launch_failed
    P4-->>P2: typed result
    P2-->>P1: typed result
Loading
Files changed (16)
File What changed
packages/cli/src/serve/mesh/mesh-host-session.ts New 132-line host owner: claim, heartbeat, revive under deadline, launch, interval tick
packages/cli/src/serve/mesh/mesh-host-session.test.ts New 362-line suite: singleton revive, concurrent-claim loser, real-bridge reaper reload, catalog and count exclusion, pre-pagination filtering, resume dedup
packages/cli/src/serve/mesh/mesh-session-source.ts New 7-line module holding the single source-type constant (finding 4)
packages/cli/src/serve/server/session-list.ts Excludes mesh from five list paths ahead of paging, plus the count aggregate and live count
packages/cli/src/serve/scheduled-task-keepalive.ts Extracts beginKeepaliveSessionResume and re-points the existing revive caller at it
packages/cli/src/acp-integration/acpAgent.ts New ext-method route plus widening of the trusted-parent gate to cover it
packages/cli/src/acp-integration/acpAgent.test.ts Route test asserting a configured agent launches only from a trusted mesh host
packages/core/src/services/sessionService.ts Adds an optional source exclusion to the session-info count walk (finding 1)
packages/core/src/agents/mesh/mesh-store.ts Adds claimMeshHostSession — singleton claim under the workspace lock
packages/core/src/agents/index.ts Exports the claim, agent-read, and workspace-read helpers plus the workspace state type
packages/acp-bridge/src/bridge.ts Implements launchMeshAgent by routing through the owning session's control channel
packages/acp-bridge/src/bridgeTypes.ts Declares the launch result alias and the bridge method signature
packages/acp-bridge/src/status.ts Registers the new private control ext-method name
packages/acp-bridge/src/bridge.test.ts Asserts mesh launches route through the owning session with the right params
docs/plans/2026-09-06-multi-agent-board-collaboration.md Updates status, module map, and recorded step-4 test evidence
docs/plans/2026-09-07-mesh-implementation-acceptance.md Rewrites gate (d) to an in-process reaper, defers the daemon observation to step 6/7, and schedules stale-host replacement in step 8

Testing

This PR has no test signal from its own CI, and that is structural rather than a flake. ci.yml's pull_request trigger is filtered to main and release/**; this PR targets codex/multi-agent-mesh-foundation, so the unit, lint, typecheck, and integration jobs never fire. I re-fetched at the current head: all 13 check-runs on 8d9e5d0 are bot orchestration or TUI parity, and there is no test, lint_and_static, typecheck, or integration_no_ak check in any state. The single pull_request-event run is tui-parity, which renders terminal UI and does not execute this change. I did not run the PR's tests or build myself: the review is static, and the agent environment here holds a write token that PR-derived code could read.

So the numbers in the description — core 218, host/keepalive 33, bridge 914, ACP child 623, and the 4.3 ms reload after a 20 ms reap — are the author's local claims on macOS, not verified evidence. They also predate both force-pushes, so they do not account for the pagination or totals tests either. The PR labels them honestly, marking Windows and Linux as pending #11206's whole-branch CI, and the acceptance doc repeats the same caveat. I am not disputing them; I am noting that nothing in this thread independently confirms them, and the gate that would (the #11206 whole-branch run) has not happened.

Not verified: any test, lint, typecheck, or build result — no CI lane covers this base branch, so "it compiles and the suite passes" is an inference from reading, not an observation. Not verified: Windows and Linux behaviour, per the author's own table. Not verified: every behavioural claim below, because the module has no production caller yet and its suite is the only thing that exercises it.

Check Conclusion
OpenTUI no-flicker gate in_progress
TUI parity snapshots (ink vs opentui) in_progress
delay-automatic-review waiting
assign success
label success (1), cancelled (1)
authorize success
Remind on force-push success
review-config, resolve-pr, precheck-pr, ack-review-request, publish-resolution skipped
test / lint_and_static / typecheck / integration_no_ak not scheduled — base branch is outside the ci.yml pull_request filter

One consequence worth being explicit about, because it changes what "wait for green" would mean here: deferring approval until CI lands is not a usable remedy on this branch. The only CI that will ever complete for this PR is tui-parity, which does not touch the mesh host, the catalog exclusion, or the ACP route — so a green result would say nothing about this change. Real evidence has to come from either #11206's whole-branch gate (the PR's own declared bar, not yet run) or a sandboxed lane.

Sandboxed verification would settle this: @qwen-code /verify — the central claims are behavioural and nothing above substantiates them. Specifically, that two concurrent owners resolve to one stored host while the loser's session is closed, that a reaped host reloads and the next launch returns started without recreating the bridge, that repeated ticks past a resume deadline never start a second resume, that the persisted backfill loop returns a full page of visible sessions when a hidden host sorts ahead of them, and that the session-info counts now agree with the list. All of these rest on a suite no CI lane runs, against a module with no production caller, so the tests are the only oracle and nobody has independently executed them. The author has write access, so this needs no sponsorship. @qwen-code /tmux is not the right lane — there is no TUI surface in this step.

中文说明

本次审查基于 head 8d9e5d0。审查过程中 head 移动了两次——59d2b6bdfb8708fix(mesh): filter hidden host before pagination)→ 8d9e5d0fix(mesh): hide host from session totals)——两个后续提交都回应了我第一轮的发现。下面所有内容都已针对当前 head 重新验证。

代码审查

我在打开 diff 之前先写了自己的独立方案:一个工作区级 host 模块,在现有 mesh 锁内完成认领,复用已存储的 session,通过类型化 launcher 启动,靠复用 scheduled-task 的 resume 原语(带 deadline 与单飞守卫)保活,并把 sourceType === 'mesh' 从公开 session 界面中排除。这个 PR 的结构与我的方案一致,并且在一处做得更好——它没有复制 resume 加超时的逻辑,而是从 scheduled-task-keepalive.ts 里抽出 beginKeepaliveSessionResume,再让原有的 keepalive 调用方指向它。我验证了抽取保持语义不变:in-flight 守卫仍然在 resume 的真实结束(completion 上的 .finally)时清除,而不是在超时时清除,withTimeout 的标签也没变。把超时构造挪进 helper 只是让它在同一个同步 tick 里早了几条语句,行为无差异。

第一轮的四个发现现在都已解决。 我针对当前 head 逐条复核,而不是只看提交信息:

1. 计数/列表不对称——已修。 getWorkspaceSessionInfoForResponse 现在把 excludeSourceType: MESH_HOST_SESSION_SOURCE_TYPE 传进 getSessionInfoCounts,并且对 live 也用列表路径所使用的同一个 bridge.listWorkspaceSessions 调用做过滤。这里关键的一步核查是:这个新选项会不会是个"死开关"。全仓库 getSessionInfoCounts 只有一个生产调用方——就是这个函数——所以该选项确实被填充了;而它的两个消费者(serve/server.tsroutes/session.ts 里的 GET .../session-info 路由)都是本应排除隐藏 host 的公开界面。测试是真实的而非 mock:其中一个写了两个 JSONL 文件(一个 default、一个 mesh),断言磁盘扫描报出 active: 1, archived: 0, total: 1, live: 0,端到端地验证了这个排除。

2. 分页后过滤——已修。result.sessions 的外层 .filter() 已移除;排除现在发生在全部五条路径内部、分页之前(organized 1053、metadata 1246、persisted backfill 循环 1412、live 合并 1456、live-only 路径 1523)。回归测试是真正吃重的,不是装饰:listSessions 按 mtime 降序排序(sessionService.ts:2558-2559),而测试把 mesh host 写得比可见 session 一分钟,所以隐藏的那个会先被遇到,backfill 循环必须真的迭代。它还断言 nextCursorundefined,钉住了循环消费完整个列表而没有留下悬空 cursor;并断言显式 sourceType: 'mesh' 查询返回空——解决了我原先关于该路径是否应继续通过公开 helper 可达的疑问。

3. 失效 host 恢复——现已明确排期并设 gate。 这是我那条"没有任何地方清除失效 hostSessionId"的发现。验收文档的 Step 8 现在在 Lands("stale host-session binding replacement after a definitive resume failure")和 Gate("a stored host session that cannot be resumed is replaced once")里都点了名,这正是我要求的——它不再是一个没有 gate 钉住的缺口,而是 step 8 一项有具名测试的既定交付。

4. 字符串字面量耦合——已修。 常量搬到了新的单一职责模块 serve/mesh/mesh-session-source.ts,由 mesh-host-session.tssession-list.ts 共同导入,因此 'mesh' 现在只出现在一个地方,而不是六处。没有循环引用,因为该模块自身没有依赖。

由于 CI 缺失、别人不会去查,我把查过且干净的几点列出来:

  • 信任门的措辞修改不会破坏现有测试。privateParentState !== 'trusted' 检查扩展到新路由,使消息从 "Background notifications require a trusted private ACP parent" 变成 "This operation requires a trusted private ACP parent"。基线分支上现有的三条断言匹配的是子串 trusted private ACP parent,因此三条都仍然成立。
  • 它应该能编译。 SessionListItem 声明了 sourceType?: string(sessionService.ts:166),所以 backfill 循环里的 item.sourceType 合法;bridgeTypes.ts 里的 sourceType 同样是开放字符串,因此 'mesh' 不需要扩宽联合类型。之所以值得说明,只是因为这里没有 typecheck lane 会跑。
  • backfill 循环是有界的。 只有在 persistedItems.length < pageSize 时才会再次进入循环,因此 size: pageSize - persistedItems.length 始终 ≥ 1,绝不会为零或负;每轮最多推入它所请求的数量,因此一页永远不会超出 pageSize;cursor 在有限的 persisted 列表上单调前进,直到 listSessions 返回 nextCursor: undefined。由于 host 在每个工作区是单例,实际上最多跑两轮。有一个我不打算要求任何人改的小点:readOptions.signal?.throwIfAborted() 位于循环之后而不是各轮之间,所以被取消的请求会先跑完它的 backfill 页——在两轮的情况下无关紧要。
  • 计数排除没有让本已昂贵的遍历变得无界。 extractCreationMetadataFromFile 在头部记录已带 sourceType 时立即返回,只有在没有时才回落到 readSessionSourceFromTail,而后者最多从文件尾部读取 TAIL_READ_SIZE 字节。更新后的文档注释里"一次有界尾部读取"的说法是准确的。session-info 仍然是 expensive: true / cost: 'disk_scan',仍然不能被紧密轮询。
  • ensure() 的 rejection 处理是正确的。 completion 带了 .catch(() => {}),每条设置 reviving 的路径都立刻 await 它的 deadline,并且单飞守卫是在 completion 而非 deadline 上清除的——所以 resume 挂住时会持续阻止重复,而不是泄漏出第二个。这正是 "does not duplicate a resume that outlives its deadline" 测试所钉住的行为。
  • 认领路径是 fail-closed 的。hostSessionId 已存在而心跳抛错时,ensure() 会 await revive 的 deadline 并把失败向上传播,而不是返回一个无法确认存活的 session id。并发认领的失败方会关闭自己的 session 再递归,而且递归有界,因为 hostSessionIdwithWorkspaceLock 下写入后即稳定。

我没有剩余的代码层面发现。我给不出的是执行证据——见"测试"。

测试

这个 PR 从自身 CI 拿不到任何测试信号,而且这是结构性的,不是 flake。 ci.ymlpull_request 触发被限定在 mainrelease/**;本 PR 目标是 codex/multi-agent-mesh-foundation,所以 unit、lint、typecheck、integration 全都不会触发。我在当前 head 上重新拉取:8d9e5d0 上全部 13 个 check-run 都是 bot 编排或 TUI parity,任何状态下都不存在 testlint_and_statictypecheckintegration_no_ak 检查。唯一的 pull_request 事件运行是 tui-parity,它渲染终端 UI,不会执行本次改动。我没有自己运行 PR 的测试或构建:审查是静态的,而且这里的 agent 环境持有写权限 token,PR 派生的代码可以读到它。

因此描述里的数字——core 218、host/keepalive 33、bridge 914、ACP child 623,以及 20 ms reap 后 4.3 ms reload——是作者在 macOS 上的本地声明,不是已验证的证据。它们也早于两次 force-push,因此同样没有把分页或计数测试计入。PR 对此标注得很诚实,把 Windows 和 Linux 标为等待 #11206 的整分支 CI,验收文档也重复了同样的 caveat。我不是在质疑这些数字;我是在说明本线程里没有任何东西独立确认过它们,而本该确认它们的那道门(#11206 整分支运行)还没有发生。

未验证:任何测试、lint、typecheck 或构建结果——没有 CI lane 覆盖这个基线分支,所以"它能编译、套件能通过"是阅读得出的推断,不是观测。未验证:Windows 与 Linux 行为,依据作者自己的表格。未验证:下面所有行为性声明,因为该模块尚无生产调用方,其测试套件是唯一会执行它的东西。

有一点值得说清楚,因为它改变了"等绿"在这里的含义:在这个分支上,把批准推迟到 CI 落地并不是一个可用的补救。本 PR 唯一会完成的 CI 是 tui-parity,而它不接触 mesh host、catalog 排除或 ACP 路由——所以绿了也说明不了本次改动的任何事。真实证据只能来自 #11206 的整分支 gate(PR 自己声明的标准,尚未运行),或者一个沙箱化 lane。

沙箱化验证可以定这件事:@qwen-code /verify——核心声明都是行为性的,而上面没有任何东西能证实它们。具体说:两个并发 owner 是否解析到同一个已存储 host、失败方的 session 是否被关闭;被 reap 的 host 是否重新加载、下一次 launch 是否返回 started 且不重建 bridge;超过 resume deadline 的重复 tick 是否绝不启动第二个 resume;当隐藏 host 排序在可见 session 之前时,persisted backfill 循环是否返回满页的可见 session;以及 session-info 计数现在是否与列表一致。这些都只依赖一个没有任何 CI lane 会运行的套件,针对一个没有生产调用方的模块,所以测试是唯一 oracle,而没有人独立执行过它。作者有写权限,因此不需要 sponsor。@qwen-code /tmux 不是合适的 lane——本步骤没有 TUI 界面。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 8d9e5d010a3347d6903648b4469cf072f7db69c3 · re-run with @qwen-code /triage

@yiliang114

Copy link
Copy Markdown
Collaborator Author

Addressed three of the four stage-2 findings:

  • dfb8708659f3 filters hidden mesh-host sessions before pagination, so a public size=1 page no longer comes back empty.
  • 8d9e5d010a33 excludes the hidden host from public session-info persisted/live counts and centralizes the local mesh source tag. Runtime bridge capacity still includes the host by design because it consumes a real session slot.
  • The stale persisted hostSessionId case is now an explicit Step 8 acceptance item: after a definitive resume failure, the dispatcher must replace the binding exactly once. It is not implemented in Step 4 because recovery belongs to the dispatcher reliability step.

Verified at the current head with mesh-host-session.test.ts (6/6) and sessionService.test.ts (251/251). No remaining Step 4 blocker from this review.

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 3/5 — the code review came out clean and I have no remaining findings; the cap is evidentiary, not doubt about quality. Nothing in CI compiles or runs this change, so approving would attest to a build and test result that does not exist.

Stepping back: this is a well-built stacked step, and it beats the baseline I wrote before opening the diff. I had proposed reusing the scheduled-task resume primitive; the PR went further and extracted beginKeepaliveSessionResume so both callers share one deadline implementation instead of growing a copy. The concurrency handling is right where it matters — the claim is lock-protected, the loser closes its own session, the revive path fails closed rather than handing back an unconfirmed session id, and the single-flight guard clears on the resume's true settlement so a hung load keeps blocking duplicates. Test volume exceeds production volume. The documentation is unusually honest: it removed a daemon-process observation from the acceptance doc and said why, instead of adding unused wiring to satisfy the old wording, and it labels Windows and Linux as untested rather than leaving the row blank.

What I'd most credit is how the review landed. The head moved twice while I was reading it, and both pushes were direct answers to findings rather than unrelated churn: the pagination fix moved the exclusion ahead of paging in all five catalog paths, and the totals fix closed the count/list asymmetry and collapsed six scattered 'mesh' literals into one shared constant. Both came with tests that actually bite — the pagination one writes the hidden host newer than the visible session so the backfill loop has to iterate (ordering matters, since listSessions sorts mtime descending), and the totals one asserts against a real disk scan of two written JSONL files rather than a mock. The stale-host gap I raised was not fixed here but was explicitly scheduled into step 8's Lands and Gate, which is the right answer for a stacked plan. I checked the new excludeSourceType option for the classic dead-switch failure — it has exactly one production caller repo-wide, and that caller populates it.

So why am I still not approving?

There is no CI evidence to approve against, and on this branch there never will be. ci.yml gates pull_request on main and release/**; this PR targets codex/multi-agent-mesh-foundation, so unit, lint, typecheck, and integration never fire. All 13 check-runs on the current head are bot orchestration or TUI parity. The one pull_request run — tui-parity — renders terminal UI and does not touch the mesh host, the catalog exclusion, or the ACP route. That matters because it removes the usual remedy: deferring approval until CI goes green is meaningless here, since the only green that can arrive says nothing about this change. Everything I believe about correctness is inference from reading. I could not find a compile break (SessionListItem declares sourceType?: string; the reworded trust-gate message still contains the substring the three existing assertions match), the backfill loop provably terminates and never over-collects, and the added count-filter read is bounded by TAIL_READ_SIZE with a short-circuit when the head records already carry the source type — but "I could not break it by reading" is not "it builds and passes". For a cross-package core change that now also touches packages/core/src/services/, with acp-integration in the high-risk path set, that gap is the whole ballgame.

And the PR's own declared gate has not run. The description and the acceptance doc both name #11206's whole-branch CI as the bar for this step. #11206 is still open against main, so this PR cannot land first regardless, and the evidence it defers to does not exist yet.

I am deliberately not requesting changes — there is nothing left to request. This is a defer on evidence, not on merit.

⏸️ Deferring to @qqqys — you own the core-agents area this touches, and the repo's own PR-owner resolver already assigned you. The code is clean as far as static review can tell; what needs a human is the evidence call. Two options, and I'd take the first: run @qwen-code /verify to get independent A/B proof of the behavioural claims (concurrent-owner singleton, reaper reload returning started without recreating the bridge, resume dedup past deadline, pre-pagination backfill, and count/list agreement) — the author has write access, so it needs no sponsorship; or hold this until #11206's whole-branch CI runs and lets that gate cover the stack. The one thing I'd avoid is approving on the strength of the local macOS numbers in the description, because nothing here has executed them.

中文说明

Confidence: 3/5 —— 代码审查结果是干净的,我没有剩余发现;这个封顶是证据层面的,不是对质量的怀疑。CI 里没有任何东西会编译或运行这次改动,所以批准将等于为一个并不存在的构建与测试结果背书。

退一步看:这是一个构建良好的 stacked step,并且优于我在打开 diff 之前写下的基线方案。我原本的设想是复用 scheduled-task 的 resume 原语;这个 PR 更进一步,抽出了 beginKeepaliveSessionResume,让两个调用方共享同一份 deadline 实现,而不是各自长一份副本。并发处理在关键处是对的——认领受锁保护、失败方关闭自己的 session、revive 路径 fail-closed 而不是交回一个未确认存活的 session id、单飞守卫在 resume 真实结束时清除,因此挂住的加载会持续阻止重复。测试量超过生产代码量。文档异常诚实:它从验收文档里删除了一项 daemon 进程观测并说明原因,而不是为满足旧措辞添加无用接线;也把 Windows 和 Linux 标为未测试,而不是留空。

我最想肯定的是这轮审查的落地方式。审查过程中 head 移动了两次,而两次推送都是对发现的直接回应,不是无关的翻动:分页修复把排除移到了全部五条 catalog 路径的分页之前,计数修复关闭了计数/列表不对称,并把散落在六处的 'mesh' 字面量收敛为一个共享常量。两者都带了真正咬得住的测试——分页那个把隐藏 host 写得比可见 session 更新,于是 backfill 循环必须迭代(顺序很关键,因为 listSessions 按 mtime 降序排序);计数那个断言的是对两个真实写入的 JSONL 文件的磁盘扫描结果,而不是 mock。我提出的失效 host 缺口没有在这里修,而是被明确排进了 step 8 的 Lands Gate,对一个 stacked 计划来说这是正确的答案。我还检查了新增的 excludeSourceType 选项有没有典型的"死开关"问题——全仓库它只有一个生产调用方,而那个调用方确实填充了它。

那为什么我仍然不批准?

没有可供批准所依据的 CI 证据,而且在这个分支上永远不会有。 ci.ymlpull_request 限定在 mainrelease/**;本 PR 目标是 codex/multi-agent-mesh-foundation,所以 unit、lint、typecheck、integration 全都不触发。当前 head 上全部 13 个 check-run 都是 bot 编排或 TUI parity。唯一的 pull_request 运行——tui-parity——渲染终端 UI,不接触 mesh host、catalog 排除或 ACP 路由。这一点很要紧,因为它拿掉了通常的补救手段:在这里把批准推迟到 CI 变绿是没有意义的,因为唯一可能到达的绿说明不了本次改动的任何事。我对正确性的全部判断都来自阅读推断。我没能找到编译破坏(SessionListItem 声明了 sourceType?: string;改写后的信任门消息仍然包含现有三条断言所匹配的子串),backfill 循环可证明会终止且永不超额收集,新增的计数过滤读取被 TAIL_READ_SIZE 限界、并在头部记录已带 source type 时短路——但"我读不出问题"不等于"它能编译并通过"。对一个现在连 packages/core/src/services/ 也触及的跨包核心改动,加上 acp-integration 属于高风险路径集合,这个缺口就是决定性的。

而且 PR 自己声明的那道门还没有运行。 描述和验收文档都把 #11206 的整分支 CI 命名为本步骤的标准。#11206main 仍然是 open,所以无论如何本 PR 都不会先合并,而它所推迟依赖的证据目前也还不存在。

我刻意请求变更——已经没有什么可请求的了。这是一次基于证据的 defer,不是基于价值的否定。

⏸️ 转交 @qqqys —— 你拥有本 PR 触及的 core-agents 领域,仓库自身的 PR-owner 解析器也已经把你指派上了。就静态审查所能判断,代码是干净的;需要人来定的是证据这一环。两个选项,我会选第一个:运行 @qwen-code /verify,拿到行为性声明的独立 A/B 证明(并发 owner 单例、reaper 重载后返回 started 且不重建 bridge、超过 deadline 的 resume 去重、分页前 backfill,以及计数与列表一致)——作者有写权限,因此不需要 sponsor;或者把这个 PR 押到 #11206 的整分支 CI 运行时,让那道门覆盖整个 stack。我唯一会避免的做法,是凭描述里那些本地 macOS 数字就批准,因为这里没有任何东西真正执行过它们。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 8d9e5d010a3347d6903648b4469cf072f7db69c3 · re-run with @qwen-code /triage

@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.

Partially reviewed — gaps disclosed.

Not reviewed: build-and-test — the harness's own test phase never ran because the packages/cli build gate failed first, so there is no harness testScope; the diff-covering suites were run by hand instead (all green) and the full packages/core suite's 75 failures were measured as pre-existing by re-running the same 11 files on the built merge base (empty net-new failing-file set).

Not reviewed: test-efficacy probe — harnessValidated: null, i.e. the positive control produced no verdict because no probe file was green in the unmutated baseline; 0 mutants and 0 hunk probes ran (2 mutants and 19 hunks skipped for cap, 3 and 6 for baseline), so no probe-kit survivor evidence backs the coverage findings — each of those was instead established by a verifier running the mutation by hand.

Not explored to full depth (tool budget reached): "agent reverse-audit (round 3)": re-measuring the four core test counts the docs record (52 background-agent-resume , 150 background-tasks , 12 capability , 4 launcher = 218) — packages/c…; "agent reverse-audit (round 3)": whether a mesh agent launched into the clientless hidden host can raise a tool-permission request no client can answer — I read createMeshToolInvocationGuard …; "agent reverse-audit (round 3)": the launch RPC's timeout margin — requestSessionStatus defaults to initTimeoutMs ( bridge.ts:6588-6592 , DEFAULT_INIT_TIMEOUT_MS ≈ 10 s per the comment at…; "agent reverse-audit (round 1)": did not read the cold-task register(entry) path in background-tasks.ts to confirm how a duplicate agentId collides in the this.agents task map (only the…; "agent reverse-audit (round 1)": did not verify DEFAULT_QUEUE_LIMIT / whether dispatch-policy.ts would in practice hold a second run for the same agent in a queue rather than launching it c…, and 1 more.

Not reviewed: reverse audit — did not converge within the reverse-audit round cap of 5.

中文说明

仅完成部分审查,审查缺口已披露。

未审查(原文为英文):build-and-test — the harness's own test phase never ran because the packages/cli build gate failed first, so there is no harness testScope; the diff-covering suites were run by hand instead (all green) and the full packages/core suite's 75 failures were measured as pre-existing by re-running the same 11 files on the built merge base (empty net-new failing-file set).

未审查(原文为英文):test-efficacy probe — harnessValidated: null, i.e. the positive control produced no verdict because no probe file was green in the unmutated baseline; 0 mutants and 0 hunk probes ran (2 mutants and 19 hunks skipped for cap, 3 and 6 for baseline), so no probe-kit survivor evidence backs the coverage findings — each of those was instead established by a verifier running the mutation by hand.

未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 3)"re-measuring the four core test counts the docs record (52 background-agent-resume , 150 background-tasks , 12 capability , 4 launcher = 218) — packages/c…"agent reverse-audit (round 3)"whether a mesh agent launched into the clientless hidden host can raise a tool-permission request no client can answer — I read createMeshToolInvocationGuard …"agent reverse-audit (round 3)"the launch RPC's timeout margin — requestSessionStatus defaults to initTimeoutMs ( bridge.ts:6588-6592 , DEFAULT_INIT_TIMEOUT_MS ≈ 10 s per the comment at…"agent reverse-audit (round 1)"did not read the cold-task register(entry) path in background-tasks.ts to confirm how a duplicate agentId collides in the this.agents task map (only the…"agent reverse-audit (round 1)"did not verify DEFAULT_QUEUE_LIMIT / whether dispatch-policy.ts would in practice hold a second run for the same agent in a queue rather than launching it c…,另有 1 条。

未审查:反向审计——在 5 轮的反审轮数上限内未收敛。

— qwen3.8-max via Qwen Code /review (v0.23.0)

Comment thread packages/cli/src/serve/mesh/mesh-host-session.test.ts
Comment thread packages/cli/src/serve/server/session-list.ts
Comment thread packages/cli/src/runtime/mesh-session-source.ts
Comment thread packages/cli/src/serve/server/session-list.ts Outdated
Comment thread packages/cli/src/serve/server/session-list.ts Outdated
});
resident = false;
const startedAt = performance.now();
await owner.tick();

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] R1-20: The periodic keepalive — this module's stated purpose, and the driver of step-4 gate (d) — never fires in any test. All six owners are built with intervalMs: 60_000 (lines 113, 159, 164, 207, 329, 352), none installs fake timers, and every revive is pulled by an explicit owner.tick() or owner.launch(). So the setInterval body at mesh-host-session.ts:107-115 is never entered, and three separate guards inside it are unpinned. The sharpest consequence is the swallowed rejection: test 6 is itself proof that tick() rejects (rejects.toThrow(/timed out/)) whenever a resume misses its deadline, so removing .catch(() => {}) leaves that rejection unhandled inside a timer body — and Node's default --unhandled-rejections=throw then terminates the process, meaning one slow host revive would take down the whole serve daemon. With the 70 s default resume timeout at 2.33× the 30 s interval, overlapping ticks are the normal case, and nothing pins that they are dropped either.

Witness:

Three mutants, each leaving the suite fully green (Tests 6 passed (6)):
  delete `.catch(() => {})`                    (mesh-host-session.ts:111)
  delete `if (running) return;`                (:108)
  delete the entire setInterval registration + clearInterval(timer)   (:106-116)
The Node consequence, measured rather than assumed -- repo floor is Node >=22 (.nvmrc, engines):
  Error: tick failed 1
   at Timeout._onTimeout (file:///tmp/probe-unhandled.mjs:4:23)
  Node.js v22.23.2
  EXIT=1                     <- the `SURVIVED 500ms` line never printed
In-repo corroboration that the serve daemon installs no handler of its own (run-qwen-serve.ts:6582-6587):
  "the serve fast path installs no process-level unhandledRejection handler before this runs, and
   Node's default for one is to exit -- a failed chunk load ... would take down every runtime,
   session, and connection the daemon serves."
Why the existing tests cannot see it: test 3 measures its reload INSIDE owner.launch() (:216-220)
  and test 1 inside an explicit owner.tick() (:123) -- never on a timer edge.

Add a case that installs vi.useFakeTimers(), starts the owner with a small intervalMs/resumeTimeoutMs, lets the reaper (or a throwing recordHeartbeat mock) mark the host gone, advances past one interval and asserts resumeSession was called with no explicit tick()/launch(); then advance a second time while the first revive is still in flight and assert recordHeartbeat/resumeSession were entered once (the running guard), and that a rejecting tick does not escape the timer — a process.on('unhandledRejection') spy, or simply that the owner is still usable on the next tick.

Three constraints on that test: DEFAULT_MESH_RESUME_TIMEOUT_MS = 70_000 is more than double DEFAULT_MESH_KEEPALIVE_INTERVAL_MS = 30_000 (mesh-host-session.ts:17-18), so it must advance across an interval boundary while a tick is still in flight rather than assuming ticks are short; it must advance by the intervalMs it injects, not the 30 s default; and it must not depend on the timer holding the process open, because timer.unref?.() (:116) means a real-timer variant can never be observed after the test body returns. startMeshHostSessionOwner has no production caller yet, so the crash is latent until step 6 wires the owner — that is a severity consideration and the reason this stays a Suggestion, not a reason to leave the module's only production path untested.

Acceptance criterion: that test must go red when if (running) return; is removed (two overlapping resumes recorded), when .catch(() => {}) is removed (unhandled rejection observed), and when the setInterval registration is removed (no revive on clock advance). No existing test fails under any of the three.

中文说明

周期性 keepalive——本模块声明的目的、也是第 4 步 gate (d) 的驱动者——在任何测试中都不会触发。全部六个 owner 都以 intervalMs: 60_000 构建(113、159、164、207、329、352 行),没有一个安装假定时器,每次恢复都是由显式的 owner.tick()owner.launch() 拉动的。所以 mesh-host-session.ts:107-115setInterval 主体从未被进入,其中三道各自的守卫都没有被钉住。

最尖锐的后果是被吞掉的拒绝:测试 6 本身就证明了只要 resume 错过 deadline,tick() 就会拒绝(rejects.toThrow(/timed out/)),所以移除 .catch(() => {}) 会让该拒绝在定时器主体内无人处理——而 Node 默认的 --unhandled-rejections=throw 随后会终止进程,意味着一次缓慢的 host 恢复就会拖垮整个 serve daemon。由于默认 70 秒的 resume 超时是 30 秒周期的 2.33 倍,重叠的 tick 是常态,而这一点同样没有被钉住。

为什么现有测试看不见它:测试 3 在 owner.launch() 内部测量它的重载(:216-220),测试 1 在显式的 owner.tick() 内部测量(:123)——从来不在定时器边沿上。

修法:新增一个用例,安装 vi.useFakeTimers(),以较小的 intervalMs/resumeTimeoutMs 启动 owner,让 reaper(或一个会抛错的 recordHeartbeat mock)标记 host 已消失,推进超过一个周期并断言 resumeSession 在没有显式 tick()/launch() 的情况下被调用;然后在第一次恢复仍在进行时再推进一次,断言 recordHeartbeat/resumeSession 只被进入一次(running 守卫),并且一次拒绝的 tick 不会逃出定时器——用 process.on('unhandledRejection') spy,或简单地断言 owner 在下一个 tick 仍可用。

该测试的三个约束:DEFAULT_MESH_RESUME_TIMEOUT_MS = 70_000DEFAULT_MESH_KEEPALIVE_INTERVAL_MS = 30_000 的两倍多(mesh-host-session.ts:17-18),所以它必须在某个 tick 仍在进行时跨越一个周期边界推进,而不是假设 tick 很短;它必须按自己注入的 intervalMs 推进,而不是 30 秒默认值;它也不能依赖定时器把进程挂住,因为 timer.unref?.():116)意味着真实定时器的变体在测试主体返回后永远无法被观测到。startMeshHostSessionOwner 目前还没有生产调用方,所以崩溃在第 6 步接线之前是潜伏的——这是严重性上的考量,也是它保持为 Suggestion 的原因,但不是让该模块唯一的生产路径不被测试的理由。

验收标准:当 if (running) return; 被移除(记录到两次重叠恢复)、当 .catch(() => {}) 被移除(观测到未处理拒绝)、以及当 setInterval 注册被移除(时钟推进时没有恢复)时,该测试都必须变红。三种情况下现有测试都不会失败。

— qwen3.8-max via Qwen Code /review (v0.23.0)

expect(spawnCount).toBe(1);
expect(resumes).toEqual(['mesh-host-1']);
expect(launches).toEqual(['mesh-host-1:ag_alice', 'mesh-host-1:ag_alice']);
expect(reloadMs).toBeLessThan(1_000);

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] R1-34: Both wall-clock assertions in this file use the same literal (1_000) as the resumeTimeoutMs the tests were constructed with, but the window they measure is strictly wider than the window that timeout governs — so the bound is set by the code under test, and it fails on terms the deadline never polices. beginKeepaliveSessionResume wraps only bridge.resumeSession(request) in withTimeout (scheduled-task-keepalive.ts:118-131, :637-644). reloadMs here brackets owner.tick()readMeshWorkspace plus ensure()'s own readMeshWorkspace — two real withWorkspaceLock acquisitions, each fs.mkdir + lockfile.lock with a ~655 ms retry budget (mesh-store.ts:46-53) — plus the resume; in test 3 it additionally brackets bridge.launchMeshAgent's full channel round-trip. Neither the store reads nor the launch RPC is inside withTimeout. Both directions were measured: a 900 ms regression in the exact branch these two test names advertise keeps both assertions green, while a 1,100 ms launch RPC with a perfectly healthy resume fails the assertion naming no slow subsystem.

Witness:

Baseline (5 runs, unmodified PR): test 1 reloadMs 1.5/1.4/1.4/1.5/1.9 ms; test 3 4.3/4.2/4.1/4.5/4.1 ms
  (the acceptance doc's "4.3 ms" reproduces). Headroom to the 1,000 ms bound ~995 ms.
Direction 1 -- 900 ms delay inserted at mesh-host-session.ts:57, in the revive branch BEFORE
  beginKeepaliveSessionResume (so outside withTimeout):
  {"test":"1-revive","reloadMs":902.6}   {"test":"3-reaper","reloadMs":904.4}
   ✓ owns one hidden session and revives it before the next launch 927ms
   ✓ reloads after the daemon bridge reaper closes the host 980ms
   Tests 6 passed (6)                    <- a 900 ms regression, both assertions green
Direction 2 -- a 1,100 ms delay inside test 3's fake-child extMethodImpl for sessionMeshAgentLaunch
  only, resume untouched and healthy:
  AssertionError: expected 1105.4093300000004 to be less than 1000   (:239)
  ...while line 227's resolves.toMatchObject passed -- the deadline never fired.

Direction 2 is reported with one declared config change: the run raised this test's own sessionIdleTimeoutMs from the shipped 20 to 5_000, because at 20 ms the reaper kills the freshly-resumed channel mid-request. That affects only which term trips the assertion, not the structural point, and Direction 1 needed no config change at all.

Decouple the two numbers — resumeTimeoutMs: 5_000 with expect(reloadMs).toBeLessThan(200) — or drop the wall-clock assertions entirely: test 1 already pins spawnCount === 1 and resumes, test 3 already pins handles.length === 2 and handles[0].killed, so the speed property is the only thing they add and it is currently bounded by nothing.

A raised resumeTimeoutMs must stay at or below MAX_SESSION_RESTORE_TIMEOUT_MS = 2_147_483_647 (session-restore-timeout.ts:8), because withTimeout begins with if (ms > MAX_SESSION_RESTORE_TIMEOUT_MS) return p; (scheduled-task-keepalive.ts:638) and returns the raw non-abortable promise — above the ceiling there is no deadline at all, and test 6's two rejects.toThrow(/timed out/) assertions depend on one existing. 5_000 is far below it.

Acceptance criterion: with the bound decoupled, inserting await new Promise((r) => setTimeout(r, 400)) into ensure()'s revive branch (mesh-host-session.ts:58, right after recordHeartbeat throws) turns test 1 red; against the shipped toBeLessThan(1_000) the same delay leaves all 6 green — measured at 900 ms above.

中文说明

本文件中两处墙钟断言都使用了与测试构造时所用 resumeTimeoutMs 相同的字面量(1_000),但它们测量的窗口严格宽于该超时所管辖的窗口——于是这个界限是由被测代码设定的,而且它会在 deadline 从不监察的项上失败。beginKeepaliveSessionResume 只把 bridge.resumeSession(request) 包进 withTimeoutscheduled-task-keepalive.ts:118-131:637-644)。这里的 reloadMs 括住的是 owner.tick()readMeshWorkspace 加上 ensure() 自己的 readMeshWorkspace——两次真实的 withWorkspaceLock 获取,每次都是 fs.mkdir + lockfile.lock,重试预算约 655 ms(mesh-store.ts:46-53)——再加上 resume;在测试 3 中它还额外括住了 bridge.launchMeshAgent 的完整 channel 往返。store 读取和 launch RPC 都不在 withTimeout 之内。

两个方向都做了实测:在这两个测试名所宣称的那个分支里插入 900 ms 回归,两处断言仍然全绿;而一次 1,100 ms 的 launch RPC、resume 完全健康,却会让断言失败,并且指不出任何慢的子系统。

方向 2 的报告附带一处已声明的配置改动:该次运行把本测试自己的 sessionIdleTimeoutMs 从出厂的 20 提高到 5_000,因为在 20 ms 下 reaper 会在请求进行中杀掉刚恢复的 channel。这只会影响哪一项触发断言,不影响结构性结论;而方向 1 完全不需要配置改动。

修法:把两个数字解耦——resumeTimeoutMs: 5_000expect(reloadMs).toBeLessThan(200)——或者干脆去掉墙钟断言:测试 1 已经钉住 spawnCount === 1resumes,测试 3 已经钉住 handles.length === 2handles[0].killed,所以这两处断言唯一新增的就是速度属性,而它目前没有任何界限。

约束:提高后的 resumeTimeoutMs 必须不高于 MAX_SESSION_RESTORE_TIMEOUT_MS = 2_147_483_647session-restore-timeout.ts:8),因为 withTimeoutif (ms > MAX_SESSION_RESTORE_TIMEOUT_MS) return p; 开头(scheduled-task-keepalive.ts:638),会返回原始的、不可中止的 promise——超过该上限就完全没有 deadline,而测试 6 的两条 rejects.toThrow(/timed out/) 断言依赖于存在一个 deadline。5_000 远低于它。

验收标准:在界限解耦之后,向 ensure() 的恢复分支插入 await new Promise((r) => setTimeout(r, 400))mesh-host-session.ts:58,紧跟 recordHeartbeat 抛错之后)会让测试 1 变红;而在出厂的 toBeLessThan(1_000) 下同样的延迟会让 6 个测试全绿——上文已在 900 ms 下实测。

— qwen3.8-max via Qwen Code /review (v0.23.0)

});
let spawnCount = 0;
const bridge = {
recordHeartbeat: vi.fn(),

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] R1-35: This is the suite's only concurrency test, and it gives both owners ONE shared bridge whose recordHeartbeat never throws and whose resumeSession is vi.fn() — so the loser's post-closeSession recursion is exercised only in its benign form, and the production shape the claim protocol exists for (two daemons, two bridges) is unreachable here. In production each daemon owns its bridge, so after closeSession the loser's return ensure() (mesh-host-session.ts:88-89) re-reads the winner's hostSessionId and heartbeats a session its own bridge never held, which throws SessionNotFoundError, and the bare catch turns that into a resumeSession of the winner's session in the loser's own bridge. This test cannot enter that branch, so ensure() returns the winner's id and expect(new Set(sessionIds).size).toBe(1) passes — and the stubs could not represent it if it were entered. The cost is that the green result on "two owners start concurrently" is evidence about a shared-bridge unit only: when that cross-bridge behaviour is fixed no test in this file goes red, and if it regresses none goes red either.

Witness:

(a) read-confirmed: test 2 uses `recordHeartbeat: vi.fn()` -- returns undefined, never throws -- so the
    recursion re-enters ensure(), passes the heartbeat at :55 and returns at :77; the revive branch at
    :56-76 is never entered. Both owners are handed the SAME bridge object (:156-166).
(b) the stubs could not represent the divergent path:
    {"resumeSessionCalls":1,"outcome":{"status":"rejected","name":"TypeError",
      "message":"Cannot read properties of undefined (reading 'finally')"}}
      at beginKeepaliveSessionResume (scheduled-task-keepalive.ts:125) <- ensure (mesh-host-session.ts:58)
(c) the two-bridge probe, built here -- two real makeBridge instances over one workspace dir, one owner
    each, both ensureResident() concurrently:
    {"phase":"bridge-state","aSessions":["hostA:/tmp/.../workspace"],
     "bSessions":["hostA:/tmp/.../workspace"],"bResumeExtCalls":[]}
    Bridge B, which never spawned that session, ends with a LIVE in-memory entry for the winner's id,
    and both owners report the same id -- so `new Set(sessionIds).size === 1` passes in the divergent
    world exactly as in the healthy one.
Declaration: (c) models two daemons as two real bridges in ONE process; the divergent half
(recordHeartbeat/resumeSession/byId) is per-bridge in-memory state and does not cross a process
boundary, while the cross-process half (claimMeshHostSession's withWorkspaceLock) ran for real.

One correction to how this was first described: the first throw is not withTimeout's p.then — it is void started.completion.finally(...) at mesh-host-session.ts:69 throwing synchronously out of the catch; p.then produces a second, unhandled TypeError.

Construct one bridge per owner whose recordHeartbeat throws for any id that owner did not itself spawn and whose resumeSession returns a promise recording the id, then assert the intended post-condition — exactly one owner ends up driving a live host, and the loser never resumes a session it never owned — instead of only new Set(sessionIds).size === 1, which still passes on the shipped code since both owners return the winner's id. If the two-daemon shape is judged out of scope for step 4, the cheaper honest move is to rename the test so it no longer advertises concurrency coverage it does not have.

Any per-owner stub must return a promise from resumeSession, since bridgeTypes.ts:1523-1525 declares resumeSession(req: BridgeRestoreSessionRequest): Promise<BridgeRestoredSession> and mesh-host-session.ts:19-26 types the bridge as Pick<AcpSessionBridge, ...> — the same contract the 8 TS2322 build errors are raised against, so this fix must not add a ninth.

Acceptance criterion: the rewritten test must go red against mesh-host-session.ts:81-90 as shipped — the loser resumes the winner's session in its own bridge, which is what bSessions above records — and go green only once the claim is resolved before spawnOrAttach.

中文说明

这是套件中唯一的并发测试,而它给两个 owner 的是同一个共享 bridge,其 recordHeartbeat 从不抛错、其 resumeSessionvi.fn()——因此落败方在 closeSession 之后的递归只以良性形态被执行,而认领协议为之存在的生产形态(两个 daemon、两个 bridge)在这里不可达。

在生产中每个 daemon 拥有自己的 bridge,所以 closeSession 之后落败方的 return ensure()mesh-host-session.ts:88-89)会重新读取胜者hostSessionId,并对一个自己的 bridge 从未持有过的 session 发心跳,这会抛 SessionNotFoundError,而那个裸 catch 会把它变成在落败方自己的 bridge 中对胜者 session 的一次 resumeSession。本测试无法进入该分支,所以 ensure() 返回胜者的 id,expect(new Set(sessionIds).size).toBe(1) 通过——而且即使进入了该分支,这些 stub 也无法表达它。

代价是:「两个 owner 并发启动」上的绿色结果只是关于一个共享 bridge 单元的证据;当那个跨 bridge 行为被修复时,本文件没有测试会变红,而当它回归时也没有测试会变红。

对最初描述的一处更正:第一次抛错不是 withTimeoutp.then——而是 mesh-host-session.ts:69void started.completion.finally(...)catch 中同步抛出;p.then 会产生第二个、未被处理的 TypeError。

修法:为每个 owner 构造一个自己的 bridge,其 recordHeartbeat 对任何不是该 owner 自己 spawn 的 id 抛错,其 resumeSession 返回一个记录该 id 的 promise;然后断言预期的后置条件——恰好一个 owner 最终驱动一个存活 host,且落败方绝不会恢复一个它从未拥有过的 session——而不只是 new Set(sessionIds).size === 1(在出厂代码上它仍然通过,因为两个 owner 都返回胜者的 id)。如果认为双 daemon 形态超出第 4 步范围,更省的诚实做法是重命名该测试,使它不再宣称自己并不具备的并发覆盖。

约束:任何按 owner 划分的 stub 都必须从 resumeSession 返回一个 promise,因为 bridgeTypes.ts:1523-1525 声明了 resumeSession(req: BridgeRestoreSessionRequest): Promise<BridgeRestoredSession>,而 mesh-host-session.ts:19-26 把 bridge 类型定为 Pick<AcpSessionBridge, ...>——正是那 8 个 TS2322 构建错误所针对的同一契约,所以本修复不能添第九个。

验收标准:改写后的测试必须针对出厂的 mesh-host-session.ts:81-90 变红——落败方在自己的 bridge 中恢复了胜者的 session,这正是上面 bSessions 所记录的——并且只有在认领于 spawnOrAttach 之前完成后才变绿。

— qwen3.8-max via Qwen Code /review (v0.23.0)

}),
];
try {
const sessionIds = await Promise.all(

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] R1-28: No test ever issues two overlapping calls on ONE owner, so the single-flight ensuring ??= ensure() guard (mesh-host-session.ts:94-97) — the only thing stopping a concurrent launch burst from minting one host session per caller — is unpinned. This Promise.all spreads one call each across two owners, which have separate ensuring closures; tests 1, 3 and 6 await every call before starting the next. With the guard gone, two mesh agents dispatched in the same turn (or one 30 s tick() overlapping a launch()) both enter ensure(), both await readMeshWorkspace, both see hostSessionId === undefined, and both call spawnOrAttach — two real ACP children where one suffices. The loser is then closed by the claim race, which can itself throw and orphan the session; each transient child also occupies a bridge admission slot, and at the cap the second spawn throws SessionLimitExceededError (bridgeErrors.ts:224-226), so launch() rejects even though a good host was one await away. Nothing observable in the result betrays it — both callers still end on the same session id — only the spawn count does.

Witness:

INTACT: mesh-host-session.test.ts 6 passed
        PROBE-R1-28 spawnOrAttach=1 closeSession=[]            sessionIds=["mesh-host-1","mesh-host-1"]
MUT28 : mesh-host-session.test.ts 6 passed      <- the guard is unpinned
        PROBE-R1-28 spawnOrAttach=2 closeSession=[<loser>]     sessionIds=["mesh-host-1","mesh-host-1"]
MUT28 = ensureResident reduced to `return ensure();` (dropping `ensuring ??=` / `return ensuring`,
        mesh-host-session.ts:95-98). Probe: ONE owner, two overlapping launch() calls.

Add a test that builds ONE owner and calls Promise.all([owner.launch(AGENT, 'a'), owner.launch(AGENT, 'b')]) — or two ensureResident() calls — against a spawnOrAttach mock that hands out a distinct id per call, asserting spawnOrAttach was called exactly once and both results carry the same sessionId.

The new mock must satisfy MeshHostBridge = Pick<AcpSessionBridge, 'recordHeartbeat' | 'resumeSession' | 'spawnOrAttach' | 'closeSession' | 'launchMeshAgent'> (mesh-host-session.ts:20-27), because this file's existing mocks already fail that shape and break the build with 8 TS2322 — a new mock must not add a ninth. Please keep this separate from the reviving-reset and running-guard coverage gaps: they are three different lines, three different mutations and three different consequences (duplicate ACP children vs duplicate resumes vs overlapping ticks), and merging them would misstate the coverage, since reviving already has partial coverage from test 6 (resumeSession called once across two timed-out ticks).

Acceptance criterion: expect(bridge.spawnOrAttach).toHaveBeenCalledTimes(1) across two concurrent same-owner calls; the mutation above (spawn count becomes 2) must make it red, and the existing 6 tests staying green under it is the gap.

中文说明

没有任何测试在同一个 owner 上发起两次重叠的调用,因此单飞守卫 ensuring ??= ensure()mesh-host-session.ts:94-97)——阻止并发启动爆发按调用方各铸一个 host session 的唯一机制——没有被钉住。这个 Promise.all 把调用分别摊在两个 owner 上,而它们各自有独立的 ensuring 闭包;测试 1、3、6 则在开始下一次调用前 await 了每一次调用。

守卫消失后,同一轮中被派发的两个 mesh agent(或一次 30 秒 tick() 与一次 launch() 重叠)都会进入 ensure(),都 await readMeshWorkspace,都看到 hostSessionId === undefined,都调用 spawnOrAttach——在本只需一个的地方产生两个真实 ACP 子进程。落败方随后被认领竞争关闭,而那一步自己也可能抛错并遗留孤儿 session;每个瞬时子进程还会占用一个 bridge 准入槽位,而在上限处第二次 spawn 会抛 SessionLimitExceededErrorbridgeErrors.ts:224-226),于是尽管一个好 host 只差一次 awaitlaunch() 仍会拒绝。结果上没有任何可观察的东西暴露它——两个调用方最终仍得到同一个 session id——只有 spawn 次数会。

修法:新增一个测试,构建一个 owner 并调用 Promise.all([owner.launch(AGENT, 'a'), owner.launch(AGENT, 'b')])——或两次 ensureResident()——配一个每次调用给出不同 id 的 spawnOrAttach mock,断言 spawnOrAttach 恰好被调用一次、且两个结果携带相同的 sessionId

约束:新 mock 必须满足 MeshHostBridge = Pick<AcpSessionBridge, 'recordHeartbeat' | 'resumeSession' | 'spawnOrAttach' | 'closeSession' | 'launchMeshAgent'>mesh-host-session.ts:20-27),因为本文件现有的 mock 已经不满足该形态并以 8 个 TS2322 破坏构建——新 mock 不能添第九个。请把这一条与 reviving 重置、running 守卫的覆盖缺口分开处理:它们是三处不同的代码行、三种不同的变异、三种不同的后果(重复的 ACP 子进程 vs 重复的恢复 vs 重叠的 tick),把它们合并会错误陈述覆盖情况,因为 reviving 已经从测试 6 获得了部分覆盖(在两次超时的 tick 中 resumeSession 只被调用一次)。

验收标准:两次同 owner 并发调用下 expect(bridge.spawnOrAttach).toHaveBeenCalledTimes(1);上述变异(spawn 次数变为 2)必须让它变红,而现有 6 个测试在该变异下仍全绿正是缺口所在。

— qwen3.8-max via Qwen Code /review (v0.23.0)

});
const reloadMs = performance.now() - startedAt;

expect(handles).toHaveLength(2);

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] R1-19: Every test reaps the host at most once, so the reviving reset that makes a second revive possible is unexercised — deleting it keeps all six tests green while making the host permanently unrevivable in production. mesh-host-session.ts:69-73 clears the in-flight revive only in the completion's .finally (if (reviving === started) reviving = undefined;). Remove those lines and no test fails: test 1 revives once and then hits the healthy recordHeartbeat path, test 3 has a single reap cycle, and test 6 ends while the completion is still pending. In the daemon the second reap of a long-lived host then takes the if (!reviving) false branch, await reviving.deadline resolves instantly against the stale, already-settled deadline, and ensure() returns a hostSessionId the bridge no longer holds — launch() forwards bridge.launchMeshAgent to a dead session and every mesh launch fails from then on, with no revive ever attempted again until the daemon restarts. The mesh host is by design the longest-lived session in the workspace and survives many reaper cycles, so this is the normal path rather than the rare one.

Witness:

Coverage arm: replacing mesh-host-session.ts:69-73 with `void started.completion.catch(() => {});`
  -- removing ONLY the reset, keeping rejection suppression -- leaves `Tests 6 passed (6)`.
Mechanism arm, a discriminating probe over two reaper cycles (recordHeartbeat throws while
resident === false; resumeSession sets it back):
  INTACT: resumeSession calls cycle1=1 cycle2=2 residentAfterCycle2=true  -> 1 passed
  MUTANT: AssertionError: expected 1 to be 2                              -> 1 failed
The second reap never revives under the mutant: once `completion` settles, `deadline` is a settled
promise, so with `reviving` never cleared the second reap awaits it and returns instantly.

Extend test 3, or add a case, with a second reap-and-revive cycle: after the first reload succeeds, wait for bridge.sessionCount to drop to 0 again, launch once more, and assert handles reaches length 3 with handles[1].killed === true.

The reset fires on the completion's settlement (mesh-host-session.ts:71), so the new test must let the first revive fully settle before reaping again — the verifying probe needed one setImmediate between cycles for exactly that reason. A still-pending first revive is legitimately suppressed, which is the behaviour test 6 already pins, so the new case must not duplicate that one.

Acceptance criterion: that second cycle must go red when the .finally cleanup at mesh-host-session.ts:69-73 is removed — the second launch would return the dead session id instead of producing a third channel.

中文说明

每个测试至多 reap host 一次,因此让第二次恢复成为可能的 reviving 重置从未被执行——删除它会让六个测试全部保持绿色,同时在生产中让 host 永久无法恢复。mesh-host-session.ts:69-73 只在 completion 的 .finally 中清除进行中的恢复(if (reviving === started) reviving = undefined;)。移除这几行后没有测试会失败:测试 1 恢复一次后就走上健康的 recordHeartbeat 路径,测试 3 只有一个 reap 周期,测试 6 在 completion 仍挂起时结束。

在 daemon 中,一个长生命周期 host 的第二次 reap 随后会走 if (!reviving) 的假分支,await reviving.deadline 会针对那个陈旧的、已经结束的 deadline 立即解析,于是 ensure() 返回一个 bridge 已不再持有的 hostSessionId——launch() 会把 bridge.launchMeshAgent 转发给一个死掉的 session,此后每次 mesh 启动都失败,并且在 daemon 重启之前再也不会尝试恢复。按设计,mesh host 是工作区中生命周期最长的 session、会经历多个 reaper 周期,所以这是常态路径而不是罕见路径。

修法:扩展测试 3,或新增一个用例,加入第二个 reap-and-revive 周期:第一次重载成功后,等待 bridge.sessionCount 再次降到 0,再启动一次,并断言 handles 达到长度 3 且 handles[1].killed === true

约束:该重置是在 completion 结束时触发的(mesh-host-session.ts:71),所以新测试必须让第一次恢复完全结束之后再 reap——验证用的 probe 正是为此在两个周期之间需要一个 setImmediate。仍在挂起的第一次恢复被合法地抑制,这正是测试 6 已经钉住的行为,所以新用例不能与它重复。

验收标准:当 mesh-host-session.ts:69-73.finally 清理被移除时,那个第二周期必须变红——第二次启动会返回死掉的 session id,而不是产生第三个 channel。

— qwen3.8-max via Qwen Code /review (v0.23.0)

Both tui-parity jobs died in `tsc --build` before running anything: the
four inline bridge mocks in mesh-host-session.test.ts are partial, so
they no longer satisfy MeshHostBridge = Pick<AcpSessionBridge, ...> now
that this branch adds launchMeshAgent and the bridge methods carry their
real signatures (recordHeartbeat returns BridgeHeartbeatResult,
spawnOrAttach/resumeSession return BridgeSession/BridgeRestoredSession).

Eight TS2322 errors, at 111:7, 157:9, 162:9, 322:9, 323:36, 324:36,
346:36 and 347:9. Use the `as unknown as AcpSessionBridge` escape hatch
this file already applies to its other two bridge literals (and the
convention across src/serve/*.test.ts for this interface, which is far
too wide to mock in full). Type-level only: emitted JS is unchanged, so
no runtime behaviour moves.

Verified locally with `npm run typecheck --workspace=packages/cli`:
the same environment reproduces all eight errors on the pre-fix file at
the identical line:col, and after the fix reports zero for this file —
a diff of exactly 8 errors removed and 0 introduced.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-conflict/jmtqkvx55o8
… codex/mesh-step-4-launcher

# Conflicts:
#	packages/cli/src/serve/mesh/mesh-host-session.test.ts
@yiliang114

Copy link
Copy Markdown
Collaborator Author

已推送 c1636a1,收口本轮隐藏宿主会话审查。

已处理:R1-1/R1-2 两个 Critical;公开创建与 ACP 创建保留 mesh source;session list、search、live-state、goals、branch conflict、CLI list/continue、TUI resume、completion、ACP list、realtime context 全部排除宿主;SessionService 在分页前过滤;launch 回查 workspace.hostSessionId;store 失败返回 typed launch_failed;claim/loser清理 best-effort;计数扫描复用 tail buffer;协议和验收证据同步更新。共享 source 常量移到 runtime 叶子模块,避免 ACP 反向依赖 serve。

定向观测:acpAgent 629/629;SessionService 253/253;mesh host 6/6;multi-workspace 单独 153/153;commands/config/realtime/completion/goals/Standalone picker 合计 483/483;server 新增路径 4/4;定向 ESLint 通过。批量并行跑 7 个命名文件时 multi-workspace 两个既有路由用例出现 405/404,共享状态串扰;同文件单独复跑 153/153。useSessionPicker 与 OpenTUI render 用例在本 worktree 因缺少可选 UI runtime 未收集,留给 #11206 整分支 CI。按本轮约束未运行 build/typecheck。

未在本 PR 扩张:R1-6/R1-21/R1-22/R1-35 属于第 8 步跨进程与生命周期可靠性;R1-10/R1-19/R1-20/R1-28/R1-29/R1-34 是额外的 mutation/timer/时序测试加固,保留给对应可靠性步骤,避免隐藏宿主 PR 继续膨胀。

@yiliang114
yiliang114 merged commit 3a81ed8 into codex/multi-agent-mesh-foundation Sep 7, 2026
10 of 13 checks passed
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.

3 participants