From 13bedd167426ec3c2b9dcac91a18189558c0040a Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 10 Nov 2025 15:59:58 +0000 Subject: [PATCH 01/19] =?UTF-8?q?=F0=9F=A4=96=20fix:=20preserve=20backoff?= =?UTF-8?q?=20progression=20through=20manual=20retries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bug: Manual retries were clearing retry state entirely, causing subsequent auto-retries to start from attempt 0 with no backoff. Example bug scenario: 1. Auto-retry fails 3 times (should wait 8s for next retry) 2. User clicks manual retry 3. Manual retry fails 4. BUG: Next auto-retry waits only 1s instead of 16s Root cause: RetryBarrier.tsx line 97 was setting retry state to null, which caused useResumeManager to use default state with attempt: 0. The fix: - Manual retries now preserve attempt counter while making retry immediate - Created utility functions (retryState.ts) to encapsulate state transitions - All retry state mutations now use these utilities (DRY) Design improvements: - createManualRetryState(): immediate retry, preserves backoff - createFailedRetryState(): increments attempt, stores error - createFreshRetryState(): resets on successful stream start This makes the bug impossible to reintroduce because: 1. Utility functions enforce correct state transitions 2. Tests verify backoff progression through manual retries 3. Single source of truth for INITIAL_DELAY constant Generated with `cmux` --- .../Messages/ChatBarrier/RetryBarrier.tsx | 8 +- src/hooks/useResumeManager.ts | 26 ++-- src/stores/WorkspaceStore.ts | 7 +- src/utils/messages/retryState.test.ts | 137 ++++++++++++++++++ src/utils/messages/retryState.ts | 59 ++++++++ 5 files changed, 215 insertions(+), 22 deletions(-) create mode 100644 src/utils/messages/retryState.test.ts create mode 100644 src/utils/messages/retryState.ts diff --git a/src/components/Messages/ChatBarrier/RetryBarrier.tsx b/src/components/Messages/ChatBarrier/RetryBarrier.tsx index 27c58e91e..244595697 100644 --- a/src/components/Messages/ChatBarrier/RetryBarrier.tsx +++ b/src/components/Messages/ChatBarrier/RetryBarrier.tsx @@ -7,13 +7,13 @@ import type { RetryState } from "@/hooks/useResumeManager"; import { useWorkspaceState } from "@/stores/WorkspaceStore"; import { isEligibleForAutoRetry, isNonRetryableSendError } from "@/utils/messages/retryEligibility"; import { formatSendMessageError } from "@/utils/errors/formatSendError"; +import { createManualRetryState, INITIAL_DELAY } from "@/utils/messages/retryState"; interface RetryBarrierProps { workspaceId: string; className?: string; } -const INITIAL_DELAY = 1000; // 1 second const MAX_DELAY = 60000; // 60 seconds (cap for exponential backoff) const defaultRetryState: RetryState = { @@ -92,9 +92,9 @@ export const RetryBarrier: React.FC = ({ workspaceId, classNa const handleManualRetry = () => { setAutoRetry(true); // Re-enable auto-retry for next failure - // Clear retry state to make workspace immediately eligible for resume - // Use updatePersistedState to ensure listener-enabled hooks receive the update - updatePersistedState(getRetryStateKey(workspaceId), null); + // Create manual retry state: immediate retry BUT preserves attempt counter + // This prevents infinite retry loops without backoff if the retry fails + updatePersistedState(getRetryStateKey(workspaceId), createManualRetryState(attempt)); // Emit event to useResumeManager - it will handle the actual resume // Pass isManual flag to bypass eligibility checks (user explicitly wants to retry) diff --git a/src/hooks/useResumeManager.ts b/src/hooks/useResumeManager.ts index b9eea7e41..2d69b0459 100644 --- a/src/hooks/useResumeManager.ts +++ b/src/hooks/useResumeManager.ts @@ -7,6 +7,7 @@ import { readPersistedState, updatePersistedState } from "./usePersistedState"; import { isEligibleForAutoRetry, isNonRetryableSendError } from "@/utils/messages/retryEligibility"; import { applyCompactionOverrides } from "@/utils/messages/compactionOptions"; import type { SendMessageError } from "@/types/errors"; +import { createFailedRetryState } from "@/utils/messages/retryState"; export interface RetryState { attempt: number; @@ -171,12 +172,10 @@ export function useResumeManager() { if (!result.success) { // Store error in retry state so RetryBarrier can display it - const newState: RetryState = { - attempt: attempt + 1, - retryStartTime: Date.now(), - lastError: result.error, - }; - updatePersistedState(getRetryStateKey(workspaceId), newState); + updatePersistedState( + getRetryStateKey(workspaceId), + createFailedRetryState(attempt, result.error) + ); } else { // Success - clear retry state entirely // If stream fails again, we'll start fresh (immediately eligible) @@ -184,15 +183,14 @@ export function useResumeManager() { } } catch (error) { // Store error in retry state for display - const newState: RetryState = { - attempt: attempt + 1, - retryStartTime: Date.now(), - lastError: { - type: "unknown", - raw: error instanceof Error ? error.message : "Failed to resume stream", - }, + const errorData: SendMessageError = { + type: "unknown", + raw: error instanceof Error ? error.message : "Failed to resume stream", }; - updatePersistedState(getRetryStateKey(workspaceId), newState); + updatePersistedState( + getRetryStateKey(workspaceId), + createFailedRetryState(attempt, errorData) + ); } finally { // Always clear retrying flag retryingRef.current.delete(workspaceId); diff --git a/src/stores/WorkspaceStore.ts b/src/stores/WorkspaceStore.ts index 4442f4fbc..4221905a2 100644 --- a/src/stores/WorkspaceStore.ts +++ b/src/stores/WorkspaceStore.ts @@ -18,6 +18,7 @@ import type { TokenConsumer } from "@/types/chatStats"; import type { LanguageModelV2Usage } from "@ai-sdk/provider"; import { getCancelledCompactionKey } from "@/constants/storage"; import { isCompactingStream, findCompactionRequestMessage } from "@/utils/compaction/handler"; +import { createFreshRetryState } from "@/utils/messages/retryState"; export interface WorkspaceState { name: string; // User-facing workspace name (e.g., "feature-branch") @@ -123,10 +124,8 @@ export class WorkspaceStore { if (this.onModelUsed) { this.onModelUsed((data as { model: string }).model); } - updatePersistedState(getRetryStateKey(workspaceId), { - attempt: 0, - retryStartTime: Date.now(), - }); + // Reset retry state on successful stream start + updatePersistedState(getRetryStateKey(workspaceId), createFreshRetryState()); this.states.bump(workspaceId); }, "stream-delta": (workspaceId, aggregator, data) => { diff --git a/src/utils/messages/retryState.test.ts b/src/utils/messages/retryState.test.ts new file mode 100644 index 000000000..f5b455155 --- /dev/null +++ b/src/utils/messages/retryState.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect } from "bun:test"; +import { + createFreshRetryState, + createManualRetryState, + createFailedRetryState, + INITIAL_DELAY, +} from "./retryState"; + +describe("retryState utilities", () => { + describe("createFreshRetryState", () => { + it("creates a state with attempt 0 and no error", () => { + const state = createFreshRetryState(); + expect(state.attempt).toBe(0); + expect(state.lastError).toBeUndefined(); + expect(state.retryStartTime).toBeLessThanOrEqual(Date.now()); + }); + }); + + describe("createManualRetryState", () => { + it("preserves attempt counter (critical for backoff)", () => { + const currentAttempt = 3; + const state = createManualRetryState(currentAttempt); + + // CRITICAL: Manual retry must preserve attempt counter + // This ensures exponential backoff continues if the retry fails + expect(state.attempt).toBe(currentAttempt); + }); + + it("makes retry immediately eligible by backdating retryStartTime", () => { + const state = createManualRetryState(0); + + // Should be backdated by INITIAL_DELAY to be immediately eligible + const expectedTime = Date.now() - INITIAL_DELAY; + expect(state.retryStartTime).toBeLessThanOrEqual(expectedTime); + expect(state.retryStartTime).toBeGreaterThan(expectedTime - 100); // Allow 100ms tolerance + }); + + it("clears any previous error", () => { + const state = createManualRetryState(2); + expect(state.lastError).toBeUndefined(); + }); + + it("prevents no-backoff bug: manual retry at attempt 3 should continue backoff progression", () => { + const currentAttempt = 3; + const state = createManualRetryState(currentAttempt); + + // Bug scenario: User manually retries after 3 failed attempts + // Expected: Next auto-retry should wait for 2^3 = 8 seconds + // Bug (before fix): Next auto-retry would start at attempt 0 (1 second) + + // Verify attempt counter is preserved (not reset to 0) + expect(state.attempt).toBe(3); + + // Calculate expected backoff if this manual retry fails + const expectedDelay = INITIAL_DELAY * Math.pow(2, state.attempt); + expect(expectedDelay).toBe(8000); // 8 seconds for attempt 3 + + // If attempt was 0 (the bug), delay would only be 1 second + const buggyDelay = INITIAL_DELAY * Math.pow(2, 0); + expect(buggyDelay).toBe(1000); + + // Verify we're NOT creating buggy state + expect(state.attempt).not.toBe(0); + }); + }); + + describe("createFailedRetryState", () => { + it("increments attempt counter", () => { + const error = { type: "unknown" as const, raw: "Test error" }; + const state = createFailedRetryState(2, error); + + expect(state.attempt).toBe(3); // 2 + 1 + }); + + it("stores the error for display", () => { + const error = { type: "api_key_not_found" as const }; + const state = createFailedRetryState(0, error); + + expect(state.lastError).toEqual(error); + }); + + it("updates retryStartTime for backoff calculation", () => { + const error = { type: "unknown" as const, raw: "Test" }; + const state = createFailedRetryState(1, error); + + expect(state.retryStartTime).toBeLessThanOrEqual(Date.now()); + expect(state.retryStartTime).toBeGreaterThan(Date.now() - 1000); // Within last second + }); + }); + + describe("backoff progression scenario", () => { + it("maintains exponential backoff through manual retries", () => { + // Simulate: auto-retry fails 3 times, user clicks manual retry, it fails again + + // Initial auto-retry fails + let state = createFailedRetryState(0, { type: "unknown" as const, raw: "Error 1" }); + expect(state.attempt).toBe(1); + + // Second auto-retry fails + state = createFailedRetryState(state.attempt, { type: "unknown" as const, raw: "Error 2" }); + expect(state.attempt).toBe(2); + + // Third auto-retry fails + state = createFailedRetryState(state.attempt, { type: "unknown" as const, raw: "Error 3" }); + expect(state.attempt).toBe(3); + + // User clicks manual retry - CRITICAL: preserve attempt counter + state = createManualRetryState(state.attempt); + expect(state.attempt).toBe(3); // NOT reset to 0 + + // Manual retry fails - should increment to 4 + state = createFailedRetryState(state.attempt, { type: "unknown" as const, raw: "Error 4" }); + expect(state.attempt).toBe(4); + + // Next auto-retry should wait 2^4 = 16 seconds + const delay = INITIAL_DELAY * Math.pow(2, state.attempt); + expect(delay).toBe(16000); + }); + + it("resets backoff on successful stream start", () => { + // Simulate: failed several times, then succeeded + let state = createFailedRetryState(0, { type: "unknown" as const, raw: "Error" }); + state = createFailedRetryState(state.attempt, { type: "unknown" as const, raw: "Error" }); + state = createFailedRetryState(state.attempt, { type: "unknown" as const, raw: "Error" }); + expect(state.attempt).toBe(3); + + // Stream starts successfully - reset everything + state = createFreshRetryState(); + expect(state.attempt).toBe(0); + expect(state.lastError).toBeUndefined(); + + // Next failure should start fresh at attempt 1 + state = createFailedRetryState(state.attempt, { type: "unknown" as const, raw: "Error" }); + expect(state.attempt).toBe(1); + }); + }); +}); diff --git a/src/utils/messages/retryState.ts b/src/utils/messages/retryState.ts new file mode 100644 index 000000000..82f9ec12f --- /dev/null +++ b/src/utils/messages/retryState.ts @@ -0,0 +1,59 @@ +import type { RetryState } from "@/hooks/useResumeManager"; + +export const INITIAL_DELAY = 1000; // 1 second + +/** + * Utility functions for managing retry state + * + * These functions encapsulate retry state transitions to prevent bugs + * like bypassing exponential backoff. + */ + +/** + * Create a fresh retry state (for new stream starts) + * + * Use this when a stream starts successfully - resets backoff completely. + */ +export function createFreshRetryState(): RetryState { + return { + attempt: 0, + retryStartTime: Date.now(), + }; +} + +/** + * Create retry state for manual retry (user-initiated) + * + * Makes the retry immediately eligible BUT preserves the attempt counter + * to maintain backoff progression if the retry fails. + * + * This prevents infinite retry loops without backoff. + * + * @param currentAttempt - Current attempt count to preserve backoff progression + */ +export function createManualRetryState(currentAttempt: number): RetryState { + return { + attempt: currentAttempt, + retryStartTime: Date.now() - INITIAL_DELAY, // Make immediately eligible + lastError: undefined, // Clear error (user is manually retrying) + }; +} + +/** + * Create retry state after a failed attempt + * + * Increments attempt counter and records the error for display. + * + * @param previousAttempt - Previous attempt count + * @param error - Error that caused the failure + */ +export function createFailedRetryState( + previousAttempt: number, + error: RetryState["lastError"] +): RetryState { + return { + attempt: previousAttempt + 1, + retryStartTime: Date.now(), + lastError: error, + }; +} From be78a6c46c6d9e22dae58fd0fc60e66fdb9ab4eb Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 10 Nov 2025 16:08:19 +0000 Subject: [PATCH 02/19] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20consolidate=20?= =?UTF-8?q?retry=20backoff=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed duplication of backoff calculation across 3 files by creating a single source of truth in retryState.ts. Before: - INITIAL_DELAY duplicated in useResumeManager + retryState.ts - MAX_DELAY duplicated in useResumeManager + RetryBarrier - Backoff formula (INITIAL_DELAY * 2^attempt) duplicated in 2 places After: - All constants and backoff calculation in retryState.ts - calculateBackoffDelay() helper used by all consumers - RetryBarrier simplified: removed getDelay callback, uses shared helper Benefits: - Single place to change backoff formula - DRY: 13 lines of duplication removed - Simpler RetryBarrier (no longer calculates, just displays) - Added 3 tests for calculateBackoffDelay() function Net change: -5 LoC in production code, +3 test cases Generated with `cmux` --- .../Messages/ChatBarrier/RetryBarrier.tsx | 14 +++-------- src/hooks/useResumeManager.ts | 11 ++++---- src/utils/messages/retryState.test.ts | 25 ++++++++++++++++++- src/utils/messages/retryState.ts | 12 +++++++++ 4 files changed, 45 insertions(+), 17 deletions(-) diff --git a/src/components/Messages/ChatBarrier/RetryBarrier.tsx b/src/components/Messages/ChatBarrier/RetryBarrier.tsx index 244595697..37b2dba05 100644 --- a/src/components/Messages/ChatBarrier/RetryBarrier.tsx +++ b/src/components/Messages/ChatBarrier/RetryBarrier.tsx @@ -7,15 +7,13 @@ import type { RetryState } from "@/hooks/useResumeManager"; import { useWorkspaceState } from "@/stores/WorkspaceStore"; import { isEligibleForAutoRetry, isNonRetryableSendError } from "@/utils/messages/retryEligibility"; import { formatSendMessageError } from "@/utils/errors/formatSendError"; -import { createManualRetryState, INITIAL_DELAY } from "@/utils/messages/retryState"; +import { createManualRetryState, calculateBackoffDelay } from "@/utils/messages/retryState"; interface RetryBarrierProps { workspaceId: string; className?: string; } -const MAX_DELAY = 60000; // 60 seconds (cap for exponential backoff) - const defaultRetryState: RetryState = { attempt: 0, retryStartTime: Date.now(), @@ -64,19 +62,13 @@ export const RetryBarrier: React.FC = ({ workspaceId, classNa // Local state for UI const [countdown, setCountdown] = useState(0); - // Calculate delay with exponential backoff (same as useResumeManager) - const getDelay = useCallback((attemptNum: number) => { - const exponentialDelay = INITIAL_DELAY * Math.pow(2, attemptNum); - return Math.min(exponentialDelay, MAX_DELAY); - }, []); - // Update countdown display (pure display logic, no side effects) // useResumeManager handles the actual retry logic useEffect(() => { if (!autoRetry) return; const interval = setInterval(() => { - const delay = getDelay(attempt); + const delay = calculateBackoffDelay(attempt); const nextRetryTime = retryStartTime + delay; const timeUntilRetry = Math.max(0, nextRetryTime - Date.now()); @@ -84,7 +76,7 @@ export const RetryBarrier: React.FC = ({ workspaceId, classNa }, 100); return () => clearInterval(interval); - }, [autoRetry, attempt, retryStartTime, getDelay]); + }, [autoRetry, attempt, retryStartTime]); // Manual retry handler (user-initiated, immediate) // Emits event to useResumeManager instead of calling resumeStream directly diff --git a/src/hooks/useResumeManager.ts b/src/hooks/useResumeManager.ts index 2d69b0459..9082717dc 100644 --- a/src/hooks/useResumeManager.ts +++ b/src/hooks/useResumeManager.ts @@ -7,7 +7,11 @@ import { readPersistedState, updatePersistedState } from "./usePersistedState"; import { isEligibleForAutoRetry, isNonRetryableSendError } from "@/utils/messages/retryEligibility"; import { applyCompactionOverrides } from "@/utils/messages/compactionOptions"; import type { SendMessageError } from "@/types/errors"; -import { createFailedRetryState } from "@/utils/messages/retryState"; +import { + createFailedRetryState, + calculateBackoffDelay, + INITIAL_DELAY, +} from "@/utils/messages/retryState"; export interface RetryState { attempt: number; @@ -15,9 +19,6 @@ export interface RetryState { lastError?: SendMessageError; } -const INITIAL_DELAY = 1000; // 1 second -const MAX_DELAY = 60000; // 60 seconds - /** * Centralized auto-resume manager for interrupted streams * @@ -123,7 +124,7 @@ export function useResumeManager() { // 5. Check exponential backoff timer const { attempt, retryStartTime } = retryState; - const delay = Math.min(INITIAL_DELAY * Math.pow(2, attempt), MAX_DELAY); + const delay = calculateBackoffDelay(attempt); const timeSinceLastRetry = Date.now() - retryStartTime; if (timeSinceLastRetry < delay) return false; // Not time yet diff --git a/src/utils/messages/retryState.test.ts b/src/utils/messages/retryState.test.ts index f5b455155..0163e1477 100644 --- a/src/utils/messages/retryState.test.ts +++ b/src/utils/messages/retryState.test.ts @@ -3,10 +3,33 @@ import { createFreshRetryState, createManualRetryState, createFailedRetryState, + calculateBackoffDelay, INITIAL_DELAY, + MAX_DELAY, } from "./retryState"; describe("retryState utilities", () => { + describe("calculateBackoffDelay", () => { + it("returns exponential backoff delays", () => { + expect(calculateBackoffDelay(0)).toBe(1000); // 2^0 = 1s + expect(calculateBackoffDelay(1)).toBe(2000); // 2^1 = 2s + expect(calculateBackoffDelay(2)).toBe(4000); // 2^2 = 4s + expect(calculateBackoffDelay(3)).toBe(8000); // 2^3 = 8s + expect(calculateBackoffDelay(4)).toBe(16000); // 2^4 = 16s + expect(calculateBackoffDelay(5)).toBe(32000); // 2^5 = 32s + }); + + it("caps delay at MAX_DELAY", () => { + expect(calculateBackoffDelay(6)).toBe(60000); // 2^6 = 64s → capped at 60s + expect(calculateBackoffDelay(7)).toBe(60000); // 2^7 = 128s → capped at 60s + expect(calculateBackoffDelay(10)).toBe(60000); // Always capped + }); + + it("MAX_DELAY should be 60 seconds", () => { + expect(MAX_DELAY).toBe(60000); + }); + }); + describe("createFreshRetryState", () => { it("creates a state with attempt 0 and no error", () => { const state = createFreshRetryState(); @@ -73,7 +96,7 @@ describe("retryState utilities", () => { }); it("stores the error for display", () => { - const error = { type: "api_key_not_found" as const }; + const error = { type: "api_key_not_found" as const, provider: "openai" }; const state = createFailedRetryState(0, error); expect(state.lastError).toEqual(error); diff --git a/src/utils/messages/retryState.ts b/src/utils/messages/retryState.ts index 82f9ec12f..7f5691f69 100644 --- a/src/utils/messages/retryState.ts +++ b/src/utils/messages/retryState.ts @@ -1,6 +1,7 @@ import type { RetryState } from "@/hooks/useResumeManager"; export const INITIAL_DELAY = 1000; // 1 second +export const MAX_DELAY = 60000; // 60 seconds /** * Utility functions for managing retry state @@ -9,6 +10,17 @@ export const INITIAL_DELAY = 1000; // 1 second * like bypassing exponential backoff. */ +/** + * Calculate exponential backoff delay with capped maximum + * + * Formula: min(INITIAL_DELAY * 2^attempt, MAX_DELAY) + * Examples: 1s → 2s → 4s → 8s → 16s → 32s → 60s (capped) + */ +export function calculateBackoffDelay(attempt: number): number { + const exponentialDelay = INITIAL_DELAY * Math.pow(2, attempt); + return Math.min(exponentialDelay, MAX_DELAY); +} + /** * Create a fresh retry state (for new stream starts) * From 56fed701fa3bf4b4cbd6d87dd7d3ffd39d73d5b9 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 10 Nov 2025 16:14:35 +0000 Subject: [PATCH 03/19] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20simplify=20ret?= =?UTF-8?q?ry=20state=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed redundant assertions per review feedback: - Removed pointless MAX_DELAY constant test - Consolidated createFailedRetryState tests (3 → 1) - Simplified manual retry backoff test (removed redundant calculations) - Simplified scenario tests (removed verbose comments and intermediate checks) Result: 10 tests, 22 assertions (down from 13 tests, 35 assertions) Same coverage, clearer intent, less noise. Generated with `cmux` --- src/utils/messages/retryState.test.ts | 104 ++++++-------------------- 1 file changed, 22 insertions(+), 82 deletions(-) diff --git a/src/utils/messages/retryState.test.ts b/src/utils/messages/retryState.test.ts index 0163e1477..8c4f78a13 100644 --- a/src/utils/messages/retryState.test.ts +++ b/src/utils/messages/retryState.test.ts @@ -10,23 +10,16 @@ import { describe("retryState utilities", () => { describe("calculateBackoffDelay", () => { - it("returns exponential backoff delays", () => { - expect(calculateBackoffDelay(0)).toBe(1000); // 2^0 = 1s - expect(calculateBackoffDelay(1)).toBe(2000); // 2^1 = 2s - expect(calculateBackoffDelay(2)).toBe(4000); // 2^2 = 4s - expect(calculateBackoffDelay(3)).toBe(8000); // 2^3 = 8s - expect(calculateBackoffDelay(4)).toBe(16000); // 2^4 = 16s - expect(calculateBackoffDelay(5)).toBe(32000); // 2^5 = 32s + it("returns exponential backoff: 1s → 2s → 4s → 8s...", () => { + expect(calculateBackoffDelay(0)).toBe(1000); + expect(calculateBackoffDelay(1)).toBe(2000); + expect(calculateBackoffDelay(2)).toBe(4000); + expect(calculateBackoffDelay(3)).toBe(8000); }); - it("caps delay at MAX_DELAY", () => { - expect(calculateBackoffDelay(6)).toBe(60000); // 2^6 = 64s → capped at 60s - expect(calculateBackoffDelay(7)).toBe(60000); // 2^7 = 128s → capped at 60s - expect(calculateBackoffDelay(10)).toBe(60000); // Always capped - }); - - it("MAX_DELAY should be 60 seconds", () => { - expect(MAX_DELAY).toBe(60000); + it("caps at 60 seconds for large attempts", () => { + expect(calculateBackoffDelay(6)).toBe(60000); + expect(calculateBackoffDelay(10)).toBe(60000); }); }); @@ -51,11 +44,8 @@ describe("retryState utilities", () => { it("makes retry immediately eligible by backdating retryStartTime", () => { const state = createManualRetryState(0); - - // Should be backdated by INITIAL_DELAY to be immediately eligible const expectedTime = Date.now() - INITIAL_DELAY; expect(state.retryStartTime).toBeLessThanOrEqual(expectedTime); - expect(state.retryStartTime).toBeGreaterThan(expectedTime - 100); // Allow 100ms tolerance }); it("clears any previous error", () => { @@ -63,98 +53,48 @@ describe("retryState utilities", () => { expect(state.lastError).toBeUndefined(); }); - it("prevents no-backoff bug: manual retry at attempt 3 should continue backoff progression", () => { - const currentAttempt = 3; - const state = createManualRetryState(currentAttempt); - - // Bug scenario: User manually retries after 3 failed attempts - // Expected: Next auto-retry should wait for 2^3 = 8 seconds - // Bug (before fix): Next auto-retry would start at attempt 0 (1 second) - - // Verify attempt counter is preserved (not reset to 0) - expect(state.attempt).toBe(3); - - // Calculate expected backoff if this manual retry fails - const expectedDelay = INITIAL_DELAY * Math.pow(2, state.attempt); - expect(expectedDelay).toBe(8000); // 8 seconds for attempt 3 - - // If attempt was 0 (the bug), delay would only be 1 second - const buggyDelay = INITIAL_DELAY * Math.pow(2, 0); - expect(buggyDelay).toBe(1000); - - // Verify we're NOT creating buggy state - expect(state.attempt).not.toBe(0); + it("prevents no-backoff bug: preserves attempt counter for continued backoff", () => { + // Bug scenario: After 3 failed attempts, manual retry should preserve counter + // so next failure waits 2^3=8s, not reset to 2^0=1s + const state = createManualRetryState(3); + expect(state.attempt).toBe(3); // NOT reset to 0 }); }); describe("createFailedRetryState", () => { - it("increments attempt counter", () => { + it("increments attempt counter and stores error", () => { const error = { type: "unknown" as const, raw: "Test error" }; const state = createFailedRetryState(2, error); - expect(state.attempt).toBe(3); // 2 + 1 - }); - - it("stores the error for display", () => { - const error = { type: "api_key_not_found" as const, provider: "openai" }; - const state = createFailedRetryState(0, error); - + expect(state.attempt).toBe(3); expect(state.lastError).toEqual(error); - }); - - it("updates retryStartTime for backoff calculation", () => { - const error = { type: "unknown" as const, raw: "Test" }; - const state = createFailedRetryState(1, error); - expect(state.retryStartTime).toBeLessThanOrEqual(Date.now()); - expect(state.retryStartTime).toBeGreaterThan(Date.now() - 1000); // Within last second }); }); describe("backoff progression scenario", () => { it("maintains exponential backoff through manual retries", () => { - // Simulate: auto-retry fails 3 times, user clicks manual retry, it fails again - - // Initial auto-retry fails - let state = createFailedRetryState(0, { type: "unknown" as const, raw: "Error 1" }); - expect(state.attempt).toBe(1); - - // Second auto-retry fails - state = createFailedRetryState(state.attempt, { type: "unknown" as const, raw: "Error 2" }); - expect(state.attempt).toBe(2); - - // Third auto-retry fails - state = createFailedRetryState(state.attempt, { type: "unknown" as const, raw: "Error 3" }); + // 3 auto-retry failures → manual retry → preserves attempt counter + let state = createFailedRetryState(0, { type: "unknown" as const, raw: "Error" }); + state = createFailedRetryState(state.attempt, { type: "unknown" as const, raw: "Error" }); + state = createFailedRetryState(state.attempt, { type: "unknown" as const, raw: "Error" }); expect(state.attempt).toBe(3); - // User clicks manual retry - CRITICAL: preserve attempt counter state = createManualRetryState(state.attempt); expect(state.attempt).toBe(3); // NOT reset to 0 - // Manual retry fails - should increment to 4 - state = createFailedRetryState(state.attempt, { type: "unknown" as const, raw: "Error 4" }); - expect(state.attempt).toBe(4); - - // Next auto-retry should wait 2^4 = 16 seconds - const delay = INITIAL_DELAY * Math.pow(2, state.attempt); - expect(delay).toBe(16000); + state = createFailedRetryState(state.attempt, { type: "unknown" as const, raw: "Error" }); + expect(state.attempt).toBe(4); // Continues progression }); it("resets backoff on successful stream start", () => { - // Simulate: failed several times, then succeeded let state = createFailedRetryState(0, { type: "unknown" as const, raw: "Error" }); state = createFailedRetryState(state.attempt, { type: "unknown" as const, raw: "Error" }); - state = createFailedRetryState(state.attempt, { type: "unknown" as const, raw: "Error" }); - expect(state.attempt).toBe(3); + expect(state.attempt).toBe(2); - // Stream starts successfully - reset everything state = createFreshRetryState(); expect(state.attempt).toBe(0); expect(state.lastError).toBeUndefined(); - - // Next failure should start fresh at attempt 1 - state = createFailedRetryState(state.attempt, { type: "unknown" as const, raw: "Error" }); - expect(state.attempt).toBe(1); }); }); }); From 0c1086578a5e583308d2b5c08e2558f815371c9d Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 10 Nov 2025 16:25:03 +0000 Subject: [PATCH 04/19] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20debug=20flag?= =?UTF-8?q?=20to=20force=20all=20errors=20retryable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added window.__CMUX_FORCE_ALL_RETRYABLE flag for testing retry/backoff logic. Usage in browser console: window.__CMUX_FORCE_ALL_RETRYABLE = true This makes non-retryable errors (authentication, quota, model_not_found, etc.) eligible for auto-retry, allowing easy testing of: - Exponential backoff progression - Manual retry preserving attempt counter - Retry UI states without needing to simulate network conditions Generated with `cmux` --- src/utils/messages/retryEligibility.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/utils/messages/retryEligibility.ts b/src/utils/messages/retryEligibility.ts index 199ba92d0..dc885b0e6 100644 --- a/src/utils/messages/retryEligibility.ts +++ b/src/utils/messages/retryEligibility.ts @@ -1,6 +1,19 @@ import type { DisplayedMessage } from "@/types/message"; import type { StreamErrorType, SendMessageError } from "@/types/errors"; +/** + * Debug flag to force all errors to be retryable + * Set in browser console: window.__CMUX_FORCE_ALL_RETRYABLE = true + * + * Useful for testing retry/backoff logic without needing to simulate + * specific network conditions or rate limits. + */ +declare global { + interface Window { + __CMUX_FORCE_ALL_RETRYABLE?: boolean; + } +} + /** * Error types that should NOT be auto-retried because they require user action * These errors won't resolve on their own - the user must fix the underlying issue @@ -17,6 +30,11 @@ const NON_RETRYABLE_STREAM_ERRORS: StreamErrorType[] = [ * Check if a SendMessageError (from resumeStream failures) is non-retryable */ export function isNonRetryableSendError(error: SendMessageError): boolean { + // Debug flag: force all errors to be retryable + if (window.__CMUX_FORCE_ALL_RETRYABLE) { + return false; + } + switch (error.type) { case "api_key_not_found": // Missing API key - user must configure case "provider_not_supported": // Unsupported provider - user must switch @@ -91,6 +109,10 @@ export function isEligibleForAutoRetry( // (but manual retry is still available via hasInterruptedStream) const lastMessage = messages[messages.length - 1]; if (lastMessage.type === "stream-error") { + // Debug flag: force all errors to be retryable + if (window.__CMUX_FORCE_ALL_RETRYABLE) { + return true; + } return !NON_RETRYABLE_STREAM_ERRORS.includes(lastMessage.errorType); } From ae0ad320c282c06a8bc7c44122e4a9e2161f287e Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 10 Nov 2025 16:27:10 +0000 Subject: [PATCH 05/19] =?UTF-8?q?=F0=9F=A4=96=20docs:=20clarify=20debug=20?= =?UTF-8?q?flag=20requires=20manual=20retry=20to=20clear=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When window.__CMUX_FORCE_ALL_RETRYABLE is set after an error already occurred, the stored retry state may contain a non-retryable error. Clicking the "Retry" button once will clear this state and enable auto-retry. This is expected behavior - the flag affects eligibility checks going forward, but doesn't retroactively modify stored state. Generated with `cmux` --- src/components/Messages/ChatBarrier/RetryBarrier.tsx | 1 + src/utils/messages/retryEligibility.ts | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/src/components/Messages/ChatBarrier/RetryBarrier.tsx b/src/components/Messages/ChatBarrier/RetryBarrier.tsx index 37b2dba05..74c553226 100644 --- a/src/components/Messages/ChatBarrier/RetryBarrier.tsx +++ b/src/components/Messages/ChatBarrier/RetryBarrier.tsx @@ -52,6 +52,7 @@ export const RetryBarrier: React.FC = ({ workspaceId, classNa ); // Also check RetryState for SendMessageErrors (from resumeStream failures) + // Note: isNonRetryableSendError already respects window.__CMUX_FORCE_ALL_RETRYABLE if (lastError && isNonRetryableSendError(lastError)) { return false; // Non-retryable SendMessageError } diff --git a/src/utils/messages/retryEligibility.ts b/src/utils/messages/retryEligibility.ts index dc885b0e6..fe2818ef9 100644 --- a/src/utils/messages/retryEligibility.ts +++ b/src/utils/messages/retryEligibility.ts @@ -7,6 +7,10 @@ import type { StreamErrorType, SendMessageError } from "@/types/errors"; * * Useful for testing retry/backoff logic without needing to simulate * specific network conditions or rate limits. + * + * Note: If you set this flag after an error occurs, you may need to + * trigger a manual retry first (click "Retry" button) to clear the + * stored non-retryable error state. */ declare global { interface Window { From e27e355192f8aecac8e332516a65dd4de067222e Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 10 Nov 2025 16:28:34 +0000 Subject: [PATCH 06/19] =?UTF-8?q?=F0=9F=A4=96=20debug:=20add=20console=20l?= =?UTF-8?q?ogging=20for=20retry=20eligibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added console.debug statements to trace retry eligibility decisions: - isNonRetryableSendError: shows error type and debug flag status - isEligibleForAutoRetry: shows stream error type and retryability - RetryBarrier effectiveAutoRetry: shows all inputs to the decision Usage: 1. Open browser console 2. Set window.__CMUX_FORCE_ALL_RETRYABLE = true 3. Trigger an error or click Retry 4. Watch console output to see why it's not auto-retrying Generated with `cmux` --- .../Messages/ChatBarrier/RetryBarrier.tsx | 15 ++++++++++- src/utils/messages/retryEligibility.ts | 27 ++++++++++++++++--- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/components/Messages/ChatBarrier/RetryBarrier.tsx b/src/components/Messages/ChatBarrier/RetryBarrier.tsx index 74c553226..f40e55372 100644 --- a/src/components/Messages/ChatBarrier/RetryBarrier.tsx +++ b/src/components/Messages/ChatBarrier/RetryBarrier.tsx @@ -43,7 +43,16 @@ export const RetryBarrier: React.FC = ({ workspaceId, classNa // Compute effective autoRetry state: user preference AND error is retryable // This ensures UI shows "Retry" button (not "Retrying...") for non-retryable errors const effectiveAutoRetry = useMemo(() => { - if (!autoRetry || !workspaceState) return false; + console.debug("[retry] RetryBarrier effectiveAutoRetry calculation:", { + autoRetry, + hasWorkspaceState: !!workspaceState, + lastError, + }); + + if (!autoRetry || !workspaceState) { + console.debug("[retry] effectiveAutoRetry=false: autoRetry disabled or no workspace state"); + return false; + } // Check if current state is eligible for auto-retry const messagesEligible = isEligibleForAutoRetry( @@ -51,12 +60,16 @@ export const RetryBarrier: React.FC = ({ workspaceId, classNa workspaceState.pendingStreamStartTime ); + console.debug("[retry] messagesEligible:", messagesEligible); + // Also check RetryState for SendMessageErrors (from resumeStream failures) // Note: isNonRetryableSendError already respects window.__CMUX_FORCE_ALL_RETRYABLE if (lastError && isNonRetryableSendError(lastError)) { + console.debug("[retry] effectiveAutoRetry=false: lastError is non-retryable"); return false; // Non-retryable SendMessageError } + console.debug("[retry] effectiveAutoRetry:", messagesEligible); return messagesEligible; }, [autoRetry, workspaceState, lastError]); diff --git a/src/utils/messages/retryEligibility.ts b/src/utils/messages/retryEligibility.ts index fe2818ef9..1977743ce 100644 --- a/src/utils/messages/retryEligibility.ts +++ b/src/utils/messages/retryEligibility.ts @@ -36,17 +36,29 @@ const NON_RETRYABLE_STREAM_ERRORS: StreamErrorType[] = [ export function isNonRetryableSendError(error: SendMessageError): boolean { // Debug flag: force all errors to be retryable if (window.__CMUX_FORCE_ALL_RETRYABLE) { + console.debug("[retry] __CMUX_FORCE_ALL_RETRYABLE enabled, treating error as retryable:", error); return false; } + let isNonRetryable = false; switch (error.type) { case "api_key_not_found": // Missing API key - user must configure case "provider_not_supported": // Unsupported provider - user must switch case "invalid_model_string": // Bad model format - user must fix - return true; + isNonRetryable = true; + break; case "unknown": - return false; // Unknown errors might be transient + isNonRetryable = false; // Unknown errors might be transient + break; } + + console.debug("[retry] isNonRetryableSendError:", { + errorType: error.type, + isNonRetryable, + debugFlag: window.__CMUX_FORCE_ALL_RETRYABLE, + }); + + return isNonRetryable; } /** @@ -106,6 +118,7 @@ export function isEligibleForAutoRetry( ): boolean { // First check if there's an interrupted stream at all if (!hasInterruptedStream(messages, pendingStreamStartTime)) { + console.debug("[retry] No interrupted stream detected"); return false; } @@ -115,11 +128,19 @@ export function isEligibleForAutoRetry( if (lastMessage.type === "stream-error") { // Debug flag: force all errors to be retryable if (window.__CMUX_FORCE_ALL_RETRYABLE) { + console.debug("[retry] __CMUX_FORCE_ALL_RETRYABLE enabled, stream-error is retryable:", lastMessage.errorType); return true; } - return !NON_RETRYABLE_STREAM_ERRORS.includes(lastMessage.errorType); + const isRetryable = !NON_RETRYABLE_STREAM_ERRORS.includes(lastMessage.errorType); + console.debug("[retry] Stream error eligibility:", { + errorType: lastMessage.errorType, + isRetryable, + debugFlag: window.__CMUX_FORCE_ALL_RETRYABLE, + }); + return isRetryable; } // Other interrupted states (partial messages, user messages) are auto-retryable + console.debug("[retry] Other interrupted state (partial/user message), eligible for auto-retry"); return true; } From c1d7d2e226ebbfc50c941bc90c9bd95a158eb21b Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 10 Nov 2025 16:31:14 +0000 Subject: [PATCH 07/19] =?UTF-8?q?=F0=9F=A4=96=20debug:=20switch=20to=20con?= =?UTF-8?q?sole.log=20for=20better=20visibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changed console.debug → console.log so retry eligibility logs are always visible without needing to enable verbose logging in DevTools. Also added render-time log showing barrier state (effectiveAutoRetry, autoRetry, attempt, countdown, lastError). Generated with `cmux` --- .../Messages/ChatBarrier/RetryBarrier.tsx | 18 +++++++++++++----- src/utils/messages/retryEligibility.ts | 12 ++++++------ 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/components/Messages/ChatBarrier/RetryBarrier.tsx b/src/components/Messages/ChatBarrier/RetryBarrier.tsx index f40e55372..c44544ac6 100644 --- a/src/components/Messages/ChatBarrier/RetryBarrier.tsx +++ b/src/components/Messages/ChatBarrier/RetryBarrier.tsx @@ -43,14 +43,14 @@ export const RetryBarrier: React.FC = ({ workspaceId, classNa // Compute effective autoRetry state: user preference AND error is retryable // This ensures UI shows "Retry" button (not "Retrying...") for non-retryable errors const effectiveAutoRetry = useMemo(() => { - console.debug("[retry] RetryBarrier effectiveAutoRetry calculation:", { + console.log("[retry] RetryBarrier effectiveAutoRetry calculation:", { autoRetry, hasWorkspaceState: !!workspaceState, lastError, }); if (!autoRetry || !workspaceState) { - console.debug("[retry] effectiveAutoRetry=false: autoRetry disabled or no workspace state"); + console.log("[retry] effectiveAutoRetry=false: autoRetry disabled or no workspace state"); return false; } @@ -60,16 +60,16 @@ export const RetryBarrier: React.FC = ({ workspaceId, classNa workspaceState.pendingStreamStartTime ); - console.debug("[retry] messagesEligible:", messagesEligible); + console.log("[retry] messagesEligible:", messagesEligible); // Also check RetryState for SendMessageErrors (from resumeStream failures) // Note: isNonRetryableSendError already respects window.__CMUX_FORCE_ALL_RETRYABLE if (lastError && isNonRetryableSendError(lastError)) { - console.debug("[retry] effectiveAutoRetry=false: lastError is non-retryable"); + console.log("[retry] effectiveAutoRetry=false: lastError is non-retryable"); return false; // Non-retryable SendMessageError } - console.debug("[retry] effectiveAutoRetry:", messagesEligible); + console.log("[retry] effectiveAutoRetry:", messagesEligible); return messagesEligible; }, [autoRetry, workspaceState, lastError]); @@ -128,6 +128,14 @@ export const RetryBarrier: React.FC = ({ workspaceId, classNa : formatted.message; }; + console.log("[retry] RetryBarrier rendering:", { + effectiveAutoRetry, + autoRetry, + attempt, + countdown, + lastError, + }); + if (effectiveAutoRetry) { // Auto-retry mode: Show countdown and stop button // useResumeManager handles the actual retry logic diff --git a/src/utils/messages/retryEligibility.ts b/src/utils/messages/retryEligibility.ts index 1977743ce..de025ea03 100644 --- a/src/utils/messages/retryEligibility.ts +++ b/src/utils/messages/retryEligibility.ts @@ -36,7 +36,7 @@ const NON_RETRYABLE_STREAM_ERRORS: StreamErrorType[] = [ export function isNonRetryableSendError(error: SendMessageError): boolean { // Debug flag: force all errors to be retryable if (window.__CMUX_FORCE_ALL_RETRYABLE) { - console.debug("[retry] __CMUX_FORCE_ALL_RETRYABLE enabled, treating error as retryable:", error); + console.log("[retry] __CMUX_FORCE_ALL_RETRYABLE enabled, treating error as retryable:", error); return false; } @@ -52,7 +52,7 @@ export function isNonRetryableSendError(error: SendMessageError): boolean { break; } - console.debug("[retry] isNonRetryableSendError:", { + console.log("[retry] isNonRetryableSendError:", { errorType: error.type, isNonRetryable, debugFlag: window.__CMUX_FORCE_ALL_RETRYABLE, @@ -118,7 +118,7 @@ export function isEligibleForAutoRetry( ): boolean { // First check if there's an interrupted stream at all if (!hasInterruptedStream(messages, pendingStreamStartTime)) { - console.debug("[retry] No interrupted stream detected"); + console.log("[retry] No interrupted stream detected"); return false; } @@ -128,11 +128,11 @@ export function isEligibleForAutoRetry( if (lastMessage.type === "stream-error") { // Debug flag: force all errors to be retryable if (window.__CMUX_FORCE_ALL_RETRYABLE) { - console.debug("[retry] __CMUX_FORCE_ALL_RETRYABLE enabled, stream-error is retryable:", lastMessage.errorType); + console.log("[retry] __CMUX_FORCE_ALL_RETRYABLE enabled, stream-error is retryable:", lastMessage.errorType); return true; } const isRetryable = !NON_RETRYABLE_STREAM_ERRORS.includes(lastMessage.errorType); - console.debug("[retry] Stream error eligibility:", { + console.log("[retry] Stream error eligibility:", { errorType: lastMessage.errorType, isRetryable, debugFlag: window.__CMUX_FORCE_ALL_RETRYABLE, @@ -141,6 +141,6 @@ export function isEligibleForAutoRetry( } // Other interrupted states (partial messages, user messages) are auto-retryable - console.debug("[retry] Other interrupted state (partial/user message), eligible for auto-retry"); + console.log("[retry] Other interrupted state (partial/user message), eligible for auto-retry"); return true; } From 165abec1391d4033813ae563840b2506528ef469 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 10 Nov 2025 16:35:46 +0000 Subject: [PATCH 08/19] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20make=20retry?= =?UTF-8?q?=20logs=20conditional=20on=20debug=20flag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Logs are now only shown when window.__CMUX_FORCE_ALL_RETRYABLE is set, eliminating console spam from polling every 1 second. Exception: Non-retryable errors still log to help diagnose issues. Now console is clean by default, verbose only when debugging retries. Generated with `cmux` --- .../Messages/ChatBarrier/RetryBarrier.tsx | 44 ++++++++++++------- src/utils/messages/retryEligibility.ts | 33 +++++++++----- 2 files changed, 49 insertions(+), 28 deletions(-) diff --git a/src/components/Messages/ChatBarrier/RetryBarrier.tsx b/src/components/Messages/ChatBarrier/RetryBarrier.tsx index c44544ac6..f1ae82018 100644 --- a/src/components/Messages/ChatBarrier/RetryBarrier.tsx +++ b/src/components/Messages/ChatBarrier/RetryBarrier.tsx @@ -43,14 +43,18 @@ export const RetryBarrier: React.FC = ({ workspaceId, classNa // Compute effective autoRetry state: user preference AND error is retryable // This ensures UI shows "Retry" button (not "Retrying...") for non-retryable errors const effectiveAutoRetry = useMemo(() => { - console.log("[retry] RetryBarrier effectiveAutoRetry calculation:", { - autoRetry, - hasWorkspaceState: !!workspaceState, - lastError, - }); + if (window.__CMUX_FORCE_ALL_RETRYABLE) { + console.log("[retry] RetryBarrier effectiveAutoRetry calculation:", { + autoRetry, + hasWorkspaceState: !!workspaceState, + lastError, + }); + } if (!autoRetry || !workspaceState) { - console.log("[retry] effectiveAutoRetry=false: autoRetry disabled or no workspace state"); + if (window.__CMUX_FORCE_ALL_RETRYABLE) { + console.log("[retry] effectiveAutoRetry=false: autoRetry disabled or no workspace state"); + } return false; } @@ -60,16 +64,22 @@ export const RetryBarrier: React.FC = ({ workspaceId, classNa workspaceState.pendingStreamStartTime ); - console.log("[retry] messagesEligible:", messagesEligible); + if (window.__CMUX_FORCE_ALL_RETRYABLE) { + console.log("[retry] messagesEligible:", messagesEligible); + } // Also check RetryState for SendMessageErrors (from resumeStream failures) // Note: isNonRetryableSendError already respects window.__CMUX_FORCE_ALL_RETRYABLE if (lastError && isNonRetryableSendError(lastError)) { - console.log("[retry] effectiveAutoRetry=false: lastError is non-retryable"); + if (window.__CMUX_FORCE_ALL_RETRYABLE) { + console.log("[retry] effectiveAutoRetry=false: lastError is non-retryable"); + } return false; // Non-retryable SendMessageError } - console.log("[retry] effectiveAutoRetry:", messagesEligible); + if (window.__CMUX_FORCE_ALL_RETRYABLE) { + console.log("[retry] effectiveAutoRetry:", messagesEligible); + } return messagesEligible; }, [autoRetry, workspaceState, lastError]); @@ -128,13 +138,15 @@ export const RetryBarrier: React.FC = ({ workspaceId, classNa : formatted.message; }; - console.log("[retry] RetryBarrier rendering:", { - effectiveAutoRetry, - autoRetry, - attempt, - countdown, - lastError, - }); + if (window.__CMUX_FORCE_ALL_RETRYABLE) { + console.log("[retry] RetryBarrier rendering:", { + effectiveAutoRetry, + autoRetry, + attempt, + countdown, + lastError, + }); + } if (effectiveAutoRetry) { // Auto-retry mode: Show countdown and stop button diff --git a/src/utils/messages/retryEligibility.ts b/src/utils/messages/retryEligibility.ts index de025ea03..7a58de550 100644 --- a/src/utils/messages/retryEligibility.ts +++ b/src/utils/messages/retryEligibility.ts @@ -52,11 +52,14 @@ export function isNonRetryableSendError(error: SendMessageError): boolean { break; } - console.log("[retry] isNonRetryableSendError:", { - errorType: error.type, - isNonRetryable, - debugFlag: window.__CMUX_FORCE_ALL_RETRYABLE, - }); + // Only log when debug flag is set or when dealing with non-retryable errors + if (window.__CMUX_FORCE_ALL_RETRYABLE || isNonRetryable) { + console.log("[retry] isNonRetryableSendError:", { + errorType: error.type, + isNonRetryable, + debugFlag: window.__CMUX_FORCE_ALL_RETRYABLE, + }); + } return isNonRetryable; } @@ -118,7 +121,9 @@ export function isEligibleForAutoRetry( ): boolean { // First check if there's an interrupted stream at all if (!hasInterruptedStream(messages, pendingStreamStartTime)) { - console.log("[retry] No interrupted stream detected"); + if (window.__CMUX_FORCE_ALL_RETRYABLE) { + console.log("[retry] No interrupted stream detected"); + } return false; } @@ -132,15 +137,19 @@ export function isEligibleForAutoRetry( return true; } const isRetryable = !NON_RETRYABLE_STREAM_ERRORS.includes(lastMessage.errorType); - console.log("[retry] Stream error eligibility:", { - errorType: lastMessage.errorType, - isRetryable, - debugFlag: window.__CMUX_FORCE_ALL_RETRYABLE, - }); + if (window.__CMUX_FORCE_ALL_RETRYABLE) { + console.log("[retry] Stream error eligibility:", { + errorType: lastMessage.errorType, + isRetryable, + debugFlag: window.__CMUX_FORCE_ALL_RETRYABLE, + }); + } return isRetryable; } // Other interrupted states (partial messages, user messages) are auto-retryable - console.log("[retry] Other interrupted state (partial/user message), eligible for auto-retry"); + if (window.__CMUX_FORCE_ALL_RETRYABLE) { + console.log("[retry] Other interrupted state (partial/user message), eligible for auto-retry"); + } return true; } From 0ac7357a17e5b1a82d153d0b974b92f3545d4ace Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 10 Nov 2025 16:40:21 +0000 Subject: [PATCH 09/19] =?UTF-8?q?=F0=9F=A4=96=20fix:=20prevent=20backoff?= =?UTF-8?q?=20reset=20on=20stream=20start?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: resumeStream() returning success meant "stream initiated", not "stream completed". useResumeManager was clearing retry state too early, causing: 1. Retry N fails → stored as attempt N+1 2. Retry N+1 starts → resumeStream succeeds → state cleared to null 3. stream-start event → WorkspaceStore resets to attempt 0 4. Stream fails → stored as attempt 1 (should be N+2!) Result: Backoff never progresses beyond attempt 1 → no exponential backoff. Fix: Don't clear retry state in useResumeManager on resumeStream success. Let stream-start/stream-end events handle clearing (they already do via createFreshRetryState). This preserves attempt counter until stream actually completes successfully. Generated with `cmux` --- src/hooks/useResumeManager.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/hooks/useResumeManager.ts b/src/hooks/useResumeManager.ts index 9082717dc..6ac73b8ca 100644 --- a/src/hooks/useResumeManager.ts +++ b/src/hooks/useResumeManager.ts @@ -177,11 +177,10 @@ export function useResumeManager() { getRetryStateKey(workspaceId), createFailedRetryState(attempt, result.error) ); - } else { - // Success - clear retry state entirely - // If stream fails again, we'll start fresh (immediately eligible) - updatePersistedState(getRetryStateKey(workspaceId), null); } + // Note: Don't clear retry state here on success - stream-start event will handle that + // resumeStream success just means "stream initiated", not "stream completed" + // Clearing here causes backoff reset bug when stream starts then immediately fails } catch (error) { // Store error in retry state for display const errorData: SendMessageError = { From ea12c69f91e0e9f1fa5a29b94209a804f0449306 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 10 Nov 2025 16:42:04 +0000 Subject: [PATCH 10/19] =?UTF-8?q?=F0=9F=A4=96=20debug:=20add=20targeted=20?= =?UTF-8?q?logging=20for=20attempt=20counter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added minimal logging to trace the actual retry state transitions: - attemptResume: shows current attempt when retry is triggered - resumeStream result: shows attempt increment on failure - stream-start: shows when retry state is reset to 0 This will help identify where backoff progression is breaking. Generated with `cmux` --- src/hooks/useResumeManager.ts | 17 +++++++++-------- src/stores/WorkspaceStore.ts | 1 + 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/hooks/useResumeManager.ts b/src/hooks/useResumeManager.ts index 6ac73b8ca..57da82af8 100644 --- a/src/hooks/useResumeManager.ts +++ b/src/hooks/useResumeManager.ts @@ -153,6 +153,7 @@ export function useResumeManager() { }); const { attempt } = retryState; + console.log(`[retry] ${workspaceId} attemptResume: current attempt=${attempt}, isManual=${isManual}`); try { // Start with workspace defaults @@ -173,10 +174,11 @@ export function useResumeManager() { if (!result.success) { // Store error in retry state so RetryBarrier can display it - updatePersistedState( - getRetryStateKey(workspaceId), - createFailedRetryState(attempt, result.error) - ); + const newState = createFailedRetryState(attempt, result.error); + console.log(`[retry] ${workspaceId} resumeStream failed: attempt ${attempt} → ${newState.attempt}`); + updatePersistedState(getRetryStateKey(workspaceId), newState); + } else { + console.log(`[retry] ${workspaceId} resumeStream succeeded (stream initiated)`); } // Note: Don't clear retry state here on success - stream-start event will handle that // resumeStream success just means "stream initiated", not "stream completed" @@ -187,10 +189,9 @@ export function useResumeManager() { type: "unknown", raw: error instanceof Error ? error.message : "Failed to resume stream", }; - updatePersistedState( - getRetryStateKey(workspaceId), - createFailedRetryState(attempt, errorData) - ); + const newState = createFailedRetryState(attempt, errorData); + console.log(`[retry] ${workspaceId} resumeStream exception: attempt ${attempt} → ${newState.attempt}`); + updatePersistedState(getRetryStateKey(workspaceId), newState); } finally { // Always clear retrying flag retryingRef.current.delete(workspaceId); diff --git a/src/stores/WorkspaceStore.ts b/src/stores/WorkspaceStore.ts index 4221905a2..836968206 100644 --- a/src/stores/WorkspaceStore.ts +++ b/src/stores/WorkspaceStore.ts @@ -125,6 +125,7 @@ export class WorkspaceStore { this.onModelUsed((data as { model: string }).model); } // Reset retry state on successful stream start + console.log(`[retry] ${workspaceId} stream-start: resetting to attempt=0`); updatePersistedState(getRetryStateKey(workspaceId), createFreshRetryState()); this.states.bump(workspaceId); }, From d66b0058dfb758e264427eb5d54a42dfd385e7e7 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 10 Nov 2025 16:46:13 +0000 Subject: [PATCH 11/19] =?UTF-8?q?=F0=9F=A4=96=20fix:=20configure=20HMR=20f?= =?UTF-8?q?or=20custom=20host=20in=20dev-server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When using custom host (e.g., VITE_HOST=100.127.77.14), Vite's HMR WebSocket was trying to connect to localhost instead of the configured host, breaking hot module reload. Added explicit hmr configuration to use the same host/port as the server. Generated with `cmux` --- vite.config.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/vite.config.ts b/vite.config.ts index 26ef9f70e..0816c1d2b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -87,6 +87,12 @@ export default defineConfig(({ mode }) => ({ strictPort: true, allowedHosts: devServerHost === "0.0.0.0" ? undefined : ["localhost", "127.0.0.1"], sourcemapIgnoreList: () => false, // Show all sources in DevTools + hmr: { + // Configure HMR to use the correct host for remote access + host: devServerHost, + port: devServerPort, + protocol: "ws", + }, }, preview: { host: "127.0.0.1", From 804315b1227ceb66e550e5e29199c5cceaaffc0e Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 10 Nov 2025 16:53:29 +0000 Subject: [PATCH 12/19] =?UTF-8?q?=F0=9F=A4=96=20fix:=20reset=20retry=20sta?= =?UTF-8?q?te=20on=20stream-end=20not=20stream-start?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: stream-start fires when stream initiates, not when it completes. Auth errors happen AFTER stream-start, so resetting retry state there caused backoff to reset every retry attempt. Flow before fix: 1. Retry attempt N → stream-start fires → reset to attempt=0 2. Auth error happens → stored as attempt=1 3. Next retry → reset to attempt=0 again (infinite loop at attempt=0) Flow after fix: 1. Retry attempt N → stream-start fires → attempt=N preserved 2. Auth error happens → stored as attempt=N+1 3. Next retry → uses attempt=N+1 → exponential backoff works! stream-start = "stream initiated" (could still fail) stream-end = "stream completed successfully" (actually succeeded) Only reset retry state when stream actually completes, not when it starts. Generated with `cmux` --- src/stores/WorkspaceStore.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/stores/WorkspaceStore.ts b/src/stores/WorkspaceStore.ts index 836968206..1bc01e78e 100644 --- a/src/stores/WorkspaceStore.ts +++ b/src/stores/WorkspaceStore.ts @@ -124,9 +124,8 @@ export class WorkspaceStore { if (this.onModelUsed) { this.onModelUsed((data as { model: string }).model); } - // Reset retry state on successful stream start - console.log(`[retry] ${workspaceId} stream-start: resetting to attempt=0`); - updatePersistedState(getRetryStateKey(workspaceId), createFreshRetryState()); + // Don't reset retry state here - stream might still fail after starting + // Retry state will be reset on stream-end (successful completion) this.states.bump(workspaceId); }, "stream-delta": (workspaceId, aggregator, data) => { @@ -141,6 +140,10 @@ export class WorkspaceStore { return; } + // Reset retry state on successful stream completion + console.log(`[retry] ${workspaceId} stream-end: resetting to attempt=0`); + updatePersistedState(getRetryStateKey(workspaceId), createFreshRetryState()); + this.states.bump(workspaceId); this.checkAndBumpRecencyIfChanged(); this.finalizeUsageStats(workspaceId, (data as { metadata?: never }).metadata); From a3b5635cf9af070514cf63bfc9dd92f380cfe068 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 10 Nov 2025 17:01:21 +0000 Subject: [PATCH 13/19] =?UTF-8?q?=F0=9F=A4=96=20fix:=20increment=20retry?= =?UTF-8?q?=20counter=20on=20stream-error=20event?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: resumeStream() returns success when stream INITIATES, but auth errors happen AFTER on the backend. When backend sends stream-error event, we weren't incrementing the attempt counter. Result: attempt counter stayed at 0 forever → no backoff → spam. Fix: Increment attempt counter when stream-error event fires. Now: 1. Stream starts → attempt=0 preserved 2. Auth error on backend → stream-error event → attempt=0 → 1 3. Next retry → reads attempt=1 → waits 2s 4. Stream starts → attempt=1 preserved 5. Auth error → stream-error → attempt=1 → 2 6. Next retry → reads attempt=2 → waits 4s (exponential backoff works!) Generated with `cmux` --- src/stores/WorkspaceStore.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/stores/WorkspaceStore.ts b/src/stores/WorkspaceStore.ts index 1bc01e78e..1de68a434 100644 --- a/src/stores/WorkspaceStore.ts +++ b/src/stores/WorkspaceStore.ts @@ -5,8 +5,9 @@ import type { FrontendWorkspaceMetadata } from "@/types/workspace"; import type { WorkspaceChatMessage } from "@/types/ipc"; import type { TodoItem } from "@/types/tools"; import { StreamingMessageAggregator } from "@/utils/messages/StreamingMessageAggregator"; -import { updatePersistedState } from "@/hooks/usePersistedState"; +import { updatePersistedState, readPersistedState } from "@/hooks/usePersistedState"; import { getRetryStateKey } from "@/constants/storage"; +import type { RetryState } from "@/hooks/useResumeManager"; import { CUSTOM_EVENTS, createCustomEvent } from "@/constants/events"; import { useSyncExternalStore } from "react"; import { isCaughtUpMessage, isStreamError, isDeleteMessage, isCmuxMessage } from "@/types/ipc"; From a7b2f1ecef8c71876004d19482b74fe08ed86526 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 10 Nov 2025 17:02:26 +0000 Subject: [PATCH 14/19] =?UTF-8?q?=F0=9F=A4=96=20fix:=20actually=20incremen?= =?UTF-8?q?t=20retry=20counter=20on=20stream-error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous commit only added imports. Now actually incrementing the attempt counter in the stream-error handler. Generated with `cmux` --- src/stores/WorkspaceStore.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/stores/WorkspaceStore.ts b/src/stores/WorkspaceStore.ts index 1de68a434..ce4cc1505 100644 --- a/src/stores/WorkspaceStore.ts +++ b/src/stores/WorkspaceStore.ts @@ -924,6 +924,21 @@ export class WorkspaceStore { // Handle non-buffered special events first if (isStreamError(data)) { aggregator.handleStreamError(data); + + // Increment retry attempt counter when stream fails + // This handles auth errors that happen AFTER stream-start + const retryState = readPersistedState(getRetryStateKey(workspaceId), { + attempt: 0, + retryStartTime: Date.now(), + }); + const newState: RetryState = { + attempt: retryState.attempt + 1, + retryStartTime: Date.now(), + // Don't store error here - it's already in the message + }; + console.log(`[retry] ${workspaceId} stream-error: attempt ${retryState.attempt} → ${newState.attempt}`); + updatePersistedState(getRetryStateKey(workspaceId), newState); + this.states.bump(workspaceId); this.dispatchResumeCheck(workspaceId); return; From 28b33b7d8740c20e80438ae2cca15e547c999942 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 10 Nov 2025 17:07:52 +0000 Subject: [PATCH 15/19] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20clean=20up=20r?= =?UTF-8?q?etry=20logging=20and=20simplify=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Downgraded all retry logs from console.log to console.debug - Removed verbose logging from RetryBarrier.tsx (31 lines) - Simplified isNonRetryableSendError with direct returns - Simplified isEligibleForAutoRetry - removed redundant logs - Removed success log from useResumeManager (not needed) - Updated comment: stream-end (not stream-start) resets retry state Removed 65 lines of debug logging while preserving the debug flag functionality. All typecheck and tests pass. Generated with `cmux` --- .../Messages/ChatBarrier/RetryBarrier.tsx | 31 ---------------- src/hooks/useResumeManager.ts | 10 +++--- src/stores/WorkspaceStore.ts | 4 +-- src/utils/messages/retryEligibility.ts | 36 ++----------------- 4 files changed, 8 insertions(+), 73 deletions(-) diff --git a/src/components/Messages/ChatBarrier/RetryBarrier.tsx b/src/components/Messages/ChatBarrier/RetryBarrier.tsx index f1ae82018..206abad7f 100644 --- a/src/components/Messages/ChatBarrier/RetryBarrier.tsx +++ b/src/components/Messages/ChatBarrier/RetryBarrier.tsx @@ -43,18 +43,7 @@ export const RetryBarrier: React.FC = ({ workspaceId, classNa // Compute effective autoRetry state: user preference AND error is retryable // This ensures UI shows "Retry" button (not "Retrying...") for non-retryable errors const effectiveAutoRetry = useMemo(() => { - if (window.__CMUX_FORCE_ALL_RETRYABLE) { - console.log("[retry] RetryBarrier effectiveAutoRetry calculation:", { - autoRetry, - hasWorkspaceState: !!workspaceState, - lastError, - }); - } - if (!autoRetry || !workspaceState) { - if (window.__CMUX_FORCE_ALL_RETRYABLE) { - console.log("[retry] effectiveAutoRetry=false: autoRetry disabled or no workspace state"); - } return false; } @@ -64,22 +53,12 @@ export const RetryBarrier: React.FC = ({ workspaceId, classNa workspaceState.pendingStreamStartTime ); - if (window.__CMUX_FORCE_ALL_RETRYABLE) { - console.log("[retry] messagesEligible:", messagesEligible); - } - // Also check RetryState for SendMessageErrors (from resumeStream failures) // Note: isNonRetryableSendError already respects window.__CMUX_FORCE_ALL_RETRYABLE if (lastError && isNonRetryableSendError(lastError)) { - if (window.__CMUX_FORCE_ALL_RETRYABLE) { - console.log("[retry] effectiveAutoRetry=false: lastError is non-retryable"); - } return false; // Non-retryable SendMessageError } - if (window.__CMUX_FORCE_ALL_RETRYABLE) { - console.log("[retry] effectiveAutoRetry:", messagesEligible); - } return messagesEligible; }, [autoRetry, workspaceState, lastError]); @@ -138,16 +117,6 @@ export const RetryBarrier: React.FC = ({ workspaceId, classNa : formatted.message; }; - if (window.__CMUX_FORCE_ALL_RETRYABLE) { - console.log("[retry] RetryBarrier rendering:", { - effectiveAutoRetry, - autoRetry, - attempt, - countdown, - lastError, - }); - } - if (effectiveAutoRetry) { // Auto-retry mode: Show countdown and stop button // useResumeManager handles the actual retry logic diff --git a/src/hooks/useResumeManager.ts b/src/hooks/useResumeManager.ts index 57da82af8..6c37ee65e 100644 --- a/src/hooks/useResumeManager.ts +++ b/src/hooks/useResumeManager.ts @@ -153,7 +153,7 @@ export function useResumeManager() { }); const { attempt } = retryState; - console.log(`[retry] ${workspaceId} attemptResume: current attempt=${attempt}, isManual=${isManual}`); + console.debug(`[retry] ${workspaceId} attemptResume: current attempt=${attempt}, isManual=${isManual}`); try { // Start with workspace defaults @@ -175,12 +175,10 @@ export function useResumeManager() { if (!result.success) { // Store error in retry state so RetryBarrier can display it const newState = createFailedRetryState(attempt, result.error); - console.log(`[retry] ${workspaceId} resumeStream failed: attempt ${attempt} → ${newState.attempt}`); + console.debug(`[retry] ${workspaceId} resumeStream failed: attempt ${attempt} → ${newState.attempt}`); updatePersistedState(getRetryStateKey(workspaceId), newState); - } else { - console.log(`[retry] ${workspaceId} resumeStream succeeded (stream initiated)`); } - // Note: Don't clear retry state here on success - stream-start event will handle that + // Note: Don't clear retry state on success - stream-end event will handle that // resumeStream success just means "stream initiated", not "stream completed" // Clearing here causes backoff reset bug when stream starts then immediately fails } catch (error) { @@ -190,7 +188,7 @@ export function useResumeManager() { raw: error instanceof Error ? error.message : "Failed to resume stream", }; const newState = createFailedRetryState(attempt, errorData); - console.log(`[retry] ${workspaceId} resumeStream exception: attempt ${attempt} → ${newState.attempt}`); + console.debug(`[retry] ${workspaceId} resumeStream exception: attempt ${attempt} → ${newState.attempt}`); updatePersistedState(getRetryStateKey(workspaceId), newState); } finally { // Always clear retrying flag diff --git a/src/stores/WorkspaceStore.ts b/src/stores/WorkspaceStore.ts index ce4cc1505..e8b2cb6be 100644 --- a/src/stores/WorkspaceStore.ts +++ b/src/stores/WorkspaceStore.ts @@ -142,7 +142,6 @@ export class WorkspaceStore { } // Reset retry state on successful stream completion - console.log(`[retry] ${workspaceId} stream-end: resetting to attempt=0`); updatePersistedState(getRetryStateKey(workspaceId), createFreshRetryState()); this.states.bump(workspaceId); @@ -934,9 +933,8 @@ export class WorkspaceStore { const newState: RetryState = { attempt: retryState.attempt + 1, retryStartTime: Date.now(), - // Don't store error here - it's already in the message }; - console.log(`[retry] ${workspaceId} stream-error: attempt ${retryState.attempt} → ${newState.attempt}`); + console.debug(`[retry] ${workspaceId} stream-error: incrementing attempt ${retryState.attempt} → ${newState.attempt}`); updatePersistedState(getRetryStateKey(workspaceId), newState); this.states.bump(workspaceId); diff --git a/src/utils/messages/retryEligibility.ts b/src/utils/messages/retryEligibility.ts index 7a58de550..fe2818ef9 100644 --- a/src/utils/messages/retryEligibility.ts +++ b/src/utils/messages/retryEligibility.ts @@ -36,32 +36,17 @@ const NON_RETRYABLE_STREAM_ERRORS: StreamErrorType[] = [ export function isNonRetryableSendError(error: SendMessageError): boolean { // Debug flag: force all errors to be retryable if (window.__CMUX_FORCE_ALL_RETRYABLE) { - console.log("[retry] __CMUX_FORCE_ALL_RETRYABLE enabled, treating error as retryable:", error); return false; } - let isNonRetryable = false; switch (error.type) { case "api_key_not_found": // Missing API key - user must configure case "provider_not_supported": // Unsupported provider - user must switch case "invalid_model_string": // Bad model format - user must fix - isNonRetryable = true; - break; + return true; case "unknown": - isNonRetryable = false; // Unknown errors might be transient - break; - } - - // Only log when debug flag is set or when dealing with non-retryable errors - if (window.__CMUX_FORCE_ALL_RETRYABLE || isNonRetryable) { - console.log("[retry] isNonRetryableSendError:", { - errorType: error.type, - isNonRetryable, - debugFlag: window.__CMUX_FORCE_ALL_RETRYABLE, - }); + return false; // Unknown errors might be transient } - - return isNonRetryable; } /** @@ -121,9 +106,6 @@ export function isEligibleForAutoRetry( ): boolean { // First check if there's an interrupted stream at all if (!hasInterruptedStream(messages, pendingStreamStartTime)) { - if (window.__CMUX_FORCE_ALL_RETRYABLE) { - console.log("[retry] No interrupted stream detected"); - } return false; } @@ -133,23 +115,11 @@ export function isEligibleForAutoRetry( if (lastMessage.type === "stream-error") { // Debug flag: force all errors to be retryable if (window.__CMUX_FORCE_ALL_RETRYABLE) { - console.log("[retry] __CMUX_FORCE_ALL_RETRYABLE enabled, stream-error is retryable:", lastMessage.errorType); return true; } - const isRetryable = !NON_RETRYABLE_STREAM_ERRORS.includes(lastMessage.errorType); - if (window.__CMUX_FORCE_ALL_RETRYABLE) { - console.log("[retry] Stream error eligibility:", { - errorType: lastMessage.errorType, - isRetryable, - debugFlag: window.__CMUX_FORCE_ALL_RETRYABLE, - }); - } - return isRetryable; + return !NON_RETRYABLE_STREAM_ERRORS.includes(lastMessage.errorType); } // Other interrupted states (partial messages, user messages) are auto-retryable - if (window.__CMUX_FORCE_ALL_RETRYABLE) { - console.log("[retry] Other interrupted state (partial/user message), eligible for auto-retry"); - } return true; } From aa1189912f9d1d9f7bc503c98defb6f9710cdc03 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 10 Nov 2025 17:12:02 +0000 Subject: [PATCH 16/19] =?UTF-8?q?=F0=9F=A4=96=20fix:=20remove=20unused=20i?= =?UTF-8?q?mports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unused useCallback from RetryBarrier - Remove unused MAX_DELAY from retryState tests Generated with `cmux` --- src/components/Messages/ChatBarrier/RetryBarrier.tsx | 2 +- src/hooks/useResumeManager.ts | 12 +++++++++--- src/stores/WorkspaceStore.ts | 8 +++++--- src/utils/messages/retryState.test.ts | 1 - 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/components/Messages/ChatBarrier/RetryBarrier.tsx b/src/components/Messages/ChatBarrier/RetryBarrier.tsx index 206abad7f..cf369b991 100644 --- a/src/components/Messages/ChatBarrier/RetryBarrier.tsx +++ b/src/components/Messages/ChatBarrier/RetryBarrier.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback, useMemo } from "react"; +import React, { useState, useEffect, useMemo } from "react"; import { usePersistedState, updatePersistedState } from "@/hooks/usePersistedState"; import { getRetryStateKey, getAutoRetryKey } from "@/constants/storage"; import { CUSTOM_EVENTS, createCustomEvent } from "@/constants/events"; diff --git a/src/hooks/useResumeManager.ts b/src/hooks/useResumeManager.ts index 6c37ee65e..e0b35e728 100644 --- a/src/hooks/useResumeManager.ts +++ b/src/hooks/useResumeManager.ts @@ -153,7 +153,9 @@ export function useResumeManager() { }); const { attempt } = retryState; - console.debug(`[retry] ${workspaceId} attemptResume: current attempt=${attempt}, isManual=${isManual}`); + console.debug( + `[retry] ${workspaceId} attemptResume: current attempt=${attempt}, isManual=${isManual}` + ); try { // Start with workspace defaults @@ -175,7 +177,9 @@ export function useResumeManager() { if (!result.success) { // Store error in retry state so RetryBarrier can display it const newState = createFailedRetryState(attempt, result.error); - console.debug(`[retry] ${workspaceId} resumeStream failed: attempt ${attempt} → ${newState.attempt}`); + console.debug( + `[retry] ${workspaceId} resumeStream failed: attempt ${attempt} → ${newState.attempt}` + ); updatePersistedState(getRetryStateKey(workspaceId), newState); } // Note: Don't clear retry state on success - stream-end event will handle that @@ -188,7 +192,9 @@ export function useResumeManager() { raw: error instanceof Error ? error.message : "Failed to resume stream", }; const newState = createFailedRetryState(attempt, errorData); - console.debug(`[retry] ${workspaceId} resumeStream exception: attempt ${attempt} → ${newState.attempt}`); + console.debug( + `[retry] ${workspaceId} resumeStream exception: attempt ${attempt} → ${newState.attempt}` + ); updatePersistedState(getRetryStateKey(workspaceId), newState); } finally { // Always clear retrying flag diff --git a/src/stores/WorkspaceStore.ts b/src/stores/WorkspaceStore.ts index e8b2cb6be..f150aa232 100644 --- a/src/stores/WorkspaceStore.ts +++ b/src/stores/WorkspaceStore.ts @@ -923,7 +923,7 @@ export class WorkspaceStore { // Handle non-buffered special events first if (isStreamError(data)) { aggregator.handleStreamError(data); - + // Increment retry attempt counter when stream fails // This handles auth errors that happen AFTER stream-start const retryState = readPersistedState(getRetryStateKey(workspaceId), { @@ -934,9 +934,11 @@ export class WorkspaceStore { attempt: retryState.attempt + 1, retryStartTime: Date.now(), }; - console.debug(`[retry] ${workspaceId} stream-error: incrementing attempt ${retryState.attempt} → ${newState.attempt}`); + console.debug( + `[retry] ${workspaceId} stream-error: incrementing attempt ${retryState.attempt} → ${newState.attempt}` + ); updatePersistedState(getRetryStateKey(workspaceId), newState); - + this.states.bump(workspaceId); this.dispatchResumeCheck(workspaceId); return; diff --git a/src/utils/messages/retryState.test.ts b/src/utils/messages/retryState.test.ts index 8c4f78a13..f76bfdf01 100644 --- a/src/utils/messages/retryState.test.ts +++ b/src/utils/messages/retryState.test.ts @@ -5,7 +5,6 @@ import { createFailedRetryState, calculateBackoffDelay, INITIAL_DELAY, - MAX_DELAY, } from "./retryState"; describe("retryState utilities", () => { From 66d83bab4e787c4d5784ec4c377a22e3ffd2e31d Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 10 Nov 2025 17:15:28 +0000 Subject: [PATCH 17/19] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20use=20callback?= =?UTF-8?q?=20pattern=20in=20updatePersistedState?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplified stream-error retry counter increment to use the callback pattern instead of read-then-write, making the update atomic. Before: - readPersistedState to get current state - Create new state object - updatePersistedState with new state After: - updatePersistedState with callback that increments atomically Also removed unused RetryState import from WorkspaceStore. Generated with `cmux` --- src/stores/WorkspaceStore.ts | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/stores/WorkspaceStore.ts b/src/stores/WorkspaceStore.ts index f150aa232..bd61a812b 100644 --- a/src/stores/WorkspaceStore.ts +++ b/src/stores/WorkspaceStore.ts @@ -5,9 +5,8 @@ import type { FrontendWorkspaceMetadata } from "@/types/workspace"; import type { WorkspaceChatMessage } from "@/types/ipc"; import type { TodoItem } from "@/types/tools"; import { StreamingMessageAggregator } from "@/utils/messages/StreamingMessageAggregator"; -import { updatePersistedState, readPersistedState } from "@/hooks/usePersistedState"; +import { updatePersistedState } from "@/hooks/usePersistedState"; import { getRetryStateKey } from "@/constants/storage"; -import type { RetryState } from "@/hooks/useResumeManager"; import { CUSTOM_EVENTS, createCustomEvent } from "@/constants/events"; import { useSyncExternalStore } from "react"; import { isCaughtUpMessage, isStreamError, isDeleteMessage, isCmuxMessage } from "@/types/ipc"; @@ -926,18 +925,20 @@ export class WorkspaceStore { // Increment retry attempt counter when stream fails // This handles auth errors that happen AFTER stream-start - const retryState = readPersistedState(getRetryStateKey(workspaceId), { - attempt: 0, - retryStartTime: Date.now(), - }); - const newState: RetryState = { - attempt: retryState.attempt + 1, - retryStartTime: Date.now(), - }; - console.debug( - `[retry] ${workspaceId} stream-error: incrementing attempt ${retryState.attempt} → ${newState.attempt}` + updatePersistedState( + getRetryStateKey(workspaceId), + (prev) => { + const newAttempt = prev.attempt + 1; + console.debug( + `[retry] ${workspaceId} stream-error: incrementing attempt ${prev.attempt} → ${newAttempt}` + ); + return { + attempt: newAttempt, + retryStartTime: Date.now(), + }; + }, + { attempt: 0, retryStartTime: Date.now() } ); - updatePersistedState(getRetryStateKey(workspaceId), newState); this.states.bump(workspaceId); this.dispatchResumeCheck(workspaceId); From 62e957e8385a9739abec6a72857b0dd707030d5f Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 10 Nov 2025 17:16:18 +0000 Subject: [PATCH 18/19] =?UTF-8?q?=F0=9F=A4=96=20fix:=20guard=20window=20ac?= =?UTF-8?q?cess=20in=20retry=20eligibility=20helpers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added typeof window !== "undefined" checks before accessing window.__CMUX_FORCE_ALL_RETRYABLE in: - isNonRetryableSendError - isEligibleForAutoRetry This prevents ReferenceError when these utilities are imported in Node.js test environments (jest.config.js uses testEnvironment: "node"). Generated with `cmux` --- src/utils/messages/retryEligibility.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils/messages/retryEligibility.ts b/src/utils/messages/retryEligibility.ts index fe2818ef9..80bf63ae7 100644 --- a/src/utils/messages/retryEligibility.ts +++ b/src/utils/messages/retryEligibility.ts @@ -35,7 +35,7 @@ const NON_RETRYABLE_STREAM_ERRORS: StreamErrorType[] = [ */ export function isNonRetryableSendError(error: SendMessageError): boolean { // Debug flag: force all errors to be retryable - if (window.__CMUX_FORCE_ALL_RETRYABLE) { + if (typeof window !== "undefined" && window.__CMUX_FORCE_ALL_RETRYABLE) { return false; } @@ -114,7 +114,7 @@ export function isEligibleForAutoRetry( const lastMessage = messages[messages.length - 1]; if (lastMessage.type === "stream-error") { // Debug flag: force all errors to be retryable - if (window.__CMUX_FORCE_ALL_RETRYABLE) { + if (typeof window !== "undefined" && window.__CMUX_FORCE_ALL_RETRYABLE) { return true; } return !NON_RETRYABLE_STREAM_ERRORS.includes(lastMessage.errorType); From 4672fa34f4693e0189f604b051559f4561dde3dd Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 10 Nov 2025 17:59:15 +0000 Subject: [PATCH 19/19] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20extract=20isFo?= =?UTF-8?q?rceAllRetryableEnabled=20helper=20(DRY)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the duplicated window.__CMUX_FORCE_ALL_RETRYABLE check into a helper function to follow DRY principles. Before: - typeof window !== "undefined" && window.__CMUX_FORCE_ALL_RETRYABLE duplicated in isNonRetryableSendError and isEligibleForAutoRetry After: - Single isForceAllRetryableEnabled() helper used by both functions Generated with `cmux` --- src/utils/messages/retryEligibility.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/utils/messages/retryEligibility.ts b/src/utils/messages/retryEligibility.ts index 80bf63ae7..f14877b2e 100644 --- a/src/utils/messages/retryEligibility.ts +++ b/src/utils/messages/retryEligibility.ts @@ -18,6 +18,13 @@ declare global { } } +/** + * Check if the debug flag to force all errors to be retryable is enabled + */ +function isForceAllRetryableEnabled(): boolean { + return typeof window !== "undefined" && window.__CMUX_FORCE_ALL_RETRYABLE === true; +} + /** * Error types that should NOT be auto-retried because they require user action * These errors won't resolve on their own - the user must fix the underlying issue @@ -35,7 +42,7 @@ const NON_RETRYABLE_STREAM_ERRORS: StreamErrorType[] = [ */ export function isNonRetryableSendError(error: SendMessageError): boolean { // Debug flag: force all errors to be retryable - if (typeof window !== "undefined" && window.__CMUX_FORCE_ALL_RETRYABLE) { + if (isForceAllRetryableEnabled()) { return false; } @@ -114,7 +121,7 @@ export function isEligibleForAutoRetry( const lastMessage = messages[messages.length - 1]; if (lastMessage.type === "stream-error") { // Debug flag: force all errors to be retryable - if (typeof window !== "undefined" && window.__CMUX_FORCE_ALL_RETRYABLE) { + if (isForceAllRetryableEnabled()) { return true; } return !NON_RETRYABLE_STREAM_ERRORS.includes(lastMessage.errorType);