From ef58314dd5da1fcbbcde6ebe757544ea42a83435 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Sat, 5 Sep 2026 00:01:45 -0400 Subject: [PATCH] fix(desktop): do not retry terminal receiver initialization Signed-off-by: Logan Johnson --- desktop/src/features/agents/AGENTS.md | 6 ++- .../agents/desktopLifecycleDiagnostics.ts | 9 ++++ .../agents/desktopLifecycleReceiver.test.mjs | 44 +++++++++++++++++++ .../agents/desktopLifecycleReceiver.ts | 10 ++++- 4 files changed, 65 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index e48401b476..a6b0855394 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -318,8 +318,10 @@ failures and transient CLOSED states recover through a bounded receiver-owner budget; each attempt uses a fresh live-only subscription and repeats projection-only sync before admission. Terminal closure or exhausted recovery stays in the scope-owned notification, whose deliberate retry resets the receiver -budget. Recovery must discard queued callbacks from the retired receiver, not -retry an operation, and must respect the relay rate-limit gate. +budget. A known latched-terminal relay session also reports immediately during +initialization without consuming that budget; unknown and transient failures +remain bounded retries. Recovery must discard queued callbacks from the retired +receiver, not retry an operation, and must respect the relay rate-limit gate. A readiness timeout is unconfirmed delivery, not a failed initialization; late EOSE clears that warning after successful projection. An explicit operation retry republishes the exact request; the relay redelivers stored Stop duplicates without repeating relay side effects. diff --git a/desktop/src/features/agents/desktopLifecycleDiagnostics.ts b/desktop/src/features/agents/desktopLifecycleDiagnostics.ts index aebe01c41a..cee8e60769 100644 --- a/desktop/src/features/agents/desktopLifecycleDiagnostics.ts +++ b/desktop/src/features/agents/desktopLifecycleDiagnostics.ts @@ -6,9 +6,17 @@ type Stage = | "projection" | "reconciliation"; +export type LifecycleReceiverFailureClassification = "retryable" | "terminal"; + export class LifecycleReceiverError extends Error { + readonly recoveryClassification: LifecycleReceiverFailureClassification; + constructor(stage: Stage, error: unknown) { const value = error instanceof Error ? error.message : error; + const recoveryClassification = + value === "Relay session is terminal; cannot reconnect." + ? "terminal" + : "retryable"; const reason = value === "closed" ? "subscription closed" @@ -20,6 +28,7 @@ export class LifecycleReceiverError extends Error { ? "history timed out" : "request failed"; super(`Desktop lifecycle receiver is unavailable (${stage}: ${reason}).`); + this.recoveryClassification = recoveryClassification; } } diff --git a/desktop/src/features/agents/desktopLifecycleReceiver.test.mjs b/desktop/src/features/agents/desktopLifecycleReceiver.test.mjs index 70b9689005..6948a55ca6 100644 --- a/desktop/src/features/agents/desktopLifecycleReceiver.test.mjs +++ b/desktop/src/features/agents/desktopLifecycleReceiver.test.mjs @@ -133,6 +133,50 @@ test("first subscribe failure recovers without a reconnect callback and syncs be assert.equal(closed, 1); }); +test("terminal relay-session initialization failure does not consume the recovery budget", async () => { + const clock = timers(); + let subscribeCalls = 0; + const relay = { + getSessionEpoch: () => 1, + getConnectionGeneration: () => 1, + subscribeLive: async () => { + subscribeCalls++; + throw new Error("Relay session is terminal; cannot reconnect."); + }, + fetchEvents: async () => assert.fail("terminal session must not sync"), + publishEvent: async () => {}, + }; + const errors = []; + ownLifecycleReceiver( + scope, + (error) => errors.push(error), + () => {}, + { + ...clock, + waitForRateLimit: async () => {}, + startReceiver: (receiverScope, active, onError, onReady, onClosed) => + receiveLifecycle( + receiverScope, + active, + onError, + async () => + assert.fail("terminal session must not invoke native IPC"), + relay, + onReady, + onClosed, + ), + }, + ); + + await flushUntil(() => errors.length === 1); + assert.equal(subscribeCalls, 1); + assert.equal(clock.pending.size, 0); + assert.equal( + errors[0], + "Desktop lifecycle receiver is unavailable (subscription: relay session requires reconnection).", + ); +}); + test("transient CLOSED before readiness retires and replaces the whole receiver", async () => { const clock = timers(); let subscribeCalls = 0; diff --git a/desktop/src/features/agents/desktopLifecycleReceiver.ts b/desktop/src/features/agents/desktopLifecycleReceiver.ts index 12a2a81ff2..62417287e9 100644 --- a/desktop/src/features/agents/desktopLifecycleReceiver.ts +++ b/desktop/src/features/agents/desktopLifecycleReceiver.ts @@ -2,7 +2,10 @@ import type { LiveSubscriptionClosedRecovery } from "@/shared/api/relayClientSha import { waitForRateLimit } from "@/shared/api/relayRateLimitGate"; import type { DesktopScope } from "./desktopList"; import { receiveLifecycle } from "./desktopLifecycle"; -import { receiverErrorMessage } from "./desktopLifecycleDiagnostics"; +import { + LifecycleReceiverError, + receiverErrorMessage, +} from "./desktopLifecycleDiagnostics"; export const RECEIVER_RECOVERY_DELAYS_MS = [1_000, 2_000, 4_000] as const; @@ -121,7 +124,10 @@ export function ownLifecycleReceiver( .catch((error) => { if (current(token)) recover(token, receiverErrorMessage(error), { - classification: "retryable", + classification: + error instanceof LifecycleReceiverError + ? error.recoveryClassification + : "retryable", retryAfterMs: 0, }); });