From 925d9db70d56fcb83a6a692bc5e839a85599fb8f Mon Sep 17 00:00:00 2001 From: yibo Date: Mon, 17 Aug 2026 17:18:35 +0800 Subject: [PATCH] feat(p4): R-19 产物索引——补上「同图内上游到下游没有数据通路」这个断点 --- docs/todo/system-design/27-p4-parallel-and-artifacts.md | 137 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------------- docs/todo/system-design/27-requirement.md | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ server/services/graph-engine.js | 166 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ server/services/work-graph-db.js | 180 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 519 insertions(+), 33 deletions(-) diff --git a/docs/todo/system-design/27-p4-parallel-and-artifacts.md b/docs/todo/system-design/27-p4-parallel-and-artifacts.md index aaa7351..b934ce8 100644 --- a/docs/todo/system-design/27-p4-parallel-and-artifacts.md +++ b/docs/todo/system-design/27-p4-parallel-and-artifacts.md @@ -199,50 +199,121 @@ DROP INDEX IF EXISTS idx_graph_runs_one_active_root; **为什么从「索引强制」改为「计数检查」**:唯一索引只能表达「≤1」,表达不了「≤3」。计数检查要放在**同一个事务**里(先 `SELECT COUNT(*)` 再 `INSERT`),否则并发建 run 会超额。 -### 4.6 产物统一目录 + `files.json` +### 4.6 产物统一目录 + 产物索引表(R-19 定案后重写) -**目录约定**(用户要求的第 3 项,P3 未实现): +> **v0.2 重写**:本节原设计是「公司根放一个 `files.json`」。[27-REQ](./27-requirement.md) R-19 +> 改为**建 sqlite 表**,并把「边即输入输出」这层显式化。原设计的目录约定保留。 + +#### 为什么这一节从「优化项」变成「阻塞项」 + +第四次真 CLI 冒烟(自动模式)跑出的断点:`org-planner-prd` 失败,它自己的归因说得很准 —— +**「不是上游没产出,是它的产出结构上到不了我手上」**。核实:上游 `brd` 的 `brd.md` / +`contract.json` 落在**它自己的节点私有目录**,共享区 `artifacts/` 只有 +`project-root.json` 与 `task-goal.json`,而 `brd --success--> prd` 这条边 +**不携带任何文件引用**。 + +同一张 work graph 内部,上游到下游**没有任何数据通路**。这不是引擎 bug —— +边一直只承载控制流(`condition` / `edge_kind` / `fail_reason`), +而 [27-P3/P4](./27-p3-p4-work-graph-console.md) §四.B.3 把 R-4 要的「边的输入输出」 +落成了**从 `output_ref` 推断**的 UI 展示,没有实体。推断的那一端是私有目录,下游读不到。 + +**结论:不通这条路,任何多节点的图都只能跑完第一个节点。** 它不依赖并行,应从 P4 里 +单独拆出来先做。 + +#### 目录约定(沿用原设计) ``` data/companies/{companyId}/ - files.json ← 统一检索索引(本节重点) tasks/{taskId}/ - brd.md org-planner 的 work graph 里 brd-planning 节点的产出 - prd.md - sdd.md - acceptance.json 验收契约(goal-contract.js 已有,路径归并到这里) - {nodeName}/ 节点私有的中间产物(attempt-{n}.json 等) + brd.md · prd.md · sdd.md + acceptance.json 验收契约(goal-contract.js 的路径归并到这里) + {nodeName}/ 节点私有的中间产物(attempt-{n}.json 等) ``` -**为什么按 task 分目录而不是平铺在公司根目录**:同一家公司会跑很多个 task,平铺的话第二个 task 的 `prd.md` 会覆盖第一个。按 task 分目录既避免覆盖,也保留历史。 - -**`files.json` 的形态**: - -```jsonc -{ - "version": "1", - "companyId": "auto-illust", - "files": [ - { - "path": "tasks/T-abc/prd.md", // 相对 data/companies/{cid}/ - "kind": "prd", // brd | prd | sdd | acceptance | artifact | log - "taskId": "T-abc", - "producedBy": { // 谁产出的 - "runId": "gr_xxx", "nodeName": "org-planner-prd", "systemAgentId": "prd-planning" - }, - "consumedBy": ["org-coder", "org-validator"], // 声明式:谁应该读它 - "createdAt": "2026-08-16T...", - "supersedes": "tasks/T-abc/prd.md@rev1" // 同名产物的版本链,可空 - } - ] -} +**按 task 分目录而不是平铺**:同一家公司会跑很多 task,平铺的话第二个 task 的 `prd.md` +会覆盖第一个。按 task 分既避免覆盖,也保留历史。 + +**不走 `workspace/`**(R-19.3 定案):`workspace/` 的现有语义是 +[06-沙盒自治](../../done/system-design/06-sandbox-autonomy.md) 定的 +「**Agent 自治领地,框架不干预**」。产物索引要的是「**框架管着的、可检索的**交付物区」, +两种所有权混在一个目录里,索引就无法保证不漂移。 + +**根路径用代码推导,不入库**(R-19.3 定案):库里只存**相对公司根**的路径。 +理由是库里存绝对路径会烂 —— W13 清掉的那 10 条 `legacy_path` 悬空行就是这个病 +(指向早已删除的目录)。推导方式与现有 `runRootDir` / `nodeArtifactDir` 一致, +需要覆盖时给环境变量(测试隔离已在用 `GRAPH_RUNS_DIR` 这招)。 +另:**库里没有 `companies` 表**,公司是 `company.json` + 目录扫描,所以也没有 +「company 表的定义」这个落点。 + +#### 两类数据流,都要(R-19.3 定案①) + +纯「文件挂在边上」有硬伤:边是 `(from, to)` 二元关系,只能表达**相邻**节点间的传递。 +而 R-9.3 定的「验收产物由 planner 生成、由 validator 执行」是**非相邻**依赖 —— +org graph 是 `org-planner → org-coder → org-validator`,`acceptance.json` +从 planner 流到 validator,**跳过 coder,这两个节点之间没有边**。 + +| | 沿边传递 | 任务级共享 | +|---|---|---| +| 例 | `brd.md` → 紧邻的 prd 节点 | `acceptance.json` → 任意下游 validator | +| 绑定方式 | 边上声明契约(`kind`) | 挂在 task 上,按 `kind` 检索 | +| 受拓扑相邻性限制 | 是 | **否** | + +#### 契约与实例分两阶段(R-19.4) + +`workgraph-planning` 画图时**不可能知道文件名**(节点还没跑),但它能声明 +「这条边传一份 `brd`」。所以: + +- **规划时**:边上声明**契约**(`kind`),落 `graph_edge_contracts` +- **运行时**:节点产出落盘后,引擎记**实例**(`rel_path`),落 `graph_files` + +由此得到一个原设计没有的收益:**引擎能在节点跑之前校验「你声明要吃 brd,但上游没有任何 +节点产 brd」**。这类图结构错误现在完全查不出来,只能等跑到那一步 agent 说「我拿不到输入」 +(第四次真跑正是这么发现的)。 + +#### 表结构 + +```sql +-- 产物实例:运行时由**引擎**追加。不靠 agent 自觉写,否则索引必然与实际文件漂移 +-- (本轮已被验证过多次:凡「靠 agent 自觉」的环节几乎全部落空)。 +CREATE TABLE IF NOT EXISTS graph_files ( + id TEXT PRIMARY KEY, -- gf_ + company_id TEXT NOT NULL, + task_id TEXT, -- 任务级共享产物按它检索 + rel_path TEXT NOT NULL, -- **相对公司根**,根由代码推导 + kind TEXT NOT NULL, -- brd|prd|sdd|acceptance|contract|artifact|log + produced_by_run_id TEXT, + produced_by_node_run_id TEXT, -- 与 graph_run_nodes.output_ref 同源 + created_at TEXT NOT NULL, + superseded_by TEXT -- 同名产物的版本链,可空 +); + +-- 边上声明的数据契约:规划时写,只有 kind 没有实例。 +-- edge_layer 区分两层边表 —— org_graph_edges.id 是 INTEGER、work_graph_edges.id 是 TEXT, +-- 与 graph_run_nodes.def_node_id 同一处类型不整齐(见 27-P3/P4 §四.A.2 的注)。 +CREATE TABLE IF NOT EXISTS graph_edge_contracts ( + id TEXT PRIMARY KEY, + edge_layer TEXT NOT NULL CHECK (edge_layer IN ('org','work')), + edge_id TEXT NOT NULL, + kind TEXT NOT NULL, + required INTEGER NOT NULL DEFAULT 1, -- 0 = 可选输入,缺了不算错 + created_at TEXT NOT NULL +); ``` -**谁写**:引擎在节点产出落盘后**自动追加**一条——不能靠 agent 自觉写,否则索引必然和实际文件漂移。这与 [27-P3/P4] 里 `output_ref` 的写入时机相同,是同一处扩展。 +#### 引擎怎么用 + +**产出侧**:节点结算时,除了写 `output_ref`(节点级指针,不变), +再按落盘的文件往 `graph_files` 追加实例。两者关系与原设计一致 —— +`output_ref` 回答「我这次跑出了什么」,`graph_files` 回答「这家公司有哪些产物、谁能用」, +后者由前者派生,所以要有 **rebuild** 能力(索引损坏时从节点行重建,类比 `_rebuildIndex()`)。 -**谁读**:所有 node agent。在 prompt 里注入一句「本公司的产物索引在 `files.json`,按 `kind`/`taskId` 检索你需要的输入」,取代现在靠约定路径硬拼的方式。 +**输入侧**(这是补上断点的关键):节点执行前,引擎按 +「该节点**入边**声明的 `kind`」+「本 task 的共享产物」去 `graph_files` 查实例, +把**真实路径**拼进提示词。 -**与 `output_ref` 的关系**:`output_ref` 是**节点级**的产物指针("我这次跑出了什么"),`files.json` 是**公司级**的检索索引("这家公司有哪些产物、谁能用")。两者不重复:前者一对一挂在 `graph_run_nodes` 行上,后者是聚合视图。**`files.json` 由 `output_ref` 派生**,所以需要一个 rebuild 能力(索引损坏时可从节点行重建),类比 `_index.json` 的 `_rebuildIndex()`。 +> 这比原设计的「注入一句『索引在 files.json,按 kind/taskId 自己检索』」更可靠: +> 后者仍然依赖 agent 自觉去查、自觉拼对路径,而第四次真跑证明 agent 拼不出来 —— +> **引擎直接把输入递到它手上**,不给它猜的机会。 --- diff --git a/docs/todo/system-design/27-requirement.md b/docs/todo/system-design/27-requirement.md index d425172..0165adc 100644 --- a/docs/todo/system-design/27-requirement.md +++ b/docs/todo/system-design/27-requirement.md @@ -425,6 +425,71 @@ R-16 正文中的原话引用**保留 `validator-reflection` 的用词不改**ï¼ --- +## R-19 · 产物统一路径与「边即输入输出」 + +起于第四次真跑:自动模式下 `org-planner-prd` 失败,它自己的归因写得很准 —— +「不是上游没产出,是它的产出**结构上到不了我手上**」。上游 `brd.md` / `contract.json` +落在**节点私有目录**,而 `prd` 的提示词只给了它自己的私有目录 + 只读 `artifacts/`, +那条 `brd --success--> prd` 的边**不携带任何文件引用**。同一张 work graph 内部 +上游到下游没有数据通路 —— 这不是引擎 bug,是这条路从来没建。 + +### R-19.1 · 统一产物路径 + 索引(原话) + +> 我记得之前说过这个,要所有的 agent 把自己的产出都放在 data/company/company-id/workspace 下,你可以约束一下所有的 agent subagent graph agent,统一一下输出和输入的共享路径。这个路径下有一个 json 文件描述节点,边(输入,输出文件),时间,作为所有文件的索引。 + +**核实结果**:这条在 [27-REQ](./27-requirement.md) 里**没有记录**(本页此前 0 处提 workspace), +但设计确实存在 —— [27-P4](./27-p4-parallel-and-artifacts.md) §4.6「产物统一目录 + `files.json`」, +文中自己标着「用户要求的第 3 项,**P3 未实现**」。所以是**设计在、需求源头漏记、实现未做**。 + +### R-19.2 · 建表而不是 JSON 文件(原话) + +> 所以我们应该在 sqlite 库里增加一个表 files ,再增加 edges-files 对应关系表,关联每一个边和每一个文件的对应关系,边代表着节点的输入输出,这样节点就能找到输入的文件都在哪里,输出的文件存哪里,真实存储目录则有全局的 workspace 指定,可以存在 company 表的定义里 + +**改掉 §4.6 的 `files.json`,改为建表。** 理由不是抽象的:本轮刚被 `_registry.json` 咬过 —— +它既是人工维护清单又被 `_updateTemplateUsedCount()` 每次招聘原地改写,任何一次真跑都产生 +git diff,最后 W13 只能把计数挪去另一个被 ignore 的文件。`files.json` 会是同一形状 +(并发追加、整文件重写、和 git 打架)。表没有这些问题,还带外键与可查询性。 + +### R-19.3 · 三处定案 + +> 两类都要,workspace 根用代码推导,路径走 tasks/{taskId}/ + +**① 两类数据流都要。** 纯 `edges_files` 有硬伤:边是 `(from, to)` 二元关系,只能表达 +**相邻**节点间的传递,而 R-9.3 定的「验收产物由 planner 生成、由 validator 执行」是 +**非相邻**依赖 —— org graph 是 `org-planner → org-coder → org-validator`, +`acceptance.json` 从 planner 流到 validator **跳过 coder,这两个节点之间没有边**。 +所以分两类: + +| | 沿边传递 | 任务级共享 | +|---|---|---| +| 例 | `brd.md` → 紧邻的 prd 节点 | `acceptance.json` → 任意下游 validator | +| 绑定 | 边上声明契约(`kind`) | 挂在 task 上,按 `kind` 检索 | +| 不受限于 | —— | 拓扑相邻性 | + +**② workspace 根用代码推导,不入库。** 库里存路径会烂:W13 清掉的那 10 条 +`legacy_path` 悬空行就是这个病(指向早已删除的目录)。`files` 表只存**相对公司根**的路径, +根由代码推导(与 `runRootDir` / `nodeArtifactDir` 现有做法一致,需要覆盖时给环境变量)。 +另核实:**库里没有 `companies` 表** —— 公司是 `company.json` 文件 + 目录扫描, +所以原话里「存在 company 表的定义里」这个落点不存在。 + +**③ 路径走 `tasks/{taskId}/`**,不是 `workspace/`。两个理由:§4.6 原文的「同一家公司会跑 +很多 task,平铺的话第二个 task 的 `prd.md` 会覆盖第一个」;以及 `workspace/` 现有语义是 +[06-沙盒自治](../../done/system-design/06-sandbox-autonomy.md) 定的「**Agent 自治领地, +框架不干预**」,把框架管着的索引塞进去会混淆两种所有权。 + +### R-19.4 · 由此带来的一个设计收益(本条是我提出、用户定案的部分之外的推论) + +边上声明的是**契约(kind)**、`files` 表记的是**实例(path)**,两者是规划时 / 运行时两阶段: +`workgraph-planning` 画图时不可能知道文件名,但能声明「这条边传一份 brd」。 + +于是引擎**能在节点跑之前就校验**「你声明要吃 brd,但上游没有任何节点产 brd」—— +这类图结构错误现在完全查不出来,只能等跑到那一步 agent 说「我拿不到输入」 +(第四次真跑就是这么发现的)。这比事后归因强得多。 + +**落点**:[27-P4](./27-p4-parallel-and-artifacts.md) §4.6 重写。 + +--- + ## 附 · 需求 → 设计落点 速查 | 需求 | 一句话 | 主要落点 | @@ -455,3 +520,7 @@ R-16 正文中的原话引用**保留 `validator-reflection` 的用词不改**ï¼ | R-18.1 | 人工操作不该是跑脚本;点一下让 `orggraph-planning` 真跑并自行招聘 | P3B §四 W8 / P2 §4.1 | | R-18.2 | **work 层本体由 `workgraph-planning` 自己雇**(方案 a);须同时解决 org graph 被污染 | P3B §四 W5-W7 | | R-18.3 | **停用 mock,改真实 Claude CLI 验证**(mock 不读提示词,此前结论只验到引擎层) | P3B 全文 | +| R-19.1 | 统一产物路径 + 索引(原话此前**未记录**在源头文档,但 27-P4 §4.6 有设计、未实现) | P4 §4.6 | +| R-19.2 | **建 sqlite 表而不是 files.json**(JSON 会重演 _registry.json 的并发重写与 git 冲突) | P4 §4.6 | +| R-19.3 | **两类数据流都要**(沿边传递 + 任务级共享);workspace 根**代码推导**不入库;路径走 `tasks/{taskId}/` | P4 §4.6 | +| R-19.4 | 边存**契约(kind)**、files 表存**实例(path)**,规划/运行两阶段 → 引擎可在跑之前校验「声明要吃的输入没人产」 | P4 §4.6 | diff --git a/server/services/graph-engine.js b/server/services/graph-engine.js index 638edde..c354f9f 100644 --- a/server/services/graph-engine.js +++ b/server/services/graph-engine.js @@ -46,6 +46,12 @@ const { bumpBackwardCount, canTransitionRun, canTransitionNode, + // R-19 产物索引(27-P4 §4.6):inboundContracts/findFiles 给输入侧解析, + // recordFile 给产出侧记实例 —— 都由**引擎**调,不给 agent 自己写 + inboundContracts, + edgeContracts, + findFiles, + recordFile, } = require('./work-graph-db'); /** @@ -220,6 +226,11 @@ function parseNodeRef(json) { } /** 判「这次失败了」的 verdict(agent.md 产出格式那三个取值里的两个);no_prior_failure 是放行 */ +/** 任务级共享产物的 kind(不受拓扑相邻性限制,任何下游都可能要读,R-19.3 定案①) */ +const TASK_SHARED_KINDS = ['acceptance', 'contract']; +/** 按文件名兜底认 kind 的白名单(出边没声明契约时用;认不出来一律 'artifact') */ +const KNOWN_FILE_KINDS = ['brd', 'prd', 'sdd', 'acceptance', 'contract']; + const FAILING_VERDICTS = new Set(['attributed', 'inconclusive']); /** @@ -1269,6 +1280,16 @@ async function advanceOne(runId, opts = {}) { // 项目根同理,且比产物目录更早断:不写进提示词,agent 就会写到它能看见的最近的仓库。 if (ctx.nodeKind !== 'subgraph') ctx.task += describeArtifactContract(ctx); + // R-19:把**上游产物的真实路径**递到节点手上。 + // + // 这是第四次真跑那个断点的正解。当时 prd 节点的归因原话是「不是上游没产出, + // 是它的产出**结构上到不了我手上**」—— 边只承载控制流,上游的 brd.md 躺在它自己的 + // 私有目录里,下游的提示词里没有任何可读路径,只能靠硬拼,拼不出来。 + // + // 刻意**不是**「注入一句『索引在某个文件里,自己去检索』」:那仍然依赖 agent 自觉去查、 + // 自觉拼对路径,而真跑已经证明它拼不出来。引擎直接把路径列出来,不给它猜的机会。 + ctx.task += describeInputs(ctx, def); + // 抢到节点即记账:一次调用一定发生了(哪怕它随后炸掉),预算「只增不减」(§四.A.3)。 // subgraph 节点这一次花的是 workgraph-planning 的调用,同样按一次计。 spendBudget(run, { agentCalls: 1 }); @@ -1393,6 +1414,10 @@ function settleNode(runId, nodeRunId, outcome = {}) { }; if (to === 'success') { + // R-19 产出侧:把这次落盘的文件登记进产物索引,下游节点靠它拿到真实路径。 + // 只在 success 时记 —— 失败节点的半成品不该被下游当输入读。 + indexNodeOutputs(run, def, nodeRun); + // 人工「重试」留下的契约快照在这里判:契约变了才级联下游(§四.C 末段) out.contractDiff = applyRetryCascade(runId, def, nodeRun); activateSuccessTargets(runId, def, nodeRun.def_node_id, out, outcome.remediationNote || null); @@ -3017,6 +3042,147 @@ function nodeView(runId, r) { } /** + * 公司根目录 —— R-19.3 定案:**代码推导,不入库**。 + * 库里存绝对路径会烂(W13 清掉的那 10 条 legacy_path 悬空行就是这个病), + * 所以 graph_files.rel_path 只存**相对本目录**的路径,绝对路径每次由这里拼。 + * GRAPH_RUNS_DIR 覆盖时跟着挪,理由与 runRootDir 那处 override 一致: + * 否则测试每跑一次就往仓库 data/companies/ 里堆目录。 + */ +function companyRootDir(companyId) { + return RUNS_ROOT + ? path.join(RUNS_ROOT, '_companies', companyId) + : path.join(REPO_ROOT, 'data', 'companies', companyId); +} + +/** + * 本 task 的产物目录(R-19.3:走 tasks/{taskId}/,不走 workspace/)。 + * + * 按 task 分而不是平铺:同一家公司会跑很多 task,平铺的话第二个 task 的 prd.md + * 会覆盖第一个。不走 workspace/ 是因为它的现有语义是「Agent 自治领地、框架不干预」 + * (06-沙盒自治),而这里要的是框架管着、可检索的交付物区 —— 两种所有权混在 + * 一个目录里,索引就没法保证不漂移。 + */ +function taskDirRel(taskId) { + return path.join('tasks', taskId || '_no-task'); +} + +/** + * R-19 产出侧:把节点这次落盘的文件登记进 graph_files。 + * + * **由引擎调,不给 agent 自己写** —— 原设计(§4.6 v0.1)也是这么定的,理由是 + * 「否则索引必然和实际文件漂移」。本轮反复验证过这条:凡「靠 agent 自觉」的环节 + * 几乎全部落空过(goal-contract 零调用、setPlanWorkGraph 零调用、三值透传只接了读那一半)。 + * + * kind 怎么定:先看这个节点的**出边**上声明了什么(规划时就写下了「我会产一份 brd」), + * 再按文件名兜底。声明优先,因为那是图作者的意图;文件名只是巧合。 + */ +function indexNodeOutputs(run, def, nodeRun) { + if (!run || !nodeRun || !nodeRun.output_ref) return; + let dir = nodeRun.output_ref; + let files = []; + try { + if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return; + files = fs.readdirSync(dir).filter((f) => !f.startsWith('.')); + } catch { + return; // 目录读不了不该拖垮结算 + } + if (!files.length) return; + + // 出边声明的 kind:优先用它给文件定 kind + let declared = []; + try { + const layer = run.graph_kind === 'org' ? 'org' : 'work'; + const outbound = ((def && def.edges) || []).filter( + (e) => String(e.from) === String(nodeRun.def_node_id) && (e.edgeKind || 'forward') !== 'backward' + ); + for (const e of outbound) { + for (const c of edgeContracts(layer, e.id)) declared.push(c.kind); + } + declared = [...new Set(declared)]; + } catch { /* 契约查不到就只靠文件名 */ } + + const companyRoot = companyRootDir(run.company_id); + for (const f of files) { + // attempt-*.json 是引擎自己读的归因文件(readNodeProduct),不是给下游的产物 + if (/^attempt-\d+\.json$/.test(f)) continue; + const abs = path.join(dir, f); + const base = f.replace(/\.[^.]+$/, '').toLowerCase(); + // 声明优先:出边说会产 brd,且文件名里就有 brd → 定 brd + const kind = declared.find((k) => base === k || base.includes(k)) + || (KNOWN_FILE_KINDS.includes(base) ? base : 'artifact'); + try { + recordFile({ + companyId: run.company_id, + taskId: run.task_id, + relPath: path.relative(companyRoot, abs), + kind, + runId: run.id, + nodeRunId: nodeRun.id, + }); + } catch (e) { + timelineNote({ run, runId: run.id, nodeRunId: nodeRun.id, attempt: nodeRun.attempt }, + 'node.output', `产物索引写入失败 ${f}: ${e.message}`, { degraded: 'file_index_write_failed' }); + } + } +} + +/** + * 「这个节点需要什么输入、在哪」—— 两类数据流都查(R-19.3 定案①): + * ① 沿边传递:入边上声明的 kind(受拓扑相邻性限制) + * ② 任务级共享:本 task 下的 acceptance / contract 这类,**不受相邻性限制** + * —— R-9.3 的「planner 生成、validator 执行」是隔着一个 coder 的非相邻依赖, + * 纯边绑定表达不了,所以必须有这一类。 + * + * 声明了却查不到实例时**明说缺哪个 kind**,不要静默省略:那正是引擎能在跑之前 + * 发现「你要吃 brd,但上游没人产 brd」的地方(R-19.4)。 + */ +function describeInputs(ctx, def) { + if (!ctx || !ctx.run || ctx.nodeKind === 'subgraph') return ''; + let contracts = []; + let shared = []; + try { + const layer = ctx.run.graph_kind === 'org' ? 'org' : 'work'; + contracts = inboundContracts(layer, (def && def.edges) || [], ctx.defNodeId); + shared = findFiles({ + companyId: ctx.companyId, + taskId: ctx.run.task_id, + kinds: TASK_SHARED_KINDS, + }); + } catch { + return ''; // 索引读不出来不该拖垮节点执行 + } + + const declared = [...new Set(contracts.map((c) => c.kind))]; + const lines = []; + + if (declared.length) { + const found = findFiles({ companyId: ctx.companyId, taskId: ctx.run.task_id, kinds: declared }); + const byKind = new Map(); + for (const f of found) byKind.set(f.kind, f); + const root = companyRootDir(ctx.companyId); + for (const kind of declared) { + const hit = byKind.get(kind); + const required = contracts.some((c) => c.kind === kind && c.required); + if (hit) lines.push(`- ${kind}:${path.join(root, hit.rel_path)}`); + else if (required) lines.push(`- ${kind}:⚠️ 上游声明会产出,但索引里查不到实例`); + } + } + for (const f of shared) { + if (declared.includes(f.kind)) continue; // 已在上面列过 + lines.push(`- ${f.kind}(任务级共享):${path.join(companyRootDir(ctx.companyId), f.rel_path)}`); + } + + if (!lines.length) return ''; + return [ + '', '', + '[上游输入 —— 引擎已按图上声明的数据契约解析好,直接读这些路径]', + ...lines, + '标 ⚠️ 的表示图上声明了这份输入、但索引里没有实例:先确认是不是上游没产出,', + '不要自己去别的节点私有目录里翻(跨节点私有目录不可读,见写入边界)。', + ].join('\n'); +} + +/** * 拼进提示词的产物目录约定(§四.A.5 + validator-triager/agent.md「产出格式」)。 * * 路径与 readNodeProduct 读的**是同一个**(nodeArtifactDir),文件名给绝对路径, diff --git a/server/services/work-graph-db.js b/server/services/work-graph-db.js index 237db02..d452f6e 100644 --- a/server/services/work-graph-db.js +++ b/server/services/work-graph-db.js @@ -163,6 +163,51 @@ function getDb() { target_node_ref TEXT, created_at TEXT NOT NULL ); + + -- ── R-19:产物索引(27-P4 §4.6)──────────────────────────────────── + -- 补的是「同一张 work graph 内部上游到下游没有数据通路」这个断点:边一直只承载 + -- 控制流(condition / edge_kind / fail_reason),而「边的输入输出」此前只是 + -- 从 output_ref **推断**出来的 UI 展示、没有实体,推断的那一端是节点私有目录, + -- 下游读不到。第四次真跑就死在这:prd 节点的归因原话是 + -- 「不是上游没产出,是它的产出结构上到不了我手上」。 + + -- 产物**实例**:运行时由**引擎**追加。不靠 agent 自觉写 —— 否则索引与实际文件必然漂移 + -- (本轮反复验证:凡「靠 agent 自觉」的环节几乎全部落空过)。 + -- rel_path 是**相对公司根**的路径,根由代码推导不入库:存绝对路径会烂, + -- W13 清掉的那 10 条 legacy_path 悬空行就是这个病。 + CREATE TABLE IF NOT EXISTS graph_files ( + id TEXT PRIMARY KEY, + company_id TEXT NOT NULL, + task_id TEXT, + rel_path TEXT NOT NULL, + kind TEXT NOT NULL, + produced_by_run_id TEXT, + produced_by_node_run_id TEXT, + created_at TEXT NOT NULL, + superseded_by TEXT + ); + -- **部分**唯一:只约束「当前版本」(superseded_by IS NULL),历史行可以同路径共存。 + -- 与 idx_org_graph_nodes_company_role_active 同一个模式 —— 想要「同一键只有一个活的」 + -- 又要留历史,就得把条件写进索引,而不是靠删行。 + CREATE UNIQUE INDEX IF NOT EXISTS idx_graph_files_current + ON graph_files(company_id, COALESCE(task_id, ''), rel_path) WHERE superseded_by IS NULL; + CREATE INDEX IF NOT EXISTS idx_graph_files_lookup + ON graph_files(company_id, COALESCE(task_id, ''), kind); + + -- 边上声明的数据**契约**:规划时写,只有 kind 没有实例。 + -- edge_layer 区分两层边表 —— org_graph_edges.id 是 INTEGER、work_graph_edges.id 是 TEXT, + -- 与 graph_run_nodes.def_node_id 同一处类型不整齐(见 27-P3/P4 §四.A.2 的注), + -- 所以这里存字符串形式、不建外键,引用完整性靠服务层。 + CREATE TABLE IF NOT EXISTS graph_edge_contracts ( + id TEXT PRIMARY KEY, + edge_layer TEXT NOT NULL CHECK (edge_layer IN ('org','work')), + edge_id TEXT NOT NULL, + kind TEXT NOT NULL, + required INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_graph_edge_contracts_dedupe + ON graph_edge_contracts(edge_layer, edge_id, kind); `); // 存量库迁移(写法照抄 graph-db.js migrate():hasColumn 判断 + ALTER TABLE ADD COLUMN, @@ -858,6 +903,133 @@ function listLoopRecords(runId, { edgeId = null } = {}) { .all(runId, String(edgeId)); } + +// ──────────────────────────────────────────────────────────────── +// R-19 产物索引(27-P4 §4.6) +// +// 分工与 output_ref 不重复: +// output_ref 节点级指针 —— 「我这次跑出了什么」,一对一挂在 graph_run_nodes 行上 +// graph_files 公司级索引 —— 「这家公司有哪些产物、谁能用」,聚合视图,由前者派生 +// 所以 graph_files 必须可从节点行重建(rebuildFilesFromNodes),索引坏了不至于丢产物。 +// ──────────────────────────────────────────────────────────────── + +/** + * 记一条产物实例。**只由引擎调用**,不给 agent 自己写 —— + * 否则索引与实际文件必然漂移(本轮反复验证过这一点)。 + * + * 同一 (company, task, relPath) 重复产出时:把旧行标 superseded_by 指向新行, + * 而不是堆一堆同路径的行。这样「最新的那份」是唯一 superseded_by IS NULL 的行, + * 历史仍可追溯。 + */ +function recordFile({ companyId, taskId = null, relPath, kind, runId = null, nodeRunId = null }) { + if (!companyId || !relPath || !kind) throw new Error('recordFile 需要 companyId / relPath / kind'); + const db = getDb(); + const now = new Date().toISOString(); + const id = newId('gf'); + + const tx = db.transaction(() => { + const prev = db + .prepare(` + SELECT id FROM graph_files + WHERE company_id = ? AND COALESCE(task_id, '') = ? AND rel_path = ? + AND superseded_by IS NULL + `) + .get(companyId, taskId || '', relPath); + + if (prev) { + // 同路径再产出:先把旧行指向新行(腾出部分唯一索引的位置),再插新行。 + // 顺序不能反 —— 反了会撞 idx_graph_files_current。 + db.prepare(`UPDATE graph_files SET superseded_by = ? WHERE id = ?`).run(id, prev.id); + } + db.prepare(` + INSERT INTO graph_files + (id, company_id, task_id, rel_path, kind, + produced_by_run_id, produced_by_node_run_id, created_at, superseded_by) + VALUES (@id, @company_id, @task_id, @rel_path, @kind, @run_id, @node_run_id, @now, NULL) + `).run({ + id, company_id: companyId, task_id: taskId, rel_path: relPath, kind, + run_id: runId, node_run_id: nodeRunId, now, + }); + }); + tx(); + return id; +} + +/** 按 kind 查这家公司/这个 task 下最新的产物实例(superseded_by IS NULL) */ +function findFiles({ companyId, taskId = null, kinds = null }) { + const db = getDb(); + const rows = db + .prepare(` + SELECT * FROM graph_files + WHERE company_id = ? AND COALESCE(task_id, '') = ? AND superseded_by IS NULL + ORDER BY created_at + `) + .all(companyId, taskId || ''); + if (!kinds || !kinds.length) return rows; + const want = new Set(kinds); + return rows.filter((r) => want.has(r.kind)); +} + +/** 在边上声明「这条边要传什么 kind」。规划时写,只有 kind 没有实例。 */ +function declareEdgeContract({ edgeLayer, edgeId, kind, required = true }) { + if (!['org', 'work'].includes(edgeLayer)) throw new Error(`edgeLayer 非法: ${edgeLayer}`); + if (!edgeId || !kind) throw new Error('declareEdgeContract 需要 edgeId / kind'); + getDb() + .prepare(` + INSERT INTO graph_edge_contracts (id, edge_layer, edge_id, kind, required, created_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(edge_layer, edge_id, kind) DO NOTHING + `) + .run(newId('gec'), edgeLayer, String(edgeId), kind, required ? 1 : 0, new Date().toISOString()); +} + +/** 某条边上声明的契约 */ +function edgeContracts(edgeLayer, edgeId) { + return getDb() + .prepare(`SELECT * FROM graph_edge_contracts WHERE edge_layer = ? AND edge_id = ?`) + .all(edgeLayer, String(edgeId)); +} + +/** + * 某个节点的**入边**上声明了哪些 kind —— 这是「这个节点需要什么输入」的答案。 + * 只看 forward 入边:backward 入边是回退路径,不是数据来源。 + */ +function inboundContracts(edgeLayer, edges, defNodeId) { + const inbound = (edges || []).filter( + (e) => String(e.to) === String(defNodeId) && (e.edgeKind || 'forward') !== 'backward' + ); + const out = []; + for (const e of inbound) { + for (const c of edgeContracts(edgeLayer, e.id)) out.push({ ...c, fromNodeId: e.from }); + } + return out; +} + +/** + * 从 graph_run_nodes 重建索引(索引损坏时的兜底,类比 okr 的 _rebuildIndex)。 + * 只能恢复「节点产出过什么目录」这一层,恢复不了 kind —— 所以 kind 统一记 'artifact', + * 这是有损重建,够用来定位文件,不够还原语义。 + */ +function rebuildFilesFromNodes(companyId) { + const db = getDb(); + const rows = db + .prepare(` + SELECT n.id AS node_run_id, n.run_id, n.output_ref, r.task_id + FROM graph_run_nodes n JOIN graph_runs r ON r.id = n.run_id + WHERE r.company_id = ? AND n.output_ref IS NOT NULL + `) + .all(companyId); + let n = 0; + for (const r of rows) { + recordFile({ + companyId, taskId: r.task_id, relPath: r.output_ref, kind: 'artifact', + runId: r.run_id, nodeRunId: r.node_run_id, + }); + n += 1; + } + return n; +} + module.exports = { getDb, newId, @@ -886,6 +1058,14 @@ module.exports = { listRunTree, listCompanyRuns, + // R-19 产物索引(27-P4 §4.6) + recordFile, + findFiles, + declareEdgeContract, + edgeContracts, + inboundContracts, + rebuildFilesFromNodes, + // 回退记录 countLoopRecords, insertLoopRecord, -- libgit2 0.21.4