Skip to content

docs(design): daemon side-channel coordination (A1/A2/A4/A5)#4511

Merged
yiliang114 merged 4 commits into
mainfrom
docs/daemon-sidechannel-coordination-design
Jul 5, 2026
Merged

docs(design): daemon side-channel coordination (A1/A2/A4/A5)#4511
yiliang114 merged 4 commits into
mainfrom
docs/daemon-sidechannel-coordination-design

Conversation

@chiga0

@chiga0 chiga0 commented May 25, 2026

Copy link
Copy Markdown
Collaborator

What this is

A design-first, docs-only proposal for the A-series follow-ups surfaced by the cross-client real-time sync audit and the PR #4484 post-merge review. No implementation — it defines the approach so it can be reviewed before any code lands. Each item ships as its own implementation PR after this is approved.

The bugfix/cleanup follow-ups from the same review (epoch-reset resync, approval-mode serialization, cancel dedup, forward-failure signal, replay wire rename, Provider catch-up) are separate and already in review.

The four gaps

  • A1 — in-session model switch never reaches the bus. /model, plan-mode, or agent-internal switches call config.switchModel() directly and emit nothing; only the HTTP route broadcasts. Proposal: a current_model_update sessionUpdate (mirrors the existing current_mode_update), mapped by the bridge to the existing model_switched event, with HTTP + in-session converged on a single emitter to avoid double-broadcast.
  • A2 — in-session approval-mode change emits no event. setMode calls config.setApprovalMode() without notifying. Proposal: emit current_mode_update from setMode; affirm and explicitly document the session-scoped (always) vs workspace-scoped (persist-only) broadcast split with a scope discriminator.
  • A4 — permission_resolved originator/voter ambiguity. Its originatorClientId carries the voter, while permission_request's carries the prompt originator. Proposal: add a canonical voterClientId alias (non-breaking, same pattern as the accepted lastReplayedEventId rename); SDK prefers it.
  • A5 — no side-channel snapshot on attach. A reconnecting client gets transcript replay but must separately pull current mode/model/commands/pending-permissions. Proposal: an opt-in session_snapshot frame emitted before replay so a fresh attach renders correct state without extra round-trips.

Why

These are the remaining "a state change on one path is invisible to other clients" gaps. Closing them gives the daemon a single coordination invariant: every model/approval-mode/permission transition broadcasts exactly once regardless of entry path, and a fresh attach can reconstruct side-channel state without out-of-band pulls.

Contents

Per-item problem (with code anchors), proposed design, alternatives, wire-compat, and risk; cross-cutting single-emitter and additive-alias patterns; proposed sequencing (A4 → A1+A2 → A5) and a test plan.

Requesting design review before implementation.

@github-actions

Copy link
Copy Markdown
Contributor

📋 Review Summary

This is a well-structured design document proposing four side-channel coordination improvements (A1, A2, A4, A5) for the daemon's real-time sync system. The document clearly articulates the problems, proposes non-breaking solutions, and establishes a coherent architectural pattern (single-emitter convergence) across multiple changes. The design is thorough, with excellent code anchors, alternatives analysis, and a clear test plan.

🔍 General Feedback

  • Excellent structure: The problem → proposed design → alternatives → wire/compat → risk format is consistently applied and makes the document easy to review
  • Strong code anchoring: Specific file:line references (e.g., Session.ts:1580, bridge.ts:2798) enable precise verification
  • Coherent architectural pattern: The "single-emitter convergence" principle (agent as single source of truth) elegantly solves the double-broadcast problem across A1 and A2
  • Non-breaking discipline: All proposals maintain wire compatibility through additive patterns, following the established D4 lastReplayedEventId precedent
  • Clear sequencing: The A4 → A1+A2 → A5 order reflects good risk management (smallest/additive first)

🎯 Specific Feedback

🔵 Low

  • Section 0 (Scope): Consider adding a brief "Success Criteria" subsection that defines what "done" looks like for each item—e.g., "A1 is complete when a /model slash command in session X is visible to peer client Y within Z milliseconds." This helps validate the test plan covers the right outcomes.

  • Section 2 (A1): The design mentions authType?: string on current_model_update but doesn't explain what values this field might carry or why it's optional. A brief note on the semantics (e.g., "OAuth vs API key, present only when auth context is available") would help SDK implementers.

  • Section 3 (A2): The scope: 'session' | 'workspace' discriminator is proposed but the document doesn't specify whether this field should be present on both broadcast types or only the workspace-scoped ones. Clarify: is scope always present, or only when persist=true?

  • Section 4 (A4): The deprecation strategy is sound, but consider adding a timeline note (e.g., "originatorClientId will remain indefinitely for wire compat; no removal planned"). This prevents future contributors from treating it as a temporary compat shim.

  • Section 5 (A5): The pendingPermissionIds? field raises a question: if a permission is pending during snapshot but resolves before replay completes, will the client see both the snapshot state and the resolution event? A brief note on this edge case (even if "handled by normal event ordering") would strengthen the design.

  • Section 7 (Sequencing): Consider noting whether A1 and A2 truly must land together, or if they could be split (A1 first, then A2) to reduce PR size. The shared single-emitter convergence logic suggests they're coupled, but explicit confirmation would help.

  • Section 9 (Open questions): Question 3 ("new discriminator vs documenting existing behavior only") reads as still open. If you have a recommendation (even a tentative one), add it—reviewers can then affirm or challenge rather than leaving it unresolved.

✅ Highlights

  • Invariant-driven design: The core invariant ("every state transition produces exactly one bus broadcast") is crisply stated and serves as an excellent north star for the implementation
  • Alternatives considered: Each section explicitly documents rejected approaches with clear rationale (e.g., agent→bridge callback rejected for A1 because sessionUpdate pattern already exists)
  • Risk-aware: Each proposal includes a frank risk assessment with mitigation strategies (e.g., double-broadcast risk mitigated by single-emitter convergence)
  • Test plan quality: The test cases are specific and verifiable—each bullet describes exact inputs and expected outputs
  • Cross-cutting patterns: Section 6 elegantly captures the recurring themes, preventing repetition and helping implementers see the unified vision

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Docs-only, design-first proposal describing how the daemon/bridge should close remaining cross-client side-channel coordination gaps (A1/A2/A4/A5) identified in the real-time sync audit and the #4484 follow-ups, before implementation PRs land.

Changes:

  • Defines a single-emitter approach for model + approval-mode changes so all entry paths broadcast exactly once (A1/A2).
  • Proposes an additive alias (voterClientId) to resolve permission_resolved originator/voter ambiguity without breaking wire compat (A4).
  • Proposes an attach-time session_snapshot synthetic frame (optionally gated) so reconnecting clients can render correct side-channel state without extra pulls (A5).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md
Comment thread docs/design/daemon-sidechannel-coordination/design.md
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md

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

Validated all anchors against daemon_mode_b_main HEAD. Endorsing wenshao's 3 Critical findings (A2 asymmetry / demux gap / pendingPermissionIds auth gap) and the §6/§9 internal contradiction — all confirmed against code. Adding new findings inline below that aren't covered by the existing threads.

Minor anchor-hygiene note (not blocking): bridge.ts and permissionMediator.ts DO exist on this branch (at packages/acp-bridge/src/..., 3923/1291 lines respectively); the design's anchors there are correct. The 101-line file at packages/cli/src/serve/httpAcpBridge.ts is a Stage-1 re-export shim. Suggest the design use full packages/acp-bridge/src/bridge.ts:NNNN paths in the next revision to avoid reviewers burning cycles disambiguating against the shim.

— Opus 4.7 via Claude Code /review

Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md
Comment thread docs/design/daemon-sidechannel-coordination/design.md
Comment thread docs/design/daemon-sidechannel-coordination/design.md
@chiga0

chiga0 commented May 26, 2026

Copy link
Copy Markdown
Collaborator Author

Revised twice — v2 (0448c3f) + v3 (ee5d112)

Thanks @wenshao, @doudouOUC, and Copilot — all 20 threads addressed and resolved. Highlights:

Core model reframed (v3 §1.1). Dropped the v1 "single-emitter (agent sole source)" — it would have lost the bridge's modelChangeQueue serialization, timeout handling, model_switch_failed, and persist/workspace ownership. New model: the bridge stays authoritative for changes it drives; in-session changes add an agent notification the bridge demuxes; the bridge suppresses demux-promotion during its own in-flight roundtrip to avoid double-emit.

Per finding:

  • A1: all three model_switched sites enumerated; model_switch_failed carved out as bridge-only; timeout-race contract (late model_switched after a timeout-fail is authoritative-latest); workspace-mirror decision made explicit (session-scoped phase 1).
  • A2: confirmed asymmetric (HTTP uses the extMethod, bypassing Session.setMode → bridge stays emitter); previousModeId added; persisted/workspace stays bridge-only; scope discriminator; helper generalization + double-emit edge acknowledged.
  • A4: SDK typed event now exposes BOTH originatorClientId and voterClientId (no rename — v2's rename was SDK-breaking); voterClientId optional with no-voter behavior defined.
  • A5: session_snapshot now emitted AFTER replay_complete (fixes the stale-replay reducer corruption); pendingPermissionIds dropped (auth gap); ?snapshot=1 sub-contract specified.
  • §9 emitter-ownership decision table replaces the former §6/§9 contradiction.
  • Test plan expanded (§8). Anchors on full packages/... paths (note: bridge.ts/permissionMediator.ts are the real files; httpAcpBridge.ts is a re-export shim).

Implementation will not begin until this design is approved. Re-review appreciated.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 5 comments.

Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated

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

The v3 revision substantively addresses all 4 Critical findings from round 1 — the bridge-authoritative model (§1.1) replaces the broken single-emitter approach, A2 asymmetry is an honest decision, the demux contract (§2.1) is specified, and the open-question contradiction is resolved. Two new Critical issues remain (wrong anchor for the demux insertion point; hard dependency on a non-existent approvalModeQueue not flagged as a blocker), plus 10 Suggestions on test coverage, sequencing, and edge-case contracts. — qwen3.7-max via Qwen Code /review

Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
@chiga0

chiga0 commented May 26, 2026

Copy link
Copy Markdown
Collaborator Author

v4 (431e1d9) — third round addressed

Thanks @wenshao @doudouOUC @copilot — all 17 threads resolved. Architecture (the §1.1 bridge-authoritative model) is unchanged; this round was accuracy + refinement:

  • Demux insertion point corrected to BridgeClient.sessionUpdate() (bridgeClient.ts:397), not bridge.ts:352; added the third rule (drop-when-suppressed, no generic fallback).
  • A2 is now a hard prerequisite on fix(daemon): cross-client sync follow-up cleanup (epoch-reset resync, approval-mode serialization, catch-up indicator) #4510 (approvalModeQueue does not exist yet); the unbounded-window divergence is documented as what it mitigates.
  • A2 HTTP path emits no agent notification (bypasses Session.setMode) → bridge is sole emitter, nothing to suppress (§1.1/§9 corrected).
  • Step-2 demux covers current_model_update only; current_mode_update promotion deferred to step 3 (needs previousModeId) — no shape regression.
  • A5 snapshot captured at emission time (after replay_complete), fixing the stale-overwrite; first-attach ordering defined.
  • Not "additive everywhere": current_mode_update promotion is lockstep — vscode-ide-companion qwenSessionUpdateHandler is named as an affected consumer.
  • previousModeId capture point, helper generalization, persist-scope (workspace-or-user), security enumeration (resolveTrustedClientId), tests + anchors all fixed.

Re-review appreciated. Per the sequencing, A4 (additive, unblocked) can begin independently; A2 stays blocked on #4510.

Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
@chiga0

chiga0 commented May 26, 2026

Copy link
Copy Markdown
Collaborator Author

v5 (f0109cf) — fourth round addressed (all 10 threads resolved)

Thanks @wenshao. Two Criticals + 8 suggestions handled:

  • Concurrent in-session /model drift (Critical): added §2.2 post-roundtrip reconciliation — on settle the bridge re-reads the agent model and emits a corrective model_switched if divergent (in-session /model bypasses modelChangeQueue, so drop-when-suppressed alone could leave bus=A while session=B).
  • IDE-companion lockstep (Critical): added a one-release dual-emit transition + enumerated the upstream dispatch sites (daemonIdeConnection.ts / DaemonChannelBridge.ts) that drop unknown types and also need handlers.
  • model_switched payload mapping specified (currentModelId→data.modelId, sessionId→data.sessionId) — without it the SDK validator drops it (A1 non-functional).
  • Demux observability now required (promoted/dropped/suppressed/generic log).
  • replay_complete correction: it does exist (eventBus.ts:444, merged feat(daemon+sdk): cross-client real-time sync completeness #4484); A5 phase 2 introduces only session_snapshot.
  • First-attach no longer synthesizes replay_complete{0}; snapshot is self-delimiting.
  • Capture-at-emission tightened to a synchronous read+publish block.
  • Helper migration model specified; Q3 resolved (keep the extMethod bypass); A4 distinguishing test added (shipped in feat(daemon): add voterClientId to permission_resolved (A4) #4539).

Note: A4 (#4539) is already implemented and approved. Re-review of the design appreciated.

@chiga0
chiga0 requested a review from wenshao May 26, 2026 08:58
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
@chiga0

chiga0 commented May 26, 2026

Copy link
Copy Markdown
Collaborator Author

v7 (a703a14) — A1 transport correction (found at implementation start)

Began implementing A1 and immediately hit a feasibility blocker worth surfacing: current_model_update is not an ACP SessionUpdate variant. SessionUpdate is the external @agentclientprotocol/sdk type — acp.d.ts defines current_mode_update but not current_model_update, so a variant cannot be added (v1–v6, and the §2 Alternative that rejected extNotification for symmetry, were wrong on this).

Correction: A1 emits the in-session model change via the existing agent→bridge extNotification side-channel (bridgeClient.ts:491), demuxed there to model_switched. A2 keeps current_mode_update as a real sessionUpdate, demuxed in sessionUpdate(). So A1 and A2 use different transports + insertion points (documented). All other demux rules unchanged; A1 needs no ACP-spec change and no SDK change.

This is the design-first payoff — caught on the first line of code, fixed in the doc rather than as a cast onto the external union.

wenshao pushed a commit that referenced this pull request May 26, 2026
* feat(daemon): add voterClientId to permission_resolved (A4)

Resolve the originator/voter ambiguity on permission_resolved without
breaking wire or SDK consumers (design PR #4511, A4):

- Wire: the mediator now emits data.voterClientId alongside the envelope
  originatorClientId on permission_resolved (same value, the resolving
  voter). Both are omitted together for no-voter resolutions (timer expiry,
  session-closed, loopback voter with no clientId). permission_already_
  resolved is unchanged (deliberately stamps neither).
- SDK: the normalizer exposes an optional voterClientId on the
  permission.resolved typed event, reading data.voterClientId and falling
  back to the envelope originatorClientId for daemons predating the field.
  originatorClientId stays available on the base (no rename, no break).

voterClientId is the canonical, unambiguous name; originatorClientId on
permission_resolved is kept as a deprecated alias (it means the voter here,
unlike the prompt originator on permission_request).

Tests: permissionMediator emits voterClientId (+ omits both with no voter);
normalizer surfaces voterClientId from data, falls back to originatorClientId,
omits it for no-voter. acp-bridge 297, sdk daemon-ui 186 pass.

* test(daemon): cover the prompt-originator vs voter distinction (A4)

Add the distinguishing case wenshao asked for: client A submits the prompt
(permission_request.originatorClientId === A) while a different client B casts
the resolving vote (permission_resolved.voterClientId === B), and assert the
two differ — the disambiguation A4 exists to enable. The prior tests only
covered the same-client value.

---------

Co-authored-by: 秦奇 <[email protected]>
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md
Comment thread docs/design/daemon-sidechannel-coordination/design.md
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md Outdated
Comment thread docs/design/daemon-sidechannel-coordination/design.md

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

Design review (verified against daemon_mode_b_main and main). The technical content is sound and corroborated by the actual daemon-branch implementation (the extNotification demux, publishModelSwitched chokepoint, generation-guarded reconciliation, and replay_complete sentinel all exist on the branch). The reconciliation logic holds up on inspection, and the doc is commendably honest about the residual zombie-roundtrip race it does not close. Inline notes below are about the PR as an artifact (merge base, framing, changelog bloat, two anchor nits) — none affect the design's correctness. Approve with minor cleanup.

@@ -0,0 +1,398 @@
# Daemon side-channel coordination — Design (A1 / A2 / A4 / A5)

> Targets `daemon_mode_b_main` (per #4175 branching strategy). Author: 秦奇. Date: 2026-05-25. Revised: 2026-05-27 (v13 — zombie-gap doc, reconciliation_failed contract, availableCommands spec, §7 atomic-coupling, §8 bounded-call-count).

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.

Merge-base vs anchor-target mismatch. This PR's base is main, but every code anchor in the doc (bridge.ts, bridgeClient.ts, permissionMediator.ts, acp.d.ts, replay_complete) exists only on daemon_mode_b_main — none are present on main. Co-locating design docs on main is the established convention here (docs/design/ already has a channels/ subdir), so this is defensible, but the "Targets daemon_mode_b_main" line should be reconciled with the actual merge base — or the doc should land on the daemon branch alongside the code it annotates.

# Daemon side-channel coordination — Design (A1 / A2 / A4 / A5)

> Targets `daemon_mode_b_main` (per #4175 branching strategy). Author: 秦奇. Date: 2026-05-25. Revised: 2026-05-27 (v13 — zombie-gap doc, reconciliation_failed contract, availableCommands spec, §7 atomic-coupling, §8 bounded-call-count).
> **Docs-only / design-first.** A4 implemented + approved (#4539); A1 implemented (#4546).

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.

Framing drift between doc and PR body. This line states A4 (#4539) and A1 (#4546) are already implemented, but the PR description says "No implementation … before any code lands. Each item ships as its own implementation PR after this is approved." The doc is design-first for A2/A5 but retroactive for A1/A4. Please update the PR body so reviewers aren't reviewing already-merged decisions as if they were still open.

>
> Source: cross-client real-time sync audit (2026-05-24) + PR #4484 post-merge review (the **A-series** follow-ups). The bugfix/cleanup follow-ups from the same review ship separately (PR #4510) and are **out of scope here**.

## Changelog

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.

Changelog is ~90 of 398 lines (≈23%). Twelve rounds (v2–v13) of internal-review history will be frozen into the repo on a doc that has never been on main — git history already captures revisions. Several entries narrate corrections to earlier drafts of this same unmerged doc (e.g. v10 "§8 test contradicted v9"; v12 "stale-but-equal scenario was self-contradictory"). Recommend squashing to a short status block (current decisions + open items) before merge and leaving the round-by-round narrative in the PR thread.

session_snapshot { approvalMode, model, availableCommands? }
```

- **`replay_complete` already exists** (`eventBus.ts:444`, shipped by merged #4484) — A5 phase 2 introduces only the new `session_snapshot` frame, not `replay_complete`.

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.

Anchor rot. replay_complete does exist (good — the claim is correct), but it's at eventBus.ts:516 on daemon_mode_b_main, not :444 (the :437 neighbourhood is the false-caught-up comment). With ~40 file:line anchors in a long-lived doc this drift is inevitable; lean on the symbol name when symbol and line disagree.

- **Mitigation (§2.1 dual-emit):** the first release emits BOTH the legacy generic `session_update{current_mode_update}` AND the promoted `approval_mode_changed`; the IDE companion keeps working on the legacy frame; once its `approval_mode_changed` path ships, the next release drops the dual-emit. A4 (`voterClientId`) and A5 (opt-in frame) ARE additive (no transition needed).
- **Failure events stay bridge-only** (`model_switch_failed`).
- **Concurrent-in-session drift** is bounded by §2.2 post-roundtrip reconciliation.
- **SDK reducer updates** (naming, to avoid the A1/A2 mix-up): A1 introduces **`current_model_update`** → `model.changed`; A2 promotes **`current_mode_update`** → `approval_mode_changed`; A4 adds optional `voterClientId`; A5 seeds side-channel state from `session.snapshot`.

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.

Naming inconsistency — the exact "A1/A2 mix-up" this line claims to prevent. A1 here maps current_model_updatemodel.changed, while every other section (§2.1 promotion table, §8) maps it to the bus event model_switched; A2 in this same list uses the bus name approval_mode_changed. Either make A1 read model_switched for parity, or state explicitly that model.changed is the SDK-typed reducer event distinct from the model_switched bus event.

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

Design Doc Review

Summary

问题分析深刻,对竞态/TOCTOU/排序的覆盖非常细致,v1→v13 迭代痕迹完整。但文档已被代码实现超越,存在多处事实性失准,需要更新后才能合并。

Critical

1. A2 transport 与文档不一致

文档 §1.1/§2.1/§6/§9 坚持 A2 走 ACP sessionUpdate 通道,但实际实现中:

  • Session.setMode 发出的是 extNotification('qwen/notify/session/mode-update', ...)
  • BridgeClientextNotification() 中 demux(注释明确标注 "A2: promote an in-session current_mode_update extNotification")

这意味着 A1 和 A2 实际上都在 extNotification() 中处理——是对称的,而非文档所述的"设计上保留的非对称性"。

建议: 增加 v14 changelog,说明 A2 也改用了 extNotification(与 A1 对称),并同步 §1.1/§2.1/§6/§9。

2. "BLOCKED on PR #4510" 已过期

文档多处将 A2 标记为硬阻塞于 #4510,但 approvalModeQueue 已在 bridge.ts 中定义、初始化、使用并有测试覆盖。前置已满足,文档未更新会误导接手者。

建议: §7 step 3 替换为"前置已满足(#4510 已合入)"。

High

3. 行号锚点系统性失效

文档大量引用精确行号(如 bridge.ts:2883Session.ts:1561),但与当前 HEAD 对比几乎全部偏离 300~1000+ 行。设计文档对行号依赖很高,失效会让落地者必须二次定位。

建议: 批量修正行号,或改用方法名/节路径锚点(如 BridgeClient.extNotification() 内的 model-update 分支),至少标注取自哪个 commit SHA。

4. §0 httpAcpBridge.ts 路径不存在

packages/cli/src/serve/httpAcpBridge.ts 在当前仓库中找不到。请核对路径或删除该说明。

5. §10 长期方案 vs 当前设计矛盾

文档同时主张必须原子落地 reconciliation 复杂栈,又推荐 serialize-at-source 方案。建议在 §7 明确这套中间方案的维护期上限或 exit criteria。

Medium

  • §2.1 规则引用 "third rule" 但未显式编号 1)/2)/3)
  • §5 first-attach vs resume 的客户端判定算法未显式说明
  • §3 helper generalization 仍以未来时态描述,但实现已绕过该 helper

Low

  • Author 字段不一致(文档 "秦奇" vs PR 元数据 "ChiGao")
  • v2~v13 changelog 占 90 行,建议折叠早期版本到附录
  • 缺少 sequence diagram,398 行文本涉及 4 个发布点 + 5+ 个竞态,一张时序图可显著降低认知成本

核心修复项:更新 A2 transport 描述 + 解除 #4510 阻塞标记 + 修正行号锚点。完成后即可 approve。

- **A2 — `BridgeClient.sessionUpdate()` (`bridgeClient.ts:397`):** the `current_mode_update` sessionUpdate → `approval_mode_changed`. This method today publishes every notification verbatim as `{ type: 'session_update', data: params }`; the demux is added here.

The rules below apply at whichever insertion point the sub-type arrives:

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] A2 transport contradicts the shipped code. The doc repeatedly states A2 uses BridgeClient.sessionUpdate() for the demux (§0, §1.1 item 2b, §2.1, §6, §9 table). The shipped code uses extNotification() for both A1 and A2: Session.setMode sends extNotification('qwen/notify/session/mode-update', ...) (Session.ts:2520-2529) and the demux is in BridgeClient.extNotification() via handleInSessionModeUpdate (bridgeClient.ts:480-481). The "two insertion points / different transports" architecture described throughout the document does not exist.

Anyone reading the doc to implement follow-ups will look in the wrong method. The §2.1 demux contract's two-insertion-point framing is incorrect.

— qwen3.7-max via Qwen Code /review

4. Bridge publishes the authoritative `model_switched(A)`; **bus shows A, session runs B — nothing reconciles.**

**Contract (v9/v10/v11 — authoritative read, generation-guarded, non-recursive):** reconciliation fires when a bridge model roundtrip settles — on **both** the success and failure paths (a `.finally` on the roundtrip, since the timeout/failure case is exactly when the bus is most likely diverged). It reads the agent's **true** current model via `getSessionContextStatus` (`bridge.ts:2784`, async `extMethod`) and, if it diverges from the bus's current model (`entry.currentModelId` — on the failure path this is the **pre-roundtrip** value, since `model_switch_failed` does not update the cache), emits a corrective `model_switched` via `publishModelSwitched`. **Why not the §2.3 cache _as truth_:** the cache is updated only at publish sites, so it can't observe a concurrent in-session change the demux **dropped** — reading it would falsely conclude "no divergence". The agent is the only source of truth. The read is async but runs **post-settle, outside the demux**, so the §5 synchronous-block constraint doesn't apply. (Longer-term: route in-session `/model` through `modelChangeQueue` — §10 Q3 — to make this race-free at the source.) The same reconciliation applies to A2 once `approvalModeQueue` exists.

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] Failure-path reconciliation is described but not implemented. This section states reconciliation fires on "both the success and failure paths (a .finally on the roundtrip)" and calls the failure case "exactly when the bus is most likely diverged." The shipped code gates reconciliation on if (succeeded) at all three sites (bridge.ts:1563-1569, :3637-3644, :3866-3875), explicitly skipping reconciliation on failure with action=skipped reason=roundtrip_failed. The zombie roundtrip gap (§2 item 4, v13) is a direct consequence: without failure-path reconciliation, a timed-out model that completes late is never corrected.

Either implement failure-path reconciliation (remove the if (succeeded) guard), or update the doc to state reconciliation fires on success only and acknowledge the zombie roundtrip gap is a consequence of this decision.

— qwen3.7-max via Qwen Code /review


### Risk / compat

Additive wire (`current_mode_update` reuse + `previousModeId` + `scope`) but **not** SDK-additive for the promoted type (see §6). Hard-blocked on #4510.

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] Internal contradiction on previousModeId. This line says "Additive wire (current_mode_update reuse + previousModeId + scope)" but §3 point 3 (line 248) explicitly resolves that the agent does NOT send previousModeId — the bridge derives previous from its state cache. An implementor reading this line would add previousModeId to the wire format, contradicting the detailed design.

Suggested change
Additive wire (`current_mode_update` reuse + `previousModeId` + `scope`) but **not** SDK-additive for the promoted type (see §6). Hard-blocked on #4510.
Additive wire (`current_mode_update` reuse + `scope`) but **not** SDK-additive for the promoted type (see §6). Hard-blocked on #4510.

— qwen3.7-max via Qwen Code /review


The staleness check (§2 item 4), §2.2 reconciliation, and A5's snapshot (§5) all need the session's **current** model / approval-mode / commands. The bridge had no synchronous accessor — only `getSessionContextStatus` (`bridge.ts:2784` → `requestSessionStatus`, an async `extMethod` roundtrip), and an `await` there reopens the very TOCTOU window these mechanisms close. So:

- Add to `SessionEntry`: `currentModelId?: string`, `currentApprovalMode?: ApprovalMode`, `availableCommands?: AvailableCommand[]`.

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] SessionEntry field declaration is incomplete. This bullet lists three fields (currentModelId, currentApprovalMode, availableCommands) but omits modelPublishGeneration and reconciliationInFlight, both required by §2.2. An implementor following this field list would miss the generation guard and the structural non-recursion guard, causing the §2.2 TOCTOU protection and re-entry protection to be absent.

— qwen3.7-max via Qwen Code /review


- **Payload (v13):** `reconciliation_failed { sessionId: string, error: string, retryCount: number, trigger: 'roundtrip-settled' | 'failed' }`. The `error` distinguishes "agent process crashed" from "JSON-RPC timeout" for consumer UX and oncall diagnostics.
- **Consumer contract:** advisory — clients MAY surface a transient warning and MAY trigger their own `getSessionContextStatus` pull to self-heal. No mandatory handler; absent consumers, the bus state remains as-last-published (stale but non-terminal).
- **Per-attempt logging:** each retry attempt emits its own log line: `[reconcile] session=<id> attempt=<n>/<max> error=<msg>`, so oncall can distinguish transient from sustained failure without needing the final aggregated event.

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] reconciliationInFlight structural guard has no timeout. The flag is set before the async read and cleared in .finally. If getSessionContextStatus hangs indefinitely (agent stuck, transport half-open), the flag stays true forever and all future reconciliation is skipped (action=skipped-reentrant). The bounded-retry path covers error responses but not hangs. The authoritative correctness backstop is permanently disabled with no recovery.

Add a per-attempt timeout to the reconciliation getSessionContextStatus call (the bridge already uses withTimeout for model roundtrips), or clear reconciliationInFlight with a maximum-hold timer.

— qwen3.7-max via Qwen Code /review

3. **`model_switch_failed` stays bridge-only** — `Session.setModel` throws with no notification; the bridge keeps publishing it on both failure paths.
4. **Timeout-race (best-effort demux drop + authoritative reconciliation backstop — v9).** The bridge's `withTimeout` (`bridge.ts:2844-2849`) can reject (publishing `model_switch_failed(A)`) while A's ACP call keeps running (FIXME `bridge.ts:2836-2840`). If a change B then succeeds (`model_switched(B)`) and A's call finally completes, A's late `current_model_update(A)` must not make A the apparent final state. **Value comparison alone can't decide** this (a late stale `A` and a fresh switch to `A` look identical — a distributed-ordering problem). So: the demux does a **best-effort dedup** (drops a `current_model_update` whose `currentModelId` already equals `entry.currentModelId` — a redundant no-op), and the **authoritative correctness comes from §2.2 reconciliation**: a timed-out earlier change always corresponds to a _settled bridge roundtrip_, which triggers a post-settle authoritative read that re-publishes the agent's true model. No agent-side sequence counter required.

**Residual gap — zombie roundtrip (v13).** Reconciliation covers the _first_ settlement (the timeout), but a zombie ACP call that completes **after** reconciliation has already fired `action=converged` is NOT covered: the agent applies the timed-out model late → emits `current_model_update(A)` → demux promotes it (no roundtrip in flight, not a dup) → bus silently reverts to A, contradicting the user's successful switch to B. The long-term fix is an ACP cancel signal (the existing FIXME at `bridge.ts:2836-2840`). Until then this is a **known residual race** under the narrow condition: timeout fires, reconciliation converges (agent hasn't applied yet), user successfully switches to B, THEN the zombie completes. Likelihood is low (requires the agent to take longer than the timeout + reconciliation read + a subsequent successful switch), but it is not zero. Document it here rather than claim reconciliation fully eliminates the timeout race.

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] The zombie roundtrip residual race (§2 item 4, v13) has no corresponding test scenario in §8. The design documents a specific sequence (timeout → reconciliation converges → user switches to B → zombie completes → bus reverts) but §8 omits it entirely. Without a test, there is no regression guard when the ACP cancel fix eventually lands.

Add a §8 bullet asserting the current (broken) behavior as a documented test case, to be updated when the fix lands.

— qwen3.7-max via Qwen Code /review


- Add to `SessionEntry`: `currentModelId?: string`, `currentApprovalMode?: ApprovalMode`, `availableCommands?: AvailableCommand[]`.
- **Update synchronously at every publish site**, in the same synchronous turn as the publish (no `await` between read-of-old and write-of-new): all `model_switched` publishes go through the §2.2 `publishModelSwitched` helper (which atomically updates `entry.currentModelId` + bumps `entry.modelPublishGeneration` + publishes to bus); `approval_mode_changed` (`:2979` / `:3007`) updates `entry.currentApprovalMode`; `availableCommands` is updated in `BridgeClient.sessionUpdate()` when it receives an `available_commands_update` generic sessionUpdate — the handler sets `entry.availableCommands = payload.commands` synchronously **before** the generic forwarding publish. The helper guarantees no publish site can miss a cache or generation update.
- **`availableCommands` specifics (v13):** type is `AvailableCommand[]` (matching `status.ts`). Unlike model/mode, this field has **no named promoted bus event** and **no reconciliation** — it's a passive cache, updated by the generic `session_update` path. If the implementer misses the hook, A5's snapshot serves stale/undefined commands with no backstop. The trigger path is explicitly `BridgeClient.sessionUpdate()` → check `params.type === 'available_commands_update'` → update cache → forward as generic `session_update`.

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] availableCommands cache has no reconciliation backstop, no test, and is not yet implemented in SessionEntry. Unlike model/mode (which have authoritative reconciliation), this passive cache field has a single population hook with no staleness detection. §8 has no test for cache population or snapshot inclusion.

Either add availableCommands to the reconciliation read (cheap — the status call already returns it), or add a §8 test and explicitly document undefined as a legal snapshot value.

— qwen3.7-max via Qwen Code /review


### Double-emit edge

`/approval-mode` during an open tool-confirmation dialog can fire two `current_mode_update` within ms (user `setMode` + the tool's `ProceedAlways` handler). Acceptable (converges); optionally skip emit when the resulting mode equals current. Documented, not gated.

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] A2 prerequisite text and §7 status markers are stale. approvalModeQueue already exists in the codebase (bridge.ts:262-263), A4 is shipped (#4539), and A1 is shipped (#4546). The "Hard prerequisite" section overstates what the queue alone achieves (reconciliation is the actual divergence closer — see §7 step 3c). §7 step 1 doesn't mark A4 as completed.

Update status markers: add "(Shipped as #4539)" to §7 step 1, update §3 to note the queue has landed, and restate A2's status as unblocked.

— qwen3.7-max via Qwen Code /review


**Read-error: bounded retry, then surface.** A transient `getSessionContextStatus` failure must not leave the bus permanently diverged with only a log line. Retry 1–2× with short backoff; if all fail, emit a `reconciliation_failed` bus event and log `action=read-error`.

- **Payload (v13):** `reconciliation_failed { sessionId: string, error: string, retryCount: number, trigger: 'roundtrip-settled' | 'failed' }`. The `error` distinguishes "agent process crashed" from "JSON-RPC timeout" for consumer UX and oncall diagnostics.

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] Generation-guard rerun behavior: doc says "skip" but implementation re-runs

The doc states reconciliation "skips the corrective if the generation advanced during the read." Verified: the implementation (bridge.ts:1876-1958) does something fundamentally different — it sets rerun = true, skips the stale read, then re-invokes reconcileAfterRoundtrip in the finally block after releasing the in-flight guard. This rerun is load-bearing correctness: without it, the newer change that landed during the read is never reconciled (its own reconciliation bailed on the in-flight guard). The doc contains zero mention of "rerun" or "re-run."

An implementor following this doc would skip on generation mismatch without re-running, silently breaking reconciliation under the exact race condition the guard is designed to handle.

Suggested fix: Add to §2.2: "When the generation guard fires (a newer publish landed during the async read), the current read is discarded (stale) and reconciliation re-runs once after releasing the in-flight guard. The re-run uses the post-publish generation as its new baseline, so it correctly detects further drift. The re-run is bounded to one retry per triggering settle event."

— qwen3.7-max via Qwen Code /review


### 2.3 Bridge state cache (synchronous source of "current" model/mode/commands)

The staleness check (§2 item 4), §2.2 reconciliation, and A5's snapshot (§5) all need the session's **current** model / approval-mode / commands. The bridge had no synchronous accessor — only `getSessionContextStatus` (`bridge.ts:2784` → `requestSessionStatus`, an async `extMethod` roundtrip), and an `await` there reopens the very TOCTOU window these mechanisms close. So:

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] Four specified contract items are NOT implemented — doc doesn't acknowledge the divergence

Verified against the current codebase:

  1. reconciliation_failed bus event (bridge.ts:1945): explicitly log-only — "not a known SDK event type." The doc specifies a full payload shape.
  2. "1-2 bounded retries with short backoff": implementation does zero retries.
  3. availableCommands on SessionEntry (§2.3): field does not exist in the current type.
  4. scope: 'session' | 'workspace' discriminator (§3): not included in the publishApprovalModeChanged helper.

Each is a silent correctness gap between spec and reality. Future consumers built against this spec will wait for bus events that never arrive, assume retries that don't happen, and read cache fields that return undefined.

Suggested fix: Add explicit implementation-status markers next to each item — e.g., [deferred — SDK type gap], [pending — A5 follow-up], [deferred — A2 not yet promoted]. This prevents the design doc from becoming a lying specification.

— qwen3.7-max via Qwen Code /review

Suppress + drop assumes the bridge roundtrip and the agent describe the **same** change. That breaks under a concurrent in-session change, because in-session `/model` calls `Session.setModel` **directly and does NOT enter `modelChangeQueue`**:

1. Bridge `setSessionModel(A)` starts → suppress window opens.
2. User types `/model B` in the terminal → `Session.setModel(B)` (bypasses the queue) → agent emits `current_model_update(B)`.

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] Best-effort dedup (§2.1) specified in doc but missing from implementation

The doc states: "the demux drops a current_model_update whose currentModelId already equals entry.currentModelId." Verified: handleInSessionModelUpdate (bridgeClient.ts:730-771) performs only type validation, entry existence, and roundtrip-suppress checks — no value-equality comparison. Any current_model_update is unconditionally promoted.

Spurious promotions bump modelPublishGeneration without real state change, which can cause a subsequent reconciliation to falsely skip (action=skipped-newer-gen) when a real corrective is needed — amplifying the zombie roundtrip race and any future race depending on generation accuracy.

Suggested fix: Either add the dedup check in the implementation (if (currentModelId === entry.currentModelId) { log dropped; return; }), or update the doc to reflect current behavior and note it as a pending follow-up.

— qwen3.7-max via Qwen Code /review

4. **No serialization primitive yet.** `approvalModeQueue` **does not exist** in the codebase today; the approval-mode HTTP path (`bridge.ts:2893-3020`) runs extMethod + publish inline with no per-session queue (unlike the model path's `modelChangeQueue`). The suppress/race window is therefore unbounded until #4510 lands it.

### Proposed design

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] approvalModeQueue blocking status is stale

The doc says "approvalModeQueue does not exist in the codebase today" and A2 is "BLOCKED on PR #4510." Verified: approvalModeQueue exists at bridge.ts:268 with full usage at :3771, :3885, and tests at bridge.test.ts:4861. The blocking prerequisite appears to have been satisfied.

Suggested fix: Update §3 item 4 and §7 step 3 to reflect the current state — change "does not exist" / "BLOCKED" to "available since #4510" / "depends on approvalModeQueue."

— qwen3.7-max via Qwen Code /review

- **Cross-axis non-suppression (§2.1):** an in-flight bridge **model** roundtrip does NOT suppress an in-session `current_mode_update` (it IS promoted), and vice-versa.
- **Bridge state cache (§2.3):** every `model_switched` publish site routes through `publishModelSwitched` which updates `entry.currentModelId` AND bumps `entry.modelPublishGeneration`; assert generation advanced by exactly 1 after each (including the reconciliation corrective). The snapshot/dedup/generation-guard reads see the latest value synchronously; cache seeded on session create.
- **Dual-emit transition (§2.1/§6):** during the window both `approval_mode_changed` AND `session_update{current_mode_update}` are emitted; after removal only `approval_mode_changed`.
- **extNotification transport (v7):** `current_model_update` arrives via `extNotification()` (not `sessionUpdate()`) and promotes to `model_switched`.

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] Missing A2 reconciliation test scenarios in §8

§8 has 7 model-axis reconciliation test scenarios (corrective, converged, generation-skip, failure-path trigger, read-error, failure-path converged, non-recursion guard) but zero approval-mode equivalents. §2.2 states "the same reconciliation applies to A2 once approvalModeQueue exists" and §7 step 3c commits to approvalModePublishGeneration + publishApprovalModeChanged helper landing atomically with A2 promotion.

Suggested fix: Add a "Reconciliation (A2, approval-mode)" test group in §8 mirroring the model scenarios: corrective after concurrent in-session /approval-mode during bridge roundtrip, converged, generation-skip, publishApprovalModeChanged generation invariant. Note these are gated on A2 unblocking.

— qwen3.7-max via Qwen Code /review

- **Update synchronously at every publish site**, in the same synchronous turn as the publish (no `await` between read-of-old and write-of-new): all `model_switched` publishes go through the §2.2 `publishModelSwitched` helper (which atomically updates `entry.currentModelId` + bumps `entry.modelPublishGeneration` + publishes to bus); `approval_mode_changed` (`:2979` / `:3007`) updates `entry.currentApprovalMode`; `availableCommands` is updated in `BridgeClient.sessionUpdate()` when it receives an `available_commands_update` generic sessionUpdate — the handler sets `entry.availableCommands = payload.commands` synchronously **before** the generic forwarding publish. The helper guarantees no publish site can miss a cache or generation update.
- **`availableCommands` specifics (v13):** type is `AvailableCommand[]` (matching `status.ts`). Unlike model/mode, this field has **no named promoted bus event** and **no reconciliation** — it's a passive cache, updated by the generic `session_update` path. If the implementer misses the hook, A5's snapshot serves stale/undefined commands with no backstop. The trigger path is explicitly `BridgeClient.sessionUpdate()` → check `params.type === 'available_commands_update'` → update cache → forward as generic `session_update`.
- **Seed** from the `createSession` / `loadSession` ACP response when the entry is created (initial model/mode), before any change occurs.
- **Consumers (synchronous field reads):**

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] Missing gen_read in reconciliation observability format

The [reconcile] log format shows gen_before and gen_after but not the generation value observed when the async read returns. An oncall engineer investigating a skipped-newer-gen decision cannot tell what generation the guard actually observed — they only know "before was N, after was N" with no way to verify the skip was justified vs a bug that skipped incorrectly.

Suggested fix: Add gen_read=<K> to the format. For corrected and converged, gen_read = gen_before. For skipped-newer-gen, gen_read > gen_before, proving the skip was justified.

— qwen3.7-max via Qwen Code /review


- **A1 — `BridgeClient.extNotification()` (`bridgeClient.ts:491`):** the `current_model_update` notification → `model_switched`.
- **A2 — `BridgeClient.sessionUpdate()` (`bridgeClient.ts:397`):** the `current_mode_update` sessionUpdate → `approval_mode_changed`. This method today publishes every notification verbatim as `{ type: 'session_update', data: params }`; the demux is added here.

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] Zombie roundtrip description glosses over dedup interaction

The paragraph says the demux promotes the zombie because it's "not a dup" — but the §2.1 best-effort dedup mechanism IS relevant in the general case. If the zombie arrives before any intervening switch (cache still holds A), dedup catches it (A === A → dropped). The residual race is narrower than described: it only manifests when the zombie arrives AFTER the user's switch to B has completed publishModelSwitched (cache moved to B, so A !== B and dedup doesn't fire).

Suggested fix: Acknowledge that dedup catches the zombie in the no-intervening-switch case, and specify the precise residual window: "zombie arrives after B's settle and publishModelSwitched has moved the cache past A."

— qwen3.7-max via Qwen Code /review

**Anchor convention:** full repo-root paths.

- **`packages/acp-bridge/src/bridgeClient.ts`** — the ACP→bus client; `sessionUpdate()` and `extNotification()` forward agent notifications to the EventBus (the **two** demux insertion points — A2 in `sessionUpdate()`, A1 in `extNotification()`; see §2.1).
- **`packages/acp-bridge/src/bridge.ts`** — the 3923-LOC orchestrator (HTTP control methods, publish sites). `packages/cli/src/serve/httpAcpBridge.ts` is a 101-LOC re-export shim — not an anchor target.

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] Systematically stale line-number anchors + phantom file reference

The anchor convention references httpAcpBridge.ts (described as a "101-LOC re-export shim") — this file does not exist anywhere in the repository. The bridge.ts LOC count ("3923-LOC") is also wrong (actual: 4971).

More broadly, virtually every line-number reference in this document is stale, typically off by hundreds to over a thousand lines. I verified ~30 references against the current codebase and none matched. Examples:

Doc claim Actual Subject
Session.ts:1580 line 3044 setModel
bridge.ts:2883 line 1616 model_switched publish
bridgeClient.ts:491 line 601 extNotification()
bridge.ts:2784 line 3719 getSessionContextStatus
acpAgent.ts:935 line 2932 session.setModel
normalizer.ts:754 line 974 normalizeApprovalModeChanged

Impact: Implementers following these anchors will land on unrelated code. For a document whose value proposition is mapping coordination surfaces to code locations, this undermines the entire guide.

Suggested fix: Switch to function/method-name anchors (e.g., Session.setModel, BridgeClient.extNotification()) without line numbers. Line numbers are inherently fragile in a living codebase; symbol names survive refactoring.

— qwen3.7-max via Qwen Code /review


### v12 (2026-05-27) — ninth review round (helper signature + structural guard)

- **`publishModelSwitched` helper now accepts `originatorClientId` (Critical).** Both bridge roundtrip (`bridge.ts:1172`, `:2883`) and `applyModelServiceId` pass `originatorClientId` into every `model_switched` event. v11's `publishModelSwitched(entry, modelId)` signature omitted this — forcing implementers to either silently drop attribution or bypass the helper. Fixed: signature is now `publishModelSwitched(entry, modelId, opts?: { originatorClientId?: string })`. Bridge roundtrip and `applyModelServiceId` pass the resolved `originatorClientId`; demux promotion and reconciliation corrective pass none.

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] publishModelSwitched helper signature does not match implementation

The v12 changelog claims the implemented signature is:

publishModelSwitched(entry, modelId, opts?: { originatorClientId?: string })

The actual code at bridge.ts:1899 uses a positional parameter:

const publishModelSwitched = (
  entry: SessionEntry,
  modelId: string,
  originatorClientId: string | undefined,
): void => {

An implementer following the documented signature would pass { originatorClientId: 'x' } where a string | undefined is expected. The truthy object would be used as originatorClientId in the published event, serialized as [object Object] — silent data corruption on the wire.

Suggested fix: Update the documented signature to match: publishModelSwitched(entry, modelId, originatorClientId: string | undefined).

— qwen3.7-max via Qwen Code /review


### v10 (2026-05-27) — seventh review round (reconciliation TOCTOU + retry + tests)

- **Reconciliation TOCTOU (Critical) → publish-generation guard.** Even the v9 authoritative read has a window: after settle, a concurrent in-session `/model C` can promote `model_switched(C)` while the async read is in flight; the read (issued earlier) returns the pre-C value B; reconciliation then emits `model_switched(B)`, clobbering C. **Fix:** add a per-session `modelPublishGeneration`, bumped on every `model_switched` publish (bridge / demux promotion / reconciliation corrective). Reconciliation captures the generation **before** the async read and **skips the corrective if the generation advanced** during the read (a newer authoritative publish already landed). Reconciliation also fires on **both** success and failure paths (`.finally` on the roundtrip), since the timeout/failure case is exactly when it's most needed.

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] Reconciliation generation-guard: doc says "skip" but code does "skip-and-rerun"

This section describes the generation guard as: reconciliation skips the corrective if the generation advanced during the read.

The actual implementation (bridge.ts:1956-2038) does more: when the generation advances, it sets rerun = true, returns early, then in finally calls reconcileAfterRoundtrip(entry, target) once more. This bounded rerun exists because the newer publish's own reconciliation bailed on the in-flight guard, so without a re-run the latest change would never be reconciled.

This is a key behavioral detail that is entirely absent from the document. Worse, the §8 test plan (line 374) asserts getSessionContextStatus is called exactly once per settle event — but the rerun path calls it twice.

Suggested fix: Document the bounded-rerun mechanism alongside the generation guard. Update the §8 test plan: the generation-skip scenario should expect two getSessionContextStatus calls (the stale read + the rerun), not one.

— qwen3.7-max via Qwen Code /review

1. **Silent in-session change.** `Session.setMode` (`Session.ts:1561`) → `config.setApprovalMode()` (`:1573`), no notification.
2. **HTTP bypasses `Session.setMode`.** `setSessionApprovalMode` drives extMethod `qwen/control/session/approval_mode` (`acpAgent.ts:2200`) → `config.setApprovalMode()` directly (`acpAgent.ts:2228`). The in-session emit alone doesn't cover HTTP, and HTTP emits no agent notification.
3. **Payload + persist.** `approval_mode_changed` needs `{previous,next,persisted}` (`bridge.ts:2979` session-scoped, `:3007` workspace-scoped). `current_mode_update` carries only `currentModeId`; the agent has no `persist` concept.
4. **No serialization primitive yet.** `approvalModeQueue` **does not exist** in the codebase today; the approval-mode HTTP path (`bridge.ts:2893-3020`) runs extMethod + publish inline with no per-session queue (unlike the model path's `modelChangeQueue`). The suppress/race window is therefore unbounded until #4510 lands it.

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] approvalModeQueue already exists — A2 is not blocked on this prerequisite

This section states "approvalModeQueue does not exist in the codebase today" and marks A2 as blocked on PR #4510. In reality, approvalModeQueue is already implemented:

  • Defined on SessionEntry at bridge.ts:288
  • Initialized at bridge.ts:2059
  • Actively used for serialization at bridge.ts:4000 and bridge.ts:4114

The same claim appears in §7 sequencing (line 344: "BLOCKED on PR #4510") and §3 header (line 231: "blocked on #4510"). These should all be updated to reflect that the serialization infrastructure has landed.

— qwen3.7-max via Qwen Code /review


The staleness check (§2 item 4), §2.2 reconciliation, and A5's snapshot (§5) all need the session's **current** model / approval-mode / commands. The bridge had no synchronous accessor — only `getSessionContextStatus` (`bridge.ts:2784` → `requestSessionStatus`, an async `extMethod` roundtrip), and an `await` there reopens the very TOCTOU window these mechanisms close. So:

- Add to `SessionEntry`: `currentModelId?: string`, `currentApprovalMode?: ApprovalMode`, `availableCommands?: AvailableCommand[]`.

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] availableCommands cache field specified but not implemented

§2.3 specifies availableCommands?: AvailableCommand[] on SessionEntry, but this field does not exist in the codebase. A grep for availableCommands across bridge.ts returns zero matches.

The document itself warns (line 211): "If the implementer misses the hook, A5's snapshot serves stale/undefined commands with no backstop." Since this field is a prerequisite for A5's snapshot, the spec should clearly mark it as pending implementation rather than presenting it as part of the §2.3 cache that's already in place.

— qwen3.7-max via Qwen Code /review


**Non-recursion rule (v11/v12 — structurally enforced):** the reconciliation corrective calls `publishModelSwitched` (a local bus publish) and does **NOT** schedule a subsequent reconciliation. If an implementer factors `publishModelSwitched` through a wrapper that also attaches `.finally` reconciliation, the result is an infinite corrective loop (reconcile → read → publish → reconcile → …). Each corrective bumps the generation, but each new reconciliation reads the agent and may find divergence (the corrective updates the _bus_, not the _agent_). **Structural guard (v12):** a per-session `reconciliationInFlight: boolean` flag is set `true` before the async read and cleared after (in `.finally`). The roundtrip-settle `.finally` checks this flag before scheduling reconciliation; if `true`, it logs `[reconcile] session=<id> action=skipped-reentrant` and returns. This makes non-recursion invariant under refactoring — it cannot be defeated by call-graph reorganization. The `publishModelSwitched` helper itself has no side-effects beyond items (1)–(3).

**Read-error: bounded retry, then surface.** A transient `getSessionContextStatus` failure must not leave the bus permanently diverged with only a log line. Retry 1–2× with short backoff; if all fail, emit a `reconciliation_failed` bus event and log `action=read-error`.

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] reconciliation_failed bus event specified here but deliberately not implemented

§2.2 specifies retry + reconciliation_failed bus event with a concrete payload. The actual code (bridge.ts:2020-2035) deliberately does not implement this — the rationale exists only in a code comment: reconciliation_failed is not a known SDK event type, so asKnownDaemonEvent drops it and the reducer never sees it.

A future engineer reading this design doc will see a concrete spec, see it's missing from the code, and conclude it was overlooked — then implement it. The new event will be silently dropped by the SDK reducer, wasting effort.

Suggested fix: Document the deliberate omission and the reason (SDK reducer drops unknown events) directly in the design, so future implementers don't re-implement something that will be silently swallowed.

— qwen3.7-max via Qwen Code /review

- **Promotion table:** `current_model_update → model_switched`; `current_mode_update → approval_mode_changed` (session-scoped; deferred to step 3, see §7).
- **Payload mapping (both sub-types must be specified, else SDK validation drops them):**
- `current_model_update → model_switched`: map `currentModelId → data.modelId` and lift the envelope/`params.sessionId` into `data.sessionId`. The SDK validator requires a non-empty `data.modelId` (`events.ts:1910`); a verbatim promote (which keeps `currentModelId`) would fail validation and be silently dropped — **A1 non-functional**. So promotion is a field-mapping, not a relabel.
- `current_mode_update → approval_mode_changed`: build the full payload `{ sessionId, previous, next, persisted: false, scope: 'session' }`. `next` = the notification's `currentModeId`; **`previous` is taken from the bridge state cache** `entry.currentApprovalMode` (the value before this change — §2.3), so the agent does **not** send `previousModeId` (ACP `CurrentModeUpdate` has no such field anyway). An in-session change is never workspace-persisted, hence `persisted:false`, `scope:'session'`. `scope` is **additive** on `DaemonApprovalModeChangedData` and orthogonal to `persisted`: `scope` says which bus (this session vs peer sessions) the event targets; `persisted` says whether it also wrote workspace settings. The bridge's own `persist:true` HTTP path emits the `scope:'workspace', persisted:true` mirror (`bridge.ts:3007`).

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] scope field specified on approval_mode_changed but absent from implementation

§2.1 specifies scope: 'session' as additive on DaemonApprovalModeChangedData. The actual publishApprovalModeChanged helper (bridge.ts:1917-1935) emits { sessionId, previous, next, persisted } — no scope field.

The doc says scope tells consumers which bus the event targets (session vs workspace). Without it, consumers must infer scope from persisted — but the doc describes these as orthogonal dimensions.

Either implement the scope field or remove it from the spec to avoid confusion.

— qwen3.7-max via Qwen Code /review

- **2c. Atomic coupling:** reconciliation and `modelPublishGeneration` guard are a single atomic deliverable; shipping reconciliation without the guard creates a clobber regression (concurrent promotion during the async `getSessionContextStatus` read would write a stale value back). Both must land in the same PR.
3. **A2 — BLOCKED on PR #4510** (`approvalModeQueue`). Adds `current_mode_update` promotion (`previous` derived from the §2.3 cache — no `previousModeId` on the wire), `Session.setMode` emit, helper generalization, `scope`, retained bridge workspace publish, the **dual-emit transition** + IDE-companion + upstream-dispatch updates.
- **3b. Dual-emit removal** — tracked by a GitHub issue with a target release; the dual-emit publish site carries `TODO(dual-emit-removal)` referencing §2.1. Close the issue when the next release drops the dual-emit.
- **3c. A2 post-roundtrip reconciliation** — same §2.2 contract, reading the agent's true approval mode; adds `approvalModePublishGeneration` and `publishApprovalModeChanged` helper. Must land together with the A2 promotion (same rationale as 2c — reconciliation without the generation guard is worse than no reconciliation).

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] A2 post-roundtrip reconciliation has zero test coverage in §8

§7 step 3c calls A2 reconciliation an atomic deliverable: "reconciliation without the generation guard is worse than no reconciliation." Yet §8's reconciliation test scenarios (corrective, converged, generation-skip, failure-path trigger, read-error, non-recursion guard, generation counters) are all model-only.

No A2 reconciliation test scenarios exist: no corrective scenario for approval mode, no approvalModePublishGeneration assertion, no publishApprovalModeChanged helper test, no read-error path for approval-mode reconciliation.

Suggested fix: Add an A2 reconciliation subsection mirroring the A1 bullets: corrective (bridge setSessionApprovalMode + concurrent in-session /approval-mode → post-settle read → corrective approval_mode_changed), converged, generation-skip, and read-error.

— qwen3.7-max via Qwen Code /review

Opt-in via `?snapshot=1`; emit a synthetic **`session_snapshot`** frame after replay:

```
session_snapshot { approvalMode, model, availableCommands? }

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] A5 session_snapshot schema uses wrong field names — diverges from SDK type and implementation

The doc specifies session_snapshot { approvalMode, model, availableCommands? } but the SDK type DaemonSessionSnapshotData (packages/sdk-typescript/src/daemon/events.ts:741) and the bridge emit (bridge.ts:3193) use:

{ sessionId: string, currentModelId: string | null, currentApprovalMode: string | null }

Three divergences:

  • model → actual field is currentModelId
  • approvalMode → actual field is currentApprovalMode
  • availableCommands? → absent from both SDK type and bridge emit
  • sessionId → in the implementation but missing from the doc

An implementer following the doc's schema would write a reducer that reads undefined for both fields, making A5 non-functional.

Suggested change
session_snapshot { approvalMode, model, availableCommands? }
session_snapshot { sessionId, currentModelId, currentApprovalMode }

— qwen3.7-max via Qwen Code /review

### Workspace mirror (explicit decision)

`Session.setModel` defaults `persistDefault:true` (`Session.ts:1610`) and writes `model.name` via `getPersistScopeForModelSelection(this.settings)` (`Session.ts:1611`) — **workspace scope for a trusted workspace owning `modelProviders`, otherwise user scope**. Either way, **A1 phase 1 does session-scoped broadcast only**; rationale: peer sessions pick up the persisted default on next spawn, and there is no security-relevant cross-session gating like approval-mode. A persisted-model workspace mirror is an explicit deferred follow-up (§10), not silently omitted.

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] Reconciliation observability log format diverges from implementation

The doc specifies field names trigger=, baseline=, gen_before, and action values read-error, skipped-newer-gen, skipped-reentrant. The actual code uses different names:

Doc spec Actual code
trigger=roundtrip-settled|failed target=model|approvalMode
baseline=<modelId> cached=<modelId>
gen_before=<N> genBefore=<N>
action=skipped-newer-gen action=skipped reason=generation_changed
action=read-error action=failed error=<msg>
action=converged (no log emitted)

Oncall runbooks and grep queries built from the doc's format would fail to match actual log output.

— qwen3.7-max via Qwen Code /review

### 2.2 Post-roundtrip reconciliation (concurrent-in-session drift)

Suppress + drop assumes the bridge roundtrip and the agent describe the **same** change. That breaks under a concurrent in-session change, because in-session `/model` calls `Session.setModel` **directly and does NOT enter `modelChangeQueue`**:

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] legacyFrameSent dual-emit dedup flag is undocumented

The implementation has a legacyFrameSent: true flag — set in Session.ts:3174 (sendCurrentModeUpdateNotification for the exit_plan_mode path) and consumed at bridgeClient.ts:873 (handleInSessionModeUpdate skips the legacy session_update{current_mode_update} dual-emit when present). This prevents double-delivery because the sendUpdate() call on those paths already emitted the legacy frame before the extNotification fires.

The Session.setMode() path omits this flag (no prior sendUpdate), so the demux's dual-emit fires normally there.

This is the key implementation detail for making dual-emit correct on the tool-confirmation path. An implementer working on the dual-emit removal (§7 step 3b) would not know about it and could introduce double-delivery bugs.

— qwen3.7-max via Qwen Code /review

- **Suppress-during-roundtrip (per change-type, not per-session):** promote a `current_model_update` only when no bridge-driven **model** roundtrip is in flight for that session; promote a `current_mode_update` only when no bridge-driven **approval-mode** roundtrip is in flight. A model roundtrip must NOT suppress an in-session `current_mode_update` (and vice-versa) — cross-attribute suppression would silently drop the other axis's change.
- **Best-effort dedup (model):** the demux drops a `current_model_update` whose `currentModelId` already equals `entry.currentModelId` (§2.3) — a redundant no-op. It does **not** try to value-distinguish stale-vs-fresh (impossible by value alone); the authoritative backstop for the timeout/concurrent race is §2.2 reconciliation (§2 item 4).
- **Drop-when-suppressed (third rule):** when a _promotable_ sub-type is NOT promoted (suppressed or stale), **drop it entirely** — do **not** fall back to publishing the generic `session_update`. The bridge is already publishing the authoritative named event; emitting the generic wrapper too would double-signal. (Residual concurrent-in-session drift is handled by the §2.2 reconciliation.)
- **Generic-wrapper suppression:** a promoted sub-type publishes the named event only — **except during the dual-emit transition window (below)**.

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] events.ts:1910 references a file that does not exist

The doc cites events.ts:1910 for the SDK validator requiring a non-empty modelId. No file packages/acp-bridge/src/events.ts exists in the codebase. The actual validation is at packages/sdk-typescript/src/daemon/events.ts:2251 (isNonEmptyString(value['modelId']) inside isModelSwitchedData()).

Suggested change
- **Generic-wrapper suppression:** a promoted sub-type publishes the named event only — **except during the dual-emit transition window (below)**.
(`packages/sdk-typescript/src/daemon/events.ts:2251`, requires non-empty `modelId`)

— qwen3.7-max via Qwen Code /review

### Double-emit edge

`/approval-mode` during an open tool-confirmation dialog can fire two `current_mode_update` within ms (user `setMode` + the tool's `ProceedAlways` handler). Acceptable (converges); optionally skip emit when the resulting mode equals current. Documented, not gated.

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] sendCurrentModeUpdateNotification helper generalization was never implemented as specified

§3 point 4 describes generalizing sendCurrentModeUpdateNotification to accept an explicit currentModeId. The actual implementation took a different approach: Session.setMode() at line 3028 emits extNotification('qwen/notify/session/mode-update', ...) directly, bypassing the helper entirely. The helper at Session.ts:3132 still accepts only ToolConfirmationOutcome (unchanged signature) and is called only from the exit_plan_mode flow (lines 4041, 4049).

The §8 "Helper regression" test bullet still applies — exit_plan_mode and ProceedAlways callers must produce correct payloads — but the migration path described here was not followed.

— qwen3.7-max via Qwen Code /review

- **SDK typed event:** expose **both** `originatorClientId` (unchanged — no rename, no break) **and** a new optional `voterClientId`; old field documented as deprecated-alias for a future major.
- Prompt originator remains available by correlating with the matching `permission_request`.

### Wire / compat

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] A5 session_snapshot schema uses field names that diverge from the actual SDK type and implementation.

The doc specifies:

session_snapshot { approvalMode, model, availableCommands? }

But the SDK type DaemonSessionSnapshotData at packages/sdk-typescript/src/daemon/events.ts:741 defines:

export interface DaemonSessionSnapshotData {
  sessionId: string;
  currentModelId: string | null;
  currentApprovalMode: string | null;
}

The bridge implementation at bridge.ts:3193-3197 emits using the SDK field names (sessionId, currentModelId, currentApprovalMode). Three divergences: approvalModecurrentApprovalMode, modelcurrentModelId, and availableCommands is absent from the SDK type entirely.

An implementer following this doc would produce a frame the SDK validator (isSessionSnapshotData at events.ts:2648) would reject.

Suggested change
### Wire / compat
session_snapshot { sessionId, currentModelId, currentApprovalMode }

Also consider whether availableCommands should be added to the SDK type, or removed from the doc's proposed schema.

— qwen3.7-max via Qwen Code /review


### Proposed design

1. **Transport: `extNotification`, not a sessionUpdate (v7).** `current_model_update` is **not** an ACP `SessionUpdate` variant. So `Session.setModel`, after `switchModel` resolves (**success only**), emits via the agent→bridge **`extNotification`** side-channel with the **fully-qualified method name `qwen/notify/session/model-update`** (matching the existing `qwen/notify/session/*` convention; impl in #4546) and payload `{ v:1, sessionId, currentModelId }`. No `previousModelId` / `authType` (the bridge derives `previous` from its state cache §2.3; `model_switched` is `{sessionId,modelId}`). **Implementation note:** `BridgeClient.extNotification()`'s current early-return guard (`if (method !== 'qwen/notify/session/mcp-budget-event') return;`) must become a method dispatch so the model-update handler is reachable (done in #4546).

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] A2 transport diverges from design. The doc says:

2b. A2 (approval-mode): Session.setMode emits current_mode_update as a real ACP sessionUpdate. BridgeClient.sessionUpdate() demuxes it → approval_mode_changed.

But the implementation uses extNotification, mirroring A1:

// Session.ts:3028
void this.client
  .extNotification('qwen/notify/session/mode-update', {
    v: 1, sessionId: this.sessionId, currentModeId: params.modeId,
  })

The bridge-side demux is in BridgeClient.extNotification() (bridgeClient.ts:601), not sessionUpdate(). This makes A1 and A2 symmetric (both extNotification), contradicting §0 ("two demux insertion points — A2 in sessionUpdate(), A1 in extNotification()"), §2.1, and the anchor convention.

If the implementation's choice is intentional (it appears to be — the code comment says "Mirrors the model-update extNotification in setModel"), update the doc to reflect this. The asymmetry described in v7 no longer exists.

— qwen3.7-max via Qwen Code /review

# Daemon side-channel coordination — Design (A1 / A2 / A4 / A5)

> Targets `daemon_mode_b_main` (per #4175 branching strategy). Author: 秦奇. Date: 2026-05-25. Revised: 2026-05-27 (v13 — zombie-gap doc, reconciliation_failed contract, availableCommands spec, §7 atomic-coupling, §8 bounded-call-count).
> **Docs-only / design-first.** A4 implemented + approved (#4539); A1 implemented (#4546).

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] A2's blocking dependency is resolved. The doc header says "A4 implemented + approved (#4539); A1 implemented (#4546)" but does not mention A2's status. Meanwhile §3 says:

A2 is blocked on PR #4510 landing approvalModeQueue

approvalModeQueue is now in the codebase (bridge.ts:288, initialized at :2059, chained at :4114, tested in bridge.test.ts). The A2 implementation itself is also done — Session.setMode emits via extNotification (Session.ts:3028) and the bridge demux promotes it (bridgeClient.ts:860+).

Update the header and §3 to reflect that A2 is implemented (like A1 and A4) rather than blocked.

— qwen3.7-max via Qwen Code /review

Double-broadcast (mitigated by §1.1 + the three §2.1 rules); failure-event loss (item 3 carve-out). Tests in §8.

---

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] Doc says reconciliation failures should emit a reconciliation_failed bus event. The implementation deliberately does not emit it:

// bridge.ts:2025-2031
// The status read failed — drift can be neither confirmed nor
// corrected. Keep the signal in the operator log rather than
// emitting a bus event no client can decode: `reconciliation_failed`
// is not a known SDK event type, so `asKnownDaemonEvent` drops it
// and the reducer never sees it.

The implementation chose stderr logging over a bus event because the SDK has no decoder for reconciliation_failed. The doc's §2.2 "Read-error" section and the reconciliation_failed payload spec ({ sessionId, error, retryCount, trigger }) describe a contract the code intentionally does not fulfill.

Update the doc to match: either document the deliberate omission and rationale, or file a follow-up to add SDK decoding support.

— qwen3.7-max via Qwen Code /review


**Non-recursion rule (v11/v12 — structurally enforced):** the reconciliation corrective calls `publishModelSwitched` (a local bus publish) and does **NOT** schedule a subsequent reconciliation. If an implementer factors `publishModelSwitched` through a wrapper that also attaches `.finally` reconciliation, the result is an infinite corrective loop (reconcile → read → publish → reconcile → …). Each corrective bumps the generation, but each new reconciliation reads the agent and may find divergence (the corrective updates the _bus_, not the _agent_). **Structural guard (v12):** a per-session `reconciliationInFlight: boolean` flag is set `true` before the async read and cleared after (in `.finally`). The roundtrip-settle `.finally` checks this flag before scheduling reconciliation; if `true`, it logs `[reconcile] session=<id> action=skipped-reentrant` and returns. This makes non-recursion invariant under refactoring — it cannot be defeated by call-graph reorganization. The `publishModelSwitched` helper itself has no side-effects beyond items (1)–(3).

**Read-error: bounded retry, then surface.** A transient `getSessionContextStatus` failure must not leave the bus permanently diverged with only a log line. Retry 1–2× with short backoff; if all fail, emit a `reconciliation_failed` bus event and log `action=read-error`.

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] The dual-emit transition section describes emitting both the promoted approval_mode_changed and the legacy session_update{current_mode_update}, but doesn't document the legacyFrameSent dedup mechanism present in the implementation.

In bridgeClient.ts:873:

if (params['legacyFrameSent'] === true) {
  // skip legacy dual-emit
}

This flag prevents double-delivery when the producer (e.g., exit_plan_mode path) has already sent the session_update{current_mode_update} frame via sendUpdate. The setMode path omits the flag, so its dual-emit still fires.

This dedup mechanism is load-bearing for correctness (without it, the exit_plan_mode path delivers the mode update twice) but is undocumented in the design. Add a description of the legacyFrameSent flag and its two-path behavior.

— qwen3.7-max via Qwen Code /review

- **Payload mapping (both sub-types must be specified, else SDK validation drops them):**
- `current_model_update → model_switched`: map `currentModelId → data.modelId` and lift the envelope/`params.sessionId` into `data.sessionId`. The SDK validator requires a non-empty `data.modelId` (`events.ts:1910`); a verbatim promote (which keeps `currentModelId`) would fail validation and be silently dropped — **A1 non-functional**. So promotion is a field-mapping, not a relabel.
- `current_mode_update → approval_mode_changed`: build the full payload `{ sessionId, previous, next, persisted: false, scope: 'session' }`. `next` = the notification's `currentModeId`; **`previous` is taken from the bridge state cache** `entry.currentApprovalMode` (the value before this change — §2.3), so the agent does **not** send `previousModeId` (ACP `CurrentModeUpdate` has no such field anyway). An in-session change is never workspace-persisted, hence `persisted:false`, `scope:'session'`. `scope` is **additive** on `DaemonApprovalModeChangedData` and orthogonal to `persisted`: `scope` says which bus (this session vs peer sessions) the event targets; `persisted` says whether it also wrote workspace settings. The bridge's own `persist:true` HTTP path emits the `scope:'workspace', persisted:true` mirror (`bridge.ts:3007`).
- **Suppress-during-roundtrip (per change-type, not per-session):** promote a `current_model_update` only when no bridge-driven **model** roundtrip is in flight for that session; promote a `current_mode_update` only when no bridge-driven **approval-mode** roundtrip is in flight. A model roundtrip must NOT suppress an in-session `current_mode_update` (and vice-versa) — cross-attribute suppression would silently drop the other axis's change.

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] A2 transport documented as sessionUpdate() but implementation uses extNotification() — same as A1

The doc states A2 uses ACP sessionUpdate demuxed in BridgeClient.sessionUpdate(), while A1 uses extNotification demuxed in BridgeClient.extNotification(). Verified against code: both A1 and A2 use extNotification — A1 via qwen/notify/session/model-update, A2 via qwen/notify/session/mode-update (both dispatched at bridgeClient.ts:605-611). The "two insertion points" framing and every section referencing the A1/A2 transport split (§0, §1.1, §6, §7, §9) inherit this error.

The real asymmetry is the dual-emit compat path (A2 emits a legacy session_update{current_mode_update} frame; A1 does not), not the transport.

Suggested change
- **Suppress-during-roundtrip (per change-type, not per-session):** promote a `current_model_update` only when no bridge-driven **model** roundtrip is in flight for that session; promote a `current_mode_update` only when no bridge-driven **approval-mode** roundtrip is in flight. A model roundtrip must NOT suppress an in-session `current_mode_update` (and vice-versa) — cross-attribute suppression would silently drop the other axis's change.
> **A2 — in-session approval-mode change (asymmetric; dual-emit compat)**
- Transport: `extNotification` dispatched from `bridgeClient.extNotification()` (same insertion point as A1), method `qwen/notify/session/mode-update`.
- Dual-emit compat: also emits a legacy generic `session_update{current_mode_update}` frame for the IDE companion's `case 'current_mode_update'` handler.

# Daemon side-channel coordination — Design (A1 / A2 / A4 / A5)

> Targets `daemon_mode_b_main` (per #4175 branching strategy). Author: 秦奇. Date: 2026-05-25. Revised: 2026-05-27 (v13 — zombie-gap doc, reconciliation_failed contract, availableCommands spec, §7 atomic-coupling, §8 bounded-call-count).
> **Docs-only / design-first.** A4 implemented + approved (#4539); A1 implemented (#4546).

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] Stale implementation status — A2 and A5 are implemented but doc still marks them as pending/blocked

Verified: approvalModeQueue exists at bridge.ts:289, A2 demux at bridgeClient.ts:785, A5 session_snapshot injection at bridge.ts:3187-3202. Update the header to reflect all four axes' implementation status and remove "blocked on #4510" from §3/§7.

- Add to `SessionEntry`: `currentModelId?: string`, `currentApprovalMode?: ApprovalMode`, `availableCommands?: AvailableCommand[]`.
- **Update synchronously at every publish site**, in the same synchronous turn as the publish (no `await` between read-of-old and write-of-new): all `model_switched` publishes go through the §2.2 `publishModelSwitched` helper (which atomically updates `entry.currentModelId` + bumps `entry.modelPublishGeneration` + publishes to bus); `approval_mode_changed` (`:2979` / `:3007`) updates `entry.currentApprovalMode`; `availableCommands` is updated in `BridgeClient.sessionUpdate()` when it receives an `available_commands_update` generic sessionUpdate — the handler sets `entry.availableCommands = payload.commands` synchronously **before** the generic forwarding publish. The helper guarantees no publish site can miss a cache or generation update.
- **`availableCommands` specifics (v13):** type is `AvailableCommand[]` (matching `status.ts`). Unlike model/mode, this field has **no named promoted bus event** and **no reconciliation** — it's a passive cache, updated by the generic `session_update` path. If the implementer misses the hook, A5's snapshot serves stale/undefined commands with no backstop. The trigger path is explicitly `BridgeClient.sessionUpdate()` → check `params.type === 'available_commands_update'` → update cache → forward as generic `session_update`.
- **Seed** from the `createSession` / `loadSession` ACP response when the entry is created (initial model/mode), before any change occurs.

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] Failure-path reconciliation not implemented — doc mandates both paths, code fires on success only

Verified at bridge.ts:1643-1649 and :3974-3980: reconciliation fires only when succeeded === true. On failure, it logs action=skipped reason=roundtrip_failed and returns. The exact scenario the doc calls most dangerous — timed-out model switch that actually applied — is the one case reconciliation does NOT fire. The §8 "failure-path trigger" and "failure-path converged" test scenarios cannot pass against current code.

Either update the implementation to reconcile on failure, or update the doc to match reality.


Double-broadcast (mitigated by §1.1 + the three §2.1 rules); failure-event loss (item 3 carve-out). Tests in §8.

---

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] reconciliation_failed retry + bus event not implemented — and not registered in SDK types

Verified at bridge.ts:2031-2044: no retry, no bus event (explicit comment: SDK can't decode the event type). Additionally, reconciliation_failed is not in asKnownDaemonEvent (events.ts:1352-1526), not in KnownDaemonEvent union, not in DaemonEvent type. Even if emitted, asKnownDaemonEvent would silently drop it. The §8 "read-error" test bullet asserting the event payload cannot pass.

- **NOT a consumer: §2.2 reconciliation.** Reconciliation needs the agent's _true_ model, which the cache can't provide (it never sees dropped suppressed notifications); reconciliation uses the authoritative `getSessionContextStatus` read instead (§2.2, v9). The cache reflects only what was _published_.

This makes the cache a first-class synchronous source for the snapshot + dedup + `previous`, without overreaching into the reconciliation truth path.

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] Generation-guard re-run mechanism undocumented

The implementation has a re-run mechanism: when generation advances during the async read, reconciliation skips the stale corrective AND re-runs once (bridge.ts:1972-1985, :2047). This is a correctness mechanism — without it, a concurrent promotion whose reconciliation bailed on the in-flight guard would never be reconciled. The doc only says "skips the corrective if the generation advanced" with no mention of the re-run.

Suggested change
> Skips the corrective if the generation advanced during the read (a newer publish landed since `genBefore` was captured); in this case, reconciliation re-runs once after releasing the in-flight guard so the newer publish gets its own reconciliation pass.

1. Emit `current_mode_update` from `Session.setMode` (covers ACP `setSessionMode`, `acpAgent.ts:922`, and in-session `/approval-mode`).
2. The HTTP extMethod path keeps the **bridge's** session-scoped `approval_mode_changed` publish (`bridge.ts:2979`) and emits **no** agent notification (it bypasses `Session.setMode`) — the bridge is the sole emitter; nothing to suppress.
3. **`previous` comes from the bridge state cache — the agent does NOT send `previousModeId`.** The SDK normalizer `normalizeApprovalModeChanged` (`normalizer.ts:754`) requires `previous`, so the promoted `approval_mode_changed` must carry it. But ACP's `CurrentModeUpdate` has only `currentModeId` (no `previousModeId` field — the same external-union constraint v7 hit for A1; you can't add a required field to the spec'd type). Resolution: the **demux fills `previous` from `entry.currentApprovalMode`** (the cached value before this change, §2.3), and updates the cache to `currentModeId` in the same synchronous turn. The agent's `current_mode_update` stays the unmodified ACP shape (`{currentModeId}`), and the bridge always produces a complete `{previous,next}` — no SDK-drop, no ACP-schema change.
4. **Helper generalization (migration model specified):** `sendCurrentModeUpdateNotification` (`Session.ts:1625`) today derives `newModeId` from a `ToolConfirmationOutcome` (only `auto-edit`/`default`/current). Generalize it to accept an explicit `currentModeId` so `Session.setMode` can emit for any `ApprovalMode` (`plan`/`yolo`/`auto`/…). The two existing tool-confirmation callers (`Session.ts:2160`, `:2168`) keep their `ToolConfirmationOutcome` entry point (which pre-computes `currentModeId` then delegates) — NOT a flag-day removal; deprecation tracked separately. No caller needs to compute `previous` (the bridge derives it, item 3).

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] availableCommands cache not implemented on SessionEntry

Verified: no availableCommands field on SessionEntry (bridge.ts:260-290), no handler for available_commands_update in BridgeClient.sessionUpdate(), and A5's snapshot (bridge.ts:3202-3208) omits it. The doc warns "If the implementer misses the hook, A5's snapshot serves stale/undefined commands with no backstop" — this is the current state.

Mark as "not yet implemented" in §2.3 and add a tracking issue in §7.


---

## 5. A5 — attach-time side-channel snapshot

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] DaemonChannelBridge still drops approval_mode_changed — dual-emit gap unaddressed

§6 lists DaemonChannelBridge.ts as an affected consumer that "must gain an approval_mode_changed path." Verified: DaemonChannelBridge.ts still has default: break; with zero approval_mode_changed handling in packages/channels/. If the dual-emit window has passed, the VS Code companion never receives the promoted event.

Either implement the handler or track this as an explicit follow-up in §7.

- **Generic-wrapper suppression:** a promoted sub-type publishes the named event only — **except during the dual-emit transition window (below)**.
- **Dual-emit transition (IDE-companion lockstep, see §6):** because the daemon and the VS Code IDE companion ship on different channels and can't flip atomically, the FIRST release of `current_mode_update` promotion publishes **both** the promoted `approval_mode_changed` AND the legacy generic `session_update{sessionUpdate:'current_mode_update'}` for one release cycle. The IDE companion's existing `case 'current_mode_update'` keeps working; once its `approval_mode_changed` handler ships, the next release drops the dual-emit. `current_model_update` is brand-new (no legacy consumer) so it promotes directly without dual-emit. **Removal is enforced, not left to memory:** a `TODO(dual-emit-removal)` comment at the dual-emit publish site references this section, and §7 step 3 carries a tracking issue with a target release — so the redundant generic wrapper can't silently become permanent (and no new consumer should build on it).
- **Observability (required, not optional):** emit a structured log at every demux decision — `[demux] session=<id> type=<sub> action=promoted|dropped|suppressed|generic reason=<why>`. `BridgeClient.sessionUpdate()` has zero logging today; the `dropped` case especially must be visible so oncall can distinguish "agent didn't emit" / "demux dropped" / "SSE lost".
- **Unknown sub-types:** unchanged (generic `session_update`).

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] scope field on approval_mode_changed specified but not implemented

Doc says scope: 'session' | 'workspace' is "additive on DaemonApprovalModeChangedData" and "orthogonal to persisted." Verified: DaemonApprovalModeChangedData (events.ts:560-566) has no scope field, publishApprovalModeChanged payload type omits it, isApprovalModeChangedData validator doesn't check it.

- **First-attach ordering** (no `Last-Event-ID`): `replay_complete` is NOT force-pushed (no replay occurred), and the design does **not** synthesize a `replay_complete{replayedCount:0}` — doing so would widen that event's "replaying→live" contract for existing consumers. Instead `session_snapshot` is **self-delimiting on first attach**: it is emitted as the first frame, before live tail; consumers treat a `session_snapshot` as "baseline established". (Resume keeps the replay → `replay_complete` → snapshot order above.)
- **`pendingPermissionIds` excluded** (Security, below).
- SDK: typed `session.snapshot` event seeds the view-state reducer's side-channel fields, applied last (on resume) / first (on first-attach).

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] Snapshot payload field names diverge from implementation and SDK types

Doc specifies session_snapshot { approvalMode, model, availableCommands? } but implementation (bridge.ts:3202-3208) and SDK type DaemonSessionSnapshotData (events.ts:767-772) use currentModelId and currentApprovalMode. An implementer building from the doc alone would read undefined fields.

Suggested change
> session_snapshot { sessionId: string, currentModelId: string | null, currentApprovalMode: string | null, availableCommands?: AvailableCommand[] }


---

## 8. Test plan

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] Test plan gaps for critical contract items

Three mechanisms with Critical design rationale have no corresponding test scenarios:

  1. A1 promoted event payload shape — field mapping currentModelId → modelId and sessionId lifting are load-bearing ("would fail validation and be silently dropped — A1 non-functional" per §2.1) but no test asserts the payload shape
  2. originatorClientId propagation — elevated to Critical in v12 changelog, but §8 "Bridge state cache" bullet never asserts originatorClientId on emitted model_switched events
  3. availableCommands cache update — the hook whose absence §2.3 warns about has zero test coverage
Suggested change
## 8. Test plan
> A1 — promoted event payload: `current_model_update` produces a `model_switched` with `data.modelId` (not `data.currentModelId`) and `data.sessionId` set; assert `data.currentModelId` is NOT present.
>
> originatorClientId — bridge `setSessionModel`/`applyModelServiceId` publish with `originatorClientId` set; demux promotion and reconciliation corrective publish with `originatorClientId` undefined.
>
> availableCommands — `available_commands_update` generic sessionUpdate updates `entry.availableCommands` synchronously; a subsequent A5 `session_snapshot` reflects the updated commands.

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

Review Summary

Verdict: Request Changes — 5 Critical findings, 4 Suggestions, 7 additional low/medium-confidence items (terminal-only).

This design document is impressively thorough — the reconciliation contract, generation guards, and non-recursion enforcement are well thought out. However, several gaps between the spec and the (now-shipped) implementation need closing before this doc can serve as the authoritative reference it aims to be.

Critical (must-fix)

  1. Reconciliation doc/code mismatch — §2.2's reconciliation_failed event has no SDK type, no retry mechanism, and no consumer contract path to implementation. The forward reference to §8's test plan is also broken (§8 doesn't cover reconciliation_failed).
  2. Missing v13 changelog entry — The header claims v13 revisions but the changelog stops at v12. Several v13-tagged changes (zombie roundtrip, reconciliation_failed payload, availableCommands spec) have no corresponding changelog section.
  3. §2.3 cache fields incomplete — Omits modelPublishGeneration, reconciliationInFlight, approvalModePublishGeneration, and approvalModeReconciliationInFlight despite these being referenced by §2.2 mechanisms.
  4. Zombie roundtrip has no test scenario — §2.1's v13 zombie roundtrip is a documented residual race but §8's test plan includes no scenario for it, and there's no tracking issue.
  5. reconciliation_failed bus event unreachable — No SDK event type, no retry wiring, and the §2.2 contract describes it as advisory with no required handler. Risk of silent data loss.

Suggestions

  1. All Session.ts/bridge.ts/bridgeClient.ts line-number citations are stale after 13+ revision rounds — consider switching to anchor-based references.
  2. availableCommands is a passive cache with no backstop — if the hook is missed, A5 snapshot serves stale data silently.
  3. A2 approvalModePublishGeneration guard in §7 but not in §3 — inconsistent coupling.
  4. Observability log format in §2.2 diverges from the implementation's structured logging conventions.

— qwen3.7-max via Qwen Code /review


## Changelog

### v12 (2026-05-27) — ninth review round (helper signature + structural guard)

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] Missing v13 changelog entry

The header (line 5) says "Revised: 2026-05-27 (v13 — zombie-gap doc, reconciliation_failed contract, availableCommands spec, §7 atomic-coupling, §8 bounded-call-count)" but the changelog sections only go up to v12. Several v13-tagged additions throughout the document (zombie roundtrip §2.1, reconciliation_failed payload §2.2, availableCommands spec §2.3, atomic coupling §7) have no corresponding changelog entries.

Please add a ### v13 (2026-05-27) — tenth review round section with bullet points for each v13 change, consistent with the v11/v12 format.

— qwen3.7-max via Qwen Code /review

### 1.1 Coordination model (the load-bearing decision)

v1's "agent is the single emitter; bridge drops its publish" was **rejected** — the bridge owns serialization (`modelChangeQueue`), timeout handling, `model_switch_failed`, and the persist/workspace distinction. Adopted model:

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] Stale line-number citations

This document contains dozens of references like Session.ts:1645, bridge.ts:2784, bridgeClient.ts:491, acpAgent.ts:935, etc. These line numbers were accurate at v1 but have drifted through 13+ revision rounds as the implementation PRs (#4539, #4546, #4510) modified the source files.

Consider switching to anchor-based references (e.g., Session.setModel, BridgeClient.extNotification(), the modelChangeQueue block) with a one-time note that line numbers reflect the pre-implementation snapshot. This prevents the doc from becoming misleading as the codebase evolves.

— qwen3.7-max via Qwen Code /review

3. **`model_switch_failed` stays bridge-only** — `Session.setModel` throws with no notification; the bridge keeps publishing it on both failure paths.
4. **Timeout-race (best-effort demux drop + authoritative reconciliation backstop — v9).** The bridge's `withTimeout` (`bridge.ts:2844-2849`) can reject (publishing `model_switch_failed(A)`) while A's ACP call keeps running (FIXME `bridge.ts:2836-2840`). If a change B then succeeds (`model_switched(B)`) and A's call finally completes, A's late `current_model_update(A)` must not make A the apparent final state. **Value comparison alone can't decide** this (a late stale `A` and a fresh switch to `A` look identical — a distributed-ordering problem). So: the demux does a **best-effort dedup** (drops a `current_model_update` whose `currentModelId` already equals `entry.currentModelId` — a redundant no-op), and the **authoritative correctness comes from §2.2 reconciliation**: a timed-out earlier change always corresponds to a _settled bridge roundtrip_, which triggers a post-settle authoritative read that re-publishes the agent's true model. No agent-side sequence counter required.

**Residual gap — zombie roundtrip (v13).** Reconciliation covers the _first_ settlement (the timeout), but a zombie ACP call that completes **after** reconciliation has already fired `action=converged` is NOT covered: the agent applies the timed-out model late → emits `current_model_update(A)` → demux promotes it (no roundtrip in flight, not a dup) → bus silently reverts to A, contradicting the user's successful switch to B. The long-term fix is an ACP cancel signal (the existing FIXME at `bridge.ts:2836-2840`). Until then this is a **known residual race** under the narrow condition: timeout fires, reconciliation converges (agent hasn't applied yet), user successfully switches to B, THEN the zombie completes. Likelihood is low (requires the agent to take longer than the timeout + reconciliation read + a subsequent successful switch), but it is not zero. Document it here rather than claim reconciliation fully eliminates the timeout race.

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] Zombie roundtrip has no test scenario and no tracking issue

The v13 zombie roundtrip residual race is well-documented: timeout fires → reconciliation converges → user switches to B → zombie ACP call completes → bus silently reverts. However:

  1. §8's test plan includes no scenario for this race. The bounded-call-count extension and generation-skip assertions cover other paths but not the zombie case.
  2. There's no tracking issue referenced for the ACP cancel signal fix (the FIXME at bridge.ts:2836-2840).

Suggest: (a) add a §8 test scenario asserting the bus stays on B after zombie A completion (the expected behavior is that the demux drops it as a dup since entry.currentModelId is already B — but this only holds if B's publish completed before A's zombie notification, which is the narrow window described); (b) file a tracking issue for the ACP cancel signal and reference it here.

— qwen3.7-max via Qwen Code /review

3. Demux **drops** B (suppress window open).
4. Bridge publishes the authoritative `model_switched(A)`; **bus shows A, session runs B — nothing reconciles.**

**Contract (v9/v10/v11 — authoritative read, generation-guarded, non-recursive):** reconciliation fires when a bridge model roundtrip settles — on **both** the success and failure paths (a `.finally` on the roundtrip, since the timeout/failure case is exactly when the bus is most likely diverged). It reads the agent's **true** current model via `getSessionContextStatus` (`bridge.ts:2784`, async `extMethod`) and, if it diverges from the bus's current model (`entry.currentModelId` — on the failure path this is the **pre-roundtrip** value, since `model_switch_failed` does not update the cache), emits a corrective `model_switched` via `publishModelSwitched`. **Why not the §2.3 cache _as truth_:** the cache is updated only at publish sites, so it can't observe a concurrent in-session change the demux **dropped** — reading it would falsely conclude "no divergence". The agent is the only source of truth. The read is async but runs **post-settle, outside the demux**, so the §5 synchronous-block constraint doesn't apply. (Longer-term: route in-session `/model` through `modelChangeQueue` — §10 Q3 — to make this race-free at the source.) The same reconciliation applies to A2 once `approvalModeQueue` exists.

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] Reconciliation doc/code mismatch on failure path + broken forward reference

Two issues here:

  1. reconciliation_failed bus event contract is unreachable. The spec defines reconciliation_failed { sessionId, error, retryCount, trigger } but: (a) there's no corresponding SDK event type in the bus's type union — implementers would need to add one; (b) the retry mechanism ("1–2× with short backoff") has no wiring spec (delays? exponential? jitter?); (c) the consumer contract says clients MAY self-heal but MAY also ignore it — so the event could land on the bus with zero consumers, and the bus state remains stale-but-non-terminal indefinitely.

  2. Broken forward reference to §8. The text says "§8 includes a test asserting this full consumer-visible event ordering" but §8's test plan doesn't include a reconciliation_failed test scenario. The failure-path consumer ordering test mentioned in §8 covers model_switch_failedmodel_switched(A) (the success-after-timeout case), not the reconciliation_failed event.

— qwen3.7-max via Qwen Code /review


**Generation guard (v10 — closes the read-window TOCTOU):** between settle and the async read returning, a concurrent in-session `/model C` can promote `model_switched(C)`; the in-flight read (issued before C) returns the pre-C value and reconciliation would clobber C. Fix: a per-session `modelPublishGeneration` is bumped on **every** `model_switched` publish (bridge / demux promotion / reconciliation corrective) — exclusively via the `publishModelSwitched` helper (v11). Reconciliation captures the generation **before** the read and **skips the corrective if it advanced** during the read — a newer authoritative publish already landed, so the bus is current.

**`publishModelSwitched` helper (v11/v12 — enforcement mechanism):** a single function `publishModelSwitched(entry, modelId, opts?: { originatorClientId?: string })` that atomically (one synchronous turn): (1) sets `entry.currentModelId = modelId`, (2) increments `entry.modelPublishGeneration`, (3) publishes `model_switched` to the bus (with `originatorClientId` if provided). **All** `model_switched` publish sites — bridge roundtrip success, `applyModelServiceId`, demux promotion, reconciliation corrective — MUST route through this helper. Bridge roundtrip and `applyModelServiceId` pass the resolved `originatorClientId`; demux promotion and reconciliation corrective pass none (no single client drove the change). Direct `events.publish({type:'model_switched', ...})` is forbidden outside the helper. This makes it impossible to miss a generation bump or silently drop client attribution, and a test invariant can assert: after any code path that produces a `model_switched`, the generation advanced by exactly 1.

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] Observability log format diverges from implementation conventions

The specified format [reconcile] session=<id> trigger=… baseline=… actual=… gen_before=… gen_after=… action=… uses a custom key=value format. The project's existing structured logging typically uses JSON objects passed to the logger (e.g., log.info('reconcile', { sessionId, trigger, baseline, actual, ... })).

Suggest aligning the observability format with whatever structured logging abstraction the bridge currently uses, rather than specifying a string format that the implementer would need to manually construct. This also makes the log lines grep-friendly and parseable by log aggregation tools.

— qwen3.7-max via Qwen Code /review


**Read-error: bounded retry, then surface.** A transient `getSessionContextStatus` failure must not leave the bus permanently diverged with only a log line. Retry 1–2× with short backoff; if all fail, emit a `reconciliation_failed` bus event and log `action=read-error`.

- **Payload (v13):** `reconciliation_failed { sessionId: string, error: string, retryCount: number, trigger: 'roundtrip-settled' | 'failed' }`. The `error` distinguishes "agent process crashed" from "JSON-RPC timeout" for consumer UX and oncall diagnostics.

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] reconciliation_failed bus event has no SDK type and no retry mechanism

The reconciliation_failed event contract defined here requires:

  • A new bus event type in the SDK's event union (not mentioned)
  • A retry mechanism with backoff (no implementation guidance — is this setTimeout? a retry utility? p-retry?)
  • The consumer contract is advisory ("MAY surface… MAY trigger their own pull") which means this event can be emitted into a void

Risk: if all retries fail and no consumer handles reconciliation_failed, the bus state is permanently stale with only a log line as evidence. Consider either: (a) making reconciliation_failed require a handler that falls back to getSessionContextStatus on the consumer side, or (b) having the bridge itself perform the fallback pull after exhausting retries.

— qwen3.7-max via Qwen Code /review


The staleness check (§2 item 4), §2.2 reconciliation, and A5's snapshot (§5) all need the session's **current** model / approval-mode / commands. The bridge had no synchronous accessor — only `getSessionContextStatus` (`bridge.ts:2784` → `requestSessionStatus`, an async `extMethod` roundtrip), and an `await` there reopens the very TOCTOU window these mechanisms close. So:

- Add to `SessionEntry`: `currentModelId?: string`, `currentApprovalMode?: ApprovalMode`, `availableCommands?: AvailableCommand[]`.

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] §2.3 omits modelPublishGeneration, reconciliationInFlight, approvalModePublishGeneration, and approvalModeReconciliationInFlight

The §2.3 cache spec lists currentModelId, currentApprovalMode, and availableCommands as the SessionEntry additions. But §2.2 references four additional per-session fields that must also live on SessionEntry:

  • modelPublishGeneration (generation guard for A1 reconciliation)
  • reconciliationInFlight (structural non-recursion guard for A1)
  • approvalModePublishGeneration (generation guard for A2 reconciliation, §7 3c)
  • approvalModeReconciliationInFlight (structural non-recursion guard for A2, by analogy)

These are load-bearing for correctness — omitting them from §2.3 means an implementer reading only §2.3 would produce a SessionEntry type that's missing the fields needed by §2.2 mechanisms.

— qwen3.7-max via Qwen Code /review


- Add to `SessionEntry`: `currentModelId?: string`, `currentApprovalMode?: ApprovalMode`, `availableCommands?: AvailableCommand[]`.
- **Update synchronously at every publish site**, in the same synchronous turn as the publish (no `await` between read-of-old and write-of-new): all `model_switched` publishes go through the §2.2 `publishModelSwitched` helper (which atomically updates `entry.currentModelId` + bumps `entry.modelPublishGeneration` + publishes to bus); `approval_mode_changed` (`:2979` / `:3007`) updates `entry.currentApprovalMode`; `availableCommands` is updated in `BridgeClient.sessionUpdate()` when it receives an `available_commands_update` generic sessionUpdate — the handler sets `entry.availableCommands = payload.commands` synchronously **before** the generic forwarding publish. The helper guarantees no publish site can miss a cache or generation update.
- **`availableCommands` specifics (v13):** type is `AvailableCommand[]` (matching `status.ts`). Unlike model/mode, this field has **no named promoted bus event** and **no reconciliation** — it's a passive cache, updated by the generic `session_update` path. If the implementer misses the hook, A5's snapshot serves stale/undefined commands with no backstop. The trigger path is explicitly `BridgeClient.sessionUpdate()` → check `params.type === 'available_commands_update'` → update cache → forward as generic `session_update`.

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] availableCommands passive cache with no backstop

The spec explicitly notes that availableCommands has "no named promoted bus event and no reconciliation" — it's a passive cache updated only via the generic session_update path. The warning "if the implementer misses the hook, A5's snapshot serves stale/undefined commands with no backstop" is honest but concerning.

Suggest adding at minimum: (a) a startup invariant check that asserts the cache was seeded (log a warning if availableCommands is undefined after the first session_update window); (b) a §8 test scenario that asserts the cache is populated after a known available_commands_update notification.

— qwen3.7-max via Qwen Code /review

- **2c. Atomic coupling:** reconciliation and `modelPublishGeneration` guard are a single atomic deliverable; shipping reconciliation without the guard creates a clobber regression (concurrent promotion during the async `getSessionContextStatus` read would write a stale value back). Both must land in the same PR.
3. **A2 — BLOCKED on PR #4510** (`approvalModeQueue`). Adds `current_mode_update` promotion (`previous` derived from the §2.3 cache — no `previousModeId` on the wire), `Session.setMode` emit, helper generalization, `scope`, retained bridge workspace publish, the **dual-emit transition** + IDE-companion + upstream-dispatch updates.
- **3b. Dual-emit removal** — tracked by a GitHub issue with a target release; the dual-emit publish site carries `TODO(dual-emit-removal)` referencing §2.1. Close the issue when the next release drops the dual-emit.
- **3c. A2 post-roundtrip reconciliation** — same §2.2 contract, reading the agent's true approval mode; adds `approvalModePublishGeneration` and `publishApprovalModeChanged` helper. Must land together with the A2 promotion (same rationale as 2c — reconciliation without the generation guard is worse than no reconciliation).

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] A2 generation guard in §7 but not in §3

§7 step 3c mentions approvalModePublishGeneration and publishApprovalModeChanged helper with the same atomic-coupling rationale as A1 ("reconciliation without the generation guard is worse than no reconciliation"). But §3 (A2 design) doesn't describe the generation guard mechanism for approval mode — it only covers the promotion, emit, helper generalization, and dual-emit transition.

Suggest adding the generation guard + approvalModeReconciliationInFlight structural guard to §3 as well, mirroring the A1 treatment in §2.2. This keeps each section self-contained and prevents implementers from shipping A2 reconciliation without the guard.

— qwen3.7-max via Qwen Code /review

@DragonnZhang DragonnZhang 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 the current head. The design is sound and internally consistent after 13 revisions — one localized stale-text contradiction noted inline (previousModeId in the §3 Risk/compat summary, which the rest of the doc says is not on the wire). No other new blocking concerns; the prior review threads appear resolved.

— Qwen Code /review


Generated by Claude Code


### Risk / compat

Additive wire (`current_mode_update` reuse + `previousModeId` + `scope`) but **not** SDK-additive for the promoted type (see §6). Hard-blocked on #4510.

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] Stale previousModeId in this §3 Risk/compat summary contradicts the rest of the live design. This line lists the A2 additive wire as current_mode_update reuse + previousModeId + scope, but §3 item 3 four lines above explicitly says "the agent does NOT send previousModeId" and "the agent's current_mode_update stays the unmodified ACP shape ({currentModeId})". §2.3 ("the agent never sends previousModeId/previousModelId"), §7 step 3 ("no previousModeId on the wire"), and the v8 changelog ("neither notification carries a previous* field") all agree — previous is derived bridge-side from entry.currentApprovalMode precisely because ACP's CurrentModeUpdate has no previousModeId field to add.

An implementer skimming this Risk/compat line for the final wire contract would conclude they must add previousModeId to the notification — re-opening the exact ACP-schema trap v6–v8 ruled out. Suggest dropping it:

Suggested change
Additive wire (`current_mode_update` reuse + `previousModeId` + `scope`) but **not** SDK-additive for the promoted type (see §6). Hard-blocked on #4510.
Additive wire (`current_mode_update` reuse + `scope`; `previous` derived bridge-side from the §2.3 cache, NOT on the wire) but **not** SDK-additive for the promoted type (see §6). Hard-blocked on #4510.

— Qwen Code /review


Generated by Claude Code

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

No issues found. LGTM.

— GPT-5 via Qwen Code /review

@DragonnZhang DragonnZhang 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 diff at 446096e. Docs-only PR. No high-confidence findings.

— reviewed via DragonnZhang /review


Generated by Claude Code

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

One high-confidence finding: a stale remnant in §3 Risk/compat directly contradicts the v8+ design decision that removed previousModeId from the wire. All other areas of the doc are internally consistent at this revision.

— claude-sonnet-4-6 via Qwen Code /review


Generated by Claude Code

@yiliang114

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@yiliang114
yiliang114 dismissed stale reviews from doudouOUC and DragonnZhang July 5, 2026 09:43

fixed

@yiliang114
yiliang114 added this pull request to the merge queue Jul 5, 2026
Merged via the queue into main with commit b13032d Jul 5, 2026
16 of 28 checks passed
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the design doc, @chiga0!

Template: the PR body uses custom headings ("What this is", "The four gaps", "Why") rather than the standard template. Acceptable here — this is a design doc PR and the document itself contains equivalent sections (problem statement with code anchors, proposed design, alternatives, test plan in §8). Not a blocker.

Problem: the four gaps (A1/A2/A4/A5) are grounded in a real cross-client sync audit (2026-05-24) and the PR #4484 post-merge review. Strong evidence these are real: A4 is already implemented and approved (#4539), and A1 core is implemented (#4546). Not theoretical hardening.

Direction: well-aligned. The daemon's multi-client coordination invariant — "every state transition produces exactly one bus broadcast" — is clearly articulated and addresses genuine sync gaps. The bridge-authoritative model (§1.1) is a sound architectural choice.

Approach: scope feels right. Four gaps, each tightly scoped with explicit alternatives and risk. Sequencing (A4 → A1+A2 → A5) is well-reasoned. Good that A2 is explicitly blocked on #4510's approvalModeQueue. The dual-emit transition plan (§2.1/§6) for A2 is pragmatic given the daemon/IDE-companion release-channel mismatch. Open question §10.3 (serialize-at-source vs reconciliation hardening) is honest about the long-term direction.

Moving on to review the design doc. 🔍

中文说明

感谢 @chiga0 的设计文档!

模板:PR body 使用了自定义标题("What this is"、"The four gaps"、"Why"),而非标准模板。可以接受——这是一个设计文档 PR,文档本身包含了等效章节(问题陈述带代码锚点、方案设计、替代方案、§8 测试计划)。不构成阻塞。

问题:四个 gap(A1/A2/A4/A5)基于真实的跨客户端同步审计(2026-05-24)和 PR #4484 的 post-merge review。有强有力的证据表明这些是真实问题:A4 已实现并获批(#4539),A1 核心已实现(#4546)。不是理论性加固。

方向:对齐。daemon 的多客户端协调不变式——"每个状态转换恰好产生一次总线广播"——表述清晰,解决了真实的同步 gap。bridge-authoritative 模型(§1.1)是合理的架构选择。

方案:范围合理。四个 gap 各自范围紧凑,附带替代方案和风险分析。排序(A4 → A1+A2 → A5)有理有据。A2 明确阻塞于 #4510approvalModeQueue 是对的。A2 的 dual-emit 过渡方案(§2.1/§6)考虑到 daemon/IDE-companion 发布通道不同步的现实,是务实的选择。开放问题 §10.3(serialize-at-source vs 持续加固 reconciliation)对长期方向的坦诚态度值得肯定。

进入设计文档审查 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

Reviewed the design document (398 lines, single markdown file). Focus was internal consistency, correctness of code anchors, and completeness of alternatives/compat/risk.

No critical blockers found. The document is internally consistent across its 13 revision cycles. Key findings:

  • §1.1 bridge-authoritative model is well-motivated. The v3 reframing (dropping the v1 "single-emitter agent sole source" in favor of bridge-owns-its-roundtrips + agent-notification-demux) was the right call — it preserves the bridge's modelChangeQueue serialization, timeout handling, and model_switch_failed semantics that would have been lost.

  • §2.1 demux + suppress logic is correct. The per-type suppress window (model roundtrip doesn't suppress approval-mode notifications, and vice versa) avoids cross-axis suppression bugs. The best-effort dedup (drop when cache already matches) is a clean optimization without correctness risk.

  • §2.2 reconciliation is the most complex section. The generation-guard (modelPublishGeneration) TOCTOU mitigation is sound — a concurrent promotion during the async getSessionContextStatus read bumps the generation, and reconciliation skips. The reconciliationInFlight structural guard prevents recursive reconciliation. The failure-path semantics (baseline = entry.currentModelId since model_switch_failed doesn't update cache) are correctly documented.

  • §2.3 state cache fields (currentModelId, currentApprovalMode, availableCommands) are a clear prerequisite for both the reconciliation reads and the A5 snapshot. Seeded on session create + updated at every publish via publishModelSwitched helper — the atomicity invariant (one helper, one synchronous turn) is well-specified.

  • §3 A2 hard-block on fix(daemon): cross-client sync follow-up cleanup (epoch-reset resync, approval-mode serialization, catch-up indicator) #4510 is correctly called out as a security concern (unbounded suppress window → dropped yolo notification while mode is actually yolo).

  • §5 A5 capture-at-emission (v3 fix) correctly addresses the stale-overwrite bug where a model_switched during replay would be clobbered by a T0 snapshot applied last.

  • §6 cross-cutting honestly notes that promotion is NOT additive everywhere (the current_mode_updateapproval_mode_changed type change) and specifies the dual-emit transition as mitigation. The consumer chain that needs updating (VS Code IDE companion + upstream dispatch) is explicitly named.

  • §8 test plan is thorough — covers demux, suppress, reconciliation (corrective, converged, generation-skip, failure-path, read-error, non-recursion), cross-axis non-suppression, dual-emit transition, and compat migration. Each bullet has specific inputs/outputs.

One minor note: §10.3's serialize-at-source recommendation is the right long-term answer, and the document is honest that the v10 reconciliation stack is an interim mitigation. Scheduling that refactor rather than hardening reconciliation indefinitely is the correct call.

Testing

N/A — this is a docs-only PR with no behavioral changes. No tmux testing applicable.

中文说明

代码审查

审查了设计文档(398 行,单个 markdown 文件)。重点关注内部一致性、代码锚点的正确性、以及替代方案/兼容性/风险的完整性。

未发现关键阻塞问题。 文档经过 13 轮修订,内部一致性良好。主要发现:

  • §1.1 bridge-authoritative 模型 动机充分。v3 的重构(放弃 v1 的"单一发射器 agent 唯一来源",改为 bridge 拥有其 roundtrip + agent 通知 demux)是正确的选择——保留了 bridge 的 modelChangeQueue 序列化、超时处理和 model_switch_failed 语义。

  • §2.1 demux + suppress 逻辑 正确。按类型的 suppress 窗口(model roundtrip 不 suppress approval-mode 通知,反之亦然)避免了跨轴 suppress bug。best-effort dedup(cache 已匹配时丢弃)是干净的优化,无正确性风险。

  • §2.2 reconciliation 是最复杂的部分。generation-guard TOCTOU 缓解方案合理——异步读取期间的并发 promotion 会提升 generation,reconciliation 会跳过。reconciliationInFlight 结构性 guard 防止递归。failure-path 语义正确记录。

  • §3 A2 硬阻塞于 fix(daemon): cross-client sync follow-up cleanup (epoch-reset resync, approval-mode serialization, catch-up indicator) #4510 被正确标记为安全问题(无界的 suppress 窗口 → yolo 通知被丢弃而实际模式已是 yolo)。

  • §8 测试计划 全面——覆盖 demux、suppress、reconciliation(corrective/converged/generation-skip/failure-path/read-error/non-recursion)、跨轴非 suppress、dual-emit 过渡和兼容迁移。每个条目都有具体的输入/输出。

一个备注:§10.3 的 serialize-at-source 建议是正确的长期答案,文档诚实地承认 v10 reconciliation 栈是临时缓解。安排该重构而非无限加固 reconciliation 是正确的选择。

测试

N/A — 纯文档 PR,无行为变更。tmux 测试不适用。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Stepping back: this is a design-doc PR that defines the approach for four daemon side-channel coordination gaps before any implementation lands. The document has been through 13 revision cycles with 20+ review threads addressed. Two of the four items are already implemented against this design (A4 in #4539, A1 in #4546) — which is strong evidence the design is actionable and the problems are real.

The document itself is thorough without being bloated. Each section follows a consistent structure (problem → design → alternatives → wire-compat → risk), the code anchors are specific, and the test plan (§8) is detailed enough to implement against. The bridge-authoritative model with demux is a cleaner architecture than the v1 single-emitter approach, and the document is honest about what's interim (reconciliation stack) vs the long-term target (serialize-at-source, §10.3).

The dual-emit transition for A2 is the one area that adds operational complexity, but it's the pragmatic choice given the daemon/IDE-companion release-channel mismatch. The mitigation is well-scoped and tracked.

This is a solid design reference that the remaining implementation PRs can build on. Ships the doc cleanly, no concerns.

中文说明

回顾整体:这是一个设计文档 PR,在实现代码落地之前定义了四个 daemon side-channel 协调 gap 的方案。文档经历了 13 轮修订,解决了 20+ 个 review 线程。四个项目中有两个已经按照此设计实现(A4 在 #4539,A1 在 #4546)——这是设计可行性和问题真实性的有力证据。

文档本身详实而不臃肿。每个章节遵循一致的结构(问题 → 设计 → 替代方案 → 线兼容性 → 风险),代码锚点具体,测试计划(§8)详细到可以直接据此实现。bridge-authoritative + demux 模型比 v1 的单一发射器方案更干净,文档对什么是临时的(reconciliation 栈)和什么是长期目标(serialize-at-source,§10.3)也很坦诚。

A2 的 dual-emit 过渡是唯一增加运维复杂性的部分,但考虑到 daemon/IDE-companion 发布通道不同步的现实,这是务实的选择。缓解方案范围明确且有跟踪。

这是一个扎实的设计参考,后续实现 PR 可以在此基础上构建。文档交付干净,无顾虑。

Qwen Code · qwen3.7-max

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

LGTM, looks ready to ship. ✅

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.

7 participants