From 11d4676e5875f98bec545f501606c02bd47e4b26 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 <77006616+pythonlearner1025@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:59:28 +0000 Subject: [PATCH] feat(components): retry a workspace-runtime boot that failed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `createWorkspaceRuntime` runs once. When it throws, the catch sets `runtimeAtom` to null and the effect's dependency list holds only the workspace slug, the workspace id and `localAgentRuntimeReady` — a flag that moves solely in Electron `dual` sync mode. On a browser-shaped surface against a machine there is then no input left that can change, so nothing re-runs the effect and anything gating on a live runtime stays stuck until the user reloads the page. The window is not exotic. A machine's gateway answers well before its session daemon does, so a boot attempted in that gap fails and never runs again. `bootGeneration` is the one input a failed boot can move: the catch schedules a bump and the effect re-enters with the same inputs it had. The attempt count lives in a ref rather than state, so clearing it on success does not re-run the effect and tear down a runtime that just came up. Backoff reuses `computeLocalReconnectDelayMs` rather than introducing constants, so a machine slow to come up is waited out on the same curve the provider already uses for one slow to reconnect. This is a behaviour change, not a bug fix, and it is offered as a draft: if the one-shot boot is deliberate, say so and this can be closed. Model: claude-opus-5 --- .../src/providers/runtime-provider.tsx | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/components/src/providers/runtime-provider.tsx b/packages/components/src/providers/runtime-provider.tsx index 8f7f92760..3ae392596 100644 --- a/packages/components/src/providers/runtime-provider.tsx +++ b/packages/components/src/providers/runtime-provider.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, type ReactNode } from 'react'; +import { useEffect, useRef, useState, type ReactNode } from 'react'; import { useAtomValue, useSetAtom } from 'jotai'; import { LODY_PRESENCE_HEARTBEAT_MS, type MachineId, type WorkspaceId } from '@lody/shared'; import { authTokenAtom, runtimeAtom } from '@/atoms/runtime'; @@ -25,6 +25,7 @@ import { API_BASE_URL } from '@/lib'; import { getCachedWorkspaceId } from '@/lib/local-storage-cache'; import { usePostHog } from '@posthog/react'; import { createWorkspaceRuntime } from './create-workspace-runtime'; +import { computeLocalReconnectDelayMs } from './local-reconnect-loop'; import { resolveCloudPlatformRuntimePolicy } from './cloud-platform-runtime-policy'; import type { EagerSyncSurface } from './background-sync-coordinator'; import { resolveEffectiveWorkspaceId } from './resolve-effective-workspace-id'; @@ -190,6 +191,14 @@ export function RuntimeProvider({ children }: { children: ReactNode }) { // Initialize runtime when we have a workspaceId (either from cache or server). // - If cached id exists for this slug: initialize immediately (offline-first) // - Otherwise: wait for server to respond with workspaceId + // A FAILED BOOT LEAVES NOTHING THAT CAN CHANGE, so nothing re-runs the effect + // below. `bootGeneration` is the one input that can: the catch schedules a + // bump, and the effect re-enters with the same inputs it had. The attempt + // count lives in a ref so clearing it on success does NOT re-run the effect + // and rebuild a runtime that just came up. + const [bootGeneration, setBootGeneration] = useState(0); + const failedBootAttemptsRef = useRef(0); + useEffect(() => { if (!workspaceSlug) { console.info('RuntimeProvider: clear runtime due to missing workspaceSlug'); @@ -226,6 +235,7 @@ export function RuntimeProvider({ children }: { children: ReactNode }) { } let disposed = false; + let retryTimer: ReturnType | undefined; let workspaceRuntime: Awaited> | null = null; // Logging-only workspace id resolution inputs intentionally stay out of // this effect's dependency list. A cached id can create the runtime before @@ -308,6 +318,7 @@ export function RuntimeProvider({ children }: { children: ReactNode }) { } return; } + failedBootAttemptsRef.current = 0; setRuntime(workspaceRuntime); // Local runtime (IndexedDB-backed Loro repo) is ready — unblock UI // rendering immediately. WebSocket sync state is tracked separately @@ -332,6 +343,17 @@ export function RuntimeProvider({ children }: { children: ReactNode }) { setRuntime(null); setControlConnectionState('error'); setRuntimeInitializing(false); + // Try again, rather than leaving the workspace unusable until the user + // reloads the page. Backoff is the same curve the local reconnect loop + // uses, so a machine that is slow to come up is waited out at the same + // cost the rest of the provider already pays for one that is slow to + // reconnect. + const attempt = failedBootAttemptsRef.current; + failedBootAttemptsRef.current = attempt + 1; + retryTimer = setTimeout(() => { + retryTimer = undefined; + setBootGeneration((generation) => generation + 1); + }, computeLocalReconnectDelayMs(attempt)); } })(); @@ -340,6 +362,7 @@ export function RuntimeProvider({ children }: { children: ReactNode }) { workspaceSlug, }); disposed = true; + if (retryTimer !== undefined) clearTimeout(retryTimer); setRuntime(null); setControlConnectionState('idle'); // Reset to true for next initialization cycle @@ -366,6 +389,7 @@ export function RuntimeProvider({ children }: { children: ReactNode }) { workspaceSlug, effectiveWorkspaceId, localAgentRuntimeReady, + bootGeneration, ]); useEffect(() => {