diff --git a/server/services/graph-engine.js b/server/services/graph-engine.js index f146598..c7571b8 100644 --- a/server/services/graph-engine.js +++ b/server/services/graph-engine.js @@ -37,6 +37,7 @@ const { // 注意:updateNodeStatus / updateRunStatus **故意不在这里 import**——状态转换一律走 // 下面的 sse.transitionNode / transitionRun,否则又会退回「改了状态但没有事件」的老路。 bumpNodeAttempt, + bumpInfraFlakeCount, setRunFlags, // 批 3 复核 F1:准入被拒之后的两条出路 —— 退回队列(可自愈)或留痕(等人) RunSlotBusyError, @@ -143,6 +144,16 @@ const LOOP_HARD_CAP_RUN = 5; const INFRA_FLAKE_REASON = 'infra_flake'; /** + * 30 号文档 §四:`infra_flake` 原地重跑故意不烧 attempt/backward_count("这不是节点自己的 + * 错,不该烧它的重试额度"),代价是它是唯一没有熔断线的失败路径——真撞见过一次确定性 + * 死循环(归因节点把"该重跑别的节点"的判词误判成自己的 infra_flake,每轮产出雷同)。 + * 这条硬顶只管一件事:同一个节点连续判 infra_flake 到第几次就不再当"真的是基础设施抖动", + * 改判失败走正常选边(大概率落进兜底边或直接 blocked,交给人工)。数值跟 + * `LOOP_HARD_CAP_EDGE`(backward 边单边硬顶)取同一量级,没有更细的依据。 + */ +const INFRA_FLAKE_HARD_CAP = 3; + +/** * validator-triager 的归因取值域(system/agents/validator-triager/agent.md * 「归因取值域(四选一,不要发明第五个)」= §四.D.2.2 那张表)。 * @@ -1941,6 +1952,23 @@ function routeFailure(runId, def, nodeRun, out, remediationNote = null) { if (nodeRun.fail_reason === INFRA_FLAKE_REASON) { const d = def.nodes.get(nodeRun.def_node_id) || {}; const name = d.name || nodeRun.def_node_id; + + // 30 号文档 §四:这是唯一没有熔断线的失败路径(attempt/backward_count 都故意不烧), + // 真撞过一次确定性死循环——连续 N 次判 infra_flake 就不再当"基础设施抖动",改判失败, + // 交给人工(不硬猜该走哪条 fail 边:infra_flake 这个取值域通常没人给它连边,直接 + // blockNode 比"碰巧选中一条不相关的边"更可预期)。 + const flakeCount = bumpInfraFlakeCount(nodeRun.id); + if (flakeCount > INFRA_FLAKE_HARD_CAP) { + sse.emitEvent(runId, 'loop.terminated', + `熔断:节点 ${name} 连续 ${flakeCount} 次被判 infra_flake,不像基础设施抖动,改为人工介入`, + { node_run_id: nodeRun.id, node_name: name, infra_flake_count: flakeCount, + cap: INFRA_FLAKE_HARD_CAP, verdict: 'force_terminated' }, + { ref: nodeRun.id }); + blockNode(runId, nodeRun.id, + remediationNote || `连续 ${flakeCount} 次被判 infra_flake(超过硬顶 ${INFRA_FLAKE_HARD_CAP}),已停止原地重跑`); + return; + } + // 原地重跑 = 一轮新的执行,但它**不加 attempt**(§四.D.2.1 末段),所以 R-17.1 的代次判据 // 在这条路径上分辨不出新旧轮 —— 因此 child_run_id 与 child_run_attempt 必须一起清空, // 让 executeSubgraph 走「没有子 run」那条分支重新规划。少清 child_run_attempt 那一半, @@ -1950,7 +1978,8 @@ function routeFailure(runId, def, nodeRun, out, remediationNote = null) { sse.transitionNode(runId, nodeRun.id, 'fail', 'ready', { childRunId: null, childRunAttempt: null, failReason: null, failNodeRef: null, ...notePatch(remediationNote) }, - { node_name: name, attempt: nodeRun.attempt, summary: `node ${name}: infra_flake 原地重跑(不烧计数)` }) + { node_name: name, attempt: nodeRun.attempt, + summary: `node ${name}: infra_flake 原地重跑(不烧计数,第 ${flakeCount}/${INFRA_FLAKE_HARD_CAP} 次)` }) ) { out.activated.push({ id: nodeRun.id, defNodeId: nodeRun.def_node_id, name }); out.infraRerun = true; diff --git a/server/services/work-graph-db.js b/server/services/work-graph-db.js index 41b853c..5934a0d 100644 --- a/server/services/work-graph-db.js +++ b/server/services/work-graph-db.js @@ -370,6 +370,13 @@ function getDb() { -- 且 loop record 是熔断账本(loop_count 按 (run_id, edge_id) 记录数算), -- 借它存判词会直接污染熔断计数。 remediation_note TEXT, + -- 2026-08-23(30 号文档 §四):infra_flake 原地重跑故意不烧 attempt/backward_count + -- ("这不是节点自己的错"),代价是它是唯一没有熔断线的失败路径——真撞见过一次 + -- 确定性死循环(归因节点把"该重跑别的节点"的判词误判成自己的 infra_flake, + -- 每轮产出雷同,永远不会被现有的 attempt/backward 计数拦住)。这一列记"这个节点 + -- 连续被判 infra_flake 原地重跑了几次",真正发起新一轮真实派发时清零, + -- 见 graph-engine.js 的 INFRA_FLAKE_HARD_CAP。 + infra_flake_count INTEGER NOT NULL DEFAULT 0, started_at TEXT, completed_at TEXT, created_at TEXT NOT NULL, @@ -639,6 +646,10 @@ function getDb() { if (!hasColumn(db, 'graph_run_loop_records', 'target_node_ref')) { db.exec(`ALTER TABLE graph_run_loop_records ADD COLUMN target_node_ref TEXT`); } + // 30 号文档 §四:infra_flake 熔断计数,旧行一律 0("从没原地重跑过",与事实一致)。 + if (!hasColumn(db, 'graph_run_nodes', 'infra_flake_count')) { + db.exec(`ALTER TABLE graph_run_nodes ADD COLUMN infra_flake_count INTEGER NOT NULL DEFAULT 0`); + } // 27-P3C §四.9(批 2):自动拆分失败时的降级留痕。 // 降级后的 goal 与「本来就只有一条 task」的 goal 在库里长得**一模一样**(1 条 task、 // created_by='human'、depends_on=[]),没有这一列就分不出「这是拆出来的」和 @@ -1424,12 +1435,25 @@ function updateNodeStatus(nodeRunId, from, to, patch = {}) { } /** attempt + 1(回退时用,§四.D.2.1 第 3 步);返回受影响行数 */ +// 同一次 UPDATE 顺手把 infra_flake_count 清零:attempt 真的往前走了,说明这一轮不是 +// infra_flake 原地重跑(那条路径故意不调这个函数,§四.D.2.1),上一轮的连续 flake 计数 +// 不该带进新的一轮——不清零的话,一次真实重试之后紧跟着的下一次 infra_flake 会被 +// 误判成"已经连续好几次了",提前熔断本该被放行的重试。 function bumpNodeAttempt(nodeRunId) { return getDb() - .prepare(`UPDATE graph_run_nodes SET attempt = attempt + 1, updated_at = ? WHERE id = ?`) + .prepare(`UPDATE graph_run_nodes SET attempt = attempt + 1, infra_flake_count = 0, updated_at = ? WHERE id = ?`) .run(new Date().toISOString(), nodeRunId).changes; } +/** 30 号文档 §四:infra_flake 原地重跑一次,计数 +1;返回加完之后的值供调用方直接比熔断线 */ +function bumpInfraFlakeCount(nodeRunId) { + const now = new Date().toISOString(); + getDb() + .prepare(`UPDATE graph_run_nodes SET infra_flake_count = infra_flake_count + 1, updated_at = ? WHERE id = ?`) + .run(now, nodeRunId); + return (getRunNode(nodeRunId) || {}).infra_flake_count || 0; +} + const RUN_PATCH_COLUMNS = { pauseRequested: 'pause_requested', stopRequested: 'stop_requested', @@ -2618,6 +2642,7 @@ module.exports = { getRunNodeByDefId, updateNodeStatus, bumpNodeAttempt, + bumpInfraFlakeCount, updateRunStatus, // 「坑满没满」的唯一计数处(WHERE 与准入事务同源),调度器 / 路由复用它 countActiveRootRuns, diff --git a/server/tests/unit/graph-engine.test.js b/server/tests/unit/graph-engine.test.js index 7949033..87603ce 100644 --- a/server/tests/unit/graph-engine.test.js +++ b/server/tests/unit/graph-engine.test.js @@ -832,6 +832,33 @@ describe('graph-engine', () => { expect(wdb.getRun(runId).backward_count).toBe(0); // 不加 run 级计数 expect(wdb.getRun(runId).run_status).toBe('completed'); }); + + // 30 号文档 §四:infra_flake 是唯一没有熔断线的失败路径,真撞过一次确定性死循环 + // (归因节点把"该重跑别的节点"的判词误判成自己的 infra_flake,每轮产出雷同、 + // 永远不停)。锁住修完之后的行为:连续判 infra_flake 到硬顶就停止原地重跑,改判 blocked。 + it('连续判 infra_flake 超过硬顶就停止原地重跑,改判 blocked(不是无限循环)', async () => { + const companyId = nextCompany(); + const { runId } = buildWorkRun({ + companyId, + nodes: ['w'], + edges: [], + mode: 'auto', + }); + // 每次都判 infra_flake,永不恢复——模拟真实撞见的那种"归因结论每轮雷同"死循环 + const calls = fakeExecutor({ + w: { status: 'fail', failReason: 'infra_flake' }, + }); + + await engine.startRun(runId, { mode: 'auto' }).driver; + + // 硬顶 3:第 4 次判定 infra_flake 时熔断(1、2、3 次原地重跑,第 4 次不再重跑) + expect(calls.filter((c) => c.node === 'w')).toHaveLength(4); + expect(nodeByName(runId, 'w').status).toBe('blocked'); + expect(nodeByName(runId, 'w').attempt).toBe(0); // 全程没烧 attempt——熔断走的不是 backward 那条账 + expect(wdb.listLoopRecords(runId)).toHaveLength(0); // 也没有污染 backward 的熔断账本 + expect(wdb.getRun(runId).backward_count).toBe(0); + expect(wdb.getRun(runId).run_status).toBe('blocked'); + }); }); // ──────────────────────────────────────────────────────────────