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:
@@ -42,6 +42,16 @@ Initialization timeouts guard lack of catch-up progress, not the full multi-page
|
||||
Opening or resuming an agent fetches one bounded latest tail page. Older history remains
|
||||
user-driven by scrolling upward.
|
||||
|
||||
A failed catch-up or subscription reconcile retries on its own, doubling from 1s to a 30s ceiling.
|
||||
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.
|
||||
|
||||
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
|
||||
boolean, so the button reports in-flight and the callout returns to `error` when the attempt fails.
|
||||
|
||||
Reaching the history-start threshold loads one older page and preserves the visible content anchor.
|
||||
Cursor progress does not trigger another page. The user must leave and return to the threshold unless
|
||||
the anchored page still leaves the viewport at history start, as with short or compacted content; in
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import {
|
||||
expectTimelinePromptVisible,
|
||||
openAgentTimeline,
|
||||
seedLongMockAgentTimeline,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
import {
|
||||
disconnectViewedTimeline,
|
||||
restoreViewedTimelineWithHeldResponse,
|
||||
} from "../support/helpers/timeline-resume";
|
||||
|
||||
test.describe("Agent timeline sync retry", () => {
|
||||
test("a refused catch-up offers a retry that reports pending, failure, and recovery", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 2 });
|
||||
try {
|
||||
await openAgentTimeline(page, agent);
|
||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||
|
||||
gate.failTimelineResponses(agent.agentId);
|
||||
await disconnectViewedTimeline(page, gate);
|
||||
await restoreViewedTimelineWithHeldResponse(page, gate);
|
||||
|
||||
const callout = page.getByTestId("agent-timeline-sync-error");
|
||||
const retry = page.getByTestId("agent-timeline-sync-retry");
|
||||
await expect(callout).toBeVisible({ timeout: 30_000 });
|
||||
await expect(retry).toBeEnabled();
|
||||
await page.screenshot({ path: testInfo.outputPath("1-sync-error.png"), fullPage: true });
|
||||
|
||||
gate.holdTimelineResponses(agent.agentId);
|
||||
await retry.click();
|
||||
await gate.waitForHeldTimelineResponse();
|
||||
await expect(retry).toBeDisabled();
|
||||
await expect(retry).toHaveText("Retrying…");
|
||||
await page.screenshot({ path: testInfo.outputPath("2-retry-pending.png"), fullPage: true });
|
||||
|
||||
gate.releaseHeldTimelineResponses();
|
||||
await expect(retry).toBeEnabled({ timeout: 30_000 });
|
||||
await expect(callout).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("3-retry-failed.png"), fullPage: true });
|
||||
|
||||
gate.allowTimelineResponses();
|
||||
await retry.click();
|
||||
await expect(callout).toBeHidden({ timeout: 30_000 });
|
||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||
await page.screenshot({ path: testInfo.outputPath("4-recovered.png"), fullPage: true });
|
||||
} finally {
|
||||
gate.restore();
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a refused first load offers a retry instead of a dead panel", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 2 });
|
||||
try {
|
||||
gate.failTimelineResponses(agent.agentId);
|
||||
await openAgentTimeline(page, agent);
|
||||
|
||||
const loadError = page.getByTestId("agent-load-error");
|
||||
const retry = page.getByTestId("agent-load-error-retry");
|
||||
await expect(loadError).toBeVisible({ timeout: 60_000 });
|
||||
await expect(loadError).toContainText("already has an active writer");
|
||||
await page.screenshot({ path: testInfo.outputPath("5-load-error.png"), fullPage: true });
|
||||
|
||||
await retry.click();
|
||||
await expect(loadError).toBeVisible({ timeout: 30_000 });
|
||||
await page.screenshot({ path: testInfo.outputPath("6-load-retry-failed.png") });
|
||||
|
||||
gate.allowTimelineResponses();
|
||||
await retry.click();
|
||||
await expect(loadError).toBeHidden({ timeout: 30_000 });
|
||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||
await page.screenshot({ path: testInfo.outputPath("7-load-recovered.png"), fullPage: true });
|
||||
} finally {
|
||||
gate.restore();
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -133,6 +133,23 @@ function forceTimelineReset(message: string | Buffer, enabled: boolean): string
|
||||
return JSON.stringify(envelope);
|
||||
}
|
||||
|
||||
// The daemon refusal this reproduces: a Codex thread that already has an active writer.
|
||||
const TIMELINE_WRITER_CONFLICT_ERROR =
|
||||
"Failed to resume Codex thread playwright-thread: thread playwright-thread already has an active writer";
|
||||
|
||||
function failTimelineResponse(message: string | Buffer, agentId: string | null): string | Buffer {
|
||||
if (!agentId || typeof message !== "string") return message;
|
||||
const envelope = JSON.parse(message) as {
|
||||
message?: { payload?: Record<string, unknown> };
|
||||
payload?: Record<string, unknown>;
|
||||
};
|
||||
const payload = envelope.message?.payload ?? envelope.payload;
|
||||
if (!payload || payload.agentId !== agentId) return message;
|
||||
payload.error = TIMELINE_WRITER_CONFLICT_ERROR;
|
||||
payload.entries = [];
|
||||
return JSON.stringify(envelope);
|
||||
}
|
||||
|
||||
function rewriteShellToolCommand(
|
||||
message: string | Buffer,
|
||||
command: string | null,
|
||||
@@ -284,6 +301,10 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
let stripAssistantMessageIds = false;
|
||||
let stripCanonicalSubmittedPromptsFeature = false;
|
||||
let shellToolCommandOverride: string | null = null;
|
||||
let failingTimelineAgentId: string | null = null;
|
||||
let holdingTimelineAgentId: string | null = null;
|
||||
const heldTimelineResponses: Array<() => void> = [];
|
||||
const heldTimelineResponseWaiters = new Set<() => void>();
|
||||
let heldClientRequestType: string | null = null;
|
||||
let heldClientRequest: { server: WebSocketRoute; message: string | Buffer } | null = null;
|
||||
let resolveHeldClientRequest: (() => void) | null = null;
|
||||
@@ -487,11 +508,25 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
stripCanonicalSubmittedPromptsFeature,
|
||||
serverMessage?.type,
|
||||
);
|
||||
const isTimelineResponse = serverMessage?.type === "fetch_agent_timeline_response";
|
||||
if (isTimelineResponse) {
|
||||
outboundMessage = failTimelineResponse(outboundMessage, failingTimelineAgentId);
|
||||
}
|
||||
const shouldForceTimelineReset =
|
||||
forceTimelineEpochReset && serverMessage?.type === "fetch_agent_timeline_response";
|
||||
outboundMessage = forceTimelineReset(outboundMessage, shouldForceTimelineReset);
|
||||
if (shouldForceTimelineReset) forceTimelineEpochReset = false;
|
||||
recordServerMessage(serverMessage);
|
||||
if (isTimelineResponse && holdingTimelineAgentId) {
|
||||
const payload = (serverMessage as { payload?: { agentId?: unknown } } | null)?.payload;
|
||||
if (payload?.agentId === holdingTimelineAgentId) {
|
||||
const forward = outboundMessage;
|
||||
heldTimelineResponses.push(() => ws.send(forward));
|
||||
for (const resolve of heldTimelineResponseWaiters) resolve();
|
||||
heldTimelineResponseWaiters.clear();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (holdServerMessage({ browser: ws, message: outboundMessage, parsed: serverMessage }))
|
||||
return;
|
||||
if (holdReadyFileUpdate(ws, outboundMessage, fileMessage)) return;
|
||||
@@ -560,6 +595,24 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
restore(): void {
|
||||
acceptingConnections = true;
|
||||
},
|
||||
failTimelineResponses(agentId: string): void {
|
||||
failingTimelineAgentId = agentId;
|
||||
},
|
||||
allowTimelineResponses(): void {
|
||||
failingTimelineAgentId = null;
|
||||
},
|
||||
holdTimelineResponses(agentId: string): void {
|
||||
holdingTimelineAgentId = agentId;
|
||||
},
|
||||
async waitForHeldTimelineResponse(): Promise<void> {
|
||||
while (heldTimelineResponses.length === 0) {
|
||||
await new Promise<void>((resolve) => heldTimelineResponseWaiters.add(resolve));
|
||||
}
|
||||
},
|
||||
releaseHeldTimelineResponses(): void {
|
||||
holdingTimelineAgentId = null;
|
||||
for (const forward of heldTimelineResponses.splice(0)) forward();
|
||||
},
|
||||
restoreFresh(): void {
|
||||
reconnectWithFreshClient = true;
|
||||
acceptingConnections = true;
|
||||
|
||||
@@ -317,6 +317,22 @@ describe("deriveAgentScreenViewState", () => {
|
||||
expect(ready.agent.id).toBe("agent-1");
|
||||
});
|
||||
|
||||
it("marks the sync error as retrying while a user-requested retry is in flight", () => {
|
||||
const memory = createBaseMemory({
|
||||
hasRenderedReady: true,
|
||||
lastReadyAgent: createAgent("agent-1"),
|
||||
});
|
||||
const input: AgentScreenMachineInput = {
|
||||
...createBaseInput(),
|
||||
visibilityCatchUpStatus: "retrying",
|
||||
};
|
||||
|
||||
const result = deriveAgentScreenViewState({ input, memory });
|
||||
const ready = expectReadyState(result.state);
|
||||
|
||||
expect(ready.sync).toEqual({ status: "sync_error", isRetrying: true });
|
||||
});
|
||||
|
||||
it("returns blocking error before first paint when refresh fails", () => {
|
||||
const memory = createBaseMemory();
|
||||
const input: AgentScreenMachineInput = {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
AgentFeature,
|
||||
AgentProvider,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
import type { ViewedTimelineStatus } from "@/timeline/viewed-timeline-sync";
|
||||
|
||||
export interface AgentScreenAgent {
|
||||
serverId: string;
|
||||
@@ -47,7 +48,7 @@ export interface AgentScreenMachineInput {
|
||||
isArchivingCurrentAgent: boolean;
|
||||
isHistorySyncing: boolean;
|
||||
needsAuthoritativeSync: boolean;
|
||||
visibilityCatchUpStatus: "ready" | "pending" | "error";
|
||||
visibilityCatchUpStatus: ViewedTimelineStatus;
|
||||
continuity: AgentScreenContinuity;
|
||||
hasHydratedHistoryBefore: boolean;
|
||||
}
|
||||
@@ -82,7 +83,7 @@ export type AgentScreenReadySyncState =
|
||||
status: "catching_up";
|
||||
ui: "overlay" | "silent";
|
||||
}
|
||||
| { status: "sync_error" };
|
||||
| { status: "sync_error"; isRetrying: boolean };
|
||||
|
||||
export type AgentScreenViewState =
|
||||
| {
|
||||
@@ -173,10 +174,10 @@ function resolveAgentScreenSync(args: {
|
||||
return { status: "reconnecting" };
|
||||
}
|
||||
if (input.missingAgentState.kind === "error") {
|
||||
return { status: "sync_error" };
|
||||
return { status: "sync_error", isRetrying: input.visibilityCatchUpStatus === "retrying" };
|
||||
}
|
||||
if (input.visibilityCatchUpStatus === "error") {
|
||||
return { status: "sync_error" };
|
||||
if (input.visibilityCatchUpStatus === "error" || input.visibilityCatchUpStatus === "retrying") {
|
||||
return { status: "sync_error", isRetrying: input.visibilityCatchUpStatus === "retrying" };
|
||||
}
|
||||
if (
|
||||
input.visibilityCatchUpStatus === "pending" ||
|
||||
|
||||
@@ -215,7 +215,8 @@ export const ar: TranslationResources = {
|
||||
notFound: "لم يتم العثور على Agent",
|
||||
failedToLoad: "فشل تحميل الوكيل",
|
||||
reconnecting: "جارٍ إعادة الاتصال",
|
||||
timelineSyncFailed: "تعذر تحديث سجل الوكيل. جارٍ إعادة المحاولة…",
|
||||
timelineSyncFailed: "تعذر تحديث سجل الوكيل.",
|
||||
timelineSyncRetrying: "جارٍ إعادة المحاولة…",
|
||||
archivingTitle: "وكيل الارشيف...",
|
||||
archivingSubtitle: "الرجاء الانتظار بينما نقوم بأرشفة هذا الوكيل.",
|
||||
},
|
||||
|
||||
@@ -212,7 +212,8 @@ export const en = {
|
||||
notFound: "Agent not found",
|
||||
failedToLoad: "Failed to load agent",
|
||||
reconnecting: "Reconnecting",
|
||||
timelineSyncFailed: "Couldn't refresh agent history. Retrying…",
|
||||
timelineSyncFailed: "Couldn't refresh agent history.",
|
||||
timelineSyncRetrying: "Retrying…",
|
||||
archivingTitle: "Archiving agent...",
|
||||
archivingSubtitle: "Please wait while we archive this agent.",
|
||||
},
|
||||
|
||||
@@ -215,7 +215,8 @@ export const es: TranslationResources = {
|
||||
notFound: "Agentno encontrado",
|
||||
failedToLoad: "No se pudo cargar el agente",
|
||||
reconnecting: "Reconectando",
|
||||
timelineSyncFailed: "No se pudo actualizar el historial del agente. Reintentando…",
|
||||
timelineSyncFailed: "No se pudo actualizar el historial del agente.",
|
||||
timelineSyncRetrying: "Reintentando…",
|
||||
archivingTitle: "Agente de archivo...",
|
||||
archivingSubtitle: "Espere mientras archivamos este agente.",
|
||||
},
|
||||
|
||||
@@ -217,7 +217,8 @@ export const fr: TranslationResources = {
|
||||
notFound: "Agentintrouvable",
|
||||
failedToLoad: "Échec du chargement de l'agent",
|
||||
reconnecting: "Reconnexion",
|
||||
timelineSyncFailed: "Impossible d’actualiser l’historique de l’agent. Nouvelle tentative…",
|
||||
timelineSyncFailed: "Impossible d’actualiser l’historique de l’agent.",
|
||||
timelineSyncRetrying: "Nouvelle tentative…",
|
||||
archivingTitle: "Agent d'archivage...",
|
||||
archivingSubtitle: "Veuillez patienter pendant que nous archivons cet agent.",
|
||||
},
|
||||
|
||||
@@ -215,7 +215,8 @@ export const ja: TranslationResources = {
|
||||
notFound: "エージェントが見つかりません",
|
||||
failedToLoad: "エージェントの読み込みに失敗しました",
|
||||
reconnecting: "再接続中",
|
||||
timelineSyncFailed: "エージェントの履歴を更新できませんでした。再試行しています…",
|
||||
timelineSyncFailed: "エージェントの履歴を更新できませんでした。",
|
||||
timelineSyncRetrying: "再試行しています…",
|
||||
archivingTitle: "エージェントをアーカイブ中...",
|
||||
archivingSubtitle: "このエージェントをアーカイブするまでお待ちください。",
|
||||
},
|
||||
|
||||
@@ -215,7 +215,8 @@ export const ko: TranslationResources = {
|
||||
notFound: "에이전트를 찾을 수 없습니다",
|
||||
failedToLoad: "에이전트를 불러오지 못했습니다",
|
||||
reconnecting: "다시 연결하는 중",
|
||||
timelineSyncFailed: "에이전트 기록을 새로고침할 수 없습니다. 재시도 중…",
|
||||
timelineSyncFailed: "에이전트 기록을 새로고침할 수 없습니다.",
|
||||
timelineSyncRetrying: "재시도 중…",
|
||||
archivingTitle: "에이전트 보관 중...",
|
||||
archivingSubtitle: "이 에이전트를 보관하는 동안 잠시 기다려 주세요.",
|
||||
},
|
||||
|
||||
@@ -215,7 +215,8 @@ export const ptBR: TranslationResources = {
|
||||
notFound: "Agente não encontrado",
|
||||
failedToLoad: "Falha ao carregar agente",
|
||||
reconnecting: "Reconectando",
|
||||
timelineSyncFailed: "Não foi possível atualizar o histórico do agente. Tentando novamente…",
|
||||
timelineSyncFailed: "Não foi possível atualizar o histórico do agente.",
|
||||
timelineSyncRetrying: "Tentando novamente…",
|
||||
archivingTitle: "Arquivando agente...",
|
||||
archivingSubtitle: "Aguarde enquanto arquivamos este agente.",
|
||||
},
|
||||
|
||||
@@ -215,7 +215,8 @@ export const ru: TranslationResources = {
|
||||
notFound: "Agent не найден",
|
||||
failedToLoad: "Не удалось загрузить агент",
|
||||
reconnecting: "Повторное подключение",
|
||||
timelineSyncFailed: "Не удалось обновить историю агента. Повторная попытка…",
|
||||
timelineSyncFailed: "Не удалось обновить историю агента.",
|
||||
timelineSyncRetrying: "Повторная попытка…",
|
||||
archivingTitle: "Архивный агент...",
|
||||
archivingSubtitle: "Пожалуйста, подождите, пока мы архивируем этого агента.",
|
||||
},
|
||||
|
||||
@@ -215,7 +215,8 @@ export const zhCN: TranslationResources = {
|
||||
notFound: "未找到 Agent",
|
||||
failedToLoad: "加载 Agent 失败",
|
||||
reconnecting: "正在重连",
|
||||
timelineSyncFailed: "无法刷新代理历史记录。正在重试…",
|
||||
timelineSyncFailed: "无法刷新代理历史记录。",
|
||||
timelineSyncRetrying: "正在重试…",
|
||||
archivingTitle: "正在归档 Agent...",
|
||||
archivingSubtitle: "请稍候,我们正在归档这个 Agent。",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -79,10 +79,11 @@ class TimelineWorld {
|
||||
schedule: (task, delayMs) => {
|
||||
const scheduled = { task, delayMs };
|
||||
this.scheduled.push(scheduled);
|
||||
const waiter = this.retryWaiters.shift();
|
||||
if (waiter && delayMs === 1_000) {
|
||||
const waiterIndex = this.retryWaiters.findIndex((waiter) => waiter.delayMs === delayMs);
|
||||
if (waiterIndex >= 0) {
|
||||
const [waiter] = this.retryWaiters.splice(waiterIndex, 1);
|
||||
this.scheduled.splice(this.scheduled.indexOf(scheduled), 1);
|
||||
waiter(task);
|
||||
waiter.resolve(task);
|
||||
}
|
||||
return () => {
|
||||
const index = this.scheduled.indexOf(scheduled);
|
||||
@@ -100,7 +101,10 @@ class TimelineWorld {
|
||||
}> = [];
|
||||
private readonly errorWaiters: Array<(message: string) => void> = [];
|
||||
private readonly scheduled: Array<{ task: () => void; delayMs: number }> = [];
|
||||
private readonly retryWaiters: Array<(retry: () => void) => void> = [];
|
||||
private readonly retryWaiters: Array<{
|
||||
delayMs: number;
|
||||
resolve(retry: () => void): void;
|
||||
}> = [];
|
||||
|
||||
private fetchTimeline(
|
||||
agentId: string,
|
||||
@@ -147,10 +151,10 @@ class TimelineWorld {
|
||||
return new Promise((resolve) => this.errorWaiters.push(resolve));
|
||||
}
|
||||
|
||||
nextRetry(): Promise<() => void> {
|
||||
const index = this.scheduled.findIndex((entry) => entry.delayMs === 1_000);
|
||||
nextRetry(delayMs = 1_000): Promise<() => void> {
|
||||
const index = this.scheduled.findIndex((entry) => entry.delayMs === delayMs);
|
||||
if (index >= 0) return Promise.resolve(this.scheduled.splice(index, 1)[0].task);
|
||||
return new Promise((resolve) => this.retryWaiters.push(resolve));
|
||||
return new Promise((resolve) => this.retryWaiters.push({ delayMs, resolve }));
|
||||
}
|
||||
|
||||
elapse(elapsedMs: number): void {
|
||||
@@ -411,6 +415,74 @@ test("a failed catch-up reports once and retries through the explicit retry poli
|
||||
world.expectNoPendingMembership();
|
||||
});
|
||||
|
||||
test("a failed catch-up retries with exponential backoff", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
|
||||
const first = await world.nextFetch("agent-a");
|
||||
first.fail("timeline unavailable");
|
||||
const [firstError, retryAfterFirstFailure] = await Promise.all([
|
||||
world.nextError(),
|
||||
world.nextRetry(),
|
||||
]);
|
||||
expect(firstError).toBe("timeline unavailable");
|
||||
retryAfterFirstFailure();
|
||||
|
||||
const second = await world.nextFetch("agent-a");
|
||||
second.fail("timeline unavailable");
|
||||
await world.nextError();
|
||||
|
||||
const retryAfterSecondFailure = await world.nextRetry(2_000);
|
||||
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error");
|
||||
|
||||
retryAfterSecondFailure();
|
||||
const third = await world.nextFetch("agent-a");
|
||||
third.respond({ hasNewer: false });
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||
});
|
||||
|
||||
test("manual retries can immediately re-attempt a failed catch-up", 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();
|
||||
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error");
|
||||
|
||||
world.sync.retryVisibleAgentTimeline("agent-a");
|
||||
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("retrying");
|
||||
|
||||
const retry = await world.nextFetch("agent-a");
|
||||
retry.respond({ hasNewer: false });
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||
});
|
||||
|
||||
test("a manual retry that fails returns to the error state", 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.retryVisibleAgentTimeline("agent-a");
|
||||
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("retrying");
|
||||
|
||||
const retry = await world.nextFetch("agent-a");
|
||||
retry.fail("timeline still unavailable");
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error"));
|
||||
});
|
||||
|
||||
test("gap recovery supersedes completed catch-up and pages through the current tail", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
@@ -486,6 +558,34 @@ test("membership failure autonomously retries without another visibility declara
|
||||
});
|
||||
});
|
||||
|
||||
test("membership failures retry with exponential backoff", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
|
||||
const first = await world.nextMembership();
|
||||
first.fail("subscription unavailable");
|
||||
const [firstError, retryAfterFirstFailure] = await Promise.all([
|
||||
world.nextError(),
|
||||
world.nextRetry(),
|
||||
]);
|
||||
expect(firstError).toBe("subscription unavailable");
|
||||
|
||||
retryAfterFirstFailure();
|
||||
const second = await world.nextMembership();
|
||||
second.fail("subscription unavailable again");
|
||||
|
||||
const retryAfterSecondFailure = await world.nextRetry(2_000);
|
||||
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error");
|
||||
|
||||
retryAfterSecondFailure();
|
||||
const retry = await world.nextMembership();
|
||||
retry.succeed();
|
||||
const catchUp = await world.nextFetch("agent-a");
|
||||
catchUp.respond({ hasNewer: false });
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||
});
|
||||
|
||||
test("backgrounding preserves the hot membership without catch-up on return", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
|
||||
@@ -23,12 +23,13 @@ interface ViewedTimelineSyncPorts {
|
||||
}
|
||||
|
||||
export type TimelineDeliveryMode = "legacy" | "selective";
|
||||
export type ViewedTimelineStatus = "ready" | "pending" | "error";
|
||||
export type ViewedTimelineStatus = "ready" | "pending" | "error" | "retrying";
|
||||
|
||||
export interface ViewedTimelineUiBridge {
|
||||
replaceVisibleAgentIds(sourceId: string, agentIds: string[]): void;
|
||||
subscribe(listener: () => void): () => void;
|
||||
getAgentTimelineStatus(agentId: string): ViewedTimelineStatus;
|
||||
retryVisibleAgentTimeline(agentId: string): void;
|
||||
}
|
||||
|
||||
export interface ViewedTimelineSync extends ViewedTimelineUiBridge {
|
||||
@@ -40,6 +41,7 @@ export interface ViewedTimelineSync extends ViewedTimelineUiBridge {
|
||||
}
|
||||
|
||||
const RETRY_DELAY_MS = 1_000;
|
||||
const MAX_RETRY_DELAY_MS = 30_000;
|
||||
const VIEWED_TIMELINE_HOT_AGENT_LIMIT = 5;
|
||||
|
||||
type CatchUpStatus = "running" | "complete" | "error";
|
||||
@@ -49,8 +51,16 @@ interface CatchUpState {
|
||||
status: CatchUpStatus;
|
||||
request?: ProjectedTimelineForwardFetchPlan;
|
||||
cancelRetry?: () => void;
|
||||
retryDelayMs?: number;
|
||||
}
|
||||
|
||||
const getNextRetryDelayMs = (previousDelayMs: number | undefined): number => {
|
||||
if (previousDelayMs == null) {
|
||||
return RETRY_DELAY_MS;
|
||||
}
|
||||
return Math.min(previousDelayMs * 2, MAX_RETRY_DELAY_MS);
|
||||
};
|
||||
|
||||
function isSameCatchUpRequest(
|
||||
left: ProjectedTimelineForwardFetchPlan | undefined,
|
||||
right: ProjectedTimelineForwardFetchPlan | undefined,
|
||||
@@ -102,6 +112,9 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
const pendingCatchUps = new Map<string, ProjectedTimelineForwardFetchPlan>();
|
||||
const visibilityCatchUpPending = new Set<string>();
|
||||
const visibilityCatchUpErrors = new Set<string>();
|
||||
// User-initiated retries only. Background retries stay silent; a retry the user asked for
|
||||
// owes them a pending state until it settles.
|
||||
const manualRetries = new Set<string>();
|
||||
const listeners = new Set<() => void>();
|
||||
let active = true;
|
||||
let connected = false;
|
||||
@@ -113,6 +126,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
let reconciling = false;
|
||||
let reconcileRequested = false;
|
||||
let membershipNeedsRetry = false;
|
||||
let membershipRetryDelayMs: number | undefined;
|
||||
let cancelMembershipRetry: (() => void) | null = null;
|
||||
let recentlyViewedAgentIds: string[] = [];
|
||||
|
||||
@@ -152,12 +166,14 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
const setVisibilityCatchUpReady = (agentId: string) => {
|
||||
const wasPending = visibilityCatchUpPending.delete(agentId);
|
||||
const hadError = visibilityCatchUpErrors.delete(agentId);
|
||||
if (wasPending || hadError) notifyListeners();
|
||||
const wasRetrying = manualRetries.delete(agentId);
|
||||
if (wasPending || hadError || wasRetrying) notifyListeners();
|
||||
};
|
||||
|
||||
const setVisibilityCatchUpError = (agentIds: string[]) => {
|
||||
let changed = false;
|
||||
for (const agentId of agentIds) {
|
||||
if (manualRetries.delete(agentId)) changed = true;
|
||||
if (!visibilityCatchUpPending.delete(agentId)) continue;
|
||||
visibilityCatchUpErrors.add(agentId);
|
||||
changed = true;
|
||||
@@ -210,12 +226,19 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
setVisibilityCatchUpReady(agentId);
|
||||
} catch (error) {
|
||||
if (catchUps.get(agentId)?.generation === generation) {
|
||||
const nextRetryDelayMs = getNextRetryDelayMs(catchUps.get(agentId)?.retryDelayMs);
|
||||
const cancelRetry = ports.schedule(() => {
|
||||
const current = catchUps.get(agentId);
|
||||
if (current?.generation !== generation || current.status !== "error") return;
|
||||
startCatchUp(agentId);
|
||||
}, RETRY_DELAY_MS);
|
||||
catchUps.set(agentId, { generation, status: "error", cancelRetry });
|
||||
}, nextRetryDelayMs);
|
||||
catchUps.set(agentId, {
|
||||
generation,
|
||||
status: "error",
|
||||
request,
|
||||
cancelRetry,
|
||||
retryDelayMs: nextRetryDelayMs,
|
||||
});
|
||||
setVisibilityCatchUpError([agentId]);
|
||||
ports.reportError(error);
|
||||
}
|
||||
@@ -247,7 +270,14 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
current?.cancelRetry?.();
|
||||
const generation = (catchUpGenerations.get(agentId) ?? 0) + 1;
|
||||
catchUpGenerations.set(agentId, generation);
|
||||
catchUps.set(agentId, { generation, status: "running", request: nextRequest });
|
||||
const retryDelayMs =
|
||||
supersede || current?.status !== "error" ? undefined : current.retryDelayMs;
|
||||
catchUps.set(agentId, {
|
||||
generation,
|
||||
status: "running",
|
||||
request: nextRequest,
|
||||
retryDelayMs,
|
||||
});
|
||||
pendingCatchUps.delete(agentId);
|
||||
void fetchUntilCurrent(
|
||||
agentId,
|
||||
@@ -279,6 +309,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
membershipNeedsRetry = true;
|
||||
setVisibilityCatchUpError(requested);
|
||||
cancelMembershipRetry?.();
|
||||
const nextRetryDelayMs = getNextRetryDelayMs(membershipRetryDelayMs);
|
||||
cancelMembershipRetry = ports.schedule(() => {
|
||||
cancelMembershipRetry = null;
|
||||
if (
|
||||
@@ -290,12 +321,14 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
return;
|
||||
}
|
||||
void reconcileMembership();
|
||||
}, RETRY_DELAY_MS);
|
||||
}, nextRetryDelayMs);
|
||||
membershipRetryDelayMs = nextRetryDelayMs;
|
||||
ports.reportError(error);
|
||||
return;
|
||||
}
|
||||
cancelMembershipRetry?.();
|
||||
cancelMembershipRetry = null;
|
||||
membershipRetryDelayMs = undefined;
|
||||
if (disposed || !connected || deliveryMode !== "selective") return;
|
||||
acknowledged = requested;
|
||||
if (generation !== membershipGeneration) {
|
||||
@@ -338,6 +371,25 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
}
|
||||
};
|
||||
|
||||
const retryVisibleAgentTimeline = (agentId: string) => {
|
||||
if (!isDesired(agentId) || manualRetries.has(agentId)) return;
|
||||
const catchUp = catchUps.get(agentId);
|
||||
const membershipRetryable = deliveryMode === "selective" && membershipNeedsRetry && connected;
|
||||
if (catchUp?.status !== "error" && !membershipRetryable) return;
|
||||
manualRetries.add(agentId);
|
||||
notifyListeners();
|
||||
if (catchUp?.status === "error") {
|
||||
catchUp.cancelRetry?.();
|
||||
startCatchUp(agentId, { request: catchUp.request, supersede: true });
|
||||
return;
|
||||
}
|
||||
cancelMembershipRetry?.();
|
||||
cancelMembershipRetry = null;
|
||||
membershipRetryDelayMs = undefined;
|
||||
membershipNeedsRetry = false;
|
||||
void reconcileMembership();
|
||||
};
|
||||
|
||||
const commitDesiredMembership = (
|
||||
nextDesired: string[],
|
||||
options: { resetCatchUpStatus?: boolean } = {},
|
||||
@@ -350,6 +402,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
statusChanged = true;
|
||||
}
|
||||
if (visibilityCatchUpErrors.delete(agentId)) statusChanged = true;
|
||||
if (manualRetries.delete(agentId)) statusChanged = true;
|
||||
}
|
||||
}
|
||||
if (sameAgentIds(nextDesired, desired)) {
|
||||
@@ -364,12 +417,14 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
cancelCatchUp(agentId);
|
||||
visibilityCatchUpPending.delete(agentId);
|
||||
visibilityCatchUpErrors.delete(agentId);
|
||||
manualRetries.delete(agentId);
|
||||
}
|
||||
}
|
||||
for (const agentId of nextDesired) {
|
||||
if (!desired.includes(agentId)) {
|
||||
visibilityCatchUpPending.add(agentId);
|
||||
visibilityCatchUpErrors.delete(agentId);
|
||||
manualRetries.delete(agentId);
|
||||
}
|
||||
}
|
||||
cancelMembershipRetry?.();
|
||||
@@ -403,6 +458,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
getAgentTimelineStatus(agentId) {
|
||||
if (manualRetries.has(agentId)) return "retrying";
|
||||
if (visibilityCatchUpErrors.has(agentId)) return "error";
|
||||
if (!isDesired(agentId) || visibilityCatchUpPending.has(agentId)) return "pending";
|
||||
return "ready";
|
||||
@@ -427,6 +483,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
commitDesiredMembership(visible, { resetCatchUpStatus: true });
|
||||
cancelMembershipRetry?.();
|
||||
cancelMembershipRetry = null;
|
||||
membershipRetryDelayMs = undefined;
|
||||
acknowledged = [];
|
||||
membershipGeneration += 1;
|
||||
for (const agentId of desired) cancelCatchUp(agentId);
|
||||
@@ -445,6 +502,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
deliveryMode = nextMode;
|
||||
cancelMembershipRetry?.();
|
||||
cancelMembershipRetry = null;
|
||||
membershipRetryDelayMs = undefined;
|
||||
membershipNeedsRetry = false;
|
||||
membershipGeneration += 1;
|
||||
for (const agentId of desired) cancelCatchUp(agentId);
|
||||
@@ -453,6 +511,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
desired = visible;
|
||||
visibilityCatchUpPending.clear();
|
||||
visibilityCatchUpErrors.clear();
|
||||
manualRetries.clear();
|
||||
for (const agentId of desired) visibilityCatchUpPending.add(agentId);
|
||||
acknowledged = deliveryMode === "legacy" && connected ? desired : [];
|
||||
notifyListeners();
|
||||
@@ -470,6 +529,8 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
disposed = true;
|
||||
cancelMembershipRetry?.();
|
||||
cancelMembershipRetry = null;
|
||||
membershipNeedsRetry = false;
|
||||
membershipRetryDelayMs = undefined;
|
||||
sources.clear();
|
||||
membershipGeneration += 1;
|
||||
for (const agentId of desired) cancelCatchUp(agentId);
|
||||
@@ -478,8 +539,10 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
recentlyViewedAgentIds = [];
|
||||
visibilityCatchUpPending.clear();
|
||||
visibilityCatchUpErrors.clear();
|
||||
manualRetries.clear();
|
||||
notifyListeners();
|
||||
listeners.clear();
|
||||
},
|
||||
retryVisibleAgentTimeline,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user