# 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 全绿 > **状态**(2026-09-08 校准): ✅ **本文档全部收口**。§六「九条验收」1-9 全部验完;正文里散落的 > `W5/W6/W7/W8/W9` 等更细粒度小项「仍未验」备注也已逐条核对——大半(W5/W6/W7/W9)本来就已经被 > 后续的真跑(§六bis/§六ter)验过,只是这份文档没有回填,本轮补齐了引用;真正之前从未验过的只有 > W8(重新规划组织按钮),本轮用隔离 worktree 起真 Claude CLI + 真实浏览器点击补验,见「W8 浏览器 > 真跑验收」一节(该节末尾记了一个真跑中顺手发现、尚待排查但不影响验收本身成立的前端细节)。 > 第一批验收 1/3 已验、2 由两次独立真跑(§六ter / §六quater)在干净环境下验掉; > 验收 4 ✅ 由零注入真实链路验掉(不需要真 Claude CLI,见该行说明); > 验收 7 ✅ **已修**(`58a2865`「W7 零边图」,守卫 `company-hire-org-layer.test.js` 的 > `CO_ZEROEDGE` + `W7-fix`,§六ter 复验 + 2026-09-08 浏览器肉眼确认)—— 下面 §六bis 缺陷③ > 那段是**当时的现场记录**,不是当前状态; > 验收 8 ✅ 由 2026-08-20 的真跑验掉, > **验收 9 ✅ 已验**(2026-09-08,隔离 worktree 真跑)——逐条证据见 §六quater。 > §六bis 缺陷② 的**根因**(边界守卫误判并行会话的改动)已于 2026-08-20 修掉, > 见 [27-P3C §17.2](../../done/system-design/27-p3c-goal-task-model.md)。 --- ## 一、为什么需要这份文档 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` 守卫下的空转(W11 后仍空转,但已改成显式 `console.warn`)。**W11 另查出**:工具权限来自 `config.json` 的 `cli.toolPolicy`(建协议时读一次,与 phase 无关),Hot Swap 换的只是提示词文本 —— 补技能包也关不上权限闸 | --- ## 四、工作项 ### 第一批 · 真跑的最小前提 做完这四件就能真跑,且不会写坏仓库、验收不会假放行。 | # | 内容 | 文件 | 量 | |---|---|---|---| | **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`,不是接线。 → **已在「W12 补完」那一轮补上**:`applyBoundaryGuard` 导出 + 多一个可选的 `after` 快照参数(生产路径仍只传三个参数、仍现拍 `hostRepoStatus()`),判罚三条分支各一例。 仍需真 CLI 验的只剩「git 扫描能不能真的扫到那次越界写」。 #### 阻断真跑的真正断点(§三 第 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 例)。 > **W12 补完 · 本轮状态:✅ 已修(`fail_reason` 那一半)** — `server/services/graph-engine.js` > > 上一轮只做了「原因文本进 timeline / `output_excerpt`」,`graph_run_nodes.fail_reason` 与 > `node.output` 的 `fail_reason` 仍然恒 `null`。**真跑与界面走查确认的后果**:点开失败节点, > **概览 tab 第一眼是「失败、原因 —」**,要知道去翻第三个 tab(运行日志)才看得到 > 「Agent not found: prd-planning」。「引擎判定的失败」与「agent 归因产出的失败」在这一列上 > 被区别对待,没有道理——本轮把三类引擎自判失败都署上名。 > > - **取值:另起一个集合 `ENGINE_FAIL_REASONS`,不动 `ATTRIBUTION_REASONS`** > (`{node_error, boundary_escape, child_run_failed}`)。理由三条,逐条写在引擎的注释里: > ① `ATTRIBUTION_REASONS` 是 `readNodeProduct` 的**入口白名单**,管的是「agent 在 > `attempt-{n}.json` 里允许写哪几个词」;往里加取值等于放宽**别人**的取值域, > 而本轮要落的是**引擎自己**的判定,两件事不共用一个白名单(也意味着 agent 仍然不能 > 自称 `node_error`,写了照旧被降级成 `null` + 一条 `invalid_fail_reason`)。 > ② 复用现成四个取值更糟:`infra_flake` **有引擎特判**(原地重跑、不烧任何计数), > `Agent not found` 这种确定性失败判成它会原地重跑到预算耗尽;`impl_bug` / > `input_underspecified` 是图上**精确 fail 边**的匹配键,典型图里指向「退回 impl 重写」 > 「退回 planner 补输入」,把「本体没雇进公司」判成 `impl_bug` 会让引擎真的退回去重写代码, > 而根因一行代码都不在那儿——**比 `null` 更坏**。 > ③ 新取值的**路由行为与今天的 `null` 完全一致**:`selectFailEdge` 精确匹配不到就退兜底边, > 现有图骨架没有任何边写这三个词。**只加信息,不改选边。** > - **三个产生点**:`advanceOne` 的 executor catch → `node_error`;`applyBoundaryGuard` > 判越界 → `boundary_escape`(agent 自己给了归因则尊重它,越界只是附加事实); > `deriveChildOutcome` 子 run 收敛为 fail 而子图一个归因都没有 → `child_run_failed` > (**只是兜底**,真有更具体的根因一律优先透传)。 > - **四环**:产生(引擎判定)→ 传递(`outcome.failReason` → `settleNode`)→ > 落库(`graph_run_nodes.fail_reason`,与状态**同一次条件更新**)→ > 读回(`getRunState` → `GET /api/graph-runs/:id` → 概览 tab;同一份值也进 `node.output` 事件 data)。 > 读回这一环本来就通(`getRunState` 一直在读这一列),断的只有第一环。 > - **前端不需要改**(名字订正:实现叫 `failInfo`,不是 `failSignal`;见下方 W12-UI 落地记录): > `NodeDetailPanel.vue` 的 `failInfo` 第一条判据就是 > `node.failReason`(W12-UI 那一轮已铺好),本轮起它会真的有值。可选优化:给这三个词 > 加中文标签(「执行器异常 / 越界写入 / 子图失败」),纯展示层,不加也不缺信息。 > - **顺带补上第一批终验点名的那处零覆盖**:`applyBoundaryGuard` 现在导出,并多一个 > **可选的 `after` 快照参数**(生产路径一字未变,`advanceOne` 仍只传三个参数)。 > 「越界 → success 降级成 fail」这条唯一有实际后果的分支,从此不必起真 CLI 就能验。 > - **改了两条既有断言**(`graph-engine-node-error.test.js:132/163`: > `expect(node.failReason).toBeNull()` → `toBe('node_error')`)。它们锁的正是本轮要改的行为, > 不是被绕过的期望;两处都留了说明为什么改。 > - 锁:`server/tests/unit/graph-engine-fail-reason.test.js`(13 例,含四处产生点各自的变异验证)。 #### W12-UI 落地记录(✅ 已完成;mock 服务 + 浏览器实点走查,未起真 CLI) 只动前端四个文件 + 一个新模块:`web/src/components/graph-console/{node-signals.js(新), GraphRunPane.vue, NodeDetailPanel.vue, NodeRunLog.vue}`、`web/src/components/WorkGraphCanvas.vue`、`web/src/views/GraphConsole.vue`。 **先修的是一个更靠前的断点:整类事件根本没到浏览器。** `GraphRunPane.connect()` 里只 `addEventListener` 了 6 个事件名,而 EventSource 的 `onmessage` **只接没有 `event:` 字段的帧**。引擎实际发 16 种,于是 `node.error`(执行器异常的原因)/ `node.triage`(归因判词)/ `node.boundary` / `node.contract` / `node.subgraph` / `loop.terminated` / `loop.cross_layer` / `plan.*` **全部被浏览器丢弃** —— stream.log 里有、timeline 里有、界面上一条都看不到。名单收敛成 `node-signals.js` 的 `RUN_EVENT_KINDS`,由 `GraphRunPane` 唯一消费。 **① 概览 tab 的失败原因:三级回落 + 来源标**(`NodeDetailPanel.failInfo`) | 级 | 来源标 | 取值 | 什么时候走这一条 | |---|---|---|---| | 1 | 引擎判定 | `node.failReason`(`graph_run_nodes.fail_reason`) | 引擎判出了根因(W12 补完那一轮起会真有值) | | 2 | 归因产出 | 本轮 `node.triage` 事件的 `fail_reason` | 归因文件里有,但还没落到节点行上 | | 3 | 摘自输出 | `node.output.output_excerpt` / `node.error.error` / 越界横幅 | **`fail_reason` 合法地为 `null`**(= 没匹配上精确 fail 边,走兜底边)时唯一能说清「到底出了什么事」的东西 | `fail_reason=null` 在这套引擎里是常态而非异常,所以旧版「只读 `node.failReason`,取不到显示 `—`」 等于让失败节点常态显示成「失败、无原因」。现在 null 也会写明「没有匹配上精确 fail 边 → 走兜底边」, 并把 excerpt 以终端块展示。`remediation_note` 同款回落(节点行 → `node.triage`), 「输入输出」tab 的 `consumedSource` 一并接上这条链。 **与并行的 W12 引擎侧解耦**:引擎给了 `fail_reason` 走第 1 级,没给走 2/3 级,两条路都实测过。 **② 单步进度感:只报真实信号,不画进度条** - **画布节点卡片**:RUNNING 节点右下角 `⏱ mm:ss` 秒表(`WorkGraphCanvas.elapsedLabels`), 状态灯改成呼吸点。秒表单独一个 computed,**不塞进 `laidOut`** —— 否则整张图的拓扑排序 + 回退边分道 每秒重算一遍。ticker 只在真有节点 running 时转。 - **详情面板**:tabs 之上一条常驻「运行中 mm:ss + 最近事件摘要(N 前)」,四个 tab 都看得见。 - **`duration` 以前是死的**:它用 `Date.now()`,而 `Date.now()` 不是响应式的, 节点在跑的那几分钟里「耗时」一格是冻住的。现在挂在只在 running 时转的 ticker 上。 - **明确不做假进度条**:引擎不知道 agent 还剩多久(CLI 不上报进度),面板上有一句 title 写明这件事。 - 真 CLI 单节点期间常常**几分钟一条事件都没有**,所以「最近事件」多半停在 `agent.call` — 秒表 + 「N 前」正是为这种真空设计的,不是补充。 **③ NodeDetailPanel 不跟 SSE 走(doc 记的已知缺陷):根因与修法** 根因不在 SSE,在 `GraphConsole.vue` 的分发:`onMidState` 会把 `selectedNode` 指到最新快照, **`onRootState` 完全不管**。而左栏(org run)选中的节点走的正是 `onRootState` 这条路 —— 于是左栏已经 `RUNNING·#2`,右栏还是上次点进去时的那张快照,要点别的节点再点回来才刷新。 修法是加一个 `selectedFrom: 'root' | 'mid'` 记住选中节点属于哪一层,两栏的刷新各自喂回右栏。 同一个字段顺带修掉两处串层:`loopRecords` 与 `events` 以前**恒取中栏那份**, 选中 org node 时读的是另一张图的账本与事件轨。另外,org node 是被拾取时才现规划出 work graph 的, 子 run 一出现就把中栏指过去(仅在下钻栈为空时,不抢用户位置)。 **顺带**:`NodeRunLog` 的日志行以前回落成 `JSON.stringify(data)`(SSE 帧里**没有** summary, 引擎只把它写进 timeline.ndjson),现在由 `describeEvent` 按事件类型拼人话。 **四环自查**(读回那一环是本轮的全部):产生(引擎 `emitEvent`/`timelineNote`)→ 传递(stream.log + SSE 具名事件,**本轮修的就是这一环漏了 10 种事件名**)→ 落库(stream.log,`lastEventId=0` 全量重放)→ 读回(`node-signals` → 面板三处)。 **验证** - `npm run web:build` 通过;`npm run test:unit` **786/786 绿,76 文件**(本轮新增 4 例,无改期望)。 - 锁:`server/tests/unit/graph-console-node-signals.test.js`。第一例是**防线**: 从 `graph-engine.js` / `graph-run-sse.js` 的源码文本扫出引擎实际发的事件名, 与 `RUN_EVENT_KINDS` 双向对账(扫不到 8 个以上直接抛,不静默空跑)。 变异验证:从名单里删掉 `node.error` → 立刻变红并报出精确名字。 ⚠️ **引擎将来新增事件名,这条会红** —— 那正是它的用途,修法是把新名字加进 `RUN_EVENT_KINDS`。 - 浏览器实点走查(`LEMONBOSS_CLAUDE_MODE=mock` 起 3010 + vite,临时公司 `w12ui-demo`, 一个 running 节点 + 一个 fail 节点 + 手工写入的事件轨): 秒表 5m29s → 5m49s 真的在走;画布卡片上 `⏱ 5m 58s`; 失败节点概览依次显示「摘自输出」→(补一条 `node.triage` 后)「归因产出 contract_violation」→ (落库 `fail_reason` 后)「引擎判定 infra_flake」,三级回落全部走到; `POST /nodes/:id/retry` 后右栏**没有任何点击**就从 `FAIL·attempt 1` 变成 `READY·attempt 2` (即缺陷③已修);运行日志 tab 的 `node.error` / `node.triage` 两类**第一次出现在界面上**; 无 console 报错。走查完临时公司已按收尾要求删净(`graph_runs` / `graph_run_nodes` / `org_graph_*` / `system_agents` 全部 0 行,`PRAGMA foreign_key_check` 空,目录已 rm)。 **未做 / 已知上限** - `agent.call` 与 `node.output` 之间那几分钟仍然**没有任何中间信号**,因为引擎没把 `onAcpEvent` 转成 `agent.chunk` / `agent.tool` 事件(`NodeRunLog` 里那条黄框写的就是这件事)。 接上之后「最近事件」会自然变活,前端不用再改。 - 事件缓冲 `MAX_EVENTS=400`,长 run 会丢最早的行(原有行为,未动)。 ### 第二批 · 招聘走方案 a | # | 内容 | 文件 | 量 | |---|---|---|---| | **W5** ✅ 真跑已验(§六bis) | `workgraph-planning` 模板改语义:撤掉「你不招人也不辞人」,加招聘步骤,`tools.md` 给 `company hire` 用法 | `system/templates/workgraph-planning/**` | 小 | | **W6** ✅ 真跑已验(§六bis:先雇后提交,图未外键挂) | 写死顺序:**先雇、再提交图**(反了必外键挂);雇失败的重试与降级 | 同上 | 小 | | ~~**W7**~~ ✅ 已修并真跑复验(`58a2865`「W7 零边图」;§六ter 验收 7 / 2026-09-08 浏览器真跑肉眼确认,见下方「W8 浏览器真跑验收」第 2 点) | **work 层招聘不得污染 org graph**:`hire()` 现在会把任何被雇 agent upsert 成一行 `org_graph_nodes`。方案 a 下每次任务规划都雇人,org graph 会被越塞越满(界面走查已实际撞到)。让这类招聘不写 org 节点,或写了打标区分 | `server/services/company-service.js`、`server/services/graph-service.js` | 中 | | **W8** ✅ 真 CLI + 真实浏览器已验(2026-09-08,见下方「W8 浏览器真跑验收」) | 控制台加「重新规划组织」按钮,调 `POST /:companyId/org-graph/replan`(R-18.1)。该端点已实现,全仓库无前端调用方 | `web/src/views/GraphConsole.vue` 或 `CompanyDashboard.vue` | 小 | #### W5 / W6 落地记录(模板层已改;真 CLI 已验,见下方标注) 改的是 `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 是否照着做~~ ✅ **已验**(§六bis「`workgraph-planning` 真跑规划 + 自雇 (W5/W6)」):真 Claude CLI 下它确实先 `hire` 两个被引用的本体、再提交图,图提交没有外键挂—— 这条写这一段时还没跑,后来第三次真 CLI 终验补上了,见 §六 验收标准第 6 条。 - **`--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 例)。每条「不入图」都配了一条只换一个条件的正例, 免得测试退化成「这个角色被整体拉黑」。 - **这条备注要拆成两半看**:验收标准 7 本身(org graph 数据层不混进 work 层本体)已经真 CLI 验过 (§六ter;另有 `company-hire-org-layer.test.js` 9 例守卫),**数据层的正确性不是悬而未决的**。 真正没验过的是**浏览器里点开控制台左栏、肉眼确认那一屏画的确实只有 org 节点**这个纯展示层 的检查——2026-09-08 一并补验,见下方「W8 浏览器真跑验收」。 #### 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]`)。 #### W8 浏览器真跑验收(2026-09-08,真 Claude CLI + 真实浏览器点击) **跑法**:隔离 git worktree(`.claude/worktrees/p3b-w8-browser-realrun`,跑完即删),后端 3011 / 前端 5174(避开宿主机已在跑的 3010/5173),真实注册账号登录(此环境没有预置的 `admin/hex1234`,那是生产库 才有的种子账号),`claude-in-chrome` 实点。 1. **建公司 → 自动规划**:`公司ID w8-verify-co`,创建后 `[Create] ✅ 已触发 w8-verify-co:orggraph-planning 规划 org graph` 真起 Claude CLI,`招聘org-planner/org-coder/org-validator` 三条日志真实出现,`GET org-graph` 读回三节点+两条 backward 边,形状与 `system/org-graph-templates/dev.json` 一致——这本身 就是验收标准 5 的「新建公司自动规划」那一半,此前从未真跑过。 2. **控制台左栏(`CompanyDashboard` 的「组织流程」块)渲染正确**:刷新页面后画面上只有 `org-planner`/`org-coder`/`org-validator` 三张卡片 + 两条回退边文字说明,没有混进任何 work 层本体 ——这是验收标准 7 此前一直缺的**纯展示层**核实(数据层早已验过,见上方 §六 验收标准 7 备注)。 3. **点击「重新规划组织」按钮**:服务端日志 `[Replan] ✅ 已触发 w8-verify-co:orggraph-planning 规划 org graph`(与建公司时的 `[Create]` 是不同的日志前缀,确认点的是 replan 端点不是恰好又触发了一次建公司) → 真起第二次 Claude CLI 会话(`Memory` 日志的 session 文件名时间戳与第一次不同)→ 完成后 `GET org-graph` 读回的图与之前**内容一致**(仍是 org-planner/org-coder/org-validator 三节点 + 两条边)。 **这就是 W5/W6/W8 三项加一起、也是验收标准 5/6 真正意义上第一次被真实浏览器点击 + 真实 Claude CLI 完整跑通**。 **顺带验出一个此前设计笔记里没预料到的细节,如实记录,不确定的部分标清楚**: - **确认的部分**:`insertNode`(`org-graph-service.js:362`)是按 `(company_id, role_name)` 走 `upsertOrgGraphNode`,重规划成同名角色时**复用同一行、同一个 `id`**(本轮实测:replan 前后 `org_graph_nodes` 里 `org-planner`/`org-coder`/`org-validator` 三行 id 分别仍是 2/3/4,一个没变)。 这与 W8 落地记录那句「`writeGraph` 每次换新 id」的设计假设**不一致**——那句话写的时候可能是对的, 实现后来演化了没有回填这份记录,又是一次「文档滞后于代码」。 - **不确定、本轮没能坐实的部分**:`ReplanOrgGraphButton.vue` 的完成检测依赖"图指纹含节点 id" (见上方落地记录),若这份笔记描述的机制仍然成立,"重规划出内容相同的图"这种情况下前端可能真的 检测不到变化、一直卡在 `planning` 态直到 6 分钟超时。本轮真跑中点击按钮后页面确实变得对自动化工具 长时间无响应(`CDP sendCommand` 超时、`screenshot` 反复报"页面繁忙"),**但不能排除是同一个页面 累积的 SSE/ACP 事件量过大导致标签页整体卡顿**(`ACP Events` 面板长时间开着、`agent_output` 事件 刷了几十条)——两种解释都有可能,本轮证据不足以二选一。刷新页面后数据本身是对的(第 3 点), 所以**不是数据错了,顶多是「完成后前端没自己发现,需要手动刷新」这一类体验缺口**,不影响 验收标准 5/6/8 本身成立。这条列为已知的后续排查项,不阻塞本文档收口。 ### 第三批 · 让结果可信 | # | 内容 | 量 | |---|---|---| | **W9** ✅ 已实现,✅ 真跑已验(§六ter 验收 8) | 失败上下文进提示词:`validator-triager` 的 agent.md 承诺「给你哪个节点失败、第几 attempt、失败摘要」,全仓库没有代码提供。已由 `describeFailureContext` 补上,详见下方「W9 落地记录」 | 中 | | **W10** ✅ | ~~跨层地址要 `target_org_node` + `target_node` **两半都写**才合法~~ —— **原表述与引擎相反,已订正**:`target_node` 是唯一必填半,`target_org_node` 可选。模板已按引擎逐字改写,并补上「org node 名从哪来」。详见下方「W10 落地记录」 | 小 | | **W11** ✅ | `system/skills/meta-*` 缺失导致 base agent 的 Hot Swap 静默空转。**选方案 (b)**:不补技能包,改成显式告警 + 如实改文档。核实出更深一层:补技能包也关不上权限闸(工具权限来自 `config.json` 的 `toolPolicy`,与 phase 无关)。详见下方「W11 落地记录」 | 中 | | **W13** ✅ 真跑已验(§六bis:整轮真跑 git status 与基线逐字相同) | agent 运行态文件从 git 移出去(守卫误判的**一半**根因;另一半是并行 agent,见 §六bis 缺陷②,W13 管不到)| 小 | #### W9 落地记录(已实现;未起真 CLI,用注入 executor 验提示词文本) - **接在哪**:`graph-engine.advanceOne` 组好 `ctx` 之后、拼产物契约之前, `ctx.task += describeFailureContext(ctx)`。**两种节点都拼**:`agent_call` 的 `ctx.task` 直接就是提示词正文;`subgraph` 的 `ctx.task` 被 `buildPlanningPrompt` 原样放进 「## ② 任务描述」,规划本体重规划时同样需要知道上一轮死在哪。 - **段落形状**(只在**确实有前序失败**时出现,没有就是空串,不塞空段落白烧上下文): ``` [前序失败上下文 —— 引擎提供,本 run 里最近的一次节点失败] 失败节点:**benchmark-validator**(node_run_id=grn_xxx) 第几次尝试:attempt-1(人类计数第 2 次) 当时落库的 fail_reason:impl_bug 失败摘要(来自 node.output):契约非法:acceptance 缺字段…(已截断,完整输出见 output_ref) 你若是归因节点(validator-triager):上面这个**节点名**就是 agent.md 里 `target_node` 三个取名来源中的第 (a) 个(引擎点名的失败节点),可直接用;判断它不是真正的根因时再走 (b)/(c)。 ``` 这一段正是 agent.md 那三个取名来源里**第 (a) 个**的供给端 —— 在此之前它恒为空, 归因只能靠 (b)/(c) 猜。 - **数据源是 `timeline.ndjson`,不是 `graph_run_nodes`**(这是本项的关键判断): 直觉写法 `SELECT … WHERE status IN ('fail','blocked')` 在**最需要它的那条路径上恰好取不到值**—— `benchmark-validator` 失败 → backward 边退回 `validator-triager` → `handleBackwardEdge` 调 `resetForwardDownstream`,而 `benchmark-validator` 正是 `validator-triager` 的 forward 下游,**被重置成 `pending` 且 `fail_reason` / `remediation_note` 一并 `SET NULL`**。等归因节点真正跑起来时,库里已经没有任何一行记着刚才谁失败了。 `timeline.ndjson` 是 append-only、永不改写已有行的审计轨,失败那一刻的 `node.output` (带 W12 那一轮加的 `output_excerpt`)原样躺在里面。 测试里显式断言了那一刻库里确实**零失败行**、而提示词里仍然有上下文。 - **顺带给 W12 的 `output_excerpt` 加了第二个消费方**:它不再只是「给人看的」, 引擎自己会把它读回来喂给下一个节点。 - **四环**:产生(节点失败时的 `node.output` / `node.error` 事件,W12 那一轮落的)→ 落库(`timeline.ndjson`,append-only)→ 读回(`readRunTimeline` + `findLastFailure`)→ 传递(`ctx.task` → `AgentManager.callAgent` 的提示词 / `buildPlanningPrompt` 的 ②)。 - **已知上限(ponytail)**:每个节点起跑前读一次整份 `timeline.ndjson`(切尾 2000 行)。 O(文件大小),而文件大小被任务级预算(十几到几十次调用)钳住,眼下无所谓; 真要拆的话升级路径是给 `graph-run-sse` 加「按 ref 倒读最近 N 条」的接口,**不是**加一列缓存。 - **作用域是「本 run」**:按 timeline 行的 `run_id` 过滤。子 run 里的失败不会串到父 run 的 提示词里(父层拿到的是 `deriveChildOutcome` 那条透传),反之亦然。 - ~~仍未验:真 CLI 下 `validator-triager` 是否真的照着 (a) 取名~~ ✅ **已验**(§六 验收标准 8 / §六ter):triager 判词原文引用了只可能来自提示词的内部 id(节点私有目录路径里的 `graph_run_nodes.id`),真的照着 (a) 取了名。 对应验收标准 8。 - 锁:`server/tests/unit/graph-engine-fail-reason.test.js` 后 6 例(含 backward 回退那条关键路径)。 #### W13 落地记录(已完成;未起真 CLI,用 mock + 服务层验) **根因**:system agent 的 Claude CLI workDir **就是它自己的源码目录** `system/agents//`, 而那是被 git 跟踪的源码树。它每跑一次就重写自己的 `todo.md` / `plan.md`,Claude CLI 还改 `.claude/scheduled_tasks.json`;边界守卫扫的是整仓 `git status`,于是这些改动被归属到 「当时正在跑的那个 work 节点」头上 —— 一个真正成功的节点被判越界 fail(W3 那次真跑的断点)。 **已移出 git 索引(`git rm --cached` + `.gitignore`)**,共 17 个文件: | 路径模式 | 判据 | |---|---| | `system/agents/*/todo.md`(8) | 内容是 agent 自写的任务流水(最大 112KB / `innovator-evolver`),无人工维护内容 | | `system/agents/*/plan.md`(5) | 同上 | | `system/agents/*/.claude/scheduled_tasks.json`(3) | Claude CLI 自己写的 cron 状态(含 `lastFiredAt` 时间戳),必然每跑必变 | | `system/agents/*/tasks/*.json`(1) | agent 自己落的任务快照,正规任务元数据在已忽略的 `system/tasklog/` | **不移的**(排查后确认是源码 / 人工维护): `system/templates/**` 全部(`todo.md` / `plan.md` 是 hire 的**拷贝源**, `company-service.TEMPLATE_FILES` 白名单从这里取)、`cron.json`(人工配置, `9f15ed3` 特意把 `enabled` 关成 false 防夜间 IO 崩,且只由 `PUT /api/agent/:id/cron` 显式改写)、 `config.json` / `agent.md` / `soul.md` / `identity.md` / `tools.md` / `user.md` / `heartbeat.md`。 已经在 `.gitignore` 里的运行态目录(`memory/` `workspace/` `conversations/` `logs/` `system/tasklog/`)不需要动 —— `memory.md` 和 `_lessons.md` 分别落在 `memory/`、`workspace/` 下,本来就没被跟踪。 **缺省逻辑**:读侧唯一入口是 `agent-chat.js:loadFile()`(`readFileSync` 包 `try/catch` → `''`), 缺失只是提示词里少一段,不崩;`_copyTemplate()` 逐个文件 `existsSync` 后再拷; 新 agent 的这两个文件由 `AgentScaffold` / `_copyTemplate` 从模板生成。 **新 clone 验证**:把这 17 个文件移走后 `LEMONBOSS_CLAUDE_MODE=mock node server/index.js` 起服务, `/api/health` `/api/agent/list` `/api/agent/:id/files` `/api/agent/:id/cron` `/api/company/templates` 全部正常,服务端日志零 error / 零 ENOENT。 **`_registry.json` 的 usedCount 同类问题**:`company-service._updateTemplateUsedCount()` 每次 hire 都 原地 +1 改写这份**人工维护**的模板清单(`template-refs.test.js` 拿它当登记表校验),于是任何一次真跑 都留一条 `M system/templates/_registry.json`。现在计数搬到 `data/template-usage.json`(已 gitignore), registry 里的 `usedCount` 退化成**冻结的人工基线**,读回时基线 + 增量相加,前端 badge 数字不回退。 四环:`hire()` 命中模板分支 `_updateTemplateUsedCount()` → 写 `data/template-usage.json` → `listTemplates()` 合并 → `GET /api/company/templates` → `CompanyDashboard.vue` / `TemplateAgents.vue` 的 `tpl.usedCount`。跑完整套件后 registry 字节不变、usage 文件里有 11 个模板的真实计数 —— 四环 + 真调用方同时被证。 **`graph-engine` 的守卫豁免保留**(结论):根治只覆盖「这批已知文件」, agent 在自己目录里新写一个没进清单的文件仍会被扫到;而且 `system/agents/*/` 里还有 `system/agents/templates/`、`system/agents/innovator-hunt/system/templates/` 这类 agent 路径解析错误留下的**历史目录**,说明它确实会往意想不到的地方写。留作第二道防线, 但注释要从「缓解不是根治」改成「根治已做(W13),这里是兜底」。 > **28 号改造后已改名并放宽**(旧名 `AGENT_RUNTIME_STATE_RE` → 现 `AGENT_REGISTRY_TREE_RE = /^system\/agents\//`): > 本节写下这条结论时,豁免是「本体根上的五个固定文件名」;28 之后 `system/agents/` 成了 > **agent 注册表**而不是纯源码树 —— 新本体的落点从(被 ignore 的)`data/companies/{cid}/agents/` > 搬到了(被 git 跟踪的)`system/agents/{id}/`,于是 `hireFromBaseMetaAgent` / agent-creator > 在运行期新建的目录会以 `?? system/agents/{newId}/` **一整条目录项**进 `git status`, > 五文件名的名单接不住 → 一个**真正成功**的招聘节点被判 `boundary_escape` fail。 > gitignore 永远挡不住这一类(新本体的骨架本身就是要进版本库的),所以必须在守卫侧解决: > 整棵注册表树归 `unattributable`(留痕不判罚),判罚留给 `server/` `web/` `packages/` 这些真源码目录。 > 两道防线因此不重叠 —— gitignore 管「运行态不进 git」,守卫管「注册表树里的合法写入不判罚」。 > 详见 28 号设计 §5.5b。 **测试**:`server/tests/unit/agent-runtime-state.test.js`(6 例)—— 锁 `git ls-files` 不再返回这些路径、 `git check-ignore` 真的挡得住、模板本体仍被跟踪、usedCount 三环 + 坏文件降级。 #### W10 落地记录(已完成;只改模板 + 加一个测试文件,未起真 CLI) **先说结论:W10 原文写反了。** 原文说「`target_org_node` + `target_node` 两半都写才合法, 只写一半会静默退化」。拿到引擎上实跑,**只写 `target_node` 是完全有效的**: | 环节 | 代码(`graph-engine.js`) | 实际行为 | |---|---|---| | 产生 | `readNodeProduct`:`normalizeNodeRef({ orgNode: doc.target_org_node, workNode: doc.target_node })` | 读的就是这两个**顶层**键 | | 合法性 | `normalizeNodeRef`:`if (!workNode) return null` → `orgNode` 取不到就写死 `null` | **只有 `workNode` 缺失才判非法**;`orgNode` 缺省是合法的 | | 落库 | `settleNode` → `failNodeRef` 列(与 `fail_reason` 同一次条件更新) | 存的是 `{"orgNode":null,"workNode":"…"}` | | 解析 | `resolveCrossLayerTarget` 第 ④ 关:`if (ref.orgNode && ref.orgNode !== orgNodeName)` | `orgNode` 为 null 时**这一关根本不进**,以回退边的目标为准 | | 读回 | `NodeDetailPanel.vue` 的 `readRef`(`v.workNode` 为真才算有地址)+ 概览渲染 `failRef.orgNode \|\| '(按回退边的目标)'` | 前端本来就按「org 那半可缺」写的 | **真正无声的是反过来那种写法**:只写 `target_org_node`、不写 `target_node`。 此时 `normalizeNodeRef` 返回 null,而降级 timeline 的守卫是 `if (doc.target_node != null && !failNodeRef)` —— 你根本没写 `target_node`,**一条记录都不会留**,控制台只显示「整张 work graph 重跑」。 这是这条链上唯一一处完全没有痕迹的退化。 **改了什么**(`system/templates/validator-triager/{agent.md,tools.md}`): 1. 粒度块的键表加了「必填?」一列,`target_node` 标 ✅ 必填、`target_org_node` 标 ⭕ 可选, 并把「两个都给全」的措辞整段换掉(那句话会诱导 agent 去**编** org node 名, 恰是同一份 agent.md 自己那条红线); 2. 明写反向那种写法是「全链路唯一一处完全无声的退化」,要删就两个键一起删; 3. 四道关的第 ④ 关改成「**若你写了** `target_org_node`」,与 `if (ref.orgNode && …)` 对齐; 4. **补上原本缺的那一环:`` 从哪来**。agent 的提示词里没有 org 层上下文 (`describeTask` 只拼 task-goal + 任务标识),但 `[节点产物目录]` 那段给的是**绝对路径** `…/data/companies//runs//nodes//`(`nodeArtifactDir` → `runRootDir`,用的是 `run.root_run_id`,子 run 里的节点也落在根 run 目录下)。切出 `runs/` 后面那一段就能 `GET /api/graph-runs/` 拿到**组织层**那张图: `run.graph_kind==="org"` 确认层级,`nodes[].name` 是 org node 名、`nodes[].nodeKind` 筛出 `subgraph`、`nodes[].childRunId` 再查一层就是可填进 `target_node` 的候选节点名。 原 tools.md 里那两条 curl 是对的,但 `` 一直**没有出处**,agent 填不出来。 **测试**:`server/tests/unit/graph-cross-layer-address.test.js`(3 例,全部走注入假 executor,不起 CLI) —— ① 只写 `target_node` → 定点重置生效、`impl-b` 一次都没重跑、账本 `target_node_ref` 里的 `orgNode` 由引擎补成 `coder`;② 只写 `target_org_node` → 地址整条没落库、整张子图重跑; ③ 模板里的键名与引擎逐字一致,且不许出现 `targetNode` / `target_work_node` 之类近似拼写、 不许再出现「两个都给全」「两半都写」这两句已被推翻的措辞。 > 顺带说明**为什么不去修引擎那处无声降级**(给「只写了 org 半」也补一条 timeline): > `graph-engine.js` 本轮由别的并行线独占,本轮只在模板侧堵。要修的话是把 > `if (doc.target_node != null && !failNodeRef)` 放宽成 > `if ((doc.target_node != null || doc.target_org_node != null) && !failNodeRef)`,**一行**,留给后续轮次。 #### W11 落地记录(已完成;选 **方案 (b)**) **选 (b) 的论证 —— 关键证据是本轮新查出来的,不是原文里那条**: `system/skills/` 目录确实不存在(`agent-chat.js` 的 `existsSync` 恒 false),这一点原文没错。 但补出四个 `meta-*` 技能包**并不能兑现「EXECUTE 阶段才拿破坏性权限」**: - Hot Swap 做的事只有 `copyFileSync(sourceSkill, agentDir/.claude/skills/main_skill/SKILL.md)`; 那个文件的**唯一消费者**是 `buildSystemPrompt`,被当作提示词文本拼进「我的核心主技能」一段。 也就是说 **`meta-*` 能改的只有话术,改不了权限**。 - 工具权限的**唯一**来源是 agent 自己 `config.json` 的 `cli.toolPolicy`, 由 `getOrCreateProtocol` 在**建协议时读一次**转成 `--allowedTools` / `--disallowedTools`, 与 `phase` **没有任何关联**。 - 更直接的反证:`system/templates/base/config.json` 现在是 `{"mode":"restricted","allowedTools":["bash","read_file","str_replace"]}` —— **`bash` 和 `str_replace` 从 RESEARCH 第一秒就在手上**。所以「RESEARCH 阶段照样能写文件」 的成因是 `config.json` 直接给全了工具,**不是**技能包缺失。 **收尾补一处:agent 自己的提示词还在骗它自己(2026-08-17)** 上面查清了「技能包改不了权限」,但 `system/templates/base/agent.md` **仍然在对 agent 本人** 写着这三句: - 开场「你当前默认只被挂载了 `meta-research` 这个元技能」—— 那个包不存在,没挂载过; - 阶段三「*系统给你换上 `meta-execute` 技能时,你才会拥有修改核心文件的破坏性权限*」 —— **这是纯假承诺**,权限自始至终由 `config.json` 决定,与 phase 无关; - 阶段一/二「给你换上 Plan 技能」—— 同上。 方案 (b) 的本意是「如实降级」,只把 `agent-chat.js` 的空转喊出来是喊给**运维**听的; agent 读的是 `agent.md`,那一侧不改,模型就会继续以为「前三阶段我写不动文件」—— 而它其实从第一秒就握着 `bash` 和 `str_replace`。**一个以为自己被拦着的 agent, 比知道没人拦它的 agent 更危险。** 已改成如实表述:开场加一段⚠️说明「技能包不存在、换装不会发生、权限全程不变、 四段式真正给的是**上下文隔离**而不是权限隔离」,并把三处「换上 X 技能」改成 「记忆已清空,你手上只有 」。阶段三那句改成 「你的写入权限从第一阶段起就没变过 —— 『前三步不落盘』是纪律要求,不是系统给你的限制」。 至此 W11 的 (b) 才算真的闭合:**运维侧有告警、agent 侧不再被误导、文档侧记清了根因**。 真要落地按阶段收权限,见 27-P3/P4 §九bis 的结论(必须让 `transition` 同期改写生效中的 `toolPolicy`,且它与「四段式改写成引擎上的标准图模板」是同一件事的两半)。 结论:走 (a) 会造出一个**比现状更危险的状态** —— 四个文件躺着、日志说「已切换」、文档可以打勾, 而权限闸门一如既往地开着。这正是 T9 那次「落地了降级却既没核实也没回改文档」的同型。 真要落地阶段化权限,必须让 `transition` 同时按 phase 改写生效中的 `toolPolicy`, 那与 §九bis 已列为「P3 之后」的「四段式改写成引擎上的标准图模板」是同一件事 (改造后每个阶段是一个独立节点、独立一次 agent 调用,`toolPolicy` 才第一次有机会按阶段取值)。 **改了什么**: - `server/routes/agent-chat.js` Hot Swap 段补 `else` 分支,打一条 `console.warn`, 写明「元技能包不存在(附完整路径)→ 本次流转**未换装** → 工具权限仍由 `config.json` 的 `cli.toolPolicy` 决定,与阶段无关」。**只加了这一个 else 分支,别处未动。** - `27-P3/P4` §九bis:Hot Swap 那行由「❌ 静默失效」改成「❌ 仍然失效(不再静默,改成显式告警)」, 新增一整块「为什么不选方案 a」的论证与证据;「另两处顺带发现」(`phase` 无校验、 可跳阶段、直接拼进路径)标注为**均未修、仍然成立**;§九bis 末尾那三条改造收益的第 2 条 补上「改造后 `toolPolicy` 才有机会按阶段取值」这半 —— 那正是方案 a 单独做不到的部分。 **没做什么(明确不留第三种状态)**:`system/skills/` 目录仍然不存在,四个 `meta-*` 技能包**一个都没建**。 这一维的**能力没有变化**,变的只是它不再无声。 --- ## 五、成本预估 一次 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` 在宿主仓库无任何新增/修改 | ✅ **2026-08-20 干净环境重验通过**(§六ter 验收 2):19 次真 agent 调用之后 `git status --porcelain` 完全为空,交付物全部落在 `data/companies/e2e-demo/projects/p-bkhylu/` 内。**2026-09-08 又一次独立复验**(§六quater,`accept9-mtsksam9` 那次真跑):15 次真调用、backward_count=3,全程未在宿主仓库留下任何改动。这条此前"仍未在干净环境下重验"的备注已过期,两次独立真跑都证实成立 | | 3 | 故意让节点往产物目录外写,被守卫拒绝并在 timeline 留痕 | ✅ **真 CLI 已验**(2026-08-17,§六bis):git 扫描确实扫到了宿主仓库里的那处改动,`node.boundary` 事件带完整 `entries`,结果被降级判 fail 且 `fail_reason='boundary_escape'` 落库。**但同一次也验出了误判**:那处改动是并行的 Master agent 写的,不是被判罚的节点写的(W3「拦不住」第 5 条,见 §六bis 缺陷②)。判罚逻辑侧另有 W12 补完那一轮加的三条分支用例。**2026-08-20 补记**:误判那一半已修([27-P3C §17.2](../../done/system-design/27-p3c-goal-task-model.md),`BOUNDARY_GUARD=strict|record` 两个开关 + 8 例守卫,含「仓库干净时同样一处越界仍判 fail」的对照) | | 4 | 造一份非法契约跑 `benchmark-validator`:判决为失败且引擎按失败处理(**不是** success) | ✅ **由零注入真实链路验掉**(`server/tests/unit/graph-real-chain-smoke.test.js` ⑥,进 CI):非法契约真实驱动到 `readNodeProduct` → `callAgentNode`,节点判 `fail`、`fail_reason=input_underspecified`、沿 fail 边点亮下游,判词落库可读回,反向验过(`verdict` 改成 `passed` 立刻红)。**这不是起了真 Claude CLI**——只换掉最外层 CLI 子进程为 fixture 回放,其余代码路径(`AgentManager`/`readNodeProduct`/`settleNode`/边界守卫)全部真实,按 27-P3/P4 §十「四条硬规矩」第 2 条的标准算「零注入」而不是「假 executor 摆出来的分支覆盖」 | > 2 与 3 原计划要真 CLI 才验得了,实际 2 后来靠两次独立真跑(§六ter / §六quater)验掉, > 4 靠零注入真实链路(不需要真 Claude CLI)验掉——都已完成,不用再挪到第二批之后。 **第二批** 5. 新建公司 → 界面点「重新规划组织」→ `orggraph-planning` 真跑 → org graph 出现,且它雇的三个 org 节点都在。 ✅ **真 CLI + 真实浏览器已验**(2026-09-08,见下方「W8 浏览器真跑验收」):新建公司自动规划、 点击按钮触发的重新规划,两条路径都真起了 Claude CLI,`org-planner`/`org-coder`/`org-validator` 三个 org 节点都真实出现在图里。 6. 下达任务 → `workgraph-planning` 真跑 → 它引用的本体被自己雇进公司 → 图提交成功(不再外键挂)。 ✅ **真跑已验**(§六bis 验收点「`workgraph-planning` 真跑规划 + 自雇(W5/W6)」):真 Claude CLI 下 `wg_55c7e53f959f` 规划出两节点 + 正向边 + backward 兜底边,`org-writer-impl`/`org-writer-validator` 引用的两个本体都被 `workgraph-planning` 自己 `hire` 进 `data/companies/p3bfin/agents/`,图提交没有 外键挂。触发方是 Master agent 的自主巡检派单,不是人工点「下一步」,走的是同一条链路、同一个端点。 7. 上条跑完后,控制台左栏 org graph **仍然只有 org 节点**,没有混进 `prd-planning` 这类 work 层本体。 ✅ **已修**(`58a2865`「W7 零边图」,2026-08-17 当天)。判据加了主判据 `authored_by='planner'` 的节点存在 —— 「orggraph-planning 提交过一张图」的直接证据, 零边也认;边数降为存量行的回落判据。守卫 `company-hire-org-layer.test.js` 的 `CO_ZEROEDGE`(零边图上 work 层本体不入图)+ `W7-fix`(零边图上图里点名的 org 节点 重复雇仍入图,P2 语义不被误伤)。 **以下是修之前的现场记录,留档不是当前状态**:三条判据都盖不住 「零条边的最小 org 图 + 一个既非 `meta-*`、又没在 work graph 骨架里点过名的本体」, `readme-writer` 被真的写进了 `org_graph_nodes`,`GET /org-graph` 返回两个节点, 下一次任务的左栏并排显示两张 org 卡片。 服务层那 9 例测试仍然全绿——它们造的是**有边**的成型图,正好绕开了这个形状。 **第三批** 8. `validator-triager` 的提示词里能看到「哪个节点失败、第几 attempt、失败摘要」。 ✅ **真 CLI 已验**(§六ter 验收 8,2026-08-20):判据是 triager 的判词原文引用了只可能来自 提示词的内部 id——「`org-coder-impl` 私有目录 `rn_96a8cd696dcc` 下只有 `_research.md`」, `rn_96a8cd696dcc` 是 `graph_run_nodes.id`,agent 没有任何别的途径知道它,只能来自 `describeFailureContext` 注入的那一段,即真的照着取名来源 (a) 用了。 9. 一次真实的跨层回退:`target_org_node` + `target_node` 两半齐全,只有被点名的节点被重置。 ✅ **真 CLI 已验,但落地形状比原表述更保守**(§六quater,2026-09-08):真跑撞上的是 「回退目标本身就是 org node、没有更细粒度可指」这种情形——triager 没写 `target_node`/ `target_org_node`(图上没有 `org-coder` 可指),退化成「由 org 层 fail 边直接决定重跑哪个 org node」,是 R-17.1 设计里最保守的一档,行为符合预期。「两半地址齐全」这个更细的子情形 (归因精确指到某个 org node 内部的具体 work 节点)仍然只有 `graph-cross-layer-address.test.js` 的注入式覆盖,没有真跑验过——但这不影响验收 9 本身成立(原始表述的核心诉求是「org 层回退边 真的能指向 subgraph 节点」,已经证实)。 --- ## 六bis、第三次真 CLI 终验(2026-08-17,未改一行代码) **跑法**:主仓库 vite(5173,前端代码)+ **worktree** `.claude/worktrees/wf_55ba803e-5ac-6` 的真 CLI 服务 (3010,`data/` 独立)。worktree 先 `git reset --hard 3d9cb5b`,再把主工作树里未提交的本轮改动 (14 改 + 5 新增 + W13 的 17 个 `git rm --cached`)拷进去。公司 `p3bfin`,单 org 节点最小图。 预算 `total_agent_calls` 实跑 2/15。跑完公司已删净(DB 四表 0 行、`PRAGMA foreign_key_check` 空、目录 rm)。 | 验收点 | 结果 | |---|---| | 建公司 → `orggraph-planning` 真跑落图并自雇(R-18.1) | ✅ 成立。创建后 1 分钟内 `org_graph_nodes` 出现 `org-writer`,`orggraph-planning` / `workgraph-planning` 按 W7 判据①**没有**混进 org 节点(日志逐条留痕) | | 界面点「下达任务」→ 起 run(W14) | ✅ 成立。点按钮 → 弹出「任务描述 + 手动/自动」→ 点「下达」→ 新 root run `run_9da101273663` RUNNING、预算条 `calls 0/100` 出现、入口节点 READY | | `workgraph-planning` 真跑规划 + 自雇(W5/W6) | ✅ 成立。`wg_55c7e53f959f` 两节点(`org-writer-impl`→`readme-writer`、`org-writer-validator`→`planner-validator`)+ 正向边 + backward fail 兜底边;两个本体都被它自己 `hire` 进 `data/companies/p3bfin/agents/`,图提交没有外键挂。**这一段是 Master 自主巡检下的单,不是我点的「下一步」**——同一条链路、同一个端点 | | RUNNING 中的节点有进度感(本轮新增) | ✅ 成立。org 节点卡片 `⏱ 2m 23s` 秒表在走 + 呼吸点;详情面板常驻条「运行中 2m 40s · 最近事件 `plan.call` 调 workgraph-planning 规划子图(第 1 轮)(2m 40s 前)」;work 节点上换成 `agent.call · 调用 readme-writer(attempt #1)`;概览 tab 的「耗时」不再冻结 | | 跑一个真 work 节点 → success | ⛔ **本轮没跑到**。`org-writer-impl` 跑了 7m19s 后 fail,真实根因是 `AgentManager._runViaProtocol` 的 `idleTimeoutMs` 硬编码 120s:`readme-writer` 通读 `graph-engine.js` 期间静默超过 2 分钟被掐死(`idle timeout: no stream data for 120000ms`)。详见下方「本轮查出的三处缺陷」① | | 失败节点的概览 tab 能看到原因(不是 —) | ✅ 成立。work 节点与 org 节点的概览都显示 `fail_reason` = `[引擎判定] boundary_escape`,下面一块终端样式 excerpt 写明「[边界守卫] …留下 1 处改动」+ agent 原始错误串;「运行日志」tab 里 `node.boundary` / `node.output` / `node.status` 七条事件都到了浏览器(W12-UI 的具名事件白名单在真 CLI 下生效) | | `fail_reason` 真落库 | ✅ 成立。`graph_run_nodes.fail_reason='boundary_escape'`,work 节点与**父 org 节点**两行都有(子 run 终态回写父节点那个修复没被打破)。四环(产生→传递→落库→读回)在真 CLI 下走通 | | W13:真跑期间 `git status` 不再出现 agent 运行态条目 | ✅ 成立。整轮真跑(含 `agent-creator` / `master` 各一次真 CLI)结束时 worktree `git status` 与基线**逐字相同**;`git check-ignore -v` 四条模式全部命中;`system/templates/_registry.json` **零 diff**,3 次 hire 的计数落在已忽略的 `data/template-usage.json`。守卫这一轮**没有**用到 `splitUnattributable` 兜任何一条 agent 运行态文件 | | W3 守卫:真 CLI 下 git 扫描能不能扫到越界写 | ✅ **扫得到**(第一批验收标准 3 从 🟡 转 ✅)—— 但扫到的是**并行的 Master agent** 写的,不是本节点写的。见下方缺陷② | | W10 跨层地址两半齐全 | ⚪ **本轮无回退发生**(没有节点走到归因产出这一步),未验 | | 第一批验收标准 2(产出全落项目根、宿主仓库无新增) | 🟡 **一半成立**:项目根 `data/companies/p3bfin/projects/p-24vxi8/` 真的被分配并写入(`workspace/workgraph-plan-T-*.md`、`workgraph-T-*.json`),`project-root.json` 四环闭合;但宿主仓库**有**两处新增改动 —— 来源是并行的 Master agent(缺陷②) | ### 本轮查出的三处缺陷(都未改代码) **① `AgentManager` 的 120s idle timeout 掐死长思考的 work 节点** — `server/src/core/agent-manager.js:324` ```js const idleTimeoutMs = this.config?.agents?.[agentName]?.idleTimeout || 120_000; ``` 同进程里 `ClaudeCliProtocol` 早就把自己的 idle 上限放到 24h(日志里那句「🔧 工具执行中 → 超时切为 86400s」), `stream-executor` 的 JSDoc 默认值写的是 `1_800_000`(30 分钟)。这里的 120s 把两者都压掉一个数量级, 而且 `...options` 里若透传 `idleTimeoutMs` 会被下一行覆盖掉 —— 调用方无法覆写。 真 CLI 下任何一个「先读几千行源码再动笔」的 work 节点都会在动笔前被杀,产物为空。 **这是本轮「真 work 节点 → success」没跑成的唯一原因**,与 W1-W14 无关,属新缺陷。 **② 边界守卫在有并行 agent 时误判(W3「拦不住」第 5 条真实发生)** `org-writer-impl` 跑到第 7 分钟时,**另一个进程里的 Master agent**(`retrain-watchdog` 每 5 分钟一次的智能巡检唤起) 在同一个 worktree 里改了两个源文件: - `server/tests/unit/company-hire-org-layer.test.js`(+82/-6,给 W7 加了一条判据④和配套用例) - `server/src/core/agent-manager.js`(改上面那个 idle timeout;且引用了**未定义**的 `DEFAULT_IDLE_TIMEOUT_MS`, 真跑起来是 ReferenceError) 守卫的 `git status` 差集把第一处算到了正在跑的 work 节点头上 → `boundary_escape` → 该节点被降级判 fail。 两处改动已 `git checkout` 复原(diff 存档 `/tmp/master-agent-pollution{,-2}.diff`),主仓库全程零污染。 **结论**:真跑期间不能有任何别的 agent 在同一工作树里活动,否则守卫必误判; `retrain-watchdog` 的 Master 巡检就是这样一个 agent,它默认开着且没有开关。 **③ W7 的「已知残留」在真跑里被踩中,且后果比注释写的更直接** `decideOrgNodeRecording` 判据③要求「公司已有 active 的 `org_graph_edges`」, 而单 org 节点的最小图**零条边**,判据③恒假;`readme-writer` 既不是 `meta-*`(判据①) 也不在 `system/work-graph-templates/*.json` 里(判据②)—— 于是它被写进 `org_graph_nodes`。 `GET /api/company/p3bfin/org-graph` 实测返回 **两个** org 节点(`org-writer` + `readme-writer`), 下一次「下达任务」的控制台左栏就并排显示两张卡片、两个都 READY、都会被 `advanceOne` 当 org 节点执行 (截图 `screenshot-1786937704828-44.jpg`)。 → **验收标准 7 在「零边图」这一形状下不成立**,状态应从 🟡 改为 ⛔(下方已改)。 **④ 顺带:`blocked` 的 root run 让控制台变成死胡同**(前端/后端取值域不一致) `web/src/views/GraphConsole.vue:183` 的 `ACTIVE_RUN` 含 `blocked`, 而 `server/routes/company.js` 的 `ACTIVE_ROOT_RUN_STATUS` 只有 `['ready','running','paused']`。 run 收敛成 `blocked` 后:「下达任务」按钮被前端藏起来(后端其实会放行), `GraphRunPane` 的「停止」也被 `can('stop')` 置灰(后端 `POST /:id/stop` 实测能把 blocked → stopped)。 **一家公司只要失败过一次,控制台上就再也下不了第二个任务**,只能走 API。 按 W14 那段注释自述的意图(「按钮的显隐要和端点的 409 条件一致」),这是它自己没对齐。 ### 本轮没做的 - **没点「下一步」**:断点(缺陷①)在第一个 work 节点上已经明确,再花一次 3-5 分钟的规划调用 只是重跑一条同一轮里已经走通的路径,按 §七.1 的成本纪律停手。 - **没验 W10**(没有回退发生)、**没验第一批标准 4**(`benchmark-validator` 没进这张图)。 - **没造「引用不存在本体」的失败节点**:真跑自己给出了一个更真实的失败样本 (`boundary_escape` + idle timeout),第 6 项的两个验收点(概览有原因、`fail_reason` 落库) 已由它满足,不必再造一个。 --- ## 七、风险 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` 的组成对应改一行即可;**不要**把断言删掉了事。 --- ## 六ter、第四次真跑复验(2026-08-20) **跑法**:主仓库、真 Claude CLI、工作区干净(`git status` 空)。公司 `e2e-demo`, 把 2026-08-18 卡住的那条 run(`run_9168bd24f46c`,做 lemonai 官网)解开后逐节点 `/next` 推到收敛。 19 次真 agent 调用、177,462 tokens,全树 `completed`,goal → `done`。 过程记在 [27-P3C §17.5](../../done/system-design/27-p3c-goal-task-model.md)。 ### 逐条结果 | 验收 | 结果 | 证据 | |---|---|---| | **2** 产出落在项目根内、宿主仓库 `git status` 无新增/修改 | ✅ **成立** | 19 次真 agent 调用之后 `git status --porcelain` **完全为空**;交付物(`index.html` / `styles.css` / `app.js` / `assets/img/` / `DELIVERY.md`)都在 `data/companies/e2e-demo/projects/p-bkhylu/` 内。这是 §六bis「后半被并行 agent 打破」那一条的干净环境重验 | | **3** 越界被守卫拒绝并留痕 | ✅ 早已成立;误判那一半 2026-08-20 修掉 | 见 [27-P3C §17.2](../../done/system-design/27-p3c-goal-task-model.md) | | **4** 非法契约 → validator 判失败且引擎按失败处理 | ✅ **成立**,但**不是真 CLI 验的** | `graph-real-chain-smoke.test.js` ⑥(零注入链路,只换掉 CLI 子进程):fixture 产出 `verdict: "attributed"` → 节点 `fail`、`fail_reason=input_underspecified`、沿 fail 边点亮下游、判词落在下游节点行上。反向验过:verdict 改成 `passed` 立刻红 | | **7** work 层本体不混进 org graph | ✅ 已修(`58a2865`) | `CO_ZEROEDGE` / `W7-fix` | | **8** validator-triager 的提示词里有「哪个节点失败、第几 attempt、失败摘要」 | ✅ **成立(真 CLI)** | 判据是**它引用了只可能来自提示词的内部 id**:triager 的判词原文写着「`org-coder-impl` 私有目录 `rn_96a8cd696dcc` 下只有 `_research.md`」—— `rn_96a8cd696dcc` 是 `graph_run_nodes.id`,agent 没有任何别的途径知道它,只能来自 `describeFailureContext` 注入的那一段 | | **9** 一次真实的跨层回退,两半地址齐全、只重置被点名的节点 | 🟡 **产出端通了,消费端没等到那个形状** | 见下 | ### 验收 9 的准确状态 真跑里 triager **确实产出了两半地址**: ``` node.triage | 归因 verdict=attributed fail_reason=impl_bug → {"orgNode":"org-coder","workNode":"org-coder-impl"} ``` 这一条本身有分量:[27-P3/P4](./27-p3-p4-work-graph-console.md) §八.22 的**第 13 处**记的正是 「引擎四环全通,但产出方 `validator-triager` 的 agent.md 里根本没有这个键」—— **那半条现在通了**,真实 agent 真的写出了 `orgNode` + `workNode`。 但紧接着的一条: ``` node.triage | 归因点名了 org-coder-impl,但回退目标 org-coder-impl 不是 subgraph 节点,忽略跨层地址 ``` **这不是缺陷,是判据在正常工作**:这次回退发生在 **work 层内部** (`org-coder-validator` → `org-coder-impl`),`edge.to` 本身就是 `agent_call` 节点, 已经是最细粒度,没有「跨层」可言 —— `resolveCrossLayerTarget` 第 ② 关就是拦这个的。 所以验收 9 缺的是**那个形状本身**:需要一次 **org 层的回退边、且目标是 subgraph 节点**。 今天这条 run 的三个 org 节点重试后一次过,没有 org 层失败。 > **别把「机制没验」和「机制没有」混为一谈**:定点重置的逻辑侧有 > `graph-cross-layer-address.test.js` 三例注入式覆盖(只写 `target_node` / 只写 > `target_org_node` / 模板键名逐字对齐)。缺的只是「真跑里发生过一次」。 ### 顺带发现的一条缺陷(未修) 真跑的 timeline 里,**每个节点的产物索引写入都失败了**,且被降级成一条日志: ``` node.output | 产物索引写入失败 contract.json: UNIQUE constraint failed: index 'idx_graph_files_path' node.output | 产物索引写入失败 sdd.md: UNIQUE constraint failed: index 'idx_graph_files_path' ``` **根因已查明并修复(2026-08-20)**,而且它是个**存量库专属**缺陷: 生产库里还留着产物索引加「版本」之前的那条**无条件**唯一索引 `idx_graph_files_path`(`UNIQUE(company_id, COALESCE(task_id,''), rel_path)`)。 加版本之后建表块换成了带谓词的 `idx_graph_files_current`(`WHERE superseded_by IS NULL`), **但没人删旧的那条** —— `CREATE ... IF NOT EXISTS` 只管不重复建,不会删已有的。 于是 `recordFile` 那套「先把旧行 `superseded_by` 指向新行、再插新行」的版本化写法 在存量库上整个失效:旧索引不认 `superseded_by`,同路径第二行直接撞。 新建库压根没有那条索引,所以**只有存量库会踩**,任何单测都照不到。 修法是迁移里一句 `DROP INDEX IF EXISTS idx_graph_files_path`, 守卫 `graph-files-stale-index.test.js` 走存量库重启那条路(反向验过:不删就红)。 生产库已实跑验证:删索引后同路径连产两次不再抛,当前版本仍只有一条。 > 这是**同一类问题的第三次**(`tasks` 加列的索引顺序、墙钟锚点迁移、这次)。 > 判据可以固定成一句话:**凡是碰 schema,「存量库重启」那条路必须单独走一遍** > —— 新建库的路径由单测天然覆盖,存量库的不会。 --- ## 六quater、验收 9 真跑验掉(2026-09-08) **跑法**:隔离 git worktree(`.claude/worktrees/p3b-acceptance9-realrun`,跑完即删,`EnterWorktree`/ `ExitWorktree` 管理,不碰主仓库工作树也不碰生产 `data/`),真 Claude CLI(未设 `LEMONBOSS_CLAUDE_MODE=mock`),一次性脚本直接 require 服务层驱动(`scripts/real-accept9-run.js` + 续跑脚本 `real-accept9-resume.js`,均未提交,随 worktree 一起丢弃)。 **场景设计**:2 节点 org 图 `org-planner --success--> org-validator`,`org-validator --fail(catch-all)--> org-planner`(backward)——**刻意跳过 `org-coder`**。理由:org graph 里所有节点定义上都是 `nodeKind='subgraph'`(`work-graph-db.readDefGraph`),所以任何一条 org 层 fail 边的目标天然就是 subgraph 节点,验收 9 真正的难点不是"凑出这个图形状",而是"让一次真实、认真执行自己 `agent.md` 的 org-validator 真的判 fail"——跳过 org-coder 让"压根没有实现代码"这件事对 validator 而言是 **客观事实**,不依赖运气或对提示词做手脚。 **真跑结果**(`accept9-mtsksam9` / 根 run `run_dd09fc486b0e`,15 次真实 agent 调用, 218,887 tokens,backward_count=3,全程未撞任何硬顶): 1. `org-planner` 自己的 work graph(`workgraph-planning` 真规划出 brd→prd→sdd→goal-benchmark→ planner-validator,中途 planner-validator 打回 sdd 重写一轮)跑到 success,只产出规格文档, 不产任何实现代码——设计使然。 2. `org-validator` 被点亮,`workgraph-planning` 为它规划出的子图与它自己 `agent.md` 里写的骨架 一字不差:`validator-triager → benchmark-validator → testdata-validator/testscript-validator`。 3. `validator-triager` 首轮 `no_prior_failure`,放行给 `benchmark-validator`。 4. **`benchmark-validator` 真实读了项目根、真实按 `goal-benchmark.json` 四个维度打分** (`post_create_correctness` / `get_list_correctness` / `validation_and_error_handling` / `runnable_delivery`),**全部判 0 分**,加权总分 0,远低于 `overall_pass_threshold=80`—— 这是它自己读代码目录后的真实判断,不是任何注入或预设。 5. `validator-triager` 复验轮给出的归因(原文摘录): > 根因: 被测产物目录(项目根 `data/companies/accept9-mtsksam9/projects/p-4ntvbi/`)内没有任何 > 实现代码,只有 org-planner 和 org-validator 各自 workgraph-planning 阶段留下的两份规划记录 > md 文件;本次根 run 的 `nodes/` 目录下也不存在任何 org-coder 节点的产出记录,说明 > POST/GET /todos 的具体实现从未被写出,BRD/PRD/SDD 定义的验收标准清晰……不是标准写得不清楚。 6. **org-validator 的子 run 收敛为 fail,engine 在 org 层选中了那条唯一的 fail 边** (`org_graph_edges.id=2`:`from_node_id=2(org-validator) to_node_id=1(org-planner) condition='fail' fail_reason=NULL edge_kind='backward'`),真实写下一条 `graph_run_loop_records`(`loop_count=1, verdict='registered', from=org-validator 的 nodeRunId, to=org-planner 的 nodeRunId`)。 7. **回退目标 `org-planner` 真的是 `nodeKind='subgraph'` 节点**(直接读 `graph_run_nodes` 行核实), 且按 R-17.1「复用同一张子 run」的既定语义处理:`childRunId` 不变(仍是 `run_5991c4a313ac`), `attempt` 从 0 → 1,重新从它自己 work graph 的入口节点(`brd-planning`)跑起,第 5 步那份 `remediation_note` 原样带下去。 **逐条对齐验收 9 的原始表述**("一次真实的跨层回退,两半地址齐全、只重置被点名的节点"): - ~~"两半地址齐全"~~ **这次不适用,且不是缺陷**:`validator-triager` 没有写 `target_node`/ `target_org_node`——它的归因原话是"org-coder 从未产出",图上压根没有 `org-coder` 这个节点可指, 没有更细的粒度可给(同 §六ter 归纳的"work 层内部回退没有跨层可言"是同一类道理的另一面: 这次是"该退的目标本身就是整个 org node,没有更细可指")。三个键都可能为空的用法本来就在 R-17.1 的设计范围内("指不准时只写 `target_org_node`;引擎退化成整张 work graph 重跑"), 这次连 `target_org_node` 都没写,退化成"由 org 层的 fail 边直接决定重跑哪个 org node", 是该设计里最保守的那一档,行为完全符合预期。 - **"只重置被点名的节点"**:这里"被点名的节点"就是 org 层 fail 边指向的 `org-planner` 本身(org 层没有比 org node 更细的粒度),它被正确重置(子 run 复用、`attempt+1`), 没有波及任何其他 org node(这张图上也没有别的 org node 可波及)。 - **验收 9 真正要的核心事实——"org 层回退边、目标是 subgraph 节点"——第 6/7 点已完整证实**。 **成本**:全程约 47 分钟真实墙钟(含一次因脚本硬超时设太紧、被迫续跑的中断), 15 次真实 Claude CLI 调用,218,887 tokens,落在 27-P3B §五「$3-8 / 15-40 分钟」的预估量级内。 **收尾**:worktree 已删除(`ExitWorktree action:remove`),跑出来的公司数据、脚本均未进主仓库, 不留痕迹。以上证据全部来自真跑当时直接读库/读文件核实,不是转述模型输出。