Commit 364788aedcb8b878220cade4838174512d945165

Authored by yibo
1 parent a4b24893

fix(graph-engine): commit T-mtgxcnfz fix — stopRun orphan-run race + /next auto-mode guard

Snapshot-then-stop race in stopRun() left child runs created after the
snapshot outside the stop scope; add auto-mode guard rejecting manual
/next on execution_mode='auto' runs (source of the concurrent-advance race).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docs/README.md
@@ -61,6 +61,7 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d @@ -61,6 +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 | [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 个失败与本次改动无关,本次改动前即存在) |
65 66
66 ## todo/future/(暂缓,非当前重心) 67 ## todo/future/(暂缓,非当前重心)
docs/todo/system-design/45-graph-run-node-liveness-recovery.md 0 → 100644
  1 +# 45 · 卡在 `running` 且背后进程已死的 graph 节点,没有任何机制会重新捡起来
  2 +
  3 +> **状态**: 📝 设计中,未实现。本文档只是诊断 + 修法提案,供**没有 CTO、只有 CEO** 的当前团队配置下,
  4 +> 由人(或下一次专门处理的会话)按下面的 Part 拆分逐条改。
  5 +> **日期**: 2026-08-31
  6 +> **发现场景**:h3video 公司 goal `G-mtgmij2n-26d8` / task `T-mtgmij2n-d7ac`(太空歌剧预告片)的
  7 +> `h3-story` 阶段子图里,`h3-story-draft` 节点(`nodeKind: agent_call`,`rn_04a9474682f3`,
  8 +> run `run_900ceddabf30`)卡在 `status: running`,但 `agentRunId`/`childRunId` 均为 `null`——背后
  9 +> 驱动它的 Claude CLI 会话已经死亡/被中断,节点这一行永远不会自己动。用户把这个情况转给 `ceo`
  10 +> 排查(`conv-mtgvjiyq`),`ceo` 没有任何工具能"正确地"处理这件事,于是自己直接对同一个 run 拉起
  11 +> 了一个新的 `h3-story` 会话,随后陷入反复的工具调用死循环(think loop),最终由人手动中断
  12 +> (`code=143`)并重启容器才平息。
  13 +
  14 +---
  15 +
  16 +## 一、时间线(UTC,本地 UTC+8)
  17 +
  18 +```
  19 +2026-08-31T06:10:34Z h3-story-draft 首次启动(run_900ceddabf30),随后驱动它的 CLI 会话
  20 + 在某个时点死亡/被中断——没有任何日志记录这次死亡本身,只能靠"此后再
  21 + 没更新过"反推
  22 +2026-08-31T06:45:35Z 用户把任务转给 ceo(conv-mtgvjiyq),ceo 开始排查
  23 +2026-08-31T06:53:12Z ceo 直接对着同一个卡住的 run(run_900ceddabf30)拉起了一个新的
  24 + h3-story 会话——不是通过任何"重试节点"的工具,是绕过引擎直接启动
  25 + 一个 agent 会话,key 与卡住节点的 acp key 一致
  26 +2026-08-31T06:53:12Z ~ 06:55:19Z
  27 + ceo 自己的会话 + 它新拉起的 h3-story 会话反复打
  28 + "🔧 工具执行中 → 超时切为 900s",两分钟内十几条,无实质进展(死循环)
  29 +2026-08-31T06:55:22Z 用户手动中断 conv-mtgvjiyq,CLI 进程 code=143 退出
  30 +2026-08-31T07:00:09Z 用户重新部署容器(RestartCount=0,干净重启,不是崩溃)
  31 +2026-08-31T07:02+ 容器重启后再没有任何进程碰过 run_900ceddabf30——
  32 + h3-story-draft 节点原地卡着,跟死循环发生前一模一样
  33 +```
  34 +
  35 +**结论**:`ceo` 修的是它自己的行为(不该再打 think 死循环),**没有、也不可能修**底层那个卡住的节点——
  36 +因为节点重新变得可跑,靠的是 Part B/C 里要新增的能力,不是重启容器就会自动发生的事。
  37 +
  38 +---
  39 +
  40 +## 二、根因:三层机制,没有一层真的接住这个失败模式
  41 +
  42 +### 2.1 心跳是纯 LLM 驱动,不查 `graph_run_nodes`
  43 +
  44 +- `server/services/heartbeat-service.js:221-227` 的心跳消息原文是让 agent"扫描全局 OKR 目标状态和节点"——
  45 + 对 `ceo` 而言这条通用文案根本不适用(`system/agents/ceo/heartbeat.md:3-4` 自己也说了"那是通用触发文案,
  46 + 对你不适用")。这条路径全程没有一行代码去查 `graph_runs`/`graph_run_nodes`,完全靠 LLM 读 prompt 后自己
  47 + 决定做什么。
  48 +- `server/services/company-heartbeat-service.js` 的 `_onHeartbeat`(116 行)只做三件事:起 cron Pipeline
  49 + (`_checkAndStartPipelines`,154 行)、驱动旧版 Pipeline 的超时(`_driveActiveRuns`,196 行,走的是
  50 + `pipeline-service.js` 那条**遗留**模型,不是 org/work graph 引擎)、触发 CEO 心跳文案
  51 + (`_triggerCeoHeartbeat`,266 行)。同样不碰 `graph_run_nodes`。
  52 +
  53 +### 2.2 唯一存在的节点级孤儿扫描,只在服务器启动时跑一次
  54 +
  55 +`sweepOrphanedRunningNodes(cutoffIso)`(`server/services/graph-engine.js:3118-3145`)确实是为同一类问题写的——
  56 +文档注释(3100-3116 行)写得很清楚:`status='running'` 这一行"只是上次问到的状态",不代表背后真有进程;
  57 +它复用 `routeFailure` 的 `infra_flake` 分支(跟现成的重跑/熔断逻辑接上,`INFRA_FLAKE_REASON`,
  58 +`graph-engine.js:149`),设计思路是对的。
  59 +
  60 +**但它只在 `server/index.js:172-178` 服务器进程启动时调用一次**,`cutoffIso` 固定传
  61 +`PROCESS_STARTED_AT`(`server/index.js:24`,注释还特意强调"不要在调用点现取,那样每次重试都会把截止线
  62 +往后挪")。判据是"`started_at` 早于本次进程启动时间"——**只抓"上一次进程重启前遗留的孤儿"**。
  63 +
  64 +我们这次的场景恰恰是设计上没覆盖的那一半:**服务器主进程从头到尾没重启过**,只是驱动某个节点的
  65 +Claude CLI 子进程自己死了/被中断。`sweepOrphanedRunningNodes` 从进程启动后再也没被调用第二次
  66 +(不是定时器,是一次性调用),永远不会看到这个节点。
  67 +
  68 +### 2.3 其他周期性巡检,覆盖的是别的层级,判据对不上
  69 +
  70 +| 巡检 | 位置 | 判据 | 为什么看不到这个节点 |
  71 +|---|---|---|---|
  72 +| 僵尸 goal 扫描 | `server/services/task-settle.js:634-682`,`startZombieGoalSweeper` 每 5 分钟 | task 是否全部到终态 | 看 task 状态,不看节点;task 本身是 `running`,非终态很正常 |
  73 +| blocked task 扫描 | `task-settle.js:693-721`,`startCeoSweeper` 每 30 分钟 | `SELECT ... WHERE status='blocked'` | 这个节点对应的 task 状态是 `running`,不是 `blocked`,查询条件直接漏掉 |
  74 +| wallclock 预算看门狗 | `graph-engine.js:3056-3098`,`startWallclockWatchdog` 每 60 秒(`server/index.js:217-221` 起) | `budget_json` 里 `wallclock_ms` 是否耗尽 | 按预算判断,不按存活状态判断;只要没到预算上限就永远不触发,跟节点是死是活无关 |
  75 +
  76 +### 2.4 `ceo` 的心跳指令明确写了"这不是你该管的",但依据的前提不成立
  77 +
  78 +`system/agents/ceo/heartbeat.md:18-21`:
  79 +
  80 +```
  81 +## 不用管的
  82 +
  83 +- 正在排队 / 正在跑但没超时的 task——那是调度器的活,不用你盯
  84 +- 纯技术性的失败(比如基础设施抖动)——引擎自己有重试和熔断机制
  85 +```
  86 +
  87 +这条指令的前提是"引擎自己有重试和熔断机制"——**这句话对 2.2 描述的开机一次性孤儿扫描是对的,
  88 +但对"进程活着、子任务中途死掉"这个场景是不对的**,因为根本没有对应的持续性巡检。指令和实际能力
  89 +之间的落差,就是这次 `ceo` 决定亲自动手(绕过引擎直接拉起会话)、进而陷入死循环的直接诱因——
  90 +它被告知"别管,引擎会兜底",但引擎并不会兜底,人转发问题过来时它又没有一个"正确的"动作可用,
  91 +只能自己瞎折腾。
  92 +
  93 +---
  94 +
  95 +## 三、影响面
  96 +
  97 +不只是这一个任务。**任何一个 `agent_call`/`subgraph` 节点,只要驱动它的 Claude CLI 子进程在服务器
  98 +主进程没有重启的情况下异常终止**(进程被杀、OOM、网络抖动导致连接掉线且没有走到正常的失败结算路径、
  99 +人工在别处手动 kill 掉了对应的 CLI 子进程等),这个节点就会永久卡在 `running`,无法通过任何已知的
  100 +自动机制恢复,只能靠人在控制台点"重试",或者靠 `ceo` 心跳时"想多了"越权直接插手(本次事故的成因)。
  101 +
  102 +随着公司数量、并发 run 数增多,这类中途死亡只会更频繁——目前完全没有能力度量它发生的频率,因为
  103 +没有任何巡检会主动发现它,只能靠用户偶然点开 graph console 才会注意到。
  104 +
  105 +---
  106 +
  107 +## 四、建议修法:拆成四个 Part,互相独立,可以分开做
  108 +
  109 +### Part A(优先级最高):把"开机一次性孤儿扫描"升级成"持续性存活扫描"
  110 +
  111 +现有 `sweepOrphanedRunningNodes` 的判据是"`started_at` 早于进程启动时间",这对持续运行的进程没有
  112 +意义。改成按**存活信号**判断:
  113 +
  114 +- 复用 `SessionPool`(`server/src/core/session-pool.js:65` 的 `this.sessions` Map,key 的组装方式见
  115 + `server/src/core/agent-manager.js:531` 那行日志的拼法,形如
  116 + `acp:{companyId}:{agentId}:...:{runId}:...`)——对每个 `status='running'` 超过一个阈值(建议先用
  117 + 跟 `SessionPool` 空闲回收一致的量级,比如 10-15 分钟,具体数字要跟 idle-timer 的 TTL 对齐,避免
  118 + 跟正常的长任务打架)的节点,反推它应该对应的 acp key,查 `SessionPool` 里还有没有活的条目。
  119 +- 没有活会话 → 判定孤儿,走 `sweepOrphanedRunningNodes` 现成的那条路(`routeFailure` 的
  120 + `infra_flake` 分支),不用发明新状态。
  121 +- 挂成周期性定时器,跟 `startWallclockWatchdog`(`graph-engine.js:3086-3093`)一个形状:
  122 + `setInterval` + `unref()` + 幂等重复调用只留一个。可以直接加一个
  123 + `startNodeLivenessWatchdog(intervalMs)`,在 `server/index.js` 里紧挨着
  124 + `engine.startWallclockWatchdog()`(217-221 行)那几行一起起。
  125 +- **风险点**:判断"节点应该对应哪个 SessionPool key"需要理清 key 的组装规则——目前这个拼法散落在
  126 + `agent-manager.js` 里,没有一个单独的、可复用的"给定 runId+nodeId 反推 key"的函数,可能需要先补一个。
  127 + 这是本 Part 里工作量最大的一块,其余(挂定时器、复用 `routeFailure`)都是现成积木。
  128 +
  129 +### Part B:给 `ceo`(以及任何被转发这类问题的 agent)一个"正确的"重试动作,而不是逼它绕过引擎
  130 +
  131 +这次死循环的直接诱因是 `ceo` 没有工具可用,只能自己拉起一个跟卡住节点撞 key 的新会话。应该:
  132 +
  133 +- 在 `ceo` 的 `tools.md`(或全公司通用的工具集)里加一个明确的"重试卡住节点"动作——本质是调一个
  134 + REST 端点或工具,语义是"把这个 `running` 节点标记为需要人工/引擎介入的失败态,交给已有的
  135 + `routeFailure`/重试熔断逻辑",而不是自己起一个平行会话去抢同一个 key。
  136 +- 如果 Part A 落地,这个工具的大部分场景其实会被自动巡检提前接住,`ceo` 手动触发只是兜底/加急通道。
  137 +- 需要先确认现有 graph-runs 相关路由(`server/routes/graph-run.js` 之类)有没有现成的"强制判失败/
  138 + 强制重试"端点可以直接复用——42 号文档 Phase 1 提过"强制判失败端点",如果那个端点已经存在,这里
  139 + 可能只是把它包装成一个 agent 可调用的 tool,不用重新造。
  140 +
  141 +### Part C:更新 `heartbeat.md` 的"不用管的"一节,让指令跟实际能力对齐
  142 +
  143 +`system/agents/ceo/heartbeat.md:18-21` 现在的措辞是"引擎自己有重试和熔断机制"——在 Part A 落地前,
  144 +这句话对"进程中途死亡"这个子场景是不准确的。两种改法二选一(或分阶段):
  145 +
  146 +1. **Part A 落地后**:这句话就是对的,不用改,只是需要补一句——如果心跳/巡检都没能自愈、用户主动
  147 + 转发过来问,`ceo` 该用 Part B 的工具而不是自己动手拉会话。
  148 +2. **Part A 落地前的过渡期**:把"不用管的"这一条改准确——明确写"节点级别的存活问题目前没有自动
  149 + 巡检,如果用户主动转发这类情况,用 Part B 的工具处理,不要自己直接对着卡住的 run/节点拉起新会话"。
  150 +
  151 +### Part D(低优先级,可选):给节点补一个显式的"最后心跳时间"字段
  152 +
  153 +目前 Part A 的存活判断完全依赖"`SessionPool` 里还有没有活会话"这个间接信号。如果 `SessionPool`
  154 +的生命周期跟节点生命周期没有严格一一对应(比如同一个会话被多个节点复用,或者节点执行期间会话
  155 +一度被 idle 回收又重建),间接信号可能不够可靠。更稳的做法是节点表补一个 `last_heartbeat_at`,
  156 +由驱动节点的 CLI 会话每隔一段时间主动更新;Part A 的判据改成"`last_heartbeat_at` 超过阈值未更新",
  157 +不再依赖对 `SessionPool` 内部实现的假设。这个改动面更大(要动 schema、动会话驱动循环里的写入点),
  158 +先做 Part A 用间接信号验证思路,效果不够再上这个。
  159 +
  160 +---
  161 +
  162 +## 五、不在本文档范围内
  163 +
  164 +- `ceo` 这次具体为什么会陷入"think 死循环"(是不是工具调用协议本身有 bug、还是纯粹被卡住状态
  165 + 搞懵了反复自我追问)——这是 harness/agent 行为层面的问题,跟"节点该怎么被自动捡回来"是两回事,
  166 + 值得单独起一份诊断,不在这里展开。
  167 +- 火星单(`T-mtfr4p77-7e79`)跟这条太空歌剧单之间"谁该占并发锁"顺序看起来反了这件事——是另一个
  168 + 独立观察,见对话记录,未落成文档,跟本文档的节点存活问题无关。
server/routes/graph-run.js
@@ -304,6 +304,12 @@ router.post(&#39;/:id/next&#39;, h(async (req, res) =&gt; { @@ -304,6 +304,12 @@ router.post(&#39;/:id/next&#39;, h(async (req, res) =&gt; {
304 const run = loadRun(req, res); 304 const run = loadRun(req, res);
305 if (!run) return; 305 if (!run) return;
306 if (!ensureStatus(res, run, '下一步', ['running', 'paused'])) return; 306 if (!ensureStatus(res, run, '下一步', ['running', 'paused'])) return;
  307 + // auto 模式的 run 后台已经挂着 runAuto 驱动循环在自己推进(startRun 起跑时挂的),
  308 + // 这里再手动 /next 会和它并发推进同一条 run、两条路径互不知情(T-mtgxcnfz 实锤复现)。
  309 + // 想手动单步就先 /pause,落 paused 后 execution_mode 不变但驱动循环已经退出了。
  310 + if (run.execution_mode === 'auto') {
  311 + return res.status(400).json({ error: 'auto 模式的 run 由后台驱动循环自动推进,不支持手动「下一步」;请先 /pause 再单步' });
  312 + }
307 313
308 const out = await engine.nextStep(run.id, req.body && req.body.task ? { task: req.body.task } : {}); 314 const out = await engine.nextStep(run.id, req.body && req.body.task ? { task: req.body.task } : {});
309 sendAdvance(res, out); 315 sendAdvance(res, out);
server/services/graph-engine.js
@@ -1212,6 +1212,23 @@ async function executeSubgraph(ctx) { @@ -1212,6 +1212,23 @@ async function executeSubgraph(ctx) {
1212 // 正是本轮在盯的那类漏接(注入假规划的测试不返回 tokens,这里按 0 处理)。 1212 // 正是本轮在盯的那类漏接(注入假规划的测试不返回 tokens,这里按 0 处理)。
1213 const tokens = (planned && planned.tokens) || 0; 1213 const tokens = (planned && planned.tokens) || 0;
1214 1214
  1215 + // T-mtgxcnfz:stopRun 在函数开头拍的 targets 快照天然不含「还没建出来」的子 run —— 如果
  1216 + // 这个节点正好在上面 `await planWorkGraph(ctx)` 那段真实等待期间被 stopRun 请求停止,
  1217 + // 旧代码会照样在这里建出子 run 并 startRunSync 挂起,变成没人再管的孤儿(父树已 stopped,
  1218 + // 它还在跑,实锤复现:run_e4ba218e9446 落 stopped 早了 37ms,子 run run_12aa199b07f2
  1219 + // 从未被置 stop_requested)。stopRun 本身全同步、无内部 await,事后补扫赶不上这类横跨
  1220 + // await 边界的竞态,所以拦截点只能放在真正落子 run 之前:重新读一次父 run,
  1221 + // stop_requested 已经变真就放弃创建,按节点失败结算,不留孤儿。
  1222 + const freshRun = getRun(ctx.runId);
  1223 + if (!freshRun || freshRun.stop_requested) {
  1224 + return {
  1225 + status: 'fail',
  1226 + failReason: ENGINE_FAIL_REASONS.NODE_ERROR,
  1227 + output: '父 run 在规划子图期间被请求停止(stop_requested),放弃创建子 run',
  1228 + tokens,
  1229 + };
  1230 + }
  1231 +
1215 const child = createRun({ 1232 const child = createRun({
1216 graphKind: 'work', 1233 graphKind: 'work',
1217 graphId: workGraphId, 1234 graphId: workGraphId,
@@ -3541,7 +3558,7 @@ function stopRun(runId) { @@ -3541,7 +3558,7 @@ function stopRun(runId) {
3541 // 软信号先在整棵子树上一次置齐,再逐层处理:否则处理深层的时候浅层还能拾取新节点 3558 // 软信号先在整棵子树上一次置齐,再逐层处理:否则处理深层的时候浅层还能拾取新节点
3542 for (const r of targets) setRunFlags(r.id, { stopRequested: true }); 3559 for (const r of targets) setRunFlags(r.id, { stopRequested: true });
3543 3560
3544 - // **自底向上**(targets 里父一定排在子前面,倒序即最深优先)。 3561 + // **自底向上**(层内父一定排在子前面,倒序即最深优先)。
3545 // 3562 //
3546 // 倒序 + 每层落 stopped 后补一次 propagateToParent,修的是「终态 run 上留着永远 running 3563 // 倒序 + 每层落 stopped 后补一次 propagateToParent,修的是「终态 run 上留着永远 running
3547 // 的节点」(R6-F3 minor):子 run 被 stopRun 直接转 stopped,不经过 settleRun, 3564 // 的节点」(R6-F3 minor):子 run 被 stopRun 直接转 stopped,不经过 settleRun,
@@ -3552,26 +3569,47 @@ function stopRun(runId) { @@ -3552,26 +3569,47 @@ function stopRun(runId) {
3552 // 于是回写顺带点亮的下游(fail 边 / 兜底边的目标)同样会被 skip,不留悬空 ready。 3569 // 于是回写顺带点亮的下游(fail 边 / 兜底边的目标)同样会被 skip,不留悬空 ready。
3553 // 只停子树时父 run 不在 targets 里,它就此拿到「子 run 停了」这个失败结果,按图上的 3570 // 只停子树时父 run 不在 targets 里,它就此拿到「子 run 停了」这个失败结果,按图上的
3554 // fail 边自行处置 —— 这正是「停止一个子图」在父层应有的语义,而不是父层永远干等。 3571 // fail 边自行处置 —— 这正是「停止一个子图」在父层应有的语义,而不是父层永远干等。
3555 - for (const r of [...targets].reverse()) {  
3556 - for (const n of getDb()  
3557 - .prepare(`SELECT id, status FROM graph_run_nodes WHERE run_id = ? AND status IN ('pending','ready')`)  
3558 - .all(r.id)) {  
3559 - sse.transitionNode(r.id, n.id, n.status, 'skipped', {}, { summary: '人工「停止」:未开跑的节点批量 skipped' });  
3560 - }  
3561 - const fresh = getRun(r.id);  
3562 - if (fresh && canTransitionRun(fresh.run_status, 'stopped')) {  
3563 - sse.transitionRun(r.id, fresh.run_status, 'stopped', {}, {  
3564 - summary: `人工「停止」:${fresh.run_status} → stopped`,  
3565 - cascaded_from: runId,  
3566 - }); 3572 + const stopLayer = (layer) => {
  3573 + for (const r of [...layer].reverse()) {
  3574 + for (const n of getDb()
  3575 + .prepare(`SELECT id, status FROM graph_run_nodes WHERE run_id = ? AND status IN ('pending','ready')`)
  3576 + .all(r.id)) {
  3577 + sse.transitionNode(r.id, n.id, n.status, 'skipped', {}, { summary: '人工「停止」:未开跑的节点批量 skipped' });
  3578 + }
  3579 + const fresh = getRun(r.id);
  3580 + if (fresh && canTransitionRun(fresh.run_status, 'stopped')) {
  3581 + sse.transitionRun(r.id, fresh.run_status, 'stopped', {}, {
  3582 + summary: `人工「停止」:${fresh.run_status} → stopped`,
  3583 + cascaded_from: runId,
  3584 + });
  3585 + }
  3586 + propagateToParent(r.id);
3567 } 3587 }
3568 - propagateToParent(r.id); 3588 + };
  3589 + stopLayer(targets);
  3590 +
  3591 + // 补扫:targets 是函数开头拍的一次快照,如果某个节点在这次 stopRun 执行期间(同一事件循环内)
  3592 + // 刚好建出了新的子 run(比如 org-coder 子图节点 running → 真正落 child_run_id),这个新 run
  3593 + // 不在快照里,上面那圈永远不会碰到它——父树已经 stopped,它却继续跑,没人再管(T-mtgxcnfz 实锤:
  3594 + // run_e4ba218e9446 落 stopped 早了 37ms,子 run run_12aa199b07f2 就此变孤儿)。
  3595 + // 这里按 root_run_id 重新查一遍,把快照之外、非终态的新后代补上同一套处理。
  3596 + // 有界轮询而非一次性:新建可能连锁触发下一层新建,多轮几乎总能收敛;轮数封顶防止和「新建」死缠。
  3597 + const seen = new Set(targets.map((r) => r.id));
  3598 + for (let round = 0; round < 5; round++) {
  3599 + const fresh = descendantRuns(run).filter(
  3600 + (r) => !seen.has(r.id) && !TERMINAL_RUN_STATUS.has((getRun(r.id) || {}).run_status)
  3601 + );
  3602 + if (fresh.length === 0) break;
  3603 + for (const r of fresh) setRunFlags(r.id, { stopRequested: true });
  3604 + stopLayer(fresh);
  3605 + for (const r of fresh) seen.add(r.id);
3569 } 3606 }
  3607 +
3570 return { 3608 return {
3571 ok: true, 3609 ok: true,
3572 runId, 3610 runId,
3573 runStatus: (getRun(runId) || {}).run_status, 3611 runStatus: (getRun(runId) || {}).run_status,
3574 - cascaded: targets.map((r) => r.id).filter((id) => id !== runId), 3612 + cascaded: [...seen].filter((id) => id !== runId),
3575 }; 3613 };
3576 } 3614 }
3577 3615
server/tests/unit/graph-engine-stop-run-orphan-race.test.js 0 → 100644
  1 +/**
  2 + * Unit Test — stopRun 不能给「正在 executeSubgraph 里 await planWorkGraph」的节点留孤儿子 run
  3 + * (T-mtgxcnfz 复现)
  4 + *
  5 + * 真实事故时序:run_e4ba218e9446 于 2026-08-31T07:30:50.644Z 落 stopped,它的子 run
  6 + * run_12aa199b07f2 created_at 是 07:30:50.607Z(早 37ms,同一次事件循环附近),但从未被置
  7 + * stop_requested,一直跑到人工手动 /stop 才收掉。
  8 + *
  9 + * 根因:`stopRun`(graph-engine.js)在函数开头一次性拍 `descendantRuns(run)` 快照决定要停
  10 + * 哪些 run;`executeSubgraph`(同文件)建子 run 之前有一次真实 `await planWorkGraph(ctx)`——
  11 + * 如果这次 await 跨越了 stopRun 执行的时间点,子 run 在 stopRun 拍完快照**之后**才落库,
  12 + * 永远不在 targets 里,`stopRun` 本身又是全同步函数、没有内部 await,事后补扫赶不上。
  13 + *
  14 + * 修法钉在 executeSubgraph:`await planWorkGraph` 之后、真正 `createRun` 之前重新读一次
  15 + * 父 run,`stop_requested` 已经变真就放弃创建子 run,就地按节点失败结算——不产生孤儿。
  16 + *
  17 + * 用真实的 org run + subgraph 节点复现(不是打桩内部实现细节):`setPlanWorkGraph` 注入一个
  18 + * 可以从测试里手动控制何时 resolve 的假规划,制造出与生产一致的 await 窗口,在窗口内调用
  19 + * `engine.stopRun`,再放行规划,断言子 run 没有被建出来、父节点按 fail 收敛、run 落 stopped。
  20 + */
  21 +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
  22 +import fs from 'fs';
  23 +import os from 'os';
  24 +import path from 'path';
  25 +
  26 +describe('stopRun 与 executeSubgraph 的建子 run 竞态(T-mtgxcnfz)', () => {
  27 + let orgSvc;
  28 + let wdb;
  29 + let engine;
  30 + let runsDir;
  31 + const TEST_DB_PATH = '/tmp/test-graph-engine-stop-run-orphan-race.sqlite';
  32 +
  33 + beforeAll(() => {
  34 + for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) {
  35 + try { fs.unlinkSync(f); } catch {}
  36 + }
  37 + process.env.GRAPH_DB_PATH = TEST_DB_PATH;
  38 + runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-engine-stop-run-orphan-race-'));
  39 + process.env.GRAPH_RUNS_DIR = runsDir;
  40 + for (const m of [
  41 + '../../services/graph-db',
  42 + '../../services/org-graph-service',
  43 + '../../services/work-graph-db',
  44 + '../../services/graph-run-sse',
  45 + '../../services/graph-engine',
  46 + ]) {
  47 + delete require.cache[require.resolve(m)];
  48 + }
  49 + orgSvc = require('../../services/org-graph-service');
  50 + wdb = require('../../services/work-graph-db');
  51 + engine = require('../../services/graph-engine');
  52 + });
  53 +
  54 + afterAll(() => {
  55 + for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) {
  56 + try { fs.unlinkSync(f); } catch {}
  57 + }
  58 + delete process.env.GRAPH_DB_PATH;
  59 + try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {}
  60 + delete process.env.GRAPH_RUNS_DIR;
  61 + });
  62 +
  63 + beforeEach(() => {
  64 + const db = wdb.getDb();
  65 + db.pragma('foreign_keys = OFF');
  66 + db.exec(`
  67 + DELETE FROM graph_run_loop_records;
  68 + DELETE FROM graph_run_nodes;
  69 + DELETE FROM graph_runs;
  70 + DELETE FROM work_graph_edges;
  71 + DELETE FROM work_graph_nodes;
  72 + DELETE FROM work_graphs;
  73 + DELETE FROM org_graph_edges;
  74 + DELETE FROM org_graph_nodes;
  75 + DELETE FROM system_agents;
  76 + `);
  77 + db.pragma('foreign_keys = ON');
  78 + engine.setAgentExecutor(null);
  79 + engine.setPlanWorkGraph(null);
  80 + });
  81 +
  82 + let seq = 0;
  83 + const nextCompany = () => `co-stop-race-${++seq}`;
  84 +
  85 + it('planWorkGraph 还没 resolve 时被 stopRun 停掉:不建子 run,节点按 fail 收敛,run 落 stopped', async () => {
  86 + const companyId = nextCompany();
  87 + expect(orgSvc.writeGraph(companyId, { nodes: ['org-coder'], edges: [] }).ok).toBe(true);
  88 +
  89 + let releasePlanning;
  90 + const planningStarted = new Promise((resolveStarted) => {
  91 + engine.setPlanWorkGraph((ctx) => {
  92 + resolveStarted();
  93 + return new Promise((resolve) => {
  94 + releasePlanning = () => {
  95 + // 造一张真实可用的子图定义——workGraphId 不能是 null,否则会在
  96 + // executeSubgraph 里被「未返回 workGraphId」那道**无关**的校验拦下,
  97 + // 那不是这条测试要盯的路径,会把「没建成子 run」的原因张冠李戴。
  98 + const worker = `${ctx.nodeName}-worker`;
  99 + const db = wdb.getDb();
  100 + const now = new Date().toISOString();
  101 + db.prepare(`
  102 + INSERT OR IGNORE INTO system_agents
  103 + (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at)
  104 + VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?)
  105 + `).run(worker, worker, `system/agents/${worker}`, now, now);
  106 + const g = wdb.createWorkGraph({ companyId, nodes: [worker], edges: [] });
  107 + resolve({ workGraphId: g.id });
  108 + };
  109 + });
  110 + });
  111 + });
  112 +
  113 + const run = wdb.createRun({ graphKind: 'org', graphId: companyId, companyId, executionMode: 'manual' });
  114 + engine.markRunReady(run.id);
  115 + engine.startRun(run.id, { mode: 'manual' });
  116 +
  117 + // 不 await:advanceOne 内部会在 executeSubgraph 里卡在 planWorkGraph 那次 await 上,
  118 + // 这条 promise 要等我们手动 releasePlanning 之后才会继续往下走。
  119 + const stepPromise = engine.nextStep(run.id);
  120 + await planningStarted; // 确认已经真的进了 await 窗口,不是还没排到
  121 +
  122 + // 窗口期间人工调 /stop——此时子 run 还没建出来,descendantRuns(run) 快照里只有 run 自己。
  123 + const stopResult = engine.stopRun(run.id);
  124 + expect(stopResult.ok).toBe(true);
  125 + expect(wdb.getRun(run.id).run_status).toBe('stopped');
  126 + // 这一刻整棵树只有 root 自己——旧代码的 bug 正是「这个断言此刻成立,但之后会被打破」
  127 + expect(wdb.listRunTree(run.id)).toHaveLength(1);
  128 +
  129 + // 放行规划,让 executeSubgraph 走到「要不要建子 run」那一步
  130 + releasePlanning();
  131 + const stepResult = await stepPromise;
  132 + expect(stepResult.ok).toBe(true);
  133 +
  134 + // 核心断言:没有孤儿子 run——树上除了 root 一个都没多出来
  135 + const tree = wdb.listRunTree(run.id);
  136 + expect(tree).toHaveLength(1);
  137 +
  138 + // 节点没有 pending 的 child_run_id,且不再停在 running 干等一个不存在的子 run——
  139 + // 没有匹配的 fail 边时 routeFailure 落 blocked(需要人工介入),这里不咬死具体是
  140 + // fail 还是 blocked,只钉住「不是孤儿」这条:没有子 run、没有继续假装在跑。
  141 + const node = wdb.getRunState(run.id).nodes.find((n) => n.name === 'org-coder');
  142 + expect(node.childRunId).toBeNull();
  143 + expect(node.status).not.toBe('running');
  144 + expect(['fail', 'blocked']).toContain(node.status);
  145 + });
  146 +
  147 + it('对照组:没有并发 stop 时,同样的 await 窗口结束后子 run 正常建出来', async () => {
  148 + const companyId = nextCompany();
  149 + expect(orgSvc.writeGraph(companyId, { nodes: ['org-coder'], edges: [] }).ok).toBe(true);
  150 +
  151 + engine.setPlanWorkGraph(async (ctx) => {
  152 + const worker = `${ctx.nodeName}-worker`;
  153 + const db = wdb.getDb();
  154 + const now = new Date().toISOString();
  155 + db.prepare(`
  156 + INSERT OR IGNORE INTO system_agents
  157 + (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at)
  158 + VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?)
  159 + `).run(worker, worker, `system/agents/${worker}`, now, now);
  160 + const g = wdb.createWorkGraph({ companyId, nodes: [worker], edges: [] });
  161 + expect(g.errors || []).toEqual([]);
  162 + return { workGraphId: g.id };
  163 + });
  164 +
  165 + const run = wdb.createRun({ graphKind: 'org', graphId: companyId, companyId, executionMode: 'manual' });
  166 + engine.markRunReady(run.id);
  167 + engine.startRun(run.id, { mode: 'manual' });
  168 +
  169 + const stepResult = await engine.nextStep(run.id);
  170 + expect(stepResult.ok).toBe(true);
  171 + expect(wdb.listRunTree(run.id)).toHaveLength(2);
  172 + const node = wdb.getRunState(run.id).nodes.find((n) => n.name === 'org-coder');
  173 + expect(node.childRunId).toBeTruthy();
  174 + expect(node.status).toBe('running'); // 等子 run 收敛,父节点停在 running
  175 + });
  176 +});
server/tests/unit/graph-run-route-next-auto-guard.test.js 0 → 100644
  1 +/**
  2 + * Unit Test — POST /api/graph-runs/:id/next 必须拒绝 auto 模式的 run(T-mtgxcnfz 复现)
  3 + *
  4 + * `execution_mode='auto'` 的 run 在 `startRun` 里已经挂了一个 `runAuto` 后台驱动循环
  5 + * (graph-engine.js 的 `runAuto`),会自己不停调 `advanceTree` 往下推。此前 `/next` 路由
  6 + * 只检查 `run_status ∈ {running, paused}`,不看 `execution_mode`——人在控制台上对一条正在
  7 + * auto 跑的 run 手动点「下一步」,会和后台驱动循环并发推进同一条 run,两条路径互不知情。
  8 + *
  9 + * 真实事故:CEO 验证 44 号修复时手动反复调 /next 撞上这个口子,导致 task/goal 被误判
  10 + * cancelled/failed,人工排查+纠正过数据(见 T-mtgxcnfz 任务描述)。
  11 + *
  12 + * 走真入口:router 挂真 express,listen(0) 再 fetch(与 graph-run-route-io.test.js 同法)。
  13 + */
  14 +const fs = require('fs');
  15 +const os = require('os');
  16 +const path = require('path');
  17 +const express = require('express');
  18 +
  19 +const TEST_DB_PATH = '/tmp/test-graph-run-route-next-auto-guard.sqlite';
  20 +const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`];
  21 +const MODULES = [
  22 + '../../services/graph-db',
  23 + '../../services/graph-service',
  24 + '../../services/org-graph-service',
  25 + '../../services/work-graph-db',
  26 + '../../services/company-paths',
  27 + '../../services/graph-run-sse',
  28 + '../../services/graph-engine',
  29 + '../../routes/graph-run',
  30 +];
  31 +
  32 +const CID = 'co-next-auto-guard';
  33 +
  34 +describe('POST /api/graph-runs/:id/next — auto 模式必须 400,不许和后台驱动并发推进', () => {
  35 + let wdb;
  36 + let engine;
  37 + let srv;
  38 + let base;
  39 + let runsDir;
  40 +
  41 + beforeAll(async () => {
  42 + require('../helpers/ensure-test-company').ensureTestCompany(CID);
  43 + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
  44 + process.env.GRAPH_DB_PATH = TEST_DB_PATH;
  45 + runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'route-next-auto-guard-runs-'));
  46 + process.env.GRAPH_RUNS_DIR = runsDir;
  47 + for (const m of MODULES) delete require.cache[require.resolve(m)];
  48 + wdb = require('../../services/work-graph-db');
  49 + engine = require('../../services/graph-engine');
  50 +
  51 + const app = express();
  52 + app.use(express.json());
  53 + app.use('/api/graph-runs', require('../../routes/graph-run'));
  54 + await new Promise((resolve) => {
  55 + srv = app.listen(0, '127.0.0.1', () => {
  56 + base = `http://127.0.0.1:${srv.address().port}`;
  57 + resolve();
  58 + });
  59 + });
  60 + });
  61 +
  62 + afterAll(async () => {
  63 + if (srv) await new Promise((r) => srv.close(r));
  64 + require('../helpers/ensure-test-company').removeTestCompany(CID);
  65 + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
  66 + delete process.env.GRAPH_DB_PATH;
  67 + try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {}
  68 + delete process.env.GRAPH_RUNS_DIR;
  69 + for (const m of MODULES) delete require.cache[require.resolve(m)];
  70 + });
  71 +
  72 + afterEach(() => {
  73 + engine.setAgentExecutor(null);
  74 + });
  75 +
  76 + function ensureAgents(ids) {
  77 + const db = wdb.getDb();
  78 + const now = new Date().toISOString();
  79 + const ins = db.prepare(`
  80 + INSERT OR IGNORE INTO system_agents
  81 + (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at)
  82 + VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?)
  83 + `);
  84 + for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, now, now);
  85 + }
  86 +
  87 + it('run 处于 running 且 execution_mode=auto 时,/next 返回 400,不推进节点', async () => {
  88 + ensureAgents(['w']);
  89 + const g = wdb.createWorkGraph({ companyId: CID, nodes: ['w'], edges: [] });
  90 + expect(g.errors || []).toEqual([]);
  91 + const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId: CID, executionMode: 'auto' });
  92 + engine.markRunReady(run.id);
  93 +
  94 + // executor 挂起不返回:让节点卡在 running,run 停在 running + auto,
  95 + // 这正是「后台驱动循环还没跑完」的真实窗口——/next 必须在这个窗口里也拒绝。
  96 + let releaseNode;
  97 + engine.setAgentExecutor(() => new Promise((resolve) => { releaseNode = resolve; }));
  98 +
  99 + const { driver } = engine.startRun(run.id, { mode: 'auto' });
  100 + // 等 advanceOne 真正把节点从 ready 推成 running 再发请求,避免时序竞态误判
  101 + await new Promise((resolve) => {
  102 + const check = () => {
  103 + const node = wdb.getRunState(run.id).nodes.find((n) => n.name === 'w');
  104 + if (node && node.status === 'running') return resolve();
  105 + setImmediate(check);
  106 + };
  107 + check();
  108 + });
  109 +
  110 + const before = wdb.getRun(run.id);
  111 + expect(before.execution_mode).toBe('auto');
  112 + expect(before.run_status).toBe('running');
  113 +
  114 + const r = await fetch(`${base}/api/graph-runs/${run.id}/next`, { method: 'POST' });
  115 + expect(r.status).toBe(400);
  116 + const body = await r.json();
  117 + expect(body.error).toMatch(/auto/);
  118 +
  119 + // 节点没被 /next 动过——还是同一个 running 节点,没有被拾取第二次
  120 + const after = wdb.getRunState(run.id).nodes.find((n) => n.name === 'w');
  121 + expect(after.status).toBe('running');
  122 + expect(after.attempt).toBe(before && 0); // 未被推进,attempt 维持初始值
  123 +
  124 + releaseNode({ status: 'success' });
  125 + await driver;
  126 + expect(wdb.getRun(run.id).run_status).toBe('completed');
  127 + });
  128 +
  129 + it('manual 模式的 running run,/next 仍然放行(不误伤既有用法)', async () => {
  130 + ensureAgents(['m']);
  131 + const g = wdb.createWorkGraph({ companyId: CID, nodes: ['m'], edges: [] });
  132 + const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId: CID, executionMode: 'manual' });
  133 + engine.markRunReady(run.id);
  134 + engine.setAgentExecutor(async () => ({ status: 'success' }));
  135 + engine.startRun(run.id, { mode: 'manual' });
  136 +
  137 + const r = await fetch(`${base}/api/graph-runs/${run.id}/next`, { method: 'POST' });
  138 + expect(r.status).toBe(200);
  139 + expect(wdb.getRun(run.id).run_status).toBe('completed');
  140 + });
  141 +});