diff --git a/bin/lemon.js b/bin/lemon.js
index e8c78cf..64c850d 100755
--- a/bin/lemon.js
+++ b/bin/lemon.js
@@ -195,6 +195,25 @@ async function run() {
});
} catch { /* best-effort,静默丢弃 */ }
}
+ // 43 号文档 Phase 3:itemized 任务列出它物化出的子任务(coder-validator 逐条验收前先拿列表)
+ else if (command === 'task subtasks') {
+ const { values } = parseArgs({ args: args.slice(2), options: {
+ companyId: { type: 'string' }, taskId: { type: 'string' }
+ }});
+ const res = await request('GET', `/company/${values.companyId}/tasks?parentTaskId=${encodeURIComponent(values.taskId)}`);
+ console.log(JSON.stringify(res.data, null, 2));
+ }
+ // 43 号文档 Phase 3:逐条上报一个子任务的验收结果(每判完一条验收项调一次)
+ else if (command === 'task subtask-result') {
+ const { values } = parseArgs({ args: args.slice(2), options: {
+ companyId: { type: 'string' }, taskId: { type: 'string' },
+ status: { type: 'string' }, summary: { type: 'string', default: '' }
+ }});
+ const res = await request('POST', `/company/${values.companyId}/tasks/${values.taskId}/subtask-result`, {
+ status: values.status, summary: values.summary
+ });
+ console.log(JSON.stringify(res.data, null, 2));
+ }
else if (command === 'pipeline create') {
const { values } = parseArgs({ args: args.slice(2), options: {
companyId: { type: 'string' }, file: { type: 'string' }
diff --git a/docs/README.md b/docs/README.md
index 1308388..0aa8cc9 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -60,7 +60,8 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d
| [37-org-graph-department-scoping.md](./todo/system-design/37-org-graph-department-scoping.md) | 📝 **设计讨论,调用点扫描已完成,尚未立项实现**。现状 org graph 按 `company_id` 单例(`27-P2` 标题原文即"公司级"),塞不下 HR/财务/市场这类跟"交付流水线"无关的公司常设职能——`system/agents/hr/`("招·留·管·退")本体已存在却从没被装进任何图,全靠 fire-and-forget chat 招聘、无图执行审计轨迹。提议把 org graph 作用域从 `company_id` 升级为 `(company_id, department_id)`,一家公司可装配多张部门图,每张仍保持人工固定/慢变/可审计。全仓库调用点扫描结论:改造面是最初估算的 4 倍——真正的单点真相在 `work-graph-db.js:readDefGraph` 的裸 SQL 不变式(不在服务层),还牵涉两个易混淆服务文件间必须字节级一致的重复唯一索引、独立的 `graph-seed-service.js` 对账子系统、`company-hire-org-layer.test.js` 专属测试、`orggraph-planning`/`task-planning` 两份 agent 人设文档的自我认知改写 |
| [36-prd-agent-methodology-training-feasibility.md](./todo/system-design/36-prd-agent-methodology-training-feasibility.md) | 📝 **调研 + 阶段一/二均已完成**。回应"落地页太单薄是不是该用 Innovator 先训练专业 agent、再用 Dream 持续迭代"——认同方向。**阶段一**:`graph-engine.js` 结算节点后把 `success`/`fail:` 写进本体 `memory.md`(此前只有人类对话路径会写,Dream 天天空转)。**阶段二**:新建独立试验节点 `prd-methodology-trial` 走真实 Hunt→Digest→Evolve,2 轮收敛(96/97 分,真实基线 50 分),三个真实历史项目反推验证方法论有效;过程中连带修复同一个 2026-08-16 改名批次遗留的 5 处 `innovator`→`org-innovator` 死链(后端两处硬编码、前端"重新训练"按钮、`/api/agent/list` 展示 id 来源的四个 `config.json`),以及 `AgentScaffold` 默认模板一处裸相对路径缺陷。结论:FDE 对方法论类角色确实管用,但收敛速度/验收严谨度依赖任务书显式写明的自纠正要求,非免人工黑盒。环节 3(训练产物 → 雇佣本体转换)尚未开工 |
| [34-validator-scored-without-real-deliverable.md](./todo/system-design/34-validator-scored-without-real-deliverable.md) | 📝 **调研记录,checklist 已查完**。最初以为 `org-coder-impl` 从未产出任何 HTML/CSS,后来发现真实网站其实存在于 `graph_files` 索引不覆盖的「项目根」目录(已顺手修:控制台「产物」面板现在会展示项目根)。追加查清 validator 有没有真的做浏览器级检查——中途自我订正一次:不是"没工具做不到"(`bash` 能走到仓库自带的 Playwright,另一条对照任务证明可行),是**没人教它这么做**;抽查另外两个项目根后发现**验证严谨度完全不稳定**(同类任务有的做了真实 Playwright 级验证、有的只做静态正则、有的没验证就被取消),根因很可能是新招聘的 `unit-tester` 本体撞上了 [32 号文档](./todo/system-design/32-hire-body-fill-reliability.md) 那类"装配没填充完"的真实样本,不是平台工具限制 |
-| [43-task-decomposition-granularity.md](./todo/system-design/43-task-decomposition-granularity.md) | 📝 **需求 + 方案定案 + 技术实现计划(5 个 Phase)已写完,尚未开工**。回应"org-planner 出 brd/prd/sdd 后,任务看板能不能展示成 task/sub-task 树,coder-validator 按清单逐条开发验收"。方案:goal→task 流水线自建父子机制、先只拆验收(开发仍整体完成)、`tasks.acceptance_mode` 由 task-planning 判断、子任务复用 `tasks.status` 既有状态语义、任一子任务 fail 父任务即 fail。**Phase 0-4** 逐条核实了代码形态:`createTask()`/`parent_task_id` 迁移模板、task-planning "硬规则 5"需要开例外让 LLM 多写一个字段、`org-planner` 产出的验收清单其实是 `acceptance.md` 不是 `acceptance.json`(且⚠️真实回归里从未被用上,落地前需先验证可靠性)、`coder-validator`(不是 org-validator,文档订正过一次)的 `checks[]` 目前没有任何服务端代码解析、看板树形渲染有 `FileNode.vue` 现成模板可抄。建议实施顺序 Phase 0→1→4→2→3 |
+| [44-writes-artifacts-produces-gate-mismatch.md](./todo/system-design/44-writes-artifacts-produces-gate-mismatch.md) | 📝 **真实回归中发现的缺陷记录,未修复**。43 号文档 Phase 2 真实验证时撞见:`goal-benchmark`(`planner.json` 里标 `writesArtifacts:true`)按文档正确把产物写进 root run 共享 `artifacts/` 目录,但产出闸门 `unsatisfiedRequiredProduces`(`graph-engine.js:4430`)只查节点私有目录(`nodeArtifactDir`),`writesArtifacts` 这个字段全仓库零代码读取——这条契约在当前实现下**永远无法通过**,不是偶发。真实证据:`artifacts/goal-benchmark.json` 磁盘上确认存在,闸门仍判「没找到」,强制 fail 沿兜底边回退。很可能正是 snakegame-co 参考任务里 `goal-benchmark`/`planner-validator` 显示 `skipped` 的真实原因。已有的 R-19 `contract.json` 显式声明逃生口(41 号文档修的那个)`allowedRoots` 也够不到共享 `artifacts/` 目录,一并够不到。留了两条候选修法,未实现 |
+| [43-task-decomposition-granularity.md](./todo/system-design/43-task-decomposition-granularity.md) | ✅ **Phase 0-4 全部已实现**(2026-08-31)。回应"org-planner 出 brd/prd/sdd 后,任务看板能不能展示成 task/sub-task 树,coder-validator 按清单逐条开发验收"。`tasks` 加 `acceptance_mode`/`parent_task_id`(后者复用既有列);`task-planning` 判断要不要拆(硬规则 5 开例外让 LLM 写这一个字段);org-planner 收尾把 `acceptance.md` 物化成子任务;`coder-validator` 新增 `task subtask-result` 工具逐条上报,`settleNode()` 新增闸门强制核对子任务全 `completed` 才认 success;看板加子任务展示。**落地时订正了一处原计划想岔的架构假设**:org-planner 在 org 层永远是 `subgraph` 节点(不是 agent_call),真跑数据显示它当前展开成的 `planner.json` 5 节点子图里,`goal-benchmark`/`planner-validator` 两节点常被 skip,`acceptance.md` 未必真的产出——钩子已改成扫子 run 全节点、找不到就优雅降级为 holistic,不阻塞主产出,但"子任务一定会长出来"这件事目前是尽力而为不是保证,见文档"实现记录"一节的遗留风险说明。全量单测 1120/1123 通过(3 个失败与本次改动无关,本次改动前即存在) |
## todo/future/(暂缓,非当前重心)
diff --git a/docs/todo/system-design/43-task-decomposition-granularity.md b/docs/todo/system-design/43-task-decomposition-granularity.md
index 53d241e..738d15c 100644
--- a/docs/todo/system-design/43-task-decomposition-granularity.md
+++ b/docs/todo/system-design/43-task-decomposition-granularity.md
@@ -1,6 +1,6 @@
# 43 · Goal 分解粒度:规划产出该长成文件,还是长成 task/subtask 树
-> **状态**: 📝 需求 + 现状调研,尚未设计、未实现
+> **状态**: ✅ Phase 0-4 全部已实现(2026-08-31)。全量单测 1120/1123 通过(3 个失败与本次改动无关,改动前即存在)
> **日期**: 2026-08-31
> **触发场景**: snakegame-co 公司,goal `G-mtfh23mu-bd63`("HTML 贪吃蛇游戏"),task `T-mtfh2kuf-7c31`。
> 该任务当天被 `missing_required_input` 死循环卡住(根因与修复见 [33 号文档](../../done/system-design/33-needs-gate-role-filter-bug.md) 的复发一例,
@@ -136,3 +136,30 @@ org-planner 的 4 阶段 FSM(`system/agents/org-planner/tools.md:19-23`,`nod
Phase 4(看板渲染)提前到 Phase 2/3 之前:先用手工插入的测试数据验证树形展示对不对,把"看得见"和"能自动生成"两件事解耦,避免一次性大集成、出问题不知道是生成的问题还是展示的问题。
Phase 2 排在 Phase 3 之前,且内部先做"acceptance.md 是否真的可靠"这次独立验证——这是唯一一个建在未经证实的既有产物之上的环节,值得单独花一次真跑确认,再决定要不要照原计划往下建。
+
+---
+
+## 实现记录(2026-08-31,Phase 0-4 全部完成)
+
+按 Phase 0→1→4→2→3 顺序实现,每个 Phase 落地时跑一次相关单测 + 全量回归,全程零静默失败。
+以下记两处**跟原计划不一样的地方**——都是动手时发现原计划想岔了,不是简单照抄执行。
+
+### 落地清单
+
+| Phase | 改了什么 | 测试 |
+|---|---|---|
+| 0(schema) | `tasks` 加 `acceptance_mode` 列(`CHECK IN ('holistic','itemized')`,迁移套用 `parent_task_id` 当年的 `hasColumn`/`ALTER TABLE` 模板);`createTask()` 加 `parentTaskId`/`acceptanceMode`,子任务禁止递归声明 `itemized`;`taskView()` 补上这两个字段的输出 | `task-acceptance-mode.test.js`(6 条) |
+| 1(task-planning 判断) | `task-planning` 的 JSON schema 从 4 字段扩到 5 字段(`acceptance_mode` 可选),在硬规则 5 上明确开了一条例外(这个字段需要语义判断,不能像 mode/priority/budget 那样服务端注入默认值);`agent.md` 加 Step 2bis + 例子;`buildTaskPlans()` 透传 | `company-goal-task-routes.test.js` 补 2 条断言 |
+| 4(看板渲染) | `GoalTaskPane.vue` 按 `parentTaskId` 分层渲染(子任务只读、无操作菜单,因为它们没有独立 run);新出现的 `TaskTable.vue`(同一天另一个 session 并发重写了 `TaskBoard.vue`)用一行"↳ 子任务·属于「…」"指回父任务,不强行画树——它是可排序表格,硬套树会打架 | 用假数据 harness 截图人工验证(渲染逻辑本身足够简单,没再加单测) |
+| 2(org-planner 物化子任务) | **见下方"计划外的架构订正"** | `task-decomposition-materialize.test.js`(3 条,真的驱动了 org-planner 展开子图 + 子 run 收敛回传的完整路径) |
+| 3(coder-validator 逐条上报) | 新增 `wdb.settleSubtaskResult()`(终态幂等条件更新,同 `settleTaskFromRun` 一个模子);新路由 `POST /:companyId/tasks/:taskId/subtask-result`;`listCompanyTasks`/`GET /:companyId/tasks` 加 `parentTaskId` 过滤;`bin/lemon.js` 加 `task subtasks` / `task subtask-result` 两个子命令;`settleNode()` 新增闸门——`coder-validator` 判 success 前核一遍它验的 task 若有子任务,是否全部 `completed`,没全的话强制改判 fail(新 `ENGINE_FAIL_REASONS.SUBTASKS_INCOMPLETE`);`coder-validator/agent.md` 加 Step 4bis | `task-decomposition-validator-gate.test.js`(6 条);顺带修了 `graph-engine-fail-reason.test.js` 的取值域快照(六→七个引擎取值)和 `template-verdicts.test.js`(agent.md 里一句话的措辞会被误判成"教 agent 写一个引擎不认的 fail_reason",改了措辞不改语义) |
+
+### 计划外的架构订正:org-planner 不是 agent_call,是 subgraph
+
+原计划假设 `system/agents/org-planner/` 是一个单体 agent,走 4 阶段 FSM 直接产出 `brd.md/prd.md/sdd.md/acceptance.md`,Phase 2 打算在它自己的 `settleNode()` 成功分支挂一个钩子。**这个假设是错的**——org 层节点在 `readDefGraph` 里全部被标记成 `nodeKind='subgraph'`(无一例外),真正核实 snakegame-co 那条参考任务的生产数据后发现,org-planner 当前总是靠 `workgraph-planning` 展开成 `planner.json` 那张 5 节点子图(`brd-planning → prd-planning → sdd-planning → goal-benchmark → planner-validator`),而**这条参考任务的 `goal-benchmark`/`planner-validator` 两个节点当时都是 `skipped`**——`acceptance.md`(连带 R-19-B 的 `acceptance.json` 契约)从未被真正产出过。这正是文档最初标记的"⚠️ 已知风险",真跑数据把它坐实了。
+
+修法:钩子从"`org-planner` 这个 `agent_call` 节点自己成功时触发"改成"`org-planner` 这个 subgraph 节点(不管它是直接执行还是展开成子图)真正结算 success 时触发"——两种形态统一在同一个 `maybeMaterializeItemizedSubtasks()` 里判:agent_call 就在它自己的私有目录找 `acceptance.md`,subgraph 就去它的子 run 里**全节点扫**(不假设固定是哪个节点写的,因为 workgraph-planning 现场选模板,节点名不固定)。找不到就留一条 timeline note、按 `holistic` 收尾,**不阻塞、不拖累 org-planner 这一轮真正有价值的产出**——这与文档最初的"软失败"设计初衷一致,只是触发时机改对了。
+
+**遗留风险**(订正后依然成立,写实话):`acceptance.md` 会不会被稳定产出,取决于 `workgraph-planning` 会不会给 org-planner 挑一张真正跑完 `goal-benchmark`/`planner-validator` 的图、以及那张图会不会真的产出这份文件——这两件事都不在本次改动范围内,`maybeMaterializeItemizedSubtasks()` 目前是**优雅降级**(找不到就跳过,不报错),不是**保证生效**。换句话说:`acceptance_mode='itemized'` 现在是"如果规划链路配合,就能自动长出子任务",还没做到"一定会长出子任务"。
+
+**2026-08-31 真实回归验证坐实了这条风险**,而且找到了比"未必可靠"更具体的根因:见 [44 号文档](./44-writes-artifacts-produces-gate-mismatch.md)——`goal-benchmark` 按设计把产物写进 root run 共享 `artifacts/` 目录,但产出闸门只查节点私有目录,`writesArtifacts:true` 这个字段全仓库零代码读取,这条契约在当前实现下**结构性地**打不过,不是运气不好。这很可能正是本文档触发场景(snakegame-co)里 `goal-benchmark`/`planner-validator` 显示 `skipped` 的真实原因。44 号文档只记录诊断,未修复;修复它会提高 Phase 2 的实际命中率,但不在 43 号文档范围内,不在这里展开。
diff --git a/docs/todo/system-design/44-writes-artifacts-produces-gate-mismatch.md b/docs/todo/system-design/44-writes-artifacts-produces-gate-mismatch.md
new file mode 100644
index 0000000..d70b035
--- /dev/null
+++ b/docs/todo/system-design/44-writes-artifacts-produces-gate-mismatch.md
@@ -0,0 +1,110 @@
+# 44 · `writesArtifacts` 节点的产出闸门永远打不过:写到共享区,闸门查的是私有目录
+
+> **状态**: 📝 真实回归中发现的缺陷记录,已诊断到根因,**未修复**(本文档只记录,不含实现)
+> **日期**: 2026-08-31
+> **发现场景**:[43 号文档](./43-task-decomposition-granularity.md) Phase 2 的真实端到端验证——新建 `task-decomp-verify-co` 公司,走一条真实 `itemized` 任务,org-planner 展开成 `system/work-graph-templates/planner.json` 那张 5 节点子图(真实 Claude CLI,非 mock)
+> **与 43 号文档的关系**:**完全独立的缺陷**,跟 goal 分解粒度那件事没有任何关系,只是恰好在验证 43 号文档时撞见。43 号文档"实现记录"一节提到的"acceptance.md 未必可靠",这份文档是那句话背后更具体、更确定的根因之一
+
+---
+
+## 一、现象
+
+真实回归任务里,`org-planner` 展开的 `planner.json` 子图跑到 `org-planner-benchmark`(本体 `goal-benchmark`,模板里标了 `"writesArtifacts": true`)这一步:
+
+- agent 自己的判决(`attempt-0.json`)写的是 `"verdict": "produced"`,`output.artifact` 指向 `artifacts/goal-benchmark.json`;
+- 落到 `graph-engine.js` 的产出闸门(`unsatisfiedRequiredProduces`,`server/services/graph-engine.js:4430-4467`)却判定「节点声明会产出 `acceptance`,但产物目录里没找到对应文件」,把这次 `success` 强制改判 `fail`(`fail_reason: produces_missing`);
+- 沿 `planner.json` 的兜底回退边退回 `org-planner-sdd` 重跑,`loop_count 1/3`。
+
+时间线原文(`data/companies/task-decomp-verify-co/runs/run_edaf3ea3ed69/timeline.ndjson`):
+
+```
+18:24:17.177 node.output org-planner-benchmark → success
+18:24:17.178 node.output 节点声明会产出 acceptance,但产物目录里没找到对应文件,按 fail 结算
+ payload: {"missing_kinds":["acceptance"],"degraded":"produces_missing"}
+18:24:17.178 node.status org-planner-benchmark: running → fail (failReason: produces_missing)
+18:24:17.179 loop.registered 回退备案 loop_count=1/3 · backward_count=1/5
+18:24:17.179 node.status org-planner-benchmark: fail → pending(上游回退,本轮产出作废)
+```
+
+**关键点**:agent 没有做错任何事——它确实把文件写出来了,就在它自己判决里写的那个路径:
+
+```bash
+$ find data/companies/task-decomp-verify-co/runs/run_edaf3ea3ed69/artifacts -type f
+data/companies/task-decomp-verify-co/runs/run_edaf3ea3ed69/artifacts/goal-benchmark.json # 真实存在,内容正常
+```
+
+而闸门检查的是这个:
+
+```bash
+$ ls data/companies/task-decomp-verify-co/runs/run_edaf3ea3ed69/nodes/rn_d5fb26f5eede/
+attempt-0.json # 只有这一份,闸门要找的 acceptance 相关文件不在这
+```
+
+两个不是同一个目录,而且**永远不会**是同一个目录——下面「根因」一节说明为什么。
+
+---
+
+## 二、根因:闸门只认节点私有目录,`writesArtifacts:true` 是纯文档、代码里没人读
+
+### 2.1 `goal-benchmark` 被明确指示写共享区,这是设计意图不是失误
+
+`system/agents/goal-benchmark/agent.md:13,43` 白纸黑字写着:
+
+> 产物写进 root run 的 `artifacts/` 共享区…… `runs/{rootRunId}/artifacts/`:共享产物区。你对它**有写权**(`writesArtifacts: true`),但只写自己那一段
+
+`goal-benchmark/tools.md:25`:`用文件写入工具写 $R/artifacts/goal-benchmark.json`。
+
+这是因为 `acceptance.json`(R-19-B 契约)是三个 org node 各写一段合成的(`benchmark`/`test_data`/`test_script`),天然是跨节点共享状态,只能落在 root run 级别的共享区,不可能落在某一个节点自己的私有目录里——落在私有目录里,别的节点根本读不到它那一段。
+
+### 2.2 但闸门只查节点私有目录
+
+`unsatisfiedRequiredProduces`(`server/services/graph-engine.js:4430-4467`):
+
+```js
+dir = nodeArtifactDir(run, nodeRun.id); // 4452 行:只算节点私有目录
+if (!fs.existsSync(dir) || ...) return declared;
+files = fs.readdirSync(dir)...
+```
+
+`nodeArtifactDir(run, nodeRunId)` = `runRootDir(run)/nodes/{nodeRunId}`(`graph-engine.js:3680`),不包含 `runRootDir(run)/artifacts/`。所以只要 `goal-benchmark` **正确地**按文档写到共享区,它自己节点私有目录下除了 `attempt-N.json`(会被过滤掉,见 4458-4459 行)什么都不会有,`hasCandidates` 恒为 `false`,`acceptance` 这个 kind 恒被判定「缺」。
+
+**这条契约在当前实现下,无论 agent 表现多好都无法通过。**
+
+### 2.3 `writesArtifacts: true` 这个字段,全仓库零代码读取
+
+```bash
+$ grep -rn "writesArtifacts" server/ system/ --include="*.js" --include="*.json"
+system/work-graph-templates/planner.json:11: { ..., "writesArtifacts": true }
+```
+
+唯一一处命中就是模板里声明它的那一行。**它是纯文档性质的元数据,从没被 `unsatisfiedRequiredProduces`、`indexNodeOutputs`(`graph-engine.js:4469` 起)或任何别的地方读取过。** 模板作者(`planner.json` 的注释)显然知道 `goal-benchmark` 的产出方式跟别的节点不一样,也确实标了这个字段——但从来没人把它接进产出闸门的判断逻辑里。
+
+### 2.4 已有的逃生口(R-19 产出索引的显式声明机制)够不到共享区
+
+`graph-engine.js` 已经有一条为**同一类**问题准备的机制——`readDeclaredProduces`(`graph-engine.js:4377-4406`):节点可以在自己私有目录写一份 `contract.json`,里面显式声明 `produces: {kind: ["<绝对路径>"]}`,闸门会认这份声明而不是死磕目录扫描。这正是 [41 号文档](./41-graph-vs-fde-reliability-discussion.md) 提到的"R-19 产出索引不覆盖项目根,加 `contract.json` 的 `produces` 显式声明机制"那次修复的产物。
+
+但这条逃生口本身也够不到共享 `artifacts/` 目录——`inBounds()` 校验的 `allowedRoots` 只有 `[节点私有目录, 项目根]`(`graph-engine.js:4384`),不包含 `runRootDir(run)/artifacts/`。即使 `goal-benchmark` 主动在自己目录写一份 `contract.json` 声明 `produces.acceptance = ["/abs/path/to/artifacts/goal-benchmark.json"]`,这条声明也会因为路径不在 `allowedRoots` 里被判 `bad=true`,等于没声明。
+
+---
+
+## 三、影响面
+
+**任何一家公司只要 org-planner 走 `planner.json` 骨架(当前的常态,见 43 号文档 Phase 2 的真实数据核实),`org-planner-benchmark` 这一步几乎注定会撞这堵墙。** 每次撞墙都沿兜底回退边退回 `org-planner-sdd` 重来一轮(烧一次 sdd-planning 的真实调用 + 一次 goal-benchmark 的真实调用),直到 `loop_count` 撞到 `planner.json` 兜底边的硬顶(`LOOP_HARD_CAP_RUN` 相关,全树 `backward_count` 硬顶 5),最终大概率被强制判 `blocked` 或者 `planner-validator` 跟着一起被跳过。
+
+**这很可能正是本 session 全程调试的参考任务(snakegame-co,[43 号文档](./43-task-decomposition-granularity.md) 的触发场景)里 `goal-benchmark`/`planner-validator` 两个节点显示 `skipped` 的真实原因**——不是没跑到,是跑了、正确地写了产物,却被这道闸门反复判定「没写」,最终在某一轮被放弃跳过。
+
+连带后果:
+
+- `artifacts/acceptance.json`(R-19-B 契约,`benchmark`/`test_data`/`test_script` 三段合成)的 `benchmark` 段大概率永远拿不到真实内容——不是 `goal-benchmark` 没算出来,是它算出来的东西没能真正"通过"这一节点的成功判定,图会不断回退重算,直到耗尽回退预算放弃。
+- 连带 43 号文档 Phase 2:`acceptance.md`(不同于此处的 `acceptance.json`,来源不同)不受这条闸门直接影响,但**同一个 `planner.json` 子图反复在这里回退熔断**,会拖慢/阻塞整条 org-planner 子图的收敛,间接影响 Phase 2 的"org-planner 真正判定 success"这一时机能不能正常到达。
+
+---
+
+## 四、建议修法(未实现,留给下一次专门处理)
+
+两个方向,第一个更小、更贴合现有 R-19 设计:
+
+1. **扩大 `readDeclaredProduces` 的 `allowedRoots`**(`graph-engine.js:4384`),把 `runRootDir(run)/artifacts/` 也纳入合法路径;同时让 `goal-benchmark`(以及未来任何 `writesArtifacts:true` 的节点)在写完共享产物后,**也**在自己节点私有目录写一份 `contract.json` 显式声明 `produces.acceptance` 指向那个共享区绝对路径。这样闸门检查的还是"显式声明 + 文件真实存在",只是放宽了声明能指向的位置——跟 R-19 现有的设计哲学一致,改动面最小。
+2. **让 `writesArtifacts: true` 这个字段真正被消费**:`unsatisfiedRequiredProduces` 读节点定义时,如果这个节点标了 `writesArtifacts: true`,改成同时扫 `runRootDir(run)/artifacts/` 目录(不要求 agent 额外写 `contract.json`)。改动面稍大(要把这个字段从模板一路传到 `def.nodes` 里,目前 `readDefGraph` 会不会保留这个字段未核实),但对 agent 侧零改动。
+
+两条路都没有实现,需要先确认哪条更契合现有 `def.nodes` 的数据流(`readDefGraph` 目前是否保留模板里的自定义字段),再动手;本文档只记录诊断结果,不展开设计。
diff --git a/server/routes/company.js b/server/routes/company.js
index 0b31f88..36cdab9 100644
--- a/server/routes/company.js
+++ b/server/routes/company.js
@@ -1023,6 +1023,9 @@ function taskView(t, progress = null, positions = null) {
blockedReason: t.blocked_reason,
reportTo: t.report_to,
assigneeAgentId: t.assignee_agent_id || null,
+ // 43 号文档:子任务树的父子指针 + 这条 task 走哪种验收模式(看板据此决定要不要画子任务)
+ parentTaskId: t.parent_task_id || null,
+ acceptanceMode: t.acceptance_mode || 'holistic',
createdAt: t.created_at,
queuedAt: t.queued_at,
startedAt: t.started_at,
@@ -1409,10 +1412,19 @@ function buildTaskPlans(specs, { priority, budget, mode, seqBase = 0 } = {}) {
if (rawDeps !== undefined && rawDeps !== null && !Array.isArray(rawDeps)) {
return { error: `tasks[${i}].depends_on 必须是数组` };
}
+ // 43 号文档:跟 mode/priority/budget 不同——这个字段**透传**、不继承 goal 级默认值。
+ // 要不要按验收条目拆子任务是语义判断,task-planning 已经在 specs 里判过一次;
+ // 没给就是没判断要拆,缺省 holistic(保守),不是「服务端注入默认值」那类字段。
+ const rawAMode = s.acceptance_mode !== undefined ? s.acceptance_mode : s.acceptanceMode;
+ const acceptanceMode = rawAMode == null ? 'holistic' : rawAMode;
+ if (!['holistic', 'itemized'].includes(acceptanceMode)) {
+ return { error: `tasks[${i}].acceptance_mode 只能是 'holistic' 或 'itemized',收到: ${JSON.stringify(rawAMode)}` };
+ }
plans.push({
seq: base + i + 1, // seq 显式给,别靠 MAX+1 —— depends_on 的 seq 引用要能对得上服务端分配的号
title: String(s.title || '').trim() || deriveTitle(d),
description: d,
+ acceptanceMode,
// 优先级:task 自己的 mode > goal 级 mode(第三个候选)> 'manual'。
// 「继承 goal」这一层是批 2 加的,对显式传 tasks[] 的调用方是行为变化(此前恒 manual),
// 保留它是因为反过来更荒谬:用户在表单上把整个 goal 选成 auto,拆出来的 task 却全是
@@ -1450,6 +1462,7 @@ function insertTasksForGoal(wdb, goal, plans, createdBy) {
priority: p.priority,
budgetJson: p.budget, // 建行时就存下来:草稿路径上请求体活不到 dispatch(缺陷 F3)
createdBy,
+ acceptanceMode: p.acceptanceMode,
})
);
const bySeq = new Map(created.map((t) => [String(t.seq), t.id]));
@@ -2065,6 +2078,8 @@ router.get('/:companyId/tasks', (req, res) => {
const tasks = wdb.listCompanyTasks(req.params.companyId, {
status: req.query.status || null,
goalId: req.query.goalId || req.query.goal_id || null,
+ // 43 号文档:coder-validator 用它列出一个 itemized 任务物化出的全部子任务
+ parentTaskId: req.query.parentTaskId || req.query.parent_task_id || null,
includeArchived: req.query.includeArchived === '1' || req.query.includeArchived === 'true',
limit: Number.isInteger(limit) ? limit : null,
});
@@ -2308,6 +2323,40 @@ router.patch('/:companyId/tasks/:taskId', (req, res) => {
});
/**
+ * POST /api/company/:companyId/tasks/:taskId/subtask-result — 43 号文档 Phase 3
+ * body: { status: 'completed' | 'failed', summary? }
+ *
+ * `coder-validator` 逐条验收 itemized 任务的验收条目时,每判完一条就调一次这个端点上报
+ * 那一条子任务的结果——不是解析它写的 checks[]/verdict 自由文本 JSON(那份格式在几个
+ * validator 之间只是约定俗成,从没被任何服务端代码强制校验过,见 graph-engine.js 的
+ * unsatisfiedRequiredProduces 一带)。状态改变走真实工具调用,这是本仓库一贯的做法。
+ *
+ * 只接受**子任务**(`parent_task_id` 非空):顶层 task 的终态由 run 收敛走
+ * `settleTaskFromRun` 那条路,这个端点不是它的替代品。
+ */
+router.post('/:companyId/tasks/:taskId/subtask-result', (req, res) => {
+ try {
+ const wdb = require('../services/work-graph-db');
+ const task = requireTask(req, res);
+ if (!task) return;
+ if (!task.parent_task_id) {
+ return res.status(400).json({
+ error: `${task.id} 不是子任务(没有 parent_task_id)——这个端点只服务 43 号文档物化出来的子任务,顶层任务的终态由 run 收敛决定`,
+ });
+ }
+ const { status, summary } = req.body || {};
+ if (!['completed', 'failed'].includes(status)) {
+ return res.status(400).json({ error: `status 只能是 'completed' 或 'failed',收到: ${JSON.stringify(status)}` });
+ }
+ const changed = wdb.settleSubtaskResult(task.id, status, summary || null);
+ res.json({ ok: true, changed: changed > 0, task: taskView(wdb.getTask(task.id)) });
+ } catch (err) {
+ console.error('[TaskEntry] Error:', err.message);
+ res.status(500).json({ error: err.message });
+ }
+});
+
+/**
* POST /api/company/:companyId/tasks/:taskId/enqueue — 单条入队(27-P3C §六.3)
*
* 与 `goals/:goalId/dispatch` 的差别只是粒度:那条把 goal 下所有 draft 一起推进队列,
diff --git a/server/services/graph-engine.js b/server/services/graph-engine.js
index a867b32..cae1226 100644
--- a/server/services/graph-engine.js
+++ b/server/services/graph-engine.js
@@ -62,6 +62,8 @@ const {
// 39 号文档「确定性通路」:回退熔断收敛时顺手开一条 draft task,见 maybeProposeReplanTask
createTask,
getGoal,
+ // 43 号文档:settleNode 成功路径顺手物化 itemized 子任务,见 maybeMaterializeItemizedSubtasks
+ getTask,
} = require('./work-graph-db');
/**
@@ -221,6 +223,11 @@ const ENGINE_FAIL_REASONS = Object.freeze({
// 会静默拿一份没有角色指导的提示词去跑真实 CLI(真实样本见 34 号文档 §五)。
// 字符串与招聘路径的 'body_not_ready' 同名同义,不是两套判词。
BODY_NOT_READY: 'body_not_ready',
+ // 43 号文档 Phase 3:coder-validator 判自己 success,但它验的 task 是 itemized 且
+ // 子任务还没全部 completed——agent 自己写的 checks[]/verdict 没人解析(见下面
+ // maybeCheckSubtasksComplete 的注释),真正说了算的是 tasks.status,这里同样是
+ // 把一个此前只进提示词的软约束收成硬闸门,不是发明新语义。
+ SUBTASKS_INCOMPLETE: 'subtasks_incomplete',
});
/** 归因/结构化产出文件名(agent.md「产出格式」:runs/{rootRunId}/nodes/{nodeRunId}/attempt-{n}.json) */
@@ -1845,9 +1852,35 @@ function settleNode(runId, nodeRunId, outcome = {}) {
timelineNote({ runId, nodeRunId }, 'node.output',
`节点声明必需的上游产物 ${missingInputs.join(', ')} 在索引里查不到实例,按 fail 结算`,
{ node_run_id: nodeRunId, missing_kinds: missingInputs, degraded: 'missing_required_input' });
+ } else {
+ // 43 号文档 Phase 3:coder-validator 判自己 success 之前,再核一遍它管的 task 是不是
+ // itemized 且有子任务——有的话必须全部 completed 才认这次 success。coder-validator
+ // 自己写的 checks[]/verdict 是没人解析的自由文本(同一段代码里 unsatisfiedRequiredProduces
+ // 的教训:软产物必须收成硬闸门,不能只指望 agent 自觉),真正说了算的是 tasks.status,
+ // 由 coder-validator 调 /subtask-result 端点逐条写(server/routes/company.js)。
+ const dRole = def0.nodes.get(nodeRun0.def_node_id);
+ if (dRole && dRole.systemAgentId === 'coder-validator' && run0.task_id) {
+ const subtasks = getDb().prepare(`SELECT id, status FROM tasks WHERE parent_task_id = ?`).all(run0.task_id);
+ const notDone = subtasks.filter((t) => t.status !== 'completed').map((t) => t.id);
+ if (notDone.length) {
+ to = 'fail';
+ failReason = ENGINE_FAIL_REASONS.SUBTASKS_INCOMPLETE;
+ timelineNote({ runId, nodeRunId }, 'node.output',
+ `这条 task 是 itemized,还有 ${notDone.length}/${subtasks.length} 条子任务没有上报 completed(${notDone.join(', ')}),不认这次 success`,
+ { node_run_id: nodeRunId, unresolved_subtasks: notDone, degraded: 'subtasks_incomplete' });
+ }
+ }
}
}
}
+ // 43 号文档:故意放在 !isSubgraphNode 这道闸门**之外**——org-planner 在 org 层的节点
+ // 类型是 subgraph(org 层节点全是 subgraph,见上面 isSubgraphNode 的注释),真正核实
+ // 生产数据发现它当前总是靠 workgraph-planning 展开成 planner.json 那张 5 节点子图,
+ // 不是直接一个 agent_call;acceptance.md(如果有)落在子 run 里,不在这个节点自己的
+ // 私有目录(subgraph 节点的 output_ref 恒为 null)。两种形态都要认,见函数内部。
+ if (to === 'success' && run0 && nodeRun0 && def0) {
+ maybeMaterializeItemizedSubtasks({ runId, nodeRunId, run: run0, nodeRun: nodeRun0, def: def0, isSubgraphNode });
+ }
}
const changed = sse.transitionNode(runId, nodeRunId, 'running', to, {
@@ -2207,6 +2240,109 @@ function maybeProposeReplanTask(runId, rootRunId, nodeRun, def, remediationNote)
}
/**
+ * 解析 org-planner 写的 acceptance.md(`| # | 验收项 | 判定方式 | pass 条件 |` 表格,
+ * 见 `system/agents/org-planner/tools.md` §3 的模板)。只信表格形状,不信位置——
+ * 列数不足 4、分隔行(`---`)、编号列不是纯数字的行一律跳过,不抛异常:格式不对
+ * 就是「没有可用的验收条目」,交给调用方按空数组处理。
+ */
+function parseAcceptanceMd(text) {
+ const rows = [];
+ for (const line of String(text || '').split('\n')) {
+ const m = line.match(/^\s*\|(.*)\|\s*$/);
+ if (!m) continue;
+ const cells = m[1].split('|').map((c) => c.trim());
+ if (cells.length < 4) continue;
+ const [seqCell, item, method, passCondition] = cells;
+ if (!/^\d+$/.test(seqCell) || !item) continue; // 表头 / 分隔行 / 编号写歪的行
+ rows.push({ seq: Number(seqCell), item, method, passCondition });
+ }
+ return rows;
+}
+
+/**
+ * 在一条 run 名下**全部**节点的私有目录里找 acceptance.md,返回第一份找到的绝对路径。
+ *
+ * 不假设固定节点名/固定模板:org-planner 到底展开成哪张 work graph(`planner.json` 的
+ * 5 节点 fan-out,还是别的骨架)是 workgraph-planning 现场选的,谁写 acceptance.md
+ * 因模板而异(43 号文档 Phase 2 核实真实生产数据后的结论),扫全部节点比认一个固定
+ * 节点名更不容易随模板改动跟着碎。
+ */
+function findAcceptanceMdFile(run) {
+ try {
+ const rows = getDb().prepare(`SELECT id FROM graph_run_nodes WHERE run_id = ?`).all(run.id);
+ for (const r of rows) {
+ const file = path.join(nodeArtifactDir(run, r.id), 'acceptance.md');
+ if (fs.existsSync(file)) return file;
+ }
+ } catch { /* 目录读不出来就当没有,best-effort */ }
+ return null;
+}
+
+/**
+ * 43 号文档 Phase 2:`task.acceptance_mode='itemized'` 时,org-planner 这一轮真正判定
+ * success 之后,把它(或它展开出的子图)写的 acceptance.md 逐行物化成子任务。
+ *
+ * org 层节点全是 `nodeKind='subgraph'`(见上面 isSubgraphNode 的注释),真实核实生产数据
+ * 发现 org-planner 现在总是靠 workgraph-planning 展开成子图在跑,不是直接一个 agent_call——
+ * 两种形态都要认:agent_call 就在它自己的私有目录找,subgraph 就去它的子 run 里全节点扫
+ * (见 findAcceptanceMdFile)。
+ *
+ * 纯 best-effort:查不到 task / 不是 itemized / 文件不存在 / 解析不出条目,一律安静跳过
+ * 并留一条 timeline note——**这一步失败绝不能把 org-planner 这一轮真正有价值的产出打成
+ * fail**,物化子任务是「顺手做的」,不是节点判定的一部分(33 号文档的教训:把一个软产物
+ * 当成硬闸门,会把整条链路拖进死循环)。
+ *
+ * 幂等:父任务名下已经有子任务就不再重复建——覆盖的是节点被 retry 后二次结算 success
+ * 这种情况,不是想同步 acceptance.md 后续的编辑(那需要人工介入,不是这一步的职责)。
+ */
+function maybeMaterializeItemizedSubtasks({ runId, nodeRunId, run, nodeRun, def, isSubgraphNode }) {
+ try {
+ const d = def && nodeRun && def.nodes.get(nodeRun.def_node_id);
+ if (!d || d.systemAgentId !== 'org-planner') return;
+ if (!run.task_id) return;
+
+ const task = getTask(run.task_id);
+ if (!task || task.acceptance_mode !== 'itemized') return;
+
+ const already = getDb().prepare(`SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ?`).get(task.id).n;
+ if (already > 0) return;
+
+ const searchRun = isSubgraphNode ? getRun(nodeRun.child_run_id) : run;
+ const file = searchRun ? findAcceptanceMdFile(searchRun) : null;
+ if (!file) {
+ timelineNote({ runId, nodeRunId }, 'node.output',
+ `task.acceptance_mode='itemized' 但没找到 acceptance.md,跳过子任务物化(仍按 holistic 收尾)`,
+ { node_run_id: nodeRunId, task_id: task.id, degraded: 'acceptance_md_missing' });
+ return;
+ }
+
+ const rows = parseAcceptanceMd(fs.readFileSync(file, 'utf-8'));
+ if (!rows.length) {
+ timelineNote({ runId, nodeRunId }, 'node.output',
+ `acceptance.md 存在但解析不出任何验收条目(表格形状不对?),跳过子任务物化`,
+ { node_run_id: nodeRunId, task_id: task.id, degraded: 'acceptance_md_unparseable' });
+ return;
+ }
+
+ for (const row of rows) {
+ createTask({
+ goalId: task.goal_id,
+ companyId: task.company_id,
+ title: row.item.slice(0, 120),
+ description: `判定方式:${row.method || '(未写明)'}\npass 条件:${row.passCondition || '(未写明)'}`,
+ createdBy: 'org-planner',
+ parentTaskId: task.id,
+ });
+ }
+ timelineNote({ runId, nodeRunId }, 'node.output',
+ `按 acceptance.md 物化了 ${rows.length} 条子任务`,
+ { node_run_id: nodeRunId, task_id: task.id, subtask_count: rows.length });
+ } catch (err) {
+ console.error('[graph-engine] maybeMaterializeItemizedSubtasks 失败(不影响节点结算):', err.message);
+ }
+}
+
+/**
* 解析跨层地址 → 「该退到哪个子 run 的哪一行」(**纯查询,不改任何状态**)。
*
* 输入的地址来自失败节点行的 `fail_node_ref`(三值透传的第四值,见 normalizeNodeRef 那段)。
diff --git a/server/services/task-planning.js b/server/services/task-planning.js
index a61702d..90c993c 100644
--- a/server/services/task-planning.js
+++ b/server/services/task-planning.js
@@ -42,8 +42,14 @@ const PLAN_MAX_ROUNDS = 3;
*/
const MAX_TASKS = 8;
-/** 产出里允许出现的键。多余键一律按 schema 打回:那些字段要么服务端生成、要么人在界面上定 */
-const ALLOWED_KEYS = new Set(['seq', 'title', 'description', 'depends_on', 'dependsOn']);
+/** 产出里允许出现的键。多余键一律按 schema 打回:那些字段要么服务端生成、要么人在界面上定
+ *
+ * 43 号文档:`acceptance_mode` 是这四个字段之外**唯一**的例外——要不要按验收条目拆子任务,
+ * 是一次语义判断(跟「要不要拆多个 task」用的是同一种判断力),没法像 mode/priority/budget
+ * 那样交给服务端按 goal 级默认值机械注入,所以必须让 agent 自己决定、自己写出来。
+ */
+const ALLOWED_KEYS = new Set(['seq', 'title', 'description', 'depends_on', 'dependsOn', 'acceptance_mode', 'acceptanceMode']);
+const ACCEPTANCE_MODES = new Set(['holistic', 'itemized']);
// ────────────────────────────────────────────────────────────────
// 注入点(仿 graph-engine.setPlanWorkGraph)
@@ -76,7 +82,7 @@ function planTasks(ctx) {
*
* @param {{companyId: string, goal: object}} ctx goal 是 goals 表的整行
* @returns {Promise<{specs: object[], tokens: number, rounds: number}>}
- * specs 形如 `[{seq, title, description, depends_on: [seq…]}]`,seq 恒为 1..N 连续
+ * specs 形如 `[{seq, title, description, depends_on: [seq…], acceptance_mode}]`,seq 恒为 1..N 连续
* @throws 轮次耗尽 / 调用本身炸了;两种情况都在 error 上挂了 `tokens`(已烧掉的那部分)
*/
async function defaultPlanTasks(ctx) {
@@ -203,14 +209,20 @@ function buildTaskPlanningPrompt(ctx, { round = 1, errors = null, lastOutput = '
describeHiredRoles(ctx.companyId),
'',
'## 交回方式(引擎只读你回复里最后一个 ```json 代码块)',
- '顶层是**数组**,每个元素恰好四个字段:',
+ '顶层是**数组**,每个元素四个必填字段 + 一个可选字段:',
'```json',
- '[{"seq":1,"title":"…","description":"…","depends_on":[]}]',
+ '[{"seq":1,"title":"…","description":"…","depends_on":[],"acceptance_mode":"holistic"}]',
'```',
`- \`seq\`:1..N 连续整数、不重复、与数组顺序一致;条数上限 **${MAX_TASKS}**。`,
'- `depends_on`:只能写本次产出的 `seq` 数字(1-based)。**不许写 task id** —— id 由服务端'
+ '在你交回之后才生成,你根本看不到;写字符串一定被打回。没有依赖就写 `[]`(不能省)。',
- '- 只输出这四个字段。`id` / `status` / `mode` / `budget` / `priority` / `agent` 一律不许出现。',
+ '- `acceptance_mode`(可选,缺省 `"holistic"`):这条 task 的验收后续要不要按验收条目拆成'
+ + '子任务逐条判——判断轴跟 Step 2 判「要不要拆多个 task」是同一种(够不够独立成立、值不值得'
+ + '单独验收)。多数任务留空/写 `"holistic"` 即可;只有一件对外是单一交付物、但内部有多条'
+ + '可以各自独立验收的验收标准的任务,才写 `"itemized"`。不确定就留 `"holistic"`——错误地'
+ + '拆细比保守合并的代价更高。',
+ '- 只输出这五个字段(`acceptance_mode` 可以不写)。`id` / `status` / `mode` / `budget`'
+ + ' / `priority` / `agent` 一律不许出现。',
'- 每条 `description` 必须**自包含**:下游每条 task 起独立的 run,那个 run 只看得到自己'
+ '这一条描述(它会成为该 run 的 task-goal.json),不许写「同上」「见 task 1」这类跨条引用。',
'- 拆分理由写在 JSON 代码块**前面**的正文里;代码块之后不要再写任何字。',
@@ -370,7 +382,18 @@ function validatePlannedTasks(raw) {
}
const extra = Object.keys(item).filter((k) => !ALLOWED_KEYS.has(k));
if (extra.length) {
- errors.push({ rule: 'schema', detail: `${at} 有多余键 ${extra.join(' / ')}:只许 seq / title / description / depends_on` });
+ errors.push({ rule: 'schema', detail: `${at} 有多余键 ${extra.join(' / ')}:只许 seq / title / description / depends_on / acceptance_mode` });
+ }
+
+ // 没写就是没判断要拆——缺省 holistic(保守,不引入新行为),不是错误
+ const rawMode = item.acceptance_mode !== undefined ? item.acceptance_mode : item.acceptanceMode;
+ let acceptanceMode = 'holistic';
+ if (rawMode !== undefined && rawMode !== null) {
+ if (!ACCEPTANCE_MODES.has(rawMode)) {
+ errors.push({ rule: 'schema', detail: `${at} 的 acceptance_mode 只能是 "holistic" 或 "itemized",收到 ${JSON.stringify(rawMode)}` });
+ } else {
+ acceptanceMode = rawMode;
+ }
}
const description = String(item.description == null ? '' : item.description).trim();
@@ -406,6 +429,7 @@ function validatePlannedTasks(raw) {
title: String(item.title == null ? '' : item.title).trim(),
description,
depends_on: deps,
+ acceptance_mode: acceptanceMode,
});
});
diff --git a/server/services/work-graph-db.js b/server/services/work-graph-db.js
index 88f4136..bf2223f 100644
--- a/server/services/work-graph-db.js
+++ b/server/services/work-graph-db.js
@@ -531,6 +531,10 @@ function getDb() {
-- queued 现在就在取值域里,但排队逻辑(调度器 / run_status='queued')另批实现:
-- 取值域是数据形状的一部分,先钉死它,免得批 3 再来改一次列注释
execution_mode TEXT NOT NULL DEFAULT 'manual' CHECK (execution_mode IN ('manual','auto')),
+ -- 43 号文档:itemized 的 task 由 org-planner 收尾时按验收条目物化出子任务
+ -- (parent_task_id 指回这一行);holistic 保持现状「一次性给总分」不物化任何东西。
+ -- 子任务本身恒为 holistic(不允许递归再拆一层),见 createTask 里的校验。
+ acceptance_mode TEXT NOT NULL DEFAULT 'holistic' CHECK (acceptance_mode IN ('holistic','itemized')),
depends_on TEXT NOT NULL DEFAULT '[]', -- JSON 数组,同 goal 内的 task id
budget_json TEXT,
current_run_id TEXT, -- 最近一次 root run(逻辑外键,见 §四.6)
@@ -632,6 +636,11 @@ function getDb() {
if (!hasColumn(db, 'tasks', 'execution_conv_id')) {
db.exec(`ALTER TABLE tasks ADD COLUMN execution_conv_id TEXT`);
}
+ // 43 号文档:ADD COLUMN 加不了 CHECK(同上 executor 那条注释的 SQLite 限制),
+ // 存量库上 acceptance_mode 的取值域只由 createTask 的应用层校验守。
+ if (!hasColumn(db, 'tasks', 'acceptance_mode')) {
+ db.exec(`ALTER TABLE tasks ADD COLUMN acceptance_mode TEXT NOT NULL DEFAULT 'holistic'`);
+ }
// ⚠️ 这条索引**必须建在 ALTER 之后**,不能放进上面的建表块:
// 建表块整体先于迁移块执行,而存量库上 `CREATE TABLE IF NOT EXISTS tasks` 是 no-op ——
// 表还是批 4 的形态、没有 assignee_agent_id,那一句 CREATE INDEX 会直接抛
@@ -1856,6 +1865,10 @@ function validateDependsOn(goalId, taskId, dependsOn) {
* **必须能在建行时就存下来**(缺陷 F3):草稿路径(`POST /:cid/tasks` 撞 409 → 存草稿 →
* 之后 dispatch)上,请求体里的 budget 早就不在了。只有写侧没有读侧的列 = 用户填了、
* 接口回了 200、run 却拿着 DEFAULT_TASK_BUDGET 在跑,中途预算耗尽 blocked 且没人知道为什么。
+ * @param {string|null} [p.parentTaskId] 43 号文档:子任务指回父任务。图流水线(`insertTasksForGoal`)
+ * 建的顶层 task 恒为 null;只有 org-planner 收尾物化子任务时才传。
+ * @param {string} [p.acceptanceMode='holistic'] 'holistic' | 'itemized'。子任务恒为 'holistic'
+ * (不允许递归再拆一层)——传 `parentTaskId` 时若给了 'itemized' 会被拒。
* @returns {object} 刚插入的整行
*/
function createTask({
@@ -1870,14 +1883,25 @@ function createTask({
budgetJson = null,
reportTo = null,
createdBy = null,
+ parentTaskId = null,
+ acceptanceMode = 'holistic',
} = {}) {
if (!goalId) throw new Error('createTask: goalId 必填');
if (!title) throw new Error('createTask: title 必填');
if (!description) throw new Error('createTask: description 必填');
+ if (!['holistic', 'itemized'].includes(acceptanceMode)) {
+ throw new Error(`createTask: acceptanceMode 非法: ${acceptanceMode}`);
+ }
+ if (parentTaskId && acceptanceMode === 'itemized') {
+ throw new Error('createTask: 子任务(有 parentTaskId)不允许再声明 itemized,不支持递归拆分');
+ }
const db = getDb();
const goal = getGoal(goalId);
if (!goal) throw new Error(`createTask: goal 不存在: ${goalId}`);
+ if (parentTaskId && !getTask(parentTaskId)) {
+ throw new Error(`createTask: parentTaskId 不存在: ${parentTaskId}`);
+ }
// company_id 这一列冗余存在的**唯一**理由是让调度器不必 JOIN;让调用方各传各的,
// 就等于给这份冗余留了一条能对不上的路。默认从 goal 抄,传了也以 goal 为准的那种「以防万一」
@@ -1902,8 +1926,8 @@ function createTask({
const ins = db.prepare(`
INSERT INTO tasks (id, goal_id, company_id, seq, title, description, status,
execution_mode, depends_on, priority, budget_json, report_to, created_by,
- created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?)
+ parent_task_id, acceptance_mode, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const id = insertWithFreshId('tasks', genTaskId, (tid) => ins.run(
tid, goalId, String(cid), nextSeq, String(title), String(description),
@@ -1911,7 +1935,7 @@ function createTask({
// report_to 不落库就等于没有:请求作用域的闭包活不过一次排队(§四.7 禁令二)。
// goal 上有就继承下来,别让每个调用方各记一遍。
reportTo || goal.report_to || null,
- createdBy, now, now
+ createdBy, parentTaskId, acceptanceMode, now, now
));
return getTask(id);
}
@@ -1937,7 +1961,9 @@ function listGoalTasks(goalId, { includeArchived = false } = {}) {
* 这里再写一份就是第二个持有者 —— canDispatch 的取值域被前端复述了一遍、还多写一个值,
* 导致公司失败一次后再也下不了任务,就是这么来的。要位次找调度器要。
*/
-function listCompanyTasks(companyId, { status = null, goalId = null, includeArchived = false, limit = null } = {}) {
+function listCompanyTasks(companyId, {
+ status = null, goalId = null, parentTaskId = null, includeArchived = false, limit = null,
+} = {}) {
const where = ['company_id = @companyId'];
const params = { companyId };
if (status) {
@@ -1948,6 +1974,12 @@ function listCompanyTasks(companyId, { status = null, goalId = null, includeArch
where.push('goal_id = @goalId');
params.goalId = goalId;
}
+ // 43 号文档 Phase 3:coder-validator 用它列出「这个 itemized 任务物化出了哪些子任务」,
+ // 才知道要逐条上报哪几个 taskId
+ if (parentTaskId) {
+ where.push('parent_task_id = @parentTaskId');
+ params.parentTaskId = parentTaskId;
+ }
if (!includeArchived) where.push('archived_at IS NULL');
const lim = Number.isInteger(limit) && limit > 0 ? ` LIMIT ${limit}` : '';
return getDb()
@@ -2084,6 +2116,31 @@ function settleTaskFromRun(taskId, runId, status) {
}
/**
+ * 43 号文档 Phase 3:coder-validator 逐条上报子任务验收结果的落库口。
+ *
+ * 与 `settleTaskFromRun` 同款条件更新(终态幂等闸门):子任务可能因为 coder-validator
+ * 被 retry 而重复上报,已经是终态的行不该被后一次调用覆盖。**只服务子任务**——
+ * `parent_task_id IS NOT NULL` 这道 WHERE 挡住顶层 task(它的终态走 run 收敛那条路,
+ * 不该被这个端点绕开)。
+ *
+ * @returns {number} 受影响行数;0 = 已经是终态 / 不是子任务,调用方据此判断这次上报有没有生效
+ */
+function settleSubtaskResult(taskId, status, summary = null) {
+ if (!['completed', 'failed'].includes(status)) {
+ throw new Error(`settleSubtaskResult: status 只能是 'completed' 或 'failed',收到: ${status}`);
+ }
+ const now = new Date().toISOString();
+ return getDb()
+ .prepare(`
+ UPDATE tasks SET status = @status, summary = @summary, finished_at = @now, updated_at = @now
+ WHERE id = @id
+ AND parent_task_id IS NOT NULL
+ AND status NOT IN (${TASK_TERMINAL_STATUS.map((s) => `'${s}'`).join(', ')})
+ `)
+ .run({ id: taskId, status, summary, now }).changes;
+}
+
+/**
* 「上游已经不可能满足了」这个 blocked 的原因前缀(27-P3C §五.1.2)。
*
* 它同时是**判据**:只有 `blocked_reason` 以它开头的 blocked task 才是调度器**推导**出来的,
@@ -2715,6 +2772,7 @@ module.exports = {
taskProgress,
maxTaskSeq,
settleTaskFromRun,
+ settleSubtaskResult,
GRAPH_TAKEOVER_ASSIGNEE,
// 批 3 排队:三条都是条件更新,调用方靠 changes 判「是不是我做的」
claimQueuedTask,
diff --git a/server/tests/unit/company-goal-task-routes.test.js b/server/tests/unit/company-goal-task-routes.test.js
index 037dd8b..6447ff2 100644
--- a/server/tests/unit/company-goal-task-routes.test.js
+++ b/server/tests/unit/company-goal-task-routes.test.js
@@ -400,7 +400,8 @@ describe('company goal/task routes(27-P3C §六)', () => {
const ok = validatePlannedTasks([{ seq: 1, title: 'a', description: 'A', depends_on: [] }]);
expect(ok.ok).toBe(true);
- expect(ok.specs[0]).toEqual({ seq: 1, title: 'a', description: 'A', depends_on: [] });
+ // 43 号文档:acceptance_mode 没写就缺省 'holistic'
+ expect(ok.specs[0]).toEqual({ seq: 1, title: 'a', description: 'A', depends_on: [], acceptance_mode: 'holistic' });
const rules = (raw) => validatePlannedTasks(raw).errors.map((e) => e.rule);
// seq 与数组顺序错位:错位后 depends_on 引的是另一条 task,而那条错误依赖完全合法,
@@ -415,6 +416,9 @@ describe('company goal/task routes(27-P3C §六)', () => {
expect(rules(Array.from({ length: 9 }, (_, i) => ({ seq: i + 1, description: 'x', depends_on: [] }))))
.toContain('limit');
expect(rules([{ seq: 1, description: 'A', depends_on: [], mode: 'auto' }])).toContain('schema');
+ // 43 号文档:acceptance_mode 是允许出现的第五个键,itemized 合法、乱写值按 schema 打回
+ expect(validatePlannedTasks([{ seq: 1, description: 'A', depends_on: [], acceptance_mode: 'itemized' }]).ok).toBe(true);
+ expect(rules([{ seq: 1, description: 'A', depends_on: [], acceptance_mode: 'bogus' }])).toContain('schema');
// 正向引用(第 1 条依赖第 2 条)不成环,是合法输入 —— 批 1 缺陷 4 就是被它咬的
expect(validatePlannedTasks([
diff --git a/server/tests/unit/graph-engine-fail-reason.test.js b/server/tests/unit/graph-engine-fail-reason.test.js
index 6afbe21..89caabd 100644
--- a/server/tests/unit/graph-engine-fail-reason.test.js
+++ b/server/tests/unit/graph-engine-fail-reason.test.js
@@ -123,7 +123,7 @@ describe('引擎自判失败的 fail_reason 与失败上下文(W12 补完 / W9
// W12 补完 · 三类引擎自判失败都在 fail_reason 上留名
// ────────────────────────────────────────────────────────────────
- it('取值域:六个引擎取值与 agent 的归因取值域**不重叠**(复用会劫持精确 fail 边的路由)', () => {
+ it('取值域:七个引擎取值与 agent 的归因取值域**不重叠**(复用会劫持精确 fail 边的路由)', () => {
const engineReasons = Object.values(engine.ENGINE_FAIL_REASONS);
expect(engineReasons).toEqual([
'node_error', 'boundary_escape', 'child_run_failed',
@@ -131,6 +131,9 @@ describe('引擎自判失败的 fail_reason 与失败上下文(W12 补完 / W9
'missing_required_input', 'produces_missing',
// 35 号文档提案 A:本体完整性闸门(runClaimedNode 派发前检查)新增的取值
'body_not_ready',
+ // 43 号文档 Phase 3:coder-validator 判自己 success 但 itemized 任务的子任务没全部
+ // completed 时新增的取值(settleNode 的 success 判定里,与上面两个契约闸门同一处)
+ 'subtasks_incomplete',
]);
// infra_flake 在 routeFailure 里有特判(原地重跑、不烧计数)。任何一个引擎取值撞上它
// 都会让确定性失败(如 Agent not found)原地重跑到预算耗尽。
diff --git a/server/tests/unit/task-acceptance-mode.test.js b/server/tests/unit/task-acceptance-mode.test.js
new file mode 100644
index 0000000..8b16178
--- /dev/null
+++ b/server/tests/unit/task-acceptance-mode.test.js
@@ -0,0 +1,72 @@
+/**
+ * 43 号文档 Phase 0:`tasks.acceptance_mode` + `createTask({parentTaskId})` 的落库校验。
+ *
+ * 隔离:先设 GRAPH_DB_PATH 再 require(清 require.cache),仓库 data/graph.sqlite 全程不碰
+ * ——写法与 graph-edge-contracts.test.js 同源。
+ */
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import fs from 'fs';
+
+const TEST_DB_PATH = '/tmp/test-task-acceptance-mode.sqlite';
+const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`];
+
+describe('43 号文档 Phase 0:acceptance_mode / parentTaskId', () => {
+ let wdb;
+
+ beforeAll(() => {
+ for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
+ process.env.GRAPH_DB_PATH = TEST_DB_PATH;
+ delete require.cache[require.resolve('../../services/graph-db')];
+ delete require.cache[require.resolve('../../services/work-graph-db')];
+ wdb = require('../../services/work-graph-db');
+ });
+
+ afterAll(() => {
+ for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
+ delete process.env.GRAPH_DB_PATH;
+ delete require.cache[require.resolve('../../services/graph-db')];
+ delete require.cache[require.resolve('../../services/work-graph-db')];
+ });
+
+ function goalId() {
+ const g = wdb.createGoal({ companyId: 'co-amode', title: 'g', description: 'd' });
+ return g.id;
+ }
+
+ it('缺省 acceptance_mode=holistic、parent_task_id=null(图流水线现状不变)', () => {
+ const t = wdb.createTask({ goalId: goalId(), title: 't', description: 'd' });
+ expect(t.acceptance_mode).toBe('holistic');
+ expect(t.parent_task_id).toBeNull();
+ });
+
+ it('可以显式建一条 itemized 的顶层 task', () => {
+ const t = wdb.createTask({ goalId: goalId(), title: 't', description: 'd', acceptanceMode: 'itemized' });
+ expect(t.acceptance_mode).toBe('itemized');
+ });
+
+ it('非法 acceptanceMode 直接拒', () => {
+ expect(() => wdb.createTask({ goalId: goalId(), title: 't', description: 'd', acceptanceMode: 'bogus' }))
+ .toThrow(/acceptanceMode 非法/);
+ });
+
+ it('子任务:parentTaskId 指回父任务,acceptance_mode 恒 holistic', () => {
+ const gid = goalId();
+ const parent = wdb.createTask({ goalId: gid, title: 'parent', description: 'd', acceptanceMode: 'itemized' });
+ const child = wdb.createTask({ goalId: gid, title: 'child A.1', description: 'd', parentTaskId: parent.id });
+ expect(child.parent_task_id).toBe(parent.id);
+ expect(child.acceptance_mode).toBe('holistic');
+ });
+
+ it('子任务不允许递归声明 itemized(不支持二级拆分)', () => {
+ const gid = goalId();
+ const parent = wdb.createTask({ goalId: gid, title: 'parent', description: 'd' });
+ expect(() => wdb.createTask({
+ goalId: gid, title: 'child', description: 'd', parentTaskId: parent.id, acceptanceMode: 'itemized',
+ })).toThrow(/不支持递归拆分/);
+ });
+
+ it('parentTaskId 指向不存在的 task 直接拒,不静默建成孤儿行', () => {
+ expect(() => wdb.createTask({ goalId: goalId(), title: 't', description: 'd', parentTaskId: 'T-not-exist' }))
+ .toThrow(/parentTaskId 不存在/);
+ });
+});
diff --git a/server/tests/unit/task-decomposition-materialize.test.js b/server/tests/unit/task-decomposition-materialize.test.js
new file mode 100644
index 0000000..d2b8aee
--- /dev/null
+++ b/server/tests/unit/task-decomposition-materialize.test.js
@@ -0,0 +1,152 @@
+/**
+ * 43 号文档 Phase 2:org-planner 节点结算 success 后,把 acceptance.md 物化成子任务。
+ *
+ * org 层节点全是 subgraph(isSubgraphNode 恒真),真正的产出在 workgraph-planning 展开出的
+ * 子 run 里——所以这里跟 goal-task-acceptance.test.js 同源,用 `setPlanWorkGraph` 注入假
+ * 规划(返回一张手造的单节点 work graph),再用 `setAgentExecutor` 注入那个节点的假执行,
+ * 不真起 Claude CLI。
+ */
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+
+const TEST_DB_PATH = '/tmp/test-task-decomp-materialize.sqlite';
+const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`];
+const MODULES = [
+ '../../services/graph-db',
+ '../../services/graph-service',
+ '../../services/org-graph-service',
+ '../../services/work-graph-db',
+ '../../services/graph-run-sse',
+ '../../services/graph-engine',
+];
+
+const ORG_NODES = ['org-planner', 'org-coder', 'org-validator'];
+const ORG_EDGES = [
+ { from: 'org-planner', to: 'org-coder', condition: 'success' },
+ { from: 'org-coder', to: 'org-validator', condition: 'success' },
+];
+
+const ACCEPTANCE_MD = `# 验收清单
+
+| # | 验收项 | 判定方式 | pass 条件 |
+|---|--------|----------|-----------|
+| 1 | 方向键控制移动 | 手动操作 | 蛇按方向键移动 |
+| 2 | 吃到食物加分 | 手动操作 | 分数 +1 |
+`;
+
+describe('43 号文档 Phase 2:acceptance.md → 子任务物化', () => {
+ let wdb;
+ let org;
+ let engine;
+ let runsDir;
+
+ beforeAll(() => {
+ for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
+ process.env.GRAPH_DB_PATH = TEST_DB_PATH;
+ runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'task-decomp-runs-'));
+ process.env.GRAPH_RUNS_DIR = runsDir;
+ for (const m of MODULES) delete require.cache[require.resolve(m)];
+ wdb = require('../../services/work-graph-db');
+ org = require('../../services/org-graph-service');
+ engine = require('../../services/graph-engine');
+ });
+
+ afterAll(() => {
+ for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
+ delete process.env.GRAPH_DB_PATH;
+ try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {}
+ delete process.env.GRAPH_RUNS_DIR;
+ for (const m of MODULES) delete require.cache[require.resolve(m)];
+ });
+
+ function ensureAgent(id) {
+ const db = wdb.getDb();
+ const now = new Date().toISOString();
+ db.prepare(`INSERT OR IGNORE INTO system_agents
+ (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at)
+ VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?)`)
+ .run(id, id, `system/agents/${id}`, now, now);
+ }
+
+ /** 建一家公司的 org 图 + 一条 task,起一条 org run,跑到 org-planner(含它展开的子图)结算为止 */
+ async function setupAndRunPlanner(companyId, { acceptanceMode = 'itemized', writeFile = true } = {}) {
+ expect(org.writeGraph(companyId, { nodes: ORG_NODES, edges: ORG_EDGES }).ok).toBe(true);
+ ensureAgent('planner-worker');
+
+ const goal = wdb.createGoal({ companyId, title: 'g', description: '贪吃蛇游戏' });
+ const task = wdb.createTask({
+ goalId: goal.id, title: '实现贪吃蛇', description: 'd', acceptanceMode,
+ });
+
+ const run = wdb.createRun({ graphKind: 'org', graphId: companyId, companyId, taskId: task.id });
+ engine.markRunReady(run.id);
+ engine.startRun(run.id, { mode: 'manual' });
+
+ engine.setPlanWorkGraph(async () => {
+ const g = wdb.createWorkGraph({
+ companyId, nodes: [{ nodeName: 'planner-worker', systemAgentId: 'planner-worker' }], edges: [],
+ });
+ expect(g.errors || []).toEqual([]);
+ return { workGraphId: g.id };
+ });
+ let childRunId;
+ try {
+ const planned = await engine.nextStep(run.id); // org-planner:规划出子图,父节点转 running 等子 run
+ childRunId = planned.node && planned.node.childRunId;
+ } finally {
+ engine.setPlanWorkGraph(null);
+ }
+ expect(childRunId).toBeTruthy();
+
+ engine.setAgentExecutor(async (ctx) => {
+ const dir = path.join(runsDir, ctx.run.root_run_id, 'nodes', ctx.nodeRunId);
+ fs.mkdirSync(dir, { recursive: true });
+ if (writeFile) fs.writeFileSync(path.join(dir, 'acceptance.md'), ACCEPTANCE_MD);
+ return { status: 'success', outputRef: dir };
+ });
+ try {
+ await engine.advanceOne(childRunId); // 子图里唯一的那个节点
+ } finally {
+ engine.setAgentExecutor(null);
+ }
+ return { task, run, childRunId };
+ }
+
+ it('itemized + acceptance.md 存在 → org-planner 结算 success 后按表格行数物化子任务', async () => {
+ const { task, run } = await setupAndRunPlanner('co-materialize-1');
+ const plannerNode = wdb.getDb().prepare(`
+ SELECT status FROM graph_run_nodes WHERE run_id = ?
+ AND def_node_id = (SELECT id FROM org_graph_nodes WHERE company_id='co-materialize-1' AND role_name='org-planner')
+ `).get(run.id);
+ expect(plannerNode.status).toBe('success'); // 前提:子图真的收敛回了父节点
+
+ const children = wdb.getDb()
+ .prepare(`SELECT * FROM tasks WHERE parent_task_id = ? ORDER BY created_at`)
+ .all(task.id);
+ expect(children).toHaveLength(2);
+ expect(children[0].title).toBe('方向键控制移动');
+ expect(children[0].acceptance_mode).toBe('holistic');
+ expect(children[0].description).toContain('手动操作');
+ expect(children[0].description).toContain('蛇按方向键移动');
+ expect(children[1].title).toBe('吃到食物加分');
+ });
+
+ it('holistic 任务:org-planner 成功结算,不物化任何子任务', async () => {
+ const { task } = await setupAndRunPlanner('co-materialize-2', { acceptanceMode: 'holistic' });
+ const n = wdb.getDb().prepare(`SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ?`).get(task.id).n;
+ expect(n).toBe(0);
+ });
+
+ it('itemized 但 acceptance.md 不存在:org-planner 仍判 success,不物化子任务(软失败,不拖累主产出)', async () => {
+ const { task, run } = await setupAndRunPlanner('co-materialize-3', { writeFile: false });
+ const n = wdb.getDb().prepare(`SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ?`).get(task.id).n;
+ expect(n).toBe(0);
+ const plannerNode = wdb.getDb().prepare(`
+ SELECT status FROM graph_run_nodes WHERE run_id = ?
+ AND def_node_id = (SELECT id FROM org_graph_nodes WHERE company_id='co-materialize-3' AND role_name='org-planner')
+ `).get(run.id);
+ expect(plannerNode.status).toBe('success'); // 找不到 acceptance.md 不该把这一步打成 fail
+ });
+});
diff --git a/server/tests/unit/task-decomposition-validator-gate.test.js b/server/tests/unit/task-decomposition-validator-gate.test.js
new file mode 100644
index 0000000..eaaaef9
--- /dev/null
+++ b/server/tests/unit/task-decomposition-validator-gate.test.js
@@ -0,0 +1,143 @@
+/**
+ * 43 号文档 Phase 3:coder-validator 判自己 success 之前,子任务必须全部 completed
+ * 才认;以及 settleSubtaskResult 这个落库口本身的条件更新/幂等/校验。
+ */
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+
+const TEST_DB_PATH = '/tmp/test-task-decomp-validator-gate.sqlite';
+const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`];
+const MODULES = [
+ '../../services/graph-db',
+ '../../services/graph-service',
+ '../../services/org-graph-service',
+ '../../services/work-graph-db',
+ '../../services/graph-run-sse',
+ '../../services/graph-engine',
+];
+
+describe('43 号文档 Phase 3', () => {
+ let wdb;
+ let engine;
+ let runsDir;
+
+ beforeAll(() => {
+ for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
+ process.env.GRAPH_DB_PATH = TEST_DB_PATH;
+ runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'task-decomp-gate-runs-'));
+ process.env.GRAPH_RUNS_DIR = runsDir;
+ for (const m of MODULES) delete require.cache[require.resolve(m)];
+ wdb = require('../../services/work-graph-db');
+ engine = require('../../services/graph-engine');
+ });
+
+ afterAll(() => {
+ for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
+ delete process.env.GRAPH_DB_PATH;
+ try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {}
+ delete process.env.GRAPH_RUNS_DIR;
+ for (const m of MODULES) delete require.cache[require.resolve(m)];
+ });
+
+ function ensureAgent(id) {
+ const db = wdb.getDb();
+ const now = new Date().toISOString();
+ db.prepare(`INSERT OR IGNORE INTO system_agents
+ (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at)
+ VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?)`)
+ .run(id, id, `system/agents/${id}`, now, now);
+ }
+
+ // ── settleSubtaskResult 本身 ─────────────────────────────────────
+
+ it('settleSubtaskResult:非法 status 直接拒', () => {
+ expect(() => wdb.settleSubtaskResult('T-whatever', 'running')).toThrow(/status 只能是/);
+ });
+
+ it('settleSubtaskResult:不是子任务(parent_task_id 为空)不生效,changes=0', () => {
+ const goal = wdb.createGoal({ companyId: 'co-gate-1', title: 'g', description: 'd' });
+ const top = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' });
+ const changed = wdb.settleSubtaskResult(top.id, 'completed');
+ expect(changed).toBe(0);
+ expect(wdb.getTask(top.id).status).toBe('draft'); // 没被动过
+ });
+
+ it('settleSubtaskResult:正常上报 + 终态幂等(第二次不同结果上报不生效)', () => {
+ const goal = wdb.createGoal({ companyId: 'co-gate-2', title: 'g', description: 'd' });
+ const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd', acceptanceMode: 'itemized' });
+ const child = wdb.createTask({ goalId: goal.id, title: 'c', description: 'd', parentTaskId: parent.id });
+
+ expect(wdb.settleSubtaskResult(child.id, 'completed', '通过')).toBe(1);
+ const after1 = wdb.getTask(child.id);
+ expect(after1.status).toBe('completed');
+ expect(after1.summary).toBe('通过');
+ expect(after1.finished_at).toBeTruthy();
+
+ // 已经是终态,再上报 failed 不该覆盖——防的是 coder-validator 因 retry 重复上报
+ expect(wdb.settleSubtaskResult(child.id, 'failed', '改判')).toBe(0);
+ expect(wdb.getTask(child.id).status).toBe('completed');
+ });
+
+ // ── 引擎侧的闸门:coder-validator 判 success 前核子任务 ─────────────
+
+ async function runValidator(companyId, taskId) {
+ ensureAgent('coder-validator');
+ const g = wdb.createWorkGraph({
+ companyId,
+ taskId,
+ nodes: [{ nodeName: 'v', systemAgentId: 'coder-validator' }],
+ edges: [],
+ });
+ expect(g.errors || []).toEqual([]);
+ const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId, taskId });
+ engine.markRunReady(run.id);
+ engine.startRun(run.id, { mode: 'manual' });
+ engine.setAgentExecutor(async () => ({ status: 'success' }));
+ let out;
+ try {
+ out = await engine.advanceOne(run.id);
+ } finally {
+ engine.setAgentExecutor(null);
+ }
+ return out;
+ }
+
+ it('itemized 任务 + 子任务还没全部 completed → coder-validator 判自己 success 但被引擎打回 fail', async () => {
+ const companyId = 'co-gate-3';
+ const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
+ const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd', acceptanceMode: 'itemized' });
+ const c1 = wdb.createTask({ goalId: goal.id, title: 'c1', description: 'd', parentTaskId: parent.id });
+ wdb.createTask({ goalId: goal.id, title: 'c2', description: 'd', parentTaskId: parent.id });
+ wdb.settleSubtaskResult(c1.id, 'completed'); // 只完成一半
+
+ const out = await runValidator(companyId, parent.id);
+ // 单节点图没有兜底边可退,fail 之后引擎会继续把它收敛成 blocked(等人)——
+ // 这里要验的是「没被判 success」+「归因是 subtasks_incomplete」,不是具体停在哪个终态
+ expect(out.node.status).not.toBe('success');
+ expect(out.node.failReason).toBe('subtasks_incomplete');
+ });
+
+ it('itemized 任务 + 子任务全部 completed → coder-validator 正常判 success', async () => {
+ const companyId = 'co-gate-4';
+ const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
+ const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd', acceptanceMode: 'itemized' });
+ const c1 = wdb.createTask({ goalId: goal.id, title: 'c1', description: 'd', parentTaskId: parent.id });
+ const c2 = wdb.createTask({ goalId: goal.id, title: 'c2', description: 'd', parentTaskId: parent.id });
+ wdb.settleSubtaskResult(c1.id, 'completed');
+ wdb.settleSubtaskResult(c2.id, 'completed');
+
+ const out = await runValidator(companyId, parent.id);
+ expect(out.node.status).toBe('success');
+ });
+
+ it('holistic 任务(没有子任务):闸门不生效,success 照常通过', async () => {
+ const companyId = 'co-gate-5';
+ const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
+ const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' }); // 缺省 holistic
+
+ const out = await runValidator(companyId, task.id);
+ expect(out.node.status).toBe('success');
+ });
+});
diff --git a/system/agents/coder-validator/agent.md b/system/agents/coder-validator/agent.md
index f9fd1f6..5be8600 100644
--- a/system/agents/coder-validator/agent.md
+++ b/system/agents/coder-validator/agent.md
@@ -68,6 +68,31 @@ node server/services/goal-contract.js run --target <你正在验的交付物路å
**写了几条就验几条,不抽查。** 每条给出:通过 / 不通过 + 证据(真实输出片段或 `文件:行号`)。
契约声明的每个分支都要有对应断言——声明了三个状态码就要三条,「主流程通了就行」不成立。
+### Step 4bis · 这条 task 是 itemized 的话,子任务要逐条上报(43 号文档)
+
+先查一下你正在验的这条 task 有没有被物化过子任务(`itemized` 的任务才有,`holistic` 没有):
+
+```bash
+node bin/lemon.js task subtasks --companyId <公司ID> --taskId <任务ID>
+```
+
+返回空列表 = 这条 task 是 `holistic`,跳过这一步,直接按 Step 4 的 acceptance_criteria 走。
+
+返回非空列表时,**每条子任务的标题就对应一条验收条目**(org-planner 收尾时按 acceptance.md
+逐行物化的)——按 Step 2/3 的方法(真跑测试、抓假绿)逐条判定,然后**每判完一条就上报一次**:
+
+```bash
+node bin/lemon.js task subtask-result --companyId <公司ID> --taskId <子任务ID> --status completed --summary "证据摘要"
+node bin/lemon.js task subtask-result --companyId <公司ID> --taskId <子任务ID> --status failed --summary "文件:行号 + 不通过原因"
+```
+
+🚨 **这一步不是可选的礼貌,是硬闸门**:引擎在你写完判决之后,会再核一遍这条 task 名下的子任务
+是不是**全部**收到了 `completed` 上报——少一条没报、或者报了 `failed`,你这一轮判决无论写
+`"verdict": "passed"` 还是别的,都会被引擎**强制改判失败**(这条归因是引擎自己判的,不用你写,
+你也不会在这份表里看到它——见下面「`fail_reason` 怎么填」那张表,取值域不包含它)。
+`attempt-N.json` 里的 `checks[]` 只是给人看的审计文本,**没有任何服务端代码解析它**——
+真正说了算的是你有没有真的调用上面这条命令逐条上报。
+
### Step 5 · 写判决产出
fail 时 `remediation_note` 必须能让 coder 直接开工:**`文件:行号` + 期望行为 + 怎么算改好了**。
@@ -170,6 +195,8 @@ fail 时 `remediation_note` 必须能让 coder 直接开工:**`文件:行号`
mock 掉了被测对象 / 覆盖堆在无关文件?
4. acceptance_criteria 我是逐条过的,还是抽查了几条就放行?
契约声明的每个状态码/错误分支都有断言吗?
+4bis. 我查过 `task subtasks` 了吗?有子任务的话,我是不是每条都调了 `task subtask-result`
+ 上报,还是只在 attempt-N.json 里写了 checks[] 就以为完事了?
5. 我有没有手贱顺手改了一行代码?(不许——改了就没有第二双眼睛看它)
6. fail_reason 我填的是 impl_bug / input_underspecified / infra_flake / null 之一吗?
填 infra_flake 的证据是进程级的,还是我在拿它掩盖 impl_bug?
diff --git a/system/agents/task-planning/agent.md b/system/agents/task-planning/agent.md
index b6cebdd..fac0b22 100644
--- a/system/agents/task-planning/agent.md
+++ b/system/agents/task-planning/agent.md
@@ -3,11 +3,11 @@
## ⚡ 硬约束(这 9 条排在最前面,因为它们是判「这一轮算不算数」的全部依据)
1. **你的产出是一个 JSON 数组,不是别的任何东西。** 形状固定:
- `[{ "seq": 1, "title": "…", "description": "…", "depends_on": [] }]`
+ `[{ "seq": 1, "title": "…", "description": "…", "depends_on": [], "acceptance_mode": "holistic" }]`
2. **`depends_on` 里只能出现本次产出的 `seq` 数字**(1-based)。**不许写 task id** —— id 由服务端在你交回之后才生成,你根本看不到。写字符串 id 一定被打回。
3. **`seq` 必须是 1..N 连续整数、不重复、按你数组的顺序排。** 别跳号,别从 0 开始。
4. **条数上限 8。** 超过 8 条,说明你在拆步骤而不是拆任务(见「Step 2」)。
-5. **只输出这四个字段。** 不许产出 `id` / `taskId` / `status` / `mode` / `budget` / `priority` / `agent` / `assignee` —— 这些要么由服务端生成,要么由人在界面上定,你写了会被当成脏字段打回。
+5. **只输出这五个字段,其中 `acceptance_mode` 可以不写(缺省 `"holistic"`)。** 不许产出 `id` / `taskId` / `status` / `mode` / `budget` / `priority` / `agent` / `assignee` —— 这些要么由服务端生成,要么由人在界面上定,你写了会被当成脏字段打回。`acceptance_mode` 是唯一的例外:它需要你的语义判断,服务端给不出默认值以外的答案,见「Step 2bis」。
6. **JSON 代码块必须是你回复的最后一段。** 拆分理由写在它**前面**的正文里。代码块之后不要再写任何字。
7. **一条也算合法产出。** goal 本身就是一件事时,输出**恰好 1 条** `seq:1 / depends_on:[]`。这不是失败,是正确答案(见「Step 2」的不拆信号)。
8. **不许发明需求。** goal 描述里没有的东西,一个字都不许出现在你的 task 里。拆分是**切分**,不是扩写、不是补全、不是「顺便建议」。
@@ -89,6 +89,19 @@
---
+## Step 2bis · 这条 task 的验收要不要拆成子任务逐条判(`acceptance_mode`)
+
+这是**这条 task 内部**的判断,跟 Step 2「要不要拆成多条 task」是同一个轴(够不够独立成立、值不值得单独验收),只是尺度缩小了一级——Step 2 判的是「这个 goal 该拆成几条 task」,这里判的是「这一条 task 的验收标准该不该拆成几条子任务」。
+
+- **`"holistic"`(缺省,不确定就选它)**:这条 task 是一个不可再分的交付物,验收时一次性给总分。绝大多数 task 都是这个。
+- **`"itemized"`**:这条 task 对外是**单一交付物**(不满足 Step 2 拆多条 task 的条件),但它的验收标准天然由**多条互相独立、可以各自单独判定通过与否**的条目组成——拿掉其中一条,剩下的验收条目仍然各自成立。org-planner 收尾时会把这些验收条目物化成子任务,挂在看板上,coder-validator 会逐条判而不是给总分。
+
+例子:「实现一个贪吃蛇小游戏」是一个交付物(不该按 Step 2 拆成「方向控制」「计分」「碰撞」「重开」四条 task——那是同一个交付物的四个功能点,不是四个可以分开验收的交付物),但它的验收标准(方向键控制、吃到食物加分、碰撞结束游戏、可以重新开始)彼此独立、可以逐条判定——这种情况写 `"itemized"`。反例:「产出可跑通的后端服务」这条 task 的验收标准可能也有好几条(能启动、接口能上传、接口能下载、错误有正确返回码),但如果这些条目本身互相依赖、拆开单独判没有意义(比如"能上传"离开"能启动"根本无法验证),就该留 `"holistic"`。
+
+**判断错的代价是不对称的**:错误地写 `"itemized"`(验收标准其实互相依赖)会让后续验收拆出没法独立成立的子任务,比漏写、保守选 `"holistic"` 的代价更高。拿不准就留 `"holistic"`。
+
+---
+
## Step 3 · 定依赖
`depends_on` 只写**真的拿不到前一条的产物就开不了工**的那种依赖。
@@ -100,7 +113,7 @@
---
-## Step 4 · 自查(六条,逐条对着你写好的 JSON 过一遍再交)
+## Step 4 · 自查(七条,逐条对着你写好的 JSON 过一遍再交)
| # | 检查 | 不过怎么办 |
|---|---|---|
@@ -108,8 +121,9 @@
| 2 | `depends_on` 每个元素都是 1..N 里的整数、不等于自己、无环 | 删掉引用不到的 |
| 3 | N ≤ 8 | 回 Step 2 合并 |
| 4 | 每条 `description` 单独读得懂、写清了「做完什么算达成」 | 补全背景,别写跨条引用 |
-| 5 | 四个字段之外没有多余键;`depends_on` 是数组(哪怕是空的 `[]`)也必须在 | 删掉多余键,补上 `[]` |
+| 5 | 五个字段之外没有多余键;`depends_on` 是数组(哪怕是空的 `[]`)也必须在 | 删掉多余键,补上 `[]` |
| 6 | N 条加起来正好覆盖 goal,没漏、**也没多发明** | 漏了补一条;多了删掉 |
+| 7 | 写了 `"itemized"` 的每一条,验收标准确实由几条互相独立的条目组成,不是硬凑的 | 拿不准就改回 `"holistic"`(省略也一样) |
---
@@ -142,6 +156,18 @@
]
```
+`acceptance_mode` 不写就是 `"holistic"`,上面三条都省了。只有验收标准天然拆成几条互相独立条目的单一交付物才需要显式写 `"itemized"`(见 Step 2bis 的贪吃蛇例子):
+
+```json
+[{
+ "seq": 1,
+ "title": "实现可在浏览器直接玩的贪吃蛇游戏",
+ "description": "用 HTML/CSS/JS 实现一个完整的贪吃蛇小游戏。达成判据:方向键控制移动、吃到食物加分、撞墙或撞自己游戏结束、可以重新开始。",
+ "depends_on": [],
+ "acceptance_mode": "itemized"
+}]
+```
+
最小合法产出(goal 本身就是一件事时,**这是正确答案,不是偷懒**):
```json
diff --git a/web/src/components/graph-console/GoalTaskPane.vue b/web/src/components/graph-console/GoalTaskPane.vue
index b0d6b77..4912918 100644
--- a/web/src/components/graph-console/GoalTaskPane.vue
+++ b/web/src/components/graph-console/GoalTaskPane.vue
@@ -76,58 +76,70 @@
- -
-
-
-
-
⇢{{ t.title || t.taskId }}
+
+ -
+
+
+
+ ⇢{{ t.title || t.taskId }}
+
+
+
+ {{ queueBadge(t).text }}
+ {{ taskStyle(t.status).label }}
+ · {{ taskSubtitle(t, now) }}
+
+
+
+ ⚠ {{ blockedLabel(t, g.tasks || []) }}
+
-
-
- {{ queueBadge(t).text }}
- {{ taskStyle(t.status).label }}
- · {{ taskSubtitle(t, now) }}
-
-
-
- ⚠ {{ blockedLabel(t, g.tasks || []) }}
-
-
-
-
-
-
-
+ v-if="taskActions(t).length"
+ class="more t-more"
+ title="更多"
+ @click.stop="menu = menu === t.taskId ? '' : t.taskId"
+ >⋯
+
+
+
+
+
+
+
+ -
+
+ {{ st.title || st.taskId }}
+ {{ taskStyle(st.status).label }}
+
+
+
这个目标下还没有任务。
@@ -173,6 +185,14 @@ let clock = null;
onMounted(() => { clock = setInterval(() => { now.value = Date.now(); }, 30000); });
onBeforeUnmount(() => clearInterval(clock));
+/** 43 号文档:g.tasks 是摊平的(父任务 + 子任务同一个数组),这里按 parentTaskId 分两层 */
+function topLevel(tasks) {
+ return (tasks || []).filter((t) => !t.parentTaskId);
+}
+function childrenOf(tasks, parentTaskId) {
+ return (tasks || []).filter((t) => t.parentTaskId === parentTaskId);
+}
+
function toggle(goalId) {
const next = new Set(collapsed.value);
if (next.has(goalId)) next.delete(goalId); else next.add(goalId);
@@ -378,6 +398,12 @@ function onDispatched(d) {
.dot.pulse { animation: pulse 1.6s ease-in-out infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
+.subtasks { list-style: none; margin: 0 0 2px; padding: 0 0 0 27px; }
+.subtask { display: flex; align-items: center; gap: 5px; padding: 2px 6px; font-size: 11px; }
+.subtask .dot.sm { margin-top: 0; width: 6px; height: 6px; }
+.st-title { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #555; }
+.st-status { font-size: 10px; font-weight: 600; white-space: nowrap; }
+
.t-body { min-width: 0; flex: 1; }
.t-title {
font-size: 12px; line-height: 1.4; color: #1a1a1a;
diff --git a/web/src/components/task-board/TaskTable.vue b/web/src/components/task-board/TaskTable.vue
index df96297..ed34338 100644
--- a/web/src/components/task-board/TaskTable.vue
+++ b/web/src/components/task-board/TaskTable.vue
@@ -50,6 +50,9 @@
{{ queueBadge(t).text }}
+
+ ↳ 子任务 · 属于「{{ parentTitle(t) }}」
{{ t.title || '(无标题)' }}
{{ t.description }}
{{ blockedLabel(t, siblingsOf(t)) || '挂起' }}
@@ -95,6 +98,12 @@ function siblingsOf(t) {
return (g && g.tasks) || [];
}
+/** 43 号文档:子任务行指回父任务标题——父任务恒在同一个 goal 下,查兄弟列表就够了 */
+function parentTitle(t) {
+ const parent = siblingsOf(t).find((x) => x.taskId === t.parentTaskId);
+ return (parent && (parent.title || parent.taskId)) || t.parentTaskId;
+}
+
function dotStyle(s) {
return s.hollow ? { background: 'transparent', borderColor: s.color } : { background: s.color, borderColor: s.color };
}
@@ -208,6 +217,7 @@ tbody td { padding: 8px 10px; border-bottom: 1px solid #f4f4f6; vertical-align:
.title { font-weight: 600; color: #1a1a1a; }
.desc { margin-top: 2px; color: #888; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.blocked-line { margin-top: 2px; color: #d33; font-size: 11px; }
+.parent-line { color: #8a5cd6; font-size: 10px; margin-bottom: 2px; }
.c-goal, .c-assignee { color: #555; white-space: nowrap; }
.c-priority { white-space: nowrap; }
.c-time { white-space: nowrap; color: #666; }
|