qwen-code Shell 超时错误语义:从"成功返回"到结构化 EXECUTION_TIMEOUT 的修复实践
【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code
导读
本文深入剖析 qwen-code 中对前台 Shell 命令超时错误语义的一次关键修正:此前命令超时只以文本描述、却返回"成功"的ToolResult,导致下游链路将其记录为成功、模型函数响应带output字段,甚至渲染成功指示器。本文以设计文档 shell-timeout-error-semantics.md 为骨架,结合仓库源码(packages/core/src/tools/shell.ts、packages/core/src/core/coreToolScheduler.ts、packages/core/src/services/shellExecutionService.ts)讲解新的三通道结果契约、首因判定规则、预中止启动行为与各消费者适配。读完你将掌握 qwen-code 中"超时 / 取消 / 提升(promote)"三类中止的判别顺序、结构化错误在各协议层的编码方式,以及这一修正对可观测性指标与安全边界的影响。
背景问题:超时被误记为成功
设计文档开篇点明了缺陷的根源:前台 Shell 命令超时后,虽然错误文本中描述了 timeout,但返回的ToolResult仍被标记为成功。由此引发一系列连锁错误:
- 下游代码将该调用记录为"成功";
- 模型函数响应携带
output字段而非error; - 交互界面可以渲染成功指示器,尽管命令实际并未完成;
- 超时之后到达的取消(cancellation)信号还可能覆盖原始原因;
- 在 PTY 发现(PTY discovery)阶段,一个已被中止的调用仍可能拉起进程,因为执行服务在启动完成前不会观察到该信号。
结果契约:三通道分离
修复的核心是为前台 Shell 超时引入专用错误类型ToolErrorType.EXECUTION_TIMEOUT(定义于 tool-error-type.ts,枚举值为'execution_timeout',注释明确其为"工具调用超过单工具执行超时并被中止")。结果通过三个有意分离的通道输出:
| 通道 | 受众 | 超时内容 |
|---|---|---|
error.message | Hooks、遥测、spans、日志、告警 | 仅简短超时摘要 |
llmContent | 模型函数响应 | 超时摘要 + 部分输出(或显式"无输出"声明)+ 任何截断提示 |
returnDisplay | 交互历史与 ACP 客户端 | 超时摘要 + 部分输出(或无输出声明)+ 任何截断提示 |
调度器(Core scheduler)将超时llmContent转换为函数响应时,其response携带error字段且没有output字段;失败钩子(failure hook)的附加上下文只追加一次到模型面向的错误中。顶层ToolCallResponseInfo.error保持为简短运维摘要,确保命令输出不会被复制进遥测或钩子错误参数。
在源码层面,shell.ts 展示了超时内容的组装逻辑:
const abortReasonName = getAbortReasonName(combinedSignal); const wasTimeout = result.aborted && effectiveTimeout > 0 && abortReasonName === 'TimeoutError'; const timeoutSummary = wasTimeout ? `Command timed out after ${effectiveTimeout}ms before it could complete.` : undefined; if (result.aborted) { if (wasTimeout) { llmContent = timeoutSummary!; if (result.output.trim()) { llmContent += ` Below is the output before it timed out:\n${result.output}`; } else { llmContent += ' There was no output before it timed out.'; } } // ... }而结构化错误类型则在 shell.ts 中落地:
const executionError = timeoutSummary ? { error: { message: timeoutSummary, type: ToolErrorType.EXECUTION_TIMEOUT, }, } : // ... 其它分支(SHELL_EXECUTE_ERROR、信号终止、非零退出等)其他软错误(soft tool errors)保留 Core 调度器既有行为;ACP 与投机执行(speculative execution)则因为直接调用工具、缺少调度器分类步骤,所有软错误都统一用错误信封(error envelope)编码。
首因规则:谁先发生,就以谁为准
AbortSignal.any()会保留第一个触发中止的信号的原因。Shell 分类在执行完成后只读取合并信号(combined signal)的原因,判定顺序如下:
TimeoutError+ 已中止的执行 →超时;- background-promote 原因 + 已中止、但未提升的执行 → 既有的promote-refused 竞态;
- 其它已中止执行 →取消;
- 超时先发生 → 之后的用户取消或提升请求不改变它;
- 取消或提升请求先发生 → 之后的超时不改变它。
Core 调度器还有一个可选的全局执行计时器(第二计时器)。工具返回的结构化超时,即使父信号在调度器消费结果前已被中止,仍是超时;而当调度器自身计时器提供超时结果时,只有当计时器触发时父信号尚未被中止,它才胜出。即:父信号取消在先、随后计时器对一个不配合的工具触发 → 结果仍是取消。相关实现可在 coreToolScheduler.ts 看到(schedulerTimeoutResultSelected/schedulerTimeoutWon参与isTimeout判定),并最终以ToolErrorType.EXECUTION_TIMEOUT+TOOL_FAILURE_KIND_TIMEOUT构建timeoutResponse(见 coreToolScheduler.ts)。
ACP 对结构化工具超时应用相同规则:即使父信号事后被观察为已中止,超时也编码为错误而非中断(interrupt);而抛出的异常(thrown exceptions)继续使用实时中止状态。
启动行为:预中止不再拉起进程
ShellExecutionService.execute()在信号已经中止时,立即返回一个"已中止、无进程"的句柄。PTY 发现阶段用getPty()与信号竞速,并在竞速结束后移除临时监听器;若中止获胜,后续 PTY 的 resolve/reject 被直接消费,既不会拉起 PTY、也不会回退到child_process。返回结果使用executionMethod: 'none'且没有 pid。
对应实现是 shellExecutionService.ts 中的早退检查:
if (abortSignal.aborted) { return createPreSpawnAbortedHandle(); }以及createPreSpawnAbortedHandle()(shellExecutionService.ts)构造的规范化结果——aborted: true、空输出、executionMethod: 'none'、无 pid。PTY 竞速部分通过Promise.race([ptyResult, aborted])实现(shellExecutionService.ts),并在 PTY 解析、xterm headless 加载之后再次检查abortSignal.aborted。
该行为影响仓库内所有该服务的消费者:前台与后台 Shell 管道、用户!Shell、提示词命令注入(prompt command injection)、ACP 桥接 Shell 处理、git 归属探测(attribution probes)。唯一的行为变化是:已中止的请求不再启动进程。
消费者行为矩阵
| 消费者 | 超时行为 |
|---|---|
| Core 调度器 | status: error、短顶层错误、详细response.error、超时失败类别(timeout failure kind) |
| ACP 会话 | failed tool 更新、模型历史与记录中的详细错误信封、短运维元数据 |
| 投机执行 | 详细错误信封;被接受的投机历史渲染为 Error |
| Anthropic 适配器 | tool_result.is_error: true |
| OpenAI 兼容适配器 | 显式详细错误文本;协议层不存在错误位 |
| JSON 与 stream-json | is_error: true,优先详细嵌套错误内容而非短摘要 |
| 上下文估算与批量预算 | response.output与response.error文本都计数;超限卸载时保留 error 键 |
微压缩(microcompaction)继续不动失败的工具结果;而完整聊天压缩(full chat compression)现在能看见详细错误大小,从而在正确的预算阈值触发。
Claude Code 对比与设计取舍
Claude Code 将命令超时视为失败的工具结果:保留终止前产生的输出给模型与用户,并在 Anthropic 协议中将工具结果标记为错误。本文设计采纳这些可观察属性,同时保持 qwen-code 既有的ToolResult形状与遥测约定;不会把命令输出复制进短运维错误通道。
兼容性与可观测性影响
这是一次有意的线上(wire-level)修正:
- ACP 与投机执行的软失败从
{ output }变为{ error }; - Core 仅在
EXECUTION_TIMEOUT场景改变该形状; - 超时计数从成功指标迁移到错误/超时指标;
- 失败钩子(failure hooks)取代成功钩子;
- 无schema、错误枚举、超时默认值、迁移或灰度开关变更。
安全边界:部分命令输出可能包含敏感数据。它仍然如修正前一样对模型、交互结果、聊天记录与显式 JSON 输出可见,但不会被加入钩子错误参数、顶层错误、span 结果属性或运维日志摘要;既有的截断与溢出到磁盘(spill-to-disk)限制继续作用于详细模型通道。
明确不在范围内
设计文档划定了清晰边界,以下内容不属于本次修正:
- 心跳或周期性进度上报;
- Todo 停止守卫(stop guards)或提示词变更;
- 非零退出码语义;
- 外部信号终止语义;
- 后台 Shell 超时;
- 全局调度器计时器胜出后等待部分输出;
- 新增超时设置或协议字段。
验证与测试
单元测试覆盖了:预中止与 PTY 发现竞态、Shell 超时/取消/提升的排序、sed 模拟、调度器短/详细通道、Core 全局超时排序、ACP 与投机直接调用、Anthropic 转换、JSON 内容选择、错误大小估算与批量卸载。E2E 计划记录在.qwen/e2e-tests/shell-timeout-semantics.md(该路径位于仓库运行时目录,本文仅作设计文档中的验证说明引用)。
小结
本次修正让 qwen-code 的 Shell 超时从"文本提示 + 成功结果"的误导性状态,升级为结构化、三通道分离、首因确定的EXECUTION_TIMEOUT错误:模型得到详细且含部分输出的错误内容,运维与遥测只看到简短摘要,协议适配层(Anthropic / OpenAI / JSON / ACP / 投机执行)各按其能力编码错误,同时预中止请求不再启动进程,从根本上消除了"超时被计为成功"与"取消覆盖超时原因"两类语义污染。
【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考