fix(extension): make tool cancellation cooperative (#89)

Tool cancellation is now cooperative end-to-end: the queue stays busy until compensation completes, cancellation is checked cooperatively across the pipeline, the fallback window is compensated too, and cleanup_failed is surfaced explicitly. Includes a gated delayed-compensation E2E test.
This commit is contained in:
NianJiu
2026-08-13 17:31:23 +08:00
committed by GitHub
parent 37fbaacc94
commit 4549ed0892
20 changed files with 904 additions and 214 deletions
@@ -75,6 +75,52 @@ describe("SessionManager", () => {
await expect(sm.start("aa11")).rejects.toThrow(/already exists/);
});
it("removes a newly created Agent Window when startup is aborted", async () => {
const aw = fakeAgentWindow();
let resolveCreate: (windowId: number) => void = () => {};
aw.createMock.mockImplementationOnce(
() =>
new Promise<number>((resolve) => {
resolveCreate = resolve;
}),
);
const sm = new SessionManager({ agentWindow: aw });
const controller = new AbortController();
const pending = sm.start("aa11", { signal: controller.signal });
controller.abort();
resolveCreate(777);
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
expect(aw.removeMock).toHaveBeenCalledWith(777);
expect(sm.has("aa11")).toBe(false);
});
it("removes an incomplete Agent Window when active-tab setup fails", async () => {
const aw = fakeAgentWindow();
aw.ensureActiveTabMock.mockRejectedValueOnce(new Error("tab setup failed"));
const sm = new SessionManager({ agentWindow: aw });
await expect(sm.start("aa11")).rejects.toThrow("tab setup failed");
expect(aw.removeMock).toHaveBeenCalledWith(100);
expect(sm.has("aa11")).toBe(false);
});
it("surfaces the orphan Agent Window id when startup cleanup fails", async () => {
const aw = fakeAgentWindow();
aw.ensureActiveTabMock.mockRejectedValueOnce(new Error("tab setup failed"));
aw.removeMock.mockRejectedValueOnce(new Error("window removal denied"));
const sm = new SessionManager({ agentWindow: aw });
await expect(sm.start("aa11")).rejects.toMatchObject({
name: "SessionStartCleanupError",
windowId: 100,
message: expect.stringMatching(/cleanup of Agent Window 100 failed.*window removal denied/),
});
expect(sm.has("aa11")).toBe(false);
});
it("stop() closes the Agent Window and forgets the session", async () => {
const aw = fakeAgentWindow();
const sm = new SessionManager({ agentWindow: aw });
@@ -43,12 +43,11 @@ export const chromeAgentWindowApi: AgentWindowApi = {
return win.id;
},
async remove(windowId: number): Promise<void> {
try {
await chrome.windows.remove(windowId);
} catch (err) {
// Window may have been closed by the user already; ignore.
console.debug("[bh] chrome.windows.remove failed", err);
}
// Callers decide whether a missing/failed removal is benign. In
// particular, transactional session-start cleanup must be able to
// surface a window it could not remove instead of reporting a false
// cancellation success while the Agent Window remains open.
await chrome.windows.remove(windowId);
},
async ensureActiveTab(windowId: number, url: string): Promise<void> {
const tabs = await chrome.tabs.query({ windowId });
+62 -12
View File
@@ -31,6 +31,38 @@ export interface SessionStartOptions {
size?: { width: number; height: number };
/** Defaults to true so existing clients keep visible Agent Windows. */
focused?: boolean;
/** Cancellation for the transactional Agent Window startup sequence. */
signal?: AbortSignal;
}
export class SessionStartCleanupError extends Error {
readonly windowId: number;
readonly startupError: unknown;
readonly cleanupError: unknown;
constructor(windowId: number, startupError: unknown, cleanupError: unknown) {
const startupMessage =
startupError instanceof Error ? startupError.message : String(startupError);
const cleanupMessage =
cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
super(
`session_start failed (${startupMessage}) and cleanup of Agent Window ${windowId} failed: ${cleanupMessage}`,
);
this.name = "SessionStartCleanupError";
this.windowId = windowId;
this.startupError = startupError;
this.cleanupError = cleanupError;
}
}
function sessionStartAbortError(): Error {
const error = new Error("session_start aborted");
error.name = "AbortError";
return error;
}
function throwIfSessionStartAborted(signal: AbortSignal | undefined): void {
if (signal?.aborted) throw sessionStartAbortError();
}
/**
@@ -140,18 +172,36 @@ export class SessionManager {
if (this.sessions.has(sessionId)) {
throw new Error(`[bh] session ${sessionId} already exists`);
}
const windowId = await this.agentWindow.create(AGENT_WINDOW_HOME, opts);
await this.agentWindow.ensureActiveTab(windowId, AGENT_WINDOW_HOME);
const ctx: SessionContext = {
sessionId,
agentWindowId: windowId,
refStore: new RefStore(),
borrowedTabs: new Map(),
createdAtMs: this.now(),
};
this.sessions.set(sessionId, ctx);
this.windowIndex.set(windowId, sessionId);
return ctx;
throwIfSessionStartAborted(opts.signal);
let windowId: number | null = null;
try {
const { signal: _signal, ...createOptions } = opts;
windowId = await this.agentWindow.create(AGENT_WINDOW_HOME, createOptions);
throwIfSessionStartAborted(opts.signal);
await this.agentWindow.ensureActiveTab(windowId, AGENT_WINDOW_HOME);
throwIfSessionStartAborted(opts.signal);
const ctx: SessionContext = {
sessionId,
agentWindowId: windowId,
refStore: new RefStore(),
borrowedTabs: new Map(),
createdAtMs: this.now(),
};
this.sessions.set(sessionId, ctx);
this.windowIndex.set(windowId, sessionId);
return ctx;
} catch (startupError) {
if (windowId !== null) {
try {
await this.agentWindow.remove(windowId);
} catch (cleanupError) {
throw new SessionStartCleanupError(windowId, startupError, cleanupError);
}
}
throw startupError;
}
}
/**