# 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`)跟这条太空歌剧单之间"谁该占并发锁"顺序看起来反了这件事——是另一个 独立观察,见对话记录,未落成文档,跟本文档的节点存活问题无关。