Commit 3bd46d05e3da38628deaf487f0c44ff416ae9871
1 parent
2b77f8c9
feat(p4): R-19 org 层契约接活;引擎提示词与模板口径对齐
第六次真 CLI 冒烟 gapClosed=true:org 层契约落库 4 行(此前恒 0), 且那段文字真的进了 workgraph-planning 收到的提示词(52677 字节,第 746 行起)。 ## workflow 四路并行的产出 - **org 层两半一起接**(关键顺序):先接读回方 buildPlanningPrompt 的 ③bis 段 (org 边入边 → 上游绝对路径;出边 produces → 「你规划的子图必须有节点产出这些」), 再接声明方 orggraph-planning 模板。只补模板的话就是又一次「声明了没人读」—— org 图节点全是 subgraph,而 describeInputs 对 subgraph 直接返回空。 resolveInboundLines 从 describeInputs 抽出共用,两侧口径物理上不可能漂移。 - **清掉 404 死路径**:模板三处教的 POST /api/company/:id/work-graphs 从未注册。 真跑复验:agent 侧零调用,改为按新 Step 7 写项目根绝对路径,还自己 require validateWorkGraph 预检了一遍才交回,round=1 一次过。 - **四张骨架补 produces/needs**:从「靠模板文字施压」变成结构保证,每张过规则 6。 真跑里 agent 照抄了 planner.json 并跟着任务删掉 sdd 节点——是改编不是复制。 - **graph-seed 顺序依赖**:靠生产代码里已有的 GRAPH_SEED_ROOT 缝隙指向冻结快照。 不是「跑几次都绿」结案:构造了原先必红的 churn,修前 12 次 3 红、修后 15 次 0 红。 ## 我自己收的 4 条 major + 1 条 minor(都在 graph-engine.js) 复验挖出「引擎自己拼的提示词在教 agent 做模板明令禁止的事」,同一条提示词里对立: 1. 「已自行落库成功时回一行 workGraphId: wg_xxx」vs 模板「你永远拿不到真的 wg_ id, 永远别写」。agent 没有任何受支持的落库途径,写出来必是编的,而 extractPlannedGraph 优先匹配那个形状、查库查不到就整轮作废。删掉邀请,保留引擎解析真实 wg_ 的能力。 2. 内联图示例的边一个 produces 都没有——agent 唯一见过的「边长什么样」是零声明的。 3. 「端点尚未上线」暗示将来会上线,于是它还想去试一次。改成「不存在、也不会上线」。 4. indexNodeOutputs 收集 declared 不按 role 过滤,把出边的 needs 当成本节点的产出。 Chrome 走查实测:产品需求文档.md 落成 artifact,下游 prd 输入变 ⚠️。 更坏的一面是错标(brd_notes.md 命中中继 needs[brd] → 归因给错的产出方)。 1ea9efa 加的 role 列,这里正是它存在的理由。 5. (minor) contract.prev.json(retryNode 的契约快照)被索引成 artifact 噪声。 新增 guard:引擎提示词不许与模板对立(静态读源码,剔掉注释行—— 第一版把「解释为什么不许说某句话」的注释也扫进去了,量错了对象)。 测试 850 全绿(83 文件),既有断言未改。5 处变异精确变红后逐字还原。 Chrome 走查确认控制台正常分支(此前只验过降级分支):契约药丸、绝对路径、 needs 中继说明、tab 上的 ⚠️ 计数全部渲染,零 console 报错。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Showing
16 changed files
with
1527 additions
and
183 deletions
docs/todo/system-design/27-p4-parallel-and-artifacts.md
| @@ -342,8 +342,10 @@ CREATE TABLE IF NOT EXISTS graph_edge_contracts ( | @@ -342,8 +342,10 @@ CREATE TABLE IF NOT EXISTS graph_edge_contracts ( | ||
| 342 | 「声明要吃 X 但没人产 X」永远自洽 —— 那条校验会退化成一条永不报错的空跑规则。 | 342 | 「声明要吃 X 但没人产 X」永远自洽 —— 那条校验会退化成一条永不报错的空跑规则。 |
| 343 | `required:false` 是显式逃生口:产出方不在本图(`acceptance` 由上一层 org graph / `artifacts/` 给)时用它。 | 343 | `required:false` 是显式逃生口:产出方不在本图(`acceptance` 由上一层 org graph / `artifacts/` 给)时用它。 |
| 344 | 344 | ||
| 345 | -**尚未接通的一环**:`system/work-graph-templates/*.json` 四张骨架的边**还没有** `produces` 声明, | ||
| 346 | -所以「照抄骨架」得到的图不传数据,全靠规划 agent 自己按约束九补。骨架补声明是下一步。 | 345 | +**~~尚未接通的一环~~(已接通,见下面第六次真跑)**:`system/work-graph-templates/*.json` 四张骨架的边 |
| 346 | +原先**没有** `produces` 声明,所以「照抄骨架」得到的图不传数据,全靠规划 agent 自己按约束九补。 | ||
| 347 | +四张骨架现已补齐 `produces`/`needs`(backward 边一条都没动),第六次真跑实测规划 agent | ||
| 348 | +会把骨架上的声明连同结构一起改编下来 —— 见「第六次真 CLI 冒烟」验收点⑤。 | ||
| 347 | 349 | ||
| 348 | #### 第五次真 CLI 冒烟:第四次那个断点复验通过(2026-08-17,未改一行代码) | 350 | #### 第五次真 CLI 冒烟:第四次那个断点复验通过(2026-08-17,未改一行代码) |
| 349 | 351 | ||
| @@ -373,6 +375,86 @@ CREATE TABLE IF NOT EXISTS graph_edge_contracts ( | @@ -373,6 +375,86 @@ CREATE TABLE IF NOT EXISTS graph_edge_contracts ( | ||
| 373 | 所以真跑后 `graph_edge_contracts` 里 `edge_layer='org'` **0 行**。 | 375 | 所以真跑后 `graph_edge_contracts` 里 `edge_layer='org'` **0 行**。 |
| 374 | org 层这一半仍是「实现在、无真实调用方」。 | 376 | org 层这一半仍是「实现在、无真实调用方」。 |
| 375 | 377 | ||
| 378 | +#### 第六次真 CLI 冒烟:org 层四环闭合(2026-08-17) | ||
| 379 | + | ||
| 380 | +**跑法**:worktree `wf_9fbefcf9-79e-5`,真 CLI 服务 3010(`data/` 是 worktree 自己的 —— | ||
| 381 | +`company-service.js:18` 与 `graph-db.js:34` 都从 `__dirname/../../data` 派生,所以生产 | ||
| 382 | +`data/graph.sqlite` 结构上碰不到),`LEMONBOSS_DISABLE_WATCHDOG=1`(启动日志有 | ||
| 383 | +`[Watchdog] ⏸️ 已由 LEMONBOSS_DISABLE_WATCHDOG=1 关闭`)。公司 `smoke6`,org run | ||
| 384 | +`run_5274dad6b10c` 手动模式,子 run `run_0691b85eb7fc`。 | ||
| 385 | +**被测版本 = `2b77f8c` + 工作树里未提交的四份改动**(org 层读回方 `describeOrgContracts`、 | ||
| 386 | +`orggraph-planning` 约束五、`workgraph-planning` 去死端点、四张骨架补 `produces`/`needs`)。 | ||
| 387 | + | ||
| 388 | +**证据一律取 Claude CLI 自己的 transcript 原文**(`~/.claude/projects/<agent-cwd>/*.jsonl` | ||
| 389 | +里第一条 user 消息 = 引擎真的发出去的那段字),不用 timeline 推断。 | ||
| 390 | + | ||
| 391 | +**单测基线**:`npm run test:unit` 在 worktree 里 **837 passed / 6 skipped,83 个文件中 82 通过**。 | ||
| 392 | +唯一失败的 `server/tests/integration/graph-agent-p1-acceptance.test.js` 是**环境性失败、不是回归**: | ||
| 393 | +它在 `:75` 断言 `data/companies/idearun/agents/evolver-youtube-crawler/agent.md` 存在, | ||
| 394 | +而 `data/companies/` 被 `.gitignore` 忽略,**全新 worktree 里结构上不可能有这份 fixture** | ||
| 395 | +(该断言是作者刻意加的「别让测试空跑通过」保险)。对账:主树基线 829 passed / 80 文件, | ||
| 396 | +本轮新增三个测试文件共 14 条,失败套件里 6 条没跑 → `829 + 14 − 6 = 837`,与实测**逐数吻合**, | ||
| 397 | +即既有用例零回归。 | ||
| 398 | + | ||
| 399 | +| 验收点 | 结果 | | ||
| 400 | +|---|---| | ||
| 401 | +| **① 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)**零契约行** | | ||
| 402 | +| **② org 契约进没进规划 agent 的提示词**(本轮核心) | ✅ **成立**。发给 `workgraph-planning` 的提示词(52 677 字节)第 746 行逐字出现 `## ③bis org-planner 在上一层图上的数据契约(引擎已解析,不要自己猜路径)`,其下「你这一层必须产出的东西(硬约束)」列出 `acceptance / prd` —— 正是 org 边 1 上声明的两个 kind。四环里的「读回」这一环闭合 | | ||
| 403 | +| **③ 声明侧的语义质量** | ✅ 超出预期。`needs` 被用在**真正的隔节点中继**上:`org-validator` 要吃的 `acceptance` 产出方是 `org-planner`(非紧邻上游 `org-coder`),所以单独写 `needs`。规划记录 `workspace/orggraph-plan.md` 第 6 节「数据契约自查」表**填满了**,含 `data-contract` 规则预检、kind 取值域预检、以及「粒度:org 层比 work 层粗一档」自查 —— 约束五被完整吸收,不是照抄 | | ||
| 404 | +| **④ 死端点** | ✅ agent **不再** curl `POST /api/company/:id/work-graphs`。全 transcript 里该字符串只出现在**提示词**里(两处,见下面的不对称②),agent 侧零调用。它按新 Step 7 把图写成项目根下的绝对路径 `projects/p-xdc09a/workgraph-T-msx3w8xm.json`(**没有**写 `workspace/` 那条与写入边界冲突的旧路径),并**自己 require 引擎的 `validateWorkGraph` 预检了一遍**才交回。落库一轮通过,`plan` 无 `rejected` | | ||
| 405 | +| **⑤ 骨架 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 行** | | ||
| 406 | +| **⑥ 回归:第五次那条已成立的链路** | ✅ **没被 `resolveInboundLines` 抽函数搞坏**。`org-planner-prd`(`prd-planning`)的真实提示词逐字复现第五次的形状:<br>`[上游输入 —— 引擎已按图上声明的数据契约解析好,直接读这些路径]`<br>`- brd:…/runs/run_5274dad6b10c/nodes/rn_9836aeb60827/brd.md`<br>`- contract(任务级共享):…/rn_9836aeb60827/contract.json`<br>**一个 ⚠️ 都没有**。行为侧也一致:prd agent 的头两个动作就是 `Read` 这两条绝对路径,随后判「BRD 完整且五问齐备,无需打回」,节点 **attempt 0 一次 success** | | ||
| 407 | +| 产出侧 kind 认领 | ✅ `brd.md`→`kind=brd`、`prd.md`→`kind=prd`,两者都是**按出边 `produces` 声明**定的(不是文件名兜底);`contract.json`→`kind=contract` 按文件名 | | ||
| 408 | + | ||
| 409 | +**归因上的诚实交代**:⑤ 这一条无法把功劳单独归给 ③bis 还是骨架 —— `planner.json` 本身就在 | ||
| 410 | +`benchmark→validator` 上写了 `produces[acceptance]`、在 `prd→sdd` 上写了 `produces[prd]`, | ||
| 411 | +所以「acceptance 与 prd 都有节点产出」这个结果两条路径都能推出来,本轮是**同向叠加**, | ||
| 412 | +不构成对 ③bis 的独立验证。③bis 的独立证据只有验收点②(那段文字确实进了提示词)。 | ||
| 413 | +要把两者分开,得构造一个骨架覆盖不到的 kind(例如 org 边上声明 `produces[log]`)再看它补不补。 | ||
| 414 | + | ||
| 415 | +**跑法上踩到的一个坑(我自己造成的,写下来免得下一轮再踩)**: | ||
| 416 | +第一次跑 `org-planner-brd` 时节点被判 `fail / fail_reason=boundary_escape` 而进 `blocked`, | ||
| 417 | +**而 agent 本身一个越界写入都没有**。`node.boundary` 事件的 `entries` 只有一条: | ||
| 418 | +`M docs/todo/system-design/27-p4-parallel-and-artifacts.md` —— 那是**我在节点跑的同时编辑本文件**。 | ||
| 419 | +`applyBoundaryGuard`(`graph-engine.js:2829`)拍调用前后两次**全仓库** `git status`, | ||
| 420 | +差集里的每一条都算当前节点的越界;`splitUnattributable`(:2807)的豁免名单只有 | ||
| 421 | +`system/agents/*/{todo.md,plan.md,memory.md,_lessons.md,.claude/scheduled_tasks.json}` | ||
| 422 | +(`AGENT_RUNTIME_STATE_RE`),文档、源码、任何别的并发写入都不在豁免内。 | ||
| 423 | +**结论:真跑期间不要在同一个工作树里改任何被 git 跟踪的文件** —— 这条约束此前只写成 | ||
| 424 | +「关掉 Master 巡检」,但巡检只是并发写入的一种,人工编辑同样会冤枉节点。 | ||
| 425 | +(重试后 `attempt 1` 一次 success,所以这只是浪费一次调用,不影响上表任何结论; | ||
| 426 | +但重试本身留下了一个副作用文件 `contract.prev.json`,它落成了 `kind=artifact` —— 见下面第 4 条。) | ||
| 427 | + | ||
| 428 | +**同轮暴露的四条不对称**(都不是本轮改动引入的,且都不在本轮可改文件内): | ||
| 429 | + | ||
| 430 | +1. **引擎提示词与新模板在 `wg_` 上直接对立,而且同处一条提示词里**。 | ||
| 431 | + `workgraph-planning/agent.md` 第 329/361 行(进了提示词)说「别自己编 `wg_` id」 | ||
| 432 | + 「**不许出现 `wg_` + 12 位十六进制**,引擎优先匹配这个形状并当成已落库的图 id,查不到就整轮作废」; | ||
| 433 | + 而 `graph-engine.js:832` 拼出的第 775 行说「已经自行落库成功时,改为回一行 `workGraphId: wg_xxx`」。 | ||
| 434 | + 本轮 agent 选择了听模板(没写 `wg_`),但这是运气而非保证。修法:删掉 `graph-engine.js` 那一行 | ||
| 435 | + —— agent 永远不可能「自行落库成功」,那个分支对它来说只是陷阱。 | ||
| 436 | +2. **同一处还留着「端点尚未上线」的措辞**(`graph-engine.js:829` → 提示词第 770 行), | ||
| 437 | + 暗示将来会上线;新模板的口径是 `NO-SUCH-ENDPOINT — 从来没有实现过`。两边措辞不一致。 | ||
| 438 | + 顺带一提,`graph-engine.js:832` 那个内联 JSON 示例的边是 | ||
| 439 | + `{"from":"...","to":"...","condition":"success"}` —— **一个 `produces` 都没有**, | ||
| 440 | + 与模板「forward 边基本都要写 `produces`」正相反。三处(829/832/835)都在同一段字符串数组里, | ||
| 441 | + 一次改完最省事。 | ||
| 442 | +3. **org 层 `produces` 在产出侧没有认领方**(已知上限,非回归):org 节点是 subgraph, | ||
| 443 | + `deriveChildOutcome` 不回 `outputRef` → `output_ref` 恒 `null` → `indexNodeOutputs` 第一行就 return。 | ||
| 444 | + org 边上的 `produces` 能不能真的查到实例,完全取决于**子 run 里的 work 节点**把文件登记成对的 kind | ||
| 445 | + (`graph_files` 按 `(company_id, task_id)` 检索,子 run 继承同一个 `task_id`)。 | ||
| 446 | + 本轮 org run 只推进到第一个 org node,**没有**验到第二个 org node 的入边把路径查出来 —— | ||
| 447 | + ③bis 的「上游节点交给你的输入」那一半(`resolveInboundLines` 拼绝对路径)在真跑里**仍未被覆盖**, | ||
| 448 | + 下一次真跑应当专门推到第二个 org node。 | ||
| 449 | + *间接旁证*:`resolveInboundLines` 是 `describeInputs` 与 `describeOrgContracts` **共用的同一个函数** | ||
| 450 | + (`graph-engine.js:3226`),验收点⑥已经证明它在真跑里能拼出正确绝对路径; | ||
| 451 | + org 层缺的只是「这条 org 入边上确实有声明 + 索引里确实有实例」这个具体组合的现场,不是代码路径。 | ||
| 452 | +4. **`contract.prev.json` 会被当成产物索引进去**(`kind=artifact`)。它是 `retryNode` 为算契约 hash | ||
| 453 | + 留的快照(`retry` 响应里的 `contractSnapshot.file`),不是节点交付物。产出侧没把它排除, | ||
| 454 | + 于是每次人工重试都会给 `graph_files` 添一行噪声。`validator.json` 那条「刻意零 produces」的 | ||
| 455 | + 注释里已经写过同类道理(`attempt-{n}.json` 被排除在索引外),`contract.prev.json` 漏了。 | ||
| 456 | + 修法:`indexNodeOutputs` 的排除名单加上它。 | ||
| 457 | + | ||
| 376 | #### 控制台读回端(R19-C:R-4「边的输入输出」的字面落地) | 458 | #### 控制台读回端(R19-C:R-4「边的输入输出」的字面落地) |
| 377 | 459 | ||
| 378 | 此前 [27-P3/P4](./27-p3-p4-work-graph-console.md) §四.B.3 第 2 个 tab 把 R-4 落成了 | 460 | 此前 [27-P3/P4](./27-p3-p4-work-graph-console.md) §四.B.3 第 2 个 tab 把 R-4 落成了 |
server/services/graph-engine.js
| @@ -801,6 +801,10 @@ function buildPlanningPrompt(ctx, { round = 1, errors = null, lastOutput = '' } | @@ -801,6 +801,10 @@ function buildPlanningPrompt(ctx, { round = 1, errors = null, lastOutput = '' } | ||
| 801 | '', | 801 | '', |
| 802 | '## ③ 可读的 artifacts/(root run 共享产物区,只读)', | 802 | '## ③ 可读的 artifacts/(root run 共享产物区,只读)', |
| 803 | describeArtifactsDir(ctx.run), | 803 | describeArtifactsDir(ctx.run), |
| 804 | + // R-19 org 层契约的读回方。返回 null(没有任何契约行)时**整段连标题一起消失**, | ||
| 805 | + // 靠下面那个 filter 滤掉 —— 编号用 ③bis 而不是插进 ①②③④,那四项是模板 Step 1 | ||
| 806 | + // 「四项输入一项都不能跳」的原文清单,重编号会让模板与提示词对不上。 | ||
| 807 | + describeOrgContracts(ctx), | ||
| 804 | '', | 808 | '', |
| 805 | '## ④ 上一轮 remediation_note', | 809 | '## ④ 上一轮 remediation_note', |
| 806 | ctx.remediationNote || '无,首次规划。', | 810 | ctx.remediationNote || '无,首次规划。', |
| @@ -822,13 +826,21 @@ function buildPlanningPrompt(ctx, { round = 1, errors = null, lastOutput = '' } | @@ -822,13 +826,21 @@ function buildPlanningPrompt(ctx, { round = 1, errors = null, lastOutput = '' } | ||
| 822 | `- 节点引用的 systemAgentId 必须是**已存在的本体**;不存在的本体会被引擎以 rule=reference 打回。`, | 826 | `- 节点引用的 systemAgentId 必须是**已存在的本体**;不存在的本体会被引擎以 rule=reference 打回。`, |
| 823 | '', | 827 | '', |
| 824 | '## 交回方式(引擎读你回复的最后一段)', | 828 | '## 交回方式(引擎读你回复的最后一段)', |
| 825 | - '`POST /api/company/{公司ID}/work-graphs` 端点尚未上线,走模板 Step 6 的降级路径:', | 829 | + // 措辞必须与 workgraph-planning/agent.md 的 NO-SUCH-ENDPOINT 一致:说「尚未上线」会被读成 |
| 830 | + // 「将来会上线」,agent 就还会去试一次、吃一个 404、白烧一轮(前五次真跑每轮都在烧)。 | ||
| 831 | + // 这个端点从来没有设计过,落库始终是引擎的活。 | ||
| 832 | + '`POST /api/company/{公司ID}/work-graphs` **不存在、也不会上线** —— 落库是引擎的活:', | ||
| 826 | '**把最终图直接贴在回复里的一个 ```json 代码块中**(或写成一个 .json 文件后给出它的绝对路径),形状:', | 833 | '**把最终图直接贴在回复里的一个 ```json 代码块中**(或写成一个 .json 文件后给出它的绝对路径),形状:', |
| 827 | '```json', | 834 | '```json', |
| 835 | + // 示例的边必须带 produces:这段是 agent 唯一见过的「一条边长什么样」, | ||
| 836 | + // 示例里没有声明,它照抄出来的就是一张不传数据的图 —— 引擎自己的示例在教反面教材。 | ||
| 828 | '{"nodes":[{"nodeName":"...","systemAgentId":"...","nodeKind":"agent_call"}],' | 837 | '{"nodes":[{"nodeName":"...","systemAgentId":"...","nodeKind":"agent_call"}],' |
| 829 | - + '"edges":[{"from":"...","to":"...","condition":"success"}]}', | 838 | + + '"edges":[{"from":"...","to":"...","condition":"success","produces":["brd"]}]}', |
| 830 | '```', | 839 | '```', |
| 831 | - '已经自行落库成功时,改为回一行 `workGraphId: wg_xxx`。', | 840 | + // 刻意**不**再提「已落库时回一行 workGraphId」:没有任何受支持的落库途径, |
| 841 | + // 所以这句话只会诱发它编一个 wg_ id;而 extractPlannedGraph 优先匹配那个形状、 | ||
| 842 | + // 查库查不到就整轮作废(模板 agent.md:345 把这条列成硬禁令,两边对立时 agent 得赌)。 | ||
| 843 | + // 引擎侧仍保留认真实 wg_ id 的分支,只是不再邀请它写。 | ||
| 832 | ]; | 844 | ]; |
| 833 | 845 | ||
| 834 | if (round > 1) { | 846 | if (round > 1) { |
| @@ -891,6 +903,86 @@ function describeArtifactsDir(run) { | @@ -891,6 +903,86 @@ function describeArtifactsDir(run) { | ||
| 891 | return out.join('\n'); | 903 | return out.join('\n'); |
| 892 | } | 904 | } |
| 893 | 905 | ||
| 906 | +/** 本节点**出边**上声明为 produces 的 kind(去重)。role 为 NULL 的历史行按 produces 读, | ||
| 907 | + * 与 org-graph-service.readGraph 回读契约时同一个口径。backward 出边不算:引擎解析输入 | ||
| 908 | + * 只看 forward 入边,写在回退边上的声明本就不生效(validateGraph 也拦了)。 */ | ||
| 909 | +function outboundProducedKinds(layer, edges, defNodeId) { | ||
| 910 | + const out = []; | ||
| 911 | + for (const e of edges || []) { | ||
| 912 | + if (String(e.from) !== String(defNodeId)) continue; | ||
| 913 | + if ((e.edgeKind || 'forward') === 'backward') continue; | ||
| 914 | + for (const c of edgeContracts(layer, e.id)) { | ||
| 915 | + if ((c.role || 'produces') !== 'needs') out.push(c.kind); | ||
| 916 | + } | ||
| 917 | + } | ||
| 918 | + return [...new Set(out)]; | ||
| 919 | +} | ||
| 920 | + | ||
| 921 | +/** | ||
| 922 | + * R-19 org 层:把 org 图上的数据契约递给 **workgraph-planning**(这就是 org 层契约的读回方)。 | ||
| 923 | + * | ||
| 924 | + * 为什么不能靠 describeInputs:它第一行就是 `ctx.nodeKind === 'subgraph'` 时返回空串, | ||
| 925 | + * 而 readDefGraph 把 org 图的节点**全部**写成 nodeKind='subgraph'(work-graph-db.js | ||
| 926 | + * 「org graph 的节点全部是 subgraph executor」)。两条合起来 = org 层的边契约在输入侧 | ||
| 927 | + * 一个读回方都没有。先补模板去教 orggraph-planning 声明,产出的就是「声明了没人读」。 | ||
| 928 | + * | ||
| 929 | + * 语义也不该复用 describeInputs 的措辞。org 节点自己不干活,它派给规划本体的是**约束**: | ||
| 930 | + * 入边声明的 kind → 你规划的子图的**入口节点**要吃这些(引擎已经把绝对路径查好) | ||
| 931 | + * 出边声明的 kind → 你规划的子图**必须有节点产出**这些,否则下游 org node 拿不到输入 | ||
| 932 | + * 这才是两层递归里 org 层约束 work 层的方式。 | ||
| 933 | + * | ||
| 934 | + * 拼不出来(没有契约行 / def 读不出来)时返回 **null**,整段连标题一起省略: | ||
| 935 | + * 留一个空标题会被 agent 读成「引擎查过了,确实没有」,而真相是「引擎没查」。 | ||
| 936 | + * | ||
| 937 | + * ponytail: 这里自己再读一次 def(defIndex)而不是让 advanceOne 把 def 塞进 ctx —— | ||
| 938 | + * 代价是每一轮规划多一次定义层查询(org 图是个位数节点,可忽略),换来的是 ctx 形状不变: | ||
| 939 | + * ctx 会原样传给 setAgentExecutor 注入的外部实现,往里塞一个含 Map 的字段是更大的面。 | ||
| 940 | + * 规划轮数一多(PLAN_MAX_ROUNDS=3)也就三次;真要省,把 def 提到 executeSubgraph 里传参。 | ||
| 941 | + */ | ||
| 942 | +function describeOrgContracts(ctx) { | ||
| 943 | + if (!ctx || !ctx.run) return null; | ||
| 944 | + let inboundLines = []; | ||
| 945 | + let produced = []; | ||
| 946 | + try { | ||
| 947 | + const layer = ctx.run.graph_kind === 'org' ? 'org' : 'work'; | ||
| 948 | + const edges = defIndex(ctx.run).edges || []; | ||
| 949 | + inboundLines = resolveInboundLines(ctx, inboundContracts(layer, edges, ctx.defNodeId)); | ||
| 950 | + produced = outboundProducedKinds(layer, edges, ctx.defNodeId); | ||
| 951 | + } catch { | ||
| 952 | + return null; // 索引/定义层读不出来不该拖垮规划调用 | ||
| 953 | + } | ||
| 954 | + if (!inboundLines.length && !produced.length) return null; | ||
| 955 | + | ||
| 956 | + const lines = ['', `## ③bis ${ctx.nodeName} 在上一层图上的数据契约(引擎已解析,不要自己猜路径)`]; | ||
| 957 | + if (inboundLines.length) { | ||
| 958 | + lines.push( | ||
| 959 | + '', | ||
| 960 | + '### 上游节点交给你的输入', | ||
| 961 | + ...inboundLines, | ||
| 962 | + '标 ⚠️ 的表示上一层图上声明了这份输入、但产物索引里还没有实例:先确认是不是上游还没跑完,', | ||
| 963 | + '不要自己去别的节点私有目录里翻。', | ||
| 964 | + '**这些路径要落到你规划的子图的入口节点手上** —— 引擎不会自动把它们拼进那个节点的提示词,', | ||
| 965 | + '你要写进入口节点的任务描述里。', | ||
| 966 | + ); | ||
| 967 | + } | ||
| 968 | + if (produced.length) { | ||
| 969 | + lines.push( | ||
| 970 | + '', | ||
| 971 | + '### 你这一层必须产出的东西(硬约束)', | ||
| 972 | + `上一层图上你的出边声明了这些 kind,下游节点就是按它们去产物索引里找输入的:${produced.join(' / ')}`, | ||
| 973 | + '**你规划的子图里必须有节点真的产出上面每一个 kind**,一个都不能少。', | ||
| 974 | + '少一个的后果不是现在报错,而是下游节点跑到那一步说「我拿不到输入」—— 那时整个子图的预算已经烧掉了。', | ||
| 975 | + '落地方式(两条都要做到位才算产出):', | ||
| 976 | + ` ① 在产出它的那条 work 边上写 \`"produces":["${produced[0]}"]\`(引擎按出边声明给文件定 kind);`, | ||
| 977 | + // 出口节点没有出边 → 它的 indexNodeOutputs 拿不到任何声明,只能按文件名认 kind。 | ||
| 978 | + // 这条不写清楚的话,org 层最后一个 kind 恰好就是最容易掉的那个。 | ||
| 979 | + ' ② 出口节点没有出边、写不了 produces 时,把**文件名**直接起成 kind 本身' | ||
| 980 | + + `(引擎只看去掉扩展名的那一段,认得出的是 ${KNOWN_FILE_KINDS.join(' / ')})。`, | ||
| 981 | + ); | ||
| 982 | + } | ||
| 983 | + return lines.join('\n'); | ||
| 984 | +} | ||
| 985 | + | ||
| 894 | /** | 986 | /** |
| 895 | * 从 agent 的回复里取出图。三种形态按优先级: | 987 | * 从 agent 的回复里取出图。三种形态按优先级: |
| 896 | * ① `workGraphId: wg_xxx`(它自己 POST 成功了) | 988 | * ① `workGraphId: wg_xxx`(它自己 POST 成功了) |
| @@ -3070,7 +3162,17 @@ function indexNodeOutputs(run, def, nodeRun) { | @@ -3070,7 +3162,17 @@ function indexNodeOutputs(run, def, nodeRun) { | ||
| 3070 | } | 3162 | } |
| 3071 | if (!files.length) return; | 3163 | if (!files.length) return; |
| 3072 | 3164 | ||
| 3073 | - // 出边声明的 kind:优先用它给文件定 kind | 3165 | + // 出边声明的 kind:优先用它给文件定 kind。 |
| 3166 | + // | ||
| 3167 | + // **只收 role='produces'**:出边上的 needs 说的是「下游要吃这个 kind,而产出方不是紧邻上游」 | ||
| 3168 | + // (中继声明,写在消费方入边上 —— 那与本节点的出边是同一条边),它不是本节点的产出。 | ||
| 3169 | + // 混进来会坏两件事: | ||
| 3170 | + // ① 认领作废:p-prd 的出边写 produces[prd]+needs[brd] → 未认领 kind 数 2 ≠ 1 → | ||
| 3171 | + // 下面那条「一对一无歧义」的兜底不成立,文件退成 artifact,下游查不到实例。 | ||
| 3172 | + // ② 更坏的错标:文件名里带 brd(如 brd_notes.md)会命中中继的 needs[brd], | ||
| 3173 | + // 把它错标成 brd —— 错的 kind 让下游读到一份不是它要的文件,比 artifact 查不到更坏。 | ||
| 3174 | + // role 列是随 needs/produces 一起落库的(work-graph-db 的 graph_edge_contracts), | ||
| 3175 | + // 这里正是它存在的理由;旧库迁移行回填成 produces,所以缺省当 produces 处理。 | ||
| 3074 | let declared = []; | 3176 | let declared = []; |
| 3075 | try { | 3177 | try { |
| 3076 | const layer = run.graph_kind === 'org' ? 'org' : 'work'; | 3178 | const layer = run.graph_kind === 'org' ? 'org' : 'work'; |
| @@ -3078,7 +3180,9 @@ function indexNodeOutputs(run, def, nodeRun) { | @@ -3078,7 +3180,9 @@ function indexNodeOutputs(run, def, nodeRun) { | ||
| 3078 | (e) => String(e.from) === String(nodeRun.def_node_id) && (e.edgeKind || 'forward') !== 'backward' | 3180 | (e) => String(e.from) === String(nodeRun.def_node_id) && (e.edgeKind || 'forward') !== 'backward' |
| 3079 | ); | 3181 | ); |
| 3080 | for (const e of outbound) { | 3182 | for (const e of outbound) { |
| 3081 | - for (const c of edgeContracts(layer, e.id)) declared.push(c.kind); | 3183 | + for (const c of edgeContracts(layer, e.id)) { |
| 3184 | + if ((c.role || 'produces') !== 'needs') declared.push(c.kind); | ||
| 3185 | + } | ||
| 3082 | } | 3186 | } |
| 3083 | declared = [...new Set(declared)]; | 3187 | declared = [...new Set(declared)]; |
| 3084 | } catch { /* 契约查不到就只靠文件名 */ } | 3188 | } catch { /* 契约查不到就只靠文件名 */ } |
| @@ -3086,8 +3190,13 @@ function indexNodeOutputs(run, def, nodeRun) { | @@ -3086,8 +3190,13 @@ function indexNodeOutputs(run, def, nodeRun) { | ||
| 3086 | const companyRoot = companyRootDir(run.company_id); | 3190 | const companyRoot = companyRootDir(run.company_id); |
| 3087 | 3191 | ||
| 3088 | // ── 第一遍:按文件名认领 kind | 3192 | // ── 第一遍:按文件名认领 kind |
| 3089 | - // attempt-*.json 是引擎自己读的归因文件(readNodeProduct),不是给下游的产物 | ||
| 3090 | - const candidates = files.filter((f) => !/^attempt-\d+\.json$/.test(f)); | 3193 | + // 引擎自己的簿记文件不是节点交付物,不进索引: |
| 3194 | + // attempt-*.json 归因文件(readNodeProduct 读它) | ||
| 3195 | + // contract.prev.json retryNode 为算 contract_hash 留的上一轮快照(CONTRACT_SNAPSHOT_FILE) | ||
| 3196 | + // 漏掉后者的话,每次人工重试都给 graph_files 添一行 kind=artifact 的噪声。 | ||
| 3197 | + const candidates = files.filter( | ||
| 3198 | + (f) => !/^attempt-\d+\.json$/.test(f) && f !== CONTRACT_SNAPSHOT_FILE | ||
| 3199 | + ); | ||
| 3091 | const assigned = candidates.map((f) => { | 3200 | const assigned = candidates.map((f) => { |
| 3092 | const base = f.replace(/\.[^.]+$/, '').toLowerCase(); | 3201 | const base = f.replace(/\.[^.]+$/, '').toLowerCase(); |
| 3093 | // 声明优先:出边说会产 brd,且文件名里就有 brd → 定 brd | 3202 | // 声明优先:出边说会产 brd,且文件名里就有 brd → 定 brd |
| @@ -3132,6 +3241,31 @@ function indexNodeOutputs(run, def, nodeRun) { | @@ -3132,6 +3241,31 @@ function indexNodeOutputs(run, def, nodeRun) { | ||
| 3132 | } | 3241 | } |
| 3133 | 3242 | ||
| 3134 | /** | 3243 | /** |
| 3244 | + * 入边声明的 kind → 产物索引里的绝对路径,一个 kind 一行。 | ||
| 3245 | + * | ||
| 3246 | + * 抽出来是为了让**执行侧**(describeInputs,agent_call 节点)与**规划侧** | ||
| 3247 | + * (describeOrgContracts,subgraph 节点交给 workgraph-planning 的那份提示词) | ||
| 3248 | + * 走同一段代码:三种口径(查得到给绝对路径 / required 查不到给 ⚠️ / | ||
| 3249 | + * 可选查不到整行省略)就不可能在两处漂移 —— 复制一遍再各自演化正是本轮在防的那类断链。 | ||
| 3250 | + */ | ||
| 3251 | +function resolveInboundLines(ctx, contracts) { | ||
| 3252 | + const declared = [...new Set(contracts.map((c) => c.kind))]; | ||
| 3253 | + if (!declared.length) return []; | ||
| 3254 | + const found = findFiles({ companyId: ctx.companyId, taskId: ctx.run.task_id, kinds: declared }); | ||
| 3255 | + const byKind = new Map(); | ||
| 3256 | + for (const f of found) byKind.set(f.kind, f); | ||
| 3257 | + const root = companyRootDir(ctx.companyId); | ||
| 3258 | + const lines = []; | ||
| 3259 | + for (const kind of declared) { | ||
| 3260 | + const hit = byKind.get(kind); | ||
| 3261 | + const required = contracts.some((c) => c.kind === kind && c.required); | ||
| 3262 | + if (hit) lines.push(`- ${kind}:${path.join(root, hit.rel_path)}`); | ||
| 3263 | + else if (required) lines.push(`- ${kind}:⚠️ 上游声明会产出,但索引里查不到实例`); | ||
| 3264 | + } | ||
| 3265 | + return lines; | ||
| 3266 | +} | ||
| 3267 | + | ||
| 3268 | +/** | ||
| 3135 | * 「这个节点需要什么输入、在哪」—— 两类数据流都查(R-19.3 定案①): | 3269 | * 「这个节点需要什么输入、在哪」—— 两类数据流都查(R-19.3 定案①): |
| 3136 | * ① 沿边传递:入边上声明的 kind(受拓扑相邻性限制) | 3270 | * ① 沿边传递:入边上声明的 kind(受拓扑相邻性限制) |
| 3137 | * ② 任务级共享:本 task 下的 acceptance / contract 这类,**不受相邻性限制** | 3271 | * ② 任务级共享:本 task 下的 acceptance / contract 这类,**不受相邻性限制** |
| @@ -3158,20 +3292,8 @@ function describeInputs(ctx, def) { | @@ -3158,20 +3292,8 @@ function describeInputs(ctx, def) { | ||
| 3158 | } | 3292 | } |
| 3159 | 3293 | ||
| 3160 | const declared = [...new Set(contracts.map((c) => c.kind))]; | 3294 | const declared = [...new Set(contracts.map((c) => c.kind))]; |
| 3161 | - const lines = []; | 3295 | + const lines = resolveInboundLines(ctx, contracts); |
| 3162 | 3296 | ||
| 3163 | - if (declared.length) { | ||
| 3164 | - const found = findFiles({ companyId: ctx.companyId, taskId: ctx.run.task_id, kinds: declared }); | ||
| 3165 | - const byKind = new Map(); | ||
| 3166 | - for (const f of found) byKind.set(f.kind, f); | ||
| 3167 | - const root = companyRootDir(ctx.companyId); | ||
| 3168 | - for (const kind of declared) { | ||
| 3169 | - const hit = byKind.get(kind); | ||
| 3170 | - const required = contracts.some((c) => c.kind === kind && c.required); | ||
| 3171 | - if (hit) lines.push(`- ${kind}:${path.join(root, hit.rel_path)}`); | ||
| 3172 | - else if (required) lines.push(`- ${kind}:⚠️ 上游声明会产出,但索引里查不到实例`); | ||
| 3173 | - } | ||
| 3174 | - } | ||
| 3175 | // 任务级共享按 kind 去重,只列最新的那一份(findFiles 是 created_at 升序,所以后写的覆盖前写的 | 3297 | // 任务级共享按 kind 去重,只列最新的那一份(findFiles 是 created_at 升序,所以后写的覆盖前写的 |
| 3176 | // —— 与上面沿边那一半的 byKind.set 同一个口径)。 | 3298 | // —— 与上面沿边那一半的 byKind.set 同一个口径)。 |
| 3177 | // | 3299 | // |
server/tests/unit/graph-files-index.test.js
| @@ -427,4 +427,82 @@ describe('R-19 产物索引:graph_files / graph_edge_contracts', () => { | @@ -427,4 +427,82 @@ describe('R-19 产物索引:graph_files / graph_edge_contracts', () => { | ||
| 427 | expect(listed[0]).toContain(path.join(companyRoot(companyId), rows[1].rel_path)); | 427 | expect(listed[0]).toContain(path.join(companyRoot(companyId), rows[1].rel_path)); |
| 428 | expect(listed[0]).not.toContain(rows[0].rel_path); | 428 | expect(listed[0]).not.toContain(rows[0].rel_path); |
| 429 | }); | 429 | }); |
| 430 | + | ||
| 431 | + // ── ⑨ 出边上的 needs 不算本节点的产出 ────────────────────────── | ||
| 432 | + // | ||
| 433 | + // Chrome 走查里实测到的:p-prd 的出边写 produces['prd'] + needs['brd'](中继声明), | ||
| 434 | + // 收集「本节点产出哪些 kind」时若不按 role 过滤,未认领 kind 数就是 2 而不是 1, | ||
| 435 | + // ⑦ 那条一对一认领当场失效 —— 文件退成 artifact、下游拿不到。 | ||
| 436 | + | ||
| 437 | + it('⑨ 出边同时有 produces 与 needs:认领只看 produces,中继的 needs 不参与', async () => { | ||
| 438 | + const companyId = 'co-files-role'; | ||
| 439 | + const taskId = 'task-role'; | ||
| 440 | + const b = buildRun({ | ||
| 441 | + companyId, taskId, | ||
| 442 | + nodes: [{ nodeName: 'w-brd' }, { nodeName: 'w-prd' }, { nodeName: 'w-val' }], | ||
| 443 | + edges: [ | ||
| 444 | + { from: 'w-brd', to: 'w-prd', condition: 'success' }, | ||
| 445 | + { from: 'w-prd', to: 'w-val', condition: 'success' }, | ||
| 446 | + ], | ||
| 447 | + }); | ||
| 448 | + // w-prd 的出边:它产 prd;同时声明 w-val 还要吃 brd(产出方是 w-brd,不是紧邻上游) | ||
| 449 | + const e2 = b.edgeId('w-prd', 'w-val'); | ||
| 450 | + wdb.declareEdgeContract({ edgeLayer: 'work', edgeId: e2, kind: 'prd', role: 'produces' }); | ||
| 451 | + wdb.declareEdgeContract({ edgeLayer: 'work', edgeId: e2, kind: 'brd', role: 'needs' }); | ||
| 452 | + | ||
| 453 | + await advance(b.runId, 2, { | ||
| 454 | + writes: { | ||
| 455 | + // 文件名认不出 kind → 只能靠声明认领;needs 混进来的话这里会退成 artifact | ||
| 456 | + 'w-prd': { '产品需求文档.md': '# 产品需求' }, | ||
| 457 | + }, | ||
| 458 | + }); | ||
| 459 | + | ||
| 460 | + const rows = allFiles(companyId); | ||
| 461 | + expect(rows).toHaveLength(1); | ||
| 462 | + expect(rows[0].kind).toBe('prd'); | ||
| 463 | + }); | ||
| 464 | + | ||
| 465 | + it('⑨ 出边只有 needs 时不认领(本节点什么都没声明要产)', async () => { | ||
| 466 | + const companyId = 'co-files-only-needs'; | ||
| 467 | + const taskId = 'task-only-needs'; | ||
| 468 | + const b = buildRun({ | ||
| 469 | + companyId, taskId, | ||
| 470 | + nodes: [{ nodeName: 'n-a' }, { nodeName: 'n-b' }], | ||
| 471 | + edges: [{ from: 'n-a', to: 'n-b', condition: 'success' }], | ||
| 472 | + }); | ||
| 473 | + wdb.declareEdgeContract({ | ||
| 474 | + edgeLayer: 'work', edgeId: b.edgeId('n-a', 'n-b'), kind: 'sdd', role: 'needs', | ||
| 475 | + }); | ||
| 476 | + | ||
| 477 | + await advance(b.runId, 1, { writes: { 'n-a': { '随便写的.md': 'x' } } }); | ||
| 478 | + | ||
| 479 | + // needs 不是产出声明,不该被拿来认领 —— 认领了就是把「下游要吃 sdd」 | ||
| 480 | + // 误读成「我产 sdd」,下游读到一份不是它要的文件 | ||
| 481 | + const rows = allFiles(companyId); | ||
| 482 | + expect(rows).toHaveLength(1); | ||
| 483 | + expect(rows[0].kind).toBe('artifact'); | ||
| 484 | + }); | ||
| 485 | + | ||
| 486 | + // ── ⑩ 引擎自己的簿记文件不进索引 ──────────────────────────────── | ||
| 487 | + | ||
| 488 | + it('⑩ contract.prev.json 不进索引(retryNode 的契约快照,不是节点交付物)', async () => { | ||
| 489 | + const companyId = 'co-files-snapshot'; | ||
| 490 | + const taskId = 'task-snapshot'; | ||
| 491 | + const b = buildRun({ companyId, taskId, nodes: [{ nodeName: 's-brd' }] }); | ||
| 492 | + | ||
| 493 | + await advance(b.runId, 1, { | ||
| 494 | + writes: { | ||
| 495 | + 's-brd': { | ||
| 496 | + 'brd.md': '# BRD', | ||
| 497 | + // 引擎为算 contract_hash 留的上一轮快照。漏掉它的话, | ||
| 498 | + // 每次人工重试都往 graph_files 添一行 kind=artifact 的噪声 | ||
| 499 | + 'contract.prev.json': JSON.stringify({ outputs: [] }), | ||
| 500 | + 'attempt-1.json': JSON.stringify({ fail_reason: null }), | ||
| 501 | + }, | ||
| 502 | + }, | ||
| 503 | + }); | ||
| 504 | + | ||
| 505 | + const rows = allFiles(companyId); | ||
| 506 | + expect(rows.map((r) => path.basename(r.rel_path))).toEqual(['brd.md']); | ||
| 507 | + }); | ||
| 430 | }); | 508 | }); |
server/tests/unit/graph-seed-service.test.js
| @@ -12,6 +12,8 @@ | @@ -12,6 +12,8 @@ | ||
| 12 | * B. **真实仓库根回归**(保留原有两条用例,断言未放松):seed 一遍真实 | 12 | * B. **真实仓库根回归**(保留原有两条用例,断言未放松):seed 一遍真实 |
| 13 | * `system/` + `data/companies/` 到一次性 DB,verifyCounts() 必须 ok, | 13 | * `system/` + `data/companies/` 到一次性 DB,verifyCounts() 必须 ok, |
| 14 | * 且往 DB 里塞一行 fs 不背书的数据时必须被抓出来。 | 14 | * 且往 DB 里塞一行 fs 不背书的数据时必须被抓出来。 |
| 15 | + * 读的仍然是真实仓库的内容,但 `data/companies/` 走一次**冻结快照**(见 freezeRepoRoot)—— | ||
| 16 | + * 原因写在那个函数上。 | ||
| 15 | */ | 17 | */ |
| 16 | import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; | 18 | import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; |
| 17 | import fs from 'fs'; | 19 | import fs from 'fs'; |
| @@ -33,6 +35,70 @@ function write(file, content) { | @@ -33,6 +35,70 @@ function write(file, content) { | ||
| 33 | fs.writeFileSync(file, content); | 35 | fs.writeFileSync(file, content); |
| 34 | } | 36 | } |
| 35 | 37 | ||
| 38 | +const REPO_ROOT = path.resolve(__dirname, '../../..'); | ||
| 39 | + | ||
| 40 | +/** | ||
| 41 | + * 把「真实仓库根」冻成一个不会在用例中途变形的 GRAPH_SEED_ROOT。 | ||
| 42 | + * | ||
| 43 | + * 为什么需要冻:verifyCounts() 的价值在于**独立重扫一遍目录**再跟库里的行数对, | ||
| 44 | + * 于是 seedCompanyAgents() 那次扫描和 verifyCounts() 那次扫描之间只要磁盘变了一次, | ||
| 45 | + * 期望值和实际值就必然不等 —— 而 `data/companies/` 是运行期状态,同一轮 `vitest run` 里 | ||
| 46 | + * 至少有 company-hire-org-layer.test.js / graph-agent-p1-{hire,acceptance}.test.js | ||
| 47 | + * 三个文件在真实 `data/companies/` 下建公司再 hire()(company.json 的 agents[] 逐个变长), | ||
| 48 | + * afterAll 又整目录删掉;用户开着 dev server 时还会有人从 UI 建公司。 | ||
| 49 | + * 实测:拿一个反复建/删的公司目录撞这个窗口,12 次单跑里 3 次红在 | ||
| 50 | + * `org_graph_nodes COUNT(*) WHERE company_id=...`。这就是它在全套里偶发变红的全部原因。 | ||
| 51 | + * | ||
| 52 | + * 冻的是「一致性」而不是「新鲜度」:快照本身可能抓到某家公司写一半的样子, | ||
| 53 | + * 但两次扫描读的是同一份,结论照旧成立。所以断言一条没放松 —— 真实仓库里多一个 | ||
| 54 | + * 模板目录 / 多一家公司带着 seeder 会跳过的角色,照样红(下面有验证)。 | ||
| 55 | + * | ||
| 56 | + * `system/` 不快照,直接软链:它是签入代码,跑测试期间没人往里加目录 | ||
| 57 | + * (hire() 只 bump `_registry.json` 里的 usedCount,改内容不建目录,而 _registry.json | ||
| 58 | + * 只影响 benchmark_score,不影响任何行数)。软链还顺带让 | ||
| 59 | + * 「every system/templates/* dir …」那条用例仍然对着真实目录清单比。 | ||
| 60 | + */ | ||
| 61 | +function freezeRepoRoot(dest) { | ||
| 62 | + fs.mkdirSync(dest, { recursive: true }); | ||
| 63 | + fs.symlinkSync(path.join(REPO_ROOT, 'system'), path.join(dest, 'system')); | ||
| 64 | + | ||
| 65 | + const src = path.join(REPO_ROOT, 'data', 'companies'); | ||
| 66 | + const dst = path.join(dest, 'data', 'companies'); | ||
| 67 | + if (!fs.existsSync(src)) return; // 连目录都没有 → 快照里也不该有,别把安全阀的判定条件改掉 | ||
| 68 | + fs.mkdirSync(dst, { recursive: true }); | ||
| 69 | + | ||
| 70 | + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { | ||
| 71 | + if (!entry.isDirectory()) continue; | ||
| 72 | + try { | ||
| 73 | + // 没有 company.json → seedCompanyAgents 与 verifyCounts 都跳过它,不必进快照 | ||
| 74 | + const companyJson = fs.readFileSync(path.join(src, entry.name, 'company.json')); | ||
| 75 | + write(path.join(dst, entry.name, 'company.json'), companyJson); | ||
| 76 | + | ||
| 77 | + let roles = []; | ||
| 78 | + try { | ||
| 79 | + const parsed = JSON.parse(companyJson); | ||
| 80 | + if (Array.isArray(parsed.agents)) roles = parsed.agents; | ||
| 81 | + } catch { | ||
| 82 | + // 坏掉的 company.json:两个实现都 continue,快照里留着原样即可 | ||
| 83 | + } | ||
| 84 | + for (const role of roles) { | ||
| 85 | + const roleDir = path.join(src, entry.name, 'agents', role); | ||
| 86 | + if (!fs.existsSync(roleDir)) continue; // legacy_path 存在性也要一并复制 | ||
| 87 | + fs.mkdirSync(path.join(dst, entry.name, 'agents', role), { recursive: true }); | ||
| 88 | + // isMetaBootstrap() 只看这一个文件,而它决定 seedCompanyAgents 建不建 org 节点 —— 必须带上 | ||
| 89 | + try { | ||
| 90 | + fs.copyFileSync( | ||
| 91 | + path.join(roleDir, 'config.json'), | ||
| 92 | + path.join(dst, entry.name, 'agents', role, 'config.json') | ||
| 93 | + ); | ||
| 94 | + } catch {} | ||
| 95 | + } | ||
| 96 | + } catch { | ||
| 97 | + // 这家公司正被别的测试 / dev server 删掉 → 跳过。快照要的是自洽,不是全集。 | ||
| 98 | + } | ||
| 99 | + } | ||
| 100 | +} | ||
| 101 | + | ||
| 36 | describe('graph-seed-service (hermetic fixture root)', () => { | 102 | describe('graph-seed-service (hermetic fixture root)', () => { |
| 37 | let root; | 103 | let root; |
| 38 | let dbPath; | 104 | let dbPath; |
| @@ -271,20 +337,22 @@ describe('graph-seed-service (hermetic fixture root)', () => { | @@ -271,20 +337,22 @@ describe('graph-seed-service (hermetic fixture root)', () => { | ||
| 271 | describe('graph-seed-service verifyCounts (real repo root)', () => { | 337 | describe('graph-seed-service verifyCounts (real repo root)', () => { |
| 272 | let seedService; | 338 | let seedService; |
| 273 | let graphDb; | 339 | let graphDb; |
| 274 | - const TEST_DB_PATH = '/tmp/test-graph-seed-service.sqlite'; | 340 | + let snapshotRoot; |
| 275 | 341 | ||
| 276 | beforeAll(() => { | 342 | beforeAll(() => { |
| 277 | - if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH); | ||
| 278 | - delete process.env.GRAPH_SEED_ROOT; // 真实仓库根 | ||
| 279 | - process.env.GRAPH_DB_PATH = TEST_DB_PATH; | 343 | + // mkdtemp 而不是固定 /tmp/test-graph-seed-service.sqlite:同一台机器上并行跑两遍 |
| 344 | + // 这个文件时不该共用一个库 | ||
| 345 | + snapshotRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-seed-realroot-')); | ||
| 346 | + freezeRepoRoot(snapshotRoot); | ||
| 347 | + process.env.GRAPH_SEED_ROOT = snapshotRoot; | ||
| 348 | + process.env.GRAPH_DB_PATH = path.join(snapshotRoot, 'graph.sqlite'); | ||
| 280 | ({ graphDb, seedService } = reloadServices()); | 349 | ({ graphDb, seedService } = reloadServices()); |
| 281 | }); | 350 | }); |
| 282 | 351 | ||
| 283 | afterAll(() => { | 352 | afterAll(() => { |
| 284 | - try { fs.unlinkSync(TEST_DB_PATH); } catch {} | ||
| 285 | - try { fs.unlinkSync(`${TEST_DB_PATH}-shm`); } catch {} | ||
| 286 | - try { fs.unlinkSync(`${TEST_DB_PATH}-wal`); } catch {} | 353 | + delete process.env.GRAPH_SEED_ROOT; |
| 287 | delete process.env.GRAPH_DB_PATH; | 354 | delete process.env.GRAPH_DB_PATH; |
| 355 | + try { fs.rmSync(snapshotRoot, { recursive: true, force: true }); } catch {} | ||
| 288 | }); | 356 | }); |
| 289 | 357 | ||
| 290 | it('reports ok:true against a freshly-seeded DB (no drift)', () => { | 358 | it('reports ok:true against a freshly-seeded DB (no drift)', () => { |
server/tests/unit/org-layer-contracts.test.js
0 → 100644
| 1 | +/** | ||
| 2 | + * Unit Tests — R-19 **org 层**数据契约:先有读回方,才谈声明方 | ||
| 3 | + * | ||
| 4 | + * 起因是第五次真 CLI 冒烟:`graph_edge_contracts` 里 `edge_layer='org'` 是 0 行。 | ||
| 5 | + * 直觉判断是「orggraph-planning 模板没教它声明」,核实之后这个判断是**错的**: | ||
| 6 | + * | ||
| 7 | + * - work-graph-db.readDefGraph 给 org 图的节点**全部**写 nodeKind='subgraph' | ||
| 8 | + * (注释原文:「org graph 的节点全部是 subgraph executor」); | ||
| 9 | + * - graph-engine.describeInputs 第一行就是 `ctx.nodeKind === 'subgraph'` 时返回空串。 | ||
| 10 | + * | ||
| 11 | + * 两条合起来 = org 层的边契约在输入侧**一个读回方都没有**。先补模板的话,产出的正是 | ||
| 12 | + * 「声明了没人读」——本仓库已经踩了 14 次的那个形状。 | ||
| 13 | + * | ||
| 14 | + * 所以读回方接在 `buildPlanningPrompt`(org 节点派给 workgraph-planning 的提示词)上: | ||
| 15 | + * org 层契约的语义正落在这里 —— 「你这个 org node 要吃上游产出的 X、要产下游要吃的 Y, | ||
| 16 | + * 于是你规划的子图必须让入口节点消费 X、必须有节点产出 Y」。 | ||
| 17 | + * | ||
| 18 | + * 本文件锁四件事: | ||
| 19 | + * ① 读回方成立 org 边上的 produces → 规划提示词里出现 ③bis 段 + 上游实例的**绝对路径** | ||
| 20 | + * ② 三种口径 查得到给绝对路径 / required 查不到给 ⚠️ / 可选查不到整行省略 | ||
| 21 | + * ③ 无契约不留空标题 一条契约都没有时整段(含标题)消失,不给 agent「查过了,没有」的错觉 | ||
| 22 | + * ④ 模板防漂移 orggraph-planning 模板里出现的每个「边上声明键」都必须被 | ||
| 23 | + * org-graph-service.normalizeContracts 真读到;kind 取值域逐字对齐 ARTIFACT_KINDS | ||
| 24 | + * | ||
| 25 | + * buildPlanningPrompt / describeOrgContracts **故意不导出**(graph-engine.js 的口径: | ||
| 26 | + * 不为测试新开公开面),所以 ①②③ 全部走 advanceOne 这个真入口: | ||
| 27 | + * 用 setPlanWorkGraph 只替掉**上游** org 节点的规划(给它一张现成子图), | ||
| 28 | + * 被测节点则委托回真的 `defaultPlanWorkGraph`,靠替换 AgentManager 截获它发出的提示词。 | ||
| 29 | + * | ||
| 30 | + * 隔离手法同 graph-files-index.test.js:先设 GRAPH_DB_PATH / GRAPH_RUNS_DIR 再 require, | ||
| 31 | + * 仓库 data/ 全程不被碰;Claude CLI 一次都不起(AgentManager 被换成假货)。 | ||
| 32 | + */ | ||
| 33 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; | ||
| 34 | +import fs from 'fs'; | ||
| 35 | +import os from 'os'; | ||
| 36 | +import path from 'path'; | ||
| 37 | + | ||
| 38 | +const REPO = path.resolve(__dirname, '../../..'); | ||
| 39 | + | ||
| 40 | +describe('R-19 org 层数据契约(读回方 = buildPlanningPrompt)', () => { | ||
| 41 | + let wdb; | ||
| 42 | + let orgSvc; | ||
| 43 | + let engine; | ||
| 44 | + let runsDir; | ||
| 45 | + const TEST_DB_PATH = '/tmp/test-org-layer-contracts.sqlite'; | ||
| 46 | + const AM_PATH = require.resolve('../../src/core/agent-manager'); | ||
| 47 | + | ||
| 48 | + /** defaultPlanWorkGraph 发出的提示词,一次调用一条 */ | ||
| 49 | + let captured = []; | ||
| 50 | + let savedAgentManager; | ||
| 51 | + | ||
| 52 | + /** | ||
| 53 | + * 假 AgentManager:截下提示词就抛。 | ||
| 54 | + * | ||
| 55 | + * 抛而不是「回一张合法子图」是刻意的:本文件要验的是**提示词内容**, | ||
| 56 | + * 让规划"成功"还得替它造一张过五项校验的子图,那些代码与被测行为无关。 | ||
| 57 | + * defaultPlanWorkGraph 对 callAgent 抛出的错不重试(只对「图画错了」重试), | ||
| 58 | + * 所以一次 advanceOne 恰好产出一条提示词,节点按 node_error 失败 —— 正是我们要的。 | ||
| 59 | + */ | ||
| 60 | + class FakeAgentManager { | ||
| 61 | + async callAgent(agentName, prompt) { | ||
| 62 | + captured.push({ agentName, prompt }); | ||
| 63 | + throw new Error('fake-agent-manager: 已截获提示词,不真的调 CLI'); | ||
| 64 | + } | ||
| 65 | + } | ||
| 66 | + | ||
| 67 | + beforeAll(() => { | ||
| 68 | + for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) { | ||
| 69 | + try { fs.unlinkSync(f); } catch {} | ||
| 70 | + } | ||
| 71 | + process.env.GRAPH_DB_PATH = TEST_DB_PATH; | ||
| 72 | + runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'org-contracts-runs-')); | ||
| 73 | + process.env.GRAPH_RUNS_DIR = runsDir; | ||
| 74 | + for (const m of [ | ||
| 75 | + '../../services/graph-db', | ||
| 76 | + '../../services/graph-service', | ||
| 77 | + '../../services/org-graph-service', | ||
| 78 | + '../../services/work-graph-db', | ||
| 79 | + '../../services/graph-run-sse', | ||
| 80 | + '../../services/graph-engine', | ||
| 81 | + ]) { | ||
| 82 | + delete require.cache[require.resolve(m)]; | ||
| 83 | + } | ||
| 84 | + wdb = require('../../services/work-graph-db'); | ||
| 85 | + orgSvc = require('../../services/org-graph-service'); | ||
| 86 | + engine = require('../../services/graph-engine'); | ||
| 87 | + | ||
| 88 | + // defaultPlanWorkGraph 里是 `require('../src/core/agent-manager')`(调用时才 require), | ||
| 89 | + // 所以换掉 require.cache 里的 exports 就够,不必去 patch 原型 —— 也就不会构造真的 | ||
| 90 | + // AgentManager(它的构造函数会挂上全局 LogManager / SessionPool)。afterAll 原样还回去。 | ||
| 91 | + savedAgentManager = require.cache[AM_PATH]; | ||
| 92 | + require.cache[AM_PATH] = { | ||
| 93 | + id: AM_PATH, filename: AM_PATH, loaded: true, children: [], paths: [], | ||
| 94 | + exports: FakeAgentManager, | ||
| 95 | + }; | ||
| 96 | + }); | ||
| 97 | + | ||
| 98 | + afterAll(() => { | ||
| 99 | + if (savedAgentManager) require.cache[AM_PATH] = savedAgentManager; | ||
| 100 | + else delete require.cache[AM_PATH]; | ||
| 101 | + for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) { | ||
| 102 | + try { fs.unlinkSync(f); } catch {} | ||
| 103 | + } | ||
| 104 | + delete process.env.GRAPH_DB_PATH; | ||
| 105 | + try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {} | ||
| 106 | + delete process.env.GRAPH_RUNS_DIR; | ||
| 107 | + for (const m of ['../../services/graph-run-sse', '../../services/graph-engine']) { | ||
| 108 | + delete require.cache[require.resolve(m)]; | ||
| 109 | + } | ||
| 110 | + }); | ||
| 111 | + | ||
| 112 | + // ── 脚手架 ─────────────────────────────────────────────────── | ||
| 113 | + | ||
| 114 | + let seq = 0; | ||
| 115 | + /** 前缀刻意带 tmp-:resolvePlanningAgentName 会拿它去 REPO/data/companies 下找目录, | ||
| 116 | + * 找不到就试着 hire 一次;hire 第一步 get(companyId) 读不到 company.json 直接抛, | ||
| 117 | + * 被 catch 掉 —— 全程不在仓库 data/ 下建任何东西。 */ | ||
| 118 | + const nextCompany = () => `tmp-orgc-${process.pid}-${++seq}`; | ||
| 119 | + | ||
| 120 | + function ensureAgents(ids) { | ||
| 121 | + const db = wdb.getDb(); | ||
| 122 | + const ts = new Date().toISOString(); | ||
| 123 | + const ins = db.prepare(` | ||
| 124 | + INSERT OR IGNORE INTO system_agents | ||
| 125 | + (id, name, scope, owner_company_id, source_kind, legacy_path, status, created_at, updated_at) | ||
| 126 | + VALUES (?, ?, 'public', NULL, 'global_agent', ?, 'active', ?, ?) | ||
| 127 | + `); | ||
| 128 | + for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, ts, ts); | ||
| 129 | + } | ||
| 130 | + | ||
| 131 | + /** | ||
| 132 | + * 起一张 org run,把入口节点跑通(它的子图写出 `writes` 里的文件并被索引), | ||
| 133 | + * 让第二个 org 节点变 ready,然后再推一步 —— 那一步就会调真的 defaultPlanWorkGraph。 | ||
| 134 | + * | ||
| 135 | + * @param edges org 边,可带 produces / needs | ||
| 136 | + * @param writes 入口节点的子图那个 work 节点要落盘的文件 {文件名: 内容} | ||
| 137 | + * @returns {Promise<{prompt: string, runId: string}>} 被测节点(org-coder)收到的规划提示词 | ||
| 138 | + */ | ||
| 139 | + async function planOrgRun({ companyId, taskId, nodes, edges, writes = {} }) { | ||
| 140 | + expect(orgSvc.writeGraph(companyId, { nodes, edges }).ok).toBe(true); | ||
| 141 | + | ||
| 142 | + ensureAgents(['plan-worker']); | ||
| 143 | + const sub = wdb.createWorkGraph({ companyId, taskId, nodes: ['plan-worker'], edges: [] }); | ||
| 144 | + expect(sub.errors || []).toEqual([]); | ||
| 145 | + | ||
| 146 | + // 上游 org 节点用注入的假规划(一张现成子图);被测节点委托回真路径。 | ||
| 147 | + engine.setPlanWorkGraph(async (ctx) => { | ||
| 148 | + if (ctx.nodeName === 'org-planner') return { workGraphId: sub.id }; | ||
| 149 | + return engine.defaultPlanWorkGraph(ctx); | ||
| 150 | + }); | ||
| 151 | + // 子图里那个 work 节点:把文件写进自己的私有目录,outputRef 指向该目录 | ||
| 152 | + // —— 形状与 defaultAgentExecutor 一致,settleNode 的 indexNodeOutputs 才会登记它。 | ||
| 153 | + engine.setAgentExecutor(async (ctx) => { | ||
| 154 | + const dir = path.join(runsDir, ctx.run.root_run_id, 'nodes', ctx.nodeRunId); | ||
| 155 | + fs.mkdirSync(dir, { recursive: true }); | ||
| 156 | + for (const [name, body] of Object.entries(writes)) { | ||
| 157 | + fs.writeFileSync(path.join(dir, name), body); | ||
| 158 | + } | ||
| 159 | + return { status: 'success', outputRef: dir }; | ||
| 160 | + }); | ||
| 161 | + | ||
| 162 | + let runId; | ||
| 163 | + try { | ||
| 164 | + const run = wdb.createRun({ graphKind: 'org', graphId: companyId, companyId, taskId }); | ||
| 165 | + runId = run.id; | ||
| 166 | + engine.markRunReady(run.id); | ||
| 167 | + engine.startRun(run.id, { mode: 'manual' }); | ||
| 168 | + | ||
| 169 | + captured = []; | ||
| 170 | + await engine.advanceOne(run.id); // org-planner → 起子 run | ||
| 171 | + const child = wdb.listRunTree(run.id).find((r) => r.parent_run_id === run.id); | ||
| 172 | + await engine.advanceOne(child.id); // 子图那个节点跑完 → 回写父节点 | ||
| 173 | + expect(wdb.getRun(child.id).run_status).toBe('completed'); | ||
| 174 | + await engine.advanceOne(run.id); // org-coder → 真 defaultPlanWorkGraph | ||
| 175 | + } finally { | ||
| 176 | + engine.setPlanWorkGraph(null); | ||
| 177 | + engine.setAgentExecutor(null); | ||
| 178 | + } | ||
| 179 | + | ||
| 180 | + expect(captured).toHaveLength(1); | ||
| 181 | + return { prompt: captured[0].prompt, runId }; | ||
| 182 | + } | ||
| 183 | + | ||
| 184 | + // ── ① 读回方成立(最关键的一条)─────────────────────────────── | ||
| 185 | + | ||
| 186 | + it('① org 边上声明 produces → 被测 org node 的规划提示词里出现 ③bis 段与上游实例的绝对路径', async () => { | ||
| 187 | + const companyId = nextCompany(); | ||
| 188 | + const taskId = 'task-org-in'; | ||
| 189 | + const { prompt } = await planOrgRun({ | ||
| 190 | + companyId, taskId, | ||
| 191 | + nodes: ['org-planner', 'org-coder', 'org-validator'], | ||
| 192 | + edges: [ | ||
| 193 | + // 入边:上游 org node 会产出一份 brd 交给 org-coder | ||
| 194 | + { from: 'org-planner', to: 'org-coder', condition: 'success', produces: ['brd'] }, | ||
| 195 | + // 出边:org-coder 自己要产出一份 sdd 给下游 org node; | ||
| 196 | + // 同一条边上的 needs 是**下游**要吃的(产出方是隔了一个节点的 org-planner), | ||
| 197 | + // 它不是 org-coder 的产出 —— 混进去就会命令 org-coder 再产一份 brd。 | ||
| 198 | + { from: 'org-coder', to: 'org-validator', condition: 'success', produces: ['sdd'], needs: ['brd'] }, | ||
| 199 | + ], | ||
| 200 | + writes: { 'brd.md': '# 业务需求' }, | ||
| 201 | + }); | ||
| 202 | + | ||
| 203 | + // 提示词确实发给了规划本体(不是发给别人) | ||
| 204 | + expect(captured[0].agentName).toMatch(/workgraph-planning$/); | ||
| 205 | + | ||
| 206 | + // 入边那一半:给的是**绝对路径**,且指向真实落盘的那份 brd.md | ||
| 207 | + expect(prompt).toContain('③bis'); | ||
| 208 | + expect(prompt).toContain('### 上游节点交给你的输入'); | ||
| 209 | + const abs = prompt.match(/^- brd:(.+)$/m); | ||
| 210 | + expect(abs, '提示词里没有 brd 那一行').toBeTruthy(); | ||
| 211 | + expect(path.isAbsolute(abs[1])).toBe(true); | ||
| 212 | + // 真正的证据不是「长得像路径」,而是这个路径**读得出上游刚写的那份内容**。 | ||
| 213 | + // (rel_path 是相对公司根算的,节点私有目录在 GRAPH_RUNS_DIR 覆盖下位于公司根之外, | ||
| 214 | + // 所以拼回来的绝对路径带 ../ 归一后并不以公司根开头 —— 不拿前缀做断言。) | ||
| 215 | + expect(fs.readFileSync(abs[1], 'utf-8')).toBe('# 业务需求'); | ||
| 216 | + expect(prompt).not.toContain('⚠️ 上游声明会产出'); | ||
| 217 | + | ||
| 218 | + // 出边那一半:硬约束语气 + 点名 kind | ||
| 219 | + expect(prompt).toContain('### 你这一层必须产出的东西(硬约束)'); | ||
| 220 | + expect(prompt).toMatch(/必须有节点真的产出上面每一个 kind/); | ||
| 221 | + // 只列出边上 role='produces' 的那些。同一条出边上的 needs(brd)是下游要吃的东西, | ||
| 222 | + // 混进来就变成「命令 org-coder 再产一份 brd」——一条它根本没答应过的硬约束。 | ||
| 223 | + const produced = prompt.match(/找输入的:(.+)$/m); | ||
| 224 | + expect(produced, '提示词里没有「必须产出」那一行').toBeTruthy(); | ||
| 225 | + expect(produced[1]).toBe('sdd'); | ||
| 226 | + // 出边声明的 kind 不该被误当成「上游给你的输入」列在入边那一半里 | ||
| 227 | + expect(prompt).not.toMatch(/^- sdd:/m); | ||
| 228 | + }); | ||
| 229 | + | ||
| 230 | + // ── ② 另两种口径:required 查不到给 ⚠️;可选查不到整行省略 ────── | ||
| 231 | + | ||
| 232 | + it('② required 声明查不到实例 → ⚠️;required:false 查不到 → 整行省略(不留半句话)', async () => { | ||
| 233 | + const companyId = nextCompany(); | ||
| 234 | + const taskId = 'task-org-miss'; | ||
| 235 | + const { prompt } = await planOrgRun({ | ||
| 236 | + companyId, taskId, | ||
| 237 | + nodes: ['org-planner', 'org-coder'], | ||
| 238 | + edges: [{ | ||
| 239 | + from: 'org-planner', to: 'org-coder', condition: 'success', | ||
| 240 | + // brd 会真的落盘;acceptance 是 required 但没人产;log 是可选且没人产 | ||
| 241 | + produces: ['brd', 'acceptance', { kind: 'log', required: false }], | ||
| 242 | + }], | ||
| 243 | + writes: { 'brd.md': '# 业务需求' }, | ||
| 244 | + }); | ||
| 245 | + | ||
| 246 | + expect(prompt).toMatch(/^- brd:\//m); | ||
| 247 | + expect(prompt).toContain('- acceptance:⚠️ 上游声明会产出,但索引里查不到实例'); | ||
| 248 | + // 可选且查不到 → 一行都不留(留一行「查不到」会让 agent 以为这是个待办) | ||
| 249 | + expect(prompt).not.toMatch(/^- log[::]/m); | ||
| 250 | + }); | ||
| 251 | + | ||
| 252 | + // ── ③ 一条契约都没有时整段消失(含标题)────────────────────── | ||
| 253 | + | ||
| 254 | + it('③ org 图上一条契约都没声明 → ③bis 整段不出现(空标题会被读成「查过了,没有」)', async () => { | ||
| 255 | + const companyId = nextCompany(); | ||
| 256 | + const { prompt } = await planOrgRun({ | ||
| 257 | + companyId, taskId: 'task-org-none', | ||
| 258 | + nodes: ['org-planner', 'org-coder'], | ||
| 259 | + edges: [{ from: 'org-planner', to: 'org-coder', condition: 'success' }], | ||
| 260 | + writes: { 'brd.md': '# 业务需求' }, // 实例在索引里,但图上没人声明要它 | ||
| 261 | + }); | ||
| 262 | + | ||
| 263 | + expect(prompt).not.toContain('③bis'); | ||
| 264 | + expect(prompt).not.toContain('上游节点交给你的输入'); | ||
| 265 | + expect(prompt).not.toContain('必须产出的东西'); | ||
| 266 | + // 其余四项输入照旧在(证明省略的只是这一段,不是整段提示词拼挂了) | ||
| 267 | + expect(prompt).toContain('## ③ 可读的 artifacts/'); | ||
| 268 | + expect(prompt).toContain('## ④ 上一轮 remediation_note'); | ||
| 269 | + }); | ||
| 270 | + | ||
| 271 | + // ── ⑤ 产出侧的事实:org 节点自己什么都不登记 ──────────────────── | ||
| 272 | + | ||
| 273 | + it('⑤ org 层的 produces 在产出侧不靠 indexNodeOutputs:org 节点 output_ref 恒为 null,实例是子 run 里的 work 节点登记的', async () => { | ||
| 274 | + const companyId = nextCompany(); | ||
| 275 | + const taskId = 'task-org-out'; | ||
| 276 | + const { runId } = await planOrgRun({ | ||
| 277 | + companyId, taskId, | ||
| 278 | + nodes: ['org-planner', 'org-coder'], | ||
| 279 | + edges: [{ from: 'org-planner', to: 'org-coder', condition: 'success', produces: ['brd'] }], | ||
| 280 | + writes: { 'brd.md': '# 业务需求' }, | ||
| 281 | + }); | ||
| 282 | + | ||
| 283 | + // org 节点是 subgraph executor,它的 outcome 由 deriveChildOutcome 拼,**不带 outputRef**。 | ||
| 284 | + // 所以 indexNodeOutputs 在 org run 上第一行就 return —— 3076 行那句 layer 三元里的 | ||
| 285 | + // 'org' 分支对 org run 是走不到的死路径。这不是缺陷,是「org 节点自己不写盘」的必然结果。 | ||
| 286 | + const orgNodes = wdb.getDb() | ||
| 287 | + .prepare(`SELECT * FROM graph_run_nodes WHERE run_id = ?`).all(runId); | ||
| 288 | + expect(orgNodes.length).toBeGreaterThan(0); | ||
| 289 | + expect(orgNodes.map((n) => n.output_ref)).toEqual(orgNodes.map(() => null)); | ||
| 290 | + | ||
| 291 | + // 那份 brd 的索引行是**子 run 里那个 work 节点**登记的(node_run_id 属于子 run)。 | ||
| 292 | + // 它之所以能被 org 层的入边声明查到,只因为 graph_files 是按 (company, task) 检索的, | ||
| 293 | + // 而子 run 从父 run 继承同一个 task_id —— 这就是 org 层 produces 在产出侧成立的全部理由。 | ||
| 294 | + const rows = wdb.findFiles({ companyId, taskId, kinds: ['brd'] }); | ||
| 295 | + expect(rows).toHaveLength(1); | ||
| 296 | + expect(orgNodes.map((n) => n.id)).not.toContain(rows[0].produced_by_node_run_id); | ||
| 297 | + const child = wdb.listRunTree(runId).find((r) => r.parent_run_id === runId); | ||
| 298 | + expect(rows[0].produced_by_run_id).toBe(child.id); | ||
| 299 | + }); | ||
| 300 | + | ||
| 301 | + // ── ④ 模板防漂移:教的键名 / 取值域必须是落库侧真读的那个 ────── | ||
| 302 | + | ||
| 303 | + it('④ orggraph-planning 模板里的边上声明键,normalizeContracts 必须真的认(差一个字母就是静默失效)', () => { | ||
| 304 | + const dir = path.join(REPO, 'system/templates/orggraph-planning'); | ||
| 305 | + const text = fs.readdirSync(dir) | ||
| 306 | + .filter((f) => f.endsWith('.md')) | ||
| 307 | + .map((f) => fs.readFileSync(path.join(dir, f), 'utf-8')) | ||
| 308 | + .join('\n'); | ||
| 309 | + | ||
| 310 | + // 两个键都得教到:少 produces 就没人产,少 needs 就表达不了非相邻依赖 | ||
| 311 | + expect(text).toMatch(/"produces"/); | ||
| 312 | + expect(text).toMatch(/"needs"/); | ||
| 313 | + | ||
| 314 | + const keys = [...new Set( | ||
| 315 | + [...text.matchAll(/"(produces|needs|carries|consumes|inputs|outputs|provides)"/g)].map((m) => m[1]) | ||
| 316 | + )]; | ||
| 317 | + expect(keys.length).toBeGreaterThan(0); | ||
| 318 | + for (const key of keys) { | ||
| 319 | + const contracts = orgSvc.normalizeContracts({ from: 'x', to: 'y', [key]: ['brd'] }); | ||
| 320 | + expect(contracts.map((c) => c.kind), `模板教了 "${key}",但归一函数不读这个键`).toEqual(['brd']); | ||
| 321 | + } | ||
| 322 | + | ||
| 323 | + // kind 取值域逐字对齐:模板漏教一个值,规划 agent 就永远不会用它; | ||
| 324 | + // 模板多教一个值,validateGraph 会以 rule=schema 把整张图打回。 | ||
| 325 | + for (const kind of orgSvc.ARTIFACT_KINDS) { | ||
| 326 | + expect(text, `ARTIFACT_KINDS 里的 ${kind} 模板没教`).toContain(kind); | ||
| 327 | + } | ||
| 328 | + // backward 边上不许声明 —— 引擎解析输入只看 forward 入边(validateGraph 也拦) | ||
| 329 | + expect(text).toMatch(/backward/); | ||
| 330 | + }); | ||
| 331 | +}); |
server/tests/unit/work-graph-skeletons.test.js
0 → 100644
| 1 | +/** | ||
| 2 | + * system/work-graph-templates/*.json 的**数据契约**防线(R-19 / 27-P4 §4.6)。 | ||
| 3 | + * | ||
| 4 | + * 挡的是这一类缺陷:骨架的 forward 边只带控制流(condition / edgeKind / failReason), | ||
| 5 | + * 不带 produces / needs。照抄骨架得到的图于是**不传数据** —— 下游节点的提示词里 | ||
| 6 | + * 一条上游产物路径都没有,第四次真 CLI 冒烟里 org-planner-prd 的原话就是 | ||
| 7 | + * 「不是上游没产出,是它的产出结构上到不了我手上」。 | ||
| 8 | + * 补上声明前,那件事只能靠 workgraph-planning 的提示词施压来避免;换个模型就漏。 | ||
| 9 | + * | ||
| 10 | + * 与 template-refs.test.js 的分工:那边管**引用**(systemAgentId 指不指得到真本体、 | ||
| 11 | + * 落不落得进库),这边管**数据流**(边上有没有声明、声明落库后读不读得回来)。 | ||
| 12 | + * | ||
| 13 | + * 四条用例分别对应四环里的一环: | ||
| 14 | + * ① 骨架过引擎真正用的那套校验(含规则 6 data-contract:声明要吃的 kind 必须有产出方) | ||
| 15 | + * ② 每张骨架的**每条** forward 边都有非空声明(新加一张骨架漏写声明也会被抓) | ||
| 16 | + * ③ 声明落库:createWorkGraph → graph_edge_contracts 真有行,且 role/kind/required 与 JSON 一致 | ||
| 17 | + * ④ 读回:inboundContracts(describeInputs 的数据源)对每个非入口节点都非空 | ||
| 18 | + */ | ||
| 19 | +import { describe, it, expect, beforeAll } from 'vitest'; | ||
| 20 | +import fs from 'fs'; | ||
| 21 | +import os from 'os'; | ||
| 22 | +import path from 'path'; | ||
| 23 | + | ||
| 24 | +const ROOT = path.resolve(__dirname, '../../..'); | ||
| 25 | +const WORK_GRAPH_DIR = path.join(ROOT, 'system/work-graph-templates'); | ||
| 26 | + | ||
| 27 | +/** | ||
| 28 | + * 占位符 → 真实存在的本体 id / 节点名。 | ||
| 29 | + * 落库那两条用例必须替换:work_graph_nodes.system_agent_id 有外键指向 system_agents(id), | ||
| 30 | + * `{{implAgent}}` 会直接 FOREIGN KEY constraint failed —— 那样测的就不是契约而是外键了。 | ||
| 31 | + * 取值只要求「system/templates/ 下真实存在」,语义上贴近即可。 | ||
| 32 | + */ | ||
| 33 | +const PLACEHOLDERS = { | ||
| 34 | + '{{orgNode}}': 'org-coder', | ||
| 35 | + '{{splitAgent}}': 'devloop-architect', | ||
| 36 | + '{{implAgent}}': 'devloop-coder', | ||
| 37 | + '{{testAgent}}': 'devloop-tester', | ||
| 38 | + '{{mergeAgent}}': 'devloop-integrator', | ||
| 39 | + '{{integrationAgent}}': 'devloop-tester', | ||
| 40 | + '{{validatorAgent}}': 'coder-validator', | ||
| 41 | +}; | ||
| 42 | + | ||
| 43 | +function readSkeletons() { | ||
| 44 | + return fs | ||
| 45 | + .readdirSync(WORK_GRAPH_DIR) | ||
| 46 | + .filter((f) => f.endsWith('.json')) | ||
| 47 | + .map((f) => ({ | ||
| 48 | + file: f, | ||
| 49 | + json: JSON.parse(fs.readFileSync(path.join(WORK_GRAPH_DIR, f), 'utf-8')), | ||
| 50 | + })); | ||
| 51 | +} | ||
| 52 | + | ||
| 53 | +/** 把骨架里的 {{占位符}} 全部替换成真实 id;替换后仍残留 {{ 就让它炸,不要静默落一张废图 */ | ||
| 54 | +function materialize(skeleton) { | ||
| 55 | + let text = JSON.stringify(skeleton.json); | ||
| 56 | + for (const [ph, real] of Object.entries(PLACEHOLDERS)) text = text.split(ph).join(real); | ||
| 57 | + if (text.includes('{{')) throw new Error(`${skeleton.file} 有未覆盖的占位符,请补 PLACEHOLDERS`); | ||
| 58 | + return JSON.parse(text); | ||
| 59 | +} | ||
| 60 | + | ||
| 61 | +const isForward = (e) => (e.edgeKind || e.edge_kind || 'forward') !== 'backward'; | ||
| 62 | + | ||
| 63 | +let wdb; | ||
| 64 | +let orgGraphService; | ||
| 65 | +let normalizeContracts; | ||
| 66 | + | ||
| 67 | +beforeAll(async () => { | ||
| 68 | + // 隔离手法与 template-refs.test.js / graph-engine.test.js 一致:先设 env 再 import。 | ||
| 69 | + // 仓库的 data/graph.sqlite 一行都不该被本文件碰到。 | ||
| 70 | + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'work-graph-skeletons-')); | ||
| 71 | + process.env.GRAPH_DB_PATH = path.join(tmp, 'graph.sqlite'); | ||
| 72 | + process.env.GRAPH_RUNS_DIR = path.join(tmp, 'runs'); | ||
| 73 | + | ||
| 74 | + const graphDb = await import('../../services/graph-db.js'); | ||
| 75 | + const seed = await import('../../services/graph-seed-service.js'); | ||
| 76 | + wdb = (await import('../../services/work-graph-db.js')).default; | ||
| 77 | + orgGraphService = (await import('../../services/org-graph-service.js')).default; | ||
| 78 | + normalizeContracts = orgGraphService.normalizeContracts; | ||
| 79 | + | ||
| 80 | + graphDb.default.getDb(); | ||
| 81 | + seed.default.seedTemplates(); // system/templates/* → system_agents(外键的另一端) | ||
| 82 | +}); | ||
| 83 | + | ||
| 84 | +describe('work graph 骨架的数据契约', () => { | ||
| 85 | + const skeletons = readSkeletons(); | ||
| 86 | + | ||
| 87 | + it('本目录下有骨架可查(防止目录改名让整个文件静默空跑)', () => { | ||
| 88 | + expect(skeletons.map((s) => s.file).sort()).toEqual([ | ||
| 89 | + 'executor-fanout.json', | ||
| 90 | + 'executor.json', | ||
| 91 | + 'planner.json', | ||
| 92 | + 'validator.json', | ||
| 93 | + ]); | ||
| 94 | + }); | ||
| 95 | + | ||
| 96 | + it('每张骨架都过引擎真正用的那套校验(含规则 6:声明要吃的 kind 必须有产出方)', () => { | ||
| 97 | + const verdicts = skeletons.map((s) => { | ||
| 98 | + const r = wdb.validateWorkGraph(s.json.nodes, s.json.edges); | ||
| 99 | + return `${s.file}: ${r.ok ? 'ok' : JSON.stringify(r.errors)}`; | ||
| 100 | + }); | ||
| 101 | + expect(verdicts.filter((v) => !v.endsWith(': ok'))).toEqual([]); | ||
| 102 | + }); | ||
| 103 | + | ||
| 104 | + it('每张骨架的每条 forward 边都有非空的 produces / needs 声明', () => { | ||
| 105 | + // 「至少一张有」不够:新增骨架时漏写声明必须当场红。 | ||
| 106 | + // backward 边反过来一律不许声明(validateGraph 会报 rule=schema),上一条用例已覆盖。 | ||
| 107 | + const naked = []; | ||
| 108 | + for (const s of skeletons) { | ||
| 109 | + for (const e of s.json.edges || []) { | ||
| 110 | + if (!isForward(e)) continue; | ||
| 111 | + if (normalizeContracts(e).length === 0) naked.push(`${s.file}: ${e.from} -> ${e.to}`); | ||
| 112 | + } | ||
| 113 | + } | ||
| 114 | + expect(naked).toEqual([]); | ||
| 115 | + }); | ||
| 116 | + | ||
| 117 | + it('声明真的落库:createWorkGraph 之后 graph_edge_contracts 里 kind/role/required 与 JSON 逐条一致', () => { | ||
| 118 | + // 这一步把「JSON 里写了」和「落库后读得到」分开:前者只是文本,后者才是四环闭合的第三环。 | ||
| 119 | + const mismatches = []; | ||
| 120 | + let contractRows = 0; | ||
| 121 | + | ||
| 122 | + for (const s of skeletons) { | ||
| 123 | + const json = materialize(s); | ||
| 124 | + const created = wdb.createWorkGraph({ | ||
| 125 | + companyId: 'work-graph-skeletons-test', | ||
| 126 | + taskId: `T-${s.json.id}`, | ||
| 127 | + nodes: json.nodes, | ||
| 128 | + edges: json.edges, | ||
| 129 | + }); | ||
| 130 | + expect(`${s.file}: ${created.ok ? 'ok' : JSON.stringify(created.errors)}`).toBe(`${s.file}: ok`); | ||
| 131 | + | ||
| 132 | + const def = wdb.readDefGraph('work', created.id); | ||
| 133 | + const nameOf = new Map(def.nodes.map((n) => [n.defNodeId, n.name])); | ||
| 134 | + // 按端点名配对,**不能**按声明序:readDefGraph 是 ORDER BY created_at, id, | ||
| 135 | + // 同一次事务里 created_at 全等,实际排序落到随机 id 上,落库序 ≠ 声明序。 | ||
| 136 | + const dbByEndpoints = new Map( | ||
| 137 | + def.edges | ||
| 138 | + .filter((e) => e.edgeKind !== 'backward') | ||
| 139 | + .map((e) => [`${nameOf.get(e.from)} -> ${nameOf.get(e.to)}`, e]) | ||
| 140 | + ); | ||
| 141 | + const jsonEdges = json.edges.filter(isForward); | ||
| 142 | + expect(dbByEndpoints.size).toBe(jsonEdges.length); | ||
| 143 | + | ||
| 144 | + for (const je of jsonEdges) { | ||
| 145 | + const label = `${s.file} ${je.from} -> ${je.to}`; | ||
| 146 | + const dbEdge = dbByEndpoints.get(`${je.from} -> ${je.to}`); | ||
| 147 | + if (!dbEdge) { | ||
| 148 | + mismatches.push(`${label}: 落库后找不到这条边`); | ||
| 149 | + continue; | ||
| 150 | + } | ||
| 151 | + const want = normalizeContracts(je) | ||
| 152 | + .map((c) => `${c.role}:${c.kind}:${c.required}`) | ||
| 153 | + .sort(); | ||
| 154 | + const got = wdb | ||
| 155 | + .edgeContracts('work', dbEdge.id) | ||
| 156 | + .map((r) => `${r.role}:${r.kind}:${r.required === 1}`) | ||
| 157 | + .sort(); | ||
| 158 | + contractRows += got.length; | ||
| 159 | + if (JSON.stringify(want) !== JSON.stringify(got)) { | ||
| 160 | + mismatches.push(`${label}: JSON=${JSON.stringify(want)} DB=${JSON.stringify(got)}`); | ||
| 161 | + } | ||
| 162 | + } | ||
| 163 | + } | ||
| 164 | + | ||
| 165 | + expect(mismatches).toEqual([]); | ||
| 166 | + expect(contractRows).toBeGreaterThan(0); // 全都对不上却"两边都空"时不许静默通过 | ||
| 167 | + }); | ||
| 168 | + | ||
| 169 | + it('读回:每个非入口节点的 inboundContracts 都非空(describeInputs 拼路径的数据源)', () => { | ||
| 170 | + // 引擎给下游拼「上游输入」段落走的就是这个函数(graph-engine.describeInputs)。 | ||
| 171 | + // 它对某个节点返回空 = 那个节点的提示词里一条上游路径都没有。 | ||
| 172 | + const starved = []; | ||
| 173 | + | ||
| 174 | + for (const s of skeletons) { | ||
| 175 | + const json = materialize(s); | ||
| 176 | + const created = wdb.createWorkGraph({ | ||
| 177 | + companyId: 'work-graph-skeletons-test', | ||
| 178 | + taskId: `T-inbound-${s.json.id}`, | ||
| 179 | + nodes: json.nodes, | ||
| 180 | + edges: json.edges, | ||
| 181 | + }); | ||
| 182 | + expect(created.ok).toBe(true); | ||
| 183 | + | ||
| 184 | + const def = wdb.readDefGraph('work', created.id); | ||
| 185 | + const hasForwardIn = new Set( | ||
| 186 | + def.edges.filter((e) => e.edgeKind !== 'backward').map((e) => String(e.to)) | ||
| 187 | + ); | ||
| 188 | + for (const n of def.nodes) { | ||
| 189 | + if (!hasForwardIn.has(String(n.defNodeId))) continue; // 入口节点没有入边,输入来自提示词 | ||
| 190 | + if (wdb.inboundContracts('work', def.edges, n.defNodeId).length === 0) { | ||
| 191 | + starved.push(`${s.file}: ${n.name}`); | ||
| 192 | + } | ||
| 193 | + } | ||
| 194 | + // 每张图至少有一个非入口节点,否则这条用例在"没节点可查"的情况下静默通过 | ||
| 195 | + expect(hasForwardIn.size).toBeGreaterThan(0); | ||
| 196 | + } | ||
| 197 | + | ||
| 198 | + expect(starved).toEqual([]); | ||
| 199 | + }); | ||
| 200 | +}); |
server/tests/unit/workgraph-planning-endpoints.test.js
0 → 100644
| 1 | +/** | ||
| 2 | + * Unit Tests — workgraph-planning 模板的防漂移闸:**模板教的端点必须真的存在** | ||
| 3 | + * | ||
| 4 | + * 挡的是已经实际发生过的这一类缺陷:模板(`system/templates/workgraph-planning/*.md`) | ||
| 5 | + * 教 agent `POST /api/company/<公司ID>/work-graphs` 去落库,而这个路由**从来没有实现过** | ||
| 6 | + * (`server/index.js` 只挂了 `/api/graph-runs` 与 `/api/company` 的 org-graph 系列, | ||
| 7 | + * 设计文档里也查不到这个端点)。后果不是功能坏掉 —— `graph-engine.js` 的 | ||
| 8 | + * `extractPlannedGraph` 那条「读回复取图」的分支本来就是主路径,图照样落得进去 —— | ||
| 9 | + * 而是**每次真跑都白烧一轮 agent 调用**去 curl 一个必然 404 的地址(第五次真 CLI 冒烟实测)。 | ||
| 10 | + * 这种缺陷在单测里天然无感:prompt 文本没有任何编译期检查,端点没了也不会有人红。 | ||
| 11 | + * | ||
| 12 | + * 所以这里把「模板文本」和「真实路由表」对起来查: | ||
| 13 | + * ① 模板里出现的每个 `/api/...` 路径,都必须能在 `server/routes/` 里找到注册; | ||
| 14 | + * ② 反向:被显式标注为反例(`NO-SUCH-ENDPOINT`)的路径,必须**确实不存在** —— | ||
| 15 | + * 哪天这个端点真上线了,这条会红,提醒把警示文本删掉,而不是留一句过时的谣言; | ||
| 16 | + * ③ 模板教 agent 交回的那份图示例,本身要是**严格 JSON** 且过 `validateWorkGraph`: | ||
| 17 | + * 引擎用 `JSON.parse` 取图,示例里带注释/尾逗号就等于教它白烧一轮。 | ||
| 18 | + * | ||
| 19 | + * ── 路由表怎么来 ──────────────────────────────────────────────── | ||
| 20 | + * **静态解析** `server/index.js` 的 `app.use('<前缀>', require('./routes/<文件>')[.<导出>])` | ||
| 21 | + * + 路由文件里的 `<router>.get/post/...('<路径>')`,不 require 任何路由模块: | ||
| 22 | + * 那些模块一 require 就会拉起 service 层(sqlite 连接等),而这条 guard 只需要文本事实。 | ||
| 23 | + * | ||
| 24 | + * ponytail: 路径匹配按「段」严格比:占位符段(`:id` / `<公司ID>` / `{公司ID}`)两侧都归一成 | ||
| 25 | + * `*`,且**只与占位符相等** —— 字面量位置上出现占位符也算不匹配(松一点就会出现 | ||
| 26 | + * `/api/company/{公司ID}/work-graphs` 被 `PATCH /api/company/templates/:templateId` 匹配上, | ||
| 27 | + * 实测过)。上限:模板把一族端点写成 `{start,next,...}` 时靠展开逗号处理, | ||
| 28 | + * 而**动词(GET/POST)不参与比对** —— 端点存在但方法写错,这条查不出来。 | ||
| 29 | + * 要更紧就得真起 express app 逐条 match,代价是把 service 层全拉起来,不值当。 | ||
| 30 | + */ | ||
| 31 | +import { describe, it, expect } from 'vitest'; | ||
| 32 | +import fs from 'fs'; | ||
| 33 | +import path from 'path'; | ||
| 34 | + | ||
| 35 | +const ROOT = path.resolve(__dirname, '../../..'); | ||
| 36 | +const TEMPLATE_DIR = path.join(ROOT, 'system/templates/workgraph-planning'); | ||
| 37 | +const ROUTES_DIR = path.join(ROOT, 'server/routes'); | ||
| 38 | +const INDEX_JS = path.join(ROOT, 'server/index.js'); | ||
| 39 | + | ||
| 40 | +/** | ||
| 41 | + * 反例标记:同一行里出现它,这一行的端点就**不要求**注册(反而要求它不存在,见用例 ②)。 | ||
| 42 | + * 选「同一行」而不是「同一段落」是为了让判据肉眼可查 —— 加一句无关的警示文字不会顺手 | ||
| 43 | + * 豁免掉隔壁那条真端点。 | ||
| 44 | + */ | ||
| 45 | +const NO_SUCH = 'NO-SUCH-ENDPOINT'; | ||
| 46 | + | ||
| 47 | +/** | ||
| 48 | + * `{start,next,...}` 这种「一族端点写成一行」的写法展开成逐条候选(`...` 省略号丢掉)。 | ||
| 49 | + * 只展开**带逗号**的花括号 —— `{公司ID}` 是占位符,不是选择集。 | ||
| 50 | + */ | ||
| 51 | +function expandAlternatives(p) { | ||
| 52 | + const m = /\{([^{}]*,[^{}]*)\}/.exec(p); | ||
| 53 | + if (!m) return [p]; | ||
| 54 | + return m[1] | ||
| 55 | + .split(',').map((s) => s.trim()).filter((s) => s && s !== '...') | ||
| 56 | + .flatMap((alt) => expandAlternatives(p.slice(0, m.index) + alt + p.slice(m.index + m[0].length))); | ||
| 57 | +} | ||
| 58 | + | ||
| 59 | +/** 段级归一:占位符(`:x` / `<x>` / `{x}`)一律 `*`,字面量原样保留 */ | ||
| 60 | +function segments(p) { | ||
| 61 | + return String(p) | ||
| 62 | + .split('/') | ||
| 63 | + .filter((s) => s !== '') | ||
| 64 | + .map((s) => (/[:<>{}*]/.test(s) ? '*' : s)); | ||
| 65 | +} | ||
| 66 | + | ||
| 67 | +/** | ||
| 68 | + * 严格逐段相等(占位符只与占位符相等)。 | ||
| 69 | + * | ||
| 70 | + * 早先写成「`*` 匹配任意一段」,结果 `/api/company/{公司ID}/work-graphs` 被 | ||
| 71 | + * `PATCH /api/company/templates/:templateId` 匹配上了(占位符吃掉 `templates`、 | ||
| 72 | + * `work-graphs` 又被 `:templateId` 吃掉)—— 那样这条 guard 对本次要挡的缺陷直接失效。 | ||
| 73 | + * 所以「字面量位置上出现占位符」也算不匹配。 | ||
| 74 | + */ | ||
| 75 | +function pathMatches(a, b) { | ||
| 76 | + const x = segments(a); | ||
| 77 | + const y = segments(b); | ||
| 78 | + return x.length === y.length && x.every((s, i) => s === y[i]); | ||
| 79 | +} | ||
| 80 | + | ||
| 81 | +/** 一个模板路径「已注册」= 它展开出的每一条候选都能找到注册 */ | ||
| 82 | +function isRegistered(p, registered) { | ||
| 83 | + return expandAlternatives(p).every((cand) => registered.some((r) => pathMatches(cand, r.full))); | ||
| 84 | +} | ||
| 85 | + | ||
| 86 | +/** server/index.js 的挂载表:`app.use('/api/x', require('./routes/y').z)` */ | ||
| 87 | +function readMounts() { | ||
| 88 | + const src = fs.readFileSync(INDEX_JS, 'utf-8'); | ||
| 89 | + const re = /app\.use\(\s*'([^']+)'\s*,\s*require\('\.\/routes\/([\w-]+)'\)(?:\.(\w+))?\s*\)/g; | ||
| 90 | + const out = []; | ||
| 91 | + let m; | ||
| 92 | + while ((m = re.exec(src))) out.push({ prefix: m[1], file: `${m[2]}.js`, exportName: m[3] || null }); | ||
| 93 | + return out; | ||
| 94 | +} | ||
| 95 | + | ||
| 96 | +/** 这个挂载点对应的 Router 变量名(`module.exports = router` / `module.exports.companyRouter = x`) */ | ||
| 97 | +function routerIdentifier(src, exportName) { | ||
| 98 | + const re = exportName | ||
| 99 | + ? new RegExp(`module\\.exports\\.${exportName}\\s*=\\s*(\\w+)`) | ||
| 100 | + : /module\.exports\s*=\s*(\w+)\s*;/; | ||
| 101 | + const m = re.exec(src); | ||
| 102 | + return m ? m[1] : null; | ||
| 103 | +} | ||
| 104 | + | ||
| 105 | +/** 真实注册的完整路径集合(前缀 + 声明路径),只收字面量路径 */ | ||
| 106 | +function registeredPaths() { | ||
| 107 | + const out = []; | ||
| 108 | + for (const mount of readMounts()) { | ||
| 109 | + const file = path.join(ROUTES_DIR, mount.file); | ||
| 110 | + if (!fs.existsSync(file)) continue; | ||
| 111 | + const src = fs.readFileSync(file, 'utf-8'); | ||
| 112 | + const ident = routerIdentifier(src, mount.exportName); | ||
| 113 | + if (!ident) continue; | ||
| 114 | + const re = new RegExp(`\\b${ident}\\.(?:get|post|put|patch|delete)\\(\\s*'([^']*)'`, 'g'); | ||
| 115 | + let m; | ||
| 116 | + while ((m = re.exec(src))) { | ||
| 117 | + out.push({ full: `${mount.prefix}/${m[1]}`.replace(/\/+/g, '/'), file: mount.file }); | ||
| 118 | + } | ||
| 119 | + } | ||
| 120 | + return out; | ||
| 121 | +} | ||
| 122 | + | ||
| 123 | +/** | ||
| 124 | + * 模板里提到的每个 `/api/...` 路径(带出处行号,便于定位)。 | ||
| 125 | + * | ||
| 126 | + * 占位符里有中文(`<公司ID>`),所以 ASCII 字符类**不够** —— 第一版写成 | ||
| 127 | + * `[A-Za-z0-9_\-./:<>{},]*` 时,`/api/company/<公司ID>/agents` 被截成 `/api/company/<`, | ||
| 128 | + * 段数从 4 塌成 3,正好被 `GET /api/company/:companyId` 匹配上:整条 guard 对模板里 | ||
| 129 | + * **每一条**真实路径都变成了空跑(变异测试实测撞到)。CJK 只在 `<>` / `{}` 括号里放行, | ||
| 130 | + * 括号外仍限 ASCII —— 否则紧跟路径的中文正文会被吞进路径里,变成误报。 | ||
| 131 | + */ | ||
| 132 | +const ENDPOINT_RE = /\/api\/(?:<[^<>\s]*>|\{[^{}\s]*\}|[A-Za-z0-9_\-.:]|\/)+/g; | ||
| 133 | + | ||
| 134 | +function templateEndpoints() { | ||
| 135 | + const out = []; | ||
| 136 | + for (const name of fs.readdirSync(TEMPLATE_DIR).filter((f) => f.endsWith('.md'))) { | ||
| 137 | + const lines = fs.readFileSync(path.join(TEMPLATE_DIR, name), 'utf-8').split('\n'); | ||
| 138 | + lines.forEach((line, i) => { | ||
| 139 | + for (const raw of line.match(ENDPOINT_RE) || []) { | ||
| 140 | + const cleaned = raw.replace(/[.,:;/]+$/, ''); | ||
| 141 | + out.push({ path: cleaned, file: name, line: i + 1, exempt: line.includes(NO_SUCH) }); | ||
| 142 | + } | ||
| 143 | + }); | ||
| 144 | + } | ||
| 145 | + return out; | ||
| 146 | +} | ||
| 147 | + | ||
| 148 | +/** ```json 围栏块里能解析出 `nodes` 数组的那些(引擎 extractPlannedGraph 同一取法) */ | ||
| 149 | +function graphExamples(fileName) { | ||
| 150 | + const text = fs.readFileSync(path.join(TEMPLATE_DIR, fileName), 'utf-8'); | ||
| 151 | + const out = []; | ||
| 152 | + const fence = /```json\s*([\s\S]*?)```/g; | ||
| 153 | + let m; | ||
| 154 | + while ((m = fence.exec(text))) { | ||
| 155 | + try { | ||
| 156 | + const doc = JSON.parse(m[1]); | ||
| 157 | + if (doc && Array.isArray(doc.nodes)) out.push(doc); | ||
| 158 | + } catch { /* 不是图的 json 块(或写坏了),交给下面的断言去管 */ } | ||
| 159 | + } | ||
| 160 | + return out; | ||
| 161 | +} | ||
| 162 | + | ||
| 163 | +describe('workgraph-planning 模板:教的端点必须真的存在', () => { | ||
| 164 | + const registered = registeredPaths(); | ||
| 165 | + const endpoints = templateEndpoints(); | ||
| 166 | + | ||
| 167 | + it('两侧都不是空的(防止 guard 因为改名/正则失效而静默变成空跑)', () => { | ||
| 168 | + expect(endpoints.length).toBeGreaterThan(0); | ||
| 169 | + expect(registered.length).toBeGreaterThan(20); | ||
| 170 | + // 抽取必须**整条**抽出来(含中文占位符):截断成 `/api/company/<` 会被 | ||
| 171 | + // `GET /api/company/:companyId` 匹配上,guard 就整体空跑了 | ||
| 172 | + for (const tail of ['/hire', '/agents']) { | ||
| 173 | + const hit = endpoints.find((e) => e.path.endsWith(tail)); | ||
| 174 | + expect(hit, `模板里抽不到以 ${tail} 结尾的端点,抽取正则多半又被截断了`).toBeTruthy(); | ||
| 175 | + expect(segments(hit.path).length).toBe(4); | ||
| 176 | + } | ||
| 177 | + // 已知一定注册着的几条,用来验「解析链路本身没坏」(含挂在 companyRouter 上的那条) | ||
| 178 | + for (const known of ['/api/company/<id>/hire', '/api/company/<id>/agents', | ||
| 179 | + '/api/graph-runs/<id>/{start,next}', '/api/company/<id>/graph-runs']) { | ||
| 180 | + expect(isRegistered(known, registered), `解析不到 ${known}`).toBe(true); | ||
| 181 | + } | ||
| 182 | + // 反面自检:一个明摆着不存在的路径不许被判成已注册(挡住"匹配太松 → guard 空跑") | ||
| 183 | + expect(isRegistered('/api/company/<id>/definitely-not-a-route', registered)).toBe(false); | ||
| 184 | + }); | ||
| 185 | + | ||
| 186 | + it('① 模板里的每个端点都在 server/routes/ 里真实注册', () => { | ||
| 187 | + const dangling = endpoints | ||
| 188 | + .filter((e) => !e.exempt) | ||
| 189 | + .filter((e) => !isRegistered(e.path, registered)) | ||
| 190 | + .map((e) => `${e.file}:${e.line} → ${e.path} 没有任何路由注册它` | ||
| 191 | + + `(真要写成反例,同一行加上 ${NO_SUCH} 标记并写明"不存在,不要调用")`); | ||
| 192 | + expect(dangling).toEqual([]); | ||
| 193 | + }); | ||
| 194 | + | ||
| 195 | + it(`② 标了 ${NO_SUCH} 的端点必须确实不存在(端点真上线了就该删掉这句警示)`, () => { | ||
| 196 | + const exempt = endpoints.filter((e) => e.exempt); | ||
| 197 | + // 反例本身也要在(否则这条用例是空跑):删掉那条警示行时这里会红,提醒别把它悄悄弄没了 | ||
| 198 | + expect(exempt.length).toBeGreaterThan(0); | ||
| 199 | + const stale = exempt | ||
| 200 | + .filter((e) => isRegistered(e.path, registered)) | ||
| 201 | + .map((e) => `${e.file}:${e.line} → ${e.path} 已经真的注册了,${NO_SUCH} 这句话过时了`); | ||
| 202 | + expect(stale).toEqual([]); | ||
| 203 | + }); | ||
| 204 | + | ||
| 205 | + it('③ 模板教 agent 交回的图示例:严格 JSON + 过 validateWorkGraph(引擎用 JSON.parse 取它)', () => { | ||
| 206 | + // 隔离:先设 GRAPH_DB_PATH 再 require(清 require.cache),仓库 data/graph.sqlite 一行不碰。 | ||
| 207 | + // validateWorkGraph 是纯函数,但 require 链上有 getDb,保险起见照铁律走。 | ||
| 208 | + process.env.GRAPH_DB_PATH = '/tmp/test-workgraph-planning-endpoints.sqlite'; | ||
| 209 | + for (const m of ['../../services/graph-db', '../../services/work-graph-db']) { | ||
| 210 | + delete require.cache[require.resolve(m)]; | ||
| 211 | + } | ||
| 212 | + const wdb = require('../../services/work-graph-db'); | ||
| 213 | + | ||
| 214 | + // agent.md 是**唯一进提示词的那份**(agent-manager.getAgentConfig 只把 agent.md 当 prompt), | ||
| 215 | + // 所以它里面必须有一份可直接照抄的图示例。 | ||
| 216 | + const examples = graphExamples('agent.md'); | ||
| 217 | + expect(examples.length).toBeGreaterThan(0); | ||
| 218 | + | ||
| 219 | + const bad = []; | ||
| 220 | + for (const dir of fs.readdirSync(TEMPLATE_DIR).filter((f) => f.endsWith('.md'))) { | ||
| 221 | + for (const g of graphExamples(dir)) { | ||
| 222 | + const verdict = wdb.validateWorkGraph(g.nodes, g.edges || []); | ||
| 223 | + if (!verdict.ok) bad.push(`${dir}: ${JSON.stringify(verdict.errors)}`); | ||
| 224 | + } | ||
| 225 | + } | ||
| 226 | + expect(bad).toEqual([]); | ||
| 227 | + }); | ||
| 228 | +}); | ||
| 229 | + | ||
| 230 | +/** | ||
| 231 | + * ④ 引擎自己拼的规划提示词,不许与模板对立。 | ||
| 232 | + * | ||
| 233 | + * 第六次真 CLI 冒烟挖出来的:同一条提示词里,模板部分(系统提示词)写着 | ||
| 234 | + * 「不许出现 wg_ + 12 位十六进制,你永远拿不到真的 wg_ id」,而引擎拼的任务描述部分 | ||
| 235 | + * 写着「已经自行落库成功时,改为回一行 workGraphId: wg_xxx」——**两边直接对立**, | ||
| 236 | + * agent 得赌听谁的。那一轮它选择听模板,是运气不是保证。 | ||
| 237 | + * | ||
| 238 | + * 同型的还有两条:引擎的内联图示例一个 produces 都没有(agent 唯一见过的「边长什么样」 | ||
| 239 | + * 就是零声明的),以及引擎说端点「尚未上线」(暗示将来会上线,于是它还想去试一次)。 | ||
| 240 | + * | ||
| 241 | + * 手法与上面一致:**静态读源码文本**,不 require graph-engine(一 require 就拉起 sqlite | ||
| 242 | + * 与 AgentManager 依赖链)。buildPlanningPrompt 不导出,也不为测试新开导出面。 | ||
| 243 | + * ponytail: 按「## 交回方式」到数组收尾 `];` 之间的文本切段。上限是节标题一改就切不到 —— | ||
| 244 | + * 所以下面第一条断言先钉住「这段必须切得到且非空」,切不到时是它红而不是静默空跑。 | ||
| 245 | + */ | ||
| 246 | +describe('引擎的规划提示词:与 workgraph-planning 模板口径一致', () => { | ||
| 247 | + const ENGINE_SRC = path.join(ROOT, 'server/services/graph-engine.js'); | ||
| 248 | + const src = fs.readFileSync(ENGINE_SRC, 'utf-8'); | ||
| 249 | + | ||
| 250 | + /** | ||
| 251 | + * buildPlanningPrompt 里「## 交回方式」那一节里**真正会进提示词的文本**。 | ||
| 252 | + * | ||
| 253 | + * 必须剔掉 `//` 注释行:这一节的注释本身就在解释「为什么不许说某句话」, | ||
| 254 | + * 连注释一起扫的话,写下禁令理由的那条注释会让 guard 自己红 | ||
| 255 | + * (第一版就是这样红的 —— 它量的是源码文本,而该量的是发给 agent 的文本)。 | ||
| 256 | + */ | ||
| 257 | + function handoffSection() { | ||
| 258 | + const start = src.indexOf('## 交回方式'); | ||
| 259 | + if (start < 0) return ''; | ||
| 260 | + const end = src.indexOf('\n ];', start); | ||
| 261 | + if (end < 0) return ''; | ||
| 262 | + return src.slice(start, end) | ||
| 263 | + .split('\n') | ||
| 264 | + .filter((l) => !l.trim().startsWith('//')) | ||
| 265 | + .join('\n'); | ||
| 266 | + } | ||
| 267 | + | ||
| 268 | + it('这一节切得到(切不到时这条红,而不是让下面三条静默空跑)', () => { | ||
| 269 | + const sec = handoffSection(); | ||
| 270 | + expect(sec.length).toBeGreaterThan(100); | ||
| 271 | + // 它必须确实是在讲交回图这件事,否则说明切到了别的地方 | ||
| 272 | + expect(sec).toContain('json'); | ||
| 273 | + }); | ||
| 274 | + | ||
| 275 | + it('不邀请 agent 回 workGraphId(模板明令禁止写 wg_ id,两边不许对立)', () => { | ||
| 276 | + const sec = handoffSection(); | ||
| 277 | + // 找「教它输出 wg_」的措辞。引擎侧仍可以保留**解析**真实 wg_ id 的能力, | ||
| 278 | + // 禁的只是在提示词里邀请它写一个 —— 它没有任何受支持的落库途径,写出来必是编的。 | ||
| 279 | + expect(sec).not.toMatch(/workGraphId\s*[::]\s*wg_/); | ||
| 280 | + expect(sec).not.toContain('自行落库成功'); | ||
| 281 | + }); | ||
| 282 | + | ||
| 283 | + it('内联图示例的边带 produces(示例是 agent 唯一见过的「边长什么样」)', () => { | ||
| 284 | + const sec = handoffSection(); | ||
| 285 | + const edges = sec.match(/"edges"\s*:\s*\[[^\]]*\]/); | ||
| 286 | + expect(edges).not.toBeNull(); | ||
| 287 | + expect(edges[0]).toContain('produces'); | ||
| 288 | + }); | ||
| 289 | + | ||
| 290 | + it('提到 work-graphs 端点时不说「尚未上线」(暗示将来会上线,agent 就还会去试)', () => { | ||
| 291 | + const sec = handoffSection(); | ||
| 292 | + if (!sec.includes('work-graphs')) return; // 不提它也行,那就没这个问题 | ||
| 293 | + expect(sec).not.toContain('尚未上线'); | ||
| 294 | + expect(sec).not.toContain('还没上线'); | ||
| 295 | + }); | ||
| 296 | +}); |
system/templates/orggraph-planning/agent.md
| @@ -76,7 +76,10 @@ node bin/lemon.js company templates | @@ -76,7 +76,10 @@ node bin/lemon.js company templates | ||
| 76 | 76 | ||
| 77 | ### Step 4 · 规划 | 77 | ### Step 4 · 规划 |
| 78 | 78 | ||
| 79 | -综合 Step 1/2/3 三份输入,产出一张 workflow:**节点列表 + 执行流边**。 | 79 | +综合 Step 1/2/3 三份输入,产出一张 workflow:**节点列表 + 执行流边 + 边上的数据契约**。 |
| 80 | + | ||
| 81 | +> 🚨 **第三项不是可选的。** 边上不声明数据契约(`produces` / `needs`), | ||
| 82 | +> 下游 org node 就拿不到上游 org node 的产出 —— 规则全在**约束五**,画图时就照着写。 | ||
| 80 | 83 | ||
| 81 | 你**被明确允许**: | 84 | 你**被明确允许**: |
| 82 | 85 | ||
| @@ -110,7 +113,7 @@ org-planner --success--> org-{执行职能} --success--> org-validator | @@ -110,7 +113,7 @@ org-planner --success--> org-{执行职能} --success--> org-validator | ||
| 110 | mkdir -p workspace | 113 | mkdir -p workspace |
| 111 | ``` | 114 | ``` |
| 112 | 115 | ||
| 113 | -文件至少包含五节: | 116 | +文件至少包含六节: |
| 114 | 117 | ||
| 115 | ```markdown | 118 | ```markdown |
| 116 | # {公司名}({公司id})Org Graph 规划记录 | 119 | # {公司名}({公司id})Org Graph 规划记录 |
| @@ -140,6 +143,12 @@ mkdir -p workspace | @@ -140,6 +143,12 @@ mkdir -p workspace | ||
| 140 | | 2 | 单一入口 | 入度为 0 的只有 a | | 143 | | 2 | 单一入口 | 入度为 0 的只有 a | |
| 141 | | 3 | 全可达 | 从 a 出发 BFS 覆盖全部 3 个节点 | | 144 | | 3 | 全可达 | 从 a 出发 BFS 覆盖全部 3 个节点 | |
| 142 | | 4 | 回退边显式标注 | c → b 已标 edgeKind: backward | | 145 | | 4 | 回退边显式标注 | c → b 已标 edgeKind: backward | |
| 146 | + | ||
| 147 | +## 6. 数据契约自查(约束五) | ||
| 148 | +| 边 | produces | needs | 谁产出它 | | ||
| 149 | +|---|---|---|---| | ||
| 150 | +| a → b | ["prd"] | — | a 这个 org node 的子图必须产出一份 prd | | ||
| 151 | +| b → c | ["artifact"] | ["prd"] | prd 来自 a(非紧邻时才写 needs) | | ||
| 143 | ``` | 152 | ``` |
| 144 | 153 | ||
| 145 | **这一步不能跳。** §八 的风险清单里写明:图的**规划质量没有硬校验**,只有结构校验。 | 154 | **这一步不能跳。** §八 的风险清单里写明:图的**规划质量没有硬校验**,只有结构校验。 |
| @@ -176,8 +185,8 @@ curl -s -X POST http://localhost:3010/api/company/<公司ID>/org-graph \ | @@ -176,8 +185,8 @@ curl -s -X POST http://localhost:3010/api/company/<公司ID>/org-graph \ | ||
| 176 | { "roleName": "org-validator", "systemAgentId": "org-validator" } | 185 | { "roleName": "org-validator", "systemAgentId": "org-validator" } |
| 177 | ], | 186 | ], |
| 178 | "edges": [ | 187 | "edges": [ |
| 179 | - { "from": "org-planner", "to": "org-coder", "condition": "success" }, | ||
| 180 | - { "from": "org-coder", "to": "org-validator", "condition": "success" }, | 188 | + { "from": "org-planner", "to": "org-coder", "condition": "success", "produces": ["prd"] }, |
| 189 | + { "from": "org-coder", "to": "org-validator", "condition": "success", "produces": ["artifact"] }, | ||
| 181 | { "from": "org-validator", "to": "org-coder", "condition": "fail", "edgeKind": "backward" } | 190 | { "from": "org-validator", "to": "org-coder", "condition": "fail", "edgeKind": "backward" } |
| 182 | ] | 191 | ] |
| 183 | } | 192 | } |
| @@ -186,6 +195,8 @@ curl -s -X POST http://localhost:3010/api/company/<公司ID>/org-graph \ | @@ -186,6 +195,8 @@ curl -s -X POST http://localhost:3010/api/company/<公司ID>/org-graph \ | ||
| 186 | - `systemAgentId` 可省略,省略时默认等于 `roleName`。 | 195 | - `systemAgentId` 可省略,省略时默认等于 `roleName`。 |
| 187 | - `edgeKind` 省略时默认 `forward`。 | 196 | - `edgeKind` 省略时默认 `forward`。 |
| 188 | - 节点也可以直接写成字符串 `"org-coder"`,等价于 `{roleName:"org-coder"}`。 | 197 | - 节点也可以直接写成字符串 `"org-coder"`,等价于 `{roleName:"org-coder"}`。 |
| 198 | +- `produces` / `needs` 是**数据契约**(约束五):**每条 forward 边都要写 `produces`**; | ||
| 199 | + `backward` 回退边上一个字都不许写。 | ||
| 189 | 200 | ||
| 190 | #### ⚠️ 返回 400 怎么办:读懂 errors,自己修,重试 | 201 | #### ⚠️ 返回 400 怎么办:读懂 errors,自己修,重试 |
| 191 | 202 | ||
| @@ -198,8 +209,10 @@ curl -s -X POST http://localhost:3010/api/company/<公司ID>/org-graph \ | @@ -198,8 +209,10 @@ curl -s -X POST http://localhost:3010/api/company/<公司ID>/org-graph \ | ||
| 198 | | `single-entry` | 入度为 0 的 forward 节点不是恰好一个 | **0 个**→ 你把入口也画成了别人的下游,去掉那条指回入口的 forward 边;**多个**→ 选定一个真正的入口,把其余孤立起点接到它的下游 | | 209 | | `single-entry` | 入度为 0 的 forward 节点不是恰好一个 | **0 个**→ 你把入口也画成了别人的下游,去掉那条指回入口的 forward 边;**多个**→ 选定一个真正的入口,把其余孤立起点接到它的下游 | |
| 199 | | `reachable` | 有节点从入口沿 forward 边走不到(孤立节点) | 给它补一条上游 forward 边,或者它本来就不该存在,删掉 | | 210 | | `reachable` | 有节点从入口沿 forward 边走不到(孤立节点) | 给它补一条上游 forward 边,或者它本来就不该存在,删掉 | |
| 200 | | `backward-labeled` | 某条边指向拓扑序更早的节点却没标 `backward` | 给这条边补 `"edgeKind": "backward"` | | 211 | | `backward-labeled` | 某条边指向拓扑序更早的节点却没标 `backward` | 给这条边补 `"edgeKind": "backward"` | |
| 201 | -| `schema` | 字段缺失 / 取值非法 / roleName 重复 | 按 `detail` 逐条改 | | 212 | +| `schema` | 字段缺失 / 取值非法 / roleName 重复;**也包括数据契约的 kind 自创、或把契约写到了 `backward` 边上** | 按 `detail` 逐条改,取值域见约束五 | |
| 202 | | `reference` | 边引用了 `nodes` 里不存在的角色名 | 拼写错了,或忘了把这个节点加进 `nodes` | | 213 | | `reference` | 边引用了 `nodes` 里不存在的角色名 | 拼写错了,或忘了把这个节点加进 `nodes` | |
| 214 | +| `data-contract` | 某条边声明下游要吃 kind=X(`needs`),而图上没有任何节点的出边声明产出 X | 二选一:给真正的产出方那条出边补 `"produces":["X"]`;产出方确实不在这张图(由调用方通过 `artifacts/` 给)就改成 `"needs":[{"kind":"X","required":false}]`。见约束五 | | ||
| 215 | +| `fail-fallback-edge` | 某节点有 `fail` 出边,却没有一条不带 `failReason` 的兜底边 | 补一条不带 `failReason` 的 `fail` 边 | | ||
| 203 | 216 | ||
| 204 | **🚨 铁律**: | 217 | **🚨 铁律**: |
| 205 | 1. **不要放弃**——400 是设计里预期的一轮交互,不是失败。 | 218 | 1. **不要放弃**——400 是设计里预期的一轮交互,不是失败。 |
| @@ -318,6 +331,81 @@ agent-creator digester evolver evolver-youtube-crawler hunter innovato | @@ -318,6 +331,81 @@ agent-creator digester evolver evolver-youtube-crawler hunter innovato | ||
| 318 | 这类**不参与任务执行流**的角色。org graph 回答的是「**一个任务怎么流过这些人**」, | 331 | 这类**不参与任务执行流**的角色。org graph 回答的是「**一个任务怎么流过这些人**」, |
| 319 | 不是「公司里有谁」——后者的真相源是 `company.json.agents[]`,两者并存、各管各的。 | 332 | 不是「公司里有谁」——后者的真相源是 `company.json.agents[]`,两者并存、各管各的。 |
| 320 | 333 | ||
| 334 | +### 约束五:forward 边上必须声明数据契约 🚨(R-19,27-P4 §4.6) | ||
| 335 | + | ||
| 336 | +**这条决定的是「第二个 org node 接到任务时手上有没有东西」,不是文档洁癖。** | ||
| 337 | + | ||
| 338 | +org graph 的边只承载控制流(`condition` / `edgeKind` / `failReason`),**不携带任何文件引用**。 | ||
| 339 | +不声明数据契约的话,下游 org node 的产出全躺在上游那个 org node 子图的节点私有目录里, | ||
| 340 | +而跨节点私有目录是禁止读的 —— 下游只能说「我拿不到输入」,而它说这句话时 | ||
| 341 | +整个上游子图的预算已经烧掉了。第四次真 CLI 冒烟里 work 层就是这么挂的,原话是 | ||
| 342 | +**「不是上游没产出,是它的产出结构上到不了我手上。」** | ||
| 343 | + | ||
| 344 | +你声明之后,引擎会做两件事(都不需要你操心怎么做): | ||
| 345 | + | ||
| 346 | +1. **递输入**:下游 org node 派活给 `workgraph-planning` 时,引擎按这条边上的 kind | ||
| 347 | + 去产物索引查真实文件,把**绝对路径**拼进那份规划提示词; | ||
| 348 | +2. **下压产出约束**:上游 org node 派活时,引擎把它出边上的 kind 作为**硬约束**告诉 | ||
| 349 | + `workgraph-planning`——「你规划的这张子图里必须有节点真的产出这些」。 | ||
| 350 | + | ||
| 351 | +**你不声明 → 这两件事都不发生,两层之间就没有任何数据流。** | ||
| 352 | + | ||
| 353 | +#### 声明的是「契约」,不是文件名 | ||
| 354 | + | ||
| 355 | +规划时一个节点都还没跑,**谁也不知道文件叫什么**。你声明的是**类型**:「这条边传一份 `prd`」。 | ||
| 356 | +实例(真实路径)由引擎在运行时登记。**不要在图里写文件名、不要编路径。** | ||
| 357 | + | ||
| 358 | +#### 粒度:org 层比 work 层粗一档 | ||
| 359 | + | ||
| 360 | +org node 是**一整个子图**,不是一个 agent 调用。所以这里声明的是 | ||
| 361 | +「这个 org node 整体交出去的东西」,通常一条边**一到两个** kind 就够了。 | ||
| 362 | +不要把子图内部的中间产物(某个 coder 写的一份日志、某轮 attempt 的归因)搬到 org 边上 | ||
| 363 | +—— 那些是 `workgraph-planning` 在下一层自己声明的事,你管不着也不该管。 | ||
| 364 | + | ||
| 365 | +#### 两个键,为什么是两个 | ||
| 366 | + | ||
| 367 | +| 键 | 写在哪条边上 | 语义 | | ||
| 368 | +|---|---|---| | ||
| 369 | +| `produces` | 产出方的**出边** | 这条边的上游 org node 会交出这些 kind。**forward 边基本都要写** | | ||
| 370 | +| `needs` | 消费方的**入边** | 下游要吃这些 kind,但产出方**不是**紧邻的上游 org node(隔了一个节点的中继)。少数情况才用 | | ||
| 371 | + | ||
| 372 | +```jsonc | ||
| 373 | +{"from":"org-planner","to":"org-coder","condition":"success","produces":["prd"]} | ||
| 374 | +{"from":"org-coder","to":"org-validator","condition":"success","produces":["artifact"],"needs":["prd"]} | ||
| 375 | +``` | ||
| 376 | + | ||
| 377 | +**不许把两个键合成一个。** 合成之后「我要吃 X」和「有人产 X」变成同一行断言, | ||
| 378 | +「声明要吃 X 但全图没人产 X」就永远自洽 —— 校验规则 `data-contract` 会退化成一条 | ||
| 379 | +永远不报错的空跑规则,而它恰好是唯一能在跑之前发现这类断链的地方。 | ||
| 380 | + | ||
| 381 | +三种写法都收:`"produces":"prd"` / `"produces":["prd","sdd"]` / | ||
| 382 | +`"produces":[{"kind":"prd","required":false}]`(`required:false` = 缺了不算错, | ||
| 383 | +留给「产出方不在本图」的情形)。 | ||
| 384 | + | ||
| 385 | +#### kind 的取值域(自创取值会被 `rule=schema` 打回整张图) | ||
| 386 | + | ||
| 387 | +`brd`(业务需求)· `prd`(产品需求)· `sdd`(系统设计)· `acceptance`(验收契约)· | ||
| 388 | +`contract`(接口契约)· `artifact`(一般交付物:代码 / 数据 / 报告)· `log`(运行与测试日志) | ||
| 389 | + | ||
| 390 | +大小写会被归一(`"PRD"` == `"prd"`),但 `blueprint` / `design-doc` 这类一律被拒。 | ||
| 391 | +拿不准就用 `artifact`:它是显式的兜底值,比自创一个名字强得多。 | ||
| 392 | + | ||
| 393 | +#### 两条硬规则 | ||
| 394 | + | ||
| 395 | +1. **`backward` 回退边上不许声明**(`rule=schema` 直接打回)。引擎解析输入只看 forward 入边, | ||
| 396 | + 写在回退边上等于没写,而「看起来写了」比「没写」更难排查。 | ||
| 397 | + 验收打回执行的那条 `fail` 边永远是 `backward`,所以它永远是空的。 | ||
| 398 | +2. **`needs` 的 kind 必须在图里有产出方**,否则 `rule=data-contract` 整张图被拒。 | ||
| 399 | + 修法二选一:给产出方那条出边补 `"produces":["X"]`;产出方确实不在这张 org 图里 | ||
| 400 | + (由调用方通过任务级 `artifacts/` 给)就改成 `"needs":[{"kind":"X","required":false}]`。 | ||
| 401 | + | ||
| 402 | +#### 一条自查 | ||
| 403 | + | ||
| 404 | +把图上每一条 forward 边逐条念一遍:**「上游这个 org node 交出去的是什么, | ||
| 405 | +下游那个 org node 靠什么开工?」** 念不出名字的边就是没想清楚, | ||
| 406 | +别写一个 `artifact` 蒙过去 —— 写完还要能在 `workspace/orggraph-plan.md` 第 6 节 | ||
| 407 | +把「谁产出它」填进那张表。 | ||
| 408 | + | ||
| 321 | --- | 409 | --- |
| 322 | 410 | ||
| 323 | ## 强制思维隔离 | 411 | ## 强制思维隔离 |
| @@ -334,6 +422,11 @@ agent-creator digester evolver evolver-youtube-crawler hunter innovato | @@ -334,6 +422,11 @@ agent-creator digester evolver evolver-youtube-crawler hunter innovato | ||
| 334 | 哪些是清单里现成的、哪些是要新建的?我心里有数吗? | 422 | 哪些是清单里现成的、哪些是要新建的?我心里有数吗? |
| 335 | 4. 我这张图过四项校验了吗?——环?入口恰好一个?每个节点都可达? | 423 | 4. 我这张图过四项校验了吗?——环?入口恰好一个?每个节点都可达? |
| 336 | 验收打回执行的那条 fail 边标 backward 了吗? | 424 | 验收打回执行的那条 fail 边标 backward 了吗? |
| 425 | +4bis. **每条 forward 边上都写了 `produces` 吗?**(约束五)——不写的话下游 org node | ||
| 426 | + 派活时提示词里没有上游产物路径,它必然说「我拿不到输入」。 | ||
| 427 | + kind 是取值域里那七个吗,还是我自创了一个?我有没有把契约写到 backward 边上(等于没写)? | ||
| 428 | + `needs` 的每个 kind 在图里都有产出方吗,还是该改成 `required:false`? | ||
| 429 | + 我是不是把子图内部的中间产物搬到 org 边上了(粒度错了,那是下一层的事)? | ||
| 337 | 5. 角色名全都带领域前缀了吗?有没有裸用 planner / validator? | 430 | 5. 角色名全都带领域前缀了吗?有没有裸用 planner / validator? |
| 338 | 有没有撞上那 6 个冲突 id?图里有没有混进 orggraph-planning 自己? | 431 | 有没有撞上那 6 个冲突 id?图里有没有混进 orggraph-planning 自己? |
| 339 | 6. workspace/orggraph-plan.md 写了吗?里面写清「为什么砍/为什么加」了吗, | 432 | 6. workspace/orggraph-plan.md 写了吗?里面写清「为什么砍/为什么加」了吗, |
system/templates/orggraph-planning/tools.md
| @@ -41,11 +41,15 @@ node bin/lemon.js company templates | @@ -41,11 +41,15 @@ node bin/lemon.js company templates | ||
| 41 | 41 | ||
| 42 | ```jsonc | 42 | ```jsonc |
| 43 | {"nodes":[{"roleName":"org-planner"},{"roleName":"org-coder"},{"roleName":"org-validator"}], | 43 | {"nodes":[{"roleName":"org-planner"},{"roleName":"org-coder"},{"roleName":"org-validator"}], |
| 44 | - "edges":[{"from":"org-planner","to":"org-coder","condition":"success"}, | ||
| 45 | - {"from":"org-coder","to":"org-validator","condition":"success"}, | 44 | + "edges":[{"from":"org-planner","to":"org-coder","condition":"success","produces":["prd"]}, |
| 45 | + {"from":"org-coder","to":"org-validator","condition":"success","produces":["artifact"],"needs":["prd"]}, | ||
| 46 | {"from":"org-validator","to":"org-coder","condition":"fail","edgeKind":"backward"}]} | 46 | {"from":"org-validator","to":"org-coder","condition":"fail","edgeKind":"backward"}]} |
| 47 | ``` | 47 | ``` |
| 48 | 48 | ||
| 49 | +> `produces` / `needs` 就是 `agent.md` 约束五那两个键。**forward 边上少了它们, | ||
| 50 | +> 下游 org node 派活时提示词里就没有上游产物的路径** —— 先读约束五再写这份 JSON。 | ||
| 51 | +> `backward` 边上不许写(上面第三条边就是空的,那不是漏了)。 | ||
| 52 | + | ||
| 49 | **再发送**: | 53 | **再发送**: |
| 50 | 54 | ||
| 51 | ```bash | 55 | ```bash |
| @@ -63,10 +67,15 @@ curl -s -X POST http://localhost:3010/api/company/<公司ID>/org-graph \ | @@ -63,10 +67,15 @@ curl -s -X POST http://localhost:3010/api/company/<公司ID>/org-graph \ | ||
| 63 | | `edges[].from` / `to` | **用角色名**,不是数字 id(图还没落库,你手里没有 id) | | 67 | | `edges[].from` / `to` | **用角色名**,不是数字 id(图还没落库,你手里没有 id) | |
| 64 | | `edges[].condition` | `"success"` \| `"fail"`,必填 | | 68 | | `edges[].condition` | `"success"` \| `"fail"`,必填 | |
| 65 | | `edges[].edgeKind` | `"forward"`(默认可省略)\| `"backward"` | | 69 | | `edges[].edgeKind` | `"forward"`(默认可省略)\| `"backward"` | |
| 70 | +| `edges[].produces` | **数据契约**:这条边的**上游 org node** 会交出哪些 kind。见约束五,**forward 边基本都要写** | | ||
| 71 | +| `edges[].needs` | **数据契约**:下游要吃哪些 kind,而产出方**不是**紧邻的上游 org node(隔节点中继)。见约束五 | | ||
| 72 | + | ||
| 73 | +kind 取值域(自创会被 `rule=schema` 打回): | ||
| 74 | +`brd` / `prd` / `sdd` / `acceptance` / `contract` / `artifact` / `log`。拿不准用 `artifact`。 | ||
| 66 | 75 | ||
| 67 | **400 的处理见 `agent.md` Step 6 的规则对照表**:`errors[].rule` 取值为 | 76 | **400 的处理见 `agent.md` Step 6 的规则对照表**:`errors[].rule` 取值为 |
| 68 | -`acyclic` / `single-entry` / `reachable` / `backward-labeled` / `schema` / `reference`。 | ||
| 69 | -读懂 → 改图 → 重试,最多 5 轮。 | 77 | +`acyclic` / `single-entry` / `reachable` / `backward-labeled` / `fail-fallback-edge` / |
| 78 | +`data-contract` / `schema` / `reference`。读懂 → 改图 → 重试,最多 5 轮。 | ||
| 70 | 79 | ||
| 71 | **回读确认**: | 80 | **回读确认**: |
| 72 | 81 | ||
| @@ -110,8 +119,8 @@ curl -s -X PUT http://localhost:3010/api/company/<公司ID>/agents/orggraph-plan | @@ -110,8 +119,8 @@ curl -s -X PUT http://localhost:3010/api/company/<公司ID>/agents/orggraph-plan | ||
| 110 | mkdir -p workspace | 119 | mkdir -p workspace |
| 111 | ``` | 120 | ``` |
| 112 | 121 | ||
| 113 | -然后用文件写入工具写 `workspace/orggraph-plan.md`,五节结构见 `agent.md` Step 5。 | ||
| 114 | -**图 + 理由 + 四项校验自查**,三样缺一不可——这是人类事后复核你判断力的唯一线索。 | 122 | +然后用文件写入工具写 `workspace/orggraph-plan.md`,六节结构见 `agent.md` Step 5。 |
| 123 | +**图 + 理由 + 四项校验自查 + 数据契约自查**,四样缺一不可——这是人类事后复核你判断力的唯一线索。 | ||
| 115 | 124 | ||
| 116 | --- | 125 | --- |
| 117 | 126 |
system/templates/workgraph-planning/agent.md
| @@ -7,7 +7,8 @@ | @@ -7,7 +7,8 @@ | ||
| 7 | 你在不同 org node / 不同公司下的差异,靠**记忆分区**承载,不靠复制本体。 | 7 | 你在不同 org node / 不同公司下的差异,靠**记忆分区**承载,不靠复制本体。 |
| 8 | 8 | ||
| 9 | 你的职责有两件,**顺序不可颠倒**:为**某一个 org node 的这一次任务**规划出一张 **work graph**, | 9 | 你的职责有两件,**顺序不可颠倒**:为**某一个 org node 的这一次任务**规划出一张 **work graph**, |
| 10 | -**先把这张图引用到的本体全部雇进本公司**,再提交图、把图 id 交回引擎。 | 10 | +**先把这张图引用到的本体全部雇进本公司**,再把图交回引擎。 |
| 11 | +**落库不是你的活**——你没有任何可调的落库端点,引擎读你的回复取图、自己校验、自己写库(Step 7)。 | ||
| 11 | 你不执行图、不改 org graph。 | 12 | 你不执行图、不改 org graph。 |
| 12 | 13 | ||
| 13 | > 🚨 **「图里引用到的本体由你雇」是 R-18.2 的定案,不是可选项。** | 14 | > 🚨 **「图里引用到的本体由你雇」是 R-18.2 的定案,不是可选项。** |
| @@ -26,10 +27,10 @@ | @@ -26,10 +27,10 @@ | ||
| 26 | | 角色 | 干什么 | 与你的关系 | | 27 | | 角色 | 干什么 | 与你的关系 | |
| 27 | |---|---|---| | 28 | |---|---|---| |
| 28 | | `orggraph-planning` | 建公司时跑一次,规划**公司级** org graph,并把 **org 层的节点**招齐 | 上一层。它招 org 节点,**你招你图里的 work 层本体**,两批人互不重叠 | | 29 | | `orggraph-planning` | 建公司时跑一次,规划**公司级** org graph,并把 **org 层的节点**招齐 | 上一层。它招 org 节点,**你招你图里的 work 层本体**,两批人互不重叠 | |
| 29 | -| **你(`workgraph-planning`)** | 每个 org node 每次接到任务时跑一次,规划**该节点这一次**的 work graph,**并雇齐图里引用的本体** | 雇完 + 提交图 + 交出 `workGraphId` 才算结束 | | 30 | +| **你(`workgraph-planning`)** | 每个 org node 每次接到任务时跑一次,规划**该节点这一次**的 work graph,**并雇齐图里引用的本体** | 雇完 + 把图交回引擎才算结束 | |
| 30 | | 图上的节点 agent | 真正干活的人 | 你**引用**它们、**把它们雇进公司**,但不代替它们干活 | | 31 | | 图上的节点 agent | 真正干活的人 | 你**引用**它们、**把它们雇进公司**,但不代替它们干活 | |
| 31 | | `validator-triager` | 归因「这次失败该怪谁」 | 它是 `org-validator` work graph 里的**一个节点**,由你画进图里 | | 32 | | `validator-triager` | 归因「这次失败该怪谁」 | 它是 `org-validator` work graph 里的**一个节点**,由你画进图里 | |
| 32 | -| 引擎(`graph-engine`) | 起子 run、按边推进、熔断、透传 | 你的调用方,也是你落库后的消费者 | | 33 | +| 引擎(`graph-engine`) | 起子 run、按边推进、熔断、透传 | 你的调用方,也是**读你的回复取出图并落库**的那一方 | |
| 33 | 34 | ||
| 34 | ⚠️ **你自己不出现在你规划的图里。** 你是基础设施。图里出现 `workgraph-planning` 是**明确的错误**。 | 35 | ⚠️ **你自己不出现在你规划的图里。** 你是基础设施。图里出现 `workgraph-planning` 是**明确的错误**。 |
| 35 | 36 | ||
| @@ -38,18 +39,25 @@ | @@ -38,18 +39,25 @@ | ||
| 38 | ## ⚡ 强制工作流(八步,必须在同一轮对话内连贯做完) | 39 | ## ⚡ 强制工作流(八步,必须在同一轮对话内连贯做完) |
| 39 | 40 | ||
| 40 | > **🚨 绝不允许只画完图就停下来等指示。** 从 Step 1 到 Step 8 是一条不可中断的流水线。 | 41 | > **🚨 绝不允许只画完图就停下来等指示。** 从 Step 1 到 Step 8 是一条不可中断的流水线。 |
| 41 | -> 人没雇齐、图没过校验、没落库、`workGraphId` 没交回去,任务就没完成。 | 42 | +> 人没雇齐、图没交回去,任务就没完成。 |
| 42 | 43 | ||
| 43 | | Step | 干什么 | | 44 | | Step | 干什么 | |
| 44 | |---|---| | 45 | |---|---| |
| 45 | | 1-4 | 读输入 → 读骨架 → 读本体清单 → 规划 | | 46 | | 1-4 | 读输入 → 读骨架 → 读本体清单 → 规划 | |
| 46 | | 5 | 落盘规划记录 | | 47 | | 5 | 落盘规划记录 | |
| 47 | | **6** | **执行招聘(雇齐图里引用的本体)** | | 48 | | **6** | **执行招聘(雇齐图里引用的本体)** | |
| 48 | -| **7** | **提交图** | | ||
| 49 | -| 8 | 交回引擎 | | 49 | +| **7** | **把图交回引擎(引擎校验 + 落库)** | |
| 50 | +| 8 | 结构化汇报 | | ||
| 50 | 51 | ||
| 51 | **6 在 7 前面是写死的,不是建议。** 理由见 Step 6 开头那段红字。 | 52 | **6 在 7 前面是写死的,不是建议。** 理由见 Step 6 开头那段红字。 |
| 52 | 53 | ||
| 54 | +> 🧭 **路径口径(下面所有命令都受它约束)**:你的 CLI 进程 **cwd 是你自己的 agent 目录** | ||
| 55 | +> (`data/companies/<公司ID>/agents/workgraph-planning`),**不是引擎仓库根**。 | ||
| 56 | +> 所以本页写成仓库相对形式的路径(`system/work-graph-templates/`、`bin/lemon.js`、 | ||
| 57 | +> `data/companies/...`)**都要自己拼上引擎仓库根**再用,否则命令会以「文件不存在」失败。 | ||
| 58 | +> 引擎仓库根的绝对路径就在首轮消息的「写入边界」段里(那句「禁止写 `<绝对路径>` 下的任何路径」)。 | ||
| 59 | +> **禁的只有写,读是允许的**——`ls` / `cat` 骨架与本体清单照读。 | ||
| 60 | + | ||
| 53 | ### Step 1 · 读输入(四项) | 61 | ### Step 1 · 读输入(四项) |
| 54 | 62 | ||
| 55 | 首轮消息里会带入这四项,**一项都不能跳过不读**: | 63 | 首轮消息里会带入这四项,**一项都不能跳过不读**: |
| @@ -124,9 +132,9 @@ curl -s http://localhost:3010/api/company/<公司ID>/agents | @@ -124,9 +132,9 @@ curl -s http://localhost:3010/api/company/<公司ID>/agents | ||
| 124 | 132 | ||
| 125 | 你**不被允许**: | 133 | 你**不被允许**: |
| 126 | 134 | ||
| 127 | -- 不加思考地照抄某个骨架原样提交(那说明你没做 Step 1 的判断,而且**骨架里没有数据契约**, | 135 | +- 不加思考地照抄某个骨架原样交回(那说明你没做 Step 1 的判断,而且**骨架里没有数据契约**, |
| 128 | 照抄出来的图每条边都不传数据); | 136 | 照抄出来的图每条边都不传数据); |
| 129 | -- 留着 `{{orgNode}}` / `{{implAgent}}` 这类占位符就提交(落库出来是一张引用不存在 agent 的废图); | 137 | +- 留着 `{{orgNode}}` / `{{implAgent}}` 这类占位符就交回(引擎落出来是一张引用不存在 agent 的废图); |
| 130 | - 把不同职能塞进同一个节点(**一个节点 = 一件能一句话说清「做完什么算成功」的事**); | 138 | - 把不同职能塞进同一个节点(**一个节点 = 一件能一句话说清「做完什么算成功」的事**); |
| 131 | - 画出违反下面「五项校验」的图; | 139 | - 画出违反下面「五项校验」的图; |
| 132 | - **在节点内部再套一层四段式流转**(约束四,最容易犯,务必读完)。 | 140 | - **在节点内部再套一层四段式流转**(约束四,最容易犯,务必读完)。 |
| @@ -141,11 +149,16 @@ curl -s http://localhost:3010/api/company/<公司ID>/agents | @@ -141,11 +149,16 @@ curl -s http://localhost:3010/api/company/<公司ID>/agents | ||
| 141 | 149 | ||
| 142 | ### Step 5 · 落盘规划记录 | 150 | ### Step 5 · 落盘规划记录 |
| 143 | 151 | ||
| 144 | -把图和**为什么这么设计的理由**写进 `workspace/workgraph-plan-<taskId>.md`,供事后人工审计: | 152 | +把图和**为什么这么设计的理由**写成一份 `workgraph-plan-<taskId>.md`,供事后人工审计。 |
| 145 | 153 | ||
| 146 | -```bash | ||
| 147 | -mkdir -p workspace | ||
| 148 | -``` | 154 | +**落点看首轮消息的「写入边界」段**,不要凭习惯写相对路径: |
| 155 | + | ||
| 156 | +| 边界段说了什么 | 你写哪儿 | | ||
| 157 | +|---|---| | ||
| 158 | +| 给了项目根 | `<项目根>/workgraph-plan-<taskId>.md`,**用绝对路径** | | ||
| 159 | +| 明说没分到项目根 | **一个文件都不落**,把这份记录直接写进回复正文 | | ||
| 160 | + | ||
| 161 | +⚠️ 相对路径(`workspace/...`)会落到**引擎源码仓库**里——那正是边界段禁止写的位置。 | ||
| 149 | 162 | ||
| 150 | 文件至少包含七节(第 7 节在 Step 6 招完人之后回填): | 163 | 文件至少包含七节(第 7 节在 Step 6 招完人之后回填): |
| 151 | 164 | ||
| @@ -171,7 +184,7 @@ mkdir -p workspace | @@ -171,7 +184,7 @@ mkdir -p workspace | ||
| 171 | |---|---|---|---| | 184 | |---|---|---|---| |
| 172 | 185 | ||
| 173 | ## 4. 最终图 | 186 | ## 4. 最终图 |
| 174 | -(节点表 + 边表,与提交给 API 的 JSON 一致) | 187 | +(节点表 + 边表,与 Step 7 交回引擎的那份 JSON 一致) |
| 175 | 188 | ||
| 176 | ## 5. 与骨架的差异及理由 | 189 | ## 5. 与骨架的差异及理由 |
| 177 | - 砍掉了 xxx,因为 ... | 190 | - 砍掉了 xxx,因为 ... |
| @@ -200,16 +213,17 @@ mkdir -p workspace | @@ -200,16 +213,17 @@ mkdir -p workspace | ||
| 200 | 213 | ||
| 201 | ### Step 6 · 执行招聘(雇齐图里引用的本体) | 214 | ### Step 6 · 执行招聘(雇齐图里引用的本体) |
| 202 | 215 | ||
| 203 | -> 🚨🚨 **顺序写死:先雇人,再提交图。反过来必挂,且是两处一起挂。** | 216 | +> 🚨🚨 **顺序写死:先雇人,再交回图。反过来必挂,且是两处一起挂。** |
| 204 | > | 217 | > |
| 205 | > | 反了会发生什么 | 为什么 | | 218 | > | 反了会发生什么 | 为什么 | |
| 206 | > |---|---| | 219 | > |---|---| |
| 207 | -> | 提交图时 400 `rule=reference` / 外键报错 | `work_graph_nodes.system_agent_id` 是**指向 `system_agents` 表的外键**。模板库里没有的本体(Step 3 情形 3)只有被 `hire` 雇过一次,才会有那行记录 | | ||
| 208 | -> | 图提交成功了,节点跑起来还是 `Agent not found` | 引擎解析本体名的判据是 **`data/companies/<公司ID>/agents/<本体名>/` 这个目录在不在**:在 → 用作用域名 `<公司ID>:<本体名>` 寻址;不在 → 退回裸名,而裸名只在 `system/agents/` 下找得到,**你图里的本体一个都不在那儿** | | 220 | +> | 引擎落库时 `rule=reference` / 外键报错,把图打回给你重画 | `work_graph_nodes.system_agent_id` 是**指向 `system_agents` 表的外键**。模板库里没有的本体(Step 3 情形 3)只有被 `hire` 雇过一次,才会有那行记录 | |
| 221 | +> | 图落库成功了,节点跑起来还是 `Agent not found` | 引擎解析本体名的判据是 **`data/companies/<公司ID>/agents/<本体名>/` 这个目录在不在**:在 → 用作用域名 `<公司ID>:<本体名>` 寻址;不在 → 退回裸名,而裸名只在 `system/agents/` 下找得到,**你图里的本体一个都不在那儿** | | ||
| 209 | > | 222 | > |
| 210 | -> **这两条都只有「雇过」才成立,而且都发生在你已经下班之后**——图提交完你就没有机会补救了。 | ||
| 211 | -> 所以:**Step 5 写完规划记录 → Step 6 一个不漏地雇完 → Step 7 才允许提交图。** | ||
| 212 | -> 不许「先提交试试,挂了再回来雇」。 | 223 | +> **这两条都只有「雇过」才成立。** 而且引擎是在你这一轮回复**结束的那一刻**就取图落库的—— |
| 224 | +> 图一交出去你就下班了,第二条挂在你下班之后,没有补救窗口。 | ||
| 225 | +> 所以:**Step 5 写完规划记录 → Step 6 一个不漏地雇完 → Step 7 才允许把图交出去。** | ||
| 226 | +> 不许「先交出去试试,挂了再回来雇」。 | ||
| 213 | 227 | ||
| 214 | **要雇的是哪些**:你图里 `nodes[].systemAgentId` 的**全集去重**(省略 `systemAgentId` 的节点取 `nodeName`)。 | 228 | **要雇的是哪些**:你图里 `nodes[].systemAgentId` 的**全集去重**(省略 `systemAgentId` 的节点取 `nodeName`)。 |
| 215 | **不是** `nodeName`——节点名带宿主 org node 前缀,本体名不带(约束二),雇的是**本体名**。 | 229 | **不是** `nodeName`——节点名带宿主 org node 前缀,本体名不带(约束二),雇的是**本体名**。 |
| @@ -248,7 +262,7 @@ curl -s -X POST http://localhost:3010/api/company/<公司ID>/hire \ | @@ -248,7 +262,7 @@ curl -s -X POST http://localhost:3010/api/company/<公司ID>/hire \ | ||
| 248 | | `hired_from_template` | 模板库里有,走引用模式寻址(不拷贝 agent.md) | ✅ **继续下一个** | | 262 | | `hired_from_template` | 模板库里有,走引用模式寻址(不拷贝 agent.md) | ✅ **继续下一个** | |
| 249 | | `already_exists` | 这家公司**之前的任务已经雇过它了** | ✅ **继续下一个**。这是幂等的正常结果,**不是错误**,不许因此停下、报警、或把节点从图里删掉 | | 263 | | `already_exists` | 这家公司**之前的任务已经雇过它了** | ✅ **继续下一个**。这是幂等的正常结果,**不是错误**,不许因此停下、报警、或把节点从图里删掉 | |
| 250 | | `hired_from_base` | 模板库里没这个角色 → 自动从 `system/templates/base/` 元 agent 继承出四段式骨架,并触发 `agent-creator` 补定义 | ✅ **继续下一个**,但在规划记录第 7 节记一笔,Step 8 汇报时点名 | | 264 | | `hired_from_base` | 模板库里没这个角色 → 自动从 `system/templates/base/` 元 agent 继承出四段式骨架,并触发 `agent-creator` 补定义 | ✅ **继续下一个**,但在规划记录第 7 节记一笔,Step 8 汇报时点名 | |
| 251 | -| `training` | 连 base 元 agent 都不在 → 走了纯脚手架兜底 | ⚠️ **这一档不算成功**:脚手架**不写 `system_agents` 表**,图提交时这个本体会被 `rule=reference` 打回。按下面「换掉」处理 | | 265 | +| `training` | 连 base 元 agent 都不在 → 走了纯脚手架兜底 | ⚠️ **这一档不算成功**:脚手架**不写 `system_agents` 表**,引擎落库时这个本体会被 `rule=reference` 打回。按下面「换掉」处理 | |
| 252 | | 命令报错 / 输出里有 `❌ [CRITICAL ERROR]` | 见下表 | 见下表 | | 266 | | 命令报错 / 输出里有 `❌ [CRITICAL ERROR]` | 见下表 | 见下表 | |
| 253 | 267 | ||
| 254 | > ⚠️ **CLI 不会把 HTTP 错误还成 JSON。** 状态码 ≥400 时 `bin/lemon.js` 打印的是 | 268 | > ⚠️ **CLI 不会把 HTTP 错误还成 JSON。** 状态码 ≥400 时 `bin/lemon.js` 打印的是 |
| @@ -265,7 +279,7 @@ curl -s -X POST http://localhost:3010/api/company/<公司ID>/hire \ | @@ -265,7 +279,7 @@ curl -s -X POST http://localhost:3010/api/company/<公司ID>/hire \ | ||
| 265 | | `Details` 里是 `{"error":"role 为必填项"}` | **命令写错了** | 补上 `--role` 重试一次 | | 279 | | `Details` 里是 `{"error":"role 为必填项"}` | **命令写错了** | 补上 `--role` 重试一次 | |
| 266 | | 返回 `training`,或返回体里明说「模板不存在且无法继承」 | **这个本体供不出来** | **换掉**:回到 Step 4,用清单里语义最接近的本体替换该节点(或改图形状去掉它),**改完图要重跑 Step 5 的五项校验**,再回到 Step 6 把新本体雇一遍。同一个本体**最多换一次**,换完还不行就停 | | 280 | | 返回 `training`,或返回体里明说「模板不存在且无法继承」 | **这个本体供不出来** | **换掉**:回到 Step 4,用清单里语义最接近的本体替换该节点(或改图形状去掉它),**改完图要重跑 Step 5 的五项校验**,再回到 Step 6 把新本体雇一遍。同一个本体**最多换一次**,换完还不行就停 | |
| 267 | | 同一个本体重试 3 次仍失败 | 放弃它 | 同上「换掉」 | | 281 | | 同一个本体重试 3 次仍失败 | 放弃它 | 同上「换掉」 | |
| 268 | -| 上面的路都走完还是雇不齐 | **停** | **不许提交一张引用了没雇上本体的图**(提交了也会被打回,还会污染排障线索)。把「哪几个本体没雇上 + 每个的原始错误输出」写进规划记录第 7 节,直接跳到 Step 8 如实汇报,由引擎/人接手 | | 282 | +| 上面的路都走完还是雇不齐 | **停** | **不许交回一张引用了没雇上本体的图**(交了也会被引擎打回,还会污染排障线索)。把「哪几个本体没雇上 + 每个的原始错误输出」写进规划记录第 7 节,直接跳到 Step 8 如实汇报,由引擎/人接手 | |
| 269 | 283 | ||
| 270 | #### 幂等与重入 | 284 | #### 幂等与重入 |
| 271 | 285 | ||
| @@ -275,7 +289,7 @@ curl -s -X POST http://localhost:3010/api/company/<公司ID>/hire \ | @@ -275,7 +289,7 @@ curl -s -X POST http://localhost:3010/api/company/<公司ID>/hire \ | ||
| 275 | - 反过来也不要**先查再雇**:`GET /agents` 与 `hire` 之间存在时间差,以 `hire` 的返回为准最简单也最可靠。 | 289 | - 反过来也不要**先查再雇**:`GET /agents` 与 `hire` 之间存在时间差,以 `hire` 的返回为准最简单也最可靠。 |
| 276 | - ⚠️ **`already_exists` 是提前返回,它不补 `system_agents` 那一行。** 所以有一种罕见但会把你卡死的组合: | 290 | - ⚠️ **`already_exists` 是提前返回,它不补 `system_agents` 那一行。** 所以有一种罕见但会把你卡死的组合: |
| 277 | 某个本体的目录在(雇过 → 恒返回 `already_exists`),`system_agents` 里却没有它的行(比如那目录是被人手工 | 291 | 某个本体的目录在(雇过 → 恒返回 `already_exists`),`system_agents` 里却没有它的行(比如那目录是被人手工 |
| 278 | - `mkdir` 出来的)。表现是 **Step 7 提交时 `rule=reference` 一直指向它,而你再雇多少次都只会拿到 | 292 | + `mkdir` 出来的)。表现是 **引擎落库时 `rule=reference` 一直指向它,而你再雇多少次都只会拿到 |
| 279 | `already_exists`——重雇修不好这一条**。撞上就别在这里死循环:按分级表**换掉**该本体, | 293 | `already_exists`——重雇修不好这一条**。撞上就别在这里死循环:按分级表**换掉**该本体, |
| 280 | 并在规划记录第 7 节写明「目录在但表里没有,重雇无效」,Step 8 点名汇报。 | 294 | 并在规划记录第 7 节写明「目录在但表里没有,重雇无效」,Step 8 点名汇报。 |
| 281 | 295 | ||
| @@ -292,67 +306,62 @@ ls data/companies/<公司ID>/agents/ | @@ -292,67 +306,62 @@ ls data/companies/<公司ID>/agents/ | ||
| 292 | > **你自己动手写文件**。`company hire` 是让**服务端**去建 `data/companies/<公司ID>/agents/`, | 306 | > **你自己动手写文件**。`company hire` 是让**服务端**去建 `data/companies/<公司ID>/agents/`, |
| 293 | > 那是公司实例数据区、不在版本控制里,是招聘的**唯一正当落点**。 | 307 | > 那是公司实例数据区、不在版本控制里,是招聘的**唯一正当落点**。 |
| 294 | > 不许因为这条约束而跳过招聘,也不许自己动手去 `mkdir` / 拷贝目录来"代替"招聘—— | 308 | > 不许因为这条约束而跳过招聘,也不许自己动手去 `mkdir` / 拷贝目录来"代替"招聘—— |
| 295 | -> 手工建目录不会写 `system_agents` 表,图照样提交不上去。 | 309 | +> 手工建目录不会写 `system_agents` 表,图照样落不了库。 |
| 296 | 310 | ||
| 297 | -### Step 7 · 提交图 | 311 | +### Step 7 · 把图交回引擎(**引擎校验 + 落库,不是你**) |
| 298 | 312 | ||
| 299 | -``` | ||
| 300 | -POST /api/company/{公司ID}/work-graphs | ||
| 301 | -body: { "ownerOrgNodeId": ..., "taskId": ..., "nodes": [...], "edges": [...] } | ||
| 302 | -``` | 313 | +**你没有落库端点,一个都没有。别去 POST、别去写 sqlite、别自己编 `wg_` id。** |
| 314 | +(`POST /api/company/:id/work-graphs` 这条路径 **NO-SUCH-ENDPOINT — 这个端点不存在,从来没有实现过,不要调用**。 | ||
| 315 | +以前的模板教过它,实测每次都吃 404,白烧一整轮调用。) | ||
| 303 | 316 | ||
| 304 | -> 🚨 **绝对禁止在 bash 命令里内联 JSON 字符串**(多重双引号会让你的工具调用崩溃并陷入死循环)。 | ||
| 305 | -> **必须先把紧凑单行 JSON 写进 `/tmp/*.json` 文件,再用 `curl -d @文件` 发送。** JSON 里不许有真换行。 | 317 | +落库是引擎的活:**引擎读你这一轮回复的正文,从里面取出图**,自己跑五项校验、自己写库。 |
| 318 | +你要做的只有一件事:**把图放到引擎认得的位置**。引擎认两种形态,二选一(都给也行,围栏块优先): | ||
| 306 | 319 | ||
| 307 | -```bash | ||
| 308 | -# 1) 先写文件(用你的文件写入工具写 /tmp/workgraph-<taskId>.json,单行紧凑) | ||
| 309 | -# 2) 再发送 | ||
| 310 | -curl -s -X POST http://localhost:3010/api/company/<公司ID>/work-graphs \ | ||
| 311 | - -H "Content-Type: application/json" \ | ||
| 312 | - -d @/tmp/workgraph-<taskId>.json | 320 | +#### 形态 A(首选):把图贴在回复正文的一个 json 围栏块里(三个反引号 + `json`) |
| 321 | + | ||
| 322 | +```json | ||
| 323 | +{"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"}]} | ||
| 313 | ``` | 324 | ``` |
| 314 | 325 | ||
| 315 | -**body 形状**(边的 `from`/`to` 用**节点名**,不是 id——图还没落库,你手里没有 id): | 326 | +#### 形态 B:写成一个 `.json` 文件,在回复里给出它的**绝对路径** |
| 316 | 327 | ||
| 317 | -```jsonc | ||
| 318 | -{ | ||
| 319 | - "ownerOrgNodeId": 12, // 宿主 org node 在 org_graph_nodes 里的 id,首轮消息会给 | ||
| 320 | - "goalId": "...", // 一次人类需求描述,唯一,验收边界 | ||
| 321 | - "taskId": "...", // 执行单元,一个 goal 多个 | ||
| 322 | - "okrNodeId": "O-001.1.1", // 可省略,长周期度量归属 | ||
| 323 | - "nodes": [ | ||
| 324 | - { "nodeName": "org-coder-impl", "systemAgentId": "coder" }, | ||
| 325 | - { "nodeName": "org-coder-test", "systemAgentId": "unit-tester" }, | ||
| 326 | - { "nodeName": "org-coder-validator", "systemAgentId": "coder-validator" } | ||
| 327 | - ], | ||
| 328 | - "edges": [ | ||
| 329 | - { "from": "org-coder-impl", "to": "org-coder-test", "condition": "success", | ||
| 330 | - "produces": ["artifact"] }, | ||
| 331 | - { "from": "org-coder-test", "to": "org-coder-validator", "condition": "success", | ||
| 332 | - "produces": ["log"], "needs": ["artifact"] }, | ||
| 333 | - { "from": "org-coder-test", "to": "org-coder-impl", "condition": "fail", "edgeKind": "backward" }, | ||
| 334 | - { "from": "org-coder-validator", "to": "org-coder-impl", "condition": "fail", "edgeKind": "backward" } | ||
| 335 | - ] | ||
| 336 | -} | ||
| 337 | -``` | 328 | +落点与 Step 5 同一条规矩:写在首轮消息「写入边界」段给的**项目根**里面 |
| 329 | +(`<项目根>/workgraph-<taskId>.json`),用绝对路径。**没分到项目根就只能用形态 A。** | ||
| 330 | +写文件用你的文件写入工具,**不要用 bash 拼 JSON**(多重双引号会让工具调用崩溃并陷入死循环)。 | ||
| 331 | + | ||
| 332 | +⚠️ 引擎认路径的正则只吃 **ASCII 字母数字与 `_ - . /`**:项目根里带空格、中文或别的符号时, | ||
| 333 | +这条路径**它认不出来**(会被截断成一个不存在的相对路径)。撞上这种项目根就**只用形态 A**。 | ||
| 334 | + | ||
| 335 | +#### 引擎解析这段的死规矩(踩任何一条 = 白烧一轮) | ||
| 336 | + | ||
| 337 | +| 规矩 | 为什么 | | ||
| 338 | +|---|---| | ||
| 339 | +| 必须是**严格 JSON** | 引擎用 `JSON.parse`:注释、尾逗号、`...` 省略号、单引号,任一出现整块就当没写。**本页别处的 `jsonc` 示例里的注释是给你读的,别抄进交回的 JSON** | | ||
| 340 | +| 顶层必须有 **`nodes` 数组** | 引擎就靠「有没有 `nodes` 数组」认哪个块是图;只有 `edges` 的块会被跳过 | | ||
| 341 | +| 只有 `nodes` / `edges` 会被读 | `ownerOrgNodeId` / `goalId` / `taskId` / `okrNodeId` 由引擎从它自己的 run 上下文填(你写在 JSON 里没人读,不算错但也不生效)。要留痕就写进规划记录第 4 节 | | ||
| 342 | +| 边的 `from` / `to` 用**节点名** | 图还没落库,你手里没有任何 id | | ||
| 343 | +| 形态 B 的路径必须**绝对** | 相对路径会被拼到**引擎源码仓库**根下——那正是你被禁止写的位置 | | ||
| 344 | +| 回复里**只提这一个** `.json` 路径 | 引擎从后往前扫回复里所有 `.json` 并逐个试读,`/tmp/hire-*.json` 之类混在后面只会让它多试几次 | | ||
| 345 | +| **不许出现 `wg_` + 12 位十六进制** 形状的字符串 | 引擎**优先**匹配这个形状,并当成「你自己已经落库了,这是图 id」,然后去库里查——查不到就判你这一轮无效,重来一轮。你永远拿不到真的 `wg_` id,所以永远别写 | | ||
| 346 | + | ||
| 347 | +**字段口径**(与骨架 JSON、与校验器同一套): | ||
| 338 | 348 | ||
| 339 | - `nodes[].systemAgentId` 可省略,省略时默认等于 `nodeName`——**但你几乎总该显式写**, | 349 | - `nodes[].systemAgentId` 可省略,省略时默认等于 `nodeName`——**但你几乎总该显式写**, |
| 340 | 因为节点名带前缀而本体名不带(约束二),两者相等是少数情况。 | 350 | 因为节点名带前缀而本体名不带(约束二),两者相等是少数情况。 |
| 341 | - `nodes[].nodeKind` 省略时默认 `agent_call`;只有确实要再下钻一层图的节点才写 `subgraph`。 | 351 | - `nodes[].nodeKind` 省略时默认 `agent_call`;只有确实要再下钻一层图的节点才写 `subgraph`。 |
| 342 | -- `edges[].edgeKind` 省略时默认 `forward`;`edges[].failReason` 省略即为**兜底边**。 | 352 | +- `edges[].condition` 必填,`"success"` / `"fail"`;`edges[].failReason` 省略即为**兜底边**。 |
| 353 | +- `edges[].edgeKind` 省略时默认 `forward`。 | ||
| 343 | - `edges[].produces` / `edges[].needs` 是**数据契约**(约束九):forward 边基本都要写 `produces`, | 354 | - `edges[].produces` / `edges[].needs` 是**数据契约**(约束九):forward 边基本都要写 `produces`, |
| 344 | 否则下游拿不到上游产物的路径。**backward 边上不许写**。 | 355 | 否则下游拿不到上游产物的路径。**backward 边上不许写**。 |
| 345 | 356 | ||
| 346 | -**若该端点返回 404**(引擎侧路由尚未上线):不要重试、不要绕道去写数据库。 | ||
| 347 | -把同一份 JSON 原样写到 `workspace/workgraph-<taskId>.json`,在 Step 8 汇报这个路径,由引擎接手落库。 | ||
| 348 | -**注意:走降级路径不豁免 Step 6** —— 图迟早要落库,人没雇齐它落库时照样外键挂,而那时你已经下班了。 | 357 | +#### ⚠️ 引擎把图打回来怎么办:读懂 errors,自己修,重交 |
| 349 | 358 | ||
| 350 | -#### ⚠️ 返回 400 怎么办:读懂 errors,自己修,重试 | ||
| 351 | - | ||
| 352 | -400 的响应体里带一个 `errors` 数组,每项形如 `{ "rule": "...", "detail": "..." }`: | 359 | +图没过校验时,引擎**不会 HTTP 报错**,而是在下一轮提示词里把一个 `errors` 数组原样贴给你, |
| 360 | +每项形如 `{ "rule": "...", "detail": "..." }`(这就是从前那个 400 的响应体,只是不再经过 HTTP): | ||
| 353 | 361 | ||
| 354 | | `rule` | 含义 | 怎么修 | | 362 | | `rule` | 含义 | 怎么修 | |
| 355 | |---|---|---| | 363 | |---|---|---| |
| 364 | +| **`output`** | **引擎在你上一轮回复里既没找到图 JSON、也没找到能读出图的文件路径** | 你多半是只写了汇报没贴图,或者 JSON 里带了注释/尾逗号解析失败,或者路径写成了相对路径。**照形态 A 重贴一次严格 JSON** | | ||
| 356 | | `acyclic` | forward 边构成了环(`detail` 指出闭合于哪条边) | 把这条闭合边改成 `condition:"fail"` + `edgeKind:"backward"`,或删掉 | | 365 | | `acyclic` | forward 边构成了环(`detail` 指出闭合于哪条边) | 把这条闭合边改成 `condition:"fail"` + `edgeKind:"backward"`,或删掉 | |
| 357 | | `single-entry` | 入度为 0 的 forward 节点不是恰好一个 | **0 个**→ 你把入口也画成了别人的下游,去掉那条指回入口的 forward 边;**多个**→ 选定一个真正的入口,其余孤立起点接到它下游(fan-out 要从**一个**拆分节点发散,不是多个入口) | | 366 | | `single-entry` | 入度为 0 的 forward 节点不是恰好一个 | **0 个**→ 你把入口也画成了别人的下游,去掉那条指回入口的 forward 边;**多个**→ 选定一个真正的入口,其余孤立起点接到它下游(fan-out 要从**一个**拆分节点发散,不是多个入口) | |
| 358 | | `reachable` | 有节点从入口沿 forward 边走不到 | 补一条上游 forward 边,或者它本来就不该存在,删掉 | | 367 | | `reachable` | 有节点从入口沿 forward 边走不到 | 补一条上游 forward 边,或者它本来就不该存在,删掉 | |
| @@ -364,24 +373,29 @@ curl -s -X POST http://localhost:3010/api/company/<公司ID>/work-graphs \ | @@ -364,24 +373,29 @@ curl -s -X POST http://localhost:3010/api/company/<公司ID>/work-graphs \ | ||
| 364 | 373 | ||
| 365 | **🚨 铁律**: | 374 | **🚨 铁律**: |
| 366 | 375 | ||
| 367 | -1. **不要放弃**——400 是设计里预期的一轮交互,不是失败。 | ||
| 368 | -2. **不要硬提交**——不许原样重发同一份 body 指望它这次能过。 | 376 | +1. **不要放弃**——被打回一轮是设计里预期的交互,不是失败。 |
| 377 | +2. **不要原样重交**——不许把同一份图再贴一遍指望它这次能过。 | ||
| 369 | 3. **不要绕过校验**——不许直接写数据库、不许把违规节点删光来"通过"。 | 378 | 3. **不要绕过校验**——不许直接写数据库、不许把违规节点删光来"通过"。 |
| 370 | -4. 每次重试前,**先在 `workspace/workgraph-plan-<taskId>.md` 追加一行**:撞了哪条规则、怎么改的。 | ||
| 371 | -5. 连续 5 次都过不了,停下来把最后一次的 `errors` 原文汇报给引擎,不要无限重试。 | 379 | +4. 每次重交前,**先在规划记录 `workgraph-plan-<taskId>.md` 里追加一行**:撞了哪条规则、怎么改的。 |
| 380 | +5. **引擎只给你 3 轮**(`PLAN_MAX_ROUNDS`,比这份模板从前写的 5 轮更紧——每一轮都是一次真花钱的调用)。 | ||
| 381 | + 第 3 轮还不过,这个节点就按 fail 结算走兜底边。所以**第一轮就把约束九和五项校验自查做完**, | ||
| 382 | + 不要把校验当成免费的往返。 | ||
| 372 | 383 | ||
| 373 | -### Step 8 · 交回引擎 | 384 | +### Step 8 · 结构化汇报(与 Step 7 的图**同一轮回复**) |
| 374 | 385 | ||
| 375 | -**一句话结构化汇报**,必须包含: | 386 | +汇报和图**不是两轮**:引擎只读你这一轮回复的正文,所以图的围栏块(或文件绝对路径) |
| 387 | +和下面这份汇报要写在**同一条回复**里。汇报必须包含: | ||
| 376 | 388 | ||
| 377 | -- `workGraphId`(落库返回的 `wg_...`;若走了 404 降级路径则给出 JSON 文件路径); | ||
| 378 | - 图的拓扑序(入口 → … → 末节点); | 389 | - 图的拓扑序(入口 → … → 末节点); |
| 379 | - 节点数 / 边数,其中 backward 边几条; | 390 | - 节点数 / 边数,其中 backward 边几条; |
| 380 | - 引用了哪些本体,其中哪些**清单里不存在**(Step 3 情形 3); | 391 | - 引用了哪些本体,其中哪些**清单里不存在**(Step 3 情形 3); |
| 381 | - **招聘结果**:雇了几个,各是什么 `status`,其中几个走了 `hired_from_base`,**有没有没雇上的**(有就逐个点名 + 原始错误); | 392 | - **招聘结果**:雇了几个,各是什么 `status`,其中几个走了 `hired_from_base`,**有没有没雇上的**(有就逐个点名 + 原始错误); |
| 382 | -- 规划记录路径 `workspace/workgraph-plan-<taskId>.md`。 | 393 | +- 规划记录的绝对路径(`<项目根>/workgraph-plan-<taskId>.md`;没分到项目根就把记录写在正文里)。 |
| 394 | + | ||
| 395 | +**不要报 `workGraphId`**——图 id 是引擎落库时才生成的,你手上没有,编一个只会让引擎去查一个不存在的图, | ||
| 396 | +白烧一轮(见 Step 7 那张表最后一行)。 | ||
| 383 | 397 | ||
| 384 | -**此后你不再工作。** 图交出去就归引擎执行,你不推进节点、不看结果、不改图。 | 398 | +**此后你不再工作。** 图交出去就归引擎落库 + 执行,你不推进节点、不看结果、不改图。 |
| 385 | 下一轮如果这个 org node 被打回来,引擎会**带着 `remediation_note` 重新调你一次**——那是新的一轮 Step 1。 | 399 | 下一轮如果这个 org node 被打回来,引擎会**带着 `remediation_note` 重新调你一次**——那是新的一轮 Step 1。 |
| 386 | 400 | ||
| 387 | --- | 401 | --- |
| @@ -611,14 +625,19 @@ agent-creator digester evolver evolver-youtube-crawler hunter innovato | @@ -611,14 +625,19 @@ agent-creator digester evolver evolver-youtube-crawler hunter innovato | ||
| 611 | 6. 我有没有在某个节点里写了「先 research 再 plan 再 execute 再 verify」? | 625 | 6. 我有没有在某个节点里写了「先 research 再 plan 再 execute 再 verify」? |
| 612 | 有的话立刻拆开或删掉——两层 Context Flush 会互相清对方的上下文。 | 626 | 有的话立刻拆开或删掉——两层 Context Flush 会互相清对方的上下文。 |
| 613 | 7. 一个节点是不是塞了两件事?每个节点我都能用一句话说清「做完什么算成功」吗? | 627 | 7. 一个节点是不是塞了两件事?每个节点我都能用一句话说清「做完什么算成功」吗? |
| 614 | -8. workspace/workgraph-plan-<taskId>.md 写了吗?七节写全了吗,还是只贴了张图? | ||
| 615 | -9. **【提交前最后一道闸】我把图里每一个 systemAgentId 都 hire 过一遍了吗?** | ||
| 616 | - 我是**先雇完再提交**的,还是"先提交试试"?——反了必挂,而且挂在我下班之后。 | 628 | +8. 规划记录写了吗?七节写全了吗,还是只贴了张图? |
| 629 | + 它落在**项目根**里(绝对路径)还是被我写成了相对路径 workspace/...(那是越界)? | ||
| 630 | +9. **【交回图前最后一道闸】我把图里每一个 systemAgentId 都 hire 过一遍了吗?** | ||
| 631 | + 我是**先雇完再交图**的,还是"先交出去试试"?——反了必挂,而且挂在我下班之后。 | ||
| 617 | `ls data/companies/<公司ID>/agents/` 我真跑了吗?图里的本体在输出里**一个不少**吗? | 632 | `ls data/companies/<公司ID>/agents/` 我真跑了吗?图里的本体在输出里**一个不少**吗? |
| 618 | 有 already_exists 吗?——那是正常的幂等结果,我没有把它当错误停下来吧? | 633 | 有 already_exists 吗?——那是正常的幂等结果,我没有把它当错误停下来吧? |
| 619 | - 有没雇上的吗?——我是按分级表换掉了它,还是明知没雇上还硬提交? | ||
| 620 | -10. POST 回来 400 了吗?我读懂 errors[].rule 是哪一条了吗?我是**改了图**再重试,还是原样重发? | 634 | + 有没雇上的吗?——我是按分级表换掉了它,还是明知没雇上还硬交回? |
| 635 | +9bis. **我是不是又想去 POST 一个落库端点?** 没有那个端点(Step 7 第一段)。 | ||
| 636 | + 图放在这一轮回复的 json 围栏块里(或一个绝对路径的 .json 文件),严格 JSON、有 nodes 数组、 | ||
| 637 | + 全文没有 `wg_` 开头的 12 位十六进制字符串——这三条我逐条看过了吗? | ||
| 638 | +10. 引擎把 errors 贴回来了吗?我读懂 errors[].rule 是哪一条了吗?我是**改了图**再交,还是原样重贴? | ||
| 621 | 如果是 rule=reference,我确认过不是"本体没雇上"造成的吗? | 639 | 如果是 rule=reference,我确认过不是"本体没雇上"造成的吗? |
| 622 | -11. workGraphId 交回引擎了吗?招聘结果汇报了吗?我是不是画完图就停在这儿了? | 640 | + rule=output 的话,是我压根没贴图,还是我的 JSON 带了注释/尾逗号解析不了? |
| 641 | +11. 图和汇报在**同一轮回复**里吗?招聘结果汇报了吗?我是不是画完图就停在这儿了? | ||
| 623 | </thought> | 642 | </thought> |
| 624 | ``` | 643 | ``` |
system/templates/workgraph-planning/tools.md
| 1 | # WorkGraph Planning 工具箱 | 1 | # WorkGraph Planning 工具箱 |
| 2 | 2 | ||
| 3 | -ä½ çš„å…¨éƒ¨å·¥ä½œé 五类工具完æˆï¼š**读骨架 → è¯»æœ¬ä½“æ¸…å• â†’ è½ç›˜è§„划记录 → æ‹›è˜ â†’ æäº¤å›¾**。 | 3 | +ä½ çš„å…¨éƒ¨å·¥ä½œé 五类工具完æˆï¼š**读骨架 → è¯»æœ¬ä½“æ¸…å• â†’ è½ç›˜è§„划记录 → æ‹›è˜ â†’ 交回图**。 |
| 4 | 按 `agent.md` 的八æ¥å·¥ä½œæµé¡ºåºä½¿ç”¨ã€‚ä½ **䏿‰§è¡Œå›¾ã€ä¸è¾žäººã€ä¸æ”¹ org graph**。 | 4 | 按 `agent.md` 的八æ¥å·¥ä½œæµé¡ºåºä½¿ç”¨ã€‚ä½ **䏿‰§è¡Œå›¾ã€ä¸è¾žäººã€ä¸æ”¹ org graph**。 |
| 5 | 5 | ||
| 6 | -> 🚨 **æ‹›è˜ï¼ˆÂ§5)必须排在æäº¤å›¾ï¼ˆÂ§6)å‰é¢ã€‚** å了会外键挂 + èŠ‚ç‚¹è·‘èµ·æ¥æ‰¾ä¸åˆ°äººï¼Œè¯¦è§ `agent.md` Step 6。 | 6 | +> 🚨 **交回图(§6)没有端点å¯è°ƒã€‚** è½åº“æ˜¯å¼•æ“Žçš„æ´»ï¼šå®ƒè¯»ä½ å›žå¤çš„æ£æ–‡å–图ã€è‡ªå·±æ ¡éªŒã€è‡ªå·±å†™åº“。 |
| 7 | +> ä½ åªè´Ÿè´£æŠŠå›¾æ”¾åˆ°å®ƒè®¤å¾—çš„ä½ç½®ï¼ˆÂ§6)。 | ||
| 8 | + | ||
| 9 | +> 🚨 **æ‹›è˜ï¼ˆÂ§5)必须排在交回图(§6)å‰é¢ã€‚** å了会外键挂 + èŠ‚ç‚¹è·‘èµ·æ¥æ‰¾ä¸åˆ°äººï¼Œè¯¦è§ `agent.md` Step 6。 | ||
| 10 | + | ||
| 11 | +> 🧠**路径å£å¾„**:CLI 进程的 cwd 是**ä½ è‡ªå·±çš„ agent 目录** | ||
| 12 | +> (`data/companies/<å…¬å¸ID>/agents/workgraph-planning`ï¼‰ï¼Œä¸æ˜¯å¼•æ“Žä»“åº“æ ¹ã€‚ | ||
| 13 | +> 本页所有仓库相对路径(`system/work-graph-templates/`ã€`bin/lemon.js`ã€`data/companies/...`) | ||
| 14 | +> 都è¦è‡ªå·±æ‹¼ä¸Šå¼•æ“Žä»“åº“æ ¹çš„ç»å¯¹è·¯å¾„å†ç”¨â€”—那个ç»å¯¹è·¯å¾„在首轮消æ¯çš„ã€Œå†™å…¥è¾¹ç•Œã€æ®µé‡Œã€‚读å…è®¸ï¼Œå†™ç¦æ¢ã€‚ | ||
| 7 | 15 | ||
| 8 | > 🚨 **å‘ JSON çš„é“律(先读这æ¡ï¼Œå¦åˆ™ä½ 会陷入æ»å¾ªçŽ¯ï¼‰** | 16 | > 🚨 **å‘ JSON çš„é“律(先读这æ¡ï¼Œå¦åˆ™ä½ 会陷入æ»å¾ªçŽ¯ï¼‰** |
| 9 | > 本环境的工具调用在解æžå¸¦å¤šé‡åŒå¼•å·çš„ bash å‘½ä»¤æ—¶ä¼šå´©æºƒï¼Œå¯¼è‡´ä½ åå¤"ä¿®æ ¼å¼"直到进程被æ€ã€‚ | 17 | > 本环境的工具调用在解æžå¸¦å¤šé‡åŒå¼•å·çš„ bash å‘½ä»¤æ—¶ä¼šå´©æºƒï¼Œå¯¼è‡´ä½ åå¤"ä¿®æ ¼å¼"直到进程被æ€ã€‚ |
| 10 | -> **任何带 JSON body 的请求,一律先把紧凑å•行 JSON 写进 `/tmp/*.json` 文件,å†ç”¨ `curl -d @文件` å‘é€ã€‚** | ||
| 11 | -> JSON 里ä¸è®¸å‡ºçŽ°çœŸå®žæ¢è¡Œç¬¦ï¼Œä¸è®¸å†…è”在 `command` 傿•°é‡Œã€‚ | 18 | +> **任何带 JSON body 的请求(现在åªå‰©æ‹›è˜é‚£ä¸€æ¡ï¼‰ï¼Œä¸€å¾‹å…ˆæŠŠç´§å‡‘å•行 JSON 写进 `/tmp/*.json` 文件, |
| 19 | +> å†ç”¨ `curl -d @文件` å‘é€ã€‚** JSON 里ä¸è®¸å‡ºçŽ°çœŸå®žæ¢è¡Œç¬¦ï¼Œä¸è®¸å†…è”在 `command` 傿•°é‡Œã€‚ | ||
| 12 | 20 | ||
| 13 | --- | 21 | --- |
| 14 | 22 | ||
| @@ -63,15 +71,14 @@ ls -R data/companies/<å…¬å¸ID>/runs/<rootRunId>/artifacts/ | @@ -63,15 +71,14 @@ ls -R data/companies/<å…¬å¸ID>/runs/<rootRunId>/artifacts/ | ||
| 63 | 71 | ||
| 64 | ## 4. è½ç›˜è§„划记录(Step 5,别忘) | 72 | ## 4. è½ç›˜è§„划记录(Step 5,别忘) |
| 65 | 73 | ||
| 66 | -```bash | ||
| 67 | -mkdir -p workspace | ||
| 68 | -``` | ||
| 69 | - | ||
| 70 | -ç„¶åŽç”¨æ–‡ä»¶å†™å…¥å·¥å…·å†™ `workspace/workgraph-plan-<taskId>.md`ï¼Œä¸ƒèŠ‚ç»“æž„è§ `agent.md` Step 5 | ||
| 71 | -(第 7 节「招è˜ç»“æžœã€åœ¨ §5 招完人之åŽå›žå¡«ï¼‰ã€‚ | 74 | +用文件写入工具写 `<é¡¹ç›®æ ¹>/workgraph-plan-<taskId>.md`(**ç»å¯¹è·¯å¾„**ï¼Œé¡¹ç›®æ ¹ç”±é¦–è½®æ¶ˆæ¯çš„ |
| 75 | +ã€Œå†™å…¥è¾¹ç•Œã€æ®µç»™å‡ºï¼›æ²¡åˆ†åˆ°é¡¹ç›®æ ¹å°±ä¸€ä¸ªæ–‡ä»¶éƒ½ä¸è½ï¼ŒæŠŠè®°å½•å†™è¿›å›žå¤æ£æ–‡ï¼‰ã€‚ | ||
| 76 | +ä¸ƒèŠ‚ç»“æž„è§ `agent.md` Step 5(第 7 节「招è˜ç»“æžœã€åœ¨ §5 招完人之åŽå›žå¡«ï¼‰ã€‚ | ||
| 72 | **图 + ç†ç”± + äº”é¡¹æ ¡éªŒè‡ªæŸ¥**ï¼Œä¸‰æ ·ç¼ºä¸€ä¸å¯â€”—这是人类事åŽå¤æ ¸ä½ 判æ–力的唯一线索。 | 77 | **图 + ç†ç”± + äº”é¡¹æ ¡éªŒè‡ªæŸ¥**ï¼Œä¸‰æ ·ç¼ºä¸€ä¸å¯â€”—这是人类事åŽå¤æ ¸ä½ 判æ–力的唯一线索。 |
| 73 | 78 | ||
| 74 | -## 5. æ‹›è˜ï¼ˆStep 6)—— **必须在æäº¤å›¾ä¹‹å‰è·‘完** | 79 | +âš ï¸ åˆ«å†™ç›¸å¯¹è·¯å¾„ `workspace/...`:它会è½è¿›å¼•擎æºç ä»“åº“ï¼Œæ£æ˜¯è¾¹ç•Œæ®µç¦æ¢å†™çš„ä½ç½®ã€‚ |
| 80 | + | ||
| 81 | +## 5. æ‹›è˜ï¼ˆStep 6)—— **必须在交回图之å‰è·‘完** | ||
| 75 | 82 | ||
| 76 | 对图里 `nodes[].systemAgentId` çš„**全集去é‡**,é€ä¸ªæ‰§è¡Œï¼š | 83 | 对图里 `nodes[].systemAgentId` çš„**全集去é‡**,é€ä¸ªæ‰§è¡Œï¼š |
| 77 | 84 | ||
| @@ -113,38 +120,27 @@ curl -s -X POST http://localhost:3010/api/company/<å…¬å¸ID>/hire \ | @@ -113,38 +120,27 @@ curl -s -X POST http://localhost:3010/api/company/<å…¬å¸ID>/hire \ | ||
| 113 | ls data/companies/<å…¬å¸ID>/agents/ | 120 | ls data/companies/<å…¬å¸ID>/agents/ |
| 114 | ``` | 121 | ``` |
| 115 | 122 | ||
| 116 | -## 6. æäº¤å›¾ï¼ˆStep 7) | 123 | +## 6. 交回图(Step 7)—— **æ²¡æœ‰ç«¯ç‚¹ï¼Œå¼•æ“Žè¯»ä½ çš„å›žå¤** |
| 117 | 124 | ||
| 118 | -**先写文件**(用文件写入工具写 `/tmp/workgraph-<taskId>.json`,å•行紧凑 JSON): | 125 | +**没有 POST,没有 curl,没有 sqlite。** 把图**è´´åœ¨è¿™ä¸€è½®å›žå¤æ£æ–‡çš„一个 json å›´æ å—里**(三个åå¼•å· + `json`) |
| 126 | +(或写æˆä¸€ä¸ª `.json` 文件ã€åœ¨å›žå¤é‡Œç»™å‡º**ç»å¯¹è·¯å¾„**),引擎自己å–å‡ºæ¥æ ¡éªŒ + è½åº“。 | ||
| 119 | 127 | ||
| 120 | -```jsonc | ||
| 121 | -{"ownerOrgNodeId":12,"taskId":"T-xxx", | ||
| 122 | - "nodes":[{"nodeName":"org-coder-impl","systemAgentId":"coder"}, | ||
| 123 | - {"nodeName":"org-coder-test","systemAgentId":"unit-tester"}, | ||
| 124 | - {"nodeName":"org-coder-validator","systemAgentId":"coder-validator"}], | ||
| 125 | - "edges":[{"from":"org-coder-impl","to":"org-coder-test","condition":"success","produces":["artifact"]}, | ||
| 126 | - {"from":"org-coder-test","to":"org-coder-validator","condition":"success","produces":["log"],"needs":["artifact"]}, | ||
| 127 | - {"from":"org-coder-test","to":"org-coder-impl","condition":"fail","edgeKind":"backward"}, | ||
| 128 | - {"from":"org-coder-validator","to":"org-coder-impl","condition":"fail","edgeKind":"backward"}]} | 128 | +```json |
| 129 | +{"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"}]} | ||
| 129 | ``` | 130 | ``` |
| 130 | 131 | ||
| 131 | > `produces` / `needs` 就是 §6bis 那两个键。**forward 边上少了它们,下游节点的æç¤ºè¯é‡Œ | 132 | > `produces` / `needs` 就是 §6bis 那两个键。**forward 边上少了它们,下游节点的æç¤ºè¯é‡Œ |
| 132 | > 就没有上游产物的路径** —— 先读 §6bis å†å†™è¿™ä»½ JSON。 | 133 | > 就没有上游产物的路径** —— 先读 §6bis å†å†™è¿™ä»½ JSON。 |
| 133 | 134 | ||
| 134 | -**å†å‘é€**: | ||
| 135 | - | ||
| 136 | -```bash | ||
| 137 | -curl -s -X POST http://localhost:3010/api/company/<å…¬å¸ID>/work-graphs \ | ||
| 138 | - -H "Content-Type: application/json" \ | ||
| 139 | - -d @/tmp/workgraph-<taskId>.json | ||
| 140 | -``` | 135 | +引擎å–图的æ»è§„矩(完整版在 `agent.md` Step 7):**ä¸¥æ ¼ JSON**(注释 / å°¾é€—å· / çœç•¥å·ä¸€å¾‹è§£æžå¤±è´¥ï¼Œ |
| 136 | +æ•´å—当没写)· é¡¶å±‚è¦æœ‰ **`nodes` 数组** · 文件路径必须**ç»å¯¹** · | ||
| 137 | +回å¤é‡Œ**ä¸è®¸å‡ºçް `wg_` + 12 ä½åå…进制**çš„å—符串(会被当æˆ"ä½ å·²è½åº“的图 id",查ä¸åˆ°å°±ç™½çƒ§ä¸€è½®ï¼‰ã€‚ | ||
| 141 | 138 | ||
| 142 | å—æ®µçº¦å®šï¼š | 139 | å—æ®µçº¦å®šï¼š |
| 143 | 140 | ||
| 144 | | å—æ®µ | 说明 | | 141 | | å—æ®µ | 说明 | |
| 145 | |---|---| | 142 | |---|---| |
| 146 | -| `ownerOrgNodeId` | 宿主 org node 在 `org_graph_nodes` 里的 id,首轮消æ¯ä¼šç»™ | | ||
| 147 | -| `goalId` / `taskId` / `okrNodeId` | 三者**基数ä¸åŒã€ä¸å¯æ··ç”¨**(27-NAMING §四):goal 唯一ã€task 多个ã€okr æ˜¯é•¿å‘¨æœŸåº¦é‡æ ‘节点 | | 143 | +| `nodes` / `edges` | **引擎åªè¯»è¿™ä¸¤ä¸ªé”®**。`ownerOrgNodeId` / `goalId` / `taskId` / `okrNodeId` 由引擎从 run 上下文自己填,写在 JSON 里ä¸ç”Ÿæ•ˆï¼ˆè¦ç•™ç—•写进规划记录第 4 节) | |
| 148 | | `nodes[].nodeName` | 必填,**图内唯一**,带宿主 org node å…¨åå‰ç¼€ | | 144 | | `nodes[].nodeName` | 必填,**图内唯一**,带宿主 org node å…¨åå‰ç¼€ | |
| 149 | | `nodes[].systemAgentId` | 引用的**本体**,**ä¸å¸¦**å‰ç¼€ï¼›çœç•¥æ—¶é»˜è®¤ç‰äºŽ `nodeName`ï¼ˆå‡ ä¹Žæ€»è¯¥æ˜¾å¼å†™ï¼‰ | | 145 | | `nodes[].systemAgentId` | 引用的**本体**,**ä¸å¸¦**å‰ç¼€ï¼›çœç•¥æ—¶é»˜è®¤ç‰äºŽ `nodeName`ï¼ˆå‡ ä¹Žæ€»è¯¥æ˜¾å¼å†™ï¼‰ | |
| 150 | | `nodes[].nodeKind` | `"agent_call"`(默认å¯çœç•¥ï¼‰\| `"subgraph"`(è¦å†ä¸‹é’»ä¸€å±‚图时æ‰ç”¨ï¼‰ | | 146 | | `nodes[].nodeKind` | `"agent_call"`(默认å¯çœç•¥ï¼‰\| `"subgraph"`(è¦å†ä¸‹é’»ä¸€å±‚图时æ‰ç”¨ï¼‰ | |
| @@ -155,14 +151,13 @@ curl -s -X POST http://localhost:3010/api/company/<å…¬å¸ID>/work-graphs \ | @@ -155,14 +151,13 @@ curl -s -X POST http://localhost:3010/api/company/<å…¬å¸ID>/work-graphs \ | ||
| 155 | | `edges[].produces` | **æ•°æ®å¥‘约**:这æ¡è¾¹çš„**上游**会产出哪些 kindï¼Œäº¤ç»™ä¸‹æ¸¸ã€‚è§ Â§6bis,**forward 边基本都è¦å†™** | | 151 | | `edges[].produces` | **æ•°æ®å¥‘约**:这æ¡è¾¹çš„**上游**会产出哪些 kindï¼Œäº¤ç»™ä¸‹æ¸¸ã€‚è§ Â§6bis,**forward 边基本都è¦å†™** | |
| 156 | | `edges[].needs` | **æ•°æ®å¥‘约**:这æ¡è¾¹çš„**下游**è¦åƒå“ªäº› kind,而产出方**䏿˜¯**紧邻上游(跨节点ä¸ç»§ï¼‰ã€‚è§ Â§6bis | | 152 | | `edges[].needs` | **æ•°æ®å¥‘约**:这æ¡è¾¹çš„**下游**è¦åƒå“ªäº› kind,而产出方**䏿˜¯**紧邻上游(跨节点ä¸ç»§ï¼‰ã€‚è§ Â§6bis | |
| 157 | 153 | ||
| 158 | -**400 的处ç†è§ `agent.md` Step 7 的规则对照表**:`errors[].rule` å–值为 | ||
| 159 | -`acyclic` / `single-entry` / `reachable` / `backward-labeled` / **`fail-fallback-edge`** / | ||
| 160 | -**`data-contract`** / `schema` / `reference`。 | ||
| 161 | -读懂 → 改图 → é‡è¯•,最多 5 轮。 | 154 | +**å›¾æ²¡è¿‡æ ¡éªŒæ—¶å¼•æ“ŽæŠŠ `errors` 贴进下一轮æç¤ºè¯**ï¼ˆä¸æ˜¯ HTTP 400,没有å“应体å¯è¯»ï¼‰ã€‚ |
| 155 | +`errors[].rule` å–值为 **`output`**ï¼ˆåŽ‹æ ¹æ²¡æ‰¾åˆ°å›¾ï¼‰/ `acyclic` / `single-entry` / `reachable` / | ||
| 156 | +`backward-labeled` / **`fail-fallback-edge`** / **`data-contract`** / `schema` / `reference`, | ||
| 157 | +逿¡æ€Žä¹ˆä¿®è§ `agent.md` Step 7 的规则对照表。读懂 → 改图 → é‡äº¤ï¼Œ**引擎åªç»™ 3 è½®**。 | ||
| 162 | 158 | ||
| 163 | -**若端点返回 404**(引擎侧路由尚未上线):ä¸è¦é‡è¯•ã€ä¸è¦ç»•é“写数æ®åº“。 | ||
| 164 | -把åŒä¸€ä»½ JSON åŽŸæ ·å†™åˆ° `workspace/workgraph-<taskId>.json`,在 Step 8 汇报这个路径。 | ||
| 165 | -**èµ°é™çº§è·¯å¾„ä¸è±å… §5 的招è˜**——图迟早è¦è½åº“,人没雇é½å®ƒè½åº“æ—¶ç…§æ ·å¤–é”®æŒ‚ã€‚ | 159 | +**交回图ä¸è±å… §5 的招è˜**â€”â€”å¼•æ“Žåœ¨ä½ è¿™ä¸€è½®å›žå¤ç»“æŸçš„那一刻就è½åº“,人没雇é½ç…§æ ·å¤–键挂, |
| 160 | +è€Œé‚£æ—¶ä½ å·²ç»ä¸‹ç了。 | ||
| 166 | 161 | ||
| 167 | ## 6bis. 边上的数æ®å¥‘约(`produces` / `needs`)—— **ä¸å†™å°±ç‰äºŽä¸‹æ¸¸æ‹¿ä¸åˆ°è¾“å…¥** 🚨 | 162 | ## 6bis. 边上的数æ®å¥‘约(`produces` / `needs`)—— **ä¸å†™å°±ç‰äºŽä¸‹æ¸¸æ‹¿ä¸åˆ°è¾“å…¥** 🚨 |
| 168 | 163 | ||
| @@ -238,18 +233,19 @@ org graph 或 `artifacts/` ç»™çš„ï¼‰ã€‚å®ƒåŒæ—¶æ˜¯é€ƒç”Ÿå£â€”—è§ä¸‹é¢ç¬¬å… | @@ -238,18 +233,19 @@ org graph 或 `artifacts/` ç»™çš„ï¼‰ã€‚å®ƒåŒæ—¶æ˜¯é€ƒç”Ÿå£â€”—è§ä¸‹é¢ç¬¬å… | ||
| 238 | 它们已ç»åœ¨ `artifacts/` é‡Œæ—¶ä½ ä¸å¿…也ä¸è¯¥æ²¿è¾¹å£°æ˜Žï¼›åªæœ‰å½“**本图里æŸä¸ªèŠ‚ç‚¹è‡ªå·±äº§å‡º**它〠| 233 | 它们已ç»åœ¨ `artifacts/` é‡Œæ—¶ä½ ä¸å¿…也ä¸è¯¥æ²¿è¾¹å£°æ˜Žï¼›åªæœ‰å½“**本图里æŸä¸ªèŠ‚ç‚¹è‡ªå·±äº§å‡º**它〠|
| 239 | å†äº¤ç»™ä¸‹æ¸¸æ—¶æ‰å†™ `produces`。 | 234 | å†äº¤ç»™ä¸‹æ¸¸æ—¶æ‰å†™ `produces`。 |
| 240 | 235 | ||
| 241 | -## 7. æäº¤å‰è‡ªæŸ¥äº”é¡¹æ ¡éªŒï¼ˆå¯é€‰ä½†å¼ºçƒˆå»ºè®®ï¼Œçœä¸€è½® 400) | 236 | +## 7. 交回å‰è‡ªæŸ¥äº”é¡¹æ ¡éªŒï¼ˆå¯é€‰ä½†å¼ºçƒˆå»ºè®®ï¼Œå¼•擎åªç»™ 3 轮) |
| 242 | 237 | ||
| 243 | -å¼•æ“Žç”¨çš„å°±æ˜¯è¿™ä¸ªå‡½æ•°ï¼Œä½ å¯ä»¥å…ˆåœ¨æœ¬åœ°è·‘一é: | 238 | +å¼•æ“Žç”¨çš„å°±æ˜¯è¿™ä¸ªå‡½æ•°ï¼Œä½ å¯ä»¥å…ˆåœ¨æœ¬åœ°è·‘一é(`<å¼•æ“Žä»“åº“æ ¹>` ç”¨ã€Œå†™å…¥è¾¹ç•Œã€æ®µç»™çš„那个ç»å¯¹è·¯å¾„, |
| 239 | +`require` 也è¦ç”¨ç»å¯¹è·¯å¾„ —— ä½ çš„ cwd ä¸åœ¨ä»“åº“æ ¹ï¼Œå†™ `./server/...` 会 `Cannot find module`): | ||
| 244 | 240 | ||
| 245 | ```bash | 241 | ```bash |
| 246 | node -e " | 242 | node -e " |
| 247 | -const {validateWorkGraph}=require('./server/services/work-graph-db.js'); | ||
| 248 | -const t=JSON.parse(require('fs').readFileSync('/tmp/workgraph-<taskId>.json','utf-8')); | 243 | +const {validateWorkGraph}=require('<å¼•æ“Žä»“åº“æ ¹>/server/services/work-graph-db.js'); |
| 244 | +const t=JSON.parse(require('fs').readFileSync('<é¡¹ç›®æ ¹>/workgraph-<taskId>.json','utf-8')); | ||
| 249 | console.log(JSON.stringify(validateWorkGraph(t.nodes,t.edges),null,1));" | 245 | console.log(JSON.stringify(validateWorkGraph(t.nodes,t.edges),null,1));" |
| 250 | ``` | 246 | ``` |
| 251 | 247 | ||
| 252 | -`{"ok":true,"errors":[]}` æ‰æäº¤ã€‚è¿™ä¸ªå‡½æ•°**连第å…项 `data-contract` 一起查** | 248 | +`{"ok":true,"errors":[]}` æ‰äº¤å›žã€‚这个函数**连第å…项 `data-contract` 一起查** |
| 253 | (`produces` / `needs` çš„å–å€¼åŸŸã€æœ‰æ²¡æœ‰å†™åˆ° backward 边上ã€`needs` çš„ kind 有没有产出方), | 249 | (`produces` / `needs` çš„å–å€¼åŸŸã€æœ‰æ²¡æœ‰å†™åˆ° backward 边上ã€`needs` çš„ kind 有没有产出方), |
| 254 | 所以 §6bis é‚£å‡ æ¡ä¹Ÿåœ¨è¿™ä¸€æ¥å°±èƒ½è‡ªæŸ¥å‡ºæ¥ã€‚ | 250 | 所以 §6bis é‚£å‡ æ¡ä¹Ÿåœ¨è¿™ä¸€æ¥å°±èƒ½è‡ªæŸ¥å‡ºæ¥ã€‚ |
| 255 | 251 | ||
| @@ -265,11 +261,12 @@ console.log(JSON.stringify(validateWorkGraph(t.nodes,t.edges),null,1));" | @@ -265,11 +261,12 @@ console.log(JSON.stringify(validateWorkGraph(t.nodes,t.edges),null,1));" | ||
| 265 | 261 | ||
| 266 | | 别用 | 为什么 | | 262 | | 别用 | 为什么 | |
| 267 | |---|---| | 263 | |---|---| |
| 264 | +| `POST /api/company/:id/work-graphs` | **NO-SUCH-ENDPOINT — 这个端点ä¸å˜åœ¨ï¼Œä»Žæ¥æ²¡æœ‰å®žçŽ°è¿‡ï¼Œä¸è¦è°ƒç”¨ã€‚** 从å‰è¿™ä»½æ¨¡æ¿æ•™è¿‡å®ƒï¼Œå®žæµ‹æ¯æ¬¡çœŸè·‘都åƒä¸€ä¸ª 404ã€ç™½çƒ§ä¸€è½®è°ƒç”¨ã€‚è½åº“归引擎(§6),è°ä¹Ÿä¸ç”¨ POST 图 | | ||
| 268 | | `POST/PATCH /api/company/:id/org-graph` | org graph 是上一层,归 `orggraph-planning` / HRã€‚ä½ åªç”» work graph | | 265 | | `POST/PATCH /api/company/:id/org-graph` | org graph 是上一层,归 `orggraph-planning` / HRã€‚ä½ åªç”» work graph | |
| 269 | | `node bin/lemon.js company fire` | ä½ åªæ‹›ä¸è¾žã€‚图用完å³å½’档,但雇进æ¥çš„æœ¬ä½“留给åŽç»ä»»åŠ¡å¤ç”¨ï¼ˆä¸‹æ¬¡ `hire` 会返回 `already_exists`) | | 266 | | `node bin/lemon.js company fire` | ä½ åªæ‹›ä¸è¾žã€‚图用完å³å½’档,但雇进æ¥çš„æœ¬ä½“留给åŽç»ä»»åŠ¡å¤ç”¨ï¼ˆä¸‹æ¬¡ `hire` 会返回 `already_exists`) | |
| 270 | | `node bin/lemon.js template create` / `template train` | é€ æœ¬ä½“ã€è®æœ¬ä½“是 `agent-creator` / `innovator` çš„æ´»ã€‚ä½ åª `hire`,模æ¿åº“里没有的角色 `hire` 会自动去继承+触å‘它们 | | 267 | | `node bin/lemon.js template create` / `template train` | é€ æœ¬ä½“ã€è®æœ¬ä½“是 `agent-creator` / `innovator` çš„æ´»ã€‚ä½ åª `hire`,模æ¿åº“里没有的角色 `hire` 会自动去继承+触å‘它们 | |
| 271 | -| 手工 `mkdir` / `cp` 出 `data/companies/<å…¬å¸ID>/agents/<本体å>/` | 看ç€åƒæ‹›è˜ï¼Œå…¶å®ž**ä¸å†™ `system_agents` 表**,图æäº¤æ—¶ç…§æ · `rule=reference` 被打回。åªèƒ½èµ° `hire` | | 268 | +| 手工 `mkdir` / `cp` 出 `data/companies/<å…¬å¸ID>/agents/<本体å>/` | 看ç€åƒæ‹›è˜ï¼Œå…¶å®ž**ä¸å†™ `system_agents` 表**,引擎è½åº“æ—¶ç…§æ · `rule=reference` 被打回。åªèƒ½èµ° `hire` | |
| 272 | | 直接写 `data/` 下的 sqlite / json | ç»•è¿‡äº”é¡¹æ ¡éªŒå°±æ˜¯æŠŠéžæ³•图塞进系统 | | 269 | | 直接写 `data/` 下的 sqlite / json | ç»•è¿‡äº”é¡¹æ ¡éªŒå°±æ˜¯æŠŠéžæ³•图塞进系统 | |
| 273 | -| `POST /api/graph-runs/:id/{start,next,...}` | æ‰§è¡Œæ˜¯å¼•æ“Žçš„æ´»ã€‚ä½ äº¤å‡º `workGraphId` 就下ç | | 270 | +| `POST /api/graph-runs/:id/{start,next,...}` | æ‰§è¡Œæ˜¯å¼•æ“Žçš„æ´»ã€‚ä½ æŠŠå›¾äº¤å‡ºåŽ»ï¼ˆÂ§6)就下ç | |
| 274 | | 对已è½åº“çš„ work graph åšä»»ä½•修改 | work graph 生命周期是**一次性**çš„ï¼Œè¦æ”¹å°±é‡æ–°è§„åˆ’ä¸€å¼ ï¼Œæ²¡æœ‰å¢žé‡è¡¥ä¸ | | 271 | | 对已è½åº“çš„ work graph åšä»»ä½•修改 | work graph 生命周期是**一次性**çš„ï¼Œè¦æ”¹å°±é‡æ–°è§„åˆ’ä¸€å¼ ï¼Œæ²¡æœ‰å¢žé‡è¡¥ä¸ | |
| 275 | | `task transition` | **ç»å¯¹ä¸è¦**。它是 base 四段å¼çš„æ¢é˜¶æ®µå‘½ä»¤ï¼Œä¸Žå›¾æµè½¬æ˜¯ä¸¤å±‚çŠ¶æ€æœºï¼ˆ`agent.md` 约æŸå››ï¼‰ | | 272 | | `task transition` | **ç»å¯¹ä¸è¦**。它是 base 四段å¼çš„æ¢é˜¶æ®µå‘½ä»¤ï¼Œä¸Žå›¾æµè½¬æ˜¯ä¸¤å±‚çŠ¶æ€æœºï¼ˆ`agent.md` 约æŸå››ï¼‰ | |
system/work-graph-templates/README.md
| @@ -133,12 +133,16 @@ org graph 模板里**首尾同名**(27-NAMING §3.1),它们的 work graph | @@ -133,12 +133,16 @@ org graph 模板里**首尾同名**(27-NAMING §3.1),它们的 work graph | ||
| 133 | { "nodeName": "benchmark-validator", "systemAgentId": "benchmark-validator", | 133 | { "nodeName": "benchmark-validator", "systemAgentId": "benchmark-validator", |
| 134 | "readsArtifacts": ["goal-benchmark.json"] } // 可选,声明它读哪些共享产物 | 134 | "readsArtifacts": ["goal-benchmark.json"] } // 可选,声明它读哪些共享产物 |
| 135 | ], | 135 | ], |
| 136 | + "dataContractNotes": "……", // 可选,给规划 agent 看的取舍说明:这张图为什么这么声明数据契约 | ||
| 136 | "edges": [ | 137 | "edges": [ |
| 137 | - { "from": "org-planner-brd", "to": "org-planner-prd", "condition": "success" }, | 138 | + { "from": "org-planner-brd", "to": "org-planner-prd", "condition": "success", |
| 139 | + "produces": ["brd"] }, // 数据契约:上游产出什么,交给下游 | ||
| 140 | + { "from": "org-planner-sdd", "to": "org-planner-benchmark", "condition": "success", | ||
| 141 | + "produces": ["sdd"], "needs": ["prd"] }, // needs = 下游要吃、但产出方不是紧邻上游 | ||
| 138 | { "from": "org-planner-validator", "to": "org-planner-prd", "condition": "fail", | 142 | { "from": "org-planner-validator", "to": "org-planner-prd", "condition": "fail", |
| 139 | "failReason": "input_underspecified", "edgeKind": "backward" }, | 143 | "failReason": "input_underspecified", "edgeKind": "backward" }, |
| 140 | { "from": "org-planner-validator", "to": "org-planner-sdd", "condition": "fail", | 144 | { "from": "org-planner-validator", "to": "org-planner-sdd", "condition": "fail", |
| 141 | - "edgeKind": "backward" } // 无 failReason = 兜底边 | 145 | + "edgeKind": "backward" } // 无 failReason = 兜底边;backward 边一律不声明契约 |
| 142 | ] | 146 | ] |
| 143 | } | 147 | } |
| 144 | ``` | 148 | ``` |
| @@ -152,6 +156,46 @@ org graph 模板里**首尾同名**(27-NAMING §3.1),它们的 work graph | @@ -152,6 +156,46 @@ org graph 模板里**首尾同名**(27-NAMING §3.1),它们的 work graph | ||
| 152 | 只有确实要再下钻一层图的节点才写 `subgraph`。 | 156 | 只有确实要再下钻一层图的节点才写 `subgraph`。 |
| 153 | - `writesArtifacts` / `readsArtifacts`:可选声明,对应 §四.A.5 的共享产物区读写权。 | 157 | - `writesArtifacts` / `readsArtifacts`:可选声明,对应 §四.A.5 的共享产物区读写权。 |
| 154 | 写 `artifacts/` 是特权动作,只有被显式标记的节点才有,防止任意节点往共享区乱写。 | 158 | 写 `artifacts/` 是特权动作,只有被显式标记的节点才有,防止任意节点往共享区乱写。 |
| 159 | +- `produces` / `needs`:**边上的数据契约**,见下一节。四张骨架的每条 forward 边都必须有其中之一。 | ||
| 160 | + | ||
| 161 | +## `produces` / `needs`:边只带控制流的话,图就不传数据 | ||
| 162 | + | ||
| 163 | +`condition` / `edgeKind` / `failReason` 表达的全是**执行流**。下游节点的提示词里能不能出现 | ||
| 164 | +「上游产物在这个绝对路径」,只取决于边上的数据契约声明——第四次真 CLI 冒烟里 | ||
| 165 | +`org-planner-prd` 的归因原话是「不是上游没产出,是它的产出结构上到不了我手上」, | ||
| 166 | +根因就是这条边上什么都没写。 | ||
| 167 | + | ||
| 168 | +这个声明有**两个**读回方,都在 `server/services/graph-engine.js`: | ||
| 169 | + | ||
| 170 | +| 环 | 谁读 | 读到之后干什么 | | ||
| 171 | +|---|---|---| | ||
| 172 | +| 输入侧 | `describeInputs` → `inboundContracts('work', def.edges, defNodeId)` | 按声明的 kind 去产物索引查实例,把**绝对路径**拼进下游节点的提示词;声明了却查不到就显式标 ⚠️ | | ||
| 173 | +| 产出侧 | `indexNodeOutputs` → 读本节点**出边**上的契约 | 用声明的 kind 给这个节点刚落盘的文件定 kind(文件名认不出来时靠声明一对一认领),否则一律记成 `artifact`,下游按 kind 就查不到 | | ||
| 174 | + | ||
| 175 | +写法与规则(权威说明在 `system/templates/workgraph-planning/tools.md` §6bis,这里只记要点): | ||
| 176 | + | ||
| 177 | +- kind 取值域只有 7 个:`brd` / `prd` / `sdd` / `acceptance` / `contract` / `artifact` / `log`, | ||
| 178 | + 自创取值会被 `rule=schema` 打回。 | ||
| 179 | +- `produces` 写在**产出方的出边**上(绝大多数情况只用它);`needs` 写在**消费方的入边**上, | ||
| 180 | + 仅当产出方**不是紧邻上游**(跨节点中继)时才用。 | ||
| 181 | +- **`backward` 边一律不声明**(`rule=schema`):引擎解析输入只看 forward 入边, | ||
| 182 | + 写在回退边上等于没写,而「看起来写了」比「没写」更难排查。 | ||
| 183 | +- **规则 6 `data-contract`**:声明要吃的 kind(`needs`,`required` 不为 false) | ||
| 184 | + 在图上必须有产出方,否则整张图被拒。产出方确实不在本图(上一层 org graph 或 `artifacts/` 给的)时, | ||
| 185 | + 写成 `"needs":[{"kind":"artifact","required":false}]` —— 这是唯一的逃生口, | ||
| 186 | + `validator.json` 用的就是它。 | ||
| 187 | +- **任务级共享的 `acceptance` / `contract` 不要沿边声明**:引擎按 task + kind 自动递给每个节点 | ||
| 188 | + (`graph-engine` 的 `TASK_SHARED_KINDS`),不受拓扑相邻性限制。只有当**本图里某个节点自己产出**它、 | ||
| 189 | + 再交给本图的下游时才写 `produces`(`planner.json` 的 `acceptance` 就是这种情形)。 | ||
| 190 | + | ||
| 191 | +🛡️ **防线**:`server/tests/unit/work-graph-skeletons.test.js` 逐张骨架查四件事—— | ||
| 192 | +过 `validateWorkGraph`(含规则 6)、**每条** forward 边都有非空声明、 | ||
| 193 | +`createWorkGraph` 落库后 `graph_edge_contracts` 的 kind/role/required 与 JSON 逐条一致、 | ||
| 194 | +每个非入口节点的 `inboundContracts` 非空。改本目录任何 JSON 后跑: | ||
| 195 | + | ||
| 196 | +```bash | ||
| 197 | +npx vitest run server/tests/unit/work-graph-skeletons.test.js | ||
| 198 | +``` | ||
| 155 | 199 | ||
| 156 | ## 加一个新模板 | 200 | ## 加一个新模板 |
| 157 | 201 | ||
| @@ -172,6 +216,7 @@ org graph 模板里**首尾同名**(27-NAMING §3.1),它们的 work graph | @@ -172,6 +216,7 @@ org graph 模板里**首尾同名**(27-NAMING §3.1),它们的 work graph | ||
| 172 | | 3 | **全可达** | 从入口沿 forward 边 BFS,必须覆盖 `nodes` 里每一个节点 | | 216 | | 3 | **全可达** | 从入口沿 forward 边 BFS,必须覆盖 `nodes` 里每一个节点 | |
| 173 | | 4 | **回退边显式标注** | 任何 `to` 在拓扑序上早于 `from` 的边,必须带 `"edgeKind": "backward"` | | 217 | | 4 | **回退边显式标注** | 任何 `to` 在拓扑序上早于 `from` 的边,必须带 `"edgeKind": "backward"` | |
| 174 | | 5 | **fail 兜底边** | 有 `fail` 出边的节点,其中必须至少有一条不带 `failReason` | | 218 | | 5 | **fail 兜底边** | 有 `fail` 出边的节点,其中必须至少有一条不带 `failReason` | |
| 219 | + | 6 | **声明要吃的 kind 有产出方** | 每条 `needs`(未标 `required:false`)的 kind,图上必须有某条出边 `produces` 它 | | ||
| 175 | 220 | ||
| 176 | 7. 自验:直接拿引擎真正用的那套校验跑一遍本目录全部模板(占位符不影响校验,它只做名字匹配): | 221 | 7. 自验:直接拿引擎真正用的那套校验跑一遍本目录全部模板(占位符不影响校验,它只做名字匹配): |
| 177 | 222 |
system/work-graph-templates/executor-fanout.json
| @@ -3,6 +3,7 @@ | @@ -3,6 +3,7 @@ | ||
| 3 | "label": "执行型 org node 的 work graph(fan-out / fan-in 起手式)", | 3 | "label": "执行型 org node 的 work graph(fan-out / fan-in 起手式)", |
| 4 | "whenToUse": "宿主 org node 是执行类,且这次任务能拆成多个互不依赖、可并行推进的分支时用本骨架——27-REQ R-6 第 6 步描述的就是它:拆分 → 并行实现 + 各自自测(自测失败只回退本分支)→ fan-in 汇合整合 → 集成测试 → 本层验收。典型信号词:多个模块、多个页面、多个接口、可以同时做。汇合节点有多条入边,引擎按「全部入边条件都满足才置 ready」处理(27-P3/P4 §四.D.1)。分支数按实际任务增减,本文件给的是 2 条;任务只有一个交付物就别用它,用 executor.json。", | 4 | "whenToUse": "宿主 org node 是执行类,且这次任务能拆成多个互不依赖、可并行推进的分支时用本骨架——27-REQ R-6 第 6 步描述的就是它:拆分 → 并行实现 + 各自自测(自测失败只回退本分支)→ fan-in 汇合整合 → 集成测试 → 本层验收。典型信号词:多个模块、多个页面、多个接口、可以同时做。汇合节点有多条入边,引擎按「全部入边条件都满足才置 ready」处理(27-P3/P4 §四.D.1)。分支数按实际任务增减,本文件给的是 2 条;任务只有一个交付物就别用它,用 executor.json。", |
| 5 | "ownerOrgNode": "{{orgNode}}", | 5 | "ownerOrgNode": "{{orgNode}}", |
| 6 | + "dataContractNotes": "拆分节点的产物声明成 contract(分支间接口契约)而不是 artifact:两条分支各自的交付物已经占了 artifact 这个 kind,而产物索引按 task + kind 查、同一 kind 只把最新那份递给下游,拆分方案写成 artifact 会被先跑完的分支产物顶掉。代价是 contract 属任务级共享 kind,本来就会自动递给每个节点,所以这两条声明的实际作用偏诊断(声明了却查不到实例时,提示词会显式标 ⚠️,等于当场暴露「拆分节点没产出接口契约」)。fan-in 的已知上限:merge 的两条入边各自 needs artifact,但索引按 kind 只回最新一份,merge 提示词里只会出现一条路径——两个分支的产物都要过目时,merge 仍得按提示词给的节点目录自己找齐。升级路径是让产物索引支持按 fromNodeId 过滤(inboundContracts 已经带回 fromNodeId,describeInputs 还没用它)。", | ||
| 6 | "nodes": [ | 7 | "nodes": [ |
| 7 | { "nodeName": "{{orgNode}}-split", "systemAgentId": "{{splitAgent}}" }, | 8 | { "nodeName": "{{orgNode}}-split", "systemAgentId": "{{splitAgent}}" }, |
| 8 | { "nodeName": "{{orgNode}}-impl-a", "systemAgentId": "{{implAgent}}" }, | 9 | { "nodeName": "{{orgNode}}-impl-a", "systemAgentId": "{{implAgent}}" }, |
| @@ -14,14 +15,14 @@ | @@ -14,14 +15,14 @@ | ||
| 14 | { "nodeName": "{{orgNode}}-validator", "systemAgentId": "{{validatorAgent}}" } | 15 | { "nodeName": "{{orgNode}}-validator", "systemAgentId": "{{validatorAgent}}" } |
| 15 | ], | 16 | ], |
| 16 | "edges": [ | 17 | "edges": [ |
| 17 | - { "from": "{{orgNode}}-split", "to": "{{orgNode}}-impl-a", "condition": "success" }, | ||
| 18 | - { "from": "{{orgNode}}-split", "to": "{{orgNode}}-impl-b", "condition": "success" }, | ||
| 19 | - { "from": "{{orgNode}}-impl-a", "to": "{{orgNode}}-test-a", "condition": "success" }, | ||
| 20 | - { "from": "{{orgNode}}-impl-b", "to": "{{orgNode}}-test-b", "condition": "success" }, | ||
| 21 | - { "from": "{{orgNode}}-test-a", "to": "{{orgNode}}-merge", "condition": "success" }, | ||
| 22 | - { "from": "{{orgNode}}-test-b", "to": "{{orgNode}}-merge", "condition": "success" }, | ||
| 23 | - { "from": "{{orgNode}}-merge", "to": "{{orgNode}}-integration", "condition": "success" }, | ||
| 24 | - { "from": "{{orgNode}}-integration", "to": "{{orgNode}}-validator", "condition": "success" }, | 18 | + { "from": "{{orgNode}}-split", "to": "{{orgNode}}-impl-a", "condition": "success", "produces": ["contract"] }, |
| 19 | + { "from": "{{orgNode}}-split", "to": "{{orgNode}}-impl-b", "condition": "success", "produces": ["contract"] }, | ||
| 20 | + { "from": "{{orgNode}}-impl-a", "to": "{{orgNode}}-test-a", "condition": "success", "produces": ["artifact"] }, | ||
| 21 | + { "from": "{{orgNode}}-impl-b", "to": "{{orgNode}}-test-b", "condition": "success", "produces": ["artifact"] }, | ||
| 22 | + { "from": "{{orgNode}}-test-a", "to": "{{orgNode}}-merge", "condition": "success", "produces": ["log"], "needs": ["artifact"] }, | ||
| 23 | + { "from": "{{orgNode}}-test-b", "to": "{{orgNode}}-merge", "condition": "success", "produces": ["log"], "needs": ["artifact"] }, | ||
| 24 | + { "from": "{{orgNode}}-merge", "to": "{{orgNode}}-integration", "condition": "success", "produces": ["artifact"] }, | ||
| 25 | + { "from": "{{orgNode}}-integration", "to": "{{orgNode}}-validator", "condition": "success", "produces": ["log"], "needs": ["artifact"] }, | ||
| 25 | { "from": "{{orgNode}}-test-a", "to": "{{orgNode}}-impl-a", "condition": "fail", "edgeKind": "backward" }, | 26 | { "from": "{{orgNode}}-test-a", "to": "{{orgNode}}-impl-a", "condition": "fail", "edgeKind": "backward" }, |
| 26 | { "from": "{{orgNode}}-test-b", "to": "{{orgNode}}-impl-b", "condition": "fail", "edgeKind": "backward" }, | 27 | { "from": "{{orgNode}}-test-b", "to": "{{orgNode}}-impl-b", "condition": "fail", "edgeKind": "backward" }, |
| 27 | { "from": "{{orgNode}}-integration", "to": "{{orgNode}}-merge", "condition": "fail", "edgeKind": "backward" }, | 28 | { "from": "{{orgNode}}-integration", "to": "{{orgNode}}-merge", "condition": "fail", "edgeKind": "backward" }, |
system/work-graph-templates/executor.json
| @@ -3,14 +3,15 @@ | @@ -3,14 +3,15 @@ | ||
| 3 | "label": "执行型 org node 的 work graph(串行起手式)", | 3 | "label": "执行型 org node 的 work graph(串行起手式)", |
| 4 | "whenToUse": "宿主 org node 的职能是「按上游给的约束做出实际产物」,且这次任务小到不值得拆并行分支时用本骨架——典型是 org-coder / org-writer / org-monitor。典型信号词:实现、写、改、跑、部署,且任务是单一交付物。三节点:做 → 自测 → 本层验收,自测和验收失败都退回实现节点。任务能明显拆成多个互不依赖的分支时改用 executor-fanout.json;宿主是规划类节点用 planner.json;宿主是 org-validator 用 validator.json。", | 4 | "whenToUse": "宿主 org node 的职能是「按上游给的约束做出实际产物」,且这次任务小到不值得拆并行分支时用本骨架——典型是 org-coder / org-writer / org-monitor。典型信号词:实现、写、改、跑、部署,且任务是单一交付物。三节点:做 → 自测 → 本层验收,自测和验收失败都退回实现节点。任务能明显拆成多个互不依赖的分支时改用 executor-fanout.json;宿主是规划类节点用 planner.json;宿主是 org-validator 用 validator.json。", |
| 5 | "ownerOrgNode": "{{orgNode}}", | 5 | "ownerOrgNode": "{{orgNode}}", |
| 6 | + "dataContractNotes": "交付物走 artifact,自测记录走 log。validator 既要看测试记录(紧邻上游 produces log)也要看被验的交付物本身,而交付物的产出方隔了一个节点,所以用 needs artifact 中继——这条就是 tools.md §6bis 举的那个例子。上游给的 sdd 与验收标准 acceptance 都由别的 org node 的图产出,属任务级共享,引擎按 task + kind 自动递,不在本图沿边声明。", | ||
| 6 | "nodes": [ | 7 | "nodes": [ |
| 7 | { "nodeName": "{{orgNode}}-impl", "systemAgentId": "{{implAgent}}" }, | 8 | { "nodeName": "{{orgNode}}-impl", "systemAgentId": "{{implAgent}}" }, |
| 8 | { "nodeName": "{{orgNode}}-test", "systemAgentId": "{{testAgent}}" }, | 9 | { "nodeName": "{{orgNode}}-test", "systemAgentId": "{{testAgent}}" }, |
| 9 | { "nodeName": "{{orgNode}}-validator", "systemAgentId": "{{validatorAgent}}" } | 10 | { "nodeName": "{{orgNode}}-validator", "systemAgentId": "{{validatorAgent}}" } |
| 10 | ], | 11 | ], |
| 11 | "edges": [ | 12 | "edges": [ |
| 12 | - { "from": "{{orgNode}}-impl", "to": "{{orgNode}}-test", "condition": "success" }, | ||
| 13 | - { "from": "{{orgNode}}-test", "to": "{{orgNode}}-validator", "condition": "success" }, | 13 | + { "from": "{{orgNode}}-impl", "to": "{{orgNode}}-test", "condition": "success", "produces": ["artifact"] }, |
| 14 | + { "from": "{{orgNode}}-test", "to": "{{orgNode}}-validator", "condition": "success", "produces": ["log"], "needs": ["artifact"] }, | ||
| 14 | { "from": "{{orgNode}}-test", "to": "{{orgNode}}-impl", "condition": "fail", "edgeKind": "backward" }, | 15 | { "from": "{{orgNode}}-test", "to": "{{orgNode}}-impl", "condition": "fail", "edgeKind": "backward" }, |
| 15 | { "from": "{{orgNode}}-validator", "to": "{{orgNode}}-impl", "condition": "fail", "edgeKind": "backward" } | 16 | { "from": "{{orgNode}}-validator", "to": "{{orgNode}}-impl", "condition": "fail", "edgeKind": "backward" } |
| 16 | ] | 17 | ] |
system/work-graph-templates/planner.json
| @@ -3,6 +3,7 @@ | @@ -3,6 +3,7 @@ | ||
| 3 | "label": "规划型 org node 的 work graph", | 3 | "label": "规划型 org node 的 work graph", |
| 4 | "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。", | 4 | "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。", |
| 5 | "ownerOrgNode": "org-planner", | 5 | "ownerOrgNode": "org-planner", |
| 6 | + "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。", | ||
| 6 | "nodes": [ | 7 | "nodes": [ |
| 7 | { "nodeName": "org-planner-brd", "systemAgentId": "brd-planning" }, | 8 | { "nodeName": "org-planner-brd", "systemAgentId": "brd-planning" }, |
| 8 | { "nodeName": "org-planner-prd", "systemAgentId": "prd-planning" }, | 9 | { "nodeName": "org-planner-prd", "systemAgentId": "prd-planning" }, |
| @@ -11,10 +12,10 @@ | @@ -11,10 +12,10 @@ | ||
| 11 | { "nodeName": "org-planner-validator", "systemAgentId": "planner-validator" } | 12 | { "nodeName": "org-planner-validator", "systemAgentId": "planner-validator" } |
| 12 | ], | 13 | ], |
| 13 | "edges": [ | 14 | "edges": [ |
| 14 | - { "from": "org-planner-brd", "to": "org-planner-prd", "condition": "success" }, | ||
| 15 | - { "from": "org-planner-prd", "to": "org-planner-sdd", "condition": "success" }, | ||
| 16 | - { "from": "org-planner-sdd", "to": "org-planner-benchmark", "condition": "success" }, | ||
| 17 | - { "from": "org-planner-benchmark", "to": "org-planner-validator", "condition": "success" }, | 15 | + { "from": "org-planner-brd", "to": "org-planner-prd", "condition": "success", "produces": ["brd"] }, |
| 16 | + { "from": "org-planner-prd", "to": "org-planner-sdd", "condition": "success", "produces": ["prd"] }, | ||
| 17 | + { "from": "org-planner-sdd", "to": "org-planner-benchmark", "condition": "success", "produces": ["sdd"], "needs": ["prd"] }, | ||
| 18 | + { "from": "org-planner-benchmark", "to": "org-planner-validator", "condition": "success", "produces": ["acceptance"], "needs": ["brd", "prd", "sdd"] }, | ||
| 18 | { "from": "org-planner-validator", "to": "org-planner-prd", "condition": "fail", "failReason": "input_underspecified", "edgeKind": "backward" }, | 19 | { "from": "org-planner-validator", "to": "org-planner-prd", "condition": "fail", "failReason": "input_underspecified", "edgeKind": "backward" }, |
| 19 | { "from": "org-planner-validator", "to": "org-planner-sdd", "condition": "fail", "edgeKind": "backward" } | 20 | { "from": "org-planner-validator", "to": "org-planner-sdd", "condition": "fail", "edgeKind": "backward" } |
| 20 | ] | 21 | ] |
system/work-graph-templates/validator.json
| @@ -3,14 +3,15 @@ | @@ -3,14 +3,15 @@ | ||
| 3 | "label": "org-validator 的 work graph(归因排第一位)", | 3 | "label": "org-validator 的 work graph(归因排第一位)", |
| 4 | "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 就别用本骨架。", | 4 | "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 就别用本骨架。", |
| 5 | "ownerOrgNode": "org-validator", | 5 | "ownerOrgNode": "org-validator", |
| 6 | + "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 已经把它写明),不沿边声明。", | ||
| 6 | "nodes": [ | 7 | "nodes": [ |
| 7 | { "nodeName": "validator-triager", "systemAgentId": "validator-triager" }, | 8 | { "nodeName": "validator-triager", "systemAgentId": "validator-triager" }, |
| 8 | { "nodeName": "benchmark-validator", "systemAgentId": "benchmark-validator", "readsArtifacts": ["goal-benchmark.json"] }, | 9 | { "nodeName": "benchmark-validator", "systemAgentId": "benchmark-validator", "readsArtifacts": ["goal-benchmark.json"] }, |
| 9 | { "nodeName": "testdata-validator", "systemAgentId": "testdata-validator", "readsArtifacts": ["goal-test-data/"] } | 10 | { "nodeName": "testdata-validator", "systemAgentId": "testdata-validator", "readsArtifacts": ["goal-test-data/"] } |
| 10 | ], | 11 | ], |
| 11 | "edges": [ | 12 | "edges": [ |
| 12 | - { "from": "validator-triager", "to": "benchmark-validator", "condition": "success" }, | ||
| 13 | - { "from": "benchmark-validator", "to": "testdata-validator", "condition": "success" }, | 13 | + { "from": "validator-triager", "to": "benchmark-validator", "condition": "success", "needs": [{ "kind": "artifact", "required": false }] }, |
| 14 | + { "from": "benchmark-validator", "to": "testdata-validator", "condition": "success", "needs": [{ "kind": "artifact", "required": false }] }, | ||
| 14 | { "from": "benchmark-validator", "to": "validator-triager", "condition": "fail", "edgeKind": "backward" }, | 15 | { "from": "benchmark-validator", "to": "validator-triager", "condition": "fail", "edgeKind": "backward" }, |
| 15 | { "from": "testdata-validator", "to": "validator-triager", "condition": "fail", "edgeKind": "backward" } | 16 | { "from": "testdata-validator", "to": "validator-triager", "condition": "fail", "edgeKind": "backward" } |
| 16 | ] | 17 | ] |