Commit 59d57224ffbc03738ea68ded30b5181d05bf80e6

Authored by yibo
1 parent 8ef7b123

feat(p3b): 第二批 W5-W8 + W12;真 CLI 首次跑起 work 节点

真 CLI 冒烟(worktree 隔离,PORT 3010)九轮来首次把 work 节点跑起来:
orggraph-planning 真跑 3 分钟自主裁成单 org node 零边图 → workgraph-planning
第 1 轮产出合法图 → **自己按 Step 6 把 brd-planning / planner-validator 雇进公司**
(W5/W6 核心验收成立,且 W7 判据生效、两者都没写进 org_graph_nodes)→
第一个 work 节点 brd-planning 真起 CLI 子进程,产出 brd.md / contract.json /
attempt-0.json 全部落在 run 目录内。图内预算仅 2 次调用 / 13998 tokens(上限 15)。

W5/W6 · workgraph-planning 自雇:撤掉「你不招人也不辞人」,Step 6 加招聘环节,
  写死「先雇、再提交图」的顺序,给出失败分级与幂等要求
W7 · work 层招聘不写 org_graph_nodes:按 type 与 work graph 骨架点名两条判据区分层级,
  P2 「hire org 角色 → org graph 出现该节点」的语义保持不变
W8 · 控制台「规划组织结构」按钮(抽成 ReplanOrgGraphButton.vue,带过渡态与秒表),
  接 POST /:companyId/org-graph/replan(该端点此前无任何前端调用方);
  新增 fixtures/devloop-mock/orggraph-planning.json
W12 · 节点失败留痕:node.output 带 output_excerpt、新增 node.error 事件。
  真跑当场救场——守卫误判那条归因正是靠它才查得出来,
  换成之前看到的只有「节点 fail、无原因、无痕迹」

测试 753/753 全绿(71 文件,起点 736/68)。

已知未修(下一轮):子 run 转终态不回写父 node_run 导致任务永久卡死;
applyBoundaryGuard 用整仓 git diff 判归属会把并行 agent 的写算到当前节点头上;
W12 的 fail_reason 未落库(概览 tab 仍为空);POST /:cid/tasks 无前端调用方。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs/todo/system-design/27-p3b-real-run-runway.md
@@ -224,6 +224,11 @@ master RESOLVED @@ -224,6 +224,11 @@ master RESOLVED
224 所以一次真跑的必然走向是:org 节点 → 规划成功 → 第一个 work 节点 `Agent not found` → 节点判失败。 224 所以一次真跑的必然走向是:org 节点 → 规划成功 → 第一个 work 节点 `Agent not found` → 节点判失败。
225 **这正是 W5/W6 要解决的问题,第一批四项做得再对也绕不过去。** 225 **这正是 W5/W6 要解决的问题,第一批四项做得再对也绕不过去。**
226 226
  227 +> **W5/W6 落地后的复测**:把 `prd-planning` / `benchmark-validator` / `validator-triager`
  228 +> 三个本体 `hire` 进一家探针公司后,`getAgentConfig('<cid>:<role>')` 由 `THROWS` 变 **RESOLVED**
  229 +> (裸名照旧 `THROWS`,符合预期——引擎走的是作用域名)。断链的解法确认就是「先雇」。
  230 +> 详见下方「W5 / W6 落地记录」。
  231 +
227 > 推论:**现在起真 CLI 不会产出新信息** —— 断点已被零成本静态证明, 232 > 推论:**现在起真 CLI 不会产出新信息** —— 断点已被零成本静态证明,
228 > 而按 §五 的量级,跑一次是 $3-8 / 15-40 分钟。应先做 W5/W6 再真跑。 233 > 而按 §五 的量级,跑一次是 $3-8 / 15-40 分钟。应先做 W5/W6 再真跑。
229 234
@@ -254,14 +259,149 @@ data 里同样只有 `status/fail_reason/output_ref/child_run_id`。于是: @@ -254,14 +259,149 @@ data 里同样只有 `status/fail_reason/output_ref/child_run_id`。于是:
254 「节点失败,无原因,无痕迹」。建议在 W5/W6 之前先补一条 timeline(最小改动:catch 里加 259 「节点失败,无原因,无痕迹」。建议在 W5/W6 之前先补一条 timeline(最小改动:catch 里加
255 `timelineNote(ctx,'node.error',err.message,{...})`),否则真跑的排障成本会被这一环吃掉。 260 `timelineNote(ctx,'node.error',err.message,{...})`),否则真跑的排障成本会被这一环吃掉。
256 261
  262 +> **W12 · 本轮状态:✅ 已修(选「只留痕,不加列」)** — `server/services/graph-engine.js`
  263 +>
  264 +> - **不加 `output` 列**(`graph_run_nodes` 保持 16 列不变)。理由:`outcome.output` 长度无上限,
  265 +> 而 `getRunState` 是 `SELECT *` 全表读回,控制台每次轮询都会把所有历史输出重传一遍;
  266 +> 完整产物已有地址(`output_ref` → `nodes/{nodeRunId}/`),再存一份就是第二个真相源;
  267 +> `fail_reason` 是**选边的匹配键**,更不能借来装自由文本。
  268 +> - **catch 那份净损失补上**:executor 抛异常时发 `node.error`(`timelineNote`),带
  269 +> `node_run_id / node_name / attempt / node_kind / system_agent_id / error / error_type / error_stack`(栈顶 6 行)。
  270 +> 四环 = 产生(catch)→ 传递(`timelineNote`)→ 落库(`timeline.ndjson` + `stream.log`,append-only)
  271 +> → 读回(`GET /api/graph-runs/:id/stream?lastEventId=0` 全量重放)。
  272 +> - **`outcome.output` 也终于有了消费方**:`node.output` 事件 data 加定长 `output_excerpt`
  273 +> (≤600 字符,超长截断并注明「完整输出见 output_ref」),三个产生方(`defaultAgentExecutor`
  274 +> 的 `res.output`、W3 越界 banner、catch 的 `err.message`)一并接上。前端 `NodeRunLog`
  275 +> 已按 `data.node_run_id` 消费 `node.output`,无需改动即可看到。
  276 +> - W3 banner 依建议**去掉重复的改动清单**(清单仍在 `node.boundary` 事件的 `entries` 里),
  277 +> 只留「为什么被判 fail」一句,免得 20 行清单把 agent 真实输出挤出摘要窗口。
  278 +> - **仍需前端配合一行**:`web/src/components/graph-console/GraphRunPane.vue` 的
  279 +> `es.addEventListener` 白名单里没有 `node.error`(命名事件不会走 `onmessage`),
  280 +> 加上 `'node.error'` 这条错误才会出现在浏览器的事件轨;不加也不丢数据(在
  281 +> `timeline.ndjson` / `stream.log` 里),只是控制台看不到。
  282 +> - 锁:`server/tests/unit/graph-engine-node-error.test.js`(4 例)。
  283 +
257 ### 第二批 · 招聘走方案 a 284 ### 第二批 · 招聘走方案 a
258 285
259 | # | 内容 | 文件 | 量 | 286 | # | 内容 | 文件 | 量 |
260 |---|---|---|---| 287 |---|---|---|---|
261 -| **W5** | `workgraph-planning` 模板改语义:撤掉「你不招人也不辞人」,加招聘步骤,`tools.md` 给 `company hire` 用法 | `system/templates/workgraph-planning/**` | 小 |  
262 -| **W6** | 写死顺序:**先雇、再提交图**(反了必外键挂);雇失败的重试与降级 | 同上 | 小 |  
263 -| **W7** | **work 层招聘不得污染 org graph**:`hire()` 现在会把任何被雇 agent upsert 成一行 `org_graph_nodes`。方案 a 下每次任务规划都雇人,org graph 会被越塞越满(界面走查已实际撞到)。让这类招聘不写 org 节点,或写了打标区分 | `server/services/company-service.js`、`server/services/graph-service.js` | 中 |  
264 -| **W8** | 控制台加「重新规划组织」按钮,调 `POST /:companyId/org-graph/replan`(R-18.1)。该端点已实现,全仓库无前端调用方 | `web/src/views/GraphConsole.vue` 或 `CompanyDashboard.vue` | 小 | 288 +| **W5** ✅ | `workgraph-planning` 模板改语义:撤掉「你不招人也不辞人」,加招聘步骤,`tools.md` 给 `company hire` 用法 | `system/templates/workgraph-planning/**` | 小 |
  289 +| **W6** ✅ | 写死顺序:**先雇、再提交图**(反了必外键挂);雇失败的重试与降级 | 同上 | 小 |
  290 +| **W7** ✅ 已改,🟡 真跑未验 | **work 层招聘不得污染 org graph**:`hire()` 现在会把任何被雇 agent upsert 成一行 `org_graph_nodes`。方案 a 下每次任务规划都雇人,org graph 会被越塞越满(界面走查已实际撞到)。让这类招聘不写 org 节点,或写了打标区分 | `server/services/company-service.js`、`server/services/graph-service.js` | 中 |
  291 +| **W8** ✅ UI / 🟡 mock | 控制台加「重新规划组织」按钮,调 `POST /:companyId/org-graph/replan`(R-18.1)。该端点已实现,全仓库无前端调用方 | `web/src/views/GraphConsole.vue` 或 `CompanyDashboard.vue` | 小 |
  292 +
  293 +#### W5 / W6 落地记录(模板层已改,真跑仍未验)
  294 +
  295 +改的是 `system/templates/workgraph-planning/` 三份:`agent.md`(主体)、`tools.md`、`identity.md`。
  296 +
  297 +- **语义反转**:`tools.md` 的「你不招人也不辞人」那一行**已删**,替换成「你只招不辞」+ 一段明写
  298 + 「旧提示词里那句已作废,以本节为准」的警示(老话可能还留在别处的上下文里)。
  299 + `agent.md` 身份段、角色边界表、`identity.md` 同步改成「图里引用到的本体由你雇」。
  300 +- **顺序写死**:工作流从七步改八步,**Step 6 招聘 → Step 7 提交图**,Step 6 开头一张表列出
  301 + 「反了会怎样」的两条后果(`work_graph_nodes.system_agent_id` 外键 / 节点跑起来 `resolveAgentName`
  302 + 找不到公司目录退回裸名),并写明「都发生在你已经下班之后,补不了」。八步表 + 思维隔离自查第 9 条
  303 + 两处复述同一条顺序。
  304 +- **失败分级**:`status` 四档(`hired_from_template` / `already_exists` / `hired_from_base` 三档继续,
  305 + `training` 一档**不算成功**——脚手架路径不写 `system_agents`,图提交必被 `rule=reference` 打回);
  306 + 出错三级(瞬时故障重试 3 次 → 供不出来就换本体、同一本体最多换一次 → 都不行就**停**,
  307 + 不许提交引用了没雇上本体的图)。
  308 +- **`already_exists` 的死角也写进去了**:它是提前返回,**不补 `system_agents` 那一行**。
  309 + 所以「目录在、表里没有」这种组合下重雇永远只返回 `already_exists`,`rule=reference` 修不好——
  310 + 模板要求直接换掉该本体并点名汇报,避免在这里死循环。
  311 +- **提示词组装的实测结论(决定了内容该写在哪)**:图执行侧走
  312 + `graph-engine → AgentManager.callAgent → PromptBuilder.build`,而 `build()` 只拼
  313 + **`agent.md` + `company-context.md`**,**`tools.md` / `soul.md` / `identity.md` 都不进这条提示词**
  314 + (`packages/agent-core/Agent.js` 那套读六个文件的 bootstrap 是另一条链路)。
  315 + 所以招聘所需的一切——命令行、参数表、curl 降级、四档判读、三级分级——**全部内联在 `agent.md`**,
  316 + `tools.md` 只作为人类可读的重复件。
  317 +- **实测(未起真 CLI,临时 `GRAPH_DB_PATH` + 用完即删的探针公司)**:
  318 + `hire` 三个 work 层本体均 `hired_from_template`,重雇均 `already_exists`(幂等成立);
  319 + 雇后 `AgentManager.getAgentConfig('<cid>:<role>')` **RESOLVED**,裸名仍 `THROWS: Agent not found`
  320 + —— 与 §三 第 6 条的断链分析完全一致,**「雇进公司」正是解法**。
  321 + 另用 `PromptBuilder.build()` 实拼一次 `workgraph-planning` 的 system prompt(20.8k 字符),
  322 + `company hire` / `--role` / `先雇` / 三个 `status` 取值 / `ls data/companies/` 全部命中。
  323 +- **仍未验**:真 CLI 下 agent 是否照着做(mock 不读提示词,验不了)。归第三阶段。
  324 +- **`--layer` 不是模板的入参**:模板明写「别自己发明这个参数」(`parseArgs` 严格模式,多给未知选项
  325 + 会让整条命令报错)。work 层招聘写不写 org 节点由服务端判据决定,见 W7。
  326 +
  327 +#### W7 落地记录(选「不写 org 节点」,判据在服务端;真跑未验)
  328 +
  329 +- **选「不写」而不是「写了打标」**:打标要么在 `org_graph_nodes` 加列(schema 归 `graph-db.js`)、
  330 + 要么在 `readGraph`/`getGraph` 上加过滤(归 `org-graph-service.js`),两处都不在本轮可动的文件里;
  331 + 更关键的是**这行不只是画图用**——`work-graph-db.js:473` 起 org run 时读的就是这张表的 active 行,
  332 + 混进去的 work 层本体会变成一个**真的会被执行**的 org 节点。所以不是「显示时过滤掉」够用的问题。
  333 +- **调用方不表达层级,判据只看数据**:唯一会雇 work 层本体的真实调用方是
  334 + `POST /api/company/:companyId/hire`(`workgraph-planning` 在 CLI 里 curl 它),
  335 + 该路由按字段解构 body、不透传未知字段,所以「加个 `opts.layer` 参数」在本轮会是一个
  336 + **永远没人传的死开关**(第一版就是这么写的,已删)。何况「谁雇的」本来也不该决定
  337 + 「它是不是组织节点」。`company-service.decideOrgNodeRecording()` 三条判据,命中任一不写 org 节点:
  338 + ① `config.type ∈ {meta-bootstrap, meta-planning}`(`orggraph-planning` / `workgraph-planning` /
  339 + `brd|prd|sdd-planning` / `goal-benchmark`);② 本体在 `system/work-graph-templates/*.json` 里被
  340 + 点名当节点(`planner-validator` / `validator-triager` / `benchmark-validator` / `testdata-validator`
  341 + ——它们 `type='validator'`,判据①盖不住;`org-validator` 只作为 `ownerOrgNode` 出现,不会被误伤);
  342 + ③ 公司已有**成型**的 org graph(存在 active 的 `org_graph_edges`)而该角色不在图上。
  343 +- **判据③为什么看边不看节点**:P1/P2 的 `hire` 自己就会造节点,拿「有没有节点」当信号会把
  344 + 「同一家公司连雇两个人,第二个不入图」——`graph-agent-p1-hire.test.js` 正是这么用的——直接废掉。
  345 + 第一版就是拿节点当信号,跑全量测试当场红了这一条,才改成看边。
  346 +- **不写 org 节点时仍然补 `system_agents`**(`graph-service.ensureSystemAgentRow()`,从
  347 + `upsertOrgGraphNode` 里拆出来单独导出):`work_graph_nodes.system_agent_id` 是指向该表的外键,
  348 + 少这行 W6 的「先雇再提交图」照样外键挂(W1 记过同一条失效链)。
  349 +- **顺带修掉一个老问题**:org 节点 `role_name='org-coder'` 绑定本体 `coder` 时,在成型图上再雇一次
  350 + `coder` 以前会多出一行同义节点,现在不会。
  351 +- **默认行为 = 写**,P1/P2 语义在「公司还没有成型的图」这个前提下原样保住。
  352 +- **已知残留(不提前加规则)**:① 单 org node 的最小图**没有边**,判据③不生效,这时只有 ①② 认识的
  353 + 本体会被拦下——`executor.json` 的 `{{implAgent}}` / `{{validatorAgent}}` 现填出来的本体仍会入图;
  354 + ② `hire()` 的 `already_exists` 是提前返回,**不补 `system_agents`**(W5/W6 记录里那条死角),
  355 + 本轮**故意没补**:目录在而表里没有时插一行 `company_hired` 会**squat 掉模板 id**,
  356 + 正是 W1 清掉的那条失效链(`seedTemplates` 撞上被占用的 id 会 skip,且此时目录还在、
  357 + 不满足悬空回收条件),代价比收益大;③ `graph-seed-service.seedCompanyAgents()`(手动脚本
  358 + `scripts/graph-seed.js` 才跑)只镜像了判据①的 `meta-bootstrap` 那半条,跑它仍会把 work 层本体
  359 + 塞回 org graph——启动流程不跑它,暂时无害,等它有运行时调用方时必须同步这三条判据。
  360 +- **测试**:`server/tests/unit/company-hire-org-layer.test.js`(9 例,真 `hire()` + 从
  361 + `org-graph-service.getGraph()` 读回,即 GET org-graph 的同一条读路径)。三条判据各做过变异验证
  362 + (分别短路掉 → 红 3 / 1 / 3 例)。每条「不入图」都配了一条只换一个条件的正例,
  363 + 免得测试退化成「这个角色被整体拉黑」。
  364 +- **仍未验**:真 CLI 下 `workgraph-planning` 雇完一轮后控制台左栏的实况(验收标准 7),归第三阶段。
  365 +
  366 +#### W8 落地记录(按钮已有真实调用方;mock 演示仍差两环,两环都不在 UI 侧)
  367 +
  368 +- **入口两处,同一个组件** `web/src/components/graph-console/ReplanOrgGraphButton.vue`:
  369 + `GraphConsole` 左栏(有图 → 标题栏小按钮;**无图 → 左栏空态里的主操作**,没有 org graph 的公司
  370 + 整个控制台无事可做,入口在这里必须是主按钮而不是一句「先跑 orggraph-planning」的提示语);
  371 + `CompanyDashboard` 的「🕸️ 组织流程」块(同样有图挂标题栏、空态给大按钮——新建公司后
  372 + 用户最先看到的就是这一屏的「暂无组织流程」)。至此 `POST /:companyId/org-graph/replan`
  373 + 有了前端调用方,不再只能 curl。
  374 +- **过渡态**:`posting` → `planning mm:ss`(按钮禁用防连点)→ `done` / `timeout`(6 分钟,可「继续等」)。
  375 + 已有图时先 `confirm`:`writeGraph` 是整图覆盖,旧图作废。
  376 +- **刷新:轮询 `GET /:companyId/org-graph` 的图指纹(4s),不是 SSE**。replan 内部那条 agent 对话的
  377 + SSE 由服务端自己消费(`triggerOrgGraphPlanning` 的 fetch),浏览器没有把手,端点也不回
  378 + `conversationId`;控制台现成的 `/graph-runs/:id/stream` 是 graph run 的事件轨,而 org graph
  379 + 规划不产生 graph run。指纹**含节点 id**(`writeGraph` 每次换新 id),否则「重规划出同一张图」
  380 + 会被误判成超时。变化即 `emit('planned')` → 父组件重载左栏定义 + run 列表(旧 run 的节点与新图对不上,必须一起重拉)。
  381 +- **fixture**:新增 `fixtures/devloop-mock/orggraph-planning.json`(此前回放 `default.json`,
  382 + 建公司时的自动规划是空转)。产出 `org-planner → org-coder → org-validator` + 两条 fail 回退边,
  383 + 形状对齐 `system/org-graph-templates/dev.json`。实测把它 chunks 里那段图 POST 给真正的写入方:
  384 + `{"ok":true,"nodeCount":3,"edgeCount":4}`,GET 读回四条边齐全(含 `input_underspecified` 那条)。
  385 + 锁:`server/tests/unit/orggraph-planning-fixture.test.js`(4 例:图在 chunks 不在 result /
  386 + 形状对齐 dev.json / 过五项校验 / mock 协议按 agent id 与 `{cid}:` 作用域名都能回放)。
  387 +- **🟡 mock 下「点一下就出图」还差两环**(真 CLI 模式不受影响,agent 自己跑 curl):
  388 + 1. **fixture 命不中**:`_sendMockPrompt` 用 `agentInfo.name` 冒号后那段查 fixture。
  389 + `graph-engine` 传 `{cid}:{agentId}`(命中),`agent-chat.js:684` 的 `getOrCreateProtocol`
  390 + 传的是 **identity.md 的显示名**——`orggraph-planning` 的 identity.md 没有「名称:」行,
  391 + 退化成字面量 `'Agent'` → 回放 `default.json`(实测就是那两块 38 / 83 字符)。
  392 + `orggraph-planning` 只有 chat 这一条入口。最小修法:把 agent id 传给协议
  393 + (同时协议侧 `rawName` 要改成 `options.agentName` 优先,否则 `agentInfo.name` 恒真)。
  394 + 2. **mock 没有落库环**:真实 agent 是 Step 6 自己 `curl -d @/tmp/x.json` POST `/org-graph`
  395 + 写进去的,而 mock 只回放 chunks/files/result,**不执行任何工具调用**,HTTP 副作用为零。
  396 + work 层没这个问题(`extractPlannedGraph` 从 output 解析)。要让 mock 也能演示,
  397 + 需在 `triggerOrgGraphPlanning` 消费完输出后 `extractPlannedGraph` → `writeGraph`(仅当图仍为空)。
  398 + 实测:mock 下 `POST .../org-graph/replan` → `{"status":"replanning"}`,日志
  399 + `[Replan] ✅ 已触发 {cid}:orggraph-planning`,但 `GET /org-graph` 仍是 `{nodes:[],edges:[]}`。
  400 + **验收标准 5 仍要留到真 CLI 那一轮验。**
  401 +- 顺带确认:`CompanyDashboard` 的「🕸️ 执行控制台」入口仍在(`goToGraphConsole`)。
  402 +- 附带发现(与 W1 相关,正面):`writeGraph` 会给未登记的 `systemAgentId` 自动补 `company_hired` 行;
  403 + 本轮测试公司删掉后 `graph-seed-service.reconcileDanglingRows()` 一次收干净
  404 + (`orgNodesRemoved:3 / orgEdgesRemoved:4 / agentsRemoved:[org-planner, org-coder]`)。
265 405
266 ### 第三批 · 让结果可信 406 ### 第三批 · 让结果可信
267 407
@@ -299,6 +439,9 @@ data 里同样只有 `status/fail_reason/output_ref/child_run_id`。于是: @@ -299,6 +439,9 @@ data 里同样只有 `status/fail_reason/output_ref/child_run_id`。于是:
299 5. 新建公司 → 界面点「重新规划组织」→ `orggraph-planning` 真跑 → org graph 出现,且它雇的三个 org 节点都在。 439 5. 新建公司 → 界面点「重新规划组织」→ `orggraph-planning` 真跑 → org graph 出现,且它雇的三个 org 节点都在。
300 6. 下达任务 → `workgraph-planning` 真跑 → 它引用的本体被自己雇进公司 → 图提交成功(不再外键挂)。 440 6. 下达任务 → `workgraph-planning` 真跑 → 它引用的本体被自己雇进公司 → 图提交成功(不再外键挂)。
301 7. 上条跑完后,控制台左栏 org graph **仍然只有 org 节点**,没有混进 `prd-planning` 这类 work 层本体。 441 7. 上条跑完后,控制台左栏 org graph **仍然只有 org 节点**,没有混进 `prd-planning` 这类 work 层本体。
  442 + 🟡 **服务层已成立,真跑未验**:`hire()` 三条判据 + 9 例测试(含「雇一轮 work 层本体后
  443 + `getGraph` 仍只剩三个 org 节点」这条直接对应本标准),见上方 W7 落地记录;
  444 + 真 CLI 下的实况归第三阶段。
302 445
303 **第三批** 446 **第三批**
304 447
fixtures/devloop-mock/orggraph-planning.json 0 → 100644
  1 +{
  2 + "_doc": "P3B mock fixture · orggraph-planning。建公司时(company.js /create)与手动点「重新规划组织」时(POST /:companyId/org-graph/replan → triggerOrgGraphPlanning)各被调一次,产出这家公司的 org graph。\n\n在此之前本 agent 没有 fixture,mock 下回放的是 default.json 的 echo,所以新建公司后 org graph 一直是空的(用户新建 test-org 撞到的就是这个)。\n\n⚠️ 图必须放在 chunks 里,不能只放 result:agent-chat 的 fullOutput 只累积 agent_message_chunk(agent-chat.js:2316),session/result 的文本只有在 fullOutput 为空时才补进去。凡是要被读回的东西都放 chunks。\n\n⚠️⚠️ mock 的边界(别把这份 fixture 当成「mock 下 org graph 会自动落库」):真实 orggraph-planning 是靠 Step 6 自己 `curl -d @/tmp/x.json` POST /api/company/{id}/org-graph 把图写进库的,而 mock 模式不执行任何工具调用(ClaudeCliProtocol._sendMockPrompt 只回放 chunks/files/result,没有 HTTP 副作用)。所以本 fixture 只让 mock 下的规划**产出一份形状正确、可被读回的图文本**(会话页可见、SSE 可见、日志可见),落库那一环仍缺一个服务端消费者——见 27-P3B §四 W8 的备注。work 层没这个问题:workgraph-planning 的图由 graph-engine.extractPlannedGraph 从 output 里解析,不靠 agent 自己发 HTTP。\n\n图的形状对齐 system/org-graph-templates/dev.json:org-planner → org-coder → org-validator 三节点 + 两条 fail 回退边(一条带 failReason,一条兜底),满足 org-graph-service 的四项校验(无环 / 单入口 org-planner / 全可达 / 回退边显式标 backward)。三个角色名与 dev.json 一致;其中只有 org-validator 在 system/templates/ 里有模板,org-planner / org-coder 尚无(agent.md Step 4 明确允许「新造」库里没有的角色)。图本身不受影响:org-graph-service.writeGraph 会为未登记的 systemAgentId 自动补一行 company_hired 的 system_agents(实测 ok:true / nodeCount 3 / edgeCount 4)。\n\n⚠️⚠️⚠️ fixture 名的查找键:ClaudeCliProtocol._sendMockPrompt 用 `agentInfo.name`(冒号后那段)找 fixture。graph-engine 传的是 `{cid}:{agentId}`,能命中本文件;而 agent-chat.js 的 getOrCreateProtocol 传的是 **identity.md 里的显示名**(orggraph-planning 的 identity.md 没有「名称:」行 → 退化成字面量 'Agent'),于是走 chat 的调用回放的仍是 default.json。orggraph-planning 只有 chat 这一条入口,所以在 agent-chat 改传 agentId 之前,本 fixture 在跑起来的服务里命不中——见 27-P3B §四 W8 备注。\n\n单变体(不用 sequence):一家公司可能被反复 replan 走查,序列会在第二次之后变形。",
  3 + "chunks": [
  4 + "[mock] orggraph-planning 接到建司通知,开始按八步工作流规划这家公司的 org graph。\n\n",
  5 + "Step 1 读输入:company-goal 指向「做出/改进一个能跑的软件产品」,交付物是代码,验收方式是跑测试。\n",
  6 + "Step 2 读候选骨架:dev.json 的 whenToUse 命中(信号词:实现 / 开发 / 接口 / 上线);content.json、ops.json 不命中,不采用。\n",
  7 + "Step 3 读可招角色:org-validator 在 system/templates/ 里有现成模板;org-planner / org-coder 清单里还没有,按 Step 4「新造」条款直接写进图,Step 7 走 base 继承生成(模板补齐后自动改走 hired_from_template)。\n",
  8 + "Step 4 规划:主干沿用 dev 骨架不裁剪——本公司既要出需求也要出代码还要验收,三个职能缺一不可。\n\n",
  9 + "Step 5 规划记录已写入 workspace/orggraph-plan.md。\n\n",
  10 + "Step 6 提交图(POST /api/company/{公司ID}/org-graph):\n\n",
  11 + "```json\n{\n \"nodes\": [\n { \"roleName\": \"org-planner\", \"systemAgentId\": \"org-planner\" },\n { \"roleName\": \"org-coder\", \"systemAgentId\": \"org-coder\" },\n { \"roleName\": \"org-validator\", \"systemAgentId\": \"org-validator\" }\n ],\n \"edges\": [\n { \"from\": \"org-planner\", \"to\": \"org-coder\", \"condition\": \"success\" },\n { \"from\": \"org-coder\", \"to\": \"org-validator\", \"condition\": \"success\" },\n { \"from\": \"org-validator\", \"to\": \"org-planner\", \"condition\": \"fail\", \"failReason\": \"input_underspecified\", \"edgeKind\": \"backward\" },\n { \"from\": \"org-validator\", \"to\": \"org-coder\", \"condition\": \"fail\", \"edgeKind\": \"backward\" }\n ]\n}\n```\n\n",
  12 + "四项校验自查:1) forward 边可排拓扑序 org-planner → org-coder → org-validator,无环;2) 入度为 0 的只有 org-planner,单入口;3) 从 org-planner BFS 覆盖全部 3 个节点;4) 两条 fail 边都已标 edgeKind: backward。\n\n",
  13 + "Step 7 执行招聘:按拓扑序 hire org-planner → org-coder → org-validator。\n",
  14 + "Step 8 关闭自身心跳,转入休眠;后续增量调整交给 HR。\n"
  15 + ],
  16 + "result": "mock-ok orggraph-planning:已规划 dev 型 org graph(org-planner → org-coder → org-validator,含两条 fail 回退边),并按拓扑序完成招聘。"
  17 +}
server/services/company-service.js
@@ -12,7 +12,7 @@ @@ -12,7 +12,7 @@
12 12
13 const fs = require('fs'); 13 const fs = require('fs');
14 const path = require('path'); 14 const path = require('path');
15 -const { lookupSharedAgentMd, upsertOrgGraphNode } = require('./graph-service'); 15 +const { lookupSharedAgentMd, ensureSystemAgentRow, orgGraphMembership, upsertOrgGraphNode } = require('./graph-service');
16 const { getDb } = require('./graph-db'); 16 const { getDb } = require('./graph-db');
17 17
18 const ROOT_DATA_DIR = path.resolve(__dirname, '../../data'); 18 const ROOT_DATA_DIR = path.resolve(__dirname, '../../data');
@@ -25,6 +25,99 @@ const GLOBAL_AGENTS_DIR = path.resolve(__dirname, &#39;../../system/agents&#39;); @@ -25,6 +25,99 @@ const GLOBAL_AGENTS_DIR = path.resolve(__dirname, &#39;../../system/agents&#39;);
25 // (docs/todo/system-design/27-p2-org-graph.md §4.1 第 7 步 / 27-e2e §八bis)。 25 // (docs/todo/system-design/27-p2-org-graph.md §4.1 第 7 步 / 27-e2e §八bis)。
26 const BASE_META_AGENT_ID = 'base'; 26 const BASE_META_AGENT_ID = 'base';
27 27
  28 +// ── 招聘的两层,与「谁算 org 节点」(27-P3B §四 W7 / 27-REQ R-18.2 末段)──────
  29 +//
  30 +// 招聘有两层,但只有 **org 层**的被雇者才是公司 org graph 上的一个节点:
  31 +// org 层:orggraph-planning 按它规划的 org graph 雇进来的角色(org-planner / org-coder /
  32 +// org-validator …),以及公司还没有图时人工从界面雇的员工 ——
  33 +// P2 §4.2 的「hire 成功后写一行 org_graph_nodes」说的是它们;
  34 +// work 层:workgraph-planning 每接到一个任务、按它规划出的 work graph 现雇的本体
  35 +// (prd-planning / benchmark-validator / validator-triager / coder-validator …)。
  36 +// 这些本体是某个 org node **内部**工作流的节点,写进 org_graph_nodes 不只是
  37 +// 把控制台左栏画歪:`work-graph-db.js:473` 起 org run 时读的就是这张表的 active 行,
  38 +// 混进去的行会变成一个真的会被执行的 org 节点;而 POST /org-graph 又会把不在
  39 +// 规划 agent 节点列表里的行标 removed —— 两边互相打架。
  40 +//
  41 +// **判据不问调用方,只看数据**。原因是唯一会雇 work 层本体的真实调用方是
  42 +// `POST /api/company/:companyId/hire`(workgraph-planning 在 CLI 里 curl 它),
  43 +// 该路由按字段解构 body、不透传未知字段,所以「加个 opts.layer 参数」在本轮是一个
  44 +// 永远没人传的死开关。何况「谁雇的」本来也不该决定「它是不是组织节点」。三条判据:
  45 +//
  46 +// ① 本体自称是图基础设施(config.json 的 type ∈ NON_ORG_AGENT_TYPES)→ 永不入图。
  47 +// meta-bootstrap 那半条 P2 就有了(否则建公司到规划跑完之间 GET /org-graph 会返回
  48 +// 一张只有 orggraph-planning 的假组织图);W7 把它推广到 meta-planning,覆盖
  49 +// graph-engine.resolvePlanningAgentName() 按需雇的 workgraph-planning,以及
  50 +// workgraph-planning 自己雇的 prd/sdd/brd-planning、goal-benchmark。
  51 +// ② 本体在 system/work-graph-templates/*.json 里被点名当 work graph 节点 → 永不入图
  52 +// (planner-validator / validator-triager / benchmark-validator / testdata-validator,
  53 +// 这些 type='validator' 的本体判据①盖不住,而 org-validator 只当 ownerOrgNode 出现、
  54 +// 不是 nodes[] 里的 systemAgentId,所以不会被误伤)。
  55 +// ③ 公司已经有一张**成型的**组织工作流图(存在 active 的 org_graph_edges),
  56 +// 而这次雇的角色不在图上 → 不入图:图一旦成型,作者就是 orggraph-planning
  57 +// (POST /org-graph),hire() 不再往上追加节点。这条兜住 ①② 都不认识的、
  58 +// 按 executor.json 的 {{implAgent}} / {{validatorAgent}} 动态选出来的本体。
  59 +// 判据用「有没有边」而不是「有没有节点」,因为 P1/P2 的 hire 自己就会造节点,
  60 +// 拿节点当信号会把「同一家公司连雇两个人,第二个不入图」这条 P2 语义废掉
  61 +// (server/tests/integration/graph-agent-p1-hire.test.js 就是这么用的)。
  62 +// 顺带修掉一个老问题:org 节点 role_name='org-coder' 绑定本体 'coder' 时,
  63 +// 在成型图上再雇一次 'coder' 以前会多出一行同义节点,现在不会了。
  64 +//
  65 +// 默认行为(都不命中)= 写。P1/P2 既有语义「雇一个角色 → org graph 出现这个节点」
  66 +// 在「公司还没有成型的图」这个前提下原样保住(graph-agent-p1-acceptance.test.js 判据 1)。
  67 +//
  68 +// 已知残留:单 org node 的最小图没有边,判据③不生效,这时只有 ①② 认识的本体会被拦下;
  69 +// 一个既不是 meta-* 又没在骨架里点过名的本体(executor.json 的占位符现填)仍会入图。
  70 +// 真跑到那一步再看,别为它提前加规则。
  71 +const NON_ORG_AGENT_TYPES = new Set(['meta-bootstrap', 'meta-planning']);
  72 +const WORK_GRAPH_TEMPLATES_DIR = path.resolve(__dirname, '../../system/work-graph-templates');
  73 +
  74 +let workGraphBodyIds = null; // 懒加载 + 进程内缓存(骨架是随代码发布的静态文件)
  75 +
  76 +/** system/work-graph-templates/*.json 里 nodes[].systemAgentId 的全集(跳过 {{占位符}}) */
  77 +function workGraphTemplateBodies() {
  78 + if (workGraphBodyIds) return workGraphBodyIds;
  79 + workGraphBodyIds = new Set();
  80 + try {
  81 + for (const f of fs.readdirSync(WORK_GRAPH_TEMPLATES_DIR)) {
  82 + if (!f.endsWith('.json')) continue;
  83 + const tpl = JSON.parse(fs.readFileSync(path.join(WORK_GRAPH_TEMPLATES_DIR, f), 'utf-8'));
  84 + for (const n of tpl.nodes || []) {
  85 + const id = n && n.systemAgentId;
  86 + if (id && !id.startsWith('{{')) workGraphBodyIds.add(id);
  87 + }
  88 + }
  89 + } catch (err) {
  90 + // 读不到就退化成「这条判据不生效」,另外两条还在,绝不因此挡住招聘
  91 + console.warn(`[Company] ⚠️ 读 work graph 骨架失败,W7 判据②本轮跳过: ${err.message}`);
  92 + }
  93 + return workGraphBodyIds;
  94 +}
  95 +
  96 +/**
  97 + * 这次招聘要不要在 org_graph_nodes 里记一行?判据见上方注释。
  98 + *
  99 + * @param {string} companyId
  100 + * @param {string} roleName
  101 + * @param {object|null} agentConfig 被雇 agent 的 config.json(取 type)
  102 + * @returns {{ record: boolean, reason: string }} reason 用于日志,说明是哪一条判据生效
  103 + */
  104 +function decideOrgNodeRecording(companyId, roleName, agentConfig) {
  105 + const type = agentConfig && agentConfig.type;
  106 + if (NON_ORG_AGENT_TYPES.has(type)) {
  107 + return { record: false, reason: `type=${type},图基础设施本体从不作为 org 节点` };
  108 + }
  109 + if (workGraphTemplateBodies().has(roleName)) {
  110 + return { record: false, reason: 'work graph 骨架里点名的 work 层本体' };
  111 + }
  112 +
  113 + const { hasWorkflow, isNode } = orgGraphMembership(companyId, roleName);
  114 + if (hasWorkflow && !isNode) {
  115 + return { record: false, reason: '公司已有成型的 org graph,而该角色不在图上(work 层本体)' };
  116 + }
  117 +
  118 + return { record: true, reason: isNode ? '该角色就是图上的 org 节点' : '公司还没有成型的 org graph(P2 语义:雇进来就记一行)' };
  119 +}
  120 +
28 // 模板骨架文件白名单 (复制这些,不复制 memory/workspace/conversations 等运行态数据) 121 // 模板骨架文件白名单 (复制这些,不复制 memory/workspace/conversations 等运行态数据)
29 const TEMPLATE_FILES = [ 122 const TEMPLATE_FILES = [
30 'agent.md', 'soul.md', 'identity.md', 'user.md', 'tools.md', 123 'agent.md', 'soul.md', 'identity.md', 'user.md', 'tools.md',
@@ -237,20 +330,8 @@ class CompanyService { @@ -237,20 +330,8 @@ class CompanyService {
237 company.updatedAt = new Date().toISOString(); 330 company.updatedAt = new Date().toISOString();
238 this._saveCompany(company); 331 this._saveCompany(company);
239 332
240 - // Org Graph 引用节点记录(审计用途,失败不影响雇佣结果本身)。  
241 - // 基础设施角色(type='meta-bootstrap',如 orggraph-planning)不入图:  
242 - // 27-p2-org-graph.md §4.1 明确「orggraph-planning 自己不出现在它规划的 org graph 里」。  
243 - // 不加这个 guard 的话,/create 之后到规划跑完之间的几分钟里,GET /org-graph 会把  
244 - // orggraph-planning 当成这家公司唯一的节点返回,前端就渲染出一张假的组织图。  
245 - if (agentConfig && agentConfig.type === 'meta-bootstrap') {  
246 - console.log(`[Company] ⏭️ ${roleName} 是 meta-bootstrap 基础设施角色,不写入 org_graph_nodes`);  
247 - } else {  
248 - try {  
249 - upsertOrgGraphNode(companyId, roleName, roleName);  
250 - } catch (err) {  
251 - console.warn(`[Company] ⚠️ org_graph_nodes 写入失败(不影响雇佣结果): ${err.message}`);  
252 - }  
253 - } 333 + // Org Graph 引用节点记录(审计用途,失败不影响雇佣结果本身)。判据见 NON_ORG_AGENT_TYPES 上方注释。
  334 + this._recordHireInGraph(companyId, roleName, agentConfig);
254 335
255 console.log(`[Company] ✅ ${companyId} 招聘 ${roleName} (从模板${sharedAgentMdPath ? ',引用模式' : ''})`); 336 console.log(`[Company] ✅ ${companyId} 招聘 ${roleName} (从模板${sharedAgentMdPath ? ',引用模式' : ''})`);
256 return { status: 'hired_from_template', agentId: roleName, agentDir: targetDir }; 337 return { status: 'hired_from_template', agentId: roleName, agentDir: targetDir };
@@ -319,12 +400,13 @@ class CompanyService { @@ -319,12 +400,13 @@ class CompanyService {
319 company.updatedAt = new Date().toISOString(); 400 company.updatedAt = new Date().toISOString();
320 this._saveCompany(company); 401 this._saveCompany(company);
321 402
322 - // 4. Org Graph 节点 + scope 治理(失败不影响雇佣结果本身) 403 + // 4. Org Graph 节点 + scope 治理(失败不影响雇佣结果本身)。
  404 + // 这条路径下 config.json 刚由本函数写出(type 来自 opts.type),直接把它喂给判据。
  405 + this._recordHireInGraph(companyId, roleName, config);
323 try { 406 try {
324 - upsertOrgGraphNode(companyId, roleName, roleName);  
325 this._ensureCompanyScopedAgent(companyId, roleName); 407 this._ensureCompanyScopedAgent(companyId, roleName);
326 } catch (err) { 408 } catch (err) {
327 - console.warn(`[Company] ⚠️ org_graph_nodes 写入失败(不影响雇佣结果): ${err.message}`); 409 + console.warn(`[Company] ⚠️ system_agents scope 校正失败(不影响雇佣结果): ${err.message}`);
328 } 410 }
329 411
330 console.log(`[Company] 🧬 ${companyId} 招聘 ${roleName}(从 base 元 agent 继承,待 agent-creator 填充定义)`); 412 console.log(`[Company] 🧬 ${companyId} 招聘 ${roleName}(从 base 元 agent 继承,待 agent-creator 填充定义)`);
@@ -338,6 +420,31 @@ class CompanyService { @@ -338,6 +420,31 @@ class CompanyService {
338 } 420 }
339 421
340 /** 422 /**
  423 + * 把一次成功的招聘落到图上(hire() 与 hireFromBaseMetaAgent() 共用的收尾动作)。
  424 + *
  425 + * org 层 → 记一行 org_graph_nodes(顺带补 system_agents,upsertOrgGraphNode 内部做)。
  426 + * work 层 → **不记** org_graph_nodes,但**仍然补** system_agents 那一行:
  427 + * work_graph_nodes.system_agent_id 是指向 system_agents 的外键,
  428 + * 少了这行,workgraph-planning 雇完人再提交 work graph 会直接外键挂
  429 + * (27-P3B §四 W1 记录过同一条失效链)。
  430 + *
  431 + * 图写失败一律降级成告警,不影响雇佣结果本身(保持 P1/P2 既有行为)。
  432 + */
  433 + _recordHireInGraph(companyId, roleName, agentConfig) {
  434 + const { record, reason } = decideOrgNodeRecording(companyId, roleName, agentConfig);
  435 + try {
  436 + if (record) {
  437 + upsertOrgGraphNode(companyId, roleName, roleName);
  438 + } else {
  439 + ensureSystemAgentRow(companyId, roleName, roleName);
  440 + console.log(`[Company] ⏭️ ${roleName} 不写入 org_graph_nodes(${reason}),仅登记 system_agents`);
  441 + }
  442 + } catch (err) {
  443 + console.warn(`[Company] ⚠️ 图登记失败(不影响雇佣结果): ${err.message}`);
  444 + }
  445 + }
  446 +
  447 + /**
341 * 校正新建 agent 在 system_agents 里的归属:scope='company' + owner_company_id=本公司 448 * 校正新建 agent 在 system_agents 里的归属:scope='company' + owner_company_id=本公司
342 * (27-graph-agent-architecture.md §3.2)。 449 * (27-graph-agent-architecture.md §3.2)。
343 * 450 *
server/services/graph-engine.js
@@ -527,6 +527,29 @@ function resolveTriageFile(dir, attempt) { @@ -527,6 +527,29 @@ function resolveTriageFile(dir, attempt) {
527 return bestN >= attempt ? { file: best, stale: null } : { file: null, stale: best }; 527 return bestN >= attempt ? { file: best, stale: null } : { file: null, stale: best };
528 } 528 }
529 529
  530 +/**
  531 + * 事件 data 里携带的文本上限(W12)。
  532 + *
  533 + * 为什么是「截断进事件」而不是「加一列 output 落库」:
  534 + * 1. outcome.output 是 agent 的**完整输出**,长度无上限;graph_run_nodes 每次
  535 + * getRunState 都是 `SELECT *` 全表读回去(控制台每次轮询都拉一遍所有节点),
  536 + * 把整份输出塞进节点行 = 每次轮询把所有历史输出重传一遍。
  537 + * 2. 完整产物已经有地址了 —— output_ref 指向 nodes/{nodeRunId}/ 那个目录,
  538 + * 再存一份是同一份数据的第二个真相源。
  539 + * 3. fail_reason 不能借用:它是**选边的匹配键**(精确 fail 边靠它匹配),
  540 + * 掺进自由文本会让所有精确 fail 边失配(§四.D.2.2「引擎不发明 fail_reason」)。
  541 + * 所以留痕轨道选 timeline.ndjson + stream.log(append-only、按 run 分目录、天然有上限),
  542 + * 节点行只保留定长的结构化列。
  543 + */
  544 +const EVENT_TEXT_MAX = 600;
  545 +
  546 +/** 事件 data 里的自由文本一律截断,避免 stream.log / timeline.ndjson 被单条巨型输出撑爆 */
  547 +function clipText(s, n = EVENT_TEXT_MAX) {
  548 + if (s === null || s === undefined || s === '') return null;
  549 + const str = typeof s === 'string' ? s : String(s);
  550 + return str.length <= n ? str : `${str.slice(0, n)}…(已截断,完整输出见 output_ref)`;
  551 +}
  552 +
530 /** 只记一条审计(同时也发 SSE,走 emitEvent 保证两轨不分家);失败绝不影响执行 */ 553 /** 只记一条审计(同时也发 SSE,走 emitEvent 保证两轨不分家);失败绝不影响执行 */
531 function timelineNote(ctx, event, summary, data) { 554 function timelineNote(ctx, event, summary, data) {
532 try { 555 try {
@@ -1220,6 +1243,20 @@ async function advanceOne(runId, opts = {}) { @@ -1220,6 +1243,20 @@ async function advanceOne(runId, opts = {}) {
1220 // executor 自己炸了(规划失败 / 配置缺失等)算节点失败,不带 failReason,走兜底边。 1243 // executor 自己炸了(规划失败 / 配置缺失等)算节点失败,不带 failReason,走兜底边。
1221 // err.tokens:规划连着跑了几轮才放弃时,那几轮的 token 是真花了的,照扣不误(§四.A.3「只增不减」)。 1244 // err.tokens:规划连着跑了几轮才放弃时,那几轮的 token 是真花了的,照扣不误(§四.A.3「只增不减」)。
1222 outcome = { status: 'fail', output: err.message, failReason: null, tokens: err.tokens || 0 }; 1245 outcome = { status: 'fail', output: err.message, failReason: null, tokens: err.tokens || 0 };
  1246 + // W12:这条路径以前是**净损失** —— err.message 写进 outcome.output 之后没有任何一环消费它:
  1247 + // 不落库(graph_run_nodes 没有 output 列,且不该有,见 EVENT_TEXT_MAX 的注释)、
  1248 + // 不进 SSE、也不写 timeline,fail_reason 还是 null。操作者看到的是「节点 fail、无任何原因」。
  1249 + // 真跑第一个 work 节点时 AgentManager 抛的「Agent not found」正是从这里出去的。
  1250 + // 现在原因先进审计轨(timeline.ndjson,永不改写)+ 实时轨(stream.log / SSE),
  1251 + // 排障至少有的可查;错误类型与调用目标一并记,好判断是「本体没雇进公司」还是别的。
  1252 + timelineNote(ctx, 'node.error', `节点执行器抛异常(${(err && err.name) || 'Error'}):${err && err.message}`, {
  1253 + node_run_id: ctx.nodeRunId, node_name: nodeName, attempt: ctx.attempt,
  1254 + node_kind: ctx.nodeKind, system_agent_id: ctx.systemAgentId || null,
  1255 + error: clipText(err && err.message ? err.message : String(err)),
  1256 + error_type: (err && err.name) || 'Error',
  1257 + // 栈顶几行足够定位抛点(整份栈进事件没意义,也会把 stream.log 撑大)
  1258 + error_stack: clipText(err && err.stack ? String(err.stack).split('\n').slice(0, 6).join('\n') : null),
  1259 + });
1223 } 1260 }
1224 // 炸掉的那条路径同样要查:agent 写完仓库再崩,痕迹一样在。 1261 // 炸掉的那条路径同样要查:agent 写完仓库再崩,痕迹一样在。
1225 outcome = applyBoundaryGuard(ctx, guardSnapshot, outcome); 1262 outcome = applyBoundaryGuard(ctx, guardSnapshot, outcome);
@@ -1238,6 +1275,11 @@ async function advanceOne(runId, opts = {}) { @@ -1238,6 +1275,11 @@ async function advanceOne(runId, opts = {}) {
1238 status: outcome && outcome.status, fail_reason: (outcome && outcome.failReason) || null, 1275 status: outcome && outcome.status, fail_reason: (outcome && outcome.failReason) || null,
1239 output_ref: (outcome && outcome.outputRef) || null, 1276 output_ref: (outcome && outcome.outputRef) || null,
1240 child_run_id: (outcome && outcome.childRunId) || null, 1277 child_run_id: (outcome && outcome.childRunId) || null,
  1278 + // W12:outcome.output 有三个产生方(defaultAgentExecutor 的 res.output、
  1279 + // applyBoundaryGuard 的越界 banner、上面 catch 的 err.message),以前**一个消费方都没有**。
  1280 + // 这里是它唯一的出口:定长摘要进事件(前端 NodeRunLog 已经在按 node_run_id 消费
  1281 + // node.output 这条事件),完整输出仍然只在 output_ref 指的产物目录里。
  1282 + output_excerpt: clipText(outcome && outcome.output),
1241 }, 1283 },
1242 { ref: picked.id }, 1284 { ref: picked.id },
1243 ); 1285 );
@@ -2529,8 +2571,11 @@ function applyBoundaryGuard(ctx, before, outcome) { @@ -2529,8 +2571,11 @@ function applyBoundaryGuard(ctx, before, outcome) {
2529 }); 2571 });
2530 2572
2531 if (outcome && outcome.status === 'pending') return outcome; 2573 if (outcome && outcome.status === 'pending') return outcome;
  2574 + // W12:banner 里不再重复改动清单 —— 清单已经在上面那条 node.boundary 事件的 entries 里
  2575 + // (证据不丢),而 outcome.output 现在会被截成 output_excerpt 进 node.output 事件,
  2576 + // 塞 20 行清单只会把 agent 真正的输出挤出摘要窗口。这里只留「为什么被判 fail」这一句。
2532 const banner = `[边界守卫] 本次调用在宿主仓库(${REPO_ROOT})留下 ${escapes.length} 处改动,` 2577 const banner = `[边界守卫] 本次调用在宿主仓库(${REPO_ROOT})留下 ${escapes.length} 处改动,`
2533 - + `按越界判失败。改动清单:\n${shown.join('\n')}`; 2578 + + '按越界判失败(改动清单见同一节点的 node.boundary 事件 entries)。';
2534 return { 2579 return {
2535 ...(outcome || {}), 2580 ...(outcome || {}),
2536 status: 'fail', 2581 status: 'fail',
server/services/graph-service.js
@@ -54,12 +54,78 @@ function ensureOrgGraphNodeIndex(db) { @@ -54,12 +54,78 @@ function ensureOrgGraphNodeIndex(db) {
54 } 54 }
55 55
56 /** 56 /**
  57 + * 保证 system_agents 里有 systemAgentId 这一行(缺则按 seedCompanyAgents()
  58 + * (graph-seed-service.js 约 213-295 行)对同样场景的字段约定 fallback 插入
  59 + * source_kind='company_hired' 的记录)。已存在的行一个字段都不改——绝不把
  60 + * source_kind='template' / 'global_agent' 的公共 agent 降级成公司私有。
  61 + *
  62 + * P3B/W7 起从 upsertOrgGraphNode() 里拆出来单独导出:work 层本体被雇时**不写**
  63 + * org_graph_nodes(判据见 company-service.decideOrgNodeRecording),但 system_agents
  64 + * 这一行仍然必须补——`work_graph_nodes.system_agent_id` 是指向该表的外键,
  65 + * 缺行的话 workgraph-planning 雇完人再提交图会直接外键挂(27-P3B §四 W1)。
  66 + *
  67 + * 幂等,可重复调用。调用方自己起事务时也安全(better-sqlite3 支持嵌套 transaction)。
  68 + *
  69 + * @param {string} companyId
  70 + * @param {string} systemAgentId
  71 + * @param {string} roleName - 仅用于 fallback 行的 legacy_path
  72 + * @returns {boolean} true = 本次真的插了一行;false = 早就有了
  73 + */
  74 +function ensureSystemAgentRow(companyId, systemAgentId, roleName) {
  75 + const db = getDb();
  76 + const existingAgent = db.prepare(`SELECT id FROM system_agents WHERE id = ?`).get(systemAgentId);
  77 + if (existingAgent) return false;
  78 +
  79 + const now = new Date().toISOString();
  80 + db.prepare(`
  81 + INSERT INTO system_agents (id, name, scope, owner_company_id, source_kind, legacy_path, status, benchmark_score, created_at, updated_at)
  82 + VALUES (@id, @name, 'company', @owner_company_id, 'company_hired', @legacy_path, 'active', NULL, @created_at, @updated_at)
  83 + `).run({
  84 + id: systemAgentId,
  85 + name: systemAgentId,
  86 + owner_company_id: companyId,
  87 + legacy_path: `data/companies/${companyId}/agents/${roleName}`,
  88 + created_at: now,
  89 + updated_at: now,
  90 + });
  91 + return true;
  92 +}
  93 +
  94 +/**
  95 + * 这家公司的 org graph 有没有成型,以及某个角色在不在图上(P3B §四 W7 的判据之一)。
  96 + *
  97 + * `hasWorkflow` 看的是**边**不是节点:节点 hire() 自己就会造(P1/P2 语义),拿节点当
  98 + * 「图已存在」的信号会把「同一家公司连雇两个人,第二个不入图」这条既有语义废掉。
  99 + * 有 active 边则说明 orggraph-planning 提交过一张连起来的图(POST /org-graph →
  100 + * org-graph-service.writeGraph 是唯一写边的地方),此后这张图的作者就是它,
  101 + * hire() 不再往上追加节点:`work-graph-db.js:473` 起 org run 时读的正是 org_graph_nodes
  102 + * 的 active 行,混进去的 work 层本体不是画歪一张图,而是真的多出一个会被执行的 org 节点。
  103 + *
  104 + * @param {string} companyId
  105 + * @param {string} roleName
  106 + * @returns {{hasWorkflow: boolean, isNode: boolean}}
  107 + */
  108 +function orgGraphMembership(companyId, roleName) {
  109 + const db = getDb();
  110 + const edges = db.prepare(
  111 + `SELECT COUNT(*) AS c FROM org_graph_edges WHERE company_id = ? AND status = 'active'`
  112 + ).get(companyId);
  113 + const node = db.prepare(
  114 + `SELECT COUNT(*) AS c FROM org_graph_nodes WHERE company_id = ? AND role_name = ? AND status = 'active'`
  115 + ).get(companyId, roleName);
  116 +
  117 + return { hasWorkflow: ((edges && edges.c) || 0) > 0, isNode: ((node && node.c) || 0) > 0 };
  118 +}
  119 +
  120 +/**
57 * 为 (companyId, roleName) 建立/复用一条 org_graph_nodes 引用记录。 121 * 为 (companyId, roleName) 建立/复用一条 org_graph_nodes 引用记录。
58 * 122 *
59 - * hire() 每次成功后都会调用(不论走引用模式还是 legacy 全量拷贝模式),因此不能假设  
60 - * system_agents 里已经有 systemAgentId 对应的行——没有时按 seedCompanyAgents()  
61 - * (graph-seed-service.js 约 213-295 行)对同样场景的字段约定 fallback 插入一行  
62 - * source_kind='company_hired' 的 system_agents 记录。 123 + * 语义是「把这个 agent 记成本公司 org graph 的一个节点」——**显式动作**,
  124 + * 调用方有两类:org-graph-service.insertNode(规划 agent 提交 org graph)与
  125 + * company-service.hire 判定为 org 层招聘时。work 层招聘不走这里(P3B §四 W7)。
  126 + *
  127 + * system_agents 缺行时先补(ensureSystemAgentRow),因为 org_graph_nodes.system_agent_id
  128 + * 也是外键。
63 * 129 *
64 * 幂等:org_graph_nodes(company_id, role_name) WHERE status='active' 部分唯一索引 130 * 幂等:org_graph_nodes(company_id, role_name) WHERE status='active' 部分唯一索引
65 * + INSERT ... ON CONFLICT(...) WHERE status='active' DO NOTHING,重复调用不产生重复行。 131 * + INSERT ... ON CONFLICT(...) WHERE status='active' DO NOTHING,重复调用不产生重复行。
@@ -77,21 +143,7 @@ function upsertOrgGraphNode(companyId, systemAgentId, roleName) { @@ -77,21 +143,7 @@ function upsertOrgGraphNode(companyId, systemAgentId, roleName) {
77 const now = new Date().toISOString(); 143 const now = new Date().toISOString();
78 144
79 const tx = db.transaction(() => { 145 const tx = db.transaction(() => {
80 - const existingAgent = db.prepare(`SELECT id FROM system_agents WHERE id = ?`).get(systemAgentId);  
81 -  
82 - if (!existingAgent) {  
83 - db.prepare(`  
84 - INSERT INTO system_agents (id, name, scope, owner_company_id, source_kind, legacy_path, status, benchmark_score, created_at, updated_at)  
85 - VALUES (@id, @name, 'company', @owner_company_id, 'company_hired', @legacy_path, 'active', NULL, @created_at, @updated_at)  
86 - `).run({  
87 - id: systemAgentId,  
88 - name: systemAgentId,  
89 - owner_company_id: companyId,  
90 - legacy_path: `data/companies/${companyId}/agents/${roleName}`,  
91 - created_at: now,  
92 - updated_at: now,  
93 - });  
94 - } 146 + ensureSystemAgentRow(companyId, systemAgentId, roleName);
95 147
96 db.prepare(` 148 db.prepare(`
97 INSERT INTO org_graph_nodes (company_id, system_agent_id, role_name, status, created_at) 149 INSERT INTO org_graph_nodes (company_id, system_agent_id, role_name, status, created_at)
@@ -110,5 +162,7 @@ function upsertOrgGraphNode(companyId, systemAgentId, roleName) { @@ -110,5 +162,7 @@ function upsertOrgGraphNode(companyId, systemAgentId, roleName) {
110 162
111 module.exports = { 163 module.exports = {
112 lookupSharedAgentMd, 164 lookupSharedAgentMd,
  165 + ensureSystemAgentRow,
  166 + orgGraphMembership,
113 upsertOrgGraphNode, 167 upsertOrgGraphNode,
114 }; 168 };
server/tests/unit/company-hire-org-layer.test.js 0 → 100644
  1 +/**
  2 + * Unit Tests — 招聘的「层」:work 层本体不得进 org graph
  3 + * (27-P3B §四 W7 / 27-REQ R-18.2 末段)
  4 + *
  5 + * 锁的是 company-service.decideOrgNodeRecording 的三条判据 + 默认行为:
  6 + * ① config.type ∈ {meta-bootstrap, meta-planning} 的图基础设施本体永不入图;
  7 + * ② system/work-graph-templates/*.json 里点名当节点的本体永不入图;
  8 + * ③ 公司已有**成型**的 org graph(有 active 边)时,不在图上的角色不入图;
  9 + * ④ 都不命中 → 入图,即 P1/P2 既有语义(「雇一个角色 → org graph 出现它」)。
  10 + *
  11 + * 每条「不入图」的用例都配一条同角色(或同公司)只换一个条件的正例,
  12 + * 免得测试退化成「这个角色被整体拉黑了」而看不出判据本身有没有生效。
  13 + * 三条判据都做过变异验证(删判据①/②/③ 各会红掉对应用例)。
  14 + *
  15 + * 走的是**真的** hire()(不是 mock),落库后从 org-graph-service.getGraph() 读回
  16 + * ——即 GET /api/company/:id/org-graph 的同一条读路径。
  17 + *
  18 + * 隔离手法与 graph-agent-p1-acceptance.test.js 同源:
  19 + * - GRAPH_DB_PATH 指向一次性 sqlite,真实 data/graph.sqlite 全程不被碰;
  20 + * - 公司建在 data/companies/tmp-w7-* 下,afterAll 删掉;
  21 + * - hire() 会 bump system/templates/_registry.json 的 usedCount,快照后恢复。
  22 + */
  23 +import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  24 +import fs from 'fs';
  25 +import path from 'path';
  26 +
  27 +describe('hire() 的 org / work 分层(27-P3B W7)', () => {
  28 + const REPO_ROOT = path.resolve(__dirname, '../../..');
  29 + const TEST_DB_PATH = '/tmp/test-company-hire-org-layer.sqlite';
  30 + const DATA_DIR = path.join(REPO_ROOT, 'data/companies');
  31 + const TEMPLATES_DIR = path.join(REPO_ROOT, 'system/templates');
  32 + const REGISTRY_PATH = path.join(TEMPLATES_DIR, '_registry.json');
  33 +
  34 + // 用到的模板(都是仓库里真实存在的),beforeAll 里逐个核实
  35 + const TEMPLATES = ['qa-judge', 'hr', 'orggraph-planning', 'prd-planning', 'validator-triager', 'coder-validator'];
  36 +
  37 + const RUN_ID = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
  38 + const CO_PLANNED = `tmp-w7-planned-${RUN_ID}`; // orggraph-planning 已提交过成型的图
  39 + const CO_BLANK = `tmp-w7-blank-${RUN_ID}`; // 从没有过图(P1/P2 的老场景)
  40 + const CO_BOOTSTRAP = `tmp-w7-bootstrap-${RUN_ID}`; // 刚建的公司,也没有图
  41 +
  42 + // org graph(照抄 system/org-graph-templates/dev.json,能过 §3.3 四项校验)
  43 + const ORG_NODES = ['org-planner', 'org-coder', 'org-validator'];
  44 + const ORG_EDGES = [
  45 + { from: 'org-planner', to: 'org-coder', condition: 'success' },
  46 + { from: 'org-coder', to: 'org-validator', condition: 'success' },
  47 + { from: 'org-validator', to: 'org-coder', condition: 'fail', edgeKind: 'backward' },
  48 + ];
  49 +
  50 + let graphDb;
  51 + let orgGraphService;
  52 + let companyService;
  53 + let registrySnapshot;
  54 +
  55 + /** 从 GET org-graph 的同一条读路径拿 active 角色名 */
  56 + const roleNames = (companyId) =>
  57 + orgGraphService.getGraph(companyId).nodes.filter((n) => n.status === 'active').map((n) => n.roleName);
  58 +
  59 + const systemAgentRow = (id) =>
  60 + graphDb.getDb().prepare('SELECT * FROM system_agents WHERE id = ?').get(id);
  61 +
  62 + beforeAll(() => {
  63 + // 模板必须真的在仓库里,否则 hire() 会走 base 继承分支,测试变成空跑
  64 + for (const tpl of TEMPLATES) {
  65 + expect(fs.existsSync(path.join(TEMPLATES_DIR, tpl, 'config.json'))).toBe(true);
  66 + }
  67 + // 判据②的数据源也要真的点名了 validator-triager,否则那条用例是空跑
  68 + const workTpl = JSON.parse(
  69 + fs.readFileSync(path.join(REPO_ROOT, 'system/work-graph-templates/validator.json'), 'utf-8')
  70 + );
  71 + expect(workTpl.nodes.map((n) => n.systemAgentId)).toContain('validator-triager');
  72 +
  73 + registrySnapshot = fs.readFileSync(REGISTRY_PATH, 'utf-8');
  74 +
  75 + if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH);
  76 + process.env.GRAPH_DB_PATH = TEST_DB_PATH;
  77 + for (const m of ['../../services/graph-db', '../../services/graph-service',
  78 + '../../services/org-graph-service', '../../services/company-service']) {
  79 + delete require.cache[require.resolve(m)];
  80 + }
  81 + graphDb = require('../../services/graph-db');
  82 + orgGraphService = require('../../services/org-graph-service');
  83 + const { CompanyService } = require('../../services/company-service');
  84 + companyService = new CompanyService();
  85 +
  86 + // 镜像生产种子:这些模板在 system_agents 里是 source_kind='template'(引用模式)
  87 + const db = graphDb.getDb();
  88 + const now = new Date().toISOString();
  89 + for (const id of TEMPLATES) {
  90 + db.prepare(`
  91 + INSERT INTO system_agents (id, name, scope, owner_company_id, source_kind, legacy_path, status, benchmark_score, created_at, updated_at)
  92 + VALUES (?, ?, 'public', NULL, 'template', ?, 'active', NULL, ?, ?)
  93 + `).run(id, id, `system/templates/${id}`, now, now);
  94 + }
  95 +
  96 + for (const [id, name] of [[CO_PLANNED, 'W7 已有组织图'], [CO_BLANK, 'W7 空图公司'], [CO_BOOTSTRAP, 'W7 新建公司']]) {
  97 + companyService.create({ id, name, description: 'W7 分层测试' });
  98 + }
  99 +
  100 + // CO_PLANNED 走 P2 §4.1 的正常顺序:orggraph-planning 先提交图,再招聘
  101 + const written = orgGraphService.writeGraph(CO_PLANNED, { nodes: ORG_NODES, edges: ORG_EDGES });
  102 + expect(written.ok).toBe(true);
  103 + expect(written.nodeCount).toBe(3);
  104 + expect(written.edgeCount).toBe(3);
  105 + });
  106 +
  107 + afterAll(() => {
  108 + for (const id of [CO_PLANNED, CO_BLANK, CO_BOOTSTRAP]) {
  109 + try { fs.rmSync(path.join(DATA_DIR, id), { recursive: true, force: true }); } catch {}
  110 + }
  111 + try { fs.unlinkSync(TEST_DB_PATH); } catch {}
  112 + try { fs.unlinkSync(`${TEST_DB_PATH}-shm`); } catch {}
  113 + try { fs.unlinkSync(`${TEST_DB_PATH}-wal`); } catch {}
  114 + delete process.env.GRAPH_DB_PATH;
  115 + if (registrySnapshot !== undefined) fs.writeFileSync(REGISTRY_PATH, registrySnapshot, 'utf-8');
  116 + });
  117 +
  118 + // ── ④ 默认行为:P1/P2 语义原样保住 ──────────────────────────────────
  119 + it('公司还没有 org graph 时雇一个角色 → org graph 里出现这个节点(P2 §4.2 语义)', () => {
  120 + expect(roleNames(CO_BLANK)).toEqual([]);
  121 +
  122 + expect(companyService.hire(CO_BLANK, 'qa-judge', {}).status).toBe('hired_from_template');
  123 +
  124 + expect(roleNames(CO_BLANK)).toEqual(['qa-judge']);
  125 + });
  126 +
  127 + it('同一家没有图的公司连雇两个人,第二个照样入图(判据③不能拿节点当信号的原因)', () => {
  128 + expect(companyService.hire(CO_BLANK, 'hr', {}).status).toBe('hired_from_template');
  129 +
  130 + expect(roleNames(CO_BLANK).sort()).toEqual(['hr', 'qa-judge']);
  131 + });
  132 +
  133 + // ── ② work graph 骨架点名的本体 ─────────────────────────────────────
  134 + it('validator-triager(type=validator,骨架里点过名)不入图,哪怕公司连一条边都没有', () => {
  135 + const before = roleNames(CO_BLANK).sort();
  136 +
  137 + expect(companyService.hire(CO_BLANK, 'validator-triager', {}).status).toBe('hired_from_template');
  138 +
  139 + expect(roleNames(CO_BLANK).sort()).toEqual(before);
  140 + // 但 system_agents 那行必须在:work_graph_nodes.system_agent_id 是指向它的外键,
  141 + // 缺行的话 workgraph-planning 雇完人再提交 work graph 会直接外键挂(W1 那条失效链)
  142 + expect(systemAgentRow('validator-triager')).toBeTruthy();
  143 + });
  144 +
  145 + // ── ① 图基础设施本体 ────────────────────────────────────────────────
  146 + it('meta-bootstrap(orggraph-planning)不入图:建公司到规划跑完之间不会返回一张假组织图', () => {
  147 + expect(companyService.hire(CO_BOOTSTRAP, 'orggraph-planning', {}).status).toBe('hired_from_template');
  148 +
  149 + expect(roleNames(CO_BOOTSTRAP)).toEqual([]);
  150 + expect(systemAgentRow('orggraph-planning')).toBeTruthy();
  151 + });
  152 +
  153 + it('meta-planning(prd-planning)不入图,哪怕公司还没有图(判据①单独生效)', () => {
  154 + expect(roleNames(CO_BOOTSTRAP)).toEqual([]);
  155 +
  156 + expect(companyService.hire(CO_BOOTSTRAP, 'prd-planning', {}).status).toBe('hired_from_template');
  157 +
  158 + expect(roleNames(CO_BOOTSTRAP)).toEqual([]);
  159 + });
  160 +
  161 + it('coder-validator 在没有成型图的公司里照样入图 —— 判据①②都不认识它', () => {
  162 + expect(companyService.hire(CO_BOOTSTRAP, 'coder-validator', {}).status).toBe('hired_from_template');
  163 +
  164 + expect(roleNames(CO_BOOTSTRAP)).toEqual(['coder-validator']);
  165 + });
  166 +
  167 + // ── ③ 成型的图 + 不在图上 = work 层本体 ─────────────────────────────
  168 + it('同一个 coder-validator,在已有成型 org graph 的公司里被雇 → GET org-graph 里没有它', () => {
  169 + expect(roleNames(CO_PLANNED)).toEqual(ORG_NODES);
  170 +
  171 + expect(companyService.hire(CO_PLANNED, 'coder-validator', {}).status).toBe('hired_from_template');
  172 +
  173 + // 雇到位了:目录在、公司名册里有
  174 + expect(fs.existsSync(path.join(DATA_DIR, CO_PLANNED, 'agents/coder-validator'))).toBe(true);
  175 + expect(companyService.get(CO_PLANNED).agents).toContain('coder-validator');
  176 + // 但组织图一个节点都没多
  177 + expect(roleNames(CO_PLANNED)).toEqual(ORG_NODES);
  178 + });
  179 +
  180 + it('R-18.2 验收 7:workgraph-planning 雇一轮 work 层本体后,org graph 仍然只有 org 节点', () => {
  181 + for (const body of ['prd-planning', 'validator-triager']) {
  182 + expect(['hired_from_template', 'already_exists']).toContain(
  183 + companyService.hire(CO_PLANNED, body, {}).status
  184 + );
  185 + }
  186 +
  187 + expect(roleNames(CO_PLANNED)).toEqual(ORG_NODES);
  188 + });
  189 +
  190 + it('org 节点本身重复雇不产生新节点(P2 幂等语义不变)', () => {
  191 + const res = companyService.hire(CO_PLANNED, 'org-coder', {});
  192 + // 模板库里没有 org-coder → 走 base 元 agent 继承分支,同样要过 _recordHireInGraph
  193 + expect(['hired_from_template', 'hired_from_base']).toContain(res.status);
  194 +
  195 + expect(roleNames(CO_PLANNED)).toEqual(ORG_NODES);
  196 + const count = graphDb.getDb()
  197 + .prepare(`SELECT COUNT(*) AS c FROM org_graph_nodes WHERE company_id = ? AND role_name = 'org-coder' AND status = 'active'`)
  198 + .get(CO_PLANNED).c;
  199 + expect(count).toBe(1);
  200 + });
  201 +});
server/tests/unit/graph-engine-node-error.test.js 0 → 100644
  1 +/**
  2 + * Unit Tests — 节点失败时原因留不留得下来(27-P3B W12,「第 12 处四环漏一环」)
  3 + *
  4 + * 修之前:advanceOne 的 executor catch 把 err.message 写进 outcome.output,
  5 + * 而 outcome.output **一个消费方都没有** —— 不落库(graph_run_nodes 没有 output 列)、
  6 + * 不进 SSE、也不写 timeline,fail_reason 还是 null。真跑第一个 work 节点时
  7 + * AgentManager 抛的「Agent not found」就是这么消失的,操作者看到「节点 fail、无任何原因」。
  8 + *
  9 + * 这里锁的是修完之后的四环:
  10 + * 产生(catch / executor 返回值)→ 传递(timelineNote / node.output 事件 data)
  11 + * → 落库(timeline.ndjson + stream.log,append-only)→ 读回(本用例就是按读回的那条路读的:
  12 + * 前端走 GET /api/graph-runs/:id/stream?lastEventId=0 全量重放同一份 stream.log)
  13 + *
  14 + * 隔离手法同 graph-engine.test.js:先设 GRAPH_DB_PATH / GRAPH_RUNS_DIR 再 require,
  15 + * 仓库里的 data/graph.sqlite 与 data/companies/ 全程不被碰到;executor 全是注入的假货,
  16 + * 不起任何 Claude CLI。
  17 + */
  18 +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
  19 +import fs from 'fs';
  20 +import os from 'os';
  21 +import path from 'path';
  22 +
  23 +describe('graph-engine 节点失败留痕(W12)', () => {
  24 + let wdb;
  25 + let engine;
  26 + let sse;
  27 + let runsDir;
  28 + const TEST_DB_PATH = '/tmp/test-graph-engine-node-error.sqlite';
  29 +
  30 + beforeAll(() => {
  31 + for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) {
  32 + try { fs.unlinkSync(f); } catch {}
  33 + }
  34 + process.env.GRAPH_DB_PATH = TEST_DB_PATH;
  35 + runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-node-error-runs-'));
  36 + process.env.GRAPH_RUNS_DIR = runsDir;
  37 + for (const m of [
  38 + '../../services/graph-db',
  39 + '../../services/graph-service',
  40 + '../../services/org-graph-service',
  41 + '../../services/work-graph-db',
  42 + '../../services/graph-run-sse',
  43 + '../../services/graph-engine',
  44 + ]) {
  45 + delete require.cache[require.resolve(m)];
  46 + }
  47 + wdb = require('../../services/work-graph-db');
  48 + sse = require('../../services/graph-run-sse');
  49 + engine = require('../../services/graph-engine');
  50 + });
  51 +
  52 + afterAll(() => {
  53 + for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) {
  54 + try { fs.unlinkSync(f); } catch {}
  55 + }
  56 + delete process.env.GRAPH_DB_PATH;
  57 + try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {}
  58 + delete process.env.GRAPH_RUNS_DIR;
  59 + });
  60 +
  61 + beforeEach(() => {
  62 + const db = wdb.getDb();
  63 + db.pragma('foreign_keys = OFF');
  64 + db.exec(`
  65 + DELETE FROM graph_run_loop_records;
  66 + DELETE FROM graph_run_nodes;
  67 + DELETE FROM graph_runs;
  68 + DELETE FROM work_graph_edges;
  69 + DELETE FROM work_graph_nodes;
  70 + DELETE FROM work_graphs;
  71 + DELETE FROM system_agents;
  72 + `);
  73 + db.pragma('foreign_keys = ON');
  74 + engine.setAgentExecutor(null);
  75 + });
  76 +
  77 + let seq = 0;
  78 + const nextCompany = () => `w12-co-${++seq}`;
  79 +
  80 + function ensureAgents(ids) {
  81 + const db = wdb.getDb();
  82 + const now = new Date().toISOString();
  83 + const ins = db.prepare(`
  84 + INSERT OR IGNORE INTO system_agents
  85 + (id, name, scope, owner_company_id, source_kind, legacy_path, status, created_at, updated_at)
  86 + VALUES (?, ?, 'public', NULL, 'global_agent', ?, 'active', ?, ?)
  87 + `);
  88 + for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, now, now);
  89 + }
  90 +
  91 + function buildRun(nodes, edges = []) {
  92 + const companyId = nextCompany();
  93 + ensureAgents(nodes);
  94 + const g = wdb.createWorkGraph({ companyId, nodes, edges });
  95 + expect(g.errors || []).toEqual([]);
  96 + const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId, executionMode: 'manual' });
  97 + engine.markRunReady(run.id);
  98 + return run.id;
  99 + }
  100 +
  101 + /** 读回轨 1:审计轨 timeline.ndjson(append-only) */
  102 + const timeline = (runId) =>
  103 + fs.readFileSync(sse.timelinePath(runId), 'utf8')
  104 + .split('\n').filter(Boolean).map((l) => JSON.parse(l));
  105 +
  106 + /** 读回轨 2:stream.log —— 前端 lastEventId=0 全量重放读的就是这一份 */
  107 + const streamEvents = (runId) =>
  108 + fs.readFileSync(path.join(runsDir, runId, 'stream.log'), 'utf8')
  109 + .split('\n\n').filter(Boolean)
  110 + .map((block) => {
  111 + const ev = /^event: (.+)$/m.exec(block);
  112 + const data = /^data: (.+)$/m.exec(block);
  113 + return { event: ev && ev[1], data: JSON.parse(data[1]) };
  114 + });
  115 +
  116 + const nodeByName = (runId, name) => wdb.getRunState(runId).nodes.find((n) => n.name === name);
  117 +
  118 + it('executor 抛异常:节点 fail,且 timeline 里查得到原因(不是空的)', async () => {
  119 + const runId = buildRun(['prd-planning']);
  120 + engine.setAgentExecutor(async () => {
  121 + // 真跑第一个 work 节点时 AgentManager.getAgentConfig 抛的就是这一句
  122 + throw new Error('Agent not found: prd-planning');
  123 + });
  124 +
  125 + engine.startRun(runId);
  126 + await engine.nextStep(runId);
  127 +
  128 + const node = nodeByName(runId, 'prd-planning');
  129 + // 没有 fail 边的死路:fail 之后就地 blocked —— 真跑第一个 work 节点时看到的就是这个
  130 + expect(node.status).toBe('blocked');
  131 + // 引擎仍然不发明 fail_reason(它是选边的匹配键)
  132 + expect(node.failReason).toBeNull();
  133 +
  134 + const errs = timeline(runId).filter((e) => e.kind === 'node.error');
  135 + expect(errs).toHaveLength(1);
  136 + expect(errs[0].summary).toContain('Agent not found: prd-planning');
  137 + expect(errs[0].payload.node_run_id).toBe(node.id);
  138 + expect(errs[0].payload.attempt).toBe(0);
  139 + expect(errs[0].payload.node_name).toBe('prd-planning');
  140 + expect(errs[0].payload.system_agent_id).toBe('prd-planning');
  141 + expect(errs[0].payload.error).toBe('Agent not found: prd-planning');
  142 + expect(errs[0].payload.error_type).toBe('Error');
  143 + expect(errs[0].payload.error_stack).toContain('Error: Agent not found');
  144 + // ref = 节点 run id:前端 NodeRunLog 按 node_run_id 归组,这条落在失败节点自己的卡片里
  145 + expect(errs[0].ref).toBe(node.id);
  146 +
  147 + // 同一条事件也在实时轨上(emitEvent 保证两轨不分家)
  148 + const onStream = streamEvents(runId).filter((e) => e.event === 'node.error');
  149 + expect(onStream).toHaveLength(1);
  150 + expect(onStream[0].data.error).toBe('Agent not found: prd-planning');
  151 + });
  152 +
  153 + it('抛异常时 node.output 事件带 output_excerpt,错误原因同样能读回', async () => {
  154 + const runId = buildRun(['a']);
  155 + engine.setAgentExecutor(async () => { throw new Error('boom: 配置缺失'); });
  156 +
  157 + engine.startRun(runId);
  158 + await engine.nextStep(runId);
  159 +
  160 + const out = streamEvents(runId).filter((e) => e.event === 'node.output');
  161 + expect(out).toHaveLength(1);
  162 + expect(out[0].data.status).toBe('fail');
  163 + expect(out[0].data.fail_reason).toBeNull();
  164 + expect(out[0].data.output_excerpt).toBe('boom: 配置缺失');
  165 + });
  166 +
  167 + it('executor 正常返回的 output 也进 output_excerpt,超长按定长截断', async () => {
  168 + const runId = buildRun(['long']);
  169 + const huge = 'x'.repeat(5000);
  170 + engine.setAgentExecutor(async () => ({ status: 'success', output: huge }));
  171 +
  172 + engine.startRun(runId);
  173 + await engine.nextStep(runId);
  174 +
  175 + const out = streamEvents(runId).find((e) => e.event === 'node.output');
  176 + expect(out.data.status).toBe('success');
  177 + expect(out.data.output_excerpt.startsWith('xxxx')).toBe(true);
  178 + // 截断了,且短得多(完整输出只在 output_ref 指的产物目录里,不进事件也不进库)
  179 + expect(out.data.output_excerpt.length).toBeLessThan(huge.length);
  180 + expect(out.data.output_excerpt).toContain('已截断');
  181 + });
  182 +
  183 + it('executor 没有 output 时 output_excerpt 是 null,不塞空串', async () => {
  184 + const runId = buildRun(['quiet']);
  185 + engine.setAgentExecutor(async () => ({ status: 'success' }));
  186 +
  187 + engine.startRun(runId);
  188 + await engine.nextStep(runId);
  189 +
  190 + const out = streamEvents(runId).find((e) => e.event === 'node.output');
  191 + expect(out.data.output_excerpt).toBeNull();
  192 + // 没抛异常就不该有 node.error
  193 + expect(timeline(runId).filter((e) => e.kind === 'node.error')).toHaveLength(0);
  194 + });
  195 +});
server/tests/unit/orggraph-planning-fixture.test.js 0 → 100644
  1 +/**
  2 + * Unit Tests — fixtures/devloop-mock/orggraph-planning.json(P3B / W8)
  3 + *
  4 + * 这份 fixture 的价值全在「能不能被读回并落库」,所以本文件按四环各钉一颗钉子:
  5 + * 产生 → chunks 里确实有一段 ```json 图(不是只写在 result 里,那样 agent-chat 的
  6 + * fullOutput 收不到,见 agent-chat.js:2315 只累积 agent_message_chunk)
  7 + * 传递 → mock 协议按 agent id(含 `{cid}:` 前缀的作用域名)能命中这份 fixture 并把
  8 + * 图原样吐进流式 chunks
  9 + * 落库 → 这张图过得了 org-graph-service 的五项校验(真正的写入方 POST /org-graph 用的就是它)
  10 + * 读回 → 形状与 system/org-graph-templates/dev.json 对齐:三节点 + 两条 fail 回退边
  11 + *
  12 + * 反例保护:把图从 chunks 挪到 result、或让回退边漏标 backward、或砍掉那条兜底 fail 边,
  13 + * 本文件都会红。
  14 + */
  15 +import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  16 +import fs from 'fs';
  17 +import path from 'path';
  18 +
  19 +const REPO = path.resolve(__dirname, '../../..');
  20 +const FIXTURE = path.join(REPO, 'fixtures/devloop-mock/orggraph-planning.json');
  21 +
  22 +/** ```json 围栏块(与 graph-engine.extractPlannedGraph 同一取法) */
  23 +function fencedGraphs(text) {
  24 + const out = [];
  25 + const fence = /```(?:json|jsonc)?\s*([\s\S]*?)```/g;
  26 + let m;
  27 + while ((m = fence.exec(text))) {
  28 + try {
  29 + const doc = JSON.parse(m[1]);
  30 + if (Array.isArray(doc.nodes)) out.push(doc);
  31 + } catch { /* 非图的围栏块,跳过 */ }
  32 + }
  33 + return out;
  34 +}
  35 +
  36 +describe('orggraph-planning mock fixture', () => {
  37 + const fixture = JSON.parse(fs.readFileSync(FIXTURE, 'utf-8'));
  38 + const chunkText = (fixture.chunks || []).join('');
  39 +
  40 + it('图放在 chunks 里(而不是只放 result)', () => {
  41 + expect(fencedGraphs(chunkText)).toHaveLength(1);
  42 + // result 只是收尾摘要,不该承载图 —— 它进不了 agent-chat 的 fullOutput
  43 + expect(fencedGraphs(String(fixture.result || ''))).toHaveLength(0);
  44 + });
  45 +
  46 + it('形状对齐 system/org-graph-templates/dev.json:三节点 + 两条 fail 回退边', () => {
  47 + const g = fencedGraphs(chunkText)[0];
  48 + const dev = JSON.parse(fs.readFileSync(path.join(REPO, 'system/org-graph-templates/dev.json'), 'utf-8'));
  49 +
  50 + expect(g.nodes.map((n) => n.roleName)).toEqual(dev.nodes);
  51 + expect(g.edges.filter((e) => e.condition === 'success')).toHaveLength(2);
  52 +
  53 + const fails = g.edges.filter((e) => e.condition === 'fail');
  54 + expect(fails).toHaveLength(2);
  55 + expect(fails.every((e) => e.edgeKind === 'backward')).toBe(true);
  56 + // 兜底边:有 fail 出边的节点必须留一条不带 failReason 的(org-graph-service 的 fail-fallback-edge 规则)
  57 + expect(fails.filter((e) => !e.failReason)).toHaveLength(1);
  58 + });
  59 +
  60 + /**
  61 + * validateGraph 是纯函数(不碰 DB,graph-db 的连接只在 getDb() 里惰性打开),
  62 + * 所以这里既不设 GRAPH_DB_PATH 也不清 require.cache —— 那套隔离手法会污染同一
  63 + * worker 里后跑的 graph-seed-service.test.js(实测:加进去后它必红)。
  64 + */
  65 + it('这张图过得了 org-graph-service 的五项校验(真正的写入方用的就是它)', () => {
  66 + const svc = require('../../services/org-graph-service');
  67 + const g = fencedGraphs(chunkText)[0];
  68 + expect(svc.validateGraph(g.nodes, g.edges).errors).toEqual([]);
  69 + });
  70 +
  71 + /**
  72 + * fixture 的查找键是 `agentInfo.name` 冒号后那段(ClaudeCliProtocol._sendMockPrompt)。
  73 + * graph-engine 传的是 `{cid}:{agentId}`,命得中;agent-chat 传的是 identity.md 的显示名,
  74 + * 命不中(W8 备注里记了这个断点)。这条用例钉住的是「按 agent id 传就必须命中」这半边契约。
  75 + */
  76 + it('mock 协议按 agent id / 公司作用域名都能回放出这张图', () => {
  77 + const { ClaudeCliProtocol } = require('../../../packages/acp-core/protocol/ClaudeCliProtocol');
  78 + for (const name of ['orggraph-planning', 'some-co:orggraph-planning']) {
  79 + const p = new ClaudeCliProtocol({ name }, { mock: true });
  80 + let text = '';
  81 + p.on('session_update', (e) => { text += e.update.content.text; });
  82 + p._sendMockPrompt('规划这家公司的 org graph');
  83 + expect(fencedGraphs(text)).toHaveLength(1);
  84 + }
  85 + });
  86 +});
system/templates/_registry.json
@@ -15,7 +15,7 @@ @@ -15,7 +15,7 @@
15 "description": "质量验收、OKR 进度审查、标准化仲裁", 15 "description": "质量验收、OKR 进度审查、标准化仲裁",
16 "benchmarkScore": 95, 16 "benchmarkScore": 95,
17 "trainedAt": "2026-03-25", 17 "trainedAt": "2026-03-25",
18 - "usedCount": 62 18 + "usedCount": 82
19 }, 19 },
20 { 20 {
21 "id": "base", 21 "id": "base",
@@ -87,7 +87,7 @@ @@ -87,7 +87,7 @@
87 "description": "全局唯一一份 work graph 规划本体(R-15.2):每个 org node 每次接到任务时跑一次,读角色定义 + 任务 + artifacts/ + 上一轮 remediation_note,产出 work graph(org node 级 workflow)→ 过五项写入前校验 → 落库交回图 id。设计 docs/todo/system-design/27-p3-p4-work-graph-console.md §四.D.0 / §五 T11", 87 "description": "全局唯一一份 work graph 规划本体(R-15.2):每个 org node 每次接到任务时跑一次,读角色定义 + 任务 + artifacts/ + 上一轮 remediation_note,产出 work graph(org node 级 workflow)→ 过五项写入前校验 → 落库交回图 id。设计 docs/todo/system-design/27-p3-p4-work-graph-console.md §四.D.0 / §五 T11",
88 "benchmarkScore": null, 88 "benchmarkScore": null,
89 "trainedAt": null, 89 "trainedAt": null,
90 - "usedCount": 28 90 + "usedCount": 29
91 }, 91 },
92 { 92 {
93 "id": "validator-triager", 93 "id": "validator-triager",
@@ -96,7 +96,7 @@ @@ -96,7 +96,7 @@
96 "description": "org-validator work graph 的第一个节点:判失败根因,产出 fail_reason(impl_bug/input_underspecified/skill_defect/infra_flake) + remediation_note,透传回 org 层选边。skill_defect 挂起等人工,不触发 L4。设计 docs/todo/system-design/27-p3-p4-work-graph-console.md §四.D.2.2", 96 "description": "org-validator work graph 的第一个节点:判失败根因,产出 fail_reason(impl_bug/input_underspecified/skill_defect/infra_flake) + remediation_note,透传回 org 层选边。skill_defect 挂起等人工,不触发 L4。设计 docs/todo/system-design/27-p3-p4-work-graph-console.md §四.D.2.2",
97 "benchmarkScore": null, 97 "benchmarkScore": null,
98 "trainedAt": null, 98 "trainedAt": null,
99 - "usedCount": 5 99 + "usedCount": 6
100 }, 100 },
101 { 101 {
102 "id": "org-validator", 102 "id": "org-validator",
@@ -123,7 +123,7 @@ @@ -123,7 +123,7 @@
123 "description": "work 层验收本体:按 contract + acceptance_criteria 逐条验代码,测试必须真跑过,专抓空断言/跳过充数/只有 happy path 等假绿。设计 docs/todo/system-design/27-p3-p4-work-graph-console.md §四.D.3", 123 "description": "work 层验收本体:按 contract + acceptance_criteria 逐条验代码,测试必须真跑过,专抓空断言/跳过充数/只有 happy path 等假绿。设计 docs/todo/system-design/27-p3-p4-work-graph-console.md §四.D.3",
124 "benchmarkScore": null, 124 "benchmarkScore": null,
125 "trainedAt": null, 125 "trainedAt": null,
126 - "usedCount": 13 126 + "usedCount": 15
127 }, 127 },
128 { 128 {
129 "id": "brd-planning", 129 "id": "brd-planning",
@@ -141,7 +141,7 @@ @@ -141,7 +141,7 @@
141 "description": "work graph 内的规划节点:产出产品需求文档(PRD)。命名见 27-naming-conventions.md §二。", 141 "description": "work graph 内的规划节点:产出产品需求文档(PRD)。命名见 27-naming-conventions.md §二。",
142 "benchmarkScore": null, 142 "benchmarkScore": null,
143 "trainedAt": null, 143 "trainedAt": null,
144 - "usedCount": 4 144 + "usedCount": 7
145 }, 145 },
146 { 146 {
147 "id": "sdd-planning", 147 "id": "sdd-planning",
@@ -150,7 +150,7 @@ @@ -150,7 +150,7 @@
150 "description": "work graph 内的规划节点:产出系统设计文档(SDD)。命名见 27-naming-conventions.md §二。", 150 "description": "work graph 内的规划节点:产出系统设计文档(SDD)。命名见 27-naming-conventions.md §二。",
151 "benchmarkScore": null, 151 "benchmarkScore": null,
152 "trainedAt": null, 152 "trainedAt": null,
153 - "usedCount": 4 153 + "usedCount": 5
154 }, 154 },
155 { 155 {
156 "id": "goal-benchmark", 156 "id": "goal-benchmark",
@@ -168,7 +168,7 @@ @@ -168,7 +168,7 @@
168 "description": "org-validator work graph 的节点:执行 goal-benchmark 产出的验收基准并给出判决。见 27-p3-p4-work-graph-console.md §四.D.3。", 168 "description": "org-validator work graph 的节点:执行 goal-benchmark 产出的验收基准并给出判决。见 27-p3-p4-work-graph-console.md §四.D.3。",
169 "benchmarkScore": null, 169 "benchmarkScore": null,
170 "trainedAt": null, 170 "trainedAt": null,
171 - "usedCount": 1 171 + "usedCount": 3
172 }, 172 },
173 { 173 {
174 "id": "testdata-validator", 174 "id": "testdata-validator",
system/templates/workgraph-planning/agent.md
@@ -6,8 +6,15 @@ @@ -6,8 +6,15 @@
6 都调**同一个你**——不存在 `org-coder-workgraph-planning` 这种按节点特化的副本。 6 都调**同一个你**——不存在 `org-coder-workgraph-planning` 这种按节点特化的副本。
7 你在不同 org node / 不同公司下的差异,靠**记忆分区**承载,不靠复制本体。 7 你在不同 org node / 不同公司下的差异,靠**记忆分区**承载,不靠复制本体。
8 8
9 -你的唯一职责:为**某一个 org node 的这一次任务**规划出一张 **work graph**,过校验,落库,把图 id 交回引擎。  
10 -你不执行图、不招人、不建 agent、不改 org graph。 9 +你的职责有两件,**顺序不可颠倒**:为**某一个 org node 的这一次任务**规划出一张 **work graph**,
  10 +**先把这张图引用到的本体全部雇进本公司**,再提交图、把图 id 交回引擎。
  11 +你不执行图、不改 org graph。
  12 +
  13 +> 🚨 **「图里引用到的本体由你雇」是 R-18.2 的定案,不是可选项。**
  14 +> 从前这一层没有任何执行者:org 层的三个节点由 `orggraph-planning` 建公司时招齐,
  15 +> 而你图里引用的 `prd-planning` / `coder-validator` 这类本体**是任务来了才知道的**,
  16 +> 建公司时没人能预知,于是一个都没被雇 —— 节点跑起来必然
  17 +> `Agent not found: <本体名>`,整条链断在第一个 work 节点。**你就是这一环的执行者。**
11 18
12 > **work graph 是什么**:一张**执行流图**,节点是一次 agent 调用(引用某个 system agent **本体**), 19 > **work graph 是什么**:一张**执行流图**,节点是一次 agent 调用(引用某个 system agent **本体**),
13 > 边是「上游出这个结果时,任务流向谁」。边只有两种 `condition`:`success` / `fail`。 20 > 边是「上游出这个结果时,任务流向谁」。边只有两种 `condition`:`success` / `fail`。
@@ -18,9 +25,9 @@ @@ -18,9 +25,9 @@
18 25
19 | 角色 | 干什么 | 与你的关系 | 26 | 角色 | 干什么 | 与你的关系 |
20 |---|---|---| 27 |---|---|---|
21 -| `orggraph-planning` | 建公司时跑一次,规划**公司级** org graph 并招齐人 | 上一层,与你无关 |  
22 -| **你(`workgraph-planning`)** | 每个 org node 每次接到任务时跑一次,规划**该节点这一次**的 work graph | 干完交出 `workGraphId` 就结束 |  
23 -| 图上的节点 agent | 真正干活的人 | 你只**引用**它们,不代替它们干活 | 28 +| `orggraph-planning` | 建公司时跑一次,规划**公司级** org graph,并把 **org 层的节点**招齐 | 上一层。它招 org 节点,**你招你图里的 work 层本体**,两批人互不重叠 |
  29 +| **你(`workgraph-planning`)** | 每个 org node 每次接到任务时跑一次,规划**该节点这一次**的 work graph,**并雇齐图里引用的本体** | 雇完 + 提交图 + 交出 `workGraphId` 才算结束 |
  30 +| 图上的节点 agent | 真正干活的人 | 你**引用**它们、**把它们雇进公司**,但不代替它们干活 |
24 | `validator-triager` | 归因「这次失败该怪谁」 | 它是 `org-validator` work graph 里的**一个节点**,由你画进图里 | 31 | `validator-triager` | 归因「这次失败该怪谁」 | 它是 `org-validator` work graph 里的**一个节点**,由你画进图里 |
25 | 引擎(`graph-engine`) | 起子 run、按边推进、熔断、透传 | 你的调用方,也是你落库后的消费者 | 32 | 引擎(`graph-engine`) | 起子 run、按边推进、熔断、透传 | 你的调用方,也是你落库后的消费者 |
26 33
@@ -28,10 +35,20 @@ @@ -28,10 +35,20 @@
28 35
29 --- 36 ---
30 37
31 -## ⚡ 强制工作流(七步,必须在同一轮对话内连贯做完) 38 +## ⚡ 强制工作流(八步,必须在同一轮对话内连贯做完)
  39 +
  40 +> **🚨 绝不允许只画完图就停下来等指示。** 从 Step 1 到 Step 8 是一条不可中断的流水线。
  41 +> 人没雇齐、图没过校验、没落库、`workGraphId` 没交回去,任务就没完成。
32 42
33 -> **🚨 绝不允许只画完图就停下来等指示。** 从 Step 1 到 Step 7 是一条不可中断的流水线。  
34 -> 图没过校验、没落库、`workGraphId` 没交回去,任务就没完成。 43 +| Step | 干什么 |
  44 +|---|---|
  45 +| 1-4 | 读输入 → 读骨架 → 读本体清单 → 规划 |
  46 +| 5 | 落盘规划记录 |
  47 +| **6** | **执行招聘(雇齐图里引用的本体)** |
  48 +| **7** | **提交图** |
  49 +| 8 | 交回引擎 |
  50 +
  51 +**6 在 7 前面是写死的,不是建议。** 理由见 Step 6 开头那段红字。
35 52
36 ### Step 1 · 读输入(四项) 53 ### Step 1 · 读输入(四项)
37 54
@@ -72,12 +89,22 @@ curl -s http://localhost:3010/api/company/&lt;公司ID&gt;/agents @@ -72,12 +89,22 @@ curl -s http://localhost:3010/api/company/&lt;公司ID&gt;/agents
72 这是**唯一权威**的「哪些本体真的存在」清单。模板 JSON 里写的 `systemAgentId` 只是**建议**, 89 这是**唯一权威**的「哪些本体真的存在」清单。模板 JSON 里写的 `systemAgentId` 只是**建议**,
73 `{{implAgent}}` 这类占位符更是**必须替换**的空位。 90 `{{implAgent}}` 这类占位符更是**必须替换**的空位。
74 91
  92 +两份清单的分工要分清:
  93 +
  94 +- **`company templates` 是「能不能雇」的清单**——`system/templates/` 下有目录的本体,Step 6 一雇就有;
  95 +- **`GET /agents` 是「已经雇过没有」的清单**——已经在里面的,Step 6 那一次雇会返回 `already_exists`,
  96 + 这是**正常且预期**的(同一家公司做第二个任务时,多半半张图都已经雇过了)。
  97 + **不在里面不代表不能用**,只代表 Step 6 要真去雇它一次。
  98 +
75 **找不到理想本体时怎么办**(按优先级): 99 **找不到理想本体时怎么办**(按优先级):
76 100
77 -1. 用清单里语义最接近的现有本体,并在 Step 5 的规划记录里写明「用 X 代替了理想中的 Y」; 101 +1. 用 `company templates` 清单里语义最接近的现有本体,并在 Step 5 的规划记录里写明「用 X 代替了理想中的 Y」;
78 2. 换一个不需要这个本体的图形状(比如把 fan-out 改成串行); 102 2. 换一个不需要这个本体的图形状(比如把 fan-out 改成串行);
79 -3. **实在绕不过去**:照常把它写进图里、在规划记录里单列一节「本图依赖尚不存在的本体 Z」,交回引擎时一并汇报。  
80 - **你不负责创建 agent**——建 agent 是 `agent-creator` 的活,招人是 `orggraph-planning` / HR 的活。 103 +3. **实在绕不过去**:照常把它写进图里,Step 6 照样去雇它——模板库里没有的角色,
  104 + `hire` 会自动从 `system/templates/base/` 元 agent 继承出骨架(返回 `hired_from_base`),
  105 + 并**顺手把它写进 `system_agents` 表**,图才引用得动它。
  106 + 在规划记录里单列一节「本图依赖尚不存在的本体 Z,已走 base 继承分支」,交回引擎时一并汇报。
  107 + **你仍然不亲手写 agent 定义**——那是 `agent-creator` 的活,`hire` 会自动去触发它。
81 108
82 ### Step 4 · 规划 109 ### Step 4 · 规划
83 110
@@ -115,7 +142,7 @@ curl -s http://localhost:3010/api/company/&lt;公司ID&gt;/agents @@ -115,7 +142,7 @@ curl -s http://localhost:3010/api/company/&lt;公司ID&gt;/agents
115 mkdir -p workspace 142 mkdir -p workspace
116 ``` 143 ```
117 144
118 -文件至少包含六节: 145 +文件至少包含七节(第 7 节在 Step 6 招完人之后回填):
119 146
120 ```markdown 147 ```markdown
121 # {宿主 org node}(公司 {公司id} / 任务 {taskId})Work Graph 规划记录 148 # {宿主 org node}(公司 {公司id} / 任务 {taskId})Work Graph 规划记录
@@ -153,12 +180,115 @@ mkdir -p workspace @@ -153,12 +180,115 @@ mkdir -p workspace
153 | 3 | 全可达 | 从 a 出发 BFS 覆盖全部 3 个节点 | 180 | 3 | 全可达 | 从 a 出发 BFS 覆盖全部 3 个节点 |
154 | 4 | 回退边显式标注 | c → b 已标 edgeKind: backward | 181 | 4 | 回退边显式标注 | c → b 已标 edgeKind: backward |
155 | 5 | fail 兜底边 | c 有 fail 出边,其中 c → b 不带 failReason | 182 | 5 | fail 兜底边 | c 有 fail 出边,其中 c → b 不带 failReason |
  183 +
  184 +## 7. 招聘结果(Step 6 之后回填)
  185 +| 本体 | hire 返回 | 判读 | 后续动作 |
  186 +|---|---|---|---|
  187 +| coder | hired_from_template | ✅ | 无 |
  188 +| unit-tester | already_exists | ✅ 上次任务已雇 | 无 |
  189 +| xxx-checker | hired_from_base | ✅ 新造,已走 base 继承 | 记一笔,交回引擎时汇报 |
156 ``` 190 ```
157 191
158 **这一步不能跳。** 27-P3/P4 §八.8 写明:**图的规划质量没有硬校验**,五项只保证图**结构合法**, 192 **这一步不能跳。** 27-P3/P4 §八.8 写明:**图的规划质量没有硬校验**,五项只保证图**结构合法**,
159 保证不了它**适合这个任务**。这份 md 是人类事后发现图不合理时唯一的决策线索。 193 保证不了它**适合这个任务**。这份 md 是人类事后发现图不合理时唯一的决策线索。
160 194
161 -### Step 6 · 提交图 195 +### Step 6 · 执行招聘(雇齐图里引用的本体)
  196 +
  197 +> 🚨🚨 **顺序写死:先雇人,再提交图。反过来必挂,且是两处一起挂。**
  198 +>
  199 +> | 反了会发生什么 | 为什么 |
  200 +> |---|---|
  201 +> | 提交图时 400 `rule=reference` / 外键报错 | `work_graph_nodes.system_agent_id` 是**指向 `system_agents` 表的外键**。模板库里没有的本体(Step 3 情形 3)只有被 `hire` 雇过一次,才会有那行记录 |
  202 +> | 图提交成功了,节点跑起来还是 `Agent not found` | 引擎解析本体名的判据是 **`data/companies/<公司ID>/agents/<本体名>/` 这个目录在不在**:在 → 用作用域名 `<公司ID>:<本体名>` 寻址;不在 → 退回裸名,而裸名只在 `system/agents/` 下找得到,**你图里的本体一个都不在那儿** |
  203 +>
  204 +> **这两条都只有「雇过」才成立,而且都发生在你已经下班之后**——图提交完你就没有机会补救了。
  205 +> 所以:**Step 5 写完规划记录 → Step 6 一个不漏地雇完 → Step 7 才允许提交图。**
  206 +> 不许「先提交试试,挂了再回来雇」。
  207 +
  208 +**要雇的是哪些**:你图里 `nodes[].systemAgentId` 的**全集去重**(省略 `systemAgentId` 的节点取 `nodeName`)。
  209 +**不是** `nodeName`——节点名带宿主 org node 前缀,本体名不带(约束二),雇的是**本体名**。
  210 +
  211 +**逐个雇**(顺序建议按图的拓扑序,与执行顺序一致,便于排查;但顺序在这里不影响正确性):
  212 +
  213 +```bash
  214 +node bin/lemon.js company hire --id <公司ID> --role <本体名> --type executor --name "<中文岗位名>"
  215 +```
  216 +
  217 +| 参数 | 说明 |
  218 +|---|---|
  219 +| `--id` | 公司 ID,首轮消息会给 |
  220 +| `--role` | **本体名**(`systemAgentId`),一字不差,不是节点名。**必填** |
  221 +| `--type` | 一律 `executor`(work 节点是执行流上的节点,不是 cxo)。**省略时 CLI 默认 `cxo`,别省** |
  222 +| `--name` | 给人看的中文岗位名,省略时默认等于 `--role` |
  223 +| `--desc` | 可选,一句话职责。模板库里没有的新本体建议写上——它会被转给 `agent-creator` 当写定义的依据 |
  224 +
  225 +> **`--layer` 这个参数不存在,别自己发明**(`parseArgs` 是严格模式,多给一个未知选项会让整条命令直接报错)。
  226 +> work 层招聘会不会污染 org graph 由服务端判定,不是你的入参。
  227 +
  228 +这条命令等价于 `POST /api/company/<公司ID>/hire`,body `{"role":...,"type":...,"name":...}`。
  229 +**CLI 起不来时**才走 curl 降级(照铁律:先把单行紧凑 JSON 写进 `/tmp/hire-<本体名>.json`,再发):
  230 +
  231 +```bash
  232 +curl -s -X POST http://localhost:3010/api/company/<公司ID>/hire \
  233 + -H "Content-Type: application/json" -d @/tmp/hire-<本体名>.json
  234 +```
  235 +
  236 +成功返回体形如 `{"status":"hired_from_template","agentId":"coder","agentDir":"..."}`。
  237 +
  238 +#### 每条命令的返回都要读:四档判读
  239 +
  240 +| `status` | 含义 | 你该做什么 |
  241 +|---|---|---|
  242 +| `hired_from_template` | 模板库里有,走引用模式寻址(不拷贝 agent.md) | ✅ **继续下一个** |
  243 +| `already_exists` | 这家公司**之前的任务已经雇过它了** | ✅ **继续下一个**。这是幂等的正常结果,**不是错误**,不许因此停下、报警、或把节点从图里删掉 |
  244 +| `hired_from_base` | 模板库里没这个角色 → 自动从 `system/templates/base/` 元 agent 继承出四段式骨架,并触发 `agent-creator` 补定义 | ✅ **继续下一个**,但在规划记录第 7 节记一笔,Step 8 汇报时点名 |
  245 +| `training` | 连 base 元 agent 都不在 → 走了纯脚手架兜底 | ⚠️ **这一档不算成功**:脚手架**不写 `system_agents` 表**,图提交时这个本体会被 `rule=reference` 打回。按下面「换掉」处理 |
  246 +| 命令报错 / 输出里有 `❌ [CRITICAL ERROR]` | 见下表 | 见下表 |
  247 +
  248 +> ⚠️ **CLI 不会把 HTTP 错误还成 JSON。** 状态码 ≥400 时 `bin/lemon.js` 打印的是
  249 +> `❌ [CRITICAL ERROR]: API returned 400.\nDetails: {"error":"..."}` 加一段催你重试的话。
  250 +> **真正的原因在 `Details:` 后面那个 JSON 里**,先读它、再照下表定性;
  251 +> 那段催促语不是让你原样重发同一条命令。
  252 +
  253 +#### 出错分三级:重试 / 换掉 / 停
  254 +
  255 +| 现象 | 定性 | 动作 |
  256 +|---|---|---|
  257 +| 连不上 `localhost:3010`(`ECONNREFUSED` / 超时 / 空输出) | **瞬时故障** | **原地重试,最多 3 次**,每次之间 `sleep 2`。3 次仍不通 → 停(见最后一行) |
  258 +| `Details` 里是 `{"error":"公司 ... 不存在"}` | **参数错**,多半是公司 ID 抄错了 | 核对首轮消息里的公司 ID,**改对后重试一次**。仍报同样的错 → 停 |
  259 +| `Details` 里是 `{"error":"role 为必填项"}` | **命令写错了** | 补上 `--role` 重试一次 |
  260 +| 返回 `training`,或返回体里明说「模板不存在且无法继承」 | **这个本体供不出来** | **换掉**:回到 Step 4,用清单里语义最接近的本体替换该节点(或改图形状去掉它),**改完图要重跑 Step 5 的五项校验**,再回到 Step 6 把新本体雇一遍。同一个本体**最多换一次**,换完还不行就停 |
  261 +| 同一个本体重试 3 次仍失败 | 放弃它 | 同上「换掉」 |
  262 +| 上面的路都走完还是雇不齐 | **停** | **不许提交一张引用了没雇上本体的图**(提交了也会被打回,还会污染排障线索)。把「哪几个本体没雇上 + 每个的原始错误输出」写进规划记录第 7 节,直接跳到 Step 8 如实汇报,由引擎/人接手 |
  263 +
  264 +#### 幂等与重入
  265 +
  266 +- 你可能被**同一个 org node 反复调用**(带 `remediation_note` 重规划)。每一轮都**照常把全套本体雇一遍**——
  267 + 已经在的会返回 `already_exists`,不会报错、不会重复建目录、不会覆盖已有的 agent。
  268 + **不要为了"省一次调用"而跳过招聘**:上一轮雇过不代表这一轮的图引用的是同一批本体。
  269 +- 反过来也不要**先查再雇**:`GET /agents` 与 `hire` 之间存在时间差,以 `hire` 的返回为准最简单也最可靠。
  270 +- ⚠️ **`already_exists` 是提前返回,它不补 `system_agents` 那一行。** 所以有一种罕见但会把你卡死的组合:
  271 + 某个本体的目录在(雇过 → 恒返回 `already_exists`),`system_agents` 里却没有它的行(比如那目录是被人手工
  272 + `mkdir` 出来的)。表现是 **Step 7 提交时 `rule=reference` 一直指向它,而你再雇多少次都只会拿到
  273 + `already_exists`——重雇修不好这一条**。撞上就别在这里死循环:按分级表**换掉**该本体,
  274 + 并在规划记录第 7 节写明「目录在但表里没有,重雇无效」,Step 8 点名汇报。
  275 +
  276 +#### 招完必须核对一遍(别只信 API 的返回值)
  277 +
  278 +```bash
  279 +ls data/companies/<公司ID>/agents/
  280 +```
  281 +
  282 +**引擎判定本体在不在,用的就是这个目录**(不是 `company.json` 的 `agents` 列表,也不是 API 返回的 status)。
  283 +图里每一个 `systemAgentId` 都必须在这份 `ls` 的输出里出现,**一个都不能少**。少了就回到上面的分级表。
  284 +
  285 +> **招聘不违反「写入边界」约束。** 提示词里那条「禁止写引擎源码仓库下的任何路径」针对的是
  286 +> **你自己动手写文件**。`company hire` 是让**服务端**去建 `data/companies/<公司ID>/agents/`,
  287 +> 那是公司实例数据区、不在版本控制里,是招聘的**唯一正当落点**。
  288 +> 不许因为这条约束而跳过招聘,也不许自己动手去 `mkdir` / 拷贝目录来"代替"招聘——
  289 +> 手工建目录不会写 `system_agents` 表,图照样提交不上去。
  290 +
  291 +### Step 7 · 提交图
162 292
163 ``` 293 ```
164 POST /api/company/{公司ID}/work-graphs 294 POST /api/company/{公司ID}/work-graphs
@@ -204,7 +334,8 @@ curl -s -X POST http://localhost:3010/api/company/&lt;公司ID&gt;/work-graphs \ @@ -204,7 +334,8 @@ curl -s -X POST http://localhost:3010/api/company/&lt;公司ID&gt;/work-graphs \
204 - `edges[].edgeKind` 省略时默认 `forward`;`edges[].failReason` 省略即为**兜底边**。 334 - `edges[].edgeKind` 省略时默认 `forward`;`edges[].failReason` 省略即为**兜底边**。
205 335
206 **若该端点返回 404**(引擎侧路由尚未上线):不要重试、不要绕道去写数据库。 336 **若该端点返回 404**(引擎侧路由尚未上线):不要重试、不要绕道去写数据库。
207 -把同一份 JSON 原样写到 `workspace/workgraph-<taskId>.json`,在 Step 7 汇报这个路径,由引擎接手落库。 337 +把同一份 JSON 原样写到 `workspace/workgraph-<taskId>.json`,在 Step 8 汇报这个路径,由引擎接手落库。
  338 +**注意:走降级路径不豁免 Step 6** —— 图迟早要落库,人没雇齐它落库时照样外键挂,而那时你已经下班了。
208 339
209 #### ⚠️ 返回 400 怎么办:读懂 errors,自己修,重试 340 #### ⚠️ 返回 400 怎么办:读懂 errors,自己修,重试
210 341
@@ -218,7 +349,7 @@ curl -s -X POST http://localhost:3010/api/company/&lt;公司ID&gt;/work-graphs \ @@ -218,7 +349,7 @@ curl -s -X POST http://localhost:3010/api/company/&lt;公司ID&gt;/work-graphs \
218 | `backward-labeled` | 指向拓扑序更早节点的边没标 `backward`(或反过来,没构成回路的边标了 `backward`) | 按 `detail` 加上/去掉 `"edgeKind": "backward"` | 349 | `backward-labeled` | 指向拓扑序更早节点的边没标 `backward`(或反过来,没构成回路的边标了 `backward`) | 按 `detail` 加上/去掉 `"edgeKind": "backward"` |
219 | **`fail-fallback-edge`** | **某节点有 `fail` 出边,却没有一条不带 `failReason` 的兜底边** | **给它补一条兜底边**,见约束三 | 350 | **`fail-fallback-edge`** | **某节点有 `fail` 出边,却没有一条不带 `failReason` 的兜底边** | **给它补一条兜底边**,见约束三 |
220 | `schema` | 字段缺失 / 取值非法 / `nodeName` 重复 | 按 `detail` 逐条改 | 351 | `schema` | 字段缺失 / 取值非法 / `nodeName` 重复 | 按 `detail` 逐条改 |
221 -| `reference` | 边引用了 `nodes` 里不存在的节点名 | 拼写错了,或忘了把这个节点加进 `nodes`;**也可能是占位符没替换干净** | 352 +| `reference` | 边引用了 `nodes` 里不存在的节点名,**或某个 `systemAgentId` 在 `system_agents` 表里没有对应行** | 拼写错了,或忘了把这个节点加进 `nodes`;**也可能是占位符没替换干净**;**还可能是 Step 6 没把这个本体雇上**——回 Step 6 重雇一次,雇不上就按那里的分级表换掉该本体。**重雇只拿到 `already_exists` 却还是这条 rule** → 见 Step 6「幂等与重入」最后一条,重雇修不好,直接换掉 |
222 353
223 **🚨 铁律**: 354 **🚨 铁律**:
224 355
@@ -228,7 +359,7 @@ curl -s -X POST http://localhost:3010/api/company/&lt;公司ID&gt;/work-graphs \ @@ -228,7 +359,7 @@ curl -s -X POST http://localhost:3010/api/company/&lt;公司ID&gt;/work-graphs \
228 4. 每次重试前,**先在 `workspace/workgraph-plan-<taskId>.md` 追加一行**:撞了哪条规则、怎么改的。 359 4. 每次重试前,**先在 `workspace/workgraph-plan-<taskId>.md` 追加一行**:撞了哪条规则、怎么改的。
229 5. 连续 5 次都过不了,停下来把最后一次的 `errors` 原文汇报给引擎,不要无限重试。 360 5. 连续 5 次都过不了,停下来把最后一次的 `errors` 原文汇报给引擎,不要无限重试。
230 361
231 -### Step 7 · 交回引擎 362 +### Step 8 · 交回引擎
232 363
233 **一句话结构化汇报**,必须包含: 364 **一句话结构化汇报**,必须包含:
234 365
@@ -236,6 +367,7 @@ curl -s -X POST http://localhost:3010/api/company/&lt;公司ID&gt;/work-graphs \ @@ -236,6 +367,7 @@ curl -s -X POST http://localhost:3010/api/company/&lt;公司ID&gt;/work-graphs \
236 - 图的拓扑序(入口 → … → 末节点); 367 - 图的拓扑序(入口 → … → 末节点);
237 - 节点数 / 边数,其中 backward 边几条; 368 - 节点数 / 边数,其中 backward 边几条;
238 - 引用了哪些本体,其中哪些**清单里不存在**(Step 3 情形 3); 369 - 引用了哪些本体,其中哪些**清单里不存在**(Step 3 情形 3);
  370 +- **招聘结果**:雇了几个,各是什么 `status`,其中几个走了 `hired_from_base`,**有没有没雇上的**(有就逐个点名 + 原始错误);
239 - 规划记录路径 `workspace/workgraph-plan-<taskId>.md`。 371 - 规划记录路径 `workspace/workgraph-plan-<taskId>.md`。
240 372
241 **此后你不再工作。** 图交出去就归引擎执行,你不推进节点、不看结果、不改图。 373 **此后你不再工作。** 图交出去就归引擎执行,你不推进节点、不看结果、不改图。
@@ -409,8 +541,14 @@ agent-creator digester evolver evolver-youtube-crawler hunter innovato @@ -409,8 +541,14 @@ agent-creator digester evolver evolver-youtube-crawler hunter innovato
409 6. 我有没有在某个节点里写了「先 research 再 plan 再 execute 再 verify」? 541 6. 我有没有在某个节点里写了「先 research 再 plan 再 execute 再 verify」?
410 有的话立刻拆开或删掉——两层 Context Flush 会互相清对方的上下文。 542 有的话立刻拆开或删掉——两层 Context Flush 会互相清对方的上下文。
411 7. 一个节点是不是塞了两件事?每个节点我都能用一句话说清「做完什么算成功」吗? 543 7. 一个节点是不是塞了两件事?每个节点我都能用一句话说清「做完什么算成功」吗?
412 -8. workspace/workgraph-plan-<taskId>.md 写了吗?六节写全了吗,还是只贴了张图?  
413 -9. POST 回来 400 了吗?我读懂 errors[].rule 是哪一条了吗?我是**改了图**再重试,还是原样重发?  
414 -10. workGraphId 交回引擎了吗?我是不是画完图就停在这儿了? 544 +8. workspace/workgraph-plan-<taskId>.md 写了吗?七节写全了吗,还是只贴了张图?
  545 +9. **【提交前最后一道闸】我把图里每一个 systemAgentId 都 hire 过一遍了吗?**
  546 + 我是**先雇完再提交**的,还是"先提交试试"?——反了必挂,而且挂在我下班之后。
  547 + `ls data/companies/<公司ID>/agents/` 我真跑了吗?图里的本体在输出里**一个不少**吗?
  548 + 有 already_exists 吗?——那是正常的幂等结果,我没有把它当错误停下来吧?
  549 + 有没雇上的吗?——我是按分级表换掉了它,还是明知没雇上还硬提交?
  550 +10. POST 回来 400 了吗?我读懂 errors[].rule 是哪一条了吗?我是**改了图**再重试,还是原样重发?
  551 + 如果是 rule=reference,我确认过不是"本体没雇上"造成的吗?
  552 +11. workGraphId 交回引擎了吗?招聘结果汇报了吗?我是不是画完图就停在这儿了?
415 </thought> 553 </thought>
416 ``` 554 ```
system/templates/workgraph-planning/identity.md
@@ -18,5 +18,10 @@ agent 池因此不随 org node 数量膨胀,这是 P1 虚拟引用寻址在规 @@ -18,5 +18,10 @@ agent 池因此不随 org node 数量膨胀,这是 P1 虚拟引用寻址在规
18 二是宿主一旦是 `org-validator`,他必定把归因节点 `validator-triager` 摆在第一位—— 18 二是宿主一旦是 `org-validator`,他必定把归因节点 `validator-triager` 摆在第一位——
19 少了它,上层失败之后连该怪谁都不知道,那张图跑得再顺也没有意义。 19 少了它,上层失败之后连该怪谁都不知道,那张图跑得再顺也没有意义。
20 20
  21 +还有一条是 R-18.2 之后新长出来的规矩:**图里引用到的本体,由他自己雇进本公司,雇完才准提交图。**
  22 +这一层从前没有执行者——org 层的人建公司时就招齐了,而他图里引用的本体是任务来了才知道的,
  23 +没人能提前预知,于是节点跑起来必然「找不到人」。他不接受"先提交试试,挂了再回来补":
  24 +图一旦落库他就下班了,那时候补不了。
  25 +
21 图交出去他就下班。执行是引擎的事,他不推进节点、不看结果、也不回头改图。 26 图交出去他就下班。执行是引擎的事,他不推进节点、不看结果、也不回头改图。
22 下一轮真被打回来,那也是一次全新的规划,不是补丁。 27 下一轮真被打回来,那也是一次全新的规划,不是补丁。
system/templates/workgraph-planning/tools.md
1 # WorkGraph Planning 工具箱 1 # WorkGraph Planning 工具箱
2 2
3 -你的全部工作靠四类工具完成:**读骨架 → 读本体清单 → 落盘规划记录 → 提交图**。  
4 -按 `agent.md` 的七步工作流顺序使用。你**不招人、不建 agent、不执行图**。 3 +你的全部工作靠五类工具完成:**读骨架 → 读本体清单 → 落盘规划记录 → 招聘 → 提交图**。
  4 +按 `agent.md` 的八步工作流顺序使用。你**不执行图、不辞人、不改 org graph**。
  5 +
  6 +> 🚨 **招聘(§5)必须排在提交图(§6)前面。** 反了会外键挂 + 节点跑起来找不到人,详见 `agent.md` Step 6。
5 7
6 > 🚨 **发 JSON 的铁律(先读这条,否则你会陷入死循环)** 8 > 🚨 **发 JSON 的铁律(先读这条,否则你会陷入死循环)**
7 > 本环境的工具调用在解析带多重双引号的 bash 命令时会崩溃,导致你反复"修格式"直到进程被杀。 9 > 本环境的工具调用在解析带多重双引号的 bash 命令时会崩溃,导致你反复"修格式"直到进程被杀。
@@ -35,11 +37,13 @@ node bin/lemon.js company templates @@ -35,11 +37,13 @@ node bin/lemon.js company templates
35 curl -s http://localhost:3010/api/company/<公司ID>/agents 37 curl -s http://localhost:3010/api/company/<公司ID>/agents
36 ``` 38 ```
37 39
38 -前者是全局可用的 agent 模板(`• <id> — <description>` 每行一个),后者是这家公司已有的 agent。  
39 -这是**唯一权威**的「哪些本体真的存在」清单——骨架 JSON 里写的 `systemAgentId` 只是建议,  
40 -`{{implAgent}}` 这类占位符更是**必须替换**的空位。 40 +前者是**「能雇到哪些」**(全局 agent 模板,`• <id> — <description>` 每行一个),
  41 +后者是**「这家公司已经雇了哪些」**。两份都要读,但别混:
  42 +**不在后者里 ≠ 不能用**,只意味着 §5 要真去雇它一次。
  43 +骨架 JSON 里写的 `systemAgentId` 只是建议,`{{implAgent}}` 这类占位符更是**必须替换**的空位。
41 44
42 -找不到理想本体的三条出路见 `agent.md` Step 3。**你不创建 agent**,那是 `agent-creator` 的活。 45 +找不到理想本体的三条出路见 `agent.md` Step 3。**你不亲手写 agent 定义**,那是 `agent-creator` 的活;
  46 +但**把本体雇进公司是你的活**(§5)。
43 47
44 ## 3. 读上游留下的共享产物(Step 1) 48 ## 3. 读上游留下的共享产物(Step 1)
45 49
@@ -63,10 +67,53 @@ ls -R data/companies/&lt;公司ID&gt;/runs/&lt;rootRunId&gt;/artifacts/ @@ -63,10 +67,53 @@ ls -R data/companies/&lt;公司ID&gt;/runs/&lt;rootRunId&gt;/artifacts/
63 mkdir -p workspace 67 mkdir -p workspace
64 ``` 68 ```
65 69
66 -然后用文件写入工具写 `workspace/workgraph-plan-<taskId>.md`,六节结构见 `agent.md` Step 5。 70 +然后用文件写入工具写 `workspace/workgraph-plan-<taskId>.md`,七节结构见 `agent.md` Step 5
  71 +(第 7 节「招聘结果」在 §5 招完人之后回填)。
67 **图 + 理由 + 五项校验自查**,三样缺一不可——这是人类事后复核你判断力的唯一线索。 72 **图 + 理由 + 五项校验自查**,三样缺一不可——这是人类事后复核你判断力的唯一线索。
68 73
69 -## 5. 提交图(Step 6) 74 +## 5. 招聘(Step 6)—— **必须在提交图之前跑完**
  75 +
  76 +对图里 `nodes[].systemAgentId` 的**全集去重**,逐个执行:
  77 +
  78 +```bash
  79 +node bin/lemon.js company hire --id <公司ID> --role <本体名> --type executor --name "<中文岗位名>"
  80 +```
  81 +
  82 +| 参数 | 说明 |
  83 +|---|---|
  84 +| `--id` | 公司 ID,首轮消息会给 |
  85 +| `--role` | **本体名**(`systemAgentId`),不是节点名。必填 |
  86 +| `--type` | 一律 `executor`(work 节点是执行流上的节点,不是 cxo)。省略时 CLI 默认 `cxo`,**别省** |
  87 +| `--name` | 中文岗位名,省略时默认等于 `--role` |
  88 +| `--desc` | 可选,一句话职责。新造的本体(模板库里没有)建议写上——它会被转给 `agent-creator` 当写定义的依据 |
  89 +
  90 +这条命令等价于 `POST /api/company/<公司ID>/hire`,body `{"role":...,"type":...,"name":...}`。
  91 +CLI 起不来时可以走 curl 降级(**照铁律先写文件**,`/tmp/hire-<本体名>.json` 单行紧凑):
  92 +
  93 +```bash
  94 +curl -s -X POST http://localhost:3010/api/company/<公司ID>/hire \
  95 + -H "Content-Type: application/json" -d @/tmp/hire-<本体名>.json
  96 +```
  97 +
  98 +返回体形如 `{"status":"hired_from_template","agentId":"coder","agentDir":"..."}`。
  99 +**`status` 的四档判读、出错的三级处理(重试 / 换掉 / 停)、以及幂等规则,全在 `agent.md` Step 6**,
  100 +不要凭感觉处理——尤其 `already_exists` 是**正常结果**,不是错误。
  101 +
  102 +> ⚠️ **CLI 把 HTTP 4xx/5xx 包成一段文本,不是 JSON。** `bin/lemon.js` 在状态码 ≥400 时打印的是
  103 +> `❌ [CRITICAL ERROR]: API returned 400.\nDetails: {"error":"role 为必填项"}` 这种字符串
  104 +> (还附一段催你重试的话)。**照 `agent.md` Step 6 的分级表处理,别被那段催促语带着无脑重发同一条命令**——
  105 +> 真正的原因在 `Details:` 后面那个 JSON 里,先读它。
  106 +
  107 +**`--layer` 这个参数不存在,别自己发明。** work 层招聘该不该写进 org graph 由服务端判定
  108 +(27-P3B §四 W7),不是你的入参;你只管把人雇进来。
  109 +
  110 +招完核对(引擎判定本体在不在,用的就是这个目录):
  111 +
  112 +```bash
  113 +ls data/companies/<公司ID>/agents/
  114 +```
  115 +
  116 +## 6. 提交图(Step 7)
70 117
71 **先写文件**(用文件写入工具写 `/tmp/workgraph-<taskId>.json`,单行紧凑 JSON): 118 **先写文件**(用文件写入工具写 `/tmp/workgraph-<taskId>.json`,单行紧凑 JSON):
72 119
@@ -103,14 +150,15 @@ curl -s -X POST http://localhost:3010/api/company/&lt;公司ID&gt;/work-graphs \ @@ -103,14 +150,15 @@ curl -s -X POST http://localhost:3010/api/company/&lt;公司ID&gt;/work-graphs \
103 | `edges[].failReason` | 仅 `fail` 边可带;**省略即为兜底边** | 150 | `edges[].failReason` | 仅 `fail` 边可带;**省略即为兜底边** |
104 | `edges[].edgeKind` | `"forward"`(默认可省略)\| `"backward"` | 151 | `edges[].edgeKind` | `"forward"`(默认可省略)\| `"backward"` |
105 152
106 -**400 的处理见 `agent.md` Step 6 的规则对照表**:`errors[].rule` 取值为 153 +**400 的处理见 `agent.md` Step 7 的规则对照表**:`errors[].rule` 取值为
107 `acyclic` / `single-entry` / `reachable` / `backward-labeled` / **`fail-fallback-edge`** / `schema` / `reference`。 154 `acyclic` / `single-entry` / `reachable` / `backward-labeled` / **`fail-fallback-edge`** / `schema` / `reference`。
108 读懂 → 改图 → 重试,最多 5 轮。 155 读懂 → 改图 → 重试,最多 5 轮。
109 156
110 **若端点返回 404**(引擎侧路由尚未上线):不要重试、不要绕道写数据库。 157 **若端点返回 404**(引擎侧路由尚未上线):不要重试、不要绕道写数据库。
111 -把同一份 JSON 原样写到 `workspace/workgraph-<taskId>.json`,在 Step 7 汇报这个路径。 158 +把同一份 JSON 原样写到 `workspace/workgraph-<taskId>.json`,在 Step 8 汇报这个路径。
  159 +**走降级路径不豁免 §5 的招聘**——图迟早要落库,人没雇齐它落库时照样外键挂。
112 160
113 -## 6. 提交前自查五项校验(可选但强烈建议,省一轮 400) 161 +## 7. 提交前自查五项校验(可选但强烈建议,省一轮 400)
114 162
115 引擎用的就是这个函数,你可以先在本地跑一遍: 163 引擎用的就是这个函数,你可以先在本地跑一遍:
116 164
@@ -128,10 +176,15 @@ console.log(JSON.stringify(validateWorkGraph(t.nodes,t.edges),null,1));&quot; @@ -128,10 +176,15 @@ console.log(JSON.stringify(validateWorkGraph(t.nodes,t.edges),null,1));&quot;
128 176
129 ## 你**不该**用的工具 177 ## 你**不该**用的工具
130 178
  179 +> ⚠️ **`company hire` 已经不在这张表里了**(R-18.2 定案)。以前这里写着「你不招人也不辞人」,
  180 +> 那条**已作废**:招人现在是你的活(§5),只有**辞人**还不是。看到旧提示词里的老话以本节为准。
  181 +
131 | 别用 | 为什么 | 182 | 别用 | 为什么 |
132 |---|---| 183 |---|---|
133 | `POST/PATCH /api/company/:id/org-graph` | org graph 是上一层,归 `orggraph-planning` / HR。你只画 work graph | 184 | `POST/PATCH /api/company/:id/org-graph` | org graph 是上一层,归 `orggraph-planning` / HR。你只画 work graph |
134 -| `node bin/lemon.js company hire` / `fire` | 你不招人也不辞人。图上的节点是**引用**已有本体,不是新建员工 | 185 +| `node bin/lemon.js company fire` | 你只招不辞。图用完即归档,但雇进来的本体留给后续任务复用(下次 `hire` 会返回 `already_exists`) |
  186 +| `node bin/lemon.js template create` / `template train` | 造本体、训本体是 `agent-creator` / `innovator` 的活。你只 `hire`,模板库里没有的角色 `hire` 会自动去继承+触发它们 |
  187 +| 手工 `mkdir` / `cp` 出 `data/companies/<公司ID>/agents/<本体名>/` | 看着像招聘,其实**不写 `system_agents` 表**,图提交时照样 `rule=reference` 被打回。只能走 `hire` |
135 | 直接写 `data/` 下的 sqlite / json | 绕过五项校验就是把非法图塞进系统 | 188 | 直接写 `data/` 下的 sqlite / json | 绕过五项校验就是把非法图塞进系统 |
136 | `POST /api/graph-runs/:id/{start,next,...}` | 执行是引擎的活。你交出 `workGraphId` 就下班 | 189 | `POST /api/graph-runs/:id/{start,next,...}` | 执行是引擎的活。你交出 `workGraphId` 就下班 |
137 | 对已落库的 work graph 做任何修改 | work graph 生命周期是**一次性**的,要改就重新规划一张,没有增量补丁 | 190 | 对已落库的 work graph 做任何修改 | work graph 生命周期是**一次性**的,要改就重新规划一张,没有增量补丁 |
web/src/components/graph-console/ReplanOrgGraphButton.vue 0 → 100644
  1 +<template>
  2 + <div class="replan" :class="[variant, phase]">
  3 + <button class="rp-btn" :disabled="busy" @click="onClick">
  4 + <template v-if="phase === 'posting'">⏳ 正在触发…</template>
  5 + <template v-else-if="phase === 'planning'">⏳ 规划中 {{ mmss }}</template>
  6 + <template v-else>🧭 {{ hasGraph ? '重新规划组织' : '规划组织结构' }}</template>
  7 + </button>
  8 +
  9 + <!-- 过渡态的说明文字:fire-and-forget 的调用点完没反应最容易被当成没点上 -->
  10 + <p v-if="note" class="rp-note" :class="noteKind">
  11 + {{ note }}
  12 + <button v-if="phase === 'timeout'" class="rp-link" @click="waitMore">继续等</button>
  13 + </p>
  14 + </div>
  15 +</template>
  16 +
  17 +<script setup>
  18 +import { ref, computed, onBeforeUnmount } from 'vue';
  19 +
  20 +/**
  21 + * 「重新规划组织」入口(R-18.1 / 27-P3B §四 W8)。
  22 + *
  23 + * 用户原话:「人工的操作不应该是跑脚本,而是我点击 org graph planning,让他来真实运行,
  24 + * 并且 plan 出来需要雇佣哪几个 agent 到 company」。后端 `POST /:companyId/org-graph/replan`
  25 + * 从 P2 起就在(company.js),但在此之前**全仓库没有任何前端调用方**,只能 curl。
  26 + *
  27 + * 三件事这个组件全包了,因为两个页面(GraphConsole 左栏、CompanyDashboard 组织流程块)
  28 + * 要的是同一套行为,只是大小不同:
  29 + * 1. 触发:POST replan(服务端 hire orggraph-planning + fire-and-forget 起一轮对话)
  30 + * 2. 过渡态:按钮转「规划中 mm:ss」并禁用 —— agent 一轮是分钟级的,没有过渡态用户会连点
  31 + * 3. 刷新:轮询 GET org-graph,图变了就 emit('planned'),由父组件重载自己的视图
  32 + *
  33 + * **为什么是轮询不是 SSE**:replan 端点内部起的那条 agent 对话 SSE 由服务端自己消费掉了
  34 + * (company.js triggerOrgGraphPlanning 的 fetch),浏览器手里没有那条流的把手,端点也不回
  35 + * conversationId。控制台现成的 SSE(/graph-runs/:id/stream)是 graph run 的事件轨,org graph
  36 + * 规划不产生 graph run。所以能观测到「规划完成」的唯一信号就是 org graph 本身变了。
  37 + */
  38 +const props = defineProps({
  39 + companyId: { type: String, required: true },
  40 + /** 已有图时按钮文案改成「重新规划」,并在点击前确认一次:writeGraph 是整图覆盖 */
  41 + hasGraph: { type: Boolean, default: false },
  42 + /** primary = 空态里的大按钮;inline = 已有图时挂在标题栏的小按钮 */
  43 + variant: { type: String, default: 'inline' },
  44 +});
  45 +const emit = defineEmits(['planned']);
  46 +
  47 +const POLL_MS = 4000;
  48 +const WAIT_MS = 6 * 60 * 1000; // 一轮 orggraph-planning 的墙钟量级是分钟;超时不代表失败,只代表别再干等
  49 +
  50 +const phase = ref('idle'); // idle | posting | planning | done | timeout | error
  51 +const note = ref('');
  52 +const startedAt = ref(0);
  53 +const now = ref(Date.now());
  54 +
  55 +let poller = null;
  56 +let ticker = null;
  57 +let baseline = '';
  58 +
  59 +const busy = computed(() => phase.value === 'posting' || phase.value === 'planning');
  60 +const noteKind = computed(() => (phase.value === 'error' ? 'bad' : phase.value === 'done' ? 'ok' : ''));
  61 +const mmss = computed(() => {
  62 + const s = Math.max(0, Math.floor((now.value - startedAt.value) / 1000));
  63 + return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
  64 +});
  65 +
  66 +onBeforeUnmount(stopTimers);
  67 +
  68 +function stopTimers() {
  69 + clearInterval(poller); poller = null;
  70 + clearInterval(ticker); ticker = null;
  71 +}
  72 +
  73 +/**
  74 + * 图的指纹。节点 id 也进指纹:writeGraph 是「旧行整体置 removed + 插新行」,
  75 + * 即便重新规划出的形状与原来一模一样,节点 id 也会全变——只比形状会把「重规划出同一张图」
  76 + * 误判成超时。
  77 + */
  78 +async function fetchSig() {
  79 + const r = await fetch(`/api/company/${props.companyId}/org-graph`);
  80 + if (!r.ok) throw new Error(`org-graph HTTP ${r.status}`);
  81 + const g = await r.json();
  82 + return JSON.stringify([
  83 + (g.nodes || []).map((n) => [n.id, n.roleName, n.systemAgentId]),
  84 + (g.edges || []).map((e) => [e.from, e.to, e.condition, e.failReason ?? null, e.edgeKind]),
  85 + ]);
  86 +}
  87 +
  88 +async function onClick() {
  89 + if (busy.value) return;
  90 + if (props.hasGraph && !window.confirm(
  91 + '重新规划会让 orggraph-planning 重画整张 org graph,并覆盖现有的节点与边(旧图作废)。继续?'
  92 + )) return;
  93 +
  94 + phase.value = 'posting';
  95 + note.value = '';
  96 + try {
  97 + // 先取基线,再 POST:反过来的话规划快到抢在取基线之前完成,就永远比不出变化
  98 + baseline = await fetchSig().catch(() => '');
  99 + const r = await fetch(`/api/company/${props.companyId}/org-graph/replan`, { method: 'POST' });
  100 + if (!r.ok) {
  101 + const j = await r.json().catch(() => ({}));
  102 + throw new Error(j.error || `HTTP ${r.status}`);
  103 + }
  104 + } catch (e) {
  105 + phase.value = 'error';
  106 + note.value = `触发失败:${e.message}`;
  107 + return;
  108 + }
  109 + startWaiting();
  110 +}
  111 +
  112 +function startWaiting() {
  113 + phase.value = 'planning';
  114 + startedAt.value = Date.now();
  115 + now.value = Date.now();
  116 + note.value = 'orggraph-planning 已启动:读公司目标 → 选骨架 → 落图 → 按拓扑序招人。这一轮是分钟级的,图出现后本页会自动刷新。';
  117 + stopTimers();
  118 + ticker = setInterval(() => { now.value = Date.now(); }, 1000);
  119 + poller = setInterval(poll, POLL_MS);
  120 +}
  121 +
  122 +/** 超时不是失败判定,只是停止干等;再点一下继续轮询(基线不重置,之前的变化照样算数) */
  123 +function waitMore() { startWaiting(); }
  124 +
  125 +async function poll() {
  126 + let sig;
  127 + try {
  128 + sig = await fetchSig();
  129 + } catch {
  130 + return; // 单次轮询失败(后端重启等)不打断,等下一拍
  131 + }
  132 + if (sig !== baseline) {
  133 + stopTimers();
  134 + phase.value = 'done';
  135 + note.value = '✅ 组织流程已生成,已刷新。';
  136 + emit('planned');
  137 + return;
  138 + }
  139 + if (Date.now() - startedAt.value > WAIT_MS) {
  140 + stopTimers();
  141 + phase.value = 'timeout';
  142 + note.value = '等了 6 分钟还没看到新图。规划可能仍在进行,也可能失败了(同一 agent 不允许并发会话,上一轮没结束时这次触发会被拒)。去「团队成员 → orggraph-planning」的对话里看它跑到哪一步了。';
  143 + }
  144 +}
  145 +</script>
  146 +
  147 +<style scoped>
  148 +.replan { display: inline-flex; flex-direction: column; gap: 6px; min-width: 0; }
  149 +.replan.primary { display: flex; align-items: center; }
  150 +
  151 +.rp-btn {
  152 + cursor: pointer; border-radius: 6px; border: 1px solid #2b6cb0;
  153 + background: #2b6cb0; color: #fff; font-size: 12px; padding: 5px 12px;
  154 + white-space: nowrap;
  155 +}
  156 +.rp-btn:disabled { cursor: default; opacity: .6; }
  157 +.replan.inline .rp-btn { background: #fff; color: #2b6cb0; padding: 3px 9px; font-size: 11px; }
  158 +
  159 +.rp-note { margin: 0; font-size: 11px; line-height: 1.7; color: #777; max-width: 420px; }
  160 +.rp-note.ok { color: #2f855a; }
  161 +.rp-note.bad { color: #c53030; }
  162 +.rp-link {
  163 + border: none; background: none; padding: 0 0 0 4px; cursor: pointer;
  164 + color: #2b6cb0; font-size: 11px; text-decoration: underline;
  165 +}
  166 +</style>
web/src/views/CompanyDashboard.vue
@@ -226,14 +226,26 @@ @@ -226,14 +226,26 @@
226 <div class="org-graph-header"> 226 <div class="org-graph-header">
227 <h4>🕸️ 组织流程(Org Graph)</h4> 227 <h4>🕸️ 组织流程(Org Graph)</h4>
228 <span class="org-graph-hint">任务在这些角色之间的流转顺序</span> 228 <span class="org-graph-hint">任务在这些角色之间的流转顺序</span>
  229 + <!-- 有图时重新规划是低频且破坏性的(整图覆盖),收成标题栏小按钮;空态另有大按钮(R-18.1 / W8) -->
  230 + <ReplanOrgGraphButton
  231 + v-if="orgGraphChain.length > 0"
  232 + :company-id="selectedCompany.id" has-graph variant="inline"
  233 + @planned="loadOrgGraph(selectedCompany.id)" />
229 <button class="btn-sm" @click="loadOrgGraph(selectedCompany.id)">🔄 刷新</button> 234 <button class="btn-sm" @click="loadOrgGraph(selectedCompany.id)">🔄 刷新</button>
230 </div> 235 </div>
231 236
232 <div v-if="orgGraphLoading" class="empty-mini">⏳ 加载中...</div> 237 <div v-if="orgGraphLoading" class="empty-mini">⏳ 加载中...</div>
233 - <!-- 降级态:存量公司 / orggraph-planning 尚未跑完 / 端点不可用,统一显示空态 --> 238 + <!-- 降级态:存量公司 / orggraph-planning 尚未跑完 / 端点不可用,统一显示空态。
  239 + 这里正是用户新建公司后最先看到的地方,所以入口必须在这一屏(R-18.1:
  240 + 「人工的操作不应该是跑脚本,而是我点击 org graph planning」)。 -->
234 <div v-else-if="orgGraphChain.length === 0" class="empty-mini"> 241 <div v-else-if="orgGraphChain.length === 0" class="empty-mini">
235 暂无组织流程<br /> 242 暂无组织流程<br />
236 <span style="font-size:12px; opacity:0.7;">该公司还没有生成 workflow(存量公司或规划仍在进行中)</span> 243 <span style="font-size:12px; opacity:0.7;">该公司还没有生成 workflow(存量公司或规划仍在进行中)</span>
  244 + <div class="org-empty-action">
  245 + <ReplanOrgGraphButton
  246 + :company-id="selectedCompany.id" variant="primary"
  247 + @planned="loadOrgGraph(selectedCompany.id)" />
  248 + </div>
237 </div> 249 </div>
238 <template v-else> 250 <template v-else>
239 <div v-if="orgGraphNoEdges" class="org-graph-warn"> 251 <div v-if="orgGraphNoEdges" class="org-graph-warn">
@@ -823,6 +835,7 @@ import { useRouter, useRoute } from &#39;vue-router&#39; @@ -823,6 +835,7 @@ import { useRouter, useRoute } from &#39;vue-router&#39;
823 import axios from 'axios' 835 import axios from 'axios'
824 import AppNav from '@/components/AppNav.vue' 836 import AppNav from '@/components/AppNav.vue'
825 import CompanyFileNode from '@/components/CompanyFileNode.vue' 837 import CompanyFileNode from '@/components/CompanyFileNode.vue'
  838 +import ReplanOrgGraphButton from '@/components/graph-console/ReplanOrgGraphButton.vue'
826 import { useCompanies } from '@/composables/useCompanies' 839 import { useCompanies } from '@/composables/useCompanies'
827 840
828 const router = useRouter() 841 const router = useRouter()
@@ -1716,6 +1729,7 @@ function closeDropdown() { @@ -1716,6 +1729,7 @@ function closeDropdown() {
1716 background: var(--bg-surface); border: 1px solid var(--border-primary); 1729 background: var(--bg-surface); border: 1px solid var(--border-primary);
1717 border-radius: 12px; padding: 20px; margin-bottom: 8px; 1730 border-radius: 12px; padding: 20px; margin-bottom: 8px;
1718 } 1731 }
  1732 +.org-empty-action { display: flex; justify-content: center; margin-top: 14px; }
1719 .org-graph-header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; } 1733 .org-graph-header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
1720 .org-graph-header h4 { font-size: 15px; font-weight: 700; } 1734 .org-graph-header h4 { font-size: 15px; font-weight: 700; }
1721 .org-graph-hint { font-size: 12px; color: var(--text-muted); margin-right: auto; } 1735 .org-graph-hint { font-size: 12px; color: var(--text-muted); margin-right: auto; }
web/src/views/GraphConsole.vue
@@ -34,6 +34,11 @@ @@ -34,6 +34,11 @@
34 <div class="cols"> 34 <div class="cols">
35 <!-- 左栏:org graph。与中栏是同一个画布组件的两个实例(§四.B.1 / R-15.4 的前端收益) --> 35 <!-- 左栏:org graph。与中栏是同一个画布组件的两个实例(§四.B.1 / R-15.4 的前端收益) -->
36 <aside class="col left"> 36 <aside class="col left">
  37 + <!-- 有图时「重新规划组织」是低频操作,收在标题栏上方即可;空态另有大按钮(R-18.1 / W8) -->
  38 + <div v-if="hasOrgGraph || rootRun" class="left-actions">
  39 + <ReplanOrgGraphButton :company-id="companyId" has-graph variant="inline" @planned="onReplanned" />
  40 + </div>
  41 +
37 <GraphRunPane 42 <GraphRunPane
38 v-if="rootRun" 43 v-if="rootRun"
39 :run-id="rootRun.id" 44 :run-id="rootRun.id"
@@ -42,13 +47,23 @@ @@ -42,13 +47,23 @@
42 @select="onOrgSelect" 47 @select="onOrgSelect"
43 @state="onRootState" 48 @state="onRootState"
44 /> 49 />
45 - <template v-else> 50 + <template v-else-if="hasOrgGraph">
46 <header class="pane-head"><span class="ttl">Org Graph</span><span class="tag">定义(未执行)</span></header> 51 <header class="pane-head"><span class="ttl">Org Graph</span><span class="tag">定义(未执行)</span></header>
47 <p class="hint">这家公司还没有 graph run,下面是 org graph 定义本身,节点状态一律 pending。</p> 52 <p class="hint">这家公司还没有 graph run,下面是 org graph 定义本身,节点状态一律 pending。</p>
48 - <WorkGraphCanvas :nodes="defNodes" :edges="defEdges" @select="onOrgSelect">  
49 - <template #empty>这家公司还没有 org graph,先跑 orggraph-planning。</template>  
50 - </WorkGraphCanvas> 53 + <WorkGraphCanvas :nodes="defNodes" :edges="defEdges" @select="onOrgSelect" />
51 </template> 54 </template>
  55 +
  56 + <!-- 空态:整个控制台在这种公司上什么都做不了(没有 org graph 就没有 org node 可跑),
  57 + 所以入口在这里必须是主操作,而不是一句「先跑 orggraph-planning」的提示语 -->
  58 + <div v-else class="org-empty">
  59 + <div class="es-icon">🕸️</div>
  60 + <h3>这家公司还没有 org graph</h3>
  61 + <p>
  62 + org graph 由 <code>orggraph-planning</code> 规划:它读公司目标选骨架、把图落库,
  63 + 再按拓扑序把图里的角色招进公司。规划完成前,控制台没有可执行的 org node。
  64 + </p>
  65 + <ReplanOrgGraphButton :company-id="companyId" variant="primary" @planned="onReplanned" />
  66 + </div>
52 </aside> 67 </aside>
53 68
54 <!-- 中栏:选中 org node 的 work graph(下钻后是子 run) --> 69 <!-- 中栏:选中 org node 的 work graph(下钻后是子 run) -->
@@ -110,6 +125,7 @@ import { useRoute } from &#39;vue-router&#39;; @@ -110,6 +125,7 @@ import { useRoute } from &#39;vue-router&#39;;
110 import WorkGraphCanvas from '@/components/WorkGraphCanvas.vue'; 125 import WorkGraphCanvas from '@/components/WorkGraphCanvas.vue';
111 import GraphRunPane from '@/components/graph-console/GraphRunPane.vue'; 126 import GraphRunPane from '@/components/graph-console/GraphRunPane.vue';
112 import NodeDetailPanel from '@/components/graph-console/NodeDetailPanel.vue'; 127 import NodeDetailPanel from '@/components/graph-console/NodeDetailPanel.vue';
  128 +import ReplanOrgGraphButton from '@/components/graph-console/ReplanOrgGraphButton.vue';
113 129
114 /** 130 /**
115 * 执行控制台(T3 + T4,§四.B)。三栏:org graph / work graph 画布 / 节点详情。 131 * 执行控制台(T3 + T4,§四.B)。三栏:org graph / work graph 画布 / 节点详情。
@@ -135,6 +151,7 @@ const selectedNode = ref(null); // 右栏详情对象 @@ -135,6 +151,7 @@ const selectedNode = ref(null); // 右栏详情对象
135 const rootState = ref({}); 151 const rootState = ref({});
136 const midState = ref({ loopRecords: [], events: [] }); 152 const midState = ref({ loopRecords: [], events: [] });
137 153
  154 +const hasOrgGraph = computed(() => defNodes.value.length > 0);
138 const currentRunId = computed(() => (stack.value.length ? stack.value[stack.value.length - 1].runId : null)); 155 const currentRunId = computed(() => (stack.value.length ? stack.value[stack.value.length - 1].runId : null));
139 const budget = computed(() => rootState.value.budget || null); 156 const budget = computed(() => rootState.value.budget || null);
140 const spent = computed(() => budget.value?.spent || {}); 157 const spent = computed(() => budget.value?.spent || {});
@@ -198,6 +215,18 @@ async function loadOrgDef() { @@ -198,6 +215,18 @@ async function loadOrgDef() {
198 } catch { /* ignore */ } 215 } catch { /* ignore */ }
199 } 216 }
200 217
  218 +/**
  219 + * 规划完成(org graph 指纹变了)后重载左栏。run 列表一起重拉:新图的节点与旧 run 的
  220 + * def node 已经对不上了,不重拉的话左栏会拿旧 run 画一张不存在的图。
  221 + * 选中态一并清掉,理由同上。
  222 + */
  223 +async function onReplanned() {
  224 + orgNode.value = null;
  225 + selectedNode.value = null;
  226 + stack.value = [];
  227 + await Promise.all([loadOrgDef(), loadRuns()]);
  228 +}
  229 +
201 function onRootState(s) { 230 function onRootState(s) {
202 rootState.value = s; 231 rootState.value = s;
203 // root run 的 childRuns 就是各 org node 这次跑的 work run。allRuns 只在挂载时拉过一次, 232 // root run 的 childRuns 就是各 org node 这次跑的 work run。allRuns 只在挂载时拉过一次,
@@ -287,6 +316,14 @@ function fmt(n) { return n == null ? &#39;∞&#39; : Number(n).toLocaleString(); } @@ -287,6 +316,14 @@ function fmt(n) { return n == null ? &#39;∞&#39; : Number(n).toLocaleString(); }
287 .history { display:flex; align-items:center; gap:8px; margin-bottom:8px; font-size:12px; color:#666; } 316 .history { display:flex; align-items:center; gap:8px; margin-bottom:8px; font-size:12px; color:#666; }
288 .history select { font-size:11px; padding:3px 6px; border:1px solid #ddd; border-radius:4px; background:#fff; max-width:100%; } 317 .history select { font-size:11px; padding:3px 6px; border:1px solid #ddd; border-radius:4px; background:#fff; max-width:100%; }
289 318
  319 +.left-actions { display:flex; justify-content:flex-end; margin-bottom:8px; }
  320 +
  321 +/* 左栏空态:控制台在无图公司上唯一能做的事,所以按钮是主操作而不是脚注 */
  322 +.org-empty { text-align:center; color:#888; padding:34px 10px; }
  323 +.org-empty h3 { font-size:13px; color:#555; margin:10px 0 6px; }
  324 +.org-empty p { font-size:11px; line-height:1.9; margin:0 0 14px; }
  325 +.org-empty code { background:#f2f2f4; padding:1px 5px; border-radius:3px; }
  326 +
290 .empty-state { text-align:center; color:#888; padding:60px 24px; } 327 .empty-state { text-align:center; color:#888; padding:60px 24px; }
291 .es-icon { font-size:34px; color:#d8d8dc; } 328 .es-icon { font-size:34px; color:#d8d8dc; }
292 .empty-state h3 { font-size:14px; color:#555; margin:10px 0 6px; } 329 .empty-state h3 { font-size:14px; color:#555; margin:10px 0 6px; }