Commit c162484a46d1e72c9d01d77aa966e16194ef8b5d

Authored by yibo
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>
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(&#39;R-19 产物索引:graph_files / graph_edge_contracts&#39;, () =&gt; {
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 () => {
... ...