43-task-decomposition-granularity.md 24 KB

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 号文档 的复发一例, tools.md 范例与 work-graph-db.js 引擎层已同日修复),排查过程中用户提出了本篇的需求。 格式对齐 27-requirement.md:原话照搬 + 结构化复述,现状调研另起一节。


原始需求(用户原话,一字不改)

就这个任务,我讲讲我理想的状态,应该是 org planner 完成了brd prd sdd 后,在 task 任务看板应该能看到规划出来的 todo list 作为这个 html 贪吃蛇游戏 goal 的 task 和 sub task 展示出来,然后每一个 org coder 都是按照这个 task todo list 在逐个的开发完成,然后 org validator 应该是针对 task todo list 在逐个验收,这个现在是不是实现不了?


结构化复述

用户设想的理想流程,拆成四步:

  1. 规划阶段:org-planner 完成 brd/prd/sdd 之后,不只是产出文档,还应该把这份规划分解成一份 todo 清单。
  2. 展示阶段:这份 todo 清单应该以 task 和 sub-task 的形态出现在任务看板上,挂在"HTML 贪吃蛇游戏"这个 goal 下面(例子:方向键控制、吃食物加分、碰撞结束、重新开始——即当前 acceptance.json 里的 A.1-A.5)。
  3. 开发阶段:org-coder 不是一次性对着一大段任务描述写完整个交付物,而是按 todo 清单逐条开发。
  4. 验收阶段:org-validator 也不是一次性给整个交付物判总分,而是按 todo 清单逐条验收。

用户明确问:现状是不是做不到这个?


现状调研结论(本次会话核实,非最终设计)

结论:部分可以,"做不到"的那部分是当前 agent 设计上的主动选择,不是漏做的功能。四层现状分别是:

1. tasks.parent_task_id——字段在,但服务的是另一条流水线

tasks 表(server/services/work-graph-db.js)确实有 parent_task_id 列,也确实有代码在读写它——但那是 LemonBoss Agent Chat 的人工派单流程(server/routes/agent-chat.js + task-service.js 建任务树),跟本篇讨论的 goal → task → org-graph/work-graph 这条流水线是两套独立机制。

本篇关心的这条流水线里,真正负责"从 goal 生成 task 行"的函数是 work-graph-db.js 的 createTask({goalId, companyId, seq, title, description, executionMode, dependsOn, priority, budgetJson, reportTo, createdBy})——这个函数签名里根本没有 parentTaskId 参数。也就是说,子任务对这条流水线而言是结构性够不着,不是"字段有、没人填"。

2. task-planning 阶段本身有能力拆多任务,但拆分依据不是"检查项"

真正把 goal 拆成 task 的阶段是 task-planning(server/services/task-planning.js + system/agents/task-planning/,落库调用点在 server/routes/company.js:1577 insertTasksForGoal)。这一步确实能一次拆出多条 task(已有测试验证过 2 条以上的场景)。

snakegame-co 这次只拆出 1 条 task,不是能力限制,是 system/agents/task-planning/agent.md 第二步的明确设计判断:拆分依据是"交付物/验收边界",不是"工作阶段"。原文点名"①调研②设计③开发④测试⑤上线"这种按阶段拆是"最常见也最致命的错法"。按这份人设的判断,方向键/得分/碰撞/重开这四条,是同一个交付物的验收清单(正是最终 acceptance.json 里一起被同一份 Playwright 脚本验证的 A.1-A.5),不是四个可以独立排期验收的 task——agent 设计上就会拒绝把它们拆成四条 task。该 agent 人设还硬顶单 goal ≤ 8 条 task,整体偏"合并"不偏"细分"。

3. org-planner 没有"建任务"这个工具

org-planner 在跑的时候(system/agents/org-planner/tools.md)能力边界是:调研 → 阶段流转 → 写 brd/prd/sdd/acceptance.md 这几份文件 → 向 CEO 汇报。没有任何一个工具允许它创建新的 task 行(org-coder / org-validator 同样没有)。规划阶段的产出,结构上只能落成文档,附着在它所属的那一条已经固定的 task 上,没法在跑的过程中反过来生出新的、可独立调度的子任务。

4. 看板 UI 没有树形渲染

web/src/views/TaskBoard.vue、web/src/components/graph-console/GoalTaskPane.vue 目前都没有父子任务的渲染逻辑——即便前三层都打通、数据库里真的出现了带 parentTaskId 的行,看板现在也画不出树形结构,只会当成平铺列表处理(或者干脆不显示子任务)。


设计决策(2026-08-31 定案,三选一各选完)

问题 决定 理由
子任务挂在哪套机制上 goal → task 流水线里新建一套独立父子机制(不复用 Agent Chat 的 parent_task_id) 后者是为「人工派单、多轮对话」设计的语义,跟 org-graph 自动执行的生命周期(谁创建、何时终结)对不上,硬凑容易埋雷
子任务要不要独立开发+验收循环 先只拆验收,开发仍整体完成——org-coder 一次性写完整个交付物,验收侧改成按子任务(验收条目)逐条判,而不是给总分 改动小得多,且先解决「验收颗粒度太粗」这个更痛的点;独立开发循环(每个子任务各起一个 work-graph run)留作后续再评估
什么粒度的 goal 该拆 由 task-planning agent 自己判断,不写死规则 跟它现在「要不要拆多个 task」的判断逻辑同源,交给同一套"交付物/验收边界"标准,不额外发明一套阈值规则

由这三条决定组合出的实现方向:task-planning 判断"值不值得走细粒度验收"是一个粗粒度的开关,真正的子任务清单在 org-planner 写完 acceptance.json 之后才生成——因为验收条目本身就是规划阶段的产出,task-planning 跑的时候 brd/prd/sdd 还不存在,没法预先知道要拆成哪几条。这意味着"判断要不要拆"和"生成子任务"是两个时间点、两个步骤:

  1. task-planning(goal 创建时):新增一个判断维度——这个 task 的验收后续要不要走"逐条子任务"模式,写一个标记(例如 task 行上加 acceptance_mode: 'holistic' | 'itemized',或等价字段),不生成任何子任务行。
  2. org-planner 收尾(acceptance 产物写完之后,org-graph 流程内新增一步):如果上一步标记是 itemized,读验收条目,逐条 createTask({parentTaskId, ...}) 生成子任务行,挂在同一个 goal 下。
  3. coder-validator(work-graph 内既有节点,就是本 session 全程调试的那个 agent):判断逻辑从"给整个 task 一个总分"改成"遍历子任务逐条判",每条子任务各自 success/fail,父任务的终态由子任务汇总(全 success → success,否则看具体子任务)。
  4. 看板 UI:TaskBoard.vue / GoalTaskPane.vue 加父子任务树形渲染,只读展示,不涉及交互改动。

实现细节决策(2026-08-31 定案)

问题 决定 理由
标记字段怎么设计 tasks 表加 acceptance_mode 字段,取值 `'holistic' \ 'itemized',命名风格对齐既有execution_mode`(manual/auto),由 task-planning 生成 task 行时一并写入
部分子任务失败时父任务终态 必须全部子任务 success,父任务才 success;任一子任务 fail → 父任务 fail 跟当前"一次性给总分、不合格就是不合格"的严谨程度保持一致,也呼应 34 号文档 关于验证不能打折的教训;optional 子验收项留作后续需要时再加,先不预留这层复杂度
子任务要不要复用现成的状态语义 复用(pending/ready/running/success/fail/blocked 这套状态词汇) 直接复用能白拿状态流转、看板渲染的既有约定,不用另起一套并行的状态语义

落地时的订正(技术设计阶段核实后发现):上一行"复用现成状态机"字面上写的是 graph_run_nodes 那套,但子任务本质是 tasks 表的行(带 parent_task_id),不是某个 work-graph run 内部的节点实例——它没有自己独立的 run。所以真正落地时复用的是 tasks.status 这套既有枚举(queued/running/blocked/completed/failed/cancelled),只是沿用同一套状态词汇的精神(有限状态、终态幂等、settleNode/settleTaskFromRun 那一类条件更新闸门),不是字面上去 graph_run_nodes 表插行。见下方技术实现计划 Phase 3。

另一处订正:上面第 3 步原来写的是"org-validator",但技术设计阶段查证后发现 org-validator 和 coder-validator 是两个不同目录、不同职责的独立 agent——org-validator 是 org-graph 顶层节点(整体验收),coder-validator 是 org-coder 这一层 work-graph 内部的节点(就是本 session 全程调试的那个,work-validator/v1 输出格式)。真正产出逐项验收证据、需要按子任务逐条判的是 coder-validator,已在上面订正。

至此,43 号文档的方案层面已经全部定案。下面是核实过具体代码形态后的技术实现计划。


技术实现计划(2026-08-31,代码形态已核实,未开工)

Phase 0 · Schema

  • tasks 表加 acceptance_mode 列,CHECK (acceptance_mode IN ('holistic','itemized')) DEFAULT 'holistic'——语法上照抄 execution_mode 已有的 CHECK 写法(work-graph-db.js:533),迁移方式照抄 parent_task_id 自己当年落地时用的 hasColumn 判断 + ALTER TABLE 兜底那一段(work-graph-db.js:626-627),同一个文件里已有两份现成模板可抄,不用发明新的迁移写法。
  • createTask({...}) 签名(work-graph-db.js:1861-1873)加 parentTaskId、acceptanceMode 两个参数,INSERT 列表(work-graph-db.js:1902-1907)跟着加。parent_task_id 列本身早就存在(是 Agent Chat 那条派单流程在用),这里只是第一次让 goal→task 流水线自己也写这一列——顺手确认一下现有读它的 task-service.js:66-81 那段任务树构建逻辑,会不会因为多了一类新来源的父子行而产生非预期的合并/展示(大概率不会,因为按 company_id/goalId 天然分域,但落地前应该跑一次它的既有测试)。
  • 守卫测试:图流水线创建的顶层 task(insertTasksForGoal 路径)parent_task_id 仍是 NULL;子任务的 parent_task_id 非空且指向父任务、acceptance_mode 恒为 'holistic'(子任务不允许再递归拆一层)。

Phase 1 · task-planning 判断 acceptance_mode

task-planning 目前是纯"回复 JSON 代码块"模式(system/agents/task-planning/tools.md:5),产出 4 字段 [{seq, title, description, depends_on}](agent.md:120-142),而且硬规则 5(agent.md:10)明确禁止 LLM 多写字段——"服务端生成,你写了会被当成脏字段打回",mode/priority/budget 这些运营字段都是 company.js:1396-1429 buildTaskPlans() 在服务端按 goal 级默认值统一注入的(company.js:1421-1423),不是 LLM 决定的。

acceptance_mode 要不要拆本质上是一次语义判断(跟"要不要拆多个 task"用的是同一种判断力,这正是当初选"由 agent 自己判断、不写死规则"的理由),没法像 mode/priority/budget 那样简单地由服务端注入默认值——所以这里必须显式在硬规则 5 上开一个例外:

  • agent.md 的 JSON schema 从 4 字段扩到 5 字段,加 acceptance_mode,并在硬规则 5 旁边补一句明确的例外说明(为什么这一个字段跟其它运营字段不同,避免以后被"这些字段都是服务端生成"的惯例带偏、被误当脏字段清掉)。
  • buildTaskPlans()(company.js:1396-1429)改成透传 LLM 给的 acceptance_mode,而不是像其它字段那样用服务端默认值覆盖;LLM 没给这个字段时兜底 'holistic'(保守,不引入新行为,除非规划阶段明确判断要拆)。

Phase 2 · org-planner 收尾生成子任务(⚠️ 有一个真实风险要先处理)

org-planner 的 4 阶段 FSM(system/agents/org-planner/tools.md:19-23,node bin/lemon.js task transition --phase {plan|execute|verify})产出的验收清单叫 acceptance.md(不是 .json)——Markdown 表格 | # | 验收项 | 判定方式 | pass 条件 |(tools.md:44-50),一行一条验收项,是子任务清单最自然的数据源。这不是同名的 R-19-B acceptance.json 契约(goal-contract.js:76 定义的 benchmark|test_data|test_script 三段式,由另外三个独立的 goal-benchmark/goal-test-data/goal-test-script 节点产出)——两者名字像、来源和用途完全不同,写代码时一定要认清楚读的是哪一份。

⚠️ 已知风险,动工前必须先确认:本 session 全程调试的 snakegame-co 这条真实任务,acceptance.md 和 acceptance.json 实际上都没有被真正当成验收依据用——coder-validator 最终判定用的是自己临时写的一套 Playwright 测试(A.1-A.5),goal-contract.js 那份 acceptance.json 甚至报了 note:"goal-contract.js cannot parse index.html as JSON...treat as no task-level contract",被当场判定"这个任务没有 task 级契约"直接跳过。如果子任务清单要建在 acceptance.md 之上,得先跑一次真实回归确认它确实会被稳定产出、格式跟文档描述一致——不能假设它已经可靠,这跟 33 号文档"建立在一个从来没人真正产出过的 kind='log' 之上"是同一类风险,值得先花一次真跑单独验证,而不是直接在上面盖楼。

如果验证通过,新增步骤:org-planner 的 verify 阶段落盘 acceptance.md 之后,插入一步"物化子任务"——仅当父任务 acceptance_mode==='itemized' 时,逐行读表格,为每一条验收项调用 createTask({parentTaskId: 当前 task.id, acceptanceMode:'holistic', title: 该行"验收项"文本, description: 该行"判定方式"+"pass 条件", ...})。

两个待确认的实现细节:

  • 这一步该是给 org-planner 加一个新工具(让它自己触发"物化子任务"),还是挂在服务端某个 hook 上(verify 阶段落盘后自动触发,agent 无感)?倾向后者——子任务生成是纯数据搬运,不需要占用一次 agent 调用去决定"要不要生成"(acceptance_mode 这个语义判断已经在 Phase 1 做完了),机械地转换 Markdown 表格到 task 行不需要 LLM 参与。
  • 本次调研没能确认 org-planner"完成→进入 org-coder"具体由哪个信号触发(verify transition 本身,还是 tools.md:59-65 那个额外的 task/report 调用)——插入新步骤前必须先查清楚,避免插错时机打断现有的阶段流转。

Phase 3 · coder-validator 改成逐条判 + 回写子任务状态

关键发现:coder-validator 现在产出的 checks[]/verdict 结构化 JSON(就是本 session 里 attempt-2.json 那种形状),目前没有任何服务端代码解析它——graph-engine.js 判定这个节点成功与否只看 executor 返回的顶层 status 字段,checks[] 纯粹是给人看的审计文本,goal-contract.js 走的是完全独立的另一条 CLI 路径(自己生成 verdict_id),跟 coder-validator 的输出没有关系。

所以"逐条判"要真正影响子任务状态,不能只是把 agent 提示词写得更细——必须新增一个真实的工具调用:coder-validator 每判完一条验收项,调用一次新工具(例如 report-subtask-result),带上子任务 id + success/fail,服务端据此走一次 tasks 表的条件更新(复用 settleTaskFromRun 同款的"终态幂等闸门"思路:已经是终态的子任务不能被覆盖)。这跟本 repo 一贯的做法一致——状态改变走真实工具调用,不解析自由文本 JSON(checks[] 格式在 org-validator/coder-validator/planner-validator/testdata-validator/benchmark-validator 五个 validator 之间只是"约定俗成、没有强制"的 work-validator/v1,不是哪里都强制校验的 schema,解析它不安全)。

父任务终态:全部子任务 completed → 父任务按现有 settleNode 逻辑判 success;任一子任务 failed → 父任务 fail(Phase 0 就已经决定)。

Phase 4 · 看板树形渲染

  • web/src/components/FileNode.vue 是现成的自递归 <script setup> 组件模板(v-for="child in node.children" + depth*14+6 缩进),照这个结构写一个 TaskNode.vue,不用发明新模式。
  • GoalTaskPane.vue:78-131 现在是纯平铺 <ul class="tasks"><li v-for="t in g.tasks || []">,改成先按 parentTaskId 把 g.tasks 分组建成树,再用 TaskNode 递归渲染。TaskBoard.vue 本身不用改——它是纯外壳,全权委托给 GoalTaskPane.vue(TaskBoard.vue:23-32)。
  • taskView()(company.js:994-1031,GET /:companyId/goals?withTasks=1 等多个路由共用的唯一整形函数)要把 parent_task_id、acceptance_mode 加进输出字段列表——目前两个字段都完全没有出现在响应里,包括未使用的形式都没有。

建议的实施顺序

Phase 0 → 1 → 4 → 2 → 3。

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 号文档——goal-benchmark 按设计把产物写进 root run 共享 artifacts/ 目录,但产出闸门只查节点私有目录,writesArtifacts:true 这个字段全仓库零代码读取,这条契约在当前实现下结构性地打不过,不是运气不好。这很可能正是本文档触发场景(snakegame-co)里 goal-benchmark/planner-validator 显示 skipped 的真实原因。44 号文档只记录诊断,未修复;修复它会提高 Phase 2 的实际命中率,但不在 43 号文档范围内,不在这里展开。