Commit 364788aedcb8b878220cade4838174512d945165
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>
Showing
6 changed files
with
545 additions
and
15 deletions
docs/README.md
| ... | ... | @@ -61,6 +61,7 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d |
| 61 | 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 | 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 | 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 | 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 | 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('/:id/next', h(async (req, res) => { |
| 304 | 304 | const run = loadRun(req, res); |
| 305 | 305 | if (!run) return; |
| 306 | 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 | 314 | const out = await engine.nextStep(run.id, req.body && req.body.task ? { task: req.body.task } : {}); |
| 309 | 315 | sendAdvance(res, out); | ... | ... |
server/services/graph-engine.js
| ... | ... | @@ -1212,6 +1212,23 @@ async function executeSubgraph(ctx) { |
| 1212 | 1212 | // 正是本轮在盯的那类漏接(注入假规划的测试不返回 tokens,这里按 0 处理)。 |
| 1213 | 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 | 1232 | const child = createRun({ |
| 1216 | 1233 | graphKind: 'work', |
| 1217 | 1234 | graphId: workGraphId, |
| ... | ... | @@ -3541,7 +3558,7 @@ function stopRun(runId) { |
| 3541 | 3558 | // 软信号先在整棵子树上一次置齐,再逐层处理:否则处理深层的时候浅层还能拾取新节点 |
| 3542 | 3559 | for (const r of targets) setRunFlags(r.id, { stopRequested: true }); |
| 3543 | 3560 | |
| 3544 | - // **自底向上**(targets 里父一定排在子前面,倒序即最深优先)。 | |
| 3561 | + // **自底向上**(层内父一定排在子前面,倒序即最深优先)。 | |
| 3545 | 3562 | // |
| 3546 | 3563 | // 倒序 + 每层落 stopped 后补一次 propagateToParent,修的是「终态 run 上留着永远 running |
| 3547 | 3564 | // 的节点」(R6-F3 minor):子 run 被 stopRun 直接转 stopped,不经过 settleRun, |
| ... | ... | @@ -3552,26 +3569,47 @@ function stopRun(runId) { |
| 3552 | 3569 | // 于是回写顺带点亮的下游(fail 边 / 兜底边的目标)同样会被 skip,不留悬空 ready。 |
| 3553 | 3570 | // 只停子树时父 run 不在 targets 里,它就此拿到「子 run 停了」这个失败结果,按图上的 |
| 3554 | 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 | 3608 | return { |
| 3571 | 3609 | ok: true, |
| 3572 | 3610 | runId, |
| 3573 | 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 | +}); | ... | ... |