From 17708087bb0a20a8de5af9131b1689d8e2e7c9fa Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:00:43 -0400 Subject: [PATCH 01/53] fix(web): bound initial chat hydration --- .../adapter/__tests__/adapter.test.ts | 94 +++++++++++++++++++ .../renderer/webclient/adapter/agentChat.ts | 45 ++++++--- 2 files changed, 124 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts b/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts index 92c6d2231..dc04e3bd6 100644 --- a/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts +++ b/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts @@ -250,6 +250,100 @@ describe("createAdeWebAdapter", () => { adapter.dispose(); }); + it("bounds initial web chat hydration while preserving paged scroll-back", async () => { + fake.descriptors = descriptors(["chat.getChatEventHistory"]); + fake.commandResults.set("chat.getChatEventHistory", { + sessionId: "chat-long-running", + events: [], + truncated: true, + sessionFound: true, + tailStartOffset: 4096, + }); + const adapter = createAdeWebAdapter(fake.asClient()); + adapter.bindProject(project, "project-1"); + + await expect(adapter.ade.agentChat.getEventHistory({ + sessionId: "chat-long-running", + maxEvents: 20_000, + })).resolves.toMatchObject({ + sessionId: "chat-long-running", + truncated: true, + tailStartOffset: 4096, + }); + + expect(fake.chatSubscribeCalls).toEqual([{ + sessionId: "chat-long-running", + opts: { maxBytes: 128 * 1024 }, + }]); + expect(fake.commandCalls).toEqual([{ + action: "chat.getChatEventHistory", + args: { + sessionId: "chat-long-running", + maxEvents: 512, + maxBytes: 128 * 1024, + }, + opts: { projectId: "project-1", timeoutMs: undefined }, + }]); + + adapter.dispose(); + }); + + it("does not subscribe every chat returned by a session-list read", async () => { + fake.descriptors = descriptors([ + "chat.listSessions", + "chat.getSummary", + "chat.create", + "chat.launch", + "chat.send", + ]); + fake.commandResults.set("chat.listSessions", [ + { sessionId: "chat-background-1" }, + { sessionId: "chat-background-2" }, + { sessionId: "chat-background-3" }, + ]); + fake.commandResults.set("chat.getSummary", { sessionId: "chat-selected" }); + fake.commandResults.set("chat.create", { sessionId: "chat-created" }); + fake.commandResults.set("chat.launch", { sessionId: "chat-launched" }); + const adapter = createAdeWebAdapter(fake.asClient()); + adapter.bindProject(project, "project-1"); + + await adapter.ade.agentChat.list({ laneId: "lane-1" }); + expect(fake.chatSubscribeCalls).toEqual([]); + + await adapter.ade.agentChat.getSummary({ sessionId: "chat-selected" }); + await adapter.ade.agentChat.create({} as never); + await adapter.ade.agentChat.launch({} as never); + await adapter.ade.agentChat.send({ sessionId: "chat-sent", text: "Continue" }); + + expect(fake.chatSubscribeCalls.map((call) => call.sessionId)).toEqual([ + "chat-selected", + "chat-created", + "chat-launched", + "chat-sent", + ]); + + adapter.dispose(); + }); + + it("does not subscribe background chats after chat-table invalidation", async () => { + vi.useFakeTimers(); + fake.descriptors = descriptors(["chat.listSessions"]); + fake.commandResults.set("chat.listSessions", [ + { sessionId: "chat-background-1" }, + { sessionId: "chat-background-2" }, + ]); + const adapter = createAdeWebAdapter(fake.asClient()); + adapter.bindProject(project, "project-1"); + + fake.emitTables(["agent_chats"]); + await vi.advanceTimersByTimeAsync(260); + + expect(fake.commandCalls).toEqual([]); + expect(fake.chatSubscribeCalls).toEqual([]); + + adapter.dispose(); + }); + it("keeps the last successful read through a transport outage without caching it as fresh", async () => { vi.useFakeTimers(); fake.descriptors = descriptors(["lanes.list"]); diff --git a/apps/desktop/src/renderer/webclient/adapter/agentChat.ts b/apps/desktop/src/renderer/webclient/adapter/agentChat.ts index ac773d48e..60e669e22 100644 --- a/apps/desktop/src/renderer/webclient/adapter/agentChat.ts +++ b/apps/desktop/src/renderer/webclient/adapter/agentChat.ts @@ -10,6 +10,15 @@ import { deriveSmartLinkPreview } from "../../../shared/smartLinks"; import type { AdapterInfra, AdeNamespace } from "./types"; import { requestDataUrl, requestFileBlob } from "./infra/fileBlob"; +// The browser gets the current chat tail through both chat_subscribe and +// chat.getChatEventHistory. Keep each initial payload small: remote hosts +// serialize those responses ahead of later summary/model commands. Older +// history remains available through getChatEventHistoryPage when the user +// scrolls back. +const WEB_CHAT_INITIAL_SNAPSHOT_MAX_BYTES = 128 * 1024; +const WEB_CHAT_INITIAL_HISTORY_MAX_EVENTS = 512; +const WEB_CHAT_INITIAL_HISTORY_MAX_BYTES = 128 * 1024; + export function createAgentChatNamespace(infra: AdapterInfra): AdeNamespace<"agentChat"> { const { client, commands, events, terminalRegistry } = infra; const chatSubscriptions = new Map void>(); @@ -34,7 +43,7 @@ export function createAgentChatNamespace(infra: AdapterInfra): AdeNamespace<"age if (!sessionId || chatSubscriptions.has(sessionId)) return; const unsubscribe = client.subscribeChat( sessionId, - { maxBytes: 1024 * 1024 }, + { maxBytes: WEB_CHAT_INITIAL_SNAPSHOT_MAX_BYTES }, { snapshot: (payload) => { for (const event of payload.events) emitChatEvent(event as SyncChatEventPayload); @@ -64,16 +73,6 @@ export function createAgentChatNamespace(infra: AdapterInfra): AdeNamespace<"age emitChatEvent(payload); })); - infra.addDispose( - events.on("chatsInvalidated", async () => { - const sessions = await commands.call("chat.listSessions", {}, { - fallback: [], - idempotent: true, - }); - ensureFromResult(sessions); - }) - ); - infra.addDispose(() => { for (const unsubscribe of chatSubscriptions.values()) unsubscribe(); chatSubscriptions.clear(); @@ -94,9 +93,10 @@ export function createAgentChatNamespace(infra: AdapterInfra): AdeNamespace<"age const agentChat: Record = { list: async (args?: unknown) => { - const result = await call("chat.listSessions", args, []); - ensureFromResult(result); - return result; + // Session lists drive UI metadata only. Subscribing every result eagerly + // replays each transcript tail and can block the selected chat's + // hydration behind background sessions. + return await call("chat.listSessions", args, []); }, getSummary: async (args: unknown) => { const result = await call("chat.getSummary", args, null); @@ -239,7 +239,17 @@ export function createAgentChatNamespace(infra: AdapterInfra): AdeNamespace<"age ensureChatSubscription(stringField(record, "sessionId")); return await call( "chat.getChatEventHistory", - args, + { + ...record, + maxEvents: boundedPositiveInteger( + record.maxEvents, + WEB_CHAT_INITIAL_HISTORY_MAX_EVENTS, + ), + maxBytes: boundedPositiveInteger( + record.maxBytes, + WEB_CHAT_INITIAL_HISTORY_MAX_BYTES, + ), + }, { sessionId: stringField(record, "sessionId"), events: [], @@ -273,6 +283,11 @@ export function createAgentChatNamespace(infra: AdapterInfra): AdeNamespace<"age return agentChat as AdeNamespace<"agentChat">; } +function boundedPositiveInteger(value: unknown, maximum: number): number { + if (typeof value !== "number" || !Number.isFinite(value)) return maximum; + return Math.max(1, Math.min(maximum, Math.floor(value))); +} + function asRecord(args: unknown): Record { return args && typeof args === "object" ? (args as Record) : {}; } From ac30fe71427860ce521b9c2a106563bc56de0562 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:12:54 -0400 Subject: [PATCH 02/53] fix(web): request invalidation-only sync --- .../webclient/sync/__tests__/sync.test.ts | 48 +++++++++++++++++++ .../src/renderer/webclient/sync/connection.ts | 30 +++++++++++- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts b/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts index 26487f887..d7c582c03 100644 --- a/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts +++ b/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts @@ -12,6 +12,7 @@ import { AdeSyncClient } from "../client"; import { BACKOFF_STABLE_CONNECTED_MS, RELAY_READY_NEGOTIATION_WINDOW_MS, + SYNC_INVALIDATION_ONLY_V1_CAPABILITY, SyncConnection, type WebSocketLike, } from "../connection"; @@ -1958,6 +1959,53 @@ describe("browser sync connection and client", () => { client.dispose(); }); + it("uses invalidation-only sync without suppressing live changeset hints", async () => { + const environment = await makeEnvironment(new MemoryStorage(), { dpopPublicKeyX963: null }); + const script = createSocketFactory((socket, envelope) => { + if (envelope.type === "hello") { + socket.serverSend({ type: "hello_ok", requestId: envelope.requestId, payload: helloOk() }); + } + }); + const connection = new SyncConnection({ socketFactory: script.factory, document: null }); + const invalidations: string[][] = []; + connection.on("tablesChanged", (tables) => invalidations.push([...tables].sort())); + const endpoint = { url: "ws://127.0.0.1:8787", kind: "loopback" as const, dialable: true }; + + await connection.connect(environment, [endpoint]); + const hello = script.sockets[0]?.sent.find((envelope) => envelope.type === "hello"); + expect((hello?.payload as { peer?: SyncPeerMetadata }).peer?.capabilities).toContain( + SYNC_INVALIDATION_ONLY_V1_CAPABILITY, + ); + expect(invalidations).toEqual([[ + "agent_chats", + "files", + "github", + "lanes", + "pull_requests", + "rebase", + "sessions", + ]]); + + script.sockets[0]?.serverSend({ + type: "changeset_batch", + payload: { + batchId: "live-1", + reason: "broadcast", + fromDbVersion: 12, + toDbVersion: 13, + changes: [{ table: "agent_chats" }], + }, + }); + await flushMicrotasks(); + expect(invalidations).toHaveLength(2); + expect(invalidations[1]).toEqual(["agent_chats"]); + + await connection.connect(environment, [endpoint]); + expect(invalidations).toHaveLength(3); + expect(invalidations[2]).toEqual(invalidations[0]); + connection.dispose(); + }); + it("lets a locally paired environment use Relay only after the account directory verifies its host", async () => { const storage = new MemoryStorage(); const environment = await makeEnvironment(storage, { accountOwnerUserId: null, addressCandidates: [] }); diff --git a/apps/desktop/src/renderer/webclient/sync/connection.ts b/apps/desktop/src/renderer/webclient/sync/connection.ts index e562f5ddd..a03602db9 100644 --- a/apps/desktop/src/renderer/webclient/sync/connection.ts +++ b/apps/desktop/src/renderer/webclient/sync/connection.ts @@ -49,6 +49,23 @@ const VISIBILITY_RECONNECT_DEBOUNCE_MS = 1_000; const RELAY_REAUTH_RESULT_TIMEOUT_MS = 4_000; const RELAY_REAUTH_RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000] as const; export const RELAY_READY_NEGOTIATION_WINDOW_MS = 750; +// This browser never applies CRDT rows locally: it uses remote commands and +// treats changesets only as cache-invalidation hints. A capable host can skip +// replaying its historical changeset backlog and begin this peer at its live +// DB version instead. +export const SYNC_INVALIDATION_ONLY_V1_CAPABILITY = "invalidationOnlyV1"; + +// Keep this as table-shaped names so the web adapter's existing invalidation +// scheduler maps one accepted hello to every UI domain it owns. +const FULL_INVALIDATION_TABLES = [ + "lanes", + "sessions", + "agent_chats", + "pull_requests", + "files", + "github", + "rebase", +] as const; export type WebSocketLike = { readonly readyState: number; @@ -620,9 +637,13 @@ export class SyncConnection { ...args.peer, capabilities: [ ...(args.peer.capabilities ?? []).filter( - (capability) => capability !== SYNC_RELAY_REAUTHORIZE_V1_CAPABILITY, + (capability) => ( + capability !== SYNC_RELAY_REAUTHORIZE_V1_CAPABILITY + && capability !== SYNC_INVALIDATION_ONLY_V1_CAPABILITY + ), ), SYNC_RELAY_REAUTHORIZE_V1_CAPABILITY, + SYNC_INVALIDATION_ONLY_V1_CAPABILITY, ], }, auth: { @@ -775,7 +796,10 @@ export class SyncConnection { deviceType: "browser" as SyncPeerMetadata["deviceType"], siteId: environment.siteId, dbVersion: 0, - capabilities: [SYNC_RELAY_REAUTHORIZE_V1_CAPABILITY], + capabilities: [ + SYNC_RELAY_REAUTHORIZE_V1_CAPABILITY, + SYNC_INVALIDATION_ONLY_V1_CAPABILITY, + ], }, auth: { kind: "paired", @@ -900,6 +924,8 @@ export class SyncConnection { this.scheduleRelayAuthorizationRefresh(generation, helloOk.relayAuthorization ?? null, true); this.emit("helloOk", helloOk); if (!this.isCurrentSocket(socket, generation)) return false; + this.emit("tablesChanged", new Set(FULL_INVALIDATION_TABLES)); + if (!this.isCurrentSocket(socket, generation)) return false; if (helloOk.projects) { this.emit("projectCatalog", { projects: helloOk.projects }); if (!this.isCurrentSocket(socket, generation)) return false; From c02778e64a9e2f4c0959e69f13449aae29a6b859 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:17:35 -0400 Subject: [PATCH 03/53] fix(web): hide desktop connections panel --- .../components/account/AccountPage.test.tsx | 22 +++++++++++++++++ .../components/account/AccountPage.tsx | 24 ++++++++++++------- .../renderer/components/app/TopBar.test.tsx | 10 ++++++++ .../src/renderer/components/app/TopBar.tsx | 13 ++++++---- 4 files changed, 55 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/renderer/components/account/AccountPage.test.tsx b/apps/desktop/src/renderer/components/account/AccountPage.test.tsx index 55efaaa22..5f47ca756 100644 --- a/apps/desktop/src/renderer/components/account/AccountPage.test.tsx +++ b/apps/desktop/src/renderer/components/account/AccountPage.test.tsx @@ -67,6 +67,7 @@ describe("AccountPage signed-out card", () => { const originalAde = window.ade; beforeEach(() => { + delete window.__adeWebClient; statusRef.current = SIGNED_OUT; window.ade = { app: { openExternal: vi.fn(async () => undefined) }, @@ -79,6 +80,7 @@ describe("AccountPage signed-out card", () => { afterEach(() => { cleanup(); + delete window.__adeWebClient; beginLogin.mockClear(); refreshAccount.mockClear(); window.ade = originalAde; @@ -142,6 +144,7 @@ describe("AccountPage signed-in", () => { const signOut = vi.fn(async () => SIGNED_OUT); beforeEach(() => { + delete window.__adeWebClient; statusRef.current = { signedIn: true, configured: true, @@ -173,6 +176,7 @@ describe("AccountPage signed-in", () => { afterEach(() => { cleanup(); + delete window.__adeWebClient; listMachines.mockReset(); getLocalMachineIdentity.mockReset(); removeMachine.mockClear(); @@ -306,4 +310,22 @@ describe("AccountPage signed-in", () => { expect(screen.queryByRole("button", { name: "Mobile" })).toBeNull(); expect(screen.queryByRole("button", { name: "Web clients" })).toBeNull(); }); + + it("does not offer the desktop Connections panel in hosted web mode", async () => { + window.__adeWebClient = true; + renderPage(); + await screen.findByText("MacBook Pro"); + + expect(screen.queryByRole("button", { name: /Manage connections/ })).toBeNull(); + }); + + it("directs hosted web users to the machine menu when the directory is unavailable", async () => { + window.__adeWebClient = true; + listMachines.mockResolvedValueOnce({ state: "unavailable", message: null, machines: [] }); + + renderPage(); + + expect(await screen.findByText("Use the machine menu above to switch Macs.")).toBeTruthy(); + expect(screen.queryByText(/still connect from Connections/)).toBeNull(); + }); }); diff --git a/apps/desktop/src/renderer/components/account/AccountPage.tsx b/apps/desktop/src/renderer/components/account/AccountPage.tsx index be2f99de2..47c8127f2 100644 --- a/apps/desktop/src/renderer/components/account/AccountPage.tsx +++ b/apps/desktop/src/renderer/components/account/AccountPage.tsx @@ -48,6 +48,7 @@ import { } from "../remoteTargets/remoteMachineModel"; import { openConnectionsPanel } from "../../lib/connectionsPanel"; import { openExternalUrl } from "../../lib/openExternal"; +import { isWebClientMode } from "../../lib/webClientMode"; import { docs } from "../../onboarding/docsLinks"; import { useClampedFixedPosition } from "../../hooks/useClampedFixedPosition"; @@ -387,6 +388,7 @@ export function SignInCard({ // --------------------------------------------------------------------------- function YourMacsCard() { + const webMode = isWebClientMode(); const [result, setResult] = useState(null); const [loading, setLoading] = useState(true); const [localIdentity, setLocalIdentity] = useState(null); @@ -544,14 +546,16 @@ function YourMacsCard() {
{summary}
- + {!webMode ? ( + + ) : null} {result?.state === "ok" && machines.length > 0 ? ( @@ -676,7 +680,9 @@ function YourMacsCard() { }} > - {result.state === "not_configured" + {webMode + ? "Use the machine menu above to switch Macs." + : result.state === "not_configured" ? "Your Macs still connect from Connections — the shared directory just isn't live yet." : "Your Macs still connect from Connections while the directory reconnects."} diff --git a/apps/desktop/src/renderer/components/app/TopBar.test.tsx b/apps/desktop/src/renderer/components/app/TopBar.test.tsx index f863a2e1d..e699d5836 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.test.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.test.tsx @@ -421,6 +421,16 @@ describe("TopBar", () => { }); }); + it("hides the desktop Connections panel controls in hosted web mode", () => { + globalThis.window.__adeWebClient = true; + + render(); + + expect(screen.queryByRole("button", { name: /^Connections, (?:not )?connected$/ })).toBeNull(); + act(() => openConnectionsPanel("machines")); + expect(screen.queryByRole("dialog", { name: "Connections" })).toBeNull(); + }); + it("shows a closable Chats pseudo-tab when chats are open without a project", () => { const { onNavigate } = renderChatsTopBar({ personalChatsRouteActive: true, diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index 25bbba3d3..59f4229ab 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -899,9 +899,10 @@ export function TopBar({ const connectionsPanelRef = useRef(null); const closeConnections = useCallback(() => setConnectionsOpen(false), []); const openConnections = useCallback((tab: ConnectionsPanelTab = "machines") => { + if (webMode) return; setConnectionsTab(tab); setConnectionsOpen(true); - }, []); + }, [webMode]); const handleConnectionsPanelKeyDown = useDialogFocusTrap( connectionsPanelRef, closeConnections, @@ -911,7 +912,7 @@ export function TopBar({ const isProjectBusy = projectTransition != null || relocatingPath != null; const remoteBinding = projectBinding?.kind === "remote" ? projectBinding : null; - const chromePanelOccludesNativeBrowser = connectionsOpen; + const chromePanelOccludesNativeBrowser = !webMode && connectionsOpen; const workspaceProjectOpen = projectHydrated === true && showWelcome !== true && @@ -1228,10 +1229,11 @@ export function TopBar({ // Let other surfaces (e.g. the Account page) open the Connections panel to a // specific tab. useEffect(() => { + if (webMode) return; return subscribeOpenConnectionsPanel((tab) => { openConnections(tab); }); - }, [openConnections]); + }, [openConnections, webMode]); const checkForActiveWorkloads = useCallback( async (projectRootPath: string): Promise => { @@ -1755,7 +1757,7 @@ export function TopBar({ options?.onActivate?.(); }; - const connectionsChip = ( + const connectionsChip = webMode ? null : ( Date: Wed, 22 Jul 2026 01:26:45 -0400 Subject: [PATCH 04/53] fix(sync): prioritize browser chat hydration --- .../src/services/sync/syncHostService.test.ts | 230 +++++++++++++++++- .../src/services/sync/syncHostService.ts | 110 ++++++--- .../sync/syncRemoteCommandService.test.ts | 6 +- .../services/sync/syncRemoteCommandService.ts | 10 +- .../services/chat/agentChatService.test.ts | 78 ++++++ .../main/services/chat/agentChatService.ts | 159 ++++++++++-- apps/desktop/src/shared/types/sync.ts | 7 + 7 files changed, 536 insertions(+), 64 deletions(-) diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts index d37f357a9..db1dea8f8 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -19,7 +19,10 @@ import type { SyncProjectCatalogPayload, SyncRemoteCommandDescriptor, } from "../../../../desktop/src/shared/types"; -import { SYNC_RELAY_REAUTHORIZE_V1_CAPABILITY } from "../../../../desktop/src/shared/types"; +import { + SYNC_INVALIDATION_ONLY_V1_CAPABILITY, + SYNC_RELAY_REAUTHORIZE_V1_CAPABILITY, +} from "../../../../desktop/src/shared/types"; import { MOBILE_SYNC_COMPATIBILITY_CONTRACT_VERSION, MOBILE_SYNC_OPTIONAL_REMOTE_COMMAND_ACTIONS, @@ -37,6 +40,7 @@ import { createChatEventReplayBuffer, createSyncHostService, createTerminalInputDedupeLedger, + initialSyncHostCursorForPeer, isRuntimeOnlySyncPeer, isRuntimeHostPairingRecord, planChatEventResume, @@ -3780,6 +3784,230 @@ describe("CTO-gated Linear sync commands", () => { }); }); +describe("initial hydration priority", () => { + it("keeps historical catch-up for legacy browsers without the invalidation-only capability", () => { + expect(initialSyncHostCursorForPeer({ + peer: { + deviceType: "browser", + dbVersion: 7, + dbVersionBySite: { "site-host": 11 }, + capabilities: [], + }, + serverDbSiteId: "site-host", + serverDbVersion: 99, + })).toBe(11); + }); + + it("admits a queued chat subscription before a replica peer's initial catch-up", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const exportChangesSince = vi.fn(() => [makeChange(1, 0)]); + const base = createHostArgs(projectRoot, []); + const host = createSyncHostService({ + ...base, + projectId: "project-1", + discoveryEnabled: false, + pollIntervalMs: 60_000, + db: { + sync: { + getSiteId: () => "site-host-queued-subscribe", + getDbVersion: () => 1, + exportChangesSince, + applyChanges: () => ({ appliedCount: 0 }), + discardUnpublishedChangesForTables: () => {}, + }, + }, + } as unknown as Parameters[0]); + let client: WebSocket | null = null; + + try { + const port = await host.waitUntilListening(); + client = new WebSocket(`ws://127.0.0.1:${port}`); + const { envelopes } = trackClientEnvelopes(client); + await new Promise((resolve, reject) => { + client!.once("open", resolve); + client!.once("error", reject); + }); + client.send(encodeSyncEnvelope({ + type: "hello", + requestId: "phone-hello", + payload: { + peer: { + deviceId: "phone-initial-hydration", + deviceName: "Phone", + platform: "iOS", + deviceType: "phone", + siteId: "phone-initial-hydration-site", + dbVersion: 0, + }, + auth: { kind: "bootstrap", token: host.getBootstrapToken() }, + }, + })); + client.send(encodeSyncEnvelope({ + type: "chat_subscribe", + requestId: "phone-chat-subscribe", + projectId: "project-1", + payload: { sessionId: "selected-chat" }, + })); + + await waitForEnvelope(envelopes, "chat_subscribe", "phone-chat-subscribe"); + expect(exportChangesSince).not.toHaveBeenCalled(); + } finally { + try { + client?.close(); + } catch { + // ignore + } + await host.dispose(); + cleanup(); + } + }); + + it("hydrates the selected browser chat without replaying historical CRDT rows", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const transcriptPath = path.join(projectRoot, "transcripts", "selected-chat.chat.jsonl"); + fs.mkdirSync(path.dirname(transcriptPath), { recursive: true }); + const event: AgentChatEventEnvelope = { + sessionId: "selected-chat", + timestamp: "2026-07-22T04:50:55.000Z", + sequence: 1, + event: { type: "text", text: "selected transcript" }, + }; + fs.writeFileSync(transcriptPath, `${JSON.stringify(event)}\n`, "utf8"); + + const state = { + dbVersion: 1, + changes: [makeChange(1, 0)], + }; + const exportChangesSince = vi.fn( + (fromDbVersion: number, options?: { maxRows?: number; throughDbVersion?: number }) => + state.changes + .filter((change) => Number(change.db_version) > fromDbVersion) + .filter((change) => Number(change.db_version) <= (options?.throughDbVersion ?? Number.MAX_SAFE_INTEGER)) + .slice(0, options?.maxRows ?? state.changes.length), + ); + const getChatEventHistory = vi.fn(() => ({ + sessionId: "selected-chat", + events: [event], + truncated: false, + transcriptTruncated: false, + windowTruncated: false, + sessionFound: true, + })); + let releaseSummary!: (summary: { status: string }) => void; + const summaryGate = new Promise<{ status: string }>((resolve) => { + releaseSummary = resolve; + }); + const base = createHostArgs(projectRoot, []); + const host = createSyncHostService({ + ...base, + projectId: "project-1", + discoveryEnabled: false, + pollIntervalMs: 100, + db: { + sync: { + getSiteId: () => "site-host-initial-hydration", + getDbVersion: () => state.dbVersion, + exportChangesSince, + applyChanges: () => ({ appliedCount: 0 }), + discardUnpublishedChangesForTables: () => {}, + }, + }, + sessionService: { + list: () => [], + get: (sessionId: string) => sessionId === "selected-chat" + ? { id: sessionId, transcriptPath, status: "running" } + : null, + readTranscriptTail: async () => "", + }, + agentChatService: { + subscribeToEvents: vi.fn().mockReturnValue(() => {}), + getChatEventHistory, + getSessionSummary: vi.fn(() => summaryGate), + }, + } as unknown as Parameters[0]); + let client: WebSocket | null = null; + + try { + const port = await host.waitUntilListening(); + client = new WebSocket(`ws://127.0.0.1:${port}`); + const { envelopes } = trackClientEnvelopes(client); + await new Promise((resolve, reject) => { + client!.once("open", resolve); + client!.once("error", reject); + }); + + // Browsers send their selected-chat subscription as soon as hello_ok + // arrives. Queue both frames here to make the host ordering contract + // deterministic: foreground hydration must beat the initial backlog. + client.send(encodeSyncEnvelope({ + type: "hello", + requestId: "browser-hello", + payload: { + peer: { + deviceId: "browser-initial-hydration", + deviceName: "Browser", + platform: "macOS", + deviceType: "browser", + siteId: "browser-initial-hydration-site", + dbVersion: 0, + capabilities: [SYNC_INVALIDATION_ONLY_V1_CAPABILITY], + }, + auth: { kind: "bootstrap", token: host.getBootstrapToken() }, + }, + })); + client.send(encodeSyncEnvelope({ + type: "chat_subscribe", + requestId: "selected-chat-subscribe", + projectId: "project-1", + payload: { sessionId: "selected-chat", maxBytes: 64 * 1024 }, + })); + + await waitForValue( + () => getChatEventHistory.mock.calls[0], + "selected chat history read", + ); + await new Promise((resolve) => setTimeout(resolve, 250)); + expect(exportChangesSince).not.toHaveBeenCalled(); + releaseSummary({ status: "idle" }); + + const snapshot = await waitForEnvelope( + envelopes, + "chat_subscribe", + "selected-chat-subscribe", + ); + expect(snapshot.payload).toMatchObject({ + sessionId: "selected-chat", + events: [event], + turnActive: false, + }); + expect(getChatEventHistory).toHaveBeenCalledTimes(1); + expect(envelopes.some((envelope) => envelope.type === "changeset_batch")).toBe(false); + + // A browser is invalidation-only: historical rows are skipped, while a + // mutation committed after hello still produces a normal live signal. + state.dbVersion = 2; + state.changes.push(makeChange(2, 1)); + const liveInvalidation = await waitForValue( + () => envelopes.find((envelope) => envelope.type === "changeset_batch"), + "post-connect browser invalidation", + ); + expect(exportChangesSince).toHaveBeenCalledWith( + 1, + expect.objectContaining({ throughDbVersion: 2 }), + ); + expect((liveInvalidation.payload as SyncChangesetBatchPayload).changes.map((change) => change.db_version)).toEqual([2]); + } finally { + try { + client?.close(); + } catch { + // ignore + } + await host.dispose(); + cleanup(); + } + }); +}); + describe("outbound changeset ack retries", () => { beforeEach(() => { publishMock.mockReset(); diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index d439f838a..4d445381b 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -88,7 +88,10 @@ import type { SyncTerminalInputPayload, SyncTerminalSnapshotPayload, } from "../../../../desktop/src/shared/types"; -import { SYNC_RELAY_REAUTHORIZE_V1_CAPABILITY } from "../../../../desktop/src/shared/types"; +import { + SYNC_INVALIDATION_ONLY_V1_CAPABILITY, + SYNC_RELAY_REAUTHORIZE_V1_CAPABILITY, +} from "../../../../desktop/src/shared/types"; import { parseAgentChatTranscript } from "../../../../desktop/src/shared/chatTranscript"; import type { Logger } from "../../../../desktop/src/main/services/logging/logger"; import type { ProductAnalyticsService } from "../../../../desktop/src/main/services/analytics/productAnalyticsService"; @@ -501,6 +504,7 @@ type PeerState = { rosterSeq: number; rosterBaseline: Map; messageQueue: Promise; + queuedMessageCount: number; terminalInputQueue: Promise; pendingTerminalOwnershipChanges: number; /** Local consent for this browser/phone; never mutates machine-wide consent. */ @@ -891,6 +895,27 @@ export function syncHeartbeatMissLimitForPeerMetadata(metadata: Pick; + serverDbSiteId: string; + serverDbVersion: number; +}): number { + // A browser may explicitly negotiate an invalidation-only contract: it has + // no SQLite replica, fully refetches its query domains after hello, and uses + // only post-connect changesets as invalidation hints. Starting that peer at + // the current watermark avoids replaying CRR history it cannot apply. Keep + // legacy browsers on replica semantics unless they declare the capability. + if ( + args.peer.deviceType === "browser" + && args.peer.capabilities?.includes(SYNC_INVALIDATION_ONLY_V1_CAPABILITY) + ) { + return Math.max(0, Math.floor(args.serverDbVersion)); + } + const cursorForThisDb = args.peer.dbVersionBySite?.[args.serverDbSiteId] + ?? (args.peer.dbVersionBySite ? 0 : args.peer.dbVersion); + return Math.max(0, Math.floor(cursorForThisDb)); +} + export function shouldDeferSyncHostBackgroundChangesForChat(args: { subscribedChatSessionCount: number; bufferedAmount: number; @@ -2451,8 +2476,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { let tailnetServePublishSequence = 0; let tailnetServeActivePublishToken = 0; let discoveryEnabled = args.discoveryEnabled !== false; - let chatPumpInFlight = false; - let changesPumpInFlight = false; + let pollPumpInFlight = false; // All-projects roster (mobile hub) coalescing state. Each subscribed peer // carries its own monotonic seq (PeerState.rosterSeq); clients re-snapshot on // any seq discontinuity. @@ -2491,33 +2515,34 @@ export function createSyncHostService(args: SyncHostServiceArgs) { args.onStateChanged?.(); }); - const runChatPump = (): void => { - if (disposed || chatPumpInFlight) return; - chatPumpInFlight = true; - void pumpChatEvents() - .catch((error) => { + const runPollPump = (): void => { + if (disposed || pollPumpInFlight) return; + pollPumpInFlight = true; + void (async () => { + try { + // Transcript reads are asynchronous. Finish them before entering the + // synchronous CRR export scan so a large catch-up cannot overtake chat. + await pumpChatEvents(); + } catch (error) { args.logger.warn("sync_host.chat_poll_failed", { error: error instanceof Error ? error.message : String(error) }); - }) - .finally(() => { - chatPumpInFlight = false; - }); - }; - - const runChangesPump = (): void => { - if (disposed || changesPumpInFlight) return; - changesPumpInFlight = true; - void pumpChanges() - .catch((error) => { + } + // The per-peer message queue owns subscriptions, snapshots, and remote + // commands. A background export is synchronous and cannot be preempted, + // so never start one while any already-received foreground work remains + // queued or in flight. + if ([...peers].some((peer) => peer.queuedMessageCount > 0)) return; + try { + await pumpChanges(); + } catch (error) { args.logger.warn("sync_host.poll_failed", { error: error instanceof Error ? error.message : String(error) }); - }) - .finally(() => { - changesPumpInFlight = false; - }); + } + })().finally(() => { + pollPumpInFlight = false; + }); }; const pollTimer = setInterval(() => { - runChatPump(); - runChangesPump(); + runPollPump(); }, pollIntervalMs); const heartbeatTimer = setInterval(() => { pruneExpiredPairFailures(); @@ -2849,6 +2874,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { rosterSeq: 0, rosterBaseline: new Map(), messageQueue: Promise.resolve(), + queuedMessageCount: 0, terminalInputQueue: Promise.resolve(), pendingTerminalOwnershipChanges: 0, // Paired clients own their local preference. Fail closed on every new @@ -2882,6 +2908,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { return; } if (handleImmediateControlEnvelope(peer, envelope)) return; + peer.queuedMessageCount += 1; const changesTerminalOwnership = envelope.type === "terminal_subscribe" || envelope.type === "terminal_unsubscribe"; if (changesTerminalOwnership) peer.pendingTerminalOwnershipChanges += 1; @@ -2900,6 +2927,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { }); }) .finally(() => { + peer.queuedMessageCount = Math.max(0, peer.queuedMessageCount - 1); if (changesTerminalOwnership) { peer.pendingTerminalOwnershipChanges = Math.max(0, peer.pendingTerminalOwnershipChanges - 1); } @@ -3048,12 +3076,18 @@ export function createSyncHostService(args: SyncHostServiceArgs) { ); terminalInputDedupeLedger.restore(snapshot.terminalInputDedupe ?? []); peer.connectedAt = snapshot.connectedAt; - peer.lastKnownServerDbVersion = Math.max( - 0, - Math.floor(snapshot.metadata.dbVersionBySite?.[args.db.sync.getSiteId()] ?? 0), - ); + const serverDbSiteId = args.db.sync.getSiteId(); + peer.lastKnownServerDbVersion = initialSyncHostCursorForPeer({ + peer: snapshot.metadata, + serverDbSiteId, + serverDbVersion: args.db.sync.getDbVersion(), + }); if ( - snapshot.serverDbSiteId === args.db.sync.getSiteId() + !( + snapshot.metadata.deviceType === "browser" + && snapshot.metadata.capabilities?.includes(SYNC_INVALIDATION_ONLY_V1_CAPABILITY) + ) + && snapshot.serverDbSiteId === serverDbSiteId && typeof snapshot.lastKnownServerDbVersion === "number" && Number.isFinite(snapshot.lastKnownServerDbVersion) ) { @@ -3147,7 +3181,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { } } } - await pumpChanges(); + runPollPump(); } let detachSharedListener: (() => void) | null = null; @@ -6091,9 +6125,12 @@ export function createSyncHostService(args: SyncHostServiceArgs) { // DB; after a hosted-project change it points into a different DB's // version sequence and silently skips (or replays) the entire backlog. const ownSiteId = args.db.sync.getSiteId(); - const cursorForThisDb = hello.peer.dbVersionBySite?.[ownSiteId] - ?? (hello.peer.dbVersionBySite ? 0 : hello.peer.dbVersion); - peer.lastKnownServerDbVersion = Math.max(0, Math.floor(cursorForThisDb)); + const serverDbVersion = args.db.sync.getDbVersion(); + peer.lastKnownServerDbVersion = initialSyncHostCursorForPeer({ + peer: hello.peer, + serverDbSiteId: ownSiteId, + serverDbVersion, + }); args.deviceRegistryService?.upsertPeerMetadata(hello.peer, { lastSeenAt: nowIso(), lastHost: peer.remoteAddress, @@ -6112,7 +6149,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { send(peer.ws, "hello_ok", buildSyncHostHelloOkPayload({ peer: hello.peer, brain: readBrainMetadata(), - serverDbVersion: args.db.sync.getDbVersion(), + serverDbVersion, serverDbSiteId: ownSiteId, heartbeatIntervalMs, pollIntervalMs, @@ -6137,7 +6174,8 @@ export function createSyncHostService(args: SyncHostServiceArgs) { accountPairing, }), envelope.requestId); args.onStateChanged?.(); - await pumpChanges(); + // Catch-up is background work. The periodic poll starts it after the + // serialized hello queue has had a chance to admit subscriptions. if (!isPeerLifecycleCurrent(peer, lifecycleGeneration)) return; broadcastBrainStatus(); return; diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts index a40bb77a8..1ebe7e073 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts @@ -1208,9 +1208,13 @@ describe("createSyncRemoteCommandService", () => { const result = await service.execute(makePayload("chat.getChatEventHistory", { sessionId: "chat-1", maxEvents: 128, + maxBytes: 131_072, })); - expect(getChatEventHistory).toHaveBeenCalledWith("chat-1", { maxEvents: 128 }); + expect(getChatEventHistory).toHaveBeenCalledWith("chat-1", { + maxEvents: 128, + maxBytes: 131_072, + }); expect(result).toEqual({ sessionId: "chat-1", events: [], diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 0ef236110..571ecd308 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -3934,7 +3934,15 @@ function registerChatRemoteCommands({ args, register }: RemoteCommandRegistratio const agentChatService = requireService(args.agentChatService, "Agent chat service not available."); const sessionId = requireString(payload.sessionId, "chat.getChatEventHistory requires sessionId."); const maxEvents = asOptionalNumber(payload.maxEvents); - return agentChatService.getChatEventHistory(sessionId, maxEvents == null ? undefined : { maxEvents }); + const maxBytes = asOptionalNumber(payload.maxBytes); + const options = { + ...(maxEvents != null ? { maxEvents } : {}), + ...(maxBytes != null ? { maxBytes } : {}), + }; + return agentChatService.getChatEventHistory( + sessionId, + Object.keys(options).length > 0 ? options : undefined, + ); }); register("chat.getTranscript", { viewerAllowed: true }, async (payload) => { const agentChatService = requireService(args.agentChatService, "Agent chat service not available."); diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index b6b715828..6386f16a4 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -21867,6 +21867,84 @@ describe("createAgentChatService", () => { expect(page.hasMore).toBe(false); }); + it("keeps a requested-byte snapshot seamless with its older page and unflushed ring events", async () => { + installRealTranscriptParser(); + const emitted: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => emitted.push(event), + }); + const session = await service.createSession({ laneId: "lane-1", provider: "codex", model: "gpt-5.4" }); + + // Create a normal committed event, then replace the files beneath it so + // the ring is one append ahead of the transcript snapshot (the same + // state as an fs.appendFile still in flight). + const pendingInput = service.requestChatInput({ + chatSessionId: session.id, + title: "Live ring event", + body: "Choose one", + questions: [{ + id: "choice", + question: "Choose one", + options: [{ label: "One" }, { label: "Two" }], + }], + }); + const liveRingEvent = await waitForEvent( + emitted, + (entry): entry is AgentChatEventEnvelope & { + event: Extract; + } => entry.event.type === "approval_request", + ); + + const LINE_BYTES = 16 * 1024; + const LINE_COUNT = 24; + const lines = Array.from({ length: LINE_COUNT }, (_, index) => paddedLine({ + sessionId: session.id, + timestamp: new Date(Date.UTC(2026, 0, 1, 0, 0, index)).toISOString(), + event: { type: "text", text: `persisted-${index}-` }, + sequence: index, + }, LINE_BYTES)); + const raw = lines.join(""); + const legacyTranscript = path.join(tmpRoot, "transcripts", `${session.id}.chat.jsonl`); + const durableTranscript = path.join(tmpRoot, ".ade", "transcripts", "chat", `${session.id}.jsonl`); + fs.mkdirSync(path.dirname(legacyTranscript), { recursive: true }); + fs.mkdirSync(path.dirname(durableTranscript), { recursive: true }); + fs.writeFileSync(legacyTranscript, raw, "utf8"); + fs.writeFileSync(durableTranscript, raw, "utf8"); + + const maxBytes = 128 * 1024; + const history = service.getChatEventHistory(session.id, { maxEvents: 512, maxBytes }); + expect(history.events).toContainEqual(liveRingEvent); + expect(history.tailStartOffset).toEqual(expect.any(Number)); + expect(history.tailStartOffset).toBeGreaterThan(0); + expect(history.events.reduce( + (total, entry) => total + Buffer.byteLength(JSON.stringify(entry), "utf8"), + 0, + )).toBeLessThanOrEqual(maxBytes); + + const snapshotSequences = history.events.flatMap((entry) => + typeof entry.sequence === "number" && entry.event.type === "text" ? [entry.sequence] : []); + expect(snapshotSequences.length).toBeGreaterThan(0); + const firstSnapshotSequence = snapshotSequences[0]!; + expect(history.tailStartOffset).toBe(firstSnapshotSequence * LINE_BYTES); + + const page = service.getChatEventHistoryPage(session.id, { + beforeOffset: history.tailStartOffset!, + maxBytes, + }); + const pageSequences = page.events.flatMap((entry) => + typeof entry.sequence === "number" && entry.event.type === "text" ? [entry.sequence] : []); + expect(pageSequences.at(-1)).toBe(firstSnapshotSequence - 1); + expect(new Set([...pageSequences, ...snapshotSequences]).size) + .toBe(pageSequences.length + snapshotSequences.length); + + await service.respondToInput({ + sessionId: session.id, + itemId: liveRingEvent.event.itemId, + decision: "decline", + }); + await pendingInput; + }); + it("reports a null tailStartOffset when the transcript is fully hydrated", async () => { const { service } = createService(); const session = await service.createSession({ laneId: "lane-1", provider: "codex", model: "gpt-5.4" }); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index e24df2017..c24b7ec4c 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -6642,6 +6642,7 @@ export function createAgentChatService(args: { // Envelopes are immutable once recorded; cache their serialized size so // byte-budget trims do not re-stringify multi-MB events on every snapshot. const envelopeSizeCache = new WeakMap(); + const envelopeByteSizeCache = new WeakMap(); const estimateEnvelopeChars = (envelope: AgentChatEventEnvelope): number => { let size = envelopeSizeCache.get(envelope); @@ -6652,6 +6653,19 @@ export function createAgentChatService(args: { return size; }; + const estimateEnvelopeBytes = (envelope: AgentChatEventEnvelope): number => { + let size = envelopeByteSizeCache.get(envelope); + if (size == null) { + try { + size = Buffer.byteLength(JSON.stringify(envelope), "utf8"); + } catch { + size = 2_048; + } + envelopeByteSizeCache.set(envelope, size); + } + return size; + }; + const trimEnvelopesToByteBudget = ( envelopes: AgentChatEventEnvelope[], maxChars: number, @@ -6945,11 +6959,15 @@ export function createAgentChatService(args: { transcriptPath: string; size: number; mtimeMs: number; + maxBytes: number; truncated: boolean; /** Byte offset (line start) where the cached tail window begins; 0 when not truncated. */ startOffset: number; + /** Logical transcript end offset (decompressed bytes for gzip files). */ + endOffset: number; hasCapNotice: boolean; envelopes: AgentChatEventEnvelope[]; + envelopeStartOffsets: Map; }; const transcriptHistoryCacheBySession = new Map>(); type TranscriptSubagentSnapshotCacheEntry = { @@ -8236,11 +8254,12 @@ export function createAgentChatService(args: { const readTranscriptTailForHistory = ( transcriptPath: string, stat: fs.Stats, + maxBytes: number, ): { raw: string; truncated: boolean; startOffset: number } => { if (transcriptPath.endsWith(".gz")) { const full = readHistoryFileSync(transcriptPath); const size = full.length; - const start = Math.max(0, size - CHAT_EVENT_HISTORY_TRANSCRIPT_MAX_BYTES); + const start = Math.max(0, size - maxBytes); let slice = full.subarray(start); let startOffset = start; if (start > 0 && slice.length > 0) { @@ -8259,7 +8278,7 @@ export function createAgentChatService(args: { }; } const size = stat.size; - const start = Math.max(0, size - CHAT_EVENT_HISTORY_TRANSCRIPT_MAX_BYTES); + const start = Math.max(0, size - maxBytes); // Read one extra byte before the window (when possible) so a window // boundary that lands exactly on a line start does not silently drop a // complete line: if byte `start - 1` is "\n" the line at `start` is kept. @@ -8298,38 +8317,91 @@ export function createAgentChatService(args: { const parseTranscriptHistoryTail = ( sessionId: string, transcriptPath: string, - ): { envelopes: AgentChatEventEnvelope[]; truncated: boolean; startOffset: number; hasCapNotice: boolean } => { + requestedMaxBytes = CHAT_EVENT_HISTORY_TRANSCRIPT_MAX_BYTES, + ): { + envelopes: AgentChatEventEnvelope[]; + truncated: boolean; + startOffset: number; + endOffset: number; + hasCapNotice: boolean; + envelopeStartOffsets: Map; + } => { const stat = fs.statSync(transcriptPath); + const maxBytes = Math.max( + 1_024, + Math.min(CHAT_EVENT_HISTORY_TRANSCRIPT_MAX_BYTES, Math.floor(requestedMaxBytes)), + ); const cached = transcriptHistoryCacheBySession.get(sessionId)?.get(transcriptPath); if ( cached && cached.transcriptPath === transcriptPath && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs + && cached.maxBytes === maxBytes ) { rememberTranscriptHistoryCache(sessionId, cached); return { envelopes: cached.envelopes.slice(), truncated: cached.truncated, startOffset: cached.startOffset, + endOffset: cached.endOffset, hasCapNotice: cached.hasCapNotice, + envelopeStartOffsets: new Map( + [...cached.envelopeStartOffsets].map(([key, offsets]) => [key, offsets.slice()]), + ), }; } - const { raw, truncated, startOffset } = readTranscriptTailForHistory(transcriptPath, stat); + const { raw, truncated, startOffset } = readTranscriptTailForHistory(transcriptPath, stat, maxBytes); + const endOffset = transcriptPath.endsWith(".gz") + ? readHistoryFileSync(transcriptPath).length + : stat.size; const hasCapNotice = raw.includes(CHAT_TRANSCRIPT_LIMIT_NOTICE.trim()); const envelopes = parseAgentChatTranscript(raw) .filter((entry) => entry.sessionId === sessionId); + const envelopeStartOffsets = new Map(); + let rawByteOffset = 0; + for (const line of raw.split(/(?<=\n)/)) { + const lineBytes = Buffer.byteLength(line, "utf8"); + const trimmedLine = line.trim(); + if (trimmedLine) { + try { + const parsed = JSON.parse(trimmedLine) as AgentChatEventEnvelope; + if (parsed?.sessionId === sessionId && parsed.event && typeof parsed.event === "object") { + const key = `${parsed.timestamp}#${parsed.event.type}#${JSON.stringify(parsed.event)}`; + const offsets = envelopeStartOffsets.get(key) ?? []; + offsets.push(startOffset + rawByteOffset); + envelopeStartOffsets.set(key, offsets); + } + } catch { + // Legacy/splice-repaired lines remain readable through the canonical + // parser. Their cursor safely falls back to the tail window start. + } + } + rawByteOffset += lineBytes; + } rememberTranscriptHistoryCache(sessionId, { transcriptPath, size: stat.size, mtimeMs: stat.mtimeMs, + maxBytes, truncated, startOffset, + endOffset, hasCapNotice, envelopes, + envelopeStartOffsets, }); - return { envelopes: envelopes.slice(), truncated, startOffset, hasCapNotice }; + return { + envelopes: envelopes.slice(), + truncated, + startOffset, + endOffset, + hasCapNotice, + envelopeStartOffsets: new Map( + [...envelopeStartOffsets].map(([key, offsets]) => [key, offsets.slice()]), + ), + }; }; const transcriptPathCandidatesForSessionId = ( @@ -8347,6 +8419,7 @@ export function createAgentChatService(args: { const resolveBestTranscriptPathForSessionId = ( sessionId: string, managed?: ManagedChatSession | null, + maxBytes = CHAT_EVENT_HISTORY_TRANSCRIPT_MAX_BYTES, ): string | null => { type Candidate = { path: string; @@ -8377,7 +8450,7 @@ export function createAgentChatService(args: { if (!transcriptPath) continue; const stat = fs.statSync(transcriptPath); if (!stat.isFile()) continue; - const parsed = parseTranscriptHistoryTail(sessionId, transcriptPath); + const parsed = parseTranscriptHistoryTail(sessionId, transcriptPath, maxBytes); const candidate: Candidate = { path: transcriptPath, size: transcriptPath.endsWith(".gz") ? readHistoryFileSync(transcriptPath).length : stat.size, @@ -8400,17 +8473,24 @@ export function createAgentChatService(args: { // in-memory ring buffer without allocating huge historical transcripts. const readTranscriptEnvelopesForSessionId = ( sessionId: string, - ): { envelopes: AgentChatEventEnvelope[]; truncated: boolean; startOffset: number } => { + maxBytes = CHAT_EVENT_HISTORY_TRANSCRIPT_MAX_BYTES, + ): { + envelopes: AgentChatEventEnvelope[]; + truncated: boolean; + startOffset: number; + endOffset: number; + envelopeStartOffsets: Map; + } => { const managed = managedSessions.get(sessionId); - const transcriptPath = resolveBestTranscriptPathForSessionId(sessionId, managed); + const transcriptPath = resolveBestTranscriptPathForSessionId(sessionId, managed, maxBytes); if (transcriptPath) { try { - return parseTranscriptHistoryTail(sessionId, transcriptPath); + return parseTranscriptHistoryTail(sessionId, transcriptPath, maxBytes); } catch { - return { envelopes: [], truncated: false, startOffset: 0 }; + return { envelopes: [], truncated: false, startOffset: 0, endOffset: 0, envelopeStartOffsets: new Map() }; } } - return { envelopes: [], truncated: false, startOffset: 0 }; + return { envelopes: [], truncated: false, startOffset: 0, endOffset: 0, envelopeStartOffsets: new Map() }; }; // Resolve the best on-disk transcript path for a session the same @@ -8624,7 +8704,10 @@ export function createAgentChatService(args: { // transcript is the durable source for project/tab switch recovery, while // the buffer contributes events that fs.appendFile may not have flushed yet. const bufferExisting = eventHistoryBySession.get(trimmedId) ?? []; - const transcriptHistory = readTranscriptEnvelopesForSessionId(trimmedId); + const transcriptHistory = readTranscriptEnvelopesForSessionId( + trimmedId, + requestedMaxBytes == null ? CHAT_EVENT_HISTORY_TRANSCRIPT_MAX_BYTES : responseMaxChars, + ); let merged = mergeEnvelopeStreams(transcriptHistory.envelopes, bufferExisting); const mergedLengthBeforeResponseCap = merged.length; if (merged.length > CHAT_EVENT_HISTORY_RESPONSE_MAX_PER_SESSION) { @@ -8638,20 +8721,46 @@ export function createAgentChatService(args: { const countWindowed = parentVisibleLength > maxEvents ? parentVisibleMerged.slice(-maxEvents) : parentVisibleMerged; - // Backstop byte budget so the serialized snapshot always fits one RPC - // message. The ring and transcript-tail budgets keep snapshots well under - // it, so it only trims when a single envelope dwarfs both (>~6 MB); such - // trimmed events sit AFTER tailStartOffset and are not reachable through - // getChatEventHistoryPage (which pages strictly older) — an accepted - // seam, the alternative being a response the client must discard. - const windowed = trimEnvelopesToByteBudget(countWindowed, responseMaxChars, { - keepOversizeNewest: requestedMaxBytes == null, - }); + // Desktop keeps its historical character-budget behavior. A caller that + // explicitly requests maxBytes gets a strict UTF-8 byte budget, including + // live ring events that have not reached the transcript file yet. + const windowed = requestedMaxBytes == null + ? trimEnvelopesToByteBudget(countWindowed, responseMaxChars) + : keepNewestWithinCharBudget(countWindowed, responseMaxChars, estimateEnvelopeBytes, { + keepOversizeNewest: false, + }); const windowTruncated = mergedLengthBeforeResponseCap > CHAT_EVENT_HISTORY_RESPONSE_MAX_PER_SESSION || parentVisibleLength > maxEvents || windowed.length < countWindowed.length; const truncated = transcriptTruncated || windowTruncated; + const transcriptKeys = new Set(transcriptHistory.envelopes.map(envelopeDedupKey)); + const availableOffsets = new Map( + [...transcriptHistory.envelopeStartOffsets].map(([key, offsets]) => [key, offsets.slice()]), + ); + let firstReturnedTranscriptOffset: number | null = null; + let returnedTranscriptEvent = false; + for (const envelope of windowed) { + const key = envelopeDedupKey(envelope); + if (!transcriptKeys.has(key)) continue; + returnedTranscriptEvent = true; + const offsets = availableOffsets.get(key); + const offset = offsets?.shift(); + if (offset != null) { + firstReturnedTranscriptOffset = offset; + break; + } + } + let tailStartOffset: number | null = null; + if (firstReturnedTranscriptOffset != null) { + tailStartOffset = firstReturnedTranscriptOffset > 0 ? firstReturnedTranscriptOffset : null; + } else if (transcriptHistory.endOffset > 0 && (truncated || !returnedTranscriptEvent)) { + // A legacy/repaired transcript line may not have a direct JSONL offset, + // and a snapshot can consist solely of not-yet-flushed ring events. Page + // from the current transcript end in those cases: it may overlap already + // returned events, but it can never skip persisted history. + tailStartOffset = transcriptHistory.endOffset; + } return { sessionId: trimmedId, events: windowed, @@ -8659,10 +8768,10 @@ export function createAgentChatService(args: { transcriptTruncated, windowTruncated, sessionFound: true, - // Pagination cursor: the byte offset (line start) where the hydrated - // transcript tail began. Null when the transcript was fully hydrated - // (or absent) — i.e. there is nothing older on disk to page through. - tailStartOffset: transcriptTruncated ? transcriptHistory.startOffset : null, + // Exact byte offset of the first persisted event in this response. When + // response caps remove rows inside the raw tail window, older pagination + // resumes at that event instead of the original window boundary. + tailStartOffset, }; }; diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index d8804f02d..c086af503 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -49,6 +49,13 @@ export type SyncProtocolVersion = 1; /** Additive hello capability for in-place ADE Relay account reauthorization. */ export const SYNC_RELAY_REAUTHORIZE_V1_CAPABILITY = "relayReauthorizeV1" as const; +/** + * Additive hello capability for browser peers that keep no local CRR replica. + * Such peers fully refetch their query domains after hello and consume only + * post-connect changesets as invalidation hints. + */ +export const SYNC_INVALIDATION_ONLY_V1_CAPABILITY = "invalidationOnlyV1" as const; + /** Relay transport readiness protocol used before the ADE sync hello. */ export const SYNC_RELAY_READY_VERSION = 2 as const; From df6e384c77e53d1014dbcc78b56f1d2ac4c9561f Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:27:43 -0400 Subject: [PATCH 05/53] refactor(sync): share browser capability constant --- apps/desktop/src/renderer/webclient/sync/connection.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/renderer/webclient/sync/connection.ts b/apps/desktop/src/renderer/webclient/sync/connection.ts index a03602db9..7791df6a3 100644 --- a/apps/desktop/src/renderer/webclient/sync/connection.ts +++ b/apps/desktop/src/renderer/webclient/sync/connection.ts @@ -17,9 +17,11 @@ import type { SyncRelayReauthorizeResultPayload, } from "../../../shared/types/sync"; import { + SYNC_INVALIDATION_ONLY_V1_CAPABILITY, SYNC_RELAY_READY_VERSION, SYNC_RELAY_REAUTHORIZE_V1_CAPABILITY, } from "../../../shared/types/sync"; +export { SYNC_INVALIDATION_ONLY_V1_CAPABILITY } from "../../../shared/types/sync"; import { resolveAccountHelloPairing } from "../../../shared/accountDirectory"; import { browserEndpointRequiresRelayAccess, @@ -49,12 +51,6 @@ const VISIBILITY_RECONNECT_DEBOUNCE_MS = 1_000; const RELAY_REAUTH_RESULT_TIMEOUT_MS = 4_000; const RELAY_REAUTH_RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000] as const; export const RELAY_READY_NEGOTIATION_WINDOW_MS = 750; -// This browser never applies CRDT rows locally: it uses remote commands and -// treats changesets only as cache-invalidation hints. A capable host can skip -// replaying its historical changeset backlog and begin this peer at its live -// DB version instead. -export const SYNC_INVALIDATION_ONLY_V1_CAPABILITY = "invalidationOnlyV1"; - // Keep this as table-shaped names so the web adapter's existing invalidation // scheduler maps one accepted hello to every UI domain it owns. const FULL_INVALIDATION_TABLES = [ From 08ddba4886062f4419fc963914a2cd786270e22c Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:32:22 -0400 Subject: [PATCH 06/53] fix(web): refresh relay leases in background --- .../webclient/sync/__tests__/sync.test.ts | 31 ++++++++++++++----- .../src/renderer/webclient/sync/connection.ts | 11 +++++-- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts b/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts index d7c582c03..2ee684133 100644 --- a/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts +++ b/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts @@ -1414,7 +1414,7 @@ describe("browser sync connection and client", () => { connection.dispose(); }); - it("advertises reauthorization support and refreshes on the host lease schedule", async () => { + it("advertises reauthorization support and refreshes ahead of the host lease deadline", async () => { const nowMs = 1_800_000_000_000; vi.useFakeTimers(); vi.setSystemTime(nowMs); @@ -1424,7 +1424,11 @@ describe("browser sync connection and client", () => { const relayTokenProvider: () => Promise = vi.fn(async () => `relay-token-${++relayTokenSequence}`); const script = createSocketFactory((socket, envelope) => { if (envelope.type === "hello") { - socket.serverSend({ type: "hello_ok", requestId: envelope.requestId, payload: relayHelloOk(nowMs) }); + socket.serverSend({ + type: "hello_ok", + requestId: envelope.requestId, + payload: relayHelloOk(nowMs, { expiresAfterMs: 120_000, refreshAfterMs: 91_000 }), + }); } else if (envelope.type === "relay_reauthorize") { socket.serverSend({ type: "relay_reauthorize_result", @@ -1483,7 +1487,11 @@ describe("browser sync connection and client", () => { let refreshFrames = 0; const script = createSocketFactory((socket, envelope) => { if (envelope.type === "hello") { - socket.serverSend({ type: "hello_ok", requestId: envelope.requestId, payload: relayHelloOk(nowMs) }); + socket.serverSend({ + type: "hello_ok", + requestId: envelope.requestId, + payload: relayHelloOk(nowMs, { expiresAfterMs: 120_000, refreshAfterMs: 91_000 }), + }); } else if (envelope.type === "relay_reauthorize") { refreshFrames += 1; if (refreshFrames === 2) { @@ -1547,7 +1555,7 @@ describe("browser sync connection and client", () => { socket.serverSend({ type: "hello_ok", requestId: envelope.requestId, - payload: relayHelloOk(nowMs, { expiresAfterMs: 90_000 }), + payload: relayHelloOk(nowMs, { expiresAfterMs: 120_000, refreshAfterMs: 91_000 }), }); } else if (envelope.type === "relay_reauthorize") { refreshTimes.push(Date.now()); @@ -1650,7 +1658,10 @@ describe("browser sync connection and client", () => { socket.serverSend({ type: "hello_ok", requestId: envelope.requestId, - payload: relayHelloOk(nowMs, { refreshAfterMs: helloCount === 1 ? 1_000 : 5_000 }), + payload: relayHelloOk(nowMs, { + expiresAfterMs: 120_000, + refreshAfterMs: helloCount === 1 ? 91_000 : 95_000, + }), }); }); const connection = new SyncConnection({ @@ -1686,7 +1697,7 @@ describe("browser sync connection and client", () => { connection.dispose(); }); - it("wakes an overdue hidden refresh on visibility and treats account change as terminal", async () => { + it("refreshes a hidden Relay tab before expiry and treats account change as terminal", async () => { const nowMs = 1_800_000_000_000; vi.useFakeTimers(); vi.setSystemTime(nowMs); @@ -1695,7 +1706,11 @@ describe("browser sync connection and client", () => { const visibility = new VisibilityDocument(); const script = createSocketFactory((socket, envelope) => { if (envelope.type === "hello") { - socket.serverSend({ type: "hello_ok", requestId: envelope.requestId, payload: relayHelloOk(nowMs) }); + socket.serverSend({ + type: "hello_ok", + requestId: envelope.requestId, + payload: relayHelloOk(nowMs, { expiresAfterMs: 120_000, refreshAfterMs: 91_000 }), + }); } else if (envelope.type === "relay_reauthorize") { socket.serverSend({ type: "relay_reauthorize_result", @@ -1727,7 +1742,7 @@ describe("browser sync connection and client", () => { visibility.setVisibility("hidden"); await vi.advanceTimersByTimeAsync(2_000); expect(script.sockets[0]?.sent.filter((envelope) => envelope.type === "relay_reauthorize")) - .toHaveLength(0); + .toHaveLength(1); visibility.setVisibility("visible"); await flushMicrotasks(); diff --git a/apps/desktop/src/renderer/webclient/sync/connection.ts b/apps/desktop/src/renderer/webclient/sync/connection.ts index 7791df6a3..09769e07c 100644 --- a/apps/desktop/src/renderer/webclient/sync/connection.ts +++ b/apps/desktop/src/renderer/webclient/sync/connection.ts @@ -50,6 +50,11 @@ const MAX_CONSECUTIVE_AUTH_FAILURES = 5; const VISIBILITY_RECONNECT_DEBOUNCE_MS = 1_000; const RELAY_REAUTH_RESULT_TIMEOUT_MS = 4_000; const RELAY_REAUTH_RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000] as const; +// Browser background throttling can delay a timer well past its nominal fire +// time. Start before the host's refresh deadline and do not depend on the tab +// becoming visible again: otherwise a healthy Relay socket is guaranteed to +// reconnect whenever the tab stays hidden across account-token expiry. +const RELAY_REAUTH_CLIENT_SAFETY_LEAD_MS = 90_000; export const RELAY_READY_NEGOTIATION_WINDOW_MS = 750; // Keep this as table-shaped names so the web adapter's existing invalidation // scheduler maps one accepted hello to every UI domain it owns. @@ -938,10 +943,12 @@ export class SyncConnection { this.relayRefreshTimer = null; if (resetRetries) this.relayRefreshRetryCount = 0; if (!lease || generation !== this.connectionGeneration) return; - const delayMs = Math.max(0, lease.refreshAfter - Date.now()); + const delayMs = Math.max( + 0, + lease.refreshAfter - RELAY_REAUTH_CLIENT_SAFETY_LEAD_MS - Date.now(), + ); this.relayRefreshTimer = setTimeout(() => { this.relayRefreshTimer = null; - if (!visible(this.documentRef)) return; this.beginRelayAuthorizationRefresh(generation); }, delayMs); } From c28ce1bc490c64d349e67e561aa7943661630b0b Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:33:17 -0400 Subject: [PATCH 07/53] refactor(sync): import shared capability directly --- .../renderer/webclient/sync/__tests__/sync.test.ts | 14 +++++++------- .../src/renderer/webclient/sync/connection.ts | 1 - 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts b/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts index 2ee684133..ee9c4165c 100644 --- a/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts +++ b/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts @@ -1,18 +1,18 @@ import { gzipSync } from "node:zlib"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { - SyncEnvelope, - SyncFeatureFlags, - SyncHelloOkPayload, - SyncPairingQrPayload, - SyncPeerMetadata, +import { + SYNC_INVALIDATION_ONLY_V1_CAPABILITY, + type SyncEnvelope, + type SyncFeatureFlags, + type SyncHelloOkPayload, + type SyncPairingQrPayload, + type SyncPeerMetadata, } from "../../../../shared/types/sync"; import type { AdeAccountMachine } from "../../../../shared/types/account"; import { AdeSyncClient } from "../client"; import { BACKOFF_STABLE_CONNECTED_MS, RELAY_READY_NEGOTIATION_WINDOW_MS, - SYNC_INVALIDATION_ONLY_V1_CAPABILITY, SyncConnection, type WebSocketLike, } from "../connection"; diff --git a/apps/desktop/src/renderer/webclient/sync/connection.ts b/apps/desktop/src/renderer/webclient/sync/connection.ts index 09769e07c..467aea307 100644 --- a/apps/desktop/src/renderer/webclient/sync/connection.ts +++ b/apps/desktop/src/renderer/webclient/sync/connection.ts @@ -21,7 +21,6 @@ import { SYNC_RELAY_READY_VERSION, SYNC_RELAY_REAUTHORIZE_V1_CAPABILITY, } from "../../../shared/types/sync"; -export { SYNC_INVALIDATION_ONLY_V1_CAPABILITY } from "../../../shared/types/sync"; import { resolveAccountHelloPairing } from "../../../shared/accountDirectory"; import { browserEndpointRequiresRelayAccess, From 8b458b7091dfbb594e3dc69ad6910226fa91d99a Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:41:50 -0400 Subject: [PATCH 08/53] perf(chat): avoid duplicate transcript inflation --- .../main/services/chat/agentChatService.ts | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index c24b7ec4c..00209210e 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -8255,7 +8255,7 @@ export function createAgentChatService(args: { transcriptPath: string, stat: fs.Stats, maxBytes: number, - ): { raw: string; truncated: boolean; startOffset: number } => { + ): { raw: string; truncated: boolean; startOffset: number; endOffset: number } => { if (transcriptPath.endsWith(".gz")) { const full = readHistoryFileSync(transcriptPath); const size = full.length; @@ -8275,6 +8275,7 @@ export function createAgentChatService(args: { raw: slice.toString("utf8"), truncated: start > 0, startOffset: start > 0 ? startOffset : 0, + endOffset: size, }; } const size = stat.size; @@ -8284,7 +8285,7 @@ export function createAgentChatService(args: { // complete line: if byte `start - 1` is "\n" the line at `start` is kept. const readStart = Math.max(0, start - 1); const length = size - readStart; - if (length <= 0) return { raw: "", truncated: false, startOffset: 0 }; + if (length <= 0) return { raw: "", truncated: false, startOffset: 0, endOffset: size }; const fd = fs.openSync(transcriptPath, "r"); try { const out = Buffer.allocUnsafe(length); @@ -8308,7 +8309,12 @@ export function createAgentChatService(args: { startOffset = start; } } - return { raw: slice.toString("utf8"), truncated, startOffset: truncated ? startOffset : 0 }; + return { + raw: slice.toString("utf8"), + truncated, + startOffset: truncated ? startOffset : 0, + endOffset: size, + }; } finally { fs.closeSync(fd); } @@ -8352,10 +8358,11 @@ export function createAgentChatService(args: { }; } - const { raw, truncated, startOffset } = readTranscriptTailForHistory(transcriptPath, stat, maxBytes); - const endOffset = transcriptPath.endsWith(".gz") - ? readHistoryFileSync(transcriptPath).length - : stat.size; + const { raw, truncated, startOffset, endOffset } = readTranscriptTailForHistory( + transcriptPath, + stat, + maxBytes, + ); const hasCapNotice = raw.includes(CHAT_TRANSCRIPT_LIMIT_NOTICE.trim()); const envelopes = parseAgentChatTranscript(raw) .filter((entry) => entry.sessionId === sessionId); @@ -8453,7 +8460,7 @@ export function createAgentChatService(args: { const parsed = parseTranscriptHistoryTail(sessionId, transcriptPath, maxBytes); const candidate: Candidate = { path: transcriptPath, - size: transcriptPath.endsWith(".gz") ? readHistoryFileSync(transcriptPath).length : stat.size, + size: parsed.endOffset, mtimeMs: stat.mtimeMs, envelopeCount: parsed.envelopes.length, hasCapNotice: parsed.hasCapNotice, From 1e8b66a9b44e1bca361c3cf6d46dcb2e99f2d06d Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:42:29 -0400 Subject: [PATCH 09/53] Fix active sync host project catalog marker --- apps/ade-cli/src/cli.ts | 18 ++++++++---- .../src/services/projects/projectCatalog.ts | 9 ++++++ .../services/projects/projectScope.test.ts | 29 +++++++++++++++++++ 3 files changed, 50 insertions(+), 6 deletions(-) create mode 100644 apps/ade-cli/src/services/projects/projectCatalog.ts diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 795ecdec3..c1b106eed 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -49,6 +49,7 @@ import type { ProjectBrowseInput, } from "../../desktop/src/shared/types/core"; import { resolveMachineAdeLayout } from "./services/projects/machineLayout"; +import { markActiveHostProjectOpen } from "./services/projects/projectCatalog"; import { findAdeManagedWorktreeRoot, normalizeProjectRootPath, @@ -15131,18 +15132,23 @@ async function runServe( }; }; const machineProjectCatalogProvider: SyncProjectCatalogProvider = { - listProjects: async () => ({ - projects: includeHostProjectInCatalog( + listProjects: async () => { + const activeHostProjectId = scopeRegistry.getActiveSyncHostProjectId(); + const catalogHostProjectId = activeHostProjectId ?? preferredSyncProjectId; + const projects = includeHostProjectInCatalog( projectRegistry.listRecent(), - preferredSyncProjectId - ? projectRegistry.get(preferredSyncProjectId) + catalogHostProjectId + ? projectRegistry.get(catalogHostProjectId) : null, ) .map((record) => toMobileProjectSummary(record, { isAvailable: fs.existsSync(record.rootPath), - })), - }), + })); + return { + projects: markActiveHostProjectOpen(projects, activeHostProjectId), + }; + }, prepareProjectConnection: async ( request: SyncProjectSwitchRequestPayload, ): Promise => { diff --git a/apps/ade-cli/src/services/projects/projectCatalog.ts b/apps/ade-cli/src/services/projects/projectCatalog.ts new file mode 100644 index 000000000..20ea6d406 --- /dev/null +++ b/apps/ade-cli/src/services/projects/projectCatalog.ts @@ -0,0 +1,9 @@ +export function markActiveHostProjectOpen( + projects: T[], + activeHostProjectId: string | null, +): T[] { + return projects.map((project) => { + const isOpen = project.id === activeHostProjectId; + return project.isOpen === isOpen ? project : { ...project, isOpen }; + }); +} diff --git a/apps/ade-cli/src/services/projects/projectScope.test.ts b/apps/ade-cli/src/services/projects/projectScope.test.ts index 68293f4f9..30f1522e3 100644 --- a/apps/ade-cli/src/services/projects/projectScope.test.ts +++ b/apps/ade-cli/src/services/projects/projectScope.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { markActiveHostProjectOpen } from "./projectCatalog"; import { ProjectRegistry } from "./projectRegistry"; import { ProjectScopeRegistry } from "./projectScope"; @@ -352,5 +353,33 @@ describe("ProjectScopeRegistry", () => { await scopeRegistry.disposeAll(); }); +}); + +describe("markActiveHostProjectOpen", () => { + it("marks the current sync host open even when a stale project is first", () => { + const catalog = [ + { id: "project_stale_mru", displayName: "Stale MRU", isOpen: false }, + { id: "project_active", displayName: "Active host", isOpen: false }, + ]; + + const updated = markActiveHostProjectOpen(catalog, "project_active"); + expect(updated).toEqual([ + { id: "project_stale_mru", displayName: "Stale MRU", isOpen: false }, + { id: "project_active", displayName: "Active host", isOpen: true }, + ]); + expect(updated.find((project) => project.isOpen)?.id).toBe("project_active"); + }); + + it("moves the open marker when the active sync host changes", () => { + const catalog = [ + { id: "project_previous", isOpen: true }, + { id: "project_current", isOpen: false }, + ]; + + expect(markActiveHostProjectOpen(catalog, "project_current")).toEqual([ + { id: "project_previous", isOpen: false }, + { id: "project_current", isOpen: true }, + ]); + }); }); From a4a54b1c157a2c326fe08caf5b3eeacf627592a2 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:44:26 -0400 Subject: [PATCH 10/53] docs(web): document bounded invalidation sync --- docs/features/web-client/README.md | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/docs/features/web-client/README.md b/docs/features/web-client/README.md index a1c0ba704..ce9cc5ed7 100644 --- a/docs/features/web-client/README.md +++ b/docs/features/web-client/README.md @@ -84,7 +84,10 @@ Browser sync client: heartbeat, reconnect/backoff, project catalog chunks, and auth-failure attribution. DPoP/token preparation begins in parallel with the socket dial; transport open and authenticated hello have separate 8-second and 12-second - deadlines. Relay sockets negotiate ready-v2 first: no ADE hello is sent + deadlines. Relay authorization is renewed in place ahead of expiry even + while the tab is hidden, so background timer throttling does not turn a + healthy socket into a reconnect loop. Relay sockets negotiate ready-v2 + first: no ADE hello is sent before `accepted` then `ready`; an old Worker that does not send `accepted` within the short negotiation window is retried on a fresh legacy socket, never downgraded in place. While the page is visible, a 15-second watchdog closes a socket @@ -183,7 +186,10 @@ Browser `window.ade` adapter: - `apps/desktop/src/renderer/webclient/adapter/agentChat.ts`, `personalChats.ts`, `lanes.ts`, `git.ts`, `prs.ts`, `project.ts`, `app.ts`, and `misc.ts` - web implementations of desktop renderer namespaces, mixing remote commands, - sync sub-protocols, and local browser-only state. The chat adapter routes + sync sub-protocols, and local browser-only state. The chat adapter subscribes + only the selected or explicitly requested chat and bounds initial transcript + hydration to 128 KiB; older events page in on demand instead of every chat + transcript competing with the active pane. It routes smart-link metadata through viewer-allowed `chat.resolveSmartLinkPreview` and falls back to the shared deterministic provider label when an older host does not advertise the action. It also routes @@ -286,7 +292,10 @@ Machine runtime and sync host: Worker/Durable Object. - `apps/ade-cli/src/services/sync/brainProjectActionsSyncHandler.ts` - machine-level fallback handler for pairing/project actions before a project - host owns the sync listener. + host owns the sync listener. The headless machine catalog marks the + `ProjectScopeRegistry`'s actual current sync host as `isOpen` on every read, + including after a host handoff, so reconnect restoration cannot guess from + stale MRU order and bind streams to the wrong project. - `apps/ade-cli/src/services/sync/deviceRegistryService.ts` - device records; `SyncPeerDeviceType` includes `browser`. @@ -359,8 +368,10 @@ Tests: still use an already-verified direct `wss://` route. `ws://127.0.0.1:` is for local web-client development only. - **The browser has no ADE database.** It does not load cr-sqlite, does not - apply changesets, and does not advertise the `changesetAck` capability. A - `changeset_batch` only tells the adapter which table domains to refresh. + apply changesets, and does not advertise the `changesetAck` capability. It + advertises `invalidationOnlyV1`: the host starts it at the current database + watermark, the browser performs one full-domain refresh after hello, and + later `changeset_batch` envelopes identify only the domains that changed. - **Protocol version 1 extensions are additive.** The browser decodes the common envelope and ignores valid types it does not implement, including the desktop-only `rpc_*` and `fwd_*` channels. Unknown `hello_ok.features` keys @@ -592,8 +603,12 @@ for their non-Relay routes. ## Data strategy: no local DB The browser intentionally does not maintain a local replica of `.ade/ade.db`. -`SyncConnection.sendHello` sends `dbVersion: 0` and `capabilities: []`, so the -host does not treat it like a changeset-acknowledging CRDT peer. +`SyncConnection.sendHello` sends `dbVersion: 0` and advertises +`invalidationOnlyV1` (along with Relay reauthorization support), so the host +does not replay historical CRR rows to a client that cannot apply them. The +host places the browser at its current watermark; the accepted hello triggers +a full-domain refresh, and subsequent changeset batches remain live +invalidation hints rather than replicated state. Because there is no local replica, every read is a live relay round-trip to the machine — where the desktop renderer would hit its in-process cr-sqlite. Two From ee34129012b1aad0716a3ba5ef11ce7d771d1c99 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:57:28 -0400 Subject: [PATCH 11/53] Fix sync handoff cursor and peer fairness --- .../src/services/sync/syncHostService.test.ts | 251 +++++++++++++++++- .../src/services/sync/syncHostService.ts | 102 ++++--- 2 files changed, 302 insertions(+), 51 deletions(-) diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts index db1dea8f8..49817be1c 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -33,13 +33,14 @@ import { CHAT_EVENT_REPLAY_MAX_EVENTS, CONNECTION_ATTEMPT_RESERVATION_TTL_MS, SYNC_HOST_CHAT_ACTIVE_BACKGROUND_BACKPRESSURE_BYTES, - SYNC_HOST_CHAT_ACTIVE_MAX_CHANGESET_DEFER_MS, + SYNC_HOST_PRIORITY_MAX_CHANGESET_DEFER_MS, buildSyncHostHelloOkPayload, buildSyncProjectCatalogMessages, compactChatEventEnvelopeForSync, createChatEventReplayBuffer, createSyncHostService, createTerminalInputDedupeLedger, + adoptedSyncHostCursorForPeer, initialSyncHostCursorForPeer, isRuntimeOnlySyncPeer, isRuntimeHostPairingRecord, @@ -3798,6 +3799,37 @@ describe("initial hydration priority", () => { })).toBe(11); }); + it("preserves an invalidation browser's same-DB handoff cursor but resets for a new DB", () => { + const peer = { + deviceType: "browser" as const, + dbVersion: 0, + dbVersionBySite: { "site-host": 4 }, + capabilities: [SYNC_INVALIDATION_ONLY_V1_CAPABILITY], + }; + + expect(adoptedSyncHostCursorForPeer({ + peer, + serverDbSiteId: "site-host", + serverDbVersion: 9, + snapshotServerDbSiteId: "site-host", + snapshotLastKnownServerDbVersion: 6, + })).toBe(6); + expect(adoptedSyncHostCursorForPeer({ + peer, + serverDbSiteId: "site-new", + serverDbVersion: 9, + snapshotServerDbSiteId: "site-host", + snapshotLastKnownServerDbVersion: 6, + })).toBe(9); + expect(adoptedSyncHostCursorForPeer({ + peer, + serverDbSiteId: "site-host", + serverDbVersion: 9, + snapshotServerDbSiteId: "site-host", + snapshotLastKnownServerDbVersion: 12, + })).toBe(9); + }); + it("admits a queued chat subscription before a replica peer's initial catch-up", async () => { const { projectRoot, cleanup } = createTempProjectRoot(); const exportChangesSince = vi.fn(() => [makeChange(1, 0)]); @@ -3862,6 +3894,131 @@ describe("initial hydration priority", () => { } }); + it("serves other peers while one foreground queue is slow, then admits a bounded batch", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const state = { + dbVersion: 0, + changes: Array.from({ length: 200 }, (_, index) => makeChange(index + 1, index)), + }; + const exportChangesSince = vi.fn( + (fromDbVersion: number, options?: { maxRows?: number; throughDbVersion?: number }) => + state.changes + .filter((change) => Number(change.db_version) > fromDbVersion) + .filter((change) => Number(change.db_version) <= (options?.throughDbVersion ?? Number.MAX_SAFE_INTEGER)) + .slice(0, options?.maxRows ?? state.changes.length), + ); + let releaseSummary!: (summary: { status: string }) => void; + const summaryGate = new Promise<{ status: string }>((resolve) => { + releaseSummary = resolve; + }); + const getSessionSummary = vi.fn((sessionId: string) => + sessionId === "slow-chat" ? summaryGate : Promise.resolve(null) + ); + const logger = createDiscoveryLogger(); + const base = createHostArgs(projectRoot, []); + const host = createSyncHostService({ + ...base, + logger, + projectId: "project-1", + discoveryEnabled: false, + pollIntervalMs: 25, + db: { + sync: { + getSiteId: () => "site-host-peer-fairness", + getDbVersion: () => state.dbVersion, + exportChangesSince, + applyChanges: () => ({ appliedCount: 0 }), + discardUnpublishedChangesForTables: () => {}, + }, + }, + agentChatService: { + subscribeToEvents: vi.fn().mockReturnValue(() => {}), + getChatEventHistory: vi.fn(() => ({ + sessionId: "slow-chat", + events: [], + truncated: false, + transcriptTruncated: false, + windowTruncated: false, + sessionFound: true, + })), + getSessionSummary, + }, + deviceRegistryService: { + ...base.deviceRegistryService, + upsertPeerMetadata: vi.fn(), + }, + } as unknown as Parameters[0]); + let slowPeer: Awaited> | null = null; + let fastPeer: Awaited> | null = null; + let dateNowSpy: { mockRestore(): void } | null = null; + + try { + const port = await host.waitUntilListening(); + slowPeer = await connectPeer(port, host.getBootstrapToken(), "slow-foreground-peer", { + platform: "macOS", + deviceType: "browser", + capabilities: [SYNC_INVALIDATION_ONLY_V1_CAPABILITY, "changesetAck"], + }); + fastPeer = await connectPeer(port, host.getBootstrapToken(), "independent-peer", { + platform: "macOS", + deviceType: "browser", + capabilities: [SYNC_INVALIDATION_ONLY_V1_CAPABILITY, "changesetAck"], + }); + const realDateNow = Date.now.bind(Date); + let clockOffsetMs = 0; + dateNowSpy = vi.spyOn(Date, "now").mockImplementation(() => realDateNow() + clockOffsetMs); + + slowPeer.ws.send(encodeSyncEnvelope({ + type: "chat_subscribe", + requestId: "slow-chat-subscribe", + projectId: "project-1", + payload: { sessionId: "slow-chat" }, + })); + await waitForValue(() => getSessionSummary.mock.calls[0], "slow foreground handler"); + state.dbVersion = 200; + + await waitForValue( + () => logger.debug.mock.calls.find(([event, fields]) => + event === "sync_host.changeset_priority_deferral_started" + && fields?.peerDeviceId === "slow-foreground-peer" + ), + "per-peer foreground deferral", + ); + const independentBatch = await waitForValue( + () => fastPeer?.envelopes.find((envelope) => envelope.type === "changeset_batch"), + "independent peer changeset", + ); + expect((independentBatch.payload as SyncChangesetBatchPayload).changes).toHaveLength(200); + expect(slowPeer.envelopes.some((envelope) => envelope.type === "changeset_batch")).toBe(false); + expect(getSessionSummary).toHaveBeenCalledWith("slow-chat"); + + clockOffsetMs += SYNC_HOST_PRIORITY_MAX_CHANGESET_DEFER_MS + 25; + const boundedSlowBatch = await waitForValue( + () => slowPeer?.envelopes.find((envelope) => envelope.type === "changeset_batch"), + "bounded slow-peer changeset", + ); + expect((boundedSlowBatch.payload as SyncChangesetBatchPayload).changes).toHaveLength(64); + expect(logger.debug).toHaveBeenCalledWith( + "sync_host.changeset_priority_deferral_ended", + expect.objectContaining({ + peerDeviceId: "slow-foreground-peer", + reason: "batch_admitted", + }), + ); + expect(slowPeer.envelopes.some((envelope) => envelope.type === "chat_subscribe")).toBe(false); + + releaseSummary({ status: "idle" }); + await waitForEnvelope(slowPeer.envelopes, "chat_subscribe", "slow-chat-subscribe"); + } finally { + releaseSummary({ status: "idle" }); + dateNowSpy?.mockRestore(); + slowPeer?.ws.close(); + fastPeer?.ws.close(); + await host.dispose(); + cleanup(); + } + }); + it("hydrates the selected browser chat without replaying historical CRDT rows", async () => { const { projectRoot, cleanup } = createTempProjectRoot(); const transcriptPath = path.join(projectRoot, "transcripts", "selected-chat.chat.jsonl"); @@ -4161,21 +4318,21 @@ describe("outbound changeset ack retries", () => { state.dbVersion = 1; await waitForValue( - () => logger.debug.mock.calls.find(([event]) => event === "sync_host.changeset_chat_deferral_started"), + () => logger.debug.mock.calls.find(([event]) => event === "sync_host.changeset_priority_deferral_started"), "chat changeset deferral transition", ); await new Promise((resolve) => setTimeout(resolve, 75)); expect(peer.envelopes.some((envelope) => envelope.type === "changeset_batch")).toBe(false); - clockOffsetMs += SYNC_HOST_CHAT_ACTIVE_MAX_CHANGESET_DEFER_MS + 25; + clockOffsetMs += SYNC_HOST_PRIORITY_MAX_CHANGESET_DEFER_MS + 25; const batch = await waitForValue( () => peer?.envelopes.find((envelope) => envelope.type === "changeset_batch"), "fair changeset admission", ); expect((batch.payload as SyncChangesetBatchPayload).changes).toHaveLength(1); - expect(logger.debug.mock.calls.filter(([event]) => event === "sync_host.changeset_chat_deferral_started")).toHaveLength(1); + expect(logger.debug.mock.calls.filter(([event]) => event === "sync_host.changeset_priority_deferral_started")).toHaveLength(1); expect(logger.debug).toHaveBeenCalledWith( - "sync_host.changeset_chat_deferral_ended", + "sync_host.changeset_priority_deferral_ended", expect.objectContaining({ reason: "batch_admitted" }), ); } finally { @@ -4211,19 +4368,19 @@ describe("outbound changeset ack retries", () => { .spyOn(WebSocket.prototype, "bufferedAmount", "get") .mockImplementation(() => bufferedAmount); const realDateNow = Date.now.bind(Date); - let clockOffsetMs = SYNC_HOST_CHAT_ACTIVE_MAX_CHANGESET_DEFER_MS + 5_000; + let clockOffsetMs = SYNC_HOST_PRIORITY_MAX_CHANGESET_DEFER_MS + 5_000; dateNowSpy = vi.spyOn(Date, "now").mockImplementation(() => realDateNow() + clockOffsetMs); state.dbVersion = 1; await new Promise((resolve) => setTimeout(resolve, 100)); expect(peer.envelopes.some((envelope) => envelope.type === "changeset_batch")).toBe(false); - expect(logger.debug.mock.calls.some(([event]) => event === "sync_host.changeset_chat_deferral_started")).toBe(false); + expect(logger.debug.mock.calls.some(([event]) => event === "sync_host.changeset_priority_deferral_started")).toBe(false); bufferedAmount = SYNC_HOST_CHAT_ACTIVE_BACKGROUND_BACKPRESSURE_BYTES; await waitForValue( - () => logger.debug.mock.calls.find(([event]) => event === "sync_host.changeset_chat_deferral_started"), + () => logger.debug.mock.calls.find(([event]) => event === "sync_host.changeset_priority_deferral_started"), "soft deferral after hard pressure clears", ); - clockOffsetMs += SYNC_HOST_CHAT_ACTIVE_MAX_CHANGESET_DEFER_MS + 25; + clockOffsetMs += SYNC_HOST_PRIORITY_MAX_CHANGESET_DEFER_MS + 25; await waitForValue( () => peer?.envelopes.find((envelope) => envelope.type === "changeset_batch"), "changeset after hard pressure clears", @@ -5084,6 +5241,82 @@ describe("sync host handoff over a shared listener", () => { } }); + it("preserves an invalidation browser's cursor across a same-database handoff", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const tokenPath = path.join(projectRoot, "shared-browser-bootstrap-token"); + const listener = createSharedSyncListener({ bindHost: "127.0.0.1" }); + const db = { + siteId: "site-shared-browser-db", + dbVersion: 1, + changes: [makeHostChange(1, 0)], + }; + let browser: Awaited> | null = null; + let hostA: ReturnType | null = null; + let hostB: ReturnType | null = null; + + try { + const port = await listener.ensureListening([0]); + hostA = createSyncHostService({ + ...createHandoffHostArgs(projectRoot, tokenPath, db), + sharedListener: listener, + pollIntervalMs: 25, + } as unknown as Parameters[0]); + await hostA.waitUntilListening(); + browser = await connectPeer(port, hostA.getBootstrapToken(), "browser-handoff-peer", { + platform: "macOS", + deviceType: "browser", + capabilities: [SYNC_INVALIDATION_ONLY_V1_CAPABILITY], + }); + + expect(hostA.getPeerStates()).toEqual([ + expect.objectContaining({ + deviceId: "browser-handoff-peer", + syncLag: 0, + }), + ]); + expect(browser.envelopes.some((envelope) => envelope.type === "changeset_batch")).toBe(false); + + await hostA.dispose(); + hostA = null; + const envelopeCountAfterDeposit = browser.envelopes.length; + + // This write lands after the old owner deposits the live socket but + // before the new owner adopts it. The deposited cursor is the only safe + // boundary for a same-DB invalidation-only browser. + db.dbVersion = 2; + db.changes.push(makeHostChange(2, 1)); + hostB = createSyncHostService({ + ...createHandoffHostArgs(projectRoot, tokenPath, db), + sharedListener: listener, + pollIntervalMs: 25, + } as unknown as Parameters[0]); + await hostB.waitUntilListening(); + + const postHandoffBatch = await waitForValue( + () => browser?.envelopes + .slice(envelopeCountAfterDeposit) + .find((envelope) => envelope.type === "changeset_batch"), + "same-DB post-handoff browser invalidation", + ); + expect((postHandoffBatch.payload as SyncChangesetBatchPayload).changes).toEqual([ + expect.objectContaining({ db_version: 2 }), + ]); + expect(browser.closeEvents).toEqual([]); + expect(browser.ws.readyState).toBe(WebSocket.OPEN); + expect(hostB.getPeerStates()).toEqual([ + expect.objectContaining({ + deviceId: "browser-handoff-peer", + }), + ]); + } finally { + browser?.ws.close(); + await hostA?.dispose(); + await hostB?.dispose(); + await listener.close(); + cleanup(); + } + }); + it("keeps personal and project chat subscriptions across handoff without restoring foreign quick looks", async () => { const rootA = createTempProjectRoot(); const rootB = createTempProjectRoot(); diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index 4d445381b..2b1fe421d 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -263,7 +263,7 @@ const DEFAULT_SYNC_MESSAGE_TIMEOUT_MS = 60_000; const MAX_SYNC_ARTIFACT_BYTES = 8 * 1024 * 1024; export const SYNC_HOST_CHAT_ACTIVE_BACKGROUND_BACKPRESSURE_BYTES = 512 * 1024; export const SYNC_HOST_CHAT_ACTIVE_CHANGESET_BATCH_BYTES = 64 * 1024; -export const SYNC_HOST_CHAT_ACTIVE_MAX_CHANGESET_DEFER_MS = 2_000; +export const SYNC_HOST_PRIORITY_MAX_CHANGESET_DEFER_MS = 2_000; const MOBILE_COMMAND_RESULT_CACHE_TTL_MS = 30 * 60 * 1000; const MOBILE_COMMAND_RESULT_CACHE_MAX_ENTRIES = 512; const CHANGESET_ACK_TIMEOUT_MS = 10_000; @@ -477,7 +477,7 @@ type PeerState = { awaitingHeartbeatAt: string | null; missedHeartbeatCount: number; backpressuredSinceMs: number | null; - changesetChatDeferredSinceMs: number | null; + changesetPriorityDeferredSinceMs: number | null; changesetRecoveryLevel: number; changesetRecoveryNotBeforeMs: number; remoteAddress: string | null; @@ -916,6 +916,37 @@ export function initialSyncHostCursorForPeer(args: { return Math.max(0, Math.floor(cursorForThisDb)); } +export function adoptedSyncHostCursorForPeer(args: { + peer: Pick; + serverDbSiteId: string; + serverDbVersion: number; + snapshotServerDbSiteId?: string | null; + snapshotLastKnownServerDbVersion?: number | null; +}): number { + const initialCursor = initialSyncHostCursorForPeer(args); + if ( + args.snapshotServerDbSiteId !== args.serverDbSiteId + || typeof args.snapshotLastKnownServerDbVersion !== "number" + || !Number.isFinite(args.snapshotLastKnownServerDbVersion) + ) { + return initialCursor; + } + const snapshotCursor = Math.max(0, Math.floor(args.snapshotLastKnownServerDbVersion)); + // Invalidation-only browsers have no replica cursor to merge. On a + // same-DB seamless adoption, the deposited cursor is the exact boundary: + // writes committed while the socket is parked must be exported by the new + // owner. A different DB still starts at that DB's current watermark. + if ( + args.peer.deviceType === "browser" + && args.peer.capabilities?.includes(SYNC_INVALIDATION_ONLY_V1_CAPABILITY) + ) { + return Math.min(Math.max(0, Math.floor(args.serverDbVersion)), snapshotCursor); + } + // Replica peers may have advertised a newer durable per-site cursor than + // the depositing host had observed, so retain the fresher same-DB value. + return Math.max(initialCursor, snapshotCursor); +} + export function shouldDeferSyncHostBackgroundChangesForChat(args: { subscribedChatSessionCount: number; bufferedAmount: number; @@ -2526,11 +2557,6 @@ export function createSyncHostService(args: SyncHostServiceArgs) { } catch (error) { args.logger.warn("sync_host.chat_poll_failed", { error: error instanceof Error ? error.message : String(error) }); } - // The per-peer message queue owns subscriptions, snapshots, and remote - // commands. A background export is synchronous and cannot be preempted, - // so never start one while any already-received foreground work remains - // queued or in flight. - if ([...peers].some((peer) => peer.queuedMessageCount > 0)) return; try { await pumpChanges(); } catch (error) { @@ -2854,7 +2880,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { awaitingHeartbeatAt: null, missedHeartbeatCount: 0, backpressuredSinceMs: null, - changesetChatDeferredSinceMs: null, + changesetPriorityDeferredSinceMs: null, changesetRecoveryLevel: 0, changesetRecoveryNotBeforeMs: 0, remoteAddress, @@ -3077,28 +3103,13 @@ export function createSyncHostService(args: SyncHostServiceArgs) { terminalInputDedupeLedger.restore(snapshot.terminalInputDedupe ?? []); peer.connectedAt = snapshot.connectedAt; const serverDbSiteId = args.db.sync.getSiteId(); - peer.lastKnownServerDbVersion = initialSyncHostCursorForPeer({ + peer.lastKnownServerDbVersion = adoptedSyncHostCursorForPeer({ peer: snapshot.metadata, serverDbSiteId, serverDbVersion: args.db.sync.getDbVersion(), + snapshotServerDbSiteId: snapshot.serverDbSiteId, + snapshotLastKnownServerDbVersion: snapshot.lastKnownServerDbVersion, }); - if ( - !( - snapshot.metadata.deviceType === "browser" - && snapshot.metadata.capabilities?.includes(SYNC_INVALIDATION_ONLY_V1_CAPABILITY) - ) - && snapshot.serverDbSiteId === serverDbSiteId - && typeof snapshot.lastKnownServerDbVersion === "number" - && Number.isFinite(snapshot.lastKnownServerDbVersion) - ) { - // Same project DB as the depositing host (e.g. a same-project host - // restart): its live ack watermark is fresher than the hello-time - // dbVersionBySite snapshot and avoids re-draining the backlog. - peer.lastKnownServerDbVersion = Math.max( - peer.lastKnownServerDbVersion, - Math.floor(snapshot.lastKnownServerDbVersion), - ); - } // Restore live subscriptions so streaming does not silently stop for // a peer that never observes a disconnect. Sessions from a different // project simply no-op on this host; the phone that REQUESTED a @@ -3983,18 +3994,18 @@ export function createSyncHostService(args: SyncHostServiceArgs) { }; } - function finishChangesetChatDeferral( + function finishChangesetPriorityDeferral( peer: PeerState, reason: "pressure_relieved" | "no_changes" | "batch_admitted", nowMs: number, ): void { - if (peer.changesetChatDeferredSinceMs == null) return; - args.logger.debug("sync_host.changeset_chat_deferral_ended", { + if (peer.changesetPriorityDeferredSinceMs == null) return; + args.logger.debug("sync_host.changeset_priority_deferral_ended", { peerDeviceId: peer.metadata?.deviceId ?? null, reason, - deferredMs: Math.max(0, nowMs - peer.changesetChatDeferredSinceMs), + deferredMs: Math.max(0, nowMs - peer.changesetPriorityDeferredSinceMs), }); - peer.changesetChatDeferredSinceMs = null; + peer.changesetPriorityDeferredSinceMs = null; } function abandonPendingChangesetBatch( @@ -4731,29 +4742,36 @@ export function createSyncHostService(args: SyncHostServiceArgs) { continue; } if (currentDbVersion <= peer.lastKnownServerDbVersion) { - finishChangesetChatDeferral(peer, "no_changes", nowMs); + finishChangesetPriorityDeferral(peer, "no_changes", nowMs); continue; } if (nowMs < peer.changesetRecoveryNotBeforeMs) continue; - if (shouldDeferBackgroundChangesForChat(peer)) { - if (peer.changesetChatDeferredSinceMs == null) { - peer.changesetChatDeferredSinceMs = nowMs; - args.logger.debug("sync_host.changeset_chat_deferral_started", { + const hasQueuedForegroundWork = peer.queuedMessageCount > 0; + const chatBackpressured = shouldDeferBackgroundChangesForChat(peer); + if (hasQueuedForegroundWork || chatBackpressured) { + if (peer.changesetPriorityDeferredSinceMs == null) { + peer.changesetPriorityDeferredSinceMs = nowMs; + args.logger.debug("sync_host.changeset_priority_deferral_started", { peerDeviceId: peer.metadata.deviceId, bufferedAmount: peer.ws.bufferedAmount, thresholdBytes: SYNC_HOST_CHAT_ACTIVE_BACKGROUND_BACKPRESSURE_BYTES, - maxDeferMs: SYNC_HOST_CHAT_ACTIVE_MAX_CHANGESET_DEFER_MS, + hasQueuedForegroundWork, + chatBackpressured, + maxDeferMs: SYNC_HOST_PRIORITY_MAX_CHANGESET_DEFER_MS, }); } - if (nowMs - peer.changesetChatDeferredSinceMs < SYNC_HOST_CHAT_ACTIVE_MAX_CHANGESET_DEFER_MS) { + if (nowMs - peer.changesetPriorityDeferredSinceMs < SYNC_HOST_PRIORITY_MAX_CHANGESET_DEFER_MS) { continue; } } else { - finishChangesetChatDeferral(peer, "pressure_relieved", nowMs); + finishChangesetPriorityDeferral(peer, "pressure_relieved", nowMs); } const recoveryLimits = changesetBatchLimits(peer); const chatLimits = syncHostChangesetBatchOptionsForChat({ - subscribedChatSessionCount: peer.subscribedChatSessionIds.size, + // Once a foreground queue ages past the deadline, admit only the same + // small batch used for an active chat. This bounds the synchronous + // export pause before the peer returns to its serialized messages. + subscribedChatSessionCount: peer.subscribedChatSessionIds.size + (hasQueuedForegroundWork ? 1 : 0), maxRows: recoveryLimits.maxRows, maxBytes: recoveryLimits.maxBytes, }); @@ -4795,7 +4813,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { toDbVersion: exportedThroughDbVersion, reason: "peer_owned_changes_only", }); - finishChangesetChatDeferral(peer, "no_changes", nowMs); + finishChangesetPriorityDeferral(peer, "no_changes", nowMs); continue; } const pending = sendNextChangesetBatch( @@ -4813,7 +4831,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { } else { peer.lastKnownServerDbVersion = Math.max(peer.lastKnownServerDbVersion, pending.toDbVersion); } - finishChangesetChatDeferral(peer, "batch_admitted", nowMs); + finishChangesetPriorityDeferral(peer, "batch_admitted", nowMs); lastBroadcastAt = nowIso(); } } From 2fd65ae5e844258846bae4774dd8dc1885a93a5d Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 02:02:00 -0400 Subject: [PATCH 12/53] fix(web): bound chat streams and stabilize history paging --- .../services/chat/agentChatService.test.ts | 77 +++++++++++++++++++ .../main/services/chat/agentChatService.ts | 31 ++++++-- .../adapter/__tests__/adapter.test.ts | 47 ++++++++++- .../renderer/webclient/adapter/agentChat.ts | 17 +++- 4 files changed, 165 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 6386f16a4..f56d03bfa 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -21945,6 +21945,83 @@ describe("createAgentChatService", () => { await pendingInput; }); + it("keeps small snapshots and older pages on one deterministic transcript", async () => { + installRealTranscriptParser(); + const { service } = createService(); + const session = await service.createSession({ laneId: "lane-1", provider: "codex", model: "gpt-5.4" }); + + const LINE_BYTES = 8 * 1024; + const durableTargetCount = 8; + const durableLines = [ + ...Array.from({ length: durableTargetCount }, (_, index) => paddedLine({ + sessionId: session.id, + timestamp: new Date(Date.UTC(2026, 0, 2, 0, 0, index)).toISOString(), + event: { type: "text", text: `durable-${index}-` }, + sequence: index, + }, LINE_BYTES)), + // More than 128 KiB of unrelated trailing data makes the small + // hydration probe see no events for this session, while the fixed + // 2 MiB identity probe still sees the durable target history. + ...Array.from({ length: 18 }, (_, index) => paddedLine({ + sessionId: "other-session", + timestamp: new Date(Date.UTC(2026, 0, 2, 1, 0, index)).toISOString(), + event: { type: "text", text: `foreign-${index}-` }, + sequence: 1_000 + index, + }, LINE_BYTES)), + ]; + const legacyLines = Array.from({ length: 20 }, (_, index) => paddedLine({ + sessionId: session.id, + timestamp: new Date(Date.UTC(2026, 0, 1, 0, 0, index)).toISOString(), + event: { type: "text", text: `legacy-${index}-` }, + sequence: 2_000 + index, + }, LINE_BYTES)); + + const legacyTranscript = path.join(tmpRoot, "transcripts", `${session.id}.chat.jsonl`); + const durableTranscript = path.join(tmpRoot, ".ade", "transcripts", "chat", `${session.id}.jsonl`); + fs.mkdirSync(path.dirname(legacyTranscript), { recursive: true }); + fs.mkdirSync(path.dirname(durableTranscript), { recursive: true }); + fs.writeFileSync(legacyTranscript, legacyLines.join(""), "utf8"); + fs.writeFileSync(durableTranscript, durableLines.join(""), "utf8"); + fs.utimesSync(legacyTranscript, new Date("2026-01-01T00:00:00.000Z"), new Date("2026-01-01T00:00:00.000Z")); + fs.utimesSync(durableTranscript, new Date("2026-01-02T00:00:00.000Z"), new Date("2026-01-02T00:00:00.000Z")); + vi.mocked(parseAgentChatTranscript).mockClear(); + + const maxBytes = 128 * 1024; + const history = service.getChatEventHistory(session.id, { maxEvents: 512, maxBytes }); + expect(history.tailStartOffset).toBe(Buffer.byteLength(durableLines.join(""), "utf8")); + expect(history.events.some((entry) => + entry.event.type === "text" && entry.event.text.startsWith("legacy-"), + )).toBe(false); + + const durableSequences = history.events.flatMap((entry) => + entry.event.type === "text" && entry.event.text.startsWith("durable-") && typeof entry.sequence === "number" + ? [entry.sequence] + : []); + let beforeOffset = history.tailStartOffset!; + while (beforeOffset > 0) { + const parseCallsBeforePage = vi.mocked(parseAgentChatTranscript).mock.calls.length; + const page = service.getChatEventHistoryPage(session.id, { beforeOffset, maxBytes }); + // Candidate ranking reuses both fixed-window cache entries. The only + // parse here is the page payload itself, rather than synchronously + // re-reading both candidates on every scroll-back request. + expect(parseAgentChatTranscript).toHaveBeenCalledTimes(parseCallsBeforePage + 1); + expect(page.events.some((entry) => + entry.event.type === "text" && entry.event.text.startsWith("legacy-"), + )).toBe(false); + durableSequences.push(...page.events.flatMap((entry) => + entry.event.type === "text" && entry.event.text.startsWith("durable-") && typeof entry.sequence === "number" + ? [entry.sequence] + : [])); + expect(page.startOffset).toBeLessThan(beforeOffset); + beforeOffset = page.startOffset; + } + + expect(durableSequences.slice().sort((a, b) => a - b)).toEqual( + Array.from({ length: durableTargetCount }, (_, index) => index), + ); + expect(new Set(durableSequences).size).toBe(durableSequences.length); + }); + it("reports a null tailStartOffset when the transcript is fully hydrated", async () => { const { service } = createService(); const session = await service.createSession({ laneId: "lane-1", provider: "codex", model: "gpt-5.4" }); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 00209210e..53ebf1dc5 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -6592,6 +6592,10 @@ export function createAgentChatService(args: { const CHAT_EVENT_HISTORY_RESPONSE_MAX_PER_SESSION = 20_000; const CHAT_EVENT_HISTORY_TRANSCRIPT_MAX_BYTES = 2_000_000; const CHAT_EVENT_HISTORY_TRANSCRIPT_CACHE_MAX_SESSIONS = 32; + // Path selection has at most three candidates; the selected path can also + // be parsed at a smaller client hydration window. Retain that working set + // without allowing arbitrary caller budgets to grow the cache indefinitely. + const CHAT_EVENT_HISTORY_TRANSCRIPT_CACHE_MAX_ENTRIES_PER_SESSION = 4; // Byte budgets alongside the event-count caps above. Individual events are // unbounded (multi-MB tool outputs exist in real transcripts), so count caps // alone cannot keep a history snapshot under the desktop RPC client's @@ -6989,7 +6993,14 @@ export function createAgentChatService(args: { entry: TranscriptHistoryCacheEntry, ): void => { const sessionEntries = transcriptHistoryCacheBySession.get(sessionId) ?? new Map(); - sessionEntries.set(entry.transcriptPath, entry); + const cacheKey = `${entry.transcriptPath}\0${entry.maxBytes}`; + sessionEntries.delete(cacheKey); + sessionEntries.set(cacheKey, entry); + while (sessionEntries.size > CHAT_EVENT_HISTORY_TRANSCRIPT_CACHE_MAX_ENTRIES_PER_SESSION) { + const oldestKey = sessionEntries.keys().next().value; + if (typeof oldestKey !== "string") break; + sessionEntries.delete(oldestKey); + } transcriptHistoryCacheBySession.delete(sessionId); transcriptHistoryCacheBySession.set(sessionId, sessionEntries); while (transcriptHistoryCacheBySession.size > CHAT_EVENT_HISTORY_TRANSCRIPT_CACHE_MAX_SESSIONS) { @@ -8337,7 +8348,8 @@ export function createAgentChatService(args: { 1_024, Math.min(CHAT_EVENT_HISTORY_TRANSCRIPT_MAX_BYTES, Math.floor(requestedMaxBytes)), ); - const cached = transcriptHistoryCacheBySession.get(sessionId)?.get(transcriptPath); + const cacheKey = `${transcriptPath}\0${maxBytes}`; + const cached = transcriptHistoryCacheBySession.get(sessionId)?.get(cacheKey); if ( cached && cached.transcriptPath === transcriptPath @@ -8426,7 +8438,6 @@ export function createAgentChatService(args: { const resolveBestTranscriptPathForSessionId = ( sessionId: string, managed?: ManagedChatSession | null, - maxBytes = CHAT_EVENT_HISTORY_TRANSCRIPT_MAX_BYTES, ): string | null => { type Candidate = { path: string; @@ -8457,7 +8468,17 @@ export function createAgentChatService(args: { if (!transcriptPath) continue; const stat = fs.statSync(transcriptPath); if (!stat.isFile()) continue; - const parsed = parseTranscriptHistoryTail(sessionId, transcriptPath, maxBytes); + // Transcript identity must not depend on the response byte budget. + // Snapshot cursors are raw offsets into this selected file, and older + // history pages resolve the path again without carrying an opaque path + // token. Rank every caller's candidates through the same fixed window + // so a small web hydration and a later page cannot address different + // legacy/durable transcripts. + const parsed = parseTranscriptHistoryTail( + sessionId, + transcriptPath, + CHAT_EVENT_HISTORY_TRANSCRIPT_MAX_BYTES, + ); const candidate: Candidate = { path: transcriptPath, size: parsed.endOffset, @@ -8489,7 +8510,7 @@ export function createAgentChatService(args: { envelopeStartOffsets: Map; } => { const managed = managedSessions.get(sessionId); - const transcriptPath = resolveBestTranscriptPathForSessionId(sessionId, managed, maxBytes); + const transcriptPath = resolveBestTranscriptPathForSessionId(sessionId, managed); if (transcriptPath) { try { return parseTranscriptHistoryTail(sessionId, transcriptPath, maxBytes); diff --git a/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts b/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts index dc04e3bd6..74463b475 100644 --- a/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts +++ b/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts @@ -288,6 +288,47 @@ describe("createAdeWebAdapter", () => { adapter.dispose(); }); + it("keeps only the eight most recently used project chat subscriptions", async () => { + fake.descriptors = descriptors(["chat.getChatEventHistory"]); + const adapter = createAdeWebAdapter(fake.asClient()); + adapter.bindProject(project, "project-1"); + + for (let index = 1; index <= 8; index += 1) { + await adapter.ade.agentChat.getEventHistory({ sessionId: `chat-${index}` }); + } + // Reusing chat-1 makes it the most recent entry without opening another + // wire subscription, so chat-2 is the oldest when chat-9 is selected. + await adapter.ade.agentChat.getEventHistory({ sessionId: "chat-1" }); + await adapter.ade.agentChat.getEventHistory({ sessionId: "chat-9" }); + + expect(fake.chatSubscribeCalls.map((call) => call.sessionId)).toEqual([ + "chat-1", + "chat-2", + "chat-3", + "chat-4", + "chat-5", + "chat-6", + "chat-7", + "chat-8", + "chat-9", + ]); + expect(fake.chatUnsubscribeCalls).toEqual(["chat-2"]); + + adapter.dispose(); + expect(new Set(fake.chatUnsubscribeCalls)).toEqual(new Set([ + "chat-1", + "chat-2", + "chat-3", + "chat-4", + "chat-5", + "chat-6", + "chat-7", + "chat-8", + "chat-9", + ])); + expect(fake.chatUnsubscribeCalls).toHaveLength(9); + }); + it("does not subscribe every chat returned by a session-list read", async () => { fake.descriptors = descriptors([ "chat.listSessions", @@ -1275,6 +1316,7 @@ class FakeAdeSyncClient { terminalResizes: Array<{ sessionId: string; cols: number; rows: number }> = []; terminalSubscribeCalls: Array<{ sessionId: string; opts: { maxBytes?: number } }> = []; chatSubscribeCalls: Array<{ sessionId: string; opts: Record }> = []; + chatUnsubscribeCalls: string[] = []; terminalUnsubscribeCalls: string[] = []; terminalHistoryCalls: TerminalHistoryCall[] = []; terminalHistoryResults = new Map(); @@ -1356,7 +1398,10 @@ class FakeAdeSyncClient { opts: opts && typeof opts === "object" ? { ...(opts as Record) } : {}, }); this.chatHandlers.set(sessionId, handlers); - return () => this.chatHandlers.delete(sessionId); + return () => { + this.chatUnsubscribeCalls.push(sessionId); + if (this.chatHandlers.get(sessionId) === handlers) this.chatHandlers.delete(sessionId); + }; } subscribeTerminal(sessionId: string, opts: unknown, handlers: TerminalHandlers): () => void { diff --git a/apps/desktop/src/renderer/webclient/adapter/agentChat.ts b/apps/desktop/src/renderer/webclient/adapter/agentChat.ts index 60e669e22..cdf9624c4 100644 --- a/apps/desktop/src/renderer/webclient/adapter/agentChat.ts +++ b/apps/desktop/src/renderer/webclient/adapter/agentChat.ts @@ -18,6 +18,7 @@ import { requestDataUrl, requestFileBlob } from "./infra/fileBlob"; const WEB_CHAT_INITIAL_SNAPSHOT_MAX_BYTES = 128 * 1024; const WEB_CHAT_INITIAL_HISTORY_MAX_EVENTS = 512; const WEB_CHAT_INITIAL_HISTORY_MAX_BYTES = 128 * 1024; +const WEB_CHAT_PROJECT_SUBSCRIPTION_LIMIT = 8; export function createAgentChatNamespace(infra: AdapterInfra): AdeNamespace<"agentChat"> { const { client, commands, events, terminalRegistry } = infra; @@ -40,7 +41,21 @@ export function createAgentChatNamespace(infra: AdapterInfra): AdeNamespace<"age } function ensureChatSubscription(sessionId: string | null | undefined): void { - if (!sessionId || chatSubscriptions.has(sessionId)) return; + if (!sessionId) return; + const existingUnsubscribe = chatSubscriptions.get(sessionId); + if (existingUnsubscribe) { + // Map insertion order is the LRU order. A selected/reused chat should + // survive ahead of background chats visited earlier in this tab. + chatSubscriptions.delete(sessionId); + chatSubscriptions.set(sessionId, existingUnsubscribe); + return; + } + while (chatSubscriptions.size >= WEB_CHAT_PROJECT_SUBSCRIPTION_LIMIT) { + const oldest = chatSubscriptions.entries().next().value as [string, () => void] | undefined; + if (!oldest) break; + chatSubscriptions.delete(oldest[0]); + oldest[1](); + } const unsubscribe = client.subscribeChat( sessionId, { maxBytes: WEB_CHAT_INITIAL_SNAPSHOT_MAX_BYTES }, From d40079d8fd9058d3d8c047589c3c94017a879fd7 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 02:02:41 -0400 Subject: [PATCH 13/53] fix(web): require invalidation sync acceptance --- .../src/services/sync/syncHostService.test.ts | 31 +++++++ .../src/services/sync/syncHostService.ts | 7 ++ .../webclient/sync/__tests__/sync.test.ts | 89 +++++++++++++++++++ .../src/renderer/webclient/sync/connection.ts | 54 +++++++++-- apps/desktop/src/shared/types/sync.ts | 11 +++ 5 files changed, 184 insertions(+), 8 deletions(-) diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts index 49817be1c..7d6dbe2ec 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -192,6 +192,37 @@ describe("buildSyncHostHelloOkPayload", () => { expect(syncConnectionTransportForOrigin("relay-bridge")).toBe("relay"); }); + it("acknowledges invalidation-only sync only to peers that requested it", () => { + const peer = { + deviceId: "browser-1", + deviceName: "ADE Browser", + platform: "macOS", + deviceType: "browser", + siteId: "browser-site-1", + dbVersion: 0, + capabilities: [SYNC_INVALIDATION_ONLY_V1_CAPABILITY], + } satisfies SyncPeerMetadata; + const base = { + brain: peer, + serverDbVersion: 0, + heartbeatIntervalMs: 30_000, + pollIntervalMs: 400, + projectCatalog: { projects: [] }, + projectCatalogEnabled: false, + projectActionsEnabled: false, + crossProjectChatEnabled: false, + remoteCommandSupportedActions: [], + remoteCommandDescriptors: [], + localCommandDescriptors: [], + }; + + expect(buildSyncHostHelloOkPayload({ ...base, peer }).features.invalidationOnlyV1).toEqual({ enabled: true }); + expect(buildSyncHostHelloOkPayload({ + ...base, + peer: { ...peer, deviceType: "phone", capabilities: [] }, + }).features).not.toHaveProperty("invalidationOnlyV1"); + }); + it("advertises daemon-hosted project catalog support in hello_ok without desktop", () => { const peer = { deviceId: "ios-phone-1", diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index 2b1fe421d..f443bec99 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -1109,6 +1109,13 @@ export function buildSyncHostHelloOkPayload(args: { chatStreaming: { enabled: true, }, + ...(args.peer.capabilities?.includes(SYNC_INVALIDATION_ONLY_V1_CAPABILITY) + ? { + invalidationOnlyV1: { + enabled: true, + }, + } + : {}), crossProjectChat: { enabled: args.crossProjectChatEnabled, }, diff --git a/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts b/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts index ee9c4165c..a6bc64d86 100644 --- a/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts +++ b/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts @@ -12,6 +12,7 @@ import type { AdeAccountMachine } from "../../../../shared/types/account"; import { AdeSyncClient } from "../client"; import { BACKOFF_STABLE_CONNECTED_MS, + INVALIDATION_ONLY_V1_HOST_UPDATE_MESSAGE, RELAY_READY_NEGOTIATION_WINDOW_MS, SyncConnection, type WebSocketLike, @@ -70,6 +71,7 @@ const features: SyncFeatureFlags = { fileAccess: true, terminalStreaming: true, chatStreaming: { enabled: true }, + invalidationOnlyV1: { enabled: true }, projectCatalog: { enabled: true }, projectActions: { enabled: true }, changesetAck: { enabled: true }, @@ -142,6 +144,15 @@ function helloOk(projectId = "project-1"): SyncHelloOkPayload { }; } +function legacyHelloOk(projectId = "project-1"): SyncHelloOkPayload { + const payload = helloOk(projectId); + const { invalidationOnlyV1: _ignored, ...featuresWithoutAcceptance } = payload.features; + return { + ...payload, + features: featuresWithoutAcceptance, + }; +} + function relayHelloOk(nowMs: number, options: { refreshAfterMs?: number; expiresAfterMs?: number; @@ -762,6 +773,84 @@ describe("browser sync connection and client", () => { vi.unstubAllGlobals(); }); + it("rejects a saved browser pairing when the host does not accept invalidation-only sync", async () => { + const environment = await makeEnvironment(new MemoryStorage(), { dpopPublicKeyX963: null }); + vi.useFakeTimers(); + const script = createSocketFactory((socket, envelope) => { + if (envelope.type === "hello") { + socket.serverSend({ type: "hello_ok", requestId: envelope.requestId, payload: legacyHelloOk() }); + } + }); + const connection = new SyncConnection({ socketFactory: script.factory, document: null }); + + const outcome = connection.connect(environment, [ + { url: "ws://127.0.0.1:8787", kind: "loopback", dialable: true }, + { url: "ws://127.0.0.1:8788", kind: "loopback", dialable: true }, + ]).catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(0); + await expect(outcome).resolves.toMatchObject({ + code: "invalidation_only_v1_unsupported", + message: INVALIDATION_ONLY_V1_HOST_UPDATE_MESSAGE, + }); + + expect(script.sockets).toHaveLength(1); + expect(script.sockets[0]?.closeHistory).toContainEqual({ code: 4000, reason: "Incompatible ADE host" }); + expect(connection.getStatus()).toMatchObject({ + state: "error", + error: INVALIDATION_ONLY_V1_HOST_UPDATE_MESSAGE, + }); + await vi.advanceTimersByTimeAsync(60_000); + expect(script.sockets).toHaveLength(1); + connection.dispose(); + }); + + it("rejects account adoption before creating trust when the host is too old", async () => { + const environment = await makeEnvironment(new MemoryStorage(), { dpopPublicKeyX963: null }); + vi.useFakeTimers(); + const buildEnvironment = vi.fn(() => environment); + const script = createSocketFactory((socket, envelope) => { + if (envelope.type === "hello") { + const peer = (envelope.payload as { peer: SyncPeerMetadata }).peer; + socket.serverSend({ + type: "hello_ok", + requestId: envelope.requestId, + payload: { + ...legacyHelloOk(), + accountPairing: { deviceId: peer.deviceId, secret: "new-pairing-secret" }, + }, + }); + } + }); + const connection = new SyncConnection({ socketFactory: script.factory, document: null }); + + const outcome = connection.pairWithAccount({ + endpoints: [ + { url: "ws://127.0.0.1:8787", kind: "loopback", dialable: true }, + { url: "ws://127.0.0.1:8788", kind: "loopback", dialable: true }, + ], + peer: { ...hostPeer, deviceId: "new-browser-device", deviceType: "browser" }, + accountToken: "account-token", + createDpop: async () => ({ timestamp: 1, nonce: "nonce", signature: "signature" }), + expectedHostDeviceId: hostPeer.deviceId, + existingPairing: null, + buildEnvironment, + }).catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(0); + await expect(outcome).resolves.toMatchObject({ + code: "invalidation_only_v1_unsupported", + message: INVALIDATION_ONLY_V1_HOST_UPDATE_MESSAGE, + }); + + expect(buildEnvironment).not.toHaveBeenCalled(); + expect(script.sockets).toHaveLength(1); + expect(script.sockets[0]?.closeHistory).toContainEqual({ code: 4000, reason: "Incompatible ADE host" }); + expect(connection.getStatus()).toMatchObject({ + state: "error", + error: INVALIDATION_ONLY_V1_HOST_UPDATE_MESSAGE, + }); + connection.dispose(); + }); + it("retries an old Worker on a fresh legacy socket without sending hello on ready-v2", async () => { const environment = await makeEnvironment(new MemoryStorage(), { dpopPublicKeyX963: null }); vi.useFakeTimers(); diff --git a/apps/desktop/src/renderer/webclient/sync/connection.ts b/apps/desktop/src/renderer/webclient/sync/connection.ts index 467aea307..e9c1ae213 100644 --- a/apps/desktop/src/renderer/webclient/sync/connection.ts +++ b/apps/desktop/src/renderer/webclient/sync/connection.ts @@ -66,6 +66,8 @@ const FULL_INVALIDATION_TABLES = [ "github", "rebase", ] as const; +export const INVALIDATION_ONLY_V1_HOST_UPDATE_MESSAGE = + "Update ADE on this Mac via Settings > General > Check for Updates, then retry."; export type WebSocketLike = { readonly readyState: number; @@ -164,6 +166,10 @@ export class SyncConnectionError extends Error { } } +function hostAcceptedInvalidationOnlyV1(payload: SyncHelloOkPayload): boolean { + return payload.features?.invalidationOnlyV1?.enabled === true; +} + function createDefaultSocket(url: string): WebSocketLike { return new WebSocket(url); } @@ -336,6 +342,7 @@ export class SyncConnection { || operationGeneration !== this.operationGeneration ) throw new StaleSocketAttemptError(); this.cleanupSocket(); + if (this.isTerminalConnectionError(lastError)) throw lastError; } } throw lastError ?? new Error("Failed to connect to the ADE account machine."); @@ -411,19 +418,13 @@ export class SyncConnection { lastError = error instanceof Error ? error : new Error(String(error)); if (lastError instanceof StaleSocketAttemptError) throw lastError; this.cleanupSocket(); - if ( - error instanceof SyncConnectionError - && (error.code === "attributed_auth_failed" || error.code === "terminal_auth_failed") - ) { + if (this.isTerminalConnectionError(lastError)) { break; } } } const message = lastError?.message ?? "Failed to connect to ADE machine."; - if ( - lastError instanceof SyncConnectionError - && (lastError.code === "attributed_auth_failed" || lastError.code === "terminal_auth_failed") - ) { + if (this.isTerminalConnectionError(lastError)) { this.emit("error", lastError); throw lastError; } @@ -547,6 +548,11 @@ export class SyncConnection { fail(new Error("Connected machine identity did not match the stored pairing.")); return; } + const compatibilityError = this.requireInvalidationOnlyV1(payload); + if (compatibilityError) { + fail(compatibilityError, "Incompatible ADE host"); + return; + } if (!this.finishConnected(socket, environment, endpoint, payload, generation)) { fail(new StaleSocketAttemptError(), "Connection attempt superseded"); return; @@ -720,6 +726,11 @@ export class SyncConnection { fail(new Error("Account machine identity did not match the verified directory record.")); return; } + const compatibilityError = this.requireInvalidationOnlyV1(payload); + if (compatibilityError) { + fail(compatibilityError, "Incompatible ADE host"); + return; + } let environment: WebClientEnvironmentRecord; try { environment = args.buildEnvironment(payload, endpoint, pairing); @@ -1127,6 +1138,33 @@ export class SyncConnection { return new SyncConnectionError(payload.message, "auth_failed", payload); } + private isTerminalConnectionError(error: unknown): error is SyncConnectionError { + return error instanceof SyncConnectionError + && ( + error.code === "attributed_auth_failed" + || error.code === "terminal_auth_failed" + || error.code === "invalidation_only_v1_unsupported" + ); + } + + private requireInvalidationOnlyV1(payload: SyncHelloOkPayload): SyncConnectionError | null { + if (hostAcceptedInvalidationOnlyV1(payload)) return null; + this.shouldReconnect = false; + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + this.setStatus({ + state: "error", + connectedAt: null, + error: INVALIDATION_ONLY_V1_HOST_UPDATE_MESSAGE, + }); + return new SyncConnectionError( + INVALIDATION_ONLY_V1_HOST_UPDATE_MESSAGE, + "invalidation_only_v1_unsupported", + ); + } + private startHeartbeat(intervalMs: number | undefined): void { this.heartbeatIntervalMs = Math.max( 5_000, diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index c086af503..7ffc01deb 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -427,6 +427,17 @@ export type SyncFeatureFlags = { chatStreaming: { enabled: true; }; + /** + * Browser invalidation-only sync contract. The host includes this only when + * the concrete hello requested `invalidationOnlyV1`, confirming it will not + * replay CRDT history to a browser with no local replica. + * + * Older hosts omit this additive feature. Browser clients that require the + * contract must fail closed; other clients safely ignore it. + */ + invalidationOnlyV1?: { + enabled: true; + }; /** * Cross-project chat "quick look": when enabled, the host honors a * `projectId`/`projectRootPath` override on `chat_subscribe` and streams a From df210d1e222e179eafe079eb2f7e1f6da74beabd Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 02:03:37 -0400 Subject: [PATCH 14/53] docs(web): record resilient hydration contract --- docs/features/sync-and-multi-device/crdt-model.md | 11 +++++++++++ docs/features/web-client/README.md | 11 ++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/features/sync-and-multi-device/crdt-model.md b/docs/features/sync-and-multi-device/crdt-model.md index 5c140539d..fa674d72c 100644 --- a/docs/features/sync-and-multi-device/crdt-model.md +++ b/docs/features/sync-and-multi-device/crdt-model.md @@ -324,6 +324,17 @@ most 2 seconds, so a busy chat cannot starve CRR convergence. The byte/row limits are split targets rather than hard transaction caps: one complete `db_version` group is admitted even when that group alone exceeds a target. +Hosted browsers negotiate `invalidationOnlyV1` because they have no local CRR +replica. A supporting host confirms the capability in `hello_ok`, starts a new +browser at the current database watermark, and sends only post-connect rows as +invalidation hints after the browser's initial full-domain refresh. Same-DB +socket handoff restores the deposited live cursor so writes committed during +the handoff window are not skipped. Foreground requests defer changesets only +for their own peer and for at most 2 seconds; the forced fairness batch is +bounded to the active-chat 64 KB/64-row limits. Browsers close with desktop +update guidance when an older host does not confirm the contract, preventing a +historical replay from overflowing the Relay bridge. + ### Apply ```sql diff --git a/docs/features/web-client/README.md b/docs/features/web-client/README.md index ce9cc5ed7..e9df6906a 100644 --- a/docs/features/web-client/README.md +++ b/docs/features/web-client/README.md @@ -189,7 +189,12 @@ Browser `window.ade` adapter: sync sub-protocols, and local browser-only state. The chat adapter subscribes only the selected or explicitly requested chat and bounds initial transcript hydration to 128 KiB; older events page in on demand instead of every chat - transcript competing with the active pane. It routes + transcript competing with the active pane. It retains at most eight + most-recently used chat streams and evicts the oldest before opening another, + keeping aggregate snapshots below the Relay bridge budget. Host-side + pagination ranks durable and legacy transcript candidates through one fixed + identity window, so the initial tail and every older page keep addressing the + same file even when their response byte budgets differ. It routes smart-link metadata through viewer-allowed `chat.resolveSmartLinkPreview` and falls back to the shared deterministic provider label when an older host does not advertise the action. It also routes @@ -372,6 +377,10 @@ Tests: advertises `invalidationOnlyV1`: the host starts it at the current database watermark, the browser performs one full-domain refresh after hello, and later `changeset_batch` envelopes identify only the domains that changed. + The host must confirm that contract through + `hello_ok.features.invalidationOnlyV1`; an older host is closed immediately + with concrete desktop-update guidance instead of being allowed to replay its + historical CRR backlog through Relay. - **Protocol version 1 extensions are additive.** The browser decodes the common envelope and ignores valid types it does not implement, including the desktop-only `rpc_*` and `fwd_*` channels. Unknown `hello_ok.features` keys From 6c35f8db0c70ad00a1a97425ea9dbcf1dc41d474 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 02:17:40 -0400 Subject: [PATCH 15/53] perf(chat): compact transcript cursor cache --- .../services/chat/agentChatService.test.ts | 31 +++++ .../main/services/chat/agentChatService.ts | 119 ++++++++++-------- 2 files changed, 101 insertions(+), 49 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index f56d03bfa..d814b3a74 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -21867,6 +21867,37 @@ describe("createAgentChatService", () => { expect(page.hasMore).toBe(false); }); + it("pages identical UTF-8 transcript rows by occurrence without skipping the older duplicate", async () => { + installRealTranscriptParser(); + const { service } = createService(); + const session = await service.createSession({ laneId: "lane-1", provider: "codex", model: "gpt-5.4" }); + const duplicate: AgentChatEventEnvelope = { + sessionId: session.id, + timestamp: "2026-06-10T10:00:00.000Z", + event: { type: "text", text: "héllo-🙂-漢字" }, + sequence: 1, + }; + const line = `${JSON.stringify(duplicate)}\n`; + const lineBytes = Buffer.byteLength(line, "utf8"); + expect(lineBytes).toBeGreaterThan(line.length); + + const transcriptFile = path.join(tmpRoot, "transcripts", `${session.id}.chat.jsonl`); + fs.writeFileSync(transcriptFile, `${line}${line}`, "utf8"); + + const history = service.getChatEventHistory(session.id, { maxEvents: 1 }); + expect(history.events).toHaveLength(1); + expect(history.events[0]?.event).toEqual(duplicate.event); + expect(history.tailStartOffset).toBe(lineBytes); + + const page = service.getChatEventHistoryPage(session.id, { + beforeOffset: history.tailStartOffset!, + }); + expect(page.events).toHaveLength(1); + expect(page.events[0]?.event).toEqual(duplicate.event); + expect(page.startOffset).toBe(0); + expect(page.hasMore).toBe(false); + }); + it("keeps a requested-byte snapshot seamless with its older page and unflushed ring events", async () => { installRealTranscriptParser(); const emitted: AgentChatEventEnvelope[] = []; diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 53ebf1dc5..9a788da1f 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -6971,7 +6971,17 @@ export function createAgentChatService(args: { endOffset: number; hasCapNotice: boolean; envelopes: AgentChatEventEnvelope[]; - envelopeStartOffsets: Map; + /** + * Cursor metadata keyed by the parsed envelope object itself. Values are + * null only when a custom/legacy parser produced an envelope that could + * not be aligned with a physical JSONL line. + * + * Keeping this index on the immutable cached envelopes avoids retaining a + * second payload-sized JSON.stringify(event) for every row. Object identity + * also distinguishes byte-for-byte duplicate rows without occurrence + * counters or per-request copies of offset arrays. + */ + envelopeStartOffsetByIdentity: WeakMap; }; const transcriptHistoryCacheBySession = new Map>(); type TranscriptSubagentSnapshotCacheEntry = { @@ -8341,7 +8351,7 @@ export function createAgentChatService(args: { startOffset: number; endOffset: number; hasCapNotice: boolean; - envelopeStartOffsets: Map; + envelopeStartOffsetByIdentity: WeakMap; } => { const stat = fs.statSync(transcriptPath); const maxBytes = Math.max( @@ -8358,16 +8368,7 @@ export function createAgentChatService(args: { && cached.maxBytes === maxBytes ) { rememberTranscriptHistoryCache(sessionId, cached); - return { - envelopes: cached.envelopes.slice(), - truncated: cached.truncated, - startOffset: cached.startOffset, - endOffset: cached.endOffset, - hasCapNotice: cached.hasCapNotice, - envelopeStartOffsets: new Map( - [...cached.envelopeStartOffsets].map(([key, offsets]) => [key, offsets.slice()]), - ), - }; + return cached; } const { raw, truncated, startOffset, endOffset } = readTranscriptTailForHistory( @@ -8378,7 +8379,7 @@ export function createAgentChatService(args: { const hasCapNotice = raw.includes(CHAT_TRANSCRIPT_LIMIT_NOTICE.trim()); const envelopes = parseAgentChatTranscript(raw) .filter((entry) => entry.sessionId === sessionId); - const envelopeStartOffsets = new Map(); + const physicalEnvelopeStartOffsets: number[] = []; let rawByteOffset = 0; for (const line of raw.split(/(?<=\n)/)) { const lineBytes = Buffer.byteLength(line, "utf8"); @@ -8386,11 +8387,13 @@ export function createAgentChatService(args: { if (trimmedLine) { try { const parsed = JSON.parse(trimmedLine) as AgentChatEventEnvelope; - if (parsed?.sessionId === sessionId && parsed.event && typeof parsed.event === "object") { - const key = `${parsed.timestamp}#${parsed.event.type}#${JSON.stringify(parsed.event)}`; - const offsets = envelopeStartOffsets.get(key) ?? []; - offsets.push(startOffset + rawByteOffset); - envelopeStartOffsets.set(key, offsets); + if ( + typeof parsed?.sessionId === "string" + && parsed.sessionId.trim() === sessionId + && parsed.event + && typeof parsed.event === "object" + ) { + physicalEnvelopeStartOffsets.push(startOffset + rawByteOffset); } } catch { // Legacy/splice-repaired lines remain readable through the canonical @@ -8399,7 +8402,15 @@ export function createAgentChatService(args: { } rawByteOffset += lineBytes; } - rememberTranscriptHistoryCache(sessionId, { + const envelopeStartOffsetByIdentity = new WeakMap(); + const offsetsAlignWithParsedEnvelopes = physicalEnvelopeStartOffsets.length === envelopes.length; + for (let index = 0; index < envelopes.length; index += 1) { + envelopeStartOffsetByIdentity.set( + envelopes[index]!, + offsetsAlignWithParsedEnvelopes ? physicalEnvelopeStartOffsets[index]! : null, + ); + } + const entry: TranscriptHistoryCacheEntry = { transcriptPath, size: stat.size, mtimeMs: stat.mtimeMs, @@ -8409,18 +8420,10 @@ export function createAgentChatService(args: { endOffset, hasCapNotice, envelopes, - envelopeStartOffsets, - }); - return { - envelopes: envelopes.slice(), - truncated, - startOffset, - endOffset, - hasCapNotice, - envelopeStartOffsets: new Map( - [...envelopeStartOffsets].map(([key, offsets]) => [key, offsets.slice()]), - ), + envelopeStartOffsetByIdentity, }; + rememberTranscriptHistoryCache(sessionId, entry); + return entry; }; const transcriptPathCandidatesForSessionId = ( @@ -8435,6 +8438,22 @@ export function createAgentChatService(args: { return [...new Set(candidates)]; }; + const readTranscriptHistoryCandidateMetadata = ( + sessionId: string, + transcriptPath: string, + ): { endOffset: number; envelopeCount: number; hasCapNotice: boolean } => { + const cachedWindow = parseTranscriptHistoryTail( + sessionId, + transcriptPath, + CHAT_EVENT_HISTORY_TRANSCRIPT_MAX_BYTES, + ); + return { + endOffset: cachedWindow.endOffset, + envelopeCount: cachedWindow.envelopes.length, + hasCapNotice: cachedWindow.hasCapNotice, + }; + }; + const resolveBestTranscriptPathForSessionId = ( sessionId: string, managed?: ManagedChatSession | null, @@ -8474,17 +8493,13 @@ export function createAgentChatService(args: { // token. Rank every caller's candidates through the same fixed window // so a small web hydration and a later page cannot address different // legacy/durable transcripts. - const parsed = parseTranscriptHistoryTail( - sessionId, - transcriptPath, - CHAT_EVENT_HISTORY_TRANSCRIPT_MAX_BYTES, - ); + const metadata = readTranscriptHistoryCandidateMetadata(sessionId, transcriptPath); const candidate: Candidate = { path: transcriptPath, - size: parsed.endOffset, + size: metadata.endOffset, mtimeMs: stat.mtimeMs, - envelopeCount: parsed.envelopes.length, - hasCapNotice: parsed.hasCapNotice, + envelopeCount: metadata.envelopeCount, + hasCapNotice: metadata.hasCapNotice, }; if (!best || isBetterCandidate(candidate, best)) { best = candidate; @@ -8507,7 +8522,7 @@ export function createAgentChatService(args: { truncated: boolean; startOffset: number; endOffset: number; - envelopeStartOffsets: Map; + envelopeStartOffsetByIdentity: WeakMap; } => { const managed = managedSessions.get(sessionId); const transcriptPath = resolveBestTranscriptPathForSessionId(sessionId, managed); @@ -8515,10 +8530,22 @@ export function createAgentChatService(args: { try { return parseTranscriptHistoryTail(sessionId, transcriptPath, maxBytes); } catch { - return { envelopes: [], truncated: false, startOffset: 0, endOffset: 0, envelopeStartOffsets: new Map() }; + return { + envelopes: [], + truncated: false, + startOffset: 0, + endOffset: 0, + envelopeStartOffsetByIdentity: new WeakMap(), + }; } } - return { envelopes: [], truncated: false, startOffset: 0, endOffset: 0, envelopeStartOffsets: new Map() }; + return { + envelopes: [], + truncated: false, + startOffset: 0, + endOffset: 0, + envelopeStartOffsetByIdentity: new WeakMap(), + }; }; // Resolve the best on-disk transcript path for a session the same @@ -8762,18 +8789,12 @@ export function createAgentChatService(args: { || parentVisibleLength > maxEvents || windowed.length < countWindowed.length; const truncated = transcriptTruncated || windowTruncated; - const transcriptKeys = new Set(transcriptHistory.envelopes.map(envelopeDedupKey)); - const availableOffsets = new Map( - [...transcriptHistory.envelopeStartOffsets].map(([key, offsets]) => [key, offsets.slice()]), - ); let firstReturnedTranscriptOffset: number | null = null; let returnedTranscriptEvent = false; for (const envelope of windowed) { - const key = envelopeDedupKey(envelope); - if (!transcriptKeys.has(key)) continue; + if (!transcriptHistory.envelopeStartOffsetByIdentity.has(envelope)) continue; returnedTranscriptEvent = true; - const offsets = availableOffsets.get(key); - const offset = offsets?.shift(); + const offset = transcriptHistory.envelopeStartOffsetByIdentity.get(envelope); if (offset != null) { firstReturnedTranscriptOffset = offset; break; From 8a0a3b09570a2ed31be69ca506a39ee99b38e489 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 02:24:09 -0400 Subject: [PATCH 16/53] fix(sync): isolate peer transcript polling --- .../src/services/sync/syncHostService.test.ts | 349 ++++++++++++++++++ .../src/services/sync/syncHostService.ts | 279 ++++++++++---- 2 files changed, 561 insertions(+), 67 deletions(-) diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts index 7d6dbe2ec..050e80d24 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -33,6 +33,8 @@ import { CHAT_EVENT_REPLAY_MAX_EVENTS, CONNECTION_ATTEMPT_RESERVATION_TTL_MS, SYNC_HOST_CHAT_ACTIVE_BACKGROUND_BACKPRESSURE_BYTES, + SYNC_HOST_CHAT_TRANSCRIPT_DELTA_MAX_BYTES, + SYNC_HOST_CHAT_TRANSCRIPT_MAX_RECORD_BYTES, SYNC_HOST_PRIORITY_MAX_CHANGESET_DEFER_MS, buildSyncHostHelloOkPayload, buildSyncProjectCatalogMessages, @@ -4050,6 +4052,183 @@ describe("initial hydration priority", () => { } }); + it("keeps an independent replica delivering and retrying while another peer's transcript read is stalled", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const transcriptPath = path.join(projectRoot, "transcripts", "stalled-chat.chat.jsonl"); + fs.mkdirSync(path.dirname(transcriptPath), { recursive: true }); + fs.writeFileSync(transcriptPath, "", "utf8"); + const session = { + id: "stalled-chat", + laneId: "lane-1", + transcriptPath, + status: "running", + runtimeState: "running", + lastOutputPreview: "", + }; + const state = { dbVersion: 0, changes: [] as CrsqlChangeRow[] }; + const logger = createDiscoveryLogger(); + const base = createHostArgs(projectRoot, []); + const host = createSyncHostService({ + ...base, + logger, + projectId: "project-1", + discoveryEnabled: false, + pollIntervalMs: 100, + db: { + sync: { + getSiteId: () => "site-host-transcript-fairness", + getDbVersion: () => state.dbVersion, + exportChangesSince: (fromDbVersion: number) => + state.changes.filter((change) => Number(change.db_version) > fromDbVersion), + applyChanges: () => ({ appliedCount: 0 }), + discardUnpublishedChangesForTables: () => {}, + }, + }, + sessionService: { + list: () => [session], + get: (id: string) => id === session.id ? session : null, + readTranscriptTail: async () => "", + }, + agentChatService: { + subscribeToEvents: vi.fn().mockReturnValue(() => {}), + getChatEventHistory: vi.fn().mockReturnValue({ + sessionId: session.id, + events: [], + truncated: false, + transcriptTruncated: false, + windowTruncated: false, + sessionFound: true, + }), + getSessionSummary: vi.fn().mockResolvedValue({ status: "active" }), + }, + deviceRegistryService: { + ...base.deviceRegistryService, + upsertPeerMetadata: vi.fn(), + }, + } as unknown as Parameters[0]); + let stalledPeer: Awaited> | null = null; + let replicaPeer: Awaited> | null = null; + let openSpy: { mockRestore(): void } | null = null; + let dateNowSpy: { mockRestore(): void } | null = null; + let releaseTranscriptRead = () => {}; + + try { + const port = await host.waitUntilListening(); + stalledPeer = await connectPeer(port, host.getBootstrapToken(), "stalled-chat-peer", { + capabilities: ["changesetAck"], + }); + replicaPeer = await connectPeer(port, host.getBootstrapToken(), "independent-replica-peer", { + capabilities: ["changesetAck"], + }); + stalledPeer.ws.send(encodeSyncEnvelope({ + type: "chat_subscribe", + requestId: "stalled-chat-subscribe", + projectId: "project-1", + payload: { sessionId: session.id }, + })); + await waitForEnvelope(stalledPeer.envelopes, "chat_subscribe", "stalled-chat-subscribe"); + + const realOpen = fs.promises.open.bind(fs.promises); + let markTranscriptReadStarted = () => {}; + const transcriptReadStarted = new Promise((resolve) => { + markTranscriptReadStarted = resolve; + }); + const transcriptReadGate = new Promise((resolve) => { + releaseTranscriptRead = resolve; + }); + let shouldStallTranscriptRead = true; + openSpy = vi.spyOn(fs.promises, "open").mockImplementation((async ( + ...openArgs: Parameters + ) => { + if (String(openArgs[0]) === transcriptPath && shouldStallTranscriptRead) { + shouldStallTranscriptRead = false; + markTranscriptReadStarted(); + await transcriptReadGate; + } + return realOpen(...openArgs); + }) as typeof fs.promises.open); + + const realDateNow = Date.now.bind(Date); + let clockOffsetMs = 0; + dateNowSpy = vi.spyOn(Date, "now").mockImplementation(() => realDateNow() + clockOffsetMs); + const chatEvent: AgentChatEventEnvelope = { + sessionId: session.id, + timestamp: "2026-07-22T10:00:00.000Z", + sequence: 1, + event: { type: "text", text: "chat must lead its own catch-up" }, + }; + fs.appendFileSync(transcriptPath, `${JSON.stringify(chatEvent)}\n`, "utf8"); + state.dbVersion = 1; + state.changes.push(makeChange(1, 0)); + const stalledEnvelopeStart = stalledPeer.envelopes.length; + + await transcriptReadStarted; + const firstReplicaBatch = await waitForValue( + () => replicaPeer?.envelopes.find((entry) => entry.type === "changeset_batch"), + "independent replica changeset while transcript is stalled", + ); + expect(stalledPeer.envelopes.slice(stalledEnvelopeStart).some((entry) => + entry.type === "changeset_batch" || entry.type === "chat_event" + )).toBe(false); + + const firstPayload = firstReplicaBatch.payload as SyncChangesetBatchPayload; + replicaPeer.ws.send(encodeSyncEnvelope({ + type: "changeset_ack", + requestId: firstPayload.batchId, + projectId: "project-1", + payload: { + batchId: firstPayload.batchId, + fromDbVersion: firstPayload.fromDbVersion, + toDbVersion: firstPayload.toDbVersion, + appliedDbVersion: firstPayload.fromDbVersion, + appliedCount: 0, + ok: false, + error: { code: "apply_failed", message: "retry deterministically" }, + } satisfies SyncChangesetAckPayload, + })); + await waitForValue( + () => logger.warn.mock.calls.find(([event, fields]) => + event === "sync_host.changeset_ack_failed" + && fields?.peerDeviceId === "independent-replica-peer" + ), + "independent replica retry scheduling", + ); + clockOffsetMs += 60_000; + await waitForValue( + () => replicaPeer?.envelopes.filter((entry) => + entry.type === "changeset_batch" + && (entry.payload as SyncChangesetBatchPayload).batchId === firstPayload.batchId + ).length === 2 ? true : null, + "independent replica retry while transcript remains stalled", + ); + + releaseTranscriptRead(); + const stalledChatEvent = await waitForValue( + () => stalledPeer?.envelopes.slice(stalledEnvelopeStart).find((entry) => entry.type === "chat_event"), + "stalled peer chat event after read release", + ); + const stalledBatch = await waitForValue( + () => stalledPeer?.envelopes.slice(stalledEnvelopeStart).find((entry) => entry.type === "changeset_batch"), + "stalled peer changeset after chat", + ); + expect(stalledChatEvent.payload).toMatchObject({ + sessionId: session.id, + event: { type: "text", text: "chat must lead its own catch-up" }, + }); + expect(stalledPeer.envelopes.indexOf(stalledChatEvent)).toBeLessThan( + stalledPeer.envelopes.indexOf(stalledBatch), + ); + } finally { + releaseTranscriptRead(); + openSpy?.mockRestore(); + dateNowSpy?.mockRestore(); + stalledPeer?.ws.close(); + replicaPeer?.ws.close(); + await host.dispose(); + cleanup(); + } + }); + it("hydrates the selected browser chat without replaying historical CRDT rows", async () => { const { projectRoot, cleanup } = createTempProjectRoot(); const transcriptPath = path.join(projectRoot, "transcripts", "selected-chat.chat.jsonl"); @@ -6388,6 +6567,176 @@ describe("chat_subscribe snapshots", () => { } }); + it("bounds transcript deltas at complete JSONL boundaries and recovers partial and oversized records", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const transcriptPath = path.join(projectRoot, "transcripts", "bounded-chat.chat.jsonl"); + fs.mkdirSync(path.dirname(transcriptPath), { recursive: true }); + fs.writeFileSync(transcriptPath, "", "utf8"); + const session = { + id: "bounded-chat", + laneId: "lane-1", + transcriptPath, + status: "running", + runtimeState: "running", + lastOutputPreview: "", + }; + const logger = createDiscoveryLogger(); + const base = createHostArgs(projectRoot, []); + const host = createSyncHostService({ + ...base, + logger, + pollIntervalMs: 100, + projectId: "project-1", + db: { + sync: { + getSiteId: () => "site-host-bounded-chat", + getDbVersion: () => 0, + exportChangesSince: () => [], + applyChanges: () => ({ appliedCount: 0 }), + discardUnpublishedChangesForTables: () => {}, + }, + }, + deviceRegistryService: { + ...base.deviceRegistryService, + upsertPeerMetadata: vi.fn(), + }, + sessionService: { + list: () => [session], + get: (id: string) => id === session.id ? session : null, + readTranscriptTail: async () => "", + }, + agentChatService: { + subscribeToEvents: vi.fn().mockReturnValue(() => {}), + getChatEventHistory: vi.fn().mockReturnValue({ + sessionId: session.id, + events: [], + truncated: false, + transcriptTruncated: false, + windowTruncated: false, + sessionFound: true, + }), + getSessionSummary: vi.fn().mockResolvedValue({ status: "active" }), + }, + } as unknown as Parameters[0]); + let peer: Awaited> | null = null; + let openSpy: { mockRestore(): void } | null = null; + let releaseSecondRead = () => {}; + + const event = (sequence: number, text: string): AgentChatEventEnvelope => ({ + sessionId: session.id, + timestamp: new Date(Date.UTC(2026, 6, 22, 11, 0, sequence)).toISOString(), + sequence, + event: { type: "text", text }, + }); + const line = (entry: AgentChatEventEnvelope): Buffer => + Buffer.from(`${JSON.stringify(entry)}\n`, "utf8"); + const deliveredSequences = (): number[] => (peer?.envelopes ?? []) + .filter((entry) => entry.type === "chat_event") + .map((entry) => Number((entry.payload as AgentChatEventEnvelope).sequence)); + + try { + const port = await host.waitUntilListening(); + peer = await connectPeer(port, host.getBootstrapToken(), "bounded-chat-peer"); + peer.ws.send(encodeSyncEnvelope({ + type: "chat_subscribe", + requestId: "bounded-chat-subscribe", + projectId: "project-1", + payload: { sessionId: session.id }, + })); + await waitForEnvelope(peer.envelopes, "chat_subscribe", "bounded-chat-subscribe"); + + const largeText = "🙂".repeat(18_000); + const completeLines = [line(event(1, largeText)), line(event(2, largeText)), line(event(3, largeText))]; + expect(completeLines[0].length).toBeLessThan(SYNC_HOST_CHAT_TRANSCRIPT_DELTA_MAX_BYTES); + expect(completeLines[0].length + completeLines[1].length).toBeGreaterThan( + SYNC_HOST_CHAT_TRANSCRIPT_DELTA_MAX_BYTES, + ); + const partialLine = line(event(4, "split-🙂-record")); + const emojiOffset = partialLine.indexOf(Buffer.from("🙂", "utf8")); + expect(emojiOffset).toBeGreaterThan(0); + const splitOffset = emojiOffset + 2; + + const realOpen = fs.promises.open.bind(fs.promises); + let openCount = 0; + let markSecondReadStarted = () => {}; + const secondReadStarted = new Promise((resolve) => { + markSecondReadStarted = resolve; + }); + let markPartialRetryStarted = () => {}; + const partialRetryStarted = new Promise((resolve) => { + markPartialRetryStarted = resolve; + }); + const secondReadGate = new Promise((resolve) => { + releaseSecondRead = resolve; + }); + openSpy = vi.spyOn(fs.promises, "open").mockImplementation((async ( + ...openArgs: Parameters + ) => { + if (String(openArgs[0]) === transcriptPath) { + openCount += 1; + if (openCount === 2) { + markSecondReadStarted(); + await secondReadGate; + } else if (openCount === 4) { + markPartialRetryStarted(); + } + } + return realOpen(...openArgs); + }) as typeof fs.promises.open); + + fs.writeFileSync( + transcriptPath, + Buffer.concat([...completeLines, partialLine.subarray(0, splitOffset)]), + ); + await secondReadStarted; + await waitForValue( + () => deliveredSequences().length === 1 ? true : null, + "first bounded transcript chunk", + ); + expect(deliveredSequences()).toEqual([1]); + + releaseSecondRead(); + await partialRetryStarted; + await waitForValue( + () => deliveredSequences().length === 3 ? true : null, + "three complete bounded transcript records", + ); + expect(deliveredSequences()).toEqual([1, 2, 3]); + fs.appendFileSync(transcriptPath, partialLine.subarray(splitOffset)); + await waitForValue( + () => deliveredSequences().includes(4) ? true : null, + "UTF-8 split transcript record recovery", + ); + + const oversized = line(event( + 5, + "x".repeat(SYNC_HOST_CHAT_TRANSCRIPT_MAX_RECORD_BYTES + 1_024), + )); + const recovered = line(event(6, "record after explicit oversized recovery")); + fs.appendFileSync(transcriptPath, Buffer.concat([oversized, recovered])); + await waitForValue( + () => deliveredSequences().includes(6) ? true : null, + "record after oversized transcript recovery", + ); + expect(deliveredSequences()).toEqual([1, 2, 3, 4, 6]); + expect(logger.warn).toHaveBeenCalledWith( + "sync_host.chat_transcript_record_too_large", + expect.objectContaining({ + peerDeviceId: "bounded-chat-peer", + sessionId: session.id, + recordBytes: oversized.length, + maxRecordBytes: SYNC_HOST_CHAT_TRANSCRIPT_MAX_RECORD_BYTES, + }), + ); + } finally { + releaseSecondRead(); + openSpy?.mockRestore(); + peer?.ws.close(); + await host.dispose(); + cleanup(); + } + }); + it("replays a chat event whose optional live send was backpressured", async () => { const { projectRoot, cleanup } = createTempProjectRoot(); const transcriptPath = path.join(projectRoot, "transcripts", "chat-replay.chat.jsonl"); diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index f443bec99..427d69def 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -264,6 +264,8 @@ const MAX_SYNC_ARTIFACT_BYTES = 8 * 1024 * 1024; export const SYNC_HOST_CHAT_ACTIVE_BACKGROUND_BACKPRESSURE_BYTES = 512 * 1024; export const SYNC_HOST_CHAT_ACTIVE_CHANGESET_BATCH_BYTES = 64 * 1024; export const SYNC_HOST_PRIORITY_MAX_CHANGESET_DEFER_MS = 2_000; +export const SYNC_HOST_CHAT_TRANSCRIPT_DELTA_MAX_BYTES = 128 * 1024; +export const SYNC_HOST_CHAT_TRANSCRIPT_MAX_RECORD_BYTES = 2 * 1024 * 1024; const MOBILE_COMMAND_RESULT_CACHE_TTL_MS = 30 * 60 * 1000; const MOBILE_COMMAND_RESULT_CACHE_MAX_ENTRIES = 512; const CHANGESET_ACK_TIMEOUT_MS = 10_000; @@ -490,6 +492,10 @@ type PeerState = { subscribedChatSessionIds: Set; chatSubscriptionScopes: Map; chatTranscriptOffsets: Map; + // Progress while scanning one JSONL record that exceeded a normal bounded + // transcript-delta read. The durable offset above still advances only after + // a complete newline boundary is found and a deliverable record is parsed. + chatTranscriptScanOffsets: Map; chatEventIdsSent: Map>; // Subscriptions resolved outside the active project's session service: // machine-scoped personal chats and cross-project quick looks. Scope stays @@ -2514,7 +2520,10 @@ export function createSyncHostService(args: SyncHostServiceArgs) { let tailnetServePublishSequence = 0; let tailnetServeActivePublishToken = 0; let discoveryEnabled = args.discoveryEnabled !== false; - let pollPumpInFlight = false; + // A peer owns one serialized chat -> changeset poll chain. Keeping the + // in-flight gate per peer prevents a slow transcript filesystem read from + // blocking unrelated peers or their later ack retries. + const pollPumpPeersInFlight = new Set(); // All-projects roster (mobile hub) coalescing state. Each subscribed peer // carries its own monotonic seq (PeerState.rosterSeq); clients re-snapshot on // any seq discontinuity. @@ -2554,24 +2563,33 @@ export function createSyncHostService(args: SyncHostServiceArgs) { }); const runPollPump = (): void => { - if (disposed || pollPumpInFlight) return; - pollPumpInFlight = true; - void (async () => { - try { - // Transcript reads are asynchronous. Finish them before entering the - // synchronous CRR export scan so a large catch-up cannot overtake chat. - await pumpChatEvents(); - } catch (error) { - args.logger.warn("sync_host.chat_poll_failed", { error: error instanceof Error ? error.message : String(error) }); - } - try { - await pumpChanges(); - } catch (error) { - args.logger.warn("sync_host.poll_failed", { error: error instanceof Error ? error.message : String(error) }); - } - })().finally(() => { - pollPumpInFlight = false; - }); + if (disposed) return; + for (const peer of peers) { + if (pollPumpPeersInFlight.has(peer)) continue; + pollPumpPeersInFlight.add(peer); + void (async () => { + try { + // Preserve chat-first hydration for this peer without making its + // transcript latency part of any other peer's catch-up path. + await pumpChatEvents(peer); + } catch (error) { + args.logger.warn("sync_host.chat_poll_failed", { + peerDeviceId: peer.metadata?.deviceId ?? null, + error: error instanceof Error ? error.message : String(error), + }); + } + try { + await pumpChanges(peer); + } catch (error) { + args.logger.warn("sync_host.poll_failed", { + peerDeviceId: peer.metadata?.deviceId ?? null, + error: error instanceof Error ? error.message : String(error), + }); + } + })().finally(() => { + pollPumpPeersInFlight.delete(peer); + }); + } }; const pollTimer = setInterval(() => { @@ -2900,6 +2918,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { subscribedChatSessionIds: new Set(), chatSubscriptionScopes: new Map(), chatTranscriptOffsets: new Map(), + chatTranscriptScanOffsets: new Map(), chatEventIdsSent: new Map(), resolvedChatTranscriptPaths: new Map(), pendingChangesetBatch: null, @@ -4508,32 +4527,141 @@ export function createSyncHostService(args: SyncHostServiceArgs) { async function readChatTranscriptEventsSince( transcriptPath: string, startOffset: number, - ): Promise<{ events: AgentChatEventEnvelope[]; nextOffset: number }> { + scanOffset: number | null, + ): Promise<{ + events: AgentChatEventEnvelope[]; + nextOffset: number; + nextScanOffset: number | null; + droppedOversizedRecordBytes: number | null; + }> { let fh: fs.promises.FileHandle | null = null; try { fh = await fs.promises.open(transcriptPath, "r"); const stat = await fh.stat(); const size = stat.size; - const normalizedStart = Math.max(0, Math.min(startOffset, size)); - if (size <= normalizedStart) { - return { events: [], nextOffset: size }; + const durableStart = Math.max(0, Math.floor(startOffset)); + // A truncation/rotation invalidates both cursors. Restart from the new + // EOF (the same recovery behavior as the old unbounded reader). + if (size < durableStart || (scanOffset != null && size < scanOffset)) { + return { + events: [], + nextOffset: size, + nextScanOffset: null, + droppedOversizedRecordBytes: null, + }; + } + const normalizedScanOffset = scanOffset == null + ? null + : Math.max(durableStart, Math.floor(scanOffset)); + const readStart = normalizedScanOffset ?? durableStart; + if (size <= readStart) { + return { + events: [], + nextOffset: durableStart, + nextScanOffset: normalizedScanOffset, + droppedOversizedRecordBytes: null, + }; + } + + const readLength = Math.min( + size - readStart, + SYNC_HOST_CHAT_TRANSCRIPT_DELTA_MAX_BYTES, + ); + const out = Buffer.alloc(readLength); + const { bytesRead } = await fh.read(out, 0, out.length, readStart); + const readSlice = out.subarray(0, bytesRead); + if (normalizedScanOffset != null) { + const firstNewline = readSlice.indexOf(0x0a); + if (firstNewline < 0) { + return { + events: [], + nextOffset: durableStart, + nextScanOffset: readStart + bytesRead, + droppedOversizedRecordBytes: null, + }; + } + const firstRecordEnd = readStart + firstNewline + 1; + const firstRecordBytes = firstRecordEnd - durableStart; + const lastNewline = readSlice.lastIndexOf(0x0a); + if (firstRecordBytes <= SYNC_HOST_CHAT_TRANSCRIPT_MAX_RECORD_BYTES) { + // The long record is still deliverable. Re-read it once, now that a + // complete boundary is known, together with any later complete rows + // already present in this bounded scan chunk. + const completeEnd = readStart + lastNewline + 1; + const completeBytes = completeEnd - durableStart; + const completeSlice = Buffer.alloc(completeBytes); + let rereadBytes = 0; + while (rereadBytes < completeBytes) { + const reread = await fh.read( + completeSlice, + rereadBytes, + completeBytes - rereadBytes, + durableStart + rereadBytes, + ); + if (reread.bytesRead <= 0) break; + rereadBytes += reread.bytesRead; + } + if (rereadBytes < completeBytes) { + return { + events: [], + nextOffset: durableStart, + nextScanOffset: normalizedScanOffset, + droppedOversizedRecordBytes: null, + }; + } + return { + events: parseAgentChatTranscript(completeSlice.toString("utf8")), + nextOffset: durableStart + completeSlice.length, + nextScanOffset: null, + droppedOversizedRecordBytes: null, + }; + } + + // A single record beyond the explicit one-record ceiling is not safe + // to materialize. Drop exactly that complete row, surface a structured + // warning, and recover at its newline; later complete rows still flow. + const firstCompleteOffset = firstNewline + 1; + const completeSlice = readSlice.subarray(firstCompleteOffset, lastNewline + 1); + return { + events: completeSlice.length > 0 + ? parseAgentChatTranscript(completeSlice.toString("utf8")) + : [], + nextOffset: readStart + lastNewline + 1, + nextScanOffset: null, + droppedOversizedRecordBytes: firstRecordBytes, + }; } - const out = Buffer.alloc(size - normalizedStart); - await fh.read(out, 0, out.length, normalizedStart); - const lastNewline = out.lastIndexOf(0x0a); + const lastNewline = readSlice.lastIndexOf(0x0a); if (lastNewline < 0) { - return { events: [], nextOffset: normalizedStart }; + const hitReadBound = bytesRead === SYNC_HOST_CHAT_TRANSCRIPT_DELTA_MAX_BYTES; + return { + events: [], + nextOffset: durableStart, + // A short trailing record may still be mid-write, so retain and + // retry it. Once one record fills the normal cap, scan for its + // newline in bounded chunks; a record within the separate hard + // ceiling is then re-read and delivered intact. + nextScanOffset: hitReadBound ? readStart + bytesRead : null, + droppedOversizedRecordBytes: null, + }; } - const completeSlice = out.subarray(0, lastNewline + 1); + const completeSlice = readSlice.subarray(0, lastNewline + 1); const raw = completeSlice.toString("utf8"); return { events: parseAgentChatTranscript(raw), - nextOffset: normalizedStart + completeSlice.length, + nextOffset: durableStart + completeSlice.length, + nextScanOffset: null, + droppedOversizedRecordBytes: null, }; } catch { - return { events: [], nextOffset: Math.max(0, startOffset) }; + return { + events: [], + nextOffset: Math.max(0, startOffset), + nextScanOffset: scanOffset, + droppedOversizedRecordBytes: null, + }; } finally { await fh?.close().catch(() => {}); } @@ -4661,33 +4789,49 @@ export function createSyncHostService(args: SyncHostServiceArgs) { return sent ? "sent" : "failed"; } - async function pumpChatEvents(): Promise { - if (disposed) return; - - for (const peer of peers) { - if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue; - if (isPeerBackpressured(peer)) continue; - for (const sessionId of peer.subscribedChatSessionIds) { - // A foreign quick-look session has no local row; tail its resolved - // transcript path directly. Local sessions resolve via sessionService. - const resolvedTranscriptPath = peer.resolvedChatTranscriptPaths.get(sessionId); - const transcriptPath = resolvedTranscriptPath ?? args.sessionService.get(sessionId)?.transcriptPath; - if (!transcriptPath) continue; - - const startOffset = peer.chatTranscriptOffsets.get(sessionId) ?? 0; - const { events, nextOffset } = await readChatTranscriptEventsSince(transcriptPath, startOffset); - let allEventsDelivered = true; - for (const event of events) { - const seq = recordChatEventSeq(event); - if (sendChatEvent(peer, event, seq) === "failed") { - allEventsDelivered = false; - break; - } - } - if (allEventsDelivered && nextOffset !== startOffset) { - peer.chatTranscriptOffsets.set(sessionId, nextOffset); + async function pumpChatEvents(peer: PeerState): Promise { + if (disposed || !peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) return; + if (isPeerBackpressured(peer)) return; + for (const sessionId of peer.subscribedChatSessionIds) { + // A foreign quick-look session has no local row; tail its resolved + // transcript path directly. Local sessions resolve via sessionService. + const resolvedTranscriptPath = peer.resolvedChatTranscriptPaths.get(sessionId); + const transcriptPath = resolvedTranscriptPath ?? args.sessionService.get(sessionId)?.transcriptPath; + if (!transcriptPath) continue; + + const startOffset = peer.chatTranscriptOffsets.get(sessionId) ?? 0; + const scanOffset = peer.chatTranscriptScanOffsets.get(sessionId) ?? null; + const { + events, + nextOffset, + nextScanOffset, + droppedOversizedRecordBytes, + } = await readChatTranscriptEventsSince(transcriptPath, startOffset, scanOffset); + if (droppedOversizedRecordBytes != null) { + args.logger.warn("sync_host.chat_transcript_record_too_large", { + peerDeviceId: peer.metadata?.deviceId ?? null, + sessionId, + recordBytes: droppedOversizedRecordBytes, + maxRecordBytes: SYNC_HOST_CHAT_TRANSCRIPT_MAX_RECORD_BYTES, + }); + } + let allEventsDelivered = true; + for (const event of events) { + const seq = recordChatEventSeq(event); + if (sendChatEvent(peer, event, seq) === "failed") { + allEventsDelivered = false; + break; } } + if (!allEventsDelivered) continue; + if (nextOffset !== startOffset) { + peer.chatTranscriptOffsets.set(sessionId, nextOffset); + } + if (nextScanOffset == null) { + peer.chatTranscriptScanOffsets.delete(sessionId); + } else { + peer.chatTranscriptScanOffsets.set(sessionId, nextScanOffset); + } } } @@ -4710,20 +4854,19 @@ export function createSyncHostService(args: SyncHostServiceArgs) { markRosterDirty(); } - async function pumpChanges(): Promise { + async function pumpChanges(peer: PeerState): Promise { if (disposed) return; const currentDbVersion = args.db.sync.getDbVersion(); const nowMs = Date.now(); - for (const peer of peers) { - if (!peer.authenticated || !peer.metadata || peer.ws.readyState !== WebSocket.OPEN) continue; + if (!peer.authenticated || !peer.metadata || peer.ws.readyState !== WebSocket.OPEN) return; // A paired desktop runtime connection shares this authenticated socket // only for rpc/fwd envelopes. The authoritative pairing record remains // the gate so a phone/browser cannot suppress its normal CRDT stream by // spoofing the hello capability. - if (isRuntimeOnlyPairedHost(peer)) continue; + if (isRuntimeOnlyPairedHost(peer)) return; // The 4 MiB gate is a hard socket-safety boundary. Fair scheduling may // override only the lower chat-priority watermark below. - if (isPeerBackpressured(peer)) continue; + if (isPeerBackpressured(peer)) return; if (peer.pendingChangesetBatch) { const pending = peer.pendingChangesetBatch; const rejectedRetryDue = pending.retryNotBeforeMs > 0 && nowMs >= pending.retryNotBeforeMs; @@ -4732,7 +4875,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { if (rejectedRetryDue || ackTimedOut) { if (pending.attemptCount >= MAX_CHANGESET_SEND_ATTEMPTS) { abandonPendingChangesetBatch(peer, ackTimedOut ? "ack_timeout" : "ack_failed", nowMs); - continue; + return; } const resent = resendPendingChangesetBatch(peer); if (resent) { @@ -4746,13 +4889,13 @@ export function createSyncHostService(args: SyncHostServiceArgs) { }); } } - continue; + return; } if (currentDbVersion <= peer.lastKnownServerDbVersion) { finishChangesetPriorityDeferral(peer, "no_changes", nowMs); - continue; + return; } - if (nowMs < peer.changesetRecoveryNotBeforeMs) continue; + if (nowMs < peer.changesetRecoveryNotBeforeMs) return; const hasQueuedForegroundWork = peer.queuedMessageCount > 0; const chatBackpressured = shouldDeferBackgroundChangesForChat(peer); if (hasQueuedForegroundWork || chatBackpressured) { @@ -4768,7 +4911,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { }); } if (nowMs - peer.changesetPriorityDeferredSinceMs < SYNC_HOST_PRIORITY_MAX_CHANGESET_DEFER_MS) { - continue; + return; } } else { finishChangesetPriorityDeferral(peer, "pressure_relieved", nowMs); @@ -4821,7 +4964,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { reason: "peer_owned_changes_only", }); finishChangesetPriorityDeferral(peer, "no_changes", nowMs); - continue; + return; } const pending = sendNextChangesetBatch( peer, @@ -4841,7 +4984,6 @@ export function createSyncHostService(args: SyncHostServiceArgs) { finishChangesetPriorityDeferral(peer, "batch_admitted", nowMs); lastBroadcastAt = nowIso(); } - } } function handleChangesetAck(peer: PeerState, payload: SyncChangesetAckPayload | null | undefined): void { @@ -6679,6 +6821,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { ? fs.statSync(transcriptPath).size : 0; peer.chatTranscriptOffsets.set(sessionId, transcriptSize); + peer.chatTranscriptScanOffsets.delete(sessionId); const resumeAck: SyncChatSubscribeSnapshotPayload = { sessionId, capturedAt: nowIso(), @@ -6731,6 +6874,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { } events = events.map(compactChatEventEnvelopeForSync); peer.chatTranscriptOffsets.set(sessionId, transcriptSize); + peer.chatTranscriptScanOffsets.delete(sessionId); const snapshot: SyncChatSubscribeSnapshotPayload = { sessionId, capturedAt: nowIso(), @@ -6751,6 +6895,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { peer.subscribedChatSessionIds.delete(sessionId); peer.chatSubscriptionScopes.delete(sessionId); peer.chatTranscriptOffsets.delete(sessionId); + peer.chatTranscriptScanOffsets.delete(sessionId); peer.chatEventIdsSent.delete(sessionId); peer.resolvedChatTranscriptPaths.delete(sessionId); } From 15edfed5b45ea3b569e5ff875bb63f51dd3d9d71 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 02:39:29 -0400 Subject: [PATCH 17/53] fix(web): isolate active project handoffs --- .../adapter/__tests__/adapter.test.ts | 44 ++++ .../renderer/webclient/adapter/agentChat.ts | 7 + .../src/renderer/webclient/adapter/index.ts | 16 +- .../webclient/adapter/infra/invalidation.ts | 10 + .../webclient/adapter/infra/registries.ts | 6 + .../renderer/webclient/adapter/sessionsPty.ts | 5 + .../src/renderer/webclient/adapter/types.ts | 1 + .../webclient/shell/WebClientRoot.tsx | 22 +- .../shell/__tests__/WebClientRoot.test.tsx | 188 +++++++++++++++- .../webclient/sync/__tests__/sync.test.ts | 200 +++++++++++++++++- .../src/renderer/webclient/sync/client.ts | 143 ++++++++++--- 11 files changed, 611 insertions(+), 31 deletions(-) diff --git a/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts b/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts index 74463b475..a288b9968 100644 --- a/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts +++ b/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts @@ -777,6 +777,50 @@ describe("createAdeWebAdapter", () => { adapter.dispose(); }); + it("atomically replaces the bound project and refreshes every mounted domain", async () => { + fake.descriptors = descriptors(["lanes.list"]); + fake.commandResults.set("lanes.list", [{ id: "lane-old" }]); + const projectTwoSummary = { + ...fake.projects[0]!, + id: "project-2", + rootPath: "/repo-2", + displayName: "Repo Two", + }; + fake.projects.push(projectTwoSummary); + const projectTwo = { rootPath: "/repo-2", displayName: "Repo Two", baseRef: "main" }; + const adapter = createAdeWebAdapter(fake.asClient(), fake.projects); + adapter.bindProject(project, "project-1"); + + const projects: Array = []; + const laneEvents: unknown[] = []; + const sessionEvents: unknown[] = []; + const fileEvents: unknown[] = []; + const rebaseEvents: unknown[] = []; + adapter.ade.app.onProjectChanged((next) => projects.push(next)); + adapter.ade.lanes.onLifecycleEvent((event) => laneEvents.push(event)); + adapter.ade.sessions.onChanged((event) => sessionEvents.push(event)); + adapter.ade.files.onChange((event) => fileEvents.push(event)); + adapter.ade.rebase.onEvent((event) => rebaseEvents.push(event)); + + await adapter.ade.lanes.list(); + fake.commandResults.set("lanes.list", [{ id: "lane-new" }]); + adapter.replaceProject(projectTwo, "project-2"); + await expect(adapter.ade.app.getProject()).resolves.toEqual(projectTwo); + await expect(adapter.ade.lanes.list()).resolves.toEqual([{ id: "lane-new" }]); + + expect(projects).toEqual([projectTwo]); + expect(fake.commandCalls.filter((call) => call.action === "lanes.list")).toEqual([ + expect.objectContaining({ opts: expect.objectContaining({ projectId: "project-1" }) }), + expect.objectContaining({ opts: expect.objectContaining({ projectId: "project-2" }) }), + ]); + expect(laneEvents).toHaveLength(1); + expect(sessionEvents).toHaveLength(1); + expect(fileEvents).toHaveLength(1); + expect(rebaseEvents).toHaveLength(2); + + adapter.dispose(); + }); + it("keeps the current project binding when a remote project switch is rejected", async () => { fake.projects.push({ ...fake.projects[0]!, diff --git a/apps/desktop/src/renderer/webclient/adapter/agentChat.ts b/apps/desktop/src/renderer/webclient/adapter/agentChat.ts index cdf9624c4..4875ab8bf 100644 --- a/apps/desktop/src/renderer/webclient/adapter/agentChat.ts +++ b/apps/desktop/src/renderer/webclient/adapter/agentChat.ts @@ -93,6 +93,13 @@ export function createAgentChatNamespace(infra: AdapterInfra): AdeNamespace<"age chatSubscriptions.clear(); }); + infra.addDispose(events.on("projectBoundary", () => { + for (const unsubscribe of chatSubscriptions.values()) unsubscribe(); + chatSubscriptions.clear(); + deliveredEvents.length = 0; + deliveredEventSet.clear(); + })); + function call(action: string, args: unknown, fallback: T, idempotent = true): Promise { return commands.call(action, asRecord(args), { fallback, idempotent }); } diff --git a/apps/desktop/src/renderer/webclient/adapter/index.ts b/apps/desktop/src/renderer/webclient/adapter/index.ts index 9e3d5ddd1..fc948647c 100644 --- a/apps/desktop/src/renderer/webclient/adapter/index.ts +++ b/apps/desktop/src/renderer/webclient/adapter/index.ts @@ -11,7 +11,7 @@ import { createGitNamespaces } from "./git"; import { CommandCaller } from "./infra/commandCaller"; import { EventBus } from "./infra/eventBus"; import { createInvalidationScheduler } from "./infra/invalidation"; -import type { InvalidationDomain } from "./infra/invalidation"; +import { ALL_INVALIDATION_DOMAINS, type InvalidationDomain } from "./infra/invalidation"; import { createLocalState } from "./infra/localState"; import { createProjectState } from "./infra/projectState"; import { withFallbackProxy } from "./infra/proxy"; @@ -28,6 +28,7 @@ import type { AdapterEvents, AdapterInfra } from "./types"; export type AdeWebAdapter = { ade: Window["ade"]; bindProject(project: ProjectInfo | null, projectId?: string | null): void; + replaceProject(project: ProjectInfo, projectId: string): void; dispose(): void; }; @@ -188,6 +189,19 @@ export function createAdeWebAdapter( events.emit("projectChanged", project); events.emit("projectBindingChanged", null); }, + replaceProject(project: ProjectInfo, projectId: string): void { + events.emit("projectBoundary", { projectId }); + terminalRegistry.clear(); + state.bindProject(project, projectId); + commands.invalidateCache(); + events.emit("projectChanged", project); + events.emit("projectBindingChanged", null); + events.emit("invalidation", { + tables: [], + domains: [...ALL_INVALIDATION_DOMAINS], + at: new Date().toISOString(), + }); + }, dispose(): void { for (const dispose of disposers.splice(0)) dispose(); events.clear(); diff --git a/apps/desktop/src/renderer/webclient/adapter/infra/invalidation.ts b/apps/desktop/src/renderer/webclient/adapter/infra/invalidation.ts index f08d272f8..6cb7518e1 100644 --- a/apps/desktop/src/renderer/webclient/adapter/infra/invalidation.ts +++ b/apps/desktop/src/renderer/webclient/adapter/infra/invalidation.ts @@ -2,6 +2,16 @@ import type { EventBus } from "./eventBus"; export type InvalidationDomain = "lanes" | "sessions" | "chats" | "prs" | "files" | "github" | "rebase"; +export const ALL_INVALIDATION_DOMAINS: readonly InvalidationDomain[] = [ + "lanes", + "sessions", + "chats", + "prs", + "files", + "github", + "rebase", +]; + export type InvalidationEvent = { tables: string[]; domains: InvalidationDomain[]; diff --git a/apps/desktop/src/renderer/webclient/adapter/infra/registries.ts b/apps/desktop/src/renderer/webclient/adapter/infra/registries.ts index 1c700622a..5f0532ef1 100644 --- a/apps/desktop/src/renderer/webclient/adapter/infra/registries.ts +++ b/apps/desktop/src/renderer/webclient/adapter/infra/registries.ts @@ -70,6 +70,12 @@ export class TerminalRegistry { } return null; } + + clear(): void { + this.ptyToSession.clear(); + this.sessionToPty.clear(); + this.summaries.clear(); + } } export function chatTerminalFromSummary( diff --git a/apps/desktop/src/renderer/webclient/adapter/sessionsPty.ts b/apps/desktop/src/renderer/webclient/adapter/sessionsPty.ts index 877b49ed0..cfb21b22e 100644 --- a/apps/desktop/src/renderer/webclient/adapter/sessionsPty.ts +++ b/apps/desktop/src/renderer/webclient/adapter/sessionsPty.ts @@ -80,6 +80,11 @@ export function createSessionsPtyNamespaces(infra: AdapterInfra): SessionsPtyNam terminalSubscriptions.clear(); }); + infra.addDispose(events.on("projectBoundary", () => { + for (const unsubscribe of terminalSubscriptions.values()) unsubscribe(); + terminalSubscriptions.clear(); + })); + infra.addDispose( events.on("sessionsInvalidated", () => { events.emit("sessionsChanged", { diff --git a/apps/desktop/src/renderer/webclient/adapter/types.ts b/apps/desktop/src/renderer/webclient/adapter/types.ts index f3eda55b0..fc0377deb 100644 --- a/apps/desktop/src/renderer/webclient/adapter/types.ts +++ b/apps/desktop/src/renderer/webclient/adapter/types.ts @@ -24,6 +24,7 @@ import type { AdapterProjectState } from "./infra/projectState"; import type { TerminalRegistry } from "./infra/registries"; export type AdapterEvents = { + projectBoundary: { projectId: string }; projectChanged: ProjectInfo | null; projectBindingChanged: OpenProjectBinding | null; projectMissing: { rootPath: string }; diff --git a/apps/desktop/src/renderer/webclient/shell/WebClientRoot.tsx b/apps/desktop/src/renderer/webclient/shell/WebClientRoot.tsx index 504ea5198..b7c4dcd2d 100644 --- a/apps/desktop/src/renderer/webclient/shell/WebClientRoot.tsx +++ b/apps/desktop/src/renderer/webclient/shell/WebClientRoot.tsx @@ -29,6 +29,7 @@ import { COLORS, SANS_FONT, primaryButton } from "./shellTokens"; type AdeWebAdapter = { ade: Window["ade"]; bindProject: (project: ProjectInfo | null, projectId?: string | null) => void; + replaceProject: (project: ProjectInfo, projectId: string) => void; dispose: () => void; }; @@ -173,6 +174,7 @@ export function WebClientRoot({ const [connectingAccountMachineKey, setConnectingAccountMachineKey] = useState(null); const adapterRef = useRef(null); + const activeProjectBoundaryRef = useRef(null); const stashedTargetRef = useRef(null); const bootedRef = useRef(false); const fatalRebootRef = useRef(false); @@ -278,7 +280,11 @@ export function WebClientRoot({ adapterRef.current = await loadAdapter(client, accountClient, catalogSeed); } window.ade = adapterRef.current.ade; - adapterRef.current.bindProject(toProjectInfo(project), project.id); + const boundaryProject = activeProjectBoundaryRef.current; + const projectToBind = boundaryProject?.id === client.getStatus().activeProjectId + ? boundaryProject + : project; + adapterRef.current.bindProject(toProjectInfo(projectToBind), projectToBind.id); // Point the address bar at the initial App route before mounting so the // App's BrowserRouter renders the right tab on first paint. @@ -491,6 +497,18 @@ export function WebClientRoot({ return client.onProjectCatalog((payload) => setCatalog(payload.projects)); }, [client]); + useEffect(() => { + return client.onActiveProjectChanged(({ project, catalog: nextCatalog }) => { + activeProjectBoundaryRef.current = project; + setCatalog(nextCatalog.projects); + // Personal Chats is intentionally machine-scoped and projectless. Keep + // its mounted adapter detached even when the machine hands the shared + // listener to another open project. + if (isChatsRoute(window.location.pathname)) return; + adapterRef.current?.replaceProject(toProjectInfo(project), project.id); + }); + }, [client]); + // Account-owned trust and account-authorized Relay sockets stay usable only // while the same browser account remains valid. A local pairing keeps its // saved trust after Relay logout so it can reconnect directly later. @@ -567,7 +585,7 @@ export function WebClientRoot({ try { const result = await client.switchProject(project.id); if (!result.ok) return; - adapterRef.current?.bindProject(toProjectInfo(project), result.project?.id ?? project.id); + adapterRef.current?.replaceProject(toProjectInfo(project), result.project?.id ?? project.id); } catch { // switchProject surfaces its own failure via status; leave the app up. } diff --git a/apps/desktop/src/renderer/webclient/shell/__tests__/WebClientRoot.test.tsx b/apps/desktop/src/renderer/webclient/shell/__tests__/WebClientRoot.test.tsx index c9e564083..d223e2b2d 100644 --- a/apps/desktop/src/renderer/webclient/shell/__tests__/WebClientRoot.test.tsx +++ b/apps/desktop/src/renderer/webclient/shell/__tests__/WebClientRoot.test.tsx @@ -3,7 +3,8 @@ import React from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { SyncMobileProjectSummary, SyncProjectCatalogPayload } from "../../../../shared/types/sync"; import type { BrowserAccountClient, BrowserAccountSnapshot } from "../../account/client"; import type { AdeSyncClient, @@ -13,8 +14,29 @@ import type { } from "../../sync"; import { WebClientRoot } from "../WebClientRoot"; +const createAdapterMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../adapter/index", () => ({ + createAdeWebAdapter: createAdapterMock, +})); + +vi.mock("../../../components/app/App", () => ({ + App: () => null, +})); + +vi.mock("../../../components/app/RendererErrorBoundary", () => ({ + RendererErrorBoundary: ({ children }: { children: React.ReactNode }) => children, +})); + +vi.mock("../../../state/appStore", () => ({ + useAppStore: { + getState: () => ({ applyAutoSizeChatFontOnLargeScreenIfNotOverridden: () => undefined }), + }, +})); + afterEach(() => { cleanup(); + createAdapterMock.mockReset(); window.history.replaceState(null, "", "/"); }); @@ -101,6 +123,7 @@ function syncClient(overrides: Record = {}): AdeSyncClient { pruneAccountOwnedEnvironments: vi.fn(async () => pruneResult([])), subscribe: vi.fn(() => () => undefined), onProjectCatalog: vi.fn(() => () => undefined), + onActiveProjectChanged: vi.fn(() => () => undefined), ...overrides, } as unknown as AdeSyncClient; } @@ -253,4 +276,167 @@ describe("WebClientRoot entry routes", () => { expect(await screen.findByRole("heading", { name: "Open a project" })).toBeTruthy(); expect(listEnvironments).toHaveBeenCalledTimes(1); }); + + it("replaces the mounted project when the connected client crosses a hydration boundary", async () => { + const environment = savedEnvironment({ activeProjectId: "project-1" }); + const projectOne: SyncMobileProjectSummary = { + id: "project-1", + displayName: "Repo One", + rootPath: "/repo-1", + defaultBaseRef: "main", + lastOpenedAt: null, + iconDataUrl: null, + laneCount: 1, + isAvailable: true, + isCached: true, + isOpen: true, + }; + const projectTwo: SyncMobileProjectSummary = { + ...projectOne, + id: "project-2", + displayName: "Repo Two", + rootPath: "/repo-2", + }; + let currentStatus: AdeSyncClientStatus = idleStatus; + let currentCatalog: SyncProjectCatalogPayload = { projects: [projectOne] }; + const statusListeners = new Set<(status: AdeSyncClientStatus) => void>(); + const catalogListeners = new Set<(catalog: SyncProjectCatalogPayload) => void>(); + const activeProjectListeners = new Set<(change: { + previousProjectId: string | null; + project: SyncMobileProjectSummary; + catalog: SyncProjectCatalogPayload; + }) => void>(); + const bindProject = vi.fn(); + const replaceProject = vi.fn(); + createAdapterMock.mockReturnValue({ + ade: {} as Window["ade"], + bindProject, + replaceProject, + dispose: vi.fn(), + }); + const client = syncClient({ + getStatus: () => currentStatus, + listEnvironments: vi.fn(async () => [environment]), + pruneAccountOwnedEnvironments: vi.fn(async () => pruneResult([environment])), + connect: vi.fn(async () => { + currentStatus = { + ...idleStatus, + state: "connected", + readiness: "ready", + endpoint: "wss://saved.example.test/sync", + envId: environment.envId, + selectedEnvId: environment.envId, + activeProjectId: "project-1", + }; + statusListeners.forEach((listener) => listener(currentStatus)); + }), + getProjectCatalog: vi.fn(async () => currentCatalog), + subscribe: vi.fn((listener: (status: AdeSyncClientStatus) => void) => { + statusListeners.add(listener); + return () => statusListeners.delete(listener); + }), + onProjectCatalog: vi.fn((listener: (catalog: SyncProjectCatalogPayload) => void) => { + catalogListeners.add(listener); + return () => catalogListeners.delete(listener); + }), + onActiveProjectChanged: vi.fn((listener: (change: { + previousProjectId: string | null; + project: SyncMobileProjectSummary; + catalog: SyncProjectCatalogPayload; + }) => void) => { + activeProjectListeners.add(listener); + return () => activeProjectListeners.delete(listener); + }), + }); + + render(); + fireEvent.click(await screen.findByRole("button", { name: /Current saved Mac/i })); + await waitFor(() => expect(bindProject).toHaveBeenCalledWith({ + rootPath: "/repo-1", + displayName: "Repo One", + baseRef: "main", + }, "project-1")); + + currentCatalog = { + projects: [{ ...projectOne, isOpen: false }, projectTwo], + }; + currentStatus = { ...currentStatus, activeProjectId: "project-2" }; + act(() => { + catalogListeners.forEach((listener) => listener(currentCatalog)); + activeProjectListeners.forEach((listener) => listener({ + previousProjectId: "project-1", + project: projectTwo, + catalog: currentCatalog, + })); + statusListeners.forEach((listener) => listener(currentStatus)); + }); + + expect(replaceProject).toHaveBeenCalledWith({ + rootPath: "/repo-2", + displayName: "Repo Two", + baseRef: "main", + }, "project-2"); + }); + + it("keeps the mounted adapter projectless when a Chats route receives a project boundary", async () => { + window.history.replaceState(null, "", "/chats"); + const environment = savedEnvironment({ activeProjectId: "project-1" }); + const project: SyncMobileProjectSummary = { + id: "project-1", + displayName: "Repo", + rootPath: "/repo", + defaultBaseRef: "main", + lastOpenedAt: null, + iconDataUrl: null, + laneCount: 1, + isAvailable: true, + isCached: true, + isOpen: true, + }; + let activeProjectListener: ((change: { + previousProjectId: string | null; + project: SyncMobileProjectSummary; + catalog: SyncProjectCatalogPayload; + }) => void) | null = null; + const bindProject = vi.fn(); + const replaceProject = vi.fn(); + createAdapterMock.mockReturnValue({ + ade: {} as Window["ade"], + bindProject, + replaceProject, + dispose: vi.fn(), + }); + const connectedStatus: AdeSyncClientStatus = { + ...idleStatus, + state: "connected", + readiness: "ready", + envId: environment.envId, + selectedEnvId: environment.envId, + activeProjectId: project.id, + }; + const client = syncClient({ + getStatus: () => connectedStatus, + listEnvironments: vi.fn(async () => [environment]), + pruneAccountOwnedEnvironments: vi.fn(async () => pruneResult([environment])), + connect: vi.fn(async () => undefined), + getProjectCatalog: vi.fn(async () => ({ projects: [project] })), + onActiveProjectChanged: vi.fn((listener) => { + activeProjectListener = listener as typeof activeProjectListener; + return () => { activeProjectListener = null; }; + }), + }); + + render(); + fireEvent.click(await screen.findByRole("button", { name: /Current saved Mac/i })); + await waitFor(() => expect(bindProject).toHaveBeenCalledWith(null)); + + act(() => { + activeProjectListener?.({ + previousProjectId: "project-1", + project: { ...project, id: "project-2", displayName: "Repo Two", rootPath: "/repo-2" }, + catalog: { projects: [project] }, + }); + }); + expect(replaceProject).not.toHaveBeenCalled(); + }); }); diff --git a/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts b/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts index a6bc64d86..ec41337b1 100644 --- a/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts +++ b/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts @@ -2896,7 +2896,7 @@ describe("browser sync connection and client", () => { client.dispose(); }); - it("rebinds stream subscriptions to the new project after switching", async () => { + it("retires old project streams and subscribes only newly requested streams after switching", async () => { const storage = new MemoryStorage(); const environment = await makeEnvironment(storage); let helloProjectId = "project-1"; @@ -2935,14 +2935,208 @@ describe("browser sync connection and client", () => { expect(script.sockets).toHaveLength(2); expect(script.sockets[0].sent.some((envelope) => envelope.type === "chat_subscribe")).toBe(true); expect(script.sockets[0].sent.some((envelope) => envelope.type === "terminal_subscribe")).toBe(true); + expect(script.sockets[1].sent.some((envelope) => envelope.type === "chat_subscribe")).toBe(false); + expect(script.sockets[1].sent.some((envelope) => envelope.type === "terminal_subscribe")).toBe(false); + + client.subscribeChat("chat-2", {}, {}); + client.subscribeTerminal("term-2", {}, {}); + await flush(); expect(script.sockets[1].sent.find((envelope) => envelope.type === "chat_subscribe")).toMatchObject({ projectId: "project-2", - payload: { sessionId: "chat-1" }, + payload: { sessionId: "chat-2" }, }); expect(script.sockets[1].sent.find((envelope) => envelope.type === "terminal_subscribe")).toMatchObject({ projectId: "project-2", - payload: { sessionId: "term-1" }, + payload: { sessionId: "term-2" }, + }); + + client.dispose(); + }); + + it("rebinds /chats foreign streams while retiring active streams on a same-socket project boundary", async () => { + const storage = new MemoryStorage(); + const environment = await makeEnvironment(storage); + const commandProjectIds: Array = []; + const script = createSocketFactory((socket, envelope) => { + if (envelope.type === "hello") { + socket.serverSend({ type: "hello_ok", requestId: envelope.requestId, payload: helloOk("project-1") }); + } + if (envelope.type === "command") { + commandProjectIds.push(envelope.projectId); + const { commandId } = envelope.payload as { commandId: string }; + socket.serverSend({ + type: "command_result", + requestId: envelope.requestId, + payload: { commandId, ok: true, result: { projectId: envelope.projectId } }, + }); + } + }); + const client = new AdeSyncClient({ storage, socketFactory: script.factory, document: null }); + const changes: Array<{ previousProjectId: string | null; projectId: string }> = []; + const deliveredChatSessionIds: string[] = []; + client.onActiveProjectChanged(({ previousProjectId, project }) => { + changes.push({ previousProjectId, projectId: project.id }); + }); + client.onChatEvent((payload) => deliveredChatSessionIds.push(payload.sessionId)); + + const connecting = client.connect(environment.envId, signedInRelayAccess); + await completeRelayReadyV2AfterOpen(script.sockets, 0); + await connecting; + client.subscribeChat("project-chat", {}, {}); + client.subscribeChat("personal-chat", { chatScope: "personal" }, {}); + client.subscribeChat("foreign-chat", { + projectId: "project-foreign", + projectRootPath: "/repo-foreign", + }, {}); + client.subscribeTerminal("project-terminal", {}, {}); + await flush(); + script.sockets[0]?.serverSend({ + type: "chat_event", + payload: { + sessionId: "foreign-chat", + timestamp: new Date().toISOString(), + seq: 9, + event: { type: "foreign-event-before-handoff" }, + }, + } as never); + await flush(); + deliveredChatSessionIds.length = 0; + + const projectOne = { ...helloOk("project-1").projects![0], isOpen: false }; + const projectTwo = { + ...helloOk("project-2").projects![0], + displayName: "Repo Two", + rootPath: "/repo-2", + isOpen: true, + }; + script.sockets[0]?.serverSend({ + type: "project_catalog", + payload: { projects: [projectOne, projectTwo] }, }); + await flush(); + + expect(script.sockets).toHaveLength(1); + expect(client.getStatus().activeProjectId).toBe("project-2"); + expect(changes).toEqual([{ previousProjectId: "project-1", projectId: "project-2" }]); + expect(script.sockets[0].sent.filter((envelope) => ( + envelope.type === "chat_subscribe" + && (envelope.payload as { sessionId?: string }).sessionId === "project-chat" + ))).toEqual([expect.objectContaining({ projectId: "project-1" })]); + expect(script.sockets[0].sent.filter((envelope) => ( + envelope.type === "chat_subscribe" + && (envelope.payload as { sessionId?: string }).sessionId === "personal-chat" + ))).toHaveLength(1); + expect(script.sockets[0].sent.find((envelope) => ( + envelope.type === "chat_subscribe" + && (envelope.payload as { sessionId?: string }).sessionId === "personal-chat" + ))?.projectId).toBeUndefined(); + const foreignSubscriptions = script.sockets[0].sent.filter((envelope) => ( + envelope.type === "chat_subscribe" + && (envelope.payload as { sessionId?: string }).sessionId === "foreign-chat" + )); + expect(foreignSubscriptions).toHaveLength(2); + expect(foreignSubscriptions).toEqual([ + expect.objectContaining({ projectId: "project-foreign" }), + expect.objectContaining({ projectId: "project-foreign" }), + ]); + expect(foreignSubscriptions[1]?.payload).toMatchObject({ + sessionId: "foreign-chat", + projectId: "project-foreign", + projectRootPath: "/repo-foreign", + }); + expect(foreignSubscriptions[1]?.payload).not.toHaveProperty("sinceSeq"); + expect(script.sockets[0].sent.filter((envelope) => envelope.type === "terminal_subscribe")).toEqual([ + expect.objectContaining({ projectId: "project-1" }), + ]); + + script.sockets[0]?.serverSend({ + type: "chat_event", + payload: { + sessionId: "project-chat", + timestamp: new Date().toISOString(), + event: { type: "late-old-project-event" }, + }, + } as never); + script.sockets[0]?.serverSend({ + type: "chat_event", + payload: { + sessionId: "personal-chat", + timestamp: new Date().toISOString(), + event: { type: "personal-event" }, + }, + } as never); + await flush(); + expect(deliveredChatSessionIds).toEqual(["personal-chat"]); + + client.subscribeChat("new-project-chat", {}, {}); + client.subscribeTerminal("new-project-terminal", {}, {}); + await flush(); + expect(script.sockets[0].sent.find((envelope) => ( + envelope.type === "chat_subscribe" + && (envelope.payload as { sessionId?: string }).sessionId === "new-project-chat" + ))).toMatchObject({ projectId: "project-2" }); + expect(script.sockets[0].sent.find((envelope) => ( + envelope.type === "terminal_subscribe" + && (envelope.payload as { sessionId?: string }).sessionId === "new-project-terminal" + ))).toMatchObject({ projectId: "project-2" }); + + await expect(client.sendCommand("chat.send", { text: "new project" })).resolves.toEqual({ + projectId: "project-2", + }); + expect(commandProjectIds).toEqual(["project-2"]); + await flush(); + await expect(new WebClientEnvStore(storage).getEnvironment(environment.envId)).resolves.toMatchObject({ + activeProjectId: "project-2", + }); + + client.dispose(); + }); + + it("publishes the same project boundary when reconnect hello opens a different project", async () => { + const storage = new MemoryStorage(); + const environment = await makeEnvironment(storage); + let helloProjectId = "project-1"; + const script = createSocketFactory((socket, envelope) => { + if (envelope.type === "hello") { + socket.serverSend({ + type: "hello_ok", + requestId: envelope.requestId, + payload: helloOk(helloProjectId), + }); + } + }); + const client = new AdeSyncClient({ storage, socketFactory: script.factory, document: null }); + const changes: Array<{ previousProjectId: string | null; projectId: string }> = []; + client.onActiveProjectChanged(({ previousProjectId, project }) => { + changes.push({ previousProjectId, projectId: project.id }); + }); + + const initialConnect = client.connect(environment.envId, signedInRelayAccess); + await completeRelayReadyV2AfterOpen(script.sockets, 0); + await initialConnect; + client.subscribeChat("old-project-chat", {}, {}); + client.subscribeChat("personal-chat", { chatScope: "personal" }, {}); + client.subscribeTerminal("old-project-terminal", {}, {}); + await flush(); + + script.sockets[0]?.close(1006, "listener handoff"); + helloProjectId = "project-2"; + const reconnect = client.connect(environment.envId, signedInRelayAccess); + await completeRelayReadyV2AfterOpen(script.sockets, 1); + await reconnect; + await flush(); + + expect(client.getStatus().activeProjectId).toBe("project-2"); + expect(changes).toContainEqual({ previousProjectId: "project-1", projectId: "project-2" }); + expect(script.sockets[1].sent.some((envelope) => ( + envelope.type === "chat_subscribe" + && (envelope.payload as { sessionId?: string }).sessionId === "old-project-chat" + ))).toBe(false); + expect(script.sockets[1].sent.some((envelope) => envelope.type === "terminal_subscribe")).toBe(false); + expect(script.sockets[1].sent.find((envelope) => ( + envelope.type === "chat_subscribe" + && (envelope.payload as { sessionId?: string }).sessionId === "personal-chat" + ))?.projectId).toBeUndefined(); client.dispose(); }); diff --git a/apps/desktop/src/renderer/webclient/sync/client.ts b/apps/desktop/src/renderer/webclient/sync/client.ts index 5ebd1e84c..2397b2086 100644 --- a/apps/desktop/src/renderer/webclient/sync/client.ts +++ b/apps/desktop/src/renderer/webclient/sync/client.ts @@ -64,6 +64,12 @@ export type AdeSyncClientStatus = SyncConnectionStatus & { readiness: "disconnected" | "restoring" | "ready" | "failed"; }; +export type AdeSyncActiveProjectChange = Readonly<{ + previousProjectId: string | null; + project: SyncMobileProjectSummary; + catalog: SyncProjectCatalogPayload; +}>; + export type WebAccountSessionLease = Readonly<{ userId: string; generation: number; @@ -144,6 +150,7 @@ type ClientEvents = { tablesChanged: Set; chatEvent: SyncChatEventPayload; projectCatalog: SyncProjectCatalogPayload; + activeProjectChanged: AdeSyncActiveProjectChange; }; type ListenerMap = { @@ -247,6 +254,7 @@ export class AdeSyncClient { tablesChanged: new Set(), chatEvent: new Set(), projectCatalog: new Set(), + activeProjectChanged: new Set(), }; constructor(options: { @@ -311,13 +319,21 @@ export class AdeSyncClient { this.connection.on("tablesChanged", (tables) => this.emit("tablesChanged", tables)); this.connection.on("projectCatalog", (payload) => { this.currentCatalog = payload; - const catalogProjectId = openProjectFromCatalog(payload.projects); - if (catalogProjectId && catalogProjectId !== this.activeProjectId) { - this.activeProjectId = catalogProjectId; - this.rebindStreamSubscriptions(); - } this.resolveProjectCatalog(payload, this.clientGeneration); this.emit("projectCatalog", payload); + const openProject = payload.projects.find((project) => project.isOpen) ?? null; + if (openProject && openProject.id !== this.activeProjectId) { + const previousProjectId = this.activeProjectId; + this.activeProjectId = openProject.id; + if (this.readiness === "restoring") { + this.rejectTerminalInputQueue(this.activeProjectChangedError()); + } else { + this.advanceActiveProjectBoundary(); + } + this.retireActiveProjectStreams(); + this.rebindForeignProjectStreams(); + this.publishActiveProjectBoundary(previousProjectId, openProject, payload); + } if (this.readiness === "failed" && this.latestHello && this.connection.isConnected()) { this.beginRestoration({ ...this.latestHello, projects: payload.projects }); } @@ -697,7 +713,7 @@ export class AdeSyncClient { payload: { ...opts, sessionId }, handlers, sinceSeq: existing?.sinceSeq ?? null, - bindsActiveProject: opts.projectId == null, + bindsActiveProject: opts.projectId == null && opts.chatScope !== "personal", }; this.chatSubscriptions.set(sessionId, subscription); if (!this.streamSubscriptionsPaused) this.sendChatSubscribe(subscription); @@ -710,7 +726,9 @@ export class AdeSyncClient { if (!subscription || !this.connection.isConnected()) return; this.connection.send({ type: "chat_unsubscribe", - projectId: subscription.payload.projectId ?? this.activeProjectId, + projectId: subscription.bindsActiveProject + ? this.activeProjectId + : subscription.payload.projectId ?? null, payload: { sessionId, projectId: subscription.payload.projectId, @@ -890,7 +908,7 @@ export class AdeSyncClient { this.currentCatalog = null; } this.invalidateGeneration(new AdeSyncError("Project connection changed.", "disconnected")); - this.rebindStreamSubscriptions(); + this.retireActiveProjectStreams(); await this.persistCurrentEnvironment((environment) => ({ ...environment, port: result.connection?.port ?? environment.port, @@ -928,6 +946,10 @@ export class AdeSyncClient { return this.on("projectCatalog", listener); } + onActiveProjectChanged(listener: (payload: AdeSyncActiveProjectChange) => void): () => void { + return this.on("activeProjectChanged", listener); + } + dispose(): void { this.disconnect(); this.connection.dispose(); @@ -949,10 +971,17 @@ export class AdeSyncClient { "terminal_input_ack_unavailable", )); } - const restoredProjectId = openProjectFromCatalog(payload.projects); - if (restoredProjectId && restoredProjectId !== this.activeProjectId) { - this.activeProjectId = restoredProjectId; - this.rebindStreamSubscriptions(); + const restoredProject = payload.projects?.find((project) => project.isOpen) + ?? payload.projects?.[0] + ?? null; + const previousProjectId = this.activeProjectId; + const crossedProjectBoundary = Boolean( + restoredProject && restoredProject.id !== previousProjectId, + ); + if (restoredProject && crossedProjectBoundary) { + this.activeProjectId = restoredProject.id; + this.retireActiveProjectStreams(); + this.rejectTerminalInputQueue(this.activeProjectChangedError()); } const restoration = (async () => { @@ -960,6 +989,9 @@ export class AdeSyncClient { const catalog = { projects: payload.projects } satisfies SyncProjectCatalogPayload; this.currentCatalog = catalog; this.resolveProjectCatalog(catalog, generation); + if (restoredProject && crossedProjectBoundary) { + this.publishActiveProjectBoundary(previousProjectId, restoredProject, catalog); + } } else { await this.requestProjectCatalog(this.restorationTimeoutMs); } @@ -1099,13 +1131,16 @@ export class AdeSyncClient { private handleChatEvent(payload: SyncChatEventPayload): void { const sessionId = typeof payload.sessionId === "string" ? payload.sessionId : null; - if (sessionId) { - const subscription = this.chatSubscriptions.get(sessionId); - if (subscription && typeof payload.seq === "number") { - subscription.sinceSeq = Math.max(subscription.sinceSeq ?? 0, payload.seq); - } - subscription?.handlers.event?.(payload); + if (!sessionId) return; + const subscription = this.chatSubscriptions.get(sessionId); + // A project handoff retires the old project's subscription ownership + // before rebinding the UI. A late poll result from that host must not leak + // through the global adapter listener into the newly mounted project. + if (!subscription) return; + if (typeof payload.seq === "number") { + subscription.sinceSeq = Math.max(subscription.sinceSeq ?? 0, payload.seq); } + subscription.handlers.event?.(payload); this.emit("chatEvent", payload); } @@ -1274,7 +1309,9 @@ export class AdeSyncClient { try { this.connection.send({ type: "chat_subscribe", - projectId: subscription.payload.projectId ?? this.activeProjectId, + projectId: subscription.bindsActiveProject + ? this.activeProjectId + : subscription.payload.projectId ?? null, payload: { ...subscription.payload, ...(subscription.sinceSeq != null ? { sinceSeq: subscription.sinceSeq } : {}), @@ -1437,14 +1474,72 @@ export class AdeSyncClient { this.pumpTerminalInputQueue(); } - private rebindStreamSubscriptions(): void { + private retireActiveProjectStreams(): void { + for (const [sessionId, subscription] of this.chatSubscriptions) { + if (subscription.bindsActiveProject) this.chatSubscriptions.delete(sessionId); + } + this.terminalSubscriptions.clear(); + } + + private rebindForeignProjectStreams(): void { + if (this.readiness !== "ready" || !this.connection.isConnected()) return; for (const subscription of this.chatSubscriptions.values()) { - if (subscription.bindsActiveProject) subscription.sinceSeq = null; + const isForeignProject = !subscription.bindsActiveProject + && subscription.payload.chatScope !== "personal" + && Boolean(subscription.payload.projectId); + if (!isForeignProject) continue; + // The shared-listener handoff deliberately carries only the new active + // project's stream state. Re-open explicit foreign-project quick looks + // from a full snapshot; their prior cursor belongs to the old host. + subscription.sinceSeq = null; + this.sendChatSubscribe(subscription); } - for (const subscription of this.terminalSubscriptions.values()) { - subscription.sinceOffset = null; - subscription.recoveryInFlight = false; + } + + private advanceActiveProjectBoundary(): void { + const error = this.activeProjectChangedError(); + this.clientGeneration += 1; + this.rejectPendingCommands(error); + for (const [requestId, pending] of this.pendingFiles) { + clearTimeout(pending.timer); + this.pendingFiles.delete(requestId); + pending.reject(error); + } + for (const [requestId, pending] of this.pendingTerminalHistory) { + clearTimeout(pending.timer); + this.pendingTerminalHistory.delete(requestId); + pending.reject(error); + } + for (const [requestId, pending] of this.pendingProjectSwitches) { + clearTimeout(pending.timer); + this.pendingProjectSwitches.delete(requestId); + pending.reject(error); } + this.rejectTerminalInputQueue(error); + } + + private activeProjectChangedError(): AdeSyncError { + return new AdeSyncError( + "The active project changed before the request completed.", + "project_changed", + ); + } + + private publishActiveProjectBoundary( + previousProjectId: string | null, + project: SyncMobileProjectSummary, + catalog: SyncProjectCatalogPayload, + ): void { + this.emit("activeProjectChanged", { + previousProjectId, + project, + catalog, + }); + this.emitStatus(); + void this.persistCurrentEnvironment((environment) => ({ + ...environment, + activeProjectId: project.id, + })).catch(() => undefined); } private resolveProjectCatalog(payload: SyncProjectCatalogPayload, generation: number): void { From 57e445342ecb603977a5f60178b7a5260c34563b Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 02:52:22 -0400 Subject: [PATCH 18/53] fix(web): reset chat dedup epochs --- .../adapter/__tests__/adapter.test.ts | 133 ++++++++++++++++++ .../renderer/webclient/adapter/agentChat.ts | 39 ++--- .../webclient/adapter/infra/chatEventDedup.ts | 29 ++++ .../webclient/adapter/personalChats.ts | 28 ++-- 4 files changed, 205 insertions(+), 24 deletions(-) create mode 100644 apps/desktop/src/renderer/webclient/adapter/infra/chatEventDedup.ts diff --git a/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts b/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts index a288b9968..04c3fc189 100644 --- a/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts +++ b/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ProjectInfo } from "../../../../shared/types"; import type { SyncChatEventPayload, + SyncChatSubscribeSnapshotPayload, SyncFileBlob, SyncTerminalHistoryResponsePayload, SyncMobileProjectSummary, @@ -22,6 +23,24 @@ const project: ProjectInfo = { baseRef: "main", }; +function chatEvent(sessionId: string, seq: number, marker: string): SyncChatEventPayload { + return { + sessionId, + seq, + timestamp: `2026-07-20T00:00:${String(seq).padStart(2, "0")}.000Z`, + event: { type: "status", status: "started", marker } as never, + }; +} + +function transcriptChatEvent(sessionId: string, sequence: number, marker: string): SyncChatEventPayload { + return { + sessionId, + sequence, + timestamp: `2026-07-20T00:01:${String(sequence).padStart(2, "0")}.000Z`, + event: { type: "status", status: "started", marker } as never, + }; +} + describe("createAdeWebAdapter", () => { let fake: FakeAdeSyncClient; @@ -385,6 +404,50 @@ describe("createAdeWebAdapter", () => { adapter.dispose(); }); + it("accepts a restarted project chat seq before its non-resumed snapshot", async () => { + fake.descriptors = descriptors(["chat.getSummary"]); + fake.commandResults.set("chat.getSummary", { sessionId: "chat-restarted" }); + const adapter = createAdeWebAdapter(fake.asClient()); + adapter.bindProject(project, "project-1"); + await adapter.ade.agentChat.getSummary({ sessionId: "chat-restarted" }); + fake.commandResults.set("chat.getSummary", { sessionId: "chat-unrelated" }); + await adapter.ade.agentChat.getSummary({ sessionId: "chat-unrelated" }); + const received: SyncChatEventPayload[] = []; + adapter.ade.agentChat.onEvent((event) => received.push(event as SyncChatEventPayload)); + + const initialSnapshotEvent = transcriptChatEvent("chat-restarted", 1, "snapshot-source"); + fake.emitChatSnapshot("chat-restarted", { + sessionId: "chat-restarted", + capturedAt: "2026-07-20T00:00:00.000Z", + truncated: false, + resumed: false, + events: [initialSnapshotEvent, { ...initialSnapshotEvent }], + }); + fake.emitChat(chatEvent("chat-restarted", 1, "live-before-restart")); + const unrelatedEvent = chatEvent("chat-unrelated", 1, "old-unrelated"); + fake.emitChat(unrelatedEvent); + const liveAfterRestart = chatEvent("chat-restarted", 1, "live-after-restart"); + fake.emitChat(liveAfterRestart); + fake.emitChat({ ...liveAfterRestart }); + fake.emitChatSnapshot("chat-restarted", { + sessionId: "chat-restarted", + capturedAt: "2026-07-20T00:00:01.000Z", + truncated: false, + resumed: false, + events: [transcriptChatEvent("chat-restarted", 1, "snapshot-after-restart")], + }); + fake.emitChat({ ...unrelatedEvent }); + + expect(received.map((payload) => [payload.sessionId, payload.event])).toEqual([ + ["chat-restarted", expect.objectContaining({ marker: "snapshot-source" })], + ["chat-restarted", expect.objectContaining({ marker: "live-before-restart" })], + ["chat-unrelated", expect.objectContaining({ marker: "old-unrelated" })], + ["chat-restarted", expect.objectContaining({ marker: "live-after-restart" })], + ["chat-restarted", expect.objectContaining({ marker: "snapshot-after-restart" })], + ]); + adapter.dispose(); + }); + it("keeps the last successful read through a transport outage without caching it as fresh", async () => { vi.useFakeTimers(); fake.descriptors = descriptors(["lanes.list"]); @@ -553,6 +616,72 @@ describe("createAdeWebAdapter", () => { adapter.dispose(); }); + it("accepts a restarted personal-chat seq before its non-resumed snapshot", async () => { + fake.descriptors = [{ + action: "personalChats.list", + scope: "runtime", + policy: { viewerAllowed: true }, + }]; + fake.commandResults.set("personalChats.list", [ + { sessionId: "personal-restarted" }, + { sessionId: "personal-unrelated" }, + ]); + const adapter = createAdeWebAdapter(fake.asClient()); + await adapter.ade.personalChats.call({ action: "list", args: {} }); + + const initialSnapshotEvent = transcriptChatEvent("personal-restarted", 1, "snapshot-source"); + fake.emitChatSnapshot("personal-restarted", { + sessionId: "personal-restarted", + capturedAt: "2026-07-20T00:00:00.000Z", + truncated: false, + resumed: false, + events: [initialSnapshotEvent, { ...initialSnapshotEvent }], + }); + fake.emitChat(chatEvent("personal-restarted", 1, "live-before-restart")); + const unrelatedEvent = chatEvent("personal-unrelated", 1, "old-unrelated"); + fake.emitChat(unrelatedEvent); + await expect(adapter.ade.personalChats.streamEvents({ cursor: 0 })).resolves.toMatchObject({ + nextCursor: 3, + events: [ + { payload: { sessionId: "personal-restarted", event: { marker: "snapshot-source" } } }, + { payload: { sessionId: "personal-restarted", event: { marker: "live-before-restart" } } }, + { payload: { sessionId: "personal-unrelated", event: { marker: "old-unrelated" } } }, + ], + }); + + const liveAfterRestart = chatEvent("personal-restarted", 1, "live-after-restart"); + fake.emitChat(liveAfterRestart); + fake.emitChat({ ...liveAfterRestart }); + fake.emitChatSnapshot("personal-restarted", { + sessionId: "personal-restarted", + capturedAt: "2026-07-20T00:00:01.000Z", + truncated: false, + resumed: false, + events: [transcriptChatEvent("personal-restarted", 1, "snapshot-after-restart")], + }); + fake.emitChat({ ...unrelatedEvent }); + + await expect(adapter.ade.personalChats.streamEvents({ cursor: 3 })).resolves.toMatchObject({ + nextCursor: 5, + hasMore: false, + events: [ + { + payload: { + sessionId: "personal-restarted", + event: { marker: "live-after-restart" }, + }, + }, + { + payload: { + sessionId: "personal-restarted", + event: { marker: "snapshot-after-restart" }, + }, + }, + ], + }); + adapter.dispose(); + }); + it("captures web analytics at runtime scope with a durable browser-local opt-out", async () => { const stored = new Map(); vi.stubGlobal("window", { @@ -1521,6 +1650,10 @@ class FakeAdeSyncClient { this.chatHandlers.get(payload.sessionId)?.event?.(payload); } + emitChatSnapshot(sessionId: string, payload: SyncChatSubscribeSnapshotPayload): void { + this.chatHandlers.get(sessionId)?.snapshot?.(payload); + } + emitTerminalData(sessionId: string, payload: SyncTerminalDataPayload): void { this.terminalHandlers.get(sessionId)?.data?.(payload); } diff --git a/apps/desktop/src/renderer/webclient/adapter/agentChat.ts b/apps/desktop/src/renderer/webclient/adapter/agentChat.ts index 4875ab8bf..46e3a3485 100644 --- a/apps/desktop/src/renderer/webclient/adapter/agentChat.ts +++ b/apps/desktop/src/renderer/webclient/adapter/agentChat.ts @@ -9,6 +9,7 @@ import type { import { deriveSmartLinkPreview } from "../../../shared/smartLinks"; import type { AdapterInfra, AdeNamespace } from "./types"; import { requestDataUrl, requestFileBlob } from "./infra/fileBlob"; +import { chatEventDedupKey } from "./infra/chatEventDedup"; // The browser gets the current chat tail through both chat_subscribe and // chat.getChatEventHistory. Keep each initial payload small: remote hosts @@ -27,19 +28,31 @@ export function createAgentChatNamespace(infra: AdapterInfra): AdeNamespace<"age const deliveredEventSet = new Set(); function emitChatEvent(payload: SyncChatEventPayload): void { - const key = chatEventKey(payload); - if (key) { - if (deliveredEventSet.has(key)) return; - deliveredEventSet.add(key); - deliveredEvents.push(key); - while (deliveredEvents.length > 500) { - const oldest = deliveredEvents.shift(); - if (oldest) deliveredEventSet.delete(oldest); - } + const key = chatEventDedupKey(payload); + if (deliveredEventSet.has(key)) return; + deliveredEventSet.add(key); + deliveredEvents.push(key); + while (deliveredEvents.length > 500) { + const oldest = deliveredEvents.shift(); + if (oldest) deliveredEventSet.delete(oldest); } events.emit("agentChatEvent", payload); } + function resetDeliveredSyncEpoch(sessionId: string): void { + const prefix = `${sessionId}:sync-seq:`; + let writeIndex = 0; + for (const key of deliveredEvents) { + if (key.startsWith(prefix)) { + deliveredEventSet.delete(key); + continue; + } + deliveredEvents[writeIndex] = key; + writeIndex += 1; + } + deliveredEvents.length = writeIndex; + } + function ensureChatSubscription(sessionId: string | null | undefined): void { if (!sessionId) return; const existingUnsubscribe = chatSubscriptions.get(sessionId); @@ -61,6 +74,7 @@ export function createAgentChatNamespace(infra: AdapterInfra): AdeNamespace<"age { maxBytes: WEB_CHAT_INITIAL_SNAPSHOT_MAX_BYTES }, { snapshot: (payload) => { + if (payload.resumed !== true) resetDeliveredSyncEpoch(payload.sessionId); for (const event of payload.events) emitChatEvent(event as SyncChatEventPayload); }, event: (payload) => { @@ -318,10 +332,3 @@ function stringField(record: Record, key: string): string { const value = record[key]; return typeof value === "string" ? value : ""; } - -function chatEventKey(payload: SyncChatEventPayload): string | null { - const seq = typeof payload.seq === "number" ? payload.seq : typeof payload.sequence === "number" ? payload.sequence : null; - if (seq !== null) return `${payload.sessionId}:seq:${seq}`; - const eventType = payload.event && typeof payload.event === "object" && "type" in payload.event ? String(payload.event.type) : ""; - return `${payload.sessionId}:ts:${payload.timestamp}:${eventType}`; -} diff --git a/apps/desktop/src/renderer/webclient/adapter/infra/chatEventDedup.ts b/apps/desktop/src/renderer/webclient/adapter/infra/chatEventDedup.ts new file mode 100644 index 000000000..406939b87 --- /dev/null +++ b/apps/desktop/src/renderer/webclient/adapter/infra/chatEventDedup.ts @@ -0,0 +1,29 @@ +import type { SyncChatEventPayload } from "../../../../shared/types/sync"; +import { stableCacheKey } from "./cacheKey"; + +export function chatEventDedupKey(payload: SyncChatEventPayload): string { + if (typeof payload.seq === "number") { + return `${payload.sessionId}:sync-seq:${payload.seq}:${eventFingerprint(payload)}`; + } + + const sourceSequence = typeof payload.sequence === "number" + ? `:${payload.sequence}` + : ""; + return `${payload.sessionId}:source${sourceSequence}:${eventFingerprint(payload)}`; +} + +function eventFingerprint(payload: SyncChatEventPayload): string { + const serialized = stableCacheKey({ + timestamp: payload.timestamp, + event: payload.event, + provenance: payload.provenance ?? null, + }); + let first = 2_166_136_261; + let second = 2_166_136_261; + for (let index = 0; index < serialized.length; index += 1) { + const code = serialized.charCodeAt(index); + first = Math.imul(first ^ code, 16_777_619); + second = Math.imul(second ^ code, 1_597_334_677); + } + return `${serialized.length}:${(first >>> 0).toString(36)}:${(second >>> 0).toString(36)}`; +} diff --git a/apps/desktop/src/renderer/webclient/adapter/personalChats.ts b/apps/desktop/src/renderer/webclient/adapter/personalChats.ts index c652f86ac..83f861b6a 100644 --- a/apps/desktop/src/renderer/webclient/adapter/personalChats.ts +++ b/apps/desktop/src/renderer/webclient/adapter/personalChats.ts @@ -7,6 +7,7 @@ import type { RemoteRuntimeBufferedEvent, } from "../../../shared/types"; import type { SyncChatEventPayload } from "../../../shared/types/sync"; +import { chatEventDedupKey } from "./infra/chatEventDedup"; import type { AdapterInfra, AdeNamespace } from "./types"; function fallbackFor(action: PersonalChatAction): unknown { @@ -30,13 +31,7 @@ export function createPersonalChatsNamespace(infra: AdapterInfra): AdeNamespace< let nextEventId = 1; const pushEvent = (payload: SyncChatEventPayload) => { - const seq = typeof payload.seq === "number" ? payload.seq : null; - const eventType = payload.event && typeof payload.event === "object" && "type" in payload.event - ? String(payload.event.type) - : ""; - const key = seq == null - ? `${payload.sessionId}:${payload.timestamp}:${eventType}` - : `${payload.sessionId}:${seq}`; + const key = chatEventDedupKey(payload); if (delivered.has(key)) return; delivered.add(key); deliveredOrder.push(key); @@ -53,13 +48,30 @@ export function createPersonalChatsNamespace(infra: AdapterInfra): AdeNamespace< while (buffered.length > 2_000) buffered.shift(); }; + const resetDeliveredSyncEpoch = (sessionId: string) => { + const prefix = `${sessionId}:sync-seq:`; + let writeIndex = 0; + for (const key of deliveredOrder) { + if (key.startsWith(prefix)) { + delivered.delete(key); + continue; + } + deliveredOrder[writeIndex] = key; + writeIndex += 1; + } + deliveredOrder.length = writeIndex; + }; + const ensureSubscription = (sessionId: unknown) => { if (typeof sessionId !== "string" || !sessionId || subscriptions.has(sessionId)) return; subscriptions.set(sessionId, client.subscribeChat( sessionId, { chatScope: "personal", maxBytes: 4 * 1024 * 1024 }, { - snapshot: (snapshot) => snapshot.events.forEach((event) => pushEvent(event as SyncChatEventPayload)), + snapshot: (snapshot) => { + if (snapshot.resumed !== true) resetDeliveredSyncEpoch(snapshot.sessionId); + snapshot.events.forEach((event) => pushEvent(event as SyncChatEventPayload)); + }, event: pushEvent, }, )); From 7ce40b280f87e6dba2626b7104613da1774a6e4f Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:02:17 -0400 Subject: [PATCH 19/53] fix(web): retire adopted project streams --- .../webclient/sync/__tests__/sync.test.ts | 18 ++++++++++++++++++ .../src/renderer/webclient/sync/client.ts | 8 +++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts b/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts index ec41337b1..bbf78b222 100644 --- a/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts +++ b/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts @@ -3022,10 +3022,18 @@ describe("browser sync connection and client", () => { envelope.type === "chat_subscribe" && (envelope.payload as { sessionId?: string }).sessionId === "project-chat" ))).toEqual([expect.objectContaining({ projectId: "project-1" })]); + expect(script.sockets[0].sent.filter((envelope) => ( + envelope.type === "chat_unsubscribe" + && (envelope.payload as { sessionId?: string }).sessionId === "project-chat" + ))).toEqual([expect.objectContaining({ projectId: "project-2" })]); expect(script.sockets[0].sent.filter((envelope) => ( envelope.type === "chat_subscribe" && (envelope.payload as { sessionId?: string }).sessionId === "personal-chat" ))).toHaveLength(1); + expect(script.sockets[0].sent.some((envelope) => ( + envelope.type === "chat_unsubscribe" + && (envelope.payload as { sessionId?: string }).sessionId === "personal-chat" + ))).toBe(false); expect(script.sockets[0].sent.find((envelope) => ( envelope.type === "chat_subscribe" && (envelope.payload as { sessionId?: string }).sessionId === "personal-chat" @@ -3045,9 +3053,19 @@ describe("browser sync connection and client", () => { projectRootPath: "/repo-foreign", }); expect(foreignSubscriptions[1]?.payload).not.toHaveProperty("sinceSeq"); + expect(script.sockets[0].sent.some((envelope) => ( + envelope.type === "chat_unsubscribe" + && (envelope.payload as { sessionId?: string }).sessionId === "foreign-chat" + ))).toBe(false); expect(script.sockets[0].sent.filter((envelope) => envelope.type === "terminal_subscribe")).toEqual([ expect.objectContaining({ projectId: "project-1" }), ]); + expect(script.sockets[0].sent.filter((envelope) => envelope.type === "terminal_unsubscribe")).toEqual([ + expect.objectContaining({ + projectId: "project-2", + payload: { sessionId: "project-terminal" }, + }), + ]); script.sockets[0]?.serverSend({ type: "chat_event", diff --git a/apps/desktop/src/renderer/webclient/sync/client.ts b/apps/desktop/src/renderer/webclient/sync/client.ts index 2397b2086..5335a2ee2 100644 --- a/apps/desktop/src/renderer/webclient/sync/client.ts +++ b/apps/desktop/src/renderer/webclient/sync/client.ts @@ -1475,10 +1475,12 @@ export class AdeSyncClient { } private retireActiveProjectStreams(): void { - for (const [sessionId, subscription] of this.chatSubscriptions) { - if (subscription.bindsActiveProject) this.chatSubscriptions.delete(sessionId); + for (const [sessionId, subscription] of [...this.chatSubscriptions]) { + if (subscription.bindsActiveProject) this.unsubscribeChat(sessionId); + } + for (const sessionId of [...this.terminalSubscriptions.keys()]) { + this.unsubscribeTerminal(sessionId); } - this.terminalSubscriptions.clear(); } private rebindForeignProjectStreams(): void { From 4b6513317c9bf4f36cbfe8ba3fcf381466122327 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:12:14 -0400 Subject: [PATCH 20/53] perf(sync): bound project catalog artwork --- .../ade-cli/src/multiProjectRpcServer.test.ts | 43 ++++++++++- apps/ade-cli/src/multiProjectRpcServer.ts | 34 +++++++-- .../projects/projectIconResolver.test.ts | 11 ++- .../services/projects/projectIconResolver.ts | 67 +++++++++-------- .../projects/projectIconResolver.test.ts | 39 +++++++++- .../services/projects/projectIconResolver.ts | 74 ++++++++++++++++++- .../services/projects/projectIconThumbnail.ts | 39 +++++++--- 7 files changed, 249 insertions(+), 58 deletions(-) diff --git a/apps/ade-cli/src/multiProjectRpcServer.test.ts b/apps/ade-cli/src/multiProjectRpcServer.test.ts index f0411f636..c66403736 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.test.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.test.ts @@ -4,7 +4,10 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { createEventBuffer } from "./eventBuffer"; -import { createMultiProjectRpcRequestHandler } from "./multiProjectRpcServer"; +import { + createMultiProjectRpcRequestHandler, + decorateProjectListWithIcons, +} from "./multiProjectRpcServer"; import * as gitModule from "../../desktop/src/main/services/git/git"; import { ProjectRegistry } from "./services/projects/projectRegistry"; import { ProjectScopeRegistry } from "./services/projects/projectScope"; @@ -121,6 +124,44 @@ function makeRuntime(label: string) { } describe("multi-project RPC server", () => { + it("keeps the complete inline icon catalog below its hard wire budget", () => { + const records = Array.from({ length: 8 }, (_, index) => ({ + rootPath: `/project-${index}`, + lastOpenedAt: index, + })); + const iconPayload = `data:image/png;base64,${"a".repeat(100 * 1024)}`; + + const decorated = decorateProjectListWithIcons(records, (rootPath) => ({ + dataUrl: iconPayload, + sourcePath: `${rootPath}/icon.png`, + mimeType: "image/png", + })); + + const iconBytes = decorated.reduce( + (total, record) => total + Buffer.byteLength(record.icon.dataUrl ?? "", "utf8"), + 0, + ); + expect(iconBytes).toBeLessThanOrEqual(512 * 1024); + expect(decorated.filter((record) => record.icon.dataUrl).length).toBe(5); + }); + + it("drops an individually oversized icon before it reaches the catalog", () => { + const [decorated] = decorateProjectListWithIcons( + [{ rootPath: "/project", lastOpenedAt: 1 }], + () => ({ + dataUrl: `data:image/png;base64,${"a".repeat(129 * 1024)}`, + sourcePath: "/project/icon.png", + mimeType: "image/png", + }), + ); + + expect(decorated.icon).toEqual({ + dataUrl: null, + sourcePath: null, + mimeType: null, + }); + }); + it("reconciles account-owned client trust on sign-out and account switch", async () => { const { registry } = createRegistry(); const accountAuthService = makeAccountAuthServiceMock(); diff --git a/apps/ade-cli/src/multiProjectRpcServer.ts b/apps/ade-cli/src/multiProjectRpcServer.ts index a0da94e2b..4d80621bc 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.ts @@ -26,7 +26,10 @@ import { type JsonRpcRequest, } from "./jsonrpc"; import { resolveMachineAdeLayout } from "./services/projects/machineLayout"; -import { resolveRemoteProjectIcon } from "./services/projects/projectIconResolver"; +import { + REMOTE_ICON_MAX_DATA_URL_BYTES, + resolveRemoteProjectIcon, +} from "./services/projects/projectIconResolver"; import { ProjectRegistry, SYSTEM_PROJECT_REGISTRATION, @@ -251,8 +254,9 @@ const EMPTY_PROJECT_ICON: ResolvedProjectIcon = Object.freeze({ // most this many icons and this many inlined bytes per call. A large or // slow-filesystem registry then can't stall a connect just to render tab // artwork — projects past the budget fall back to a null icon. -const LIST_ICON_COUNT_BUDGET = 64; -const LIST_ICON_BYTE_BUDGET = 12 * 1024 * 1024; +const LIST_ICON_COUNT_BUDGET = 24; +const LIST_ICON_BYTE_BUDGET = 512 * 1024; +const LIST_ICON_RESOLVE_BUDGET_MS = 750; // Stamp a single project record with its host-resolved icon so a remote desktop // can render the real project logo. Used for the records returned by @@ -268,20 +272,36 @@ function decorateProjectWithIcon( // Decorate a full project list with icons under the connect-path budget above. // Icons are resolved for the most-recently-opened projects first (those most // likely to be open as tabs) while the returned array stays in registry order. -function decorateProjectListWithIcons( +export function decorateProjectListWithIcons( records: readonly T[], + resolveIcon: (rootPath: string) => ResolvedProjectIcon = resolveRemoteProjectIcon, ): Array { const icons = new Map(); let count = 0; let bytes = 0; + const startedAt = Date.now(); const byRecency = records .map((record, index) => ({ record, index })) .sort((a, b) => b.record.lastOpenedAt - a.record.lastOpenedAt); for (const { record, index } of byRecency) { - if (count >= LIST_ICON_COUNT_BUDGET || bytes >= LIST_ICON_BYTE_BUDGET) break; - const icon = resolveRemoteProjectIcon(record.rootPath); + if ( + count >= LIST_ICON_COUNT_BUDGET + || bytes >= LIST_ICON_BYTE_BUDGET + || Date.now() - startedAt >= LIST_ICON_RESOLVE_BUDGET_MS + ) break; + const icon = resolveIcon(record.rootPath); count += 1; - if (icon.dataUrl) bytes += icon.dataUrl.length; + const iconBytes = icon.dataUrl + ? Buffer.byteLength(icon.dataUrl, "utf8") + : 0; + if ( + iconBytes > REMOTE_ICON_MAX_DATA_URL_BYTES + || bytes + iconBytes > LIST_ICON_BYTE_BUDGET + ) { + icons.set(index, EMPTY_PROJECT_ICON); + continue; + } + bytes += iconBytes; icons.set(index, icon); } return records.map((record, index) => ({ diff --git a/apps/ade-cli/src/services/projects/projectIconResolver.test.ts b/apps/ade-cli/src/services/projects/projectIconResolver.test.ts index 30df38b73..d4741bd22 100644 --- a/apps/ade-cli/src/services/projects/projectIconResolver.test.ts +++ b/apps/ade-cli/src/services/projects/projectIconResolver.test.ts @@ -3,7 +3,10 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { resolveRemoteProjectIcon } from "./projectIconResolver"; +import { + REMOTE_ICON_MAX_DATA_URL_BYTES, + resolveRemoteProjectIcon, +} from "./projectIconResolver"; const tempRoots = new Set(); @@ -147,9 +150,11 @@ describe("resolveRemoteProjectIcon", () => { expect(icon.sourcePath).toBeNull(); }); - it("skips an oversized icon but keeps its metadata", () => { + it("never returns an encoded icon above the wire cap and keeps its metadata", () => { const root = makeTempRoot(); - const big = Buffer.alloc(2 * 1024 * 1024 + 1, 0); + // Invalid raster data makes the thumbnailer take its bounded raw-PNG + // fallback, where base64 expansion pushes this beyond the encoded cap. + const big = Buffer.alloc(REMOTE_ICON_MAX_DATA_URL_BYTES, 0); writeFileEnsuringDir(path.join(root, "logo.png"), big); const icon = resolveRemoteProjectIcon(root); diff --git a/apps/ade-cli/src/services/projects/projectIconResolver.ts b/apps/ade-cli/src/services/projects/projectIconResolver.ts index e934b6b3b..1e55e51f6 100644 --- a/apps/ade-cli/src/services/projects/projectIconResolver.ts +++ b/apps/ade-cli/src/services/projects/projectIconResolver.ts @@ -4,21 +4,20 @@ import { resolveProjectIcon, resolveProjectIconPath, } from "../../../../desktop/src/main/services/projects/projectIconResolver"; +import { + PROJECT_ICON_THUMBNAIL_MAX_DATA_URL_BYTES, + resolveMobileProjectIconDataUrl, +} from "../../../../desktop/src/main/services/projects/projectIconThumbnail"; /** * Resolves a project's icon on the machine that hosts the project files, so a * desktop connected to this brain over the remote runtime can show the real * project logo in its project tab instead of a blank folder. * - * This reuses the desktop's `resolveProjectIcon` (already in the brain bundle — - * `cli.ts` imports the same module chain for the mobile sync icon path), so the - * icon a remote desktop sees is exactly the one the host machine would show, - * and we inherit its mtime-keyed result cache. Two things are layered on top: - * 1. A wire-size cap — these icons travel inline in the `projects.list` - * payload, so anything too large is dropped. - * 2. A size preflight via `resolveProjectIconPath` BEFORE `resolveProjectIcon` - * reads/encodes/caches the data URL, so an oversized icon is never inlined - * or retained in the resolver's cache just to be discarded. + * This reuses the same 64px thumbnail path as mobile (already in the brain + * bundle) and applies a strict encoded-size cap. Project art is cosmetic and + * travels inline in `projects.list`, so a full-resolution app icon must never + * consume a relay frame or delay project bootstrap. */ export type RemoteProjectIcon = { dataUrl: string | null; @@ -26,10 +25,10 @@ export type RemoteProjectIcon = { mimeType: string | null; }; -// Cap on the raw icon file. base64 inflates ~33%, so a 2 MB file yields a -// ~2.7 MB data URL — an acceptable ceiling for inline transport, and well below -// the desktop resolver's 10 MB on-disk limit. -const REMOTE_ICON_MAX_FILE_BYTES = 2 * 1024 * 1024; +// Keep this aligned with the persisted remote-project icon boundary. The +// thumbnail normally lands well below this; the cap also protects platforms +// where no rasterizer is available and the helper falls back to a raw PNG. +export const REMOTE_ICON_MAX_DATA_URL_BYTES = PROJECT_ICON_THUMBNAIL_MAX_DATA_URL_BYTES; // Frozen so the shared singleton can't be mutated by a caller and silently // corrupt every subsequent resolve. @@ -71,26 +70,30 @@ export function resolveRemoteProjectIcon(projectRoot: string): RemoteProjectIcon } if (!iconPath) return EMPTY_ICON; - // Preflight the file size BEFORE resolveProjectIcon reads, base64-encodes, and - // caches the full data URL. Without this, an oversized icon (under the - // desktop resolver's 10 MB cap) would be inlined and retained in the shared - // result cache even though we drop it from the wire. - let size: number; - try { - size = fs.statSync(iconPath).size; - } catch { - return EMPTY_ICON; - } - if (size > REMOTE_ICON_MAX_FILE_BYTES) { - return { - dataUrl: null, - sourcePath: iconPath, - mimeType: mimeTypeForIconPath(iconPath), - }; - } - try { - return resolveProjectIcon(root); + const thumbnailDataUrl = resolveMobileProjectIconDataUrl(root, { + resolvedSourcePath: iconPath, + }); + // Headless hosts may not have a rasterizer. Small originals remain safe to + // inline (and preserve SVG/JPEG/WebP project art); larger originals are + // never read merely to discover that base64 would exceed the wire cap. + const dataUrl = thumbnailDataUrl ?? ( + fs.statSync(iconPath).size <= 96 * 1024 + ? resolveProjectIcon(root).dataUrl + : null + ); + if ( + !dataUrl + || Buffer.byteLength(dataUrl, "utf8") > REMOTE_ICON_MAX_DATA_URL_BYTES + ) { + return { + dataUrl: null, + sourcePath: iconPath, + mimeType: mimeTypeForIconPath(iconPath), + }; + } + const mimeType = /^data:([^;,]+)[;,]/i.exec(dataUrl)?.[1] ?? null; + return { dataUrl, sourcePath: iconPath, mimeType }; } catch { return EMPTY_ICON; } diff --git a/apps/desktop/src/main/services/projects/projectIconResolver.test.ts b/apps/desktop/src/main/services/projects/projectIconResolver.test.ts index ecb1d8267..0daf48896 100644 --- a/apps/desktop/src/main/services/projects/projectIconResolver.test.ts +++ b/apps/desktop/src/main/services/projects/projectIconResolver.test.ts @@ -10,7 +10,10 @@ import { setProjectIconOverride, setProjectIconOverrideFromSelection, } from "./projectIconResolver"; -import { resolveMobileProjectIconDataUrl } from "./projectIconThumbnail"; +import { + PROJECT_ICON_THUMBNAIL_MAX_DATA_URL_BYTES, + resolveMobileProjectIconDataUrl, +} from "./projectIconThumbnail"; const OVER_ICON_LIMIT_BYTES = 10 * 1024 * 1024 + 1; const PNG_DATA = Buffer.from( @@ -185,6 +188,21 @@ describe("projectIconResolver", () => { expect(icon.dataUrl).toMatch(/^data:image\/svg\+xml;base64,/); }); + it("reuses positive icon-path discovery until its source signature changes", () => { + const root = makeProjectRoot(); + const iconPath = writeFile(root, "icon.png", PNG_DATA); + expect(resolveProjectIconPath(root)).toBe(iconPath); + + const readdirSpy = vi.spyOn(fs, "readdirSync"); + expect(resolveProjectIconPath(root)).toBe(iconPath); + expect(readdirSpy).not.toHaveBeenCalled(); + + fs.appendFileSync(iconPath, Buffer.from([0])); + expect(resolveProjectIconPath(root)).toBe(iconPath); + expect(readdirSpy).toHaveBeenCalled(); + readdirSpy.mockRestore(); + }); + it("uses an Electron nativeImage thumbnail for mobile when one can be decoded", () => { const root = makeProjectRoot(); writeFile(root, "icon.png", PNG_DATA); @@ -246,6 +264,25 @@ describe("projectIconResolver", () => { expect(dataUrl).toBe(`data:image/png;base64,${PNG_DATA.toString("base64")}`); }); + it("drops a thumbnail that exceeds the sync payload cap", () => { + const root = makeProjectRoot(); + writeFile(root, "icon.png", PNG_DATA); + + const dataUrl = resolveMobileProjectIconDataUrl(root, { + nativeImage: { + createFromPath: () => ({ + isEmpty: () => false, + resize: () => ({ + toDataURL: () => + `data:image/png;base64,${"a".repeat(PROJECT_ICON_THUMBNAIL_MAX_DATA_URL_BYTES)}`, + }), + }), + }, + }); + + expect(dataUrl).toBeNull(); + }); + it("keeps native and headless mobile thumbnail cache entries separate", () => { const root = makeProjectRoot(); writeFile( diff --git a/apps/desktop/src/main/services/projects/projectIconResolver.ts b/apps/desktop/src/main/services/projects/projectIconResolver.ts index 36fe4a0cb..55736ac3f 100644 --- a/apps/desktop/src/main/services/projects/projectIconResolver.ts +++ b/apps/desktop/src/main/services/projects/projectIconResolver.ts @@ -201,6 +201,19 @@ type ProjectIconResultCacheEntry = { const projectIconResultCache = new Map(); +type ProjectIconPathCacheEntry = { + rootMtimeMs: number; + appsMtimeMs: number; + packagesMtimeMs: number; + configMtimeMs: number; + sourceMtimeMs: number; + sourceSize: number; + expiresAtMs: number; + value: string; +}; + +const projectIconPathCache = new Map(); + function dirMtimeMs(absPath: string): number { try { return fs.statSync(absPath).mtimeMs; @@ -240,6 +253,16 @@ function setProjectIconResultCache(key: string, entry: ProjectIconResultCacheEnt projectIconResultCache.set(key, entry); } +function setProjectIconPathCache(key: string, entry: ProjectIconPathCacheEntry): void { + if (projectIconPathCache.has(key)) { + projectIconPathCache.delete(key); + } else if (projectIconPathCache.size >= PROJECT_ICON_RESULT_CACHE_MAX) { + const oldestKey = projectIconPathCache.keys().next().value; + if (oldestKey !== undefined) projectIconPathCache.delete(oldestKey); + } + projectIconPathCache.set(key, entry); +} + function clearProjectIconResultCache(projectRoot: string): void { const root = path.resolve(projectRoot); for (const key of projectIconResultCache.keys()) { @@ -247,6 +270,11 @@ function clearProjectIconResultCache(projectRoot: string): void { projectIconResultCache.delete(key); } } + for (const key of projectIconPathCache.keys()) { + if (key === root || key.startsWith(`${root}\0`)) { + projectIconPathCache.delete(key); + } + } } // Resolving a project icon scans the project root and every first-level child @@ -531,15 +559,53 @@ export function resolveProjectIconPath( options: { iconPathOverride?: string | null } = {}, ): string | null { const root = path.resolve(projectRoot); + const cacheKey = projectIconResultCacheKey(root, options); + const rootMtimeMs = dirMtimeMs(root); + const appsMtimeMs = dirMtimeMs(path.join(root, "apps")); + const packagesMtimeMs = dirMtimeMs(path.join(root, "packages")); + const configMtimeMs = dirMtimeMs(path.join(root, ".ade", "ade.yaml")); + const cached = projectIconPathCache.get(cacheKey); + if ( + cached + && cached.expiresAtMs > Date.now() + && cached.rootMtimeMs === rootMtimeMs + && cached.appsMtimeMs === appsMtimeMs + && cached.packagesMtimeMs === packagesMtimeMs + && cached.configMtimeMs === configMtimeMs + ) { + const sourceSignature = fileSignature(cached.value); + if ( + sourceSignature.mtimeMs === cached.sourceMtimeMs + && sourceSignature.size === cached.sourceSize + ) { + projectIconPathCache.delete(cacheKey); + projectIconPathCache.set(cacheKey, cached); + return cached.value; + } + } + const cacheValue = (value: string): string => { + const sourceSignature = fileSignature(value); + setProjectIconPathCache(cacheKey, { + rootMtimeMs, + appsMtimeMs, + packagesMtimeMs, + configMtimeMs, + sourceMtimeMs: sourceSignature.mtimeMs, + sourceSize: sourceSignature.size, + expiresAtMs: Date.now() + PROJECT_ICON_RESULT_CACHE_TTL_MS, + value, + }); + return value; + }; const configured = Object.prototype.hasOwnProperty.call(options, "iconPathOverride") ? options.iconPathOverride : readProjectIconOverride(root); if (configured === null) return null; const configuredMatch = resolveConfiguredProjectIconPath(root, configured); - if (configuredMatch) return configuredMatch; + if (configuredMatch) return cacheValue(configuredMatch); const directMatch = findBestDetectedIcon(root); - if (directMatch) return directMatch; + if (directMatch) return cacheValue(directMatch); for (const sourceFile of ICON_SOURCE_FILES) { // Resolve through the real filesystem so a symlinked source file (e.g. @@ -560,7 +626,9 @@ export function resolveProjectIconPath( const href = extractIconHref(source); if (!href || !isLocalIconHref(href)) continue; const existing = findExistingFile(root, resolveIconHref(root, href)); - if (existing && isSupportedIconPath(existing) && isInlineableIconFile(existing)) return existing; + if (existing && isSupportedIconPath(existing) && isInlineableIconFile(existing)) { + return cacheValue(existing); + } } return null; diff --git a/apps/desktop/src/main/services/projects/projectIconThumbnail.ts b/apps/desktop/src/main/services/projects/projectIconThumbnail.ts index 0b41bc808..030464bc9 100644 --- a/apps/desktop/src/main/services/projects/projectIconThumbnail.ts +++ b/apps/desktop/src/main/services/projects/projectIconThumbnail.ts @@ -3,11 +3,13 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { resolveProjectIcon } from "./projectIconResolver"; +import { resolveProjectIcon, resolveProjectIconPath } from "./projectIconResolver"; const MOBILE_PROJECT_ICON_EDGE = 64; const MOBILE_PROJECT_ICON_THUMBNAIL_CACHE_MAX = 64; const SIPS_PATH = "/usr/bin/sips"; +export const PROJECT_ICON_THUMBNAIL_MAX_DATA_URL_BYTES = 128 * 1024; +const IMAGE_DATA_URL_RE = /^data:image\/[a-z0-9.+-]+;base64,/i; type NativeImageInstanceLike = { isEmpty(): boolean; @@ -32,6 +34,7 @@ type ResolveMobileProjectIconDataUrlOptions = { nativeImage?: NativeImageModuleLike; rasterizeWithSips?: SipsRasterizer; tmpRoot?: string; + resolvedSourcePath?: string; }; const thumbnailCache = new Map(); @@ -77,10 +80,17 @@ function defaultSipsRasterizer(sourcePath: string, outputPath: string, edge: num outputPath, ], { stdio: "ignore", - timeout: 5_000, + timeout: 500, }); } +function boundedThumbnailDataUrl(value: string | null): string | null { + if (!value || !IMAGE_DATA_URL_RE.test(value)) return null; + return Buffer.byteLength(value, "utf8") <= PROJECT_ICON_THUMBNAIL_MAX_DATA_URL_BYTES + ? value + : null; +} + function nativeImagePngDataUrl( sourcePath: string, nativeImage: NativeImageModuleLike | undefined, @@ -128,16 +138,16 @@ export function resolveMobileProjectIconDataUrl( projectRoot: string, options: ResolveMobileProjectIconDataUrlOptions = {}, ): string | null { - let icon: ReturnType; + let sourcePath: string | null; try { - icon = resolveProjectIcon(projectRoot); + sourcePath = options.resolvedSourcePath ?? resolveProjectIconPath(projectRoot); } catch { return null; } - if (!icon.sourcePath) return null; + if (!sourcePath) return null; - const signature = fileSignature(icon.sourcePath); - const cacheKey = thumbnailCacheKey(icon.sourcePath, options); + const signature = fileSignature(sourcePath); + const cacheKey = thumbnailCacheKey(sourcePath, options); const cached = thumbnailCache.get(cacheKey); if ( cached @@ -149,14 +159,21 @@ export function resolveMobileProjectIconDataUrl( return cached.value; } - const value = - nativeImagePngDataUrl(icon.sourcePath, options.nativeImage) + const value = boundedThumbnailDataUrl( + nativeImagePngDataUrl(sourcePath, options.nativeImage) ?? sipsPngDataUrl( - icon.sourcePath, + sourcePath, options.rasterizeWithSips ?? defaultSipsRasterizer, options.tmpRoot ?? os.tmpdir(), ) - ?? (icon.mimeType === "image/png" ? icon.dataUrl : null); + // Only read/base64-encode the original after both thumbnail paths fail. + // The common native/sips paths therefore never retain a multi-megabyte + // source image merely to return its tiny thumbnail. + ?? (() => { + const icon = resolveProjectIcon(projectRoot); + return icon.mimeType === "image/png" ? icon.dataUrl : null; + })(), + ); setThumbnailCache(cacheKey, { ...signature, value }); return value; From 30027ac9dcd9ddea1a023ecc1aa0577cf62a6ea2 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:15:54 -0400 Subject: [PATCH 21/53] Fix runtime starvation during project hydration --- apps/ade-cli/src/jsonrpc.test.ts | 46 +++ .../services/projects/projectScope.test.ts | 150 +++++++++- .../src/services/projects/projectScope.ts | 92 +++--- apps/desktop/src/main/services/git/git.ts | 48 ++-- .../services/github/githubService.test.ts | 68 ++++- .../src/main/services/github/githubService.ts | 264 +++++++++++++++--- .../src/main/services/ipc/ipcTimeouts.test.ts | 8 + .../src/main/services/ipc/ipcTimeouts.ts | 7 + .../lanes/laneListSnapshotService.test.ts | 130 +++++++++ .../services/lanes/laneListSnapshotService.ts | 146 ++++++++-- .../services/lanes/rebaseSuggestionService.ts | 56 ++-- .../localRuntimeConnectionPool.test.ts | 84 ++++++ .../localRuntimeConnectionPool.ts | 12 + .../src/main/services/pty/ptyService.test.ts | 54 ++++ .../src/main/services/pty/ptyService.ts | 76 ++--- 15 files changed, 1058 insertions(+), 183 deletions(-) diff --git a/apps/ade-cli/src/jsonrpc.test.ts b/apps/ade-cli/src/jsonrpc.test.ts index 9f67171ee..13b0b292d 100644 --- a/apps/ade-cli/src/jsonrpc.test.ts +++ b/apps/ade-cli/src/jsonrpc.test.ts @@ -142,6 +142,52 @@ describe("startJsonRpcServer", () => { stop(); }); + it("keeps session and layout reads responsive during slow lane, GitHub, and mutation calls", async () => { + const transport = new MemoryTransport(); + const slowSnapshot = deferred(); + const slowGithub = deferred(); + const slowDelete = deferred(); + const calls: string[] = []; + const handler = (async (request) => { + const params = request.params as { arguments?: { domain?: string; action?: string } } | undefined; + const action = params?.arguments?.action ?? request.method ?? ""; + calls.push(action); + if (action === "listSnapshots") await slowSnapshot.promise; + if (action === "getStatus") await slowGithub.promise; + if (action === "delete") await slowDelete.promise; + return { ok: true, action }; + }) as JsonRpcHandler; + + const stop = startJsonRpcServer(handler, transport, { nonFatal: true }); + const call = (id: number, domain: string, action: string) => transport.push({ + jsonrpc: "2.0", + id, + method: "ade/actions/call", + params: { arguments: { domain, action } }, + }); + + call(1, "lane", "listSnapshots"); + call(2, "github", "getStatus"); + call(3, "lane", "delete"); + call(4, "session", "list"); + call(5, "layout", "get"); + await waitForDrain(); + + expect(calls).toHaveLength(5); + expect(calls).toEqual(expect.arrayContaining(["listSnapshots", "getStatus", "delete", "list", "get"])); + expect(jsonlResponses(transport)).toEqual([ + { jsonrpc: "2.0", id: 4, result: { ok: true, action: "list" } }, + { jsonrpc: "2.0", id: 5, result: { ok: true, action: "get" } }, + ]); + + slowDelete.resolve(undefined); + slowSnapshot.resolve(undefined); + slowGithub.resolve(undefined); + await stop.waitForIdle(); + expect(calls.filter((action) => action === "delete")).toHaveLength(1); + stop(); + }); + it("waits for active dispatches before reporting idle", async () => { const transport = new MemoryTransport(); const slow = deferred(); diff --git a/apps/ade-cli/src/services/projects/projectScope.test.ts b/apps/ade-cli/src/services/projects/projectScope.test.ts index 30f1522e3..13b9a7073 100644 --- a/apps/ade-cli/src/services/projects/projectScope.test.ts +++ b/apps/ade-cli/src/services/projects/projectScope.test.ts @@ -8,6 +8,16 @@ import { ProjectScopeRegistry } from "./projectScope"; const createAdeRuntimeMock = vi.fn(); +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((innerResolve, innerReject) => { + resolve = innerResolve; + reject = innerReject; + }); + return { promise, resolve, reject }; +} + vi.mock("../../bootstrap", () => ({ createAdeRuntime: createAdeRuntimeMock, })); @@ -195,8 +205,8 @@ describe("ProjectScopeRegistry", () => { projectRoot: first.rootPath, syncRuntime: { enabled: true, - hostStartupEnabled: true, - hostDiscoveryEnabled: true, + hostStartupEnabled: false, + hostDiscoveryEnabled: false, initializeInBackground: true, }, }); @@ -251,8 +261,8 @@ describe("ProjectScopeRegistry", () => { projectRoot: second.rootPath, syncRuntime: { enabled: true, - hostStartupEnabled: true, - hostDiscoveryEnabled: true, + hostStartupEnabled: false, + hostDiscoveryEnabled: false, initializeInBackground: true, }, }); @@ -262,6 +272,138 @@ describe("ProjectScopeRegistry", () => { expect(secondDispose).toHaveBeenCalledTimes(1); }); + it("keeps the previous host active past parked-peer grace throughout a slow target boot", async () => { + vi.useFakeTimers(); + try { + const { registry, first, second } = createRegistry(); + const targetRuntime = deferred(); + const firstSyncService = { + initialize: vi.fn(async () => undefined), + setHostDiscoveryEnabled: vi.fn(), + setHostStartupEnabled: vi.fn(async () => undefined), + }; + const secondSyncService = { + initialize: vi.fn(async () => undefined), + setHostDiscoveryEnabled: vi.fn(), + setHostStartupEnabled: vi.fn(async () => undefined), + }; + createAdeRuntimeMock + .mockResolvedValueOnce({ dispose: vi.fn(), syncService: firstSyncService }) + .mockImplementationOnce(() => targetRuntime.promise); + const scopeRegistry = new ProjectScopeRegistry(registry, { + syncRuntime: { + enabled: true, + hostStartupEnabled: true, + hostDiscoveryEnabled: true, + forceHostRole: false, + runtimeKind: "daemon", + }, + }); + await scopeRegistry.switchSyncHost(first.projectId); + firstSyncService.setHostDiscoveryEnabled.mockClear(); + firstSyncService.setHostStartupEnabled.mockClear(); + + const switching = scopeRegistry.switchSyncHost(second.projectId); + await vi.advanceTimersByTimeAsync(30_001); + + // Parked peers are closed after 30s. The previous listener must still own + // them even when cold target setup outlives that entire grace period. + expect(scopeRegistry.getActiveSyncHostProjectId()).toBe(first.projectId); + expect(firstSyncService.setHostDiscoveryEnabled).not.toHaveBeenCalledWith(false); + expect(firstSyncService.setHostStartupEnabled).not.toHaveBeenCalledWith(false); + await expect(scopeRegistry.prewarmRecentScopes()).resolves.toEqual([]); + + targetRuntime.resolve({ dispose: vi.fn(), syncService: secondSyncService }); + await switching; + expect(scopeRegistry.getActiveSyncHostProjectId()).toBe(second.projectId); + expect(firstSyncService.setHostDiscoveryEnabled).toHaveBeenCalledWith(false); + expect(secondSyncService.setHostDiscoveryEnabled).toHaveBeenCalledWith(true); + } finally { + vi.useRealTimers(); + } + }); + + it("restores the previous host when target activation fails", async () => { + const { registry, first, second } = createRegistry(); + const firstSyncService = { + initialize: vi.fn(async () => undefined), + setHostDiscoveryEnabled: vi.fn(), + setHostStartupEnabled: vi.fn(async () => undefined), + }; + const secondSyncService = { + initialize: vi.fn(async () => undefined), + setHostDiscoveryEnabled: vi.fn(), + setHostStartupEnabled: vi.fn(async (enabled: boolean) => { + if (enabled) throw new Error("target activation failed"); + }), + }; + createAdeRuntimeMock + .mockResolvedValueOnce({ dispose: vi.fn(), syncService: firstSyncService }) + .mockResolvedValueOnce({ dispose: vi.fn(), syncService: secondSyncService }); + const scopeRegistry = new ProjectScopeRegistry(registry, { + syncRuntime: { + enabled: true, + hostStartupEnabled: true, + hostDiscoveryEnabled: true, + forceHostRole: false, + runtimeKind: "daemon", + }, + }); + await scopeRegistry.switchSyncHost(first.projectId); + firstSyncService.setHostDiscoveryEnabled.mockClear(); + firstSyncService.setHostStartupEnabled.mockClear(); + + await expect(scopeRegistry.switchSyncHost(second.projectId)).rejects.toThrow("target activation failed"); + + expect(scopeRegistry.getActiveSyncHostProjectId()).toBe(first.projectId); + expect(firstSyncService.setHostStartupEnabled).toHaveBeenNthCalledWith(1, false); + expect(firstSyncService.setHostStartupEnabled).toHaveBeenNthCalledWith(2, true); + expect(secondSyncService.setHostStartupEnabled).toHaveBeenCalledWith(false); + }); + + it("lets the newest concurrent sync-host selection win without flapping an intermediate host", async () => { + const { registry, first, second } = createRegistry(); + const thirdRoot = path.join(path.dirname(first.rootPath), "third-concurrent"); + fs.mkdirSync(thirdRoot, { recursive: true }); + const third = registry.add(thirdRoot); + const secondRuntime = deferred(); + const makeSyncService = () => ({ + initialize: vi.fn(async () => undefined), + setHostDiscoveryEnabled: vi.fn(), + setHostStartupEnabled: vi.fn(async () => undefined), + }); + const firstSyncService = makeSyncService(); + const secondSyncService = makeSyncService(); + const thirdSyncService = makeSyncService(); + createAdeRuntimeMock + .mockResolvedValueOnce({ dispose: vi.fn(), syncService: firstSyncService }) + .mockImplementationOnce(() => secondRuntime.promise) + .mockResolvedValueOnce({ dispose: vi.fn(), syncService: thirdSyncService }); + const scopeRegistry = new ProjectScopeRegistry(registry, { + syncRuntime: { + enabled: true, + hostStartupEnabled: true, + hostDiscoveryEnabled: true, + forceHostRole: false, + runtimeKind: "daemon", + }, + }); + await scopeRegistry.switchSyncHost(first.projectId); + firstSyncService.setHostStartupEnabled.mockClear(); + + const switchToSecond = scopeRegistry.switchSyncHost(second.projectId); + const switchToThird = scopeRegistry.switchSyncHost(third.projectId); + await new Promise((resolve) => setImmediate(resolve)); + secondRuntime.resolve({ dispose: vi.fn(), syncService: secondSyncService }); + await Promise.all([switchToSecond, switchToThird]); + + expect(scopeRegistry.getActiveSyncHostProjectId()).toBe(third.projectId); + expect(secondSyncService.setHostStartupEnabled).not.toHaveBeenCalledWith(true); + expect(thirdSyncService.setHostStartupEnabled).toHaveBeenCalledWith(true); + expect(firstSyncService.setHostStartupEnabled).toHaveBeenCalledTimes(1); + expect(firstSyncService.setHostStartupEnabled).toHaveBeenCalledWith(false); + }); + it("can prepare a new phone sync host before retiring the previous host", async () => { const { registry, first, second } = createRegistry(); const firstSyncService = { diff --git a/apps/ade-cli/src/services/projects/projectScope.ts b/apps/ade-cli/src/services/projects/projectScope.ts index 68c106f55..e40ae5567 100644 --- a/apps/ade-cli/src/services/projects/projectScope.ts +++ b/apps/ade-cli/src/services/projects/projectScope.ts @@ -36,6 +36,8 @@ export class ProjectScopeRegistry { private readonly disposeListeners = new Set<(projectId: ProjectId) => void>(); private syncHostProjectId: ProjectId | null = null; private syncHostTransitionDepth = 0; + private syncHostTransitionTail: Promise = Promise.resolve(); + private latestSyncHostTransitionId = 0; private prewarmStarted = false; private disposed = false; private readonly remoteCommandExecutor = { @@ -213,47 +215,66 @@ export class ProjectScopeRegistry { options: SwitchSyncHostOptions = {}, ): Promise { if (!this.options.syncRuntime?.enabled) return null; + const transitionId = ++this.latestSyncHostTransitionId; this.syncHostTransitionDepth += 1; + const work = this.syncHostTransitionTail.then( + () => this.performSyncHostSwitch(projectId, options, transitionId), + () => this.performSyncHostSwitch(projectId, options, transitionId), + ); + this.syncHostTransitionTail = work.then( + () => undefined, + () => undefined, + ); try { - const previousHostId = this.syncHostProjectId; - const deactivatePreviousHost = options.deactivatePreviousHost ?? true; - if (previousHostId && previousHostId !== projectId && deactivatePreviousHost) { + return await work; + } finally { + this.syncHostTransitionDepth = Math.max(0, this.syncHostTransitionDepth - 1); + } + } + + private async performSyncHostSwitch( + projectId: ProjectId, + options: SwitchSyncHostOptions, + transitionId: number, + ): Promise { + const previousHostId = this.syncHostProjectId; + const deactivatePreviousHost = options.deactivatePreviousHost ?? true; + + // Boot and initialize the target while the previous project remains the + // authoritative host. get() sees syncHostProjectId still pointing at the + // old host, so the new runtime starts with host startup/discovery disabled. + const scope = await this.get(projectId); + await scope.runtime.syncService?.initialize(); + + // A newer queued selection superseded this one while its cold runtime was + // booting. Keep the warm scope, but never flap the active listener/peers. + if (transitionId !== this.latestSyncHostTransitionId) return scope; + if (previousHostId === projectId) { + await this.configureSyncHost(scope, true, { initialize: false }); + return scope; + } + + let previousDeactivated = false; + try { + if (previousHostId && deactivatePreviousHost) { await this.configureCachedSyncHost(previousHostId, false); + previousDeactivated = true; } this.syncHostProjectId = projectId; - try { - const scope = await this.get(projectId); - await this.configureSyncHost(scope, true); - return scope; - } catch (error) { - // A failing get() already nulls syncHostProjectId, so check for both. - if (this.syncHostProjectId === projectId) { - this.syncHostProjectId = deactivatePreviousHost ? null : previousHostId; - } - if ( - this.syncHostProjectId == null - && deactivatePreviousHost - && previousHostId - && previousHostId !== projectId - ) { - // The previous host was already stopped. With a brain-level shared - // sync listener that leaves NO host owning the socket: reconnecting - // phones would park until the grace close (4002), forever, since - // nothing else restarts a host. Restore the known-good previous - // host before surfacing the failure. - try { - const previousScope = await this.get(previousHostId); - await this.configureSyncHost(previousScope, true); - this.syncHostProjectId = previousHostId; - } catch { - // Leave syncHostProjectId null; resolveActiveSyncHost() (e.g. the - // next prepareProjectConnection) is the remaining recovery path. - } + await this.configureSyncHost(scope, true, { initialize: false }); + return scope; + } catch (error) { + await this.configureSyncHost(scope, false).catch(() => {}); + this.syncHostProjectId = previousHostId; + if (previousHostId && previousDeactivated) { + try { + const previousScope = await this.get(previousHostId); + await this.configureSyncHost(previousScope, true); + } catch { + this.syncHostProjectId = null; } - throw error; } - } finally { - this.syncHostTransitionDepth = Math.max(0, this.syncHostTransitionDepth - 1); + throw error; } } @@ -279,12 +300,13 @@ export class ProjectScopeRegistry { private async configureSyncHost( scope: ProjectScope, enabled: boolean, + options: { initialize?: boolean } = {}, ): Promise { const syncService = scope.runtime.syncService; if (!syncService) return; syncService.setHostDiscoveryEnabled?.(enabled); await syncService.setHostStartupEnabled?.(enabled); - if (enabled) await syncService.initialize(); + if (enabled && options.initialize !== false) await syncService.initialize(); } private buildSyncRuntimeOptions(projectId: ProjectId, isHost: boolean): AdeRuntimeSyncOptions | null { diff --git a/apps/desktop/src/main/services/git/git.ts b/apps/desktop/src/main/services/git/git.ts index 7415dd1fe..db0580395 100644 --- a/apps/desktop/src/main/services/git/git.ts +++ b/apps/desktop/src/main/services/git/git.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; -import { execFileSync, spawn } from "node:child_process"; +import { execFile, spawn } from "node:child_process"; +import { promisify } from "node:util"; import type { ConflictFileType } from "../../../shared/types"; import { terminateProcessTree } from "../shared/processExecution"; import { @@ -13,6 +14,8 @@ import { // Silicon when shell PATH probe times out). Resolve git's absolute path once // and reuse it so spawn never throws ENOENT. let cachedGitExecutable: string | null = null; +let gitExecutableResolution: Promise | null = null; +const execFileAsync = promisify(execFile); export function selectGitExecutable( candidates: readonly ResolvedExecutable[], platform: NodeJS.Platform = process.platform, @@ -31,8 +34,18 @@ export function shouldProbeLoginShellForGit( || (platform === "darwin" && selectedExecutable === "/usr/bin/git"); } -function resolveGitExecutable(): string { +async function resolveGitExecutable(): Promise { if (cachedGitExecutable) return cachedGitExecutable; + if (gitExecutableResolution) return await gitExecutableResolution; + gitExecutableResolution = resolveGitExecutableUncached(); + try { + return await gitExecutableResolution; + } finally { + gitExecutableResolution = null; + } +} + +async function resolveGitExecutableUncached(): Promise { if (process.env.ADE_GIT_EXECUTABLE && fs.existsSync(process.env.ADE_GIT_EXECUTABLE)) { cachedGitExecutable = process.env.ADE_GIT_EXECUTABLE; return cachedGitExecutable; @@ -48,10 +61,11 @@ function resolveGitExecutable(): string { if (process.platform !== "win32" && shouldProbeLoginShellForGit(resolvedCandidate)) { try { const shell = process.env.SHELL?.trim() || "/bin/sh"; - const out = execFileSync(shell, ["-lc", "command -v git"], { + const { stdout } = await execFileAsync(shell, ["-lc", "command -v git"], { encoding: "utf8", timeout: 3_000, - }).trim(); + }); + const out = stdout.trim(); const isIndependentMacGit = process.platform !== "darwin" || out !== "/usr/bin/git"; if (out && fs.existsSync(out) && (isIndependentMacGit || !resolvedCandidate)) { cachedGitExecutable = out; @@ -84,12 +98,12 @@ function gitExecutableNotFoundMessage(executable: string): string { return `git executable not found (tried ${executable}). Install git or set ADE_GIT_EXECUTABLE to git's absolute path.`; } -function gitSpawnErrorMessage(error: NodeJS.ErrnoException, opts: GitRunOptions): string { +function gitSpawnErrorMessage(error: NodeJS.ErrnoException, opts: GitRunOptions, executable: string): string { if (error.code !== "ENOENT") return error.message; if (!fs.existsSync(opts.cwd)) { return `git working directory not found: ${opts.cwd}`; } - return gitExecutableNotFoundMessage(resolveGitExecutable()); + return gitExecutableNotFoundMessage(executable); } export type GitRunOptions = { @@ -136,22 +150,21 @@ function extractIndexLockPath(message: string): string | null { return doubleQuoteMatch?.[1] ?? null; } -function isIndexLockHeldByProcess(lockPath: string): boolean { +async function isIndexLockHeldByProcess(lockPath: string): Promise { if (activeGitPids.size > 0) return true; if (process.platform === "win32") return false; try { - const out = execFileSync("lsof", [lockPath], { + const { stdout } = await execFileAsync("lsof", [lockPath], { encoding: "utf8", timeout: 2_000, - stdio: ["ignore", "pipe", "ignore"], }); - return out.trim().split(/\r?\n/).length > 1; + return stdout.trim().split(/\r?\n/).length > 1; } catch { return false; } } -function recoverStaleIndexLock(lockPath: string): boolean { +async function recoverStaleIndexLock(lockPath: string): Promise { try { const normalizedPath = path.normalize(lockPath); if (path.basename(normalizedPath) !== "index.lock") return false; @@ -160,7 +173,7 @@ function recoverStaleIndexLock(lockPath: string): boolean { const stat = fs.statSync(lockPath); if (!stat.isFile()) return false; if (Date.now() - stat.mtimeMs < STALE_GIT_INDEX_LOCK_MIN_AGE_MS) return false; - if (isIndexLockHeldByProcess(lockPath)) return false; + if (await isIndexLockHeldByProcess(lockPath)) return false; fs.renameSync(lockPath, `${lockPath}.stale-${Date.now()}`); return true; } catch (error) { @@ -171,13 +184,13 @@ function recoverStaleIndexLock(lockPath: string): boolean { } } -function shouldRetryAfterIndexLock(result: GitRunResult): boolean { +async function shouldRetryAfterIndexLock(result: GitRunResult): Promise { if (result.exitCode === 0) return false; const message = `${result.stderr}\n${result.stdout}`; const lockPath = extractIndexLockPath(message); if (!lockPath) return false; if (!message.includes("Another git process seems to be running")) return false; - return recoverStaleIndexLock(lockPath); + return await recoverStaleIndexLock(lockPath); } function appendChunkWithCap(args: { @@ -211,8 +224,9 @@ async function runGitOnce(args: string[], opts: GitRunOptions): Promise((resolve) => { - const child = spawn(resolveGitExecutable(), args, { + const child = spawn(executable, args, { cwd: opts.cwd, env: { ...process.env, ...(opts.env ?? {}) }, stdio: ["ignore", "pipe", "pipe"] @@ -279,7 +293,7 @@ async function runGitOnce(args: string[], opts: GitRunOptions): Promise { - const friendlyMessage = gitSpawnErrorMessage(error as NodeJS.ErrnoException, opts); + const friendlyMessage = gitSpawnErrorMessage(error as NodeJS.ErrnoException, opts, executable); finish({ exitCode: 1, stdout, @@ -303,7 +317,7 @@ async function runGitOnce(args: string[], opts: GitRunOptions): Promise { const first = await runGitOnce(args, opts); - if (!shouldRetryAfterIndexLock(first)) { + if (!(await shouldRetryAfterIndexLock(first))) { return first; } return await runGitOnce(args, opts); diff --git a/apps/desktop/src/main/services/github/githubService.test.ts b/apps/desktop/src/main/services/github/githubService.test.ts index 7450bc6e4..bfb9c9fbf 100644 --- a/apps/desktop/src/main/services/github/githubService.test.ts +++ b/apps/desktop/src/main/services/github/githubService.test.ts @@ -1,4 +1,5 @@ import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; // --------------------------------------------------------------------------- // vi.hoisted mock state @@ -70,6 +71,7 @@ function resetMocks() { runGitMock.mockReset(); delete process.env.GH_TOKEN; delete process.env.GITHUB_TOKEN; + delete process.env.GH_CONFIG_DIR; delete process.env.ADE_GITHUB_TOKEN; delete process.env.ADE_GITHUB_RELAY_API_BASE_URL; delete process.env.ADE_GITHUB_RELAY_ACCESS_TOKEN; @@ -115,7 +117,9 @@ class MemoryCredentialStore { function makeService(options: { credentialStore?: MemoryCredentialStore; - ghAuthTokenProvider?: () => { token: string | null; ghCliPath: string | null; ghAuthError: string | null }; + ghAuthTokenProvider?: () => + | { token: string | null; ghCliPath: string | null; ghAuthError: string | null } + | Promise<{ token: string | null; ghCliPath: string | null; ghAuthError: string | null }>; githubRelaySecretReader?: (ref: string) => string | null; getAccountAccessToken?: () => Promise; } = {}) { @@ -703,6 +707,68 @@ describe("githubService.getStatus", () => { expect((init.headers as Record).authorization).toBe("Bearer gho_cli_token"); }); + it("reads a cached hosts.yml token synchronously before async status warmup", () => { + delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; + process.env.GH_CONFIG_DIR = "/tmp/gh-fresh-sync-token"; + vi.mocked(fs.readFileSync).mockImplementationOnce(((filePath: fs.PathOrFileDescriptor) => { + if (String(filePath).endsWith("hosts.yml")) { + return "github.com:\n user: alice\n oauth_token: gho_hosts_fresh\n"; + } + return Buffer.from("encrypted"); + }) as typeof fs.readFileSync); + + expect(makeService().getTokenOrThrow()).toBe("gho_hosts_fresh"); + expect(makeService().getTokenOrThrow()).toBe("gho_hosts_fresh"); + expect(fs.readFileSync).toHaveBeenCalledTimes(1); + delete process.env.GH_CONFIG_DIR; + }); + + it("coalesces slow gh auth and failed status probes across project services", async () => { + stubOriginRemote(); + delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; + const baseNow = Date.now(); + const now = vi.spyOn(Date, "now").mockReturnValue(baseNow); + let resolveAuth!: (value: { token: string; ghCliPath: string; ghAuthError: null }) => void; + const ghAuthTokenProvider = vi.fn(() => new Promise<{ + token: string; + ghCliPath: string; + ghAuthError: null; + }>((resolve) => { + resolveAuth = resolve; + })); + mockFetch.mockImplementation(async (input: string | URL) => { + if (String(input).endsWith("/user")) { + return jsonResponse(200, { login: "alice" }); + } + const timeout = new Error("request timed out"); + timeout.name = "AbortError"; + throw timeout; + }); + const first = makeService({ ghAuthTokenProvider }); + const second = makeService({ ghAuthTokenProvider }); + + const firstStatus = first.getStatus(); + const secondStatus = second.getStatus(); + await Promise.resolve(); + expect(ghAuthTokenProvider).toHaveBeenCalledTimes(1); + + resolveAuth({ + token: "github_pat_shared_slow_token", + ghCliPath: "/opt/homebrew/bin/gh", + ghAuthError: null, + }); + const statuses = await Promise.all([firstStatus, secondStatus]); + expect(statuses.map((status) => status.repoAccessOk)).toEqual([false, false]); + expect(mockFetch).toHaveBeenCalledTimes(2); // one /user + one repo probe total + + now.mockReturnValue(baseNow + 31_000); + const third = await makeService({ ghAuthTokenProvider }).getStatus(); + expect(third.repoAccessOk).toBe(false); + expect(ghAuthTokenProvider).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledTimes(2); + now.mockRestore(); + }); + it("clearing a stored PAT falls back to gh auth", async () => { stubOriginRemote(); delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; diff --git a/apps/desktop/src/main/services/github/githubService.ts b/apps/desktop/src/main/services/github/githubService.ts index 9e551e76b..41189cfea 100644 --- a/apps/desktop/src/main/services/github/githubService.ts +++ b/apps/desktop/src/main/services/github/githubService.ts @@ -1,7 +1,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { spawnSync } from "node:child_process"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { promisify } from "node:util"; import { safeStorage } from "electron"; import type { Logger } from "../logging/logger"; import { runGit } from "../git/git"; @@ -27,6 +29,9 @@ const AUTH_STORE_FILE_NAME = "github-token.v1.bin"; const MACHINE_TOKEN_KEY = "github.token.v1"; const GITHUB_API_TIMEOUT_MS = 20_000; const GH_AUTH_TOKEN_CACHE_TTL_MS = 30_000; +const GITHUB_STATUS_FAILURE_COOLDOWN_MS = 2 * 60_000; +const execFileAsync = promisify(execFile); +const processGhHostsTokenCache = new Map(); type GitHubAuthSource = GitHubStatus["authSource"]; @@ -36,6 +41,45 @@ type GitHubCliAuthResult = { ghAuthError: string | null; }; +type GitHubCliAuthProvider = () => GitHubCliAuthResult | Promise; + +type SharedGithubStatusProbe = { + validated: { userLogin: string | null; scopes: string[]; tokenType: GitHubStatus["tokenType"] }; + repoAccessOk: boolean | null; + repoAccessError: string | null; +}; + +type SharedGithubStatusProbeResult = + | { ok: true; value: SharedGithubStatusProbe } + | { ok: false; error: string }; + +type ProcessGithubAuthState = { + authCache: (GitHubCliAuthResult & { expiresAt: number }) | null; + authInFlight: Promise | null; + statusCache: Map; + statusInFlight: Map>; +}; + +const processGithubAuthStates = new WeakMap(); + +function processGithubAuthState(provider: GitHubCliAuthProvider): ProcessGithubAuthState { + const existing = processGithubAuthStates.get(provider); + if (existing) return existing; + const created: ProcessGithubAuthState = { + authCache: null, + authInFlight: null, + statusCache: new Map(), + statusInFlight: new Map(), + }; + processGithubAuthStates.set(provider, created); + return created; +} + +function githubStatusProbeKey(token: string, repo: GitHubRepoRef | null): string { + const tokenDigest = createHash("sha256").update(token).digest("hex").slice(0, 16); + return `${tokenDigest}:${repo ? `${repo.owner.toLowerCase()}/${repo.name.toLowerCase()}` : "no-repo"}`; +} + type GitHubTokenLookup = GitHubCliAuthResult & { source: GitHubAuthSource; patTokenStored: boolean; @@ -51,6 +95,8 @@ function readGhHostsFileToken(env: NodeJS.ProcessEnv = process.env): string | nu const configDir = env.GH_CONFIG_DIR?.trim() || path.join(env.XDG_CONFIG_HOME?.trim() || path.join(os.homedir(), ".config"), "gh"); const hostsPath = path.join(configDir, "hosts.yml"); + const cached = processGhHostsTokenCache.get(hostsPath); + if (cached && cached.expiresAt > Date.now()) return cached.token; try { const raw = fs.readFileSync(hostsPath, "utf8"); const lines = raw.split(/\r?\n/); @@ -64,26 +110,37 @@ function readGhHostsFileToken(env: NodeJS.ProcessEnv = process.env): string | nu const match = line.match(/^\s+oauth_token\s*:\s*(\S+)\s*$/); if (match) { const token = match[1].replace(/^["']|["']$/g, "").trim(); - if (token) return token; + if (token) { + processGhHostsTokenCache.set(hostsPath, { + expiresAt: Date.now() + GH_AUTH_TOKEN_CACHE_TTL_MS, + token, + }); + return token; + } } } } catch { // No hosts.yml or unreadable — fall through. } + processGhHostsTokenCache.set(hostsPath, { + expiresAt: Date.now() + GH_AUTH_TOKEN_CACHE_TTL_MS, + token: null, + }); return null; } -function readGitHubCliAuthToken(): GitHubCliAuthResult { +async function readGitHubCliAuthToken(): Promise { if (process.env.ADE_DISABLE_GH_AUTH_FALLBACK === "1") { return { token: null, ghCliPath: null, ghAuthError: null }; } + const hostsToken = readGhHostsFileToken(); + if (hostsToken) { + return { token: hostsToken, ghCliPath: null, ghAuthError: null }; + } + const resolved = resolveExecutableFromKnownLocations("gh"); if (!resolved?.path) { - const hostsToken = readGhHostsFileToken(); - if (hostsToken) { - return { token: hostsToken, ghCliPath: null, ghAuthError: null }; - } return { token: null, ghCliPath: null, @@ -92,29 +149,28 @@ function readGitHubCliAuthToken(): GitHubCliAuthResult { } try { - const result = spawnSync(resolved.path, ["auth", "token"], { + const { stdout } = await execFileAsync(resolved.path, ["auth", "token"], { encoding: "utf8", timeout: 5_000, windowsHide: true, + maxBuffer: 256 * 1024, env: { ...process.env, PATH: mergePathEntries(process.env.PATH, path.dirname(resolved.path)), }, }); - const token = typeof result.stdout === "string" ? result.stdout.trim() : ""; - if (result.status === 0 && token.length > 0) { + const token = stdout.trim(); + if (token.length > 0) { return { token, ghCliPath: resolved.path, ghAuthError: null }; } const hostsToken = readGhHostsFileToken(); if (hostsToken) { return { token: hostsToken, ghCliPath: resolved.path, ghAuthError: null }; } - const stderr = typeof result.stderr === "string" ? result.stderr.trim() : ""; - const message = result.error instanceof Error ? result.error.message : stderr; return { token: null, ghCliPath: resolved.path, - ghAuthError: message || "GitHub CLI is installed, but `gh auth token` did not return a token.", + ghAuthError: "GitHub CLI is installed, but `gh auth token` did not return a token.", }; } catch (error) { const hostsToken = readGhHostsFileToken(); @@ -352,7 +408,7 @@ export function createGithubService({ projectRoot: string; appDataDir: string; credentialStore?: SyncCredentialStore | null; - ghAuthTokenProvider?: (() => GitHubCliAuthResult) | null; + ghAuthTokenProvider?: GitHubCliAuthProvider | null; githubRelaySecretReader?: GitHubRelaySecretReader | null; getAccountAccessToken?: (() => Promise) | null; }) { @@ -369,7 +425,9 @@ export function createGithubService({ let tokenDecryptionFailed = false; let machineTokenReadFailed = false; - let ghAuthTokenCache: (GitHubCliAuthResult & { expiresAt: number }) | null = null; + const ghAuthProvider = ghAuthTokenProvider ?? readGitHubCliAuthToken; + const sharedGhAuth = processGithubAuthState(ghAuthProvider); + let statusInFlight: Promise | null = null; const readMachineToken = (): string | null => { if (!credentialStore) return null; @@ -523,25 +581,32 @@ export function createGithubService({ return envToken.length > 0 ? envToken : null; }; - const readGhAuthToken = (): GitHubCliAuthResult => { + const readGhAuthToken = async (): Promise => { if (process.env.ADE_DISABLE_GH_AUTH_FALLBACK === "1") { - ghAuthTokenCache = null; + sharedGhAuth.authCache = null; return { token: null, ghCliPath: null, ghAuthError: null }; } const now = Date.now(); - if (ghAuthTokenCache && ghAuthTokenCache.expiresAt > now) { + if (sharedGhAuth.authCache && sharedGhAuth.authCache.expiresAt > now) { return { - token: ghAuthTokenCache.token, - ghCliPath: ghAuthTokenCache.ghCliPath, - ghAuthError: ghAuthTokenCache.ghAuthError, + token: sharedGhAuth.authCache.token, + ghCliPath: sharedGhAuth.authCache.ghCliPath, + ghAuthError: sharedGhAuth.authCache.ghAuthError, }; } - const result = (ghAuthTokenProvider ?? readGitHubCliAuthToken)(); - ghAuthTokenCache = { ...result, expiresAt: now + GH_AUTH_TOKEN_CACHE_TTL_MS }; - return result; + if (sharedGhAuth.authInFlight) return await sharedGhAuth.authInFlight; + const work = Promise.resolve().then(() => ghAuthProvider()); + sharedGhAuth.authInFlight = work; + try { + const result = await work; + sharedGhAuth.authCache = { ...result, expiresAt: Date.now() + GH_AUTH_TOKEN_CACHE_TTL_MS }; + return result; + } finally { + if (sharedGhAuth.authInFlight === work) sharedGhAuth.authInFlight = null; + } }; - const readAuthToken = (): GitHubTokenLookup => { + const readPrimaryAuthToken = (): GitHubTokenLookup | null => { const patToken = readStoredPatToken(); if (patToken) { return { @@ -564,7 +629,32 @@ export function createGithubService({ }; } - const gh = readGhAuthToken(); + return null; + }; + + const readAuthToken = async (): Promise => { + const primary = readPrimaryAuthToken(); + if (primary) return primary; + const gh = await readGhAuthToken(); + return { + ...gh, + source: gh.token ? "gh" : "none", + patTokenStored: false, + }; + }; + + const readAuthTokenSync = (): GitHubTokenLookup => { + const primary = readPrimaryAuthToken(); + if (primary) return primary; + const cachedGh = sharedGhAuth.authCache && sharedGhAuth.authCache.expiresAt > Date.now() + ? sharedGhAuth.authCache + : null; + const hostsToken = cachedGh?.token ? null : readGhHostsFileToken(); + const gh: GitHubCliAuthResult = cachedGh ?? { + token: hostsToken, + ghCliPath: null, + ghAuthError: hostsToken ? null : "GitHub auth has not been resolved yet.", + }; return { ...gh, source: gh.token ? "gh" : "none", @@ -670,6 +760,76 @@ export function createGithubService({ } }; + const computeGithubStatusProbe = async ( + token: string, + repo: GitHubRepoRef | null, + ): Promise => { + try { + const validated = await validateToken(token); + let repoAccessOk: boolean | null = null; + let repoAccessError: string | null = null; + if (repo && validated.tokenType === "fine-grained") { + const probe = await probeRepoAccess(token, repo); + repoAccessOk = probe.ok; + repoAccessError = probe.error; + } + return { ok: true, value: { validated, repoAccessOk, repoAccessError } }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) }; + } + }; + + const readSharedGithubStatusProbe = async ( + token: string, + repo: GitHubRepoRef | null, + forceRefresh: boolean, + ): Promise => { + const key = githubStatusProbeKey(token, repo); + if (forceRefresh) { + const existing = sharedGhAuth.statusInFlight.get(key); + if (existing) await existing.catch(() => {}); + const newer = sharedGhAuth.statusInFlight.get(key); + if (newer && newer !== existing) return await newer; + sharedGhAuth.statusCache.delete(key); + } else { + const cached = sharedGhAuth.statusCache.get(key); + if (cached && cached.expiresAt > Date.now()) return cached.result; + const inFlight = sharedGhAuth.statusInFlight.get(key); + if (inFlight) return await inFlight; + } + + const work = computeGithubStatusProbe(token, repo); + sharedGhAuth.statusInFlight.set(key, work); + try { + const result = await work; + const isNetworkFailure = !result.ok + || (result.value.repoAccessOk === false + && /timed out|network|fetch failed/i.test(result.value.repoAccessError ?? "")); + sharedGhAuth.statusCache.set(key, { + expiresAt: Date.now() + (isNetworkFailure + ? GITHUB_STATUS_FAILURE_COOLDOWN_MS + : GH_AUTH_TOKEN_CACHE_TTL_MS), + result, + }); + if (isNetworkFailure && sharedGhAuth.authCache?.token === token) { + sharedGhAuth.authCache.expiresAt = Math.max( + sharedGhAuth.authCache.expiresAt, + Date.now() + GITHUB_STATUS_FAILURE_COOLDOWN_MS, + ); + } + while (sharedGhAuth.statusCache.size > 32) { + const oldest = sharedGhAuth.statusCache.keys().next().value as string | undefined; + if (!oldest) break; + sharedGhAuth.statusCache.delete(oldest); + } + return result; + } finally { + if (sharedGhAuth.statusInFlight.get(key) === work) { + sharedGhAuth.statusInFlight.delete(key); + } + } + }; + // ETag cache for conditional GET requests. Responses that return 304 Not Modified // don't count against GitHub's rate limit, so this dramatically reduces API usage. const etagCache = new Map(); @@ -697,7 +857,7 @@ export function createGithubService({ */ accept?: string; }): Promise<{ data: T; response: Response | null; linkHeader?: string | null }> => { - const token = (args.token ?? readAuthToken().token ?? "").trim(); + const token = (args.token ?? (await readAuthToken()).token ?? "").trim(); if (!token) { throw new Error("GitHub auth missing. Run `gh auth login -h github.com -s repo -s workflow` or add a personal access token in Settings."); } @@ -852,13 +1012,14 @@ export function createGithubService({ return Boolean(args.userLogin); }; - const getStatus = async (opts: { forceRefresh?: boolean } = {}): Promise => { + const computeStatus = async (opts: { forceRefresh?: boolean } = {}): Promise => { if (opts.forceRefresh) { cachedStatus = null; cachedAt = 0; - ghAuthTokenCache = null; + sharedGhAuth.authCache = null; + sharedGhAuth.statusCache.clear(); } - const tokenLookup = readAuthToken(); + const tokenLookup = await readAuthToken(); const token = tokenLookup.token; const { repo, hasOrigin } = await detectOrigin().catch(() => ({ repo: null, hasOrigin: false })); if (!token) { @@ -922,22 +1083,19 @@ export function createGithubService({ } try { - const validated = await validateToken(token); - let repoAccessOk: boolean | null = null; - let repoAccessError: string | null = null; + const statusProbe = tokenLookup.source === "gh" + ? await readSharedGithubStatusProbe(token, repo, opts.forceRefresh === true) + : await computeGithubStatusProbe(token, repo); + if (!statusProbe.ok) throw new Error(statusProbe.error); + const { validated, repoAccessOk, repoAccessError } = statusProbe.value; // Classic PATs and gh OAuth tokens expose scopes in the /user response. // Fine-grained tokens do not expose selected repos and need a repo probe. - if (repo && validated.tokenType === "fine-grained") { - const probe = await probeRepoAccess(token, repo); - repoAccessOk = probe.ok; - repoAccessError = probe.error; - if (!probe.ok) { + if (repo && validated.tokenType === "fine-grained" && repoAccessOk === false) { logger.warn("github.repo_probe_failed", { repo: `${repo.owner}/${repo.name}`, tokenType: validated.tokenType, - error: probe.error, + error: repoAccessError, }); - } } const connected = computeConnected({ tokenStored: true, @@ -992,6 +1150,20 @@ export function createGithubService({ } }; + const getStatus = async (opts: { forceRefresh?: boolean } = {}): Promise => { + if (statusInFlight) { + if (!opts.forceRefresh) return await statusInFlight; + await statusInFlight.catch(() => {}); + } + const work = computeStatus(opts); + statusInFlight = work; + try { + return await work; + } finally { + if (statusInFlight === work) statusInFlight = null; + } + }; + const listRepoLabels = async (owner: string, name: string): Promise => { return await apiRequestAllPages({ path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/labels`, @@ -1254,7 +1426,7 @@ export function createGithubService({ // `/user/repos`. The renderer now populates `owner` from the connected // login, so detect that case and avoid the org route for personal publishes. const authenticatedLogin = owner - ? ((await validateToken(readAuthToken().token ?? "").catch(() => ({ userLogin: null as string | null }))).userLogin?.trim() || null) + ? ((await validateToken((await readAuthToken()).token ?? "").catch(() => ({ userLogin: null as string | null }))).userLogin?.trim() || null) : null; // Only take the org route when we POSITIVELY resolved the authenticated // login and it differs from `owner`. If token validation failed (transient @@ -1313,7 +1485,7 @@ export function createGithubService({ const publishCurrentProject = async ( args: { owner?: string; name: string; description?: string; isPrivate: boolean }, ): Promise<{ state: "pushed" | "remote_added"; owner: string; name: string; fullName: string; htmlUrl: string }> => { - const token = readAuthToken().token; + const token = (await readAuthToken()).token; if (!token) { const err = new Error("GitHub is not connected. Run `gh auth login -h github.com -s repo -s workflow` or add a personal access token in Settings.") as Error & { code?: string }; err.code = "github_not_connected"; @@ -1443,7 +1615,8 @@ export function createGithubService({ tokenDecryptionFailed = false; cachedStatus = null; cachedAt = 0; - ghAuthTokenCache = null; + sharedGhAuth.authCache = null; + sharedGhAuth.statusCache.clear(); }, clearToken(): void { @@ -1451,7 +1624,8 @@ export function createGithubService({ tokenDecryptionFailed = false; cachedStatus = null; cachedAt = 0; - ghAuthTokenCache = null; + sharedGhAuth.authCache = null; + sharedGhAuth.statusCache.clear(); }, async getRepoOrThrow(): Promise { @@ -1461,7 +1635,7 @@ export function createGithubService({ }, getTokenOrThrow(): string { - const token = readAuthToken().token; + const token = readAuthTokenSync().token; if (!token) throw new Error("GitHub auth missing. Run `gh auth login -h github.com -s repo -s workflow` or add a personal access token in Settings."); return token; }, diff --git a/apps/desktop/src/main/services/ipc/ipcTimeouts.test.ts b/apps/desktop/src/main/services/ipc/ipcTimeouts.test.ts index 3caeab9ab..3aaf11601 100644 --- a/apps/desktop/src/main/services/ipc/ipcTimeouts.test.ts +++ b/apps/desktop/src/main/services/ipc/ipcTimeouts.test.ts @@ -14,6 +14,13 @@ describe("ipcInvokeTimeoutMs", () => { }])).toBe(4 * 60_000); }); + it("uses a bounded archive budget on direct and runtime-backed paths", () => { + expect(ipcInvokeTimeoutMs(IPC.lanesArchive)).toBe(4 * 60_000); + expect(ipcInvokeTimeoutMs(IPC.localRuntimeCallAction, [{ + request: { domain: "lane", action: "archive", args: { laneId: "lane-1" } }, + }])).toBe(4 * 60_000); + }); + it("gives ordinary local runtime calls enough time to bind a cold project", () => { expect(ipcInvokeTimeoutMs(IPC.localRuntimeCallAction, [{ request: { domain: "lane", action: "list" }, @@ -22,6 +29,7 @@ describe("ipcInvokeTimeoutMs", () => { expect(ipcInvokeTimeoutMs(IPC.localRuntimeCallSync)).toBe(150_000); expect(ipcInvokeTimeoutMs(IPC.localRuntimeListActionRegistry)).toBe(150_000); expect(ipcInvokeTimeoutMs(IPC.localRuntimeStreamEvents)).toBe(150_000); + expect(ipcInvokeTimeoutMs(IPC.projectSwitchToPath)).toBe(150_000); }); it("gives retryable remote runtime actions enough time to reconnect", () => { diff --git a/apps/desktop/src/main/services/ipc/ipcTimeouts.ts b/apps/desktop/src/main/services/ipc/ipcTimeouts.ts index 7e7588389..a83b7836e 100644 --- a/apps/desktop/src/main/services/ipc/ipcTimeouts.ts +++ b/apps/desktop/src/main/services/ipc/ipcTimeouts.ts @@ -11,6 +11,7 @@ const RUNTIME_ACTION_CHANNEL: Record> = { createChild: IPC.lanesCreateChild, createFromUnstaged: IPC.lanesCreateFromUnstaged, importBranch: IPC.lanesImportBranch, + archive: IPC.lanesArchive, delete: IPC.lanesDelete, }, ios_simulator: { @@ -64,6 +65,11 @@ export function ipcInvokeTimeoutMs(channel: string, args: readonly unknown[] = [ return 30_000; } switch (channel) { + // Switching projects can cold-start and bind the local runtime. Keep the + // renderer's outcome known until the same setup budget used by local + // runtime calls expires. + case IPC.projectSwitchToPath: + return LOCAL_RUNTIME_PROJECT_SETUP_TIMEOUT_MS; case IPC.remoteRuntimeConnect: case IPC.remoteRuntimeListProjects: case IPC.remoteRuntimeAddProject: @@ -84,6 +90,7 @@ export function ipcInvokeTimeoutMs(channel: string, args: readonly unknown[] = [ case IPC.lanesCreateChild: case IPC.lanesCreateFromUnstaged: case IPC.lanesImportBranch: + case IPC.lanesArchive: case IPC.lanesDelete: return 4 * 60_000; // Handoff runs an AI brief + session creation + first-message dispatch diff --git a/apps/desktop/src/main/services/lanes/laneListSnapshotService.test.ts b/apps/desktop/src/main/services/lanes/laneListSnapshotService.test.ts index 244dac434..307e95292 100644 --- a/apps/desktop/src/main/services/lanes/laneListSnapshotService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneListSnapshotService.test.ts @@ -151,4 +151,134 @@ describe("laneListSnapshotService", () => { sessionCount: 1, }); }); + + it("returns core lane rows when optional rebase enrichment exceeds its budget", async () => { + vi.useFakeTimers(); + try { + const services = { + ...makeHarness({ + id: "session-1", + laneId: "lane-1", + status: "running", + runtimeState: "running", + toolType: "shell", + lastOutputPreview: "working", + }), + rebaseSuggestionService: { + listSuggestions: vi.fn(() => new Promise(() => {})), + }, + }; + + const pending = buildLaneListSnapshots( + services as any, + [{ id: "lane-1", name: "Lane 1", laneType: "worktree", archivedAt: null }] as any, + { + includeConflictStatus: false, + includeRebaseSuggestions: true, + includeAutoRebaseStatus: false, + optionalEnrichmentBudgetMs: 25, + }, + ); + await vi.advanceTimersByTimeAsync(25); + + await expect(pending).resolves.toEqual([ + expect.objectContaining({ + lane: expect.objectContaining({ id: "lane-1" }), + runtime: expect.objectContaining({ bucket: "running", sessionCount: 1 }), + rebaseSuggestion: null, + }), + ]); + expect(services.rebaseSuggestionService.listSuggestions).toHaveBeenCalledTimes(1); + expect(services.logger.info).toHaveBeenCalledWith( + "lanes.listSnapshots.optional_enrichment_deferred", + expect.objectContaining({ budgetMs: 25, laneCount: 1 }), + ); + } finally { + vi.useRealTimers(); + } + }); + + it("preserves last-known decorations while a newer enrichment is still pending", async () => { + vi.useFakeTimers(); + try { + let resolveRefresh!: (value: any[]) => void; + const rebaseSuggestions = vi.fn() + .mockResolvedValueOnce([{ laneId: "lane-1", behindCount: 1 }]) + .mockImplementationOnce(() => new Promise((resolve) => { + resolveRefresh = resolve; + })) + .mockImplementation(() => new Promise(() => {})); + const services = { + ...makeHarness({ + id: "session-1", + laneId: "lane-1", + status: "running", + runtimeState: "running", + toolType: "shell", + lastOutputPreview: "working", + }), + rebaseSuggestionService: { listSuggestions: rebaseSuggestions }, + }; + const lanes = [{ id: "lane-1", name: "Lane 1", laneType: "worktree", archivedAt: null }] as any; + const options = { + includeConflictStatus: false, + includeRebaseSuggestions: true, + includeAutoRebaseStatus: false, + optionalEnrichmentBudgetMs: 25, + }; + + const first = await buildLaneListSnapshots(services as any, lanes, options); + expect(first[0]?.rebaseSuggestion).toEqual(expect.objectContaining({ behindCount: 1 })); + + const secondPending = buildLaneListSnapshots(services as any, lanes, options); + await vi.advanceTimersByTimeAsync(25); + const second = await secondPending; + expect(second[0]?.rebaseSuggestion).toEqual(expect.objectContaining({ behindCount: 1 })); + + resolveRefresh([{ laneId: "lane-1", behindCount: 2 }]); + await Promise.resolve(); + const thirdPending = buildLaneListSnapshots(services as any, lanes, options); + await vi.advanceTimersByTimeAsync(25); + const third = await thirdPending; + expect(third[0]?.rebaseSuggestion).toEqual(expect.objectContaining({ behindCount: 2 })); + } finally { + vi.useRealTimers(); + } + }); + + it("coalesces repeated snapshot calls onto one optional enrichment per lane set", async () => { + vi.useFakeTimers(); + try { + const never = () => new Promise(() => {}); + const services = { + ...makeHarness({ + id: "session-1", + laneId: "lane-1", + status: "running", + runtimeState: "running", + toolType: "shell", + lastOutputPreview: "working", + }), + rebaseSuggestionService: { listSuggestions: vi.fn(never) }, + autoRebaseService: { listStatuses: vi.fn(never) }, + conflictService: { getBatchAssessment: vi.fn(never) }, + }; + const lanes = [{ id: "lane-1", name: "Lane 1", laneType: "worktree", archivedAt: null }] as any; + const options = { optionalEnrichmentBudgetMs: 25 }; + + const first = buildLaneListSnapshots(services as any, lanes, options); + const second = buildLaneListSnapshots(services as any, lanes, options); + const otherLaneSet = buildLaneListSnapshots(services as any, [ + { id: "lane-2", name: "Lane 2", laneType: "worktree", archivedAt: null }, + ] as any, options); + await vi.advanceTimersByTimeAsync(25); + await Promise.all([first, second, otherLaneSet]); + + expect(services.rebaseSuggestionService.listSuggestions).toHaveBeenCalledTimes(2); + expect(services.autoRebaseService.listStatuses).toHaveBeenCalledTimes(2); + expect(services.conflictService.getBatchAssessment).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/apps/desktop/src/main/services/lanes/laneListSnapshotService.ts b/apps/desktop/src/main/services/lanes/laneListSnapshotService.ts index 441ad3270..d72fde2f5 100644 --- a/apps/desktop/src/main/services/lanes/laneListSnapshotService.ts +++ b/apps/desktop/src/main/services/lanes/laneListSnapshotService.ts @@ -52,12 +52,55 @@ type LaneListSnapshotServices = { logger: Pick; }; +type OptionalLaneListEnrichment = [ + Array>, + Array>, + { lanes?: Array> } | null, +]; + +type OptionalLaneListEnrichmentCacheEntry = { + last: OptionalLaneListEnrichment; + inFlight: Promise | null; +}; + +const OPTIONAL_LANE_ENRICHMENT_CACHE_MAX_ENTRIES = 8; +const OPTIONAL_LANE_ENRICHMENT_RETRY_AFTER_MS = 2 * 60_000; +const optionalEnrichmentByLaneService = new WeakMap< + object, + Map +>(); + +function optionalLaneEnrichmentKey( + lanes: LaneSummary[], + options: LaneListSnapshotOptions, +): string { + return JSON.stringify({ + lanes: lanes + .map((lane) => ({ + id: lane.id, + parentLaneId: lane.parentLaneId, + branchRef: lane.branchRef, + baseRef: lane.baseRef, + worktreePath: lane.worktreePath, + archivedAt: lane.archivedAt, + })) + .sort((left, right) => left.id.localeCompare(right.id)), + conflict: options.includeConflictStatus !== false, + rebase: options.includeRebaseSuggestions !== false, + autoRebase: options.includeAutoRebaseStatus !== false, + }); +} + export type LaneListSnapshotOptions = { includeConflictStatus?: boolean; includeRebaseSuggestions?: boolean; includeAutoRebaseStatus?: boolean; + /** Test/diagnostic override; optional decorations must never gate core rows. */ + optionalEnrichmentBudgetMs?: number; }; +export const OPTIONAL_LANE_ENRICHMENT_BUDGET_MS = 250; + function isChatToolType(toolType: string | null | undefined): boolean { if (!toolType) return false; const t = toolType.trim().toLowerCase(); @@ -242,31 +285,96 @@ export async function buildLaneListSnapshots( } }; - const [sessions, rebaseSuggestions, autoRebaseStatuses, stateSnapshots, batchAssessment] = await Promise.all([ + let optionalCache = optionalEnrichmentByLaneService.get(args.laneService); + if (!optionalCache) { + optionalCache = new Map(); + optionalEnrichmentByLaneService.set(args.laneService, optionalCache); + } + const optionalCacheKey = optionalLaneEnrichmentKey(lanes, options); + let optionalEntry = optionalCache.get(optionalCacheKey); + if (!optionalEntry) { + optionalEntry = { last: [[], [], null], inFlight: null }; + optionalCache.set(optionalCacheKey, optionalEntry); + while (optionalCache.size > OPTIONAL_LANE_ENRICHMENT_CACHE_MAX_ENTRIES) { + const oldestKey = optionalCache.keys().next().value as string | undefined; + if (!oldestKey) break; + optionalCache.delete(oldestKey); + } + } + const previousOptionalEnrichment = optionalEntry.last; + if (!optionalEntry.inFlight) { + const work: Promise = Promise.all([ + options.includeRebaseSuggestions === false + ? Promise.resolve([]) + : timePhase("rebase_suggestions", () => + Promise.resolve() + .then(() => args.rebaseSuggestionService?.listSuggestions({ lanes }) ?? []) + .catch(() => previousOptionalEnrichment[0])), + options.includeAutoRebaseStatus === false + ? Promise.resolve([]) + : timePhase("auto_rebase_statuses", () => + Promise.resolve() + .then(() => args.autoRebaseService?.listStatuses({ lanes }) ?? []) + .catch(() => previousOptionalEnrichment[1])), + options.includeConflictStatus === false + ? Promise.resolve(null) + : timePhase("conflict_assessment", () => + Promise.resolve() + .then(() => args.conflictService?.getBatchAssessment({ lanes }) ?? null) + .catch(() => previousOptionalEnrichment[2])), + ]); + optionalEntry.inFlight = work; + const retryTimer = setTimeout(() => { + if (optionalEntry?.inFlight === work) optionalEntry.inFlight = null; + }, OPTIONAL_LANE_ENRICHMENT_RETRY_AFTER_MS); + retryTimer.unref?.(); + void work.then((result) => { + // A watchdog may release a genuinely hung probe so a newer scan can + // start. Never let that older probe overwrite newer last-known data if + // it eventually settles out of order. + if (optionalEntry?.inFlight === work) optionalEntry.last = result; + }).finally(() => { + clearTimeout(retryTimer); + if (optionalEntry?.inFlight === work) optionalEntry.inFlight = null; + }); + } + const optionalEnrichment = optionalEntry.inFlight ?? Promise.resolve(optionalEntry.last); + const optionalBudgetMs = Math.max( + 0, + Math.floor(options.optionalEnrichmentBudgetMs ?? OPTIONAL_LANE_ENRICHMENT_BUDGET_MS), + ); + let optionalBudgetTimer: ReturnType | null = null; + const optionalWithinBudget: Promise = Promise.race([ + optionalEnrichment, + new Promise((resolve) => { + optionalBudgetTimer = setTimeout(() => resolve(null), optionalBudgetMs); + optionalBudgetTimer.unref?.(); + }), + ]); + + const [sessions, stateSnapshots, optionalResult] = await Promise.all([ timePhase("sessions", () => enrichSessionsForLaneList(args)), - options.includeRebaseSuggestions === false - ? Promise.resolve([]) - : timePhase("rebase_suggestions", () => - Promise.resolve() - .then(() => args.rebaseSuggestionService?.listSuggestions({ lanes }) ?? []) - .catch(() => [])), - options.includeAutoRebaseStatus === false - ? Promise.resolve([]) - : timePhase("auto_rebase_statuses", () => - Promise.resolve() - .then(() => args.autoRebaseService?.listStatuses({ lanes }) ?? []) - .catch(() => [])), timePhase("state_snapshots", () => Promise.resolve() .then(() => args.laneService.listStateSnapshots()) .catch(() => [])), - options.includeConflictStatus === false - ? Promise.resolve(null) - : timePhase("conflict_assessment", () => - Promise.resolve() - .then(() => args.conflictService?.getBatchAssessment({ lanes }) ?? null) - .catch(() => null)), + optionalWithinBudget, ]); + if (optionalBudgetTimer) clearTimeout(optionalBudgetTimer); + const [rebaseSuggestions, autoRebaseStatuses, batchAssessment] = optionalResult ?? [ + previousOptionalEnrichment[0], + previousOptionalEnrichment[1], + previousOptionalEnrichment[2], + ]; + if (optionalResult === null) { + args.logger.info("lanes.listSnapshots.optional_enrichment_deferred", { + laneCount: lanes.length, + budgetMs: optionalBudgetMs, + includeConflictStatus: options.includeConflictStatus !== false, + includeRebaseSuggestions: options.includeRebaseSuggestions !== false, + includeAutoRebaseStatus: options.includeAutoRebaseStatus !== false, + }); + } const durationMs = Date.now() - startedAt; if (durationMs >= 120) { args.logger.info("lanes.listSnapshots.summary", { diff --git a/apps/desktop/src/main/services/lanes/rebaseSuggestionService.ts b/apps/desktop/src/main/services/lanes/rebaseSuggestionService.ts index f63ab55b8..f94644bef 100644 --- a/apps/desktop/src/main/services/lanes/rebaseSuggestionService.ts +++ b/apps/desktop/src/main/services/lanes/rebaseSuggestionService.ts @@ -20,6 +20,7 @@ type StoredSuggestionState = { const KEY_PREFIX = "rebase:suggestion:"; const SUGGESTION_CACHE_TTL_MS = 10_000; const SUGGESTION_SCAN_CONCURRENCY = 4; +const SUGGESTION_CACHE_MAX_ENTRIES = 8; type ListSuggestionsOptions = { force?: boolean; @@ -27,6 +28,20 @@ type ListSuggestionsOptions = { refreshRemoteTracking?: boolean; }; +function suggestionCacheKey(options: ListSuggestionsOptions): string { + if (!options.lanes) return "default"; + return JSON.stringify(options.lanes + .map((lane) => ({ + id: lane.id, + parentLaneId: lane.parentLaneId, + branchRef: lane.branchRef, + baseRef: lane.baseRef, + worktreePath: lane.worktreePath, + archivedAt: lane.archivedAt, + })) + .sort((left, right) => left.id.localeCompare(right.id))); +} + function keyForLane(laneId: string): string { return `${KEY_PREFIX}${laneId}`; } @@ -109,12 +124,13 @@ export function createRebaseSuggestionService(args: { db.setJson(keyForLane(state.laneId), state); }; - let cachedSuggestions: { atMs: number; suggestions: RebaseSuggestion[] } | null = null; - let suggestionsInFlight: Promise | null = null; + const cachedSuggestions = new Map(); + const suggestionsInFlight = new Map>(); let suggestionsCacheGeneration = 0; const invalidateSuggestionsCache = () => { - cachedSuggestions = null; + cachedSuggestions.clear(); + suggestionsInFlight.clear(); suggestionsCacheGeneration += 1; }; @@ -433,33 +449,41 @@ export function createRebaseSuggestionService(args: { }; const listSuggestions = async (options: ListSuggestionsOptions = {}): Promise => { - // Only share the global cache and in-flight promise for default (no - // request-specific options) requests. Caller-supplied lane subsets and - // refreshRemoteTracking each compute different results, so they must not - // read or populate the shared default-result cache. - const useSharedCache = !options.force && !options.lanes && options.refreshRemoteTracking !== true; + // Snapshot callers provide the lanes they already loaded. Cache those + // bounded scans too; otherwise every invalidation starts the same slow git + // probes again while an earlier timed-out/deferred scan is still running. + const useSharedCache = !options.force && options.refreshRemoteTracking !== true; + const cacheKey = suggestionCacheKey(options); const nowMs = Date.now(); - if (useSharedCache && cachedSuggestions && nowMs - cachedSuggestions.atMs < SUGGESTION_CACHE_TTL_MS) { - return cachedSuggestions.suggestions; + const cached = cachedSuggestions.get(cacheKey); + if (useSharedCache && cached && nowMs - cached.atMs < SUGGESTION_CACHE_TTL_MS) { + return cached.suggestions; } - if (useSharedCache && suggestionsInFlight) { - return suggestionsInFlight; + const inFlight = suggestionsInFlight.get(cacheKey); + if (useSharedCache && inFlight) { + return inFlight; } const generation = suggestionsCacheGeneration; const work = computeSuggestions(options); if (useSharedCache) { - suggestionsInFlight = work; + suggestionsInFlight.set(cacheKey, work); } try { const suggestions = await work; if (useSharedCache && generation === suggestionsCacheGeneration) { - cachedSuggestions = { atMs: Date.now(), suggestions }; + cachedSuggestions.delete(cacheKey); + cachedSuggestions.set(cacheKey, { atMs: Date.now(), suggestions }); + while (cachedSuggestions.size > SUGGESTION_CACHE_MAX_ENTRIES) { + const oldestKey = cachedSuggestions.keys().next().value as string | undefined; + if (!oldestKey) break; + cachedSuggestions.delete(oldestKey); + } } return suggestions; } finally { - if (suggestionsInFlight === work) { - suggestionsInFlight = null; + if (suggestionsInFlight.get(cacheKey) === work) { + suggestionsInFlight.delete(cacheKey); } } }; diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts index 5ff0775be..98db9bc43 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts @@ -954,6 +954,90 @@ describe("local runtime connection pool", () => { expect(call).toHaveBeenCalledTimes(2); }); + it("single-flights an exact lane delete and keeps its client timeout above daemon work", async () => { + let resolveCall!: (value: unknown) => void; + const call = vi.fn(() => new Promise((resolve) => { + resolveCall = resolve; + })); + const pool = new LocalRuntimeConnectionPool("1.2.3", { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as never); + const rootPath = path.resolve("/repo"); + (pool as unknown as { projectsByRoot: Map }).projectsByRoot.set(rootPath, { + projectId: "project-1", + rootPath, + displayName: "repo", + addedAt: 1, + lastOpenedAt: 1, + gitOriginUrl: null, + }); + (pool as unknown as { connection: Promise }).connection = Promise.resolve({ + client: { call, isClosed: vi.fn(() => false) }, + child: null, + socketPath: "/tmp/ade.sock", + }); + const request = { + domain: "lane", + action: "delete", + args: { laneId: "lane-1", force: true, deleteRemoteBranch: true }, + }; + + const first = pool.callActionForRoot(rootPath, request); + const duplicate = pool.callActionForRoot(rootPath, { + ...request, + args: { deleteRemoteBranch: true, force: true, laneId: "lane-1" }, + }); + await new Promise((resolve) => setImmediate(resolve)); + + expect(call).toHaveBeenCalledTimes(1); + expect(call).toHaveBeenCalledWith( + "ade/actions/call", + expect.objectContaining({ + arguments: expect.objectContaining({ domain: "lane", action: "delete" }), + }), + { timeoutMs: 4 * 60_000 }, + ); + + resolveCall({ domain: "lane", action: "delete", result: null, statusHints: {} }); + await expect(Promise.all([first, duplicate])).resolves.toHaveLength(2); + expect(call).toHaveBeenCalledTimes(1); + }); + + it("extends archive mutations while preserving a single delivery attempt", async () => { + const call = vi.fn().mockResolvedValue({ + domain: "lane", + action: "archive", + result: null, + statusHints: {}, + }); + const pool = new LocalRuntimeConnectionPool("1.2.3", { + debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), + } as never); + const rootPath = path.resolve("/repo"); + (pool as unknown as { projectsByRoot: Map }).projectsByRoot.set(rootPath, { + projectId: "project-1", rootPath, displayName: "repo", addedAt: 1, lastOpenedAt: 1, gitOriginUrl: null, + }); + (pool as unknown as { connection: Promise }).connection = Promise.resolve({ + client: { call, isClosed: vi.fn(() => false) }, child: null, socketPath: "/tmp/ade.sock", + }); + + await pool.callActionForRoot(rootPath, { + domain: "lane", + action: "archive", + args: { laneId: "lane-1" }, + }); + + expect(call).toHaveBeenCalledTimes(1); + expect(call).toHaveBeenCalledWith( + "ade/actions/call", + expect.anything(), + { timeoutMs: 120_000 }, + ); + }); + it("retries project registration when the cached runtime connection drops before a read action", async () => { const dropped = new Error("Remote ADE service connection closed."); const logger = { diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts index 836c4e8bd..93bd90769 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts @@ -87,6 +87,12 @@ const LOCAL_RUNTIME_SERVICE_UNINSTALL_TIMEOUT_MS = 20_000; const LOCAL_RUNTIME_FILE_ACTION_TIMEOUT_MS = 8_000; const LOCAL_RUNTIME_EVENT_POLL_TIMEOUT_MS = 2_000; const LONG_RUNNING_LOCAL_RUNTIME_ACTION_TIMEOUTS: ReadonlyMap = new Map([ + // Lane deletion can legitimately include a 60s worktree removal followed by + // a 45s remote-branch deletion. The old 30s client budget reported failure + // while the daemon kept mutating state to a successful completion. + ["lane.delete", 4 * 60_000], + ["lane.archive", 120_000], + ["lane.unarchive", 120_000], ["chat.suggestLaneNameFromPrompt", 120_000], // Handoff = AI brief generation (bounded at 45s) + session creation + // provider dispatch of the first message; the 30s default fired a false @@ -100,6 +106,12 @@ const LOCAL_RUNTIME_OUTPUT_LINE_MAX_CHARS = 4_000; const LOCAL_RUNTIME_OUTPUT_BUFFER_MAX_CHARS = 16_000; const COALESCED_LOCAL_RUNTIME_ACTIONS = new Set([ "chat.listSessions", + // Exact duplicate destructive requests share one in-flight result. This is + // not a retry: mutations still have maxAttempts=1, and different arguments + // or sequential invocations remain independent. + "lane.archive", + "lane.delete", + "lane.unarchive", "layout.get", "project_config.get", "pty.resize", diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index c3f3378aa..43c24c64e 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -6424,6 +6424,7 @@ describe("ptyService", () => { }); it("signalTerminal sends ^C for SIGINT and forwards SIGTERM to pty.kill", async () => { + const kill = vi.spyOn(process, "kill").mockImplementation(() => true as const); const { service, mockPty } = createChatHarness(); await service.create({ laneId: "lane-1", @@ -6436,8 +6437,61 @@ describe("ptyService", () => { service.signalTerminal({ chatSessionId: "chat-signal", signal: "SIGINT" }); expect(mockPty.write).toHaveBeenCalledWith("\x03"); + mocks.spawnSync.mockClear(); service.signalTerminal({ chatSessionId: "chat-signal", signal: "SIGTERM" }); expect(mockPty.kill).toHaveBeenCalledWith("SIGTERM"); + expect(kill).toHaveBeenCalledWith(-12345, "SIGTERM"); + expect(mocks.spawnSync).not.toHaveBeenCalled(); + kill.mockRestore(); + }); + + it("uses node-pty's kill fallback without POSIX process-group signals on Windows", async () => { + const kill = vi.spyOn(process, "kill").mockImplementation(() => true as const); + setPlatform("win32"); + try { + const { service, mockPty } = createChatHarness(); + await service.create({ + laneId: "lane-1", + title: "Signal", + cols: 80, + rows: 24, + chatSessionId: "chat-signal-windows", + }); + + service.signalTerminal({ chatSessionId: "chat-signal-windows", signal: "SIGTERM" }); + expect(mockPty.kill).toHaveBeenCalledWith("SIGTERM"); + expect(kill).not.toHaveBeenCalledWith(-12345, "SIGTERM"); + } finally { + setPlatform(originalPlatform); + kill.mockRestore(); + } + }); + + it("force-kills a live PTY process group after its leader exits", async () => { + vi.useFakeTimers(); + const kill = vi.spyOn(process, "kill").mockImplementation(((pid: number, signal?: number | NodeJS.Signals) => { + if (pid === -12345 && signal === 0) return true; + return true; + }) as typeof process.kill); + try { + const { service } = createChatHarness(); + await service.create({ + laneId: "lane-1", + title: "Signal", + cols: 80, + rows: 24, + chatSessionId: "chat-signal-group", + }); + + service.signalTerminal({ chatSessionId: "chat-signal-group", signal: "SIGTERM" }); + await vi.advanceTimersByTimeAsync(1_500); + + expect(kill).toHaveBeenCalledWith(-12345, 0); + expect(kill).toHaveBeenCalledWith(-12345, "SIGKILL"); + } finally { + kill.mockRestore(); + vi.useRealTimers(); + } }); it("fails loudly when chat terminal calls cannot resolve a target", async () => { diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 19bfaa194..f80cbf464 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -165,7 +165,6 @@ const AGENT_CLI_READY_TIMEOUT_MS = 20_000; const AGENT_CLI_READY_POLL_MS = 100; const AGENT_CLI_READY_QUIET_MS = 600; const PTY_PROCESS_TREE_KILL_DELAY_MS = 1500; -const PTY_PROCESS_TREE_MAX_DEPTH = 12; let cachedOpenCodeReplayResumeSupport: boolean | null = null; @@ -178,50 +177,36 @@ function isPidLive(pid: number): boolean { } } -function childPidsOf(pid: number): number[] { - if (!Number.isFinite(pid) || pid <= 0) return []; +function killPidBestEffort(pid: number, signal: NodeJS.Signals): void { + if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid) return; try { - const result = spawnSync("pgrep", ["-P", String(Math.trunc(pid))], { - encoding: "utf8", - timeout: 1000, - }); - if (result.error || result.status === 1) return []; - return String(result.stdout ?? "") - .split(/\s+/) - .map((value) => Number.parseInt(value, 10)) - .filter((value) => Number.isFinite(value) && value > 0); + process.kill(Math.trunc(pid), signal); } catch { - return []; + // The process may have already exited. } } -function collectDescendantPids(rootPid: number): number[] { - const root = Math.trunc(rootPid); - if (!Number.isFinite(root) || root <= 0) return []; - const seen = new Set([root]); - const descendants: number[] = []; - let frontier = [root]; - for (let depth = 0; depth < PTY_PROCESS_TREE_MAX_DEPTH && frontier.length > 0; depth += 1) { - const next: number[] = []; - for (const parent of frontier) { - for (const child of childPidsOf(parent)) { - if (seen.has(child)) continue; - seen.add(child); - descendants.push(child); - next.push(child); - } - } - frontier = next; +function killPtyProcessGroupBestEffort(rootPid: number, signal: NodeJS.Signals): boolean { + if (process.platform === "win32" || !Number.isFinite(rootPid) || rootPid <= 0) return false; + try { + // node-pty's POSIX backend uses forkpty(3); forkpty's login_tty(3) creates + // a new session, making the child both session and process-group leader. + // Targeting `-pid` therefore signals the PTY group in one syscall, instead + // of recursively running synchronous `pgrep` calls on the main thread. + process.kill(-Math.trunc(rootPid), signal); + return true; + } catch { + return false; } - return descendants; } -function killPidBestEffort(pid: number, signal: NodeJS.Signals): void { - if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid) return; +function isPtyProcessGroupLive(rootPid: number): boolean { + if (process.platform === "win32" || !Number.isFinite(rootPid) || rootPid <= 0) return false; try { - process.kill(Math.trunc(pid), signal); + process.kill(-Math.trunc(rootPid), 0); + return true; } catch { - // The process may have already exited. + return false; } } @@ -589,27 +574,26 @@ function terminatePtyProcessTree( const rootPid = typeof entry.pty.pid === "number" && Number.isFinite(entry.pty.pid) ? Math.trunc(entry.pty.pid) : null; - const descendants = rootPid ? collectDescendantPids(rootPid) : []; - for (const pid of [...descendants].reverse()) { - killPidBestEffort(pid, signal); - } + const signaledProcessGroup = rootPid + ? killPtyProcessGroupBestEffort(rootPid, signal) + : false; try { entry.pty.kill(signal); } catch { if (rootPid) killPidBestEffort(rootPid, signal); } - if (signal === "SIGKILL" || (!rootPid && descendants.length === 0)) return; - const pidsToReap = Array.from(new Set([...(rootPid ? [rootPid] : []), ...descendants])); - if (!pidsToReap.length) return; + if (signal === "SIGKILL" || !rootPid) return; const timer = setTimeout(() => { - const stillLive = pidsToReap.filter((pid) => isPidLive(pid)); - for (const pid of stillLive) killPidBestEffort(pid, "SIGKILL"); - if (stillLive.length > 0) { + const processGroupLive = signaledProcessGroup && isPtyProcessGroupLive(rootPid); + const rootLive = !signaledProcessGroup && isPidLive(rootPid); + if (processGroupLive || rootLive) { + if (processGroupLive) killPtyProcessGroupBestEffort(rootPid, "SIGKILL"); + killPidBestEffort(rootPid, "SIGKILL"); logger.warn("pty.process_tree_force_killed", { sessionId: entry.sessionId, toolType: entry.toolTypeHint, rootPid, - pids: stillLive, + pids: [rootPid], }); } }, PTY_PROCESS_TREE_KILL_DELAY_MS); From d9824c5a08d2133c9837637ac4b197da8e08630c Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:18:06 -0400 Subject: [PATCH 22/53] fix(chat): restore returned session controls --- .../components/chat/AgentChatPane.test.tsx | 38 +++++++++++++++++++ .../components/chat/AgentChatPane.tsx | 3 ++ .../Settings/SettingsConnectionHeader.swift | 15 ++++---- apps/ios/ADETests/ADETests.swift | 9 +++-- 4 files changed, 53 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 83984c440..3c2f6cece 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -3427,6 +3427,44 @@ describe("AgentChatPane submit recovery", () => { }); }); + it("resyncs model tuning controls when a returned chat finishes hydrating", async () => { + const session = buildSession("session-1", { + status: "idle", + reasoningEffort: "medium", + fastMode: false, + executionMode: "focused", + }); + const sessions = [session]; + const { emitChatEvent } = installAdeMocks({ sessions }); + + renderPane(session); + + const fastModeButton = await screen.findByRole("button", { name: "Fast mode" }); + expect(fastModeButton.getAttribute("aria-pressed")).toBe("false"); + + sessions[0] = { + ...session, + reasoningEffort: "xhigh", + fastMode: true, + executionMode: "teams", + }; + emitChatEvent({ + sessionId: session.sessionId, + timestamp: "2026-03-24T07:15:00.000Z", + event: { + type: "done", + status: "completed", + turnId: "turn-hydrated", + model: "gpt-5.4", + }, + }); + + await waitFor(() => { + expect(fastModeButton.getAttribute("aria-pressed")).toBe("true"); + expect(screen.getByLabelText("Reasoning effort").textContent).toContain("XH"); + }); + }); + it("exits plan mode in the composer chip when an exit notice arrives even if the session refetch is stale", async () => { // Reproduces the production bug: the backend accepted the plan and emitted // the exit notice, but the debounced session refetch still reports plan diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 9a1071374..8b00431da 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -5727,6 +5727,9 @@ export function AgentChatPane({ }, [ selectedSession?.sessionId, selectedSessionModelId, + selectedSession?.reasoningEffort, + selectedSession?.fastMode, + selectedSession?.executionMode, selectedSession?.interactionMode, selectedSession?.claudePermissionMode, selectedSession?.codexApprovalPolicy, diff --git a/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift b/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift index 50e59a459..d172d6c5a 100644 --- a/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift +++ b/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift @@ -5,14 +5,13 @@ func settingsConnectedRouteChipText( durationMs: Int?, routeKind: SyncConnectionRouteKind? ) -> String? { - guard let routeKind else { return nil } - let routeLabel = switch routeKind { - case .lan: "lan" - case .tailnet: "tailnet" - case .relay: "relay" - } + // A non-nil observed route proves that a connection attempt completed. Keep + // the primary performance chip route-neutral; the diagnostics section has a + // separate connectionRoute row for people who actually need LAN/Tailscale/ + // relay detail. + guard routeKind != nil else { return nil } guard let durationMs, durationMs >= 0, durationMs <= 10_000 else { - return routeLabel + return "Connected" } let seconds = Double(durationMs) / 1_000 let durationLabel = String( @@ -20,7 +19,7 @@ func settingsConnectedRouteChipText( locale: Locale(identifier: "en_US_POSIX"), seconds ) - return "Connected in \(durationLabel)s · \(routeLabel)" + return "Connected in \(durationLabel)s" } struct SettingsConnectionHeader: View { diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 5ecd8694a..5a80b2ae8 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -1495,19 +1495,20 @@ final class ADETests: XCTestCase { XCTAssertNil(health.lastFailureMessage) } - func testSettingsConnectedRouteChipFormatsDurationAndSlowFallback() { + func testSettingsConnectedRouteChipKeepsPrimaryCopyRouteNeutral() { XCTAssertEqual( settingsConnectedRouteChipText(durationMs: 300, routeKind: .tailnet), - "Connected in 0.3s · tailnet" + "Connected in 0.3s" ) XCTAssertEqual( settingsConnectedRouteChipText(durationMs: 1_200, routeKind: .lan), - "Connected in 1.2s · lan" + "Connected in 1.2s" ) XCTAssertEqual( settingsConnectedRouteChipText(durationMs: 10_001, routeKind: .relay), - "relay" + "Connected" ) + XCTAssertNil(settingsConnectedRouteChipText(durationMs: 300, routeKind: nil)) } @MainActor From 1de4b620b730089d55898d0a410abaaaecbcdfb1 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:18:49 -0400 Subject: [PATCH 23/53] fix(sync): preserve verified relay routes --- apps/account-directory/src/directory.ts | 52 +++++- apps/account-directory/test/directory.test.ts | 112 +++++++++++++ .../accountMachinePublisherService.test.ts | 155 +++++++++++++++++- .../account/accountMachinePublisherService.ts | 141 ++++++++++++++-- 4 files changed, 442 insertions(+), 18 deletions(-) diff --git a/apps/account-directory/src/directory.ts b/apps/account-directory/src/directory.ts index 1394a5e9d..75dcc2bbc 100644 --- a/apps/account-directory/src/directory.ts +++ b/apps/account-directory/src/directory.ts @@ -41,6 +41,7 @@ type RegisterInput = { deviceType: string; pubkey: string | null; reachableEndpoints: ReachableEndpoint[]; + retainRelayEndpoints: boolean; }; type MachineRecord = { @@ -172,10 +173,29 @@ function parseRegisterInput(value: unknown): RegisterInput | null { const deviceType = requiredString(value, "deviceType"); const pubkey = optionalString(value, "pubkey"); const reachableEndpoints = parseReachableEndpoints(value.reachableEndpoints); - if (!machineKey || !deviceId || !name || !platform || !deviceType || pubkey === undefined || !reachableEndpoints) { + const retainRelayEndpoints = value.retainRelayEndpoints ?? false; + if ( + !machineKey + || !deviceId + || !name + || !platform + || !deviceType + || pubkey === undefined + || !reachableEndpoints + || typeof retainRelayEndpoints !== "boolean" + ) { return null; } - return { machineKey, deviceId, name, platform, deviceType, pubkey, reachableEndpoints }; + return { + machineKey, + deviceId, + name, + platform, + deviceType, + pubkey, + reachableEndpoints, + retainRelayEndpoints, + }; } function getRemoteJwks(rawUrl: string): ReturnType { @@ -331,7 +351,32 @@ async function handleRegister(request: Request, env: Env, userId: string): Promi platform = excluded.platform, device_type = excluded.device_type, pubkey = excluded.pubkey, - reachable_endpoints = excluded.reachable_endpoints, + reachable_endpoints = case + when ? = 1 + and json_valid(machines.reachable_endpoints) + and exists ( + select 1 + from json_each(machines.reachable_endpoints) + where json_extract(value, '$.kind') = 'relay' + ) + and not exists ( + select 1 + from json_each(excluded.reachable_endpoints) + where json_extract(value, '$.kind') = 'relay' + ) + then ( + select json_group_array(json(endpoint)) + from ( + select value as endpoint + from json_each(excluded.reachable_endpoints) + union all + select value as endpoint + from json_each(machines.reachable_endpoints) + where json_extract(value, '$.kind') = 'relay' + ) + ) + else excluded.reachable_endpoints + end, last_seen_at = excluded.last_seen_at `).bind( userId, @@ -344,6 +389,7 @@ async function handleRegister(request: Request, env: Env, userId: string): Promi JSON.stringify(input.reachableEndpoints), now, now, + input.retainRelayEndpoints ? 1 : 0, ).run(); const row = await env.DB.prepare(` diff --git a/apps/account-directory/test/directory.test.ts b/apps/account-directory/test/directory.test.ts index 54b23831a..379fbebaa 100644 --- a/apps/account-directory/test/directory.test.ts +++ b/apps/account-directory/test/directory.test.ts @@ -153,6 +153,7 @@ class FakeD1Database { run(sql: string, values: unknown[]): number { const normalized = sql.toLowerCase(); if (normalized.includes("insert into machines")) { + const retainRelayEndpoints = values[10] === 1; const row: StoredMachine = { user_id: String(values[0]), machine_key: String(values[1]), @@ -169,6 +170,21 @@ class FakeD1Database { entry.user_id === row.user_id && entry.machine_key === row.machine_key ); if (existing) { + if (retainRelayEndpoints) { + const nextEndpoints = JSON.parse(row.reachable_endpoints ?? "[]") as Array<{ kind?: string }>; + const existingRelayEndpoints = ( + JSON.parse(existing.reachable_endpoints ?? "[]") as Array<{ kind?: string }> + ).filter((endpoint) => endpoint.kind === "relay"); + if ( + !nextEndpoints.some((endpoint) => endpoint.kind === "relay") + && existingRelayEndpoints.length > 0 + ) { + row.reachable_endpoints = JSON.stringify([ + ...nextEndpoints, + ...existingRelayEndpoints, + ]); + } + } Object.assign(existing, row, { created_at: existing.created_at }); } else { this.rows.push(row); @@ -431,6 +447,13 @@ function registerBody(machineKey: string, endpoints: unknown = [{ kind: "lan", h }; } +function registrationWithRelayRetention(machineKey: string, endpoints: unknown) { + return { + ...registerBody(machineKey, endpoints), + retainRelayEndpoints: true, + }; +} + function request( method: string, pathname: string, @@ -1031,6 +1054,95 @@ describe("machine directory", () => { expect(await otherUserList.json()).toEqual({ machines: [] }); }); + it("retains the authenticated machine's verified Relay route during a transient health dip", async () => { + const env = makeEnv(); + const token = await mintToken({ sub: "user_1" }); + const relayEndpoint = { kind: "relay", url: "wss://relay.test/machine-a" }; + await register(env, token, "machine-a", [ + { kind: "lan", host: "old.local", port: 8787 }, + relayEndpoint, + ]); + + const transient = await handleRequest(request( + "POST", + "/account/machines/register", + token, + registrationWithRelayRetention("machine-a", [ + { kind: "lan", host: "new.local", port: 8787 }, + ]), + ), env); + + expect(transient.status).toBe(200); + expect(await transient.json()).toEqual(expect.objectContaining({ + machineKey: "machine-a", + reachableEndpoints: [ + { kind: "lan", host: "new.local", port: 8787 }, + relayEndpoint, + ], + })); + }); + + it("never retains Relay routes across owners, deletion, or an authoritative replacement", async () => { + const env = makeEnv(); + const firstToken = await mintToken({ sub: "user_1" }); + const secondToken = await mintToken({ sub: "user_2" }); + const relayEndpoint = { kind: "relay", url: "wss://relay.test/shared-machine" }; + await register(env, firstToken, "shared-machine", [relayEndpoint]); + + const otherOwner = await handleRequest(request( + "POST", + "/account/machines/register", + secondToken, + registrationWithRelayRetention("shared-machine", [ + { kind: "lan", host: "second.local", port: 8787 }, + ]), + ), env); + expect(await otherOwner.json()).toEqual(expect.objectContaining({ + reachableEndpoints: [{ kind: "lan", host: "second.local", port: 8787 }], + })); + + const authoritative = await register(env, firstToken, "shared-machine", [ + { kind: "lan", host: "first.local", port: 8787 }, + ]); + expect(await authoritative.json()).toEqual(expect.objectContaining({ + reachableEndpoints: [{ kind: "lan", host: "first.local", port: 8787 }], + })); + + await handleRequest(request( + "DELETE", + "/account/machines/shared-machine", + firstToken, + ), env); + const afterDelete = await handleRequest(request( + "POST", + "/account/machines/register", + firstToken, + registrationWithRelayRetention("shared-machine", [ + { kind: "lan", host: "after-delete.local", port: 8787 }, + ]), + ), env); + expect(await afterDelete.json()).toEqual(expect.objectContaining({ + reachableEndpoints: [{ kind: "lan", host: "after-delete.local", port: 8787 }], + })); + }); + + it("rejects a non-boolean Relay-retention instruction", async () => { + const env = makeEnv(); + const token = await mintToken(); + const response = await handleRequest(request( + "POST", + "/account/machines/register", + token, + { + ...registerBody("machine-a"), + retainRelayEndpoints: "yes", + }, + ), env); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "invalid request body" }); + }); + it("returns online and offline machines, online first and newest first", async () => { const env = makeEnv(); const token = await mintToken({ sub: "user_1" }); diff --git a/apps/ade-cli/src/services/account/accountMachinePublisherService.test.ts b/apps/ade-cli/src/services/account/accountMachinePublisherService.test.ts index d9b887d0c..19909aae8 100644 --- a/apps/ade-cli/src/services/account/accountMachinePublisherService.test.ts +++ b/apps/ade-cli/src/services/account/accountMachinePublisherService.test.ts @@ -357,7 +357,10 @@ describe("account machine publisher health", () => { let token: string | null = null; let signInListener: (() => void) | null = null; const emitSignIn = () => signInListener?.(); - const fetchImpl = vi.fn(async () => new Response(null, { status: 200 })); + const fetchImpl = vi.fn(async ( + _input: string | URL | Request, + _init?: RequestInit, + ) => new Response(null, { status: 200 })); const service = createAccountMachinePublisherService({ getAccessToken: async () => token, getAccountStatus: () => ({ @@ -394,10 +397,17 @@ describe("account machine publisher health", () => { it("coalesces relay readiness changes into a publish and resets the heartbeat", async () => { vi.useFakeTimers(); const current = routeSnapshot(); - const fetchImpl = vi.fn(async () => new Response(null, { status: 200 })); + const fetchImpl = vi.fn(async ( + _input: string | URL | Request, + _init?: RequestInit, + ) => new Response(null, { status: 200 })); const service = createAccountMachinePublisherService({ getAccessToken: async () => "account-token", - getAccountStatus: () => ({ signedIn: true, sessionReadState: "available" as const }), + getAccountStatus: () => ({ + signedIn: true, + userId: "owner-a", + sessionReadState: "available" as const, + }), getSnapshot: async () => current, getMachineKey: () => "machine-studio", directoryBaseUrl: () => "https://directory.example", @@ -412,6 +422,17 @@ describe("account machine publisher health", () => { current.routeHealth.relay.relayBridgeValidated = false; await vi.advanceTimersByTimeAsync(ACCOUNT_MACHINE_RELAY_STATE_POLL_MS); expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(JSON.parse(String(fetchImpl.mock.calls[1]?.[1]?.body))).toEqual( + expect.objectContaining({ + retainRelayEndpoints: true, + reachableEndpoints: [ + { kind: "lan", host: "192.168.1.20", port: 8787 }, + { kind: "tailnet", host: "studio.tailnet.ts.net", port: 8787 }, + { kind: "relay", url: "wss://relay.example/connect/machine-studio" }, + ], + }), + ); + expect(service.getPublisherHealth().reachableEndpointCount).toBe(3); await vi.advanceTimersByTimeAsync(ACCOUNT_MACHINE_HEARTBEAT_MS - 1); expect(fetchImpl).toHaveBeenCalledTimes(2); @@ -420,6 +441,134 @@ describe("account machine publisher health", () => { service.dispose(); }); + it("does not retain a verified Relay route across account-owner changes", async () => { + let accountOwnerId = "owner-a"; + const current = routeSnapshot(); + const fetchImpl = vi.fn(async ( + _input: string | URL | Request, + _init?: RequestInit, + ) => new Response(null, { status: 204 })); + const service = createAccountMachinePublisherService({ + getAccessToken: async () => "account-token", + getAccountStatus: () => ({ + signedIn: true, + userId: accountOwnerId, + sessionReadState: "available" as const, + }), + getSnapshot: async () => current, + getMachineKey: () => "machine-studio", + directoryBaseUrl: () => "https://directory.example", + fetchImpl, + }); + + await service.publishNow(); + current.routeHealth.relay.relayControlConnected = false; + current.routeHealth.relay.relayBridgeValidated = false; + accountOwnerId = "owner-b"; + await service.publishNow(); + + expect(JSON.parse(String(fetchImpl.mock.calls[1]?.[1]?.body))).toEqual( + expect.objectContaining({ + retainRelayEndpoints: true, + reachableEndpoints: [ + { kind: "lan", host: "192.168.1.20", port: 8787 }, + { kind: "tailnet", host: "studio.tailnet.ts.net", port: 8787 }, + ], + }), + ); + service.dispose(); + }); + + it("clears retained Relay ownership on explicit sign-out", async () => { + let signedIn = true; + const current = routeSnapshot(); + const fetchImpl = vi.fn(async ( + _input: string | URL | Request, + _init?: RequestInit, + ) => new Response(null, { status: 204 })); + const service = createAccountMachinePublisherService({ + getAccessToken: async () => "account-token", + getAccountStatus: () => ({ + signedIn, + userId: signedIn ? "owner-a" : null, + sessionReadState: signedIn ? "available" as const : "missing" as const, + }), + getSnapshot: async () => current, + getMachineKey: () => "machine-studio", + directoryBaseUrl: () => "https://directory.example", + fetchImpl, + }); + + await service.publishNow(); + signedIn = false; + await service.publishNow(); + current.routeHealth.relay.relayControlConnected = false; + current.routeHealth.relay.relayBridgeValidated = false; + signedIn = true; + await service.publishNow(); + + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(JSON.parse(String(fetchImpl.mock.calls[1]?.[1]?.body))).toEqual( + expect.objectContaining({ + retainRelayEndpoints: true, + reachableEndpoints: [ + { kind: "lan", host: "192.168.1.20", port: 8787 }, + { kind: "tailnet", host: "studio.tailnet.ts.net", port: 8787 }, + ], + }), + ); + service.dispose(); + }); + + it("clears retained Relay ownership after an authoritative authentication rejection", async () => { + const current = routeSnapshot(); + let rejectAuthentication = false; + const fetchImpl = vi.fn(async ( + _input: string | URL | Request, + _init?: RequestInit, + ) => rejectAuthentication + ? new Response(JSON.stringify({ error: "invalid token" }), { + status: 401, + headers: { "content-type": "application/json" }, + }) + : new Response(null, { status: 204 })); + const service = createAccountMachinePublisherService({ + getAccessToken: async () => "account-token", + getAccountStatus: () => ({ + signedIn: true, + userId: "owner-a", + sessionReadState: "available" as const, + }), + getSnapshot: async () => current, + getMachineKey: () => "machine-studio", + directoryBaseUrl: () => "https://directory.example", + fetchImpl, + }); + + await service.publishNow(); + current.routeHealth.relay.relayControlConnected = false; + current.routeHealth.relay.relayBridgeValidated = false; + rejectAuthentication = true; + await service.publishNow(); + expect(service.getPublisherHealth()).toMatchObject({ + state: "http_error", + lastHttpStatus: 401, + }); + + rejectAuthentication = false; + await service.publishNow(); + expect(JSON.parse(String(fetchImpl.mock.calls[3]?.[1]?.body))).toEqual( + expect.objectContaining({ + retainRelayEndpoints: true, + reachableEndpoints: [ + { kind: "lan", host: "192.168.1.20", port: 8787 }, + { kind: "tailnet", host: "studio.tailnet.ts.net", port: 8787 }, + ], + }), + ); + service.dispose(); + }); + it("publishes at the first relay poll when the startup snapshot was unavailable", async () => { vi.useFakeTimers(); let ready = false; diff --git a/apps/ade-cli/src/services/account/accountMachinePublisherService.ts b/apps/ade-cli/src/services/account/accountMachinePublisherService.ts index 8ab1e70e8..58301929a 100644 --- a/apps/ade-cli/src/services/account/accountMachinePublisherService.ts +++ b/apps/ade-cli/src/services/account/accountMachinePublisherService.ts @@ -36,6 +36,14 @@ export type AccountMachineRegistration = { deviceType: string; pubkey: null; reachableEndpoints: AdeAccountMachineEndpoint[]; + /** + * Asks a compatible directory to retain its stored Relay endpoint when this + * heartbeat catches the independently asynchronous Relay components between + * ready states. The directory scopes retention to the authenticated owner + * and machine key; current endpoints remain authoritative for every other + * route kind. + */ + retainRelayEndpoints?: true; }; type AccountMachinePublisherLogger = { @@ -53,13 +61,42 @@ export type AccountMachineRegistrationSnapshot = Pick< routeHealth: Pick; }; -type PublisherAccountStatus = Pick & { - sessionReadState: AccountSessionReadState; -}; +type PublisherAccountStatus = Pick & + Partial> & { + sessionReadState: AccountSessionReadState; + }; + +type PublishedRelayEndpoint = Extract; + +function relayEndpoints( + registration: AccountMachineRegistration, +): PublishedRelayEndpoint[] { + return registration.reachableEndpoints.filter( + (endpoint): endpoint is PublishedRelayEndpoint => endpoint.kind === "relay", + ); +} + +function withRetainedRelayEndpoints( + registration: AccountMachineRegistration, + retained: readonly PublishedRelayEndpoint[], +): AccountMachineRegistration { + if (retained.length === 0 || relayEndpoints(registration).length > 0) { + return registration; + } + const reachableEndpoints = [...registration.reachableEndpoints]; + const seen = new Set(reachableEndpoints.map((endpoint) => JSON.stringify(endpoint))); + for (const endpoint of retained) { + const key = JSON.stringify(endpoint); + if (seen.has(key)) continue; + seen.add(key); + reachableEndpoints.push(endpoint); + } + return { ...registration, reachableEndpoints }; +} function isPublisherSignedOut( status: PublisherAccountStatus | null, -): status is PublisherAccountStatus { +): boolean { return status !== null && !status.signedIn && status.source !== "env-token"; @@ -214,6 +251,11 @@ export function createAccountMachinePublisherService(options: { let inFlight: Promise | null = null; let triggeredPublishPending = false; let lastRelayPublishStateSignature: string | null = null; + let lastPublishedRelayState: { + machineKey: string; + accountOwnerId: string | null; + endpoints: PublishedRelayEndpoint[]; + } | null = null; let lastWarning: string | null = null; let transientFailureCount = 0; let unsubscribeSignIn: (() => void) | null = null; @@ -245,6 +287,33 @@ export function createAccountMachinePublisherService(options: { transientFailureCount = 0; }; + const clearRetainedRelayState = (): void => { + lastPublishedRelayState = null; + }; + + const reconcileRetainedRelayOwner = ( + status: PublisherAccountStatus | null, + ): string | null => { + if (isPublisherSignedOut(status)) { + clearRetainedRelayState(); + return null; + } + const accountOwnerId = status?.signedIn + ? status.userId?.trim() || null + : null; + if ( + lastPublishedRelayState + && lastPublishedRelayState.accountOwnerId !== accountOwnerId + // A missing owner is tolerated only when BOTH observations lack one. The + // brain publisher supplies userId, while small embedded/test publishers + // may intentionally omit account identity. + && (lastPublishedRelayState.accountOwnerId !== null || accountOwnerId !== null) + ) { + clearRetainedRelayState(); + } + return accountOwnerId; + }; + const recordOutcome = ( state: SyncAccountDirectoryHealth["state"], args: { @@ -365,12 +434,12 @@ export function createAccountMachinePublisherService(options: { return; } - const registration = buildAccountMachineRegistration({ + const observedRegistration = buildAccountMachineRegistration({ machineKey, snapshot, packageChannel: process.env.ADE_PACKAGE_CHANNEL, }); - if (!registration) { + if (!observedRegistration) { recordOutcome("machine_key_unavailable", { attemptAt, skipReason: "The machine registration could not be built.", @@ -378,8 +447,8 @@ export function createAccountMachinePublisherService(options: { }); return; } - observeRelayPublishState(relayPublishStateSignature(snapshot, registration)); - const reachableEndpointCount = registration.reachableEndpoints.length; + observeRelayPublishState(relayPublishStateSignature(snapshot, observedRegistration)); + const observedReachableEndpointCount = observedRegistration.reachableEndpoints.length; let accountStatus: PublisherAccountStatus | null = null; try { @@ -389,22 +458,53 @@ export function createAccountMachinePublisherService(options: { attemptAt, skipReason: "The ADE brain could not read account status.", directoryOrigin, - reachableEndpointCount, + reachableEndpointCount: observedReachableEndpointCount, }); return; } if (isPublisherSignedOut(accountStatus)) { - const unreadable = accountStatus.sessionReadState === "unreadable"; + clearRetainedRelayState(); + const unreadable = accountStatus?.sessionReadState === "unreadable"; recordOutcome(unreadable ? "token_unreadable" : "account_signed_out", { attemptAt, skipReason: unreadable ? "The ADE brain could not read the stored account session." : "The ADE brain is signed out of the ADE account.", directoryOrigin, - reachableEndpointCount, + reachableEndpointCount: observedReachableEndpointCount, }); return; } + const accountOwnerId = reconcileRetainedRelayOwner(accountStatus); + + // Relay readiness is sampled from multiple independently asynchronous + // components (control socket, local bridge validation, listener handoff). + // A momentary false sample must not overwrite the directory's last verified + // Relay route and strand every browser/mobile client. Retain only a route + // that THIS publisher successfully registered, for the same machine and + // account owner, while Relay remains enabled. Explicit sign-out, owner + // change, terminal auth rejection, or a genuinely disabled Relay clears the + // retention boundary. The process-local compatibility path below retains + // only a route this publisher successfully registered. + const relayTemporarilyUnavailable = snapshot.routeHealth.relay.enabled === true + && relayEndpoints(observedRegistration).length === 0; + const canRetainRelay = relayTemporarilyUnavailable + && lastPublishedRelayState?.machineKey === machineKey + && lastPublishedRelayState.accountOwnerId === accountOwnerId; + const registrationWithRetainedRelay = canRetainRelay + ? withRetainedRelayEndpoints( + observedRegistration, + lastPublishedRelayState?.endpoints ?? [], + ) + : observedRegistration; + // The server-side retention hint protects the same invariant across brain + // restarts, where this process-local compatibility cache is necessarily + // empty. Older directory deployments safely ignore the extra property and + // still benefit from the process-local retained route above. + const registration: AccountMachineRegistration = relayTemporarilyUnavailable + ? { ...registrationWithRetainedRelay, retainRelayEndpoints: true } + : registrationWithRetainedRelay; + const reachableEndpointCount = registration.reachableEndpoints.length; let accessToken: string | null = null; try { @@ -467,6 +567,9 @@ export function createAccountMachinePublisherService(options: { : responseReason; if (response.status >= 500) recordTransientFailure(); else resetPublishCadence(); + if (response.status === 401 || response.status === 403) { + clearRetainedRelayState(); + } recordOutcome("http_error", { attemptAt, skipReason: httpReason @@ -483,6 +586,14 @@ export function createAccountMachinePublisherService(options: { await response.body?.cancel().catch(() => {}); lastWarning = null; resetPublishCadence(); + const publishedRelayEndpoints = relayEndpoints(registration); + lastPublishedRelayState = publishedRelayEndpoints.length > 0 + ? { + machineKey, + accountOwnerId, + endpoints: publishedRelayEndpoints, + } + : null; recordOutcome("published", { attemptAt, skipReason: null, @@ -578,7 +689,11 @@ export function createAccountMachinePublisherService(options: { if (!started || disposed || options.isSyncEnabled?.() === false) return; try { const accountStatus = options.getAccountStatus?.() ?? null; - if (isPublisherSignedOut(accountStatus)) return; + if (isPublisherSignedOut(accountStatus)) { + clearRetainedRelayState(); + return; + } + reconcileRetainedRelayOwner(accountStatus); } catch { return; } @@ -651,6 +766,7 @@ export function createAccountMachinePublisherService(options: { dispose(): void { disposed = true; started = false; + clearRetainedRelayState(); clearHeartbeatTimer(); if (relayStatePollTimer) clearTimeout(relayStatePollTimer); relayStatePollTimer = null; @@ -693,6 +809,7 @@ export function createBrainAccountMachinePublisherService(options: { const status = accountAuthService.getStatus(); return { signedIn: status.signedIn, + userId: status.userId, source: status.source ?? null, sessionReadState: accountAuthService.getSessionReadState(), }; From b0e41fba4fc6e6990219fb36d614785b6ba3a5b6 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:21:56 -0400 Subject: [PATCH 24/53] fix(github): limit transient probe cooldown --- .../services/github/githubService.test.ts | 25 +++++++++++++++++++ .../src/main/services/github/githubService.ts | 14 ++++++++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/services/github/githubService.test.ts b/apps/desktop/src/main/services/github/githubService.test.ts index bfb9c9fbf..ad287b889 100644 --- a/apps/desktop/src/main/services/github/githubService.test.ts +++ b/apps/desktop/src/main/services/github/githubService.test.ts @@ -769,6 +769,31 @@ describe("githubService.getStatus", () => { now.mockRestore(); }); + it("does not extend the shared cooldown for an invalid gh token", async () => { + stubOriginRemote(); + delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; + const baseNow = Date.now(); + const now = vi.spyOn(Date, "now").mockReturnValue(baseNow); + const ghAuthTokenProvider = vi.fn(() => ({ + token: "gho_invalid_token", + ghCliPath: "/opt/homebrew/bin/gh", + ghAuthError: null, + })); + mockFetch.mockResolvedValue(jsonResponse(401, { message: "Bad credentials" })); + + const first = await makeService({ ghAuthTokenProvider }).getStatus(); + expect(first.connected).toBe(false); + expect(ghAuthTokenProvider).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledTimes(1); + + now.mockReturnValue(baseNow + 31_000); + const second = await makeService({ ghAuthTokenProvider }).getStatus(); + expect(second.connected).toBe(false); + expect(ghAuthTokenProvider).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenCalledTimes(2); + now.mockRestore(); + }); + it("clearing a stored PAT falls back to gh auth", async () => { stubOriginRemote(); delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; diff --git a/apps/desktop/src/main/services/github/githubService.ts b/apps/desktop/src/main/services/github/githubService.ts index 41189cfea..46fde11c5 100644 --- a/apps/desktop/src/main/services/github/githubService.ts +++ b/apps/desktop/src/main/services/github/githubService.ts @@ -62,6 +62,11 @@ type ProcessGithubAuthState = { const processGithubAuthStates = new WeakMap(); +function isTransientGithubProbeFailure(error: string | null): boolean { + return /timed out|timeout|network|fetch failed|aborted|econn(?:reset|refused|aborted)|enotfound|eai_again|socket|tls|temporarily unavailable/i + .test(error ?? ""); +} + function processGithubAuthState(provider: GitHubCliAuthProvider): ProcessGithubAuthState { const existing = processGithubAuthStates.get(provider); if (existing) return existing; @@ -802,9 +807,12 @@ export function createGithubService({ sharedGhAuth.statusInFlight.set(key, work); try { const result = await work; - const isNetworkFailure = !result.ok - || (result.value.repoAccessOk === false - && /timed out|network|fetch failed/i.test(result.value.repoAccessError ?? "")); + let isNetworkFailure = false; + if (!result.ok) { + isNetworkFailure = isTransientGithubProbeFailure(result.error); + } else if (result.value.repoAccessOk === false) { + isNetworkFailure = isTransientGithubProbeFailure(result.value.repoAccessError); + } sharedGhAuth.statusCache.set(key, { expiresAt: Date.now() + (isNetworkFailure ? GITHUB_STATUS_FAILURE_COOLDOWN_MS From 3967e374614feb5fcb8f60ac1a9bf66cea402edc Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:23:38 -0400 Subject: [PATCH 25/53] fix(sync): bound browser invalidation hints --- .../src/services/sync/syncHostService.test.ts | 239 ++++++++++++++++-- .../src/services/sync/syncHostService.ts | 100 +++++++- .../webclient/sync/__tests__/sync.test.ts | 44 +++- .../src/renderer/webclient/sync/connection.ts | 48 +++- apps/desktop/src/shared/types/sync.ts | 32 ++- .../sync-and-multi-device/crdt-model.md | 24 +- docs/features/web-client/README.md | 40 +-- 7 files changed, 457 insertions(+), 70 deletions(-) diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts index 050e80d24..bdc737101 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -14,12 +14,16 @@ import type { PersonalChatScopeContract, SyncChangesetAckPayload, SyncChangesetBatchPayload, + SyncInvalidationBatchPayload, SyncMobileProjectSummary, SyncPeerMetadata, SyncProjectCatalogPayload, SyncRemoteCommandDescriptor, } from "../../../../desktop/src/shared/types"; import { + SYNC_COMPACT_INVALIDATION_V1_CAPABILITY, + SYNC_INVALIDATION_BATCH_MAX_ENVELOPE_BYTES, + SYNC_INVALIDATION_TABLE_MAX_BYTES, SYNC_INVALIDATION_ONLY_V1_CAPABILITY, SYNC_RELAY_REAUTHORIZE_V1_CAPABILITY, } from "../../../../desktop/src/shared/types"; @@ -36,6 +40,7 @@ import { SYNC_HOST_CHAT_TRANSCRIPT_DELTA_MAX_BYTES, SYNC_HOST_CHAT_TRANSCRIPT_MAX_RECORD_BYTES, SYNC_HOST_PRIORITY_MAX_CHANGESET_DEFER_MS, + buildSyncInvalidationBatchPayload, buildSyncHostHelloOkPayload, buildSyncProjectCatalogMessages, compactChatEventEnvelopeForSync, @@ -63,7 +68,7 @@ import { buildRelayReauthorizationChallenge, sha256RelayToken, } from "./relayAuthorization"; -import { encodeSyncEnvelope, parseSyncEnvelope, SYNC_RUNTIME_ONLY_CAPABILITY, wsDataToText, type ParsedSyncEnvelope } from "./syncProtocol"; +import { encodeSyncEnvelope, parseSyncEnvelope, PEER_BACKPRESSURE_BYTES, SYNC_RUNTIME_ONLY_CAPABILITY, wsDataToText, type ParsedSyncEnvelope } from "./syncProtocol"; import { EncryptedFileCredentialStore } from "../credentials/credentialStore"; import { verifyClerkAccountAttestation } from "../account/accountAttestationVerifier"; @@ -219,10 +224,72 @@ describe("buildSyncHostHelloOkPayload", () => { }; expect(buildSyncHostHelloOkPayload({ ...base, peer }).features.invalidationOnlyV1).toEqual({ enabled: true }); + expect(buildSyncHostHelloOkPayload({ ...base, peer }).features).not.toHaveProperty("compactInvalidationV1"); expect(buildSyncHostHelloOkPayload({ ...base, - peer: { ...peer, deviceType: "phone", capabilities: [] }, - }).features).not.toHaveProperty("invalidationOnlyV1"); + peer: { + ...peer, + capabilities: [ + SYNC_INVALIDATION_ONLY_V1_CAPABILITY, + SYNC_COMPACT_INVALIDATION_V1_CAPABILITY, + ], + }, + }).features.compactInvalidationV1).toEqual({ enabled: true }); + const phoneFeatures = buildSyncHostHelloOkPayload({ + ...base, + peer: { + ...peer, + deviceType: "phone", + capabilities: [ + SYNC_INVALIDATION_ONLY_V1_CAPABILITY, + SYNC_COMPACT_INVALIDATION_V1_CAPABILITY, + ], + }, + }).features; + expect(phoneFeatures).not.toHaveProperty("invalidationOnlyV1"); + expect(phoneFeatures).not.toHaveProperty("compactInvalidationV1"); + }); + + it("keeps invalidation envelopes bounded when table metadata is oversized", () => { + const oversizedNamePayload = buildSyncInvalidationBatchPayload({ + fromDbVersion: 4, + toDbVersion: 5, + changes: [{ + ...makeChange(5, 0), + table: "t".repeat(SYNC_INVALIDATION_TABLE_MAX_BYTES + 1), + }], + compressionThresholdBytes: Number.MAX_SAFE_INTEGER, + }); + + expect(oversizedNamePayload).toEqual({ + fromDbVersion: 4, + toDbVersion: 5, + tables: [], + fullRefresh: true, + }); + const oversizedEnvelopePayload = buildSyncInvalidationBatchPayload({ + fromDbVersion: 5, + toDbVersion: 6, + changes: Array.from({ length: 128 }, (_, index) => ({ + ...makeChange(6, index), + table: `${String(index).padStart(3, "0")}${"t".repeat(253)}`, + })), + compressionThresholdBytes: Number.MAX_SAFE_INTEGER, + }); + expect(oversizedEnvelopePayload).toEqual({ + fromDbVersion: 5, + toDbVersion: 6, + tables: [], + fullRefresh: true, + }); + + for (const payload of [oversizedNamePayload, oversizedEnvelopePayload]) { + expect(Buffer.byteLength(encodeSyncEnvelope({ + type: "invalidation_batch", + payload, + compressionThresholdBytes: Number.MAX_SAFE_INTEGER, + }), "utf8")).toBeLessThanOrEqual(SYNC_INVALIDATION_BATCH_MAX_ENVELOPE_BYTES); + } }); it("advertises daemon-hosted project catalog support in hello_ok without desktop", () => { @@ -3990,12 +4057,20 @@ describe("initial hydration priority", () => { slowPeer = await connectPeer(port, host.getBootstrapToken(), "slow-foreground-peer", { platform: "macOS", deviceType: "browser", - capabilities: [SYNC_INVALIDATION_ONLY_V1_CAPABILITY, "changesetAck"], + capabilities: [ + SYNC_INVALIDATION_ONLY_V1_CAPABILITY, + SYNC_COMPACT_INVALIDATION_V1_CAPABILITY, + "changesetAck", + ], }); fastPeer = await connectPeer(port, host.getBootstrapToken(), "independent-peer", { platform: "macOS", deviceType: "browser", - capabilities: [SYNC_INVALIDATION_ONLY_V1_CAPABILITY, "changesetAck"], + capabilities: [ + SYNC_INVALIDATION_ONLY_V1_CAPABILITY, + SYNC_COMPACT_INVALIDATION_V1_CAPABILITY, + "changesetAck", + ], }); const realDateNow = Date.now.bind(Date); let clockOffsetMs = 0; @@ -4018,19 +4093,31 @@ describe("initial hydration priority", () => { "per-peer foreground deferral", ); const independentBatch = await waitForValue( - () => fastPeer?.envelopes.find((envelope) => envelope.type === "changeset_batch"), - "independent peer changeset", + () => fastPeer?.envelopes.find((envelope) => envelope.type === "invalidation_batch"), + "independent peer invalidation", ); - expect((independentBatch.payload as SyncChangesetBatchPayload).changes).toHaveLength(200); - expect(slowPeer.envelopes.some((envelope) => envelope.type === "changeset_batch")).toBe(false); + expect(independentBatch.payload as SyncInvalidationBatchPayload).toEqual({ + fromDbVersion: 0, + toDbVersion: 200, + tables: ["kv"], + fullRefresh: false, + }); + expect(slowPeer.envelopes.some((envelope) => + envelope.type === "changeset_batch" || envelope.type === "invalidation_batch" + )).toBe(false); expect(getSessionSummary).toHaveBeenCalledWith("slow-chat"); clockOffsetMs += SYNC_HOST_PRIORITY_MAX_CHANGESET_DEFER_MS + 25; const boundedSlowBatch = await waitForValue( - () => slowPeer?.envelopes.find((envelope) => envelope.type === "changeset_batch"), - "bounded slow-peer changeset", + () => slowPeer?.envelopes.find((envelope) => envelope.type === "invalidation_batch"), + "bounded slow-peer invalidation", ); - expect((boundedSlowBatch.payload as SyncChangesetBatchPayload).changes).toHaveLength(64); + expect(boundedSlowBatch.payload as SyncInvalidationBatchPayload).toEqual({ + fromDbVersion: 0, + toDbVersion: 64, + tables: ["kv"], + fullRefresh: false, + }); expect(logger.debug).toHaveBeenCalledWith( "sync_host.changeset_priority_deferral_ended", expect.objectContaining({ @@ -4317,7 +4404,10 @@ describe("initial hydration priority", () => { deviceType: "browser", siteId: "browser-initial-hydration-site", dbVersion: 0, - capabilities: [SYNC_INVALIDATION_ONLY_V1_CAPABILITY], + capabilities: [ + SYNC_INVALIDATION_ONLY_V1_CAPABILITY, + SYNC_COMPACT_INVALIDATION_V1_CAPABILITY, + ], }, auth: { kind: "bootstrap", token: host.getBootstrapToken() }, }, @@ -4351,18 +4441,35 @@ describe("initial hydration priority", () => { expect(envelopes.some((envelope) => envelope.type === "changeset_batch")).toBe(false); // A browser is invalidation-only: historical rows are skipped, while a - // mutation committed after hello still produces a normal live signal. + // mutation committed after hello produces a compact live signal even + // when the source row itself is larger than the Relay peer budget. state.dbVersion = 2; - state.changes.push(makeChange(2, 1)); + const oversizedValue = "x".repeat(PEER_BACKPRESSURE_BYTES + 1); + state.changes.push({ + ...makeChange(2, 1, oversizedValue), + table: "operations", + }); const liveInvalidation = await waitForValue( - () => envelopes.find((envelope) => envelope.type === "changeset_batch"), + () => envelopes.find((envelope) => envelope.type === "invalidation_batch"), "post-connect browser invalidation", ); expect(exportChangesSince).toHaveBeenCalledWith( 1, expect.objectContaining({ throughDbVersion: 2 }), ); - expect((liveInvalidation.payload as SyncChangesetBatchPayload).changes.map((change) => change.db_version)).toEqual([2]); + const invalidationPayload = liveInvalidation.payload as SyncInvalidationBatchPayload; + expect(invalidationPayload).toEqual({ + fromDbVersion: 1, + toDbVersion: 2, + tables: ["operations"], + fullRefresh: false, + }); + expect(envelopes.some((envelope) => envelope.type === "changeset_batch")).toBe(false); + expect(Buffer.byteLength(encodeSyncEnvelope({ + type: "invalidation_batch", + payload: invalidationPayload, + compressionThresholdBytes: Number.MAX_SAFE_INTEGER, + }), "utf8")).toBeLessThanOrEqual(SYNC_INVALIDATION_BATCH_MAX_ENVELOPE_BYTES); } finally { try { client?.close(); @@ -4373,6 +4480,84 @@ describe("initial hydration priority", () => { cleanup(); } }); + + it("keeps older invalidation-only browsers on changeset hints", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const state = { + dbVersion: 0, + changes: [] as CrsqlChangeRow[], + }; + const base = createHostArgs(projectRoot, []); + const host = createSyncHostService({ + ...base, + projectId: "project-1", + discoveryEnabled: false, + pollIntervalMs: 25, + db: { + sync: { + getSiteId: () => "site-host-legacy-browser", + getDbVersion: () => state.dbVersion, + exportChangesSince: (fromDbVersion: number) => + state.changes.filter((change) => Number(change.db_version) > fromDbVersion), + applyChanges: () => ({ appliedCount: 0 }), + discardUnpublishedChangesForTables: () => {}, + }, + }, + deviceRegistryService: { + ...base.deviceRegistryService, + upsertPeerMetadata: vi.fn(), + }, + } as unknown as Parameters[0]); + let browser: WebSocket | null = null; + let envelopes: ParsedSyncEnvelope[] = []; + + try { + const port = await host.waitUntilListening(); + browser = new WebSocket(`ws://127.0.0.1:${port}`); + ({ envelopes } = trackClientEnvelopes(browser)); + await new Promise((resolve, reject) => { + browser!.once("open", resolve); + browser!.once("error", reject); + }); + browser.send(encodeSyncEnvelope({ + type: "hello", + requestId: "legacy-browser-hello", + payload: { + peer: { + deviceId: "legacy-invalidation-browser", + deviceName: "Legacy Browser", + platform: "macOS", + deviceType: "browser", + siteId: "legacy-invalidation-browser-site", + dbVersion: 0, + capabilities: [SYNC_INVALIDATION_ONLY_V1_CAPABILITY], + }, + auth: { kind: "bootstrap", token: host.getBootstrapToken() }, + }, + })); + const helloOkEnvelope = await waitForEnvelope(envelopes, "hello_ok", "legacy-browser-hello"); + expect((helloOkEnvelope.payload as { features?: Record }).features) + .not.toHaveProperty("compactInvalidationV1"); + expect(envelopes.some((envelope) => + envelope.type === "changeset_batch" || envelope.type === "invalidation_batch" + )).toBe(false); + + state.dbVersion = 1; + state.changes.push(makeChange(1, 0)); + const legacyBatch = await waitForValue( + () => envelopes.find((envelope) => envelope.type === "changeset_batch"), + "legacy browser changeset hint", + ); + expect((legacyBatch.payload as SyncChangesetBatchPayload).changes).toEqual([ + expect.objectContaining({ db_version: 1, table: "kv" }), + ]); + expect(envelopes.some((envelope) => envelope.type === "invalidation_batch")).toBe(false); + } finally { + browser?.close(); + await host.dispose(); + cleanup(); + } + }); }); describe("outbound changeset ack retries", () => { @@ -5475,7 +5660,10 @@ describe("sync host handoff over a shared listener", () => { browser = await connectPeer(port, hostA.getBootstrapToken(), "browser-handoff-peer", { platform: "macOS", deviceType: "browser", - capabilities: [SYNC_INVALIDATION_ONLY_V1_CAPABILITY], + capabilities: [ + SYNC_INVALIDATION_ONLY_V1_CAPABILITY, + SYNC_COMPACT_INVALIDATION_V1_CAPABILITY, + ], }); expect(hostA.getPeerStates()).toEqual([ @@ -5484,7 +5672,9 @@ describe("sync host handoff over a shared listener", () => { syncLag: 0, }), ]); - expect(browser.envelopes.some((envelope) => envelope.type === "changeset_batch")).toBe(false); + expect(browser.envelopes.some((envelope) => + envelope.type === "changeset_batch" || envelope.type === "invalidation_batch" + )).toBe(false); await hostA.dispose(); hostA = null; @@ -5505,12 +5695,15 @@ describe("sync host handoff over a shared listener", () => { const postHandoffBatch = await waitForValue( () => browser?.envelopes .slice(envelopeCountAfterDeposit) - .find((envelope) => envelope.type === "changeset_batch"), + .find((envelope) => envelope.type === "invalidation_batch"), "same-DB post-handoff browser invalidation", ); - expect((postHandoffBatch.payload as SyncChangesetBatchPayload).changes).toEqual([ - expect.objectContaining({ db_version: 2 }), - ]); + expect(postHandoffBatch.payload as SyncInvalidationBatchPayload).toEqual({ + fromDbVersion: 1, + toDbVersion: 2, + tables: ["kv"], + fullRefresh: false, + }); expect(browser.closeEvents).toEqual([]); expect(browser.ws.readyState).toBe(WebSocket.OPEN); expect(hostB.getPeerStates()).toEqual([ diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index 427d69def..000da006a 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -58,6 +58,7 @@ import type { SyncFileResponsePayload, SyncHelloPayload, SyncHelloErrorPayload, + SyncInvalidationBatchPayload, SyncMobileProjectSummary, SyncPairingRequestPayload, PairedRuntimeHelloOkPayload, @@ -89,6 +90,10 @@ import type { SyncTerminalSnapshotPayload, } from "../../../../desktop/src/shared/types"; import { + SYNC_COMPACT_INVALIDATION_V1_CAPABILITY, + SYNC_INVALIDATION_BATCH_MAX_ENVELOPE_BYTES, + SYNC_INVALIDATION_BATCH_MAX_TABLES, + SYNC_INVALIDATION_TABLE_MAX_BYTES, SYNC_INVALIDATION_ONLY_V1_CAPABILITY, SYNC_RELAY_REAUTHORIZE_V1_CAPABILITY, } from "../../../../desktop/src/shared/types"; @@ -908,13 +913,10 @@ export function initialSyncHostCursorForPeer(args: { }): number { // A browser may explicitly negotiate an invalidation-only contract: it has // no SQLite replica, fully refetches its query domains after hello, and uses - // only post-connect changesets as invalidation hints. Starting that peer at + // only post-connect sync messages as invalidation hints. Starting that peer at // the current watermark avoids replaying CRR history it cannot apply. Keep // legacy browsers on replica semantics unless they declare the capability. - if ( - args.peer.deviceType === "browser" - && args.peer.capabilities?.includes(SYNC_INVALIDATION_ONLY_V1_CAPABILITY) - ) { + if (isInvalidationOnlyBrowserPeer(args.peer)) { return Math.max(0, Math.floor(args.serverDbVersion)); } const cursorForThisDb = args.peer.dbVersionBySite?.[args.serverDbSiteId] @@ -942,10 +944,7 @@ export function adoptedSyncHostCursorForPeer(args: { // same-DB seamless adoption, the deposited cursor is the exact boundary: // writes committed while the socket is parked must be exported by the new // owner. A different DB still starts at that DB's current watermark. - if ( - args.peer.deviceType === "browser" - && args.peer.capabilities?.includes(SYNC_INVALIDATION_ONLY_V1_CAPABILITY) - ) { + if (isInvalidationOnlyBrowserPeer(args.peer)) { return Math.min(Math.max(0, Math.floor(args.serverDbVersion)), snapshotCursor); } // Replica peers may have advertised a newer durable per-site cursor than @@ -953,6 +952,69 @@ export function adoptedSyncHostCursorForPeer(args: { return Math.max(initialCursor, snapshotCursor); } +function isInvalidationOnlyBrowserPeer( + peer: Pick | null | undefined, +): boolean { + return peer?.deviceType === "browser" + && peer.capabilities?.includes(SYNC_INVALIDATION_ONLY_V1_CAPABILITY) === true; +} + +function isCompactInvalidationBrowserPeer( + peer: Pick | null | undefined, +): boolean { + return isInvalidationOnlyBrowserPeer(peer) + && peer?.capabilities?.includes(SYNC_COMPACT_INVALIDATION_V1_CAPABILITY) === true; +} + +export function buildSyncInvalidationBatchPayload(args: { + fromDbVersion: number; + toDbVersion: number; + changes: readonly CrsqlChangeRow[]; + compressionThresholdBytes?: number; +}): SyncInvalidationBatchPayload { + const fromDbVersion = Number.isFinite(args.fromDbVersion) + ? Math.max(0, Math.floor(args.fromDbVersion)) + : 0; + const toDbVersion = Number.isFinite(args.toDbVersion) + ? Math.max(fromDbVersion, Math.floor(args.toDbVersion)) + : fromDbVersion; + const fullRefresh = (): SyncInvalidationBatchPayload => ({ + fromDbVersion, + toDbVersion, + tables: [], + fullRefresh: true, + }); + if (args.changes.length === 0) return fullRefresh(); + const tables = new Set(); + for (const change of args.changes) { + const table = typeof change.table === "string" ? change.table : ""; + if ( + !table + || table.trim() !== table + || table.includes("\0") + || Buffer.byteLength(table, "utf8") > SYNC_INVALIDATION_TABLE_MAX_BYTES + ) { + return fullRefresh(); + } + tables.add(table); + if (tables.size > SYNC_INVALIDATION_BATCH_MAX_TABLES) return fullRefresh(); + } + const payload: SyncInvalidationBatchPayload = { + fromDbVersion, + toDbVersion, + tables: [...tables].sort(), + fullRefresh: false, + }; + const envelopeBytes = Buffer.byteLength(encodeSyncEnvelope({ + type: "invalidation_batch", + payload, + compressionThresholdBytes: args.compressionThresholdBytes, + }), "utf8"); + return envelopeBytes <= SYNC_INVALIDATION_BATCH_MAX_ENVELOPE_BYTES + ? payload + : fullRefresh(); +} + export function shouldDeferSyncHostBackgroundChangesForChat(args: { subscribedChatSessionCount: number; bufferedAmount: number; @@ -1115,13 +1177,20 @@ export function buildSyncHostHelloOkPayload(args: { chatStreaming: { enabled: true, }, - ...(args.peer.capabilities?.includes(SYNC_INVALIDATION_ONLY_V1_CAPABILITY) + ...(isInvalidationOnlyBrowserPeer(args.peer) ? { invalidationOnlyV1: { enabled: true, }, } : {}), + ...(isCompactInvalidationBrowserPeer(args.peer) + ? { + compactInvalidationV1: { + enabled: true as const, + }, + } + : {}), crossProjectChat: { enabled: args.crossProjectChatEnabled, }, @@ -3980,7 +4049,14 @@ export function createSyncHostService(args: SyncHostServiceArgs) { attemptCount: 0, retryNotBeforeMs: 0, }; - const sent = send(peer, "changeset_batch", payload); + const sent = isCompactInvalidationBrowserPeer(peer.metadata) + ? send(peer, "invalidation_batch", buildSyncInvalidationBatchPayload({ + fromDbVersion: payload.fromDbVersion, + toDbVersion: payload.toDbVersion, + changes: payload.changes, + compressionThresholdBytes, + })) + : send(peer, "changeset_batch", payload); if (!sent) return null; batch.sentAtMs = Date.now(); batch.attemptCount = 1; @@ -4976,7 +5052,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { ); if (pending) { peer.changesetRecoveryNotBeforeMs = 0; - if (peerSupportsChangesetAck(peer)) { + if (peerSupportsChangesetAck(peer) && !isCompactInvalidationBrowserPeer(peer.metadata)) { peer.pendingChangesetBatch = pending; } else { peer.lastKnownServerDbVersion = Math.max(peer.lastKnownServerDbVersion, pending.toDbVersion); diff --git a/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts b/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts index bbf78b222..dd8b4cd50 100644 --- a/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts +++ b/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts @@ -1,6 +1,8 @@ import { gzipSync } from "node:zlib"; import { afterEach, describe, expect, it, vi } from "vitest"; import { + SYNC_COMPACT_INVALIDATION_V1_CAPABILITY, + SYNC_INVALIDATION_TABLE_MAX_BYTES, SYNC_INVALIDATION_ONLY_V1_CAPABILITY, type SyncEnvelope, type SyncFeatureFlags, @@ -72,6 +74,7 @@ const features: SyncFeatureFlags = { terminalStreaming: true, chatStreaming: { enabled: true }, invalidationOnlyV1: { enabled: true }, + compactInvalidationV1: { enabled: true }, projectCatalog: { enabled: true }, projectActions: { enabled: true }, changesetAck: { enabled: true }, @@ -153,6 +156,15 @@ function legacyHelloOk(projectId = "project-1"): SyncHelloOkPayload { }; } +function changesetHintHelloOk(projectId = "project-1"): SyncHelloOkPayload { + const payload = helloOk(projectId); + const { compactInvalidationV1: _ignored, ...featuresWithoutCompactInvalidation } = payload.features; + return { + ...payload, + features: featuresWithoutCompactInvalidation, + }; +} + function relayHelloOk(nowMs: number, options: { refreshAfterMs?: number; expiresAfterMs?: number; @@ -773,12 +785,12 @@ describe("browser sync connection and client", () => { vi.unstubAllGlobals(); }); - it("rejects a saved browser pairing when the host does not accept invalidation-only sync", async () => { + it("rejects a saved browser pairing when the host only supports legacy changeset hints", async () => { const environment = await makeEnvironment(new MemoryStorage(), { dpopPublicKeyX963: null }); vi.useFakeTimers(); const script = createSocketFactory((socket, envelope) => { if (envelope.type === "hello") { - socket.serverSend({ type: "hello_ok", requestId: envelope.requestId, payload: legacyHelloOk() }); + socket.serverSend({ type: "hello_ok", requestId: envelope.requestId, payload: changesetHintHelloOk() }); } }); const connection = new SyncConnection({ socketFactory: script.factory, document: null }); @@ -2063,7 +2075,7 @@ describe("browser sync connection and client", () => { client.dispose(); }); - it("uses invalidation-only sync without suppressing live changeset hints", async () => { + it("uses invalidation-only sync with bounded live hints", async () => { const environment = await makeEnvironment(new MemoryStorage(), { dpopPublicKeyX963: null }); const script = createSocketFactory((socket, envelope) => { if (envelope.type === "hello") { @@ -2080,6 +2092,9 @@ describe("browser sync connection and client", () => { expect((hello?.payload as { peer?: SyncPeerMetadata }).peer?.capabilities).toContain( SYNC_INVALIDATION_ONLY_V1_CAPABILITY, ); + expect((hello?.payload as { peer?: SyncPeerMetadata }).peer?.capabilities).toContain( + SYNC_COMPACT_INVALIDATION_V1_CAPABILITY, + ); expect(invalidations).toEqual([[ "agent_chats", "files", @@ -2091,22 +2106,35 @@ describe("browser sync connection and client", () => { ]]); script.sockets[0]?.serverSend({ - type: "changeset_batch", + type: "invalidation_batch", payload: { - batchId: "live-1", - reason: "broadcast", fromDbVersion: 12, toDbVersion: 13, - changes: [{ table: "agent_chats" }], + tables: ["agent_chats"], + fullRefresh: false, }, }); await flushMicrotasks(); expect(invalidations).toHaveLength(2); expect(invalidations[1]).toEqual(["agent_chats"]); + expect(script.sockets[0]?.sent.some((envelope) => envelope.type === "changeset_ack")).toBe(false); - await connection.connect(environment, [endpoint]); + script.sockets[0]?.serverSend({ + type: "invalidation_batch", + payload: { + fromDbVersion: 13, + toDbVersion: 14, + tables: ["t".repeat(SYNC_INVALIDATION_TABLE_MAX_BYTES + 1)], + fullRefresh: false, + }, + }); + await flushMicrotasks(); expect(invalidations).toHaveLength(3); expect(invalidations[2]).toEqual(invalidations[0]); + + await connection.connect(environment, [endpoint]); + expect(invalidations).toHaveLength(4); + expect(invalidations[3]).toEqual(invalidations[0]); connection.dispose(); }); diff --git a/apps/desktop/src/renderer/webclient/sync/connection.ts b/apps/desktop/src/renderer/webclient/sync/connection.ts index e9c1ae213..1dab760a4 100644 --- a/apps/desktop/src/renderer/webclient/sync/connection.ts +++ b/apps/desktop/src/renderer/webclient/sync/connection.ts @@ -7,6 +7,7 @@ import type { SyncHelloOkPayload, SyncHelloPayload, SyncHelloErrorPayload, + SyncInvalidationBatchPayload, SyncPeerMetadata, SyncProjectCatalogChunkPayload, SyncProjectCatalogPayload, @@ -17,6 +18,9 @@ import type { SyncRelayReauthorizeResultPayload, } from "../../../shared/types/sync"; import { + SYNC_COMPACT_INVALIDATION_V1_CAPABILITY, + SYNC_INVALIDATION_BATCH_MAX_TABLES, + SYNC_INVALIDATION_TABLE_MAX_BYTES, SYNC_INVALIDATION_ONLY_V1_CAPABILITY, SYNC_RELAY_READY_VERSION, SYNC_RELAY_REAUTHORIZE_V1_CAPABILITY, @@ -69,6 +73,39 @@ const FULL_INVALIDATION_TABLES = [ export const INVALIDATION_ONLY_V1_HOST_UPDATE_MESSAGE = "Update ADE on this Mac via Settings > General > Check for Updates, then retry."; +function invalidationTables(payload: SyncInvalidationBatchPayload): Set { + if (!payload || typeof payload !== "object") return new Set(FULL_INVALIDATION_TABLES); + if (payload.fullRefresh === true) return new Set(FULL_INVALIDATION_TABLES); + if (payload.fullRefresh !== false || !Array.isArray(payload.tables)) { + return new Set(FULL_INVALIDATION_TABLES); + } + if ( + !Number.isSafeInteger(payload.fromDbVersion) + || payload.fromDbVersion < 0 + || !Number.isSafeInteger(payload.toDbVersion) + || payload.toDbVersion <= payload.fromDbVersion + || payload.tables.length === 0 + || payload.tables.length > SYNC_INVALIDATION_BATCH_MAX_TABLES + ) { + return new Set(FULL_INVALIDATION_TABLES); + } + const encoder = new TextEncoder(); + const tables = new Set(); + for (const table of payload.tables) { + if ( + typeof table !== "string" + || !table + || table.trim() !== table + || table.includes("\0") + || encoder.encode(table).byteLength > SYNC_INVALIDATION_TABLE_MAX_BYTES + ) { + return new Set(FULL_INVALIDATION_TABLES); + } + tables.add(table); + } + return tables; +} + export type WebSocketLike = { readonly readyState: number; onopen: ((event: Event) => void) | null; @@ -167,7 +204,8 @@ export class SyncConnectionError extends Error { } function hostAcceptedInvalidationOnlyV1(payload: SyncHelloOkPayload): boolean { - return payload.features?.invalidationOnlyV1?.enabled === true; + return payload.features?.invalidationOnlyV1?.enabled === true + && payload.features?.compactInvalidationV1?.enabled === true; } function createDefaultSocket(url: string): WebSocketLike { @@ -646,10 +684,12 @@ export class SyncConnection { (capability) => ( capability !== SYNC_RELAY_REAUTHORIZE_V1_CAPABILITY && capability !== SYNC_INVALIDATION_ONLY_V1_CAPABILITY + && capability !== SYNC_COMPACT_INVALIDATION_V1_CAPABILITY ), ), SYNC_RELAY_REAUTHORIZE_V1_CAPABILITY, SYNC_INVALIDATION_ONLY_V1_CAPABILITY, + SYNC_COMPACT_INVALIDATION_V1_CAPABILITY, ], }, auth: { @@ -810,6 +850,7 @@ export class SyncConnection { capabilities: [ SYNC_RELAY_REAUTHORIZE_V1_CAPABILITY, SYNC_INVALIDATION_ONLY_V1_CAPABILITY, + SYNC_COMPACT_INVALIDATION_V1_CAPABILITY, ], }, auth: { @@ -881,6 +922,11 @@ export class SyncConnection { if (tables.size > 0) this.emit("tablesChanged", tables); break; } + case "invalidation_batch": { + const tables = invalidationTables(envelope.payload as SyncInvalidationBatchPayload); + if (tables.size > 0) this.emit("tablesChanged", tables); + break; + } case "brain_status": this.emit("brainStatus", envelope.payload as SyncBrainStatusPayload); break; diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index 7ffc01deb..58b97ff9d 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -52,9 +52,16 @@ export const SYNC_RELAY_REAUTHORIZE_V1_CAPABILITY = "relayReauthorizeV1" as cons /** * Additive hello capability for browser peers that keep no local CRR replica. * Such peers fully refetch their query domains after hello and consume only - * post-connect changesets as invalidation hints. + * post-connect sync messages as invalidation hints. */ export const SYNC_INVALIDATION_ONLY_V1_CAPABILITY = "invalidationOnlyV1" as const; +/** Browser can consume compact `invalidation_batch` envelopes. */ +export const SYNC_COMPACT_INVALIDATION_V1_CAPABILITY = "compactInvalidationV1" as const; + +/** Hard bounds for compact browser invalidation hints. */ +export const SYNC_INVALIDATION_BATCH_MAX_ENVELOPE_BYTES = 16 * 1024; +export const SYNC_INVALIDATION_BATCH_MAX_TABLES = 128; +export const SYNC_INVALIDATION_TABLE_MAX_BYTES = 256; /** Relay transport readiness protocol used before the ADE sync hello. */ export const SYNC_RELAY_READY_VERSION = 2 as const; @@ -438,6 +445,14 @@ export type SyncFeatureFlags = { invalidationOnlyV1?: { enabled: true; }; + /** + * Compact invalidation envelope contract. Kept separate from + * `invalidationOnlyV1` so hosts remain compatible with older browsers that + * negotiated history suppression but only understood `changeset_batch`. + */ + compactInvalidationV1?: { + enabled: true; + }; /** * Cross-project chat "quick look": when enabled, the host honors a * `projectId`/`projectRootPath` override on `chat_subscribe` and streams a @@ -926,6 +941,19 @@ export type SyncChangesetBatchPayload = { changes: CrsqlChangeRow[]; }; +/** + * Compact live-change hint for an invalidation-only browser. The browser has + * no CRR replica and therefore needs table names, never row values. When a + * table list cannot be represented inside the hard protocol bounds, the host + * sends `fullRefresh: true` and the browser invalidates every owned domain. + */ +export type SyncInvalidationBatchPayload = { + fromDbVersion: number; + toDbVersion: number; + tables: string[]; + fullRefresh: boolean; +}; + export type SyncChangesetAckPayload = { batchId: string; fromDbVersion: number; @@ -1651,6 +1679,7 @@ export type SyncProjectListMyGitHubReposResultEnvelope = SyncEnvelopeWithPayload export type SyncPairingRequestEnvelope = SyncEnvelopeWithPayload<"pairing_request", SyncPairingRequestPayload>; export type SyncPairingResultEnvelope = SyncEnvelopeWithPayload<"pairing_result", SyncPairingResultPayload>; export type SyncChangesetBatchEnvelope = SyncEnvelopeWithPayload<"changeset_batch", SyncChangesetBatchPayload>; +export type SyncInvalidationBatchEnvelope = SyncEnvelopeWithPayload<"invalidation_batch", SyncInvalidationBatchPayload>; export type SyncChangesetAckEnvelope = SyncEnvelopeWithPayload<"changeset_ack", SyncChangesetAckPayload>; export type SyncHeartbeatEnvelope = SyncEnvelopeWithPayload<"heartbeat", SyncHeartbeatPayload>; export type SyncFileRequestEnvelope = SyncEnvelopeWithPayload<"file_request", SyncFileRequest>; @@ -1718,6 +1747,7 @@ export type SyncEnvelope = | SyncPairingRequestEnvelope | SyncPairingResultEnvelope | SyncChangesetBatchEnvelope + | SyncInvalidationBatchEnvelope | SyncChangesetAckEnvelope | SyncHeartbeatEnvelope | SyncFileRequestEnvelope diff --git a/docs/features/sync-and-multi-device/crdt-model.md b/docs/features/sync-and-multi-device/crdt-model.md index fa674d72c..45099bd1f 100644 --- a/docs/features/sync-and-multi-device/crdt-model.md +++ b/docs/features/sync-and-multi-device/crdt-model.md @@ -325,15 +325,21 @@ limits are split targets rather than hard transaction caps: one complete `db_version` group is admitted even when that group alone exceeds a target. Hosted browsers negotiate `invalidationOnlyV1` because they have no local CRR -replica. A supporting host confirms the capability in `hello_ok`, starts a new -browser at the current database watermark, and sends only post-connect rows as -invalidation hints after the browser's initial full-domain refresh. Same-DB -socket handoff restores the deposited live cursor so writes committed during -the handoff window are not skipped. Foreground requests defer changesets only -for their own peer and for at most 2 seconds; the forced fairness batch is -bounded to the active-chat 64 KB/64-row limits. Browsers close with desktop -update guidance when an older host does not confirm the contract, preventing a -historical replay from overflowing the Relay bridge. +replica and `compactInvalidationV1` when they understand bounded refresh hints. +A supporting host confirms both capabilities in `hello_ok`, starts a new +browser at the current database watermark, and sends post-connect +`invalidation_batch` envelopes containing only database-version bounds and +changed table names after the browser's initial full-domain refresh. These +envelopes have a hard 16 KB serialized limit; an invalid or oversized table set +collapses to a compact full-refresh hint, so a single large CRR value cannot +overflow the Relay bridge. Same-DB socket handoff restores the deposited live +cursor so writes committed during the handoff window are not skipped. +Foreground requests defer invalidation scans only for their own peer and for at +most 2 seconds; the forced fairness scan uses the active-chat 64 KB/64-row +limits. Browsers close with desktop update guidance when an older host does not +confirm both contracts, preventing a historical replay or oversized live row +from overflowing Relay. Older browsers that advertise only +`invalidationOnlyV1` remain on their existing `changeset_batch` hint path. ### Apply diff --git a/docs/features/web-client/README.md b/docs/features/web-client/README.md index e9df6906a..b6a1627b9 100644 --- a/docs/features/web-client/README.md +++ b/docs/features/web-client/README.md @@ -74,7 +74,8 @@ Browser sync client: sync client. Adopts a machine through the verified account relay, reconnects saved environments, stores only the resulting paired credentials, sends remote commands, requests files, subscribes to chat and terminal streams, - switches projects, and treats `changeset_batch` as invalidation input. Its + switches projects, and treats compact `invalidation_batch` envelopes as + refresh input. Its terminal subscriptions keep logical UTF-8 byte watermarks, drop duplicates, trim overlaps, and perform one guarded `sinceOffset` resubscribe when a gap appears. Delta snapshots append only the missing suffix; full snapshots are @@ -140,7 +141,7 @@ Browser `window.ade` adapter: read cache: concurrent identical calls join a single in-flight relay request, and the resolved value is reused for the TTL window (3 s). `invalidateCache` clears the whole cache or a set of action prefixes so a mutation or a - `changeset_batch`-driven refresh drops stale reads. + sync-driven refresh drops stale reads. - `apps/desktop/src/renderer/webclient/adapter/infra/coalescingReadCache.ts` and `infra/cacheKey.ts` - the shared coalescing/TTL cache primitive and a deterministic argument-serializer used to key it. The cache keeps concurrent @@ -153,7 +154,7 @@ Browser `window.ade` adapter: name the prior project during reconnect and would otherwise stamp file requests and project commands with a stale id. - `apps/desktop/src/renderer/webclient/adapter/infra/invalidation.ts` - - maps changed table names from `changeset_batch` envelopes to coarse + maps changed table names from `invalidation_batch` envelopes to coarse renderer invalidation domains. - `apps/desktop/src/renderer/webclient/adapter/files.ts` - browser file API over sync `file_request`; no local file watcher. List reads @@ -376,11 +377,16 @@ Tests: apply changesets, and does not advertise the `changesetAck` capability. It advertises `invalidationOnlyV1`: the host starts it at the current database watermark, the browser performs one full-domain refresh after hello, and - later `changeset_batch` envelopes identify only the domains that changed. - The host must confirm that contract through - `hello_ok.features.invalidationOnlyV1`; an older host is closed immediately - with concrete desktop-update guidance instead of being allowed to replay its - historical CRR backlog through Relay. + later `invalidation_batch` envelopes identify only the domains that changed. + The additive `compactInvalidationV1` capability distinguishes this format + from older invalidation-only browsers that understood only changeset hints. + The host never includes CRR row values in these hints and caps their serialized + size at 16 KB, falling back to a full-domain refresh hint for invalid or + oversized table sets. + The host must confirm both contracts through + `hello_ok.features.invalidationOnlyV1` and `compactInvalidationV1`; an older + host is closed immediately with concrete desktop-update guidance instead of + being allowed to replay its historical CRR backlog through Relay. - **Protocol version 1 extensions are additive.** The browser decodes the common envelope and ignores valid types it does not implement, including the desktop-only `rpc_*` and `fwd_*` channels. Unknown `hello_ok.features` keys @@ -452,7 +458,7 @@ apps/desktop/src/renderer/webclient/sync/ - WebCrypto DPoP key - sync envelope codec - command/file/chat/terminal/project sub-protocols - - changeset_batch -> invalidation only + - invalidation_batch -> bounded refresh hints | v Browser-safe WebSocket transport @@ -613,11 +619,12 @@ for their non-Relay routes. The browser intentionally does not maintain a local replica of `.ade/ade.db`. `SyncConnection.sendHello` sends `dbVersion: 0` and advertises -`invalidationOnlyV1` (along with Relay reauthorization support), so the host +`invalidationOnlyV1` and `compactInvalidationV1` (along with Relay +reauthorization support), so the host does not replay historical CRR rows to a client that cannot apply them. The host places the browser at its current watermark; the accepted hello triggers -a full-domain refresh, and subsequent changeset batches remain live -invalidation hints rather than replicated state. +a full-domain refresh, and subsequent compact invalidation batches remain live +refresh hints rather than replicated state. Because there is no local replica, every read is a live relay round-trip to the machine — where the desktop renderer would hit its in-process cr-sqlite. Two @@ -625,7 +632,7 @@ adapter-side measures keep that from turning routine UI into a burst of redundant relay traffic. First, read commands and file-list requests pass through a short (3 s) **coalescing read cache**: concurrent identical reads join one in-flight request and reuse its result for the TTL window, while any -mutation or `changeset_batch`-driven invalidation drops the affected entries. +mutation or sync-driven invalidation drops the affected entries. Second, the PRs surface **batches** its reads: instead of separate `prs.list` / `prs.getForLane` / `listWithConflicts` round-trips it hydrates a single coalesced `prs.getMobileSnapshot` and derives the list views from it, and a @@ -633,8 +640,9 @@ coalesced `prs.getMobileSnapshot` and derives the list views from it, and a empty-list marker. These caches are freshness hints over the authoritative relay reads, not a persisted store. -Incoming `changeset_batch` envelopes are reduced to a set of table names in -`connection.ts`. `createInvalidationScheduler` maps those table names to +Incoming `invalidation_batch` envelopes already contain only table names and +database-version bounds; `connection.ts` validates their table-count and name +limits before emitting them. `createInvalidationScheduler` maps those names to domains such as lanes, sessions, chats, PRs, files, GitHub, and rebase. The adapter then refreshes through the appropriate remote command or sub-protocol: @@ -756,7 +764,7 @@ Ops checks after deploy: Projectless Chats therefore shows its runtime-backed Terminal control but not the desktop-only Browser button/profile. - No local file watcher. File-change events are synthesized from - changeset-driven invalidation and are coarser than desktop chokidar events. + sync-driven invalidation and are coarser than desktop chokidar events. - Some progress/live updates are invalidation-triggered snapshots rather than the exact desktop event stream. - Hosted HTTPS cannot dial LAN or Tailscale-IP `ws://` candidates. Use relay From 320f8bc4c70e2b3700119e1a5d7c0c561b621ebb Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:24:30 -0400 Subject: [PATCH 26/53] perf(runtime): keep inactive project scopes cold --- apps/ade-cli/src/cli.ts | 13 ++--- .../services/projects/projectScope.test.ts | 14 ++---- .../src/services/projects/projectScope.ts | 47 ------------------- docs/features/remote-runtime/README.md | 6 ++- 4 files changed, 13 insertions(+), 67 deletions(-) diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index c1b106eed..76548025c 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -15372,15 +15372,10 @@ async function runServe( if (!activeScope && sharedSyncListener) { await sharedSyncListener.ensureListening([DEFAULT_SYNC_HOST_PORT]); } - if (activeScope) { - const prewarmTimer = setImmediate(() => { - void scopeRegistry.prewarmRecentScopes({ - excludeProjectId: activeScope?.registryProjectId, - limit: 2, - }); - }); - prewarmTimer.unref?.(); - } + // A ProjectScope is a complete runtime (DB, search, chat, automation, + // polling, PTY, and sync services), not a lightweight metadata cache. + // Keep non-host projects lazy; the sync-host handoff keeps the old host + // authoritative while a newly selected project boots on demand. return activeScope ?? null; }; const disposeServeResources = async () => { diff --git a/apps/ade-cli/src/services/projects/projectScope.test.ts b/apps/ade-cli/src/services/projects/projectScope.test.ts index 13b9a7073..2b4a8a562 100644 --- a/apps/ade-cli/src/services/projects/projectScope.test.ts +++ b/apps/ade-cli/src/services/projects/projectScope.test.ts @@ -113,7 +113,7 @@ describe("ProjectScopeRegistry", () => { await scopeRegistry.disposeAll(); }); - it("prewarms only recent projects after starting an explicit system host", async () => { + it("keeps inactive recent projects cold after starting an explicit system host", async () => { const { registry, first, second } = createRegistry(); const projectsRoot = path.dirname(first.rootPath); const thirdProjectRoot = path.join(projectsRoot, "third"); @@ -157,17 +157,13 @@ describe("ProjectScopeRegistry", () => { }); await scopeRegistry.ensureSyncHost(first.projectId); - const warmed = await scopeRegistry.prewarmRecentScopes({ - excludeProjectId: first.projectId, - limit: 2, - }); + await new Promise((resolve) => setImmediate(resolve)); - expect(warmed).toEqual([recentSecond.projectId, recentThird.projectId]); expect(createAdeRuntimeMock.mock.calls.map(([args]) => args.projectRoot)).toEqual([ first.rootPath, - recentSecond.rootPath, - recentThird.rootPath, ]); + expect(scopeRegistry.getIfBooted(recentSecond.projectId)).toBeNull(); + expect(scopeRegistry.getIfBooted(recentThird.projectId)).toBeNull(); expect(createAdeRuntimeMock).not.toHaveBeenCalledWith( expect.objectContaining({ projectRoot: healthProbe.rootPath }), ); @@ -311,8 +307,6 @@ describe("ProjectScopeRegistry", () => { expect(scopeRegistry.getActiveSyncHostProjectId()).toBe(first.projectId); expect(firstSyncService.setHostDiscoveryEnabled).not.toHaveBeenCalledWith(false); expect(firstSyncService.setHostStartupEnabled).not.toHaveBeenCalledWith(false); - await expect(scopeRegistry.prewarmRecentScopes()).resolves.toEqual([]); - targetRuntime.resolve({ dispose: vi.fn(), syncService: secondSyncService }); await switching; expect(scopeRegistry.getActiveSyncHostProjectId()).toBe(second.projectId); diff --git a/apps/ade-cli/src/services/projects/projectScope.ts b/apps/ade-cli/src/services/projects/projectScope.ts index e40ae5567..35432e659 100644 --- a/apps/ade-cli/src/services/projects/projectScope.ts +++ b/apps/ade-cli/src/services/projects/projectScope.ts @@ -6,11 +6,6 @@ type SwitchSyncHostOptions = { deactivatePreviousHost?: boolean; }; -type PrewarmRecentScopesOptions = { - excludeProjectId?: ProjectId | null; - limit?: number; -}; - export class ProjectScope { readonly registryProjectId: ProjectId; readonly record: ProjectRecord; @@ -38,8 +33,6 @@ export class ProjectScopeRegistry { private syncHostTransitionDepth = 0; private syncHostTransitionTail: Promise = Promise.resolve(); private latestSyncHostTransitionId = 0; - private prewarmStarted = false; - private disposed = false; private readonly remoteCommandExecutor = { execute: async (payload: SyncCommandPayload): Promise => { return await this.executeRemoteCommand(payload); @@ -134,50 +127,10 @@ export class ProjectScopeRegistry { } async disposeAll(): Promise { - this.disposed = true; const projectIds = [...this.scopes.keys()]; await Promise.all(projectIds.map((projectId) => this.dispose(projectId))); } - /** - * One-shot background warm-up for at most two MRU project scopes. Warming - * never changes registry recency and never starts while a sync-host switch - * is active; the active host is already warm and should be excluded by the - * startup hook. - */ - async prewarmRecentScopes( - options: PrewarmRecentScopesOptions = {}, - ): Promise { - if (this.prewarmStarted || this.disposed || this.syncHostTransitionDepth > 0) { - return []; - } - this.prewarmStarted = true; - const limit = Math.min(2, Math.max(0, Math.trunc(options.limit ?? 2))); - const candidates = this.projectRegistry - .list() - .filter((record) => record.catalogVisibility === "recent") - .filter((record) => record.projectId !== options.excludeProjectId) - .filter((record) => !this.scopes.has(record.projectId)) - .sort((left, right) => { - const openedDelta = right.lastOpenedAt - left.lastOpenedAt; - return openedDelta !== 0 ? openedDelta : right.addedAt - left.addedAt; - }) - .slice(0, limit); - - const warmed: ProjectId[] = []; - for (const record of candidates) { - if (this.disposed || this.syncHostTransitionDepth > 0) break; - try { - await this.get(record.projectId, { touch: false }); - warmed.push(record.projectId); - } catch { - // Prewarming is opportunistic. A later real project open retries get() - // normally and surfaces its own actionable error. - } - } - return warmed; - } - async ensureSyncHost( projectId?: ProjectId, options?: SwitchSyncHostOptions, diff --git a/docs/features/remote-runtime/README.md b/docs/features/remote-runtime/README.md index 008b9649b..cc3ddb113 100644 --- a/docs/features/remote-runtime/README.md +++ b/docs/features/remote-runtime/README.md @@ -139,7 +139,11 @@ relay payload E2E encryption is planned security work. See the trust boundary in (64 icons / 12 MB per call), so a connected desktop can render real project logos without letting an oversized registry stall connection setup. - `apps/ade-cli/src/services/projects/` — machine project registry, - per-project service scope cache, and `projectIconResolver.ts` + lazy per-project service scope cache, and `projectIconResolver.ts`. Brain + startup boots only the authoritative sync-host project; other recent + projects stay cold until a project-scoped request or explicit handoff needs + them, because each scope owns a complete DB/search/chat/automation/PTY + runtime rather than lightweight catalog metadata. `projectIconResolver.ts` (`resolveRemoteProjectIcon`, an electron-free port of the desktop icon resolver: `.ade/ade.yaml` override + conventional icon/logo files + `index.html` ``, best-effort and capped at 2 MB to stay From 442de81fd42fe8bbac66392a048ecbbbb4fa91e0 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:26:56 -0400 Subject: [PATCH 27/53] docs(sync): align project artwork budgets --- docs/ARCHITECTURE.md | 2 +- docs/features/project-home/README.md | 7 ++++--- docs/features/remote-runtime/README.md | 11 ++++++----- docs/features/sync-and-multi-device/README.md | 7 ++++--- 4 files changed, 15 insertions(+), 12 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ac48e26cd..8654b43cb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -120,7 +120,7 @@ Product positioning and workflows live in [`docs/PRD.md`](../docs/PRD.md). This - **SSH stdio bridge (`ade rpc --stdio`)** — runs a single-session JSON-RPC runtime over stdin/stdout. This is what desktop's `RemoteConnectionPool` execs over SSH after `bootstrapRemoteRuntime` has uploaded a matching `ade-` binary. Exits when the SSH channel closes. - **Terminal client (`ade code`)** — launches the Ink + React Work chat (`apps/ade-cli/src/tuiClient/`). Defaults to attaching to the machine brain and will start it if the endpoint is missing. `ade --socket /path code` requires a specific endpoint; `ade code --embedded` keeps the in-process runtime fallback explicit. -**Machine and multi-project RPC.** The runtime exposes runtime-scoped methods (`projects.list/add/remove/touch`, `sync.*`, `runtime/info`, `machineInfo.get`, `runtimeEvents.subscribe/unsubscribe`) directly. Project-scoped operations dispatch through `ade/actions/call` with a `projectId`. Personal chats use the separate machine methods `personalChats.call` and `personalChats.streamEvents`; they never enter project dispatch and their capability/version is advertised by `runtime/info`. Per-project services are spun up lazily by `ProjectScopeRegistry` (`apps/ade-cli/src/services/projects/projectScope.ts`) which calls `createAdeRuntime({ projectRoot, ... })` the first time a project is touched. `PersonalChatScope` (`apps/ade-cli/src/services/personalChats/personalChatScope.ts`) lazily boots a chat-only runtime under `$ADE_HOME/personal-chats`, with distinct state and scratch roots and no project-registry entry. The project registry (`projectRegistry.ts`) is the durable list of known projects; `machineLayout.ts` resolves machine-wide paths under `$ADE_HOME`. Wire formats live in `apps/ade-cli/src/multiProjectRpcServer.ts`. Runtime-event replay is backed by `apps/ade-cli/src/eventBuffer.ts`, a bounded buffer (10k events, 16 MB total, 1 MB per retained event by default) that returns `eventEpoch`, `gap`, and `oldestCursor` so clients can detect daemon restarts or evicted history. `projects.list` resolves host-side project icons under a connect-path budget (64 icons / 12 MB per call); records outside the budget get a null icon instead of blocking connection setup. +**Machine and multi-project RPC.** The runtime exposes runtime-scoped methods (`projects.list/add/remove/touch`, `sync.*`, `runtime/info`, `machineInfo.get`, `runtimeEvents.subscribe/unsubscribe`) directly. Project-scoped operations dispatch through `ade/actions/call` with a `projectId`. Personal chats use the separate machine methods `personalChats.call` and `personalChats.streamEvents`; they never enter project dispatch and their capability/version is advertised by `runtime/info`. Per-project services are spun up lazily by `ProjectScopeRegistry` (`apps/ade-cli/src/services/projects/projectScope.ts`) which calls `createAdeRuntime({ projectRoot, ... })` the first time a project is touched. `PersonalChatScope` (`apps/ade-cli/src/services/personalChats/personalChatScope.ts`) lazily boots a chat-only runtime under `$ADE_HOME/personal-chats`, with distinct state and scratch roots and no project-registry entry. The project registry (`projectRegistry.ts`) is the durable list of known projects; `machineLayout.ts` resolves machine-wide paths under `$ADE_HOME`. Wire formats live in `apps/ade-cli/src/multiProjectRpcServer.ts`. Runtime-event replay is backed by `apps/ade-cli/src/eventBuffer.ts`, a bounded buffer (10k events, 16 MB total, 1 MB per retained event by default) that returns `eventEpoch`, `gap`, and `oldestCursor` so clients can detect daemon restarts or evicted history. `projects.list` resolves at most 24 host-side project icons within 750 ms, with 128 KiB per-icon and 512 KiB aggregate wire caps; records outside those budgets get a null icon instead of blocking connection setup. **Runtime-side services** (under `apps/ade-cli/src/services/`): diff --git a/docs/features/project-home/README.md b/docs/features/project-home/README.md index e52605204..ef8f1f009 100644 --- a/docs/features/project-home/README.md +++ b/docs/features/project-home/README.md @@ -624,9 +624,10 @@ stamps each record with an `icon: { dataUrl, sourcePath, mimeType }` produced by `resolveRemoteProjectIcon` (`apps/ade-cli/src/services/projects/projectIconResolver.ts`), a compact electron-free port of the desktop resolver that covers the `.ade/ade.yaml` override, the conventional icon/logo files, and an -`index.html` `` (resolution is best-effort and -capped at 2 MB so it stays inline-safe on the wire; a failure for one -project degrades to a null icon rather than breaking the list). That +`index.html` `` (resolution is best-effort, rendered as a +64 px thumbnail, and capped at 128 KiB per icon; a failure or exhausted +catalog budget degrades that project to a null icon rather than breaking the +list). That icon rides through `RemoteRuntimeProjectRecord.icon` → `OpenProjectBinding.iconDataUrl`. The desktop persists that data URL on both `globalState.lastRemoteProjectBinding` and the matching remote recent diff --git a/docs/features/remote-runtime/README.md b/docs/features/remote-runtime/README.md index cc3ddb113..f87ce218a 100644 --- a/docs/features/remote-runtime/README.md +++ b/docs/features/remote-runtime/README.md @@ -135,9 +135,10 @@ relay payload E2E encryption is planned security work. See the trust boundary in cross-machine handoff setup mutates the machine. `runtimeEvents.*` replies include `eventEpoch`, `gap`, and `oldestCursor` from the runtime's bounded event buffer. `projects.list` inlines host-resolved icons, with - `dataUrl`, `sourcePath`, and `mimeType` fields, under a connect-path budget - (64 icons / 12 MB per call), so a connected desktop can render real project - logos without letting an oversized registry stall connection setup. + `dataUrl`, `sourcePath`, and `mimeType` fields, under a 24-icon / 750 ms + connect-path budget with 128 KiB per-icon and 512 KiB aggregate wire caps, + so a connected desktop can render real project logos without letting an + oversized registry stall connection setup. - `apps/ade-cli/src/services/projects/` — machine project registry, lazy per-project service scope cache, and `projectIconResolver.ts`. Brain startup boots only the authoritative sync-host project; other recent @@ -146,8 +147,8 @@ relay payload E2E encryption is planned security work. See the trust boundary in runtime rather than lightweight catalog metadata. `projectIconResolver.ts` (`resolveRemoteProjectIcon`, an electron-free port of the desktop icon resolver: `.ade/ade.yaml` override + conventional icon/logo files + - `index.html` ``, best-effort and capped at 2 MB to stay - inline-safe on the wire). + `index.html` ``, best-effort and rendered to a 64 px + thumbnail capped at 128 KiB on the wire). - `apps/ade-cli/scripts/build-static.mjs` — produces the static `ade-` SEA binary and the `.native.tar.gz` of native modules, resolves the runtime version from the CLI / desktop package metadata, and diff --git a/docs/features/sync-and-multi-device/README.md b/docs/features/sync-and-multi-device/README.md index ae5998f4d..f00076bf1 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -314,9 +314,10 @@ Runtime support files outside `services/sync/`: surface for `projects.*`, `sync.*`, `runtimeEvents.*`, project-scoped `ade/actions/call`, and project-independent `personalChats.call` / `personalChats.streamEvents`. Runtime-event subscribe replies include the gap - fields above; `projects.list` resolves host-side icons under a connect-path - budget (64 icons / 12 MB per call) so large project registries cannot stall - remote desktop or mobile catalog setup just to inline artwork. + fields above; `projects.list` resolves at most 24 host-side icons within + 750 ms, with 128 KiB per-icon and 512 KiB aggregate wire caps, so large + project registries cannot stall remote desktop or mobile catalog setup just + to inline artwork. `projects.getHandoffStoragePreflight` checks the destination parent path, write access, target collision, free space, and destination-local Git access before the desktop offers to clone a missing handoff repository. `projects.add` From 18bac25b6899fef28be1cddf4dfb591155fe0cca Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:32:25 -0400 Subject: [PATCH 28/53] docs(ios): keep connection status route neutral --- docs/features/sync-and-multi-device/ios-companion.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index b5651f7d8..7b1520f07 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -664,10 +664,10 @@ Sources: `apps/ios/ADE/Services/SyncService.swift` and same-account requirement without consuming the reconnect retry budget; LAN and Tailscale attempts remain available. `accountOwnerId` separately marks profiles created by account adoption and therefore deleted on owner loss. - When the ACTIVE connection is a relay route, the Settings connection - header shows one quiet line: "Using ADE relay. For faster, more - stable sync, connect both devices with Tailscale." `reconnectIfPossible` is - the single connection-attempt owner: socket delegate failures, path changes, + The primary Settings status stays route-neutral (for example, "Connected in + 0.3s"); the host-observed LAN/Tailscale/relay route remains available in the + diagnostics row for troubleshooting. `reconnectIfPossible` is the single + connection-attempt owner: socket delegate failures, path changes, foreground return, heartbeat silence, and failed liveness probes all converge there. Overlapping wake-ups are coalesced, delayed path tasks carry a connection-generation guard, and an automatic reconnect never tears down From 0b36a44e30820e006dbb35031f48d8d9bb56db31 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:37:03 -0400 Subject: [PATCH 29/53] fix(runtime): harden deferred cleanup work --- .../services/github/githubService.test.ts | 27 +++ .../src/main/services/github/githubService.ts | 18 +- .../lanes/laneListSnapshotService.test.ts | 42 ++++ .../services/lanes/laneListSnapshotService.ts | 61 +++-- .../src/main/services/pty/ptyService.test.ts | 157 ++++++++++++- .../src/main/services/pty/ptyService.ts | 218 +++++++++++++++--- 6 files changed, 464 insertions(+), 59 deletions(-) diff --git a/apps/desktop/src/main/services/github/githubService.test.ts b/apps/desktop/src/main/services/github/githubService.test.ts index ad287b889..d44dfbab3 100644 --- a/apps/desktop/src/main/services/github/githubService.test.ts +++ b/apps/desktop/src/main/services/github/githubService.test.ts @@ -794,6 +794,33 @@ describe("githubService.getStatus", () => { now.mockRestore(); }); + it("does not reuse a project-local status after the shared gh token changes", async () => { + stubOriginRemote(); + delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; + let token = "gho_shared_token_alice"; + const ghAuthTokenProvider = vi.fn(async () => ({ + token, + ghCliPath: "/opt/homebrew/bin/gh", + ghAuthError: null, + })); + mockFetch.mockImplementation(async (_input: string | URL, init?: RequestInit) => { + const authorization = (init?.headers as Record | undefined)?.authorization ?? ""; + return jsonResponse(200, { + login: authorization.includes("gho_shared_token_bob") ? "bob" : "alice", + }); + }); + const first = makeService({ ghAuthTokenProvider }); + const second = makeService({ ghAuthTokenProvider }); + + await expect(first.getStatus()).resolves.toMatchObject({ userLogin: "alice" }); + token = "gho_shared_token_bob"; + await expect(second.getStatus({ forceRefresh: true })).resolves.toMatchObject({ userLogin: "bob" }); + await expect(first.getStatus()).resolves.toMatchObject({ userLogin: "bob" }); + + expect(ghAuthTokenProvider).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + it("clearing a stored PAT falls back to gh auth", async () => { stubOriginRemote(); delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; diff --git a/apps/desktop/src/main/services/github/githubService.ts b/apps/desktop/src/main/services/github/githubService.ts index 46fde11c5..f618ecc73 100644 --- a/apps/desktop/src/main/services/github/githubService.ts +++ b/apps/desktop/src/main/services/github/githubService.ts @@ -81,10 +81,14 @@ function processGithubAuthState(provider: GitHubCliAuthProvider): ProcessGithubA } function githubStatusProbeKey(token: string, repo: GitHubRepoRef | null): string { - const tokenDigest = createHash("sha256").update(token).digest("hex").slice(0, 16); + const tokenDigest = githubTokenDigest(token); return `${tokenDigest}:${repo ? `${repo.owner.toLowerCase()}/${repo.name.toLowerCase()}` : "no-repo"}`; } +function githubTokenDigest(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + type GitHubTokenLookup = GitHubCliAuthResult & { source: GitHubAuthSource; patTokenStored: boolean; @@ -433,6 +437,7 @@ export function createGithubService({ const ghAuthProvider = ghAuthTokenProvider ?? readGitHubCliAuthToken; const sharedGhAuth = processGithubAuthState(ghAuthProvider); let statusInFlight: Promise | null = null; + let cachedStatusTokenDigest: string | null = null; const readMachineToken = (): string | null => { if (!credentialStore) return null; @@ -1024,6 +1029,7 @@ export function createGithubService({ if (opts.forceRefresh) { cachedStatus = null; cachedAt = 0; + cachedStatusTokenDigest = null; sharedGhAuth.authCache = null; sharedGhAuth.statusCache.clear(); } @@ -1050,10 +1056,12 @@ export function createGithubService({ connected: false, }; cachedAt = Date.now(); + cachedStatusTokenDigest = null; return cachedStatus; } const now = Date.now(); + const tokenDigest = githubTokenDigest(token); if (cachedStatus && now - cachedAt < 30_000 && cachedStatus.tokenStored) { // Still re-detect repo and re-evaluate `connected` so a remote change is reflected. const repoChanged = @@ -1062,9 +1070,10 @@ export function createGithubService({ const authSourceChanged = cachedStatus.authSource !== tokenLookup.source || cachedStatus.patTokenStored !== tokenLookup.patTokenStored; - if (authSourceChanged) { + if (authSourceChanged || cachedStatusTokenDigest !== tokenDigest) { cachedStatus = null; cachedAt = 0; + cachedStatusTokenDigest = null; } else { // If the repo just changed we can't trust the cached probe result. const repoAccessOk = repoChanged ? null : cachedStatus.repoAccessOk; @@ -1132,6 +1141,7 @@ export function createGithubService({ connected, }; cachedAt = now; + cachedStatusTokenDigest = tokenDigest; return cachedStatus; } catch (error) { logger.warn("github.token_validation_failed", { error: error instanceof Error ? error.message : String(error) }); @@ -1154,6 +1164,7 @@ export function createGithubService({ connected: false, }; cachedAt = now; + cachedStatusTokenDigest = tokenDigest; return cachedStatus; } }; @@ -1587,6 +1598,7 @@ export function createGithubService({ cachedStatus = null; cachedAt = 0; + cachedStatusTokenDigest = null; return { state: resultState, @@ -1623,6 +1635,7 @@ export function createGithubService({ tokenDecryptionFailed = false; cachedStatus = null; cachedAt = 0; + cachedStatusTokenDigest = null; sharedGhAuth.authCache = null; sharedGhAuth.statusCache.clear(); }, @@ -1632,6 +1645,7 @@ export function createGithubService({ tokenDecryptionFailed = false; cachedStatus = null; cachedAt = 0; + cachedStatusTokenDigest = null; sharedGhAuth.authCache = null; sharedGhAuth.statusCache.clear(); }, diff --git a/apps/desktop/src/main/services/lanes/laneListSnapshotService.test.ts b/apps/desktop/src/main/services/lanes/laneListSnapshotService.test.ts index 307e95292..d6fa8d852 100644 --- a/apps/desktop/src/main/services/lanes/laneListSnapshotService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneListSnapshotService.test.ts @@ -246,6 +246,48 @@ describe("laneListSnapshotService", () => { } }); + it("publishes completed decorations when another optional enrichment hangs", async () => { + vi.useFakeTimers(); + try { + const services = { + ...makeHarness({ + id: "session-1", + laneId: "lane-1", + status: "running", + runtimeState: "running", + toolType: "shell", + lastOutputPreview: "working", + }), + rebaseSuggestionService: { + listSuggestions: vi.fn().mockResolvedValue([{ laneId: "lane-1", behindCount: 3 }]), + }, + conflictService: { + getBatchAssessment: vi.fn(() => new Promise(() => {})), + }, + }; + const pending = buildLaneListSnapshots( + services as any, + [{ id: "lane-1", name: "Lane 1", laneType: "worktree", archivedAt: null }] as any, + { + includeConflictStatus: true, + includeRebaseSuggestions: true, + includeAutoRebaseStatus: false, + optionalEnrichmentBudgetMs: 25, + }, + ); + + await vi.advanceTimersByTimeAsync(25); + await expect(pending).resolves.toEqual([ + expect.objectContaining({ + rebaseSuggestion: expect.objectContaining({ behindCount: 3 }), + conflictStatus: null, + }), + ]); + } finally { + vi.useRealTimers(); + } + }); + it("coalesces repeated snapshot calls onto one optional enrichment per lane set", async () => { vi.useFakeTimers(); try { diff --git a/apps/desktop/src/main/services/lanes/laneListSnapshotService.ts b/apps/desktop/src/main/services/lanes/laneListSnapshotService.ts index d72fde2f5..3a96e01bd 100644 --- a/apps/desktop/src/main/services/lanes/laneListSnapshotService.ts +++ b/apps/desktop/src/main/services/lanes/laneListSnapshotService.ts @@ -61,6 +61,7 @@ type OptionalLaneListEnrichment = [ type OptionalLaneListEnrichmentCacheEntry = { last: OptionalLaneListEnrichment; inFlight: Promise | null; + generation: number; }; const OPTIONAL_LANE_ENRICHMENT_CACHE_MAX_ENTRIES = 8; @@ -293,7 +294,7 @@ export async function buildLaneListSnapshots( const optionalCacheKey = optionalLaneEnrichmentKey(lanes, options); let optionalEntry = optionalCache.get(optionalCacheKey); if (!optionalEntry) { - optionalEntry = { last: [[], [], null], inFlight: null }; + optionalEntry = { last: [[], [], null], inFlight: null, generation: 0 }; optionalCache.set(optionalCacheKey, optionalEntry); while (optionalCache.size > OPTIONAL_LANE_ENRICHMENT_CACHE_MAX_ENTRIES) { const oldestKey = optionalCache.keys().next().value as string | undefined; @@ -301,44 +302,67 @@ export async function buildLaneListSnapshots( optionalCache.delete(oldestKey); } } - const previousOptionalEnrichment = optionalEntry.last; - if (!optionalEntry.inFlight) { - const work: Promise = Promise.all([ - options.includeRebaseSuggestions === false + const cacheEntry = optionalEntry; + if (!cacheEntry.inFlight) { + const generation = cacheEntry.generation + 1; + cacheEntry.generation = generation; + const rebaseWork = (options.includeRebaseSuggestions === false ? Promise.resolve([]) : timePhase("rebase_suggestions", () => Promise.resolve() .then(() => args.rebaseSuggestionService?.listSuggestions({ lanes }) ?? []) - .catch(() => previousOptionalEnrichment[0])), - options.includeAutoRebaseStatus === false + .catch(() => cacheEntry.last[0]))) + .then((value) => { + if (cacheEntry.generation === generation) { + cacheEntry.last = [value, cacheEntry.last[1], cacheEntry.last[2]]; + } + return value; + }); + const autoRebaseWork = (options.includeAutoRebaseStatus === false ? Promise.resolve([]) : timePhase("auto_rebase_statuses", () => Promise.resolve() .then(() => args.autoRebaseService?.listStatuses({ lanes }) ?? []) - .catch(() => previousOptionalEnrichment[1])), - options.includeConflictStatus === false + .catch(() => cacheEntry.last[1]))) + .then((value) => { + if (cacheEntry.generation === generation) { + cacheEntry.last = [cacheEntry.last[0], value, cacheEntry.last[2]]; + } + return value; + }); + const conflictWork = (options.includeConflictStatus === false ? Promise.resolve(null) : timePhase("conflict_assessment", () => Promise.resolve() .then(() => args.conflictService?.getBatchAssessment({ lanes }) ?? null) - .catch(() => previousOptionalEnrichment[2])), + .catch(() => cacheEntry.last[2]))) + .then((value) => { + if (cacheEntry.generation === generation) { + cacheEntry.last = [cacheEntry.last[0], cacheEntry.last[1], value]; + } + return value; + }); + const work: Promise = Promise.all([ + rebaseWork, + autoRebaseWork, + conflictWork, ]); - optionalEntry.inFlight = work; + cacheEntry.inFlight = work; const retryTimer = setTimeout(() => { - if (optionalEntry?.inFlight === work) optionalEntry.inFlight = null; + if (cacheEntry.inFlight === work) cacheEntry.inFlight = null; }, OPTIONAL_LANE_ENRICHMENT_RETRY_AFTER_MS); retryTimer.unref?.(); void work.then((result) => { // A watchdog may release a genuinely hung probe so a newer scan can // start. Never let that older probe overwrite newer last-known data if // it eventually settles out of order. - if (optionalEntry?.inFlight === work) optionalEntry.last = result; + if (cacheEntry.inFlight === work) cacheEntry.last = result; }).finally(() => { clearTimeout(retryTimer); - if (optionalEntry?.inFlight === work) optionalEntry.inFlight = null; + if (cacheEntry.inFlight === work) cacheEntry.inFlight = null; }); } - const optionalEnrichment = optionalEntry.inFlight ?? Promise.resolve(optionalEntry.last); + const optionalEnrichment = cacheEntry.inFlight ?? Promise.resolve(cacheEntry.last); const optionalBudgetMs = Math.max( 0, Math.floor(options.optionalEnrichmentBudgetMs ?? OPTIONAL_LANE_ENRICHMENT_BUDGET_MS), @@ -361,11 +385,8 @@ export async function buildLaneListSnapshots( optionalWithinBudget, ]); if (optionalBudgetTimer) clearTimeout(optionalBudgetTimer); - const [rebaseSuggestions, autoRebaseStatuses, batchAssessment] = optionalResult ?? [ - previousOptionalEnrichment[0], - previousOptionalEnrichment[1], - previousOptionalEnrichment[2], - ]; + const [rebaseSuggestions, autoRebaseStatuses, batchAssessment] = optionalResult + ?? cacheEntry.last; if (optionalResult === null) { args.logger.info("lanes.listSnapshots.optional_enrichment_deferred", { laneCount: lanes.length, diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index 43c24c64e..9a72b9f36 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -189,6 +189,13 @@ const mocks = vi.hoisted(() => { enabled: true; } | null> => null), execFileSync: vi.fn((_file?: unknown, _args?: unknown) => ""), + execFile: vi.fn((...args: unknown[]) => { + const callback = args.at(-1); + if (typeof callback === "function") { + (callback as (...callbackArgs: unknown[]) => void)(null, "", ""); + } + return { kill: vi.fn() }; + }), spawnSync: vi.fn(() => ({ status: 1, stdout: "", stderr: "" })), }; }); @@ -236,6 +243,7 @@ vi.mock("node:crypto", () => ({ })); vi.mock("node:child_process", () => ({ + execFile: mocks.execFile, execFileSync: mocks.execFileSync, spawnSync: mocks.spawnSync, })); @@ -484,6 +492,13 @@ describe("ptyService", () => { mocks.derivePreviewFromChunk.mockReturnValue({ nextLine: "", preview: "preview" }); mocks.resolveOpenCodeBinaryPath.mockReturnValue(null); mocks.resolveCodexComputerUseMcpConfig.mockResolvedValue(null); + mocks.execFile.mockImplementation((...args: unknown[]) => { + const callback = args.at(-1); + if (typeof callback === "function") { + (callback as (...callbackArgs: unknown[]) => void)(null, "", ""); + } + return { kill: vi.fn() }; + }); mocks.spawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" }); }); @@ -4329,6 +4344,7 @@ describe("ptyService", () => { const { service, mockPty, sessionService, broadcastExit } = createHarness(); const { ptyId, sessionId } = await service.create({ laneId: "lane-1", title: "d", cols: 80, rows: 24 }); service.dispose({ ptyId }); + await Promise.resolve(); expect(mockPty.kill).toHaveBeenCalled(); expect(sessionService.end).toHaveBeenCalledWith( expect.objectContaining({ sessionId, status: "disposed" }), @@ -6439,6 +6455,7 @@ describe("ptyService", () => { mocks.spawnSync.mockClear(); service.signalTerminal({ chatSessionId: "chat-signal", signal: "SIGTERM" }); + await Promise.resolve(); expect(mockPty.kill).toHaveBeenCalledWith("SIGTERM"); expect(kill).toHaveBeenCalledWith(-12345, "SIGTERM"); expect(mocks.spawnSync).not.toHaveBeenCalled(); @@ -6467,12 +6484,56 @@ describe("ptyService", () => { } }); + it("force-kills a stubborn Windows PTY tree without blocking the main process", async () => { + vi.useFakeTimers(); + setPlatform("win32"); + const kill = vi.spyOn(process, "kill").mockImplementation(() => true as const); + try { + const { service, mockPty } = createChatHarness(); + await service.create({ + laneId: "lane-1", + title: "Signal", + cols: 80, + rows: 24, + chatSessionId: "chat-signal-windows-stubborn", + }); + + service.signalTerminal({ + chatSessionId: "chat-signal-windows-stubborn", + signal: "SIGTERM", + }); + expect(mockPty.kill).toHaveBeenCalledWith("SIGTERM"); + + await vi.advanceTimersByTimeAsync(1_500); + expect(kill).toHaveBeenCalledWith(12345, 0); + expect(mocks.execFile).toHaveBeenCalledWith( + "taskkill", + ["/pid", "12345", "/T", "/F"], + expect.objectContaining({ windowsHide: true }), + expect.any(Function), + ); + expect(mocks.spawnSync).not.toHaveBeenCalled(); + } finally { + setPlatform(originalPlatform); + kill.mockRestore(); + vi.useRealTimers(); + } + }); + it("force-kills a live PTY process group after its leader exits", async () => { vi.useFakeTimers(); - const kill = vi.spyOn(process, "kill").mockImplementation(((pid: number, signal?: number | NodeJS.Signals) => { - if (pid === -12345 && signal === 0) return true; - return true; - }) as typeof process.kill); + mocks.execFile.mockImplementation((...args: unknown[]) => { + const callback = args.at(-1); + if (typeof callback === "function") { + (callback as (...callbackArgs: unknown[]) => void)( + null, + "12345 1 12345 12345\n", + "", + ); + } + return { kill: vi.fn() }; + }); + const kill = vi.spyOn(process, "kill").mockImplementation(() => true as const); try { const { service } = createChatHarness(); await service.create({ @@ -6486,7 +6547,6 @@ describe("ptyService", () => { service.signalTerminal({ chatSessionId: "chat-signal-group", signal: "SIGTERM" }); await vi.advanceTimersByTimeAsync(1_500); - expect(kill).toHaveBeenCalledWith(-12345, 0); expect(kill).toHaveBeenCalledWith(-12345, "SIGKILL"); } finally { kill.mockRestore(); @@ -6494,6 +6554,93 @@ describe("ptyService", () => { } }); + it("kills a foreground job group after the PTY shell group exits", async () => { + vi.useFakeTimers(); + let scanCount = 0; + mocks.execFile.mockImplementation((...args: unknown[]) => { + scanCount += 1; + const callback = args.at(-1); + if (typeof callback === "function") { + (callback as (...callbackArgs: unknown[]) => void)( + null, + scanCount === 1 + ? [ + "12345 1 12345 23456", + "23456 12345 23456 23456", + "34567 23456 34567 -1", + ].join("\n") + : [ + "23456 1 23456 23456", + "34567 23456 34567 -1", + ].join("\n"), + "", + ); + } + return { kill: vi.fn() }; + }); + const kill = vi.spyOn(process, "kill").mockImplementation(() => true as const); + try { + const { service } = createChatHarness(); + await service.create({ + laneId: "lane-1", + title: "Signal", + cols: 80, + rows: 24, + chatSessionId: "chat-signal-foreground-group", + }); + + service.signalTerminal({ + chatSessionId: "chat-signal-foreground-group", + signal: "SIGTERM", + }); + await vi.advanceTimersByTimeAsync(0); + expect(kill).toHaveBeenCalledWith(-23456, "SIGTERM"); + expect(kill).toHaveBeenCalledWith(-34567, "SIGTERM"); + + await vi.advanceTimersByTimeAsync(1_500); + expect(kill).toHaveBeenCalledWith(-23456, "SIGKILL"); + expect(kill).toHaveBeenCalledWith(23456, "SIGKILL"); + expect(kill).toHaveBeenCalledWith(-34567, "SIGKILL"); + expect(kill).toHaveBeenCalledWith(34567, "SIGKILL"); + expect(mocks.execFile).toHaveBeenCalledWith( + "ps", + ["-axo", "pid=,ppid=,pgid=,tpgid="], + expect.any(Object), + expect.any(Function), + ); + } finally { + kill.mockRestore(); + vi.useRealTimers(); + } + }); + + it("signals the PTY within a bounded delay when the process scan stalls", async () => { + vi.useFakeTimers(); + mocks.execFile.mockImplementation(() => ({ kill: vi.fn() })); + const kill = vi.spyOn(process, "kill").mockImplementation(() => true as const); + try { + const { service, mockPty } = createChatHarness(); + await service.create({ + laneId: "lane-1", + title: "Signal", + cols: 80, + rows: 24, + chatSessionId: "chat-signal-scan-stall", + }); + + service.signalTerminal({ chatSessionId: "chat-signal-scan-stall", signal: "SIGTERM" }); + await vi.advanceTimersByTimeAsync(99); + expect(mockPty.kill).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(kill).toHaveBeenCalledWith(-12345, "SIGTERM"); + expect(mockPty.kill).toHaveBeenCalledWith("SIGTERM"); + } finally { + kill.mockRestore(); + vi.useRealTimers(); + } + }); + it("fails loudly when chat terminal calls cannot resolve a target", async () => { const { service } = createChatHarness(); diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index f80cbf464..a18ac9f87 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { randomUUID } from "node:crypto"; -import { spawnSync } from "node:child_process"; +import { execFile, spawnSync } from "node:child_process"; import type { IPty, IWindowsPtyForkOptions } from "node-pty"; import type * as ptyNs from "node-pty"; import * as HeadlessXterm from "@xterm/headless"; @@ -165,18 +165,12 @@ const AGENT_CLI_READY_TIMEOUT_MS = 20_000; const AGENT_CLI_READY_POLL_MS = 100; const AGENT_CLI_READY_QUIET_MS = 600; const PTY_PROCESS_TREE_KILL_DELAY_MS = 1500; +const PTY_PROCESS_SCAN_SIGNAL_DELAY_MS = 100; +const PTY_PROCESS_SCAN_TIMEOUT_MS = 250; +const PTY_PROCESS_SCAN_MAX_BYTES = 512 * 1024; let cachedOpenCodeReplayResumeSupport: boolean | null = null; -function isPidLive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - function killPidBestEffort(pid: number, signal: NodeJS.Signals): void { if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid) return; try { @@ -200,13 +194,110 @@ function killPtyProcessGroupBestEffort(rootPid: number, signal: NodeJS.Signals): } } -function isPtyProcessGroupLive(rootPid: number): boolean { - if (process.platform === "win32" || !Number.isFinite(rootPid) || rootPid <= 0) return false; - try { - process.kill(-Math.trunc(rootPid), 0); - return true; - } catch { - return false; +type PtyTreeProcess = { + pid: number; + parentPid: number; + processGroupId: number; + foregroundProcessGroupId: number; +}; + +function parsePtyTreeProcesses( + stdout: string, + rootPid: number, + knownProcessGroupIds: ReadonlySet = new Set(), +): PtyTreeProcess[] { + const rows = stdout.split(/\r?\n/).flatMap((line) => { + const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(-?\d+)\s*$/); + if (!match) return []; + const [pid, parentPid, processGroupId, foregroundProcessGroupId] = match.slice(1).map((value) => + Number.parseInt(value, 10) + ); + if (![pid, parentPid, processGroupId, foregroundProcessGroupId].every(Number.isFinite)) return []; + return [{ pid, parentPid, processGroupId, foregroundProcessGroupId }]; + }); + const selectedPids = new Set( + rows.some((row) => row.pid === rootPid) ? [rootPid] : [], + ); + const selectedProcessGroups = new Set(knownProcessGroupIds); + for (const row of rows) { + if (knownProcessGroupIds.has(row.processGroupId)) selectedPids.add(row.pid); + } + let added = true; + while (added) { + added = false; + for (const row of rows) { + if ( + !selectedPids.has(row.pid) + && (selectedPids.has(row.parentPid) || selectedProcessGroups.has(row.processGroupId)) + ) { + selectedPids.add(row.pid); + added = true; + } + if (!selectedPids.has(row.pid)) continue; + if (row.processGroupId > 1 && !selectedProcessGroups.has(row.processGroupId)) { + selectedProcessGroups.add(row.processGroupId); + added = true; + } + if ( + row.foregroundProcessGroupId > 1 + && !selectedProcessGroups.has(row.foregroundProcessGroupId) + ) { + selectedProcessGroups.add(row.foregroundProcessGroupId); + added = true; + } + } + } + return rows.filter((row) => + selectedPids.has(row.pid) || selectedProcessGroups.has(row.processGroupId) + ); +} + +function collectPtyTreeProcesses( + rootPid: number, + knownProcessGroupIds: ReadonlySet = new Set(), +): Promise { + if (process.platform === "win32" || !Number.isFinite(rootPid) || rootPid <= 0) { + return Promise.resolve([]); + } + return new Promise((resolve) => { + try { + execFile( + "ps", + ["-axo", "pid=,ppid=,pgid=,tpgid="], + { + encoding: "utf8", + timeout: PTY_PROCESS_SCAN_TIMEOUT_MS, + maxBuffer: PTY_PROCESS_SCAN_MAX_BYTES, + windowsHide: true, + }, + (error, stdout) => { + resolve(error + ? [] + : parsePtyTreeProcesses(String(stdout ?? ""), rootPid, knownProcessGroupIds)); + }, + ); + } catch { + resolve([]); + } + }); +} + +function signalPtyTreeProcesses( + processes: readonly PtyTreeProcess[], + signal: NodeJS.Signals, +): void { + const processGroups = new Set(processes + .map((entry) => entry.processGroupId) + .filter((processGroupId) => processGroupId > 1 && processGroupId !== process.pid)); + for (const processGroupId of processGroups) { + try { + process.kill(-processGroupId, signal); + } catch { + // A group may have exited between the process scan and signal. + } + } + for (const { pid } of [...processes].reverse()) { + killPidBestEffort(pid, signal); } } @@ -574,28 +665,91 @@ function terminatePtyProcessTree( const rootPid = typeof entry.pty.pid === "number" && Number.isFinite(entry.pty.pid) ? Math.trunc(entry.pty.pid) : null; - const signaledProcessGroup = rootPid - ? killPtyProcessGroupBestEffort(rootPid, signal) - : false; - try { - entry.pty.kill(signal); - } catch { - if (rootPid) killPidBestEffort(rootPid, signal); + if (!rootPid) { + try { + entry.pty.kill(signal); + } catch { + // No numeric PID is available for a direct fallback. + } + return; + } + if (process.platform === "win32") { + try { + entry.pty.kill(signal); + } catch { + killPidBestEffort(rootPid, signal); + } + if (signal === "SIGKILL") return; + const timer = setTimeout(() => { + try { + process.kill(rootPid, 0); + } catch { + return; + } + try { + execFile( + "taskkill", + ["/pid", String(rootPid), "/T", "/F"], + { timeout: 5_000, maxBuffer: 64 * 1024, windowsHide: true }, + (error) => { + if (error) return; + logger.warn("pty.process_tree_force_killed", { + sessionId: entry.sessionId, + toolType: entry.toolTypeHint, + rootPid, + pids: [rootPid], + }); + }, + ); + } catch { + // taskkill may be unavailable; the initial node-pty signal still ran. + } + }, PTY_PROCESS_TREE_KILL_DELAY_MS); + timer.unref?.(); + return; } - if (signal === "SIGKILL" || !rootPid) return; + + let initialProcesses: PtyTreeProcess[] = []; + let initialSignalDispatched = false; + const dispatchInitialSignal = (processes: readonly PtyTreeProcess[]) => { + if (initialSignalDispatched) return; + initialSignalDispatched = true; + killPtyProcessGroupBestEffort(rootPid, signal); + try { + entry.pty.kill(signal); + } catch { + killPidBestEffort(rootPid, signal); + } + signalPtyTreeProcesses(processes, signal); + }; + const initialProcessScan = collectPtyTreeProcesses(rootPid); + const signalFallbackTimer = setTimeout(() => { + dispatchInitialSignal([]); + }, PTY_PROCESS_SCAN_SIGNAL_DELAY_MS); + signalFallbackTimer.unref?.(); + void initialProcessScan.then((processes) => { + initialProcesses = processes; + clearTimeout(signalFallbackTimer); + const signalAlreadyDispatched = initialSignalDispatched; + dispatchInitialSignal(processes); + if (signalAlreadyDispatched) signalPtyTreeProcesses(processes, signal); + }); + if (signal === "SIGKILL") return; const timer = setTimeout(() => { - const processGroupLive = signaledProcessGroup && isPtyProcessGroupLive(rootPid); - const rootLive = !signaledProcessGroup && isPidLive(rootPid); - if (processGroupLive || rootLive) { - if (processGroupLive) killPtyProcessGroupBestEffort(rootPid, "SIGKILL"); - killPidBestEffort(rootPid, "SIGKILL"); + const knownProcessGroupIds = new Set(initialProcesses.flatMap((process) => [ + process.processGroupId, + process.foregroundProcessGroupId, + ]).filter((processGroupId) => processGroupId > 1)); + void collectPtyTreeProcesses(rootPid, knownProcessGroupIds).then((currentProcesses) => { + if (currentProcesses.length === 0) return; + signalPtyTreeProcesses(currentProcesses, "SIGKILL"); logger.warn("pty.process_tree_force_killed", { sessionId: entry.sessionId, toolType: entry.toolTypeHint, rootPid, - pids: [rootPid], + pids: Array.from(new Set(currentProcesses.map(({ pid }) => pid))), }); - } + }); }, PTY_PROCESS_TREE_KILL_DELAY_MS); timer.unref?.(); } From 9aa34dcf423ca17130d5b60551a5c1cd1bcb652b Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:40:29 -0400 Subject: [PATCH 30/53] fix(web): preserve hydration ordering under load --- .../adapter/__tests__/adapter.test.ts | 16 ++ .../webclient/adapter/infra/invalidation.ts | 7 +- .../webclient/shell/WebClientRoot.tsx | 45 +++- .../shell/__tests__/WebClientRoot.test.tsx | 93 +++++++ .../webclient/sync/__tests__/sync.test.ts | 230 ++++++++++++++++++ .../src/renderer/webclient/sync/client.ts | 20 +- .../src/renderer/webclient/sync/connection.ts | 196 +++++++++------ 7 files changed, 512 insertions(+), 95 deletions(-) diff --git a/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts b/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts index 04c3fc189..58c22a78e 100644 --- a/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts +++ b/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts @@ -404,6 +404,22 @@ describe("createAdeWebAdapter", () => { adapter.dispose(); }); + it("drains invalidations on a bounded cadence while writes stay continuous", async () => { + vi.useFakeTimers(); + const adapter = createAdeWebAdapter(fake.asClient()); + adapter.bindProject(project, "project-1"); + const lifecycleEvents: unknown[] = []; + adapter.ade.lanes.onLifecycleEvent((event) => lifecycleEvents.push(event)); + + for (let index = 0; index < 8; index += 1) { + fake.emitTables(["lanes"]); + await vi.advanceTimersByTimeAsync(100); + } + + expect(lifecycleEvents).toHaveLength(2); + adapter.dispose(); + }); + it("accepts a restarted project chat seq before its non-resumed snapshot", async () => { fake.descriptors = descriptors(["chat.getSummary"]); fake.commandResults.set("chat.getSummary", { sessionId: "chat-restarted" }); diff --git a/apps/desktop/src/renderer/webclient/adapter/infra/invalidation.ts b/apps/desktop/src/renderer/webclient/adapter/infra/invalidation.ts index 6cb7518e1..01a67a07d 100644 --- a/apps/desktop/src/renderer/webclient/adapter/infra/invalidation.ts +++ b/apps/desktop/src/renderer/webclient/adapter/infra/invalidation.ts @@ -45,8 +45,11 @@ export function createInvalidationScheduler( const unsubscribe = onTablesChanged((tables) => { for (const table of tables) pendingTables.add(table); - if (timer) clearTimeout(timer); - timer = setTimeout(flush, debounceMs); + // Bound invalidation latency from the first pending table. Resetting this + // timer on every live write lets a busy chat or terminal postpone every + // lane/session/PR refresh forever. One timer still coalesces bursts while + // guaranteeing a drain at most once per debounce window. + if (!timer) timer = setTimeout(flush, debounceMs); }); return () => { diff --git a/apps/desktop/src/renderer/webclient/shell/WebClientRoot.tsx b/apps/desktop/src/renderer/webclient/shell/WebClientRoot.tsx index b7c4dcd2d..ed0f39e36 100644 --- a/apps/desktop/src/renderer/webclient/shell/WebClientRoot.tsx +++ b/apps/desktop/src/renderer/webclient/shell/WebClientRoot.tsx @@ -4,6 +4,7 @@ import type { AdeAccountMachine } from "../../../shared/types/account"; import type { SyncMobileProjectSummary } from "../../../shared/types/sync"; import type { DeeplinkTarget } from "../../../shared/deeplinks"; import type { + AdeSyncActiveProjectChange, AdeSyncClient, AdeSyncClientStatus, WebRelayAccess, @@ -174,7 +175,7 @@ export function WebClientRoot({ const [connectingAccountMachineKey, setConnectingAccountMachineKey] = useState(null); const adapterRef = useRef(null); - const activeProjectBoundaryRef = useRef(null); + const activeProjectBoundaryRef = useRef(null); const stashedTargetRef = useRef(null); const bootedRef = useRef(false); const fatalRebootRef = useRef(false); @@ -266,24 +267,29 @@ export function WebClientRoot({ // Bring the connected machine's catalog + selected project online, then mount // the shared App with the sync-backed adapter installed on window.ade. const enterProject = useCallback(async (project: SyncMobileProjectSummary, catalogSeed?: SyncMobileProjectSummary[]) => { + const pendingBoundary = activeProjectBoundaryRef.current; + const projectToEnter = pendingBoundary?.project.id === client.getStatus().activeProjectId + ? pendingBoundary.project + : project; // Switch onto the target project's host only when it isn't already the // active one. The host serves file_request/commands for the peer's bound // project without a redundant switch, so avoid the extra disconnect + // reconnect (and its startup latency) when we're already on this project. - if (project.id !== client.getStatus().activeProjectId) { - const result = await client.switchProject(project.id); + if (projectToEnter.id !== client.getStatus().activeProjectId) { + const result = await client.switchProject(projectToEnter.id); if (!result.ok) { - throw new Error(result.message?.trim() || `Could not switch to ${project.displayName}.`); + throw new Error(result.message?.trim() || `Could not switch to ${projectToEnter.displayName}.`); } } if (!adapterRef.current) { adapterRef.current = await loadAdapter(client, accountClient, catalogSeed); } window.ade = adapterRef.current.ade; - const boundaryProject = activeProjectBoundaryRef.current; - const projectToBind = boundaryProject?.id === client.getStatus().activeProjectId - ? boundaryProject - : project; + const latestBoundary = activeProjectBoundaryRef.current; + const projectToBind = latestBoundary?.project.id === client.getStatus().activeProjectId + ? latestBoundary.project + : projectToEnter; + activeProjectBoundaryRef.current = null; adapterRef.current.bindProject(toProjectInfo(projectToBind), projectToBind.id); // Point the address bar at the initial App route before mounting so the @@ -321,7 +327,11 @@ export function WebClientRoot({ const activeEnv = availableEnvironments.find( (environment) => environment.envId === client.getStatus().selectedEnvId, ) ?? null; - const projects = (await client.getProjectCatalog()).projects; + const fetchedProjects = (await client.getProjectCatalog()).projects; + const pendingBoundary = activeProjectBoundaryRef.current; + const projects = pendingBoundary?.project.id === client.getStatus().activeProjectId + ? pendingBoundary.catalog.projects + : fetchedProjects; setCatalog(projects); if (isChatsRoute(window.location.pathname)) { @@ -498,14 +508,23 @@ export function WebClientRoot({ }, [client]); useEffect(() => { - return client.onActiveProjectChanged(({ project, catalog: nextCatalog }) => { - activeProjectBoundaryRef.current = project; + return client.onActiveProjectChanged((change) => { + const { project, catalog: nextCatalog } = change; setCatalog(nextCatalog.projects); // Personal Chats is intentionally machine-scoped and projectless. Keep // its mounted adapter detached even when the machine hands the shared // listener to another open project. - if (isChatsRoute(window.location.pathname)) return; - adapterRef.current?.replaceProject(toProjectInfo(project), project.id); + if (isChatsRoute(window.location.pathname)) { + activeProjectBoundaryRef.current = null; + return; + } + const adapter = adapterRef.current; + if (!adapter) { + activeProjectBoundaryRef.current = change; + return; + } + activeProjectBoundaryRef.current = null; + adapter.replaceProject(toProjectInfo(project), project.id); }); }, [client]); diff --git a/apps/desktop/src/renderer/webclient/shell/__tests__/WebClientRoot.test.tsx b/apps/desktop/src/renderer/webclient/shell/__tests__/WebClientRoot.test.tsx index d223e2b2d..15efc532b 100644 --- a/apps/desktop/src/renderer/webclient/shell/__tests__/WebClientRoot.test.tsx +++ b/apps/desktop/src/renderer/webclient/shell/__tests__/WebClientRoot.test.tsx @@ -277,6 +277,99 @@ describe("WebClientRoot entry routes", () => { expect(listEnvironments).toHaveBeenCalledTimes(1); }); + it("does not switch back when a stale catalog resolves after a project boundary", async () => { + const environment = savedEnvironment({ activeProjectId: "project-1" }); + const projectOne: SyncMobileProjectSummary = { + id: "project-1", + displayName: "Repo One", + rootPath: "/repo-1", + defaultBaseRef: "main", + lastOpenedAt: null, + iconDataUrl: null, + laneCount: 1, + isAvailable: true, + isCached: true, + isOpen: true, + }; + const projectTwo: SyncMobileProjectSummary = { + ...projectOne, + id: "project-2", + displayName: "Repo Two", + rootPath: "/repo-2", + }; + let currentStatus: AdeSyncClientStatus = idleStatus; + let resolveCatalog!: (catalog: SyncProjectCatalogPayload) => void; + const getProjectCatalog = vi.fn(() => new Promise((resolve) => { + resolveCatalog = resolve; + })); + const statusListeners = new Set<(status: AdeSyncClientStatus) => void>(); + const activeProjectListeners = new Set<(change: { + previousProjectId: string | null; + project: SyncMobileProjectSummary; + catalog: SyncProjectCatalogPayload; + }) => void>(); + const switchProject = vi.fn(async () => ({ ok: true, project: projectOne })); + const bindProject = vi.fn(); + createAdapterMock.mockReturnValue({ + ade: {} as Window["ade"], + bindProject, + replaceProject: vi.fn(), + dispose: vi.fn(), + }); + const client = syncClient({ + getStatus: () => currentStatus, + listEnvironments: vi.fn(async () => [environment]), + pruneAccountOwnedEnvironments: vi.fn(async () => pruneResult([environment])), + connect: vi.fn(async () => { + currentStatus = { + ...idleStatus, + state: "connected", + readiness: "ready", + envId: environment.envId, + selectedEnvId: environment.envId, + activeProjectId: projectOne.id, + }; + statusListeners.forEach((listener) => listener(currentStatus)); + }), + getProjectCatalog, + switchProject, + subscribe: vi.fn((listener: (status: AdeSyncClientStatus) => void) => { + statusListeners.add(listener); + return () => statusListeners.delete(listener); + }), + onActiveProjectChanged: vi.fn((listener: (change: { + previousProjectId: string | null; + project: SyncMobileProjectSummary; + catalog: SyncProjectCatalogPayload; + }) => void) => { + activeProjectListeners.add(listener); + return () => activeProjectListeners.delete(listener); + }), + }); + + render(); + fireEvent.click(await screen.findByRole("button", { name: /Current saved Mac/i })); + await waitFor(() => expect(getProjectCatalog).toHaveBeenCalledOnce()); + currentStatus = { ...currentStatus, activeProjectId: projectTwo.id }; + const currentCatalog = { projects: [{ ...projectOne, isOpen: false }, projectTwo] }; + act(() => { + activeProjectListeners.forEach((listener) => listener({ + previousProjectId: projectOne.id, + project: projectTwo, + catalog: currentCatalog, + })); + statusListeners.forEach((listener) => listener(currentStatus)); + resolveCatalog({ projects: [projectOne] }); + }); + + await waitFor(() => expect(bindProject).toHaveBeenCalledWith({ + rootPath: projectTwo.rootPath, + displayName: projectTwo.displayName, + baseRef: "main", + }, projectTwo.id)); + expect(switchProject).not.toHaveBeenCalled(); + }); + it("replaces the mounted project when the connected client crosses a hydration boundary", async () => { const environment = savedEnvironment({ activeProjectId: "project-1" }); const projectOne: SyncMobileProjectSummary = { diff --git a/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts b/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts index dd8b4cd50..5c216f3cf 100644 --- a/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts +++ b/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts @@ -4,6 +4,7 @@ import { SYNC_COMPACT_INVALIDATION_V1_CAPABILITY, SYNC_INVALIDATION_TABLE_MAX_BYTES, SYNC_INVALIDATION_ONLY_V1_CAPABILITY, + type SyncBrainStatusPayload, type SyncEnvelope, type SyncFeatureFlags, type SyncHelloOkPayload, @@ -1577,6 +1578,123 @@ describe("browser sync connection and client", () => { connection.dispose(); }); + it("paces successful Relay renewals when short token leases stay inside the safety lead", async () => { + const nowMs = 1_800_000_000_000; + vi.useFakeTimers(); + vi.setSystemTime(nowMs); + const storage = new MemoryStorage(); + const environment = await makeEnvironment(storage); + let refreshCount = 0; + const script = createSocketFactory((socket, envelope) => { + if (envelope.type === "hello") { + socket.serverSend({ + type: "hello_ok", + requestId: envelope.requestId, + payload: relayHelloOk(nowMs, { expiresAfterMs: 120_000, refreshAfterMs: 91_000 }), + }); + } else if (envelope.type === "relay_reauthorize") { + refreshCount += 1; + if (refreshCount === 1) { + socket.serverSend({ + type: "relay_reauthorize_result", + requestId: envelope.requestId, + payload: { + ok: true, + relayAuthorization: { + expiresAt: nowMs + 120_000, + refreshAfter: nowMs + 60_000, + challenge: "short-token-lease", + graceMs: 10_000, + }, + }, + }); + } + } + }); + const connection = new SyncConnection({ + socketFactory: script.factory, + document: null, + relayReauthorizationSigner, + }); + + const connecting = connection.connect( + environment, + [{ url: pairingPayload.relayUrl!, kind: "relay", dialable: true }], + async () => "relay-token", + ); + await vi.advanceTimersByTimeAsync(0); + completeRelayReadyV2(script.sockets[0]); + await connecting; + await vi.advanceTimersByTimeAsync(1_000); + await vi.advanceTimersByTimeAsync(0); + expect(refreshCount).toBe(1); + + await vi.advanceTimersByTimeAsync(29_999); + expect(refreshCount).toBe(1); + await vi.advanceTimersByTimeAsync(1); + await flushMicrotasks(); + expect(refreshCount).toBe(2); + connection.dispose(); + }); + + it("caps Relay pacing before a short accepted lease expires", async () => { + const nowMs = 1_800_000_000_000; + vi.useFakeTimers(); + vi.setSystemTime(nowMs); + const storage = new MemoryStorage(); + const environment = await makeEnvironment(storage); + let refreshCount = 0; + const script = createSocketFactory((socket, envelope) => { + if (envelope.type === "hello") { + socket.serverSend({ + type: "hello_ok", + requestId: envelope.requestId, + payload: relayHelloOk(nowMs, { expiresAfterMs: 120_000, refreshAfterMs: 91_000 }), + }); + } else if (envelope.type === "relay_reauthorize") { + refreshCount += 1; + if (refreshCount === 1) { + socket.serverSend({ + type: "relay_reauthorize_result", + requestId: envelope.requestId, + payload: { + ok: true, + relayAuthorization: { + expiresAt: nowMs + 32_000, + refreshAfter: nowMs + 12_000, + challenge: "short-accepted-lease", + graceMs: 10_000, + }, + }, + }); + } + } + }); + const connection = new SyncConnection({ + socketFactory: script.factory, + document: null, + relayReauthorizationSigner, + }); + + const connecting = connection.connect( + environment, + [{ url: pairingPayload.relayUrl!, kind: "relay", dialable: true }], + async () => "relay-token", + ); + await vi.advanceTimersByTimeAsync(0); + completeRelayReadyV2(script.sockets[0]); + await connecting; + await vi.advanceTimersByTimeAsync(1_000); + expect(refreshCount).toBe(1); + + await vi.advanceTimersByTimeAsync(25_999); + expect(refreshCount).toBe(1); + await vi.advanceTimersByTimeAsync(1); + await flushMicrotasks(); + expect(refreshCount).toBe(2); + connection.dispose(); + }); + it("deduplicates refresh preparation and retries an identical request after a lost ACK", async () => { const nowMs = 1_800_000_000_000; vi.useFakeTimers(); @@ -3138,6 +3256,118 @@ describe("browser sync connection and client", () => { client.dispose(); }); + it("preserves wire order while a compressed project boundary is decoding", async () => { + const storage = new MemoryStorage(); + const environment = await makeEnvironment(storage); + const script = createSocketFactory((socket, envelope) => { + if (envelope.type === "hello") { + socket.serverSend({ type: "hello_ok", requestId: envelope.requestId, payload: helloOk("project-1") }); + } + }); + const client = new AdeSyncClient({ storage, socketFactory: script.factory, document: null }); + const delivered: string[] = []; + + const connecting = client.connect(environment.envId, signedInRelayAccess); + await completeRelayReadyV2AfterOpen(script.sockets, 0); + await connecting; + client.subscribeChat("old-project-chat", {}, { + event: (payload) => delivered.push(String((payload.event as { type?: unknown }).type ?? "")), + }); + await flush(); + + const projectOne = { ...helloOk("project-1").projects![0], isOpen: false }; + const projectTwo = { + ...helloOk("project-2").projects![0], + displayName: `Repo Two ${"x".repeat(50_000)}`, + rootPath: "/repo-2", + isOpen: true, + }; + script.sockets[0]?.onmessage?.({ + data: encodeSyncEnvelope({ + type: "project_catalog", + payload: { projects: [projectOne, projectTwo] }, + compressionThresholdBytes: 0, + }), + } as MessageEvent); + script.sockets[0]?.serverSend({ + type: "chat_event", + payload: { + sessionId: "old-project-chat", + timestamp: new Date().toISOString(), + seq: 1, + event: { type: "late-old-project-event" }, + }, + } as never); + + for (let attempt = 0; attempt < 20 && client.getStatus().activeProjectId !== "project-2"; attempt += 1) { + await flush(); + } + expect(client.getStatus().activeProjectId).toBe("project-2"); + expect(delivered).toEqual([]); + client.dispose(); + }); + + it("continues the ordered inbound queue after one malformed envelope", async () => { + const environment = await makeEnvironment(new MemoryStorage(), { dpopPublicKeyX963: null }); + const script = createSocketFactory((socket, envelope) => { + if (envelope.type === "hello") { + socket.serverSend({ type: "hello_ok", requestId: envelope.requestId, payload: helloOk() }); + } + }); + const connection = new SyncConnection({ socketFactory: script.factory, document: null }); + const statuses: SyncBrainStatusPayload[] = []; + connection.on("brainStatus", (payload) => statuses.push(payload)); + await connection.connect(environment, [ + { url: "ws://127.0.0.1:8787", kind: "loopback", dialable: true }, + ]); + + script.sockets[0]?.onmessage?.({ data: "{not-json" } as MessageEvent); + script.sockets[0]?.serverSend({ + type: "brain_status", + payload: { state: "ready" }, + }); + await flushMicrotasks(); + + expect(statuses).toEqual([{ state: "ready" }]); + expect(connection.getStatus().state).toBe("connected"); + connection.dispose(); + }); + + it("persists rapid project boundaries in arrival order", async () => { + const storage = new DelayedPutStorage(); + const environment = await makeEnvironment(storage); + const script = createSocketFactory((socket, envelope) => { + if (envelope.type === "hello") { + socket.serverSend({ type: "hello_ok", requestId: envelope.requestId, payload: helloOk("project-1") }); + } + }); + const client = new AdeSyncClient({ storage, socketFactory: script.factory, document: null }); + const connecting = client.connect(environment.envId, signedInRelayAccess); + await completeRelayReadyV2AfterOpen(script.sockets, 0); + await connecting; + await flush(); + + storage.delayNextEnvironmentPut = true; + script.sockets[0]?.serverSend({ + type: "project_catalog", + payload: { projects: [{ ...helloOk("project-2").projects![0], isOpen: true }] }, + }); + await storage.waitForPausedPut(); + script.sockets[0]?.serverSend({ + type: "project_catalog", + payload: { projects: [{ ...helloOk("project-3").projects![0], isOpen: true }] }, + }); + await flush(); + storage.resumePausedPut(); + for (let attempt = 0; attempt < 10; attempt += 1) await flush(); + + expect(client.getStatus().activeProjectId).toBe("project-3"); + await expect(new WebClientEnvStore(storage).getEnvironment(environment.envId)).resolves.toMatchObject({ + activeProjectId: "project-3", + }); + client.dispose(); + }); + it("publishes the same project boundary when reconnect hello opens a different project", async () => { const storage = new MemoryStorage(); const environment = await makeEnvironment(storage); diff --git a/apps/desktop/src/renderer/webclient/sync/client.ts b/apps/desktop/src/renderer/webclient/sync/client.ts index 5335a2ee2..ab7a7fd56 100644 --- a/apps/desktop/src/renderer/webclient/sync/client.ts +++ b/apps/desktop/src/renderer/webclient/sync/client.ts @@ -245,6 +245,7 @@ export class AdeSyncClient { private readonly terminalInputQueue: TerminalInputOperation[] = []; private terminalInputQueueBytes = 0; private streamSubscriptionsPaused = false; + private environmentPersistenceTail: Promise = Promise.resolve(); private readonly restorationTimeoutMs: number; private readonly terminalInputAckTimeoutMs: number; private readonly terminalInputMaxAttempts: number; @@ -531,6 +532,7 @@ export class AdeSyncClient { } async removeAccountOwnedEnvironments(ownerUserId: string): Promise { + await this.drainEnvironmentPersistence(); const removedIds = await this.envStore.removeAccountOwnedEnvironments(ownerUserId); return await this.finishAccountEnvironmentRemoval(removedIds); } @@ -538,6 +540,7 @@ export class AdeSyncClient { async pruneAccountOwnedEnvironments( currentOwnerUserId: string | null, ): Promise { + await this.drainEnvironmentPersistence(); const result = await this.envStore.pruneAccountOwnedEnvironments( currentOwnerUserId, ); @@ -609,6 +612,7 @@ export class AdeSyncClient { this.selectedEnvId = null; this.activeProjectId = null; } + await this.drainEnvironmentPersistence(); await this.envStore.removeEnvironment(envId); this.emitStatus(); } @@ -1632,9 +1636,19 @@ export class AdeSyncClient { envId = this.selectedEnvId, ): Promise { if (!envId) return; - const current = await this.envStore.getEnvironment(envId); - if (!current) return; - await this.envStore.saveEnvironment(update(current)); + const persistence = this.environmentPersistenceTail + .catch(() => undefined) + .then(async () => { + const current = await this.envStore.getEnvironment(envId); + if (!current) return; + await this.envStore.saveEnvironment(update(current)); + }); + this.environmentPersistenceTail = persistence; + await persistence; + } + + private async drainEnvironmentPersistence(): Promise { + await this.environmentPersistenceTail.catch(() => undefined); } private emitStatus(): void { diff --git a/apps/desktop/src/renderer/webclient/sync/connection.ts b/apps/desktop/src/renderer/webclient/sync/connection.ts index 1dab760a4..5dcf26f19 100644 --- a/apps/desktop/src/renderer/webclient/sync/connection.ts +++ b/apps/desktop/src/renderer/webclient/sync/connection.ts @@ -53,6 +53,8 @@ const MAX_CONSECUTIVE_AUTH_FAILURES = 5; const VISIBILITY_RECONNECT_DEBOUNCE_MS = 1_000; const RELAY_REAUTH_RESULT_TIMEOUT_MS = 4_000; const RELAY_REAUTH_RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000] as const; +const RELAY_REAUTH_MIN_SUCCESS_INTERVAL_MS = 30_000; +const RELAY_REAUTH_EXPIRY_SAFETY_MS = 5_000; // Browser background throttling can delay a timer well past its nominal fire // time. Start before the host's refresh deadline and do not depend on the tab // becoming visible again: otherwise a healthy Relay socket is guaranteed to @@ -274,6 +276,8 @@ export class SyncConnection { private relayRefreshAttempt: RelayRefreshAttempt | null = null; private relayRefreshPreparation: Promise | null = null; private relayRefreshRetryCount = 0; + private relayRefreshDueAtMs: number | null = null; + private relayLastRefreshStartedAtMs: number | null = null; private relayAuthorizationTerminalError: string | null = null; private readonly listeners: ListenerMap = { statusChanged: new Set(), @@ -509,6 +513,7 @@ export class SyncConnection { let relayNegotiationTimeout: ReturnType | null = null; let helloStarted = false; let relayAccepted = false; + let inboundMessages = Promise.resolve(); let cancelAttempt: () => void = () => {}; const clearDeadlines = () => { clearTimeout(openTimeout); @@ -579,38 +584,41 @@ export class SyncConnection { // final ready within the existing overall authenticated-hello budget. return; } - void this.handleMessage(asMessageEvent(event.data), socket, generation, { - onHelloOk: (payload) => { - if (settled || !this.isCurrentSocket(socket, generation)) return; - if (payload.brain?.deviceId?.trim() !== environment.hostDeviceId) { - fail(new Error("Connected machine identity did not match the stored pairing.")); - return; - } - const compatibilityError = this.requireInvalidationOnlyV1(payload); - if (compatibilityError) { - fail(compatibilityError, "Incompatible ADE host"); - return; - } - if (!this.finishConnected(socket, environment, endpoint, payload, generation)) { - fail(new StaleSocketAttemptError(), "Connection attempt superseded"); - return; - } - settled = true; - clearDeadlines(); - if (this.pendingAttemptCancel === cancelAttempt) this.pendingAttemptCancel = null; - resolve(); - }, - onHelloError: (payload) => { - if (settled || !this.isCurrentSocket(socket, generation)) return; - const error = this.handleAuthFailure(environment, payload); - fail(error); - if (error.code === "attributed_auth_failed") { - this.emit("pairingRejected", { - envId: environment.envId, - hostDeviceId: environment.hostDeviceId, - }); - } - }, + inboundMessages = inboundMessages.then(() => { + if (!this.isCurrentSocket(socket, generation)) return; + return this.handleMessage(asMessageEvent(event.data), socket, generation, { + onHelloOk: (payload) => { + if (settled || !this.isCurrentSocket(socket, generation)) return; + if (payload.brain?.deviceId?.trim() !== environment.hostDeviceId) { + fail(new Error("Connected machine identity did not match the stored pairing.")); + return; + } + const compatibilityError = this.requireInvalidationOnlyV1(payload); + if (compatibilityError) { + fail(compatibilityError, "Incompatible ADE host"); + return; + } + if (!this.finishConnected(socket, environment, endpoint, payload, generation)) { + fail(new StaleSocketAttemptError(), "Connection attempt superseded"); + return; + } + settled = true; + clearDeadlines(); + if (this.pendingAttemptCancel === cancelAttempt) this.pendingAttemptCancel = null; + resolve(); + }, + onHelloError: (payload) => { + if (settled || !this.isCurrentSocket(socket, generation)) return; + const error = this.handleAuthFailure(environment, payload); + fail(error); + if (error.code === "attributed_auth_failed") { + this.emit("pairingRejected", { + envId: environment.envId, + hostDeviceId: environment.hostDeviceId, + }); + } + }, + }); }).catch((error) => { if (!this.isCurrentSocket(socket, generation)) return; fail(error instanceof Error ? error : new Error(String(error))); @@ -665,6 +673,7 @@ export class SyncConnection { let relayNegotiationTimeout: ReturnType | null = null; let helloStarted = false; let relayAccepted = false; + let inboundMessages = Promise.resolve(); let cancelAttempt: () => void = () => {}; const clearDeadlines = () => { clearTimeout(openTimeout); @@ -750,48 +759,51 @@ export class SyncConnection { } return; } - void this.handleMessage(asMessageEvent(event.data), socket, generation, { - onHelloOk: (payload) => { - if (settled || !this.isCurrentSocket(socket, generation)) return; - const hostDeviceId = payload.brain?.deviceId?.trim(); - const pairing = resolveAccountHelloPairing({ - accountPairing: payload.accountPairing, - existingPairing: args.existingPairing, - expectedDeviceId: args.peer.deviceId, - }); - if ( - hostDeviceId !== args.expectedHostDeviceId - || !pairing - ) { - fail(new Error("Account machine identity did not match the verified directory record.")); - return; - } - const compatibilityError = this.requireInvalidationOnlyV1(payload); - if (compatibilityError) { - fail(compatibilityError, "Incompatible ADE host"); - return; - } - let environment: WebClientEnvironmentRecord; - try { - environment = args.buildEnvironment(payload, endpoint, pairing); - } catch (error) { - fail(error instanceof Error ? error : new Error(String(error))); - return; - } - if (!this.finishConnected(socket, environment, endpoint, payload, generation)) { - fail(new StaleSocketAttemptError(), "Connection attempt superseded"); - return; - } - settled = true; - clearDeadlines(); - if (this.pendingAttemptCancel === cancelAttempt) this.pendingAttemptCancel = null; - this.shouldReconnect = true; - resolve({ environment, helloOk: payload, endpoint }); - }, - onHelloError: (payload) => { - if (settled || !this.isCurrentSocket(socket, generation)) return; - fail(new Error(payload.message || "Account authentication was rejected.")); - }, + inboundMessages = inboundMessages.then(() => { + if (!this.isCurrentSocket(socket, generation)) return; + return this.handleMessage(asMessageEvent(event.data), socket, generation, { + onHelloOk: (payload) => { + if (settled || !this.isCurrentSocket(socket, generation)) return; + const hostDeviceId = payload.brain?.deviceId?.trim(); + const pairing = resolveAccountHelloPairing({ + accountPairing: payload.accountPairing, + existingPairing: args.existingPairing, + expectedDeviceId: args.peer.deviceId, + }); + if ( + hostDeviceId !== args.expectedHostDeviceId + || !pairing + ) { + fail(new Error("Account machine identity did not match the verified directory record.")); + return; + } + const compatibilityError = this.requireInvalidationOnlyV1(payload); + if (compatibilityError) { + fail(compatibilityError, "Incompatible ADE host"); + return; + } + let environment: WebClientEnvironmentRecord; + try { + environment = args.buildEnvironment(payload, endpoint, pairing); + } catch (error) { + fail(error instanceof Error ? error : new Error(String(error))); + return; + } + if (!this.finishConnected(socket, environment, endpoint, payload, generation)) { + fail(new StaleSocketAttemptError(), "Connection attempt superseded"); + return; + } + settled = true; + clearDeadlines(); + if (this.pendingAttemptCancel === cancelAttempt) this.pendingAttemptCancel = null; + this.shouldReconnect = true; + resolve({ environment, helloOk: payload, endpoint }); + }, + onHelloError: (payload) => { + if (settled || !this.isCurrentSocket(socket, generation)) return; + fail(new Error(payload.message || "Account authentication was rejected.")); + }, + }); }).catch((error) => { if (!this.isCurrentSocket(socket, generation)) return; fail(error instanceof Error ? error : new Error(String(error))); @@ -893,6 +905,7 @@ export class SyncConnection { if (!this.isCurrentSocket(socket, generation)) return; if (this.isConnected()) { this.scheduleHeartbeatFallback(); + this.beginRelayAuthorizationRefreshIfDue(generation); } } @@ -997,11 +1010,31 @@ export class SyncConnection { ): void { if (this.relayRefreshTimer) clearTimeout(this.relayRefreshTimer); this.relayRefreshTimer = null; + this.relayRefreshDueAtMs = null; if (resetRetries) this.relayRefreshRetryCount = 0; if (!lease || generation !== this.connectionGeneration) return; + const desiredRefreshAtMs = Math.max( + Date.now(), + lease.refreshAfter - RELAY_REAUTH_CLIENT_SAFETY_LEAD_MS, + ); + const pacedRefreshAtMs = Math.max( + desiredRefreshAtMs, + this.relayLastRefreshStartedAtMs == null + ? 0 + : this.relayLastRefreshStartedAtMs + RELAY_REAUTH_MIN_SUCCESS_INTERVAL_MS, + ); + // A verifier may legitimately return a lease with only ~30 seconds left. + // Pace repeated successes when there is room, but never let that pacing + // push the next proof beyond the current lease's latest safe refresh time. + const latestSafeRefreshAtMs = Math.max( + Date.now(), + lease.expiresAt - RELAY_REAUTH_EXPIRY_SAFETY_MS, + ); + const refreshAtMs = Math.min(pacedRefreshAtMs, latestSafeRefreshAtMs); + this.relayRefreshDueAtMs = refreshAtMs; const delayMs = Math.max( 0, - lease.refreshAfter - RELAY_REAUTH_CLIENT_SAFETY_LEAD_MS - Date.now(), + refreshAtMs - Date.now(), ); this.relayRefreshTimer = setTimeout(() => { this.relayRefreshTimer = null; @@ -1022,6 +1055,7 @@ export class SyncConnection { const lease = this.latestHello?.relayAuthorization ?? null; const provider = this.relayAccountTokenProvider; if (!environment || !lease || !provider) return; + this.relayRefreshDueAtMs = null; const preparation = (async () => { try { @@ -1045,6 +1079,7 @@ export class SyncConnection { responseTimer: null, }; this.relayRefreshAttempt = attempt; + this.relayLastRefreshStartedAtMs = Date.now(); this.sendRelayAuthorizationAttempt(attempt); } catch { this.scheduleRelayAuthorizationRetry(generation, false); @@ -1056,6 +1091,11 @@ export class SyncConnection { }); } + private beginRelayAuthorizationRefreshIfDue(generation: number): void { + if (this.relayRefreshDueAtMs == null || Date.now() < this.relayRefreshDueAtMs) return; + this.beginRelayAuthorizationRefresh(generation); + } + private sendRelayAuthorizationAttempt(attempt: RelayRefreshAttempt): void { if ( this.relayRefreshAttempt !== attempt @@ -1370,8 +1410,8 @@ export class SyncConnection { if (this.isConnected()) { if (this.closeIfInboundStale(true)) return; const lease = this.latestHello?.relayAuthorization ?? null; - if (lease && Date.now() >= lease.refreshAfter) { - this.beginRelayAuthorizationRefresh(this.connectionGeneration); + if (lease) { + this.beginRelayAuthorizationRefreshIfDue(this.connectionGeneration); } return; } @@ -1424,6 +1464,8 @@ export class SyncConnection { this.relayRefreshAttempt = null; this.relayRefreshPreparation = null; this.relayRefreshRetryCount = 0; + this.relayRefreshDueAtMs = null; + this.relayLastRefreshStartedAtMs = null; } private setStatus(patch: Partial): void { From 61d2d7d765d416c6c230b426712a60020ef15ae0 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:42:15 -0400 Subject: [PATCH 31/53] fix(github): bound hosts token cache --- .../services/github/githubService.test.ts | 22 ++++++++++++++ .../src/main/services/github/githubService.ts | 30 +++++++++++++------ 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/main/services/github/githubService.test.ts b/apps/desktop/src/main/services/github/githubService.test.ts index d44dfbab3..f18a5eaac 100644 --- a/apps/desktop/src/main/services/github/githubService.test.ts +++ b/apps/desktop/src/main/services/github/githubService.test.ts @@ -723,6 +723,28 @@ describe("githubService.getStatus", () => { delete process.env.GH_CONFIG_DIR; }); + it("bounds the process-wide hosts.yml token cache", () => { + delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; + const prefix = `/tmp/gh-bounded-token-cache-${Date.now()}`; + vi.mocked(fs.readFileSync).mockImplementation(((filePath: fs.PathOrFileDescriptor) => { + if (String(filePath).endsWith("hosts.yml")) { + return "github.com:\n user: alice\n oauth_token: gho_hosts_bounded\n"; + } + return Buffer.from("encrypted"); + }) as typeof fs.readFileSync); + + for (let index = 0; index <= 32; index += 1) { + process.env.GH_CONFIG_DIR = `${prefix}-${index}`; + expect(makeService().getTokenOrThrow()).toBe("gho_hosts_bounded"); + } + expect(fs.readFileSync).toHaveBeenCalledTimes(33); + + process.env.GH_CONFIG_DIR = `${prefix}-0`; + expect(makeService().getTokenOrThrow()).toBe("gho_hosts_bounded"); + expect(fs.readFileSync).toHaveBeenCalledTimes(34); + delete process.env.GH_CONFIG_DIR; + }); + it("coalesces slow gh auth and failed status probes across project services", async () => { stubOriginRemote(); delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; diff --git a/apps/desktop/src/main/services/github/githubService.ts b/apps/desktop/src/main/services/github/githubService.ts index f618ecc73..aa8299762 100644 --- a/apps/desktop/src/main/services/github/githubService.ts +++ b/apps/desktop/src/main/services/github/githubService.ts @@ -29,10 +29,24 @@ const AUTH_STORE_FILE_NAME = "github-token.v1.bin"; const MACHINE_TOKEN_KEY = "github.token.v1"; const GITHUB_API_TIMEOUT_MS = 20_000; const GH_AUTH_TOKEN_CACHE_TTL_MS = 30_000; +const GH_HOSTS_TOKEN_CACHE_MAX_ENTRIES = 32; const GITHUB_STATUS_FAILURE_COOLDOWN_MS = 2 * 60_000; const execFileAsync = promisify(execFile); const processGhHostsTokenCache = new Map(); +function cacheGhHostsToken(hostsPath: string, token: string | null): void { + processGhHostsTokenCache.delete(hostsPath); + processGhHostsTokenCache.set(hostsPath, { + expiresAt: Date.now() + GH_AUTH_TOKEN_CACHE_TTL_MS, + token, + }); + while (processGhHostsTokenCache.size > GH_HOSTS_TOKEN_CACHE_MAX_ENTRIES) { + const oldest = processGhHostsTokenCache.keys().next().value as string | undefined; + if (!oldest) break; + processGhHostsTokenCache.delete(oldest); + } +} + type GitHubAuthSource = GitHubStatus["authSource"]; type GitHubCliAuthResult = { @@ -105,7 +119,11 @@ function readGhHostsFileToken(env: NodeJS.ProcessEnv = process.env): string | nu || path.join(env.XDG_CONFIG_HOME?.trim() || path.join(os.homedir(), ".config"), "gh"); const hostsPath = path.join(configDir, "hosts.yml"); const cached = processGhHostsTokenCache.get(hostsPath); - if (cached && cached.expiresAt > Date.now()) return cached.token; + if (cached && cached.expiresAt > Date.now()) { + processGhHostsTokenCache.delete(hostsPath); + processGhHostsTokenCache.set(hostsPath, cached); + return cached.token; + } try { const raw = fs.readFileSync(hostsPath, "utf8"); const lines = raw.split(/\r?\n/); @@ -120,10 +138,7 @@ function readGhHostsFileToken(env: NodeJS.ProcessEnv = process.env): string | nu if (match) { const token = match[1].replace(/^["']|["']$/g, "").trim(); if (token) { - processGhHostsTokenCache.set(hostsPath, { - expiresAt: Date.now() + GH_AUTH_TOKEN_CACHE_TTL_MS, - token, - }); + cacheGhHostsToken(hostsPath, token); return token; } } @@ -131,10 +146,7 @@ function readGhHostsFileToken(env: NodeJS.ProcessEnv = process.env): string | nu } catch { // No hosts.yml or unreadable — fall through. } - processGhHostsTokenCache.set(hostsPath, { - expiresAt: Date.now() + GH_AUTH_TOKEN_CACHE_TTL_MS, - token: null, - }); + cacheGhHostsToken(hostsPath, null); return null; } From ae46b843ae0eb725695431500d5464e76b5aad8f Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:48:50 -0400 Subject: [PATCH 32/53] fix(ios): preserve terminal data behind snapshots --- apps/ios/ADE/Services/SyncService.swift | 41 +++++++++++----- .../ADETests/SyncRecoveryPolicyTests.swift | 48 +++++++++++++++++++ 2 files changed, 77 insertions(+), 12 deletions(-) diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 1ada37372..a9c2b9c2f 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -2387,6 +2387,11 @@ final class SyncService: ObservableObject { let completion: (Result) -> Void let timeoutTask: Task let timeoutPolicy: PendingRequestTimeoutPolicy + /// Runs synchronously when the matching response frame is resolved, before + /// the receive loop can advance to the next frame. Terminal subscriptions + /// use this to install their snapshot/subscription barrier before the host's + /// immediately queued `terminal_data` frames become eligible for delivery. + let acceptResponse: (@MainActor (Any) throws -> Void)? /// Monotonic timestamp captured via `ProcessInfo.processInfo.systemUptime` /// — RTT calculations must not be skewed by user-initiated wall-clock /// adjustments (DST, NTP step, manual time changes). @@ -6607,9 +6612,22 @@ final class SyncService: ObservableObject { // budget on a constrained Relay path. Fail this subscription without // probing or replacing the shared sync socket; the caller can retry while // chats, CRDT changes, and heartbeats keep flowing. - let raw: Any do { - raw = try await awaitResponse(requestId: requestId, disconnectOnTimeout: false) { + _ = try await awaitResponse( + requestId: requestId, + disconnectOnTimeout: false, + acceptResponse: { raw in + let snapshot = try self.decode(raw, as: TerminalSnapshot.self) + guard self.terminalSnapshotRequestTokens[sessionId] == requestToken, + self.desiredTerminalSessionIds.contains(sessionId), + self.isCurrentTerminalSnapshotRecoveryScope(scope) + else { return } + self.terminalSnapshotRequestTokens.removeValue(forKey: sessionId) + self.applyTerminalSnapshot(snapshot, sessionId: sessionId) + self.subscribedTerminalSessionIds.insert(sessionId) + self.flushTerminalInputQueue(sessionId: sessionId) + } + ) { self.sendEnvelope(type: "terminal_subscribe", requestId: requestId, payload: payload) } } catch { @@ -6618,14 +6636,6 @@ final class SyncService: ObservableObject { } throw error } - let snapshot = try decode(raw, as: TerminalSnapshot.self) - guard terminalSnapshotRequestTokens[sessionId] == requestToken, - desiredTerminalSessionIds.contains(sessionId), - isCurrentTerminalSnapshotRecoveryScope(scope) - else { return } - applyTerminalSnapshot(snapshot, sessionId: sessionId) - subscribedTerminalSessionIds.insert(sessionId) - flushTerminalInputQueue(sessionId: sessionId) } private func currentTerminalSnapshotRecoveryScope() -> TerminalSnapshotRecoveryScope { @@ -13254,8 +13264,13 @@ final class SyncService: ObservableObject { request.timeoutTask.cancel() switch result { case .success(let payload): - recordConnectionLoadSample(roundTripSeconds: ProcessInfo.processInfo.systemUptime - request.startedAt) - request.completion(.success(payload)) + do { + try request.acceptResponse?(payload) + recordConnectionLoadSample(roundTripSeconds: ProcessInfo.processInfo.systemUptime - request.startedAt) + request.completion(.success(payload)) + } catch { + request.completion(.failure(SyncUserFacingError.error(from: error))) + } case .failure(let error): request.completion(.failure(SyncUserFacingError.error(from: error))) } @@ -13266,6 +13281,7 @@ final class SyncService: ObservableObject { disconnectOnTimeout: Bool = true, timeoutMessage: String = SyncRequestTimeout.message, timeoutNanoseconds: UInt64 = SyncRequestTimeout.defaultTimeoutNanoseconds, + acceptResponse: (@MainActor (Any) throws -> Void)? = nil, send: () -> Void ) async throws -> Any { try await withCheckedThrowingContinuation { continuation in @@ -13284,6 +13300,7 @@ final class SyncService: ObservableObject { }, timeoutTask: timeoutTask, timeoutPolicy: timeoutPolicy, + acceptResponse: acceptResponse, startedAt: ProcessInfo.processInfo.systemUptime ) send() diff --git a/apps/ios/ADETests/SyncRecoveryPolicyTests.swift b/apps/ios/ADETests/SyncRecoveryPolicyTests.swift index eb30b42f6..801df66de 100644 --- a/apps/ios/ADETests/SyncRecoveryPolicyTests.swift +++ b/apps/ios/ADETests/SyncRecoveryPolicyTests.swift @@ -662,6 +662,54 @@ final class SyncRecoveryPolicyTests: XCTestCase { } } + @MainActor + func testTerminalSnapshotInstallsSubscriptionBeforeFollowingDataFrame() async throws { + let baseURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true) + let database = DatabaseService(baseURL: baseURL) + let service = SyncService(database: database) + service.beginOutboundEnvelopeCaptureForTesting() + service.configureConnectedTransportForTesting() + defer { + service.endOutboundEnvelopeCaptureForTesting() + service.disconnect(clearCredentials: false) + database.close() + try? FileManager.default.removeItem(at: baseURL) + } + + let snapshotTask = Task { + try await service.refreshTerminalSnapshot(sessionId: "terminal-barrier") + } + var requestId: String? + for _ in 0..<20 where requestId == nil { + await Task.yield() + requestId = service.capturedOutboundRequestIdsForTesting(type: "terminal_subscribe").first + } + let capturedRequestId = try XCTUnwrap(requestId) + + // Host ordering is snapshot then any PTY data queued behind its capture + // barrier. Accepting the snapshot must synchronously make that next frame + // deliverable; waiting for the request continuation can lose one-shot + // output when the receive loop wins the scheduling race. + service.completeTerminalSnapshotRequestForTesting( + requestId: capturedRequestId, + sessionId: "terminal-barrier", + transcript: "Mac% ", + startOffset: 0, + endOffset: 5 + ) + XCTAssertTrue(service.subscribedTerminalSessionIds.contains("terminal-barrier")) + + service.handleTerminalDataChunkForTesting( + sessionId: "terminal-barrier", + chunk: "pwd\r\n", + endOffset: 10 + ) + XCTAssertEqual(service.terminalBuffers["terminal-barrier"], "Mac% pwd\r\n") + try await snapshotTask.value + } + @MainActor func testTerminalSnapshotTimeoutRetriesAndRestoresLiveStreamWithoutSocketTeardown() async throws { let baseURL = FileManager.default.temporaryDirectory From 937e2a21b9d324d43549dbf49bef27d08c21fa92 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:52:39 -0400 Subject: [PATCH 33/53] fix(ios): finalize terminal snapshot recovery atomically --- apps/ios/ADE/Services/SyncService.swift | 6 ++++++ apps/ios/ADETests/SyncRecoveryPolicyTests.swift | 3 +++ 2 files changed, 9 insertions(+) diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index a9c2b9c2f..90e57cd62 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -6623,6 +6623,12 @@ final class SyncService: ObservableObject { self.isCurrentTerminalSnapshotRecoveryScope(scope) else { return } self.terminalSnapshotRequestTokens.removeValue(forKey: sessionId) + // Acceptance is the terminal state for any retry job serving this + // session. Clear its ownership in the same actor turn as the + // subscription barrier; otherwise callers can observe a live stream + // alongside a stale recovery marker until the awaiting retry task's + // defer gets scheduled. + self.terminalSnapshotRecoveryJobs.removeValue(forKey: sessionId) self.applyTerminalSnapshot(snapshot, sessionId: sessionId) self.subscribedTerminalSessionIds.insert(sessionId) self.flushTerminalInputQueue(sessionId: sessionId) diff --git a/apps/ios/ADETests/SyncRecoveryPolicyTests.swift b/apps/ios/ADETests/SyncRecoveryPolicyTests.swift index 801df66de..b6d898c24 100644 --- a/apps/ios/ADETests/SyncRecoveryPolicyTests.swift +++ b/apps/ios/ADETests/SyncRecoveryPolicyTests.swift @@ -766,6 +766,9 @@ final class SyncRecoveryPolicyTests: XCTestCase { } XCTAssertTrue(service.subscribedTerminalSessionIds.contains("terminal-retry")) XCTAssertEqual(service.terminalBuffers["terminal-retry"], "Mac% ") + // Snapshot acceptance and retry ownership cleanup are one atomic state + // transition; this assertion must not need a yield for the retry task's + // defer to catch up. XCTAssertFalse(service.hasTerminalSnapshotRecoveryForTesting(sessionId: "terminal-retry")) service.handleTerminalDataChunkForTesting( From 5675ef55ee48edfdabb39c607fe1dccb42376689 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 04:02:29 -0400 Subject: [PATCH 34/53] test(ios): reflect post-hello syncing lifecycle --- apps/ios/ADETests/ADETests.swift | 20 +++++------ .../ADETests/SyncRecoveryPolicyTests.swift | 34 +++++++++++++++++++ 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 5a80b2ae8..08582a6a4 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -5350,7 +5350,7 @@ final class ADETests: XCTestCase { XCTAssertEqual(attemptedAddresses, ["192.168.1.10", "192.168.1.11"]) XCTAssertEqual(winner, attempts[1]) XCTAssertEqual(failedCandidateStates, [.connecting]) - XCTAssertEqual(service.connectionState, .connected) + XCTAssertEqual(service.connectionState, .syncing) } @MainActor @@ -5385,7 +5385,7 @@ final class ADETests: XCTestCase { XCTAssertEqual(attemptedAddresses, ["100.64.0.10", "100.64.0.11"]) XCTAssertEqual(winner, attempts[1]) XCTAssertEqual(failedCandidateStates, [.error]) - XCTAssertEqual(service.connectionState, .connected) + XCTAssertEqual(service.connectionState, .syncing) } @MainActor @@ -5416,7 +5416,7 @@ final class ADETests: XCTestCase { } @MainActor - func testSyncServiceKeepsLegacyHelloConnectedInLimitedCompatibilityMode() async throws { + func testSyncServiceAcceptsLegacyHelloInLimitedCompatibilityMode() async throws { let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) try service.applyHelloPayloadForTesting([ @@ -5429,7 +5429,7 @@ final class ADETests: XCTestCase { ], ]) - XCTAssertEqual(service.connectionState, .connected) + XCTAssertEqual(service.connectionState, .syncing) XCTAssertEqual(service.hostCompatibilityMode, .limited) XCTAssertEqual(service.hostCompatibilityMissingActions, ["commandRouting"]) XCTAssertFalse(service.supportsRemoteAction("usage.getAdeStats")) @@ -5490,7 +5490,7 @@ final class ADETests: XCTestCase { ], ]) - XCTAssertEqual(service.connectionState, .connected) + XCTAssertEqual(service.connectionState, .syncing) XCTAssertEqual(service.hostCompatibilityMode, .limited) XCTAssertFalse(service.supportsRemoteAction("cto.startLinearMobileOAuth")) XCTAssertFalse(service.supportsRemoteAction("cto.setLinearToken")) @@ -5535,7 +5535,7 @@ final class ADETests: XCTestCase { ], ]) - XCTAssertEqual(service.connectionState, .connected) + XCTAssertEqual(service.connectionState, .syncing) XCTAssertEqual(service.hostCompatibilityMode, .full) XCTAssertTrue(service.supportsRemoteAction("cto.startLinearMobileOAuth")) XCTAssertTrue(service.supportsRemoteAction("cto.completeLinearMobileOAuth")) @@ -5596,7 +5596,7 @@ final class ADETests: XCTestCase { ], ]) - XCTAssertEqual(service.connectionState, .connected) + XCTAssertEqual(service.connectionState, .syncing) XCTAssertEqual(service.hostCompatibilityMode, .full) XCTAssertEqual(service.hostCompatibilityMissingActions, []) XCTAssertTrue(service.supportsRemoteAction("chat.send")) @@ -5640,7 +5640,7 @@ final class ADETests: XCTestCase { ], ]) - XCTAssertEqual(service.connectionState, .connected) + XCTAssertEqual(service.connectionState, .syncing) XCTAssertFalse(service.supportsChatRemoteAction("chat.cancelScheduledWork", sessionId: "chat-1")) XCTAssertFalse(service.canInvokeChatRemoteAction("chat.cancelScheduledWork", sessionId: "chat-1")) do { @@ -5679,7 +5679,7 @@ final class ADETests: XCTestCase { ], ]) - XCTAssertEqual(service.connectionState, .connected) + XCTAssertEqual(service.connectionState, .syncing) XCTAssertEqual(service.hostCompatibilityMode, .limited) XCTAssertEqual(service.hostCompatibilityMissingActions, ["prs.getMobileGithubDetail"]) XCTAssertFalse(service.supportsRemoteAction("prs.getMobileGithubDetail")) @@ -5982,7 +5982,7 @@ final class ADETests: XCTestCase { ], ]) - XCTAssertEqual(service.connectionState, .connected) + XCTAssertEqual(service.connectionState, .syncing) XCTAssertEqual(service.hostCompatibilityMode, .limited) XCTAssertTrue(service.supportsPersonalChats) XCTAssertTrue(service.supportsRemoteAction("personalChats.list")) diff --git a/apps/ios/ADETests/SyncRecoveryPolicyTests.swift b/apps/ios/ADETests/SyncRecoveryPolicyTests.swift index b6d898c24..aac2f1e02 100644 --- a/apps/ios/ADETests/SyncRecoveryPolicyTests.swift +++ b/apps/ios/ADETests/SyncRecoveryPolicyTests.swift @@ -1123,6 +1123,40 @@ final class SyncRecoveryPolicyTests: XCTestCase { XCTAssertEqual(service.nextReconnectDelayForTesting(), 1_000_000_000) } + @MainActor + func testSuccessfulPostHelloRestorationPublishesConnectedOnlyAfterCompletion() async throws { + let baseURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true) + let database = DatabaseService(baseURL: baseURL) + let service = SyncService(database: database) + let restoration = DeferredRecoveryWork() + service.configureConnectedTransportForTesting() + defer { + service.disconnect(clearCredentials: false) + database.close() + try? FileManager.default.removeItem(at: baseURL) + } + + try service.applyHelloPayloadForTesting([ + "brain": ["deviceId": "ready-host", "deviceName": "Mac Studio"], + "features": [:], + ]) + XCTAssertEqual(service.connectionState, .syncing) + + let postHello = Task { @MainActor in + await service.performPostHelloRestorationForTesting { + await restoration.wait() + } + } + while !restoration.isWaiting { await Task.yield() } + XCTAssertEqual(service.connectionState, .syncing) + + restoration.resume() + await postHello.value + XCTAssertEqual(service.connectionState, .connected) + } + @MainActor func testStalePostHelloRestorationCannotRepublishConnected() async throws { let baseURL = FileManager.default.temporaryDirectory From 370c48ffdf64908aab103f2626489b4a27b8d3ea Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 04:07:47 -0400 Subject: [PATCH 35/53] fix(ios): restore Work timeline tool grouping --- .../ios/ADE/Views/Work/WorkEventMapping.swift | 18 ++++++- .../WorkNavigationAndTranscriptHelpers.swift | 47 +++-------------- .../Work/WorkStatusAndFormattingHelpers.swift | 4 +- apps/ios/ADETests/ADETests.swift | 52 +++++++++++++------ 4 files changed, 62 insertions(+), 59 deletions(-) diff --git a/apps/ios/ADE/Views/Work/WorkEventMapping.swift b/apps/ios/ADE/Views/Work/WorkEventMapping.swift index 82c596607..f1e548849 100644 --- a/apps/ios/ADE/Views/Work/WorkEventMapping.swift +++ b/apps/ios/ADE/Views/Work/WorkEventMapping.swift @@ -303,7 +303,9 @@ func makeWorkChatEvent(from event: AgentChatEvent) -> WorkChatEvent { return .systemNotice(kind: noticeKind.rawValue, message: message, detail: prettyPrintedRemoteJSONValue(detail), turnId: turnId, steerId: steerId) case .error(let message, let detail, let turnId, _, let errorInfo): let detailText = detail ?? prettyPrintedRemoteJSONValue(errorInfo) - return .error(message: message, detail: detailText, category: workErrorCategory(message: message, detail: detailText), turnId: turnId) + let category = workStructuredErrorCategory(from: errorInfo) + ?? workErrorCategory(message: message, detail: detailText) + return .error(message: message, detail: detailText, category: category, turnId: turnId) case .done(let turnId, let status, let model, let modelId, let usage, let costUsd, let terminalReason): var parts = [status.rawValue.replacingOccurrences(of: "_", with: " ").capitalized] if let model, !model.isEmpty { @@ -574,6 +576,20 @@ func makeWorkChatEvent(from event: AgentChatEvent) -> WorkChatEvent { } } +private func workStructuredErrorCategory(from errorInfo: RemoteJSONValue?) -> String? { + guard case .object(let fields)? = errorInfo, + case .string(let rawCategory)? = fields["category"] + else { return nil } + + let category = rawCategory.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + switch category { + case "auth", "rate_limit", "network", "permission", "general", "busy", "unknown": + return category + default: + return nil + } +} + func ansiAttributedString(_ text: String) -> AttributedString { let key = text as NSString if let cached = workANSIAttributedStringCache.object(forKey: key) { diff --git a/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift b/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift index 9e576a6e3..867801195 100644 --- a/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift @@ -881,45 +881,14 @@ func buildWorkMobileTimelineToolCards( from transcript: [WorkChatEnvelope], suppressedPendingItemIds: Set = [] ) -> [WorkToolCardModel] { - var cards: [String: WorkToolCardModel] = [:] - var orderedIds: [String] = [] - - for envelope in transcript { - switch envelope.event { - case .toolCall(let tool, let argsText, let itemId, _, _): - guard isQuestionInputToolName(tool), - !suppressedPendingItemIds.contains(itemId), - pendingWorkQuestionFromAskUserToolCall(argsText: argsText, itemId: itemId) == nil - else { continue } - if cards[itemId] == nil { - orderedIds.append(itemId) - } - cards[itemId] = WorkToolCardModel( - id: itemId, - toolName: tool, - status: .running, - startedAt: envelope.timestamp, - completedAt: nil, - argsText: nonEmpty(argsText), - resultText: cards[itemId]?.resultText - ) - case .toolResult(let tool, let resultText, let itemId, _, _, let status): - guard isQuestionInputToolName(tool), let existing = cards[itemId] else { continue } - cards[itemId] = WorkToolCardModel( - id: itemId, - toolName: existing.toolName, - status: status, - startedAt: existing.startedAt, - completedAt: envelope.timestamp, - argsText: existing.argsText, - resultText: nonEmpty(resultText) - ) - default: - continue - } - } - - return orderedIds.compactMap { cards[$0] } + // The mobile timeline uses the same normalized tool-card stream as the full + // Work surface. `buildWorkToolCards` still suppresses a pending structured + // AskUser call (and every pending input id), but preserves ordinary work so + // the timeline can collapse Read/Edit/Shell bursts into compact groups. + buildWorkToolCards( + from: transcript, + suppressedPendingItemIds: suppressedPendingItemIds + ) } func parseANSISegments(_ input: String) -> [ANSISegment] { diff --git a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift index 3c76055f2..2a58eb441 100644 --- a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift @@ -115,8 +115,8 @@ func workChatComposerPlaceholder(pendingInputs: [WorkPendingInputItem], sessionS return "Type to vibecode..." } -func workMobileShowsToolCardInTimeline(_ card: WorkToolCardModel) -> Bool { - isQuestionInputToolName(card.toolName) +func workMobileShowsToolCardInTimeline(_: WorkToolCardModel) -> Bool { + true } struct WorkToolActivityPresentation: Equatable { diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 08582a6a4..a1cbfe6b8 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -446,7 +446,7 @@ final class ADETests: XCTestCase { let service = SyncService(database: database) SyncService.shared = service - DeepLinkRouter.shared.handle(try XCTUnwrap(URL(string: "ade://session/session%201%2F2?lane=lane%26active"))) + DeepLinkRouter.shared.handle(try XCTUnwrap(URL(string: "ade://session/session%201%2F2"))) XCTAssertEqual(service.requestedWorkSessionNavigation?.sessionId, "session 1/2") } @@ -969,11 +969,15 @@ final class ADETests: XCTestCase { func testWorkSessionDeepLinkMatchesDesktopSessionFormat() { XCTAssertEqual( workSessionDeepLink(sessionId: "session 1/2", laneId: "lane&active"), - "ade://session/session%201%2F2?lane=lane%26active" + "https://ade-app.dev/open?type=session&id=session%201%2F2&lane=lane%26active" ) XCTAssertEqual( workSessionDeepLink(sessionId: "session-plain", laneId: " "), - "ade://session/session-plain" + "https://ade-app.dev/open?type=session&id=session-plain" + ) + XCTAssertEqual( + workSessionDeepLink(sessionId: "session 1/2", laneId: "lane&active", form: .ade), + "ade://session/session%201%2F2?lane=lane%26active" ) } @@ -7421,7 +7425,7 @@ final class ADETests: XCTestCase { XCTAssertEqual(mirrored.first?.color, "violet") XCTAssertEqual(mirrored.last?.attachedRootPath, "/tmp/project/.ade/worktrees/linear-test") XCTAssertEqual(mirrored.last?.parentStatus?.dirty, true) - XCTAssertEqual(database.listWorkspaces().first?.isReadOnlyByDefault, true) + XCTAssertEqual(database.listWorkspaces().first?.isReadOnlyByDefault, false) database.close() } @@ -14116,7 +14120,7 @@ final class ADETests: XCTestCase { XCTAssertEqual(merged.count, 3) XCTAssertEqual(merged.map(\.timestamp), [ "2026-03-25T00:00:01.000Z", - "2026-03-25T00:00:03.000Z", + "2026-03-25T00:00:02.000Z", "2026-03-25T00:00:04.000Z", ]) XCTAssertEqual(merged[1].id, "chat-1:assistant-text:turn-1:msg-2") @@ -15977,7 +15981,7 @@ final class ADETests: XCTestCase { searchText: "" ) - XCTAssertEqual(filtered.map(\.id), ["chat-parent", "shell-child"]) + XCTAssertEqual(filtered.map(\.id), ["chat-parent", "shell-child", "legacy-cli"]) } func testWorkFilteredSessionsPrioritizesWaitingBeforeActiveAndEnded() { @@ -18066,7 +18070,7 @@ final class ADETests: XCTestCase { let cards = buildWorkEventCards(from: transcript).filter { $0.kind == "contextCompact" } XCTAssertEqual(cards.count, 1) - XCTAssertEqual(cards.first?.id, "context-compact:chat-1:turn:turn-compact") + XCTAssertEqual(cards.first?.id, "context-compact:chat-1:compaction:turn-compact") XCTAssertEqual(cards.first?.title, "Context compacted") XCTAssertEqual(cards.first?.body, "Manual\nPre-compact tokens: 12000") XCTAssertEqual(cards.first?.isInProgress, false) @@ -18173,11 +18177,11 @@ final class ADETests: XCTestCase { artifacts: [], localEchoMessages: [] ) - XCTAssertTrue(snapshot.toolCards.isEmpty) - XCTAssertFalse(snapshot.timeline.contains { entry in - if case .toolGroup = entry.payload { return true } - if case .toolCard = entry.payload { return true } - return false + XCTAssertEqual(snapshot.toolCards.map(\.id), ["call-dup"]) + XCTAssertTrue(snapshot.timeline.contains { entry in + guard case .toolGroup(let group) = entry.payload else { return false } + guard case .tool(let card)? = group.members.first else { return false } + return group.members.count == 1 && card.id == "call-dup" }) } @@ -18374,7 +18378,13 @@ final class ADETests: XCTestCase { XCTAssertTrue(reasoningCards.first?.body?.contains("First thought.") == true) XCTAssertTrue(reasoningCards.first?.body?.contains("Second thought.") == true) XCTAssertTrue(reasoningCards.first?.body?.contains("Third thought.") == true) - XCTAssertEqual(toolGroups.first?.count, 3) + XCTAssertEqual(toolGroups.first?.count, 2) + let changedFileGroups = snapshot.timeline.compactMap { entry -> WorkChangedFilesGroupModel? in + guard case .changedFiles(let group) = entry.payload else { return nil } + return group + } + XCTAssertEqual(changedFileGroups.count, 1) + XCTAssertEqual(changedFileGroups.first?.files.map(\.path), ["b.ts"]) } func testBuildWorkTimelineCollapsesReasoningTurnWithGroupedWorkRows() { @@ -18785,7 +18795,7 @@ final class ADETests: XCTestCase { XCTAssertTrue(cards.isEmpty) } - func testBuildWorkTimelineHidesNormalToolCallsOnMobile() { + func testBuildWorkTimelineShowsNormalToolCallsOnMobile() { let transcript: [WorkChatEnvelope] = [ WorkChatEnvelope( sessionId: "chat-1", @@ -18814,13 +18824,21 @@ final class ADETests: XCTestCase { localEchoMessages: [] ) - XCTAssertTrue(snapshot.toolCards.isEmpty) + XCTAssertEqual(snapshot.toolCards.map(\.id), ["tool-1"]) XCTAssertFalse(snapshot.eventCards.contains { $0.kind == "toolUseSummary" }) - XCTAssertEqual(snapshot.timeline.count, 1) + XCTAssertEqual(snapshot.timeline.count, 2) guard case .message(let message)? = snapshot.timeline.first?.payload else { - return XCTFail("Expected only the assistant message to remain visible.") + return XCTFail("Expected the assistant message before the tool group.") } XCTAssertEqual(message.markdown, "I will inspect it.") + guard case .toolGroup(let group)? = snapshot.timeline.last?.payload else { + return XCTFail("Expected the ordinary tool call in a compact tool group.") + } + guard case .tool(let card)? = group.members.first else { + return XCTFail("Expected the tool group to retain the Read card.") + } + XCTAssertEqual(group.members.count, 1) + XCTAssertEqual(card.id, "tool-1") } func testBuildWorkTimelineKeepsMalformedAskUserFallbackOnMobile() { From 2ee797ffcb5d22f6b128d01eacea04ee3144404b Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 04:10:34 -0400 Subject: [PATCH 36/53] fix runtime performance deadline layering --- apps/ade-cli/src/cli.test.ts | 10 ++ apps/ade-cli/src/cli.ts | 15 ++ .../ade-cli/src/multiProjectRpcServer.test.ts | 38 ++++- apps/ade-cli/src/multiProjectRpcServer.ts | 139 ++++++++++++++++-- .../src/main/services/ipc/ipcTimeouts.test.ts | 50 ++++++- .../src/main/services/ipc/ipcTimeouts.ts | 18 ++- .../localRuntimeConnectionPool.ts | 22 +-- .../localRuntime/localRuntimeTimeoutPolicy.ts | 48 ++++++ 8 files changed, 294 insertions(+), 46 deletions(-) create mode 100644 apps/desktop/src/main/services/localRuntime/localRuntimeTimeoutPolicy.ts diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 7013583ae..1a2a3ba7f 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -792,6 +792,16 @@ describe("ADE CLI", () => { }); }); + it("passes the project root to the hidden icon worker entrypoint", () => { + expect(buildCliPlan([ + "__ade-project-icon-worker", + "/tmp/project with spaces", + ])).toEqual({ + kind: "project-icon-worker", + rootPath: "/tmp/project with spaces", + }); + }); + it("classifies only ADE temp runtime sockets as ephemeral", () => { const tempSocket = path.join(os.tmpdir(), "ade-stdio-rpc-test", "sock", "ade.sock"); diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 76548025c..3f08b953a 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -50,6 +50,7 @@ import type { } from "../../desktop/src/shared/types/core"; import { resolveMachineAdeLayout } from "./services/projects/machineLayout"; import { markActiveHostProjectOpen } from "./services/projects/projectCatalog"; +import { resolveRemoteProjectIcon } from "./services/projects/projectIconResolver"; import { findAdeManagedWorktreeRoot, normalizeProjectRootPath, @@ -287,6 +288,7 @@ type CliPlan = | { kind: "serve"; rest: string[] } | { kind: "rpc-stdio"; rest: string[] } | { kind: "pty-host-worker" } + | { kind: "project-icon-worker"; rootPath: string } | { kind: "init"; targetPath: string | null } | { kind: "cursor-cloud"; rest: string[] } | { kind: "deeplink"; rest: string[] } @@ -11625,6 +11627,13 @@ function buildCliPlan( if (primary === "__ade-pty-host-worker") { return { kind: "pty-host-worker" }; } + if (primary === "__ade-project-icon-worker") { + const primaryIndex = args.indexOf(primary); + return { + kind: "project-icon-worker", + rootPath: args.slice(primaryIndex + 1).find((arg) => arg !== "--") ?? "", + }; + } if (primary === "code") { const rest = args; return { kind: "ade-code", rest }; @@ -19036,6 +19045,12 @@ async function runCli( output: formatOutput(plan.value, parsed.options, plan.formatter), exitCode: 0, }; + if (plan.kind === "project-icon-worker") { + return { + output: `${JSON.stringify(resolveRemoteProjectIcon(plan.rootPath))}\n`, + exitCode: 0, + }; + } if (plan.kind === "execute" && plan.laneCreationNudge) { const notice = detectUnmergedLaneCreateNudge(plan.laneCreationNudge); if (notice) process.stderr.write(`${notice}\n`); diff --git a/apps/ade-cli/src/multiProjectRpcServer.test.ts b/apps/ade-cli/src/multiProjectRpcServer.test.ts index c66403736..5b65c8be8 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.test.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.test.ts @@ -124,14 +124,14 @@ function makeRuntime(label: string) { } describe("multi-project RPC server", () => { - it("keeps the complete inline icon catalog below its hard wire budget", () => { + it("keeps the complete inline icon catalog below its hard wire budget", async () => { const records = Array.from({ length: 8 }, (_, index) => ({ rootPath: `/project-${index}`, lastOpenedAt: index, })); const iconPayload = `data:image/png;base64,${"a".repeat(100 * 1024)}`; - const decorated = decorateProjectListWithIcons(records, (rootPath) => ({ + const decorated = await decorateProjectListWithIcons(records, (rootPath) => ({ dataUrl: iconPayload, sourcePath: `${rootPath}/icon.png`, mimeType: "image/png", @@ -145,8 +145,8 @@ describe("multi-project RPC server", () => { expect(decorated.filter((record) => record.icon.dataUrl).length).toBe(5); }); - it("drops an individually oversized icon before it reaches the catalog", () => { - const [decorated] = decorateProjectListWithIcons( + it("drops an individually oversized icon before it reaches the catalog", async () => { + const [decorated] = await decorateProjectListWithIcons( [{ rootPath: "/project", lastOpenedAt: 1 }], () => ({ dataUrl: `data:image/png;base64,${"a".repeat(129 * 1024)}`, @@ -162,6 +162,36 @@ describe("multi-project RPC server", () => { }); }); + it("returns at the wall-clock icon budget when a resolver stalls", async () => { + const startedAt = performance.now(); + let eventLoopTicked = false; + const eventLoopProbe = setTimeout(() => { + eventLoopTicked = true; + }, 5); + const decorated = await decorateProjectListWithIcons( + [{ rootPath: "/slow-project", lastOpenedAt: 1 }], + async () => { + await new Promise((resolve) => setTimeout(resolve, 250)); + return { + dataUrl: "data:image/png;base64,YQ==", + sourcePath: "/slow-project/icon.png", + mimeType: "image/png", + }; + }, + 40, + ); + const elapsedMs = performance.now() - startedAt; + clearTimeout(eventLoopProbe); + + expect(elapsedMs).toBeLessThan(150); + expect(eventLoopTicked).toBe(true); + expect(decorated[0]?.icon).toEqual({ + dataUrl: null, + sourcePath: null, + mimeType: null, + }); + }); + it("reconciles account-owned client trust on sign-out and account switch", async () => { const { registry } = createRegistry(); const accountAuthService = makeAccountAuthServiceMock(); diff --git a/apps/ade-cli/src/multiProjectRpcServer.ts b/apps/ade-cli/src/multiProjectRpcServer.ts index 4d80621bc..09b16385c 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.ts @@ -1,5 +1,6 @@ import { createAdeRpcRequestHandler } from "./adeRpcServer"; import { createHash, randomUUID } from "node:crypto"; +import { execFile } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -258,38 +259,148 @@ const LIST_ICON_COUNT_BUDGET = 24; const LIST_ICON_BYTE_BUDGET = 512 * 1024; const LIST_ICON_RESOLVE_BUDGET_MS = 750; +type ProjectIconResolver = ( + rootPath: string, + timeoutMs: number, +) => ResolvedProjectIcon | Promise; + +function projectIconWorkerInvocation(rootPath: string): { + command: string; + args: string[]; +} { + const entryPath = process.argv[1] ?? ""; + const isCliScript = /(^|[/\\])cli\.(?:ts|js|cjs)$/i.test(entryPath) + && fs.existsSync(entryPath); + return isCliScript + ? { + command: process.execPath, + args: [ + ...process.execArgv, + entryPath, + "__ade-project-icon-worker", + rootPath, + ], + } + : { + // Node SEA executables re-enter the bundled CLI directly. Its SEA + // banner restores the synthetic cli.cjs argv entry before parsing. + command: process.execPath, + args: ["__ade-project-icon-worker", rootPath], + }; +} + +function isResolvedProjectIcon(value: unknown): value is ResolvedProjectIcon { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const icon = value as Record; + return [icon.dataUrl, icon.sourcePath, icon.mimeType].every( + (field) => field === null || typeof field === "string", + ); +} + +// Icon discovery includes synchronous directory scans and a native rasterizer. +// Run it outside the RPC process so the connect-critical event loop remains +// responsive and the parent can enforce a real wall-clock deadline by killing +// a worker that outlives its remaining catalog budget. +function resolveRemoteProjectIconInWorker( + rootPath: string, + timeoutMs: number, +): Promise { + if (timeoutMs <= 0) return Promise.resolve(EMPTY_PROJECT_ICON); + const invocation = projectIconWorkerInvocation(rootPath); + return new Promise((resolve) => { + execFile( + invocation.command, + invocation.args, + { + timeout: Math.max(1, Math.floor(timeoutMs)), + killSignal: "SIGKILL", + maxBuffer: REMOTE_ICON_MAX_DATA_URL_BYTES + 16 * 1024, + encoding: "utf8", + }, + (error, stdout) => { + if (error) { + resolve(EMPTY_PROJECT_ICON); + return; + } + try { + const icon = JSON.parse(stdout.trim()) as unknown; + resolve(isResolvedProjectIcon(icon) ? icon : EMPTY_PROJECT_ICON); + } catch { + resolve(EMPTY_PROJECT_ICON); + } + }, + ); + }); +} + +async function resolveIconBeforeDeadline( + resolveIcon: ProjectIconResolver, + rootPath: string, + timeoutMs: number, +): Promise { + if (timeoutMs <= 0) return EMPTY_PROJECT_ICON; + let timer: ReturnType | null = null; + try { + return await Promise.race([ + Promise.resolve().then(() => resolveIcon(rootPath, timeoutMs)), + new Promise((resolve) => { + timer = setTimeout(() => resolve(EMPTY_PROJECT_ICON), timeoutMs); + }), + ]); + } catch { + return EMPTY_PROJECT_ICON; + } finally { + if (timer) clearTimeout(timer); + } +} + // Stamp a single project record with its host-resolved icon so a remote desktop // can render the real project logo. Used for the records returned by // add/create/clone (which feed the desktop's cached connection.projects), so a // freshly registered project opens with its icon instead of a blank folder. // Best-effort: a failed resolve degrades to a null icon and never throws. -function decorateProjectWithIcon( +async function decorateProjectWithIcon( record: T, -): T & { icon: ResolvedProjectIcon } { - return { ...record, icon: resolveRemoteProjectIcon(record.rootPath) }; +): Promise { + return { + ...record, + icon: await resolveIconBeforeDeadline( + resolveRemoteProjectIconInWorker, + record.rootPath, + LIST_ICON_RESOLVE_BUDGET_MS, + ), + }; } // Decorate a full project list with icons under the connect-path budget above. // Icons are resolved for the most-recently-opened projects first (those most // likely to be open as tabs) while the returned array stays in registry order. -export function decorateProjectListWithIcons( +export async function decorateProjectListWithIcons( records: readonly T[], - resolveIcon: (rootPath: string) => ResolvedProjectIcon = resolveRemoteProjectIcon, -): Array { + resolveIcon: ProjectIconResolver = resolveRemoteProjectIconInWorker, + resolveBudgetMs = LIST_ICON_RESOLVE_BUDGET_MS, +): Promise> { const icons = new Map(); let count = 0; let bytes = 0; const startedAt = Date.now(); const byRecency = records .map((record, index) => ({ record, index })) - .sort((a, b) => b.record.lastOpenedAt - a.record.lastOpenedAt); + .sort((a, b) => + b.record.lastOpenedAt - a.record.lastOpenedAt || a.index - b.index + ); for (const { record, index } of byRecency) { + const elapsedMs = Date.now() - startedAt; if ( count >= LIST_ICON_COUNT_BUDGET || bytes >= LIST_ICON_BYTE_BUDGET - || Date.now() - startedAt >= LIST_ICON_RESOLVE_BUDGET_MS + || elapsedMs >= resolveBudgetMs ) break; - const icon = resolveIcon(record.rootPath); + const icon = await resolveIconBeforeDeadline( + resolveIcon, + record.rootPath, + resolveBudgetMs - elapsedMs, + ); count += 1; const iconBytes = icon.dataUrl ? Buffer.byteLength(icon.dataUrl, "utf8") @@ -1028,7 +1139,7 @@ export function createMultiProjectRpcRequestHandler( } if (method === "projects.list") { - return decorateProjectListWithIcons(projectRegistry.list()); + return await decorateProjectListWithIcons(projectRegistry.list()); } if (method === "projects.add") { @@ -1040,7 +1151,7 @@ export function createMultiProjectRpcRequestHandler( "projects.add requires rootPath.", ); } - return decorateProjectWithIcon( + return await decorateProjectWithIcon( projectRegistry.add(rootPath, readProjectRegistrationIntent(params)), ); } @@ -1060,7 +1171,7 @@ export function createMultiProjectRpcRequestHandler( registration.catalogVisibility, registration.registrationSource, ); - return project ? decorateProjectWithIcon(project) : null; + return project ? await decorateProjectWithIcon(project) : null; } if (method === "projects.remove") { @@ -1140,7 +1251,7 @@ export function createMultiProjectRpcRequestHandler( await createMachineProjectScaffoldService().createLocalProject( readCreateProjectInput(params), ); - return decorateProjectWithIcon( + return await decorateProjectWithIcon( projectRegistry.add( result.rootPath, readProjectRegistrationIntent(params), @@ -1153,7 +1264,7 @@ export function createMultiProjectRpcRequestHandler( await createMachineProjectScaffoldService().cloneRepository( readCloneProjectInput(params), ); - return decorateProjectWithIcon( + return await decorateProjectWithIcon( projectRegistry.add( result.rootPath, readProjectRegistrationIntent(params), diff --git a/apps/desktop/src/main/services/ipc/ipcTimeouts.test.ts b/apps/desktop/src/main/services/ipc/ipcTimeouts.test.ts index 3aaf11601..9773f7d79 100644 --- a/apps/desktop/src/main/services/ipc/ipcTimeouts.test.ts +++ b/apps/desktop/src/main/services/ipc/ipcTimeouts.test.ts @@ -1,12 +1,36 @@ import { describe, expect, it } from "vitest"; import { IPC } from "../../../shared/ipc"; import { ipcInvokeTimeoutMs } from "./ipcTimeouts"; +import { + LOCAL_RUNTIME_IPC_COMPLETION_HEADROOM_MS, + LOCAL_RUNTIME_IPC_PROJECT_SETUP_TIMEOUT_MS, + LOCAL_RUNTIME_PROJECT_TIMEOUT_MS, + longRunningLocalRuntimeActionTimeoutMs, +} from "../localRuntime/localRuntimeTimeoutPolicy"; describe("ipcInvokeTimeoutMs", () => { - it("uses the lane delete budget for runtime-backed lane delete actions", () => { - expect(ipcInvokeTimeoutMs(IPC.localRuntimeCallAction, [{ + it("keeps local lane delete IPC alive through cold setup and the daemon action", () => { + const innerTimeoutMs = longRunningLocalRuntimeActionTimeoutMs("lane.delete")!; + const outerTimeoutMs = ipcInvokeTimeoutMs(IPC.localRuntimeCallAction, [{ request: { domain: "lane", action: "delete", args: { laneId: "lane-1" } }, - }])).toBe(4 * 60_000); + }]); + + expect(innerTimeoutMs).toBe(4 * 60_000); + expect(outerTimeoutMs).toBe( + LOCAL_RUNTIME_IPC_PROJECT_SETUP_TIMEOUT_MS + + innerTimeoutMs + + LOCAL_RUNTIME_IPC_COMPLETION_HEADROOM_MS, + ); + // Model the full cold setup allowance (connect + projects.add) followed by + // the full daemon delete budget. The renderer timer still owns the explicit + // completion headroom instead of racing the inner timer. + expect( + outerTimeoutMs + - (LOCAL_RUNTIME_IPC_PROJECT_SETUP_TIMEOUT_MS + innerTimeoutMs), + ).toBe(LOCAL_RUNTIME_IPC_COMPLETION_HEADROOM_MS); + expect(LOCAL_RUNTIME_IPC_PROJECT_SETUP_TIMEOUT_MS) + .toBeGreaterThan(LOCAL_RUNTIME_PROJECT_TIMEOUT_MS); + expect(ipcInvokeTimeoutMs(IPC.remoteRuntimeCallAction, [{ id: "target-1", projectId: "project-1", @@ -14,11 +38,23 @@ describe("ipcInvokeTimeoutMs", () => { }])).toBe(4 * 60_000); }); - it("uses a bounded archive budget on direct and runtime-backed paths", () => { + it("composes cold setup, daemon action, and headroom for archive and unarchive", () => { expect(ipcInvokeTimeoutMs(IPC.lanesArchive)).toBe(4 * 60_000); - expect(ipcInvokeTimeoutMs(IPC.localRuntimeCallAction, [{ - request: { domain: "lane", action: "archive", args: { laneId: "lane-1" } }, - }])).toBe(4 * 60_000); + for (const action of ["archive", "unarchive"] as const) { + const innerTimeoutMs = longRunningLocalRuntimeActionTimeoutMs(`lane.${action}`)!; + const outerTimeoutMs = ipcInvokeTimeoutMs(IPC.localRuntimeCallAction, [{ + request: { domain: "lane", action, args: { laneId: "lane-1" } }, + }]); + expect(outerTimeoutMs).toBe( + LOCAL_RUNTIME_IPC_PROJECT_SETUP_TIMEOUT_MS + + innerTimeoutMs + + LOCAL_RUNTIME_IPC_COMPLETION_HEADROOM_MS, + ); + expect( + outerTimeoutMs + - (LOCAL_RUNTIME_IPC_PROJECT_SETUP_TIMEOUT_MS + innerTimeoutMs), + ).toBe(LOCAL_RUNTIME_IPC_COMPLETION_HEADROOM_MS); + } }); it("gives ordinary local runtime calls enough time to bind a cold project", () => { diff --git a/apps/desktop/src/main/services/ipc/ipcTimeouts.ts b/apps/desktop/src/main/services/ipc/ipcTimeouts.ts index a83b7836e..f6d5e4c38 100644 --- a/apps/desktop/src/main/services/ipc/ipcTimeouts.ts +++ b/apps/desktop/src/main/services/ipc/ipcTimeouts.ts @@ -1,5 +1,9 @@ import { IPC } from "../../../shared/ipc"; import { isRetryableRemoteAction } from "../remoteRuntime/retryableRemoteActions"; +import { + destructiveLaneLocalRuntimeIpcTimeoutMs, + LOCAL_RUNTIME_IPC_PROJECT_SETUP_TIMEOUT_MS, +} from "../localRuntime/localRuntimeTimeoutPolicy"; function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); @@ -25,7 +29,6 @@ const RUNTIME_ACTION_CHANNEL: Record> = { }, }; -const LOCAL_RUNTIME_PROJECT_SETUP_TIMEOUT_MS = 150_000; const REMOTE_RUNTIME_BOOTSTRAP_TIMEOUT_MS = 10 * 60_000; const REMOTE_RUNTIME_RETRYABLE_ACTION_TIMEOUT_MS = 75_000; @@ -50,12 +53,19 @@ function retryableRemoteActionTimeoutMs(args: readonly unknown[]): number | null export function ipcInvokeTimeoutMs(channel: string, args: readonly unknown[] = []): number { if (channel === IPC.localRuntimeCallAction) { + const payload = args[0]; + const request = isRecord(payload) && isRecord(payload.request) ? payload.request : null; + const destructiveLaneTimeoutMs = typeof request?.domain === "string" + && typeof request.action === "string" + ? destructiveLaneLocalRuntimeIpcTimeoutMs(request.domain, request.action) + : null; + if (destructiveLaneTimeoutMs != null) return destructiveLaneTimeoutMs; const actionTimeoutMs = runtimeActionTimeoutMs(args); if (actionTimeoutMs != null) return actionTimeoutMs; - return LOCAL_RUNTIME_PROJECT_SETUP_TIMEOUT_MS; + return LOCAL_RUNTIME_IPC_PROJECT_SETUP_TIMEOUT_MS; } if (channel === IPC.localRuntimeCallSync || channel === IPC.localRuntimeListActionRegistry || channel === IPC.localRuntimeStreamEvents) { - return LOCAL_RUNTIME_PROJECT_SETUP_TIMEOUT_MS; + return LOCAL_RUNTIME_IPC_PROJECT_SETUP_TIMEOUT_MS; } if (channel === IPC.remoteRuntimeCallAction) { const actionTimeoutMs = runtimeActionTimeoutMs(args); @@ -69,7 +79,7 @@ export function ipcInvokeTimeoutMs(channel: string, args: readonly unknown[] = [ // renderer's outcome known until the same setup budget used by local // runtime calls expires. case IPC.projectSwitchToPath: - return LOCAL_RUNTIME_PROJECT_SETUP_TIMEOUT_MS; + return LOCAL_RUNTIME_IPC_PROJECT_SETUP_TIMEOUT_MS; case IPC.remoteRuntimeConnect: case IPC.remoteRuntimeListProjects: case IPC.remoteRuntimeAddProject: diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts index 93bd90769..f3b2cfcfd 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts @@ -39,6 +39,10 @@ import { readLastFailure } from "../runtime/lastFailureStore"; import type { AdeRecoveryErrorCode } from "../../../shared/types/recovery"; import { LOCAL_RELEASE_BUILD_OUTPUT_RUNTIME_MESSAGE } from "../../../shared/runtimeErrors"; import type { RuntimeHealthSnapshot } from "../../../shared/types/storage"; +import { + LOCAL_RUNTIME_PROJECT_TIMEOUT_MS, + longRunningLocalRuntimeActionTimeoutMs, +} from "./localRuntimeTimeoutPolicy"; const SLOW_ACTION_THRESHOLD_MS = 500; const RUNTIME_HEALTH_WINDOW_MS = 24 * 60 * 60_000; @@ -81,26 +85,10 @@ type LocalRuntimeConnectionPoolOptions = { type LocalRuntimeNodePathOptions = PackagedRuntimeNodePathOptions; -const LOCAL_RUNTIME_PROJECT_TIMEOUT_MS = 120_000; const LOCAL_RUNTIME_ACTION_TIMEOUT_MS = 30_000; const LOCAL_RUNTIME_SERVICE_UNINSTALL_TIMEOUT_MS = 20_000; const LOCAL_RUNTIME_FILE_ACTION_TIMEOUT_MS = 8_000; const LOCAL_RUNTIME_EVENT_POLL_TIMEOUT_MS = 2_000; -const LONG_RUNNING_LOCAL_RUNTIME_ACTION_TIMEOUTS: ReadonlyMap = new Map([ - // Lane deletion can legitimately include a 60s worktree removal followed by - // a 45s remote-branch deletion. The old 30s client budget reported failure - // while the daemon kept mutating state to a successful completion. - ["lane.delete", 4 * 60_000], - ["lane.archive", 120_000], - ["lane.unarchive", 120_000], - ["chat.suggestLaneNameFromPrompt", 120_000], - // Handoff = AI brief generation (bounded at 45s) + session creation + - // provider dispatch of the first message; the 30s default fired a false - // timeout while the daemon-side handoff kept running to a late "surprise" - // success (ADE-122). - ["chat.handoffSession", 120_000], - ["chat.prepareCrossMachineHandoff", 120_000], -]); const PLACEHOLDER_RUNTIME_VERSION = "0.0.0"; const LOCAL_RUNTIME_OUTPUT_LINE_MAX_CHARS = 4_000; const LOCAL_RUNTIME_OUTPUT_BUFFER_MAX_CHARS = 16_000; @@ -1226,7 +1214,7 @@ export class LocalRuntimeConnectionPool { const tConnect = Date.now(); const actionKey = `${request.domain}.${request.action}`; const actionCallOptions = { - timeoutMs: LONG_RUNNING_LOCAL_RUNTIME_ACTION_TIMEOUTS.get(actionKey) + timeoutMs: longRunningLocalRuntimeActionTimeoutMs(actionKey) ?? (request.domain === "file" ? LOCAL_RUNTIME_FILE_ACTION_TIMEOUT_MS : LOCAL_RUNTIME_ACTION_TIMEOUT_MS), diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeTimeoutPolicy.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeTimeoutPolicy.ts new file mode 100644 index 000000000..5664048b3 --- /dev/null +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeTimeoutPolicy.ts @@ -0,0 +1,48 @@ +export const LOCAL_RUNTIME_PROJECT_TIMEOUT_MS = 120_000; +export const LOCAL_RUNTIME_IPC_PROJECT_SETUP_TIMEOUT_MS = 150_000; +export const LOCAL_RUNTIME_IPC_COMPLETION_HEADROOM_MS = 15_000; + +const LONG_RUNNING_LOCAL_RUNTIME_ACTION_TIMEOUTS: ReadonlyMap = new Map([ + // Lane deletion can legitimately include a 60s worktree removal followed by + // a 45s remote-branch deletion. The old 30s client budget reported failure + // while the daemon kept mutating state to a successful completion. + ["lane.delete", 4 * 60_000], + ["lane.archive", 120_000], + ["lane.unarchive", 120_000], + ["chat.suggestLaneNameFromPrompt", 120_000], + // Handoff = AI brief generation (bounded at 45s) + session creation + + // provider dispatch of the first message; the 30s default fired a false + // timeout while the daemon-side handoff kept running to a late "surprise" + // success (ADE-122). + ["chat.handoffSession", 120_000], + ["chat.prepareCrossMachineHandoff", 120_000], +]); + +const DESTRUCTIVE_LANE_ACTIONS = new Set([ + "lane.archive", + "lane.delete", + "lane.unarchive", +]); + +export function longRunningLocalRuntimeActionTimeoutMs( + actionKey: string, +): number | null { + return LONG_RUNNING_LOCAL_RUNTIME_ACTION_TIMEOUTS.get(actionKey) ?? null; +} + +// The renderer-side IPC timer starts before a cold project is registered and +// connected, while the daemon action timer starts afterwards. Compose those +// sequential budgets and retain explicit delivery headroom so IPC cannot +// report a false timeout immediately before a destructive action resolves. +export function destructiveLaneLocalRuntimeIpcTimeoutMs( + domain: string, + action: string, +): number | null { + const actionKey = `${domain}.${action}`; + if (!DESTRUCTIVE_LANE_ACTIONS.has(actionKey)) return null; + const actionTimeoutMs = longRunningLocalRuntimeActionTimeoutMs(actionKey); + if (actionTimeoutMs == null) return null; + return LOCAL_RUNTIME_IPC_PROJECT_SETUP_TIMEOUT_MS + + actionTimeoutMs + + LOCAL_RUNTIME_IPC_COMPLETION_HEADROOM_MS; +} From af91188d094267d05b62f2c34dbe339d4982b4f9 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 04:13:38 -0400 Subject: [PATCH 37/53] test(ios): align Work tool timeline expectations --- apps/ios/ADETests/ADETests.swift | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index a1cbfe6b8..d391f70b6 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -12550,6 +12550,7 @@ final class ADETests: XCTestCase { XCTAssertEqual(visibleKinds, [ "user", "assistant:msg-progress", + "tool:tool-1", "assistant:msg-final", ]) } @@ -18309,12 +18310,23 @@ final class ADETests: XCTestCase { localEchoMessages: [] ) - XCTAssertTrue(snapshot.toolCards.isEmpty) - XCTAssertFalse(snapshot.timeline.contains { entry in - if case .toolGroup = entry.payload { return true } - if case .toolCard = entry.payload { return true } - return false - }) + let toolGroups = snapshot.timeline.compactMap { entry -> WorkToolGroupModel? in + guard case .toolGroup(let group) = entry.payload else { return nil } + return group + } + let standaloneToolCards = snapshot.timeline.compactMap { entry -> WorkToolCardModel? in + guard case .toolCard(let card) = entry.payload else { return nil } + return card + } + + XCTAssertEqual(toolGroups.count, 1) + XCTAssertEqual(toolGroups.first?.members.count, 2) + XCTAssertTrue(standaloneToolCards.isEmpty) + guard case .tool(let latest)? = toolGroups.first?.latest else { + return XCTFail("Expected the latest visible group member to be the newest tool call.") + } + XCTAssertEqual(latest.id, "tool-2") + XCTAssertEqual(latest.status, .running) } func testBuildWorkTimelineCollapsesAlternatingReasoningAndToolBursts() { From d0189afddfd463e1dc75a76626ce897b0dabcad0 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 04:19:41 -0400 Subject: [PATCH 38/53] fix(web): pin visible chat subscriptions --- .../adapter/__tests__/adapter.test.ts | 111 +++++++++++++++++- .../renderer/webclient/adapter/agentChat.ts | 38 +++--- .../webclient/adapter/personalChats.ts | 19 +-- 3 files changed, 126 insertions(+), 42 deletions(-) diff --git a/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts b/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts index 58c22a78e..bcf1f8682 100644 --- a/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts +++ b/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts @@ -348,6 +348,109 @@ describe("createAdeWebAdapter", () => { expect(fake.chatUnsubscribeCalls).toHaveLength(9); }); + it("pins the visible project chat while more than eight background chats touch the LRU", async () => { + fake.descriptors = descriptors(["chat.getChatEventHistory", "chat.getSummary"]); + const adapter = createAdeWebAdapter(fake.asClient()); + adapter.bindProject(project, "project-1"); + const received: SyncChatEventPayload[] = []; + adapter.ade.agentChat.onEvent((event) => received.push(event as SyncChatEventPayload)); + + await adapter.ade.agentChat.getEventHistory({ sessionId: "chat-visible-oldest" }); + for (let index = 1; index <= 8; index += 1) { + fake.commandResults.set("chat.getSummary", { sessionId: `chat-background-${index}` }); + await adapter.ade.agentChat.getSummary({ sessionId: `chat-background-${index}` }); + } + + expect(fake.chatUnsubscribeCalls).toEqual(["chat-background-1"]); + expect(fake.chatUnsubscribeCalls).not.toContain("chat-visible-oldest"); + + const done = { + sessionId: "chat-visible-oldest", + seq: 41, + timestamp: "2026-07-20T00:02:00.000Z", + event: { + type: "done", + turnId: "turn-visible", + status: "completed", + model: "gpt-5.6", + modelId: "openai/gpt-5.6", + }, + } as SyncChatEventPayload; + fake.emitChatSnapshot("chat-visible-oldest", { + sessionId: "chat-visible-oldest", + capturedAt: "2026-07-20T00:02:01.000Z", + truncated: false, + resumed: true, + events: [done], + }); + + expect(received).toEqual([done]); + adapter.dispose(); + }); + + it("moves the visible pin on selection change and evicts the old selection first", async () => { + fake.descriptors = descriptors(["chat.getChatEventHistory", "chat.getSummary"]); + const adapter = createAdeWebAdapter(fake.asClient()); + adapter.bindProject(project, "project-1"); + + await adapter.ade.agentChat.getEventHistory({ sessionId: "chat-selection-old" }); + for (let index = 1; index <= 7; index += 1) { + fake.commandResults.set("chat.getSummary", { sessionId: `chat-selection-background-${index}` }); + await adapter.ade.agentChat.getSummary({ sessionId: `chat-selection-background-${index}` }); + } + await adapter.ade.agentChat.getEventHistory({ sessionId: "chat-selection-new" }); + + expect(fake.chatUnsubscribeCalls).toEqual(["chat-selection-old"]); + + fake.commandResults.set("chat.getSummary", { sessionId: "chat-selection-background-8" }); + await adapter.ade.agentChat.getSummary({ sessionId: "chat-selection-background-8" }); + expect(fake.chatUnsubscribeCalls).toEqual([ + "chat-selection-old", + "chat-selection-background-1", + ]); + expect(fake.chatUnsubscribeCalls).not.toContain("chat-selection-new"); + + adapter.dispose(); + }); + + it("drops every old-project stream and starts a fresh bounded pin after project handoff", async () => { + fake.descriptors = descriptors(["chat.getChatEventHistory", "chat.getSummary"]); + const adapter = createAdeWebAdapter(fake.asClient()); + adapter.bindProject(project, "project-1"); + + await adapter.ade.agentChat.getEventHistory({ sessionId: "chat-project-one-visible" }); + for (let index = 1; index <= 7; index += 1) { + fake.commandResults.set("chat.getSummary", { sessionId: `chat-project-one-${index}` }); + await adapter.ade.agentChat.getSummary({ sessionId: `chat-project-one-${index}` }); + } + + const projectTwo = { ...project, rootPath: "/repo-2", displayName: "Repo Two" }; + adapter.replaceProject(projectTwo, "project-2"); + expect(new Set(fake.chatUnsubscribeCalls)).toEqual(new Set([ + "chat-project-one-visible", + "chat-project-one-1", + "chat-project-one-2", + "chat-project-one-3", + "chat-project-one-4", + "chat-project-one-5", + "chat-project-one-6", + "chat-project-one-7", + ])); + + await adapter.ade.agentChat.getEventHistory({ sessionId: "chat-project-two-visible" }); + for (let index = 1; index <= 8; index += 1) { + fake.commandResults.set("chat.getSummary", { sessionId: `chat-project-two-${index}` }); + await adapter.ade.agentChat.getSummary({ sessionId: `chat-project-two-${index}` }); + } + + expect(fake.chatUnsubscribeCalls).toContain("chat-project-two-1"); + expect(fake.chatUnsubscribeCalls).not.toContain("chat-project-two-visible"); + expect(fake.chatSubscribeCalls.at(-1)?.sessionId).toBe("chat-project-two-8"); + expect(fake.commandCalls.at(-1)?.opts.projectId).toBe("project-2"); + + adapter.dispose(); + }); + it("does not subscribe every chat returned by a session-list read", async () => { fake.descriptors = descriptors([ "chat.listSessions", @@ -420,7 +523,7 @@ describe("createAdeWebAdapter", () => { adapter.dispose(); }); - it("accepts a restarted project chat seq before its non-resumed snapshot", async () => { + it("accepts a restarted project chat seq without duplicating a non-resumed snapshot replay", async () => { fake.descriptors = descriptors(["chat.getSummary"]); fake.commandResults.set("chat.getSummary", { sessionId: "chat-restarted" }); const adapter = createAdeWebAdapter(fake.asClient()); @@ -450,7 +553,7 @@ describe("createAdeWebAdapter", () => { capturedAt: "2026-07-20T00:00:01.000Z", truncated: false, resumed: false, - events: [transcriptChatEvent("chat-restarted", 1, "snapshot-after-restart")], + events: [liveAfterRestart, transcriptChatEvent("chat-restarted", 1, "snapshot-after-restart")], }); fake.emitChat({ ...unrelatedEvent }); @@ -632,7 +735,7 @@ describe("createAdeWebAdapter", () => { adapter.dispose(); }); - it("accepts a restarted personal-chat seq before its non-resumed snapshot", async () => { + it("accepts a restarted personal-chat seq without duplicating a non-resumed snapshot replay", async () => { fake.descriptors = [{ action: "personalChats.list", scope: "runtime", @@ -673,7 +776,7 @@ describe("createAdeWebAdapter", () => { capturedAt: "2026-07-20T00:00:01.000Z", truncated: false, resumed: false, - events: [transcriptChatEvent("personal-restarted", 1, "snapshot-after-restart")], + events: [liveAfterRestart, transcriptChatEvent("personal-restarted", 1, "snapshot-after-restart")], }); fake.emitChat({ ...unrelatedEvent }); diff --git a/apps/desktop/src/renderer/webclient/adapter/agentChat.ts b/apps/desktop/src/renderer/webclient/adapter/agentChat.ts index 46e3a3485..b7d1dbbe9 100644 --- a/apps/desktop/src/renderer/webclient/adapter/agentChat.ts +++ b/apps/desktop/src/renderer/webclient/adapter/agentChat.ts @@ -26,6 +26,7 @@ export function createAgentChatNamespace(infra: AdapterInfra): AdeNamespace<"age const chatSubscriptions = new Map void>(); const deliveredEvents: string[] = []; const deliveredEventSet = new Set(); + let visibleSessionId: string | null = null; function emitChatEvent(payload: SyncChatEventPayload): void { const key = chatEventDedupKey(payload); @@ -39,22 +40,12 @@ export function createAgentChatNamespace(infra: AdapterInfra): AdeNamespace<"age events.emit("agentChatEvent", payload); } - function resetDeliveredSyncEpoch(sessionId: string): void { - const prefix = `${sessionId}:sync-seq:`; - let writeIndex = 0; - for (const key of deliveredEvents) { - if (key.startsWith(prefix)) { - deliveredEventSet.delete(key); - continue; - } - deliveredEvents[writeIndex] = key; - writeIndex += 1; - } - deliveredEvents.length = writeIndex; - } - - function ensureChatSubscription(sessionId: string | null | undefined): void { + function ensureChatSubscription( + sessionId: string | null | undefined, + options: { visible?: boolean } = {}, + ): void { if (!sessionId) return; + if (options.visible) visibleSessionId = sessionId; const existingUnsubscribe = chatSubscriptions.get(sessionId); if (existingUnsubscribe) { // Map insertion order is the LRU order. A selected/reused chat should @@ -64,8 +55,12 @@ export function createAgentChatNamespace(infra: AdapterInfra): AdeNamespace<"age return; } while (chatSubscriptions.size >= WEB_CHAT_PROJECT_SUBSCRIPTION_LIMIT) { - const oldest = chatSubscriptions.entries().next().value as [string, () => void] | undefined; - if (!oldest) break; + const oldest = [...chatSubscriptions.entries()].find(([candidateId]) => ( + candidateId !== visibleSessionId + )); + // Preserve the hard bound even if future pinning rules ever make every + // resident stream ineligible for eviction. + if (!oldest) return; chatSubscriptions.delete(oldest[0]); oldest[1](); } @@ -74,7 +69,6 @@ export function createAgentChatNamespace(infra: AdapterInfra): AdeNamespace<"age { maxBytes: WEB_CHAT_INITIAL_SNAPSHOT_MAX_BYTES }, { snapshot: (payload) => { - if (payload.resumed !== true) resetDeliveredSyncEpoch(payload.sessionId); for (const event of payload.events) emitChatEvent(event as SyncChatEventPayload); }, event: (payload) => { @@ -110,6 +104,7 @@ export function createAgentChatNamespace(infra: AdapterInfra): AdeNamespace<"age infra.addDispose(events.on("projectBoundary", () => { for (const unsubscribe of chatSubscriptions.values()) unsubscribe(); chatSubscriptions.clear(); + visibleSessionId = null; deliveredEvents.length = 0; deliveredEventSet.clear(); })); @@ -272,7 +267,10 @@ export function createAgentChatNamespace(infra: AdapterInfra): AdeNamespace<"age }, getEventHistory: async (args: unknown) => { const record = asRecord(args); - ensureChatSubscription(stringField(record, "sessionId")); + // AgentChatPane requests history when a session becomes visible. Metadata + // reads such as list/getSummary are also used for background rows, so this + // is the adapter's authoritative selected-session signal. + ensureChatSubscription(stringField(record, "sessionId"), { visible: true }); return await call( "chat.getChatEventHistory", { @@ -296,7 +294,7 @@ export function createAgentChatNamespace(infra: AdapterInfra): AdeNamespace<"age }, getEventHistoryPage: async (args: unknown) => { const record = asRecord(args); - ensureChatSubscription(stringField(record, "sessionId")); + ensureChatSubscription(stringField(record, "sessionId"), { visible: true }); return await call( "chat.getChatEventHistoryPage", args, diff --git a/apps/desktop/src/renderer/webclient/adapter/personalChats.ts b/apps/desktop/src/renderer/webclient/adapter/personalChats.ts index 83f861b6a..74379a9c9 100644 --- a/apps/desktop/src/renderer/webclient/adapter/personalChats.ts +++ b/apps/desktop/src/renderer/webclient/adapter/personalChats.ts @@ -48,30 +48,13 @@ export function createPersonalChatsNamespace(infra: AdapterInfra): AdeNamespace< while (buffered.length > 2_000) buffered.shift(); }; - const resetDeliveredSyncEpoch = (sessionId: string) => { - const prefix = `${sessionId}:sync-seq:`; - let writeIndex = 0; - for (const key of deliveredOrder) { - if (key.startsWith(prefix)) { - delivered.delete(key); - continue; - } - deliveredOrder[writeIndex] = key; - writeIndex += 1; - } - deliveredOrder.length = writeIndex; - }; - const ensureSubscription = (sessionId: unknown) => { if (typeof sessionId !== "string" || !sessionId || subscriptions.has(sessionId)) return; subscriptions.set(sessionId, client.subscribeChat( sessionId, { chatScope: "personal", maxBytes: 4 * 1024 * 1024 }, { - snapshot: (snapshot) => { - if (snapshot.resumed !== true) resetDeliveredSyncEpoch(snapshot.sessionId); - snapshot.events.forEach((event) => pushEvent(event as SyncChatEventPayload)); - }, + snapshot: (snapshot) => snapshot.events.forEach((event) => pushEvent(event as SyncChatEventPayload)), event: pushEvent, }, )); From 6ea221e874da4ed5db4cf0a5f658641f14c19d89 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 04:23:58 -0400 Subject: [PATCH 39/53] fix(runtime): preserve cleanup and GitHub auth recovery --- apps/ade-cli/src/headlessLinearServices.ts | 3 + apps/desktop/src/main/main.ts | 2 +- .../main/services/chat/agentChatService.ts | 10 ++-- .../services/github/githubService.test.ts | 41 ++++++++++++-- .../src/main/services/github/githubService.ts | 8 ++- .../src/main/services/ipc/registerIpc.ts | 7 ++- .../src/main/services/ipc/runtimeBridge.ts | 4 +- .../projects/projectScaffoldService.test.ts | 4 +- .../projects/projectScaffoldService.ts | 4 +- .../src/main/services/prs/prService.test.ts | 5 +- .../src/main/services/prs/prService.ts | 13 ++--- .../src/main/services/pty/ptyService.test.ts | 55 +++++++++++++++++++ .../src/main/services/pty/ptyService.ts | 39 ++++++++++--- 13 files changed, 160 insertions(+), 35 deletions(-) diff --git a/apps/ade-cli/src/headlessLinearServices.ts b/apps/ade-cli/src/headlessLinearServices.ts index 6dc482c10..c98c2cb84 100644 --- a/apps/ade-cli/src/headlessLinearServices.ts +++ b/apps/ade-cli/src/headlessLinearServices.ts @@ -1042,6 +1042,9 @@ export function createHeadlessGitHubService( ); return token; }, + async getTokenOrThrowAsync() { + return service.getTokenOrThrow(); + }, async getAppUserTokenForRelay() { return await appUserAuth.getValidTokenForRelay(); }, diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index f1df01ee3..27ac37166 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -3093,7 +3093,7 @@ app.whenReady().then(async () => { logger, appVersion: app.getVersion(), getAdeCliAgentEnv: adeCliService.agentEnv, - getLocalGitHubToken: () => githubService.getTokenOrThrow(), + getLocalGitHubToken: () => githubService.getTokenOrThrowAsync(), onLinearIssueChatLinked: publishLinearChatLink, onEvent: (event) => { emitProjectEvent(projectRoot, IPC.agentChatEvent, event); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 9a788da1f..443ca96f0 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -6382,7 +6382,7 @@ export function createAgentChatService(args: { appVersion: string; getAdeCliAgentEnv?: (baseEnv?: NodeJS.ProcessEnv) => NodeJS.ProcessEnv; /** Resolves credentials owned by this runtime only; never supplied by a handoff capsule. */ - getLocalGitHubToken?: () => string | null | undefined; + getLocalGitHubToken?: () => string | null | undefined | Promise; resolveCodexComputerUseMcp?: () => | CodexComputerUseMcpConfig | null @@ -27119,14 +27119,14 @@ export function createAgentChatService(args: { const crossMachineHandoffRecordKey = (handoffId: string): string => `agent-chat-cross-machine-handoff:v1:${handoffId}`; - const destinationGitEnv = (): NodeJS.ProcessEnv => { + const destinationGitEnv = async (): Promise => { const env: NodeJS.ProcessEnv = { GIT_TERMINAL_PROMPT: "0", GCM_INTERACTIVE: "Never", }; let token = ""; try { - token = getLocalGitHubToken?.()?.trim() ?? ""; + token = (await getLocalGitHubToken?.())?.trim() ?? ""; } catch { // A destination credential helper may still authorize Git. Keep prompts // disabled so a headless handoff fails clearly instead of hanging. @@ -27866,7 +27866,7 @@ export function createAgentChatService(args: { const remote = await runGit(["ls-remote", "--heads", "origin", `refs/heads/${branchRef}`], { cwd: projectRoot, timeoutMs: 30_000, - env: destinationGitEnv(), + env: await destinationGitEnv(), }); if (remote.exitCode !== 0) { blockingErrors.push(`The destination cannot read origin: ${remote.stderr.trim() || "check Git credentials and network access."}`); @@ -28247,7 +28247,7 @@ export function createAgentChatService(args: { const fetch = await runGit(["fetch", "origin", `refs/heads/${branchRef}:refs/remotes/origin/${branchRef}`], { cwd: projectRoot, timeoutMs: 60_000, - env: destinationGitEnv(), + env: await destinationGitEnv(), }); if (fetch.exitCode !== 0) { throw new Error(`The destination could not fetch '${branchRef}': ${fetch.stderr.trim() || "unknown Git error"}`); diff --git a/apps/desktop/src/main/services/github/githubService.test.ts b/apps/desktop/src/main/services/github/githubService.test.ts index f18a5eaac..9371e3cf9 100644 --- a/apps/desktop/src/main/services/github/githubService.test.ts +++ b/apps/desktop/src/main/services/github/githubService.test.ts @@ -745,7 +745,34 @@ describe("githubService.getStatus", () => { delete process.env.GH_CONFIG_DIR; }); - it("coalesces slow gh auth and failed status probes across project services", async () => { + it("awaits keyring-backed gh auth when no synchronous hosts token exists", async () => { + delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; + let resolveAuth!: (value: { token: string; ghCliPath: string; ghAuthError: null }) => void; + const ghAuthTokenProvider = vi.fn(() => new Promise<{ + token: string; + ghCliPath: string; + ghAuthError: null; + }>((resolve) => { + resolveAuth = resolve; + })); + const first = makeService({ ghAuthTokenProvider }); + const second = makeService({ ghAuthTokenProvider }); + + const firstToken = first.getTokenOrThrowAsync(); + const secondToken = second.getTokenOrThrowAsync(); + await Promise.resolve(); + expect(ghAuthTokenProvider).toHaveBeenCalledTimes(1); + + resolveAuth({ + token: "gho_keyring_token", + ghCliPath: "/opt/homebrew/bin/gh", + ghAuthError: null, + }); + await expect(firstToken).resolves.toBe("gho_keyring_token"); + await expect(secondToken).resolves.toBe("gho_keyring_token"); + }); + + it("retries transient status failures after a short shared cooldown", async () => { stubOriginRemote(); delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; const baseNow = Date.now(); @@ -774,11 +801,13 @@ describe("githubService.getStatus", () => { await Promise.resolve(); expect(ghAuthTokenProvider).toHaveBeenCalledTimes(1); - resolveAuth({ + const resolvedAuth = { token: "github_pat_shared_slow_token", ghCliPath: "/opt/homebrew/bin/gh", - ghAuthError: null, - }); + ghAuthError: null as null, + }; + resolveAuth(resolvedAuth); + ghAuthTokenProvider.mockResolvedValue(resolvedAuth); const statuses = await Promise.all([firstStatus, secondStatus]); expect(statuses.map((status) => status.repoAccessOk)).toEqual([false, false]); expect(mockFetch).toHaveBeenCalledTimes(2); // one /user + one repo probe total @@ -786,8 +815,8 @@ describe("githubService.getStatus", () => { now.mockReturnValue(baseNow + 31_000); const third = await makeService({ ghAuthTokenProvider }).getStatus(); expect(third.repoAccessOk).toBe(false); - expect(ghAuthTokenProvider).toHaveBeenCalledTimes(1); - expect(mockFetch).toHaveBeenCalledTimes(2); + expect(ghAuthTokenProvider).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenCalledTimes(4); now.mockRestore(); }); diff --git a/apps/desktop/src/main/services/github/githubService.ts b/apps/desktop/src/main/services/github/githubService.ts index aa8299762..f95c1e10f 100644 --- a/apps/desktop/src/main/services/github/githubService.ts +++ b/apps/desktop/src/main/services/github/githubService.ts @@ -30,7 +30,7 @@ const MACHINE_TOKEN_KEY = "github.token.v1"; const GITHUB_API_TIMEOUT_MS = 20_000; const GH_AUTH_TOKEN_CACHE_TTL_MS = 30_000; const GH_HOSTS_TOKEN_CACHE_MAX_ENTRIES = 32; -const GITHUB_STATUS_FAILURE_COOLDOWN_MS = 2 * 60_000; +const GITHUB_STATUS_FAILURE_COOLDOWN_MS = 30_000; const execFileAsync = promisify(execFile); const processGhHostsTokenCache = new Map(); @@ -1674,6 +1674,12 @@ export function createGithubService({ return token; }, + async getTokenOrThrowAsync(): Promise { + const token = (await readAuthToken()).token; + if (!token) throw new Error("GitHub auth missing. Run `gh auth login -h github.com -s repo -s workflow` or add a personal access token in Settings."); + return token; + }, + async getAppUserTokenForRelay(): Promise { return await appUserAuth.getValidTokenForRelay(); }, diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index cd33d0482..2770e5123 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -3797,6 +3797,9 @@ export function registerIpc({ ipcMain.handle(IPC.appGetLatestRelease, async (): Promise => { let token: string | null = null; try { + // ADE's release repository is public. Use only an immediately available + // token here so a slow/keyring-backed `gh auth token` lookup cannot hold + // the update affordance behind unrelated GitHub authentication. token = getCtx().githubService.getTokenOrThrow(); } catch { token = null; @@ -4122,9 +4125,9 @@ export function registerIpc({ const runtimeBridge = registerRuntimeBridge({ appVersion: app.getVersion(), bindRemoteProject, - getGitHubTokenForRemoteClone: () => { + getGitHubTokenForRemoteClone: async () => { try { - return getCtx().githubService.getTokenOrThrow(); + return await getCtx().githubService.getTokenOrThrowAsync(); } catch { return null; } diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.ts index b7dd36eb9..c6e9be257 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.ts @@ -94,7 +94,7 @@ type RuntimeBridgeArgs = { binding: OpenProjectBinding & { kind: "remote" }, ) => void; localRuntimeConnectionPool?: LocalRuntimeConnectionPool | null; - getGitHubTokenForRemoteClone?: (() => string | null) | null; + getGitHubTokenForRemoteClone?: (() => string | null | Promise) | null; getLocalMachineIdentity?: (() => AdeAccountLocalMachineIdentity) | null; }; @@ -909,7 +909,7 @@ export function registerRuntimeBridge({ if (!destinationCredentialsOnly && target && hasKnownSshHostKeyForTarget(target)) { try { githubAuthHeader = createGitHubAuthHeader( - getGitHubTokenForRemoteClone?.() ?? null, + await getGitHubTokenForRemoteClone?.() ?? null, ); } catch { githubAuthHeader = null; diff --git a/apps/desktop/src/main/services/projects/projectScaffoldService.test.ts b/apps/desktop/src/main/services/projects/projectScaffoldService.test.ts index 44197c48e..ed7e2129e 100644 --- a/apps/desktop/src/main/services/projects/projectScaffoldService.test.ts +++ b/apps/desktop/src/main/services/projects/projectScaffoldService.test.ts @@ -25,9 +25,11 @@ function makeGithubServiceStub(overrides: Partial<{ getTokenOrThrow: ReturnType; parseGitHubRepoFromRemoteUrl: ReturnType; }> = {}) { + const getTokenOrThrow = overrides.getTokenOrThrow ?? vi.fn(() => "ghp_fake_token_12345"); return { apiRequest: overrides.apiRequest ?? vi.fn(), - getTokenOrThrow: overrides.getTokenOrThrow ?? vi.fn(() => "ghp_fake_token_12345"), + getTokenOrThrow, + getTokenOrThrowAsync: vi.fn(async () => getTokenOrThrow()), parseGitHubRepoFromRemoteUrl: overrides.parseGitHubRepoFromRemoteUrl ?? vi.fn((url: string) => { diff --git a/apps/desktop/src/main/services/projects/projectScaffoldService.ts b/apps/desktop/src/main/services/projects/projectScaffoldService.ts index e1ada86b9..066128428 100644 --- a/apps/desktop/src/main/services/projects/projectScaffoldService.ts +++ b/apps/desktop/src/main/services/projects/projectScaffoldService.ts @@ -215,7 +215,7 @@ export function createProjectScaffoldService({ let authHeader = (input.githubAuthHeader ?? "").trim(); if (!authHeader) { try { - const storedToken = githubService.getTokenOrThrow(); + const storedToken = await githubService.getTokenOrThrowAsync(); const basic = Buffer.from(`x-access-token:${storedToken}`, "utf8").toString("base64"); authHeader = `basic ${basic}`; } catch { @@ -260,7 +260,7 @@ export function createProjectScaffoldService({ const listMyGitHubRepos = async (input: ListMyGitHubReposInput): Promise => { let token: string; try { - token = githubService.getTokenOrThrow(); + token = await githubService.getTokenOrThrowAsync(); } catch (err) { const wrapped = new Error("GitHub is not connected. Run gh auth login or add a PAT in Settings.") as Error & { code?: string }; wrapped.code = "github_not_connected"; diff --git a/apps/desktop/src/main/services/prs/prService.test.ts b/apps/desktop/src/main/services/prs/prService.test.ts index 5c86935f7..3d50f7559 100644 --- a/apps/desktop/src/main/services/prs/prService.test.ts +++ b/apps/desktop/src/main/services/prs/prService.test.ts @@ -211,6 +211,8 @@ function makeUnmappedBranchPull(overrides?: Partial>) { } function makeGithubService(overrides?: Record) { + const getTokenOrThrow = (overrides?.getTokenOrThrow as (() => string) | undefined) + ?? vi.fn(() => "ghp_mock"); return { getRepoOrThrow: vi.fn(async () => REPO), apiRequest: vi.fn(), @@ -218,7 +220,8 @@ function makeGithubService(overrides?: Record) { getStatus: vi.fn(), setToken: vi.fn(), clearToken: vi.fn(), - getTokenOrThrow: vi.fn(() => "ghp_mock"), + getTokenOrThrow, + getTokenOrThrowAsync: vi.fn(async () => getTokenOrThrow()), ...overrides, } as any; } diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index ffc8a62af..12a85488c 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -6192,19 +6192,18 @@ export function createPrService({ const runGh = async (ghArgs: string[], opts: { cwd: string; timeoutMs?: number }): Promise => { const timeoutMs = opts.timeoutMs ?? 90_000; + let ghToken: string | null = null; + try { + ghToken = await githubService.getTokenOrThrowAsync(); + } catch { + ghToken = null; + } return await new Promise((resolve) => { let stdout = ""; let stderr = ""; let settled = false; let timer: NodeJS.Timeout | null = null; - let ghToken: string | null = null; - try { - ghToken = githubService.getTokenOrThrow(); - } catch { - ghToken = null; - } - const child = spawn("gh", ghArgs, { cwd: opts.cwd, env: ghToken ? { ...process.env, GH_TOKEN: ghToken, GITHUB_TOKEN: ghToken } : process.env, diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index 9a72b9f36..bb737b43f 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -6641,6 +6641,61 @@ describe("ptyService", () => { } }); + it("force-kills known PTY groups when the reap scan fails under load", async () => { + vi.useFakeTimers(); + let scanCount = 0; + mocks.execFile.mockImplementation((...args: unknown[]) => { + scanCount += 1; + const callback = args.at(-1); + if (typeof callback === "function") { + if (scanCount === 1) { + (callback as (...callbackArgs: unknown[]) => void)( + null, + [ + "12345 1 12345 23456", + "23456 12345 23456 23456", + ].join("\n"), + "", + ); + } else { + (callback as (...callbackArgs: unknown[]) => void)( + new Error("process scan timed out"), + "", + "", + ); + } + } + return { kill: vi.fn() }; + }); + const kill = vi.spyOn(process, "kill").mockImplementation(() => true as const); + try { + const { service } = createChatHarness(); + await service.create({ + laneId: "lane-1", + title: "Signal", + cols: 80, + rows: 24, + chatSessionId: "chat-signal-reap-scan-failure", + }); + + service.signalTerminal({ + chatSessionId: "chat-signal-reap-scan-failure", + signal: "SIGTERM", + }); + await vi.advanceTimersByTimeAsync(0); + kill.mockClear(); + + await vi.advanceTimersByTimeAsync(1_500); + expect(kill).toHaveBeenCalledWith(-12345, "SIGKILL"); + expect(kill).toHaveBeenCalledWith(12345, "SIGKILL"); + expect(kill).toHaveBeenCalledWith(-23456, "SIGKILL"); + expect(kill).toHaveBeenCalledWith(23456, "SIGKILL"); + } finally { + kill.mockRestore(); + vi.useRealTimers(); + } + }); + it("fails loudly when chat terminal calls cannot resolve a target", async () => { const { service } = createChatHarness(); diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index a18ac9f87..60ce51422 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -201,6 +201,11 @@ type PtyTreeProcess = { foregroundProcessGroupId: number; }; +type PtyTreeProcessScan = { + processes: PtyTreeProcess[]; + succeeded: boolean; +}; + function parsePtyTreeProcesses( stdout: string, rootPid: number, @@ -255,9 +260,9 @@ function parsePtyTreeProcesses( function collectPtyTreeProcesses( rootPid: number, knownProcessGroupIds: ReadonlySet = new Set(), -): Promise { +): Promise { if (process.platform === "win32" || !Number.isFinite(rootPid) || rootPid <= 0) { - return Promise.resolve([]); + return Promise.resolve({ processes: [], succeeded: false }); } return new Promise((resolve) => { try { @@ -272,12 +277,15 @@ function collectPtyTreeProcesses( }, (error, stdout) => { resolve(error - ? [] - : parsePtyTreeProcesses(String(stdout ?? ""), rootPid, knownProcessGroupIds)); + ? { processes: [], succeeded: false } + : { + processes: parsePtyTreeProcesses(String(stdout ?? ""), rootPid, knownProcessGroupIds), + succeeded: true, + }); }, ); } catch { - resolve([]); + resolve({ processes: [], succeeded: false }); } }); } @@ -727,7 +735,7 @@ function terminatePtyProcessTree( dispatchInitialSignal([]); }, PTY_PROCESS_SCAN_SIGNAL_DELAY_MS); signalFallbackTimer.unref?.(); - void initialProcessScan.then((processes) => { + void initialProcessScan.then(({ processes }) => { initialProcesses = processes; clearTimeout(signalFallbackTimer); const signalAlreadyDispatched = initialSignalDispatched; @@ -740,7 +748,24 @@ function terminatePtyProcessTree( process.processGroupId, process.foregroundProcessGroupId, ]).filter((processGroupId) => processGroupId > 1)); - void collectPtyTreeProcesses(rootPid, knownProcessGroupIds).then((currentProcesses) => { + void collectPtyTreeProcesses(rootPid, knownProcessGroupIds).then(({ processes: currentProcesses, succeeded }) => { + if (!succeeded) { + // A saturated host can time out the fallback `ps` scan precisely when + // cleanup matters most. Do not interpret an unavailable scan as proof + // that the tree exited: force the known PTY/root groups once more so a + // surviving child cannot keep a lane worktree busy indefinitely. + killPtyProcessGroupBestEffort(rootPid, "SIGKILL"); + killPidBestEffort(rootPid, "SIGKILL"); + signalPtyTreeProcesses(initialProcesses, "SIGKILL"); + logger.warn("pty.process_tree_force_killed", { + sessionId: entry.sessionId, + toolType: entry.toolTypeHint, + rootPid, + pids: Array.from(new Set([rootPid, ...initialProcesses.map(({ pid }) => pid)])), + processScanFailed: true, + }); + return; + } if (currentProcesses.length === 0) return; signalPtyTreeProcesses(currentProcesses, "SIGKILL"); logger.warn("pty.process_tree_force_killed", { From e4c6cf2ee171ba7b6d95da5433239948919046ff Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 04:26:19 -0400 Subject: [PATCH 40/53] fix(runtime): harden sync host switch tail --- .../services/projects/projectScope.test.ts | 232 ++++++++++++++++- .../src/services/projects/projectScope.ts | 238 +++++++++++++++--- 2 files changed, 422 insertions(+), 48 deletions(-) diff --git a/apps/ade-cli/src/services/projects/projectScope.test.ts b/apps/ade-cli/src/services/projects/projectScope.test.ts index 2b4a8a562..57f6c77d7 100644 --- a/apps/ade-cli/src/services/projects/projectScope.test.ts +++ b/apps/ade-cli/src/services/projects/projectScope.test.ts @@ -172,7 +172,7 @@ describe("ProjectScopeRegistry", () => { }); it("warms the most recently opened project as the sync host", async () => { - const { registry, first, second } = createRegistry(); + const { registry, first } = createRegistry(); const file = JSON.parse(fs.readFileSync(registry.path, "utf8")) as { projects: Array<{ projectId: string; lastOpenedAt: number; addedAt: number }>; }; @@ -355,12 +355,12 @@ describe("ProjectScopeRegistry", () => { expect(secondSyncService.setHostStartupEnabled).toHaveBeenCalledWith(false); }); - it("lets the newest concurrent sync-host selection win without flapping an intermediate host", async () => { + it("coalesces a queued A -> B -> C selection before booting the superseded target", async () => { const { registry, first, second } = createRegistry(); const thirdRoot = path.join(path.dirname(first.rootPath), "third-concurrent"); fs.mkdirSync(thirdRoot, { recursive: true }); const third = registry.add(thirdRoot); - const secondRuntime = deferred(); + const thirdRuntime = deferred(); const makeSyncService = () => ({ initialize: vi.fn(async () => undefined), setHostDiscoveryEnabled: vi.fn(), @@ -371,8 +371,7 @@ describe("ProjectScopeRegistry", () => { const thirdSyncService = makeSyncService(); createAdeRuntimeMock .mockResolvedValueOnce({ dispose: vi.fn(), syncService: firstSyncService }) - .mockImplementationOnce(() => secondRuntime.promise) - .mockResolvedValueOnce({ dispose: vi.fn(), syncService: thirdSyncService }); + .mockImplementationOnce(() => thirdRuntime.promise); const scopeRegistry = new ProjectScopeRegistry(registry, { syncRuntime: { enabled: true, @@ -388,16 +387,235 @@ describe("ProjectScopeRegistry", () => { const switchToSecond = scopeRegistry.switchSyncHost(second.projectId); const switchToThird = scopeRegistry.switchSyncHost(third.projectId); await new Promise((resolve) => setImmediate(resolve)); - secondRuntime.resolve({ dispose: vi.fn(), syncService: secondSyncService }); - await Promise.all([switchToSecond, switchToThird]); + expect(createAdeRuntimeMock.mock.calls.map(([args]) => args.projectRoot)).toEqual([ + first.rootPath, + third.rootPath, + ]); + thirdRuntime.resolve({ dispose: vi.fn(), syncService: thirdSyncService }); + const [secondResult, thirdResult] = await Promise.all([switchToSecond, switchToThird]); + expect(secondResult).toBeNull(); + expect(thirdResult?.registryProjectId).toBe(third.projectId); expect(scopeRegistry.getActiveSyncHostProjectId()).toBe(third.projectId); + expect(secondSyncService.initialize).not.toHaveBeenCalled(); expect(secondSyncService.setHostStartupEnabled).not.toHaveBeenCalledWith(true); expect(thirdSyncService.setHostStartupEnabled).toHaveBeenCalledWith(true); expect(firstSyncService.setHostStartupEnabled).toHaveBeenCalledTimes(1); expect(firstSyncService.setHostStartupEnabled).toHaveBeenCalledWith(false); }); + it("does not let a never-resolving obsolete cold boot delay the newest host", async () => { + vi.useFakeTimers(); + try { + const { registry, first, second } = createRegistry(); + const thirdRoot = path.join(path.dirname(first.rootPath), "third-after-stuck-boot"); + fs.mkdirSync(thirdRoot, { recursive: true }); + const third = registry.add(thirdRoot); + const stuckSecondRuntime = deferred(); + const makeSyncService = () => ({ + initialize: vi.fn(async () => undefined), + setHostDiscoveryEnabled: vi.fn(), + setHostStartupEnabled: vi.fn(async () => undefined), + }); + const firstSyncService = makeSyncService(); + const thirdSyncService = makeSyncService(); + createAdeRuntimeMock + .mockResolvedValueOnce({ dispose: vi.fn(), syncService: firstSyncService }) + .mockImplementationOnce(() => stuckSecondRuntime.promise) + .mockResolvedValueOnce({ dispose: vi.fn(), syncService: thirdSyncService }); + const scopeRegistry = new ProjectScopeRegistry(registry, { + syncRuntime: { + enabled: true, + hostStartupEnabled: true, + hostDiscoveryEnabled: true, + forceHostRole: false, + runtimeKind: "daemon", + }, + }); + await scopeRegistry.switchSyncHost(first.projectId); + firstSyncService.setHostStartupEnabled.mockClear(); + + const switchToSecond = scopeRegistry.switchSyncHost(second.projectId); + const secondRejection = expect(switchToSecond).rejects.toThrow( + `Sync host cold boot for ${second.projectId} timed out`, + ); + await vi.advanceTimersByTimeAsync(0); + expect(createAdeRuntimeMock).toHaveBeenCalledTimes(2); + + const switchedToThird = await scopeRegistry.switchSyncHost(third.projectId); + expect(switchedToThird?.registryProjectId).toBe(third.projectId); + expect(scopeRegistry.getActiveSyncHostProjectId()).toBe(third.projectId); + expect(firstSyncService.setHostStartupEnabled).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(60_001); + await secondRejection; + expect(scopeRegistry.getIfBooted(second.projectId)).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it("disposes a late cold-boot completion without deleting a successful retry", async () => { + vi.useFakeTimers(); + try { + const { registry, first, second } = createRegistry(); + const staleRuntime = deferred(); + const staleDispose = vi.fn(); + const makeSyncService = () => ({ + initialize: vi.fn(async () => undefined), + setHostDiscoveryEnabled: vi.fn(), + setHostStartupEnabled: vi.fn(async () => undefined), + }); + const firstSyncService = makeSyncService(); + const staleSyncService = makeSyncService(); + const retrySyncService = makeSyncService(); + createAdeRuntimeMock + .mockResolvedValueOnce({ dispose: vi.fn(), syncService: firstSyncService }) + .mockImplementationOnce(() => staleRuntime.promise) + .mockResolvedValueOnce({ dispose: vi.fn(), syncService: retrySyncService }); + const scopeRegistry = new ProjectScopeRegistry(registry, { + syncRuntime: { + enabled: true, + hostStartupEnabled: true, + hostDiscoveryEnabled: true, + forceHostRole: false, + runtimeKind: "daemon", + }, + }); + await scopeRegistry.switchSyncHost(first.projectId); + + const firstAttempt = scopeRegistry.switchSyncHost(second.projectId); + const rejection = expect(firstAttempt).rejects.toThrow( + `Sync host cold boot for ${second.projectId} timed out`, + ); + await vi.advanceTimersByTimeAsync(60_001); + await rejection; + + const retryScope = await scopeRegistry.switchSyncHost(second.projectId); + expect(retryScope?.runtime.syncService).toBe(retrySyncService); + expect(scopeRegistry.getActiveSyncHostProjectId()).toBe(second.projectId); + + staleRuntime.resolve({ dispose: staleDispose, syncService: staleSyncService }); + await vi.advanceTimersByTimeAsync(0); + expect(staleDispose).toHaveBeenCalledTimes(1); + await expect(scopeRegistry.getIfBooted(second.projectId)).resolves.toBe(retryScope); + } finally { + vi.useRealTimers(); + } + }); + + it("bounds a never-resolving target initialization without disabling the old host", async () => { + vi.useFakeTimers(); + try { + const { registry, first, second } = createRegistry(); + const stuckInitialization = deferred(); + const firstSyncService = { + initialize: vi.fn(async () => undefined), + setHostDiscoveryEnabled: vi.fn(), + setHostStartupEnabled: vi.fn(async () => undefined), + }; + const secondSyncService = { + initialize: vi.fn(() => stuckInitialization.promise), + setHostDiscoveryEnabled: vi.fn(), + setHostStartupEnabled: vi.fn(async () => undefined), + }; + createAdeRuntimeMock + .mockResolvedValueOnce({ dispose: vi.fn(), syncService: firstSyncService }) + .mockResolvedValueOnce({ dispose: vi.fn(), syncService: secondSyncService }); + const scopeRegistry = new ProjectScopeRegistry(registry, { + syncRuntime: { + enabled: true, + hostStartupEnabled: true, + hostDiscoveryEnabled: true, + forceHostRole: false, + runtimeKind: "daemon", + }, + }); + await scopeRegistry.switchSyncHost(first.projectId); + firstSyncService.setHostDiscoveryEnabled.mockClear(); + firstSyncService.setHostStartupEnabled.mockClear(); + + const switching = scopeRegistry.switchSyncHost(second.projectId); + const rejection = expect(switching).rejects.toThrow( + `Sync host initialization for ${second.projectId} timed out`, + ); + await vi.advanceTimersByTimeAsync(30_001); + await rejection; + + expect(scopeRegistry.getActiveSyncHostProjectId()).toBe(first.projectId); + expect(firstSyncService.setHostDiscoveryEnabled).not.toHaveBeenCalledWith(false); + expect(firstSyncService.setHostStartupEnabled).not.toHaveBeenCalledWith(false); + expect(secondSyncService.setHostStartupEnabled).not.toHaveBeenCalledWith(true); + expect(scopeRegistry.getIfBooted(second.projectId)).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it("times out a stuck activation, rolls back, and releases the mutation tail", async () => { + vi.useFakeTimers(); + try { + const { registry, first, second } = createRegistry(); + const thirdRoot = path.join(path.dirname(first.rootPath), "third-after-stuck-activation"); + fs.mkdirSync(thirdRoot, { recursive: true }); + const third = registry.add(thirdRoot); + const stuckActivation = deferred(); + const firstSyncService = { + initialize: vi.fn(async () => undefined), + setHostDiscoveryEnabled: vi.fn(), + setHostStartupEnabled: vi.fn(async () => undefined), + }; + const secondSyncService = { + initialize: vi.fn(async () => undefined), + setHostDiscoveryEnabled: vi.fn(), + setHostStartupEnabled: vi.fn((enabled: boolean) => ( + enabled ? stuckActivation.promise : Promise.resolve() + )), + }; + const thirdSyncService = { + initialize: vi.fn(async () => undefined), + setHostDiscoveryEnabled: vi.fn(), + setHostStartupEnabled: vi.fn(async () => undefined), + }; + createAdeRuntimeMock + .mockResolvedValueOnce({ dispose: vi.fn(), syncService: firstSyncService }) + .mockResolvedValueOnce({ dispose: vi.fn(), syncService: secondSyncService }) + .mockResolvedValueOnce({ dispose: vi.fn(), syncService: thirdSyncService }); + const scopeRegistry = new ProjectScopeRegistry(registry, { + syncRuntime: { + enabled: true, + hostStartupEnabled: true, + hostDiscoveryEnabled: true, + forceHostRole: false, + runtimeKind: "daemon", + }, + }); + await scopeRegistry.switchSyncHost(first.projectId); + firstSyncService.setHostStartupEnabled.mockClear(); + + const switching = scopeRegistry.switchSyncHost(second.projectId); + const rejection = expect(switching).rejects.toThrow( + `Sync host activation for ${second.projectId} timed out`, + ); + await vi.advanceTimersByTimeAsync(0); + expect(scopeRegistry.getActiveSyncHostProjectId()).toBe(first.projectId); + expect(firstSyncService.setHostStartupEnabled).toHaveBeenCalledWith(false); + expect(secondSyncService.setHostStartupEnabled).toHaveBeenCalledWith(true); + + await vi.advanceTimersByTimeAsync(10_001); + await rejection; + expect(scopeRegistry.getActiveSyncHostProjectId()).toBe(first.projectId); + expect(secondSyncService.setHostStartupEnabled).toHaveBeenCalledWith(false); + expect(firstSyncService.setHostStartupEnabled).toHaveBeenLastCalledWith(true); + + const switchedToThird = await scopeRegistry.switchSyncHost(third.projectId); + expect(switchedToThird?.registryProjectId).toBe(third.projectId); + expect(scopeRegistry.getActiveSyncHostProjectId()).toBe(third.projectId); + } finally { + vi.useRealTimers(); + } + }); + it("can prepare a new phone sync host before retiring the previous host", async () => { const { registry, first, second } = createRegistry(); const firstSyncService = { diff --git a/apps/ade-cli/src/services/projects/projectScope.ts b/apps/ade-cli/src/services/projects/projectScope.ts index 35432e659..7a9f3a874 100644 --- a/apps/ade-cli/src/services/projects/projectScope.ts +++ b/apps/ade-cli/src/services/projects/projectScope.ts @@ -1,11 +1,40 @@ import type { AdeRuntime, AdeRuntimeSyncOptions } from "../../bootstrap"; import type { SyncCommandPayload } from "../../../../desktop/src/shared/types"; -import { ProjectRegistry, type ProjectId, type ProjectRecord } from "./projectRegistry"; +import type { ProjectId, ProjectRecord, ProjectRegistry } from "./projectRegistry"; type SwitchSyncHostOptions = { deactivatePreviousHost?: boolean; }; +const SYNC_HOST_COLD_BOOT_TIMEOUT_MS = 60_000; +const SYNC_HOST_INITIALIZE_TIMEOUT_MS = 30_000; +const SYNC_HOST_CONFIGURE_TIMEOUT_MS = 10_000; + +class SyncHostPhaseTimeoutError extends Error {} + +async function runSyncHostPhase( + phase: string, + projectId: ProjectId, + timeoutMs: number, + operation: () => T | Promise, +): Promise { + let timer: ReturnType | null = null; + const work = Promise.resolve().then(operation); + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new SyncHostPhaseTimeoutError( + `Sync host ${phase} for ${projectId} timed out after ${timeoutMs}ms.`, + )); + }, timeoutMs); + timer.unref?.(); + }); + try { + return await Promise.race([work, timeout]); + } finally { + if (timer) clearTimeout(timer); + } +} + export class ProjectScope { readonly registryProjectId: ProjectId; readonly record: ProjectRecord; @@ -30,9 +59,9 @@ export class ProjectScopeRegistry { private readonly scopes = new Map>(); private readonly disposeListeners = new Set<(projectId: ProjectId) => void>(); private syncHostProjectId: ProjectId | null = null; - private syncHostTransitionDepth = 0; private syncHostTransitionTail: Promise = Promise.resolve(); private latestSyncHostTransitionId = 0; + private latestSyncHostTransitionProjectId: ProjectId | null = null; private readonly remoteCommandExecutor = { execute: async (payload: SyncCommandPayload): Promise => { return await this.executeRemoteCommand(payload); @@ -103,9 +132,15 @@ export class ProjectScopeRegistry { try { return await pending; } catch (error) { - this.scopes.delete(projectId); - if (this.syncHostProjectId === projectId) { - this.syncHostProjectId = null; + // A timed-out cold sync-host boot can be evicted and retried while the + // original createAdeRuntime() promise is still settling. Never let that + // stale completion delete a newer retry from the cache or clear a host + // that the retry successfully promoted. + if (this.scopes.get(projectId) === pending) { + this.scopes.delete(projectId); + if (this.syncHostProjectId === projectId) { + this.syncHostProjectId = null; + } } throw error; } @@ -169,68 +204,165 @@ export class ProjectScopeRegistry { ): Promise { if (!this.options.syncRuntime?.enabled) return null; const transitionId = ++this.latestSyncHostTransitionId; - this.syncHostTransitionDepth += 1; + this.latestSyncHostTransitionProjectId = projectId; + + // Coalesce calls made in one turn before any cold runtime work begins. + // Once booting has started it stays outside the authority-mutation tail, + // so a slow obsolete target cannot delay a newer ready target. + await Promise.resolve(); + if (transitionId !== this.latestSyncHostTransitionId) return null; + + const scopePromiseBeforeBoot = this.scopes.get(projectId) ?? null; + const scopeOperation = this.get(projectId); + const scopePromise = this.scopes.get(projectId) ?? scopePromiseBeforeBoot; + let scope: ProjectScope; + try { + scope = await runSyncHostPhase( + "cold boot", + projectId, + SYNC_HOST_COLD_BOOT_TIMEOUT_MS, + () => scopeOperation, + ); + } catch (error) { + if ( + error instanceof SyncHostPhaseTimeoutError + && !scopePromiseBeforeBoot + && this.canAbandonTransitionScope(projectId, transitionId) + ) { + this.abandonTransitionScope(projectId, scopePromise); + } + throw error; + } + if (transitionId !== this.latestSyncHostTransitionId) return null; + + try { + await runSyncHostPhase( + "initialization", + projectId, + SYNC_HOST_INITIALIZE_TIMEOUT_MS, + async () => await scope.runtime.syncService?.initialize(), + ); + } catch (error) { + if ( + error instanceof SyncHostPhaseTimeoutError + && !scopePromiseBeforeBoot + && this.canAbandonTransitionScope(projectId, transitionId) + ) { + this.abandonTransitionScope(projectId, scopePromise); + } + throw error; + } + if (transitionId !== this.latestSyncHostTransitionId) return null; + const work = this.syncHostTransitionTail.then( - () => this.performSyncHostSwitch(projectId, options, transitionId), - () => this.performSyncHostSwitch(projectId, options, transitionId), + () => transitionId === this.latestSyncHostTransitionId + ? this.performSyncHostSwitch(scope, options, transitionId) + : null, + () => transitionId === this.latestSyncHostTransitionId + ? this.performSyncHostSwitch(scope, options, transitionId) + : null, ); this.syncHostTransitionTail = work.then( () => undefined, () => undefined, ); - try { - return await work; - } finally { - this.syncHostTransitionDepth = Math.max(0, this.syncHostTransitionDepth - 1); - } + return await work; } private async performSyncHostSwitch( - projectId: ProjectId, + scope: ProjectScope, options: SwitchSyncHostOptions, transitionId: number, - ): Promise { + ): Promise { + const projectId = scope.registryProjectId; const previousHostId = this.syncHostProjectId; const deactivatePreviousHost = options.deactivatePreviousHost ?? true; - // Boot and initialize the target while the previous project remains the - // authoritative host. get() sees syncHostProjectId still pointing at the - // old host, so the new runtime starts with host startup/discovery disabled. - const scope = await this.get(projectId); - await scope.runtime.syncService?.initialize(); - - // A newer queued selection superseded this one while its cold runtime was - // booting. Keep the warm scope, but never flap the active listener/peers. - if (transitionId !== this.latestSyncHostTransitionId) return scope; + if (transitionId !== this.latestSyncHostTransitionId) return null; if (previousHostId === projectId) { - await this.configureSyncHost(scope, true, { initialize: false }); + await this.configureSyncHostWithTimeout(scope, true, "activation"); return scope; } - let previousDeactivated = false; - try { - if (previousHostId && deactivatePreviousHost) { - await this.configureCachedSyncHost(previousHostId, false); - previousDeactivated = true; + let previousDeactivationAttempted = false; + let targetActivationAttempted = false; + const rollback = async (): Promise => { + if (targetActivationAttempted) { + await this.configureSyncHostWithTimeout(scope, false, "rollback deactivation") + .catch(() => {}); } - this.syncHostProjectId = projectId; - await this.configureSyncHost(scope, true, { initialize: false }); - return scope; - } catch (error) { - await this.configureSyncHost(scope, false).catch(() => {}); - this.syncHostProjectId = previousHostId; - if (previousHostId && previousDeactivated) { + if (previousHostId && previousDeactivationAttempted) { try { - const previousScope = await this.get(previousHostId); - await this.configureSyncHost(previousScope, true); + const previousScope = await this.getCachedScopeWithinTimeout(previousHostId); + if (!previousScope) { + this.syncHostProjectId = null; + return; + } + await this.configureSyncHostWithTimeout(previousScope, true, "rollback restoration"); } catch { this.syncHostProjectId = null; } } + }; + + try { + if (previousHostId && deactivatePreviousHost) { + previousDeactivationAttempted = true; + const previousScope = await this.getCachedScopeWithinTimeout(previousHostId); + if (previousScope) { + await this.configureSyncHostWithTimeout(previousScope, false, "previous-host deactivation"); + } + } + if (transitionId !== this.latestSyncHostTransitionId) { + await rollback(); + return null; + } + + targetActivationAttempted = true; + await this.configureSyncHostWithTimeout(scope, true, "activation"); + if (transitionId !== this.latestSyncHostTransitionId) { + await rollback(); + return null; + } + + // Publish authority only after the target has fully activated. Until + // this assignment every failure/timeout still resolves to the old host. + this.syncHostProjectId = projectId; + return scope; + } catch (error) { + await rollback(); throw error; } } + private canAbandonTransitionScope(projectId: ProjectId, transitionId: number): boolean { + return transitionId === this.latestSyncHostTransitionId + || this.latestSyncHostTransitionProjectId !== projectId; + } + + private abandonTransitionScope( + projectId: ProjectId, + pending: Promise | null, + ): void { + if (!pending || this.scopes.get(projectId) !== pending) return; + this.scopes.delete(projectId); + void pending.then( + (scope) => scope.dispose(), + () => undefined, + ); + } + + private async getCachedScopeWithinTimeout(projectId: ProjectId): Promise { + const cached = this.scopes.get(projectId); + if (!cached) return null; + return await runSyncHostPhase( + "cached-scope lookup", + projectId, + SYNC_HOST_CONFIGURE_TIMEOUT_MS, + () => cached, + ); + } + async deactivateInactiveSyncHosts(activeProjectId: ProjectId | null = this.syncHostProjectId): Promise { if (!activeProjectId) return; await Promise.all( @@ -246,8 +378,32 @@ export class ProjectScopeRegistry { ): Promise { const cached = this.scopes.get(projectId); if (!cached) return; - const scope = await cached.catch(() => null); - if (scope) await this.configureSyncHost(scope, enabled); + const scope = await runSyncHostPhase( + "cached-scope lookup", + projectId, + SYNC_HOST_CONFIGURE_TIMEOUT_MS, + () => cached, + ).catch(() => null); + if (scope) { + await this.configureSyncHostWithTimeout( + scope, + enabled, + enabled ? "activation" : "deactivation", + ); + } + } + + private async configureSyncHostWithTimeout( + scope: ProjectScope, + enabled: boolean, + phase: string, + ): Promise { + await runSyncHostPhase( + phase, + scope.registryProjectId, + SYNC_HOST_CONFIGURE_TIMEOUT_MS, + () => this.configureSyncHost(scope, enabled, { initialize: false }), + ); } private async configureSyncHost( From d0f0c2f9e7aa09dbf3c6f39c0646a990637175cb Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 04:33:10 -0400 Subject: [PATCH 41/53] fix(sync): await queued role transitions --- apps/ade-cli/src/services/sync/syncService.ts | 174 +++++++++--------- .../main/services/sync/syncService.test.ts | 60 ++++++ 2 files changed, 151 insertions(+), 83 deletions(-) diff --git a/apps/ade-cli/src/services/sync/syncService.ts b/apps/ade-cli/src/services/sync/syncService.ts index 5a8839bd9..ba2d4f55d 100644 --- a/apps/ade-cli/src/services/sync/syncService.ts +++ b/apps/ade-cli/src/services/sync/syncService.ts @@ -592,8 +592,8 @@ export function createSyncService(args: SyncServiceArgs) { lastFailureAt: null, lastSuccessAt: null, }; - let refreshRunning = false; let refreshQueued = false; + let refreshPromise: Promise | null = null; let disposed = false; // Mobile project switch can fire `sync.initialize` as a background task and // then immediately await `service.initialize()` from the dialog handler. @@ -1031,100 +1031,108 @@ export function createSyncService(args: SyncServiceArgs) { return !argsIn.cluster || isStaleNonLocalBrainCluster(argsIn.cluster, argsIn.localDevice.deviceId); }; - const refreshRoleState = async (): Promise => { - if (disposed) return; - if (refreshRunning) { - refreshQueued = true; - return; - } - refreshRunning = true; - try { + const refreshRoleState = (): Promise => { + if (disposed) return Promise.resolve(); + refreshQueued = true; + if (refreshPromise) return refreshPromise; + + // Every caller joins the same drain promise. In particular, a host-start + // request immediately followed by a rollback must not report the rollback + // complete while the first role refresh is still starting the host. + const work = Promise.resolve().then(async () => { + try { do { refreshQueued = false; - const savedDraft = readSavedDraft(); - syncPeerService.setSavedDraft(savedDraft); - const localDevice = deviceRegistryService.ensureLocalDevice(); - let cluster = deviceRegistryService.getClusterState(); - if (forceHostRole) { - if (!cluster || cluster.brainDeviceId !== localDevice.deviceId) { - cluster = deviceRegistryService.setClusterState({ - brainDeviceId: localDevice.deviceId, - brainEpoch: (cluster?.brainEpoch ?? 0) + 1, - updatedByDeviceId: localDevice.deviceId, - }); - } - } else if (!savedDraft) { - if (!cluster) { - cluster = deviceRegistryService.bootstrapLocalBrainIfNeeded(); - } else if (isStaleNonLocalBrainCluster(cluster, localDevice.deviceId)) { - deviceRegistryService.touchLocalDevice({ - lastSeenAt: nowIso(), - lastHost: localDevice.lastHost, - lastPort: localDevice.lastPort ?? DEFAULT_SYNC_HOST_PORT, - }); - cluster = deviceRegistryService.setClusterState({ - brainDeviceId: localDevice.deviceId, - brainEpoch: (cluster?.brainEpoch ?? 0) + 1, - updatedByDeviceId: localDevice.deviceId, - }); - } - } - const isLocalBrain = forceHostRole || (cluster - ? cluster.brainDeviceId === localDevice.deviceId - : !savedDraft); - if (isLocalBrain) { - if (syncPeerService.isConnected()) { - syncPeerService.disconnect({ preserveDraft: true }); + try { + const savedDraft = readSavedDraft(); + syncPeerService.setSavedDraft(savedDraft); + const localDevice = deviceRegistryService.ensureLocalDevice(); + let cluster = deviceRegistryService.getClusterState(); + if (forceHostRole) { + if (!cluster || cluster.brainDeviceId !== localDevice.deviceId) { + cluster = deviceRegistryService.setClusterState({ + brainDeviceId: localDevice.deviceId, + brainEpoch: (cluster?.brainEpoch ?? 0) + 1, + updatedByDeviceId: localDevice.deviceId, + }); + } + } else if (!savedDraft) { + if (!cluster) { + cluster = deviceRegistryService.bootstrapLocalBrainIfNeeded(); + } else if (isStaleNonLocalBrainCluster(cluster, localDevice.deviceId)) { + deviceRegistryService.touchLocalDevice({ + lastSeenAt: nowIso(), + lastHost: localDevice.lastHost, + lastPort: localDevice.lastPort ?? DEFAULT_SYNC_HOST_PORT, + }); + cluster = deviceRegistryService.setClusterState({ + brainDeviceId: localDevice.deviceId, + brainEpoch: (cluster?.brainEpoch ?? 0) + 1, + updatedByDeviceId: localDevice.deviceId, + }); + } } - await startHostIfNeeded(); - } else { - await stopHostIfRunning(); - if (!isCrdtSyncAvailable()) { + const isLocalBrain = forceHostRole || (cluster + ? cluster.brainDeviceId === localDevice.deviceId + : !savedDraft); + if (isLocalBrain) { if (syncPeerService.isConnected()) { syncPeerService.disconnect({ preserveDraft: true }); } - continue; - } - const draft = savedDraft ?? resolveViewerDraftFromRegistry(); - if (draft && !syncPeerService.isConnected()) { - syncPeerService.setSavedDraft(draft); - try { - await syncPeerService.connect(draft); - deviceRegistryService.touchLocalDevice({ lastSeenAt: nowIso() }); - syncPeerService.flushLocalChanges(); - } catch (error) { - args.logger.warn("sync.role.viewer_connect_failed", { - error: error instanceof Error ? error.message : String(error), - }); - if (shouldReclaimStaleViewerDraft({ cluster, localDevice, draft, error })) { - args.logger.warn("sync.role.viewer_stale_draft_reclaimed", { - host: draft.host, - port: draft.port, - previousBrainDeviceId: cluster?.brainDeviceId ?? null, + await startHostIfNeeded(); + } else { + await stopHostIfRunning(); + if (!isCrdtSyncAvailable()) { + if (syncPeerService.isConnected()) { + syncPeerService.disconnect({ preserveDraft: true }); + } + continue; + } + const draft = savedDraft ?? resolveViewerDraftFromRegistry(); + if (draft && !syncPeerService.isConnected()) { + syncPeerService.setSavedDraft(draft); + try { + await syncPeerService.connect(draft); + deviceRegistryService.touchLocalDevice({ lastSeenAt: nowIso() }); + syncPeerService.flushLocalChanges(); + } catch (error) { + args.logger.warn("sync.role.viewer_connect_failed", { error: error instanceof Error ? error.message : String(error), }); - writeSavedDraft(null); - syncPeerService.setSavedDraft(null); - deviceRegistryService.touchLocalDevice({ - lastSeenAt: nowIso(), - lastHost: localDevice.lastHost, - lastPort: localDevice.lastPort ?? DEFAULT_SYNC_HOST_PORT, - }); - cluster = deviceRegistryService.setClusterState({ - brainDeviceId: localDevice.deviceId, - brainEpoch: (cluster?.brainEpoch ?? 0) + 1, - updatedByDeviceId: localDevice.deviceId, - }); - await startHostIfNeeded(); + if (shouldReclaimStaleViewerDraft({ cluster, localDevice, draft, error })) { + args.logger.warn("sync.role.viewer_stale_draft_reclaimed", { + host: draft.host, + port: draft.port, + previousBrainDeviceId: cluster?.brainDeviceId ?? null, + error: error instanceof Error ? error.message : String(error), + }); + writeSavedDraft(null); + syncPeerService.setSavedDraft(null); + deviceRegistryService.touchLocalDevice({ + lastSeenAt: nowIso(), + lastHost: localDevice.lastHost, + lastPort: localDevice.lastPort ?? DEFAULT_SYNC_HOST_PORT, + }); + cluster = deviceRegistryService.setClusterState({ + brainDeviceId: localDevice.deviceId, + brainEpoch: (cluster?.brainEpoch ?? 0) + 1, + updatedByDeviceId: localDevice.deviceId, + }); + await startHostIfNeeded(); + } } } } + } finally { + await emitStatus(); } - } while (refreshQueued); - } finally { - refreshRunning = false; - await emitStatus(); - } + } while (refreshQueued && !disposed); + } finally { + if (refreshPromise === work) refreshPromise = null; + } + }); + refreshPromise = work; + return work; }; const listRuntimeDevices = async (): Promise => { diff --git a/apps/desktop/src/main/services/sync/syncService.test.ts b/apps/desktop/src/main/services/sync/syncService.test.ts index e88d793c3..7a0dc0b60 100644 --- a/apps/desktop/src/main/services/sync/syncService.test.ts +++ b/apps/desktop/src/main/services/sync/syncService.test.ts @@ -1480,5 +1480,65 @@ describe.skipIf(!isCrsqliteAvailable())("syncService", () => { const enabledStatus = await service.getStatus(); expect(enabledStatus.bootstrapToken).toBe("await-token"); }, 30_000); + + it("waits for a queued host rollback before resolving either toggle", async () => { + const projectRoot = makeProjectRoot("ade-sync-service-startup-rollback-"); + const appPairingDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-sync-service-startup-rollback-app-")); + const db = await openKvDb( + path.join(projectRoot, ".ade", "ade.db"), + createLogger() as any, + ); + let resolveListening!: (port: number) => void; + const listening = new Promise((resolve) => { + resolveListening = resolve; + }); + const host = createDefaultSyncHostServiceMock(); + const disposeHost = vi.fn(async () => undefined); + createSyncHostServiceMock.mockReturnValueOnce({ + ...host, + waitUntilListening: () => listening, + dispose: disposeHost, + }); + + const service = createSyncService({ + db, + logger: createLogger() as any, + projectRoot, + phonePairingStateDir: appPairingDir, + fileService: { dispose: () => {} } as any, + laneService: { list: async () => [] } as any, + prService: {} as any, + sessionService: { list: () => [] } as any, + ptyService: {} as any, + computerUseArtifactBrokerService: {} as any, + agentChatService: { listSessions: async () => [] } as any, + processService: { listRuntime: () => [] } as any, + hostStartupEnabled: false, + } as any); + + activeDisposers.push(async () => { + await service.dispose(); + db.close(); + }); + + await service.initialize(); + const enabling = service.setHostStartupEnabled(true); + await vi.waitFor(() => { + expect(createSyncHostServiceMock).toHaveBeenCalledTimes(1); + }); + + let rollbackResolved = false; + const disabling = service.setHostStartupEnabled(false).then(() => { + rollbackResolved = true; + }); + await Promise.resolve(); + expect(rollbackResolved).toBe(false); + + resolveListening(8787); + await Promise.all([enabling, disabling]); + + expect(disposeHost).toHaveBeenCalledTimes(1); + expect(service.getHostService()).toBeNull(); + }, 30_000); }); }); From 3fc4ff4a3ac2c2c2139298b62c8f965cb0ac6e63 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 04:58:32 -0400 Subject: [PATCH 42/53] perf(usage): aggregate daily stats in SQLite --- .../main/services/usage/usageStatsStore.ts | 102 ++++++++++++------ .../usage/usageTrackingService.test.ts | 64 ++++++----- 2 files changed, 109 insertions(+), 57 deletions(-) diff --git a/apps/desktop/src/main/services/usage/usageStatsStore.ts b/apps/desktop/src/main/services/usage/usageStatsStore.ts index 31b8d4b3a..1275f82b5 100644 --- a/apps/desktop/src/main/services/usage/usageStatsStore.ts +++ b/apps/desktop/src/main/services/usage/usageStatsStore.ts @@ -444,14 +444,21 @@ export function collectAdeDatabaseUsageStats( `, eventRange.params); const clientDailyRows = safeAll<{ - occurred_at: string; + active_date: string | null; client_surface: AdeUsageClientSurface; + interactions: number; }>(db, ` - select occurred_at, client_surface - from usage_events - where ${eventRange.sql} - order by occurred_at desc - limit ? + select date(occurred_at, 'localtime') active_date, + client_surface, + count(*) interactions + from ( + select occurred_at, client_surface + from usage_events + where ${eventRange.sql} + order by occurred_at desc + limit ? + ) + group by active_date, client_surface `, [...eventRange.params, DAILY_BUCKET_SCAN_MAX_ROWS]); // Summary day counts and streaks must not depend on the capped chart scans @@ -509,14 +516,24 @@ export function collectAdeDatabaseUsageStats( and kind in ('git_commit', 'git_push', 'pr_land', 'git_pull', 'git_sync_merge', 'git_sync_rebase') group by kind `, operationRange.params); - const operationDailyRows = safeAll<{ started_at: string; kind: string }>(db, ` - select started_at, kind - from operations - where ${operationRange.sql} - and status = 'succeeded' - and kind in ('git_commit', 'pr_land') - order by started_at desc - limit ? + const operationDailyRows = safeAll<{ + active_date: string | null; + kind: string; + operations: number; + }>(db, ` + select date(started_at, 'localtime') active_date, + kind, + count(*) operations + from ( + select started_at, kind + from operations + where ${operationRange.sql} + and status = 'succeeded' + and kind in ('git_commit', 'pr_land') + order by started_at desc + limit ? + ) + group by active_date, kind `, [...operationRange.params, DAILY_BUCKET_SCAN_MAX_ROWS]); const operationCounts = new Map(operationRows.map((row) => [row.kind, int(row.count)])); const activityCounts = new Map(interactionRows.map((row) => [row.action, int(row.count)])); @@ -654,24 +671,42 @@ export function collectAdeDatabaseUsageStats( return existing; }; const aiDailyRows = safeAll<{ - timestamp: string; + active_date: string | null; input_tokens: number; output_tokens: number; duration_ms: number; + calls: number; }>(db, ` - select timestamp, - coalesce(input_tokens, 0) input_tokens, - coalesce(output_tokens, 0) output_tokens, - coalesce(duration_ms, 0) duration_ms - from ai_usage_log - where ${aiRange.sql} - order by timestamp desc - limit ? + select date(timestamp, 'localtime') active_date, + sum(max(0, cast(coalesce(input_tokens, 0) as integer))) input_tokens, + sum(max(0, cast(coalesce(output_tokens, 0) as integer))) output_tokens, + sum(max(0, cast(coalesce(duration_ms, 0) as integer))) duration_ms, + count(*) calls + from ( + select timestamp, input_tokens, output_tokens, duration_ms + from ai_usage_log + where ${aiRange.sql} + order by timestamp desc + limit ? + ) + group by active_date `, [...aiRange.params, DAILY_BUCKET_SCAN_MAX_ROWS]); + const clientDailyScanCount = clientDailyRows.reduce( + (sum, row) => sum + int(row.interactions), + 0, + ); + const operationDailyScanCount = operationDailyRows.reduce( + (sum, row) => sum + int(row.operations), + 0, + ); + const aiDailyScanCount = aiDailyRows.reduce( + (sum, row) => sum + int(row.calls), + 0, + ); const cappedDailySources = [ - clientDailyRows.length === DAILY_BUCKET_SCAN_MAX_ROWS ? "usage_events" : null, - operationDailyRows.length === DAILY_BUCKET_SCAN_MAX_ROWS ? "operations" : null, - aiDailyRows.length === DAILY_BUCKET_SCAN_MAX_ROWS ? "ai_usage_log" : null, + clientDailyScanCount === DAILY_BUCKET_SCAN_MAX_ROWS ? "usage_events" : null, + operationDailyScanCount === DAILY_BUCKET_SCAN_MAX_ROWS ? "operations" : null, + aiDailyScanCount === DAILY_BUCKET_SCAN_MAX_ROWS ? "ai_usage_log" : null, ].filter((source): source is string => source !== null); if (cappedDailySources.length > 0) { logger?.debug("usage.daily_bucket_scan_capped", { @@ -680,7 +715,7 @@ export function collectAdeDatabaseUsageStats( }); } for (const row of aiDailyRows) { - const date = isoDate(row.timestamp); + const date = isoDate(row.active_date); if (!date) continue; const day = ensureDay(date); day.inputTokens = int(day.inputTokens) + int(row.input_tokens); @@ -703,21 +738,22 @@ export function collectAdeDatabaseUsageStats( day.deletions = int(day.deletions) + int(row.deletions); } for (const row of clientDailyRows) { - const date = isoDate(row.occurred_at); + const date = isoDate(row.active_date); if (!date) continue; + const interactions = int(row.interactions); const day = ensureDay(date); - day.interactions = int(day.interactions) + 1; + day.interactions = int(day.interactions) + interactions; day.clients = { ...(day.clients ?? {}), - [row.client_surface]: int(day.clients?.[row.client_surface]) + 1, + [row.client_surface]: int(day.clients?.[row.client_surface]) + interactions, }; } for (const row of operationDailyRows) { - const date = isoDate(row.started_at); + const date = isoDate(row.active_date); if (!date) continue; const day = ensureDay(date); - if (row.kind === "git_commit") day.commits = int(day.commits) + 1; - if (row.kind === "pr_land") day.prs = int(day.prs) + 1; + if (row.kind === "git_commit") day.commits = int(day.commits) + int(row.operations); + if (row.kind === "pr_land") day.prs = int(day.prs) + int(row.operations); } const streaks = calculateStreaks(activeDateRows.map((row) => row.active_date), range.until); diff --git a/apps/desktop/src/main/services/usage/usageTrackingService.test.ts b/apps/desktop/src/main/services/usage/usageTrackingService.test.ts index 2420d3b5f..9a624e009 100644 --- a/apps/desktop/src/main/services/usage/usageTrackingService.test.ts +++ b/apps/desktop/src/main/services/usage/usageTrackingService.test.ts @@ -4218,22 +4218,10 @@ describe("ADE database usage aggregation", () => { })); }); - it("caps raw daily bucket scans newest-first without throwing", async () => { + it("caps daily source windows newest-first before aggregating in SQLite", async () => { const db = await createStatsDb(); const logger = createLogger(); const originalAll = db.all.bind(db); - const newestRow = { - timestamp: "2026-07-08T12:00:00.000Z", - input_tokens: 1, - output_tokens: 0, - duration_ms: 0, - }; - const oldestRow = { - ...newestRow, - timestamp: "2026-07-07T12:00:00.000Z", - }; - const rowsBeyondCap = Array(250_001).fill(newestRow); - rowsBeyondCap[0] = oldestRow; let checkedClientQuery = false; let checkedOperationQuery = false; let checkedAiQuery = false; @@ -4241,20 +4229,38 @@ describe("ADE database usage aggregation", () => { ...db, all: ((sql: string, params = []) => { const normalized = sql.replace(/\s+/g, " ").trim(); - if (normalized.startsWith("select occurred_at, client_surface")) { - expect(normalized).toContain("order by occurred_at desc limit ?"); + if (normalized.startsWith("select date(occurred_at, 'localtime') active_date")) { + expect(normalized).toContain("from ( select occurred_at, client_surface"); + expect(normalized).toContain("order by occurred_at desc limit ? ) group by active_date, client_surface"); checkedClientQuery = true; + return [{ + active_date: "2026-07-08", + client_surface: "desktop", + interactions: 250_000, + }]; } - if (normalized.startsWith("select started_at, kind")) { - expect(normalized).toContain("order by started_at desc limit ?"); + if (normalized.startsWith("select date(started_at, 'localtime') active_date")) { + expect(normalized).toContain("from ( select started_at, kind"); + expect(normalized).toContain("order by started_at desc limit ? ) group by active_date, kind"); checkedOperationQuery = true; + return [{ + active_date: "2026-07-08", + kind: "git_commit", + operations: 250_000, + }]; } - if (normalized.startsWith("select timestamp,")) { - expect(normalized).toContain("order by timestamp desc limit ?"); - const limit = Number(params.at(-1)); - expect(rowsBeyondCap.length).toBeGreaterThan(limit); + if (normalized.startsWith("select date(timestamp, 'localtime') active_date")) { + expect(normalized).toContain("from ( select timestamp, input_tokens, output_tokens, duration_ms"); + expect(normalized).toContain("order by timestamp desc limit ? ) group by active_date"); + expect(params.at(-1)).toBe(250_000); checkedAiQuery = true; - return rowsBeyondCap.slice(1, limit + 1); + return [{ + active_date: "2026-07-08", + input_tokens: 250_000, + output_tokens: 0, + duration_ms: 0, + calls: 250_000, + }]; } return originalAll(sql, params); }) as AdeDb["all"], @@ -4272,12 +4278,15 @@ describe("ADE database usage aggregation", () => { date: "2026-07-08", inputTokens: 250_000, totalTokens: 250_000, + commits: 250_000, + interactions: 250_000, + clients: { desktop: 250_000 }, })); expect(stats?.daily.some((point) => point.date === "2026-07-07")).toBe(false); expect(logger.debug).toHaveBeenCalledTimes(1); expect(logger.debug).toHaveBeenCalledWith("usage.daily_bucket_scan_capped", { maxRows: 250_000, - sources: ["ai_usage_log"], + sources: ["usage_events", "operations", "ai_usage_log"], }); }); @@ -4373,11 +4382,13 @@ describe("ADE database usage aggregation", () => { ); db.run( `insert into operations(id, project_id, lane_id, kind, started_at, ended_at, status) - values (?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?)`, + values (?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?), + (?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?)`, [ "op-1", "project-1", "lane-1", "git_commit", "2026-07-08T12:20:00.000Z", "2026-07-08T12:20:01.000Z", "succeeded", "op-2", "project-1", "lane-1", "git_push", "2026-07-08T12:21:00.000Z", "2026-07-08T12:21:01.000Z", "succeeded", "op-3", "project-1", "lane-1", "git_commit", "2026-07-08T12:22:00.000Z", "2026-07-08T12:22:01.000Z", "failed", + "op-4", "project-1", "lane-1", "pr_land", "2026-07-08T12:23:00.000Z", "2026-07-08T12:23:01.000Z", "succeeded", ], ); recordUsageInteraction(db, { projectId: "project-1", client: "desktop", action: "chat.send", sessionId: "session-1", occurredAt: "2026-07-08T12:05:00.000Z" }); @@ -4395,6 +4406,7 @@ describe("ADE database usage aggregation", () => { chatSessions: 1, commitsCreated: 1, pushOperations: 1, + prLandings: 1, filesChanged: 5, insertions: 120, deletions: 20, @@ -4420,10 +4432,14 @@ describe("ADE database usage aggregation", () => { }), expect.objectContaining({ date: "2026-07-08", + inputTokens: 120, + outputTokens: 60, totalTokens: 180, + durationMs: 1_500, sessions: 1, filesChanged: 3, commits: 1, + prs: 1, interactions: 2, clients: { desktop: 1, mobile: 1 }, }), From 51cae61b45c6743976684286474665e7553119ae Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 05:04:52 -0400 Subject: [PATCH 43/53] perf(usage): isolate historical ledger scans --- apps/ade-cli/tsup.config.ts | 3 +- .../main/services/usage/usageLedgerWorker.ts | 93 ++++++++ .../usage/usageLedgerWorkerClient.test.ts | 96 +++++++++ .../services/usage/usageLedgerWorkerClient.ts | 153 +++++++++++++ .../usage/usageTrackingService.test.ts | 59 +++++ .../services/usage/usageTrackingService.ts | 201 ++++++++++-------- apps/desktop/tsup.config.ts | 1 + 7 files changed, 515 insertions(+), 91 deletions(-) create mode 100644 apps/desktop/src/main/services/usage/usageLedgerWorker.ts create mode 100644 apps/desktop/src/main/services/usage/usageLedgerWorkerClient.test.ts create mode 100644 apps/desktop/src/main/services/usage/usageLedgerWorkerClient.ts diff --git a/apps/ade-cli/tsup.config.ts b/apps/ade-cli/tsup.config.ts index 8d988a8dd..0733fde14 100644 --- a/apps/ade-cli/tsup.config.ts +++ b/apps/ade-cli/tsup.config.ts @@ -44,7 +44,8 @@ export default defineConfig([ adeRpcServer: "src/adeRpcServer.ts", ptyHostWorker: "../desktop/src/main/services/pty/ptyHostWorker.ts", cursorSdkWorker: "../desktop/src/main/services/chat/cursorSdkWorker.ts", - droidSdkWorker: "../desktop/src/main/services/chat/droidSdkWorker.ts" + droidSdkWorker: "../desktop/src/main/services/chat/droidSdkWorker.ts", + usageLedgerWorker: "../desktop/src/main/services/usage/usageLedgerWorker.ts" }, format: ["cjs"], platform: "node", diff --git a/apps/desktop/src/main/services/usage/usageLedgerWorker.ts b/apps/desktop/src/main/services/usage/usageLedgerWorker.ts new file mode 100644 index 000000000..13f920fd8 --- /dev/null +++ b/apps/desktop/src/main/services/usage/usageLedgerWorker.ts @@ -0,0 +1,93 @@ +import type { UsageProvider } from "../../../shared/types"; +import { getErrorMessage, isRecord } from "../shared/utils"; +import { refreshDynamicTokenPricing } from "./usagePricing"; +import { + scanClaudeLogs, + scanCodexLogs, + scanCopilotLogs, + scanCursorAgentLogs, + scanCursorLogs, + scanDroidLogs, + scanGeminiLogs, + scanOpenClawLogs, + scanOpenCodeLogs, + type TokenEntry, +} from "./ledgers/localUsageLedgers"; +import { buildCostSnapshots, bucketDaily7d } from "./usageTrackingService"; +import type { UsageLedgerScanResult } from "./usageLedgerWorkerClient"; + +const WORKER_INPUT_MAX_BYTES = 64 * 1024; + +type ProviderScanner = { + provider: string; + scan: () => Promise; +}; + +const providerScanners: ProviderScanner[] = [ + { provider: "claude", scan: scanClaudeLogs }, + { provider: "codex", scan: scanCodexLogs }, + { provider: "cursor", scan: scanCursorLogs }, + { provider: "cursor-agent", scan: scanCursorAgentLogs }, + { provider: "openclaw", scan: scanOpenClawLogs }, + { provider: "opencode", scan: scanOpenCodeLogs }, + { provider: "droid", scan: scanDroidLogs }, + { provider: "copilot", scan: scanCopilotLogs }, + { provider: "gemini", scan: scanGeminiLogs }, +]; + +async function readInput(): Promise<{ projectRoot: string | null }> { + let raw = ""; + for await (const chunk of process.stdin) { + raw += chunk.toString(); + if (Buffer.byteLength(raw, "utf8") > WORKER_INPUT_MAX_BYTES) { + throw new Error("Usage ledger worker input is too large"); + } + } + const parsed = JSON.parse(raw) as unknown; + if (!isRecord(parsed) || (parsed.projectRoot !== null && typeof parsed.projectRoot !== "string")) { + throw new Error("Usage ledger worker input is invalid"); + } + return { projectRoot: parsed.projectRoot }; +} + +async function main(): Promise { + const { projectRoot } = await readInput(); + await refreshDynamicTokenPricing().catch(() => 0); + const result: UsageLedgerScanResult = { + costs: [], + projectCosts: [], + daily7d: {}, + entryCounts: {}, + providerErrors: {}, + }; + const nowMs = Date.now(); + + // Scan and aggregate one provider at a time. The old Promise.all path kept + // every provider's per-turn ledger objects alive together and pushed a busy + // ADE runtime into multi-gigabyte peaks. This worker also keeps that work off + // the runtime's project/chat/sync event loop. + for (const scanner of providerScanners) { + let entries: TokenEntry[]; + try { + entries = await scanner.scan(); + } catch (error) { + result.providerErrors[scanner.provider] = getErrorMessage(error); + result.entryCounts[scanner.provider] = 0; + continue; + } + result.entryCounts[scanner.provider] = entries.length; + const providerEntries = new Map([[scanner.provider, entries]]); + result.costs.push(...buildCostSnapshots(providerEntries, "machine", projectRoot)); + result.projectCosts.push(...buildCostSnapshots(providerEntries, "project", projectRoot)); + if ((scanner.provider === "claude" || scanner.provider === "codex") && entries.length > 0) { + result.daily7d[scanner.provider as UsageProvider] = bucketDaily7d(entries, nowMs); + } + } + + process.stdout.write(JSON.stringify(result)); +} + +void main().catch((error) => { + process.stderr.write(getErrorMessage(error).slice(0, 64 * 1024)); + process.exitCode = 1; +}); diff --git a/apps/desktop/src/main/services/usage/usageLedgerWorkerClient.test.ts b/apps/desktop/src/main/services/usage/usageLedgerWorkerClient.test.ts new file mode 100644 index 000000000..deb15ee44 --- /dev/null +++ b/apps/desktop/src/main/services/usage/usageLedgerWorkerClient.test.ts @@ -0,0 +1,96 @@ +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import { describe, expect, it, vi } from "vitest"; +import { + parseUsageLedgerWorkerResult, + scanUsageLedgersInWorker, +} from "./usageLedgerWorkerClient"; + +function resultJson(): string { + return JSON.stringify({ + costs: [{ provider: "codex", todayCostUsd: 1, last30dCostUsd: 2, tokenBreakdown: {} }], + projectCosts: [], + daily7d: { codex: [0, 0, 0, 0, 0, 0, 10] }, + entryCounts: { codex: 1 }, + providerErrors: {}, + }); +} + +function fakeChild() { + const child = new EventEmitter() as EventEmitter & { + stdin: PassThrough; + stdout: PassThrough; + stderr: PassThrough; + kill: ReturnType; + pid: number; + exitCode: number | null; + signalCode: NodeJS.Signals | null; + }; + child.stdin = new PassThrough(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.kill = vi.fn(() => true); + child.pid = 1234; + child.exitCode = null; + child.signalCode = null; + return child; +} + +describe("usage ledger worker client", () => { + it("validates compact worker results", () => { + expect(parseUsageLedgerWorkerResult(resultJson())).toMatchObject({ + entryCounts: { codex: 1 }, + daily7d: { codex: [0, 0, 0, 0, 0, 0, 10] }, + }); + expect(() => parseUsageLedgerWorkerResult(JSON.stringify({ costs: "invalid" }))).toThrow( + "invalid result", + ); + }); + + it("streams input and resolves a successful worker result", async () => { + const child = fakeChild(); + let input = ""; + child.stdin.on("data", (chunk) => { input += chunk.toString(); }); + const spawnWorker = vi.fn(() => child); + const promise = scanUsageLedgersInWorker("/repo", { + workerPath: __filename, + spawnWorker: spawnWorker as never, + }); + child.stdout.end(resultJson()); + child.emit("close", 0, null); + + await expect(promise).resolves.toMatchObject({ entryCounts: { codex: 1 } }); + expect(JSON.parse(input)).toEqual({ projectRoot: "/repo" }); + expect(spawnWorker).toHaveBeenCalledWith( + process.execPath, + [__filename], + expect.objectContaining({ stdio: ["pipe", "pipe", "pipe"] }), + ); + }); + + it("cancels the worker without waiting for its timeout", async () => { + const child = fakeChild(); + const controller = new AbortController(); + const promise = scanUsageLedgersInWorker(null, { + signal: controller.signal, + workerPath: __filename, + spawnWorker: (() => child) as never, + }); + controller.abort(); + + await expect(promise).rejects.toMatchObject({ name: "AbortError" }); + expect(child.kill).toHaveBeenCalledWith("SIGTERM"); + }); + + it("rejects non-zero exits with bounded stderr context", async () => { + const child = fakeChild(); + const promise = scanUsageLedgersInWorker(null, { + workerPath: __filename, + spawnWorker: (() => child) as never, + }); + child.stderr.end("scanner failed"); + child.emit("close", 1, null); + + await expect(promise).rejects.toThrow("scanner failed"); + }); +}); diff --git a/apps/desktop/src/main/services/usage/usageLedgerWorkerClient.ts b/apps/desktop/src/main/services/usage/usageLedgerWorkerClient.ts new file mode 100644 index 000000000..cecd6eedf --- /dev/null +++ b/apps/desktop/src/main/services/usage/usageLedgerWorkerClient.ts @@ -0,0 +1,153 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import type { CostSnapshot, UsageProvider } from "../../../shared/types"; +import { isRecord } from "../shared/utils"; +import { terminateProcessTree } from "../shared/processExecution"; + +const LEDGER_WORKER_TIMEOUT_MS = 90_000; +const LEDGER_WORKER_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; +const LEDGER_WORKER_MAX_ERROR_BYTES = 64 * 1024; + +export type UsageLedgerScanResult = { + costs: CostSnapshot[]; + projectCosts: CostSnapshot[]; + daily7d: Partial>; + entryCounts: Record; + providerErrors: Record; +}; + +type WorkerOptions = { + signal?: AbortSignal; + workerPath?: string; + spawnWorker?: typeof spawn; +}; + +function abortError(): Error { + const error = new Error("Usage ledger scan cancelled"); + error.name = "AbortError"; + return error; +} + +export function resolveUsageLedgerWorkerPath(baseDir = __dirname): string { + return path.join(baseDir, "usageLedgerWorker.cjs"); +} + +function isCostSnapshot(value: unknown): value is CostSnapshot { + return isRecord(value) + && typeof value.provider === "string" + && typeof value.todayCostUsd === "number" + && typeof value.last30dCostUsd === "number" + && isRecord(value.tokenBreakdown); +} + +export function parseUsageLedgerWorkerResult(raw: string): UsageLedgerScanResult { + const parsed = JSON.parse(raw) as unknown; + if (!isRecord(parsed) + || !Array.isArray(parsed.costs) + || !parsed.costs.every(isCostSnapshot) + || !Array.isArray(parsed.projectCosts) + || !parsed.projectCosts.every(isCostSnapshot) + || !isRecord(parsed.daily7d) + || !isRecord(parsed.entryCounts) + || !isRecord(parsed.providerErrors)) { + throw new Error("Usage ledger worker returned an invalid result"); + } + return parsed as UsageLedgerScanResult; +} + +export function scanUsageLedgersInWorker( + projectRoot: string | null | undefined, + options: WorkerOptions = {}, +): Promise { + const workerPath = options.workerPath ?? resolveUsageLedgerWorkerPath(); + if (!fs.existsSync(workerPath)) { + return Promise.reject(new Error(`Usage ledger worker is missing: ${workerPath}`)); + } + if (options.signal?.aborted) return Promise.reject(abortError()); + + return new Promise((resolve, reject) => { + const spawnWorker = options.spawnWorker ?? spawn; + const env = { ...process.env }; + if (process.versions.electron) env.ELECTRON_RUN_AS_NODE = "1"; + let child: ChildProcessWithoutNullStreams; + try { + child = spawnWorker(process.execPath, [workerPath], { + env, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }) as ChildProcessWithoutNullStreams; + } catch (error) { + reject(error); + return; + } + if (!options.spawnWorker && typeof child.pid === "number") { + try { + os.setPriority(child.pid, os.constants.priority.PRIORITY_BELOW_NORMAL); + } catch { + // Best effort: isolation is the correctness boundary; priority is an + // additional guard against ledger IO competing with active chats. + } + } + + let stdout = ""; + let stderr = ""; + let settled = false; + const finish = (callback: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + options.signal?.removeEventListener("abort", onAbort); + callback(); + }; + const fail = (error: Error) => { + finish(() => { + terminateProcessTree(child); + reject(error); + }); + }; + const onAbort = () => fail(abortError()); + const timeout = setTimeout(() => { + fail(new Error(`Usage ledger worker timed out after ${LEDGER_WORKER_TIMEOUT_MS}ms`)); + }, LEDGER_WORKER_TIMEOUT_MS); + timeout.unref?.(); + options.signal?.addEventListener("abort", onAbort, { once: true }); + + child.stdout.on("data", (chunk: Buffer | string) => { + stdout += chunk.toString(); + if (Buffer.byteLength(stdout, "utf8") > LEDGER_WORKER_MAX_OUTPUT_BYTES) { + fail(new Error("Usage ledger worker produced too much output")); + } + }); + child.stderr.on("data", (chunk: Buffer | string) => { + if (Buffer.byteLength(stderr, "utf8") >= LEDGER_WORKER_MAX_ERROR_BYTES) return; + stderr += chunk.toString(); + }); + child.on("error", (error) => finish(() => reject(error))); + child.on("close", (code, signal) => { + finish(() => { + if (code !== 0) { + const detail = stderr.trim().slice(0, LEDGER_WORKER_MAX_ERROR_BYTES); + reject(new Error(`Usage ledger worker exited with ${code ?? signal ?? "unknown"}${detail ? `: ${detail}` : ""}`)); + return; + } + try { + resolve(parseUsageLedgerWorkerResult(stdout)); + } catch (error) { + reject(error); + } + }); + }); + child.stdin.on("error", (error: NodeJS.ErrnoException) => { + if (error.code !== "EPIPE" && error.code !== "ERR_STREAM_DESTROYED") fail(error); + }); + child.stdin.end(JSON.stringify({ projectRoot: projectRoot ?? null })); + }); +} + +export const _testing = { + LEDGER_WORKER_TIMEOUT_MS, + LEDGER_WORKER_MAX_OUTPUT_BYTES, + LEDGER_WORKER_MAX_ERROR_BYTES, +}; diff --git a/apps/desktop/src/main/services/usage/usageTrackingService.test.ts b/apps/desktop/src/main/services/usage/usageTrackingService.test.ts index 9a624e009..3e063890e 100644 --- a/apps/desktop/src/main/services/usage/usageTrackingService.test.ts +++ b/apps/desktop/src/main/services/usage/usageTrackingService.test.ts @@ -1958,6 +1958,65 @@ describe("createUsageTrackingService", () => { service.dispose(); }); + it("serves aged provider history without launching a surprise transcript rescan", async () => { + const logger = createLogger(); + const now = new Date("2026-07-22T12:00:00.000Z").getTime(); + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(now); + const dependencies = { + ...createFastDependencies(), + scanGitHubStats: vi.fn(async () => ({ + repo: null, + available: false, + fetchedAt: null, + error: null, + commitsCreated: 0, + prsTracked: 0, + prsOpen: 0, + prsMerged: 0, + prsClosed: 0, + prAdditions: 0, + prDeletions: 0, + filesChanged: 0, + daily: [], + })), + }; + const service = createUsageTrackingService({ logger, dependencies }); + await service.refreshHistory(); + for (const scanner of [ + dependencies.scanClaudeLogs, + dependencies.scanCodexLogs, + dependencies.scanCursorLogs, + dependencies.scanCursorAgentLogs, + dependencies.scanOpenClawLogs, + dependencies.scanOpenCodeLogs, + dependencies.scanDroidLogs, + dependencies.scanCopilotLogs, + dependencies.scanGeminiLogs, + ]) scanner.mockClear(); + + nowSpy.mockReturnValue(now + 2 * 60 * 60_000); + const stats = await service.getAdeUsageStats({ preset: "7d" }); + expect(stats.freshness?.state).toBe("refreshing"); + await vi.waitFor(() => expect(dependencies.scanGitHubStats).toHaveBeenCalledTimes(1)); + await new Promise((resolve) => setImmediate(resolve)); + const settled = await service.getAdeUsageStats({ preset: "7d" }); + expect(settled.freshness?.state).toBe("stale"); + for (const scanner of [ + dependencies.scanClaudeLogs, + dependencies.scanCodexLogs, + dependencies.scanCursorLogs, + dependencies.scanCursorAgentLogs, + dependencies.scanOpenClawLogs, + dependencies.scanOpenCodeLogs, + dependencies.scanDroidLogs, + dependencies.scanCopilotLogs, + dependencies.scanGeminiLogs, + ]) expect(scanner).not.toHaveBeenCalled(); + + service.dispose(); + nowSpy.mockRestore(); + }); + it("runs an explicit history scan independently from a pending startup quota poll", async () => { const logger = createLogger(); const dependencies = createFastDependencies(); diff --git a/apps/desktop/src/main/services/usage/usageTrackingService.ts b/apps/desktop/src/main/services/usage/usageTrackingService.ts index a2fde8c0b..eb73de542 100644 --- a/apps/desktop/src/main/services/usage/usageTrackingService.ts +++ b/apps/desktop/src/main/services/usage/usageTrackingService.ts @@ -86,6 +86,10 @@ import { collectAdeDatabaseUsageStats, type AdeDatabaseUsageStats, } from "./usageStatsStore"; +import { + scanUsageLedgersInWorker, + type UsageLedgerScanResult, +} from "./usageLedgerWorkerClient"; import type { FreshUsageProviderPollResult, UsageProviderPollContext, @@ -111,7 +115,7 @@ const ACTIVE_POLL_INTERVAL_MS = 60_000; const IDLE_POLL_INTERVAL_MS = 5 * 60_000; const IDLE_AFTER_MS = 15 * 60_000; const QUOTA_DEMAND_LEASE_MS = 90_000; -const COST_CACHE_TTL_MS = 10 * 60_000; // 10 min +const COST_CACHE_TTL_MS = 60 * 60_000; // 1 hour; history scans are intentionally low priority const CODEX_CLI_RPC_TIMEOUT_MS = 10_000; const CLAUDE_CLI_USAGE_TIMEOUT_MS = 16_000; const QUOTA_REFRESH_RESPONSE_TIMEOUT_MS = 20_000; @@ -902,7 +906,7 @@ async function pollCodexViaCliRpc(logger: Logger): Promise(7).fill(0); const today = new Date(nowMs); const bucketByDay = new Map(); @@ -1092,7 +1096,7 @@ const PROVIDER_ESTIMATION: Readonly; +export type ProviderTokenEntries = Map; function canonicalProjectRoot(projectRoot: string): string { const resolved = path.resolve(projectRoot); @@ -1116,7 +1120,7 @@ function tokenEntryMatchesProject(entry: TokenEntry, projectRoot: string | null return false; } -function buildCostSnapshots( +export function buildCostSnapshots( entriesByProvider: ProviderTokenEntries, scope: AdeUsageScope, projectRoot: string | null | undefined, @@ -2358,6 +2362,7 @@ type UsageTrackingDependencies = { scanGeminiLogs?: () => Promise; scanGitHubStats?: (range: ResolvedAdeUsageRange) => Promise; collectDatabaseStats?: (range: ResolvedAdeUsageRange) => AdeDatabaseUsageStats | null; + scanUsageLedgers?: (projectRoot: string | null | undefined, signal: AbortSignal) => Promise; }; type PollOptions = { @@ -2446,6 +2451,19 @@ export function createUsageTrackingService({ ?? ((range: ResolvedAdeUsageRange) => scanGithubActivityStats(projectRoot, range)); const collectDatabaseStatsForRange = dependencies?.collectDatabaseStats ?? ((range: ResolvedAdeUsageRange) => collectAdeDatabaseUsageStats(db, range, logger)); + const hasInjectedLedgerScanners = Boolean( + dependencies?.scanClaudeLogs + || dependencies?.scanCodexLogs + || dependencies?.scanCursorLogs + || dependencies?.scanCursorAgentLogs + || dependencies?.scanOpenClawLogs + || dependencies?.scanOpenCodeLogs + || dependencies?.scanDroidLogs + || dependencies?.scanCopilotLogs + || dependencies?.scanGeminiLogs, + ); + const ledgerAbortController = new AbortController(); + let disposed = false; const emptySnapshot = (): UsageSnapshot => ({ windows: [], @@ -2460,6 +2478,7 @@ export function createUsageTrackingService({ }); function emitUpdate(snapshot: UsageSnapshot): void { + if (disposed) return; try { onUpdate?.(snapshot); } catch { @@ -2485,96 +2504,86 @@ export function createUsageTrackingService({ }); } - const [ - claudeEntries, - codexEntries, - cursorEntries, - cursorAgentEntries, - openClawEntries, - openCodeEntries, - droidEntries, - copilotEntries, - geminiEntries, - ] = await Promise.all([ - scanClaudeCostLogs().catch((err) => { - logger.warn("usage.cost_scan.claude_failed", { error: getErrorMessage(err) }); - return [] as TokenEntry[]; - }), - scanCodexCostLogs().catch((err) => { - logger.warn("usage.cost_scan.codex_failed", { error: getErrorMessage(err) }); - return [] as TokenEntry[]; - }), - scanCursorCostLogs().catch((err) => { - logger.warn("usage.cost_scan.cursor_failed", { error: getErrorMessage(err) }); - return [] as TokenEntry[]; - }), - scanCursorAgentCostLogs().catch((err) => { - logger.warn("usage.cost_scan.cursor_agent_failed", { error: getErrorMessage(err) }); - return [] as TokenEntry[]; - }), - scanOpenClawCostLogs().catch((err) => { - logger.warn("usage.cost_scan.openclaw_failed", { error: getErrorMessage(err) }); - return [] as TokenEntry[]; - }), - scanOpenCodeCostLogs().catch((err) => { - logger.warn("usage.cost_scan.opencode_failed", { error: getErrorMessage(err) }); - return [] as TokenEntry[]; - }), - scanDroidCostLogs().catch((err) => { - logger.warn("usage.cost_scan.droid_failed", { error: getErrorMessage(err) }); - return [] as TokenEntry[]; - }), - scanCopilotCostLogs().catch((err) => { - logger.warn("usage.cost_scan.copilot_failed", { error: getErrorMessage(err) }); - return [] as TokenEntry[]; - }), - scanGeminiCostLogs().catch((err) => { - logger.warn("usage.cost_scan.gemini_failed", { error: getErrorMessage(err) }); - return [] as TokenEntry[]; - }), - ]); - - const providerEntries: ProviderTokenEntries = new Map([ - ["claude", claudeEntries], - ["codex", codexEntries], - ["cursor", cursorEntries], - ["cursor-agent", cursorAgentEntries], - ["openclaw", openClawEntries], - ["opencode", openCodeEntries], - ["droid", droidEntries], - ["copilot", copilotEntries], - ["gemini", geminiEntries], - ]); - const costs = buildCostSnapshots(providerEntries, "machine", projectRoot); - const projectCosts = buildCostSnapshots(providerEntries, "project", projectRoot); - - const daily7d: Partial> = {}; - if (claudeEntries.length > 0) daily7d.claude = bucketDaily7d(claudeEntries, now); - if (codexEntries.length > 0) daily7d.codex = bucketDaily7d(codexEntries, now); + let scanResult: UsageLedgerScanResult; + if (!hasInjectedLedgerScanners) { + scanResult = await (dependencies?.scanUsageLedgers ?? ((root, signal) => ( + scanUsageLedgersInWorker(root, { signal }) + )))(projectRoot, ledgerAbortController.signal); + } else { + const scanInjected = async (provider: string, work: () => Promise): Promise => { + try { + return await work(); + } catch (error) { + logger.warn(`usage.cost_scan.${provider}_failed`, { error: getErrorMessage(error) }); + return []; + } + }; + const [ + claudeEntries, + codexEntries, + cursorEntries, + cursorAgentEntries, + openClawEntries, + openCodeEntries, + droidEntries, + copilotEntries, + geminiEntries, + ] = await Promise.all([ + scanInjected("claude", scanClaudeCostLogs), + scanInjected("codex", scanCodexCostLogs), + scanInjected("cursor", scanCursorCostLogs), + scanInjected("cursor_agent", scanCursorAgentCostLogs), + scanInjected("openclaw", scanOpenClawCostLogs), + scanInjected("opencode", scanOpenCodeCostLogs), + scanInjected("droid", scanDroidCostLogs), + scanInjected("copilot", scanCopilotCostLogs), + scanInjected("gemini", scanGeminiCostLogs), + ]); + const providerEntries: ProviderTokenEntries = new Map([ + ["claude", claudeEntries], + ["codex", codexEntries], + ["cursor", cursorEntries], + ["cursor-agent", cursorAgentEntries], + ["openclaw", openClawEntries], + ["opencode", openCodeEntries], + ["droid", droidEntries], + ["copilot", copilotEntries], + ["gemini", geminiEntries], + ]); + scanResult = { + costs: buildCostSnapshots(providerEntries, "machine", projectRoot), + projectCosts: buildCostSnapshots(providerEntries, "project", projectRoot), + daily7d: { + ...(claudeEntries.length > 0 ? { claude: bucketDaily7d(claudeEntries, now) } : {}), + ...(codexEntries.length > 0 ? { codex: bucketDaily7d(codexEntries, now) } : {}), + }, + entryCounts: Object.fromEntries( + Array.from(providerEntries, ([provider, entries]) => [provider, entries.length]), + ), + providerErrors: {}, + }; + } + if (disposed) throw new Error("Usage tracking service disposed during ledger scan"); + for (const [provider, error] of Object.entries(scanResult.providerErrors)) { + logger.warn(`usage.cost_scan.${provider}_failed`, { error }); + } - cachedCosts = costs; + cachedCosts = scanResult.costs; cachedAdeCosts = []; - cachedProjectCosts = projectCosts; + cachedProjectCosts = scanResult.projectCosts; projectCostsReady = true; - cachedDaily7d = daily7d; + cachedDaily7d = scanResult.daily7d; costCacheTimestamp = now; const durationMs = Date.now() - startedAt; if (durationMs > 500) { logger.warn("usage.cost_scan_slow", { durationMs, - providerCount: costs.length, - claudeEntries: claudeEntries.length, - codexEntries: codexEntries.length, - cursorEntries: cursorEntries.length, - cursorAgentEntries: cursorAgentEntries.length, - openClawEntries: openClawEntries.length, - openCodeEntries: openCodeEntries.length, - droidEntries: droidEntries.length, - copilotEntries: copilotEntries.length, - geminiEntries: geminiEntries.length, + isolated: !hasInjectedLedgerScanners, + providerCount: scanResult.costs.length, + entryCounts: scanResult.entryCounts, }); } - return { costs, adeCosts: [] }; + return { costs: scanResult.costs, adeCosts: [] }; } async function poll(options: PollOptions = {}): Promise { @@ -2930,10 +2939,16 @@ export function createUsageTrackingService({ const snapshot = scope === "project" ? { ...machineSnapshot, costs: cachedProjectCosts } : machineSnapshot; - const staleCosts = costCacheTimestamp === 0 - || nowMs - costCacheTimestamp > COST_CACHE_TTL_MS - || (scope === "project" && !projectCostsReady); - const providerNeedsRefresh = staleCosts; + const providerHistoryMissing = costCacheTimestamp === 0; + const providerHistoryStale = !providerHistoryMissing + && (nowMs - costCacheTimestamp > COST_CACHE_TTL_MS + || (scope === "project" && !projectCostsReady)); + // Reading the compact Activity card must never start a multi-gigabyte + // transcript walk merely because a cached history snapshot aged out. A + // first-run install still populates history in the isolated worker, while + // established installs keep serving their snapshot until the explicit + // Settings > Activity refresh asks for a newer ledger pass. + const providerNeedsRefresh = providerHistoryMissing; const githubNeedsRefresh = !githubCached || nowMs - (githubStatsCache.get(cacheKey)?.fetchedAtMs ?? 0) > GITHUB_STATS_CACHE_TTL_MS; if (providerNeedsRefresh || githubNeedsRefresh) { refreshStatsInBackground(range, { provider: providerNeedsRefresh, github: githubNeedsRefresh }, exactRange); @@ -2946,7 +2961,9 @@ export function createUsageTrackingService({ nowMs, }); stats.freshness = { - state: providerNeedsRefresh || githubNeedsRefresh ? "refreshing" : "fresh", + state: providerNeedsRefresh || githubNeedsRefresh + ? "refreshing" + : providerHistoryStale ? "stale" : "fresh", providerUpdatedAt: machineSnapshot.costsLastPolledAt ?? null, githubUpdatedAt: githubCached?.fetchedAt ?? null, }; @@ -3036,7 +3053,11 @@ export function createUsageTrackingService({ refreshHistory, getAdeUsageStats, poll, - dispose: stop, + dispose: () => { + disposed = true; + ledgerAbortController.abort(); + stop(); + }, }; } diff --git a/apps/desktop/tsup.config.ts b/apps/desktop/tsup.config.ts index cc42a5b16..b2df96aef 100644 --- a/apps/desktop/tsup.config.ts +++ b/apps/desktop/tsup.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ "main/cursorSdkWorker": "src/main/services/chat/cursorSdkWorker.ts", "main/droidSdkWorker": "src/main/services/chat/droidSdkWorker.ts", "main/ptyHostWorker": "src/main/services/pty/ptyHostWorker.ts", + "main/usageLedgerWorker": "src/main/services/usage/usageLedgerWorker.ts", "main/packagedRuntimeSmoke": "src/main/packagedRuntimeSmoke.ts", "preload/preload": "src/preload/preload.ts" }, From ebabcc439a05c1307517978796907d792a9dd951 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 06:01:34 -0400 Subject: [PATCH 44/53] fix(work): preserve imported CLI launch state --- .../services/sync/syncRemoteCommandService.ts | 44 ++++ .../externalSessions/discoverCodex.ts | 171 ++++++++++++++ .../discoverProviders.test.ts | 79 +++++++ .../externalSessions/discoveryUtils.ts | 3 + .../externalSessionsService.test.ts | 45 +++- .../externalSessionsService.ts | 106 ++++++++- .../src/main/services/pty/ptyService.test.ts | 18 +- .../src/main/services/pty/ptyService.ts | 53 ++++- .../services/sessions/sessionService.test.ts | 10 +- .../main/services/sessions/sessionService.ts | 23 +- .../sync/syncRemoteCommandService.test.ts | 14 ++ .../components/lanes/useLaneWorkSessions.ts | 19 +- .../components/terminals/TerminalsPage.tsx | 15 +- .../terminals/WorkViewArea.test.tsx | 133 ++++++++++- .../components/terminals/WorkViewArea.tsx | 221 ++++++++++++++++-- .../components/terminals/cliLaunch.test.ts | 29 +++ apps/desktop/src/shared/cliLaunch.ts | 43 +++- .../src/shared/types/externalSessions.ts | 8 +- apps/desktop/src/shared/types/sessions.ts | 4 + apps/desktop/src/shared/types/sync.ts | 15 +- 20 files changed, 998 insertions(+), 55 deletions(-) diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 571ecd308..b049efb6f 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -1379,6 +1379,30 @@ function parseCliPermissionMode(value: unknown): SyncStartCliSessionArgs["permis return isTrackedCliPermissionMode(mode) ? mode : "default"; } +function parseOptionalCliPermissionMode(value: unknown): SyncSendToSessionArgs["permissionMode"] { + const mode = asTrimmedString(value); + return isTrackedCliPermissionMode(mode) ? mode : undefined; +} + +function parseOptionalCodexApprovalPolicy(value: unknown): SyncSendToSessionArgs["codexApprovalPolicy"] { + const policy = asTrimmedString(value); + return policy === "untrusted" || policy === "on-request" || policy === "on-failure" || policy === "never" + ? policy + : undefined; +} + +function parseOptionalCodexSandbox(value: unknown): SyncSendToSessionArgs["codexSandbox"] { + const sandbox = asTrimmedString(value); + return sandbox === "read-only" || sandbox === "workspace-write" || sandbox === "danger-full-access" + ? sandbox + : undefined; +} + +function parseOptionalCodexConfigSource(value: unknown): SyncSendToSessionArgs["codexConfigSource"] { + const source = asTrimmedString(value); + return source === "flags" || source === "config-toml" ? source : undefined; +} + function parseStartCliSessionArgs(value: Record): SyncStartCliSessionArgs { const laneId = requireString(value.laneId, "work.startCliSession requires laneId."); const provider = parseCliProvider(value.provider); @@ -1435,6 +1459,10 @@ function parseListExternalSessionsArgs(value: Record): SyncList } result.limit = Math.max(1, Math.min(100, Math.floor(value.limit))); } + if (value.sessionId != null) { + if (typeof value.sessionId !== "string") throw new Error("work.listExternalSessions sessionId must be a string."); + result.sessionId = value.sessionId.trim(); + } return result; } @@ -1457,6 +1485,8 @@ function parseImportExternalSessionArgs(value: Record): SyncImp target, mode, ...(asTrimmedString(value.model) ? { model: asTrimmedString(value.model)! } : {}), + ...(asTrimmedString(value.reasoningEffort) ? { reasoningEffort: asTrimmedString(value.reasoningEffort)! } : {}), + ...(typeof value.fastMode === "boolean" ? { fastMode: value.fastMode } : {}), ...(asTrimmedString(value.permissionMode) ? { permissionMode: asTrimmedString(value.permissionMode)! } : {}), }; } @@ -2093,6 +2123,13 @@ function parseSendToSessionArgs(value: Record): SyncSendToSessi text, cols: asOptionalNumber(value.cols), rows: asOptionalNumber(value.rows), + model: asTrimmedString(value.model), + reasoningEffort: asTrimmedString(value.reasoningEffort), + fastMode: asOptionalBoolean(value.fastMode), + permissionMode: parseOptionalCliPermissionMode(value.permissionMode), + codexApprovalPolicy: parseOptionalCodexApprovalPolicy(value.codexApprovalPolicy), + codexSandbox: parseOptionalCodexSandbox(value.codexSandbox), + codexConfigSource: parseOptionalCodexConfigSource(value.codexConfigSource), }; } @@ -3781,6 +3818,13 @@ function registerWorkRemoteCommands({ args, register }: RemoteCommandRegistratio text: parsed.text, ...(parsed.cols != null ? { cols: parsed.cols } : {}), ...(parsed.rows != null ? { rows: parsed.rows } : {}), + ...(parsed.model != null ? { model: parsed.model } : {}), + ...(parsed.reasoningEffort != null ? { reasoningEffort: parsed.reasoningEffort } : {}), + ...(parsed.fastMode != null ? { fastMode: parsed.fastMode } : {}), + ...(parsed.permissionMode != null ? { permissionMode: parsed.permissionMode } : {}), + ...(parsed.codexApprovalPolicy != null ? { codexApprovalPolicy: parsed.codexApprovalPolicy } : {}), + ...(parsed.codexSandbox != null ? { codexSandbox: parsed.codexSandbox } : {}), + ...(parsed.codexConfigSource != null ? { codexConfigSource: parsed.codexConfigSource } : {}), }); return result satisfies SyncSendToSessionResult; }); diff --git a/apps/desktop/src/main/services/externalSessions/discoverCodex.ts b/apps/desktop/src/main/services/externalSessions/discoverCodex.ts index 78f3622b7..ac734071e 100644 --- a/apps/desktop/src/main/services/externalSessions/discoverCodex.ts +++ b/apps/desktop/src/main/services/externalSessions/discoverCodex.ts @@ -23,6 +23,16 @@ import { type ExternalSessionFileCandidate, type ExternalSessionDiscoveryRecord, } from "./discoveryUtils"; +import type { + AgentChatCodexApprovalPolicy, + AgentChatCodexSandbox, + AgentChatPermissionMode, + TerminalResumeLaunchConfig, +} from "../../../shared/types"; + +const CODEX_LAUNCH_BACKWARD_SCAN_CHUNK_BYTES = 256 * 1024; +const CODEX_LAUNCH_BACKWARD_SCAN_MAX_BYTES = 64 * 1024 * 1024; +const CODEX_LAUNCH_BACKWARD_SCAN_MAX_LINE_BYTES = 1024 * 1024; type CodexIndexEntry = { id: string; @@ -203,6 +213,165 @@ function firstCodexUserText(records: unknown[]): string | null { : firstUserTextFromRecords(records); } +function codexApprovalPolicy(payload: Record): AgentChatCodexApprovalPolicy | null { + const value = ( + asString(payload.approval_policy) + ?? asString(payload.approvalPolicy) + ?? "" + ).toLowerCase(); + if (value === "untrusted" || value === "on-request" || value === "on-failure" || value === "never") { + return value; + } + return null; +} + +function codexSandbox(payload: Record): AgentChatCodexSandbox | null { + const sandbox = asRecord(payload.sandbox_policy) ?? asRecord(payload.sandboxPolicy); + const value = ( + asString(sandbox?.type) + ?? asString(payload.sandbox_mode) + ?? asString(payload.sandboxMode) + ?? "" + ).toLowerCase(); + if (value === "read-only" || value === "workspace-write" || value === "danger-full-access") { + return value; + } + return null; +} + +function codexPermissionMode( + approvalPolicy: AgentChatCodexApprovalPolicy | null, + sandbox: AgentChatCodexSandbox | null, +): AgentChatPermissionMode | null { + if (approvalPolicy === "never" && sandbox === "danger-full-access") return "full-auto"; + if (approvalPolicy === "untrusted" && sandbox === "workspace-write") return "edit"; + if (approvalPolicy === "on-request" && sandbox === "workspace-write") return "default"; + if (approvalPolicy === "on-request" && sandbox === "read-only") return "plan"; + return null; +} + +function codexLaunchFromRecords(records: unknown[]): TerminalResumeLaunchConfig | null { + let launch: TerminalResumeLaunchConfig | null = null; + for (const item of records) { + const record = asRecord(item); + if (asString(record?.type)?.toLowerCase() !== "turn_context") continue; + const payload = asRecord(record?.payload); + if (!payload) continue; + const model = asString(payload.model) ?? asString(payload.model_id) ?? asString(payload.modelId); + const reasoningEffort = asString(payload.effort) + ?? asString(payload.reasoning_effort) + ?? asString(payload.reasoningEffort); + const approvalPolicy = codexApprovalPolicy(payload); + const sandbox = codexSandbox(payload); + const permissionMode = codexPermissionMode(approvalPolicy, sandbox); + const serviceTier = (asString(payload.service_tier) ?? asString(payload.serviceTier) ?? "").toLowerCase(); + const next: TerminalResumeLaunchConfig = { + ...(model ? { model } : {}), + ...(reasoningEffort ? { reasoningEffort } : {}), + ...(permissionMode ? { permissionMode } : {}), + ...(approvalPolicy ? { codexApprovalPolicy: approvalPolicy } : {}), + ...(sandbox ? { codexSandbox: sandbox } : {}), + ...(approvalPolicy && sandbox ? { codexConfigSource: "flags" as const } : {}), + ...(serviceTier === "fast" ? { fastMode: true } : {}), + ...(serviceTier === "default" || serviceTier === "standard" ? { fastMode: false } : {}), + ...(serviceTier && serviceTier !== "fast" && serviceTier !== "default" && serviceTier !== "standard" + ? { fastMode: null } + : {}), + }; + if (Object.keys(next).length) launch = { ...(launch ?? {}), ...next }; + } + return launch; +} + +async function latestCodexLaunchFromFile( + filePath: string, + logger: ExternalSessionDiscoveryArgs["logger"], +): Promise { + let handle: fs.promises.FileHandle | null = null; + let launch: TerminalResumeLaunchConfig | null = null; + try { + handle = await fs.promises.open(filePath, "r"); + const stat = await handle.stat(); + let position = stat.size; + let bytesScanned = 0; + let partialLeadingLine = Buffer.alloc(0); + while (position > 0 && bytesScanned < CODEX_LAUNCH_BACKWARD_SCAN_MAX_BYTES) { + const bytesToRead = Math.min( + CODEX_LAUNCH_BACKWARD_SCAN_CHUNK_BYTES, + position, + CODEX_LAUNCH_BACKWARD_SCAN_MAX_BYTES - bytesScanned, + ); + const start = position - bytesToRead; + const chunk = Buffer.allocUnsafe(bytesToRead); + const { bytesRead } = await handle.read(chunk, 0, bytesToRead, start); + if (bytesRead <= 0) break; + const combined = Buffer.concat([chunk.subarray(0, bytesRead), partialLeadingLine]); + let completeStart = 0; + if (start > 0) { + const firstNewline = combined.indexOf(0x0a); + if (firstNewline < 0) { + // turn_context rows are tiny. Do not repeatedly concatenate an + // unbounded tool-output row while walking backwards through a large + // rollout; once its fragment exceeds this cap, discard that row and + // keep searching for the preceding newline/context. + partialLeadingLine = combined.length <= CODEX_LAUNCH_BACKWARD_SCAN_MAX_LINE_BYTES + ? combined + : Buffer.alloc(0); + position = start; + bytesScanned += bytesRead; + continue; + } + partialLeadingLine = Buffer.from(combined.subarray(0, firstNewline)); + completeStart = firstNewline + 1; + } else { + partialLeadingLine = Buffer.alloc(0); + } + const lines = combined.subarray(completeStart).toString("utf8").split(/\r?\n/u); + for (let index = lines.length - 1; index >= 0; index -= 1) { + const line = lines[index]; + if (!line?.includes("turn_context")) continue; + const record = safeParseJson(line); + const olderLaunch = record ? codexLaunchFromRecords([record]) : null; + if (!olderLaunch) continue; + // Records are visited newest-first. Fill fields omitted by a partial + // newest context from the prior context without overwriting newer data. + launch = { ...olderLaunch, ...(launch ?? {}) }; + if ( + launch.model?.trim() + && launch.reasoningEffort?.trim() + && launch.codexApprovalPolicy + && launch.codexSandbox + ) return launch; + } + position = start; + bytesScanned += bytesRead; + } + if (position > 0) { + logger?.warn?.("external_sessions.codex_launch_scan_truncated", { + filePath, + bytesScanned, + fileSize: stat.size, + }); + } + return launch; + } catch { + return launch; + } finally { + await handle?.close().catch(() => {}); + } +} + +async function codexLaunchForFile( + filePath: string, + prefixRecords: unknown[], + exactLookup: boolean, + logger: ExternalSessionDiscoveryArgs["logger"], +): Promise { + const prefixLaunch = codexLaunchFromRecords(prefixRecords); + if (!exactLookup) return prefixLaunch; + return latestCodexLaunchFromFile(filePath, logger); +} + function collectProjectScopedCodexSessionCandidates( root: string, limit: number, @@ -330,6 +499,7 @@ export async function discoverCodexSessions( const indexed = index.get(id); const firstUserText = firstCodexUserText(jsonl); const title = candidate.meta?.title ?? titleFromCodexPayload(payload, indexed); + const launch = await codexLaunchForFile(filePath, jsonl, lookupId != null, args.logger); recordsById.set(id, recordWithFile({ provider: "codex", id, @@ -339,6 +509,7 @@ export async function discoverCodexSessions( createdAt: candidate.meta?.createdAt ?? asEpochMs(payload.timestamp) ?? asEpochMs(first?.timestamp), updatedAt: Math.max(indexed?.updatedAt ?? 0, candidate.mtimeMs), messageCount: countJsonlUserMessagesCheap(filePath, "codex"), + launch, filePath, sourceMtimeMs: candidate.mtimeMs, })); diff --git a/apps/desktop/src/main/services/externalSessions/discoverProviders.test.ts b/apps/desktop/src/main/services/externalSessions/discoverProviders.test.ts index 74630b7b9..4b8fb3c4d 100644 --- a/apps/desktop/src/main/services/externalSessions/discoverProviders.test.ts +++ b/apps/desktop/src/main/services/externalSessions/discoverProviders.test.ts @@ -196,6 +196,17 @@ describe("external session provider discovery", () => { type: "session_meta", payload: { id, session_id: id, cwd, timestamp: "2026-07-06T10:00:00.000Z", source: "cli", originator: "codex-tui" }, }, + { + timestamp: "2026-07-06T10:00:10.000Z", + type: "turn_context", + payload: { + model: "gpt-5.6-sol", + effort: "max", + service_tier: "fast", + approval_policy: "never", + sandbox_policy: { type: "danger-full-access" }, + }, + }, { timestamp: "2026-07-06T10:00:30.000Z", type: "response_item", payload: { type: "message", role: "user", content: [{ type: "input_text", text: "synthetic" }] } }, { timestamp: "2026-07-06T10:01:00.000Z", type: "event_msg", payload: { type: "user_message", message: "please fix flakes" } }, ]); @@ -212,6 +223,74 @@ describe("external session provider discovery", () => { preview: "please fix flakes", updatedAt: Date.parse("2026-07-06T11:00:00.000Z"), messageCount: 1, + launch: { + model: "gpt-5.6-sol", + reasoningEffort: "max", + fastMode: true, + permissionMode: "full-auto", + codexApprovalPolicy: "never", + codexSandbox: "danger-full-access", + codexConfigSource: "flags", + }, + }); + }); + + it("finds and merges the latest Codex turn context beyond a 2 MiB tail", async () => { + const homeDir = path.join(root, "home"); + const cwd = path.join(root, "repo"); + const id = "24242424-2424-4242-8242-242424242424"; + const prefixFiller = Array.from({ length: 90 }, (_, index) => ({ + type: "event_msg", + payload: { type: "agent_message", message: `prefix-filler-${index}` }, + })); + const filler = [{ + type: "event_msg", + payload: { type: "agent_message", message: `oversized-filler-${"x".repeat(3 * 1024 * 1024)}` }, + }]; + writeJsonl(path.join(homeDir, ".codex", "sessions", "2026", "07", "06", `rollout-${id}.jsonl`), [ + { + type: "session_meta", + payload: { id, cwd, source: "cli", originator: "codex-tui" }, + }, + { + type: "turn_context", + payload: { + model: "gpt-5.4", + effort: "low", + service_tier: "default", + approval_policy: "on-request", + sandbox_policy: { type: "read-only" }, + }, + }, + ...prefixFiller, + { + type: "turn_context", + payload: { + model: "gpt-5.6-sol", + service_tier: "priority", + }, + }, + ...filler, + ]); + + const [broad] = await discoverCodexSessions({ homeDir, limit: 10 }); + expect(broad?.launch).toMatchObject({ + model: "gpt-5.4", + reasoningEffort: "low", + permissionMode: "plan", + codexApprovalPolicy: "on-request", + codexSandbox: "read-only", + }); + + const [exact] = await discoverCodexSessions({ homeDir, sessionId: id, limit: 1 }); + expect(exact?.launch).toEqual({ + model: "gpt-5.6-sol", + reasoningEffort: "low", + fastMode: null, + permissionMode: "plan", + codexApprovalPolicy: "on-request", + codexSandbox: "read-only", + codexConfigSource: "flags", }); }); diff --git a/apps/desktop/src/main/services/externalSessions/discoveryUtils.ts b/apps/desktop/src/main/services/externalSessions/discoveryUtils.ts index b0181dfd2..6fe7f39ca 100644 --- a/apps/desktop/src/main/services/externalSessions/discoveryUtils.ts +++ b/apps/desktop/src/main/services/externalSessions/discoveryUtils.ts @@ -5,6 +5,7 @@ import type { ExternalSessionProvider, ExternalSessionSummary, } from "../../../shared/types/externalSessions"; +import type { TerminalResumeLaunchConfig } from "../../../shared/types/sessions"; export type ExternalSessionDiscoveryRecord = Omit< ExternalSessionSummary, @@ -462,6 +463,7 @@ export function recordWithFile(args: { createdAt?: number | null; updatedAt?: number | null; messageCount?: number | null; + launch?: TerminalResumeLaunchConfig | null; filePath?: string | null; sourceMtimeMs?: number | null; }): ExternalSessionDiscoveryRecord { @@ -478,6 +480,7 @@ export function recordWithFile(args: { createdAt: args.createdAt ?? null, updatedAt: args.updatedAt ?? sourceMtimeMs, messageCount: args.messageCount ?? null, + launch: args.launch ?? null, sourcePath: args.filePath ?? null, sourceMtimeMs, }; diff --git a/apps/desktop/src/main/services/externalSessions/externalSessionsService.test.ts b/apps/desktop/src/main/services/externalSessions/externalSessionsService.test.ts index 55a3203a6..684ec4eb4 100644 --- a/apps/desktop/src/main/services/externalSessions/externalSessionsService.test.ts +++ b/apps/desktop/src/main/services/externalSessions/externalSessionsService.test.ts @@ -63,6 +63,27 @@ afterEach(() => { }); describe("externalSessionsService", () => { + it("rejects unsafe exact lookup ids before provider path resolution", async () => { + const service = createExternalSessionsService({ + projectRoot: path.join(root, "repo"), + homeDir: path.join(root, "home"), + laneService: {}, + sessionService: { list: () => [], listClaudeSessionPointers: () => [] }, + ptyService: { create: vi.fn() }, + logger: makeLogger(), + }); + const statSync = vi.spyOn(fs, "statSync"); + try { + await expect(service.list({ providers: ["cursor", "droid"], scope: "all", sessionId: "../../outside" })) + .resolves.toEqual([]); + await expect(service.list({ providers: ["codex"], scope: "all", sessionId: "not-a-uuid" })) + .resolves.toEqual([]); + expect(statSync).not.toHaveBeenCalled(); + } finally { + statSync.mockRestore(); + } + }); + it("lists sessions with imported flags, active flags, capabilities, and lane cwd matching", async () => { const homeDir = path.join(root, "home"); const projectRoot = path.join(root, "repo"); @@ -560,6 +581,16 @@ describe("externalSessionsService", () => { type: "session_meta", payload: { id, cwd: path.join(root, "elsewhere"), timestamp: "2026-07-06T10:00:00.000Z" }, }, + { + type: "turn_context", + payload: { + model: "gpt-5.6-sol", + effort: "max", + service_tier: "fast", + approval_policy: "on-request", + sandbox_policy: { type: "danger-full-access" }, + }, + }, ]); const create = vi.fn(async (_args: PtyCreateArgs) => ({ sessionId: "terminal-1", ptyId: "pty-1", pid: 123 })); const service = createExternalSessionsService({ @@ -578,7 +609,6 @@ describe("externalSessionsService", () => { laneId: "lane-1", target: "cli", mode: "resume", - permissionMode: "edit", }); expect(result).toEqual({ kind: "cli", sessionId: "terminal-1", ptyId: "pty-1", laneId: "lane-1" }); @@ -588,11 +618,24 @@ describe("externalSessionsService", () => { expect(args.allowExternalCwd).toBe(false); expect(args.startupCommand).toContain("codex --no-alt-screen"); expect(args.startupCommand).toContain(`resume ${id}`); + expect(args.startupCommand).toContain("--model gpt-5.6-sol"); + expect(args.startupCommand).toContain("model_reasoning_effort"); + expect(args.startupCommand).toContain("service_tier"); + expect(args.startupCommand).toContain("--sandbox danger-full-access --ask-for-approval on-request"); + expect(args.startupCommand).not.toContain("dangerously-bypass"); expect(args.resumeMetadata).toMatchObject({ provider: "codex", targetKind: "thread", targetId: id, importedFrom: { provider: "codex", targetId: id, mode: "resume" }, + launch: { + model: "gpt-5.6-sol", + reasoningEffort: "max", + fastMode: true, + codexApprovalPolicy: "on-request", + codexSandbox: "danger-full-access", + codexConfigSource: "flags", + }, }); }); diff --git a/apps/desktop/src/main/services/externalSessions/externalSessionsService.ts b/apps/desktop/src/main/services/externalSessions/externalSessionsService.ts index a1ad55204..3afc6ae0c 100644 --- a/apps/desktop/src/main/services/externalSessions/externalSessionsService.ts +++ b/apps/desktop/src/main/services/externalSessions/externalSessionsService.ts @@ -320,11 +320,21 @@ function metadataForImport(args: { originalTargetId: string; mode: "resume" | "fork"; model?: string | null; + reasoningEffort?: string | null; + fastMode?: boolean | null; permissionMode?: string | null; + codexApprovalPolicy?: TerminalResumeMetadata["launch"]["codexApprovalPolicy"]; + codexSandbox?: TerminalResumeMetadata["launch"]["codexSandbox"]; + codexConfigSource?: TerminalResumeMetadata["launch"]["codexConfigSource"]; }): TerminalResumeMetadata { const launch = { ...(args.model ? { model: args.model } : {}), + ...(args.reasoningEffort ? { reasoningEffort: args.reasoningEffort } : {}), + ...(typeof args.fastMode === "boolean" ? { fastMode: args.fastMode } : {}), ...(args.permissionMode ? { permissionMode: args.permissionMode as TerminalResumeMetadata["launch"]["permissionMode"] } : {}), + ...(args.codexApprovalPolicy ? { codexApprovalPolicy: args.codexApprovalPolicy } : {}), + ...(args.codexSandbox ? { codexSandbox: args.codexSandbox } : {}), + ...(args.codexConfigSource ? { codexConfigSource: args.codexConfigSource } : {}), }; return { provider: args.provider, @@ -345,7 +355,12 @@ async function forkCommandFor(args: { metadata: TerminalResumeMetadata; targetId: string; model?: string | null; + reasoningEffort?: string | null; + fastMode?: boolean | null; permissionMode?: string | null; + codexApprovalPolicy?: TerminalResumeMetadata["launch"]["codexApprovalPolicy"]; + codexSandbox?: TerminalResumeMetadata["launch"]["codexSandbox"]; + codexConfigSource?: TerminalResumeMetadata["launch"]["codexConfigSource"]; transplantedClaude: boolean; }): Promise { if (args.provider === "claude") { @@ -353,7 +368,12 @@ async function forkCommandFor(args: { { ...args.metadata, targetId: args.targetId }, { model: args.model, + reasoningEffort: args.reasoningEffort, + fastMode: args.fastMode, permissionMode: args.permissionMode as TerminalResumeMetadata["launch"]["permissionMode"], + codexApprovalPolicy: args.codexApprovalPolicy, + codexSandbox: args.codexSandbox, + codexConfigSource: args.codexConfigSource, }, ); return args.transplantedClaude ? command : `${command} --fork-session`; @@ -364,7 +384,12 @@ async function forkCommandFor(args: { { ...args.metadata, targetId: args.targetId }, { model: args.model, + reasoningEffort: args.reasoningEffort, + fastMode: args.fastMode, permissionMode: args.permissionMode as TerminalResumeMetadata["launch"]["permissionMode"], + codexApprovalPolicy: args.codexApprovalPolicy, + codexSandbox: args.codexSandbox, + codexConfigSource: args.codexConfigSource, codexComputerUse: await resolveCodexComputerUseMcpConfig(), }, ); @@ -380,7 +405,12 @@ async function forkCommandFor(args: { { ...args.metadata, targetId: args.targetId }, { model: args.model, + reasoningEffort: args.reasoningEffort, + fastMode: args.fastMode, permissionMode: args.permissionMode as TerminalResumeMetadata["launch"]["permissionMode"], + codexApprovalPolicy: args.codexApprovalPolicy, + codexSandbox: args.codexSandbox, + codexConfigSource: args.codexConfigSource, }, ); return `${resume} --fork`; @@ -467,12 +497,28 @@ export function createExternalSessionsService(args: ExternalSessionsServiceArgs) }; const list = async (rawArgs: ExternalSessionListArgs = {}): Promise => { - const limit = normalizeExternalSessionLimit(rawArgs.limit); + const hasRequestedSessionId = rawArgs.sessionId != null; + const requestedSessionId = rawArgs.sessionId?.trim() || null; + if (hasRequestedSessionId && !requestedSessionId) return []; + const limit = requestedSessionId ? 1 : normalizeExternalSessionLimit(rawArgs.limit); const projectScoped = rawArgs.scope !== "all"; - const discoveryLimit = projectScoped - ? Math.max(limit, PROJECT_SCOPE_DISCOVERY_LIMIT) - : limit; - const providers = providerSet(rawArgs.providers); + const discoveryLimit = requestedSessionId + ? 1 + : projectScoped + ? Math.max(limit, PROJECT_SCOPE_DISCOVERY_LIMIT) + : limit; + const requestedProviders = providerSet(rawArgs.providers); + const providers = requestedSessionId + ? requestedProviders.filter((provider) => { + try { + validateExternalSessionId(provider, requestedSessionId); + return true; + } catch { + return false; + } + }) + : requestedProviders; + if (providers.length === 0) return []; const requestedLaneCwd = rawArgs.laneId ? resolveLaneCwd(args.laneService, rawArgs.laneId) : null; const requestedCwd = rawArgs.cwd?.trim() ? realish(rawArgs.cwd) : requestedLaneCwd; const scopeRoots = projectScoped ? deriveProjectScopeRoots(args.projectRoot) : []; @@ -482,6 +528,7 @@ export function createExternalSessionsService(args: ExternalSessionsServiceArgs) cwd: requestedCwd, projectRoot: args.projectRoot, limit: discoveryLimit, + sessionId: requestedSessionId, scopeRoots: projectScoped ? scopeRoots : null, logger: args.logger, }; @@ -520,6 +567,7 @@ export function createExternalSessionsService(args: ExternalSessionsServiceArgs) createdAt: session.createdAt, updatedAt: session.updatedAt, messageCount: session.messageCount, + launch: session.launch ?? null, alreadyImported: importedRef != null, importedSessionRef: importedRef, possiblyActive: typeof session.sourceMtimeMs === "number" && session.sourceMtimeMs >= activeCutoffMs, @@ -564,6 +612,7 @@ export function createExternalSessionsService(args: ExternalSessionsServiceArgs) createdAt: session.createdAt, updatedAt: session.updatedAt, messageCount: session.messageCount, + launch: session.launch ?? null, alreadyImported: false, importedSessionRef: null, possiblyActive: typeof session.sourceMtimeMs === "number" @@ -690,26 +739,61 @@ export function createExternalSessionsService(args: ExternalSessionsServiceArgs) } } + const resolvedModel = importArgs.model?.trim() || summary.launch?.model?.trim() || null; + const resolvedReasoningEffort = importArgs.reasoningEffort?.trim() + || summary.launch?.reasoningEffort?.trim() + || null; + const resolvedPermissionMode = importArgs.permissionMode?.trim() + || summary.launch?.permissionMode?.trim() + || null; + const resolvedFastMode = typeof importArgs.fastMode === "boolean" + ? importArgs.fastMode + : summary.launch?.fastMode ?? summary.launch?.codexFastMode ?? null; + const preserveDiscoveredCodexPermissions = provider === "codex" && importArgs.permissionMode == null; + const resolvedCodexApprovalPolicy = preserveDiscoveredCodexPermissions + ? summary.launch?.codexApprovalPolicy ?? null + : null; + const resolvedCodexSandbox = preserveDiscoveredCodexPermissions + ? summary.launch?.codexSandbox ?? null + : null; + const resolvedCodexConfigSource = preserveDiscoveredCodexPermissions + ? summary.launch?.codexConfigSource ?? null + : null; const metadata = metadataForImport({ provider, targetId: metadataTargetId, originalTargetId: sessionId, mode: importArgs.mode, - model: importArgs.model, - permissionMode: importArgs.permissionMode, + model: resolvedModel, + reasoningEffort: resolvedReasoningEffort, + fastMode: resolvedFastMode, + permissionMode: resolvedPermissionMode, + codexApprovalPolicy: resolvedCodexApprovalPolicy, + codexSandbox: resolvedCodexSandbox, + codexConfigSource: resolvedCodexConfigSource, }); const startupCommand = importArgs.mode === "resume" ? buildTrackedCliResumeCommand(metadata, { - model: importArgs.model, - permissionMode: importArgs.permissionMode as TerminalResumeMetadata["launch"]["permissionMode"], + model: resolvedModel, + reasoningEffort: resolvedReasoningEffort, + fastMode: resolvedFastMode, + permissionMode: resolvedPermissionMode as TerminalResumeMetadata["launch"]["permissionMode"], + codexApprovalPolicy: resolvedCodexApprovalPolicy, + codexSandbox: resolvedCodexSandbox, + codexConfigSource: resolvedCodexConfigSource, ...(provider === "codex" ? { codexComputerUse: await resolveCodexComputerUseMcpConfig() } : {}), }) : await forkCommandFor({ provider, metadata, targetId: launchTargetId, - model: importArgs.model, - permissionMode: importArgs.permissionMode, + model: resolvedModel, + reasoningEffort: resolvedReasoningEffort, + fastMode: resolvedFastMode, + permissionMode: resolvedPermissionMode, + codexApprovalPolicy: resolvedCodexApprovalPolicy, + codexSandbox: resolvedCodexSandbox, + codexConfigSource: resolvedCodexConfigSource, transplantedClaude, }); diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index bb737b43f..9ab623641 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -406,6 +406,7 @@ function createHarness(overrides: { ...(args.title !== undefined ? { title: args.title } : {}), ...(args.goal !== undefined ? { goal: args.goal } : {}), ...(args.manuallyNamed !== undefined ? { manuallyNamed: args.manuallyNamed } : {}), + ...(args.resumeMetadata !== undefined ? { resumeMetadata: args.resumeMetadata } : {}), }); return session; }), @@ -2791,7 +2792,11 @@ describe("ptyService", () => { rows: 40, model: "gpt-5.4", reasoningEffort: "high", - permissionMode: "plan", + fastMode: true, + permissionMode: "full-auto", + codexApprovalPolicy: "on-request", + codexSandbox: "danger-full-access", + codexConfigSource: "flags", }); await Promise.resolve(); const result = await pending; @@ -2809,9 +2814,18 @@ describe("ptyService", () => { const spawn = (loadPty.mock.results[0]?.value as any).spawn; expect(spawn).toHaveBeenCalledWith( "/bin/bash", - ["--noprofile", "--norc", "-lc", "codex --no-alt-screen --model gpt-5.4 -c \"model_reasoning_effort=\\\"high\\\"\" --sandbox read-only --ask-for-approval on-request resume thread-ended \"fix failing tests\""], + ["--noprofile", "--norc", "-lc", "codex --no-alt-screen --model gpt-5.4 -c \"model_reasoning_effort=\\\"high\\\"\" -c \"service_tier=\\\"fast\\\"\" -c features.fast_mode=true --sandbox danger-full-access --ask-for-approval on-request resume thread-ended \"fix failing tests\""], expect.any(Object), ); + expect(sessionService.get("session-ended-send")?.resumeMetadata?.launch).toMatchObject({ + model: "gpt-5.4", + reasoningEffort: "high", + fastMode: true, + permissionMode: "full-auto", + codexApprovalPolicy: "on-request", + codexSandbox: "danger-full-access", + codexConfigSource: "flags", + }); expect(mockPty.write).not.toHaveBeenCalled(); }); diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 60ce51422..c19b5fb6d 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -3967,7 +3967,16 @@ export function createPtyService({ }; const resumeLaunchOverrides = ( - args: Pick, + args: Pick< + PtySendToSessionArgs, + | "model" + | "reasoningEffort" + | "fastMode" + | "permissionMode" + | "codexApprovalPolicy" + | "codexSandbox" + | "codexConfigSource" + >, ) => ({ model: typeof args.model === "string" && args.model.trim().length ? args.model.trim() @@ -3975,9 +3984,24 @@ export function createPtyService({ reasoningEffort: typeof args.reasoningEffort === "string" && args.reasoningEffort.trim().length ? args.reasoningEffort.trim() : undefined, + fastMode: typeof args.fastMode === "boolean" ? args.fastMode : undefined, permissionMode: typeof args.permissionMode === "string" && args.permissionMode.trim().length ? args.permissionMode : undefined, + codexApprovalPolicy: args.codexApprovalPolicy === "untrusted" + || args.codexApprovalPolicy === "on-request" + || args.codexApprovalPolicy === "on-failure" + || args.codexApprovalPolicy === "never" + ? args.codexApprovalPolicy + : undefined, + codexSandbox: args.codexSandbox === "read-only" + || args.codexSandbox === "workspace-write" + || args.codexSandbox === "danger-full-access" + ? args.codexSandbox + : undefined, + codexConfigSource: args.codexConfigSource === "flags" || args.codexConfigSource === "config-toml" + ? args.codexConfigSource + : undefined, }); const buildResumeCommandForSession = ( @@ -4942,8 +4966,31 @@ export function createPtyService({ ); } - const { session: resumableSession, provider } = await resolveEndedResumeSession(sessionId, session); + const resolvedResume = await resolveEndedResumeSession(sessionId, session); + let resumableSession = resolvedResume.session; + const provider = resolvedResume.provider; const overrides = resumeLaunchOverrides(args); + const launchOverridePatch = { + ...(overrides.model !== undefined ? { model: overrides.model } : {}), + ...(overrides.reasoningEffort !== undefined ? { reasoningEffort: overrides.reasoningEffort } : {}), + ...(overrides.fastMode !== undefined ? { fastMode: overrides.fastMode } : {}), + ...(overrides.permissionMode !== undefined ? { permissionMode: overrides.permissionMode } : {}), + ...(overrides.codexApprovalPolicy !== undefined ? { codexApprovalPolicy: overrides.codexApprovalPolicy } : {}), + ...(overrides.codexSandbox !== undefined ? { codexSandbox: overrides.codexSandbox } : {}), + ...(overrides.codexConfigSource !== undefined ? { codexConfigSource: overrides.codexConfigSource } : {}), + }; + if (resumableSession.resumeMetadata && Object.keys(launchOverridePatch).length > 0) { + resumableSession = sessionService.updateMeta({ + sessionId, + resumeMetadata: { + ...resumableSession.resumeMetadata, + launch: { + ...resumableSession.resumeMetadata.launch, + ...launchOverridePatch, + }, + }, + }) ?? resumableSession; + } const launchMetadata = resumableSession.resumeMetadata?.launch; const openCodeReplayCommand = provider === "opencode" && resumableSession.resumeMetadata?.provider === "opencode" @@ -4953,7 +5000,7 @@ export function createPtyService({ targetId: sanitizeResumeTargetId(resumableSession.resumeMetadata.targetId ?? null), model: overrides.model ?? launchMetadata?.model ?? null, reasoningEffort: overrides.reasoningEffort ?? launchMetadata?.reasoningEffort ?? null, - fastMode: launchMetadata?.fastMode ?? launchMetadata?.codexFastMode ?? null, + fastMode: overrides.fastMode ?? launchMetadata?.fastMode ?? launchMetadata?.codexFastMode ?? null, prompt: text, }) : null; diff --git a/apps/desktop/src/main/services/sessions/sessionService.test.ts b/apps/desktop/src/main/services/sessions/sessionService.test.ts index 714753e49..dc6c17068 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.test.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.test.ts @@ -223,6 +223,7 @@ describe("sessionService resume metadata", () => { permissionMode: "edit", model: "gpt-5.4", reasoningEffort: "medium", + fastMode: true, codexApprovalPolicy: "untrusted", codexSandbox: "workspace-write", codexConfigSource: "flags", @@ -232,7 +233,7 @@ describe("sessionService resume metadata", () => { const created = service.get("session-2"); expect(created?.resumeCommand).toBe( - "codex --no-alt-screen --model gpt-5.4 -c \"model_reasoning_effort=\\\"medium\\\"\" --sandbox workspace-write --ask-for-approval untrusted resume", + "codex --no-alt-screen --model gpt-5.4 -c \"model_reasoning_effort=\\\"medium\\\"\" -c \"service_tier=\\\"fast\\\"\" -c features.fast_mode=true --sandbox workspace-write --ask-for-approval untrusted resume", ); service.setResumeCommand("session-2", "codex resume thread-1"); @@ -246,13 +247,14 @@ describe("sessionService resume metadata", () => { permissionMode: "edit", model: "gpt-5.4", reasoningEffort: "medium", + fastMode: true, codexApprovalPolicy: "untrusted", codexSandbox: "workspace-write", codexConfigSource: "flags", }, }); expect(resumed?.resumeCommand).toBe( - "codex --no-alt-screen --model gpt-5.4 -c \"model_reasoning_effort=\\\"medium\\\"\" --sandbox workspace-write --ask-for-approval untrusted resume thread-1", + "codex --no-alt-screen --model gpt-5.4 -c \"model_reasoning_effort=\\\"medium\\\"\" -c \"service_tier=\\\"fast\\\"\" -c features.fast_mode=true --sandbox workspace-write --ask-for-approval untrusted resume thread-1", ); activeDisposers.push(async () => db.close()); @@ -301,7 +303,9 @@ describe("sessionService resume metadata", () => { codexConfigSource: "flags", }, }); - expect(resumed?.resumeCommand).toBe("codex --no-alt-screen --dangerously-bypass-approvals-and-sandbox resume thread-full-auto"); + expect(resumed?.resumeCommand).toBe( + "codex --no-alt-screen --sandbox danger-full-access --ask-for-approval never resume thread-full-auto", + ); activeDisposers.push(async () => db.close()); }); diff --git a/apps/desktop/src/main/services/sessions/sessionService.ts b/apps/desktop/src/main/services/sessions/sessionService.ts index f025cf5ea..f7b7f10c8 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.ts @@ -137,9 +137,25 @@ function normalizeResumeMetadata(raw: unknown): TerminalResumeMetadata | null { const reasoningEffort = typeof launchRecord.reasoningEffort === "string" && launchRecord.reasoningEffort.trim().length ? launchRecord.reasoningEffort.trim() : null; - const codexApprovalPolicy = typeof launchRecord.codexApprovalPolicy === "string" ? launchRecord.codexApprovalPolicy : null; - const codexSandbox = typeof launchRecord.codexSandbox === "string" ? launchRecord.codexSandbox : null; - const codexConfigSource = typeof launchRecord.codexConfigSource === "string" ? launchRecord.codexConfigSource : null; + const fastMode = typeof launchRecord.fastMode === "boolean" + ? launchRecord.fastMode + : typeof launchRecord.codexFastMode === "boolean" + ? launchRecord.codexFastMode + : null; + const codexApprovalPolicy = launchRecord.codexApprovalPolicy === "untrusted" + || launchRecord.codexApprovalPolicy === "on-request" + || launchRecord.codexApprovalPolicy === "on-failure" + || launchRecord.codexApprovalPolicy === "never" + ? launchRecord.codexApprovalPolicy + : null; + const codexSandbox = launchRecord.codexSandbox === "read-only" + || launchRecord.codexSandbox === "workspace-write" + || launchRecord.codexSandbox === "danger-full-access" + ? launchRecord.codexSandbox + : null; + const codexConfigSource = launchRecord.codexConfigSource === "flags" || launchRecord.codexConfigSource === "config-toml" + ? launchRecord.codexConfigSource + : null; const importedFromRecord = record.importedFrom != null && typeof record.importedFrom === "object" && !Array.isArray(record.importedFrom) ? record.importedFrom as Record : null; @@ -168,6 +184,7 @@ function normalizeResumeMetadata(raw: unknown): TerminalResumeMetadata | null { ...(permissionMode ? { permissionMode } : {}), ...(model ? { model } : {}), ...(reasoningEffort ? { reasoningEffort } : {}), + ...(fastMode !== null ? { fastMode } : {}), ...(claudePermissionMode ? { claudePermissionMode: claudePermissionMode as TerminalResumeMetadata["launch"]["claudePermissionMode"] } : {}), ...(codexApprovalPolicy ? { codexApprovalPolicy: codexApprovalPolicy as TerminalResumeMetadata["launch"]["codexApprovalPolicy"] } : {}), ...(codexSandbox ? { codexSandbox: codexSandbox as TerminalResumeMetadata["launch"]["codexSandbox"] } : {}), diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts index c58632f24..7960078fc 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts @@ -2335,12 +2335,26 @@ describe("createSyncRemoteCommandService", () => { text: "continue here", cols: 999, rows: 999, + model: "gpt-5.6-sol", + reasoningEffort: "max", + fastMode: true, + permissionMode: "full-auto", + codexApprovalPolicy: "on-request", + codexSandbox: "danger-full-access", + codexConfigSource: "flags", })); expect(ptyService.sendToSession).toHaveBeenCalledWith({ sessionId: "pty-existing", text: "continue here", cols: 999, rows: 999, + model: "gpt-5.6-sol", + reasoningEffort: "max", + fastMode: true, + permissionMode: "full-auto", + codexApprovalPolicy: "on-request", + codexSandbox: "danger-full-access", + codexConfigSource: "flags", }); expect(result).toMatchObject({ sessionId: "pty-1", diff --git a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts index dcb08ae2a..43a2c4fd9 100644 --- a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts +++ b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { AgentChatSession, TerminalSessionSummary } from "../../../shared/types"; +import type { AgentChatSession, TerminalResumeLaunchConfig, TerminalSessionSummary } from "../../../shared/types"; import { selectActiveProjectRoot, useAppStore, useAppStoreApi, type WorkDraftKind, type WorkProjectViewState } from "../../state/appStore"; import { listSessionsCached, invalidateSessionListCache } from "../../lib/sessionListCache"; import { sessionStatusBucket } from "../../lib/terminalAttention"; @@ -781,12 +781,27 @@ export function useLaneWorkSessions(laneId: string | null) { void refresh({ showLoading: false, force: true }); }, [focusSession, laneId, openSessionTab, refresh, selectLane, upsertOptimisticChatSession]); - const continueCliSession = useCallback(async (session: TerminalSessionSummary, text: string) => { + const continueCliSession = useCallback(async ( + session: TerminalSessionSummary, + text: string, + launch: TerminalResumeLaunchConfig | null = null, + ) => { const sendArgs = { sessionId: session.id, text, cols: 100, rows: 30, + ...(launch?.model?.trim() ? { model: launch.model.trim() } : {}), + ...(launch?.reasoningEffort?.trim() ? { reasoningEffort: launch.reasoningEffort.trim() } : {}), + ...(typeof launch?.fastMode === "boolean" + ? { fastMode: launch.fastMode } + : typeof launch?.codexFastMode === "boolean" + ? { fastMode: launch.codexFastMode } + : {}), + ...(launch?.permissionMode ? { permissionMode: launch.permissionMode } : {}), + ...(launch?.codexApprovalPolicy ? { codexApprovalPolicy: launch.codexApprovalPolicy } : {}), + ...(launch?.codexSandbox ? { codexSandbox: launch.codexSandbox } : {}), + ...(launch?.codexConfigSource ? { codexConfigSource: launch.codexConfigSource } : {}), }; const pin = workPtyLaunchPinFor(session); const result = pin diff --git a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx index ea44a9394..07adb7d0c 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx @@ -8,7 +8,7 @@ import { WorkSidebar, type WorkSidebarContextTarget } from "./WorkSidebar"; import { SessionContextMenu, type SessionContextMenuState } from "./SessionContextMenu"; import { SessionInfoPopover, type InfoPopoverState } from "./SessionInfoPopover"; import { ConfirmDialog, useConfirmDialog } from "../shared/InlineDialogs"; -import type { AgentChatSession, TerminalSessionSummary } from "../../../shared/types"; +import type { AgentChatSession, TerminalResumeLaunchConfig, TerminalSessionSummary } from "../../../shared/types"; import { buildDeeplink } from "../../../shared/deeplinks"; import { parseGithubRemoteUrl } from "../../../shared/githubRemote"; import { buildWebClientUrl } from "../../../shared/webClientUrl"; @@ -569,7 +569,7 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { }, [selectedSessions, stopAndDeleteConfirm, work]); const handleContinueCliSession = useCallback( - async (session: TerminalSessionSummary, text: string) => { + async (session: TerminalSessionSummary, text: string, launch: TerminalResumeLaunchConfig | null) => { setSessionActionError(null); try { const result = await window.ade.pty.sendToSession({ @@ -577,6 +577,17 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { text, cols: 100, rows: 30, + ...(launch?.model?.trim() ? { model: launch.model.trim() } : {}), + ...(launch?.reasoningEffort?.trim() ? { reasoningEffort: launch.reasoningEffort.trim() } : {}), + ...(typeof launch?.fastMode === "boolean" + ? { fastMode: launch.fastMode } + : typeof launch?.codexFastMode === "boolean" + ? { fastMode: launch.codexFastMode } + : {}), + ...(launch?.permissionMode ? { permissionMode: launch.permissionMode } : {}), + ...(launch?.codexApprovalPolicy ? { codexApprovalPolicy: launch.codexApprovalPolicy } : {}), + ...(launch?.codexSandbox ? { codexSandbox: launch.codexSandbox } : {}), + ...(launch?.codexConfigSource ? { codexConfigSource: launch.codexConfigSource } : {}), }); invalidateSessionListCache(); // Patch the local sessions list with the freshly-resumed snapshot so diff --git a/apps/desktop/src/renderer/components/terminals/WorkViewArea.test.tsx b/apps/desktop/src/renderer/components/terminals/WorkViewArea.test.tsx index 892028607..185632e07 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkViewArea.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkViewArea.test.tsx @@ -190,6 +190,7 @@ const modelsMock = vi.fn(); const sendToSessionMock = vi.fn(); const resumeSessionMock = vi.fn(); const resourceUsageMock = vi.fn(); +const externalSessionsListMock = vi.fn(); const resolvePtyLaunch = async () => ({ sessionId: "test-session", ptyId: "test-pty", pid: null }); beforeEach(() => { @@ -239,6 +240,8 @@ beforeEach(() => { freeMemoryMB: 12_000, totalMemoryMB: 16_000, }); + externalSessionsListMock.mockReset(); + externalSessionsListMock.mockResolvedValue([]); Object.defineProperty(window, "ade", { configurable: true, value: { @@ -254,6 +257,9 @@ beforeEach(() => { resumeSession: resumeSessionMock, sendToSession: sendToSessionMock, }, + externalSessions: { + list: externalSessionsListMock, + }, terminal: { preview: terminalPreviewMock, }, @@ -1050,12 +1056,16 @@ describe("WorkViewArea", () => { fireEvent.change(textarea, { target: { value: "fix the test" } }); fireEvent.keyDown(textarea, { key: "Enter" }); - await waitFor(() => expect(onContinue).toHaveBeenCalledWith(session, "fix the test")); + await waitFor(() => expect(onContinue).toHaveBeenCalledWith( + session, + "fix the test", + { permissionMode: "plan" }, + )); expect((window.ade as any).app.writeClipboardText).toHaveBeenCalledWith("fix the test"); expect((textarea as HTMLTextAreaElement).value).toBe(""); }); - it("does not show resume-time model or permission controls", async () => { + it("shows saved resume state without presenting misleading editable controls", async () => { const session = { ...makeSession(), toolType: "codex" as const, @@ -1064,7 +1074,11 @@ describe("WorkViewArea", () => { provider: "codex" as const, targetKind: "thread" as const, targetId: "thread-1", - launch: { permissionMode: "plan" as const }, + launch: { + model: "gpt-5.4", + reasoningEffort: "high", + permissionMode: "plan" as const, + }, }, }; const view = render( @@ -1085,10 +1099,123 @@ describe("WorkViewArea", () => { const local = within(view.container); expect(await local.findByLabelText("Continue Codex session")).toBeTruthy(); + expect(local.getByText("GPT-5.4")).toBeTruthy(); + expect(local.getByText("high")).toBeTruthy(); + expect(local.getByText("Plan")).toBeTruthy(); expect(local.queryByRole("button", { name: /Select model/i })).toBeNull(); expect(local.queryByLabelText("Codex permission mode")).toBeNull(); }); + it("recovers imported Codex launch state once and uses it for continuation", async () => { + const onContinue = vi.fn().mockResolvedValue(undefined); + externalSessionsListMock.mockResolvedValue([{ + provider: "codex", + id: "019f8135-cd9d-7ba1-8f4f-f594d76d8273", + cwd: "/tmp/lane-1", + title: "Imported Codex", + preview: null, + createdAt: null, + updatedAt: null, + messageCount: null, + launch: { + model: "gpt-5.6-sol", + reasoningEffort: "max", + fastMode: true, + permissionMode: "full-auto", + codexApprovalPolicy: "never", + codexSandbox: "danger-full-access", + codexConfigSource: "flags", + }, + alreadyImported: true, + importedSessionRef: { kind: "cli", sessionId: "session-1" }, + possiblyActive: false, + cwdMatchesRequestedLane: true, + capabilities: { + resumeInPlace: true, + resumeInDifferentCwd: true, + fork: true, + forkIntoDifferentCwd: true, + importToChat: true, + }, + }]); + const session = { + ...makeSession(), + toolType: "codex" as const, + resumeCommand: "codex resume 019f8135-cd9d-7ba1-8f4f-f594d76d8273", + resumeMetadata: { + provider: "codex" as const, + targetKind: "thread" as const, + targetId: "019f8135-cd9d-7ba1-8f4f-f594d76d8273", + launch: {}, + importedFrom: { + provider: "codex" as const, + targetId: "019f8135-cd9d-7ba1-8f4f-f594d76d8273", + mode: "resume" as const, + }, + }, + }; + const view = render( + {}} + onCloseItem={() => {}} + onOpenChatSession={() => {}} + onLaunchPtySession={resolvePtyLaunch} + onShowDraftKind={() => {}} + closingPtyIds={new Set()} + onContinueCliSession={onContinue} + />, + ); + const local = within(view.container); + + expect(await local.findByText("GPT-5.6 Sol")).toBeTruthy(); + expect(local.getByText("max")).toBeTruthy(); + expect(local.getByText("Fast")).toBeTruthy(); + expect(local.getByText("Full access")).toBeTruthy(); + expect(externalSessionsListMock).toHaveBeenCalledTimes(1); + expect(externalSessionsListMock).toHaveBeenCalledWith({ + providers: ["codex"], + scope: "all", + sessionId: "019f8135-cd9d-7ba1-8f4f-f594d76d8273", + limit: 1, + }); + + view.rerender( + {}} + onCloseItem={() => {}} + onOpenChatSession={() => {}} + onLaunchPtySession={resolvePtyLaunch} + onShowDraftKind={() => {}} + closingPtyIds={new Set()} + onContinueCliSession={onContinue} + />, + ); + expect(externalSessionsListMock).toHaveBeenCalledTimes(1); + + const textarea = local.getByLabelText("Continue Codex session"); + fireEvent.change(textarea, { target: { value: "continue" } }); + fireEvent.keyDown(textarea, { key: "Enter" }); + await waitFor(() => expect(onContinue).toHaveBeenCalledWith(session, "continue", { + model: "gpt-5.6-sol", + reasoningEffort: "max", + fastMode: true, + permissionMode: "full-auto", + codexApprovalPolicy: "never", + codexSandbox: "danger-full-access", + codexConfigSource: "flags", + })); + }); + it("shows provider-specific slash command suggestions in the continuation composer", async () => { slashCommandsMock.mockResolvedValue([ { name: "/status", description: "Show status", source: "sdk" }, diff --git a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx index d60ae1449..a97414757 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx @@ -14,6 +14,7 @@ import type { LaneLinearIssue, LaneSummary, TerminalResumeProvider, + TerminalResumeLaunchConfig, TerminalSessionSummary, TerminalSnapshotCell, TerminalSnapshotRow, @@ -305,21 +306,134 @@ function continuationProviderLabel(provider: TerminalResumeProvider | null): str return "agent CLI"; } +const CONTINUATION_LAUNCH_LOOKUP_TTL_MS = 60_000; +const CONTINUATION_LAUNCH_LOOKUP_MAX_ENTRIES = 100; + +type ContinuationLaunchLookup = { + promise: Promise; + expiresAt: number; +}; + +const continuationLaunchLookups = new Map(); + +function mergeContinuationLaunch( + recovered: TerminalResumeLaunchConfig | null, + stored: TerminalResumeLaunchConfig | null, +): TerminalResumeLaunchConfig | null { + if (!recovered) return stored; + if (!stored) return recovered; + const storedCoarsePermission = stored.permissionMode ?? null; + return { + ...recovered, + ...stored, + model: stored.model?.trim() || recovered.model?.trim() || null, + reasoningEffort: stored.reasoningEffort?.trim() || recovered.reasoningEffort?.trim() || null, + permissionMode: stored.permissionMode ?? recovered.permissionMode ?? null, + fastMode: stored.fastMode ?? stored.codexFastMode ?? recovered.fastMode ?? recovered.codexFastMode ?? null, + codexApprovalPolicy: stored.codexApprovalPolicy + ?? (storedCoarsePermission ? null : recovered.codexApprovalPolicy) + ?? null, + codexSandbox: stored.codexSandbox + ?? (storedCoarsePermission ? null : recovered.codexSandbox) + ?? null, + codexConfigSource: stored.codexConfigSource + ?? (storedCoarsePermission ? null : recovered.codexConfigSource) + ?? null, + }; +} + +function recoverImportedContinuationLaunch( + provider: TerminalResumeProvider | null, + importedProvider: TerminalResumeProvider | null, + targetId: string, +): Promise | null { + // Codex rollouts persist a turn_context record with the launch state. Other + // providers currently do not expose an equivalent bounded exact lookup. + if (provider !== "codex" || importedProvider !== provider || !targetId) return null; + const key = `${provider}:${targetId}`; + const now = Date.now(); + const existing = continuationLaunchLookups.get(key); + if (existing && existing.expiresAt > now) { + continuationLaunchLookups.delete(key); + continuationLaunchLookups.set(key, existing); + return existing.promise; + } + if (existing) continuationLaunchLookups.delete(key); + const request = window.ade.externalSessions.list({ + providers: [provider], + scope: "all", + sessionId: targetId, + limit: 1, + }).then((sessions) => sessions.find((candidate) => candidate.id === targetId)?.launch ?? null) + .catch((error) => { + continuationLaunchLookups.delete(key); + throw error; + }); + continuationLaunchLookups.set(key, { + promise: request, + expiresAt: now + CONTINUATION_LAUNCH_LOOKUP_TTL_MS, + }); + while (continuationLaunchLookups.size > CONTINUATION_LAUNCH_LOOKUP_MAX_ENTRIES) { + const oldestKey = continuationLaunchLookups.keys().next().value as string | undefined; + if (!oldestKey) break; + continuationLaunchLookups.delete(oldestKey); + } + return request; +} + +function continuationPermissionLabel(launch: TerminalResumeLaunchConfig | null): string | null { + if (launch?.codexApprovalPolicy === "on-request") return "Ask first"; + if (launch?.codexApprovalPolicy === "on-failure") return "On failure"; + if (launch?.codexApprovalPolicy === "untrusted") return "Restricted"; + if (launch?.codexApprovalPolicy === "never" && launch.codexSandbox === "danger-full-access") return "Full access"; + const mode = launch?.permissionMode; + if (mode === "full-auto") return "Full access"; + if (mode === "plan") return "Plan"; + if (mode === "edit") return "Edit"; + if (mode === "auto") return "Auto"; + if (mode === "config-toml") return "Config"; + if (mode === "default") return "Default"; + return null; +} + function WorkCliContinuationComposer({ session, onContinue, }: { session: TerminalSessionSummary; - onContinue?: (session: TerminalSessionSummary, text: string) => Promise | void; + onContinue?: ( + session: TerminalSessionSummary, + text: string, + launch: TerminalResumeLaunchConfig | null, + ) => Promise | void; }) { const provider = continuationProviderForSession(session); const providerLabel = continuationProviderLabel(provider); // Mirror the active chat composer's model pill: resolve the model the session was // launched with (recorded on its resume metadata) so we show the same glyph + name. - const modelId = session.resumeMetadata?.launch?.model?.trim() || null; + const storedLaunch = session.resumeMetadata?.launch ?? null; + const importedProvider = session.resumeMetadata?.importedFrom?.provider ?? null; + const importedTargetId = session.resumeMetadata?.importedFrom?.targetId?.trim() || ""; + const storedLaunchFingerprint = JSON.stringify([ + storedLaunch?.model ?? null, + storedLaunch?.reasoningEffort ?? null, + storedLaunch?.fastMode ?? null, + storedLaunch?.codexFastMode ?? null, + storedLaunch?.permissionMode ?? null, + storedLaunch?.codexApprovalPolicy ?? null, + storedLaunch?.codexSandbox ?? null, + storedLaunch?.codexConfigSource ?? null, + ]); + const storedLaunchRef = useRef(storedLaunch); + storedLaunchRef.current = storedLaunch; + const recoveryIdentity = `${session.id}:${provider ?? ""}:${importedProvider ?? ""}:${importedTargetId}:${storedLaunchFingerprint}`; + const appliedRecoveryIdentityRef = useRef(null); + const [resolvedLaunch, setResolvedLaunch] = useState(storedLaunch); + const modelId = resolvedLaunch?.model?.trim() || null; const modelDescriptor = modelId ? (resolveModelDescriptorWithRuntimeCatalog(modelId) ?? createUnknownModelPlaceholder(modelId)) : null; + const permissionLabel = continuationPermissionLabel(resolvedLaunch); const textareaRef = useRef(null); const commandMenuRef = useRef(null); const [draft, setDraft] = useState(""); @@ -330,6 +444,40 @@ function WorkCliContinuationComposer({ const [submitError, setSubmitError] = useState(null); const launchPromptClipboardEnabled = useAppStore((s) => s.launchPromptClipboardEnabled); + useEffect(() => { + let cancelled = false; + const currentStoredLaunch = storedLaunchRef.current; + if (appliedRecoveryIdentityRef.current !== recoveryIdentity) { + appliedRecoveryIdentityRef.current = recoveryIdentity; + setResolvedLaunch(currentStoredLaunch); + } + // Historical imports often stored only one launch field (or an empty + // object), so the presence of a permission or fast-mode value must not + // prevent recovery of the model and reasoning effort. + if (provider !== "codex" || ( + currentStoredLaunch?.model?.trim() + && currentStoredLaunch?.reasoningEffort?.trim() + && (currentStoredLaunch?.permissionMode || ( + currentStoredLaunch?.codexApprovalPolicy && currentStoredLaunch?.codexSandbox + )) + )) return () => { + cancelled = true; + }; + const request = recoverImportedContinuationLaunch(provider, importedProvider, importedTargetId); + if (!request) return () => { + cancelled = true; + }; + void request.then((launch) => { + if (!cancelled && launch) setResolvedLaunch(mergeContinuationLaunch(launch, currentStoredLaunch)); + }).catch(() => { + // The native provider transcript may have moved or been compressed. + // Continuing still uses the durable stored resume command. + }); + return () => { + cancelled = true; + }; + }, [importedProvider, importedTargetId, provider, recoveryIdentity]); + useEffect(() => { let cancelled = false; setSlashCommands([]); @@ -395,7 +543,7 @@ function WorkCliContinuationComposer({ if (launchPromptClipboardEnabled) { void copyLaunchPromptToClipboard(text); } - await onContinue?.(session, text); + await onContinue?.(session, text, resolvedLaunch); setDraft(""); setCommandMenuTrigger(null); } catch (err) { @@ -403,7 +551,7 @@ function WorkCliContinuationComposer({ } finally { setSending(false); } - }, [draft, launchPromptClipboardEnabled, onContinue, sending, session]); + }, [draft, launchPromptClipboardEnabled, onContinue, resolvedLaunch, sending, session]); // Auto-grow from a single-line height (matches the active chat composer): start // thin and expand with the draft, capped so the transcript above keeps the room. @@ -421,21 +569,38 @@ function WorkCliContinuationComposer({ className="mx-auto w-full max-w-[var(--chat-column,46rem)]" footer={(
- {modelDescriptor ? ( - - - {modelDescriptor.displayName} - - ) : ( - {providerLabel} - )} +
+ {modelDescriptor ? ( + + + {modelDescriptor.displayName} + + ) : ( + {providerLabel} + )} + {resolvedLaunch?.reasoningEffort ? ( + + {resolvedLaunch.reasoningEffort} + + ) : null} + {(resolvedLaunch?.fastMode ?? resolvedLaunch?.codexFastMode) ? ( + + Fast + + ) : null} + {permissionLabel ? ( + + {permissionLabel} + + ) : null} +