diff --git a/server/services/graph-engine.js b/server/services/graph-engine.js index 14ca49b..40b0369 100644 --- a/server/services/graph-engine.js +++ b/server/services/graph-engine.js @@ -4718,6 +4718,14 @@ function readDeclaredProduces(run, dir) { * 不看下面「私有目录里有没有任何候选文件」这条粗粒度兜底的脸色——声明校验不过就是不满足, * 哪怕私有目录里凑巧有别的文件(那不该被当成这个 kind 的替代品)。 * + * T-mtratkfb 补丁:`_` 前缀文件(`_research.md` / `_plan.md` / `_lessons.md` ……)是 + * `_base/agent.md` 四段式生命周期(RESEARCH→PLAN→EXECUTE→VERIFY)沿途留的阶段性私有笔记, + * 全仓库几十个角色都在用这个前缀(约定见各 agent.md,不是 org-coder 独有)。这类文件在 + * RESEARCH 阶段就会出现,比真正的交付物早得多——`hasCandidates` 兜底如果把它们也算作 + * 「候选文件」,节点只做到 RESEARCH 就能骗过这道闸门被判 success(复现见 run_fe5a50b4e836: + * org-coder 只写了 `_research.md` 就报 success,项目根仍是空目录)。真交付物 + * (`coder.log`、代码文件……)从不用这个前缀,排除它们不会误杀合法产出。 + * * @returns {string[]} 未满足的 kind 列表;全部满足 = 放行([]) */ function unsatisfiedRequiredProduces(run, def, nodeRun) { @@ -4749,7 +4757,7 @@ function unsatisfiedRequiredProduces(run, def, nodeRun) { return []; // 目录读不了不该拖垮结算 } const candidates = files.filter( - (f) => !/^attempt-\d+\.json$/.test(f) && f !== CONTRACT_SNAPSHOT_FILE + (f) => !/^attempt-\d+\.json$/.test(f) && f !== CONTRACT_SNAPSHOT_FILE && !f.startsWith('_') ); const hasCandidates = candidates.length > 0; diff --git a/server/tests/unit/graph-files-index.test.js b/server/tests/unit/graph-files-index.test.js index d3494e2..7a1bc77 100644 --- a/server/tests/unit/graph-files-index.test.js +++ b/server/tests/unit/graph-files-index.test.js @@ -325,6 +325,33 @@ describe('R-19 产物索引:graph_files / graph_edge_contracts', () => { expect(impl.failReason).toBe('missing_required_input'); }); + it('④c T-mtratkfb:只落了阶段性私有笔记(_research.md)没有真交付物 → 仍判 produces_missing,' + + '不被 hasCandidates 兜底放过', async () => { + // 复现 run_fe5a50b4e836:org-coder 四段式生命周期第一阶段写完 _research.md 就直接 + // 上报 success,产物目录里除了这份阶段性笔记什么都没有——闸门如果把 _research.md + // 也算进候选文件,这个节点会被误判「已产出」放行,下游拿到的其实是空交付。 + const companyId = 'co-files-private-note'; + const taskId = 'task-private-note'; + const b = buildRun({ + companyId, taskId, + nodes: [{ nodeName: 'coder' }, { nodeName: 'validate' }], + edges: [{ from: 'coder', to: 'validate', condition: 'success' }], + }); + wdb.declareEdgeContract({ + edgeLayer: 'work', edgeId: b.edgeId('coder', 'validate'), kind: 'artifact', required: true, + }); + + const tasks = await advance(b.runId, 2, { + writes: { coder: { '_research.md': '结论:可以直接进入 PLAN,无阻塞项' } }, + }); + + const state = wdb.getRunState(b.runId); + const coder = state.nodes.find((n) => n.name === 'coder'); + expect(coder.status).toBe('blocked'); + expect(coder.failReason).toBe('produces_missing'); + expect(tasks.has('validate')).toBe(false); + }); + // ── ⑤ 节点 fail 不登记 ────────────────────────────────────── it('⑤ 节点 fail 时落盘的半成品不进索引(下游不该把它当输入读)', async () => {