From f473c80e86d2236080b7812d6c6345f1907bed50 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 28 Oct 2026 14:07:53 +0000 Subject: [PATCH 01/20] test(webviews): reproduce missing local peer state events On base 1f723c64fe2a70709ac21b88f10f4bc8bfdf0823, bun test src/agent-session/acpmux/peerStateEvents.test.ts runs 7 tests: 3 fail and 4 pass. The client never reads peers and ignores the local state feed. Nonlocal and feature-absent fallback cases pass. Native validation is held by #26809. Agent-Session: https://cmux.com/deeplink/agent?id=aid-93c3a66aa0c6bbfa650c --- .../acpmux/peerStateEvents.test.ts | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 webviews/src/agent-session/acpmux/peerStateEvents.test.ts diff --git a/webviews/src/agent-session/acpmux/peerStateEvents.test.ts b/webviews/src/agent-session/acpmux/peerStateEvents.test.ts new file mode 100644 index 000000000000..d5608a3d6f3d --- /dev/null +++ b/webviews/src/agent-session/acpmux/peerStateEvents.test.ts @@ -0,0 +1,140 @@ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { JSDOM } from "jsdom"; +import type { AcpmuxSnapshot } from "./model"; + +const dom = new JSDOM("", { url: "http://localhost/" }); +const scope = globalThis as Record; +const saved = new Map(["window", "document"].map((key) => [key, Object.getOwnPropertyDescriptor(scope, key)])); +beforeAll(() => Object.assign(scope, { window: dom.window, document: dom.window.document })); +afterAll(() => { + for (const [key, descriptor] of saved) { + if (descriptor) Object.defineProperty(scope, key, descriptor); + else delete scope[key]; + } + dom.window.close(); +}); + +type Peer = { name: string; state: string; lastSeen?: number | null; error?: string | null }; +async function fixture(origin: string | undefined, supported = true) { + const { AcpmuxDirectClient } = await import("./direct"); + const { MockAcpmuxSocket, mockHost } = await import("./mock"); + const requests: string[] = []; + const snapshots: AcpmuxSnapshot[] = []; + let peers: Peer[] = [{ name: "studio-mac", state: "connecting", lastSeen: null, error: null }]; + let duringRead: (() => void) | undefined; + const socket = new MockAcpmuxSocket(() => Promise.resolve()); + const daemon = socket as unknown as { answer(method: string, params: Record): Promise }; + const answer = daemon.answer.bind(daemon); + daemon.answer = async (method, params) => { + requests.push(method); + if (method === "initialize") + return { protocolVersion: 1, _meta: { acpmux: { origin, features: supported ? ["peerStateEvents"] : [] } } }; + if (method === "_acpmux/status") + return { peers: [{ name: "studio-mac", transport: "ssh", connected: true, state: "connected", lastSeen: 99 }] }; + if (method === "_acpmux/peers") { + const result = { peers }; + const run = duringRead; + duringRead = undefined; + run?.(); + return result; + } + return answer(method, params); + }; + const client = await AcpmuxDirectClient.connect( + mockHost, + (snapshot) => snapshots.push(snapshot), + undefined, + () => socket as unknown as WebSocket, + ); + const notify = (value: Record) => + socket.onmessage?.({ + data: JSON.stringify({ jsonrpc: "2.0", method: "_acpmux/peer_state_changed", params: value }), + }); + return { + client, + requests, + snapshots, + notify, + latest: () => snapshots.at(-1)!, + peers: (value: Peer[]) => { + peers = value; + }, + duringRead: (run: () => void) => { + duringRead = run; + }, + }; +} + +const note = (state: string, extra: Record = {}) => ({ + peer: "studio-mac", + state, + lastSeen: null, + error: null, + at: 1, + ...extra, +}); + +test("a local app snapshots after watch, then closed controls receive the daemon's exact states", async () => { + const f = await fixture("local"); + try { + expect(f.requests.indexOf("_acpmux/peers")).toBeGreaterThan(f.requests.indexOf("_acpmux/watch")); + expect(f.latest().peerStates?.["studio-mac"]).toBe("connecting"); + for (const state of ["degraded", "connected", "reconnecting", "offline"]) { + f.notify(note(state)); + expect(f.latest().peerStates?.["studio-mac"]).toBe(state); + expect(f.latest().peerConnected?.["studio-mac"]).toBe(state === "connected"); + } + f.notify(note("offline", { error: "removed" })); + expect(f.latest().peerStates?.["studio-mac"]).toBe("offline"); + } finally { + f.client.close(); + } +}); + +test("identical lag replay is idempotent and the next state still reaches the client", async () => { + const f = await fixture("local"); + try { + const offline = note("offline", { lastSeen: 10, error: "lost" }); + f.notify(offline); + expect(f.latest().peerStates?.["studio-mac"]).toBe("offline"); + const emitted = f.snapshots.length; + f.notify({ ...offline, at: 2 }); + expect(f.snapshots).toHaveLength(emitted); + f.notify(note("connected")); + expect(f.latest().peerStates?.["studio-mac"]).toBe("connected"); + } finally { + f.client.close(); + } +}); + +test("an event during a snapshot wins; an empty later snapshot clears removed peer state", async () => { + const f = await fixture("local"); + try { + f.peers([{ name: "studio-mac", state: "connected", lastSeen: 20 }]); + f.duringRead(() => f.notify(note("offline", { error: "removed" }))); + await f.client.refreshPeers(); + expect(f.latest().peerStates?.["studio-mac"]).toBe("offline"); + f.peers([]); + await f.client.refreshPeers(); + expect(f.latest().peerStates?.["studio-mac"]).toBeUndefined(); + } finally { + f.client.close(); + } +}); + +for (const [origin, supported] of [ + ["peer", true], + ["remote", true], + [undefined, true], + ["local", false], +] as const) + test(`origin=${origin}, feature=${supported} retains its status snapshot without a peer-event subscription`, async () => { + const f = await fixture(origin, supported); + try { + f.notify(note("offline")); + expect(f.latest().peerStates?.["studio-mac"]).toBe("connected"); + expect(f.requests).not.toContain("_acpmux/peers"); + } finally { + f.client.close(); + } + }); From 8e9ada837e6f56d10ef1f624389a2fdcc63a9b0b Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 28 Oct 2026 14:14:13 +0000 Subject: [PATCH 02/20] fix(webviews): consume local peer state snapshots and events Gate peer events on the advertised feature and local/app origin. Read once after watch at initialize/reconnect, retain exact daemon state, clear nullable metadata, deduplicate lag replay, and reject superseded sockets/reads. Add a zero-parameter pane snapshot relay filtered to name, state, lastSeen and error. Preserve picker presentation and policies. Linux web validation: 14 focused tests / 62 assertions pass; bun --bun run check passes including formatting, lint, typecheck (2933 files), and protocol checks. The focused regression was red before repair (3 failed / 4 passed). Native relay tests were added but native build/test/parse and CI dispatch remain held by #26809/HQ4779. No real-machine capture is claimed. Agent-Session: https://cmux.com/deeplink/agent?id=aid-93c3a66aa0c6bbfa650c --- .../AcpmuxPaneMethods+Params.swift | 1 + .../AcpmuxPaneMethods+ReplyShapes.swift | 4 + .../CmuxNextAgentPane/AcpmuxPaneMethods.swift | 2 +- .../AcpmuxPanePeerSnapshotTests.swift | 23 +++++ webviews/src/agent-session/acpmux/direct.ts | 40 +++++++- .../acpmux/peerStateEvents.test.ts | 95 +++++++++++++------ .../agent-session/acpmux/peerStateEvents.ts | 80 ++++++++++++++++ .../acpmux/peerStateReducer.test.ts | 87 +++++++++++++++++ 8 files changed, 300 insertions(+), 32 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AcpmuxPanePeerSnapshotTests.swift create mode 100644 webviews/src/agent-session/acpmux/peerStateEvents.ts create mode 100644 webviews/src/agent-session/acpmux/peerStateReducer.test.ts diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxPaneMethods+Params.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxPaneMethods+Params.swift index 0bb20679cb65..74b943a6f1e4 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxPaneMethods+Params.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxPaneMethods+Params.swift @@ -70,6 +70,7 @@ nonisolated extension AcpmuxPaneMethods { // grants nothing (the same probes acpmux runs in the background), so no gesture rule. "_acpmux/models": (["refresh"], []), "_acpmux/status": ([], []), + "_acpmux/peers": ([], []), "_acpmux/draft_get": (["sessionId"], []), "_acpmux/draft_set": (["sessionId", "text"], []), "_acpmux/bookmark_get": (["sessionId"], []), diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxPaneMethods+ReplyShapes.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxPaneMethods+ReplyShapes.swift index 4edb34337389..58dcb7a4cfbb 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxPaneMethods+ReplyShapes.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxPaneMethods+ReplyShapes.swift @@ -27,6 +27,10 @@ nonisolated extension AcpmuxPaneMethods { /// tell a chat on a machine that is not connected (with when it was last seen) from a deleted /// one, and from one whose machine is still connecting (chatUnavailable.ts). public static let replyShapes: [String: ReplyShape] = [ + // The local/app connection's peer state snapshot; never transport URLs or credentials. + "_acpmux/peers": .object(["peers": .list(.object([ + "name": .string, "state": .string, "lastSeen": .number, "error": .string, + ]))]), "_acpmux/status": .object([ "peers": .list(.object([ "name": .string, "transport": .scheme(from: "url"), "connected": .bool, diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxPaneMethods.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxPaneMethods.swift index c3d7ec04ab60..32257f90a489 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxPaneMethods.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxPaneMethods.swift @@ -46,7 +46,7 @@ nonisolated enum AcpmuxPaneMethods { // Take Over a View Only chat (direct.ts takeOver): the chat runs the conversation from then on. "_acpmux/watch_take_over", // Read-only, and its reply is filtered to ``replyShapes`` (ad349). - "_acpmux/status", "_acpmux/draft_get", + "_acpmux/status", "_acpmux/peers", "_acpmux/draft_get", // Session-scoped draft writes are kept separate from the filtered replies above. "_acpmux/draft_set", // The thread minimap's prompt bookmarks of a chat in the pane's scope (direct.ts diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AcpmuxPanePeerSnapshotTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AcpmuxPanePeerSnapshotTests.swift new file mode 100644 index 000000000000..0fa23846a4cd --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AcpmuxPanePeerSnapshotTests.swift @@ -0,0 +1,23 @@ +import Foundation +import Testing +@testable import CmuxNextAgentPane + +@Suite struct AcpmuxPanePeerSnapshotTests { + @Test func peerSnapshotAcceptsNoParameters() { + #expect(AcpmuxPaneMethods.requests.contains("_acpmux/peers")) + #expect(!AcpmuxPaneMethods.breaksParamsRule(["method": "_acpmux/peers", "params": [:]], modeFields: nil)) + for params in [["command": "pwd"], ["peer": "other"], ["url": "ssh://private"]] { + #expect(AcpmuxPaneMethods.breaksParamsRule(["method": "_acpmux/peers", "params": params], modeFields: nil)) + } + } + + @Test func peerSnapshotKeepsOnlyDisplayFields() throws { + let shape = try #require(AcpmuxPaneMethods.replyShapes["_acpmux/peers"]) + let value: [String: Any] = ["peers": [["name": "studio-mac", "state": "offline", "lastSeen": 5, + "error": "removed", "url": "ssh://private", "token": "secret"]]] + let filtered = try #require(AcpmuxPaneMethods.filtered(value, to: shape) as? [String: Any]) + let peer = try #require((filtered["peers"] as? [[String: Any]])?.first) + #expect(Set(peer.keys) == ["name", "state", "lastSeen", "error"]) + #expect(peer["error"] as? String == "removed") + } +} diff --git a/webviews/src/agent-session/acpmux/direct.ts b/webviews/src/agent-session/acpmux/direct.ts index 051bbad67cdb..9b29c326b6fd 100644 --- a/webviews/src/agent-session/acpmux/direct.ts +++ b/webviews/src/agent-session/acpmux/direct.ts @@ -38,6 +38,7 @@ import { carryInline, inlinePrompt, recordedPromptParts, restoreInline, stripFil import { liftInlineImages } from "./inlineImages"; import { primeSentImages } from "./conversation/imageEntries"; import { resentAttachments, shownPrompt } from "./pastedText"; +import { PeerStateEvents } from "./peerStateEvents"; import { peerConnectedFromStatus, peerKindsFromStatus, peerStatesFromStatus } from "./environments"; import { type ChatUnavailable, chatUnavailable, type PeerStatus, peerStatusFromStatus } from "./chatUnavailable"; import { hostKind, sessionEntry, summaryActivityAt, text, type AcpmuxSessionEntry } from "./sessionList"; @@ -1389,6 +1390,7 @@ export class AcpmuxDirectClient { /// acpmux lists `acp.session.fork` among the operations it serves. private canFork = false; private handoffSupported = false; + private readonly peerEvents = new PeerStateEvents(); /// The `_acpmux/*` methods acpmux's `initialize` lists (`_meta.acpmux.extensions`). private extensions: readonly string[] = []; private features = new Set(); @@ -1729,7 +1731,10 @@ export class AcpmuxDirectClient { onLost(); } else this.scheduleReconnect(); }; - socket.onmessage = (message) => acpmuxPerf.timed("client.receive", () => this.receive(String(message.data))); + socket.onmessage = (message) => { + if (this.socket === socket && !this.closed) + acpmuxPerf.timed("client.receive", () => this.receive(String(message.data))); + }; }).catch((error: unknown) => { this.releaseCopyAttach(mine); throw error; @@ -1756,11 +1761,13 @@ export class AcpmuxDirectClient { const origin = initialized?._meta?.acpmux?.origin; this.origin = origin === "local" || origin === "remote" || origin === "peer" ? origin : "unknown"; this.handoffSupported = supportsHandoff(initialized); + this.peerEvents.configure(initialized); const groupedPermissionsSupported = supportsPermissionGroups(initialized); if (!groupedPermissionsSupported) this.groupedPermissions.clear(); this.permissions.configure(groupedPermissionsSupported); this.readPeers(await this.request("_acpmux/status", {}).catch(() => undefined)); const first = await this.sessionPages.first(); + if (this.peerEvents.supported) await this.refreshPeerStates().catch(() => undefined); let laterPages = first?.next ? first : undefined; const linkedLater = first !== undefined && @@ -2392,6 +2399,13 @@ export class AcpmuxDirectClient { return; } const notification = message as Notification; + if (notification.method === "_acpmux/peer_state_changed") { + if (this.peerEvents.changed(notification.params)) { + this.applyPeerStates(); + if (!this.snapshotHold) this.emit(); + } + return; + } // Who else is in the chat, ephemeral (multiplayer/presenceClient.ts): never a record. if (notification.method === "_acpmux/presence") { this.presence.notification(notification.params); @@ -6787,7 +6801,8 @@ export class AcpmuxDirectClient { ]; } /// Reads acpmux's peers again (`_acpmux/status`): the environment picker asks as it opens, so a - /// machine that connected or dropped since shows as it is now. acpmux sends no peer events. + /// machine that connected or dropped since shows as it is now. Local/app peer events keep the + /// closed control current when the daemon advertises them; older daemons keep their last snapshot. async refreshPeers(): Promise { const status = await this.request("_acpmux/status", {}).catch(() => undefined); if (status === undefined) return; @@ -6796,6 +6811,26 @@ export class AcpmuxDirectClient { // emitting now would record "connected" on the hold before setup says so. if (!this.snapshotHold) this.emit(); } + private async refreshPeerStates(): Promise { + const since = this.peerEvents.revision; + const read = this.peerEvents.readStarted(); + if (this.peerEvents.snapshot(await this.request("_acpmux/peers", {}), since, read)) this.applyPeerStates(); + } + private applyPeerStates(): void { + const peers = [...this.peerEvents.entries()]; + this.peerStates = { + ...(this.peerEvents.authoritative ? {} : this.peerStates), + ...Object.fromEntries(peers.map(([peer, link]) => [peer, link.state])), + }; + this.peerConnected = { + ...(this.peerEvents.authoritative ? {} : this.peerConnected), + ...Object.fromEntries(peers.map(([peer, link]) => [peer, link.connected])), + }; + this.peerStatus = { + ...(this.peerEvents.authoritative ? {} : this.peerStatus), + ...Object.fromEntries(peers.map(([peer, link]) => [peer, { ...this.peerStatus[peer], ...link }])), + }; + } private readPeers(status: any): void { this.peers = Array.isArray(status?.peers) ? status.peers @@ -6806,6 +6841,7 @@ export class AcpmuxDirectClient { this.peerConnected = peerConnectedFromStatus(status); this.peerStates = peerStatesFromStatus(status); this.peerStatus = peerStatusFromStatus(status); + if (this.peerEvents.authoritative || this.peerEvents.revision > 0) this.applyPeerStates(); } /// Probes every agent's live model list again (`_acpmux/models {refresh: true}`): acpmux forgets /// the lists it has and asks each harness before it answers (the picker's Refresh models). diff --git a/webviews/src/agent-session/acpmux/peerStateEvents.test.ts b/webviews/src/agent-session/acpmux/peerStateEvents.test.ts index d5608a3d6f3d..9901bad377a6 100644 --- a/webviews/src/agent-session/acpmux/peerStateEvents.test.ts +++ b/webviews/src/agent-session/acpmux/peerStateEvents.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, expect, test } from "bun:test"; +import { afterAll, beforeAll, expect, spyOn, test } from "bun:test"; import { JSDOM } from "jsdom"; import type { AcpmuxSnapshot } from "./model"; @@ -21,30 +21,33 @@ async function fixture(origin: string | undefined, supported = true) { const requests: string[] = []; const snapshots: AcpmuxSnapshot[] = []; let peers: Peer[] = [{ name: "studio-mac", state: "connecting", lastSeen: null, error: null }]; - let duringRead: (() => void) | undefined; - const socket = new MockAcpmuxSocket(() => Promise.resolve()); - const daemon = socket as unknown as { answer(method: string, params: Record): Promise }; - const answer = daemon.answer.bind(daemon); - daemon.answer = async (method, params) => { - requests.push(method); - if (method === "initialize") - return { protocolVersion: 1, _meta: { acpmux: { origin, features: supported ? ["peerStateEvents"] : [] } } }; - if (method === "_acpmux/status") - return { peers: [{ name: "studio-mac", transport: "ssh", connected: true, state: "connected", lastSeen: 99 }] }; - if (method === "_acpmux/peers") { - const result = { peers }; - const run = duringRead; - duringRead = undefined; - run?.(); - return result; - } - return answer(method, params); + let socket: InstanceType; + let onSnapshot: ((snapshot: AcpmuxSnapshot) => void) | undefined; + const openSocket = () => { + socket = new MockAcpmuxSocket(() => Promise.resolve()); + const daemon = socket as unknown as { answer(method: string, params: Record): Promise }; + const answer = daemon.answer.bind(daemon); + daemon.answer = async (method, params) => { + requests.push(method); + if (method === "initialize") + return { protocolVersion: 1, _meta: { acpmux: { origin, features: supported ? ["peerStateEvents"] : [] } } }; + if (method === "_acpmux/status") + return { peers: [{ name: "studio-mac", transport: "ssh", connected: true, state: "connected", lastSeen: 99 }] }; + if (method === "_acpmux/peers") { + return { peers }; + } + return answer(method, params); + }; + return socket as unknown as WebSocket; }; const client = await AcpmuxDirectClient.connect( mockHost, - (snapshot) => snapshots.push(snapshot), + (snapshot) => { + snapshots.push(snapshot); + onSnapshot?.(snapshot); + }, undefined, - () => socket as unknown as WebSocket, + openSocket, ); const notify = (value: Record) => socket.onmessage?.({ @@ -52,6 +55,16 @@ async function fixture(origin: string | undefined, supported = true) { }); return { client, + socket: () => socket, + waitForState: (state: string) => + new Promise((resolve) => { + onSnapshot = (snapshot) => { + if (snapshot.peerStates?.["studio-mac"] === state) { + onSnapshot = undefined; + resolve(); + } + }; + }), requests, snapshots, notify, @@ -59,9 +72,6 @@ async function fixture(origin: string | undefined, supported = true) { peers: (value: Peer[]) => { peers = value; }, - duringRead: (run: () => void) => { - duringRead = run; - }, }; } @@ -107,16 +117,14 @@ test("identical lag replay is idempotent and the next state still reaches the cl } }); -test("an event during a snapshot wins; an empty later snapshot clears removed peer state", async () => { +test("a later status refresh cannot erase a newer live state and never polls the event snapshot", async () => { const f = await fixture("local"); try { - f.peers([{ name: "studio-mac", state: "connected", lastSeen: 20 }]); - f.duringRead(() => f.notify(note("offline", { error: "removed" }))); + f.notify(note("offline", { error: "removed" })); await f.client.refreshPeers(); expect(f.latest().peerStates?.["studio-mac"]).toBe("offline"); - f.peers([]); await f.client.refreshPeers(); - expect(f.latest().peerStates?.["studio-mac"]).toBeUndefined(); + expect(f.requests.filter((method) => method === "_acpmux/peers")).toHaveLength(1); } finally { f.client.close(); } @@ -138,3 +146,32 @@ for (const [origin, supported] of [ f.client.close(); } }); + +test("reconnect reads a fresh snapshot and ignores late notifications from the old socket", async () => { + const f = await fixture("local"); + let reconnect: (() => void) | undefined; + const timer = spyOn(dom.window, "setTimeout").mockImplementation((run) => { + if (typeof run === "function") reconnect = () => run(); + return 1; + }); + try { + const old = f.socket(); + f.notify(note("offline", { error: "removed" })); + f.peers([{ name: "studio-mac", state: "connected", lastSeen: null, error: null }]); + old.onclose?.(); + const connected = f.waitForState("connected"); + expect(reconnect).toBeDefined(); + reconnect!(); + await connected; + expect(f.requests.filter((method) => method === "_acpmux/peers")).toHaveLength(2); + const emitted = f.snapshots.length; + old.onmessage?.({ + data: JSON.stringify({ jsonrpc: "2.0", method: "_acpmux/peer_state_changed", params: note("offline") }), + }); + expect(f.latest().peerStates?.["studio-mac"]).toBe("connected"); + expect(f.snapshots).toHaveLength(emitted); + } finally { + timer.mockRestore(); + f.client.close(); + } +}); diff --git a/webviews/src/agent-session/acpmux/peerStateEvents.ts b/webviews/src/agent-session/acpmux/peerStateEvents.ts new file mode 100644 index 000000000000..c5494c7b26bf --- /dev/null +++ b/webviews/src/agent-session/acpmux/peerStateEvents.ts @@ -0,0 +1,80 @@ +import type { PeerLinkState } from "./protocol/generated/types"; + +const STATES = new Set(["connecting", "connected", "degraded", "reconnecting", "offline"]); +type Link = { state: PeerLinkState; connected: boolean; lastSeen: number | undefined; error: string | undefined }; +type VersionedLink = { link: Link; revision: number }; +const object = (value: unknown): Record => + value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; +const same = (a: Link | undefined, b: Link) => a?.state === b.state && a.lastSeen === b.lastSeen && a.error === b.error; + +/// One local/app connection's snapshot and pushed state. The daemon names both proven origins +/// `local`; a peer daemon's connection never hears this host's peer inventory (#26860). +export class PeerStateEvents { + supported = false; + authoritative = false; + revision = 0; + private reads = 0; + private links = new Map(); + + configure(initialized: unknown): void { + const acpmux = object(object(object(initialized)._meta).acpmux); + this.supported = + acpmux.origin === "local" && Array.isArray(acpmux.features) && acpmux.features.includes("peerStateEvents"); + this.authoritative = false; + this.revision = 0; + this.links.clear(); + this.reads += 1; + } + + changed(value: unknown): boolean { + const note = object(value); + const link = observation(note); + if ( + !this.supported || + typeof note.peer !== "string" || + !note.peer || + !link || + typeof note.at !== "number" || + !Number.isFinite(note.at) + ) + return false; + const previous = this.links.get(note.peer)?.link; + // Even a repeated note fences an in-flight stale read, without redrawing identical content. + this.links.set(note.peer, { link, revision: ++this.revision }); + return !same(previous, link); + } + + readStarted(): number { + return ++this.reads; + } + + snapshot(value: unknown, since: number, read = this.reads): boolean { + const peers = object(value).peers; + if (!this.supported || read !== this.reads || !Array.isArray(peers)) return false; + const next = new Map(); + for (const value of peers) { + const peer = object(value); + const link = observation(peer); + if (typeof peer.name === "string" && peer.name && link) next.set(peer.name, { link, revision: since }); + } + for (const [name, entry] of this.links) if (entry.revision > since) next.set(name, entry); + this.links = next; + this.authoritative = true; + return true; + } + + *entries(): IterableIterator<[string, Link]> { + for (const [name, entry] of this.links) yield [name, entry.link]; + } +} + +function observation(value: Record): Link | undefined { + if (!STATES.has(value.state)) return undefined; + return { + state: value.state as PeerLinkState, + connected: value.state === "connected", + // A full observation clears old nullable values when overlaid on an older status read. + lastSeen: typeof value.lastSeen === "number" && Number.isFinite(value.lastSeen) ? value.lastSeen : undefined, + error: typeof value.error === "string" ? value.error : undefined, + }; +} diff --git a/webviews/src/agent-session/acpmux/peerStateReducer.test.ts b/webviews/src/agent-session/acpmux/peerStateReducer.test.ts new file mode 100644 index 000000000000..70d2f47d0799 --- /dev/null +++ b/webviews/src/agent-session/acpmux/peerStateReducer.test.ts @@ -0,0 +1,87 @@ +import { expect, test } from "bun:test"; +import { PeerStateEvents } from "./peerStateEvents"; + +const initialized = (origin = "local", features = ["peerStateEvents"]) => ({ _meta: { acpmux: { origin, features } } }); +const note = (state: string, extra: Record = {}) => ({ + peer: "studio-mac", + state, + lastSeen: null, + error: null, + at: 1, + ...extra, +}); +function fixture() { + const events = new PeerStateEvents(); + events.configure(initialized()); + return events; +} +const peer = (events: PeerStateEvents) => Object.fromEntries(events.entries())["studio-mac"]; + +test("nullable and absent metadata clear a prior observation when the consumer overlays it", () => { + const events = fixture(); + events.changed(note("offline", { lastSeen: 10, error: "lost", url: "ssh://private" })); + const before = peer(events)!; + expect(before.error).toBe("lost"); + for (const metadata of [{ lastSeen: null, error: null }, {}]) { + events.changed({ peer: "studio-mac", state: "connected", at: 2, ...metadata }); + const overlaid = { ...before, ...peer(events) }; + expect(overlaid.state).toBe("connected"); + expect(overlaid.lastSeen).toBeUndefined(); + expect(overlaid.error).toBeUndefined(); + expect(overlaid).not.toHaveProperty("url"); + expect(overlaid).not.toHaveProperty("at"); + } +}); + +test("duplicate replay does not redraw but still fences an in-flight stale snapshot", () => { + const events = fixture(); + expect(events.changed(note("offline", { error: "removed" }))).toBe(true); + const since = events.revision; + const read = events.readStarted(); + expect(events.changed(note("offline", { error: "removed", at: 20 }))).toBe(false); + events.snapshot({ peers: [{ name: "studio-mac", state: "connected" }] }, since, read); + expect(peer(events)).toMatchObject({ state: "offline", error: "removed" }); + expect(events.changed(note("offline", { error: "different" }))).toBe(true); + expect(events.changed(note("offline", { error: "different", lastSeen: 5 }))).toBe(true); +}); + +test("a reconnect and a later read both invalidate the older read", () => { + const events = fixture(); + const old = events.readStarted(); + events.configure(initialized()); + expect(events.snapshot({ peers: [{ name: "studio-mac", state: "offline" }] }, 0, old)).toBe(false); + const first = events.readStarted(); + const second = events.readStarted(); + expect(events.snapshot({ peers: [{ name: "studio-mac", state: "connected" }] }, 0, second)).toBe(true); + expect(events.snapshot({ peers: [{ name: "studio-mac", state: "offline" }] }, 0, first)).toBe(false); + expect(peer(events)?.state).toBe("connected"); +}); + +test("a peer-origin reconnect drops local state and rejects inventory and live notes", () => { + const events = fixture(); + events.changed(note("connected")); + events.configure(initialized("peer")); + expect(events.supported).toBe(false); + expect(events.authoritative).toBe(false); + expect(events.changed(note("offline"))).toBe(false); + expect(events.snapshot({ peers: [{ name: "studio-mac", state: "connected" }] }, 0)).toBe(false); + expect([...events.entries()]).toEqual([]); +}); + +test("invalid notes cannot replace the last daemon observation", () => { + const events = fixture(); + events.changed(note("connected")); + for (const value of [null, [], note("invented"), note("offline", { at: NaN }), note("offline", { peer: "" })]) + expect(events.changed(value)).toBe(false); + expect(peer(events)?.state).toBe("connected"); +}); + +test("a complete empty snapshot removes stale state without inventing an offline transition", () => { + const events = fixture(); + events.changed(note("connected")); + const since = events.revision; + const read = events.readStarted(); + expect(events.snapshot({ peers: [] }, since, read)).toBe(true); + expect([...events.entries()]).toEqual([]); + expect(events.authoritative).toBe(true); +}); From 7e9b2a92ebb71c7eccb0796467f15806be8ca349 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 28 Oct 2026 14:16:43 +0000 Subject: [PATCH 03/20] fix(webviews): reset peer event capability before reconnect The replacement socket must initialize its own local/app origin and peerStateEvents feature before receiving peer notes; invalidate prior read tokens at open start. Focused Linux web tests: 15 pass, 66 assertions. Full bun --bun run check passes. Native verification and CI remain held by #26809/HQ4779. Agent-Session: https://cmux.com/deeplink/agent?id=aid-93c3a66aa0c6bbfa650c --- webviews/src/agent-session/acpmux/direct.ts | 2 ++ .../src/agent-session/acpmux/peerStateReducer.test.ts | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/webviews/src/agent-session/acpmux/direct.ts b/webviews/src/agent-session/acpmux/direct.ts index 9b29c326b6fd..1f7eec1c07b3 100644 --- a/webviews/src/agent-session/acpmux/direct.ts +++ b/webviews/src/agent-session/acpmux/direct.ts @@ -1668,6 +1668,8 @@ export class AcpmuxDirectClient { if (this.opening || this.closed) return; this.features.clear(); this.opening = true; + // A new socket must advertise its own feature and proven origin before its notes are accepted. + this.peerEvents.configure(undefined); // A reconnect owns the attach of the copy on screen from its start: a row that says its machine // is back meanwhile waits for it (`copyBack`), never a second attach over the same copy. if (this.peerCopy && this.peerCopy.sessionId === this.selectedSessionId) this.peerCopy.attaching = true; diff --git a/webviews/src/agent-session/acpmux/peerStateReducer.test.ts b/webviews/src/agent-session/acpmux/peerStateReducer.test.ts index 70d2f47d0799..29fc6031ac39 100644 --- a/webviews/src/agent-session/acpmux/peerStateReducer.test.ts +++ b/webviews/src/agent-session/acpmux/peerStateReducer.test.ts @@ -85,3 +85,13 @@ test("a complete empty snapshot removes stale state without inventing an offline expect([...events.entries()]).toEqual([]); expect(events.authoritative).toBe(true); }); + +test("a new connection has no inherited capability while initialize is pending", () => { + const events = fixture(); + const read = events.readStarted(); + events.configure(undefined); + expect(events.changed(note("offline"))).toBe(false); + expect(events.snapshot({ peers: [{ name: "studio-mac", state: "offline" }] }, 0, read)).toBe(false); + expect(events.supported).toBe(false); + expect([...events.entries()]).toEqual([]); +}); From 0e6b80977e143c61f3752a596af760b0497050b8 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 16:59:56 -0700 Subject: [PATCH 04/20] fix(webviews): a lag notice reads the peer snapshot again The daemon drops peer_state_changed notices when a client lags and says so with _acpmux/lagged; resyncing only the selected chat left a machine that went down meanwhile shown as connected. Review P2 on #26885. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-710a4a518c009a6ad126 --- webviews/src/agent-session/acpmux/direct.ts | 8 ++++++++ .../acpmux/peerStateEvents.test.ts | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/webviews/src/agent-session/acpmux/direct.ts b/webviews/src/agent-session/acpmux/direct.ts index 1f7eec1c07b3..d3f31f0bd8ad 100644 --- a/webviews/src/agent-session/acpmux/direct.ts +++ b/webviews/src/agent-session/acpmux/direct.ts @@ -2458,6 +2458,14 @@ export class AcpmuxDirectClient { /// agent-gui daemons send {sessionIds, watch, dropped}; older ones send only /// {dropped}, so a notice without sessionIds resyncs the selected session. private resyncAfterLag(params: any): void { + // Peer state notices may be among the dropped ones: read the snapshot again, so a machine that + // went down while this client lagged never shows as connected (no session needs to be open). + if (this.peerEvents.supported) + void this.refreshPeerStates() + .then(() => { + if (!this.closed && !this.snapshotHold) this.emit(); + }) + .catch(() => undefined); // Needs you's asks: notices of new or answered requests may be lost, so every chat with open // requests is read again (after the session list, which drops the chats acpmux no longer lists). if (params?.watch === true) diff --git a/webviews/src/agent-session/acpmux/peerStateEvents.test.ts b/webviews/src/agent-session/acpmux/peerStateEvents.test.ts index 9901bad377a6..cd5c1468b07b 100644 --- a/webviews/src/agent-session/acpmux/peerStateEvents.test.ts +++ b/webviews/src/agent-session/acpmux/peerStateEvents.test.ts @@ -130,6 +130,25 @@ test("a later status refresh cannot erase a newer live state and never polls the } }); +test("a lag notice reads the peer snapshot again, so a dropped disconnect is not lost", async () => { + const f = await fixture("local"); + try { + f.notify(note("connected")); + expect(f.latest().peerStates?.["studio-mac"]).toBe("connected"); + // The daemon dropped the offline notice and said so with `_acpmux/lagged`. + f.peers([{ name: "studio-mac", state: "offline", lastSeen: 5, error: "lost" }]); + const offline = f.waitForState("offline"); + f.socket().onmessage?.({ + data: JSON.stringify({ jsonrpc: "2.0", method: "_acpmux/lagged", params: { dropped: 3 } }), + }); + await offline; + expect(f.latest().peerConnected?.["studio-mac"]).toBe(false); + expect(f.requests.filter((method) => method === "_acpmux/peers")).toHaveLength(2); + } finally { + f.client.close(); + } +}); + for (const [origin, supported] of [ ["peer", true], ["remote", true], From 30480e3468ab5140e4bdcdbdb3d49838e5d887a1 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 03:53:42 +0000 Subject: [PATCH 05/20] test(webviews): reproduce a restarting peer dropped from the peer snapshot _acpmux/peers says `restarting` (peer_link.rs PeerState::as_str) while a peer's daemon restarts. The adapter drops that row, so the authoritative snapshot clears the peer's state and the picker reads it as ready. Red: bun test src/agent-session/acpmux/peerStateReducer.test.ts src/agent-session/acpmux/peerStateEvents.test.ts: 16 pass, 2 fail. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-c35ebd1681cf79649972 --- .../acpmux/peerStateEvents.test.ts | 26 +++++++++++++++++++ .../acpmux/peerStateReducer.test.ts | 13 ++++++++++ 2 files changed, 39 insertions(+) diff --git a/webviews/src/agent-session/acpmux/peerStateEvents.test.ts b/webviews/src/agent-session/acpmux/peerStateEvents.test.ts index cd5c1468b07b..761421b139a2 100644 --- a/webviews/src/agent-session/acpmux/peerStateEvents.test.ts +++ b/webviews/src/agent-session/acpmux/peerStateEvents.test.ts @@ -149,6 +149,32 @@ test("a lag notice reads the peer snapshot again, so a dropped disconnect is not } }); +test("a restarting peer in the snapshot stays restarting, so the picker never offers it as ready", async () => { + const { environmentReady, knownMachines } = await import("./environments"); + const f = await fixture("local"); + try { + f.notify(note("connected")); + f.peers([{ name: "studio-mac", state: "restarting", lastSeen: 5, error: null }]); + const restarting = f.waitForState("restarting"); + f.socket().onmessage?.({ + data: JSON.stringify({ jsonrpc: "2.0", method: "_acpmux/lagged", params: { dropped: 1 } }), + }); + await restarting; + const latest = f.latest(); + expect(latest.peerConnected?.["studio-mac"]).toBe(false); + const peers = { + kinds: { "studio-mac": "ssh" as const }, + connected: latest.peerConnected, + states: latest.peerStates, + }; + const [machine] = knownMachines(["studio-mac"], [], peers.kinds, [], peers.connected, peers.states); + expect(machine).toMatchObject({ state: "restarting", connected: false }); + expect(environmentReady(machine!)).toBe(false); + } finally { + f.client.close(); + } +}); + for (const [origin, supported] of [ ["peer", true], ["remote", true], diff --git a/webviews/src/agent-session/acpmux/peerStateReducer.test.ts b/webviews/src/agent-session/acpmux/peerStateReducer.test.ts index 29fc6031ac39..8980528142cb 100644 --- a/webviews/src/agent-session/acpmux/peerStateReducer.test.ts +++ b/webviews/src/agent-session/acpmux/peerStateReducer.test.ts @@ -95,3 +95,16 @@ test("a new connection has no inherited capability while initialize is pending", expect(events.supported).toBe(false); expect([...events.entries()]).toEqual([]); }); + +test("a snapshot keeps a restarting peer marked restarting and not connected", () => { + // `_acpmux/peers` says `restarting` (acpmux peer_link.rs `PeerState::as_str`) while the daemon on + // the other end restarts; dropping the row would leave the picker no state and read it as ready. + const events = fixture(); + events.changed(note("connected")); + const since = events.revision; + const read = events.readStarted(); + expect(events.snapshot({ peers: [{ name: "studio-mac", state: "restarting", lastSeen: 7 }] }, since, read)).toBe( + true, + ); + expect(peer(events)).toMatchObject({ state: "restarting", connected: false, lastSeen: 7 }); +}); From 6e2627340693996a8949653d1c9d8349ce7d4229 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 03:55:54 +0000 Subject: [PATCH 06/20] fix(webviews): keep a restarting peer in the peer snapshot _acpmux/peers names every PeerState acpmux has, `restarting` included (peer_link.rs as_str), while peer_state_changed maps it to reconnecting. The adapter now accepts it, so a restarting peer keeps its state and reads not connected instead of falling back to ready in the picker. Green: bun test src/agent-session/acpmux/peerStateReducer.test.ts src/agent-session/acpmux/peerStateEvents.test.ts: 18 pass, 0 fail. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-c35ebd1681cf79649972 --- webviews/src/agent-session/acpmux/peerStateEvents.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/webviews/src/agent-session/acpmux/peerStateEvents.ts b/webviews/src/agent-session/acpmux/peerStateEvents.ts index c5494c7b26bf..0320e400ce61 100644 --- a/webviews/src/agent-session/acpmux/peerStateEvents.ts +++ b/webviews/src/agent-session/acpmux/peerStateEvents.ts @@ -1,7 +1,11 @@ import type { PeerLinkState } from "./protocol/generated/types"; -const STATES = new Set(["connecting", "connected", "degraded", "reconnecting", "offline"]); -type Link = { state: PeerLinkState; connected: boolean; lastSeen: number | undefined; error: string | undefined }; +/// Every state acpmux sends (peer_link.rs `PeerState::as_str`). Notes carry the protocol's +/// `PeerLinkState`; `_acpmux/peers` also says `restarting` while the peer's daemon restarts, and a +/// snapshot that dropped that row would leave the picker no state, so it would read the peer as ready. +type LinkState = PeerLinkState | "restarting"; +const STATES = new Set(["connecting", "connected", "degraded", "reconnecting", "restarting", "offline"]); +type Link = { state: LinkState; connected: boolean; lastSeen: number | undefined; error: string | undefined }; type VersionedLink = { link: Link; revision: number }; const object = (value: unknown): Record => value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; @@ -71,7 +75,7 @@ export class PeerStateEvents { function observation(value: Record): Link | undefined { if (!STATES.has(value.state)) return undefined; return { - state: value.state as PeerLinkState, + state: value.state as LinkState, connected: value.state === "connected", // A full observation clears old nullable values when overlaid on an older status read. lastSeen: typeof value.lastSeen === "number" && Number.isFinite(value.lastSeen) ? value.lastSeen : undefined, From 61315ec046b6bc9645af45a2124464f5790c1476 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 04:08:24 +0000 Subject: [PATCH 07/20] test(webviews): reproduce a listed peer the peer snapshot omits reading as ready A peer that _acpmux/status lists but the authoritative _acpmux/peers snapshot omits (removed between the two reads, or an invalid row) loses its state and `connected`, so environmentReady offers it as ready. Red: bun test src/agent-session/acpmux/peerStateEvents.test.ts: 10 pass, 1 fail. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-c35ebd1681cf79649972 --- .../acpmux/peerStateEvents.test.ts | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/webviews/src/agent-session/acpmux/peerStateEvents.test.ts b/webviews/src/agent-session/acpmux/peerStateEvents.test.ts index 761421b139a2..ff6f831ff037 100644 --- a/webviews/src/agent-session/acpmux/peerStateEvents.test.ts +++ b/webviews/src/agent-session/acpmux/peerStateEvents.test.ts @@ -56,7 +56,7 @@ async function fixture(origin: string | undefined, supported = true) { return { client, socket: () => socket, - waitForState: (state: string) => + waitForState: (state: string | undefined) => new Promise((resolve) => { onSnapshot = (snapshot) => { if (snapshot.peerStates?.["studio-mac"] === state) { @@ -175,6 +175,31 @@ test("a restarting peer in the snapshot stays restarting, so the picker never of } }); +test("a peer the status lists but the snapshot omits is not offered as ready", async () => { + const { environmentReady, knownMachines } = await import("./environments"); + const f = await fixture("local"); + try { + // Removed between the `_acpmux/status` read and the `_acpmux/peers` read (or an invalid row). + f.peers([]); + const omitted = f.waitForState(undefined); + f.socket().onmessage?.({ + data: JSON.stringify({ jsonrpc: "2.0", method: "_acpmux/lagged", params: { dropped: 1 } }), + }); + await omitted; + const latest = f.latest(); + expect(latest.peerConnected?.["studio-mac"]).toBe(false); + const peers = { + kinds: { "studio-mac": "ssh" as const }, + connected: latest.peerConnected, + states: latest.peerStates, + }; + const [machine] = knownMachines(["studio-mac"], [], peers.kinds, [], peers.connected, peers.states); + expect(environmentReady(machine!)).toBe(false); + } finally { + f.client.close(); + } +}); + for (const [origin, supported] of [ ["peer", true], ["remote", true], From 1ffadb38d0de9504735b804ece387bc797430168 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 04:08:45 +0000 Subject: [PATCH 08/20] fix(webviews): a listed peer the peer snapshot omits reads not connected An authoritative _acpmux/peers snapshot replaced the peer maps with its own rows, so a peer the status read still lists lost its state and `connected` and the picker offered it as ready. Such a peer now reads not connected until a note or the next read says otherwise. Green: bun test src/agent-session/acpmux/peerStateEvents.test.ts: 11 pass, 0 fail. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-c35ebd1681cf79649972 --- webviews/src/agent-session/acpmux/direct.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/webviews/src/agent-session/acpmux/direct.ts b/webviews/src/agent-session/acpmux/direct.ts index d3f31f0bd8ad..f7ffe501210d 100644 --- a/webviews/src/agent-session/acpmux/direct.ts +++ b/webviews/src/agent-session/acpmux/direct.ts @@ -6828,16 +6828,23 @@ export class AcpmuxDirectClient { } private applyPeerStates(): void { const peers = [...this.peerEvents.entries()]; + // A peer the status read lists but an authoritative snapshot omits (removed since, or a row it + // could not read) is not connected: with no entry at all the picker would read it as ready. + const unlisted = this.peerEvents.authoritative + ? Object.fromEntries(this.peers.map((peer) => [peer, false] as const)) + : this.peerConnected; this.peerStates = { ...(this.peerEvents.authoritative ? {} : this.peerStates), ...Object.fromEntries(peers.map(([peer, link]) => [peer, link.state])), }; this.peerConnected = { - ...(this.peerEvents.authoritative ? {} : this.peerConnected), + ...unlisted, ...Object.fromEntries(peers.map(([peer, link]) => [peer, link.connected])), }; this.peerStatus = { - ...(this.peerEvents.authoritative ? {} : this.peerStatus), + ...(this.peerEvents.authoritative + ? Object.fromEntries(this.peers.map((peer) => [peer, { connected: false }])) + : this.peerStatus), ...Object.fromEntries(peers.map(([peer, link]) => [peer, { ...this.peerStatus[peer], ...link }])), }; } From afcea9e1a76c5da8b57036ae69b6add94762ac89 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 10:54:53 +0000 Subject: [PATCH 09/20] test(webviews): reproduce an omitted peer losing its last-seen status When the authoritative peer snapshot omits a peer the status read lists, its peerStatus becomes only { connected: false }, so the offline banner (chatUnavailable) loses lastSeen and attempts. Red: bun test src/agent-session/acpmux/peerStateEvents.test.ts: 10 pass, 1 fail. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-c35ebd1681cf79649972 --- webviews/src/agent-session/acpmux/peerStateEvents.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/webviews/src/agent-session/acpmux/peerStateEvents.test.ts b/webviews/src/agent-session/acpmux/peerStateEvents.test.ts index ff6f831ff037..412da91d9494 100644 --- a/webviews/src/agent-session/acpmux/peerStateEvents.test.ts +++ b/webviews/src/agent-session/acpmux/peerStateEvents.test.ts @@ -179,6 +179,7 @@ test("a peer the status lists but the snapshot omits is not offered as ready", a const { environmentReady, knownMachines } = await import("./environments"); const f = await fixture("local"); try { + f.notify(note("offline", { lastSeen: 42, error: "lost" })); // Removed between the `_acpmux/status` read and the `_acpmux/peers` read (or an invalid row). f.peers([]); const omitted = f.waitForState(undefined); @@ -195,6 +196,10 @@ test("a peer the status lists but the snapshot omits is not offered as ready", a }; const [machine] = knownMachines(["studio-mac"], [], peers.kinds, [], peers.connected, peers.states); expect(environmentReady(machine!)).toBe(false); + // The offline banner (chatUnavailable) still reads what the status read last said of it. + const status = (f.client as unknown as { peerStatus: Record }).peerStatus["studio-mac"]; + expect(status).toMatchObject({ connected: false, lastSeen: 42, error: "lost" }); + expect(status).not.toHaveProperty("state"); } finally { f.client.close(); } From 6ef5b1cd5a713d0cbf1d21537bc695103fe143a1 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 10:55:25 +0000 Subject: [PATCH 10/20] fix(webviews): an omitted peer keeps its last-seen status The authoritative-snapshot branch replaced an omitted peer's status with { connected: false } alone. It now keeps lastSeen, attempts and error from the last read and drops only the stale state, so the offline banner still says when the machine was last seen. Green: bun test src/agent-session/acpmux/peerStateEvents.test.ts: 11 pass, 0 fail. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-c35ebd1681cf79649972 --- webviews/src/agent-session/acpmux/direct.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/webviews/src/agent-session/acpmux/direct.ts b/webviews/src/agent-session/acpmux/direct.ts index d3f5cc3cbcdc..52dcd2bb88a0 100644 --- a/webviews/src/agent-session/acpmux/direct.ts +++ b/webviews/src/agent-session/acpmux/direct.ts @@ -6872,7 +6872,13 @@ export class AcpmuxDirectClient { }; this.peerStatus = { ...(this.peerEvents.authoritative - ? Object.fromEntries(this.peers.map((peer) => [peer, { connected: false }])) + ? Object.fromEntries( + this.peers.map((peer) => { + // Keeps what the status read last said (`lastSeen`, `attempts`) for the offline banner. + const { state: _state, ...seen } = this.peerStatus[peer] ?? {}; + return [peer, { ...seen, connected: false }]; + }), + ) : this.peerStatus), ...Object.fromEntries(peers.map(([peer, link]) => [peer, { ...this.peerStatus[peer], ...link }])), }; From 6e624f4c410a1628b1286b8bb646eaba4167b47a Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 13:13:08 +0000 Subject: [PATCH 11/20] test(agent-pane-policy): reproduce _acpmux/peers missing from the Rust relay policy The web pane sends _acpmux/peers (zero parameters) and the macOS host admits it, but policy.json does not list it, so a Rust host refuses the request and its reply is not filtered. The cases also keep a numeric lastSeen, as the Swift .number shape does. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-c35ebd1681cf79649972 --- .../tests/cases/params.json | 24 ++++++++++ .../tests/cases/replies.json | 47 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/cmux-tui/crates/cmux-agent-pane-policy/tests/cases/params.json b/cmux-tui/crates/cmux-agent-pane-policy/tests/cases/params.json index 34fb4b622f19..e3d983970419 100644 --- a/cmux-tui/crates/cmux-agent-pane-policy/tests/cases/params.json +++ b/cmux-tui/crates/cmux-agent-pane-policy/tests/cases/params.json @@ -1949,5 +1949,29 @@ }, "mode_fields": null, "breaks": true + }, + { + "name": "_acpmux/peers every known param", + "frame": { + "jsonrpc": "2.0", + "id": 1, + "method": "_acpmux/peers", + "params": {} + }, + "mode_fields": null, + "breaks": false + }, + { + "name": "_acpmux/peers refuses any param", + "frame": { + "jsonrpc": "2.0", + "id": 1, + "method": "_acpmux/peers", + "params": { + "peer": "other" + } + }, + "mode_fields": null, + "breaks": true } ] diff --git a/cmux-tui/crates/cmux-agent-pane-policy/tests/cases/replies.json b/cmux-tui/crates/cmux-agent-pane-policy/tests/cases/replies.json index 46f32f2647f6..cce9b2615f32 100644 --- a/cmux-tui/crates/cmux-agent-pane-policy/tests/cases/replies.json +++ b/cmux-tui/crates/cmux-agent-pane-policy/tests/cases/replies.json @@ -160,5 +160,52 @@ ] } } + }, + { + "name": "peer snapshot keeps each peer's name, state, lastSeen and error, never its endpoint or a boolean lastSeen", + "method": "_acpmux/peers", + "page_id": "9", + "reply": { + "jsonrpc": "2.0", + "id": 42, + "result": { + "peers": [ + { + "name": "studio", + "url": "ssh://studio", + "token": "s", + "state": "restarting", + "lastSeen": 1793357781599, + "error": "daemon restarting", + "attempts": 2 + }, + { + "name": "box", + "state": "connected", + "lastSeen": true, + "error": null + } + ], + "token": "s" + } + }, + "expected": { + "jsonrpc": "2.0", + "id": 9, + "result": { + "peers": [ + { + "name": "studio", + "state": "restarting", + "lastSeen": 1793357781599, + "error": "daemon restarting" + }, + { + "name": "box", + "state": "connected" + } + ] + } + } } ] From a4b6b6ee352e036ccc47af7c81dbf480034af2b9 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 13:14:40 +0000 Subject: [PATCH 12/20] test(webviews): reproduce a stale restarting state and the snapshot on the first frame - A status read after a snapshot said `restarting` keeps it: a failed restart goes restarting -> reconnecting, both `reconnecting` on the wire, so no note says so and readPeers re-applies the snapshot. - connect awaits _acpmux/peers before the first frame, a second round trip after the one #27755 keeps it to (a held snapshot holds connect). - chatUnavailable calls a restarting peer offline instead of connecting. The initial snapshot is settled in the fixture, and the in-flight status test now races a real in-flight read. Red: bun test src/agent-session/acpmux/peerStateEvents.test.ts: 11 pass, 2 fail; bun test test/chat-unavailable.test.ts: 1 fail. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-c35ebd1681cf79649972 --- .../acpmux/peerStateEvents.test.ts | 64 +++++++++++++++++-- webviews/test/chat-unavailable.test.ts | 4 ++ 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/webviews/src/agent-session/acpmux/peerStateEvents.test.ts b/webviews/src/agent-session/acpmux/peerStateEvents.test.ts index 412da91d9494..f42feec7048b 100644 --- a/webviews/src/agent-session/acpmux/peerStateEvents.test.ts +++ b/webviews/src/agent-session/acpmux/peerStateEvents.test.ts @@ -15,12 +15,18 @@ afterAll(() => { }); type Peer = { name: string; state: string; lastSeen?: number | null; error?: string | null }; -async function fixture(origin: string | undefined, supported = true) { +async function fixture(origin: string | undefined, supported = true, holdPeers = false) { const { AcpmuxDirectClient } = await import("./direct"); const { MockAcpmuxSocket, mockHost } = await import("./mock"); const requests: string[] = []; const snapshots: AcpmuxSnapshot[] = []; let peers: Peer[] = [{ name: "studio-mac", state: "connecting", lastSeen: null, error: null }]; + let status: Record[] = [ + { name: "studio-mac", transport: "ssh", connected: true, state: "connected", lastSeen: 99 }, + ]; + // A held `_acpmux/peers` answers only once released (`releasePeers`). + let held: (() => void) | undefined; + const peersHeld = holdPeers ? new Promise((resolve) => (held = resolve)) : undefined; let socket: InstanceType; let onSnapshot: ((snapshot: AcpmuxSnapshot) => void) | undefined; const openSocket = () => { @@ -31,9 +37,9 @@ async function fixture(origin: string | undefined, supported = true) { requests.push(method); if (method === "initialize") return { protocolVersion: 1, _meta: { acpmux: { origin, features: supported ? ["peerStateEvents"] : [] } } }; - if (method === "_acpmux/status") - return { peers: [{ name: "studio-mac", transport: "ssh", connected: true, state: "connected", lastSeen: 99 }] }; + if (method === "_acpmux/status") return { peers: status }; if (method === "_acpmux/peers") { + await peersHeld; return { peers }; } return answer(method, params); @@ -53,11 +59,12 @@ async function fixture(origin: string | undefined, supported = true) { socket.onmessage?.({ data: JSON.stringify({ jsonrpc: "2.0", method: "_acpmux/peer_state_changed", params: value }), }); - return { + const f = { client, socket: () => socket, waitForState: (state: string | undefined) => new Promise((resolve) => { + if (snapshots.length > 0 && snapshots.at(-1)!.peerStates?.["studio-mac"] === state) return resolve(); onSnapshot = (snapshot) => { if (snapshot.peerStates?.["studio-mac"] === state) { onSnapshot = undefined; @@ -72,7 +79,14 @@ async function fixture(origin: string | undefined, supported = true) { peers: (value: Peer[]) => { peers = value; }, + status: (value: Record[]) => { + status = value; + }, + releasePeers: () => held?.(), }; + // The initial snapshot lands after the first frame: settle it, unless the test holds it. + if (origin === "local" && supported && !holdPeers) await f.waitForState("connecting"); + return f; } const note = (state: string, extra: Record = {}) => ({ @@ -117,11 +131,12 @@ test("identical lag replay is idempotent and the next state still reaches the cl } }); -test("a later status refresh cannot erase a newer live state and never polls the event snapshot", async () => { +test("a status read in flight cannot erase a newer live state and never polls the event snapshot", async () => { const f = await fixture("local"); try { + const refreshed = f.client.refreshPeers(); f.notify(note("offline", { error: "removed" })); - await f.client.refreshPeers(); + await refreshed; expect(f.latest().peerStates?.["studio-mac"]).toBe("offline"); await f.client.refreshPeers(); expect(f.requests.filter((method) => method === "_acpmux/peers")).toHaveLength(1); @@ -205,6 +220,43 @@ test("a peer the status lists but the snapshot omits is not offered as ready", a } }); +test("the first frame does not wait on the peer snapshot", async () => { + // A held `_acpmux/peers` would hold `connect` if the snapshot sat on the first frame's path, + // after the one round trip #27755 keeps it to. + const f = await fixture("local", true, true); + try { + expect(f.requests).toContain("_acpmux/peers"); + expect(f.latest().peerStates?.["studio-mac"]).toBe("connected"); + const snapshot = f.waitForState("connecting"); + f.releasePeers(); + await snapshot; + } finally { + f.client.close(); + } +}); + +test("a later status read replaces a restarting state no note has updated since", async () => { + // A failed restart goes restarting -> reconnecting, both `reconnecting` on the wire, so the daemon + // sends no note; the next `_acpmux/status` read is what says so. + const f = await fixture("local"); + try { + f.peers([{ name: "studio-mac", state: "restarting", lastSeen: 5, error: null }]); + const restarting = f.waitForState("restarting"); + f.socket().onmessage?.({ + data: JSON.stringify({ jsonrpc: "2.0", method: "_acpmux/lagged", params: { dropped: 1 } }), + }); + await restarting; + f.status([ + { name: "studio-mac", transport: "ssh", connected: false, state: "reconnecting", lastSeen: 5, attempts: 1 }, + ]); + await f.client.refreshPeers(); + expect(f.latest().peerStates?.["studio-mac"]).toBe("reconnecting"); + expect(f.latest().peerConnected?.["studio-mac"]).toBe(false); + } finally { + f.client.close(); + } +}); + for (const [origin, supported] of [ ["peer", true], ["remote", true], diff --git a/webviews/test/chat-unavailable.test.ts b/webviews/test/chat-unavailable.test.ts index 30a553aa375a..65f821c04ad4 100644 --- a/webviews/test/chat-unavailable.test.ts +++ b/webviews/test/chat-unavailable.test.ts @@ -58,6 +58,10 @@ describe("chatUnavailable", () => { expect( chatUnavailable({ peers: { box: { connected: false, state: "reconnecting", attempts: 0 } }, waitedMs: 0 }).reason, ).toBe("connecting"); + // A peer whose daemon announced a restart (acpmux peer_link.rs `Restarting`) is on its way back. + expect( + chatUnavailable({ peers: { box: { connected: false, state: "restarting", attempts: 0 } }, waitedMs: 0 }).reason, + ).toBe("connecting"); }); test("a machine that is down names itself and when it was last seen", () => { From bdcd37109d1ae279dbc7a0ac11d90b46d4906e75 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 13:18:52 +0000 Subject: [PATCH 13/20] fix(agent-pane-policy): admit _acpmux/peers in the Rust relay policy The web pane sends _acpmux/peers and the macOS host admits it with a zero-parameter rule and a name/state/lastSeen/error reply filter. policy.json now does the same, so a Rust host no longer refuses the request. ReplyShape gains Number (a finite number, never a boolean), mirroring Swift's .number, so lastSeen survives the filter. Red at a4b6b6ee (cmux-ci, cargo test -p cmux-agent-pane-policy): parity replies FAILED, 15 passed, 1 failed. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-c35ebd1681cf79649972 --- cmux-tui/crates/cmux-agent-pane-policy/policy.json | 6 ++++-- cmux-tui/crates/cmux-agent-pane-policy/src/data.rs | 5 +++++ cmux-tui/crates/cmux-agent-pane-policy/src/reply.rs | 2 ++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/cmux-tui/crates/cmux-agent-pane-policy/policy.json b/cmux-tui/crates/cmux-agent-pane-policy/policy.json index 977918c3c3c7..b5f5de3aa65f 100644 --- a/cmux-tui/crates/cmux-agent-pane-policy/policy.json +++ b/cmux-tui/crates/cmux-agent-pane-policy/policy.json @@ -7,7 +7,7 @@ "_acpmux/watch", "_acpmux/sessions", "_acpmux/events", "_acpmux/attachment", "_acpmux/attach", "_acpmux/detach", "_acpmux/view", "_acpmux/typing", "_acpmux/warm", "_acpmux/peer_snapshot", "_acpmux/peer_reconnect", "_acpmux/retry", "_acpmux/kill", "_acpmux/prewarm", "_acpmux/harnesses", "_acpmux/models", "_acpmux/permission_respond", - "_acpmux/status", "_acpmux/draft_get", "_acpmux/draft_set", "_acpmux/bookmark_get", "_acpmux/bookmark_set", "_acpmux/btw", "_acpmux/queue_remove", "_acpmux/queue_edit", + "_acpmux/status", "_acpmux/peers", "_acpmux/draft_get", "_acpmux/draft_set", "_acpmux/bookmark_get", "_acpmux/bookmark_set", "_acpmux/btw", "_acpmux/queue_remove", "_acpmux/queue_edit", "_acpmux/handoff_prepare", "_acpmux/handoff_get", "_acpmux/handoff_draft", "_acpmux/handoff_start", "_acpmux/handoff_discard", "_acpmux/permission_groups", "_acpmux/permission_group_respond", "_acpmux/permission_chat_revoke", @@ -78,6 +78,7 @@ "_acpmux/harnesses": {"params": ["cwd"], "acpmux": []}, "_acpmux/models": {"params": ["refresh"], "acpmux": []}, "_acpmux/status": {"params": [], "acpmux": []}, + "_acpmux/peers": {"params": [], "acpmux": []}, "_acpmux/draft_get": {"params": ["sessionId"], "acpmux": []}, "_acpmux/draft_set": {"params": ["sessionId", "text"], "acpmux": []}, "_acpmux/bookmark_get": {"params": ["sessionId"], "acpmux": []}, @@ -125,7 +126,8 @@ }, "reply_shapes": { "_acpmux/status": {"object": {"peers": {"list": {"object": {"name": "string"}}}}}, - "_acpmux/peer_reconnect": {"object": {"peers": {"list": {"object": {"name": "string", "state": "string", "error": "string"}}}}} + "_acpmux/peer_reconnect": {"object": {"peers": {"list": {"object": {"name": "string", "state": "string", "error": "string"}}}}}, + "_acpmux/peers": {"object": {"peers": {"list": {"object": {"name": "string", "state": "string", "lastSeen": "number", "error": "string"}}}}} }, "history_replies": ["_acpmux/attach", "_acpmux/events"], "path_keys": ["cwd", "path", "additionalDirectories", "directory", "directories", "workingDirectory", diff --git a/cmux-tui/crates/cmux-agent-pane-policy/src/data.rs b/cmux-tui/crates/cmux-agent-pane-policy/src/data.rs index 4d97cdd8d503..0c8a74e2aa32 100644 --- a/cmux-tui/crates/cmux-agent-pane-policy/src/data.rs +++ b/cmux-tui/crates/cmux-agent-pane-policy/src/data.rs @@ -53,6 +53,8 @@ pub struct QuestionAnswers { #[derive(Clone, Debug, PartialEq)] pub enum ReplyShape { String, + /// A finite number, never a boolean (Swift's `.number`). + Number, Object(BTreeMap), List(Box), } @@ -62,6 +64,9 @@ impl ReplyShape { if v.as_str() == Some("string") { return Some(ReplyShape::String); } + if v.as_str() == Some("number") { + return Some(ReplyShape::Number); + } let o = v.as_object()?; if let Some(fields) = o.get("object").and_then(Value::as_object) { let fields = fields diff --git a/cmux-tui/crates/cmux-agent-pane-policy/src/reply.rs b/cmux-tui/crates/cmux-agent-pane-policy/src/reply.rs index 7a7cce68c89e..b77550f5147e 100644 --- a/cmux-tui/crates/cmux-agent-pane-policy/src/reply.rs +++ b/cmux-tui/crates/cmux-agent-pane-policy/src/reply.rs @@ -10,6 +10,8 @@ use serde_json::{Map, Value, json}; pub fn filtered(value: &Value, shape: &ReplyShape) -> Option { match shape { ReplyShape::String => value.as_str().map(|s| Value::String(s.to_owned())), + // serde_json keeps no NaN or infinity, and a boolean is not a number. + ReplyShape::Number => value.is_number().then(|| value.clone()), ReplyShape::List(item) => value .as_array() .map(|a| Value::Array(a.iter().filter_map(|v| filtered(v, item)).collect())), From 14c60b73d2d03686ab20b313ffe00c9d35f3a252 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 13:18:52 +0000 Subject: [PATCH 14/20] fix(webviews): a status read clears a stale restarting state; the snapshot leaves the first frame - A _acpmux/status read now drops every peer event entry no note has updated since the read started (PeerStateEvents.statusRead), so a failed restart that the daemon reports only as a state (no note) reads reconnecting instead of restarting forever. - connect starts the _acpmux/peers snapshot once the list's watch answered and draws it when it lands, instead of awaiting it before the first frame (#27755's one round trip). - chatUnavailable counts a restarting peer as still connecting. Green: bun test peerStateEvents, peerStateReducer, environments and test/chat-unavailable.test.ts: 28 pass, 0 fail. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-c35ebd1681cf79649972 --- .../agent-session/acpmux/chatUnavailable.ts | 7 ++-- webviews/src/agent-session/acpmux/direct.ts | 35 ++++++++++++------- .../agent-session/acpmux/peerStateEvents.ts | 9 +++++ 3 files changed, 35 insertions(+), 16 deletions(-) diff --git a/webviews/src/agent-session/acpmux/chatUnavailable.ts b/webviews/src/agent-session/acpmux/chatUnavailable.ts index a0cc9b1b0837..329814c97f28 100644 --- a/webviews/src/agent-session/acpmux/chatUnavailable.ts +++ b/webviews/src/agent-session/acpmux/chatUnavailable.ts @@ -15,7 +15,7 @@ /// One peer as `_acpmux/status` last said. export type PeerStatus = { connected?: boolean; - /// acpmux's link state: `connecting`, `connected`, `degraded`, `reconnecting` or `offline`. + /// acpmux's link state: `connecting`, `connected`, `degraded`, `reconnecting`, `restarting` or `offline`. state?: string; /// When the peer last answered, in milliseconds since the epoch. lastSeen?: number; @@ -64,10 +64,11 @@ function settled(status: PeerStatus): boolean { } /// A peer still on its way up: acpmux's first attempt (`connecting`), a socket whose handshake has -/// not finished (`degraded`), or a saved peer a restarted daemon has not tried yet. +/// not finished (`degraded`), a peer whose daemon announced a restart (`restarting`), or a saved +/// peer a restarted daemon has not tried yet. function stillConnecting(status: PeerStatus): boolean { if (settled(status)) return false; - if (status.state === "connecting" || status.state === "degraded") return true; + if (status.state === "connecting" || status.state === "degraded" || status.state === "restarting") return true; return status.state === "reconnecting" && (status.attempts ?? 0) === 0; } diff --git a/webviews/src/agent-session/acpmux/direct.ts b/webviews/src/agent-session/acpmux/direct.ts index 5d3ad94a6af3..c3178c5e1c9b 100644 --- a/webviews/src/agent-session/acpmux/direct.ts +++ b/webviews/src/agent-session/acpmux/direct.ts @@ -1829,15 +1829,17 @@ export class AcpmuxDirectClient { // acpmux answers a connection's requests side by side (server/mod.rs spawns each), so the // frame waits on one round trip rather than one per read (Leo, 2026-10-29: "as in i want // ours to load fast too :("). + const peersSince = this.peerEvents.revision; const peersRead = this.request("_acpmux/status", {}).catch(() => undefined); const listRead = this.sessionPages.first(); // Awaited below, after the machines: a list that fails meanwhile is not left unobserved. listRead.catch(() => undefined); early = this.askEarly(); - this.readPeers(await peersRead); + this.readPeers(await peersRead, peersSince); const first = await listRead; - // After the list's `_acpmux/watch` answered, so no peer note falls between the read and the watch. - if (this.peerEvents.supported) await this.refreshPeerStates().catch(() => undefined); + // After the list's `_acpmux/watch` answered, so no peer note falls between the read and the + // watch; not awaited, so the first frame keeps its one round trip (#27755). + if (this.peerEvents.supported) void this.refreshPeerStatesAndEmit(); let laterPages = first?.next ? first : undefined; const linkedLater = first !== undefined && @@ -2547,12 +2549,7 @@ export class AcpmuxDirectClient { private resyncAfterLag(params: any): void { // Peer state notices may be among the dropped ones: read the snapshot again, so a machine that // went down while this client lagged never shows as connected (no session needs to be open). - if (this.peerEvents.supported) - void this.refreshPeerStates() - .then(() => { - if (!this.closed && !this.snapshotHold) this.emit(); - }) - .catch(() => undefined); + if (this.peerEvents.supported) void this.refreshPeerStatesAndEmit(); // Needs you's asks: notices of new or answered requests may be lost, so every chat with open // requests is read again (after the session list, which drops the chats acpmux no longer lists). if (params?.watch === true) @@ -7077,17 +7074,26 @@ export class AcpmuxDirectClient { /// machine that connected or dropped since shows as it is now. Local/app peer events keep the /// closed control current when the daemon advertises them; older daemons keep their last snapshot. async refreshPeers(): Promise { + const since = this.peerEvents.revision; const status = await this.request("_acpmux/status", {}).catch(() => undefined); if (status === undefined) return; - this.readPeers(status); + this.readPeers(status, since); // A held snapshot (a chat being switched or reattached) draws the peers when it is released; // emitting now would record "connected" on the hold before setup says so. if (!this.snapshotHold) this.emit(); } - private async refreshPeerStates(): Promise { + private async refreshPeerStates(): Promise { const since = this.peerEvents.revision; const read = this.peerEvents.readStarted(); - if (this.peerEvents.snapshot(await this.request("_acpmux/peers", {}), since, read)) this.applyPeerStates(); + if (!this.peerEvents.snapshot(await this.request("_acpmux/peers", {}), since, read)) return false; + this.applyPeerStates(); + return true; + } + /// Reads the peer snapshot off the caller's path and draws it when it lands; a held snapshot (or a + /// connection still opening) draws it with its own emit. + private async refreshPeerStatesAndEmit(): Promise { + const applied = await this.refreshPeerStates().catch(() => false); + if (applied && !this.closed && !this.opening && !this.snapshotHold) this.emit(); } private applyPeerStates(): void { const peers = [...this.peerEvents.entries()]; @@ -7117,7 +7123,9 @@ export class AcpmuxDirectClient { ...Object.fromEntries(peers.map(([peer, link]) => [peer, { ...this.peerStatus[peer], ...link }])), }; } - private readPeers(status: any): void { + /// `since`: the peer event revision when the status read started; omitted for a reply that is not + /// a fresh read of every peer's state, which then never replaces an event entry. + private readPeers(status: any, since?: number): void { this.peers = Array.isArray(status?.peers) ? status.peers .map((peer: any) => (typeof peer?.name === "string" ? peer.name : undefined)) @@ -7127,6 +7135,7 @@ export class AcpmuxDirectClient { this.peerConnected = peerConnectedFromStatus(status); this.peerStates = peerStatesFromStatus(status); this.peerStatus = peerStatusFromStatus(status); + if (since !== undefined) this.peerEvents.statusRead(since); if (this.peerEvents.authoritative || this.peerEvents.revision > 0) this.applyPeerStates(); } /// Probes every agent's live model list again (`_acpmux/models {refresh: true}`): acpmux forgets diff --git a/webviews/src/agent-session/acpmux/peerStateEvents.ts b/webviews/src/agent-session/acpmux/peerStateEvents.ts index 0320e400ce61..bf0fef84e632 100644 --- a/webviews/src/agent-session/acpmux/peerStateEvents.ts +++ b/webviews/src/agent-session/acpmux/peerStateEvents.ts @@ -67,6 +67,15 @@ export class PeerStateEvents { return true; } + /// A `_acpmux/status` read that started at revision `since` landed: it is newer than every entry + /// no note has updated since, so those entries are dropped and the status read speaks for them. A + /// failed restart goes restarting -> reconnecting, both `reconnecting` on the wire, so the daemon + /// sends no note and only a status read says so. + statusRead(since: number): void { + for (const [name, entry] of this.links) if (entry.revision <= since) this.links.delete(name); + this.authoritative = false; + } + *entries(): IterableIterator<[string, Link]> { for (const [name, entry] of this.links) yield [name, entry.link]; } From 337df9b67690250ab730d27b3c2561b9f0f820a8 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 13:25:11 +0000 Subject: [PATCH 15/20] test(agent-pane-policy): _acpmux/peers passes; the escaped-name refusal samples another unlisted method frames.json sampled _acpmux/peers as a daemon method the pane never sends. The pane now sends it (policy.json admits it), so those two refusal cases move to _acpmux/peer_remove, which stays unlisted, and a pass case covers _acpmux/peers like _acpmux/status. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-c35ebd1681cf79649972 --- .../tests/cases/frames.json | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/cmux-tui/crates/cmux-agent-pane-policy/tests/cases/frames.json b/cmux-tui/crates/cmux-agent-pane-policy/tests/cases/frames.json index 475838e5dcf1..4ce37405ca87 100644 --- a/cmux-tui/crates/cmux-agent-pane-policy/tests/cases/frames.json +++ b/cmux-tui/crates/cmux-agent-pane-policy/tests/cases/frames.json @@ -446,6 +446,15 @@ "send": "unchanged" } }, + { + "name": "request _acpmux/peers passes", + "text": "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"_acpmux/peers\",\"params\":{}}", + "first": false, + "token": "abc", + "expect": { + "send": "unchanged" + } + }, { "name": "request _acpmux/warm passes", "text": "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"_acpmux/warm\",\"params\":{}}", @@ -643,24 +652,24 @@ } }, { - "name": "escaped '_acpmux/peers' refused", - "text": "{\"jsonrpc\":\"2.0\",\"id\":505,\"method\":\"\\u005f\\u0061\\u0063\\u0070\\u006d\\u0075\\u0078\\u002f\\u0070\\u0065\\u0065\\u0072\\u0073\",\"params\":{}}", + "name": "escaped '_acpmux/peer_remove' refused", + "text": "{\"jsonrpc\":\"2.0\",\"id\":505,\"method\":\"\\u005f\\u0061\\u0063\\u0070\\u006d\\u0075\\u0078\\u002f\\u0070\\u0065\\u0065\\u0072\\u005f\\u0072\\u0065\\u006d\\u006f\\u0076\\u0065\",\"params\":{}}", "first": false, "token": null, "expect": { "refuse": "transport.method_refused", - "method": "_acpmux/peers", + "method": "_acpmux/peer_remove", "id": "505" } }, { - "name": "notification '_acpmux/peers' refused", - "text": "{\"jsonrpc\":\"2.0\",\"method\":\"_acpmux/peers\",\"params\":{}}", + "name": "notification '_acpmux/peer_remove' refused", + "text": "{\"jsonrpc\":\"2.0\",\"method\":\"_acpmux/peer_remove\",\"params\":{}}", "first": false, "token": null, "expect": { "refuse": "transport.method_refused", - "method": "_acpmux/peers", + "method": "_acpmux/peer_remove", "id": null } }, From 815f25fb02d379e64112b5bbca132c0d3933e068 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 14:27:27 +0000 Subject: [PATCH 16/20] test(webviews): reproduce status and snapshot reads landing out of order The status-read fence and the snapshot fence are separate, so: - a snapshot that started before a status read, and landed after it, brings back the restarting state the status read cleared; - a status read that started before a snapshot, and landed after it, deletes the newer snapshot's state. Red: bun test src/agent-session/acpmux/peerStateEvents.test.ts: 13 pass, 2 fail. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-c35ebd1681cf79649972 --- .../acpmux/peerStateEvents.test.ts | 64 +++++++++++++++++-- 1 file changed, 58 insertions(+), 6 deletions(-) diff --git a/webviews/src/agent-session/acpmux/peerStateEvents.test.ts b/webviews/src/agent-session/acpmux/peerStateEvents.test.ts index f42feec7048b..f8824d0ad6dd 100644 --- a/webviews/src/agent-session/acpmux/peerStateEvents.test.ts +++ b/webviews/src/agent-session/acpmux/peerStateEvents.test.ts @@ -24,9 +24,18 @@ async function fixture(origin: string | undefined, supported = true, holdPeers = let status: Record[] = [ { name: "studio-mac", transport: "ssh", connected: true, state: "connected", lastSeen: 99 }, ]; - // A held `_acpmux/peers` answers only once released (`releasePeers`). - let held: (() => void) | undefined; - const peersHeld = holdPeers ? new Promise((resolve) => (held = resolve)) : undefined; + // A held read answers only once released, with what the daemon says then (`hold`, `release`). + const gates: Record; release: () => void } | undefined> = {}; + const hold = (method: string) => { + let release = () => {}; + const open = new Promise((resolve) => (release = resolve)); + gates[method] = { open, release }; + }; + const release = (method: string) => { + gates[method]?.release(); + gates[method] = undefined; + }; + if (holdPeers) hold("_acpmux/peers"); let socket: InstanceType; let onSnapshot: ((snapshot: AcpmuxSnapshot) => void) | undefined; const openSocket = () => { @@ -37,9 +46,12 @@ async function fixture(origin: string | undefined, supported = true, holdPeers = requests.push(method); if (method === "initialize") return { protocolVersion: 1, _meta: { acpmux: { origin, features: supported ? ["peerStateEvents"] : [] } } }; - if (method === "_acpmux/status") return { peers: status }; + if (method === "_acpmux/status") { + await gates[method]?.open; + return { peers: status }; + } if (method === "_acpmux/peers") { - await peersHeld; + await gates[method]?.open; return { peers }; } return answer(method, params); @@ -82,7 +94,13 @@ async function fixture(origin: string | undefined, supported = true, holdPeers = status: (value: Record[]) => { status = value; }, - releasePeers: () => held?.(), + releasePeers: () => release("_acpmux/peers"), + hold, + release, + /// What the client holds now, drawn or not. + peerStates: () => (client as unknown as { peerStates: Record }).peerStates, + /// The client's own `_acpmux/peers` read, awaited (what a lag notice or a reconnect starts). + readSnapshot: () => (client as unknown as { refreshPeerStates(): Promise }).refreshPeerStates(), }; // The initial snapshot lands after the first frame: settle it, unless the test holds it. if (origin === "local" && supported && !holdPeers) await f.waitForState("connecting"); @@ -257,6 +275,40 @@ test("a later status read replaces a restarting state no note has updated since" } }); +test("an older snapshot that lands after a newer status read never brings back its state", async () => { + const f = await fixture("local"); + try { + f.peers([{ name: "studio-mac", state: "restarting", lastSeen: 5, error: null }]); + f.hold("_acpmux/peers"); + const snapshot = f.readSnapshot(); + // Started after the snapshot and answered first: the restart failed meanwhile. + f.status([{ name: "studio-mac", transport: "ssh", connected: false, state: "reconnecting", lastSeen: 5 }]); + await f.client.refreshPeers(); + f.release("_acpmux/peers"); + await snapshot; + expect(f.peerStates()["studio-mac"]).toBe("reconnecting"); + } finally { + f.client.close(); + } +}); + +test("an older status read that lands after a newer snapshot never erases it", async () => { + const f = await fixture("local"); + try { + f.hold("_acpmux/status"); + const status = f.client.refreshPeers(); + // Started after the status read and answered first. + f.peers([{ name: "studio-mac", state: "restarting", lastSeen: 5, error: null }]); + await f.readSnapshot(); + f.status([{ name: "studio-mac", transport: "ssh", connected: false, state: "reconnecting", lastSeen: 5 }]); + f.release("_acpmux/status"); + await status; + expect(f.peerStates()["studio-mac"]).toBe("restarting"); + } finally { + f.client.close(); + } +}); + for (const [origin, supported] of [ ["peer", true], ["remote", true], From a01e4e5ff09ed7e06ef0add679b58c63b9552da0 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 14:28:11 +0000 Subject: [PATCH 17/20] fix(webviews): order status and snapshot reads on one clock PeerStateEvents now ticks one clock for notes and for the start of every read (statusStarted, readStarted). A snapshot that landed after a status read started later is dropped, and a status read drops only entries written before it started, so an older read of either kind can neither bring back a cleared restarting state nor erase a newer snapshot. A status read clears the snapshot's authority only when it is newer. The reducer tests take the read's tick in place of a separate revision. Green: bun test peerStateEvents, peerStateReducer, environments and test/chat-unavailable.test.ts: 30 pass, 0 fail. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-c35ebd1681cf79649972 --- webviews/src/agent-session/acpmux/direct.ts | 13 ++-- .../agent-session/acpmux/peerStateEvents.ts | 61 +++++++++++++------ .../acpmux/peerStateReducer.test.ts | 19 +++--- 3 files changed, 57 insertions(+), 36 deletions(-) diff --git a/webviews/src/agent-session/acpmux/direct.ts b/webviews/src/agent-session/acpmux/direct.ts index c3178c5e1c9b..ed0c6ed1ad5d 100644 --- a/webviews/src/agent-session/acpmux/direct.ts +++ b/webviews/src/agent-session/acpmux/direct.ts @@ -1829,7 +1829,7 @@ export class AcpmuxDirectClient { // acpmux answers a connection's requests side by side (server/mod.rs spawns each), so the // frame waits on one round trip rather than one per read (Leo, 2026-10-29: "as in i want // ours to load fast too :("). - const peersSince = this.peerEvents.revision; + const peersSince = this.peerEvents.statusStarted(); const peersRead = this.request("_acpmux/status", {}).catch(() => undefined); const listRead = this.sessionPages.first(); // Awaited below, after the machines: a list that fails meanwhile is not left unobserved. @@ -7074,7 +7074,7 @@ export class AcpmuxDirectClient { /// machine that connected or dropped since shows as it is now. Local/app peer events keep the /// closed control current when the daemon advertises them; older daemons keep their last snapshot. async refreshPeers(): Promise { - const since = this.peerEvents.revision; + const since = this.peerEvents.statusStarted(); const status = await this.request("_acpmux/status", {}).catch(() => undefined); if (status === undefined) return; this.readPeers(status, since); @@ -7083,9 +7083,8 @@ export class AcpmuxDirectClient { if (!this.snapshotHold) this.emit(); } private async refreshPeerStates(): Promise { - const since = this.peerEvents.revision; const read = this.peerEvents.readStarted(); - if (!this.peerEvents.snapshot(await this.request("_acpmux/peers", {}), since, read)) return false; + if (!this.peerEvents.snapshot(await this.request("_acpmux/peers", {}), read)) return false; this.applyPeerStates(); return true; } @@ -7123,8 +7122,8 @@ export class AcpmuxDirectClient { ...Object.fromEntries(peers.map(([peer, link]) => [peer, { ...this.peerStatus[peer], ...link }])), }; } - /// `since`: the peer event revision when the status read started; omitted for a reply that is not - /// a fresh read of every peer's state, which then never replaces an event entry. + /// `since`: the status read's tick (`PeerStateEvents.statusStarted`); omitted for a reply that is + /// not a fresh read of every peer's state, which then never replaces an event entry. private readPeers(status: any, since?: number): void { this.peers = Array.isArray(status?.peers) ? status.peers @@ -7136,7 +7135,7 @@ export class AcpmuxDirectClient { this.peerStates = peerStatesFromStatus(status); this.peerStatus = peerStatusFromStatus(status); if (since !== undefined) this.peerEvents.statusRead(since); - if (this.peerEvents.authoritative || this.peerEvents.revision > 0) this.applyPeerStates(); + if (this.peerEvents.observed) this.applyPeerStates(); } /// Probes every agent's live model list again (`_acpmux/models {refresh: true}`): acpmux forgets /// the lists it has and asks each harness before it answers (the picker's Refresh models). diff --git a/webviews/src/agent-session/acpmux/peerStateEvents.ts b/webviews/src/agent-session/acpmux/peerStateEvents.ts index bf0fef84e632..b36cdae555e0 100644 --- a/webviews/src/agent-session/acpmux/peerStateEvents.ts +++ b/webviews/src/agent-session/acpmux/peerStateEvents.ts @@ -16,8 +16,15 @@ const same = (a: Link | undefined, b: Link) => a?.state === b.state && a.lastSee export class PeerStateEvents { supported = false; authoritative = false; - revision = 0; - private reads = 0; + /// One clock for notes and for the start of every read, status and snapshot alike: an entry keeps + /// the tick it was written at, and a read that lands is ordered against the others by the tick it + /// started at, so neither kind of read brings back or erases what a newer one said. + private clock = 0; + /// The newest snapshot read started; only that one may land. + private latestRead = 0; + /// The start of the newest status read and of the newest snapshot that landed. + private statusLanded = 0; + private snapshotLanded = 0; private links = new Map(); configure(initialized: unknown): void { @@ -25,9 +32,16 @@ export class PeerStateEvents { this.supported = acpmux.origin === "local" && Array.isArray(acpmux.features) && acpmux.features.includes("peerStateEvents"); this.authoritative = false; - this.revision = 0; this.links.clear(); - this.reads += 1; + // The clock never goes back, so no read from before reads as one from this connection. + this.latestRead = 0; + this.statusLanded = this.clock; + this.snapshotLanded = this.clock; + } + + /// Whether anything here overlays a status read. + get observed(): boolean { + return this.authoritative || this.links.size > 0; } changed(value: unknown): boolean { @@ -44,36 +58,49 @@ export class PeerStateEvents { return false; const previous = this.links.get(note.peer)?.link; // Even a repeated note fences an in-flight stale read, without redrawing identical content. - this.links.set(note.peer, { link, revision: ++this.revision }); + this.links.set(note.peer, { link, revision: ++this.clock }); return !same(previous, link); } + /// A `_acpmux/peers` read starts: its tick, which `snapshot` takes when it lands. readStarted(): number { - return ++this.reads; + this.latestRead = ++this.clock; + return this.latestRead; + } + + /// A `_acpmux/status` read starts: its tick, which `statusRead` takes when it lands. + statusStarted(): number { + return ++this.clock; } - snapshot(value: unknown, since: number, read = this.reads): boolean { + /// The snapshot read started at `start` landed. A newer snapshot read, or a status read that + /// started later and already landed, says more than it does: it is dropped. Entries written after + /// it started (a note, a newer status read's) stay. + snapshot(value: unknown, start = this.latestRead): boolean { const peers = object(value).peers; - if (!this.supported || read !== this.reads || !Array.isArray(peers)) return false; + if (!this.supported || start !== this.latestRead || start < this.statusLanded || !Array.isArray(peers)) + return false; const next = new Map(); for (const value of peers) { const peer = object(value); const link = observation(peer); - if (typeof peer.name === "string" && peer.name && link) next.set(peer.name, { link, revision: since }); + if (typeof peer.name === "string" && peer.name && link) next.set(peer.name, { link, revision: start }); } - for (const [name, entry] of this.links) if (entry.revision > since) next.set(name, entry); + for (const [name, entry] of this.links) if (entry.revision > start) next.set(name, entry); this.links = next; + this.snapshotLanded = start; this.authoritative = true; return true; } - /// A `_acpmux/status` read that started at revision `since` landed: it is newer than every entry - /// no note has updated since, so those entries are dropped and the status read speaks for them. A - /// failed restart goes restarting -> reconnecting, both `reconnecting` on the wire, so the daemon - /// sends no note and only a status read says so. - statusRead(since: number): void { - for (const [name, entry] of this.links) if (entry.revision <= since) this.links.delete(name); - this.authoritative = false; + /// The status read started at `start` landed: it is newer than every entry written before it + /// started, so those are dropped and the status read speaks for them. A failed restart goes + /// restarting -> reconnecting, both `reconnecting` on the wire, so the daemon sends no note and + /// only a status read says so. A snapshot that started after it stays the authority. + statusRead(start: number): void { + this.statusLanded = Math.max(this.statusLanded, start); + for (const [name, entry] of this.links) if (entry.revision < start) this.links.delete(name); + if (start > this.snapshotLanded) this.authoritative = false; } *entries(): IterableIterator<[string, Link]> { diff --git a/webviews/src/agent-session/acpmux/peerStateReducer.test.ts b/webviews/src/agent-session/acpmux/peerStateReducer.test.ts index 8980528142cb..2595d545bbdc 100644 --- a/webviews/src/agent-session/acpmux/peerStateReducer.test.ts +++ b/webviews/src/agent-session/acpmux/peerStateReducer.test.ts @@ -36,10 +36,9 @@ test("nullable and absent metadata clear a prior observation when the consumer o test("duplicate replay does not redraw but still fences an in-flight stale snapshot", () => { const events = fixture(); expect(events.changed(note("offline", { error: "removed" }))).toBe(true); - const since = events.revision; const read = events.readStarted(); expect(events.changed(note("offline", { error: "removed", at: 20 }))).toBe(false); - events.snapshot({ peers: [{ name: "studio-mac", state: "connected" }] }, since, read); + events.snapshot({ peers: [{ name: "studio-mac", state: "connected" }] }, read); expect(peer(events)).toMatchObject({ state: "offline", error: "removed" }); expect(events.changed(note("offline", { error: "different" }))).toBe(true); expect(events.changed(note("offline", { error: "different", lastSeen: 5 }))).toBe(true); @@ -49,11 +48,11 @@ test("a reconnect and a later read both invalidate the older read", () => { const events = fixture(); const old = events.readStarted(); events.configure(initialized()); - expect(events.snapshot({ peers: [{ name: "studio-mac", state: "offline" }] }, 0, old)).toBe(false); + expect(events.snapshot({ peers: [{ name: "studio-mac", state: "offline" }] }, old)).toBe(false); const first = events.readStarted(); const second = events.readStarted(); - expect(events.snapshot({ peers: [{ name: "studio-mac", state: "connected" }] }, 0, second)).toBe(true); - expect(events.snapshot({ peers: [{ name: "studio-mac", state: "offline" }] }, 0, first)).toBe(false); + expect(events.snapshot({ peers: [{ name: "studio-mac", state: "connected" }] }, second)).toBe(true); + expect(events.snapshot({ peers: [{ name: "studio-mac", state: "offline" }] }, first)).toBe(false); expect(peer(events)?.state).toBe("connected"); }); @@ -79,9 +78,8 @@ test("invalid notes cannot replace the last daemon observation", () => { test("a complete empty snapshot removes stale state without inventing an offline transition", () => { const events = fixture(); events.changed(note("connected")); - const since = events.revision; const read = events.readStarted(); - expect(events.snapshot({ peers: [] }, since, read)).toBe(true); + expect(events.snapshot({ peers: [] }, read)).toBe(true); expect([...events.entries()]).toEqual([]); expect(events.authoritative).toBe(true); }); @@ -91,7 +89,7 @@ test("a new connection has no inherited capability while initialize is pending", const read = events.readStarted(); events.configure(undefined); expect(events.changed(note("offline"))).toBe(false); - expect(events.snapshot({ peers: [{ name: "studio-mac", state: "offline" }] }, 0, read)).toBe(false); + expect(events.snapshot({ peers: [{ name: "studio-mac", state: "offline" }] }, read)).toBe(false); expect(events.supported).toBe(false); expect([...events.entries()]).toEqual([]); }); @@ -101,10 +99,7 @@ test("a snapshot keeps a restarting peer marked restarting and not connected", ( // the other end restarts; dropping the row would leave the picker no state and read it as ready. const events = fixture(); events.changed(note("connected")); - const since = events.revision; const read = events.readStarted(); - expect(events.snapshot({ peers: [{ name: "studio-mac", state: "restarting", lastSeen: 7 }] }, since, read)).toBe( - true, - ); + expect(events.snapshot({ peers: [{ name: "studio-mac", state: "restarting", lastSeen: 7 }] }, read)).toBe(true); expect(peer(events)).toMatchObject({ state: "restarting", connected: false, lastSeen: 7 }); }); From 820e2c7992d9daee198af457a33ad51f89eb9ead Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 16:09:30 +0000 Subject: [PATCH 18/20] fix(webviews): a replaced socket's replies still land; only its peer notes drop The stale-socket guard dropped every message from a socket the client had replaced, so a request asked on it never settled: the export that watches for a socket swap waited forever instead of rejecting (conversation/exportClient.test.ts timed out on this branch and passes on base). Only _acpmux/peer_state_changed from a replaced socket (or after close) is dropped now; everything else is received as on base. Red: bun test ./src/agent-session/acpmux/conversation/exportClient.test.ts times out at a01e4e5f (CI react-apps-check shard 1: TIMEOUT twice, passes replayed on base ad6de25e). Green: 3 pass; peerStateEvents 15, reducer 8, chat-unavailable 7 pass. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-c35ebd1681cf79649972 --- webviews/src/agent-session/acpmux/direct.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/webviews/src/agent-session/acpmux/direct.ts b/webviews/src/agent-session/acpmux/direct.ts index ed0c6ed1ad5d..d3b6779992fc 100644 --- a/webviews/src/agent-session/acpmux/direct.ts +++ b/webviews/src/agent-session/acpmux/direct.ts @@ -1789,10 +1789,10 @@ export class AcpmuxDirectClient { onLost(); } else this.scheduleReconnect(); }; - socket.onmessage = (message) => { - if (this.socket === socket && !this.closed) - acpmuxPerf.timed("client.receive", () => this.receive(String(message.data))); - }; + // A socket this client replaced still answers what was asked on it (an export that watches for + // the swap rejects on that answer); only its peer notes, about a connection that is gone, drop. + socket.onmessage = (message) => + acpmuxPerf.timed("client.receive", () => this.receive(String(message.data), this.socket !== socket)); }).catch((error: unknown) => { this.releaseCopyAttach(mine); throw error; @@ -2453,7 +2453,7 @@ export class AcpmuxDirectClient { }, delay); } - private receive(raw: string): void { + private receive(raw: string, stale = false): void { this.wire.received(raw); let message: Reply | Notification; try { @@ -2491,7 +2491,7 @@ export class AcpmuxDirectClient { } const notification = message as Notification; if (notification.method === "_acpmux/peer_state_changed") { - if (this.peerEvents.changed(notification.params)) { + if (!stale && !this.closed && this.peerEvents.changed(notification.params)) { this.applyPeerStates(); if (!this.snapshotHold) this.emit(); } From 07b0e47a7b8678e6ff559737e88791f54f086fe4 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 20:37:58 +0000 Subject: [PATCH 19/20] test(webviews): reproduce an older status read overwriting a newer one readPeers writes each _acpmux/status reply into peers, peerConnected, peerStates and peerStatus before the clock fence runs, so of two refreshPeers calls the one asked first and answered last wins. The fixture's status read now answers with what the daemon knew when the read reached it. Red: bun test ./src/agent-session/acpmux/peerStateEvents.test.ts: 15 pass, 1 fail. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-c35ebd1681cf79649972 --- .../acpmux/peerStateEvents.test.ts | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/webviews/src/agent-session/acpmux/peerStateEvents.test.ts b/webviews/src/agent-session/acpmux/peerStateEvents.test.ts index f8824d0ad6dd..30d1799267d1 100644 --- a/webviews/src/agent-session/acpmux/peerStateEvents.test.ts +++ b/webviews/src/agent-session/acpmux/peerStateEvents.test.ts @@ -30,11 +30,16 @@ async function fixture(origin: string | undefined, supported = true, holdPeers = let release = () => {}; const open = new Promise((resolve) => (release = resolve)); gates[method] = { open, release }; + return release; }; const release = (method: string) => { gates[method]?.release(); gates[method] = undefined; }; + /// Later reads of `method` answer at once; the ones already held stay held. + const free = (method: string) => { + gates[method] = undefined; + }; if (holdPeers) hold("_acpmux/peers"); let socket: InstanceType; let onSnapshot: ((snapshot: AcpmuxSnapshot) => void) | undefined; @@ -47,8 +52,10 @@ async function fixture(origin: string | undefined, supported = true, holdPeers = if (method === "initialize") return { protocolVersion: 1, _meta: { acpmux: { origin, features: supported ? ["peerStateEvents"] : [] } } }; if (method === "_acpmux/status") { + // The daemon answers with what it knew when the read reached it. + const said = status; await gates[method]?.open; - return { peers: status }; + return { peers: said }; } if (method === "_acpmux/peers") { await gates[method]?.open; @@ -97,6 +104,9 @@ async function fixture(origin: string | undefined, supported = true, holdPeers = releasePeers: () => release("_acpmux/peers"), hold, release, + free, + peerConnected: () => (client as unknown as { peerConnected: Record }).peerConnected, + peerStatus: () => (client as unknown as { peerStatus: Record> }).peerStatus, /// What the client holds now, drawn or not. peerStates: () => (client as unknown as { peerStates: Record }).peerStates, /// The client's own `_acpmux/peers` read, awaited (what a lag notice or a reconnect starts). @@ -309,6 +319,26 @@ test("an older status read that lands after a newer snapshot never erases it", a } }); +test("an older status read that lands after a newer one never overwrites it", async () => { + const f = await fixture("local"); + try { + f.status([{ name: "studio-mac", transport: "ssh", connected: false, state: "offline", lastSeen: 1 }]); + const resume = f.hold("_acpmux/status"); + const older = f.client.refreshPeers(); + f.free("_acpmux/status"); + // Asked later, answered first: the machine came back. + f.status([{ name: "studio-mac", transport: "ssh", connected: true, state: "connected", lastSeen: 50 }]); + await f.client.refreshPeers(); + resume(); + await older; + expect(f.peerStates()["studio-mac"]).toBe("connected"); + expect(f.peerConnected()["studio-mac"]).toBe(true); + expect(f.peerStatus()["studio-mac"]).toMatchObject({ connected: true, state: "connected", lastSeen: 50 }); + } finally { + f.client.close(); + } +}); + for (const [origin, supported] of [ ["peer", true], ["remote", true], From 886353b403e82d9713da89e7148bdf2e336c133f Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 20:38:09 +0000 Subject: [PATCH 20/20] fix(webviews): an older status read that lands late is dropped whole statusRead now says whether the read is newer than every status read that already landed (and than this connection); readPeers drops one that is not before it writes peers, peerConnected, peerStates or peerStatus, so two refreshPeers calls answered out of order keep the newer answer. Green: peerStateEvents 16, peerStateReducer 8, chat-unavailable 7, exportClient 3, chatOpenTrips 3, agent-pane-peer-copy 55, agent-pane-cached-chat-open 10 pass. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-c35ebd1681cf79649972 --- webviews/src/agent-session/acpmux/direct.ts | 3 ++- .../src/agent-session/acpmux/peerStateEvents.ts | 16 ++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/webviews/src/agent-session/acpmux/direct.ts b/webviews/src/agent-session/acpmux/direct.ts index 3e939d3a4a2b..c88d9275e2cf 100644 --- a/webviews/src/agent-session/acpmux/direct.ts +++ b/webviews/src/agent-session/acpmux/direct.ts @@ -7501,6 +7501,8 @@ export class AcpmuxDirectClient { /// `since`: the status read's tick (`PeerStateEvents.statusStarted`); omitted for a reply that is /// not a fresh read of every peer's state, which then never replaces an event entry. private readPeers(status: any, since?: number): void { + // Asked before a status read that already landed: it would put back what that one replaced. + if (since !== undefined && !this.peerEvents.statusRead(since)) return; this.peers = Array.isArray(status?.peers) ? status.peers .map((peer: any) => (typeof peer?.name === "string" ? peer.name : undefined)) @@ -7511,7 +7513,6 @@ export class AcpmuxDirectClient { this.peerStates = peerStatesFromStatus(status); this.peerResources = peerResourcesFromStatus(status); this.peerStatus = peerStatusFromStatus(status); - if (since !== undefined) this.peerEvents.statusRead(since); if (this.peerEvents.observed) this.applyPeerStates(); } /// Probes every agent's live model list again (`_acpmux/models {refresh: true}`): acpmux forgets diff --git a/webviews/src/agent-session/acpmux/peerStateEvents.ts b/webviews/src/agent-session/acpmux/peerStateEvents.ts index b36cdae555e0..d642dc6e9b81 100644 --- a/webviews/src/agent-session/acpmux/peerStateEvents.ts +++ b/webviews/src/agent-session/acpmux/peerStateEvents.ts @@ -93,14 +93,18 @@ export class PeerStateEvents { return true; } - /// The status read started at `start` landed: it is newer than every entry written before it - /// started, so those are dropped and the status read speaks for them. A failed restart goes - /// restarting -> reconnecting, both `reconnecting` on the wire, so the daemon sends no note and - /// only a status read says so. A snapshot that started after it stays the authority. - statusRead(start: number): void { - this.statusLanded = Math.max(this.statusLanded, start); + /// The status read started at `start` landed. One older than a status read that already landed + /// (or than this connection) says nothing new: false, and the caller drops it whole. Otherwise it + /// is newer than every entry written before it started, so those are dropped and the status read + /// speaks for them. A failed restart goes restarting -> reconnecting, both `reconnecting` on the + /// wire, so the daemon sends no note and only a status read says so. A snapshot that started after + /// it stays the authority. + statusRead(start: number): boolean { + if (start <= this.statusLanded) return false; + this.statusLanded = start; for (const [name, entry] of this.links) if (entry.revision < start) this.links.delete(name); if (start > this.snapshotLanded) this.authoritative = false; + return true; } *entries(): IterableIterator<[string, Link]> {