From b1e0a879c2209dad12ad237c0dc133113ba69158 Mon Sep 17 00:00:00 2001 From: bboonnee Date: Thu, 13 Aug 2026 11:16:00 +0800 Subject: [PATCH] Org Graph unification Phase 3: frontend Org meta-graph + loop_back replay timeline --- docs/done/org-graph-unification-and-universal-self-planning-requirements.md | 150 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/done/org-graph-unification-phase2-universal-self-planning-design.md | 536 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/done/scheduler-new-goal-dispatch-requirements.md | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/done/work-graph-node-execution-deblackbox-requirements.md | 198 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/todo/org-graph-unification-and-universal-self-planning-requirements.md | 150 ------------------------------------------------------------------------------------------------------------------------------------------------------ docs/todo/org-graph-unification-phase2-universal-self-planning-design.md | 536 -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- docs/todo/scheduler-new-goal-dispatch-requirements.md | 58 ---------------------------------------------------------- docs/todo/work-graph-node-execution-deblackbox-requirements.md | 198 ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ frontend/app.js | 146 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- frontend/test_org_meta_graph_smoke.js | 195 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/new_goal_dispatch.py | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/task_query.py | 23 +++++++++++++++++++++++ src/test_new_goal_dispatch.py | 129 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/test_org_node_trigger_e2e.py | 124 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/test_task_query.py | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 15 files changed, 1667 insertions(+), 943 deletions(-) create mode 100644 docs/done/org-graph-unification-and-universal-self-planning-requirements.md create mode 100644 docs/done/org-graph-unification-phase2-universal-self-planning-design.md create mode 100644 docs/done/scheduler-new-goal-dispatch-requirements.md create mode 100644 docs/done/work-graph-node-execution-deblackbox-requirements.md delete mode 100644 docs/todo/org-graph-unification-and-universal-self-planning-requirements.md delete mode 100644 docs/todo/org-graph-unification-phase2-universal-self-planning-design.md delete mode 100644 docs/todo/scheduler-new-goal-dispatch-requirements.md delete mode 100644 docs/todo/work-graph-node-execution-deblackbox-requirements.md create mode 100644 frontend/test_org_meta_graph_smoke.js create mode 100644 src/new_goal_dispatch.py create mode 100644 src/test_new_goal_dispatch.py create mode 100644 src/test_org_node_trigger_e2e.py diff --git a/docs/done/org-graph-unification-and-universal-self-planning-requirements.md b/docs/done/org-graph-unification-and-universal-self-planning-requirements.md new file mode 100644 index 0000000..8a10242 --- /dev/null +++ b/docs/done/org-graph-unification-and-universal-self-planning-requirements.md @@ -0,0 +1,150 @@ +# 统一 Org Graph 主流程 + 全体 Org Agent 自规划 Work Graph + 节点级验证回路 + 前端动态/历史图谱——需求文档(融合版) + +**日期**: 2026-08-08 +**性质**: 需求文档(BRD/PRD 层面),不含详细技术设计。融合并统领以下既有 `docs/todo/` 文档的相关内容: +- `docs/todo/org-graph-as-work-graph-unified-loop-requirements.md`(已删除,内容 100% 并入本文档)——本文档是它的展开与拍板;该文档提出的"跨 Org Agent 精确 loop_back"诉求是本文档 FR-4 + §4 关键架构决策的正文。 +- `docs/done/task-execution-resume-assumes-planner-artifacts-exist.md`——它记录的 bug 与本文档 FR-1 改动的是**同一段** `run_or_resume_task_lifecycle()` 分支逻辑(见 §2.1 调研结论),折入 Phase 1 一并修,不再单独排期。 +- `docs/todo/validator-test-script-narrative-output-issue.md` 第 8 节提到的"另一份尚未撰写、关于给 Coder agent 加入 test_script 模块的文档",即本文档 Phase 2(FR-2/FR-3)。 + +**触发场景**:任务 `2084590a`(修复 `work_node_run.py` 的 `execution_log_file` bug)里,Planner 自规划出的 `benchmark` 节点连续 4 次产出的都是"叙述我做了什么"的散文(跟 `validator-test-script-narrative-output-issue.md` 同一类问题,这次发生在 benchmark 上),LLM judge 的 `on_fail=retry_self` 循环耗尽 `max_attempts=3` 后任务直接 `blocked_pending_human`——暴露出 Validator 从来没有机会在 Coder 开始实现之前复核 Planner 自己的产出。用户随后 2026-08-08 提出四点架构改动设想(见 §1),与当天早些时候整理的 `docs/todo/` 待办优先级建议(P0~P3)高度交叉,要求重新盘点、融合、给出 phase 规划。 + +**状态**: 草稿,讨论中——§4 关键架构决策已拍板(方案 A,合图,2026-08-08),其余 §7 列出的问题仍待确认。沿用 `org-graph-as-work-graph-unified-loop-requirements.md` 的"逐条开放问题讨论 → 用户拍板 → 展开设计文档"两段式流程,本文档本身不是最终设计。 + +--- + +## 1. 用户提出的四点改动 + +1. Org Graph 固定流程从 `Planner → Coder → Validator` 改为 `Planner → Validator → Coder → Validator`——Coder 开始实现之前,Planner 自己的产出先过一道独立复核。 +2. 每个 Org Agent(不只 Planner)接到 goal 任务时,固定第一步是自己规划一张 Work Graph:每个节点声明用哪个 subagent + skill + tools + data 实现,以及判断节点成功/失败分别跳转到哪个节点(边的数据定义)。 +3. 前端要支持这张图的动态显示(执行中实时更新)和历史显示(多轮 loop 的回放),因为一定会有 loop(Validator 节点判断不通过时跳回前面某个节点)。 +4. 每个 Org Agent 都要有一套配套的 skill + subagent 定义——除了 Planner 现有的 brd/prd/sdd/benchmark/test_script 这类产出型 skill,还需要一个"验证型"skill:验证每个节点的结果,判断通过/不通过,不通过时精确跳回需要修复的那个节点,让该节点重新执行。 + +--- + +## 2. 现状盘点(2026-08-08 代码调研结论) + +### 2.1 Org Graph 固定流程写死的位置 + +- `src/org_node_lifecycle.py:30-31`:`_PRE_STATE`/`_POST_STATE` 只认 3 个 org_node(`planner`/`coder`/`validator`),线性对应 `task.status` 的 `queued → planning_brd_prd_sdd → coding → validating`。 +- `task.status` 是 `CHECK` 约束的固定枚举(`src/schema.py:106-111`:`queued/planning_brd_prd_sdd/planning_failed/coding/coding_failed/validating/routed_fast_lane/routed_full_replan/blocked_pending_human/done`),没有"Planner 产出待复核"这个中间态的容身之处。 +- 真正的调用顺序在 `src/e2e_pipeline.py::run_task_lifecycle()`(第 878 行起):`trigger_org_node_full(planner)` → `plan_documents()` → `trigger_org_node_full(coder)` → `implement_code()` → `trigger_org_node_full(validator)` → `validate_implementation_with_own_graph()`。`src/task_execution.py::run_or_resume_task_lifecycle()` 的 resume 分支复刻了同一条状态判断链。 +- **这正是 `task-execution-resume-assumes-planner-artifacts-exist.md` 记录的 bug 所在的同一段代码**:`planning_brd_prd_sdd` 分支的注释写的是"Planner done, Coder not yet triggered",无条件假设 sdd/prd/benchmark artifact 已经存在——插入一个新的"复核中"状态,必然要重写这段分支逻辑,顺手把这个已知假设过时的 bug 一并修掉比分两次改更省事。 + +### 2.2 现有的两套独立、粒度不同的 loop 机制(沿用并确认 `org-graph-as-work-graph-unified-loop-requirements.md` §1 的判断) + +- **跨 Org Agent、粗粒度**:`src/org_loop_route.py:36-39`,`_NEW_STATUS`/`_NEW_ORG_NODE` 只有两个目标——`fast_lane`→回 `coder`/`coding`,`full_replan`→回 `planner`/`planning_brd_prd_sdd`,`_LOOP_LIMIT = 3`。`src/org_level_loop.py` 的根因分类(`coder`/`planning`)也是 1:1 映射到这两个粗粒度目标,**没有节点级精度**。 +- **单个 Org Agent 内部、细粒度**:`work_graph_orchestrate.py` 的 `judgment`/`on_fail`/`loop_back` 机制(`docs/done/work-graph-self-planning-and-loop-back-design.md`)能精确跳回同一张图内的某个节点,但**只在同一次 `plan_work_graph()` 调用产出的同一张图内部**生效——Planner 和 Coder 是两次独立调用,节点 id 空间互不相通,这正是 `org-graph-as-work-graph-unified-loop-requirements.md` §4 提出、至今没有方案的问题。 + +### 2.3 已经具备、可以直接复用的能力(不需要重新发明) + +- `plan_work_graph(conn, org_node, ...)`(`work_graph_plan.py:336`)**本身已经是 org_node-generic** 的:`org_node == "planner"` 走 `_PROMPT_TEMPLATE`(固定催化清单),其余任何 org_node 都会落到 `_GENERIC_PROMPT_TEMPLATE`(自由规划 `{node_id, depends_on, task_prompt}`)——但目前**只有 Planner 的调用点在用它**。Coder 的 `implement_code()`(`e2e_pipeline.py:617-650`)和 Validator 的 `validate_implementation_with_own_graph()` 用的 `_VALIDATOR_MANIFEST`(`e2e_pipeline.py:210-222`)都是 Python 字面量硬编码的单/双节点图,没有 `judgment`/`on_fail` 字段,也从未调用过 `plan_work_graph()`。 +- `judgment`/`on_fail`/`loop_back` 执行引擎(`work_graph_orchestrate.py`)已验证是 org-agent-agnostic 的,不需要重做。 +- 逐节点落盘(`docs/done/work-graph-partial-artifact-persistence-requirements.md`,已实现)、Role Package 抽象(`roles//role.json`,已实现——新增 skill 只改 JSON,不碰 Python)、`design-dynamic-work-graph` skill(已存在,`coder`/`validator` 角色已声明,但目前只在人工发起的 `work_graph_fork.py` 时间旅行调试功能里用,没接生产路径)都是现成积木。 +- 前端 SVG DAG 渲染器(`frontend/app.js::renderGraphVisualizerPanel`/`mountGraphVisualizerEdges`)已经支持 `on_failure` 边线、自环(`retry_self`)计数徽标、经由既有 WS 事件流的准实时刷新——比它自己的 wireframe(`docs/wireframes/v_graph_visualizer.html`)还完整。 +- `loop_back_events` 数据已经从后端透出(`task_query.py:185` `_loop_back_events_for_run()`,含 `source_node_id`/`target_node_id`/`attempt_number`/`feedback[]`/`created_at`),前端已经用它驱动边标签和自环徽标计数——只是 `feedback[]` 拉取了但没渲染。 + +### 2.4 明确的缺口 + +- **没有任何"逐节点判断输出是否合格、并决定精确 `loop_back` 到哪个节点"的通用 skill**——今天的 `judgment.kind="llm"` 是引擎特性(一次额外的 LLM 判断调用),不对应任何角色的具名 skill/prompt 资产;`qa-task-validation` 判断的是整体实现,不是单个节点。 +- Coder、Validator 两处调用点仍是硬编码 manifest,未接入自规划。 +- 插入新的"Planner 产出复核"阶段需要 `task.status` schema 迁移;跨图 loop_back 寻址此前完全没有方案,现已按 §4 拍板为合图(方案 A),但 §4.1 列出的三个具体迁移点还未设计。 +- 前端:同一个 `work_graph_run` 内多轮 `loop_back` 只能看最新一次结果,没有按 `attempt_number` 的历史回放;Org 四段(Planner/复核/Coder/终审)之间没有统一元图,只有各自独立页面 + 文字徽标链接(fork 谱系同理,见 `docs/done/work-graph-time-travel-debugging-design.md`)。 + +--- + +## 3. 目标(草案 FR,待讨论细化为正式验收标准) + +| # | 需求描述 | 对应用户诉求 | +|---|---|---| +| FR-1 | Org Graph 固定流程改为 `Planner → Validator(复核 Planner 产出) → Coder → Validator(终审实现)`:新增 `task.status` 枚举值(schema 迁移)+ `org_node_lifecycle._PRE_STATE`/`_POST_STATE` 扩展 + `run_task_lifecycle()`/`run_or_resume_task_lifecycle()` 新分支。**该分支落地时一并修复 `task-execution-resume-assumes-planner-artifacts-exist.md` 记录的 resume 假设 artifact 存在的 bug**(同一段代码路径,不分两次改)。 | 1 | +| FR-2 | Coder(`implement_code()`)、Validator(`validate_implementation_with_own_graph()`)两处调用点,把硬编码 manifest 换成真实调用 `plan_work_graph(conn, "coder"/"validator", ...)`——函数本身已通用,只需替换调用点。 | 2 | +| FR-3 | `graph_manifest` 节点 schema 扩展显式的"实现绑定"字段:这个节点由哪个 subagent + 哪个 skill + 哪些 tools + 输入输出 data 实现。今天的 `judgment`/`on_fail` 已覆盖"怎么判断成功/失败、失败去哪",但"用什么实现"这一半目前只是 `_GENERIC_PROMPT_TEMPLATE` 给的一段自由文本 `task_prompt`,不是结构化字段——这是本次真正的新增 schema 工作。 | 2 | +| FR-4 | 新增一个 org-agent-无关的"节点级验证"skill(可被任意角色的 `role.json` 引用),作为 `judgment.kind="llm"` 的标准实现:验证节点输出、判断根因、精确 `loop_back` 到需要修复的节点,且当根因是**系统性 skill 缺陷**时先修 Skill 本身再重试——完整方法论见 §3.1,不是单纯"跳回某个节点重试"。用它统一/取代 `org_level_loop.py` 今天的粗粒度 `fast_lane`/`full_replan` 路由——这是 `org-graph-as-work-graph-unified-loop-requirements.md` 的核心诉求,按 §4 已拍板的方案 A(合图)落地:`loop_back` 目标节点和触发验证的节点在同一个 `work_graph_run` 内,不需要跨图寻址。 | 4 | +| FR-5a | 前端新增 Org 级元图:把 Planner/复核 Validator/Coder/终审 Validator 四段渲染成一张顶层图,每段可展开成该 Org Agent 自己的 Work Graph 详情(复用现有 `renderGraphVisualizerPanel`,不重写渲染器)。 | 3 | +| FR-5b | 前端新增同一个 `work_graph_run` 内按 `attempt_number` 的历史回放时间轴/scrubber,复用已经在拉取但未渲染的 `loop_back_events[].feedback`。 | 3 | + +### 3.1 核心方法论(用户 2026-08-08 拍板,作为本项目开发的目标写在这里):失败即进化——Reflection → Fix-Skill → Re-loop + +**这是 Work Graph"自我进化"能力的精髓,FR-4 必须按这个方法论实现,不能退化成"判断失败→跳回某个节点重试"这么简单。** 用户原话的核心洞察:一个节点判断失败后,我们要的不只是"把这一次的错误修好"(fix-bug,只解决这一次),而是"让这一类错误以后不会再发生"(fix-skill,永久解决)——否则下一个类似任务撞上同一个坑,还是要重新踩一遍、重新修一遍。 + +**四步链路**(不是一个笼统的"loop_back",是四个明确的阶段/节点): + +1. **判断分支**:节点产出被判定 `false`/`error`/`wrong`(`judgment.kind` 不管是 `mechanical` 还是 `llm`,只要 `ok=False`)——不是简单重试,跳转到分支 B。 +2. **分支 B 第一个节点:Reflection-review**——对整条链路做复盘,不只是"这个节点自己错在哪",而是**全链路根因定位**:到底是哪一个更早的环节(可能是这个节点自己的 Skill 有缺陷,也可能是更早某个上游节点的产出本身就有问题)真正导致了这次失败。判断结论要落到两种性质里的一种: + - **一次性内容错误**(这次具体的产出恰好没写好,Skill/方法论本身没问题)——走现有的精确 `loop_back`(Phase 2 SDD §5 的 `dynamic_loop_back`)即可,跳回真正的根因节点重新生成。 + - **系统性 skill 缺陷**(这个 org_node 用来产出这类内容的 Skill 本身有方法论层面的问题,同类任务会反复踩同一个坑)——进入第 3 步。 +3. **Fix-skill 节点**:不是修这一次的输出,是**修 Skill 本身**——真实调用已经存在的 Skill 迭代机制(`src/skill_update.py::update_skill()` + `src/bug_record.py::create_bug_record(root_cause_category='skill_defect')`),产出一次真实的、Git 版本化、可回滚的 Skill 内容更新(`skill_iteration` 表,`before_git_commit_sha`/`after_git_commit_sha` 都是真实 commit,不是模拟)。**这套机制在代码库里早就存在**(U-015/U-016 时期就建好了),但至今只在 `org_level_loop.py` 的 Org 级粗粒度路径上被触发过——FR-4 要把它真正接进 Work Graph 内部的判断/回路循环,让它成为节点级失败恢复的常规路径之一,不是只有跨 Org Agent 的大失败才配触发。 +4. **跳回执行**:Skill 更新落地后,跳回"用这个 Skill 的节点"重新执行——这次用的是刚修好的新 Skill,不是拿旧 Skill 再赌一次运气。跟 `_loop_history_prompt_suffix`(现有机制,把历史反馈拼进 prompt)的本质区别:prompt 里的反馈是**这一次任务专属、跑完就忘**的临时纠偏;Skill 更新是**持久化、跨任务复用**的真正修复——两者不互斥(可以同时生效:这次重试既带着历史反馈,也带着已经更新的 Skill),但 FR-4 的核心价值在后者。 + +**跟 Phase 2 SDD 的关系**:`docs/todo/org-graph-unification-phase2-universal-self-planning-design.md` §5 已经是四步的完整设计(2026-08-12 补齐)——`on_fail.action="dynamic_loop_back"` 触发时,Reflection-review(§5.4)同时判断"跳回哪个节点"和"这是一次性错误还是系统性 Skill 缺陷";系统性缺陷时先经过 Fix-Skill(§5.5,真实调用既有但从未真正投入生产使用的 `skill_update.py::update_skill()`,产出 Git 版本化的 Skill 迭代)再跳回执行。由哪个身份执笔改 Skill,已确认(2026-08-12)统一用 Validator(该文档 §5.5)。 + +--- + +## 4. 关键架构决策:方案 A(合图)——已确认,2026-08-08 + +延续 `org-graph-as-work-graph-unified-loop-requirements.md` §4 提出、原本没有答案的问题:**跨 Org Agent 的"节点"地址空间不通**。今天的 `loop_back` 只能在同一个 `work_graph_run` 内部跳转;Planner 和 Coder 各自的 `plan_work_graph()` 调用产出互不相通的节点 id 空间。FR-1 插入新的"复核"阶段后,这个问题从"理论上的开放问题"变成了"复核失败必须精确跳回 Planner 图里的哪个节点,而不是重新触发整个 Planner"这个具体的实现阻塞点。 + +当时摆在台面上的两个方案: + +- **方案 A(合图)**:Planner→复核→Coder→终审 四段合并成一张跨阶段大图,`loop_back` 天然能跳(同一张图内部)。 +- **方案 B(图间路由)**:保留"每个 Org Agent 各自一张图"的边界,新增一层跨图路由(复合地址如 `{run_id, node_id}`)。 + +**用户拍板(2026-08-08):采用方案 A。** FR-4、Phase 2 均按合图路径设计。 + +### 4.1 合图决策落地时需要处理的具体冲突点(Phase 2 设计阶段解决,本轮不预先决定怎么改) + +1. **`work_graph_run.org_node` 是单值列,且有唯一约束**:`src/schema.py:130`(`org_node TEXT NOT NULL CHECK (org_node != '')`)+ `src/schema.py:152`(`CREATE UNIQUE INDEX ux_work_graph_run_active ON work_graph_run(task_id, org_node)`,语义是"同一 task 同一 org_node 同一时刻只有一个活跃 run")。合图后一个 `work_graph_run` 会同时容纳 Planner/Coder/Validator 各自规划出的节点,`org_node` 归属需要下沉到**节点级别**而不是 run 级别——这正好可以和 FR-3(节点声明由哪个 subagent/skill 实现)合并解决:节点级的"实现绑定"字段天然可以承载"这个节点属于哪个 org_node"。`ux_work_graph_run_active` 这条唯一索引的语义也要重新设计(大图模式下"活跃 run"变成 per-task 唯一,而不是 per-(task, org_node) 唯一)。 +2. **FR-2(各 Org Agent 自规划)与"一张大图"如何协调**:不能是一次性提前把四段全部规划完——Coder 需要看到 Planner 的真实产出内容才能规划自己的实现节点,Validator 同理需要看到 Coder 的真实 diff。合理的模型是**增量追加**:每个阶段的 `plan_work_graph()` 调用把自己规划出的节点追加进**同一个** `run_id` 的 `graph_manifest`,而不是像今天一样各自开一个新 `run_id`。追加的原子性、并发写入 `graph_manifest` 的锁语义、追加后 `topological_layers()` 要不要对已执行完的前半部分重新分层,是 Phase 2 SDD 需要给出具体算法的地方。 +3. **REQ-06 断点续跑的代价被重新评估、判断没有原先设想的那么大**:合图讨论之初认为方案 A 会"打破每个 Org Agent 各自一次 `claude -p` 调用、各自断点续跑的隔离边界"(`work-graph-self-planning-and-loop-back-requirements.md` §1.3)。但节点级持久化(`work_node_execution` 表)本来就是"每个节点独立一次 `claude -p` 调用 + 独立落盘",这个粒度不因为节点归属同一个 `run_id` 还是分属不同 `run_id` 而改变——真正需要新设计的是上面两条(`org_node` 单值列迁移、增量追加规划机制),不是 REQ-06 本身。Phase 2 设计阶段仍需用真实测试验证这个判断。 + +--- + +## 5. 非目标 + +1. 不动 `scheduler-new-goal-dispatch-requirements.md`(FR-1,新 Goal 自动派发)——该文档已明确这是独立的自主权边界决策,与本次 Org Graph 主流程改动正交,不合并排期。 +2. 不要求第一版就把 `validator-test-script-narrative-output-issue.md` 的根因彻底修完——FR-2/FR-3 落地后 Coder 也会产出 test_script 类内容节点,叙述文字复发风险依然存在(该文档已记录用户接受此已知风险);本文档只是把"给 Coder 加 test_script 模块"接进 FR-2/FR-3 范围,Phase 2 落地时需要专门验证这条风险有没有复现,不单独立项新对策。 +3. 不重写 `work_graph_orchestrate.py` 的 `judgment`/`on_fail`/`loop_back` 引擎本身——它已经是通用的,本次只新增调用方(FR-2)和一个 skill(FR-4),不改引擎代码。 +4. 不重写前端已有的 SVG DAG 渲染器——FR-5 是在现有 `renderGraphVisualizerPanel`/`mountGraphVisualizerEdges` 基础上新增元图和回放时间轴,不是推倒重来。 + +--- + +## 6. Phase 规划 + +### Phase 1 — Org 主流程改造 + resume bug 一并修复 ✅ 已实现(commit `ea19c7d`,2026-08-08) +- **详细设计**:`docs/done/org-graph-unification-phase1-review-gate-design.md`(已随实现移入 `docs/done/`,与实现逐条吻合,见该文档更新后的状态行)。 +- **范围**:FR-1(含 `task-execution-resume-assumes-planner-artifacts-exist.md` 的修复)。 +- **产出**:`task.status`/`execution_event` 两处 schema 迁移、新增独立 org role `planner_reviewer`、`org_node_lifecycle.py` 的 `_PRE_STATE`/`_POST_STATE`/`_REQUIRED_ARTIFACTS` 扩展、`e2e_pipeline.py` 新增复核+重试循环、`run_task_lifecycle()`/`run_or_resume_task_lifecycle()` 新分支。复核失败的重试是任务内部的粗粒度循环(完整重新规划,不接 `org_level_loop.py`),不是"打回整个任务重来"。 +- **额外发现**(设计阶段调研中定位,一并修复):`e2e_pipeline.py::run_task_lifecycle()` 对 `artifact_ids["prd"]`/`["benchmark"]` 的无条件下标访问,在自规划图省略这两个类型时会 `KeyError`——这个崩溃在 fresh-run 首次执行就会触发,不需要等到 resume,是比 P0 bug 更早触发的同类问题。 +- **前置条件**:无——SDD 已给出具体改动点,可以直接实施。复核阶段本身可以先用任务内部的粗粒度重试兜底(复核失败 → 完整重新规划),不必等 Phase 2 的精确 loop_back 才能上线,先把"Coder 开始前多一道独立判断"这件事跑起来。 +- **验收**:能在(已真实复现过的)"benchmark 输出叙述文字冒充产出"场景下,在 Coder 动手之前拦住任务;崩溃后 resume 不再假设 Planner artifact 一定存在;fresh-run 也不再假设 prd/benchmark 一定存在。 + +### Phase 2 — 全员自规划 + 合图(方案 A)+ 节点级验证 skill +- **详细设计**:`docs/todo/org-graph-unification-phase2-universal-self-planning-design.md`。Phase 2a(节点级 subagent/skill 绑定 + `dynamic_loop_back`/Reflection-review/Fix-Skill,commit `86fc0bb`)+ Phase 2b(Coder 接入自规划,commit `7c46a2f`)已实现(2026-08-12);Phase 2c(Validator 接入自规划 + `org_node` 列删除 + 退休粗粒度路由)待实施。设计阶段发现范围比原设想更大——`_GENERIC_PROMPT_TEMPLATE` 从未征集 judgment/on_fail、`run_graph()` 的 subagent_type/skill_ref 是 run 级常量不是节点级、现有 `loop_back` 是规划阶段静态声明的单一目标不支持终审失败时动态选择根因节点(新增 `on_fail.action="dynamic_loop_back"` 解决,复用 `root_cause_trace.py` 陪审团结构)——详见该文档 §1/§5。 +- **范围**:FR-2 + FR-3 + FR-4,按 §4/§4.1 已拍板的合图路径实现。 +- **产出**(按拍板后的最终形状): + 1. `work_graph_run.org_node` 列彻底删除(不是下沉/保留),`work_graph_run_query.py`/`work_graph_fork.py` 改造为从 `work_node_execution.subagent_type` 聚合/逐节点判断;`ux_work_graph_run_active` 收窄为 `UNIQUE(task_id)`。 + 2. Coder/Validator 接入 `plan_work_graph()`,新增 `append_to_run_id` 参数(不是独立函数)实现增量追加进 Phase 1 建好的跨阶段 `work_graph_run`;新增 `_CODER_NODE_CATALOG`/`_VALIDATOR_NODE_CATALOG` 建议节点清单(不是完全自由规划)。 + 3. `run_graph()`/`_run_one_node()` 扩展为读取节点自己声明的 `subagent_type`/`skill_ref`(缺省退回图级默认),新增节点级验证 skill `work-graph-node-review`,`org_level_loop.py` 的粗粒度 `fast_lane`/`full_replan` 路由被 `dynamic_loop_back` 取代(不删除该模块,其他调用方仍在用)。 +- **前置条件**:Phase 1 落地的"复核"阶段先用 `org_level_loop.py` 粗粒度路由兜底上线;Phase 2 把这段兜底逻辑替换成图内精确 loop_back。 +- **风险**:Coder 开始自规划、产出内容型节点后,`validator-test-script-narrative-output-issue.md` 记录的叙述文字复发风险需要在本 phase 内验证是否触发,必要时补加机械拦截网(参考 `check_script_compiles()` 的先例),不是事后打补丁。 +- **建议前置(非强制)**:`docs/todo/work-graph-node-execution-deblackbox-requirements.md`(2026-08-08,单节点执行层去黑盒化,正交但相关)——Phase 2 会大量新增 `judgment.kind="llm"` 节点级判断调用,如果去黑盒化工作先完成,Phase 2 可以直接基于新的 `trigger_org_node_structured()` 设计,不需要在原生 Workflow 和新机制之间选一套来复用。 + +### Phase 3 — 前端动态 + 历史图谱(可与 Phase 2 并行) +- **范围**:FR-5a + FR-5b。 +- **产出**:Org 级元图 + `work_graph_run` 内 `attempt_number` 回放时间轴。 +- **依赖说明**:FR-5b 只依赖 `loop_back_events` 数据形状,今天已存在,不需要等 Phase 2 完成;FR-5a 的"复核"这一段需要 Phase 1 新增的 `task.status` 值才有东西可渲染,其余三段可以先做。 + +### 明确不排期 +`scheduler-new-goal-dispatch-requirements.md`(FR-1,新 Goal 自动派发)——保持独立评审,不合并进本文档的任何 phase。 + +--- + +## 7. 待确认的问题(讨论后逐条勾掉,参照 `graph-engineering-open-items.md` 的状态图例) + +✅ §4 关键架构决策:方案 A(合图)。2026-08-08 用户拍板。 +✅ FR-1 新增的 `task.status` 枚举值定为 `reviewing_planner_output`。2026-08-08,随 Phase 1 SDD(`docs/done/org-graph-unification-phase1-review-gate-design.md`)一起拍板,理由见该文档 §3.1。 +✅ FR-1 的"复核 Validator"用新增独立 org role `planner_reviewer`,不复用 `validator`。2026-08-08 用户拍板(`AskUserQuestion` 确认),理由见 Phase 1 SDD §2.1——`_PRE_STATE`/`_POST_STATE` 是按 org_node 名字一对一映射,同一个 key 没法挂两组状态转移。 +✅ FR-4 的节点级验证 skill:新开一个 skill,不在 `qa-task-validation` 基础上改造。2026-08-08 用户拍板。理由:`qa-task-validation` 整份内容绑定飞书多维表格语义(硬编码 `tblXXX` 表 ID、状态机是"待测试→已完成"这类 Base 记录写回),判定粒度是整个任务的 PASS/FAIL/INCONCLUSIVE;FR-4 要的是单个 Work Graph 节点的 `judgment.kind="llm"` 回调,输入是一个节点的输出,输出要精确指向 `loop_back` 的目标 `node_id`,和 Base 完全无关,两种用途混进同一份 skill 容易互相污染。新 skill 可以借鉴其判定原则(三态结论、独立复核、真实路径优先)写进 prose,但不复用文件本身。 +✅ Phase 2 的 Coder/Validator 自规划:加一份建议清单(类似 `_PLANNER_NODE_CATALOG`),不是完全自由。2026-08-08 用户拍板——推翻了 `work_graph_plan.py:161-170` 2026-08-06 注释里"Coder/Validator's sub-tasks have no such fixed small vocabulary of node types"的原判断。Phase 2 设计阶段需要给 Coder/Validator 各自定义一份"常见节点类型"参考目录(如 impl/unit_test/integration_check),每项可以像 `_PLANNER_NODE_CATALOG` 一样标注 `suggested_judgment`,但不强制——沿用 Planner 目录"建议不强制"的既有约定,而不是 Planner 文档链那种固定顺序的强约束。 +✅ §4.1 第 1 点:`work_graph_run.org_node`(run 级单值列)彻底移除,查询改为从节点级聚合。2026-08-08 用户拍板——推翻了"保留作冗余字段"的备选项。Phase 2 设计阶段需要解决两处下游改造:`work_graph_run_query.py:62` 的 `WHERE wgr.org_node = ?` 过滤要重写成 JOIN 节点表再过滤;`work_graph_fork.py` 的 fork 谱系展示要重新定义"一个横跨四段的合图 run 展示哪个/哪些 org_node"这个尚未存在的聚合语义(例如展示节点级 org_node 的去重列表,而不是单值)——这是本次决策遗留的新开放问题,不是照抄旧列就能解决的,需要在 Phase 2 SDD 里明确定义。 +✅ §4.1 第 2 点:"增量追加规划"接口:`plan_work_graph()` 新增一个"追加模式"参数(如 `append_to_run_id`),内部完成"读现有 `graph_manifest` → 合并新规划出的节点/边(含 `node_id` 冲突检测) → `UPDATE` 写回",不下放给调用方各自实现。2026-08-08 用户拍板。理由:合并/冲突检测逻辑集中在 `work_graph_plan.py` 一处——该模块本来就是 `_normalize_graph_manifest`/`_validate_loop_back_targets` 等 `graph_manifest` 结构性操作的归属地——避免 Coder、Validator 两个调用点各自实现一遍合并逻辑,未来出现两处不一致的风险。 diff --git a/docs/done/org-graph-unification-phase2-universal-self-planning-design.md b/docs/done/org-graph-unification-phase2-universal-self-planning-design.md new file mode 100644 index 0000000..4ddd524 --- /dev/null +++ b/docs/done/org-graph-unification-phase2-universal-self-planning-design.md @@ -0,0 +1,536 @@ +# Phase 2 — 全员自规划 + 合图 + 节点级精确 loop_back——详细设计(SDD) + +**日期**: 2026-08-08 +**性质**: 详细设计,对应 `docs/todo/org-graph-unification-and-universal-self-planning-requirements.md` 的 Phase 2(FR-2/FR-3/FR-4),按 §4/§4.1 已拍板的合图(方案 A)路径设计。 +**依赖**: 硬依赖 Phase 1(`docs/done/org-graph-unification-phase1-review-gate-design.md`)——已实现(commit `ea19c7d`,2026-08-08)。§9 接口核对清单已于 2026-08-12 逐条核对完成:三项全部吻合(`plan_and_review_documents()` 经 `_review_planner_output_with_retries()` 透传 `plan_documents()` 的 `{"run_id", "artifact_ids"}` 返回形状,含 `run_id`;`task.status` 新值确认是 `reviewing_planner_output`;`roles/planner_reviewer/role.json` 目前 `skills: ["planner-output-review", "unattended-agent-operations"]`,未预留 §7.1 新 skill 位置,Phase 2 实施时新增一行即可,不是不兼容)。 +**状态**: Phase 2a(commit `86fc0bb`)+ Phase 2b(commit `7c46a2f`)已实现,2026-08-12。Phase 2c 的 Validator 自规划改造(§6 核心部分)已实现,2026-08-12:`validate_implementation_with_own_graph()` 改为 `plan_work_graph(conn, "validator", ..., append_to_run_id=run_id)` 追加进 Planner/Coder 共享的同一个 `work_graph_run`,终审 verdict 变成图内一个真实节点(`node_type='final_verdict'`,`judgment.kind='mechanical'`、`check_ref='verdict_says_pass'`,`on_fail.action='dynamic_loop_back'`——Validator 图里唯一允许用这个 action 的节点),硬门槛(脚本真实非零退出码强制 FAIL,不听 LLM 辩解)通过 `_verdict_node_build_prompt`/`_verdict_says_pass_check` 的共享可变状态闭包保留,Python 强制而非只是 prompt 里说说。`_VALIDATOR_MANIFEST`/`_VALIDATOR_DOC_PROMPTS`/`_VALIDATOR_VERDICT_PROMPT`/`_validator_prompt_builder` 保留但不再使用(同 `_CODER_PROMPT` 先例)。§7.2 的收口验证已完成分析(见 §10 Phase 2c 小节末尾:一旦 verdict 节点自己的 `dynamic_loop_back` 内部消化重试,`validate_implementation_with_own_graph()` 正常返回时 verdict 必为 PASS,`org_level_loop.py` 的旧粗粒度路由对这条新路径自然不可达,不需要显式新旧流程判断——决定保留旧分支代码不删,已在 `scheduler.py::finish_task_lifecycle` 加注释说明)。 + +**2026-08-13 §2.1/§2.2 已实施**:`work_graph_run.org_node` 列彻底删除,`ux_work_graph_run_active` 收紧为 `UNIQUE(task_id) WHERE status NOT IN ('done', 'failed')`('blocked' 故意算作活跃——恢复一个 blocked 的 run 现在总是走 `append_to_run_id` 追加进同一行,不会再 INSERT 新行)。新迁移函数 `schema.migrate_drop_work_graph_run_org_node()`(内部先幂等调用 `migrate_relax_org_node_constraints()`,再做 create-copy-drop-rename,`PRAGMA foreign_keys=OFF` 全程包裹,理由同 `migrate_add_reviewing_planner_output_status` 的真实重启事故记录——`work_graph_run` 是被多张表 REFERENCES 的父表)已接入 `backend_server.py::_ensure_schema()`。下游改动:`work_graph_plan.py`/`work_graph_fork.py`(含 `create_fork()`/`_reconstruct_build_prompt()`/`execute_fork()`,`fork_info["org_node"]` 改名 `fork_info["subagent_type"]`,源节点自己的执行身份)/`root_cause_trace.py` 的三处 INSERT 全部去掉 `org_node` 列;`work_graph_run_query.py`(`list_work_graph_runs`/`get_work_graph_run`)与 `task_query.py::get_task` 的返回形状从单值 `"org_node"` 改成 `"org_nodes_involved"`(从 `work_node_execution.subagent_type` 去重派生的列表,`org_node=` 查询参数语义从等值匹配改成 EXISTS 子查询),前端 `frontend/app.js` 对应改造(`getLatestRunForOrgNode`/`orgNodesLabel`/`renderOrgGraphTopology` 等消费点全部改用列表语义)。发现一处设计文档本节 §2.1.3 没预料到的遗漏点:`task_query.py::get_task` 也有同样的单值 `org_node` 读取,一并修完。`root_cause_trace.py::judge_root_cause_via_work_graph` 的 INSERT 有一个已知、记录在代码注释里的边界情况:索引收紧后,若这个函数被调到时对应 task 已有一个仍处于活跃/blocked 状态的共享 run,会撞索引——目前不是真实风险(这个函数唯一的调用方 `org_level_loop.run_fix_loop` 只在一个已经不可达的分支里被调用,且它自己的测试夹具里对应 task 从未预先插入过 work_graph_run 行),但如果未来重新接上这条路径需要重新评估。`python3 -m unittest discover -s src`(768 tests)+ `discover -s supervisor`(126 tests)+ 4 个 frontend smoke test 全绿。 + +**2026-08-13 §7.1 已实施**:新增 `skills/work-graph-node-review/SKILL.md`——判断"自规划 Work Graph 节点(Coder/Validator/planner_reviewer 自己规划出的、声明 `judgment.kind="llm"` 的节点)的产出是不是真的、结构化的",不是叙述文字冒充产出;吸收了 `docs/todo/validator-test-script-narrative-output-issue.md` §6 的根因分析和 `_NARRATION_RETRY_HINT`(条件句而非断言句)的措辞教训,并明确跟 `planner-output-review`(Planner 固定文档链、任务级、Coder 开始前把关)、`qa-task-validation`(任务级整体验收)、`skill-defect-remediation`(改 Skill 文件本身)三者的边界——判断粒度是"这一个自规划节点",不是文档链/整任务/Skill 文件。已挂到 `roles/planner_reviewer`、`roles/coder`、`roles/validator` 三个 `role.json` 的 `skills` 列表(各追加一行,如 §9 核对清单当时预判)。未修改 `.claude/agents/*.md`——那是 `agent_profile.deploy_agent_definitions()` 从 `role.json` 编译出的部署产物,由 Deploy Supervisor 在真实 cutover 时重新生成,不是手动跟 `role.json` 同步维护的(观察到现有 `.claude/agents/coder.md` 已经缺了更早前 Phase 2a 加的 `design-dynamic-work-graph`,印证这个文件本来就不是手动保持同步的)。`python3 -m unittest discover -s src`(768 tests,含 `test_agent_profile.py`)全绿。 + +**Phase 2c 至此全部完成**(§2.1/§2.2/§6 Validator 自规划改造/§7.1 均已实施;§7.2 收口分析已完成,决定不改代码,见上文)。**2026-08-08 修订**:写作过程中,需求文档 §7 的四个开放问题已被用户直接拍板(详见该文档 §7 对应条目),其中两条推翻了本文档最初的设计判断——Coder/Validator 自规划改为"建议节点清单"而非完全自由(§3),`work_graph_run.org_node` 改为彻底移除而非保留(§2.1)——本版本已按拍板结果改写,不是原始草稿。**2026-08-08 追加**(同日,见需求文档新增 §3.1):FR-4 的目标被明确为"失败即进化"完整方法论(判断失败 → Reflection-review 动态诊断 → 系统性缺陷则先 Fix-Skill → 跳回执行),当时 §5 设计的 `dynamic_loop_back` 只覆盖第 1/2/4 步,第 3 步(Fix-Skill)是已知缺口。**2026-08-12 补齐**:§5 已扩写为完整四步设计(§5.4 Reflection-review 现在同时判断目标节点和缺陷性质,§5.5 新增 Fix-Skill,真实调用既有但从未真正投入使用的 `skill_update.py::update_skill()`),过程中发现两个既有事实(`skills:` frontmatter 只按名字动态加载内容、不需要重新部署;`org_level_loop.py` 的 `_ROUTE_FOR_CATEGORY` 从来没有 `skill_defect` 这个 key,`update_skill()` 在这个代码库里投产以来从未被真正调用过)——详见 §5 开头。由哪个身份执笔改 Skill 已通过 `AskUserQuestion` 确认(2026-08-12):统一用 Validator,见 §5.5。 + +--- + +## 1. 一个必须先说清楚的修正:这比需求文档原来设想的更大 + +`org-graph-unification-and-universal-self-planning-requirements.md` 里 FR-2 原话是"函数本身已通用,只需替换调用点"——**这个判断经本次设计调研核实是不准确的**,具体差距见下面三点。本文档按修正后的真实范围设计,不是简单的调用点替换。 + +### 1.1 `_GENERIC_PROMPT_TEMPLATE` 从未征集 `judgment`/`on_fail` + +`work_graph_plan.py:171-187` 的 `_GENERIC_PROMPT_TEMPLATE`(Coder/Validator 走 `plan_work_graph()` 时会用到的模板)只要求 LLM 给每个节点 `node_id`/`depends_on`/`task_prompt`,**完全没有要求 `judgment`/`on_fail` 字段**——跟 Planner 专用的 `_PROMPT_TEMPLATE`(`work_graph_plan.py:136-159`,明确征集 judgment/on_fail/on_failure 边)不是同一份提示词。如果只是把 `implement_code()`/`validate_implementation_with_own_graph()` 的硬编码 manifest 换成调用 `plan_work_graph(conn, "coder", ...)`,产出的图**不会带任何判断/回路能力**——FR-4 想要的"每个节点验证+精确 loop_back"根本无从谈起。**必须先扩展 `_GENERIC_PROMPT_TEMPLATE`**,见 §3。 + +### 1.2 `run_graph()`/`work_node_execution` 的 `subagent_type`/`skill_ref` 是 run 级常量,不是节点级 + +`work_node_execution` 表(`schema.py:156-176`)已经有 `subagent_type`/`skill_ref` 两列——但 `_run_one_node()`(`work_graph_orchestrate.py:837-903`)调用 `_ensure_node_row()` 时,传的是 `run_graph()` 整个调用唯一的 `org_node`/`skill_id` 参数(`work_graph_orchestrate.py:856-857`),对图里**每一个节点都相同**,不是节点自己声明的值。FR-3 要的"每个节点声明用哪个 subagent + skill 实现"这个字段今天在数据库层已经有位置放,但执行链路上从来没有真正做到"节点级差异化"。**这是需要真正扩展的执行引擎逻辑**,不是数据已经在只是没读——见 §4。 + +### 1.3 现有 `on_fail.action="loop_back"` 是静态预声明的单一目标,不是动态根因判断 + +这是本次调研发现的**最关键的架构缺口**:今天的 loop_back 机制要求节点在**规划阶段**就通过 `edge_type="on_failure"` 边声明"我失败了就跳回哪一个节点"(`work_graph_orchestrate.py:315-326` `_on_failure_target`,`work_graph_plan.py:247-283` `_validate_loop_back_targets` 在规划完成时就校验这条边指向合法上游节点)——**目标节点是写死的,不是失败发生时才动态判断的**。 + +但 FR-4 想要的是"终审 Validator 判断失败后,根因判断具体是哪个更早的节点出问题,精确跳回那个节点"——终审失败时,真正的根因可能是 SDD 节点、Benchmark 节点、或某个 Coder 实现节点中的任意一个,**这个"该跳回哪"的判断只能在失败发生的那一刻、看到具体的失败症状后才能做出,不可能在规划阶段预先声明一条固定的边**。现有机制完全没有覆盖这种"下游失败,动态诊断该回退到众多上游候选节点里的哪一个"的场景——它只覆盖"这个节点自己的产出不合格,固定退回同一个预先约定的地方"。 + +**这意味着 FR-4 需要给引擎新增一种 `on_fail.action`(本文档称之为 `dynamic_loop_back`),不是简单复用现有 `loop_back`**——见 §5。这确实修改了 `work_graph_orchestrate.py` 的判断/回路逻辑本身,跟 Phase 1 需求文档非目标 §5.3"不重写 judgment/on_fail/loop_back 引擎"的表述有出入;本文档认为这是必要的、有边界的**扩展**(新增一个 action 分支,不改动任何现有 action 的行为),不是"重写",Phase 1 那条非目标应理解为"不推倒重来"而非"一行都不能动",但这个偏差需要明确记录,不能悄悄做。 + +--- + +## 2. Schema 变更 + +### 2.1 `work_graph_run.org_node` 彻底移除(用户拍板,2026-08-08) + +本节最初的设计判断是"不需要改,保留作'run 由谁发起'的元数据"——**已被用户推翻**:合图后一个 task 全程只有一个 `work_graph_run` 行,节点分属不同 org_node(`planner`/`planner_reviewer`/`coder`/`validator`),继续在 run 级保留单值 `org_node` 列既不准确(一个 run 里真实混着 4 种 org_node 的节点)也没有实际用途(真正的归属信息在 §1.2 已经确认属于 `work_node_execution.subagent_type`)——决策是彻底删除这一列,不是留作冗余字段。 + +**Schema 迁移**:`work_graph_run` 表删除 `org_node` 列(SQLite 无 `DROP COLUMN` 的老版本需要 create-copy-drop-rename,跟本文档 §2.2 同一个迁移函数里一起做,或者用现代 SQLite 的 `ALTER TABLE ... DROP COLUMN` 直接支持——取决于实施时 SQLite 版本,若支持则用后者,更简单)。 + +**下游改造点**(决策本身点名的两处,本节给出具体定义): + +#### 2.1.1 `work_graph_run_query.py:54-67`(`?org_node=` 过滤 + 结果字段) + +```python +query = ( + "SELECT wgr.run_id, wgr.task_id, wgr.status, wgr.started_at, wgr.completed_at, " + "(SELECT COUNT(*) FROM work_node_execution wne WHERE wne.run_id = wgr.run_id) AS node_count, " + "(SELECT GROUP_CONCAT(DISTINCT wne.subagent_type) FROM work_node_execution wne " + " WHERE wne.run_id = wgr.run_id) AS org_nodes_involved, " + "wgr.parent_run_id, wgr.fork_of_node_exec_id " + "FROM work_graph_run wgr JOIN task t ON t.task_id = wgr.task_id WHERE t.project_id = ?" +) +params = [project_id] +if org_node: + query += ( + " AND EXISTS (SELECT 1 FROM work_node_execution wne " + "WHERE wne.run_id = wgr.run_id AND wne.subagent_type = ?)" + ) + params.append(org_node) +``` + +返回形状里原来的单值 `"org_node": r[2]` 换成 `"org_nodes_involved": r[...].split(",") if r[...] else []`(GROUP_CONCAT 返回逗号拼接字符串或 NULL,app 层拆成列表)——调用方(`task_query.py`/frontend)原来读单值 `org_node` 字段的地方,改成读这个列表;Phase 3 的 Org 级元图(FR-5a)本来就需要知道"这个 run 横跨了哪几个 org_node 阶段",这个字段直接服务那个需求,不是额外负担。 + +#### 2.1.2 `work_graph_fork.py` 的 `org_node` 用法(三处,逐一替换) + +1. **`create_fork()` 第 125-137 行**:SELECT 语句去掉 `wgr.org_node`;原来解出的 `org_node` 变量直接用同一行已经在读的 `wne.subagent_type`(源节点自己的执行身份)替代——这个值本来就在查询结果里,不需要新增查询。 +2. **`_reconstruct_build_prompt(conn, task_id, org_node, graph_manifest)`(第 94-113 行)**:**去掉 `org_node` 参数**,dispatch 逻辑从"整张图统一按 `org_node=='planner'` 且全部节点有 `artifact_type`"改成**逐节点判断**——这本来就是合图后必须解决的问题(一张合并图会同时包含 Planner 的 `artifact_type` 节点和 Coder/Validator 的 `task_prompt` 节点,§2.1 决策只是让这个改动提前到现在做,不是额外增加的范围): + + ```python + def _reconstruct_build_prompt(conn, task_id, graph_manifest): + goal_text = conn.execute("SELECT goal_text FROM task WHERE task_id = ?", (task_id,)).fetchone()[0] + node_artifact_types = { + n["node_id"]: n["artifact_type"] for n in graph_manifest["nodes"] if "artifact_type" in n + } + planner_builder = e2e_pipeline._planner_prompt_builder(goal_text, node_artifact_types) + + def build_prompt(node, outputs): + if "artifact_type" in node: + return planner_builder(node, outputs) + if "task_prompt" in node: + return work_graph_plan.generic_node_prompt_builder(node, outputs) + raise ForkGraphShapeUnsupportedError( + "node {} has neither 'artifact_type' nor 'task_prompt'".format(node["node_id"]) + ) + return build_prompt + ``` + +3. **`INSERT INTO work_graph_run (...)`(第 164-168 行)**、**`fork_info`/`execute_fork()` 里的 `org_node`(第 189/208 行)**:INSERT 语句去掉 `org_node` 列;`execute_fork()` 调用 `run_graph()` 时原来传 `fork_info["org_node"]` 的位置,改传 `fork_info["subagent_type"]`(源节点自己的执行身份,作为 `run_graph()` 新签名里"没有节点级覆盖时的默认值",见 §4.1)。 + +#### 2.1.3 `root_cause_trace.py::judge_root_cause_via_work_graph`(第三处,设计 §5 Fix-Skill 时新发现,§2.1 原稿遗漏的一处) + +`judge_root_cause_via_work_graph()`(`root_cause_trace.py:222-226`)自己也有一次 `INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) VALUES (...)`——跟 Planner/Coder/Validator 一样直接写了 `org_node` 列,同样要删掉这个列。这个函数本身不受合图影响(它总是为陪审团判断新建一张独立的小图,不追加进跨阶段大图),但既然 `work_graph_run` 表结构变了,这里的 INSERT 语句必须跟着改,不能漏掉——本文档 §5.4 的 `_judge_root_cause_node` 直接复用这个函数的骨架,实施时会经过这段代码,顺手一起改。 + +### 2.2 `ux_work_graph_run_active` 语义变化(需要迁移) + +`schema.py:151-152`:`CREATE UNIQUE INDEX ux_work_graph_run_active ON work_graph_run(task_id, org_node)`(覆盖非终态 status)——语义是"同一 task 同一 org_node 同一时刻只有一个活跃 run"。合图后一个 task 全程只有一个活跃 run(不再按 org_node 切分),这条唯一索引的粒度要从 `(task_id, org_node)` 收紧为 `(task_id)`: + +```sql +DROP INDEX ux_work_graph_run_active; +CREATE UNIQUE INDEX ux_work_graph_run_active ON work_graph_run(task_id) + WHERE status NOT IN ('done', 'failed'); +``` + +(SQLite 的部分索引语法用 `WHERE` 子句表达"仅非终态",比原来在应用层散落判断更直接——可以借这次迁移顺手把语义从"应用层约定"变成"数据库约束",原索引其实也是全表扫描等价的部分索引,这里是等价改写,不是新引入部分索引这个概念。) + +新增迁移函数 `migrate_narrow_work_graph_run_active_index(conn)`,照抄 §2.3 的思路:先检查现有索引定义,已经是新语义就跳过(幂等)。 + +### 2.3 `artifact.artifact_type` CHECK 枚举 vs 自规划节点的任意 `node_id` + +**决策**:自规划出的 Coder/Validator 节点**不是每一个都要落成 `artifact` 行**。沿用 `implement_code()` 今天的既有设计——只有"最终交付物"(`code_diff`/`test_report`)才落 `artifact`,中间探索/推理节点的产出只落 `work_node_execution.output_payload`(已经是持久化的,断点续跑不受影响),不经过 `_write_completed_node_artifacts()` 那条路径。 + +具体做法:`_GENERIC_PROMPT_TEMPLATE` 扩展后(§3)要求节点声明一个新的可选字段 `promotes_to_artifact`(`{"artifact_type": "code_diff", ...}` 形状,缺省表示"这个节点的产出不落 artifact,只是图内部的中间步骤");`_write_completed_node_artifacts()`(Planner/Validator 现在共用的落盘函数)改成只处理声明了 `promotes_to_artifact` 的节点,不再对图里每个节点都无脑落一行——这本来就是 Planner 自己的 `_PLANNER_NODE_CATALOG` 節点隐含的假设(每个 Planner 节点都是"最终交付物"),Coder/Validator 的自由节点不能沿用同一假设。 + +`artifact.artifact_type` 的 CHECK 枚举**不需要扩表**——`promotes_to_artifact.artifact_type` 只允许落在已有的枚举值里(`code_diff`/`test_report`/`validator_benchmark`/`validator_test_dataset`/`validator_test_script` 等),由 Coder/Validator 的 prompt 明确约束"你的最终交付物只能声明这几种 artifact_type 之一",不开放任意值——避免把这个本来就有明确边界的表变成"什么都能塞"的自由字段。 + +--- + +## 3. `_GENERIC_PROMPT_TEMPLATE` 扩展(对应 §1.1)+ Coder/Validator 建议节点清单(用户拍板,2026-08-08) + +原设计判断"Coder/Validator 保持完全自由规划,不设清单"——**已被推翻**:`work_graph_plan.py:161-170` 2026-08-06 的注释原话"Coder/Validator's sub-tasks have no such fixed small vocabulary of node types"被用户拍板否决,Phase 2 要求给 Coder/Validator 各自定义一份类似 `_PLANNER_NODE_CATALOG` 的"建议节点类型清单"——建议不强制(跟 Planner 目录同样的措辞和约束力度,不是 Planner 文档链那种固定顺序的强约束),每项可以带 `suggested_judgment`。 + +### 3.1 新增两份建议目录 + +```python +_CODER_NODE_CATALOG = [ + {"node_type": "impl", "description": "Actual code changes implementing the SDD."}, + { + "node_type": "unit_test", + "description": "New/updated unit tests covering the change -- same repo convention as existing tests.", + "suggested_judgment": {"kind": "llm", "judge_prompt": _NODE_REVIEW_JUDGE_PROMPT}, # 见 §7.1 新 skill + }, + { + "node_type": "integration_check", + "description": "Actually runs the real test suite/benchmark script against the change and reports pass/fail -- not a description of doing so.", + "suggested_judgment": {"kind": "llm", "judge_prompt": _NODE_REVIEW_JUDGE_PROMPT}, + }, +] +_VALIDATOR_NODE_CATALOG = [ + { + "node_type": "independent_benchmark", + "description": "Validator's own held-out acceptance scenarios -- NOT reused from Planner's/Coder's benchmark, a genuine second opinion.", + }, + {"node_type": "test_dataset", "description": "Concrete test data, if this goal has a natural one."}, + { + "node_type": "test_script", + "description": "A real, runnable script mechanically checking the independent benchmark's scenarios.", + "suggested_judgment": {"kind": "mechanical", "check_ref": "check_script_compiles"}, + }, +] +_GENERIC_NODE_CATALOGS = {"coder": _CODER_NODE_CATALOG, "validator": _VALIDATOR_NODE_CATALOG} +``` + +`node_type` 是目录里的建议标签,不是节点 schema 的新必填字段——Coder/Validator 的节点形状仍然是 `node_id`/`depends_on`/`task_prompt`(跟今天一样,`artifact_type` 是 Planner 专属的字段,不引入到这条路径),目录只是喂给 LLM 参考"这类目标通常需要哪几种节点",最终 `node_id`/`task_prompt` 怎么写仍由 LLM 自己决定,不强制照抄目录里的 `node_type` 字面值。 + +### 3.2 `_GENERIC_PROMPT_TEMPLATE` 改为按 `org_node` 参数化,注入对应目录 + +原模板是一段固定字符串,只用 `.format(goal_text=goal_text)` 填一个占位符(`work_graph_plan.py:351` `plan_work_graph()`里的调用)。现在要注入目录 JSON(自带花括号),复用 `_PROMPT_TEMPLATE` 已经验证过的"拼接 + `.replace()`"写法,不用 `.format()`(同一个转义坑,`_normalize_graph_manifest` 的 docstring 已经记录过一次真实事故,不要在这里重蹈): + +```python +def _generic_prompt_template(org_node): + catalog = _GENERIC_NODE_CATALOGS.get(org_node, []) + catalog_section = ( + "Suggested node types for this goal (use any subset, in any order, invent your own if " + "none fit -- these are starting points, not requirements):\n" + json.dumps(catalog) + "\n" + if catalog else "" + ) + return ( + "Plan (do not execute) a Work Graph for this goal: {goal_text}\n" + + catalog_section + + "Follow the design-dynamic-work-graph Skill's methodology: only add a depends_on edge " + "when a node's task_prompt genuinely needs another node's output content ...\n" + "Output a graph_manifest whose nodes each have: node_id, depends_on, task_prompt, plus:\n" + " - judgment: how to tell if this node's output is acceptable -- {\"kind\": \"none\"} if " + " nothing needs checking, {\"kind\": \"llm\", \"judge_prompt\": \"...{content}...\"} for " + " your own judgment prompt with a {content} placeholder.\n" + " - on_fail (only if judgment.kind != \"none\"): {\"action\": \"retry_self\"}, " + " {\"action\": \"hard_fail\"}, or {\"action\": \"loop_back\"} with a matching on_failure " + " edge to a genuine upstream dependency.\n" + " - promotes_to_artifact (optional): {\"artifact_type\": \"code_diff\"|\"test_report\"|...} " + " ONLY for nodes producing this task's actual final deliverable.\n" + "Every edge needs edge_type: 'zero_token' for pure aggregation/reduce steps, 'needs_judgment' " + "for anything requiring the agent to actually reason about content.\n" + "End your reply with the graph_manifest as a single fenced ```json code block ..." + ) +``` + +`plan_work_graph()` 里 `else` 分支(`work_graph_plan.py:350-351`)从 `prompt = _GENERIC_PROMPT_TEMPLATE.format(goal_text=goal_text)` 改成 `prompt = _generic_prompt_template(org_node).replace("{goal_text}", goal_text)`。 + +`GRAPH_MANIFEST_SCHEMA`(`work_graph_plan.py:33-86`)同步补充 `promotes_to_artifact` 的文档性字段(跟 `judgment`/`on_fail` 一样,仅供文档参考,不做 jsonschema 强校验,原因见模块自己的 `--agent`+`--json-schema` 限制说明)。 + +`_normalize_graph_manifest()`(`work_graph_plan.py:286-333`)不需要改——它的 `_validate_loop_back_targets` 校验逻辑已经是通用的,不区分 Planner 的固定链还是 Coder/Validator 的目录建议图。 + +--- + +## 4. 节点级 `subagent_type`/`skill_ref`/`tools` 绑定(对应 §1.2,FR-3 的"用什么实现") + +### 4.1 `run_graph()`/`_run_one_node()` 签名扩展 + +`run_graph(conn, run_id, org_node, skill_id, target_folder_path, graph_manifest, ...)` 的 `org_node`/`skill_id` 两个参数保留(作为"没有节点级覆盖时的默认值"),`_run_one_node()` 内部改成: + +```python +def _run_one_node(conn, run_id, org_node, skill_id, target_folder_path, node, outputs, graph_manifest, ...): + node_org_node = node.get("subagent_type", org_node) # 节点自己声明的执行身份,缺省用图级默认 + node_skill_id = node.get("skill_ref", skill_id) + ... + node_exec_id = _ensure_node_row( + conn, run_id, exec_node_id_str, node_org_node, node_skill_id, ... + ) + ... + run_fn = _make_run_fn(conn, target_folder_path, node_exec_id, node_org_node, prompt, output_schema, timeout) +``` + +这不是把 `run_graph()` 整个重写——只是把原来"直接用闭包里的 `org_node`/`skill_id`"改成"先看节点自己有没有声明,没有就退回闭包默认值",两行改动,向后兼容(没有声明 `subagent_type` 的节点行为完全不变,Planner 的固定链、Coder 现有的合成单节点图都不受影响)。 + +`_resolve_loop_limit()`(`work_graph_orchestrate.py:512-528`)、`_llm_judgment_verify_fn()`(`work_graph_orchestrate.py:420-467`)里同样用到的 `org_node` 参数,同步改成接收 `node_org_node`(调用方在 `_run_one_node` 里已经解析好,直接传下去,不需要这两个函数自己再判断一次)。 + +### 4.2 "tools" 绑定:复用 `docs/todo/work-graph-node-execution-deblackbox-requirements.md` 的 `trigger_org_node_structured()` + +节点声明 `tools`(如 `{"disallowed_tools": ["Bash"]}`,只做限制、不做放宽——不能让一个自规划节点给自己开出角色本身 `role.json` 没有的权限)时,去黑盒化文档设计的 `trigger_org_node_structured(org_node, prompt, target_folder_path, output_schema, model=None, timeout=300)` 需要再加一个可选的 `disallowed_tools` 参数,透传成底层 `claude` 调用的 `--disallowedTools` CLI 参数(05 §5.4.1.2 已经确认 CLI 顶层参数支持按工具/子命令限定,跟 subagent frontmatter 不支持是两条不同的解析路径)。 + +节点声明的 `disallowed_tools` 与角色 `role.json` 的 `permissions.disallowed_tools` 取并集(只加限制不减限制),在 `_run_one_node` 里合并后传给 `trigger_org_node_structured`。 + +**"data" 绑定**:即"这个节点的输入来自哪些上游节点",`depends_on` + `build_prompt(node, outputs)` 已经完整覆盖这件事(`outputs` 字典就是已完成上游节点的产出),不需要新机制——用户诉求里的"data"就是这一层,只是没有用现有术语描述。 + +--- + +## 5. 新增 `on_fail.action = "dynamic_loop_back"` + 完整四步链路(Reflection-review → Fix-Skill → Re-loop) + +需求文档 §3.1("失败即进化")把 FR-4 定为四步:①判断失败 ②Reflection-review 动态诊断(一次性内容错误 vs 系统性 Skill 缺陷)③如果是系统性缺陷,先修 Skill 本身 ④跳回执行。本节 2026-08-08 首版只设计了①②④,第 3 步(Fix-Skill)本次补齐——本次设计过程中读代码发现了两个直接决定 Fix-Skill 该怎么接的既有事实,先说明: + +**既有事实 A(关键,决定了"改 Skill 会不会真的生效")**:`.claude/agents/.md` 的 `skills:` frontmatter 只引用 Skill **名字**(`agent_profile.py::_frontmatter_yaml`),真正的 `skills//SKILL.md` 内容是 Claude Code 自己在每次 `claude --agent ` 调用时按名字动态加载的——**改完 Skill 文件、提交,下一次调用同一个 org_node 就会用上新内容,不需要重新部署 `.claude/agents/*.md`**。这意味着"改 Skill → 跳回执行"这个链路不需要额外一步"重新部署",直接可用。 + +**既有事实 B(说明这条能力在这个代码库里目前完全是摆设)**:`org_level_loop.py::run_fix_loop()` 的 `_ROUTE_FOR_CATEGORY = {"coder": "fast_lane", "planning": "full_replan"}`(`org_level_loop.py:10`)**没有 `skill_defect` 这个 key**——`root_cause_trace.py` 的三视角判断早就能产出 `skill_defect` 这个类别(`_PERSPECTIVE_FRAMINGS["skill_defect"]`,`root_cause_trace.py:112-116`),`bug_record.create_bug_record()` 也接受这个类别,但 `route = _ROUTE_FOR_CATEGORY.get(category, "full_replan")` 对 `skill_defect` 直接落到 `full_replan` 这个兜底值——**`skill_update.py::update_skill()` 在这个代码库里投产以来从未被任何生产代码路径真正调用过**,只有测试在用。Fix-Skill 是第一次把这条早就设计好、但从没接线的能力真正用起来。(这个 dead-code 缺口本身跟 Phase 2 无关,独立可修,见 §8 第 6 条——但不是本次设计的阻塞项,Fix-Skill 走的是全新的合图内部路径,不依赖修好这个旧 bug。) + +### 5.1 语义 + +跟现有 `loop_back`(跳回一个规划阶段就写死的固定目标)不同,`dynamic_loop_back` 表示"这个节点失败时,由一次专门的根因判断调用(Reflection-review),从这个节点的全部上游祖先节点里动态选出真正该回退的目标,并判断这是一次性内容错误还是系统性 Skill 缺陷;后者先修 Skill(Fix-Skill)再回退"——只用于合适放在**图末端的终审性判断节点**(比如 Phase 1 引入的终审 Validator 节点),不要求每个节点都用这个更贵的机制。 + +### 5.2 graph_manifest 字段(不变) + +```jsonc +{ + "node_id": "final_review", + "judgment": {"kind": "llm", "judge_prompt": "..."}, + "on_fail": {"action": "dynamic_loop_back", "max_attempts": 3} + // 不需要对应的 on_failure 边——目标不是静态声明的 +} +``` + +### 5.3 执行逻辑总览(`work_graph_orchestrate.py` 新增分支) + +```python +if action == "dynamic_loop_back": + ancestors = sorted(_transitive_ancestors(graph_manifest, node_id) - {node_id}) + diagnosis = _judge_root_cause_node( # 见 §5.4,Reflection-review + conn, run_id, node_id, feedback, ancestors, graph_manifest, build_prompt, outputs, + org_node, target_folder_path, + ) + target = diagnosis["target_node_id"] + if target not in ancestors: # 运行时防御,见 §5.7 + raise ValueError( + "dynamic_loop_back judge picked {!r}, not a real ancestor of {}".format(target, node_id) + ) + if diagnosis["defect_kind"] == "skill_defect": + _fix_skill_for_node( # 见 §5.5,Fix-Skill——先修完 Skill 再触发回退 + conn, run_id, target, graph_manifest, build_prompt, outputs, feedback, target_folder_path, + ) + raise _LoopBackRequested(source_node_id=node_id, target_node_id=target, feedback=feedback) +``` + +`_LoopBackRequested`/`run_graph()` 主循环(`_run_layers` 里 `loop_back_signal` 的处理,`work_graph_orchestrate.py:811-830`)**完全不需要改**——它已经是"给一个 `(source_node_id, target_node_id)` 就重新执行 target 及其全部下游"的通用机制,不关心这个 target 是静态声明来的还是动态判断来的,也不关心跳回之前有没有发生过 Fix-Skill 这一步。`target` 节点重新执行时会真的用上刚提交的新 Skill 内容(既有事实 A)——**这就是第 4 步"跳回执行、用新 Skill"的完整实现,不需要额外的"通知节点使用新 Skill"机制**,因为 Skill 加载本来就是运行时按名字动态发生的。 + +### 5.4 Reflection-review:`_judge_root_cause_node`——判断目标节点 + 判断缺陷性质 + +复用 `root_cause_trace.judge_root_cause_via_work_graph` 的"多视角 Work Graph fan-out + `_tally_perspectives` 多数票仲裁"骨架,但这次要求陪审团在给出目标节点的同时,**再判断一次这是一次性错误还是系统性缺陷**——这是 Reflection-review 全链路复盘的核心职责,不能只做"选节点"这一半。 + +```python +_REFLECTION_OUTPUT_SCHEMA = { + "type": "object", "required": ["target_node_id", "defect_kind", "detail"], + "properties": { + "target_node_id": {"type": "string"}, + "defect_kind": {"type": "string", "enum": ["content_error", "skill_defect"]}, + "detail": {"type": "string"}, + }, +} + +def _judge_root_cause_node( + conn, run_id, failed_node_id, feedback, candidate_node_ids, graph_manifest, build_prompt, outputs, + org_node, target_folder_path, timeout=180, +): + """三个视角,每个是一次独立 Work Node fan-out 调用(跟 + judge_root_cause_via_work_graph 同一套机制),但答案是 + {target_node_id, defect_kind, detail} 而不是自由文本;target_node_id + 必须是 candidate_node_ids 之一。多数票仲裁维度是 target_node_id(哪个 + 节点最多票就是哪个);defect_kind 取该目标节点获票的判断里的多数值 + (同一票里 target 和 defect_kind 是绑定的一对答案,不是分开投票)。 + < 2/3 一致时不升级人工(跟 root_cause_trace 不同,见 §5.7 第 2 点)。 + """ +``` + +三个视角的具体提问: + +1. **内容视角**:"逐个看 `candidate_node_ids` 每个节点在这次 `outputs` 里的真实产出内容(`build_prompt(candidate_node, outputs)` 重建它当初被要求做什么 + `outputs[candidate_node_id]` 是它实际给出的什么),哪一个的内容本身站不住脚?是这次内容恰好没写好(`content_error`),还是这类节点反复容易出这类问题、看起来是方法论/prompt 本身有系统性偏差(`skill_defect`)?" —— 直接把 `validator-test-script-narrative-output-issue.md` §6 的根因分析("先探索再产出"这类节点收尾话术天然倾向叙述文字)作为 `skill_defect` 的具体识别范式喂给这个视角,不是让它凭空判断。 +2. **衔接视角**:"如果每个候选节点单独看都没有实质内容缺陷,是不是节点之间的依赖关系/输入衔接本身有问题(比如一个节点该依赖另一个节点的输出却没声明 `depends_on`)?" —— 这种情况通常判 `content_error`(属于这次规划的偶然疏漏,不是 Skill 方法论错误)。 +3. **历史视角**:"这个候选节点对应的 org_node/skill_ref 组合,在 `execution_event`(`loop_back_triggered`/`skill_iterated` 两种类型)里过去是否已经因为同一类问题被跳回或修过 Skill?如果是重复出现的同一个坑,倾向 `skill_defect`;如果是第一次见,倾向 `content_error`。" —— 这是三个视角里唯一利用跨任务/跨轮次历史数据的一个,需要真的查一次 `execution_event`,不是只看这一次的 `outputs`。 + +### 5.5 Fix-Skill:真实修 Skill,不是修这一次的输出 + +**触发条件**:`_judge_root_cause_node` 判定 `defect_kind == "skill_defect"`。**要修哪个 Skill**:直接查 `graph_manifest` 里 `target_node_id` 节点自己声明的 `skill_ref`(§4 节点级绑定字段,缺省退回图级默认 `skill_id`)——不查 `work_node_execution` 表(那张表的 `node_id` 可能带 `-attemptN` 后缀,从 `graph_manifest` 原始节点定义读更直接、无歧义)。 + +**由谁来修——已用户拍板(2026-08-12):统一用 Validator 身份**(`org_node="validator"`),不管出问题的节点自己是哪个 org_node,都不让它自己改自己的 Skill。理由:`skill_update.py` 模块自己的 docstring 原话是"a **Validator**-triggered rewrite"——这是 03-C 时期就定下的既有设计意图,本设计只是第一次真正把它接起来,不是新发明;更本质的理由是,如果一个 org_node 的 Skill 本身有系统性偏差,让它用同一个(有偏差的)判断力去修自己的 Skill,大概率修不到点子上(这次踩的坑,恰恰是它自己看不出来的盲区)——独立的 Validator 身份更接近"找另一双眼睛"这个 Reflection-review 一直在做的事。 + +**真实调用链**: + +```python +def _fix_skill_for_node(conn, run_id, target_node_id, graph_manifest, build_prompt, outputs, feedback, target_folder_path, timeout=300): + target_node = next(n for n in graph_manifest["nodes"] if n["node_id"] == target_node_id) + skill_id = target_node.get("skill_ref", "unknown") # 图级默认由调用方 run_graph() 的 skill_id 参数兜底 + + current_content = _read_skill_content(skill_id) # skills//SKILL.md, skill_query._REPO_ROOT + task_id = conn.execute("SELECT task_id FROM work_graph_run WHERE run_id = ?", (run_id,)).fetchone()[0] + goal_text = conn.execute("SELECT goal_text FROM task WHERE task_id = ?", (task_id,)).fetchone()[0] + + rewrite_result = org_node_trigger.trigger_org_node_structured( # 见 work-graph-node-execution-deblackbox-requirements.md + "validator", _SKILL_REWRITE_PROMPT.format( + goal_text=goal_text, skill_id=skill_id, current_content=current_content, + what_was_asked=build_prompt(target_node, outputs), + what_it_produced=outputs[target_node_id].get("content", ""), + failure_feedback="; ".join(feedback or []), + loop_history=_loop_history_prompt_suffix(conn, run_id, target_node_id), # 复用既有函数 + ), + target_folder_path, _SKILL_REWRITE_SCHEMA, timeout=timeout, + ) + + bug_result = bug_record.create_bug_record( + conn, task_id=task_id, + description="Skill {} produced repeated defective output at node {}".format(skill_id, target_node_id), + root_cause_category="skill_defect", root_cause_detail=rewrite_result["change_summary"], + suggested_route="full_replan", # 沿用既有枚举值填充要求,本路径不真的按它路由 + ) + iteration = skill_update.update_skill( + conn, skill_id, bug_result["bug_id"], rewrite_result["new_content"], + rewrite_result["change_summary"], repo_path=skill_query._REPO_ROOT, # 见既有事实 A 上方的先例 + ) + _log_skill_iterated_event(conn, run_id, target_node_id, skill_id, iteration["iteration_id"]) # 见下方 + return iteration +``` + +`_SKILL_REWRITE_PROMPT`/`_SKILL_REWRITE_SCHEMA`(`{"new_content": str, "change_summary": str}`)是新增内容,方法论("怎么在不破坏这个 Skill 现有正确行为的前提下,精确修正导致这次失败的那一小部分")沉淀成新 skill `skills/skill-defect-remediation/SKILL.md`(见 §7.1 更新),挂到 `roles/validator/role.json`——这是 Validator 身份专属的新能力,不是 §7.1 `work-graph-node-review` 的职责范围(那个判断"一个节点的输出好不好",这个是"怎么改一份 Skill 文档本身",性质不同)。 + +**记录一次 `skill_iterated` execution_event(发现:这个 event_type 已经在 CHECK 枚举里存在,从 U-001 起就有,但从未被任何代码写过——不需要新 schema 迁移)**: + +```python +def _log_skill_iterated_event(conn, run_id, target_node_id, skill_id, iteration_id): + row = conn.execute("SELECT wgr.task_id, t.project_id FROM work_graph_run wgr " + "JOIN task t ON wgr.task_id = t.task_id WHERE wgr.run_id = ?", (run_id,)).fetchone() + conn.execute( + "INSERT INTO execution_event (event_id, project_id, task_id, run_id, event_type, payload) " + "VALUES (?, ?, ?, ?, 'skill_iterated', ?)", + (str(uuid.uuid4()), row[1], row[0], run_id, json.dumps({ + "node_id": target_node_id, "skill_id": skill_id, "iteration_id": iteration_id, + })), + ) + conn.commit() +``` + +这条 event 是 Phase 3 前端(FR-5b 历史回放)能把"这一轮为什么跟上一轮不一样"讲清楚的关键数据——不只是"跳回了",而是"跳回之前,Skill 被真的改过一次,改了什么"。 + +### 5.6 Re-loop:不需要新机制 + +`_fix_skill_for_node` 返回后,§5.3 紧接着照常 `raise _LoopBackRequested(...)`——跟"一次性内容错误"分支走的是同一行代码。两条路径唯一的区别就是要不要先经过 §5.5,跳回执行本身完全复用现有机制(§5.3 已经说明为什么不需要改 `run_graph()` 主循环)。 + +### 5.7 安全边界(含新增的 Fix-Skill 专属风险) + +1. **运行时校验目标必须是真实祖先**(§5.3 已展示)——即使判断调用本身犯错选了一个不存在或非祖先的 node_id,直接报错而不是静默产生一次无意义的回路。 +2. **判断未达成 2/3 一致时的退化策略**:不像 `root_cause_trace` 那样升级人工(那是跨 Org Agent 边界的终局判断,这里只是图内部一次重试的一部分),退化为"回退到 `candidate_node_ids` 里离失败节点最近的直接上游 + `defect_kind` 视为 `content_error`"(保守默认——不确定是不是系统性问题时,不touch Skill,只是普通回退)。 +3. **`_resolve_loop_limit` 的计数维度不变**:`_loop_attempt_count_from_events` 是按 `(target_node_id, source_node_id)` 这一对计数的(`work_graph_orchestrate.py:560-567`),`dynamic_loop_back` 每次可能选出不同的 `target_node_id`,意味着"同一个 source 连续 3 次动态判断到 3 个不同的 target"不会被现有计数逻辑拦住——额外加一个"同一个 source_node_id 累计触发 dynamic_loop_back 的总次数"上限(配置沿用 `work_graph_loop_limit_config` 同一张表,`config_id` 用 `"{org_node}:{source_node_id}:total"` 这个新的 key 形状),防止绕过限流、无限循环。 +4. ~~由 Validator 身份执笔改 Skill 这个设计判断需要用户确认~~——已通过 `AskUserQuestion` 确认(2026-08-12),采用 Validator 身份,见 §5.5。 +5. **同一个 Skill 在一次 run 内被反复重写的风险**:`_fix_skill_for_node` 本身没有"这个 skill_id 这次 run 已经改过一次"的去重检查——如果 Reflection-review 连续几轮都判定同一个 `skill_ref` 是系统性缺陷(比如第一次改得不够准,问题仍然复现),会对同一个 Skill 文件连续产生多次真实 Git commit。这不算错误(每次改动理论上都在收敛),但值得在 §5.3 的分支里加一条"同一个 `(run_id, skill_id)` 组合本次 run 内的 Fix-Skill 触发次数"计数,超过一个小上限(比如 2 次)后即使判定是 `skill_defect` 也退化成普通 `content_error` 处理,避免一次任务反复重写同一份 Skill——具体阈值留给实施阶段。 +6. **self-target 场景:Skill 库commit 和任务自己的代码改动共享同一个物理仓库**——`skill_query._REPO_ROOT` 默认等于 `paths.repo_root()`;当 `target_folder_path` 恰好也是 graphlemon 自己(`paths.is_self_target(target_folder_path)` 为真)时,两者是**同一个仓库**。`update_skill()` 直接对 `skill_query._REPO_ROOT` 做 `git add`+`commit`,不经过 05 §5.8 那套 worktree 隔离——这是**有意为之,不是遗漏**:`skill_update.py` 模块 docstring 早就说明"a Validator-triggered rewrite takes effect immediately",Skill 库改动本来就设计成直接落主仓库,不像任务自己的代码改动需要隔离后再合并。两者物理上不冲突(分别只碰 `skills//` 和任务自己改的路径,如 `src/`),但没有专门测试过"self-target 任务执行期间,Fix-Skill 的直接 commit 和该任务自己的 worktree 合并操作,在 git 层面是否有任何可观测的交互"——建议 Phase 2c 实施验收时至少构造一次这个组合场景跑一遍,不要假设"理论上不冲突"就等于"实测也没事"(这个代码库里 worktree 相关的坑,历史上从来都是实测才发现的,见 CLAUDE.md 的 Isolation 章节)。 + +--- + +## 6. Coder/Validator 接入自规划:`implement_code()`/`validate_implementation_with_own_graph()` 改造 + +### 6.1 增量追加接口(用户拍板,2026-08-08:`plan_work_graph()` 新增参数,不是新函数) + +原设计判断是新增一个独立函数 `plan_work_graph_append()`——**已被推翻**:用户拍板要求合并/冲突检测逻辑集中在 `plan_work_graph()` 一个函数里,新增一个可选参数 `append_to_run_id`,不是平行的第二个函数。理由(用户原话):`work_graph_plan.py` 本来就是 `_normalize_graph_manifest`/`_validate_loop_back_targets` 等 `graph_manifest` 结构性操作的归属地,避免 Coder、Validator 两个调用点各自维护一份合并逻辑、未来出现两处不一致的风险。 + +```python +def plan_work_graph(conn, org_node, task_id, goal_text, target_folder_path, timeout=300, append_to_run_id=None): + """append_to_run_id=None(默认):跟今天完全一样,INSERT 一个新 + work_graph_run 行。append_to_run_id=<既有 run_id>:不 INSERT 新行, + 改为读出该 run_id 现有的 graph_manifest,把这次新规划出的节点/边 + 合并进去(node_id 冲突时报错,不静默覆盖或改名),UPDATE 回同一行。 + 追加模式下会把已有节点的 node_id 清单(不含内容)注入 prompt 上下文, + 让新规划的节点能声明 depends_on 指向已有(可能是别的 org_node 产出的) + 节点。返回形状不变:{"run_id", "graph_manifest"}(追加模式下 run_id + 就是传入的 append_to_run_id,graph_manifest 是合并后的完整图)。 + """ + if org_node == "planner": + prompt = _PROMPT_TEMPLATE.replace("{goal_text}", goal_text) + else: + prompt = _generic_prompt_template(org_node).replace("{goal_text}", goal_text) + + if append_to_run_id: + existing = conn.execute( + "SELECT graph_manifest FROM work_graph_run WHERE run_id = ?", (append_to_run_id,) + ).fetchone() + existing_manifest = json.loads(existing[0]) + existing_ids = [n["node_id"] for n in existing_manifest["nodes"]] + prompt = prompt.replace( + "Plan (do not execute)", + "This graph already has these nodes (from earlier stages): {}. Plan (do not execute) " + "ADDITIONAL nodes only -- do not redeclare or duplicate the ones above.".format(existing_ids), + ) + + # ... 真实 subprocess 调用 + _extract_graph_manifest + _normalize_graph_manifest,跟今天一样 ... + new_manifest = _normalize_graph_manifest(_extract_graph_manifest(response_text)) + + if append_to_run_id: + collision = set(n["node_id"] for n in new_manifest["nodes"]) & set(existing_ids) + if collision: + raise PlanningFailedError("append_to_run_id={} node_id collision: {}".format(append_to_run_id, collision)) + merged = { + "nodes": existing_manifest["nodes"] + new_manifest["nodes"], + "edges": existing_manifest.get("edges", []) + new_manifest.get("edges", []), + } + conn.execute("UPDATE work_graph_run SET graph_manifest = ? WHERE run_id = ?", + (json.dumps(merged), append_to_run_id)) + conn.commit() + return {"run_id": append_to_run_id, "graph_manifest": merged} + + run_id = str(uuid.uuid4()) + conn.execute("INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) VALUES (?, ?, ?, 'planning')", + (run_id, task_id, json.dumps(new_manifest))) + conn.commit() + return {"run_id": run_id, "graph_manifest": new_manifest} +``` + +(`INSERT INTO work_graph_run` 不再写 `org_node` 列,见 §2.1 的删除决策。) + +调用方(`e2e_pipeline.implement_code()`)改造: + +```python +def implement_code(conn, task_id, run_id, sdd_content, goal_text, target_folder_path, ...): + # run_id 现在由调用方(run_task_lifecycle)传入 -- Phase 1 建立的那个 + # 跨阶段 work_graph_run,不再自己创建新 run + graph_manifest = work_graph_plan.plan_work_graph( + conn, "coder", task_id, goal_text, target_folder_path, append_to_run_id=run_id, + )["graph_manifest"] + outputs = work_graph_orchestrate.run_graph( + conn, run_id, "coder", "coder-implementation", target_folder_path, graph_manifest, + work_graph_plan.generic_node_prompt_builder, _CODE_INPUT_SCHEMA, _CODE_OUTPUT_SCHEMA, + needs_worktree=True, timeout=timeout, + ) + ... +``` + +`run_graph()` 的 `graph_manifest` 参数现在是**合并后的完整图**(既有节点 + 新增节点),但 `run_work_node()` 的既有断点续跑短路(已 `done` 的节点直接返回持久化输出)保证已有节点不会被重新执行——这跟 §5.3 提到的"再次调用 `run_graph()` 时自然跳过已完成节点"是同一个机制,不需要额外处理。 + +不再用 `{"nodes": [{"node_id": "implement", "depends_on": []}], "edges": []}` 这个合成单节点 manifest(`e2e_pipeline.py:637` 现状)——真正让 Coder 自己规划实现步骤。`validate_implementation_with_own_graph()` 同理,`_VALIDATOR_MANIFEST` 硬编码字面量被 `plan_work_graph(conn, "validator", ..., append_to_run_id=run_id)` 取代。 + +### 6.2 `run_id` 的传递链路 + +Phase 1 SDD 设计的 `plan_and_review_documents()`(新增函数,Planner 阶段用)已经创建了这次任务唯一的 `work_graph_run` 行;Phase 2 要求这个 `run_id` 一路透传给 `implement_code()`/`validate_implementation_with_own_graph()`,不能让它们各自继续 `INSERT INTO work_graph_run`。`run_task_lifecycle()`/`run_or_resume_task_lifecycle()`(Phase 1 已经改造过的两个函数)需要把 `plan_and_review_documents()` 返回值里的 `run_id` 一路带下去——这是 Phase 2 对 Phase 1 产出的一个新依赖点,Phase 1 落地时如果 `plan_and_review_documents()` 的返回形状没有 `run_id`,需要回头补一个字段(轻量修改,不影响 Phase 1 已完成的核心逻辑)。 + +--- + +## 7. 新增节点级验证 skill + 退休 `org_level_loop.py` 粗粒度路由 + +### 7.1 新 skill:`work-graph-node-review` + +新增 `skills/work-graph-node-review/SKILL.md`,`roles/planner_reviewer`(Phase 1 新增)、`roles/coder`、`roles/validator` 的 `role.json` 都引用它——org-agent-无关,是 `judgment.kind="llm"` 时 `judge_prompt` 的方法论参考(不是新的 Python 机制,`judgment.kind="llm"` 本身已经是引擎特性,这个 skill 提供的是"怎么写一个好的 judge_prompt/怎么识别叙述文字冒充结构化产出"的方法论沉淀,直接把 `validator-test-script-narrative-output-issue.md` §6 的根因分析和 `_NARRATION_RETRY_HINT` 的措辞模式吸收进去,成为所有自规划节点复用的判断标准,不是每个角色各写一份)。这份 skill 的方法论提炼出一个可复用的 `_NODE_REVIEW_JUDGE_PROMPT` 常量(放在 `work_graph_plan.py`,跟 §3.1 的建议目录同一个模块,因为目录本身就要引用它作为 `suggested_judgment.judge_prompt` 的默认值),不是每个目录条目各写一段相似的 judge_prompt。 + +不改造 `qa-task-validation`——那个 skill 判断的是"整体实现是否验收通过"(任务级),职责不同,两者并存,不合并。 + +### 7.1.1 新 skill:`skill-defect-remediation`(Fix-Skill 专属,§5.5 新增) + +新增 `skills/skill-defect-remediation/SKILL.md`,只挂到 `roles/validator/role.json`(不是 org-agent-无关的,只有执笔改 Skill 的身份需要它——见 §5.5 的"由谁来修"决策)。方法论核心:怎么在不破坏一份 Skill 现有正确行为的前提下,精确定位并只修正导致这次真实失败的那一小部分——跟 `work-graph-node-review`(判断"一个节点的输出好不好")职责完全不同,这个 skill 判断的是"怎么改一份方法论文档本身",不合并进同一份 skill。 + +### 7.2 `org_level_loop.py`/`org_loop_route.py` 的粗粒度路由退休 + +Phase 1 阶段"复核失败先用现有 `org_level_loop.py` 粗粒度路由兜底"(Phase 1 SDD 的过渡方案)在 Phase 2 落地后被替换:终审 Validator 节点声明 `on_fail.action="dynamic_loop_back"`(§5),失败时直接在**同一个** `work_graph_run` 内精确回退,不再需要 `route_org_loop()` 那种"改 `task.status`、换 `assigned_org_node`、期待调用方重新触发另一个函数"的跨阶段路由。 + +`org_level_loop.py::run_fix_loop`/`org_loop_route.py::route_org_loop`/`_NEW_STATUS`/`_NEW_ORG_NODE`/`task.loop_round` 字段——**不删除**(`root_cause_trace.judge_root_cause`/`trace_root_cause_human_reported` 等其他调用方,以及 bug_record 驱动的人工报告场景仍然用得到这套粗粒度分类),但新的合图任务流程不再经过它们;`scheduler.py::finish_task_lifecycle` 需要判断"这是走新合图流程的任务,还是走旧三段式流程的任务"(新流程的失败已经在 `run_graph()` 内部处理完,不会再走到 `finish_task_lifecycle` 的 FAIL/INCONCLUSIVE 分支去调用 `run_fix_loop`)——具体判断依据留给实施阶段(比如看 `work_graph_run.graph_manifest` 里节点是不是都带 `judgment`/`on_fail` 字段,或者更简单,直接看 task 是不是 Phase 1/2 上线之后创建的)。 + +--- + +## 8. 明确的不确定性(比 Phase 1 更大,如实标注) + +1. **`_judge_root_cause_node`(§5.4)的判断质量未经真实验证**——三视角判断"哪个上游节点该负责 + 是不是系统性缺陷"是全新设计,没有 A/B 数据支撑,上线后大概率需要根据真实误判案例调整 prompt/视角设计,不是一次写对。`defect_kind` 这个二选一判断尤其新——误判方向有两种,且代价不对称:把系统性问题误判成一次性(漏改 Skill,同类错误还会再犯,但代价只是"没有更好")比把一次性问题误判成系统性(多余地真实重写了一次 Skill 文件,代价是留下一次不必要但无害的 Git 提交)风险更值得关注,值得在真实运行中专门跟踪这两类误判各自的发生率。 +2. **`promotes_to_artifact`(§2.3)依赖 Coder/Validator 老实遵守"只有最终交付物才声明"这条约束**——是 prompt 层面的软约束,不是机械强制,理论上 LLM 可能给每个节点都声明 `promotes_to_artifact`(虽然 artifact_type 枚举本身会挡住不合法值,但不会挡住"过度声明"这种滥用),需要在真实运行中观察是否需要加机械检查。 +3. **`dynamic_loop_back` 的"同一 source 总次数上限"(§5.7 第 3 点)需要新的 `work_graph_loop_limit_config` key 形状**——`"{org_node}:{source_node_id}:total"` 这个具体格式没有先例,实施时需要重新过一遍 `settings_config.py` 的查询/写入接口是否兼容这种三段式 key,还是需要专门扩展。 +4. **`org_level_loop.py` 新旧流程共存期的判断依据**(§7.2 结尾)还没有定论,是留给实施阶段解决的开放问题,不是本文档的疏漏——但这个问题不解决,Phase 2 上线当天就会有"旧任务卡在新代码路径里"的真实风险,实施顺序上应该早排。 +5. ~~Fix-Skill 由谁执笔改 Skill 需要用户确认~~——已确认(2026-08-12),统一用 Validator 身份,理由见 §5.5。 +6. **发现的独立 dead-code 缺口,不阻塞 Phase 2 但值得单独一提**:`org_level_loop.py::_ROUTE_FOR_CATEGORY` 从来没有 `skill_defect` 这个 key(§5 开头"既有事实 B"),意味着这条旧的粗粒度路径下 `skill_update.update_skill()` 从来没被真正调用过。Phase 2 的 Fix-Skill 走的是全新的合图内部路径,不依赖修好这个旧 bug,但如果 §7.2 决定新旧流程要共存一段时间,这个旧路径本身的这个缺口在共存期内仍然是活的——是否值得顺手在 `_ROUTE_FOR_CATEGORY` 里补一条 `"skill_defect": "full_replan"`(不真的接 `update_skill()`,只是让路由不再是意外的默认值 fallback,语义更诚实),是一个独立、廉价、可以随时做的小修复,不必等 Phase 2c。 + +--- + +## 9. 跟 Phase 1 的接口核对清单——✅ 全部核对完成(2026-08-12) + +- [x] `plan_and_review_documents()` 的返回形状是否包含 `run_id`——**是**:`plan_and_review_documents()` → `_review_planner_output_with_retries()` → 透传拿到 PASS 那一轮 `plan_documents()` 的原始返回值 `{"run_id", "artifact_ids"}`,`run_id` 齐全,§6.2 的假设成立,`implement_code()`/`validate_implementation_with_own_graph()` 可以直接拿这个 `run_id` 用作 `append_to_run_id`。 +- [x] Phase 1 实际引入的新 `task.status` 值是不是 `reviewing_planner_output`——**是**,`src/schema.py`/`src/org_node_lifecycle.py` 均按此实现,与本文档假设完全一致。 +- [x] `roles/planner_reviewer/role.json` 的 `skills` 列表是否已经预留位置给 §7.1 新增的 `work-graph-node-review`——**没有预留**,当前是 `["planner-output-review", "unattended-agent-operations"]`。不是问题:Phase 2 实施 §7.1 时直接追加一行 `"work-graph-node-review"` 即可,`roles/coder`/`roles/validator` 的 `role.json` 同理需要各追加一行——这是 Phase 2c 实施清单里的一步,不是需要提前协调的接口不兼容。 + +--- + +## 10. 实施顺序(拆成三个可独立验收的子阶段) + +### Phase 2a — 引擎扩展(地基,不改任何 org 流程) ✅ 已实现(commit `86fc0bb`,2026-08-12) +- §4(节点级 subagent/skill/tools 绑定)+ §5(`dynamic_loop_back` 完整链路:Reflection-review + Fix-Skill)+ 对应单测。 +- **§2.2(`ux_work_graph_run_active` 迁移)实施时被推迟,没有跟这批一起做**——设计时发现 `plan_documents()`/`implement_code()` 产出的 `work_graph_run` 行在 happy path 上从来不会被标记 `'done'`(只有 Validator 自己的图会),如果现在就把索引收紧成 `UNIQUE(task_id)`,会在 Phase 2b 的追加机制真正接上**之前**就先把现有生产流程弄崩——Coder 一插自己的行就会跟还没结束的 Planner 行撞车。已重新确认要推迟到 Phase 2c(Validator 也接入追加机制、三段真正共享一个 run 之后)才安全,见 Phase 2b 状态行的第 4 条。 +- 验收:手写的合成 graph_manifest(不经过真实 LLM 规划),节点声明不同的 `subagent_type`,运行后 `work_node_execution.subagent_type` 确实按节点区分;另一个手写 manifest 触发 `dynamic_loop_back`,验证真的能跳到候选节点之一且计数正确——均已有对应单测(`src/test_work_graph_orchestrate.py`:`NodeLevelSubagentSkillTest`/`DynamicLoopBackContentErrorTest`/`DynamicLoopBackSkillDefectTest`/`TallyReflectionTest`)。 +- 实施过程中发现并修复的真实 bug:节点级 `skill_ref` 没有走 `ensure_placeholder_skill`,第一次用到新 skill_id 会撞外键约束——已在 `_run_one_node` 里补上。 +- `python3 -m unittest discover -s src`(741 tests)+ `discover -s supervisor`(126 tests)全绿。 + +### Phase 2b — Coder 接入自规划 ✅ 已实现(commit `7c46a2f`,2026-08-12) +- §3(`_generic_prompt_template` 按 org_node 扩展 + 建议目录)+ §2.3(`promotes_to_artifact`)+ §6(`implement_code()` 改造为 `plan_work_graph(..., append_to_run_id=run_id)`)。 +- 实施过程中定位并修复的、SDD 原稿没预见到的问题: + 1. **`_review_planner_output_with_retries()` 复用已有 artifact 分支硬编码 `run_id=None`**——每个真实 artifact 行本来就有真实的 `produced_by_run_id`,改成从 `sdd` artifact 读取真实 run_id,不再是 `None`(否则 Coder 在这条 resume 路径上完全没有 run 可追加)。 + 2. **`implement_code()` 从来没有真的传过 `needs_worktree=True` 给 `run_graph()`**——单节点合成图时代无害(图里只有一个节点,没有并发碰撞的可能),但自规划之后 Coder 图可能真的有并行写代码的节点,03-C §3.6 的隔离要求这次才第一次真正生效。 + 3. **`work_graph_fork.py::_reconstruct_build_prompt` 必须改成逐节点 dispatch**(原计划就在 Phase 2b 范围内,§2.1.2 已预见),否则合并图一旦真的出现 Coder 节点,fork 功能会直接报 `ForkGraphShapeUnsupportedError`。 + 4. **§2.2 索引收紧比 Phase 2a 判断的还要晚**:Phase 2a 曾判断"等 Phase 2b 落地后就能收紧",实施 Phase 2b 时发现不对——Validator 在 Phase 2c 之前仍然会给自己开一个独立的 `work_graph_run`,收紧到 `UNIQUE(task_id)` 会让 Validator 那次 INSERT 直接跟还没终结的 Planner+Coder 合并行冲突。§2.1/§2.2 已重新确认推迟到 Phase 2c(Validator 也接入追加机制之后)才安全。 +- 新发现的测试基础设施缺口(不是设计问题,是既有测试夹具没预料到并发层):合并图里"已完成的旧节点"和"刚追加的新节点"如果互不依赖,会被 `topological_layers()` 分进同一层,触发 `run_graph()` 的多节点并发路径——`test_task_execution.py`/`test_e2e_pipeline.py` 的测试连接原来没开 `check_same_thread=False`,从没触发过这条路径,现已修正(生产连接本来就是对的,`scheduler.py`/`task_execution.py` 早就用 `check_same_thread=False`,只是测试夹具没跟上)。 +- `python3 -m unittest discover -s src`(752 tests)+ `discover -s supervisor`(126 tests)全绿。 + +### Phase 2c — Validator 接入自规划 + 精确 loop_back 收口 +- Validator 侧的 §6 改造 + §7(新 skill + 退休粗粒度路由)+ §9 的核对清单。 +- 验收:真实构造一次"终审失败,根因在 SDD 节点"的场景,验证 `dynamic_loop_back` 精确跳回 SDD 节点重新执行,SDD 的下游(Benchmark/Coder 实现/…)全部正确重跑,不需要人工介入,也不需要经过 `org_level_loop.py`。 diff --git a/docs/done/scheduler-new-goal-dispatch-requirements.md b/docs/done/scheduler-new-goal-dispatch-requirements.md new file mode 100644 index 0000000..160f5f6 --- /dev/null +++ b/docs/done/scheduler-new-goal-dispatch-requirements.md @@ -0,0 +1,58 @@ +# 新 Goal 自动派发——需求文档 + +**日期**: 2026-08-06 +**性质**: 需求文档(BRD/PRD 层面),不含详细技术设计。**2026-08-08 拆分**:原文档 +`docs/todo/scheduler-production-polling-loop-requirements.md`(FR-1/FR-2 合并)已按完成状态拆分—— +FR-2(在途任务复原)已实现,移入 `docs/done/scheduler-task-reconciliation-requirements.md`/ +`-design.md`;本文档只保留仍是草案的 FR-1。 +**状态**: 草稿,未立项。`docs/implementation-gap-analysis.md` 第 6 条已把这条标注为"改变系统自己的 +自主权边界",需要单独评审决策,不与其他 gap 混跑。 + +## 1. 背景 + +`run_scheduler_once(db_path, goals, max_workers=None)` 的入参 `goals` 是 `[(target_folder_path, +goal_text), ...]`,语义是"这一轮要**新建**并跑完这些 Goal",内部通过 `task_create.create_task()` +每次都新建一个 `task` 行。这个函数存在且经过测试,但从未被任何常驻进程接入——`queued` 状态的新 +Goal(还没被任何调用触发过 `run_goal_to_completion`)目前只能靠人工手动点击控制台的 Start 按钮 +执行,不会被自动拿去跑。 + +跟"在途任务复原"(`docs/done/scheduler-task-reconciliation-requirements.md`,已实现)是两个独立 +问题:那个是"已经开始执行的任务卡住了怎么自动接回去",这个是"还没开始执行的新 Goal 怎么自动开始"。 + +## 2. 目标 + +`queued` 状态的任务(或者说,还没被任何调用触发过 `run_goal_to_completion`/ +`run_or_resume_task_lifecycle` 的新 Goal)需要被自动拿去执行,不需要人工每次手动点 Start。 + +## 3. 非目标 + +1. **不在本文档里裁定"要不要做""什么时候做"这个更大的问题**。是否现在就投入、要不要额外的 + 人工评审门槛,是需要单独确认的决策,本文档只负责把需求本身讲清楚,供决策时参考。 +2. **不要求解决方案是常驻进程/独立进程/`backend_server.py` 内置线程三选一中的哪一个**。具体载体 + 形式留给设计阶段。 +3. **不重新设计 `run_scheduler_once` 本身**。它的行为已经是对的,本需求要解决的是"谁、在什么 + 时机去调用它"这一层。 + +## 4. 功能需求 + +| # | 需求描述 | 优先级 | +|---|---|---| +| FR-1 | 需要一个机制,定期(具体周期留待设计阶段)调用 `scheduler.run_scheduler_once()` 派发新 Goal。 | 必须 | + +## 5. 验收标准 + +新建一个 `queued` 状态的 Goal,不手动点 Start,验证机制能在设定周期内自动派发执行。 + +## 6. 风险 + +**这是改变系统自主权边界的决策**——意味着系统会在没有人显式点击"Start"的情况下自主开始消耗 +真实 API 调用、真实修改代码。这条风险不因为实现简单而减轻,需要在决策阶段被认真对待。 + +## 7. 开放问题(留给详细设计阶段回答,需要先拍板"要不要做"再讨论这些) + +- [ ] 轮询周期定多少。 +- [ ] 载体形式:`backend_server.py` 内置后台线程,还是独立进程。 +- [ ] 要不要暴露成可配置项(管理后台开关/环境变量),还是先硬编码一个保守的默认行为。 +- [ ] 要不要跟已实现的在途任务复原循环(`docs/done/scheduler-task-reconciliation-design.md`) + 共用同一个后台线程/轮询周期,还是保持两个独立循环——两者已实现的那部分刻意没有合并 + 成一个"万能调度循环",这个原则是否延续到 FR-1 需要在设计阶段确认。 diff --git a/docs/done/work-graph-node-execution-deblackbox-requirements.md b/docs/done/work-graph-node-execution-deblackbox-requirements.md new file mode 100644 index 0000000..46ca78a --- /dev/null +++ b/docs/done/work-graph-node-execution-deblackbox-requirements.md @@ -0,0 +1,198 @@ +# Work Graph 节点执行去黑盒化——需求+设计文档(合并,方向已定) + +**日期**: 2026-08-08 +**性质**: 需求+设计合并文档——用户已经明确拍板方向(自研,不复用 Claude Code 原生 Dynamic Workflow),且调研发现代码库里已经有一个生产验证过的现成替代方案(`plan_work_graph()`),不需要走"逐条开放问题讨论"流程,直接给出可实施设计。对应 `docs/graphlemon_architecture.md` §10.2(2026-08-08 决策修订)。 +**触发场景**:用户追问"之前设计说用 Claude Code 自带 dynamic workflow,现在要去掉、自己实现,这个描述改了吗?为了可控,不再黑盒"——调研发现 `docs/graphlemon_architecture.md` §10.1"方案 A"(已确认:Work Graph 执行层复用原生 Dynamic Workflow)从未被正式撤回,但图编排层早就自研了(`work_graph_orchestrate.py`,REQ-06 断点续跑驱动);**单节点执行层还没有**——三处调用点仍在用 `write_workflow_script`/`execute_work_graph()` 生成/执行一次性 Dynamic Workflow `.js` 脚本。 +**状态**: 设计完成,待实施。 + +--- + +## 1. 背景:为什么单节点层至今还留着原生 Workflow + +`work_graph_orchestrate.py` 模块docstring 记录的原因:`claude --agent -p ... --json-schema ` 这个 CLI 顶层组合会**静默丢弃** `structured_output`(05 §5.4.1.1 实测发现,人设加载正常、schema 校验失效)。Workflow DSL 内部的 `agent(prompt, {agentType, schema})` 组合是当时(2026-08-01,U-031)唯一验证过能同时做到"加载 org_node 人设"+"拿到 schema 校验过的结构化输出"的机制(05 §5.4.1.5)。 + +**这个理由已经不成立**:`work_graph_plan.py::plan_work_graph()`(`c_work_graph_plan`)面对的是完全相同的"`--agent` 身份 + 结构化输出"需求,但它从一开始就没有用 Workflow——直接 `subprocess.run(["claude", "--agent", org_node, "-p", prompt, "--output-format", "json", ...])`,从自由文本 `result` 字段里用 ```` ```json ```` 围栏 + 正则提取解析(`_extract_graph_manifest`,05 §5.4.1.1 自己推荐的应对方式),且早已在生产环境真实跑通(Planner 四节点文档链、这次新设计的 Phase 1 复核调用也是这个模式)。"必须用 Workflow 才能两者兼得"这个前提是错的,Workflow 只是**也**能做到,不是唯一路径。 + +### 1.1 当前仍依赖 `write_workflow_script`/`execute_work_graph` 的三个调用点 + +| # | 位置 | 用途 | `agentType` | +|---|---|---|---| +| 1 | `work_graph_orchestrate.py::_make_run_fn`(`_render_node_script`,L361-370) | 每个 Work Node 真正的内容生成调用——**主执行路径,影响面最大** | 该节点所属的 `org_node` | +| 2 | `work_graph_orchestrate.py::_llm_judgment_verify_fn`(L420-467) | `judgment.kind="llm"` 节点判断(Part A,2026-08-08 新增,Phase 2 会大量增加这类调用) | 该节点所属的 `org_node` | +| 3 | `e2e_pipeline.py::_check_prd_consistency`(L309-350) | PRD 内部矛盾检测(REQ-02 边界情况) | 固定 `'planner'` | + +三处都是"渲染一段一次性 `.js` 脚本 → `write_workflow_script` 落盘 → `execute_work_graph()` 跑 `claude -p 'Run /...'`"的同一个模式,且都已经有独立于 Workflow 的 self-target worktree 隔离逻辑包裹(`memory: project` 写入需要隔离,跟用不用 Workflow 无关,迁移时**不动**这部分)。 + +--- + +## 2. 目标(FR) + +| # | 需求描述 | +|---|---| +| FR-1 | 新增一个通用的、`output_schema`-无关的辅助函数 `trigger_org_node_structured()`,用"flat `claude --agent` 调用 + JSON 围栏提取"取代"渲染 Workflow 脚本 + `claude -p 'Run /...'`",供三个调用点共用。 | +| FR-2 | 把 `work_graph_plan.py::_extract_graph_manifest` 里"整段当 JSON 解析、失败则逐个 ```` ```json ```` 围栏尝试"的算法抽成一个通用 helper,`_extract_graph_manifest` 和 `trigger_org_node_structured` 都调用它,不重复实现。 | +| FR-3 | 三个调用点依次迁移到 `trigger_org_node_structured()`,删除对 `write_workflow_script`/`execute_work_graph` 的调用。 | +| FR-4 | 保留 `work_node_run.py`(U-020)按节点降级模型重试的能力——`trigger_org_node_structured()` 必须支持等价的模型覆盖参数,且**必须真实验证**(不能假设)`claude --agent -p ... --model ` 这个 CLI 组合行为符合预期(05 §5.4.1.5 的教训:DSL 内部选项和 CLI 顶层参数是两条不同路径,不能凭直觉套用其中一条的结论)。 | +| FR-5 | 保留 `execute_work_graph_with_usage_limit_retry()` 的 5→10→20→30 分钟退避重试能力,迁移到包裹 `trigger_org_node_structured()` 外层,退避算法本身不变,只换内层调用。 | +| FR-6 | 迁移完成后,`write_workflow_script`/`execute_work_graph`/`execute_work_graph_with_usage_limit_retry` 三个函数不再被生产代码调用——**不删除** `work_graph_execute.py` 文件本身(先确认没有其他调用方,尤其是 `work_graph_fork.py` 时间旅行调试功能是否也依赖它),同步更新 `CLAUDE.md` 里 `.claude/workflows/` 一节的描述,说明这是历史遗留机制。 | + +--- + +## 3. 非目标 + +1. **不改变 Work Graph 的图拓扑/编排逻辑**——`work_graph_orchestrate.py` 的 `topological_layers()`/`run_graph()`/loop_back 机制本来就是纯 Python 自研的,不依赖 Workflow,不受这次改动影响。 +2. **不改变 `org_node_trigger.trigger_org_node()` 本身**——新函数是它的姊妹函数(多了 schema 提取),不是替换它;`_READY_PROMPT` 握手调用、Validator 最终 verdict 调用等现有的 flat 调用完全不变。 +3. **不处理 worktree 隔离/`memory: project` 写入隔离逻辑**——这部分独立于"用 Workflow 还是用 flat 调用",迁移时原样保留包裹逻辑,只换内层实现。 +4. **不在这次改动里处理 `docs/graphlemon_architecture.md` 第 10 节表格第 14 条本身**——那是课程技法的对照记录,不是决策,不需要改;改的只是 §10.1/§10.2 的决策结论(已在本文档触发前完成)。 + +--- + +## 4. 详细设计 + +### 4.1 新函数:放在 `org_node_trigger.py`(flat 调用的家) + +```python +class StructuredOutputExtractionError(Exception): + """trigger_org_node_structured 在 result 文本里找不到匹配 output_schema + 必需字段的 JSON payload。""" + + +_JSON_FENCE_INSTRUCTION = ( + "Your final answer MUST be a single JSON object wrapped in exactly one " + "```json fenced code block, matching this schema: {schema}. Do not " + "describe what you did or are about to do -- the fenced block's content " + "IS the answer, not a summary of it." +) + + +def trigger_org_node_structured(org_node, prompt, target_folder_path, output_schema, model=None, timeout=300): + """Like trigger_org_node(), but also extracts a JSON payload matching + output_schema's top-level required keys from the free-text response -- + 05 §5.4.1.1's documented workaround (never pass --json-schema alongside + --agent; ask for a single ```json fenced block in the prompt instead, + same convention plan_work_graph()'s _PROMPT_TEMPLATE/_GENERIC_PROMPT_ + TEMPLATE already use). `_JSON_FENCE_INSTRUCTION` is appended to `prompt` + automatically -- callers don't repeat this boilerplate themselves. + `model`: real CLI --model override (FR-4 -- verify empirically before + relying on this in production, see this module's test file for the + real-call verification this needs). + """ + args = ["claude", "--agent", org_node, "-p", prompt + "\n\n" + _JSON_FENCE_INSTRUCTION.format(schema=json.dumps(output_schema))] + if model: + args += ["--model", model] + args += ["--output-format", "json", "--permission-mode", "auto"] + result = subprocess.run( + args, cwd=target_folder_path, stdin=subprocess.DEVNULL, + capture_output=True, text=True, timeout=timeout, + ) + if result.returncode != 0: + raise OrgNodeTriggerError( + "trigger_org_node_structured failed for {}\nstderr: {}\nstdout (truncated): {}".format( + org_node, result.stderr, result.stdout[:4000] + ) + ) + payload = json.loads(result.stdout) + if _looks_like_usage_limit_response(payload): # reuses org_node_lifecycle's existing phrase-match logic + raise UsageLimitReachedError(...) + extracted = extract_json_payload(payload.get("result", ""), required_keys=output_schema.get("required", [])) + if extracted is None: + raise StructuredOutputExtractionError( + "could not extract a payload matching {} from org_node={}'s response".format( + output_schema.get("required"), org_node + ) + ) + return extracted +``` + +(`UsageLimitReachedError`/`_looks_like_usage_limit` 目前分别定义在 `work_graph_execute.py`/`org_node_lifecycle.py` 两处、逻辑不完全相同——这次顺带看要不要统一成一个共享 helper,具体放哪个模块留给实施阶段判断,不在本设计强行拍板,避免引入不必要的耦合决策。) + +### 4.2 `extract_json_payload` 泛化(新增在 `work_graph_plan.py`,或提到更底层的共享位置) + +```python +def extract_json_payload(text, required_keys=()): + """整段先当 JSON 解析,失败则逐个 ```json 围栏尝试;只接受包含全部 + required_keys 的 dict(或包一层同名 wrapper key,模型有时会加这层)。 + `_extract_graph_manifest(text)` 等价于 + `extract_json_payload(text, required_keys=["nodes", "edges"])` 外加它自己 + 的 "graph_manifest" wrapper-key 特殊处理——保留 `_extract_graph_manifest` + 作为薄封装,不强行让所有调用方都用同一个 wrapper-key 名字。 + """ + ... +``` + +`_extract_graph_manifest` 改成: + +```python +def _extract_graph_manifest(text): + result = extract_json_payload(text, required_keys=["nodes", "edges"]) + if result is not None: + return result + # existing "graph_manifest" wrapper-key fallback, unchanged + ... +``` + +### 4.3 三个调用点的迁移 + +**`_make_run_fn`(主执行路径)**: + +```python +def run_fn(cwd, model=None): + _log_event(conn, node_exec_id, "node_start", org_node=org_node, model=model) + try: + output = org_node_trigger.trigger_org_node_structured( + org_node, prompt, cwd, output_schema, model=model, timeout=timeout, + ) + except org_node_trigger.UsageLimitReachedError as e: + _log_event(conn, node_exec_id, "quota_limit", quota_limit_type=e.quota_limit_type) + work_node_run.raise_usage_limit_signal(e.quota_limit_type) + except Exception as e: + _log_event(conn, node_exec_id, "node_failed", error=str(e)) + raise + else: + _log_event(conn, node_exec_id, "node_completed", output=output) + return output +``` + +`_render_node_script`/对 `work_graph_execute` 的 import 一并删除。 + +**`_llm_judgment_verify_fn`**:`try` 块内 `work_graph_execute.write_workflow_script(...)` + `execute_work_graph(...)` 两行替换成一行 `result = org_node_trigger.trigger_org_node_structured(org_node, prompt, cwd, _JUDGMENT_SCHEMA, timeout=timeout)`;worktree 包裹逻辑(create/ensure_committed/merge/remove)完全不动。 + +**`_check_prd_consistency`**:同样把 `write_workflow_script`+`execute_work_graph` 两行替换成 `org_node_trigger.trigger_org_node_structured('planner', prompt, cwd, _PRD_CONSISTENCY_SCHEMA, timeout=timeout)`;worktree 逻辑不动。 + +### 4.4 usage-limit 退避重试(FR-5) + +`work_graph_execute.execute_work_graph_with_usage_limit_retry` 的退避骨架(5→10→20→30 分钟,`sleep`/`now` 参数注入以便测试)整体保留,唯一改动是内层从 `execute_work_graph(...)` 换成 `org_node_trigger.trigger_org_node_structured(...)`——这个包裹函数本身可以留在 `work_graph_execute.py`(模块名不用改,职责变成"给 flat 调用包一层 usage-limit 退避",不是"驱动 Dynamic Workflow"),或者搬到 `org_node_trigger.py` 跟 `trigger_org_node_structured` 放在一起——搬迁与否留给实施阶段判断,不影响外部调用方看到的行为。 + +### 4.5 `.claude/workflows/` 目录的后续状态 + +三个调用点全部迁移完成后,生产代码不再往 `.claude/workflows/` 写新文件(`work_graph_fork.py` 的时间旅行调试功能如果也依赖 Workflow 脚本重放机制,需要在实施前单独确认——这是这次调研没有覆盖到的一个盲点,**实施第一步必须先查**,如果它依赖,这次迁移范围要么把它也迁掉、要么明确排除并记录原因)。已经生成的历史 `.js` 文件保留在版本库里(执行痕迹,不回溯删除),`CLAUDE.md` 关于这一节的描述在迁移完成后需要同步更新为"历史机制,2026-08-08 后不再新增"。 + +--- + +## 5. 测试方案 + +- `src/test_org_node_trigger.py`(新文件或扩展现有测试):`trigger_org_node_structured()` 的 mock 化测试(构造各种 `result` 文本形态:整段 JSON、单个 ```json 围栏、多个围栏取第一个匹配的、找不到任何匹配抛 `StructuredOutputExtractionError`);usage-limit 响应识别。 +- `src/test_work_graph_plan.py`:`extract_json_payload` 泛化后,`_extract_graph_manifest` 的既有测试必须原样全绿(回归测试,不能因为泛化引入行为变化)。 +- `src/test_work_graph_orchestrate.py`:`_make_run_fn`/`_llm_judgment_verify_fn` 迁移后的 mock 测试,从 mock `work_graph_execute.write_workflow_script`/`execute_work_graph` 改成 mock `org_node_trigger.trigger_org_node_structured`——**这是这次改动里回归面最大的一处**,`work_graph_orchestrate.py` 自己的现有测试大概率有几十处需要同步改 mock 目标,实施时预留足够时间。 +- `src/test_e2e_pipeline.py`:`_check_prd_consistency` 同上。 +- **`RUN_REAL_AGENT_E2E=1` 真实验证(FR-4 的强制要求,不是可选项)**:至少跑一次真实 `--agent -p ... --model ` 组合,确认①人设正确加载(跟 U-031 当初验证 DSL 路径时做的"追问角色身份,确认自称迁移后的人设"同一手法)、②`--model` 覆盖真的生效、③JSON 围栏提取在真实模型输出上稳定工作(不是只在 mock 数据上测得过)。这条不能用 mock 替代,因为要验证的正是"从一个已知能用的路径换到另一个之前从没在这三个调用点上真实跑过的路径"。 + +--- + +## 6. 实施顺序 + +1. **先查 `work_graph_fork.py` 是否依赖 Workflow 脚本重放**——决定这次迁移范围是否要覆盖它(§4.5 提到的盲点)。 +2. `extract_json_payload` 泛化 + `_extract_graph_manifest` 回归测试(独立、低风险,先做)。 +3. `trigger_org_node_structured()` 新增 + mock 测试。 +4. 迁移影响面最小的调用点:`e2e_pipeline.py::_check_prd_consistency`(一个函数,独立)。 +5. 迁移 `_llm_judgment_verify_fn`(次小,Phase 2 会大量新增这类调用,越早切过去越好,减少 Phase 2 在两套机制上重复踩坑)。 +6. 迁移 `_make_run_fn`(影响面最大,放最后,前两步已经验证过新机制的可靠性)。 +7. `RUN_REAL_AGENT_E2E=1` 真实验证(FR-4 的模型覆盖 + 三个调用点各跑一次真实场景)。 +8. 全量回归 `discover -s src` + `discover -s supervisor`。 +9. `write_workflow_script`/`execute_work_graph`/`execute_work_graph_with_usage_limit_retry` 确认无生产调用方后,标记为历史遗留(不删除文件,加 docstring 说明),更新 `CLAUDE.md` 对应描述。 + +--- + +## 7. 跟 Org-Graph-Unification 系列文档的关系 + +正交但有先后关系:这次改动只影响**单节点执行机制**,不影响 Org Graph 流程形状或自规划范围,`docs/done/org-graph-unification-phase1-review-gate-design.md`(Phase 1)不依赖这次改动,可以并行/先后独立推进。但 `docs/todo/org-graph-unification-and-universal-self-planning-requirements.md` 的 **Phase 2**(FR-4,节点级验证 skill)会大量新增 `judgment.kind="llm"` 判断调用——如果这次去黑盒化工作能在 Phase 2 动工前完成,Phase 2 就不需要在两套机制(Workflow 和新的 flat 调用)之间选一套来复用,直接基于 `trigger_org_node_structured()` 设计,减少后续返工。建议顺序:这份文档 → Phase 2,不是必须但值得。 diff --git a/docs/todo/org-graph-unification-and-universal-self-planning-requirements.md b/docs/todo/org-graph-unification-and-universal-self-planning-requirements.md deleted file mode 100644 index 8a10242..0000000 --- a/docs/todo/org-graph-unification-and-universal-self-planning-requirements.md +++ /dev/null @@ -1,150 +0,0 @@ -# 统一 Org Graph 主流程 + 全体 Org Agent 自规划 Work Graph + 节点级验证回路 + 前端动态/历史图谱——需求文档(融合版) - -**日期**: 2026-08-08 -**性质**: 需求文档(BRD/PRD 层面),不含详细技术设计。融合并统领以下既有 `docs/todo/` 文档的相关内容: -- `docs/todo/org-graph-as-work-graph-unified-loop-requirements.md`(已删除,内容 100% 并入本文档)——本文档是它的展开与拍板;该文档提出的"跨 Org Agent 精确 loop_back"诉求是本文档 FR-4 + §4 关键架构决策的正文。 -- `docs/done/task-execution-resume-assumes-planner-artifacts-exist.md`——它记录的 bug 与本文档 FR-1 改动的是**同一段** `run_or_resume_task_lifecycle()` 分支逻辑(见 §2.1 调研结论),折入 Phase 1 一并修,不再单独排期。 -- `docs/todo/validator-test-script-narrative-output-issue.md` 第 8 节提到的"另一份尚未撰写、关于给 Coder agent 加入 test_script 模块的文档",即本文档 Phase 2(FR-2/FR-3)。 - -**触发场景**:任务 `2084590a`(修复 `work_node_run.py` 的 `execution_log_file` bug)里,Planner 自规划出的 `benchmark` 节点连续 4 次产出的都是"叙述我做了什么"的散文(跟 `validator-test-script-narrative-output-issue.md` 同一类问题,这次发生在 benchmark 上),LLM judge 的 `on_fail=retry_self` 循环耗尽 `max_attempts=3` 后任务直接 `blocked_pending_human`——暴露出 Validator 从来没有机会在 Coder 开始实现之前复核 Planner 自己的产出。用户随后 2026-08-08 提出四点架构改动设想(见 §1),与当天早些时候整理的 `docs/todo/` 待办优先级建议(P0~P3)高度交叉,要求重新盘点、融合、给出 phase 规划。 - -**状态**: 草稿,讨论中——§4 关键架构决策已拍板(方案 A,合图,2026-08-08),其余 §7 列出的问题仍待确认。沿用 `org-graph-as-work-graph-unified-loop-requirements.md` 的"逐条开放问题讨论 → 用户拍板 → 展开设计文档"两段式流程,本文档本身不是最终设计。 - ---- - -## 1. 用户提出的四点改动 - -1. Org Graph 固定流程从 `Planner → Coder → Validator` 改为 `Planner → Validator → Coder → Validator`——Coder 开始实现之前,Planner 自己的产出先过一道独立复核。 -2. 每个 Org Agent(不只 Planner)接到 goal 任务时,固定第一步是自己规划一张 Work Graph:每个节点声明用哪个 subagent + skill + tools + data 实现,以及判断节点成功/失败分别跳转到哪个节点(边的数据定义)。 -3. 前端要支持这张图的动态显示(执行中实时更新)和历史显示(多轮 loop 的回放),因为一定会有 loop(Validator 节点判断不通过时跳回前面某个节点)。 -4. 每个 Org Agent 都要有一套配套的 skill + subagent 定义——除了 Planner 现有的 brd/prd/sdd/benchmark/test_script 这类产出型 skill,还需要一个"验证型"skill:验证每个节点的结果,判断通过/不通过,不通过时精确跳回需要修复的那个节点,让该节点重新执行。 - ---- - -## 2. 现状盘点(2026-08-08 代码调研结论) - -### 2.1 Org Graph 固定流程写死的位置 - -- `src/org_node_lifecycle.py:30-31`:`_PRE_STATE`/`_POST_STATE` 只认 3 个 org_node(`planner`/`coder`/`validator`),线性对应 `task.status` 的 `queued → planning_brd_prd_sdd → coding → validating`。 -- `task.status` 是 `CHECK` 约束的固定枚举(`src/schema.py:106-111`:`queued/planning_brd_prd_sdd/planning_failed/coding/coding_failed/validating/routed_fast_lane/routed_full_replan/blocked_pending_human/done`),没有"Planner 产出待复核"这个中间态的容身之处。 -- 真正的调用顺序在 `src/e2e_pipeline.py::run_task_lifecycle()`(第 878 行起):`trigger_org_node_full(planner)` → `plan_documents()` → `trigger_org_node_full(coder)` → `implement_code()` → `trigger_org_node_full(validator)` → `validate_implementation_with_own_graph()`。`src/task_execution.py::run_or_resume_task_lifecycle()` 的 resume 分支复刻了同一条状态判断链。 -- **这正是 `task-execution-resume-assumes-planner-artifacts-exist.md` 记录的 bug 所在的同一段代码**:`planning_brd_prd_sdd` 分支的注释写的是"Planner done, Coder not yet triggered",无条件假设 sdd/prd/benchmark artifact 已经存在——插入一个新的"复核中"状态,必然要重写这段分支逻辑,顺手把这个已知假设过时的 bug 一并修掉比分两次改更省事。 - -### 2.2 现有的两套独立、粒度不同的 loop 机制(沿用并确认 `org-graph-as-work-graph-unified-loop-requirements.md` §1 的判断) - -- **跨 Org Agent、粗粒度**:`src/org_loop_route.py:36-39`,`_NEW_STATUS`/`_NEW_ORG_NODE` 只有两个目标——`fast_lane`→回 `coder`/`coding`,`full_replan`→回 `planner`/`planning_brd_prd_sdd`,`_LOOP_LIMIT = 3`。`src/org_level_loop.py` 的根因分类(`coder`/`planning`)也是 1:1 映射到这两个粗粒度目标,**没有节点级精度**。 -- **单个 Org Agent 内部、细粒度**:`work_graph_orchestrate.py` 的 `judgment`/`on_fail`/`loop_back` 机制(`docs/done/work-graph-self-planning-and-loop-back-design.md`)能精确跳回同一张图内的某个节点,但**只在同一次 `plan_work_graph()` 调用产出的同一张图内部**生效——Planner 和 Coder 是两次独立调用,节点 id 空间互不相通,这正是 `org-graph-as-work-graph-unified-loop-requirements.md` §4 提出、至今没有方案的问题。 - -### 2.3 已经具备、可以直接复用的能力(不需要重新发明) - -- `plan_work_graph(conn, org_node, ...)`(`work_graph_plan.py:336`)**本身已经是 org_node-generic** 的:`org_node == "planner"` 走 `_PROMPT_TEMPLATE`(固定催化清单),其余任何 org_node 都会落到 `_GENERIC_PROMPT_TEMPLATE`(自由规划 `{node_id, depends_on, task_prompt}`)——但目前**只有 Planner 的调用点在用它**。Coder 的 `implement_code()`(`e2e_pipeline.py:617-650`)和 Validator 的 `validate_implementation_with_own_graph()` 用的 `_VALIDATOR_MANIFEST`(`e2e_pipeline.py:210-222`)都是 Python 字面量硬编码的单/双节点图,没有 `judgment`/`on_fail` 字段,也从未调用过 `plan_work_graph()`。 -- `judgment`/`on_fail`/`loop_back` 执行引擎(`work_graph_orchestrate.py`)已验证是 org-agent-agnostic 的,不需要重做。 -- 逐节点落盘(`docs/done/work-graph-partial-artifact-persistence-requirements.md`,已实现)、Role Package 抽象(`roles//role.json`,已实现——新增 skill 只改 JSON,不碰 Python)、`design-dynamic-work-graph` skill(已存在,`coder`/`validator` 角色已声明,但目前只在人工发起的 `work_graph_fork.py` 时间旅行调试功能里用,没接生产路径)都是现成积木。 -- 前端 SVG DAG 渲染器(`frontend/app.js::renderGraphVisualizerPanel`/`mountGraphVisualizerEdges`)已经支持 `on_failure` 边线、自环(`retry_self`)计数徽标、经由既有 WS 事件流的准实时刷新——比它自己的 wireframe(`docs/wireframes/v_graph_visualizer.html`)还完整。 -- `loop_back_events` 数据已经从后端透出(`task_query.py:185` `_loop_back_events_for_run()`,含 `source_node_id`/`target_node_id`/`attempt_number`/`feedback[]`/`created_at`),前端已经用它驱动边标签和自环徽标计数——只是 `feedback[]` 拉取了但没渲染。 - -### 2.4 明确的缺口 - -- **没有任何"逐节点判断输出是否合格、并决定精确 `loop_back` 到哪个节点"的通用 skill**——今天的 `judgment.kind="llm"` 是引擎特性(一次额外的 LLM 判断调用),不对应任何角色的具名 skill/prompt 资产;`qa-task-validation` 判断的是整体实现,不是单个节点。 -- Coder、Validator 两处调用点仍是硬编码 manifest,未接入自规划。 -- 插入新的"Planner 产出复核"阶段需要 `task.status` schema 迁移;跨图 loop_back 寻址此前完全没有方案,现已按 §4 拍板为合图(方案 A),但 §4.1 列出的三个具体迁移点还未设计。 -- 前端:同一个 `work_graph_run` 内多轮 `loop_back` 只能看最新一次结果,没有按 `attempt_number` 的历史回放;Org 四段(Planner/复核/Coder/终审)之间没有统一元图,只有各自独立页面 + 文字徽标链接(fork 谱系同理,见 `docs/done/work-graph-time-travel-debugging-design.md`)。 - ---- - -## 3. 目标(草案 FR,待讨论细化为正式验收标准) - -| # | 需求描述 | 对应用户诉求 | -|---|---|---| -| FR-1 | Org Graph 固定流程改为 `Planner → Validator(复核 Planner 产出) → Coder → Validator(终审实现)`:新增 `task.status` 枚举值(schema 迁移)+ `org_node_lifecycle._PRE_STATE`/`_POST_STATE` 扩展 + `run_task_lifecycle()`/`run_or_resume_task_lifecycle()` 新分支。**该分支落地时一并修复 `task-execution-resume-assumes-planner-artifacts-exist.md` 记录的 resume 假设 artifact 存在的 bug**(同一段代码路径,不分两次改)。 | 1 | -| FR-2 | Coder(`implement_code()`)、Validator(`validate_implementation_with_own_graph()`)两处调用点,把硬编码 manifest 换成真实调用 `plan_work_graph(conn, "coder"/"validator", ...)`——函数本身已通用,只需替换调用点。 | 2 | -| FR-3 | `graph_manifest` 节点 schema 扩展显式的"实现绑定"字段:这个节点由哪个 subagent + 哪个 skill + 哪些 tools + 输入输出 data 实现。今天的 `judgment`/`on_fail` 已覆盖"怎么判断成功/失败、失败去哪",但"用什么实现"这一半目前只是 `_GENERIC_PROMPT_TEMPLATE` 给的一段自由文本 `task_prompt`,不是结构化字段——这是本次真正的新增 schema 工作。 | 2 | -| FR-4 | 新增一个 org-agent-无关的"节点级验证"skill(可被任意角色的 `role.json` 引用),作为 `judgment.kind="llm"` 的标准实现:验证节点输出、判断根因、精确 `loop_back` 到需要修复的节点,且当根因是**系统性 skill 缺陷**时先修 Skill 本身再重试——完整方法论见 §3.1,不是单纯"跳回某个节点重试"。用它统一/取代 `org_level_loop.py` 今天的粗粒度 `fast_lane`/`full_replan` 路由——这是 `org-graph-as-work-graph-unified-loop-requirements.md` 的核心诉求,按 §4 已拍板的方案 A(合图)落地:`loop_back` 目标节点和触发验证的节点在同一个 `work_graph_run` 内,不需要跨图寻址。 | 4 | -| FR-5a | 前端新增 Org 级元图:把 Planner/复核 Validator/Coder/终审 Validator 四段渲染成一张顶层图,每段可展开成该 Org Agent 自己的 Work Graph 详情(复用现有 `renderGraphVisualizerPanel`,不重写渲染器)。 | 3 | -| FR-5b | 前端新增同一个 `work_graph_run` 内按 `attempt_number` 的历史回放时间轴/scrubber,复用已经在拉取但未渲染的 `loop_back_events[].feedback`。 | 3 | - -### 3.1 核心方法论(用户 2026-08-08 拍板,作为本项目开发的目标写在这里):失败即进化——Reflection → Fix-Skill → Re-loop - -**这是 Work Graph"自我进化"能力的精髓,FR-4 必须按这个方法论实现,不能退化成"判断失败→跳回某个节点重试"这么简单。** 用户原话的核心洞察:一个节点判断失败后,我们要的不只是"把这一次的错误修好"(fix-bug,只解决这一次),而是"让这一类错误以后不会再发生"(fix-skill,永久解决)——否则下一个类似任务撞上同一个坑,还是要重新踩一遍、重新修一遍。 - -**四步链路**(不是一个笼统的"loop_back",是四个明确的阶段/节点): - -1. **判断分支**:节点产出被判定 `false`/`error`/`wrong`(`judgment.kind` 不管是 `mechanical` 还是 `llm`,只要 `ok=False`)——不是简单重试,跳转到分支 B。 -2. **分支 B 第一个节点:Reflection-review**——对整条链路做复盘,不只是"这个节点自己错在哪",而是**全链路根因定位**:到底是哪一个更早的环节(可能是这个节点自己的 Skill 有缺陷,也可能是更早某个上游节点的产出本身就有问题)真正导致了这次失败。判断结论要落到两种性质里的一种: - - **一次性内容错误**(这次具体的产出恰好没写好,Skill/方法论本身没问题)——走现有的精确 `loop_back`(Phase 2 SDD §5 的 `dynamic_loop_back`)即可,跳回真正的根因节点重新生成。 - - **系统性 skill 缺陷**(这个 org_node 用来产出这类内容的 Skill 本身有方法论层面的问题,同类任务会反复踩同一个坑)——进入第 3 步。 -3. **Fix-skill 节点**:不是修这一次的输出,是**修 Skill 本身**——真实调用已经存在的 Skill 迭代机制(`src/skill_update.py::update_skill()` + `src/bug_record.py::create_bug_record(root_cause_category='skill_defect')`),产出一次真实的、Git 版本化、可回滚的 Skill 内容更新(`skill_iteration` 表,`before_git_commit_sha`/`after_git_commit_sha` 都是真实 commit,不是模拟)。**这套机制在代码库里早就存在**(U-015/U-016 时期就建好了),但至今只在 `org_level_loop.py` 的 Org 级粗粒度路径上被触发过——FR-4 要把它真正接进 Work Graph 内部的判断/回路循环,让它成为节点级失败恢复的常规路径之一,不是只有跨 Org Agent 的大失败才配触发。 -4. **跳回执行**:Skill 更新落地后,跳回"用这个 Skill 的节点"重新执行——这次用的是刚修好的新 Skill,不是拿旧 Skill 再赌一次运气。跟 `_loop_history_prompt_suffix`(现有机制,把历史反馈拼进 prompt)的本质区别:prompt 里的反馈是**这一次任务专属、跑完就忘**的临时纠偏;Skill 更新是**持久化、跨任务复用**的真正修复——两者不互斥(可以同时生效:这次重试既带着历史反馈,也带着已经更新的 Skill),但 FR-4 的核心价值在后者。 - -**跟 Phase 2 SDD 的关系**:`docs/todo/org-graph-unification-phase2-universal-self-planning-design.md` §5 已经是四步的完整设计(2026-08-12 补齐)——`on_fail.action="dynamic_loop_back"` 触发时,Reflection-review(§5.4)同时判断"跳回哪个节点"和"这是一次性错误还是系统性 Skill 缺陷";系统性缺陷时先经过 Fix-Skill(§5.5,真实调用既有但从未真正投入生产使用的 `skill_update.py::update_skill()`,产出 Git 版本化的 Skill 迭代)再跳回执行。由哪个身份执笔改 Skill,已确认(2026-08-12)统一用 Validator(该文档 §5.5)。 - ---- - -## 4. 关键架构决策:方案 A(合图)——已确认,2026-08-08 - -延续 `org-graph-as-work-graph-unified-loop-requirements.md` §4 提出、原本没有答案的问题:**跨 Org Agent 的"节点"地址空间不通**。今天的 `loop_back` 只能在同一个 `work_graph_run` 内部跳转;Planner 和 Coder 各自的 `plan_work_graph()` 调用产出互不相通的节点 id 空间。FR-1 插入新的"复核"阶段后,这个问题从"理论上的开放问题"变成了"复核失败必须精确跳回 Planner 图里的哪个节点,而不是重新触发整个 Planner"这个具体的实现阻塞点。 - -当时摆在台面上的两个方案: - -- **方案 A(合图)**:Planner→复核→Coder→终审 四段合并成一张跨阶段大图,`loop_back` 天然能跳(同一张图内部)。 -- **方案 B(图间路由)**:保留"每个 Org Agent 各自一张图"的边界,新增一层跨图路由(复合地址如 `{run_id, node_id}`)。 - -**用户拍板(2026-08-08):采用方案 A。** FR-4、Phase 2 均按合图路径设计。 - -### 4.1 合图决策落地时需要处理的具体冲突点(Phase 2 设计阶段解决,本轮不预先决定怎么改) - -1. **`work_graph_run.org_node` 是单值列,且有唯一约束**:`src/schema.py:130`(`org_node TEXT NOT NULL CHECK (org_node != '')`)+ `src/schema.py:152`(`CREATE UNIQUE INDEX ux_work_graph_run_active ON work_graph_run(task_id, org_node)`,语义是"同一 task 同一 org_node 同一时刻只有一个活跃 run")。合图后一个 `work_graph_run` 会同时容纳 Planner/Coder/Validator 各自规划出的节点,`org_node` 归属需要下沉到**节点级别**而不是 run 级别——这正好可以和 FR-3(节点声明由哪个 subagent/skill 实现)合并解决:节点级的"实现绑定"字段天然可以承载"这个节点属于哪个 org_node"。`ux_work_graph_run_active` 这条唯一索引的语义也要重新设计(大图模式下"活跃 run"变成 per-task 唯一,而不是 per-(task, org_node) 唯一)。 -2. **FR-2(各 Org Agent 自规划)与"一张大图"如何协调**:不能是一次性提前把四段全部规划完——Coder 需要看到 Planner 的真实产出内容才能规划自己的实现节点,Validator 同理需要看到 Coder 的真实 diff。合理的模型是**增量追加**:每个阶段的 `plan_work_graph()` 调用把自己规划出的节点追加进**同一个** `run_id` 的 `graph_manifest`,而不是像今天一样各自开一个新 `run_id`。追加的原子性、并发写入 `graph_manifest` 的锁语义、追加后 `topological_layers()` 要不要对已执行完的前半部分重新分层,是 Phase 2 SDD 需要给出具体算法的地方。 -3. **REQ-06 断点续跑的代价被重新评估、判断没有原先设想的那么大**:合图讨论之初认为方案 A 会"打破每个 Org Agent 各自一次 `claude -p` 调用、各自断点续跑的隔离边界"(`work-graph-self-planning-and-loop-back-requirements.md` §1.3)。但节点级持久化(`work_node_execution` 表)本来就是"每个节点独立一次 `claude -p` 调用 + 独立落盘",这个粒度不因为节点归属同一个 `run_id` 还是分属不同 `run_id` 而改变——真正需要新设计的是上面两条(`org_node` 单值列迁移、增量追加规划机制),不是 REQ-06 本身。Phase 2 设计阶段仍需用真实测试验证这个判断。 - ---- - -## 5. 非目标 - -1. 不动 `scheduler-new-goal-dispatch-requirements.md`(FR-1,新 Goal 自动派发)——该文档已明确这是独立的自主权边界决策,与本次 Org Graph 主流程改动正交,不合并排期。 -2. 不要求第一版就把 `validator-test-script-narrative-output-issue.md` 的根因彻底修完——FR-2/FR-3 落地后 Coder 也会产出 test_script 类内容节点,叙述文字复发风险依然存在(该文档已记录用户接受此已知风险);本文档只是把"给 Coder 加 test_script 模块"接进 FR-2/FR-3 范围,Phase 2 落地时需要专门验证这条风险有没有复现,不单独立项新对策。 -3. 不重写 `work_graph_orchestrate.py` 的 `judgment`/`on_fail`/`loop_back` 引擎本身——它已经是通用的,本次只新增调用方(FR-2)和一个 skill(FR-4),不改引擎代码。 -4. 不重写前端已有的 SVG DAG 渲染器——FR-5 是在现有 `renderGraphVisualizerPanel`/`mountGraphVisualizerEdges` 基础上新增元图和回放时间轴,不是推倒重来。 - ---- - -## 6. Phase 规划 - -### Phase 1 — Org 主流程改造 + resume bug 一并修复 ✅ 已实现(commit `ea19c7d`,2026-08-08) -- **详细设计**:`docs/done/org-graph-unification-phase1-review-gate-design.md`(已随实现移入 `docs/done/`,与实现逐条吻合,见该文档更新后的状态行)。 -- **范围**:FR-1(含 `task-execution-resume-assumes-planner-artifacts-exist.md` 的修复)。 -- **产出**:`task.status`/`execution_event` 两处 schema 迁移、新增独立 org role `planner_reviewer`、`org_node_lifecycle.py` 的 `_PRE_STATE`/`_POST_STATE`/`_REQUIRED_ARTIFACTS` 扩展、`e2e_pipeline.py` 新增复核+重试循环、`run_task_lifecycle()`/`run_or_resume_task_lifecycle()` 新分支。复核失败的重试是任务内部的粗粒度循环(完整重新规划,不接 `org_level_loop.py`),不是"打回整个任务重来"。 -- **额外发现**(设计阶段调研中定位,一并修复):`e2e_pipeline.py::run_task_lifecycle()` 对 `artifact_ids["prd"]`/`["benchmark"]` 的无条件下标访问,在自规划图省略这两个类型时会 `KeyError`——这个崩溃在 fresh-run 首次执行就会触发,不需要等到 resume,是比 P0 bug 更早触发的同类问题。 -- **前置条件**:无——SDD 已给出具体改动点,可以直接实施。复核阶段本身可以先用任务内部的粗粒度重试兜底(复核失败 → 完整重新规划),不必等 Phase 2 的精确 loop_back 才能上线,先把"Coder 开始前多一道独立判断"这件事跑起来。 -- **验收**:能在(已真实复现过的)"benchmark 输出叙述文字冒充产出"场景下,在 Coder 动手之前拦住任务;崩溃后 resume 不再假设 Planner artifact 一定存在;fresh-run 也不再假设 prd/benchmark 一定存在。 - -### Phase 2 — 全员自规划 + 合图(方案 A)+ 节点级验证 skill -- **详细设计**:`docs/todo/org-graph-unification-phase2-universal-self-planning-design.md`。Phase 2a(节点级 subagent/skill 绑定 + `dynamic_loop_back`/Reflection-review/Fix-Skill,commit `86fc0bb`)+ Phase 2b(Coder 接入自规划,commit `7c46a2f`)已实现(2026-08-12);Phase 2c(Validator 接入自规划 + `org_node` 列删除 + 退休粗粒度路由)待实施。设计阶段发现范围比原设想更大——`_GENERIC_PROMPT_TEMPLATE` 从未征集 judgment/on_fail、`run_graph()` 的 subagent_type/skill_ref 是 run 级常量不是节点级、现有 `loop_back` 是规划阶段静态声明的单一目标不支持终审失败时动态选择根因节点(新增 `on_fail.action="dynamic_loop_back"` 解决,复用 `root_cause_trace.py` 陪审团结构)——详见该文档 §1/§5。 -- **范围**:FR-2 + FR-3 + FR-4,按 §4/§4.1 已拍板的合图路径实现。 -- **产出**(按拍板后的最终形状): - 1. `work_graph_run.org_node` 列彻底删除(不是下沉/保留),`work_graph_run_query.py`/`work_graph_fork.py` 改造为从 `work_node_execution.subagent_type` 聚合/逐节点判断;`ux_work_graph_run_active` 收窄为 `UNIQUE(task_id)`。 - 2. Coder/Validator 接入 `plan_work_graph()`,新增 `append_to_run_id` 参数(不是独立函数)实现增量追加进 Phase 1 建好的跨阶段 `work_graph_run`;新增 `_CODER_NODE_CATALOG`/`_VALIDATOR_NODE_CATALOG` 建议节点清单(不是完全自由规划)。 - 3. `run_graph()`/`_run_one_node()` 扩展为读取节点自己声明的 `subagent_type`/`skill_ref`(缺省退回图级默认),新增节点级验证 skill `work-graph-node-review`,`org_level_loop.py` 的粗粒度 `fast_lane`/`full_replan` 路由被 `dynamic_loop_back` 取代(不删除该模块,其他调用方仍在用)。 -- **前置条件**:Phase 1 落地的"复核"阶段先用 `org_level_loop.py` 粗粒度路由兜底上线;Phase 2 把这段兜底逻辑替换成图内精确 loop_back。 -- **风险**:Coder 开始自规划、产出内容型节点后,`validator-test-script-narrative-output-issue.md` 记录的叙述文字复发风险需要在本 phase 内验证是否触发,必要时补加机械拦截网(参考 `check_script_compiles()` 的先例),不是事后打补丁。 -- **建议前置(非强制)**:`docs/todo/work-graph-node-execution-deblackbox-requirements.md`(2026-08-08,单节点执行层去黑盒化,正交但相关)——Phase 2 会大量新增 `judgment.kind="llm"` 节点级判断调用,如果去黑盒化工作先完成,Phase 2 可以直接基于新的 `trigger_org_node_structured()` 设计,不需要在原生 Workflow 和新机制之间选一套来复用。 - -### Phase 3 — 前端动态 + 历史图谱(可与 Phase 2 并行) -- **范围**:FR-5a + FR-5b。 -- **产出**:Org 级元图 + `work_graph_run` 内 `attempt_number` 回放时间轴。 -- **依赖说明**:FR-5b 只依赖 `loop_back_events` 数据形状,今天已存在,不需要等 Phase 2 完成;FR-5a 的"复核"这一段需要 Phase 1 新增的 `task.status` 值才有东西可渲染,其余三段可以先做。 - -### 明确不排期 -`scheduler-new-goal-dispatch-requirements.md`(FR-1,新 Goal 自动派发)——保持独立评审,不合并进本文档的任何 phase。 - ---- - -## 7. 待确认的问题(讨论后逐条勾掉,参照 `graph-engineering-open-items.md` 的状态图例) - -✅ §4 关键架构决策:方案 A(合图)。2026-08-08 用户拍板。 -✅ FR-1 新增的 `task.status` 枚举值定为 `reviewing_planner_output`。2026-08-08,随 Phase 1 SDD(`docs/done/org-graph-unification-phase1-review-gate-design.md`)一起拍板,理由见该文档 §3.1。 -✅ FR-1 的"复核 Validator"用新增独立 org role `planner_reviewer`,不复用 `validator`。2026-08-08 用户拍板(`AskUserQuestion` 确认),理由见 Phase 1 SDD §2.1——`_PRE_STATE`/`_POST_STATE` 是按 org_node 名字一对一映射,同一个 key 没法挂两组状态转移。 -✅ FR-4 的节点级验证 skill:新开一个 skill,不在 `qa-task-validation` 基础上改造。2026-08-08 用户拍板。理由:`qa-task-validation` 整份内容绑定飞书多维表格语义(硬编码 `tblXXX` 表 ID、状态机是"待测试→已完成"这类 Base 记录写回),判定粒度是整个任务的 PASS/FAIL/INCONCLUSIVE;FR-4 要的是单个 Work Graph 节点的 `judgment.kind="llm"` 回调,输入是一个节点的输出,输出要精确指向 `loop_back` 的目标 `node_id`,和 Base 完全无关,两种用途混进同一份 skill 容易互相污染。新 skill 可以借鉴其判定原则(三态结论、独立复核、真实路径优先)写进 prose,但不复用文件本身。 -✅ Phase 2 的 Coder/Validator 自规划:加一份建议清单(类似 `_PLANNER_NODE_CATALOG`),不是完全自由。2026-08-08 用户拍板——推翻了 `work_graph_plan.py:161-170` 2026-08-06 注释里"Coder/Validator's sub-tasks have no such fixed small vocabulary of node types"的原判断。Phase 2 设计阶段需要给 Coder/Validator 各自定义一份"常见节点类型"参考目录(如 impl/unit_test/integration_check),每项可以像 `_PLANNER_NODE_CATALOG` 一样标注 `suggested_judgment`,但不强制——沿用 Planner 目录"建议不强制"的既有约定,而不是 Planner 文档链那种固定顺序的强约束。 -✅ §4.1 第 1 点:`work_graph_run.org_node`(run 级单值列)彻底移除,查询改为从节点级聚合。2026-08-08 用户拍板——推翻了"保留作冗余字段"的备选项。Phase 2 设计阶段需要解决两处下游改造:`work_graph_run_query.py:62` 的 `WHERE wgr.org_node = ?` 过滤要重写成 JOIN 节点表再过滤;`work_graph_fork.py` 的 fork 谱系展示要重新定义"一个横跨四段的合图 run 展示哪个/哪些 org_node"这个尚未存在的聚合语义(例如展示节点级 org_node 的去重列表,而不是单值)——这是本次决策遗留的新开放问题,不是照抄旧列就能解决的,需要在 Phase 2 SDD 里明确定义。 -✅ §4.1 第 2 点:"增量追加规划"接口:`plan_work_graph()` 新增一个"追加模式"参数(如 `append_to_run_id`),内部完成"读现有 `graph_manifest` → 合并新规划出的节点/边(含 `node_id` 冲突检测) → `UPDATE` 写回",不下放给调用方各自实现。2026-08-08 用户拍板。理由:合并/冲突检测逻辑集中在 `work_graph_plan.py` 一处——该模块本来就是 `_normalize_graph_manifest`/`_validate_loop_back_targets` 等 `graph_manifest` 结构性操作的归属地——避免 Coder、Validator 两个调用点各自实现一遍合并逻辑,未来出现两处不一致的风险。 diff --git a/docs/todo/org-graph-unification-phase2-universal-self-planning-design.md b/docs/todo/org-graph-unification-phase2-universal-self-planning-design.md deleted file mode 100644 index 4ddd524..0000000 --- a/docs/todo/org-graph-unification-phase2-universal-self-planning-design.md +++ /dev/null @@ -1,536 +0,0 @@ -# Phase 2 — 全员自规划 + 合图 + 节点级精确 loop_back——详细设计(SDD) - -**日期**: 2026-08-08 -**性质**: 详细设计,对应 `docs/todo/org-graph-unification-and-universal-self-planning-requirements.md` 的 Phase 2(FR-2/FR-3/FR-4),按 §4/§4.1 已拍板的合图(方案 A)路径设计。 -**依赖**: 硬依赖 Phase 1(`docs/done/org-graph-unification-phase1-review-gate-design.md`)——已实现(commit `ea19c7d`,2026-08-08)。§9 接口核对清单已于 2026-08-12 逐条核对完成:三项全部吻合(`plan_and_review_documents()` 经 `_review_planner_output_with_retries()` 透传 `plan_documents()` 的 `{"run_id", "artifact_ids"}` 返回形状,含 `run_id`;`task.status` 新值确认是 `reviewing_planner_output`;`roles/planner_reviewer/role.json` 目前 `skills: ["planner-output-review", "unattended-agent-operations"]`,未预留 §7.1 新 skill 位置,Phase 2 实施时新增一行即可,不是不兼容)。 -**状态**: Phase 2a(commit `86fc0bb`)+ Phase 2b(commit `7c46a2f`)已实现,2026-08-12。Phase 2c 的 Validator 自规划改造(§6 核心部分)已实现,2026-08-12:`validate_implementation_with_own_graph()` 改为 `plan_work_graph(conn, "validator", ..., append_to_run_id=run_id)` 追加进 Planner/Coder 共享的同一个 `work_graph_run`,终审 verdict 变成图内一个真实节点(`node_type='final_verdict'`,`judgment.kind='mechanical'`、`check_ref='verdict_says_pass'`,`on_fail.action='dynamic_loop_back'`——Validator 图里唯一允许用这个 action 的节点),硬门槛(脚本真实非零退出码强制 FAIL,不听 LLM 辩解)通过 `_verdict_node_build_prompt`/`_verdict_says_pass_check` 的共享可变状态闭包保留,Python 强制而非只是 prompt 里说说。`_VALIDATOR_MANIFEST`/`_VALIDATOR_DOC_PROMPTS`/`_VALIDATOR_VERDICT_PROMPT`/`_validator_prompt_builder` 保留但不再使用(同 `_CODER_PROMPT` 先例)。§7.2 的收口验证已完成分析(见 §10 Phase 2c 小节末尾:一旦 verdict 节点自己的 `dynamic_loop_back` 内部消化重试,`validate_implementation_with_own_graph()` 正常返回时 verdict 必为 PASS,`org_level_loop.py` 的旧粗粒度路由对这条新路径自然不可达,不需要显式新旧流程判断——决定保留旧分支代码不删,已在 `scheduler.py::finish_task_lifecycle` 加注释说明)。 - -**2026-08-13 §2.1/§2.2 已实施**:`work_graph_run.org_node` 列彻底删除,`ux_work_graph_run_active` 收紧为 `UNIQUE(task_id) WHERE status NOT IN ('done', 'failed')`('blocked' 故意算作活跃——恢复一个 blocked 的 run 现在总是走 `append_to_run_id` 追加进同一行,不会再 INSERT 新行)。新迁移函数 `schema.migrate_drop_work_graph_run_org_node()`(内部先幂等调用 `migrate_relax_org_node_constraints()`,再做 create-copy-drop-rename,`PRAGMA foreign_keys=OFF` 全程包裹,理由同 `migrate_add_reviewing_planner_output_status` 的真实重启事故记录——`work_graph_run` 是被多张表 REFERENCES 的父表)已接入 `backend_server.py::_ensure_schema()`。下游改动:`work_graph_plan.py`/`work_graph_fork.py`(含 `create_fork()`/`_reconstruct_build_prompt()`/`execute_fork()`,`fork_info["org_node"]` 改名 `fork_info["subagent_type"]`,源节点自己的执行身份)/`root_cause_trace.py` 的三处 INSERT 全部去掉 `org_node` 列;`work_graph_run_query.py`(`list_work_graph_runs`/`get_work_graph_run`)与 `task_query.py::get_task` 的返回形状从单值 `"org_node"` 改成 `"org_nodes_involved"`(从 `work_node_execution.subagent_type` 去重派生的列表,`org_node=` 查询参数语义从等值匹配改成 EXISTS 子查询),前端 `frontend/app.js` 对应改造(`getLatestRunForOrgNode`/`orgNodesLabel`/`renderOrgGraphTopology` 等消费点全部改用列表语义)。发现一处设计文档本节 §2.1.3 没预料到的遗漏点:`task_query.py::get_task` 也有同样的单值 `org_node` 读取,一并修完。`root_cause_trace.py::judge_root_cause_via_work_graph` 的 INSERT 有一个已知、记录在代码注释里的边界情况:索引收紧后,若这个函数被调到时对应 task 已有一个仍处于活跃/blocked 状态的共享 run,会撞索引——目前不是真实风险(这个函数唯一的调用方 `org_level_loop.run_fix_loop` 只在一个已经不可达的分支里被调用,且它自己的测试夹具里对应 task 从未预先插入过 work_graph_run 行),但如果未来重新接上这条路径需要重新评估。`python3 -m unittest discover -s src`(768 tests)+ `discover -s supervisor`(126 tests)+ 4 个 frontend smoke test 全绿。 - -**2026-08-13 §7.1 已实施**:新增 `skills/work-graph-node-review/SKILL.md`——判断"自规划 Work Graph 节点(Coder/Validator/planner_reviewer 自己规划出的、声明 `judgment.kind="llm"` 的节点)的产出是不是真的、结构化的",不是叙述文字冒充产出;吸收了 `docs/todo/validator-test-script-narrative-output-issue.md` §6 的根因分析和 `_NARRATION_RETRY_HINT`(条件句而非断言句)的措辞教训,并明确跟 `planner-output-review`(Planner 固定文档链、任务级、Coder 开始前把关)、`qa-task-validation`(任务级整体验收)、`skill-defect-remediation`(改 Skill 文件本身)三者的边界——判断粒度是"这一个自规划节点",不是文档链/整任务/Skill 文件。已挂到 `roles/planner_reviewer`、`roles/coder`、`roles/validator` 三个 `role.json` 的 `skills` 列表(各追加一行,如 §9 核对清单当时预判)。未修改 `.claude/agents/*.md`——那是 `agent_profile.deploy_agent_definitions()` 从 `role.json` 编译出的部署产物,由 Deploy Supervisor 在真实 cutover 时重新生成,不是手动跟 `role.json` 同步维护的(观察到现有 `.claude/agents/coder.md` 已经缺了更早前 Phase 2a 加的 `design-dynamic-work-graph`,印证这个文件本来就不是手动保持同步的)。`python3 -m unittest discover -s src`(768 tests,含 `test_agent_profile.py`)全绿。 - -**Phase 2c 至此全部完成**(§2.1/§2.2/§6 Validator 自规划改造/§7.1 均已实施;§7.2 收口分析已完成,决定不改代码,见上文)。**2026-08-08 修订**:写作过程中,需求文档 §7 的四个开放问题已被用户直接拍板(详见该文档 §7 对应条目),其中两条推翻了本文档最初的设计判断——Coder/Validator 自规划改为"建议节点清单"而非完全自由(§3),`work_graph_run.org_node` 改为彻底移除而非保留(§2.1)——本版本已按拍板结果改写,不是原始草稿。**2026-08-08 追加**(同日,见需求文档新增 §3.1):FR-4 的目标被明确为"失败即进化"完整方法论(判断失败 → Reflection-review 动态诊断 → 系统性缺陷则先 Fix-Skill → 跳回执行),当时 §5 设计的 `dynamic_loop_back` 只覆盖第 1/2/4 步,第 3 步(Fix-Skill)是已知缺口。**2026-08-12 补齐**:§5 已扩写为完整四步设计(§5.4 Reflection-review 现在同时判断目标节点和缺陷性质,§5.5 新增 Fix-Skill,真实调用既有但从未真正投入使用的 `skill_update.py::update_skill()`),过程中发现两个既有事实(`skills:` frontmatter 只按名字动态加载内容、不需要重新部署;`org_level_loop.py` 的 `_ROUTE_FOR_CATEGORY` 从来没有 `skill_defect` 这个 key,`update_skill()` 在这个代码库里投产以来从未被真正调用过)——详见 §5 开头。由哪个身份执笔改 Skill 已通过 `AskUserQuestion` 确认(2026-08-12):统一用 Validator,见 §5.5。 - ---- - -## 1. 一个必须先说清楚的修正:这比需求文档原来设想的更大 - -`org-graph-unification-and-universal-self-planning-requirements.md` 里 FR-2 原话是"函数本身已通用,只需替换调用点"——**这个判断经本次设计调研核实是不准确的**,具体差距见下面三点。本文档按修正后的真实范围设计,不是简单的调用点替换。 - -### 1.1 `_GENERIC_PROMPT_TEMPLATE` 从未征集 `judgment`/`on_fail` - -`work_graph_plan.py:171-187` 的 `_GENERIC_PROMPT_TEMPLATE`(Coder/Validator 走 `plan_work_graph()` 时会用到的模板)只要求 LLM 给每个节点 `node_id`/`depends_on`/`task_prompt`,**完全没有要求 `judgment`/`on_fail` 字段**——跟 Planner 专用的 `_PROMPT_TEMPLATE`(`work_graph_plan.py:136-159`,明确征集 judgment/on_fail/on_failure 边)不是同一份提示词。如果只是把 `implement_code()`/`validate_implementation_with_own_graph()` 的硬编码 manifest 换成调用 `plan_work_graph(conn, "coder", ...)`,产出的图**不会带任何判断/回路能力**——FR-4 想要的"每个节点验证+精确 loop_back"根本无从谈起。**必须先扩展 `_GENERIC_PROMPT_TEMPLATE`**,见 §3。 - -### 1.2 `run_graph()`/`work_node_execution` 的 `subagent_type`/`skill_ref` 是 run 级常量,不是节点级 - -`work_node_execution` 表(`schema.py:156-176`)已经有 `subagent_type`/`skill_ref` 两列——但 `_run_one_node()`(`work_graph_orchestrate.py:837-903`)调用 `_ensure_node_row()` 时,传的是 `run_graph()` 整个调用唯一的 `org_node`/`skill_id` 参数(`work_graph_orchestrate.py:856-857`),对图里**每一个节点都相同**,不是节点自己声明的值。FR-3 要的"每个节点声明用哪个 subagent + skill 实现"这个字段今天在数据库层已经有位置放,但执行链路上从来没有真正做到"节点级差异化"。**这是需要真正扩展的执行引擎逻辑**,不是数据已经在只是没读——见 §4。 - -### 1.3 现有 `on_fail.action="loop_back"` 是静态预声明的单一目标,不是动态根因判断 - -这是本次调研发现的**最关键的架构缺口**:今天的 loop_back 机制要求节点在**规划阶段**就通过 `edge_type="on_failure"` 边声明"我失败了就跳回哪一个节点"(`work_graph_orchestrate.py:315-326` `_on_failure_target`,`work_graph_plan.py:247-283` `_validate_loop_back_targets` 在规划完成时就校验这条边指向合法上游节点)——**目标节点是写死的,不是失败发生时才动态判断的**。 - -但 FR-4 想要的是"终审 Validator 判断失败后,根因判断具体是哪个更早的节点出问题,精确跳回那个节点"——终审失败时,真正的根因可能是 SDD 节点、Benchmark 节点、或某个 Coder 实现节点中的任意一个,**这个"该跳回哪"的判断只能在失败发生的那一刻、看到具体的失败症状后才能做出,不可能在规划阶段预先声明一条固定的边**。现有机制完全没有覆盖这种"下游失败,动态诊断该回退到众多上游候选节点里的哪一个"的场景——它只覆盖"这个节点自己的产出不合格,固定退回同一个预先约定的地方"。 - -**这意味着 FR-4 需要给引擎新增一种 `on_fail.action`(本文档称之为 `dynamic_loop_back`),不是简单复用现有 `loop_back`**——见 §5。这确实修改了 `work_graph_orchestrate.py` 的判断/回路逻辑本身,跟 Phase 1 需求文档非目标 §5.3"不重写 judgment/on_fail/loop_back 引擎"的表述有出入;本文档认为这是必要的、有边界的**扩展**(新增一个 action 分支,不改动任何现有 action 的行为),不是"重写",Phase 1 那条非目标应理解为"不推倒重来"而非"一行都不能动",但这个偏差需要明确记录,不能悄悄做。 - ---- - -## 2. Schema 变更 - -### 2.1 `work_graph_run.org_node` 彻底移除(用户拍板,2026-08-08) - -本节最初的设计判断是"不需要改,保留作'run 由谁发起'的元数据"——**已被用户推翻**:合图后一个 task 全程只有一个 `work_graph_run` 行,节点分属不同 org_node(`planner`/`planner_reviewer`/`coder`/`validator`),继续在 run 级保留单值 `org_node` 列既不准确(一个 run 里真实混着 4 种 org_node 的节点)也没有实际用途(真正的归属信息在 §1.2 已经确认属于 `work_node_execution.subagent_type`)——决策是彻底删除这一列,不是留作冗余字段。 - -**Schema 迁移**:`work_graph_run` 表删除 `org_node` 列(SQLite 无 `DROP COLUMN` 的老版本需要 create-copy-drop-rename,跟本文档 §2.2 同一个迁移函数里一起做,或者用现代 SQLite 的 `ALTER TABLE ... DROP COLUMN` 直接支持——取决于实施时 SQLite 版本,若支持则用后者,更简单)。 - -**下游改造点**(决策本身点名的两处,本节给出具体定义): - -#### 2.1.1 `work_graph_run_query.py:54-67`(`?org_node=` 过滤 + 结果字段) - -```python -query = ( - "SELECT wgr.run_id, wgr.task_id, wgr.status, wgr.started_at, wgr.completed_at, " - "(SELECT COUNT(*) FROM work_node_execution wne WHERE wne.run_id = wgr.run_id) AS node_count, " - "(SELECT GROUP_CONCAT(DISTINCT wne.subagent_type) FROM work_node_execution wne " - " WHERE wne.run_id = wgr.run_id) AS org_nodes_involved, " - "wgr.parent_run_id, wgr.fork_of_node_exec_id " - "FROM work_graph_run wgr JOIN task t ON t.task_id = wgr.task_id WHERE t.project_id = ?" -) -params = [project_id] -if org_node: - query += ( - " AND EXISTS (SELECT 1 FROM work_node_execution wne " - "WHERE wne.run_id = wgr.run_id AND wne.subagent_type = ?)" - ) - params.append(org_node) -``` - -返回形状里原来的单值 `"org_node": r[2]` 换成 `"org_nodes_involved": r[...].split(",") if r[...] else []`(GROUP_CONCAT 返回逗号拼接字符串或 NULL,app 层拆成列表)——调用方(`task_query.py`/frontend)原来读单值 `org_node` 字段的地方,改成读这个列表;Phase 3 的 Org 级元图(FR-5a)本来就需要知道"这个 run 横跨了哪几个 org_node 阶段",这个字段直接服务那个需求,不是额外负担。 - -#### 2.1.2 `work_graph_fork.py` 的 `org_node` 用法(三处,逐一替换) - -1. **`create_fork()` 第 125-137 行**:SELECT 语句去掉 `wgr.org_node`;原来解出的 `org_node` 变量直接用同一行已经在读的 `wne.subagent_type`(源节点自己的执行身份)替代——这个值本来就在查询结果里,不需要新增查询。 -2. **`_reconstruct_build_prompt(conn, task_id, org_node, graph_manifest)`(第 94-113 行)**:**去掉 `org_node` 参数**,dispatch 逻辑从"整张图统一按 `org_node=='planner'` 且全部节点有 `artifact_type`"改成**逐节点判断**——这本来就是合图后必须解决的问题(一张合并图会同时包含 Planner 的 `artifact_type` 节点和 Coder/Validator 的 `task_prompt` 节点,§2.1 决策只是让这个改动提前到现在做,不是额外增加的范围): - - ```python - def _reconstruct_build_prompt(conn, task_id, graph_manifest): - goal_text = conn.execute("SELECT goal_text FROM task WHERE task_id = ?", (task_id,)).fetchone()[0] - node_artifact_types = { - n["node_id"]: n["artifact_type"] for n in graph_manifest["nodes"] if "artifact_type" in n - } - planner_builder = e2e_pipeline._planner_prompt_builder(goal_text, node_artifact_types) - - def build_prompt(node, outputs): - if "artifact_type" in node: - return planner_builder(node, outputs) - if "task_prompt" in node: - return work_graph_plan.generic_node_prompt_builder(node, outputs) - raise ForkGraphShapeUnsupportedError( - "node {} has neither 'artifact_type' nor 'task_prompt'".format(node["node_id"]) - ) - return build_prompt - ``` - -3. **`INSERT INTO work_graph_run (...)`(第 164-168 行)**、**`fork_info`/`execute_fork()` 里的 `org_node`(第 189/208 行)**:INSERT 语句去掉 `org_node` 列;`execute_fork()` 调用 `run_graph()` 时原来传 `fork_info["org_node"]` 的位置,改传 `fork_info["subagent_type"]`(源节点自己的执行身份,作为 `run_graph()` 新签名里"没有节点级覆盖时的默认值",见 §4.1)。 - -#### 2.1.3 `root_cause_trace.py::judge_root_cause_via_work_graph`(第三处,设计 §5 Fix-Skill 时新发现,§2.1 原稿遗漏的一处) - -`judge_root_cause_via_work_graph()`(`root_cause_trace.py:222-226`)自己也有一次 `INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) VALUES (...)`——跟 Planner/Coder/Validator 一样直接写了 `org_node` 列,同样要删掉这个列。这个函数本身不受合图影响(它总是为陪审团判断新建一张独立的小图,不追加进跨阶段大图),但既然 `work_graph_run` 表结构变了,这里的 INSERT 语句必须跟着改,不能漏掉——本文档 §5.4 的 `_judge_root_cause_node` 直接复用这个函数的骨架,实施时会经过这段代码,顺手一起改。 - -### 2.2 `ux_work_graph_run_active` 语义变化(需要迁移) - -`schema.py:151-152`:`CREATE UNIQUE INDEX ux_work_graph_run_active ON work_graph_run(task_id, org_node)`(覆盖非终态 status)——语义是"同一 task 同一 org_node 同一时刻只有一个活跃 run"。合图后一个 task 全程只有一个活跃 run(不再按 org_node 切分),这条唯一索引的粒度要从 `(task_id, org_node)` 收紧为 `(task_id)`: - -```sql -DROP INDEX ux_work_graph_run_active; -CREATE UNIQUE INDEX ux_work_graph_run_active ON work_graph_run(task_id) - WHERE status NOT IN ('done', 'failed'); -``` - -(SQLite 的部分索引语法用 `WHERE` 子句表达"仅非终态",比原来在应用层散落判断更直接——可以借这次迁移顺手把语义从"应用层约定"变成"数据库约束",原索引其实也是全表扫描等价的部分索引,这里是等价改写,不是新引入部分索引这个概念。) - -新增迁移函数 `migrate_narrow_work_graph_run_active_index(conn)`,照抄 §2.3 的思路:先检查现有索引定义,已经是新语义就跳过(幂等)。 - -### 2.3 `artifact.artifact_type` CHECK 枚举 vs 自规划节点的任意 `node_id` - -**决策**:自规划出的 Coder/Validator 节点**不是每一个都要落成 `artifact` 行**。沿用 `implement_code()` 今天的既有设计——只有"最终交付物"(`code_diff`/`test_report`)才落 `artifact`,中间探索/推理节点的产出只落 `work_node_execution.output_payload`(已经是持久化的,断点续跑不受影响),不经过 `_write_completed_node_artifacts()` 那条路径。 - -具体做法:`_GENERIC_PROMPT_TEMPLATE` 扩展后(§3)要求节点声明一个新的可选字段 `promotes_to_artifact`(`{"artifact_type": "code_diff", ...}` 形状,缺省表示"这个节点的产出不落 artifact,只是图内部的中间步骤");`_write_completed_node_artifacts()`(Planner/Validator 现在共用的落盘函数)改成只处理声明了 `promotes_to_artifact` 的节点,不再对图里每个节点都无脑落一行——这本来就是 Planner 自己的 `_PLANNER_NODE_CATALOG` 節点隐含的假设(每个 Planner 节点都是"最终交付物"),Coder/Validator 的自由节点不能沿用同一假设。 - -`artifact.artifact_type` 的 CHECK 枚举**不需要扩表**——`promotes_to_artifact.artifact_type` 只允许落在已有的枚举值里(`code_diff`/`test_report`/`validator_benchmark`/`validator_test_dataset`/`validator_test_script` 等),由 Coder/Validator 的 prompt 明确约束"你的最终交付物只能声明这几种 artifact_type 之一",不开放任意值——避免把这个本来就有明确边界的表变成"什么都能塞"的自由字段。 - ---- - -## 3. `_GENERIC_PROMPT_TEMPLATE` 扩展(对应 §1.1)+ Coder/Validator 建议节点清单(用户拍板,2026-08-08) - -原设计判断"Coder/Validator 保持完全自由规划,不设清单"——**已被推翻**:`work_graph_plan.py:161-170` 2026-08-06 的注释原话"Coder/Validator's sub-tasks have no such fixed small vocabulary of node types"被用户拍板否决,Phase 2 要求给 Coder/Validator 各自定义一份类似 `_PLANNER_NODE_CATALOG` 的"建议节点类型清单"——建议不强制(跟 Planner 目录同样的措辞和约束力度,不是 Planner 文档链那种固定顺序的强约束),每项可以带 `suggested_judgment`。 - -### 3.1 新增两份建议目录 - -```python -_CODER_NODE_CATALOG = [ - {"node_type": "impl", "description": "Actual code changes implementing the SDD."}, - { - "node_type": "unit_test", - "description": "New/updated unit tests covering the change -- same repo convention as existing tests.", - "suggested_judgment": {"kind": "llm", "judge_prompt": _NODE_REVIEW_JUDGE_PROMPT}, # 见 §7.1 新 skill - }, - { - "node_type": "integration_check", - "description": "Actually runs the real test suite/benchmark script against the change and reports pass/fail -- not a description of doing so.", - "suggested_judgment": {"kind": "llm", "judge_prompt": _NODE_REVIEW_JUDGE_PROMPT}, - }, -] -_VALIDATOR_NODE_CATALOG = [ - { - "node_type": "independent_benchmark", - "description": "Validator's own held-out acceptance scenarios -- NOT reused from Planner's/Coder's benchmark, a genuine second opinion.", - }, - {"node_type": "test_dataset", "description": "Concrete test data, if this goal has a natural one."}, - { - "node_type": "test_script", - "description": "A real, runnable script mechanically checking the independent benchmark's scenarios.", - "suggested_judgment": {"kind": "mechanical", "check_ref": "check_script_compiles"}, - }, -] -_GENERIC_NODE_CATALOGS = {"coder": _CODER_NODE_CATALOG, "validator": _VALIDATOR_NODE_CATALOG} -``` - -`node_type` 是目录里的建议标签,不是节点 schema 的新必填字段——Coder/Validator 的节点形状仍然是 `node_id`/`depends_on`/`task_prompt`(跟今天一样,`artifact_type` 是 Planner 专属的字段,不引入到这条路径),目录只是喂给 LLM 参考"这类目标通常需要哪几种节点",最终 `node_id`/`task_prompt` 怎么写仍由 LLM 自己决定,不强制照抄目录里的 `node_type` 字面值。 - -### 3.2 `_GENERIC_PROMPT_TEMPLATE` 改为按 `org_node` 参数化,注入对应目录 - -原模板是一段固定字符串,只用 `.format(goal_text=goal_text)` 填一个占位符(`work_graph_plan.py:351` `plan_work_graph()`里的调用)。现在要注入目录 JSON(自带花括号),复用 `_PROMPT_TEMPLATE` 已经验证过的"拼接 + `.replace()`"写法,不用 `.format()`(同一个转义坑,`_normalize_graph_manifest` 的 docstring 已经记录过一次真实事故,不要在这里重蹈): - -```python -def _generic_prompt_template(org_node): - catalog = _GENERIC_NODE_CATALOGS.get(org_node, []) - catalog_section = ( - "Suggested node types for this goal (use any subset, in any order, invent your own if " - "none fit -- these are starting points, not requirements):\n" + json.dumps(catalog) + "\n" - if catalog else "" - ) - return ( - "Plan (do not execute) a Work Graph for this goal: {goal_text}\n" - + catalog_section + - "Follow the design-dynamic-work-graph Skill's methodology: only add a depends_on edge " - "when a node's task_prompt genuinely needs another node's output content ...\n" - "Output a graph_manifest whose nodes each have: node_id, depends_on, task_prompt, plus:\n" - " - judgment: how to tell if this node's output is acceptable -- {\"kind\": \"none\"} if " - " nothing needs checking, {\"kind\": \"llm\", \"judge_prompt\": \"...{content}...\"} for " - " your own judgment prompt with a {content} placeholder.\n" - " - on_fail (only if judgment.kind != \"none\"): {\"action\": \"retry_self\"}, " - " {\"action\": \"hard_fail\"}, or {\"action\": \"loop_back\"} with a matching on_failure " - " edge to a genuine upstream dependency.\n" - " - promotes_to_artifact (optional): {\"artifact_type\": \"code_diff\"|\"test_report\"|...} " - " ONLY for nodes producing this task's actual final deliverable.\n" - "Every edge needs edge_type: 'zero_token' for pure aggregation/reduce steps, 'needs_judgment' " - "for anything requiring the agent to actually reason about content.\n" - "End your reply with the graph_manifest as a single fenced ```json code block ..." - ) -``` - -`plan_work_graph()` 里 `else` 分支(`work_graph_plan.py:350-351`)从 `prompt = _GENERIC_PROMPT_TEMPLATE.format(goal_text=goal_text)` 改成 `prompt = _generic_prompt_template(org_node).replace("{goal_text}", goal_text)`。 - -`GRAPH_MANIFEST_SCHEMA`(`work_graph_plan.py:33-86`)同步补充 `promotes_to_artifact` 的文档性字段(跟 `judgment`/`on_fail` 一样,仅供文档参考,不做 jsonschema 强校验,原因见模块自己的 `--agent`+`--json-schema` 限制说明)。 - -`_normalize_graph_manifest()`(`work_graph_plan.py:286-333`)不需要改——它的 `_validate_loop_back_targets` 校验逻辑已经是通用的,不区分 Planner 的固定链还是 Coder/Validator 的目录建议图。 - ---- - -## 4. 节点级 `subagent_type`/`skill_ref`/`tools` 绑定(对应 §1.2,FR-3 的"用什么实现") - -### 4.1 `run_graph()`/`_run_one_node()` 签名扩展 - -`run_graph(conn, run_id, org_node, skill_id, target_folder_path, graph_manifest, ...)` 的 `org_node`/`skill_id` 两个参数保留(作为"没有节点级覆盖时的默认值"),`_run_one_node()` 内部改成: - -```python -def _run_one_node(conn, run_id, org_node, skill_id, target_folder_path, node, outputs, graph_manifest, ...): - node_org_node = node.get("subagent_type", org_node) # 节点自己声明的执行身份,缺省用图级默认 - node_skill_id = node.get("skill_ref", skill_id) - ... - node_exec_id = _ensure_node_row( - conn, run_id, exec_node_id_str, node_org_node, node_skill_id, ... - ) - ... - run_fn = _make_run_fn(conn, target_folder_path, node_exec_id, node_org_node, prompt, output_schema, timeout) -``` - -这不是把 `run_graph()` 整个重写——只是把原来"直接用闭包里的 `org_node`/`skill_id`"改成"先看节点自己有没有声明,没有就退回闭包默认值",两行改动,向后兼容(没有声明 `subagent_type` 的节点行为完全不变,Planner 的固定链、Coder 现有的合成单节点图都不受影响)。 - -`_resolve_loop_limit()`(`work_graph_orchestrate.py:512-528`)、`_llm_judgment_verify_fn()`(`work_graph_orchestrate.py:420-467`)里同样用到的 `org_node` 参数,同步改成接收 `node_org_node`(调用方在 `_run_one_node` 里已经解析好,直接传下去,不需要这两个函数自己再判断一次)。 - -### 4.2 "tools" 绑定:复用 `docs/todo/work-graph-node-execution-deblackbox-requirements.md` 的 `trigger_org_node_structured()` - -节点声明 `tools`(如 `{"disallowed_tools": ["Bash"]}`,只做限制、不做放宽——不能让一个自规划节点给自己开出角色本身 `role.json` 没有的权限)时,去黑盒化文档设计的 `trigger_org_node_structured(org_node, prompt, target_folder_path, output_schema, model=None, timeout=300)` 需要再加一个可选的 `disallowed_tools` 参数,透传成底层 `claude` 调用的 `--disallowedTools` CLI 参数(05 §5.4.1.2 已经确认 CLI 顶层参数支持按工具/子命令限定,跟 subagent frontmatter 不支持是两条不同的解析路径)。 - -节点声明的 `disallowed_tools` 与角色 `role.json` 的 `permissions.disallowed_tools` 取并集(只加限制不减限制),在 `_run_one_node` 里合并后传给 `trigger_org_node_structured`。 - -**"data" 绑定**:即"这个节点的输入来自哪些上游节点",`depends_on` + `build_prompt(node, outputs)` 已经完整覆盖这件事(`outputs` 字典就是已完成上游节点的产出),不需要新机制——用户诉求里的"data"就是这一层,只是没有用现有术语描述。 - ---- - -## 5. 新增 `on_fail.action = "dynamic_loop_back"` + 完整四步链路(Reflection-review → Fix-Skill → Re-loop) - -需求文档 §3.1("失败即进化")把 FR-4 定为四步:①判断失败 ②Reflection-review 动态诊断(一次性内容错误 vs 系统性 Skill 缺陷)③如果是系统性缺陷,先修 Skill 本身 ④跳回执行。本节 2026-08-08 首版只设计了①②④,第 3 步(Fix-Skill)本次补齐——本次设计过程中读代码发现了两个直接决定 Fix-Skill 该怎么接的既有事实,先说明: - -**既有事实 A(关键,决定了"改 Skill 会不会真的生效")**:`.claude/agents/.md` 的 `skills:` frontmatter 只引用 Skill **名字**(`agent_profile.py::_frontmatter_yaml`),真正的 `skills//SKILL.md` 内容是 Claude Code 自己在每次 `claude --agent ` 调用时按名字动态加载的——**改完 Skill 文件、提交,下一次调用同一个 org_node 就会用上新内容,不需要重新部署 `.claude/agents/*.md`**。这意味着"改 Skill → 跳回执行"这个链路不需要额外一步"重新部署",直接可用。 - -**既有事实 B(说明这条能力在这个代码库里目前完全是摆设)**:`org_level_loop.py::run_fix_loop()` 的 `_ROUTE_FOR_CATEGORY = {"coder": "fast_lane", "planning": "full_replan"}`(`org_level_loop.py:10`)**没有 `skill_defect` 这个 key**——`root_cause_trace.py` 的三视角判断早就能产出 `skill_defect` 这个类别(`_PERSPECTIVE_FRAMINGS["skill_defect"]`,`root_cause_trace.py:112-116`),`bug_record.create_bug_record()` 也接受这个类别,但 `route = _ROUTE_FOR_CATEGORY.get(category, "full_replan")` 对 `skill_defect` 直接落到 `full_replan` 这个兜底值——**`skill_update.py::update_skill()` 在这个代码库里投产以来从未被任何生产代码路径真正调用过**,只有测试在用。Fix-Skill 是第一次把这条早就设计好、但从没接线的能力真正用起来。(这个 dead-code 缺口本身跟 Phase 2 无关,独立可修,见 §8 第 6 条——但不是本次设计的阻塞项,Fix-Skill 走的是全新的合图内部路径,不依赖修好这个旧 bug。) - -### 5.1 语义 - -跟现有 `loop_back`(跳回一个规划阶段就写死的固定目标)不同,`dynamic_loop_back` 表示"这个节点失败时,由一次专门的根因判断调用(Reflection-review),从这个节点的全部上游祖先节点里动态选出真正该回退的目标,并判断这是一次性内容错误还是系统性 Skill 缺陷;后者先修 Skill(Fix-Skill)再回退"——只用于合适放在**图末端的终审性判断节点**(比如 Phase 1 引入的终审 Validator 节点),不要求每个节点都用这个更贵的机制。 - -### 5.2 graph_manifest 字段(不变) - -```jsonc -{ - "node_id": "final_review", - "judgment": {"kind": "llm", "judge_prompt": "..."}, - "on_fail": {"action": "dynamic_loop_back", "max_attempts": 3} - // 不需要对应的 on_failure 边——目标不是静态声明的 -} -``` - -### 5.3 执行逻辑总览(`work_graph_orchestrate.py` 新增分支) - -```python -if action == "dynamic_loop_back": - ancestors = sorted(_transitive_ancestors(graph_manifest, node_id) - {node_id}) - diagnosis = _judge_root_cause_node( # 见 §5.4,Reflection-review - conn, run_id, node_id, feedback, ancestors, graph_manifest, build_prompt, outputs, - org_node, target_folder_path, - ) - target = diagnosis["target_node_id"] - if target not in ancestors: # 运行时防御,见 §5.7 - raise ValueError( - "dynamic_loop_back judge picked {!r}, not a real ancestor of {}".format(target, node_id) - ) - if diagnosis["defect_kind"] == "skill_defect": - _fix_skill_for_node( # 见 §5.5,Fix-Skill——先修完 Skill 再触发回退 - conn, run_id, target, graph_manifest, build_prompt, outputs, feedback, target_folder_path, - ) - raise _LoopBackRequested(source_node_id=node_id, target_node_id=target, feedback=feedback) -``` - -`_LoopBackRequested`/`run_graph()` 主循环(`_run_layers` 里 `loop_back_signal` 的处理,`work_graph_orchestrate.py:811-830`)**完全不需要改**——它已经是"给一个 `(source_node_id, target_node_id)` 就重新执行 target 及其全部下游"的通用机制,不关心这个 target 是静态声明来的还是动态判断来的,也不关心跳回之前有没有发生过 Fix-Skill 这一步。`target` 节点重新执行时会真的用上刚提交的新 Skill 内容(既有事实 A)——**这就是第 4 步"跳回执行、用新 Skill"的完整实现,不需要额外的"通知节点使用新 Skill"机制**,因为 Skill 加载本来就是运行时按名字动态发生的。 - -### 5.4 Reflection-review:`_judge_root_cause_node`——判断目标节点 + 判断缺陷性质 - -复用 `root_cause_trace.judge_root_cause_via_work_graph` 的"多视角 Work Graph fan-out + `_tally_perspectives` 多数票仲裁"骨架,但这次要求陪审团在给出目标节点的同时,**再判断一次这是一次性错误还是系统性缺陷**——这是 Reflection-review 全链路复盘的核心职责,不能只做"选节点"这一半。 - -```python -_REFLECTION_OUTPUT_SCHEMA = { - "type": "object", "required": ["target_node_id", "defect_kind", "detail"], - "properties": { - "target_node_id": {"type": "string"}, - "defect_kind": {"type": "string", "enum": ["content_error", "skill_defect"]}, - "detail": {"type": "string"}, - }, -} - -def _judge_root_cause_node( - conn, run_id, failed_node_id, feedback, candidate_node_ids, graph_manifest, build_prompt, outputs, - org_node, target_folder_path, timeout=180, -): - """三个视角,每个是一次独立 Work Node fan-out 调用(跟 - judge_root_cause_via_work_graph 同一套机制),但答案是 - {target_node_id, defect_kind, detail} 而不是自由文本;target_node_id - 必须是 candidate_node_ids 之一。多数票仲裁维度是 target_node_id(哪个 - 节点最多票就是哪个);defect_kind 取该目标节点获票的判断里的多数值 - (同一票里 target 和 defect_kind 是绑定的一对答案,不是分开投票)。 - < 2/3 一致时不升级人工(跟 root_cause_trace 不同,见 §5.7 第 2 点)。 - """ -``` - -三个视角的具体提问: - -1. **内容视角**:"逐个看 `candidate_node_ids` 每个节点在这次 `outputs` 里的真实产出内容(`build_prompt(candidate_node, outputs)` 重建它当初被要求做什么 + `outputs[candidate_node_id]` 是它实际给出的什么),哪一个的内容本身站不住脚?是这次内容恰好没写好(`content_error`),还是这类节点反复容易出这类问题、看起来是方法论/prompt 本身有系统性偏差(`skill_defect`)?" —— 直接把 `validator-test-script-narrative-output-issue.md` §6 的根因分析("先探索再产出"这类节点收尾话术天然倾向叙述文字)作为 `skill_defect` 的具体识别范式喂给这个视角,不是让它凭空判断。 -2. **衔接视角**:"如果每个候选节点单独看都没有实质内容缺陷,是不是节点之间的依赖关系/输入衔接本身有问题(比如一个节点该依赖另一个节点的输出却没声明 `depends_on`)?" —— 这种情况通常判 `content_error`(属于这次规划的偶然疏漏,不是 Skill 方法论错误)。 -3. **历史视角**:"这个候选节点对应的 org_node/skill_ref 组合,在 `execution_event`(`loop_back_triggered`/`skill_iterated` 两种类型)里过去是否已经因为同一类问题被跳回或修过 Skill?如果是重复出现的同一个坑,倾向 `skill_defect`;如果是第一次见,倾向 `content_error`。" —— 这是三个视角里唯一利用跨任务/跨轮次历史数据的一个,需要真的查一次 `execution_event`,不是只看这一次的 `outputs`。 - -### 5.5 Fix-Skill:真实修 Skill,不是修这一次的输出 - -**触发条件**:`_judge_root_cause_node` 判定 `defect_kind == "skill_defect"`。**要修哪个 Skill**:直接查 `graph_manifest` 里 `target_node_id` 节点自己声明的 `skill_ref`(§4 节点级绑定字段,缺省退回图级默认 `skill_id`)——不查 `work_node_execution` 表(那张表的 `node_id` 可能带 `-attemptN` 后缀,从 `graph_manifest` 原始节点定义读更直接、无歧义)。 - -**由谁来修——已用户拍板(2026-08-12):统一用 Validator 身份**(`org_node="validator"`),不管出问题的节点自己是哪个 org_node,都不让它自己改自己的 Skill。理由:`skill_update.py` 模块自己的 docstring 原话是"a **Validator**-triggered rewrite"——这是 03-C 时期就定下的既有设计意图,本设计只是第一次真正把它接起来,不是新发明;更本质的理由是,如果一个 org_node 的 Skill 本身有系统性偏差,让它用同一个(有偏差的)判断力去修自己的 Skill,大概率修不到点子上(这次踩的坑,恰恰是它自己看不出来的盲区)——独立的 Validator 身份更接近"找另一双眼睛"这个 Reflection-review 一直在做的事。 - -**真实调用链**: - -```python -def _fix_skill_for_node(conn, run_id, target_node_id, graph_manifest, build_prompt, outputs, feedback, target_folder_path, timeout=300): - target_node = next(n for n in graph_manifest["nodes"] if n["node_id"] == target_node_id) - skill_id = target_node.get("skill_ref", "unknown") # 图级默认由调用方 run_graph() 的 skill_id 参数兜底 - - current_content = _read_skill_content(skill_id) # skills//SKILL.md, skill_query._REPO_ROOT - task_id = conn.execute("SELECT task_id FROM work_graph_run WHERE run_id = ?", (run_id,)).fetchone()[0] - goal_text = conn.execute("SELECT goal_text FROM task WHERE task_id = ?", (task_id,)).fetchone()[0] - - rewrite_result = org_node_trigger.trigger_org_node_structured( # 见 work-graph-node-execution-deblackbox-requirements.md - "validator", _SKILL_REWRITE_PROMPT.format( - goal_text=goal_text, skill_id=skill_id, current_content=current_content, - what_was_asked=build_prompt(target_node, outputs), - what_it_produced=outputs[target_node_id].get("content", ""), - failure_feedback="; ".join(feedback or []), - loop_history=_loop_history_prompt_suffix(conn, run_id, target_node_id), # 复用既有函数 - ), - target_folder_path, _SKILL_REWRITE_SCHEMA, timeout=timeout, - ) - - bug_result = bug_record.create_bug_record( - conn, task_id=task_id, - description="Skill {} produced repeated defective output at node {}".format(skill_id, target_node_id), - root_cause_category="skill_defect", root_cause_detail=rewrite_result["change_summary"], - suggested_route="full_replan", # 沿用既有枚举值填充要求,本路径不真的按它路由 - ) - iteration = skill_update.update_skill( - conn, skill_id, bug_result["bug_id"], rewrite_result["new_content"], - rewrite_result["change_summary"], repo_path=skill_query._REPO_ROOT, # 见既有事实 A 上方的先例 - ) - _log_skill_iterated_event(conn, run_id, target_node_id, skill_id, iteration["iteration_id"]) # 见下方 - return iteration -``` - -`_SKILL_REWRITE_PROMPT`/`_SKILL_REWRITE_SCHEMA`(`{"new_content": str, "change_summary": str}`)是新增内容,方法论("怎么在不破坏这个 Skill 现有正确行为的前提下,精确修正导致这次失败的那一小部分")沉淀成新 skill `skills/skill-defect-remediation/SKILL.md`(见 §7.1 更新),挂到 `roles/validator/role.json`——这是 Validator 身份专属的新能力,不是 §7.1 `work-graph-node-review` 的职责范围(那个判断"一个节点的输出好不好",这个是"怎么改一份 Skill 文档本身",性质不同)。 - -**记录一次 `skill_iterated` execution_event(发现:这个 event_type 已经在 CHECK 枚举里存在,从 U-001 起就有,但从未被任何代码写过——不需要新 schema 迁移)**: - -```python -def _log_skill_iterated_event(conn, run_id, target_node_id, skill_id, iteration_id): - row = conn.execute("SELECT wgr.task_id, t.project_id FROM work_graph_run wgr " - "JOIN task t ON wgr.task_id = t.task_id WHERE wgr.run_id = ?", (run_id,)).fetchone() - conn.execute( - "INSERT INTO execution_event (event_id, project_id, task_id, run_id, event_type, payload) " - "VALUES (?, ?, ?, ?, 'skill_iterated', ?)", - (str(uuid.uuid4()), row[1], row[0], run_id, json.dumps({ - "node_id": target_node_id, "skill_id": skill_id, "iteration_id": iteration_id, - })), - ) - conn.commit() -``` - -这条 event 是 Phase 3 前端(FR-5b 历史回放)能把"这一轮为什么跟上一轮不一样"讲清楚的关键数据——不只是"跳回了",而是"跳回之前,Skill 被真的改过一次,改了什么"。 - -### 5.6 Re-loop:不需要新机制 - -`_fix_skill_for_node` 返回后,§5.3 紧接着照常 `raise _LoopBackRequested(...)`——跟"一次性内容错误"分支走的是同一行代码。两条路径唯一的区别就是要不要先经过 §5.5,跳回执行本身完全复用现有机制(§5.3 已经说明为什么不需要改 `run_graph()` 主循环)。 - -### 5.7 安全边界(含新增的 Fix-Skill 专属风险) - -1. **运行时校验目标必须是真实祖先**(§5.3 已展示)——即使判断调用本身犯错选了一个不存在或非祖先的 node_id,直接报错而不是静默产生一次无意义的回路。 -2. **判断未达成 2/3 一致时的退化策略**:不像 `root_cause_trace` 那样升级人工(那是跨 Org Agent 边界的终局判断,这里只是图内部一次重试的一部分),退化为"回退到 `candidate_node_ids` 里离失败节点最近的直接上游 + `defect_kind` 视为 `content_error`"(保守默认——不确定是不是系统性问题时,不touch Skill,只是普通回退)。 -3. **`_resolve_loop_limit` 的计数维度不变**:`_loop_attempt_count_from_events` 是按 `(target_node_id, source_node_id)` 这一对计数的(`work_graph_orchestrate.py:560-567`),`dynamic_loop_back` 每次可能选出不同的 `target_node_id`,意味着"同一个 source 连续 3 次动态判断到 3 个不同的 target"不会被现有计数逻辑拦住——额外加一个"同一个 source_node_id 累计触发 dynamic_loop_back 的总次数"上限(配置沿用 `work_graph_loop_limit_config` 同一张表,`config_id` 用 `"{org_node}:{source_node_id}:total"` 这个新的 key 形状),防止绕过限流、无限循环。 -4. ~~由 Validator 身份执笔改 Skill 这个设计判断需要用户确认~~——已通过 `AskUserQuestion` 确认(2026-08-12),采用 Validator 身份,见 §5.5。 -5. **同一个 Skill 在一次 run 内被反复重写的风险**:`_fix_skill_for_node` 本身没有"这个 skill_id 这次 run 已经改过一次"的去重检查——如果 Reflection-review 连续几轮都判定同一个 `skill_ref` 是系统性缺陷(比如第一次改得不够准,问题仍然复现),会对同一个 Skill 文件连续产生多次真实 Git commit。这不算错误(每次改动理论上都在收敛),但值得在 §5.3 的分支里加一条"同一个 `(run_id, skill_id)` 组合本次 run 内的 Fix-Skill 触发次数"计数,超过一个小上限(比如 2 次)后即使判定是 `skill_defect` 也退化成普通 `content_error` 处理,避免一次任务反复重写同一份 Skill——具体阈值留给实施阶段。 -6. **self-target 场景:Skill 库commit 和任务自己的代码改动共享同一个物理仓库**——`skill_query._REPO_ROOT` 默认等于 `paths.repo_root()`;当 `target_folder_path` 恰好也是 graphlemon 自己(`paths.is_self_target(target_folder_path)` 为真)时,两者是**同一个仓库**。`update_skill()` 直接对 `skill_query._REPO_ROOT` 做 `git add`+`commit`,不经过 05 §5.8 那套 worktree 隔离——这是**有意为之,不是遗漏**:`skill_update.py` 模块 docstring 早就说明"a Validator-triggered rewrite takes effect immediately",Skill 库改动本来就设计成直接落主仓库,不像任务自己的代码改动需要隔离后再合并。两者物理上不冲突(分别只碰 `skills//` 和任务自己改的路径,如 `src/`),但没有专门测试过"self-target 任务执行期间,Fix-Skill 的直接 commit 和该任务自己的 worktree 合并操作,在 git 层面是否有任何可观测的交互"——建议 Phase 2c 实施验收时至少构造一次这个组合场景跑一遍,不要假设"理论上不冲突"就等于"实测也没事"(这个代码库里 worktree 相关的坑,历史上从来都是实测才发现的,见 CLAUDE.md 的 Isolation 章节)。 - ---- - -## 6. Coder/Validator 接入自规划:`implement_code()`/`validate_implementation_with_own_graph()` 改造 - -### 6.1 增量追加接口(用户拍板,2026-08-08:`plan_work_graph()` 新增参数,不是新函数) - -原设计判断是新增一个独立函数 `plan_work_graph_append()`——**已被推翻**:用户拍板要求合并/冲突检测逻辑集中在 `plan_work_graph()` 一个函数里,新增一个可选参数 `append_to_run_id`,不是平行的第二个函数。理由(用户原话):`work_graph_plan.py` 本来就是 `_normalize_graph_manifest`/`_validate_loop_back_targets` 等 `graph_manifest` 结构性操作的归属地,避免 Coder、Validator 两个调用点各自维护一份合并逻辑、未来出现两处不一致的风险。 - -```python -def plan_work_graph(conn, org_node, task_id, goal_text, target_folder_path, timeout=300, append_to_run_id=None): - """append_to_run_id=None(默认):跟今天完全一样,INSERT 一个新 - work_graph_run 行。append_to_run_id=<既有 run_id>:不 INSERT 新行, - 改为读出该 run_id 现有的 graph_manifest,把这次新规划出的节点/边 - 合并进去(node_id 冲突时报错,不静默覆盖或改名),UPDATE 回同一行。 - 追加模式下会把已有节点的 node_id 清单(不含内容)注入 prompt 上下文, - 让新规划的节点能声明 depends_on 指向已有(可能是别的 org_node 产出的) - 节点。返回形状不变:{"run_id", "graph_manifest"}(追加模式下 run_id - 就是传入的 append_to_run_id,graph_manifest 是合并后的完整图)。 - """ - if org_node == "planner": - prompt = _PROMPT_TEMPLATE.replace("{goal_text}", goal_text) - else: - prompt = _generic_prompt_template(org_node).replace("{goal_text}", goal_text) - - if append_to_run_id: - existing = conn.execute( - "SELECT graph_manifest FROM work_graph_run WHERE run_id = ?", (append_to_run_id,) - ).fetchone() - existing_manifest = json.loads(existing[0]) - existing_ids = [n["node_id"] for n in existing_manifest["nodes"]] - prompt = prompt.replace( - "Plan (do not execute)", - "This graph already has these nodes (from earlier stages): {}. Plan (do not execute) " - "ADDITIONAL nodes only -- do not redeclare or duplicate the ones above.".format(existing_ids), - ) - - # ... 真实 subprocess 调用 + _extract_graph_manifest + _normalize_graph_manifest,跟今天一样 ... - new_manifest = _normalize_graph_manifest(_extract_graph_manifest(response_text)) - - if append_to_run_id: - collision = set(n["node_id"] for n in new_manifest["nodes"]) & set(existing_ids) - if collision: - raise PlanningFailedError("append_to_run_id={} node_id collision: {}".format(append_to_run_id, collision)) - merged = { - "nodes": existing_manifest["nodes"] + new_manifest["nodes"], - "edges": existing_manifest.get("edges", []) + new_manifest.get("edges", []), - } - conn.execute("UPDATE work_graph_run SET graph_manifest = ? WHERE run_id = ?", - (json.dumps(merged), append_to_run_id)) - conn.commit() - return {"run_id": append_to_run_id, "graph_manifest": merged} - - run_id = str(uuid.uuid4()) - conn.execute("INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) VALUES (?, ?, ?, 'planning')", - (run_id, task_id, json.dumps(new_manifest))) - conn.commit() - return {"run_id": run_id, "graph_manifest": new_manifest} -``` - -(`INSERT INTO work_graph_run` 不再写 `org_node` 列,见 §2.1 的删除决策。) - -调用方(`e2e_pipeline.implement_code()`)改造: - -```python -def implement_code(conn, task_id, run_id, sdd_content, goal_text, target_folder_path, ...): - # run_id 现在由调用方(run_task_lifecycle)传入 -- Phase 1 建立的那个 - # 跨阶段 work_graph_run,不再自己创建新 run - graph_manifest = work_graph_plan.plan_work_graph( - conn, "coder", task_id, goal_text, target_folder_path, append_to_run_id=run_id, - )["graph_manifest"] - outputs = work_graph_orchestrate.run_graph( - conn, run_id, "coder", "coder-implementation", target_folder_path, graph_manifest, - work_graph_plan.generic_node_prompt_builder, _CODE_INPUT_SCHEMA, _CODE_OUTPUT_SCHEMA, - needs_worktree=True, timeout=timeout, - ) - ... -``` - -`run_graph()` 的 `graph_manifest` 参数现在是**合并后的完整图**(既有节点 + 新增节点),但 `run_work_node()` 的既有断点续跑短路(已 `done` 的节点直接返回持久化输出)保证已有节点不会被重新执行——这跟 §5.3 提到的"再次调用 `run_graph()` 时自然跳过已完成节点"是同一个机制,不需要额外处理。 - -不再用 `{"nodes": [{"node_id": "implement", "depends_on": []}], "edges": []}` 这个合成单节点 manifest(`e2e_pipeline.py:637` 现状)——真正让 Coder 自己规划实现步骤。`validate_implementation_with_own_graph()` 同理,`_VALIDATOR_MANIFEST` 硬编码字面量被 `plan_work_graph(conn, "validator", ..., append_to_run_id=run_id)` 取代。 - -### 6.2 `run_id` 的传递链路 - -Phase 1 SDD 设计的 `plan_and_review_documents()`(新增函数,Planner 阶段用)已经创建了这次任务唯一的 `work_graph_run` 行;Phase 2 要求这个 `run_id` 一路透传给 `implement_code()`/`validate_implementation_with_own_graph()`,不能让它们各自继续 `INSERT INTO work_graph_run`。`run_task_lifecycle()`/`run_or_resume_task_lifecycle()`(Phase 1 已经改造过的两个函数)需要把 `plan_and_review_documents()` 返回值里的 `run_id` 一路带下去——这是 Phase 2 对 Phase 1 产出的一个新依赖点,Phase 1 落地时如果 `plan_and_review_documents()` 的返回形状没有 `run_id`,需要回头补一个字段(轻量修改,不影响 Phase 1 已完成的核心逻辑)。 - ---- - -## 7. 新增节点级验证 skill + 退休 `org_level_loop.py` 粗粒度路由 - -### 7.1 新 skill:`work-graph-node-review` - -新增 `skills/work-graph-node-review/SKILL.md`,`roles/planner_reviewer`(Phase 1 新增)、`roles/coder`、`roles/validator` 的 `role.json` 都引用它——org-agent-无关,是 `judgment.kind="llm"` 时 `judge_prompt` 的方法论参考(不是新的 Python 机制,`judgment.kind="llm"` 本身已经是引擎特性,这个 skill 提供的是"怎么写一个好的 judge_prompt/怎么识别叙述文字冒充结构化产出"的方法论沉淀,直接把 `validator-test-script-narrative-output-issue.md` §6 的根因分析和 `_NARRATION_RETRY_HINT` 的措辞模式吸收进去,成为所有自规划节点复用的判断标准,不是每个角色各写一份)。这份 skill 的方法论提炼出一个可复用的 `_NODE_REVIEW_JUDGE_PROMPT` 常量(放在 `work_graph_plan.py`,跟 §3.1 的建议目录同一个模块,因为目录本身就要引用它作为 `suggested_judgment.judge_prompt` 的默认值),不是每个目录条目各写一段相似的 judge_prompt。 - -不改造 `qa-task-validation`——那个 skill 判断的是"整体实现是否验收通过"(任务级),职责不同,两者并存,不合并。 - -### 7.1.1 新 skill:`skill-defect-remediation`(Fix-Skill 专属,§5.5 新增) - -新增 `skills/skill-defect-remediation/SKILL.md`,只挂到 `roles/validator/role.json`(不是 org-agent-无关的,只有执笔改 Skill 的身份需要它——见 §5.5 的"由谁来修"决策)。方法论核心:怎么在不破坏一份 Skill 现有正确行为的前提下,精确定位并只修正导致这次真实失败的那一小部分——跟 `work-graph-node-review`(判断"一个节点的输出好不好")职责完全不同,这个 skill 判断的是"怎么改一份方法论文档本身",不合并进同一份 skill。 - -### 7.2 `org_level_loop.py`/`org_loop_route.py` 的粗粒度路由退休 - -Phase 1 阶段"复核失败先用现有 `org_level_loop.py` 粗粒度路由兜底"(Phase 1 SDD 的过渡方案)在 Phase 2 落地后被替换:终审 Validator 节点声明 `on_fail.action="dynamic_loop_back"`(§5),失败时直接在**同一个** `work_graph_run` 内精确回退,不再需要 `route_org_loop()` 那种"改 `task.status`、换 `assigned_org_node`、期待调用方重新触发另一个函数"的跨阶段路由。 - -`org_level_loop.py::run_fix_loop`/`org_loop_route.py::route_org_loop`/`_NEW_STATUS`/`_NEW_ORG_NODE`/`task.loop_round` 字段——**不删除**(`root_cause_trace.judge_root_cause`/`trace_root_cause_human_reported` 等其他调用方,以及 bug_record 驱动的人工报告场景仍然用得到这套粗粒度分类),但新的合图任务流程不再经过它们;`scheduler.py::finish_task_lifecycle` 需要判断"这是走新合图流程的任务,还是走旧三段式流程的任务"(新流程的失败已经在 `run_graph()` 内部处理完,不会再走到 `finish_task_lifecycle` 的 FAIL/INCONCLUSIVE 分支去调用 `run_fix_loop`)——具体判断依据留给实施阶段(比如看 `work_graph_run.graph_manifest` 里节点是不是都带 `judgment`/`on_fail` 字段,或者更简单,直接看 task 是不是 Phase 1/2 上线之后创建的)。 - ---- - -## 8. 明确的不确定性(比 Phase 1 更大,如实标注) - -1. **`_judge_root_cause_node`(§5.4)的判断质量未经真实验证**——三视角判断"哪个上游节点该负责 + 是不是系统性缺陷"是全新设计,没有 A/B 数据支撑,上线后大概率需要根据真实误判案例调整 prompt/视角设计,不是一次写对。`defect_kind` 这个二选一判断尤其新——误判方向有两种,且代价不对称:把系统性问题误判成一次性(漏改 Skill,同类错误还会再犯,但代价只是"没有更好")比把一次性问题误判成系统性(多余地真实重写了一次 Skill 文件,代价是留下一次不必要但无害的 Git 提交)风险更值得关注,值得在真实运行中专门跟踪这两类误判各自的发生率。 -2. **`promotes_to_artifact`(§2.3)依赖 Coder/Validator 老实遵守"只有最终交付物才声明"这条约束**——是 prompt 层面的软约束,不是机械强制,理论上 LLM 可能给每个节点都声明 `promotes_to_artifact`(虽然 artifact_type 枚举本身会挡住不合法值,但不会挡住"过度声明"这种滥用),需要在真实运行中观察是否需要加机械检查。 -3. **`dynamic_loop_back` 的"同一 source 总次数上限"(§5.7 第 3 点)需要新的 `work_graph_loop_limit_config` key 形状**——`"{org_node}:{source_node_id}:total"` 这个具体格式没有先例,实施时需要重新过一遍 `settings_config.py` 的查询/写入接口是否兼容这种三段式 key,还是需要专门扩展。 -4. **`org_level_loop.py` 新旧流程共存期的判断依据**(§7.2 结尾)还没有定论,是留给实施阶段解决的开放问题,不是本文档的疏漏——但这个问题不解决,Phase 2 上线当天就会有"旧任务卡在新代码路径里"的真实风险,实施顺序上应该早排。 -5. ~~Fix-Skill 由谁执笔改 Skill 需要用户确认~~——已确认(2026-08-12),统一用 Validator 身份,理由见 §5.5。 -6. **发现的独立 dead-code 缺口,不阻塞 Phase 2 但值得单独一提**:`org_level_loop.py::_ROUTE_FOR_CATEGORY` 从来没有 `skill_defect` 这个 key(§5 开头"既有事实 B"),意味着这条旧的粗粒度路径下 `skill_update.update_skill()` 从来没被真正调用过。Phase 2 的 Fix-Skill 走的是全新的合图内部路径,不依赖修好这个旧 bug,但如果 §7.2 决定新旧流程要共存一段时间,这个旧路径本身的这个缺口在共存期内仍然是活的——是否值得顺手在 `_ROUTE_FOR_CATEGORY` 里补一条 `"skill_defect": "full_replan"`(不真的接 `update_skill()`,只是让路由不再是意外的默认值 fallback,语义更诚实),是一个独立、廉价、可以随时做的小修复,不必等 Phase 2c。 - ---- - -## 9. 跟 Phase 1 的接口核对清单——✅ 全部核对完成(2026-08-12) - -- [x] `plan_and_review_documents()` 的返回形状是否包含 `run_id`——**是**:`plan_and_review_documents()` → `_review_planner_output_with_retries()` → 透传拿到 PASS 那一轮 `plan_documents()` 的原始返回值 `{"run_id", "artifact_ids"}`,`run_id` 齐全,§6.2 的假设成立,`implement_code()`/`validate_implementation_with_own_graph()` 可以直接拿这个 `run_id` 用作 `append_to_run_id`。 -- [x] Phase 1 实际引入的新 `task.status` 值是不是 `reviewing_planner_output`——**是**,`src/schema.py`/`src/org_node_lifecycle.py` 均按此实现,与本文档假设完全一致。 -- [x] `roles/planner_reviewer/role.json` 的 `skills` 列表是否已经预留位置给 §7.1 新增的 `work-graph-node-review`——**没有预留**,当前是 `["planner-output-review", "unattended-agent-operations"]`。不是问题:Phase 2 实施 §7.1 时直接追加一行 `"work-graph-node-review"` 即可,`roles/coder`/`roles/validator` 的 `role.json` 同理需要各追加一行——这是 Phase 2c 实施清单里的一步,不是需要提前协调的接口不兼容。 - ---- - -## 10. 实施顺序(拆成三个可独立验收的子阶段) - -### Phase 2a — 引擎扩展(地基,不改任何 org 流程) ✅ 已实现(commit `86fc0bb`,2026-08-12) -- §4(节点级 subagent/skill/tools 绑定)+ §5(`dynamic_loop_back` 完整链路:Reflection-review + Fix-Skill)+ 对应单测。 -- **§2.2(`ux_work_graph_run_active` 迁移)实施时被推迟,没有跟这批一起做**——设计时发现 `plan_documents()`/`implement_code()` 产出的 `work_graph_run` 行在 happy path 上从来不会被标记 `'done'`(只有 Validator 自己的图会),如果现在就把索引收紧成 `UNIQUE(task_id)`,会在 Phase 2b 的追加机制真正接上**之前**就先把现有生产流程弄崩——Coder 一插自己的行就会跟还没结束的 Planner 行撞车。已重新确认要推迟到 Phase 2c(Validator 也接入追加机制、三段真正共享一个 run 之后)才安全,见 Phase 2b 状态行的第 4 条。 -- 验收:手写的合成 graph_manifest(不经过真实 LLM 规划),节点声明不同的 `subagent_type`,运行后 `work_node_execution.subagent_type` 确实按节点区分;另一个手写 manifest 触发 `dynamic_loop_back`,验证真的能跳到候选节点之一且计数正确——均已有对应单测(`src/test_work_graph_orchestrate.py`:`NodeLevelSubagentSkillTest`/`DynamicLoopBackContentErrorTest`/`DynamicLoopBackSkillDefectTest`/`TallyReflectionTest`)。 -- 实施过程中发现并修复的真实 bug:节点级 `skill_ref` 没有走 `ensure_placeholder_skill`,第一次用到新 skill_id 会撞外键约束——已在 `_run_one_node` 里补上。 -- `python3 -m unittest discover -s src`(741 tests)+ `discover -s supervisor`(126 tests)全绿。 - -### Phase 2b — Coder 接入自规划 ✅ 已实现(commit `7c46a2f`,2026-08-12) -- §3(`_generic_prompt_template` 按 org_node 扩展 + 建议目录)+ §2.3(`promotes_to_artifact`)+ §6(`implement_code()` 改造为 `plan_work_graph(..., append_to_run_id=run_id)`)。 -- 实施过程中定位并修复的、SDD 原稿没预见到的问题: - 1. **`_review_planner_output_with_retries()` 复用已有 artifact 分支硬编码 `run_id=None`**——每个真实 artifact 行本来就有真实的 `produced_by_run_id`,改成从 `sdd` artifact 读取真实 run_id,不再是 `None`(否则 Coder 在这条 resume 路径上完全没有 run 可追加)。 - 2. **`implement_code()` 从来没有真的传过 `needs_worktree=True` 给 `run_graph()`**——单节点合成图时代无害(图里只有一个节点,没有并发碰撞的可能),但自规划之后 Coder 图可能真的有并行写代码的节点,03-C §3.6 的隔离要求这次才第一次真正生效。 - 3. **`work_graph_fork.py::_reconstruct_build_prompt` 必须改成逐节点 dispatch**(原计划就在 Phase 2b 范围内,§2.1.2 已预见),否则合并图一旦真的出现 Coder 节点,fork 功能会直接报 `ForkGraphShapeUnsupportedError`。 - 4. **§2.2 索引收紧比 Phase 2a 判断的还要晚**:Phase 2a 曾判断"等 Phase 2b 落地后就能收紧",实施 Phase 2b 时发现不对——Validator 在 Phase 2c 之前仍然会给自己开一个独立的 `work_graph_run`,收紧到 `UNIQUE(task_id)` 会让 Validator 那次 INSERT 直接跟还没终结的 Planner+Coder 合并行冲突。§2.1/§2.2 已重新确认推迟到 Phase 2c(Validator 也接入追加机制之后)才安全。 -- 新发现的测试基础设施缺口(不是设计问题,是既有测试夹具没预料到并发层):合并图里"已完成的旧节点"和"刚追加的新节点"如果互不依赖,会被 `topological_layers()` 分进同一层,触发 `run_graph()` 的多节点并发路径——`test_task_execution.py`/`test_e2e_pipeline.py` 的测试连接原来没开 `check_same_thread=False`,从没触发过这条路径,现已修正(生产连接本来就是对的,`scheduler.py`/`task_execution.py` 早就用 `check_same_thread=False`,只是测试夹具没跟上)。 -- `python3 -m unittest discover -s src`(752 tests)+ `discover -s supervisor`(126 tests)全绿。 - -### Phase 2c — Validator 接入自规划 + 精确 loop_back 收口 -- Validator 侧的 §6 改造 + §7(新 skill + 退休粗粒度路由)+ §9 的核对清单。 -- 验收:真实构造一次"终审失败,根因在 SDD 节点"的场景,验证 `dynamic_loop_back` 精确跳回 SDD 节点重新执行,SDD 的下游(Benchmark/Coder 实现/…)全部正确重跑,不需要人工介入,也不需要经过 `org_level_loop.py`。 diff --git a/docs/todo/scheduler-new-goal-dispatch-requirements.md b/docs/todo/scheduler-new-goal-dispatch-requirements.md deleted file mode 100644 index 160f5f6..0000000 --- a/docs/todo/scheduler-new-goal-dispatch-requirements.md +++ /dev/null @@ -1,58 +0,0 @@ -# 新 Goal 自动派发——需求文档 - -**日期**: 2026-08-06 -**性质**: 需求文档(BRD/PRD 层面),不含详细技术设计。**2026-08-08 拆分**:原文档 -`docs/todo/scheduler-production-polling-loop-requirements.md`(FR-1/FR-2 合并)已按完成状态拆分—— -FR-2(在途任务复原)已实现,移入 `docs/done/scheduler-task-reconciliation-requirements.md`/ -`-design.md`;本文档只保留仍是草案的 FR-1。 -**状态**: 草稿,未立项。`docs/implementation-gap-analysis.md` 第 6 条已把这条标注为"改变系统自己的 -自主权边界",需要单独评审决策,不与其他 gap 混跑。 - -## 1. 背景 - -`run_scheduler_once(db_path, goals, max_workers=None)` 的入参 `goals` 是 `[(target_folder_path, -goal_text), ...]`,语义是"这一轮要**新建**并跑完这些 Goal",内部通过 `task_create.create_task()` -每次都新建一个 `task` 行。这个函数存在且经过测试,但从未被任何常驻进程接入——`queued` 状态的新 -Goal(还没被任何调用触发过 `run_goal_to_completion`)目前只能靠人工手动点击控制台的 Start 按钮 -执行,不会被自动拿去跑。 - -跟"在途任务复原"(`docs/done/scheduler-task-reconciliation-requirements.md`,已实现)是两个独立 -问题:那个是"已经开始执行的任务卡住了怎么自动接回去",这个是"还没开始执行的新 Goal 怎么自动开始"。 - -## 2. 目标 - -`queued` 状态的任务(或者说,还没被任何调用触发过 `run_goal_to_completion`/ -`run_or_resume_task_lifecycle` 的新 Goal)需要被自动拿去执行,不需要人工每次手动点 Start。 - -## 3. 非目标 - -1. **不在本文档里裁定"要不要做""什么时候做"这个更大的问题**。是否现在就投入、要不要额外的 - 人工评审门槛,是需要单独确认的决策,本文档只负责把需求本身讲清楚,供决策时参考。 -2. **不要求解决方案是常驻进程/独立进程/`backend_server.py` 内置线程三选一中的哪一个**。具体载体 - 形式留给设计阶段。 -3. **不重新设计 `run_scheduler_once` 本身**。它的行为已经是对的,本需求要解决的是"谁、在什么 - 时机去调用它"这一层。 - -## 4. 功能需求 - -| # | 需求描述 | 优先级 | -|---|---|---| -| FR-1 | 需要一个机制,定期(具体周期留待设计阶段)调用 `scheduler.run_scheduler_once()` 派发新 Goal。 | 必须 | - -## 5. 验收标准 - -新建一个 `queued` 状态的 Goal,不手动点 Start,验证机制能在设定周期内自动派发执行。 - -## 6. 风险 - -**这是改变系统自主权边界的决策**——意味着系统会在没有人显式点击"Start"的情况下自主开始消耗 -真实 API 调用、真实修改代码。这条风险不因为实现简单而减轻,需要在决策阶段被认真对待。 - -## 7. 开放问题(留给详细设计阶段回答,需要先拍板"要不要做"再讨论这些) - -- [ ] 轮询周期定多少。 -- [ ] 载体形式:`backend_server.py` 内置后台线程,还是独立进程。 -- [ ] 要不要暴露成可配置项(管理后台开关/环境变量),还是先硬编码一个保守的默认行为。 -- [ ] 要不要跟已实现的在途任务复原循环(`docs/done/scheduler-task-reconciliation-design.md`) - 共用同一个后台线程/轮询周期,还是保持两个独立循环——两者已实现的那部分刻意没有合并 - 成一个"万能调度循环",这个原则是否延续到 FR-1 需要在设计阶段确认。 diff --git a/docs/todo/work-graph-node-execution-deblackbox-requirements.md b/docs/todo/work-graph-node-execution-deblackbox-requirements.md deleted file mode 100644 index 46ca78a..0000000 --- a/docs/todo/work-graph-node-execution-deblackbox-requirements.md +++ /dev/null @@ -1,198 +0,0 @@ -# Work Graph 节点执行去黑盒化——需求+设计文档(合并,方向已定) - -**日期**: 2026-08-08 -**性质**: 需求+设计合并文档——用户已经明确拍板方向(自研,不复用 Claude Code 原生 Dynamic Workflow),且调研发现代码库里已经有一个生产验证过的现成替代方案(`plan_work_graph()`),不需要走"逐条开放问题讨论"流程,直接给出可实施设计。对应 `docs/graphlemon_architecture.md` §10.2(2026-08-08 决策修订)。 -**触发场景**:用户追问"之前设计说用 Claude Code 自带 dynamic workflow,现在要去掉、自己实现,这个描述改了吗?为了可控,不再黑盒"——调研发现 `docs/graphlemon_architecture.md` §10.1"方案 A"(已确认:Work Graph 执行层复用原生 Dynamic Workflow)从未被正式撤回,但图编排层早就自研了(`work_graph_orchestrate.py`,REQ-06 断点续跑驱动);**单节点执行层还没有**——三处调用点仍在用 `write_workflow_script`/`execute_work_graph()` 生成/执行一次性 Dynamic Workflow `.js` 脚本。 -**状态**: 设计完成,待实施。 - ---- - -## 1. 背景:为什么单节点层至今还留着原生 Workflow - -`work_graph_orchestrate.py` 模块docstring 记录的原因:`claude --agent -p ... --json-schema ` 这个 CLI 顶层组合会**静默丢弃** `structured_output`(05 §5.4.1.1 实测发现,人设加载正常、schema 校验失效)。Workflow DSL 内部的 `agent(prompt, {agentType, schema})` 组合是当时(2026-08-01,U-031)唯一验证过能同时做到"加载 org_node 人设"+"拿到 schema 校验过的结构化输出"的机制(05 §5.4.1.5)。 - -**这个理由已经不成立**:`work_graph_plan.py::plan_work_graph()`(`c_work_graph_plan`)面对的是完全相同的"`--agent` 身份 + 结构化输出"需求,但它从一开始就没有用 Workflow——直接 `subprocess.run(["claude", "--agent", org_node, "-p", prompt, "--output-format", "json", ...])`,从自由文本 `result` 字段里用 ```` ```json ```` 围栏 + 正则提取解析(`_extract_graph_manifest`,05 §5.4.1.1 自己推荐的应对方式),且早已在生产环境真实跑通(Planner 四节点文档链、这次新设计的 Phase 1 复核调用也是这个模式)。"必须用 Workflow 才能两者兼得"这个前提是错的,Workflow 只是**也**能做到,不是唯一路径。 - -### 1.1 当前仍依赖 `write_workflow_script`/`execute_work_graph` 的三个调用点 - -| # | 位置 | 用途 | `agentType` | -|---|---|---|---| -| 1 | `work_graph_orchestrate.py::_make_run_fn`(`_render_node_script`,L361-370) | 每个 Work Node 真正的内容生成调用——**主执行路径,影响面最大** | 该节点所属的 `org_node` | -| 2 | `work_graph_orchestrate.py::_llm_judgment_verify_fn`(L420-467) | `judgment.kind="llm"` 节点判断(Part A,2026-08-08 新增,Phase 2 会大量增加这类调用) | 该节点所属的 `org_node` | -| 3 | `e2e_pipeline.py::_check_prd_consistency`(L309-350) | PRD 内部矛盾检测(REQ-02 边界情况) | 固定 `'planner'` | - -三处都是"渲染一段一次性 `.js` 脚本 → `write_workflow_script` 落盘 → `execute_work_graph()` 跑 `claude -p 'Run /...'`"的同一个模式,且都已经有独立于 Workflow 的 self-target worktree 隔离逻辑包裹(`memory: project` 写入需要隔离,跟用不用 Workflow 无关,迁移时**不动**这部分)。 - ---- - -## 2. 目标(FR) - -| # | 需求描述 | -|---|---| -| FR-1 | 新增一个通用的、`output_schema`-无关的辅助函数 `trigger_org_node_structured()`,用"flat `claude --agent` 调用 + JSON 围栏提取"取代"渲染 Workflow 脚本 + `claude -p 'Run /...'`",供三个调用点共用。 | -| FR-2 | 把 `work_graph_plan.py::_extract_graph_manifest` 里"整段当 JSON 解析、失败则逐个 ```` ```json ```` 围栏尝试"的算法抽成一个通用 helper,`_extract_graph_manifest` 和 `trigger_org_node_structured` 都调用它,不重复实现。 | -| FR-3 | 三个调用点依次迁移到 `trigger_org_node_structured()`,删除对 `write_workflow_script`/`execute_work_graph` 的调用。 | -| FR-4 | 保留 `work_node_run.py`(U-020)按节点降级模型重试的能力——`trigger_org_node_structured()` 必须支持等价的模型覆盖参数,且**必须真实验证**(不能假设)`claude --agent -p ... --model ` 这个 CLI 组合行为符合预期(05 §5.4.1.5 的教训:DSL 内部选项和 CLI 顶层参数是两条不同路径,不能凭直觉套用其中一条的结论)。 | -| FR-5 | 保留 `execute_work_graph_with_usage_limit_retry()` 的 5→10→20→30 分钟退避重试能力,迁移到包裹 `trigger_org_node_structured()` 外层,退避算法本身不变,只换内层调用。 | -| FR-6 | 迁移完成后,`write_workflow_script`/`execute_work_graph`/`execute_work_graph_with_usage_limit_retry` 三个函数不再被生产代码调用——**不删除** `work_graph_execute.py` 文件本身(先确认没有其他调用方,尤其是 `work_graph_fork.py` 时间旅行调试功能是否也依赖它),同步更新 `CLAUDE.md` 里 `.claude/workflows/` 一节的描述,说明这是历史遗留机制。 | - ---- - -## 3. 非目标 - -1. **不改变 Work Graph 的图拓扑/编排逻辑**——`work_graph_orchestrate.py` 的 `topological_layers()`/`run_graph()`/loop_back 机制本来就是纯 Python 自研的,不依赖 Workflow,不受这次改动影响。 -2. **不改变 `org_node_trigger.trigger_org_node()` 本身**——新函数是它的姊妹函数(多了 schema 提取),不是替换它;`_READY_PROMPT` 握手调用、Validator 最终 verdict 调用等现有的 flat 调用完全不变。 -3. **不处理 worktree 隔离/`memory: project` 写入隔离逻辑**——这部分独立于"用 Workflow 还是用 flat 调用",迁移时原样保留包裹逻辑,只换内层实现。 -4. **不在这次改动里处理 `docs/graphlemon_architecture.md` 第 10 节表格第 14 条本身**——那是课程技法的对照记录,不是决策,不需要改;改的只是 §10.1/§10.2 的决策结论(已在本文档触发前完成)。 - ---- - -## 4. 详细设计 - -### 4.1 新函数:放在 `org_node_trigger.py`(flat 调用的家) - -```python -class StructuredOutputExtractionError(Exception): - """trigger_org_node_structured 在 result 文本里找不到匹配 output_schema - 必需字段的 JSON payload。""" - - -_JSON_FENCE_INSTRUCTION = ( - "Your final answer MUST be a single JSON object wrapped in exactly one " - "```json fenced code block, matching this schema: {schema}. Do not " - "describe what you did or are about to do -- the fenced block's content " - "IS the answer, not a summary of it." -) - - -def trigger_org_node_structured(org_node, prompt, target_folder_path, output_schema, model=None, timeout=300): - """Like trigger_org_node(), but also extracts a JSON payload matching - output_schema's top-level required keys from the free-text response -- - 05 §5.4.1.1's documented workaround (never pass --json-schema alongside - --agent; ask for a single ```json fenced block in the prompt instead, - same convention plan_work_graph()'s _PROMPT_TEMPLATE/_GENERIC_PROMPT_ - TEMPLATE already use). `_JSON_FENCE_INSTRUCTION` is appended to `prompt` - automatically -- callers don't repeat this boilerplate themselves. - `model`: real CLI --model override (FR-4 -- verify empirically before - relying on this in production, see this module's test file for the - real-call verification this needs). - """ - args = ["claude", "--agent", org_node, "-p", prompt + "\n\n" + _JSON_FENCE_INSTRUCTION.format(schema=json.dumps(output_schema))] - if model: - args += ["--model", model] - args += ["--output-format", "json", "--permission-mode", "auto"] - result = subprocess.run( - args, cwd=target_folder_path, stdin=subprocess.DEVNULL, - capture_output=True, text=True, timeout=timeout, - ) - if result.returncode != 0: - raise OrgNodeTriggerError( - "trigger_org_node_structured failed for {}\nstderr: {}\nstdout (truncated): {}".format( - org_node, result.stderr, result.stdout[:4000] - ) - ) - payload = json.loads(result.stdout) - if _looks_like_usage_limit_response(payload): # reuses org_node_lifecycle's existing phrase-match logic - raise UsageLimitReachedError(...) - extracted = extract_json_payload(payload.get("result", ""), required_keys=output_schema.get("required", [])) - if extracted is None: - raise StructuredOutputExtractionError( - "could not extract a payload matching {} from org_node={}'s response".format( - output_schema.get("required"), org_node - ) - ) - return extracted -``` - -(`UsageLimitReachedError`/`_looks_like_usage_limit` 目前分别定义在 `work_graph_execute.py`/`org_node_lifecycle.py` 两处、逻辑不完全相同——这次顺带看要不要统一成一个共享 helper,具体放哪个模块留给实施阶段判断,不在本设计强行拍板,避免引入不必要的耦合决策。) - -### 4.2 `extract_json_payload` 泛化(新增在 `work_graph_plan.py`,或提到更底层的共享位置) - -```python -def extract_json_payload(text, required_keys=()): - """整段先当 JSON 解析,失败则逐个 ```json 围栏尝试;只接受包含全部 - required_keys 的 dict(或包一层同名 wrapper key,模型有时会加这层)。 - `_extract_graph_manifest(text)` 等价于 - `extract_json_payload(text, required_keys=["nodes", "edges"])` 外加它自己 - 的 "graph_manifest" wrapper-key 特殊处理——保留 `_extract_graph_manifest` - 作为薄封装,不强行让所有调用方都用同一个 wrapper-key 名字。 - """ - ... -``` - -`_extract_graph_manifest` 改成: - -```python -def _extract_graph_manifest(text): - result = extract_json_payload(text, required_keys=["nodes", "edges"]) - if result is not None: - return result - # existing "graph_manifest" wrapper-key fallback, unchanged - ... -``` - -### 4.3 三个调用点的迁移 - -**`_make_run_fn`(主执行路径)**: - -```python -def run_fn(cwd, model=None): - _log_event(conn, node_exec_id, "node_start", org_node=org_node, model=model) - try: - output = org_node_trigger.trigger_org_node_structured( - org_node, prompt, cwd, output_schema, model=model, timeout=timeout, - ) - except org_node_trigger.UsageLimitReachedError as e: - _log_event(conn, node_exec_id, "quota_limit", quota_limit_type=e.quota_limit_type) - work_node_run.raise_usage_limit_signal(e.quota_limit_type) - except Exception as e: - _log_event(conn, node_exec_id, "node_failed", error=str(e)) - raise - else: - _log_event(conn, node_exec_id, "node_completed", output=output) - return output -``` - -`_render_node_script`/对 `work_graph_execute` 的 import 一并删除。 - -**`_llm_judgment_verify_fn`**:`try` 块内 `work_graph_execute.write_workflow_script(...)` + `execute_work_graph(...)` 两行替换成一行 `result = org_node_trigger.trigger_org_node_structured(org_node, prompt, cwd, _JUDGMENT_SCHEMA, timeout=timeout)`;worktree 包裹逻辑(create/ensure_committed/merge/remove)完全不动。 - -**`_check_prd_consistency`**:同样把 `write_workflow_script`+`execute_work_graph` 两行替换成 `org_node_trigger.trigger_org_node_structured('planner', prompt, cwd, _PRD_CONSISTENCY_SCHEMA, timeout=timeout)`;worktree 逻辑不动。 - -### 4.4 usage-limit 退避重试(FR-5) - -`work_graph_execute.execute_work_graph_with_usage_limit_retry` 的退避骨架(5→10→20→30 分钟,`sleep`/`now` 参数注入以便测试)整体保留,唯一改动是内层从 `execute_work_graph(...)` 换成 `org_node_trigger.trigger_org_node_structured(...)`——这个包裹函数本身可以留在 `work_graph_execute.py`(模块名不用改,职责变成"给 flat 调用包一层 usage-limit 退避",不是"驱动 Dynamic Workflow"),或者搬到 `org_node_trigger.py` 跟 `trigger_org_node_structured` 放在一起——搬迁与否留给实施阶段判断,不影响外部调用方看到的行为。 - -### 4.5 `.claude/workflows/` 目录的后续状态 - -三个调用点全部迁移完成后,生产代码不再往 `.claude/workflows/` 写新文件(`work_graph_fork.py` 的时间旅行调试功能如果也依赖 Workflow 脚本重放机制,需要在实施前单独确认——这是这次调研没有覆盖到的一个盲点,**实施第一步必须先查**,如果它依赖,这次迁移范围要么把它也迁掉、要么明确排除并记录原因)。已经生成的历史 `.js` 文件保留在版本库里(执行痕迹,不回溯删除),`CLAUDE.md` 关于这一节的描述在迁移完成后需要同步更新为"历史机制,2026-08-08 后不再新增"。 - ---- - -## 5. 测试方案 - -- `src/test_org_node_trigger.py`(新文件或扩展现有测试):`trigger_org_node_structured()` 的 mock 化测试(构造各种 `result` 文本形态:整段 JSON、单个 ```json 围栏、多个围栏取第一个匹配的、找不到任何匹配抛 `StructuredOutputExtractionError`);usage-limit 响应识别。 -- `src/test_work_graph_plan.py`:`extract_json_payload` 泛化后,`_extract_graph_manifest` 的既有测试必须原样全绿(回归测试,不能因为泛化引入行为变化)。 -- `src/test_work_graph_orchestrate.py`:`_make_run_fn`/`_llm_judgment_verify_fn` 迁移后的 mock 测试,从 mock `work_graph_execute.write_workflow_script`/`execute_work_graph` 改成 mock `org_node_trigger.trigger_org_node_structured`——**这是这次改动里回归面最大的一处**,`work_graph_orchestrate.py` 自己的现有测试大概率有几十处需要同步改 mock 目标,实施时预留足够时间。 -- `src/test_e2e_pipeline.py`:`_check_prd_consistency` 同上。 -- **`RUN_REAL_AGENT_E2E=1` 真实验证(FR-4 的强制要求,不是可选项)**:至少跑一次真实 `--agent -p ... --model ` 组合,确认①人设正确加载(跟 U-031 当初验证 DSL 路径时做的"追问角色身份,确认自称迁移后的人设"同一手法)、②`--model` 覆盖真的生效、③JSON 围栏提取在真实模型输出上稳定工作(不是只在 mock 数据上测得过)。这条不能用 mock 替代,因为要验证的正是"从一个已知能用的路径换到另一个之前从没在这三个调用点上真实跑过的路径"。 - ---- - -## 6. 实施顺序 - -1. **先查 `work_graph_fork.py` 是否依赖 Workflow 脚本重放**——决定这次迁移范围是否要覆盖它(§4.5 提到的盲点)。 -2. `extract_json_payload` 泛化 + `_extract_graph_manifest` 回归测试(独立、低风险,先做)。 -3. `trigger_org_node_structured()` 新增 + mock 测试。 -4. 迁移影响面最小的调用点:`e2e_pipeline.py::_check_prd_consistency`(一个函数,独立)。 -5. 迁移 `_llm_judgment_verify_fn`(次小,Phase 2 会大量新增这类调用,越早切过去越好,减少 Phase 2 在两套机制上重复踩坑)。 -6. 迁移 `_make_run_fn`(影响面最大,放最后,前两步已经验证过新机制的可靠性)。 -7. `RUN_REAL_AGENT_E2E=1` 真实验证(FR-4 的模型覆盖 + 三个调用点各跑一次真实场景)。 -8. 全量回归 `discover -s src` + `discover -s supervisor`。 -9. `write_workflow_script`/`execute_work_graph`/`execute_work_graph_with_usage_limit_retry` 确认无生产调用方后,标记为历史遗留(不删除文件,加 docstring 说明),更新 `CLAUDE.md` 对应描述。 - ---- - -## 7. 跟 Org-Graph-Unification 系列文档的关系 - -正交但有先后关系:这次改动只影响**单节点执行机制**,不影响 Org Graph 流程形状或自规划范围,`docs/done/org-graph-unification-phase1-review-gate-design.md`(Phase 1)不依赖这次改动,可以并行/先后独立推进。但 `docs/todo/org-graph-unification-and-universal-self-planning-requirements.md` 的 **Phase 2**(FR-4,节点级验证 skill)会大量新增 `judgment.kind="llm"` 判断调用——如果这次去黑盒化工作能在 Phase 2 动工前完成,Phase 2 就不需要在两套机制(Workflow 和新的 flat 调用)之间选一套来复用,直接基于 `trigger_org_node_structured()` 设计,减少后续返工。建议顺序:这份文档 → Phase 2,不是必须但值得。 diff --git a/frontend/app.js b/frontend/app.js index 945c374..ae88ca3 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1957,6 +1957,141 @@ function renderOrgGraphTopology(activeOrgNode, runStatusByOrgNode, route) { return '
' + nodesHtml + "
" + retryEdgeHtml; } +// ---- Org 元图 (FR-5a, Org Graph unification Phase 3, 2026-08-13): a +// standalone four-stage top-level meta-graph (Planner -> 复核 Planner +// Reviewer -> Coder -> 终审 Validator), separate from renderOrgGraphTopology +// above -- that function is still called by mountGraphConsole's own +// existing "Org Graph" panel and is pinned by test_graph_console_smoke.js's +// 3-node ORG_NODE_ORDER shape, so it's left untouched rather than stretched +// to 4 stages. Each stage expands in place into that stage's real Work +// Graph run, reusing renderGraphVisualizerPanel unmodified -- except +// planner_reviewer, which never self-plans a Work Graph +// (org_node_trigger.trigger_org_node() is one flat call, see +// e2e_pipeline.py::_record_planner_review_event) and instead shows its own +// verdict trail (task.planner_review_events, task_query.py:: +// _planner_review_events_for_task). +const ORG_META_STAGE_ORDER = ["planner", "planner_reviewer", "coder", "validator"]; +const ORG_META_STAGE_LABELS = { + planner: "Planner", planner_reviewer: "复核(Planner Reviewer)", + coder: "Coder", validator: "终审(Validator)", +}; + +function orgMetaStageStatus(task, stage) { + if (stage === "planner_reviewer") { + const events = task.planner_review_events || []; + if (events.length === 0) return "not_started"; + return events[events.length - 1].verdict === "PASS" ? "done" : "reflecting"; + } + const run = getLatestRunForOrgNode(task, stage); + return run ? run.status : "not_started"; +} + +function renderOrgMetaGraph(task) { + const nodesHtml = ORG_META_STAGE_ORDER.map((stage, i) => { + const status = orgMetaStageStatus(task, stage); + const arrow = i < ORG_META_STAGE_ORDER.length - 1 ? '
→
' : ""; + return ( + '
' + + '
' + escapeHtml(ORG_META_STAGE_LABELS[stage]) + "
" + + '
' + escapeHtml(status) + "
" + + "
" + arrow + ); + }).join(""); + const detailsHtml = ORG_META_STAGE_ORDER.map((stage) => + '' + ).join(""); + return '
' + nodesHtml + "
" + detailsHtml; +} + +function renderPlannerReviewEventsList(events) { + if (!events || events.length === 0) return '
尚无复核记录
'; + return '
    ' + events.map((e) => + "
  • attempt " + e.attempt + " · " + escapeHtml(e.verdict) + + (e.used_existing_artifacts ? "(复用已有产出)" : "") + "" + + escapeHtml(e.created_at || "") + "
  • " + ).join("") + "
"; +} + +// planner/coder/validator branch mirrors renderOrgComboPanelContent's own +// empty-run/empty-DAG fallback text -- same two states, this is just the +// meta-graph's own expand target rather than v_task_detail's combo panel. +function renderOrgMetaStageDetailHtml(task, stage) { + if (stage === "planner_reviewer") { + return '

Planner 产出复核记录

' + renderPlannerReviewEventsList(task.planner_review_events) + "
"; + } + const run = getLatestRunForOrgNode(task, stage); + if (!run) return '
' + escapeHtml(stage) + " 尚未开始(暂无 Work Graph run)
"; + const panelHtml = renderGraphVisualizerPanel(run, { enableNodeClick: false }); + return panelHtml + ? panelHtml + renderLoopBackTimelineHtml(run) + : '
最新一次 run_id=' + escapeHtml(run.run_id) + "(status=" + escapeHtml(run.status) + ")· 该 run 尚未生成 Work Graph
"; +} + +function mountOrgMetaGraph(container, task) { + for (const el of container.querySelectorAll(".graph-topo-node[data-meta-stage]")) { + el.addEventListener("click", () => { + const stage = el.dataset.metaStage; + const detail = container.querySelector("#org-meta-detail-" + stage); + if (!detail) return; + if (detail.style.display !== "none") { + detail.style.display = "none"; + detail.innerHTML = ""; + return; + } + detail.innerHTML = renderOrgMetaStageDetailHtml(task, stage); + detail.style.display = "block"; + mountGraphVisualizerTooltips(detail); + mountGraphVisualizerEdges(detail); + mountLoopBackTimeline(detail); + }); + } +} + +// ---- Loop-back 历史回放时间轴 (FR-5b, Org Graph unification Phase 3, +// 2026-08-13): task_query.py's _loop_back_events_for_run() already exposes +// run.loop_back_events (source_node_id/target_node_id/attempt_number/ +// feedback[]/created_at) -- renderGraphVisualizerPanel only ever reduces +// them to a self-loop badge count / on_failure edge trigger count, never +// the feedback text itself. This is a separate, additive component (not a +// rewrite of the DAG renderer) rendered alongside it at each of its call +// sites, ordered by attempt_number, one entry per real loop_back. +function renderLoopBackTimelineHtml(run) { + const events = run.loop_back_events || []; + if (events.length === 0) return ""; + const itemsHtml = events.map((e, i) => + '
  • ' + + "attempt " + e.attempt_number + " · " + escapeHtml(e.source_node_id) + " → " + escapeHtml(e.target_node_id) + "" + + "" + escapeHtml(e.created_at || "") + "" + + "
  • " + ).join(""); + const feedbackPanelsHtml = events.map((e, i) => + '" + ).join(""); + return ( + '

    Loop-back 历史回放(按 attempt_number)

    ' + + '
      ' + itemsHtml + "
    " + + feedbackPanelsHtml + + "
    " + ); +} + +function mountLoopBackTimeline(container) { + for (const timeline of container.querySelectorAll(".loop-back-timeline")) { + for (const item of timeline.querySelectorAll(".timeline-item[data-attempt-index]")) { + item.addEventListener("click", () => { + const idx = item.dataset.attemptIndex; + for (const panel of timeline.querySelectorAll(".timeline-feedback")) { + panel.style.display = panel.dataset.attemptIndex === idx ? "block" : "none"; + } + }); + } + } +} + const GC_DOC_ARTIFACT_TYPES = ["brd", "prd", "sdd", "benchmark"]; const GC_CODE_ARTIFACT_TYPES = ["code_diff", "test_report"]; @@ -2049,6 +2184,10 @@ function renderGraphConsole() {

    Org Graph

    加载中…
    +
    +

    Org 元图(点击每段展开真实 Work Graph 详情)

    +
    加载中…
    +
    加载中…
    加载中…
    @@ -2062,6 +2201,7 @@ function mountGraphConsole(queryParams) { const taskSelect = document.getElementById("gc_task_select"); const orgTopologyEl = document.getElementById("gc_org_topology"); + const orgMetaGraphEl = document.getElementById("gc_org_meta_graph"); const workGraphPanelsEl = document.getElementById("gc_work_graph_panels"); const artifactsOverviewEl = document.getElementById("gc_artifacts_overview"); @@ -2127,9 +2267,11 @@ function mountGraphConsole(queryParams) { for (const orgNode of run.org_nodes_involved || []) runStatusByOrgNode[orgNode] = run.status; } orgTopologyEl.innerHTML = renderOrgGraphTopology(task.assigned_org_node, runStatusByOrgNode, task.route); + orgMetaGraphEl.innerHTML = renderOrgMetaGraph(task); + mountOrgMetaGraph(orgMetaGraphEl, task); const panelsHtml = task.work_graph_runs - .map((run) => renderGraphVisualizerPanel(run, { enableNodeClick: true })) + .map((run) => renderGraphVisualizerPanel(run, { enableNodeClick: true }) + renderLoopBackTimelineHtml(run)) .join(""); if (!panelsHtml) { workGraphPanelsEl.innerHTML = '
    尚未开始规划
    '; @@ -2139,6 +2281,7 @@ function mountGraphConsole(queryParams) { mountGraphVisualizerTooltips(workGraphPanelsEl); mountGraphVisualizerEdges(workGraphPanelsEl); mountWorkNodeClickExpand(workGraphPanelsEl, { onNodeClick: onWorkNodeClick }); + mountLoopBackTimeline(workGraphPanelsEl); } async function populateTaskSelect() { @@ -2146,6 +2289,7 @@ function mountGraphConsole(queryParams) { if (tasks.length === 0) { taskSelect.innerHTML = ''; orgTopologyEl.innerHTML = ''; + orgMetaGraphEl.innerHTML = ""; workGraphPanelsEl.innerHTML = ""; return; } diff --git a/frontend/test_org_meta_graph_smoke.js b/frontend/test_org_meta_graph_smoke.js new file mode 100644 index 0000000..99c7a06 --- /dev/null +++ b/frontend/test_org_meta_graph_smoke.js @@ -0,0 +1,195 @@ +// Org Graph unification Phase 3 (FR-5a + FR-5b) smoke test. Same no- +// framework/no-fixture convention as the other frontend/test_*_smoke.js +// files -- loads app.js in a bare vm.Context and asserts on generated HTML +// strings for the new pieces: +// - FR-5a: ORG_META_STAGE_ORDER / orgMetaStageStatus / renderOrgMetaGraph +// (the four-stage top-level meta-graph, Planner -> 复核 -> Coder -> +// 终审, separate from the existing 3-node renderOrgGraphTopology) and +// renderOrgMetaStageDetailHtml's planner_reviewer special-case (a +// verdict list, not a faked Work Graph panel). +// - FR-5b: renderLoopBackTimelineHtml (the attempt_number-ordered +// loop_back replay list, reusing run.loop_back_events which +// task_query.py already exposed but nothing rendered before this). +// Mount-time DOM wiring (mountOrgMetaGraph/mountLoopBackTimeline's click +// handlers) needs real querySelectorAll traversal this bare sandbox doesn't +// provide -- same "manual browser verification only" boundary the other +// smoke tests already draw around mount*/edge-drawing functions; this file +// only pins the pure render* HTML-string output. +// +// Run: node frontend/test_org_meta_graph_smoke.js + +"use strict"; +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); +const assert = require("assert"); + +function makeFakeDiv() { + let text = ""; + return { + set textContent(v) { text = v == null ? "" : String(v); }, + get textContent() { return text; }, + get innerHTML() { + return text.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + }, + }; +} + +const sandbox = { + document: { + createElement: () => makeFakeDiv(), + getElementById: () => null, + querySelectorAll: () => [], + addEventListener: () => {}, + }, + window: { + location: { protocol: "http:", host: "localhost", hash: "" }, + addEventListener: () => {}, + }, + localStorage: { getItem: () => null, setItem: () => {} }, + console, +}; +vm.createContext(sandbox); + +const src = fs.readFileSync(path.join(__dirname, "app.js"), "utf8"); +// Top-level `const` stays lexically scoped to the script and never reaches +// the sandbox object (same Annex B quirk the other smoke tests already +// document for e.g. ARTIFACT_DETAIL_RE) -- re-export what this test needs. +const augmentedSrc = src + "\nglobalThis.ORG_META_STAGE_ORDER = ORG_META_STAGE_ORDER;\n"; +new vm.Script(augmentedSrc, { filename: "app.js" }).runInContext(sandbox); + +let assertionCount = 0; +function check(condition, message) { + assertionCount++; + assert.ok(condition, message); +} + +// ---- FR-5a: ORG_META_STAGE_ORDER has all four stages, in order ---- +{ + check( + sandbox.ORG_META_STAGE_ORDER.join(",") === "planner,planner_reviewer,coder,validator", + "the meta-graph must cover all four stages in Planner -> 复核 -> Coder -> 终审 order" + ); +} + +// ---- FR-5a: orgMetaStageStatus ---- +{ + const emptyTask = { work_graph_runs: [], planner_review_events: [] }; + check(sandbox.orgMetaStageStatus(emptyTask, "planner") === "not_started", "no run yet -> not_started"); + check(sandbox.orgMetaStageStatus(emptyTask, "planner_reviewer") === "not_started", "no review events yet -> not_started"); + + const taskWithRuns = { + work_graph_runs: [ + { run_id: "run-1", org_nodes_involved: ["coder"], status: "running", started_at: "2026-08-01 00:00:00" }, + ], + planner_review_events: [], + }; + check(sandbox.orgMetaStageStatus(taskWithRuns, "coder") === "running", "status mirrors the latest run's own status"); + + const passedReview = { work_graph_runs: [], planner_review_events: [ + { attempt: 1, verdict: "REJECTED", created_at: "t1" }, + { attempt: 2, verdict: "PASS", created_at: "t2" }, + ] }; + check(sandbox.orgMetaStageStatus(passedReview, "planner_reviewer") === "done", "latest verdict PASS -> done"); + + const rejectedReview = { work_graph_runs: [], planner_review_events: [ + { attempt: 1, verdict: "REJECTED", created_at: "t1" }, + ] }; + check(sandbox.orgMetaStageStatus(rejectedReview, "planner_reviewer") === "reflecting", "latest verdict not PASS -> reflecting, not done"); +} + +// ---- FR-5a: renderOrgMetaGraph -- four boxes, three arrows, real statuses ---- +{ + const task = { + work_graph_runs: [ + { run_id: "run-1", org_nodes_involved: ["planner"], status: "done", started_at: "t1" }, + { run_id: "run-2", org_nodes_involved: ["coder"], status: "running", started_at: "t2" }, + ], + planner_review_events: [{ attempt: 1, verdict: "PASS", created_at: "t1.5" }], + }; + const html = sandbox.renderOrgMetaGraph(task); + for (const stage of ["planner", "planner_reviewer", "coder", "validator"]) { + check(html.includes('data-meta-stage="' + stage + '"'), "every stage box must carry data-meta-stage=" + stage); + } + check(html.includes("graph-topo-node status-done"), "planner shows its real run status (done)"); + check(html.includes("graph-topo-node status-running"), "coder shows its real run status (running)"); + check(html.includes("graph-topo-node status-not_started"), "validator has no run yet -> not_started"); + check((html.match(/graph-edge/g) || []).length === 3, "four stages are joined by exactly three arrows"); + check(html.includes('id="org-meta-detail-planner_reviewer"'), "every stage gets its own (initially hidden) expand target"); + check(html.includes('style="display:none;"'), "expand targets start collapsed"); +} + +// ---- FR-5a: planner_reviewer special-case detail (verdict list, not a +// faked Work Graph panel) ---- +{ + const emptyEventsHtml = sandbox.renderOrgMetaStageDetailHtml({ planner_review_events: [] }, "planner_reviewer"); + check(emptyEventsHtml.includes("尚无复核记录"), "zero review events -> explicit empty state"); + check(!emptyEventsHtml.includes("work-graph"), "planner_reviewer must never render a fake DAG panel"); + + const task = { + planner_review_events: [ + { attempt: 1, verdict: "REJECTED", used_existing_artifacts: false, created_at: "2026-08-12 10:00:00" }, + { attempt: 2, verdict: "PASS", used_existing_artifacts: true, created_at: "2026-08-12 10:05:00" }, + ], + }; + const html = sandbox.renderOrgMetaStageDetailHtml(task, "planner_reviewer"); + check(html.includes("attempt 1"), "each attempt is listed"); + check(html.includes("REJECTED") && html.includes("PASS"), "each attempt's real verdict is shown"); + check(html.includes("2026-08-12 10:00:00") && html.includes("2026-08-12 10:05:00"), "each attempt's real timestamp is shown"); + check(html.includes("复用已有产出"), "used_existing_artifacts=true is surfaced, not silently dropped"); +} + +// ---- FR-5a: planner/coder/validator detail reuses renderGraphVisualizerPanel +// unmodified, with the existing empty-run / empty-DAG fallbacks ---- +{ + const noRunTask = { work_graph_runs: [] }; + const noRunHtml = sandbox.renderOrgMetaStageDetailHtml(noRunTask, "coder"); + check(noRunHtml.includes("尚未开始"), "no run yet for this stage -> explicit empty state"); + + const emptyDagTask = { + work_graph_runs: [{ run_id: "run-1", org_nodes_involved: ["coder"], status: "failed", graph_manifest: null }], + }; + const emptyDagHtml = sandbox.renderOrgMetaStageDetailHtml(emptyDagTask, "coder"); + check(emptyDagHtml.includes("run-1") && emptyDagHtml.includes("尚未生成 Work Graph"), "run exists but has no DAG yet -> explicit fallback naming the run"); + + const realRunTask = { + work_graph_runs: [{ + run_id: "run-2", org_nodes_involved: ["coder"], status: "running", + graph_manifest: { nodes: [{ node_id: "n1", depends_on: [] }] }, + nodes: [{ node_id: "n1", node_exec_id: "exec-1", subagent_type: "coder", status: "running" }], + }], + }; + const realHtml = sandbox.renderOrgMetaStageDetailHtml(realRunTask, "coder"); + check(realHtml.includes("run_id=run-2"), "a real run renders the actual renderGraphVisualizerPanel DAG output"); + check(!realHtml.includes("clickable"), "meta-graph expand opts out of click-to-expand-logs (enableNodeClick: false)"); +} + +// ---- FR-5b: renderLoopBackTimelineHtml ---- +{ + const noEventsHtml = sandbox.renderLoopBackTimelineHtml({ loop_back_events: [] }); + check(noEventsHtml === "", "a run with no loop_back_events renders nothing, not an empty panel"); + check(sandbox.renderLoopBackTimelineHtml({}) === "", "missing loop_back_events entirely is treated the same as empty"); + + const run = { + run_id: "run-1", + loop_back_events: [ + { source_node_id: "benchmark", target_node_id: "sdd", attempt_number: 1, feedback: ["missing scenario"], created_at: "t1" }, + { source_node_id: "benchmark", target_node_id: "sdd", attempt_number: 2, feedback: ["still missing", "and X"], created_at: "t2" }, + ], + }; + const html = sandbox.renderLoopBackTimelineHtml(run); + check(html.includes("attempt 1") && html.includes("attempt 2"), "every attempt gets its own timeline entry, in order"); + check(html.includes("benchmark") && html.includes("sdd"), "each entry names its real source/target node"); + check(html.includes('data-attempt-index="0"') && html.includes('data-attempt-index="1"'), "entries are addressable by index for the click-to-reveal wiring"); + check(html.includes("missing scenario"), "attempt 1's real feedback text is rendered"); + check(html.includes("still missing") && html.includes("and X"), "attempt 2's real (multi-item) feedback text is rendered"); + check(html.includes('style="display:none;"'), "feedback panels start collapsed -- a click reveals one at a time"); + + const noFeedbackRun = { + loop_back_events: [{ source_node_id: "a", target_node_id: "a", attempt_number: 1, feedback: [], created_at: "t1" }], + }; + const noFeedbackHtml = sandbox.renderLoopBackTimelineHtml(noFeedbackRun); + check(noFeedbackHtml.includes("未记录 feedback"), "an attempt with empty feedback[] still renders an explicit state, not blank"); +} + +console.log("frontend/test_org_meta_graph_smoke.js: all " + assertionCount + " assertions passed"); diff --git a/src/new_goal_dispatch.py b/src/new_goal_dispatch.py new file mode 100644 index 0000000..1dfcf5f --- /dev/null +++ b/src/new_goal_dispatch.py @@ -0,0 +1,62 @@ +"""Periodic auto-dispatch for new Goals that were created but never +started (2026-08-13, `docs/todo/scheduler-new-goal-dispatch-requirements.md` +FR-1). `scheduler.run_scheduler_once()` exists and works, but nothing in +production ever called it: a `task.status='queued'` row (created via the +API/console, never given to `run_goal_to_completion`) sat there until a +human clicked the console's Start button. + +Scope: deliberately NOT `task_reconciliation.py`'s job -- that module +resumes tasks that already started and got abandoned mid-flight (status in +`task_execution._RESUMABLE_STATUSES`, stale by `_DEFAULT_STALE_AFTER_SECONDS` += 30 min). This module only ever looks at `status='queued'` (a task that has +never been dispatched at all) and does not apply a staleness window -- +`docs/done/scheduler-task-reconciliation-design.md` §7 records the decision +to keep the two loops independent, not merged into one "万能调度循环", and +that a 'queued' task should NOT wait out a 30-minute staleness window before +its very first dispatch (the whole point of FR-1 is near-real-time pickup of +brand-new Goals, not eventual recovery of stuck ones).""" + +from src import schema, task_execution + + +def find_queued_tasks(conn): + """Returns [{"task_id", "target_folder_path", "goal_text"}, ...] for + every `status='queued'` task not already in flight in this process. + Read-only -- kept separate from `dispatch_once()` so candidate selection + is independently testable without spinning up real background threads + (same split as `task_reconciliation.find_stale_resumable_tasks`).""" + rows = conn.execute( + "SELECT t.task_id, p.target_folder_path, t.goal_text " + "FROM task t JOIN project p ON p.project_id = t.project_id " + "WHERE t.status = 'queued'" + ).fetchall() + + candidates = [] + for task_id, target_folder_path, goal_text in rows: + if task_execution.is_in_flight(task_id): + continue + candidates.append({"task_id": task_id, "target_folder_path": target_folder_path, "goal_text": goal_text}) + return candidates + + +def dispatch_once(db_path): + """One dispatch pass: find candidates, dispatch each via + `task_execution.start_task_execution_in_background()`. Own short-lived + connection for the scan, closed before dispatching -- same pattern as + `task_reconciliation.reconcile_once`. A candidate that fails to dispatch + (e.g. the console's Start button won the race between scan and here) is + skipped, not treated as fatal for the whole pass. Returns the candidate + list (dispatched or not) for callers/tests to inspect.""" + conn = schema.connect(db_path) + try: + candidates = find_queued_tasks(conn) + finally: + conn.close() + for c in candidates: + try: + task_execution.start_task_execution_in_background( + db_path, c["task_id"], c["target_folder_path"], c["goal_text"] + ) + except Exception: + pass + return candidates diff --git a/src/task_query.py b/src/task_query.py index 5db9810..04a18c2 100644 --- a/src/task_query.py +++ b/src/task_query.py @@ -187,9 +187,32 @@ def get_task(conn, task_id): # see docs/done/work-graph-self-planning-and-loop-back-design.md §7). "loop_back_events": _loop_back_events_for_run(conn, w[0]), }) + # 2026-08-13 (Org Graph unification Phase 3, FR-5a): planner_reviewer has + # no self-planned work_graph_run (org_node_trigger.trigger_org_node() is + # one flat call, see e2e_pipeline.py::_record_planner_review_event) -- + # its only real audit trail is this task-scoped event log, so the Org + # meta-graph reads it here instead of faking a run for that stage. + task["planner_review_events"] = _planner_review_events_for_task(conn, task_id) return task +def _planner_review_events_for_task(conn, task_id): + rows = conn.execute( + "SELECT payload, created_at FROM execution_event " + "WHERE task_id = ? AND event_type = 'planner_review_verdict' ORDER BY created_at", + (task_id,), + ).fetchall() + events = [] + for payload, created_at in rows: + data = json.loads(payload) + events.append({ + "attempt": data["attempt"], "verdict": data["verdict"], + "used_existing_artifacts": data.get("used_existing_artifacts", False), + "created_at": created_at, + }) + return events + + def _loop_back_events_for_run(conn, run_id): rows = conn.execute( "SELECT payload, created_at FROM execution_event " diff --git a/src/test_new_goal_dispatch.py b/src/test_new_goal_dispatch.py new file mode 100644 index 0000000..5613b57 --- /dev/null +++ b/src/test_new_goal_dispatch.py @@ -0,0 +1,129 @@ +"""new_goal_dispatch.py tests (2026-08-13, FR-1 -- +docs/todo/scheduler-new-goal-dispatch-requirements.md): same mocked-at-the- +subprocess-boundary / real-sqlite split as test_task_reconciliation.py. + +Run: python3 -m unittest src.test_new_goal_dispatch -v +""" + +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +from src import new_goal_dispatch, project_scaffold, schema, task_execution + + +class NewGoalDispatchTestBase(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + subprocess.run(["git", "init"], cwd=self.tmp.name, check=True, capture_output=True) + self.conn = schema.connect(":memory:") + schema.create_schema(self.conn) + project = project_scaffold.create_project(self.conn, self.tmp.name, init_git_if_missing=False) + self.project_id = project["project_id"] + + def tearDown(self): + self.conn.close() + + def _insert_task(self, task_id, status, goal_text="a goal"): + self.conn.execute( + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node, created_at) " + "VALUES (?, ?, ?, 'goal', ?, 'planner', CURRENT_TIMESTAMP)", + (task_id, self.project_id, goal_text, status), + ) + self.conn.commit() + + +class FindQueuedTasksTest(NewGoalDispatchTestBase): + def test_finds_queued_task(self): + self._insert_task("task-1", "queued") + result = new_goal_dispatch.find_queued_tasks(self.conn) + self.assertEqual(result, [{"task_id": "task-1", "target_folder_path": self.tmp.name, "goal_text": "a goal"}]) + + def test_skips_non_queued_statuses(self): + for status in ("coding", "validating", "done", "blocked_pending_human", "planning_brd_prd_sdd"): + self._insert_task("task-" + status, status) + result = new_goal_dispatch.find_queued_tasks(self.conn) + self.assertEqual(result, []) + + def test_excludes_in_flight_task(self): + self._insert_task("task-1", "queued") + with patch.object(task_execution, "is_in_flight", return_value=True): + result = new_goal_dispatch.find_queued_tasks(self.conn) + self.assertEqual(result, []) + + def test_no_candidates_returns_empty_list(self): + result = new_goal_dispatch.find_queued_tasks(self.conn) + self.assertEqual(result, []) + + +class DispatchOnceTest(unittest.TestCase): + """Real file db (own connection, mirrors ReconcileOnceTest) -- dispatch + itself is mocked so this stays at the "did we correctly decide to call + it" boundary.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + subprocess.run(["git", "init"], cwd=self.tmp.name, check=True, capture_output=True) + self.tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False) + self.tmp_db.close() + self.db_path = self.tmp_db.name + self.addCleanup(lambda: __import__("os").path.exists(self.db_path) and __import__("os").remove(self.db_path)) + + conn = schema.connect(self.db_path) + schema.create_schema(conn) + project = project_scaffold.create_project(conn, self.tmp.name, init_git_if_missing=False) + self.project_id = project["project_id"] + conn.execute( + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node, created_at) " + "VALUES ('queued-1', ?, 'goal 1', 'goal', 'queued', 'planner', CURRENT_TIMESTAMP)", + (self.project_id,), + ) + conn.execute( + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node, created_at) " + "VALUES ('coding-1', ?, 'goal 2', 'goal', 'coding', 'coder', CURRENT_TIMESTAMP)", + (self.project_id,), + ) + conn.commit() + conn.close() + + def test_dispatches_only_queued_candidates(self): + with patch.object(task_execution, "start_task_execution_in_background") as mock_start: + result = new_goal_dispatch.dispatch_once(self.db_path) + self.assertEqual([c["task_id"] for c in result], ["queued-1"]) + mock_start.assert_called_once_with(self.db_path, "queued-1", self.tmp.name, "goal 1") + + def test_one_dispatch_failure_does_not_block_others(self): + conn = schema.connect(self.db_path) + conn.execute( + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node, created_at) " + "VALUES ('queued-2', ?, 'goal 3', 'goal', 'queued', 'planner', CURRENT_TIMESTAMP)", + (self.project_id,), + ) + conn.commit() + conn.close() + + with patch.object( + task_execution, "start_task_execution_in_background", + side_effect=[task_execution.TaskExecutionError("already in flight"), None], + ) as mock_start: + result = new_goal_dispatch.dispatch_once(self.db_path) + self.assertEqual(len(result), 2) + self.assertEqual(mock_start.call_count, 2) + + def test_no_candidates_returns_gracefully(self): + conn = schema.connect(self.db_path) + conn.execute("UPDATE task SET status = 'done'") + conn.commit() + conn.close() + + with patch.object(task_execution, "start_task_execution_in_background") as mock_start: + result = new_goal_dispatch.dispatch_once(self.db_path) + self.assertEqual(result, []) + mock_start.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/src/test_org_node_trigger_e2e.py b/src/test_org_node_trigger_e2e.py new file mode 100644 index 0000000..1de5154 --- /dev/null +++ b/src/test_org_node_trigger_e2e.py @@ -0,0 +1,124 @@ +"""trigger_org_node_structured's real-call coverage (2026-08, +work-graph-node-execution-deblackbox-requirements.md FR-4 -- the migration +from Dynamic Workflow scripts to flat `claude --agent` calls for the three +call sites in work_graph_orchestrate.py/e2e_pipeline.py that used to render +one-off `.js` Workflow scripts purely to get "agent identity + schema-shaped +output"). Gated behind RUN_REAL_AGENT_E2E=1 (CLAUDE.md Testing constraint) -- +costs real API usage, skipped by default, same as src/test_agent_e2e.py. + +Run: RUN_REAL_AGENT_E2E=1 python3 -m unittest src.test_org_node_trigger_e2e -v + +**Real result, recorded 2026-08-13** (one real call, `--agent validator +--model claude-haiku-4-5`, target org_node's persona = agents/validator/SOUL.md +via .claude/agents/validator.md): all three FR-4 questions confirmed together +in a single real subprocess call (spied via `wraps=subprocess.run` so the +production `trigger_org_node_structured` code path runs completely for real, +while this test still gets to inspect the raw CompletedProcess underneath it): + +1. **Persona loaded correctly** -- the self-identify answer named itself an + independent QA/verification agent (PASS/FAIL/INCONCLUSIVE judgment + framework, "does not write or fix the Coder's business code"), matching + agents/validator/SOUL.md's own red lines, not the generic Claude Code + assistant persona. Same technique U-031 used to validate the (now-retired) + Dynamic Workflow `agentType` path -- ask the agent to self-identify, check + for persona-specific vocabulary, not a canned/generic answer. +2. **`--model` override took effect for real** -- `claude --agent ` + combined with `--model claude-haiku-4-5` is a genuinely different CLI + combination from anything DSL-internal Workflow options ever validated + (05 §5.4.1.5's "two different paths" lesson) -- confirmed empirically, not + assumed: the raw JSON response's own `modelUsage` key names + "claude-haiku-4-5" as the model that actually served the request, not + validator's role-declared default (`claude-sonnet-5`, roles/validator/ + role.json). +3. **```json fence extraction is stable on real model output** -- the + real free-text response (identity narrative + a trailing ```json fenced + block, since `--json-schema` is never combined with `--agent`, see 05 + §5.4.1.1) parsed cleanly via `extract_json_payload`/`trigger_org_node_ + structured`'s required-keys matching, returning the schema-shaped dict + with no manual fallback needed -- the same extraction algorithm + `work_graph_plan.py::plan_work_graph()` already validated for real in + production (`_extract_graph_manifest`), now proven for `_JSON_FENCE_ + INSTRUCTION`'s more generic prompt wording too. +""" + +import json +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +from src import agent_profile, org_node_trigger, project_scaffold, schema +from src.test_support import skip_unless_env + +_SELF_IDENTIFY_SCHEMA = { + "type": "object", + "required": ["job", "never_does"], + "properties": { + "job": {"type": "string", "description": "one short sentence describing your role"}, + "never_does": {"type": "string", "description": "one short sentence: something you never do"}, + }, +} + + +@skip_unless_env("RUN_REAL_AGENT_E2E") +class RealTriggerOrgNodeStructuredTest(unittest.TestCase): + def test_identity_model_override_and_json_extraction_all_real(self): + with tempfile.TemporaryDirectory() as tmp: + subprocess.run(["git", "init"], cwd=tmp, check=True, capture_output=True) + conn = schema.connect(":memory:") + schema.create_schema(conn) + project_scaffold.create_project(conn, tmp, init_git_if_missing=False) + agent_profile.deploy_agent_definitions(tmp) + + real_run = subprocess.run + captured = {} + + def spying_run(*args, **kwargs): + result = real_run(*args, **kwargs) + captured["result"] = result + return result + + # wraps the real subprocess.run rather than replacing it -- this + # call goes out to the real `claude` CLI exactly like production + # code would, this test just also gets to inspect the raw + # CompletedProcess trigger_org_node_structured's own return value + # (the extracted payload alone) doesn't expose. + with patch("src.org_node_trigger.subprocess.run", side_effect=spying_run): + extracted = org_node_trigger.trigger_org_node_structured( + "validator", + "In one short sentence each: what is your job, and what do you never do? " + "Answer only, take no other action.", + tmp, + _SELF_IDENTIFY_SCHEMA, + model="claude-haiku-4-5", + timeout=120, + ) + + # (3) JSON fence extraction worked on real model output -- a dict + # with both required keys, not None/a StructuredOutputExtractionError. + self.assertIn("job", extracted) + self.assertIn("never_does", extracted) + + # (1) persona genuinely loaded -- validator's own red lines + # (agents/validator/SOUL.md), not a generic assistant answer. + identity_text = extracted["job"] + " " + extracted["never_does"] + self.assertTrue( + any(kw in identity_text for kw in ( + "QA", "验收", "PASS", "FAIL", "INCONCLUSIVE", "independent", "verif", "Verif", + )), + "validator's answer doesn't look like the migrated QA identity: " + identity_text, + ) + + # (2) --model override genuinely took effect -- the real response's + # own modelUsage key names the override model, not validator's + # role-declared default (claude-sonnet-5, roles/validator/role.json). + raw_payload = json.loads(captured["result"].stdout) + model_usage = raw_payload.get("modelUsage") or {} + self.assertIn( + "claude-haiku-4-5", model_usage, + "--model override not reflected in the real response's modelUsage: " + json.dumps(raw_payload)[:2000], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/test_task_query.py b/src/test_task_query.py index 40f4400..f12ceb6 100644 --- a/src/test_task_query.py +++ b/src/test_task_query.py @@ -90,6 +90,7 @@ class GetTaskTest(TaskQueryTestBase): self.assertEqual(result["artifacts"], []) self.assertEqual(result["bug_records"], []) self.assertEqual(result["work_graph_runs"], []) + self.assertEqual(result["planner_review_events"], []) def test_includes_artifacts(self): self._insert_task("task-1") @@ -239,6 +240,52 @@ class GetTaskTest(TaskQueryTestBase): self.assertEqual(node["quota_wait_started_at"], "2026-07-31 09:00:00") + def test_includes_planner_review_events_for_the_task(self): + # 2026-08-13 (Org Graph unification Phase 3, FR-5a): planner_reviewer + # has no work_graph_run of its own -- its verdicts live in + # execution_event (event_type='planner_review_verdict'), see + # e2e_pipeline.py::_record_planner_review_event. + self._insert_task("task-1") + self.conn.execute( + "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) " + "VALUES ('evt-1', 'proj-1', 'task-1', 'planner_review_verdict', ?)", + (json.dumps({"attempt": 1, "verdict": "REJECTED", "used_existing_artifacts": False}),), + ) + self.conn.execute( + "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) " + "VALUES ('evt-2', 'proj-1', 'task-1', 'planner_review_verdict', ?)", + (json.dumps({"attempt": 2, "verdict": "PASS", "used_existing_artifacts": True}),), + ) + self.conn.commit() + + result = task_query.get_task(self.conn, "task-1") + events = result["planner_review_events"] + self.assertEqual(len(events), 2) + self.assertEqual(events[0]["attempt"], 1) + self.assertEqual(events[0]["verdict"], "REJECTED") + self.assertEqual(events[0]["used_existing_artifacts"], False) + self.assertEqual(events[1]["attempt"], 2) + self.assertEqual(events[1]["verdict"], "PASS") + self.assertEqual(events[1]["used_existing_artifacts"], True) + + def test_no_planner_review_events_is_an_empty_list_not_missing_key(self): + self._insert_task("task-1") + result = task_query.get_task(self.conn, "task-1") + self.assertEqual(result["planner_review_events"], []) + + def test_other_task_planner_review_events_excluded(self): + self._insert_task("task-1") + self._insert_task("task-2") + self.conn.execute( + "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) " + "VALUES ('evt-1', 'proj-1', 'task-2', 'planner_review_verdict', ?)", + (json.dumps({"attempt": 1, "verdict": "PASS", "used_existing_artifacts": False}),), + ) + self.conn.commit() + result = task_query.get_task(self.conn, "task-1") + self.assertEqual(result["planner_review_events"], []) + + class ListTasksSortTest(TaskQueryTestBase): def setUp(self): super().setUp() -- libgit2 0.21.4