Commit a4b8b87b0f4530afb3ac5f43afd76c1e645c80f8
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
Showing
7 changed files
with
407 additions
and
13 deletions
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 "${LEMON_API_BASE:-http://localhost:3010/api}/company/<companyId | @@ -78,6 +78,56 @@ curl -s -X POST "${LEMON_API_BASE:-http://localhost:3010/api}/company/<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 "${LEMON_API_BASE:-http://localhost:3010/api}/company/<companyId | @@ -100,5 +150,6 @@ curl -s -X POST "${LEMON_API_BASE:-http://localhost:3010/api}/company/<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`) | 会立刻烧预算,见上面的强调 | |