fix(app): back off and offer retry when timeline sync is refused

A host can refuse a timeline read for as long as it likes. Codex does
this whenever another writer holds the thread: every catch-up fails with
"already has an active writer" until the other session lets go. The app
retried that on a fixed 1s timer with no ceiling, so an idle window sat
there issuing a request and logging a warning every second, indefinitely.
The refusal is normal lifecycle; the traffic was the bug.

Catch-up and subscription retries now double from 1s to a 30s ceiling,
resetting on success, reconnect, delivery-mode change, and visibility
change so recovery stays immediate once the condition clears.

Backing off alone would leave the user waiting up to 30s with no way in,
so both failure surfaces get a retry they can press. Background retries
stay silent; a retry the user asked for is a fallible user action and
owns its pending state, so "retrying" is a status the sync model
publishes rather than a React boolean.

The blocking "Failed to load agent" screen was the worse half: its load
effect self-gates on the error state, so a refused first resume was
terminal until the user navigated away. Its retry resets the state to
idle, which re-arms the effect.
This commit is contained in:
Mohamed Boudra
2026-08-18 14:36:27 +02:00
parent 05ee7a1d31
commit 0f2e58d4bb
17 changed files with 461 additions and 51 deletions
+95 -24
View File
@@ -1,3 +1,4 @@
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { TFunction } from "i18next";
@@ -23,7 +24,6 @@ import { AgentStreamView, type AgentStreamViewHandle } from "@/agent-stream/view
import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { SidebarCallout } from "@/components/sidebar-callout";
import { Composer } from "@/composer";
import { resolveComposerTrackTailClearance } from "@/composer/pill-styles";
import { getActiveMessageSubmissions } from "@/composer/submission/model";
@@ -37,7 +37,11 @@ import {
} from "@/components/toast-host";
import type { WorkspaceComposerAttachment } from "@/attachments/types";
import { useWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
import { COMPACT_FORM_FACTOR_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import {
COMPACT_FORM_FACTOR_WIDTH,
MAX_CONTENT_WIDTH,
useIsCompactFormFactor,
} from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
@@ -197,12 +201,30 @@ function buildChatAgentFromState(
};
}
function AgentLoadErrorView({ message, onRetry }: { message: string; onRetry: () => void }) {
const { t } = useTranslation();
return (
<View style={styles.container} testID="agent-load-error">
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{t("agentPanel.states.failedToLoad")}</Text>
<Text style={styles.statusText}>{message}</Text>
<View style={styles.errorAction}>
<Button size="sm" variant="secondary" onPress={onRetry} testID="agent-load-error-retry">
{t("common.actions.retry")}
</Button>
</View>
</View>
</View>
);
}
function renderChatAgentNonReadyView(args: {
viewState: AgentScreenViewState;
effectiveAgent: AgentScreenAgent | null;
t: TFunction;
onRetryLoad: () => void;
}): React.ReactElement | null {
const { viewState, effectiveAgent, t } = args;
const { viewState, effectiveAgent, t, onRetryLoad } = args;
if (viewState.tag === "not_found") {
return (
<View style={styles.container} testID="agent-not-found">
@@ -213,14 +235,7 @@ function renderChatAgentNonReadyView(args: {
);
}
if (viewState.tag === "error") {
return (
<View style={styles.container} testID="agent-load-error">
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{t("agentPanel.states.failedToLoad")}</Text>
<Text style={styles.statusText}>{viewState.message}</Text>
</View>
</View>
);
return <AgentLoadErrorView message={viewState.message} onRetry={onRetryLoad} />;
}
if (viewState.tag === "boot" || !effectiveAgent) {
return (
@@ -603,6 +618,7 @@ function AgentPanelBody({
);
const [lookupState, setLookupState] = useState<AgentLookupState>({ tag: "idle" });
const lookupAttemptTokenRef = useRef(0);
const retryAgentLookup = useCallback(() => setLookupState({ tag: "idle" }), []);
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
const resolvePendingAgent = useWorkspaceLayoutStore((state) => state.resolvePendingAgent);
@@ -695,14 +711,7 @@ function AgentPanelBody({
}
if (lookupState.tag === "error") {
return (
<View style={styles.container} testID="agent-load-error">
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{t("agentPanel.states.failedToLoad")}</Text>
<Text style={styles.statusText}>{lookupState.message}</Text>
</View>
</View>
);
return <AgentLoadErrorView message={lookupState.message} onRetry={retryAgentLookup} />;
}
const agent: AgentScreenAgent | null =
@@ -1029,6 +1038,13 @@ function ChatAgentContent({
streamViewRef.current?.scrollToBottom("message-sent");
}, [agentId]);
const retryAgentLoad = useCallback(() => setMissingAgentState({ kind: "idle" }), []);
const retryTimelineSync = useCallback(() => {
if (!agentId || !viewedTimelineSync) return;
viewedTimelineSync.retryVisibleAgentTimeline(agentId);
}, [agentId, viewedTimelineSync]);
useEffect(() => {
initAttemptTokenRef.current += 1;
setMissingAgentState({ kind: "idle" });
@@ -1127,6 +1143,7 @@ function ChatAgentContent({
viewState,
effectiveAgent,
t,
onRetryLoad: retryAgentLoad,
});
if (nonReadyView) return nonReadyView;
invariant(agentId, "agent id is defined when agent content is ready");
@@ -1138,6 +1155,10 @@ function ChatAgentContent({
viewState.sync.status === "catching_up" &&
viewState.sync.ui === "overlay";
const showHistorySyncError = viewState.tag === "ready" && viewState.sync.status === "sync_error";
const isRetryingHistorySync =
viewState.tag === "ready" &&
viewState.sync.status === "sync_error" &&
viewState.sync.isRetrying;
return (
<ChatAgentReadyContent
@@ -1158,7 +1179,9 @@ function ChatAgentContent({
handleMessageSent={handleMessageSent}
showHistorySyncOverlay={showHistorySyncOverlay}
showHistorySyncError={showHistorySyncError}
isRetryingHistorySync={isRetryingHistorySync}
cwd={agentCwd}
retryTimelineSync={retryTimelineSync}
onAttentionInputFocus={attentionController.clearOnInputFocus}
onAttentionPromptSend={attentionController.clearOnPromptSend}
onOpenWorkspaceFile={onOpenWorkspaceFile}
@@ -1184,6 +1207,8 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
handleMessageSent,
showHistorySyncOverlay,
showHistorySyncError,
isRetryingHistorySync,
retryTimelineSync,
cwd,
onAttentionInputFocus,
onAttentionPromptSend,
@@ -1206,6 +1231,8 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
handleMessageSent: () => void;
showHistorySyncOverlay: boolean;
showHistorySyncError: boolean;
isRetryingHistorySync: boolean;
retryTimelineSync: () => void;
cwd: string;
onAttentionInputFocus: () => void;
onAttentionPromptSend: () => void;
@@ -1333,11 +1360,26 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
{contentContainer}
{showHistorySyncError ? (
<SidebarCallout
title={t("agentPanel.states.timelineSyncFailed")}
variant="error"
testID="agent-timeline-sync-error"
/>
<View style={styles.timelineSyncCalloutRail}>
<View style={styles.timelineSyncCalloutContent}>
<View style={styles.timelineSyncCallout} testID="agent-timeline-sync-error">
<Text style={styles.timelineSyncCalloutText}>
{t("agentPanel.states.timelineSyncFailed")}
</Text>
<Button
size="sm"
variant="secondary"
onPress={retryTimelineSync}
disabled={isRetryingHistorySync}
testID="agent-timeline-sync-retry"
>
{isRetryingHistorySync
? t("agentPanel.states.timelineSyncRetrying")
: t("common.actions.retry")}
</Button>
</View>
</View>
</View>
) : null}
{composerSection}
@@ -1745,6 +1787,32 @@ const styles = StyleSheet.create((theme) => ({
overflow: "hidden",
...(isWeb ? { userSelect: "none" as const } : {}),
},
timelineSyncCalloutRail: {
width: "100%",
alignItems: "center",
paddingHorizontal: theme.spacing[4],
paddingTop: theme.spacing[2],
},
timelineSyncCalloutContent: {
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
},
timelineSyncCallout: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.statusDanger,
borderRadius: theme.borderRadius["2xl"],
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[4],
},
timelineSyncCalloutText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
historySyncOverlay: {
position: "absolute",
top: 0,
@@ -1807,6 +1875,9 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
textAlign: "center",
},
errorAction: {
marginTop: theme.spacing[4],
},
statusText: {
marginTop: theme.spacing[2],
textAlign: "center",