From 4094eeb566630ca218a0ab4701319e7add95d7d2 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 23 Aug 2026 13:04:14 +0200 Subject: [PATCH] fix(app): preserve timeline retry backoff (#3736) Equivalent visibility publications were acting as implicit retry commands, so unrelated tab and layout changes could restart failed timeline work. Keep visibility identity stable and reserve immediate retries for explicit user action. --- docs/timeline-sync.md | 2 +- .../screens/workspace/visible-agent-ids.ts | 18 ++++++++++ .../screens/workspace/workspace-screen.tsx | 20 +++++------ .../src/timeline/viewed-timeline-sync.test.ts | 34 +++++++++++++++++++ .../app/src/timeline/viewed-timeline-sync.ts | 8 ----- 5 files changed, 63 insertions(+), 19 deletions(-) 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; }