Commit fa930b9b52860e3bbbacece3dde391300923f66f
1 parent
677fec23
docs: sharpen Boss Agent scope + new requirements doc for liveness-based agent-call timeout
Two real findings from today's live self-hosting run, surfaced while diagnosing why bfd3ee82/71868db6 were blocked: 1. trigger_org_node/trigger_org_node_structured use a fixed wall-clock subprocess.run(timeout=...) -- a long-running-but-genuinely-progressing agent call gets killed just for taking longer than a guessed constant (300s), which is exactly what happened to bfd3ee82's Validator planning call. Per an already-established (but never actually implemented) decision: an agent call should never be judged "stuck" by total duration alone, only by an extended period with no activity AND no error. New requirements doc: docs/todo/agent-call-liveness-not-fixed-duration-timeout-requirements.md. 2. task_execution.py::_mark_blocked's catch-all dumps anything outside a small exception whitelist into blocked_reason='execution_error' -- including ConcurrencyLimitReachedError, which isn't an error at all, just normal backpressure. Boss Agent needs the system to emit distinct, meaningful reasons at the point of failure, not reverse-engineer root cause from a generic bucket -- added as FR-7 to the Boss Agent doc. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
2 changed files
with
112 additions
and
1 deletions
docs/todo/agent-call-liveness-not-fixed-duration-timeout-requirements.md
0 → 100644
| 1 | +# Agent 调用超时判定:不能用固定时长,只能用"是否还活着"——需求文档 | |
| 2 | + | |
| 3 | +**日期**: 2026-08-13 | |
| 4 | +**性质**: 需求文档(BRD/PRD 层面),不含详细技术设计。这是喂给 graphlemon 自己实现的一个真实 | |
| 5 | +Goal,本次会话不直接改代码——这是一条基础设施级别的修复,影响所有 org_node 调用,不只是某一个 | |
| 6 | +具体场景。 | |
| 7 | +**状态**: 草案,待确认后作为 Goal 提交。用户明确重申过的既有决定(本次会话之前已经讨论过一次), | |
| 8 | +但实际代码目前并不满足这个决定——需要作为一个真实缺口记录下来,不能假设"讨论过就等于已实现"。 | |
| 9 | + | |
| 10 | +## 1. 背景 | |
| 11 | + | |
| 12 | +`src/org_node_trigger.py::trigger_org_node`/`trigger_org_node_structured`(所有 org_node 真实调用 | |
| 13 | +`claude -p` 的唯一入口)目前是一次性阻塞调用: | |
| 14 | + | |
| 15 | +```python | |
| 16 | +result = subprocess.run( | |
| 17 | + args, cwd=target_folder_path, stdin=subprocess.DEVNULL, | |
| 18 | + capture_output=True, text=True, timeout=timeout, | |
| 19 | +) | |
| 20 | +``` | |
| 21 | + | |
| 22 | +`timeout` 参数在不同调用点取值不同(大多数是 300 秒,`implement_code`/`validate_implementation` | |
| 23 | +这类更重的调用是 1200 秒),但性质都是同一种——**固定时长的墙钟超时**:不管这次调用是不是还在正常 | |
| 24 | +产出内容、只要总耗时超过这个数字,`subprocess.TimeoutExpired` 就会被抛出,然后被 | |
| 25 | +`task_execution.py::_mark_blocked` 兜底成 `blocked_reason='execution_error'`、状态打成 | |
| 26 | +`blocked_pending_human`。 | |
| 27 | + | |
| 28 | +今天(2026-08-13)真实撞上了这个问题:任务 `bfd3ee82` 的 Validator 在规划自己的 Work Graph 时, | |
| 29 | +一次 `claude --agent validator -p ...` 调用(prompt 里嵌了完整的已批准 SDD/PRD 全文,内容量较大) | |
| 30 | +跑满 300 秒被杀掉,直接变成 `blocked_pending_human`。 | |
| 31 | + | |
| 32 | +这不是这次调用"卡死"了——只是内容量大、真实需要的时间比 300 秒长。**一次真实的 agent 调用本身是 | |
| 33 | +多轮工具调用的会话(读文件、搜索、判断、再读文件……),耗时长短取决于任务复杂度,不取决于一个 | |
| 34 | +预先猜的固定数字**——这跟 `docs/done/validator-loop-until-dry-skill-requirements.md` 里"一次 | |
| 35 | +`agent()` 调用会话自己收敛需要多轮"是同一个认知:**只要这次调用还在真实产出/推进,哪怕跑 | |
| 36 | +10000 秒也不应该被判定为超时**;只有当调用**长时间没有任何新输出、也没有报错**(真正卡死、进程 | |
| 37 | +挂起不动了)时,才应该被判定为异常,需要处理。这是两种完全不同的判断逻辑——前者是"总时长封顶", | |
| 38 | +后者是"活跃度/心跳"——现在的实现只有前者,从未实现过后者。 | |
| 39 | + | |
| 40 | +## 2. 目标 | |
| 41 | + | |
| 42 | +`trigger_org_node`/`trigger_org_node_structured`(以及任何其他调用 `claude -p` 的地方,比如 | |
| 43 | +`e2e_pipeline.py::_check_prd_consistency`/`work_graph_plan.py::plan_work_graph` 各自传入的 | |
| 44 | +`timeout` 参数)改成按"是否还活着"判断,而不是按固定总时长判断: | |
| 45 | + | |
| 46 | +- 只要这次调用还在持续产出内容(不管是 stdout 增量、还是别的可观察的"进行中"信号),无论总耗时 | |
| 47 | + 多长,都不应该被判定为超时/卡死。 | |
| 48 | +- 只有当**长时间没有任何新输出、且没有报错**(真正卡死)时,才应该判定为异常,走现有的 | |
| 49 | + `blocked_pending_human`/异常处理路径(这种情况下打的 `blocked_reason` 应该是一个专属值,比如 | |
| 50 | + `agent_call_stalled`,不能再混进笼统的 `execution_error`——跟 Boss Agent 需求文档 | |
| 51 | + `docs/todo/boss-agent-autonomous-blocked-task-arbiter-requirements.md` 里"细化 blocked_reason | |
| 52 | + 分类"的要求是同一个方向,这条本身就是那份文档要处理的原因分类之一)。 | |
| 53 | + | |
| 54 | +## 3. 非目标 | |
| 55 | + | |
| 56 | +1. **不裁定具体用什么信号判断"是否还活着"**——这是本文档刻意留给设计阶段的核心技术问题(见 | |
| 57 | + §7),不同方案(读取 `claude` 是否支持增量输出模式、监控进程 CPU/IO 活动、依赖 | |
| 58 | + `execution_log_file`/`c_ws_log_tail` 这条已有的实时日志旁路)代价和可靠性都不同,需要先调研 | |
| 59 | + `claude -p` 这个执行后端实际能提供什么可观察信号,不能在需求阶段假设某个方案一定可行。 | |
| 60 | +2. **不改变 `claude` CLI 本身的调用方式约定**(`--agent`/`--output-format json`/ | |
| 61 | + `--permission-mode auto`,见 CLAUDE.md"硬约束"一节)——如果心跳检测需要切换到某种增量输出 | |
| 62 | + 格式,这需要先验证是否兼容现有的 ```json fence 提取约定(`org_node_trigger.py` 模块文档已经 | |
| 63 | + 记录过的先例:`--agent` + `--json-schema` 一起用会静默丢失 `structured_output`,切输出格式 | |
| 64 | + 前必须先做同类的真实兼容性验证,不能假设新格式和旧解析逻辑天然兼容)。 | |
| 65 | +3. **不等同于**已有的 `p_usage_limit_wait`(`work_graph_execute.py::UsageLimitWaitTimeoutError`, | |
| 66 | + 处理 Claude 账号本身触发用量限额时的等待重试)——那是一个已知、已分类的特定信号触发的等待 | |
| 67 | + 逻辑,跟"普通调用要跑多久才算异常"是两回事,不要合并成同一个机制。 | |
| 68 | + | |
| 69 | +## 4. 功能需求 | |
| 70 | + | |
| 71 | +| # | 需求描述 | 优先级 | | |
| 72 | +|---|---|---| | |
| 73 | +| FR-1 | `trigger_org_node`/`trigger_org_node_structured` 的超时判断从"总时长超过 N 秒"改为"距离上一次可观察的活跃信号超过 N 秒",具体信号来源留待设计阶段调研确定。 | 必须 | | |
| 74 | +| FR-2 | 真正判定为"卡死"(而不是"还在正常跑")时,`blocked_reason` 使用专属值(如 `agent_call_stalled`),不落入笼统的 `execution_error`。 | 必须 | | |
| 75 | +| FR-3 | 各调用点现有的固定 `timeout` 参数值(300/1200 秒等)语义需要重新定义——如果改成心跳判断,这些数字可能变成"心跳最大间隔"而不是"总时长上限",需要在设计阶段明确每个数字新的含义,不能保留旧数字但换了新语义却没人核对是否还合理。 | 必须 | | |
| 76 | + | |
| 77 | +## 5. 验收标准 | |
| 78 | + | |
| 79 | +1. 构造一个真实场景:一次 agent 调用总耗时明显超过当前 300 秒的固定值,但过程中持续有可观察的 | |
| 80 | + 活跃信号(不管最终选的是什么信号),验证这次调用**不会**被判定为超时/卡死,能正常跑到完成。 | |
| 81 | +2. 构造一个真实场景:一次调用在启动后很快就不再有任何活跃信号、且没有报错(模拟进程真正挂起), | |
| 82 | + 验证系统能在合理时间内判定为 `agent_call_stalled` 并正确处理,不会无限等下去。 | |
| 83 | +3. 今天实际复现的 `bfd3ee82` 场景(大 prompt、验证式 Work Graph 规划调用)用修复后的机制重跑, | |
| 84 | + 验证不再因为总时长超过 300 秒就被杀。 | |
| 85 | + | |
| 86 | +## 6. 风险 | |
| 87 | + | |
| 88 | +如果最终选择的"活跃信号"本身不可靠(比如信号源本身有延迟、或者某些真实卡死的场景恰好还在产生 | |
| 89 | +噪声信号),可能导致真正卡死的调用永远不会被判定为异常、任务无限挂起——这跟"固定超时太激进、 | |
| 90 | +误杀正常调用"是同一个问题的另一个极端,设计阶段需要两头都验证到,不能只解决"误杀"这一半。 | |
| 91 | + | |
| 92 | +## 7. 开放问题 | |
| 93 | + | |
| 94 | +- [ ] **"活跃信号"具体是什么**:`claude -p ... --output-format json` 目前是一次性输出,本身不是 | |
| 95 | + 增量流;需要先调研 `claude` CLI 是否有增量/流式输出格式可用(如果有,切换格式本身要先做真实 | |
| 96 | + 兼容性验证,见 §3.2),如果没有,是否可以用进程级别的 CPU/IO 活动作为替代信号,或者依赖 | |
| 97 | + `work_node_run.py`/`execution_log_file` 这条已有的实时日志旁路(如果这次调用本身会写日志的 | |
| 98 | + 话)。这是本文档最核心、最需要先调研清楚的技术问题。 | |
| 99 | +- [ ] **"长时间没有活跃信号"的具体阈值定多少**:不再是"总时长 N 秒",而是"距上次活跃信号 N 秒", | |
| 100 | + 这个新的 N 该怎么定,是否所有调用点都用同一个值。 | |
| 101 | +- [ ] 跟 Boss Agent(`docs/todo/boss-agent-autonomous-blocked-task-arbiter-requirements.md`)的 | |
| 102 | + 实现顺序:这条修复解决的是"避免正常调用被误判为异常进而错误占用 `blocked_pending_human`", | |
| 103 | + Boss Agent 解决的是"任务真的卡在 `blocked_pending_human` 之后怎么自主处理"——两者独立,但 | |
| 104 | + 这条修复先落地,能显著减少 Boss Agent 将来要处理的"假异常"数量,建议这条优先。 | ... | ... |
docs/todo/boss-agent-autonomous-blocked-task-arbiter-requirements.md
| ... | ... | @@ -4,7 +4,13 @@ |
| 4 | 4 | **性质**: 需求文档(BRD/PRD 层面),不含详细技术设计。按本仓库既有约定,这份文档确认后本身就 |
| 5 | 5 | 是喂给 graphlemon 自己(Planner→planner_reviewer→Coder→Validator)的一个真实 Goal,由它自举 |
| 6 | 6 | 实现,不是本次会话直接写代码。 |
| 7 | -**状态**: 草案,待确认后作为 Goal 提交。 | |
| 7 | +**状态**: 草案,待确认后作为 Goal 提交。2026-08-13 修订:原稿把"细化真实原因"框定成"Boss Agent | |
| 8 | +自己去诊断",讨论中发现这个框定不够——**至少一部分原因在触发的当下就已经是明确、结构化已知的** | |
| 9 | +(比如并发上限、Agent 调用是否真的卡死),根本不需要事后靠 Boss Agent 去反推,系统自己在触发的 | |
| 10 | +那一刻就该打上准确的原因,不能眉毛胡子一把抓全丢进 `execution_error` 再指望下游硬猜。见 §4 新增 | |
| 11 | +FR-7,以及关联文档 | |
| 12 | +`docs/todo/agent-call-liveness-not-fixed-duration-timeout-requirements.md`(同日新增,独立于本 | |
| 13 | +文档,但直接影响本文档要处理的"假异常"数量,建议优先于本文档落地)。 | |
| 8 | 14 | |
| 9 | 15 | ## 1. 背景 |
| 10 | 16 | |
| ... | ... | @@ -86,6 +92,7 @@ Code 协作)手动读 `execution_event`/日志/代码逐个诊断。 |
| 86 | 92 | | FR-4 | 超出授权边界的情况,明确升级给人,并说明升级原因(不能是"我不确定所以升级"这种不可操作的理由,要具体到"诊断出的原因是 X,属于需要人工判断的 Y 类别")。 | 必须 | |
| 87 | 93 | | FR-5 | 每一次处理(自主执行或升级)都要留下可事后追溯的决策记录,包含:task_id、诊断出的真实原因、采取的动作、时间。 | 必须 | |
| 88 | 94 | | FR-6 | 并发安全:同一时刻可能有多个任务同时卡住,Boss Agent 对它们的处理不能重蹈今天 `watch_resume.py` 的覆辙——批量触发 resume 前必须正确读取当前并发占用(`max_concurrent_goals`),不能对全局并发上限的检查引入竞态。 | 必须 | |
| 95 | +| FR-7 | **系统自己在触发 `blocked_pending_human` 的那一刻,就要给出明确、专属、可区分的 `blocked_reason`,不能让"不认识的异常"全部落入笼统的 `execution_error`。** 今天真实观察到的例子:`ConcurrencyLimitReachedError`(并发上限,本质是"正常排队等待",不是错误)和 `subprocess.TimeoutExpired`(调用超时,且现有超时判定本身有问题,见关联文档)都被 `task_execution.py::_mark_blocked` 的兜底逻辑(`_ROUTABLE_EXCEPTIONS` 白名单之外一律 `execution_error`)打成了同一个含糊的原因。至少需要扩充 `_ROUTABLE_EXCEPTIONS`/`_BLOCKED_REASON_FOR_EXCEPTION`,让 `ConcurrencyLimitReachedError` 有自己专属的 reason;并且要重新评估"并发上限"这类**系统自己造成、不需要任何判断力就能自动恢复**的场景,是否真的应该产生 `blocked_pending_human`(暗示"需要人/Boss Agent 介入判断"),还是应该是一种不需要人工/Boss Agent 判断介入、由既有的自动派发/复原循环自然清空的状态——如果两种真实原因用同一个状态表达,Boss Agent 就没法只凭状态本身区分"需要我判断"和"不需要任何判断,系统自己会恢复"。 | 必须 | | |
| 89 | 96 | |
| 90 | 97 | ## 5. 验收标准 |
| 91 | 98 | ... | ... |