Commit 2f177c7a38a18ef45df25ee5ef4b23d4235858c2
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>
Showing
3 changed files
with
83 additions
and
2 deletions
server/services/graph-engine.js
| @@ -37,6 +37,7 @@ const { | @@ -37,6 +37,7 @@ const { | ||
| 37 | // 注意:updateNodeStatus / updateRunStatus **故意不在这里 import**——状态转换一律走 | 37 | // 注意:updateNodeStatus / updateRunStatus **故意不在这里 import**——状态转换一律走 |
| 38 | // 下面的 sse.transitionNode / transitionRun,否则又会退回「改了状态但没有事件」的老路。 | 38 | // 下面的 sse.transitionNode / transitionRun,否则又会退回「改了状态但没有事件」的老路。 |
| 39 | bumpNodeAttempt, | 39 | bumpNodeAttempt, |
| 40 | + bumpInfraFlakeCount, | ||
| 40 | setRunFlags, | 41 | setRunFlags, |
| 41 | // 批 3 复核 F1:准入被拒之后的两条出路 —— 退回队列(可自愈)或留痕(等人) | 42 | // 批 3 复核 F1:准入被拒之后的两条出路 —— 退回队列(可自愈)或留痕(等人) |
| 42 | RunSlotBusyError, | 43 | RunSlotBusyError, |
| @@ -143,6 +144,16 @@ const LOOP_HARD_CAP_RUN = 5; | @@ -143,6 +144,16 @@ const LOOP_HARD_CAP_RUN = 5; | ||
| 143 | const INFRA_FLAKE_REASON = 'infra_flake'; | 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 | * validator-triager 的归因取值域(system/agents/validator-triager/agent.md | 157 | * validator-triager 的归因取值域(system/agents/validator-triager/agent.md |
| 147 | * 「归因取值域(四选一,不要发明第五个)」= §四.D.2.2 那张表)。 | 158 | * 「归因取值域(四选一,不要发明第五个)」= §四.D.2.2 那张表)。 |
| 148 | * | 159 | * |
| @@ -1941,6 +1952,23 @@ function routeFailure(runId, def, nodeRun, out, remediationNote = null) { | @@ -1941,6 +1952,23 @@ function routeFailure(runId, def, nodeRun, out, remediationNote = null) { | ||
| 1941 | if (nodeRun.fail_reason === INFRA_FLAKE_REASON) { | 1952 | if (nodeRun.fail_reason === INFRA_FLAKE_REASON) { |
| 1942 | const d = def.nodes.get(nodeRun.def_node_id) || {}; | 1953 | const d = def.nodes.get(nodeRun.def_node_id) || {}; |
| 1943 | const name = d.name || nodeRun.def_node_id; | 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 | // 原地重跑 = 一轮新的执行,但它**不加 attempt**(§四.D.2.1 末段),所以 R-17.1 的代次判据 | 1972 | // 原地重跑 = 一轮新的执行,但它**不加 attempt**(§四.D.2.1 末段),所以 R-17.1 的代次判据 |
| 1945 | // 在这条路径上分辨不出新旧轮 —— 因此 child_run_id 与 child_run_attempt 必须一起清空, | 1973 | // 在这条路径上分辨不出新旧轮 —— 因此 child_run_id 与 child_run_attempt 必须一起清空, |
| 1946 | // 让 executeSubgraph 走「没有子 run」那条分支重新规划。少清 child_run_attempt 那一半, | 1974 | // 让 executeSubgraph 走「没有子 run」那条分支重新规划。少清 child_run_attempt 那一半, |
| @@ -1950,7 +1978,8 @@ function routeFailure(runId, def, nodeRun, out, remediationNote = null) { | @@ -1950,7 +1978,8 @@ function routeFailure(runId, def, nodeRun, out, remediationNote = null) { | ||
| 1950 | sse.transitionNode(runId, nodeRun.id, 'fail', 'ready', | 1978 | sse.transitionNode(runId, nodeRun.id, 'fail', 'ready', |
| 1951 | { childRunId: null, childRunAttempt: null, failReason: null, failNodeRef: null, | 1979 | { childRunId: null, childRunAttempt: null, failReason: null, failNodeRef: null, |
| 1952 | ...notePatch(remediationNote) }, | 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 | out.activated.push({ id: nodeRun.id, defNodeId: nodeRun.def_node_id, name }); | 1984 | out.activated.push({ id: nodeRun.id, defNodeId: nodeRun.def_node_id, name }); |
| 1956 | out.infraRerun = true; | 1985 | out.infraRerun = true; |
server/services/work-graph-db.js
| @@ -370,6 +370,13 @@ function getDb() { | @@ -370,6 +370,13 @@ function getDb() { | ||
| 370 | -- 且 loop record 是熔断账本(loop_count 按 (run_id, edge_id) 记录数算), | 370 | -- 且 loop record 是熔断账本(loop_count 按 (run_id, edge_id) 记录数算), |
| 371 | -- 借它存判词会直接污染熔断计数。 | 371 | -- 借它存判词会直接污染熔断计数。 |
| 372 | remediation_note TEXT, | 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 | started_at TEXT, | 380 | started_at TEXT, |
| 374 | completed_at TEXT, | 381 | completed_at TEXT, |
| 375 | created_at TEXT NOT NULL, | 382 | created_at TEXT NOT NULL, |
| @@ -639,6 +646,10 @@ function getDb() { | @@ -639,6 +646,10 @@ function getDb() { | ||
| 639 | if (!hasColumn(db, 'graph_run_loop_records', 'target_node_ref')) { | 646 | if (!hasColumn(db, 'graph_run_loop_records', 'target_node_ref')) { |
| 640 | db.exec(`ALTER TABLE graph_run_loop_records ADD COLUMN target_node_ref TEXT`); | 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 | // 27-P3C §四.9(批 2):自动拆分失败时的降级留痕。 | 653 | // 27-P3C §四.9(批 2):自动拆分失败时的降级留痕。 |
| 643 | // 降级后的 goal 与「本来就只有一条 task」的 goal 在库里长得**一模一样**(1 条 task、 | 654 | // 降级后的 goal 与「本来就只有一条 task」的 goal 在库里长得**一模一样**(1 条 task、 |
| 644 | // created_by='human'、depends_on=[]),没有这一列就分不出「这是拆出来的」和 | 655 | // created_by='human'、depends_on=[]),没有这一列就分不出「这是拆出来的」和 |
| @@ -1424,12 +1435,25 @@ function updateNodeStatus(nodeRunId, from, to, patch = {}) { | @@ -1424,12 +1435,25 @@ function updateNodeStatus(nodeRunId, from, to, patch = {}) { | ||
| 1424 | } | 1435 | } |
| 1425 | 1436 | ||
| 1426 | /** attempt + 1(回退时用,§四.D.2.1 第 3 步);返回受影响行数 */ | 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 | function bumpNodeAttempt(nodeRunId) { | 1442 | function bumpNodeAttempt(nodeRunId) { |
| 1428 | return getDb() | 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 | .run(new Date().toISOString(), nodeRunId).changes; | 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 | const RUN_PATCH_COLUMNS = { | 1457 | const RUN_PATCH_COLUMNS = { |
| 1434 | pauseRequested: 'pause_requested', | 1458 | pauseRequested: 'pause_requested', |
| 1435 | stopRequested: 'stop_requested', | 1459 | stopRequested: 'stop_requested', |
| @@ -2618,6 +2642,7 @@ module.exports = { | @@ -2618,6 +2642,7 @@ module.exports = { | ||
| 2618 | getRunNodeByDefId, | 2642 | getRunNodeByDefId, |
| 2619 | updateNodeStatus, | 2643 | updateNodeStatus, |
| 2620 | bumpNodeAttempt, | 2644 | bumpNodeAttempt, |
| 2645 | + bumpInfraFlakeCount, | ||
| 2621 | updateRunStatus, | 2646 | updateRunStatus, |
| 2622 | // 「坑满没满」的唯一计数处(WHERE 与准入事务同源),调度器 / 路由复用它 | 2647 | // 「坑满没满」的唯一计数处(WHERE 与准入事务同源),调度器 / 路由复用它 |
| 2623 | countActiveRootRuns, | 2648 | countActiveRootRuns, |
server/tests/unit/graph-engine.test.js
| @@ -832,6 +832,33 @@ describe('graph-engine', () => { | @@ -832,6 +832,33 @@ describe('graph-engine', () => { | ||
| 832 | expect(wdb.getRun(runId).backward_count).toBe(0); // 不加 run 级计数 | 832 | expect(wdb.getRun(runId).backward_count).toBe(0); // 不加 run 级计数 |
| 833 | expect(wdb.getRun(runId).run_status).toBe('completed'); | 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 | // ────────────────────────────────────────────────────────────── |