docs/design/shell-timeout-error-semantics.md
Foreground Shell commands currently describe a timeout in text but return a successful ToolResult. Downstream code therefore records the call as successful, sends a function response with an output field, and can render a success indicator even though the command did not finish. A cancellation that arrives after the timeout can also overwrite the original reason. During PTY discovery, an already-aborted call can still spawn a process because the execution service does not observe the signal until after startup.
A Shell-owned foreground timeout returns ToolErrorType.EXECUTION_TIMEOUT. The result uses three intentionally separate channels:
| Channel | Audience | Timeout content |
|---|---|---|
error.message | Hooks, telemetry, spans, logs, alerting | Short timeout summary only |
llmContent | Model function response | Timeout summary, partial output or an explicit no-output statement, and any truncation pointer |
returnDisplay | Interactive history and ACP clients | Timeout summary, partial output or no-output statement, and any truncation pointer |
The scheduler converts timeout llmContent to a function response whose response has an error field and no output field. Failure-hook additional context is appended to that model-facing error once. The top-level ToolCallResponseInfo.error remains the short operational summary so command output is not copied into telemetry or hook error arguments.
Other soft tool errors retain their existing Core scheduler behavior. ACP and speculative execution consistently encode all soft errors with an error envelope because those paths invoke tools directly and otherwise have no scheduler classification step.
AbortSignal.any() preserves the reason from the first signal that aborts. Shell classification reads only the combined signal reason after execution:
TimeoutError plus an aborted execution is a timeout.The Core scheduler has a second, optional global execution timer. A structured timeout returned by a tool remains a timeout even if the parent signal is aborted before the scheduler consumes the result. When the scheduler's own timer supplies the timeout result, it wins only if the parent signal was not already aborted when the timer fired. A parent cancellation followed by the timer firing against an uncooperative tool remains cancelled.
ACP applies the same rule for structured tool timeouts: the timeout is an error rather than an interrupt even if its parent signal is observed as aborted afterward. Thrown exceptions continue to use the live abort state.
ShellExecutionService.execute() returns an aborted, no-process handle immediately when its signal is already aborted. PTY discovery races the signal with getPty() and removes its temporary listener after the race. If abort wins, a later PTY resolution or rejection is consumed without spawning a PTY or falling back to child_process. The returned result uses executionMethod: 'none' and has no pid.
This behavior affects all in-repository consumers of the service: foreground and background Shell plumbing, user ! Shell, prompt command injection, ACP bridge shell handling, and git attribution probes. The only behavioral change is that an already-aborted request no longer starts a process.
| Consumer | Timeout behavior |
|---|---|
| Core scheduler | status: error, short top-level error, detailed response.error, timeout failure kind |
| ACP session | failed tool update, detailed error envelope in model history and recording, short operational metadata |
| Speculative execution | detailed error envelope; accepted speculative history renders Error |
| Anthropic adapter | tool_result.is_error: true |
| OpenAI-compatible adapters | explicit detailed error text; no protocol-level error bit exists |
| JSON and stream-json | is_error: true with detailed nested error content preferred over the short summary |
| Context estimation and batch budget | both response.output and response.error text are counted; oversized errors retain the error key when offloaded |
Microcompaction continues to leave failed tool results untouched. Full chat compression now sees the detailed error size and can trigger at the correct budget.
Claude Code treats a command timeout as a failed tool result, retains output produced before termination for the model and user, and marks the tool result as an error in the Anthropic protocol. This design adopts those observable properties while keeping qwen-code's existing ToolResult shape and telemetry conventions. It does not copy command output into the short operational error channel.
This is an intentional wire-level correction. ACP and speculative soft failures change from { output } to { error }; Core changes that shape only for EXECUTION_TIMEOUT. Timeout counts move from success metrics to error/timeout metrics and failure hooks replace success hooks. No schema, error enum, timeout default, migration, or rollout flag changes.
Partial command output can contain sensitive data. It remains available to the model, the interactive result, chat recording, and explicit JSON output, as it was before the classification correction. It is not added to hook error arguments, top-level errors, span result attributes, or operational log summaries. Existing truncation and spill-to-disk limits apply to the detailed model channel.
Unit coverage exercises pre-aborted and PTY-discovery races, Shell timeout/cancel/promote ordering, sed simulation, short-versus-detailed scheduler channels, Core global timeout ordering, ACP and speculative direct invocation, Anthropic conversion, JSON content selection, error-size estimation, and batch offload. The E2E plan is recorded in .qwen/e2e-tests/shell-timeout-semantics.md.