Agent 调用超时判定:不能用固定时长,只能用"是否还活着"——需求文档
日期: 2026-08-13 性质: 需求文档(BRD/PRD 层面),不含详细技术设计。这是喂给 graphlemon 自己实现的一个真实 Goal,本次会话不直接改代码——这是一条基础设施级别的修复,影响所有 org_node 调用,不只是某一个 具体场景。 状态: 草案,待确认后作为 Goal 提交。用户明确重申过的既有决定(本次会话之前已经讨论过一次), 但实际代码目前并不满足这个决定——需要作为一个真实缺口记录下来,不能假设"讨论过就等于已实现"。
1. 背景
src/org_node_trigger.py::trigger_org_node/trigger_org_node_structured(所有 org_node 真实调用
claude -p 的唯一入口)目前是一次性阻塞调用:
result = subprocess.run(
args, cwd=target_folder_path, stdin=subprocess.DEVNULL,
capture_output=True, text=True, timeout=timeout,
)
timeout 参数在不同调用点取值不同(大多数是 300 秒,implement_code/validate_implementation
这类更重的调用是 1200 秒),但性质都是同一种——固定时长的墙钟超时:不管这次调用是不是还在正常
产出内容、只要总耗时超过这个数字,subprocess.TimeoutExpired 就会被抛出,然后被
task_execution.py::_mark_blocked 兜底成 blocked_reason='execution_error'、状态打成
blocked_pending_human。
今天(2026-08-13)真实撞上了这个问题:任务 bfd3ee82 的 Validator 在规划自己的 Work Graph 时,
一次 claude --agent validator -p ... 调用(prompt 里嵌了完整的已批准 SDD/PRD 全文,内容量较大)
跑满 300 秒被杀掉,直接变成 blocked_pending_human。
这不是这次调用"卡死"了——只是内容量大、真实需要的时间比 300 秒长。一次真实的 agent 调用本身是
多轮工具调用的会话(读文件、搜索、判断、再读文件……),耗时长短取决于任务复杂度,不取决于一个
预先猜的固定数字——这跟 docs/done/validator-loop-until-dry-skill-requirements.md 里"一次
agent() 调用会话自己收敛需要多轮"是同一个认知:只要这次调用还在真实产出/推进,哪怕跑
10000 秒也不应该被判定为超时;只有当调用长时间没有任何新输出、也没有报错(真正卡死、进程
挂起不动了)时,才应该被判定为异常,需要处理。这是两种完全不同的判断逻辑——前者是"总时长封顶",
后者是"活跃度/心跳"——现在的实现只有前者,从未实现过后者。
2. 目标
trigger_org_node/trigger_org_node_structured(以及任何其他调用 claude -p 的地方,比如
e2e_pipeline.py::_check_prd_consistency/work_graph_plan.py::plan_work_graph 各自传入的
timeout 参数)改成按"是否还活着"判断,而不是按固定总时长判断:
- 只要这次调用还在持续产出内容(不管是 stdout 增量、还是别的可观察的"进行中"信号),无论总耗时 多长,都不应该被判定为超时/卡死。
- 只有当长时间没有任何新输出、且没有报错(真正卡死)时,才应该判定为异常,走现有的
blocked_pending_human/异常处理路径(这种情况下打的blocked_reason应该是一个专属值,比如agent_call_stalled,不能再混进笼统的execution_error——跟 Boss Agent 需求文档docs/todo/boss-agent-autonomous-blocked-task-arbiter-requirements.md里"细化 blocked_reason 分类"的要求是同一个方向,这条本身就是那份文档要处理的原因分类之一)。
3. 非目标
- 不裁定具体用什么信号判断"是否还活着"——这是本文档刻意留给设计阶段的核心技术问题(见
§7),不同方案(读取
claude是否支持增量输出模式、监控进程 CPU/IO 活动、依赖execution_log_file/c_ws_log_tail这条已有的实时日志旁路)代价和可靠性都不同,需要先调研claude -p这个执行后端实际能提供什么可观察信号,不能在需求阶段假设某个方案一定可行。 - 不改变
claudeCLI 本身的调用方式约定(--agent/--output-format json/--permission-mode auto,见 CLAUDE.md"硬约束"一节)——如果心跳检测需要切换到某种增量输出 格式,这需要先验证是否兼容现有的 ``json fence 提取约定(org_node_trigger.py模块文档已经 记录过的先例:--agent+--json-schema一起用会静默丢失structured_output`,切输出格式 前必须先做同类的真实兼容性验证,不能假设新格式和旧解析逻辑天然兼容)。 - 不等同于已有的
p_usage_limit_wait(work_graph_execute.py::UsageLimitWaitTimeoutError, 处理 Claude 账号本身触发用量限额时的等待重试)——那是一个已知、已分类的特定信号触发的等待 逻辑,跟"普通调用要跑多久才算异常"是两回事,不要合并成同一个机制。
4. 功能需求
| # | 需求描述 | 优先级 |
|---|---|---|
| FR-1 | trigger_org_node/trigger_org_node_structured 的超时判断从"总时长超过 N 秒"改为"距离上一次可观察的活跃信号超过 N 秒",具体信号来源留待设计阶段调研确定。 |
必须 |
| FR-2 | 真正判定为"卡死"(而不是"还在正常跑")时,blocked_reason 使用专属值(如 agent_call_stalled),不落入笼统的 execution_error。 |
必须 |
| FR-3 | 各调用点现有的固定 timeout 参数值(300/1200 秒等)语义需要重新定义——如果改成心跳判断,这些数字可能变成"心跳最大间隔"而不是"总时长上限",需要在设计阶段明确每个数字新的含义,不能保留旧数字但换了新语义却没人核对是否还合理。 |
必须 |
5. 验收标准
- 构造一个真实场景:一次 agent 调用总耗时明显超过当前 300 秒的固定值,但过程中持续有可观察的 活跃信号(不管最终选的是什么信号),验证这次调用不会被判定为超时/卡死,能正常跑到完成。
- 构造一个真实场景:一次调用在启动后很快就不再有任何活跃信号、且没有报错(模拟进程真正挂起),
验证系统能在合理时间内判定为
agent_call_stalled并正确处理,不会无限等下去。 - 今天实际复现的
bfd3ee82场景(大 prompt、验证式 Work Graph 规划调用)用修复后的机制重跑, 验证不再因为总时长超过 300 秒就被杀。
6. 风险
如果最终选择的"活跃信号"本身不可靠(比如信号源本身有延迟、或者某些真实卡死的场景恰好还在产生 噪声信号),可能导致真正卡死的调用永远不会被判定为异常、任务无限挂起——这跟"固定超时太激进、 误杀正常调用"是同一个问题的另一个极端,设计阶段需要两头都验证到,不能只解决"误杀"这一半。
7. 开放问题
- [ ] "活跃信号"具体是什么:
claude -p ... --output-format json目前是一次性输出,本身不是 增量流;需要先调研claudeCLI 是否有增量/流式输出格式可用(如果有,切换格式本身要先做真实 兼容性验证,见 §3.2),如果没有,是否可以用进程级别的 CPU/IO 活动作为替代信号,或者依赖work_node_run.py/execution_log_file这条已有的实时日志旁路(如果这次调用本身会写日志的 话)。这是本文档最核心、最需要先调研清楚的技术问题。 - [ ] "长时间没有活跃信号"的具体阈值定多少:不再是"总时长 N 秒",而是"距上次活跃信号 N 秒", 这个新的 N 该怎么定,是否所有调用点都用同一个值。
- [ ] 跟 Boss Agent(
docs/todo/boss-agent-autonomous-blocked-task-arbiter-requirements.md)的 实现顺序:这条修复解决的是"避免正常调用被误判为异常进而错误占用blocked_pending_human", Boss Agent 解决的是"任务真的卡在blocked_pending_human之后怎么自主处理"——两者独立,但 这条修复先落地,能显著减少 Boss Agent 将来要处理的"假异常"数量,建议这条优先。