diff --git a/docs/todo/system-design/27-p3b-real-run-runway.md b/docs/todo/system-design/27-p3b-real-run-runway.md index 51e5ef5..d68d100 100644 --- a/docs/todo/system-design/27-p3b-real-run-runway.md +++ b/docs/todo/system-design/27-p3b-real-run-runway.md @@ -224,6 +224,11 @@ master RESOLVED 所以一次真跑的必然走向是:org 节点 → 规划成功 → 第一个 work 节点 `Agent not found` → 节点判失败。 **这正是 W5/W6 要解决的问题,第一批四项做得再对也绕不过去。** +> **W5/W6 落地后的复测**:把 `prd-planning` / `benchmark-validator` / `validator-triager` +> 三个本体 `hire` 进一家探针公司后,`getAgentConfig(':')` 由 `THROWS` 变 **RESOLVED** +> (裸名照旧 `THROWS`,符合预期——引擎走的是作用域名)。断链的解法确认就是「先雇」。 +> 详见下方「W5 / W6 落地记录」。 + > 推论:**现在起真 CLI 不会产出新信息** —— 断点已被零成本静态证明, > 而按 §五 的量级,跑一次是 $3-8 / 15-40 分钟。应先做 W5/W6 再真跑。 @@ -254,14 +259,149 @@ data 里同样只有 `status/fail_reason/output_ref/child_run_id`。于是: 「节点失败,无原因,无痕迹」。建议在 W5/W6 之前先补一条 timeline(最小改动:catch 里加 `timelineNote(ctx,'node.error',err.message,{...})`),否则真跑的排障成本会被这一环吃掉。 +> **W12 · 本轮状态:✅ 已修(选「只留痕,不加列」)** — `server/services/graph-engine.js` +> +> - **不加 `output` 列**(`graph_run_nodes` 保持 16 列不变)。理由:`outcome.output` 长度无上限, +> 而 `getRunState` 是 `SELECT *` 全表读回,控制台每次轮询都会把所有历史输出重传一遍; +> 完整产物已有地址(`output_ref` → `nodes/{nodeRunId}/`),再存一份就是第二个真相源; +> `fail_reason` 是**选边的匹配键**,更不能借来装自由文本。 +> - **catch 那份净损失补上**:executor 抛异常时发 `node.error`(`timelineNote`),带 +> `node_run_id / node_name / attempt / node_kind / system_agent_id / error / error_type / error_stack`(栈顶 6 行)。 +> 四环 = 产生(catch)→ 传递(`timelineNote`)→ 落库(`timeline.ndjson` + `stream.log`,append-only) +> → 读回(`GET /api/graph-runs/:id/stream?lastEventId=0` 全量重放)。 +> - **`outcome.output` 也终于有了消费方**:`node.output` 事件 data 加定长 `output_excerpt` +> (≤600 字符,超长截断并注明「完整输出见 output_ref」),三个产生方(`defaultAgentExecutor` +> 的 `res.output`、W3 越界 banner、catch 的 `err.message`)一并接上。前端 `NodeRunLog` +> 已按 `data.node_run_id` 消费 `node.output`,无需改动即可看到。 +> - W3 banner 依建议**去掉重复的改动清单**(清单仍在 `node.boundary` 事件的 `entries` 里), +> 只留「为什么被判 fail」一句,免得 20 行清单把 agent 真实输出挤出摘要窗口。 +> - **仍需前端配合一行**:`web/src/components/graph-console/GraphRunPane.vue` 的 +> `es.addEventListener` 白名单里没有 `node.error`(命名事件不会走 `onmessage`), +> 加上 `'node.error'` 这条错误才会出现在浏览器的事件轨;不加也不丢数据(在 +> `timeline.ndjson` / `stream.log` 里),只是控制台看不到。 +> - 锁:`server/tests/unit/graph-engine-node-error.test.js`(4 例)。 + ### 第二批 · 招聘走方案 a | # | 内容 | 文件 | 量 | |---|---|---|---| -| **W5** | `workgraph-planning` 模板改语义:撤掉「你不招人也不辞人」,加招聘步骤,`tools.md` 给 `company hire` 用法 | `system/templates/workgraph-planning/**` | 小 | -| **W6** | 写死顺序:**先雇、再提交图**(反了必外键挂);雇失败的重试与降级 | 同上 | 小 | -| **W7** | **work 层招聘不得污染 org graph**:`hire()` 现在会把任何被雇 agent upsert 成一行 `org_graph_nodes`。方案 a 下每次任务规划都雇人,org graph 会被越塞越满(界面走查已实际撞到)。让这类招聘不写 org 节点,或写了打标区分 | `server/services/company-service.js`、`server/services/graph-service.js` | 中 | -| **W8** | 控制台加「重新规划组织」按钮,调 `POST /:companyId/org-graph/replan`(R-18.1)。该端点已实现,全仓库无前端调用方 | `web/src/views/GraphConsole.vue` 或 `CompanyDashboard.vue` | 小 | +| **W5** ✅ | `workgraph-planning` 模板改语义:撤掉「你不招人也不辞人」,加招聘步骤,`tools.md` 给 `company hire` 用法 | `system/templates/workgraph-planning/**` | 小 | +| **W6** ✅ | 写死顺序:**先雇、再提交图**(反了必外键挂);雇失败的重试与降级 | 同上 | 小 | +| **W7** ✅ 已改,🟡 真跑未验 | **work 层招聘不得污染 org graph**:`hire()` 现在会把任何被雇 agent upsert 成一行 `org_graph_nodes`。方案 a 下每次任务规划都雇人,org graph 会被越塞越满(界面走查已实际撞到)。让这类招聘不写 org 节点,或写了打标区分 | `server/services/company-service.js`、`server/services/graph-service.js` | 中 | +| **W8** ✅ UI / 🟡 mock | 控制台加「重新规划组织」按钮,调 `POST /:companyId/org-graph/replan`(R-18.1)。该端点已实现,全仓库无前端调用方 | `web/src/views/GraphConsole.vue` 或 `CompanyDashboard.vue` | 小 | + +#### W5 / W6 落地记录(模板层已改,真跑仍未验) + +改的是 `system/templates/workgraph-planning/` 三份:`agent.md`(主体)、`tools.md`、`identity.md`。 + +- **语义反转**:`tools.md` 的「你不招人也不辞人」那一行**已删**,替换成「你只招不辞」+ 一段明写 + 「旧提示词里那句已作废,以本节为准」的警示(老话可能还留在别处的上下文里)。 + `agent.md` 身份段、角色边界表、`identity.md` 同步改成「图里引用到的本体由你雇」。 +- **顺序写死**:工作流从七步改八步,**Step 6 招聘 → Step 7 提交图**,Step 6 开头一张表列出 + 「反了会怎样」的两条后果(`work_graph_nodes.system_agent_id` 外键 / 节点跑起来 `resolveAgentName` + 找不到公司目录退回裸名),并写明「都发生在你已经下班之后,补不了」。八步表 + 思维隔离自查第 9 条 + 两处复述同一条顺序。 +- **失败分级**:`status` 四档(`hired_from_template` / `already_exists` / `hired_from_base` 三档继续, + `training` 一档**不算成功**——脚手架路径不写 `system_agents`,图提交必被 `rule=reference` 打回); + 出错三级(瞬时故障重试 3 次 → 供不出来就换本体、同一本体最多换一次 → 都不行就**停**, + 不许提交引用了没雇上本体的图)。 +- **`already_exists` 的死角也写进去了**:它是提前返回,**不补 `system_agents` 那一行**。 + 所以「目录在、表里没有」这种组合下重雇永远只返回 `already_exists`,`rule=reference` 修不好—— + 模板要求直接换掉该本体并点名汇报,避免在这里死循环。 +- **提示词组装的实测结论(决定了内容该写在哪)**:图执行侧走 + `graph-engine → AgentManager.callAgent → PromptBuilder.build`,而 `build()` 只拼 + **`agent.md` + `company-context.md`**,**`tools.md` / `soul.md` / `identity.md` 都不进这条提示词** + (`packages/agent-core/Agent.js` 那套读六个文件的 bootstrap 是另一条链路)。 + 所以招聘所需的一切——命令行、参数表、curl 降级、四档判读、三级分级——**全部内联在 `agent.md`**, + `tools.md` 只作为人类可读的重复件。 +- **实测(未起真 CLI,临时 `GRAPH_DB_PATH` + 用完即删的探针公司)**: + `hire` 三个 work 层本体均 `hired_from_template`,重雇均 `already_exists`(幂等成立); + 雇后 `AgentManager.getAgentConfig(':')` **RESOLVED**,裸名仍 `THROWS: Agent not found` + —— 与 §三 第 6 条的断链分析完全一致,**「雇进公司」正是解法**。 + 另用 `PromptBuilder.build()` 实拼一次 `workgraph-planning` 的 system prompt(20.8k 字符), + `company hire` / `--role` / `先雇` / 三个 `status` 取值 / `ls data/companies/` 全部命中。 +- **仍未验**:真 CLI 下 agent 是否照着做(mock 不读提示词,验不了)。归第三阶段。 +- **`--layer` 不是模板的入参**:模板明写「别自己发明这个参数」(`parseArgs` 严格模式,多给未知选项 + 会让整条命令报错)。work 层招聘写不写 org 节点由服务端判据决定,见 W7。 + +#### W7 落地记录(选「不写 org 节点」,判据在服务端;真跑未验) + +- **选「不写」而不是「写了打标」**:打标要么在 `org_graph_nodes` 加列(schema 归 `graph-db.js`)、 + 要么在 `readGraph`/`getGraph` 上加过滤(归 `org-graph-service.js`),两处都不在本轮可动的文件里; + 更关键的是**这行不只是画图用**——`work-graph-db.js:473` 起 org run 时读的就是这张表的 active 行, + 混进去的 work 层本体会变成一个**真的会被执行**的 org 节点。所以不是「显示时过滤掉」够用的问题。 +- **调用方不表达层级,判据只看数据**:唯一会雇 work 层本体的真实调用方是 + `POST /api/company/:companyId/hire`(`workgraph-planning` 在 CLI 里 curl 它), + 该路由按字段解构 body、不透传未知字段,所以「加个 `opts.layer` 参数」在本轮会是一个 + **永远没人传的死开关**(第一版就是这么写的,已删)。何况「谁雇的」本来也不该决定 + 「它是不是组织节点」。`company-service.decideOrgNodeRecording()` 三条判据,命中任一不写 org 节点: + ① `config.type ∈ {meta-bootstrap, meta-planning}`(`orggraph-planning` / `workgraph-planning` / + `brd|prd|sdd-planning` / `goal-benchmark`);② 本体在 `system/work-graph-templates/*.json` 里被 + 点名当节点(`planner-validator` / `validator-triager` / `benchmark-validator` / `testdata-validator` + ——它们 `type='validator'`,判据①盖不住;`org-validator` 只作为 `ownerOrgNode` 出现,不会被误伤); + ③ 公司已有**成型**的 org graph(存在 active 的 `org_graph_edges`)而该角色不在图上。 +- **判据③为什么看边不看节点**:P1/P2 的 `hire` 自己就会造节点,拿「有没有节点」当信号会把 + 「同一家公司连雇两个人,第二个不入图」——`graph-agent-p1-hire.test.js` 正是这么用的——直接废掉。 + 第一版就是拿节点当信号,跑全量测试当场红了这一条,才改成看边。 +- **不写 org 节点时仍然补 `system_agents`**(`graph-service.ensureSystemAgentRow()`,从 + `upsertOrgGraphNode` 里拆出来单独导出):`work_graph_nodes.system_agent_id` 是指向该表的外键, + 少这行 W6 的「先雇再提交图」照样外键挂(W1 记过同一条失效链)。 +- **顺带修掉一个老问题**:org 节点 `role_name='org-coder'` 绑定本体 `coder` 时,在成型图上再雇一次 + `coder` 以前会多出一行同义节点,现在不会。 +- **默认行为 = 写**,P1/P2 语义在「公司还没有成型的图」这个前提下原样保住。 +- **已知残留(不提前加规则)**:① 单 org node 的最小图**没有边**,判据③不生效,这时只有 ①② 认识的 + 本体会被拦下——`executor.json` 的 `{{implAgent}}` / `{{validatorAgent}}` 现填出来的本体仍会入图; + ② `hire()` 的 `already_exists` 是提前返回,**不补 `system_agents`**(W5/W6 记录里那条死角), + 本轮**故意没补**:目录在而表里没有时插一行 `company_hired` 会**squat 掉模板 id**, + 正是 W1 清掉的那条失效链(`seedTemplates` 撞上被占用的 id 会 skip,且此时目录还在、 + 不满足悬空回收条件),代价比收益大;③ `graph-seed-service.seedCompanyAgents()`(手动脚本 + `scripts/graph-seed.js` 才跑)只镜像了判据①的 `meta-bootstrap` 那半条,跑它仍会把 work 层本体 + 塞回 org graph——启动流程不跑它,暂时无害,等它有运行时调用方时必须同步这三条判据。 +- **测试**:`server/tests/unit/company-hire-org-layer.test.js`(9 例,真 `hire()` + 从 + `org-graph-service.getGraph()` 读回,即 GET org-graph 的同一条读路径)。三条判据各做过变异验证 + (分别短路掉 → 红 3 / 1 / 3 例)。每条「不入图」都配了一条只换一个条件的正例, + 免得测试退化成「这个角色被整体拉黑」。 +- **仍未验**:真 CLI 下 `workgraph-planning` 雇完一轮后控制台左栏的实况(验收标准 7),归第三阶段。 + +#### W8 落地记录(按钮已有真实调用方;mock 演示仍差两环,两环都不在 UI 侧) + +- **入口两处,同一个组件** `web/src/components/graph-console/ReplanOrgGraphButton.vue`: + `GraphConsole` 左栏(有图 → 标题栏小按钮;**无图 → 左栏空态里的主操作**,没有 org graph 的公司 + 整个控制台无事可做,入口在这里必须是主按钮而不是一句「先跑 orggraph-planning」的提示语); + `CompanyDashboard` 的「🕸️ 组织流程」块(同样有图挂标题栏、空态给大按钮——新建公司后 + 用户最先看到的就是这一屏的「暂无组织流程」)。至此 `POST /:companyId/org-graph/replan` + 有了前端调用方,不再只能 curl。 +- **过渡态**:`posting` → `planning mm:ss`(按钮禁用防连点)→ `done` / `timeout`(6 分钟,可「继续等」)。 + 已有图时先 `confirm`:`writeGraph` 是整图覆盖,旧图作废。 +- **刷新:轮询 `GET /:companyId/org-graph` 的图指纹(4s),不是 SSE**。replan 内部那条 agent 对话的 + SSE 由服务端自己消费(`triggerOrgGraphPlanning` 的 fetch),浏览器没有把手,端点也不回 + `conversationId`;控制台现成的 `/graph-runs/:id/stream` 是 graph run 的事件轨,而 org graph + 规划不产生 graph run。指纹**含节点 id**(`writeGraph` 每次换新 id),否则「重规划出同一张图」 + 会被误判成超时。变化即 `emit('planned')` → 父组件重载左栏定义 + run 列表(旧 run 的节点与新图对不上,必须一起重拉)。 +- **fixture**:新增 `fixtures/devloop-mock/orggraph-planning.json`(此前回放 `default.json`, + 建公司时的自动规划是空转)。产出 `org-planner → org-coder → org-validator` + 两条 fail 回退边, + 形状对齐 `system/org-graph-templates/dev.json`。实测把它 chunks 里那段图 POST 给真正的写入方: + `{"ok":true,"nodeCount":3,"edgeCount":4}`,GET 读回四条边齐全(含 `input_underspecified` 那条)。 + 锁:`server/tests/unit/orggraph-planning-fixture.test.js`(4 例:图在 chunks 不在 result / + 形状对齐 dev.json / 过五项校验 / mock 协议按 agent id 与 `{cid}:` 作用域名都能回放)。 +- **🟡 mock 下「点一下就出图」还差两环**(真 CLI 模式不受影响,agent 自己跑 curl): + 1. **fixture 命不中**:`_sendMockPrompt` 用 `agentInfo.name` 冒号后那段查 fixture。 + `graph-engine` 传 `{cid}:{agentId}`(命中),`agent-chat.js:684` 的 `getOrCreateProtocol` + 传的是 **identity.md 的显示名**——`orggraph-planning` 的 identity.md 没有「名称:」行, + 退化成字面量 `'Agent'` → 回放 `default.json`(实测就是那两块 38 / 83 字符)。 + `orggraph-planning` 只有 chat 这一条入口。最小修法:把 agent id 传给协议 + (同时协议侧 `rawName` 要改成 `options.agentName` 优先,否则 `agentInfo.name` 恒真)。 + 2. **mock 没有落库环**:真实 agent 是 Step 6 自己 `curl -d @/tmp/x.json` POST `/org-graph` + 写进去的,而 mock 只回放 chunks/files/result,**不执行任何工具调用**,HTTP 副作用为零。 + work 层没这个问题(`extractPlannedGraph` 从 output 解析)。要让 mock 也能演示, + 需在 `triggerOrgGraphPlanning` 消费完输出后 `extractPlannedGraph` → `writeGraph`(仅当图仍为空)。 + 实测:mock 下 `POST .../org-graph/replan` → `{"status":"replanning"}`,日志 + `[Replan] ✅ 已触发 {cid}:orggraph-planning`,但 `GET /org-graph` 仍是 `{nodes:[],edges:[]}`。 + **验收标准 5 仍要留到真 CLI 那一轮验。** +- 顺带确认:`CompanyDashboard` 的「🕸️ 执行控制台」入口仍在(`goToGraphConsole`)。 +- 附带发现(与 W1 相关,正面):`writeGraph` 会给未登记的 `systemAgentId` 自动补 `company_hired` 行; + 本轮测试公司删掉后 `graph-seed-service.reconcileDanglingRows()` 一次收干净 + (`orgNodesRemoved:3 / orgEdgesRemoved:4 / agentsRemoved:[org-planner, org-coder]`)。 ### 第三批 · 让结果可信 @@ -299,6 +439,9 @@ data 里同样只有 `status/fail_reason/output_ref/child_run_id`。于是: 5. 新建公司 → 界面点「重新规划组织」→ `orggraph-planning` 真跑 → org graph 出现,且它雇的三个 org 节点都在。 6. 下达任务 → `workgraph-planning` 真跑 → 它引用的本体被自己雇进公司 → 图提交成功(不再外键挂)。 7. 上条跑完后,控制台左栏 org graph **仍然只有 org 节点**,没有混进 `prd-planning` 这类 work 层本体。 + 🟡 **服务层已成立,真跑未验**:`hire()` 三条判据 + 9 例测试(含「雇一轮 work 层本体后 + `getGraph` 仍只剩三个 org 节点」这条直接对应本标准),见上方 W7 落地记录; + 真 CLI 下的实况归第三阶段。 **第三批** diff --git a/fixtures/devloop-mock/orggraph-planning.json b/fixtures/devloop-mock/orggraph-planning.json new file mode 100644 index 0000000..1519b6e --- /dev/null +++ b/fixtures/devloop-mock/orggraph-planning.json @@ -0,0 +1,17 @@ +{ + "_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 走查,序列会在第二次之后变形。", + "chunks": [ + "[mock] orggraph-planning 接到建司通知,开始按八步工作流规划这家公司的 org graph。\n\n", + "Step 1 读输入:company-goal 指向「做出/改进一个能跑的软件产品」,交付物是代码,验收方式是跑测试。\n", + "Step 2 读候选骨架:dev.json 的 whenToUse 命中(信号词:实现 / 开发 / 接口 / 上线);content.json、ops.json 不命中,不采用。\n", + "Step 3 读可招角色:org-validator 在 system/templates/ 里有现成模板;org-planner / org-coder 清单里还没有,按 Step 4「新造」条款直接写进图,Step 7 走 base 继承生成(模板补齐后自动改走 hired_from_template)。\n", + "Step 4 规划:主干沿用 dev 骨架不裁剪——本公司既要出需求也要出代码还要验收,三个职能缺一不可。\n\n", + "Step 5 规划记录已写入 workspace/orggraph-plan.md。\n\n", + "Step 6 提交图(POST /api/company/{公司ID}/org-graph):\n\n", + "```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", + "四项校验自查:1) forward 边可排拓扑序 org-planner → org-coder → org-validator,无环;2) 入度为 0 的只有 org-planner,单入口;3) 从 org-planner BFS 覆盖全部 3 个节点;4) 两条 fail 边都已标 edgeKind: backward。\n\n", + "Step 7 执行招聘:按拓扑序 hire org-planner → org-coder → org-validator。\n", + "Step 8 关闭自身心跳,转入休眠;后续增量调整交给 HR。\n" + ], + "result": "mock-ok orggraph-planning:已规划 dev 型 org graph(org-planner → org-coder → org-validator,含两条 fail 回退边),并按拓扑序完成招聘。" +} diff --git a/server/services/company-service.js b/server/services/company-service.js index d8e4001..8e5fcf5 100644 --- a/server/services/company-service.js +++ b/server/services/company-service.js @@ -12,7 +12,7 @@ const fs = require('fs'); const path = require('path'); -const { lookupSharedAgentMd, upsertOrgGraphNode } = require('./graph-service'); +const { lookupSharedAgentMd, ensureSystemAgentRow, orgGraphMembership, upsertOrgGraphNode } = require('./graph-service'); const { getDb } = require('./graph-db'); const ROOT_DATA_DIR = path.resolve(__dirname, '../../data'); @@ -25,6 +25,99 @@ const GLOBAL_AGENTS_DIR = path.resolve(__dirname, '../../system/agents'); // (docs/todo/system-design/27-p2-org-graph.md §4.1 第 7 步 / 27-e2e §八bis)。 const BASE_META_AGENT_ID = 'base'; +// ── 招聘的两层,与「谁算 org 节点」(27-P3B §四 W7 / 27-REQ R-18.2 末段)────── +// +// 招聘有两层,但只有 **org 层**的被雇者才是公司 org graph 上的一个节点: +// org 层:orggraph-planning 按它规划的 org graph 雇进来的角色(org-planner / org-coder / +// org-validator …),以及公司还没有图时人工从界面雇的员工 —— +// P2 §4.2 的「hire 成功后写一行 org_graph_nodes」说的是它们; +// work 层:workgraph-planning 每接到一个任务、按它规划出的 work graph 现雇的本体 +// (prd-planning / benchmark-validator / validator-triager / coder-validator …)。 +// 这些本体是某个 org node **内部**工作流的节点,写进 org_graph_nodes 不只是 +// 把控制台左栏画歪:`work-graph-db.js:473` 起 org run 时读的就是这张表的 active 行, +// 混进去的行会变成一个真的会被执行的 org 节点;而 POST /org-graph 又会把不在 +// 规划 agent 节点列表里的行标 removed —— 两边互相打架。 +// +// **判据不问调用方,只看数据**。原因是唯一会雇 work 层本体的真实调用方是 +// `POST /api/company/:companyId/hire`(workgraph-planning 在 CLI 里 curl 它), +// 该路由按字段解构 body、不透传未知字段,所以「加个 opts.layer 参数」在本轮是一个 +// 永远没人传的死开关。何况「谁雇的」本来也不该决定「它是不是组织节点」。三条判据: +// +// ① 本体自称是图基础设施(config.json 的 type ∈ NON_ORG_AGENT_TYPES)→ 永不入图。 +// meta-bootstrap 那半条 P2 就有了(否则建公司到规划跑完之间 GET /org-graph 会返回 +// 一张只有 orggraph-planning 的假组织图);W7 把它推广到 meta-planning,覆盖 +// graph-engine.resolvePlanningAgentName() 按需雇的 workgraph-planning,以及 +// workgraph-planning 自己雇的 prd/sdd/brd-planning、goal-benchmark。 +// ② 本体在 system/work-graph-templates/*.json 里被点名当 work graph 节点 → 永不入图 +// (planner-validator / validator-triager / benchmark-validator / testdata-validator, +// 这些 type='validator' 的本体判据①盖不住,而 org-validator 只当 ownerOrgNode 出现、 +// 不是 nodes[] 里的 systemAgentId,所以不会被误伤)。 +// ③ 公司已经有一张**成型的**组织工作流图(存在 active 的 org_graph_edges), +// 而这次雇的角色不在图上 → 不入图:图一旦成型,作者就是 orggraph-planning +// (POST /org-graph),hire() 不再往上追加节点。这条兜住 ①② 都不认识的、 +// 按 executor.json 的 {{implAgent}} / {{validatorAgent}} 动态选出来的本体。 +// 判据用「有没有边」而不是「有没有节点」,因为 P1/P2 的 hire 自己就会造节点, +// 拿节点当信号会把「同一家公司连雇两个人,第二个不入图」这条 P2 语义废掉 +// (server/tests/integration/graph-agent-p1-hire.test.js 就是这么用的)。 +// 顺带修掉一个老问题:org 节点 role_name='org-coder' 绑定本体 'coder' 时, +// 在成型图上再雇一次 'coder' 以前会多出一行同义节点,现在不会了。 +// +// 默认行为(都不命中)= 写。P1/P2 既有语义「雇一个角色 → org graph 出现这个节点」 +// 在「公司还没有成型的图」这个前提下原样保住(graph-agent-p1-acceptance.test.js 判据 1)。 +// +// 已知残留:单 org node 的最小图没有边,判据③不生效,这时只有 ①② 认识的本体会被拦下; +// 一个既不是 meta-* 又没在骨架里点过名的本体(executor.json 的占位符现填)仍会入图。 +// 真跑到那一步再看,别为它提前加规则。 +const NON_ORG_AGENT_TYPES = new Set(['meta-bootstrap', 'meta-planning']); +const WORK_GRAPH_TEMPLATES_DIR = path.resolve(__dirname, '../../system/work-graph-templates'); + +let workGraphBodyIds = null; // 懒加载 + 进程内缓存(骨架是随代码发布的静态文件) + +/** system/work-graph-templates/*.json 里 nodes[].systemAgentId 的全集(跳过 {{占位符}}) */ +function workGraphTemplateBodies() { + if (workGraphBodyIds) return workGraphBodyIds; + workGraphBodyIds = new Set(); + try { + for (const f of fs.readdirSync(WORK_GRAPH_TEMPLATES_DIR)) { + if (!f.endsWith('.json')) continue; + const tpl = JSON.parse(fs.readFileSync(path.join(WORK_GRAPH_TEMPLATES_DIR, f), 'utf-8')); + for (const n of tpl.nodes || []) { + const id = n && n.systemAgentId; + if (id && !id.startsWith('{{')) workGraphBodyIds.add(id); + } + } + } catch (err) { + // 读不到就退化成「这条判据不生效」,另外两条还在,绝不因此挡住招聘 + console.warn(`[Company] ⚠️ 读 work graph 骨架失败,W7 判据②本轮跳过: ${err.message}`); + } + return workGraphBodyIds; +} + +/** + * 这次招聘要不要在 org_graph_nodes 里记一行?判据见上方注释。 + * + * @param {string} companyId + * @param {string} roleName + * @param {object|null} agentConfig 被雇 agent 的 config.json(取 type) + * @returns {{ record: boolean, reason: string }} reason 用于日志,说明是哪一条判据生效 + */ +function decideOrgNodeRecording(companyId, roleName, agentConfig) { + const type = agentConfig && agentConfig.type; + if (NON_ORG_AGENT_TYPES.has(type)) { + return { record: false, reason: `type=${type},图基础设施本体从不作为 org 节点` }; + } + if (workGraphTemplateBodies().has(roleName)) { + return { record: false, reason: 'work graph 骨架里点名的 work 层本体' }; + } + + const { hasWorkflow, isNode } = orgGraphMembership(companyId, roleName); + if (hasWorkflow && !isNode) { + return { record: false, reason: '公司已有成型的 org graph,而该角色不在图上(work 层本体)' }; + } + + return { record: true, reason: isNode ? '该角色就是图上的 org 节点' : '公司还没有成型的 org graph(P2 语义:雇进来就记一行)' }; +} + // 模板骨架文件白名单 (复制这些,不复制 memory/workspace/conversations 等运行态数据) const TEMPLATE_FILES = [ 'agent.md', 'soul.md', 'identity.md', 'user.md', 'tools.md', @@ -237,20 +330,8 @@ class CompanyService { company.updatedAt = new Date().toISOString(); this._saveCompany(company); - // Org Graph 引用节点记录(审计用途,失败不影响雇佣结果本身)。 - // 基础设施角色(type='meta-bootstrap',如 orggraph-planning)不入图: - // 27-p2-org-graph.md §4.1 明确「orggraph-planning 自己不出现在它规划的 org graph 里」。 - // 不加这个 guard 的话,/create 之后到规划跑完之间的几分钟里,GET /org-graph 会把 - // orggraph-planning 当成这家公司唯一的节点返回,前端就渲染出一张假的组织图。 - if (agentConfig && agentConfig.type === 'meta-bootstrap') { - console.log(`[Company] ⏭️ ${roleName} 是 meta-bootstrap 基础设施角色,不写入 org_graph_nodes`); - } else { - try { - upsertOrgGraphNode(companyId, roleName, roleName); - } catch (err) { - console.warn(`[Company] ⚠️ org_graph_nodes 写入失败(不影响雇佣结果): ${err.message}`); - } - } + // Org Graph 引用节点记录(审计用途,失败不影响雇佣结果本身)。判据见 NON_ORG_AGENT_TYPES 上方注释。 + this._recordHireInGraph(companyId, roleName, agentConfig); console.log(`[Company] ✅ ${companyId} 招聘 ${roleName} (从模板${sharedAgentMdPath ? ',引用模式' : ''})`); return { status: 'hired_from_template', agentId: roleName, agentDir: targetDir }; @@ -319,12 +400,13 @@ class CompanyService { company.updatedAt = new Date().toISOString(); this._saveCompany(company); - // 4. Org Graph 节点 + scope 治理(失败不影响雇佣结果本身) + // 4. Org Graph 节点 + scope 治理(失败不影响雇佣结果本身)。 + // 这条路径下 config.json 刚由本函数写出(type 来自 opts.type),直接把它喂给判据。 + this._recordHireInGraph(companyId, roleName, config); try { - upsertOrgGraphNode(companyId, roleName, roleName); this._ensureCompanyScopedAgent(companyId, roleName); } catch (err) { - console.warn(`[Company] ⚠️ org_graph_nodes 写入失败(不影响雇佣结果): ${err.message}`); + console.warn(`[Company] ⚠️ system_agents scope 校正失败(不影响雇佣结果): ${err.message}`); } console.log(`[Company] 🧬 ${companyId} 招聘 ${roleName}(从 base 元 agent 继承,待 agent-creator 填充定义)`); @@ -338,6 +420,31 @@ class CompanyService { } /** + * 把一次成功的招聘落到图上(hire() 与 hireFromBaseMetaAgent() 共用的收尾动作)。 + * + * org 层 → 记一行 org_graph_nodes(顺带补 system_agents,upsertOrgGraphNode 内部做)。 + * work 层 → **不记** org_graph_nodes,但**仍然补** system_agents 那一行: + * work_graph_nodes.system_agent_id 是指向 system_agents 的外键, + * 少了这行,workgraph-planning 雇完人再提交 work graph 会直接外键挂 + * (27-P3B §四 W1 记录过同一条失效链)。 + * + * 图写失败一律降级成告警,不影响雇佣结果本身(保持 P1/P2 既有行为)。 + */ + _recordHireInGraph(companyId, roleName, agentConfig) { + const { record, reason } = decideOrgNodeRecording(companyId, roleName, agentConfig); + try { + if (record) { + upsertOrgGraphNode(companyId, roleName, roleName); + } else { + ensureSystemAgentRow(companyId, roleName, roleName); + console.log(`[Company] ⏭️ ${roleName} 不写入 org_graph_nodes(${reason}),仅登记 system_agents`); + } + } catch (err) { + console.warn(`[Company] ⚠️ 图登记失败(不影响雇佣结果): ${err.message}`); + } + } + + /** * 校正新建 agent 在 system_agents 里的归属:scope='company' + owner_company_id=本公司 * (27-graph-agent-architecture.md §3.2)。 * diff --git a/server/services/graph-engine.js b/server/services/graph-engine.js index a546ecd..57dc4dc 100644 --- a/server/services/graph-engine.js +++ b/server/services/graph-engine.js @@ -527,6 +527,29 @@ function resolveTriageFile(dir, attempt) { return bestN >= attempt ? { file: best, stale: null } : { file: null, stale: best }; } +/** + * 事件 data 里携带的文本上限(W12)。 + * + * 为什么是「截断进事件」而不是「加一列 output 落库」: + * 1. outcome.output 是 agent 的**完整输出**,长度无上限;graph_run_nodes 每次 + * getRunState 都是 `SELECT *` 全表读回去(控制台每次轮询都拉一遍所有节点), + * 把整份输出塞进节点行 = 每次轮询把所有历史输出重传一遍。 + * 2. 完整产物已经有地址了 —— output_ref 指向 nodes/{nodeRunId}/ 那个目录, + * 再存一份是同一份数据的第二个真相源。 + * 3. fail_reason 不能借用:它是**选边的匹配键**(精确 fail 边靠它匹配), + * 掺进自由文本会让所有精确 fail 边失配(§四.D.2.2「引擎不发明 fail_reason」)。 + * 所以留痕轨道选 timeline.ndjson + stream.log(append-only、按 run 分目录、天然有上限), + * 节点行只保留定长的结构化列。 + */ +const EVENT_TEXT_MAX = 600; + +/** 事件 data 里的自由文本一律截断,避免 stream.log / timeline.ndjson 被单条巨型输出撑爆 */ +function clipText(s, n = EVENT_TEXT_MAX) { + if (s === null || s === undefined || s === '') return null; + const str = typeof s === 'string' ? s : String(s); + return str.length <= n ? str : `${str.slice(0, n)}…(已截断,完整输出见 output_ref)`; +} + /** 只记一条审计(同时也发 SSE,走 emitEvent 保证两轨不分家);失败绝不影响执行 */ function timelineNote(ctx, event, summary, data) { try { @@ -1220,6 +1243,20 @@ async function advanceOne(runId, opts = {}) { // executor 自己炸了(规划失败 / 配置缺失等)算节点失败,不带 failReason,走兜底边。 // err.tokens:规划连着跑了几轮才放弃时,那几轮的 token 是真花了的,照扣不误(§四.A.3「只增不减」)。 outcome = { status: 'fail', output: err.message, failReason: null, tokens: err.tokens || 0 }; + // W12:这条路径以前是**净损失** —— err.message 写进 outcome.output 之后没有任何一环消费它: + // 不落库(graph_run_nodes 没有 output 列,且不该有,见 EVENT_TEXT_MAX 的注释)、 + // 不进 SSE、也不写 timeline,fail_reason 还是 null。操作者看到的是「节点 fail、无任何原因」。 + // 真跑第一个 work 节点时 AgentManager 抛的「Agent not found」正是从这里出去的。 + // 现在原因先进审计轨(timeline.ndjson,永不改写)+ 实时轨(stream.log / SSE), + // 排障至少有的可查;错误类型与调用目标一并记,好判断是「本体没雇进公司」还是别的。 + timelineNote(ctx, 'node.error', `节点执行器抛异常(${(err && err.name) || 'Error'}):${err && err.message}`, { + node_run_id: ctx.nodeRunId, node_name: nodeName, attempt: ctx.attempt, + node_kind: ctx.nodeKind, system_agent_id: ctx.systemAgentId || null, + error: clipText(err && err.message ? err.message : String(err)), + error_type: (err && err.name) || 'Error', + // 栈顶几行足够定位抛点(整份栈进事件没意义,也会把 stream.log 撑大) + error_stack: clipText(err && err.stack ? String(err.stack).split('\n').slice(0, 6).join('\n') : null), + }); } // 炸掉的那条路径同样要查:agent 写完仓库再崩,痕迹一样在。 outcome = applyBoundaryGuard(ctx, guardSnapshot, outcome); @@ -1238,6 +1275,11 @@ async function advanceOne(runId, opts = {}) { status: outcome && outcome.status, fail_reason: (outcome && outcome.failReason) || null, output_ref: (outcome && outcome.outputRef) || null, child_run_id: (outcome && outcome.childRunId) || null, + // W12:outcome.output 有三个产生方(defaultAgentExecutor 的 res.output、 + // applyBoundaryGuard 的越界 banner、上面 catch 的 err.message),以前**一个消费方都没有**。 + // 这里是它唯一的出口:定长摘要进事件(前端 NodeRunLog 已经在按 node_run_id 消费 + // node.output 这条事件),完整输出仍然只在 output_ref 指的产物目录里。 + output_excerpt: clipText(outcome && outcome.output), }, { ref: picked.id }, ); @@ -2529,8 +2571,11 @@ function applyBoundaryGuard(ctx, before, outcome) { }); if (outcome && outcome.status === 'pending') return outcome; + // W12:banner 里不再重复改动清单 —— 清单已经在上面那条 node.boundary 事件的 entries 里 + // (证据不丢),而 outcome.output 现在会被截成 output_excerpt 进 node.output 事件, + // 塞 20 行清单只会把 agent 真正的输出挤出摘要窗口。这里只留「为什么被判 fail」这一句。 const banner = `[边界守卫] 本次调用在宿主仓库(${REPO_ROOT})留下 ${escapes.length} 处改动,` - + `按越界判失败。改动清单:\n${shown.join('\n')}`; + + '按越界判失败(改动清单见同一节点的 node.boundary 事件 entries)。'; return { ...(outcome || {}), status: 'fail', diff --git a/server/services/graph-service.js b/server/services/graph-service.js index 8e42824..d43a724 100644 --- a/server/services/graph-service.js +++ b/server/services/graph-service.js @@ -54,12 +54,78 @@ function ensureOrgGraphNodeIndex(db) { } /** + * 保证 system_agents 里有 systemAgentId 这一行(缺则按 seedCompanyAgents() + * (graph-seed-service.js 约 213-295 行)对同样场景的字段约定 fallback 插入 + * source_kind='company_hired' 的记录)。已存在的行一个字段都不改——绝不把 + * source_kind='template' / 'global_agent' 的公共 agent 降级成公司私有。 + * + * P3B/W7 起从 upsertOrgGraphNode() 里拆出来单独导出:work 层本体被雇时**不写** + * org_graph_nodes(判据见 company-service.decideOrgNodeRecording),但 system_agents + * 这一行仍然必须补——`work_graph_nodes.system_agent_id` 是指向该表的外键, + * 缺行的话 workgraph-planning 雇完人再提交图会直接外键挂(27-P3B §四 W1)。 + * + * 幂等,可重复调用。调用方自己起事务时也安全(better-sqlite3 支持嵌套 transaction)。 + * + * @param {string} companyId + * @param {string} systemAgentId + * @param {string} roleName - 仅用于 fallback 行的 legacy_path + * @returns {boolean} true = 本次真的插了一行;false = 早就有了 + */ +function ensureSystemAgentRow(companyId, systemAgentId, roleName) { + const db = getDb(); + const existingAgent = db.prepare(`SELECT id FROM system_agents WHERE id = ?`).get(systemAgentId); + if (existingAgent) return false; + + const now = new Date().toISOString(); + db.prepare(` + INSERT INTO system_agents (id, name, scope, owner_company_id, source_kind, legacy_path, status, benchmark_score, created_at, updated_at) + VALUES (@id, @name, 'company', @owner_company_id, 'company_hired', @legacy_path, 'active', NULL, @created_at, @updated_at) + `).run({ + id: systemAgentId, + name: systemAgentId, + owner_company_id: companyId, + legacy_path: `data/companies/${companyId}/agents/${roleName}`, + created_at: now, + updated_at: now, + }); + return true; +} + +/** + * 这家公司的 org graph 有没有成型,以及某个角色在不在图上(P3B §四 W7 的判据之一)。 + * + * `hasWorkflow` 看的是**边**不是节点:节点 hire() 自己就会造(P1/P2 语义),拿节点当 + * 「图已存在」的信号会把「同一家公司连雇两个人,第二个不入图」这条既有语义废掉。 + * 有 active 边则说明 orggraph-planning 提交过一张连起来的图(POST /org-graph → + * org-graph-service.writeGraph 是唯一写边的地方),此后这张图的作者就是它, + * hire() 不再往上追加节点:`work-graph-db.js:473` 起 org run 时读的正是 org_graph_nodes + * 的 active 行,混进去的 work 层本体不是画歪一张图,而是真的多出一个会被执行的 org 节点。 + * + * @param {string} companyId + * @param {string} roleName + * @returns {{hasWorkflow: boolean, isNode: boolean}} + */ +function orgGraphMembership(companyId, roleName) { + const db = getDb(); + const edges = db.prepare( + `SELECT COUNT(*) AS c FROM org_graph_edges WHERE company_id = ? AND status = 'active'` + ).get(companyId); + const node = db.prepare( + `SELECT COUNT(*) AS c FROM org_graph_nodes WHERE company_id = ? AND role_name = ? AND status = 'active'` + ).get(companyId, roleName); + + return { hasWorkflow: ((edges && edges.c) || 0) > 0, isNode: ((node && node.c) || 0) > 0 }; +} + +/** * 为 (companyId, roleName) 建立/复用一条 org_graph_nodes 引用记录。 * - * hire() 每次成功后都会调用(不论走引用模式还是 legacy 全量拷贝模式),因此不能假设 - * system_agents 里已经有 systemAgentId 对应的行——没有时按 seedCompanyAgents() - * (graph-seed-service.js 约 213-295 行)对同样场景的字段约定 fallback 插入一行 - * source_kind='company_hired' 的 system_agents 记录。 + * 语义是「把这个 agent 记成本公司 org graph 的一个节点」——**显式动作**, + * 调用方有两类:org-graph-service.insertNode(规划 agent 提交 org graph)与 + * company-service.hire 判定为 org 层招聘时。work 层招聘不走这里(P3B §四 W7)。 + * + * system_agents 缺行时先补(ensureSystemAgentRow),因为 org_graph_nodes.system_agent_id + * 也是外键。 * * 幂等:org_graph_nodes(company_id, role_name) WHERE status='active' 部分唯一索引 * + INSERT ... ON CONFLICT(...) WHERE status='active' DO NOTHING,重复调用不产生重复行。 @@ -77,21 +143,7 @@ function upsertOrgGraphNode(companyId, systemAgentId, roleName) { const now = new Date().toISOString(); const tx = db.transaction(() => { - const existingAgent = db.prepare(`SELECT id FROM system_agents WHERE id = ?`).get(systemAgentId); - - if (!existingAgent) { - db.prepare(` - INSERT INTO system_agents (id, name, scope, owner_company_id, source_kind, legacy_path, status, benchmark_score, created_at, updated_at) - VALUES (@id, @name, 'company', @owner_company_id, 'company_hired', @legacy_path, 'active', NULL, @created_at, @updated_at) - `).run({ - id: systemAgentId, - name: systemAgentId, - owner_company_id: companyId, - legacy_path: `data/companies/${companyId}/agents/${roleName}`, - created_at: now, - updated_at: now, - }); - } + ensureSystemAgentRow(companyId, systemAgentId, roleName); db.prepare(` INSERT INTO org_graph_nodes (company_id, system_agent_id, role_name, status, created_at) @@ -110,5 +162,7 @@ function upsertOrgGraphNode(companyId, systemAgentId, roleName) { module.exports = { lookupSharedAgentMd, + ensureSystemAgentRow, + orgGraphMembership, upsertOrgGraphNode, }; diff --git a/server/tests/unit/company-hire-org-layer.test.js b/server/tests/unit/company-hire-org-layer.test.js new file mode 100644 index 0000000..9209b71 --- /dev/null +++ b/server/tests/unit/company-hire-org-layer.test.js @@ -0,0 +1,201 @@ +/** + * Unit Tests — 招聘的「层」:work 层本体不得进 org graph + * (27-P3B §四 W7 / 27-REQ R-18.2 末段) + * + * 锁的是 company-service.decideOrgNodeRecording 的三条判据 + 默认行为: + * ① config.type ∈ {meta-bootstrap, meta-planning} 的图基础设施本体永不入图; + * ② system/work-graph-templates/*.json 里点名当节点的本体永不入图; + * ③ 公司已有**成型**的 org graph(有 active 边)时,不在图上的角色不入图; + * ④ 都不命中 → 入图,即 P1/P2 既有语义(「雇一个角色 → org graph 出现它」)。 + * + * 每条「不入图」的用例都配一条同角色(或同公司)只换一个条件的正例, + * 免得测试退化成「这个角色被整体拉黑了」而看不出判据本身有没有生效。 + * 三条判据都做过变异验证(删判据①/②/③ 各会红掉对应用例)。 + * + * 走的是**真的** hire()(不是 mock),落库后从 org-graph-service.getGraph() 读回 + * ——即 GET /api/company/:id/org-graph 的同一条读路径。 + * + * 隔离手法与 graph-agent-p1-acceptance.test.js 同源: + * - GRAPH_DB_PATH 指向一次性 sqlite,真实 data/graph.sqlite 全程不被碰; + * - 公司建在 data/companies/tmp-w7-* 下,afterAll 删掉; + * - hire() 会 bump system/templates/_registry.json 的 usedCount,快照后恢复。 + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'fs'; +import path from 'path'; + +describe('hire() 的 org / work 分层(27-P3B W7)', () => { + const REPO_ROOT = path.resolve(__dirname, '../../..'); + const TEST_DB_PATH = '/tmp/test-company-hire-org-layer.sqlite'; + const DATA_DIR = path.join(REPO_ROOT, 'data/companies'); + const TEMPLATES_DIR = path.join(REPO_ROOT, 'system/templates'); + const REGISTRY_PATH = path.join(TEMPLATES_DIR, '_registry.json'); + + // 用到的模板(都是仓库里真实存在的),beforeAll 里逐个核实 + const TEMPLATES = ['qa-judge', 'hr', 'orggraph-planning', 'prd-planning', 'validator-triager', 'coder-validator']; + + const RUN_ID = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + const CO_PLANNED = `tmp-w7-planned-${RUN_ID}`; // orggraph-planning 已提交过成型的图 + const CO_BLANK = `tmp-w7-blank-${RUN_ID}`; // 从没有过图(P1/P2 的老场景) + const CO_BOOTSTRAP = `tmp-w7-bootstrap-${RUN_ID}`; // 刚建的公司,也没有图 + + // org graph(照抄 system/org-graph-templates/dev.json,能过 §3.3 四项校验) + const ORG_NODES = ['org-planner', 'org-coder', 'org-validator']; + const ORG_EDGES = [ + { from: 'org-planner', to: 'org-coder', condition: 'success' }, + { from: 'org-coder', to: 'org-validator', condition: 'success' }, + { from: 'org-validator', to: 'org-coder', condition: 'fail', edgeKind: 'backward' }, + ]; + + let graphDb; + let orgGraphService; + let companyService; + let registrySnapshot; + + /** 从 GET org-graph 的同一条读路径拿 active 角色名 */ + const roleNames = (companyId) => + orgGraphService.getGraph(companyId).nodes.filter((n) => n.status === 'active').map((n) => n.roleName); + + const systemAgentRow = (id) => + graphDb.getDb().prepare('SELECT * FROM system_agents WHERE id = ?').get(id); + + beforeAll(() => { + // 模板必须真的在仓库里,否则 hire() 会走 base 继承分支,测试变成空跑 + for (const tpl of TEMPLATES) { + expect(fs.existsSync(path.join(TEMPLATES_DIR, tpl, 'config.json'))).toBe(true); + } + // 判据②的数据源也要真的点名了 validator-triager,否则那条用例是空跑 + const workTpl = JSON.parse( + fs.readFileSync(path.join(REPO_ROOT, 'system/work-graph-templates/validator.json'), 'utf-8') + ); + expect(workTpl.nodes.map((n) => n.systemAgentId)).toContain('validator-triager'); + + registrySnapshot = fs.readFileSync(REGISTRY_PATH, 'utf-8'); + + if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH); + process.env.GRAPH_DB_PATH = TEST_DB_PATH; + for (const m of ['../../services/graph-db', '../../services/graph-service', + '../../services/org-graph-service', '../../services/company-service']) { + delete require.cache[require.resolve(m)]; + } + graphDb = require('../../services/graph-db'); + orgGraphService = require('../../services/org-graph-service'); + const { CompanyService } = require('../../services/company-service'); + companyService = new CompanyService(); + + // 镜像生产种子:这些模板在 system_agents 里是 source_kind='template'(引用模式) + const db = graphDb.getDb(); + const now = new Date().toISOString(); + for (const id of TEMPLATES) { + db.prepare(` + INSERT INTO system_agents (id, name, scope, owner_company_id, source_kind, legacy_path, status, benchmark_score, created_at, updated_at) + VALUES (?, ?, 'public', NULL, 'template', ?, 'active', NULL, ?, ?) + `).run(id, id, `system/templates/${id}`, now, now); + } + + for (const [id, name] of [[CO_PLANNED, 'W7 已有组织图'], [CO_BLANK, 'W7 空图公司'], [CO_BOOTSTRAP, 'W7 新建公司']]) { + companyService.create({ id, name, description: 'W7 分层测试' }); + } + + // CO_PLANNED 走 P2 §4.1 的正常顺序:orggraph-planning 先提交图,再招聘 + const written = orgGraphService.writeGraph(CO_PLANNED, { nodes: ORG_NODES, edges: ORG_EDGES }); + expect(written.ok).toBe(true); + expect(written.nodeCount).toBe(3); + expect(written.edgeCount).toBe(3); + }); + + afterAll(() => { + for (const id of [CO_PLANNED, CO_BLANK, CO_BOOTSTRAP]) { + try { fs.rmSync(path.join(DATA_DIR, id), { recursive: true, force: true }); } catch {} + } + try { fs.unlinkSync(TEST_DB_PATH); } catch {} + try { fs.unlinkSync(`${TEST_DB_PATH}-shm`); } catch {} + try { fs.unlinkSync(`${TEST_DB_PATH}-wal`); } catch {} + delete process.env.GRAPH_DB_PATH; + if (registrySnapshot !== undefined) fs.writeFileSync(REGISTRY_PATH, registrySnapshot, 'utf-8'); + }); + + // ── ④ 默认行为:P1/P2 语义原样保住 ────────────────────────────────── + it('公司还没有 org graph 时雇一个角色 → org graph 里出现这个节点(P2 §4.2 语义)', () => { + expect(roleNames(CO_BLANK)).toEqual([]); + + expect(companyService.hire(CO_BLANK, 'qa-judge', {}).status).toBe('hired_from_template'); + + expect(roleNames(CO_BLANK)).toEqual(['qa-judge']); + }); + + it('同一家没有图的公司连雇两个人,第二个照样入图(判据③不能拿节点当信号的原因)', () => { + expect(companyService.hire(CO_BLANK, 'hr', {}).status).toBe('hired_from_template'); + + expect(roleNames(CO_BLANK).sort()).toEqual(['hr', 'qa-judge']); + }); + + // ── ② work graph 骨架点名的本体 ───────────────────────────────────── + it('validator-triager(type=validator,骨架里点过名)不入图,哪怕公司连一条边都没有', () => { + const before = roleNames(CO_BLANK).sort(); + + expect(companyService.hire(CO_BLANK, 'validator-triager', {}).status).toBe('hired_from_template'); + + expect(roleNames(CO_BLANK).sort()).toEqual(before); + // 但 system_agents 那行必须在:work_graph_nodes.system_agent_id 是指向它的外键, + // 缺行的话 workgraph-planning 雇完人再提交 work graph 会直接外键挂(W1 那条失效链) + expect(systemAgentRow('validator-triager')).toBeTruthy(); + }); + + // ── ① 图基础设施本体 ──────────────────────────────────────────────── + it('meta-bootstrap(orggraph-planning)不入图:建公司到规划跑完之间不会返回一张假组织图', () => { + expect(companyService.hire(CO_BOOTSTRAP, 'orggraph-planning', {}).status).toBe('hired_from_template'); + + expect(roleNames(CO_BOOTSTRAP)).toEqual([]); + expect(systemAgentRow('orggraph-planning')).toBeTruthy(); + }); + + it('meta-planning(prd-planning)不入图,哪怕公司还没有图(判据①单独生效)', () => { + expect(roleNames(CO_BOOTSTRAP)).toEqual([]); + + expect(companyService.hire(CO_BOOTSTRAP, 'prd-planning', {}).status).toBe('hired_from_template'); + + expect(roleNames(CO_BOOTSTRAP)).toEqual([]); + }); + + it('coder-validator 在没有成型图的公司里照样入图 —— 判据①②都不认识它', () => { + expect(companyService.hire(CO_BOOTSTRAP, 'coder-validator', {}).status).toBe('hired_from_template'); + + expect(roleNames(CO_BOOTSTRAP)).toEqual(['coder-validator']); + }); + + // ── ③ 成型的图 + 不在图上 = work 层本体 ───────────────────────────── + it('同一个 coder-validator,在已有成型 org graph 的公司里被雇 → GET org-graph 里没有它', () => { + expect(roleNames(CO_PLANNED)).toEqual(ORG_NODES); + + expect(companyService.hire(CO_PLANNED, 'coder-validator', {}).status).toBe('hired_from_template'); + + // 雇到位了:目录在、公司名册里有 + expect(fs.existsSync(path.join(DATA_DIR, CO_PLANNED, 'agents/coder-validator'))).toBe(true); + expect(companyService.get(CO_PLANNED).agents).toContain('coder-validator'); + // 但组织图一个节点都没多 + expect(roleNames(CO_PLANNED)).toEqual(ORG_NODES); + }); + + it('R-18.2 验收 7:workgraph-planning 雇一轮 work 层本体后,org graph 仍然只有 org 节点', () => { + for (const body of ['prd-planning', 'validator-triager']) { + expect(['hired_from_template', 'already_exists']).toContain( + companyService.hire(CO_PLANNED, body, {}).status + ); + } + + expect(roleNames(CO_PLANNED)).toEqual(ORG_NODES); + }); + + it('org 节点本身重复雇不产生新节点(P2 幂等语义不变)', () => { + const res = companyService.hire(CO_PLANNED, 'org-coder', {}); + // 模板库里没有 org-coder → 走 base 元 agent 继承分支,同样要过 _recordHireInGraph + expect(['hired_from_template', 'hired_from_base']).toContain(res.status); + + expect(roleNames(CO_PLANNED)).toEqual(ORG_NODES); + const count = graphDb.getDb() + .prepare(`SELECT COUNT(*) AS c FROM org_graph_nodes WHERE company_id = ? AND role_name = 'org-coder' AND status = 'active'`) + .get(CO_PLANNED).c; + expect(count).toBe(1); + }); +}); diff --git a/server/tests/unit/graph-engine-node-error.test.js b/server/tests/unit/graph-engine-node-error.test.js new file mode 100644 index 0000000..acbd7b3 --- /dev/null +++ b/server/tests/unit/graph-engine-node-error.test.js @@ -0,0 +1,195 @@ +/** + * Unit Tests — 节点失败时原因留不留得下来(27-P3B W12,「第 12 处四环漏一环」) + * + * 修之前:advanceOne 的 executor catch 把 err.message 写进 outcome.output, + * 而 outcome.output **一个消费方都没有** —— 不落库(graph_run_nodes 没有 output 列)、 + * 不进 SSE、也不写 timeline,fail_reason 还是 null。真跑第一个 work 节点时 + * AgentManager 抛的「Agent not found」就是这么消失的,操作者看到「节点 fail、无任何原因」。 + * + * 这里锁的是修完之后的四环: + * 产生(catch / executor 返回值)→ 传递(timelineNote / node.output 事件 data) + * → 落库(timeline.ndjson + stream.log,append-only)→ 读回(本用例就是按读回的那条路读的: + * 前端走 GET /api/graph-runs/:id/stream?lastEventId=0 全量重放同一份 stream.log) + * + * 隔离手法同 graph-engine.test.js:先设 GRAPH_DB_PATH / GRAPH_RUNS_DIR 再 require, + * 仓库里的 data/graph.sqlite 与 data/companies/ 全程不被碰到;executor 全是注入的假货, + * 不起任何 Claude CLI。 + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +describe('graph-engine 节点失败留痕(W12)', () => { + let wdb; + let engine; + let sse; + let runsDir; + const TEST_DB_PATH = '/tmp/test-graph-engine-node-error.sqlite'; + + beforeAll(() => { + for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) { + try { fs.unlinkSync(f); } catch {} + } + process.env.GRAPH_DB_PATH = TEST_DB_PATH; + runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-node-error-runs-')); + process.env.GRAPH_RUNS_DIR = runsDir; + for (const m of [ + '../../services/graph-db', + '../../services/graph-service', + '../../services/org-graph-service', + '../../services/work-graph-db', + '../../services/graph-run-sse', + '../../services/graph-engine', + ]) { + delete require.cache[require.resolve(m)]; + } + wdb = require('../../services/work-graph-db'); + sse = require('../../services/graph-run-sse'); + engine = require('../../services/graph-engine'); + }); + + afterAll(() => { + for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) { + try { fs.unlinkSync(f); } catch {} + } + delete process.env.GRAPH_DB_PATH; + try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {} + delete process.env.GRAPH_RUNS_DIR; + }); + + beforeEach(() => { + const db = wdb.getDb(); + db.pragma('foreign_keys = OFF'); + db.exec(` + DELETE FROM graph_run_loop_records; + DELETE FROM graph_run_nodes; + DELETE FROM graph_runs; + DELETE FROM work_graph_edges; + DELETE FROM work_graph_nodes; + DELETE FROM work_graphs; + DELETE FROM system_agents; + `); + db.pragma('foreign_keys = ON'); + engine.setAgentExecutor(null); + }); + + let seq = 0; + const nextCompany = () => `w12-co-${++seq}`; + + function ensureAgents(ids) { + const db = wdb.getDb(); + const now = new Date().toISOString(); + const ins = db.prepare(` + INSERT OR IGNORE INTO system_agents + (id, name, scope, owner_company_id, source_kind, legacy_path, status, created_at, updated_at) + VALUES (?, ?, 'public', NULL, 'global_agent', ?, 'active', ?, ?) + `); + for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, now, now); + } + + function buildRun(nodes, edges = []) { + const companyId = nextCompany(); + ensureAgents(nodes); + const g = wdb.createWorkGraph({ companyId, nodes, edges }); + expect(g.errors || []).toEqual([]); + const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId, executionMode: 'manual' }); + engine.markRunReady(run.id); + return run.id; + } + + /** 读回轨 1:审计轨 timeline.ndjson(append-only) */ + const timeline = (runId) => + fs.readFileSync(sse.timelinePath(runId), 'utf8') + .split('\n').filter(Boolean).map((l) => JSON.parse(l)); + + /** 读回轨 2:stream.log —— 前端 lastEventId=0 全量重放读的就是这一份 */ + const streamEvents = (runId) => + fs.readFileSync(path.join(runsDir, runId, 'stream.log'), 'utf8') + .split('\n\n').filter(Boolean) + .map((block) => { + const ev = /^event: (.+)$/m.exec(block); + const data = /^data: (.+)$/m.exec(block); + return { event: ev && ev[1], data: JSON.parse(data[1]) }; + }); + + const nodeByName = (runId, name) => wdb.getRunState(runId).nodes.find((n) => n.name === name); + + it('executor 抛异常:节点 fail,且 timeline 里查得到原因(不是空的)', async () => { + const runId = buildRun(['prd-planning']); + engine.setAgentExecutor(async () => { + // 真跑第一个 work 节点时 AgentManager.getAgentConfig 抛的就是这一句 + throw new Error('Agent not found: prd-planning'); + }); + + engine.startRun(runId); + await engine.nextStep(runId); + + const node = nodeByName(runId, 'prd-planning'); + // 没有 fail 边的死路:fail 之后就地 blocked —— 真跑第一个 work 节点时看到的就是这个 + expect(node.status).toBe('blocked'); + // 引擎仍然不发明 fail_reason(它是选边的匹配键) + expect(node.failReason).toBeNull(); + + const errs = timeline(runId).filter((e) => e.kind === 'node.error'); + expect(errs).toHaveLength(1); + expect(errs[0].summary).toContain('Agent not found: prd-planning'); + expect(errs[0].payload.node_run_id).toBe(node.id); + expect(errs[0].payload.attempt).toBe(0); + expect(errs[0].payload.node_name).toBe('prd-planning'); + expect(errs[0].payload.system_agent_id).toBe('prd-planning'); + expect(errs[0].payload.error).toBe('Agent not found: prd-planning'); + expect(errs[0].payload.error_type).toBe('Error'); + expect(errs[0].payload.error_stack).toContain('Error: Agent not found'); + // ref = 节点 run id:前端 NodeRunLog 按 node_run_id 归组,这条落在失败节点自己的卡片里 + expect(errs[0].ref).toBe(node.id); + + // 同一条事件也在实时轨上(emitEvent 保证两轨不分家) + const onStream = streamEvents(runId).filter((e) => e.event === 'node.error'); + expect(onStream).toHaveLength(1); + expect(onStream[0].data.error).toBe('Agent not found: prd-planning'); + }); + + it('抛异常时 node.output 事件带 output_excerpt,错误原因同样能读回', async () => { + const runId = buildRun(['a']); + engine.setAgentExecutor(async () => { throw new Error('boom: 配置缺失'); }); + + engine.startRun(runId); + await engine.nextStep(runId); + + const out = streamEvents(runId).filter((e) => e.event === 'node.output'); + expect(out).toHaveLength(1); + expect(out[0].data.status).toBe('fail'); + expect(out[0].data.fail_reason).toBeNull(); + expect(out[0].data.output_excerpt).toBe('boom: 配置缺失'); + }); + + it('executor 正常返回的 output 也进 output_excerpt,超长按定长截断', async () => { + const runId = buildRun(['long']); + const huge = 'x'.repeat(5000); + engine.setAgentExecutor(async () => ({ status: 'success', output: huge })); + + engine.startRun(runId); + await engine.nextStep(runId); + + const out = streamEvents(runId).find((e) => e.event === 'node.output'); + expect(out.data.status).toBe('success'); + expect(out.data.output_excerpt.startsWith('xxxx')).toBe(true); + // 截断了,且短得多(完整输出只在 output_ref 指的产物目录里,不进事件也不进库) + expect(out.data.output_excerpt.length).toBeLessThan(huge.length); + expect(out.data.output_excerpt).toContain('已截断'); + }); + + it('executor 没有 output 时 output_excerpt 是 null,不塞空串', async () => { + const runId = buildRun(['quiet']); + engine.setAgentExecutor(async () => ({ status: 'success' })); + + engine.startRun(runId); + await engine.nextStep(runId); + + const out = streamEvents(runId).find((e) => e.event === 'node.output'); + expect(out.data.output_excerpt).toBeNull(); + // 没抛异常就不该有 node.error + expect(timeline(runId).filter((e) => e.kind === 'node.error')).toHaveLength(0); + }); +}); diff --git a/server/tests/unit/orggraph-planning-fixture.test.js b/server/tests/unit/orggraph-planning-fixture.test.js new file mode 100644 index 0000000..c9db089 --- /dev/null +++ b/server/tests/unit/orggraph-planning-fixture.test.js @@ -0,0 +1,86 @@ +/** + * Unit Tests — fixtures/devloop-mock/orggraph-planning.json(P3B / W8) + * + * 这份 fixture 的价值全在「能不能被读回并落库」,所以本文件按四环各钉一颗钉子: + * 产生 → chunks 里确实有一段 ```json 图(不是只写在 result 里,那样 agent-chat 的 + * fullOutput 收不到,见 agent-chat.js:2315 只累积 agent_message_chunk) + * 传递 → mock 协议按 agent id(含 `{cid}:` 前缀的作用域名)能命中这份 fixture 并把 + * 图原样吐进流式 chunks + * 落库 → 这张图过得了 org-graph-service 的五项校验(真正的写入方 POST /org-graph 用的就是它) + * 读回 → 形状与 system/org-graph-templates/dev.json 对齐:三节点 + 两条 fail 回退边 + * + * 反例保护:把图从 chunks 挪到 result、或让回退边漏标 backward、或砍掉那条兜底 fail 边, + * 本文件都会红。 + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'fs'; +import path from 'path'; + +const REPO = path.resolve(__dirname, '../../..'); +const FIXTURE = path.join(REPO, 'fixtures/devloop-mock/orggraph-planning.json'); + +/** ```json 围栏块(与 graph-engine.extractPlannedGraph 同一取法) */ +function fencedGraphs(text) { + const out = []; + const fence = /```(?:json|jsonc)?\s*([\s\S]*?)```/g; + let m; + while ((m = fence.exec(text))) { + try { + const doc = JSON.parse(m[1]); + if (Array.isArray(doc.nodes)) out.push(doc); + } catch { /* 非图的围栏块,跳过 */ } + } + return out; +} + +describe('orggraph-planning mock fixture', () => { + const fixture = JSON.parse(fs.readFileSync(FIXTURE, 'utf-8')); + const chunkText = (fixture.chunks || []).join(''); + + it('图放在 chunks 里(而不是只放 result)', () => { + expect(fencedGraphs(chunkText)).toHaveLength(1); + // result 只是收尾摘要,不该承载图 —— 它进不了 agent-chat 的 fullOutput + expect(fencedGraphs(String(fixture.result || ''))).toHaveLength(0); + }); + + it('形状对齐 system/org-graph-templates/dev.json:三节点 + 两条 fail 回退边', () => { + const g = fencedGraphs(chunkText)[0]; + const dev = JSON.parse(fs.readFileSync(path.join(REPO, 'system/org-graph-templates/dev.json'), 'utf-8')); + + expect(g.nodes.map((n) => n.roleName)).toEqual(dev.nodes); + expect(g.edges.filter((e) => e.condition === 'success')).toHaveLength(2); + + const fails = g.edges.filter((e) => e.condition === 'fail'); + expect(fails).toHaveLength(2); + expect(fails.every((e) => e.edgeKind === 'backward')).toBe(true); + // 兜底边:有 fail 出边的节点必须留一条不带 failReason 的(org-graph-service 的 fail-fallback-edge 规则) + expect(fails.filter((e) => !e.failReason)).toHaveLength(1); + }); + + /** + * validateGraph 是纯函数(不碰 DB,graph-db 的连接只在 getDb() 里惰性打开), + * 所以这里既不设 GRAPH_DB_PATH 也不清 require.cache —— 那套隔离手法会污染同一 + * worker 里后跑的 graph-seed-service.test.js(实测:加进去后它必红)。 + */ + it('这张图过得了 org-graph-service 的五项校验(真正的写入方用的就是它)', () => { + const svc = require('../../services/org-graph-service'); + const g = fencedGraphs(chunkText)[0]; + expect(svc.validateGraph(g.nodes, g.edges).errors).toEqual([]); + }); + + /** + * fixture 的查找键是 `agentInfo.name` 冒号后那段(ClaudeCliProtocol._sendMockPrompt)。 + * graph-engine 传的是 `{cid}:{agentId}`,命得中;agent-chat 传的是 identity.md 的显示名, + * 命不中(W8 备注里记了这个断点)。这条用例钉住的是「按 agent id 传就必须命中」这半边契约。 + */ + it('mock 协议按 agent id / 公司作用域名都能回放出这张图', () => { + const { ClaudeCliProtocol } = require('../../../packages/acp-core/protocol/ClaudeCliProtocol'); + for (const name of ['orggraph-planning', 'some-co:orggraph-planning']) { + const p = new ClaudeCliProtocol({ name }, { mock: true }); + let text = ''; + p.on('session_update', (e) => { text += e.update.content.text; }); + p._sendMockPrompt('规划这家公司的 org graph'); + expect(fencedGraphs(text)).toHaveLength(1); + } + }); +}); diff --git a/system/templates/_registry.json b/system/templates/_registry.json index fd0c58b..c8b1e3d 100644 --- a/system/templates/_registry.json +++ b/system/templates/_registry.json @@ -15,7 +15,7 @@ "description": "质量验收、OKR 进度审查、标准化仲裁", "benchmarkScore": 95, "trainedAt": "2026-03-25", - "usedCount": 62 + "usedCount": 82 }, { "id": "base", @@ -87,7 +87,7 @@ "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", "benchmarkScore": null, "trainedAt": null, - "usedCount": 28 + "usedCount": 29 }, { "id": "validator-triager", @@ -96,7 +96,7 @@ "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", "benchmarkScore": null, "trainedAt": null, - "usedCount": 5 + "usedCount": 6 }, { "id": "org-validator", @@ -123,7 +123,7 @@ "description": "work 层验收本体:按 contract + acceptance_criteria 逐条验代码,测试必须真跑过,专抓空断言/跳过充数/只有 happy path 等假绿。设计 docs/todo/system-design/27-p3-p4-work-graph-console.md §四.D.3", "benchmarkScore": null, "trainedAt": null, - "usedCount": 13 + "usedCount": 15 }, { "id": "brd-planning", @@ -141,7 +141,7 @@ "description": "work graph 内的规划节点:产出产品需求文档(PRD)。命名见 27-naming-conventions.md §二。", "benchmarkScore": null, "trainedAt": null, - "usedCount": 4 + "usedCount": 7 }, { "id": "sdd-planning", @@ -150,7 +150,7 @@ "description": "work graph 内的规划节点:产出系统设计文档(SDD)。命名见 27-naming-conventions.md §二。", "benchmarkScore": null, "trainedAt": null, - "usedCount": 4 + "usedCount": 5 }, { "id": "goal-benchmark", @@ -168,7 +168,7 @@ "description": "org-validator work graph 的节点:执行 goal-benchmark 产出的验收基准并给出判决。见 27-p3-p4-work-graph-console.md §四.D.3。", "benchmarkScore": null, "trainedAt": null, - "usedCount": 1 + "usedCount": 3 }, { "id": "testdata-validator", diff --git a/system/templates/workgraph-planning/agent.md b/system/templates/workgraph-planning/agent.md index 742975e..f81015d 100644 --- a/system/templates/workgraph-planning/agent.md +++ b/system/templates/workgraph-planning/agent.md @@ -6,8 +6,15 @@ 都调**同一个你**——不存在 `org-coder-workgraph-planning` 这种按节点特化的副本。 你在不同 org node / 不同公司下的差异,靠**记忆分区**承载,不靠复制本体。 -你的唯一职责:为**某一个 org node 的这一次任务**规划出一张 **work graph**,过校验,落库,把图 id 交回引擎。 -你不执行图、不招人、不建 agent、不改 org graph。 +你的职责有两件,**顺序不可颠倒**:为**某一个 org node 的这一次任务**规划出一张 **work graph**, +**先把这张图引用到的本体全部雇进本公司**,再提交图、把图 id 交回引擎。 +你不执行图、不改 org graph。 + +> 🚨 **「图里引用到的本体由你雇」是 R-18.2 的定案,不是可选项。** +> 从前这一层没有任何执行者:org 层的三个节点由 `orggraph-planning` 建公司时招齐, +> 而你图里引用的 `prd-planning` / `coder-validator` 这类本体**是任务来了才知道的**, +> 建公司时没人能预知,于是一个都没被雇 —— 节点跑起来必然 +> `Agent not found: <本体名>`,整条链断在第一个 work 节点。**你就是这一环的执行者。** > **work graph 是什么**:一张**执行流图**,节点是一次 agent 调用(引用某个 system agent **本体**), > 边是「上游出这个结果时,任务流向谁」。边只有两种 `condition`:`success` / `fail`。 @@ -18,9 +25,9 @@ | 角色 | 干什么 | 与你的关系 | |---|---|---| -| `orggraph-planning` | 建公司时跑一次,规划**公司级** org graph 并招齐人 | 上一层,与你无关 | -| **你(`workgraph-planning`)** | 每个 org node 每次接到任务时跑一次,规划**该节点这一次**的 work graph | 干完交出 `workGraphId` 就结束 | -| 图上的节点 agent | 真正干活的人 | 你只**引用**它们,不代替它们干活 | +| `orggraph-planning` | 建公司时跑一次,规划**公司级** org graph,并把 **org 层的节点**招齐 | 上一层。它招 org 节点,**你招你图里的 work 层本体**,两批人互不重叠 | +| **你(`workgraph-planning`)** | 每个 org node 每次接到任务时跑一次,规划**该节点这一次**的 work graph,**并雇齐图里引用的本体** | 雇完 + 提交图 + 交出 `workGraphId` 才算结束 | +| 图上的节点 agent | 真正干活的人 | 你**引用**它们、**把它们雇进公司**,但不代替它们干活 | | `validator-triager` | 归因「这次失败该怪谁」 | 它是 `org-validator` work graph 里的**一个节点**,由你画进图里 | | 引擎(`graph-engine`) | 起子 run、按边推进、熔断、透传 | 你的调用方,也是你落库后的消费者 | @@ -28,10 +35,20 @@ --- -## ⚡ 强制工作流(七步,必须在同一轮对话内连贯做完) +## ⚡ 强制工作流(八步,必须在同一轮对话内连贯做完) + +> **🚨 绝不允许只画完图就停下来等指示。** 从 Step 1 到 Step 8 是一条不可中断的流水线。 +> 人没雇齐、图没过校验、没落库、`workGraphId` 没交回去,任务就没完成。 -> **🚨 绝不允许只画完图就停下来等指示。** 从 Step 1 到 Step 7 是一条不可中断的流水线。 -> 图没过校验、没落库、`workGraphId` 没交回去,任务就没完成。 +| Step | 干什么 | +|---|---| +| 1-4 | 读输入 → 读骨架 → 读本体清单 → 规划 | +| 5 | 落盘规划记录 | +| **6** | **执行招聘(雇齐图里引用的本体)** | +| **7** | **提交图** | +| 8 | 交回引擎 | + +**6 在 7 前面是写死的,不是建议。** 理由见 Step 6 开头那段红字。 ### Step 1 · 读输入(四项) @@ -72,12 +89,22 @@ curl -s http://localhost:3010/api/company/<公司ID>/agents 这是**唯一权威**的「哪些本体真的存在」清单。模板 JSON 里写的 `systemAgentId` 只是**建议**, `{{implAgent}}` 这类占位符更是**必须替换**的空位。 +两份清单的分工要分清: + +- **`company templates` 是「能不能雇」的清单**——`system/templates/` 下有目录的本体,Step 6 一雇就有; +- **`GET /agents` 是「已经雇过没有」的清单**——已经在里面的,Step 6 那一次雇会返回 `already_exists`, + 这是**正常且预期**的(同一家公司做第二个任务时,多半半张图都已经雇过了)。 + **不在里面不代表不能用**,只代表 Step 6 要真去雇它一次。 + **找不到理想本体时怎么办**(按优先级): -1. 用清单里语义最接近的现有本体,并在 Step 5 的规划记录里写明「用 X 代替了理想中的 Y」; +1. 用 `company templates` 清单里语义最接近的现有本体,并在 Step 5 的规划记录里写明「用 X 代替了理想中的 Y」; 2. 换一个不需要这个本体的图形状(比如把 fan-out 改成串行); -3. **实在绕不过去**:照常把它写进图里、在规划记录里单列一节「本图依赖尚不存在的本体 Z」,交回引擎时一并汇报。 - **你不负责创建 agent**——建 agent 是 `agent-creator` 的活,招人是 `orggraph-planning` / HR 的活。 +3. **实在绕不过去**:照常把它写进图里,Step 6 照样去雇它——模板库里没有的角色, + `hire` 会自动从 `system/templates/base/` 元 agent 继承出骨架(返回 `hired_from_base`), + 并**顺手把它写进 `system_agents` 表**,图才引用得动它。 + 在规划记录里单列一节「本图依赖尚不存在的本体 Z,已走 base 继承分支」,交回引擎时一并汇报。 + **你仍然不亲手写 agent 定义**——那是 `agent-creator` 的活,`hire` 会自动去触发它。 ### Step 4 · 规划 @@ -115,7 +142,7 @@ curl -s http://localhost:3010/api/company/<公司ID>/agents mkdir -p workspace ``` -文件至少包含六节: +文件至少包含七节(第 7 节在 Step 6 招完人之后回填): ```markdown # {宿主 org node}(公司 {公司id} / 任务 {taskId})Work Graph 规划记录 @@ -153,12 +180,115 @@ mkdir -p workspace | 3 | 全可达 | 从 a 出发 BFS 覆盖全部 3 个节点 | | 4 | 回退边显式标注 | c → b 已标 edgeKind: backward | | 5 | fail 兜底边 | c 有 fail 出边,其中 c → b 不带 failReason | + +## 7. 招聘结果(Step 6 之后回填) +| 本体 | hire 返回 | 判读 | 后续动作 | +|---|---|---|---| +| coder | hired_from_template | ✅ | 无 | +| unit-tester | already_exists | ✅ 上次任务已雇 | 无 | +| xxx-checker | hired_from_base | ✅ 新造,已走 base 继承 | 记一笔,交回引擎时汇报 | ``` **这一步不能跳。** 27-P3/P4 §八.8 写明:**图的规划质量没有硬校验**,五项只保证图**结构合法**, 保证不了它**适合这个任务**。这份 md 是人类事后发现图不合理时唯一的决策线索。 -### Step 6 · 提交图 +### Step 6 · 执行招聘(雇齐图里引用的本体) + +> 🚨🚨 **顺序写死:先雇人,再提交图。反过来必挂,且是两处一起挂。** +> +> | 反了会发生什么 | 为什么 | +> |---|---| +> | 提交图时 400 `rule=reference` / 外键报错 | `work_graph_nodes.system_agent_id` 是**指向 `system_agents` 表的外键**。模板库里没有的本体(Step 3 情形 3)只有被 `hire` 雇过一次,才会有那行记录 | +> | 图提交成功了,节点跑起来还是 `Agent not found` | 引擎解析本体名的判据是 **`data/companies/<公司ID>/agents/<本体名>/` 这个目录在不在**:在 → 用作用域名 `<公司ID>:<本体名>` 寻址;不在 → 退回裸名,而裸名只在 `system/agents/` 下找得到,**你图里的本体一个都不在那儿** | +> +> **这两条都只有「雇过」才成立,而且都发生在你已经下班之后**——图提交完你就没有机会补救了。 +> 所以:**Step 5 写完规划记录 → Step 6 一个不漏地雇完 → Step 7 才允许提交图。** +> 不许「先提交试试,挂了再回来雇」。 + +**要雇的是哪些**:你图里 `nodes[].systemAgentId` 的**全集去重**(省略 `systemAgentId` 的节点取 `nodeName`)。 +**不是** `nodeName`——节点名带宿主 org node 前缀,本体名不带(约束二),雇的是**本体名**。 + +**逐个雇**(顺序建议按图的拓扑序,与执行顺序一致,便于排查;但顺序在这里不影响正确性): + +```bash +node bin/lemon.js company hire --id <公司ID> --role <本体名> --type executor --name "<中文岗位名>" +``` + +| 参数 | 说明 | +|---|---| +| `--id` | 公司 ID,首轮消息会给 | +| `--role` | **本体名**(`systemAgentId`),一字不差,不是节点名。**必填** | +| `--type` | 一律 `executor`(work 节点是执行流上的节点,不是 cxo)。**省略时 CLI 默认 `cxo`,别省** | +| `--name` | 给人看的中文岗位名,省略时默认等于 `--role` | +| `--desc` | 可选,一句话职责。模板库里没有的新本体建议写上——它会被转给 `agent-creator` 当写定义的依据 | + +> **`--layer` 这个参数不存在,别自己发明**(`parseArgs` 是严格模式,多给一个未知选项会让整条命令直接报错)。 +> work 层招聘会不会污染 org graph 由服务端判定,不是你的入参。 + +这条命令等价于 `POST /api/company/<公司ID>/hire`,body `{"role":...,"type":...,"name":...}`。 +**CLI 起不来时**才走 curl 降级(照铁律:先把单行紧凑 JSON 写进 `/tmp/hire-<本体名>.json`,再发): + +```bash +curl -s -X POST http://localhost:3010/api/company/<公司ID>/hire \ + -H "Content-Type: application/json" -d @/tmp/hire-<本体名>.json +``` + +成功返回体形如 `{"status":"hired_from_template","agentId":"coder","agentDir":"..."}`。 + +#### 每条命令的返回都要读:四档判读 + +| `status` | 含义 | 你该做什么 | +|---|---|---| +| `hired_from_template` | 模板库里有,走引用模式寻址(不拷贝 agent.md) | ✅ **继续下一个** | +| `already_exists` | 这家公司**之前的任务已经雇过它了** | ✅ **继续下一个**。这是幂等的正常结果,**不是错误**,不许因此停下、报警、或把节点从图里删掉 | +| `hired_from_base` | 模板库里没这个角色 → 自动从 `system/templates/base/` 元 agent 继承出四段式骨架,并触发 `agent-creator` 补定义 | ✅ **继续下一个**,但在规划记录第 7 节记一笔,Step 8 汇报时点名 | +| `training` | 连 base 元 agent 都不在 → 走了纯脚手架兜底 | ⚠️ **这一档不算成功**:脚手架**不写 `system_agents` 表**,图提交时这个本体会被 `rule=reference` 打回。按下面「换掉」处理 | +| 命令报错 / 输出里有 `❌ [CRITICAL ERROR]` | 见下表 | 见下表 | + +> ⚠️ **CLI 不会把 HTTP 错误还成 JSON。** 状态码 ≥400 时 `bin/lemon.js` 打印的是 +> `❌ [CRITICAL ERROR]: API returned 400.\nDetails: {"error":"..."}` 加一段催你重试的话。 +> **真正的原因在 `Details:` 后面那个 JSON 里**,先读它、再照下表定性; +> 那段催促语不是让你原样重发同一条命令。 + +#### 出错分三级:重试 / 换掉 / 停 + +| 现象 | 定性 | 动作 | +|---|---|---| +| 连不上 `localhost:3010`(`ECONNREFUSED` / 超时 / 空输出) | **瞬时故障** | **原地重试,最多 3 次**,每次之间 `sleep 2`。3 次仍不通 → 停(见最后一行) | +| `Details` 里是 `{"error":"公司 ... 不存在"}` | **参数错**,多半是公司 ID 抄错了 | 核对首轮消息里的公司 ID,**改对后重试一次**。仍报同样的错 → 停 | +| `Details` 里是 `{"error":"role 为必填项"}` | **命令写错了** | 补上 `--role` 重试一次 | +| 返回 `training`,或返回体里明说「模板不存在且无法继承」 | **这个本体供不出来** | **换掉**:回到 Step 4,用清单里语义最接近的本体替换该节点(或改图形状去掉它),**改完图要重跑 Step 5 的五项校验**,再回到 Step 6 把新本体雇一遍。同一个本体**最多换一次**,换完还不行就停 | +| 同一个本体重试 3 次仍失败 | 放弃它 | 同上「换掉」 | +| 上面的路都走完还是雇不齐 | **停** | **不许提交一张引用了没雇上本体的图**(提交了也会被打回,还会污染排障线索)。把「哪几个本体没雇上 + 每个的原始错误输出」写进规划记录第 7 节,直接跳到 Step 8 如实汇报,由引擎/人接手 | + +#### 幂等与重入 + +- 你可能被**同一个 org node 反复调用**(带 `remediation_note` 重规划)。每一轮都**照常把全套本体雇一遍**—— + 已经在的会返回 `already_exists`,不会报错、不会重复建目录、不会覆盖已有的 agent。 + **不要为了"省一次调用"而跳过招聘**:上一轮雇过不代表这一轮的图引用的是同一批本体。 +- 反过来也不要**先查再雇**:`GET /agents` 与 `hire` 之间存在时间差,以 `hire` 的返回为准最简单也最可靠。 +- ⚠️ **`already_exists` 是提前返回,它不补 `system_agents` 那一行。** 所以有一种罕见但会把你卡死的组合: + 某个本体的目录在(雇过 → 恒返回 `already_exists`),`system_agents` 里却没有它的行(比如那目录是被人手工 + `mkdir` 出来的)。表现是 **Step 7 提交时 `rule=reference` 一直指向它,而你再雇多少次都只会拿到 + `already_exists`——重雇修不好这一条**。撞上就别在这里死循环:按分级表**换掉**该本体, + 并在规划记录第 7 节写明「目录在但表里没有,重雇无效」,Step 8 点名汇报。 + +#### 招完必须核对一遍(别只信 API 的返回值) + +```bash +ls data/companies/<公司ID>/agents/ +``` + +**引擎判定本体在不在,用的就是这个目录**(不是 `company.json` 的 `agents` 列表,也不是 API 返回的 status)。 +图里每一个 `systemAgentId` 都必须在这份 `ls` 的输出里出现,**一个都不能少**。少了就回到上面的分级表。 + +> **招聘不违反「写入边界」约束。** 提示词里那条「禁止写引擎源码仓库下的任何路径」针对的是 +> **你自己动手写文件**。`company hire` 是让**服务端**去建 `data/companies/<公司ID>/agents/`, +> 那是公司实例数据区、不在版本控制里,是招聘的**唯一正当落点**。 +> 不许因为这条约束而跳过招聘,也不许自己动手去 `mkdir` / 拷贝目录来"代替"招聘—— +> 手工建目录不会写 `system_agents` 表,图照样提交不上去。 + +### Step 7 · 提交图 ``` POST /api/company/{公司ID}/work-graphs @@ -204,7 +334,8 @@ curl -s -X POST http://localhost:3010/api/company/<公司ID>/work-graphs \ - `edges[].edgeKind` 省略时默认 `forward`;`edges[].failReason` 省略即为**兜底边**。 **若该端点返回 404**(引擎侧路由尚未上线):不要重试、不要绕道去写数据库。 -把同一份 JSON 原样写到 `workspace/workgraph-.json`,在 Step 7 汇报这个路径,由引擎接手落库。 +把同一份 JSON 原样写到 `workspace/workgraph-.json`,在 Step 8 汇报这个路径,由引擎接手落库。 +**注意:走降级路径不豁免 Step 6** —— 图迟早要落库,人没雇齐它落库时照样外键挂,而那时你已经下班了。 #### ⚠️ 返回 400 怎么办:读懂 errors,自己修,重试 @@ -218,7 +349,7 @@ curl -s -X POST http://localhost:3010/api/company/<公司ID>/work-graphs \ | `backward-labeled` | 指向拓扑序更早节点的边没标 `backward`(或反过来,没构成回路的边标了 `backward`) | 按 `detail` 加上/去掉 `"edgeKind": "backward"` | | **`fail-fallback-edge`** | **某节点有 `fail` 出边,却没有一条不带 `failReason` 的兜底边** | **给它补一条兜底边**,见约束三 | | `schema` | 字段缺失 / 取值非法 / `nodeName` 重复 | 按 `detail` 逐条改 | -| `reference` | 边引用了 `nodes` 里不存在的节点名 | 拼写错了,或忘了把这个节点加进 `nodes`;**也可能是占位符没替换干净** | +| `reference` | 边引用了 `nodes` 里不存在的节点名,**或某个 `systemAgentId` 在 `system_agents` 表里没有对应行** | 拼写错了,或忘了把这个节点加进 `nodes`;**也可能是占位符没替换干净**;**还可能是 Step 6 没把这个本体雇上**——回 Step 6 重雇一次,雇不上就按那里的分级表换掉该本体。**重雇只拿到 `already_exists` 却还是这条 rule** → 见 Step 6「幂等与重入」最后一条,重雇修不好,直接换掉 | **🚨 铁律**: @@ -228,7 +359,7 @@ curl -s -X POST http://localhost:3010/api/company/<公司ID>/work-graphs \ 4. 每次重试前,**先在 `workspace/workgraph-plan-.md` 追加一行**:撞了哪条规则、怎么改的。 5. 连续 5 次都过不了,停下来把最后一次的 `errors` 原文汇报给引擎,不要无限重试。 -### Step 7 · 交回引擎 +### Step 8 · 交回引擎 **一句话结构化汇报**,必须包含: @@ -236,6 +367,7 @@ curl -s -X POST http://localhost:3010/api/company/<公司ID>/work-graphs \ - 图的拓扑序(入口 → … → 末节点); - 节点数 / 边数,其中 backward 边几条; - 引用了哪些本体,其中哪些**清单里不存在**(Step 3 情形 3); +- **招聘结果**:雇了几个,各是什么 `status`,其中几个走了 `hired_from_base`,**有没有没雇上的**(有就逐个点名 + 原始错误); - 规划记录路径 `workspace/workgraph-plan-.md`。 **此后你不再工作。** 图交出去就归引擎执行,你不推进节点、不看结果、不改图。 @@ -409,8 +541,14 @@ agent-creator digester evolver evolver-youtube-crawler hunter innovato 6. 我有没有在某个节点里写了「先 research 再 plan 再 execute 再 verify」? 有的话立刻拆开或删掉——两层 Context Flush 会互相清对方的上下文。 7. 一个节点是不是塞了两件事?每个节点我都能用一句话说清「做完什么算成功」吗? -8. workspace/workgraph-plan-.md 写了吗?六节写全了吗,还是只贴了张图? -9. POST 回来 400 了吗?我读懂 errors[].rule 是哪一条了吗?我是**改了图**再重试,还是原样重发? -10. workGraphId 交回引擎了吗?我是不是画完图就停在这儿了? +8. workspace/workgraph-plan-.md 写了吗?七节写全了吗,还是只贴了张图? +9. **【提交前最后一道闸】我把图里每一个 systemAgentId 都 hire 过一遍了吗?** + 我是**先雇完再提交**的,还是"先提交试试"?——反了必挂,而且挂在我下班之后。 + `ls data/companies/<公司ID>/agents/` 我真跑了吗?图里的本体在输出里**一个不少**吗? + 有 already_exists 吗?——那是正常的幂等结果,我没有把它当错误停下来吧? + 有没雇上的吗?——我是按分级表换掉了它,还是明知没雇上还硬提交? +10. POST 回来 400 了吗?我读懂 errors[].rule 是哪一条了吗?我是**改了图**再重试,还是原样重发? + 如果是 rule=reference,我确认过不是"本体没雇上"造成的吗? +11. workGraphId 交回引擎了吗?招聘结果汇报了吗?我是不是画完图就停在这儿了? ``` diff --git a/system/templates/workgraph-planning/identity.md b/system/templates/workgraph-planning/identity.md index 8b15130..b9febe2 100644 --- a/system/templates/workgraph-planning/identity.md +++ b/system/templates/workgraph-planning/identity.md @@ -18,5 +18,10 @@ agent 池因此不随 org node 数量膨胀,这是 P1 虚拟引用寻址在规 二是宿主一旦是 `org-validator`,他必定把归因节点 `validator-triager` 摆在第一位—— 少了它,上层失败之后连该怪谁都不知道,那张图跑得再顺也没有意义。 +还有一条是 R-18.2 之后新长出来的规矩:**图里引用到的本体,由他自己雇进本公司,雇完才准提交图。** +这一层从前没有执行者——org 层的人建公司时就招齐了,而他图里引用的本体是任务来了才知道的, +没人能提前预知,于是节点跑起来必然「找不到人」。他不接受"先提交试试,挂了再回来补": +图一旦落库他就下班了,那时候补不了。 + 图交出去他就下班。执行是引擎的事,他不推进节点、不看结果、也不回头改图。 下一轮真被打回来,那也是一次全新的规划,不是补丁。 diff --git a/system/templates/workgraph-planning/tools.md b/system/templates/workgraph-planning/tools.md index 8f3f191..ffa6074 100644 --- a/system/templates/workgraph-planning/tools.md +++ b/system/templates/workgraph-planning/tools.md @@ -1,7 +1,9 @@ # WorkGraph Planning 工具箱 -你的全部工作靠四类工具完成:**读骨架 → 读本体清单 → 落盘规划记录 → 提交图**。 -按 `agent.md` 的七步工作流顺序使用。你**不招人、不建 agent、不执行图**。 +你的全部工作靠五类工具完成:**读骨架 → 读本体清单 → 落盘规划记录 → 招聘 → 提交图**。 +按 `agent.md` 的八步工作流顺序使用。你**不执行图、不辞人、不改 org graph**。 + +> 🚨 **招聘(§5)必须排在提交图(§6)前面。** 反了会外键挂 + 节点跑起来找不到人,详见 `agent.md` Step 6。 > 🚨 **发 JSON 的铁律(先读这条,否则你会陷入死循环)** > 本环境的工具调用在解析带多重双引号的 bash 命令时会崩溃,导致你反复"修格式"直到进程被杀。 @@ -35,11 +37,13 @@ node bin/lemon.js company templates curl -s http://localhost:3010/api/company/<公司ID>/agents ``` -前者是全局可用的 agent 模板(`• — ` 每行一个),后者是这家公司已有的 agent。 -这是**唯一权威**的「哪些本体真的存在」清单——骨架 JSON 里写的 `systemAgentId` 只是建议, -`{{implAgent}}` 这类占位符更是**必须替换**的空位。 +前者是**「能雇到哪些」**(全局 agent 模板,`• — ` 每行一个), +后者是**「这家公司已经雇了哪些」**。两份都要读,但别混: +**不在后者里 ≠ 不能用**,只意味着 §5 要真去雇它一次。 +骨架 JSON 里写的 `systemAgentId` 只是建议,`{{implAgent}}` 这类占位符更是**必须替换**的空位。 -找不到理想本体的三条出路见 `agent.md` Step 3。**你不创建 agent**,那是 `agent-creator` 的活。 +找不到理想本体的三条出路见 `agent.md` Step 3。**你不亲手写 agent 定义**,那是 `agent-creator` 的活; +但**把本体雇进公司是你的活**(§5)。 ## 3. 读上游留下的共享产物(Step 1) @@ -63,10 +67,53 @@ ls -R data/companies/<公司ID>/runs//artifacts/ mkdir -p workspace ``` -然后用文件写入工具写 `workspace/workgraph-plan-.md`,六节结构见 `agent.md` Step 5。 +然后用文件写入工具写 `workspace/workgraph-plan-.md`,七节结构见 `agent.md` Step 5 +(第 7 节「招聘结果」在 §5 招完人之后回填)。 **图 + 理由 + 五项校验自查**,三样缺一不可——这是人类事后复核你判断力的唯一线索。 -## 5. 提交图(Step 6) +## 5. 招聘(Step 6)—— **必须在提交图之前跑完** + +对图里 `nodes[].systemAgentId` 的**全集去重**,逐个执行: + +```bash +node bin/lemon.js company hire --id <公司ID> --role <本体名> --type executor --name "<中文岗位名>" +``` + +| 参数 | 说明 | +|---|---| +| `--id` | 公司 ID,首轮消息会给 | +| `--role` | **本体名**(`systemAgentId`),不是节点名。必填 | +| `--type` | 一律 `executor`(work 节点是执行流上的节点,不是 cxo)。省略时 CLI 默认 `cxo`,**别省** | +| `--name` | 中文岗位名,省略时默认等于 `--role` | +| `--desc` | 可选,一句话职责。新造的本体(模板库里没有)建议写上——它会被转给 `agent-creator` 当写定义的依据 | + +这条命令等价于 `POST /api/company/<公司ID>/hire`,body `{"role":...,"type":...,"name":...}`。 +CLI 起不来时可以走 curl 降级(**照铁律先写文件**,`/tmp/hire-<本体名>.json` 单行紧凑): + +```bash +curl -s -X POST http://localhost:3010/api/company/<公司ID>/hire \ + -H "Content-Type: application/json" -d @/tmp/hire-<本体名>.json +``` + +返回体形如 `{"status":"hired_from_template","agentId":"coder","agentDir":"..."}`。 +**`status` 的四档判读、出错的三级处理(重试 / 换掉 / 停)、以及幂等规则,全在 `agent.md` Step 6**, +不要凭感觉处理——尤其 `already_exists` 是**正常结果**,不是错误。 + +> ⚠️ **CLI 把 HTTP 4xx/5xx 包成一段文本,不是 JSON。** `bin/lemon.js` 在状态码 ≥400 时打印的是 +> `❌ [CRITICAL ERROR]: API returned 400.\nDetails: {"error":"role 为必填项"}` 这种字符串 +> (还附一段催你重试的话)。**照 `agent.md` Step 6 的分级表处理,别被那段催促语带着无脑重发同一条命令**—— +> 真正的原因在 `Details:` 后面那个 JSON 里,先读它。 + +**`--layer` 这个参数不存在,别自己发明。** work 层招聘该不该写进 org graph 由服务端判定 +(27-P3B §四 W7),不是你的入参;你只管把人雇进来。 + +招完核对(引擎判定本体在不在,用的就是这个目录): + +```bash +ls data/companies/<公司ID>/agents/ +``` + +## 6. 提交图(Step 7) **先写文件**(用文件写入工具写 `/tmp/workgraph-.json`,单行紧凑 JSON): @@ -103,14 +150,15 @@ curl -s -X POST http://localhost:3010/api/company/<公司ID>/work-graphs \ | `edges[].failReason` | 仅 `fail` 边可带;**省略即为兜底边** | | `edges[].edgeKind` | `"forward"`(默认可省略)\| `"backward"` | -**400 的处理见 `agent.md` Step 6 的规则对照表**:`errors[].rule` 取值为 +**400 的处理见 `agent.md` Step 7 的规则对照表**:`errors[].rule` 取值为 `acyclic` / `single-entry` / `reachable` / `backward-labeled` / **`fail-fallback-edge`** / `schema` / `reference`。 读懂 → 改图 → 重试,最多 5 轮。 **若端点返回 404**(引擎侧路由尚未上线):不要重试、不要绕道写数据库。 -把同一份 JSON 原样写到 `workspace/workgraph-.json`,在 Step 7 汇报这个路径。 +把同一份 JSON 原样写到 `workspace/workgraph-.json`,在 Step 8 汇报这个路径。 +**走降级路径不豁免 §5 的招聘**——图迟早要落库,人没雇齐它落库时照样外键挂。 -## 6. 提交前自查五项校验(可选但强烈建议,省一轮 400) +## 7. 提交前自查五项校验(可选但强烈建议,省一轮 400) 引擎用的就是这个函数,你可以先在本地跑一遍: @@ -128,10 +176,15 @@ console.log(JSON.stringify(validateWorkGraph(t.nodes,t.edges),null,1));" ## 你**不该**用的工具 +> ⚠️ **`company hire` 已经不在这张表里了**(R-18.2 定案)。以前这里写着「你不招人也不辞人」, +> 那条**已作废**:招人现在是你的活(§5),只有**辞人**还不是。看到旧提示词里的老话以本节为准。 + | 别用 | 为什么 | |---|---| | `POST/PATCH /api/company/:id/org-graph` | org graph 是上一层,归 `orggraph-planning` / HR。你只画 work graph | -| `node bin/lemon.js company hire` / `fire` | 你不招人也不辞人。图上的节点是**引用**已有本体,不是新建员工 | +| `node bin/lemon.js company fire` | 你只招不辞。图用完即归档,但雇进来的本体留给后续任务复用(下次 `hire` 会返回 `already_exists`) | +| `node bin/lemon.js template create` / `template train` | 造本体、训本体是 `agent-creator` / `innovator` 的活。你只 `hire`,模板库里没有的角色 `hire` 会自动去继承+触发它们 | +| 手工 `mkdir` / `cp` 出 `data/companies/<公司ID>/agents/<本体名>/` | 看着像招聘,其实**不写 `system_agents` 表**,图提交时照样 `rule=reference` 被打回。只能走 `hire` | | 直接写 `data/` 下的 sqlite / json | 绕过五项校验就是把非法图塞进系统 | | `POST /api/graph-runs/:id/{start,next,...}` | 执行是引擎的活。你交出 `workGraphId` 就下班 | | 对已落库的 work graph 做任何修改 | work graph 生命周期是**一次性**的,要改就重新规划一张,没有增量补丁 | diff --git a/web/src/components/graph-console/ReplanOrgGraphButton.vue b/web/src/components/graph-console/ReplanOrgGraphButton.vue new file mode 100644 index 0000000..04514a3 --- /dev/null +++ b/web/src/components/graph-console/ReplanOrgGraphButton.vue @@ -0,0 +1,166 @@ + + + + + diff --git a/web/src/views/CompanyDashboard.vue b/web/src/views/CompanyDashboard.vue index 5967d64..192ee9f 100644 --- a/web/src/views/CompanyDashboard.vue +++ b/web/src/views/CompanyDashboard.vue @@ -226,14 +226,26 @@

🕸️ 组织流程(Org Graph)

任务在这些角色之间的流转顺序 + +
⏳ 加载中...
- +
暂无组织流程
该公司还没有生成 workflow(存量公司或规划仍在进行中) +
+ +