diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index 01efe84c14..d645ff6833 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -49,7 +49,7 @@ A failed catch-up or subscription reconcile retries on its own, doubling from 1s A fixed 1s retry turned a persistent daemon-side refusal — a Codex thread that already has an active writer, say — into a request and a log line every second on an idle app. The delay resets on success, reconnect, delivery-mode change, and visibility change, so recovery is still immediate once the -condition clears. +condition clears. Republishing the same visibility set is a no-op and never bypasses backoff. Background retries are silent. The retry the user presses in the sync-error callout is a fallible user action and owns its pending state: `retrying` is a status the sync model publishes, not a React diff --git a/packages/app/src/screens/workspace/visible-agent-ids.ts b/packages/app/src/screens/workspace/visible-agent-ids.ts index b9e9e86623..6fdd246a9b 100644 --- a/packages/app/src/screens/workspace/visible-agent-ids.ts +++ b/packages/app/src/screens/workspace/visible-agent-ids.ts @@ -1,3 +1,4 @@ +import { useRef } from "react"; import { collectAllPanes, type WorkspaceLayout } from "@/stores/workspace-layout-store"; import type { WorkspaceTab } from "@/workspace-tabs/model"; import { deriveWorkspacePaneState } from "./workspace-pane-state"; @@ -25,3 +26,20 @@ export function selectVisibleAgentIds(input: { ), ].sort(); } + +export function useVisibleAgentIds(input: { + layout: WorkspaceLayout | null; + tabs: WorkspaceTab[]; + routeFocused: boolean; + focusedPaneOnly: boolean; +}): string[] { + const nextAgentIds = selectVisibleAgentIds(input); + const stableAgentIds = useRef([]); + if ( + stableAgentIds.current.length !== nextAgentIds.length || + stableAgentIds.current.some((agentId, index) => agentId !== nextAgentIds[index]) + ) { + stableAgentIds.current = nextAgentIds; + } + return stableAgentIds.current; +} diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 003827f23d..d83c28240b 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -84,7 +84,7 @@ import type { } from "@/keyboard/keyboard-action-dispatcher"; import { useCreateFlowStore } from "@/stores/create-flow-store"; import { normalizeWorkspaceTabTarget, workspaceTabTargetsEqual } from "@/workspace-tabs/identity"; -import { selectVisibleAgentIds } from "./visible-agent-ids"; +import { useVisibleAgentIds } from "./visible-agent-ids"; import { getHostRuntimeStore, useHostRuntimeClient, @@ -1845,16 +1845,16 @@ function WorkspaceScreenContent({ const viewedTimelineSync = useSessionStore( (state) => state.sessions[normalizedServerId]?.viewedTimelineSync ?? null, ); - const visibleAgentIds = useMemo( - () => - selectVisibleAgentIds({ - layout: workspaceLayout, - tabs: uiTabs, - routeFocused: isRouteFocused, - focusedPaneOnly: isMobile || isFocusModeEnabled || !supportsDesktopPaneSplits(), - }), - [isFocusModeEnabled, isMobile, isRouteFocused, uiTabs, workspaceLayout], + const syncFocusedPaneOnly = useMemo( + () => isMobile || isFocusModeEnabled || !supportsDesktopPaneSplits(), + [isFocusModeEnabled, isMobile], ); + const visibleAgentIds = useVisibleAgentIds({ + layout: workspaceLayout, + tabs: uiTabs, + routeFocused: isRouteFocused, + focusedPaneOnly: syncFocusedPaneOnly, + }); useLayoutEffect(() => { if (!persistenceKey || !viewedTimelineSync) { return; diff --git a/packages/app/src/timeline/viewed-timeline-sync.test.ts b/packages/app/src/timeline/viewed-timeline-sync.test.ts index f158178e14..2b189e7faf 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.test.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.test.ts @@ -464,6 +464,24 @@ test("manual retries can immediately re-attempt a failed catch-up", async () => await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); }); +test("redeclaring unchanged visibility does not bypass catch-up backoff", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const membership = await world.nextMembership(); + membership.succeed(); + + const failed = await world.nextFetch("agent-a"); + failed.fail("timeline unavailable"); + await world.nextRetry(); + + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + + world.expectNoPendingMembership(); + world.expectNoPendingFetch(); + expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error"); +}); + test("a manual retry that fails returns to the error state", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); @@ -586,6 +604,22 @@ test("membership failures retry with exponential backoff", async () => { await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); }); +test("redeclaring unchanged visibility does not bypass membership backoff", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + + const failed = await world.nextMembership(); + failed.fail("subscription unavailable"); + await world.nextRetry(); + + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + + world.expectNoPendingMembership(); + world.expectNoPendingFetch(); + expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error"); +}); + test("backgrounding preserves the hot membership without catch-up on return", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); diff --git a/packages/app/src/timeline/viewed-timeline-sync.ts b/packages/app/src/timeline/viewed-timeline-sync.ts index 110ce0acee..cf90918c11 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.ts @@ -365,12 +365,6 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed } }; - const retryFailedCatchUps = () => { - for (const agentId of acknowledged) { - if (catchUps.get(agentId)?.status === "error") startCatchUp(agentId); - } - }; - const retryVisibleAgentTimeline = (agentId: string) => { if (!isDesired(agentId) || manualRetries.has(agentId)) return; const catchUp = catchUps.get(agentId); @@ -407,8 +401,6 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed } if (sameAgentIds(nextDesired, desired)) { if (statusChanged) notifyListeners(); - if (deliveryMode === "selective" && membershipNeedsRetry) void reconcileMembership(); - retryFailedCatchUps(); return; }