diff --git a/server/services/graph-engine.js b/server/services/graph-engine.js index 57dc4dc..fe8e984 100644 --- a/server/services/graph-engine.js +++ b/server/services/graph-engine.js @@ -1879,11 +1879,66 @@ function propagateToParent(childRunId) { const child = getRun(childRunId); if (!child || !child.parent_run_id || !child.parent_node_run_id) return; const parentNode = getRunNode(child.parent_node_run_id); - if (!parentNode || parentNode.status !== 'running') return; // 已经结算过 → 幂等返回 + if (!parentNode) return; - const outcome = deriveChildOutcome(child); - // remediationNote 不是 graph_run_nodes 的列,随 outcome 一起交给父层的选边/回退用 - settleNode(child.parent_run_id, child.parent_node_run_id, outcome); + if (parentNode.status === 'running') { + // remediationNote 不是 graph_run_nodes 的列,随 outcome 一起交给父层的选边/回退用 + settleNode(child.parent_run_id, child.parent_node_run_id, deriveChildOutcome(child)); + return; + } + reviveParentFromChild(child, parentNode); +} + +/** + * 迟到的好消息:父节点已经因这个子 run 的**上一次**失败结算成 fail/blocked, + * 而子 run 后来被人从下一层救回来跑到了 completed。 + * + * 原来这里的幂等守卫是 `parentNode.status !== 'running' → return`,判据用错了维度: + * 它想防的是「同一个结果被结算两次」,实际拦掉的是「子 run 换了个结果」。 + * 界面走查实测的后果是**任务永久卡死** —— 子 run 已 completed、父节点钉在 blocked、 + * root run 跟着钉住,org 层再也推不下去;唯一人工出路是对 org 节点点「重试」, + * 而那会把整张子图重置到下一个 attempt,**刚跑完的成果全丢**。 + * + * subgraph 节点的状态本来就是**从子 run 派生**的,子 run 换了终态就该重新派生一次。 + * + * 只处理「转好」(fail/blocked → success),不处理「转坏」:子 run 从 completed 掉回 + * blocked 只可能是上层回退把它重新拉起又失败了,那条路径本来就会重走 running → 正常结算。 + * + * `blocked`/`fail` 的合法出路只有 `ready`(NODE_TRANSITIONS),所以这里走 + * **blocked → ready → running → success 三步合法转换**,而不是放宽转换表 —— + * 转换表是给所有调用方的安全网,不该为一条恢复路径把 `blocked → success` 对全局开放。 + * 三条 node.status 事件也让「这个节点是被救回来的」在 timeline 上看得见。 + */ +function reviveParentFromChild(child, parentNode) { + if (parentNode.status !== 'fail' && parentNode.status !== 'blocked') return; + if (parentNode.child_run_id !== child.id) return; // 不是它在等的那个子 run + if (child.run_status !== 'completed') return; // 只认「转好」 + + const runId = child.parent_run_id; + const nodeRunId = child.parent_node_run_id; + const nodeName = (defIndex(getRun(runId)).nodes.get(parentNode.def_node_id) || {}).name || nodeRunId; + const why = `子 run ${child.id} 已收敛为 completed,父节点从 ${parentNode.status} 重新派生`; + + // 父 run 多半也跟着钉在 blocked(唯一的 blocked 节点就是它)。settleRun 在转换非法时是 + // **静默跳过**的,而 RUN_TRANSITIONS 里没有 blocked → completed,所以不先把 run 拉回 + // running 的话:节点复活成 success 了,run 还留在 blocked,org 层照样推不下去 —— + // 这一条是补测试时才暴露出来的,光复活节点并不够。 + const parentRun = getRun(runId); + if (parentRun && parentRun.run_status === 'blocked') { + sse.transitionRun(runId, 'blocked', 'running', {}, { + summary: `run ${runId}: blocked → running:${why}`, revived_from_child: child.id, + }); + } + + // 每一步都是条件更新(WHERE status = from),任一步被别人抢走就整体放弃,不留半截状态 + if (!sse.transitionNode(runId, nodeRunId, parentNode.status, 'ready', {}, { + nodeName, summary: `${parentNode.status} → ready:${why}`, revived_from_child: child.id, + })) return; + if (!sse.transitionNode(runId, nodeRunId, 'ready', 'running', {}, { + nodeName, summary: `ready → running:${why}`, revived_from_child: child.id, + })) return; + + settleNode(runId, nodeRunId, deriveChildOutcome(child)); } /** 从子 run 提取三值透传的内容 */ @@ -2554,11 +2609,53 @@ function beginBoundaryGuard(ctx) { * 越界走的是那条兜底边)。subgraph 的 pending 不降级:它的子 run 还在跑,就地判失败会 * 留下一张没人回写的孤儿子 run,只留痕。 */ +/** + * agent 的**运行态**文件 —— 不是源码,却躺在被 git 跟踪的源码树里。 + * + * 真 CLI 冒烟实测的断点就在这:招聘一个模板库里没有的角色会走 hired_from_base, + * 自动流水线起 agent-creator,而它的 CLI workDir 就是 `system/agents/agent-creator/`。 + * 它在里面重写自己的 todo.md、删一条 scheduled_tasks —— 这些改动被整仓 git diff 扫到, + * 归属算到了**当时正在跑的那个 work 节点**头上,于是一个真正成功的节点被判 fail。 + * 只要这些文件还被 git 跟踪、agent 的 cwd 还在源码树里,每次含 hired_from_base 的 + * 招聘都会稳定复现。 + * + * 所以这类路径从「越界证据」里摘出去,单独记一条 unattributable,不参与判罚。 + * ponytail: 这是**缓解不是根治**。根治要把这些运行态文件从 git 移出去 + * (见 27-P3B §四 W13),本函数只保证守卫不再冤枉节点。 + * 上限:同一次招聘里 agent 若写了运行态文件**之外**的源码,仍会算到当前节点头上。 + */ +const AGENT_RUNTIME_STATE_RE = + /^system\/agents\/[^/]+\/(todo\.md|plan\.md|memory\.md|_lessons\.md|\.claude\/scheduled_tasks\.json)$/; + +function splitUnattributable(entries) { + const escapes = []; + const unattributable = []; + for (const e of entries) { + // diffRepoStatus 已经把前导空格去掉了,所以 porcelain 的两字符状态位在这里长度不定: + // 修改类是 "M path"(1 字符),未跟踪类是 "?? path"(2 字符)。按「首个空白之前」切, + // 不要按固定偏移切 —— 固定 slice(3) 会把 "M system/..." 切成 "stem/...", + // 恰好对**修改类**失效,而 agent-creator 改自己 todo.md 正是修改类。 + const p = String(e).replace(/^\S{1,2}\s+/, '').trim().replace(/^"|"$/g, ''); + (AGENT_RUNTIME_STATE_RE.test(p) ? unattributable : escapes).push(e); + } + return { escapes, unattributable }; +} + function applyBoundaryGuard(ctx, before, outcome) { if (before === null || before === undefined) return outcome; const after = hostRepoStatus(); if (after === null) return outcome; - const escapes = diffRepoStatus(before, after); + const { escapes, unattributable } = splitUnattributable(diffRepoStatus(before, after)); + + if (unattributable.length) { + timelineNote(ctx, 'node.boundary', + `本次调用窗口内有 ${unattributable.length} 处 agent 运行态文件改动,无法归属到本节点,不计入越界`, { + node_run_id: ctx.nodeRunId, attempt: ctx.attempt, violation: null, + unattributable: unattributable.slice(0, 20), + note: '多半是同期招聘起的 agent 写的(如 agent-creator 改自己的 todo.md);' + + '根治见 27-P3B W13:把这些运行态文件从 git 移出去', + }); + } if (escapes.length === 0) return outcome; const shown = escapes.slice(0, 20); @@ -3016,6 +3113,8 @@ module.exports = { // 导出只是让单测能直接验这两条判据,不是新开一圈公开面。 ensureProjectRoot, diffRepoStatus, + // 归属判定:agent 运行态文件不算越界(真跑冒烟的断点就在这,见函数上方注释) + splitUnattributable, // 选边 / 熔断 / 透传 selectFailEdge, diff --git a/server/tests/unit/graph-boundary.test.js b/server/tests/unit/graph-boundary.test.js index 61ab8bb..415832b 100644 --- a/server/tests/unit/graph-boundary.test.js +++ b/server/tests/unit/graph-boundary.test.js @@ -111,6 +111,45 @@ describe('graph-engine 项目根与边界守卫', () => { expect(tasks[0].projectRoot).toBe(tasks[1].projectRoot); }); + /** + * 真 CLI 冒烟的断点:招聘一个模板库里没有的角色会走 hired_from_base, + * 自动流水线起 agent-creator,而它的 CLI workDir 就是 system/agents/agent-creator/。 + * 它在里面重写自己的 todo.md、删一条 scheduled_tasks —— 这些改动被整仓 git diff 扫到, + * 归属算到了当时正在跑的那个 work 节点头上,一个**真正成功**的节点被判 fail。 + * 只要这些运行态文件还被 git 跟踪,每次含 hired_from_base 的招聘都会稳定复现。 + */ + it('W3:agent 运行态文件不算越界(真跑冒烟的断点)', () => { + const { escapes, unattributable } = engine.splitUnattributable([ + 'M system/agents/agent-creator/todo.md', + 'M system/agents/agent-creator/.claude/scheduled_tasks.json', + '?? server/routes/echo.js', + 'M server/index.js', + ]); + // 真越界的两条要留下判罚 + expect(escapes).toEqual(['?? server/routes/echo.js', 'M server/index.js']); + // 运行态那两条摘出去,只记录不判罚 + expect(unattributable).toEqual([ + 'M system/agents/agent-creator/todo.md', + 'M system/agents/agent-creator/.claude/scheduled_tasks.json', + ]); + }); + + it('W3:路径提取按空白切,不按固定偏移(修改类只有 1 个状态字符)', () => { + // 固定 slice(3) 会把 "M system/..." 切成 "stem/...",恰好对**修改类**失效 —— + // 而 agent-creator 改自己 todo.md 正是修改类,这条锁的就是那个回归 + const { unattributable } = engine.splitUnattributable(['M system/agents/x/plan.md']); + expect(unattributable).toEqual(['M system/agents/x/plan.md']); + }); + + it('W3:agent 目录下的源码仍然算越界(摘除范围不能过宽)', () => { + const { escapes, unattributable } = engine.splitUnattributable([ + 'M system/agents/agent-creator/agent.md', + 'M system/agents/agent-creator/config.json', + ]); + expect(escapes).toHaveLength(2); + expect(unattributable).toEqual([]); + }); + it('W3:diffRepoStatus 只报本次调用期间新出现的仓库改动', () => { const before = ' M server/services/graph-engine.js\n?? scratch/a.txt\n'; const after = ' M server/services/graph-engine.js\n?? scratch/a.txt\n?? server/routes/echo.js\n M server/index.js\n'; diff --git a/server/tests/unit/graph-engine-revive-parent.test.js b/server/tests/unit/graph-engine-revive-parent.test.js new file mode 100644 index 0000000..774a2e9 --- /dev/null +++ b/server/tests/unit/graph-engine-revive-parent.test.js @@ -0,0 +1,217 @@ +/** + * Unit Tests — 子 run 转终态回写父节点(27-P3B 界面走查发现的永久卡死) + * + * 界面走查(Claude in Chrome 真点)实测的复现路径: + * org 节点的子 work run 第一次失败 → 父 org 节点判 fail → 入口节点没有 fail 出边 → blocked + * → 用户在中栏把子 run 修好、跑到 completed + * → **父节点还钉在 blocked,root run 也钉在 blocked,org 层永远推不到下一个节点** + * + * 根因:propagateToParent 的幂等守卫写的是 + * if (parentNode.status !== 'running') return; // 「已经结算过 → 幂等返回」 + * 判据用错了维度 —— 它想防「同一个结果被结算两次」,实际拦掉的是「子 run 换了个结果」。 + * subgraph 节点的状态本来就是从子 run 派生的,子 run 换了终态就该重新派生。 + * + * 当时唯一的人工出路是对 org 节点点「重试」,而那会把整张子图重置到下一个 attempt, + * 刚跑完的成果全丢 —— 所以这条必须进 CI,不能只靠人点界面才发现。 + * + * 隔离手法同 graph-engine-node-error.test.js:先设 GRAPH_DB_PATH / GRAPH_RUNS_DIR 再 require, + * 仓库里的 data/graph.sqlite 与 data/companies/ 全程不被碰到;executor 全是注入的假货。 + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +describe('子 run 转终态回写父节点', () => { + let wdb; + let engine; + let sse; + let runsDir; + const TEST_DB_PATH = '/tmp/test-graph-engine-revive-parent.sqlite'; + + beforeAll(() => { + for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) { + try { fs.unlinkSync(f); } catch {} + } + process.env.GRAPH_DB_PATH = TEST_DB_PATH; + runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-revive-runs-')); + process.env.GRAPH_RUNS_DIR = runsDir; + for (const m of [ + '../../services/graph-db', + '../../services/graph-service', + '../../services/org-graph-service', + '../../services/work-graph-db', + '../../services/graph-run-sse', + '../../services/graph-engine', + ]) { + delete require.cache[require.resolve(m)]; + } + wdb = require('../../services/work-graph-db'); + sse = require('../../services/graph-run-sse'); + engine = require('../../services/graph-engine'); + }); + + afterAll(() => { + for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) { + try { fs.unlinkSync(f); } catch {} + } + delete process.env.GRAPH_DB_PATH; + try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {} + delete process.env.GRAPH_RUNS_DIR; + }); + + beforeEach(() => { + const db = wdb.getDb(); + db.pragma('foreign_keys = OFF'); + db.exec(` + DELETE FROM graph_run_loop_records; + DELETE FROM graph_run_nodes; + DELETE FROM graph_runs; + DELETE FROM work_graph_edges; + DELETE FROM work_graph_nodes; + DELETE FROM work_graphs; + DELETE FROM system_agents; + `); + db.pragma('foreign_keys = ON'); + engine.setAgentExecutor(null); + engine.setPlanWorkGraph(null); + }); + + let seq = 0; + const nextCompany = () => `revive-co-${++seq}`; + + function ensureAgents(ids) { + const db = wdb.getDb(); + const now = new Date().toISOString(); + const ins = db.prepare(` + INSERT OR IGNORE INTO system_agents + (id, name, scope, owner_company_id, source_kind, legacy_path, status, created_at, updated_at) + VALUES (?, ?, 'public', NULL, 'global_agent', ?, 'active', ?, ?) + `); + for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, now, now); + } + + const nodeByName = (runId, name) => { + const st = wdb.getRunState(runId); + return (st.nodes || []).find((n) => n.name === name); + }; + + /** + * 造出走查里那个局面:父 run 一个 subgraph 节点 + 一张会失败的子图。 + * 父图的入口节点故意**没有 fail 出边** —— 走查里就是这么走到 blocked 的。 + */ + function buildParentWithFailingChild() { + const companyId = nextCompany(); + ensureAgents(['org-planner', 'wg-only']); + + const childG = wdb.createWorkGraph({ companyId, nodes: ['wg-only'], edges: [] }); + expect(childG.errors || []).toEqual([]); + + const parentG = wdb.createWorkGraph({ + companyId, + nodes: [{ nodeName: 'org-planner', systemAgentId: 'org-planner', nodeKind: 'subgraph' }], + edges: [], + }); + expect(parentG.errors || []).toEqual([]); + + const parentRun = wdb.createRun({ + graphKind: 'work', graphId: parentG.id, companyId, executionMode: 'manual', + }); + engine.markRunReady(parentRun.id); + engine.startRun(parentRun.id, { mode: 'manual' }); // advanceOne 要求 run_status='running' + + // 子图规划注入:第一次执行该 subgraph 节点时把上面那张 childG 挂上去 + engine.setPlanWorkGraph(() => ({ workGraphId: childG.id })); + // 子图里那个节点固定失败,且不给 fail_reason(走查里就是 fail_reason=null) + engine.setAgentExecutor(async () => ({ status: 'fail', failReason: null, output: '子节点失败' })); + + return { companyId, parentRunId: parentRun.id }; + } + + /** 把父 run 推到「子 run 失败、父节点 blocked」的状态 */ + async function driveToBlocked(parentRunId) { + for (let i = 0; i < 12; i += 1) { + const r = await engine.advanceTree(parentRunId); + if (!r || r.ok === false) break; + } + const parent = nodeByName(parentRunId, 'org-planner'); + return parent; + } + + it('复现前提:子 run 失败会把父 subgraph 节点推到 blocked', async () => { + const { parentRunId } = buildParentWithFailingChild(); + const parent = await driveToBlocked(parentRunId); + + expect(parent.status).toBe('blocked'); + expect(parent.childRunId).toBeTruthy(); + expect(wdb.getRunState(parentRunId).run.run_status).toBe('blocked'); + }); + + it('子 run 后来跑到 completed 时,父节点从 blocked 被重新派生为 success(不再永久卡死)', async () => { + const { parentRunId } = buildParentWithFailingChild(); + const blocked = await driveToBlocked(parentRunId); + const childRunId = blocked.childRunId; + expect(blocked.status).toBe('blocked'); + + // 用户在中栏把子 run 修好:换成会成功的执行器,重试子图里那个失败节点并跑完 + engine.setAgentExecutor(async () => ({ status: 'success', output: '修好了' })); + const childNode = wdb.getRunState(childRunId).nodes[0]; + engine.retryNode(childRunId, childNode.id); + for (let i = 0; i < 6; i += 1) { + const r = await engine.advanceOne(childRunId); + if (!r || r.ok === false) break; + } + expect(wdb.getRunState(childRunId).run.run_status).toBe('completed'); + + // 关键断言:父节点不再钉在 blocked + const parent = nodeByName(parentRunId, 'org-planner'); + expect(parent.status).toBe('success'); + expect(wdb.getRunState(parentRunId).run.run_status).toBe('completed'); + }); + + it('复活走的是 blocked → ready → running → success 三步合法转换,timeline 上留得下痕迹', async () => { + const { parentRunId } = buildParentWithFailingChild(); + const blocked = await driveToBlocked(parentRunId); + const childRunId = blocked.childRunId; + + engine.setAgentExecutor(async () => ({ status: 'success', output: '修好了' })); + const childNode = wdb.getRunState(childRunId).nodes[0]; + engine.retryNode(childRunId, childNode.id); + for (let i = 0; i < 6; i += 1) { + const r = await engine.advanceOne(childRunId); + if (!r || r.ok === false) break; + } + + const lines = fs.readFileSync(sse.timelinePath(parentRunId), 'utf8') + .split('\n').filter(Boolean).map((l) => JSON.parse(l)); + const revived = lines.filter((l) => l.payload && l.payload.revived_from_child === childRunId); + const hop = (l) => `${l.payload.from}→${l.payload.to}`; + + // 节点轨:blocked → ready → running 两跳都要在,否则说明是靠放宽转换表硬转过去的 + expect(revived.filter((l) => l.kind === 'node.status').map(hop)) + .toEqual(['blocked→ready', 'ready→running']); + // run 轨:父 run 也得被拉回 running,否则节点复活了 run 还钉在 blocked + //(RUN_TRANSITIONS 没有 blocked → completed,settleRun 在转换非法时是静默跳过的) + expect(revived.filter((l) => l.kind === 'run.status').map(hop)).toEqual(['blocked→running']); + }); + + it('子 run 再失败一轮时父节点不动(只认转好,不追加惩罚)', async () => { + // propagateToParent 不导出(本模块的约定:不为测试开公开面), + // 所以这里用真入口触发第二次回写:重试子节点、让它再失败一次, + // 子 run 重新收敛到 blocked 的路上引擎自己会调 propagateToParent。 + const { parentRunId } = buildParentWithFailingChild(); + const blocked = await driveToBlocked(parentRunId); + const childRunId = blocked.childRunId; + + const childNode = wdb.getRunState(childRunId).nodes[0]; + engine.retryNode(childRunId, childNode.id); + for (let i = 0; i < 6; i += 1) { + const r = await engine.advanceOne(childRunId); + if (!r || r.ok === false) break; + } + expect(wdb.getRunState(childRunId).run.run_status).toBe('blocked'); + + const parent = nodeByName(parentRunId, 'org-planner'); + expect(parent.status).toBe('blocked'); + }); +});