Commit 91d5a9880fcdaf3b43d5f747c93ff90f7ee36a0c
1 parent
59d57224
fix(p3b): 修两个硬缺陷——子 run 终态不回写父节点、守卫归属误判
## 1. 子 run 转终态不回写父节点 → 任务永久卡死(界面走查发现) 复现:org 节点的子 work run 第一次失败 → 父节点判 fail → 入口节点没有 fail 出边 → blocked → 用户在中栏把子 run 修好跑到 completed → **父节点仍钉在 blocked, root run 也钉住,org 层永远推不下去**。唯一人工出路是对 org 节点点「重试」, 而那会把整张子图重置到下一个 attempt,刚跑完的成果全丢。 根因:propagateToParent 的幂等守卫写的是 `parentNode.status !== 'running' → return`, 判据用错了维度——它想防「同一个结果被结算两次」,实际拦掉的是「子 run 换了个结果」。 subgraph 节点的状态本来就是从子 run 派生的,子 run 换了终态就该重新派生。 修法:新增 reviveParentFromChild,只认「转好」(fail/blocked → success), 且要求 parentNode.child_run_id 正是这个子 run。恢复走 **blocked → ready → running → success 三步合法转换**,而不是给全局放宽 NODE_TRANSITIONS——转换表是给所有调用方的 安全网,不该为一条恢复路径把 blocked → success 对外开放;三条 node.status 事件 也让「这个节点是被救回来的」在 timeline 上看得见。 补测试时才暴露的第二半:父 run 多半也钉在 blocked,而 RUN_TRANSITIONS 没有 blocked → completed,settleRun 在转换非法时是**静默跳过**的。所以复活时要先把 父 run 从 blocked 拉回 running(这条合法),否则节点复活了 run 还是卡着。 ## 2. 边界守卫把并行 agent 的写算到当前节点头上(真 CLI 冒烟的断点) 真跑实测:招聘模板库里没有的角色走 hired_from_base,自动流水线起 agent-creator, 而它的 CLI workDir 就是 system/agents/agent-creator/——一个被 git 跟踪的目录。 它在里面重写自己的 todo.md、删一条 scheduled_tasks,被整仓 git diff 扫到, 归属算到当时正在跑的 work 节点,一个**真正成功**的节点被判 fail。 只要这些运行态文件还被 git 跟踪,每次含 hired_from_base 的招聘都稳定复现。 修法:splitUnattributable 把 agent 运行态文件(todo.md / plan.md / memory.md / _lessons.md / .claude/scheduled_tasks.json)从越界证据里摘出去,单独记一条 unattributable,不参与判罚。摘除范围刻意窄——同目录下的 agent.md / config.json 等源码仍然算越界。 路径提取按「首个空白之前」切而不是固定偏移:diffRepoStatus 已去掉前导空格, porcelain 状态位长度不定(修改类 "M path" 1 字符、未跟踪类 "?? path" 2 字符), 固定 slice(3) 恰好对**修改类**失效——而 agent-creator 改自己 todo.md 正是修改类。 这条单独立了一个回归用例。 ponytail: 这是缓解不是根治。根治要把运行态文件从 git 移出去(27-P3B W13), 本次只保证守卫不再冤枉节点;同一次招聘里 agent 若写了运行态文件之外的源码, 仍会算到当前节点头上。 测试 760/760 全绿(72 文件,起点 753/71):新增 graph-engine-revive-parent.test.js 4 条 + graph-boundary.test.js 3 条。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Showing
3 changed files
with
360 additions
and
5 deletions
server/services/graph-engine.js
| ... | ... | @@ -1879,11 +1879,66 @@ function propagateToParent(childRunId) { |
| 1879 | 1879 | const child = getRun(childRunId); |
| 1880 | 1880 | if (!child || !child.parent_run_id || !child.parent_node_run_id) return; |
| 1881 | 1881 | const parentNode = getRunNode(child.parent_node_run_id); |
| 1882 | - if (!parentNode || parentNode.status !== 'running') return; // 已经结算过 → 幂等返回 | |
| 1882 | + if (!parentNode) return; | |
| 1883 | 1883 | |
| 1884 | - const outcome = deriveChildOutcome(child); | |
| 1885 | - // remediationNote 不是 graph_run_nodes 的列,随 outcome 一起交给父层的选边/回退用 | |
| 1886 | - settleNode(child.parent_run_id, child.parent_node_run_id, outcome); | |
| 1884 | + if (parentNode.status === 'running') { | |
| 1885 | + // remediationNote 不是 graph_run_nodes 的列,随 outcome 一起交给父层的选边/回退用 | |
| 1886 | + settleNode(child.parent_run_id, child.parent_node_run_id, deriveChildOutcome(child)); | |
| 1887 | + return; | |
| 1888 | + } | |
| 1889 | + reviveParentFromChild(child, parentNode); | |
| 1890 | +} | |
| 1891 | + | |
| 1892 | +/** | |
| 1893 | + * 迟到的好消息:父节点已经因这个子 run 的**上一次**失败结算成 fail/blocked, | |
| 1894 | + * 而子 run 后来被人从下一层救回来跑到了 completed。 | |
| 1895 | + * | |
| 1896 | + * 原来这里的幂等守卫是 `parentNode.status !== 'running' → return`,判据用错了维度: | |
| 1897 | + * 它想防的是「同一个结果被结算两次」,实际拦掉的是「子 run 换了个结果」。 | |
| 1898 | + * 界面走查实测的后果是**任务永久卡死** —— 子 run 已 completed、父节点钉在 blocked、 | |
| 1899 | + * root run 跟着钉住,org 层再也推不下去;唯一人工出路是对 org 节点点「重试」, | |
| 1900 | + * 而那会把整张子图重置到下一个 attempt,**刚跑完的成果全丢**。 | |
| 1901 | + * | |
| 1902 | + * subgraph 节点的状态本来就是**从子 run 派生**的,子 run 换了终态就该重新派生一次。 | |
| 1903 | + * | |
| 1904 | + * 只处理「转好」(fail/blocked → success),不处理「转坏」:子 run 从 completed 掉回 | |
| 1905 | + * blocked 只可能是上层回退把它重新拉起又失败了,那条路径本来就会重走 running → 正常结算。 | |
| 1906 | + * | |
| 1907 | + * `blocked`/`fail` 的合法出路只有 `ready`(NODE_TRANSITIONS),所以这里走 | |
| 1908 | + * **blocked → ready → running → success 三步合法转换**,而不是放宽转换表 —— | |
| 1909 | + * 转换表是给所有调用方的安全网,不该为一条恢复路径把 `blocked → success` 对全局开放。 | |
| 1910 | + * 三条 node.status 事件也让「这个节点是被救回来的」在 timeline 上看得见。 | |
| 1911 | + */ | |
| 1912 | +function reviveParentFromChild(child, parentNode) { | |
| 1913 | + if (parentNode.status !== 'fail' && parentNode.status !== 'blocked') return; | |
| 1914 | + if (parentNode.child_run_id !== child.id) return; // 不是它在等的那个子 run | |
| 1915 | + if (child.run_status !== 'completed') return; // 只认「转好」 | |
| 1916 | + | |
| 1917 | + const runId = child.parent_run_id; | |
| 1918 | + const nodeRunId = child.parent_node_run_id; | |
| 1919 | + const nodeName = (defIndex(getRun(runId)).nodes.get(parentNode.def_node_id) || {}).name || nodeRunId; | |
| 1920 | + const why = `子 run ${child.id} 已收敛为 completed,父节点从 ${parentNode.status} 重新派生`; | |
| 1921 | + | |
| 1922 | + // 父 run 多半也跟着钉在 blocked(唯一的 blocked 节点就是它)。settleRun 在转换非法时是 | |
| 1923 | + // **静默跳过**的,而 RUN_TRANSITIONS 里没有 blocked → completed,所以不先把 run 拉回 | |
| 1924 | + // running 的话:节点复活成 success 了,run 还留在 blocked,org 层照样推不下去 —— | |
| 1925 | + // 这一条是补测试时才暴露出来的,光复活节点并不够。 | |
| 1926 | + const parentRun = getRun(runId); | |
| 1927 | + if (parentRun && parentRun.run_status === 'blocked') { | |
| 1928 | + sse.transitionRun(runId, 'blocked', 'running', {}, { | |
| 1929 | + summary: `run ${runId}: blocked → running:${why}`, revived_from_child: child.id, | |
| 1930 | + }); | |
| 1931 | + } | |
| 1932 | + | |
| 1933 | + // 每一步都是条件更新(WHERE status = from),任一步被别人抢走就整体放弃,不留半截状态 | |
| 1934 | + if (!sse.transitionNode(runId, nodeRunId, parentNode.status, 'ready', {}, { | |
| 1935 | + nodeName, summary: `${parentNode.status} → ready:${why}`, revived_from_child: child.id, | |
| 1936 | + })) return; | |
| 1937 | + if (!sse.transitionNode(runId, nodeRunId, 'ready', 'running', {}, { | |
| 1938 | + nodeName, summary: `ready → running:${why}`, revived_from_child: child.id, | |
| 1939 | + })) return; | |
| 1940 | + | |
| 1941 | + settleNode(runId, nodeRunId, deriveChildOutcome(child)); | |
| 1887 | 1942 | } |
| 1888 | 1943 | |
| 1889 | 1944 | /** 从子 run 提取三值透传的内容 */ |
| ... | ... | @@ -2554,11 +2609,53 @@ function beginBoundaryGuard(ctx) { |
| 2554 | 2609 | * 越界走的是那条兜底边)。subgraph 的 pending 不降级:它的子 run 还在跑,就地判失败会 |
| 2555 | 2610 | * 留下一张没人回写的孤儿子 run,只留痕。 |
| 2556 | 2611 | */ |
| 2612 | +/** | |
| 2613 | + * agent 的**运行态**文件 —— 不是源码,却躺在被 git 跟踪的源码树里。 | |
| 2614 | + * | |
| 2615 | + * 真 CLI 冒烟实测的断点就在这:招聘一个模板库里没有的角色会走 hired_from_base, | |
| 2616 | + * 自动流水线起 agent-creator,而它的 CLI workDir 就是 `system/agents/agent-creator/`。 | |
| 2617 | + * 它在里面重写自己的 todo.md、删一条 scheduled_tasks —— 这些改动被整仓 git diff 扫到, | |
| 2618 | + * 归属算到了**当时正在跑的那个 work 节点**头上,于是一个真正成功的节点被判 fail。 | |
| 2619 | + * 只要这些文件还被 git 跟踪、agent 的 cwd 还在源码树里,每次含 hired_from_base 的 | |
| 2620 | + * 招聘都会稳定复现。 | |
| 2621 | + * | |
| 2622 | + * 所以这类路径从「越界证据」里摘出去,单独记一条 unattributable,不参与判罚。 | |
| 2623 | + * ponytail: 这是**缓解不是根治**。根治要把这些运行态文件从 git 移出去 | |
| 2624 | + * (见 27-P3B §四 W13),本函数只保证守卫不再冤枉节点。 | |
| 2625 | + * 上限:同一次招聘里 agent 若写了运行态文件**之外**的源码,仍会算到当前节点头上。 | |
| 2626 | + */ | |
| 2627 | +const AGENT_RUNTIME_STATE_RE = | |
| 2628 | + /^system\/agents\/[^/]+\/(todo\.md|plan\.md|memory\.md|_lessons\.md|\.claude\/scheduled_tasks\.json)$/; | |
| 2629 | + | |
| 2630 | +function splitUnattributable(entries) { | |
| 2631 | + const escapes = []; | |
| 2632 | + const unattributable = []; | |
| 2633 | + for (const e of entries) { | |
| 2634 | + // diffRepoStatus 已经把前导空格去掉了,所以 porcelain 的两字符状态位在这里长度不定: | |
| 2635 | + // 修改类是 "M path"(1 字符),未跟踪类是 "?? path"(2 字符)。按「首个空白之前」切, | |
| 2636 | + // 不要按固定偏移切 —— 固定 slice(3) 会把 "M system/..." 切成 "stem/...", | |
| 2637 | + // 恰好对**修改类**失效,而 agent-creator 改自己 todo.md 正是修改类。 | |
| 2638 | + const p = String(e).replace(/^\S{1,2}\s+/, '').trim().replace(/^"|"$/g, ''); | |
| 2639 | + (AGENT_RUNTIME_STATE_RE.test(p) ? unattributable : escapes).push(e); | |
| 2640 | + } | |
| 2641 | + return { escapes, unattributable }; | |
| 2642 | +} | |
| 2643 | + | |
| 2557 | 2644 | function applyBoundaryGuard(ctx, before, outcome) { |
| 2558 | 2645 | if (before === null || before === undefined) return outcome; |
| 2559 | 2646 | const after = hostRepoStatus(); |
| 2560 | 2647 | if (after === null) return outcome; |
| 2561 | - const escapes = diffRepoStatus(before, after); | |
| 2648 | + const { escapes, unattributable } = splitUnattributable(diffRepoStatus(before, after)); | |
| 2649 | + | |
| 2650 | + if (unattributable.length) { | |
| 2651 | + timelineNote(ctx, 'node.boundary', | |
| 2652 | + `本次调用窗口内有 ${unattributable.length} 处 agent 运行态文件改动,无法归属到本节点,不计入越界`, { | |
| 2653 | + node_run_id: ctx.nodeRunId, attempt: ctx.attempt, violation: null, | |
| 2654 | + unattributable: unattributable.slice(0, 20), | |
| 2655 | + note: '多半是同期招聘起的 agent 写的(如 agent-creator 改自己的 todo.md);' | |
| 2656 | + + '根治见 27-P3B W13:把这些运行态文件从 git 移出去', | |
| 2657 | + }); | |
| 2658 | + } | |
| 2562 | 2659 | if (escapes.length === 0) return outcome; |
| 2563 | 2660 | |
| 2564 | 2661 | const shown = escapes.slice(0, 20); |
| ... | ... | @@ -3016,6 +3113,8 @@ module.exports = { |
| 3016 | 3113 | // 导出只是让单测能直接验这两条判据,不是新开一圈公开面。 |
| 3017 | 3114 | ensureProjectRoot, |
| 3018 | 3115 | diffRepoStatus, |
| 3116 | + // 归属判定:agent 运行态文件不算越界(真跑冒烟的断点就在这,见函数上方注释) | |
| 3117 | + splitUnattributable, | |
| 3019 | 3118 | |
| 3020 | 3119 | // 选边 / 熔断 / 透传 |
| 3021 | 3120 | selectFailEdge, | ... | ... |
server/tests/unit/graph-boundary.test.js
| ... | ... | @@ -111,6 +111,45 @@ describe('graph-engine 项目根与边界守卫', () => { |
| 111 | 111 | expect(tasks[0].projectRoot).toBe(tasks[1].projectRoot); |
| 112 | 112 | }); |
| 113 | 113 | |
| 114 | + /** | |
| 115 | + * 真 CLI 冒烟的断点:招聘一个模板库里没有的角色会走 hired_from_base, | |
| 116 | + * 自动流水线起 agent-creator,而它的 CLI workDir 就是 system/agents/agent-creator/。 | |
| 117 | + * 它在里面重写自己的 todo.md、删一条 scheduled_tasks —— 这些改动被整仓 git diff 扫到, | |
| 118 | + * 归属算到了当时正在跑的那个 work 节点头上,一个**真正成功**的节点被判 fail。 | |
| 119 | + * 只要这些运行态文件还被 git 跟踪,每次含 hired_from_base 的招聘都会稳定复现。 | |
| 120 | + */ | |
| 121 | + it('W3:agent 运行态文件不算越界(真跑冒烟的断点)', () => { | |
| 122 | + const { escapes, unattributable } = engine.splitUnattributable([ | |
| 123 | + 'M system/agents/agent-creator/todo.md', | |
| 124 | + 'M system/agents/agent-creator/.claude/scheduled_tasks.json', | |
| 125 | + '?? server/routes/echo.js', | |
| 126 | + 'M server/index.js', | |
| 127 | + ]); | |
| 128 | + // 真越界的两条要留下判罚 | |
| 129 | + expect(escapes).toEqual(['?? server/routes/echo.js', 'M server/index.js']); | |
| 130 | + // 运行态那两条摘出去,只记录不判罚 | |
| 131 | + expect(unattributable).toEqual([ | |
| 132 | + 'M system/agents/agent-creator/todo.md', | |
| 133 | + 'M system/agents/agent-creator/.claude/scheduled_tasks.json', | |
| 134 | + ]); | |
| 135 | + }); | |
| 136 | + | |
| 137 | + it('W3:路径提取按空白切,不按固定偏移(修改类只有 1 个状态字符)', () => { | |
| 138 | + // 固定 slice(3) 会把 "M system/..." 切成 "stem/...",恰好对**修改类**失效 —— | |
| 139 | + // 而 agent-creator 改自己 todo.md 正是修改类,这条锁的就是那个回归 | |
| 140 | + const { unattributable } = engine.splitUnattributable(['M system/agents/x/plan.md']); | |
| 141 | + expect(unattributable).toEqual(['M system/agents/x/plan.md']); | |
| 142 | + }); | |
| 143 | + | |
| 144 | + it('W3:agent 目录下的源码仍然算越界(摘除范围不能过宽)', () => { | |
| 145 | + const { escapes, unattributable } = engine.splitUnattributable([ | |
| 146 | + 'M system/agents/agent-creator/agent.md', | |
| 147 | + 'M system/agents/agent-creator/config.json', | |
| 148 | + ]); | |
| 149 | + expect(escapes).toHaveLength(2); | |
| 150 | + expect(unattributable).toEqual([]); | |
| 151 | + }); | |
| 152 | + | |
| 114 | 153 | it('W3:diffRepoStatus 只报本次调用期间新出现的仓库改动', () => { |
| 115 | 154 | const before = ' M server/services/graph-engine.js\n?? scratch/a.txt\n'; |
| 116 | 155 | const after = ' M server/services/graph-engine.js\n?? scratch/a.txt\n?? server/routes/echo.js\n M server/index.js\n'; | ... | ... |
server/tests/unit/graph-engine-revive-parent.test.js
0 → 100644
| 1 | +/** | |
| 2 | + * Unit Tests — 子 run 转终态回写父节点(27-P3B 界面走查发现的永久卡死) | |
| 3 | + * | |
| 4 | + * 界面走查(Claude in Chrome 真点)实测的复现路径: | |
| 5 | + * org 节点的子 work run 第一次失败 → 父 org 节点判 fail → 入口节点没有 fail 出边 → blocked | |
| 6 | + * → 用户在中栏把子 run 修好、跑到 completed | |
| 7 | + * → **父节点还钉在 blocked,root run 也钉在 blocked,org 层永远推不到下一个节点** | |
| 8 | + * | |
| 9 | + * 根因:propagateToParent 的幂等守卫写的是 | |
| 10 | + * if (parentNode.status !== 'running') return; // 「已经结算过 → 幂等返回」 | |
| 11 | + * 判据用错了维度 —— 它想防「同一个结果被结算两次」,实际拦掉的是「子 run 换了个结果」。 | |
| 12 | + * subgraph 节点的状态本来就是从子 run 派生的,子 run 换了终态就该重新派生。 | |
| 13 | + * | |
| 14 | + * 当时唯一的人工出路是对 org 节点点「重试」,而那会把整张子图重置到下一个 attempt, | |
| 15 | + * 刚跑完的成果全丢 —— 所以这条必须进 CI,不能只靠人点界面才发现。 | |
| 16 | + * | |
| 17 | + * 隔离手法同 graph-engine-node-error.test.js:先设 GRAPH_DB_PATH / GRAPH_RUNS_DIR 再 require, | |
| 18 | + * 仓库里的 data/graph.sqlite 与 data/companies/ 全程不被碰到;executor 全是注入的假货。 | |
| 19 | + */ | |
| 20 | +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; | |
| 21 | +import fs from 'fs'; | |
| 22 | +import os from 'os'; | |
| 23 | +import path from 'path'; | |
| 24 | + | |
| 25 | +describe('子 run 转终态回写父节点', () => { | |
| 26 | + let wdb; | |
| 27 | + let engine; | |
| 28 | + let sse; | |
| 29 | + let runsDir; | |
| 30 | + const TEST_DB_PATH = '/tmp/test-graph-engine-revive-parent.sqlite'; | |
| 31 | + | |
| 32 | + beforeAll(() => { | |
| 33 | + for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) { | |
| 34 | + try { fs.unlinkSync(f); } catch {} | |
| 35 | + } | |
| 36 | + process.env.GRAPH_DB_PATH = TEST_DB_PATH; | |
| 37 | + runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-revive-runs-')); | |
| 38 | + process.env.GRAPH_RUNS_DIR = runsDir; | |
| 39 | + for (const m of [ | |
| 40 | + '../../services/graph-db', | |
| 41 | + '../../services/graph-service', | |
| 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 | + wdb = require('../../services/work-graph-db'); | |
| 50 | + sse = require('../../services/graph-run-sse'); | |
| 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 system_agents; | |
| 74 | + `); | |
| 75 | + db.pragma('foreign_keys = ON'); | |
| 76 | + engine.setAgentExecutor(null); | |
| 77 | + engine.setPlanWorkGraph(null); | |
| 78 | + }); | |
| 79 | + | |
| 80 | + let seq = 0; | |
| 81 | + const nextCompany = () => `revive-co-${++seq}`; | |
| 82 | + | |
| 83 | + function ensureAgents(ids) { | |
| 84 | + const db = wdb.getDb(); | |
| 85 | + const now = new Date().toISOString(); | |
| 86 | + const ins = db.prepare(` | |
| 87 | + INSERT OR IGNORE INTO system_agents | |
| 88 | + (id, name, scope, owner_company_id, source_kind, legacy_path, status, created_at, updated_at) | |
| 89 | + VALUES (?, ?, 'public', NULL, 'global_agent', ?, 'active', ?, ?) | |
| 90 | + `); | |
| 91 | + for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, now, now); | |
| 92 | + } | |
| 93 | + | |
| 94 | + const nodeByName = (runId, name) => { | |
| 95 | + const st = wdb.getRunState(runId); | |
| 96 | + return (st.nodes || []).find((n) => n.name === name); | |
| 97 | + }; | |
| 98 | + | |
| 99 | + /** | |
| 100 | + * 造出走查里那个局面:父 run 一个 subgraph 节点 + 一张会失败的子图。 | |
| 101 | + * 父图的入口节点故意**没有 fail 出边** —— 走查里就是这么走到 blocked 的。 | |
| 102 | + */ | |
| 103 | + function buildParentWithFailingChild() { | |
| 104 | + const companyId = nextCompany(); | |
| 105 | + ensureAgents(['org-planner', 'wg-only']); | |
| 106 | + | |
| 107 | + const childG = wdb.createWorkGraph({ companyId, nodes: ['wg-only'], edges: [] }); | |
| 108 | + expect(childG.errors || []).toEqual([]); | |
| 109 | + | |
| 110 | + const parentG = wdb.createWorkGraph({ | |
| 111 | + companyId, | |
| 112 | + nodes: [{ nodeName: 'org-planner', systemAgentId: 'org-planner', nodeKind: 'subgraph' }], | |
| 113 | + edges: [], | |
| 114 | + }); | |
| 115 | + expect(parentG.errors || []).toEqual([]); | |
| 116 | + | |
| 117 | + const parentRun = wdb.createRun({ | |
| 118 | + graphKind: 'work', graphId: parentG.id, companyId, executionMode: 'manual', | |
| 119 | + }); | |
| 120 | + engine.markRunReady(parentRun.id); | |
| 121 | + engine.startRun(parentRun.id, { mode: 'manual' }); // advanceOne 要求 run_status='running' | |
| 122 | + | |
| 123 | + // 子图规划注入:第一次执行该 subgraph 节点时把上面那张 childG 挂上去 | |
| 124 | + engine.setPlanWorkGraph(() => ({ workGraphId: childG.id })); | |
| 125 | + // 子图里那个节点固定失败,且不给 fail_reason(走查里就是 fail_reason=null) | |
| 126 | + engine.setAgentExecutor(async () => ({ status: 'fail', failReason: null, output: '子节点失败' })); | |
| 127 | + | |
| 128 | + return { companyId, parentRunId: parentRun.id }; | |
| 129 | + } | |
| 130 | + | |
| 131 | + /** 把父 run 推到「子 run 失败、父节点 blocked」的状态 */ | |
| 132 | + async function driveToBlocked(parentRunId) { | |
| 133 | + for (let i = 0; i < 12; i += 1) { | |
| 134 | + const r = await engine.advanceTree(parentRunId); | |
| 135 | + if (!r || r.ok === false) break; | |
| 136 | + } | |
| 137 | + const parent = nodeByName(parentRunId, 'org-planner'); | |
| 138 | + return parent; | |
| 139 | + } | |
| 140 | + | |
| 141 | + it('复现前提:子 run 失败会把父 subgraph 节点推到 blocked', async () => { | |
| 142 | + const { parentRunId } = buildParentWithFailingChild(); | |
| 143 | + const parent = await driveToBlocked(parentRunId); | |
| 144 | + | |
| 145 | + expect(parent.status).toBe('blocked'); | |
| 146 | + expect(parent.childRunId).toBeTruthy(); | |
| 147 | + expect(wdb.getRunState(parentRunId).run.run_status).toBe('blocked'); | |
| 148 | + }); | |
| 149 | + | |
| 150 | + it('子 run 后来跑到 completed 时,父节点从 blocked 被重新派生为 success(不再永久卡死)', async () => { | |
| 151 | + const { parentRunId } = buildParentWithFailingChild(); | |
| 152 | + const blocked = await driveToBlocked(parentRunId); | |
| 153 | + const childRunId = blocked.childRunId; | |
| 154 | + expect(blocked.status).toBe('blocked'); | |
| 155 | + | |
| 156 | + // 用户在中栏把子 run 修好:换成会成功的执行器,重试子图里那个失败节点并跑完 | |
| 157 | + engine.setAgentExecutor(async () => ({ status: 'success', output: '修好了' })); | |
| 158 | + const childNode = wdb.getRunState(childRunId).nodes[0]; | |
| 159 | + engine.retryNode(childRunId, childNode.id); | |
| 160 | + for (let i = 0; i < 6; i += 1) { | |
| 161 | + const r = await engine.advanceOne(childRunId); | |
| 162 | + if (!r || r.ok === false) break; | |
| 163 | + } | |
| 164 | + expect(wdb.getRunState(childRunId).run.run_status).toBe('completed'); | |
| 165 | + | |
| 166 | + // 关键断言:父节点不再钉在 blocked | |
| 167 | + const parent = nodeByName(parentRunId, 'org-planner'); | |
| 168 | + expect(parent.status).toBe('success'); | |
| 169 | + expect(wdb.getRunState(parentRunId).run.run_status).toBe('completed'); | |
| 170 | + }); | |
| 171 | + | |
| 172 | + it('复活走的是 blocked → ready → running → success 三步合法转换,timeline 上留得下痕迹', async () => { | |
| 173 | + const { parentRunId } = buildParentWithFailingChild(); | |
| 174 | + const blocked = await driveToBlocked(parentRunId); | |
| 175 | + const childRunId = blocked.childRunId; | |
| 176 | + | |
| 177 | + engine.setAgentExecutor(async () => ({ status: 'success', output: '修好了' })); | |
| 178 | + const childNode = wdb.getRunState(childRunId).nodes[0]; | |
| 179 | + engine.retryNode(childRunId, childNode.id); | |
| 180 | + for (let i = 0; i < 6; i += 1) { | |
| 181 | + const r = await engine.advanceOne(childRunId); | |
| 182 | + if (!r || r.ok === false) break; | |
| 183 | + } | |
| 184 | + | |
| 185 | + const lines = fs.readFileSync(sse.timelinePath(parentRunId), 'utf8') | |
| 186 | + .split('\n').filter(Boolean).map((l) => JSON.parse(l)); | |
| 187 | + const revived = lines.filter((l) => l.payload && l.payload.revived_from_child === childRunId); | |
| 188 | + const hop = (l) => `${l.payload.from}→${l.payload.to}`; | |
| 189 | + | |
| 190 | + // 节点轨:blocked → ready → running 两跳都要在,否则说明是靠放宽转换表硬转过去的 | |
| 191 | + expect(revived.filter((l) => l.kind === 'node.status').map(hop)) | |
| 192 | + .toEqual(['blocked→ready', 'ready→running']); | |
| 193 | + // run 轨:父 run 也得被拉回 running,否则节点复活了 run 还钉在 blocked | |
| 194 | + //(RUN_TRANSITIONS 没有 blocked → completed,settleRun 在转换非法时是静默跳过的) | |
| 195 | + expect(revived.filter((l) => l.kind === 'run.status').map(hop)).toEqual(['blocked→running']); | |
| 196 | + }); | |
| 197 | + | |
| 198 | + it('子 run 再失败一轮时父节点不动(只认转好,不追加惩罚)', async () => { | |
| 199 | + // propagateToParent 不导出(本模块的约定:不为测试开公开面), | |
| 200 | + // 所以这里用真入口触发第二次回写:重试子节点、让它再失败一次, | |
| 201 | + // 子 run 重新收敛到 blocked 的路上引擎自己会调 propagateToParent。 | |
| 202 | + const { parentRunId } = buildParentWithFailingChild(); | |
| 203 | + const blocked = await driveToBlocked(parentRunId); | |
| 204 | + const childRunId = blocked.childRunId; | |
| 205 | + | |
| 206 | + const childNode = wdb.getRunState(childRunId).nodes[0]; | |
| 207 | + engine.retryNode(childRunId, childNode.id); | |
| 208 | + for (let i = 0; i < 6; i += 1) { | |
| 209 | + const r = await engine.advanceOne(childRunId); | |
| 210 | + if (!r || r.ok === false) break; | |
| 211 | + } | |
| 212 | + expect(wdb.getRunState(childRunId).run.run_status).toBe('blocked'); | |
| 213 | + | |
| 214 | + const parent = nodeByName(parentRunId, 'org-planner'); | |
| 215 | + expect(parent.status).toBe('blocked'); | |
| 216 | + }); | |
| 217 | +}); | ... | ... |