fix(tools): the abort cascade — kill running tool calls, honest activity labels

Root cause of the live-test bugs (2026-09-19): pi passes every run's
AbortSignal to tool execute, but toPiTool dropped it — agent.abort()
only landed at the next step boundary, so a long local grep held its
turn hostage until the dev server's memory restart killed the run
mid-tool, freezing the record at its write-ahead state (the 'went
back in time' rollback).

- lh-tool: execute ctx gains signal (parent + timeout combined); every
  call races a 30s system-level timeout with a 250ms cooperative grace —
  abort kills a stuck tool but lets a poll-the-signal tool deliver its
  partial results. Pre-aborted signals never fire listeners: checked
  upfront. toolTimeoutMs is the test seam (pdfModelOverride idiom).
- fs-shared: walkFiles returns {timedOut, aborted} instead of throwing —
  abort yields partial results.
- grep: the scan phase was unbounded (the walk's 10s deadline only
  bounded collection) — same budget now checked per file; find/grep
  check the signal and return partials with a notice.
- pending/chat: streamingToolActivity names the ACTUAL running tool —
  a part sits input-streaming through its whole execution, so the old
  shimmer called minutes-long greps 'Writing a question…'.

Tests: toPiTool guard (forward, abort, timeout, grace, question
bypass, ToolInputError passthrough), walk mid-abort, find/grep
pre-abort + mid-run abort, activity labels. Gates: 617/617.
This commit is contained in:
amosblomqvist
2026-09-19 17:26:57 +03:00
parent 1b9122c88b
commit 36c5d19437
9 changed files with 430 additions and 21 deletions
+58 -1
View File
@@ -4,7 +4,7 @@ import * as os from "node:os";
import * as path from "node:path";
import { describe, it } from "node:test";
import { expandBraces, globToRegex, matchGlob } from "../src/agent/tools/fs-shared.ts";
import { expandBraces, globToRegex, matchGlob, walkFiles } from "../src/agent/tools/fs-shared.ts";
import { findTool } from "../src/agent/tools/find.ts";
import { grepTool } from "../src/agent/tools/grep.ts";
import { lsTool } from "../src/agent/tools/ls.ts";
@@ -186,3 +186,60 @@ describe("read (home-anchored re-port)", () => {
assert.match(out, /Eigenvectors keep their direction/);
});
});
describe("abort plumbing (the 2026-09-19 cascade fix)", () => {
it("walkFiles stops promptly when the signal aborts mid-walk", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "lh-fs-abort-"));
for (let i = 0; i < 20; i++) await fs.writeFile(path.join(root, `f${i}.txt`), "needle\n");
const ac = new AbortController();
let visited = 0;
const result = await walkFiles(
root,
() => {
visited++;
if (visited === 3) ac.abort(); // deterministic: abort from inside the walk
return false;
},
ac.signal,
);
assert.equal(result.aborted, true);
assert.equal(result.timedOut, false);
assert.ok(visited < 20, "the walk stopped early");
});
it("find honors a pre-aborted signal without walking", async () => {
FIXTURE = await setup();
const ac = new AbortController();
ac.abort();
const out = (await findTool.execute!({ pattern: "*.md" }, { toolCallId: "c", signal: ac.signal })) as string;
assert.equal(out, "Operation aborted");
});
it("grep honors a pre-aborted signal without scanning", async () => {
const ac = new AbortController();
ac.abort();
const out = (await grepTool.execute!({ pattern: "eigenvector" }, { toolCallId: "c", signal: ac.signal })) as string;
assert.equal(out, "Operation aborted");
});
it("grep aborts mid-run with partial results, not a hang", async () => {
// The exact shape of the 2.7-minute lesson: a tree big enough that
// the walk+scan outlast an abort mid-flight. Whichever phase the
// abort lands in, the tool returns promptly with an abort notice —
// never "No matches found", never a hang.
const root = await fs.mkdtemp(path.join(os.tmpdir(), "lh-fs-midabort-"));
await fs.mkdir(path.join(root, "deep"), { recursive: true });
for (let i = 0; i < 200; i++) {
await fs.writeFile(path.join(root, i % 2 ? `f${i}.txt` : `deep/f${i}.txt`), "needle line\n");
}
const ac = new AbortController();
setTimeout(() => ac.abort(), 1);
const started = Date.now();
const out = (await grepTool.execute!(
{ pattern: "needle", path: root },
{ toolCallId: "c", signal: ac.signal },
)) as string;
assert.match(out, /aborted/);
assert.ok(Date.now() - started < 5_000, "returned promptly");
});
});