Commit a4b8b87b0f4530afb3ac5f43afd76c1e645c80f8

Authored by yibo
1 parent 364788ae

fix(graph-engine): 卡在 running 且背后进程已死的节点持续性存活扫描(45 号文档 Part A-C)

真实事故:h3video 公司 h3-story-draft 节点卡在 status=running,背后 CLI 会话已死但服务器
进程没重启,开机一次性孤儿扫描/goal-task 巡检/wallclock 看门狗均看不到,ceo 被转发问题后
绕过引擎自己拉会话,陷入死循环。

- sweepStaleRunningAgentCalls + startStaleAgentCallWatchdog:运行时长超阈值(默认 25 分钟)
  的 agent_call 节点判 infra_flake 原地重跑,5 分钟一次巡检,挂进 server/index.js
- ceo/tools.md 补 force-fail 端点(已有,未改代码)的使用文档 + 禁止自拉会话的规则
- ceo/heartbeat.md 同步更新"不用管的"一节

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjFL1nfPDqZnJeuskYHwvs
docs/README.md
@@ -61,7 +61,7 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d @@ -61,7 +61,7 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d
61 | [37-org-graph-department-scoping.md](./todo/system-design/37-org-graph-department-scoping.md) | 📝 **设计讨论,调用点扫描已完成,尚未立项实现**。现状 org graph 按 `company_id` 单例(`27-P2` 标题原文即"公司级"),塞不下 HR/财务/市场这类跟"交付流水线"无关的公司常设职能——`system/agents/hr/`("招·留·管·退")本体已存在却从没被装进任何图,全靠 fire-and-forget chat 招聘、无图执行审计轨迹。提议把 org graph 作用域从 `company_id` 升级为 `(company_id, department_id)`,一家公司可装配多张部门图,每张仍保持人工固定/慢变/可审计。全仓库调用点扫描结论:改造面是最初估算的 4 倍——真正的单点真相在 `work-graph-db.js:readDefGraph` 的裸 SQL 不变式(不在服务层),还牵涉两个易混淆服务文件间必须字节级一致的重复唯一索引、独立的 `graph-seed-service.js` 对账子系统、`company-hire-org-layer.test.js` 专属测试、`orggraph-planning`/`task-planning` 两份 agent 人设文档的自我认知改写 | 61 | [37-org-graph-department-scoping.md](./todo/system-design/37-org-graph-department-scoping.md) | 📝 **设计讨论,调用点扫描已完成,尚未立项实现**。现状 org graph 按 `company_id` 单例(`27-P2` 标题原文即"公司级"),塞不下 HR/财务/市场这类跟"交付流水线"无关的公司常设职能——`system/agents/hr/`("招·留·管·退")本体已存在却从没被装进任何图,全靠 fire-and-forget chat 招聘、无图执行审计轨迹。提议把 org graph 作用域从 `company_id` 升级为 `(company_id, department_id)`,一家公司可装配多张部门图,每张仍保持人工固定/慢变/可审计。全仓库调用点扫描结论:改造面是最初估算的 4 倍——真正的单点真相在 `work-graph-db.js:readDefGraph` 的裸 SQL 不变式(不在服务层),还牵涉两个易混淆服务文件间必须字节级一致的重复唯一索引、独立的 `graph-seed-service.js` 对账子系统、`company-hire-org-layer.test.js` 专属测试、`orggraph-planning`/`task-planning` 两份 agent 人设文档的自我认知改写 |
62 | [36-prd-agent-methodology-training-feasibility.md](./todo/system-design/36-prd-agent-methodology-training-feasibility.md) | 📝 **调研 + 阶段一/二均已完成**。回应"落地页太单薄是不是该用 Innovator 先训练专业 agent、再用 Dream 持续迭代"——认同方向。**阶段一**:`graph-engine.js` 结算节点后把 `success`/`fail:<reason>` 写进本体 `memory.md`(此前只有人类对话路径会写,Dream 天天空转)。**阶段二**:新建独立试验节点 `prd-methodology-trial` 走真实 Hunt→Digest→Evolve,2 轮收敛(96/97 分,真实基线 50 分),三个真实历史项目反推验证方法论有效;过程中连带修复同一个 2026-08-16 改名批次遗留的 5 处 `innovator`→`org-innovator` 死链(后端两处硬编码、前端"重新训练"按钮、`/api/agent/list` 展示 id 来源的四个 `config.json`),以及 `AgentScaffold` 默认模板一处裸相对路径缺陷。结论:FDE 对方法论类角色确实管用,但收敛速度/验收严谨度依赖任务书显式写明的自纠正要求,非免人工黑盒。环节 3(训练产物 → 雇佣本体转换)尚未开工 | 62 | [36-prd-agent-methodology-training-feasibility.md](./todo/system-design/36-prd-agent-methodology-training-feasibility.md) | 📝 **调研 + 阶段一/二均已完成**。回应"落地页太单薄是不是该用 Innovator 先训练专业 agent、再用 Dream 持续迭代"——认同方向。**阶段一**:`graph-engine.js` 结算节点后把 `success`/`fail:<reason>` 写进本体 `memory.md`(此前只有人类对话路径会写,Dream 天天空转)。**阶段二**:新建独立试验节点 `prd-methodology-trial` 走真实 Hunt→Digest→Evolve,2 轮收敛(96/97 分,真实基线 50 分),三个真实历史项目反推验证方法论有效;过程中连带修复同一个 2026-08-16 改名批次遗留的 5 处 `innovator`→`org-innovator` 死链(后端两处硬编码、前端"重新训练"按钮、`/api/agent/list` 展示 id 来源的四个 `config.json`),以及 `AgentScaffold` 默认模板一处裸相对路径缺陷。结论:FDE 对方法论类角色确实管用,但收敛速度/验收严谨度依赖任务书显式写明的自纠正要求,非免人工黑盒。环节 3(训练产物 → 雇佣本体转换)尚未开工 |
63 | [34-validator-scored-without-real-deliverable.md](./todo/system-design/34-validator-scored-without-real-deliverable.md) | 📝 **调研记录,checklist 已查完**。最初以为 `org-coder-impl` 从未产出任何 HTML/CSS,后来发现真实网站其实存在于 `graph_files` 索引不覆盖的「项目根」目录(已顺手修:控制台「产物」面板现在会展示项目根)。追加查清 validator 有没有真的做浏览器级检查——中途自我订正一次:不是"没工具做不到"(`bash` 能走到仓库自带的 Playwright,另一条对照任务证明可行),是**没人教它这么做**;抽查另外两个项目根后发现**验证严谨度完全不稳定**(同类任务有的做了真实 Playwright 级验证、有的只做静态正则、有的没验证就被取消),根因很可能是新招聘的 `unit-tester` 本体撞上了 [32 号文档](./todo/system-design/32-hire-body-fill-reliability.md) 那类"装配没填充完"的真实样本,不是平台工具限制 | 63 | [34-validator-scored-without-real-deliverable.md](./todo/system-design/34-validator-scored-without-real-deliverable.md) | 📝 **调研记录,checklist 已查完**。最初以为 `org-coder-impl` 从未产出任何 HTML/CSS,后来发现真实网站其实存在于 `graph_files` 索引不覆盖的「项目根」目录(已顺手修:控制台「产物」面板现在会展示项目根)。追加查清 validator 有没有真的做浏览器级检查——中途自我订正一次:不是"没工具做不到"(`bash` 能走到仓库自带的 Playwright,另一条对照任务证明可行),是**没人教它这么做**;抽查另外两个项目根后发现**验证严谨度完全不稳定**(同类任务有的做了真实 Playwright 级验证、有的只做静态正则、有的没验证就被取消),根因很可能是新招聘的 `unit-tester` 本体撞上了 [32 号文档](./todo/system-design/32-hire-body-fill-reliability.md) 那类"装配没填充完"的真实样本,不是平台工具限制 |
64 -| [45-graph-run-node-liveness-recovery.md](./todo/system-design/45-graph-run-node-liveness-recovery.md) | 📝 **设计中,未实现**。真实事故:h3video 公司一个 `agent_call` 节点卡在 `status=running`、背后 CLI 进程已死,服务器主进程没重启——心跳(纯 LLM 驱动)、开机一次性孤儿扫描(`sweepOrphanedRunningNodes`,只在进程启动时跑一次)、goal/task 巡检(判据是 `blocked`,不是 `running`)、wallclock 看门狗(按预算不按存活)四层机制全部盖不到;`ceo` 心跳指令写"引擎自己有重试机制"但实际没有持续性巡检,导致 `ceo` 被转发问题后自己绕过引擎直接拉会话,陷入死循环。提案拆四个 Part:A 把孤儿扫描从"开机一次"升级成"持续性存活扫描"(复用 `SessionPool`)、B 给 agent 一个正确的"重试卡住节点"工具而非自己拉会话、C 更新 `heartbeat.md` 措辞、D(可选)节点补显式心跳字段 | 64 +| [45-graph-run-node-liveness-recovery.md](./todo/system-design/45-graph-run-node-liveness-recovery.md) | ✅ **Part A/B/C 已实现**(2026-08-31),**Part D 有意延后**。真实事故:h3video 公司一个 `agent_call` 节点卡在 `status=running`、背后 CLI 进程已死,服务器主进程没重启——心跳(纯 LLM 驱动)、开机一次性孤儿扫描、goal/task 巡检(判据是 `blocked` 不是 `running`)、wallclock 看门狗(按预算不按存活)四层机制全盖不到;`ceo` 被转发问题后自己绕过引擎拉会话,陷入死循环。**Part A**:调查发现 `SessionPool` 是每次调用现造即回收的一次性对象,没有可查询的存活注册表,原设计不可行,改用「运行时长超阈值(默认 25 分钟)+ 只扫 `agent_call` 节点」,新函数 `sweepStaleRunningAgentCalls`/`startStaleAgentCallWatchdog`(`graph-engine.js`),5 条新单测。**Part B**:`force-fail` 端点(42 号文档已实现)本身就够用,零新后端代码,只在 `ceo/tools.md` 补了使用文档 + 明确禁止绕过引擎自拉会话。**Part C**:`ceo/heartbeat.md` 同步更新。**Part D**(节点补显式心跳字段,消除 Part A 阈值判据的误伤局限)未做,见文内「实现记录」的理由 |
65 | [43-task-decomposition-granularity.md](./todo/system-design/43-task-decomposition-granularity.md) | ✅ **Phase 0-4 全部已实现**(2026-08-31)。回应"org-planner 出 brd/prd/sdd 后,任务看板能不能展示成 task/sub-task 树,coder-validator 按清单逐条开发验收"。`tasks` 加 `acceptance_mode`/`parent_task_id`(后者复用既有列);`task-planning` 判断要不要拆(硬规则 5 开例外让 LLM 写这一个字段);org-planner 收尾把 `acceptance.md` 物化成子任务;`coder-validator` 新增 `task subtask-result` 工具逐条上报,`settleNode()` 新增闸门强制核对子任务全 `completed` 才认 success;看板加子任务展示。**落地时订正了一处原计划想岔的架构假设**:org-planner 在 org 层永远是 `subgraph` 节点(不是 agent_call),真跑数据显示它当前展开成的 `planner.json` 5 节点子图里,`goal-benchmark`/`planner-validator` 两节点常被 skip,`acceptance.md` 未必真的产出——钩子已改成扫子 run 全节点、找不到就优雅降级为 holistic,不阻塞主产出,但"子任务一定会长出来"这件事目前是尽力而为不是保证,见文档"实现记录"一节的遗留风险说明。全量单测 1120/1123 通过(3 个失败与本次改动无关,本次改动前即存在) | 65 | [43-task-decomposition-granularity.md](./todo/system-design/43-task-decomposition-granularity.md) | ✅ **Phase 0-4 全部已实现**(2026-08-31)。回应"org-planner 出 brd/prd/sdd 后,任务看板能不能展示成 task/sub-task 树,coder-validator 按清单逐条开发验收"。`tasks` 加 `acceptance_mode`/`parent_task_id`(后者复用既有列);`task-planning` 判断要不要拆(硬规则 5 开例外让 LLM 写这一个字段);org-planner 收尾把 `acceptance.md` 物化成子任务;`coder-validator` 新增 `task subtask-result` 工具逐条上报,`settleNode()` 新增闸门强制核对子任务全 `completed` 才认 success;看板加子任务展示。**落地时订正了一处原计划想岔的架构假设**:org-planner 在 org 层永远是 `subgraph` 节点(不是 agent_call),真跑数据显示它当前展开成的 `planner.json` 5 节点子图里,`goal-benchmark`/`planner-validator` 两节点常被 skip,`acceptance.md` 未必真的产出——钩子已改成扫子 run 全节点、找不到就优雅降级为 holistic,不阻塞主产出,但"子任务一定会长出来"这件事目前是尽力而为不是保证,见文档"实现记录"一节的遗留风险说明。全量单测 1120/1123 通过(3 个失败与本次改动无关,本次改动前即存在) |
66 66
67 ## todo/future/(暂缓,非当前重心) 67 ## todo/future/(暂缓,非当前重心)
docs/todo/system-design/45-graph-run-node-liveness-recovery.md
1 # 45 ツキ 蜊。蝨ィ `running` 荳碑レ蜷手ソ帷ィ句キイ豁サ逧 graph 闃らせ梧イ。譛我ササ菴墓惻蛻カ莨夐譁ー謐。襍キ譚・ 1 # 45 ツキ 蜊。蝨ィ `running` 荳碑レ蜷手ソ帷ィ句キイ豁サ逧 graph 闃らせ梧イ。譛我ササ菴墓惻蛻カ莨夐譁ー謐。襍キ譚・
2 2
3 -> **迥カ諤**: 統 隶セ隶。荳ュ梧悴螳樒鴫縲よ悽譁。」蜿ェ譏ッ隸頑妙 + 菫ョ豕墓署譯茨シ御セ**豐。譛 CTO縲∝宵譛 CEO** 逧ス灘燕蝗「髦滄鄂ョ荳具シ  
4 -> 逕ア莠コ域荳倶ク谺。荳馴葎螟炊逧シ夊ッ晢シ画潔荳矩擇逧 Part 諡騾先擅謾ケ縲 3 +> **迥カ諤**: 笨 **Part A/B/C 蟾イ螳樒鴫**2026-08-31会シ**Part D 譛ェ螳樒鴫域怏諢丞サカ蜷趣シ瑚ァ∬ッ・闃よ眠蠅櫁ッエ譏趣シ**縲
  4 +> Part A 關ス蝨ー譌カ蟇ケ譛蛻晉噪隶セ隶。蛛壻コク谺。隶「豁」窶披碑ァ∽ク矩擇縲御コ斐∝ョ樒鴫隶ー蠖輔搾シ悟悪謖峨悟屁縲∝サコ隶ョ菫ョ豕輔榊次譁
  5 +> 逶エ謗・蜴サ莉」遐謇セ SessionPool 逶ク蜈ウ逧ク懆・ソ碁ぅ譚。霍ッ郤ソ隹衍蜷主愛螳壻ク榊庄陦鯉シ悟キイ謾ケ驕薙
5 > **譌・譛**: 2026-08-31 6 > **譌・譛**: 2026-08-31
6 > **蜿醍鴫蝨コ譎ッ**喇3video 蜈ャ蜿ク goal `G-mtgmij2n-26d8` / task `T-mtgmij2n-d7ac`亥、ェ遨コ豁悟鴬鬚相迚シ臥噪 7 > **蜿醍鴫蝨コ譎ッ**喇3video 蜈ャ蜿ク goal `G-mtgmij2n-26d8` / task `T-mtgmij2n-d7ac`亥、ェ遨コ豁悟鴬鬚相迚シ臥噪
7 > `h3-story` 髦カ谿オ蟄仙崟驥鯉シ形h3-story-draft` 闃らせnodeKind: agent_call`形rn_04a9474682f3` 8 > `h3-story` 髦カ谿オ蟄仙崟驥鯉シ形h3-story-draft` 闃らせnodeKind: agent_call`形rn_04a9474682f3`
@@ -159,7 +160,69 @@ Claude CLI 蟄占ソ帷ィ玖蟾ア豁サ莠/陲ォ荳ュ譁ュ縲AsweepOrphanedRunningNodes` 莉手ソ帷 @@ -159,7 +160,69 @@ Claude CLI 蟄占ソ帷ィ玖蟾ア豁サ莠/陲ォ荳ュ譁ュ縲AsweepOrphanedRunningNodes` 莉手ソ帷
159 160
160 --- 161 ---
161 162
162 -## 莠斐∽ク榊惠譛ャ譁。」闌峩蜀 163 +## 莠斐∝ョ樒鴫隶ー蠖包シ2026-08-31
  164 +
  165 +### Part A售essionPool 譁ケ譯郁ー衍蜷主愛螳壻ク榊庄陦鯉シ梧隼逕ィ霑占。梧慮髟ソ髦亥シ
  166 +
  167 +蜀吶悟屁縲∝サコ隶ョ菫ョ豕輔肴慮莉・荳コ `SessionPool`server/src/core/session-pool.js`画弍荳ェ謖∫サュ蟄伜惠縲∝庄譟・隸「
  168 +逧シ夊ッ晄ウィ蜀瑚。ィ悟ョ樣刔蜴サ謗・逧慮蛟吝書邇ー**荳肴弍**啻server/services/graph-engine.js` 驥梧ッ丈ク谺。
  169 +`agent_call` 闃らせ豢セ蜿鷹譏ッ `defaultAgentExecutor`377 陦鯉シ臥鴫騾荳荳ェ `new AgentManager({}, ...)`
  170 +隹畑扈捺據育爆閾ウ謌仙粥荵滉ク譬キ蛾ぅ荳ェ `AgentManager`/`SessionPool` 蟆ア豐。莠コ蠑慕畑莠シ檎ュ臥捩陲ォ GC窶披**蜈ィ莉灘コ
  171 +豐。譛我ク荳ェ"霑吩クェ闃らせ閭悟錘霑俶怏豐。譛画エサ霑帷ィ"逧戟扈ュ諤ァ逋サ隶ー螟**縲よ潔蜴溯ョ。蛻貞渚譟・ key 莨壽ーク霑懈衍荳榊芦檎ュ我コ
  172 +荳荳顔コソ蟆ア謚頑園譛芽カソ蛟シ逧鰍轤ケ蜈ィ蛻、謌仙ュ、蜆ソ瑚ソ呎弍譛ャ蜿ッ閭ス騾謌千噪荳谺。逵溷ョ櫁ッッ莨、悟・ス蝨ィ隶セ隶。髦カ谿オ逧
  173 +"霑吩クェ諤晁キッ隕∽ク崎ヲ∬。悟セ鈴"霑吩ク豁・蜈亥★莠シ御サ」遐∵イ。譛臥逹髞咏噪蛛ョセ蜀吩ク句悉縲
  174 +
  175 +**謾ケ逧婿譯**啻sweepStaleRunningAgentCalls`graph-engine.js`蛾蝗槫芦蜥檎鴫譛
  176 +`sweepOrphanedRunningNodes`/`sweepWallclockBudgets` 蜷御ク荳ェ鬟朱勦遲臥コァ逧愛謐ョ窶披斐瑚ソ占。梧慮髟ソ雜ソク荳ェ
  177 +菫晏ョ磯蛟シ縲搾シ磯サ倩ョ、 25 蛻帖会シ御ク榊★逵溷ョ槫ュ俶エサ謗「豬九ょ宵謇ォ `node_kind='agent_call'` 逧鰍轤ケsubgraph`
  178 +闃らせ逧慮髟ソ讓ェ霍ィ螟壻クェ蜀Κ隹畑悟酔荳蝗コ螳夐蛟シ螂嶺ク雁悉隸ッ莨、髱「螳悟荳榊庄謗ァ梧悽谺。荳榊★瑚キ溷屁.4 蜴滓署譯育噪
  179 +逅罰荳閾エ会シ嬾ode_kind 譏ッ螳壻ケ牙アょュ玲ョオ御ク榊惠 `graph_run_nodes` 陦ィ荳奇シ悟愛譁ュ譌カ謖 `run_id` 郛灘ュ
  180 +`defIndex(run)` 騾占。梧ク蟇ケ碁∩蜈榊酔荳荳ェ run 驥檎噪螟壻クェ蛟咎芽。碁螟崎ッサ荳驕榊ョ壻ケ牙崟縲
  181 +
  182 +螟咲畑 `sweepOrphanedRunningNodes` 蜀アゅ形transitionNode` + `routeFailure`縲埼ぅ谿オ騾サ霎托シ梧歓謌
  183 +蜈ア逕ィ逧 `_reapStaleRunningNodes(rows, summary)`御ク、荳ェ隹畑譁ケ蜷譟・閾ェ蟾ア逧 `rows`縲よ撃
  184 +`startStaleAgentCallWatchdog`5 蛻帖荳谺。逧 `setInterval` + `unref()`会シ瑚オキ霍醍せ蝨ィ
  185 +`server/index.js`檎エァ霍溷惠蠅咎帖逵矩葎迢鈴ぅ谿オ蜷朱擇悟シ譛コ蜈郁。・荳谺。蜈ィ驥乗沖謠丞襍キ螳壽慮蝎ィ悟酔荳荳ェ蠖「迥カ縲
  186 +
  187 +豬玖ッ包シ啻server/tests/unit/graph-engine-stale-agent-call-sweep.test.js`5 荳ェ逕ィ萓具シ郁カ蛟シ蛻、螳/
  188 +譛ェ雜蛟シ荳榊勘/髦亥シ蜿ッ隹/subgraph 荳榊惠闌峩蜀/譌蛟咎画慮螳蛾撕 no-op会シ瑚キ滓里譛臥噪
  189 +`graph-engine-orphan-sweep.test.js`/`graph-engine-wallclock-watchdog.test.js` 蜷御ク螂鈴囈遖サ謇区ウ輔
  190 +蜈ィ驥 `vitest run server/tests/unit` 1134/1136 騾夊ソシ亥嘗 2 荳ェ螟ア雍・譏ッ `.env.example` 蜿倬譬ク蟇ケ荳
  191 +豬玖ッ id 遞ウ螳壽ァ螳亥梱瑚キ滓悽谺。謾ケ蜉ィ逧枚莉カ譌蜈ウ梧隼蜉ィ蜑榊ーア蟾イ蟄伜惠峨
  192 +
  193 +**蟾イ遏・螻髯**亥ヲょョ櫁ョー蠖包シ御ク肴弍謗ゥ逶厄シ会シ夐蛟シ蛻、謐ョ荳肴弍逵溷ョ槫ュ俶エサ謗「豬銀披比ク荳ェ蠑ょクク諷「菴悄逧ソ伜惠霍醍噪蜊墓ャ。
  194 +隹畑螯よ棡雜ソ 25 蛻帖御シ夊「ォ隸ッ蛻、謌先ュサ謗峨∝次蝨ー驥崎キ托シ井サ」莉キ譏ッ豬ェ雍ケ荳谺。逵溷ョ櫁ー畑御ク咲Ι attempt 隶。謨ー
  195 +荳堺シ壼ッシ閾エ謨ー謐ョ髞吩ケア会シ帛渚霑擂荳荳ェ蛻壽ュサ荳榊芦 25 蛻帖逧鰍轤ケ瑚ソ呎ョオ譌カ髣エ驥瑚ソ俶弍豐。莠コ邂。瑚キ"霑帷ィ矩蜷ッ"
  196 +荵句、冶ソ吩ク邀サ螟ア雍・荵句燕逧憾諤∫嶌豈疲弍蜃謾ケ蝟シ御スク肴弍"遶句綾蜿醍鴫"縲りヲ∝★蛻ー譖エ蜃シ碁怙隕 Part D 驍」遘咲悄螳
  197 +蠢キウ菫。蜿キ縲
  198 +
  199 +### Part B啻force-fail` 遶ッ轤ケ蟾イ扈冗鴫謌仙ュ伜惠悟宵陦・莠 tools.md 譁。」
  200 +
  201 +隹衍蜿醍鴫 `POST /api/graph-runs/:id/nodes/:nodeId/force-fail`server/routes/graph-run.js:395`
  202 +螳樒鴫 `graph-engine.js` 逧 `forceFailNode`42 蜿キ譁。」 Phase 1b 蟾イ扈丞★螳御コシ瑚ッュ荵牙ョ悟蛹ケ驟崎ソ咎隕∫噪
  203 +荳懆・ソ壼宵蝨ィ闃らせ `status='running'` 譌カ蜿ッ逕ィ御シ `failReason:"infra_flake"` 莨夊オー蜴溷慍驥崎キ台ク咲Ι
  204 +`attempt`御シ蛻ォ逧/荳堺シ襍ー豁」蟶ク螟ア雍・霍ッ逕ア縲**豐。蜀吩ク陦梧眠蜷守ォッ莉」遐**悟宵蝨ィ `system/agents/ceo/tools.md`
  205 +陦・莠悟今菴冗噪 run 闃らせ縲堺ク闃ゑシ域惹ケ域衍蛻ー逵滓ュ」蜊。菴冗噪驍」荳螻ゅ∵惹ケ郁ーソ吩クェ遶ッ轤ケ会シ悟ケカ謚翫御ス荳崎ッ・逕ィ逧
  206 +蟾・蜈キ縲崎。ィ驥碁ぅ譚。"荳崎ョク遒ー graph_run_nodes"逧ァ謾ケ謌仙クヲ蜚ッ荳萓句、厄シ悟酔譌カ譁ー蠅樔ク譚。"荳崎ョク閾ェ蟾ア諡我シ夊ッ晏悉
  207 +莠イ閾ェ霍"逧ァ窶披皮峩謗・蟇ケ蠎碑ソ呎ャ。豁サ蠕ェ邇ッ莠区腐逧蝗縲
  208 +
  209 +### Part C啻heartbeat.md` 蟾イ譖エ譁ー
  210 +
  211 +"荳咲畑邂。逧"荳闃らせ蜷堺コ Part A 逧ク、荳ェ蜃ス謨ー悟ケカ蜉莠ク譚。譁ー遶闃ゑシ梧遑ョ"逕ィ謌キ荳サ蜉ィ霓ャ蜿醍噪蜊。菴丈ササ蜉。"霑咏ァ
  212 +蝨コ譎ッ隸・襍ー tools.md 逧ク、豁・豬∫ィ具シ御ク崎ョク閾ェ蟾ア諡我シ夊ッ昴
  213 +
  214 +### Part D壻サ肴悴螳樒鴫檎炊逕ア荳榊序
  215 +
  216 +Part A 關ス蝨ー蜷趣シ訓art D 逧サキ蛟シ蛻、譁ュ豐。蜿倪披斐悟屁縲∝サコ隶ョ菫ョ豕輔榊次譁ぅ蜿・"蜈亥★ Part A 鬪瑚ッ∵譜譫懶シ御ク榊、溷
  217 +荳願ソ吩クェ"萓晉┯謌千ォ具シ瑚御ク泌屏荳コ Part A 譛扈域弍髦亥シ蛻、謐ョ井ク肴弍逵溷ョ槫ュ俶エサ謗「豬具シ会シ訓art D郁鰍轤ケ陦・譏セ蠑
  218 +`last_heartbeat_at`檎罰鬩ア蜉ィ螳噪 CLI 莨夊ッ晏ョ壽悄蝗槫牙ヲよ棡逵溷★瑚陦・荳 Part A 邇ー蝨ィ霑吩クェ"蠑ょクク諷「逧
  219 +逵溷ョ樔ササ蜉。莨夊「ォ隸ッ蛻、"逧ア髯舌ゆスソ咎怙隕∵滑 nodeRunId 荳贋ク区枚荳霍ッ遨ソ騾丞芦 `ClaudeCliProtocol` 逧
  220 +蟾・蜈キ謇ァ陦悟ソキウ蝗櫁ー育岼蜑埼ぅ螻ょ刻隶ョ莉」遐∝ョ悟荳咲衍驕 graph 闃らせ逧ュ伜惠会シ梧隼蜉ィ髱「蜥碁」朱勦驛ス譏取仞譖エ螟ァ
  221 +霑呎ャ。豐。蛛夲シ檎蕗扈吩ク倶ク谺。譛臥悄螳櫁ッッ莨、譬キ譛ャ縲∝シ蠕玲兜蜈・譌カ蜀榊、炊縲
  222 +
  223 +---
  224 +
  225 +## 蜈ュ縲∽ク榊惠譛ャ譁。」闌峩蜀
163 226
164 - `ceo` 霑呎ャ。蜈キ菴謎クコ莉荵井シ夐匚蜈・"think 豁サ蠕ェ邇ッ"域弍荳肴弍蟾・蜈キ隹畑蜊剰ョョ譛ャ霄ォ譛 bug縲∬ソ俶弍郤ッ邊ケ陲ォ蜊。菴冗憾諤 227 - `ceo` 霑呎ャ。蜈キ菴謎クコ莉荵井シ夐匚蜈・"think 豁サ蠕ェ邇ッ"域弍荳肴弍蟾・蜈キ隹畑蜊剰ョョ譛ャ霄ォ譛 bug縲∬ソ俶弍郤ッ邊ケ陲ォ蜊。菴冗憾諤
165 謳樊莠渚螟崎謌題ソス髣ョ俄披碑ソ呎弍 harness/agent 陦御クコ螻る擇逧琉鬚假シ瑚キ"闃らせ隸・諤惹ケ郁「ォ閾ェ蜉ィ謐。蝗樊擂"譏ッ荳、蝗樔コ具シ 228 謳樊莠渚螟崎謌題ソス髣ョ俄披碑ソ呎弍 harness/agent 陦御クコ螻る擇逧琉鬚假シ瑚キ"闃らせ隸・諤惹ケ郁「ォ閾ェ蜉ィ謐。蝗樊擂"譏ッ荳、蝗樔コ具シ
server/index.js
@@ -222,6 +222,20 @@ async function start() { @@ -222,6 +222,20 @@ async function start() {
222 } catch (err) { 222 } catch (err) {
223 console.error('[Startup] 墙钟看门狗启动失败:', err.message); 223 console.error('[Startup] 墙钟看门狗启动失败:', err.message);
224 } 224 }
  225 + // 45 号文档 Part A:持续性存活扫描。上面 sweepOrphanedRunningNodes 只在开机那一刻
  226 + // 抓「上一个进程实例被杀掉留下的孤儿」,抓不住「进程没重启、驱动某个 agent_call
  227 + // 节点的 CLI 会话中途死掉/被打断」这一半(真实事故:h3video 公司 h3-story-draft
  228 + // 节点)。同样先补一次全量扫描,再起 5 分钟兜底定时器,形状同墙钟看门狗。
  229 + try {
  230 + const engine = require('./services/graph-engine');
  231 + const swept = engine.sweepStaleRunningAgentCalls();
  232 + if (swept.swept.length) {
  233 + console.log(`[Startup] 💤 存活扫描:处理 ${swept.swept.length} 条运行超时的 agent_call 节点`);
  234 + }
  235 + engine.startStaleAgentCallWatchdog();
  236 + } catch (err) {
  237 + console.error('[Startup] 存活扫描看门狗启动失败:', err.message);
  238 + }
225 const heartbeatService = require('./services/heartbeat-service'); 239 const heartbeatService = require('./services/heartbeat-service');
226 heartbeatService.start(); 240 heartbeatService.start();
227 // 公司级心跳 (Pipeline 自动驱动) 241 // 公司级心跳 (Pipeline 自动驱动)
server/services/graph-engine.js
@@ -3132,11 +3132,16 @@ function stopWallclockWatchdog() { @@ -3132,11 +3132,16 @@ function stopWallclockWatchdog() {
3132 * 测试用任意时刻构造场景。 3132 * 测试用任意时刻构造场景。
3133 * @returns {{scanned:number, swept:Array<{runId:string, nodeRunId:string, infraRerun:boolean}>}} 3133 * @returns {{scanned:number, swept:Array<{runId:string, nodeRunId:string, infraRerun:boolean}>}}
3134 */ 3134 */
3135 -function sweepOrphanedRunningNodes(cutoffIso) {  
3136 - const rows = getDb()  
3137 - .prepare(`SELECT id, run_id FROM graph_run_nodes WHERE status = 'running' AND started_at IS NOT NULL AND started_at < ?`)  
3138 - .all(cutoffIso);  
3139 - 3135 +/**
  3136 + * 共用的「判孤儿→infra_flake 结算」内层循环,`sweepOrphanedRunningNodes`(开机一次性)与
  3137 + * `sweepStaleRunningAgentCalls`(45 号文档 Part A:持续性存活扫描)各自查自己的 `rows`,
  3138 + * 结算逻辑完全一致,不重复抄一遍 `transitionNode`+`routeFailure` 那一段。
  3139 + *
  3140 + * @param {Array<{id:string, run_id:string}>} rows
  3141 + * @param {string} summary 落进 timeline 的人读摘要,两个调用方文案不同
  3142 + * @returns {{scanned:number, swept:Array<{runId:string, nodeRunId:string, infraRerun:boolean}>}}
  3143 + */
  3144 +function _reapStaleRunningNodes(rows, summary) {
3140 const swept = []; 3145 const swept = [];
3141 for (const row of rows) { 3146 for (const row of rows) {
3142 try { 3147 try {
@@ -3147,7 +3152,7 @@ function sweepOrphanedRunningNodes(cutoffIso) { @@ -3147,7 +3152,7 @@ function sweepOrphanedRunningNodes(cutoffIso) {
3147 const changed = sse.transitionNode(row.run_id, row.id, 'running', 'fail', { 3152 const changed = sse.transitionNode(row.run_id, row.id, 'running', 'fail', {
3148 failReason: INFRA_FLAKE_REASON, 3153 failReason: INFRA_FLAKE_REASON,
3149 failNodeRef: null, outputRef: null, agentRunId: null, childRunId: null, completedAt: now(), 3154 failNodeRef: null, outputRef: null, agentRunId: null, childRunId: null, completedAt: now(),
3150 - }, { summary: `开机孤儿节点清扫:服务器重启前处于 running,背后进程已不存在,判 infra_flake 交给既有熔断/重跑逻辑` }); 3155 + }, { summary });
3151 if (!changed) continue; // 0 行 = 已经被别的路径结算过了,不重复处理 3156 if (!changed) continue; // 0 行 = 已经被别的路径结算过了,不重复处理
3152 3157
3153 const nodeRun = getRunNode(row.id); 3158 const nodeRun = getRunNode(row.id);
@@ -3155,12 +3160,94 @@ function sweepOrphanedRunningNodes(cutoffIso) { @@ -3155,12 +3160,94 @@ function sweepOrphanedRunningNodes(cutoffIso) {
3155 routeFailure(row.run_id, def, nodeRun, out, null); 3160 routeFailure(row.run_id, def, nodeRun, out, null);
3156 swept.push({ runId: row.run_id, nodeRunId: row.id, infraRerun: out.infraRerun }); 3161 swept.push({ runId: row.run_id, nodeRunId: row.id, infraRerun: out.infraRerun });
3157 } catch (err) { 3162 } catch (err) {
3158 - console.error(`[graph-engine] sweepOrphanedRunningNodes node=${row.id} 异常: ${err.message}`); 3163 + console.error(`[graph-engine] _reapStaleRunningNodes node=${row.id} 异常: ${err.message}`);
3159 } 3164 }
3160 } 3165 }
3161 return { scanned: rows.length, swept }; 3166 return { scanned: rows.length, swept };
3162 } 3167 }
3163 3168
  3169 +function sweepOrphanedRunningNodes(cutoffIso) {
  3170 + const rows = getDb()
  3171 + .prepare(`SELECT id, run_id FROM graph_run_nodes WHERE status = 'running' AND started_at IS NOT NULL AND started_at < ?`)
  3172 + .all(cutoffIso);
  3173 + return _reapStaleRunningNodes(rows, `开机孤儿节点清扫:服务器重启前处于 running,背后进程已不存在,判 infra_flake 交给既有熔断/重跑逻辑`);
  3174 +}
  3175 +
  3176 +const STALE_AGENT_CALL_SWEEP_INTERVAL_MS = 5 * 60 * 1000;
  3177 +// 45 号文档 Part A 调查结论:graph 节点派发用的 AgentManager/SessionPool 是每次调用现造、
  3178 +// 调用完即回收的一次性对象(`defaultAgentExecutor`,本文件 377 行),全仓库没有一个持续存在、
  3179 +// 可查询的"这个节点背后还有没有活进程"的注册表——`sweepOrphanedRunningNodes` 能用的
  3180 +// "跟本次进程启动时间比"这条判据,对"进程没重启、单次调用中途死掉"这种情况完全用不上
  3181 +// (持续性)。在补一个真正的存活信号(45 号文档 Part D:驱动节点的 CLI 会话定期回写
  3182 +// last_heartbeat)之前,只能退而求其次——按「运行时长超过一个保守阈值」判定,与
  3183 +// `sweepWallclockBudgets` 对 run 级预算的处理是同一种取舍:可能会误判一个异常慢的真实
  3184 +// 长任务,也可能漏判一个刚死不久还没到阈值的节点,不是精确的存活探测。
  3185 +// 阈值只按 node_kind='agent_call' 收窄——subgraph 节点的"运行时长"横跨多个内部真实调用,
  3186 +// 拿同一个固定阈值判它会误伤得离谱,那类节点的存活状态应该递归看它的子 run,本次不做。
  3187 +const DEFAULT_STALE_AGENT_CALL_THRESHOLD_MS = 25 * 60 * 1000;
  3188 +
  3189 +/**
  3190 + * 45 号文档 Part A:把「开机一次性孤儿扫描」延伸出一条持续性版本,抓"进程没重启、但驱动
  3191 + * 某个 agent_call 节点的 CLI 会话中途死掉/被打断"这一半开机扫描看不到的场景(真实事故:
  3192 + * h3video 公司 `h3-story-draft` 节点,见 45 号文档时间线)。
  3193 + *
  3194 + * 判据是"运行时长超过阈值"而不是真实存活探测,见上面 `DEFAULT_STALE_AGENT_CALL_THRESHOLD_MS`
  3195 + * 那段注释里记的取舍——这是已知的、暂时接受的局限,不是本函数的 bug。
  3196 + *
  3197 + * @param {object} [opts]
  3198 + * @param {number} [opts.thresholdMs] 运行超过多久判定为"大概率死了",默认 25 分钟
  3199 + * @param {string} [opts.nowIso] 参照的"当下"时刻,默认真实当前时间;测试用固定值构造场景
  3200 + * @returns {{scanned:number, swept:Array<{runId:string, nodeRunId:string, infraRerun:boolean}>}}
  3201 + */
  3202 +function sweepStaleRunningAgentCalls(opts = {}) {
  3203 + const thresholdMs = opts.thresholdMs ?? DEFAULT_STALE_AGENT_CALL_THRESHOLD_MS;
  3204 + const nowIso = opts.nowIso || now();
  3205 + const cutoffIso = new Date(Date.parse(nowIso) - thresholdMs).toISOString();
  3206 +
  3207 + // node_kind 是定义层字段(work_graph_nodes/org_graph_nodes),graph_run_nodes 这张运行时表
  3208 + // 只有 def_node_id——先按「running 超过阈值」把候选行都拿出来,再用 defIndex(run) 逐个查
  3209 + // 它对应的定义节点是不是 agent_call,把 subgraph 节点过滤掉(defIndex 内部已缓存不了,
  3210 + // 这里按 run_id 手动缓存一下,避免同一个 run 里多个候选节点重复读同一张定义图)。
  3211 + const rows = getDb()
  3212 + .prepare(`SELECT id, run_id, def_node_id FROM graph_run_nodes
  3213 + WHERE status = 'running' AND started_at IS NOT NULL AND started_at < ?`)
  3214 + .all(cutoffIso);
  3215 +
  3216 + const defCache = new Map();
  3217 + const agentCallRows = rows.filter((row) => {
  3218 + let def = defCache.get(row.run_id);
  3219 + if (def === undefined) {
  3220 + const run = getRun(row.run_id);
  3221 + def = run ? defIndex(run) : null;
  3222 + defCache.set(row.run_id, def);
  3223 + }
  3224 + const node = def && def.nodes.get(row.def_node_id);
  3225 + return node && node.nodeKind === 'agent_call';
  3226 + });
  3227 +
  3228 + return _reapStaleRunningNodes(
  3229 + agentCallRows,
  3230 + `持续性存活扫描:agent_call 节点 running 超过 ${Math.round(thresholdMs / 60000)} 分钟,判 infra_flake 交给既有熔断/重跑逻辑(45 号文档 Part A)`
  3231 + );
  3232 +}
  3233 +
  3234 +let staleAgentCallSweepTimer = null;
  3235 +
  3236 +/** 起兜底定时器,形状照抄 `startWallclockWatchdog`:`unref()` + 幂等只留一个。 */
  3237 +function startStaleAgentCallWatchdog(intervalMs = STALE_AGENT_CALL_SWEEP_INTERVAL_MS, thresholdMs) {
  3238 + if (staleAgentCallSweepTimer) return staleAgentCallSweepTimer;
  3239 + staleAgentCallSweepTimer = setInterval(() => {
  3240 + try { sweepStaleRunningAgentCalls({ thresholdMs }); } catch (err) { console.error(`[graph-engine] 存活扫描看门狗异常: ${err.message}`); }
  3241 + }, intervalMs);
  3242 + if (typeof staleAgentCallSweepTimer.unref === 'function') staleAgentCallSweepTimer.unref();
  3243 + return staleAgentCallSweepTimer;
  3244 +}
  3245 +
  3246 +function stopStaleAgentCallWatchdog() {
  3247 + if (staleAgentCallSweepTimer) clearInterval(staleAgentCallSweepTimer);
  3248 + staleAgentCallSweepTimer = null;
  3249 +}
  3250 +
3164 /** 3251 /**
3165 * 扣减(「只增不减」,§四.A.3)。始终写 root run 那一行。 3252 * 扣减(「只增不减」,§四.A.3)。始终写 root run 那一行。
3166 * ponytail: 读-改-写不是原子的,当前单进程串行驱动(runAuto 一次只推一个节点)下够用; 3253 * ponytail: 读-改-写不是原子的,当前单进程串行驱动(runAuto 一次只推一个节点)下够用;
@@ -5130,6 +5217,11 @@ module.exports = { @@ -5130,6 +5217,11 @@ module.exports = {
5130 // 42 号文档 Phase 1a:开机孤儿节点清扫(服务器重启杀掉 in-flight 进程,节点行留在 running) 5217 // 42 号文档 Phase 1a:开机孤儿节点清扫(服务器重启杀掉 in-flight 进程,节点行留在 running)
5131 sweepOrphanedRunningNodes, 5218 sweepOrphanedRunningNodes,
5132 5219
  5220 + // 45 号文档 Part A:持续性存活扫描(进程没重启、agent_call 节点中途死掉的那一半开机扫描看不到)
  5221 + sweepStaleRunningAgentCalls,
  5222 + startStaleAgentCallWatchdog,
  5223 + stopStaleAgentCallWatchdog,
  5224 +
5133 // 重试级联判据(§四.C 末段) 5225 // 重试级联判据(§四.C 末段)
5134 diffContracts, 5226 diffContracts,
5135 applyRetryCascade, 5227 applyRetryCascade,
server/tests/unit/graph-engine-stale-agent-call-sweep.test.js 0 → 100644
  1 +/**
  2 + * Unit Tests — graph-engine 的持续性存活扫描(45 号文档 Part A)
  3 + *
  4 + * 场景:服务器主进程没有重启,但驱动某个 agent_call 节点的 Claude CLI 会话在调用中途
  5 + * 死掉/被打断,`graph_run_nodes.status` 永远停在 'running'。开机一次性孤儿扫描
  6 + * (`sweepOrphanedRunningNodes`,按「早于本次进程启动时间」判定)看不到这种情况——本文件
  7 + * 测的是它的持续性版本:`sweepStaleRunningAgentCalls`,按「running 超过一个阈值」判定,
  8 + * 且只扫 `node_kind='agent_call'`(subgraph 节点的"运行时长"横跨多个内部调用,不适用
  9 + * 同一固定阈值,本次不做)。
  10 + *
  11 + * 隔离手法与 graph-engine-orphan-sweep.test.js 一致。
  12 + */
  13 +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
  14 +import fs from 'fs';
  15 +import os from 'os';
  16 +import path from 'path';
  17 +
  18 +describe('graph-engine 持续性存活扫描', () => {
  19 + let wdb;
  20 + let engine;
  21 + let runsDir;
  22 + const TEST_DB_PATH = '/tmp/test-graph-engine-stale-agent-call-sweep.sqlite';
  23 +
  24 + beforeAll(() => {
  25 + for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) {
  26 + try { fs.unlinkSync(f); } catch {}
  27 + }
  28 + process.env.GRAPH_DB_PATH = TEST_DB_PATH;
  29 + runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-engine-stale-agent-call-sweep-'));
  30 + process.env.GRAPH_RUNS_DIR = runsDir;
  31 + for (const m of [
  32 + '../../services/graph-db',
  33 + '../../services/graph-service',
  34 + '../../services/org-graph-service',
  35 + '../../services/work-graph-db',
  36 + '../../services/graph-run-sse',
  37 + '../../services/graph-engine',
  38 + ]) {
  39 + delete require.cache[require.resolve(m)];
  40 + }
  41 + wdb = require('../../services/work-graph-db');
  42 + engine = require('../../services/graph-engine');
  43 + });
  44 +
  45 + afterAll(() => {
  46 + for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) {
  47 + try { fs.unlinkSync(f); } catch {}
  48 + }
  49 + delete process.env.GRAPH_DB_PATH;
  50 + try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {}
  51 + delete process.env.GRAPH_RUNS_DIR;
  52 + });
  53 +
  54 + beforeEach(() => {
  55 + const db = wdb.getDb();
  56 + db.pragma('foreign_keys = OFF');
  57 + db.exec(`
  58 + DELETE FROM graph_run_loop_records;
  59 + DELETE FROM graph_run_nodes;
  60 + DELETE FROM graph_runs;
  61 + DELETE FROM work_graph_edges;
  62 + DELETE FROM work_graph_nodes;
  63 + DELETE FROM work_graphs;
  64 + DELETE FROM system_agents;
  65 + `);
  66 + db.pragma('foreign_keys = ON');
  67 + engine.setAgentExecutor(null);
  68 + });
  69 +
  70 + let seq = 0;
  71 + const nextCompany = () => `co-stale-${++seq}`;
  72 +
  73 + function ensureAgents(ids) {
  74 + const db = wdb.getDb();
  75 + const now = new Date().toISOString();
  76 + const ins = db.prepare(`
  77 + INSERT OR IGNORE INTO system_agents
  78 + (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at)
  79 + VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?)
  80 + `);
  81 + for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, now, now);
  82 + }
  83 +
  84 + function buildWorkRun({ companyId, nodes, edges }) {
  85 + ensureAgents(nodes);
  86 + const g = wdb.createWorkGraph({ companyId, nodes, edges });
  87 + expect(g.errors || []).toEqual([]);
  88 + const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId, executionMode: 'manual' });
  89 + engine.markRunReady(run.id);
  90 + return { runId: run.id };
  91 + }
  92 +
  93 + const nodeByName = (runId, name) => wdb.getRunState(runId).nodes.find((n) => n.name === name);
  94 +
  95 + function markAsRunning(runId, nodeName, startedAtIso) {
  96 + const node = nodeByName(runId, nodeName);
  97 + wdb.getDb()
  98 + .prepare(`UPDATE graph_run_nodes SET status = 'running', started_at = ?, updated_at = ? WHERE id = ?`)
  99 + .run(startedAtIso, startedAtIso, node.id);
  100 + return node.id;
  101 + }
  102 +
  103 + const NOW = '2026-08-31T10:00:00.000Z';
  104 +
  105 + it('agent_call 节点 running 超过阈值判定为存活超时,走 infra_flake 原地重跑', () => {
  106 + const companyId = nextCompany();
  107 + const { runId } = buildWorkRun({ companyId, nodes: ['w'], edges: [] });
  108 + // 默认阈值 25 分钟;30 分钟前开始 = 超阈值
  109 + markAsRunning(runId, 'w', '2026-08-31T09:30:00.000Z');
  110 +
  111 + const result = engine.sweepStaleRunningAgentCalls({ nowIso: NOW });
  112 +
  113 + expect(result.scanned).toBe(1);
  114 + expect(result.swept).toHaveLength(1);
  115 + expect(result.swept[0]).toMatchObject({ runId, infraRerun: true });
  116 + const fresh = nodeByName(runId, 'w');
  117 + expect(fresh.status).toBe('ready');
  118 + expect(fresh.attempt).toBe(0);
  119 + });
  120 +
  121 + it('running 未超过阈值的节点不受影响(大概率还在真实跑)', () => {
  122 + const companyId = nextCompany();
  123 + const { runId } = buildWorkRun({ companyId, nodes: ['w'], edges: [] });
  124 + // 5 分钟前开始,远低于默认 25 分钟阈值
  125 + markAsRunning(runId, 'w', '2026-08-31T09:55:00.000Z');
  126 +
  127 + const result = engine.sweepStaleRunningAgentCalls({ nowIso: NOW });
  128 +
  129 + expect(result.scanned).toBe(0);
  130 + expect(nodeByName(runId, 'w').status).toBe('running');
  131 + });
  132 +
  133 + it('阈值可调:传更短的 thresholdMs 会让原本"没超时"的节点也被扫到', () => {
  134 + const companyId = nextCompany();
  135 + const { runId } = buildWorkRun({ companyId, nodes: ['w'], edges: [] });
  136 + markAsRunning(runId, 'w', '2026-08-31T09:55:00.000Z'); // 5 分钟前
  137 +
  138 + const result = engine.sweepStaleRunningAgentCalls({ nowIso: NOW, thresholdMs: 60 * 1000 }); // 阈值 1 分钟
  139 +
  140 + expect(result.scanned).toBe(1);
  141 + expect(nodeByName(runId, 'w').status).toBe('ready');
  142 + });
  143 +
  144 + it('subgraph 节点不在扫描范围内,即使 running 很久也不受影响', () => {
  145 + const companyId = nextCompany();
  146 + ensureAgents(['s']);
  147 + const g = wdb.createWorkGraph({ companyId, nodes: [{ nodeName: 's', nodeKind: 'subgraph' }], edges: [] });
  148 + expect(g.errors || []).toEqual([]);
  149 + const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId, executionMode: 'manual' });
  150 + engine.markRunReady(run.id);
  151 + markAsRunning(run.id, 's', '2026-08-31T08:00:00.000Z'); // 2 小时前,远超阈值
  152 +
  153 + const result = engine.sweepStaleRunningAgentCalls({ nowIso: NOW });
  154 +
  155 + expect(result.scanned).toBe(0);
  156 + expect(nodeByName(run.id, 's').status).toBe('running');
  157 + });
  158 +
  159 + it('没有任何超时节点时是安静的 no-op', () => {
  160 + const companyId = nextCompany();
  161 + buildWorkRun({ companyId, nodes: ['w'], edges: [] }); // 节点是 ready,不是 running
  162 + const result = engine.sweepStaleRunningAgentCalls({ nowIso: NOW });
  163 + expect(result).toEqual({ scanned: 0, swept: [] });
  164 + });
  165 +});
system/agents/ceo/heartbeat.md
@@ -18,4 +18,13 @@ @@ -18,4 +18,13 @@
18 ## 不用管的 18 ## 不用管的
19 19
20 - 正在排队 / 正在跑但没超时的 task——那是调度器的活,不用你盯 20 - 正在排队 / 正在跑但没超时的 task——那是调度器的活,不用你盯
21 -- 纯技术性的失败(比如基础设施抖动)——引擎自己有重试和熔断机制 21 +- 纯技术性的失败(比如基础设施抖动)——引擎自己有重试和熔断机制(`sweepOrphanedRunningNodes` +
  22 + `sweepStaleRunningAgentCalls`,见 45 号文档 Part A;后者是持续性扫描,25 分钟自动处理一次,
  23 + **心跳例行扫描不用替它兜底**,不要主动去查每条 running task 卡了多久)
  24 +
  25 +## 用户直接把"这条任务卡住了"转给你时该怎么办
  26 +
  27 +心跳例行扫描不用管上面那条,但如果是**用户主动**转发过来、明确让你看一条卡住的任务,按
  28 +`tools.md`「卡住的 run 节点」一节的两步处理(先查、再用 `force-fail` 端点)。**不要自己对着
  29 +卡住的 run/节点拉起一个新的 agent 会话去亲自跑一遍**——那是绕过引擎,会跟卡住的状态打架;
  30 +上一次这么干直接陷入了工具调用死循环,最后靠人手动中断才停下(见 45 号文档时间线)。
system/agents/ceo/tools.md
@@ -78,6 +78,56 @@ curl -s -X POST &quot;${LEMON_API_BASE:-http://localhost:3010/api}/company/&lt;companyId @@ -78,6 +78,56 @@ curl -s -X POST &quot;${LEMON_API_BASE:-http://localhost:3010/api}/company/&lt;companyId
78 78
79 --- 79 ---
80 80
  81 +## 卡住的 run 节点:只能用这一个端点处理,不许自己动手
  82 +
  83 +一条 task 的 `runId`(从 `GET /:companyId/goals/<goalId>` 的 task 摘要里能看到)对应一棵 graph
  84 +run 树。如果一条 task 长时间 `running` 不动、你判断需要介入,**只能**走下面这两步,
  85 +**不许**做的事情见本节末尾。
  86 +
  87 +### 1. 先查,找到真正卡住的那个节点
  88 +
  89 +```bash
  90 +curl -s "${LEMON_API_BASE:-http://localhost:3010/api}/graph-runs/<runId>" \
  91 + -H "x-internal-token: $LEMON_INTERNAL_TOKEN"
  92 +```
  93 +
  94 +返回里 `nodes[]` 是这一层的节点状态,`childRuns[]` 是往下展开出的子 run(org 节点会展开成
  95 +work graph 子 run)。**卡住的往往不是你拿到的第一层 `runId`,是它 `childRuns[]` 里那个**
  96 +——顺着 `childRuns[].id` 递归 `GET` 下去,找到最深那一层里 `status: 'running'` 且
  97 +`agentRunId`/`childRunId` 都是 `null` 的节点,那才是真正卡死、没有背后进程在跑的那一个。
  98 +
  99 +### 2. 确认是真卡死之后,用这个端点让引擎按既有失败路由处理
  100 +
  101 +```bash
  102 +curl -s -X POST "${LEMON_API_BASE:-http://localhost:3010/api}/graph-runs/<那一层真正卡住的runId>/nodes/<nodeId>/force-fail" \
  103 + -H "x-internal-token: $LEMON_INTERNAL_TOKEN" \
  104 + -H "Content-Type: application/json" \
  105 + -d '{"failReason":"infra_flake"}'
  106 +```
  107 +
  108 +`failReason` 传 `infra_flake`:这是"不是这个节点自己的错,是基础设施打断"的既有语义,引擎会
  109 +原地重跑、不烧这个节点的 `attempt` 计数。**只有 `nodeId` 属于你上一步在 `<那一层真正卡住的
  110 +runId>` 下面查到的、status 恰好是 `running` 的节点时,这个端点才会生效**(其他状态会直接
  111 +400 报错,不会误伤)。
  112 +
  113 +不确定要不要传 `infra_flake`(比如你怀疑是节点自己的逻辑问题,不是纯粹的基础设施打断):
  114 +不传 `failReason`,默认走 `operator_intervention`,节点会转 `fail` 交给正常的失败路由/回退边
  115 +处理,而不是原地重跑——两者的区别只是"该不该算这个节点头上",出错方向选保守的那个就好。
  116 +
  117 +### 你不该做的事(45 号文档记录过一次真实事故)
  118 +
  119 +- **不要自己直接对着这个 run/节点拉起一个新的 agent 会话去"亲自跑一遍"**——那是绕过引擎,
  120 + 会跟卡住的状态本身打架,产生冲突/重复调用,上一次这么干直接陷入死循环,最后靠人手动中断
  121 + 才停下。你能做的只有上面这一个端点,没有第二条路。
  122 +- 不要对**不是 `running`** 的节点调 `force-fail`(会直接报错,这不是漏做校验,是刻意的边界)。
  123 +- 判断"卡住多久算该管":目前没有一个精确的存活探测,只能凭经验——如果这条 task 的进度
  124 + (goal 详情里的 `progress`)长时间(半小时以上)没有变化,且你手头没有别的信息能解释
  125 + 为什么慢,可以认为大概率是死了。引擎自己也有一条持续性扫描会在 25 分钟后自动处理同类情况
  126 + (见 `docs/todo/system-design/45-graph-run-node-liveness-recovery.md` Part A)——多数情况下
  127 + 不需要你手动介入,这一节是给"用户直接把卡住的任务转给你、要你立刻看一下"这种场景兜底的。
  128 +
  129 +---
  130 +
81 ## 什么时候该提新任务、什么时候该改优先级 131 ## 什么时候该提新任务、什么时候该改优先级
82 132
83 - **一条 task 长时间 `blocked` 或 `failed`**(预算耗尽 / 依赖死锁 / 反复回退熔断):看它的 133 - **一条 task 长时间 `blocked` 或 `failed`**(预算耗尽 / 依赖死锁 / 反复回退熔断):看它的
@@ -100,5 +150,6 @@ curl -s -X POST &quot;${LEMON_API_BASE:-http://localhost:3010/api}/company/&lt;companyId @@ -100,5 +150,6 @@ curl -s -X POST &quot;${LEMON_API_BASE:-http://localhost:3010/api}/company/&lt;companyId
100 |---|---| 150 |---|---|
101 | `POST /:companyId/org-graph/replan` | 改组织结构是 `orggraph-planning` 的事,不是你的判断范围 | 151 | `POST /:companyId/org-graph/replan` | 改组织结构是 `orggraph-planning` 的事,不是你的判断范围 |
102 | 任何招聘 / 解雇端点 | 公司缺角色是 HR 与 `orggraph-planning` 的活,你发现"缺人"应该提一条 task 说明缺口,不是自己动手改编制 | 152 | 任何招聘 / 解雇端点 | 公司缺角色是 HR 与 `orggraph-planning` 的活,你发现"缺人"应该提一条 task 说明缺口,不是自己动手改编制 |
103 -| 直接改 `graph_runs` / `graph_run_nodes`(没有这类端点,但如果你找到裸 SQL 或内部函数也一样不许碰) | 执行状态由引擎维护,你只通过 goals/tasks 这层 REST 接口操作 | 153 +| 直接改 `graph_runs` / `graph_run_nodes`(裸 SQL 或内部函数) | 执行状态由引擎维护;**唯一的例外**是上面"卡住的 run 节点"一节写明的 `force-fail` 端点,别的一律不许碰 |
  154 +| 对着卡住的 run/节点自己拉起一个新的 agent 会话"亲自跑一遍" | 绕过引擎会跟卡住的状态打架,上一次这么干陷入了死循环(45 号文档)——只能用 `force-fail` 端点,没有第二条路 |
104 | `POST /:companyId/tasks`(不带 `draft:true`) | 会立刻烧预算,见上面的强调 | 155 | `POST /:companyId/tasks`(不带 `draft:true`) | 会立刻烧预算,见上面的强调 |