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
+9 -2
View File
@@ -36,7 +36,8 @@ export const findTool = lhTool({
name: "find",
description: `Search for files by glob pattern. Returns matching file paths relative to the search directory. Ignores node_modules, .git, and Library directories. Output is truncated to ${DEFAULT_LIMIT} results or ${formatSize(MAX_OUTPUT_BYTES)} (whichever is hit first).`,
schema: findInputSchema,
async execute(input) {
async execute(input, ctx) {
if (ctx?.signal?.aborted) return "Operation aborted";
const searchPath = resolveSourcePath(input.path);
let stat;
try {
@@ -49,7 +50,7 @@ export const findTool = lhTool({
const effectiveLimit = input.limit ?? DEFAULT_LIMIT;
const matches: string[] = [];
let limitReached = false;
const { timedOut } = await walkFiles(
const { timedOut, aborted } = await walkFiles(
searchPath,
(filePath) => {
const rel = path.relative(searchPath, filePath).split(path.sep).join("/");
@@ -61,8 +62,14 @@ export const findTool = lhTool({
matches.push(rel);
return false;
},
ctx?.signal,
);
if (aborted) {
return matches.length > 0
? `${matches.join("\n")}\n\n[search aborted — partial results]`
: "Search aborted before any results.";
}
if (matches.length === 0 && !limitReached) {
return timedOut
? `Search timed out after 10s with no matches yet. Narrow the search to a subdirectory.`