Commit 2f177c7a38a18ef45df25ee5ef4b23d4235858c2

Authored by yibo
1 parent 0ed020c3

fix(graph-engine): infra_flake 原地重跑加熔断线,堵住新发现的确定性死循环

30 号文档记录的真实事故:org-coder-test 被上游 missing_required_input 打回后,
产出的是「建议重跑 org-coder-validator」的诊断文件而不是正常测试结果,引擎把诊断文件里
的 infra_flake 标记当成了这个节点自己的执行结果——infra_flake 原地重跑故意不烧
attempt/backward_count("这不是节点的错,不该烧它的重试额度"),代价是它是唯一没有
熔断线的失败路径,真撞上了一次每轮产出雷同、永不停止的死循环,眼看着把任务预算耗光。

新增 graph_run_nodes.infra_flake_count 列,只在 infra_flake 原地重跑时累加,真正发起
新一轮真实派发(bumpNodeAttempt)时清零;连续判 infra_flake 超过硬顶(3,与
LOOP_HARD_CAP_EDGE 同量级)就不再原地重跑,改判 blocked 交给人工——不猜该走哪条
fail 边(infra_flake 这个取值域通常没人给它连边)。新增回归测试锁死"永不恢复的
infra_flake 在硬顶后停止重跑"这条行为,graph-engine 相关 10 个测试文件 94/94 通过。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
server/services/graph-engine.js
... ... @@ -37,6 +37,7 @@ const {
37 37 // 注意:updateNodeStatus / updateRunStatus **故意不在这里 import**——状态转换一律走
38 38 // 下面的 sse.transitionNode / transitionRun,否则又会退回「改了状态但没有事件」的老路。
39 39 bumpNodeAttempt,
  40 + bumpInfraFlakeCount,
40 41 setRunFlags,
41 42 // 批 3 复核 F1:准入被拒之后的两条出路 —— 退回队列(可自愈)或留痕(等人)
42 43 RunSlotBusyError,
... ... @@ -143,6 +144,16 @@ const LOOP_HARD_CAP_RUN = 5;
143 144 const INFRA_FLAKE_REASON = 'infra_flake';
144 145  
145 146 /**
  147 + * 30 号文档 §四:`infra_flake` 原地重跑故意不烧 attempt/backward_count("这不是节点自己的
  148 + * 错,不该烧它的重试额度"),代价是它是唯一没有熔断线的失败路径——真撞见过一次确定性
  149 + * 死循环(归因节点把"该重跑别的节点"的判词误判成自己的 infra_flake,每轮产出雷同)。
  150 + * 这条硬顶只管一件事:同一个节点连续判 infra_flake 到第几次就不再当"真的是基础设施抖动",
  151 + * 改判失败走正常选边(大概率落进兜底边或直接 blocked,交给人工)。数值跟
  152 + * `LOOP_HARD_CAP_EDGE`(backward 边单边硬顶)取同一量级,没有更细的依据。
  153 + */
  154 +const INFRA_FLAKE_HARD_CAP = 3;
  155 +
  156 +/**
146 157 * validator-triager 的归因取值域(system/agents/validator-triager/agent.md
147 158 * 「归因取值域(四选一,不要发明第五个)」= §四.D.2.2 那张表)。
148 159 *
... ... @@ -1941,6 +1952,23 @@ function routeFailure(runId, def, nodeRun, out, remediationNote = null) {
1941 1952 if (nodeRun.fail_reason === INFRA_FLAKE_REASON) {
1942 1953 const d = def.nodes.get(nodeRun.def_node_id) || {};
1943 1954 const name = d.name || nodeRun.def_node_id;
  1955 +
  1956 + // 30 号文档 §四:这是唯一没有熔断线的失败路径(attempt/backward_count 都故意不烧),
  1957 + // 真撞过一次确定性死循环——连续 N 次判 infra_flake 就不再当"基础设施抖动",改判失败,
  1958 + // 交给人工(不硬猜该走哪条 fail 边:infra_flake 这个取值域通常没人给它连边,直接
  1959 + // blockNode 比"碰巧选中一条不相关的边"更可预期)。
  1960 + const flakeCount = bumpInfraFlakeCount(nodeRun.id);
  1961 + if (flakeCount > INFRA_FLAKE_HARD_CAP) {
  1962 + sse.emitEvent(runId, 'loop.terminated',
  1963 + `熔断:节点 ${name} 连续 ${flakeCount} 次被判 infra_flake,不像基础设施抖动,改为人工介入`,
  1964 + { node_run_id: nodeRun.id, node_name: name, infra_flake_count: flakeCount,
  1965 + cap: INFRA_FLAKE_HARD_CAP, verdict: 'force_terminated' },
  1966 + { ref: nodeRun.id });
  1967 + blockNode(runId, nodeRun.id,
  1968 + remediationNote || `连续 ${flakeCount} 次被判 infra_flake(超过硬顶 ${INFRA_FLAKE_HARD_CAP}),已停止原地重跑`);
  1969 + return;
  1970 + }
  1971 +
1944 1972 // 原地重跑 = 一轮新的执行,但它**不加 attempt**(§四.D.2.1 末段),所以 R-17.1 的代次判据
1945 1973 // 在这条路径上分辨不出新旧轮 —— 因此 child_run_id 与 child_run_attempt 必须一起清空,
1946 1974 // 让 executeSubgraph 走「没有子 run」那条分支重新规划。少清 child_run_attempt 那一半,
... ... @@ -1950,7 +1978,8 @@ function routeFailure(runId, def, nodeRun, out, remediationNote = null) {
1950 1978 sse.transitionNode(runId, nodeRun.id, 'fail', 'ready',
1951 1979 { childRunId: null, childRunAttempt: null, failReason: null, failNodeRef: null,
1952 1980 ...notePatch(remediationNote) },
1953   - { node_name: name, attempt: nodeRun.attempt, summary: `node ${name}: infra_flake 原地重跑(不烧计数)` })
  1981 + { node_name: name, attempt: nodeRun.attempt,
  1982 + summary: `node ${name}: infra_flake 原地重跑(不烧计数,第 ${flakeCount}/${INFRA_FLAKE_HARD_CAP} 次)` })
1954 1983 ) {
1955 1984 out.activated.push({ id: nodeRun.id, defNodeId: nodeRun.def_node_id, name });
1956 1985 out.infraRerun = true;
... ...
server/services/work-graph-db.js
... ... @@ -370,6 +370,13 @@ function getDb() {
370 370 -- 且 loop record 是熔断账本(loop_count 按 (run_id, edge_id) 记录数算),
371 371 -- 借它存判词会直接污染熔断计数。
372 372 remediation_note TEXT,
  373 + -- 2026-08-23(30 号文档 §四):infra_flake 原地重跑故意不烧 attempt/backward_count
  374 + -- ("这不是节点自己的错"),代价是它是唯一没有熔断线的失败路径——真撞见过一次
  375 + -- 确定性死循环(归因节点把"该重跑别的节点"的判词误判成自己的 infra_flake,
  376 + -- 每轮产出雷同,永远不会被现有的 attempt/backward 计数拦住)。这一列记"这个节点
  377 + -- 连续被判 infra_flake 原地重跑了几次",真正发起新一轮真实派发时清零,
  378 + -- 见 graph-engine.js 的 INFRA_FLAKE_HARD_CAP。
  379 + infra_flake_count INTEGER NOT NULL DEFAULT 0,
373 380 started_at TEXT,
374 381 completed_at TEXT,
375 382 created_at TEXT NOT NULL,
... ... @@ -639,6 +646,10 @@ function getDb() {
639 646 if (!hasColumn(db, 'graph_run_loop_records', 'target_node_ref')) {
640 647 db.exec(`ALTER TABLE graph_run_loop_records ADD COLUMN target_node_ref TEXT`);
641 648 }
  649 + // 30 号文档 §四:infra_flake 熔断计数,旧行一律 0("从没原地重跑过",与事实一致)。
  650 + if (!hasColumn(db, 'graph_run_nodes', 'infra_flake_count')) {
  651 + db.exec(`ALTER TABLE graph_run_nodes ADD COLUMN infra_flake_count INTEGER NOT NULL DEFAULT 0`);
  652 + }
642 653 // 27-P3C §四.9(批 2):自动拆分失败时的降级留痕。
643 654 // 降级后的 goal 与「本来就只有一条 task」的 goal 在库里长得**一模一样**(1 条 task、
644 655 // created_by='human'、depends_on=[]),没有这一列就分不出「这是拆出来的」和
... ... @@ -1424,12 +1435,25 @@ function updateNodeStatus(nodeRunId, from, to, patch = {}) {
1424 1435 }
1425 1436  
1426 1437 /** attempt + 1(回退时用,§四.D.2.1 第 3 步);返回受影响行数 */
  1438 +// 同一次 UPDATE 顺手把 infra_flake_count 清零:attempt 真的往前走了,说明这一轮不是
  1439 +// infra_flake 原地重跑(那条路径故意不调这个函数,§四.D.2.1),上一轮的连续 flake 计数
  1440 +// 不该带进新的一轮——不清零的话,一次真实重试之后紧跟着的下一次 infra_flake 会被
  1441 +// 误判成"已经连续好几次了",提前熔断本该被放行的重试。
1427 1442 function bumpNodeAttempt(nodeRunId) {
1428 1443 return getDb()
1429   - .prepare(`UPDATE graph_run_nodes SET attempt = attempt + 1, updated_at = ? WHERE id = ?`)
  1444 + .prepare(`UPDATE graph_run_nodes SET attempt = attempt + 1, infra_flake_count = 0, updated_at = ? WHERE id = ?`)
1430 1445 .run(new Date().toISOString(), nodeRunId).changes;
1431 1446 }
1432 1447  
  1448 +/** 30 号文档 §四:infra_flake 原地重跑一次,计数 +1;返回加完之后的值供调用方直接比熔断线 */
  1449 +function bumpInfraFlakeCount(nodeRunId) {
  1450 + const now = new Date().toISOString();
  1451 + getDb()
  1452 + .prepare(`UPDATE graph_run_nodes SET infra_flake_count = infra_flake_count + 1, updated_at = ? WHERE id = ?`)
  1453 + .run(now, nodeRunId);
  1454 + return (getRunNode(nodeRunId) || {}).infra_flake_count || 0;
  1455 +}
  1456 +
1433 1457 const RUN_PATCH_COLUMNS = {
1434 1458 pauseRequested: 'pause_requested',
1435 1459 stopRequested: 'stop_requested',
... ... @@ -2618,6 +2642,7 @@ module.exports = {
2618 2642 getRunNodeByDefId,
2619 2643 updateNodeStatus,
2620 2644 bumpNodeAttempt,
  2645 + bumpInfraFlakeCount,
2621 2646 updateRunStatus,
2622 2647 // 「坑满没满」的唯一计数处(WHERE 与准入事务同源),调度器 / 路由复用它
2623 2648 countActiveRootRuns,
... ...
server/tests/unit/graph-engine.test.js
... ... @@ -832,6 +832,33 @@ describe(&#39;graph-engine&#39;, () =&gt; {
832 832 expect(wdb.getRun(runId).backward_count).toBe(0); // 不加 run 级计数
833 833 expect(wdb.getRun(runId).run_status).toBe('completed');
834 834 });
  835 +
  836 + // 30 号文档 §四:infra_flake 是唯一没有熔断线的失败路径,真撞过一次确定性死循环
  837 + // (归因节点把"该重跑别的节点"的判词误判成自己的 infra_flake,每轮产出雷同、
  838 + // 永远不停)。锁住修完之后的行为:连续判 infra_flake 到硬顶就停止原地重跑,改判 blocked。
  839 + it('连续判 infra_flake 超过硬顶就停止原地重跑,改判 blocked(不是无限循环)', async () => {
  840 + const companyId = nextCompany();
  841 + const { runId } = buildWorkRun({
  842 + companyId,
  843 + nodes: ['w'],
  844 + edges: [],
  845 + mode: 'auto',
  846 + });
  847 + // 每次都判 infra_flake,永不恢复——模拟真实撞见的那种"归因结论每轮雷同"死循环
  848 + const calls = fakeExecutor({
  849 + w: { status: 'fail', failReason: 'infra_flake' },
  850 + });
  851 +
  852 + await engine.startRun(runId, { mode: 'auto' }).driver;
  853 +
  854 + // 硬顶 3:第 4 次判定 infra_flake 时熔断(1、2、3 次原地重跑,第 4 次不再重跑)
  855 + expect(calls.filter((c) => c.node === 'w')).toHaveLength(4);
  856 + expect(nodeByName(runId, 'w').status).toBe('blocked');
  857 + expect(nodeByName(runId, 'w').attempt).toBe(0); // 全程没烧 attempt——熔断走的不是 backward 那条账
  858 + expect(wdb.listLoopRecords(runId)).toHaveLength(0); // 也没有污染 backward 的熔断账本
  859 + expect(wdb.getRun(runId).backward_count).toBe(0);
  860 + expect(wdb.getRun(runId).run_status).toBe('blocked');
  861 + });
835 862 });
836 863  
837 864 // ──────────────────────────────────────────────────────────────
... ...