diff --git a/docs/todo/system-design/27-p3b-real-run-runway.md b/docs/todo/system-design/27-p3b-real-run-runway.md index 2cf6448..51e5ef5 100644 --- a/docs/todo/system-design/27-p3b-real-run-runway.md +++ b/docs/todo/system-design/27-p3b-real-run-runway.md @@ -61,13 +61,199 @@ P3 主体七轮做下来,**引擎编排层是扎实的**(状态机、双层ç | # | 内容 | 文件 | 量 | |---|---|---|---| -| **W1** | 清 10 条悬空 `company_hired` 行;把 `seedTemplates()` 接进服务启动流程(或让 `hire` 顺手 upsert `system_agents`)。**这是 W5/W6 的前置**——不修,agent 雇完人提交图照样外键挂 | `server/services/graph-seed-service.js`、`server/index.js` | 小 | -| **W2** | 每个 run 分配项目根,写进节点提示词。**复用** `path-resolver.allocateProjectRoot`(sandbox 模式),不新造 | `server/services/graph-engine.js` | 小 | -| **W3** | 产出目录边界守卫:节点写入限制在自己的产物目录 + 项目根内,越界拒绝并留痕。可复用 `isSensitivePath` | `server/services/graph-engine.js` | 小 | -| **W4** | **修 fail-open 的验收 bug**:`benchmark-validator` / `testdata-validator` 的 Step 表与引擎 `FAILING_VERDICTS` 对齐。`planner-validator/agent.md` 已有一段红字警告这一点,另两份 Step 表恰恰在教 agent 犯它 | `system/templates/{benchmark,testdata}-validator/agent.md` | 小改动,**性质严重** | +| **W1** ✅ | 清 10 条悬空 `company_hired` 行;把 `seedTemplates()` 接进服务启动流程(或让 `hire` 顺手 upsert `system_agents`)。**这是 W5/W6 的前置**——不修,agent 雇完人提交图照样外键挂 | `server/services/graph-seed-service.js`、`server/index.js` | 小 | +| **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` | 小 | +| **W3** ⚠️ 部分 | 边界守卫落地为「提示词声明 + 事后取证 + 结算拒收」,**不是沙箱**:能拦住宿主仓库内可见的越界写(判 fail + timeline 留痕),拦不住仓库外与被 gitignore 的写。详见下方「W3 能力边界」 | `server/services/graph-engine.js`、`server/tests/unit/graph-boundary.test.js` | 小 | +| **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` | 小改动,**性质严重** | > W4 的失效方向是**放行**不是拦截:契约非法的产物会被当合格通过。真跑之前不修,验收形同虚设。 +#### W1 落地记录(已完成) + +§三 第 1/3/4/5 条已修复,现状核实表里那条失效链断掉了: + +- **启动接线**:`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` 手动跑。全同步幂等,失败只打日志降级,不挡启动。 +- **悬空行回收规则**(三条同时成立才回收,任一不成立就保留):`source_kind='company_hired'` + `legacy_path` 指向的目录已不存在 + 没有任何**活着的公司**在 `company.json.agents[]` 里点名这个 id。附带清掉「磁盘上已无 `company.json`」的公司遗留的 `org_graph_nodes`/`org_graph_edges`(`data/companies/` 整个目录缺失时跳过这一步,分不清「没有公司」和「盘没挂上」)。删不掉(被 `work_graph_nodes` 等外键引着)的行进 `retained`,不抛错。 +- **占位 id 转正**:`seedTemplates()` 撞上被占用的 id 时,只有当那行是**悬空的 `company_hired`** 才就地转正(`source_kind`/`scope`/`owner_company_id`/`legacy_path` 一并改写,保留行 id 让外键继续有效);`global_agent` 占位照旧 skip。 +- **实测结果**:生产 `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()` 不再静默退回全量拷贝。 +- **测试**:`server/tests/unit/graph-seed-service.test.js` 已 hermetic 化(新增 `GRAPH_SEED_ROOT` 测试缝,夹具根建在 `/tmp`,行数改为写死的精确值),2 条 → 13 条。 + +#### W4 落地记录(已完成) + +§三 第 9 条已修复。三处改动 + 一道防线: + +1. `benchmark-validator/agent.md` Step 1 表、`testdata-validator/agent.md` Step 1 表: + `verdict: "fail"` → `verdict: "attributed"`,各补一段红字说明「写 `"fail"` 引擎读成没失败」。 +2. `org-validator/agent.md` 的审计字段 `verdict: pass|fail` → **`audit_verdict`**。 + 它本来就是审计产物(org 节点是 `subgraph`,成败由 `deriveChildOutcome` 从子 run 推出), + 但它写的文件路径与 work 层判词**完全相同**(`nodes//attempt-.json`), + 只靠「本节点恰好是 subgraph」才没被 `readNodeProduct` 当判词读。改名后两套词彻底分开; + 即便哪天真被当判词读,引擎也会退到「有 `fail_reason` 就算失败」的缺省规则上,仍然判对。 +3. 防线 `server/tests/unit/template-verdicts.test.js`(对标 `template-refs.test.js`): + 从 `graph-engine.js` **源码文本**里取 `FAILING_VERDICTS` / `ATTRIBUTION_REASONS` + (两者都没 export,import 整个模块会把 graph db 拉起来;取不到就抛,不静默空跑), + 扫全部 `system/templates/**/*.md` 的赋值形态(`verdict: "X"`)与判词表整格取值, + 再加扫 `system/{work,org}-graph-templates/*.json` 的边 `failReason`(不在域内 = 这条边永远匹配不上)。 + 两条扫描路径都做过变异验证:改回 `"fail"` / 把表格取值改成 `"ok"`,都会炸。 + +> **`FAILING_VERDICTS` 要不要放宽收 `"fail"`:不要那样补,要把判据反过来** +> (失败词白名单 → 通过词白名单,不认识的一律判失败)。理由与改法见 §八。 +> `graph-engine.js` 归 W2/W3 那一轮,本轮不动它。 + +#### W2 落地记录(已完成) + +§三 第 7/8 条的前半段(**缺产出位置**)已修复。 + +- **分配**:`graph-engine.advanceOne` 在发起调用之前调 `ensureProjectRoot(ctx)`,内部复用 + `devloop/path-resolver.allocateProjectRoot({company_id, mode:'sandbox'})` → + `data/companies/{cid}/projects/{pid}/`。**每个 root run 一次**,子 run / 后续节点读回同一个 + (`runRootDir` 本来就按 `root_run_id` 拼,天然共享)。`GRAPH_RUNS_DIR` 覆盖时项目根跟着挪到 + `{GRAPH_RUNS_DIR}/{rootRunId}/project`,理由与 run 目录那处 override 完全一样:否则测试每跑 + 一次就往仓库 `data/companies/` 堆一个空项目目录。 +- **存哪:没有改 schema**。记录落在 `runs/{rootRunId}/artifacts/project-root.json` + (`{schema:"graph-run-project-root/v1", pid, root_path, mode, root_run_id, company_id, + allocated_by, allocated_at}`),与 `artifacts/task-goal.json` 同类、同目录,读回路径已经通。 + `graph_runs` 不需要加列——这条信息按 `root_run_id` 唯一,加列反而要在每一行上对齐一次。 +- **进提示词**(两种节点都进,参照 `describeArtifactContract` 的做法):agent_call 节点由 + `describeWriteBoundary` 在任务描述头部拼一段 `[写入边界 —— 硬约束,越界会被判失败]`; + subgraph 节点(起的是 `workgraph-planning`,同样是一次真 CLI 调用、同样会写盘)由 + `buildPlanningPrompt` 的「## 写入边界(硬约束)」拼同一份约束。两处都给绝对路径,并明说 + 「进程 cwd 不是项目根,别用相对路径」。`callAgent` 的 `metadata.projectRoot` 是它的机器可读副本。 +- **四环闭合**:分配(`ensureProjectRoot`)→ 传递(`ctx.projectRoot` → 提示词 + metadata)→ + 落库(`project-root.json`,`wx` 独占创建,并发下先到先得)→ 读回(下一个节点 / 子 run + `readProjectRootRecord`)。timeline 上有 `node.boundary` 一条「本次任务的项目根:…(sandbox)」。 +- **实测**(mock 模式 + 默认执行器,未起真 CLI):单节点 run 跑完 → + `data/companies/{cid}/projects/p-xxxxxx/` 被建出、`project-root.json` 内容正确、节点提示词里 + 有该绝对路径、宿主仓库 `git status` 无新增。测试 `server/tests/unit/graph-boundary.test.js`。 + +#### W3 落地记录(部分完成,**不是沙箱**) + +真 CLI 是子进程直接写盘、还带 `--dangerously-skip-permissions`,**引擎不可能在写发生的那一刻 +拦住它**。所以本轮落地的是「提示词声明(调用前)+ 事后取证 + 结算拒收」三件,能力边界如下, +不要当成「产出目录已被隔离」。 + +**能拦住的** + +1. **宿主仓库内可见的越界写**:`advanceOne` 在调用前后各跑一次 `git status --porcelain` + (cwd=仓库根),差集非空 = 本次调用在仓库里留下了痕迹 → 记一条 `node.boundary` + (`violation:'boundary_escape'` + 改动清单,最多 20 条)并**把结果降级成 `fail`** + (`success` 也降级;`fail_reason` 保持 `null`,走兜底边——引擎不发明 fail_reason)。 + 这正好覆盖已经实际发生过的那次事故(写 `server/routes/echo.js`、改 `server/index.js`)。 + executor 抛异常那条路径同样查(先写坏再崩,痕迹一样在)。 +2. **敏感路径**:项目根分配后过 `path-resolver.isSensitivePath`,命中则拒用并留痕 + (这是唯一一道**调用前就强制**的守卫)。 +3. **mock 产物越界**:`mock/files` 事件里逃出节点产物目录的路径照旧被丢弃(原有行为)。 + +**拦不住的(明确未解决)** + +1. **宿主仓库之外的写**完全看不见:`~/Documents/别的项目`、`/tmp`、`$HOME` 下任何路径。 +2. **被 `.gitignore` 忽略的路径**看不见(含 `data/companies/`、`*.log`)。 +3. **调用前就已 modified、调用中又被改一次**的文件:porcelain 行不变,看不见。 +4. 越界的写**已经发生**,守卫只拒收结果,**绝不自动回滚**——回滚等于替开发者丢弃未提交的 + 改动,代价方向比误判更贵。清理靠人工看 timeline 里的清单。 +5. **误判**:开发者本人或并行的另一个 agent 在同一时刻改仓库,会被算到本节点头上。 +6. 守卫只在**会真起 CLI 的执行器**下开(默认 executor / 默认规划器);测试注入的假 executor + 不扫(进程内函数,扫仓库既无意义又要给每个用例加两次 git 调用)。自定义 executor 若自己 + 起子进程,不在守卫范围内。 +7. `git` 跑不起来(非 git 仓库 / 超时)时守卫**自动降级为不检测**,只留一条 + `degraded:'boundary_guard_unavailable'`。 + +**真正的隔离还没有做**:最强的一招是让 CLI 的 cwd 落在项目根——但 `agent-manager._runViaProtocol` +的 `effectiveWorkDir = agent.workDir || this.workDir`,动态解析出来的 agent 一律用自己的目录, +`callAgent` 没有透传 cwd 的口子;改它不在本轮范围。要么改 `agent-manager` 加 cwd 透传, +要么上容器 / 文件系统级沙箱。**在这一条做掉之前,真跑仍应在可丢弃的工作副本里做,不要在主仓库上跑。** + +> 本轮只动了 `graph-engine.js` 的 W2/W3 部分;§八 提到的 `FAILING_VERDICTS` 白名单反转**没有做**, +> 那条仍然待办。 + +#### 第一批终验记录(独立复核,未起真 CLI) + +**结论:W1 / W2 / W4 三项到位;W3 按其自述「部分完成」到位。但第一批合起来仍然 +不足以支撑一次真 CLI 端到端跑 —— 断点不在第一批,在 §三 第 6 条(归 W5/W6)。** + +| 项 | 复核方式 | 结果 | +|---|---|---| +| 全量测试 | `npm run test:unit` | **736/736 绿,68 文件**(起点 719/66,新增 17 条,无改期望) | +| 前端 | `npm run web:build` | 通过 | +| W1 启动接线 | 读 `server/index.js` `start()` / `agent-server.js` | 均在 `listen()` 前调 `runStartupSeedSafely()` ✅ | +| W1 库状态 | 生产 `data/graph.sqlite` 实查 | `company_hired` **0 行**,`template` 45,`global_agent` 8,`verifyCounts().ok=true`,`PRAGMA foreign_key_check` 空 ✅ | +| W1 新模板自动进表 | `graph-seed-service.test.js` hermetic 用例 + 生产库重跑 `seedTemplates()` 幂等 | ✅ | +| W1 占位 id 回收 | 三条判据 + 就地转正(保留行 id 保外键) | ✅ | +| W2 四环 | 读码 + `graph-boundary.test.js` | 分配→提示词/metadata→`project-root.json`→读回,四环闭合 ✅。子 run 经 `createRun` 继承 `root_run_id`,`runRootDir` 同目录,跨层共享同一个根 —— 已核实 | +| W3 守卫 | 读码 + 变异 | 判据 `diffRepoStatus` 有测试;**降级路径(success→fail)无任何测试**,见下 | +| W4 模板改动 | 读三份 agent.md | ✅ 无 `verdict: "fail"` 残留 | +| W4 防线有效性 | **三次反例变异** | 全部变红且报出精确行号:① `benchmark-validator` 改回 `"fail"` → `agent.md:60 → verdict: "fail" 不在引擎取值域内`;② `org-validator` 的 `audit_verdict` 改回 `verdict` → `agent.md:183`;③ 造 `verdict: "ok"` → `agent.md:59`。变异后已逐字还原 ✅ | + +**W3 文档与代码一致性核查(本轮重点)**:`§四 W3` 与 `applyBoundaryGuard` 的注释 +**没有把做不到的写成已解决** —— 状态标 `⚠️ 部分`,7 条「拦不住」逐条列明, +并写了「在 cwd 隔离做掉之前,真跑仍应在可丢弃的工作副本里做」。与代码一致。 +两点补充: + +1. **「正好覆盖已发生过的那次事故」这句在当前工作树里只成立一半。** 事故是 + *写 `server/routes/echo.js`(未跟踪)+ 改 `server/index.js`(已跟踪)*。 + 而 `server/index.js` **此刻本来就是 modified**(W1 的改动),命中「拦不住」第 3 条 + (调用前已 modified、调用中再改一次 → porcelain 行不变)。所以现状下守卫只看得见 + `?? server/routes/echo.js` 那一半。这不是代码 bug,是两条自述互相打架,措辞该收敛。 +2. **降级路径零测试覆盖。** `applyBoundaryGuard` 有真实调用方(`advanceOne`),不属于 + 「零调用方」那一类;但 `boundaryGuardApplicable` 要求执行器 `=== defaultAgentExecutor`, + 而单测一律注入假 executor,`applyBoundaryGuard` 又没 export —— + **「越界 → success 降级成 fail」这条唯一有实际后果的分支,只能靠真 CLI 才走得到。** + `graph-boundary.test.js` 测的是纯函数 `diffRepoStatus`,不是接线。 + +#### 阻断真跑的真正断点(§三 第 6 条,归第二批 W5/W6) + +`AgentManager.getAgentConfig()` 解析裸名时只查 `system/agents/`,**从不查 `system/templates/`** +(`agent-manager.js:141`);带公司前缀的 `{cid}:{id}` 才查 `data/companies/{cid}/agents/`。 +而 `resolveAgentName()` 只做「目录存不存在」的判断,**不做按需 hire**,查不到就退回裸名。 + +实测(纯文件系统解析,未起 CLI): + +``` +workgraph-planning THROWS: Agent not found: workgraph-planning +prd-planning THROWS: Agent not found: prd-planning +sdd-planning THROWS: Agent not found: sdd-planning +benchmark-validator THROWS: Agent not found: benchmark-validator +testdata-validator THROWS: Agent not found: testdata-validator +validator-triager THROWS: Agent not found: validator-triager +org-validator THROWS: Agent not found: org-validator +master RESOLVED +``` + +`workgraph-planning` 在真链路里能过,只因为 `resolvePlanningAgentName()` 单独给它做了 +按需 hire;**它规划出来的每一个 work 节点引用的本体都没有这层保护**。 +所以一次真跑的必然走向是:org 节点 → 规划成功 → 第一个 work 节点 `Agent not found` → 节点判失败。 +**这正是 W5/W6 要解决的问题,第一批四项做得再对也绕不过去。** + +> 推论:**现在起真 CLI 不会产出新信息** —— 断点已被零成本静态证明, +> 而按 §五 的量级,跑一次是 $3-8 / 15-40 分钟。应先做 W5/W6 再真跑。 + +#### 第 12 处「四环漏一环」:`outcome.output` 全程被丢 + +`graph_run_nodes` **没有 `output` 列**(`NODE_PATCH_COLUMNS` 只有 9 个键,不含 `output`), +`settleNode()` 也不把 `outcome.output` 传给 `transitionNode`,`node.output` 这条 SSE 事件的 +data 里同样只有 `status/fail_reason/output_ref/child_run_id`。于是: + +**产生(executor / 守卫 banner)→ 传递(`outcome.output`)→ 落库(无)→ 读回(无)**,第三环断。 + +三个写入方全部落空:`defaultAgentExecutor`(:370/:386,存量)、W3 的越界清单 banner(:2538,本轮新增)、 +以及**最要命的那个**—— + +```js +} catch (err) { + outcome = { status: 'fail', output: err.message, failReason: null, tokens: err.tokens || 0 }; +} +``` + +`advanceOne` 里 executor 抛异常这条路径:`err.message` 写进 `outcome.output` 后**人间蒸发**, +既不落库、不进 SSE、也不写 timeline,`fail_reason` 还是 `null`。 +**操作者只会看到一个节点 fail、没有任何原因。** + +严重性判断:W3 banner 那一份是冗余的(越界清单在 `node.boundary` 的 timeline 里有 `entries`, +证据不丢);**但异常那一份是净损失**,而且正好砸在下一步最需要它的地方—— +上一节那个 `Agent not found: prd-planning` 就是从这条路径抛的,真跑起来将表现为 +「节点失败,无原因,无痕迹」。建议在 W5/W6 之前先补一条 timeline(最小改动:catch 里加 +`timelineNote(ctx,'node.error',err.message,{...})`),否则真跑的排障成本会被这一环吃掉。 + ### 第二批 · 招聘走方案 a | # | 内容 | 文件 | 量 | @@ -99,10 +285,14 @@ P3 主体七轮做下来,**引擎编排层是扎实的**(状态机、双层ç **第一批** -1. 服务启动后 `system_agents` 与 `system/templates/` 一致,无悬空 `company_hired` 行;新加一个模板目录、重启服务,它自动出现在表里。 -2. 起一个真 CLI 的 run,节点产出全部落在 `data/companies/{cid}/projects/{pid}/` 内;`git status` 在宿主仓库无任何新增/修改。 -3. 故意让节点往产物目录外写,被守卫拒绝并在 timeline 留痕。 -4. 造一份非法契约跑 `benchmark-validator`:判决为失败且引擎按失败处理(**不是** success)。 +| # | 标准 | 状态 | +|---|---|---| +| 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 个里 | +| 2 | 起一个真 CLI 的 run,节点产出全部落在 `data/companies/{cid}/projects/{pid}/` 内;`git status` 在宿主仓库无任何新增/修改 | ⛔ **未验,且暂不该验**。链路先断在 §三 第 6 条(`Agent not found`,见上方断点一节),真跑到不了产出这一步。mock + 默认执行器下项目根的分配/落库/读回已验 | +| 3 | 故意让节点往产物目录外写,被守卫拒绝并在 timeline 留痕 | ⛔ **未验**。降级分支只在 `defaultAgentExecutor` 下可达,单测注入假 executor 走不到;判据 `diffRepoStatus` 单独验过 | +| 4 | 造一份非法契约跑 `benchmark-validator`:判决为失败且引擎按失败处理(**不是** success) | 🟡 **静态成立,未真跑**。模板已改写 `verdict: "attributed"`,而 `FAILING_VERDICTS={attributed,inconclusive}` → `readNodeProduct` 置 `failingVerdict=true` → `callAgentNode` 判 `fail`(`graph-engine.js:473/385`),链路读通;防线测试三次反例变异全部变红 | + +> 2 与 3 都要真 CLI 才验得了,而真 CLI 现在会先死在 W5/W6 上。**这两条应挪到第二批之后一起验。** **第二批** @@ -123,3 +313,54 @@ P3 主体七轮做下来,**引擎编排层是扎实的**(状态机、双层ç 2. **W7 的语义决定要谨慎**:`hire()` 写 `org_graph_nodes` 是 P2 §4.2 定的(「hire 成功后写一行,幂等」),当时的前提是只有 org 节点会被雇。方案 a 打破了这个前提。改动要确认不破 P2 已有的 24 条测试。 3. **真 CLI 会暴露一批模板质量问题**:前七轮所有 agent 模板都只在 mock 下「跑过」,而 mock 不读提示词——**这些提示词实际上从未被任何模型读过**。第一次真跑大概率会集中暴露表述矛盾(W4 就是一例,`benchmark-validator` 的 Step 表与自己的产出格式表就互相打架)。 4. **不要在没有 W2/W3 的情况下跑真 CLI**:已经发生过一次 agent 写入宿主仓库。 + +--- + +## 八、`FAILING_VERDICTS` 要不要放宽(W4 的附带结论) + +> **`graph-engine.js` 归 W2/W3 那一轮改,本节只给结论与理由,不动代码。** + +### 结论 + +**要动,但不要用「把 `"fail"` 加进 `FAILING_VERDICTS`」这个改法——应该把判据反过来。** + +```js +// 现在(白名单记「失败词」,凡不认识的一律当通过 = fail-open) +const FAILING_VERDICTS = new Set(['attributed', 'inconclusive']); +failingVerdict = verdict === null ? raw !== null : FAILING_VERDICTS.has(verdict); + +// 建议(白名单记「通过词」,凡不认识的一律当失败 = fail-closed) +const PASSING_VERDICTS = new Set(['passed', 'skipped', 'produced', 'no_prior_failure']); +failingVerdict = verdict === null ? raw !== null : !PASSING_VERDICTS.has(verdict); +// 并对不认识的取值记一条 timeline(degraded: 'unknown_verdict'),与 fail_reason 的处理对称 +``` + +### 理由 + +1. **只补 `"fail"` 是打补丁,漏的是一整类。** 判据的形状才是缺陷:失败侧是白名单, + 于是 `failed` / `rejected` / `not_met` / `error` / `no` / `不通过` —— 任何一个模型 + 临场想出来的失败词,引擎都读成「没失败」。补一个词只堵一个洞,洞的数量是开放的。 +2. **模型不受模板约束,这一点 W4 本身就是证据。** mock 从不读提示词,所以这批提示词 + 在第一次真跑前从未被任何模型读过(§七.3)。模板只能提高写对的概率,不能保证; + 引擎是最后一道,它必须对没见过的词取安全的那一侧。 +3. **安全侧是「判失败」而不是「判通过」。** 判错成失败的代价是多跑一轮(烧计数、走兜底边, + 熔断有 `LOOP_HARD_CAP_EDGE/RUN` 兜底);判错成通过的代价是不合格产物直接放行, + 而且**不留任何痕迹**——这正是 W4 的失效方向。两种错代价不对称,判据就该偏向贵的那侧。 +4. **引擎里已有同形状的正确先例。** `fail_reason` 那一侧是封闭域(`ATTRIBUTION_REASONS`), + 自创取值 → 显式降级成 `null` + 一条 timeline,不静默相信。`verdict` 应当对称处理。 +5. **`verdict === null` 那条缺省分支不用动**:缺 `verdict` 但有 `fail_reason` 判失败, + 本来就是 fail-closed 的,改动只影响「写了 `verdict` 但引擎不认识」这一支。 +6. **代价接近零。** 通过词一共四个,现有模板全部命中(`template-verdicts.test.js` + 的 `PASSING_VERDICTS` 与它逐字一致,实测扫描无一例外)。反转后 + `attributed` / `inconclusive` 继续判失败,语义不变。 + +### 两点必须一起注意 + +- **反转不能替代 W4 的模板改动。** 反转之后 `verdict: "fail"` 会被判成失败, + 但那是「猜对了」,不是「写对了」:产出仍然带着一个引擎不承认的词, + `org-validator` 那种 pass/fail 审计词也仍然会串到判词字段上。模板必须写对,引擎兜底。 +- **反转会让 `template-verdicts.test.js` 报错,这是设计好的。** 它按名字从 + `graph-engine.js` 源码取 `FAILING_VERDICTS`,取不到就抛 + (「找不到 FAILING_VERDICTS——取值域防线失去依据,不要让它静默空跑」)。 + 真去反转的话,把该测试里 `engineSet('FAILING_VERDICTS')` 换成 `engineSet('PASSING_VERDICTS')` + 并把 `VERDICT_DOMAIN` 的组成对应改一行即可;**不要**把断言删掉了事。 diff --git a/scripts/graph-seed.js b/scripts/graph-seed.js index 939a798..472bfda 100644 --- a/scripts/graph-seed.js +++ b/scripts/graph-seed.js @@ -3,25 +3,42 @@ /** * graph-seed.js — seeds data/graph.sqlite from the on-disk agent/template/company sources. * - * 依次跑 seedGlobalAgents()(T1)→ seedTemplates()(T2)→ seedCompanyAgents()(T3) - * → verifyCounts()(T4,独立重新扫描目录/文件做行数交叉核对)。 + * 依次跑 reconcileDanglingRows()(P3B W1)→ seedGlobalAgents()(T1)→ seedTemplates()(T2) + * → seedCompanyAgents()(T3)→ verifyCounts()(T4,独立重新扫描目录/文件做行数交叉核对)。 * 打印按 source_kind 分组的汇总报告;核对不一致时以非零状态码退出。 * + * 前四步里的前三步(reconcile + global + templates)服务启动时会自动跑一遍 + * (graph-seed-service.runStartupSeedSafely,见 server/index.js);本脚本比启动多跑 + * seedCompanyAgents()——那一步会从 company.json 反推 org_graph_nodes,属于手动补种, + * 不放进启动流程(理由见 runStartupSeed 的注释)。 + * * 用法: * node scripts/graph-seed.js */ -const { seedGlobalAgents, seedTemplates, seedCompanyAgents, verifyCounts } = require('../server/services/graph-seed-service'); +const { + reconcileDanglingRows, seedGlobalAgents, seedTemplates, seedCompanyAgents, verifyCounts, +} = require('../server/services/graph-seed-service'); function main() { console.log('[graph-seed] 开始种子导入\n'); + const reconciled = reconcileDanglingRows(); + console.log(`[reconcile] 清理悬空 company_hired 行: ${reconciled.agentsRemoved.length} 条${reconciled.agentsRemoved.length ? ` (${reconciled.agentsRemoved.join(', ')})` : ''}`); + console.log(` 已删公司的 org 节点/边: ${reconciled.orgNodesRemoved}/${reconciled.orgEdgesRemoved} 行${reconciled.ghostCompanyIds.length ? `,涉及公司 ${reconciled.ghostCompanyIds.join(', ')}` : ''}`); + if (reconciled.retained.length > 0) { + console.log(` ⚠️ 被外键引用、暂时保留 (${reconciled.retained.length}): ${reconciled.retained.map(r => `${r.id} — ${r.reason}`).join('; ')}`); + } + const globalAgents = seedGlobalAgents(); console.log(`[global_agent] 扫描 system/agents/* 目录: ${globalAgents.scanned} 个,含 agent.md 且已导入: ${globalAgents.seeded} 个`); console.log(` ids: ${globalAgents.ids.join(', ')}`); const templates = seedTemplates(); console.log(`\n[template] 扫描 system/templates/* 目录: ${templates.scanned} 个,已导入: ${templates.seeded} 个`); + if (templates.reclaimed.length > 0) { + console.log(` ♻️ 从悬空 company_hired 占位接管回 template 的 ids (${templates.reclaimed.length}): ${templates.reclaimed.join(', ')}`); + } console.log(` registry 匹配到 benchmark_score 的 ids (${templates.registryMatched.length}): ${templates.registryMatched.join(', ')}`); if (templates.skipped.length > 0) { console.log(` ⚠️ id 与其他 source_kind 冲突,已跳过写入 (${templates.skipped.length}): ${templates.skipped.map(s => `${s.id}(已属于 ${s.existingSourceKind})`).join(', ')}`); diff --git a/server/agent-server.js b/server/agent-server.js index 7572173..a4a978b 100644 --- a/server/agent-server.js +++ b/server/agent-server.js @@ -75,6 +75,9 @@ app.use('/public', require('express').static(require('path').join(__dirname, 'pu // Health app.get('/api/health', (req, res) => res.json({ status: 'ok', service: 'agent-chat' })); +// Graph agent 元数据对齐(P3B W1)——与 server/index.js 的 start() 同一步,理由见那里的注释 +require('./services/graph-seed-service').runStartupSeedSafely(); + app.listen(PORT, '0.0.0.0', () => { console.log(`🧬 Agent Chat Server: http://localhost:${PORT}`); console.log(` API: http://localhost:${PORT}/api/agent/list`); diff --git a/server/index.js b/server/index.js index 03e1e24..2b0cb54 100644 --- a/server/index.js +++ b/server/index.js @@ -121,6 +121,12 @@ async function start() { try { ensureDataDirs(); + // Graph agent 元数据对齐(P3B W1):把 system/agents、system/templates 的目录现状 + // 同步进 system_agents,并回收指向已删目录的悬空行。必须在 listen() 之前—— + // work_graph_nodes.system_agent_id 是指向这张表的外键,第一个请求就可能提交图。 + // 幂等 + 同步 + 毫秒级;内部已包 try/catch,失败只降级不挡启动。 + require('./services/graph-seed-service').runStartupSeedSafely(); + const host = config.host || '0.0.0.0'; server.listen(Number(config.port), host, () => { console.log(`✅ 服务启动: http://${host}:${config.port}`); diff --git a/server/services/graph-engine.js b/server/services/graph-engine.js index 7d4421d..a546ecd 100644 --- a/server/services/graph-engine.js +++ b/server/services/graph-engine.js @@ -331,6 +331,8 @@ async function defaultAgentExecutor(ctx) { // 产物目录是**提示词**里那份约定的机器可读副本(提示词由 describeArtifactContract 拼, // 见 advanceOne);metadata 只进日志与 hook,agent 读不到,所以两边都要有。 rootRunId: ctx.rootRunId, attempt: ctx.attempt, artifactDir: safeNodeArtifactDir(ctx), + // 项目根同理:提示词那份给 agent 读,这份给日志/hook 用(W2) + projectRoot: ctx.projectRoot || null, }, }); } catch (e) { @@ -601,6 +603,7 @@ async function defaultPlanWorkGraph(ctx) { metadata: { taskId: ctx.runId, layer: 'graph-plan', nodeRunId: ctx.nodeRunId, rootRunId: ctx.rootRunId, attempt: ctx.attempt, + projectRoot: ctx.projectRoot || null, }, }); } catch (e) { @@ -722,6 +725,16 @@ function buildPlanningPrompt(ctx, { round = 1, errors = null, lastOutput = '' } '## ④ 上一轮 remediation_note', ctx.remediationNote || '无,首次规划。', '', + // W2:规划本体也是一次真 CLI 调用,也会写盘(模板 Step 6 的降级路径就叫它把图写成 + // workspace/workgraph-.json)。不给它位置,它同样会写到最近的仓库里去。 + '## 写入边界(硬约束)', + ctx.projectRoot + ? `本次任务的项目根:${ctx.projectRoot}\n` + + '你若要落任何中间文件(图 JSON、草稿),写在这个项目根里面,用绝对路径。' + : '本次任务没有分配到项目根(分配失败,见 timeline):不要在磁盘上留任何文件,把图直接贴在回复里。', + `禁止写 ${REPO_ROOT} 下的任何路径(那是引擎源码仓库);引擎会在调用前后扫宿主仓库的 git 状态,` + + '发现越界会把本节点判失败。你规划出来的每个节点将来也会收到同一条边界约束。', + '', '## 落库口径(本次调用的硬约束)', `- goalId=${ctx.run.goal_id || 'null'} / taskId=${ctx.run.task_id || 'null'} / okrNodeId=${ctx.run.okr_node_id || 'null'},原样带上,别自己编。`, '- 图必须过五项校验:无环 / 单一入口 / 全可达 / 回退边显式标 backward /' @@ -1170,9 +1183,15 @@ async function advanceOne(runId, opts = {}) { remediationNote: latestRemediationNote(picked.id), executionMode: run.execution_mode, }; + // W2:产出位置必须在**发起调用之前**就存在,且两种节点都要有——subgraph 节点起的是 + // workgraph-planning,那同样是一次真 CLI 调用,同样会写盘。 + const projectRoot = ensureProjectRoot(ctx); + ctx.projectRoot = projectRoot ? projectRoot.root_path : null; + // 产物目录必须**进提示词**:读的一侧(readNodeProduct)按 §四.A.5 的路径去读, // 写的一侧不告诉 agent 往哪写、文件名怎么起,这份文件在生产里就根本不会被写出来, // 于是 fail 边 / 回退 / infra_flake 全都触发不了(R3-F1 缺陷 3a)。 + // 项目根同理,且比产物目录更早断:不写进提示词,agent 就会写到它能看见的最近的仓库。 if (ctx.nodeKind !== 'subgraph') ctx.task += describeArtifactContract(ctx); // 抢到节点即记账:一次调用一定发生了(哪怕它随后炸掉),预算「只增不减」(§四.A.3)。 @@ -1191,6 +1210,9 @@ async function advanceOne(runId, opts = {}) { { ref: picked.id }, ); + // W3:调用前后各拍一张宿主仓库快照。能拦什么拦不了什么见 applyBoundaryGuard 的注释。 + const guardSnapshot = beginBoundaryGuard(ctx); + let outcome; try { outcome = ctx.nodeKind === 'subgraph' ? await executeSubgraph(ctx) : await agentExecutor(ctx); @@ -1199,6 +1221,8 @@ async function advanceOne(runId, opts = {}) { // err.tokens:规划连着跑了几轮才放弃时,那几轮的 token 是真花了的,照扣不误(§四.A.3「只增不减」)。 outcome = { status: 'fail', output: err.message, failReason: null, tokens: err.tokens || 0 }; } + // 炸掉的那条路径同样要查:agent 写完仓库再崩,痕迹一样在。 + outcome = applyBoundaryGuard(ctx, guardSnapshot, outcome); // executor 报了 token 用量就一并扣。产生端见 defaultAgentExecutor 的 onAcpEvent // (claude-cli 协议的 session/result.usage);不上报 usage 的协议这一维恒为 0, @@ -2284,6 +2308,237 @@ function runRootDir(run) { : path.join(REPO_ROOT, 'data', 'companies', run.company_id, 'runs', run.root_run_id); } +// ──────────────────────────────────────────────────────────────── +// W2 · 每个 root run 一个项目根(27-P3B §四 W2) +// ──────────────────────────────────────────────────────────────── + +/** + * **根因不是「缺沙箱」而是「缺产出位置」**:节点收到「实现某个功能」的任务,却没有 + * 「这次任务的代码写哪」,于是写到它能看见的最近的仓库(真 CLI 实测已往宿主仓库写过 + * server/routes/echo.js 等三处)。所以 W2 补的是位置,W3 才是边界。 + * + * 位置的分配**复用 DevLoop 的 path-resolver.allocateProjectRoot(sandbox 模式)**, + * 不新造一套:`data/companies/{cid}/projects/{pid}`,与 DevLoop 项目同一棵目录树。 + * + * ── 存哪:`runs/{rootRunId}/artifacts/project-root.json`,**不加 schema 列** ── + * 三条理由: + * 1. graph_runs 加列要动 work-graph-db.js(本轮不归本文件),且这条信息**天然按 + * root_run_id 唯一**——而 runRootDir 本来就是按 root_run_id 拼的,子 run 拼出来是 + * 同一个目录,天然共享同一份记录,不需要每行都存一份再对齐。 + * 2. 它是「这次任务的工作区在哪」,与 artifacts/task-goal.json 是同一类 run 级事实, + * 放在一起读回的路径已经通了(describeArtifactsDir / describeTask 都在读这个目录)。 + * 3. 产生 → 传递 → 落库 → 读回 四环都在本文件里闭合: + * ensureProjectRoot 分配(产生)→ ctx.projectRoot 进提示词 + callAgent metadata(传递) + * → project-root.json(落库)→ 下一个节点/子 run 再进来时 readProjectRootRecord 读回。 + */ +const PROJECT_ROOT_FILE = 'project-root.json'; +const PROJECT_ROOT_SCHEMA = 'graph-run-project-root/v1'; + +function projectRootFile(run) { + return path.join(runRootDir(run), 'artifacts', PROJECT_ROOT_FILE); +} + +function readProjectRootRecord(run) { + let rec = null; + try { + rec = readJsonOrNull(projectRootFile(run)); + } catch { + return null; // run 缺 company_id/root_run_id:拼不出路径 = 没有记录 + } + return rec && typeof rec.root_path === 'string' && rec.root_path ? rec : null; +} + +/** + * 真正在盘上开一个目录。 + * + * GRAPH_RUNS_DIR 覆盖时项目根跟着走(`{RUNS_ROOT}/{rootRunId}/project`),理由与本文件 + * 顶部 RUNS_ROOT 那段注释完全一样:run 的产物被重定向到临时目录,项目根却还落在仓库 + * `data/companies/` 的话,测试每跑一次就往仓库里堆一个空项目目录。两处必须同源。 + */ +function allocateRunProjectRoot(run) { + if (RUNS_ROOT) { + const root_path = path.join(RUNS_ROOT, run.root_run_id, 'project'); + fs.mkdirSync(root_path, { recursive: true }); + return { pid: `p-run-${run.root_run_id}`, root_path, mode: 'runs-dir-override' }; + } + return require('./devloop/path-resolver').allocateProjectRoot({ + company_id: run.company_id, mode: 'sandbox', + }); +} + +/** + * 取本 root run 的项目根,没有就分配一个并落库。**幂等**:先读回、再分配。 + * + * @returns {{pid: string, root_path: string, mode: string}|null} null = 这次拿不到写入位置 + * (提示词会如实说明,而不是编一个假路径塞给 agent) + */ +function ensureProjectRoot(ctx) { + const run = ctx && ctx.run; + if (!run) return null; + + const existing = readProjectRootRecord(run); + if (existing) { + // 目录被人删掉过就照原路径重建:记录里的路径是提示词/产物的唯一真源,不能换。 + try { fs.mkdirSync(existing.root_path, { recursive: true }); } catch { /* 建不出来下面照样返回,agent 会自己报错 */ } + return existing; + } + + let alloc; + try { + alloc = allocateRunProjectRoot(run); + } catch (e) { + timelineNote(ctx, 'node.boundary', `项目根分配失败(${e.message}),本节点没有产出位置`, { + node_run_id: ctx.nodeRunId, degraded: 'project_root_alloc_failed', + }); + return null; + } + + // W3 的第一道(也是唯一一道**调用前就能强制**的)守卫:复用 path-resolver 的黑名单。 + // sandbox 模式自己算出来的路径不会命中,但 GRAPH_RUNS_DIR 指到 /etc 这类配置错误会。 + try { + if (require('./devloop/path-resolver').isSensitivePath(alloc.root_path)) { + timelineNote(ctx, 'node.boundary', `项目根落在敏感路径黑名单里,拒绝使用:${alloc.root_path}`, { + node_run_id: ctx.nodeRunId, root_path: alloc.root_path, degraded: 'sensitive_project_root', + }); + return null; + } + } catch { /* 黑名单查不了不该把 run 弄挂,继续用分配到的路径 */ } + + const rec = { + schema: PROJECT_ROOT_SCHEMA, + pid: alloc.pid, + root_path: alloc.root_path, + mode: alloc.mode, + root_run_id: run.root_run_id, + company_id: run.company_id, + allocated_by: ctx.nodeRunId || null, + allocated_at: now(), + }; + const file = projectRootFile(run); + try { + fs.mkdirSync(path.dirname(file), { recursive: true }); + // 'wx' = 先到先得。并发下第二个节点会 EEXIST,读回第一个人写的那份(下面 catch)。 + fs.writeFileSync(file, JSON.stringify(rec, null, 2), { flag: 'wx' }); + } catch { + const raced = readProjectRootRecord(run); + if (raced) { + // ponytail: 输给别人的那次 allocateProjectRoot 会剩一个空目录(sandbox 模式下是 + // data/companies/{cid}/projects/{pid}/,里面什么都没有)。不清理:删目录的代价错误 + // 方向更贵。升级路径是把分配挪到 createRun 那一刻(届时只有一个调用方)。 + return raced; + } + // 落不了库但目录是真的:仍然给 agent 一个写入位置,但要吼出来——下一个节点会**另分配一个**, + // 跨节点的产物会散在两个根里。 + timelineNote(ctx, 'node.boundary', + `项目根记录写入失败(${file}),后续节点可能分配到不同的项目根`, { + node_run_id: ctx.nodeRunId, root_path: rec.root_path, degraded: 'project_root_not_persisted', + }); + return rec; + } + + timelineNote(ctx, 'node.boundary', `本次任务的项目根:${rec.root_path}(${rec.mode})`, { + node_run_id: ctx.nodeRunId, root_path: rec.root_path, pid: rec.pid, mode: rec.mode, + }); + return rec; +} + +// ──────────────────────────────────────────────────────────────── +// W3 · 产出边界守卫(27-P3B §四 W3) +// ──────────────────────────────────────────────────────────────── + +/** + * **能拦住什么、拦不住什么,先说清楚**(27-P3B §四 W3 的状态栏同文): + * + * 真 CLI 是子进程直接写盘、还带着 `--dangerously-skip-permissions`,引擎**不可能**在写发生的 + * 那一刻拦住它。所以这里落的是「事后取证 + 结算拒收」,不是沙箱: + * + * 能做到:① 调用前后各扫一次**宿主仓库**的 `git status --porcelain`,差集就是这次调用在 + * 仓库里留下的痕迹;有痕迹就把节点判失败(success 也降级成 fail)并写 timeline。 + * 这正好覆盖已经实际发生过的那次事故(写 server/routes/echo.js 并改 server/index.js)。 + * ② 项目根本身过 isSensitivePath 黑名单(调用前,真强制)。 + * 做不到:① 宿主仓库**之外**的写入(`~/Documents/别的项目`、`/tmp`)完全看不见; + * ② 仓库内被 .gitignore 忽略的路径(含 `data/companies/`)看不见; + * ③ 同一个文件在调用前就已经是 modified、调用中又被改一次 → porcelain 行不变,看不见; + * ④ 越界的写**已经发生**,守卫只拒收结果,**绝不自动回滚**——回滚等于替开发者 + * 丢弃未提交的改动,代价方向比误判更贵; + * ⑤ 别人(开发者本人、并行的另一个 agent)在同一时刻改仓库会被算到本节点头上(误判)。 + * 真正的隔离要么改 AgentManager 让 CLI 的 cwd/权限落在项目根(`agent.workDir` 现在写死是 + * agent 自己的目录,不归本文件),要么上容器/文件系统级沙箱。两者都不在本轮。 + * + * 守卫只在**真会起 CLI 的执行器**下开(默认执行器 / 默认规划器):测试注入的假 executor + * 是进程内函数,扫仓库既没意义又要给 719 个用例每人加两次 git 调用。 + */ +function boundaryGuardApplicable(ctx) { + return ctx.nodeKind === 'subgraph' + ? planWorkGraph === defaultPlanWorkGraph + : agentExecutor === defaultAgentExecutor; +} + +function hostRepoStatus() { + try { + return require('child_process').execFileSync('git', ['status', '--porcelain'], { + cwd: REPO_ROOT, encoding: 'utf8', timeout: 20_000, maxBuffer: 8 * 1024 * 1024, + }); + } catch { + return null; // 不是 git 仓库 / 没有 git / 超时:守卫不可用,如实记一条,不影响执行 + } +} + +/** + * 两次 `git status --porcelain` 的差集 = 本次调用期间新出现的仓库改动。 + * 纯函数,唯一调用方是 applyBoundaryGuard(导出只为让它能被单测直接验)。 + */ +function diffRepoStatus(before, after) { + const seen = new Set(String(before == null ? '' : before).split('\n').map((l) => l.trim()).filter(Boolean)); + return String(after == null ? '' : after) + .split('\n').map((l) => l.trim()).filter(Boolean) + .filter((l) => !seen.has(l)); +} + +/** 调用前拍一张宿主仓库快照;守卫不适用/不可用时返回 null */ +function beginBoundaryGuard(ctx) { + if (!boundaryGuardApplicable(ctx)) return null; + const snap = hostRepoStatus(); + if (snap === null) { + timelineNote(ctx, 'node.boundary', '边界守卫不可用(宿主仓库 git status 跑不了),本次调用不做越界检测', { + node_run_id: ctx.nodeRunId, attempt: ctx.attempt, degraded: 'boundary_guard_unavailable', + }); + } + return snap; +} + +/** + * 调用后比对。发现越界 → 记 timeline + **把结果降级成 fail**(引擎不发明 fail_reason, + * 越界走的是那条兜底边)。subgraph 的 pending 不降级:它的子 run 还在跑,就地判失败会 + * 留下一张没人回写的孤儿子 run,只留痕。 + */ +function applyBoundaryGuard(ctx, before, outcome) { + if (before === null || before === undefined) return outcome; + const after = hostRepoStatus(); + if (after === null) return outcome; + const escapes = diffRepoStatus(before, after); + if (escapes.length === 0) return outcome; + + const shown = escapes.slice(0, 20); + timelineNote(ctx, 'node.boundary', + `越界写入:本次调用在宿主仓库留下 ${escapes.length} 处改动(应写在项目根 ${ctx.projectRoot || '(未分配)'} 内)`, { + node_run_id: ctx.nodeRunId, attempt: ctx.attempt, violation: 'boundary_escape', + project_root: ctx.projectRoot || null, repo_root: REPO_ROOT, + count: escapes.length, entries: shown, + note: '引擎不会自动回滚这些改动(见 W3 的能力边界),请人工核对 git status', + }); + + if (outcome && outcome.status === 'pending') return outcome; + const banner = `[边界守卫] 本次调用在宿主仓库(${REPO_ROOT})留下 ${escapes.length} 处改动,` + + `按越界判失败。改动清单:\n${shown.join('\n')}`; + return { + ...(outcome || {}), + status: 'fail', + failReason: (outcome && outcome.failReason) || null, + output: `${banner}\n\n${(outcome && outcome.output) || ''}`, + }; +} + function readJsonOrNull(file) { try { return JSON.parse(fs.readFileSync(file, 'utf8')); @@ -2552,6 +2807,7 @@ function describeArtifactContract(ctx) { const dir = safeNodeArtifactDir(ctx); if (!dir) return ''; // run 缺 company_id/root_run_id:拼不出目录就不编一个假的塞给 agent return [ + ...describeWriteBoundary(ctx, dir), '', '', '[节点产物目录]', @@ -2583,6 +2839,40 @@ function describeArtifactContract(ctx) { ].join('\n'); } +/** + * 写入边界进提示词(W2 的「告诉它写哪」+ W3 的「不许写别处」)。 + * + * 这是**唯一在真 CLI 跑起来之前就能生效的一半**:引擎拦不住子进程写盘(见 applyBoundaryGuard), + * 提示词是「必要但不充分」的那一半,事后扫描是另一半。两半都写在同一个文件里,改一处 + * 不会忘了另一处。 + * + * @param {string} artifactDir 节点私有目录(调用方已算好,避免重复拼) + * @returns {string[]} 拼进任务描述的行;调用方负责 join + */ +function describeWriteBoundary(ctx, artifactDir) { + const root = (ctx && ctx.projectRoot) || null; + const lines = ['', '', '[写入边界 —— 硬约束,越界会被判失败]']; + if (root) { + lines.push( + `本次任务的项目根:${root}`, + '**一切代码 / 测试 / 配置 / 交付物都写在这个项目根里面**(它是个空目录,结构你自己定)。', + `你只被允许写两个位置:① 上面这个项目根及其子目录;② 你的节点私有目录 ${artifactDir}(结构化产出/归因/契约)。`, + ); + } else { + lines.push( + '本次任务**没有分配到项目根**(分配失败,见 timeline)。', + `在拿到项目根之前,你只被允许写你的节点私有目录 ${artifactDir},不要在别处创建任何文件。`, + ); + } + lines.push( + `禁止写这两处之外的任何路径,**尤其是 ${REPO_ROOT}**(这是引擎自己的源码仓库,不是你的工作区)。`, + '写文件一律用上面给的**绝对路径**拼接:你的进程 cwd 不是项目根,用相对路径会写到别人家里。', + '引擎会在你这次调用前后各扫一次宿主仓库的 git 状态;发现越界改动会把本节点判失败并留痕' + + '(引擎不会替你回滚,越界等于这一轮白跑)。', + ); + return lines; +} + /** nodeArtifactDir 的不抛版本(run 缺列时返回 null) */ function safeNodeArtifactDir(ctx) { try { @@ -2676,6 +2966,12 @@ module.exports = { setPlanWorkGraph, extractPlannedGraph, + // W2/W3 项目根与边界守卫(27-P3B)。 + // ensureProjectRoot 的真实调用方是 advanceOne,diffRepoStatus 的是 applyBoundaryGuard; + // 导出只是让单测能直接验这两条判据,不是新开一圈公开面。 + ensureProjectRoot, + diffRepoStatus, + // 选边 / 熔断 / 透传 selectFailEdge, handleBackwardEdge, diff --git a/server/services/graph-seed-service.js b/server/services/graph-seed-service.js index 581fe33..35f4c1d 100644 --- a/server/services/graph-seed-service.js +++ b/server/services/graph-seed-service.js @@ -5,21 +5,161 @@ * seedGlobalAgents() — system/agents/* → system_agents (source_kind='global_agent') * seedTemplates() — system/templates/* → system_agents (source_kind='template') * seedCompanyAgents() — data/companies/{id}/company.json.agents[] → system_agents (source_kind='company_hired') + org_graph_nodes - * (T4 adds verifyCounts() here later.) + * verifyCounts() — 独立重扫目录做行数交叉核对(T4) + * reconcileDanglingRows() — 回收指向已删目录的 company_hired 行 / 已删公司的 org 节点(P3B W1) + * runStartupSeed() — 服务启动时跑的那一组(reconcile + global + templates),见 server/index.js * * P0 阶段:只加不改,不涉及任何现有执行路径(company-service.js / agent-manager.js 等)。 + * P3B W1 起本模块被接进服务启动流程(runStartupSeed),不再只是手动脚本。 */ const fs = require('fs'); const path = require('path'); const { getDb } = require('./graph-db'); -const ROOT = path.resolve(__dirname, '../..'); +// GRAPH_SEED_ROOT 只是测试用的落地缝(与 graph-db.js 的 GRAPH_DB_PATH 同一套路): +// 单测把它指向临时目录,就能在完全受控的 system/ + data/companies/ 上跑种子与对账, +// 不再依赖真实仓库里有多少公司。生产不设这个变量,走仓库根。 +const ROOT = path.resolve(process.env.GRAPH_SEED_ROOT || path.resolve(__dirname, '../..')); const AGENTS_DIR = path.join(ROOT, 'system', 'agents'); const TEMPLATES_DIR = path.join(ROOT, 'system', 'templates'); const TEMPLATES_REGISTRY_PATH = path.join(TEMPLATES_DIR, '_registry.json'); const COMPANIES_DIR = path.join(ROOT, 'data', 'companies'); +// ───────────────────────── 悬空行回收(P3B W1)───────────────────────── +// +// 背景:`data/companies/{cid}/` 被手工删掉之后,seedCompanyAgents()/hire() 当初写进 +// system_agents 的 `company_hired` 行留在库里,legacy_path 指着一个早已不存在的目录。 +// 这些行占着 id 命名空间:`seedTemplates()` 扫到同名模板目录时会因为「id 已被别的 +// source_kind 占用」而跳过,于是 `lookupSharedAgentMd()`(只认 source_kind='template') +// 对这些 id 恒返回 null,company-service.hire() 静默从「引用模式」退回「legacy 全量拷贝」—— +// 结果就是模板改动对任何已雇公司都不生效。所以这不是洁癖,是一条真实的失效链。 + +/** + * 扫描 data/companies/*\/company.json,收集**所有还活着的公司**声明用到的角色名。 + * + * 这是判断「一行 company_hired 是不是还有人认领」的唯一依据,比只看 owner_company_id 宽: + * 同一个 agent id 可能被多家公司复用(seedCompanyAgents 对已存在的 id 只建节点、不重复建行), + * 只要还有任何一家活着的公司在 agents[] 里点名它,就不能回收。 + * + * @returns {{ claimedRoles: Set, liveCompanyIds: Set, companiesDirPresent: boolean }} + */ +function collectLiveCompanyClaims() { + const companiesDirPresent = fs.existsSync(COMPANIES_DIR); + const claimedRoles = new Set(); + const liveCompanyIds = new Set(); + + if (!companiesDirPresent) return { claimedRoles, liveCompanyIds, companiesDirPresent }; + + for (const entry of fs.readdirSync(COMPANIES_DIR, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const companyJsonPath = path.join(COMPANIES_DIR, entry.name, 'company.json'); + if (!fs.existsSync(companyJsonPath)) continue; // 没有 company.json → 还不是一家公司 + + liveCompanyIds.add(entry.name); + try { + const companyJson = JSON.parse(fs.readFileSync(companyJsonPath, 'utf-8')); + if (Array.isArray(companyJson.agents)) { + for (const roleName of companyJson.agents) claimedRoles.add(roleName); + } + } catch { + // company.json 坏了 → 公司仍然算活着(目录在),但读不出 agents[]。 + // 这时宁可少回收:把整个 claim 判断放宽是危险的,只能退而求其次靠 fs 存在性兜底。 + } + } + + return { claimedRoles, liveCompanyIds, companiesDirPresent }; +} + +/** + * 一行 system_agents 是不是「悬空的 company_hired」。三个条件必须同时成立,缺一不回收: + * 1. source_kind === 'company_hired'(`global_agent` / `template` 行永不参与回收, + * 否则 system/agents 与 system/templates 同名 id 的冲突保护会被绕过); + * 2. legacy_path 指向的目录**不存在**(真实存在的公司私有 agent 一律不动 —— 这是防误伤的主闸); + * 3. 没有任何活着的公司在 company.json.agents[] 里点名这个 id + * (目录一时缺失但公司还声明着它 → 视为安装损坏,保留原行,交给人处理,不擅自改语义)。 + * + * @param {{id: string, source_kind: string, legacy_path: string}} row + * @param {Set} claimedRoles collectLiveCompanyClaims().claimedRoles + */ +function isStaleCompanyHired(row, claimedRoles) { + if (!row || row.source_kind !== 'company_hired') return false; + if (claimedRoles.has(row.id)) return false; + if (!row.legacy_path) return true; + return !fs.existsSync(path.resolve(ROOT, row.legacy_path)); +} + +/** + * 幂等的维护步骤:清掉两类悬空数据。跑第二遍时两个计数都是 0。 + * + * A. **已删公司的 org_graph_nodes / org_graph_edges**:company_id 在磁盘上已经没有 + * `data/companies/{cid}/company.json`。先清这一类,否则 B 里要删的 system_agents 行 + * 还被这些幽灵节点外键引着,删不掉。 + * B. **悬空的 company_hired 行**(判定见 isStaleCompanyHired)。 + * + * 安全阀: + * - `data/companies/` 整个目录都不存在时**跳过 A**。分不清「一家公司都没有」和 + * 「目录没挂上」,宁可不删。 + * - 每行删除单独 try/catch:真被别处外键引着(work_graph_nodes.system_agent_id、 + * work_graphs.owner_org_node_id 等)就保留并记进 retained,不让一行删不掉拖垮整轮启动。 + * 这样以后新增引用表也不需要回来改这里。 + * + * @returns {{ orgNodesRemoved: number, orgEdgesRemoved: number, ghostCompanyIds: string[], + * agentsRemoved: string[], retained: {id: string, reason: string}[] }} + */ +function reconcileDanglingRows() { + const db = getDb(); + const { claimedRoles, liveCompanyIds, companiesDirPresent } = collectLiveCompanyClaims(); + + const ghostCompanyIds = []; + let orgNodesRemoved = 0; + let orgEdgesRemoved = 0; + const agentsRemoved = []; + const retained = []; + + // ── A. 已删公司的 org 节点/边 ── + if (companiesDirPresent) { + const nodeCompanyIds = db + .prepare(`SELECT DISTINCT company_id FROM org_graph_nodes`) + .all() + .map(r => r.company_id); + + for (const companyId of nodeCompanyIds) { + if (liveCompanyIds.has(companyId)) continue; + ghostCompanyIds.push(companyId); + try { + db.transaction(() => { + orgEdgesRemoved += db.prepare(`DELETE FROM org_graph_edges WHERE company_id = ?`).run(companyId).changes; + // reports_to_node_id 是同表自引用,整批删之前先解开,免得删除顺序踩到外键 + db.prepare(`UPDATE org_graph_nodes SET reports_to_node_id = NULL WHERE company_id = ?`).run(companyId); + orgNodesRemoved += db.prepare(`DELETE FROM org_graph_nodes WHERE company_id = ?`).run(companyId).changes; + })(); + } catch (err) { + retained.push({ id: `org_graph_nodes[${companyId}]`, reason: err.message }); + } + } + } + + // ── B. 悬空 company_hired 行 ── + const hiredRows = db + .prepare(`SELECT id, source_kind, legacy_path FROM system_agents WHERE source_kind = 'company_hired'`) + .all(); + + for (const row of hiredRows) { + if (!isStaleCompanyHired(row, claimedRoles)) continue; + try { + db.prepare(`DELETE FROM system_agents WHERE id = ? AND source_kind = 'company_hired'`).run(row.id); + agentsRemoved.push(row.id); + } catch (err) { + // 还被别的表外键引着 → 留着。seedTemplates() 的接管分支能把其中的模板 id 就地转正, + // 不需要先删成功。 + retained.push({ id: row.id, reason: err.message }); + } + } + + return { orgNodesRemoved, orgEdgesRemoved, ghostCompanyIds, agentsRemoved, retained }; +} + /** * 扫描 system/agents/*,过滤掉没有 agent.md 的目录(排除 templates/ 等杂物), * 逐个 upsert 到 system_agents,scope='public',source_kind='global_agent'。 @@ -104,7 +244,15 @@ function seedGlobalAgents() { * 模板目录的数据,同一行却仍标着 source_kind='global_agent',数据会被污染。 * 只有当已存在的行本身就是 source_kind='template' 时才走 UPDATE(保证本函数自身可重复执行)。 * - * @returns {{ scanned: number, seeded: number, ids: string[], registryMatched: string[], skipped: {id: string, existingSourceKind: string}[] }} + * **P3B W1 的例外(接管)**:占位的那行如果是一条**悬空的 company_hired** + * (isStaleCompanyHired:目录没了 + 没有任何活着的公司认领),就地转正成 template 行 —— + * scope/owner_company_id/source_kind/legacy_path 全部改写。原来的「一律 skip」让污染不可逆: + * 公司目录删了、行还占着模板 id,模板永远补不回来(现状核实第 3-5 条那条失效链)。 + * 转正而不是删了重建,是为了让还引着这行的 org_graph_nodes 外键继续有效。 + * `global_agent` 占位仍然照旧 skip —— 那是同名不同源,不是污染。 + * + * @returns {{ scanned: number, seeded: number, ids: string[], registryMatched: string[], + * skipped: {id: string, existingSourceKind: string}[], reclaimed: string[] }} */ function seedTemplates() { const db = getDb(); @@ -127,30 +275,43 @@ function seedTemplates() { const now = new Date().toISOString(); - const findExisting = db.prepare(`SELECT source_kind FROM system_agents WHERE id = ?`); + const findExisting = db.prepare(`SELECT id, source_kind, legacy_path FROM system_agents WHERE id = ?`); + // ON CONFLICT 分支必须连 scope/owner_company_id/source_kind 一起改写,接管才算完成: + // 只改 legacy_path 会留下一行「legacy_path 指向 system/templates/ 却仍标着 company_hired」 + // 的半吊子记录,lookupSharedAgentMd 照旧查不到。对本来就是 template 的行这三列是原值写原值。 const upsert = db.prepare(` INSERT INTO system_agents (id, name, scope, owner_company_id, source_kind, legacy_path, status, benchmark_score, created_at, updated_at) VALUES (@id, @name, 'public', NULL, 'template', @legacy_path, @status, @benchmark_score, @created_at, @updated_at) ON CONFLICT(id) DO UPDATE SET name = excluded.name, + scope = excluded.scope, + owner_company_id = excluded.owner_company_id, + source_kind = excluded.source_kind, legacy_path = excluded.legacy_path, status = excluded.status, benchmark_score = excluded.benchmark_score, updated_at = excluded.updated_at `); + const { claimedRoles } = collectLiveCompanyClaims(); + const ids = []; const registryMatched = []; const skipped = []; + const reclaimed = []; const seedAll = db.transaction((dirEntries) => { for (const entry of dirEntries) { const id = entry.name; const existing = findExisting.get(id); if (existing && existing.source_kind !== 'template') { - skipped.push({ id, existingSourceKind: existing.source_kind }); - continue; // id already owned by another source (e.g. global_agent) — don't clobber it + if (isStaleCompanyHired(existing, claimedRoles)) { + reclaimed.push(id); // 悬空的 company_hired 占位 → 下面的 upsert 就地转正成 template + } else { + skipped.push({ id, existingSourceKind: existing.source_kind }); + continue; // id already owned by a live source (e.g. global_agent) — don't clobber it + } } const dir = path.join(TEMPLATES_DIR, id); @@ -187,7 +348,7 @@ function seedTemplates() { seedAll(entries); - return { scanned: entries.length, seeded: ids.length, ids, registryMatched, skipped }; + return { scanned: entries.length, seeded: ids.length, ids, registryMatched, skipped, reclaimed }; } /** @@ -436,9 +597,60 @@ function verifyCounts() { }; } +/** + * 服务启动时跑的那一组(P3B W1)。调用方:server/index.js、server/agent-server.js。 + * + * 跑 reconcileDanglingRows() → seedGlobalAgents() → seedTemplates(),**不跑 + * seedCompanyAgents()**。取舍理由:前三个的数据源是仓库自带的 `system/` 目录, + * 属于「代码的一部分」,每次启动按最新目录对齐才是对的;而 seedCompanyAgents() 的数据源是 + * `data/companies/*\/company.json`,那是运行时状态,归 company-service 的 hire()/fire() 管, + * 启动时从 company.json 反推 org_graph_nodes 会跟这两条写入路径抢所有权。它继续留给 + * `node scripts/graph-seed.js` 手动跑。 + * + * 顺序不能换:reconcile 必须在 seedTemplates 之前,否则悬空行还占着模板 id。 + * 全同步、全幂等,51 个模板目录 + 20 家公司量级在毫秒级,放在 listen() 之前不影响启动。 + * + * @returns {{ reconciled: object, globalAgents: object, templates: object }} + */ +function runStartupSeed() { + const reconciled = reconcileDanglingRows(); + const globalAgents = seedGlobalAgents(); + const templates = seedTemplates(); + return { reconciled, globalAgents, templates }; +} + +/** + * runStartupSeed() 的「失败不许挡住服务启动」包装 —— 两个 server 入口直接调这个。 + * 种子失败只影响 agent 元数据的新鲜度,不该让整个进程起不来;打日志降级即可。 + * + * @returns {object|null} 成功时是 runStartupSeed() 的汇总,失败时 null + */ +function runStartupSeedSafely() { + try { + const summary = runStartupSeed(); + const r = summary.reconciled; + const parts = [ + `global_agent ${summary.globalAgents.seeded}`, + `template ${summary.templates.seeded}`, + ]; + if (summary.templates.reclaimed.length) parts.push(`回收占位 id ${summary.templates.reclaimed.join('/')}`); + if (r.agentsRemoved.length) parts.push(`清悬空 company_hired ${r.agentsRemoved.length} 行`); + if (r.orgNodesRemoved) parts.push(`清已删公司 org 节点 ${r.orgNodesRemoved} 行`); + if (r.retained.length) parts.push(`⚠️ ${r.retained.length} 行被外键引用暂留`); + console.log(`[graph-seed] ${parts.join(',')}`); + return summary; + } catch (err) { + console.error(`[graph-seed] 启动种子失败,已降级跳过(不影响服务启动): ${err.message}`); + return null; + } +} + module.exports = { seedGlobalAgents, seedTemplates, seedCompanyAgents, verifyCounts, + reconcileDanglingRows, + runStartupSeed, + runStartupSeedSafely, }; diff --git a/server/tests/unit/graph-boundary.test.js b/server/tests/unit/graph-boundary.test.js new file mode 100644 index 0000000..61ab8bb --- /dev/null +++ b/server/tests/unit/graph-boundary.test.js @@ -0,0 +1,125 @@ +/** + * Unit Tests — graph-engine 的项目根与边界守卫(27-P3B W2/W3) + * + * 验的是「产生 → 传递 → 落库 → 读回」四环: + * W2-1 起一个 run,节点收到的任务描述里带着项目根的绝对路径(传递) + * W2-2 项目根落到 runs/{rootRunId}/artifacts/project-root.json(落库) + * W2-3 同一个 root run 的第二个节点读回**同一个**项目根,不重新分配(读回,幂等) + * W3-1 diffRepoStatus 只报「本次调用期间新出现」的那几行(守卫的判据本身) + * + * 隔离手法同 graph-engine.test.js:先设 GRAPH_DB_PATH / GRAPH_RUNS_DIR 再 require, + * 仓库 data/ 全程不被碰;agent 调用走注入的假 executor,不起 Claude CLI。 + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +describe('graph-engine 项目根与边界守卫', () => { + let wdb; + let engine; + let runsDir; + const TEST_DB_PATH = '/tmp/test-graph-boundary.sqlite'; + + beforeAll(() => { + for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) { + try { fs.unlinkSync(f); } catch {} + } + process.env.GRAPH_DB_PATH = TEST_DB_PATH; + runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-boundary-runs-')); + process.env.GRAPH_RUNS_DIR = runsDir; + for (const m of [ + '../../services/graph-db', + '../../services/graph-service', + '../../services/org-graph-service', + '../../services/work-graph-db', + '../../services/graph-run-sse', + '../../services/graph-engine', + ]) { + delete require.cache[require.resolve(m)]; + } + wdb = require('../../services/work-graph-db'); + engine = require('../../services/graph-engine'); + }); + + afterAll(() => { + for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) { + try { fs.unlinkSync(f); } catch {} + } + delete process.env.GRAPH_DB_PATH; + try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {} + delete process.env.GRAPH_RUNS_DIR; + // 同一个进程里后续文件再 require 时要拿回没有 override 的那份 + for (const m of ['../../services/graph-run-sse', '../../services/graph-engine']) { + delete require.cache[require.resolve(m)]; + } + }); + + function ensureAgents(ids) { + const db = wdb.getDb(); + const ts = new Date().toISOString(); + const ins = db.prepare(` + INSERT OR IGNORE INTO system_agents + (id, name, scope, owner_company_id, source_kind, legacy_path, status, created_at, updated_at) + VALUES (?, ?, 'public', NULL, 'global_agent', ?, 'active', ?, ?) + `); + for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, ts, ts); + } + + it('W2:项目根进提示词、落库到 artifacts/project-root.json,第二个节点读回同一个', async () => { + const companyId = 'co-boundary-1'; + ensureAgents(['impl', 'check']); + const g = wdb.createWorkGraph({ + companyId, + nodes: [ + { nodeName: 'impl', systemAgentId: 'impl' }, + { nodeName: 'check', systemAgentId: 'check' }, + ], + edges: [{ from: 'impl', to: 'check', condition: 'success' }], + }); + expect(g.errors || []).toEqual([]); + const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId }); + engine.markRunReady(run.id); + + const tasks = []; + engine.setAgentExecutor(async (ctx) => { + tasks.push({ node: ctx.nodeName, task: ctx.task, projectRoot: ctx.projectRoot }); + return { status: 'success' }; + }); + + engine.startRun(run.id, { mode: 'manual' }); + await engine.advanceOne(run.id); + await engine.advanceOne(run.id); + engine.setAgentExecutor(null); + + // ① 落库 + const recFile = path.join(runsDir, run.id, 'artifacts', 'project-root.json'); + expect(fs.existsSync(recFile)).toBe(true); + const rec = JSON.parse(fs.readFileSync(recFile, 'utf8')); + expect(rec.root_path).toBeTruthy(); + expect(fs.existsSync(rec.root_path)).toBe(true); // 目录真的被建出来了 + + // ② 传递:两个节点都拿到了项目根,且提示词里是绝对路径 + expect(tasks.length).toBe(2); + for (const t of tasks) { + expect(t.projectRoot).toBe(rec.root_path); + expect(t.task).toContain(rec.root_path); + expect(t.task).toContain('[写入边界'); + } + + // ③ 读回幂等:第二个节点没有另分配一个根 + expect(tasks[0].projectRoot).toBe(tasks[1].projectRoot); + }); + + it('W3:diffRepoStatus 只报本次调用期间新出现的仓库改动', () => { + const before = ' M server/services/graph-engine.js\n?? scratch/a.txt\n'; + const after = ' M server/services/graph-engine.js\n?? scratch/a.txt\n?? server/routes/echo.js\n M server/index.js\n'; + expect(engine.diffRepoStatus(before, after)).toEqual([ + '?? server/routes/echo.js', + 'M server/index.js', + ]); + // 没有新增就是没有越界;空快照也不该炸 + expect(engine.diffRepoStatus(after, after)).toEqual([]); + expect(engine.diffRepoStatus(null, '')).toEqual([]); + }); +}); diff --git a/server/tests/unit/graph-seed-service.test.js b/server/tests/unit/graph-seed-service.test.js index a96d131..769680c 100644 --- a/server/tests/unit/graph-seed-service.test.js +++ b/server/tests/unit/graph-seed-service.test.js @@ -1,25 +1,283 @@ /** - * Unit Tests — graph-seed-service.js verifyCounts() (T4) - * Runs the real seed functions against the actual repo fs (system/agents/, - * system/templates/, data/companies/) into a throwaway GRAPH_DB_PATH, then - * asserts verifyCounts() reports ok:true against that freshly-seeded data, - * and that it actually catches a mismatch when the DB drifts from the fs. + * Unit Tests — graph-seed-service.js + * + * 两块: + * + * A. **hermetic 夹具根**(P3B W1 起):`GRAPH_SEED_ROOT` 指向 /tmp 下现造的 + * system/agents + system/templates + data/companies,配一个一次性 GRAPH_DB_PATH。 + * 行数因此是**写死的精确值**而不是「跟着仓库现状走」——原来的写法直接对着生产 + * `data/graph.sqlite` 与真实 `data/companies/` 断言,任何人手建一家公司都会让它变红。 + * 悬空行回收 / 模板接管 / 防误伤三条规则都在这里锁。 + * + * B. **真实仓库根回归**(保留原有两条用例,断言未放松):seed 一遍真实 + * `system/` + `data/companies/` 到一次性 DB,verifyCounts() 必须 ok, + * 且往 DB 里塞一行 fs 不背书的数据时必须被抓出来。 */ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; import fs from 'fs'; +import path from 'path'; +import os from 'os'; -describe('graph-seed-service verifyCounts', () => { +/** 换掉 GRAPH_SEED_ROOT / GRAPH_DB_PATH 之后必须重新 require,两个模块都在顶层读了常量 */ +function reloadServices() { + delete require.cache[require.resolve('../../services/graph-db')]; + delete require.cache[require.resolve('../../services/graph-seed-service')]; + return { + graphDb: require('../../services/graph-db'), + seedService: require('../../services/graph-seed-service'), + }; +} + +function write(file, content) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); +} + +describe('graph-seed-service (hermetic fixture root)', () => { + let root; + let dbPath; + let seedService; + let graphDb; + + /** 夹具:1 个 global agent、3 个模板目录(其中 alpha 与 global agent 同名)、1 家活着的公司 */ + function buildFixture() { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-seed-fixture-')); + dbPath = path.join(root, 'graph.sqlite'); + + write(path.join(root, 'system/agents/alpha/agent.md'), '# alpha global'); + write(path.join(root, 'system/agents/alpha/config.json'), JSON.stringify({ name: 'Alpha Global' })); + fs.mkdirSync(path.join(root, 'system/agents/no-agent-md'), { recursive: true }); // 无 agent.md → 不算 global + + write(path.join(root, 'system/templates/_registry.json'), JSON.stringify([{ id: 'tmpl-a', benchmarkScore: 7.5 }])); + write(path.join(root, 'system/templates/tmpl-a/agent.md'), '# tmpl-a'); + write(path.join(root, 'system/templates/tmpl-a/config.json'), JSON.stringify({ name: 'Template A' })); + write(path.join(root, 'system/templates/tmpl-b/agent.md'), '# tmpl-b'); // 无 config.json + write(path.join(root, 'system/templates/alpha/agent.md'), '# alpha template'); // 与 global agent 撞 id + + write( + path.join(root, 'data/companies/live-co/company.json'), + JSON.stringify({ id: 'live-co', agents: ['tmpl-a', 'private-role'] }) + ); + write(path.join(root, 'data/companies/live-co/agents/private-role/agent.md'), '# private'); + + process.env.GRAPH_SEED_ROOT = root; + process.env.GRAPH_DB_PATH = dbPath; + ({ graphDb, seedService } = reloadServices()); + } + + function rows() { + return graphDb.getDb().prepare('SELECT id, source_kind, scope, owner_company_id, legacy_path FROM system_agents ORDER BY id').all(); + } + function rowOf(id) { + return rows().find(r => r.id === id) || null; + } + function orgNodes() { + return graphDb.getDb().prepare('SELECT company_id, system_agent_id, role_name FROM org_graph_nodes ORDER BY company_id, role_name').all(); + } + + /** 直接造一行 company_hired(模拟历史遗留 / hire() 写下的行) */ + function insertHired(id, ownerCompanyId, legacyPath) { + const now = new Date().toISOString(); + graphDb.getDb().prepare(` + INSERT INTO system_agents (id, name, scope, owner_company_id, source_kind, legacy_path, status, benchmark_score, created_at, updated_at) + VALUES (?, ?, 'company', ?, 'company_hired', ?, 'active', NULL, ?, ?) + `).run(id, id, ownerCompanyId, legacyPath, now, now); + } + + beforeEach(() => buildFixture()); + + afterEach(() => { + delete process.env.GRAPH_SEED_ROOT; + delete process.env.GRAPH_DB_PATH; + try { fs.rmSync(root, { recursive: true, force: true }); } catch {} + }); + + it('seeds exact row counts from the fixture and passes verifyCounts', () => { + seedService.seedGlobalAgents(); + seedService.seedTemplates(); + seedService.seedCompanyAgents(); + + expect(rows().map(r => [r.id, r.source_kind])).toEqual([ + ['alpha', 'global_agent'], // system/agents/alpha 有 agent.md + ['private-role', 'company_hired'], // live-co 独有角色 + ['tmpl-a', 'template'], + ['tmpl-b', 'template'], + ]); + // system/templates/alpha 与 global agent 撞 id:仍然被跳过,绝不被模板覆盖 + expect(rowOf('alpha').legacy_path).toBe('system/agents/alpha'); + + expect(orgNodes()).toEqual([ + { company_id: 'live-co', system_agent_id: 'private-role', role_name: 'private-role' }, + { company_id: 'live-co', system_agent_id: 'tmpl-a', role_name: 'tmpl-a' }, + ]); + + const v = seedService.verifyCounts(); + expect(v.mismatches).toEqual([]); + expect(v.ok).toBe(true); + expect(v.expected).toMatchObject({ global_agent: 1, template: 2, company_hired: 1 }); + }); + + it('picks up a template directory added after the first seed (W1: 新模板目录自动进表)', () => { + seedService.runStartupSeed(); + expect(rowOf('tmpl-new')).toBeNull(); + + write(path.join(root, 'system/templates/tmpl-new/agent.md'), '# brand new'); + write(path.join(root, 'system/templates/tmpl-new/config.json'), JSON.stringify({ name: 'Brand New' })); + + seedService.runStartupSeed(); + + expect(rowOf('tmpl-new')).toEqual({ + id: 'tmpl-new', + source_kind: 'template', + scope: 'public', + owner_company_id: null, + legacy_path: 'system/templates/tmpl-new', + }); + }); + + it('reclaims a dangling company_hired row that squats on a template id', () => { + seedService.seedGlobalAgents(); + // 历史遗留:公司 ghost-co 已被删,行还占着 tmpl-b 这个模板 id + insertHired('tmpl-b', 'ghost-co', 'data/companies/ghost-co/agents/tmpl-b'); + expect(rowOf('tmpl-b').source_kind).toBe('company_hired'); + + const summary = seedService.runStartupSeed(); + + // reconcile 先删掉它(没有任何外键引着),seedTemplates 再按目录重新种回来 + expect(summary.reconciled.agentsRemoved).toContain('tmpl-b'); + expect(rowOf('tmpl-b')).toEqual({ + id: 'tmpl-b', + source_kind: 'template', + scope: 'public', + owner_company_id: null, + legacy_path: 'system/templates/tmpl-b', + }); + }); + + it('takes over a squatting row in-place when a foreign key still points at it', () => { + seedService.seedGlobalAgents(); + insertHired('tmpl-b', 'ghost-co', 'data/companies/ghost-co/agents/tmpl-b'); + // 还活着的公司 live-co 有一个 org 节点引着这行 → reconcile 删不掉(外键) + graphDb.getDb().prepare(` + INSERT INTO org_graph_nodes (company_id, system_agent_id, role_name, created_at) + VALUES ('live-co', 'tmpl-b', 'tmpl-b', ?) + `).run(new Date().toISOString()); + + const summary = seedService.runStartupSeed(); + + expect(summary.reconciled.agentsRemoved).not.toContain('tmpl-b'); + expect(summary.reconciled.retained.map(r => r.id)).toContain('tmpl-b'); + // 删不掉就地转正:同一行 id 保留(外键继续有效),source_kind / scope / legacy_path 全部改写 + expect(summary.templates.reclaimed).toContain('tmpl-b'); + expect(rowOf('tmpl-b')).toEqual({ + id: 'tmpl-b', + source_kind: 'template', + scope: 'public', + owner_company_id: null, + legacy_path: 'system/templates/tmpl-b', + }); + // 引着它的节点必须还在(转正而不是删了重建的全部意义所在) + expect(orgNodes().some(n => n.system_agent_id === 'tmpl-b')).toBe(true); + }); + + it('never reclaims rows a live company still backs (防误伤)', () => { + seedService.seedGlobalAgents(); + seedService.seedTemplates(); + seedService.seedCompanyAgents(); + + // ① 目录真实存在的公司私有 agent + expect(rowOf('private-role').source_kind).toBe('company_hired'); + // ② 目录没了、但 live-co 的 company.json 仍在 agents[] 里点名它 + insertHired('tmpl-a-clone', 'live-co', 'data/companies/live-co/agents/tmpl-a-clone'); + write( + path.join(root, 'data/companies/live-co/company.json'), + JSON.stringify({ id: 'live-co', agents: ['tmpl-a', 'private-role', 'tmpl-a-clone'] }) + ); + + const r = seedService.reconcileDanglingRows(); + + expect(r.agentsRemoved).toEqual([]); + expect(rowOf('private-role')).not.toBeNull(); + expect(rowOf('tmpl-a-clone')).not.toBeNull(); + expect(rowOf('tmpl-a-clone').source_kind).toBe('company_hired'); + }); + + it('never reclaims a global_agent row even when a same-named template dir exists', () => { + const summary = seedService.runStartupSeed(); + + expect(summary.templates.skipped).toEqual([{ id: 'alpha', existingSourceKind: 'global_agent' }]); + expect(summary.templates.reclaimed).toEqual([]); + expect(rowOf('alpha')).toMatchObject({ source_kind: 'global_agent', legacy_path: 'system/agents/alpha' }); + }); + + it('drops org nodes of deleted companies but keeps live ones, and is idempotent', () => { + seedService.seedGlobalAgents(); + seedService.seedTemplates(); + seedService.seedCompanyAgents(); + + insertHired('ghost-role', 'ghost-co', 'data/companies/ghost-co/agents/ghost-role'); + const now = new Date().toISOString(); + graphDb.getDb().prepare(` + INSERT INTO org_graph_nodes (company_id, system_agent_id, role_name, created_at) + VALUES ('ghost-co', 'ghost-role', 'ghost-role', ?) + `).run(now); + + const first = seedService.reconcileDanglingRows(); + expect(first.ghostCompanyIds).toEqual(['ghost-co']); + expect(first.orgNodesRemoved).toBe(1); + expect(first.agentsRemoved).toEqual(['ghost-role']); + expect(first.retained).toEqual([]); + + // live-co 的两个节点一个没少 + expect(orgNodes()).toEqual([ + { company_id: 'live-co', system_agent_id: 'private-role', role_name: 'private-role' }, + { company_id: 'live-co', system_agent_id: 'tmpl-a', role_name: 'tmpl-a' }, + ]); + + const second = seedService.reconcileDanglingRows(); + expect(second).toMatchObject({ orgNodesRemoved: 0, orgEdgesRemoved: 0, ghostCompanyIds: [], agentsRemoved: [], retained: [] }); + }); + + it('skips org-node reconcile entirely when data/companies is absent (安全阀)', () => { + seedService.seedGlobalAgents(); + seedService.seedTemplates(); + seedService.seedCompanyAgents(); + const before = orgNodes(); + + fs.rmSync(path.join(root, 'data/companies'), { recursive: true, force: true }); + + const r = seedService.reconcileDanglingRows(); + expect(r.ghostCompanyIds).toEqual([]); + expect(r.orgNodesRemoved).toBe(0); + expect(orgNodes()).toEqual(before); + }); + + it('runStartupSeed leaves company-derived rows alone (it must not run seedCompanyAgents)', () => { + seedService.runStartupSeed(); + + // company.json 里的 private-role 没有被启动流程带进表,org 图也一行没写—— + // 那是 hire()/fire() 与 scripts/graph-seed.js 的地盘 + expect(rowOf('private-role')).toBeNull(); + expect(orgNodes()).toEqual([]); + }); + + it('runStartupSeedSafely swallows failures and returns null', () => { + process.env.GRAPH_DB_PATH = path.join(root, 'no-such-dir/nested/graph.sqlite'); + const { seedService: broken } = reloadServices(); + expect(broken.runStartupSeedSafely()).toBeNull(); + }); +}); + +describe('graph-seed-service verifyCounts (real repo root)', () => { let seedService; let graphDb; const TEST_DB_PATH = '/tmp/test-graph-seed-service.sqlite'; beforeAll(() => { if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH); + delete process.env.GRAPH_SEED_ROOT; // 真实仓库根 process.env.GRAPH_DB_PATH = TEST_DB_PATH; - delete require.cache[require.resolve('../../services/graph-db')]; - delete require.cache[require.resolve('../../services/graph-seed-service')]; - graphDb = require('../../services/graph-db'); - seedService = require('../../services/graph-seed-service'); + ({ graphDb, seedService } = reloadServices()); }); afterAll(() => { @@ -39,6 +297,23 @@ describe('graph-seed-service verifyCounts', () => { expect(v.ok).toBe(true); }); + it('every system/templates/* dir that is not a global_agent id lands as source_kind=template', () => { + const db = graphDb.getDb(); + const globalIds = new Set( + db.prepare(`SELECT id FROM system_agents WHERE source_kind = 'global_agent'`).all().map(r => r.id) + ); + const templateDirs = fs + .readdirSync(path.resolve(__dirname, '../../../system/templates'), { withFileTypes: true }) + .filter(d => d.isDirectory()) + .map(d => d.name) + .filter(id => !globalIds.has(id)); + + const seededTemplates = new Set( + db.prepare(`SELECT id FROM system_agents WHERE source_kind = 'template'`).all().map(r => r.id) + ); + expect(templateDirs.filter(id => !seededTemplates.has(id))).toEqual([]); + }); + it('catches a mismatch when the DB has a row the filesystem does not back', () => { const db = graphDb.getDb(); const now = new Date().toISOString(); diff --git a/server/tests/unit/template-verdicts.test.js b/server/tests/unit/template-verdicts.test.js new file mode 100644 index 0000000..fa8db66 --- /dev/null +++ b/server/tests/unit/template-verdicts.test.js diff --git a/system/templates/_registry.json b/system/templates/_registry.json index 13f4c17..fd0c58b 100644 --- a/system/templates/_registry.json +++ b/system/templates/_registry.json @@ -15,7 +15,7 @@ "description": "质量验收、OKR 进度审查、标准化仲裁", "benchmarkScore": 95, "trainedAt": "2026-03-25", - "usedCount": 53 + "usedCount": 62 }, { "id": "base", diff --git a/system/templates/benchmark-validator/agent.md b/system/templates/benchmark-validator/agent.md index 2842e55..66734da 100644 --- a/system/templates/benchmark-validator/agent.md +++ b/system/templates/benchmark-validator/agent.md @@ -57,9 +57,14 @@ node server/services/goal-contract.js validate --run-dir $R |---|---| | 退出 `0`,且 `sections` 里**有** `benchmark` | 正常往下走 | | 退出 `0`,但 `sections` 里**没有** `benchmark` | 本轮没有打分式标准 → **放行**(`verdict: "skipped"`),不许硬判 | -| 退出 `1`(契约非法) | **标准本身写错了**:`verdict: "fail"` + `fail_reason: "input_underspecified"`,把 `[rule] detail` 原样抄进判决 | +| 退出 `1`(契约非法) | **标准本身写错了**:`verdict: "attributed"` + `fail_reason: "input_underspecified"`,把 `[rule] detail` 原样抄进判决 | | 退出 `2`(没有契约 / 参数错) | 同上,按 `input_underspecified` 处理并写清缺什么 | +🚨 **判失败只能写 `"attributed"` 或 `"inconclusive"`,绝不能写 `"fail"`。** +引擎的 `FAILING_VERDICTS` 只认这两个(`graph-engine.js`),写 `"fail"` 它读成**没失败**, +契约非法的产物会被当合格放行,fail 边一次都不会触发——失效方向是「放行」不是「拦截」。 +取值域见下方「`verdict` 取值」表。 + ⚠️ **契约不合法不是产物的错**。判成 `impl_bug` 会把任务退回去让 coder 修一个他修不了的东西。 ### Step 2 · 逐维度给分(这是你唯一真正在做的事) diff --git a/system/templates/org-validator/agent.md b/system/templates/org-validator/agent.md index 32e95ef..34a2c5f 100644 --- a/system/templates/org-validator/agent.md +++ b/system/templates/org-validator/agent.md @@ -167,13 +167,20 @@ node server/services/goal-contract.js run --target <被测产物绝对路径> -- > ℹ️ **这份文件是审计产物,不是引擎的判据。** org graph 里每个节点都是 `subgraph` 节点, > 你这一层的成败由**你自己 work graph 的收敛结果**推出来(引擎 `deriveChildOutcome`: > 从子 run 里 `validator-triager` 那一行取 `fail_reason` / `remediation_note` / `fail_node_ref`)。 -> 所以下面的 `verdict: pass|fail` 是给人和控制台看的,**不会**被 `readNodeProduct` 当成节点成败—— +> 所以下面的 `audit_verdict: pass|fail` 是给人和控制台看的,**不会**被 `readNodeProduct` 当成节点成败—— > 真正决定「退到哪、退多细」的是归因节点写的那份 `attempt-{n}.json`。 +> +> 🚨 **字段名是 `audit_verdict`,不是 `verdict`。** 它跟 work 层判词共用同一个文件路径 +> (`nodes//attempt-.json`,`readNodeProduct` 读的就是这条路径), +> 只靠「本节点恰好是 `subgraph`」才没被当成判词。写成 `verdict` 就是把 `pass`/`fail` +> 这套审计词塞进引擎的判词字段——而引擎的 `FAILING_VERDICTS` 不认 `fail`,会读成**没失败**。 +> 用 `audit_verdict` 两套词就永远不会串台;真要被当判词读,引擎也会退到 +> 「有 `fail_reason` 就算失败」那条缺省规则上,仍然判对。 ```jsonc { "schema": "org-validator/v1", - "verdict": "fail", // pass | fail(审计用,见上方说明) + "audit_verdict": "fail", // pass | fail(审计用,见上方说明;**不叫 verdict**) "goal_id": "", "score": { "total": 72, "pass_line": 80, "by_dimension": [ { "name": "接口正确性", "weight": 0.5, "score": 60, "evidence": "goal-test-script 3 条断言 2 红" } @@ -183,7 +190,7 @@ node server/services/goal-contract.js run --target <被测产物绝对路径> -- ], "verdict_ref": "artifacts/verdicts/vd-20260816-1208-a3b4.json", // ← 执行器那次 run 落的判决 "verdict_id": "vd-20260816-1208-a3b4", // 没跑执行器就没有这两个值 = 你的判决没有机器证据 - "fail_reason": "impl_bug", // 来自 validator-triager;verdict=pass 时为 null + "fail_reason": "impl_bug", // 来自 validator-triager;audit_verdict=pass 时为 null "remediation_note": "现象: …\n根因: …\n下一轮必须: …\n再判据: …", // 原样透传,不要改写 "triager_ref": "nodes//attempt-1.json", "action": "backward", // backward | hold_for_human | null(pass) diff --git a/system/templates/testdata-validator/agent.md b/system/templates/testdata-validator/agent.md index f28138d..de0ecc1 100644 --- a/system/templates/testdata-validator/agent.md +++ b/system/templates/testdata-validator/agent.md @@ -59,9 +59,14 @@ node server/services/goal-contract.js validate --run-dir $R |---|---| | 退出 `0`,且 `sections` 里**有** `test_data` | 正常往下走 | | 退出 `0`,但 `sections` 里**没有** `test_data` | 本轮没有样本集 → **放行**(`verdict: "skipped"`),不许硬判 | -| 退出 `1`(契约非法) | **样本集本身写错了**:`verdict: "fail"` + `fail_reason: "input_underspecified"`,`[rule] detail` 原样抄进判决 | +| 退出 `1`(契约非法) | **样本集本身写错了**:`verdict: "attributed"` + `fail_reason: "input_underspecified"`,`[rule] detail` 原样抄进判决 | | 退出 `2`(没有契约 / 参数错) | 同上 | +🚨 **判失败只能写 `"attributed"` 或 `"inconclusive"`,绝不能写 `"fail"`。** +引擎的 `FAILING_VERDICTS` 只认这两个(`graph-engine.js`),写 `"fail"` 它读成**没失败**, +样本集非法的产物会被当合格放行,fail 边一次都不会触发——失效方向是「放行」不是「拦截」。 +取值域见下方「`verdict` 取值」表。 + > ℹ️ 本版的 `system/work-graph-templates/validator.json` 把你固定排在链末, > 但 `test_data` 段是**动态选入**的(27-E2E §十一)。**大多数轮次里你会走 `skipped` 这一条**, > 这是正常的,不是你没干活。**为了显得有产出而硬判,是本岗位的红线。**