fix(agent-browser): restore bounded presentation lifecycle

This commit is contained in:
2026-09-06 12:50:18 +08:00
parent 5c61110f46
commit 9fed0cc7c4
20 changed files with 1714 additions and 49 deletions

View File

@@ -106,6 +106,7 @@ interface BrowserRecord {
title: string;
visible: boolean;
diagnosticsEnabled: boolean;
diagnosticOwners: Set<string>;
bounds: AgentBrowserBounds | null;
error?: {
code: AgentBrowserErrorCode;
@@ -148,6 +149,7 @@ export interface AgentBrowserOpenInput {
bounds?: AgentBrowserBounds;
visible?: boolean;
injectProjectData?: boolean;
diagnosticsOwner?: string;
}
export interface AgentBrowserPresentInput {
@@ -156,6 +158,12 @@ export interface AgentBrowserPresentInput {
bounds?: AgentBrowserBounds;
}
export interface AgentBrowserWaitForPresentationInput {
projectPath: string;
generation: number;
timeoutMs?: number;
}
export interface AgentBrowserNavigateInput {
projectPath: string;
action: 'url' | 'back' | 'forward' | 'reload';
@@ -213,6 +221,7 @@ export class AgentBrowserModule {
private readonly payloadStore: AgentBrowserPayloadStore;
private readonly cdpGuard: AgentBrowserCdpGuard;
private readonly eventWaiters = new Set<() => void>();
private readonly presentationWaiters = new Set<() => void>();
private readonly commandCancellers = new Set<(fault: AgentBrowserFault) => void>();
private readonly lifecycleListeners = new Set<AgentBrowserLifecycleListener>();
private record: BrowserRecord | null = null;
@@ -341,16 +350,32 @@ export class AgentBrowserModule {
}
if (this.record) {
const record = this.record;
if (bounds) {
this.applyPresentation(record, input.visible ?? true, bounds);
const diagnosticOwner = input.diagnosticsOwner?.trim();
const addedDiagnosticOwner = Boolean(
diagnosticOwner && !record.diagnosticOwners.has(diagnosticOwner),
);
try {
if (diagnosticOwner) {
await this.updateDiagnosticOwner(record, diagnosticOwner, true);
}
if (bounds) {
this.applyPresentation(record, input.visible ?? true, bounds);
}
if (record.url !== targetUrl || record.error) {
await this.navigateTo(record, targetUrl);
}
return this.snapshot(record);
} catch (error) {
if (addedDiagnosticOwner && diagnosticOwner) {
await this.updateDiagnosticOwner(record, diagnosticOwner, false).catch(() => undefined);
}
throw error;
}
if (record.url !== targetUrl || record.error) {
await this.navigateTo(record, targetUrl);
}
return this.snapshot(record);
}
const view = this.adapter.createView(agentBrowserPartition(projectPath));
const diagnosticOwners = new Set<string>();
if (input.diagnosticsOwner?.trim()) diagnosticOwners.add(input.diagnosticsOwner.trim());
const record: BrowserRecord = {
browserId: randomUUID(),
projectId: input.projectId,
@@ -362,7 +387,8 @@ export class AgentBrowserModule {
url: targetUrl,
title: '',
visible: Boolean(bounds) && (input.visible ?? true),
diagnosticsEnabled: false,
diagnosticsEnabled: diagnosticOwners.size > 0,
diagnosticOwners,
bounds,
eventBuffer: new AgentBrowserEventBuffer(),
childSessions: new Set(),
@@ -439,14 +465,73 @@ export class AgentBrowserModule {
}
const bounds = input.bounds ? normalizeBounds(input.bounds) : record.bounds;
this.applyPresentation(record, input.visible, bounds);
this.notifyPresentationWaiters();
return this.snapshot(record);
});
}
setDiagnostics(input: { projectPath: string; enabled: boolean }): Promise<AgentBrowserSnapshot> {
waitForPresentation(
input: AgentBrowserWaitForPresentationInput,
): Promise<AgentBrowserSnapshot> {
this.assertAvailable();
const timeoutMs = normalizeIntegerRange(
input.timeoutMs,
MAX_WAIT_MS,
1,
MAX_CDP_TIMEOUT_MS,
'timeoutMs',
);
const immediate = this.presentationSnapshot(input.projectPath, input.generation);
if (immediate) return Promise.resolve(immediate);
return new Promise<AgentBrowserSnapshot>((resolvePromise, rejectPromise) => {
let settled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const cleanup = () => {
this.presentationWaiters.delete(check);
if (timer) clearTimeout(timer);
};
const resolve = (snapshot: AgentBrowserSnapshot) => {
if (settled) return;
settled = true;
cleanup();
resolvePromise(snapshot);
};
const reject = (error: unknown) => {
if (settled) return;
settled = true;
cleanup();
rejectPromise(error);
};
const check = () => {
try {
const snapshot = this.presentationSnapshot(input.projectPath, input.generation);
if (snapshot) resolve(snapshot);
} catch (error) {
reject(error);
}
};
this.presentationWaiters.add(check);
timer = setTimeout(() => {
reject(new AgentBrowserFault(
'VIEWPORT_NOT_READY',
'开发浏览器显示区域没有及时准备好。',
true,
input.generation,
));
}, timeoutMs);
check();
});
}
setDiagnostics(input: {
projectPath: string;
enabled: boolean;
owner?: string;
}): Promise<AgentBrowserSnapshot> {
return this.serialize(async () => {
const record = this.requireRecord(input.projectPath);
await this.configureDiagnostics(record, input.enabled);
await this.updateDiagnosticOwner(record, input.owner ?? 'renderer', input.enabled);
return this.snapshot(record);
});
}
@@ -766,6 +851,7 @@ export class AgentBrowserModule {
generation: record.generation,
url: record.url,
});
this.notifyPresentationWaiters();
}
record.state = 'attaching';
record.error = undefined;
@@ -1023,6 +1109,7 @@ export class AgentBrowserModule {
url: record.url,
});
this.notifyEventWaiters();
this.notifyPresentationWaiters();
};
this.addDebuggerListener(record, 'message', onDebuggerMessage);
this.addDebuggerListener(record, 'detach', onDebuggerDetach);
@@ -1088,6 +1175,7 @@ export class AgentBrowserModule {
code: 'DEVTOOLS_CONFLICT',
message: '原生 DevTools 已打开,智能体调试暂时暂停。',
};
this.notifyPresentationWaiters();
});
this.addWebContentsListener(record, 'devtools-closed', () => {
if (record !== this.record || record.state === 'closing' || record.state === 'crashed') {
@@ -1112,6 +1200,7 @@ export class AgentBrowserModule {
record.generation,
);
record.error = { code: fault.code, message: fault.message };
this.notifyPresentationWaiters();
}
}).catch(() => undefined);
});
@@ -1134,6 +1223,7 @@ export class AgentBrowserModule {
record.childSessions.clear();
record.ioHandles.clear();
this.notifyEventWaiters();
this.notifyPresentationWaiters();
});
this.addWebContentsListener(record, 'destroyed', () => {
if (record !== this.record || record.state === 'closing') return;
@@ -1152,9 +1242,29 @@ export class AgentBrowserModule {
});
record.eventBuffer.markGap('view-recreated');
this.notifyEventWaiters();
this.notifyPresentationWaiters();
});
}
private async updateDiagnosticOwner(
record: BrowserRecord,
ownerValue: string,
enabled: boolean,
): Promise<void> {
const owner = ownerValue.trim() || 'renderer';
const hadOwner = record.diagnosticOwners.has(owner);
if (hadOwner === enabled) return;
if (enabled) record.diagnosticOwners.add(owner);
else record.diagnosticOwners.delete(owner);
try {
await this.configureDiagnostics(record, record.diagnosticOwners.size > 0);
} catch (error) {
if (hadOwner) record.diagnosticOwners.add(owner);
else record.diagnosticOwners.delete(owner);
throw error;
}
}
private async configureDiagnostics(record: BrowserRecord, enabled: boolean): Promise<void> {
if (record !== this.record || record.view.webContents.isDestroyed()) {
throw new AgentBrowserFault(
@@ -1520,6 +1630,56 @@ export class AgentBrowserModule {
return record;
}
private presentationSnapshot(
projectPath: string,
generation: number,
): AgentBrowserSnapshot | null {
const record = this.requireRecord(projectPath);
if (record.generation !== generation) {
throw new AgentBrowserFault(
'CLOSED',
'开发浏览器已切换到新的页面实例。',
true,
generation,
);
}
if (record.state === 'crashed') {
throw new AgentBrowserFault(
'RENDERER_CRASHED',
'开发浏览器页面进程已退出。',
true,
generation,
);
}
if (record.state === 'suspended_devtools') {
throw new AgentBrowserFault(
'DEVTOOLS_CONFLICT',
'请先关闭当前页面的原生 DevTools。',
true,
generation,
);
}
if (record.state === 'detached_fault') {
throw new AgentBrowserFault(
'DEBUGGER_BUSY',
'开发浏览器调试器尚未就绪。',
true,
generation,
);
}
if (record.state === 'closing') {
throw new AgentBrowserFault(
'CLOSED',
'开发浏览器正在关闭。',
true,
generation,
);
}
return record.state === 'attached' && record.visible && record.bounds
? this.snapshot(record)
: null;
}
private assertProject(record: BrowserRecord, projectPath: string): void {
if (!samePath(record.projectPath, normalizeRequiredPath(projectPath))) {
throw new AgentBrowserFault(
@@ -1541,6 +1701,7 @@ export class AgentBrowserModule {
const record = expected ?? this.record;
if (!record) {
this.notifyEventWaiters();
this.notifyPresentationWaiters();
return;
}
if (expected && this.record !== expected) return;
@@ -1555,6 +1716,7 @@ export class AgentBrowserModule {
});
this.removeListeners(record);
this.record = null;
this.notifyPresentationWaiters();
const interrupted = new AgentBrowserFault(
'CLOSED',
'开发浏览器已关闭。',
@@ -1580,6 +1742,7 @@ export class AgentBrowserModule {
this.adapter.destroy(record.view);
record.childSessions.clear();
record.ioHandles.clear();
record.diagnosticOwners.clear();
record.eventBuffer.clear();
this.payloadStore.clear();
this.notifyEventWaiters();
@@ -1852,6 +2015,10 @@ export class AgentBrowserModule {
for (const wake of waiters) wake();
}
private notifyPresentationWaiters(): void {
for (const wake of [...this.presentationWaiters]) wake();
}
private notifyLifecycle(event: AgentBrowserLifecycleEvent): void {
for (const listener of this.lifecycleListeners) {
try {