Commit 8ef7b1239b88f556ed80196e2df65c9cd021fa79

Authored by yibo
1 parent b92f9973

feat(p3b): 真跑前置第一批 W1-W4;真 CLI 冒烟按门槛未执行

测试 736/736 全绿(68 文件,起点 719/66,净增 17 条,未改任何既有断言)。

W1 · seed 接进启动流程 + 悬空行回收
- server/index.js 与 agent-server.js 均在 listen() 前调 runStartupSeedSafely()
  (幂等、失败降级记日志、不挡服务启动)
- seedTemplates() 此前**没有任何运行时调用方**,只有测试与手动脚本引用,
  于是 system/templates/ 新增本体永远进不了 system_agents,而
  work_graph_nodes.system_agent_id 是指向该表的外键
- 占位 id 回收:legacy_path 已失效的 company_hired 行改为可被 template 就地转正,
  保留行 id 以保外键。此前 seedTemplates 遇占用即 skip,导致污染不可逆:
  lookupSharedAgentMd 恒 null → hire 静默退回全量拷贝 → 模板改动对已雇公司不生效
- 生产库实查:company_hired 0 行(原 10 条悬空全清)、template 45、
  verifyCounts().ok=true、foreign_key_check 空

W2 · 项目根分配(四环闭合)
- ensureProjectRoot 复用 path-resolver.allocateProjectRoot(sandbox 模式)
- 分配 → 进 agent_call 与 planning 两处提示词 + callAgent metadata
  → 落 artifacts/project-root.json(wx 独占)→ readProjectRootRecord 读回
- 子 run 经 createRun 继承 root_run_id,跨层共享同一项目根
- 核实 data/companies/ 在 .gitignore 内,故项目根内的合法写入不会被 W3 守卫误报

W3 · 产出边界守卫(部分,文档如实标注)
- 真 CLI 是子进程直接写盘,引擎拦不住,守卫只能拒收结果、不能回滚
- 7 条「拦不住」的情形逐条列明;文档自述「在 cwd 隔离做掉之前,
  真跑仍应在可丢弃的工作副本里做」

W4 · 修验收 fail-open(性质严重)
- benchmark-validator / testdata-validator 的 Step 表教 agent 写 verdict:"fail",
  而引擎 FAILING_VERDICTS={attributed,inconclusive} 不含它 → 契约非法的产物
  被判 success 放行。失效方向是放行不是拦截
- org-validator 的审计字段 verdict → audit_verdict:它与 work 层判词写同一路径
  (nodes/<nodeRunId>/attempt-<n>.json),此前只靠「该节点恰好是 subgraph」
  才没被 readNodeProduct 当判词读,属隐患藏在巧合里
- 新增防线 template-verdicts.test.js,从 graph-engine.js 源码取
  FAILING_VERDICTS / ATTRIBUTION_REASONS 扫全部模板;经三次变异验证有效
  (改回 "fail" / 改回 verdict / 造 "ok" 均变红并报精确行号)

真 CLI 冒烟未执行,两条理由(终验 agent 自行判断,符合任务给的门槛):
① W3 按其自身文档明确「不是沙箱」且要求真跑在可丢弃副本里做,而当前是主仓库、
   持有未提交交付物、且有并行 agent 在写,W3 自述的误判项必然触发;
② 断点已零成本静态证明:getAgentConfig 解析裸名只查 system/agents/、
   从不查 system/templates/,resolveAgentName 不做按需 hire,实测 prd-planning /
   sdd-planning / benchmark-validator / testdata-validator / validator-triager /
   org-validator 全部 throw「Agent not found」。花 $3-8 复现已知断点无意义。
故「做完这四件就能真跑」不成立,真跑挪到 W5/W6 之后。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs/todo/system-design/27-p3b-real-run-runway.md
@@ -61,13 +61,199 @@ P3 主体七轮åšä¸‹æ¥ï¼Œ**引擎编排层是扎实的**ï¼ˆçŠ¶æ€æœºã€åŒå±‚ç @@ -61,13 +61,199 @@ P3 主体七轮åšä¸‹æ¥ï¼Œ**引擎编排层是扎实的**ï¼ˆçŠ¶æ€æœºã€åŒå±‚ç
61 61
62 | # | 内容 | 文件 | é‡ | 62 | # | 内容 | 文件 | é‡ |
63 |---|---|---|---| 63 |---|---|---|---|
64 -| **W1** | 清 10 æ¡æ‚¬ç©º `company_hired` 行;把 `seedTemplates()` 接进æœåŠ¡å¯åЍæµç¨‹ï¼ˆæˆ–让 `hire` 顺手 upsert `system_agents`)。**这是 W5/W6 çš„å‰ç½®**——ä¸ä¿®ï¼Œagent 雇完人æäº¤å›¾ç…§æ ·å¤–键挂 | `server/services/graph-seed-service.js`ã€`server/index.js` | å° |  
65 -| **W2** | æ¯ä¸ª run 分é…项目根,写进节点æç¤ºè¯ã€‚**å¤ç”¨** `path-resolver.allocateProjectRoot`(sandbox 模å¼ï¼‰ï¼Œä¸æ–°é€  | `server/services/graph-engine.js` | å° |  
66 -| **W3** | 产出目录边界守å«ï¼šèŠ‚ç‚¹å†™å…¥é™åˆ¶åœ¨è‡ªå·±çš„产物目录 + 项目根内,越界拒ç»å¹¶ç•™ç—•。å¯å¤ç”¨ `isSensitivePath` | `server/services/graph-engine.js` | å° |  
67 -| **W4** | **ä¿® fail-open 的验收 bug**:`benchmark-validator` / `testdata-validator` çš„ Step 表与引擎 `FAILING_VERDICTS` 对é½ã€‚`planner-validator/agent.md` 已有一段红字警告这一点,å¦ä¸¤ä»½ Step è¡¨æ°æ°åœ¨æ•™ agent 犯它 | `system/templates/{benchmark,testdata}-validator/agent.md` | å°æ”¹åŠ¨ï¼Œ**性质严é‡** | 64 +| **W1** ✅ | 清 10 æ¡æ‚¬ç©º `company_hired` 行;把 `seedTemplates()` 接进æœåŠ¡å¯åЍæµç¨‹ï¼ˆæˆ–让 `hire` 顺手 upsert `system_agents`)。**这是 W5/W6 çš„å‰ç½®**——ä¸ä¿®ï¼Œagent 雇完人æäº¤å›¾ç…§æ ·å¤–键挂 | `server/services/graph-seed-service.js`ã€`server/index.js` | å° |
  65 +| **W2** ✅ | æ¯ä¸ª root run 分é…一次项目根(å¤ç”¨ `path-resolver.allocateProjectRoot` sandbox 模å¼ï¼‰ï¼Œè½åœ¨ `runs/{rootRunId}/artifacts/project-root.json`(**未加 schema 列**),进 agent_call 与 planning ä¸¤ç§æç¤ºè¯ + `callAgent` metadata | `server/services/graph-engine.js`ã€`server/tests/unit/graph-boundary.test.js` | å° |
  66 +| **W3** âš ï¸ éƒ¨åˆ† | 边界守å«è½åœ°ä¸ºã€Œæç¤ºè¯å£°æ˜Ž + 事åŽå–è¯ + 结算拒收ã€ï¼Œ**䏿˜¯æ²™ç®±**:能拦ä½å®¿ä¸»ä»“库内å¯è§çš„越界写(判 fail + timeline 留痕),拦ä¸ä½ä»“库外与被 gitignore 的写。详è§ä¸‹æ–¹ã€ŒW3 能力边界〠| `server/services/graph-engine.js`ã€`server/tests/unit/graph-boundary.test.js` | å° |
  67 +| **W4** ✅ | **ä¿® fail-open 的验收 bug**:`benchmark-validator` / `testdata-validator` çš„ Step 表与引擎 `FAILING_VERDICTS` 对é½ã€‚`planner-validator/agent.md` 已有一段红字警告这一点,å¦ä¸¤ä»½ Step è¡¨æ°æ°åœ¨æ•™ agent 犯它 | `system/templates/{benchmark,testdata,org}-validator/agent.md`ã€`server/tests/unit/template-verdicts.test.js` | å°æ”¹åŠ¨ï¼Œ**性质严é‡** |
68 68
69 > W4 çš„å¤±æ•ˆæ–¹å‘æ˜¯**放行**䏿˜¯æ‹¦æˆªï¼šå¥‘çº¦éžæ³•çš„äº§ç‰©ä¼šè¢«å½“åˆæ ¼é€šè¿‡ã€‚真跑之å‰ä¸ä¿®ï¼ŒéªŒæ”¶å½¢åŒè™šè®¾ã€‚ 69 > W4 çš„å¤±æ•ˆæ–¹å‘æ˜¯**放行**䏿˜¯æ‹¦æˆªï¼šå¥‘çº¦éžæ³•çš„äº§ç‰©ä¼šè¢«å½“åˆæ ¼é€šè¿‡ã€‚真跑之å‰ä¸ä¿®ï¼ŒéªŒæ”¶å½¢åŒè™šè®¾ã€‚
70 70
  71 +#### W1 è½åœ°è®°å½•(已完æˆï¼‰
  72 +
  73 +§三 第 1/3/4/5 æ¡å·²ä¿®å¤ï¼ŒçŽ°çŠ¶æ ¸å®žè¡¨é‡Œé‚£æ¡å¤±æ•ˆé“¾æ–­æŽ‰äº†ï¼š
  74 +
  75 +- **å¯åŠ¨æŽ¥çº¿**:`graph-seed-service.runStartupSeedSafely()` ç”± `server/index.js` çš„ `start()`(`listen()` 之å‰ï¼‰ä¸Ž `server/agent-server.js`(åŒä¸Šï¼‰è°ƒç”¨ã€‚跑的是 `reconcileDanglingRows()` → `seedGlobalAgents()` → `seedTemplates()`。**ä¸è·‘ `seedCompanyAgents()`**ï¼šé‚£ä¸€æ­¥çš„æ•°æ®æºæ˜¯è¿è¡Œæ—¶çŠ¶æ€ `company.json`,归 `hire()/fire()` 管,å¯åŠ¨æ—¶åæŽ¨ `org_graph_nodes` 会跟它们抢所有æƒï¼ˆä¹Ÿæ­£æ˜¯ W7 è¦æ”¶æ•›çš„地方);它继续留在 `scripts/graph-seed.js` æ‰‹åŠ¨è·‘ã€‚å…¨åŒæ­¥å¹‚ç­‰ï¼Œå¤±è´¥åªæ‰“日志é™çº§ï¼Œä¸æŒ¡å¯åŠ¨ã€‚
  76 +- **悬空行回收规则**(三æ¡åŒæ—¶æˆç«‹æ‰å›žæ”¶ï¼Œä»»ä¸€ä¸æˆç«‹å°±ä¿ç•™ï¼‰ï¼š`source_kind='company_hired'` + `legacy_path` 指å‘的目录已ä¸å­˜åœ¨ + 没有任何**æ´»ç€çš„å…¬å¸**在 `company.json.agents[]` 里点å这个 id。附带清掉「ç£ç›˜ä¸Šå·²æ—  `company.json`ã€çš„å…¬å¸é—留的 `org_graph_nodes`/`org_graph_edges`(`data/companies/` æ•´ä¸ªç›®å½•ç¼ºå¤±æ—¶è·³è¿‡è¿™ä¸€æ­¥ï¼Œåˆ†ä¸æ¸…「没有公å¸ã€å’Œã€Œç›˜æ²¡æŒ‚上ã€ï¼‰ã€‚åˆ ä¸æŽ‰ï¼ˆè¢« `work_graph_nodes` 等外键引ç€ï¼‰çš„行进 `retained`ï¼Œä¸æŠ›é”™ã€‚
  77 +- **å ä½ id 转正**:`seedTemplates()` 撞上被å ç”¨çš„ id æ—¶ï¼Œåªæœ‰å½“那行是**悬空的 `company_hired`** æ‰å°±åœ°è½¬æ­£ï¼ˆ`source_kind`/`scope`/`owner_company_id`/`legacy_path` 一并改写,ä¿ç•™è¡Œ id 让外键继续有效);`global_agent` å ä½ç…§æ—§ skip。
  78 +- **实测结果**:生产 `data/graph.sqlite` 上跑完 → `company_hired` 11→0,`template` 37→45,`org_graph_nodes` 清掉 4 家已删公å¸çš„ 7 行,`verifyCounts()` ç”± false 转 trueï¼›`lookupSharedAgentMd('workgraph-planning' / 'prd-planning')` ä»Žæ’ `null` å˜æˆèƒ½è§£æžåˆ° `system/templates/**/agent.md`,`hire()` ä¸å†é™é»˜é€€å›žå…¨é‡æ‹·è´ã€‚
  79 +- **测试**:`server/tests/unit/graph-seed-service.test.js` å·² hermetic 化(新增 `GRAPH_SEED_ROOT` 测试ç¼ï¼Œå¤¹å…·æ ¹å»ºåœ¨ `/tmp`,行数改为写死的精确值),2 æ¡ â†’ 13 æ¡ã€‚
  80 +
  81 +#### W4 è½åœ°è®°å½•(已完æˆï¼‰
  82 +
  83 +§三 第 9 æ¡å·²ä¿®å¤ã€‚三处改动 + 一é“防线:
  84 +
  85 +1. `benchmark-validator/agent.md` Step 1 表ã€`testdata-validator/agent.md` Step 1 表:
  86 + `verdict: "fail"` → `verdict: "attributed"`,å„补一段红字说明「写 `"fail"` å¼•æ“Žè¯»æˆæ²¡å¤±è´¥ã€ã€‚
  87 +2. `org-validator/agent.md` 的审计字段 `verdict: pass|fail` → **`audit_verdict`**。
  88 + 它本æ¥å°±æ˜¯å®¡è®¡äº§ç‰©ï¼ˆorg 节点是 `subgraph`,æˆè´¥ç”± `deriveChildOutcome` ä»Žå­ run 推出),
  89 + 但它写的文件路径与 work 层判è¯**完全相åŒ**(`nodes/<nodeRunId>/attempt-<n>.json`),
  90 + åªé ã€Œæœ¬èŠ‚ç‚¹æ°å¥½æ˜¯ subgraphã€æ‰æ²¡è¢« `readNodeProduct` 当判è¯è¯»ã€‚改ååŽä¸¤å¥—è¯å½»åº•分开;
  91 + å³ä¾¿å“ªå¤©çœŸè¢«å½“判è¯è¯»ï¼Œå¼•擎也会退到「有 `fail_reason` 就算失败ã€çš„缺çœè§„则上,ä»ç„¶åˆ¤å¯¹ã€‚
  92 +3. 防线 `server/tests/unit/template-verdicts.test.js`(对标 `template-refs.test.js`):
  93 + 从 `graph-engine.js` **æºç æ–‡æœ¬**é‡Œå– `FAILING_VERDICTS` / `ATTRIBUTION_REASONS`
  94 + (两者都没 export,import 整个模å—会把 graph db 拉起æ¥ï¼›å–ä¸åˆ°å°±æŠ›ï¼Œä¸é™é»˜ç©ºè·‘),
  95 + 扫全部 `system/templates/**/*.md` 的赋值形æ€ï¼ˆ`verdict: "X"`)与判è¯è¡¨æ•´æ ¼å–值,
  96 + å†åŠ æ‰« `system/{work,org}-graph-templates/*.json` 的边 `failReason`(ä¸åœ¨åŸŸå†… = è¿™æ¡è¾¹æ°¸è¿œåŒ¹é…ä¸ä¸Šï¼‰ã€‚
  97 + ä¸¤æ¡æ‰«æè·¯å¾„都åšè¿‡å˜å¼‚验è¯ï¼šæ”¹å›ž `"fail"` / 把表格å–å€¼æ”¹æˆ `"ok"`,都会炸。
  98 +
  99 +> **`FAILING_VERDICTS` è¦ä¸è¦æ”¾å®½æ”¶ `"fail"`:ä¸è¦é‚£æ ·è¡¥ï¼Œè¦æŠŠåˆ¤æ®å过æ¥**
  100 +> (失败è¯ç™½åå• â†’ 通过è¯ç™½åå•,ä¸è®¤è¯†çš„一律判失败)。ç†ç”±ä¸Žæ”¹æ³•è§ Â§å…«ã€‚
  101 +> `graph-engine.js` å½’ W2/W3 那一轮,本轮ä¸åŠ¨å®ƒã€‚
  102 +
  103 +#### W2 è½åœ°è®°å½•(已完æˆï¼‰
  104 +
  105 +§三 第 7/8 æ¡çš„å‰åŠæ®µï¼ˆ**缺产出ä½ç½®**)已修å¤ã€‚
  106 +
  107 +- **分é…**:`graph-engine.advanceOne` 在å‘起调用之å‰è°ƒ `ensureProjectRoot(ctx)`,内部å¤ç”¨
  108 + `devloop/path-resolver.allocateProjectRoot({company_id, mode:'sandbox'})` →
  109 + `data/companies/{cid}/projects/{pid}/`。**æ¯ä¸ª root run 一次**ï¼Œå­ run / åŽç»­èŠ‚ç‚¹è¯»å›žåŒä¸€ä¸ª
  110 + (`runRootDir` 本æ¥å°±æŒ‰ `root_run_id` 拼,天然共享)。`GRAPH_RUNS_DIR` è¦†ç›–æ—¶é¡¹ç›®æ ¹è·Ÿç€æŒªåˆ°
  111 + `{GRAPH_RUNS_DIR}/{rootRunId}/project`,ç†ç”±ä¸Ž run 目录那处 override 完全一样:å¦åˆ™æµ‹è¯•æ¯è·‘
  112 + 一次就往仓库 `data/companies/` 堆一个空项目目录。
  113 +- **存哪:没有改 schema**。记录è½åœ¨ `runs/{rootRunId}/artifacts/project-root.json`
  114 + (`{schema:"graph-run-project-root/v1", pid, root_path, mode, root_run_id, company_id,
  115 + allocated_by, allocated_at}`),与 `artifacts/task-goal.json` åŒç±»ã€åŒç›®å½•,读回路径已ç»é€šã€‚
  116 + `graph_runs` ä¸éœ€è¦åŠ åˆ—â€”â€”è¿™æ¡ä¿¡æ¯æŒ‰ `root_run_id` 唯一,加列å而è¦åœ¨æ¯ä¸€è¡Œä¸Šå¯¹é½ä¸€æ¬¡ã€‚
  117 +- **è¿›æç¤ºè¯**(两ç§èŠ‚ç‚¹éƒ½è¿›ï¼Œå‚ç…§ `describeArtifactContract` çš„åšæ³•):agent_call 节点由
  118 + `describeWriteBoundary` 在任务æè¿°å¤´éƒ¨æ‹¼ä¸€æ®µ `[写入边界 —— 硬约æŸï¼Œè¶Šç•Œä¼šè¢«åˆ¤å¤±è´¥]`ï¼›
  119 + subgraph 节点(起的是 `workgraph-planning`ï¼ŒåŒæ ·æ˜¯ä¸€æ¬¡çœŸ CLI 调用ã€åŒæ ·ä¼šå†™ç›˜ï¼‰ç”±
  120 + `buildPlanningPrompt` 的「## 写入边界(硬约æŸï¼‰ã€æ‹¼åŒä¸€ä»½çº¦æŸã€‚两处都给ç»å¯¹è·¯å¾„,并明说
  121 + 「进程 cwd 䏿˜¯é¡¹ç›®æ ¹ï¼Œåˆ«ç”¨ç›¸å¯¹è·¯å¾„ã€ã€‚`callAgent` çš„ `metadata.projectRoot` 是它的机器å¯è¯»å‰¯æœ¬ã€‚
  122 +- **四环闭åˆ**:分é…(`ensureProjectRoot`)→ 传递(`ctx.projectRoot` → æç¤ºè¯ + metadata)→
  123 + è½åº“(`project-root.json`,`wx` 独å åˆ›å»ºï¼Œå¹¶å‘下先到先得)→ 读回(下一个节点 / å­ run
  124 + `readProjectRootRecord`)。timeline 上有 `node.boundary` 一æ¡ã€Œæœ¬æ¬¡ä»»åŠ¡çš„é¡¹ç›®æ ¹ï¼šâ€¦ï¼ˆsandbox)ã€ã€‚
  125 +- **实测**(mock æ¨¡å¼ + 默认执行器,未起真 CLI):å•节点 run 跑完 →
  126 + `data/companies/{cid}/projects/p-xxxxxx/` 被建出ã€`project-root.json` 内容正确ã€èŠ‚ç‚¹æç¤ºè¯é‡Œ
  127 + 有该ç»å¯¹è·¯å¾„ã€å®¿ä¸»ä»“库 `git status` 无新增。测试 `server/tests/unit/graph-boundary.test.js`。
  128 +
  129 +#### W3 è½åœ°è®°å½•(部分完æˆï¼Œ**䏿˜¯æ²™ç®±**)
  130 +
  131 +真 CLI 是å­è¿›ç¨‹ç›´æŽ¥å†™ç›˜ã€è¿˜å¸¦ `--dangerously-skip-permissions`,**引擎ä¸å¯èƒ½åœ¨å†™å‘生的那一刻
  132 +拦ä½å®ƒ**。所以本轮è½åœ°çš„æ˜¯ã€Œæç¤ºè¯å£°æ˜Žï¼ˆè°ƒç”¨å‰ï¼‰+ 事åŽå–è¯ + 结算拒收ã€ä¸‰ä»¶ï¼Œèƒ½åŠ›è¾¹ç•Œå¦‚ä¸‹ï¼Œ
  133 +ä¸è¦å½“æˆã€Œäº§å‡ºç›®å½•已被隔离ã€ã€‚
  134 +
  135 +**能拦ä½çš„**
  136 +
  137 +1. **宿主仓库内å¯è§çš„越界写**:`advanceOne` 在调用å‰åŽå„跑一次 `git status --porcelain`
  138 + (cwd=仓库根),差集éžç©º = 本次调用在仓库里留下了痕迹 → è®°ä¸€æ¡ `node.boundary`
  139 + (`violation:'boundary_escape'` + 改动清å•,最多 20 æ¡ï¼‰å¹¶**把结果é™çº§æˆ `fail`**
  140 + (`success` 也é™çº§ï¼›`fail_reason` ä¿æŒ `null`,走兜底边——引擎ä¸å‘明 fail_reason)。
  141 + 这正好覆盖已ç»å®žé™…å‘生过的那次事故(写 `server/routes/echo.js`ã€æ”¹ `server/index.js`)。
  142 + executor 抛异常那æ¡è·¯å¾„åŒæ ·æŸ¥ï¼ˆå…ˆå†™åå†å´©ï¼Œç—•迹一样在)。
  143 +2. **æ•æ„Ÿè·¯å¾„**:项目根分é…åŽè¿‡ `path-resolver.isSensitivePath`,命中则拒用并留痕
  144 + (这是唯一一é“**调用å‰å°±å¼ºåˆ¶**的守å«ï¼‰ã€‚
  145 +3. **mock 产物越界**:`mock/files` 事件里逃出节点产物目录的路径照旧被丢弃(原有行为)。
  146 +
  147 +**拦ä¸ä½çš„(明确未解决)**
  148 +
  149 +1. **宿主仓库之外的写**完全看ä¸è§ï¼š`~/Documents/别的项目`ã€`/tmp`ã€`$HOME` 下任何路径。
  150 +2. **被 `.gitignore` 忽略的路径**看ä¸è§ï¼ˆå« `data/companies/`ã€`*.log`)。
  151 +3. **调用å‰å°±å·² modifiedã€è°ƒç”¨ä¸­åˆè¢«æ”¹ä¸€æ¬¡**的文件:porcelain 行ä¸å˜ï¼Œçœ‹ä¸è§ã€‚
  152 +4. 越界的写**å·²ç»å‘生**,守å«åªæ‹’收结果,**ç»ä¸è‡ªåŠ¨å›žæ»š**——回滚等于替开å‘者丢弃未æäº¤çš„
  153 + æ”¹åŠ¨ï¼Œä»£ä»·æ–¹å‘æ¯”误判更贵。清ç†é äººå·¥çœ‹ timeline 里的清å•。
  154 +5. **误判**:开å‘者本人或并行的å¦ä¸€ä¸ª agent 在åŒä¸€æ—¶åˆ»æ”¹ä»“库,会被算到本节点头上。
  155 +6. 守å«åªåœ¨**会真起 CLI 的执行器**下开(默认 executor / é»˜è®¤è§„åˆ’å™¨ï¼‰ï¼›æµ‹è¯•æ³¨å…¥çš„å‡ executor
  156 + 䏿‰«ï¼ˆè¿›ç¨‹å†…函数,扫仓库既无æ„义åˆè¦ç»™æ¯ä¸ªç”¨ä¾‹åŠ ä¸¤æ¬¡ git 调用)。自定义 executor 若自己
  157 + èµ·å­è¿›ç¨‹ï¼Œä¸åœ¨å®ˆå«èŒƒå›´å†…。
  158 +7. `git` è·‘ä¸èµ·æ¥ï¼ˆéž git 仓库 / 超时)时守å«**自动é™çº§ä¸ºä¸æ£€æµ‹**,åªç•™ä¸€æ¡
  159 + `degraded:'boundary_guard_unavailable'`。
  160 +
  161 +**真正的隔离还没有åš**:最强的一招是让 CLI çš„ cwd è½åœ¨é¡¹ç›®æ ¹â€”—但 `agent-manager._runViaProtocol`
  162 +çš„ `effectiveWorkDir = agent.workDir || this.workDir`,动æ€è§£æžå‡ºæ¥çš„ agent 一律用自己的目录,
  163 +`callAgent` 没有é€ä¼  cwd çš„å£å­ï¼›æ”¹å®ƒä¸åœ¨æœ¬è½®èŒƒå›´ã€‚è¦ä¹ˆæ”¹ `agent-manager` 加 cwd é€ä¼ ï¼Œ
  164 +è¦ä¹ˆä¸Šå®¹å™¨ / 文件系统级沙箱。**在这一æ¡åšæŽ‰ä¹‹å‰ï¼ŒçœŸè·‘ä»åº”在å¯ä¸¢å¼ƒçš„工作副本里åšï¼Œä¸è¦åœ¨ä¸»ä»“库上跑。**
  165 +
  166 +> 本轮åªåŠ¨äº† `graph-engine.js` çš„ W2/W3 部分;§八 æåˆ°çš„ `FAILING_VERDICTS` 白åå•å转**没有åš**,
  167 +> é‚£æ¡ä»ç„¶å¾…办。
  168 +
  169 +#### ç¬¬ä¸€æ‰¹ç»ˆéªŒè®°å½•ï¼ˆç‹¬ç«‹å¤æ ¸ï¼Œæœªèµ·çœŸ CLI)
  170 +
  171 +**结论:W1 / W2 / W4 三项到ä½ï¼›W3 按其自述「部分完æˆã€åˆ°ä½ã€‚但第一批åˆèµ·æ¥ä»ç„¶
  172 +ä¸è¶³ä»¥æ”¯æ’‘一次真 CLI 端到端跑 —— 断点ä¸åœ¨ç¬¬ä¸€æ‰¹ï¼Œåœ¨ §三 第 6 æ¡ï¼ˆå½’ W5/W6)。**
  173 +
  174 +| 项 | 夿 ¸æ–¹å¼ | 结果 |
  175 +|---|---|---|
  176 +| 免釿µ‹è¯• | `npm run test:unit` | **736/736 绿,68 文件**(起点 719/66,新增 17 æ¡ï¼Œæ— æ”¹æœŸæœ›ï¼‰ |
  177 +| å‰ç«¯ | `npm run web:build` | 通过 |
  178 +| W1 å¯åŠ¨æŽ¥çº¿ | 读 `server/index.js` `start()` / `agent-server.js` | å‡åœ¨ `listen()` å‰è°ƒ `runStartupSeedSafely()` ✅ |
  179 +| W1 åº“çŠ¶æ€ | 生产 `data/graph.sqlite` 实查 | `company_hired` **0 行**,`template` 45,`global_agent` 8,`verifyCounts().ok=true`,`PRAGMA foreign_key_check` 空 ✅ |
  180 +| W1 新模æ¿è‡ªåŠ¨è¿›è¡¨ | `graph-seed-service.test.js` hermetic 用例 + 生产库é‡è·‘ `seedTemplates()` 幂等 | ✅ |
  181 +| W1 å ä½ id 回收 | 三æ¡åˆ¤æ® + 就地转正(ä¿ç•™è¡Œ id ä¿å¤–键) | ✅ |
  182 +| W2 四环 | è¯»ç  + `graph-boundary.test.js` | 分é…→æç¤ºè¯/metadata→`project-root.json`â†’è¯»å›žï¼Œå››çŽ¯é—­åˆ âœ…ã€‚å­ run ç» `createRun` 继承 `root_run_id`,`runRootDir` åŒç›®å½•,跨层共享åŒä¸€ä¸ªæ ¹ —— 已核实 |
  183 +| W3 å®ˆå« | è¯»ç  + å˜å¼‚ | åˆ¤æ® `diffRepoStatus` 有测试;**é™çº§è·¯å¾„(success→fail)无任何测试**,è§ä¸‹ |
  184 +| W4 æ¨¡æ¿æ”¹åЍ | 读三份 agent.md | ✅ æ—  `verdict: "fail"` 残留 |
  185 +| W4 防线有效性 | **三次å例å˜å¼‚** | 全部å˜çº¢ä¸”报出精确行å·ï¼šâ‘  `benchmark-validator` 改回 `"fail"` → `agent.md:60 → verdict: "fail" ä¸åœ¨å¼•擎å–值域内`;② `org-validator` çš„ `audit_verdict` 改回 `verdict` → `agent.md:183`;③ 造 `verdict: "ok"` → `agent.md:59`。å˜å¼‚åŽå·²é€å­—还原 ✅ |
  186 +
  187 +**W3 文档与代ç ä¸€è‡´æ€§æ ¸æŸ¥ï¼ˆæœ¬è½®é‡ç‚¹ï¼‰**:`§四 W3` 与 `applyBoundaryGuard` 的注释
  188 +**没有把åšä¸åˆ°çš„写æˆå·²è§£å†³** —— çŠ¶æ€æ ‡ `âš ï¸ éƒ¨åˆ†`,7 æ¡ã€Œæ‹¦ä¸ä½ã€é€æ¡åˆ—明,
  189 +并写了「在 cwd éš”ç¦»åšæŽ‰ä¹‹å‰ï¼ŒçœŸè·‘ä»åº”在å¯ä¸¢å¼ƒçš„工作副本里åšã€ã€‚与代ç ä¸€è‡´ã€‚
  190 +两点补充:
  191 +
  192 +1. **「正好覆盖已å‘生过的那次事故ã€è¿™å¥åœ¨å½“å‰å·¥ä½œæ ‘é‡Œåªæˆç«‹ä¸€åŠã€‚** 事故是
  193 + *写 `server/routes/echo.js`(未跟踪)+ 改 `server/index.js`(已跟踪)*。
  194 + 而 `server/index.js` **此刻本æ¥å°±æ˜¯ modified**(W1 的改动),命中「拦ä¸ä½ã€ç¬¬ 3 æ¡
  195 + (调用å‰å·² modifiedã€è°ƒç”¨ä¸­å†æ”¹ä¸€æ¬¡ → porcelain 行ä¸å˜ï¼‰ã€‚所以现状下守å«åªçœ‹å¾—è§
  196 + `?? server/routes/echo.js` 那一åŠã€‚è¿™ä¸æ˜¯ä»£ç  bug,是两æ¡è‡ªè¿°äº’相打架,措辞该收敛。
  197 +2. **é™çº§è·¯å¾„零测试覆盖。** `applyBoundaryGuard` 有真实调用方(`advanceOne`),ä¸å±žäºŽ
  198 + 「零调用方ã€é‚£ä¸€ç±»ï¼›ä½† `boundaryGuardApplicable` è¦æ±‚执行器 `=== defaultAgentExecutor`,
  199 + è€Œå•æµ‹ä¸€å¾‹æ³¨å…¥å‡ executor,`applyBoundaryGuard` åˆæ²¡ export ——
  200 + **「越界 → success é™çº§æˆ failã€è¿™æ¡å”¯ä¸€æœ‰å®žé™…åŽæžœçš„分支,åªèƒ½é çœŸ CLI æ‰èµ°å¾—到。**
  201 + `graph-boundary.test.js` 测的是纯函数 `diffRepoStatus`ï¼Œä¸æ˜¯æŽ¥çº¿ã€‚
  202 +
  203 +#### 阻断真跑的真正断点(§三 第 6 æ¡ï¼Œå½’第二批 W5/W6)
  204 +
  205 +`AgentManager.getAgentConfig()` è§£æžè£¸åæ—¶åªæŸ¥ `system/agents/`,**ä»Žä¸æŸ¥ `system/templates/`**
  206 +(`agent-manager.js:141`);带公å¸å‰ç¼€çš„ `{cid}:{id}` æ‰æŸ¥ `data/companies/{cid}/agents/`。
  207 +而 `resolveAgentName()` åªåšã€Œç›®å½•å­˜ä¸å­˜åœ¨ã€çš„判断,**ä¸åšæŒ‰éœ€ hire**,查ä¸åˆ°å°±é€€å›žè£¸å。
  208 +
  209 +实测(纯文件系统解æžï¼Œæœªèµ· CLI):
  210 +
  211 +```
  212 +workgraph-planning THROWS: Agent not found: workgraph-planning
  213 +prd-planning THROWS: Agent not found: prd-planning
  214 +sdd-planning THROWS: Agent not found: sdd-planning
  215 +benchmark-validator THROWS: Agent not found: benchmark-validator
  216 +testdata-validator THROWS: Agent not found: testdata-validator
  217 +validator-triager THROWS: Agent not found: validator-triager
  218 +org-validator THROWS: Agent not found: org-validator
  219 +master RESOLVED
  220 +```
  221 +
  222 +`workgraph-planning` 在真链路里能过,åªå› ä¸º `resolvePlanningAgentName()` å•独给它åšäº†
  223 +按需 hireï¼›**它规划出æ¥çš„æ¯ä¸€ä¸ª work èŠ‚ç‚¹å¼•ç”¨çš„æœ¬ä½“éƒ½æ²¡æœ‰è¿™å±‚ä¿æŠ¤**。
  224 +æ‰€ä»¥ä¸€æ¬¡çœŸè·‘çš„å¿…ç„¶èµ°å‘æ˜¯ï¼šorg 节点 → 规划æˆåŠŸ → 第一个 work 节点 `Agent not found` → 节点判失败。
  225 +**这正是 W5/W6 è¦è§£å†³çš„问题,第一批四项åšå¾—å†å¯¹ä¹Ÿç»•ä¸è¿‡åŽ»ã€‚**
  226 +
  227 +> 推论:**现在起真 CLI ä¸ä¼šäº§å‡ºæ–°ä¿¡æ¯** —— æ–­ç‚¹å·²è¢«é›¶æˆæœ¬é™æ€è¯æ˜Žï¼Œ
  228 +> 而按 §五 çš„é‡çº§ï¼Œè·‘一次是 $3-8 / 15-40 åˆ†é’Ÿã€‚åº”å…ˆåš W5/W6 å†çœŸè·‘。
  229 +
  230 +#### 第 12 处「四环æ¼ä¸€çޝã€ï¼š`outcome.output` 全程被丢
  231 +
  232 +`graph_run_nodes` **没有 `output` 列**(`NODE_PATCH_COLUMNS` åªæœ‰ 9 个键,ä¸å« `output`),
  233 +`settleNode()` ä¹Ÿä¸æŠŠ `outcome.output` ä¼ ç»™ `transitionNode`,`node.output` è¿™æ¡ SSE 事件的
  234 +data é‡ŒåŒæ ·åªæœ‰ `status/fail_reason/output_ref/child_run_id`。于是:
  235 +
  236 +**产生(executor / å®ˆå« banner)→ 传递(`outcome.output`)→ è½åº“(无)→ 读回(无)**,第三环断。
  237 +
  238 +三个写入方全部è½ç©ºï¼š`defaultAgentExecutor`(:370/:386,存é‡ï¼‰ã€W3 çš„è¶Šç•Œæ¸…å• banner(:2538,本轮新增)ã€
  239 +以åŠ**最è¦å‘½çš„那个**——
  240 +
  241 +```js
  242 +} catch (err) {
  243 + outcome = { status: 'fail', output: err.message, failReason: null, tokens: err.tokens || 0 };
  244 +}
  245 +```
  246 +
  247 +`advanceOne` 里 executor 抛异常这æ¡è·¯å¾„:`err.message` 写进 `outcome.output` åŽ**人间蒸å‘**,
  248 +æ—¢ä¸è½åº“ã€ä¸è¿› SSEã€ä¹Ÿä¸å†™ timeline,`fail_reason` 还是 `null`。
  249 +**æ“作者åªä¼šçœ‹åˆ°ä¸€ä¸ªèŠ‚ç‚¹ failã€æ²¡æœ‰ä»»ä½•原因。**
  250 +
  251 +䏥釿€§åˆ¤æ–­ï¼šW3 banner 那一份是冗余的(越界清å•在 `node.boundary` çš„ timeline 里有 `entries`,
  252 +è¯æ®ä¸ä¸¢ï¼‰ï¼›**但异常那一份是净æŸå¤±**,而且正好砸在下一步最需è¦å®ƒçš„地方——
  253 +上一节那个 `Agent not found: prd-planning` 就是从这æ¡è·¯å¾„抛的,真跑起æ¥å°†è¡¨çŽ°ä¸º
  254 +「节点失败,无原因,无痕迹ã€ã€‚建议在 W5/W6 之å‰å…ˆè¡¥ä¸€æ¡ timelineï¼ˆæœ€å°æ”¹åŠ¨ï¼šcatch 里加
  255 +`timelineNote(ctx,'node.error',err.message,{...})`),å¦åˆ™çœŸè·‘çš„æŽ’éšœæˆæœ¬ä¼šè¢«è¿™ä¸€çŽ¯åƒæŽ‰ã€‚
  256 +
71 ### 第二批 · æ‹›è˜èµ°æ–¹æ¡ˆ a 257 ### 第二批 · æ‹›è˜èµ°æ–¹æ¡ˆ a
72 258
73 | # | 内容 | 文件 | é‡ | 259 | # | 内容 | 文件 | é‡ |
@@ -99,10 +285,14 @@ P3 主体七轮åšä¸‹æ¥ï¼Œ**引擎编排层是扎实的**ï¼ˆçŠ¶æ€æœºã€åŒå±‚ç @@ -99,10 +285,14 @@ P3 主体七轮åšä¸‹æ¥ï¼Œ**引擎编排层是扎实的**ï¼ˆçŠ¶æ€æœºã€åŒå±‚ç
99 285
100 **第一批** 286 **第一批**
101 287
102 -1. æœåŠ¡å¯åŠ¨åŽ `system_agents` 与 `system/templates/` 一致,无悬空 `company_hired` 行;新加一个模æ¿ç›®å½•ã€é‡å¯æœåŠ¡ï¼Œå®ƒè‡ªåŠ¨å‡ºçŽ°åœ¨è¡¨é‡Œã€‚  
103 -2. 起一个真 CLI çš„ run,节点产出全部è½åœ¨ `data/companies/{cid}/projects/{pid}/` 内;`git status` 在宿主仓库无任何新增/修改。  
104 -3. æ•…æ„è®©èŠ‚ç‚¹å¾€äº§ç‰©ç›®å½•å¤–å†™ï¼Œè¢«å®ˆå«æ‹’ç»å¹¶åœ¨ timeline 留痕。  
105 -4. é€ ä¸€ä»½éžæ³•契约跑 `benchmark-validator`:判决为失败且引擎按失败处ç†ï¼ˆ**䏿˜¯** success)。 288 +| # | 标准 | çŠ¶æ€ |
  289 +|---|---|---|
  290 +| 1 | æœåŠ¡å¯åŠ¨åŽ `system_agents` 与 `system/templates/` 一致,无悬空 `company_hired` 行;新加一个模æ¿ç›®å½•ã€é‡å¯æœåŠ¡ï¼Œå®ƒè‡ªåŠ¨å‡ºçŽ°åœ¨è¡¨é‡Œ | ✅ **已验**(生产库实查 + hermetic 用例)。一处**已知且åˆç†**çš„å差:`system/agents/` 与 `system/templates/` åŒåçš„ 6 个 id(`agent-creator` / `org-innovator` / `innovator-{digest,evolver,hunt}` / `evolver-youtube-crawler`)按设计 skipï¼Œä»æ˜¯ `global_agent`。图用到的本体都ä¸åœ¨è¿™ 6 个里 |
  291 +| 2 | 起一个真 CLI çš„ run,节点产出全部è½åœ¨ `data/companies/{cid}/projects/{pid}/` 内;`git status` 在宿主仓库无任何新增/修改 | â›” **未验,且暂ä¸è¯¥éªŒ**。链路先断在 §三 第 6 æ¡ï¼ˆ`Agent not found`,è§ä¸Šæ–¹æ–­ç‚¹ä¸€èŠ‚ï¼‰ï¼ŒçœŸè·‘åˆ°ä¸äº†äº§å‡ºè¿™ä¸€æ­¥ã€‚mock + 默认执行器下项目根的分é…/è½åº“/读回已验 |
  292 +| 3 | æ•…æ„è®©èŠ‚ç‚¹å¾€äº§ç‰©ç›®å½•å¤–å†™ï¼Œè¢«å®ˆå«æ‹’ç»å¹¶åœ¨ timeline 留痕 | â›” **未验**。é™çº§åˆ†æ”¯åªåœ¨ `defaultAgentExecutor` 下å¯è¾¾ï¼Œå•æµ‹æ³¨å…¥å‡ executor èµ°ä¸åˆ°ï¼›åˆ¤æ® `diffRepoStatus` å•独验过 |
  293 +| 4 | é€ ä¸€ä»½éžæ³•契约跑 `benchmark-validator`:判决为失败且引擎按失败处ç†ï¼ˆ**䏿˜¯** success) | 🟡 **陿€æˆç«‹ï¼ŒæœªçœŸè·‘**。模æ¿å·²æ”¹å†™ `verdict: "attributed"`,而 `FAILING_VERDICTS={attributed,inconclusive}` → `readNodeProduct` ç½® `failingVerdict=true` → `callAgentNode` 判 `fail`(`graph-engine.js:473/385`),链路读通;防线测试三次å例å˜å¼‚全部å˜çº¢ |
  294 +
  295 +> 2 与 3 都è¦çœŸ CLI æ‰éªŒå¾—了,而真 CLI 现在会先死在 W5/W6 上。**这两æ¡åº”挪到第二批之åŽä¸€èµ·éªŒã€‚**
106 296
107 **第二批** 297 **第二批**
108 298
@@ -123,3 +313,54 @@ P3 主体七轮åšä¸‹æ¥ï¼Œ**引擎编排层是扎实的**ï¼ˆçŠ¶æ€æœºã€åŒå±‚ç @@ -123,3 +313,54 @@ P3 主体七轮åšä¸‹æ¥ï¼Œ**引擎编排层是扎实的**ï¼ˆçŠ¶æ€æœºã€åŒå±‚ç
123 2. **W7 的语义决定è¦è°¨æ…Ž**:`hire()` 写 `org_graph_nodes` 是 P2 §4.2 定的(「hire æˆåŠŸåŽå†™ä¸€è¡Œï¼Œå¹‚ç­‰ã€ï¼‰ï¼Œå½“æ—¶çš„å‰ææ˜¯åªæœ‰ org 节点会被雇。方案 a æ‰“ç ´äº†è¿™ä¸ªå‰æã€‚æ”¹åŠ¨è¦ç¡®è®¤ä¸ç ´ P2 已有的 24 æ¡æµ‹è¯•。 313 2. **W7 的语义决定è¦è°¨æ…Ž**:`hire()` 写 `org_graph_nodes` 是 P2 §4.2 定的(「hire æˆåŠŸåŽå†™ä¸€è¡Œï¼Œå¹‚ç­‰ã€ï¼‰ï¼Œå½“æ—¶çš„å‰ææ˜¯åªæœ‰ org 节点会被雇。方案 a æ‰“ç ´äº†è¿™ä¸ªå‰æã€‚æ”¹åŠ¨è¦ç¡®è®¤ä¸ç ´ P2 已有的 24 æ¡æµ‹è¯•。
124 3. **真 CLI 会暴露一批模æ¿è´¨é‡é—®é¢˜**:å‰ä¸ƒè½®æ‰€æœ‰ agent 模æ¿éƒ½åªåœ¨ mock 下「跑过ã€ï¼Œè€Œ mock ä¸è¯»æç¤ºè¯â€”—**这些æç¤ºè¯å®žé™…上从未被任何模型读过**。第一次真跑大概率会集中暴露表述矛盾(W4 就是一例,`benchmark-validator` çš„ Step 表与自己的产出格å¼è¡¨å°±äº’相打架)。 314 3. **真 CLI 会暴露一批模æ¿è´¨é‡é—®é¢˜**:å‰ä¸ƒè½®æ‰€æœ‰ agent 模æ¿éƒ½åªåœ¨ mock 下「跑过ã€ï¼Œè€Œ mock ä¸è¯»æç¤ºè¯â€”—**这些æç¤ºè¯å®žé™…上从未被任何模型读过**。第一次真跑大概率会集中暴露表述矛盾(W4 就是一例,`benchmark-validator` çš„ Step 表与自己的产出格å¼è¡¨å°±äº’相打架)。
125 4. **ä¸è¦åœ¨æ²¡æœ‰ W2/W3 的情况下跑真 CLI**:已ç»å‘生过一次 agent 写入宿主仓库。 315 4. **ä¸è¦åœ¨æ²¡æœ‰ W2/W3 的情况下跑真 CLI**:已ç»å‘生过一次 agent 写入宿主仓库。
  316 +
  317 +---
  318 +
  319 +## å…«ã€`FAILING_VERDICTS` è¦ä¸è¦æ”¾å®½ï¼ˆW4 的附带结论)
  320 +
  321 +> **`graph-engine.js` å½’ W2/W3 那一轮改,本节åªç»™ç»“论与ç†ç”±ï¼Œä¸åЍ代ç ã€‚**
  322 +
  323 +### 结论
  324 +
  325 +**è¦åŠ¨ï¼Œä½†ä¸è¦ç”¨ã€ŒæŠŠ `"fail"` 加进 `FAILING_VERDICTS`ã€è¿™ä¸ªæ”¹æ³•——应该把判æ®å过æ¥ã€‚**
  326 +
  327 +```js
  328 +// 现在(白åå•记「失败è¯ã€ï¼Œå‡¡ä¸è®¤è¯†çš„一律当通过 = fail-open)
  329 +const FAILING_VERDICTS = new Set(['attributed', 'inconclusive']);
  330 +failingVerdict = verdict === null ? raw !== null : FAILING_VERDICTS.has(verdict);
  331 +
  332 +// 建议(白åå•记「通过è¯ã€ï¼Œå‡¡ä¸è®¤è¯†çš„一律当失败 = fail-closed)
  333 +const PASSING_VERDICTS = new Set(['passed', 'skipped', 'produced', 'no_prior_failure']);
  334 +failingVerdict = verdict === null ? raw !== null : !PASSING_VERDICTS.has(verdict);
  335 +// 并对ä¸è®¤è¯†çš„å–å€¼è®°ä¸€æ¡ timeline(degraded: 'unknown_verdict'),与 fail_reason 的处ç†å¯¹ç§°
  336 +```
  337 +
  338 +### ç†ç”±
  339 +
  340 +1. **åªè¡¥ `"fail"` 是打补ä¸ï¼Œæ¼çš„æ˜¯ä¸€æ•´ç±»ã€‚** 判æ®çš„å½¢çŠ¶æ‰æ˜¯ç¼ºé™·ï¼šå¤±è´¥ä¾§æ˜¯ç™½åå•,
  341 + 于是 `failed` / `rejected` / `not_met` / `error` / `no` / `ä¸é€šè¿‡` —— 任何一个模型
  342 + 临场想出æ¥çš„失败è¯ï¼Œå¼•擎都读æˆã€Œæ²¡å¤±è´¥ã€ã€‚补一个è¯åªå µä¸€ä¸ªæ´žï¼Œæ´žçš„æ•°é‡æ˜¯å¼€æ”¾çš„。
  343 +2. **模型ä¸å—模æ¿çº¦æŸï¼Œè¿™ä¸€ç‚¹ W4 æœ¬èº«å°±æ˜¯è¯æ®ã€‚** mock 从ä¸è¯»æç¤ºè¯ï¼Œæ‰€ä»¥è¿™æ‰¹æç¤ºè¯
  344 + 在第一次真跑å‰ä»Žæœªè¢«ä»»ä½•模型读过(§七.3)。模æ¿åªèƒ½æé«˜å†™å¯¹çš„æ¦‚率,ä¸èƒ½ä¿è¯ï¼›
  345 + 引擎是最åŽä¸€é“,它必须对没è§è¿‡çš„è¯å–安全的那一侧。
  346 +3. **安全侧是「判失败ã€è€Œä¸æ˜¯ã€Œåˆ¤é€šè¿‡ã€ã€‚** 判错æˆå¤±è´¥çš„代价是多跑一轮(烧计数ã€èµ°å…œåº•边,
  347 + 熔断有 `LOOP_HARD_CAP_EDGE/RUN` 兜底);判错æˆé€šè¿‡çš„代价是ä¸åˆæ ¼äº§ç‰©ç›´æŽ¥æ”¾è¡Œï¼Œ
  348 + 而且**ä¸ç•™ä»»ä½•痕迹**——这正是 W4 的失效方å‘。两ç§é”™ä»£ä»·ä¸å¯¹ç§°ï¼Œåˆ¤æ®å°±è¯¥åå‘贵的那侧。
  349 +4. **引擎里已有åŒå½¢çŠ¶çš„æ­£ç¡®å…ˆä¾‹ã€‚** `fail_reason` 那一侧是å°é—­åŸŸï¼ˆ`ATTRIBUTION_REASONS`),
  350 + 自创å–值 → 显å¼é™çº§æˆ `null` + ä¸€æ¡ timeline,ä¸é™é»˜ç›¸ä¿¡ã€‚`verdict` 应当对称处ç†ã€‚
  351 +5. **`verdict === null` é‚£æ¡ç¼ºçœåˆ†æ”¯ä¸ç”¨åЍ**:缺 `verdict` 但有 `fail_reason` 判失败,
  352 + 本æ¥å°±æ˜¯ fail-closed 的,改动åªå½±å“「写了 `verdict` 但引擎ä¸è®¤è¯†ã€è¿™ä¸€æ”¯ã€‚
  353 +6. **代价接近零。** 通过è¯ä¸€å…±å››ä¸ªï¼ŒçŽ°æœ‰æ¨¡æ¿å…¨éƒ¨å‘½ä¸­ï¼ˆ`template-verdicts.test.js`
  354 + çš„ `PASSING_VERDICTS` 与它é€å­—ä¸€è‡´ï¼Œå®žæµ‹æ‰«ææ— ä¸€ä¾‹å¤–)。å转åŽ
  355 + `attributed` / `inconclusive` 继续判失败,语义ä¸å˜ã€‚
  356 +
  357 +### 两点必须一起注æ„
  358 +
  359 +- **å转ä¸èƒ½æ›¿ä»£ W4 çš„æ¨¡æ¿æ”¹åŠ¨ã€‚** åè½¬ä¹‹åŽ `verdict: "fail"` 会被判æˆå¤±è´¥ï¼Œ
  360 + 但那是「猜对了ã€ï¼Œä¸æ˜¯ã€Œå†™å¯¹äº†ã€ï¼šäº§å‡ºä»ç„¶å¸¦ç€ä¸€ä¸ªå¼•æ“Žä¸æ‰¿è®¤çš„è¯ï¼Œ
  361 + `org-validator` é‚£ç§ pass/fail 审计è¯ä¹Ÿä»ç„¶ä¼šä¸²åˆ°åˆ¤è¯å­—段上。模æ¿å¿…须写对,引擎兜底。
  362 +- **å转会让 `template-verdicts.test.js` 报错,这是设计好的。** 它按å字从
  363 + `graph-engine.js` æºç å– `FAILING_VERDICTS`,å–ä¸åˆ°å°±æŠ›
  364 + (「找ä¸åˆ° FAILING_VERDICTS——å–å€¼åŸŸé˜²çº¿å¤±åŽ»ä¾æ®ï¼Œä¸è¦è®©å®ƒé™é»˜ç©ºè·‘ã€ï¼‰ã€‚
  365 + 真去å转的è¯ï¼ŒæŠŠè¯¥æµ‹è¯•里 `engineSet('FAILING_VERDICTS')` æ¢æˆ `engineSet('PASSING_VERDICTS')`
  366 + 并把 `VERDICT_DOMAIN` 的组æˆå¯¹åº”改一行å³å¯ï¼›**ä¸è¦**把断言删掉了事。
scripts/graph-seed.js
@@ -3,25 +3,42 @@ @@ -3,25 +3,42 @@
3 /** 3 /**
4 * graph-seed.js — seeds data/graph.sqlite from the on-disk agent/template/company sources. 4 * graph-seed.js — seeds data/graph.sqlite from the on-disk agent/template/company sources.
5 * 5 *
6 - * 依次跑 seedGlobalAgents()(T1)→ seedTemplates()(T2)→ seedCompanyAgents()(T3)  
7 - * → verifyCounts()(T4,独立重新扫描目录/文件做行数交叉核对)。 6 + * 依次跑 reconcileDanglingRows()(P3B W1)→ seedGlobalAgents()(T1)→ seedTemplates()(T2)
  7 + * → seedCompanyAgents()(T3)→ verifyCounts()(T4,独立重新扫描目录/文件做行数交叉核对)。
8 * 打印按 source_kind 分组的汇总报告;核对不一致时以非零状态码退出。 8 * 打印按 source_kind 分组的汇总报告;核对不一致时以非零状态码退出。
9 * 9 *
  10 + * 前四步里的前三步(reconcile + global + templates)服务启动时会自动跑一遍
  11 + * (graph-seed-service.runStartupSeedSafely,见 server/index.js);本脚本比启动多跑
  12 + * seedCompanyAgents()——那一步会从 company.json 反推 org_graph_nodes,属于手动补种,
  13 + * 不放进启动流程(理由见 runStartupSeed 的注释)。
  14 + *
10 * 用法: 15 * 用法:
11 * node scripts/graph-seed.js 16 * node scripts/graph-seed.js
12 */ 17 */
13 18
14 -const { seedGlobalAgents, seedTemplates, seedCompanyAgents, verifyCounts } = require('../server/services/graph-seed-service'); 19 +const {
  20 + reconcileDanglingRows, seedGlobalAgents, seedTemplates, seedCompanyAgents, verifyCounts,
  21 +} = require('../server/services/graph-seed-service');
15 22
16 function main() { 23 function main() {
17 console.log('[graph-seed] 开始种子导入\n'); 24 console.log('[graph-seed] 开始种子导入\n');
18 25
  26 + const reconciled = reconcileDanglingRows();
  27 + console.log(`[reconcile] 清理悬空 company_hired 行: ${reconciled.agentsRemoved.length} 条${reconciled.agentsRemoved.length ? ` (${reconciled.agentsRemoved.join(', ')})` : ''}`);
  28 + console.log(` 已删公司的 org 节点/边: ${reconciled.orgNodesRemoved}/${reconciled.orgEdgesRemoved} 行${reconciled.ghostCompanyIds.length ? `,涉及公司 ${reconciled.ghostCompanyIds.join(', ')}` : ''}`);
  29 + if (reconciled.retained.length > 0) {
  30 + console.log(` ⚠️ 被外键引用、暂时保留 (${reconciled.retained.length}): ${reconciled.retained.map(r => `${r.id} — ${r.reason}`).join('; ')}`);
  31 + }
  32 +
19 const globalAgents = seedGlobalAgents(); 33 const globalAgents = seedGlobalAgents();
20 console.log(`[global_agent] 扫描 system/agents/* 目录: ${globalAgents.scanned} 个,含 agent.md 且已导入: ${globalAgents.seeded} 个`); 34 console.log(`[global_agent] 扫描 system/agents/* 目录: ${globalAgents.scanned} 个,含 agent.md 且已导入: ${globalAgents.seeded} 个`);
21 console.log(` ids: ${globalAgents.ids.join(', ')}`); 35 console.log(` ids: ${globalAgents.ids.join(', ')}`);
22 36
23 const templates = seedTemplates(); 37 const templates = seedTemplates();
24 console.log(`\n[template] 扫描 system/templates/* 目录: ${templates.scanned} 个,已导入: ${templates.seeded} 个`); 38 console.log(`\n[template] 扫描 system/templates/* 目录: ${templates.scanned} 个,已导入: ${templates.seeded} 个`);
  39 + if (templates.reclaimed.length > 0) {
  40 + console.log(` ♻️ 从悬空 company_hired 占位接管回 template 的 ids (${templates.reclaimed.length}): ${templates.reclaimed.join(', ')}`);
  41 + }
25 console.log(` registry 匹配到 benchmark_score 的 ids (${templates.registryMatched.length}): ${templates.registryMatched.join(', ')}`); 42 console.log(` registry 匹配到 benchmark_score 的 ids (${templates.registryMatched.length}): ${templates.registryMatched.join(', ')}`);
26 if (templates.skipped.length > 0) { 43 if (templates.skipped.length > 0) {
27 console.log(` ⚠️ id 与其他 source_kind 冲突,已跳过写入 (${templates.skipped.length}): ${templates.skipped.map(s => `${s.id}(已属于 ${s.existingSourceKind})`).join(', ')}`); 44 console.log(` ⚠️ id 与其他 source_kind 冲突,已跳过写入 (${templates.skipped.length}): ${templates.skipped.map(s => `${s.id}(已属于 ${s.existingSourceKind})`).join(', ')}`);
server/agent-server.js
@@ -75,6 +75,9 @@ app.use(&#39;/public&#39;, require(&#39;express&#39;).static(require(&#39;path&#39;).join(__dirname, &#39;pu @@ -75,6 +75,9 @@ app.use(&#39;/public&#39;, require(&#39;express&#39;).static(require(&#39;path&#39;).join(__dirname, &#39;pu
75 // Health 75 // Health
76 app.get('/api/health', (req, res) => res.json({ status: 'ok', service: 'agent-chat' })); 76 app.get('/api/health', (req, res) => res.json({ status: 'ok', service: 'agent-chat' }));
77 77
  78 +// Graph agent 元数据对齐(P3B W1)——与 server/index.js 的 start() 同一步,理由见那里的注释
  79 +require('./services/graph-seed-service').runStartupSeedSafely();
  80 +
78 app.listen(PORT, '0.0.0.0', () => { 81 app.listen(PORT, '0.0.0.0', () => {
79 console.log(`🧬 Agent Chat Server: http://localhost:${PORT}`); 82 console.log(`🧬 Agent Chat Server: http://localhost:${PORT}`);
80 console.log(` API: http://localhost:${PORT}/api/agent/list`); 83 console.log(` API: http://localhost:${PORT}/api/agent/list`);
server/index.js
@@ -121,6 +121,12 @@ async function start() { @@ -121,6 +121,12 @@ async function start() {
121 try { 121 try {
122 ensureDataDirs(); 122 ensureDataDirs();
123 123
  124 + // Graph agent 元数据对齐(P3B W1):把 system/agents、system/templates 的目录现状
  125 + // 同步进 system_agents,并回收指向已删目录的悬空行。必须在 listen() 之前——
  126 + // work_graph_nodes.system_agent_id 是指向这张表的外键,第一个请求就可能提交图。
  127 + // 幂等 + 同步 + 毫秒级;内部已包 try/catch,失败只降级不挡启动。
  128 + require('./services/graph-seed-service').runStartupSeedSafely();
  129 +
124 const host = config.host || '0.0.0.0'; 130 const host = config.host || '0.0.0.0';
125 server.listen(Number(config.port), host, () => { 131 server.listen(Number(config.port), host, () => {
126 console.log(`✅ 服务启动: http://${host}:${config.port}`); 132 console.log(`✅ 服务启动: http://${host}:${config.port}`);
server/services/graph-engine.js
@@ -331,6 +331,8 @@ async function defaultAgentExecutor(ctx) { @@ -331,6 +331,8 @@ async function defaultAgentExecutor(ctx) {
331 // 产物目录是**提示词**里那份约定的机器可读副本(提示词由 describeArtifactContract 拼, 331 // 产物目录是**提示词**里那份约定的机器可读副本(提示词由 describeArtifactContract 拼,
332 // 见 advanceOne);metadata 只进日志与 hook,agent 读不到,所以两边都要有。 332 // 见 advanceOne);metadata 只进日志与 hook,agent 读不到,所以两边都要有。
333 rootRunId: ctx.rootRunId, attempt: ctx.attempt, artifactDir: safeNodeArtifactDir(ctx), 333 rootRunId: ctx.rootRunId, attempt: ctx.attempt, artifactDir: safeNodeArtifactDir(ctx),
  334 + // 项目根同理:提示词那份给 agent 读,这份给日志/hook 用(W2)
  335 + projectRoot: ctx.projectRoot || null,
334 }, 336 },
335 }); 337 });
336 } catch (e) { 338 } catch (e) {
@@ -601,6 +603,7 @@ async function defaultPlanWorkGraph(ctx) { @@ -601,6 +603,7 @@ async function defaultPlanWorkGraph(ctx) {
601 metadata: { 603 metadata: {
602 taskId: ctx.runId, layer: 'graph-plan', nodeRunId: ctx.nodeRunId, 604 taskId: ctx.runId, layer: 'graph-plan', nodeRunId: ctx.nodeRunId,
603 rootRunId: ctx.rootRunId, attempt: ctx.attempt, 605 rootRunId: ctx.rootRunId, attempt: ctx.attempt,
  606 + projectRoot: ctx.projectRoot || null,
604 }, 607 },
605 }); 608 });
606 } catch (e) { 609 } catch (e) {
@@ -722,6 +725,16 @@ function buildPlanningPrompt(ctx, { round = 1, errors = null, lastOutput = &#39;&#39; } @@ -722,6 +725,16 @@ function buildPlanningPrompt(ctx, { round = 1, errors = null, lastOutput = &#39;&#39; }
722 '## ④ 上一轮 remediation_note', 725 '## ④ 上一轮 remediation_note',
723 ctx.remediationNote || '无,首次规划。', 726 ctx.remediationNote || '无,首次规划。',
724 '', 727 '',
  728 + // W2:规划本体也是一次真 CLI 调用,也会写盘(模板 Step 6 的降级路径就叫它把图写成
  729 + // workspace/workgraph-<taskId>.json)。不给它位置,它同样会写到最近的仓库里去。
  730 + '## 写入边界(硬约束)',
  731 + ctx.projectRoot
  732 + ? `本次任务的项目根:${ctx.projectRoot}\n`
  733 + + '你若要落任何中间文件(图 JSON、草稿),写在这个项目根里面,用绝对路径。'
  734 + : '本次任务没有分配到项目根(分配失败,见 timeline):不要在磁盘上留任何文件,把图直接贴在回复里。',
  735 + `禁止写 ${REPO_ROOT} 下的任何路径(那是引擎源码仓库);引擎会在调用前后扫宿主仓库的 git 状态,`
  736 + + '发现越界会把本节点判失败。你规划出来的每个节点将来也会收到同一条边界约束。',
  737 + '',
725 '## 落库口径(本次调用的硬约束)', 738 '## 落库口径(本次调用的硬约束)',
726 `- goalId=${ctx.run.goal_id || 'null'} / taskId=${ctx.run.task_id || 'null'} / okrNodeId=${ctx.run.okr_node_id || 'null'},原样带上,别自己编。`, 739 `- goalId=${ctx.run.goal_id || 'null'} / taskId=${ctx.run.task_id || 'null'} / okrNodeId=${ctx.run.okr_node_id || 'null'},原样带上,别自己编。`,
727 '- 图必须过五项校验:无环 / 单一入口 / 全可达 / 回退边显式标 backward /' 740 '- 图必须过五项校验:无环 / 单一入口 / 全可达 / 回退边显式标 backward /'
@@ -1170,9 +1183,15 @@ async function advanceOne(runId, opts = {}) { @@ -1170,9 +1183,15 @@ async function advanceOne(runId, opts = {}) {
1170 remediationNote: latestRemediationNote(picked.id), 1183 remediationNote: latestRemediationNote(picked.id),
1171 executionMode: run.execution_mode, 1184 executionMode: run.execution_mode,
1172 }; 1185 };
  1186 + // W2:产出位置必须在**发起调用之前**就存在,且两种节点都要有——subgraph 节点起的是
  1187 + // workgraph-planning,那同样是一次真 CLI 调用,同样会写盘。
  1188 + const projectRoot = ensureProjectRoot(ctx);
  1189 + ctx.projectRoot = projectRoot ? projectRoot.root_path : null;
  1190 +
1173 // 产物目录必须**进提示词**:读的一侧(readNodeProduct)按 §四.A.5 的路径去读, 1191 // 产物目录必须**进提示词**:读的一侧(readNodeProduct)按 §四.A.5 的路径去读,
1174 // 写的一侧不告诉 agent 往哪写、文件名怎么起,这份文件在生产里就根本不会被写出来, 1192 // 写的一侧不告诉 agent 往哪写、文件名怎么起,这份文件在生产里就根本不会被写出来,
1175 // 于是 fail 边 / 回退 / infra_flake 全都触发不了(R3-F1 缺陷 3a)。 1193 // 于是 fail 边 / 回退 / infra_flake 全都触发不了(R3-F1 缺陷 3a)。
  1194 + // 项目根同理,且比产物目录更早断:不写进提示词,agent 就会写到它能看见的最近的仓库。
1176 if (ctx.nodeKind !== 'subgraph') ctx.task += describeArtifactContract(ctx); 1195 if (ctx.nodeKind !== 'subgraph') ctx.task += describeArtifactContract(ctx);
1177 1196
1178 // 抢到节点即记账:一次调用一定发生了(哪怕它随后炸掉),预算「只增不减」(§四.A.3)。 1197 // 抢到节点即记账:一次调用一定发生了(哪怕它随后炸掉),预算「只增不减」(§四.A.3)。
@@ -1191,6 +1210,9 @@ async function advanceOne(runId, opts = {}) { @@ -1191,6 +1210,9 @@ async function advanceOne(runId, opts = {}) {
1191 { ref: picked.id }, 1210 { ref: picked.id },
1192 ); 1211 );
1193 1212
  1213 + // W3:调用前后各拍一张宿主仓库快照。能拦什么拦不了什么见 applyBoundaryGuard 的注释。
  1214 + const guardSnapshot = beginBoundaryGuard(ctx);
  1215 +
1194 let outcome; 1216 let outcome;
1195 try { 1217 try {
1196 outcome = ctx.nodeKind === 'subgraph' ? await executeSubgraph(ctx) : await agentExecutor(ctx); 1218 outcome = ctx.nodeKind === 'subgraph' ? await executeSubgraph(ctx) : await agentExecutor(ctx);
@@ -1199,6 +1221,8 @@ async function advanceOne(runId, opts = {}) { @@ -1199,6 +1221,8 @@ async function advanceOne(runId, opts = {}) {
1199 // err.tokens:规划连着跑了几轮才放弃时,那几轮的 token 是真花了的,照扣不误(§四.A.3「只增不减」)。 1221 // err.tokens:规划连着跑了几轮才放弃时,那几轮的 token 是真花了的,照扣不误(§四.A.3「只增不减」)。
1200 outcome = { status: 'fail', output: err.message, failReason: null, tokens: err.tokens || 0 }; 1222 outcome = { status: 'fail', output: err.message, failReason: null, tokens: err.tokens || 0 };
1201 } 1223 }
  1224 + // 炸掉的那条路径同样要查:agent 写完仓库再崩,痕迹一样在。
  1225 + outcome = applyBoundaryGuard(ctx, guardSnapshot, outcome);
1202 1226
1203 // executor 报了 token 用量就一并扣。产生端见 defaultAgentExecutor 的 onAcpEvent 1227 // executor 报了 token 用量就一并扣。产生端见 defaultAgentExecutor 的 onAcpEvent
1204 // (claude-cli 协议的 session/result.usage);不上报 usage 的协议这一维恒为 0, 1228 // (claude-cli 协议的 session/result.usage);不上报 usage 的协议这一维恒为 0,
@@ -2284,6 +2308,237 @@ function runRootDir(run) { @@ -2284,6 +2308,237 @@ function runRootDir(run) {
2284 : path.join(REPO_ROOT, 'data', 'companies', run.company_id, 'runs', run.root_run_id); 2308 : path.join(REPO_ROOT, 'data', 'companies', run.company_id, 'runs', run.root_run_id);
2285 } 2309 }
2286 2310
  2311 +// ────────────────────────────────────────────────────────────────
  2312 +// W2 · 每个 root run 一个项目根(27-P3B §四 W2)
  2313 +// ────────────────────────────────────────────────────────────────
  2314 +
  2315 +/**
  2316 + * **根因不是「缺沙箱」而是「缺产出位置」**:节点收到「实现某个功能」的任务,却没有
  2317 + * 「这次任务的代码写哪」,于是写到它能看见的最近的仓库(真 CLI 实测已往宿主仓库写过
  2318 + * server/routes/echo.js 等三处)。所以 W2 补的是位置,W3 才是边界。
  2319 + *
  2320 + * 位置的分配**复用 DevLoop 的 path-resolver.allocateProjectRoot(sandbox 模式)**,
  2321 + * 不新造一套:`data/companies/{cid}/projects/{pid}`,与 DevLoop 项目同一棵目录树。
  2322 + *
  2323 + * ── 存哪:`runs/{rootRunId}/artifacts/project-root.json`,**不加 schema 列** ──
  2324 + * 三条理由:
  2325 + * 1. graph_runs 加列要动 work-graph-db.js(本轮不归本文件),且这条信息**天然按
  2326 + * root_run_id 唯一**——而 runRootDir 本来就是按 root_run_id 拼的,子 run 拼出来是
  2327 + * 同一个目录,天然共享同一份记录,不需要每行都存一份再对齐。
  2328 + * 2. 它是「这次任务的工作区在哪」,与 artifacts/task-goal.json 是同一类 run 级事实,
  2329 + * 放在一起读回的路径已经通了(describeArtifactsDir / describeTask 都在读这个目录)。
  2330 + * 3. 产生 → 传递 → 落库 → 读回 四环都在本文件里闭合:
  2331 + * ensureProjectRoot 分配(产生)→ ctx.projectRoot 进提示词 + callAgent metadata(传递)
  2332 + * → project-root.json(落库)→ 下一个节点/子 run 再进来时 readProjectRootRecord 读回。
  2333 + */
  2334 +const PROJECT_ROOT_FILE = 'project-root.json';
  2335 +const PROJECT_ROOT_SCHEMA = 'graph-run-project-root/v1';
  2336 +
  2337 +function projectRootFile(run) {
  2338 + return path.join(runRootDir(run), 'artifacts', PROJECT_ROOT_FILE);
  2339 +}
  2340 +
  2341 +function readProjectRootRecord(run) {
  2342 + let rec = null;
  2343 + try {
  2344 + rec = readJsonOrNull(projectRootFile(run));
  2345 + } catch {
  2346 + return null; // run 缺 company_id/root_run_id:拼不出路径 = 没有记录
  2347 + }
  2348 + return rec && typeof rec.root_path === 'string' && rec.root_path ? rec : null;
  2349 +}
  2350 +
  2351 +/**
  2352 + * 真正在盘上开一个目录。
  2353 + *
  2354 + * GRAPH_RUNS_DIR 覆盖时项目根跟着走(`{RUNS_ROOT}/{rootRunId}/project`),理由与本文件
  2355 + * 顶部 RUNS_ROOT 那段注释完全一样:run 的产物被重定向到临时目录,项目根却还落在仓库
  2356 + * `data/companies/` 的话,测试每跑一次就往仓库里堆一个空项目目录。两处必须同源。
  2357 + */
  2358 +function allocateRunProjectRoot(run) {
  2359 + if (RUNS_ROOT) {
  2360 + const root_path = path.join(RUNS_ROOT, run.root_run_id, 'project');
  2361 + fs.mkdirSync(root_path, { recursive: true });
  2362 + return { pid: `p-run-${run.root_run_id}`, root_path, mode: 'runs-dir-override' };
  2363 + }
  2364 + return require('./devloop/path-resolver').allocateProjectRoot({
  2365 + company_id: run.company_id, mode: 'sandbox',
  2366 + });
  2367 +}
  2368 +
  2369 +/**
  2370 + * 取本 root run 的项目根,没有就分配一个并落库。**幂等**:先读回、再分配。
  2371 + *
  2372 + * @returns {{pid: string, root_path: string, mode: string}|null} null = 这次拿不到写入位置
  2373 + * (提示词会如实说明,而不是编一个假路径塞给 agent)
  2374 + */
  2375 +function ensureProjectRoot(ctx) {
  2376 + const run = ctx && ctx.run;
  2377 + if (!run) return null;
  2378 +
  2379 + const existing = readProjectRootRecord(run);
  2380 + if (existing) {
  2381 + // 目录被人删掉过就照原路径重建:记录里的路径是提示词/产物的唯一真源,不能换。
  2382 + try { fs.mkdirSync(existing.root_path, { recursive: true }); } catch { /* 建不出来下面照样返回,agent 会自己报错 */ }
  2383 + return existing;
  2384 + }
  2385 +
  2386 + let alloc;
  2387 + try {
  2388 + alloc = allocateRunProjectRoot(run);
  2389 + } catch (e) {
  2390 + timelineNote(ctx, 'node.boundary', `项目根分配失败(${e.message}),本节点没有产出位置`, {
  2391 + node_run_id: ctx.nodeRunId, degraded: 'project_root_alloc_failed',
  2392 + });
  2393 + return null;
  2394 + }
  2395 +
  2396 + // W3 的第一道(也是唯一一道**调用前就能强制**的)守卫:复用 path-resolver 的黑名单。
  2397 + // sandbox 模式自己算出来的路径不会命中,但 GRAPH_RUNS_DIR 指到 /etc 这类配置错误会。
  2398 + try {
  2399 + if (require('./devloop/path-resolver').isSensitivePath(alloc.root_path)) {
  2400 + timelineNote(ctx, 'node.boundary', `项目根落在敏感路径黑名单里,拒绝使用:${alloc.root_path}`, {
  2401 + node_run_id: ctx.nodeRunId, root_path: alloc.root_path, degraded: 'sensitive_project_root',
  2402 + });
  2403 + return null;
  2404 + }
  2405 + } catch { /* 黑名单查不了不该把 run 弄挂,继续用分配到的路径 */ }
  2406 +
  2407 + const rec = {
  2408 + schema: PROJECT_ROOT_SCHEMA,
  2409 + pid: alloc.pid,
  2410 + root_path: alloc.root_path,
  2411 + mode: alloc.mode,
  2412 + root_run_id: run.root_run_id,
  2413 + company_id: run.company_id,
  2414 + allocated_by: ctx.nodeRunId || null,
  2415 + allocated_at: now(),
  2416 + };
  2417 + const file = projectRootFile(run);
  2418 + try {
  2419 + fs.mkdirSync(path.dirname(file), { recursive: true });
  2420 + // 'wx' = 先到先得。并发下第二个节点会 EEXIST,读回第一个人写的那份(下面 catch)。
  2421 + fs.writeFileSync(file, JSON.stringify(rec, null, 2), { flag: 'wx' });
  2422 + } catch {
  2423 + const raced = readProjectRootRecord(run);
  2424 + if (raced) {
  2425 + // ponytail: 输给别人的那次 allocateProjectRoot 会剩一个空目录(sandbox 模式下是
  2426 + // data/companies/{cid}/projects/{pid}/,里面什么都没有)。不清理:删目录的代价错误
  2427 + // 方向更贵。升级路径是把分配挪到 createRun 那一刻(届时只有一个调用方)。
  2428 + return raced;
  2429 + }
  2430 + // 落不了库但目录是真的:仍然给 agent 一个写入位置,但要吼出来——下一个节点会**另分配一个**,
  2431 + // 跨节点的产物会散在两个根里。
  2432 + timelineNote(ctx, 'node.boundary',
  2433 + `项目根记录写入失败(${file}),后续节点可能分配到不同的项目根`, {
  2434 + node_run_id: ctx.nodeRunId, root_path: rec.root_path, degraded: 'project_root_not_persisted',
  2435 + });
  2436 + return rec;
  2437 + }
  2438 +
  2439 + timelineNote(ctx, 'node.boundary', `本次任务的项目根:${rec.root_path}(${rec.mode})`, {
  2440 + node_run_id: ctx.nodeRunId, root_path: rec.root_path, pid: rec.pid, mode: rec.mode,
  2441 + });
  2442 + return rec;
  2443 +}
  2444 +
  2445 +// ────────────────────────────────────────────────────────────────
  2446 +// W3 · 产出边界守卫(27-P3B §四 W3)
  2447 +// ────────────────────────────────────────────────────────────────
  2448 +
  2449 +/**
  2450 + * **能拦住什么、拦不住什么,先说清楚**(27-P3B §四 W3 的状态栏同文):
  2451 + *
  2452 + * 真 CLI 是子进程直接写盘、还带着 `--dangerously-skip-permissions`,引擎**不可能**在写发生的
  2453 + * 那一刻拦住它。所以这里落的是「事后取证 + 结算拒收」,不是沙箱:
  2454 + *
  2455 + * 能做到:① 调用前后各扫一次**宿主仓库**的 `git status --porcelain`,差集就是这次调用在
  2456 + * 仓库里留下的痕迹;有痕迹就把节点判失败(success 也降级成 fail)并写 timeline。
  2457 + * 这正好覆盖已经实际发生过的那次事故(写 server/routes/echo.js 并改 server/index.js)。
  2458 + * ② 项目根本身过 isSensitivePath 黑名单(调用前,真强制)。
  2459 + * 做不到:① 宿主仓库**之外**的写入(`~/Documents/别的项目`、`/tmp`)完全看不见;
  2460 + * ② 仓库内被 .gitignore 忽略的路径(含 `data/companies/`)看不见;
  2461 + * ③ 同一个文件在调用前就已经是 modified、调用中又被改一次 → porcelain 行不变,看不见;
  2462 + * ④ 越界的写**已经发生**,守卫只拒收结果,**绝不自动回滚**——回滚等于替开发者
  2463 + * 丢弃未提交的改动,代价方向比误判更贵;
  2464 + * ⑤ 别人(开发者本人、并行的另一个 agent)在同一时刻改仓库会被算到本节点头上(误判)。
  2465 + * 真正的隔离要么改 AgentManager 让 CLI 的 cwd/权限落在项目根(`agent.workDir` 现在写死是
  2466 + * agent 自己的目录,不归本文件),要么上容器/文件系统级沙箱。两者都不在本轮。
  2467 + *
  2468 + * 守卫只在**真会起 CLI 的执行器**下开(默认执行器 / 默认规划器):测试注入的假 executor
  2469 + * 是进程内函数,扫仓库既没意义又要给 719 个用例每人加两次 git 调用。
  2470 + */
  2471 +function boundaryGuardApplicable(ctx) {
  2472 + return ctx.nodeKind === 'subgraph'
  2473 + ? planWorkGraph === defaultPlanWorkGraph
  2474 + : agentExecutor === defaultAgentExecutor;
  2475 +}
  2476 +
  2477 +function hostRepoStatus() {
  2478 + try {
  2479 + return require('child_process').execFileSync('git', ['status', '--porcelain'], {
  2480 + cwd: REPO_ROOT, encoding: 'utf8', timeout: 20_000, maxBuffer: 8 * 1024 * 1024,
  2481 + });
  2482 + } catch {
  2483 + return null; // 不是 git 仓库 / 没有 git / 超时:守卫不可用,如实记一条,不影响执行
  2484 + }
  2485 +}
  2486 +
  2487 +/**
  2488 + * 两次 `git status --porcelain` 的差集 = 本次调用期间新出现的仓库改动。
  2489 + * 纯函数,唯一调用方是 applyBoundaryGuard(导出只为让它能被单测直接验)。
  2490 + */
  2491 +function diffRepoStatus(before, after) {
  2492 + const seen = new Set(String(before == null ? '' : before).split('\n').map((l) => l.trim()).filter(Boolean));
  2493 + return String(after == null ? '' : after)
  2494 + .split('\n').map((l) => l.trim()).filter(Boolean)
  2495 + .filter((l) => !seen.has(l));
  2496 +}
  2497 +
  2498 +/** 调用前拍一张宿主仓库快照;守卫不适用/不可用时返回 null */
  2499 +function beginBoundaryGuard(ctx) {
  2500 + if (!boundaryGuardApplicable(ctx)) return null;
  2501 + const snap = hostRepoStatus();
  2502 + if (snap === null) {
  2503 + timelineNote(ctx, 'node.boundary', '边界守卫不可用(宿主仓库 git status 跑不了),本次调用不做越界检测', {
  2504 + node_run_id: ctx.nodeRunId, attempt: ctx.attempt, degraded: 'boundary_guard_unavailable',
  2505 + });
  2506 + }
  2507 + return snap;
  2508 +}
  2509 +
  2510 +/**
  2511 + * 调用后比对。发现越界 → 记 timeline + **把结果降级成 fail**(引擎不发明 fail_reason,
  2512 + * 越界走的是那条兜底边)。subgraph 的 pending 不降级:它的子 run 还在跑,就地判失败会
  2513 + * 留下一张没人回写的孤儿子 run,只留痕。
  2514 + */
  2515 +function applyBoundaryGuard(ctx, before, outcome) {
  2516 + if (before === null || before === undefined) return outcome;
  2517 + const after = hostRepoStatus();
  2518 + if (after === null) return outcome;
  2519 + const escapes = diffRepoStatus(before, after);
  2520 + if (escapes.length === 0) return outcome;
  2521 +
  2522 + const shown = escapes.slice(0, 20);
  2523 + timelineNote(ctx, 'node.boundary',
  2524 + `越界写入:本次调用在宿主仓库留下 ${escapes.length} 处改动(应写在项目根 ${ctx.projectRoot || '(未分配)'} 内)`, {
  2525 + node_run_id: ctx.nodeRunId, attempt: ctx.attempt, violation: 'boundary_escape',
  2526 + project_root: ctx.projectRoot || null, repo_root: REPO_ROOT,
  2527 + count: escapes.length, entries: shown,
  2528 + note: '引擎不会自动回滚这些改动(见 W3 的能力边界),请人工核对 git status',
  2529 + });
  2530 +
  2531 + if (outcome && outcome.status === 'pending') return outcome;
  2532 + const banner = `[边界守卫] 本次调用在宿主仓库(${REPO_ROOT})留下 ${escapes.length} 处改动,`
  2533 + + `按越界判失败。改动清单:\n${shown.join('\n')}`;
  2534 + return {
  2535 + ...(outcome || {}),
  2536 + status: 'fail',
  2537 + failReason: (outcome && outcome.failReason) || null,
  2538 + output: `${banner}\n\n${(outcome && outcome.output) || ''}`,
  2539 + };
  2540 +}
  2541 +
2287 function readJsonOrNull(file) { 2542 function readJsonOrNull(file) {
2288 try { 2543 try {
2289 return JSON.parse(fs.readFileSync(file, 'utf8')); 2544 return JSON.parse(fs.readFileSync(file, 'utf8'));
@@ -2552,6 +2807,7 @@ function describeArtifactContract(ctx) { @@ -2552,6 +2807,7 @@ function describeArtifactContract(ctx) {
2552 const dir = safeNodeArtifactDir(ctx); 2807 const dir = safeNodeArtifactDir(ctx);
2553 if (!dir) return ''; // run 缺 company_id/root_run_id:拼不出目录就不编一个假的塞给 agent 2808 if (!dir) return ''; // run 缺 company_id/root_run_id:拼不出目录就不编一个假的塞给 agent
2554 return [ 2809 return [
  2810 + ...describeWriteBoundary(ctx, dir),
2555 '', 2811 '',
2556 '', 2812 '',
2557 '[节点产物目录]', 2813 '[节点产物目录]',
@@ -2583,6 +2839,40 @@ function describeArtifactContract(ctx) { @@ -2583,6 +2839,40 @@ function describeArtifactContract(ctx) {
2583 ].join('\n'); 2839 ].join('\n');
2584 } 2840 }
2585 2841
  2842 +/**
  2843 + * 写入边界进提示词(W2 的「告诉它写哪」+ W3 的「不许写别处」)。
  2844 + *
  2845 + * 这是**唯一在真 CLI 跑起来之前就能生效的一半**:引擎拦不住子进程写盘(见 applyBoundaryGuard),
  2846 + * 提示词是「必要但不充分」的那一半,事后扫描是另一半。两半都写在同一个文件里,改一处
  2847 + * 不会忘了另一处。
  2848 + *
  2849 + * @param {string} artifactDir 节点私有目录(调用方已算好,避免重复拼)
  2850 + * @returns {string[]} 拼进任务描述的行;调用方负责 join
  2851 + */
  2852 +function describeWriteBoundary(ctx, artifactDir) {
  2853 + const root = (ctx && ctx.projectRoot) || null;
  2854 + const lines = ['', '', '[写入边界 —— 硬约束,越界会被判失败]'];
  2855 + if (root) {
  2856 + lines.push(
  2857 + `本次任务的项目根:${root}`,
  2858 + '**一切代码 / 测试 / 配置 / 交付物都写在这个项目根里面**(它是个空目录,结构你自己定)。',
  2859 + `你只被允许写两个位置:① 上面这个项目根及其子目录;② 你的节点私有目录 ${artifactDir}(结构化产出/归因/契约)。`,
  2860 + );
  2861 + } else {
  2862 + lines.push(
  2863 + '本次任务**没有分配到项目根**(分配失败,见 timeline)。',
  2864 + `在拿到项目根之前,你只被允许写你的节点私有目录 ${artifactDir},不要在别处创建任何文件。`,
  2865 + );
  2866 + }
  2867 + lines.push(
  2868 + `禁止写这两处之外的任何路径,**尤其是 ${REPO_ROOT}**(这是引擎自己的源码仓库,不是你的工作区)。`,
  2869 + '写文件一律用上面给的**绝对路径**拼接:你的进程 cwd 不是项目根,用相对路径会写到别人家里。',
  2870 + '引擎会在你这次调用前后各扫一次宿主仓库的 git 状态;发现越界改动会把本节点判失败并留痕'
  2871 + + '(引擎不会替你回滚,越界等于这一轮白跑)。',
  2872 + );
  2873 + return lines;
  2874 +}
  2875 +
2586 /** nodeArtifactDir 的不抛版本(run 缺列时返回 null) */ 2876 /** nodeArtifactDir 的不抛版本(run 缺列时返回 null) */
2587 function safeNodeArtifactDir(ctx) { 2877 function safeNodeArtifactDir(ctx) {
2588 try { 2878 try {
@@ -2676,6 +2966,12 @@ module.exports = { @@ -2676,6 +2966,12 @@ module.exports = {
2676 setPlanWorkGraph, 2966 setPlanWorkGraph,
2677 extractPlannedGraph, 2967 extractPlannedGraph,
2678 2968
  2969 + // W2/W3 项目根与边界守卫(27-P3B)。
  2970 + // ensureProjectRoot 的真实调用方是 advanceOne,diffRepoStatus 的是 applyBoundaryGuard;
  2971 + // 导出只是让单测能直接验这两条判据,不是新开一圈公开面。
  2972 + ensureProjectRoot,
  2973 + diffRepoStatus,
  2974 +
2679 // 选边 / 熔断 / 透传 2975 // 选边 / 熔断 / 透传
2680 selectFailEdge, 2976 selectFailEdge,
2681 handleBackwardEdge, 2977 handleBackwardEdge,
server/services/graph-seed-service.js
@@ -5,21 +5,161 @@ @@ -5,21 +5,161 @@
5 * seedGlobalAgents() — system/agents/* → system_agents (source_kind='global_agent') 5 * seedGlobalAgents() — system/agents/* → system_agents (source_kind='global_agent')
6 * seedTemplates() — system/templates/* → system_agents (source_kind='template') 6 * seedTemplates() — system/templates/* → system_agents (source_kind='template')
7 * seedCompanyAgents() — data/companies/{id}/company.json.agents[] → system_agents (source_kind='company_hired') + org_graph_nodes 7 * seedCompanyAgents() — data/companies/{id}/company.json.agents[] → system_agents (source_kind='company_hired') + org_graph_nodes
8 - * (T4 adds verifyCounts() here later.) 8 + * verifyCounts() — 独立重扫目录做行数交叉核对(T4)
  9 + * reconcileDanglingRows() — 回收指向已删目录的 company_hired 行 / 已删公司的 org 节点(P3B W1)
  10 + * runStartupSeed() — 服务启动时跑的那一组(reconcile + global + templates),见 server/index.js
9 * 11 *
10 * P0 阶段:只加不改,不涉及任何现有执行路径(company-service.js / agent-manager.js 等)。 12 * P0 阶段:只加不改,不涉及任何现有执行路径(company-service.js / agent-manager.js 等)。
  13 + * P3B W1 起本模块被接进服务启动流程(runStartupSeed),不再只是手动脚本。
11 */ 14 */
12 15
13 const fs = require('fs'); 16 const fs = require('fs');
14 const path = require('path'); 17 const path = require('path');
15 const { getDb } = require('./graph-db'); 18 const { getDb } = require('./graph-db');
16 19
17 -const ROOT = path.resolve(__dirname, '../..'); 20 +// GRAPH_SEED_ROOT 只是测试用的落地缝(与 graph-db.js 的 GRAPH_DB_PATH 同一套路):
  21 +// 单测把它指向临时目录,就能在完全受控的 system/ + data/companies/ 上跑种子与对账,
  22 +// 不再依赖真实仓库里有多少公司。生产不设这个变量,走仓库根。
  23 +const ROOT = path.resolve(process.env.GRAPH_SEED_ROOT || path.resolve(__dirname, '../..'));
18 const AGENTS_DIR = path.join(ROOT, 'system', 'agents'); 24 const AGENTS_DIR = path.join(ROOT, 'system', 'agents');
19 const TEMPLATES_DIR = path.join(ROOT, 'system', 'templates'); 25 const TEMPLATES_DIR = path.join(ROOT, 'system', 'templates');
20 const TEMPLATES_REGISTRY_PATH = path.join(TEMPLATES_DIR, '_registry.json'); 26 const TEMPLATES_REGISTRY_PATH = path.join(TEMPLATES_DIR, '_registry.json');
21 const COMPANIES_DIR = path.join(ROOT, 'data', 'companies'); 27 const COMPANIES_DIR = path.join(ROOT, 'data', 'companies');
22 28
  29 +// ───────────────────────── 悬空行回收(P3B W1)─────────────────────────
  30 +//
  31 +// 背景:`data/companies/{cid}/` 被手工删掉之后,seedCompanyAgents()/hire() 当初写进
  32 +// system_agents 的 `company_hired` 行留在库里,legacy_path 指着一个早已不存在的目录。
  33 +// 这些行占着 id 命名空间:`seedTemplates()` 扫到同名模板目录时会因为「id 已被别的
  34 +// source_kind 占用」而跳过,于是 `lookupSharedAgentMd()`(只认 source_kind='template')
  35 +// 对这些 id 恒返回 null,company-service.hire() 静默从「引用模式」退回「legacy 全量拷贝」——
  36 +// 结果就是模板改动对任何已雇公司都不生效。所以这不是洁癖,是一条真实的失效链。
  37 +
  38 +/**
  39 + * 扫描 data/companies/*\/company.json,收集**所有还活着的公司**声明用到的角色名。
  40 + *
  41 + * 这是判断「一行 company_hired 是不是还有人认领」的唯一依据,比只看 owner_company_id 宽:
  42 + * 同一个 agent id 可能被多家公司复用(seedCompanyAgents 对已存在的 id 只建节点、不重复建行),
  43 + * 只要还有任何一家活着的公司在 agents[] 里点名它,就不能回收。
  44 + *
  45 + * @returns {{ claimedRoles: Set<string>, liveCompanyIds: Set<string>, companiesDirPresent: boolean }}
  46 + */
  47 +function collectLiveCompanyClaims() {
  48 + const companiesDirPresent = fs.existsSync(COMPANIES_DIR);
  49 + const claimedRoles = new Set();
  50 + const liveCompanyIds = new Set();
  51 +
  52 + if (!companiesDirPresent) return { claimedRoles, liveCompanyIds, companiesDirPresent };
  53 +
  54 + for (const entry of fs.readdirSync(COMPANIES_DIR, { withFileTypes: true })) {
  55 + if (!entry.isDirectory()) continue;
  56 + const companyJsonPath = path.join(COMPANIES_DIR, entry.name, 'company.json');
  57 + if (!fs.existsSync(companyJsonPath)) continue; // 没有 company.json → 还不是一家公司
  58 +
  59 + liveCompanyIds.add(entry.name);
  60 + try {
  61 + const companyJson = JSON.parse(fs.readFileSync(companyJsonPath, 'utf-8'));
  62 + if (Array.isArray(companyJson.agents)) {
  63 + for (const roleName of companyJson.agents) claimedRoles.add(roleName);
  64 + }
  65 + } catch {
  66 + // company.json 坏了 → 公司仍然算活着(目录在),但读不出 agents[]。
  67 + // 这时宁可少回收:把整个 claim 判断放宽是危险的,只能退而求其次靠 fs 存在性兜底。
  68 + }
  69 + }
  70 +
  71 + return { claimedRoles, liveCompanyIds, companiesDirPresent };
  72 +}
  73 +
  74 +/**
  75 + * 一行 system_agents 是不是「悬空的 company_hired」。三个条件必须同时成立,缺一不回收:
  76 + * 1. source_kind === 'company_hired'(`global_agent` / `template` 行永不参与回收,
  77 + * 否则 system/agents 与 system/templates 同名 id 的冲突保护会被绕过);
  78 + * 2. legacy_path 指向的目录**不存在**(真实存在的公司私有 agent 一律不动 —— 这是防误伤的主闸);
  79 + * 3. 没有任何活着的公司在 company.json.agents[] 里点名这个 id
  80 + * (目录一时缺失但公司还声明着它 → 视为安装损坏,保留原行,交给人处理,不擅自改语义)。
  81 + *
  82 + * @param {{id: string, source_kind: string, legacy_path: string}} row
  83 + * @param {Set<string>} claimedRoles collectLiveCompanyClaims().claimedRoles
  84 + */
  85 +function isStaleCompanyHired(row, claimedRoles) {
  86 + if (!row || row.source_kind !== 'company_hired') return false;
  87 + if (claimedRoles.has(row.id)) return false;
  88 + if (!row.legacy_path) return true;
  89 + return !fs.existsSync(path.resolve(ROOT, row.legacy_path));
  90 +}
  91 +
  92 +/**
  93 + * 幂等的维护步骤:清掉两类悬空数据。跑第二遍时两个计数都是 0。
  94 + *
  95 + * A. **已删公司的 org_graph_nodes / org_graph_edges**:company_id 在磁盘上已经没有
  96 + * `data/companies/{cid}/company.json`。先清这一类,否则 B 里要删的 system_agents 行
  97 + * 还被这些幽灵节点外键引着,删不掉。
  98 + * B. **悬空的 company_hired 行**(判定见 isStaleCompanyHired)。
  99 + *
  100 + * 安全阀:
  101 + * - `data/companies/` 整个目录都不存在时**跳过 A**。分不清「一家公司都没有」和
  102 + * 「目录没挂上」,宁可不删。
  103 + * - 每行删除单独 try/catch:真被别处外键引着(work_graph_nodes.system_agent_id、
  104 + * work_graphs.owner_org_node_id 等)就保留并记进 retained,不让一行删不掉拖垮整轮启动。
  105 + * 这样以后新增引用表也不需要回来改这里。
  106 + *
  107 + * @returns {{ orgNodesRemoved: number, orgEdgesRemoved: number, ghostCompanyIds: string[],
  108 + * agentsRemoved: string[], retained: {id: string, reason: string}[] }}
  109 + */
  110 +function reconcileDanglingRows() {
  111 + const db = getDb();
  112 + const { claimedRoles, liveCompanyIds, companiesDirPresent } = collectLiveCompanyClaims();
  113 +
  114 + const ghostCompanyIds = [];
  115 + let orgNodesRemoved = 0;
  116 + let orgEdgesRemoved = 0;
  117 + const agentsRemoved = [];
  118 + const retained = [];
  119 +
  120 + // ── A. 已删公司的 org 节点/边 ──
  121 + if (companiesDirPresent) {
  122 + const nodeCompanyIds = db
  123 + .prepare(`SELECT DISTINCT company_id FROM org_graph_nodes`)
  124 + .all()
  125 + .map(r => r.company_id);
  126 +
  127 + for (const companyId of nodeCompanyIds) {
  128 + if (liveCompanyIds.has(companyId)) continue;
  129 + ghostCompanyIds.push(companyId);
  130 + try {
  131 + db.transaction(() => {
  132 + orgEdgesRemoved += db.prepare(`DELETE FROM org_graph_edges WHERE company_id = ?`).run(companyId).changes;
  133 + // reports_to_node_id 是同表自引用,整批删之前先解开,免得删除顺序踩到外键
  134 + db.prepare(`UPDATE org_graph_nodes SET reports_to_node_id = NULL WHERE company_id = ?`).run(companyId);
  135 + orgNodesRemoved += db.prepare(`DELETE FROM org_graph_nodes WHERE company_id = ?`).run(companyId).changes;
  136 + })();
  137 + } catch (err) {
  138 + retained.push({ id: `org_graph_nodes[${companyId}]`, reason: err.message });
  139 + }
  140 + }
  141 + }
  142 +
  143 + // ── B. 悬空 company_hired 行 ──
  144 + const hiredRows = db
  145 + .prepare(`SELECT id, source_kind, legacy_path FROM system_agents WHERE source_kind = 'company_hired'`)
  146 + .all();
  147 +
  148 + for (const row of hiredRows) {
  149 + if (!isStaleCompanyHired(row, claimedRoles)) continue;
  150 + try {
  151 + db.prepare(`DELETE FROM system_agents WHERE id = ? AND source_kind = 'company_hired'`).run(row.id);
  152 + agentsRemoved.push(row.id);
  153 + } catch (err) {
  154 + // 还被别的表外键引着 → 留着。seedTemplates() 的接管分支能把其中的模板 id 就地转正,
  155 + // 不需要先删成功。
  156 + retained.push({ id: row.id, reason: err.message });
  157 + }
  158 + }
  159 +
  160 + return { orgNodesRemoved, orgEdgesRemoved, ghostCompanyIds, agentsRemoved, retained };
  161 +}
  162 +
23 /** 163 /**
24 * 扫描 system/agents/*,过滤掉没有 agent.md 的目录(排除 templates/ 等杂物), 164 * 扫描 system/agents/*,过滤掉没有 agent.md 的目录(排除 templates/ 等杂物),
25 * 逐个 upsert 到 system_agents,scope='public',source_kind='global_agent'。 165 * 逐个 upsert 到 system_agents,scope='public',source_kind='global_agent'。
@@ -104,7 +244,15 @@ function seedGlobalAgents() { @@ -104,7 +244,15 @@ function seedGlobalAgents() {
104 * 模板目录的数据,同一行却仍标着 source_kind='global_agent',数据会被污染。 244 * 模板目录的数据,同一行却仍标着 source_kind='global_agent',数据会被污染。
105 * 只有当已存在的行本身就是 source_kind='template' 时才走 UPDATE(保证本函数自身可重复执行)。 245 * 只有当已存在的行本身就是 source_kind='template' 时才走 UPDATE(保证本函数自身可重复执行)。
106 * 246 *
107 - * @returns {{ scanned: number, seeded: number, ids: string[], registryMatched: string[], skipped: {id: string, existingSourceKind: string}[] }} 247 + * **P3B W1 的例外(接管)**:占位的那行如果是一条**悬空的 company_hired**
  248 + * (isStaleCompanyHired:目录没了 + 没有任何活着的公司认领),就地转正成 template 行 ——
  249 + * scope/owner_company_id/source_kind/legacy_path 全部改写。原来的「一律 skip」让污染不可逆:
  250 + * 公司目录删了、行还占着模板 id,模板永远补不回来(现状核实第 3-5 条那条失效链)。
  251 + * 转正而不是删了重建,是为了让还引着这行的 org_graph_nodes 外键继续有效。
  252 + * `global_agent` 占位仍然照旧 skip —— 那是同名不同源,不是污染。
  253 + *
  254 + * @returns {{ scanned: number, seeded: number, ids: string[], registryMatched: string[],
  255 + * skipped: {id: string, existingSourceKind: string}[], reclaimed: string[] }}
108 */ 256 */
109 function seedTemplates() { 257 function seedTemplates() {
110 const db = getDb(); 258 const db = getDb();
@@ -127,30 +275,43 @@ function seedTemplates() { @@ -127,30 +275,43 @@ function seedTemplates() {
127 275
128 const now = new Date().toISOString(); 276 const now = new Date().toISOString();
129 277
130 - const findExisting = db.prepare(`SELECT source_kind FROM system_agents WHERE id = ?`); 278 + const findExisting = db.prepare(`SELECT id, source_kind, legacy_path FROM system_agents WHERE id = ?`);
131 279
  280 + // ON CONFLICT 分支必须连 scope/owner_company_id/source_kind 一起改写,接管才算完成:
  281 + // 只改 legacy_path 会留下一行「legacy_path 指向 system/templates/ 却仍标着 company_hired」
  282 + // 的半吊子记录,lookupSharedAgentMd 照旧查不到。对本来就是 template 的行这三列是原值写原值。
132 const upsert = db.prepare(` 283 const upsert = db.prepare(`
133 INSERT INTO system_agents (id, name, scope, owner_company_id, source_kind, legacy_path, status, benchmark_score, created_at, updated_at) 284 INSERT INTO system_agents (id, name, scope, owner_company_id, source_kind, legacy_path, status, benchmark_score, created_at, updated_at)
134 VALUES (@id, @name, 'public', NULL, 'template', @legacy_path, @status, @benchmark_score, @created_at, @updated_at) 285 VALUES (@id, @name, 'public', NULL, 'template', @legacy_path, @status, @benchmark_score, @created_at, @updated_at)
135 ON CONFLICT(id) DO UPDATE SET 286 ON CONFLICT(id) DO UPDATE SET
136 name = excluded.name, 287 name = excluded.name,
  288 + scope = excluded.scope,
  289 + owner_company_id = excluded.owner_company_id,
  290 + source_kind = excluded.source_kind,
137 legacy_path = excluded.legacy_path, 291 legacy_path = excluded.legacy_path,
138 status = excluded.status, 292 status = excluded.status,
139 benchmark_score = excluded.benchmark_score, 293 benchmark_score = excluded.benchmark_score,
140 updated_at = excluded.updated_at 294 updated_at = excluded.updated_at
141 `); 295 `);
142 296
  297 + const { claimedRoles } = collectLiveCompanyClaims();
  298 +
143 const ids = []; 299 const ids = [];
144 const registryMatched = []; 300 const registryMatched = [];
145 const skipped = []; 301 const skipped = [];
  302 + const reclaimed = [];
146 const seedAll = db.transaction((dirEntries) => { 303 const seedAll = db.transaction((dirEntries) => {
147 for (const entry of dirEntries) { 304 for (const entry of dirEntries) {
148 const id = entry.name; 305 const id = entry.name;
149 306
150 const existing = findExisting.get(id); 307 const existing = findExisting.get(id);
151 if (existing && existing.source_kind !== 'template') { 308 if (existing && existing.source_kind !== 'template') {
152 - skipped.push({ id, existingSourceKind: existing.source_kind });  
153 - continue; // id already owned by another source (e.g. global_agent) — don't clobber it 309 + if (isStaleCompanyHired(existing, claimedRoles)) {
  310 + reclaimed.push(id); // 悬空的 company_hired 占位 → 下面的 upsert 就地转正成 template
  311 + } else {
  312 + skipped.push({ id, existingSourceKind: existing.source_kind });
  313 + continue; // id already owned by a live source (e.g. global_agent) — don't clobber it
  314 + }
154 } 315 }
155 316
156 const dir = path.join(TEMPLATES_DIR, id); 317 const dir = path.join(TEMPLATES_DIR, id);
@@ -187,7 +348,7 @@ function seedTemplates() { @@ -187,7 +348,7 @@ function seedTemplates() {
187 348
188 seedAll(entries); 349 seedAll(entries);
189 350
190 - return { scanned: entries.length, seeded: ids.length, ids, registryMatched, skipped }; 351 + return { scanned: entries.length, seeded: ids.length, ids, registryMatched, skipped, reclaimed };
191 } 352 }
192 353
193 /** 354 /**
@@ -436,9 +597,60 @@ function verifyCounts() { @@ -436,9 +597,60 @@ function verifyCounts() {
436 }; 597 };
437 } 598 }
438 599
  600 +/**
  601 + * 服务启动时跑的那一组(P3B W1)。调用方:server/index.js、server/agent-server.js。
  602 + *
  603 + * 跑 reconcileDanglingRows() → seedGlobalAgents() → seedTemplates(),**不跑
  604 + * seedCompanyAgents()**。取舍理由:前三个的数据源是仓库自带的 `system/` 目录,
  605 + * 属于「代码的一部分」,每次启动按最新目录对齐才是对的;而 seedCompanyAgents() 的数据源是
  606 + * `data/companies/*\/company.json`,那是运行时状态,归 company-service 的 hire()/fire() 管,
  607 + * 启动时从 company.json 反推 org_graph_nodes 会跟这两条写入路径抢所有权。它继续留给
  608 + * `node scripts/graph-seed.js` 手动跑。
  609 + *
  610 + * 顺序不能换:reconcile 必须在 seedTemplates 之前,否则悬空行还占着模板 id。
  611 + * 全同步、全幂等,51 个模板目录 + 20 家公司量级在毫秒级,放在 listen() 之前不影响启动。
  612 + *
  613 + * @returns {{ reconciled: object, globalAgents: object, templates: object }}
  614 + */
  615 +function runStartupSeed() {
  616 + const reconciled = reconcileDanglingRows();
  617 + const globalAgents = seedGlobalAgents();
  618 + const templates = seedTemplates();
  619 + return { reconciled, globalAgents, templates };
  620 +}
  621 +
  622 +/**
  623 + * runStartupSeed() 的「失败不许挡住服务启动」包装 —— 两个 server 入口直接调这个。
  624 + * 种子失败只影响 agent 元数据的新鲜度,不该让整个进程起不来;打日志降级即可。
  625 + *
  626 + * @returns {object|null} 成功时是 runStartupSeed() 的汇总,失败时 null
  627 + */
  628 +function runStartupSeedSafely() {
  629 + try {
  630 + const summary = runStartupSeed();
  631 + const r = summary.reconciled;
  632 + const parts = [
  633 + `global_agent ${summary.globalAgents.seeded}`,
  634 + `template ${summary.templates.seeded}`,
  635 + ];
  636 + if (summary.templates.reclaimed.length) parts.push(`回收占位 id ${summary.templates.reclaimed.join('/')}`);
  637 + if (r.agentsRemoved.length) parts.push(`清悬空 company_hired ${r.agentsRemoved.length} 行`);
  638 + if (r.orgNodesRemoved) parts.push(`清已删公司 org 节点 ${r.orgNodesRemoved} 行`);
  639 + if (r.retained.length) parts.push(`⚠️ ${r.retained.length} 行被外键引用暂留`);
  640 + console.log(`[graph-seed] ${parts.join(',')}`);
  641 + return summary;
  642 + } catch (err) {
  643 + console.error(`[graph-seed] 启动种子失败,已降级跳过(不影响服务启动): ${err.message}`);
  644 + return null;
  645 + }
  646 +}
  647 +
439 module.exports = { 648 module.exports = {
440 seedGlobalAgents, 649 seedGlobalAgents,
441 seedTemplates, 650 seedTemplates,
442 seedCompanyAgents, 651 seedCompanyAgents,
443 verifyCounts, 652 verifyCounts,
  653 + reconcileDanglingRows,
  654 + runStartupSeed,
  655 + runStartupSeedSafely,
444 }; 656 };
server/tests/unit/graph-boundary.test.js 0 → 100644
  1 +/**
  2 + * Unit Tests — graph-engine 的项目根与边界守卫(27-P3B W2/W3)
  3 + *
  4 + * 验的是「产生 → 传递 → 落库 → 读回」四环:
  5 + * W2-1 起一个 run,节点收到的任务描述里带着项目根的绝对路径(传递)
  6 + * W2-2 项目根落到 runs/{rootRunId}/artifacts/project-root.json(落库)
  7 + * W2-3 同一个 root run 的第二个节点读回**同一个**项目根,不重新分配(读回,幂等)
  8 + * W3-1 diffRepoStatus 只报「本次调用期间新出现」的那几行(守卫的判据本身)
  9 + *
  10 + * 隔离手法同 graph-engine.test.js:先设 GRAPH_DB_PATH / GRAPH_RUNS_DIR 再 require,
  11 + * 仓库 data/ 全程不被碰;agent 调用走注入的假 executor,不起 Claude CLI。
  12 + */
  13 +import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  14 +import fs from 'fs';
  15 +import os from 'os';
  16 +import path from 'path';
  17 +
  18 +describe('graph-engine 项目根与边界守卫', () => {
  19 + let wdb;
  20 + let engine;
  21 + let runsDir;
  22 + const TEST_DB_PATH = '/tmp/test-graph-boundary.sqlite';
  23 +
  24 + beforeAll(() => {
  25 + for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) {
  26 + try { fs.unlinkSync(f); } catch {}
  27 + }
  28 + process.env.GRAPH_DB_PATH = TEST_DB_PATH;
  29 + runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-boundary-runs-'));
  30 + process.env.GRAPH_RUNS_DIR = runsDir;
  31 + for (const m of [
  32 + '../../services/graph-db',
  33 + '../../services/graph-service',
  34 + '../../services/org-graph-service',
  35 + '../../services/work-graph-db',
  36 + '../../services/graph-run-sse',
  37 + '../../services/graph-engine',
  38 + ]) {
  39 + delete require.cache[require.resolve(m)];
  40 + }
  41 + wdb = require('../../services/work-graph-db');
  42 + engine = require('../../services/graph-engine');
  43 + });
  44 +
  45 + afterAll(() => {
  46 + for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) {
  47 + try { fs.unlinkSync(f); } catch {}
  48 + }
  49 + delete process.env.GRAPH_DB_PATH;
  50 + try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {}
  51 + delete process.env.GRAPH_RUNS_DIR;
  52 + // 同一个进程里后续文件再 require 时要拿回没有 override 的那份
  53 + for (const m of ['../../services/graph-run-sse', '../../services/graph-engine']) {
  54 + delete require.cache[require.resolve(m)];
  55 + }
  56 + });
  57 +
  58 + function ensureAgents(ids) {
  59 + const db = wdb.getDb();
  60 + const ts = new Date().toISOString();
  61 + const ins = db.prepare(`
  62 + INSERT OR IGNORE INTO system_agents
  63 + (id, name, scope, owner_company_id, source_kind, legacy_path, status, created_at, updated_at)
  64 + VALUES (?, ?, 'public', NULL, 'global_agent', ?, 'active', ?, ?)
  65 + `);
  66 + for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, ts, ts);
  67 + }
  68 +
  69 + it('W2:项目根进提示词、落库到 artifacts/project-root.json,第二个节点读回同一个', async () => {
  70 + const companyId = 'co-boundary-1';
  71 + ensureAgents(['impl', 'check']);
  72 + const g = wdb.createWorkGraph({
  73 + companyId,
  74 + nodes: [
  75 + { nodeName: 'impl', systemAgentId: 'impl' },
  76 + { nodeName: 'check', systemAgentId: 'check' },
  77 + ],
  78 + edges: [{ from: 'impl', to: 'check', condition: 'success' }],
  79 + });
  80 + expect(g.errors || []).toEqual([]);
  81 + const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId });
  82 + engine.markRunReady(run.id);
  83 +
  84 + const tasks = [];
  85 + engine.setAgentExecutor(async (ctx) => {
  86 + tasks.push({ node: ctx.nodeName, task: ctx.task, projectRoot: ctx.projectRoot });
  87 + return { status: 'success' };
  88 + });
  89 +
  90 + engine.startRun(run.id, { mode: 'manual' });
  91 + await engine.advanceOne(run.id);
  92 + await engine.advanceOne(run.id);
  93 + engine.setAgentExecutor(null);
  94 +
  95 + // ① 落库
  96 + const recFile = path.join(runsDir, run.id, 'artifacts', 'project-root.json');
  97 + expect(fs.existsSync(recFile)).toBe(true);
  98 + const rec = JSON.parse(fs.readFileSync(recFile, 'utf8'));
  99 + expect(rec.root_path).toBeTruthy();
  100 + expect(fs.existsSync(rec.root_path)).toBe(true); // 目录真的被建出来了
  101 +
  102 + // ② 传递:两个节点都拿到了项目根,且提示词里是绝对路径
  103 + expect(tasks.length).toBe(2);
  104 + for (const t of tasks) {
  105 + expect(t.projectRoot).toBe(rec.root_path);
  106 + expect(t.task).toContain(rec.root_path);
  107 + expect(t.task).toContain('[写入边界');
  108 + }
  109 +
  110 + // ③ 读回幂等:第二个节点没有另分配一个根
  111 + expect(tasks[0].projectRoot).toBe(tasks[1].projectRoot);
  112 + });
  113 +
  114 + it('W3:diffRepoStatus 只报本次调用期间新出现的仓库改动', () => {
  115 + const before = ' M server/services/graph-engine.js\n?? scratch/a.txt\n';
  116 + const after = ' M server/services/graph-engine.js\n?? scratch/a.txt\n?? server/routes/echo.js\n M server/index.js\n';
  117 + expect(engine.diffRepoStatus(before, after)).toEqual([
  118 + '?? server/routes/echo.js',
  119 + 'M server/index.js',
  120 + ]);
  121 + // 没有新增就是没有越界;空快照也不该炸
  122 + expect(engine.diffRepoStatus(after, after)).toEqual([]);
  123 + expect(engine.diffRepoStatus(null, '')).toEqual([]);
  124 + });
  125 +});
server/tests/unit/graph-seed-service.test.js
1 /** 1 /**
2 - * Unit Tests — graph-seed-service.js verifyCounts() (T4)  
3 - * Runs the real seed functions against the actual repo fs (system/agents/,  
4 - * system/templates/, data/companies/) into a throwaway GRAPH_DB_PATH, then  
5 - * asserts verifyCounts() reports ok:true against that freshly-seeded data,  
6 - * and that it actually catches a mismatch when the DB drifts from the fs. 2 + * Unit Tests — graph-seed-service.js
  3 + *
  4 + * 两块:
  5 + *
  6 + * A. **hermetic 夹具根**(P3B W1 起):`GRAPH_SEED_ROOT` 指向 /tmp 下现造的
  7 + * system/agents + system/templates + data/companies,配一个一次性 GRAPH_DB_PATH。
  8 + * 行数因此是**写死的精确值**而不是「跟着仓库现状走」——原来的写法直接对着生产
  9 + * `data/graph.sqlite` 与真实 `data/companies/` 断言,任何人手建一家公司都会让它变红。
  10 + * 悬空行回收 / 模板接管 / 防误伤三条规则都在这里锁。
  11 + *
  12 + * B. **真实仓库根回归**(保留原有两条用例,断言未放松):seed 一遍真实
  13 + * `system/` + `data/companies/` 到一次性 DB,verifyCounts() 必须 ok,
  14 + * 且往 DB 里塞一行 fs 不背书的数据时必须被抓出来。
7 */ 15 */
8 -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; 16 +import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
9 import fs from 'fs'; 17 import fs from 'fs';
  18 +import path from 'path';
  19 +import os from 'os';
10 20
11 -describe('graph-seed-service verifyCounts', () => { 21 +/** 换掉 GRAPH_SEED_ROOT / GRAPH_DB_PATH 之后必须重新 require,两个模块都在顶层读了常量 */
  22 +function reloadServices() {
  23 + delete require.cache[require.resolve('../../services/graph-db')];
  24 + delete require.cache[require.resolve('../../services/graph-seed-service')];
  25 + return {
  26 + graphDb: require('../../services/graph-db'),
  27 + seedService: require('../../services/graph-seed-service'),
  28 + };
  29 +}
  30 +
  31 +function write(file, content) {
  32 + fs.mkdirSync(path.dirname(file), { recursive: true });
  33 + fs.writeFileSync(file, content);
  34 +}
  35 +
  36 +describe('graph-seed-service (hermetic fixture root)', () => {
  37 + let root;
  38 + let dbPath;
  39 + let seedService;
  40 + let graphDb;
  41 +
  42 + /** 夹具:1 个 global agent、3 个模板目录(其中 alpha 与 global agent 同名)、1 家活着的公司 */
  43 + function buildFixture() {
  44 + root = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-seed-fixture-'));
  45 + dbPath = path.join(root, 'graph.sqlite');
  46 +
  47 + write(path.join(root, 'system/agents/alpha/agent.md'), '# alpha global');
  48 + write(path.join(root, 'system/agents/alpha/config.json'), JSON.stringify({ name: 'Alpha Global' }));
  49 + fs.mkdirSync(path.join(root, 'system/agents/no-agent-md'), { recursive: true }); // 无 agent.md → 不算 global
  50 +
  51 + write(path.join(root, 'system/templates/_registry.json'), JSON.stringify([{ id: 'tmpl-a', benchmarkScore: 7.5 }]));
  52 + write(path.join(root, 'system/templates/tmpl-a/agent.md'), '# tmpl-a');
  53 + write(path.join(root, 'system/templates/tmpl-a/config.json'), JSON.stringify({ name: 'Template A' }));
  54 + write(path.join(root, 'system/templates/tmpl-b/agent.md'), '# tmpl-b'); // 无 config.json
  55 + write(path.join(root, 'system/templates/alpha/agent.md'), '# alpha template'); // 与 global agent 撞 id
  56 +
  57 + write(
  58 + path.join(root, 'data/companies/live-co/company.json'),
  59 + JSON.stringify({ id: 'live-co', agents: ['tmpl-a', 'private-role'] })
  60 + );
  61 + write(path.join(root, 'data/companies/live-co/agents/private-role/agent.md'), '# private');
  62 +
  63 + process.env.GRAPH_SEED_ROOT = root;
  64 + process.env.GRAPH_DB_PATH = dbPath;
  65 + ({ graphDb, seedService } = reloadServices());
  66 + }
  67 +
  68 + function rows() {
  69 + return graphDb.getDb().prepare('SELECT id, source_kind, scope, owner_company_id, legacy_path FROM system_agents ORDER BY id').all();
  70 + }
  71 + function rowOf(id) {
  72 + return rows().find(r => r.id === id) || null;
  73 + }
  74 + function orgNodes() {
  75 + return graphDb.getDb().prepare('SELECT company_id, system_agent_id, role_name FROM org_graph_nodes ORDER BY company_id, role_name').all();
  76 + }
  77 +
  78 + /** 直接造一行 company_hired(模拟历史遗留 / hire() 写下的行) */
  79 + function insertHired(id, ownerCompanyId, legacyPath) {
  80 + const now = new Date().toISOString();
  81 + graphDb.getDb().prepare(`
  82 + INSERT INTO system_agents (id, name, scope, owner_company_id, source_kind, legacy_path, status, benchmark_score, created_at, updated_at)
  83 + VALUES (?, ?, 'company', ?, 'company_hired', ?, 'active', NULL, ?, ?)
  84 + `).run(id, id, ownerCompanyId, legacyPath, now, now);
  85 + }
  86 +
  87 + beforeEach(() => buildFixture());
  88 +
  89 + afterEach(() => {
  90 + delete process.env.GRAPH_SEED_ROOT;
  91 + delete process.env.GRAPH_DB_PATH;
  92 + try { fs.rmSync(root, { recursive: true, force: true }); } catch {}
  93 + });
  94 +
  95 + it('seeds exact row counts from the fixture and passes verifyCounts', () => {
  96 + seedService.seedGlobalAgents();
  97 + seedService.seedTemplates();
  98 + seedService.seedCompanyAgents();
  99 +
  100 + expect(rows().map(r => [r.id, r.source_kind])).toEqual([
  101 + ['alpha', 'global_agent'], // system/agents/alpha 有 agent.md
  102 + ['private-role', 'company_hired'], // live-co 独有角色
  103 + ['tmpl-a', 'template'],
  104 + ['tmpl-b', 'template'],
  105 + ]);
  106 + // system/templates/alpha 与 global agent 撞 id:仍然被跳过,绝不被模板覆盖
  107 + expect(rowOf('alpha').legacy_path).toBe('system/agents/alpha');
  108 +
  109 + expect(orgNodes()).toEqual([
  110 + { company_id: 'live-co', system_agent_id: 'private-role', role_name: 'private-role' },
  111 + { company_id: 'live-co', system_agent_id: 'tmpl-a', role_name: 'tmpl-a' },
  112 + ]);
  113 +
  114 + const v = seedService.verifyCounts();
  115 + expect(v.mismatches).toEqual([]);
  116 + expect(v.ok).toBe(true);
  117 + expect(v.expected).toMatchObject({ global_agent: 1, template: 2, company_hired: 1 });
  118 + });
  119 +
  120 + it('picks up a template directory added after the first seed (W1: 新模板目录自动进表)', () => {
  121 + seedService.runStartupSeed();
  122 + expect(rowOf('tmpl-new')).toBeNull();
  123 +
  124 + write(path.join(root, 'system/templates/tmpl-new/agent.md'), '# brand new');
  125 + write(path.join(root, 'system/templates/tmpl-new/config.json'), JSON.stringify({ name: 'Brand New' }));
  126 +
  127 + seedService.runStartupSeed();
  128 +
  129 + expect(rowOf('tmpl-new')).toEqual({
  130 + id: 'tmpl-new',
  131 + source_kind: 'template',
  132 + scope: 'public',
  133 + owner_company_id: null,
  134 + legacy_path: 'system/templates/tmpl-new',
  135 + });
  136 + });
  137 +
  138 + it('reclaims a dangling company_hired row that squats on a template id', () => {
  139 + seedService.seedGlobalAgents();
  140 + // 历史遗留:公司 ghost-co 已被删,行还占着 tmpl-b 这个模板 id
  141 + insertHired('tmpl-b', 'ghost-co', 'data/companies/ghost-co/agents/tmpl-b');
  142 + expect(rowOf('tmpl-b').source_kind).toBe('company_hired');
  143 +
  144 + const summary = seedService.runStartupSeed();
  145 +
  146 + // reconcile 先删掉它(没有任何外键引着),seedTemplates 再按目录重新种回来
  147 + expect(summary.reconciled.agentsRemoved).toContain('tmpl-b');
  148 + expect(rowOf('tmpl-b')).toEqual({
  149 + id: 'tmpl-b',
  150 + source_kind: 'template',
  151 + scope: 'public',
  152 + owner_company_id: null,
  153 + legacy_path: 'system/templates/tmpl-b',
  154 + });
  155 + });
  156 +
  157 + it('takes over a squatting row in-place when a foreign key still points at it', () => {
  158 + seedService.seedGlobalAgents();
  159 + insertHired('tmpl-b', 'ghost-co', 'data/companies/ghost-co/agents/tmpl-b');
  160 + // 还活着的公司 live-co 有一个 org 节点引着这行 → reconcile 删不掉(外键)
  161 + graphDb.getDb().prepare(`
  162 + INSERT INTO org_graph_nodes (company_id, system_agent_id, role_name, created_at)
  163 + VALUES ('live-co', 'tmpl-b', 'tmpl-b', ?)
  164 + `).run(new Date().toISOString());
  165 +
  166 + const summary = seedService.runStartupSeed();
  167 +
  168 + expect(summary.reconciled.agentsRemoved).not.toContain('tmpl-b');
  169 + expect(summary.reconciled.retained.map(r => r.id)).toContain('tmpl-b');
  170 + // 删不掉就地转正:同一行 id 保留(外键继续有效),source_kind / scope / legacy_path 全部改写
  171 + expect(summary.templates.reclaimed).toContain('tmpl-b');
  172 + expect(rowOf('tmpl-b')).toEqual({
  173 + id: 'tmpl-b',
  174 + source_kind: 'template',
  175 + scope: 'public',
  176 + owner_company_id: null,
  177 + legacy_path: 'system/templates/tmpl-b',
  178 + });
  179 + // 引着它的节点必须还在(转正而不是删了重建的全部意义所在)
  180 + expect(orgNodes().some(n => n.system_agent_id === 'tmpl-b')).toBe(true);
  181 + });
  182 +
  183 + it('never reclaims rows a live company still backs (防误伤)', () => {
  184 + seedService.seedGlobalAgents();
  185 + seedService.seedTemplates();
  186 + seedService.seedCompanyAgents();
  187 +
  188 + // ① 目录真实存在的公司私有 agent
  189 + expect(rowOf('private-role').source_kind).toBe('company_hired');
  190 + // ② 目录没了、但 live-co 的 company.json 仍在 agents[] 里点名它
  191 + insertHired('tmpl-a-clone', 'live-co', 'data/companies/live-co/agents/tmpl-a-clone');
  192 + write(
  193 + path.join(root, 'data/companies/live-co/company.json'),
  194 + JSON.stringify({ id: 'live-co', agents: ['tmpl-a', 'private-role', 'tmpl-a-clone'] })
  195 + );
  196 +
  197 + const r = seedService.reconcileDanglingRows();
  198 +
  199 + expect(r.agentsRemoved).toEqual([]);
  200 + expect(rowOf('private-role')).not.toBeNull();
  201 + expect(rowOf('tmpl-a-clone')).not.toBeNull();
  202 + expect(rowOf('tmpl-a-clone').source_kind).toBe('company_hired');
  203 + });
  204 +
  205 + it('never reclaims a global_agent row even when a same-named template dir exists', () => {
  206 + const summary = seedService.runStartupSeed();
  207 +
  208 + expect(summary.templates.skipped).toEqual([{ id: 'alpha', existingSourceKind: 'global_agent' }]);
  209 + expect(summary.templates.reclaimed).toEqual([]);
  210 + expect(rowOf('alpha')).toMatchObject({ source_kind: 'global_agent', legacy_path: 'system/agents/alpha' });
  211 + });
  212 +
  213 + it('drops org nodes of deleted companies but keeps live ones, and is idempotent', () => {
  214 + seedService.seedGlobalAgents();
  215 + seedService.seedTemplates();
  216 + seedService.seedCompanyAgents();
  217 +
  218 + insertHired('ghost-role', 'ghost-co', 'data/companies/ghost-co/agents/ghost-role');
  219 + const now = new Date().toISOString();
  220 + graphDb.getDb().prepare(`
  221 + INSERT INTO org_graph_nodes (company_id, system_agent_id, role_name, created_at)
  222 + VALUES ('ghost-co', 'ghost-role', 'ghost-role', ?)
  223 + `).run(now);
  224 +
  225 + const first = seedService.reconcileDanglingRows();
  226 + expect(first.ghostCompanyIds).toEqual(['ghost-co']);
  227 + expect(first.orgNodesRemoved).toBe(1);
  228 + expect(first.agentsRemoved).toEqual(['ghost-role']);
  229 + expect(first.retained).toEqual([]);
  230 +
  231 + // live-co 的两个节点一个没少
  232 + expect(orgNodes()).toEqual([
  233 + { company_id: 'live-co', system_agent_id: 'private-role', role_name: 'private-role' },
  234 + { company_id: 'live-co', system_agent_id: 'tmpl-a', role_name: 'tmpl-a' },
  235 + ]);
  236 +
  237 + const second = seedService.reconcileDanglingRows();
  238 + expect(second).toMatchObject({ orgNodesRemoved: 0, orgEdgesRemoved: 0, ghostCompanyIds: [], agentsRemoved: [], retained: [] });
  239 + });
  240 +
  241 + it('skips org-node reconcile entirely when data/companies is absent (安全阀)', () => {
  242 + seedService.seedGlobalAgents();
  243 + seedService.seedTemplates();
  244 + seedService.seedCompanyAgents();
  245 + const before = orgNodes();
  246 +
  247 + fs.rmSync(path.join(root, 'data/companies'), { recursive: true, force: true });
  248 +
  249 + const r = seedService.reconcileDanglingRows();
  250 + expect(r.ghostCompanyIds).toEqual([]);
  251 + expect(r.orgNodesRemoved).toBe(0);
  252 + expect(orgNodes()).toEqual(before);
  253 + });
  254 +
  255 + it('runStartupSeed leaves company-derived rows alone (it must not run seedCompanyAgents)', () => {
  256 + seedService.runStartupSeed();
  257 +
  258 + // company.json 里的 private-role 没有被启动流程带进表,org 图也一行没写——
  259 + // 那是 hire()/fire() 与 scripts/graph-seed.js 的地盘
  260 + expect(rowOf('private-role')).toBeNull();
  261 + expect(orgNodes()).toEqual([]);
  262 + });
  263 +
  264 + it('runStartupSeedSafely swallows failures and returns null', () => {
  265 + process.env.GRAPH_DB_PATH = path.join(root, 'no-such-dir/nested/graph.sqlite');
  266 + const { seedService: broken } = reloadServices();
  267 + expect(broken.runStartupSeedSafely()).toBeNull();
  268 + });
  269 +});
  270 +
  271 +describe('graph-seed-service verifyCounts (real repo root)', () => {
12 let seedService; 272 let seedService;
13 let graphDb; 273 let graphDb;
14 const TEST_DB_PATH = '/tmp/test-graph-seed-service.sqlite'; 274 const TEST_DB_PATH = '/tmp/test-graph-seed-service.sqlite';
15 275
16 beforeAll(() => { 276 beforeAll(() => {
17 if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH); 277 if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH);
  278 + delete process.env.GRAPH_SEED_ROOT; // 真实仓库根
18 process.env.GRAPH_DB_PATH = TEST_DB_PATH; 279 process.env.GRAPH_DB_PATH = TEST_DB_PATH;
19 - delete require.cache[require.resolve('../../services/graph-db')];  
20 - delete require.cache[require.resolve('../../services/graph-seed-service')];  
21 - graphDb = require('../../services/graph-db');  
22 - seedService = require('../../services/graph-seed-service'); 280 + ({ graphDb, seedService } = reloadServices());
23 }); 281 });
24 282
25 afterAll(() => { 283 afterAll(() => {
@@ -39,6 +297,23 @@ describe(&#39;graph-seed-service verifyCounts&#39;, () =&gt; { @@ -39,6 +297,23 @@ describe(&#39;graph-seed-service verifyCounts&#39;, () =&gt; {
39 expect(v.ok).toBe(true); 297 expect(v.ok).toBe(true);
40 }); 298 });
41 299
  300 + it('every system/templates/* dir that is not a global_agent id lands as source_kind=template', () => {
  301 + const db = graphDb.getDb();
  302 + const globalIds = new Set(
  303 + db.prepare(`SELECT id FROM system_agents WHERE source_kind = 'global_agent'`).all().map(r => r.id)
  304 + );
  305 + const templateDirs = fs
  306 + .readdirSync(path.resolve(__dirname, '../../../system/templates'), { withFileTypes: true })
  307 + .filter(d => d.isDirectory())
  308 + .map(d => d.name)
  309 + .filter(id => !globalIds.has(id));
  310 +
  311 + const seededTemplates = new Set(
  312 + db.prepare(`SELECT id FROM system_agents WHERE source_kind = 'template'`).all().map(r => r.id)
  313 + );
  314 + expect(templateDirs.filter(id => !seededTemplates.has(id))).toEqual([]);
  315 + });
  316 +
42 it('catches a mismatch when the DB has a row the filesystem does not back', () => { 317 it('catches a mismatch when the DB has a row the filesystem does not back', () => {
43 const db = graphDb.getDb(); 318 const db = graphDb.getDb();
44 const now = new Date().toISOString(); 319 const now = new Date().toISOString();
server/tests/unit/template-verdicts.test.js 0 → 100644
No preview for this file type
system/templates/_registry.json
@@ -15,7 +15,7 @@ @@ -15,7 +15,7 @@
15 "description": "质量验收、OKR 进度审查、标准化仲裁", 15 "description": "质量验收、OKR 进度审查、标准化仲裁",
16 "benchmarkScore": 95, 16 "benchmarkScore": 95,
17 "trainedAt": "2026-03-25", 17 "trainedAt": "2026-03-25",
18 - "usedCount": 53 18 + "usedCount": 62
19 }, 19 },
20 { 20 {
21 "id": "base", 21 "id": "base",
system/templates/benchmark-validator/agent.md
@@ -57,9 +57,14 @@ node server/services/goal-contract.js validate --run-dir $R @@ -57,9 +57,14 @@ node server/services/goal-contract.js validate --run-dir $R
57 |---|---| 57 |---|---|
58 | 退出 `0`,且 `sections` 里**有** `benchmark` | 正常往下走 | 58 | 退出 `0`,且 `sections` 里**有** `benchmark` | 正常往下走 |
59 | 退出 `0`,但 `sections` 里**没有** `benchmark` | 本轮没有打分式标准 → **放行**(`verdict: "skipped"`),不许硬判 | 59 | 退出 `0`,但 `sections` 里**没有** `benchmark` | 本轮没有打分式标准 → **放行**(`verdict: "skipped"`),不许硬判 |
60 -| 退出 `1`(契约非法) | **标准本身写错了**:`verdict: "fail"` + `fail_reason: "input_underspecified"`,把 `[rule] detail` 原样抄进判决 | 60 +| 退出 `1`(契约非法) | **标准本身写错了**:`verdict: "attributed"` + `fail_reason: "input_underspecified"`,把 `[rule] detail` 原样抄进判决 |
61 | 退出 `2`(没有契约 / 参数错) | 同上,按 `input_underspecified` 处理并写清缺什么 | 61 | 退出 `2`(没有契约 / 参数错) | 同上,按 `input_underspecified` 处理并写清缺什么 |
62 62
  63 +🚨 **判失败只能写 `"attributed"` 或 `"inconclusive"`,绝不能写 `"fail"`。**
  64 +引擎的 `FAILING_VERDICTS` 只认这两个(`graph-engine.js`),写 `"fail"` 它读成**没失败**,
  65 +契约非法的产物会被当合格放行,fail 边一次都不会触发——失效方向是「放行」不是「拦截」。
  66 +取值域见下方「`verdict` 取值」表。
  67 +
63 ⚠️ **契约不合法不是产物的错**。判成 `impl_bug` 会把任务退回去让 coder 修一个他修不了的东西。 68 ⚠️ **契约不合法不是产物的错**。判成 `impl_bug` 会把任务退回去让 coder 修一个他修不了的东西。
64 69
65 ### Step 2 · 逐维度给分(这是你唯一真正在做的事) 70 ### Step 2 · 逐维度给分(这是你唯一真正在做的事)
system/templates/org-validator/agent.md
@@ -167,13 +167,20 @@ node server/services/goal-contract.js run --target &lt;被测产物绝对路径&gt; -- @@ -167,13 +167,20 @@ node server/services/goal-contract.js run --target &lt;被测产物绝对路径&gt; --
167 > ℹ️ **这份文件是审计产物,不是引擎的判据。** org graph 里每个节点都是 `subgraph` 节点, 167 > ℹ️ **这份文件是审计产物,不是引擎的判据。** org graph 里每个节点都是 `subgraph` 节点,
168 > 你这一层的成败由**你自己 work graph 的收敛结果**推出来(引擎 `deriveChildOutcome`: 168 > 你这一层的成败由**你自己 work graph 的收敛结果**推出来(引擎 `deriveChildOutcome`:
169 > 从子 run 里 `validator-triager` 那一行取 `fail_reason` / `remediation_note` / `fail_node_ref`)。 169 > 从子 run 里 `validator-triager` 那一行取 `fail_reason` / `remediation_note` / `fail_node_ref`)。
170 -> 所以下面的 `verdict: pass|fail` 是给人和控制台看的,**不会**被 `readNodeProduct` 当成节点成败—— 170 +> 所以下面的 `audit_verdict: pass|fail` 是给人和控制台看的,**不会**被 `readNodeProduct` 当成节点成败——
171 > 真正决定「退到哪、退多细」的是归因节点写的那份 `attempt-{n}.json`。 171 > 真正决定「退到哪、退多细」的是归因节点写的那份 `attempt-{n}.json`。
  172 +>
  173 +> 🚨 **字段名是 `audit_verdict`,不是 `verdict`。** 它跟 work 层判词共用同一个文件路径
  174 +> (`nodes/<nodeRunId>/attempt-<n>.json`,`readNodeProduct` 读的就是这条路径),
  175 +> 只靠「本节点恰好是 `subgraph`」才没被当成判词。写成 `verdict` 就是把 `pass`/`fail`
  176 +> 这套审计词塞进引擎的判词字段——而引擎的 `FAILING_VERDICTS` 不认 `fail`,会读成**没失败**。
  177 +> 用 `audit_verdict` 两套词就永远不会串台;真要被当判词读,引擎也会退到
  178 +> 「有 `fail_reason` 就算失败」那条缺省规则上,仍然判对。
172 179
173 ```jsonc 180 ```jsonc
174 { 181 {
175 "schema": "org-validator/v1", 182 "schema": "org-validator/v1",
176 - "verdict": "fail", // pass | fail(审计用,见上方说明) 183 + "audit_verdict": "fail", // pass | fail(审计用,见上方说明;**不叫 verdict**)
177 "goal_id": "<task-goal id>", 184 "goal_id": "<task-goal id>",
178 "score": { "total": 72, "pass_line": 80, "by_dimension": [ 185 "score": { "total": 72, "pass_line": 80, "by_dimension": [
179 { "name": "接口正确性", "weight": 0.5, "score": 60, "evidence": "goal-test-script 3 条断言 2 红" } 186 { "name": "接口正确性", "weight": 0.5, "score": 60, "evidence": "goal-test-script 3 条断言 2 红" }
@@ -183,7 +190,7 @@ node server/services/goal-contract.js run --target &lt;被测产物绝对路径&gt; -- @@ -183,7 +190,7 @@ node server/services/goal-contract.js run --target &lt;被测产物绝对路径&gt; --
183 ], 190 ],
184 "verdict_ref": "artifacts/verdicts/vd-20260816-1208-a3b4.json", // ← 执行器那次 run 落的判决 191 "verdict_ref": "artifacts/verdicts/vd-20260816-1208-a3b4.json", // ← 执行器那次 run 落的判决
185 "verdict_id": "vd-20260816-1208-a3b4", // 没跑执行器就没有这两个值 = 你的判决没有机器证据 192 "verdict_id": "vd-20260816-1208-a3b4", // 没跑执行器就没有这两个值 = 你的判决没有机器证据
186 - "fail_reason": "impl_bug", // 来自 validator-triager;verdict=pass 时为 null 193 + "fail_reason": "impl_bug", // 来自 validator-triager;audit_verdict=pass 时为 null
187 "remediation_note": "现象: …\n根因: …\n下一轮必须: …\n再判据: …", // 原样透传,不要改写 194 "remediation_note": "现象: …\n根因: …\n下一轮必须: …\n再判据: …", // 原样透传,不要改写
188 "triager_ref": "nodes/<validator-triager 的 nodeRunId>/attempt-1.json", 195 "triager_ref": "nodes/<validator-triager 的 nodeRunId>/attempt-1.json",
189 "action": "backward", // backward | hold_for_human | null(pass) 196 "action": "backward", // backward | hold_for_human | null(pass)
system/templates/testdata-validator/agent.md
@@ -59,9 +59,14 @@ node server/services/goal-contract.js validate --run-dir $R @@ -59,9 +59,14 @@ node server/services/goal-contract.js validate --run-dir $R
59 |---|---| 59 |---|---|
60 | 退出 `0`,且 `sections` 里**有** `test_data` | 正常往下走 | 60 | 退出 `0`,且 `sections` 里**有** `test_data` | 正常往下走 |
61 | 退出 `0`,但 `sections` 里**没有** `test_data` | 本轮没有样本集 → **放行**(`verdict: "skipped"`),不许硬判 | 61 | 退出 `0`,但 `sections` 里**没有** `test_data` | 本轮没有样本集 → **放行**(`verdict: "skipped"`),不许硬判 |
62 -| 退出 `1`(契约非法) | **样本集本身写错了**:`verdict: "fail"` + `fail_reason: "input_underspecified"`,`[rule] detail` 原样抄进判决 | 62 +| 退出 `1`(契约非法) | **样本集本身写错了**:`verdict: "attributed"` + `fail_reason: "input_underspecified"`,`[rule] detail` 原样抄进判决 |
63 | 退出 `2`(没有契约 / 参数错) | 同上 | 63 | 退出 `2`(没有契约 / 参数错) | 同上 |
64 64
  65 +🚨 **判失败只能写 `"attributed"` 或 `"inconclusive"`,绝不能写 `"fail"`。**
  66 +引擎的 `FAILING_VERDICTS` 只认这两个(`graph-engine.js`),写 `"fail"` 它读成**没失败**,
  67 +样本集非法的产物会被当合格放行,fail 边一次都不会触发——失效方向是「放行」不是「拦截」。
  68 +取值域见下方「`verdict` 取值」表。
  69 +
65 > ℹ️ 本版的 `system/work-graph-templates/validator.json` 把你固定排在链末, 70 > ℹ️ 本版的 `system/work-graph-templates/validator.json` 把你固定排在链末,
66 > 但 `test_data` 段是**动态选入**的(27-E2E §十一)。**大多数轮次里你会走 `skipped` 这一条**, 71 > 但 `test_data` 段是**动态选入**的(27-E2E §十一)。**大多数轮次里你会走 `skipped` 这一条**,
67 > 这是正常的,不是你没干活。**为了显得有产出而硬判,是本岗位的红线。** 72 > 这是正常的,不是你没干活。**为了显得有产出而硬判,是本岗位的红线。**