From 691be39c1b466666b5a2f757b12c1d8c8bcb5226 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:38:06 -0400 Subject: [PATCH 1/5] fix(tools): stop cancelled calls before execution and discard late results --- CHANGELOG.md | 2 ++ docs/CURRENT-STATE.md | 5 +++ src/tools/catalog-cancellation.test.mjs | 41 +++++++++++++++++++++++++ src/tools/catalog.js | 2 ++ 4 files changed, 50 insertions(+) create mode 100644 src/tools/catalog-cancellation.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7acdedf6b..33ebae897 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is unchanged; extra cameras share it round-robin with the other packs. +- Cancelled tool calls refuse actions before execution and discard results that settle after cancellation. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c77d1d8e3..47ce15815 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5310,3 +5310,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(tools): stop cancelled calls before execution and discard late results + +Check cancellation before invoking the tool and after it resolves, including nested calls that share the same signal. This does not promise rollback of an action already completed. diff --git a/src/tools/catalog-cancellation.test.mjs b/src/tools/catalog-cancellation.test.mjs new file mode 100644 index 000000000..a374bd71e --- /dev/null +++ b/src/tools/catalog-cancellation.test.mjs @@ -0,0 +1,41 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { composeCatalog, defineTool } from './catalog.js'; +test('cancelled catalog calls cannot execute or return late results', async () => { + let calls = 0, + release, + block = false; + const action = defineTool({ + name: 'fixture_action', + kind: 'action', + title: 'Fixture', + description: 'Offline fixture', + inputSchema: { type: 'object' }, + run: async () => { + calls++; + return block + ? new Promise((r) => (release = r)) + : { summary: 'fixture', data: {} }; + }, + }); + const catalog = composeCatalog({ tools: [action] }); + const c = new AbortController(); + c.abort(); + await assert.rejects( + catalog.call('fixture_action', {}, { signal: c.signal }), + { name: 'AbortError' }, + ); + assert.equal(calls, 0); + block = true; + const running = new AbortController(); + const pending = catalog.call( + 'fixture_action', + {}, + { signal: running.signal }, + ); + while (!release) await Promise.resolve(); + running.abort(); + release({ summary: 'late', data: {} }); + await assert.rejects(pending, { name: 'AbortError' }); + assert.equal(calls, 1); +}); diff --git a/src/tools/catalog.js b/src/tools/catalog.js index 4d5106b6e..6faf82ce5 100644 --- a/src/tools/catalog.js +++ b/src/tools/catalog.js @@ -178,6 +178,7 @@ export function composeCatalog({ return chain({ tool, args, signal, ...(parent ? { parent } : {}) }); }; const execute = async ({ tool, args, signal }) => { + signal?.throwIfAborted(); const problems = validateValue(tool.inputSchema, args); if (problems.length) throw new ToolError('invalid_arguments', problems.join('; ')); @@ -194,6 +195,7 @@ export function composeCatalog({ } catch (error) { throw fromSourceError(error); } + signal?.throwIfAborted(); if (!result || typeof result.summary !== 'string' || !result.data) throw new TypeError(`${tool.name} returned no summary or data`); if ( From 0796efe603663237b8a4f2f3a7ac50d59e727d8c Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:12:02 -0400 Subject: [PATCH 2/5] docs: address review scope and feature placement for #1031 --- docs/CURRENT-STATE.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 47ce15815..709613bad 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -97,6 +97,11 @@ lists at 25 rows by default. Voice offers the same queries next to its app actions: the session lists them, and the browser runs them through the same catalog, loaded on first use. See [tools and the MCP server](TOOLS.md). +Catalog calls check their cancellation signal before tool execution and after +the tool resolves, including nested calls. Cancellation discards late catalog +results without promising rollback of completed actions or invalidating correct +static-source caches. + ## Cyber HUD — September 30, 2026 Display > HUD > Layout includes Cyber, also available through the HUD voice @@ -5310,8 +5315,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(tools): stop cancelled calls before execution and discard late results - -Check cancellation before invoking the tool and after it resolves, including nested calls that share the same signal. This does not promise rollback of an action already completed. From fffbe67b6685ad37e6c31eeebdce2a41b1eab975 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:23:37 -0400 Subject: [PATCH 3/5] fix(tools): reject cancellation after async interception --- src/tools/catalog-cancellation.test.mjs | 37 +++++++++++++++++++++++++ src/tools/catalog.js | 12 ++++++-- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/tools/catalog-cancellation.test.mjs b/src/tools/catalog-cancellation.test.mjs index a374bd71e..a4a2b7c9d 100644 --- a/src/tools/catalog-cancellation.test.mjs +++ b/src/tools/catalog-cancellation.test.mjs @@ -39,3 +39,40 @@ test('cancelled catalog calls cannot execute or return late results', async () = await assert.rejects(pending, { name: 'AbortError' }); assert.equal(calls, 1); }); + +test('cancellation during an awaited interceptor cannot publish its late result', async () => { + const controller = new AbortController(); + const reason = new Error('interceptor caller canceled'); + let calls = 0; + const action = defineTool({ + name: 'fixture_intercepted_action', + kind: 'action', + title: 'Fixture', + description: 'Offline fixture', + inputSchema: { type: 'object' }, + run: async () => { + calls++; + return { summary: 'completed action', data: {} }; + }, + }); + const catalog = composeCatalog({ + tools: [action], + interceptors: [ + async (call, next) => { + const result = await next(call); + await Promise.resolve(); + controller.abort(reason); + return result; + }, + ], + }); + await assert.rejects( + catalog.call( + 'fixture_intercepted_action', + {}, + { signal: controller.signal }, + ), + (error) => error === reason, + ); + assert.equal(calls, 1, 'completed actions are not rolled back or rerun'); +}); diff --git a/src/tools/catalog.js b/src/tools/catalog.js index 6faf82ce5..ea0e37460 100644 --- a/src/tools/catalog.js +++ b/src/tools/catalog.js @@ -169,13 +169,21 @@ export function composeCatalog({ ); const index = new Map(available.map((tool) => [tool.name, tool])); - const invoke = (name, args, signal, parent) => { + const invoke = async (name, args, signal, parent) => { + signal?.throwIfAborted(); const tool = index.get(name); if (!tool) return Promise.reject( new ToolError('unsupported', `No tool named ${name} is available`), ); - return chain({ tool, args, signal, ...(parent ? { parent } : {}) }); + const result = await chain({ + tool, + args, + signal, + ...(parent ? { parent } : {}), + }); + signal?.throwIfAborted(); + return result; }; const execute = async ({ tool, args, signal }) => { signal?.throwIfAborted(); From aacb409fbf74a8add2c6b57ab9a2c4326f8a7376 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:32:52 -0400 Subject: [PATCH 4/5] fix(tools): limit cancellation guard to pre-execution --- CHANGELOG.md | 2 + docs/CURRENT-STATE.md | 7 +- src/tools/catalog-cancellation.test.mjs | 117 +++++++++--------------- src/tools/catalog.js | 13 +-- 4 files changed, 52 insertions(+), 87 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33ebae897..21b296fdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ - Cancelled tool calls refuse actions before execution and discard results that settle after cancellation. +- Cancelled catalog queries refuse execution when their signal is already aborted. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 709613bad..648fa0584 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -97,10 +97,9 @@ lists at 25 rows by default. Voice offers the same queries next to its app actions: the session lists them, and the browser runs them through the same catalog, loaded on first use. See [tools and the MCP server](TOOLS.md). -Catalog calls check their cancellation signal before tool execution and after -the tool resolves, including nested calls. Cancellation discards late catalog -results without promising rollback of completed actions or invalidating correct -static-source caches. +Catalog calls check their cancellation signal immediately before executing a +tool. Already-cancelled queries do not start; cancellation after execution starts +continues to follow the existing tool and caller behavior. ## Cyber HUD — September 30, 2026 diff --git a/src/tools/catalog-cancellation.test.mjs b/src/tools/catalog-cancellation.test.mjs index a4a2b7c9d..0f666b262 100644 --- a/src/tools/catalog-cancellation.test.mjs +++ b/src/tools/catalog-cancellation.test.mjs @@ -1,78 +1,51 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { composeCatalog, defineTool } from './catalog.js'; -test('cancelled catalog calls cannot execute or return late results', async () => { - let calls = 0, - release, - block = false; - const action = defineTool({ - name: 'fixture_action', - kind: 'action', - title: 'Fixture', - description: 'Offline fixture', - inputSchema: { type: 'object' }, - run: async () => { - calls++; - return block - ? new Promise((r) => (release = r)) - : { summary: 'fixture', data: {} }; - }, - }); - const catalog = composeCatalog({ tools: [action] }); - const c = new AbortController(); - c.abort(); - await assert.rejects( - catalog.call('fixture_action', {}, { signal: c.signal }), - { name: 'AbortError' }, - ); - assert.equal(calls, 0); - block = true; - const running = new AbortController(); - const pending = catalog.call( - 'fixture_action', - {}, - { signal: running.signal }, - ); - while (!release) await Promise.resolve(); - running.abort(); - release({ summary: 'late', data: {} }); - await assert.rejects(pending, { name: 'AbortError' }); - assert.equal(calls, 1); -}); -test('cancellation during an awaited interceptor cannot publish its late result', async () => { - const controller = new AbortController(); - const reason = new Error('interceptor caller canceled'); - let calls = 0; - const action = defineTool({ - name: 'fixture_intercepted_action', - kind: 'action', - title: 'Fixture', - description: 'Offline fixture', - inputSchema: { type: 'object' }, - run: async () => { - calls++; - return { summary: 'completed action', data: {} }; - }, - }); - const catalog = composeCatalog({ - tools: [action], - interceptors: [ - async (call, next) => { - const result = await next(call); - await Promise.resolve(); - controller.abort(reason); - return result; +for (const intercept of [false, true]) { + test(`cancelled query refuses execution${intercept ? ' after an awaited interceptor' : ''}`, async () => { + let calls = 0; + const controller = new AbortController(); + const query = defineTool({ + name: 'fixture_query', + kind: 'query', + title: 'Fixture', + description: 'Offline fixture', + inputSchema: { type: 'object' }, + run: async () => { + calls++; + return { summary: 'fixture', data: {} }; }, - ], + }); + const catalog = composeCatalog({ + tools: [query], + interceptors: intercept + ? [ + async (call, next) => { + await Promise.resolve(); + controller.abort(); + return next(call); + }, + ] + : [], + }); + if (!intercept) controller.abort(); + await assert.rejects( + catalog.call('fixture_query', {}, { signal: controller.signal }), + { name: 'AbortError' }, + ); + assert.equal(calls, 0); + const fresh = composeCatalog({ tools: [query] }); + assert.equal( + ( + await fresh.call( + 'fixture_query', + {}, + { signal: new AbortController().signal }, + ) + ).summary, + 'fixture', + ); + assert.equal(calls, 1); }); - await assert.rejects( - catalog.call( - 'fixture_intercepted_action', - {}, - { signal: controller.signal }, - ), - (error) => error === reason, - ); - assert.equal(calls, 1, 'completed actions are not rolled back or rerun'); -}); +} diff --git a/src/tools/catalog.js b/src/tools/catalog.js index ea0e37460..d617a9f28 100644 --- a/src/tools/catalog.js +++ b/src/tools/catalog.js @@ -169,21 +169,13 @@ export function composeCatalog({ ); const index = new Map(available.map((tool) => [tool.name, tool])); - const invoke = async (name, args, signal, parent) => { - signal?.throwIfAborted(); + const invoke = (name, args, signal, parent) => { const tool = index.get(name); if (!tool) return Promise.reject( new ToolError('unsupported', `No tool named ${name} is available`), ); - const result = await chain({ - tool, - args, - signal, - ...(parent ? { parent } : {}), - }); - signal?.throwIfAborted(); - return result; + return chain({ tool, args, signal, ...(parent ? { parent } : {}) }); }; const execute = async ({ tool, args, signal }) => { signal?.throwIfAborted(); @@ -203,7 +195,6 @@ export function composeCatalog({ } catch (error) { throw fromSourceError(error); } - signal?.throwIfAborted(); if (!result || typeof result.summary !== 'string' || !result.data) throw new TypeError(`${tool.name} returned no summary or data`); if ( From 09018ac753e7edabc94eed09094267d591ee6fa4 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 14 Oct 2026 15:18:15 -0400 Subject: [PATCH 5/5] docs: drop superseded CHANGELOG bullet The review-driven rewrite added a narrower entry but left the original bullet in place, so the PR carried two entries for one change. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21b296fdf..0a761a798 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,8 +15,6 @@ the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is unchanged; extra cameras share it round-robin with the other packs. -- Cancelled tool calls refuse actions before execution and discard results that settle after cancellation. - - Cancelled catalog queries refuse execution when their signal is already aborted. - Treat explicitly current, complete empty vessel coverage as a successful update,