feat(core): stop a hung tool from hanging the whole turn - #270
Merged
Conversation
Only Bash bounded itself. A Grep against a stalled network mount, or a fetch
wedged before its own timer arms, hangs the agent turn indefinitely — and what
the user sees is a blinking cursor with no way to tell a slow command from a
dead one.
This adds a backstop deadline around every tool call. Backstop is the operative
word: the default is deliberately generous, because a deadline that raced each
tool's own limit would replace a precise error ("killed by timeout after
120000ms") with a vague one. It fires only when the inner limit does not.
A caller-requested `timeout` in the tool's own arguments always wins when it is
longer. Bash({ timeout: 900000 }) is an explicit request for a fifteen-minute
command; a ten-minute backstop killing it would make that parameter a lie.
When it fires on a side-effecting tool, the message says the effect is unknown
rather than implying nothing happened. Abandoning the wait does not abort the
work — the process may still be running, and "it did not happen" would be a
guess presented as a fact.
The tool's signal is aborted so a well-behaved tool can stop, but the race is
settled first: a tool that resolves synchronously from its abort listener would
otherwise win the race and return its own cancellation result, losing both the
deadline message and its warning about a half-applied side effect.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
oratis
force-pushed
the
feat/tool-deadline
branch
from
August 14, 2026 12:56
7591e63 to
39fec76
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Third implementation PR from
docs/DSH_ADOPTION_PLAN.md§1.3.The gap
Only
Bashbounded itself. Every other tool could hang the agent turn forever — aGrepagainst a stalled network mount, a fetch wedged before its own timer arms — and what the user sees is a blinking cursor with no way to distinguish a slow command from a dead one.Backstop, not primary timeout
This is the part worth reviewing, because it is the opposite of how a timeout usually gets added. The opponent case in the plan was that most tools already bound themselves and a second layer creates ambiguous "which timeout fired" semantics.
That argument is correct, so the design concedes to it: the default is 10 minutes, deliberately far above every inner limit, and the inner limit is supposed to win.
[killed by timeout after 120000ms]is a better error than a generic backstop message. This layer only covers the case where the inner limit does not fire at all.A caller-requested
timeoutin the tool's own arguments always wins when it is longer.Bash({ timeout: 900000 })is an explicit request for a 15-minute command; a 10-minute backstop killing it would make the parameter a lie. The resolver adds 30s of grace on top so the tool's own limit still fires first.It does not claim the work stopped
For
Bash,Edit,Write,NotebookEditthe message says the effect is unknown — "may still be running… check the current state before retrying" — rather than implying nothing happened. Abandoning the wait does not abort the work. The process may well still be going, and reporting otherwise would be a guess presented as a fact. Read-only tools get the more useful "narrow the request and try again" instead.One bug the tests caught
The first implementation aborted the signal and then settled the race. A well-behaved tool resolves synchronously from its abort listener, so its own "stopped" result won the race — and the deadline message vanished, including its warning that a side effect might be half-applied. The order is now settle-then-abort, with a comment saying why, because the correct order looks arbitrary until it bites.
Verification
pnpm typecheck,lint,format:checkclean. Full suite green — core 1076 passed / 28 skipped, plus cli 242, desktop 104, server 49, protocol 34, lsp 13, vscode 12, scripts 42.13 new tests: 10 unit over the resolver and message (per-tool overrides, requested-timeout precedence, the 30s grace, unusable
timeoutarguments, side-effect wording) and 3 through the real loop — a tool that never returns on its own, proof its signal is actually aborted, and proof a tool returning in time is untouched.Off by
toolDeadlines: { disabled: true }; default and per-tool values are configurable.