Commit 91d5a9880fcdaf3b43d5f747c93ff90f7ee36a0c

Authored by yibo
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>
server/services/graph-engine.js
@@ -1879,11 +1879,66 @@ function propagateToParent(childRunId) { @@ -1879,11 +1879,66 @@ function propagateToParent(childRunId) {
1879 const child = getRun(childRunId); 1879 const child = getRun(childRunId);
1880 if (!child || !child.parent_run_id || !child.parent_node_run_id) return; 1880 if (!child || !child.parent_run_id || !child.parent_node_run_id) return;
1881 const parentNode = getRunNode(child.parent_node_run_id); 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 /** 从子 run 提取三值透传的内容 */ 1944 /** 从子 run 提取三值透传的内容 */
@@ -2554,11 +2609,53 @@ function beginBoundaryGuard(ctx) { @@ -2554,11 +2609,53 @@ function beginBoundaryGuard(ctx) {
2554 * 越界走的是那条兜底边)。subgraph 的 pending 不降级:它的子 run 还在跑,就地判失败会 2609 * 越界走的是那条兜底边)。subgraph 的 pending 不降级:它的子 run 还在跑,就地判失败会
2555 * 留下一张没人回写的孤儿子 run,只留痕。 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 function applyBoundaryGuard(ctx, before, outcome) { 2644 function applyBoundaryGuard(ctx, before, outcome) {
2558 if (before === null || before === undefined) return outcome; 2645 if (before === null || before === undefined) return outcome;
2559 const after = hostRepoStatus(); 2646 const after = hostRepoStatus();
2560 if (after === null) return outcome; 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 if (escapes.length === 0) return outcome; 2659 if (escapes.length === 0) return outcome;
2563 2660
2564 const shown = escapes.slice(0, 20); 2661 const shown = escapes.slice(0, 20);
@@ -3016,6 +3113,8 @@ module.exports = { @@ -3016,6 +3113,8 @@ module.exports = {
3016 // 导出只是让单测能直接验这两条判据,不是新开一圈公开面。 3113 // 导出只是让单测能直接验这两条判据,不是新开一圈公开面。
3017 ensureProjectRoot, 3114 ensureProjectRoot,
3018 diffRepoStatus, 3115 diffRepoStatus,
  3116 + // 归属判定:agent 运行态文件不算越界(真跑冒烟的断点就在这,见函数上方注释)
  3117 + splitUnattributable,
3019 3118
3020 // 选边 / 熔断 / 透传 3119 // 选边 / 熔断 / 透传
3021 selectFailEdge, 3120 selectFailEdge,
server/tests/unit/graph-boundary.test.js
@@ -111,6 +111,45 @@ describe(&#39;graph-engine 项目根与边界守卫&#39;, () =&gt; { @@ -111,6 +111,45 @@ describe(&#39;graph-engine 项目根与边界守卫&#39;, () =&gt; {
111 expect(tasks[0].projectRoot).toBe(tasks[1].projectRoot); 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 it('W3:diffRepoStatus 只报本次调用期间新出现的仓库改动', () => { 153 it('W3:diffRepoStatus 只报本次调用期间新出现的仓库改动', () => {
115 const before = ' M server/services/graph-engine.js\n?? scratch/a.txt\n'; 154 const before = ' M server/services/graph-engine.js\n?? scratch/a.txt\n';
116 const after = ' M server/services/graph-engine.js\n?? scratch/a.txt\n?? server/routes/echo.js\n M server/index.js\n'; 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 +});