diff --git a/docs/graphlemon_architecture.md b/docs/graphlemon_architecture.md index 8224953..fdd1bdb 100644 --- a/docs/graphlemon_architecture.md +++ b/docs/graphlemon_architecture.md @@ -316,6 +316,19 @@ graphlemon/ **方案 A 的可行性已通过实测确认(2026-07-29)**:`agent()`/`pipeline()` 编排脚本可通过 `claude -p`(非交互式)调用,无人值守完成、返回结构化可解析结果,验证了本节决策的前提成立。详见 `spikes/a1-dynamic-workflows/` 与 SDD `03-C-控制与接口.md` 3.5 节的具体实现契约。 +### 10.2 决策修订(2026-08-08):方案 A 已被后续实现背离并正式废弃,改为方案 A'(自研 Graph Engine,含单节点执行层) + +本节记录的"方案 A"从未被正式撤回,但实际实现在两层上都已经背离了它,本节之前一直没有同步更新,属于文档滞后: + +1. **图编排层(哪个节点先跑、重试、loop_back、跨进程断点续跑)**——`work_graph_orchestrate.py` 从 U-031(2026-08-01)起就是**纯自研 Python 状态机**,从未真正使用过 Claude Code 原生 `parallel()`/`pipeline()` 做多节点编排。原因记在该模块自己的 docstring 里:REQ-06 的断点续跑要求(进程重启后不重跑已完成节点)需要 Python 层持久化每个节点的状态,这和"把整张图包进一次不透明的 `claude -p` 调用"根本互斥——中途崩溃会丢失全部编排历史。这个背离在 `docs/done/work-graph-self-planning-and-loop-back-requirements.md` §1.3(2026-08-08)里第一次被正式记录为"既有背离,本需求延续它",但本节自己的正文从未同步。 +2. **单节点执行层(每个节点自己那一次 LLM 调用)**——原以为"至少这一层还在用原生 Dynamic Workflow"(`work_graph_orchestrate.py::_render_node_script()` 至今仍在生成 `export const meta = {...}` + `agent(prompt, {agentType, schema})` 脚本,通过 `claude -p 'Run /...'` 执行),且这一层曾经是有真实技术原因才留着的:`claude --agent -p ... --json-schema ` 直接组合会静默丢弃 `structured_output`(05 §5.4.1.1 实测发现),Workflow DSL 内部的 `agent({agentType, schema})` 组合是当时唯一验证过同时拿到"人设加载"+"schema 校验结构化输出"两者的机制(05 §5.4.1.5)。 + +2026-08-08 用户决策:**这条"留着"的理由不成立**——`work_graph_plan.py::plan_work_graph()`(`c_work_graph_plan`,跟 `_render_node_script()` 面对的是同一个"`--agent` 身份 + 结构化输出"组合需求)从一开始就没有走 Workflow 这条路,而是用最朴素的方式解决:直接 `subprocess.run(["claude", "--agent", org_node, "-p", prompt, "--output-format", "json", ...])`,从自由文本 `result` 字段里用 ```` ```json ```` 围栏 + 正则提取解析(`_extract_graph_manifest`,05 §5.4.1.1 自己推荐的应对方式),且这个方案早已在生产环境真实跑通(Planner 四节点文档链)。也就是说,"必须用 Workflow 才能同时拿到人设+结构化输出"这个前提本身就是错的——两种机制其实都能做到,Workflow 只是恰好也能做到,不是唯一路径。 + +**新决策(方案 A')**:Work Graph 的执行层(图编排 + 单节点执行)全部自研,不复用 Claude Code 原生 Dynamic Workflow 的任何部分——理由是可控性/可观测性:一次不透明的 `claude -p 'Run /...'` 调用对 graphlemon 自己的进程而言是黑盒(拿不到中间状态、拿不到细粒度日志、`work_graph_execute.execute_work_graph_with_usage_limit_retry`'s own docstring 早就承认过"动态工作流脚本没有单一顶层 `--agent` 身份,个别 `agent()` 调用要各自处理模型降级"这类可控性缺口),而 `plan_work_graph()` 已经验证过的"flat `claude --agent` 调用 + JSON 围栏提取"路径完全在 graphlemon 自己的 Python 代码可见范围内。具体迁移计划见 `docs/todo/work-graph-node-execution-deblackbox-requirements.md`——这份文档要把 `work_graph_orchestrate.py`(节点执行 + `judgment.kind="llm"` 判断)和 `e2e_pipeline.py::_check_prd_consistency()` 这三处仍在用 `write_workflow_script`/`execute_work_graph` 的调用点,全部换成 `plan_work_graph()` 已经验证过的同一套机制。 + +第 10 节表格第 14 条("让 Claude 自己画图:动态工作流/self-routing")作为课程对照笔记本身不需要改——那是纯粹的技法启发记录;改的只是"要不要采纳这个技法作为我们自己的执行机制"这个决策,本节 10.2 是这个决策的最终修订版本。 + ## 11. MVP 范围建议 - **Org Graph**:Planner → Coder → Validator 三节点 + Validator → Planner 回退边,固定实现。 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 index 6d474c9..92c7752 100644 --- a/docs/todo/org-graph-unification-and-universal-self-planning-requirements.md +++ b/docs/todo/org-graph-unification-and-universal-self-planning-requirements.md @@ -96,10 +96,12 @@ ## 6. Phase 规划 ### Phase 1 — Org 主流程改造 + resume bug 一并修复 +- **详细设计**:`docs/todo/org-graph-unification-phase1-review-gate-design.md`(2026-08-08,可直接实施)。 - **范围**:FR-1(含 `task-execution-resume-assumes-planner-artifacts-exist.md` 的修复)。 -- **产出**:`task.status` schema 迁移、`org_node_lifecycle.py` 扩展、`run_task_lifecycle()`/`run_or_resume_task_lifecycle()` 新分支。 -- **前置条件**:无——调研已给出具体改动点,可以先做。复核阶段本身可以先用 `org_level_loop.py` 现有的粗粒度路由兜底(复核失败 → 整个打回 Planner 重来),不必等 Phase 2 的精确 loop_back 才能上线,先把"Coder 开始前多一道独立判断"这件事跑起来。 -- **验收**:能在(已真实复现过的)"benchmark 输出叙述文字冒充产出"场景下,在 Coder 动手之前拦住任务;崩溃后 resume 不再假设 Planner artifact 一定存在。 +- **产出**:`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 - **范围**:FR-2 + FR-3 + FR-4,按 §4/§4.1 已拍板的合图路径实现。 @@ -109,6 +111,7 @@ 3. 新增节点级验证 skill,`org_level_loop.py` 的粗粒度 `fast_lane`/`full_replan` 路由被替换为图内精确 `loop_back`(因为合图后失败节点和目标节点天然在同一个 `run_id`,不再需要跨图路由层)。 - **前置条件**:Phase 1 落地的"复核"阶段先用 `org_level_loop.py` 粗粒度路由兜底上线;Phase 2 把这段兜底逻辑替换成图内精确 loop_back,同时完成 §4.1 的三个迁移点。 - **风险**: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。 @@ -123,8 +126,8 @@ ## 7. 待确认的问题(讨论后逐条勾掉,参照 `graph-engineering-open-items.md` 的状态图例) ✅ §4 关键架构决策:方案 A(合图)。2026-08-08 用户拍板。 -🔲 FR-1 新增的 `task.status` 枚举值具体叫什么名字(不影响功能,但要在讨论时定下来,避免实现时随意起名,例如 `planner_output_review`/`reviewing_planner_output` 等候选)。 -🔲 FR-1 的"复核 Validator"和"终审 Validator"是否复用同一个 `validator` org role 跑两次(带不同的 manifest/prompt),还是需要一个新的 org role——影响 `roles/` 目录结构和 `org_node_lifecycle._PRE_STATE`/`_POST_STATE` 的 key 设计。 +✅ FR-1 新增的 `task.status` 枚举值定为 `reviewing_planner_output`。2026-08-08,随 Phase 1 SDD(`docs/todo/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 是在 `qa-task-validation` 基础上改造,还是新开一个 skill。 🔲 Phase 2 的 Coder/Validator 自规划,要不要像 Planner 一样有一份"允许的节点类型清单"约束,还是完全自由(对应 `_PROMPT_TEMPLATE` vs `_GENERIC_PROMPT_TEMPLATE` 的既有分野)。 🔲 §4.1 第 1 点:`org_node` 下沉到节点级别后,`work_graph_run.org_node`(run 级)这一列要不要保留(比如作为"发起该 run 的原始 org_node"这种冗余但方便查询的字段),还是彻底移除、查询时改成从节点聚合。 diff --git a/docs/todo/org-graph-unification-phase1-review-gate-design.md b/docs/todo/org-graph-unification-phase1-review-gate-design.md new file mode 100644 index 0000000..8307157 --- /dev/null +++ b/docs/todo/org-graph-unification-phase1-review-gate-design.md @@ -0,0 +1,467 @@ +# Phase 1 — Planner 产出复核阶段 + resume/fresh-run artifact 假设修复——详细设计(SDD) + +**日期**: 2026-08-08 +**性质**: 详细设计,对应 `docs/todo/org-graph-unification-and-universal-self-planning-requirements.md` 的 Phase 1(FR-1,含 `task-execution-resume-assumes-planner-artifacts-exist.md` 的修复)。给出可直接实施的 schema/接口/算法/测试方案。 +**依赖代码**:`src/org_node_lifecycle.py`(`_PRE_STATE`/`_POST_STATE`/`_REQUIRED_ARTIFACTS`/`trigger_org_node_full`)、`src/e2e_pipeline.py`(`run_task_lifecycle`/`plan_documents`/`_parse_verdict_line`/`_VALIDATOR_VERDICT_PROMPT` 先例)、`src/task_execution.py`(`run_or_resume_task_lifecycle`/`_RESUMABLE_STATUSES`)、`src/scheduler.py`(`_BLOCKED_REASON_FOR_EXCEPTION`)、`src/schema.py`(`migrate_add_loop_back_event_type` 的 create-copy-drop-rename 先例)、`src/agent_profile.py`(`_discover_role_ids`,角色自动发现)、`roles/validator/role.json`(新角色的模板)。 +**状态**: 设计草案,待实施。 + +--- + +## 1. 前置决策回顾 + +`org-graph-unification-and-universal-self-planning-requirements.md` §7 留了两个 Phase 1 相关的开放问题,本设计做出以下拍板(理由见对应小节): + +1. **"复核 Validator"用新角色,不复用 `validator`**——已通过 `AskUserQuestion` 与用户确认(2026-08-08),理由见 §2。 +2. **新 `task.status` 枚举值定为 `reviewing_planner_output`**——本设计新拍板,理由见 §3.1。 + +--- + +## 2. 设计概览 + +### 2.1 为什么必须是新角色,不能复用 `validator` + +`org_node_lifecycle.py:31-32` 的 `_PRE_STATE`/`_POST_STATE` 是按 `org_node` 名字的**一对一**字典:`trigger_org_node_full()` 内部 `task["status"] != _PRE_STATE[org_node]`(`org_node_lifecycle.py:102`)这一行假设同一个 `org_node` 在整个任务生命周期里只对应一组固定的前置/后置状态。如果"复核"和"终审"都叫 `validator`,这个字典没法给同一个 key 挂两组状态转移。 + +新角色 `planner_reviewer` 走 Role Package 抽象(`roles/planner_reviewer/role.json`)——`agent_profile.py:72` 的 `ORG_NODES = _discover_role_ids()` 已经是扫描 `roles/` 目录动态发现的,新增一个角色只需要新增一个 `role.json` + `agents/planner_reviewer/SOUL.md`/`AGENTS.md`,不需要改 `agent_profile.py`/`org_node_trigger.py` 任何一行代码(`org_node_trigger.py:30` 的 `if org_node not in ORG_NODES` 检查复用同一个动态发现的集合)。 + +### 2.2 一个关键的既有语义,决定了整个设计怎么做(必须先理解,否则会重复 P0 bug 的错误) + +调研 `run_task_lifecycle()`(`e2e_pipeline.py:878-920`)发现:`trigger_org_node_full()` 对每个 org_node 的调用顺序是**先做一次轻量"ready"握手 + 立即把 `task.status` 推进到 POST_STATE,然后才执行真正的实质性工作**(`plan_documents()`/`implement_code()`/`validate_implementation_with_own_graph()`)。也就是说: + +``` +trigger_org_node_full(planner, _READY_PROMPT) # 握手成功 -> status 立即变成 planning_brd_prd_sdd +plan_result = plan_documents(...) # 真正的规划工作,此时 status 已经是"下一阶段的样子" +``` + +`task.status` 从来不是"这个阶段的产出是否合格"的信号,只是"轮到谁"的记账 token——真正的产出校验(如果有的话)由**下游独立的一次调用**完成(Validator precedent:`validate_implementation_with_own_graph()` 产出 verdict 后,`route_org_loop`/`close_task_done` 才是真正决定任务命运的地方;`e2e_pipeline.py:891-893` 目前对 Planner 的产出**完全没有**类似的 verdict 步骤,这正是本次要补的缺口)。 + +**设计推论**:新增的 `planner_reviewer` 阶段必须照抄 Validator 这个"先握手推进状态、真正校验单独一次调用"的模式,**不能**在校验通过之后才推进 `task.status`——否则会破坏 `trigger_org_node_full` 现有的统一契约(03-C 的 `c_org_node_trigger` 就是"握手+状态推进"这一个原子操作,不应该在这次改动里对 Planner Reviewer 单独发明"校验通过才推进"的第二套语义)。真正的把关,落在"任务从 `reviewing_planner_output` 恢复时必须重新调用一次真正的校验函数,不能假设状态名等于结果"——这跟 Validator 的 `validating` resume 分支已经是同一个模式(`task_execution.py:102-118` 的 docstring 原话:"this is NOT a between-phases resume... the actual gap is narrower")。 + +### 2.3 新流程总览 + +``` +queued + --[trigger_org_node_full(planner)]--> planning_brd_prd_sdd + --[plan_documents() 真正规划]--> + --[trigger_org_node_full(planner_reviewer)]--> reviewing_planner_output + --[_review_planner_output_with_retries() 真正复核,内部最多重试 3 轮]--> + 通过 --> + --[trigger_org_node_full(coder)]--> coding + --[implement_code() 真正实现]--> + --[trigger_org_node_full(validator)]--> validating + --[validate_implementation_with_own_graph() 真正验收]--> + PASS --> done (close_task_done,不变) + FAIL/INCONCLUSIVE --> org_level_loop.run_fix_loop(不变,Phase 1 不碰这段) + 复核耗尽重试 --> blocked_pending_human(reason='planner_review_loop_limit_reached',新) +``` + +--- + +## 3. Schema 变更 + +### 3.1 `task.status` 新增枚举值 `reviewing_planner_output` + +命名理由:`_PRE_STATE`/`_POST_STATE` 现有值(`planning_brd_prd_sdd`/`coding`/`validating`)全部是"上一阶段刚握手完成"的记账态,不是字面意义的"正在进行中"(见 §2.2)。`validating` 已经是这种"其实已经握手完成、真正工作在这个状态名下面跑"的先例,`reviewing_planner_output` 沿用同一个动名词命名习惯,语义一致。 + +`schema.py:106-111` 的 CHECK 约束扩展为: + +```sql +status TEXT NOT NULL CHECK (status IN ( + 'queued', 'planning_brd_prd_sdd', 'reviewing_planner_output', 'planning_failed', + 'coding', 'coding_failed', 'validating', + 'routed_fast_lane', 'routed_full_replan', + 'blocked_pending_human', 'done' +)), +``` + +新增迁移函数 `migrate_add_reviewing_planner_output_status(conn)`,照抄 `migrate_expand_artifact_types`/`migrate_add_loop_back_event_type` 的 create-copy-drop-rename 惯用法(SQLite 无法 `ALTER` CHECK 约束):读 `sqlite_master` 里 `task` 表的建表 SQL,如果已经包含 `'reviewing_planner_output'` 就直接返回(幂等);否则 `CREATE TABLE task_new (...)` 用新枚举、`INSERT INTO task_new SELECT * FROM task`、`DROP TABLE task`、`ALTER TABLE task_new RENAME TO task`,随后重建 `idx_task_status` 索引。`backend_server.py` 里紧邻 `migrate_add_loop_back_event_type(conn)`(第 1040 行)新增一行调用。 + +### 3.2 `execution_event.event_type` 新增枚举值 `planner_review_verdict` + +用于持久化每一轮复核的 PASS/FAIL/INCONCLUSIVE 结果(`payload: {"attempt": N, "verdict": "...", "used_existing_artifacts": true/false}`)——不是给控制台展示用的(那是 Phase 3 的事),是给"崩溃后 resume 时能不能信任当前这批 doc artifact 已经被复核过"这件事留下审计证据;Phase 1 本身**不**依赖读这张表来做 resume 判断(见 §4.3 的简化说明),只是照 `loop_back_triggered` 的先例把决策记下来,供人工排查和未来 Phase 2 复用。 + +`schema.py:264-268` 的 CHECK 约束新增一个值;新增迁移函数 `migrate_add_planner_review_verdict_event_type(conn)`,逻辑与 `migrate_add_loop_back_event_type` 完全同构(复制粘贴改枚举值)。`backend_server.py` 同一处再加一行调用。 + +--- + +## 4. `org_node_lifecycle.py` 变更 + +```python +_PRE_STATE = { + "planner": "queued", + "planner_reviewer": "planning_brd_prd_sdd", + "coder": "reviewing_planner_output", # 原来是 "planning_brd_prd_sdd" + "validator": "coding", +} +_POST_STATE = { + "planner": "planning_brd_prd_sdd", + "planner_reviewer": "reviewing_planner_output", + "coder": "coding", + "validator": "validating", +} +_REQUIRED_ARTIFACTS = { + "coder": ("sdd",), # 原来是 ("brd", "prd", "sdd", "benchmark") + "validator": ("code_diff", "test_report"), +} +``` + +### 4.1 为什么把 `_REQUIRED_ARTIFACTS["coder"]` 从 4 个类型收窄到只剩 `sdd` + +调研 `artifact_write.py:8-14` 发现 `validation_status` **在写入时就无条件设成 `'passed'`**(该文件自己的 docstring 承认这是一个简化:真正的语义内容校验"还没有专属的 LLM-judgment 调用")。也就是说 `_REQUIRED_ARTIFACTS["coder"]` 今天检查的"`validation_status='passed'`"永远为真——这个 gate 从来没有真正拦住过任何东西,是一个失效的桩。**新增的 `planner_reviewer` 阶段才是第一个真正做语义校验的 gate**,`_REQUIRED_ARTIFACTS["coder"]` 里 `brd`/`prd`/`benchmark` 这三个失效检查应该删掉,不是保留一个"看起来有校验、实际上从不生效"的假象。 + +保留 `sdd`:这不是校验质量,是**存在性**兜底——`implement_code()`(`e2e_pipeline.py:617`)的函数签名要求一个真实的 `sdd_content` 位置参数,`sdd` artifact 不存在会导致调用方在更早、更难定位的地方崩(`KeyError`),保留这一条纯粹是防止"复核通过了,但 sdd 其实是空的"这种不可能但值得防御的情况提前报出清晰错误,而不是让它一路传到 `implement_code()` 内部再崩。 + +### 4.2 `planner` 分支不变 + +`_check_concurrency(conn)` 仍然只在 `org_node == "planner"` 时触发(`org_node_lifecycle.py:110-111`)——并发上限的语义是"同时有几个 goal 在跑",跟这次改动无关,不变。 + +--- + +## 5. `e2e_pipeline.py` 变更 + +### 5.1 新增:叙述文字启发式(复核阶段的辅助信号,非硬拦截) + +```python +# ponytail: 子串匹配,从 docs/todo/validator-test-script-narrative-output-issue.md +# 记录的真实复现文案里提炼——"以为在描述自己做了什么"的稳定措辞模式。只作为喂给 +# planner_reviewer 的提示信号,不是硬性机械拦截(跟 check_script_compiles 不同: +# 这里没有客观的"编译通过/失败"可判断,误判成本是把合法的、恰好提到这些词的文档 +# 打回重来)。上限:这份短语表穷举不完,Phase 2 的结构性修复(把"探索"和"最终作答" +# 拆成两次独立调用,见 validator-test-script-narrative-output-issue.md §8)才是 +# 真正的上游修复,这里只是先兜住已经复现过的具体案例。 +_NARRATION_PHRASES = ( + "no further action", "launched the", "workflow finished", "workflow completed", + "it produced a", "you'll be notified", "no files were changed", +) + + +def looks_like_narration(content): + lowered = (content or "").lower() + return any(phrase in lowered for phrase in _NARRATION_PHRASES) +``` + +### 5.2 新增:复核 verdict prompt + 调用函数 + +照抄 `_VALIDATOR_VERDICT_PROMPT`/`validate_implementation_with_own_graph()` 结尾那段"拼 prompt → `org_node_trigger.trigger_org_node()` → `_parse_verdict_line()`"的既有模式(`_parse_verdict_line` 本身不用改,直接复用): + +```python +_PLANNER_REVIEW_PROMPT = """... goal_text={goal_text} ... +BRD: {brd_content} +PRD: {prd_content} +SDD: {sdd_content} +Benchmark: {benchmark_content} +{narration_hint} +... 以 "VERDICT: PASS|FAIL|INCONCLUSIVE" 收尾,说明理由 ... +""" + + +def review_planner_output( + conn, task_id, goal_text, target_folder_path, + sdd_content, brd_content="N/A: not provided", + prd_content="N/A: not provided", benchmark_content="N/A: not provided", + timeout=300, +): + """真正的复核调用(planner_reviewer 角色),不是 trigger_org_node_full 的 ready + 握手。Returns {"verdict", "raw_response"}.""" + hints = [ + "Heuristic flag: {} content resembles a narrative description of an action " + "rather than structured document content -- verify carefully.".format(label) + for label, content in (("SDD", sdd_content), ("Benchmark", benchmark_content)) + if looks_like_narration(content) + ] + response = org_node_trigger.trigger_org_node( + "planner_reviewer", + _PLANNER_REVIEW_PROMPT.format( + goal_text=goal_text, brd_content=brd_content, prd_content=prd_content, + sdd_content=sdd_content, benchmark_content=benchmark_content, + narration_hint="\n".join(hints), + ), + target_folder_path, timeout=timeout, + ) + return {"verdict": _parse_verdict_line(response), "raw_response": response} +``` + +### 5.3 新增:`_existing_doc_artifact_contents` + 重试循环核心 `_review_planner_output_with_retries` + +`_existing_doc_artifact_contents` 是 fresh-run 和 resume 两条路径共用的"这个 task 现在已经有哪些 doc artifact"探测函数——**这正是修 P0 bug 的关键点**:resume 分支不再无条件假设 sdd/prd/benchmark 存在(旧代码 `task_execution.py:126-129` 的 `_artifact_content()` 硬失败调用),而是先探测存在性,"有就用、没有就重新规划",不再对"到底有没有"这件事打赌。 + +```python +_PLANNER_REVIEW_MAX_ATTEMPTS = 3 + + +class PlannerReviewLoopLimitReachedError(Exception): + """复核 _PLANNER_REVIEW_MAX_ATTEMPTS 轮都没拿到 PASS。""" + + +def _existing_doc_artifact_contents(conn, target_folder_path, task_id): + """{artifact_type: content},只包含真的存在的(brd/prd/sdd/benchmark 的子集, + 可能是空字典——比如 task-execution-resume-assumes-planner-artifacts-exist.md + 记录的那次真实复现,Planner 图从第一个节点就彻底失败,什么都没落盘)。""" + ids = task_execution._latest_artifact_ids(conn, task_id, task_execution._DOC_ARTIFACT_TYPES) + return {t: _read_artifact_content(conn, target_folder_path, aid) for t, aid in ids.items()} + + +def _review_planner_output_with_retries(conn, task_id, goal_text, target_folder_path, timeout=300): + """fresh-run(plan_and_review_documents)和 resume-from-reviewing_planner_output + (task_execution.py)共用的核心循环。第 1 轮优先复用已经存在的 doc artifact(resume + 场景,避免重复规划一次已经成功的产出);探测为空,或后续轮次,都重新调用 + plan_documents() 生成新的一批。每轮复核结果落一条 execution_event + (event_type='planner_review_verdict'),供审计/未来 Phase 2 复用,Phase 1 自己不读它。 + Returns plan_documents() 的返回形状(来自拿到 PASS 的那一轮)。 + ponytail: 每轮拒绝都是完整重新规划 BRD/PRD/SDD/Benchmark,不是针对性地只让 Planner + 改被拒的那一份文档——粗粒度但正确,跟 org_loop_route.py 现有 full_replan 的粒度一致。 + Phase 2 的节点级 loop_back(org-graph-unification-and-universal-self-planning- + requirements.md Phase 2)是精确重试的升级路径。 + """ + feedback_suffix = "" + verdict = None + for attempt in range(1, _PLANNER_REVIEW_MAX_ATTEMPTS + 1): + existing = _existing_doc_artifact_contents(conn, target_folder_path, task_id) if attempt == 1 else {} + if existing: + artifact_ids = task_execution._latest_artifact_ids( + conn, task_id, task_execution._DOC_ARTIFACT_TYPES + ) + plan_result = {"run_id": None, "artifact_ids": artifact_ids} + else: + plan_result = plan_documents(conn, task_id, goal_text + feedback_suffix, target_folder_path) + artifact_ids = plan_result["artifact_ids"] + + sdd_content = _read_artifact_content(conn, target_folder_path, artifact_ids["sdd"]) + brd_content = _read_artifact_content(conn, target_folder_path, artifact_ids["brd"]) if "brd" in artifact_ids else "N/A: not provided" + prd_content = _read_artifact_content(conn, target_folder_path, artifact_ids["prd"]) if "prd" in artifact_ids else "N/A: not provided" + benchmark_content = _read_artifact_content(conn, target_folder_path, artifact_ids["benchmark"]) if "benchmark" in artifact_ids else "N/A: not provided" + + review = review_planner_output( + conn, task_id, goal_text, target_folder_path, + sdd_content, brd_content, prd_content, benchmark_content, timeout=timeout, + ) + _record_planner_review_event(conn, task_id, attempt, review["verdict"], bool(existing)) + verdict = review["verdict"] + if verdict == "PASS": + return plan_result + feedback_suffix = "\n\n[Planner review attempt {} was rejected ({}): {}. Address this " \ + "before continuing.]".format(attempt, verdict, review["raw_response"]) + + raise PlannerReviewLoopLimitReachedError( + "task {} exhausted {} planner-review attempts, last verdict={}".format( + task_id, _PLANNER_REVIEW_MAX_ATTEMPTS, verdict + ) + ) + + +def plan_and_review_documents(conn, task_id, goal_text, target_folder_path, timeout=300): + """fresh-run 入口:两次握手(planner, planner_reviewer)+ 共用重试循环。取代 + run_task_lifecycle() 里原来的 trigger_org_node_full(planner) + plan_documents() + 两行。""" + org_node_lifecycle.trigger_org_node_full(conn, "planner", task_id, target_folder_path, _READY_PROMPT) + org_node_lifecycle.trigger_org_node_full(conn, "planner_reviewer", task_id, target_folder_path, _READY_PROMPT) + return _review_planner_output_with_retries(conn, task_id, goal_text, target_folder_path, timeout=timeout) +``` + +(`_record_planner_review_event` 是一个几行的 `execution_event` INSERT helper,形状照抄 `org_loop_route._write_status_event`。) + +### 5.4 `run_task_lifecycle()` 改动 + +```python +def run_task_lifecycle(conn, task_id, target_folder_path, goal_text): + plan_result = plan_and_review_documents(conn, task_id, goal_text, target_folder_path) + artifact_ids = plan_result["artifact_ids"] + sdd_content = _read_artifact_content(conn, target_folder_path, artifact_ids["sdd"]) + # FIX(本设计顺带修复,发现于本次调研,见 §6"额外发现"):prd/benchmark 原来是 + # artifact_ids["prd"] 无条件下标访问,自规划图省略 prd/benchmark 时会 KeyError + # 崩在这里——不是 resume 独有的问题,fresh-run 首次也会崩。改成跟 test_dataset/ + # test_script 一样的 "in" 判断 + 默认值。 + prd_content = _read_artifact_content(conn, target_folder_path, artifact_ids["prd"]) if "prd" in artifact_ids else "N/A: not provided" + benchmark_content = _read_artifact_content(conn, target_folder_path, artifact_ids["benchmark"]) if "benchmark" in artifact_ids else "N/A: not provided" + # test_dataset/test_script 部分不变(已有的 "in" 判断本来就是对的) + ... + org_node_lifecycle.trigger_org_node_full(conn, "coder", task_id, target_folder_path, _READY_PROMPT) + code_artifact_ids = implement_code(...) # 不变 + org_node_lifecycle.trigger_org_node_full(conn, "validator", task_id, target_folder_path, _READY_PROMPT) + verdict = validate_implementation_with_own_graph(...) # 不变 + ... +``` + +--- + +## 6. `task_execution.py` 变更 + +```python +_RESUMABLE_STATUSES = ("queued", "planning_brd_prd_sdd", "reviewing_planner_output", "coding", "validating") + + +def run_or_resume_task_lifecycle(conn, task_id, target_folder_path, goal_text): + status = ... + if status not in _RESUMABLE_STATUSES: + raise TaskExecutionError(...) + + if status == "queued": + return e2e_pipeline.run_task_lifecycle(conn, task_id, target_folder_path, goal_text) + + if status == "planning_brd_prd_sdd": + # planner 的 ready 握手已经成功过,但 plan_documents() 真正跑没跑完全未知 + # (P0 bug 场景:图第一个节点就彻底失败,什么都没落盘)。不再在这里对 + # sdd/prd/benchmark 做硬性 _artifact_content() 探测——把这件事完全交给 + # _review_planner_output_with_retries() 内部的存在性探测(§5.3),有就用, + # 没有就重新 plan_documents()。 + _check_not_paused(conn, task_id) + org_node_lifecycle.trigger_org_node_full( + conn, "planner_reviewer", task_id, target_folder_path, e2e_pipeline._READY_PROMPT + ) + status = "reviewing_planner_output" + + if status == "reviewing_planner_output": + _check_not_paused(conn, task_id) + plan_result = e2e_pipeline._review_planner_output_with_retries( + conn, task_id, goal_text, target_folder_path + ) + artifact_ids = plan_result["artifact_ids"] + sdd_content = e2e_pipeline._read_artifact_content(conn, target_folder_path, artifact_ids["sdd"]) + prd_content = _artifact_content_or_default(conn, target_folder_path, task_id, "prd", "N/A: not provided") + benchmark_content = _artifact_content_or_default(conn, target_folder_path, task_id, "benchmark", "N/A: not provided") + test_dataset_content = _artifact_content_or_default(conn, target_folder_path, task_id, "test_dataset", "N/A: not provided") + test_script_content = _artifact_content_or_default(conn, target_folder_path, task_id, "test_script", "N/A: not provided") + + _check_not_paused(conn, task_id) + org_node_lifecycle.trigger_org_node_full(conn, "coder", task_id, target_folder_path, e2e_pipeline._READY_PROMPT) + code_artifact_ids = e2e_pipeline.implement_code( + conn, task_id, sdd_content, goal_text, target_folder_path, + benchmark_content=benchmark_content, test_dataset_content=test_dataset_content, + test_script_content=test_script_content, + ) + else: # status in ("coding", "validating") -- Coder already ran, unchanged from today + # 这个分支也要读 sdd_content/prd_content 给 validator 用,逻辑跟今天一样 + # (原代码在函数顶部为所有 resumable 状态统一读取,这次改动后要挪到这个分支 + # 内部,改用 _artifact_content_or_default 而不是硬失败版本——见下方"顺带修复") + sdd_content = _artifact_content_or_default(conn, target_folder_path, task_id, "sdd", "N/A: not provided") + prd_content = _artifact_content_or_default(conn, target_folder_path, task_id, "prd", "N/A: not provided") + code_artifact_ids = _latest_artifact_ids(conn, task_id, _CODE_ARTIFACT_TYPES) + + _check_not_paused(conn, task_id) + if status != "validating": + org_node_lifecycle.trigger_org_node_full(conn, "validator", task_id, target_folder_path, e2e_pipeline._READY_PROMPT) + verdict = e2e_pipeline.validate_implementation_with_own_graph( + conn, task_id, sdd_content, prd_content, goal_text, target_folder_path, + ) + return {"task_id": task_id, "doc_artifact_ids": ..., "code_artifact_ids": code_artifact_ids, "verdict": verdict} +``` + +**注意**:原代码里 `sdd_content`/`prd_content`/`benchmark_content` 是在函数最开头、所有 resumable 状态共用地读取(`task_execution.py:126-137`),且 sdd/prd/benchmark 三个都是硬失败版本(`_artifact_content`,见 §7"P0 bug 修复点")——这正是 bug 本体所在的位置。本设计把这些读取挪到各自分支内部,且除 `sdd`(`reviewing_planner_output` 分支内,此时已经过复核确认存在)外全部改用软失败的 `_artifact_content_or_default`。`coding`/`validating` 分支里的 `sdd_content` 理论上不会真的缺失(复核通过才能到这两个状态),但仍用软失败版本兜底,防止一个理论上不该发生、但值得防御的边界状态导致跟今天完全一样的硬崩溃。 + +--- + +## 7. P0 bug(`task-execution-resume-assumes-planner-artifacts-exist.md`)的修复点小结 + +| 原代码位置 | 原问题 | 本设计的修复 | +|---|---|---| +| `task_execution.py:127-129`(旧) | `planning_brd_prd_sdd`/`coding`/`validating` 三个状态共用地、无条件调用 `_artifact_content()`(硬失败)读 sdd/prd/benchmark | 存在性探测下沉到 `_review_planner_output_with_retries()`(§5.3)内部,"没有就重新规划"而不是崩溃;`coding`/`validating` 分支改用软失败版本兜底(§6) | +| `e2e_pipeline.py:892-893`(旧,fresh-run 路径,本次调研新发现) | `artifact_ids["prd"]`/`artifact_ids["benchmark"]` 无条件下标访问,自规划图省略这两个类型时 `KeyError`,**不需要 resume 就能触发,首次运行就崩** | `run_task_lifecycle()` 改成跟 `test_dataset`/`test_script` 一样的 `"in" artifact_ids` 判断(§5.4) | + +--- + +## 8. 新角色 `planner_reviewer` + +### 8.1 `roles/planner_reviewer/role.json` + +照抄 `roles/validator/role.json` 的只读哲学(复核不应该有能力改任何文件): + +```json +{ + "role_id": "planner_reviewer", + "description": "graphlemon Planner Reviewer: independently judges whether Planner's BRD/PRD/SDD/Benchmark output is real structured content (not narrative prose) and consistent with the goal, before Coder is allowed to start. Read-only on source.", + "migrated_from_profile": null, + "identity": { + "soul_md_path": "agents/planner_reviewer/SOUL.md", + "agents_md_path": "agents/planner_reviewer/AGENTS.md" + }, + "context": { "memory": "project" }, + "model": "claude-sonnet-5", + "skills": ["planner-output-review", "unattended-agent-operations"], + "applications": { "mcp_servers": [] }, + "workflows": { "dynamic_planning": "none" }, + "permissions": { + "tools": ["Read", "Grep", "Glob"], + "disallowed_tools": ["Edit", "Write", "Bash"], + "notes": [ + { "constraint": "只读复核,不做任何文件/命令操作", "enforced": "tool_level", + "detail": "跟 validator 不同:validator 需要 Bash 去真的跑测试脚本,planner_reviewer 只判断文档内容质量,不需要执行任何东西,可以比 validator 更严格地在工具层面禁掉 Bash。" } + ] + }, + "acceptance_criteria": { "ref": "docs/sdd/06-验收与测试锚点.md" } +} +``` + +(`workflows.dynamic_planning: "none"`——Phase 1 的 `planner_reviewer` 是一次 flat `trigger_org_node` 调用,不是 Work Graph 节点,不需要自规划;Phase 2 会把它接进合图后的自规划体系,那时这个字段要改。) + +### 8.2 新增 skill:`skills/planner-output-review/SKILL.md` + +内容要点(供实现阶段撰写,本设计只定范围): +- 判断对象:BRD/PRD/SDD/Benchmark 是否为结构化文档内容,而不是"我做了什么"的叙述文字(直接引用 `docs/todo/validator-test-script-narrative-output-issue.md` 记录的真实案例作为反面教材)。 +- 判断对象二:产出是否跟 goal_text 语义一致(复用 `_prd_consistency_verify_fn` 已经建立的"内部矛盾检测"思路,但这次是跨文档一致性,不是单文档内部一致性)。 +- 输出格式:必须以 `VERDICT: PASS|FAIL|INCONCLUSIVE` 收尾(复用 `_parse_verdict_line` 的既有解析约定,不新发明格式)。 +- 明确"只读,不改文件"——跟 `qa-task-validation` 的既有措辞风格一致。 + +### 8.3 `agents/planner_reviewer/SOUL.md` + `AGENTS.md` + +照 `agents/validator/SOUL.md`(32 行)+ `AGENTS.md`(48 行)的规模和"独立验收、只读、不绕过发现的问题"基调迁移,替换掉"验收最终代码实现"为"验收 Planner 的文档产出"。具体文案是实现阶段的工作,不在本 SDD 展开。 + +--- + +## 9. `scheduler.py` 变更 + +`_BLOCKED_REASON_FOR_EXCEPTION`(`scheduler.py:79-84`)新增一行: + +```python +(e2e_pipeline.PlannerReviewLoopLimitReachedError, "planner_review_loop_limit_reached"), +``` + +这样复核耗尽重试时,`task_execution.py::_mark_blocked`/`scheduler.py` 的 `except _ROUTABLE_EXCEPTIONS` 分支能给出具体的 `blocked_reason`(而不是退化成通用的 `"execution_error"`),跟 `test_script_verification_failed`/`prd_consistency_check_failed` 的既有先例一致。 + +--- + +## 10. 测试方案 + +### 10.1 新增/改动的单测文件 + +- `src/test_org_node_lifecycle.py`:`_PRE_STATE`/`_POST_STATE`/`_REQUIRED_ARTIFACTS` 改动后的握手链测试(`planner_reviewer` 的 PRE/POST、`coder` 新 PRE_STATE、`_REQUIRED_ARTIFACTS["coder"]` 收窄后不再要求 brd/prd/benchmark)。 +- `src/test_e2e_pipeline.py`: + - `review_planner_output()`/`looks_like_narration()` 单测(含真实复现案例的原文措辞作为 fixture)。 + - `_review_planner_output_with_retries()`:PASS 直接返回;FAIL 重试到 `_PLANNER_REVIEW_MAX_ATTEMPTS` 后抛 `PlannerReviewLoopLimitReachedError`;第 1 轮存在既有 artifact 时跳过 `plan_documents()` 重新调用。 + - `run_task_lifecycle()`:`artifact_ids` 缺 `prd`/`benchmark` 时不再 `KeyError`(§7 第二行的回归测试)。 +- `src/test_task_execution.py`: + - 改写 `ResumeFromPlanningTest`(现在的 `status="planning_brd_prd_sdd"` 场景,`_REQUIRED_ARTIFACTS["coder"]` 收窄后这个测试原本"必须写全部 4 个 doc artifact"的注释/setup 要跟着简化)。 + - 新增 `ResumeFromReviewingTest`(`status="reviewing_planner_output"`,只跳到 Coder,不重新握手 planner/planner_reviewer)。 + - 新增 `ResumeFromPlanningWithZeroArtifactsTest`——**这是 P0 bug 的直接回归测试**:`status="planning_brd_prd_sdd"`、不写任何 doc artifact,断言 resume 不抛异常,而是走到重新 `plan_documents()` 的路径。 + - `_RESUMABLE_STATUSES` 新增值的 `ResumeUnresumableStatusTest` 边界检查照旧模式补一条。 +- `src/test_schema.py`:新增 `MigrateAddReviewingPlannerOutputStatusTest`/`MigrateAddPlannerReviewVerdictEventTypeTest`,结构照抄 `MigrateAddWorkGraphLoopLimitConfigTest`/等价的 `loop_back_triggered` 迁移测试(幂等性 + 旧数据库升级正确性)。 +- `src/test_scheduler.py`:`_BLOCKED_REASON_FOR_EXCEPTION` 新增映射的确认测试(照抄现有几行同类断言)。 + +### 10.2 验收标准(对应需求文档 Phase 1 的验收) + +1. **真实复现场景回归**:构造一个"benchmark 输出叙述文字"的假 mock 响应(复用 `validator-test-script-narrative-output-issue.md` 记录的原文),断言 `planner_reviewer` 给出 `FAIL`/`INCONCLUSIVE`,且 Coder(`implement_code`)**没有被调用**。 +2. **P0 bug 回归**:`status="planning_brd_prd_sdd"` + 零 doc artifact 的任务能被 resume,不抛 `TaskExecutionError`。 +3. **fresh-run KeyError 回归**:`plan_documents()` 返回的 `artifact_ids` 只有 `sdd`(没有 brd/prd/benchmark)时,`run_task_lifecycle()` 不崩。 +4. 全量回归:`python3 -m unittest discover -s src` + `discover -s supervisor -p "test_*.py"` 保持全绿。 + +--- + +## 11. 非目标 / 已知简化(Phase 1 范围内,明确不做) + +1. **复核失败的重试不是精确 `loop_back`**——`_review_planner_output_with_retries()` 每次拒绝都是完整重新跑一次 `plan_documents()`(全量重新生成 BRD/PRD/SDD/Benchmark),不是 Phase 2 要做的"精确跳回某个具体节点"。这是需求文档 Phase 1 明确允许的粗粒度过渡方案。 +2. **复核重试次数不持久化跨进程重启**——`_PLANNER_REVIEW_MAX_ATTEMPTS` 循环的 `attempt` 计数只在一次函数调用的 Python 栈里,进程崩溃后 resume 会让这个计数器归零重新开始(不会死循环,因为 `_review_planner_output_with_retries()` 每次被调用都有自己独立的 3 次上限,只是"总共实际尝试次数"跨多次 resume 可能超过 3)。`execution_event(event_type='planner_review_verdict')` 已经把每一轮的真实结果记下来,为将来需要跨进程持久化计数时提供数据基础,但 Phase 1 不读它做判断。 +3. **不接入 `org_level_loop.py`/`bug_record.py` 的根因路由**——复核失败走的是 `PlannerReviewLoopLimitReachedError` → `blocked_pending_human`,不产生 `bug_record` 行,不经过 `root_cause_trace` 的三视角陪审团判断。这套机制要不要接进来是 Phase 2(FR-4,节点级验证 skill 统一路由)的范围。 +4. **`looks_like_narration()` 是软信号,不是硬拦截**——见 §5.1 的 `ponytail` 注释,误判成本导致选择不做机械硬拦截,Phase 2 关联的结构性修复(`validator-test-script-narrative-output-issue.md` §8)才是真正的上游方案。 +5. **不改 Coder/Validator 的自规划**——`plan_and_review_documents()`/`review_planner_output()` 都是 flat 调用,不是 Work Graph 节点,不涉及 `plan_work_graph()` 的通用化(Phase 2 范围)。 + +--- + +## 12. 实施顺序建议 + +1. `schema.py` 两个迁移函数 + `backend_server.py` 接线 + `test_schema.py` 迁移测试(独立、无依赖,先落地)。 +2. `roles/planner_reviewer/role.json` + `agents/planner_reviewer/SOUL.md`/`AGENTS.md` + `skills/planner-output-review/SKILL.md`(角色发现是自动的,但没有这些文件真实调用会失败)。 +3. `org_node_lifecycle.py` 的 `_PRE_STATE`/`_POST_STATE`/`_REQUIRED_ARTIFACTS` 改动 + `test_org_node_lifecycle.py`。 +4. `e2e_pipeline.py` 新增函数(§5)+ `run_task_lifecycle()` 改动 + `test_e2e_pipeline.py`(这一步体量最大,建议拆两次提交:先加新函数+测试,再改 `run_task_lifecycle()` 接入)。 +5. `task_execution.py` 改动(§6)+ `test_task_execution.py`(依赖第 4 步的新函数已存在)。 +6. `scheduler.py` 的 `_BLOCKED_REASON_FOR_EXCEPTION` 一行 + `test_scheduler.py`。 +7. 全量回归 `discover -s src` + `discover -s supervisor`,确认零回归。 +8. (可选,需要真实 API 额度,用户决定是否现在跑)`RUN_REAL_AGENT_E2E=1` 针对新流程的一次真实端到端验证。 diff --git a/docs/todo/task-execution-resume-assumes-planner-artifacts-exist.md b/docs/todo/task-execution-resume-assumes-planner-artifacts-exist.md index e658eb1..32398d7 100644 --- a/docs/todo/task-execution-resume-assumes-planner-artifacts-exist.md +++ b/docs/todo/task-execution-resume-assumes-planner-artifacts-exist.md @@ -2,7 +2,7 @@ **日期**: 2026-08-08 **性质**: 问题记录(真实复现,根因已定位,未修复)。发现于 `docs/done/work-graph-self-planning-and-loop-back-requirements.md`(Planner 自规划)上线后的第一次真实端到端验证。 -**状态**: 待修复,未立项。**2026-08-08 更新**:已折入 `docs/todo/org-graph-unification-and-universal-self-planning-requirements.md` 的 Phase 1(FR-1)——插入"Planner 产出复核"新阶段本来就要重写这段 `run_or_resume_task_lifecycle()` 分支逻辑,这个 bug 跟那次改动是同一段代码路径,不再单独排期,随 Phase 1 一并修。 +**状态**: 待修复,未立项。**2026-08-08 更新**:已折入 `docs/todo/org-graph-unification-and-universal-self-planning-requirements.md` 的 Phase 1(FR-1)——插入"Planner 产出复核"新阶段本来就要重写这段 `run_or_resume_task_lifecycle()` 分支逻辑,这个 bug 跟那次改动是同一段代码路径,不再单独排期,随 Phase 1 一并修。**详细设计已发布**:`docs/todo/org-graph-unification-phase1-review-gate-design.md` §6-7——修复方式是把 sdd/prd/benchmark 的存在性探测下沉到新增的 `_review_planner_output_with_retries()` 内部(有就用,没有就重新规划),不再在 resume 分支顶部无条件硬读;同一次调研还发现 fresh-run 路径(`run_task_lifecycle()`)有同类但更早触发的 `KeyError`,一并修复。 ## 1. 现象(真实复现) diff --git a/docs/todo/work-graph-node-execution-deblackbox-requirements.md b/docs/todo/work-graph-node-execution-deblackbox-requirements.md new file mode 100644 index 0000000..2afe815 --- /dev/null +++ b/docs/todo/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/todo/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,不是必须但值得。