diff --git a/docs/todo/system-design/27-p4-parallel-and-artifacts.md b/docs/todo/system-design/27-p4-parallel-and-artifacts.md index 5a638d9..97f3d1d 100644 --- a/docs/todo/system-design/27-p4-parallel-and-artifacts.md +++ b/docs/todo/system-design/27-p4-parallel-and-artifacts.md @@ -342,8 +342,10 @@ CREATE TABLE IF NOT EXISTS graph_edge_contracts ( 「声明要吃 X 但没人产 X」永远自洽 —— 那条校验会退化成一条永不报错的空跑规则。 `required:false` 是显式逃生口:产出方不在本图(`acceptance` 由上一层 org graph / `artifacts/` 给)时用它。 -**尚未接通的一环**:`system/work-graph-templates/*.json` 四张骨架的边**还没有** `produces` 声明, -所以「照抄骨架」得到的图不传数据,全靠规划 agent 自己按约束九补。骨架补声明是下一步。 +**~~尚未接通的一环~~(已接通,见下面第六次真跑)**:`system/work-graph-templates/*.json` 四张骨架的边 +原先**没有** `produces` 声明,所以「照抄骨架」得到的图不传数据,全靠规划 agent 自己按约束九补。 +四张骨架现已补齐 `produces`/`needs`(backward 边一条都没动),第六次真跑实测规划 agent +会把骨架上的声明连同结构一起改编下来 —— 见「第六次真 CLI 冒烟」验收点⑤。 #### 第五次真 CLI 冒烟:第四次那个断点复验通过(2026-08-17,未改一行代码) @@ -373,6 +375,86 @@ CREATE TABLE IF NOT EXISTS graph_edge_contracts ( 所以真跑后 `graph_edge_contracts` 里 `edge_layer='org'` **0 行**。 org 层这一半仍是「实现在、无真实调用方」。 +#### 第六次真 CLI 冒烟:org 层四环闭合(2026-08-17) + +**跑法**:worktree `wf_9fbefcf9-79e-5`,真 CLI 服务 3010(`data/` 是 worktree 自己的 —— +`company-service.js:18` 与 `graph-db.js:34` 都从 `__dirname/../../data` 派生,所以生产 +`data/graph.sqlite` 结构上碰不到),`LEMONBOSS_DISABLE_WATCHDOG=1`(启动日志有 +`[Watchdog] ⏸️ 已由 LEMONBOSS_DISABLE_WATCHDOG=1 关闭`)。公司 `smoke6`,org run +`run_5274dad6b10c` 手动模式,子 run `run_0691b85eb7fc`。 +**被测版本 = `2b77f8c` + 工作树里未提交的四份改动**(org 层读回方 `describeOrgContracts`、 +`orggraph-planning` 约束五、`workgraph-planning` 去死端点、四张骨架补 `produces`/`needs`)。 + +**证据一律取 Claude CLI 自己的 transcript 原文**(`~/.claude/projects//*.jsonl` +里第一条 user 消息 = 引擎真的发出去的那段字),不用 timeline 推断。 + +**单测基线**:`npm run test:unit` 在 worktree 里 **837 passed / 6 skipped,83 个文件中 82 通过**。 +唯一失败的 `server/tests/integration/graph-agent-p1-acceptance.test.js` 是**环境性失败、不是回归**: +它在 `:75` 断言 `data/companies/idearun/agents/evolver-youtube-crawler/agent.md` 存在, +而 `data/companies/` 被 `.gitignore` 忽略,**全新 worktree 里结构上不可能有这份 fixture** +(该断言是作者刻意加的「别让测试空跑通过」保险)。对账:主树基线 829 passed / 80 文件, +本轮新增三个测试文件共 14 条,失败套件里 6 条没跑 → `829 + 14 − 6 = 837`,与实测**逐数吻合**, +即既有用例零回归。 + +| 验收点 | 结果 | +|---|---| +| **① org 层声明真的落库** | ✅ `graph_edge_contracts` 里 `edge_layer='org'` **4 行**(第五次真跑是 **0 行**)。边 1 `org-planner→org-coder`:`produces[prd]` + `produces[acceptance]`;边 2 `org-coder→org-validator`:`produces[artifact]` + **`needs[acceptance]`**。两条 backward 边(3/4)**零契约行** | +| **② org 契约进没进规划 agent 的提示词**(本轮核心) | ✅ **成立**。发给 `workgraph-planning` 的提示词(52 677 字节)第 746 行逐字出现 `## ③bis org-planner 在上一层图上的数据契约(引擎已解析,不要自己猜路径)`,其下「你这一层必须产出的东西(硬约束)」列出 `acceptance / prd` —— 正是 org 边 1 上声明的两个 kind。四环里的「读回」这一环闭合 | +| **③ 声明侧的语义质量** | ✅ 超出预期。`needs` 被用在**真正的隔节点中继**上:`org-validator` 要吃的 `acceptance` 产出方是 `org-planner`(非紧邻上游 `org-coder`),所以单独写 `needs`。规划记录 `workspace/orggraph-plan.md` 第 6 节「数据契约自查」表**填满了**,含 `data-contract` 规则预检、kind 取值域预检、以及「粒度:org 层比 work 层粗一档」自查 —— 约束五被完整吸收,不是照抄 | +| **④ 死端点** | ✅ agent **不再** curl `POST /api/company/:id/work-graphs`。全 transcript 里该字符串只出现在**提示词**里(两处,见下面的不对称②),agent 侧零调用。它按新 Step 7 把图写成项目根下的绝对路径 `projects/p-xdc09a/workgraph-T-msx3w8xm.json`(**没有**写 `workspace/` 那条与写入边界冲突的旧路径),并**自己 require 引擎的 `validateWorkGraph` 预检了一遍**才交回。落库一轮通过,`plan` 无 `rejected` | +| **⑤ 骨架 json 的声明起作用了吗** | ✅ 照抄并正确改编。宿主是 `org-planner` → 选 `planner.json`;任务是单文件 CLI 于是砍掉 `sdd` 节点,产出的四条 forward 边契约与骨架**同形**:`brd→prd produces[brd]`、`prd→benchmark produces[prd]`、`benchmark→validator produces[acceptance] + needs[brd,prd]`(骨架原文是 `needs[brd,prd,sdd]`,它跟着删掉了 `sdd` —— 说明是理解后改编,不是字面复制),三条 backward 边零声明。落库 `edge_layer='work'` **5 行** | +| **⑥ 回归:第五次那条已成立的链路** | ✅ **没被 `resolveInboundLines` 抽函数搞坏**。`org-planner-prd`(`prd-planning`)的真实提示词逐字复现第五次的形状:
`[上游输入 —— 引擎已按图上声明的数据契约解析好,直接读这些路径]`
`- brd:…/runs/run_5274dad6b10c/nodes/rn_9836aeb60827/brd.md`
`- contract(任务级共享):…/rn_9836aeb60827/contract.json`
**一个 ⚠️ 都没有**。行为侧也一致:prd agent 的头两个动作就是 `Read` 这两条绝对路径,随后判「BRD 完整且五问齐备,无需打回」,节点 **attempt 0 一次 success** | +| 产出侧 kind 认领 | ✅ `brd.md`→`kind=brd`、`prd.md`→`kind=prd`,两者都是**按出边 `produces` 声明**定的(不是文件名兜底);`contract.json`→`kind=contract` 按文件名 | + +**归因上的诚实交代**:⑤ 这一条无法把功劳单独归给 ③bis 还是骨架 —— `planner.json` 本身就在 +`benchmark→validator` 上写了 `produces[acceptance]`、在 `prd→sdd` 上写了 `produces[prd]`, +所以「acceptance 与 prd 都有节点产出」这个结果两条路径都能推出来,本轮是**同向叠加**, +不构成对 ③bis 的独立验证。③bis 的独立证据只有验收点②(那段文字确实进了提示词)。 +要把两者分开,得构造一个骨架覆盖不到的 kind(例如 org 边上声明 `produces[log]`)再看它补不补。 + +**跑法上踩到的一个坑(我自己造成的,写下来免得下一轮再踩)**: +第一次跑 `org-planner-brd` 时节点被判 `fail / fail_reason=boundary_escape` 而进 `blocked`, +**而 agent 本身一个越界写入都没有**。`node.boundary` 事件的 `entries` 只有一条: +`M docs/todo/system-design/27-p4-parallel-and-artifacts.md` —— 那是**我在节点跑的同时编辑本文件**。 +`applyBoundaryGuard`(`graph-engine.js:2829`)拍调用前后两次**全仓库** `git status`, +差集里的每一条都算当前节点的越界;`splitUnattributable`(:2807)的豁免名单只有 +`system/agents/*/{todo.md,plan.md,memory.md,_lessons.md,.claude/scheduled_tasks.json}` +(`AGENT_RUNTIME_STATE_RE`),文档、源码、任何别的并发写入都不在豁免内。 +**结论:真跑期间不要在同一个工作树里改任何被 git 跟踪的文件** —— 这条约束此前只写成 +「关掉 Master 巡检」,但巡检只是并发写入的一种,人工编辑同样会冤枉节点。 +(重试后 `attempt 1` 一次 success,所以这只是浪费一次调用,不影响上表任何结论; +但重试本身留下了一个副作用文件 `contract.prev.json`,它落成了 `kind=artifact` —— 见下面第 4 条。) + +**同轮暴露的四条不对称**(都不是本轮改动引入的,且都不在本轮可改文件内): + +1. **引擎提示词与新模板在 `wg_` 上直接对立,而且同处一条提示词里**。 + `workgraph-planning/agent.md` 第 329/361 行(进了提示词)说「别自己编 `wg_` id」 + 「**不许出现 `wg_` + 12 位十六进制**,引擎优先匹配这个形状并当成已落库的图 id,查不到就整轮作废」; + 而 `graph-engine.js:832` 拼出的第 775 行说「已经自行落库成功时,改为回一行 `workGraphId: wg_xxx`」。 + 本轮 agent 选择了听模板(没写 `wg_`),但这是运气而非保证。修法:删掉 `graph-engine.js` 那一行 + —— agent 永远不可能「自行落库成功」,那个分支对它来说只是陷阱。 +2. **同一处还留着「端点尚未上线」的措辞**(`graph-engine.js:829` → 提示词第 770 行), + 暗示将来会上线;新模板的口径是 `NO-SUCH-ENDPOINT — 从来没有实现过`。两边措辞不一致。 + 顺带一提,`graph-engine.js:832` 那个内联 JSON 示例的边是 + `{"from":"...","to":"...","condition":"success"}` —— **一个 `produces` 都没有**, + 与模板「forward 边基本都要写 `produces`」正相反。三处(829/832/835)都在同一段字符串数组里, + 一次改完最省事。 +3. **org 层 `produces` 在产出侧没有认领方**(已知上限,非回归):org 节点是 subgraph, + `deriveChildOutcome` 不回 `outputRef` → `output_ref` 恒 `null` → `indexNodeOutputs` 第一行就 return。 + org 边上的 `produces` 能不能真的查到实例,完全取决于**子 run 里的 work 节点**把文件登记成对的 kind + (`graph_files` 按 `(company_id, task_id)` 检索,子 run 继承同一个 `task_id`)。 + 本轮 org run 只推进到第一个 org node,**没有**验到第二个 org node 的入边把路径查出来 —— + ③bis 的「上游节点交给你的输入」那一半(`resolveInboundLines` 拼绝对路径)在真跑里**仍未被覆盖**, + 下一次真跑应当专门推到第二个 org node。 + *间接旁证*:`resolveInboundLines` 是 `describeInputs` 与 `describeOrgContracts` **共用的同一个函数** + (`graph-engine.js:3226`),验收点⑥已经证明它在真跑里能拼出正确绝对路径; + org 层缺的只是「这条 org 入边上确实有声明 + 索引里确实有实例」这个具体组合的现场,不是代码路径。 +4. **`contract.prev.json` 会被当成产物索引进去**(`kind=artifact`)。它是 `retryNode` 为算契约 hash + 留的快照(`retry` 响应里的 `contractSnapshot.file`),不是节点交付物。产出侧没把它排除, + 于是每次人工重试都会给 `graph_files` 添一行噪声。`validator.json` 那条「刻意零 produces」的 + 注释里已经写过同类道理(`attempt-{n}.json` 被排除在索引外),`contract.prev.json` 漏了。 + 修法:`indexNodeOutputs` 的排除名单加上它。 + #### 控制台读回端(R19-C:R-4「边的输入输出」的字面落地) 此前 [27-P3/P4](./27-p3-p4-work-graph-console.md) §四.B.3 第 2 个 tab 把 R-4 落成了 diff --git a/server/services/graph-engine.js b/server/services/graph-engine.js index f7087cb..4c11d34 100644 --- a/server/services/graph-engine.js +++ b/server/services/graph-engine.js @@ -801,6 +801,10 @@ function buildPlanningPrompt(ctx, { round = 1, errors = null, lastOutput = '' } '', '## ③ 可读的 artifacts/(root run 共享产物区,只读)', describeArtifactsDir(ctx.run), + // R-19 org 层契约的读回方。返回 null(没有任何契约行)时**整段连标题一起消失**, + // 靠下面那个 filter 滤掉 —— 编号用 ③bis 而不是插进 ①②③④,那四项是模板 Step 1 + // 「四项输入一项都不能跳」的原文清单,重编号会让模板与提示词对不上。 + describeOrgContracts(ctx), '', '## ④ 上一轮 remediation_note', ctx.remediationNote || '无,首次规划。', @@ -822,13 +826,21 @@ function buildPlanningPrompt(ctx, { round = 1, errors = null, lastOutput = '' } `- 节点引用的 systemAgentId 必须是**已存在的本体**;不存在的本体会被引擎以 rule=reference 打回。`, '', '## 交回方式(引擎读你回复的最后一段)', - '`POST /api/company/{公司ID}/work-graphs` 端点尚未上线,走模板 Step 6 的降级路径:', + // 措辞必须与 workgraph-planning/agent.md 的 NO-SUCH-ENDPOINT 一致:说「尚未上线」会被读成 + // 「将来会上线」,agent 就还会去试一次、吃一个 404、白烧一轮(前五次真跑每轮都在烧)。 + // 这个端点从来没有设计过,落库始终是引擎的活。 + '`POST /api/company/{公司ID}/work-graphs` **不存在、也不会上线** —— 落库是引擎的活:', '**把最终图直接贴在回复里的一个 ```json 代码块中**(或写成一个 .json 文件后给出它的绝对路径),形状:', '```json', + // 示例的边必须带 produces:这段是 agent 唯一见过的「一条边长什么样」, + // 示例里没有声明,它照抄出来的就是一张不传数据的图 —— 引擎自己的示例在教反面教材。 '{"nodes":[{"nodeName":"...","systemAgentId":"...","nodeKind":"agent_call"}],' - + '"edges":[{"from":"...","to":"...","condition":"success"}]}', + + '"edges":[{"from":"...","to":"...","condition":"success","produces":["brd"]}]}', '```', - '已经自行落库成功时,改为回一行 `workGraphId: wg_xxx`。', + // 刻意**不**再提「已落库时回一行 workGraphId」:没有任何受支持的落库途径, + // 所以这句话只会诱发它编一个 wg_ id;而 extractPlannedGraph 优先匹配那个形状、 + // 查库查不到就整轮作废(模板 agent.md:345 把这条列成硬禁令,两边对立时 agent 得赌)。 + // 引擎侧仍保留认真实 wg_ id 的分支,只是不再邀请它写。 ]; if (round > 1) { @@ -891,6 +903,86 @@ function describeArtifactsDir(run) { return out.join('\n'); } +/** 本节点**出边**上声明为 produces 的 kind(去重)。role 为 NULL 的历史行按 produces 读, + * 与 org-graph-service.readGraph 回读契约时同一个口径。backward 出边不算:引擎解析输入 + * 只看 forward 入边,写在回退边上的声明本就不生效(validateGraph 也拦了)。 */ +function outboundProducedKinds(layer, edges, defNodeId) { + const out = []; + for (const e of edges || []) { + if (String(e.from) !== String(defNodeId)) continue; + if ((e.edgeKind || 'forward') === 'backward') continue; + for (const c of edgeContracts(layer, e.id)) { + if ((c.role || 'produces') !== 'needs') out.push(c.kind); + } + } + return [...new Set(out)]; +} + +/** + * R-19 org 层:把 org 图上的数据契约递给 **workgraph-planning**(这就是 org 层契约的读回方)。 + * + * 为什么不能靠 describeInputs:它第一行就是 `ctx.nodeKind === 'subgraph'` 时返回空串, + * 而 readDefGraph 把 org 图的节点**全部**写成 nodeKind='subgraph'(work-graph-db.js + * 「org graph 的节点全部是 subgraph executor」)。两条合起来 = org 层的边契约在输入侧 + * 一个读回方都没有。先补模板去教 orggraph-planning 声明,产出的就是「声明了没人读」。 + * + * 语义也不该复用 describeInputs 的措辞。org 节点自己不干活,它派给规划本体的是**约束**: + * 入边声明的 kind → 你规划的子图的**入口节点**要吃这些(引擎已经把绝对路径查好) + * 出边声明的 kind → 你规划的子图**必须有节点产出**这些,否则下游 org node 拿不到输入 + * 这才是两层递归里 org 层约束 work 层的方式。 + * + * 拼不出来(没有契约行 / def 读不出来)时返回 **null**,整段连标题一起省略: + * 留一个空标题会被 agent 读成「引擎查过了,确实没有」,而真相是「引擎没查」。 + * + * ponytail: 这里自己再读一次 def(defIndex)而不是让 advanceOne 把 def 塞进 ctx —— + * 代价是每一轮规划多一次定义层查询(org 图是个位数节点,可忽略),换来的是 ctx 形状不变: + * ctx 会原样传给 setAgentExecutor 注入的外部实现,往里塞一个含 Map 的字段是更大的面。 + * 规划轮数一多(PLAN_MAX_ROUNDS=3)也就三次;真要省,把 def 提到 executeSubgraph 里传参。 + */ +function describeOrgContracts(ctx) { + if (!ctx || !ctx.run) return null; + let inboundLines = []; + let produced = []; + try { + const layer = ctx.run.graph_kind === 'org' ? 'org' : 'work'; + const edges = defIndex(ctx.run).edges || []; + inboundLines = resolveInboundLines(ctx, inboundContracts(layer, edges, ctx.defNodeId)); + produced = outboundProducedKinds(layer, edges, ctx.defNodeId); + } catch { + return null; // 索引/定义层读不出来不该拖垮规划调用 + } + if (!inboundLines.length && !produced.length) return null; + + const lines = ['', `## ③bis ${ctx.nodeName} 在上一层图上的数据契约(引擎已解析,不要自己猜路径)`]; + if (inboundLines.length) { + lines.push( + '', + '### 上游节点交给你的输入', + ...inboundLines, + '标 ⚠️ 的表示上一层图上声明了这份输入、但产物索引里还没有实例:先确认是不是上游还没跑完,', + '不要自己去别的节点私有目录里翻。', + '**这些路径要落到你规划的子图的入口节点手上** —— 引擎不会自动把它们拼进那个节点的提示词,', + '你要写进入口节点的任务描述里。', + ); + } + if (produced.length) { + lines.push( + '', + '### 你这一层必须产出的东西(硬约束)', + `上一层图上你的出边声明了这些 kind,下游节点就是按它们去产物索引里找输入的:${produced.join(' / ')}`, + '**你规划的子图里必须有节点真的产出上面每一个 kind**,一个都不能少。', + '少一个的后果不是现在报错,而是下游节点跑到那一步说「我拿不到输入」—— 那时整个子图的预算已经烧掉了。', + '落地方式(两条都要做到位才算产出):', + ` ① 在产出它的那条 work 边上写 \`"produces":["${produced[0]}"]\`(引擎按出边声明给文件定 kind);`, + // 出口节点没有出边 → 它的 indexNodeOutputs 拿不到任何声明,只能按文件名认 kind。 + // 这条不写清楚的话,org 层最后一个 kind 恰好就是最容易掉的那个。 + ' ② 出口节点没有出边、写不了 produces 时,把**文件名**直接起成 kind 本身' + + `(引擎只看去掉扩展名的那一段,认得出的是 ${KNOWN_FILE_KINDS.join(' / ')})。`, + ); + } + return lines.join('\n'); +} + /** * 从 agent 的回复里取出图。三种形态按优先级: * ① `workGraphId: wg_xxx`(它自己 POST 成功了) @@ -3070,7 +3162,17 @@ function indexNodeOutputs(run, def, nodeRun) { } if (!files.length) return; - // 出边声明的 kind:优先用它给文件定 kind + // 出边声明的 kind:优先用它给文件定 kind。 + // + // **只收 role='produces'**:出边上的 needs 说的是「下游要吃这个 kind,而产出方不是紧邻上游」 + // (中继声明,写在消费方入边上 —— 那与本节点的出边是同一条边),它不是本节点的产出。 + // 混进来会坏两件事: + // ① 认领作废:p-prd 的出边写 produces[prd]+needs[brd] → 未认领 kind 数 2 ≠ 1 → + // 下面那条「一对一无歧义」的兜底不成立,文件退成 artifact,下游查不到实例。 + // ② 更坏的错标:文件名里带 brd(如 brd_notes.md)会命中中继的 needs[brd], + // 把它错标成 brd —— 错的 kind 让下游读到一份不是它要的文件,比 artifact 查不到更坏。 + // role 列是随 needs/produces 一起落库的(work-graph-db 的 graph_edge_contracts), + // 这里正是它存在的理由;旧库迁移行回填成 produces,所以缺省当 produces 处理。 let declared = []; try { const layer = run.graph_kind === 'org' ? 'org' : 'work'; @@ -3078,7 +3180,9 @@ function indexNodeOutputs(run, def, nodeRun) { (e) => String(e.from) === String(nodeRun.def_node_id) && (e.edgeKind || 'forward') !== 'backward' ); for (const e of outbound) { - for (const c of edgeContracts(layer, e.id)) declared.push(c.kind); + for (const c of edgeContracts(layer, e.id)) { + if ((c.role || 'produces') !== 'needs') declared.push(c.kind); + } } declared = [...new Set(declared)]; } catch { /* 契约查不到就只靠文件名 */ } @@ -3086,8 +3190,13 @@ function indexNodeOutputs(run, def, nodeRun) { const companyRoot = companyRootDir(run.company_id); // ── 第一遍:按文件名认领 kind - // attempt-*.json 是引擎自己读的归因文件(readNodeProduct),不是给下游的产物 - const candidates = files.filter((f) => !/^attempt-\d+\.json$/.test(f)); + // 引擎自己的簿记文件不是节点交付物,不进索引: + // attempt-*.json 归因文件(readNodeProduct 读它) + // contract.prev.json retryNode 为算 contract_hash 留的上一轮快照(CONTRACT_SNAPSHOT_FILE) + // 漏掉后者的话,每次人工重试都给 graph_files 添一行 kind=artifact 的噪声。 + const candidates = files.filter( + (f) => !/^attempt-\d+\.json$/.test(f) && f !== CONTRACT_SNAPSHOT_FILE + ); const assigned = candidates.map((f) => { const base = f.replace(/\.[^.]+$/, '').toLowerCase(); // 声明优先:出边说会产 brd,且文件名里就有 brd → 定 brd @@ -3132,6 +3241,31 @@ function indexNodeOutputs(run, def, nodeRun) { } /** + * 入边声明的 kind → 产物索引里的绝对路径,一个 kind 一行。 + * + * 抽出来是为了让**执行侧**(describeInputs,agent_call 节点)与**规划侧** + * (describeOrgContracts,subgraph 节点交给 workgraph-planning 的那份提示词) + * 走同一段代码:三种口径(查得到给绝对路径 / required 查不到给 ⚠️ / + * 可选查不到整行省略)就不可能在两处漂移 —— 复制一遍再各自演化正是本轮在防的那类断链。 + */ +function resolveInboundLines(ctx, contracts) { + const declared = [...new Set(contracts.map((c) => c.kind))]; + if (!declared.length) return []; + const found = findFiles({ companyId: ctx.companyId, taskId: ctx.run.task_id, kinds: declared }); + const byKind = new Map(); + for (const f of found) byKind.set(f.kind, f); + const root = companyRootDir(ctx.companyId); + const lines = []; + for (const kind of declared) { + const hit = byKind.get(kind); + const required = contracts.some((c) => c.kind === kind && c.required); + if (hit) lines.push(`- ${kind}:${path.join(root, hit.rel_path)}`); + else if (required) lines.push(`- ${kind}:⚠️ 上游声明会产出,但索引里查不到实例`); + } + return lines; +} + +/** * 「这个节点需要什么输入、在哪」—— 两类数据流都查(R-19.3 定案①): * ① 沿边传递:入边上声明的 kind(受拓扑相邻性限制) * ② 任务级共享:本 task 下的 acceptance / contract 这类,**不受相邻性限制** @@ -3158,20 +3292,8 @@ function describeInputs(ctx, def) { } const declared = [...new Set(contracts.map((c) => c.kind))]; - const lines = []; + const lines = resolveInboundLines(ctx, contracts); - if (declared.length) { - const found = findFiles({ companyId: ctx.companyId, taskId: ctx.run.task_id, kinds: declared }); - const byKind = new Map(); - for (const f of found) byKind.set(f.kind, f); - const root = companyRootDir(ctx.companyId); - for (const kind of declared) { - const hit = byKind.get(kind); - const required = contracts.some((c) => c.kind === kind && c.required); - if (hit) lines.push(`- ${kind}:${path.join(root, hit.rel_path)}`); - else if (required) lines.push(`- ${kind}:⚠️ 上游声明会产出,但索引里查不到实例`); - } - } // 任务级共享按 kind 去重,只列最新的那一份(findFiles 是 created_at 升序,所以后写的覆盖前写的 // —— 与上面沿边那一半的 byKind.set 同一个口径)。 // diff --git a/server/tests/unit/graph-files-index.test.js b/server/tests/unit/graph-files-index.test.js index 5c6431c..d93eed5 100644 --- a/server/tests/unit/graph-files-index.test.js +++ b/server/tests/unit/graph-files-index.test.js @@ -427,4 +427,82 @@ describe('R-19 产物索引:graph_files / graph_edge_contracts', () => { expect(listed[0]).toContain(path.join(companyRoot(companyId), rows[1].rel_path)); expect(listed[0]).not.toContain(rows[0].rel_path); }); + + // ── ⑨ 出边上的 needs 不算本节点的产出 ────────────────────────── + // + // Chrome 走查里实测到的:p-prd 的出边写 produces['prd'] + needs['brd'](中继声明), + // 收集「本节点产出哪些 kind」时若不按 role 过滤,未认领 kind 数就是 2 而不是 1, + // ⑦ 那条一对一认领当场失效 —— 文件退成 artifact、下游拿不到。 + + it('⑨ 出边同时有 produces 与 needs:认领只看 produces,中继的 needs 不参与', async () => { + const companyId = 'co-files-role'; + const taskId = 'task-role'; + const b = buildRun({ + companyId, taskId, + nodes: [{ nodeName: 'w-brd' }, { nodeName: 'w-prd' }, { nodeName: 'w-val' }], + edges: [ + { from: 'w-brd', to: 'w-prd', condition: 'success' }, + { from: 'w-prd', to: 'w-val', condition: 'success' }, + ], + }); + // w-prd 的出边:它产 prd;同时声明 w-val 还要吃 brd(产出方是 w-brd,不是紧邻上游) + const e2 = b.edgeId('w-prd', 'w-val'); + wdb.declareEdgeContract({ edgeLayer: 'work', edgeId: e2, kind: 'prd', role: 'produces' }); + wdb.declareEdgeContract({ edgeLayer: 'work', edgeId: e2, kind: 'brd', role: 'needs' }); + + await advance(b.runId, 2, { + writes: { + // 文件名认不出 kind → 只能靠声明认领;needs 混进来的话这里会退成 artifact + 'w-prd': { '产品需求文档.md': '# 产品需求' }, + }, + }); + + const rows = allFiles(companyId); + expect(rows).toHaveLength(1); + expect(rows[0].kind).toBe('prd'); + }); + + it('⑨ 出边只有 needs 时不认领(本节点什么都没声明要产)', async () => { + const companyId = 'co-files-only-needs'; + const taskId = 'task-only-needs'; + const b = buildRun({ + companyId, taskId, + nodes: [{ nodeName: 'n-a' }, { nodeName: 'n-b' }], + edges: [{ from: 'n-a', to: 'n-b', condition: 'success' }], + }); + wdb.declareEdgeContract({ + edgeLayer: 'work', edgeId: b.edgeId('n-a', 'n-b'), kind: 'sdd', role: 'needs', + }); + + await advance(b.runId, 1, { writes: { 'n-a': { '随便写的.md': 'x' } } }); + + // needs 不是产出声明,不该被拿来认领 —— 认领了就是把「下游要吃 sdd」 + // 误读成「我产 sdd」,下游读到一份不是它要的文件 + const rows = allFiles(companyId); + expect(rows).toHaveLength(1); + expect(rows[0].kind).toBe('artifact'); + }); + + // ── ⑩ 引擎自己的簿记文件不进索引 ──────────────────────────────── + + it('⑩ contract.prev.json 不进索引(retryNode 的契约快照,不是节点交付物)', async () => { + const companyId = 'co-files-snapshot'; + const taskId = 'task-snapshot'; + const b = buildRun({ companyId, taskId, nodes: [{ nodeName: 's-brd' }] }); + + await advance(b.runId, 1, { + writes: { + 's-brd': { + 'brd.md': '# BRD', + // 引擎为算 contract_hash 留的上一轮快照。漏掉它的话, + // 每次人工重试都往 graph_files 添一行 kind=artifact 的噪声 + 'contract.prev.json': JSON.stringify({ outputs: [] }), + 'attempt-1.json': JSON.stringify({ fail_reason: null }), + }, + }, + }); + + const rows = allFiles(companyId); + expect(rows.map((r) => path.basename(r.rel_path))).toEqual(['brd.md']); + }); }); diff --git a/server/tests/unit/graph-seed-service.test.js b/server/tests/unit/graph-seed-service.test.js index 769680c..ca8561b 100644 --- a/server/tests/unit/graph-seed-service.test.js +++ b/server/tests/unit/graph-seed-service.test.js @@ -12,6 +12,8 @@ * B. **真实仓库根回归**(保留原有两条用例,断言未放松):seed 一遍真实 * `system/` + `data/companies/` 到一次性 DB,verifyCounts() 必须 ok, * 且往 DB 里塞一行 fs 不背书的数据时必须被抓出来。 + * 读的仍然是真实仓库的内容,但 `data/companies/` 走一次**冻结快照**(见 freezeRepoRoot)—— + * 原因写在那个函数上。 */ import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; import fs from 'fs'; @@ -33,6 +35,70 @@ function write(file, content) { fs.writeFileSync(file, content); } +const REPO_ROOT = path.resolve(__dirname, '../../..'); + +/** + * 把「真实仓库根」冻成一个不会在用例中途变形的 GRAPH_SEED_ROOT。 + * + * 为什么需要冻:verifyCounts() 的价值在于**独立重扫一遍目录**再跟库里的行数对, + * 于是 seedCompanyAgents() 那次扫描和 verifyCounts() 那次扫描之间只要磁盘变了一次, + * 期望值和实际值就必然不等 —— 而 `data/companies/` 是运行期状态,同一轮 `vitest run` 里 + * 至少有 company-hire-org-layer.test.js / graph-agent-p1-{hire,acceptance}.test.js + * 三个文件在真实 `data/companies/` 下建公司再 hire()(company.json 的 agents[] 逐个变长), + * afterAll 又整目录删掉;用户开着 dev server 时还会有人从 UI 建公司。 + * 实测:拿一个反复建/删的公司目录撞这个窗口,12 次单跑里 3 次红在 + * `org_graph_nodes COUNT(*) WHERE company_id=...`。这就是它在全套里偶发变红的全部原因。 + * + * 冻的是「一致性」而不是「新鲜度」:快照本身可能抓到某家公司写一半的样子, + * 但两次扫描读的是同一份,结论照旧成立。所以断言一条没放松 —— 真实仓库里多一个 + * 模板目录 / 多一家公司带着 seeder 会跳过的角色,照样红(下面有验证)。 + * + * `system/` 不快照,直接软链:它是签入代码,跑测试期间没人往里加目录 + * (hire() 只 bump `_registry.json` 里的 usedCount,改内容不建目录,而 _registry.json + * 只影响 benchmark_score,不影响任何行数)。软链还顺带让 + * 「every system/templates/* dir …」那条用例仍然对着真实目录清单比。 + */ +function freezeRepoRoot(dest) { + fs.mkdirSync(dest, { recursive: true }); + fs.symlinkSync(path.join(REPO_ROOT, 'system'), path.join(dest, 'system')); + + const src = path.join(REPO_ROOT, 'data', 'companies'); + const dst = path.join(dest, 'data', 'companies'); + if (!fs.existsSync(src)) return; // 连目录都没有 → 快照里也不该有,别把安全阀的判定条件改掉 + fs.mkdirSync(dst, { recursive: true }); + + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + try { + // 没有 company.json → seedCompanyAgents 与 verifyCounts 都跳过它,不必进快照 + const companyJson = fs.readFileSync(path.join(src, entry.name, 'company.json')); + write(path.join(dst, entry.name, 'company.json'), companyJson); + + let roles = []; + try { + const parsed = JSON.parse(companyJson); + if (Array.isArray(parsed.agents)) roles = parsed.agents; + } catch { + // 坏掉的 company.json:两个实现都 continue,快照里留着原样即可 + } + for (const role of roles) { + const roleDir = path.join(src, entry.name, 'agents', role); + if (!fs.existsSync(roleDir)) continue; // legacy_path 存在性也要一并复制 + fs.mkdirSync(path.join(dst, entry.name, 'agents', role), { recursive: true }); + // isMetaBootstrap() 只看这一个文件,而它决定 seedCompanyAgents 建不建 org 节点 —— 必须带上 + try { + fs.copyFileSync( + path.join(roleDir, 'config.json'), + path.join(dst, entry.name, 'agents', role, 'config.json') + ); + } catch {} + } + } catch { + // 这家公司正被别的测试 / dev server 删掉 → 跳过。快照要的是自洽,不是全集。 + } + } +} + describe('graph-seed-service (hermetic fixture root)', () => { let root; let dbPath; @@ -271,20 +337,22 @@ describe('graph-seed-service (hermetic fixture root)', () => { describe('graph-seed-service verifyCounts (real repo root)', () => { let seedService; let graphDb; - const TEST_DB_PATH = '/tmp/test-graph-seed-service.sqlite'; + let snapshotRoot; beforeAll(() => { - if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH); - delete process.env.GRAPH_SEED_ROOT; // 真实仓库根 - process.env.GRAPH_DB_PATH = TEST_DB_PATH; + // mkdtemp 而不是固定 /tmp/test-graph-seed-service.sqlite:同一台机器上并行跑两遍 + // 这个文件时不该共用一个库 + snapshotRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-seed-realroot-')); + freezeRepoRoot(snapshotRoot); + process.env.GRAPH_SEED_ROOT = snapshotRoot; + process.env.GRAPH_DB_PATH = path.join(snapshotRoot, 'graph.sqlite'); ({ graphDb, seedService } = reloadServices()); }); afterAll(() => { - 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_SEED_ROOT; delete process.env.GRAPH_DB_PATH; + try { fs.rmSync(snapshotRoot, { recursive: true, force: true }); } catch {} }); it('reports ok:true against a freshly-seeded DB (no drift)', () => { diff --git a/server/tests/unit/org-layer-contracts.test.js b/server/tests/unit/org-layer-contracts.test.js new file mode 100644 index 0000000..2892540 --- /dev/null +++ b/server/tests/unit/org-layer-contracts.test.js @@ -0,0 +1,331 @@ +/** + * Unit Tests — R-19 **org 层**数据契约:先有读回方,才谈声明方 + * + * 起因是第五次真 CLI 冒烟:`graph_edge_contracts` 里 `edge_layer='org'` 是 0 行。 + * 直觉判断是「orggraph-planning 模板没教它声明」,核实之后这个判断是**错的**: + * + * - work-graph-db.readDefGraph 给 org 图的节点**全部**写 nodeKind='subgraph' + * (注释原文:「org graph 的节点全部是 subgraph executor」); + * - graph-engine.describeInputs 第一行就是 `ctx.nodeKind === 'subgraph'` 时返回空串。 + * + * 两条合起来 = org 层的边契约在输入侧**一个读回方都没有**。先补模板的话,产出的正是 + * 「声明了没人读」——本仓库已经踩了 14 次的那个形状。 + * + * 所以读回方接在 `buildPlanningPrompt`(org 节点派给 workgraph-planning 的提示词)上: + * org 层契约的语义正落在这里 —— 「你这个 org node 要吃上游产出的 X、要产下游要吃的 Y, + * 于是你规划的子图必须让入口节点消费 X、必须有节点产出 Y」。 + * + * 本文件锁四件事: + * ① 读回方成立 org 边上的 produces → 规划提示词里出现 ③bis 段 + 上游实例的**绝对路径** + * ② 三种口径 查得到给绝对路径 / required 查不到给 ⚠️ / 可选查不到整行省略 + * ③ 无契约不留空标题 一条契约都没有时整段(含标题)消失,不给 agent「查过了,没有」的错觉 + * ④ 模板防漂移 orggraph-planning 模板里出现的每个「边上声明键」都必须被 + * org-graph-service.normalizeContracts 真读到;kind 取值域逐字对齐 ARTIFACT_KINDS + * + * buildPlanningPrompt / describeOrgContracts **故意不导出**(graph-engine.js 的口径: + * 不为测试新开公开面),所以 ①②③ 全部走 advanceOne 这个真入口: + * 用 setPlanWorkGraph 只替掉**上游** org 节点的规划(给它一张现成子图), + * 被测节点则委托回真的 `defaultPlanWorkGraph`,靠替换 AgentManager 截获它发出的提示词。 + * + * 隔离手法同 graph-files-index.test.js:先设 GRAPH_DB_PATH / GRAPH_RUNS_DIR 再 require, + * 仓库 data/ 全程不被碰;Claude CLI 一次都不起(AgentManager 被换成假货)。 + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const REPO = path.resolve(__dirname, '../../..'); + +describe('R-19 org 层数据契约(读回方 = buildPlanningPrompt)', () => { + let wdb; + let orgSvc; + let engine; + let runsDir; + const TEST_DB_PATH = '/tmp/test-org-layer-contracts.sqlite'; + const AM_PATH = require.resolve('../../src/core/agent-manager'); + + /** defaultPlanWorkGraph 发出的提示词,一次调用一条 */ + let captured = []; + let savedAgentManager; + + /** + * 假 AgentManager:截下提示词就抛。 + * + * 抛而不是「回一张合法子图」是刻意的:本文件要验的是**提示词内容**, + * 让规划"成功"还得替它造一张过五项校验的子图,那些代码与被测行为无关。 + * defaultPlanWorkGraph 对 callAgent 抛出的错不重试(只对「图画错了」重试), + * 所以一次 advanceOne 恰好产出一条提示词,节点按 node_error 失败 —— 正是我们要的。 + */ + class FakeAgentManager { + async callAgent(agentName, prompt) { + captured.push({ agentName, prompt }); + throw new Error('fake-agent-manager: 已截获提示词,不真的调 CLI'); + } + } + + 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(), 'org-contracts-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'); + orgSvc = require('../../services/org-graph-service'); + engine = require('../../services/graph-engine'); + + // defaultPlanWorkGraph 里是 `require('../src/core/agent-manager')`(调用时才 require), + // 所以换掉 require.cache 里的 exports 就够,不必去 patch 原型 —— 也就不会构造真的 + // AgentManager(它的构造函数会挂上全局 LogManager / SessionPool)。afterAll 原样还回去。 + savedAgentManager = require.cache[AM_PATH]; + require.cache[AM_PATH] = { + id: AM_PATH, filename: AM_PATH, loaded: true, children: [], paths: [], + exports: FakeAgentManager, + }; + }); + + afterAll(() => { + if (savedAgentManager) require.cache[AM_PATH] = savedAgentManager; + else delete require.cache[AM_PATH]; + 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; + for (const m of ['../../services/graph-run-sse', '../../services/graph-engine']) { + delete require.cache[require.resolve(m)]; + } + }); + + // ── 脚手架 ─────────────────────────────────────────────────── + + let seq = 0; + /** 前缀刻意带 tmp-:resolvePlanningAgentName 会拿它去 REPO/data/companies 下找目录, + * 找不到就试着 hire 一次;hire 第一步 get(companyId) 读不到 company.json 直接抛, + * 被 catch 掉 —— 全程不在仓库 data/ 下建任何东西。 */ + const nextCompany = () => `tmp-orgc-${process.pid}-${++seq}`; + + function ensureAgents(ids) { + const db = wdb.getDb(); + const ts = new Date().toISOString(); + const ins = db.prepare(` + INSERT OR IGNORE INTO system_agents + (id, name, scope, owner_company_id, source_kind, legacy_path, status, created_at, updated_at) + VALUES (?, ?, 'public', NULL, 'global_agent', ?, 'active', ?, ?) + `); + for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, ts, ts); + } + + /** + * 起一张 org run,把入口节点跑通(它的子图写出 `writes` 里的文件并被索引), + * 让第二个 org 节点变 ready,然后再推一步 —— 那一步就会调真的 defaultPlanWorkGraph。 + * + * @param edges org 边,可带 produces / needs + * @param writes 入口节点的子图那个 work 节点要落盘的文件 {文件名: 内容} + * @returns {Promise<{prompt: string, runId: string}>} 被测节点(org-coder)收到的规划提示词 + */ + async function planOrgRun({ companyId, taskId, nodes, edges, writes = {} }) { + expect(orgSvc.writeGraph(companyId, { nodes, edges }).ok).toBe(true); + + ensureAgents(['plan-worker']); + const sub = wdb.createWorkGraph({ companyId, taskId, nodes: ['plan-worker'], edges: [] }); + expect(sub.errors || []).toEqual([]); + + // 上游 org 节点用注入的假规划(一张现成子图);被测节点委托回真路径。 + engine.setPlanWorkGraph(async (ctx) => { + if (ctx.nodeName === 'org-planner') return { workGraphId: sub.id }; + return engine.defaultPlanWorkGraph(ctx); + }); + // 子图里那个 work 节点:把文件写进自己的私有目录,outputRef 指向该目录 + // —— 形状与 defaultAgentExecutor 一致,settleNode 的 indexNodeOutputs 才会登记它。 + engine.setAgentExecutor(async (ctx) => { + const dir = path.join(runsDir, ctx.run.root_run_id, 'nodes', ctx.nodeRunId); + fs.mkdirSync(dir, { recursive: true }); + for (const [name, body] of Object.entries(writes)) { + fs.writeFileSync(path.join(dir, name), body); + } + return { status: 'success', outputRef: dir }; + }); + + let runId; + try { + const run = wdb.createRun({ graphKind: 'org', graphId: companyId, companyId, taskId }); + runId = run.id; + engine.markRunReady(run.id); + engine.startRun(run.id, { mode: 'manual' }); + + captured = []; + await engine.advanceOne(run.id); // org-planner → 起子 run + const child = wdb.listRunTree(run.id).find((r) => r.parent_run_id === run.id); + await engine.advanceOne(child.id); // 子图那个节点跑完 → 回写父节点 + expect(wdb.getRun(child.id).run_status).toBe('completed'); + await engine.advanceOne(run.id); // org-coder → 真 defaultPlanWorkGraph + } finally { + engine.setPlanWorkGraph(null); + engine.setAgentExecutor(null); + } + + expect(captured).toHaveLength(1); + return { prompt: captured[0].prompt, runId }; + } + + // ── ① 读回方成立(最关键的一条)─────────────────────────────── + + it('① org 边上声明 produces → 被测 org node 的规划提示词里出现 ③bis 段与上游实例的绝对路径', async () => { + const companyId = nextCompany(); + const taskId = 'task-org-in'; + const { prompt } = await planOrgRun({ + companyId, taskId, + nodes: ['org-planner', 'org-coder', 'org-validator'], + edges: [ + // 入边:上游 org node 会产出一份 brd 交给 org-coder + { from: 'org-planner', to: 'org-coder', condition: 'success', produces: ['brd'] }, + // 出边:org-coder 自己要产出一份 sdd 给下游 org node; + // 同一条边上的 needs 是**下游**要吃的(产出方是隔了一个节点的 org-planner), + // 它不是 org-coder 的产出 —— 混进去就会命令 org-coder 再产一份 brd。 + { from: 'org-coder', to: 'org-validator', condition: 'success', produces: ['sdd'], needs: ['brd'] }, + ], + writes: { 'brd.md': '# 业务需求' }, + }); + + // 提示词确实发给了规划本体(不是发给别人) + expect(captured[0].agentName).toMatch(/workgraph-planning$/); + + // 入边那一半:给的是**绝对路径**,且指向真实落盘的那份 brd.md + expect(prompt).toContain('③bis'); + expect(prompt).toContain('### 上游节点交给你的输入'); + const abs = prompt.match(/^- brd:(.+)$/m); + expect(abs, '提示词里没有 brd 那一行').toBeTruthy(); + expect(path.isAbsolute(abs[1])).toBe(true); + // 真正的证据不是「长得像路径」,而是这个路径**读得出上游刚写的那份内容**。 + // (rel_path 是相对公司根算的,节点私有目录在 GRAPH_RUNS_DIR 覆盖下位于公司根之外, + // 所以拼回来的绝对路径带 ../ 归一后并不以公司根开头 —— 不拿前缀做断言。) + expect(fs.readFileSync(abs[1], 'utf-8')).toBe('# 业务需求'); + expect(prompt).not.toContain('⚠️ 上游声明会产出'); + + // 出边那一半:硬约束语气 + 点名 kind + expect(prompt).toContain('### 你这一层必须产出的东西(硬约束)'); + expect(prompt).toMatch(/必须有节点真的产出上面每一个 kind/); + // 只列出边上 role='produces' 的那些。同一条出边上的 needs(brd)是下游要吃的东西, + // 混进来就变成「命令 org-coder 再产一份 brd」——一条它根本没答应过的硬约束。 + const produced = prompt.match(/找输入的:(.+)$/m); + expect(produced, '提示词里没有「必须产出」那一行').toBeTruthy(); + expect(produced[1]).toBe('sdd'); + // 出边声明的 kind 不该被误当成「上游给你的输入」列在入边那一半里 + expect(prompt).not.toMatch(/^- sdd:/m); + }); + + // ── ② 另两种口径:required 查不到给 ⚠️;可选查不到整行省略 ────── + + it('② required 声明查不到实例 → ⚠️;required:false 查不到 → 整行省略(不留半句话)', async () => { + const companyId = nextCompany(); + const taskId = 'task-org-miss'; + const { prompt } = await planOrgRun({ + companyId, taskId, + nodes: ['org-planner', 'org-coder'], + edges: [{ + from: 'org-planner', to: 'org-coder', condition: 'success', + // brd 会真的落盘;acceptance 是 required 但没人产;log 是可选且没人产 + produces: ['brd', 'acceptance', { kind: 'log', required: false }], + }], + writes: { 'brd.md': '# 业务需求' }, + }); + + expect(prompt).toMatch(/^- brd:\//m); + expect(prompt).toContain('- acceptance:⚠️ 上游声明会产出,但索引里查不到实例'); + // 可选且查不到 → 一行都不留(留一行「查不到」会让 agent 以为这是个待办) + expect(prompt).not.toMatch(/^- log[::]/m); + }); + + // ── ③ 一条契约都没有时整段消失(含标题)────────────────────── + + it('③ org 图上一条契约都没声明 → ③bis 整段不出现(空标题会被读成「查过了,没有」)', async () => { + const companyId = nextCompany(); + const { prompt } = await planOrgRun({ + companyId, taskId: 'task-org-none', + nodes: ['org-planner', 'org-coder'], + edges: [{ from: 'org-planner', to: 'org-coder', condition: 'success' }], + writes: { 'brd.md': '# 业务需求' }, // 实例在索引里,但图上没人声明要它 + }); + + expect(prompt).not.toContain('③bis'); + expect(prompt).not.toContain('上游节点交给你的输入'); + expect(prompt).not.toContain('必须产出的东西'); + // 其余四项输入照旧在(证明省略的只是这一段,不是整段提示词拼挂了) + expect(prompt).toContain('## ③ 可读的 artifacts/'); + expect(prompt).toContain('## ④ 上一轮 remediation_note'); + }); + + // ── ⑤ 产出侧的事实:org 节点自己什么都不登记 ──────────────────── + + it('⑤ org 层的 produces 在产出侧不靠 indexNodeOutputs:org 节点 output_ref 恒为 null,实例是子 run 里的 work 节点登记的', async () => { + const companyId = nextCompany(); + const taskId = 'task-org-out'; + const { runId } = await planOrgRun({ + companyId, taskId, + nodes: ['org-planner', 'org-coder'], + edges: [{ from: 'org-planner', to: 'org-coder', condition: 'success', produces: ['brd'] }], + writes: { 'brd.md': '# 业务需求' }, + }); + + // org 节点是 subgraph executor,它的 outcome 由 deriveChildOutcome 拼,**不带 outputRef**。 + // 所以 indexNodeOutputs 在 org run 上第一行就 return —— 3076 行那句 layer 三元里的 + // 'org' 分支对 org run 是走不到的死路径。这不是缺陷,是「org 节点自己不写盘」的必然结果。 + const orgNodes = wdb.getDb() + .prepare(`SELECT * FROM graph_run_nodes WHERE run_id = ?`).all(runId); + expect(orgNodes.length).toBeGreaterThan(0); + expect(orgNodes.map((n) => n.output_ref)).toEqual(orgNodes.map(() => null)); + + // 那份 brd 的索引行是**子 run 里那个 work 节点**登记的(node_run_id 属于子 run)。 + // 它之所以能被 org 层的入边声明查到,只因为 graph_files 是按 (company, task) 检索的, + // 而子 run 从父 run 继承同一个 task_id —— 这就是 org 层 produces 在产出侧成立的全部理由。 + const rows = wdb.findFiles({ companyId, taskId, kinds: ['brd'] }); + expect(rows).toHaveLength(1); + expect(orgNodes.map((n) => n.id)).not.toContain(rows[0].produced_by_node_run_id); + const child = wdb.listRunTree(runId).find((r) => r.parent_run_id === runId); + expect(rows[0].produced_by_run_id).toBe(child.id); + }); + + // ── ④ 模板防漂移:教的键名 / 取值域必须是落库侧真读的那个 ────── + + it('④ orggraph-planning 模板里的边上声明键,normalizeContracts 必须真的认(差一个字母就是静默失效)', () => { + const dir = path.join(REPO, 'system/templates/orggraph-planning'); + const text = fs.readdirSync(dir) + .filter((f) => f.endsWith('.md')) + .map((f) => fs.readFileSync(path.join(dir, f), 'utf-8')) + .join('\n'); + + // 两个键都得教到:少 produces 就没人产,少 needs 就表达不了非相邻依赖 + expect(text).toMatch(/"produces"/); + expect(text).toMatch(/"needs"/); + + const keys = [...new Set( + [...text.matchAll(/"(produces|needs|carries|consumes|inputs|outputs|provides)"/g)].map((m) => m[1]) + )]; + expect(keys.length).toBeGreaterThan(0); + for (const key of keys) { + const contracts = orgSvc.normalizeContracts({ from: 'x', to: 'y', [key]: ['brd'] }); + expect(contracts.map((c) => c.kind), `模板教了 "${key}",但归一函数不读这个键`).toEqual(['brd']); + } + + // kind 取值域逐字对齐:模板漏教一个值,规划 agent 就永远不会用它; + // 模板多教一个值,validateGraph 会以 rule=schema 把整张图打回。 + for (const kind of orgSvc.ARTIFACT_KINDS) { + expect(text, `ARTIFACT_KINDS 里的 ${kind} 模板没教`).toContain(kind); + } + // backward 边上不许声明 —— 引擎解析输入只看 forward 入边(validateGraph 也拦) + expect(text).toMatch(/backward/); + }); +}); diff --git a/server/tests/unit/work-graph-skeletons.test.js b/server/tests/unit/work-graph-skeletons.test.js new file mode 100644 index 0000000..111e03c --- /dev/null +++ b/server/tests/unit/work-graph-skeletons.test.js @@ -0,0 +1,200 @@ +/** + * system/work-graph-templates/*.json 的**数据契约**防线(R-19 / 27-P4 §4.6)。 + * + * 挡的是这一类缺陷:骨架的 forward 边只带控制流(condition / edgeKind / failReason), + * 不带 produces / needs。照抄骨架得到的图于是**不传数据** —— 下游节点的提示词里 + * 一条上游产物路径都没有,第四次真 CLI 冒烟里 org-planner-prd 的原话就是 + * 「不是上游没产出,是它的产出结构上到不了我手上」。 + * 补上声明前,那件事只能靠 workgraph-planning 的提示词施压来避免;换个模型就漏。 + * + * 与 template-refs.test.js 的分工:那边管**引用**(systemAgentId 指不指得到真本体、 + * 落不落得进库),这边管**数据流**(边上有没有声明、声明落库后读不读得回来)。 + * + * 四条用例分别对应四环里的一环: + * ① 骨架过引擎真正用的那套校验(含规则 6 data-contract:声明要吃的 kind 必须有产出方) + * ② 每张骨架的**每条** forward 边都有非空声明(新加一张骨架漏写声明也会被抓) + * ③ 声明落库:createWorkGraph → graph_edge_contracts 真有行,且 role/kind/required 与 JSON 一致 + * ④ 读回:inboundContracts(describeInputs 的数据源)对每个非入口节点都非空 + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const ROOT = path.resolve(__dirname, '../../..'); +const WORK_GRAPH_DIR = path.join(ROOT, 'system/work-graph-templates'); + +/** + * 占位符 → 真实存在的本体 id / 节点名。 + * 落库那两条用例必须替换:work_graph_nodes.system_agent_id 有外键指向 system_agents(id), + * `{{implAgent}}` 会直接 FOREIGN KEY constraint failed —— 那样测的就不是契约而是外键了。 + * 取值只要求「system/templates/ 下真实存在」,语义上贴近即可。 + */ +const PLACEHOLDERS = { + '{{orgNode}}': 'org-coder', + '{{splitAgent}}': 'devloop-architect', + '{{implAgent}}': 'devloop-coder', + '{{testAgent}}': 'devloop-tester', + '{{mergeAgent}}': 'devloop-integrator', + '{{integrationAgent}}': 'devloop-tester', + '{{validatorAgent}}': 'coder-validator', +}; + +function readSkeletons() { + return fs + .readdirSync(WORK_GRAPH_DIR) + .filter((f) => f.endsWith('.json')) + .map((f) => ({ + file: f, + json: JSON.parse(fs.readFileSync(path.join(WORK_GRAPH_DIR, f), 'utf-8')), + })); +} + +/** 把骨架里的 {{占位符}} 全部替换成真实 id;替换后仍残留 {{ 就让它炸,不要静默落一张废图 */ +function materialize(skeleton) { + let text = JSON.stringify(skeleton.json); + for (const [ph, real] of Object.entries(PLACEHOLDERS)) text = text.split(ph).join(real); + if (text.includes('{{')) throw new Error(`${skeleton.file} 有未覆盖的占位符,请补 PLACEHOLDERS`); + return JSON.parse(text); +} + +const isForward = (e) => (e.edgeKind || e.edge_kind || 'forward') !== 'backward'; + +let wdb; +let orgGraphService; +let normalizeContracts; + +beforeAll(async () => { + // 隔离手法与 template-refs.test.js / graph-engine.test.js 一致:先设 env 再 import。 + // 仓库的 data/graph.sqlite 一行都不该被本文件碰到。 + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'work-graph-skeletons-')); + process.env.GRAPH_DB_PATH = path.join(tmp, 'graph.sqlite'); + process.env.GRAPH_RUNS_DIR = path.join(tmp, 'runs'); + + const graphDb = await import('../../services/graph-db.js'); + const seed = await import('../../services/graph-seed-service.js'); + wdb = (await import('../../services/work-graph-db.js')).default; + orgGraphService = (await import('../../services/org-graph-service.js')).default; + normalizeContracts = orgGraphService.normalizeContracts; + + graphDb.default.getDb(); + seed.default.seedTemplates(); // system/templates/* → system_agents(外键的另一端) +}); + +describe('work graph 骨架的数据契约', () => { + const skeletons = readSkeletons(); + + it('本目录下有骨架可查(防止目录改名让整个文件静默空跑)', () => { + expect(skeletons.map((s) => s.file).sort()).toEqual([ + 'executor-fanout.json', + 'executor.json', + 'planner.json', + 'validator.json', + ]); + }); + + it('每张骨架都过引擎真正用的那套校验(含规则 6:声明要吃的 kind 必须有产出方)', () => { + const verdicts = skeletons.map((s) => { + const r = wdb.validateWorkGraph(s.json.nodes, s.json.edges); + return `${s.file}: ${r.ok ? 'ok' : JSON.stringify(r.errors)}`; + }); + expect(verdicts.filter((v) => !v.endsWith(': ok'))).toEqual([]); + }); + + it('每张骨架的每条 forward 边都有非空的 produces / needs 声明', () => { + // 「至少一张有」不够:新增骨架时漏写声明必须当场红。 + // backward 边反过来一律不许声明(validateGraph 会报 rule=schema),上一条用例已覆盖。 + const naked = []; + for (const s of skeletons) { + for (const e of s.json.edges || []) { + if (!isForward(e)) continue; + if (normalizeContracts(e).length === 0) naked.push(`${s.file}: ${e.from} -> ${e.to}`); + } + } + expect(naked).toEqual([]); + }); + + it('声明真的落库:createWorkGraph 之后 graph_edge_contracts 里 kind/role/required 与 JSON 逐条一致', () => { + // 这一步把「JSON 里写了」和「落库后读得到」分开:前者只是文本,后者才是四环闭合的第三环。 + const mismatches = []; + let contractRows = 0; + + for (const s of skeletons) { + const json = materialize(s); + const created = wdb.createWorkGraph({ + companyId: 'work-graph-skeletons-test', + taskId: `T-${s.json.id}`, + nodes: json.nodes, + edges: json.edges, + }); + expect(`${s.file}: ${created.ok ? 'ok' : JSON.stringify(created.errors)}`).toBe(`${s.file}: ok`); + + const def = wdb.readDefGraph('work', created.id); + const nameOf = new Map(def.nodes.map((n) => [n.defNodeId, n.name])); + // 按端点名配对,**不能**按声明序:readDefGraph 是 ORDER BY created_at, id, + // 同一次事务里 created_at 全等,实际排序落到随机 id 上,落库序 ≠ 声明序。 + const dbByEndpoints = new Map( + def.edges + .filter((e) => e.edgeKind !== 'backward') + .map((e) => [`${nameOf.get(e.from)} -> ${nameOf.get(e.to)}`, e]) + ); + const jsonEdges = json.edges.filter(isForward); + expect(dbByEndpoints.size).toBe(jsonEdges.length); + + for (const je of jsonEdges) { + const label = `${s.file} ${je.from} -> ${je.to}`; + const dbEdge = dbByEndpoints.get(`${je.from} -> ${je.to}`); + if (!dbEdge) { + mismatches.push(`${label}: 落库后找不到这条边`); + continue; + } + const want = normalizeContracts(je) + .map((c) => `${c.role}:${c.kind}:${c.required}`) + .sort(); + const got = wdb + .edgeContracts('work', dbEdge.id) + .map((r) => `${r.role}:${r.kind}:${r.required === 1}`) + .sort(); + contractRows += got.length; + if (JSON.stringify(want) !== JSON.stringify(got)) { + mismatches.push(`${label}: JSON=${JSON.stringify(want)} DB=${JSON.stringify(got)}`); + } + } + } + + expect(mismatches).toEqual([]); + expect(contractRows).toBeGreaterThan(0); // 全都对不上却"两边都空"时不许静默通过 + }); + + it('读回:每个非入口节点的 inboundContracts 都非空(describeInputs 拼路径的数据源)', () => { + // 引擎给下游拼「上游输入」段落走的就是这个函数(graph-engine.describeInputs)。 + // 它对某个节点返回空 = 那个节点的提示词里一条上游路径都没有。 + const starved = []; + + for (const s of skeletons) { + const json = materialize(s); + const created = wdb.createWorkGraph({ + companyId: 'work-graph-skeletons-test', + taskId: `T-inbound-${s.json.id}`, + nodes: json.nodes, + edges: json.edges, + }); + expect(created.ok).toBe(true); + + const def = wdb.readDefGraph('work', created.id); + const hasForwardIn = new Set( + def.edges.filter((e) => e.edgeKind !== 'backward').map((e) => String(e.to)) + ); + for (const n of def.nodes) { + if (!hasForwardIn.has(String(n.defNodeId))) continue; // 入口节点没有入边,输入来自提示词 + if (wdb.inboundContracts('work', def.edges, n.defNodeId).length === 0) { + starved.push(`${s.file}: ${n.name}`); + } + } + // 每张图至少有一个非入口节点,否则这条用例在"没节点可查"的情况下静默通过 + expect(hasForwardIn.size).toBeGreaterThan(0); + } + + expect(starved).toEqual([]); + }); +}); diff --git a/server/tests/unit/workgraph-planning-endpoints.test.js b/server/tests/unit/workgraph-planning-endpoints.test.js new file mode 100644 index 0000000..989b672 --- /dev/null +++ b/server/tests/unit/workgraph-planning-endpoints.test.js @@ -0,0 +1,296 @@ +/** + * Unit Tests — workgraph-planning 模板的防漂移闸:**模板教的端点必须真的存在** + * + * 挡的是已经实际发生过的这一类缺陷:模板(`system/templates/workgraph-planning/*.md`) + * 教 agent `POST /api/company/<公司ID>/work-graphs` 去落库,而这个路由**从来没有实现过** + * (`server/index.js` 只挂了 `/api/graph-runs` 与 `/api/company` 的 org-graph 系列, + * 设计文档里也查不到这个端点)。后果不是功能坏掉 —— `graph-engine.js` 的 + * `extractPlannedGraph` 那条「读回复取图」的分支本来就是主路径,图照样落得进去 —— + * 而是**每次真跑都白烧一轮 agent 调用**去 curl 一个必然 404 的地址(第五次真 CLI 冒烟实测)。 + * 这种缺陷在单测里天然无感:prompt 文本没有任何编译期检查,端点没了也不会有人红。 + * + * 所以这里把「模板文本」和「真实路由表」对起来查: + * ① 模板里出现的每个 `/api/...` 路径,都必须能在 `server/routes/` 里找到注册; + * ② 反向:被显式标注为反例(`NO-SUCH-ENDPOINT`)的路径,必须**确实不存在** —— + * 哪天这个端点真上线了,这条会红,提醒把警示文本删掉,而不是留一句过时的谣言; + * ③ 模板教 agent 交回的那份图示例,本身要是**严格 JSON** 且过 `validateWorkGraph`: + * 引擎用 `JSON.parse` 取图,示例里带注释/尾逗号就等于教它白烧一轮。 + * + * ── 路由表怎么来 ──────────────────────────────────────────────── + * **静态解析** `server/index.js` 的 `app.use('<前缀>', require('./routes/<文件>')[.<导出>])` + * + 路由文件里的 `.get/post/...('<路径>')`,不 require 任何路由模块: + * 那些模块一 require 就会拉起 service 层(sqlite 连接等),而这条 guard 只需要文本事实。 + * + * ponytail: 路径匹配按「段」严格比:占位符段(`:id` / `<公司ID>` / `{公司ID}`)两侧都归一成 + * `*`,且**只与占位符相等** —— 字面量位置上出现占位符也算不匹配(松一点就会出现 + * `/api/company/{公司ID}/work-graphs` 被 `PATCH /api/company/templates/:templateId` 匹配上, + * 实测过)。上限:模板把一族端点写成 `{start,next,...}` 时靠展开逗号处理, + * 而**动词(GET/POST)不参与比对** —— 端点存在但方法写错,这条查不出来。 + * 要更紧就得真起 express app 逐条 match,代价是把 service 层全拉起来,不值当。 + */ +import { describe, it, expect } from 'vitest'; +import fs from 'fs'; +import path from 'path'; + +const ROOT = path.resolve(__dirname, '../../..'); +const TEMPLATE_DIR = path.join(ROOT, 'system/templates/workgraph-planning'); +const ROUTES_DIR = path.join(ROOT, 'server/routes'); +const INDEX_JS = path.join(ROOT, 'server/index.js'); + +/** + * 反例标记:同一行里出现它,这一行的端点就**不要求**注册(反而要求它不存在,见用例 ②)。 + * 选「同一行」而不是「同一段落」是为了让判据肉眼可查 —— 加一句无关的警示文字不会顺手 + * 豁免掉隔壁那条真端点。 + */ +const NO_SUCH = 'NO-SUCH-ENDPOINT'; + +/** + * `{start,next,...}` 这种「一族端点写成一行」的写法展开成逐条候选(`...` 省略号丢掉)。 + * 只展开**带逗号**的花括号 —— `{公司ID}` 是占位符,不是选择集。 + */ +function expandAlternatives(p) { + const m = /\{([^{}]*,[^{}]*)\}/.exec(p); + if (!m) return [p]; + return m[1] + .split(',').map((s) => s.trim()).filter((s) => s && s !== '...') + .flatMap((alt) => expandAlternatives(p.slice(0, m.index) + alt + p.slice(m.index + m[0].length))); +} + +/** 段级归一:占位符(`:x` / `` / `{x}`)一律 `*`,字面量原样保留 */ +function segments(p) { + return String(p) + .split('/') + .filter((s) => s !== '') + .map((s) => (/[:<>{}*]/.test(s) ? '*' : s)); +} + +/** + * 严格逐段相等(占位符只与占位符相等)。 + * + * 早先写成「`*` 匹配任意一段」,结果 `/api/company/{公司ID}/work-graphs` 被 + * `PATCH /api/company/templates/:templateId` 匹配上了(占位符吃掉 `templates`、 + * `work-graphs` 又被 `:templateId` 吃掉)—— 那样这条 guard 对本次要挡的缺陷直接失效。 + * 所以「字面量位置上出现占位符」也算不匹配。 + */ +function pathMatches(a, b) { + const x = segments(a); + const y = segments(b); + return x.length === y.length && x.every((s, i) => s === y[i]); +} + +/** 一个模板路径「已注册」= 它展开出的每一条候选都能找到注册 */ +function isRegistered(p, registered) { + return expandAlternatives(p).every((cand) => registered.some((r) => pathMatches(cand, r.full))); +} + +/** server/index.js 的挂载表:`app.use('/api/x', require('./routes/y').z)` */ +function readMounts() { + const src = fs.readFileSync(INDEX_JS, 'utf-8'); + const re = /app\.use\(\s*'([^']+)'\s*,\s*require\('\.\/routes\/([\w-]+)'\)(?:\.(\w+))?\s*\)/g; + const out = []; + let m; + while ((m = re.exec(src))) out.push({ prefix: m[1], file: `${m[2]}.js`, exportName: m[3] || null }); + return out; +} + +/** 这个挂载点对应的 Router 变量名(`module.exports = router` / `module.exports.companyRouter = x`) */ +function routerIdentifier(src, exportName) { + const re = exportName + ? new RegExp(`module\\.exports\\.${exportName}\\s*=\\s*(\\w+)`) + : /module\.exports\s*=\s*(\w+)\s*;/; + const m = re.exec(src); + return m ? m[1] : null; +} + +/** 真实注册的完整路径集合(前缀 + 声明路径),只收字面量路径 */ +function registeredPaths() { + const out = []; + for (const mount of readMounts()) { + const file = path.join(ROUTES_DIR, mount.file); + if (!fs.existsSync(file)) continue; + const src = fs.readFileSync(file, 'utf-8'); + const ident = routerIdentifier(src, mount.exportName); + if (!ident) continue; + const re = new RegExp(`\\b${ident}\\.(?:get|post|put|patch|delete)\\(\\s*'([^']*)'`, 'g'); + let m; + while ((m = re.exec(src))) { + out.push({ full: `${mount.prefix}/${m[1]}`.replace(/\/+/g, '/'), file: mount.file }); + } + } + return out; +} + +/** + * 模板里提到的每个 `/api/...` 路径(带出处行号,便于定位)。 + * + * 占位符里有中文(`<公司ID>`),所以 ASCII 字符类**不够** —— 第一版写成 + * `[A-Za-z0-9_\-./:<>{},]*` 时,`/api/company/<公司ID>/agents` 被截成 `/api/company/<`, + * 段数从 4 塌成 3,正好被 `GET /api/company/:companyId` 匹配上:整条 guard 对模板里 + * **每一条**真实路径都变成了空跑(变异测试实测撞到)。CJK 只在 `<>` / `{}` 括号里放行, + * 括号外仍限 ASCII —— 否则紧跟路径的中文正文会被吞进路径里,变成误报。 + */ +const ENDPOINT_RE = /\/api\/(?:<[^<>\s]*>|\{[^{}\s]*\}|[A-Za-z0-9_\-.:]|\/)+/g; + +function templateEndpoints() { + const out = []; + for (const name of fs.readdirSync(TEMPLATE_DIR).filter((f) => f.endsWith('.md'))) { + const lines = fs.readFileSync(path.join(TEMPLATE_DIR, name), 'utf-8').split('\n'); + lines.forEach((line, i) => { + for (const raw of line.match(ENDPOINT_RE) || []) { + const cleaned = raw.replace(/[.,:;/]+$/, ''); + out.push({ path: cleaned, file: name, line: i + 1, exempt: line.includes(NO_SUCH) }); + } + }); + } + return out; +} + +/** ```json 围栏块里能解析出 `nodes` 数组的那些(引擎 extractPlannedGraph 同一取法) */ +function graphExamples(fileName) { + const text = fs.readFileSync(path.join(TEMPLATE_DIR, fileName), 'utf-8'); + const out = []; + const fence = /```json\s*([\s\S]*?)```/g; + let m; + while ((m = fence.exec(text))) { + try { + const doc = JSON.parse(m[1]); + if (doc && Array.isArray(doc.nodes)) out.push(doc); + } catch { /* 不是图的 json 块(或写坏了),交给下面的断言去管 */ } + } + return out; +} + +describe('workgraph-planning 模板:教的端点必须真的存在', () => { + const registered = registeredPaths(); + const endpoints = templateEndpoints(); + + it('两侧都不是空的(防止 guard 因为改名/正则失效而静默变成空跑)', () => { + expect(endpoints.length).toBeGreaterThan(0); + expect(registered.length).toBeGreaterThan(20); + // 抽取必须**整条**抽出来(含中文占位符):截断成 `/api/company/<` 会被 + // `GET /api/company/:companyId` 匹配上,guard 就整体空跑了 + for (const tail of ['/hire', '/agents']) { + const hit = endpoints.find((e) => e.path.endsWith(tail)); + expect(hit, `模板里抽不到以 ${tail} 结尾的端点,抽取正则多半又被截断了`).toBeTruthy(); + expect(segments(hit.path).length).toBe(4); + } + // 已知一定注册着的几条,用来验「解析链路本身没坏」(含挂在 companyRouter 上的那条) + for (const known of ['/api/company//hire', '/api/company//agents', + '/api/graph-runs//{start,next}', '/api/company//graph-runs']) { + expect(isRegistered(known, registered), `解析不到 ${known}`).toBe(true); + } + // 反面自检:一个明摆着不存在的路径不许被判成已注册(挡住"匹配太松 → guard 空跑") + expect(isRegistered('/api/company//definitely-not-a-route', registered)).toBe(false); + }); + + it('① 模板里的每个端点都在 server/routes/ 里真实注册', () => { + const dangling = endpoints + .filter((e) => !e.exempt) + .filter((e) => !isRegistered(e.path, registered)) + .map((e) => `${e.file}:${e.line} → ${e.path} 没有任何路由注册它` + + `(真要写成反例,同一行加上 ${NO_SUCH} 标记并写明"不存在,不要调用")`); + expect(dangling).toEqual([]); + }); + + it(`② 标了 ${NO_SUCH} 的端点必须确实不存在(端点真上线了就该删掉这句警示)`, () => { + const exempt = endpoints.filter((e) => e.exempt); + // 反例本身也要在(否则这条用例是空跑):删掉那条警示行时这里会红,提醒别把它悄悄弄没了 + expect(exempt.length).toBeGreaterThan(0); + const stale = exempt + .filter((e) => isRegistered(e.path, registered)) + .map((e) => `${e.file}:${e.line} → ${e.path} 已经真的注册了,${NO_SUCH} 这句话过时了`); + expect(stale).toEqual([]); + }); + + it('③ 模板教 agent 交回的图示例:严格 JSON + 过 validateWorkGraph(引擎用 JSON.parse 取它)', () => { + // 隔离:先设 GRAPH_DB_PATH 再 require(清 require.cache),仓库 data/graph.sqlite 一行不碰。 + // validateWorkGraph 是纯函数,但 require 链上有 getDb,保险起见照铁律走。 + process.env.GRAPH_DB_PATH = '/tmp/test-workgraph-planning-endpoints.sqlite'; + for (const m of ['../../services/graph-db', '../../services/work-graph-db']) { + delete require.cache[require.resolve(m)]; + } + const wdb = require('../../services/work-graph-db'); + + // agent.md 是**唯一进提示词的那份**(agent-manager.getAgentConfig 只把 agent.md 当 prompt), + // 所以它里面必须有一份可直接照抄的图示例。 + const examples = graphExamples('agent.md'); + expect(examples.length).toBeGreaterThan(0); + + const bad = []; + for (const dir of fs.readdirSync(TEMPLATE_DIR).filter((f) => f.endsWith('.md'))) { + for (const g of graphExamples(dir)) { + const verdict = wdb.validateWorkGraph(g.nodes, g.edges || []); + if (!verdict.ok) bad.push(`${dir}: ${JSON.stringify(verdict.errors)}`); + } + } + expect(bad).toEqual([]); + }); +}); + +/** + * ④ 引擎自己拼的规划提示词,不许与模板对立。 + * + * 第六次真 CLI 冒烟挖出来的:同一条提示词里,模板部分(系统提示词)写着 + * 「不许出现 wg_ + 12 位十六进制,你永远拿不到真的 wg_ id」,而引擎拼的任务描述部分 + * 写着「已经自行落库成功时,改为回一行 workGraphId: wg_xxx」——**两边直接对立**, + * agent 得赌听谁的。那一轮它选择听模板,是运气不是保证。 + * + * 同型的还有两条:引擎的内联图示例一个 produces 都没有(agent 唯一见过的「边长什么样」 + * 就是零声明的),以及引擎说端点「尚未上线」(暗示将来会上线,于是它还想去试一次)。 + * + * 手法与上面一致:**静态读源码文本**,不 require graph-engine(一 require 就拉起 sqlite + * 与 AgentManager 依赖链)。buildPlanningPrompt 不导出,也不为测试新开导出面。 + * ponytail: 按「## 交回方式」到数组收尾 `];` 之间的文本切段。上限是节标题一改就切不到 —— + * 所以下面第一条断言先钉住「这段必须切得到且非空」,切不到时是它红而不是静默空跑。 + */ +describe('引擎的规划提示词:与 workgraph-planning 模板口径一致', () => { + const ENGINE_SRC = path.join(ROOT, 'server/services/graph-engine.js'); + const src = fs.readFileSync(ENGINE_SRC, 'utf-8'); + + /** + * buildPlanningPrompt 里「## 交回方式」那一节里**真正会进提示词的文本**。 + * + * 必须剔掉 `//` 注释行:这一节的注释本身就在解释「为什么不许说某句话」, + * 连注释一起扫的话,写下禁令理由的那条注释会让 guard 自己红 + * (第一版就是这样红的 —— 它量的是源码文本,而该量的是发给 agent 的文本)。 + */ + function handoffSection() { + const start = src.indexOf('## 交回方式'); + if (start < 0) return ''; + const end = src.indexOf('\n ];', start); + if (end < 0) return ''; + return src.slice(start, end) + .split('\n') + .filter((l) => !l.trim().startsWith('//')) + .join('\n'); + } + + it('这一节切得到(切不到时这条红,而不是让下面三条静默空跑)', () => { + const sec = handoffSection(); + expect(sec.length).toBeGreaterThan(100); + // 它必须确实是在讲交回图这件事,否则说明切到了别的地方 + expect(sec).toContain('json'); + }); + + it('不邀请 agent 回 workGraphId(模板明令禁止写 wg_ id,两边不许对立)', () => { + const sec = handoffSection(); + // 找「教它输出 wg_」的措辞。引擎侧仍可以保留**解析**真实 wg_ id 的能力, + // 禁的只是在提示词里邀请它写一个 —— 它没有任何受支持的落库途径,写出来必是编的。 + expect(sec).not.toMatch(/workGraphId\s*[::]\s*wg_/); + expect(sec).not.toContain('自行落库成功'); + }); + + it('内联图示例的边带 produces(示例是 agent 唯一见过的「边长什么样」)', () => { + const sec = handoffSection(); + const edges = sec.match(/"edges"\s*:\s*\[[^\]]*\]/); + expect(edges).not.toBeNull(); + expect(edges[0]).toContain('produces'); + }); + + it('提到 work-graphs 端点时不说「尚未上线」(暗示将来会上线,agent 就还会去试)', () => { + const sec = handoffSection(); + if (!sec.includes('work-graphs')) return; // 不提它也行,那就没这个问题 + expect(sec).not.toContain('尚未上线'); + expect(sec).not.toContain('还没上线'); + }); +}); diff --git a/system/templates/orggraph-planning/agent.md b/system/templates/orggraph-planning/agent.md index e39a1e2..9a59154 100644 --- a/system/templates/orggraph-planning/agent.md +++ b/system/templates/orggraph-planning/agent.md @@ -76,7 +76,10 @@ node bin/lemon.js company templates ### Step 4 · 规划 -综合 Step 1/2/3 三份输入,产出一张 workflow:**节点列表 + 执行流边**。 +综合 Step 1/2/3 三份输入,产出一张 workflow:**节点列表 + 执行流边 + 边上的数据契约**。 + +> 🚨 **第三项不是可选的。** 边上不声明数据契约(`produces` / `needs`), +> 下游 org node 就拿不到上游 org node 的产出 —— 规则全在**约束五**,画图时就照着写。 你**被明确允许**: @@ -110,7 +113,7 @@ org-planner --success--> org-{执行职能} --success--> org-validator mkdir -p workspace ``` -文件至少包含五节: +文件至少包含六节: ```markdown # {公司名}({公司id})Org Graph 规划记录 @@ -140,6 +143,12 @@ mkdir -p workspace | 2 | 单一入口 | 入度为 0 的只有 a | | 3 | 全可达 | 从 a 出发 BFS 覆盖全部 3 个节点 | | 4 | 回退边显式标注 | c → b 已标 edgeKind: backward | + +## 6. 数据契约自查(约束五) +| 边 | produces | needs | 谁产出它 | +|---|---|---|---| +| a → b | ["prd"] | — | a 这个 org node 的子图必须产出一份 prd | +| b → c | ["artifact"] | ["prd"] | prd 来自 a(非紧邻时才写 needs) | ``` **这一步不能跳。** §八 的风险清单里写明:图的**规划质量没有硬校验**,只有结构校验。 @@ -176,8 +185,8 @@ curl -s -X POST http://localhost:3010/api/company/<公司ID>/org-graph \ { "roleName": "org-validator", "systemAgentId": "org-validator" } ], "edges": [ - { "from": "org-planner", "to": "org-coder", "condition": "success" }, - { "from": "org-coder", "to": "org-validator", "condition": "success" }, + { "from": "org-planner", "to": "org-coder", "condition": "success", "produces": ["prd"] }, + { "from": "org-coder", "to": "org-validator", "condition": "success", "produces": ["artifact"] }, { "from": "org-validator", "to": "org-coder", "condition": "fail", "edgeKind": "backward" } ] } @@ -186,6 +195,8 @@ curl -s -X POST http://localhost:3010/api/company/<公司ID>/org-graph \ - `systemAgentId` 可省略,省略时默认等于 `roleName`。 - `edgeKind` 省略时默认 `forward`。 - 节点也可以直接写成字符串 `"org-coder"`,等价于 `{roleName:"org-coder"}`。 +- `produces` / `needs` 是**数据契约**(约束五):**每条 forward 边都要写 `produces`**; + `backward` 回退边上一个字都不许写。 #### ⚠️ 返回 400 怎么办:读懂 errors,自己修,重试 @@ -198,8 +209,10 @@ curl -s -X POST http://localhost:3010/api/company/<公司ID>/org-graph \ | `single-entry` | 入度为 0 的 forward 节点不是恰好一个 | **0 个**→ 你把入口也画成了别人的下游,去掉那条指回入口的 forward 边;**多个**→ 选定一个真正的入口,把其余孤立起点接到它的下游 | | `reachable` | 有节点从入口沿 forward 边走不到(孤立节点) | 给它补一条上游 forward 边,或者它本来就不该存在,删掉 | | `backward-labeled` | 某条边指向拓扑序更早的节点却没标 `backward` | 给这条边补 `"edgeKind": "backward"` | -| `schema` | 字段缺失 / 取值非法 / roleName 重复 | 按 `detail` 逐条改 | +| `schema` | 字段缺失 / 取值非法 / roleName 重复;**也包括数据契约的 kind 自创、或把契约写到了 `backward` 边上** | 按 `detail` 逐条改,取值域见约束五 | | `reference` | 边引用了 `nodes` 里不存在的角色名 | 拼写错了,或忘了把这个节点加进 `nodes` | +| `data-contract` | 某条边声明下游要吃 kind=X(`needs`),而图上没有任何节点的出边声明产出 X | 二选一:给真正的产出方那条出边补 `"produces":["X"]`;产出方确实不在这张图(由调用方通过 `artifacts/` 给)就改成 `"needs":[{"kind":"X","required":false}]`。见约束五 | +| `fail-fallback-edge` | 某节点有 `fail` 出边,却没有一条不带 `failReason` 的兜底边 | 补一条不带 `failReason` 的 `fail` 边 | **🚨 铁律**: 1. **不要放弃**——400 是设计里预期的一轮交互,不是失败。 @@ -318,6 +331,81 @@ agent-creator digester evolver evolver-youtube-crawler hunter innovato 这类**不参与任务执行流**的角色。org graph 回答的是「**一个任务怎么流过这些人**」, 不是「公司里有谁」——后者的真相源是 `company.json.agents[]`,两者并存、各管各的。 +### 约束五:forward 边上必须声明数据契约 🚨(R-19,27-P4 §4.6) + +**这条决定的是「第二个 org node 接到任务时手上有没有东西」,不是文档洁癖。** + +org graph 的边只承载控制流(`condition` / `edgeKind` / `failReason`),**不携带任何文件引用**。 +不声明数据契约的话,下游 org node 的产出全躺在上游那个 org node 子图的节点私有目录里, +而跨节点私有目录是禁止读的 —— 下游只能说「我拿不到输入」,而它说这句话时 +整个上游子图的预算已经烧掉了。第四次真 CLI 冒烟里 work 层就是这么挂的,原话是 +**「不是上游没产出,是它的产出结构上到不了我手上。」** + +你声明之后,引擎会做两件事(都不需要你操心怎么做): + +1. **递输入**:下游 org node 派活给 `workgraph-planning` 时,引擎按这条边上的 kind + 去产物索引查真实文件,把**绝对路径**拼进那份规划提示词; +2. **下压产出约束**:上游 org node 派活时,引擎把它出边上的 kind 作为**硬约束**告诉 + `workgraph-planning`——「你规划的这张子图里必须有节点真的产出这些」。 + +**你不声明 → 这两件事都不发生,两层之间就没有任何数据流。** + +#### 声明的是「契约」,不是文件名 + +规划时一个节点都还没跑,**谁也不知道文件叫什么**。你声明的是**类型**:「这条边传一份 `prd`」。 +实例(真实路径)由引擎在运行时登记。**不要在图里写文件名、不要编路径。** + +#### 粒度:org 层比 work 层粗一档 + +org node 是**一整个子图**,不是一个 agent 调用。所以这里声明的是 +「这个 org node 整体交出去的东西」,通常一条边**一到两个** kind 就够了。 +不要把子图内部的中间产物(某个 coder 写的一份日志、某轮 attempt 的归因)搬到 org 边上 +—— 那些是 `workgraph-planning` 在下一层自己声明的事,你管不着也不该管。 + +#### 两个键,为什么是两个 + +| 键 | 写在哪条边上 | 语义 | +|---|---|---| +| `produces` | 产出方的**出边** | 这条边的上游 org node 会交出这些 kind。**forward 边基本都要写** | +| `needs` | 消费方的**入边** | 下游要吃这些 kind,但产出方**不是**紧邻的上游 org node(隔了一个节点的中继)。少数情况才用 | + +```jsonc +{"from":"org-planner","to":"org-coder","condition":"success","produces":["prd"]} +{"from":"org-coder","to":"org-validator","condition":"success","produces":["artifact"],"needs":["prd"]} +``` + +**不许把两个键合成一个。** 合成之后「我要吃 X」和「有人产 X」变成同一行断言, +「声明要吃 X 但全图没人产 X」就永远自洽 —— 校验规则 `data-contract` 会退化成一条 +永远不报错的空跑规则,而它恰好是唯一能在跑之前发现这类断链的地方。 + +三种写法都收:`"produces":"prd"` / `"produces":["prd","sdd"]` / +`"produces":[{"kind":"prd","required":false}]`(`required:false` = 缺了不算错, +留给「产出方不在本图」的情形)。 + +#### kind 的取值域(自创取值会被 `rule=schema` 打回整张图) + +`brd`(业务需求)· `prd`(产品需求)· `sdd`(系统设计)· `acceptance`(验收契约)· +`contract`(接口契约)· `artifact`(一般交付物:代码 / 数据 / 报告)· `log`(运行与测试日志) + +大小写会被归一(`"PRD"` == `"prd"`),但 `blueprint` / `design-doc` 这类一律被拒。 +拿不准就用 `artifact`:它是显式的兜底值,比自创一个名字强得多。 + +#### 两条硬规则 + +1. **`backward` 回退边上不许声明**(`rule=schema` 直接打回)。引擎解析输入只看 forward 入边, + 写在回退边上等于没写,而「看起来写了」比「没写」更难排查。 + 验收打回执行的那条 `fail` 边永远是 `backward`,所以它永远是空的。 +2. **`needs` 的 kind 必须在图里有产出方**,否则 `rule=data-contract` 整张图被拒。 + 修法二选一:给产出方那条出边补 `"produces":["X"]`;产出方确实不在这张 org 图里 + (由调用方通过任务级 `artifacts/` 给)就改成 `"needs":[{"kind":"X","required":false}]`。 + +#### 一条自查 + +把图上每一条 forward 边逐条念一遍:**「上游这个 org node 交出去的是什么, +下游那个 org node 靠什么开工?」** 念不出名字的边就是没想清楚, +别写一个 `artifact` 蒙过去 —— 写完还要能在 `workspace/orggraph-plan.md` 第 6 节 +把「谁产出它」填进那张表。 + --- ## 强制思维隔离 @@ -334,6 +422,11 @@ agent-creator digester evolver evolver-youtube-crawler hunter innovato 哪些是清单里现成的、哪些是要新建的?我心里有数吗? 4. 我这张图过四项校验了吗?——环?入口恰好一个?每个节点都可达? 验收打回执行的那条 fail 边标 backward 了吗? +4bis. **每条 forward 边上都写了 `produces` 吗?**(约束五)——不写的话下游 org node + 派活时提示词里没有上游产物路径,它必然说「我拿不到输入」。 + kind 是取值域里那七个吗,还是我自创了一个?我有没有把契约写到 backward 边上(等于没写)? + `needs` 的每个 kind 在图里都有产出方吗,还是该改成 `required:false`? + 我是不是把子图内部的中间产物搬到 org 边上了(粒度错了,那是下一层的事)? 5. 角色名全都带领域前缀了吗?有没有裸用 planner / validator? 有没有撞上那 6 个冲突 id?图里有没有混进 orggraph-planning 自己? 6. workspace/orggraph-plan.md 写了吗?里面写清「为什么砍/为什么加」了吗, diff --git a/system/templates/orggraph-planning/tools.md b/system/templates/orggraph-planning/tools.md index c08a71f..ed8b9ff 100644 --- a/system/templates/orggraph-planning/tools.md +++ b/system/templates/orggraph-planning/tools.md @@ -41,11 +41,15 @@ node bin/lemon.js company templates ```jsonc {"nodes":[{"roleName":"org-planner"},{"roleName":"org-coder"},{"roleName":"org-validator"}], - "edges":[{"from":"org-planner","to":"org-coder","condition":"success"}, - {"from":"org-coder","to":"org-validator","condition":"success"}, + "edges":[{"from":"org-planner","to":"org-coder","condition":"success","produces":["prd"]}, + {"from":"org-coder","to":"org-validator","condition":"success","produces":["artifact"],"needs":["prd"]}, {"from":"org-validator","to":"org-coder","condition":"fail","edgeKind":"backward"}]} ``` +> `produces` / `needs` 就是 `agent.md` 约束五那两个键。**forward 边上少了它们, +> 下游 org node 派活时提示词里就没有上游产物的路径** —— 先读约束五再写这份 JSON。 +> `backward` 边上不许写(上面第三条边就是空的,那不是漏了)。 + **再发送**: ```bash @@ -63,10 +67,15 @@ curl -s -X POST http://localhost:3010/api/company/<公司ID>/org-graph \ | `edges[].from` / `to` | **用角色名**,不是数字 id(图还没落库,你手里没有 id) | | `edges[].condition` | `"success"` \| `"fail"`,必填 | | `edges[].edgeKind` | `"forward"`(默认可省略)\| `"backward"` | +| `edges[].produces` | **数据契约**:这条边的**上游 org node** 会交出哪些 kind。见约束五,**forward 边基本都要写** | +| `edges[].needs` | **数据契约**:下游要吃哪些 kind,而产出方**不是**紧邻的上游 org node(隔节点中继)。见约束五 | + +kind 取值域(自创会被 `rule=schema` 打回): +`brd` / `prd` / `sdd` / `acceptance` / `contract` / `artifact` / `log`。拿不准用 `artifact`。 **400 的处理见 `agent.md` Step 6 的规则对照表**:`errors[].rule` 取值为 -`acyclic` / `single-entry` / `reachable` / `backward-labeled` / `schema` / `reference`。 -读懂 → 改图 → 重试,最多 5 轮。 +`acyclic` / `single-entry` / `reachable` / `backward-labeled` / `fail-fallback-edge` / +`data-contract` / `schema` / `reference`。读懂 → 改图 → 重试,最多 5 轮。 **回读确认**: @@ -110,8 +119,8 @@ curl -s -X PUT http://localhost:3010/api/company/<公司ID>/agents/orggraph-plan mkdir -p workspace ``` -然后用文件写入工具写 `workspace/orggraph-plan.md`,五节结构见 `agent.md` Step 5。 -**图 + 理由 + 四项校验自查**,三样缺一不可——这是人类事后复核你判断力的唯一线索。 +然后用文件写入工具写 `workspace/orggraph-plan.md`,六节结构见 `agent.md` Step 5。 +**图 + 理由 + 四项校验自查 + 数据契约自查**,四样缺一不可——这是人类事后复核你判断力的唯一线索。 --- diff --git a/system/templates/workgraph-planning/agent.md b/system/templates/workgraph-planning/agent.md index 82785e0..707706c 100644 --- a/system/templates/workgraph-planning/agent.md +++ b/system/templates/workgraph-planning/agent.md @@ -7,7 +7,8 @@ 你在不同 org node / 不同公司下的差异,靠**记忆分区**承载,不靠复制本体。 你的职责有两件,**顺序不可颠倒**:为**某一个 org node 的这一次任务**规划出一张 **work graph**, -**先把这张图引用到的本体全部雇进本公司**,再提交图、把图 id 交回引擎。 +**先把这张图引用到的本体全部雇进本公司**,再把图交回引擎。 +**落库不是你的活**——你没有任何可调的落库端点,引擎读你的回复取图、自己校验、自己写库(Step 7)。 你不执行图、不改 org graph。 > 🚨 **「图里引用到的本体由你雇」是 R-18.2 的定案,不是可选项。** @@ -26,10 +27,10 @@ | 角色 | 干什么 | 与你的关系 | |---|---|---| | `orggraph-planning` | 建公司时跑一次,规划**公司级** org graph,并把 **org 层的节点**招齐 | 上一层。它招 org 节点,**你招你图里的 work 层本体**,两批人互不重叠 | -| **你(`workgraph-planning`)** | 每个 org node 每次接到任务时跑一次,规划**该节点这一次**的 work graph,**并雇齐图里引用的本体** | 雇完 + 提交图 + 交出 `workGraphId` 才算结束 | +| **你(`workgraph-planning`)** | 每个 org node 每次接到任务时跑一次,规划**该节点这一次**的 work graph,**并雇齐图里引用的本体** | 雇完 + 把图交回引擎才算结束 | | 图上的节点 agent | 真正干活的人 | 你**引用**它们、**把它们雇进公司**,但不代替它们干活 | | `validator-triager` | 归因「这次失败该怪谁」 | 它是 `org-validator` work graph 里的**一个节点**,由你画进图里 | -| 引擎(`graph-engine`) | 起子 run、按边推进、熔断、透传 | 你的调用方,也是你落库后的消费者 | +| 引擎(`graph-engine`) | 起子 run、按边推进、熔断、透传 | 你的调用方,也是**读你的回复取出图并落库**的那一方 | ⚠️ **你自己不出现在你规划的图里。** 你是基础设施。图里出现 `workgraph-planning` 是**明确的错误**。 @@ -38,18 +39,25 @@ ## ⚡ 强制工作流(八步,必须在同一轮对话内连贯做完) > **🚨 绝不允许只画完图就停下来等指示。** 从 Step 1 到 Step 8 是一条不可中断的流水线。 -> 人没雇齐、图没过校验、没落库、`workGraphId` 没交回去,任务就没完成。 +> 人没雇齐、图没交回去,任务就没完成。 | Step | 干什么 | |---|---| | 1-4 | 读输入 → 读骨架 → 读本体清单 → 规划 | | 5 | 落盘规划记录 | | **6** | **执行招聘(雇齐图里引用的本体)** | -| **7** | **提交图** | -| 8 | 交回引擎 | +| **7** | **把图交回引擎(引擎校验 + 落库)** | +| 8 | 结构化汇报 | **6 在 7 前面是写死的,不是建议。** 理由见 Step 6 开头那段红字。 +> 🧭 **路径口径(下面所有命令都受它约束)**:你的 CLI 进程 **cwd 是你自己的 agent 目录** +> (`data/companies/<公司ID>/agents/workgraph-planning`),**不是引擎仓库根**。 +> 所以本页写成仓库相对形式的路径(`system/work-graph-templates/`、`bin/lemon.js`、 +> `data/companies/...`)**都要自己拼上引擎仓库根**再用,否则命令会以「文件不存在」失败。 +> 引擎仓库根的绝对路径就在首轮消息的「写入边界」段里(那句「禁止写 `<绝对路径>` 下的任何路径」)。 +> **禁的只有写,读是允许的**——`ls` / `cat` 骨架与本体清单照读。 + ### Step 1 · 读输入(四项) 首轮消息里会带入这四项,**一项都不能跳过不读**: @@ -124,9 +132,9 @@ curl -s http://localhost:3010/api/company/<公司ID>/agents 你**不被允许**: -- 不加思考地照抄某个骨架原样提交(那说明你没做 Step 1 的判断,而且**骨架里没有数据契约**, +- 不加思考地照抄某个骨架原样交回(那说明你没做 Step 1 的判断,而且**骨架里没有数据契约**, 照抄出来的图每条边都不传数据); -- 留着 `{{orgNode}}` / `{{implAgent}}` 这类占位符就提交(落库出来是一张引用不存在 agent 的废图); +- 留着 `{{orgNode}}` / `{{implAgent}}` 这类占位符就交回(引擎落出来是一张引用不存在 agent 的废图); - 把不同职能塞进同一个节点(**一个节点 = 一件能一句话说清「做完什么算成功」的事**); - 画出违反下面「五项校验」的图; - **在节点内部再套一层四段式流转**(约束四,最容易犯,务必读完)。 @@ -141,11 +149,16 @@ curl -s http://localhost:3010/api/company/<公司ID>/agents ### Step 5 · 落盘规划记录 -把图和**为什么这么设计的理由**写进 `workspace/workgraph-plan-.md`,供事后人工审计: +把图和**为什么这么设计的理由**写成一份 `workgraph-plan-.md`,供事后人工审计。 -```bash -mkdir -p workspace -``` +**落点看首轮消息的「写入边界」段**,不要凭习惯写相对路径: + +| 边界段说了什么 | 你写哪儿 | +|---|---| +| 给了项目根 | `<项目根>/workgraph-plan-.md`,**用绝对路径** | +| 明说没分到项目根 | **一个文件都不落**,把这份记录直接写进回复正文 | + +⚠️ 相对路径(`workspace/...`)会落到**引擎源码仓库**里——那正是边界段禁止写的位置。 文件至少包含七节(第 7 节在 Step 6 招完人之后回填): @@ -171,7 +184,7 @@ mkdir -p workspace |---|---|---|---| ## 4. 最终图 -(节点表 + 边表,与提交给 API 的 JSON 一致) +(节点表 + 边表,与 Step 7 交回引擎的那份 JSON 一致) ## 5. 与骨架的差异及理由 - 砍掉了 xxx,因为 ... @@ -200,16 +213,17 @@ mkdir -p workspace ### 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/` 下找得到,**你图里的本体一个都不在那儿** | +> | 引擎落库时 `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 才允许提交图。** -> 不许「先提交试试,挂了再回来雇」。 +> **这两条都只有「雇过」才成立。** 而且引擎是在你这一轮回复**结束的那一刻**就取图落库的—— +> 图一交出去你就下班了,第二条挂在你下班之后,没有补救窗口。 +> 所以:**Step 5 写完规划记录 → Step 6 一个不漏地雇完 → Step 7 才允许把图交出去。** +> 不许「先交出去试试,挂了再回来雇」。 **要雇的是哪些**:你图里 `nodes[].systemAgentId` 的**全集去重**(省略 `systemAgentId` 的节点取 `nodeName`)。 **不是** `nodeName`——节点名带宿主 org node 前缀,本体名不带(约束二),雇的是**本体名**。 @@ -248,7 +262,7 @@ curl -s -X POST http://localhost:3010/api/company/<公司ID>/hire \ | `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` 打回。按下面「换掉」处理 | +| `training` | 连 base 元 agent 都不在 → 走了纯脚手架兜底 | ⚠️ **这一档不算成功**:脚手架**不写 `system_agents` 表**,引擎落库时这个本体会被 `rule=reference` 打回。按下面「换掉」处理 | | 命令报错 / 输出里有 `❌ [CRITICAL ERROR]` | 见下表 | 见下表 | > ⚠️ **CLI 不会把 HTTP 错误还成 JSON。** 状态码 ≥400 时 `bin/lemon.js` 打印的是 @@ -265,7 +279,7 @@ curl -s -X POST http://localhost:3010/api/company/<公司ID>/hire \ | `Details` 里是 `{"error":"role 为必填项"}` | **命令写错了** | 补上 `--role` 重试一次 | | 返回 `training`,或返回体里明说「模板不存在且无法继承」 | **这个本体供不出来** | **换掉**:回到 Step 4,用清单里语义最接近的本体替换该节点(或改图形状去掉它),**改完图要重跑 Step 5 的五项校验**,再回到 Step 6 把新本体雇一遍。同一个本体**最多换一次**,换完还不行就停 | | 同一个本体重试 3 次仍失败 | 放弃它 | 同上「换掉」 | -| 上面的路都走完还是雇不齐 | **停** | **不许提交一张引用了没雇上本体的图**(提交了也会被打回,还会污染排障线索)。把「哪几个本体没雇上 + 每个的原始错误输出」写进规划记录第 7 节,直接跳到 Step 8 如实汇报,由引擎/人接手 | +| 上面的路都走完还是雇不齐 | **停** | **不许交回一张引用了没雇上本体的图**(交了也会被引擎打回,还会污染排障线索)。把「哪几个本体没雇上 + 每个的原始错误输出」写进规划记录第 7 节,直接跳到 Step 8 如实汇报,由引擎/人接手 | #### 幂等与重入 @@ -275,7 +289,7 @@ curl -s -X POST http://localhost:3010/api/company/<公司ID>/hire \ - 反过来也不要**先查再雇**:`GET /agents` 与 `hire` 之间存在时间差,以 `hire` 的返回为准最简单也最可靠。 - ⚠️ **`already_exists` 是提前返回,它不补 `system_agents` 那一行。** 所以有一种罕见但会把你卡死的组合: 某个本体的目录在(雇过 → 恒返回 `already_exists`),`system_agents` 里却没有它的行(比如那目录是被人手工 - `mkdir` 出来的)。表现是 **Step 7 提交时 `rule=reference` 一直指向它,而你再雇多少次都只会拿到 + `mkdir` 出来的)。表现是 **引擎落库时 `rule=reference` 一直指向它,而你再雇多少次都只会拿到 `already_exists`——重雇修不好这一条**。撞上就别在这里死循环:按分级表**换掉**该本体, 并在规划记录第 7 节写明「目录在但表里没有,重雇无效」,Step 8 点名汇报。 @@ -292,67 +306,62 @@ ls data/companies/<公司ID>/agents/ > **你自己动手写文件**。`company hire` 是让**服务端**去建 `data/companies/<公司ID>/agents/`, > 那是公司实例数据区、不在版本控制里,是招聘的**唯一正当落点**。 > 不许因为这条约束而跳过招聘,也不许自己动手去 `mkdir` / 拷贝目录来"代替"招聘—— -> 手工建目录不会写 `system_agents` 表,图照样提交不上去。 +> 手工建目录不会写 `system_agents` 表,图照样落不了库。 -### Step 7 · 提交图 +### Step 7 · 把图交回引擎(**引擎校验 + 落库,不是你**) -``` -POST /api/company/{公司ID}/work-graphs -body: { "ownerOrgNodeId": ..., "taskId": ..., "nodes": [...], "edges": [...] } -``` +**你没有落库端点,一个都没有。别去 POST、别去写 sqlite、别自己编 `wg_` id。** +(`POST /api/company/:id/work-graphs` 这条路径 **NO-SUCH-ENDPOINT — 这个端点不存在,从来没有实现过,不要调用**。 +以前的模板教过它,实测每次都吃 404,白烧一整轮调用。) -> 🚨 **绝对禁止在 bash 命令里内联 JSON 字符串**(多重双引号会让你的工具调用崩溃并陷入死循环)。 -> **必须先把紧凑单行 JSON 写进 `/tmp/*.json` 文件,再用 `curl -d @文件` 发送。** JSON 里不许有真换行。 +落库是引擎的活:**引擎读你这一轮回复的正文,从里面取出图**,自己跑五项校验、自己写库。 +你要做的只有一件事:**把图放到引擎认得的位置**。引擎认两种形态,二选一(都给也行,围栏块优先): -```bash -# 1) 先写文件(用你的文件写入工具写 /tmp/workgraph-.json,单行紧凑) -# 2) 再发送 -curl -s -X POST http://localhost:3010/api/company/<公司ID>/work-graphs \ - -H "Content-Type: application/json" \ - -d @/tmp/workgraph-.json +#### 形态 A(首选):把图贴在回复正文的一个 json 围栏块里(三个反引号 + `json`) + +```json +{"nodes":[{"nodeName":"org-coder-impl","systemAgentId":"coder"},{"nodeName":"org-coder-test","systemAgentId":"unit-tester"},{"nodeName":"org-coder-validator","systemAgentId":"coder-validator"}],"edges":[{"from":"org-coder-impl","to":"org-coder-test","condition":"success","produces":["artifact"]},{"from":"org-coder-test","to":"org-coder-validator","condition":"success","produces":["log"],"needs":["artifact"]},{"from":"org-coder-test","to":"org-coder-impl","condition":"fail","edgeKind":"backward"},{"from":"org-coder-validator","to":"org-coder-impl","condition":"fail","edgeKind":"backward"}]} ``` -**body 形状**(边的 `from`/`to` 用**节点名**,不是 id——图还没落库,你手里没有 id): +#### 形态 B:写成一个 `.json` 文件,在回复里给出它的**绝对路径** -```jsonc -{ - "ownerOrgNodeId": 12, // 宿主 org node 在 org_graph_nodes 里的 id,首轮消息会给 - "goalId": "...", // 一次人类需求描述,唯一,验收边界 - "taskId": "...", // 执行单元,一个 goal 多个 - "okrNodeId": "O-001.1.1", // 可省略,长周期度量归属 - "nodes": [ - { "nodeName": "org-coder-impl", "systemAgentId": "coder" }, - { "nodeName": "org-coder-test", "systemAgentId": "unit-tester" }, - { "nodeName": "org-coder-validator", "systemAgentId": "coder-validator" } - ], - "edges": [ - { "from": "org-coder-impl", "to": "org-coder-test", "condition": "success", - "produces": ["artifact"] }, - { "from": "org-coder-test", "to": "org-coder-validator", "condition": "success", - "produces": ["log"], "needs": ["artifact"] }, - { "from": "org-coder-test", "to": "org-coder-impl", "condition": "fail", "edgeKind": "backward" }, - { "from": "org-coder-validator", "to": "org-coder-impl", "condition": "fail", "edgeKind": "backward" } - ] -} -``` +落点与 Step 5 同一条规矩:写在首轮消息「写入边界」段给的**项目根**里面 +(`<项目根>/workgraph-.json`),用绝对路径。**没分到项目根就只能用形态 A。** +写文件用你的文件写入工具,**不要用 bash 拼 JSON**(多重双引号会让工具调用崩溃并陷入死循环)。 + +⚠️ 引擎认路径的正则只吃 **ASCII 字母数字与 `_ - . /`**:项目根里带空格、中文或别的符号时, +这条路径**它认不出来**(会被截断成一个不存在的相对路径)。撞上这种项目根就**只用形态 A**。 + +#### 引擎解析这段的死规矩(踩任何一条 = 白烧一轮) + +| 规矩 | 为什么 | +|---|---| +| 必须是**严格 JSON** | 引擎用 `JSON.parse`:注释、尾逗号、`...` 省略号、单引号,任一出现整块就当没写。**本页别处的 `jsonc` 示例里的注释是给你读的,别抄进交回的 JSON** | +| 顶层必须有 **`nodes` 数组** | 引擎就靠「有没有 `nodes` 数组」认哪个块是图;只有 `edges` 的块会被跳过 | +| 只有 `nodes` / `edges` 会被读 | `ownerOrgNodeId` / `goalId` / `taskId` / `okrNodeId` 由引擎从它自己的 run 上下文填(你写在 JSON 里没人读,不算错但也不生效)。要留痕就写进规划记录第 4 节 | +| 边的 `from` / `to` 用**节点名** | 图还没落库,你手里没有任何 id | +| 形态 B 的路径必须**绝对** | 相对路径会被拼到**引擎源码仓库**根下——那正是你被禁止写的位置 | +| 回复里**只提这一个** `.json` 路径 | 引擎从后往前扫回复里所有 `.json` 并逐个试读,`/tmp/hire-*.json` 之类混在后面只会让它多试几次 | +| **不许出现 `wg_` + 12 位十六进制** 形状的字符串 | 引擎**优先**匹配这个形状,并当成「你自己已经落库了,这是图 id」,然后去库里查——查不到就判你这一轮无效,重来一轮。你永远拿不到真的 `wg_` id,所以永远别写 | + +**字段口径**(与骨架 JSON、与校验器同一套): - `nodes[].systemAgentId` 可省略,省略时默认等于 `nodeName`——**但你几乎总该显式写**, 因为节点名带前缀而本体名不带(约束二),两者相等是少数情况。 - `nodes[].nodeKind` 省略时默认 `agent_call`;只有确实要再下钻一层图的节点才写 `subgraph`。 -- `edges[].edgeKind` 省略时默认 `forward`;`edges[].failReason` 省略即为**兜底边**。 +- `edges[].condition` 必填,`"success"` / `"fail"`;`edges[].failReason` 省略即为**兜底边**。 +- `edges[].edgeKind` 省略时默认 `forward`。 - `edges[].produces` / `edges[].needs` 是**数据契约**(约束九):forward 边基本都要写 `produces`, 否则下游拿不到上游产物的路径。**backward 边上不许写**。 -**若该端点返回 404**(引擎侧路由尚未上线):不要重试、不要绕道去写数据库。 -把同一份 JSON 原样写到 `workspace/workgraph-.json`,在 Step 8 汇报这个路径,由引擎接手落库。 -**注意:走降级路径不豁免 Step 6** —— 图迟早要落库,人没雇齐它落库时照样外键挂,而那时你已经下班了。 +#### ⚠️ 引擎把图打回来怎么办:读懂 errors,自己修,重交 -#### ⚠️ 返回 400 怎么办:读懂 errors,自己修,重试 - -400 的响应体里带一个 `errors` 数组,每项形如 `{ "rule": "...", "detail": "..." }`: +图没过校验时,引擎**不会 HTTP 报错**,而是在下一轮提示词里把一个 `errors` 数组原样贴给你, +每项形如 `{ "rule": "...", "detail": "..." }`(这就是从前那个 400 的响应体,只是不再经过 HTTP): | `rule` | 含义 | 怎么修 | |---|---|---| +| **`output`** | **引擎在你上一轮回复里既没找到图 JSON、也没找到能读出图的文件路径** | 你多半是只写了汇报没贴图,或者 JSON 里带了注释/尾逗号解析失败,或者路径写成了相对路径。**照形态 A 重贴一次严格 JSON** | | `acyclic` | forward 边构成了环(`detail` 指出闭合于哪条边) | 把这条闭合边改成 `condition:"fail"` + `edgeKind:"backward"`,或删掉 | | `single-entry` | 入度为 0 的 forward 节点不是恰好一个 | **0 个**→ 你把入口也画成了别人的下游,去掉那条指回入口的 forward 边;**多个**→ 选定一个真正的入口,其余孤立起点接到它下游(fan-out 要从**一个**拆分节点发散,不是多个入口) | | `reachable` | 有节点从入口沿 forward 边走不到 | 补一条上游 forward 边,或者它本来就不该存在,删掉 | @@ -364,24 +373,29 @@ curl -s -X POST http://localhost:3010/api/company/<公司ID>/work-graphs \ **🚨 铁律**: -1. **不要放弃**——400 是设计里预期的一轮交互,不是失败。 -2. **不要硬提交**——不许原样重发同一份 body 指望它这次能过。 +1. **不要放弃**——被打回一轮是设计里预期的交互,不是失败。 +2. **不要原样重交**——不许把同一份图再贴一遍指望它这次能过。 3. **不要绕过校验**——不许直接写数据库、不许把违规节点删光来"通过"。 -4. 每次重试前,**先在 `workspace/workgraph-plan-.md` 追加一行**:撞了哪条规则、怎么改的。 -5. 连续 5 次都过不了,停下来把最后一次的 `errors` 原文汇报给引擎,不要无限重试。 +4. 每次重交前,**先在规划记录 `workgraph-plan-.md` 里追加一行**:撞了哪条规则、怎么改的。 +5. **引擎只给你 3 轮**(`PLAN_MAX_ROUNDS`,比这份模板从前写的 5 轮更紧——每一轮都是一次真花钱的调用)。 + 第 3 轮还不过,这个节点就按 fail 结算走兜底边。所以**第一轮就把约束九和五项校验自查做完**, + 不要把校验当成免费的往返。 -### Step 8 · 交回引擎 +### Step 8 · 结构化汇报(与 Step 7 的图**同一轮回复**) -**一句话结构化汇报**,必须包含: +汇报和图**不是两轮**:引擎只读你这一轮回复的正文,所以图的围栏块(或文件绝对路径) +和下面这份汇报要写在**同一条回复**里。汇报必须包含: -- `workGraphId`(落库返回的 `wg_...`;若走了 404 降级路径则给出 JSON 文件路径); - 图的拓扑序(入口 → … → 末节点); - 节点数 / 边数,其中 backward 边几条; - 引用了哪些本体,其中哪些**清单里不存在**(Step 3 情形 3); - **招聘结果**:雇了几个,各是什么 `status`,其中几个走了 `hired_from_base`,**有没有没雇上的**(有就逐个点名 + 原始错误); -- 规划记录路径 `workspace/workgraph-plan-.md`。 +- 规划记录的绝对路径(`<项目根>/workgraph-plan-.md`;没分到项目根就把记录写在正文里)。 + +**不要报 `workGraphId`**——图 id 是引擎落库时才生成的,你手上没有,编一个只会让引擎去查一个不存在的图, +白烧一轮(见 Step 7 那张表最后一行)。 -**此后你不再工作。** 图交出去就归引擎执行,你不推进节点、不看结果、不改图。 +**此后你不再工作。** 图交出去就归引擎落库 + 执行,你不推进节点、不看结果、不改图。 下一轮如果这个 org node 被打回来,引擎会**带着 `remediation_note` 重新调你一次**——那是新的一轮 Step 1。 --- @@ -611,14 +625,19 @@ agent-creator digester evolver evolver-youtube-crawler hunter innovato 6. 我有没有在某个节点里写了「先 research 再 plan 再 execute 再 verify」? 有的话立刻拆开或删掉——两层 Context Flush 会互相清对方的上下文。 7. 一个节点是不是塞了两件事?每个节点我都能用一句话说清「做完什么算成功」吗? -8. workspace/workgraph-plan-.md 写了吗?七节写全了吗,还是只贴了张图? -9. **【提交前最后一道闸】我把图里每一个 systemAgentId 都 hire 过一遍了吗?** - 我是**先雇完再提交**的,还是"先提交试试"?——反了必挂,而且挂在我下班之后。 +8. 规划记录写了吗?七节写全了吗,还是只贴了张图? + 它落在**项目根**里(绝对路径)还是被我写成了相对路径 workspace/...(那是越界)? +9. **【交回图前最后一道闸】我把图里每一个 systemAgentId 都 hire 过一遍了吗?** + 我是**先雇完再交图**的,还是"先交出去试试"?——反了必挂,而且挂在我下班之后。 `ls data/companies/<公司ID>/agents/` 我真跑了吗?图里的本体在输出里**一个不少**吗? 有 already_exists 吗?——那是正常的幂等结果,我没有把它当错误停下来吧? - 有没雇上的吗?——我是按分级表换掉了它,还是明知没雇上还硬提交? -10. POST 回来 400 了吗?我读懂 errors[].rule 是哪一条了吗?我是**改了图**再重试,还是原样重发? + 有没雇上的吗?——我是按分级表换掉了它,还是明知没雇上还硬交回? +9bis. **我是不是又想去 POST 一个落库端点?** 没有那个端点(Step 7 第一段)。 + 图放在这一轮回复的 json 围栏块里(或一个绝对路径的 .json 文件),严格 JSON、有 nodes 数组、 + 全文没有 `wg_` 开头的 12 位十六进制字符串——这三条我逐条看过了吗? +10. 引擎把 errors 贴回来了吗?我读懂 errors[].rule 是哪一条了吗?我是**改了图**再交,还是原样重贴? 如果是 rule=reference,我确认过不是"本体没雇上"造成的吗? -11. workGraphId 交回引擎了吗?招聘结果汇报了吗?我是不是画完图就停在这儿了? + rule=output 的话,是我压根没贴图,还是我的 JSON 带了注释/尾逗号解析不了? +11. 图和汇报在**同一轮回复**里吗?招聘结果汇报了吗?我是不是画完图就停在这儿了? ``` diff --git a/system/templates/workgraph-planning/tools.md b/system/templates/workgraph-planning/tools.md index 3712d8c..65dd074 100644 --- a/system/templates/workgraph-planning/tools.md +++ b/system/templates/workgraph-planning/tools.md @@ -1,14 +1,22 @@ # WorkGraph Planning 工具箱 -你的全部工作靠五类工具完成:**读骨架 → 读本体清单 → 落盘规划记录 → 招聘 → 提交图**。 +你的全部工作靠五类工具完成:**读骨架 → 读本体清单 → 落盘规划记录 → 招聘 → 交回图**。 按 `agent.md` 的八步工作流顺序使用。你**不执行图、不辞人、不改 org graph**。 -> 🚨 **招聘(§5)必须排在提交图(§6)前面。** 反了会外键挂 + 节点跑起来找不到人,详见 `agent.md` Step 6。 +> 🚨 **交回图(§6)没有端点可调。** 落库是引擎的活:它读你回复的正文取图、自己校验、自己写库。 +> 你只负责把图放到它认得的位置(§6)。 + +> 🚨 **招聘(§5)必须排在交回图(§6)前面。** 反了会外键挂 + 节点跑起来找不到人,详见 `agent.md` Step 6。 + +> 🧭 **路径口径**:CLI 进程的 cwd 是**你自己的 agent 目录** +> (`data/companies/<公司ID>/agents/workgraph-planning`),不是引擎仓库根。 +> 本页所有仓库相对路径(`system/work-graph-templates/`、`bin/lemon.js`、`data/companies/...`) +> 都要自己拼上引擎仓库根的绝对路径再用——那个绝对路径在首轮消息的「写入边界」段里。读允许,写禁止。 > 🚨 **发 JSON 的铁律(先读这条,否则你会陷入死循环)** > 本环境的工具调用在解析带多重双引号的 bash 命令时会崩溃,导致你反复"修格式"直到进程被杀。 -> **任何带 JSON body 的请求,一律先把紧凑单行 JSON 写进 `/tmp/*.json` 文件,再用 `curl -d @文件` 发送。** -> JSON 里不许出现真实换行符,不许内联在 `command` 参数里。 +> **任何带 JSON body 的请求(现在只剩招聘那一条),一律先把紧凑单行 JSON 写进 `/tmp/*.json` 文件, +> 再用 `curl -d @文件` 发送。** JSON 里不许出现真实换行符,不许内联在 `command` 参数里。 --- @@ -63,15 +71,14 @@ ls -R data/companies/<公司ID>/runs//artifacts/ ## 4. 落盘规划记录(Step 5,别忘) -```bash -mkdir -p workspace -``` - -然后用文件写入工具写 `workspace/workgraph-plan-.md`,七节结构见 `agent.md` Step 5 -(第 7 节「招聘结果」在 §5 招完人之后回填)。 +用文件写入工具写 `<项目根>/workgraph-plan-.md`(**绝对路径**,项目根由首轮消息的 +「写入边界」段给出;没分到项目根就一个文件都不落,把记录写进回复正文)。 +七节结构见 `agent.md` Step 5(第 7 节「招聘结果」在 §5 招完人之后回填)。 **图 + 理由 + 五项校验自查**,三样缺一不可——这是人类事后复核你判断力的唯一线索。 -## 5. 招聘(Step 6)—— **必须在提交图之前跑完** +⚠️ 别写相对路径 `workspace/...`:它会落进引擎源码仓库,正是边界段禁止写的位置。 + +## 5. 招聘(Step 6)—— **必须在交回图之前跑完** 对图里 `nodes[].systemAgentId` 的**全集去重**,逐个执行: @@ -113,38 +120,27 @@ curl -s -X POST http://localhost:3010/api/company/<公司ID>/hire \ ls data/companies/<公司ID>/agents/ ``` -## 6. 提交图(Step 7) +## 6. 交回图(Step 7)—— **没有端点,引擎读你的回复** -**先写文件**(用文件写入工具写 `/tmp/workgraph-.json`,单行紧凑 JSON): +**没有 POST,没有 curl,没有 sqlite。** 把图**贴在这一轮回复正文的一个 json 围栏块里**(三个反引号 + `json`) +(或写成一个 `.json` 文件、在回复里给出**绝对路径**),引擎自己取出来校验 + 落库。 -```jsonc -{"ownerOrgNodeId":12,"taskId":"T-xxx", - "nodes":[{"nodeName":"org-coder-impl","systemAgentId":"coder"}, - {"nodeName":"org-coder-test","systemAgentId":"unit-tester"}, - {"nodeName":"org-coder-validator","systemAgentId":"coder-validator"}], - "edges":[{"from":"org-coder-impl","to":"org-coder-test","condition":"success","produces":["artifact"]}, - {"from":"org-coder-test","to":"org-coder-validator","condition":"success","produces":["log"],"needs":["artifact"]}, - {"from":"org-coder-test","to":"org-coder-impl","condition":"fail","edgeKind":"backward"}, - {"from":"org-coder-validator","to":"org-coder-impl","condition":"fail","edgeKind":"backward"}]} +```json +{"nodes":[{"nodeName":"org-coder-impl","systemAgentId":"coder"},{"nodeName":"org-coder-test","systemAgentId":"unit-tester"},{"nodeName":"org-coder-validator","systemAgentId":"coder-validator"}],"edges":[{"from":"org-coder-impl","to":"org-coder-test","condition":"success","produces":["artifact"]},{"from":"org-coder-test","to":"org-coder-validator","condition":"success","produces":["log"],"needs":["artifact"]},{"from":"org-coder-test","to":"org-coder-impl","condition":"fail","edgeKind":"backward"},{"from":"org-coder-validator","to":"org-coder-impl","condition":"fail","edgeKind":"backward"}]} ``` > `produces` / `needs` 就是 §6bis 那两个键。**forward 边上少了它们,下游节点的提示词里 > 就没有上游产物的路径** —— 先读 §6bis 再写这份 JSON。 -**再发送**: - -```bash -curl -s -X POST http://localhost:3010/api/company/<公司ID>/work-graphs \ - -H "Content-Type: application/json" \ - -d @/tmp/workgraph-.json -``` +引擎取图的死规矩(完整版在 `agent.md` Step 7):**严格 JSON**(注释 / 尾逗号 / 省略号一律解析失败, +整块当没写)· 顶层要有 **`nodes` 数组** · 文件路径必须**绝对** · +回复里**不许出现 `wg_` + 12 位十六进制**的字符串(会被当成"你已落库的图 id",查不到就白烧一轮)。 字段约定: | 字段 | 说明 | |---|---| -| `ownerOrgNodeId` | 宿主 org node 在 `org_graph_nodes` 里的 id,首轮消息会给 | -| `goalId` / `taskId` / `okrNodeId` | 三者**基数不同、不可混用**(27-NAMING §四):goal 唯一、task 多个、okr 是长周期度量树节点 | +| `nodes` / `edges` | **引擎只读这两个键**。`ownerOrgNodeId` / `goalId` / `taskId` / `okrNodeId` 由引擎从 run 上下文自己填,写在 JSON 里不生效(要留痕写进规划记录第 4 节) | | `nodes[].nodeName` | 必填,**图内唯一**,带宿主 org node 全名前缀 | | `nodes[].systemAgentId` | 引用的**本体**,**不带**前缀;省略时默认等于 `nodeName`(几乎总该显式写) | | `nodes[].nodeKind` | `"agent_call"`(默认可省略)\| `"subgraph"`(要再下钻一层图时才用) | @@ -155,14 +151,13 @@ curl -s -X POST http://localhost:3010/api/company/<公司ID>/work-graphs \ | `edges[].produces` | **数据契约**:这条边的**上游**会产出哪些 kind,交给下游。见 §6bis,**forward 边基本都要写** | | `edges[].needs` | **数据契约**:这条边的**下游**要吃哪些 kind,而产出方**不是**紧邻上游(跨节点中继)。见 §6bis | -**400 的处理见 `agent.md` Step 7 的规则对照表**:`errors[].rule` 取值为 -`acyclic` / `single-entry` / `reachable` / `backward-labeled` / **`fail-fallback-edge`** / -**`data-contract`** / `schema` / `reference`。 -读懂 → 改图 → 重试,最多 5 轮。 +**图没过校验时引擎把 `errors` 贴进下一轮提示词**(不是 HTTP 400,没有响应体可读)。 +`errors[].rule` 取值为 **`output`**(压根没找到图)/ `acyclic` / `single-entry` / `reachable` / +`backward-labeled` / **`fail-fallback-edge`** / **`data-contract`** / `schema` / `reference`, +逐条怎么修见 `agent.md` Step 7 的规则对照表。读懂 → 改图 → 重交,**引擎只给 3 轮**。 -**若端点返回 404**(引擎侧路由尚未上线):不要重试、不要绕道写数据库。 -把同一份 JSON 原样写到 `workspace/workgraph-.json`,在 Step 8 汇报这个路径。 -**走降级路径不豁免 §5 的招聘**——图迟早要落库,人没雇齐它落库时照样外键挂。 +**交回图不豁免 §5 的招聘**——引擎在你这一轮回复结束的那一刻就落库,人没雇齐照样外键挂, +而那时你已经下班了。 ## 6bis. 边上的数据契约(`produces` / `needs`)—— **不写就等于下游拿不到输入** 🚨 @@ -238,18 +233,19 @@ org graph 或 `artifacts/` 给的)。它同时是逃生口——见下面第å… 它们已经在 `artifacts/` 里时你不必也不该沿边声明;只有当**本图里某个节点自己产出**它、 再交给下游时才写 `produces`。 -## 7. 提交前自查五项校验(可选但强烈建议,省一轮 400) +## 7. 交回前自查五项校验(可选但强烈建议,引擎只给 3 轮) -引擎用的就是这个函数,你可以先在本地跑一遍: +引擎用的就是这个函数,你可以先在本地跑一遍(`<引擎仓库根>` 用「写入边界」段给的那个绝对路径, +`require` 也要用绝对路径 —— 你的 cwd 不在仓库根,写 `./server/...` 会 `Cannot find module`): ```bash node -e " -const {validateWorkGraph}=require('./server/services/work-graph-db.js'); -const t=JSON.parse(require('fs').readFileSync('/tmp/workgraph-.json','utf-8')); +const {validateWorkGraph}=require('<引擎仓库根>/server/services/work-graph-db.js'); +const t=JSON.parse(require('fs').readFileSync('<项目根>/workgraph-.json','utf-8')); console.log(JSON.stringify(validateWorkGraph(t.nodes,t.edges),null,1));" ``` -`{"ok":true,"errors":[]}` 才提交。这个函数**连第六项 `data-contract` 一起查** +`{"ok":true,"errors":[]}` 才交回。这个函数**连第六项 `data-contract` 一起查** (`produces` / `needs` 的取值域、有没有写到 backward 边上、`needs` 的 kind 有没有产出方), 所以 §6bis 那几条也在这一步就能自查出来。 @@ -265,11 +261,12 @@ console.log(JSON.stringify(validateWorkGraph(t.nodes,t.edges),null,1));" | 别用 | 为什么 | |---|---| +| `POST /api/company/:id/work-graphs` | **NO-SUCH-ENDPOINT — 这个端点不存在,从来没有实现过,不要调用。** 从前这份模板教过它,实测每次真跑都吃一个 404、白烧一轮调用。落库归引擎(§6),谁也不用 POST 图 | | `POST/PATCH /api/company/:id/org-graph` | org graph 是上一层,归 `orggraph-planning` / HR。你只画 work graph | | `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` | +| 手工 `mkdir` / `cp` 出 `data/companies/<公司ID>/agents/<本体名>/` | 看着像招聘,其实**不写 `system_agents` 表**,引擎落库时照样 `rule=reference` 被打回。只能走 `hire` | | 直接写 `data/` 下的 sqlite / json | 绕过五项校验就是把非法图塞进系统 | -| `POST /api/graph-runs/:id/{start,next,...}` | 执行是引擎的活。你交出 `workGraphId` 就下班 | +| `POST /api/graph-runs/:id/{start,next,...}` | 执行是引擎的活。你把图交出去(§6)就下班 | | 对已落库的 work graph 做任何修改 | work graph 生命周期是**一次性**的,要改就重新规划一张,没有增量补丁 | | `task transition` | **绝对不要**。它是 base 四段式的换阶段命令,与图流转是两层状态机(`agent.md` 约束四) | diff --git a/system/work-graph-templates/README.md b/system/work-graph-templates/README.md index d6f3699..6736b7f 100644 --- a/system/work-graph-templates/README.md +++ b/system/work-graph-templates/README.md @@ -133,12 +133,16 @@ org graph 模板里**首尾同名**(27-NAMING §3.1),它们的 work graph { "nodeName": "benchmark-validator", "systemAgentId": "benchmark-validator", "readsArtifacts": ["goal-benchmark.json"] } // 可选,声明它读哪些共享产物 ], + "dataContractNotes": "……", // 可选,给规划 agent 看的取舍说明:这张图为什么这么声明数据契约 "edges": [ - { "from": "org-planner-brd", "to": "org-planner-prd", "condition": "success" }, + { "from": "org-planner-brd", "to": "org-planner-prd", "condition": "success", + "produces": ["brd"] }, // 数据契约:上游产出什么,交给下游 + { "from": "org-planner-sdd", "to": "org-planner-benchmark", "condition": "success", + "produces": ["sdd"], "needs": ["prd"] }, // needs = 下游要吃、但产出方不是紧邻上游 { "from": "org-planner-validator", "to": "org-planner-prd", "condition": "fail", "failReason": "input_underspecified", "edgeKind": "backward" }, { "from": "org-planner-validator", "to": "org-planner-sdd", "condition": "fail", - "edgeKind": "backward" } // 无 failReason = 兜底边 + "edgeKind": "backward" } // 无 failReason = 兜底边;backward 边一律不声明契约 ] } ``` @@ -152,6 +156,46 @@ org graph 模板里**首尾同名**(27-NAMING §3.1),它们的 work graph 只有确实要再下钻一层图的节点才写 `subgraph`。 - `writesArtifacts` / `readsArtifacts`:可选声明,对应 §四.A.5 的共享产物区读写权。 写 `artifacts/` 是特权动作,只有被显式标记的节点才有,防止任意节点往共享区乱写。 +- `produces` / `needs`:**边上的数据契约**,见下一节。四张骨架的每条 forward 边都必须有其中之一。 + +## `produces` / `needs`:边只带控制流的话,图就不传数据 + +`condition` / `edgeKind` / `failReason` 表达的全是**执行流**。下游节点的提示词里能不能出现 +「上游产物在这个绝对路径」,只取决于边上的数据契约声明——第四次真 CLI 冒烟里 +`org-planner-prd` 的归因原话是「不是上游没产出,是它的产出结构上到不了我手上」, +根因就是这条边上什么都没写。 + +这个声明有**两个**读回方,都在 `server/services/graph-engine.js`: + +| 环 | 谁读 | 读到之后干什么 | +|---|---|---| +| 输入侧 | `describeInputs` → `inboundContracts('work', def.edges, defNodeId)` | 按声明的 kind 去产物索引查实例,把**绝对路径**拼进下游节点的提示词;声明了却查不到就显式标 ⚠️ | +| 产出侧 | `indexNodeOutputs` → 读本节点**出边**上的契约 | 用声明的 kind 给这个节点刚落盘的文件定 kind(文件名认不出来时靠声明一对一认领),否则一律记成 `artifact`,下游按 kind 就查不到 | + +写法与规则(权威说明在 `system/templates/workgraph-planning/tools.md` §6bis,这里只记要点): + +- kind 取值域只有 7 个:`brd` / `prd` / `sdd` / `acceptance` / `contract` / `artifact` / `log`, + 自创取值会被 `rule=schema` 打回。 +- `produces` 写在**产出方的出边**上(绝大多数情况只用它);`needs` 写在**消费方的入边**上, + 仅当产出方**不是紧邻上游**(跨节点中继)时才用。 +- **`backward` 边一律不声明**(`rule=schema`):引擎解析输入只看 forward 入边, + 写在回退边上等于没写,而「看起来写了」比「没写」更难排查。 +- **规则 6 `data-contract`**:声明要吃的 kind(`needs`,`required` 不为 false) + 在图上必须有产出方,否则整张图被拒。产出方确实不在本图(上一层 org graph 或 `artifacts/` 给的)时, + 写成 `"needs":[{"kind":"artifact","required":false}]` —— 这是唯一的逃生口, + `validator.json` 用的就是它。 +- **任务级共享的 `acceptance` / `contract` 不要沿边声明**:引擎按 task + kind 自动递给每个节点 + (`graph-engine` 的 `TASK_SHARED_KINDS`),不受拓扑相邻性限制。只有当**本图里某个节点自己产出**它、 + 再交给本图的下游时才写 `produces`(`planner.json` 的 `acceptance` 就是这种情形)。 + +🛡️ **防线**:`server/tests/unit/work-graph-skeletons.test.js` 逐张骨架查四件事—— +过 `validateWorkGraph`(含规则 6)、**每条** forward 边都有非空声明、 +`createWorkGraph` 落库后 `graph_edge_contracts` 的 kind/role/required 与 JSON 逐条一致、 +每个非入口节点的 `inboundContracts` 非空。改本目录任何 JSON 后跑: + +```bash +npx vitest run server/tests/unit/work-graph-skeletons.test.js +``` ## 加一个新模板 @@ -172,6 +216,7 @@ org graph 模板里**首尾同名**(27-NAMING §3.1),它们的 work graph | 3 | **全可达** | 从入口沿 forward 边 BFS,必须覆盖 `nodes` 里每一个节点 | | 4 | **回退边显式标注** | 任何 `to` 在拓扑序上早于 `from` 的边,必须带 `"edgeKind": "backward"` | | 5 | **fail 兜底边** | 有 `fail` 出边的节点,其中必须至少有一条不带 `failReason` | + | 6 | **声明要吃的 kind 有产出方** | 每条 `needs`(未标 `required:false`)的 kind,图上必须有某条出边 `produces` 它 | 7. 自验:直接拿引擎真正用的那套校验跑一遍本目录全部模板(占位符不影响校验,它只做名字匹配): diff --git a/system/work-graph-templates/executor-fanout.json b/system/work-graph-templates/executor-fanout.json index 53ac84e..98ad23c 100644 --- a/system/work-graph-templates/executor-fanout.json +++ b/system/work-graph-templates/executor-fanout.json @@ -3,6 +3,7 @@ "label": "执行型 org node 的 work graph(fan-out / fan-in 起手式)", "whenToUse": "宿主 org node 是执行类,且这次任务能拆成多个互不依赖、可并行推进的分支时用本骨架——27-REQ R-6 第 6 步描述的就是它:拆分 → 并行实现 + 各自自测(自测失败只回退本分支)→ fan-in 汇合整合 → 集成测试 → 本层验收。典型信号词:多个模块、多个页面、多个接口、可以同时做。汇合节点有多条入边,引擎按「全部入边条件都满足才置 ready」处理(27-P3/P4 §四.D.1)。分支数按实际任务增减,本文件给的是 2 条;任务只有一个交付物就别用它,用 executor.json。", "ownerOrgNode": "{{orgNode}}", + "dataContractNotes": "拆分节点的产物声明成 contract(分支间接口契约)而不是 artifact:两条分支各自的交付物已经占了 artifact 这个 kind,而产物索引按 task + kind 查、同一 kind 只把最新那份递给下游,拆分方案写成 artifact 会被先跑完的分支产物顶掉。代价是 contract 属任务级共享 kind,本来就会自动递给每个节点,所以这两条声明的实际作用偏诊断(声明了却查不到实例时,提示词会显式标 ⚠️,等于当场暴露「拆分节点没产出接口契约」)。fan-in 的已知上限:merge 的两条入边各自 needs artifact,但索引按 kind 只回最新一份,merge 提示词里只会出现一条路径——两个分支的产物都要过目时,merge 仍得按提示词给的节点目录自己找齐。升级路径是让产物索引支持按 fromNodeId 过滤(inboundContracts 已经带回 fromNodeId,describeInputs 还没用它)。", "nodes": [ { "nodeName": "{{orgNode}}-split", "systemAgentId": "{{splitAgent}}" }, { "nodeName": "{{orgNode}}-impl-a", "systemAgentId": "{{implAgent}}" }, @@ -14,14 +15,14 @@ { "nodeName": "{{orgNode}}-validator", "systemAgentId": "{{validatorAgent}}" } ], "edges": [ - { "from": "{{orgNode}}-split", "to": "{{orgNode}}-impl-a", "condition": "success" }, - { "from": "{{orgNode}}-split", "to": "{{orgNode}}-impl-b", "condition": "success" }, - { "from": "{{orgNode}}-impl-a", "to": "{{orgNode}}-test-a", "condition": "success" }, - { "from": "{{orgNode}}-impl-b", "to": "{{orgNode}}-test-b", "condition": "success" }, - { "from": "{{orgNode}}-test-a", "to": "{{orgNode}}-merge", "condition": "success" }, - { "from": "{{orgNode}}-test-b", "to": "{{orgNode}}-merge", "condition": "success" }, - { "from": "{{orgNode}}-merge", "to": "{{orgNode}}-integration", "condition": "success" }, - { "from": "{{orgNode}}-integration", "to": "{{orgNode}}-validator", "condition": "success" }, + { "from": "{{orgNode}}-split", "to": "{{orgNode}}-impl-a", "condition": "success", "produces": ["contract"] }, + { "from": "{{orgNode}}-split", "to": "{{orgNode}}-impl-b", "condition": "success", "produces": ["contract"] }, + { "from": "{{orgNode}}-impl-a", "to": "{{orgNode}}-test-a", "condition": "success", "produces": ["artifact"] }, + { "from": "{{orgNode}}-impl-b", "to": "{{orgNode}}-test-b", "condition": "success", "produces": ["artifact"] }, + { "from": "{{orgNode}}-test-a", "to": "{{orgNode}}-merge", "condition": "success", "produces": ["log"], "needs": ["artifact"] }, + { "from": "{{orgNode}}-test-b", "to": "{{orgNode}}-merge", "condition": "success", "produces": ["log"], "needs": ["artifact"] }, + { "from": "{{orgNode}}-merge", "to": "{{orgNode}}-integration", "condition": "success", "produces": ["artifact"] }, + { "from": "{{orgNode}}-integration", "to": "{{orgNode}}-validator", "condition": "success", "produces": ["log"], "needs": ["artifact"] }, { "from": "{{orgNode}}-test-a", "to": "{{orgNode}}-impl-a", "condition": "fail", "edgeKind": "backward" }, { "from": "{{orgNode}}-test-b", "to": "{{orgNode}}-impl-b", "condition": "fail", "edgeKind": "backward" }, { "from": "{{orgNode}}-integration", "to": "{{orgNode}}-merge", "condition": "fail", "edgeKind": "backward" }, diff --git a/system/work-graph-templates/executor.json b/system/work-graph-templates/executor.json index 2aaf2f3..323b1d0 100644 --- a/system/work-graph-templates/executor.json +++ b/system/work-graph-templates/executor.json @@ -3,14 +3,15 @@ "label": "执行型 org node 的 work graph(串行起手式)", "whenToUse": "宿主 org node 的职能是「按上游给的约束做出实际产物」,且这次任务小到不值得拆并行分支时用本骨架——典型是 org-coder / org-writer / org-monitor。典型信号词:实现、写、改、跑、部署,且任务是单一交付物。三节点:做 → 自测 → 本层验收,自测和验收失败都退回实现节点。任务能明显拆成多个互不依赖的分支时改用 executor-fanout.json;宿主是规划类节点用 planner.json;宿主是 org-validator 用 validator.json。", "ownerOrgNode": "{{orgNode}}", + "dataContractNotes": "交付物走 artifact,自测记录走 log。validator 既要看测试记录(紧邻上游 produces log)也要看被验的交付物本身,而交付物的产出方隔了一个节点,所以用 needs artifact 中继——这条就是 tools.md §6bis 举的那个例子。上游给的 sdd 与验收标准 acceptance 都由别的 org node 的图产出,属任务级共享,引擎按 task + kind 自动递,不在本图沿边声明。", "nodes": [ { "nodeName": "{{orgNode}}-impl", "systemAgentId": "{{implAgent}}" }, { "nodeName": "{{orgNode}}-test", "systemAgentId": "{{testAgent}}" }, { "nodeName": "{{orgNode}}-validator", "systemAgentId": "{{validatorAgent}}" } ], "edges": [ - { "from": "{{orgNode}}-impl", "to": "{{orgNode}}-test", "condition": "success" }, - { "from": "{{orgNode}}-test", "to": "{{orgNode}}-validator", "condition": "success" }, + { "from": "{{orgNode}}-impl", "to": "{{orgNode}}-test", "condition": "success", "produces": ["artifact"] }, + { "from": "{{orgNode}}-test", "to": "{{orgNode}}-validator", "condition": "success", "produces": ["log"], "needs": ["artifact"] }, { "from": "{{orgNode}}-test", "to": "{{orgNode}}-impl", "condition": "fail", "edgeKind": "backward" }, { "from": "{{orgNode}}-validator", "to": "{{orgNode}}-impl", "condition": "fail", "edgeKind": "backward" } ] diff --git a/system/work-graph-templates/planner.json b/system/work-graph-templates/planner.json index 45f905d..9787dd8 100644 --- a/system/work-graph-templates/planner.json +++ b/system/work-graph-templates/planner.json @@ -3,6 +3,7 @@ "label": "规划型 org node 的 work graph", "whenToUse": "宿主 org node 的职能是「把一句人类需求变成下游能照着干的约束」时用本骨架——典型是 org-planner,也适用于任何产出物是文档/规格/方案而不是可运行产物的节点。典型信号词:需求、规格、方案、拆解、定验收标准。本骨架最大的特点是它负责产出这次 task-goal 的验收契约(goal-benchmark / goal-test-data / goal-test-script 三选一或多选,动态选入,见 27-E2E §十一),这些产物写进 root run 的 artifacts/ 共享区,消费者是另一个 org node(org-validator)。若宿主节点是执行类(写代码/写内容/做运维)用 executor.json;若宿主节点是 org-validator 用 validator.json。", "ownerOrgNode": "org-planner", + "dataContractNotes": "三份文档各由自己那一步 produces(brd / prd / sdd)。goal-benchmark 的主输入是 prd + sdd(见 goal-benchmark/agent.md「输入」),prd 的产出方隔了一个节点,所以用 needs 中继。planner-validator 的职责 A 要审三份产物是否完整、彼此是否矛盾,三个 kind 全部 needs。acceptance 写 produces 是因为它的产出方(goal-benchmark)就在本图里、消费方(planner-validator)也在本图里——这是任务级共享 kind 唯一该沿边声明的情形;反过来,别的图产出的 acceptance / contract 一律不声明,引擎按 task + kind 自动递(graph-engine 的 TASK_SHARED_KINDS),重复声明只是给提示词加一行噪声。contract.json 也不声明:产出侧按文件名就能认成 kind=contract。", "nodes": [ { "nodeName": "org-planner-brd", "systemAgentId": "brd-planning" }, { "nodeName": "org-planner-prd", "systemAgentId": "prd-planning" }, @@ -11,10 +12,10 @@ { "nodeName": "org-planner-validator", "systemAgentId": "planner-validator" } ], "edges": [ - { "from": "org-planner-brd", "to": "org-planner-prd", "condition": "success" }, - { "from": "org-planner-prd", "to": "org-planner-sdd", "condition": "success" }, - { "from": "org-planner-sdd", "to": "org-planner-benchmark", "condition": "success" }, - { "from": "org-planner-benchmark", "to": "org-planner-validator", "condition": "success" }, + { "from": "org-planner-brd", "to": "org-planner-prd", "condition": "success", "produces": ["brd"] }, + { "from": "org-planner-prd", "to": "org-planner-sdd", "condition": "success", "produces": ["prd"] }, + { "from": "org-planner-sdd", "to": "org-planner-benchmark", "condition": "success", "produces": ["sdd"], "needs": ["prd"] }, + { "from": "org-planner-benchmark", "to": "org-planner-validator", "condition": "success", "produces": ["acceptance"], "needs": ["brd", "prd", "sdd"] }, { "from": "org-planner-validator", "to": "org-planner-prd", "condition": "fail", "failReason": "input_underspecified", "edgeKind": "backward" }, { "from": "org-planner-validator", "to": "org-planner-sdd", "condition": "fail", "edgeKind": "backward" } ] diff --git a/system/work-graph-templates/validator.json b/system/work-graph-templates/validator.json index 2be03f7..22e6b6c 100644 --- a/system/work-graph-templates/validator.json +++ b/system/work-graph-templates/validator.json @@ -3,14 +3,15 @@ "label": "org-validator 的 work graph(归因排第一位)", "whenToUse": "宿主 org node 是 org-validator(org graph 末端那个「整个任务达成了吗」的节点)时**必须**用本骨架。它与另外两个骨架的硬性区别:第一个节点必须是归因节点 validator-triager——先归因、再决定后面怎么验/怎么退(27-P3/P4 §四.D.3)。它产出的 fail_reason + remediation_note 会透传回 org 层,成为 org graph 选走哪条 fail 边的唯一依据(§四.D.0 三值透传 / §四.D.2.2 选边)。少了这个节点,org 层失败后不知道该退回 org-planner 还是 org-coder。宿主不是 org-validator 就别用本骨架。", "ownerOrgNode": "org-validator", + "dataContractNotes": "本图两条 forward 边只有 needs 没有 produces,这是刻意的:归因节点的判词写在 attempt-{n}.json 里、由引擎自己读(readNodeProduct),产出侧刻意把这个文件名排除在产物索引之外,benchmark-validator 的评分同理只喂给 goal-contract.js 的执行器;写 produces 会让每一轮都出现一条「声明了会产出但索引里查不到实例」的 ⚠️ 假警报。被验产物由上一层 org graph 的执行节点(org-coder 等)产出、不在本图,所以 needs 标 required:false——这既是规则 6(data-contract)的逃生口,也是本骨架能过校验的原因;产物索引按 company + task 查、不按图,路径照样递得到。验收标准 acceptance 由 org-planner 那张图产出,属任务级共享,引擎自动递(节点上的 readsArtifacts 已经把它写明),不沿边声明。", "nodes": [ { "nodeName": "validator-triager", "systemAgentId": "validator-triager" }, { "nodeName": "benchmark-validator", "systemAgentId": "benchmark-validator", "readsArtifacts": ["goal-benchmark.json"] }, { "nodeName": "testdata-validator", "systemAgentId": "testdata-validator", "readsArtifacts": ["goal-test-data/"] } ], "edges": [ - { "from": "validator-triager", "to": "benchmark-validator", "condition": "success" }, - { "from": "benchmark-validator", "to": "testdata-validator", "condition": "success" }, + { "from": "validator-triager", "to": "benchmark-validator", "condition": "success", "needs": [{ "kind": "artifact", "required": false }] }, + { "from": "benchmark-validator", "to": "testdata-validator", "condition": "success", "needs": [{ "kind": "artifact", "required": false }] }, { "from": "benchmark-validator", "to": "validator-triager", "condition": "fail", "edgeKind": "backward" }, { "from": "testdata-validator", "to": "validator-triager", "condition": "fail", "edgeKind": "backward" } ]