45-graph-run-node-liveness-recovery.md 17.4 KB

45 · 卡在 running 且背后进程已死的 graph 节点,没有任何机制会重新捡起来

状态: ✅ Part A/B/C 已实现(2026-08-31),Part D 未实现(有意延后,见该节新增说明)。 Part A 落地时对最初的设计做了一次订正——见下面「五、实现记录」,别按「四、建议修法」原文 直接去代码里找 SessionPool 相关的东西,那条路线调查后判定不可行,已改道。 日期: 2026-08-31 发现场景:h3video 公司 goal G-mtgmij2n-26d8 / task T-mtgmij2n-d7ac(太空歌剧预告片)的 h3-story 阶段子图里,h3-story-draft 节点(nodeKind: agent_call,rn_04a9474682f3, run run_900ceddabf30)卡在 status: running,但 agentRunId/childRunId 均为 null——背后 驱动它的 Claude CLI 会话已经死亡/被中断,节点这一行永远不会自己动。用户把这个情况转给 ceo 排查(conv-mtgvjiyq),ceo 没有任何工具能"正确地"处理这件事,于是自己直接对同一个 run 拉起 了一个新的 h3-story 会话,随后陷入反复的工具调用死循环(think loop),最终由人手动中断 (code=143)并重启容器才平息。


一、时间线(UTC,本地 UTC+8)

2026-08-31T06:10:34Z  h3-story-draft 首次启动(run_900ceddabf30),随后驱动它的 CLI 会话
                       在某个时点死亡/被中断——没有任何日志记录这次死亡本身,只能靠"此后再
                       没更新过"反推
2026-08-31T06:45:35Z  用户把任务转给 ceo(conv-mtgvjiyq),ceo 开始排查
2026-08-31T06:53:12Z  ceo 直接对着同一个卡住的 run(run_900ceddabf30)拉起了一个新的
                       h3-story 会话——不是通过任何"重试节点"的工具,是绕过引擎直接启动
                       一个 agent 会话,key 与卡住节点的 acp key 一致
2026-08-31T06:53:12Z ~ 06:55:19Z
                       ceo 自己的会话 + 它新拉起的 h3-story 会话反复打
                       "🔧 工具执行中 → 超时切为 900s",两分钟内十几条,无实质进展(死循环)
2026-08-31T06:55:22Z  用户手动中断 conv-mtgvjiyq,CLI 进程 code=143 退出
2026-08-31T07:00:09Z  用户重新部署容器(RestartCount=0,干净重启,不是崩溃)
2026-08-31T07:02+     容器重启后再没有任何进程碰过 run_900ceddabf30——
                       h3-story-draft 节点原地卡着,跟死循环发生前一模一样

结论:ceo 修的是它自己的行为(不该再打 think 死循环),没有、也不可能修底层那个卡住的节点—— 因为节点重新变得可跑,靠的是 Part B/C 里要新增的能力,不是重启容器就会自动发生的事。


二、根因:三层机制,没有一层真的接住这个失败模式

2.1 心跳是纯 LLM 驱动,不查 graph_run_nodes

  • server/services/heartbeat-service.js:221-227 的心跳消息原文是让 agent"扫描全局 OKR 目标状态和节点"—— 对 ceo 而言这条通用文案根本不适用(system/agents/ceo/heartbeat.md:3-4 自己也说了"那是通用触发文案, 对你不适用")。这条路径全程没有一行代码去查 graph_runs/graph_run_nodes,完全靠 LLM 读 prompt 后自己 决定做什么。
  • server/services/company-heartbeat-service.js 的 _onHeartbeat(116 行)只做三件事:起 cron Pipeline (_checkAndStartPipelines,154 行)、驱动旧版 Pipeline 的超时(_driveActiveRuns,196 行,走的是 pipeline-service.js 那条遗留模型,不是 org/work graph 引擎)、触发 CEO 心跳文案 (_triggerCeoHeartbeat,266 行)。同样不碰 graph_run_nodes。

2.2 唯一存在的节点级孤儿扫描,只在服务器启动时跑一次

sweepOrphanedRunningNodes(cutoffIso)(server/services/graph-engine.js:3118-3145)确实是为同一类问题写的—— 文档注释(3100-3116 行)写得很清楚:status='running' 这一行"只是上次问到的状态",不代表背后真有进程; 它复用 routeFailure 的 infra_flake 分支(跟现成的重跑/熔断逻辑接上,INFRA_FLAKE_REASON, graph-engine.js:149),设计思路是对的。

但它只在 server/index.js:172-178 服务器进程启动时调用一次,cutoffIso 固定传 PROCESS_STARTED_AT(server/index.js:24,注释还特意强调"不要在调用点现取,那样每次重试都会把截止线 往后挪")。判据是"started_at 早于本次进程启动时间"——只抓"上一次进程重启前遗留的孤儿"。

我们这次的场景恰恰是设计上没覆盖的那一半:服务器主进程从头到尾没重启过,只是驱动某个节点的 Claude CLI 子进程自己死了/被中断。sweepOrphanedRunningNodes 从进程启动后再也没被调用第二次 (不是定时器,是一次性调用),永远不会看到这个节点。

2.3 其他周期性巡检,覆盖的是别的层级,判据对不上

巡检 位置 判据 为什么看不到这个节点
僵尸 goal 扫描 server/services/task-settle.js:634-682,startZombieGoalSweeper 每 5 分钟 task 是否全部到终态 看 task 状态,不看节点;task 本身是 running,非终态很正常
blocked task 扫描 task-settle.js:693-721,startCeoSweeper 每 30 分钟 SELECT ... WHERE status='blocked' 这个节点对应的 task 状态是 running,不是 blocked,查询条件直接漏掉
wallclock 预算看门狗 graph-engine.js:3056-3098,startWallclockWatchdog 每 60 秒(server/index.js:217-221 起) budget_json 里 wallclock_ms 是否耗尽 按预算判断,不按存活状态判断;只要没到预算上限就永远不触发,跟节点是死是活无关

2.4 ceo 的心跳指令明确写了"这不是你该管的",但依据的前提不成立

system/agents/ceo/heartbeat.md:18-21:

## 不用管的

- 正在排队 / 正在跑但没超时的 task——那是调度器的活,不用你盯
- 纯技术性的失败(比如基础设施抖动)——引擎自己有重试和熔断机制

这条指令的前提是"引擎自己有重试和熔断机制"——这句话对 2.2 描述的开机一次性孤儿扫描是对的, 但对"进程活着、子任务中途死掉"这个场景是不对的,因为根本没有对应的持续性巡检。指令和实际能力 之间的落差,就是这次 ceo 决定亲自动手(绕过引擎直接拉起会话)、进而陷入死循环的直接诱因—— 它被告知"别管,引擎会兜底",但引擎并不会兜底,人转发问题过来时它又没有一个"正确的"动作可用, 只能自己瞎折腾。


三、影响面

不只是这一个任务。任何一个 agent_call/subgraph 节点,只要驱动它的 Claude CLI 子进程在服务器 主进程没有重启的情况下异常终止(进程被杀、OOM、网络抖动导致连接掉线且没有走到正常的失败结算路径、 人工在别处手动 kill 掉了对应的 CLI 子进程等),这个节点就会永久卡在 running,无法通过任何已知的 自动机制恢复,只能靠人在控制台点"重试",或者靠 ceo 心跳时"想多了"越权直接插手(本次事故的成因)。

随着公司数量、并发 run 数增多,这类中途死亡只会更频繁——目前完全没有能力度量它发生的频率,因为 没有任何巡检会主动发现它,只能靠用户偶然点开 graph console 才会注意到。


四、建议修法:拆成四个 Part,互相独立,可以分开做

Part A(优先级最高):把"开机一次性孤儿扫描"升级成"持续性存活扫描"

现有 sweepOrphanedRunningNodes 的判据是"started_at 早于进程启动时间",这对持续运行的进程没有 意义。改成按存活信号判断:

  • 复用 SessionPool(server/src/core/session-pool.js:65 的 this.sessions Map,key 的组装方式见 server/src/core/agent-manager.js:531 那行日志的拼法,形如 acp:{companyId}:{agentId}:...:{runId}:...)——对每个 status='running' 超过一个阈值(建议先用 跟 SessionPool 空闲回收一致的量级,比如 10-15 分钟,具体数字要跟 idle-timer 的 TTL 对齐,避免 跟正常的长任务打架)的节点,反推它应该对应的 acp key,查 SessionPool 里还有没有活的条目。
  • 没有活会话 → 判定孤儿,走 sweepOrphanedRunningNodes 现成的那条路(routeFailure 的 infra_flake 分支),不用发明新状态。
  • 挂成周期性定时器,跟 startWallclockWatchdog(graph-engine.js:3086-3093)一个形状: setInterval + unref() + 幂等重复调用只留一个。可以直接加一个 startNodeLivenessWatchdog(intervalMs),在 server/index.js 里紧挨着 engine.startWallclockWatchdog()(217-221 行)那几行一起起。
  • 风险点:判断"节点应该对应哪个 SessionPool key"需要理清 key 的组装规则——目前这个拼法散落在 agent-manager.js 里,没有一个单独的、可复用的"给定 runId+nodeId 反推 key"的函数,可能需要先补一个。 这是本 Part 里工作量最大的一块,其余(挂定时器、复用 routeFailure)都是现成积木。

Part B:给 ceo(以及任何被转发这类问题的 agent)一个"正确的"重试动作,而不是逼它绕过引擎

这次死循环的直接诱因是 ceo 没有工具可用,只能自己拉起一个跟卡住节点撞 key 的新会话。应该:

  • 在 ceo 的 tools.md(或全公司通用的工具集)里加一个明确的"重试卡住节点"动作——本质是调一个 REST 端点或工具,语义是"把这个 running 节点标记为需要人工/引擎介入的失败态,交给已有的 routeFailure/重试熔断逻辑",而不是自己起一个平行会话去抢同一个 key。
  • 如果 Part A 落地,这个工具的大部分场景其实会被自动巡检提前接住,ceo 手动触发只是兜底/加急通道。
  • 需要先确认现有 graph-runs 相关路由(server/routes/graph-run.js 之类)有没有现成的"强制判失败/ 强制重试"端点可以直接复用——42 号文档 Phase 1 提过"强制判失败端点",如果那个端点已经存在,这里 可能只是把它包装成一个 agent 可调用的 tool,不用重新造。

Part C:更新 heartbeat.md 的"不用管的"一节,让指令跟实际能力对齐

system/agents/ceo/heartbeat.md:18-21 现在的措辞是"引擎自己有重试和熔断机制"——在 Part A 落地前, 这句话对"进程中途死亡"这个子场景是不准确的。两种改法二选一(或分阶段):

  1. Part A 落地后:这句话就是对的,不用改,只是需要补一句——如果心跳/巡检都没能自愈、用户主动 转发过来问,ceo 该用 Part B 的工具而不是自己动手拉会话。
  2. Part A 落地前的过渡期:把"不用管的"这一条改准确——明确写"节点级别的存活问题目前没有自动 巡检,如果用户主动转发这类情况,用 Part B 的工具处理,不要自己直接对着卡住的 run/节点拉起新会话"。

Part D(低优先级,可选):给节点补一个显式的"最后心跳时间"字段

目前 Part A 的存活判断完全依赖"SessionPool 里还有没有活会话"这个间接信号。如果 SessionPool 的生命周期跟节点生命周期没有严格一一对应(比如同一个会话被多个节点复用,或者节点执行期间会话 一度被 idle 回收又重建),间接信号可能不够可靠。更稳的做法是节点表补一个 last_heartbeat_at, 由驱动节点的 CLI 会话每隔一段时间主动更新;Part A 的判据改成"last_heartbeat_at 超过阈值未更新", 不再依赖对 SessionPool 内部实现的假设。这个改动面更大(要动 schema、动会话驱动循环里的写入点), 先做 Part A 用间接信号验证思路,效果不够再上这个。


五、实现记录(2026-08-31)

Part A:SessionPool 方案调查后判定不可行,改用运行时长阈值

写「四、建议修法」时以为 SessionPool(server/src/core/session-pool.js)是个持续存在、可查询 的会话注册表,实际去接的时候发现不是:server/services/graph-engine.js 里每一次 agent_call 节点派发都是 defaultAgentExecutor(377 行)现造一个 new AgentManager({}, ...), 调用结束(甚至成功也一样)那个 AgentManager/SessionPool 就没人引用了,等着被 GC——全仓库 没有一个"这个节点背后还有没有活进程"的持续性登记处。按原计划反查 key 会永远查不到,等于 一上线就把所有超过阈值的节点全判成孤儿,这是本可能造成的一次真实误伤,好在设计阶段的 "这个思路要不要行得通"这一步先做了,代码没有照着错的假设写下去。

改的方案:sweepStaleRunningAgentCalls(graph-engine.js)退回到和现有 sweepOrphanedRunningNodes/sweepWallclockBudgets 同一个风险等级的判据——「运行时长超过一个 保守阈值」(默认 25 分钟),不做真实存活探测。只扫 node_kind='agent_call' 的节点(subgraph 节点的时长横跨多个内部调用,同一固定阈值套上去误伤面完全不可控,本次不做,跟四.4 原提案的 理由一致);node_kind 是定义层字段,不在 graph_run_nodes 表上,判断时按 run_id 缓存 defIndex(run) 逐行核对,避免同一个 run 里的多个候选行重复读一遍定义图。

复用 sweepOrphanedRunningNodes 内层「transitionNode + routeFailure」那段逻辑,抽成 共用的 _reapStaleRunningNodes(rows, summary),两个调用方各自查自己的 rows。挂 startStaleAgentCallWatchdog(5 分钟一次的 setInterval + unref()),起跑点在 server/index.js,紧跟在墙钟看门狗那段后面,开机先补一次全量扫描再起定时器,同一个形状。

测试:server/tests/unit/graph-engine-stale-agent-call-sweep.test.js,5 个用例(超阈值判定/ 未超阈值不动/阈值可调/subgraph 不在范围内/无候选时安静 no-op),跟既有的 graph-engine-orphan-sweep.test.js/graph-engine-wallclock-watchdog.test.js 同一套隔离手法。 全量 vitest run server/tests/unit 1134/1136 通过(另 2 个失败是 .env.example 变量核对与 测试 id 稳定性守卫,跟本次改动的文件无关,改动前就已存在)。

已知局限(如实记录,不是掩盖):阈值判据不是真实存活探测——一个异常慢但真的还在跑的单次 调用如果超过 25 分钟,会被误判成死掉、原地重跑(代价是浪费一次真实调用,不烧 attempt 计数, 不会导致数据错乱);反过来一个刚死不到 25 分钟的节点,这段时间里还是没人管,跟"进程重启" 之外这一类失败之前的状态相比是净改善,但不是"立刻发现"。要做到更准,需要 Part D 那种真实 心跳信号。

Part B:force-fail 端点已经现成存在,只补了 tools.md 文档

调查发现 POST /api/graph-runs/:id/nodes/:nodeId/force-fail(server/routes/graph-run.js:395, 实现 graph-engine.js 的 forceFailNode)42 号文档 Phase 1b 已经做完了,语义完全匹配这里要的 东西:只在节点 status='running' 时可用,传 failReason:"infra_flake" 会走原地重跑不烧 attempt,传别的/不传走正常失败路由。没写一行新后端代码,只在 system/agents/ceo/tools.md 补了「卡住的 run 节点」一节(怎么查到真正卡住的那一层、怎么调这个端点),并把「你不该用的 工具」表里那条"不许碰 graph_run_nodes"的规则改成带唯一例外,同时新增一条"不许自己拉会话去 亲自跑"的规则——直接对应这次死循环事故的成因。

Part C:heartbeat.md 已更新

"不用管的"一节点名了 Part A 的两个函数,并加了一条新章节,明确"用户主动转发的卡住任务"这种 场景该走 tools.md 的两步流程,不许自己拉会话。

Part D:仍未实现,理由不变

Part A 落地后,Part D 的价值判断没变——「四、建议修法」原文那句"先做 Part A 验证效果,不够再 上这个"依然成立,而且因为 Part A 最终是阈值判据(不是真实存活探测),Part D(节点补显式 last_heartbeat_at,由驱动它的 CLI 会话定期回写)如果真做,能补上 Part A 现在这个"异常慢的 真实任务会被误判"的局限。但这需要把 nodeRunId 上下文一路穿透到 ClaudeCliProtocol 的 工具执行心跳回调里(目前那层协议代码完全不知道 graph 节点的存在),改动面和风险都明显更大, 这次没做,留给下一次有真实误伤样本、值得投入时再处理。


六、不在本文档范围内

  • ceo 这次具体为什么会陷入"think 死循环"(是不是工具调用协议本身有 bug、还是纯粹被卡住状态 搞懵了反复自我追问)——这是 harness/agent 行为层面的问题,跟"节点该怎么被自动捡回来"是两回事, 值得单独起一份诊断,不在这里展开。
  • 火星单(T-mtfr4p77-7e79)跟这条太空歌剧单之间"谁该占并发锁"顺序看起来反了这件事——是另一个 独立观察,见对话记录,未落成文档,跟本文档的节点存活问题无关。