From e488bf34ffba801bf2334cbacccee5351d963bf4 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:12:45 -0500 Subject: [PATCH 1/8] fix: cancel delegated browser observations --- apps/server/src/browser.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/server/src/browser.ts b/apps/server/src/browser.ts index 92a79f69..d0801c68 100644 --- a/apps/server/src/browser.ts +++ b/apps/server/src/browser.ts @@ -101,7 +101,7 @@ export class BrowserService { await this.db.put(owner, "browsers", session); return this.decorate(owner, session); } - async create(owner: string, url: string) { + async create(owner: string, url: string, signal?: AbortSignal) { const id = randomUUID(); // Record ownership before calling the worker, including when its response is lost. await this.db.put(owner, "browsers", { @@ -111,7 +111,7 @@ export class BrowserService { status: "idle", updatedAt: new Date().toISOString(), }); - return this.reopen(owner, id, url); + return this.reopen(owner, id, url, signal); } private async openOwned(owner: string, id: string, url?: string, signal?: AbortSignal) { const value = await this.get(owner, id); @@ -120,6 +120,7 @@ export class BrowserService { const response = await this.request("/sessions", { id, url: target }, signal); return await this.save(owner, await response.json(), id); } catch (error) { + signal?.throwIfAborted(); await this.save( owner, { ...value, url: target, status: "error", updatedAt: new Date().toISOString() }, @@ -128,8 +129,8 @@ export class BrowserService { throw error; } } - reopen(owner: string, id: string, url?: string) { - return this.serial(id, () => this.openOwned(owner, id, url)); + reopen(owner: string, id: string, url?: string, signal?: AbortSignal) { + return this.serial(id, () => this.openOwned(owner, id, url, signal)); } navigate(owner: string, id: string, url: string) { return this.reopen(owner, id, url); @@ -155,11 +156,13 @@ export class BrowserService { read(owner: string, id: string) { return this.serial(id, () => this.readOwned(owner, id)); } - async observe(owner: string, url: string, existingId?: string) { - const id = existingId ?? (await this.create(owner, url)).id; + async observe(owner: string, url: string, existingId?: string, signal?: AbortSignal) { + const id = existingId ?? (await this.create(owner, url, signal)).id; return this.serial(id, async () => { - if (existingId) await this.openOwned(owner, id, url); - return { sessionId: id, ...(await this.readOwned(owner, id)) }; + signal?.throwIfAborted(); + if (existingId) await this.openOwned(owner, id, url, signal); + signal?.throwIfAborted(); + return { sessionId: id, ...(await this.readOwned(owner, id, signal)) }; }); } async observeForThread(owner: string, threadId: string, url: string, signal?: AbortSignal) { From 1405cafd96a0111811bd3c19bfb2008fc2b90f54 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:12:47 -0500 Subject: [PATCH 2/8] fix: thread task abort into browser reads --- apps/server/src/engine/model.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/server/src/engine/model.ts b/apps/server/src/engine/model.ts index dfd3d8ff..9d5ec51f 100644 --- a/apps/server/src/engine/model.ts +++ b/apps/server/src/engine/model.ts @@ -172,6 +172,7 @@ export async function executeModelTask( owner, url, typeof task.state.browserId === "string" ? task.state.browserId : undefined, + ctx.signal, ); task = await ctx.checkpoint({ state: { ...task.state, browserId: page.sessionId }, From 63ca86cf94855cefcfffd8b8e03235afa2d58a9a Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:12:49 -0500 Subject: [PATCH 3/8] fix: cancel monitor browser observations --- apps/server/src/engine/service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/server/src/engine/service.ts b/apps/server/src/engine/service.ts index 8b49d254..54556033 100644 --- a/apps/server/src/engine/service.ts +++ b/apps/server/src/engine/service.ts @@ -1007,6 +1007,7 @@ export class AgentService { owner, monitor.url, typeof task.state.sessionId === "string" ? task.state.sessionId : undefined, + ctx.signal, ); } const text = observation.text.replace(/\s+/g, " ").trim(); From 315247d70797ac8a17b55835041728d1cec59c61 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:13:13 -0500 Subject: [PATCH 4/8] test: stop cancelled browser observation before read --- tests/browser.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/browser.test.ts b/tests/browser.test.ts index 2a3add1c..4ac7b792 100644 --- a/tests/browser.test.ts +++ b/tests/browser.test.ts @@ -158,6 +158,24 @@ test("browser observations reuse an owned profile and reject unowned reads", asy assert.equal(fresh.text, read.text); }); + + +test("cancelled task-style browser observations stop before a follow-up read", async (t) => { + const controller = new AbortController(); + const calls: string[] = []; + const { db, service } = await browserFixture(t, (path, body) => { + calls.push(path); + controller.abort(); + return { data: { ...savedSession, id: body.id, url: body.url } }; + }); + await assert.rejects(service.observe("owner", savedSession.url, undefined, controller.signal), { + name: "AbortError", + }); + assert.deepEqual(calls, ["/sessions"]); + const [saved] = await db.list("owner", "browsers"); + assert.equal(saved?.status, "idle", "cancellation must not persist a browser failure"); +}); + test("chat browser reads reuse a persisted owned profile across turns and service restarts", async (t) => { const calls: { path: string; body: Record }[] = []; let currentUrl = savedSession.url; From f4ae5cda9968e2bc08866b00ddc8e1ca851380a4 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:13:16 -0500 Subject: [PATCH 5/8] test: cover delegated read_web cancellation --- tests/model-worker.test.ts | 78 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/tests/model-worker.test.ts b/tests/model-worker.test.ts index 6194a98d..ecc47388 100644 --- a/tests/model-worker.test.ts +++ b/tests/model-worker.test.ts @@ -1,5 +1,7 @@ import assert from "node:assert/strict"; +import { once } from "node:events"; import { mkdtemp, rm } from "node:fs/promises"; +import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; @@ -164,3 +166,79 @@ test("the model worker keeps the text a model replies with when it calls no tool await rm(directory, { recursive: true, force: true }); } }); + + +test("aborting delegated read_web stops before page read and requeues the task", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "openmuse-model-browser-abort-")); + const db = await createStore(); + const browserCalls: string[] = []; + let resolveNavigation!: () => void; + const navigationStarted = new Promise((resolve) => { + resolveNavigation = resolve; + }); + const browserWorker = createServer(async (request, response) => { + for await (const _chunk of request) { + // Drain the request body before holding the navigation response open. + } + const path = request.url ?? ""; + browserCalls.push(path); + if (path === "/sessions") { + resolveNavigation(); + return; + } + response.writeHead(200, { "content-type": "application/json" }); + response.end( + JSON.stringify({ + url: "https://example.org/", + title: "Observed", + text: "This read must never happen after abort.", + truncated: false, + }), + ); + }); + browserWorker.listen(0, "127.0.0.1"); + await once(browserWorker, "listening"); + const address = browserWorker.address(); + assert.ok(address && typeof address !== "string"); + + await modelFixture(t, (index) => + index === 0 + ? { name: "read_web", arguments: { url: "https://example.org/" } } + : undefined, + ); + const server = await createApp(db, { + mode: "sample", + port: 8787, + host: "127.0.0.1", + publicUrl: "http://localhost:8787", + dataDir: directory, + agentBackend: "model", + intelligenceApiKey: "test-project-key-never-sent", + model: "openai/fixture", + googleRedirectUri: "http://localhost:8787/api/google/callback", + allowedOrigins: [], + workerUrl: `http://127.0.0.1:${address.port}`, + workerToken: "test-worker-token-at-least-32-characters", + }); + + try { + const task = await server.agent.createTask("owner", { + prompt: "Read the example page and summarize it", + }); + const tick = server.agent.worker.tick(); + await navigationStarted; + server.agent.worker.abort(task.id); + await tick; + + const settled = await server.agent.getTask("owner", task.id); + assert.equal(settled.status, "queued", settled.error ?? settled.result); + assert.equal(settled.error, null); + assert.deepEqual(browserCalls, ["/sessions"]); + } finally { + await server.agent.stop(); + browserWorker.closeAllConnections(); + await new Promise((resolve) => browserWorker.close(() => resolve())); + await db.close(); + await rm(directory, { recursive: true, force: true }); + } +}); From 29f498526512b0b1d9e3383e11031cf9df653d2b Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:13:43 -0500 Subject: [PATCH 6/8] test: normalize cancellation test formatting --- tests/browser.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/browser.test.ts b/tests/browser.test.ts index 4ac7b792..46108bd9 100644 --- a/tests/browser.test.ts +++ b/tests/browser.test.ts @@ -158,8 +158,6 @@ test("browser observations reuse an owned profile and reject unowned reads", asy assert.equal(fresh.text, read.text); }); - - test("cancelled task-style browser observations stop before a follow-up read", async (t) => { const controller = new AbortController(); const calls: string[] = []; From 1f160ebbae9d4bedc7531280000ea44ce756ad75 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:13:45 -0500 Subject: [PATCH 7/8] test: normalize cancellation test formatting --- tests/model-worker.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/model-worker.test.ts b/tests/model-worker.test.ts index ecc47388..1021ed44 100644 --- a/tests/model-worker.test.ts +++ b/tests/model-worker.test.ts @@ -167,7 +167,6 @@ test("the model worker keeps the text a model replies with when it calls no tool } }); - test("aborting delegated read_web stops before page read and requeues the task", async (t) => { const directory = await mkdtemp(join(tmpdir(), "openmuse-model-browser-abort-")); const db = await createStore(); From 690e0e32046adfe77896f1f2582aa5627d53e4d7 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:02:30 -0500 Subject: [PATCH 8/8] test: format delegated browser cancellation regression --- tests/model-worker.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/model-worker.test.ts b/tests/model-worker.test.ts index 1021ed44..7080527c 100644 --- a/tests/model-worker.test.ts +++ b/tests/model-worker.test.ts @@ -201,9 +201,7 @@ test("aborting delegated read_web stops before page read and requeues the task", assert.ok(address && typeof address !== "string"); await modelFixture(t, (index) => - index === 0 - ? { name: "read_web", arguments: { url: "https://example.org/" } } - : undefined, + index === 0 ? { name: "read_web", arguments: { url: "https://example.org/" } } : undefined, ); const server = await createApp(db, { mode: "sample",