Commit 8ef7b1239b88f556ed80196e2df65c9cd021fa79
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>
Showing
13 changed files
with
1227 additions
and
35 deletions
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('/public', require('express').static(require('path').join(__dirname, 'pu | @@ -75,6 +75,9 @@ app.use('/public', require('express').static(require('path').join(__dirname, '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 = '' } | @@ -722,6 +725,16 @@ function buildPlanningPrompt(ctx, { round = 1, errors = null, lastOutput = '' } | ||
| 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('graph-seed-service verifyCounts', () => { | @@ -39,6 +297,23 @@ describe('graph-seed-service verifyCounts', () => { | ||
| 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 <被测产物绝对路径> -- | @@ -167,13 +167,20 @@ node server/services/goal-contract.js run --target <被测产物绝对路径> -- | ||
| 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 <被测产物绝对路径> -- | @@ -183,7 +190,7 @@ node server/services/goal-contract.js run --target <被测产物绝对路径> -- | ||
| 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 | > 这是正常的,不是你没干活。**为了显得有产出而硬判,是本岗位的红线。** |