# 27-P3B. 真实运行前置 —— 从 mock 走到真 Claude CLI > **所属**: [27 Graph Agent 架构](./27-graph-agent-architecture.md) 的 P3 收尾。P3 主体见 [27-P3/P4](./27-p3-p4-work-graph-console.md)。 > **版本**: v0.1 > **日期**: 2026-08-16 > **起因**: [27-REQ](./27-requirement.md) R-18(招聘归 `workgraph-planning`;停用 mock 改真 CLI) > **前置**: P3 主体已实现并提交(`c3abf4a` 起七轮),测试 719/719 全绿 --- ## 一、为什么需要这份文档 P3 主体七轮做下来,**引擎编排层是扎实的**(状态机、双层熔断、三层级联、跨层回退,都在脚本 executor 下逐环验过),但两件事一直没解决: 1. **验证一直建立在 mock 之上。** `ClaudeCliProtocol._sendMockPrompt` 完全不读提示词,只按 agent 名回放 fixture。所以前六轮报的「端到端生产成立」,验的是引擎编排,不是 agent 链路。第七轮换真 CLI 后,链路在**第一个 work 节点**就断。 2. **agent 供给链没有执行者。** work graph 节点引用的本体,没有任何流程会把它雇进公司。 本文档收敛这两件事,目标是:**能在控制台上点一下,让真实 Claude CLI 跑完一个最小任务,且不写坏宿主仓库、验收不假放行。** --- ## 二、目标与非目标 ### 目标 1. `workgraph-planning` 规划完图后自己雇齐引用到的本体(R-18.2 方案 a)。 2. 真实 Claude CLI 能跑完一个单 org node 的最小任务,产出落在分配好的项目根内。 3. 验收链路不会「假放行」。 4. 界面上有入口触发 `orggraph-planning` 真跑(R-18.1),不再靠脚本代劳。 ### 非目标 - **不做 fan-out 并行**(沿用 P3 §一:卡在实例隔离)。 - **不做 L4**。 - **不追求一次跑通复杂任务**:第一批的验收标准是「单 org node 的最小图能真跑完」,不是「开发型公司三节点全流程」。前七轮的教训是没跑起来之前的判断经常是错的,先拿到真实结果再排后面。 --- ## 三、现状核实(决定工作量的证据) | # | 事实 | 证据 | |---|---|---| | 1 | `seedTemplates()` **没有任何运行时调用方** | 只有测试与手动的 `scripts/graph-seed.js` 引用它 | | 2 | 因此 `system/templates/` 新增的本体进不了 `system_agents` 表 | 而 `work_graph_nodes.system_agent_id` 是指向该表的外键 | | 3 | 库里有 **10 条悬空 `company_hired` 行**,`legacy_path` 指向已删目录 | 含 `validator-triager` / `prd-planning` / `sdd-planning` / `brd-planning` / `benchmark-validator` / `testdata-validator` / `workgraph-planning` / `org-validator` | | 4 | `seedTemplates` 遇到 id 已被别的 `source_kind` 占用会 **skip** | 所以第 3 条那些永远变不回 `template` | | 5 | 后果链:`lookupSharedAgentMd` 恒 null → `hire` 静默退回全量拷贝 | **模板改动对已雇公司不生效** | | 6 | `resolveAgentName` 只查目录存不存在,**不做按需 hire** | 同文件的 `resolvePlanningAgentName` 对 `workgraph-planning` 倒是做了 | | 7 | `path-resolver.allocateProjectRoot` 已实现,sandbox 模式给 `data/companies/{cid}/projects/{pid}`,另有 `isSensitivePath` 黑名单 | 图执行侧没有调用它 | | 8 | 图执行侧的 agent_call 节点**没有产出位置也没有沙箱** | 真 CLI 实测已往宿主仓库写入 `server/routes/echo.js` 等三处(已复原) | | 9 | `FAILING_VERDICTS = {attributed, inconclusive}`,**不含 `"fail"`** | 而 `benchmark-validator` / `testdata-validator` 的 Step 表教 agent 写 `verdict: "fail"` | | 10 | `system/skills/` 目录**不存在** | base agent 的 Hot Swap 是 `existsSync` 守卫下的静默空转 | --- ## 四、工作项 ### 第一批 · 真跑的最小前提 做完这四件就能真跑,且不会写坏仓库、验收不会假放行。 | # | 内容 | 文件 | 量 | |---|---|---|---| | **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 要解决的问题,第一批四项做得再对也绕不过去。** > **W5/W6 落地后的复测**:把 `prd-planning` / `benchmark-validator` / `validator-triager` > 三个本体 `hire` 进一家探针公司后,`getAgentConfig(':')` 由 `THROWS` 变 **RESOLVED** > (裸名照旧 `THROWS`,符合预期——引擎走的是作用域名)。断链的解法确认就是「先雇」。 > 详见下方「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,{...})`),否则真跑的排障成本会被这一环吃掉。 > **W12 · 本轮状态:✅ 已修(选「只留痕,不加列」)** — `server/services/graph-engine.js` > > - **不加 `output` 列**(`graph_run_nodes` 保持 16 列不变)。理由:`outcome.output` 长度无上限, > 而 `getRunState` 是 `SELECT *` 全表读回,控制台每次轮询都会把所有历史输出重传一遍; > 完整产物已有地址(`output_ref` → `nodes/{nodeRunId}/`),再存一份就是第二个真相源; > `fail_reason` 是**选边的匹配键**,更不能借来装自由文本。 > - **catch 那份净损失补上**:executor 抛异常时发 `node.error`(`timelineNote`),带 > `node_run_id / node_name / attempt / node_kind / system_agent_id / error / error_type / error_stack`(栈顶 6 行)。 > 四环 = 产生(catch)→ 传递(`timelineNote`)→ 落库(`timeline.ndjson` + `stream.log`,append-only) > → 读回(`GET /api/graph-runs/:id/stream?lastEventId=0` 全量重放)。 > - **`outcome.output` 也终于有了消费方**:`node.output` 事件 data 加定长 `output_excerpt` > (≤600 字符,超长截断并注明「完整输出见 output_ref」),三个产生方(`defaultAgentExecutor` > 的 `res.output`、W3 越界 banner、catch 的 `err.message`)一并接上。前端 `NodeRunLog` > 已按 `data.node_run_id` 消费 `node.output`,无需改动即可看到。 > - W3 banner 依建议**去掉重复的改动清单**(清单仍在 `node.boundary` 事件的 `entries` 里), > 只留「为什么被判 fail」一句,免得 20 行清单把 agent 真实输出挤出摘要窗口。 > - **仍需前端配合一行**:`web/src/components/graph-console/GraphRunPane.vue` 的 > `es.addEventListener` 白名单里没有 `node.error`(命名事件不会走 `onmessage`), > 加上 `'node.error'` 这条错误才会出现在浏览器的事件轨;不加也不丢数据(在 > `timeline.ndjson` / `stream.log` 里),只是控制台看不到。 > - 锁:`server/tests/unit/graph-engine-node-error.test.js`(4 例)。 ### 第二批 · 招聘走方案 a | # | 内容 | 文件 | 量 | |---|---|---|---| | **W5** ✅ | `workgraph-planning` 模板改语义:撤掉「你不招人也不辞人」,加招聘步骤,`tools.md` 给 `company hire` 用法 | `system/templates/workgraph-planning/**` | 小 | | **W6** ✅ | 写死顺序:**先雇、再提交图**(反了必外键挂);雇失败的重试与降级 | 同上 | 小 | | **W7** ✅ 已改,🟡 真跑未验 | **work 层招聘不得污染 org graph**:`hire()` 现在会把任何被雇 agent upsert 成一行 `org_graph_nodes`。方案 a 下每次任务规划都雇人,org graph 会被越塞越满(界面走查已实际撞到)。让这类招聘不写 org 节点,或写了打标区分 | `server/services/company-service.js`、`server/services/graph-service.js` | 中 | | **W8** ✅ UI / 🟡 mock | 控制台加「重新规划组织」按钮,调 `POST /:companyId/org-graph/replan`(R-18.1)。该端点已实现,全仓库无前端调用方 | `web/src/views/GraphConsole.vue` 或 `CompanyDashboard.vue` | 小 | #### W5 / W6 落地记录(模板层已改,真跑仍未验) 改的是 `system/templates/workgraph-planning/` 三份:`agent.md`(主体)、`tools.md`、`identity.md`。 - **语义反转**:`tools.md` 的「你不招人也不辞人」那一行**已删**,替换成「你只招不辞」+ 一段明写 「旧提示词里那句已作废,以本节为准」的警示(老话可能还留在别处的上下文里)。 `agent.md` 身份段、角色边界表、`identity.md` 同步改成「图里引用到的本体由你雇」。 - **顺序写死**:工作流从七步改八步,**Step 6 招聘 → Step 7 提交图**,Step 6 开头一张表列出 「反了会怎样」的两条后果(`work_graph_nodes.system_agent_id` 外键 / 节点跑起来 `resolveAgentName` 找不到公司目录退回裸名),并写明「都发生在你已经下班之后,补不了」。八步表 + 思维隔离自查第 9 条 两处复述同一条顺序。 - **失败分级**:`status` 四档(`hired_from_template` / `already_exists` / `hired_from_base` 三档继续, `training` 一档**不算成功**——脚手架路径不写 `system_agents`,图提交必被 `rule=reference` 打回); 出错三级(瞬时故障重试 3 次 → 供不出来就换本体、同一本体最多换一次 → 都不行就**停**, 不许提交引用了没雇上本体的图)。 - **`already_exists` 的死角也写进去了**:它是提前返回,**不补 `system_agents` 那一行**。 所以「目录在、表里没有」这种组合下重雇永远只返回 `already_exists`,`rule=reference` 修不好—— 模板要求直接换掉该本体并点名汇报,避免在这里死循环。 - **提示词组装的实测结论(决定了内容该写在哪)**:图执行侧走 `graph-engine → AgentManager.callAgent → PromptBuilder.build`,而 `build()` 只拼 **`agent.md` + `company-context.md`**,**`tools.md` / `soul.md` / `identity.md` 都不进这条提示词** (`packages/agent-core/Agent.js` 那套读六个文件的 bootstrap 是另一条链路)。 所以招聘所需的一切——命令行、参数表、curl 降级、四档判读、三级分级——**全部内联在 `agent.md`**, `tools.md` 只作为人类可读的重复件。 - **实测(未起真 CLI,临时 `GRAPH_DB_PATH` + 用完即删的探针公司)**: `hire` 三个 work 层本体均 `hired_from_template`,重雇均 `already_exists`(幂等成立); 雇后 `AgentManager.getAgentConfig(':')` **RESOLVED**,裸名仍 `THROWS: Agent not found` —— 与 §三 第 6 条的断链分析完全一致,**「雇进公司」正是解法**。 另用 `PromptBuilder.build()` 实拼一次 `workgraph-planning` 的 system prompt(20.8k 字符), `company hire` / `--role` / `先雇` / 三个 `status` 取值 / `ls data/companies/` 全部命中。 - **仍未验**:真 CLI 下 agent 是否照着做(mock 不读提示词,验不了)。归第三阶段。 - **`--layer` 不是模板的入参**:模板明写「别自己发明这个参数」(`parseArgs` 严格模式,多给未知选项 会让整条命令报错)。work 层招聘写不写 org 节点由服务端判据决定,见 W7。 #### W7 落地记录(选「不写 org 节点」,判据在服务端;真跑未验) - **选「不写」而不是「写了打标」**:打标要么在 `org_graph_nodes` 加列(schema 归 `graph-db.js`)、 要么在 `readGraph`/`getGraph` 上加过滤(归 `org-graph-service.js`),两处都不在本轮可动的文件里; 更关键的是**这行不只是画图用**——`work-graph-db.js:473` 起 org run 时读的就是这张表的 active 行, 混进去的 work 层本体会变成一个**真的会被执行**的 org 节点。所以不是「显示时过滤掉」够用的问题。 - **调用方不表达层级,判据只看数据**:唯一会雇 work 层本体的真实调用方是 `POST /api/company/:companyId/hire`(`workgraph-planning` 在 CLI 里 curl 它), 该路由按字段解构 body、不透传未知字段,所以「加个 `opts.layer` 参数」在本轮会是一个 **永远没人传的死开关**(第一版就是这么写的,已删)。何况「谁雇的」本来也不该决定 「它是不是组织节点」。`company-service.decideOrgNodeRecording()` 三条判据,命中任一不写 org 节点: ① `config.type ∈ {meta-bootstrap, meta-planning}`(`orggraph-planning` / `workgraph-planning` / `brd|prd|sdd-planning` / `goal-benchmark`);② 本体在 `system/work-graph-templates/*.json` 里被 点名当节点(`planner-validator` / `validator-triager` / `benchmark-validator` / `testdata-validator` ——它们 `type='validator'`,判据①盖不住;`org-validator` 只作为 `ownerOrgNode` 出现,不会被误伤); ③ 公司已有**成型**的 org graph(存在 active 的 `org_graph_edges`)而该角色不在图上。 - **判据③为什么看边不看节点**:P1/P2 的 `hire` 自己就会造节点,拿「有没有节点」当信号会把 「同一家公司连雇两个人,第二个不入图」——`graph-agent-p1-hire.test.js` 正是这么用的——直接废掉。 第一版就是拿节点当信号,跑全量测试当场红了这一条,才改成看边。 - **不写 org 节点时仍然补 `system_agents`**(`graph-service.ensureSystemAgentRow()`,从 `upsertOrgGraphNode` 里拆出来单独导出):`work_graph_nodes.system_agent_id` 是指向该表的外键, 少这行 W6 的「先雇再提交图」照样外键挂(W1 记过同一条失效链)。 - **顺带修掉一个老问题**:org 节点 `role_name='org-coder'` 绑定本体 `coder` 时,在成型图上再雇一次 `coder` 以前会多出一行同义节点,现在不会。 - **默认行为 = 写**,P1/P2 语义在「公司还没有成型的图」这个前提下原样保住。 - **已知残留(不提前加规则)**:① 单 org node 的最小图**没有边**,判据③不生效,这时只有 ①② 认识的 本体会被拦下——`executor.json` 的 `{{implAgent}}` / `{{validatorAgent}}` 现填出来的本体仍会入图; ② `hire()` 的 `already_exists` 是提前返回,**不补 `system_agents`**(W5/W6 记录里那条死角), 本轮**故意没补**:目录在而表里没有时插一行 `company_hired` 会**squat 掉模板 id**, 正是 W1 清掉的那条失效链(`seedTemplates` 撞上被占用的 id 会 skip,且此时目录还在、 不满足悬空回收条件),代价比收益大;③ `graph-seed-service.seedCompanyAgents()`(手动脚本 `scripts/graph-seed.js` 才跑)只镜像了判据①的 `meta-bootstrap` 那半条,跑它仍会把 work 层本体 塞回 org graph——启动流程不跑它,暂时无害,等它有运行时调用方时必须同步这三条判据。 - **测试**:`server/tests/unit/company-hire-org-layer.test.js`(9 例,真 `hire()` + 从 `org-graph-service.getGraph()` 读回,即 GET org-graph 的同一条读路径)。三条判据各做过变异验证 (分别短路掉 → 红 3 / 1 / 3 例)。每条「不入图」都配了一条只换一个条件的正例, 免得测试退化成「这个角色被整体拉黑」。 - **仍未验**:真 CLI 下 `workgraph-planning` 雇完一轮后控制台左栏的实况(验收标准 7),归第三阶段。 #### W8 落地记录(按钮已有真实调用方;mock 演示仍差两环,两环都不在 UI 侧) - **入口两处,同一个组件** `web/src/components/graph-console/ReplanOrgGraphButton.vue`: `GraphConsole` 左栏(有图 → 标题栏小按钮;**无图 → 左栏空态里的主操作**,没有 org graph 的公司 整个控制台无事可做,入口在这里必须是主按钮而不是一句「先跑 orggraph-planning」的提示语); `CompanyDashboard` 的「🕸️ 组织流程」块(同样有图挂标题栏、空态给大按钮——新建公司后 用户最先看到的就是这一屏的「暂无组织流程」)。至此 `POST /:companyId/org-graph/replan` 有了前端调用方,不再只能 curl。 - **过渡态**:`posting` → `planning mm:ss`(按钮禁用防连点)→ `done` / `timeout`(6 分钟,可「继续等」)。 已有图时先 `confirm`:`writeGraph` 是整图覆盖,旧图作废。 - **刷新:轮询 `GET /:companyId/org-graph` 的图指纹(4s),不是 SSE**。replan 内部那条 agent 对话的 SSE 由服务端自己消费(`triggerOrgGraphPlanning` 的 fetch),浏览器没有把手,端点也不回 `conversationId`;控制台现成的 `/graph-runs/:id/stream` 是 graph run 的事件轨,而 org graph 规划不产生 graph run。指纹**含节点 id**(`writeGraph` 每次换新 id),否则「重规划出同一张图」 会被误判成超时。变化即 `emit('planned')` → 父组件重载左栏定义 + run 列表(旧 run 的节点与新图对不上,必须一起重拉)。 - **fixture**:新增 `fixtures/devloop-mock/orggraph-planning.json`(此前回放 `default.json`, 建公司时的自动规划是空转)。产出 `org-planner → org-coder → org-validator` + 两条 fail 回退边, 形状对齐 `system/org-graph-templates/dev.json`。实测把它 chunks 里那段图 POST 给真正的写入方: `{"ok":true,"nodeCount":3,"edgeCount":4}`,GET 读回四条边齐全(含 `input_underspecified` 那条)。 锁:`server/tests/unit/orggraph-planning-fixture.test.js`(4 例:图在 chunks 不在 result / 形状对齐 dev.json / 过五项校验 / mock 协议按 agent id 与 `{cid}:` 作用域名都能回放)。 - **🟡 mock 下「点一下就出图」还差两环**(真 CLI 模式不受影响,agent 自己跑 curl): 1. **fixture 命不中**:`_sendMockPrompt` 用 `agentInfo.name` 冒号后那段查 fixture。 `graph-engine` 传 `{cid}:{agentId}`(命中),`agent-chat.js:684` 的 `getOrCreateProtocol` 传的是 **identity.md 的显示名**——`orggraph-planning` 的 identity.md 没有「名称:」行, 退化成字面量 `'Agent'` → 回放 `default.json`(实测就是那两块 38 / 83 字符)。 `orggraph-planning` 只有 chat 这一条入口。最小修法:把 agent id 传给协议 (同时协议侧 `rawName` 要改成 `options.agentName` 优先,否则 `agentInfo.name` 恒真)。 2. **mock 没有落库环**:真实 agent 是 Step 6 自己 `curl -d @/tmp/x.json` POST `/org-graph` 写进去的,而 mock 只回放 chunks/files/result,**不执行任何工具调用**,HTTP 副作用为零。 work 层没这个问题(`extractPlannedGraph` 从 output 解析)。要让 mock 也能演示, 需在 `triggerOrgGraphPlanning` 消费完输出后 `extractPlannedGraph` → `writeGraph`(仅当图仍为空)。 实测:mock 下 `POST .../org-graph/replan` → `{"status":"replanning"}`,日志 `[Replan] ✅ 已触发 {cid}:orggraph-planning`,但 `GET /org-graph` 仍是 `{nodes:[],edges:[]}`。 **验收标准 5 仍要留到真 CLI 那一轮验。** - 顺带确认:`CompanyDashboard` 的「🕸️ 执行控制台」入口仍在(`goToGraphConsole`)。 - 附带发现(与 W1 相关,正面):`writeGraph` 会给未登记的 `systemAgentId` 自动补 `company_hired` 行; 本轮测试公司删掉后 `graph-seed-service.reconcileDanglingRows()` 一次收干净 (`orgNodesRemoved:3 / orgEdgesRemoved:4 / agentsRemoved:[org-planner, org-coder]`)。 ### 第三批 · 让结果可信 | # | 内容 | 量 | |---|---|---| | **W9** | 失败上下文进提示词:`validator-triager` 的 agent.md 承诺「给你哪个节点失败、第几 attempt、失败摘要」,全仓库没有代码提供 | 中 | | **W10** | 跨层地址要 `target_org_node` + `target_node` **两半都写**才合法;模板要写清(只写一半会静默退化成整张图重跑) | 小 | | **W11** | `system/skills/meta-*` 缺失导致 base agent 的 Hot Swap 静默空转,「EXECUTE 阶段才拿破坏性权限」只剩话术。补技能包,或如实降级并改文档 | 中 | --- ## 五、成本预估 一次 3 个 org node 的任务:3 次规划调用 + 每张 work graph 3-4 个节点,不含回退约 **15 次调用**,带一两次回退到 **20-25 次**。按 `plan-cost-concurrency.md` 的实测量级推算,单次 **$3-8、墙钟 15-40 分钟**。 **调试期一律用单 org node 的最小图**(省约三分之二)。第一批的验收就按这个规模定。 --- ## 六、验收标准 **第一批** | # | 标准 | 状态 | |---|---|---| | 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 上。**这两条应挪到第二批之后一起验。** **第二批** 5. 新建公司 → 界面点「重新规划组织」→ `orggraph-planning` 真跑 → org graph 出现,且它雇的三个 org 节点都在。 6. 下达任务 → `workgraph-planning` 真跑 → 它引用的本体被自己雇进公司 → 图提交成功(不再外键挂)。 7. 上条跑完后,控制台左栏 org graph **仍然只有 org 节点**,没有混进 `prd-planning` 这类 work 层本体。 🟡 **服务层已成立,真跑未验**:`hire()` 三条判据 + 9 例测试(含「雇一轮 work 层本体后 `getGraph` 仍只剩三个 org 节点」这条直接对应本标准),见上方 W7 落地记录; 真 CLI 下的实况归第三阶段。 **第三批** 8. `validator-triager` 的提示词里能看到「哪个节点失败、第几 attempt、失败摘要」。 9. 一次真实的跨层回退:`target_org_node` + `target_node` 两半齐全,只有被点名的节点被重置。 --- ## 七、风险 1. **成本不可控**:真 CLI 下一次跑错方向就是几美元 + 半小时。缓解:调试期强制单 org node;任务级预算的 `total_agent_calls` 压到 15 以内先跑通。 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` 的组成对应改一行即可;**不要**把断言删掉了事。