Skip to content

feat(acp-bridge): F3 — multi-client permission coordination (#4175)#4335

Merged
doudouOUC merged 12 commits into
daemon_mode_b_mainfrom
feat/f3-permission-mediator
May 20, 2026
Merged

feat(acp-bridge): F3 — multi-client permission coordination (#4175)#4335
doudouOUC merged 12 commits into
daemon_mode_b_mainfrom
feat/f3-permission-mediator

Conversation

@doudouOUC

Copy link
Copy Markdown
Collaborator

F3 — Multi-Client Permission Coordination (#4175)

Implements F3 from #4175: the
PermissionMediator contract frozen by PR 22a (#4295)
plus 4 strategy implementations, audit ring, capability surface, SDK reducer support, and docs.

Strategies

Strategy Behavior
first-responder Pre-F3 default. Any validated voter wins; later voters get 404. Wire-shape byte-for-byte preserved.
designated Only the prompt originator decides; non-originators get 403 permission_forbidden / designated_mismatch. Anonymous prompts (no X-Qwen-Client-Id) fall back to first-responder.
consensus N-of-M voters agree (default N = floor(M/2) + 1; override via policy.consensusQuorum). First option to reach quorum wins. Intermediate votes fan out permission_partial_vote SSE events.
local-only Loopback voters only; remote callers get 403 permission_forbidden / remote_not_allowed. Decided by kernel-stamped req.socket.remoteAddress — does NOT consult X-Forwarded-For or any header.

What's new

  • MultiClientPermissionMediator (mediator owns ALL pending + resolved permission state; bridge keeps only entry.pendingPermissionIds as a fast cap-check index)
  • PermissionAuditRing + createPermissionAuditPublisher (in-memory bounded ring, default 512 entries, NOT routed onto SSE)
  • 2 new SSE wire events: permission_partial_vote (consensus only), permission_forbidden (designated/consensus/local-only)
  • 3 new typed errors: PermissionForbiddenError (403), PermissionPolicyNotImplementedError (501, forward-compat), CancelSentinelCollisionError (500)
  • New settings: policy.permissionStrategy enum, policy.consensusQuorum number
  • New capability: permission_mediation (always-on, modes: [...]); /capabilities.policy.permission exposes the active policy
  • SDK: DaemonPermissionPartialVoteEvent / DaemonPermissionForbiddenEvent types, reducer extensions (permissionVoteProgress, forbiddenVotes), barrel re-exports

Hardness invariants

  • N1: mediator.request() registers pending entries synchronously inside the Promise executor (no await before register) so a forgetSession racing with the bridge's publish → register → await sequencing can never miss a new pending.
  • N2: resolveEntry cleanup is hardened — clearTimeout → state delete → emit (try/catch) → audit (try/catch) → Promise resolve (last). Tests verify the Promise settles even when emit/audit throw.
  • N3: New events (permission_partial_vote, permission_forbidden) stamp originatorClientId = pending.originatorClientId (prompt originator). Pre-existing permission_resolved.originatorClientId === voter.clientId inconsistency is deliberately preserved for wire-shape compatibility — frozen by regression test.
  • O5: Voter cancel ({outcome:'cancelled'}) maps to optionId: '__cancelled__' sentinel. Mediator resolves cancelled regardless of policy; collision against agent-declared option labels is detected at request issue time and throws CancelSentinelCollisionError.
  • O8: Pre-F3 wire shape preserved bit-for-bit when policy is first-responder (default). httpAcpBridge.test.ts snapshot tests continue to pass.

Out of scope (follow-ups)

  • GET /workspace/permission/audit query route on the audit ring (Commit 4 stages the ring; route follow-up).
  • Pair-token + revocation API for consensus voter authentication.
  • POST /workspace/policy live-reload (today: daemon restart).
  • Decision persistence ("always allow" rules).
  • Hook layer (PreToolUse-style external arbitrators).

References

Testing

  • 35 mediator unit tests (4 strategies × happy path / forbidden / timeout / forgetSession; consensus 48-case property enumeration + M=4 N=3 split timeout)
  • 10 audit ring tests (capacity, FIFO, snapshot filters, all 5 record shapes)
  • 55 SDK reducer tests (8 new for partial vote / forbidden + ordering + forward-compat)
  • 3 bridge integration tests (policy accessor, quorum validation)
  • Pre-existing httpAcpBridge.test.ts snapshot suite stays green (first-responder bit-for-bit preserved)

Migration

Default behavior unchanged: omitting policy.permissionStrategy from settings keeps the daemon on first-responder. Operators opt in by writing one of the 4 strings under policy.permissionStrategy in workspace settings.json. Daemon restart required to pick up changes.

🤖 Generated with Qwen Code

Copilot AI review requested due to automatic review settings May 19, 2026 19:34
@github-actions

Copy link
Copy Markdown
Contributor

Code Review: F3 — Multi-Client Permission Coordination (#4335)

📋 Review Summary

This PR implements F3 from #4175: a comprehensive multi-client permission coordination system with four distinct mediation policies (first-responder, designated, consensus, local-only), an audit ring, and SDK support. The implementation is well-architected with clear separation of concerns between the MultiClientPermissionMediator and the HTTP bridge layer. The code demonstrates strong attention to backward compatibility, security boundaries, and operational visibility. However, there are several critical and high-priority issues that must be addressed before merging, particularly around error handling consistency, documentation clarity, and edge case coverage.


🔍 General Feedback

Positive aspects:

  • Excellent separation of concerns: MultiClientPermissionMediator owns all permission state, keeping the bridge layer thin
  • Strong invariant documentation (N1-N3, O5, O8) with explicit rationale for each design decision
  • Comprehensive test coverage (35 unit tests in permissionMediator.test.ts plus bridge-level integration tests)
  • Thoughtful backward compatibility: pre-F3 wire shape preserved bit-for-bit for first-responder policy
  • Good operational visibility: stderr breadcrumbs on timeout, audit ring for forensics
  • Defense-in-depth validation at multiple layers (settings schema + bridge constructor)

Areas of concern:

  • Some critical error paths lack proper cleanup
  • Inconsistent handling of undefined clientId across policy implementations
  • Missing documentation for several new error types
  • Potential race conditions in consensus quorum calculation
  • Audit ring capacity defaults may need tuning for high-volume deployments

🎯 Specific Feedback

🔴 Critical

  • File: packages/acp-bridge/src/permissionMediator.ts:430-450 - The voteConsensus method doesn't handle the edge case where votersAtIssue is empty (anonymous prompt with no registered clients). This could lead to division by zero in quorum calculation or deadlock. Add a guard:

    if (pending.votersAtIssue.size === 0) {
      // Fall back to first-responder for anonymous prompts with no voters
      return this.voteFirstResponder(pending, vote);
    }
  • File: packages/acp-bridge/src/bridgeErrors.ts:174-190 - CancelSentinelCollisionError documentation states it's thrown when agent declares '__cancelled__' as an option label, but the error message construction doesn't include the conflicting option ID in the message. This makes debugging extremely difficult. Add the conflicting option to the error message:

    super(`Agent declared '${CANCEL_VOTE_SENTINEL}' as an option label, ` +
          `colliding with the cancel sentinel. Allowed options: ` +
          `${Array.from(allowedOptionIds).join(', ')}`);
  • File: packages/cli/src/serve/httpAcpBridge.ts:6204-6220 - The permissionMediator construction passes votersForSession callback that iterates byId.values() on every call. Under high concurrency with many sessions, this becomes O(N) per vote. Cache the voter set at request issue time (already done in mediator via votersAtIssue) and remove this callback entirely—it's redundant.

🟡 High

  • File: packages/acp-bridge/src/permissionMediator.ts:370-390 - The voteDesignated method falls back to first-responder for anonymous prompts, but this relaxation is not documented in the PermissionPolicy type definition or the protocol docs. A security-conscious deployment might expect strict designated behavior. Add explicit warning in docs/design/qwen-serve-protocol.md:

    Security note: designated policy falls back to first-responder for prompts without X-Qwen-Client-Id header. For strict per-tenant isolation, ensure all clients register a clientId.

  • File: packages/cli/src/serve/permissionAudit.ts:85-95 - The PermissionAuditRing uses FIFO eviction but doesn't expose any metrics on eviction rate. Operators can't detect if the ring size (default 512) is too small for their workload. Add evictionCount getter and include in future /workspace/permission/audit route response.

  • File: packages/acp-bridge/src/permissionMediator.ts:480-510 - The voteLocalOnly check uses vote.fromLoopback which is set by the route layer based on req.socket.remoteAddress. However, the comment acknowledges this doesn't consult X-Forwarded-For. If the daemon sits behind a reverse proxy (common in production), all votes appear as non-loopback. Either:

    1. Document this limitation prominently (recommend dedicated daemon or designated policy for proxied deployments)
    2. Add optional trustProxy flag to BridgeOptions to consult X-Forwarded-For when set
  • File: packages/sdk-typescript/src/daemon/events.ts:54-65 - New event types permission_partial_vote and permission_forbidden are added to DAEMON_KNOWN_EVENT_TYPE_VALUES, but there's no type-safe discriminator for the reason field in permission_forbidden events. The SDK should export a union type for PermissionForbiddenReason = 'designated_mismatch' | 'remote_not_allowed' to prevent typos in client code.

🟢 Medium

  • File: packages/acp-bridge/src/permissionMediator.ts:105-120 - The CANCEL_VOTE_SENTINEL constant is exported but the comment says "Bridge-side precondition: callers MUST NOT forward an incoming vote.optionId === CANCEL_VOTE_SENTINEL". This is a critical security boundary that relies on caller discipline. Consider making the sentinel value dynamic per-mediator instance or using a WeakMap to prevent any possibility of wire-level spoofing.

  • File: packages/cli/src/serve/httpAcpBridge.ts:6170-6182 - The conditional audit ring allocation (if (opts.permissionAudit !== undefined)) is correct but the comment says "keeping it would burn an empty array indefinitely". This is a micro-optimization that sacrifices consistency. Always allocate the ring (it's 512 entries ≈ 100KB) and use it uniformly—simpler mental model.

  • File: packages/acp-bridge/src/permissionMediator.ts:280-295 - The PermissionDecisionReason type has five variants but only four are used in audit records (timeout resolution doesn't include a decision reason). This is fine, but the type comment should clarify that PermissionResolution { kind: 'cancelled' } maps to no decision reason when the cause is timeout/session-close.

  • File: packages/cli/src/serve/capabilities.ts:180-195 - The new permission_mediation capability advertises all four modes, but there's no runtime validation that the configured policy matches one of the advertised modes. Add a defensive check in createHttpAcpBridge:

    const VALID_POLICIES = ['first-responder', 'designated', 'consensus', 'local-only'] as const;
    if (!VALID_POLICIES.includes(opts.permissionPolicy ?? 'first-responder')) {
      throw new Error(`Invalid permissionPolicy: ${opts.permissionPolicy}`);
    }

🔵 Low

  • File: packages/acp-bridge/src/permissionMediator.ts:1 - The license header says "Copyright 2025 Qwen Team" but we're in 2026. Update to "Copyright 2025-2026 Qwen Team" or just "Copyright Qwen Team" to avoid annual updates.

  • File: docs/developers/qwen-serve-protocol.md:420-427 - The permission policy table is excellent, but the "Wire compatibility" note at the end is buried. Move it to a prominent "⚠️ Compatibility" callout box at the top of the section.

  • File: packages/acp-bridge/src/permissionMediator.ts:395-405 - The comment "Per-policy vote handlers" section would benefit from a one-line summary of each policy's behavior before diving into code. Something like:

    // Policy dispatch table:
    // - first-responder: any validated voter wins immediately
    // - designated: only originatorClientId may vote (fallback to first-responder if anonymous)
    // - consensus: N-of-M quorum required, intermediate votes emit permission_partial_vote
    // - local-only: only loopback voters accepted, remote voters get 403
    
  • File: packages/cli/src/serve/permissionAudit.ts:150-160 - The recordTimeout method doesn't capture which policy was active when the timeout fired. For forensic analysis, it would be useful to know if a consensus policy timed out (suggesting insufficient voters) vs first-responder (suggesting no engaged clients). Add policy field to timeout entries.


✅ Highlights

  • Exceptional invariant documentation: The N1-N3 and O5-O8 invariants are model examples of how to document complex async state management. Each invariant includes the "why" (historical bug or edge case) and the "how" (specific code pattern).

  • Backward compatibility discipline: The commitment to preserving pre-F3 wire shape for first-responder policy (tested via snapshot tests in httpAcpBridge.test.ts) shows mature API design thinking. The approach of additive-only changes (permission_partial_vote, permission_forbidden as new events that old SDKs gracefully ignore) is exactly right.

  • Audit-first mindset: The separation of audit ring from SSE bus is architecturally sound. Operational observability shouldn't compete with wire protocol bandwidth, and the bounded LRU with configurable capacity shows thoughtful resource management.

  • Test quality: The 35 unit tests in permissionMediator.test.ts cover all four policies, edge cases (voter cancel sentinel, forgetSession racing with request), and cleanup ordering. The N2 cleanup ordering test (verifying Promise settles even when emit/audit throw) is particularly thorough.

  • Error message quality: Most error types include actionable context (requestId, sessionId, clientId, reason). The PermissionForbiddenError constructor includes both the forbidden reason and the request context, making debugging straightforward.


Summary Recommendation

Status: Changes Required

This is a well-designed implementation of a complex feature, but the critical issues around empty voter sets, error message clarity, and redundant callbacks must be addressed before merging. The high-priority items around security documentation and proxy handling should also be resolved to prevent production incidents.

Required fixes before merge:

  1. Add guard for empty votersAtIssue in consensus policy
  2. Improve CancelSentinelCollisionError message to include conflicting options
  3. Remove redundant votersForSession callback from mediator construction
  4. Document designated policy fallback behavior in protocol docs
  5. Add trustProxy option or prominently document loopback limitation

Suggested follow-ups (post-merge):

  • Add eviction metrics to audit ring
  • Export PermissionForbiddenReason type in SDK
  • Consider dynamic sentinel values for additional security
  • Add policy field to timeout audit entries

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

Implements F3 multi-client permission coordination for qwen serve (new mediation strategies, wire events, and SDK support), while also landing Phase C worktree session persistence/UI safety nets and a redesign of managed auto-memory recall to be non-blocking.

Changes:

  • Add permission-mediation capability + active policy surfacing, new SSE events (permission_partial_vote, permission_forbidden), audit ring, and SDK reducer/type support.
  • Persist/restore worktree session state via a per-session sidecar file, add TUI/ACP/headless restore reminders, and add WorktreeExitDialog + footer/statusline worktree indicators.
  • Make auto-memory recall fire-and-forget with opportunistic injection; extend selector timeout safety net; fix env-var-only model modalities defaults.

Reviewed changes

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

Show a summary per file
File Description
packages/vscode-ide-companion/schemas/settings.schema.json Adds ui.hideBuiltinWorktreeIndicator to the VSCode companion settings schema.
packages/sdk-typescript/test/unit/daemonEvents.test.ts Adds reducer/type-guard tests for new permission coordination events.
packages/sdk-typescript/src/index.ts Re-exports new daemon permission event types.
packages/sdk-typescript/src/daemon/events.ts Adds new daemon event types/guards + reducer state for partial votes/forbidden votes.
packages/core/src/tools/exit-worktree.ts Updates exit semantics to preserve/clear worktree sidecar appropriately.
packages/core/src/tools/exit-worktree.test.ts Increases timeouts to avoid flakiness.
packages/core/src/tools/exit-worktree.session.integ.test.ts Adds integration coverage for sidecar cleanup behavior on exit.
packages/core/src/tools/enter-worktree.ts Persists worktree session sidecar on successful enter and captures baseline HEAD commit.
packages/core/src/tools/enter-worktree.session.integ.test.ts Adds integration coverage for sidecar persistence and overwrite semantics.
packages/core/src/services/worktreeSessionService.ts Introduces sidecar read/write/clear + --resume restore helper with tamper checks.
packages/core/src/services/worktreeSessionService.test.ts Unit tests for sidecar I/O and restore/tamper cleanup behavior.
packages/core/src/services/sessionService.ts Adds getWorktreeSessionPath(sessionId) helper.
packages/core/src/services/gitWorktreeService.ts Adds post-creation core.hooksPath configuration for worktrees.
packages/core/src/services/gitWorktreeService.hooks.integ.test.ts Integration tests for hooksPath setup behavior.
packages/core/src/models/modelConfigResolver.ts Fixes modelId resolution and auto-detects default modalities when unset.
packages/core/src/models/modelConfigResolver.test.ts Regression tests for modalities defaulting in env-var-only + OAuth paths.
packages/core/src/memory/relevanceSelector.ts Raises safety-net AbortSignal timeout from 1s to 30s (with caller abort still honored).
packages/core/src/memory/recall.ts Distinguishes caller abort vs selector timeout to control heuristic fallback/logging.
packages/core/src/index.ts Exports worktreeSessionService from core package surface.
packages/core/src/core/client.ts Replaces recall deadline-race with non-blocking prefetch handle + opportunistic injection.
packages/core/src/core/client.test.ts Updates/expands tests for new async prefetch lifecycle and cleanup paths.
packages/cli/src/ui/hooks/useWorktreeSession.ts Adds hook to watch/read sidecar and drive UI state.
packages/cli/src/ui/hooks/useWorktreeSession.test.tsx Tests for sidecar watch behavior (create/delete).
packages/cli/src/ui/hooks/useStatusLine.ts Extends statusline payload with worktree fields and avoids churn via slug tracking.
packages/cli/src/ui/hooks/useDialogClose.ts Adds Ctrl+C/global escape support for WorktreeExitDialog.
packages/cli/src/ui/contexts/UIStateContext.tsx Adds activeWorktree and WorktreeExitDialog visibility to UI state.
packages/cli/src/ui/contexts/UIActionsContext.tsx Renames suggestion visibility reporting hook to onTabConsumerChange and adds worktree-exit handler.
packages/cli/src/ui/components/WorktreeExitDialog.tsx New dialog for keep/remove/cancel with git dirty/probe checks.
packages/cli/src/ui/components/WorktreeExitDialog.test.tsx Basic render test with execFile stubbed.
packages/cli/src/ui/components/InputPrompt.tsx Adds onTabConsumerChange broad Tab-consumer reporting; keeps onSuggestionsVisibilityChange narrow.
packages/cli/src/ui/components/InputPrompt.test.tsx Adds regression tests for Tab-consumer reporting and narrow dropdown visibility signal.
packages/cli/src/ui/components/Footer.tsx Adds built-in worktree indicator with opt-out setting.
packages/cli/src/ui/components/DialogManager.tsx Wires WorktreeExitDialog into dialog routing.
packages/cli/src/ui/components/Composer.tsx Separates narrow dropdown visibility from broad Tab-consumer signal for AppContainer.
packages/cli/src/ui/AppContainer.tsx Adds worktree restore notice injection, exit interception via WorktreeExitDialog, and removal flow.
packages/cli/src/serve/types.ts Extends /capabilities envelope with policy.permission.
packages/cli/src/serve/server.ts Threads loopback bit into permission vote context and maps new typed permission errors to HTTP.
packages/cli/src/serve/runQwenServe.ts Loads/validates permission policy settings at boot and wires into bridge options.
packages/cli/src/serve/permissionAudit.ts Adds in-memory permission audit ring + publisher adapter.
packages/cli/src/serve/permissionAudit.test.ts Unit tests for audit ring and publisher entry shapes/eviction.
packages/cli/src/serve/httpAcpBridge.test.ts Bridge-level integration tests for active policy accessor and quorum validation.
packages/cli/src/serve/capabilities.ts Adds permission_mediation capability descriptor with supported modes.
packages/cli/src/nonInteractiveCli.ts Injects worktree restore reminder into headless --resume prompts and emits a system message event.
packages/cli/src/nonInteractiveCli.test.ts Adds tests for headless worktree restore reminder injection and stale sidecar cleanup.
packages/cli/src/config/settingsSchema.ts Adds ui.hideBuiltinWorktreeIndicator and new policy.* settings metadata.
packages/cli/src/acp-integration/session/Session.worktree.test.ts Tests one-shot worktree notice consumption in ACP Session prompt pipeline.
packages/cli/src/acp-integration/session/Session.ts Adds pendingWorktreeNotice injection/clear behavior.
packages/cli/src/acp-integration/acpAgent.worktree.test.ts Agent-level integration tests for restoreWorktreeContext → pending notice wiring.
packages/cli/src/acp-integration/acpAgent.ts Adds shared ACP worktree restore on load/resume entrypoints.
packages/acp-bridge/src/permission.ts Documents forbidden vote reasons on the contract type.
packages/acp-bridge/src/index.ts Re-exports new permission mediator module.
packages/acp-bridge/src/bridgeTypes.ts Extends vote request context with fromLoopback and exposes permissionPolicy accessor.
packages/acp-bridge/src/bridgeOptions.ts Adds permission mediation policy/quorum/audit injection options.
packages/acp-bridge/src/bridgeErrors.ts Adds typed errors for forbidden votes, unimplemented policy, and cancel sentinel collision.
docs/developers/qwen-serve-protocol.md Documents new permission mediation capability, policy semantics, and new SSE events/errors.
docs/design/worktree.md Updates Phase C/D worktree design doc to reflect implemented persistence/UI work.
docs/design/2026-05-15-async-memory-recall-design.md Adds design spec for async/non-blocking memory recall approach.

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

Comment thread packages/core/src/services/worktreeSessionService.ts Outdated
Comment thread packages/cli/src/config/settingsSchema.ts
Comment thread packages/sdk-typescript/src/daemon/events.ts
Comment thread packages/cli/src/serve/permissionAudit.ts Outdated
Comment thread packages/core/src/core/client.ts
doudouOUC added a commit that referenced this pull request May 19, 2026
Three of the five Copilot inline findings adopted; two declined as
out-of-scope (base-mismatch artifact between feat/f3-permission-mediator
based on origin/main and PR base daemon_mode_b_main).

ADOPTED
- settingsSchema.ts:1681-1727 — `policy` / `permissionStrategy` /
  `consensusQuorum` now correctly mark `requiresRestart: true` since
  F3 v1 reads them once at boot. Description text reinforces the
  daemon-restart requirement so settings UIs don't suggest hot-reload.
- events.ts isPermissionPartialVoteData — switched the three counter
  fields (`votesReceived` / `votesNeeded` / `quorum`) from naked
  `typeof === 'number'` to `isFiniteNumber` + `Number.isInteger` +
  range checks. Malformed frames carrying NaN / Infinity / fractional
  values now route through `unrecognizedKnownEventCount` instead of
  landing in reducer state. Matches the validation posture of the
  sibling `isMcpBudgetWarningData` / `isSlowClientWarningData`.
- permissionAudit.ts header — said "bounded LRU" but the
  implementation is a FIFO ring (push + shift eviction). Comment
  fixed to "bounded FIFO ring buffer" matching the class JSDoc.

DECLINED (replied with rationale on PR)
- worktreeSessionService.ts:212 Windows path normalization — real
  bug but out of F3 scope. The diff shows it because PR base
  daemon_mode_b_main was branched from main earlier and the worktree
  session persistence work landed on main afterward; a rebase will
  drop it. Suggested a separate issue for the Windows
  forward-vs-backslash containment check.
- top-level "PR description scope" comment — same base-mismatch
  artifact. The auto-memory + worktree + model-modalities changes
  are from main commits that landed AFTER daemon_mode_b_main
  branched off; they are NOT introduced by F3.

Issue: #4175 (F3) — PR #4335
@doudouOUC doudouOUC requested a review from wenshao May 19, 2026 23:46
…rebased onto F1]

Squashed F3 implementation rebased from origin/main onto
daemon_mode_b_main (post-F1 #4319). F1 lifted the bridge core to
@qwen-code/acp-bridge package; F3's edits to the pre-F1
httpAcpBridge.ts BridgeClient class + factory were ported to the
new file locations:

  - BridgeClient.requestPermission rewrite → bridgeClient.ts
  - Factory mediator construction / pendingPermissions deletion /
    cancelPendingForSession refactor / respondTo*Permission
    rewrites / pendingPermissionCount + permissionPolicy getters /
    teardown sites (closeSession, killSession, shutdown drain)
    → bridge.ts
  - Error class re-exports → cli/src/serve/httpAcpBridge.ts shim
    (added CancelSentinelCollisionError, PermissionForbiddenError,
    PermissionPolicyNotImplementedError to the F1 re-export block)

This commit folds 13 logical F3 commits + 4 review fold-ins (Copilot
inline comments + 3 final-pass agent reviews) into a single
post-rebase squash. The full review trail is in
.claude/plans/fluttering-coalescing-kettle*.md (worktree-local).

Strategies (4): first-responder (default, byte-for-byte preserved),
designated, consensus (default N=floor(M/2)+1), local-only.

New SSE events: permission_partial_vote, permission_forbidden.
Capability tag: permission_mediation (always-on with build-supported
modes list); active policy at /capabilities.policy.permission.

Settings: policy.permissionStrategy enum + policy.consensusQuorum
number, both requiresRestart: true (F3 v1 reads at boot).

3 new typed errors: PermissionForbiddenError → 403,
PermissionPolicyNotImplementedError → 501 (forward-compat for future
policy literals), CancelSentinelCollisionError → 500 (agent / daemon
contract violation).

Hardness invariants: N1 synchronous-register, N2 cleanup ordering,
N3 originatorClientId stamping, O5 cancel sentinel pre-publish
collision check, O8 pre-F3 permission_resolved wire shape preserved.

Tests: 35 mediator unit + 10 audit ring + 56 SDK reducer + 6
bridgeClient + 3 bridge integration. Pre-existing
httpAcpBridge.test.ts cross-session-vote suite passes byte-for-byte.

Issue: #4175 (F3)
@doudouOUC doudouOUC force-pushed the feat/f3-permission-mediator branch from b0242dd to b8329cb Compare May 20, 2026 00:15
Comment thread packages/sdk-typescript/src/index.ts

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

Test coverage gaps at the HTTP integration boundary noted (not posted inline): detectFromLoopback (security gate for local-only policy), new HTTP error mappings (PermissionForbiddenError→403, PermissionPolicyNotImplementedError→501), and respondToSessionPermission forbidden/recorded branches all lack integration tests. Consider adding end-to-end vote tests for consensus, designated, and local-only policies through the HTTP surface.

Comment thread packages/acp-bridge/src/bridgeOptions.ts Outdated
Comment thread packages/sdk-typescript/src/index.ts
Comment thread packages/acp-bridge/src/permissionMediator.ts
Comment thread packages/acp-bridge/src/bridge.ts Outdated
Comment thread packages/sdk-typescript/src/daemon/events.ts
doudouOUC added 3 commits May 20, 2026 10:04
- packages/sdk-typescript/src/daemon/index.ts: re-export the four F3
  permission event types (`DaemonPermissionForbiddenData/Event`,
  `DaemonPermissionPartialVoteData/Event`) so the public package barrel
  at `src/index.ts` (which forwards them via `from './daemon/index.js'`)
  resolves at build time. Without this fix `npm run build
  --workspace=packages/sdk-typescript` failed with TS2305/TS2724;
  vitest passed only because it resolves TS source via tsx and
  bypasses tsc compilation. Reported in PR #4335 review comments
  3270615836 / 3270622302 (wenshao via Qwen Code /review).

- packages/cli/src/serve/server.test.ts: append `'permission_mediation'`
  to `EXPECTED_STAGE1_FEATURES` and adjust `EXPECTED_REGISTERED_FEATURES`
  reordering so the test fixture matches the registry's actual order
  (`...workspace_mcp_restart, require_auth, auth_device_flow,
  permission_mediation`). Without this fix four `serve capability
  registry` tests asserted via `.toEqual` against a stale list.

- docs/developers/qwen-serve-protocol.md: swap `permission_mediation`
  and `auth_device_flow` in the documented capability list so the order
  mirrors `SERVE_CAPABILITY_REGISTRY` declaration order.

- packages/vscode-ide-companion/schemas/settings.schema.json: regenerate
  the IDE-companion JSON schema with the new `policy` section (was
  pending from Commit 5 of the F3 series; checked in here so the
  IDE companion sees the same `permissionStrategy` / `consensusQuorum`
  shape that the CLI accepts).

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
Wenshao review #4335 surfaced two related Critical findings:

1. **Audit publisher silently no-op in production** (3270622298). The
   `bridgeOptions.ts:305` JSDoc claimed "the bridge allocates an
   internal `PermissionAuditRing`" but the actual fallback at
   `bridge.ts:543` is `createNoOpPermissionAuditPublisher()`, and
   `runQwenServe.ts` never wired one. All 5 audit record types
   (`requested`, `voted`, `forbidden`, `resolved`, `timeout`) were
   silently discarded — the forensic audit trail the F3 plan
   committed to ("ring 留给后续 PR 加查询接口") never existed in
   any deployed daemon.

2. **Timeout breadcrumb lost** (3270622304). Pre-F3 wrote
   `"timed out after Xms"` to daemon stderr on every permission
   timeout. F3 removed that direct write and delegated to
   `audit.recordTimeout()`, but the audit publisher is the no-op
   fallback in production (see #1). Operators tailing daemon
   stderr could no longer observe permission timeouts.

Fixes:

- `runQwenServe.ts` allocates a `PermissionAuditRing` (default cap 512)
  + `createPermissionAuditPublisher` and passes the publisher via
  `BridgeOptions.permissionAudit`. The ring is held in the daemon
  host's closure for the lifetime of the daemon — a future
  `GET /workspace/permission/audit` route (out of F3 v1 scope) can
  lift it out for query without further bridge changes.

- `permissionMediator.ts` writes the stderr breadcrumb directly from
  the timer callback, before forwarding to the (potentially no-op)
  audit publisher. Wrapped in try/catch because `process.stderr.write`
  can synchronously throw on EPIPE — losing observability is
  preferable to crashing the timer queue.

- `bridgeOptions.ts` JSDoc rewritten to match reality: the bridge
  falls back to a no-op publisher; production wiring lives in
  `runQwenServe.ts`; the stderr breadcrumb is in the mediator
  (independent of the publisher).

- New unit test `writes a stderr breadcrumb when the timer fires`
  spies on `process.stderr.write` and asserts the breadcrumb format
  contains the requestId, sessionId, and the timeout duration so
  future refactors can't silently drop the line again.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
)

Two small follow-ups from wenshao review #4335:

- **`bridge.ts:672-682` — dead `_resolutionToAcpResponse` helper**
  (3270622309). Defined and immediately suppressed with `void`. The
  identical `resolutionToAcpResponse` lives at `bridgeClient.ts:41`
  and is the one actually used by `BridgeClient.requestPermission`
  — the bridge-factory copy was a stranded leftover from the lift
  out of inline closures into the mediator pattern. Removed
  declaration, `void` statement, and the now-unused
  `RequestPermissionResponse` (`@agentclientprotocol/sdk`) and
  `PermissionResolution` (`./permission.js`) imports.

- **SDK reducer `mergeOriginator` for F3 events** (3270622311). The
  mediator stamps `originatorClientId` (= prompt originator per
  N3) on the `permission_partial_vote` / `permission_forbidden`
  envelope, but the reducer cases used `next.push({ ...event.data })`
  which only copies `data` fields. SDK consumers reading
  `permissionVoteProgress[reqId]` / `forbiddenVotes[i]` could not
  determine which client's prompt was targeted by the partial-vote
  progress / forbidden vote — same gap PR #4282 fixed for
  approval-mode / tool-toggle / workspace-init / mcp-restart.

  Applied the existing `mergeOriginator` helper to both reducer
  cases. Added `originatorClientId?: string` to both Data
  interfaces with JSDoc explaining the propagation contract
  (preserve any pre-existing `data.originatorClientId`; otherwise
  stamp from the envelope; for forbidden votes the field is
  distinct from `data.clientId` which carries the rejected voter).

  Three new reducer tests:
  1. `permission_partial_vote` propagates envelope originator into
     `permissionVoteProgress`.
  2. `permission_forbidden` propagates envelope originator into
     `forbiddenVotes`, distinct from `data.clientId`.
  3. `mergeOriginator` preserves any pre-existing
     `data.originatorClientId` over the envelope value.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

@wenshao on review #4324465567 (HTTP integration test coverage gaps): acknowledged and partially addressing in this PR cycle, partially deferring.

In-PR: the four blocking findings from the inline reviews are landed (commits aac5e63, 318ffe5, 92b58ff) — daemon barrel re-exports unblock npm run build, the audit ring is now wired through runQwenServe.ts so production accumulates audit entries, the timeout stderr breadcrumb is restored, the dead _resolutionToAcpResponse helper is removed, and the SDK reducer now propagates originatorClientId through permissionVoteProgress / forbiddenVotes via the existing mergeOriginator helper.

Test coverage acknowledged but deferred to keep this PR's diff focused on the production-correctness fixes you flagged as Critical. Specifically:

  1. detectFromLoopback end-to-end via Express (vs. just unit-testing the helper in isolation) — the security gate for local-only policy.
  2. PermissionForbiddenError → 403, PermissionPolicyNotImplementedError → 501, CancelSentinelCollisionError → 500 HTTP mapping tests in httpAcpBridge.test.ts.
  3. End-to-end vote-route tests for consensus, designated, local-only strategies (mediator unit tests cover the semantics; the HTTP boundary is uncovered).
  4. respondToSessionPermission forbidden / recorded outcome branches.

These four gaps add ~300 LOC of integration tests and will land in a separate follow-up PR off daemon_mode_b_main once this PR is merged. F3 v1's mediator + 4-strategy semantic coverage is locked by the 36 unit tests in permissionMediator.test.ts; the HTTP boundary tests are test-debt but not a correctness regression — the same mediator surface is exercised end-to-end by the 174-test httpAcpBridge.test.ts suite running under first-responder (the default that all pre-F3 behavior used).

If you'd prefer the test backfill in this PR before merge, happy to add a Commit D — let me know which way you'd like to proceed.

Comment thread packages/acp-bridge/src/permissionMediator.ts
Comment thread packages/acp-bridge/src/permissionMediator.ts
Comment thread packages/sdk-typescript/src/daemon/events.ts Outdated
Comment thread packages/acp-bridge/src/permissionMediator.ts
Comment thread packages/cli/src/serve/httpAcpBridge.test.ts
…leanup (#4335)

Four findings from wenshao review #4324937255 — the Critical one
masked an actual hang scenario; the other three are observability /
correctness fixes that round out F3 v1.

**[Critical] safeEmit / safeAudit stderr breadcrumb wraps** (3271041461).
Both helpers wrote `process.stderr.write` inside their `catch` block
WITHOUT a nested `try/catch`. If stderr itself synchronously throws
(EPIPE during daemon shutdown), the exception escapes the "safe"
wrapper. In `resolveEntry`'s cleanup ladder
(`safeEmit → rememberResolved → safeAudit → pending.resolve`), an
escaping safeEmit exception aborts before `pending.resolve(resolution)`
runs — the request was already deleted from `this.pending` (no
double-resolve guard), so the agent's awaiting Promise never
settles. `requestPermission` hangs until the timeout fires. The
timer callback already wraps its breadcrumb in `try/catch` for the
same reason — applied the matching pattern to safeEmit + safeAudit.

**[Suggestion] Idempotent re-vote audit shows attempted optionId,
not the original** (3271041464). When `client_A` originally voted
for `proceed_once` and later attempts `proceed_always`, the tally
silently keeps `proceed_once` (idempotent) but the audit ring
recorded `optionId: proceed_always`. An operator reading the ring
would see a vote for proceed_always that never counted toward
quorum. Look up the originally-voted option from the tally and
substitute it into the audit record. Added regression test
asserting the audit reflects tally state.

**[Suggestion] SDK reducer leaks `permissionVoteProgress` on
mid-permission reconnect** (3271041465). When an SDK client
reconnects and misses `permission_request`, then receives
`permission_partial_vote` (stored in `permissionVoteProgress`),
then receives `permission_resolved` — the early-return path on
unmatched `requestId` did NOT clear `permissionVoteProgress`. The
orphan progress entry persisted until session end. Both
`permission_resolved` and `permission_already_resolved` reducer
cases now unconditionally clear any orphan entry on the unmatched
path. Two new reducer tests cover the recovery contract; the
misleading "the next `permission_resolved` will clear both"
comment on `permission_partial_vote` is corrected.

**[Suggestion] Document votersAtIssue snapshot timing window**
(3271041469). The snapshot fires synchronously after
`entry.events.publish`, with no event-loop yield between, so a NEW
HTTP client cannot register between publish and snapshot. But an
SSE-only subscriber (no `X-Qwen-Client-Id` registered yet) that
connected BEFORE publish is invisible to the snapshot — `consensus`
silently rejects its later vote as `forbidden`. Documented the
window in `votersForSession` JSDoc; future PRs surfacing
`eligibleVoters[]` on `permission_request.data` should source it
from the same snapshot for consistency. No code change — the
narrow window is acceptable for F3 v1, and the structural fix
(snapshot at publish time) requires bridge-level refactor.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
Comment thread packages/acp-bridge/src/bridge.ts
Comment thread packages/acp-bridge/src/permissionMediator.ts Outdated
Comment thread packages/cli/src/serve/server.ts Outdated
Comment thread packages/vscode-ide-companion/schemas/settings.schema.json Outdated
…8 loopback (#4335)

Four findings from wenshao review #4325130053. The Critical one is
a real security gap; the others are observability + correctness
hardening.

**[Critical] Cancel sentinel injection bypass** (3271185588). The
mediator's `vote()` recognizes `CANCEL_VOTE_SENTINEL` BEFORE
validating the option against `allowedOptionIds`, so a wire client
sending `{outcome:'selected', optionId:'__cancelled__'}` would
short-circuit ALL policy dispatch (designated originator check,
consensus quorum, local-only loopback gate). The mediator's JSDoc
documented the precondition ("callers MUST NOT forward an
incoming vote.optionId === CANCEL_VOTE_SENTINEL from a wire
client") but the precondition was never enforced — the bridge's
`respondToSessionPermission` mapped the wire optionId straight
through. Added an explicit `InvalidPermissionOptionError` throw
when the wire payload is `{selected, CANCEL_VOTE_SENTINEL}`. The
collision-defense at request issue time
(`CancelSentinelCollisionError`) already prevents agents from
advertising the sentinel as a legitimate option; this closes the
remaining vector.

**[Suggestion] Silent quorum cap + M=0 hang observability**
(3271185594). Two related diagnostic gaps in the consensus
policy:
- When `policy.consensusQuorum` exceeds `votersAtIssue.size`, the
  cap fires silently. Operators investigating "why did consensus
  resolve at N=2 when I configured 5?" had no breadcrumb.
- When `policy === 'consensus'` and `votersAtIssue.size === 0`,
  every vote rejects as `forbidden: designated_mismatch` because
  the empty snapshot can never match any voter clientId. The
  request hangs until `permissionTimeoutMs` with no
  diagnostic signal.

Added stderr breadcrumbs at both points: cap-applied (once per
request via a `consensusQuorumCapNoted` flag on `MediatorPending`)
and at issue time when consensus M=0. No semantic change — the
cap and the timeout-only resolution behavior are intentional per
the F3 plan; the breadcrumbs just make them debuggable.

**[Suggestion] detectFromLoopback misses 127.0.0.0/8** (3271185597).
Per RFC 1122 the entire `127.0.0.0/8` block is loopback. The
exact-match Set of three literals (`127.0.0.1`, `::1`,
`::ffff:127.0.0.1`) silently fail-CLOSED on legitimate
`127.0.0.2` / `127.0.1.1` / `::ffff:127.0.0.2` peers, causing
unexpected `remote_not_allowed` rejections under `local-only`
policy. Switched to a prefix test so the entire `/8` and its
dual-stack mirror are accepted. Direction stays fail-CLOSED for
unrecognized address shapes.

**[Suggestion] VSCode JSON schema integer/min validation**
(3271185604). `runQwenServe.ts` validates
`Number.isInteger(consensusQuorum) && >= 1`, but the generated
`settings.schema.json` declared `"type": "number"` so VSCode's
inline JSON Schema validation accepted `0` / `-1` / `1.5` and
the user only learned the value was invalid on the next daemon
restart. Added `jsonSchemaOverride: {type:'integer', minimum:1}`
to the `consensusQuorum` settings entry and regenerated the
schema. IDE editors now flag invalid values immediately.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
@doudouOUC doudouOUC self-assigned this May 20, 2026
# Conflicts:
#	packages/cli/src/serve/runQwenServe.ts
Comment thread packages/acp-bridge/src/bridge.ts

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

Critical — Pre-merge blockers

[typecheck] packages/cli/src/serve/server.test.ts:529FakeBridge is missing the required permissionPolicy property (TS2741). HttpAcpBridge now requires readonly permissionPolicy: PermissionPolicy (added at bridgeTypes.ts:407 in this PR). The fakeBridge() factory must include permissionPolicy: 'first-responder' as const in its return object.

[typecheck] packages/cli/src/serve/server.test.ts:3994WorkspaceFileSystemFactory.forRequest() mock is missing the writeTextOverwrite method. Add writeTextOverwrite: async () => { throw new Error('unreachable'); } to the factory return.

[test] 5 F3-related test failures in server.test.tsfromLoopback was added to the vote context in server.ts (lines ~1677, ~1720) but 4 test expectations (lines ~2806, ~2832, ~2925, ~2941) were not updated to include fromLoopback: true. Update the context assertions to include fromLoopback: true when Host is a loopback address.

— DeepSeek/deepseek-v4-pro via Qwen Code /review

Comment thread packages/acp-bridge/src/permissionMediator.ts
Comment thread packages/acp-bridge/src/permissionMediator.ts
Comment thread packages/acp-bridge/src/permissionMediator.ts
Comment thread packages/acp-bridge/src/permissionMediator.ts
@doudouOUC doudouOUC requested a review from wenshao May 20, 2026 06:44
Mixed batch: bridge-test backfill from wenshao's APPROVED review
plus 4 DeepSeek/v4-pro suggestions and the 3 typecheck/test
blockers DeepSeek named in CHANGES_REQUESTED #4325674833.

**Pre-merge blockers (DeepSeek #4325674833 body)**

- `server.test.ts:529` `FakeBridge` — added the F3-required
  `permissionPolicy: 'first-responder' as const`. Tests don't
  exercise mediation; the literal pins the pre-F3 default so
  existing assertions stay shape-compatible.

- `server.test.ts:3994` `WorkspaceFileSystemFactory.forRequest()`
  mock — added the missing `writeTextOverwrite` method that PR
  #4334 introduced on `WorkspaceFileSystem` after this branch
  forked.

- 4 vote-context test failures from `fromLoopback` plumbing —
  updated the four `expect(...).toEqual(...)` assertions in
  `POST /session/:id/permission/:requestId` and
  `POST /permission/:requestId` to include `fromLoopback: true`
  on the captured context. The supertest peer is `127.0.0.1`,
  so `detectFromLoopback(req)` correctly stamps the field; the
  pre-F3 expected shape was stale.

**Inline suggestions adopted**

- **3271420267** (wenshao APPROVED, security-critical) — added
  bridge-level test `rejects cancel sentinel injection via
  {selected,'__cancelled__'}` in `httpAcpBridge.test.ts`. Without
  it, a future refactor could silently remove the wire-injection
  guard that closes the policy-bypass attack surface introduced
  in Round 5 (#3271185588). Required `npm run build
  --workspace=packages/acp-bridge` to refresh `dist/` before
  vitest picked up the F3 bridge.ts changes; documented for
  future contributors editing F3 acp-bridge code.

- **3271627444** (DeepSeek) — `request()` JSDoc rewritten to
  drop "Promise contract — never rejects" without qualification.
  The `CancelSentinelCollisionError` synchronous throw is real
  and intentional (a never-settling Promise alongside a thrown
  error is worse than fail-fast), but callers must be aware of
  it. Updated the contract doc to call out the sync-throw
  exception explicitly and documented that async callers get
  the throw via their own Promise machinery.

- **3271627446** (DeepSeek) — fixed "Bounded LRU" comment on
  `MAX_RESOLVED_PERMISSION_RECORDS` to "Bounded FIFO" since
  `rememberResolved` uses `resolvedOrder.shift()` (drop oldest).
  Mirrors the parallel `PermissionAuditRing` correction in
  commit b0242dd.

- **3271627457** (DeepSeek) — added stderr breadcrumbs to all 3
  forbidden-vote sites (voteDesignated / voteConsensus /
  voteLocalOnly). Audit ring is in-memory only (no v1 query
  route), SSE events are transient — operators tailing daemon
  stderr previously had zero indication of permission rejections.
  New `writeForbiddenStderr` helper centralizes the formatting
  + try/catch defensive posture (mirrors the timeout breadcrumb
  pattern from Round 4).

- **3271627459** (DeepSeek) — added a `TODO(forward-compat)`
  comment at `voteConsensus`'s rejection site documenting the
  `designated_mismatch` reason-code overload. The same wire
  string covers two distinct semantic cases: "voter is not the
  prompt originator" (designated policy) and "voter not in
  consensus votersAtIssue snapshot" (consensus). Splitting them
  into distinct codes is deferred to a future PR once an SDK
  consumer needs to disambiguate.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-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.

[Critical] tsc --noEmit reports 2 type errors in packages/cli/src/serve/server.test.ts (lines 529 and 3994) that are outside this PR's diff hunks but block typecheck:

  1. Line 529 — FakeBridge missing required permissionPolicy property (TS2741). HttpAcpBridge gained readonly permissionPolicy at bridgeTypes.ts:407.
  2. Line 3994 — WorkspaceFileSystem mock missing required writeTextOverwrite method (TS2741).

— qwen-latest-series-invite-beta-v34 via Qwen Code /review

Comment thread packages/acp-bridge/src/bridge.ts
Comment thread packages/acp-bridge/src/permissionMediator.ts
Comment thread packages/cli/src/serve/types.ts Outdated
Comment thread packages/acp-bridge/src/permissionMediator.ts
Comment thread packages/acp-bridge/src/permissionMediator.ts
Comment thread packages/acp-bridge/src/bridgeClient.ts
Comment thread packages/cli/src/config/settingsSchema.ts
Comment thread packages/cli/src/serve/runQwenServe.ts Outdated
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Re #4325674833 (CHANGES_REQUESTED with 3 pre-merge blockers in body): all three are landed in commit c3de8f1 alongside the inline suggestions:

  1. server.test.ts:529 FakeBridge missing permissionPolicy — added permissionPolicy: 'first-responder' as const to the factory return. Pins the pre-F3 default so existing assertions stay shape-compatible.
  2. server.test.ts:3994 factory missing writeTextOverwrite — added the missing method that PR feat(serve): F1 follow-up — BridgeFileSystem wiring + #4325 channelInfo fix #4334 introduced on WorkspaceFileSystem after this branch forked.
  3. 5 (actually 4) fromLoopback test failures — updated the four POST /session/:id/permission/:requestId and POST /permission/:requestId assertions to include fromLoopback: true on the captured context. The supertest peer is 127.0.0.1 so detectFromLoopback correctly stamps the field; the pre-F3 expected shape was stale.

229/229 server.test.ts pass; 180/180 httpAcpBridge.test.ts pass; 37/37 mediator pass.

…4335)

8 findings from wenshao Round 7. The Critical one closes a session-
existence information leak; 6 Suggestions improve observability,
type safety, and test coverage; 1 documents the cancel-sentinel
escape hatch in the local-only setting description.

**[Critical] Error precedence regression in respondToSessionPermission**
(3271978329). When `peekSessionFor(requestId)` returned `undefined`
(timed out / LRU-evicted / never registered), the cross-session
guard at line 2033 didn't fire (`!== undefined` skips it), so
execution fell through to `resolveTrustedClientId` which throws
`InvalidClientIdError` (HTTP 400) when the caller's clientId
isn't registered. Pre-F3 returned `false` (HTTP 404) for unknown
requestIds regardless of clientId validity. Without the explicit
guard, a probe with a fabricated clientId could distinguish
"session exists with these registered clients" (400) from "no
such request" (404). Added an explicit `actualSessionId ===
undefined → return false` short-circuit BEFORE the clientId
validation. The defensive `unknown_request` switch case below
becomes unreachable in practice; left in place for defense-in-depth.

**[Suggestion] Cancel sentinel cross-policy escape hatch under
`local-only`** (3271978336). Documented in `voteLocalOnly` JSDoc
and the settings description that a remote voter can ABORT a
pending permission via `{outcome:'cancelled'}` even though they
cannot RESOLVE one. The F3 plan calls this out as intentional
(cross-policy cancel for consistency with first-responder /
designated / consensus); operators wanting strict-cancel-too need
a dedicated loopback-bound daemon. Doc-only — semantic change
deferred.

**[Suggestion] CapabilitiesEnvelope.policy.permission widens
silently** (3271978342). Replaced the inlined string-literal
union with `import type { PermissionPolicy } from
'@qwen-code/acp-bridge'`. Adding a 5th policy upstream would now
trigger a compile error here instead of silently accepting the
narrower set.

**[Suggestion] M=2 unanimity surprise** (3271978356). Default
quorum `floor(M/2)+1` requires unanimity for even M (M=2 →
quorum=2; both voters must agree). An operator picking
`consensus` with two clients expecting "majority of 2 = 1" gets
unanimity instead — a split vote silently hangs until
`permissionTimeoutMs`. Added stderr breadcrumb at issue time
when the default formula yields unanimity (M ≥ 2 and floor(M/2)+1
== M). Mirrors the existing M=0 / cap-applied breadcrumbs added
in Round 5. Formula stays unchanged (true majority for all M is
mutually exclusive with M=1 → quorum=1). Description in the
settings schema also calls out the M=2 case explicitly.

**[Suggestion] Cancel sentinel adversarial test gap**
(3271978359). The existing "resolves cancelled regardless of
policy" test used the originator under designated and a
votersAtIssue voter under consensus — those would be ACCEPTED by
the policies even without the sentinel bypass. Added two
adversarial tests that pin the cross-policy escape hatch:
non-originator voter under designated and not-in-snapshot voter
under consensus.

**[Suggestion] BridgeClient pre-publish collision test gap**
(3271978365). `bridgeClient.requestPermission` throws
`CancelSentinelCollisionError` BEFORE publishing the SSE
`permission_request` to prevent orphan events (the mediator-level
collision check in `mediator.request` happens too late if publish
goes first). Added test asserting the throw + asserting publish
was NOT called + asserting `pendingPermissionIds` was NOT
incremented.

**[Suggestion] Settings descriptions missing security caveats**
(3271978370). Added explicit caveats to `permissionStrategy`
description: (a) `designated` notes that client identity is
self-declared with no proof-of-possession (impersonation by
observing originatorClientId on SSE frames is possible); (b)
`local-only` notes the cancel-sentinel cross-policy escape hatch.
Schema regenerated to `vscode-ide-companion/schemas/settings.schema.json`.

**[Suggestion] Boot validation error class** (3271978374). Replaced
`err.message.includes('invalid policy.')` substring matching with
a dedicated `InvalidPolicyConfigError` class checked via
`instanceof`. A future reworded validation message would have
silently downgraded operator misconfiguration to "fall back to
defaults" under the previous fragile match.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Re #4326092155 (top-level CHANGES_REQUESTED): both findings are already addressed in commit c3de8f1 (pushed before this review submitted at 07:25:05Z). Likely a stale automated run that scanned HEAD before the c3de8f1 push propagated.

  • server.test.ts:529 FakeBridge missing permissionPolicy — fixed at line 534: permissionPolicy: 'first-responder' as const. See comment 3271989651-area replies for the chain.
  • server.test.ts:3994 missing writeTextOverwrite — fixed at line 4041: writeTextOverwrite: async () => { throw new Error('unreachable'); }.

Verification at current HEAD 520edc4dc:

$ grep -n "permissionPolicy: 'first-responder' as const\\|writeTextOverwrite: async" packages/cli/src/serve/server.test.ts
534:    permissionPolicy: 'first-responder' as const,
4041:        writeTextOverwrite: async () => {

tsc --noEmit passes; vitest run for server.test.ts (229 tests) and httpAcpBridge.test.ts (180 tests) both green. No further action needed for this review.

@doudouOUC doudouOUC requested a review from wenshao May 20, 2026 08:13
Comment thread packages/acp-bridge/src/bridge.ts Outdated
Comment thread packages/acp-bridge/src/bridge.ts
Comment thread packages/cli/src/config/settingsSchema.ts Outdated
Comment thread packages/cli/src/serve/runQwenServe.ts Outdated
Comment thread packages/cli/src/serve/runQwenServe.ts Outdated
Comment thread packages/acp-bridge/src/permissionMediator.ts
Comment thread packages/acp-bridge/src/permissionMediator.ts Outdated
Comment thread packages/acp-bridge/src/permissionMediator.ts
Comment thread packages/sdk-typescript/src/daemon/events.ts

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

Inline notes from a review of the F3 permission mediator. Solid, carefully-invariant'd PR overall — the N1/N2 hardening, safeEmit/safeAudit wrapping, and the triple-guarded cancel-sentinel defense are well done, and first-responder wire-shape preservation looks intact.

One cleanup worth doing before merge (the bridgeClient.ts dead code, flagged inline); the rest are minor. Posting as comments, not a change request.

Comment thread packages/acp-bridge/src/bridge.ts
Comment thread packages/acp-bridge/src/permissionMediator.ts Outdated
Comment thread packages/cli/src/serve/server.ts Outdated
Comment thread packages/cli/src/serve/runQwenServe.ts Outdated
Comment thread packages/acp-bridge/src/bridgeClient.ts Outdated
doudouOUC added 2 commits May 20, 2026 17:09
…4335)

6 follow-up findings from wenshao Round 8 review #4326742064 (state:
COMMENTED — not blocking but addresses leftover risk surfaces).

**[Suggestion] Legacy `respondToPermission` info leak** (3272493777).
Round 7 closed the cross-session client-registration oracle on the
session-scoped vote route, but the legacy workspace-level route
(`POST /permission/<requestId>`) still called
`resolveAnyTrustedClientId` on unknown-requestId paths, throwing
`InvalidClientIdError` (400) for unregistered clientIds and
returning false (404) for registered ones — the same oracle. The
PR #4231 reasoning ("preserve security boundary") was inverted:
the 400-vs-404 distinction WAS the leak. Removed the call,
deleted the now-unused `resolveAnyTrustedClientId` helper, and
updated the previously-leak-asserting test (`rejects unknown
permission votes with unregistered client ids`) to assert the
new uniform `false` behavior across all 3 input shapes
(unregistered / registered / no-clientId).

**[Suggestion] Error-precedence regression test gap +
observability inconsistency** (3272493792). Two parts:
- Added regression test `returns false (not InvalidClientIdError)
  when session exists but requestId is unknown and clientId is
  unregistered` to lock the Round-7 fix against future refactors.
- Promoted the error-precedence guard's stderr line from
  debug-gated `writeServeDebugLine` to unconditional
  `writeStderrLine`, matching the `writeForbiddenStderr` posture
  in the mediator. Operators tailing stderr at 3 AM no longer
  need `QWEN_SERVE_DEBUG=1` to see unexpected 404s on the
  permission endpoint.

**[Suggestion] Settings description "UNANIMITY for even M" was
factually wrong** (3272493795). `floor(M/2)+1` equals M only when
M=2; for M=4 it gives 3 (supermajority), M=6 gives 4 (~67%).
The mediator's own unanimity warning correctly fires only when
M=2. Settings description now reads "UNANIMITY for M=2 (quorum=2,
both must agree) and supermajority for larger even M (M=4 →
quorum=3; M=6 → quorum=4)". VSCode JSON schema regenerated.

**[Suggestion] runQwenServe.ts inline policy unions** (3272493805).
Same drift-protection rationale as the types.ts fix in Round 7.
Imported `PermissionPolicy` from `@qwen-code/acp-bridge`,
replaced 3 inline unions: the `let` declaration, the `as` cast,
and the `VALID_PERMISSION_POLICIES` Set construction. Used a
typed-array + Set<string> pattern (drift caught at array
construction; runtime Set keeps `.has(string)` ergonomics).

**[Suggestion] InvalidPolicyConfigError discrimination needs
positive tests** (3272493818). Extracted the inline
`policyConfig`-validation logic into an exported
`validatePolicyConfig(policyConfig, onWarning?)` helper and
exported `InvalidPolicyConfigError` itself. Added 7 unit tests
covering: empty config, all 4 valid literals, invalid literal
throws (with class identity check + message regex), 4
non-positive-integer quorum cases throw, valid combination
returns, mismatch (consensusQuorum + non-consensus strategy)
emits warning without throwing, no-warning happy path, and
error messages name the failed field. The boot path in
`runQwenServe` now delegates to the helper (one call site,
DRY).

**[Suggestion] Unanimity breadcrumb spammed per-request**
(3272493829). The Round-7 unanimity stderr line fires inside the
synchronous Promise executor of every `request()` call, which
for a 2-client consensus session is EVERY permission request (M=2
unanimity is the normal operating mode, not a rare edge). Added
`unanimityBreadcrumbEmitted` boolean to the mediator class
(per-mediator dedup, parallel to `consensusQuorumCapNoted` on
`MediatorPending`). One emit per daemon lifetime — visible at
boot, silent thereafter. Comment also corrects the "for even M"
generalization to "for M=2" specifically, matching the actual
condition (`floor(M/2)+1 === M` only for M=1 and M=2).

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
…es (#4335)

8 follow-up findings from wenshao Round 9 (4 separate review
records: 4326832742 / 4326833568 / 4326844430 / 4326851074, the
last one a non-blocking comment review). 1 Critical + 7 Suggestions.

**[Critical] Terminal events leaked forbiddenVotes history**
(3272576003). `session_died` / `session_closed` / `client_evicted`
/ `stream_error` reducer cases cleared `pendingPermissions` and
`permissionVoteProgress` but not `forbiddenVotes` /
`forbiddenVoteCount`. Adapters reading view state for a dead
session would render stale rejection data. All 4 cases now
zero out the rejection ring + counter. Parameterized regression
test asserts the cleanup contract.

**[Suggestion] safeAudit JSDoc was orphaned over
writeForbiddenStderr** (3272567323). Two consecutive JSDoc
blocks were stacked back-to-back but the method definitions
followed in the opposite order, so IDE hover and API doc
generation showed `safeAudit`'s docs as `writeForbiddenStderr`'s.
Reordered method definitions so each JSDoc precedes its actual
method.

**[Suggestion] writeForbiddenStderr had no test coverage**
(3272568031). Added a 3-path test (designated / consensus /
local-only) that spies on `process.stderr.write` and asserts each
breadcrumb contains the expected reason fragment plus the
requestId + sessionId for grep-ability. Pins the format so a
future refactor can't silently drop the line.

**[Suggestion] resolveEntry numbered list contradicted code**
(3272581553). The N2-invariant cleanup ladder docstring bundled
"delete from pending + write to resolved" into step 2 ahead of
the SSE emit, but the actual code defers `rememberResolved`
until AFTER `safeEmit` (the I5 inline comment on line 1103
correctly explains this). Split step 2 into two halves around
the emit so the spec faithfully describes the ordering invariant.

**[Suggestion] Dead exports in bridgeClient.ts** (3272581548).
`MAX_RESOLVED_PERMISSION_RECORDS`, `PendingPermission`, and
`PermissionResolutionRecord` were defined and exported but no
longer referenced — the mediator owns the same state under
different names (`permissionMediator.ts:77` / `:319`). The
JSDoc still pointed at deleted closures (`registerPending`,
`resolvedPermissions` map). Removed all three definitions and
the matching re-exports in `cli/src/serve/httpAcpBridge.ts`.

**[Suggestion] detectFromLoopback prefix-match had no direct test**
(3272581557). Supertest in the broader server.test.ts suite
always connects from `127.0.0.1`, so the Round-5 prefix-match
fix for `127.x`-beyond-`.0.0.1`, `::1`, `::ffff:127.*`, and the
fail-closed branches had no coverage. Exported the helper from
`server.ts` (loosened parameter type to a minimal shape so tests
don't need to spin up Express) and added an `it.each` table
covering the variants the fix targets, plus an explicit "does
NOT consult X-Forwarded-For" assertion as a security pin.

**[Suggestion] Validate-policies set is a 4th hardcoded copy**
(3272581563). The policy literals already exist in 3 places —
`PermissionPolicy` type, `SERVE_CAPABILITY_REGISTRY.permission_
mediation.modes`, and `settingsSchema.ts` enum options.
`validatePolicyConfig` now derives its valid-set from
`SERVE_CAPABILITY_REGISTRY.permission_mediation.modes` (single
runtime source of truth). Adding a 5th policy upstream lands in
one place; a future drift between the registry and the type
union would still surface at the `as PermissionPolicy` cast.

**[Suggestion] BridgeClient over-coupled to MultiClientPermissionMediator**
(3272581569). `BridgeClient` only ever calls `mediator.request()`
but its field was typed as the concrete class, forcing every
test stub to fake all 6 mediator members. Narrowed the field type
to `Pick<PermissionMediator, 'request'>` (the frozen interface
from `permission.ts`); the bridge factory still passes the full
`MultiClientPermissionMediator` instance via structural typing.
Test stubs simplified from 6 placeholder members to 1.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
Comment thread packages/acp-bridge/src/bridge.ts
Comment thread packages/cli/src/serve/runQwenServe.ts Outdated
Comment thread packages/cli/src/serve/runQwenServe.ts
Comment thread packages/cli/src/serve/httpAcpBridge.test.ts

@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 in the latest Round 9 changes. Build, deterministic checks, and targeted tests passed. LGTM! ✅ — gpt-5.5 via Qwen Code /review

wenshao APPROVED the PR (review 4327485978: "No issues found in
the latest Round 9 changes... LGTM ✅") with 3 minor follow-up
suggestions in a separate COMMENTED review (4327443147). All
adopted; the 4th suggestion (3273077262) was already addressed
in Round 9.

**[Suggestion] Symmetric stderr breadcrumb on legacy
respondToPermission** (3273077256). The session-scoped sibling
already writes an unconditional `writeStderrLine` on its
`actualSessionId === undefined` rejection path (Round 8 /
3272493792); the legacy `POST /permission/<id>` route returned
`false` silently after the Round-8 oracle removal, leaving an
observability gap. Added matching `writeStderrLine`. Operators
tailing stderr at 3 AM now see legacy-route 404s without needing
QWEN_SERVE_DEBUG=1.

**[Suggestion] consensusQuorum contract mismatch** (3273077270).
The warning text told the operator "the override will be
ignored" but the function still propagated `permissionConsensusQuorum`
to BridgeOptions. The downstream mediator only reads it under
the consensus policy, so behavior was correct — but the public
contract contradicted itself. Adopt option (a): drop the value
to `undefined` when the strategy is not 'consensus' so the
returned struct matches what the warning promises. Updated the
existing `validatePolicyConfig` test to assert the new contract.

**[Suggestion] Stderr-breadcrumb assertion missing from
error-precedence regression test** (3273077272). The Round-8
test pinned the return-value behavior (`false`) but not the
unconditional-stderr promotion that was the primary behavioral
change of that hunk. Added `vi.spyOn(process.stderr, 'write')`
+ assertions for both "rejected permission vote" and the literal
requestId in the test. A future refactor that drops or downgrades
the log line is now caught.

**[Suggestion] _validPolicies underscore-prefix misleading**
(3273077262 — already addressed). Round 9's commit 6793b89
replaced the literal `_validPolicies` array with a single Set
derived from `SERVE_CAPABILITY_REGISTRY.permission_mediation.modes`
(per separate suggestion 3272581563). The underscore-prefixed
identifier is gone in current HEAD; replied via PR comment
pointing wenshao at the existing fix.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-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.

Approve based on local real-world verification on PR head c3de8f10. One outstanding ask below (CRIT #7) — please reply inline before merge.

Local test results

Item Result
npm ci exit 0
packages/acp-bridge/ (incl. new permissionMediator.test.ts + PermissionAuditRing coverage) 6 files / 99/99 pass
packages/cli/src/serve/ 19 files / 781/781 pass (one flaky-looking 1/781 on first run, stable on retry)
packages/sdk-typescript/ 13 files / 439/439 pass
npm run typecheck exit 0 (the 3 prior pre-merge blockers in server.test.ts:529 / 3994 + fromLoopback context all landed in c3de8f102)
npm run lint:ci (--max-warnings 0) exit 0
npm run build --workspace=packages/sdk-typescript exit 0 (CRIT #3270615836 / #3270622302 — daemon barrel re-export fix in aac5e638c verified)
npm run bundle exit 0
Boot smoke qwen serve --port 47336 --require-auth OK; /capabilities exposes permission_mediation feature + policy.permission: 'first-responder'

7 [Critical] threads — disposition

# thread Status
1 sdk-typescript/src/index.ts:71 (daemon barrel) ✅ Fixed aac5e638c
2 bridgeOptions.ts:305 (audit ring no-op in production) ✅ Fixed 318ffe5d5 (runQwenServe.ts allocates ring + publisher)
3 sdk-typescript/src/index.ts:71 (duplicate of #1) ✅ Same aac5e638c
4 permissionMediator.ts:481 (timeout stderr breadcrumb regression) ✅ Fixed 318ffe5d5 (breadcrumb + try/catch + spy test)
5 permissionMediator.ts:975 (safeEmit/safeAudit EPIPE → unsettled Promise) ✅ Fixed f7c8010e8 (nested try/catch around stderr write)
6 bridge.ts:2071 (wire cancel sentinel injection bypasses policy) ✅ Fixed d94a0420c (wire guard throws InvalidPermissionOptionError before mediator.vote; bridge-level test added)
7 bridge.ts:2045 (error precedence: 400 vs 404 leaks clientId↔session) ⚠️ Unresolved — no inline reply

On CRIT #7 — please reply inline

The current head adds a comment ("requestId is unknown OR matches THIS session — only now validate clientId") that suggests this validation order is intentional, but there's no explicit reply on the inline review thread (#3271978329). The trade-off is real:

  • Pre-F3: 404 for unknown requestId regardless of caller identity.
  • F3 current: resolveTrustedClientId throws before the unknown-request branch, so clientId validation happens first. Result: 400 for unregistered clientId even when requestId is fake → probe can distinguish "session X has clientId Y registered" from "no such request".

This is defensible as auth-first validation, but it inverts pre-F3 wire-error precedence and adds a small session-internal probe channel. Please either:

  • (a) acknowledge the trade-off explicitly inline + add a note to docs/developers/qwen-serve-protocol.md documenting that voters with unknown requestId receive 400 (not 404) when their clientId isn't registered for the session, so SDK consumers stop expecting pre-F3 404-uniform behavior; OR
  • (b) restore 404-first by checking actualSessionId === undefined ahead of resolveTrustedClientId (the suggested patch in the thread).

Approving on the strength of the test results + the 6/7 critical fixes; not blocking on #7 because the security delta is small and the author's posture is defensible — but please leave a paper trail before merge.

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

@wenshao Thanks for the re-approval and the thorough verification matrix! 🙏

On CRIT #7 (#3271978329 — error precedence): this was actually adopted as option (b) — restore 404-first between the head you tested (c3de8f10, Round 6) and current HEAD 9ac7b022c. The audit trail:

Commit Round What landed
520edc4dc Round 7 respondToSessionPermission short-circuits to return false when actualSessionId === undefined, BEFORE resolveTrustedClientId. Closes the session-scoped oracle. Inline thread #3271978329 resolved.
605894f39 Round 8 Legacy respondToPermission got the symmetric fix — removed resolveAnyTrustedClientId helper that was the cross-session-oracle source on the legacy route (#3272493777). Inverted the previously-leaky test rejects unknown permission votes with unregistered client ids to assert the new uniform-false posture.
9ac7b022c Round 10 Added matching writeStderrLine on the legacy route's unknown-session path so stderr breadcrumbs are symmetric across both routes (#3273077256).

Both vote routes now uniformly return false (→ 404) for unknown requestId regardless of clientId registration status. tsc + 613 unit tests + manual smoke at HEAD 9ac7b022c all green. The pre-F3 404-uniform behavior is preserved; no protocol-doc update needed because the wire shape didn't change. Thread is already resolved — let me know if you'd like me to re-open it for a fresh paper-trail reply or if a doc note in qwen-serve-protocol.md would still be useful as a forward-compat reference for future SDK readers.

@doudouOUC doudouOUC merged commit 8eeb510 into daemon_mode_b_main May 20, 2026
10 checks passed
doudouOUC added a commit that referenced this pull request May 20, 2026
F3 (#4335) merged to daemon_mode_b_main while F2 PR #4336 was still
in review. Single conflict in `packages/cli/src/serve/server.test.ts`
on `EXPECTED_REGISTERED_FEATURES`:

- F3 added `permission_mediation` to `EXPECTED_STAGE1_FEATURES` and
  expected it as the last conditional in the registered list (after
  `auth_device_flow`).
- F2 added `mcp_workspace_pool` + `mcp_pool_restart` between the
  stage1 baseline and `require_auth`.

Resolved by combining both filters: drop both `auth_device_flow` AND
`permission_mediation` from the stage1 spread, then list the four
conditional tags in their `SERVE_CAPABILITY_REGISTRY` declaration
order (`mcp_workspace_pool`, `mcp_pool_restart`, `require_auth`,
`auth_device_flow`, `permission_mediation`). Auto-merge handled all
other 9 cross-edited files cleanly (bridge.ts, bridgeTypes.ts,
capabilities.ts, runQwenServe.ts, server.ts, types.ts, events.ts,
daemonEvents.test.ts).

Tests post-merge: 268 F2 + SDK tests pass; 103 acp-bridge tests
pass. Typecheck clean across both packages.
@wenshao

wenshao commented May 20, 2026

Copy link
Copy Markdown
Collaborator

PR #4335 本地构建与测试验证报告

维护者 merge 参考 · 由本地 tmux 真实运行得出

概要

阶段 结果 耗时
npm ci 1m12s
npm run build 31.5s
npm run typecheck(5 workspaces) ✅ 0 error ~30s
npm run lint(根 scope) ✅ 0 error / 0 warning <5s
npm test --workspace=packages/acp-bridge 103 / 103 4.07s
npm test --workspace=packages/sdk-typescript 443 / 443 24.10s
npm test --workspace=packages/cli 6927 / 6936(9 skipped) 156.96s
npm test --workspace=packages/core ⚠️ 8567 / 8573(3 failed,已确认全部为 base 上预存在的失败,与 F3 无关) 78.24s

PR 相关代码共 ~816 个测试 覆盖(acp-bridge 47 + sdk-typescript 65 + cli 513,下文 §3 详列),全部通过


1. 验证环境

  • PR head: feat/f3-permission-mediator @ 9ac7b022c (Round 10 - wenshao APPROVED + 3 final polish)
  • PR base: daemon_mode_b_main @ 981bc7c7e (F1)
  • OS: macOS Darwin 25.4.0
  • Node: v22.17.0
  • npm: 11.8.0
  • 隔离工作区: git worktree add 到独立目录 qwen-code/.qwen/tmp/review-pr-4335
  • tmux 会话: pr4335(4 windows: install / build / test / lint),各 pane 独立 tee 落盘日志,便于后期对照

2. 构建与静态检查

2.1 npm ci

added 1453 packages in 1m

prepare hook 自动跑了一次 bundle,输出全部 dist 资源 OK;只在 vscode-ide-companion/src/utils/editorGroupUtils.ts:32 有 1 条 ESLint warning(Expected { after 'if' condition)。该文件最近一次修改是 PR #4130df32345d0),与本 PR 完全无关。

2.2 npm run build ✅ 31.5s

所有 workspace 编译通过,包括 PR 第 2 个 commit (aac5e638c) 修复的 sdk-typescript 类型 re-export 链路(之前 vitest 通过但 tsc 失败的两个 commit 3270615836 / 3270622302 已闭合)。

2.3 npm run typecheck

5 个 workspace 全部 tsc --noEmit 干净通过:

@qwen-code/acp-bridge      → tsc --noEmit ✓
@qwen-code/qwen-code       → tsc --noEmit ✓
@qwen-code/qwen-code-core  → tsc --noEmit ✓
@qwen-code/sdk             → tsc --noEmit ✓
@qwen-code/webui           → tsc --noEmit ✓

2.4 npm run lint ✅ exit=0

根目录 eslint . --ext .ts,.tsx && eslint integration-tests 干净无输出。


3. 单元测试(按 package)

3.1 packages/acp-bridge ✅ 103 / 103,4.07s

文件 测试数 状态
src/eventBus.test.ts 20
src/permissionMediator.test.ts 40 F3 核心
src/bridgeClient.test.ts 7 ✅ 含 cancel-sentinel 注入防御
src/spawnChannel.test.ts 12
src/status.test.ts 16
src/inMemoryChannel.test.ts 8

PR body 自述 "35 mediator unit tests",实际跑出 40 — 是 Round 4/5/7/8/9/10 review 期间累计补充的 5 个回归测试(idempotent re-vote audit、orphan voteProgress cleanup、unanimity 警示、forbidden stderr 三路径、terminal-event forbidden cleanup)。

stderr 中的 "permissionMediator: audit publisher threw" / "timed out after 5000ms" 是有意 throw 的测试用例(验证 safeAudit/safeEmit 的 try/catch 哑铃和 timer 容错),不是异常。

3.2 packages/sdk-typescript ✅ 443 / 443,24.10s

F3 相关文件 测试数 状态
test/unit/daemonEvents.test.ts 65 ✅ partial-vote / forbidden / mergeOriginator / orphan cleanup

其余 12 个文件(ProcessTransport、DaemonClient、Query 等共 378 个)全部通过。

3.3 packages/cli ✅ 6927 / 6936,9 skipped,156.96s

F3 直接覆盖的 6 个文件:

文件 测试数 状态
src/serve/httpAcpBridge.test.ts 181 ✅ 含 cancel-sentinel 注入防御 + 跨会话 vote 防泄漏
src/serve/server.test.ts 245 ✅ capability registry permission_mediationEXPECTED_STAGE1_FEATURES 顺序、detectFromLoopback 防 XFF
src/serve/permissionAudit.test.ts 10 ✅ FIFO / capacity / snapshot filters / 5 record shapes
src/serve/runQwenServe.test.ts 19 validatePolicyConfig + InvalidPolicyConfigError
src/config/settingsSchema.test.ts 18
src/config/jsonSchemaArg.test.ts 40

剩余 383 个 CLI 测试文件全绿,包括 serve/workspaceFileSystem.test.ts (75)serve/workspaceAgents.test.ts (38)serve/bridgeFileSystemAdapter.test.ts (18) 等所有 daemon 模式相邻文件。

3.4 packages/core ⚠️ 3 个失败 — 均为预存在,与 F3 无关

FAIL src/skills/skill-manager.test.ts > SkillManager > bundled skills > should load bundled skills in listSkills
FAIL src/skills/skill-manager.test.ts > SkillManager > bundled skills > should fall back to bundled level in loadSkill
FAIL src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts > treats unset baseURL as Anthropic-native

根因分析(已交叉验证):

  1. PR 4335 完全未触碰 packages/core/

    $ git diff --name-only origin/daemon_mode_b_main...HEAD | grep -E "^packages/core/" 
    (空 — 无任何 core 文件被改动)
  2. 在 PR base daemon_mode_b_main HEAD (981bc7c7e) 独立运行同样 2 个测试文件

    Test Files  2 failed (2)
         Tests  3 failed | 129 passed (132)
    

    同样的 3 个失败

  3. origin/main HEAD (16f0fde19) 独立运行

    Test Files  1 failed | 1 passed (2)
         Tests  1 failed | 131 passed (132)
    
    • skill-manager.test.ts 2 个失败在 main 上 不复现(即 main 上已修复)—— 应该是 main 上已合入但 daemon_mode_b_main 尚未 fast-forward 的 feat(core): extend cross-auth fast models to agents #4153 (5fe12d4cc feat(core): extend cross-auth fast models to agents) 间接修复(该 PR 改了 packages/core/src/skills/types.ts)。
    • anthropicContentGenerator 那 1 个失败在 main 上 仍复现,是更上游的预存在 flaky/regression,与本 PR 完全无关。

结论packages/core 的 3 个失败不是本 PR 引入;将本 PR merge 进 daemon_mode_b_main 不会让任何新测试变红。下次把 daemon_mode_b_main rebase/merge 到 main 之后,2 个 skill-manager 失败会自动消失;1 个 anthropic 失败需要另开 PR 处理(已超出 F3 范畴)。


4. F3 关键不变量(hardness invariants)测试覆盖确认

PR body 声明的 5 个硬性不变量都在测试代码中找到对应断言:

不变量 含义 测试位置
N1 同步 register mediator.request() 在 Promise executor 内同步注册 pending,避免 forgetSession 竞态 permissionMediator.test.ts 中 mediator 单元测试
N2 cleanup ordering resolveEntry: clearTimeout → state delete → emit (try/catch) → audit (try/catch) → resolve permissionMediator.test.ts 含 "even when emit/audit throw" 用例(stderr 日志可见 audit publisher threw × 4)
N3 originatorClientId 戳印 partial_vote / forbidden 事件戳 pending.originatorClientId;resolved 维持旧 wire 兼容 daemonEvents.test.ts mergeOriginator 3 用例
O5 cancel sentinel 防注入 取消映射到 __cancelled__,禁止 wire client 直接传该字面量绕过策略 httpAcpBridge.test.ts "rejects cancel sentinel injection via {selected,'cancelled'}" + bridgeClient.test.ts "throws CancelSentinelCollisionError before publish"
O8 字节级 wire 兼容 first-responder 策略下 permission_resolved 形状 bit-for-bit 不变 httpAcpBridge.test.ts cross-session-vote 快照测试组

5. 工作流副作用 / 风险扫描

  • 默认行为:未在 settings.jsonpolicy.permissionStrategy 时,默认 first-responder,与 pre-F3 行为字节级一致。升级零风险
  • 守恒能力:3 个新 typed error(403/500/501)、2 个新 SSE 事件(permission_partial_vote / permission_forbidden)、1 个新 capability(permission_mediation)—— 都是 加法,旧客户端会无视而非崩。
  • 观测性runQwenServe.ts 在 daemon 进程内持有 PermissionAuditRing(默认 512 条),同时 permissionMediator.ts 在 stderr 直接写 timeout/forbidden 面包屑,不依赖 audit publisher 是否实装(Round 3 修复)。
  • 预升级清单:daemon 需重启才能让新的 policy.permissionStrategy / consensusQuorum settings 生效(PR 已通过 requiresRestart: true 标注)。
  • 未实现GET /workspace/permission/audit 查询接口(已在 PR body "Out of scope" 列明),不影响 v1 行为。

6. 推荐结论

建议 merge

  • 构建、typecheck、lint、所有 PR 触碰范围的单测 全绿
  • ~816 条 F3 相关测试覆盖了 4 种策略、3 个新错误、5 个不变量、所有 review round 补的回归测试。
  • 仅有的 3 条红测在 packages/core在 PR base 上独立验证为预存在,且 PR 一行 core 代码都没改。
  • 10 轮 review fold-in 历史完整可追,N1/N2/N3/O5/O8 都有对应锁定测试。

合入后建议把 daemon_mode_b_main rebase/merge 进 main,预计 2 个 skill-manager 失败会自动消失;剩余 1 个 anthropic User-Agent 失败需另起 PR。


附录:复现命令

# 1. 准备隔离 worktree
cd /Users/wenshao/Work/git/qwen-code
git fetch origin pull/4335/head:pr-4335-test
git worktree add .qwen/tmp/review-pr-4335 pr-4335-test
cd .qwen/tmp/review-pr-4335

# 2. tmux 4 窗口并行
tmux new-session -d -s pr4335 -c "$PWD"
tmux send-keys -t pr4335:0 'npm ci 2>&1 | tee /tmp/01-install.log' Enter
# (build/typecheck/lint/test 各自独立 window,见 tmux ls)

# 3. PR-相关测试
npm test --workspace=packages/acp-bridge
npm test --workspace=packages/sdk-typescript
npm test --workspace=packages/cli
npm test --workspace=packages/core   # 仅 skill-manager × 2 + anthropic × 1 fail(预存在)

# 4. PR base 失败复现(证明非回归)
git worktree add .qwen/tmp/review-pr-4335-base origin/daemon_mode_b_main
cd .qwen/tmp/review-pr-4335-base/packages/core
npx vitest run src/skills/skill-manager.test.ts src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts
# → 同样 3 failed | 129 passed

日志文件位于 /tmp/pr-4335-test-logs/,tmux 会话 pr4335 保留供二次校验。

doudouOUC added a commit that referenced this pull request May 20, 2026
…stion)

Adopts all 7 review threads from the first wenshao + Copilot review
round on PR #4360. All technical fixes (no judgment calls).

**[Critical] BridgeTimeoutError constructor blocks tsc** (wenshao
PRRT_kwDOPB-92c6DfcRI)

`server.test.ts:4670` called `new BridgeTimeoutError('initialize
timed out')` but the constructor signature is `(label: string,
timeoutMs: number)` — TS2554 blocked `tsc --noEmit` and `npm run
build`. Fixed to `new BridgeTimeoutError('initialize', 5000)` per
suggested fix; resulting message `"HttpAcpBridge initialize timed
out after 5000ms"` still satisfies the existing
`.toContain('timed out')` assertion.

**[Suggestion] Copilot JSDoc package name** (Copilot
PRRT_kwDOPB-92c6De-Sm, ToolCallEmitter.ts:210)

JSDoc referenced `@qwen-code/core/mcp-tool` but the actual package
is `@qwen-code/qwen-code-core` with the file at
`packages/core/src/tools/mcp-tool.ts`. Updated the reference.

**[Suggestion] Copilot errorKind type widening** (Copilot
PRRT_kwDOPB-92c6De-Ro, events.ts:244)

`DaemonStreamErrorData.errorKind` was typed as `string` and the
JSDoc said "7-value" closed enum — but `DAEMON_ERROR_KINDS` actually
has 8 values, and `SERVE_ERROR_KINDS` (daemon-side) has 9 (adds
`stat_failed`). Typed as `DaemonErrorKind | (string & {})` for
forward-compat: SDK consumers get IDE autocomplete on the known 8
kinds while still accepting future daemon-side additions (like
`stat_failed`) without a type error. Updated JSDoc to accurately
list 8 current values + call out the forward-compat widening.

Side observation (NOT in scope of this PR): `DAEMON_ERROR_KINDS`
(SDK) lacks `stat_failed` that exists in `SERVE_ERROR_KINDS`
(daemon). That's a separate drift fix.

**[Suggestion] TERMINAL wording misleading** (wenshao
PRRT_kwDOPB-92c6Dj-JL, eventBus.ts:369)

Comment called `state_resync_required` a "TERMINAL synthetic frame"
but it's emitted FIRST (before replay) and the stream stays OPEN.
Genuine terminals like `client_evicted` close the stream after the
frame. Rewrote the comment per suggestion: "id-less synthetic
frame... Unlike `client_evicted`, the stream stays OPEN" — so an
oncall reading the source at 3am gets the right mental model.

**[Suggestion] `_meta` merge dead code + stale reference** (wenshao
PRRT_kwDOPB-92c6Dj-JF, server.ts:2569)

The `existingMeta` merge reads `event._meta` at BridgeEvent top
level, but ToolCallEmitter's `_meta` lives nested inside
`event.data._meta` (publish path goes through `events.publish({type:
'session_update', data: params})`). In production `existingMeta` is
always undefined — the merge is a forward-compat escape hatch, not
an active merge. Also the comment referenced
`extractServerTimestamp` (sdk-typescript) which grep confirms
doesn't exist yet (it's planned in chiga0 PR #4353).

Rewrote the comment block to (1) acknowledge no current producer
sets `_meta` at the top level — it's a forward-compat hook for
future envelope-level metadata; (2) drop the stale
`extractServerTimestamp` reference and instead note that chiga0
PR #4353 plans the 3-location probe. Code shape unchanged
(forward-compat spread stays).

**[Suggestion] session_closed + client_evicted passthrough tests**
(wenshao PRRT_kwDOPB-92c6Dj-JW, daemonEvents.test.ts:2284)

`RESYNC_PASSTHROUGH_TYPES` has 5 members but only `session_died`
and `stream_error` had passthrough tests. Added two missing tests:
`session_closed` and `client_evicted` while awaitingResync.
Critical because if a future refactor accidentally drops either
from the set, a consumer in resync limbo would silently swallow
the terminal signal and the UI would hang on "loading resync
state…".

**[Suggestion] readTextFile non-FsError passthrough test** (wenshao
PRRT_kwDOPB-92c6Dj-JX, bridgeClient.test.ts:251)

The non-FsError pass-through test only covered `writeTextFile`.
Added a symmetric `readTextFile` test — the two `try/catch` blocks
in `bridgeClient.ts` are independent, so test parity guards against
divergent refactors (e.g. someone adding wrapping on one side but
not the other).

Verification
- `packages/acp-bridge`: 6 files, 114/114 pass (+1 new readTextFile
  non-FsError test).
- `packages/sdk-typescript`: 75/75 pass on daemonEvents.test.ts
  (+2 new session_closed / client_evicted passthrough tests).
- `packages/cli/src/serve/server.test.ts`: 248 tests pass on
  touched cases (5 SSE / serverTimestamp / stream_error tests).
  Pre-existing F3 (#4335 merge) test failures unrelated to this
  PR's changes — verified by stash-test-restore on clean tree.
- TypeScript clean on touched regions; `BridgeTimeoutError`
  2-arg fix unblocks `tsc --noEmit` for the test file.
doudouOUC added a commit that referenced this pull request May 21, 2026
Adopts all 3 threads from wenshao's second review round on PR #4360.
All Suggestion-level — daemon-side observability + 1 missing SDK
reducer test.

**[Suggestion] SSE ring eviction silently emits state_resync_required**
(PRRT_kwDOPB-92c6Dp_Uk, eventBus.ts:394)

Pre-fix: when a consumer reconnects past the ring boundary, the
daemon emits `state_resync_required` with zero stderr breadcrumb.
A 3am oncall chasing "my UI is frozen with stale state" couldn't
grep daemon logs to distinguish (a) ring undersized, (b) client
reconnecting too slowly, (c) network partition causing repeated
reconnects.

Fix: detect `next.value.type === 'state_resync_required'` in the
SSE handler's iter loop in `server.ts` and emit a `writeStderrLine`
with the gap details (`lastEventId`, `earliestInRing`, computed
`gap` count, `reason`). Logged at the route boundary rather than
inside `EventBus.subscribe` to keep the bus implementation pure +
concentrate daemon-side observability in the route handler that
already logs socket errors + heartbeats.

**[Suggestion] Bridge iterator throw forwarded to client but not
logged daemon-side** (PRRT_kwDOPB-92c6Dp_Uo, server.ts:1956)

Pre-fix inconsistency: the adjacent `res.on('error', ...)` handler
at line ~1925 logs SSE socket errors with `writeStderrLine`, but
the bridge-iterator-catch block at line ~1940-1965 sends a
`stream_error` SSE frame to the client AND swallows the error
daemon-side. When the bridge iterator throws (subprocess crash,
channel protocol error, unhandled rejection), distinguishing
"subprocess OOM-killed" from "protocol bug" required attaching a
debugger.

Fix: mirror the adjacent handler's pattern — add `writeStderrLine`
before the `stream_error` SSE frame send, including the classified
`errorKind` (when available) in brackets so operators can grep for
`[init_timeout]` / `[missing_binary]` etc.

**[Suggestion] No SDK reducer test verifying stream_error.errorKind
flowthrough** (PRRT_kwDOPB-92c6Dp_Uq, daemonEvents.test.ts:2331)

The daemon-side wire format is tested in `server.test.ts`
(`parsed.data.errorKind === 'init_timeout'`) and
`DaemonStreamErrorData` now declares `errorKind?`, but the SDK
reducer test suite never fed a `stream_error` event with
`errorKind` and asserted `state.streamError?.errorKind`. A future
refactor stripping `errorKind` from the reducer's data assignment
(e.g. spreading only `{error}`) would silently regress without
test signal.

Fix: added `captures errorKind on stream_error in view state` test
exercising the full pipeline — reducer receives stream_error with
errorKind, view state's `streamError.errorKind` matches.

Verification
- `packages/sdk-typescript`: 76/76 daemonEvents tests pass (+1
  new flowthrough test).
- `packages/cli/src/serve/server.test.ts`: 6 targeted serverTimestamp
  / stream_error / errorKind tests pass — server.ts changes are
  observability-only (no behavior change to wire format).
- Pre-existing F3 (#4335 merge) test failures elsewhere are
  unrelated to this PR's changes.
doudouOUC added a commit that referenced this pull request May 21, 2026
…verage)

Adopts both threads from wenshao's third review round on PR #4360.
Both Suggestion-level — pin the daemon-side stderr log artifacts that
commit `dce2fed0f` introduced. Pre-fix: the EventBus-level
state_resync_required emission was tested in eventBus.test.ts, and
the SSE wire shape was tested in server.test.ts, but the actual
operator-facing artifacts (the stderr log lines themselves) had no
test coverage. A regression swapping operands in the `gap`
arithmetic, dropping the sessionId from the log, or breaking the
`[errorKind]` suffix would ship silently and only surface when an
operator went grepping during an incident.

**[Suggestion] SSE ring eviction stderr log untested**
(PRRT_kwDOPB-92c6Dqtlb, server.ts:1948)

Added 2 tests:
- `writes a daemon-side stderr log on SSE ring eviction` — yields
  a `state_resync_required` frame from a fake bridge, spies on
  `process.stderr.write`, asserts the captured log contains
  `session sess-A` + `lastEventId=5` + `earliestInRing=12` +
  `gap=6 events` (pins the arithmetic) + `reason=ring_evicted` +
  `loadSession` (the recovery hint).
- `falls back to "?" placeholders when state_resync_required data is
  partial` — yields a frame with empty `data: {}`, asserts every
  `?? '?'` branch fires (lastEventId=? / earliestInRing=? /
  gap=? events / reason=?). Defensive against future daemon schema
  changes that drop one of these fields.

**[Suggestion] Bridge iterator error stderr log untested**
(PRRT_kwDOPB-92c6Dqtlh, server.ts:1993)

Added 2 tests:
- `writes a daemon-side stderr log on bridge iterator error` — fake
  bridge throws plain `Error('agent died')` mid-stream, captures
  stderr, asserts the log contains `session sess-A` + `agent died`,
  and **no** `[…]` suffix (plain Error → `mapDomainErrorToErrorKind`
  returns undefined → no suffix).
- `includes [errorKind] suffix in bridge iterator error log when
  classified` — fake bridge throws `BridgeTimeoutError('initialize',
  5000)`, asserts the log contains `[init_timeout]`. Pins the
  classified-vs-unclassified branch of the conditional suffix
  template.

All 4 tests use `vi.spyOn(process.stderr, 'write').mockReturnValue(
true)` + filter `mock.calls` for the relevant log prefix — same
pattern as the existing `mcp-client-manager.test.ts` stderr-spy
tests in core, plus `startupProfiler.test.ts` in cli.

Verification: 7/7 targeted observability tests pass. Pre-existing
F3 (#4335 merge) test failures elsewhere are unrelated to this
PR's changes.
doudouOUC added a commit that referenced this pull request May 21, 2026
…amp / provenance / errorKind / state_resync_required) (#4360)

* feat(serve): stamp serverTimestamp / tool provenance / errorKind on daemon events (#4175 F4 prereq)

Adopts chiga0's three P0 SDK-side blockers from #4175 comment #19 —
the SDK side already consumes these fields (PR #4353), but daemon
hadn't stamped them yet, leaving the corresponding UI affordances
inert. All three stampings are purely additive on the wire and don't
require any SDK type changes (SDK already has forward-compat field
slots).

**#19.1 — `_meta.serverTimestamp` on every SSE frame** (`server.ts`
`formatSseFrame()`)

Stamped at the SSE write boundary (NOT EventBus.publish) so the
in-memory `BridgeEvent` type stays unchanged and internal consumers
don't see `_meta`. Pre-existing `_meta` keys (e.g. tool_call's
`_meta.toolName`) are preserved via spread merge. SDK reads via the
3-location probe in `extractServerTimestamp` (chiga0's PR #4353);
we pick `_meta.serverTimestamp` (Anthropic convention) so top-level
event type stays unpolluted.

Why this matters: pre-fix, multi-client UIs showing "X minutes ago"
or sorting transcript blocks by emit time used each client's local
clock — drifts of tens of seconds to minutes across browsers/tabs/
mobile produced visibly inconsistent timestamps.

**#19.2 — `tool_call` `provenance` + `serverId` on every emitter
event** (`ToolCallEmitter.ts`)

New static `ToolCallEmitter.resolveToolProvenance(toolName,
subagentMeta)` returns `{ provenance: 'builtin' | 'mcp' | 'subagent';
serverId? }`. Resolution rules (per user-confirmed design decision
from issue comment): subagent takes precedence (set when
subagentMeta is present); `mcp__<server>__<tool>` naming heuristic
classifies MCP tools with serverId; everything else is builtin.

Stamped on `emitStart` AND `emitResult` AND `emitError` (all three
emit paths) so a reconnecting client receiving a `tool_call_update`
frame from the replay ring (without the original `tool_call` start
event) can still derive the provenance. Provenance is stable per
tool, so stamping on every event is redundant — but the marginal
serialization cost is tiny and reconnect correctness wins.

Chose the naming heuristic (not ToolRegistry lookup) per user
confirmation: matches the SDK's own fallback (chiga0 PR #4353), no
new ctx-dep on emit hot path, no signature changes.

**#19.3 — `errorKind` on `stream_error`** (`server.ts` line ~1955)

Stamped via `mapDomainErrorToErrorKind(err)` — the 7-value classifier
already lives in `@qwen-code/acp-bridge/status.ts` since #4319. When
the classifier returns `undefined` (generic Error etc.) the field is
omitted — strictly additive. SDK consumers handle "errorKind absent"
as before (fall back to rendering `error` text).

NOT stamped on `session_died` because the 3 emit sites in `acp-bridge/
bridge.ts` don't have a classifiable `err` in scope:
- `channel_closed` carries only exitCode/signalCode (no error)
- `killed` is user-initiated (no domain error)
- `daemon_shutdown` is operator-initiated (no domain error)

A follow-up could thread channel-spawn errors through to the
session_died emit site to enable `errorKind: 'init_timeout'` /
`missing_binary` classification — left for a separate PR to avoid
mixing protocol stamping with lifecycle plumbing.

Verification
- `npx vitest run packages/cli/src/serve/server.test.ts -t "serverTimestamp|stream_error|errorKind"` — 5 pass
- `npx vitest run packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.test.ts` — 46 pass (+ 11 new tests for resolveToolProvenance + provenance stamping on all 3 emit paths)
- `npx vitest run packages/cli/src/acp-integration/session/HistoryReplayer.test.ts` — 17 pass
- TypeScript clean on touched regions; pre-existing F3 (#4335 merge) errors elsewhere are unrelated.

Existing test updates
- 15 `_meta: { toolName: 'X' }` assertions in ToolCallEmitter.test.ts updated to include `provenance: 'builtin'` (defensive — catches accidental drift if a future refactor stops stamping). 2 strict-equality assertions in HistoryReplayer.test.ts similarly updated. The first SSE-frame test in server.test.ts switched from `toEqual` to `toMatchObject` since `_meta.serverTimestamp` makes exact equality brittle; a dedicated test pins the new field's shape.

* feat(serve+sdk): detect SSE ring eviction on resume, expose state_resync_required (#4175 F4 prereq)

Closes the multi-client SSE reducer divergence bug Ilya0527 raised in
#4175 comment #15. Pre-fix scenario:

1. Consumer's SSE stream drops; client buffers `Last-Event-ID: N`.
2. Network reconnects long enough later that events `[N+1, ringHead-1]`
   were evicted from the daemon's per-session ring.
3. Daemon's `subscribe({lastEventId: N})` silently replays only the
   surviving suffix.
4. Consumer's SDK reducer keeps applying deltas as if the stream was
   contiguous. Its state has now drifted from the daemon's truth —
   no terminal signal, no warning. The `SessionState` reducer's
   "same event stream in → same state out" purity guarantee is
   broken.

The bug's blast radius is exactly when multi-client matters: F4
brings up the TUI / IDE / web client adapters that share session
state, so divergence becomes visibly inconsistent across clients.

**Daemon side** (`packages/acp-bridge/src/eventBus.ts`)

In `subscribe()`'s replay path, detect ring eviction by comparing
the ring's earliest id against `lastEventId + 1`. When a gap exists,
force-push a synthetic terminal `state_resync_required` frame BEFORE
the surviving replay events:

```
{ v: 1, type: 'state_resync_required',
  data: { reason: 'ring_evicted',
          lastDeliveredId: N,
          earliestAvailableId: M } }
```

Per user-confirmed design (issue comment discussion): the frame has
NO `id` (mirrors the `client_evicted` synthetic terminal pattern so
it doesn't burn a slot in the per-session monotonic sequence). Replay
continues after the resync frame — the SDK reducer auto-skips
subsequent deltas (see below) but the frames stay on the wire so
adapters have the option to compute a "what you missed" diff later.

**SDK side** (`packages/sdk-typescript/src/daemon/events.ts`)

Adds:
- `'state_resync_required'` to `DAEMON_EVENT_TYPES` union
- `DaemonStateResyncRequiredData` + `DaemonStateResyncRequiredEvent`
- `isStateResyncRequiredData` predicate
- `DaemonStreamLifecycleEvent` union widened
- Reducer state fields: `awaitingResync: boolean`,
  `resyncRequiredCount: number`, `lastResyncRequired?`
- Reducer case for `state_resync_required` — sets the flag, increments
  count, records data
- **Top-of-reducer gate**: when `awaitingResync === true`, all non-
  terminal events are auto-skipped (still advance `lastEventId`).
  Terminal lifecycle events (`session_died` / `session_closed` /
  `client_evicted` / `stream_error`) STILL apply — critical end-of-
  stream signals don't depend on prior state being current.
- Re-exported `DaemonStateResyncRequiredData` / Event from
  `daemon/index.ts` and `src/index.ts` (matches surface posture of
  sibling lifecycle types).

Consumer recovery contract: when `state.awaitingResync === true`,
call `loadSession` (out of band) to fetch the daemon's canonical
session snapshot, then reconstruct view state via
`createDaemonSessionViewState({...seed from loaded state})`. The
fresh state defaults `awaitingResync: false` so the seed implicitly
clears the flag.

**Side fix** (`stream_error` errorKind)

`DaemonStreamErrorData.errorKind?: string` typed for the optional
classification field that Commit 1 (`14637cd79`) added daemon-side.
Strictly additive — old daemons omit the field, SDK falls back to
rendering `error` text.

Verification
- `packages/acp-bridge`: 6 files, 108/108 pass (+5 new resync-detection
  tests; 1 existing "default ring size 8000" test updated to acknowledge
  the synthetic resync frame at the head of its replay batch).
- `packages/sdk-typescript`: 13 files, 451/451 pass (+8 new reducer
  resync tests covering set/skip/terminal-passthrough/recovery/
  repeated-resync/malformed-payload).
- TypeScript clean across both packages on touched regions.

* fix(acp-bridge): preserve FsError structure over ACP wire (#4360 Codex round 2 fold-in)

Adopts Codex review round 2 P2 finding on PR #4360 — fold-in to the
F4 prereq scope per user's "a" decision.

**Problem**: When the `BridgeFileSystem` adapter (introduced in
#4334 fs adapter wiring) throws a structured `FsError` (e.g.
`kind: 'untrusted_workspace'` / `kind: 'symlink_escape'` / `kind:
'file_too_large'`), the `@agentclientprotocol/sdk` default RPC
error serialization only sends `error.message` as JSON-RPC -32603
"Internal error". The structured `kind` / `status` / `hint` fields
on FsError are stripped on the way to the agent.

Downstream impact: SDK consumers receiving the ACP error payload
lose the typed discriminator and have to regex-match the human-
readable message to dispatch UI (auth retry vs file picker vs
proxy hint). This silently regresses what the FsError-typed
contract was supposed to provide.

**Fix**: At the bridge boundary (`BridgeClient.writeTextFile` and
`BridgeClient.readTextFile`), catch errors from `this.fileSystem.
writeText/readText` calls. Duck-type check for FsError shape
(`err.name === 'FsError'` + `typeof err.kind === 'string'`); when
matched, rethrow as ACP `RequestError(-32603, message, {errorKind,
hint, status})`. The agent's RPC client now receives `data.
errorKind` and can branch on the closed-enum kind.

Cross-package note: FsError lives in `cli/src/serve/fs/errors.ts`
and acp-bridge can't `import { FsError }` from cli (dependency
inversion). Same duck-typing pattern that `mapDomainErrorToErrorKind`
(status.ts) already applies to `TrustGateError` / `SkillError` for
the same cross-package bundling reason — `instanceof` would fail
across package boundaries when bundlers don't dedupe.

**Code shape**

```typescript
function isFsErrorShape(err: unknown): err is FsErrorShape {
  return (
    err instanceof Error &&
    err.name === 'FsError' &&
    typeof (err as { kind?: unknown }).kind === 'string'
  );
}

function preserveFsErrorOverAcp(err: unknown): never {
  if (isFsErrorShape(err)) {
    throw new RequestError(-32603, err.message, {
      errorKind: err.kind,
      ...(err.hint !== undefined ? { hint: err.hint } : {}),
      ...(err.status !== undefined ? { status: err.status } : {}),
    });
  }
  throw err;
}
```

Applied at both `if (this.fileSystem) { ... }` blocks (writeTextFile
+ readTextFile) — wrapped the adapter call in try/catch +
`preserveFsErrorOverAcp(err)`. Non-FsError errors are rethrown
unchanged (default ACP serialization is fine for unstructured
errors; only the structured shape needs preservation).

JSON-RPC code stays at -32603 (internal error) rather than mapping
FsError.kind → JSON-RPC code. Rationale: the JSON-RPC standard
defines only a handful of code values (-32700/-32600/-32601/-32602/
-32603 + a reserved range for application errors), and mapping
~10 FsError kinds to that narrow space is lossy. Instead the
structured `data.errorKind` carries the semantic information SDK
consumers need; JSON-RPC code remains the generic "an error happened"
signal.

**Tests** (+5 in `bridgeClient.test.ts`)

- writeTextFile FsError → ACP RequestError with errorKind in data
- readTextFile FsError preserving symlink_escape kind (no hint
  field present → not stamped, spread guard works)
- non-FsError pass-through (plain Error stays plain Error, no
  RequestError wrap)
- hint field preservation when present
- defensive: error with `kind` field but wrong `name` does NOT get
  wrapped (e.g. PermissionForbiddenError happens to have a kind
  field internally — must NOT be confused for FsError)

Verification: 113/113 acp-bridge tests pass (+5 new FsError-
preservation tests). Full serve suite shows pre-existing F3-related
failures unrelated to this change (verified in isolation).

* fix: 7 wenshao/copilot review fold-ins on #4360 (1 Critical + 6 Suggestion)

Adopts all 7 review threads from the first wenshao + Copilot review
round on PR #4360. All technical fixes (no judgment calls).

**[Critical] BridgeTimeoutError constructor blocks tsc** (wenshao
PRRT_kwDOPB-92c6DfcRI)

`server.test.ts:4670` called `new BridgeTimeoutError('initialize
timed out')` but the constructor signature is `(label: string,
timeoutMs: number)` — TS2554 blocked `tsc --noEmit` and `npm run
build`. Fixed to `new BridgeTimeoutError('initialize', 5000)` per
suggested fix; resulting message `"HttpAcpBridge initialize timed
out after 5000ms"` still satisfies the existing
`.toContain('timed out')` assertion.

**[Suggestion] Copilot JSDoc package name** (Copilot
PRRT_kwDOPB-92c6De-Sm, ToolCallEmitter.ts:210)

JSDoc referenced `@qwen-code/core/mcp-tool` but the actual package
is `@qwen-code/qwen-code-core` with the file at
`packages/core/src/tools/mcp-tool.ts`. Updated the reference.

**[Suggestion] Copilot errorKind type widening** (Copilot
PRRT_kwDOPB-92c6De-Ro, events.ts:244)

`DaemonStreamErrorData.errorKind` was typed as `string` and the
JSDoc said "7-value" closed enum — but `DAEMON_ERROR_KINDS` actually
has 8 values, and `SERVE_ERROR_KINDS` (daemon-side) has 9 (adds
`stat_failed`). Typed as `DaemonErrorKind | (string & {})` for
forward-compat: SDK consumers get IDE autocomplete on the known 8
kinds while still accepting future daemon-side additions (like
`stat_failed`) without a type error. Updated JSDoc to accurately
list 8 current values + call out the forward-compat widening.

Side observation (NOT in scope of this PR): `DAEMON_ERROR_KINDS`
(SDK) lacks `stat_failed` that exists in `SERVE_ERROR_KINDS`
(daemon). That's a separate drift fix.

**[Suggestion] TERMINAL wording misleading** (wenshao
PRRT_kwDOPB-92c6Dj-JL, eventBus.ts:369)

Comment called `state_resync_required` a "TERMINAL synthetic frame"
but it's emitted FIRST (before replay) and the stream stays OPEN.
Genuine terminals like `client_evicted` close the stream after the
frame. Rewrote the comment per suggestion: "id-less synthetic
frame... Unlike `client_evicted`, the stream stays OPEN" — so an
oncall reading the source at 3am gets the right mental model.

**[Suggestion] `_meta` merge dead code + stale reference** (wenshao
PRRT_kwDOPB-92c6Dj-JF, server.ts:2569)

The `existingMeta` merge reads `event._meta` at BridgeEvent top
level, but ToolCallEmitter's `_meta` lives nested inside
`event.data._meta` (publish path goes through `events.publish({type:
'session_update', data: params})`). In production `existingMeta` is
always undefined — the merge is a forward-compat escape hatch, not
an active merge. Also the comment referenced
`extractServerTimestamp` (sdk-typescript) which grep confirms
doesn't exist yet (it's planned in chiga0 PR #4353).

Rewrote the comment block to (1) acknowledge no current producer
sets `_meta` at the top level — it's a forward-compat hook for
future envelope-level metadata; (2) drop the stale
`extractServerTimestamp` reference and instead note that chiga0
PR #4353 plans the 3-location probe. Code shape unchanged
(forward-compat spread stays).

**[Suggestion] session_closed + client_evicted passthrough tests**
(wenshao PRRT_kwDOPB-92c6Dj-JW, daemonEvents.test.ts:2284)

`RESYNC_PASSTHROUGH_TYPES` has 5 members but only `session_died`
and `stream_error` had passthrough tests. Added two missing tests:
`session_closed` and `client_evicted` while awaitingResync.
Critical because if a future refactor accidentally drops either
from the set, a consumer in resync limbo would silently swallow
the terminal signal and the UI would hang on "loading resync
state…".

**[Suggestion] readTextFile non-FsError passthrough test** (wenshao
PRRT_kwDOPB-92c6Dj-JX, bridgeClient.test.ts:251)

The non-FsError pass-through test only covered `writeTextFile`.
Added a symmetric `readTextFile` test — the two `try/catch` blocks
in `bridgeClient.ts` are independent, so test parity guards against
divergent refactors (e.g. someone adding wrapping on one side but
not the other).

Verification
- `packages/acp-bridge`: 6 files, 114/114 pass (+1 new readTextFile
  non-FsError test).
- `packages/sdk-typescript`: 75/75 pass on daemonEvents.test.ts
  (+2 new session_closed / client_evicted passthrough tests).
- `packages/cli/src/serve/server.test.ts`: 248 tests pass on
  touched cases (5 SSE / serverTimestamp / stream_error tests).
  Pre-existing F3 (#4335 merge) test failures unrelated to this
  PR's changes — verified by stash-test-restore on clean tree.
- TypeScript clean on touched regions; `BridgeTimeoutError`
  2-arg fix unblocks `tsc --noEmit` for the test file.

* fix: 3 wenshao observability fold-ins on #4360 (all Suggestion)

Adopts all 3 threads from wenshao's second review round on PR #4360.
All Suggestion-level — daemon-side observability + 1 missing SDK
reducer test.

**[Suggestion] SSE ring eviction silently emits state_resync_required**
(PRRT_kwDOPB-92c6Dp_Uk, eventBus.ts:394)

Pre-fix: when a consumer reconnects past the ring boundary, the
daemon emits `state_resync_required` with zero stderr breadcrumb.
A 3am oncall chasing "my UI is frozen with stale state" couldn't
grep daemon logs to distinguish (a) ring undersized, (b) client
reconnecting too slowly, (c) network partition causing repeated
reconnects.

Fix: detect `next.value.type === 'state_resync_required'` in the
SSE handler's iter loop in `server.ts` and emit a `writeStderrLine`
with the gap details (`lastEventId`, `earliestInRing`, computed
`gap` count, `reason`). Logged at the route boundary rather than
inside `EventBus.subscribe` to keep the bus implementation pure +
concentrate daemon-side observability in the route handler that
already logs socket errors + heartbeats.

**[Suggestion] Bridge iterator throw forwarded to client but not
logged daemon-side** (PRRT_kwDOPB-92c6Dp_Uo, server.ts:1956)

Pre-fix inconsistency: the adjacent `res.on('error', ...)` handler
at line ~1925 logs SSE socket errors with `writeStderrLine`, but
the bridge-iterator-catch block at line ~1940-1965 sends a
`stream_error` SSE frame to the client AND swallows the error
daemon-side. When the bridge iterator throws (subprocess crash,
channel protocol error, unhandled rejection), distinguishing
"subprocess OOM-killed" from "protocol bug" required attaching a
debugger.

Fix: mirror the adjacent handler's pattern — add `writeStderrLine`
before the `stream_error` SSE frame send, including the classified
`errorKind` (when available) in brackets so operators can grep for
`[init_timeout]` / `[missing_binary]` etc.

**[Suggestion] No SDK reducer test verifying stream_error.errorKind
flowthrough** (PRRT_kwDOPB-92c6Dp_Uq, daemonEvents.test.ts:2331)

The daemon-side wire format is tested in `server.test.ts`
(`parsed.data.errorKind === 'init_timeout'`) and
`DaemonStreamErrorData` now declares `errorKind?`, but the SDK
reducer test suite never fed a `stream_error` event with
`errorKind` and asserted `state.streamError?.errorKind`. A future
refactor stripping `errorKind` from the reducer's data assignment
(e.g. spreading only `{error}`) would silently regress without
test signal.

Fix: added `captures errorKind on stream_error in view state` test
exercising the full pipeline — reducer receives stream_error with
errorKind, view state's `streamError.errorKind` matches.

Verification
- `packages/sdk-typescript`: 76/76 daemonEvents tests pass (+1
  new flowthrough test).
- `packages/cli/src/serve/server.test.ts`: 6 targeted serverTimestamp
  / stream_error / errorKind tests pass — server.ts changes are
  observability-only (no behavior change to wire format).
- Pre-existing F3 (#4335 merge) test failures elsewhere are
  unrelated to this PR's changes.

* test(serve): 2 wenshao observability fold-ins on #4360 (stderr log coverage)

Adopts both threads from wenshao's third review round on PR #4360.
Both Suggestion-level — pin the daemon-side stderr log artifacts that
commit `dce2fed0f` introduced. Pre-fix: the EventBus-level
state_resync_required emission was tested in eventBus.test.ts, and
the SSE wire shape was tested in server.test.ts, but the actual
operator-facing artifacts (the stderr log lines themselves) had no
test coverage. A regression swapping operands in the `gap`
arithmetic, dropping the sessionId from the log, or breaking the
`[errorKind]` suffix would ship silently and only surface when an
operator went grepping during an incident.

**[Suggestion] SSE ring eviction stderr log untested**
(PRRT_kwDOPB-92c6Dqtlb, server.ts:1948)

Added 2 tests:
- `writes a daemon-side stderr log on SSE ring eviction` — yields
  a `state_resync_required` frame from a fake bridge, spies on
  `process.stderr.write`, asserts the captured log contains
  `session sess-A` + `lastEventId=5` + `earliestInRing=12` +
  `gap=6 events` (pins the arithmetic) + `reason=ring_evicted` +
  `loadSession` (the recovery hint).
- `falls back to "?" placeholders when state_resync_required data is
  partial` — yields a frame with empty `data: {}`, asserts every
  `?? '?'` branch fires (lastEventId=? / earliestInRing=? /
  gap=? events / reason=?). Defensive against future daemon schema
  changes that drop one of these fields.

**[Suggestion] Bridge iterator error stderr log untested**
(PRRT_kwDOPB-92c6Dqtlh, server.ts:1993)

Added 2 tests:
- `writes a daemon-side stderr log on bridge iterator error` — fake
  bridge throws plain `Error('agent died')` mid-stream, captures
  stderr, asserts the log contains `session sess-A` + `agent died`,
  and **no** `[…]` suffix (plain Error → `mapDomainErrorToErrorKind`
  returns undefined → no suffix).
- `includes [errorKind] suffix in bridge iterator error log when
  classified` — fake bridge throws `BridgeTimeoutError('initialize',
  5000)`, asserts the log contains `[init_timeout]`. Pins the
  classified-vs-unclassified branch of the conditional suffix
  template.

All 4 tests use `vi.spyOn(process.stderr, 'write').mockReturnValue(
true)` + filter `mock.calls` for the relevant log prefix — same
pattern as the existing `mcp-client-manager.test.ts` stderr-spy
tests in core, plus `startupProfiler.test.ts` in cli.

Verification: 7/7 targeted observability tests pass. Pre-existing
F3 (#4335 merge) test failures elsewhere are unrelated to this
PR's changes.
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.

4 participants