From 2396e5e3e583a9dd8d237602372e5b66a780e6ac Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:01:11 -0700 Subject: [PATCH 01/45] fix(browser-pane): reschedule remote stream restart with bounded backoff (STA-3483) (#12787) --- config/reliability-gates.jsonc | 114 +- .../components/browser-pane/BrowserPane.tsx | 910 +++++---------- .../remote-browser-page-session.ts | 148 +++ .../remote-browser-screencast-subscription.ts | 111 ++ .../remote-browser-stream-errors.test.ts | 72 ++ .../remote-browser-stream-errors.ts | 107 ++ .../remote-browser-stream-lifecycle-deps.ts | 26 + .../remote-browser-stream-lifecycle.test.ts | 1036 +++++++++++++++++ .../remote-browser-stream-lifecycle.ts | 373 ++++++ .../remote-browser-stream-liveness.ts | 67 ++ .../remote-browser-stream-restart-attempt.ts | 85 ++ ...e-browser-stream-restart-scheduler.test.ts | 231 ++++ ...remote-browser-stream-restart-scheduler.ts | 109 ++ .../remote-browser-stream-status.test.ts | 81 ++ .../remote-browser-stream-status.ts | 95 ++ .../remote-browser-stream-tokens.ts | 146 +++ src/renderer/src/i18n/locales/en.json | 7 + ...ed-remote-browser-stream-reconnect.spec.ts | 266 +++++ 18 files changed, 3345 insertions(+), 639 deletions(-) create mode 100644 src/renderer/src/components/browser-pane/remote-browser-page-session.ts create mode 100644 src/renderer/src/components/browser-pane/remote-browser-screencast-subscription.ts create mode 100644 src/renderer/src/components/browser-pane/remote-browser-stream-errors.test.ts create mode 100644 src/renderer/src/components/browser-pane/remote-browser-stream-errors.ts create mode 100644 src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle-deps.ts create mode 100644 src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.test.ts create mode 100644 src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.ts create mode 100644 src/renderer/src/components/browser-pane/remote-browser-stream-liveness.ts create mode 100644 src/renderer/src/components/browser-pane/remote-browser-stream-restart-attempt.ts create mode 100644 src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.test.ts create mode 100644 src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.ts create mode 100644 src/renderer/src/components/browser-pane/remote-browser-stream-status.test.ts create mode 100644 src/renderer/src/components/browser-pane/remote-browser-stream-status.ts create mode 100644 src/renderer/src/components/browser-pane/remote-browser-stream-tokens.ts create mode 100644 tests/e2e/paired-remote-browser-stream-reconnect.spec.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index f7935dbfc1f..056d295c677 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-08-04", + "updatedAt": "2026-08-07", "policy": { "maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"], "blockingPromotion": { @@ -1361,6 +1361,118 @@ ], "demotionRule": "Demote if a retired runtime transport can leave a renderer subscription without a close event, if a close is delivered more than once, if one failing teardown strands its sibling subscriptions, if payload frames from a retired transport reach the renderer, or if a revealed remote terminal can stay blank or reject input after a reconnect." }, + { + "id": "browser-stream.bounded-reconnect", + "title": "Remote browser streams recover within a bounded retry budget", + "maturity": "experimental", + "protection": "partial", + "owner": "browser-runtime", + "layer": "renderer-state-machine-paired-runtime", + "surfaces": [ + "paired remote browser panes", + "browser screencast disconnect and reconnect", + "remote browser viewport restart" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["remote-runtime"], + "coveredPlatforms": ["macos", "linux"], + "coveredProviders": ["remote-runtime"], + "coverageNotes": "Deterministic fake-clock tests cover counted backoff, in-flight ownership and handoff, cancellation, supersession, transient and permanent failures, late stale rejection, silent hosts, flapping streams, viewport restarts, stale events, budget exhaustion, and manual recovery. A headless paired-runtime journey drops the real runtime connection, proves the bounded retry state and visible Reconnect control, reconnects the same host, and requires a newly painted frame. Headed paired-server, Windows, sleep/wake, and real network-partition evidence remain uncollected.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/12787", + "https://linear.app/stably/issue/STA-3483" + ], + "invariant": "A current remote browser stream that closes must retry transient failures on one counted, capped backoff chain. Cancellation, pane or runtime supersession, missing pages, and permanent failures stop that chain without stale state mutation. A stream that stays healthy refills the budget; a silent or flapping stream cannot retry forever. Exhaustion and every non-retrying failure leave an actionable Reconnect control, and manual reconnect creates a fresh stream without resurrecting stale subscriptions.", + "oracle": "With one fake clock and exact pane, operation, stream, page, and runtime identities, drop a confirmed stream and require the counted delay sequence, one active chain, stale-token rejection, exact cancellation, full cleanup, and either a new live subscription or one stopped state with Reconnect after exhaustion. Inject transport error without close, close racing rejection, never-ready subscriptions, ready-then-end flapping, viewport supersession, missing pages, and permanent failures. In a separate paired client against an isolated headless Orca host, paint one remote frame, disconnect the owning runtime, require the bounded-retry notice and Reconnect control, reconnect, click once, and require a different frame object URL from the recovered stream.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/browser-pane/remote-browser-stream-errors.test.ts src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.test.ts src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.test.ts src/renderer/src/components/browser-pane/remote-browser-stream-status.test.ts", + "pnpm exec playwright test tests/e2e/paired-remote-browser-stream-reconnect.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" + ], + "testFiles": [ + "src/renderer/src/components/browser-pane/remote-browser-stream-errors.test.ts", + "src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.test.ts", + "src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.test.ts", + "src/renderer/src/components/browser-pane/remote-browser-stream-status.test.ts", + "tests/e2e/paired-remote-browser-stream-reconnect.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.test.ts", + "assertions": [ + "transient failures retry until recovery on counted delays", + "budget exhaustion stops exactly once", + "cancellation retires an in-flight attempt without re-arming", + "a close during an in-flight attempt queues one replacement chain" + ] + }, + { + "file": "src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.test.ts", + "assertions": [ + "dropped, silent, and flapping streams converge on a bounded retry or actionable stop", + "stale, superseded, missing-page, and permanent-failure work cannot mutate the current pane", + "manual reopen starts a fresh budget and adopts one current subscription", + "a late stale rejection cannot clear the replacement stream's ready deadline" + ] + }, + { + "file": "tests/e2e/paired-remote-browser-stream-reconnect.spec.ts", + "assertions": [ + "a real paired-runtime disconnect ends with an actionable Reconnect control", + "manual reconnect paints a new remote frame instead of retaining only the frozen frame", + "a pane whose first open fails can recover from no prior subscription" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-07", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/browser-pane/remote-browser-stream-errors.test.ts src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.test.ts src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.test.ts src/renderer/src/components/browser-pane/remote-browser-stream-status.test.ts", + "result": "passed", + "durationSeconds": 0.36, + "summary": "Four focused files passed 61 deterministic error, status, scheduler, lifecycle, in-flight ownership, stale-rejection, liveness, and cleanup tests." + }, + { + "date": "2026-08-07", + "runner": "ci", + "platform": "linux", + "command": "pnpm exec playwright test tests/e2e/paired-remote-browser-stream-reconnect.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 59.8, + "summary": "Both isolated headless paired-runtime scenarios passed: bounded recovery after a live-stream disconnect and manual recovery when the first stream never opened." + } + ], + "runtimeBudget": { + "p95Seconds": 120, + "scope": "focused fake-clock renderer contracts plus one isolated headless paired-runtime journey" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "The deterministic suite and one changed-E2E CI run passed; soak history has not started." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "The byte-identical scheduler oracle fails with the reschedule disabled at one attempt instead of four and passes after restoration. On the PR head, the delayed handoff oracle counted two scheduled attempts instead of one, and the late stale-rejection oracle lost the replacement ready deadline and never offered reconnect; both pass after the ownership fixes. Current main structurally contains the original one-shot timer dead end. The focused suite passes 61 tests, and the isolated paired-runtime CI journey passes both disconnect and cold-open recovery scenarios." + }, + "performanceBudget": { + "required": true, + "evidence": "Each pane retains at most one retry timer, one in-flight attempt, and one ready deadline. Automatic recovery makes at most five counted attempts with no global scan, subprocess, polling loop, or cross-pane fanout; exhaustion stops background work until explicit user reconnect." + }, + "promotionCriteria": [ + "Run the same journey against a headed Orca desktop server and retain independent host/client state evidence.", + "Collect Linux and physical Windows paired-server evidence plus sleep/wake or network-partition recovery.", + "Collect 100 consecutive focused CI passes or 14 days without an unexplained flake.", + "Replace the E2E's post-exhaustion elapsed-time check with an exact subscribe-attempt oracle." + ], + "knownGaps": [ + "The paired journey covers headless orca serve, not the primary headed desktop-server topology.", + "The live fault is an explicit disconnect rather than sleep/wake or a real network partition.", + "The E2E observes UI convergence and new frame identity but does not count screencast subscribe attempts directly.", + "Live Linux and Windows evidence is uncollected." + ], + "demotionRule": "Keep experimental or demote if retries can stop before recovery or exhaustion, continue after exhaustion, run in parallel, mutate a superseded pane, hide the Reconnect control, leak a subscription or timer, or fail to paint a new frame after manual reconnect." + }, { "id": "editor.live-log-append-stability", "title": "Long live session logs retain their Monaco viewport while appending", diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index d1aca3c49b6..4ed12bc4f7e 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -84,6 +84,13 @@ import { } from '../../../../shared/browser-viewport-presets' import { rememberLiveBrowserUrl } from './browser-runtime' import { ensureBrowserPageWebview } from './browser-page-webview' +import { RemoteBrowserStreamLifecycle } from './remote-browser-stream-lifecycle' +import { isRemoteBrowserPageMissingError } from './remote-browser-stream-errors' +import type { + RemoteBrowserOperationToken, + RemoteBrowserStreamToken, + RemoteBrowserViewportSize +} from './remote-browser-stream-tokens' import { destroyPersistentWebview, isBrowserPageRendererRecoveryPending, @@ -157,7 +164,6 @@ import { import { callRuntimeRpc, runtimeEnvironmentSupportsCapability, - RuntimeRpcCallError, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' @@ -165,15 +171,12 @@ import type { BrowserBackResult, BrowserGotoResult, BrowserReloadResult, - BrowserScreencastResult, - BrowserTabInfo, - RuntimeStatus + BrowserTabInfo } from '../../../../shared/runtime-types' import { decodeBrowserScreencastFrame, type BrowserScreencastFrameMetadata } from '../../../../shared/browser-screencast-protocol' -import { withBrowserPaneUiRuntimeRpcSource } from '../../../../shared/runtime-rpc-feature-interaction-source' import { formatByteCount, formatPermissionNotice, formatPopupNotice } from './browser-notices' import { getDriverForBrowserPage, @@ -195,6 +198,13 @@ import { createBrowserPageGuestRecovery } from './browser-page-guest-recovery' import { subscribeBrowserSystemResume } from './browser-system-resume' +import { + canReconnectRemoteBrowserStream, + isRemoteBrowserStreamBusy, + REMOTE_BROWSER_STREAM_IDLE, + remoteBrowserStreamNotice, + type RemoteBrowserStreamStatus +} from './remote-browser-stream-status' import { type BrowserReloadTrigger, reloadBrowserPageWebview, @@ -281,26 +291,6 @@ function decodeRemoteBrowserFrameUrl(url: string): Promise { }) } -type RemoteBrowserStreamToken = { - tabId: string - environmentId: string - remotePageId: string - generation: number - operationGeneration: number -} - -type RemoteBrowserStreamSubscription = { - token: RemoteBrowserStreamToken - unsubscribe: () => void -} - -type RemoteBrowserOperationToken = { - tabId: string - environmentId: string - remotePageId: string | null - generation: number -} - type RemoteBrowserContextMenu = { x: number y: number @@ -309,11 +299,6 @@ type RemoteBrowserContextMenu = { selectionText: string } -type RemoteBrowserViewportSize = { - width: number - height: number -} - function getBrowserPageRuntimeEnvironmentId( page: BrowserPageState, inferredRuntimeEnvironmentId: string | null | undefined @@ -547,26 +532,32 @@ function PendingBrowserAnnotationCard({ ) } +// The pane-owned effects the stream lifecycle calls back into: frame paint, viewport measurement, +// and the store/tab-close decision for a page that is gone. +type RemoteBrowserStreamBridge = { + applyTabInfo: (tab: Pick) => void + clearFrame: () => void + handleFrameBytes: (token: RemoteBrowserStreamToken, bytes: Uint8Array) => void + closeMissingRemotePage: (remotePageId: string | null) => void + waitForViewportSize: () => Promise + syncViewport: (pageId: string) => Promise +} + +const NO_REMOTE_BROWSER_STREAM_BRIDGE: RemoteBrowserStreamBridge = { + applyTabInfo: () => {}, + clearFrame: () => {}, + handleFrameBytes: () => {}, + closeMissingRemotePage: () => {}, + waitForViewportSize: async () => null, + syncViewport: async () => {} +} + function browserPageExists(tabId: string): boolean { return Object.values(useAppStore.getState().browserPagesByWorkspace).some((pages) => pages.some((page) => page.id === tabId) ) } -function isRemoteBrowserPageMissingError(error: unknown): boolean { - if (error instanceof RuntimeRpcCallError) { - return isRemoteBrowserPageMissingCode(error.code) - } - if (!error || typeof error !== 'object' || !('code' in error)) { - return false - } - return isRemoteBrowserPageMissingCode((error as { code: unknown }).code) -} - -function isRemoteBrowserPageMissingCode(code: unknown): boolean { - return code === 'browser_tab_not_found' || code === 'browser_no_tab' -} - function buildLoadError(event: { errorCode?: number errorDescription?: string @@ -701,16 +692,6 @@ function getPositiveFiniteNumber(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null } -function areRemoteViewportSizesNear( - a: RemoteBrowserViewportSize | null, - b: RemoteBrowserViewportSize | null -): boolean { - if (!a || !b) { - return false - } - return Math.abs(a.width - b.width) <= 3 && Math.abs(a.height - b.height) <= 3 -} - function getRemoteBrowserDeviceScaleFactor(): number { if (typeof window === 'undefined') { return 1 @@ -931,41 +912,59 @@ function RemoteBrowserPagePane({ const [addressBarValue, setAddressBarValue] = useState(toDisplayUrl(browserTab.url)) const [frameUrl, setFrameUrl] = useState(null) const [frameMetadata, setFrameMetadata] = useState(null) - const [remoteError, setRemoteError] = useState(null) + // Pane-owned notices, split by what they are ABOUT, because that decides who outranks whom: + // + // 'direct' — feedback on what the user just did (URL validation). Always shown: it is the + // only response to their action, and suppressing it makes Enter look broken. + // 'consequence' — an operation that failed BECAUSE the stream is down (input, navigation RPCs). + // Outranked by the stream's own notice, which explains the cause; otherwise + // these repaint raw transport text over it on every stray click. + // + // Kept as one slot so the newest notice replaces the previous one, as a single toast should. + const [paneNotice, setPaneNotice] = useState<{ + kind: 'direct' | 'consequence' + text: string + } | null>(null) + // The single source for what the stream is doing. busy, the notice, and whether the reconnect + // control renders are all derived below, so they cannot disagree — see + // remote-browser-stream-status.ts for the four ways they used to. + const [streamStatus, setStreamStatus] = useState( + REMOTE_BROWSER_STREAM_IDLE + ) + // Bumped by Reconnect to re-run the open effect from scratch. See reconnectRemoteStream. + const [reopenNonce, setReopenNonce] = useState(0) const [contextMenu, setContextMenu] = useState(null) - const [busy, setBusy] = useState(false) + const [paneBusy, setPaneBusy] = useState(false) + // Derived, never stored. The stream's own notice wins over an incidental one: while the stream is + // down every input RPC fails as a matter of course, and those failures must not overwrite the + // message that explains why — nor can the reconnect control depend on one of them being present. + // A stopped stream delivers no frames that could clear paneBusy, so it must force busy off. + const busy = + streamStatus.kind === 'stopped' ? false : paneBusy || isRemoteBrowserStreamBusy(streamStatus) + const streamNotice = remoteBrowserStreamNotice(streamStatus) + const remoteError = + paneNotice?.kind === 'direct' ? paneNotice.text : (streamNotice ?? paneNotice?.text ?? null) const contextMenuRef = useRef(null) - const remotePageIdRef = useRef(null) const remoteViewportSizeRef = useRef(null) const remoteCssViewportSizeRef = useRef(null) - const remoteStreamViewportSizeRef = useRef(null) const remoteViewportTimerRef = useRef(null) const streamFrameUrlRef = useRef(null) - const streamSubscriptionRef = useRef(null) - const streamRestartTimerRef = useRef(null) - const remoteTabRefreshTimerRef = useRef(null) const remoteInputQueueRef = useRef>(Promise.resolve()) const pendingRemoteWheelRef = useRef(null) const remoteWheelFrameRef = useRef(null) const remoteWheelInFlightRef = useRef(false) const pendingFrameDecodeRef = useRef(0) - const streamGenerationRef = useRef(0) - const remoteOperationGenerationRef = useRef(0) - const activeStreamTokenRef = useRef(null) const mountedRef = useRef(true) const isActiveRef = useRef(isActive) const currentBrowserTabIdRef = useRef(browserTab.id) const currentBrowserTabUrlRef = useRef(browserTab.url) const runtimeWorktree = useMemo(() => toRuntimeWorktreeSelector(worktreeId), [worktreeId]) + const runtimeWorktreeRef = useRef(runtimeWorktree) const activeRuntimeEnvironmentIdRef = useRef(activeRuntimeEnvironmentId) - const startRemoteStreamRef = useRef< - (pageId: string) => Promise - >(async () => null) - const restartRemoteStreamForViewportRef = useRef<(pageId: string) => void>(() => {}) - const fetchRemoteTabInfoRef = useRef< - (token: RemoteBrowserOperationToken) => Promise - >(async () => null) - const setRemoteBrowserPageHandle = useAppStore((s) => s.setRemoteBrowserPageHandle) + // Why: the stream lifecycle is built once per pane, before the callbacks it needs exist. It + // reaches them through this bridge so it never captures a render's stale closure. + const streamBridgeRef = useRef(NO_REMOTE_BROWSER_STREAM_BRIDGE) + const lifecycleRef = useRef(null) const certificateFailure = useAppStore( (s) => s.browserCertificateFailuresByPageId[browserTab.id] ?? null ) @@ -1006,10 +1005,60 @@ function RemoteBrowserPagePane({ } }, [remoteCertificateEnvironmentId, certificateChallengeId]) - currentBrowserTabIdRef.current = browserTab.id - currentBrowserTabUrlRef.current = browserTab.url - activeRuntimeEnvironmentIdRef.current = activeRuntimeEnvironmentId - isActiveRef.current = isActive + useLayoutEffect(() => { + currentBrowserTabIdRef.current = browserTab.id + currentBrowserTabUrlRef.current = browserTab.url + activeRuntimeEnvironmentIdRef.current = activeRuntimeEnvironmentId + isActiveRef.current = isActive + runtimeWorktreeRef.current = runtimeWorktree + }, [activeRuntimeEnvironmentId, browserTab.id, browserTab.url, isActive, runtimeWorktree]) + + if (!lifecycleRef.current) { + lifecycleRef.current = new RemoteBrowserStreamLifecycle({ + identity: { + isMounted: () => mountedRef.current, + isActive: () => isActiveRef.current, + getTabId: () => currentBrowserTabIdRef.current, + getEnvironmentId: () => activeRuntimeEnvironmentIdRef.current, + browserPageExists + }, + callRpc: callRuntimeRpc, + subscribeScreencast: (args, callbacks) => + window.api.runtimeEnvironments.subscribe(args, callbacks), + getWorktreeSelector: () => runtimeWorktreeRef.current, + getCurrentUrl: () => currentBrowserTabUrlRef.current, + readStoredHandle: () => + useAppStore.getState().remoteBrowserPageHandlesByPageId[currentBrowserTabIdRef.current] ?? + null, + writeStoredHandle: (handle) => + useAppStore.getState().setRemoteBrowserPageHandle(currentBrowserTabIdRef.current, handle), + removeStoredHandle: (remotePageId) => { + useAppStore + .getState() + .removeRemoteBrowserPageHandle(currentBrowserTabIdRef.current, remotePageId) + }, + getDeviceScaleFactor: getRemoteBrowserDeviceScaleFactor, + setStatus: (status) => { + setStreamStatus(status) + // Why every status change clears the pane's notice, not just the recovering ones: a pane + // notice describes the situation the PREVIOUS status described, so any transition makes it + // stale. Clearing only on live/opening left a 'direct' notice — which outranks the stream's + // own — on screen after the stream stopped, so a stranded pane showed "Enter a valid http(s) + // or localhost URL." beside its Reconnect button and never showed the actual cause. + // It also has no other owner: nothing else would dismiss it. + setPaneNotice(null) + }, + applyTabInfo: (tab) => streamBridgeRef.current.applyTabInfo(tab), + clearFrame: () => streamBridgeRef.current.clearFrame(), + handleFrameBytes: (token, bytes) => streamBridgeRef.current.handleFrameBytes(token, bytes), + closeMissingRemotePage: (remotePageId) => + streamBridgeRef.current.closeMissingRemotePage(remotePageId), + waitForViewportSize: () => streamBridgeRef.current.waitForViewportSize(), + readViewportSize: () => remoteViewportSizeRef.current, + syncViewport: (pageId) => streamBridgeRef.current.syncViewport(pageId) + }) + } + const lifecycle = lifecycleRef.current const runtimeTarget = useCallback(() => { return activeRuntimeEnvironmentId @@ -1025,13 +1074,13 @@ function RemoteBrowserPagePane({ const prevUrl = streamFrameUrlRef.current streamFrameUrlRef.current = null remoteCssViewportSizeRef.current = null - remoteStreamViewportSizeRef.current = null + lifecycle.forgetStreamViewportSize() setFrameMetadata(null) setFrameUrl(null) if (prevUrl) { URL.revokeObjectURL(prevUrl) } - }, []) + }, [lifecycle]) const clearPendingRemoteWheel = useCallback((): void => { pendingRemoteWheelRef.current = null @@ -1043,33 +1092,20 @@ function RemoteBrowserPagePane({ }, []) const closeMissingRemotePage = useCallback( - (remotePageId: string | null = remotePageIdRef.current): void => { + (remotePageId: string | null = lifecycle.tokens.remotePage): void => { const state = useAppStore.getState() if (remotePageId) { state.removeRemoteBrowserPageHandle(browserTab.id, remotePageId) } - remotePageIdRef.current = null - remoteOperationGenerationRef.current += 1 - streamGenerationRef.current += 1 - activeStreamTokenRef.current = null - streamSubscriptionRef.current?.unsubscribe() - streamSubscriptionRef.current = null - if (streamRestartTimerRef.current !== null) { - window.clearTimeout(streamRestartTimerRef.current) - streamRestartTimerRef.current = null - } + lifecycle.abandonRemotePage() if (remoteViewportTimerRef.current !== null) { window.clearTimeout(remoteViewportTimerRef.current) remoteViewportTimerRef.current = null } - if (remoteTabRefreshTimerRef.current !== null) { - window.clearTimeout(remoteTabRefreshTimerRef.current) - remoteTabRefreshTimerRef.current = null - } remoteInputQueueRef.current = Promise.resolve() clearStreamFrame() - setRemoteError(null) - setBusy(false) + setPaneNotice(null) + setPaneBusy(false) // Why: a runtime-side tab close mirrors closing the visible tab; don't leave a dead pane behind. const workspacePageCount = state.browserPagesByWorkspace[browserTab.workspaceId]?.length ?? 0 if (workspacePageCount <= 1) { @@ -1078,7 +1114,14 @@ function RemoteBrowserPagePane({ } closeBrowserPage(browserTab.id) }, - [browserTab.id, browserTab.workspaceId, clearStreamFrame, closeBrowserPage, closeBrowserTab] + [ + browserTab.id, + browserTab.workspaceId, + clearStreamFrame, + closeBrowserPage, + closeBrowserTab, + lifecycle + ] ) const rememberRemoteViewportSize = useCallback( @@ -1178,57 +1221,14 @@ function RemoteBrowserPagePane({ }, []) const createRemoteOperationToken = useCallback( - (remotePageId: string | null = null): RemoteBrowserOperationToken | null => { - const target = runtimeTarget() - if (!target) { - return null - } - return { - tabId: browserTab.id, - environmentId: target.environmentId, - remotePageId, - generation: remoteOperationGenerationRef.current - } - }, - [browserTab.id, runtimeTarget] + (remotePageId: string | null = null): RemoteBrowserOperationToken | null => + lifecycle.tokens.createOperationToken(remotePageId), + [lifecycle] ) const isCurrentRemoteOperationToken = useCallback( - (token: RemoteBrowserOperationToken): boolean => - mountedRef.current && - isActiveRef.current && - browserPageExists(token.tabId) && - currentBrowserTabIdRef.current === token.tabId && - activeRuntimeEnvironmentIdRef.current === token.environmentId && - remoteOperationGenerationRef.current === token.generation && - (token.remotePageId === null || remotePageIdRef.current === token.remotePageId), - [] - ) - - const isCurrentRemoteStreamOperation = useCallback( - (token: RemoteBrowserStreamToken): boolean => - isCurrentRemoteOperationToken({ - tabId: token.tabId, - environmentId: token.environmentId, - remotePageId: token.remotePageId, - generation: token.operationGeneration - }), - [isCurrentRemoteOperationToken] - ) - - const isCurrentRemoteStreamToken = useCallback( - (token: RemoteBrowserStreamToken): boolean => { - const activeToken = activeStreamTokenRef.current - return ( - activeToken?.generation === token.generation && - activeToken.operationGeneration === token.operationGeneration && - activeToken.tabId === token.tabId && - activeToken.environmentId === token.environmentId && - activeToken.remotePageId === token.remotePageId && - isCurrentRemoteStreamOperation(token) - ) - }, - [isCurrentRemoteStreamOperation] + (token: RemoteBrowserOperationToken): boolean => lifecycle.tokens.isCurrent(token), + [lifecycle] ) useEffect(() => { @@ -1236,38 +1236,32 @@ function RemoteBrowserPagePane({ mountedRef.current = true return () => { mountedRef.current = false - remoteOperationGenerationRef.current += 1 - streamGenerationRef.current += 1 pendingFrameDecodeRef.current += 1 - activeStreamTokenRef.current = null - remoteStreamViewportSizeRef.current = null - if (streamRestartTimerRef.current !== null) { - window.clearTimeout(streamRestartTimerRef.current) - streamRestartTimerRef.current = null - } + lifecycle.dispose() if (remoteViewportTimerRef.current !== null) { window.clearTimeout(remoteViewportTimerRef.current) remoteViewportTimerRef.current = null } - if (remoteTabRefreshTimerRef.current !== null) { - window.clearTimeout(remoteTabRefreshTimerRef.current) - remoteTabRefreshTimerRef.current = null - } clearPendingRemoteWheel() - restartRemoteStreamForViewportRef.current = () => {} if (streamFrameUrlRef.current) { URL.revokeObjectURL(streamFrameUrlRef.current) streamFrameUrlRef.current = null } } - }, [clearPendingRemoteWheel]) + }, [clearPendingRemoteWheel, lifecycle]) useEffect(() => { // Why: only reset frame/wheel on identity change; bumping the stream/operation generations here races the streaming effect and wedges the pane. - remoteStreamViewportSizeRef.current = null + lifecycle.forgetStreamViewportSize() clearPendingRemoteWheel() clearStreamFrame() - }, [activeRuntimeEnvironmentId, browserTab.id, clearPendingRemoteWheel, clearStreamFrame]) + }, [ + activeRuntimeEnvironmentId, + browserTab.id, + clearPendingRemoteWheel, + clearStreamFrame, + lifecycle + ]) useEffect(() => { if (!isActive) { @@ -1284,12 +1278,12 @@ function RemoteBrowserPagePane({ } remoteViewportTimerRef.current = window.setTimeout(() => { remoteViewportTimerRef.current = null - const pageId = remotePageIdRef.current + const pageId = lifecycle.tokens.remotePage if (!pageId || !isActiveRef.current) { return } void syncRemoteViewport(pageId) - .then(() => restartRemoteStreamForViewportRef.current(pageId)) + .then(() => lifecycle.restartForViewport(pageId)) .catch(() => {}) }, 150) } @@ -1303,7 +1297,7 @@ function RemoteBrowserPagePane({ remoteViewportTimerRef.current = null } } - }, [isActive, readRemoteViewportSize, syncRemoteViewport]) + }, [isActive, lifecycle, readRemoteViewportSize, syncRemoteViewport]) useEffect(() => { if (document.activeElement === addressBarInputRef.current) { @@ -1353,7 +1347,7 @@ function RemoteBrowserPagePane({ return } return () => { - const remotePageId = remotePageIdRef.current + const remotePageId = lifecycle.tokens.remotePage if (!remotePageId) { return } @@ -1364,7 +1358,7 @@ function RemoteBrowserPagePane({ return } const removedHandle = state.removeRemoteBrowserPageHandle(browserTab.id, remotePageId) - remotePageIdRef.current = null + lifecycle.tokens.setRemotePage(null) if (!removedHandle) { return } @@ -1376,7 +1370,7 @@ function RemoteBrowserPagePane({ { timeoutMs: 15_000, suppressFeatureInteraction: true } ).catch(() => {}) } - }, [activeRuntimeEnvironmentId, browserTab.id, runtimeWorktree, worktreeId]) + }, [activeRuntimeEnvironmentId, browserTab.id, lifecycle, runtimeWorktree, worktreeId]) const applyRemoteTabInfo = useCallback( (tab: Pick): void => { @@ -1396,7 +1390,7 @@ function RemoteBrowserPagePane({ const updateStreamFrame = useCallback( (token: RemoteBrowserStreamToken, bytes: Uint8Array): void => { - if (!isCurrentRemoteStreamToken(token)) { + if (!lifecycle.tokens.isCurrentStreamToken(token)) { return } const frame = decodeBrowserScreencastFrame(bytes) @@ -1416,7 +1410,7 @@ function RemoteBrowserPagePane({ .then(() => { if ( pendingFrameDecodeRef.current !== decodeGeneration || - !isCurrentRemoteStreamToken(token) + !lifecycle.tokens.isCurrentStreamToken(token) ) { URL.revokeObjectURL(nextUrl) return @@ -1425,7 +1419,7 @@ function RemoteBrowserPagePane({ streamFrameUrlRef.current = nextUrl setFrameMetadata(frame.metadata) setFrameUrl(nextUrl) - setBusy(false) + setPaneBusy(false) if (prevUrl) { URL.revokeObjectURL(prevUrl) } @@ -1434,7 +1428,7 @@ function RemoteBrowserPagePane({ URL.revokeObjectURL(nextUrl) }) }, - [isCurrentRemoteStreamToken] + [lifecycle] ) const getRemoteImagePoint = useCallback( @@ -1466,467 +1460,64 @@ function RemoteBrowserPagePane({ [frameMetadata] ) - const ensureRemotePage = useCallback( - async (token: RemoteBrowserOperationToken): Promise => { - if (!isCurrentRemoteOperationToken(token)) { - return null - } - const target = { kind: 'environment' as const, environmentId: token.environmentId } - const createRemotePage = async (): Promise => { - const currentUrl = currentBrowserTabUrlRef.current - const initialUrl = - currentUrl === ORCA_BROWSER_BLANK_URL ? 'about:blank' : currentUrl || 'about:blank' - const created = await callRuntimeRpc<{ browserPageId: string }>( - target, - 'browser.tabCreate', - { worktree: runtimeWorktree, url: initialUrl }, - { timeoutMs: 30_000, suppressFeatureInteraction: true } - ) - if (!isCurrentRemoteOperationToken(token)) { - void callRuntimeRpc( - target, - 'browser.tabClose', - { worktree: runtimeWorktree, page: created.browserPageId }, - { timeoutMs: 15_000, suppressFeatureInteraction: true } - ).catch(() => {}) - return null - } - remotePageIdRef.current = created.browserPageId - setRemoteBrowserPageHandle(browserTab.id, { - environmentId: target.environmentId, - remotePageId: created.browserPageId - }) - return created.browserPageId - } - - const existingHandle = useAppStore.getState().remoteBrowserPageHandlesByPageId[browserTab.id] - if (existingHandle?.environmentId === target.environmentId) { - const cachedToken = { ...token, remotePageId: existingHandle.remotePageId } - remotePageIdRef.current = existingHandle.remotePageId - try { - const cachedTab = await fetchRemoteTabInfoRef.current(cachedToken) - if (!cachedTab) { - return null - } - return existingHandle.remotePageId - } catch (error) { - if (!isRemoteBrowserPageMissingError(error)) { - throw error - } - useAppStore - .getState() - .removeRemoteBrowserPageHandle(browserTab.id, existingHandle.remotePageId) - if (remotePageIdRef.current === existingHandle.remotePageId) { - remotePageIdRef.current = null - } - if (!isCurrentRemoteOperationToken(token)) { - return null - } - closeMissingRemotePage(existingHandle.remotePageId) - return null - } - } - return createRemotePage() - }, - [ - browserTab.id, - closeMissingRemotePage, - isCurrentRemoteOperationToken, - setRemoteBrowserPageHandle, - runtimeWorktree - ] - ) - - const fetchRemoteTabInfo = useCallback( - async (token: RemoteBrowserOperationToken): Promise => { - if (!isCurrentRemoteOperationToken(token) || !token.remotePageId) { - return null - } - const shown = await callRuntimeRpc<{ tab: BrowserTabInfo }>( - { kind: 'environment', environmentId: token.environmentId }, - 'browser.tabShow', - { worktree: runtimeWorktree, page: token.remotePageId }, - { timeoutMs: 15_000, suppressFeatureInteraction: true } - ) - return shown.tab - }, - [isCurrentRemoteOperationToken, runtimeWorktree] - ) - fetchRemoteTabInfoRef.current = fetchRemoteTabInfo - const scheduleRemoteTabInfoRefresh = useCallback( (token: RemoteBrowserOperationToken, delayMs = 250): void => { - if (!isCurrentRemoteOperationToken(token)) { - return - } - if (remoteTabRefreshTimerRef.current !== null) { - window.clearTimeout(remoteTabRefreshTimerRef.current) - } - remoteTabRefreshTimerRef.current = window.setTimeout(() => { - remoteTabRefreshTimerRef.current = null - if (!isCurrentRemoteOperationToken(token)) { - return - } - void fetchRemoteTabInfo(token) - .then((tab) => { - if (tab && isCurrentRemoteOperationToken(token)) { - applyRemoteTabInfo(tab) - } - }) - .catch((error: unknown) => { - if (isCurrentRemoteOperationToken(token) && isRemoteBrowserPageMissingError(error)) { - closeMissingRemotePage(token.remotePageId) - } - }) - }, delayMs) + lifecycle.session.scheduleTabInfoRefresh(token, delayMs) }, - [applyRemoteTabInfo, closeMissingRemotePage, fetchRemoteTabInfo, isCurrentRemoteOperationToken] + [lifecycle] ) - const scheduleRemoteStreamRestart = useCallback( - (token: RemoteBrowserStreamToken): void => { - if (!isCurrentRemoteStreamOperation(token) || streamRestartTimerRef.current !== null) { - return - } - streamRestartTimerRef.current = window.setTimeout(() => { - streamRestartTimerRef.current = null - if (!isCurrentRemoteStreamOperation(token)) { - return - } - setBusy(true) - const operationToken: RemoteBrowserOperationToken = { - tabId: token.tabId, - environmentId: token.environmentId, - remotePageId: token.remotePageId, - generation: token.operationGeneration - } - void fetchRemoteTabInfo(operationToken) - .then((tab) => { - if (!tab || !isCurrentRemoteStreamOperation(token)) { - return - } - applyRemoteTabInfo(tab) - }) - .catch(() => {}) - .then(() => { - if (!isCurrentRemoteStreamOperation(token)) { - return null - } - return startRemoteStreamRef.current(token.remotePageId) - }) - .then((subscription) => { - if (!subscription) { - return - } - if (!isCurrentRemoteStreamToken(subscription.token)) { - subscription?.unsubscribe() - return - } - streamSubscriptionRef.current = subscription - }) - .catch((error: unknown) => { - if (!isCurrentRemoteStreamOperation(token)) { - return - } - if (isRemoteBrowserPageMissingError(error)) { - closeMissingRemotePage(token.remotePageId) - return - } - setRemoteError( - error instanceof Error ? error.message : 'Failed to restart remote browser stream.' - ) - setBusy(false) - }) - }, 500) - }, - [ - applyRemoteTabInfo, + // Publish only callbacks from a committed render. + useLayoutEffect(() => { + streamBridgeRef.current = { + applyTabInfo: applyRemoteTabInfo, + clearFrame: clearStreamFrame, + handleFrameBytes: updateStreamFrame, closeMissingRemotePage, - fetchRemoteTabInfo, - isCurrentRemoteStreamOperation, - isCurrentRemoteStreamToken - ] - ) + waitForViewportSize: waitForRemoteViewportSize, + syncViewport: syncRemoteViewport + } + }, [ + applyRemoteTabInfo, + clearStreamFrame, + closeMissingRemotePage, + syncRemoteViewport, + updateStreamFrame, + waitForRemoteViewportSize + ]) - const handleRemoteStreamClosed = useCallback( - (token: RemoteBrowserStreamToken, restart: boolean): void => { - if (!isCurrentRemoteStreamToken(token)) { - return - } - setBusy(restart) - const current = streamSubscriptionRef.current - streamSubscriptionRef.current = null - activeStreamTokenRef.current = null - remoteStreamViewportSizeRef.current = null - // Why: navigation recreates the screencast stream; keep the last frame during restart so panes don't flash the loading placeholder. - if (!restart) { - clearStreamFrame() - } - current?.unsubscribe() - if (restart) { - scheduleRemoteStreamRestart(token) - } - }, - [clearStreamFrame, isCurrentRemoteStreamToken, scheduleRemoteStreamRestart] - ) - - const startRemoteStream = useCallback( - async (pageId: string): Promise => { - const target = runtimeTarget() - if (!target) { - return null - } - const operationToken = createRemoteOperationToken(pageId) - if (!operationToken || !isCurrentRemoteOperationToken(operationToken)) { - return null - } - const status = await callRuntimeRpc(target, 'status.get', undefined, { - timeoutMs: 15_000 - }) - if (!status.capabilities?.includes('browser.screencast.v1')) { - throw new Error('The selected runtime does not support remote browser streaming.') - } - if (!isCurrentRemoteOperationToken(operationToken)) { - return null - } - const viewportSize = await waitForRemoteViewportSize() - remoteStreamViewportSizeRef.current = viewportSize - const token: RemoteBrowserStreamToken = { - tabId: browserTab.id, - environmentId: target.environmentId, - remotePageId: pageId, - generation: streamGenerationRef.current + 1, - operationGeneration: operationToken.generation - } - streamGenerationRef.current = token.generation - activeStreamTokenRef.current = token - try { - const subscription = await window.api.runtimeEnvironments.subscribe( - { - selector: target.environmentId, - method: 'browser.screencast', - params: withBrowserPaneUiRuntimeRpcSource({ - worktree: runtimeWorktree, - page: pageId, - format: 'jpeg', - quality: 70, - maxWidth: 3840, - maxHeight: 2160, - viewportWidth: viewportSize?.width, - viewportHeight: viewportSize?.height, - deviceScaleFactor: getRemoteBrowserDeviceScaleFactor(), - everyNthFrame: 2 - }), - timeoutMs: 15_000 - }, - { - onResponse: (response) => { - if (!isCurrentRemoteStreamToken(token)) { - return - } - if (response.ok === false) { - if (isRemoteBrowserPageMissingCode(response.error.code)) { - closeMissingRemotePage(pageId) - return - } - setRemoteError(response.error.message) - handleRemoteStreamClosed(token, false) - return - } - const event = response.result as BrowserScreencastResult - if (event.type === 'ready') { - applyRemoteTabInfo(event.tab) - void syncRemoteViewport(event.browserPageId).catch(() => {}) - setBusy(false) - } else if (event.type === 'end') { - handleRemoteStreamClosed(token, true) - } else if (event.type === 'error') { - setRemoteError(event.message) - handleRemoteStreamClosed(token, false) - } - }, - onBinary: (bytes) => updateStreamFrame(token, bytes), - onError: (error) => { - if (!isCurrentRemoteStreamToken(token)) { - return - } - if (isRemoteBrowserPageMissingError(error)) { - closeMissingRemotePage(pageId) - return - } - setRemoteError(error.message) - setBusy(false) - }, - onClose: () => { - handleRemoteStreamClosed(token, true) - } - } - ) - return { token, unsubscribe: subscription.unsubscribe } - } catch (error) { - if (isCurrentRemoteStreamToken(token)) { - activeStreamTokenRef.current = null - } - throw error - } - }, - [ - applyRemoteTabInfo, - browserTab.id, - closeMissingRemotePage, - createRemoteOperationToken, - handleRemoteStreamClosed, - isCurrentRemoteOperationToken, - isCurrentRemoteStreamToken, - runtimeTarget, - syncRemoteViewport, - updateStreamFrame, - waitForRemoteViewportSize, - runtimeWorktree - ] - ) - - const restartRemoteStreamForViewport = useCallback( - (pageId: string): void => { - const current = streamSubscriptionRef.current - const nextViewportSize = remoteViewportSizeRef.current - if ( - !current || - current.token.remotePageId !== pageId || - !nextViewportSize || - areRemoteViewportSizesNear(remoteStreamViewportSizeRef.current, nextViewportSize) || - !isCurrentRemoteStreamToken(current.token) - ) { - return - } - - // Why: the runtime stream validates frames against its start viewport, so restart media after resize or new-size frames get rejected. - streamGenerationRef.current += 1 - activeStreamTokenRef.current = null - streamSubscriptionRef.current = null - remoteStreamViewportSizeRef.current = null - if (streamRestartTimerRef.current !== null) { - window.clearTimeout(streamRestartTimerRef.current) - streamRestartTimerRef.current = null - } - setBusy(true) - current.unsubscribe() - void startRemoteStreamRef - .current(pageId) - .then((subscription) => { - if (!subscription) { - if (mountedRef.current && isActiveRef.current && remotePageIdRef.current === pageId) { - setBusy(false) - } - return - } - if (!isCurrentRemoteStreamToken(subscription.token)) { - subscription.unsubscribe() - return - } - streamSubscriptionRef.current = subscription - }) - .catch((error: unknown) => { - if (!mountedRef.current || !isActiveRef.current || remotePageIdRef.current !== pageId) { - return - } - if (isRemoteBrowserPageMissingError(error)) { - closeMissingRemotePage(pageId) - return - } - setRemoteError( - error instanceof Error ? error.message : 'Failed to resize remote browser stream.' - ) - setBusy(false) - }) - }, - [closeMissingRemotePage, isCurrentRemoteStreamToken] - ) - - useEffect(() => { - startRemoteStreamRef.current = startRemoteStream - restartRemoteStreamForViewportRef.current = restartRemoteStreamForViewport - }, [restartRemoteStreamForViewport, startRemoteStream]) + const reconnectRemoteStream = useCallback((): void => { + // No status write here: bumping the nonce re-runs the open effect, and open() publishes + // 'opening'. Setting it from two places is how the old three-variable version drifted. + setPaneNotice(null) + // Why re-run the whole open effect rather than resume the stream: reconnect has to work in the + // cases where there is nothing to resume — the remote page was never created, or the very first + // open failed. Resuming a token only covers a stream that once existed. + setReopenNonce((nonce) => nonce + 1) + }, []) useEffect(() => { if (!isActive) { return } - let cancelled = false - setBusy(true) - setRemoteError(null) - remoteOperationGenerationRef.current += 1 - streamGenerationRef.current += 1 - activeStreamTokenRef.current = null - streamSubscriptionRef.current?.unsubscribe() - streamSubscriptionRef.current = null - if (streamRestartTimerRef.current !== null) { - window.clearTimeout(streamRestartTimerRef.current) - streamRestartTimerRef.current = null - } - const operationToken = createRemoteOperationToken() - if (!operationToken) { - setBusy(false) - return - } - void ensureRemotePage(operationToken) - .then(async (pageId) => { - if (!pageId || cancelled || !isCurrentRemoteOperationToken(operationToken)) { - return - } - const pageToken = { ...operationToken, remotePageId: pageId } - const tab = await fetchRemoteTabInfo(pageToken) - if (tab && !cancelled && isCurrentRemoteOperationToken(pageToken)) { - applyRemoteTabInfo(tab) - } - if (cancelled || !isCurrentRemoteOperationToken(pageToken)) { - return - } - const subscription = await startRemoteStream(pageId) - if (cancelled || !subscription) { - subscription?.unsubscribe() - return - } - if (!isCurrentRemoteStreamToken(subscription.token)) { - subscription.unsubscribe() - return - } - streamSubscriptionRef.current = subscription - }) - .catch((error: unknown) => { - if (!cancelled) { - if (isRemoteBrowserPageMissingError(error)) { - closeMissingRemotePage() - return - } - setRemoteError(error instanceof Error ? error.message : 'Failed to open remote browser.') - setBusy(false) - } - }) + const closeStream = lifecycle.open() return () => { - cancelled = true - remoteOperationGenerationRef.current += 1 - streamGenerationRef.current += 1 - activeStreamTokenRef.current = null + closeStream() clearPendingRemoteWheel() - streamSubscriptionRef.current?.unsubscribe() - streamSubscriptionRef.current = null - if (streamRestartTimerRef.current !== null) { - window.clearTimeout(streamRestartTimerRef.current) - streamRestartTimerRef.current = null - } } + // Why: the lifecycle reads tab/environment/worktree live, so it only needs to reopen when the + // pane's identity actually changes — not when an unrelated callback identity does. + // + // browserTab.id is load-bearing because lifecycle.open() reads tab identity through refs. + // reopenNonce re-runs the full open path for an explicit reconnect. }, [ + activeRuntimeEnvironmentId, + browserTab.id, clearPendingRemoteWheel, - createRemoteOperationToken, - ensureRemotePage, - fetchRemoteTabInfo, isActive, - closeMissingRemotePage, - isCurrentRemoteOperationToken, - isCurrentRemoteStreamToken, - applyRemoteTabInfo, - startRemoteStream + lifecycle, + reopenNonce, + runtimeWorktree ]) useEffect(() => { @@ -1978,7 +1569,7 @@ function RemoteBrowserPagePane({ if (!operationToken) { return } - const pageId = await ensureRemotePage(operationToken) + const pageId = await lifecycle.session.ensureRemotePage(operationToken) if (!pageId) { return } @@ -1986,8 +1577,8 @@ function RemoteBrowserPagePane({ if (!isCurrentRemoteOperationToken(pageToken)) { return } - setBusy(true) - setRemoteError(null) + setPaneBusy(true) + setPaneNotice(null) onUpdatePageState(browserTab.id, { loading: true, loadError: null }) try { const params = @@ -2009,7 +1600,7 @@ function RemoteBrowserPagePane({ return } const message = error instanceof Error ? error.message : 'Remote browser command failed.' - setRemoteError(message) + setPaneNotice({ kind: 'consequence', text: message }) onUpdatePageState(browserTab.id, { loading: false, // Why: validatedUrl is persisted, so redact the Kagi session token like the main-process failure path does. @@ -2021,7 +1612,7 @@ function RemoteBrowserPagePane({ }) } finally { if (isCurrentRemoteOperationToken(pageToken)) { - setBusy(false) + setPaneBusy(false) } } }, @@ -2030,7 +1621,7 @@ function RemoteBrowserPagePane({ browserTab.id, browserTab.url, createRemoteOperationToken, - ensureRemotePage, + lifecycle, closeMissingRemotePage, isCurrentRemoteOperationToken, onUpdatePageState, @@ -2078,7 +1669,9 @@ function RemoteBrowserPagePane({ }) if (!nextUrl) { const message = 'Enter a valid http(s) or localhost URL.' - setRemoteError(message) + // 'direct': the only response to what the user just typed. With an empty address bar no + // load-error overlay renders either, so outranking this would make Enter do nothing visible. + setPaneNotice({ kind: 'direct', text: message }) onUpdatePageState(browserTab.id, { loadError: { code: 0, @@ -2096,7 +1689,7 @@ function RemoteBrowserPagePane({ return } const target = runtimeTarget() - const pageId = remotePageIdRef.current + const pageId = lifecycle.tokens.remotePage const image = imageRef.current const operationToken = pageId ? createRemoteOperationToken(pageId) : null const point = getRemoteImagePoint(event) @@ -2110,7 +1703,7 @@ function RemoteBrowserPagePane({ event.preventDefault() image.focus() setContextMenu(null) - setRemoteError(null) + setPaneNotice(null) enqueueRemoteInput(async () => { if (!isCurrentRemoteOperationToken(operationToken)) { return @@ -2135,7 +1728,10 @@ function RemoteBrowserPagePane({ closeMissingRemotePage(pageId) return } - setRemoteError(error instanceof Error ? error.message : 'Remote mouse input failed.') + setPaneNotice({ + kind: 'consequence', + text: error instanceof Error ? error.message : 'Remote mouse input failed.' + }) } } }) @@ -2146,7 +1742,7 @@ function RemoteBrowserPagePane({ return } const target = runtimeTarget() - const pageId = remotePageIdRef.current + const pageId = lifecycle.tokens.remotePage const operationToken = pageId ? createRemoteOperationToken(pageId) : null const point = getRemoteImagePoint(event) const button = getRemoteBrowserMouseButton(event.button) @@ -2157,7 +1753,7 @@ function RemoteBrowserPagePane({ return } event.preventDefault() - setRemoteError(null) + setPaneNotice(null) enqueueRemoteInput(async () => { if (!isCurrentRemoteOperationToken(operationToken)) { return @@ -2183,7 +1779,10 @@ function RemoteBrowserPagePane({ closeMissingRemotePage(pageId) return } - setRemoteError(error instanceof Error ? error.message : 'Remote mouse input failed.') + setPaneNotice({ + kind: 'consequence', + text: error instanceof Error ? error.message : 'Remote mouse input failed.' + }) } } }) @@ -2194,14 +1793,14 @@ function RemoteBrowserPagePane({ return } const target = runtimeTarget() - const pageId = remotePageIdRef.current + const pageId = lifecycle.tokens.remotePage const point = getRemoteImagePoint(event) if (!target || !pageId || !point) { return } event.preventDefault() imageRef.current?.focus() - setRemoteError(null) + setPaneNotice(null) setContextMenu({ x: event.clientX, y: event.clientY, @@ -2256,7 +1855,7 @@ function RemoteBrowserPagePane({ return } const target = runtimeTarget() - const pageId = remotePageIdRef.current + const pageId = lifecycle.tokens.remotePage const operationToken = pageId ? createRemoteOperationToken(pageId) : null if (!target || !pageId || !operationToken) { return @@ -2267,7 +1866,7 @@ function RemoteBrowserPagePane({ return } event.preventDefault() - setRemoteError(null) + setPaneNotice(null) enqueueRemoteInput(async () => { if (!isCurrentRemoteOperationToken(operationToken)) { return @@ -2294,7 +1893,10 @@ function RemoteBrowserPagePane({ closeMissingRemotePage(pageId) return } - setRemoteError(error instanceof Error ? error.message : 'Remote keyboard input failed.') + setPaneNotice({ + kind: 'consequence', + text: error instanceof Error ? error.message : 'Remote keyboard input failed.' + }) } } }) @@ -2342,7 +1944,10 @@ function RemoteBrowserPagePane({ closeMissingRemotePage(pageId) return } - setRemoteError(error instanceof Error ? error.message : 'Remote scroll failed.') + setPaneNotice({ + kind: 'consequence', + text: error instanceof Error ? error.message : 'Remote scroll failed.' + }) } } }).finally(() => { @@ -2367,14 +1972,14 @@ function RemoteBrowserPagePane({ return } const target = runtimeTarget() - const pageId = remotePageIdRef.current + const pageId = lifecycle.tokens.remotePage const operationToken = pageId ? createRemoteOperationToken(pageId) : null const point = getRemoteImagePoint(event) if (!target || !pageId || !operationToken || !point) { return } event.preventDefault() - setRemoteError(null) + setPaneNotice(null) const deltaMultiplier = event.deltaMode === WHEEL_DELTA_LINE ? 16 @@ -2412,6 +2017,7 @@ function RemoteBrowserPagePane({ busy, createRemoteOperationToken, getRemoteImagePoint, + lifecycle, runtimeTarget, schedulePendingRemoteWheel ] @@ -2458,7 +2064,12 @@ function RemoteBrowserPagePane({ }) return ( -
+ // The testid scopes E2E queries to this pane: a workspace can hold more than one browser pane, + // and controls like the address bar are otherwise ambiguous across them. +
{contextMenu ? createPortal( <> @@ -2699,6 +2310,7 @@ function RemoteBrowserPagePane({ ) : null} {frameUrl ? ( ) : null} - {remoteError ? ( -
- {remoteError} + {/* Why the reconnect control also opens this toast: the control renders inside it, so + gating the toast on the message alone made an empty message ('' from a host that failed + with no text) swallow the user's only way back — the original stranding bug, reachable + through three sites that forward host-supplied text into `stopped`. */} + {remoteError || canReconnectRemoteBrowserStream(streamStatus) ? ( +
+ {remoteError} + {canReconnectRemoteBrowserStream(streamStatus) ? ( + + ) : null}
) : null}
diff --git a/src/renderer/src/components/browser-pane/remote-browser-page-session.ts b/src/renderer/src/components/browser-pane/remote-browser-page-session.ts new file mode 100644 index 00000000000..8cedddf81e4 --- /dev/null +++ b/src/renderer/src/components/browser-pane/remote-browser-page-session.ts @@ -0,0 +1,148 @@ +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { ORCA_BROWSER_BLANK_URL } from '../../../../shared/constants' +import type { BrowserTabInfo } from '../../../../shared/runtime-types' +import { isRemoteBrowserPageMissingError } from './remote-browser-stream-errors' +import type { + RemoteBrowserOperationToken, + RemoteBrowserOperationTokens +} from './remote-browser-stream-tokens' + +export type RemoteBrowserRpcCall = ( + target: RuntimeClientTarget, + method: string, + params?: unknown, + options?: { timeoutMs?: number; suppressFeatureInteraction?: boolean } +) => Promise + +export type RemoteBrowserPageHandle = { + environmentId: string + remotePageId: string +} + +export type RemoteBrowserPageSessionDeps = { + tokens: RemoteBrowserOperationTokens + callRpc: RemoteBrowserRpcCall + getWorktreeSelector(): string + getCurrentUrl(): string + readStoredHandle(): RemoteBrowserPageHandle | null + writeStoredHandle(handle: RemoteBrowserPageHandle): void + removeStoredHandle(remotePageId: string): void + applyTabInfo(tab: Pick): void + closeMissingRemotePage(remotePageId: string | null): void +} + +// Owns the runtime-side page this pane is bound to: creating or adopting it, reading its tab info, +// and the debounced post-input refresh. Split from the stream lifecycle so page identity stays +// testable on its own. +export class RemoteBrowserPageSession { + private tabRefreshTimer: ReturnType | null = null + + constructor(private readonly deps: RemoteBrowserPageSessionDeps) {} + + async fetchTabInfo(token: RemoteBrowserOperationToken): Promise { + if (!this.deps.tokens.isCurrent(token) || !token.remotePageId) { + return null + } + const shown = await this.deps.callRpc<{ tab: BrowserTabInfo }>( + { kind: 'environment', environmentId: token.environmentId }, + 'browser.tabShow', + { worktree: this.deps.getWorktreeSelector(), page: token.remotePageId }, + { timeoutMs: 15_000, suppressFeatureInteraction: true } + ) + return shown.tab + } + + async ensureRemotePage(token: RemoteBrowserOperationToken): Promise { + const { tokens } = this.deps + if (!tokens.isCurrent(token)) { + return null + } + const existingHandle = this.deps.readStoredHandle() + if (existingHandle?.environmentId !== token.environmentId) { + return this.createRemotePage(token) + } + tokens.setRemotePage(existingHandle.remotePageId) + try { + const cachedTab = await this.fetchTabInfo({ + ...token, + remotePageId: existingHandle.remotePageId + }) + return cachedTab ? existingHandle.remotePageId : null + } catch (error) { + if (!isRemoteBrowserPageMissingError(error)) { + throw error + } + this.deps.removeStoredHandle(existingHandle.remotePageId) + if (tokens.remotePage === existingHandle.remotePageId) { + tokens.setRemotePage(null) + } + if (tokens.isCurrent(token)) { + this.deps.closeMissingRemotePage(existingHandle.remotePageId) + } + return null + } + } + + scheduleTabInfoRefresh(token: RemoteBrowserOperationToken, delayMs = 250): void { + const { tokens } = this.deps + if (!tokens.isCurrent(token)) { + return + } + this.cancelTabInfoRefresh() + this.tabRefreshTimer = setTimeout(() => { + this.tabRefreshTimer = null + if (!tokens.isCurrent(token)) { + return + } + void this.fetchTabInfo(token) + .then((tab) => { + if (tab && tokens.isCurrent(token)) { + this.deps.applyTabInfo(tab) + } + }) + .catch((error: unknown) => { + if (tokens.isCurrent(token) && isRemoteBrowserPageMissingError(error)) { + this.deps.closeMissingRemotePage(token.remotePageId) + } + }) + }, delayMs) + } + + cancelTabInfoRefresh(): void { + if (this.tabRefreshTimer !== null) { + clearTimeout(this.tabRefreshTimer) + this.tabRefreshTimer = null + } + } + + private async createRemotePage(token: RemoteBrowserOperationToken): Promise { + const target: RuntimeClientTarget = { kind: 'environment', environmentId: token.environmentId } + const worktree = this.deps.getWorktreeSelector() + const currentUrl = this.deps.getCurrentUrl() + const initialUrl = + currentUrl === ORCA_BROWSER_BLANK_URL ? 'about:blank' : currentUrl || 'about:blank' + const created = await this.deps.callRpc<{ browserPageId: string }>( + target, + 'browser.tabCreate', + { worktree, url: initialUrl }, + { timeoutMs: 30_000, suppressFeatureInteraction: true } + ) + if (!this.deps.tokens.isCurrent(token)) { + void this.deps + .callRpc( + target, + 'browser.tabClose', + { worktree, page: created.browserPageId }, + { timeoutMs: 15_000, suppressFeatureInteraction: true } + ) + .catch(() => {}) + return null + } + this.deps.tokens.setRemotePage(created.browserPageId) + this.deps.writeStoredHandle({ + environmentId: target.environmentId, + remotePageId: created.browserPageId + }) + return created.browserPageId + } +} diff --git a/src/renderer/src/components/browser-pane/remote-browser-screencast-subscription.ts b/src/renderer/src/components/browser-pane/remote-browser-screencast-subscription.ts new file mode 100644 index 00000000000..9968722864f --- /dev/null +++ b/src/renderer/src/components/browser-pane/remote-browser-screencast-subscription.ts @@ -0,0 +1,111 @@ +import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' +import type { + BrowserScreencastReadyResult, + BrowserScreencastResult +} from '../../../../shared/runtime-types' +import { withBrowserPaneUiRuntimeRpcSource } from '../../../../shared/runtime-rpc-feature-interaction-source' +import { isRemoteBrowserPageMissingCode } from './remote-browser-stream-errors' +import type { RemoteBrowserViewportSize } from './remote-browser-stream-tokens' + +export type RemoteBrowserScreencastSubscribe = ( + args: { selector: string; method: string; params?: unknown; timeoutMs?: number }, + callbacks: { + onResponse: (response: RuntimeRpcResponse) => void + onBinary?: (bytes: Uint8Array) => void + onError?: (error: { code: string; message: string }) => void + onClose?: () => void + } +) => Promise<{ unsubscribe: () => void }> + +export type RemoteBrowserScreencastRequest = { + environmentId: string + worktree: string + pageId: string + viewportSize: RemoteBrowserViewportSize | null + deviceScaleFactor: number +} + +// Why: isCurrent gates the two channels the runtime can deliver a stale event on; frames and close +// carry their own supersession checks downstream, so they are forwarded unguarded. +export type RemoteBrowserScreencastEvents = { + isCurrent: () => boolean + onReady: (event: BrowserScreencastReadyResult) => void + onEnded: () => void + onFailed: (message: string) => void + // Why: a transport-level error reports without closing the stream, which the runtime still owns. + onTransportError: (message: string) => void + onPageMissing: () => void + onFrame: (bytes: Uint8Array) => void + onClosed: () => void +} + +// Translates one runtime screencast subscription's raw RPC envelope traffic into the semantic +// events a pane cares about. +export async function openRemoteBrowserScreencastStream( + subscribe: RemoteBrowserScreencastSubscribe, + request: RemoteBrowserScreencastRequest, + events: RemoteBrowserScreencastEvents +): Promise<{ unsubscribe: () => void }> { + return subscribe( + { + selector: request.environmentId, + method: 'browser.screencast', + params: withBrowserPaneUiRuntimeRpcSource({ + worktree: request.worktree, + page: request.pageId, + format: 'jpeg', + quality: 70, + maxWidth: 3840, + maxHeight: 2160, + viewportWidth: request.viewportSize?.width, + viewportHeight: request.viewportSize?.height, + deviceScaleFactor: request.deviceScaleFactor, + everyNthFrame: 2 + }), + timeoutMs: 15_000 + }, + { + onResponse: (response) => dispatchScreencastResponse(response, events), + onBinary: (bytes) => events.onFrame(bytes), + onError: (error) => { + if (!events.isCurrent()) { + return + } + if (isRemoteBrowserPageMissingCode(error.code)) { + events.onPageMissing() + return + } + events.onTransportError(error.message) + }, + onClose: () => events.onClosed() + } + ) +} + +function dispatchScreencastResponse( + response: RuntimeRpcResponse, + events: RemoteBrowserScreencastEvents +): void { + if (!events.isCurrent()) { + return + } + if (response.ok === false) { + if (isRemoteBrowserPageMissingCode(response.error.code)) { + events.onPageMissing() + return + } + events.onFailed(response.error.message) + return + } + if (!response.result || typeof response.result !== 'object' || !('type' in response.result)) { + return + } + const event = response.result as BrowserScreencastResult + if (event.type === 'ready') { + events.onReady(event) + } else if (event.type === 'end') { + events.onEnded() + } else if (event.type === 'error') { + events.onFailed(event.message) + } +} diff --git a/src/renderer/src/components/browser-pane/remote-browser-stream-errors.test.ts b/src/renderer/src/components/browser-pane/remote-browser-stream-errors.test.ts new file mode 100644 index 00000000000..9dcf0cfccea --- /dev/null +++ b/src/renderer/src/components/browser-pane/remote-browser-stream-errors.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { + isPermanentRemoteBrowserStreamFailure, + remoteBrowserStreamUnsupportedError, + resolveRemoteBrowserStreamRestartFailure +} from './remote-browser-stream-errors' + +function rpcError(code: string, message = 'boom'): Error { + return Object.assign(new Error(message), { code }) +} + +// Why this file exists: the permanent set decides whether the pane ever retries on its own, and it +// was enforced by nothing — re-adding `selector_not_found` (the exact code 08260a54bf had to walk +// back out) left the whole suite green, as did deleting two of the three members. +describe('remote browser stream failure classification', () => { + it('treats only codes that prove the target is gone as permanent', () => { + for (const code of [ + 'worktree_not_found_on_server', + 'repo_not_found', + 'capability_unsupported' + ]) { + expect(isPermanentRemoteBrowserStreamFailure(rpcError(code))).toBe(true) + } + expect(isPermanentRemoteBrowserStreamFailure(remoteBrowserStreamUnsupportedError())).toBe(true) + }) + + // The regression guard. `selector_not_found` means "I could not resolve this right now" — its + // producer is a live worktree scan behind a 1s TTL cache, and the connectionId-gated fallback that + // shields SSH repos from scan lag does not cover purely local ones. Treating it as permanent + // stopped retries on a blip and stranded the pane, which is the bug this area exists to prevent. + it('does not treat an unresolved selector as proof the target is gone', () => { + expect(isPermanentRemoteBrowserStreamFailure(rpcError('selector_not_found'))).toBe(false) + expect( + resolveRemoteBrowserStreamRestartFailure(rpcError('selector_not_found')).shouldRetry + ).toBe(true) + }) + + it('keeps unknown and transport failures retryable', () => { + for (const code of ['runtime_unavailable', 'runtime_timeout', 'socket_closed']) { + expect(isPermanentRemoteBrowserStreamFailure(rpcError(code))).toBe(false) + } + expect(isPermanentRemoteBrowserStreamFailure(new Error('no code at all'))).toBe(false) + expect(isPermanentRemoteBrowserStreamFailure(null)).toBe(false) + }) + + it('keeps a permanent failure specific and replaces only raw transient text', () => { + const permanent = resolveRemoteBrowserStreamRestartFailure( + rpcError('worktree_not_found_on_server', 'worktree is gone') + ) + // Its own message says something true that a generic "lost connection" would not. + expect(permanent.message).toBe('worktree is gone') + expect(permanent.shouldRetry).toBe(false) + expect(permanent.logRawError).toBe(false) + + const transient = resolveRemoteBrowserStreamRestartFailure( + rpcError('runtime_unavailable', 'Runtime environment is manually disconnected.') + ) + expect(transient.message).toBe('Lost connection to the remote server.') + expect(transient.shouldRetry).toBe(true) + // Dropped from the UI but not lost: nothing else records it. + expect(transient.logRawError).toBe(true) + }) + + it('uses localized copy for permanent failures without an Error message', () => { + const failure = resolveRemoteBrowserStreamRestartFailure({ + code: 'worktree_not_found_on_server' + }) + + expect(failure.message).toBe('Failed to restart remote browser stream.') + expect(failure.shouldRetry).toBe(false) + }) +}) diff --git a/src/renderer/src/components/browser-pane/remote-browser-stream-errors.ts b/src/renderer/src/components/browser-pane/remote-browser-stream-errors.ts new file mode 100644 index 00000000000..7db8458d266 --- /dev/null +++ b/src/renderer/src/components/browser-pane/remote-browser-stream-errors.ts @@ -0,0 +1,107 @@ +import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-client' +import { + remoteBrowserStreamLostNotice, + remoteBrowserStreamRestartFailedNotice, + remoteBrowserStreamUnsupportedNotice +} from './remote-browser-stream-status' + +// Why: a runtime lacking browser.screencast.v1 will not grow it while this connection lives, so +// retrying that failure is unbounded work with a visible error each round. Tagged rather than +// message-matched so a reworded string cannot silently turn it back into an infinite retry. +export const REMOTE_BROWSER_STREAM_UNSUPPORTED = 'remote_browser_stream_unsupported' + +// Why: the runtime answers with these when the thing the stream is anchored to is gone from the +// host (worktree deleted, repo unregistered, capability absent). Retrying cannot bring it back, so +// they are permanent for this connection exactly like the capability tag above. Codes come from +// src/main/runtime/rpc/errors.ts rather than being invented here. +// Why `selector_not_found` is NOT here: it means "I could not resolve this right now", which is +// UNKNOWN, not proof the target is gone. Its producer is a live worktree scan behind a 1s-TTL cache, +// and the connectionId-gated fallback that shields SSH repos from scan lag does not cover purely +// local ones — so a slow scan can surface it transiently. Treating that as permanent would strand +// the pane forever, which is the exact bug this file exists to prevent. The three below are +// unambiguous: the host is telling us the thing itself no longer exists. +const REMOTE_BROWSER_STREAM_TARGET_GONE_CODES: ReadonlySet = new Set([ + 'worktree_not_found_on_server', + 'repo_not_found', + 'capability_unsupported' +]) + +const REMOTE_BROWSER_PAGE_MISSING_CODES: ReadonlySet = new Set([ + 'browser_tab_not_found', + 'browser_no_tab' +]) + +export function remoteBrowserStreamUnsupportedError(): Error { + return Object.assign(new Error(remoteBrowserStreamUnsupportedNotice()), { + code: REMOTE_BROWSER_STREAM_UNSUPPORTED + }) +} + +function readErrorCode(error: unknown): string | null { + if (error instanceof RuntimeRpcCallError) { + return error.code + } + if (!error || typeof error !== 'object' || !('code' in error)) { + return null + } + const code = (error as { code: unknown }).code + return typeof code === 'string' ? code : null +} + +export function isRemoteBrowserStreamUnsupportedError(error: unknown): boolean { + return readErrorCode(error) === REMOTE_BROWSER_STREAM_UNSUPPORTED +} + +export function isRemoteBrowserPageMissingCode(code: unknown): boolean { + return typeof code === 'string' && REMOTE_BROWSER_PAGE_MISSING_CODES.has(code) +} + +export function isRemoteBrowserPageMissingError(error: unknown): boolean { + return isRemoteBrowserPageMissingCode(readErrorCode(error)) +} + +// Why: restart retries must stop for failures the host cannot recover from on its own; anything +// else is unproven and must keep retrying rather than strand the pane with a dead subscription. +export function isPermanentRemoteBrowserStreamFailure(error: unknown): boolean { + const code = readErrorCode(error) + if (code === null) { + return false + } + return ( + code === REMOTE_BROWSER_STREAM_UNSUPPORTED || REMOTE_BROWSER_STREAM_TARGET_GONE_CODES.has(code) + ) +} + +export type RemoteBrowserStreamRestartFailure = { + /** What the pane shows. */ + message: string + /** False once the failure is proven unrecoverable on this connection. */ + shouldRetry: boolean + /** Whether the raw error is worth logging — true only when the pane replaced the message. */ + logRawError: boolean +} + +// Why the message is decided here rather than at the call site: whether a failure is permanent and +// what the user should be told are the same judgement. A permanent failure is one we classified and +// understand, so its own message says something true and specific ("The selected runtime does not +// support remote browser streaming."); flattening that to "Lost connection" would be vaguer and +// wrong, since nothing was lost. Everything else carries a raw transport string written for logs, +// so the pane speaks for itself there and the raw text is left to the caller to log. +// +// Note what this deliberately does NOT decide: whether the user is offered a way back. Stopping +// automatic retries and removing the user's only recovery are different things, and conflating them +// is what stranded the pane in the first place. `selector_not_found` already had to be walked back +// out of the permanent set once (08260a54bf) — proof this classification can be wrong — so a +// misjudgement here must stay recoverable by hand. +export function resolveRemoteBrowserStreamRestartFailure( + error: unknown +): RemoteBrowserStreamRestartFailure { + if (isPermanentRemoteBrowserStreamFailure(error)) { + return { + message: error instanceof Error ? error.message : remoteBrowserStreamRestartFailedNotice(), + shouldRetry: false, + logRawError: false + } + } + return { message: remoteBrowserStreamLostNotice(), shouldRetry: true, logRawError: true } +} diff --git a/src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle-deps.ts b/src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle-deps.ts new file mode 100644 index 00000000000..e0a5216db76 --- /dev/null +++ b/src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle-deps.ts @@ -0,0 +1,26 @@ +import type { RemoteBrowserPageSessionDeps } from './remote-browser-page-session' +import type { RemoteBrowserScreencastSubscribe } from './remote-browser-screencast-subscription' +import type { RemoteBrowserStreamStatus } from './remote-browser-stream-status' +import type { + RemoteBrowserPaneIdentity, + RemoteBrowserStreamToken, + RemoteBrowserViewportSize +} from './remote-browser-stream-tokens' + +// Everything RemoteBrowserStreamLifecycle needs from the React pane, kept separate so the pane can +// be read against one surface and the lifecycle can be driven from tests without one. +export type RemoteBrowserStreamLifecycleDeps = Omit & { + identity: RemoteBrowserPaneIdentity + subscribeScreencast: RemoteBrowserScreencastSubscribe + waitForViewportSize: () => Promise + readViewportSize: () => RemoteBrowserViewportSize | null + syncViewport: (pageId: string) => Promise + getDeviceScaleFactor: () => number + // Why one setter instead of separate busy/error/reconnect calls: those three always described a + // single thing — what this stream is doing — and writing them independently is what let them + // disagree. Only this class knows whether a failure still has retry budget left, so it publishes + // the state and the pane derives every visual from it. See remote-browser-stream-status.ts. + setStatus: (status: RemoteBrowserStreamStatus) => void + clearFrame: () => void + handleFrameBytes: (token: RemoteBrowserStreamToken, bytes: Uint8Array) => void +} diff --git a/src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.test.ts b/src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.test.ts new file mode 100644 index 00000000000..3c68bcfe1b7 --- /dev/null +++ b/src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.test.ts @@ -0,0 +1,1036 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { RemoteBrowserStreamLifecycle } from './remote-browser-stream-lifecycle' +import type { RemoteBrowserPageHandle, RemoteBrowserRpcCall } from './remote-browser-page-session' +import type { RemoteBrowserScreencastSubscribe } from './remote-browser-screencast-subscription' +import type { RemoteBrowserViewportSize } from './remote-browser-stream-tokens' +import { + canReconnectRemoteBrowserStream, + isRemoteBrowserStreamBusy, + remoteBrowserStreamNotice, + type RemoteBrowserStreamStatus +} from './remote-browser-stream-status' +import { isBrowserPaneUiRuntimeRpcParams } from '../../../../shared/runtime-rpc-feature-interaction-source' + +type Gate = { + wait: Promise + release: () => void + fail: (error: unknown) => void +} + +function createGate(): Gate { + let release!: () => void + let fail!: (error: unknown) => void + const wait = new Promise((resolve, reject) => { + release = () => resolve() + fail = (error: unknown) => reject(error) + }) + // Why: the gate is released by the test, not by this tick; an unhandled rejection would fail the run. + wait.catch(() => {}) + return { wait, release, fail } +} + +type FakeScreencastStream = { + pageId: string + params: unknown + viewportWidth: number | undefined + unsubscribeCount: number + emitReady: () => void + emitEnd: () => void + emitStreamError: (message: string) => void + emitMalformedSuccess: () => void + emitResponseFailure: (code: string, message: string) => void + emitTransportError: (code: string, message: string) => void + emitClose: () => void +} + +function rpcError(code: string, message: string): Error { + return Object.assign(new Error(message), { code }) +} + +function createHarness() { + const identity = { + mounted: true, + active: true, + tabId: 'tab-1', + environmentId: 'env-1' as string | null, + pageExists: true + } + const statusLog: RemoteBrowserStreamStatus[] = [] + const appliedTitles: string[] = [] + const closedPages: (string | null)[] = [] + const streams: FakeScreencastStream[] = [] + const rpcLog: string[] = [] + + let capabilities: string[] = ['browser.screencast.v1'] + let storedHandle: RemoteBrowserPageHandle | null = null + let viewportSize: RemoteBrowserViewportSize | null = { width: 800, height: 600 } + let statusGate: Gate | null = null + let viewportGate: Gate | null = null + let tabShowGate: Gate | null = null + let subscribeGate: Gate | null = null + let persistentSubscribeError: unknown = null + const subscribeErrorQueue: unknown[] = [] + let subscribeAttempts = 0 + let closeBeforeNextSubscribeRejects = false + + const callRpc = (async (_target: unknown, method: string) => { + rpcLog.push(method) + if (method === 'status.get') { + if (statusGate) { + const gate = statusGate + statusGate = null + await gate.wait + } + return { capabilities } + } + if (method === 'browser.tabShow') { + if (tabShowGate) { + const gate = tabShowGate + tabShowGate = null + await gate.wait + } + return { tab: { url: 'https://example.test/', title: 'Example' } } + } + if (method === 'browser.tabCreate') { + return { browserPageId: 'page-1' } + } + return {} + }) as unknown as RemoteBrowserRpcCall + + const subscribeScreencast: RemoteBrowserScreencastSubscribe = async (args, callbacks) => { + subscribeAttempts += 1 + if (subscribeGate) { + const gate = subscribeGate + subscribeGate = null + await gate.wait + } + // Models the host closing the subscription and only then rejecting the request, which is what + // src/main/ipc/runtime-environments.ts does on a stale pairing. + if (closeBeforeNextSubscribeRejects) { + closeBeforeNextSubscribeRejects = false + callbacks.onClose?.() + throw rpcError( + 'runtime_unavailable', + 'Runtime environment pairing changed; refresh and try again' + ) + } + const error = subscribeErrorQueue.shift() ?? persistentSubscribeError + if (error) { + throw error + } + const params = args.params as { + page: string + viewportWidth?: number + } + const respond = (result: unknown): void => { + callbacks.onResponse({ id: 'sub-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } }) + } + const stream: FakeScreencastStream = { + pageId: params.page, + params: args.params, + viewportWidth: params.viewportWidth, + unsubscribeCount: 0, + emitReady: () => + respond({ + type: 'ready', + subscriptionId: 'sub-1', + browserPageId: params.page, + format: 'jpeg', + tab: { url: 'https://example.test/', title: 'Example' } + }), + emitEnd: () => respond({ type: 'end', subscriptionId: 'sub-1' }), + emitStreamError: (message) => respond({ type: 'error', message }), + emitMalformedSuccess: () => respond(null), + emitResponseFailure: (code, message) => + callbacks.onResponse({ + id: 'sub-1', + ok: false, + error: { code, message }, + _meta: { runtimeId: 'runtime-1' } + }), + emitTransportError: (code, message) => callbacks.onError?.({ code, message }), + emitClose: () => callbacks.onClose?.() + } + streams.push(stream) + return { + unsubscribe: () => { + stream.unsubscribeCount += 1 + } + } + } + + const lifecycle = new RemoteBrowserStreamLifecycle({ + identity: { + isMounted: () => identity.mounted, + isActive: () => identity.active, + getTabId: () => identity.tabId, + getEnvironmentId: () => identity.environmentId, + browserPageExists: () => identity.pageExists + }, + callRpc, + subscribeScreencast, + getWorktreeSelector: () => 'worktree:wt-1', + getCurrentUrl: () => 'https://example.test/', + readStoredHandle: () => storedHandle, + writeStoredHandle: (handle) => { + storedHandle = handle + }, + removeStoredHandle: () => { + storedHandle = null + }, + applyTabInfo: (tab) => appliedTitles.push(tab.title ?? ''), + closeMissingRemotePage: (remotePageId) => closedPages.push(remotePageId), + waitForViewportSize: async () => { + if (viewportGate) { + const gate = viewportGate + viewportGate = null + await gate.wait + } + return viewportSize + }, + readViewportSize: () => viewportSize, + syncViewport: async () => {}, + getDeviceScaleFactor: () => 1, + setStatus: (status) => statusLog.push(status), + clearFrame: () => {}, + handleFrameBytes: () => {} + }) + + return { + lifecycle, + identity, + statusLog, + appliedTitles, + closedPages, + streams, + rpcLog, + get subscribeAttempts(): number { + return subscribeAttempts + }, + // Kept as accessors so the assertions written against the old three-variable shape still read + // naturally — they now derive from the one status, which is the point of the change. + get errorLog(): (string | null)[] { + return statusLog.map(remoteBrowserStreamNotice) + }, + get busyLog(): boolean[] { + return statusLog.map(isRemoteBrowserStreamBusy) + }, + get reconnectOffered(): boolean { + const status = statusLog.at(-1) + return status ? canReconnectRemoteBrowserStream(status) : false + }, + get currentStatusKind(): string | null { + return statusLog.at(-1)?.kind ?? null + }, + get currentError(): string | null { + const status = statusLog.at(-1) + return status ? remoteBrowserStreamNotice(status) : null + }, + setCapabilities: (next: string[]) => { + capabilities = next + }, + setViewportSize: (next: RemoteBrowserViewportSize | null) => { + viewportSize = next + }, + queueSubscribeError: (error: unknown) => { + subscribeErrorQueue.push(error) + }, + failEverySubscribe: (error: unknown) => { + persistentSubscribeError = error + }, + closeThenRejectNextSubscribe: () => { + closeBeforeNextSubscribeRejects = true + }, + holdNextViewportSize: (): Gate => { + const gate = createGate() + viewportGate = gate + return gate + }, + holdNextStatusGet: (): Gate => { + const gate = createGate() + statusGate = gate + return gate + }, + holdNextTabShow: (): Gate => { + const gate = createGate() + tabShowGate = gate + return gate + }, + holdNextSubscribe: (): Gate => { + const gate = createGate() + subscribeGate = gate + return gate + } + } +} + +type Harness = ReturnType + +async function settle(): Promise { + await vi.advanceTimersByTimeAsync(0) +} + +async function openStreamAndConfirmReady(harness: Harness): Promise<() => void> { + const close = harness.lifecycle.open() + await settle() + harness.streams[0].emitReady() + await settle() + return close +} + +describe('RemoteBrowserStreamLifecycle', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('opens a stream for the pane and reports it live', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + + expect(harness.streams).toHaveLength(1) + expect(harness.streams[0].pageId).toBe('page-1') + expect(harness.busyLog.at(-1)).toBe(false) + }) + + it('tags the screencast request as browser-pane UI traffic', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + + expect(isBrowserPaneUiRuntimeRpcParams(harness.streams[0].params)).toBe(true) + }) + + it('unsubscribes the live stream when the pane closes it', async () => { + const harness = createHarness() + const close = await openStreamAndConfirmReady(harness) + + close() + + expect(harness.streams[0].unsubscribeCount).toBe(1) + }) + + // STA-3483: the shipped bug was a single 500ms retry that never rescheduled. + it('keeps retrying a dropped stream with backoff instead of stopping after one attempt', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + harness.queueSubscribeError(new Error('stream refused')) + + harness.streams[0].emitEnd() + await settle() + expect(harness.subscribeAttempts).toBe(1) + + await vi.advanceTimersByTimeAsync(500) + expect(harness.subscribeAttempts).toBe(2) + expect(harness.streams).toHaveLength(1) + + // Why: the second attempt is the next backoff step (1000ms), not another 500ms tick. + await vi.advanceTimersByTimeAsync(999) + expect(harness.subscribeAttempts).toBe(2) + + await vi.advanceTimersByTimeAsync(1) + expect(harness.subscribeAttempts).toBe(3) + expect(harness.streams).toHaveLength(2) + }) + + it('does not retry a runtime that cannot stream at all', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + harness.setCapabilities([]) + + harness.streams[0].emitEnd() + await vi.advanceTimersByTimeAsync(500) + const attemptsAfterFirstRetry = harness.subscribeAttempts + + await vi.advanceTimersByTimeAsync(120_000) + + expect(harness.subscribeAttempts).toBe(attemptsAfterFirstRetry) + expect(harness.currentError).toBe( + 'The selected runtime does not support remote browser streaming.' + ) + }) + + // Fix 2: a worktree the host reports as genuinely gone cannot come back on this connection, so + // retrying it is unbounded work with a permanent error toast. Note the code used here is one the + // host only sends about the thing itself — `selector_not_found` is deliberately excluded, because + // it also covers a resolution that merely failed right now. + it('stops retrying when the runtime reports the stream target is gone', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + harness.failEverySubscribe(rpcError('worktree_not_found_on_server', 'worktree is gone')) + + harness.streams[0].emitEnd() + await vi.advanceTimersByTimeAsync(500) + expect(harness.subscribeAttempts).toBe(2) + expect(harness.currentError).toBe('worktree is gone') + + await vi.advanceTimersByTimeAsync(120_000) + + expect(harness.subscribeAttempts).toBe(2) + }) + + it('keeps retrying a failure the host could still recover from', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + harness.failEverySubscribe(rpcError('runtime_timeout', 'runtime timed out')) + + harness.streams[0].emitEnd() + await vi.advanceTimersByTimeAsync(500) + expect(harness.subscribeAttempts).toBe(2) + + await vi.advanceTimersByTimeAsync(1000) + + expect(harness.subscribeAttempts).toBe(3) + }) + + // Fix 3: a pane that healed itself must not keep showing the failure toast it recovered from. + it('clears the failure a restart reported once the new stream goes live', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + harness.queueSubscribeError(new Error('stream refused')) + + harness.streams[0].emitEnd() + await vi.advanceTimersByTimeAsync(500) + expect(harness.currentError).toBe('Lost connection to the remote server.') + + await vi.advanceTimersByTimeAsync(1000) + harness.streams[1].emitReady() + await settle() + + expect(harness.currentError).toBeNull() + }) + + // A stream that lasted must forget prior failures, or the next drop inherits their backoff. + it('backs off from scratch after a stream is confirmed live again', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + harness.queueSubscribeError(new Error('stream refused')) + + harness.streams[0].emitEnd() + await vi.advanceTimersByTimeAsync(500) + await vi.advanceTimersByTimeAsync(1000) + harness.streams[1].emitReady() + // Why the wait: 'ready' alone no longer refills the budget — only a stream that stayed up does. + await vi.advanceTimersByTimeAsync(15_000) + + harness.streams[1].emitEnd() + await settle() + const attemptsBeforeSecondDrop = harness.subscribeAttempts + + await vi.advanceTimersByTimeAsync(500) + + expect(harness.subscribeAttempts).toBe(attemptsBeforeSecondDrop + 1) + }) + + it('restarts the stream for a viewport change and adopts the new subscription', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + + harness.setViewportSize({ width: 1200, height: 900 }) + harness.lifecycle.restartForViewport('page-1') + await settle() + + expect(harness.streams).toHaveLength(2) + expect(harness.streams[0].unsubscribeCount).toBe(1) + expect(harness.streams[1].viewportWidth).toBe(1200) + }) + + // waitForViewportSize can block for a few frames while the element is unmeasurable. An attempt + // superseded during that window used to resume and claim the stream token anyway, stranding the + // live stream: its 'ready' was then dropped as stale, and because one ready-deadline is kept per + // pane, the resuming attempt cancelled the safety net too. The pane sat in 'opening' with nothing + // pending — no retry, no reconnect, forever. + it('drops an attempt superseded while it waited for the viewport', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + + const viewport = harness.holdNextViewportSize() + harness.setViewportSize({ width: 1400, height: 900 }) + harness.lifecycle.restartForViewport('page-1') + await settle() + + // A reopen (tab switch, environment change, Reconnect) supersedes the blocked resize. + harness.lifecycle.open() + await settle() + const supersedingStream = harness.streams.at(-1)! + + viewport.release() + await settle() + + // The reopen's own stream must still own the pane: its 'ready' has to land. + supersedingStream.emitReady() + await settle() + expect(harness.currentStatusKind).toBe('live') + }) + + it('ignores a viewport change that is within measurement jitter', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + + harness.setViewportSize({ width: 802, height: 601 }) + harness.lifecycle.restartForViewport('page-1') + await settle() + + expect(harness.streams).toHaveLength(1) + }) + + // Fix 1a: a superseded viewport restart must not clear busy for the operation that replaced it. + it('does not clear busy when a superseded viewport restart resolves with no stream', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + harness.setViewportSize({ width: 1200, height: 900 }) + const statusGate = harness.holdNextStatusGet() + + harness.lifecycle.restartForViewport('page-1') + await settle() + + // The isActive effect re-runs for the same page and bumps the operation generation. + harness.lifecycle.open() + await settle() + harness.streams.at(-1)!.emitReady() + await settle() + const busyAfterNewOperation = [...harness.busyLog] + + statusGate.release() + await settle() + + expect(harness.busyLog).toEqual(busyAfterNewOperation) + }) + + // Fix 1b: nor may it raise the error of a restart that is no longer the current operation. + it('does not surface the error of a superseded viewport restart', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + harness.setViewportSize({ width: 1200, height: 900 }) + const statusGate = harness.holdNextStatusGet() + + harness.lifecycle.restartForViewport('page-1') + await settle() + + harness.lifecycle.open() + await settle() + harness.streams.at(-1)!.emitReady() + await settle() + + statusGate.fail(new Error('stale restart failed')) + await settle() + + expect(harness.errorLog).not.toContain('stale restart failed') + expect(harness.currentError).toBeNull() + }) + + // The generation bump in restartForViewport is what retires other in-flight operation work. + it('retires in-flight tab refreshes when a viewport restart supersedes them', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + const refreshToken = harness.lifecycle.tokens.createOperationToken('page-1')! + const tabShowGate = harness.holdNextTabShow() + + harness.lifecycle.session.scheduleTabInfoRefresh(refreshToken, 100) + await vi.advanceTimersByTimeAsync(100) + const titlesBeforeRestart = [...harness.appliedTitles] + + harness.setViewportSize({ width: 1200, height: 900 }) + harness.lifecycle.restartForViewport('page-1') + tabShowGate.release() + await settle() + + expect(harness.appliedTitles).toEqual(titlesBeforeRestart) + }) + + it('closes a page the runtime reports as missing instead of retrying it', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + + harness.streams[0].emitResponseFailure('browser_tab_not_found', 'page is gone') + await settle() + + expect(harness.closedPages).toEqual(['page-1']) + expect(harness.subscribeAttempts).toBe(1) + }) + + it('ignores a malformed success payload without poisoning the live subscription', async () => { + const harness = createHarness() + harness.lifecycle.open() + await settle() + + expect(() => harness.streams[0].emitMalformedSuccess()).not.toThrow() + harness.streams[0].emitReady() + await settle() + + expect(harness.currentStatusKind).toBe('live') + }) + + // A transport error is NOT guaranteed to be followed by a close: the web client's + // notifySubscriptionsError clears its subscription map and then delivers onError only. So this + // must land somewhere the user can act from, or the pane is stranded busy with no way back. + it('leaves a transport error actionable when no close follows it', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + + harness.streams[0].emitTransportError('runtime_timeout', 'socket hiccup') + await vi.advanceTimersByTimeAsync(120_000) + + // Pane-authored, not the transport's own string: 'socket hiccup' names our plumbing and tells + // the user nothing they can act on. + expect(harness.currentError).toBe('Lost connection to the remote server.') + expect(harness.subscribeAttempts).toBe(1) + expect(harness.reconnectOffered).toBe(true) + // Critically: not busy. A spinner here would also disable the pane's own input handlers. + expect(harness.busyLog.at(-1)).toBe(false) + }) + + // The same path, but before the host confirmed anything. This is the one stream-ending path that + // deliberately keeps its stream token, so the 'never said ready' deadline stayed armed and its + // guard still passed — firing 30s later against a stream already declared stopped and withdrawing + // the reconnect control with no user action at all. + it('keeps a transport error actionable when the stream never said ready', async () => { + const harness = createHarness() + harness.lifecycle.open() + await settle() + expect(harness.streams).toHaveLength(1) + + harness.streams[0].emitTransportError('runtime_timeout', 'socket hiccup') + await settle() + expect(harness.reconnectOffered).toBe(true) + + // Long enough for the ready deadline to have fired. + await vi.advanceTimersByTimeAsync(120_000) + + expect(harness.currentError).toBe('Lost connection to the remote server.') + expect(harness.reconnectOffered).toBe(true) + expect(harness.busyLog.at(-1)).toBe(false) + expect(harness.subscribeAttempts).toBe(1) + }) + + it('ignores stream events once the pane is unmounted', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + const errorsBeforeUnmount = harness.errorLog.length + + harness.identity.mounted = false + harness.lifecycle.dispose() + harness.streams[0].emitStreamError('too late') + await vi.advanceTimersByTimeAsync(120_000) + + expect(harness.errorLog).toHaveLength(errorsBeforeUnmount) + expect(harness.subscribeAttempts).toBe(1) + }) + + it('reopens with the same lifecycle after a StrictMode cleanup cycle', async () => { + const harness = createHarness() + const closeStream = await openStreamAndConfirmReady(harness) + + harness.identity.mounted = false + harness.lifecycle.dispose() + closeStream() + harness.identity.mounted = true + harness.lifecycle.open() + await settle() + + expect(harness.streams).toHaveLength(2) + harness.streams[1].emitReady() + await settle() + expect(harness.currentStatusKind).toBe('live') + }) + + it('keeps the replacement ready deadline when a superseded subscribe rejects late', async () => { + const harness = createHarness() + const staleSubscribe = harness.holdNextSubscribe() + const closeStaleOpen = harness.lifecycle.open() + await settle() + + closeStaleOpen() + harness.lifecycle.open() + await settle() + expect(harness.streams).toHaveLength(1) + + staleSubscribe.fail(rpcError('runtime_unavailable', 'stale subscribe failed')) + await settle() + await vi.advanceTimersByTimeAsync(400_000) + + expect(harness.reconnectOffered).toBe(true) + }) +}) + +// Why these exist: deleting every setReconnectAvailable call site left the whole suite green, so the +// affordance had no unit coverage at all — only two E2E paths. Each test below fails if its own call +// site is removed. +describe('RemoteBrowserStreamLifecycle reconnect affordance', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('withholds reconnect while retries remain, then offers it once the budget is spent', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + harness.failEverySubscribe(rpcError('runtime_unavailable', 'socket died')) + + harness.streams[0].emitEnd() + await vi.advanceTimersByTimeAsync(500) + // One attempt spent, four remain: offering a control here invites fighting the retry loop. + expect(harness.reconnectOffered).toBe(false) + + await vi.advanceTimersByTimeAsync(60_000) + expect(harness.reconnectOffered).toBe(true) + expect(harness.currentError).toBe('Lost connection to the remote server.') + }) + + // The regression the review caught: stopping automatic retries is not the same as removing the + // user's last resort. selector_not_found already had to leave the permanent set once, so a + // misclassification here must stay recoverable by hand. + it('offers reconnect even when it stops retrying a permanent failure', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + harness.failEverySubscribe(rpcError('worktree_not_found_on_server', 'worktree is gone')) + + harness.streams[0].emitEnd() + await vi.advanceTimersByTimeAsync(60_000) + + expect(harness.reconnectOffered).toBe(true) + // The specific message survives: it says something true that "Lost connection" would not. + expect(harness.currentError).toBe('worktree is gone') + }) + + it('offers reconnect when the stream never opened at all', async () => { + const harness = createHarness() + harness.failEverySubscribe(rpcError('runtime_unavailable', 'host is down')) + harness.lifecycle.open() + await vi.advanceTimersByTimeAsync(60_000) + + expect(harness.reconnectOffered).toBe(true) + expect(harness.currentError).toBe('Cannot reach the remote server.') + }) + + it('clears the offer when the pane reopens and the stream comes back', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + harness.failEverySubscribe(rpcError('runtime_unavailable', 'socket died')) + harness.streams[0].emitEnd() + await vi.advanceTimersByTimeAsync(60_000) + expect(harness.reconnectOffered).toBe(true) + + // NOTE ON WHAT THIS PINS: the clear comes from open(), not from onReady. Once the budget is + // spent the scheduler has stopped, so nothing reaches onReady except a fresh open() — which + // clears first. onReady's own clear is therefore defence-in-depth against a future path that + // sets the flag and then recovers in place; it is deliberately NOT claimed as covered. + harness.failEverySubscribe(null) + harness.lifecycle.open() + await settle() + harness.streams.at(-1)!.emitReady() + await settle() + + expect(harness.reconnectOffered).toBe(false) + }) + + // A reopen starts a fresh budget, so a flag left over from a previous exhaustion would show the + // control while attempts remain. + it('does not carry a spent offer into the next open', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + harness.failEverySubscribe(rpcError('runtime_unavailable', 'socket died')) + harness.streams[0].emitEnd() + await vi.advanceTimersByTimeAsync(60_000) + expect(harness.reconnectOffered).toBe(true) + + // Failures cleared and no 'ready' emitted yet: the flag must already be down from open() alone, + // not from the later confirmation. + harness.failEverySubscribe(null) + harness.lifecycle.open() + await settle() + expect(harness.reconnectOffered).toBe(false) + }) +}) + +// NEW-1 repro: a budget can drain without any attempt throwing. Each restart subscribes fine, then +// the stream ends before 'ready'. The catch never runs, so setError is never called — and the +// Reconnect control renders only inside the error toast, so exhaustion produces an invisible +// affordance, busy stuck true, and no further retries. On main this cycle retried forever and +// self-healed, so it is a regression in the exact class this work exists to remove. +describe('RemoteBrowserStreamLifecycle silent budget drain', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('reports a failure when the budget drains without any attempt throwing', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + + for (let round = 0; round < 6; round++) { + harness.streams.at(-1)!.emitEnd() + await vi.advanceTimersByTimeAsync(20_000) + } + + expect(harness.reconnectOffered).toBe(true) + // Without a message the button cannot render at all. + expect(harness.currentError).not.toBeNull() + // A spinner left running over a frozen frame also blocks the pane's input handlers. + expect(harness.busyLog.at(-1)).toBe(false) + }) +}) + +// A transport error is NOT a stop. On a real socket failure the client calls onError and then +// onClose (src/shared/remote-runtime-client.ts fail()), and it is onClose that starts the retry +// budget. Announcing a stop on the error therefore raises the reconnect control ~500ms before the +// first automatic attempt — the one state this pane must never present, and with raw transport copy +// the pane exists to keep out of the UI. The E2E cannot see this: manual disconnect delivers close +// without error, which is the single drop shape where the bug is absent. +describe('RemoteBrowserStreamLifecycle transport error', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + // Why this pins the METHOD and not just the call site: cancelling the deadline on a transport + // error must not also forget how long the stream had been alive, because a close that follows has + // to refill the budget for a stream that had proved healthy. Swapping stopWaitingForReady's + // clearDeadline() for clear() — deleting exactly what its comment argues for — left all 322 tests + // green while silently costing the user one automatic retry. + it('still refills the budget when a close follows a transport error on a healthy stream', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + + // Spend one budget step: a drop whose retry succeeds. + harness.streams[0].emitEnd() + await vi.advanceTimersByTimeAsync(500) + harness.streams[1].emitReady() + await settle() + + // Let the replacement prove itself — longer than the window that earns a refill. + await vi.advanceTimersByTimeAsync(15_000) + + harness.streams[1].emitTransportError('runtime_timeout', 'socket hiccup') + await settle() + harness.failEverySubscribe(rpcError('runtime_unavailable', 'still down')) + harness.streams[1].emitClose() + await settle() + const attemptsAfterClose = harness.subscribeAttempts + + await vi.advanceTimersByTimeAsync(120_000) + + // A full ladder, not the tail of the earlier one. + expect(harness.subscribeAttempts - attemptsAfterClose).toBe(5) + expect(harness.reconnectOffered).toBe(true) + }) + + // The usual case: a close does follow. It must take the control back down for the whole ladder, + // so the user is not offered a manual retry that competes with the automatic one about to run. + it('withdraws the offer once the close starts the retry budget', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + + harness.streams[0].emitTransportError('runtime_unavailable', 'socket reset by peer') + await settle() + expect(harness.reconnectOffered).toBe(true) + + harness.failEverySubscribe(rpcError('runtime_unavailable', 'still down')) + harness.streams[0].emitClose() + await settle() + expect(harness.reconnectOffered).toBe(false) + + await vi.advanceTimersByTimeAsync(600) + expect(harness.reconnectOffered).toBe(false) + + await vi.advanceTimersByTimeAsync(60_000) + expect(harness.reconnectOffered).toBe(true) + }) + + // The budget absorbs a blip invisibly: a drop whose first retry succeeds must say nothing at all. + it('says nothing when the first retry succeeds', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + + harness.streams[0].emitClose() + await vi.advanceTimersByTimeAsync(600) + harness.streams.at(-1)!.emitReady() + await settle() + + expect(harness.errorLog.filter((entry) => entry !== null)).toEqual([]) + }) +}) + +// These pin the remaining announce sites. Each was verified to leave the suite green when its +// announce was downgraded to a bare setError — the gap that let a wrong one ship. +describe('RemoteBrowserStreamLifecycle stop announcements', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('offers reconnect when the runtime reports the stream itself failed', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + + harness.streams[0].emitStreamError('screencast pipeline exploded') + await vi.advanceTimersByTimeAsync(60_000) + + // Non-restarting by design, so nothing else would ever hand the user a way back. + expect(harness.reconnectOffered).toBe(true) + expect(harness.currentError).toBe('screencast pipeline exploded') + expect(harness.busyLog.at(-1)).toBe(false) + }) + + it('offers reconnect when a viewport restart cannot re-subscribe', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + + harness.failEverySubscribe(rpcError('runtime_unavailable', 'resize refused')) + harness.setViewportSize({ width: 1400, height: 900 }) + harness.lifecycle.restartForViewport(harness.streams[0].pageId) + await vi.advanceTimersByTimeAsync(60_000) + + // A resize during a blip tears down a live subscription and never reaches the retry budget. + expect(harness.reconnectOffered).toBe(true) + expect(harness.busyLog.at(-1)).toBe(false) + }) + + // This was the one failure path still forwarding raw transport text, which is written for logs and + // names our internals. The pane speaks for itself everywhere else. + it('speaks for itself when a viewport restart fails transiently', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + + harness.failEverySubscribe( + rpcError('runtime_unavailable', 'Runtime environment pairing changed; refresh and try again') + ) + harness.setViewportSize({ width: 1400, height: 900 }) + harness.lifecycle.restartForViewport(harness.streams[0].pageId) + await vi.advanceTimersByTimeAsync(60_000) + + expect(harness.currentError).toBe('Lost connection to the remote server.') + }) + + // ...but a failure we classified ourselves keeps its own message, which says something true. + it('keeps a permanent viewport-restart failure specific', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + + harness.failEverySubscribe(rpcError('worktree_not_found_on_server', 'worktree is gone')) + harness.setViewportSize({ width: 1400, height: 900 }) + harness.lifecycle.restartForViewport(harness.streams[0].pageId) + await vi.advanceTimersByTimeAsync(60_000) + + expect(harness.currentError).toBe('worktree is gone') + }) +}) + +// Two writers, one value — the shape every round of this review has found. The stream token is +// claimed before subscribe is awaited, so a close can arrive and arm a restart while the subscribe +// promise is still rejecting. The host does exactly this: it closes the subscription and only then +// throws (src/main/ipc/runtime-environments.ts, stale pairing). +describe('RemoteBrowserStreamLifecycle close racing a rejected subscribe', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('does not offer reconnect over a restart that is already armed', async () => { + const harness = createHarness() + harness.closeThenRejectNextSubscribe() + harness.lifecycle.open() + await settle() + + // The armed restart owns the status: offering a manual control here shows it for one backoff + // step and then swaps it for a spinner, which is the state this pane must never present. + expect(harness.reconnectOffered).toBe(false) + expect(harness.busyLog.at(-1)).toBe(true) + expect(harness.currentError).toBeNull() + }) + + it('still reaches a reconnect once that restart budget is spent', async () => { + const harness = createHarness() + harness.closeThenRejectNextSubscribe() + harness.lifecycle.open() + await settle() + harness.failEverySubscribe(rpcError('runtime_unavailable', 'still down')) + + await vi.advanceTimersByTimeAsync(60_000) + + expect(harness.reconnectOffered).toBe(true) + expect(harness.currentError).toBe('Lost connection to the remote server.') + }) +}) + +// A subscribe resolves as soon as the request is sent, so a host that accepts and then goes silent +// is bounded by nothing on the client. Both busy states hide the reconnect, so this stranded the +// pane behind a permanent spinner with dead input handlers — the exact failure this work removes. +describe('RemoteBrowserStreamLifecycle host that never says ready', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('does not sit busy forever when the host accepts the subscribe and goes silent', async () => { + const harness = createHarness() + harness.lifecycle.open() + await settle() + expect(harness.streams).toHaveLength(1) + + // Nothing has failed yet, so the pane is right to still be working. + await vi.advanceTimersByTimeAsync(20_000) + expect(harness.reconnectOffered).toBe(false) + expect(harness.busyLog.at(-1)).toBe(true) + + await vi.advanceTimersByTimeAsync(400_000) + + expect(harness.reconnectOffered).toBe(true) + expect(harness.busyLog.at(-1)).toBe(false) + expect(harness.currentError).not.toBeNull() + }) + + it('releases the hung subscription rather than leaking it', async () => { + const harness = createHarness() + harness.lifecycle.open() + await settle() + + await vi.advanceTimersByTimeAsync(400_000) + + expect(harness.streams[0].unsubscribeCount).toBeGreaterThan(0) + }) +}) + +// 'ready' proves the host accepted the subscribe, not that the stream is sustained. CDP allows one +// screencast per page, so a second subscriber on the same remote page evicts the first on every +// attempt. Refilling the budget on 'ready' turned that into the unbounded retry loop the budget +// exists to prevent. +describe('RemoteBrowserStreamLifecycle flapping host', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('stops a host that flaps ready-then-end instead of retrying forever', async () => { + const harness = createHarness() + await openStreamAndConfirmReady(harness) + + for (let round = 0; round < 8; round++) { + harness.streams.at(-1)!.emitEnd() + // Long enough for the next backoff step to fire, short enough that the stream never proves + // itself healthy. + await vi.advanceTimersByTimeAsync(9_000) + harness.streams.at(-1)!.emitReady() + await settle() + } + + expect(harness.reconnectOffered).toBe(true) + expect(harness.busyLog.at(-1)).toBe(false) + }) +}) diff --git a/src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.ts b/src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.ts new file mode 100644 index 00000000000..30ce057690d --- /dev/null +++ b/src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.ts @@ -0,0 +1,373 @@ +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import { RemoteBrowserPageSession } from './remote-browser-page-session' +import { openRemoteBrowserScreencastStream } from './remote-browser-screencast-subscription' +import { RemoteBrowserStreamRestartScheduler } from './remote-browser-stream-restart-scheduler' +import { RemoteBrowserStreamLiveness } from './remote-browser-stream-liveness' +import { createRemoteBrowserStreamRestartAttempt } from './remote-browser-stream-restart-attempt' +import { + REMOTE_BROWSER_STREAM_LIVE, + REMOTE_BROWSER_STREAM_OPENING, + remoteBrowserStreamLostNotice, + remoteBrowserStreamRetrying, + remoteBrowserStreamStopped, + remoteBrowserStreamUnreachableNotice, + REMOTE_BROWSER_STREAM_IDLE +} from './remote-browser-stream-status' +import { + isPermanentRemoteBrowserStreamFailure, + isRemoteBrowserPageMissingError, + remoteBrowserStreamUnsupportedError, + resolveRemoteBrowserStreamRestartFailure +} from './remote-browser-stream-errors' +import { + areRemoteViewportSizesNear, + RemoteBrowserOperationTokens, + type RemoteBrowserStreamSubscription, + type RemoteBrowserStreamToken, + type RemoteBrowserViewportSize +} from './remote-browser-stream-tokens' +export type { RemoteBrowserStreamLifecycleDeps } from './remote-browser-stream-lifecycle-deps' +import type { RemoteBrowserStreamLifecycleDeps } from './remote-browser-stream-lifecycle-deps' + +// Owns the remote browser screencast lifecycle for one pane: opening the stream, self-healing a +// dropped one with bounded backoff, restarting it after a viewport change, and tearing everything +// down. Kept out of React so each of those paths can be driven from a test with injected deps. +export class RemoteBrowserStreamLifecycle { + readonly tokens: RemoteBrowserOperationTokens + readonly session: RemoteBrowserPageSession + private readonly restartScheduler: RemoteBrowserStreamRestartScheduler + private subscription: RemoteBrowserStreamSubscription | null = null + private streamViewportSize: RemoteBrowserViewportSize | null = null + private readonly liveness = new RemoteBrowserStreamLiveness() + + constructor(private readonly deps: RemoteBrowserStreamLifecycleDeps) { + this.tokens = new RemoteBrowserOperationTokens(deps.identity) + this.session = new RemoteBrowserPageSession({ ...deps, tokens: this.tokens }) + // Why exhaustion publishes a message too: a budget can drain without any attempt throwing — + // each restart subscribes fine, then the stream ends before 'ready', so the catch that normally + // reports never runs. 'stopped' carries its notice, so that case cannot go silent. + this.restartScheduler = new RemoteBrowserStreamRestartScheduler(undefined, () => + deps.setStatus(remoteBrowserStreamStopped(remoteBrowserStreamLostNotice())) + ) + } + + forgetStreamViewportSize(): void { + this.streamViewportSize = null + } + + // Opens the stream for the active pane and returns the teardown for that attempt. + open(): () => void { + let cancelled = false + const { tokens, deps } = this + // A reopen (tab switch, environment or worktree change, or Reconnect) starts a fresh budget, so + // 'opening' replaces whatever the previous attempt ended on — including a spent 'stopped'. + deps.setStatus(REMOTE_BROWSER_STREAM_OPENING) + this.retireInFlightWork()?.unsubscribe() + const operationToken = tokens.createOperationToken() + if (!operationToken) { + deps.setStatus(REMOTE_BROWSER_STREAM_IDLE) + return () => {} + } + void this.session + .ensureRemotePage(operationToken) + .then(async (pageId) => { + if (!pageId || cancelled || !tokens.isCurrent(operationToken)) { + return + } + const pageToken = { ...operationToken, remotePageId: pageId } + const tab = await this.session.fetchTabInfo(pageToken) + if (tab && !cancelled && tokens.isCurrent(pageToken)) { + deps.applyTabInfo(tab) + } + if (cancelled || !tokens.isCurrent(pageToken)) { + return + } + const subscription = await this.startStream(pageId) + if (cancelled || !subscription) { + subscription?.unsubscribe() + return + } + this.adoptSubscription(subscription) + }) + .catch((error: unknown) => { + if (cancelled) { + return + } + if (isRemoteBrowserPageMissingError(error)) { + deps.closeMissingRemotePage(tokens.remotePage) + return + } + // Why classified here too: the same condition (a host that cannot stream) reaches both this + // path and the restart path, and it must not read as "unreachable" here and as its own + // specific message there. + const permanent = isPermanentRemoteBrowserStreamFailure(error) + if (!permanent) { + console.warn('[browser-pane] remote browser failed to open:', error) + } + // Why this can be the loser of a race: the stream token is claimed before subscribe is + // awaited, so a close can arrive and arm a restart while this promise is still rejecting — + // the host closes the subscription and only then throws (src/main/ipc/runtime-environments.ts + // 'pairing changed'). Publishing 'stopped' over an armed restart shows a manual control for + // one backoff step and then swaps it for a spinner. The armed restart is the newer owner. + if (this.restartScheduler.isScheduled) { + return + } + // "Unreachable" rather than "lost": nothing was ever established here. And this path never + // had a stream, so the retry budget never runs — 'stopped' is what hands the user a way back. + deps.setStatus( + remoteBrowserStreamStopped( + permanent && error instanceof Error + ? error.message + : remoteBrowserStreamUnreachableNotice() + ) + ) + }) + return () => { + cancelled = true + this.retireInFlightWork()?.unsubscribe() + } + } + + // Why: the runtime stream validates frames against its start viewport, so a resize needs a fresh + // subscription or the new-size frames get rejected. + restartForViewport(pageId: string): void { + const current = this.subscription + const nextViewportSize = this.deps.readViewportSize() + if ( + !current || + current.token.remotePageId !== pageId || + !nextViewportSize || + areRemoteViewportSizesNear(this.streamViewportSize, nextViewportSize) || + !this.tokens.isCurrentStreamToken(current.token) + ) { + return + } + // Why: cancel() cannot recall a retry already dispatched into an await. Bumping the operation + // generation — as every other supersession site does — makes that attempt's own token guard + // fail, so it cannot subscribe concurrently with the restart below. + this.retireInFlightWork() + this.streamViewportSize = null + // Why: without a token the .then/.catch below would mutate busy/remoteError on behalf of a + // restart that a newer operation has already replaced. + const restartToken = this.tokens.createOperationToken(pageId) + this.deps.setStatus(REMOTE_BROWSER_STREAM_OPENING) + current.unsubscribe() + void this.startStream(pageId) + .then((subscription) => { + // Why no status write: startStream returns null only when this very token was superseded, + // so a newer operation already owns the status and writing here would clobber it. + if (!subscription) { + return + } + this.adoptSubscription(subscription) + }) + .catch((error: unknown) => { + if (!restartToken || !this.tokens.isCurrent(restartToken)) { + return + } + if (isRemoteBrowserPageMissingError(error)) { + this.deps.closeMissingRemotePage(pageId) + return + } + // Same race as open()'s catch: a close can arm a restart while this rejection is in flight. + if (this.restartScheduler.isScheduled) { + return + } + // Why classified rather than forwarded: this was the one failure path still putting raw + // transport text in the UI ("Runtime environment pairing changed; refresh and try again"), + // which is written for logs and names our internals. The other two paths already classify. + const failure = resolveRemoteBrowserStreamRestartFailure(error) + if (failure.logRawError) { + console.warn('[browser-pane] remote stream resize failed:', error) + } + // A resize during a blip tears down a live subscription and never reaches the budget. + this.deps.setStatus(remoteBrowserStreamStopped(failure.message)) + }) + } + + // Drops every handle this pane holds on a runtime page that is gone. + abandonRemotePage(): void { + this.tokens.setRemotePage(null) + this.retireInFlightWork()?.unsubscribe() + this.session.cancelTabInfoRefresh() + } + + // Unmount: retire in-flight work without unsubscribing, which the stream effect's own teardown owns. + dispose(): void { + this.tokens.supersedeOperations() + this.tokens.supersedeStream() + this.tokens.releaseStreamToken() + this.streamViewportSize = null + this.liveness.clear() + this.restartScheduler.cancel() + this.session.cancelTabInfoRefresh() + } + + // Retires every in-flight guard token and the pending retry, and hands back the subscription the + // caller is displacing so it can be released at the point the original code released it. + private retireInFlightWork(): RemoteBrowserStreamSubscription | null { + this.tokens.supersedeOperations() + this.tokens.supersedeStream() + this.tokens.releaseStreamToken() + this.liveness.clear() + const previous = this.subscription + this.subscription = null + this.restartScheduler.cancel() + return previous + } + + private adoptSubscription(subscription: RemoteBrowserStreamSubscription): void { + if (!this.tokens.isCurrentStreamToken(subscription.token)) { + subscription.unsubscribe() + return + } + this.subscription = subscription + } + + private async startStream(pageId: string): Promise { + const { tokens, deps } = this + const operationToken = tokens.createOperationToken(pageId) + if (!operationToken || !tokens.isCurrent(operationToken)) { + return null + } + const target: RuntimeClientTarget = { + kind: 'environment', + environmentId: operationToken.environmentId + } + const status = await deps.callRpc(target, 'status.get', undefined, { + timeoutMs: 15_000 + }) + if (!status.capabilities?.includes('browser.screencast.v1')) { + throw remoteBrowserStreamUnsupportedError() + } + if (!tokens.isCurrent(operationToken)) { + return null + } + const viewportSize = await deps.waitForViewportSize() + // Why this guard, which every other await here already had: waitForViewportSize can block for a + // few frames while the element is unmeasurable, and a superseded attempt resuming after that + // would claim the stream token out from under the live stream — and, since one deadline is kept + // per pane, cancel its safety net too. The pane then sits in 'opening' with nothing pending. + if (!tokens.isCurrent(operationToken)) { + return null + } + this.streamViewportSize = viewportSize + const token = tokens.claimStreamToken(operationToken, pageId) + // Treated as a drop rather than announced directly, so a hung host spends the same budget and + // ends at the same 'stopped' with a reconnect as a host that refuses outright. + this.liveness.watch(() => { + if (this.tokens.isCurrentStreamToken(token)) { + this.handleStreamClosed(token, true) + } + }) + try { + const subscription = await openRemoteBrowserScreencastStream( + deps.subscribeScreencast, + { + environmentId: target.environmentId, + worktree: deps.getWorktreeSelector(), + pageId, + viewportSize, + deviceScaleFactor: deps.getDeviceScaleFactor() + }, + { + isCurrent: () => tokens.isCurrentStreamToken(token), + onReady: (event) => { + // Why a timestamp and not restartScheduler.reset(): the budget is refilled at close + // time, and only by a stream that lasted — see REMOTE_BROWSER_STREAM_HEALTHY_MS. + // 'live' carries no notice, so a toast the retry already healed cannot survive + // (STA-3483). + this.liveness.markReady() + deps.setStatus(REMOTE_BROWSER_STREAM_LIVE) + deps.applyTabInfo(event.tab) + void deps.syncViewport(event.browserPageId).catch(() => {}) + }, + onEnded: () => this.handleStreamClosed(token, true), + // Why onFailed announces a stop: the runtime reported the stream itself failed, and the + // close below is deliberately non-restarting, so nothing else would hand the user a way + // back. Its message is the host's own, which is more specific than any substitute. + onFailed: (message) => { + deps.setStatus(remoteBrowserStreamStopped(message)) + this.handleStreamClosed(token, false) + }, + // Why 'stopped' and not 'retrying': a transport error is NOT guaranteed to be followed by + // a close. The web client's notifySubscriptionsError clears its subscription map and then + // delivers onError only, so on that path no close ever arrives — and 'retrying' would + // leave the pane busy forever with no way back, worse than the bug this PR fixes. When a + // close does follow (the usual case) it publishes 'retrying', which replaces this within + // the same tick. Landing in the actionable state and being corrected is safe; the reverse + // is not. + // Why the deadline is cancelled here: this is the one stream-ending path that keeps its + // token, so the 'never said ready' deadline stays armed and its guard still passes. It + // would then fire against a stream already declared stopped and republish 'retrying' — + // withdrawing the reconnect control 30s after handing it over, with no user action, and + // reopening the strand this whole feature exists to remove. Not clear(): a close that + // does follow must still refill the budget for a stream that had been healthy. + onTransportError: () => { + this.liveness.stopWaitingForReady() + deps.setStatus(remoteBrowserStreamStopped(remoteBrowserStreamLostNotice())) + }, + onPageMissing: () => deps.closeMissingRemotePage(pageId), + onFrame: (bytes) => deps.handleFrameBytes(token, bytes), + onClosed: () => this.handleStreamClosed(token, true) + } + ) + return { token, unsubscribe: subscription.unsubscribe } + } catch (error) { + if (tokens.isCurrentStreamToken(token)) { + this.liveness.clear() + tokens.releaseStreamToken() + } + throw error + } + } + + private handleStreamClosed(token: RemoteBrowserStreamToken, restart: boolean): void { + if (!this.tokens.isCurrentStreamToken(token)) { + return + } + // Only a stream that proved itself refills the budget; see REMOTE_BROWSER_STREAM_HEALTHY_MS. + const wasHealthy = this.liveness.settle() + if (restart && wasHealthy) { + this.restartScheduler.reset() + } + // Why no notice yet: the budget exists to absorb a blip invisibly, so nothing is said until an + // attempt has actually failed. A non-restarting close leaves whatever the caller published. + if (restart) { + this.deps.setStatus(remoteBrowserStreamRetrying(null)) + } + const current = this.subscription + this.subscription = null + this.tokens.releaseStreamToken() + this.streamViewportSize = null + // Why: navigation recreates the screencast stream; keep the last frame during restart so panes + // don't flash the loading placeholder. + if (!restart) { + this.deps.clearFrame() + } + current?.unsubscribe() + if (restart) { + this.scheduleStreamRestart(token) + } + } + + // Why: a failed self-heal attempt must keep retrying with bounded backoff (STA-3483) instead of + // leaving the pane holding a dead subscription with no way back. + private scheduleStreamRestart(token: RemoteBrowserStreamToken): void { + if (!this.tokens.isCurrentStreamOperation(token)) { + return + } + this.restartScheduler.schedule( + createRemoteBrowserStreamRestartAttempt(token, { + tokens: this.tokens, + session: this.session, + setStatus: (status) => this.deps.setStatus(status), + applyTabInfo: (tab) => this.deps.applyTabInfo(tab), + closeMissingRemotePage: (remotePageId) => this.deps.closeMissingRemotePage(remotePageId), + startStream: (pageId) => this.startStream(pageId), + adoptSubscription: (subscription) => this.adoptSubscription(subscription) + }) + ) + } +} diff --git a/src/renderer/src/components/browser-pane/remote-browser-stream-liveness.ts b/src/renderer/src/components/browser-pane/remote-browser-stream-liveness.ts new file mode 100644 index 00000000000..54c06d05bc7 --- /dev/null +++ b/src/renderer/src/components/browser-pane/remote-browser-stream-liveness.ts @@ -0,0 +1,67 @@ +// Why a deadline on a stream that subscribed but never said 'ready': subscribe resolves as soon as +// the request is sent, so a host that accepts and then goes silent (a hung CDP teardown does exactly +// this) leaves nothing on the client to time out. Both states that cover a pending stream are busy +// and offer no reconnect, so without this the pane sits behind a spinner with dead input handlers +// and no way back — the very strand this work exists to remove. +export const REMOTE_BROWSER_STREAM_READY_DEADLINE_MS = 30_000 + +// Why 'ready' alone does not prove a stream is worth trusting: it says the host accepted the +// subscribe, not that the stream is sustained. CDP allows one screencast per page, so a second +// subscriber on the same remote page evicts the first on every attempt. Treating every 'ready' as +// healthy refills the retry budget forever, which is the unbounded retry the budget exists to stop. +export const REMOTE_BROWSER_STREAM_HEALTHY_MS = 10_000 + +// Tracks whether one screencast subscription ever came alive, and for how long. Kept apart from the +// lifecycle because "is this stream actually alive?" is a different question from "what should the +// pane do about it?", and only the second needs tokens, status, or the retry budget. +export class RemoteBrowserStreamLiveness { + private readyAt: number | null = null + private deadlineTimer: ReturnType | null = null + + /** + * Starts watching a newly subscribed stream. `onNeverReady` fires once if it stays silent, and the + * caller decides what that means — treating it as a drop keeps hung and refused hosts on one path. + */ + watch(onNeverReady: () => void): void { + this.clear() + this.deadlineTimer = setTimeout(() => { + this.deadlineTimer = null + onNeverReady() + }, REMOTE_BROWSER_STREAM_READY_DEADLINE_MS) + } + + markReady(): void { + this.clearDeadline() + this.readyAt = Date.now() + } + + /** + * The stream is known not to be coming up, so stop expecting 'ready' — but keep what we already + * know about how long it lived. Distinct from clear(): a caller that has declared the stream + * stopped while deliberately holding its token still needs a later close to refill the budget + * correctly, which depends on the timestamp this preserves. + */ + stopWaitingForReady(): void { + this.clearDeadline() + } + + /** Ends the current watch and reports whether the stream stayed up long enough to be trusted. */ + settle(): boolean { + this.clearDeadline() + const readyAt = this.readyAt + this.readyAt = null + return readyAt !== null && Date.now() - readyAt >= REMOTE_BROWSER_STREAM_HEALTHY_MS + } + + clear(): void { + this.clearDeadline() + this.readyAt = null + } + + private clearDeadline(): void { + if (this.deadlineTimer !== null) { + clearTimeout(this.deadlineTimer) + this.deadlineTimer = null + } + } +} diff --git a/src/renderer/src/components/browser-pane/remote-browser-stream-restart-attempt.ts b/src/renderer/src/components/browser-pane/remote-browser-stream-restart-attempt.ts new file mode 100644 index 00000000000..648dcf52d8d --- /dev/null +++ b/src/renderer/src/components/browser-pane/remote-browser-stream-restart-attempt.ts @@ -0,0 +1,85 @@ +import type { RemoteBrowserPageSession } from './remote-browser-page-session' +import { + isRemoteBrowserPageMissingError, + resolveRemoteBrowserStreamRestartFailure +} from './remote-browser-stream-errors' +import { + remoteBrowserStreamRetrying, + remoteBrowserStreamStopped, + type RemoteBrowserStreamStatus +} from './remote-browser-stream-status' +import { + toOperationToken, + type RemoteBrowserOperationTokens, + type RemoteBrowserStreamSubscription, + type RemoteBrowserStreamToken +} from './remote-browser-stream-tokens' +import type { BrowserTabInfo } from '../../../../shared/runtime-types' + +export type RemoteBrowserStreamRestartAttemptDeps = { + tokens: RemoteBrowserOperationTokens + session: RemoteBrowserPageSession + setStatus: (status: RemoteBrowserStreamStatus) => void + applyTabInfo: (tab: BrowserTabInfo) => void + closeMissingRemotePage: (remotePageId: string) => void + startStream: (pageId: string) => Promise + adoptSubscription: (subscription: RemoteBrowserStreamSubscription) => void +} + +// One self-heal attempt for a dropped stream, as the scheduler wants it: resolves true to keep +// retrying, false to stop. Split from the lifecycle because a single attempt needs none of the +// lifecycle's own state — only the token it is retrying on behalf of. +export function createRemoteBrowserStreamRestartAttempt( + token: RemoteBrowserStreamToken, + deps: RemoteBrowserStreamRestartAttemptDeps +): () => Promise { + const { tokens } = deps + // Keeps a failure visible across the wait before the next attempt instead of blinking off. + let lastNotice: string | null = null + return async (): Promise => { + if (!tokens.isCurrentStreamOperation(token)) { + return false + } + deps.setStatus(remoteBrowserStreamRetrying(lastNotice)) + const operationToken = toOperationToken(token) + try { + const tab = await deps.session.fetchTabInfo(operationToken).catch(() => null) + if (tab && tokens.isCurrentStreamOperation(token)) { + deps.applyTabInfo(tab) + } + if (!tokens.isCurrentStreamOperation(token)) { + return false + } + const subscription = await deps.startStream(token.remotePageId) + if (subscription) { + deps.adoptSubscription(subscription) + } + return false + } catch (error) { + if (!tokens.isCurrentStreamOperation(token)) { + return false + } + if (isRemoteBrowserPageMissingError(error)) { + deps.closeMissingRemotePage(token.remotePageId) + return false + } + const failure = resolveRemoteBrowserStreamRestartFailure(error) + if (failure.logRawError) { + // The raw text is transport-level and written for logs; keep it out of the UI but not out of + // reach, since nothing else records it. + console.warn('[browser-pane] remote stream restart failed:', error) + } + // Why giving up publishes 'stopped': abandoning automatic retries is not the same as taking + // away the user's last resort, and a classification we got wrong would otherwise strand the + // pane exactly as it did before this work. While attempts remain it stays 'retrying', which + // reports the failure without offering a control that competes with the next attempt. + lastNotice = failure.message + deps.setStatus( + failure.shouldRetry + ? remoteBrowserStreamRetrying(failure.message) + : remoteBrowserStreamStopped(failure.message) + ) + return failure.shouldRetry + } + } +} diff --git a/src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.test.ts b/src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.test.ts new file mode 100644 index 00000000000..6109a64e40b --- /dev/null +++ b/src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.test.ts @@ -0,0 +1,231 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { RemoteBrowserStreamRestartScheduler } from './remote-browser-stream-restart-scheduler' + +afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() +}) + +describe('RemoteBrowserStreamRestartScheduler', () => { + it('retries transient failures until it recovers, without spending the whole budget', async () => { + vi.useFakeTimers() + const scheduler = new RemoteBrowserStreamRestartScheduler() + let attempts = 0 + const FAILURES_BEFORE_SUCCESS = 3 + + const run = vi.fn(async () => { + attempts += 1 + return attempts <= FAILURES_BEFORE_SUCCESS + }) + + scheduler.schedule(run) + await vi.advanceTimersByTimeAsync(31_000) + + expect(attempts).toBe(FAILURES_BEFORE_SUCCESS + 1) + expect(scheduler.isScheduled).toBe(false) + + await vi.advanceTimersByTimeAsync(60_000) + expect(attempts).toBe(FAILURES_BEFORE_SUCCESS + 1) + }) + + // The property the pane depends on: retries are finite, and it is told when they run out. An + // unbounded scheduler leaves a dead stream retrying forever behind a recurring error. + it('stops after the budget and reports it exactly once', async () => { + vi.useFakeTimers() + const onExhausted = vi.fn() + const scheduler = new RemoteBrowserStreamRestartScheduler(undefined, onExhausted) + const run = vi.fn(async () => true) + + scheduler.schedule(run) + await vi.advanceTimersByTimeAsync(60_000) + + expect(run).toHaveBeenCalledTimes(5) + expect(onExhausted).toHaveBeenCalledTimes(1) + expect(scheduler.isBudgetExhausted).toBe(true) + expect(scheduler.isScheduled).toBe(false) + + await vi.advanceTimersByTimeAsync(300_000) + expect(run).toHaveBeenCalledTimes(5) + }) + + it('reset gives a spent scheduler its budget back so the user can ask again', async () => { + vi.useFakeTimers() + const scheduler = new RemoteBrowserStreamRestartScheduler([10, 20]) + const run = vi.fn(async () => true) + + scheduler.schedule(run) + await vi.advanceTimersByTimeAsync(1_000) + expect(run).toHaveBeenCalledTimes(2) + + scheduler.reset() + scheduler.schedule(run) + await vi.advanceTimersByTimeAsync(1_000) + expect(run).toHaveBeenCalledTimes(4) + }) + + it('keeps retrying when an attempt rejects rather than dropping the cycle', async () => { + vi.useFakeTimers() + const onExhausted = vi.fn() + const scheduler = new RemoteBrowserStreamRestartScheduler([10, 20, 40], onExhausted) + const run = vi.fn(async () => { + throw new Error('start failed') + }) + + scheduler.schedule(run) + await vi.advanceTimersByTimeAsync(1_000) + + expect(run).toHaveBeenCalledTimes(3) + expect(onExhausted).toHaveBeenCalledTimes(1) + }) + + // Why: clearTimeout cannot recall an attempt already dispatched into an await. Pre-fix, a cancel() + // during that window was a no-op and the resolving attempt re-armed a "cancelled" scheduler. + it('does not re-arm when cancelled while an attempt is already in flight', async () => { + vi.useFakeTimers() + const scheduler = new RemoteBrowserStreamRestartScheduler() + let resolveAttempt: ((shouldRetry: boolean) => void) | undefined + const run = vi.fn( + () => + new Promise((resolve) => { + resolveAttempt = resolve + }) + ) + + scheduler.schedule(run) + await vi.advanceTimersByTimeAsync(500) + expect(run).toHaveBeenCalledTimes(1) + expect(scheduler.isScheduled).toBe(true) + + scheduler.cancel() + resolveAttempt?.(true) // the in-flight attempt reports a transient failure after the cancel + await vi.advanceTimersByTimeAsync(0) + + expect(scheduler.isScheduled).toBe(false) + await vi.advanceTimersByTimeAsync(60_000) + expect(run).toHaveBeenCalledTimes(1) + }) + + it('still retries normally when an in-flight attempt resolves without an intervening cancel', async () => { + vi.useFakeTimers() + const scheduler = new RemoteBrowserStreamRestartScheduler() + let resolveAttempt: ((shouldRetry: boolean) => void) | undefined + const run = vi.fn( + () => + new Promise((resolve) => { + resolveAttempt = resolve + }) + ) + + scheduler.schedule(run) + await vi.advanceTimersByTimeAsync(500) + resolveAttempt?.(true) + await vi.advanceTimersByTimeAsync(0) + expect(scheduler.isScheduled).toBe(true) + + await vi.advanceTimersByTimeAsync(1_000) + expect(run).toHaveBeenCalledTimes(2) + }) + + it('queues one replacement chain while the current attempt is in flight', async () => { + vi.useFakeTimers() + const scheduler = new RemoteBrowserStreamRestartScheduler([10, 20]) + let resolveAttempt: ((shouldRetry: boolean) => void) | undefined + const firstRun = vi.fn( + () => + new Promise((resolve) => { + resolveAttempt = resolve + }) + ) + const replacementRun = vi.fn(async () => false) + + scheduler.schedule(firstRun) + await vi.advanceTimersByTimeAsync(10) + scheduler.schedule(replacementRun) + + expect(scheduler.attemptCount).toBe(1) + resolveAttempt?.(false) + await vi.advanceTimersByTimeAsync(20) + + expect(firstRun).toHaveBeenCalledTimes(1) + expect(replacementRun).toHaveBeenCalledTimes(1) + expect(scheduler.isScheduled).toBe(false) + }) + + it('grows the delay per counted attempt, never by elapsed wall time, and then stops', async () => { + vi.useFakeTimers() + const scheduler = new RemoteBrowserStreamRestartScheduler() + const setTimeoutSpy = vi.spyOn(global, 'setTimeout') + const run = vi.fn(async () => true) + + scheduler.schedule(run) + await vi.advanceTimersByTimeAsync(60_000) + const observedDelays = setTimeoutSpy.mock.calls.map((call) => call[1]) + + // Literal on purpose: asserting against the constant under test makes the ladder self-certifying + // — verified, a change to 12 flat 1s attempts passed the whole suite. The budget's size and shape + // are user-visible (how long the pane waits before handing control back), so they get pinned here. + expect(observedDelays).toEqual([500, 1_000, 2_000, 4_000, 8_000]) + }) + + it('does not double-schedule while a restart is already pending', () => { + vi.useFakeTimers() + const scheduler = new RemoteBrowserStreamRestartScheduler() + const run = vi.fn(async () => true) + + scheduler.schedule(run) + expect(scheduler.attemptCount).toBe(1) + scheduler.schedule(run) // no-op: already scheduled + expect(scheduler.attemptCount).toBe(1) + expect(scheduler.isScheduled).toBe(true) + }) + + it('stops the retry chain and clears the timer when run resolves false (e.g. superseded or success)', async () => { + vi.useFakeTimers() + const scheduler = new RemoteBrowserStreamRestartScheduler() + const run = vi.fn(async () => false) + + scheduler.schedule(run) + await vi.advanceTimersByTimeAsync(500) + + expect(run).toHaveBeenCalledTimes(1) + expect(scheduler.isScheduled).toBe(false) + + await vi.advanceTimersByTimeAsync(60_000) + expect(run).toHaveBeenCalledTimes(1) + }) + + it('reset() forgets prior failures so the next drop backs off from the first delay again', async () => { + vi.useFakeTimers() + const scheduler = new RemoteBrowserStreamRestartScheduler() + const setTimeoutSpy = vi.spyOn(global, 'setTimeout') + const run = vi.fn().mockResolvedValueOnce(true).mockResolvedValue(false) + + scheduler.schedule(run) + await vi.advanceTimersByTimeAsync(1_500) + expect(scheduler.attemptCount).toBe(2) + + // A confirmed-live stream ("ready") resets the counter. + scheduler.reset() + expect(scheduler.attemptCount).toBe(0) + + scheduler.schedule(run) + const call = setTimeoutSpy.mock.calls.at(-1) + expect(call?.[1]).toBe(500) + }) + + it('cancel() clears a pending timer and resets the attempt count', async () => { + vi.useFakeTimers() + const scheduler = new RemoteBrowserStreamRestartScheduler() + const run = vi.fn(async () => true) + + scheduler.schedule(run) + expect(scheduler.isScheduled).toBe(true) + scheduler.cancel() + + expect(scheduler.isScheduled).toBe(false) + expect(scheduler.attemptCount).toBe(0) + + await vi.advanceTimersByTimeAsync(60_000) + expect(run).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.ts b/src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.ts new file mode 100644 index 00000000000..9ef35acc8a6 --- /dev/null +++ b/src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.ts @@ -0,0 +1,109 @@ +// Why a retry budget rather than "retry until it works" or "give up after one try": both extremes +// fail the user. One attempt strands the pane on any blip with no way back. Unbounded retry hides a +// genuinely dead stream behind background work that never stops and an error that keeps reappearing. +// +// The budget covers the transient case invisibly, then hands control back: once it is spent the pane +// reports that it stopped and offers an explicit reconnect. That also removes the sharpest edge in +// this area — misjudging a failure as permanent is no longer unrecoverable, because the user always +// has a way to ask again. +// +// Counts attempts, not elapsed time. A host that refuses fast spends the budget in ~16s; one that +// accepts and then hangs spends it far more slowly, since each attempt carries its own timeout. +export const REMOTE_BROWSER_STREAM_RESTART_DELAYS_MS: readonly number[] = [ + 500, 1_000, 2_000, 4_000, 8_000 +] + +export type RemoteBrowserStreamRestartAttempt = () => Promise + +export class RemoteBrowserStreamRestartScheduler { + private attempt = 0 + private timer: ReturnType | null = null + private generation = 0 + private inFlightGeneration: number | null = null + private queuedRun: RemoteBrowserStreamRestartAttempt | null = null + + constructor( + private readonly delaysMs: readonly number[] = REMOTE_BROWSER_STREAM_RESTART_DELAYS_MS, + private readonly onBudgetExhausted: () => void = () => {} + ) {} + + // Kept for tests: reset()/cancel() restoring the budget is behaviour worth asserting directly, + // and the counter is the only honest way to observe it. + get attemptCount(): number { + return this.attempt + } + + get isScheduled(): boolean { + return ( + this.timer !== null || this.inFlightGeneration === this.generation || this.queuedRun !== null + ) + } + + get isBudgetExhausted(): boolean { + return this.attempt >= this.delaysMs.length + } + + // Why: run() resolves true to keep retrying (transient failure), false to stop (success or + // superseded/missing). Retries stop regardless once the budget is spent. + schedule(run: RemoteBrowserStreamRestartAttempt): void { + if (this.timer !== null) { + return + } + if (this.inFlightGeneration === this.generation) { + this.queuedRun = run + return + } + if (this.isBudgetExhausted) { + this.onBudgetExhausted() + return + } + const delayMs = this.delaysMs[this.attempt]! + this.attempt += 1 + const generation = this.generation + this.timer = setTimeout(() => { + this.timer = null + if (generation !== this.generation) { + return + } + this.inFlightGeneration = generation + void Promise.resolve() + .then(run) + .then( + (shouldRetry) => this.finishAttempt(generation, run, shouldRetry), + () => this.finishAttempt(generation, run, true) + ) + }, delayMs) + } + + // Why: a confirmed-live stream ('ready') forgets prior failures so the next drop backs off from + // scratch — and gets the whole budget again rather than the tail of an earlier one. + reset(): void { + this.attempt = 0 + } + + cancel(): void { + if (this.timer !== null) { + clearTimeout(this.timer) + this.timer = null + } + this.queuedRun = null + this.generation += 1 + this.attempt = 0 + } + + private finishAttempt( + generation: number, + run: RemoteBrowserStreamRestartAttempt, + shouldRetry: boolean + ): void { + if (generation !== this.generation) { + return + } + this.inFlightGeneration = null + const nextRun = this.queuedRun ?? (shouldRetry ? run : null) + this.queuedRun = null + if (nextRun) { + this.schedule(nextRun) + } + } +} diff --git a/src/renderer/src/components/browser-pane/remote-browser-stream-status.test.ts b/src/renderer/src/components/browser-pane/remote-browser-stream-status.test.ts new file mode 100644 index 00000000000..d86bb71486a --- /dev/null +++ b/src/renderer/src/components/browser-pane/remote-browser-stream-status.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { + canReconnectRemoteBrowserStream, + isRemoteBrowserStreamBusy, + REMOTE_BROWSER_STREAM_IDLE, + REMOTE_BROWSER_STREAM_LIVE, + REMOTE_BROWSER_STREAM_OPENING, + remoteBrowserStreamNotice, + remoteBrowserStreamRetrying, + remoteBrowserStreamStopped, + type RemoteBrowserStreamStatus +} from './remote-browser-stream-status' + +// These pin the derivation rules, which are what make the four defects three review rounds found +// unrepresentable. The type stops you writing an invalid state; these stop the derivations drifting +// so that a valid state renders as an invalid one. +const ALL: RemoteBrowserStreamStatus[] = [ + REMOTE_BROWSER_STREAM_IDLE, + REMOTE_BROWSER_STREAM_OPENING, + REMOTE_BROWSER_STREAM_LIVE, + remoteBrowserStreamRetrying('Lost connection to the remote server.'), + remoteBrowserStreamStopped('Lost connection to the remote server.') +] + +describe('remote browser stream status', () => { + // "stopped, but no message" made the reconnect control unrenderable, because it lives inside the + // notice. The constructor requires one, so this holds by construction — asserted anyway because + // the render coupling is the part no unit test can see. + it('always carries a notice in the states that report a failure', () => { + expect(remoteBrowserStreamNotice(remoteBrowserStreamStopped('gone'))).toBe('gone') + expect(remoteBrowserStreamNotice(remoteBrowserStreamRetrying('dropped'))).toBe('dropped') + }) + + // The budget exists to absorb a blip invisibly, so a retry that has not failed yet says nothing. + it('stays silent while retrying until an attempt has actually failed', () => { + expect(remoteBrowserStreamNotice(remoteBrowserStreamRetrying(null))).toBeNull() + expect(isRemoteBrowserStreamBusy(remoteBrowserStreamRetrying(null))).toBe(true) + expect(canReconnectRemoteBrowserStream(remoteBrowserStreamRetrying(null))).toBe(false) + }) + + it('reports no notice while nothing has failed', () => { + for (const status of [ + REMOTE_BROWSER_STREAM_IDLE, + REMOTE_BROWSER_STREAM_OPENING, + REMOTE_BROWSER_STREAM_LIVE + ]) { + expect(remoteBrowserStreamNotice(status)).toBeNull() + } + }) + + // "retrying, but the control is up" offered a manual retry ~500ms before the automatic one. + it('offers reconnect only once automatic recovery is over', () => { + expect(canReconnectRemoteBrowserStream(remoteBrowserStreamStopped('gone'))).toBe(true) + for (const status of ALL.filter((candidate) => candidate.kind !== 'stopped')) { + expect(canReconnectRemoteBrowserStream(status)).toBe(false) + } + }) + + // "stopped, but still busy" left a spinner over a frozen frame, which also blocked the pane's + // own input handlers. + it('is never busy in a state the user is expected to act on', () => { + expect(isRemoteBrowserStreamBusy(remoteBrowserStreamStopped('gone'))).toBe(false) + expect(isRemoteBrowserStreamBusy(REMOTE_BROWSER_STREAM_LIVE)).toBe(false) + expect(isRemoteBrowserStreamBusy(REMOTE_BROWSER_STREAM_IDLE)).toBe(false) + }) + + it('is busy exactly while recovery is in flight', () => { + expect(isRemoteBrowserStreamBusy(REMOTE_BROWSER_STREAM_OPENING)).toBe(true) + expect(isRemoteBrowserStreamBusy(remoteBrowserStreamRetrying('dropped'))).toBe(true) + }) + + // The combination that must never be derivable: something to act on, with no way to act. + it('never offers reconnect without a notice to render it in', () => { + for (const status of ALL) { + if (canReconnectRemoteBrowserStream(status)) { + expect(remoteBrowserStreamNotice(status)).not.toBeNull() + expect(isRemoteBrowserStreamBusy(status)).toBe(false) + } + } + }) +}) diff --git a/src/renderer/src/components/browser-pane/remote-browser-stream-status.ts b/src/renderer/src/components/browser-pane/remote-browser-stream-status.ts new file mode 100644 index 00000000000..5d1bd31744d --- /dev/null +++ b/src/renderer/src/components/browser-pane/remote-browser-stream-status.ts @@ -0,0 +1,95 @@ +import { translate } from '@/i18n/i18n' + +// Why one value instead of the three booleans/strings this replaces (error, reconnectAvailable, +// busy): they always described a single thing — what this stream is doing right now — but were +// written independently from ~15 sites, so nothing stopped them disagreeing. Every defect three +// review rounds found in this area was one such disagreement: +// +// stopped, but no affordance -> the pane stranded (the bug this work exists to fix) +// stopped, but no message -> the control renders inside the message, so it could not render +// retrying, but affordance shown -> offered a manual retry ~500ms before the automatic one +// stopped, but still busy -> a spinner over a frozen frame, blocking the pane's own input +// +// Expressed as one tagged value, none of those four is a state you can write down. `busy`, the +// message, and whether the reconnect control appears are all derived from it, so they cannot drift. +export type RemoteBrowserStreamStatus = + | { kind: 'idle' } + /** Establishing a stream: no stream yet, and no failure to report. */ + | { kind: 'opening' } + /** A confirmed-live stream; the host has sent 'ready'. */ + | { kind: 'live' } + // Automatic recovery is running. The notice is null until an attempt has actually failed, so a + // blip the budget absorbs in 500ms stays invisible — which is the whole point of having a budget. + | { kind: 'retrying'; notice: string | null } + /** Automatic recovery is over. This is the only state that offers a reconnect. */ + | { kind: 'stopped'; notice: string } + +export const REMOTE_BROWSER_STREAM_IDLE: RemoteBrowserStreamStatus = { kind: 'idle' } +export const REMOTE_BROWSER_STREAM_OPENING: RemoteBrowserStreamStatus = { kind: 'opening' } +export const REMOTE_BROWSER_STREAM_LIVE: RemoteBrowserStreamStatus = { kind: 'live' } + +export function remoteBrowserStreamRetrying(notice: string | null): RemoteBrowserStreamStatus { + return { kind: 'retrying', notice } +} + +export function remoteBrowserStreamStopped(notice: string): RemoteBrowserStreamStatus { + return { kind: 'stopped', notice } +} + +/** Why the pane must not paint a stale frame as interactive: nothing is arriving in these states. */ +export function isRemoteBrowserStreamBusy(status: RemoteBrowserStreamStatus): boolean { + return status.kind === 'opening' || status.kind === 'retrying' +} + +/** The stream's own message. Incidental notices (input failures, URL validation) are separate. */ +export function remoteBrowserStreamNotice(status: RemoteBrowserStreamStatus): string | null { + if (status.kind === 'stopped') { + return status.notice + } + return status.kind === 'retrying' ? status.notice : null +} + +// Why only 'stopped': while attempts remain, a manual control competes with the automatic recovery +// that is about to run anyway; and once live there is nothing to reconnect. +export function canReconnectRemoteBrowserStream(status: RemoteBrowserStreamStatus): boolean { + return status.kind === 'stopped' +} + +// Why the pane authors these rather than forwarding the failure's own message: those strings come +// from the transport layer and are written for logs (e.g. "Runtime environment is manually +// disconnected." — an RPC error with the code runtime_manually_disconnected). They name our +// internals rather than the user's situation and none of them change what the user can do, which +// the reconnect control already says. The raw error is still logged, so diagnosis keeps the detail +// the UI drops. A failure we classified ourselves keeps its own message: it is specific and true, +// and flattening "The selected runtime does not support remote browser streaming." into "lost +// connection" would be both vaguer and wrong. + +/** The stream was established and then died. */ +export function remoteBrowserStreamLostNotice(): string { + return translate( + 'auto.components.BrowserPane.streamConnectionLost', + 'Lost connection to the remote server.' + ) +} + +/** Nothing was ever established — saying "lost" would name something the user never had. */ +export function remoteBrowserStreamUnreachableNotice(): string { + return translate( + 'auto.components.BrowserPane.streamConnectionUnreachable', + 'Cannot reach the remote server.' + ) +} + +export function remoteBrowserStreamRestartFailedNotice(): string { + return translate( + 'auto.components.BrowserPane.streamRestartFailed', + 'Failed to restart remote browser stream.' + ) +} + +export function remoteBrowserStreamUnsupportedNotice(): string { + return translate( + 'auto.components.BrowserPane.streamCapabilityUnsupported', + 'The selected runtime does not support remote browser streaming.' + ) +} diff --git a/src/renderer/src/components/browser-pane/remote-browser-stream-tokens.ts b/src/renderer/src/components/browser-pane/remote-browser-stream-tokens.ts new file mode 100644 index 00000000000..0f33af80c1b --- /dev/null +++ b/src/renderer/src/components/browser-pane/remote-browser-stream-tokens.ts @@ -0,0 +1,146 @@ +export type RemoteBrowserViewportSize = { + width: number + height: number +} + +// Why: sub-pixel jitter from the resize observer must not count as a resize, or every layout +// settle would tear down a healthy stream. +export function areRemoteViewportSizesNear( + a: RemoteBrowserViewportSize | null, + b: RemoteBrowserViewportSize | null +): boolean { + if (!a || !b) { + return false + } + return Math.abs(a.width - b.width) <= 3 && Math.abs(a.height - b.height) <= 3 +} + +export type RemoteBrowserStreamToken = { + tabId: string + environmentId: string + remotePageId: string + generation: number + operationGeneration: number +} + +export type RemoteBrowserStreamSubscription = { + token: RemoteBrowserStreamToken + unsubscribe: () => void +} + +export type RemoteBrowserOperationToken = { + tabId: string + environmentId: string + remotePageId: string | null + generation: number +} + +// Why: pane identity lives in React refs; the lifecycle reads it through this port so the same +// staleness guards can run in a test with no component mounted. +export type RemoteBrowserPaneIdentity = { + isMounted(): boolean + isActive(): boolean + getTabId(): string + getEnvironmentId(): string | null + browserPageExists(tabId: string): boolean +} + +// Owns the two generation counters every async remote-browser operation is guarded by: the +// operation generation (a new pane/page/environment epoch) and the stream generation (one +// screencast subscription). Anything that supersedes work bumps one of them. +export class RemoteBrowserOperationTokens { + private operationGeneration = 0 + private streamGeneration = 0 + private activeStreamToken: RemoteBrowserStreamToken | null = null + private remotePageId: string | null = null + + constructor(private readonly identity: RemoteBrowserPaneIdentity) {} + + get remotePage(): string | null { + return this.remotePageId + } + + setRemotePage(remotePageId: string | null): void { + this.remotePageId = remotePageId + } + + createOperationToken(remotePageId: string | null = null): RemoteBrowserOperationToken | null { + const environmentId = this.identity.getEnvironmentId() + if (!environmentId) { + return null + } + return { + tabId: this.identity.getTabId(), + environmentId, + remotePageId, + generation: this.operationGeneration + } + } + + isCurrent(token: RemoteBrowserOperationToken): boolean { + return ( + this.identity.isMounted() && + this.identity.isActive() && + this.identity.browserPageExists(token.tabId) && + this.identity.getTabId() === token.tabId && + this.identity.getEnvironmentId() === token.environmentId && + this.operationGeneration === token.generation && + (token.remotePageId === null || this.remotePageId === token.remotePageId) + ) + } + + isCurrentStreamOperation(token: RemoteBrowserStreamToken): boolean { + return this.isCurrent(toOperationToken(token)) + } + + isCurrentStreamToken(token: RemoteBrowserStreamToken): boolean { + const active = this.activeStreamToken + return ( + active?.generation === token.generation && + active.operationGeneration === token.operationGeneration && + active.tabId === token.tabId && + active.environmentId === token.environmentId && + active.remotePageId === token.remotePageId && + this.isCurrentStreamOperation(token) + ) + } + + // Why: every supersession site bumps the operation generation so in-flight awaits fail their own + // token guard on resume — clearing a timer cannot recall work already dispatched into an await. + supersedeOperations(): void { + this.operationGeneration += 1 + } + + supersedeStream(): void { + this.streamGeneration += 1 + } + + claimStreamToken( + operationToken: RemoteBrowserOperationToken, + remotePageId: string + ): RemoteBrowserStreamToken { + const token: RemoteBrowserStreamToken = { + tabId: this.identity.getTabId(), + environmentId: operationToken.environmentId, + remotePageId, + generation: this.streamGeneration + 1, + operationGeneration: operationToken.generation + } + this.streamGeneration = token.generation + this.activeStreamToken = token + return token + } + + releaseStreamToken(): void { + this.activeStreamToken = null + } +} + +export function toOperationToken(token: RemoteBrowserStreamToken): RemoteBrowserOperationToken { + return { + tabId: token.tabId, + environmentId: token.environmentId, + remotePageId: token.remotePageId, + generation: token.operationGeneration + } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 9cdfd6ecc18..8c8e9307ba8 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -13871,6 +13871,7 @@ "b472c5fe03": "Add browser annotation", "b5ba6085de": "Question", "143204e423": "Change", + "b71dc3d930": "Reconnect", "e7ca5a098c": "success", "d51ef37351": "Copy", "6f4ab3592b": "Copied", @@ -14804,6 +14805,12 @@ "pendingChip": "{{value0}} pending", "needsActionChip": "{{value0}} action required", "unresolvedChip": "{{value0}} unresolved" + }, + "BrowserPane": { + "streamConnectionLost": "Lost connection to the remote server.", + "streamConnectionUnreachable": "Cannot reach the remote server.", + "streamRestartFailed": "Failed to restart remote browser stream.", + "streamCapabilityUnsupported": "The selected runtime does not support remote browser streaming." } }, "i18n": { diff --git a/tests/e2e/paired-remote-browser-stream-reconnect.spec.ts b/tests/e2e/paired-remote-browser-stream-reconnect.spec.ts new file mode 100644 index 00000000000..d67f82d22dc --- /dev/null +++ b/tests/e2e/paired-remote-browser-stream-reconnect.spec.ts @@ -0,0 +1,266 @@ +import type { Page } from '@stablyai/playwright-test' +import { + launchHeadlessPairedRuntimeHost, + type HeadlessPairedRuntimeHost +} from './helpers/headless-paired-runtime-host' +import { expect, test } from './helpers/orca-app' +import { + launchPairedElectronClient, + type PairedElectronClient +} from './helpers/paired-electron-client' + +// What this proves: a paired client whose remote browser stream drops retries on a bounded budget, +// then surfaces a Reconnect control instead of either giving up silently or retrying forever. The +// failure is real, not injected — the runtime connection is dropped, so every restart genuinely +// fails while it is down. + +async function callEnvironment( + page: Page, + environmentId: string, + method: string, + params: unknown +): Promise { + return page.evaluate( + async ({ environmentId, method, params }) => { + const response = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method, + params + }) + if (!response.ok) { + throw new Error(`${response.error.code}: ${response.error.message}`) + } + return response.result + }, + { environmentId, method, params } + ) as Promise +} + +/** Mirrors what the client does when the user opens a browser tab on a paired host. */ +async function attachRemoteBrowserPane( + page: Page, + environmentId: string, + worktreeId: string, + remotePageId: string +): Promise { + await page.evaluate( + ({ environmentId, worktreeId, remotePageId }) => { + const state = window.__store?.getState() + if (!state) { + throw new Error('client store unavailable') + } + const browserTab = state.createBrowserTab(worktreeId, 'about:blank', { + title: 'New Browser Tab', + browserRuntimeEnvironmentId: environmentId + }) + const pageId = browserTab.activePageId ?? browserTab.pageIds?.[0] ?? null + if (!pageId) { + throw new Error('client did not allocate a browser page id') + } + state.setRemoteBrowserPageHandle(pageId, { environmentId, remotePageId }) + state.setActiveWorktree(worktreeId) + state.focusBrowserTabInWorktree(worktreeId, browserTab.id, { surfacePane: true }) + }, + { environmentId, worktreeId, remotePageId } + ) +} + +test('bounds remote browser stream retries, then offers reconnect', async ({ + testRepoPath +}, testInfo) => { + test.setTimeout(300_000) + const host: HeadlessPairedRuntimeHost = await launchHeadlessPairedRuntimeHost() + let client: PairedElectronClient | null = null + + try { + await host.client.call('repo.add', { path: testRepoPath, kind: 'git' }) + client = await launchPairedElectronClient(host.offer, testInfo, 'Remote browser reconnect') + const page = client.page + + await expect + .poll(() => page.evaluate(() => window.__store?.getState().allWorktrees().length ?? 0), { + timeout: 60_000, + message: 'paired client never saw a host worktree' + }) + .toBeGreaterThan(0) + const worktreeId = await page.evaluate( + () => window.__store?.getState().allWorktrees()[0]?.id ?? null + ) + if (!worktreeId) { + throw new Error('paired client did not receive the host worktree') + } + + const created = await callEnvironment<{ browserPageId: string }>( + page, + client.environmentId, + 'browser.tabCreate', + { worktree: `id:${worktreeId}`, url: 'about:blank', activate: true } + ) + await attachRemoteBrowserPane(page, client.environmentId, worktreeId, created.browserPageId) + + // The stream is live once the pane paints a remote frame. + const remoteFrame = page.getByTestId('remote-browser-frame').first() + await expect(remoteFrame).toBeVisible({ timeout: 60_000 }) + + const errorToast = page.getByTestId('remote-browser-stream-error') + const reconnectButton = page.getByRole('button', { name: 'Reconnect' }) + await expect(reconnectButton).toHaveCount(0) + + // Leave a pane-owned notice on screen before the drop. It outranks the stream's own message by + // design (it is the only response to what the user typed), so if nothing clears it on a status + // change, the stranded pane below reports a stale URL complaint next to its Reconnect button and + // never says what actually happened. The 'Lost connection' assertion after the drop is what + // catches that. + // Scoped to the pane holding the remote frame: a workspace can hold more than one browser pane. + const remotePane = page + .getByTestId('remote-browser-pane') + .filter({ has: page.getByTestId('remote-browser-frame') }) + const addressBar = remotePane.locator('[data-orca-browser-address-bar="true"]') + await addressBar.click() + await addressBar.fill('about:config') + await addressBar.press('Enter') + await expect(errorToast).toContainText('Enter a valid http(s) or localhost URL.') + + // Drop the runtime connection: the stream closes and every restart attempt now genuinely fails. + await page.evaluate(async (selector) => { + await window.api.runtimeEnvironments.disconnect({ selector }) + }, client.environmentId) + + // Mid-budget: the pane is retrying, so it must NOT be offering reconnect yet. + await expect(errorToast).toBeVisible({ timeout: 60_000 }) + // The pane must speak for itself here, not echo the transport's log string. + await expect(errorToast).toContainText('Lost connection to the remote server.') + await expect(errorToast).not.toContainText('Runtime environment') + await expect(reconnectButton).toHaveCount(0) + + // Budget spans 500ms+1s+2s+4s+8s; once spent the control appears and retrying stops. + await expect(reconnectButton).toBeVisible({ timeout: 90_000 }) + await page.screenshot({ + path: testInfo.outputPath('remote-browser-reconnect.png'), + fullPage: false + }) + await testInfo.attach('reconnect-ui', { + path: testInfo.outputPath('remote-browser-reconnect.png'), + contentType: 'image/png' + }) + + // It must stay put rather than quietly resuming a retry loop. + await page.waitForTimeout(15_000) + await expect(reconnectButton).toBeVisible() + + // Clicking the frozen frame is the natural reaction to a stuck pane, and the input handlers + // clear the error optimistically — so without a guard this exact gesture deletes the user's only + // way back, then repaints the raw transport string once the queued RPC fails. + await remoteFrame.click({ position: { x: 40, y: 40 }, force: true }) + await page.waitForTimeout(1_000) + await expect(reconnectButton).toBeVisible() + await expect(errorToast).not.toContainText('Runtime environment') + + await expect + .poll( + () => + page.evaluate(async (selector) => { + const response = await window.api.runtimeEnvironments.connect({ selector }) + return response.ok + }, client!.environmentId), + { timeout: 60_000, message: 'paired client never reconnected to the host runtime' } + ) + .toBe(true) + + // Why the frame src and not just visibility: the pane deliberately keeps the last frame painted + // during a restart, so "an image is showing" is also true of a frozen, dead stream. A changed + // object URL means a NEW frame actually arrived, which only a live subscription can produce. + const frozenFrameSrc = await remoteFrame.getAttribute('src') + await reconnectButton.click() + await expect(errorToast).toHaveCount(0, { timeout: 60_000 }) + await expect(remoteFrame).toBeVisible({ timeout: 60_000 }) + await expect + .poll(() => remoteFrame.getAttribute('src'), { + timeout: 60_000, + message: 'remote stream never delivered a new frame after reconnect' + }) + .not.toBe(frozenFrameSrc) + } finally { + await client?.dispose() + await host.dispose() + } +}) + +// The other door into the same failure: the pane never had a stream to lose. A drop-only fix leaves +// this one stranded on a bare error, which is the original bug wearing a different hat. +test('offers reconnect when the remote browser never opens at all', async ({ + testRepoPath +}, testInfo) => { + test.setTimeout(300_000) + const host: HeadlessPairedRuntimeHost = await launchHeadlessPairedRuntimeHost() + let client: PairedElectronClient | null = null + + try { + await host.client.call('repo.add', { path: testRepoPath, kind: 'git' }) + client = await launchPairedElectronClient(host.offer, testInfo, 'Remote browser cold failure') + const page = client.page + + await expect + .poll(() => page.evaluate(() => window.__store?.getState().allWorktrees().length ?? 0), { + timeout: 60_000, + message: 'paired client never saw a host worktree' + }) + .toBeGreaterThan(0) + const worktreeId = await page.evaluate( + () => window.__store?.getState().allWorktrees()[0]?.id ?? null + ) + if (!worktreeId) { + throw new Error('paired client did not receive the host worktree') + } + + const created = await callEnvironment<{ browserPageId: string }>( + page, + client.environmentId, + 'browser.tabCreate', + { worktree: `id:${worktreeId}`, url: 'about:blank', activate: true } + ) + + // Drop the connection BEFORE the pane mounts, so the very first open fails and no stream ever + // exists. Nothing here can be recovered by restarting a subscription. + await page.evaluate(async (selector) => { + await window.api.runtimeEnvironments.disconnect({ selector }) + }, client.environmentId) + await attachRemoteBrowserPane(page, client.environmentId, worktreeId, created.browserPageId) + + const errorToast = page.getByTestId('remote-browser-stream-error') + const reconnectButton = page.getByRole('button', { name: 'Reconnect' }) + await expect(errorToast).toBeVisible({ timeout: 90_000 }) + await expect(errorToast).toContainText('Cannot reach the remote server.') + await expect(errorToast).not.toContainText('Runtime environment') + await expect(reconnectButton).toBeVisible({ timeout: 90_000 }) + await page.screenshot({ + path: testInfo.outputPath('remote-browser-cold-failure.png'), + fullPage: false + }) + await testInfo.attach('reconnect-ui-cold-failure', { + path: testInfo.outputPath('remote-browser-cold-failure.png'), + contentType: 'image/png' + }) + + await expect + .poll( + () => + page.evaluate(async (selector) => { + const response = await window.api.runtimeEnvironments.connect({ selector }) + return response.ok + }, client!.environmentId), + { timeout: 60_000, message: 'paired client never reconnected to the host runtime' } + ) + .toBe(true) + + // Reconnect must build the stream from nothing, not resume something that never existed. + await reconnectButton.click() + await expect(errorToast).toHaveCount(0, { timeout: 60_000 }) + await expect(page.getByTestId('remote-browser-frame').first()).toBeVisible({ + timeout: 60_000 + }) + } finally { + await client?.dispose() + await host.dispose() + } +}) From f8786d5224f5b5593e9c577045c7b9b20eec38a7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:06:37 -0700 Subject: [PATCH 02/45] fix(agent-history): fold trailing-slash, NFD/NFC, and project-fallback folder group keys (#12458) Co-authored-by: Orca --- .../ai-vault-session-filters.test.ts | 4 +- .../ai-vault-session-projects.test.ts | 26 ++++++ .../ai-vault-session-projects.ts | 22 ++--- src/shared/ai-vault-session-filters.test.ts | 84 +++++++++++++++++++ src/shared/ai-vault-session-filters.ts | 30 +++++-- 5 files changed, 143 insertions(+), 23 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts index 63139bdcd71..73e28674b85 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts @@ -664,7 +664,7 @@ describe('groupAiVaultSessions', () => { ] expect(groupAiVaultSessions(sessions, 'folder')).toEqual([ - { key: '/users/ada/repo/app', label: 'repo/app', sessions } + { key: 'folder:/Users/ada/repo/app', label: 'repo/app', sessions } ]) expect(groupAiVaultSessions(sessions, 'agent').map((group) => group.label)).toEqual([ 'Claude', @@ -695,7 +695,7 @@ describe('groupAiVaultSessions', () => { it('falls back to folder grouping when project metadata is unavailable', () => { expect(groupAiVaultSessions([baseSession], 'project')).toEqual([ - { key: '/users/ada/repo/app', label: 'repo/app', sessions: [baseSession] } + { key: 'folder:/Users/ada/repo/app', label: 'repo/app', sessions: [baseSession] } ]) }) }) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts index e9d693d7dc0..6c46a144f86 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts @@ -7,6 +7,7 @@ import { buildAiVaultSessionProjectById, toAiVaultProjectKey } from './ai-vault-session-projects' +import { groupAiVaultSessions } from '../../../../shared/ai-vault-session-filters' const baseSession: AiVaultSession = { id: 'claude:1', @@ -383,6 +384,31 @@ describe('buildAiVaultProjectContext', () => { }) }) + it('emits a folder project key the shared folder grouping falls back to', () => { + const repo = makeRepo({ id: 'repo-1', displayName: 'Repo', path: '/repo' }) + const resolved = makeSession({ id: 'claude:resolved', cwd: '/outside/path' }) + const unresolved = makeSession({ id: 'claude:unresolved', cwd: '/outside/path/' }) + + const context = buildAiVaultProjectContext({ + repos: [repo], + worktrees: [], + projectHostSetupProjection: makeProjection({ projects: [], setups: [] }), + activeRepo: repo, + activeWorktree: null, + sessions: [resolved] + }) + + // Both key builders must agree, or one folder shows two identically labelled headers. + const groups = groupAiVaultSessions([resolved, unresolved], 'project', { + sessionProjectById: context.sessionProjectById + }) + expect(groups).toHaveLength(1) + expect(groups[0].sessions.map((session) => session.id)).toEqual([ + 'claude:resolved', + 'claude:unresolved' + ]) + }) + it('ignores blank setup paths instead of treating them as catch-all candidates', () => { const repo = makeRepo({ id: 'repo-1', displayName: 'Repo', path: '/repo' }) const session = makeSession({ id: 'claude:outside', cwd: '/outside/path' }) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts index be78edf4af2..80ed7f3ced7 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts @@ -9,10 +9,13 @@ import type { AiVaultSession } from '../../../../shared/ai-vault-types' import type { ProjectHostSetup, Repo, Worktree } from '../../../../shared/types' import { createNormalizedPathInsideOrEqualMatcher, - normalizeRuntimePathForComparison, - normalizeRuntimePathSeparators + normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' -import type { AiVaultSessionProject } from '../../../../shared/ai-vault-session-filters' +import { + folderGroupKey, + folderLabel, + type AiVaultSessionProject +} from '../../../../shared/ai-vault-session-filters' // Why: the plain project descriptor moved to /shared (so the lifted filter core // stays renderer-free). Re-export it here for renderer import parity. @@ -308,22 +311,13 @@ function compareCandidates(left: SessionProjectCandidate, right: SessionProjectC } function folderProject(cwd: string): AiVaultSessionProject { - const normalizedPath = normalizeRuntimePathForComparison(cwd) return { kind: 'folder', - key: `folder:${normalizedPath}`, - label: compactFolderLabel(cwd) + key: folderGroupKey(cwd), + label: folderLabel(cwd) } } -function compactFolderLabel(pathValue: string): string { - const parts = normalizeRuntimePathSeparators(pathValue).split('/').filter(Boolean) - if (parts.length >= 2) { - return parts.slice(-2).join('/') - } - return parts[0] ?? pathValue -} - function resolveActiveProjectKey( activeRepo: Repo | null, activeWorktree: Worktree | null, diff --git a/src/shared/ai-vault-session-filters.test.ts b/src/shared/ai-vault-session-filters.test.ts index 47a1e10b64d..746c18010ec 100644 --- a/src/shared/ai-vault-session-filters.test.ts +++ b/src/shared/ai-vault-session-filters.test.ts @@ -3,6 +3,7 @@ import type { AiVaultSession } from './ai-vault-types' import { agentLabel, filterAiVaultSessions, + folderGroupKey, folderLabel, groupAiVaultSessions, parseVaultQuery @@ -89,6 +90,89 @@ describe('/shared ai-vault-session-filters (lifted core)', () => { expect(groups.map((group) => group.label).sort()).toEqual(['packages/ui', 'repo/app']) }) + it('folds trailing-slash and repeated-slash cwd spellings into one folder group', () => { + const groups = groupAiVaultSessions( + [ + { ...baseSession, cwd: '/Users/ada/repo/app' }, + { ...baseSession, id: 'claude:2', cwd: '/Users/ada/repo/app/' }, + { ...baseSession, id: 'claude:3', cwd: '/Users/ada//repo/app//' } + ], + 'folder' + ) + expect(groups).toHaveLength(1) + expect(groups[0].sessions).toHaveLength(3) + expect(groups[0].label).toBe('repo/app') + }) + + it('folds NFD and NFC cwd spellings into one folder group with an NFC label', () => { + const groups = groupAiVaultSessions( + [ + { ...baseSession, cwd: '/Users/ada/Café/app'.normalize('NFD') }, + { ...baseSession, id: 'claude:2', cwd: '/Users/ada/Café/app'.normalize('NFC') } + ], + 'folder' + ) + expect(groups).toHaveLength(1) + expect(groups[0].sessions).toHaveLength(2) + expect(groups[0].label).toBe('Café/app'.normalize('NFC')) + }) + + it('folds separator and case variants of one Windows folder', () => { + const groups = groupAiVaultSessions( + [ + { ...baseSession, cwd: 'C:\\Users\\Ada\\repo\\app' }, + { ...baseSession, id: 'claude:2', cwd: 'c:/users/ada/repo/app/' } + ], + 'folder' + ) + expect(groups).toHaveLength(1) + expect(groups[0].sessions).toHaveLength(2) + }) + + it('folds the two WSL UNC aliases of one folder into a single group', () => { + const groups = groupAiVaultSessions( + [ + { ...baseSession, cwd: '//wsl.localhost/Ubuntu/home/ada/repo/app' }, + { ...baseSession, id: 'claude:2', cwd: '//wsl$/Ubuntu/home/ada/repo/app' } + ], + 'folder' + ) + expect(groups).toHaveLength(1) + expect(groups[0].sessions.map((session) => session.id)).toEqual(['claude:1', 'claude:2']) + }) + + it('keeps case-distinct POSIX folders in separate groups', () => { + const groups = groupAiVaultSessions( + [ + { ...baseSession, cwd: '/home/ada/Foo' }, + { ...baseSession, id: 'claude:2', cwd: '/home/ada/foo' } + ], + 'folder' + ) + expect(groups).toHaveLength(2) + }) + + it('folds the project-grouping fallback onto the resolved folder project key', () => { + const resolved = { ...baseSession, cwd: '/Users/ada/repo/app' } + const unresolved = { ...baseSession, id: 'claude:2', cwd: '/Users/ada/repo/app/' } + // Key literal, not folderGroupKey(), so the test still fails if both builders drift together. + const groups = groupAiVaultSessions([resolved, unresolved], 'project', { + sessionProjectById: new Map([ + [ + resolved.id, + { kind: 'folder' as const, key: 'folder:/Users/ada/repo/app', label: 'repo/app' } + ] + ]) + }) + expect(groups).toHaveLength(1) + expect(groups[0].sessions).toHaveLength(2) + }) + + it('keys unknown cwd separately from real folders', () => { + expect(folderGroupKey(null)).toBe('unknown') + expect(folderGroupKey('/Users/ada/repo/app')).toBe('folder:/Users/ada/repo/app') + }) + it('parses repo: and path: operators from the query', () => { expect(parseVaultQuery('hello repo:orca path:/tmp world')).toEqual({ terms: ['hello', 'world'], diff --git a/src/shared/ai-vault-session-filters.ts b/src/shared/ai-vault-session-filters.ts index c943dcf6837..46fafbc8efd 100644 --- a/src/shared/ai-vault-session-filters.ts +++ b/src/shared/ai-vault-session-filters.ts @@ -2,7 +2,11 @@ // It lives in /shared (not renderer) so the mobile package can reuse it — // Metro only watches mobile/ + repo-root src/shared, never src/renderer. // INVARIANT: /shared is a leaf — this module must NOT import from src/renderer. -import { isPathInsideOrEqual, normalizeRuntimePathSeparators } from './cross-platform-path' +import { + isPathInsideOrEqual, + normalizeRuntimePathForComparison, + normalizeRuntimePathSeparators +} from './cross-platform-path' import { isClipboardTextByteLengthOverLimit } from './clipboard-text' import { parseWslUncPath } from './wsl-paths' import type { @@ -143,13 +147,29 @@ export function folderLabel(pathValue: string | null): string { if (!pathValue) { return 'Unknown location' } - const parts = normalizeRuntimePathSeparators(pathValue).split('/').filter(Boolean) + // NFC so one folder renders the same header whichever spelling (macOS NFD vs + // agent-recorded NFC) reaches the group first. + const parts = normalizeRuntimePathSeparators(pathValue.normalize('NFC')) + .split('/') + .filter(Boolean) if (parts.length >= 2) { return parts.slice(-2).join('/') } return parts[0] ?? pathValue } +/** + * Why comparison-normalized: cwd is copied verbatim out of agent transcripts, so + * one folder arrives with and without a trailing slash and in both NFD/NFC — each + * spelling otherwise became its own group under an identical `folderLabel`. Also + * avoids blanket lowercasing, which merged distinct case-sensitive POSIX folders. + * The `folder:` prefix matches the folder project key so project grouping and its + * fallback agree. + */ +export function folderGroupKey(pathValue: string | null): string { + return pathValue ? `folder:${normalizeRuntimePathForComparison(pathValue)}` : 'unknown' +} + export function agentLabel(agent: AiVaultAgent): string { return aiVaultAgentLabel(agent) } @@ -253,11 +273,7 @@ function getGroupIdentity( } } } - return { key: getFolderGroupKey(session.cwd), label: folderLabel(session.cwd) } -} - -function getFolderGroupKey(pathValue: string | null): string { - return pathValue ? normalizeRuntimePathSeparators(pathValue).toLowerCase() : 'unknown' + return { key: folderGroupKey(session.cwd), label: folderLabel(session.cwd) } } function isAiVaultSessionInWorkspacePath(workspacePath: string, sessionCwd: string): boolean { From 2a93cae293d99f85127da8757722e0ff8316d250 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:51:24 -0700 Subject: [PATCH 03/45] fix(crash-reporting): stop filing Windows OS shutdown as renderer crashes (#12938) --- .../expected-teardown-state.test.ts | 193 ++++++++++++++++++ .../expected-teardown-state.ts | 58 ++++++ .../process-gone-killed-one-ordering.test.ts | 149 ++++++++++++++ src/main/index.ts | 22 +- .../startup/desktop-startup-ordering.test.ts | 27 +++ src/main/window/createMainWindow.test.ts | 94 +++++++++ src/main/window/createMainWindow.ts | 6 + 7 files changed, 540 insertions(+), 9 deletions(-) create mode 100644 src/main/crash-reporting/expected-teardown-state.test.ts create mode 100644 src/main/crash-reporting/expected-teardown-state.ts create mode 100644 src/main/crash-reporting/process-gone-killed-one-ordering.test.ts diff --git a/src/main/crash-reporting/expected-teardown-state.test.ts b/src/main/crash-reporting/expected-teardown-state.test.ts new file mode 100644 index 00000000000..17a296bb582 --- /dev/null +++ b/src/main/crash-reporting/expected-teardown-state.test.ts @@ -0,0 +1,193 @@ +import { performance } from 'node:perf_hooks' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + shouldRecordProcessGoneCrash, + shouldRecoverRendererAfterProcessGone +} from './process-gone-classification' +import { + markSystemSessionEnding, + resetExpectedTeardownStateForTest, + resolveExpectedTeardownScope, + WINDOWS_SESSION_END_CRASH_SUPPRESSION_WINDOW_MS +} from './expected-teardown-state' + +function shouldRecordKilledRenderer(expectedTeardown: 'none' | 'renderer-reload' | 'app-shutdown') { + return shouldRecordProcessGoneCrash({ + source: 'renderer', + processType: 'renderer', + reason: 'killed', + exitCode: 1, + expectedTeardown + }) +} + +let now: number + +beforeEach(() => { + now = 1_000 + resetExpectedTeardownStateForTest(() => now) +}) + +afterEach(() => { + vi.restoreAllMocks() + resetExpectedTeardownStateForTest() +}) + +describe('expected teardown state', () => { + it('uses a product-chosen five-second harm bound, not a Windows lifetime guarantee', () => { + // Restart Manager may wait 30s; tree-kills after this bound remain reportable by design. + expect(WINDOWS_SESSION_END_CRASH_SUPPRESSION_WINDOW_MS).toBe(5_000) + }) + + it('uses the production monotonic clock by default', () => { + vi.spyOn(performance, 'now').mockReturnValueOnce(1_000).mockReturnValue(1_001) + vi.spyOn(Date, 'now').mockReturnValueOnce(10_000).mockReturnValue(15_000) + resetExpectedTeardownStateForTest() + + markSystemSessionEnding() + + expect( + resolveExpectedTeardownScope({ + isQuitting: false, + isQuittingForUpdate: false, + isExpectedRendererReload: false + }) + ).toBe('app-shutdown') + }) + + it('does not resurrect session-end after an injected clock rollback and catch-up', () => { + now = 3_600_000 + markSystemSessionEnding() + now = 0 + const rollbackScope = resolveExpectedTeardownScope({ + isQuitting: false, + isQuittingForUpdate: false, + isExpectedRendererReload: false + }) + now = 3_600_001 + const catchUpScope = resolveExpectedTeardownScope({ + isQuitting: false, + isQuittingForUpdate: false, + isExpectedRendererReload: false + }) + + expect(rollbackScope).toBe('none') + expect(catchUpScope).toBe('none') + }) + + it('does not resurrect session-end after expiry and an injected backtrack', () => { + markSystemSessionEnding() + now += WINDOWS_SESSION_END_CRASH_SUPPRESSION_WINDOW_MS + const expiredScope = resolveExpectedTeardownScope({ + isQuitting: false, + isQuittingForUpdate: false, + isExpectedRendererReload: false + }) + now -= 1 + const backtrackScope = resolveExpectedTeardownScope({ + isQuitting: false, + isQuittingForUpdate: false, + isExpectedRendererReload: false + }) + + expect(expiredScope).toBe('none') + expect(backtrackScope).toBe('none') + }) + + it('re-arms the suppression window on repeated session-end events', () => { + markSystemSessionEnding() + now += WINDOWS_SESSION_END_CRASH_SUPPRESSION_WINDOW_MS - 1 + markSystemSessionEnding() + now += WINDOWS_SESSION_END_CRASH_SUPPRESSION_WINDOW_MS - 1 + + expect( + resolveExpectedTeardownScope({ + isQuitting: false, + isQuittingForUpdate: false, + isExpectedRendererReload: false + }) + ).toBe('app-shutdown') + }) + + it('classifies killed/1 just inside the session-end window as app shutdown', () => { + markSystemSessionEnding() + now += WINDOWS_SESSION_END_CRASH_SUPPRESSION_WINDOW_MS - 1 + const scope = resolveExpectedTeardownScope({ + isQuitting: false, + isQuittingForUpdate: false, + isExpectedRendererReload: false + }) + + expect(scope).toBe('app-shutdown') + expect(shouldRecordKilledRenderer(scope)).toBe(false) + }) + + it('keeps killed/1 reportable at and just outside the session-end boundary', () => { + markSystemSessionEnding() + now += WINDOWS_SESSION_END_CRASH_SUPPRESSION_WINDOW_MS + const boundaryScope = resolveExpectedTeardownScope({ + isQuitting: false, + isQuittingForUpdate: false, + isExpectedRendererReload: false + }) + now += 1 + const outsideScope = resolveExpectedTeardownScope({ + isQuitting: false, + isQuittingForUpdate: false, + isExpectedRendererReload: false + }) + + expect(boundaryScope).toBe('none') + expect(outsideScope).toBe('none') + expect(shouldRecordKilledRenderer(outsideScope)).toBe(true) + }) + + it('excludes session-end from recovery while preserving in-app quit suppression', () => { + markSystemSessionEnding() + const sessionEndScope = resolveExpectedTeardownScope({ + isQuitting: false, + isQuittingForUpdate: false, + isExpectedRendererReload: false, + includeSystemSessionEnd: false + }) + const inAppQuitScope = resolveExpectedTeardownScope({ + isQuitting: true, + isQuittingForUpdate: false, + isExpectedRendererReload: false, + includeSystemSessionEnd: false + }) + + expect(sessionEndScope).toBe('none') + expect( + shouldRecoverRendererAfterProcessGone({ + reason: 'killed', + expectedTeardown: sessionEndScope + }) + ).toBe(true) + expect(inAppQuitScope).toBe('app-shutdown') + expect( + shouldRecoverRendererAfterProcessGone({ + reason: 'killed', + expectedTeardown: inAppQuitScope + }) + ).toBe(false) + }) + + it('preserves existing update and renderer-reload scopes', () => { + expect( + resolveExpectedTeardownScope({ + isQuitting: false, + isQuittingForUpdate: true, + isExpectedRendererReload: false + }) + ).toBe('app-shutdown') + expect( + resolveExpectedTeardownScope({ + isQuitting: false, + isQuittingForUpdate: false, + isExpectedRendererReload: true, + includeSystemSessionEnd: false + }) + ).toBe('renderer-reload') + }) +}) diff --git a/src/main/crash-reporting/expected-teardown-state.ts b/src/main/crash-reporting/expected-teardown-state.ts new file mode 100644 index 00000000000..1480ecbbfe2 --- /dev/null +++ b/src/main/crash-reporting/expected-teardown-state.ts @@ -0,0 +1,58 @@ +import { performance } from 'node:perf_hooks' +import type { ExpectedTeardownScope } from './process-gone-classification' + +// Why: 5s bounds harm; Restart Manager may wait 30s, so later kills remain reportable. +export const WINDOWS_SESSION_END_CRASH_SUPPRESSION_WINDOW_MS = 5_000 + +type Clock = () => number + +// Windows performance.now is monotonic and includes time spent suspended. +const monotonicNow = (): number => performance.now() +let now: Clock = monotonicNow +let systemSessionEndedAt: number | null = null + +export function markSystemSessionEnding(): void { + systemSessionEndedAt = now() +} + +function isRecentSystemSessionEnd(): boolean { + if (systemSessionEndedAt === null) { + return false + } + const elapsed = now() - systemSessionEndedAt + if (elapsed < 0) { + systemSessionEndedAt = null + return false + } + if (elapsed >= WINDOWS_SESSION_END_CRASH_SUPPRESSION_WINDOW_MS) { + systemSessionEndedAt = null + return false + } + return true +} + +export function resolveExpectedTeardownScope({ + isQuitting, + isQuittingForUpdate, + isExpectedRendererReload, + includeSystemSessionEnd = true +}: { + isQuitting: boolean + isQuittingForUpdate: boolean + isExpectedRendererReload: boolean + includeSystemSessionEnd?: boolean +}): ExpectedTeardownScope { + if ( + isQuitting || + isQuittingForUpdate || + (includeSystemSessionEnd && isRecentSystemSessionEnd()) + ) { + return 'app-shutdown' + } + return isExpectedRendererReload ? 'renderer-reload' : 'none' +} + +export function resetExpectedTeardownStateForTest(clock: Clock = monotonicNow): void { + now = clock + systemSessionEndedAt = null +} diff --git a/src/main/crash-reporting/process-gone-killed-one-ordering.test.ts b/src/main/crash-reporting/process-gone-killed-one-ordering.test.ts new file mode 100644 index 00000000000..7f47e794252 --- /dev/null +++ b/src/main/crash-reporting/process-gone-killed-one-ordering.test.ts @@ -0,0 +1,149 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { + getVersion: () => '1.2.3-test', + getAppMetrics: () => [] + } +})) + +import { clearCrashBreadcrumbsForTest } from './crash-breadcrumb-store' +import { + markSystemSessionEnding, + resetExpectedTeardownStateForTest, + resolveExpectedTeardownScope, + WINDOWS_SESSION_END_CRASH_SUPPRESSION_WINDOW_MS +} from './expected-teardown-state' +import { ProcessGoneDedupe } from './process-gone-dedupe' +import { recordProcessGoneCrash, type ProcessGoneCrashEvent } from './process-gone-recorder' + +function event(overrides: Partial = {}): ProcessGoneCrashEvent { + return { + source: 'renderer', + processType: 'renderer', + reason: 'killed', + exitCode: 1, + expectedTeardown: 'none', + details: { processType: 'renderer' }, + ...overrides + } +} + +const gpuKill = event({ + source: 'child', + processType: 'GPU', + details: { serviceName: 'GPU', type: 'GPU' } +}) +const networkServiceKill = event({ + source: 'child', + processType: 'Utility', + details: { + name: 'Network Service', + serviceName: 'network.mojom.NetworkService', + type: 'Utility' + } +}) +const rendererKill = event() + +function currentTeardownScope() { + return resolveExpectedTeardownScope({ + isQuitting: false, + isQuittingForUpdate: false, + isExpectedRendererReload: false + }) +} + +let now: number + +beforeEach(() => { + now = 1_000 + resetExpectedTeardownStateForTest(() => now) + clearCrashBreadcrumbsForTest() +}) + +afterEach(() => { + vi.restoreAllMocks() + resetExpectedTeardownStateForTest() + clearCrashBreadcrumbsForTest() +}) + +describe('recordProcessGoneCrash killed/1 ordering', () => { + it('reports a genuine lone renderer killed/1 without teardown intent', () => { + const record = vi.fn().mockResolvedValue({ id: 'report-1' }) + + recordProcessGoneCrash({ record } as never, rendererKill, new ProcessGoneDedupe()) + + expect(record).toHaveBeenCalledOnce() + }) + + it('reports R3 after matching recoverable sibling churn', () => { + const record = vi.fn().mockResolvedValue({ id: 'report-1' }) + const dedupe = new ProcessGoneDedupe() + + recordProcessGoneCrash({ record } as never, gpuKill, dedupe) + recordProcessGoneCrash({ record } as never, networkServiceKill, dedupe) + recordProcessGoneCrash({ record } as never, rendererKill, dedupe) + + expect(record).toHaveBeenCalledOnce() + }) + + it('suppresses the fleet sequence only after independent session-end intent', () => { + const record = vi.fn().mockResolvedValue({ id: 'report-1' }) + const dedupe = new ProcessGoneDedupe() + markSystemSessionEnding() + const expectedTeardown = currentTeardownScope() + + recordProcessGoneCrash({ record } as never, event({ ...gpuKill, expectedTeardown }), dedupe) + recordProcessGoneCrash( + { record } as never, + event({ ...networkServiceKill, expectedTeardown }), + dedupe + ) + recordProcessGoneCrash( + { record } as never, + event({ ...rendererKill, expectedTeardown }), + dedupe + ) + + expect(expectedTeardown).toBe('app-shutdown') + expect(record).not.toHaveBeenCalled() + }) + + it('durably reports killed/1 after the session-end window expires', () => { + const record = vi.fn().mockResolvedValue({ id: 'report-1' }) + markSystemSessionEnding() + now += WINDOWS_SESSION_END_CRASH_SUPPRESSION_WINDOW_MS + const expectedTeardown = currentTeardownScope() + + recordProcessGoneCrash( + { record } as never, + event({ ...rendererKill, expectedTeardown }), + new ProcessGoneDedupe() + ) + + expect(expectedTeardown).toBe('none') + expect(record).toHaveBeenCalledOnce() + }) + + it('keeps a renderer report filed when session-end intent arrives later', () => { + const record = vi.fn().mockResolvedValue({ id: 'report-1' }) + const scopeBeforeSessionEnd = currentTeardownScope() + + recordProcessGoneCrash( + { record } as never, + event({ ...rendererKill, expectedTeardown: scopeBeforeSessionEnd }), + new ProcessGoneDedupe() + ) + markSystemSessionEnding() + const scopeAfterSessionEnd = currentTeardownScope() + recordProcessGoneCrash( + { record } as never, + event({ ...rendererKill, expectedTeardown: scopeAfterSessionEnd }), + new ProcessGoneDedupe() + ) + + expect(scopeBeforeSessionEnd).toBe('none') + expect(scopeAfterSessionEnd).toBe('app-shutdown') + expect(record).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/index.ts b/src/main/index.ts index 912941275d5..e5a0050a781 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -298,6 +298,7 @@ import { type ExpectedTeardownScope } from './crash-reporting/process-gone-classification' import { recordProcessGoneCrash as recordProcessGoneCrashEvent } from './crash-reporting/process-gone-recorder' +import { resolveExpectedTeardownScope } from './crash-reporting/expected-teardown-state' import { advanceSyntheticTitleSpinnerEntries, type SyntheticTitleSpinnerEntry @@ -697,14 +698,17 @@ function clearExpectedRendererReload(webContentsId?: number): void { expectedRendererReload.clear(webContentsId) } -function getExpectedTeardownScope(webContentsId?: number): ExpectedTeardownScope { - if (isQuitting || isQuittingForUpdate()) { - return 'app-shutdown' - } - if (webContentsId === undefined) { - return 'none' - } - return expectedRendererReload.matches(webContentsId) ? 'renderer-reload' : 'none' +function getExpectedTeardownScope( + webContentsId?: number, + includeSystemSessionEnd = true +): ExpectedTeardownScope { + return resolveExpectedTeardownScope({ + isQuitting, + isQuittingForUpdate: isQuittingForUpdate(), + isExpectedRendererReload: + webContentsId !== undefined && expectedRendererReload.matches(webContentsId), + includeSystemSessionEnd + }) } function markRecoveryReloadInFlight(webContentsId: number, durationMs = 10_000): void { @@ -1263,7 +1267,7 @@ function openMainWindow(): BrowserWindow { shouldRecoverRenderer: (details, webContentsId) => shouldRecoverRendererAfterProcessGone({ reason: details.reason, - expectedTeardown: getExpectedTeardownScope(webContentsId) + expectedTeardown: getExpectedTeardownScope(webContentsId, false) }), onRendererRecoveryExhausted: ({ details, recentRecoveryCount }) => { recordDurableCrashBreadcrumb('renderer_recovery_circuit_breaker_open', { diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts index 7dc2762195e..001bc5252d1 100644 --- a/src/main/startup/desktop-startup-ordering.test.ts +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -156,6 +156,33 @@ describe('startup ordering', () => { expect(startIndex).toBeGreaterThan(attachIndex) }) + it('wires bounded teardown state to reporting but not recovery or close behavior', () => { + const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const scopeStart = source.indexOf('function getExpectedTeardownScope(') + const scopeEnd = source.indexOf('function markRecoveryReloadInFlight(', scopeStart) + const scope = source.slice(scopeStart, scopeEnd) + const windowStart = source.indexOf('const window = createMainWindow(store, {') + const windowEnd = source.indexOf('onRendererRecoveryExhausted:', windowStart) + const windowOptions = source.slice(windowStart, windowEnd) + const recorderStart = source.indexOf('function recordProcessGoneCrash(') + const recorderEnd = source.indexOf('function shutdownWatchersOnce(', recorderStart) + const recorder = source.slice(recorderStart, recorderEnd) + + expect(scopeStart).toBeGreaterThanOrEqual(0) + expect(scopeEnd).toBeGreaterThan(scopeStart) + expect(scope).toContain('resolveExpectedTeardownScope({') + expect(scope).toContain('includeSystemSessionEnd') + expect(windowStart).toBeGreaterThanOrEqual(0) + expect(windowEnd).toBeGreaterThan(windowStart) + expect(windowOptions).toContain('getIsQuitting: () => isQuitting') + expect(windowOptions).toContain( + 'expectedTeardown: getExpectedTeardownScope(webContentsId, false)' + ) + expect(recorderStart).toBeGreaterThanOrEqual(0) + expect(recorderEnd).toBeGreaterThan(recorderStart) + expect(recorder).toContain('expectedTeardown: getExpectedTeardownScope(webContentsId)') + }) + it('attaches renderer services before starting the TCC prompt watcher', () => { const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') const attachIndex = source.indexOf('attachMainWindowServices(') diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index bc595f10b20..bbff3b994c3 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -74,6 +74,11 @@ import { } from './createMainWindow' import { ipcMain } from 'electron' import { shouldRecoverRendererAfterProcessGone } from '../crash-reporting/process-gone-classification' +import { + resetExpectedTeardownStateForTest, + resolveExpectedTeardownScope, + WINDOWS_SESSION_END_CRASH_SUPPRESSION_WINDOW_MS +} from '../crash-reporting/expected-teardown-state' function withPlatform(platform: NodeJS.Platform, run: () => T): T { const original = process.platform @@ -102,6 +107,7 @@ describe('createMainWindow', () => { vi.mocked(ipcMain.removeListener).mockReset() vi.mocked(ipcMain.handle).mockReset() vi.mocked(ipcMain.removeHandler).mockReset() + resetExpectedTeardownStateForTest() vi.useRealTimers() }) @@ -3143,6 +3149,40 @@ describe('createMainWindow', () => { consoleError.mockRestore() }) + it('still preserves PTYs and reloads after Windows session-end', () => { + vi.useFakeTimers() + + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { browserWindowInstance, windowHandlers } = createRendererRecoveryWindowHarness() + const onBeforeRecoveryReload = vi.fn() + + withPlatform('win32', () => { + createMainWindow(null, { + onBeforeRecoveryReload, + shouldRecoverRenderer: (details) => + shouldRecoverRendererAfterProcessGone({ + reason: details.reason, + expectedTeardown: resolveExpectedTeardownScope({ + isQuitting: false, + isQuittingForUpdate: false, + isExpectedRendererReload: false, + includeSystemSessionEnd: false + }) + }) + }) + }) + windowHandlers['session-end']?.({} as never) + windowHandlers['render-process-gone']?.( + {} as never, + { reason: 'killed', exitCode: 1 } as Electron.RenderProcessGoneDetails + ) + vi.runAllTimers() + + expect(onBeforeRecoveryReload).toHaveBeenCalledWith(143) + expect(browserWindowInstance.loadFile).toHaveBeenCalledTimes(2) + consoleError.mockRestore() + }) + it('does not reload after renderer loss when recovery is disabled', () => { vi.useFakeTimers() @@ -3730,6 +3770,60 @@ describe('createMainWindow', () => { setPlatform(originalPlatform) }) + it('marks production teardown state on irrevocable Windows session end', () => { + setPlatform('win32') + resetExpectedTeardownStateForTest(() => 1_000) + const { windowHandlers } = setupCloseWindow() + + createMainWindow(null) + windowHandlers['session-end']?.({} as never) + + expect( + resolveExpectedTeardownScope({ + isQuitting: false, + isQuittingForUpdate: false, + isExpectedRendererReload: false + }) + ).toBe('app-shutdown') + }) + + it.each(['darwin', 'linux'] as const)( + 'does not mark session teardown state on %s', + (platform) => { + setPlatform(platform) + const { windowHandlers } = setupCloseWindow() + + createMainWindow(null) + + expect(windowHandlers['session-end']).toBeUndefined() + expect( + resolveExpectedTeardownScope({ + isQuitting: false, + isQuittingForUpdate: false, + isExpectedRendererReload: false + }) + ).toBe('none') + } + ) + + it('still minimizes to tray after the session-end reporting window expires', () => { + setPlatform('win32') + let now = 1_000 + resetExpectedTeardownStateForTest(() => now) + const { windowHandlers, webContents, instance } = setupCloseWindow() + const store = makeStore(true, true) + + createMainWindow(store as never) + windowHandlers['session-end']?.({} as never) + now += WINDOWS_SESSION_END_CRASH_SUPPRESSION_WINDOW_MS + const preventDefault = vi.fn() + windowHandlers.close({ preventDefault } as never) + + expect(preventDefault).toHaveBeenCalledOnce() + expect(instance.hide).toHaveBeenCalledOnce() + expect(webContents.send).not.toHaveBeenCalledWith('window:close-requested', expect.anything()) + }) + it('hides to the tray instead of closing when the setting is on', () => { setPlatform('win32') const { windowHandlers, webContents, instance } = setupCloseWindow() diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 327772164f4..f04cfb72430 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -19,6 +19,7 @@ import { translateMain } from '../i18n/main-i18n' import { normalizeBrowserNavigationUrl } from '../../shared/browser-url' import { ORCA_BROWSER_GUEST_WEB_PREFERENCES } from '../../shared/browser-guest-web-preferences' import { isCrashReportReason } from '../../shared/crash-reporting' +import { markSystemSessionEnding } from '../crash-reporting/expected-teardown-state' import { DEFAULT_RENDERER_RECOVERY_MAX_RECOVERIES, DEFAULT_RENDERER_RECOVERY_WINDOW_MS, @@ -304,6 +305,11 @@ export function createMainWindow( // Why: native paste fallback is privileged IPC; only the top-level renderer may request it. setTrustedUIRendererWebContentsId(rendererWebContentsId) + // Unlike query-session-end, session-end cannot be canceled before this signal is recorded. + if (process.platform === 'win32') { + mainWindow.on('session-end', markSystemSessionEnding) + } + if (process.platform === 'darwin') { // Why: preserve hidden-window power savings; stable native sizing and frame-only invalidation // make wake recovery independent of the throttled viewport. From 9b70ce0424dc646a66916a6fb24d4d18f9b58275 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:59:00 -0700 Subject: [PATCH 04/45] fix(computer-use): settle macOS permission checks (#13025) --- .../Sources/OrcaComputerUseMacOS/main.swift | 189 ++++++++++++++---- .../PermissionStatusSnapshot.swift | 116 +++++++++++ .../PermissionTrustSettling.swift | 73 +++++++ .../ScreenCaptureProbeWindowSelection.swift | 22 ++ .../PermissionStatusSnapshotTests.swift | 126 ++++++++++++ .../PermissionTrustSettlingTests.swift | 59 ++++++ ...ScreenCapturePermissionSettlingTests.swift | 79 ++++++++ ...reenCaptureProbeWindowSelectionTests.swift | 44 ++++ 8 files changed, 664 insertions(+), 44 deletions(-) create mode 100644 native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/PermissionStatusSnapshot.swift create mode 100644 native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/PermissionTrustSettling.swift create mode 100644 native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/ScreenCaptureProbeWindowSelection.swift create mode 100644 native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/PermissionStatusSnapshotTests.swift create mode 100644 native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/PermissionTrustSettlingTests.swift create mode 100644 native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCapturePermissionSettlingTests.swift create mode 100644 native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCaptureProbeWindowSelectionTests.swift diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift index 7ecbdbf004a..61ee277156d 100644 --- a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift @@ -601,7 +601,7 @@ final class Provider { windowIndex: Int?, restoreWindow: Bool ) throws -> Snapshot { - guard accessibilityTrusted() else { + guard accessibilityTrustedSettled() else { // Why: agents retry failed observations. Only the explicit setup flow // should open macOS privacy prompts/settings; runtime calls stay quiet. throw ProviderError.coded( @@ -619,7 +619,7 @@ final class Provider { allowRecovery: restoreWindow ) let focusedTitle = stringAttribute(focused, kAXTitleAttribute as String) ?? app.name - let canCaptureScreenshot = includeScreenshot && screenCaptureTrusted() + let canCaptureScreenshot = includeScreenshot && screenCaptureTrustedSettled() guard let capture = WindowCapture.resolve( candidates: windowCandidates, titleHint: focusedTitle, @@ -1034,10 +1034,64 @@ private func accessibilityTrusted() -> Bool { AXIsProcessTrusted() } +private func accessibilityTrustedSettled() -> Bool { + // Fresh helper processes can receive transient TCC preflight denials before the real grant settles. + PermissionTrustSettling.settle(probe: accessibilityTrusted).settled +} + private func screenCaptureTrusted() -> Bool { CGPreflightScreenCaptureAccess() } +private func screenCaptureTrustedSettled() -> Bool { + PermissionTrustSettling.settleWithFallback( + finalTimeoutMs: 500, + probe: screenCaptureTrusted, + fallbackProbe: screenCaptureTrustedByCaptureProbe + ) +} + +private func permissionStatusSnapshotSettled() -> PermissionStatusSnapshot { + PermissionStatusSnapshotProbe.capture( + accessibilityProbe: accessibilityTrustedSettled, + screenshotsProbe: screenCaptureTrustedSettled + ) +} + +private func screenCaptureTrustedByCaptureProbe() -> Bool { + guard let infos = CGWindowListCopyWindowInfo( + [.optionOnScreenOnly], + kCGNullWindowID + ) as? [[String: Any]] else { + return false + } + let windows = infos.compactMap { info -> ScreenCaptureProbeWindow? in + guard let layer = info[kCGWindowLayer as String] as? Int, + let ownerPid = info[kCGWindowOwnerPID as String] as? NSNumber, + let number = info[kCGWindowNumber as String] as? NSNumber + else { + return nil + } + return ScreenCaptureProbeWindow( + layer: layer, + ownerPid: ownerPid.int32Value, + windowId: number.uint32Value + ) + } + guard let windowId = ScreenCaptureProbeWindowSelection.firstCrossProcessNormalWindow( + ownPid: ProcessInfo.processInfo.processIdentifier, + windows: windows + ) else { + return false + } + return CGWindowListCreateImage( + .null, + [.optionIncludingWindow], + CGWindowID(windowId), + [.boundsIgnoreFraming] + ) != nil +} + private func requestScreenCaptureAccess() -> Bool { CGRequestScreenCaptureAccess() } @@ -1065,6 +1119,38 @@ private func enableManualAccessibilityIfNeeded(_ appElement: AXUIElement, app: A private func focusedWindow(appElement: AXUIElement, app: AppDescriptor, visibleWindowCount: Int, allowRecovery: Bool) throws -> AXUIElement { let systemWide = AXUIElementCreateSystemWide() + if let window = lookupUsableWindow(systemWide: systemWide, appElement: appElement, app: app) { + return window + } + if allowRecovery { + recoverWindow(app) + if let window = lookupUsableWindow(systemWide: systemWide, appElement: appElement, app: app) { + return window + } + } + if visibleWindowCount > 0 { + var settledWindow: AXUIElement? + let outcome = PermissionTrustSettling.settle { + settledWindow = lookupUsableWindow( + systemWide: systemWide, + appElement: appElement, + app: app + ) + return settledWindow != nil + } + if let window = settledWindow, outcome.settled { + return window + } + throw ProviderError.coded("permission_denied", "app '\(app.name)' has visible windows but no accessibility window (AX reads stayed blocked for \(outcome.waitedMs)ms after retries). macOS Accessibility may need Orca Computer Use toggled off and on again in System Settings.") + } + throw ProviderError.coded("window_not_found", "app '\(app.name)' has no accessibility window; make sure the app has a visible window, then retry with --restore-window.") +} + +private func lookupUsableWindow( + systemWide: AXUIElement, + appElement: AXUIElement, + app: AppDescriptor +) -> AXUIElement? { if let window = focusedSystemWindow(systemWide: systemWide, app: app) { return window } @@ -1072,31 +1158,9 @@ private func focusedWindow(appElement: AXUIElement, app: AppDescriptor, visibleW return window } if let windows = copyArray(appElement, kAXWindowsAttribute as String) { - if let window = windows.first(where: usableWindow) { - return window - } + return windows.first(where: usableWindow) } - if allowRecovery { - recoverWindow(app) - if let window = focusedSystemWindow(systemWide: systemWide, app: app) { - return window - } - if let window = copyElement(appElement, kAXFocusedWindowAttribute as String), usableWindow(window) { - return window - } - if let windows = copyArray(appElement, kAXWindowsAttribute as String) { - if let window = windows.first(where: usableWindow) { - return window - } - } - } - let permissionHint = visibleWindowCount > 0 - ? " The app has visible windows, so macOS Accessibility may need Orca Computer Use toggled off and on again in System Settings." - : "" - if visibleWindowCount > 0 { - throw ProviderError.coded("permission_denied", "app '\(app.name)' has visible windows but no accessibility window.\(permissionHint)") - } - throw ProviderError.coded("window_not_found", "app '\(app.name)' has no accessibility window; make sure the app has a visible window, then retry with --restore-window.") + return nil } private func focusedSystemWindow(systemWide: AXUIElement, app: AppDescriptor) -> AXUIElement? { @@ -2874,6 +2938,7 @@ private final class PermissionRuntime: NSObject, NSApplicationDelegate { windowController?.window?.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) } + windowController?.refreshPermissions() } func applicationDidBecomeActive(_ notification: Notification) { @@ -2890,6 +2955,7 @@ private final class PermissionWindowController: NSWindowController { private var dragAssistantPermission: PermissionKind? private let initialPermission: PermissionKind? private let terminateWhenDragAssistantCloses: Bool + private var permissionStatusRefresh: PermissionStatusRefreshCoordinator? convenience init(initialPermission: PermissionKind? = nil, terminateWhenDragAssistantCloses: Bool = false) { let window = NSWindow( @@ -2920,6 +2986,14 @@ private final class PermissionWindowController: NSWindowController { self.initialPermission = initialPermission self.terminateWhenDragAssistantCloses = terminateWhenDragAssistantCloses super.init(window: window) + permissionStatusRefresh = PermissionStatusRefreshCoordinator( + probe: permissionStatusSnapshotSettled, + handler: { [weak self] snapshot in + Task { @MainActor in + self?.applyPermissionStatus(snapshot) + } + } + ) } required init?(coder: NSCoder) { @@ -2976,21 +3050,25 @@ private final class PermissionWindowController: NSWindowController { } func refreshPermissions() { - if let initialPermission, initialPermission.isGranted { + permissionStatusRefresh?.refresh() + } + + private func applyPermissionStatus(_ snapshot: PermissionStatusSnapshot) { + if let initialPermission, initialPermission.isGranted(in: snapshot) { // Why: targeted permission helpers should finish once the requested // grant lands, even if other Computer Use permissions remain unset. completeDragAssistant() return } - if dragAssistantPermission?.isGranted == true { + if dragAssistantPermission?.isGranted(in: snapshot) == true { // Why: after one grant in full setup, the remaining missing permission // needs fresh guidance instead of the old assistant's instructions. closeDragAssistant() } - if PermissionKind.allCases.allSatisfy(\.isGranted) { + if PermissionKind.allCases.allSatisfy({ $0.isGranted(in: snapshot) }) { closeDragAssistant() } - (window?.contentView as? PermissionView)?.refreshPermissions() + (window?.contentView as? PermissionView)?.refreshPermissions(snapshot) } } @@ -3045,12 +3123,12 @@ private enum PermissionKind: CaseIterable { } } - var isGranted: Bool { + func isGranted(in snapshot: PermissionStatusSnapshot) -> Bool { switch self { case .accessibility: - accessibilityTrusted() + snapshot.accessibilityGranted case .screenshots: - screenCaptureTrusted() + snapshot.screenshotsGranted } } @@ -3071,6 +3149,7 @@ private final class PermissionView: NSView { private let close: () -> Void private var contentStack: NSStackView? private var contentConstraints: [NSLayoutConstraint] = [] + private var permissionStatus: PermissionStatusSnapshot? init(frame frameRect: NSRect, showDragAssistant: @escaping (PermissionKind) -> Void, close: @escaping () -> Void) { self.showDragAssistant = showDragAssistant @@ -3107,12 +3186,22 @@ private final class PermissionView: NSView { icon.heightAnchor.constraint(equalToConstant: 58) ]) - let missingPermissions = PermissionKind.allCases.filter { !$0.isGranted } - let ready = missingPermissions.isEmpty + let missingPermissions = permissionStatus.map { snapshot in + PermissionKind.allCases.filter { !$0.isGranted(in: snapshot) } + } ?? [] + let checking = permissionStatus == nil + let ready = !checking && missingPermissions.isEmpty - let title = label(ready ? "Computer Use is Ready" : "Enable Orca Computer Use", size: 22, weight: .bold) + let titleText = checking + ? "Checking Computer Use" + : (ready ? "Computer Use is Ready" : "Enable Orca Computer Use") + let title = label(titleText, size: 22, weight: .bold) let subtitle = label( - ready ? "Orca can use local apps when you ask." : "Grant permissions so Orca can use apps when you ask.", + checking + ? "Checking Accessibility and Screenshots." + : (ready + ? "Orca can use local apps when you ask." + : "Grant permissions so Orca can use apps when you ask."), size: 12, weight: .regular ) @@ -3129,7 +3218,16 @@ private final class PermissionView: NSView { header.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true subtitle.widthAnchor.constraint(equalTo: stack.widthAnchor, constant: -10).isActive = true - if ready { + if checking { + let progress = NSProgressIndicator() + progress.style = .spinning + progress.controlSize = .small + progress.translatesAutoresizingMaskIntoConstraints = false + progress.startAnimation(nil) + stack.addArrangedSubview(progress) + progress.setContentHuggingPriority(.required, for: .horizontal) + progress.centerXAnchor.constraint(equalTo: stack.centerXAnchor).isActive = true + } else if ready { stack.addArrangedSubview(doneButton()) } else { for permission in missingPermissions { @@ -3149,8 +3247,9 @@ private final class PermissionView: NSView { NSLayoutConstraint.activate(contentConstraints) } - func refreshPermissions() { - // Why: TCC grants can change in System Settings while this window stays open. + func refreshPermissions(_ snapshot: PermissionStatusSnapshot) { + guard permissionStatus != snapshot else { return } + permissionStatus = snapshot build() } @@ -4039,14 +4138,16 @@ private func runPermissionCheck(initialPermission: PermissionKind? = nil) { } private func printPermissionStatus() { - let accessibility = accessibilityTrusted() ? "granted" : "not-granted" - let screenshots = screenCaptureTrusted() ? "granted" : "not-granted" + let snapshot = permissionStatusSnapshotSettled() + let accessibility = snapshot.accessibilityGranted ? "granted" : "not-granted" + let screenshots = snapshot.screenshotsGranted ? "granted" : "not-granted" print(#"{"accessibility":"\#(accessibility)","screenshots":"\#(screenshots)"}"#) } private func writePermissionStatus(to path: String) { - let accessibility = accessibilityTrusted() ? "granted" : "not-granted" - let screenshots = screenCaptureTrusted() ? "granted" : "not-granted" + let snapshot = permissionStatusSnapshotSettled() + let accessibility = snapshot.accessibilityGranted ? "granted" : "not-granted" + let screenshots = snapshot.screenshotsGranted ? "granted" : "not-granted" let text = #"{"accessibility":"\#(accessibility)","screenshots":"\#(screenshots)"}"# do { try text.write(toFile: path, atomically: true, encoding: .utf8) diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/PermissionStatusSnapshot.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/PermissionStatusSnapshot.swift new file mode 100644 index 00000000000..a1e71edebb4 --- /dev/null +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/PermissionStatusSnapshot.swift @@ -0,0 +1,116 @@ +import Dispatch +import Foundation + +public struct PermissionStatusSnapshot: Equatable, Sendable { + public let accessibilityGranted: Bool + public let screenshotsGranted: Bool + + public init(accessibilityGranted: Bool, screenshotsGranted: Bool) { + self.accessibilityGranted = accessibilityGranted + self.screenshotsGranted = screenshotsGranted + } +} + +public enum PermissionStatusSnapshotProbe { + public typealias Probe = @Sendable () -> Bool + + private static let probeQueue = DispatchQueue( + label: "com.stablyai.orca.computer-use-permission-status", + attributes: .concurrent + ) + + public static func capture( + accessibilityProbe: @escaping Probe, + screenshotsProbe: @escaping Probe + ) -> PermissionStatusSnapshot { + let result = PermissionStatusSnapshotResult() + let group = DispatchGroup() + + group.enter() + probeQueue.async { + result.recordAccessibility(accessibilityProbe()) + group.leave() + } + group.enter() + probeQueue.async { + result.recordScreenshots(screenshotsProbe()) + group.leave() + } + group.wait() + return result.snapshot + } +} + +public final class PermissionStatusRefreshCoordinator: @unchecked Sendable { + public typealias SnapshotProbe = @Sendable () -> PermissionStatusSnapshot + public typealias SnapshotHandler = @Sendable (PermissionStatusSnapshot) -> Void + + private let callbackQueue: DispatchQueue + private let handler: SnapshotHandler + private let probe: SnapshotProbe + private let stateLock = NSLock() + private let workerQueue: DispatchQueue + private var refreshInFlight = false + + public init( + workerQueue: DispatchQueue = DispatchQueue( + label: "com.stablyai.orca.computer-use-permission-refresh", + qos: .userInitiated + ), + callbackQueue: DispatchQueue = .main, + probe: @escaping SnapshotProbe, + handler: @escaping SnapshotHandler + ) { + self.workerQueue = workerQueue + self.callbackQueue = callbackQueue + self.probe = probe + self.handler = handler + } + + public func refresh() { + stateLock.lock() + guard !refreshInFlight else { + stateLock.unlock() + return + } + refreshInFlight = true + stateLock.unlock() + + workerQueue.async { [self] in + let snapshot = probe() + callbackQueue.async { [self] in + stateLock.lock() + refreshInFlight = false + stateLock.unlock() + handler(snapshot) + } + } + } +} + +private final class PermissionStatusSnapshotResult: @unchecked Sendable { + private let lock = NSLock() + private var accessibilityGranted = false + private var screenshotsGranted = false + + var snapshot: PermissionStatusSnapshot { + lock.lock() + defer { lock.unlock() } + return PermissionStatusSnapshot( + accessibilityGranted: accessibilityGranted, + screenshotsGranted: screenshotsGranted + ) + } + + func recordAccessibility(_ granted: Bool) { + lock.lock() + accessibilityGranted = granted + lock.unlock() + } + + func recordScreenshots(_ granted: Bool) { + lock.lock() + screenshotsGranted = granted + lock.unlock() + } +} diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/PermissionTrustSettling.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/PermissionTrustSettling.swift new file mode 100644 index 00000000000..80612664454 --- /dev/null +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/PermissionTrustSettling.swift @@ -0,0 +1,73 @@ +import Foundation + +public enum PermissionTrustSettling { + public struct Outcome: Equatable { + public let settled: Bool + public let attempts: Int + public let waitedMs: Int + + public init(settled: Bool, attempts: Int, waitedMs: Int) { + self.settled = settled + self.attempts = attempts + self.waitedMs = waitedMs + } + } + + public static let defaultTimeoutMs = 1_500 + public static let defaultIntervalMs = 100 + + public static func settle( + timeoutMs: Int = defaultTimeoutMs, + intervalMs: Int = defaultIntervalMs, + sleepMs: (Int) -> Void = { interval in + Thread.sleep(forTimeInterval: TimeInterval(interval) / 1_000) + }, + probe: () -> Bool + ) -> Outcome { + precondition(timeoutMs >= 0, "timeoutMs must not be negative") + precondition(intervalMs > 0, "intervalMs must be positive") + var attempts = 0 + var waitedMs = 0 + while true { + attempts += 1 + if probe() { + return Outcome(settled: true, attempts: attempts, waitedMs: waitedMs) + } + if waitedMs >= timeoutMs { + return Outcome(settled: false, attempts: attempts, waitedMs: waitedMs) + } + let interval = min(intervalMs, timeoutMs - waitedMs) + sleepMs(interval) + waitedMs += interval + } + } + + public static func settleWithFallback( + initialTimeoutMs: Int = defaultTimeoutMs, + finalTimeoutMs: Int, + intervalMs: Int = defaultIntervalMs, + sleepMs: (Int) -> Void = { interval in + Thread.sleep(forTimeInterval: TimeInterval(interval) / 1_000) + }, + probe: () -> Bool, + fallbackProbe: () -> Bool + ) -> Bool { + if settle( + timeoutMs: initialTimeoutMs, + intervalMs: intervalMs, + sleepMs: sleepMs, + probe: probe + ).settled { + return true + } + if fallbackProbe() { + return true + } + return settle( + timeoutMs: finalTimeoutMs, + intervalMs: intervalMs, + sleepMs: sleepMs, + probe: probe + ).settled + } +} diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/ScreenCaptureProbeWindowSelection.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/ScreenCaptureProbeWindowSelection.swift new file mode 100644 index 00000000000..36b4346a8bc --- /dev/null +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/ScreenCaptureProbeWindowSelection.swift @@ -0,0 +1,22 @@ +public struct ScreenCaptureProbeWindow: Equatable { + public let layer: Int + public let ownerPid: Int32 + public let windowId: UInt32 + + public init(layer: Int, ownerPid: Int32, windowId: UInt32) { + self.layer = layer + self.ownerPid = ownerPid + self.windowId = windowId + } +} + +public enum ScreenCaptureProbeWindowSelection { + public static func firstCrossProcessNormalWindow( + ownPid: Int32, + windows: [ScreenCaptureProbeWindow] + ) -> UInt32? { + windows.first { window in + window.layer == 0 && window.ownerPid != ownPid + }?.windowId + } +} diff --git a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/PermissionStatusSnapshotTests.swift b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/PermissionStatusSnapshotTests.swift new file mode 100644 index 00000000000..7aff74ee196 --- /dev/null +++ b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/PermissionStatusSnapshotTests.swift @@ -0,0 +1,126 @@ +import Dispatch +@testable import OrcaComputerUseMacOSCore +import XCTest + +final class PermissionStatusSnapshotTests: XCTestCase { + func testCapturesBothPermissionResults() { + let snapshot = PermissionStatusSnapshotProbe.capture( + accessibilityProbe: { true }, + screenshotsProbe: { false } + ) + + XCTAssertEqual( + snapshot, + PermissionStatusSnapshot(accessibilityGranted: true, screenshotsGranted: false) + ) + } + + func testRunsPermissionProbesConcurrently() { + let accessibilityStarted = DispatchSemaphore(value: 0) + let screenshotsStarted = DispatchSemaphore(value: 0) + let releaseProbes = DispatchSemaphore(value: 0) + let finished = expectation(description: "permission snapshot") + + DispatchQueue.global().async { + _ = PermissionStatusSnapshotProbe.capture( + accessibilityProbe: { + accessibilityStarted.signal() + releaseProbes.wait() + return true + }, + screenshotsProbe: { + screenshotsStarted.signal() + releaseProbes.wait() + return true + } + ) + finished.fulfill() + } + + XCTAssertEqual(accessibilityStarted.wait(timeout: .now() + 1), .success) + XCTAssertEqual(screenshotsStarted.wait(timeout: .now() + 1), .success) + releaseProbes.signal() + releaseProbes.signal() + wait(for: [finished], timeout: 1) + } + + func testCoalescesRefreshesWhileProbeIsRunning() { + let callbackQueue = DispatchQueue(label: "permission-status-test-callback") + let probeStarted = DispatchSemaphore(value: 0) + let releaseProbe = DispatchSemaphore(value: 0) + let handled = expectation(description: "snapshot handled") + let probeCount = LockedCounter() + let coordinator = PermissionStatusRefreshCoordinator( + callbackQueue: callbackQueue, + probe: { + probeCount.increment() + probeStarted.signal() + releaseProbe.wait() + return PermissionStatusSnapshot( + accessibilityGranted: true, + screenshotsGranted: true + ) + }, + handler: { _ in handled.fulfill() } + ) + + coordinator.refresh() + XCTAssertEqual(probeStarted.wait(timeout: .now() + 1), .success) + coordinator.refresh() + coordinator.refresh() + releaseProbe.signal() + + wait(for: [handled], timeout: 1) + XCTAssertEqual(probeCount.value, 1) + } + + func testAllowsRefreshAfterPreviousSnapshotWasHandled() { + let callbackQueue = DispatchQueue(label: "permission-status-test-callback") + let firstHandled = expectation(description: "first snapshot handled") + let secondHandled = expectation(description: "second snapshot handled") + let probeCount = LockedCounter() + let coordinator = PermissionStatusRefreshCoordinator( + callbackQueue: callbackQueue, + probe: { + let count = probeCount.increment() + return PermissionStatusSnapshot( + accessibilityGranted: count == 2, + screenshotsGranted: false + ) + }, + handler: { snapshot in + if snapshot.accessibilityGranted { + secondHandled.fulfill() + } else { + firstHandled.fulfill() + } + } + ) + + coordinator.refresh() + wait(for: [firstHandled], timeout: 1) + coordinator.refresh() + + wait(for: [secondHandled], timeout: 1) + XCTAssertEqual(probeCount.value, 2) + } +} + +private final class LockedCounter: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + var value: Int { + lock.lock() + defer { lock.unlock() } + return count + } + + @discardableResult + func increment() -> Int { + lock.lock() + defer { lock.unlock() } + count += 1 + return count + } +} diff --git a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/PermissionTrustSettlingTests.swift b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/PermissionTrustSettlingTests.swift new file mode 100644 index 00000000000..c3f3956acd6 --- /dev/null +++ b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/PermissionTrustSettlingTests.swift @@ -0,0 +1,59 @@ +import Testing +@testable import OrcaComputerUseMacOSCore + +@Suite("PermissionTrustSettling") +struct PermissionTrustSettlingTests { + @Test("already-trusted probe returns immediately") + func immediateSuccess() { + var sleeps: [Int] = [] + let outcome = PermissionTrustSettling.settle( + sleepMs: { sleeps.append($0) }, + probe: { true } + ) + + #expect(outcome == .init(settled: true, attempts: 1, waitedMs: 0)) + #expect(sleeps.isEmpty) + } + + @Test("transient denial settles after retries") + func lateGrant() { + var calls = 0 + var sleeps: [Int] = [] + let outcome = PermissionTrustSettling.settle( + sleepMs: { sleeps.append($0) }, + probe: { + calls += 1 + return calls >= 4 + } + ) + + #expect(outcome == .init(settled: true, attempts: 4, waitedMs: 300)) + #expect(sleeps == [100, 100, 100]) + } + + @Test("persistent denial stops at the timeout") + func neverGranted() { + let outcome = PermissionTrustSettling.settle( + timeoutMs: 1_000, + intervalMs: 300, + sleepMs: { _ in }, + probe: { false } + ) + + #expect(outcome == .init(settled: false, attempts: 5, waitedMs: 1_000)) + } + + @Test("final interval is clamped to the timeout") + func clampsFinalInterval() { + var sleeps: [Int] = [] + let outcome = PermissionTrustSettling.settle( + timeoutMs: 250, + intervalMs: 100, + sleepMs: { sleeps.append($0) }, + probe: { false } + ) + + #expect(sleeps == [100, 100, 50]) + #expect(outcome == .init(settled: false, attempts: 4, waitedMs: 250)) + } +} diff --git a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCapturePermissionSettlingTests.swift b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCapturePermissionSettlingTests.swift new file mode 100644 index 00000000000..d53991d36d0 --- /dev/null +++ b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCapturePermissionSettlingTests.swift @@ -0,0 +1,79 @@ +import Testing +@testable import OrcaComputerUseMacOSCore + +@Suite("Screen Capture permission settling") +struct ScreenCapturePermissionSettlingTests { + @Test("initial preflight success skips fallback") + func initialSuccess() { + var fallbackCalls = 0 + let trusted = PermissionTrustSettling.settleWithFallback( + finalTimeoutMs: 500, + sleepMs: { _ in }, + probe: { true }, + fallbackProbe: { + fallbackCalls += 1 + return true + } + ) + + #expect(trusted) + #expect(fallbackCalls == 0) + } + + @Test("capture fallback can establish trust") + func fallbackSuccess() { + var fallbackCalls = 0 + let trusted = PermissionTrustSettling.settleWithFallback( + initialTimeoutMs: 200, + finalTimeoutMs: 200, + intervalMs: 100, + sleepMs: { _ in }, + probe: { false }, + fallbackProbe: { + fallbackCalls += 1 + return true + } + ) + + #expect(trusted) + #expect(fallbackCalls == 1) + } + + @Test("preflight is retried after fallback failure") + func finalPreflightSuccess() { + var probeCalls = 0 + let trusted = PermissionTrustSettling.settleWithFallback( + initialTimeoutMs: 100, + finalTimeoutMs: 200, + intervalMs: 100, + sleepMs: { _ in }, + probe: { + probeCalls += 1 + return probeCalls == 4 + }, + fallbackProbe: { false } + ) + + #expect(trusted) + #expect(probeCalls == 4) + } + + @Test("persistent denial remains denied") + func persistentDenial() { + var fallbackCalls = 0 + let trusted = PermissionTrustSettling.settleWithFallback( + initialTimeoutMs: 100, + finalTimeoutMs: 100, + intervalMs: 100, + sleepMs: { _ in }, + probe: { false }, + fallbackProbe: { + fallbackCalls += 1 + return false + } + ) + + #expect(!trusted) + #expect(fallbackCalls == 1) + } +} diff --git a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCaptureProbeWindowSelectionTests.swift b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCaptureProbeWindowSelectionTests.swift new file mode 100644 index 00000000000..96afd6fbccb --- /dev/null +++ b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCaptureProbeWindowSelectionTests.swift @@ -0,0 +1,44 @@ +import Testing +@testable import OrcaComputerUseMacOSCore + +@Suite("Screen capture probe window selection") +struct ScreenCaptureProbeWindowSelectionTests { + @Test("own process windows cannot prove cross-app capture permission") + func excludesOwnWindows() { + let selected = ScreenCaptureProbeWindowSelection.firstCrossProcessNormalWindow( + ownPid: 42, + windows: [ + .init(layer: 0, ownerPid: 42, windowId: 1), + .init(layer: 0, ownerPid: 84, windowId: 2) + ] + ) + + #expect(selected == 2) + } + + @Test("non-normal windows are skipped") + func excludesNonNormalWindows() { + let selected = ScreenCaptureProbeWindowSelection.firstCrossProcessNormalWindow( + ownPid: 42, + windows: [ + .init(layer: 1, ownerPid: 84, windowId: 1), + .init(layer: 0, ownerPid: 84, windowId: 2) + ] + ) + + #expect(selected == 2) + } + + @Test("no cross-process normal window returns nil") + func noCandidate() { + let selected = ScreenCaptureProbeWindowSelection.firstCrossProcessNormalWindow( + ownPid: 42, + windows: [ + .init(layer: 0, ownerPid: 42, windowId: 1), + .init(layer: 1, ownerPid: 84, windowId: 2) + ] + ) + + #expect(selected == nil) + } +} From 7a867f12aacbbacaf07cac55fac1d430e0b2cafd Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:09:53 -0700 Subject: [PATCH 05/45] Revert "Fix grok stale pane width (#13060)" (#13098) This reverts commit 58531e4caa7a8ed48c1dede4e00e87369ac8a95f. --- ...terminals-hydration-canonical-rows.test.ts | 130 ------------------ src/renderer/src/store/slices/terminals.ts | 30 +--- 2 files changed, 1 insertion(+), 159 deletions(-) delete mode 100644 src/renderer/src/store/slices/terminals-hydration-canonical-rows.test.ts diff --git a/src/renderer/src/store/slices/terminals-hydration-canonical-rows.test.ts b/src/renderer/src/store/slices/terminals-hydration-canonical-rows.test.ts deleted file mode 100644 index 87563472922..00000000000 --- a/src/renderer/src/store/slices/terminals-hydration-canonical-rows.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { WorkspaceSessionState } from '../../../../shared/types' -import { getDefaultWorkspaceSession } from '../../../../shared/constants' -import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' -import { createTestStore, makeLayout, makeTab, makeWorktree, seedStore } from './store-test-helpers' - -vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) -vi.mock('@/runtime/sync-runtime-graph', () => ({ - scheduleRuntimeGraphSync: vi.fn() -})) -vi.mock('@/components/terminal-pane/pty-transport', () => ({ - registerEagerPtyBuffer: vi.fn(), - ensurePtyDispatcher: vi.fn() -})) - -const apiProxy = (): unknown => - new Proxy(() => undefined, { - get: (_target, prop) => (prop === 'then' ? undefined : apiProxy()), - apply: () => Promise.resolve(null) - }) - -// @ts-expect-error -- mocked browser preload API -globalThis.window = { api: apiProxy() } - -describe('hydrateWorkspaceSession canonical terminal rows', () => { - it('drops only legacy rows that duplicate canonical PTY ownership', () => { - const store = createTestStore() - const worktreeId = 'repo1::/wt-1' - const sharedPtyId = 'daemon-session-1' - const recoveryPtyId = 'daemon-session-2' - seedStore(store, { - worktreesByRepo: { - repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', path: '/wt-1' })] - } - }) - - const session: WorkspaceSessionState = { - ...getDefaultWorkspaceSession(), - activeRepoId: 'repo1', - activeWorktreeId: worktreeId, - activeTabId: 'stale-tab', - activeWorktreeIdsOnShutdown: [worktreeId], - activeTabIdByWorktree: { [worktreeId]: 'stale-tab' }, - tabsByWorktree: { - [worktreeId]: [ - makeTab({ id: 'canonical-tab', worktreeId, ptyId: sharedPtyId }), - makeTab({ id: 'stale-tab', worktreeId, ptyId: sharedPtyId }), - makeTab({ id: 'recovery-tab', worktreeId, ptyId: recoveryPtyId }) - ] - }, - terminalLayoutsByTabId: { - 'canonical-tab': { - ...makeLayout(), - ptyIdsByLeafId: { 'canonical-leaf': sharedPtyId } - }, - 'stale-tab': { - ...makeLayout(), - ptyIdsByLeafId: { 'stale-leaf': sharedPtyId } - }, - 'recovery-tab': { - ...makeLayout(), - ptyIdsByLeafId: { 'recovery-leaf': recoveryPtyId } - } - }, - remoteSessionIdsByTabId: { - 'canonical-tab': sharedPtyId, - 'stale-tab': sharedPtyId, - 'recovery-tab': recoveryPtyId - }, - unifiedTabs: { - [worktreeId]: [ - { - id: 'canonical-unified-tab', - entityId: 'canonical-tab', - groupId: 'group-1', - worktreeId, - contentType: 'terminal', - label: 'Grok', - customLabel: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - }, - tabGroups: { - [worktreeId]: [ - { - id: 'group-1', - worktreeId, - activeTabId: 'canonical-unified-tab', - tabOrder: ['canonical-unified-tab'] - } - ] - } - } - - store.getState().hydrateWorkspaceSession(session) - store.getState().hydrateTabsSession(session) - const reconciliation = store.getState().reconcileWorktreeTabModel(worktreeId) - const state = store.getState() - const persisted = buildWorkspaceSessionPayload(state) - - expect(reconciliation.renderableTabCount).toBe(2) - expect(state.unifiedTabsByWorktree[worktreeId]?.map((tab) => tab.entityId)).toEqual([ - 'canonical-tab', - 'recovery-tab' - ]) - expect(state.tabsByWorktree[worktreeId]?.map((tab) => tab.id)).toEqual([ - 'canonical-tab', - 'recovery-tab' - ]) - expect(state.terminalLayoutsByTabId['stale-tab']).toBeUndefined() - expect(persisted.tabsByWorktree[worktreeId]?.map((tab) => tab.id)).toEqual([ - 'canonical-tab', - 'recovery-tab' - ]) - expect(persisted.terminalLayoutsByTabId['stale-tab']).toBeUndefined() - expect(state.pendingReconnectTabByWorktree[worktreeId]).toEqual([ - 'canonical-tab', - 'recovery-tab' - ]) - expect(state.pendingReconnectPtyIdByTabId).toEqual({ - 'canonical-tab': sharedPtyId, - 'recovery-tab': recoveryPtyId - }) - expect(state.activeTabId).toBeNull() - expect(state.activeTabIdByWorktree).toEqual({}) - }) -}) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 87729c71ddb..7167e1cfc4d 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -492,17 +492,6 @@ function uniquePtyIds(ptyIds: readonly (string | null | undefined)[]): string[] return [...new Set(ptyIds.filter((ptyId): ptyId is string => Boolean(ptyId)))] } -function collectPersistedTerminalPtyIds( - session: WorkspaceSessionState, - tab: TerminalTab -): string[] { - return uniquePtyIds([ - tab.ptyId, - session.remoteSessionIdsByTabId?.[tab.id], - ...Object.values(session.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {}) - ]) -} - function resolvePrimaryLayoutPtyId(layout: TerminalLayoutSnapshot): string | null { const ptyIdsByLeafId = layout.ptyIdsByLeafId ?? {} const activePtyId = layout.activeLeafId ? ptyIdsByLeafId[layout.activeLeafId] : undefined @@ -3954,16 +3943,6 @@ export const createTerminalSlice: StateCreator Object.entries(session.tabsByWorktree) .filter(([worktreeId]) => validWorktreeIds.has(worktreeId)) .map(([worktreeId, tabs]) => { - const canonicalTerminalIds = new Set( - (session.unifiedTabs?.[worktreeId] ?? []).flatMap((tab) => - tab.contentType === 'terminal' ? [tab.entityId] : [] - ) - ) - const canonicalPtyIds = new Set( - tabs - .filter((tab) => canonicalTerminalIds.has(tab.id)) - .flatMap((tab) => collectPersistedTerminalPtyIds(session, tab)) - ) const quickCommandLabelByTerminalId = new Map( (session.unifiedTabs?.[worktreeId] ?? []) .filter((tab) => tab.contentType === 'terminal' && tab.quickCommandLabel?.trim()) @@ -3978,15 +3957,8 @@ export const createTerminalSlice: StateCreator worktreeId, [...tabs] .filter((tab) => { - // Why: canonical mounts win PTY ownership over stale legacy duplicates. // Why: old web-client mirrors could persist host surface ids with "::"; makePaneKey reserves ":" as its separator. - return ( - (canonicalTerminalIds.has(tab.id) || - !collectPersistedTerminalPtyIds(session, tab).some((ptyId) => - canonicalPtyIds.has(ptyId) - )) && - isValidTerminalTabId(tab.id) - ) + return isValidTerminalTabId(tab.id) }) .sort((a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt) .map((tab, index) => { From 20aeb0cb990a17f328b673f51717824d2f9c10ea Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:52:37 -0700 Subject: [PATCH 06/45] Prepare mobile 0.0.42 and fix TestFlight CI hang (#12961) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Bump mobile app.json to 0.0.42 * fix(mobile-ios): stop TestFlight CI from waiting on ASC processing 0.0.42 builds 1–2 uploaded successfully then hung for hours polling processing with no Ready build and no Apple email. Exit after upload and cap the job at 90m so the next cut does not repeat that hang. * fix(mobile-ios): fully skip Pilot wait (no changelog) Pilot only returns immediately after upload when changelog is nil; passing notes re-enters the ASC build-list poll. --- .github/workflows/mobile-ios-release.yml | 3 +++ mobile/app.json | 2 +- mobile/fastlane/Fastfile | 25 ++++++++---------------- 3 files changed, 12 insertions(+), 18 deletions(-) diff --git a/.github/workflows/mobile-ios-release.yml b/.github/workflows/mobile-ios-release.yml index f759bd3efee..9e31a0b94f4 100644 --- a/.github/workflows/mobile-ios-release.yml +++ b/.github/workflows/mobile-ios-release.yml @@ -30,6 +30,9 @@ jobs: # (@MainActor isolation). Xcode 16.x (macos-15) can't even parse it # ("unknown attribute 'MainActor'"), so we need Xcode 26.x → macos-26. runs-on: macos-26 + # Archive + upload is ~30–40m when healthy; 90m leaves margin without + # sitting multi-hour on ASC processing polls (see Fastfile pilot wait). + timeout-minutes: 90 defaults: run: diff --git a/mobile/app.json b/mobile/app.json index 87b50446486..e1749d34b7d 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Orca", "slug": "orca-mobile", - "version": "0.0.41", + "version": "0.0.42", "orientation": "default", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", diff --git a/mobile/fastlane/Fastfile b/mobile/fastlane/Fastfile index d08ce9462f1..45819c55ffd 100644 --- a/mobile/fastlane/Fastfile +++ b/mobile/fastlane/Fastfile @@ -28,8 +28,6 @@ WORKSPACE = File.join(MOBILE_ROOT, "ios", "Orca.xcworkspace") BUILD_OUTPUT_DIRECTORY = File.join(MOBILE_ROOT, "build") SCHEME = "Orca" BUNDLE_ID = "com.stably.orca.mobile" -TESTFLIGHT_GROUPS = ["peeps"].freeze -DEFAULT_TESTFLIGHT_CHANGELOG = "Latest Orca Mobile updates and fixes.".freeze # App Store version states in which the version "train" is terminally closed to # new TestFlight build uploads (altool rejects with 90186 "train ... is @@ -112,11 +110,6 @@ rescue StandardError => error nil end -def testflight_changelog - changelog = ENV.fetch("TESTFLIGHT_CHANGELOG", "").strip - changelog.empty? ? DEFAULT_TESTFLIGHT_CHANGELOG : changelog -end - platform :ios do desc "Resolve the iOS release version and next TestFlight build number" lane :prepare_release_version do |options| @@ -178,7 +171,7 @@ platform :ios do UI.message("Prepared Orca Mobile iOS #{version} (#{build_number})") end - desc "Build, sign, upload, and share Orca Mobile on TestFlight" + desc "Build, sign, and upload Orca Mobile to App Store Connect / TestFlight" lane :release do api_key = app_store_connect_api_key_from_env @@ -221,15 +214,13 @@ platform :ios do upload_to_testflight( api_key: api_key, - # Why: fastlane can only assign external TestFlight groups after Apple - # finishes processing the uploaded build. - skip_waiting_for_build_processing: false, - distribute_external: true, - groups: TESTFLIGHT_GROUPS, - notify_external_testers: true, - # Why: fastlane requires release notes when distributing to external - # TestFlight testers, even for CI-triggered smoke releases. - changelog: testflight_changelog, + # Why: ASC processing wait hung for hours (0.0.42 builds 1–2) with no Ready + # build and no email. Exit after upload acceptance. Do not pass changelog — + # Pilot only fully skips waiting when changelog is nil (otherwise it still + # polls until the build appears). External distribute (e.g. peeps) from ASC + # or a follow-up lane once Ready. + skip_waiting_for_build_processing: true, + distribute_external: false, ) end end From 38275c2aa2d4fa2bb45938f63f3bfb4e8948268b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:53:41 -0700 Subject: [PATCH 07/45] fix(codex): publish Windows system-default sessions (#12611) * fix(codex): publish Windows system-default sessions * fix(codex): close launch-scheduling races in session migration scheduler * fix(codex): bound repeated session migration audits * fix(codex): preserve delayed session publication passes * fix(codex): bound failed session audit events * fix(codex): fence stale session migration markers * chore: preserve main formatting after merge * perf(codex): bound launch session migration scans * perf(codex): preserve coalesced migration scope * fix(codex): preserve scheduled migration recovery * fix(codex): preserve session migration recovery * fix(codex): close session migration launch races * fix(codex): harden session migration completion --- .../runtime-home-service.test.ts | 71 ++- .../codex-accounts/runtime-home-service.ts | 81 +++- ...ime-home-windows-profile-ownership.test.ts | 1 + .../codex-session-backfill-audit-pass.ts | 118 +++++ .../codex/codex-session-backfill-audit.ts | 137 +++++- src/main/codex/codex-session-backfill-date.ts | 65 +++ .../codex/codex-session-backfill-marker.ts | 39 +- .../codex/codex-session-backfill-types.ts | 12 + src/main/codex/codex-session-backfill.test.ts | 428 +++++++++++++++-- src/main/codex/codex-session-backfill.ts | 178 +++---- .../codex/codex-session-index-heal-state.ts | 28 +- .../codex/codex-session-index-heal.test.ts | 31 +- .../codex-session-migration-scheduler.test.ts | 450 +++++++++++++++++- .../codex-session-migration-scheduler.ts | 252 +++++++++- src/main/index.ts | 48 +- src/main/ipc/pty.test.ts | 86 +++- src/main/ipc/pty.ts | 71 ++- .../window/attach-main-window-services.ts | 7 +- src/types/psl.ts | 17 + 19 files changed, 1890 insertions(+), 230 deletions(-) create mode 100644 src/main/codex/codex-session-backfill-audit-pass.ts create mode 100644 src/main/codex/codex-session-backfill-date.ts create mode 100644 src/types/psl.ts diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 8d3a52fa823..e07496b9e07 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -1004,10 +1004,62 @@ describe('CodexRuntimeHomeService', () => { const store = createStore(createSettings()) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) - // Why: a mirror launch never leaves the real home, so its backfill stays valid. - expect(existsSync(markerPath)).toBe(true) + expect(existsSync(markerPath)).toBe(false) + service.finishHostSystemDefaultSessionMigrationPass() + expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe( + true + ) + service.finishHostSystemDefaultSessionMigrationPass() + writeFileSync( + markerPath, + `${JSON.stringify({ + version: 3, + systemSessionsRoot: join(getSystemCodexHomePath(), 'sessions'), + summary: { scannedFiles: 1 } + })}\n`, + 'utf-8' + ) + service.prepareForCodexLaunch() + writeFileSync( + markerPath, + `${JSON.stringify({ + version: 3, + systemSessionsRoot: join(getSystemCodexHomePath(), 'sessions'), + summary: { scannedFiles: 1 } + })}\n`, + 'utf-8' + ) + expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe( + false + ) + expect(existsSync(markerPath)).toBe(false) + service.prepareForCodexLaunch() + expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe( + false + ) + expect(service.beginHostSystemDefaultSessionMigrationLaunch(null)).toBeNull() + service.finishHostSystemDefaultSessionMigrationPass() + writeFileSync( + markerPath, + `${JSON.stringify({ + version: 3, + systemSessionsRoot: join(getSystemCodexHomePath(), 'sessions'), + summary: { scannedFiles: 1 } + })}\n`, + 'utf-8' + ) + expect(service.beginHostSystemDefaultSessionMigrationLaunch(null, { reattached: true })).toBe( + false + ) + expect(existsSync(markerPath)).toBe(false) + store.updateSettings({ + codexSessionSourceHome: { host: join(testState.fakeHomeDir, 'moved-history'), wsl: {} } + }) + service.prepareForCodexLaunch() + expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe( + true + ) expect(service.prepareForRateLimitFetch()).toBe(getRuntimeCodexHomePath()) expect(service.getHostCodexHomePathsForSessionDiscovery()).toEqual([getRuntimeCodexHomePath()]) expect(existsSync(getRuntimeCodexHomePath())).toBe(true) @@ -1037,6 +1089,10 @@ describe('CodexRuntimeHomeService', () => { writeFileSync(markerPath, '{}\n', 'utf-8') expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) expect(existsSync(markerPath)).toBe(false) + expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe( + true + ) + service.finishHostSystemDefaultSessionMigrationPass() service.setRealHomeLaneGate(() => true) const perSpawnCustomHome = join(testState.fakeHomeDir, 'per-spawn-custom-codex-home') writeFileSync(markerPath, '{}\n', 'utf-8') @@ -1045,6 +1101,11 @@ describe('CodexRuntimeHomeService', () => { getRuntimeCodexHomePath() ) expect(existsSync(markerPath)).toBe(true) + expect( + service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath(), { + launchEnv: { CODEX_HOME: perSpawnCustomHome } + }) + ).toBeNull() if (process.platform !== 'win32') { // Why: shell startup CODEX_HOME discovery is a POSIX-shell lane; Windows // must not invoke an ambient WSL bash while evaluating this contract. @@ -1719,7 +1780,11 @@ describe('CodexRuntimeHomeService', () => { // A host managed account's own home is its CODEX_HOME. expect(service.isHostSystemDefaultRealHome()).toBe(false) + expect(service.isHostSystemDefaultSessionMigrationEligible()).toBe(false) expect(service.prepareForCodexLaunch()).toBe(managedHomePath) + expect( + service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath()) + ).toBeNull() // The per-account home keeps its own auth in place; the shared mirror's // auth.json is never hot-swapped, so two accounts cannot race one file. expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe( diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index c2b99b52c6d..68d054f3571 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -41,7 +41,6 @@ import { import { getOrcaManagedCodexHomePath, getOrcaUserDataPath, - getCodexSessionBackfillStateDirPath, getSystemCodexHomePath, resolveOrcaManagedCodexHomePath, syncCodexGlobalInstructionsIntoManagedHome, @@ -68,7 +67,11 @@ import { } from './runtime-selection' import { getDefaultWslDistro, getWslHome } from '../wsl' import { hasCustomCodexHomeOverrideForLaunch } from '../codex/codex-real-home-path' -import { invalidateCodexSessionBackfillMarker } from '../codex/codex-session-backfill-marker' +import { + hasCompletedCodexSessionBackfillMarker, + invalidateCodexSessionBackfillMarker +} from '../codex/codex-session-backfill-marker' +import { resolveCodexSessionBackfillPaths } from '../codex/codex-session-backfill' import { assertOwnedHostCodexManagedHomePath } from './host-codex-managed-home-ownership' import { codexAuthCouldBelongToManagedAccount, @@ -181,6 +184,9 @@ export class CodexRuntimeHomeService { private sharedAuthRefreshBlockedByManagedTransition = false // Why: transient auth.json read/parse failures must not deselect an account. private readonly credentialAbsenceGrace = new CodexCredentialAbsenceGrace() + private hostSystemDefaultSessionMigrationPending = false + private pendingHostSystemDefaultSessionMigrationNeedsFullScan = false + private pendingHostSystemDefaultSessionMigrationTarget: string | null = null constructor(private readonly store: Store) { this.safeRecoverInterruptedRuntimeAuthOperation() @@ -253,6 +259,53 @@ export class CodexRuntimeHomeService { return this.getRuntimeHomePath() } + beginHostSystemDefaultSessionMigrationLaunch( + codexHomePath: string | null, + options: { reattached?: boolean; launchEnv?: NodeJS.ProcessEnv } = {} + ): boolean | null { + if ( + !this.isHostSystemDefaultSessionMigrationEligible() || + (!codexHomePath && !options.reattached) || + (codexHomePath && + normalizeRuntimePathForComparison(codexHomePath) !== + normalizeRuntimePathForComparison(this.getRuntimeHomePath())) + ) { + return null + } + // Why: an older pass can clear launch preparation while PTY spawn awaits recovery. + return this.invalidateBackfillAfterManagedSystemDefaultLaunch( + options.reattached && !codexHomePath ? undefined : options.launchEnv + ) + } + + isHostSystemDefaultSessionMigrationEligible(): boolean { + return ( + normalizeCodexRuntimeSelection(this.store.getSettings()).host === null && + !hasCustomCodexHomeOverrideForLaunch() + ) + } + + prepareHostSystemDefaultSessionMigrationPass(): boolean { + const paths = resolveCodexSessionBackfillPaths( + resolveHostCodexSessionSourceHome(this.store.getSettings()) + ) + if ( + this.hostSystemDefaultSessionMigrationPending && + this.pendingHostSystemDefaultSessionMigrationTarget !== paths.systemSessionsRoot + ) { + this.pendingHostSystemDefaultSessionMigrationNeedsFullScan = true + this.pendingHostSystemDefaultSessionMigrationTarget = paths.systemSessionsRoot + } + invalidateCodexSessionBackfillMarker(paths.markerPath) + return this.pendingHostSystemDefaultSessionMigrationNeedsFullScan + } + + finishHostSystemDefaultSessionMigrationPass(): void { + this.hostSystemDefaultSessionMigrationPending = false + this.pendingHostSystemDefaultSessionMigrationNeedsFullScan = false + this.pendingHostSystemDefaultSessionMigrationTarget = null + } + // Why: a managed HOST account runs against its own self-contained CODEX_HOME // (codex-accounts//home) rather than the shared runtime mirror. Its // auth.json lives there and codex refreshes it in place, so two accounts never @@ -404,18 +457,26 @@ export class CodexRuntimeHomeService { this.lastHostAccountUsedSelfContainedHome = false } - private invalidateBackfillAfterManagedSystemDefaultLaunch(launchEnv?: NodeJS.ProcessEnv): void { + private invalidateBackfillAfterManagedSystemDefaultLaunch( + launchEnv?: NodeJS.ProcessEnv + ): boolean | null { const settings = this.store.getSettings() - if (normalizeCodexRuntimeSelection(settings).host !== null) { - return + if ( + normalizeCodexRuntimeSelection(settings).host !== null || + hasCustomCodexHomeOverrideForLaunch(launchEnv) + ) { + return null } - // Why: reached only when the real-home lane is selected but its gate is off, - // so the launch runs on the mirror and the backfill marker is stale. - if (this.isHostSystemDefaultRealHomeSelected(launchEnv)) { - invalidateCodexSessionBackfillMarker( - join(getCodexSessionBackfillStateDirPath(), 'backfill-complete.json') + if (!this.hostSystemDefaultSessionMigrationPending) { + const paths = resolveCodexSessionBackfillPaths( + resolveHostCodexSessionSourceHome(this.store.getSettings()) ) + this.pendingHostSystemDefaultSessionMigrationNeedsFullScan = + !hasCompletedCodexSessionBackfillMarker(paths.markerPath, paths.systemSessionsRoot) + this.pendingHostSystemDefaultSessionMigrationTarget = paths.systemSessionsRoot + this.hostSystemDefaultSessionMigrationPending = true } + return this.prepareHostSystemDefaultSessionMigrationPass() } private startWslSessionBridgeForLaunch( diff --git a/src/main/codex-accounts/runtime-home-windows-profile-ownership.test.ts b/src/main/codex-accounts/runtime-home-windows-profile-ownership.test.ts index 54ab5d5f095..337aaaf5375 100644 --- a/src/main/codex-accounts/runtime-home-windows-profile-ownership.test.ts +++ b/src/main/codex-accounts/runtime-home-windows-profile-ownership.test.ts @@ -29,6 +29,7 @@ describe('Windows System Default Codex home ownership', () => { SHELL: 'powershell.exe' }) ).toBe(false) + expect(service.isHostSystemDefaultSessionMigrationEligible()).toBe(true) }) }) diff --git a/src/main/codex/codex-session-backfill-audit-pass.ts b/src/main/codex/codex-session-backfill-audit-pass.ts new file mode 100644 index 00000000000..8958cf689b6 --- /dev/null +++ b/src/main/codex/codex-session-backfill-audit-pass.ts @@ -0,0 +1,118 @@ +import type { Stats } from 'node:fs' +import { lstat } from 'node:fs/promises' +import { + appendCodexSessionHealAuditRecord, + createCodexSessionBackfillAuditWriter, + createCodexSessionBackfillDiagnosticEventId, + createCodexSessionBackfillFileEventId, + readCodexSessionBackfillAuditCoverage, + recordExistingCodexSessionForHeal, + type CodexSessionBackfillAuditWriter +} from './codex-session-backfill-audit' +import type { CodexSessionBackfillSummary } from './codex-session-backfill-types' + +export type CodexSessionBackfillAuditPass = { + appendRecord: CodexSessionBackfillAuditWriter + recordExisting( + summary: CodexSessionBackfillSummary, + source: string, + target: string, + targetStat: Stats | null + ): Promise + recordPublished( + summary: CodexSessionBackfillSummary, + action: 'hardlink' | 'copy', + source: string, + target: string + ): Promise + recordDiagnostic( + record: { + action: 'copy-unsupported' | 'failed' + source: string + target: string + error?: string + linkError?: string + errorCode?: string + linkErrorCode?: string + }, + sourceStat: Stats | null + ): Promise + finish(summary: CodexSessionBackfillSummary): Promise +} + +export async function createCodexSessionBackfillAuditPass( + auditLogPath: string +): Promise { + const coverage = await readCodexSessionBackfillAuditCoverage(auditLogPath) + const writeAuditRecord = createCodexSessionBackfillAuditWriter(auditLogPath) + let auditChanged = false + const appendRecord: CodexSessionBackfillAuditWriter = async (record) => { + const appended = await writeAuditRecord(record) + auditChanged ||= appended + return appended + } + + return { + appendRecord, + async recordExisting(summary, source, target, targetStat): Promise { + const fileEventId = targetStat + ? createCodexSessionBackfillFileEventId(target, targetStat) + : undefined + if (fileEventId && coverage.fileEventIds.has(fileEventId)) { + summary.skippedExistingFiles += 1 + return + } + if ( + await recordExistingCodexSessionForHeal(appendRecord, summary, source, target, fileEventId) + ) { + if (fileEventId) { + coverage.fileEventIds.add(fileEventId) + } + } + }, + async recordPublished(summary, action, source, target): Promise { + const targetStat = await readCodexSessionTargetStat(target) + const fileEventId = targetStat + ? createCodexSessionBackfillFileEventId(target, targetStat) + : undefined + if ( + await appendCodexSessionHealAuditRecord(appendRecord, summary, { + action, + source, + target, + ...(fileEventId ? { fileEventId } : {}) + }) + ) { + if (fileEventId) { + coverage.fileEventIds.add(fileEventId) + } + } + }, + async recordDiagnostic(record, sourceStat): Promise { + const diagnosticEventId = createCodexSessionBackfillDiagnosticEventId({ + ...record, + sourceStat + }) + if (coverage.diagnosticEventIds.has(diagnosticEventId)) { + return + } + if (await appendRecord({ ...record, diagnosticEventId })) { + coverage.diagnosticEventIds.add(diagnosticEventId) + } + }, + async finish(summary): Promise { + if (auditChanged || !coverage.hasRunSummary) { + await appendRecord({ action: 'run-summary', ...summary }) + } + } + } +} + +/** A broken symlink at the target still counts as taken. */ +export async function readCodexSessionTargetStat(entryPath: string): Promise { + try { + return await lstat(entryPath) + } catch { + return null + } +} diff --git a/src/main/codex/codex-session-backfill-audit.ts b/src/main/codex/codex-session-backfill-audit.ts index c4740b95572..1bec593821a 100644 --- a/src/main/codex/codex-session-backfill-audit.ts +++ b/src/main/codex/codex-session-backfill-audit.ts @@ -1,10 +1,22 @@ -import { randomUUID } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' +import { createReadStream, type Stats } from 'node:fs' import { appendFile, mkdir } from 'node:fs/promises' import { dirname } from 'node:path' +import { createInterface } from 'node:readline' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import type { CodexSessionBackfillSummary } from './codex-session-backfill-types' export type CodexSessionBackfillAuditWriter = (record: Record) => Promise +export type CodexSessionBackfillAuditCoverage = { + fileEventIds: Set + diagnosticEventIds: Set + hasRunSummary: boolean +} + +const HEAL_AUDIT_ACTIONS = new Set(['hardlink', 'copy', 'existing']) +const DIAGNOSTIC_AUDIT_ACTIONS = new Set(['copy-unsupported', 'failed']) + export function createCodexSessionBackfillAuditWriter( auditLogPath: string ): CodexSessionBackfillAuditWriter { @@ -48,28 +60,139 @@ export function createCodexSessionBackfillAuditWriter( } } +export async function readCodexSessionBackfillAuditCoverage( + auditLogPath: string +): Promise { + const coverage: CodexSessionBackfillAuditCoverage = { + fileEventIds: new Set(), + diagnosticEventIds: new Set(), + hasRunSummary: false + } + const input = createReadStream(auditLogPath, { encoding: 'utf-8' }) + const lines = createInterface({ input, crlfDelay: Infinity }) + try { + for await (const raw of lines) { + try { + const parsed: unknown = JSON.parse(raw) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + continue + } + const record = parsed as Record + coverage.hasRunSummary ||= record.action === 'run-summary' + if ( + typeof record.action === 'string' && + HEAL_AUDIT_ACTIONS.has(record.action) && + typeof record.fileEventId === 'string' + ) { + coverage.fileEventIds.add(record.fileEventId) + } + if ( + typeof record.action === 'string' && + DIAGNOSTIC_AUDIT_ACTIONS.has(record.action) && + typeof record.diagnosticEventId === 'string' + ) { + coverage.diagnosticEventIds.add(record.diagnosticEventId) + } + } catch { + // Torn audit tails are quarantined by the writer's leading newline. + } + } + } catch (error) { + if (!isNotFoundError(error)) { + throw error + } + } + return coverage +} + +export function createCodexSessionBackfillFileEventId(targetPath: string, stat: Stats): string { + const fileIdentity = [ + stat.dev, + stat.ino, + stat.birthtimeMs, + stat.size, + stat.mtimeMs, + stat.ctimeMs + ].join('\0') + return createHash('sha256') + .update(normalizeRuntimePathForComparison(targetPath)) + .update('\0') + .update(fileIdentity) + .digest('hex') +} + +export function createCodexSessionBackfillDiagnosticEventId(args: { + action: 'copy-unsupported' | 'failed' + source: string + target: string + sourceStat: Stats | null + errorCode?: string + linkErrorCode?: string +}): string { + const sourceIdentity = args.sourceStat + ? [ + args.sourceStat.dev, + args.sourceStat.ino, + args.sourceStat.birthtimeMs, + args.sourceStat.size, + args.sourceStat.mtimeMs, + args.sourceStat.ctimeMs + ].join('\0') + : 'unavailable' + return createHash('sha256') + .update(args.action) + .update('\0') + .update(normalizeRuntimePathForComparison(args.source)) + .update('\0') + .update(normalizeRuntimePathForComparison(args.target)) + .update('\0') + .update(sourceIdentity) + .update('\0') + .update(args.errorCode ?? '') + .update('\0') + .update(args.linkErrorCode ?? '') + .digest('hex') +} + +export function describeCodexSessionBackfillErrorCode(error: unknown): string { + const code = (error as NodeJS.ErrnoException | null)?.code + return typeof code === 'string' && code + ? code + : error instanceof Error + ? error.name + : typeof error +} + export async function appendCodexSessionHealAuditRecord( writer: CodexSessionBackfillAuditWriter, summary: CodexSessionBackfillSummary, record: Record -): Promise { - if (!(await writer(record))) { +): Promise { + const appended = await writer(record) + if (!appended) { summary.failedHealAuditRecords += 1 } + return appended } export async function recordExistingCodexSessionForHeal( writer: CodexSessionBackfillAuditWriter, summary: CodexSessionBackfillSummary, source: string, - target: string -): Promise { + target: string, + fileEventId?: string +): Promise { summary.skippedExistingFiles += 1 // Why: this also recovers a rollout installed before a crash or audit // failure; thread/read is idempotent for a pre-existing real-home file. - await appendCodexSessionHealAuditRecord(writer, summary, { + return appendCodexSessionHealAuditRecord(writer, summary, { action: 'existing', source, - target + target, + ...(fileEventId ? { fileEventId } : {}) }) } + +function isNotFoundError(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' +} diff --git a/src/main/codex/codex-session-backfill-date.ts b/src/main/codex/codex-session-backfill-date.ts new file mode 100644 index 00000000000..8c2157d9d61 --- /dev/null +++ b/src/main/codex/codex-session-backfill-date.ts @@ -0,0 +1,65 @@ +import { join, relative, sep } from 'node:path' +import { listCodexSessionJsonlFilesIncrementally } from './codex-session-file-listing' +import type { + CodexSessionBackfillDate, + CodexSessionBackfillOptions +} from './codex-session-backfill-types' + +export function getCodexSessionBackfillDate(date = new Date()): CodexSessionBackfillDate { + return [ + String(date.getUTCFullYear()).padStart(4, '0'), + String(date.getUTCMonth() + 1).padStart(2, '0'), + String(date.getUTCDate()).padStart(2, '0') + ] +} + +export function isCodexSessionRolloutPath(sessionsRoot: string, filePath: string): boolean { + const pathParts = relative(sessionsRoot, filePath).split(sep) + if (pathParts.length !== 4) { + return false + } + const [year, month, day, fileName] = pathParts + return ( + /^\d{4}$/.test(year) && + /^\d{2}$/.test(month) && + /^\d{2}$/.test(day) && + /^rollout-.+\.jsonl$/.test(fileName) + ) +} + +export async function* listCodexSessionBackfillFilesForDates( + sessionsRoot: string, + options: CodexSessionBackfillOptions, + onDirectoryError: (directoryPath: string, error: unknown) => void | Promise +): AsyncGenerator { + const scanRoots = resolveCodexSessionBackfillDateRoots(sessionsRoot, options.scanDates) + for (const scanRoot of scanRoots) { + yield* listCodexSessionJsonlFilesIncrementally( + scanRoot, + options, + async (directoryPath, error) => { + if (directoryPath !== scanRoot || !isNotFoundError(error)) { + await onDirectoryError(directoryPath, error) + } + } + ) + } +} + +function resolveCodexSessionBackfillDateRoots( + sessionsRoot: string, + scanDates: readonly CodexSessionBackfillDate[] | undefined +): string[] { + if (!scanDates?.length) { + return [sessionsRoot] + } + return scanDates + .filter( + ([year, month, day]) => /^\d{4}$/.test(year) && /^\d{2}$/.test(month) && /^\d{2}$/.test(day) + ) + .map(([year, month, day]) => join(sessionsRoot, year, month, day)) +} + +function isNotFoundError(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' +} diff --git a/src/main/codex/codex-session-backfill-marker.ts b/src/main/codex/codex-session-backfill-marker.ts index d3d5a07e5f7..8c2c9fb9c50 100644 --- a/src/main/codex/codex-session-backfill-marker.ts +++ b/src/main/codex/codex-session-backfill-marker.ts @@ -6,6 +6,11 @@ import type { CodexSessionBackfillSummary } from './codex-session-backfill-types // Why: bump to re-run the backfill for every host after a layout or semantics // change; the run itself stays skip-existing so re-runs never overwrite. const CODEX_SESSION_BACKFILL_MARKER_VERSION = 3 +let markerInvalidationGeneration = 0 + +export function captureCodexSessionBackfillMarkerGeneration(): number { + return markerInvalidationGeneration +} export function hasCompletedCodexSessionBackfillMarker( markerPath: string, @@ -16,13 +21,22 @@ export function hasCompletedCodexSessionBackfillMarker( if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { return false } - const marker = parsed as { version?: unknown; systemSessionsRoot?: unknown } + const marker = parsed as { + version?: unknown + systemSessionsRoot?: unknown + summary?: { scannedFiles?: unknown } + } // Why: changing the configured real Codex home must backfill the new // target instead of honoring a marker written for a different history. - return ( + const markerMatchesTarget = marker.version === CODEX_SESSION_BACKFILL_MARKER_VERSION && marker.systemSessionsRoot === systemSessionsRoot - ) + if (!markerMatchesTarget) { + return false + } + // Why: an empty source can become populated after an early migration run; + // let the incremental async walk verify it without blocking the main thread. + return marker.summary?.scannedFiles !== 0 } catch { return false } @@ -31,8 +45,13 @@ export function hasCompletedCodexSessionBackfillMarker( export function writeCodexSessionBackfillMarker( markerPath: string, systemSessionsRoot: string, - summary: CodexSessionBackfillSummary + summary: CodexSessionBackfillSummary, + expectedGeneration: number ): void { + // Why: a launch can invalidate this pass before its delayed replacement begins. + if (expectedGeneration !== markerInvalidationGeneration) { + return + } mkdirSync(dirname(markerPath), { recursive: true }) writeFileAtomically( markerPath, @@ -50,11 +69,23 @@ export function writeCodexSessionBackfillMarker( } export function invalidateCodexSessionBackfillMarker(markerPath: string): void { + markerInvalidationGeneration += 1 try { // Why: a managed-lane system-default launch can create new source // rollouts, so a prior one-time marker must not suppress the next opt-in. rmSync(markerPath, { force: true }) } catch (error) { console.warn('[codex-session-backfill] Failed to invalidate completion marker:', error) + try { + writeFileAtomically( + markerPath, + `${JSON.stringify({ version: 0, invalidatedAt: Date.now() })}\n` + ) + } catch (fallbackError) { + throw new AggregateError( + [error, fallbackError], + 'Failed to invalidate Codex session backfill marker' + ) + } } } diff --git a/src/main/codex/codex-session-backfill-types.ts b/src/main/codex/codex-session-backfill-types.ts index d3f25b37bbf..92891beaaff 100644 --- a/src/main/codex/codex-session-backfill-types.ts +++ b/src/main/codex/codex-session-backfill-types.ts @@ -24,4 +24,16 @@ export type CodexSessionBackfillPaths = { export type CodexSessionBackfillOptions = CodexSessionBridgeIncrementalOptions & { /** Polled before each target mutation; true stops with progress preserved. */ shouldStop?: () => boolean + /** Limits a launch-triggered pass to the date directories that can contain its rollouts. */ + scanDates?: readonly CodexSessionBackfillDate[] + /** A scheduled launch pass must not be suppressed by a marker it just invalidated. */ + ignoreCompletionMarker?: boolean + /** Active launch passes defer global completion until their final exit scan. */ + writeCompletionMarker?: boolean + /** Final launch scans can extend a previously certified full-tree baseline. */ + writeBoundedCompletionMarker?: boolean + /** Rechecks launch scheduling state immediately before marker publication. */ + canWriteCompletionMarker?: () => boolean } + +export type CodexSessionBackfillDate = readonly [year: string, month: string, day: string] diff --git a/src/main/codex/codex-session-backfill.test.ts b/src/main/codex/codex-session-backfill.test.ts index bd5e0bca680..418ef0d8381 100644 --- a/src/main/codex/codex-session-backfill.test.ts +++ b/src/main/codex/codex-session-backfill.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + appendFileSync, existsSync, lstatSync, mkdirSync, @@ -23,12 +24,14 @@ const { homedirMock } = vi.hoisted(() => ({ const { fsMockState } = vi.hoisted(() => ({ fsMockState: { failLink: false, - failInstallLink: false, - failInstallLinkTransiently: false, + failLinkTransiently: false, + failLinkPermission: false, raceTargetIntoExistence: false, - failCopy: false, + failMarkerRm: false, + failMarkerReplacement: false, failAuditMkdirOnce: false, failAuditWrites: false, + failMkdirPath: null as string | null, failDirectoryPath: null as string | null, failLstatPath: null as string | null } @@ -43,6 +46,30 @@ vi.mock('node:fs', async () => { return false } return actual.existsSync(...args) + }, + rmSync: (...args: Parameters) => { + if ( + fsMockState.failMarkerRm && + String(args[0]).includes('codex-session-backfill') && + String(args[0]).endsWith('backfill-complete.json') + ) { + const error = new Error('EACCES: marker removal failed') as NodeJS.ErrnoException + error.code = 'EACCES' + throw error + } + return actual.rmSync(...args) + }, + renameSync: (...args: Parameters) => { + if ( + fsMockState.failMarkerReplacement && + String(args[1]).includes('codex-session-backfill') && + String(args[1]).endsWith('backfill-complete.json') + ) { + const error = new Error('EACCES: marker replacement failed') as NodeJS.ErrnoException + error.code = 'EACCES' + throw error + } + return actual.renameSync(...args) } } }) @@ -52,6 +79,11 @@ vi.mock('node:fs/promises', async () => { return { ...actual, mkdir: (...args: Parameters) => { + if (args[0] === fsMockState.failMkdirPath) { + const error = new Error('EACCES: target directory inaccessible') as NodeJS.ErrnoException + error.code = 'EACCES' + throw error + } if (fsMockState.failAuditMkdirOnce && String(args[0]).includes('codex-session-backfill')) { fsMockState.failAuditMkdirOnce = false const error = new Error( @@ -91,30 +123,17 @@ vi.mock('node:fs/promises', async () => { error.code = 'EXDEV' throw error } - // Simulate a target filesystem with no hardlink support: even the - // same-volume staged-copy install link (.orca-backfill-*.tmp) fails. - if (fsMockState.failInstallLink && String(args[0]).includes('.orca-backfill-')) { - const error = new Error('EPERM: hardlinks unsupported') as NodeJS.ErrnoException - error.code = 'EPERM' - throw error - } - if (fsMockState.failInstallLinkTransiently && String(args[0]).includes('.orca-backfill-')) { - const error = new Error('EIO: transient install failure') as NodeJS.ErrnoException + if (fsMockState.failLinkTransiently && String(args[0]).includes('codex-runtime-home')) { + const error = new Error('EIO: transient hardlink failure') as NodeJS.ErrnoException error.code = 'EIO' throw error } - return actual.link(...args) - }, - copyFile: async (...args: Parameters) => { - if (fsMockState.failCopy) { - // Simulate a copy that fails after opening its destination, which is - // the dangerous case for resumability rather than a preflight error. - await actual.writeFile(args[1], 'partial copy\n', 'utf-8') - const error = new Error('EACCES: copy disabled for test') as NodeJS.ErrnoException + if (fsMockState.failLinkPermission && String(args[0]).includes('codex-runtime-home')) { + const error = new Error('EACCES: hardlink permission denied') as NodeJS.ErrnoException error.code = 'EACCES' throw error } - return actual.copyFile(...args) + return actual.link(...args) }, opendir: (...args: Parameters) => { if (args[0] === fsMockState.failDirectoryPath) { @@ -140,6 +159,7 @@ import { resolveCodexSessionBackfillPaths, startCodexSessionBackfillInBackground } from './codex-session-backfill' +import { invalidateCodexSessionBackfillMarker } from './codex-session-backfill-marker' let fakeHomeDir: string let userDataDir: string @@ -168,27 +188,40 @@ function writeManagedSession(relativePath: string, contents: string): string { return filePath } -function readAuditActions(): string[] { +type BackfillAuditRecord = { + action: string + target?: string + fileEventId?: string + diagnosticEventId?: string +} + +function readBackfillAuditRecords(): BackfillAuditRecord[] { return readFileSync(getAuditLogPath(), 'utf-8') .split('\n') .filter(Boolean) .flatMap((line) => { try { - return [(JSON.parse(line) as { action: string }).action] + return [JSON.parse(line) as BackfillAuditRecord] } catch { return [] } }) } +function readAuditActions(): string[] { + return readBackfillAuditRecords().map((record) => record.action) +} + beforeEach(() => { fsMockState.failLink = false - fsMockState.failInstallLink = false - fsMockState.failInstallLinkTransiently = false + fsMockState.failLinkTransiently = false + fsMockState.failLinkPermission = false fsMockState.raceTargetIntoExistence = false - fsMockState.failCopy = false + fsMockState.failMarkerRm = false + fsMockState.failMarkerReplacement = false fsMockState.failAuditMkdirOnce = false fsMockState.failAuditWrites = false + fsMockState.failMkdirPath = null fsMockState.failDirectoryPath = null fsMockState.failLstatPath = null fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-backfill-home-')) @@ -367,27 +400,22 @@ describe('backfillManagedCodexSessionsIntoSystemHome', () => { expect(readAuditActions()).toEqual(['hardlink', 'run-summary']) }) - it('falls back to copy when hardlinking fails across volumes', async () => { + it('skips cross-volume rollouts instead of freezing a mutable snapshot', async () => { fsMockState.failLink = true - const managedPath = writeManagedSession( - join('2026', '05', '26', 'rollout-a ü.jsonl'), - '{"id":"a"}\n' - ) + const relativePath = join('2026', '05', '26', 'rollout-a ü.jsonl') + writeManagedSession(relativePath, '{"id":"a"}\n') const summary = await backfillManagedCodexSessionsIntoSystemHome( resolveCodexSessionBackfillPaths() ) - expect(summary).toMatchObject({ linkedFiles: 0, copiedFiles: 1, failedFiles: 0 }) - const targetPath = join(getSystemSessionsRoot(), '2026', '05', '26', 'rollout-a ü.jsonl') - expect(readFileSync(targetPath, 'utf-8')).toBe(readFileSync(managedPath, 'utf-8')) - expect(lstatSync(targetPath).ino).not.toBe(lstatSync(managedPath).ino) - expect(readAuditActions()).toEqual(['copy', 'run-summary']) + expect(summary).toMatchObject({ copiedFiles: 0, skippedUnsupportedFilesystemFiles: 1 }) + expect(existsSync(join(getSystemSessionsRoot(), relativePath))).toBe(false) + expect(readAuditActions()).toEqual(['copy-unsupported', 'run-summary']) }) it('fails closed when the target filesystem cannot install without overwrite', async () => { fsMockState.failLink = true - fsMockState.failInstallLink = true writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') const summary = await backfillManagedCodexSessionsIntoSystemHome( @@ -406,9 +434,8 @@ describe('backfillManagedCodexSessionsIntoSystemHome', () => { expect(readAuditActions()).toEqual(['copy-unsupported', 'run-summary']) }) - it('keeps transient install failures retryable', async () => { - fsMockState.failLink = true - fsMockState.failInstallLinkTransiently = true + it('keeps transient hardlink failures retryable', async () => { + fsMockState.failLinkTransiently = true writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') const summary = await backfillManagedCodexSessionsIntoSystemHome( @@ -422,9 +449,33 @@ describe('backfillManagedCodexSessionsIntoSystemHome', () => { expect(readAuditActions()).toEqual(['failed', 'run-summary']) }) + it('keeps target directory permission failures retryable', async () => { + const relativePath = join('2026', '05', '26', 'rollout-a.jsonl') + writeManagedSession(relativePath, '{"id":"a"}\n') + fsMockState.failMkdirPath = dirname(join(getSystemSessionsRoot(), relativePath)) + + const summary = await startCodexSessionBackfillInBackground() + + expect(summary).toMatchObject({ failedFiles: 1, skippedUnsupportedFilesystemFiles: 0 }) + expect(existsSync(join(getSystemSessionsRoot(), relativePath))).toBe(false) + expect(existsSync(getMarkerPath())).toBe(false) + expect(readAuditActions()).toEqual(['failed', 'run-summary']) + }) + + it('keeps hardlink permission failures retryable', async () => { + fsMockState.failLinkPermission = true + const relativePath = join('2026', '05', '26', 'rollout-a.jsonl') + writeManagedSession(relativePath, '{"id":"a"}\n') + + const summary = await startCodexSessionBackfillInBackground() + + expect(summary).toMatchObject({ failedFiles: 1, skippedUnsupportedFilesystemFiles: 0 }) + expect(existsSync(join(getSystemSessionsRoot(), relativePath))).toBe(false) + expect(existsSync(getMarkerPath())).toBe(false) + }) + it('records per-file failures without aborting the run', async () => { - fsMockState.failLink = true - fsMockState.failCopy = true + fsMockState.failLinkTransiently = true writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') const summary = await backfillManagedCodexSessionsIntoSystemHome( @@ -446,6 +497,33 @@ describe('backfillManagedCodexSessionsIntoSystemHome', () => { expect(summary).toMatchObject({ scannedFiles: 0 }) expect(existsSync(getSystemSessionsRoot())).toBe(false) }) + + it('bounds a launch pass to its rollout date directories', async () => { + const oldRelativePath = join('2025', '12', '31', 'rollout-old.jsonl') + const launchRelativePath = join('2026', '08', '05', 'rollout-launch.jsonl') + writeManagedSession(oldRelativePath, 'old\n') + writeManagedSession(launchRelativePath, 'launch\n') + + const summary = await backfillManagedCodexSessionsIntoSystemHome( + resolveCodexSessionBackfillPaths(), + { scanDates: [['2026', '08', '05']] } + ) + + expect(summary).toMatchObject({ scannedFiles: 1, linkedFiles: 1, failedDirectories: 0 }) + expect(existsSync(join(getSystemSessionsRoot(), launchRelativePath))).toBe(true) + expect(existsSync(join(getSystemSessionsRoot(), oldRelativePath))).toBe(false) + }) + + it('treats a not-yet-created launch date as an empty bounded pass', async () => { + writeManagedSession(join('2025', '12', '31', 'rollout-old.jsonl'), 'old\n') + + const summary = await backfillManagedCodexSessionsIntoSystemHome( + resolveCodexSessionBackfillPaths(), + { scanDates: [['2026', '08', '05']] } + ) + + expect(summary).toMatchObject({ scannedFiles: 0, linkedFiles: 0, failedDirectories: 0 }) + }) }) describe('startCodexSessionBackfillInBackground', () => { @@ -479,6 +557,81 @@ describe('startCodexSessionBackfillInBackground', () => { expect(existsSync(getMarkerPath())).toBe(false) }) + it('defers completion while a launch lease is active', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + + const active = await startCodexSessionBackfillInBackground({ + writeCompletionMarker: false + }) + expect(active).toMatchObject({ linkedFiles: 1 }) + expect(existsSync(getMarkerPath())).toBe(false) + + const completed = await startCodexSessionBackfillInBackground() + expect(completed).toMatchObject({ skippedExistingFiles: 1 }) + expect(existsSync(getMarkerPath())).toBe(true) + }) + + it('rechecks launch state before publishing completion', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + + const summary = await startCodexSessionBackfillInBackground({ + canWriteCompletionMarker: () => false + }) + + expect(summary).toMatchObject({ linkedFiles: 1 }) + expect(existsSync(getMarkerPath())).toBe(false) + }) + + it('keeps an invalidated active pass from recreating the completion marker', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + let invalidated = false + + const raced = await startCodexSessionBackfillInBackground({ + shouldStop: () => { + if (!invalidated) { + invalidated = true + invalidateCodexSessionBackfillMarker(getMarkerPath()) + } + return false + } + }) + + expect(raced).toMatchObject({ linkedFiles: 1, stopped: false }) + expect(existsSync(getMarkerPath())).toBe(false) + const racedAudit = readFileSync(getAuditLogPath(), 'utf-8') + + const recovered = await startCodexSessionBackfillInBackground() + + expect(recovered).toMatchObject({ skippedExistingFiles: 1, failedHealAuditRecords: 0 }) + expect(existsSync(getMarkerPath())).toBe(true) + expect(readFileSync(getAuditLogPath(), 'utf-8')).toBe(racedAudit) + }) + + it('replaces a stale marker when direct removal fails', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + await startCodexSessionBackfillInBackground() + fsMockState.failMarkerRm = true + + invalidateCodexSessionBackfillMarker(getMarkerPath()) + + expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({ version: 0 }) + const recovered = await startCodexSessionBackfillInBackground() + expect(recovered).toMatchObject({ skippedExistingFiles: 1 }) + expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({ version: 3 }) + }) + + it('fails launch preparation when a stale marker cannot be invalidated', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + await startCodexSessionBackfillInBackground() + fsMockState.failMarkerRm = true + fsMockState.failMarkerReplacement = true + + expect(() => invalidateCodexSessionBackfillMarker(getMarkerPath())).toThrow( + 'Failed to invalidate Codex session backfill marker' + ) + expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({ version: 3 }) + }) + it('writes a completion marker and skips the walk on later runs', async () => { writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') @@ -487,13 +640,160 @@ describe('startCodexSessionBackfillInBackground', () => { expect(existsSync(getMarkerPath())).toBe(true) expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({ version: 3 }) - // A file appearing after the marker must not be backfilled again. + // An ordinary call remains a no-op; only a launch-scheduled pass bypasses the marker. writeManagedSession(join('2026', '07', '01', 'rollout-later.jsonl'), '{"id":"later"}\n') const second = await startCodexSessionBackfillInBackground() expect(second).toBeNull() expect( existsSync(join(getSystemSessionsRoot(), '2026', '07', '01', 'rollout-later.jsonl')) ).toBe(false) + + const scheduled = await startCodexSessionBackfillInBackground({ + ignoreCompletionMarker: true, + scanDates: [['2026', '07', '01']] + }) + expect(scheduled).toMatchObject({ scannedFiles: 1, linkedFiles: 1 }) + }) + + it('does not let a bounded pass certify older unscanned history', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-baseline.jsonl'), 'baseline\n') + await startCodexSessionBackfillInBackground() + + invalidateCodexSessionBackfillMarker(getMarkerPath()) + const missedRelativePath = join('2026', '06', '01', 'rollout-missed.jsonl') + writeManagedSession(missedRelativePath, 'missed\n') + writeManagedSession(join('2026', '08', '05', 'rollout-launch.jsonl'), 'launch\n') + + const bounded = await startCodexSessionBackfillInBackground({ + ignoreCompletionMarker: true, + scanDates: [['2026', '08', '05']] + }) + expect(bounded).toMatchObject({ scannedFiles: 1, linkedFiles: 1 }) + expect(existsSync(getMarkerPath())).toBe(false) + + const recovered = await startCodexSessionBackfillInBackground() + expect(recovered).toMatchObject({ scannedFiles: 3, linkedFiles: 1 }) + expect(existsSync(join(getSystemSessionsRoot(), missedRelativePath))).toBe(true) + expect(existsSync(getMarkerPath())).toBe(true) + }) + + it('lets an explicit bounded final pass restore a certified baseline', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-baseline.jsonl'), 'baseline\n') + await startCodexSessionBackfillInBackground() + + invalidateCodexSessionBackfillMarker(getMarkerPath()) + writeManagedSession(join('2026', '08', '05', 'rollout-launch.jsonl'), 'launch\n') + + const bounded = await startCodexSessionBackfillInBackground({ + ignoreCompletionMarker: true, + scanDates: [['2026', '08', '05']], + writeBoundedCompletionMarker: true + }) + + expect(bounded).toMatchObject({ scannedFiles: 1, linkedFiles: 1 }) + expect(existsSync(getMarkerPath())).toBe(true) + }) + + it('records a new heal event when a linked rollout grows in place', async () => { + const relativePath = join('2026', '05', '26', 'rollout-growing.jsonl') + const managedPath = writeManagedSession(relativePath, '{"id":"a"}\n') + await startCodexSessionBackfillInBackground() + + const firstRecord = readBackfillAuditRecords().find((record) => record.action === 'hardlink') + invalidateCodexSessionBackfillMarker(getMarkerPath()) + appendFileSync(managedPath, '{"event":"later"}\n', 'utf-8') + await startCodexSessionBackfillInBackground() + + const fileRecords = readBackfillAuditRecords().filter((record) => + ['hardlink', 'existing'].includes(record.action) + ) + expect(fileRecords).toHaveLength(2) + expect(fileRecords[1]).toMatchObject({ action: 'existing' }) + expect(fileRecords[1]?.fileEventId).not.toBe(firstRecord?.fileEventId) + }) + + it('keeps repeated launch invalidations audit-stable', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + + const first = await startCodexSessionBackfillInBackground() + expect(first).toMatchObject({ linkedFiles: 1, failedHealAuditRecords: 0 }) + const firstAudit = readFileSync(getAuditLogPath(), 'utf-8') + + for (let pass = 0; pass < 2; pass += 1) { + invalidateCodexSessionBackfillMarker(getMarkerPath()) + const repeated = await startCodexSessionBackfillInBackground() + expect(repeated).toMatchObject({ + linkedFiles: 0, + copiedFiles: 0, + skippedExistingFiles: 1, + failedHealAuditRecords: 0 + }) + expect(readFileSync(getAuditLogPath(), 'utf-8')).toBe(firstAudit) + } + + const fileRecords = readBackfillAuditRecords().filter((record) => + ['hardlink', 'copy', 'existing'].includes(record.action) + ) + expect(fileRecords).toEqual([ + expect.objectContaining({ action: 'hardlink', fileEventId: expect.any(String) }) + ]) + }) + + it('recovers a post-install audit interruption without duplicating prior events', async () => { + const firstRelativePath = join('2026', '05', '26', 'rollout-a.jsonl') + const secondRelativePath = join('2026', '05', '26', 'rollout-b.jsonl') + writeManagedSession(firstRelativePath, '{"id":"a"}\n') + await startCodexSessionBackfillInBackground() + + invalidateCodexSessionBackfillMarker(getMarkerPath()) + writeManagedSession(secondRelativePath, '{"id":"b"}\n') + fsMockState.failAuditWrites = true + + const interrupted = await startCodexSessionBackfillInBackground() + + expect(interrupted).toMatchObject({ linkedFiles: 1, failedHealAuditRecords: 1 }) + expect(existsSync(getMarkerPath())).toBe(false) + expect( + readBackfillAuditRecords().filter((record) => + ['hardlink', 'copy', 'existing'].includes(record.action) + ) + ).toHaveLength(1) + + fsMockState.failAuditWrites = false + const recovered = await startCodexSessionBackfillInBackground() + + expect(recovered).toMatchObject({ + linkedFiles: 0, + skippedExistingFiles: 2, + failedHealAuditRecords: 0 + }) + expect(existsSync(getMarkerPath())).toBe(true) + const recoveredFileRecords = readBackfillAuditRecords().filter((record) => + ['hardlink', 'copy', 'existing'].includes(record.action) + ) + expect(recoveredFileRecords).toHaveLength(2) + expect(new Set(recoveredFileRecords.map((record) => record.target))).toEqual( + new Set([ + join(getSystemSessionsRoot(), firstRelativePath), + join(getSystemSessionsRoot(), secondRelativePath) + ]) + ) + }) + + it('self-heals a zero-file marker when managed rollouts appear later', async () => { + const empty = await startCodexSessionBackfillInBackground() + expect(empty).toMatchObject({ scannedFiles: 0, linkedFiles: 0 }) + expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({ + summary: { scannedFiles: 0 } + }) + + writeManagedSession(join('2026', '07', '28', 'rollout-later.jsonl'), '{"id":"later"}\n') + const healed = await startCodexSessionBackfillInBackground() + + expect(healed).toMatchObject({ scannedFiles: 1, linkedFiles: 1 }) + expect( + existsSync(join(getSystemSessionsRoot(), '2026', '07', '28', 'rollout-later.jsonl')) + ).toBe(true) }) it('recovers an installed rollout after the completion marker write fails', async () => { @@ -534,19 +834,52 @@ describe('startCodexSessionBackfillInBackground', () => { it('does not retry a stable hardlink-less filesystem limitation', async () => { fsMockState.failLink = true - fsMockState.failInstallLink = true writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') const first = await startCodexSessionBackfillInBackground() expect(first).toMatchObject({ skippedUnsupportedFilesystemFiles: 1, failedFiles: 0 }) expect(existsSync(getMarkerPath())).toBe(true) + const firstAudit = readFileSync(getAuditLogPath(), 'utf-8') + invalidateCodexSessionBackfillMarker(getMarkerPath()) + const repeated = await startCodexSessionBackfillInBackground() + + expect(repeated).toMatchObject({ skippedUnsupportedFilesystemFiles: 1, failedFiles: 0 }) + expect(readFileSync(getAuditLogPath(), 'utf-8')).toBe(firstAudit) + expect(await startCodexSessionBackfillInBackground()).toBeNull() }) + it('keeps repeated unchanged per-file failures audit-stable', async () => { + fsMockState.failLinkTransiently = true + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + + const first = await startCodexSessionBackfillInBackground() + expect(first).toMatchObject({ failedFiles: 1 }) + const firstAudit = readFileSync(getAuditLogPath(), 'utf-8') + + const repeated = await startCodexSessionBackfillInBackground() + + expect(repeated).toMatchObject({ failedFiles: 1 }) + expect(readFileSync(getAuditLogPath(), 'utf-8')).toBe(firstAudit) + }) + + it('records a new failure event when the source file changes', async () => { + fsMockState.failLinkTransiently = true + const relativePath = join('2026', '05', '26', 'rollout-a.jsonl') + writeManagedSession(relativePath, '{"id":"a"}\n') + await startCodexSessionBackfillInBackground() + + writeManagedSession(relativePath, '{"id":"a","changed":true}\n') + await startCodexSessionBackfillInBackground() + + const failedRecords = readBackfillAuditRecords().filter((record) => record.action === 'failed') + expect(failedRecords).toHaveLength(2) + expect(new Set(failedRecords.map((record) => record.diagnosticEventId)).size).toBe(2) + }) + it('leaves the marker unset when any file fails so the next startup retries', async () => { - fsMockState.failLink = true - fsMockState.failCopy = true + fsMockState.failLinkTransiently = true writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') const first = await startCodexSessionBackfillInBackground() @@ -555,8 +888,7 @@ describe('startCodexSessionBackfillInBackground', () => { const targetPath = join(getSystemSessionsRoot(), '2026', '05', '26', 'rollout-a.jsonl') expect(existsSync(targetPath)).toBe(false) - fsMockState.failLink = false - fsMockState.failCopy = false + fsMockState.failLinkTransiently = false const second = await startCodexSessionBackfillInBackground() expect(second).toMatchObject({ linkedFiles: 1, failedFiles: 0 }) expect(readFileSync(targetPath, 'utf-8')).toBe('{"id":"a"}\n') diff --git a/src/main/codex/codex-session-backfill.ts b/src/main/codex/codex-session-backfill.ts index e0294a9a72a..720469cf2b0 100644 --- a/src/main/codex/codex-session-backfill.ts +++ b/src/main/codex/codex-session-backfill.ts @@ -1,24 +1,24 @@ import { link, lstat, mkdir } from 'node:fs/promises' -import { dirname, join, relative, sep } from 'node:path' +import { dirname, join, relative } from 'node:path' import { getCodexSessionBackfillStateDirPath, getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' import { - appendCodexSessionHealAuditRecord, - createCodexSessionBackfillAuditWriter, - recordExistingCodexSessionForHeal, - type CodexSessionBackfillAuditWriter -} from './codex-session-backfill-audit' + createCodexSessionBackfillAuditPass, + readCodexSessionTargetStat, + type CodexSessionBackfillAuditPass +} from './codex-session-backfill-audit-pass' +import { describeCodexSessionBackfillErrorCode } from './codex-session-backfill-audit' import { - copySessionFileWithoutOverwrite, - isAtomicNoReplaceUnsupportedError -} from './codex-session-backfill-copy' -import { listCodexSessionJsonlFilesIncrementally } from './codex-session-file-listing' + isCodexSessionRolloutPath, + listCodexSessionBackfillFilesForDates +} from './codex-session-backfill-date' import { + captureCodexSessionBackfillMarkerGeneration, hasCompletedCodexSessionBackfillMarker, - writeCodexSessionBackfillMarker + writeCodexSessionBackfillMarker as writeBackfillMarker } from './codex-session-backfill-marker' import type { CodexSessionBackfillOptions, @@ -87,7 +87,11 @@ async function runCodexSessionBackfillOncePerHost( systemCodexHomePathOverride?: string ): Promise { const paths = resolveCodexSessionBackfillPaths(systemCodexHomePathOverride) - if (hasCompletedCodexSessionBackfillMarker(paths.markerPath, paths.systemSessionsRoot)) { + const markerGeneration = captureCodexSessionBackfillMarkerGeneration() + if ( + !options.ignoreCompletionMarker && + hasCompletedCodexSessionBackfillMarker(paths.markerPath, paths.systemSessionsRoot) + ) { return null } const summary = await backfillManagedCodexSessionsIntoSystemHome(paths, options) @@ -96,11 +100,14 @@ async function runCodexSessionBackfillOncePerHost( if ( !summary.stopped && options.shouldStop?.() !== true && + options.writeCompletionMarker !== false && + options.canWriteCompletionMarker?.() !== false && + (options.scanDates === undefined || options.writeBoundedCompletionMarker === true) && summary.failedFiles === 0 && summary.failedDirectories === 0 && summary.failedHealAuditRecords === 0 ) { - writeCodexSessionBackfillMarker(paths.markerPath, paths.systemSessionsRoot, summary) + writeBackfillMarker(paths.markerPath, paths.systemSessionsRoot, summary, markerGeneration) } return summary } @@ -109,8 +116,8 @@ async function runCodexSessionBackfillOncePerHost( * Backfills managed-home session rollout files into the real Codex home. * * Non-destructive by contract: existing target files are always skipped, and - * nothing in either home is deleted or moved. Hardlink first so resume sees - * one physical JSONL log; copy is the cross-volume fallback. + * nothing in either home is deleted or moved. A hardlink keeps mutable rollout + * contents coherent; cross-volume snapshots are skipped as unsupported. */ export async function backfillManagedCodexSessionsIntoSystemHome( paths: CodexSessionBackfillPaths, @@ -129,22 +136,18 @@ export async function backfillManagedCodexSessionsIntoSystemHome( failedFiles: 0, failedHealAuditRecords: 0 } - const appendAuditRecord = createCodexSessionBackfillAuditWriter(paths.auditLogPath) + const auditPass = await createCodexSessionBackfillAuditPass(paths.auditLogPath) const ensuredTargetDirectories = new Set() - const managedSessionsRootExists = await checkManagedSessionsRoot( - paths, - summary, - appendAuditRecord - ) + const managedSessionsRootExists = await checkManagedSessionsRoot(paths, summary, auditPass) if (managedSessionsRootExists) { - for await (const managedSessionFilePath of listCodexSessionJsonlFilesIncrementally( + for await (const managedSessionFilePath of listCodexSessionBackfillFilesForDates( paths.managedSessionsRoot, options, async (directoryPath, error) => { // Why: a partial walk must remain retryable; otherwise an unreadable // date directory would be silently omitted behind a completion marker. summary.failedDirectories += 1 - await appendAuditRecord({ + await auditPass.appendRecord({ action: 'scan-failed', source: directoryPath, error: describeError(error) @@ -158,7 +161,7 @@ export async function backfillManagedCodexSessionsIntoSystemHome( break } summary.scannedFiles += 1 - if (!isCodexRolloutPath(paths.managedSessionsRoot, managedSessionFilePath)) { + if (!isCodexSessionRolloutPath(paths.managedSessionsRoot, managedSessionFilePath)) { summary.skippedUnexpectedFiles += 1 continue } @@ -168,13 +171,13 @@ export async function backfillManagedCodexSessionsIntoSystemHome( paths, managedSessionFilePath, summary, - appendAuditRecord, - ensuredTargetDirectories + ensuredTargetDirectories, + auditPass ) } } summary.stopped ||= options.shouldStop?.() === true - await appendAuditRecord({ action: 'run-summary', ...summary }) + await auditPass.finish(summary) // Why: opt-out can land while the async summary append is pending; carry it // back to the marker gate so a managed launch cannot be hidden by stale completion. summary.stopped ||= options.shouldStop?.() === true @@ -184,7 +187,7 @@ export async function backfillManagedCodexSessionsIntoSystemHome( async function checkManagedSessionsRoot( paths: CodexSessionBackfillPaths, summary: CodexSessionBackfillSummary, - appendAuditRecord: CodexSessionBackfillAuditWriter + auditPass: CodexSessionBackfillAuditPass ): Promise { try { await lstat(paths.managedSessionsRoot) @@ -196,7 +199,7 @@ async function checkManagedSessionsRoot( // Why: existsSync collapses access failures into "missing," which could // permanently hide sessions behind an incorrect completion marker. summary.failedDirectories += 1 - await appendAuditRecord({ + await auditPass.appendRecord({ action: 'scan-failed', source: paths.managedSessionsRoot, error: describeError(error) @@ -205,26 +208,12 @@ async function checkManagedSessionsRoot( } } -function isCodexRolloutPath(sessionsRoot: string, filePath: string): boolean { - const pathParts = relative(sessionsRoot, filePath).split(sep) - if (pathParts.length !== 4) { - return false - } - const [year, month, day, fileName] = pathParts - return ( - /^\d{4}$/.test(year) && - /^\d{2}$/.test(month) && - /^\d{2}$/.test(day) && - /^rollout-.+\.jsonl$/.test(fileName) - ) -} - async function backfillOneManagedSessionFile( paths: CodexSessionBackfillPaths, managedSessionFilePath: string, summary: CodexSessionBackfillSummary, - appendAuditRecord: CodexSessionBackfillAuditWriter, - ensuredTargetDirectories: Set + ensuredTargetDirectories: Set, + auditPass: CodexSessionBackfillAuditPass ): Promise { if (await isSymbolicLink(managedSessionFilePath)) { // Why: bridge-created symlinks already point at a file in the user's own @@ -234,16 +223,18 @@ async function backfillOneManagedSessionFile( } const relativePath = relative(paths.managedSessionsRoot, managedSessionFilePath) const systemSessionFilePath = join(paths.systemSessionsRoot, relativePath) - if (await pathEntryExists(systemSessionFilePath)) { - await recordExistingCodexSessionForHeal( - appendAuditRecord, + const existingTargetStat = await readCodexSessionTargetStat(systemSessionFilePath) + if (existingTargetStat) { + await auditPass.recordExisting( summary, managedSessionFilePath, - systemSessionFilePath + systemSessionFilePath, + existingTargetStat ) return } + let linkAttempted = false try { const targetDirectory = dirname(systemSessionFilePath) if (!ensuredTargetDirectories.has(targetDirectory)) { @@ -252,66 +243,56 @@ async function backfillOneManagedSessionFile( await mkdir(targetDirectory, { recursive: true }) ensuredTargetDirectories.add(targetDirectory) } + linkAttempted = true await link(managedSessionFilePath, systemSessionFilePath) summary.linkedFiles += 1 - await appendCodexSessionHealAuditRecord(appendAuditRecord, summary, { - action: 'hardlink', - source: managedSessionFilePath, - target: systemSessionFilePath - }) + await auditPass.recordPublished( + summary, + 'hardlink', + managedSessionFilePath, + systemSessionFilePath + ) } catch (linkError) { - if (isExistsError(linkError)) { + if (linkAttempted && isExistsError(linkError)) { // Why: another window can publish the target after our existence probe; // enqueue it here too in case that writer died before its audit append. - await recordExistingCodexSessionForHeal( - appendAuditRecord, + await auditPass.recordExisting( summary, managedSessionFilePath, - systemSessionFilePath + systemSessionFilePath, + await readCodexSessionTargetStat(systemSessionFilePath) ) return } if (isNotFoundError(linkError)) { ensuredTargetDirectories.delete(dirname(systemSessionFilePath)) } - try { - // Why: cross-volume copies are staged so failures cannot strand a - // truncated rollout, then installed without overwriting collisions. - await copySessionFileWithoutOverwrite(managedSessionFilePath, systemSessionFilePath) - summary.copiedFiles += 1 - await appendCodexSessionHealAuditRecord(appendAuditRecord, summary, { - action: 'copy', - source: managedSessionFilePath, - target: systemSessionFilePath - }) - } catch (copyError) { - if (isExistsError(copyError)) { - await recordExistingCodexSessionForHeal( - appendAuditRecord, - summary, - managedSessionFilePath, - systemSessionFilePath - ) - return - } - if (isAtomicNoReplaceUnsupportedError(copyError)) { - summary.skippedUnsupportedFilesystemFiles += 1 - await appendAuditRecord({ + const sourceStat = await readCodexSessionTargetStat(managedSessionFilePath) + if (linkAttempted && isUnsupportedHardlinkError(linkError)) { + // Why: a mutable rollout cannot be kept coherent by a cross-volume snapshot. + summary.skippedUnsupportedFilesystemFiles += 1 + await auditPass.recordDiagnostic( + { action: 'copy-unsupported', source: managedSessionFilePath, - target: systemSessionFilePath - }) - return - } - summary.failedFiles += 1 - await appendAuditRecord({ + target: systemSessionFilePath, + linkErrorCode: describeCodexSessionBackfillErrorCode(linkError) + }, + sourceStat + ) + return + } + summary.failedFiles += 1 + await auditPass.recordDiagnostic( + { action: 'failed', source: managedSessionFilePath, target: systemSessionFilePath, - error: describeError(copyError), - linkError: describeError(linkError) - }) - } + linkError: describeError(linkError), + linkErrorCode: describeCodexSessionBackfillErrorCode(linkError) + }, + sourceStat + ) } } @@ -323,16 +304,6 @@ async function isSymbolicLink(filePath: string): Promise { } } -/** Existence via lstat so a broken symlink at the target still counts as taken. */ -async function pathEntryExists(entryPath: string): Promise { - try { - await lstat(entryPath) - return true - } catch { - return false - } -} - function isExistsError(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST' } @@ -341,6 +312,11 @@ function isNotFoundError(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' } +function isUnsupportedHardlinkError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | null)?.code + return code === 'EXDEV' || code === 'ENOTSUP' || code === 'EOPNOTSUPP' || code === 'ENOSYS' +} + function describeError(error: unknown): string { return error instanceof Error ? error.message : String(error) } diff --git a/src/main/codex/codex-session-index-heal-state.ts b/src/main/codex/codex-session-index-heal-state.ts index 4efc124316e..ec7b0caf9e0 100644 --- a/src/main/codex/codex-session-index-heal-state.ts +++ b/src/main/codex/codex-session-index-heal-state.ts @@ -72,10 +72,11 @@ export function collectPendingHealThreads(paths: CodexSessionIndexHealPaths): Pe const threadId = match[2].toLowerCase() const auditRecordId = typeof line.recordId === 'string' ? line.recordId : null if ( - processed.healedThreadIds.has(threadId) || - (auditRecordId - ? processed.missingAuditRecords.has(`${threadId}\0${auditRecordId}`) - : processed.legacyMissingThreadIds.has(threadId)) + auditRecordId + ? processed.healedAuditRecords.has(`${threadId}\0${auditRecordId}`) || + processed.missingAuditRecords.has(`${threadId}\0${auditRecordId}`) + : processed.legacyHealedThreadIds.has(threadId) || + processed.legacyMissingThreadIds.has(threadId) ) { // Why: only the newest publication event for a thread matters. A later // processed event must displace an older pending event from this scan. @@ -94,11 +95,13 @@ function lastPathSegment(filePath: string): string { } function readProcessedHealThreads(paths: CodexSessionIndexHealPaths): { - healedThreadIds: Set + healedAuditRecords: Set + legacyHealedThreadIds: Set missingAuditRecords: Set legacyMissingThreadIds: Set } { - const healedThreadIds = new Set() + const healedAuditRecords = new Set() + const legacyHealedThreadIds = new Set() const missingAuditRecords = new Set() const legacyMissingThreadIds = new Set() const expectedRoot = normalizeRuntimePathForComparison(paths.systemSessionsRoot) @@ -112,7 +115,11 @@ function readProcessedHealThreads(paths: CodexSessionIndexHealPaths): { ) { const threadId = line.threadId.toLowerCase() if (line.outcome === 'healed') { - healedThreadIds.add(threadId) + if (typeof line.auditRecordId === 'string') { + healedAuditRecords.add(`${threadId}\0${line.auditRecordId}`) + } else { + legacyHealedThreadIds.add(threadId) + } } else if (typeof line.auditRecordId === 'string') { missingAuditRecords.add(`${threadId}\0${line.auditRecordId}`) } else { @@ -120,7 +127,12 @@ function readProcessedHealThreads(paths: CodexSessionIndexHealPaths): { } } } - return { healedThreadIds, missingAuditRecords, legacyMissingThreadIds } + return { + healedAuditRecords, + legacyHealedThreadIds, + missingAuditRecords, + legacyMissingThreadIds + } } export function appendHealLedgerRecord( diff --git a/src/main/codex/codex-session-index-heal.test.ts b/src/main/codex/codex-session-index-heal.test.ts index 861c7f72b4e..a12dd7dc5bd 100644 --- a/src/main/codex/codex-session-index-heal.test.ts +++ b/src/main/codex/codex-session-index-heal.test.ts @@ -232,7 +232,7 @@ describe('runCodexSessionIndexHeal', () => { expect(marker.healedThreads).toBe(3) }) - it('is a no-op when the marker matches the audit ledger size', async () => { + it('keeps second and later passes cheap when the audit ledger is unchanged', async () => { const rig = createHealRig({ auditedThreads: [{ stamp: '2026-07-01T10-00-00', id: threadId('1') }] }) @@ -245,11 +245,38 @@ describe('runCodexSessionIndexHeal', () => { buildInvocation: rig.buildInvocation, interBatchDelayMs: 0 }) + const third = await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: rig.buildInvocation, + interBatchDelayMs: 0 + }) expect(second.outcome).toBe('up-to-date') - // One spawn from the first run only — the no-op run must not hit the CLI. + expect(third.outcome).toBe('up-to-date') + // One spawn from the first run only — no-op runs must not hit the CLI. expect(rig.readLog().serverStarts).toBe(1) }) + it('re-reads a healed thread after a later publication event', async () => { + const id = threadId('1') + const stamp = '2026-07-01T10-00-00' + const rig = createHealRig({ auditedThreads: [{ stamp, id }] }) + await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: rig.buildInvocation, + interBatchDelayMs: 0 + }) + + await createCodexSessionBackfillAuditWriter(rig.paths.auditLogPath)({ + action: 'existing', + target: rolloutTarget(rig.paths.systemSessionsRoot, stamp, id) + }) + const repeated = await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: rig.buildInvocation, + interBatchDelayMs: 0 + }) + + expect(repeated).toMatchObject({ pendingThreads: 1, healedThreads: 1 }) + expect(rig.readLog().threadIds).toEqual([id, id]) + }) + it('resumes only unprocessed sessions when the audit ledger grows', async () => { const rig = createHealRig({ auditedThreads: [{ stamp: '2026-07-01T10-00-00', id: threadId('1') }] diff --git a/src/main/codex/codex-session-migration-scheduler.test.ts b/src/main/codex/codex-session-migration-scheduler.test.ts index 54094e022f1..9f9aed3173c 100644 --- a/src/main/codex/codex-session-migration-scheduler.test.ts +++ b/src/main/codex/codex-session-migration-scheduler.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { CodexSessionBackfillOptions } from './codex-session-backfill-types' import { createCodexSessionMigrationScheduler } from './codex-session-migration-scheduler' describe('createCodexSessionMigrationScheduler', () => { @@ -8,12 +9,14 @@ describe('createCodexSessionMigrationScheduler', () => { it('runs after a managed-account startup switches to host system default', async () => { let eligible = false - const startBackfill = vi.fn().mockResolvedValue(null) + const prepareScheduledRun = vi.fn() + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) const startIndexHeal = vi.fn().mockResolvedValue(null) const scheduler = createCodexSessionMigrationScheduler({ isEligible: () => eligible, isQuitting: () => false, resolveSystemCodexHomePathOverride: () => undefined, + prepareScheduledRun, startBackfill, startIndexHeal }) @@ -26,6 +29,222 @@ describe('createCodexSessionMigrationScheduler', () => { scheduler.requestRun() await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledOnce()) expect(startBackfill).toHaveBeenCalledOnce() + expect(prepareScheduledRun).not.toHaveBeenCalled() + }) + + it('schedules a delayed rerun after a shared-home launch', async () => { + vi.setSystemTime(new Date(2026, 7, 5, 10, 0, 0)) + const prepareScheduledRun = vi.fn() + const finishScheduledRun = vi.fn() + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const startIndexHeal = vi.fn().mockResolvedValue(null) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + prepareScheduledRun, + finishScheduledRun, + startBackfill, + startIndexHeal, + initialDelayMs: 1_000 + }) + + scheduler.scheduleRun() + scheduler.scheduleRun() + await vi.advanceTimersByTimeAsync(999) + expect(startBackfill).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1) + await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledOnce()) + expect(startBackfill).toHaveBeenCalledOnce() + expect(startBackfill).toHaveBeenCalledWith( + expect.objectContaining({ + scanDates: [['2026', '08', '05']], + ignoreCompletionMarker: true + }), + undefined + ) + expect(prepareScheduledRun).toHaveBeenCalledOnce() + await vi.waitFor(() => expect(finishScheduledRun).toHaveBeenCalledOnce()) + }) + + it('covers both launch and run dates when a delayed pass crosses midnight', async () => { + vi.setSystemTime(new Date('2026-08-05T23:59:59.500Z')) + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.scheduleRun() + await vi.advanceTimersByTimeAsync(1_000) + + expect(startBackfill).toHaveBeenCalledWith( + expect.objectContaining({ + scanDates: [ + ['2026', '08', '05'], + ['2026', '08', '06'] + ] + }), + undefined + ) + }) + + it('keeps launch passes full when no completed baseline can cover older failures', async () => { + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.scheduleRun(true) + await vi.advanceTimersByTimeAsync(1_000) + + expect(startBackfill).toHaveBeenCalledWith( + expect.objectContaining({ scanDates: undefined }), + undefined + ) + }) + + it('upgrades a bounded pass when its target changed before the timer fired', async () => { + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => '/moved-history', + prepareScheduledRun: () => true, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.scheduleRun() + await vi.advanceTimersByTimeAsync(1_000) + + expect(startBackfill).toHaveBeenCalledWith( + expect.objectContaining({ scanDates: undefined }), + '/moved-history' + ) + }) + + it('delays the startup run from the latest shared-home launch', async () => { + const prepareScheduledRun = vi.fn() + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const startIndexHeal = vi.fn().mockResolvedValue(null) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + prepareScheduledRun, + startBackfill, + startIndexHeal, + initialDelayMs: 1_000 + }) + + scheduler.scheduleInitialRun() + await vi.advanceTimersByTimeAsync(999) + scheduler.scheduleRun() + + await vi.advanceTimersByTimeAsync(1) + expect(startBackfill).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(999) + await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledOnce()) + expect(startBackfill).toHaveBeenCalledOnce() + expect(prepareScheduledRun).toHaveBeenCalledOnce() + }) + + it('preserves a delayed launch rerun while an earlier migration is active', async () => { + let releaseFirstIndexHeal: (() => void) | undefined + const prepareScheduledRun = vi.fn() + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const startIndexHeal = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + releaseFirstIndexHeal = resolve + }) + ) + .mockResolvedValueOnce(null) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + prepareScheduledRun, + startBackfill, + startIndexHeal, + initialDelayMs: 1_000 + }) + + scheduler.requestRun() + await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledOnce()) + + scheduler.scheduleRun() + await vi.advanceTimersByTimeAsync(1_000) + expect(startBackfill).toHaveBeenCalledOnce() + expect(prepareScheduledRun).not.toHaveBeenCalled() + + releaseFirstIndexHeal?.() + await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledTimes(2)) + expect(prepareScheduledRun).toHaveBeenCalledOnce() + expect(prepareScheduledRun.mock.invocationCallOrder[0]).toBeLessThan( + startBackfill.mock.invocationCallOrder[1]! + ) + }) + + it('prepares a delayed launch pass after an earlier migration settles before the timer', async () => { + let releaseFirstBackfill: (() => void) | undefined + let markerPresent = false + const prepareScheduledRun = vi.fn(() => { + markerPresent = false + }) + const startBackfill = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + releaseFirstBackfill = () => { + markerPresent = true + resolve() + } + }) + ) + .mockImplementationOnce(async () => { + expect(markerPresent).toBe(false) + }) + const startIndexHeal = vi.fn().mockResolvedValue(null) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + prepareScheduledRun, + startBackfill, + startIndexHeal, + initialDelayMs: 1_000 + }) + + scheduler.requestRun() + scheduler.scheduleRun() + releaseFirstBackfill?.() + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + expect(markerPresent).toBe(true) + expect(startIndexHeal).toHaveBeenCalledOnce() + + await vi.advanceTimersByTimeAsync(1_000) + await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledTimes(2)) + expect(prepareScheduledRun).toHaveBeenCalledOnce() }) it('coalesces concurrent run requests and stops before index heal after opt-out', async () => { @@ -90,4 +309,233 @@ describe('createCodexSessionMigrationScheduler', () => { await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledTimes(2)) await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledOnce()) }) + + it('preserves scheduled identity and target recovery after a stopped pass', async () => { + let eligible = true + let target = '/old-history' + let releaseFirstBackfill: ((result: { stopped: boolean }) => void) | undefined + const prepareScheduledRun = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true) + const finishScheduledRun = vi.fn() + const startBackfill = vi + .fn() + .mockImplementationOnce( + () => + new Promise<{ stopped: boolean }>((resolve) => { + releaseFirstBackfill = resolve + }) + ) + .mockResolvedValueOnce({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => eligible, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => target, + prepareScheduledRun, + finishScheduledRun, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.scheduleRun() + await vi.advanceTimersByTimeAsync(1_000) + const firstOptions = startBackfill.mock.calls[0]?.[0] + eligible = false + expect(firstOptions?.shouldStop()).toBe(true) + target = '/new-history' + releaseFirstBackfill?.({ stopped: true }) + await vi.waitFor(() => expect(prepareScheduledRun).toHaveBeenCalledOnce()) + expect(finishScheduledRun).not.toHaveBeenCalled() + + eligible = true + scheduler.requestRun() + await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledTimes(2)) + expect(startBackfill).toHaveBeenLastCalledWith( + expect.objectContaining({ ignoreCompletionMarker: true, scanDates: undefined }), + '/new-history' + ) + await vi.waitFor(() => expect(finishScheduledRun).toHaveBeenCalledOnce()) + }) + + it('finishes a launch generation only after its PTY exits and every date is rescanned', async () => { + vi.setSystemTime(new Date('2026-08-05T23:59:59Z')) + const finishScheduledRun = vi.fn() + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + prepareScheduledRun: vi.fn(), + finishScheduledRun, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.beginLaunch('pty-1') + await vi.advanceTimersByTimeAsync(1_000) + await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledOnce()) + expect(startBackfill).toHaveBeenLastCalledWith( + expect.objectContaining({ writeCompletionMarker: false }), + undefined + ) + expect(finishScheduledRun).not.toHaveBeenCalled() + + vi.setSystemTime(new Date('2026-08-07T01:00:00Z')) + scheduler.finishLaunch('pty-1') + await vi.advanceTimersByTimeAsync(1_000) + await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledTimes(2)) + expect(startBackfill).toHaveBeenLastCalledWith( + expect.objectContaining({ + scanDates: [ + ['2026', '08', '05'], + ['2026', '08', '06'], + ['2026', '08', '07'] + ], + ignoreCompletionMarker: true, + writeCompletionMarker: true, + writeBoundedCompletionMarker: true + }), + undefined + ) + await vi.waitFor(() => expect(finishScheduledRun).toHaveBeenCalledOnce()) + }) + + it('keeps a failed full scan required for the final launch pass', async () => { + const finishScheduledRun = vi.fn() + const startBackfill = vi + .fn() + .mockResolvedValueOnce({ stopped: false, failedFiles: 1 }) + .mockResolvedValueOnce({ stopped: false, failedFiles: 0 }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + prepareScheduledRun: () => false, + finishScheduledRun, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.beginLaunch('pty-1', true) + await vi.advanceTimersByTimeAsync(1_000) + await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledOnce()) + expect(startBackfill).toHaveBeenLastCalledWith( + expect.objectContaining({ scanDates: undefined, writeBoundedCompletionMarker: false }), + undefined + ) + + scheduler.finishLaunch('pty-1') + await vi.advanceTimersByTimeAsync(1_000) + await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledTimes(2)) + expect(startBackfill).toHaveBeenLastCalledWith( + expect.objectContaining({ scanDates: undefined, writeBoundedCompletionMarker: false }), + undefined + ) + await vi.waitFor(() => expect(finishScheduledRun).toHaveBeenCalledOnce()) + }) + + it('blocks marker publication when a newer launch pass is pending', async () => { + let releaseBackfill: ((result: { stopped: boolean }) => void) | undefined + const startBackfill = vi.fn( + (_options: CodexSessionBackfillOptions) => + new Promise<{ stopped: boolean }>((resolve) => { + releaseBackfill = resolve + }) + ) + const startIndexHeal = vi.fn().mockResolvedValue(null) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + startBackfill, + startIndexHeal, + initialDelayMs: 1_000 + }) + + scheduler.scheduleRun() + await vi.advanceTimersByTimeAsync(1_000) + const firstOptions = startBackfill.mock.calls[0]?.[0] + expect(firstOptions?.canWriteCompletionMarker?.()).toBe(true) + + scheduler.beginLaunch('pty-2') + expect(firstOptions?.canWriteCompletionMarker?.()).toBe(false) + scheduler.finishLaunch('pty-2') + expect(firstOptions?.canWriteCompletionMarker?.()).toBe(false) + + releaseBackfill?.({ stopped: false }) + await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledOnce()) + }) + + it('turns an exit-before-begin race into a full recovery pass', async () => { + const finishScheduledRun = vi.fn() + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + finishScheduledRun, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.finishLaunch('pty-fast-exit', 2) + scheduler.beginLaunch('pty-fast-exit', false, new Date(), 1) + await vi.advanceTimersByTimeAsync(1_000) + + expect(startBackfill).toHaveBeenCalledWith( + expect.objectContaining({ scanDates: undefined, writeBoundedCompletionMarker: false }), + undefined + ) + await vi.waitFor(() => expect(finishScheduledRun).toHaveBeenCalledOnce()) + }) + + it('does not consume an exit from an earlier stable-id incarnation', async () => { + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.finishLaunch('stable-pty', 1) + scheduler.beginLaunch('stable-pty', false, new Date(), 2) + await vi.advanceTimersByTimeAsync(1_000) + + expect(startBackfill).toHaveBeenCalledWith( + expect.objectContaining({ writeCompletionMarker: false }), + undefined + ) + }) + + it('preserves the pre-spawn UTC date when launch setup crosses midnight', async () => { + vi.setSystemTime(new Date('2026-08-06T00:00:01Z')) + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.beginLaunch('pty-midnight', false, new Date('2026-08-05T23:59:59Z')) + scheduler.finishLaunch('pty-midnight') + await vi.advanceTimersByTimeAsync(1_000) + + expect(startBackfill).toHaveBeenCalledWith( + expect.objectContaining({ + scanDates: [ + ['2026', '08', '05'], + ['2026', '08', '06'] + ] + }), + undefined + ) + }) }) diff --git a/src/main/codex/codex-session-migration-scheduler.ts b/src/main/codex/codex-session-migration-scheduler.ts index 908f77a6a83..28ce4b991da 100644 --- a/src/main/codex/codex-session-migration-scheduler.ts +++ b/src/main/codex/codex-session-migration-scheduler.ts @@ -1,12 +1,29 @@ -import type { CodexSessionBackfillOptions } from './codex-session-backfill-types' +import { getCodexSessionBackfillDate } from './codex-session-backfill-date' +import type { + CodexSessionBackfillDate, + CodexSessionBackfillOptions +} from './codex-session-backfill-types' type MigrationRun = ( options: CodexSessionBackfillOptions, systemCodexHomePathOverride?: string ) => Promise +const EARLY_PTY_EXIT_RETENTION_MS = 60_000 +const MAX_EARLY_PTY_EXITS = 256 + +type EarlyPtyExit = { sequence: number; recordedAt: number } + export type CodexSessionMigrationScheduler = { + beginLaunch( + leaseId: string, + fullScanRequired?: boolean, + startedAt?: Date, + startedSequence?: number + ): void + finishLaunch(leaseId: string, exitSequence?: number): void scheduleInitialRun(): void + scheduleRun(fullScanRequired?: boolean): void requestRun(): void } @@ -14,24 +31,64 @@ export function createCodexSessionMigrationScheduler(args: { isEligible: () => boolean isQuitting: () => boolean resolveSystemCodexHomePathOverride: () => string | undefined + prepareScheduledRun?: () => boolean | void + finishScheduledRun?: () => void startBackfill: MigrationRun startIndexHeal: MigrationRun initialDelayMs?: number }): CodexSessionMigrationScheduler { - let initialTimer: ReturnType | null = null + let scheduledTimer: ReturnType | null = null + let scheduledRunGeneration = 0 + let pendingScheduledRunGeneration: number | null = null + const scheduledScanDates = new Map() + const pendingScanDates = new Map() + let scheduledFullScan = false + let pendingFullScan = false let migrationTask: Promise | null = null + const activeLaunches = new Map() + const earlyPtyExits = new Map() let activeRunStopObserved = false let rerunRequested = false - const requestRun = (): void => { + const requestRun = ( + rerunIfActive = false, + requestedGeneration?: number, + requestedScanDates: readonly CodexSessionBackfillDate[] = [], + requestedFullScan = false + ): void => { + if (requestedGeneration !== undefined) { + pendingScheduledRunGeneration = Math.max( + requestedGeneration, + pendingScheduledRunGeneration ?? requestedGeneration + ) + for (const scanDate of requestedScanDates) { + pendingScanDates.set(scanDate.join('-'), scanDate) + } + pendingFullScan ||= requestedFullScan + } if (args.isQuitting() || !args.isEligible()) { return } if (migrationTask) { - // Why: an account transition can re-enable migration while the prior run is still stopping. - rerunRequested ||= activeRunStopObserved + // Why: delayed launches and resumed account transitions must survive an older active pass. + rerunRequested ||= rerunIfActive || activeRunStopObserved return } + const isScheduledRun = pendingScheduledRunGeneration !== null + const activeScheduledRunGeneration = pendingScheduledRunGeneration + let preparationNeedsFullScan = false + if (isScheduledRun) { + pendingScheduledRunGeneration = null + // Why: an older active pass can rewrite the marker after launch invalidates it. + preparationNeedsFullScan = args.prepareScheduledRun?.() === true + } + const fullScanRequired = pendingFullScan || preparationNeedsFullScan + const scanDates = + !fullScanRequired && pendingScanDates.size > 0 + ? [...pendingScanDates.values()].sort(compareBackfillDates) + : undefined + pendingScanDates.clear() + pendingFullScan = false activeRunStopObserved = false rerunRequested = false const shouldStop = (): boolean => { @@ -41,10 +98,27 @@ export function createCodexSessionMigrationScheduler(args: { } const systemCodexHomePathOverride = args.resolveSystemCodexHomePathOverride() let stoppedBackfill = false + let incompleteBackfill = true const task = args - .startBackfill({ shouldStop }, systemCodexHomePathOverride) + .startBackfill( + { + shouldStop, + scanDates, + ignoreCompletionMarker: isScheduledRun, + writeCompletionMarker: activeLaunches.size === 0, + writeBoundedCompletionMarker: + isScheduledRun && activeLaunches.size === 0 && !fullScanRequired, + canWriteCompletionMarker: () => + activeLaunches.size === 0 && + scheduledTimer === null && + pendingScheduledRunGeneration === null && + (!isScheduledRun || activeScheduledRunGeneration === scheduledRunGeneration) + }, + systemCodexHomePathOverride + ) .then((result) => { stoppedBackfill = isStoppedMigrationResult(result) + incompleteBackfill = isIncompleteBackfillResult(result) if (stoppedBackfill || shouldStop()) { return } @@ -58,9 +132,29 @@ export function createCodexSessionMigrationScheduler(args: { void task.finally(() => { if (migrationTask === task) { migrationTask = null - const shouldRerun = rerunRequested || stoppedBackfill + const scheduledRunIncomplete = stoppedBackfill || activeRunStopObserved + const shouldRerun = rerunRequested || scheduledRunIncomplete rerunRequested = false activeRunStopObserved = false + if ((shouldRerun || incompleteBackfill) && isScheduledRun) { + pendingScheduledRunGeneration = Math.max( + activeScheduledRunGeneration!, + pendingScheduledRunGeneration ?? activeScheduledRunGeneration! + ) + pendingFullScan ||= fullScanRequired + for (const scanDate of scanDates ?? []) { + pendingScanDates.set(scanDate.join('-'), scanDate) + } + } + if ( + isScheduledRun && + !incompleteBackfill && + activeLaunches.size === 0 && + scheduledTimer === null && + pendingScheduledRunGeneration === null + ) { + args.finishScheduledRun?.() + } if (shouldRerun) { requestRun() } @@ -68,20 +162,150 @@ export function createCodexSessionMigrationScheduler(args: { }) } + const armScheduledRun = (generation?: number): void => { + scheduledTimer = setTimeout(() => { + scheduledTimer = null + if (generation !== undefined) { + const currentDate = getCodexSessionBackfillDate() + scheduledScanDates.set(currentDate.join('-'), currentDate) + } + const scanDates = [...scheduledScanDates.values()].sort(compareBackfillDates) + scheduledScanDates.clear() + const fullScanRequired = scheduledFullScan + scheduledFullScan = false + // Why: a launch can invalidate the marker while a long index-heal pass is active. + requestRun(true, generation, scanDates, fullScanRequired) + }, args.initialDelayMs ?? 15_000) + } + + const scheduleRun = (fullScanRequired = false): void => { + if (scheduledTimer) { + clearTimeout(scheduledTimer) + } + scheduledRunGeneration += 1 + scheduledFullScan ||= fullScanRequired + const launchDate = getCodexSessionBackfillDate() + scheduledScanDates.set(launchDate.join('-'), launchDate) + armScheduledRun(scheduledRunGeneration) + } + return { - scheduleInitialRun(): void { - if (initialTimer) { + beginLaunch(leaseId, fullScanRequired = false, startedAt = new Date(), startedSequence): void { + if (args.isQuitting() || activeLaunches.has(leaseId)) { return } - initialTimer = setTimeout(() => { - initialTimer = null - requestRun() - }, args.initialDelayMs ?? 15_000) + if (consumeEarlyPtyExit(earlyPtyExits, leaseId, startedSequence)) { + scheduleRun(true) + return + } + activeLaunches.set(leaseId, startedAt) + scheduleRun(fullScanRequired) }, - requestRun + finishLaunch(leaseId, exitSequence): void { + const startedAt = activeLaunches.get(leaseId) + if (!startedAt) { + if (exitSequence !== undefined) { + recordEarlyPtyExit(earlyPtyExits, leaseId, exitSequence) + } + return + } + activeLaunches.delete(leaseId) + if (args.isQuitting()) { + return + } + for (const scanDate of getCodexSessionBackfillDatesBetween(startedAt, new Date())) { + scheduledScanDates.set(scanDate.join('-'), scanDate) + } + scheduleRun() + }, + scheduleInitialRun(): void { + if (!scheduledTimer) { + armScheduledRun() + } + }, + scheduleRun, + requestRun: () => requestRun() } } +function getCodexSessionBackfillDatesBetween( + startedAt: Date, + finishedAt: Date +): CodexSessionBackfillDate[] { + const dates: CodexSessionBackfillDate[] = [] + const cursor = new Date( + Date.UTC(startedAt.getUTCFullYear(), startedAt.getUTCMonth(), startedAt.getUTCDate()) + ) + const last = new Date( + Date.UTC(finishedAt.getUTCFullYear(), finishedAt.getUTCMonth(), finishedAt.getUTCDate()) + ) + while (cursor <= last) { + dates.push(getCodexSessionBackfillDate(cursor)) + cursor.setUTCDate(cursor.getUTCDate() + 1) + } + return dates +} + +function compareBackfillDates( + left: CodexSessionBackfillDate, + right: CodexSessionBackfillDate +): number { + return left.join('-').localeCompare(right.join('-')) +} + +function recordEarlyPtyExit( + exits: Map, + leaseId: string, + sequence: number +): void { + const now = Date.now() + for (const [id, exit] of exits) { + if (now - exit.recordedAt > EARLY_PTY_EXIT_RETENTION_MS) { + exits.delete(id) + } + } + exits.set(leaseId, { sequence, recordedAt: now }) + while (exits.size > MAX_EARLY_PTY_EXITS) { + const oldestLeaseId = exits.keys().next().value + if (oldestLeaseId === undefined) { + break + } + exits.delete(oldestLeaseId) + } +} + +function consumeEarlyPtyExit( + exits: Map, + leaseId: string, + startedSequence: number | undefined +): boolean { + const exit = exits.get(leaseId) + exits.delete(leaseId) + return ( + exit !== undefined && + startedSequence !== undefined && + exit.sequence > startedSequence && + Date.now() - exit.recordedAt <= EARLY_PTY_EXIT_RETENTION_MS + ) +} + function isStoppedMigrationResult(result: unknown): boolean { return Boolean(result && typeof result === 'object' && 'stopped' in result && result.stopped) } + +function isIncompleteBackfillResult(result: unknown): boolean { + if (!result || typeof result !== 'object') { + return true + } + return ( + isStoppedMigrationResult(result) || + readPositiveResultCount(result, 'failedFiles') || + readPositiveResultCount(result, 'failedDirectories') || + readPositiveResultCount(result, 'failedHealAuditRecords') + ) +} + +function readPositiveResultCount(result: object, key: string): boolean { + const value = key in result ? (result as Record)[key] : undefined + return typeof value === 'number' && value > 0 +} diff --git a/src/main/index.ts b/src/main/index.ts index e5a0050a781..b85a315593b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -336,6 +336,7 @@ let codexUsage: CodexUsageStore | null = null let openCodeUsage: OpenCodeUsageStore | null = null let codexAccounts: CodexAccountService | null = null let codexRuntimeHome: CodexRuntimeHomeService | null = null +let codexSessionMigration: ReturnType | null = null let claudeAccounts: ClaudeAccountService | null = null let claudeRuntimeAuth: ClaudeRuntimeAuthService | null = null let runtime: OrcaRuntimeService | null = null @@ -389,6 +390,33 @@ let gpuFeatureStatus: Electron.GPUFeatureStatus | null = null let localPtyStartupReady: Promise = Promise.resolve() let localPtyProviderStartupReady: Promise = Promise.resolve() const AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS = 30_000 + +function handleCodexHomePtySpawned(args: { + id: string + codexHomePath: string | null + reattached?: boolean + launchEnv?: NodeJS.ProcessEnv + startedAt?: Date + startedSequence?: number +}): void { + const fullScanRequired = + codexRuntimeHome?.beginHostSystemDefaultSessionMigrationLaunch(args.codexHomePath, { + reattached: args.reattached, + launchEnv: args.launchEnv + }) ?? null + if (fullScanRequired !== null) { + codexSessionMigration?.beginLaunch( + args.id, + args.reattached === true || fullScanRequired, + args.startedAt, + args.startedSequence + ) + } +} + +function handlePtyExit(id: string, exitSequence: number): void { + codexSessionMigration?.finishLaunch(id, exitSequence) +} // Why: on Windows a CLI launch that lost ELECTRON_RUN_AS_NODE would boot the GUI and exit silently; redirect to node mode before the lock gate below. // Both redirects run before the serve-argv rewrite so they still match on the launch argv verbatim. // It is load-bearing for the AppImage one: rewriting first replaces the `serve` positional, so its @@ -1400,6 +1428,8 @@ function openMainWindow(): BrowserWindow { }, // Why: let the PTY layer skip its orphan sweep on the recovery reload that re-fires did-finish-load, so live local sessions survive (#5787). isRecoveryReloadInFlight, + onCodexHomePtySpawned: handleCodexHomePtySpawned, + onPtyExit: handlePtyExit, onBeforeUpdateQuit: () => preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }), updateInstallMode: resolveUpdateInstallMode(isServeMode), @@ -2254,21 +2284,21 @@ void app.whenReady().then(async () => { codexRuntimeHome.isHostSystemDefaultRealHome() && isAgentStatusHooksEnabled(store?.getSettings()) ) - const codexSessionMigration = createCodexSessionMigrationScheduler({ - isEligible: () => codexRuntimeHome?.isHostSystemDefaultRealHome() === true, + codexSessionMigration = createCodexSessionMigrationScheduler({ + isEligible: () => codexRuntimeHome?.isHostSystemDefaultSessionMigrationEligible() === true, isQuitting: () => isQuitting, resolveSystemCodexHomePathOverride: () => resolveHostCodexSessionSourceHome(store!.getSettings()), + prepareScheduledRun: () => codexRuntimeHome?.prepareHostSystemDefaultSessionMigrationPass(), + finishScheduledRun: () => codexRuntimeHome?.finishHostSystemDefaultSessionMigrationPass(), startBackfill: startCodexSessionBackfillInBackground, startIndexHeal: startCodexSessionIndexHealInBackground }) codexAccounts = new CodexAccountService(store, rateLimits, codexRuntimeHome, { onHostSystemDefaultSelected: codexSessionMigration.requestRun }) - // Why: one-time per-host backfill makes historical Orca-managed Codex - // sessions visible to the user's own resume picker and app history (#4444, - // #8612). Deferred so startup and first PTY spawns never compete with the - // sessions tree walk. + // Why: migrate historical shared-home sessions after startup; compatibility + // launches re-arm the non-destructive pass for new rollouts (#4444, #8612, #12480). codexSessionMigration.scheduleInitialRun() claudeRuntimeAuth = new ClaudeRuntimeAuthService(store) claudeAccounts = new ClaudeAccountService(store, rateLimits, claudeRuntimeAuth) @@ -2883,7 +2913,11 @@ void app.whenReady().then(async () => { () => store!.getSettings(), (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target), store, - prepareCodexSessionResumeForLaunch + prepareCodexSessionResumeForLaunch, + { + onCodexHomePtySpawned: handleCodexHomePtySpawned, + onPtyExit: handlePtyExit + } ) await runtime.refreshRestoredOrchestrationAuthority() await runtime.reconcileLegacyWorkerTerminals() diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index c3d3a7795dc..e3e70598541 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -3074,16 +3074,25 @@ describe('registerPtyHandlers', () => { new Promise((resolve) => (releaseRecovery = resolve)) ) readFileSyncMock.mockReturnValue(TEST_CODEX_AUTH_JSON) + const onCodexHomePtySpawned = vi.fn() handlers.clear() - registerPtyHandlers(mainWindow as never, undefined, () => TEST_CODEX_HOME, (() => ({ - codexManagedAccounts: [ - { - id: 'account-1', - managedHomePath: TEST_CODEX_HOME, - managedHomeRuntime: 'host' - } - ] - })) as never) + registerPtyHandlers( + mainWindow as never, + undefined, + () => TEST_CODEX_HOME, + (() => ({ + codexManagedAccounts: [ + { + id: 'account-1', + managedHomePath: TEST_CODEX_HOME, + managedHomeRuntime: 'host' + } + ] + })) as never, + undefined, + undefined, + { onCodexHomePtySpawned } + ) const spawnPromise = handlers.get('pty:spawn')!(null, { cols: 80, @@ -3094,10 +3103,17 @@ describe('registerPtyHandlers', () => { expect(ensureCodexBackfillRecoveryMock).toHaveBeenCalledWith(TEST_CODEX_HOME) ) expect(spawnMock).not.toHaveBeenCalled() + expect(onCodexHomePtySpawned).not.toHaveBeenCalled() releaseRecovery() - await spawnPromise + const result = (await spawnPromise) as { id: string } expect(spawnMock).toHaveBeenCalledTimes(1) + expect(onCodexHomePtySpawned).toHaveBeenCalledWith({ + id: result.id, + codexHomePath: TEST_CODEX_HOME, + startedAt: expect.any(Date), + startedSequence: expect.any(Number) + }) }) it('does not gate a bare local shell on managed Codex auth', async () => { @@ -3108,19 +3124,37 @@ describe('registerPtyHandlers', () => { return '' }) handlers.clear() - registerPtyHandlers(mainWindow as never, undefined, () => TEST_CODEX_HOME, (() => ({ - codexManagedAccounts: [ - { - id: 'account-1', - managedHomePath: TEST_CODEX_HOME, - managedHomeRuntime: 'host' - } - ] - })) as never) + const onCodexHomePtySpawned = vi.fn() + registerPtyHandlers( + mainWindow as never, + undefined, + () => TEST_CODEX_HOME, + (() => ({ + codexManagedAccounts: [ + { + id: 'account-1', + managedHomePath: TEST_CODEX_HOME, + managedHomeRuntime: 'host' + } + ] + })) as never, + undefined, + undefined, + { onCodexHomePtySpawned } + ) - await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 }) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24 + })) as { id: string } expect(spawnMock).toHaveBeenCalledOnce() + expect(onCodexHomePtySpawned).toHaveBeenCalledWith({ + id: result.id, + codexHomePath: TEST_CODEX_HOME, + startedAt: expect.any(Date), + startedSequence: expect.any(Number) + }) }) it('leaves an inherited CODEX_HOME untouched for system default when the flag is OFF', async () => { @@ -8883,6 +8917,7 @@ describe('registerPtyHandlers', () => { const prepareClaudeAuth = vi.fn(() => { throw new Error('replacement auth preflight must not run') }) + const onCodexHomePtySpawned = vi.fn() let controller: RuntimeSpawnController | null = null const runtime = { setPtyController: vi.fn((value) => { @@ -8920,7 +8955,8 @@ describe('registerPtyHandlers', () => { undefined, undefined, prepareClaudeAuth, - store as never + store as never, + { onCodexHomePtySpawned } ) const spawnController = controller as unknown as RuntimeSpawnController await spawnController.spawn({ @@ -9014,6 +9050,7 @@ describe('registerPtyHandlers', () => { rows: 40, cwd, command: 'codex resume should-not-run', + launchAgent: 'codex', worktreeId, preAllocatedHandle: 'term-live-owner', tabId, @@ -9039,6 +9076,13 @@ describe('registerPtyHandlers', () => { incarnationId: 'inc-live-owner', isReattach: true }) + expect(onCodexHomePtySpawned).toHaveBeenCalledWith({ + id: 'pty-live-owner', + codexHomePath: null, + reattached: true, + startedAt: expect.any(Date), + startedSequence: expect.any(Number) + }) expect(claimedResult).toMatchObject({ id: 'pty-live-owner', stablePaneOwner: { handle: 'term-live-owner', tabId, leafId } diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 54ce28b6825..7b56d142d3c 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -1202,6 +1202,18 @@ export type PrepareCodexSessionResume = (args: { launchEnv?: NodeJS.ProcessEnv workspacePath?: string }) => Promise + +export type CodexHomePtySpawnedLifecycleArgs = { + id: string + codexHomePath: string | null + reattached?: boolean + launchEnv?: NodeJS.ProcessEnv + startedAt?: Date + startedSequence?: number +} + +let ptyLifecycleSequence = 0 + type PrepareClaudeAuth = ( target?: ClaudeAccountSelectionTarget ) => Promise @@ -2243,6 +2255,8 @@ export function registerPtyHandlers( awaitLocalPtyProviderStartup?: () => Promise // Why: returns true once for the crash-recovery reload so its did-finish-load skips the orphan sweep and keeps live PTYs (#5787). isRecoveryReloadInFlight?: (webContentsId: number) => boolean + onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void + onPtyExit?: (id: string, exitSequence: number) => void } ): void { // Why: a re-registration means a new window owns delivery — cancel the prior closure's watchdog and neutralize its bridged reset so mark-hidden below can't arm a timer against the dead closure. @@ -3563,6 +3577,7 @@ export function registerPtyHandlers( } function sendPtyExitToRenderer(payload: { id: string; code: number }): void { + options?.onPtyExit?.(payload.id, ++ptyLifecycleSequence) const release = preparePtyExitForRenderer(payload) if (!release) { return @@ -4290,6 +4305,8 @@ export function registerPtyHandlers( }, adoptStablePane, spawn: async (args) => { + const codexHomeLaunchStartedAt = !args.connectionId ? new Date() : undefined + const codexHomeLaunchStartedSequence = !args.connectionId ? ++ptyLifecycleSequence : undefined const preAdoptedStablePane = args.adoptedStablePane ?? null const startupPromise = getLocalPtyStartupPromise(args.connectionId) if (startupPromise) { @@ -4300,7 +4317,7 @@ export function registerPtyHandlers( if (!handle) { throw new Error('terminal_pane_owner_unknown') } - return { + const result = { id: preAdoptedStablePane.result.id, ...(preAdoptedStablePane.result.incarnationId ? { incarnationId: preAdoptedStablePane.result.incarnationId } @@ -4314,6 +4331,16 @@ export function registerPtyHandlers( leafId: preAdoptedStablePane.owner.leafId } } + if (!args.connectionId) { + options?.onCodexHomePtySpawned?.({ + id: result.id, + codexHomePath: null, + reattached: true, + startedAt: codexHomeLaunchStartedAt, + startedSequence: codexHomeLaunchStartedSequence + }) + } + return result } if (!preAdoptedStablePane) { await assertFolderWorkspacePtyPathUsable(args.worktreeId) @@ -4976,6 +5003,16 @@ export function registerPtyHandlers( leafId: owner.surface.leafId, ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}) }) + if (!args.connectionId) { + options?.onCodexHomePtySpawned?.({ + id: result.id, + codexHomePath: selectedCodexHomePath, + reattached: true, + startedAt: codexHomeLaunchStartedAt, + startedSequence: codexHomeLaunchStartedSequence, + ...(env ? { launchEnv: env } : {}) + }) + } return { id: result.id, ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), @@ -5140,6 +5177,15 @@ export function registerPtyHandlers( } // Why: runtime-owned/background spawns bypass mounted-pane state, so inventory consumers need an explicit signal. sendPtySpawnedToRenderer(result.id) + if (!args.connectionId) { + options?.onCodexHomePtySpawned?.({ + id: result.id, + codexHomePath: selectedCodexHomePath, + startedAt: codexHomeLaunchStartedAt, + startedSequence: codexHomeLaunchStartedSequence, + ...(result.isReattach === true ? { reattached: true } : env ? { launchEnv: env } : {}) + }) + } const response = { id: result.id, ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), @@ -5623,6 +5669,8 @@ export function registerPtyHandlers( } } ) => { + const codexHomeLaunchStartedAt = !args.connectionId ? new Date() : undefined + const codexHomeLaunchStartedSequence = !args.connectionId ? ++ptyLifecycleSequence : undefined const spawnTiming = createPtySpawnTiming() const startupPromise = getLocalPtyStartupPromise(args.connectionId) if (startupPromise) { @@ -6601,6 +6649,19 @@ export function registerPtyHandlers( } // Why: renderer tab state cannot reliably infer background and reattached PTYs in the daemon inventory. sendPtySpawnedToRenderer(result.id) + if (!args.connectionId) { + options?.onCodexHomePtySpawned?.({ + id: result.id, + codexHomePath: selectedCodexHomePath, + startedAt: codexHomeLaunchStartedAt, + startedSequence: codexHomeLaunchStartedSequence, + ...(result.isReattach === true + ? { reattached: true } + : baseEnv + ? { launchEnv: baseEnv } + : {}) + }) + } return resolvePaneSpawnReservation(paneSpawnReservationKey, paneSpawnReservation, response) } catch (err) { if (pendingRegistrationPtyId) { @@ -7387,7 +7448,11 @@ export function registerHeadlessPtyRuntime( getSettings?: () => GlobalSettings, prepareClaudeAuth?: PrepareClaudeAuth, store?: Store, - prepareCodexSessionResume?: PrepareCodexSessionResume + prepareCodexSessionResume?: PrepareCodexSessionResume, + lifecycle?: { + onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void + onPtyExit?: (id: string, exitSequence: number) => void + } ): void { // Why: headless `orca serve` has no renderer window but still needs the same PTY handlers so remote clients can drive terminals. const headlessWindow = { @@ -7405,7 +7470,7 @@ export function registerHeadlessPtyRuntime( getSettings, prepareClaudeAuth, store, - { prepareCodexSessionResume } + { prepareCodexSessionResume, ...lifecycle } ) } diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index aeaca19da4a..ad8551acb6d 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -23,6 +23,7 @@ import { registerWorkspaceCleanupHandlers } from '../ipc/workspace-cleanup' import { getLocalPtyProvider, registerPtyHandlers, + type CodexHomePtySpawnedLifecycleArgs, type GetSelectedCodexHomePath, type PrepareCodexSessionResume } from '../ipc/pty' @@ -98,6 +99,8 @@ export function attachMainWindowServices( onBeforeRendererReload?: (args: { webContentsId: number; ignoreCache: boolean }) => void // Why: lets the PTY orphan sweep skip the one crash-recovery reload (#5787). isRecoveryReloadInFlight?: (webContentsId: number) => boolean + onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void + onPtyExit?: (id: string, exitSequence: number) => void onBeforeUpdateQuit?: () => void | Promise updateInstallMode?: UpdateInstallMode onWorktreeLifecycle?: (event: RuntimeWorktreeLifecycleEvent) => void @@ -125,7 +128,9 @@ export function attachMainWindowServices( prepareCodexSessionResume: options?.prepareCodexSessionResume, awaitLocalPtyStartup: options?.awaitLocalPtyStartup, awaitLocalPtyProviderStartup: options?.awaitLocalPtyProviderStartup, - isRecoveryReloadInFlight: options?.isRecoveryReloadInFlight + isRecoveryReloadInFlight: options?.isRecoveryReloadInFlight, + onCodexHomePtySpawned: options?.onCodexHomePtySpawned, + onPtyExit: options?.onPtyExit } ) // Why: register after registerPtyHandlers so pty:management:* IPC re-installs on macOS re-activation (docs/daemon-staleness-ux.md §Phase 1). diff --git a/src/types/psl.ts b/src/types/psl.ts new file mode 100644 index 00000000000..b34a5329583 --- /dev/null +++ b/src/types/psl.ts @@ -0,0 +1,17 @@ +declare module 'psl' { + export type ParsedDomain = { + input: string + tld: string | null + sld: string | null + domain: string | null + subdomain: string | null + listed: boolean + } + + export type ParseError = { + input: string + error: { code: string; message: string } + } + + export function parse(input: string): ParsedDomain | ParseError +} From e6e197feeddd1adda066c5cd22f7ee056a12c8c1 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:21:47 -0700 Subject: [PATCH 08/45] Add Recent Chats & Terminals to Cmd+J palette with digit shortcuts (#13076) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Recent Chats & Terminals to Cmd+J palette On empty query, the palette leads with recently accessed chats and terminals, addressable via ⌘1–⌘9 shortcuts. Digit chords are intercepted while the palette is open, preventing workspace switches behind the overlay. Status dots reflect agent blocking and activity. * Fix Cmd+J recent order capture and backfill behavior - Capture unfiltered recent tab order when palette opens, avoiding frozen filtered subsets on search reopen - Backfill recent section when rows drop out (mid-open narrowing) instead of rendering empty - Move create-workspace action to the end as a fallback, not competing with real matches - Add test coverage for order freezing, backfill, and create-action ranking * Fix Cmd+J selection backfill when recent tabs hydrate late Track auto-selected item to distinguish user-moved selections from auto-picks. When recent tabs arrive after the palette opens, reset selection to the top item unless the user explicitly moved it. Use useLayoutEffect to capture the pre-hydration worktree order before tabs render, preventing flashing. Fix worktree budget cap for sessions with no open tabs. Add test coverage for late hydration and user-moved selection preservation. * Rank Cmd+J results by match relevance, lead with stronger section Adds match-relevance scoring to rank search results by which field matched and match position (prefix > word-start > mid-word). When a typed query has results in both worktrees and open tabs, whichever section holds the stronger hit now leads the list, matching user intent — a prefix hit in a tab beats a mid-name hit in a worktree. Decouples live status dots from the palette body subscriptions to prevent the whole list from re-rendering on every agent transition or pane-title change. Dots now own their subscriptions via PaletteLiveStatusProvider, while the body reads status maps as a snapshot, refreshed only when the palette opens or tabs change. Freezes the snapshot identity during animation to keep the selection stable even while closing. * Handle decomposed accents in Cmd+J match relevance scoring Include Unicode combining marks (\p{M}) in word-boundary detection so decomposed characters like café are treated as word boundaries instead of mid-word matches. --- src/main/window/createMainWindow.test.ts | 50 ++ src/main/window/createMainWindow.ts | 18 +- .../components/WorktreeJumpPalette.test.tsx | 556 ++++++++++++++++- .../src/components/WorktreeJumpPalette.tsx | 560 ++++++++++++++---- .../cmd-j/palette-live-status.test.tsx | 189 ++++++ .../components/cmd-j/palette-live-status.tsx | 173 ++++++ .../src/components/sidebar/smart-attention.ts | 128 ++-- ...orktree-jump-palette-status-inputs.test.ts | 76 ++- .../worktree-jump-palette-status-inputs.ts | 61 +- .../src/hooks/ipc-events-test-harness.ts | 45 +- src/renderer/src/hooks/useIpcEvents.test.ts | 73 +++ src/renderer/src/hooks/useIpcEvents.ts | 11 + src/renderer/src/i18n/locales/en.json | 7 +- src/renderer/src/i18n/locales/es.json | 7 +- src/renderer/src/i18n/locales/ja.json | 7 +- src/renderer/src/i18n/locales/ko.json | 7 +- src/renderer/src/i18n/locales/zh.json | 7 +- .../src/lib/cmd-j-match-relevance.test.ts | 222 +++++++ src/renderer/src/lib/cmd-j-match-relevance.ts | 98 +++ src/renderer/src/lib/cmd-j-row-index-jump.ts | 22 + .../src/lib/recent-workspace-tab-rows.test.ts | 263 ++++++++ .../src/lib/recent-workspace-tab-rows.ts | 156 +++++ .../src/lib/worktree-palette-create-action.ts | 31 +- 23 files changed, 2519 insertions(+), 248 deletions(-) create mode 100644 src/renderer/src/components/cmd-j/palette-live-status.test.tsx create mode 100644 src/renderer/src/components/cmd-j/palette-live-status.tsx create mode 100644 src/renderer/src/lib/cmd-j-match-relevance.test.ts create mode 100644 src/renderer/src/lib/cmd-j-match-relevance.ts create mode 100644 src/renderer/src/lib/cmd-j-row-index-jump.ts create mode 100644 src/renderer/src/lib/recent-workspace-tab-rows.test.ts create mode 100644 src/renderer/src/lib/recent-workspace-tab-rows.ts diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index bbff3b994c3..b6d1ff70035 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -1012,6 +1012,56 @@ describe('createMainWindow', () => { expect(webContents.send).not.toHaveBeenCalledWith('ui:jumpToWorktreeIndex', expect.anything()) }) + // Held-key repeats are contained in main whether or not the floating panel has focus: every + // renderer index path skips e.repeat, so yielding one would leak a raw digit to xterm. + it('contains indexed-switch repeats without dispatching them', () => { + const windowHandlers: Record void> = {} + const webContents = { + on: vi.fn((event, handler) => { + windowHandlers[event] = handler + }), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + setWindowOpenHandler: vi.fn(), + send: vi.fn(), + isDevToolsOpened: vi.fn(), + openDevTools: vi.fn(), + closeDevTools: vi.fn() + } + const browserWindowInstance = { + webContents, + on: vi.fn(), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => true), + isFullScreen: vi.fn(() => false), + getSize: vi.fn(() => [1200, 800]), + setSize: vi.fn(), + maximize: vi.fn(), + show: vi.fn(), + loadFile: vi.fn(), + loadURL: vi.fn() + } + browserWindowMock.mockImplementation(function () { + return browserWindowInstance + }) + + createMainWindow(null) + + const isDarwin = process.platform === 'darwin' + const input = isDarwin + ? { type: 'keyDown', code: 'Digit3', key: '3', meta: true, control: false, alt: false } + : { type: 'keyDown', code: 'Digit3', key: '3', meta: false, control: true, alt: false } + const preventDefault = vi.fn() + windowHandlers['before-input-event']( + { preventDefault } as never, + { ...input, isAutoRepeat: true } as never + ) + + expect(preventDefault).toHaveBeenCalledTimes(1) + expect(webContents.send).not.toHaveBeenCalledWith('ui:jumpToWorktreeIndex', expect.anything()) + }) + it('lets main-window Ctrl+Tab flow to the renderer held switcher', () => { const windowHandlers: Record void> = {} const webContents = { diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index f04cfb72430..17bcd061e81 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -746,17 +746,17 @@ export function createMainWindow( return false } + const isIndexJump = action.type === 'jumpToWorktreeIndex' || action.type === 'jumpToTabIndex' + if (isIndexJump && isAutoRepeat) { + // Contain held-key repeats in main — every renderer index path skips e.repeat, so yielding a + // repeat would leak a raw key to xterm/DOM, and re-firing the jump is never what a hold means. + event.preventDefault() + return true + } + // While the floating panel owns the keyboard, yield indexed switch chords to the renderer // so L2 selects a floating tab instead of switching the main workspace behind the panel. - if ( - floatingPanelFocused && - (action.type === 'jumpToWorktreeIndex' || action.type === 'jumpToTabIndex') - ) { - if (isAutoRepeat) { - // Contain held-key repeats in main — both renderer index paths skip e.repeat, so yielding a repeat would leak a raw key to xterm/DOM. - event.preventDefault() - return true - } + if (floatingPanelFocused && isIndexJump) { return false } diff --git a/src/renderer/src/components/WorktreeJumpPalette.test.tsx b/src/renderer/src/components/WorktreeJumpPalette.test.tsx index 7d84be81be1..81b315d57c7 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.test.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.test.tsx @@ -4,16 +4,20 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as ReactI18Next from 'react-i18next' -import type { Repo, Worktree } from '../../../shared/types' +import type { Repo, Tab, TabGroup, TerminalTab, Worktree } from '../../../shared/types' +import type { AgentStatusEntry, AgentStatusState } from '../../../shared/agent-status-types' import { useAppStore } from '@/store' import type { AppState } from '@/store/types' +import { emitCmdJRowIndexJump } from '@/lib/cmd-j-row-index-jump' import WorktreeJumpPalette from './WorktreeJumpPalette' vi.mock('react-i18next', async (importOriginal) => { const actual = await importOriginal() return { ...actual, - useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key }) + useTranslation: () => ({ + t: (_key: string, fallback?: string) => fallback ?? _key + }) } }) @@ -43,11 +47,36 @@ vi.mock('@/components/cmd-j/palette-host-badge', () => ({ getPaletteHostBadge: () => null })) +// Why: activation reaches into window.api and the whole worktree-reveal path; the palette's own +// contract is which result it hands over, so stub the boundary and assert on that. +const { activateWorkspaceTabPaletteResult } = vi.hoisted(() => ({ + activateWorkspaceTabPaletteResult: vi.fn((_result: unknown) => ({ status: 'activated' }) as const) +})) +vi.mock('@/lib/workspace-tab-palette-activation', () => ({ + activateWorkspaceTabPaletteResult: (result: unknown) => activateWorkspaceTabPaletteResult(result) +})) + vi.mock('@/components/ui/command', async () => { const React = await import('react') return { - CommandDialog: ({ children, open }: { children: React.ReactNode; open?: boolean }) => - open ?
{children}
: null, + // Why the commandProps passthrough: cmdk resolves Enter against its `value`, so the controlled + // value is the only honest stand-in for "what would Enter activate" without mounting real cmdk. + CommandDialog: ({ + children, + open, + commandProps + }: { + children: React.ReactNode + open?: boolean + commandProps?: { value?: string; onValueChange?: (next: string) => void } + }) => { + setCommandSelection = commandProps?.onValueChange ?? null + return open ? ( +
+ {children} +
+ ) : null + }, CommandInput: ({ value, onValueChange, @@ -100,6 +129,7 @@ const initialAppState = useAppStore.getInitialState() let testRoot: Root let testContainer: HTMLDivElement let setCommandQuery: ((next: string) => void) | null = null +let setCommandSelection: ((next: string) => void) | null = null function makeRepo(): Repo { return { @@ -179,6 +209,7 @@ describe('WorktreeJumpPalette', () => { beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true setCommandQuery = null + setCommandSelection = null useAppStore.setState(initialAppState, true) testContainer = document.createElement('div') document.body.appendChild(testContainer) @@ -349,3 +380,520 @@ describe('WorktreeJumpPalette', () => { expect(testContainer.textContent).toContain('Feature workspace') }) }) + +function makeTerminalTab(id: string, worktreeId: string, title: string): TerminalTab { + return { + id, + ptyId: `pty-${id}`, + worktreeId, + title, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } +} + +function makeUnifiedTab(id: string, worktreeId: string, entityId: string, label: string): Tab { + return { + id, + entityId, + groupId: `group-${worktreeId}`, + worktreeId, + contentType: 'terminal', + label, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } +} + +function makeGroup(worktreeId: string, tabIds: string[]): TabGroup { + return { + id: `group-${worktreeId}`, + worktreeId, + activeTabId: tabIds[0] ?? null, + tabOrder: tabIds, + recentTabIds: tabIds + } +} + +const LEAF_ID = '11111111-2222-4333-8444-555555555555' + +function makeAgentEntry( + tabId: string, + state: AgentStatusState, + stateStartedAt: number +): AgentStatusEntry { + return { + state, + prompt: '', + updatedAt: stateStartedAt, + stateStartedAt, + paneKey: `${tabId}:${LEAF_ID}`, + stateHistory: [] + } +} + +/** Two worktrees, one terminal tab each, none of them current. */ +function makeRecentTabState(overrides: Partial = {}): Partial { + const alpha = makeWorktree('wt-alpha', 'Alpha workspace') + const beta = makeWorktree('wt-beta', 'Beta workspace') + return { + worktreesByRepo: { 'repo-1': [alpha, beta] }, + showSleepingWorkspaces: true, + ptyIdsByTabId: { + 'term-alpha': ['pty-term-alpha'], + 'term-beta': ['pty-term-beta'] + }, + tabsByWorktree: { + 'wt-alpha': [makeTerminalTab('term-alpha', 'wt-alpha', 'Alpha chat')], + 'wt-beta': [makeTerminalTab('term-beta', 'wt-beta', 'Beta chat')] + }, + unifiedTabsByWorktree: { + 'wt-alpha': [makeUnifiedTab('tab-alpha', 'wt-alpha', 'term-alpha', 'Alpha chat')], + 'wt-beta': [makeUnifiedTab('tab-beta', 'wt-beta', 'term-beta', 'Beta chat')] + }, + groupsByWorktree: { + 'wt-alpha': [makeGroup('wt-alpha', ['tab-alpha'])], + 'wt-beta': [makeGroup('wt-beta', ['tab-beta'])] + }, + activeGroupIdByWorktree: { + 'wt-alpha': 'group-wt-alpha', + 'wt-beta': 'group-wt-beta' + }, + ...overrides + } +} + +/** One tab-heavy worktree plus `count` bare ones, so both sections overflow their caps. */ +function makeManyTabState(count: number): Partial { + const ids = Array.from({ length: count }, (_, index) => `${index}`) + return { + worktreesByRepo: { + 'repo-1': [ + makeWorktree('wt-many', 'Many workspace'), + ...ids.map((id) => makeWorktree(`wt-${id}`, `Spare workspace ${id}`)) + ] + }, + showSleepingWorkspaces: true, + ptyIdsByTabId: Object.fromEntries(ids.map((id) => [`term-${id}`, [`pty-${id}`]])), + tabsByWorktree: { + 'wt-many': ids.map((id) => makeTerminalTab(`term-${id}`, 'wt-many', `Chat ${id}`)) + }, + unifiedTabsByWorktree: { + 'wt-many': ids.map((id) => makeUnifiedTab(`tab-${id}`, 'wt-many', `term-${id}`, `Chat ${id}`)) + }, + groupsByWorktree: { + 'wt-many': [ + makeGroup( + 'wt-many', + ids.map((id) => `tab-${id}`) + ) + ] + }, + activeGroupIdByWorktree: { 'wt-many': 'group-wt-many' } + } +} + +function getRenderedRowIds(): string[] { + return [...testContainer.querySelectorAll('[data-command-item]')].map( + (node) => node.dataset.commandItem ?? '' + ) +} + +/** The id cmdk would activate on Enter. */ +function getCommandValue(): string { + return ( + testContainer.querySelector('[data-command-dialog]')?.dataset.commandValue ?? '' + ) +} + +function getTabRowIds(): string[] { + return [...testContainer.querySelectorAll('[data-command-item^="workspace-tab:"]')] + .map((node) => node.dataset.commandItem ?? '') + .map((id) => id.replace('workspace-tab:', '')) +} + +describe('WorktreeJumpPalette recent chats & terminals', () => { + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + setCommandQuery = null + setCommandSelection = null + activateWorkspaceTabPaletteResult.mockClear() + useAppStore.setState(initialAppState, true) + testContainer = document.createElement('div') + document.body.appendChild(testContainer) + testRoot = createRoot(testContainer) + }) + + afterEach(async () => { + await act(async () => { + testRoot.unmount() + }) + document.body.replaceChildren() + useAppStore.setState(initialAppState, true) + }) + + it('leads the empty-query list with the recent section', async () => { + await renderPalette(makeRecentTabState()) + + const rows = getRenderedRowIds().filter((id) => id.length > 0) + expect(rows[0]).toMatch(/^workspace-tab:/) + expect(rows.some((id) => id.startsWith('worktree:'))).toBe(true) + expect(testContainer.textContent).toContain('Recent Chats & Terminals') + expect(testContainer.textContent).toContain('Recent Worktrees') + }) + + it('caps the recent section so the worktree header stays above the fold', async () => { + await renderPalette(makeManyTabState(12)) + + expect(getTabRowIds()).toHaveLength(6) + expect(testContainer.textContent).toContain('Recent Worktrees') + // Why: the worktree section shrinks against the recent rows so the list holds at 10 total — + // it must never uncap, not even for the frame before the order snapshot lands. + expect(getWorktreeRows().length).toBeLessThanOrEqual(4) + }) + + it('backfills past the cap when rows drop out of the frozen order', async () => { + await renderPalette(makeManyTabState(12)) + const before = getTabRowIds() + + // Why: closing the whole first page stands in for any mid-open narrowing (a filter chip does the + // same thing) — the section must fall through to the next ranked rows, not render empty. + await act(async () => { + useAppStore.setState({ + unifiedTabsByWorktree: { + 'wt-many': (useAppStore.getState().unifiedTabsByWorktree['wt-many'] ?? []).filter( + (tab) => !before.includes(tab.id) + ) + } + } as Partial) + }) + await flushEffects() + + const after = getTabRowIds() + expect(after).toHaveLength(6) + expect(after.some((id) => before.includes(id))).toBe(false) + }) + + /** A tab whose title starts with the query, against worktrees that only match mid-name. */ + function makeTypedRelevanceState(): Partial { + const weak = makeWorktree('wt-weak', 'improve-agent-dashboard-performance') + const host = makeWorktree('wt-host', 'docs-update') + return { + worktreesByRepo: { 'repo-1': [weak, host] }, + showSleepingWorkspaces: true, + ptyIdsByTabId: { 'term-host': ['pty-term-host'] }, + tabsByWorktree: { + 'wt-host': [makeTerminalTab('term-host', 'wt-host', 'Performance Review Main Daemon')] + }, + unifiedTabsByWorktree: { + 'wt-host': [ + makeUnifiedTab('tab-host', 'wt-host', 'term-host', 'Performance Review Main Daemon') + ] + }, + groupsByWorktree: { 'wt-host': [makeGroup('wt-host', ['tab-host'])] }, + activeGroupIdByWorktree: { 'wt-host': 'group-wt-host' } + } + } + + it('leads a typed query with the tab section when it holds the stronger match', async () => { + await renderPalette(makeTypedRelevanceState()) + + await act(async () => { + setCommandQuery?.('perf') + }) + await flushEffects() + + const rows = getRenderedRowIds().filter((id) => id.length > 0) + expect(rows[0]).toBe('workspace-tab:tab-host') + expect(rows).toContain('worktree:wt-weak') + expect(getCommandValue()).toBe('workspace-tab:tab-host') + }) + + it('keeps worktrees ahead of tabs when a worktree holds the stronger match', async () => { + await renderPalette({ + ...makeTypedRelevanceState(), + worktreesByRepo: { + 'repo-1': [ + makeWorktree('wt-strong', 'perf-diff-tighten'), + makeWorktree('wt-host', 'docs-update') + ] + } + }) + + await act(async () => { + setCommandQuery?.('perf-d') + }) + await flushEffects() + + const firstRow = getRenderedRowIds().find((id) => id.length > 0) + expect(firstRow).toBe('worktree:wt-strong') + }) + + it('ranks a typed query by match position inside the worktree section', async () => { + await renderPalette({ + worktreesByRepo: { + 'repo-1': [ + // Why this order: smart sort keeps the input order here, so a promoted prefix hit can only + // come from relevance re-ranking. + makeWorktree('wt-word-a', 'improve-agent-dashboard-performance'), + makeWorktree('wt-word-b', 'rc-perf-update-channels'), + makeWorktree('wt-prefix', 'perf-diff-tighten') + ] + }, + showSleepingWorkspaces: true + }) + + await act(async () => { + setCommandQuery?.('perf') + }) + await flushEffects() + + // Why the two word-start rows keep their input order: relevance ranks by where the match sits + // relative to a word boundary, not by raw offset — equal hits still defer to smart sort. + expect(getRenderedRowIds().filter((id) => id.startsWith('worktree:'))).toEqual([ + 'worktree:wt-prefix', + 'worktree:wt-word-a', + 'worktree:wt-word-b' + ]) + }) + + it('budget-caps the worktree section when nothing fills the recent one', async () => { + await renderPalette({ + worktreesByRepo: { + 'repo-1': Array.from({ length: 14 }, (_, index) => + makeWorktree(`wt-${index}`, `Spare workspace ${index}`) + ) + }, + showSleepingWorkspaces: true + }) + + // Why this shape: a filter chip that drops every open tab lands here too, and uncapping used to + // mount one row per workspace. + expect(getTabRowIds()).toEqual([]) + expect(getWorktreeRows()).toHaveLength(10) + expect(testContainer.textContent).toContain('Type to see all 14 worktrees') + }) + + it('captures the order when tabs hydrate after the palette is already open', async () => { + const hydrated = makeRecentTabState() + await renderPalette({ + ...hydrated, + tabsByWorktree: {}, + unifiedTabsByWorktree: {} + }) + + expect(getTabRowIds()).toEqual([]) + // Why: cmdk claims the first row it sees, which before hydration is a worktree. + const firstWorktreeId = getRenderedRowIds().find((id) => id.startsWith('worktree:')) + expect(firstWorktreeId).toBeDefined() + await act(async () => { + setCommandSelection?.(firstWorktreeId ?? '') + }) + await flushEffects() + + await act(async () => { + useAppStore.setState({ + tabsByWorktree: hydrated.tabsByWorktree, + unifiedTabsByWorktree: hydrated.unifiedTabsByWorktree + } as Partial) + }) + await flushEffects() + + const [topRowId] = getTabRowIds() + expect(getTabRowIds()).toHaveLength(2) + // Enter has to follow the rows up: ⌘1 already points at the first recent chat. + expect(getCommandValue()).toBe(`workspace-tab:${topRowId}`) + + // Why here: an empty snapshot also left the digit chords addressing nothing until reopen. + await act(async () => { + emitCmdJRowIndexJump(0) + }) + await flushEffects() + + expect(activateWorkspaceTabPaletteResult).toHaveBeenCalledWith( + expect.objectContaining({ tabId: topRowId }) + ) + }) + + it('leaves a deliberately moved selection alone when recents land late', async () => { + const hydrated = makeRecentTabState() + await renderPalette({ + ...hydrated, + tabsByWorktree: {}, + unifiedTabsByWorktree: {} + }) + + const worktreeIds = getRenderedRowIds().filter((id) => id.startsWith('worktree:')) + expect(worktreeIds.length).toBeGreaterThan(1) + // Why the second row: only a selection that differs from the auto-picked head proves the user moved it. + const movedTo = worktreeIds[1] + await act(async () => { + setCommandSelection?.(movedTo) + }) + await flushEffects() + + await act(async () => { + useAppStore.setState({ + tabsByWorktree: hydrated.tabsByWorktree, + unifiedTabsByWorktree: hydrated.unifiedTabsByWorktree + } as Partial) + }) + await flushEffects() + + expect(getTabRowIds()).toHaveLength(2) + expect(getCommandValue()).toBe(movedTo) + }) + + it('ranks a blocked agent above a more recently visited idle tab', async () => { + await renderPalette( + makeRecentTabState({ + agentStatusByPaneKey: { + [`term-alpha:${LEAF_ID}`]: makeAgentEntry('term-alpha', 'blocked', Date.now()) + }, + lastVisitedAtByWorktreeId: { 'wt-beta': Date.now() } + }) + ) + + expect(getTabRowIds()).toEqual(['tab-alpha', 'tab-beta']) + }) + + it('freezes the order captured on open while statuses keep changing', async () => { + await renderPalette( + makeRecentTabState({ + lastVisitedAtByWorktreeId: { 'wt-beta': Date.now() } + }) + ) + + expect(getTabRowIds()).toEqual(['tab-beta', 'tab-alpha']) + + await act(async () => { + useAppStore.setState({ + agentStatusByPaneKey: { + [`term-alpha:${LEAF_ID}`]: makeAgentEntry('term-alpha', 'blocked', Date.now()) + } + } as Partial) + }) + await flushEffects() + + expect(getTabRowIds()).toEqual(['tab-beta', 'tab-alpha']) + }) + + it('captures the unfiltered order when reopened after a search', async () => { + await renderPalette(makeRecentTabState()) + + await act(async () => { + setCommandQuery?.('Alpha') + }) + await flushEffects() + + // Why closed-then-reopened: the palette stays mounted, and the open effect clears the query one + // commit after the snapshot effect — so a naive capture would freeze the Alpha-only subset. + await act(async () => { + useAppStore.setState({ activeModal: undefined } as Partial) + }) + await flushEffects() + await act(async () => { + useAppStore.setState({ + activeModal: 'worktree-palette' + } as Partial) + }) + await flushEffects() + + expect(getTabRowIds()).toHaveLength(2) + }) + + it('excludes the current tab from the recent section', async () => { + await renderPalette( + makeRecentTabState({ + activeWorktreeId: 'wt-alpha', + activeTabType: 'terminal', + activeTabId: 'term-alpha', + activeTabIdByWorktree: { 'wt-alpha': 'term-alpha' }, + activeTabTypeByWorktree: { 'wt-alpha': 'terminal' } + }) + ) + + expect(getTabRowIds()).toEqual(['tab-beta']) + }) + + it('activates the row a digit chord addresses while open', async () => { + await renderPalette( + makeRecentTabState({ + lastVisitedAtByWorktreeId: { 'wt-beta': Date.now() } + }) + ) + + expect(getTabRowIds()).toEqual(['tab-beta', 'tab-alpha']) + + await act(async () => { + emitCmdJRowIndexJump(1) + }) + await flushEffects() + + expect(activateWorkspaceTabPaletteResult).toHaveBeenCalledWith( + expect.objectContaining({ tabId: 'tab-alpha' }) + ) + }) + + it('ignores a digit chord beyond the rendered recent rows', async () => { + await renderPalette(makeRecentTabState()) + + await act(async () => { + emitCmdJRowIndexJump(8) + }) + await flushEffects() + + expect(activateWorkspaceTabPaletteResult).not.toHaveBeenCalled() + }) + + it('stops routing digit chords once a query is typed', async () => { + await renderPalette(makeRecentTabState()) + + await act(async () => { + setCommandQuery?.('Alpha') + }) + await flushEffects() + + await act(async () => { + emitCmdJRowIndexJump(0) + }) + await flushEffects() + + expect(activateWorkspaceTabPaletteResult).not.toHaveBeenCalled() + }) + + it('keeps create-worktree below the matches it would otherwise outrank', async () => { + await renderPalette(makeRecentTabState()) + + await act(async () => { + setCommandQuery?.('Alpha') + }) + await flushEffects() + + const rows = getRenderedRowIds().filter((id) => id.length > 0) + expect(rows.at(-1)).toBe('__create_worktree__') + expect(rows.length).toBeGreaterThan(1) + }) + + it('labels a folder workspace row with its display name, not a branch', async () => { + await renderPalette( + makeRecentTabState({ + worktreesByRepo: { + 'repo-1': [ + makeWorktree('wt-alpha', 'Alpha workspace', { + isMainWorktree: true, + branch: '' + }), + makeWorktree('wt-beta', 'Beta workspace') + ] + } + }) + ) + + expect(testContainer.textContent).toContain('Alpha workspace') + }) +}) diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index c4df84abad3..a7abbd36071 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -1,5 +1,13 @@ /* oxlint-disable max-lines */ -import React, { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react' +import React, { + useCallback, + useDeferredValue, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState +} from 'react' import { useShallow } from 'zustand/react/shallow' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' @@ -15,7 +23,15 @@ import { } from 'lucide-react' import { useAppStore } from '@/store' import { getRepoMapFromState, useAllWorktrees } from '@/store/selectors' -import { selectPaletteStatusInputs } from './worktree-jump-palette-status-inputs' +import { + selectPaletteIndexStatusSnapshot, + selectPaletteStatusInputs +} from './worktree-jump-palette-status-inputs' +import { + PaletteLiveStatusProvider, + PaletteRecentTabStatusDot, + PaletteWorktreeStatusDot +} from './cmd-j/palette-live-status' import { CommandDialog, CommandInput, @@ -36,9 +52,12 @@ import { } from '@/components/sidebar/visible-worktrees' import { getLiveAgentStatusByWorktreeId, isInactiveWorkspace } from '@/lib/worktree-activity-state' import { orderEmptyQueryWorktrees } from '@/lib/order-empty-query-worktrees' -import StatusIndicator from '@/components/sidebar/StatusIndicator' +import { + getOpenTabMatchRelevance, + getWorktreeMatchRelevance, + NO_MATCH_RELEVANCE +} from '@/lib/cmd-j-match-relevance' import { cn } from '@/lib/utils' -import { getWorktreeStatus, getWorktreeStatusLabel } from '@/lib/worktree-status' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { queueWorkspaceActivationTerminalFocus } from '@/lib/workspace-activation-terminal-focus' import { findWorktreeById } from '@/store/slices/worktree-helpers' @@ -79,6 +98,18 @@ import { type WorkspaceTabPaletteSearchResult } from '@/lib/workspace-tab-palette-search' import { activateWorkspaceTabPaletteResult } from '@/lib/workspace-tab-palette-activation' +import { + buildExplicitEntriesByTabId, + type TabPaneInputSources +} from '@/components/sidebar/smart-attention' +import { + buildFocusedGroupTabRecency, + orderRecentWorkspaceTabs, + type RecentWorkspaceTabRow +} from '@/lib/recent-workspace-tab-rows' +import { subscribeCmdJRowIndexJump } from '@/lib/cmd-j-row-index-jump' +import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' +import { useShortcutKeyComboDetails } from '@/hooks/useShortcutLabel' import { ORCA_BROWSER_FOCUS_REQUEST_EVENT, queueBrowserFocusRequest @@ -141,7 +172,7 @@ import { getSettingsFocusedExecutionHostId, isRuntimeOwnedSshTargetId } from '../../../shared/execution-host' -import type { BrowserPage, BrowserWorkspace, Worktree } from '../../../shared/types' +import type { BrowserPage, BrowserWorkspace, TerminalTab, Worktree } from '../../../shared/types' import { isGitRepoKind } from '../../../shared/repo-kind' import { buildTaskSourceContextFromRepo } from '../../../shared/task-source-context' import { translate } from '@/i18n/i18n' @@ -223,6 +254,41 @@ const CREATE_WORKSPACE_QUICK_ACTION_ITEM_ID = `quick-action:${CREATE_WORKSPACE_Q // Why: outlast the CommandDialog close animation (~150–200ms) so gated status maps stay live until fading rows are gone. const PALETTE_STATUS_INPUTS_LINGER_MS = 300 +type OpenTabPaletteItem = BrowserPaletteItem | SimulatorPaletteItem | WorkspaceTabPaletteItem + +// Why: while the palette is open the workspace digit chord addresses recent rows, so it labels them. +const DIGIT_INDEX_ACTION_ID = 'workspace.selectByIndex' as const +// Why: this is also the ⌘N ceiling — any deeper and RECENT WORKTREES falls below the first screenful. +const EMPTY_QUERY_RECENT_TAB_CAP = 6 +// Why: hold total empty-query rows at the pre-existing 10 so the worktree header stays above the fold. +const EMPTY_QUERY_ROW_BUDGET = 10 +const EMPTY_QUERY_WORKTREE_CAP = 5 +const EMPTY_RECENT_TAB_ORDER: readonly string[] = [] + +function isCurrentOpenTabItem(item: OpenTabPaletteItem): boolean { + return item.type === 'browser-page' ? item.result.isCurrentPage : item.result.isCurrentTab +} + +function PaletteRowShortcutBadge({ + index, + modifierKeys +}: { + index: number | undefined + modifierKeys: readonly string[] +}): React.JSX.Element | null { + if (index === undefined || modifierKeys.length === 0) { + return null + } + return ( + + ) +} + function getComposerPrefetchRepoId( state: ReturnType, initialRepoId?: string @@ -379,17 +445,11 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { ) return () => window.clearTimeout(timer) }, [visible]) + const paletteStatusInputsActive = visible || statusInputsLingering // Why: these hot status maps get a new identity on every app-wide write, so gate the subscription on active-or-closing to stop the always-mounted palette re-rendering on unrelated terminals. // Why: ptyIdsByTabId must be included — slept tabs keep a wake-hint sessionId in tab.ptyId, so without it the palette dot would lie green. - const { - agentStatusByPaneKey, - runtimePaneTitlesByTabId, - ptyIdsByTabId, - terminalLayoutsByTabId, - tabsByWorktree - } = useAppStore(useShallow((s) => selectPaletteStatusInputs(s, visible || statusInputsLingering))) - const agentStatusEpoch = useAppStore((s) => - visible || statusInputsLingering ? s.agentStatusEpoch : 0 + const { ptyIdsByTabId, terminalLayoutsByTabId, tabsByWorktree } = useAppStore( + useShallow((s) => selectPaletteStatusInputs(s, paletteStatusInputsActive)) ) const { prCache, issueCache, hostedReviewCache } = useAppStore( useShallow((s) => selectWorktreePaletteCacheInputs(s, visible || statusInputsLingering)) @@ -406,6 +466,17 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const browserTabsByWorktree = useAppStore((s) => s.browserTabsByWorktree) const browserPagesByWorkspace = useAppStore((s) => s.browserPagesByWorkspace) const unifiedTabsByWorktree = useAppStore((s) => s.unifiedTabsByWorktree) + // Why non-reactive: agentStatusByPaneKey and runtimePaneTitlesByTabId are the two hottest maps in + // the app, and subscribing re-rendered the whole palette on every agent transition to change + // nothing but the status dots — which now hold their own subscription (PaletteLiveStatusProvider). + // Reading them here snapshots them for indexing, ordering and filtering instead, refreshed when + // the palette opens or the tab set changes: the same freeze-on-open the row order already gets. + const paletteIndexStatus = useMemo( + () => selectPaletteIndexStatusSnapshot(useAppStore.getState(), paletteStatusInputsActive), + // oxlint-disable-next-line react-hooks/exhaustive-deps -- these deps ARE the refresh policy, not reads: re-snapshot when the palette opens or the tab set moves under it, never on the agent churn the snapshot exists to ignore. + [paletteStatusInputsActive, tabsByWorktree, unifiedTabsByWorktree] + ) + const { agentStatusByPaneKey, runtimePaneTitlesByTabId } = paletteIndexStatus const openFiles = useAppStore((s) => s.openFiles) const activeGroupIdByWorktree = useAppStore((s) => s.activeGroupIdByWorktree) const groupsByWorktree = useAppStore((s) => s.groupsByWorktree) @@ -436,6 +507,10 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const [query, setQuery] = useState('') const deferredQuery = useDeferredValue(query) const [selectedItemId, setSelectedItemId] = useState('') + // Why: the id cmdk auto-selected for the last committed list, so a late recent-order snapshot can + // tell "nobody has moved the highlight yet" from "the user arrowed somewhere deliberately". + const autoSelectedItemIdRef = useRef(null) + const digitShortcutItemsRef = useRef([]) // Why: filters reset on close — a filter that survives reopen silently hides // results in a surface people open reflexively. const [rawFilter, setRawFilter] = useState(EMPTY_PALETTE_FILTER) @@ -540,16 +615,15 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const isLoading = repos.length > 0 && Object.keys(worktreesByRepo).length === 0 // Why: keep running-agent workspaces visible under "Hide sleeping" even when the live PTY is momentarily absent, matching sidebar. #7197 - const liveAgentActivity = useMemo(() => { - void agentStatusEpoch - const statusByWorktreeId = getLiveAgentStatusByWorktreeId( - agentStatusByPaneKey, - tabsByWorktree, - Date.now() - ) - return { statusByWorktreeId, worktreeIds: new Set(statusByWorktreeId.keys()) } - }, [agentStatusByPaneKey, agentStatusEpoch, tabsByWorktree]) - const worktreeIdsWithLiveAgent = liveAgentActivity.worktreeIds + // Why snapshot-scoped: this decides which rows exist, and rows appearing or vanishing mid-open + // would shift the list under the cursor — the same reason the recent order freezes on open. + const worktreeIdsWithLiveAgent = useMemo( + () => + new Set( + getLiveAgentStatusByWorktreeId(agentStatusByPaneKey, tabsByWorktree, Date.now()).keys() + ), + [agentStatusByPaneKey, tabsByWorktree] + ) // Why: empty-query mirrors sidebar filters so Search opens on the same quiet list; typed search widens to global non-archived scope. const emptyQueryVisibleWorktrees = useMemo( @@ -851,24 +925,53 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { [workspaceTabEntries, deferredQuery] ) - const worktreeItems = useMemo( - () => - worktreeMatches - .map((match) => { - const worktree = worktreeMap.get(match.worktreeId) - if (!worktree) { - return null - } - return { - id: `worktree:${worktree.id}`, - type: 'worktree' as const, + const worktreeRelevanceById = useMemo(() => { + const relevanceById = new Map() + if (!hasQuery) { + return relevanceById + } + for (const match of worktreeMatches) { + const worktree = worktreeMap.get(match.worktreeId) + if (worktree) { + relevanceById.set( + match.worktreeId, + getWorktreeMatchRelevance( match, - worktree - } - }) - .filter((item): item is WorktreePaletteItem => item !== null), - [worktreeMap, worktreeMatches] - ) + worktree, + repoMap.get(worktree.repoId)?.displayName ?? '' + ) + ) + } + } + return relevanceById + }, [hasQuery, repoMap, worktreeMap, worktreeMatches]) + + const worktreeItems = useMemo(() => { + const items = worktreeMatches + .map((match) => { + const worktree = worktreeMap.get(match.worktreeId) + if (!worktree) { + return null + } + return { + id: `worktree:${worktree.id}`, + type: 'worktree' as const, + match, + worktree + } + }) + .filter((item): item is WorktreePaletteItem => item !== null) + if (!hasQuery) { + return items + } + // Why: searchWorktrees preserves smart-sort order, so a mid-string hit used to outrank a prefix + // hit. Sort is stable, so equal relevance still falls back to smart order. + return items.sort( + (a, b) => + (worktreeRelevanceById.get(a.worktree.id) ?? NO_MATCH_RELEVANCE) - + (worktreeRelevanceById.get(b.worktree.id) ?? NO_MATCH_RELEVANCE) + ) + }, [hasQuery, worktreeMap, worktreeMatches, worktreeRelevanceById]) const browserItems = useMemo( () => @@ -900,20 +1003,148 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { [workspaceTabMatches] ) - const openTabItems = useMemo< - (BrowserPaletteItem | SimulatorPaletteItem | WorkspaceTabPaletteItem)[] - >( - () => + const openTabItems = useMemo(() => { + const items = [...browserItems, ...simulatorItems, ...workspaceTabItems] + // Why relevance first: each source's score folds in worktree/tab position, so a prefix hit in a + // far worktree used to sink below a mid-title hit in the current one. Empty query leaves every + // relevance unmatched, so ordering falls straight through to the existing scores. + const relevanceById = new Map( + items.map((item) => [item.id, getOpenTabMatchRelevance(item.result)]) + ) + return items.sort((a, b) => { + const relevance = + (relevanceById.get(a.id) ?? NO_MATCH_RELEVANCE) - + (relevanceById.get(b.id) ?? NO_MATCH_RELEVANCE) + if (relevance !== 0) { + return relevance + } // Why: result builders emit comparable ascending scores, so one sort keeps cross-source ranking consistent. - [...browserItems, ...simulatorItems, ...workspaceTabItems].sort((a, b) => { - if (a.result.score !== b.result.score) { - return a.result.score - b.result.score - } - return a.id.localeCompare(b.id) - }), - [browserItems, simulatorItems, workspaceTabItems] + if (a.result.score !== b.result.score) { + return a.result.score - b.result.score + } + return a.id.localeCompare(b.id) + }) + }, [browserItems, simulatorItems, workspaceTabItems]) + + const terminalTabsById = useMemo(() => { + const byId = new Map() + for (const tabs of Object.values(tabsByWorktree)) { + for (const tab of tabs ?? []) { + byId.set(tab.id, tab) + } + } + return byId + }, [tabsByWorktree]) + + const recentTabPaneSources = useMemo( + () => ({ + entriesByTabId: buildExplicitEntriesByTabId( + agentStatusByPaneKey, + migrationUnsupportedByPtyId + ), + ptyIdsByTabId, + runtimePaneTitlesByTabId, + terminalLayoutsByTabId + }), + [ + agentStatusByPaneKey, + migrationUnsupportedByPtyId, + ptyIdsByTabId, + runtimePaneTitlesByTabId, + terminalLayoutsByTabId + ] ) + // Why: the recent section excludes the current tab (the top slot is never "where you are") and + // archived worktrees, both of which the typed-query index still surfaces. + const recentTabRows = useMemo(() => { + const rows: RecentWorkspaceTabRow[] = [] + for (const item of openTabItems) { + const worktree = worktreeMap.get(item.result.worktreeId) + if (!worktree || worktree.isArchived || isCurrentOpenTabItem(item)) { + continue + } + rows.push({ + id: item.id, + worktreeId: worktree.id, + unifiedTabId: item.type === 'browser-page' ? null : item.result.tabId, + terminalTab: + item.type === 'workspace-tab' && item.result.contentType === 'terminal' + ? (terminalTabsById.get(item.result.entityId) ?? null) + : null, + worktreeLastActivityAt: worktree.lastActivityAt + }) + } + return rows + }, [openTabItems, terminalTabsById, worktreeMap]) + + const recentTabRowById = useMemo( + () => new Map(recentTabRows.map((row) => [row.id, row])), + [recentTabRows] + ) + + // Why: ordering is captured once on open. Live re-ranking would move rows under the cursor and + // send ⌘3 to the wrong row; dots keep updating, positions don't. + const [recentTabOrder, setRecentTabOrder] = useState(EMPTY_RECENT_TAB_ORDER) + const recentTabOrderCapturedRef = useRef(false) + // Why layout, not passive: a post-paint capture shows one frame of worktrees-only, which flashes + // the list, renumbers ⌘1–6 under the user, and lets cmdk latch a worktree as the Enter target. + useLayoutEffect(() => { + if (!visible) { + recentTabOrderCapturedRef.current = false + autoSelectedItemIdRef.current = null + setRecentTabOrder(EMPTY_RECENT_TAB_ORDER) + return + } + // Why: the query and filter are cleared by the open effect below, which runs after this one — + // capturing before that lands would freeze the *previous* session's filtered subset for good. + if (recentTabOrderCapturedRef.current || hasQuery || query.length > 0 || filterActive) { + return + } + const order = orderRecentWorkspaceTabs({ + rows: recentTabRows, + paneSources: recentTabPaneSources, + now: Date.now(), + lastVisitedAtByWorktreeId, + focusedGroupTabRecency: buildFocusedGroupTabRecency(activeGroupIdByWorktree, groupsByWorktree) + }) + if (order.length === 0) { + // Why: tabs can arrive after the palette opens (cold start, session restore, a late tab + // mirror). Latching an empty snapshot would leave Recent dead — and digits inert — until + // close+reopen, so stay unlatched; the stable empty ref keeps this a no-op re-render. + setRecentTabOrder(EMPTY_RECENT_TAB_ORDER) + return + } + recentTabOrderCapturedRef.current = true + setRecentTabOrder(order) + // Why: recents render above the worktrees, so a row auto-selected before they arrived is no + // longer the list head — hand Enter back to the top, matching ⌘1. Untouched selections only: + // a highlight the user moved themselves stays put. + setSelectedItemId((current) => + current === '' || current === autoSelectedItemIdRef.current ? '' : current + ) + }, [ + activeGroupIdByWorktree, + filterActive, + groupsByWorktree, + hasQuery, + lastVisitedAtByWorktreeId, + query.length, + recentTabPaneSources, + recentTabRows, + visible + ]) + + // Why: walk the frozen order rather than re-sorting the tab list — the frozen ids are the ranking, + // so agent churn never reshuffles rows under the cursor. Cap after resolving, not before: a chip + // applied mid-open narrows `openTabItems`, and capping first would leave the section empty. + const recentTabItems = useMemo(() => { + const itemById = new Map(openTabItems.map((item) => [item.id, item])) + return recentTabOrder + .flatMap((id) => itemById.get(id) ?? []) + .slice(0, EMPTY_QUERY_RECENT_TAB_CAP) + }, [openTabItems, recentTabOrder]) + const settingsResults = useMemo( () => buildCmdJSettingsResults(settingsSections), [settingsSections] @@ -1073,13 +1304,20 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { [actionResults, deferredQuery, quickActionContext, settingsResults] ) - // Why: on empty query, cap the worktree section so the OPEN TABS header + ≥1 row stays above the fold; typing lifts the cap. - const EMPTY_QUERY_WORKTREE_CAP = 5 - const EMPTY_QUERY_OPEN_TAB_CAP = 5 - const paletteSections = useMemo(() => { - // Why: the worktree cap only matters when open tabs need above-the-fold protection; uncap with zero open tabs. - const worktreeCap = !hasQuery && openTabItems.length > 0 ? EMPTY_QUERY_WORKTREE_CAP : Infinity + const openTabs = hasQuery + ? capPaletteSection(openTabItems) + : { visible: recentTabItems, overflowCount: 0 } + // Why: the worktree section shrinks against the recent rows to hold the empty-query list at its + // pre-existing 10, so RECENT WORKTREES stays above the fold. An empty recent section hands the + // whole budget to worktrees but never uncaps — a filter chip or a tab-less session used to drop + // every open tab and mount one row per workspace. + const worktreeCap = hasQuery + ? Infinity + : Math.min( + openTabs.visible.length === 0 ? EMPTY_QUERY_ROW_BUDGET : EMPTY_QUERY_WORKTREE_CAP, + Math.max(1, EMPTY_QUERY_ROW_BUDGET - openTabs.visible.length) + ) // Why: a typed query can match hundreds of rows; capping the rendered slice // is what keeps a one-character search from building an unbounded DOM. const worktrees = hasQuery @@ -1087,9 +1325,6 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { : { visible: worktreeItems.slice(0, worktreeCap), overflowCount: 0 } const projectTargets = capPaletteSection(hasQuery ? projectTargetItems : []) const middle = capPaletteSection(hasQuery ? middleItems : []) - const openTabs = hasQuery - ? capPaletteSection(openTabItems) - : { visible: openTabItems.slice(0, EMPTY_QUERY_OPEN_TAB_CAP), overflowCount: 0 } const showWorktreeHint = !hasQuery && worktreeItems.length > worktreeCap return { @@ -1103,18 +1338,58 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { openTabOverflowCount: openTabs.overflowCount, showWorktreeHint } - }, [worktreeItems, projectTargetItems, middleItems, openTabItems, hasQuery]) + }, [worktreeItems, projectTargetItems, middleItems, openTabItems, recentTabItems, hasQuery]) + + // Why: both lists are relevance-sorted, so their heads carry each section's best hit. The stronger + // one leads; ties go to open tabs, matching the empty-query view and favouring a tab already open + // over a workspace the user would have to switch to. + const openTabsLeadSections = useMemo(() => { + if (!hasQuery) { + return true + } + const bestWorktree = worktreeItems[0] + const bestWorktreeRelevance = bestWorktree + ? (worktreeRelevanceById.get(bestWorktree.worktree.id) ?? NO_MATCH_RELEVANCE) + : NO_MATCH_RELEVANCE + const bestOpenTab = openTabItems[0] + const bestOpenTabRelevance = bestOpenTab + ? getOpenTabMatchRelevance(bestOpenTab.result) + : NO_MATCH_RELEVANCE + return bestOpenTabRelevance <= bestWorktreeRelevance + }, [hasQuery, openTabItems, worktreeItems, worktreeRelevanceById]) const selectableItems = useMemo( - () => [ - ...paletteSections.visibleWorktreeItems, - ...paletteSections.visibleProjectTargetItems, - ...paletteSections.visibleMiddleItems, - ...paletteSections.visibleOpenTabItems - ], - [paletteSections] + () => + // Why: mirrors render order, which leads with whichever section holds the strongest match. + openTabsLeadSections + ? [ + ...paletteSections.visibleOpenTabItems, + ...paletteSections.visibleWorktreeItems, + ...paletteSections.visibleProjectTargetItems, + ...paletteSections.visibleMiddleItems + ] + : [ + ...paletteSections.visibleWorktreeItems, + ...paletteSections.visibleProjectTargetItems, + ...paletteSections.visibleMiddleItems, + ...paletteSections.visibleOpenTabItems + ], + [openTabsLeadSections, paletteSections] ) + // Why: badges number the snapshotted recent rows only — ⌘N is meaningless on a typed query. + const recentTabShortcutIndexById = useMemo( + () => + new Map( + hasQuery ? [] : paletteSections.visibleOpenTabItems.map((item, index) => [item.id, index]) + ), + [hasQuery, paletteSections] + ) + + // Why: the binding's own modifiers, minus its digit, so a remap renders honestly on every platform. + const digitShortcutModifiers = + useShortcutKeyComboDetails(DIGIT_INDEX_ACTION_ID)[0]?.keys.slice(0, -1) ?? [] + const { createWorktreeName, showCreateAction } = useMemo( () => getWorktreePaletteCreateActionState({ @@ -1150,9 +1425,10 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { }) } } - const visibleWorkspaceItemCount = visibleWorktreeItems.length + (showCreateAction ? 1 : 0) + // Why the create row is excluded: it's present for every non-empty query, so counting it would + // make "suppress lone headers" always false and reinstate the noise the rule exists to kill. const populatedSectionCount = [ - visibleWorkspaceItemCount, + visibleWorktreeItems.length, visibleProjectTargetItems.length, visibleMiddleItems.length, visibleOpenTabItems.length @@ -1160,7 +1436,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { // Header rule: empty query shows lone headers as signposts; on query, suppress unless both sections are populated (else noise). const showWorktreeHeader = hasQuery - ? visibleWorkspaceItemCount > 0 && populatedSectionCount > 1 + ? visibleWorktreeItems.length > 0 && populatedSectionCount > 1 : visibleWorktreeItems.length > 0 const showOpenTabsHeader = hasQuery ? visibleOpenTabItems.length > 0 && populatedSectionCount > 1 @@ -1169,7 +1445,10 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { hasQuery && visibleProjectTargetItems.length > 0 && populatedSectionCount > 1 const showMiddleHeader = hasQuery && visibleMiddleItems.length > 0 && populatedSectionCount > 1 - if (visibleWorkspaceItemCount > 0) { + const pushWorktreeSection = (): void => { + if (visibleWorktreeItems.length === 0) { + return + } if (showWorktreeHeader) { entries.push({ id: '__header_worktrees__', @@ -1196,6 +1475,38 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { } pushOverflowHint('__hint_worktree_overflow__', worktreeOverflowCount) } + + const pushOpenTabSection = (): void => { + if (visibleOpenTabItems.length === 0) { + return + } + if (showOpenTabsHeader) { + entries.push({ + id: '__header_open_tabs__', + type: 'section-header', + label: hasQuery + ? translate('auto.components.WorktreeJumpPalette.50a1d11d5b', 'Open Tabs') + : translate( + 'auto.components.WorktreeJumpPalette.recentChatsTerminalsHeader', + 'Recent Chats & Terminals' + ) + }) + } + appendPaletteListEntries(entries, visibleOpenTabItems) + pushOverflowHint('__hint_open_tab_overflow__', openTabOverflowCount) + } + + if (!hasQuery) { + // Why: the recent section leads the empty-query view; nothing else in this branch is populated. + pushOpenTabSection() + pushWorktreeSection() + return entries + } + + if (openTabsLeadSections) { + pushOpenTabSection() + } + pushWorktreeSection() if (visibleProjectTargetItems.length > 0) { if (showProjectTargetHeader) { entries.push({ @@ -1210,10 +1521,6 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { appendPaletteListEntries(entries, visibleProjectTargetItems) pushOverflowHint('__hint_project_overflow__', projectTargetOverflowCount) } - if (showCreateAction) { - // Why: project/group jump targets are navigation results — keep them after worktree matches, before the creation fallback. - entries.push({ id: CREATE_WORKTREE_ITEM_ID, type: 'create-worktree' }) - } if (visibleMiddleItems.length > 0) { if (showMiddleHeader) { entries.push({ @@ -1225,25 +1532,28 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { appendPaletteListEntries(entries, visibleMiddleItems) pushOverflowHint('__hint_middle_overflow__', middleOverflowCount) } - if (visibleOpenTabItems.length > 0) { - if (showOpenTabsHeader) { - entries.push({ - id: '__header_open_tabs__', - type: 'section-header', - label: translate('auto.components.WorktreeJumpPalette.50a1d11d5b', 'Open Tabs') - }) - } - appendPaletteListEntries(entries, visibleOpenTabItems) - pushOverflowHint('__hint_open_tab_overflow__', openTabOverflowCount) + if (!openTabsLeadSections) { + pushOpenTabSection() + } + if (showCreateAction) { + // Why: creating a workspace is the fallback for "nothing here matches", so it sits below every + // real result — never above them, where it would steal the default selection from a match. + entries.push({ id: CREATE_WORKTREE_ITEM_ID, type: 'create-worktree' }) } return entries - }, [hasQuery, paletteSections, showCreateAction, worktreeItems.length]) + }, [hasQuery, openTabsLeadSections, paletteSections, showCreateAction, worktreeItems.length]) const selectionItemIds = useMemo( () => getWorktreePaletteSelectionItemIds(listEntries), [listEntries] ) + // Why passive, and why after the snapshot effect: it must record the head cmdk *had* while the + // last frame was on screen, so the snapshot compares against that, not against its own result. + useEffect(() => { + autoSelectedItemIdRef.current = selectionItemIds[0] ?? null + }, [selectionItemIds]) + // Why: "has any worktrees?" counts the full visible list (incl. current) so the palette never falsely claims empty. See docs/cmd-j-empty-query-ordering.md. const hasAnyWorktrees = visibleWorktreesForState.length > 0 const hasAnySearchableWorktrees = hasQuery ? searchScopeWorktrees.length > 0 : hasAnyWorktrees @@ -1636,6 +1946,29 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { ] ) + // Why a ref: the rows change on every agent tick, and re-subscribing on each one would tear down + // the listener mid-chord; the handler only ever needs whatever is on screen right now. + useLayoutEffect(() => { + digitShortcutItemsRef.current = paletteSections.visibleOpenTabItems + }, [paletteSections]) + + // Why: main resolves the digit chord to a workspace jump; while the palette owns the keyboard it + // means "activate recent row N" instead, addressing the snapshotted order the badges show. + // Digits past the last badge fall through to nothing rather than switching behind the overlay. + useEffect(() => { + // Why the live query too: `hasQuery` is deferred, so between the keystroke and the deferred + // commit a digit would still activate a recent row the user has already typed past. + if (!visible || hasQuery || query.length > 0) { + return + } + return subscribeCmdJRowIndexJump((index) => { + const item = digitShortcutItemsRef.current[index] + if (item) { + handleSelectItem(item) + } + }) + }, [handleSelectItem, hasQuery, query.length, visible]) + const handleCreateWorktree = useCallback(() => { skipRestoreFocusRef.current = true const trimmed = createWorktreeName.trim() @@ -1865,7 +2198,11 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { } })() - return ( + // Why the dialog is held in a variable: the provider below owns the only subscription to the hot + // agent-status and pane-title maps, so an agent transition re-renders it and the dots inside it — + // never this body. That only holds while these children keep their element identity across the + // churn, which passing them straight through as `children` is what guarantees. + const paletteDialog = (
-
@@ -2226,6 +2554,9 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { ) const WorkspaceTabIcon = result.contentType === 'terminal' ? SquareTerminal : FileText + // Why null on a typed query: the dot belongs to the frozen recent section — the + // Open Tabs results a search returns show their content icon instead. + const recentRow = hasQuery ? null : (recentTabRowById.get(entry.id) ?? null) return (
-
@@ -2292,6 +2626,10 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { )} +
@@ -2377,6 +2715,10 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { )} +
@@ -2459,6 +2801,10 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { )} + @@ -2510,6 +2856,12 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { ) + + return ( + + {paletteDialog} + + ) } function getPaletteSupportingTextLabel( diff --git a/src/renderer/src/components/cmd-j/palette-live-status.test.tsx b/src/renderer/src/components/cmd-j/palette-live-status.test.tsx new file mode 100644 index 00000000000..1a809c02501 --- /dev/null +++ b/src/renderer/src/components/cmd-j/palette-live-status.test.tsx @@ -0,0 +1,189 @@ +// @vitest-environment happy-dom + +import React, { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import type { AgentStatusEntry, AgentStatusState } from '../../../../shared/agent-status-types' +import type { TerminalTab } from '../../../../shared/types' +import { + PaletteLiveStatusProvider, + PaletteRecentTabStatusDot, + PaletteWorktreeStatusDot +} from './palette-live-status' + +vi.mock('@/components/AgentWorkingSpinner', () => ({ + AgentWorkingSpinner: () => +})) + +const initialAppState = useAppStore.getInitialState() +const LEAF = '11111111-2222-4333-8444-555555555555' + +let testRoot: Root +let testContainer: HTMLDivElement + +function makeTerminalTab(id: string, worktreeId: string): TerminalTab { + return { + id, + ptyId: `pty-${id}`, + worktreeId, + title: 'Chat', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } +} + +function makeAgentEntry(tabId: string, state: AgentStatusState): AgentStatusEntry { + return { + state, + prompt: '', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + paneKey: `${tabId}:${LEAF}`, + stateHistory: [] + } +} + +function setAgentState(state: AgentStatusState): void { + useAppStore.setState((s) => ({ + agentStatusByPaneKey: { [`term-a:${LEAF}`]: makeAgentEntry('term-a', state) }, + agentStatusEpoch: s.agentStatusEpoch + 1 + })) +} + +/** Stands in for the palette body: counts how often the frozen subtree is re-rendered. */ +let bodyRenderCount = 0 +const FrozenBody = React.memo(function FrozenBody({ + children +}: { + children: React.ReactNode +}): React.JSX.Element { + bodyRenderCount += 1 + return <>{children} +}) + +function dotLabels(): string[] { + return [...testContainer.querySelectorAll('.sr-only')].map( + (node) => node.textContent ?? '' + ) +} + +describe('palette live status', () => { + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + bodyRenderCount = 0 + useAppStore.setState(initialAppState, true) + useAppStore.setState({ + tabsByWorktree: { 'wt-a': [makeTerminalTab('term-a', 'wt-a')] }, + ptyIdsByTabId: { 'term-a': ['pty-term-a'] }, + browserTabsByWorktree: {} + } as Partial) + testContainer = document.createElement('div') + document.body.appendChild(testContainer) + testRoot = createRoot(testContainer) + }) + + afterEach(async () => { + await act(async () => { + testRoot.unmount() + }) + document.body.replaceChildren() + useAppStore.setState(initialAppState, true) + }) + + async function render(active = true): Promise { + await act(async () => { + testRoot.render( + + + + + + ) + }) + } + + it('updates a worktree dot when the agent transitions', async () => { + setAgentState('working') + await render() + expect(dotLabels()).toEqual(['Working']) + + await act(async () => { + setAgentState('blocked') + }) + expect(dotLabels()).toEqual(['Needs permission']) + }) + + // Why this is the whole point: the palette body no longer subscribes to agent status, so if the + // dot didn't hold its own subscription it would freeze at whatever it showed when you opened. + it('re-renders the dot without re-rendering the frozen body around it', async () => { + setAgentState('working') + await render() + const rendersAfterMount = bodyRenderCount + + await act(async () => { + setAgentState('blocked') + }) + + expect(dotLabels()).toEqual(['Needs permission']) + expect(bodyRenderCount).toBe(rendersAfterMount) + }) + + it('goes inert while the palette is closed', async () => { + setAgentState('working') + await render(false) + // No live maps flow through, so the dot falls back to the no-tabs state rather than + // reporting a worktree it isn't watching. + expect(dotLabels()).toEqual(['Inactive']) + }) + + it('falls back to the row icon when a recent row has no terminal behind it', async () => { + await act(async () => { + testRoot.render( + + } + /> + + ) + }) + expect(testContainer.querySelector('[data-fallback]')).not.toBeNull() + expect(dotLabels()).toEqual([]) + }) + + it('resolves a live dot for a recent row backed by a terminal', async () => { + setAgentState('working') + await act(async () => { + testRoot.render( + + } + /> + + ) + }) + expect(dotLabels()).toEqual(['Working']) + + await act(async () => { + setAgentState('blocked') + }) + expect(dotLabels()).toEqual(['Needs permission']) + }) +}) diff --git a/src/renderer/src/components/cmd-j/palette-live-status.tsx b/src/renderer/src/components/cmd-j/palette-live-status.tsx new file mode 100644 index 00000000000..5e75a82bfea --- /dev/null +++ b/src/renderer/src/components/cmd-j/palette-live-status.tsx @@ -0,0 +1,173 @@ +import React, { createContext, useContext, useMemo } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { useAppStore } from '@/store' +import StatusIndicator from '@/components/sidebar/StatusIndicator' +import { + buildExplicitEntriesByTabId, + type TabPaneInputSources +} from '@/components/sidebar/smart-attention' +import { getLiveAgentStatusByWorktreeId } from '@/lib/worktree-activity-state' +import { + getWorktreeStatus, + getWorktreeStatusLabel, + type WorktreeStatus +} from '@/lib/worktree-status' +import { + resolveRecentWorkspaceTabStatus, + type RecentWorkspaceTabRow +} from '@/lib/recent-workspace-tab-rows' +import type { LiveAgentWorktreeStatus } from '@/lib/worktree-activity-state' +import type { BrowserWorkspace, TerminalTab, Worktree } from '../../../../shared/types' + +/** Confines the app's hottest status subscriptions here so only the dots re-render on their churn. */ +type PaletteLiveStatus = { + liveAgentStatusByWorktreeId: ReadonlyMap + paneSources: TabPaneInputSources + tabsByWorktree: Record + browserTabsByWorktree: Record + /** Bumped with the maps so consumers re-resolve `now`-sensitive freshness on the same tick. */ + statusEpoch: number +} + +const PaletteLiveStatusContext = createContext(null) + +export function PaletteLiveStatusProvider({ + active, + children +}: { + /** Open, or still animating closed — matches the palette's own status-input gate. */ + active: boolean + children: React.ReactNode +}): React.JSX.Element { + const { + agentStatusByPaneKey, + runtimePaneTitlesByTabId, + ptyIdsByTabId, + terminalLayoutsByTabId, + tabsByWorktree, + browserTabsByWorktree, + migrationUnsupportedByPtyId + } = useAppStore( + useShallow((s) => + active + ? { + agentStatusByPaneKey: s.agentStatusByPaneKey, + runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId, + ptyIdsByTabId: s.ptyIdsByTabId, + terminalLayoutsByTabId: s.terminalLayoutsByTabId, + tabsByWorktree: s.tabsByWorktree, + browserTabsByWorktree: s.browserTabsByWorktree, + migrationUnsupportedByPtyId: s.migrationUnsupportedByPtyId + } + : EMPTY_LIVE_INPUTS + ) + ) + const statusEpoch = useAppStore((s) => (active ? s.agentStatusEpoch : 0)) + + const value = useMemo(() => { + // Why: `now` decides freshness, so both derivations must read it on the same tick — otherwise a + // "done" dot can outlive its window while the worktree row beside it has already decayed. + const now = Date.now() + return { + liveAgentStatusByWorktreeId: getLiveAgentStatusByWorktreeId( + agentStatusByPaneKey, + tabsByWorktree, + now + ), + paneSources: { + entriesByTabId: buildExplicitEntriesByTabId( + agentStatusByPaneKey, + migrationUnsupportedByPtyId + ), + ptyIdsByTabId, + runtimePaneTitlesByTabId, + terminalLayoutsByTabId + }, + tabsByWorktree, + browserTabsByWorktree, + statusEpoch + } + }, [ + agentStatusByPaneKey, + browserTabsByWorktree, + migrationUnsupportedByPtyId, + ptyIdsByTabId, + runtimePaneTitlesByTabId, + statusEpoch, + tabsByWorktree, + terminalLayoutsByTabId + ]) + + return ( + {children} + ) +} + +const EMPTY_LIVE_INPUTS = Object.freeze({ + agentStatusByPaneKey: {}, + runtimePaneTitlesByTabId: {}, + ptyIdsByTabId: {}, + terminalLayoutsByTabId: {}, + tabsByWorktree: {}, + browserTabsByWorktree: {}, + migrationUnsupportedByPtyId: {} +}) + +function useLiveStatus(): PaletteLiveStatus | null { + return useContext(PaletteLiveStatusContext) +} + +/** + * Live dot for a worktree row. Rendering it instead of resolving the status in the palette body is + * what keeps an agent transition from re-rendering every row in the list. + */ +export function PaletteWorktreeStatusDot({ + worktree +}: { + worktree: Pick +}): React.JSX.Element | null { + const live = useLiveStatus() + if (!live) { + return null + } + const status = getWorktreeStatus( + live.tabsByWorktree[worktree.id] ?? [], + live.browserTabsByWorktree[worktree.id] ?? [], + live.paneSources.ptyIdsByTabId, + live.paneSources.runtimePaneTitlesByTabId, + { liveAgentStatus: live.liveAgentStatusByWorktreeId.get(worktree.id) } + ) + return ( + <> +