Merge remote-tracking branch 'origin/main' into brennanb2025/codex-sessions-visibility-12480

This commit is contained in:
Brennan Benson
2026-08-07 15:25:06 -07:00
23 changed files with 3488 additions and 662 deletions
+113 -1
View File
@@ -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",
File diff suppressed because it is too large Load Diff
@@ -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 = <TResult>(
target: RuntimeClientTarget,
method: string,
params?: unknown,
options?: { timeoutMs?: number; suppressFeatureInteraction?: boolean }
) => Promise<TResult>
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<BrowserTabInfo, 'url' | 'title'>): 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<typeof setTimeout> | null = null
constructor(private readonly deps: RemoteBrowserPageSessionDeps) {}
async fetchTabInfo(token: RemoteBrowserOperationToken): Promise<BrowserTabInfo | null> {
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<string | null> {
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<string | null> {
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
}
}
@@ -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<unknown>) => void
onBinary?: (bytes: Uint8Array<ArrayBufferLike>) => 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<ArrayBufferLike>) => 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<unknown>,
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)
}
}
@@ -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)
})
})
@@ -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<string> = new Set([
'worktree_not_found_on_server',
'repo_not_found',
'capability_unsupported'
])
const REMOTE_BROWSER_PAGE_MISSING_CODES: ReadonlySet<string> = 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 }
}
@@ -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<RemoteBrowserPageSessionDeps, 'tokens'> & {
identity: RemoteBrowserPaneIdentity
subscribeScreencast: RemoteBrowserScreencastSubscribe
waitForViewportSize: () => Promise<RemoteBrowserViewportSize | null>
readViewportSize: () => RemoteBrowserViewportSize | null
syncViewport: (pageId: string) => Promise<void>
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<ArrayBufferLike>) => void
}
File diff suppressed because it is too large Load Diff
@@ -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<RemoteBrowserStreamSubscription | null> {
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<RuntimeStatus>(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)
})
)
}
}
@@ -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<typeof setTimeout> | 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
}
}
}
@@ -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<RemoteBrowserStreamSubscription | null>
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<boolean> {
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<boolean> => {
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
}
}
}
@@ -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<boolean>((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<boolean>((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<boolean>((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()
})
})
@@ -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<boolean>
export class RemoteBrowserStreamRestartScheduler {
private attempt = 0
private timer: ReturnType<typeof setTimeout> | 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)
}
}
}
@@ -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)
}
}
})
})
@@ -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.'
)
}
@@ -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
}
}
@@ -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] }
])
})
})
@@ -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' })
@@ -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,
+7
View File
@@ -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": {
@@ -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'],
+23 -7
View File
@@ -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 {
@@ -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<TResult>(
page: Page,
environmentId: string,
method: string,
params: unknown
): Promise<TResult> {
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<TResult>
}
/** 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<void> {
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()
}
})