# Phase 1 — Planner 产出复核阶段 + resume/fresh-run artifact 假设修复——详细设计(SDD) **日期**: 2026-08-08 **性质**: 详细设计,对应 `docs/done/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`(新角色的模板)。 **状态**: ✅ 已实现(commit `ea19c7d`,2026-08-08)。实现与本设计逐条吻合(新角色 `planner_reviewer`、`reviewing_planner_output` 状态值、`_REQUIRED_ARTIFACTS["coder"]` 收窄到 `(sdd,)`、P0 resume bug 与 fresh-run KeyError 一并修复,均与本文档 §3.1/§4.1/§7 一致);实现过程中额外发现并修复了一个本设计未预见的问题:`agent_profile` 表的 `migrated_from_profile` 列原是 `NOT NULL`,`planner_reviewer` 这种没有对应 Hermes 遗留 profile 的新角色一注册就违反约束,随本次提交一并放开。`python3 -m unittest discover -s src` 全绿(731 tests,2026-08-12 复核)。移入 `docs/done/` 前保留在 `docs/todo/` 是 CLAUDE.md 记录过的已知文档滞后,本次一并订正。 --- ## 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/done/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/done/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` 针对新流程的一次真实端到端验证。