45 · 卡在 running 且背后进程已死的 graph 节点,没有任何机制会重新捡起来
状态: ✅ Part A/B/C 已实现(2026-08-31),Part D 未实现(有意延后,见该节新增说明)。 Part A 落地时对最初的设计做了一次订正——见下面「五、实现记录」,别按「四、建议修法」原文 直接去代码里找 SessionPool 相关的东西,那条路线调查后判定不可行,已改道。 日期: 2026-08-31 发现场景:h3video 公司 goal
G-mtgmij2n-26d8/ taskT-mtgmij2n-d7ac(太空歌剧预告片)的h3-story阶段子图里,h3-story-draft节点(nodeKind: agent_call,rn_04a9474682f3, runrun_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.sessionsMap,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 落地前,
这句话对"进程中途死亡"这个子场景是不准确的。两种改法二选一(或分阶段):
- Part A 落地后:这句话就是对的,不用改,只是需要补一句——如果心跳/巡检都没能自愈、用户主动
转发过来问,
ceo该用 Part B 的工具而不是自己动手拉会话。 - 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)跟这条太空歌剧单之间"谁该占并发锁"顺序看起来反了这件事——是另一个 独立观察,见对话记录,未落成文档,跟本文档的节点存活问题无关。