Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Each ADR includes:

| ID | Title | Status |
|---|---|---|
| tool-abort-abandons-host-call | [A user stop ends the turn even when the running tool cannot be cancelled](tool-abort-abandons-host-call.md) | Accepted for implementation |
| provider-metadata-client-certificate-retry | [Provider metadata recovery from optional client certificates](provider-metadata-client-certificate-retry.md) | Implemented candidate |
| device-local-project-lists-in-config-sync | [Device-local project lists in configuration sync](device-local-project-lists-in-config-sync.md) | Accepted |
| s3-only-configuration-backup-sync | [S3-only encrypted configuration backup and synchronization](s3-only-configuration-backup-sync.md) | Accepted; supersedes WebDAV decisions in 0300 / 0301 |
Expand Down
83 changes: 83 additions & 0 deletions docs/adr/tool-abort-abandons-host-call.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# ADR: A user stop ends the turn even when the running tool cannot be cancelled

- Status: Accepted for implementation
- Date: 2026-10-20
- Issues: #1767
- Amends: spec 03-runtime/03-tools-and-permissions.md §3

## Context

The runtime registered an abort listener for every tool call but returned
immediately unless the tool was `Bash` or `GenerateImages`. For every other
tool — `Grep`, `Read`, `Glob`, `WebFetch`, plugin tools — the listener was
inert, and `tools.execute` was awaited with no deadline and no race against the
signal. The agent loop only re-checks `signal.aborted` between tool calls, so a
single long call held the turn, and the session, hostage: one report measured a
`Grep` that ran 21m39s after the user clicked Stop six times, with the abort
applying in the same millisecond the tool finally returned.

Three facts constrain the fix:

1. `tools.abort` only cancels what host-core can cancel: it clears a pending
approval and kills a `Bash` / `GenerateImages` process. Every other tool runs
to completion inside the Rust host whatever the runtime sends.
2. A `tools.abort` for a tool call id that has no active cancellation registers
a 60s tombstone in `pending_bash_aborts`, which a later `Bash` reusing that
id would inherit as an immediate kill.
3. `TOOL_ABORTED` already means "the tool was cancelled by a user stop or a turn
abort before it finished" (spec 03-runtime/08-error-codes.md), so the runtime
may report that outcome without host cooperation.

## Decision

The abort listener now applies to every tool. For `Bash` and `GenerateImages` it
keeps sending `tools.abort` and waits a bounded grace window (10s) for the host
to return the output it collected before the kill. For every other tool it stops
waiting the moment the signal fires, without sending `tools.abort`, and the call
rejects with `TOOL_ABORTED`.

In both cases the wait is a `Promise.race` against the execute call. Losing the
race abandons the wait, not the host call: the orphaned `tools.execute` settles
on its own, its result is discarded, and the runtime never surfaces it.

The runtime also drives the tools from `abort()` itself, not only from the
signal each tool was handed. `abort()` walks the in-flight tool calls and runs
the same handler the signal would. Relying on the signal alone was not enough:
a stop can reach the runtime without reaching the tool, and then `abort()` stays
blocked on the turn settlement, which keeps the `agent.abort` RPC pending. The
renderer awaits that RPC before it clears "processing", so the session stayed
pinned even after the turn was over in the ledger. One field report showed
exactly that: three turns recorded `aborted` while the tool's process was still
alive minutes later.

## Consequences

- Stop always ends the turn, so a stuck tool can no longer block new turns,
compaction, or session switching.
- A stop releases every in-flight tool, including one whose own abort signal
never fired, so the stop RPC always returns and the renderer always leaves
"processing".
- A non-cancellable tool keeps consuming host resources until it finishes. That
is honest residual work, not a stuck UI, and it is why the durable fix stays
with host-core: teach `tools.execute` an abort token so `Grep` and friends can
actually be cancelled.
- Abandoning a call means the runtime can no longer observe its result. A retry
must carry a fresh tool call id: reusing the abandoned id would race a second
execution against the first and, for `Bash`, could pick up a cancellation
tombstone meant for the abandoned call. This is also why non-cancellable tools
do not send `tools.abort` — the RPC cannot help them, and the tombstone it
leaves behind can only hurt.
- Progress output for an abandoned call stops at the moment of the stop.

## Alternatives

- **Send `tools.abort` for every tool.** Cancels pending approvals for
non-cancellable tools, but leaves a 60s tombstone per aborted call that a
later `Bash` could inherit. Rejected as a side effect with no benefit for the
in-flight call.
- **Only arm a grace timer on abort (no race for cancellable tools).** Simpler,
but leaves every non-cancellable tool waiting the full window and still keeps
the turn blocked for that window.
- **Wait for host-core to grow cancellation for every tool.** Correct durable
fix, tracked separately; it cannot ship as a bug fix for a turn that is stuck
today.
12 changes: 12 additions & 0 deletions docs/spec/03-runtime/03-tools-and-permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,18 @@ result. Options may be plain strings or `{ label, description? }` objects; the
selected label remains the answer value. Stopping the turn resolves outstanding
questions as skipped.

A stop ends the turn even when the running tool cannot be cancelled (ADR
tool-abort-abandons-host-call). `Bash` and `GenerateImages` are cancelled through
`tools.abort`, and the runtime waits a bounded 10s grace window for the host to
return the output it collected before the kill. Every other tool has no
host-side cancellation: the runtime stops waiting the moment the signal fires,
returns `TOOL_ABORTED`, and leaves the host call to settle on its own with its
result discarded. Abandoned calls keep consuming host resources until they
finish, and a retry must carry a fresh tool call id. The stop releases the
in-flight tools directly as well, so it still ends the turn when a tool's own
abort signal never fires; the stop RPC must return even in that case, because
the renderer waits for it before it leaves the running state.

`JevClassify` is an external provider operation rather than a workspace
execution tool. It validates its structured request, observes cancellation,
uses a bounded provider timeout, and returns typed classifier results; it has
Expand Down
97 changes: 97 additions & 0 deletions packages/agent-runtime/src/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1845,6 +1845,103 @@ describe("DesktopAgentRuntime configuration matching", () => {
await expect(pending).rejects.toThrow("tools.abort protocol failure");
await runtime.dispose();
});

// #1767: host-core only cancels Bash and GenerateImages, so a stop used to
// leave the turn waiting for every other tool to return on its own.
it("ends the turn when a stop lands while a tool host-core cannot cancel runs", async () => {
let finishExecution!: (value: { ok: boolean; content: unknown }) => void;
const execution = new Promise<{ ok: boolean; content: unknown }>((resolve) => {
finishExecution = resolve;
});
const host = {
call: vi.fn((method: string) =>
method === "tools.execute"
? execution
: Promise.resolve({ ok: true, content: "done" }),
),
onNotification: vi.fn(() => () => undefined),
};
const runtime = createRuntime({ host });
const grep = (runtime as any).toolCatalog.get("Grep");
const controller = new AbortController();
const pending = grep.execute("grep-stop", { query: "needle" }, controller.signal);
await new Promise<void>((resolve) => setImmediate(resolve));

controller.abort();

// The Grep is still running in host-core; only this turn stops waiting.
await expect(pending).rejects.toMatchObject({ errorCode: "TOOL_ABORTED" });
expect(
host.call.mock.calls.filter((call: unknown[]) => call[0] === "tools.abort"),
).toHaveLength(0);
// The orphaned call settling later must not resurface as an unhandled
// rejection or a second tool result.
finishExecution({ ok: true, content: "late result" });
await runtime.dispose();
});

it("stops waiting for a Bash call that ignores tools.abort once the grace window closes", async () => {
vi.useFakeTimers();
try {
const host = {
call: vi.fn((method: string) =>
method === "tools.execute"
? new Promise<never>(() => undefined)
: Promise.resolve({ ok: true, content: "abort requested" }),
),
onNotification: vi.fn(() => () => undefined),
};
const runtime = createRuntime({ host });
const bash = (runtime as any).toolCatalog.get("Bash");
const controller = new AbortController();
const pending = bash.execute(
"bash-hung",
{ command: "sleep 600" },
controller.signal,
);
await vi.advanceTimersByTimeAsync(0);

controller.abort();
const assertion = expect(pending).rejects.toMatchObject({
errorCode: "TOOL_ABORTED",
});
await vi.advanceTimersByTimeAsync(10_000);
await assertion;
expect(host.call).toHaveBeenCalledWith("tools.abort", {
sessionId: "session-1",
toolCallId: "bash-hung",
});

await runtime.dispose();
} finally {
vi.useRealTimers();
}
});

it("releases an in-flight tool when the stop lands without the tool signal firing", async () => {
const host = {
call: vi.fn((method: string) =>
method === "tools.execute"
? new Promise<never>(() => undefined)
: Promise.resolve({ ok: true, content: "abort requested" }),
),
onNotification: vi.fn(() => () => undefined),
};
const runtime = createRuntime({ host });
const grep = (runtime as any).toolCatalog.get("Grep");
// A signal that never fires stands in for the field case: the stop reached
// the runtime but not the tool, so the executor kept awaiting a host call
// that would not return and the abort RPC never came back.
const silent = new AbortController();
const pending = grep.execute("grep-silent", { query: "needle" }, silent.signal);
await new Promise<void>((resolve) => setImmediate(resolve));

await runtime.abort();

await expect(pending).rejects.toMatchObject({ errorCode: "TOOL_ABORTED" });
expect(silent.signal.aborted).toBe(false);
await runtime.dispose();
});
});

describe("DesktopAgentRuntime live activity", () => {
Expand Down
97 changes: 84 additions & 13 deletions packages/agent-runtime/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1262,6 +1262,13 @@ const TOOL_PARAM_ALIASES: Record<string, Record<string, string>> = {
Grep: { query: "pattern" },
};
const TOOL_OUTPUT_UPDATE_THROTTLE_MS = 100;
/**
* How long a stop request waits for a tool that host-core can actually
* interrupt (`Bash`, `GenerateImages`) to hand back the output it collected
* before the kill (#1767). Tools without host-side cancellation do not wait at
* all: the runtime stops waiting the moment the signal fires.
*/
const TOOL_ABORT_GRACE_MS = 10_000;
const MAX_TOOL_PROGRESS_CHARS = 64 * 1024;
const TOOL_PROGRESS_TRUNCATION_MARKER =
"\n\n[tool output progress truncated]\n\n";
Expand Down Expand Up @@ -2005,6 +2012,12 @@ export class DesktopAgentRuntime {
private contextReminderClaimed = false;
private contextFallbackReminderClaimed = false;
private activeToolProgressCleanups = new Set<(flush: boolean) => void>();
/**
* Abort handlers for the tool calls currently awaiting `tools.execute`.
* `abort()` drives these directly: a tool must be released even when the
* signal it was handed never fires, or the stop RPC stays pending forever.
*/
private activeToolAborts = new Set<() => void>();
private hostCloseUnsubscribe?: () => void;
private turnSubagentUsage?: MessageUsage;
private compactionRequestShape?: CompactionRequestShape;
Expand Down Expand Up @@ -3466,21 +3479,51 @@ Do not invent objections or turn speculative risks into blockers. Stop when the
scheduleProgress();
})
: undefined;
// #1767: host-core only honours `tools.abort` for `Bash` and
// `GenerateImages` — it kills the process and answers the in-flight
// execute with `TOOL_ABORTED`. Every other tool keeps running to
// completion whatever the runtime sends, so awaiting it blocked the
// turn long after the user pressed Stop. Those tools therefore abandon
// the wait locally the instant the signal fires; the orphaned host call
// settles on its own and its result is discarded.
const hostAbortsTool = isBash || toolName === "GenerateImages";
const abortedMarker = Symbol("tool-aborted");
let resolveAborted: (marker: typeof abortedMarker) => void = () => undefined;
const aborted = new Promise<typeof abortedMarker>((resolve) => {
resolveAborted = resolve;
});
let abortGraceTimer: ReturnType<typeof setTimeout> | undefined;
let stopped = false;
const abort = () => {
if ((!isBash && toolName !== "GenerateImages") || abortRequested || settled) return;
if (abortRequested || settled) return;
abortRequested = true;
abortPromise = this.host
.call("tools.abort", {
sessionId: this.sessionId,
toolCallId,
})
.then(
() => undefined,
(error) => {
abortError = error;
},
);
if (hostAbortsTool) {
abortPromise = this.host
.call("tools.abort", {
sessionId: this.sessionId,
toolCallId,
})
.then(
() => undefined,
(error) => {
abortError = error;
},
);
}
// Release progress/output resources before arming the grace window:
// cleanup is idempotent and must not race the timer it would clear.
cleanup(true);
if (hostAbortsTool) {
// Bounded window for the partial output the host collected before
// the kill; past it the turn ends regardless.
abortGraceTimer = setTimeout(
() => resolveAborted(abortedMarker),
TOOL_ABORT_GRACE_MS,
);
} else {
// Nothing host-side can interrupt this tool, so stop waiting now.
resolveAborted(abortedMarker);
}
};

cleanup = (flush: boolean) => {
Expand All @@ -3492,6 +3535,7 @@ Do not invent objections or turn speculative risks into blockers. Stop when the
unsubscribeOutput?.();
signal?.removeEventListener("abort", abort);
this.activeToolProgressCleanups.delete(cleanup);
this.activeToolAborts.delete(abort);
};
this.activeToolProgressCleanups.add(cleanup);
if (signal?.aborted) {
Expand All @@ -3503,6 +3547,7 @@ Do not invent objections or turn speculative risks into blockers. Stop when the
if (signal) {
signal.addEventListener("abort", abort, { once: true });
}
this.activeToolAborts.add(abort);

let result: {
ok: boolean;
Expand All @@ -3514,7 +3559,7 @@ Do not invent objections or turn speculative risks into blockers. Stop when the
let executionError: unknown;
let executionFailed = false;
try {
result = await this.host.call<{
const execution = this.host.call<{
ok: boolean;
content: unknown;
isError?: boolean;
Expand Down Expand Up @@ -3565,15 +3610,33 @@ Do not invent objections or turn speculative risks into blockers. Stop when the
? { delegationId: this.delegateWorktrees.get(toolCallId) }
: {}),
});
// #1767: stop has to end the turn even when the tool never yields.
// Losing the race does not cancel the host call — it only stops this
// turn from waiting on it.
const raced = await Promise.race([execution, aborted]);
if (raced === abortedMarker) {
stopped = true;
} else {
result = raced;
}
} catch (error) {
executionFailed = true;
executionError = error;
} finally {
this.delegatePermissionScopes.delete(toolCallId);
cleanup(true);
// cleanup is a no-op once the abort path has run, so the grace timer
// it armed is released here instead.
if (abortGraceTimer) clearTimeout(abortGraceTimer);
}
if (abortPromise) await abortPromise;
if (abortError) throw abortError;
if (stopped) {
throw Object.assign(
new Error(`tool execution aborted while ${toolName} was running`),
{ errorCode: "TOOL_ABORTED" },
);
}
if (executionFailed) throw executionError;
if (!result) throw new Error("tool execution returned no result");
const recordParams = isRecord(params) ? params : undefined;
Expand Down Expand Up @@ -9097,6 +9160,14 @@ Do not invent objections or turn speculative risks into blockers. Stop when the
this.providerRetryAbort?.abort();
if (this.compactionInProgress) this.compactionAborted = true;
this.compactionAbort?.abort();
// `agent.abort()` normally fires the signal each in-flight tool was handed,
// and the tool executor reacts to that. When the signal does not reach a
// tool, the executor keeps awaiting `tools.execute`, the turn never
// settles, and this method never returns. The renderer awaits this RPC
// before it clears the "processing" state, so one unyielding tool pinned
// the whole session until it finished on its own (#1767). Drive the tools
// from here as well, so a stop always releases them.
for (const abortTool of [...this.activeToolAborts]) abortTool();
await settlement;
}

Expand Down