diff --git a/src/main/browser/offscreen-browser-backend.ts b/src/main/browser/offscreen-browser-backend.ts index a6e2614c664..96904d61903 100644 --- a/src/main/browser/offscreen-browser-backend.ts +++ b/src/main/browser/offscreen-browser-backend.ts @@ -1,5 +1,4 @@ import { randomUUID } from 'node:crypto' -import type { BrowserWindow } from 'electron' import { ORCA_BROWSER_PARTITION } from '../../shared/constants' import type { AgentBrowserBridge } from './agent-browser-bridge' import type { BrowserBackend, BrowserBackendCreateTab, ParkedBrowserPage } from './browser-backend' @@ -11,6 +10,10 @@ import { selectOffscreenBrowserPagesToPark, type OffscreenBrowserReclaimPolicy } from './offscreen-browser-page-reclaim' +import { + OffscreenBrowserOpenPages, + type OffscreenBrowserPage +} from './offscreen-browser-open-pages' import { createOffscreenBrowserWindow, loadOffscreenBrowserUrl } from './offscreen-browser-window' // Why (STA-4341): this backend is the lifecycle owner for headless browser @@ -19,22 +22,6 @@ import { createOffscreenBrowserWindow, loadOffscreenBrowserUrl } from './offscre // process behind it as a reclaimable resource: an idle page is parked (renderer // destroyed, record kept) and woken on the next command that targets it. -type OffscreenPage = { - browserPageId: string - worktreeId?: string - profileId?: string - partition: string - url: string - title: string - /** null while parked — the page exists, its renderer does not. */ - window: BrowserWindow | null - /** Whether the page was its worktree's active tab when it parked. */ - activeWhenParked: boolean - /** True while the initial or post-wake navigation is still in flight. */ - loading: boolean - lastActivityAt: number -} - export type OffscreenBrowserBackendOptions = { getAgentBrowserBridge?: () => AgentBrowserBridge | null /** Pages a client is streaming or that have a command in flight. */ @@ -43,7 +30,7 @@ export type OffscreenBrowserBackendOptions = { } export class OffscreenBrowserBackend implements BrowserBackend { - private readonly pages = new Map() + private readonly pages = new OffscreenBrowserOpenPages() /** Renderer teardowns this backend initiated, keyed by page. Park and close * both destroy the window on purpose, so the crash handler stands down for * them — and a wake waits on one rather than racing it. */ @@ -71,7 +58,7 @@ export class OffscreenBrowserBackend implements BrowserBackend { ? browserSessionRegistry.getProfile(params.profileId) : browserSessionRegistry.getDefaultProfile() const url = params.url || 'about:blank' - const page: OffscreenPage = { + const page: OffscreenBrowserPage = { browserPageId, worktreeId: params.worktreeId, profileId: profile?.id ?? undefined, @@ -83,7 +70,7 @@ export class OffscreenBrowserBackend implements BrowserBackend { loading: false, lastActivityAt: this.now() } - this.pages.set(browserPageId, page) + this.pages.add(page) // Why: register the guest and return immediately so the new tab appears // without waiting for the page to finish loading. A failed load leaves the // (usable) tab open, matching how a normal browser tab survives one. @@ -99,10 +86,9 @@ export class OffscreenBrowserBackend implements BrowserBackend { } async closeTab(browserPageId: string): Promise { - const page = this.pages.get(browserPageId) - this.pages.delete(browserPageId) + const page = this.pages.delete(browserPageId) ?? null this.waking.delete(browserPageId) - await this.releaseRenderer(page ?? null, browserPageId) + await this.releaseRenderer(page, browserPageId) if (this.pages.size === 0) { this.stopSweep() } @@ -162,47 +148,18 @@ export class OffscreenBrowserBackend implements BrowserBackend { } listParkedPages(worktreeId?: string): ParkedBrowserPage[] { - const parked: ParkedBrowserPage[] = [] - for (const page of this.pages.values()) { - if (page.window && !page.window.isDestroyed()) { - continue - } - if (worktreeId && page.worktreeId !== worktreeId) { - continue - } - parked.push({ - browserPageId: page.browserPageId, - worktreeId: page.worktreeId, - profileId: page.profileId, - url: page.url, - title: page.title, - active: page.activeWhenParked - }) - } - return parked + return this.pages.listParked(worktreeId) } /** Most-recently-used parked page in a worktree, for implicit targeting. */ getMostRecentlyUsedParkedPageId(worktreeId?: string): string | null { - let best: OffscreenPage | null = null - for (const page of this.pages.values()) { - if (page.window && !page.window.isDestroyed()) { - continue - } - if (worktreeId && page.worktreeId !== worktreeId) { - continue - } - if (!best || page.lastActivityAt > best.lastActivityAt) { - best = page - } - } - return best?.browserPageId ?? null + return this.pages.mostRecentlyUsedParkedId(worktreeId) } destroyAll(): void { this.stopSweep() - for (const [pageId, page] of this.pages) { - this.browserManager.unregisterGuest(pageId) + for (const page of this.pages.all()) { + this.browserManager.unregisterGuest(page.browserPageId) if (page.window && !page.window.isDestroyed()) { page.window.destroy() } @@ -214,9 +171,7 @@ export class OffscreenBrowserBackend implements BrowserBackend { /** Park every page the policy no longer wants resident. Exposed for tests. */ async reclaimIdlePages(): Promise { const now = this.now() - const resident = [...this.pages.values()].filter( - (page) => page.window && !page.window.isDestroyed() && !this.releasing.has(page.browserPageId) - ) + const resident = this.pages.resident().filter((page) => !this.releasing.has(page.browserPageId)) const doomed = selectOffscreenBrowserPagesToPark( resident.map((page) => ({ browserPageId: page.browserPageId, @@ -239,9 +194,7 @@ export class OffscreenBrowserBackend implements BrowserBackend { // agent that opens pages forever and closes none would still grow the backend // without limit, so the oldest parked pages are eventually closed outright. private async closeOverRetainedPages(): Promise { - const parked = [...this.pages.values()].filter( - (page) => !page.window || page.window.isDestroyed() - ) + const parked = this.pages.parked() const doomed = selectOffscreenBrowserPagesToClose( parked.map((page) => ({ browserPageId: page.browserPageId, @@ -286,7 +239,10 @@ export class OffscreenBrowserBackend implements BrowserBackend { // Why: teardown order matters — the bridge must destroy the helper session and // detach its debugger while the WebContents is still alive and still mapped, // or the session, its CDP proxy and its listening port outlive the page. - private async releaseRenderer(page: OffscreenPage | null, browserPageId: string): Promise { + private async releaseRenderer( + page: OffscreenBrowserPage | null, + browserPageId: string + ): Promise { const pending = this.releasing.get(browserPageId) if (pending) { await pending @@ -304,7 +260,7 @@ export class OffscreenBrowserBackend implements BrowserBackend { } private async runReleaseRenderer( - page: OffscreenPage | null, + page: OffscreenBrowserPage | null, browserPageId: string ): Promise { const bridge = this.options.getAgentBrowserBridge?.() ?? null @@ -318,7 +274,7 @@ export class OffscreenBrowserBackend implements BrowserBackend { } } - private materialize(page: OffscreenPage): void { + private materialize(page: OffscreenBrowserPage): void { const win = createOffscreenBrowserWindow(page.partition) page.window = win // Why: reading win.webContents once the contents are destroyed throws, and @@ -355,7 +311,7 @@ export class OffscreenBrowserBackend implements BrowserBackend { }) } - private async loadPage(page: OffscreenPage, url: string): Promise { + private async loadPage(page: OffscreenBrowserPage, url: string): Promise { const win = page.window if (!win || win.isDestroyed()) { return diff --git a/src/main/browser/offscreen-browser-open-pages.ts b/src/main/browser/offscreen-browser-open-pages.ts new file mode 100644 index 00000000000..5508ec66b90 --- /dev/null +++ b/src/main/browser/offscreen-browser-open-pages.ts @@ -0,0 +1,94 @@ +import type { BrowserWindow } from 'electron' +import type { ParkedBrowserPage } from './browser-backend' + +// Why (STA-4341): a headless browser page and the renderer behind it have +// different lifetimes — the page is open until it is closed, the renderer only +// while something needs it. This is the book of open pages; the backend owns +// the renderers. Keeping them apart is what makes "parked" expressible: a +// record here with no window attached. + +export type OffscreenBrowserPage = { + browserPageId: string + worktreeId?: string + profileId?: string + partition: string + url: string + title: string + /** null while parked — the page exists, its renderer does not. */ + window: BrowserWindow | null + /** Whether the page was its worktree's active tab when it parked. */ + activeWhenParked: boolean + /** True while the initial or post-wake navigation is still in flight. */ + loading: boolean + lastActivityAt: number +} + +function isResident(page: OffscreenBrowserPage): boolean { + return Boolean(page.window && !page.window.isDestroyed()) +} + +export class OffscreenBrowserOpenPages { + private readonly pages = new Map() + + get size(): number { + return this.pages.size + } + + has(browserPageId: string): boolean { + return this.pages.has(browserPageId) + } + + get(browserPageId: string): OffscreenBrowserPage | undefined { + return this.pages.get(browserPageId) + } + + add(page: OffscreenBrowserPage): void { + this.pages.set(page.browserPageId, page) + } + + delete(browserPageId: string): OffscreenBrowserPage | undefined { + const page = this.pages.get(browserPageId) + this.pages.delete(browserPageId) + return page + } + + clear(): void { + this.pages.clear() + } + + all(): OffscreenBrowserPage[] { + return [...this.pages.values()] + } + + resident(): OffscreenBrowserPage[] { + return this.all().filter(isResident) + } + + parked(worktreeId?: string): OffscreenBrowserPage[] { + return this.all().filter( + (page) => !isResident(page) && (!worktreeId || page.worktreeId === worktreeId) + ) + } + + listParked(worktreeId?: string): ParkedBrowserPage[] { + return this.parked(worktreeId).map((page) => ({ + browserPageId: page.browserPageId, + worktreeId: page.worktreeId, + profileId: page.profileId, + url: page.url, + title: page.title, + active: page.activeWhenParked + })) + } + + /** Most-recently-used parked page, for commands that name a worktree. */ + mostRecentlyUsedParkedId(worktreeId?: string): string | null { + let best: OffscreenBrowserPage | null = null + for (const page of this.parked(worktreeId)) { + if (!best || page.lastActivityAt > best.lastActivityAt) { + best = page + } + } + return best?.browserPageId ?? null + } +} diff --git a/src/main/runtime/orca-runtime-browser.test.ts b/src/main/runtime/orca-runtime-browser.test.ts index 07cc6058df4..5291848e2bc 100644 --- a/src/main/runtime/orca-runtime-browser.test.ts +++ b/src/main/runtime/orca-runtime-browser.test.ts @@ -858,6 +858,7 @@ describe('RuntimeBrowserCommands headless offscreen routing', () => { const keyboardInsertText = vi.fn().mockResolvedValue({ inserted: true }) const bridge = { getRegisteredTabs: vi.fn(() => new Map([['page-1', 100]])), + getActivePageId: vi.fn(() => 'page-1'), keyboardInsertText } as unknown as AgentBrowserBridge const commands = new RuntimeBrowserCommands(