From a4b8b87b0f4530afb3ac5f43afd76c1e645c80f8 Mon Sep 17 00:00:00 2001 From: yibo Date: Mon, 31 Aug 2026 17:57:40 +0800 Subject: [PATCH] fix(graph-engine): 卡在 running 且背后进程已死的节点持续性存活扫描(45 号文档 Part A-C) --- docs/README.md | 2 +- docs/todo/system-design/45-graph-run-node-liveness-recovery.md | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--- server/index.js | 14 ++++++++++++++ server/services/graph-engine.js | 106 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------- server/tests/unit/graph-engine-stale-agent-call-sweep.test.js | 165 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ system/agents/ceo/heartbeat.md | 11 ++++++++++- system/agents/ceo/tools.md | 53 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 7 files changed, 407 insertions(+), 13 deletions(-) create mode 100644 server/tests/unit/graph-engine-stale-agent-call-sweep.test.js diff --git a/docs/README.md b/docs/README.md index 0ea379c..bfb76e3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -61,7 +61,7 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d | [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 人设文档的自我认知改写 | | [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:` 写进本体 `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(训练产物 → 雇佣本体转换)尚未开工 | | [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) 那类"装配没填充完"的真实样本,不是平台工具限制 | -| [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(可选)节点补显式心跳字段 | +| [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 阈值判据的误伤局限)未做,见文内「实现记录」的理由 | | [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 个失败与本次改动无关,本次改动前即存在) | ## todo/future/(暂缓,非当前重心) diff --git a/docs/todo/system-design/45-graph-run-node-liveness-recovery.md b/docs/todo/system-design/45-graph-run-node-liveness-recovery.md index f229ee0..096be5a 100644 --- a/docs/todo/system-design/45-graph-run-node-liveness-recovery.md +++ b/docs/todo/system-design/45-graph-run-node-liveness-recovery.md @@ -1,7 +1,8 @@ # 45 · 卡在 `running` 且背后进程已死的 graph 节点,没有任何机制会重新捡起来 -> **状态**: 📝 设计中,未实现。本文档只是诊断 + 修法提案,供**没有 CTO、只有 CEO** 的当前团队配置下, -> 由人(或下一次专门处理的会话)按下面的 Part 拆分逐条改。 +> **状态**: ✅ **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`, @@ -159,7 +160,69 @@ Claude CLI 子进程自己死了/被中断。`sweepOrphanedRunningNodes` 从进ç --- -## 五、不在本文档范围内 +## 五、实现记录(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 行为层面的问题,跟"节点该怎么被自动捡回来"是两回事, diff --git a/server/index.js b/server/index.js index a0d26b8..5fdeefe 100644 --- a/server/index.js +++ b/server/index.js @@ -222,6 +222,20 @@ async function start() { } catch (err) { console.error('[Startup] 墙钟看门狗启动失败:', err.message); } + // 45 号文档 Part A:持续性存活扫描。上面 sweepOrphanedRunningNodes 只在开机那一刻 + // 抓「上一个进程实例被杀掉留下的孤儿」,抓不住「进程没重启、驱动某个 agent_call + // 节点的 CLI 会话中途死掉/被打断」这一半(真实事故:h3video 公司 h3-story-draft + // 节点)。同样先补一次全量扫描,再起 5 分钟兜底定时器,形状同墙钟看门狗。 + try { + const engine = require('./services/graph-engine'); + const swept = engine.sweepStaleRunningAgentCalls(); + if (swept.swept.length) { + console.log(`[Startup] 💤 存活扫描:处理 ${swept.swept.length} 条运行超时的 agent_call 节点`); + } + engine.startStaleAgentCallWatchdog(); + } catch (err) { + console.error('[Startup] 存活扫描看门狗启动失败:', err.message); + } const heartbeatService = require('./services/heartbeat-service'); heartbeatService.start(); // 公司级心跳 (Pipeline 自动驱动) diff --git a/server/services/graph-engine.js b/server/services/graph-engine.js index 0be412b..436a16d 100644 --- a/server/services/graph-engine.js +++ b/server/services/graph-engine.js @@ -3132,11 +3132,16 @@ function stopWallclockWatchdog() { * 测试用任意时刻构造场景。 * @returns {{scanned:number, swept:Array<{runId:string, nodeRunId:string, infraRerun:boolean}>}} */ -function sweepOrphanedRunningNodes(cutoffIso) { - const rows = getDb() - .prepare(`SELECT id, run_id FROM graph_run_nodes WHERE status = 'running' AND started_at IS NOT NULL AND started_at < ?`) - .all(cutoffIso); - +/** + * 共用的「判孤儿→infra_flake 结算」内层循环,`sweepOrphanedRunningNodes`(开机一次性)与 + * `sweepStaleRunningAgentCalls`(45 号文档 Part A:持续性存活扫描)各自查自己的 `rows`, + * 结算逻辑完全一致,不重复抄一遍 `transitionNode`+`routeFailure` 那一段。 + * + * @param {Array<{id:string, run_id:string}>} rows + * @param {string} summary 落进 timeline 的人读摘要,两个调用方文案不同 + * @returns {{scanned:number, swept:Array<{runId:string, nodeRunId:string, infraRerun:boolean}>}} + */ +function _reapStaleRunningNodes(rows, summary) { const swept = []; for (const row of rows) { try { @@ -3147,7 +3152,7 @@ function sweepOrphanedRunningNodes(cutoffIso) { const changed = sse.transitionNode(row.run_id, row.id, 'running', 'fail', { failReason: INFRA_FLAKE_REASON, failNodeRef: null, outputRef: null, agentRunId: null, childRunId: null, completedAt: now(), - }, { summary: `开机孤儿节点清扫:服务器重启前处于 running,背后进程已不存在,判 infra_flake 交给既有熔断/重跑逻辑` }); + }, { summary }); if (!changed) continue; // 0 行 = 已经被别的路径结算过了,不重复处理 const nodeRun = getRunNode(row.id); @@ -3155,12 +3160,94 @@ function sweepOrphanedRunningNodes(cutoffIso) { routeFailure(row.run_id, def, nodeRun, out, null); swept.push({ runId: row.run_id, nodeRunId: row.id, infraRerun: out.infraRerun }); } catch (err) { - console.error(`[graph-engine] sweepOrphanedRunningNodes node=${row.id} 异常: ${err.message}`); + console.error(`[graph-engine] _reapStaleRunningNodes node=${row.id} 异常: ${err.message}`); } } return { scanned: rows.length, swept }; } +function sweepOrphanedRunningNodes(cutoffIso) { + const rows = getDb() + .prepare(`SELECT id, run_id FROM graph_run_nodes WHERE status = 'running' AND started_at IS NOT NULL AND started_at < ?`) + .all(cutoffIso); + return _reapStaleRunningNodes(rows, `开机孤儿节点清扫:服务器重启前处于 running,背后进程已不存在,判 infra_flake 交给既有熔断/重跑逻辑`); +} + +const STALE_AGENT_CALL_SWEEP_INTERVAL_MS = 5 * 60 * 1000; +// 45 号文档 Part A 调查结论:graph 节点派发用的 AgentManager/SessionPool 是每次调用现造、 +// 调用完即回收的一次性对象(`defaultAgentExecutor`,本文件 377 行),全仓库没有一个持续存在、 +// 可查询的"这个节点背后还有没有活进程"的注册表——`sweepOrphanedRunningNodes` 能用的 +// "跟本次进程启动时间比"这条判据,对"进程没重启、单次调用中途死掉"这种情况完全用不上 +// (持续性)。在补一个真正的存活信号(45 号文档 Part D:驱动节点的 CLI 会话定期回写 +// last_heartbeat)之前,只能退而求其次——按「运行时长超过一个保守阈值」判定,与 +// `sweepWallclockBudgets` 对 run 级预算的处理是同一种取舍:可能会误判一个异常慢的真实 +// 长任务,也可能漏判一个刚死不久还没到阈值的节点,不是精确的存活探测。 +// 阈值只按 node_kind='agent_call' 收窄——subgraph 节点的"运行时长"横跨多个内部真实调用, +// 拿同一个固定阈值判它会误伤得离谱,那类节点的存活状态应该递归看它的子 run,本次不做。 +const DEFAULT_STALE_AGENT_CALL_THRESHOLD_MS = 25 * 60 * 1000; + +/** + * 45 号文档 Part A:把「开机一次性孤儿扫描」延伸出一条持续性版本,抓"进程没重启、但驱动 + * 某个 agent_call 节点的 CLI 会话中途死掉/被打断"这一半开机扫描看不到的场景(真实事故: + * h3video 公司 `h3-story-draft` 节点,见 45 号文档时间线)。 + * + * 判据是"运行时长超过阈值"而不是真实存活探测,见上面 `DEFAULT_STALE_AGENT_CALL_THRESHOLD_MS` + * 那段注释里记的取舍——这是已知的、暂时接受的局限,不是本函数的 bug。 + * + * @param {object} [opts] + * @param {number} [opts.thresholdMs] 运行超过多久判定为"大概率死了",默认 25 分钟 + * @param {string} [opts.nowIso] 参照的"当下"时刻,默认真实当前时间;测试用固定值构造场景 + * @returns {{scanned:number, swept:Array<{runId:string, nodeRunId:string, infraRerun:boolean}>}} + */ +function sweepStaleRunningAgentCalls(opts = {}) { + const thresholdMs = opts.thresholdMs ?? DEFAULT_STALE_AGENT_CALL_THRESHOLD_MS; + const nowIso = opts.nowIso || now(); + const cutoffIso = new Date(Date.parse(nowIso) - thresholdMs).toISOString(); + + // node_kind 是定义层字段(work_graph_nodes/org_graph_nodes),graph_run_nodes 这张运行时表 + // 只有 def_node_id——先按「running 超过阈值」把候选行都拿出来,再用 defIndex(run) 逐个查 + // 它对应的定义节点是不是 agent_call,把 subgraph 节点过滤掉(defIndex 内部已缓存不了, + // 这里按 run_id 手动缓存一下,避免同一个 run 里多个候选节点重复读同一张定义图)。 + const rows = getDb() + .prepare(`SELECT id, run_id, def_node_id FROM graph_run_nodes + WHERE status = 'running' AND started_at IS NOT NULL AND started_at < ?`) + .all(cutoffIso); + + const defCache = new Map(); + const agentCallRows = rows.filter((row) => { + let def = defCache.get(row.run_id); + if (def === undefined) { + const run = getRun(row.run_id); + def = run ? defIndex(run) : null; + defCache.set(row.run_id, def); + } + const node = def && def.nodes.get(row.def_node_id); + return node && node.nodeKind === 'agent_call'; + }); + + return _reapStaleRunningNodes( + agentCallRows, + `持续性存活扫描:agent_call 节点 running 超过 ${Math.round(thresholdMs / 60000)} 分钟,判 infra_flake 交给既有熔断/重跑逻辑(45 号文档 Part A)` + ); +} + +let staleAgentCallSweepTimer = null; + +/** 起兜底定时器,形状照抄 `startWallclockWatchdog`:`unref()` + 幂等只留一个。 */ +function startStaleAgentCallWatchdog(intervalMs = STALE_AGENT_CALL_SWEEP_INTERVAL_MS, thresholdMs) { + if (staleAgentCallSweepTimer) return staleAgentCallSweepTimer; + staleAgentCallSweepTimer = setInterval(() => { + try { sweepStaleRunningAgentCalls({ thresholdMs }); } catch (err) { console.error(`[graph-engine] 存活扫描看门狗异常: ${err.message}`); } + }, intervalMs); + if (typeof staleAgentCallSweepTimer.unref === 'function') staleAgentCallSweepTimer.unref(); + return staleAgentCallSweepTimer; +} + +function stopStaleAgentCallWatchdog() { + if (staleAgentCallSweepTimer) clearInterval(staleAgentCallSweepTimer); + staleAgentCallSweepTimer = null; +} + /** * 扣减(「只增不减」,§四.A.3)。始终写 root run 那一行。 * ponytail: 读-改-写不是原子的,当前单进程串行驱动(runAuto 一次只推一个节点)下够用; @@ -5130,6 +5217,11 @@ module.exports = { // 42 号文档 Phase 1a:开机孤儿节点清扫(服务器重启杀掉 in-flight 进程,节点行留在 running) sweepOrphanedRunningNodes, + // 45 号文档 Part A:持续性存活扫描(进程没重启、agent_call 节点中途死掉的那一半开机扫描看不到) + sweepStaleRunningAgentCalls, + startStaleAgentCallWatchdog, + stopStaleAgentCallWatchdog, + // 重试级联判据(§四.C 末段) diffContracts, applyRetryCascade, diff --git a/server/tests/unit/graph-engine-stale-agent-call-sweep.test.js b/server/tests/unit/graph-engine-stale-agent-call-sweep.test.js new file mode 100644 index 0000000..786fa6f --- /dev/null +++ b/server/tests/unit/graph-engine-stale-agent-call-sweep.test.js @@ -0,0 +1,165 @@ +/** + * Unit Tests — graph-engine 的持续性存活扫描(45 号文档 Part A) + * + * 场景:服务器主进程没有重启,但驱动某个 agent_call 节点的 Claude CLI 会话在调用中途 + * 死掉/被打断,`graph_run_nodes.status` 永远停在 'running'。开机一次性孤儿扫描 + * (`sweepOrphanedRunningNodes`,按「早于本次进程启动时间」判定)看不到这种情况——本文件 + * 测的是它的持续性版本:`sweepStaleRunningAgentCalls`,按「running 超过一个阈值」判定, + * 且只扫 `node_kind='agent_call'`(subgraph 节点的"运行时长"横跨多个内部调用,不适用 + * 同一固定阈值,本次不做)。 + * + * 隔离手法与 graph-engine-orphan-sweep.test.js 一致。 + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +describe('graph-engine 持续性存活扫描', () => { + let wdb; + let engine; + let runsDir; + const TEST_DB_PATH = '/tmp/test-graph-engine-stale-agent-call-sweep.sqlite'; + + beforeAll(() => { + for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) { + try { fs.unlinkSync(f); } catch {} + } + process.env.GRAPH_DB_PATH = TEST_DB_PATH; + runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-engine-stale-agent-call-sweep-')); + process.env.GRAPH_RUNS_DIR = runsDir; + for (const m of [ + '../../services/graph-db', + '../../services/graph-service', + '../../services/org-graph-service', + '../../services/work-graph-db', + '../../services/graph-run-sse', + '../../services/graph-engine', + ]) { + delete require.cache[require.resolve(m)]; + } + wdb = require('../../services/work-graph-db'); + engine = require('../../services/graph-engine'); + }); + + afterAll(() => { + for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) { + try { fs.unlinkSync(f); } catch {} + } + delete process.env.GRAPH_DB_PATH; + try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {} + delete process.env.GRAPH_RUNS_DIR; + }); + + beforeEach(() => { + const db = wdb.getDb(); + db.pragma('foreign_keys = OFF'); + db.exec(` + DELETE FROM graph_run_loop_records; + DELETE FROM graph_run_nodes; + DELETE FROM graph_runs; + DELETE FROM work_graph_edges; + DELETE FROM work_graph_nodes; + DELETE FROM work_graphs; + DELETE FROM system_agents; + `); + db.pragma('foreign_keys = ON'); + engine.setAgentExecutor(null); + }); + + let seq = 0; + const nextCompany = () => `co-stale-${++seq}`; + + function ensureAgents(ids) { + const db = wdb.getDb(); + const now = new Date().toISOString(); + const ins = db.prepare(` + INSERT OR IGNORE INTO system_agents + (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at) + VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?) + `); + for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, now, now); + } + + function buildWorkRun({ companyId, nodes, edges }) { + ensureAgents(nodes); + const g = wdb.createWorkGraph({ companyId, nodes, edges }); + expect(g.errors || []).toEqual([]); + const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId, executionMode: 'manual' }); + engine.markRunReady(run.id); + return { runId: run.id }; + } + + const nodeByName = (runId, name) => wdb.getRunState(runId).nodes.find((n) => n.name === name); + + function markAsRunning(runId, nodeName, startedAtIso) { + const node = nodeByName(runId, nodeName); + wdb.getDb() + .prepare(`UPDATE graph_run_nodes SET status = 'running', started_at = ?, updated_at = ? WHERE id = ?`) + .run(startedAtIso, startedAtIso, node.id); + return node.id; + } + + const NOW = '2026-08-31T10:00:00.000Z'; + + it('agent_call 节点 running 超过阈值判定为存活超时,走 infra_flake 原地重跑', () => { + const companyId = nextCompany(); + const { runId } = buildWorkRun({ companyId, nodes: ['w'], edges: [] }); + // 默认阈值 25 分钟;30 分钟前开始 = 超阈值 + markAsRunning(runId, 'w', '2026-08-31T09:30:00.000Z'); + + const result = engine.sweepStaleRunningAgentCalls({ nowIso: NOW }); + + expect(result.scanned).toBe(1); + expect(result.swept).toHaveLength(1); + expect(result.swept[0]).toMatchObject({ runId, infraRerun: true }); + const fresh = nodeByName(runId, 'w'); + expect(fresh.status).toBe('ready'); + expect(fresh.attempt).toBe(0); + }); + + it('running 未超过阈值的节点不受影响(大概率还在真实跑)', () => { + const companyId = nextCompany(); + const { runId } = buildWorkRun({ companyId, nodes: ['w'], edges: [] }); + // 5 分钟前开始,远低于默认 25 分钟阈值 + markAsRunning(runId, 'w', '2026-08-31T09:55:00.000Z'); + + const result = engine.sweepStaleRunningAgentCalls({ nowIso: NOW }); + + expect(result.scanned).toBe(0); + expect(nodeByName(runId, 'w').status).toBe('running'); + }); + + it('阈值可调:传更短的 thresholdMs 会让原本"没超时"的节点也被扫到', () => { + const companyId = nextCompany(); + const { runId } = buildWorkRun({ companyId, nodes: ['w'], edges: [] }); + markAsRunning(runId, 'w', '2026-08-31T09:55:00.000Z'); // 5 分钟前 + + const result = engine.sweepStaleRunningAgentCalls({ nowIso: NOW, thresholdMs: 60 * 1000 }); // 阈值 1 分钟 + + expect(result.scanned).toBe(1); + expect(nodeByName(runId, 'w').status).toBe('ready'); + }); + + it('subgraph 节点不在扫描范围内,即使 running 很久也不受影响', () => { + const companyId = nextCompany(); + ensureAgents(['s']); + const g = wdb.createWorkGraph({ companyId, nodes: [{ nodeName: 's', nodeKind: 'subgraph' }], edges: [] }); + expect(g.errors || []).toEqual([]); + const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId, executionMode: 'manual' }); + engine.markRunReady(run.id); + markAsRunning(run.id, 's', '2026-08-31T08:00:00.000Z'); // 2 小时前,远超阈值 + + const result = engine.sweepStaleRunningAgentCalls({ nowIso: NOW }); + + expect(result.scanned).toBe(0); + expect(nodeByName(run.id, 's').status).toBe('running'); + }); + + it('没有任何超时节点时是安静的 no-op', () => { + const companyId = nextCompany(); + buildWorkRun({ companyId, nodes: ['w'], edges: [] }); // 节点是 ready,不是 running + const result = engine.sweepStaleRunningAgentCalls({ nowIso: NOW }); + expect(result).toEqual({ scanned: 0, swept: [] }); + }); +}); diff --git a/system/agents/ceo/heartbeat.md b/system/agents/ceo/heartbeat.md index cc0a2fb..8c7c83a 100644 --- a/system/agents/ceo/heartbeat.md +++ b/system/agents/ceo/heartbeat.md @@ -18,4 +18,13 @@ ## 不用管的 - 正在排队 / 正在跑但没超时的 task——那是调度器的活,不用你盯 -- 纯技术性的失败(比如基础设施抖动)——引擎自己有重试和熔断机制 +- 纯技术性的失败(比如基础设施抖动)——引擎自己有重试和熔断机制(`sweepOrphanedRunningNodes` + + `sweepStaleRunningAgentCalls`,见 45 号文档 Part A;后者是持续性扫描,25 分钟自动处理一次, + **心跳例行扫描不用替它兜底**,不要主动去查每条 running task 卡了多久) + +## 用户直接把"这条任务卡住了"转给你时该怎么办 + +心跳例行扫描不用管上面那条,但如果是**用户主动**转发过来、明确让你看一条卡住的任务,按 +`tools.md`「卡住的 run 节点」一节的两步处理(先查、再用 `force-fail` 端点)。**不要自己对着 +卡住的 run/节点拉起一个新的 agent 会话去亲自跑一遍**——那是绕过引擎,会跟卡住的状态打架; +上一次这么干直接陷入了工具调用死循环,最后靠人手动中断才停下(见 45 号文档时间线)。 diff --git a/system/agents/ceo/tools.md b/system/agents/ceo/tools.md index 8097d53..0ed2d14 100644 --- a/system/agents/ceo/tools.md +++ b/system/agents/ceo/tools.md @@ -78,6 +78,56 @@ curl -s -X POST "${LEMON_API_BASE:-http://localhost:3010/api}/company/` 的 task 摘要里能看到)对应一棵 graph +run 树。如果一条 task 长时间 `running` 不动、你判断需要介入,**只能**走下面这两步, +**不许**做的事情见本节末尾。 + +### 1. 先查,找到真正卡住的那个节点 + +```bash +curl -s "${LEMON_API_BASE:-http://localhost:3010/api}/graph-runs/" \ + -H "x-internal-token: $LEMON_INTERNAL_TOKEN" +``` + +返回里 `nodes[]` 是这一层的节点状态,`childRuns[]` 是往下展开出的子 run(org 节点会展开成 +work graph 子 run)。**卡住的往往不是你拿到的第一层 `runId`,是它 `childRuns[]` 里那个** +——顺着 `childRuns[].id` 递归 `GET` 下去,找到最深那一层里 `status: 'running'` 且 +`agentRunId`/`childRunId` 都是 `null` 的节点,那才是真正卡死、没有背后进程在跑的那一个。 + +### 2. 确认是真卡死之后,用这个端点让引擎按既有失败路由处理 + +```bash +curl -s -X POST "${LEMON_API_BASE:-http://localhost:3010/api}/graph-runs/<那一层真正卡住的runId>/nodes//force-fail" \ + -H "x-internal-token: $LEMON_INTERNAL_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"failReason":"infra_flake"}' +``` + +`failReason` 传 `infra_flake`:这是"不是这个节点自己的错,是基础设施打断"的既有语义,引擎会 +原地重跑、不烧这个节点的 `attempt` 计数。**只有 `nodeId` 属于你上一步在 `<那一层真正卡住的 +runId>` 下面查到的、status 恰好是 `running` 的节点时,这个端点才会生效**(其他状态会直接 +400 报错,不会误伤)。 + +不确定要不要传 `infra_flake`(比如你怀疑是节点自己的逻辑问题,不是纯粹的基础设施打断): +不传 `failReason`,默认走 `operator_intervention`,节点会转 `fail` 交给正常的失败路由/回退边 +处理,而不是原地重跑——两者的区别只是"该不该算这个节点头上",出错方向选保守的那个就好。 + +### 你不该做的事(45 号文档记录过一次真实事故) + +- **不要自己直接对着这个 run/节点拉起一个新的 agent 会话去"亲自跑一遍"**——那是绕过引擎, + 会跟卡住的状态本身打架,产生冲突/重复调用,上一次这么干直接陷入死循环,最后靠人手动中断 + 才停下。你能做的只有上面这一个端点,没有第二条路。 +- 不要对**不是 `running`** 的节点调 `force-fail`(会直接报错,这不是漏做校验,是刻意的边界)。 +- 判断"卡住多久算该管":目前没有一个精确的存活探测,只能凭经验——如果这条 task 的进度 + (goal 详情里的 `progress`)长时间(半小时以上)没有变化,且你手头没有别的信息能解释 + 为什么慢,可以认为大概率是死了。引擎自己也有一条持续性扫描会在 25 分钟后自动处理同类情况 + (见 `docs/todo/system-design/45-graph-run-node-liveness-recovery.md` Part A)——多数情况下 + 不需要你手动介入,这一节是给"用户直接把卡住的任务转给你、要你立刻看一下"这种场景兜底的。 + +--- + ## 什么时候该提新任务、什么时候该改优先级 - **一条 task 长时间 `blocked` 或 `failed`**(预算耗尽 / 依赖死锁 / 反复回退熔断):看它的 @@ -100,5 +150,6 @@ curl -s -X POST "${LEMON_API_BASE:-http://localhost:3010/api}/company/