From 691be39c1b466666b5a2f757b12c1d8c8bcb5226 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:38:06 -0400 Subject: [PATCH] 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 (