Files
orca/src/main/browser/agent-browser-bridge-queue.ts
T
Neil 90236c3bde refactor(browser): one page gate and one owner for a host command's hold (#30652)
The revocation gate, the input cancel list and the hold's lifecycle each lived in two places.
Sinks now call one gate, held input is recorded at the CDP send (and the one native key path),
and runWithRemoteCliPageAdmission owns retire-on-revoke, release and error conversion.
2026-10-19 03:11:54 -07:00

178 lines
6.7 KiB
TypeScript

import type { BrowserTabSwitchResult } from '../../shared/runtime-types'
import { BrowserError } from './cdp-bridge'
import { AgentBrowserBridgeShutdown } from './agent-browser-bridge-shutdown'
import { ORCA_TAB_SESSION_PREFIX } from './agent-browser-orphan-sweep'
import type {
EnqueueTargetedCommandOptions,
ResolvedBrowserCommandTarget
} from './agent-browser-bridge-types'
import { runWithRemoteCliPageAdmission } from './remote-cli-page-admission'
import { AGENT_BROWSER_CLEANUP_TIMEOUT_MS } from './agent-browser-bridge-types'
import { releaseHeldPageInput } from './guest-direct-key-input'
import {
captureRemoteCliBrowserRevocation,
captureRemoteCliBrowserScope
} from '../runtime/desktop-browser-bridge/remote-cli-browser-scope'
export abstract class AgentBrowserBridgeQueue extends AgentBrowserBridgeShutdown {
// Why: route tab switch through the command queue so it can't race in-flight commands targeting the old tab.
async tabSwitch(
index: number | undefined,
worktreeId?: string,
browserPageId?: string
): Promise<BrowserTabSwitchResult> {
return this.enqueueCommand(worktreeId, async () => {
const tabs = this.getRegisteredTabs(worktreeId)
// Why: queue delay can change the tab list before execution — recompute against live webContents so no vanished index is activated.
const liveEntries = [...tabs.entries()].filter(([, wcId]) => this.getWebContents(wcId))
let switchedIndex = index ?? -1
let resolvedPageId = browserPageId
if (resolvedPageId) {
switchedIndex = liveEntries.findIndex(([tabId]) => tabId === resolvedPageId)
}
if (switchedIndex < 0 || switchedIndex >= liveEntries.length) {
const targetLabel =
resolvedPageId != null ? `Browser page ${resolvedPageId}` : `Tab index ${index}`
throw new BrowserError(
'browser_tab_not_found',
`${targetLabel} out of range (0-${liveEntries.length - 1})`
)
}
const [tabId, wcId] = liveEntries[switchedIndex]
this.activeWebContentsId = wcId
// Why: resolveActiveTab prefers the per-worktree map, so update it or later commands keep routing to the old tab.
const owningWorktreeId = worktreeId ?? this.browserManager.getWorktreeIdForTab(tabId)
// Why: `tab switch --page` may omit --worktree, so still update the owning worktree's active slot for later scoped commands.
if (owningWorktreeId) {
this.activeWebContentsPerWorktree.set(owningWorktreeId, wcId)
}
this.options.onTabsChanged?.(owningWorktreeId ?? undefined)
return { switched: switchedIndex, browserPageId: tabId }
})
}
// ── Internal ──
protected async enqueueCommand<T>(
worktreeId: string | undefined,
execute: (sessionName: string) => Promise<T>
): Promise<T> {
return this.enqueueTargetedCommand(worktreeId, undefined, execute)
}
protected async enqueueTargetedCommand<T>(
worktreeId: string | undefined,
browserPageId: string | undefined,
execute: (sessionName: string, target: ResolvedBrowserCommandTarget) => Promise<T>,
options: EnqueueTargetedCommandOptions = {}
): Promise<T> {
this.assertCommandAdmission()
const assertNotRevoked = captureRemoteCliBrowserRevocation()
const origin = captureRemoteCliBrowserScope()
assertNotRevoked()
const { browserPageId: pageId } = this.resolveCommandTarget(
worktreeId,
browserPageId,
options.requireScopedTarget
)
const sessionName = `${ORCA_TAB_SESSION_PREFIX}${pageId}`
return new Promise<T>((resolve, reject) => {
let queue = this.commandQueues.get(sessionName)
if (!queue) {
queue = []
this.commandQueues.set(sessionName, queue)
}
queue.push({
execute: () => {
assertNotRevoked()
const run = (): Promise<T> =>
this.executeQueuedCommand(
worktreeId,
pageId,
(session, target) => {
const gate = (): void => assertNotRevoked(target.webContentsId)
gate()
return origin.bridged
? this.runHostCommand(session, target.webContentsId, gate, origin.revoked, () =>
execute(session, target)
)
: execute(session, target)
},
options
)
return origin.run(run)
},
resolve: resolve as (value: unknown) => void,
reject
})
this.processQueue(sessionName)
})
}
protected async executeQueuedCommand<T>(
worktreeId: string | undefined,
browserPageId: string,
execute: (sessionName: string, target: ResolvedBrowserCommandTarget) => Promise<T>,
options: EnqueueTargetedCommandOptions
): Promise<T> {
this.assertCommandAdmission()
const sessionName = `${ORCA_TAB_SESSION_PREFIX}${browserPageId}`
// Why: the page's guest can change while queued; bind to the one current at execution.
const target = this.resolveCommandTarget(worktreeId, browserPageId)
if (options.ensureSession !== false) {
await this.ensureSession(sessionName, browserPageId, target.webContentsId)
}
return execute(sessionName, target)
}
/** Holds the page for a host's command, so a revocation stops every later step of it. */
private runHostCommand<T>(
sessionName: string,
webContentsId: number,
gate: () => void,
revoked: AbortSignal | undefined,
execute: () => Promise<T>
): Promise<T> {
return runWithRemoteCliPageAdmission(webContentsId, gate, execute, {
revoked,
// Why keep the queue: commands waiting on the page still run, on a fresh helper session.
retire: () =>
this.destroySession(sessionName, {
closeTimeoutMs: AGENT_BROWSER_CLEANUP_TIMEOUT_MS,
keepQueuedCommands: true
}),
releaseHeld: (release) => {
const wc = this.getWebContents(webContentsId)
return wc
? releaseHeldPageInput(wc, release, () =>
this.browserManager.guardUserFocusForAgentInput(webContentsId)
)
: Promise.resolve()
}
})
}
protected async processQueue(sessionName: string): Promise<void> {
if (this.processingQueues.has(sessionName)) {
return
}
this.processingQueues.add(sessionName)
const queue = this.commandQueues.get(sessionName)
while (queue && queue.length > 0) {
const cmd = queue.shift()!
try {
const result = await cmd.execute()
cmd.resolve(result)
} catch (error) {
cmd.reject(error)
}
}
if (queue && queue.length === 0 && this.commandQueues.get(sessionName) === queue) {
this.commandQueues.delete(sessionName)
}
this.processingQueues.delete(sessionName)
}
}