Commit c162484a46d1e72c9d01d77aa966e16194ef8b5d
1 parent
8b1e7fb6
fix(graph-engine): produces 闸门堵住「只写阶段性笔记就骗过 hasCandidates」的后门
T-mtratkfb(源自 T-mtraluo2 系统性缺陷评估的缺口一):unsatisfiedRequiredProduces 在 contract.json 缺失时退化成「私有目录里随便有个非元数据文件就算数」(hasCandidates), 而 _base/agent.md 四段式生命周期(RESEARCH→PLAN→EXECUTE→VERIFY,全仓库几十个角色共用) 第一阶段就会落 `_research.md`,比真正的交付物早得多——org-coder 只做到 RESEARCH 就能 用这份笔记骗过闸门被判 success(复现:run_fe5a50b4e836,项目根空目录、节点私有目录 只有 _research.md,却被判 success)。 修复:candidates 过滤掉 `_` 前缀文件(全仓库通用的阶段性私有笔记命名约定,真交付物 从不用这个前缀,不会误杀合法产出)。 回归测试:graph-files-index.test.js 新增④c,按 run_fe5a50b4e836 的真实文件状态复现 (只写 _research.md),先反向验证过——去掉过滤条件时该用例确实 FAIL(status 变 success),加回后 PASS(status 变 blocked / produces_missing)。 缺口二(impl↔validate 图缺 fail/backward 边)已在 8b1e7fb6 一并修复 (createDecomposedWorkGraph 补了 validate --fail--> impl 退回边,task-decomposition- phase4.test.js 三条新测试覆盖边形状/真实退回重试/硬顶熔断),本次未重复改动,仅确认 其单测仍全绿。 全量单测 1636/1638 通过(2 个失败 env-example-secrets / test-id-stability 与本次 改动无关,revert 后依旧失败,确认是改动前既有缺陷)。 复现样本 run_fe5a50b4e836 的图定义创建于两处修复落地之前(13:39 vs 8b1e7fb6 的 17:09),结构上永久缺 backward 边,无法靠新代码自愈;建议由 COO 走正常回收流程 (取消该 blocked 子任务,让父任务 T-mtradxxu-b112 重新派发一次子任务,新图会走 已修复的 createDecomposedWorkGraph)而不是热改这一条历史数据。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
2 changed files
with
36 additions
and
1 deletions
server/services/graph-engine.js
| ... | ... | @@ -4718,6 +4718,14 @@ function readDeclaredProduces(run, dir) { |
| 4718 | 4718 | * 不看下面「私有目录里有没有任何候选文件」这条粗粒度兜底的脸色——声明校验不过就是不满足, |
| 4719 | 4719 | * 哪怕私有目录里凑巧有别的文件(那不该被当成这个 kind 的替代品)。 |
| 4720 | 4720 | * |
| 4721 | + * T-mtratkfb 补丁:`_` 前缀文件(`_research.md` / `_plan.md` / `_lessons.md` ……)是 | |
| 4722 | + * `_base/agent.md` 四段式生命周期(RESEARCH→PLAN→EXECUTE→VERIFY)沿途留的阶段性私有笔记, | |
| 4723 | + * 全仓库几十个角色都在用这个前缀(约定见各 agent.md,不是 org-coder 独有)。这类文件在 | |
| 4724 | + * RESEARCH 阶段就会出现,比真正的交付物早得多——`hasCandidates` 兜底如果把它们也算作 | |
| 4725 | + * 「候选文件」,节点只做到 RESEARCH 就能骗过这道闸门被判 success(复现见 run_fe5a50b4e836: | |
| 4726 | + * org-coder 只写了 `_research.md` 就报 success,项目根仍是空目录)。真交付物 | |
| 4727 | + * (`coder.log`、代码文件……)从不用这个前缀,排除它们不会误杀合法产出。 | |
| 4728 | + * | |
| 4721 | 4729 | * @returns {string[]} 未满足的 kind 列表;全部满足 = 放行([]) |
| 4722 | 4730 | */ |
| 4723 | 4731 | function unsatisfiedRequiredProduces(run, def, nodeRun) { |
| ... | ... | @@ -4749,7 +4757,7 @@ function unsatisfiedRequiredProduces(run, def, nodeRun) { |
| 4749 | 4757 | return []; // 目录读不了不该拖垮结算 |
| 4750 | 4758 | } |
| 4751 | 4759 | const candidates = files.filter( |
| 4752 | - (f) => !/^attempt-\d+\.json$/.test(f) && f !== CONTRACT_SNAPSHOT_FILE | |
| 4760 | + (f) => !/^attempt-\d+\.json$/.test(f) && f !== CONTRACT_SNAPSHOT_FILE && !f.startsWith('_') | |
| 4753 | 4761 | ); |
| 4754 | 4762 | const hasCandidates = candidates.length > 0; |
| 4755 | 4763 | ... | ... |
server/tests/unit/graph-files-index.test.js
| ... | ... | @@ -325,6 +325,33 @@ describe('R-19 产物索引:graph_files / graph_edge_contracts', () => { |
| 325 | 325 | expect(impl.failReason).toBe('missing_required_input'); |
| 326 | 326 | }); |
| 327 | 327 | |
| 328 | + it('④c T-mtratkfb:只落了阶段性私有笔记(_research.md)没有真交付物 → 仍判 produces_missing,' | |
| 329 | + + '不被 hasCandidates 兜底放过', async () => { | |
| 330 | + // 复现 run_fe5a50b4e836:org-coder 四段式生命周期第一阶段写完 _research.md 就直接 | |
| 331 | + // 上报 success,产物目录里除了这份阶段性笔记什么都没有——闸门如果把 _research.md | |
| 332 | + // 也算进候选文件,这个节点会被误判「已产出」放行,下游拿到的其实是空交付。 | |
| 333 | + const companyId = 'co-files-private-note'; | |
| 334 | + const taskId = 'task-private-note'; | |
| 335 | + const b = buildRun({ | |
| 336 | + companyId, taskId, | |
| 337 | + nodes: [{ nodeName: 'coder' }, { nodeName: 'validate' }], | |
| 338 | + edges: [{ from: 'coder', to: 'validate', condition: 'success' }], | |
| 339 | + }); | |
| 340 | + wdb.declareEdgeContract({ | |
| 341 | + edgeLayer: 'work', edgeId: b.edgeId('coder', 'validate'), kind: 'artifact', required: true, | |
| 342 | + }); | |
| 343 | + | |
| 344 | + const tasks = await advance(b.runId, 2, { | |
| 345 | + writes: { coder: { '_research.md': '结论:可以直接进入 PLAN,无阻塞项' } }, | |
| 346 | + }); | |
| 347 | + | |
| 348 | + const state = wdb.getRunState(b.runId); | |
| 349 | + const coder = state.nodes.find((n) => n.name === 'coder'); | |
| 350 | + expect(coder.status).toBe('blocked'); | |
| 351 | + expect(coder.failReason).toBe('produces_missing'); | |
| 352 | + expect(tasks.has('validate')).toBe(false); | |
| 353 | + }); | |
| 354 | + | |
| 328 | 355 | // ── ⑤ 节点 fail 不登记 ────────────────────────────────────── |
| 329 | 356 | |
| 330 | 357 | it('⑤ 节点 fail 时落盘的半成品不进索引(下游不该把它当输入读)', async () => { | ... | ... |