diff --git a/apps/mesh/src/web/components/sandbox/hooks/sandbox-lifecycle-context.test.ts b/apps/mesh/src/web/components/sandbox/hooks/sandbox-lifecycle-context.test.ts index 6bf21507d8..73442fe4bd 100644 --- a/apps/mesh/src/web/components/sandbox/hooks/sandbox-lifecycle-context.test.ts +++ b/apps/mesh/src/web/components/sandbox/hooks/sandbox-lifecycle-context.test.ts @@ -3,6 +3,7 @@ import { selectVmEntry, shouldAutoStart, shouldSelfHeal, + shouldReconcileStaleCache, computeDrawerStatus, type BranchMapEntryLike, } from "./sandbox-lifecycle-context"; @@ -130,6 +131,47 @@ describe("shouldSelfHeal", () => { }); }); +describe("shouldReconcileStaleCache", () => { + const base = { + phase: "running" as string | null, + previewUrl: null as string | null, + userStopped: false, + alreadyReconciled: false, + }; + + test("running with no previewUrl in cache → true", () => { + expect(shouldReconcileStaleCache(base)).toBe(true); + }); + + test("previewUrl already present → false", () => { + expect( + shouldReconcileStaleCache({ ...base, previewUrl: "https://x" }), + ).toBe(false); + }); + + test("still booting (not running) → false", () => { + expect(shouldReconcileStaleCache({ ...base, phase: "starting" })).toBe( + false, + ); + }); + + test("no phase yet → false", () => { + expect(shouldReconcileStaleCache({ ...base, phase: null })).toBe(false); + }); + + test("user stopped → false", () => { + expect(shouldReconcileStaleCache({ ...base, userStopped: true })).toBe( + false, + ); + }); + + test("already reconciled for this arrival → false", () => { + expect( + shouldReconcileStaleCache({ ...base, alreadyReconciled: true }), + ).toBe(false); + }); +}); + describe("computeDrawerStatus", () => { test("suspended → suspended", () => { expect(computeDrawerStatus({ kind: "suspended" })).toBe("suspended"); diff --git a/apps/mesh/src/web/components/sandbox/hooks/sandbox-lifecycle-context.tsx b/apps/mesh/src/web/components/sandbox/hooks/sandbox-lifecycle-context.tsx index d3e7ec6590..ad415a52d6 100644 --- a/apps/mesh/src/web/components/sandbox/hooks/sandbox-lifecycle-context.tsx +++ b/apps/mesh/src/web/components/sandbox/hooks/sandbox-lifecycle-context.tsx @@ -73,6 +73,35 @@ export function shouldSelfHeal(args: ShouldSelfHealArgs): boolean { ); } +export interface ShouldReconcileStaleCacheArgs { + /** Latest LifecycleState phase from the SSE stream. */ + phase: string | null; + /** previewUrl resolved from the cached sandboxMap (null when the cache is stale). */ + previewUrl: string | null; + userStopped: boolean; + /** Whether we already invalidated for this sandbox arrival (dedup). */ + alreadyReconciled: boolean; +} + +/** + * The dev server reached `running` (SSE) but the cached VIRTUAL_MCP entity still + * has no previewUrl for this user/branch/kind — the sandboxMap row was written by + * a path that didn't invalidate React Query (agent-triggered start, another + * tab/device, an SSE reconnect onto an already-running pod, or the kind-keyed + * entry not yet fetched). Re-fetch once so previewUrl resolves and the preview + * unsticks without a manual page refresh. + */ +export function shouldReconcileStaleCache( + args: ShouldReconcileStaleCacheArgs, +): boolean { + return ( + args.phase === "running" && + !args.previewUrl && + !args.userStopped && + !args.alreadyReconciled + ); +} + export function computeDrawerStatus(state: PreviewState): DrawerStatus { switch (state.kind) { case "suspended": @@ -180,6 +209,10 @@ export function SandboxLifecycleProvider({ // branch-keyed (VmEventsBridge) dedup refs). const autoStartAttemptedForBranchRef = useRef>(new Set()); const reprovisionedForVmIdRef = useRef(null); + // Dedup for the stale-cache reconcile effect: records the + // `${vmId}:${branch}:${kind}` we last invalidated for, so a single `running` + // arrival re-fetches once (and a kind switch can reconcile again). + const staleReconciledRef = useRef(null); // Derived values, recomputed each render. // Cast: parseBranchMap returns SandboxRecord where sandboxProviderKind is @@ -294,6 +327,36 @@ export function SandboxLifecycleProvider({ setCurrentTaskBranch, ]); + // Stale-cache reconcile: the dev server is up (SSE `running`) but the cached + // VIRTUAL_MCP entity has no previewUrl for this user/branch/kind — the + // sandboxMap row landed without a client-side invalidation. Re-fetch once so + // previewUrl resolves and the preview (and Sections editor) mount without a + // hard refresh. Keyed on provider kind too, since selection is kind-scoped. + const reconcileKey = + virtualMcpId && branch + ? `${virtualMcpId}:${branch}:${sandboxProviderKind ?? ""}` + : null; + const reconcileStaleCache = shouldReconcileStaleCache({ + phase: events.lifecycle.phase, + previewUrl, + userStopped, + // oxlint-disable-next-line ban-ref-current-assignment/ban-ref-current-assignment -- read-only dedup probe; recorded inside effect after invalidating + alreadyReconciled: staleReconciledRef.current === reconcileKey, + }); + // oxlint-disable-next-line ban-use-effect/ban-use-effect -- bridges SSE lifecycle into a one-shot query invalidation; no render-time equivalent + useEffect(() => { + if (previewUrl) { + // Cache caught up — clear dedup so a later cold restart can reconcile again. + // oxlint-disable-next-line ban-ref-current-assignment/ban-ref-current-assignment -- reset dedup once the entry is present + staleReconciledRef.current = null; + return; + } + if (!reconcileStaleCache || !reconcileKey) return; + // oxlint-disable-next-line ban-ref-current-assignment/ban-ref-current-assignment -- record arrival to invalidate at most once + staleReconciledRef.current = reconcileKey; + invalidateVirtualMcpQueries(queryClient); + }, [reconcileStaleCache, reconcileKey, previewUrl, queryClient]); + // User-driven actions. const start = () => { if (!virtualMcpId) return; @@ -342,6 +405,8 @@ export function SandboxLifecycleProvider({ autoStartAttemptedForBranchRef.current = new Set(); // oxlint-disable-next-line ban-ref-current-assignment/ban-ref-current-assignment -- reset dedup so self-heal can fire on next gone reprovisionedForVmIdRef.current = null; + // oxlint-disable-next-line ban-ref-current-assignment/ban-ref-current-assignment -- reset dedup so a fresh cold start can reconcile again + staleReconciledRef.current = null; startVmReset(); start(); };