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 相关的开放问题,本设计做出以下拍板(理由见对应小节):
- "复核 Validator"用新角色,不复用
validator——已通过AskUserQuestion与用户确认(2026-08-08),理由见 §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 约束扩展为:
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 变更
_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 新增:叙述文字启发式(复核阶段的辅助信号,非硬拦截)
# 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 本身不用改,直接复用):
_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() 硬失败调用),而是先探测存在性,"有就用、没有就重新规划",不再对"到底有没有"这件事打赌。
_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() 改动
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 变更
_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 的只读哲学(复核不应该有能力改任何文件):
{
"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)新增一行:
(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 的验收)
- 真实复现场景回归:构造一个"benchmark 输出叙述文字"的假 mock 响应(复用
validator-test-script-narrative-output-issue.md记录的原文),断言planner_reviewer给出FAIL/INCONCLUSIVE,且 Coder(implement_code)没有被调用。 - P0 bug 回归:
status="planning_brd_prd_sdd"+ 零 doc artifact 的任务能被 resume,不抛TaskExecutionError。 - fresh-run KeyError 回归:
plan_documents()返回的artifact_ids只有sdd(没有 brd/prd/benchmark)时,run_task_lifecycle()不崩。 - 全量回归:
python3 -m unittest discover -s src+discover -s supervisor -p "test_*.py"保持全绿。
11. 非目标 / 已知简化(Phase 1 范围内,明确不做)
- 复核失败的重试不是精确
loop_back——_review_planner_output_with_retries()每次拒绝都是完整重新跑一次plan_documents()(全量重新生成 BRD/PRD/SDD/Benchmark),不是 Phase 2 要做的"精确跳回某个具体节点"。这是需求文档 Phase 1 明确允许的粗粒度过渡方案。 - 复核重试次数不持久化跨进程重启——
_PLANNER_REVIEW_MAX_ATTEMPTS循环的attempt计数只在一次函数调用的 Python 栈里,进程崩溃后 resume 会让这个计数器归零重新开始(不会死循环,因为_review_planner_output_with_retries()每次被调用都有自己独立的 3 次上限,只是"总共实际尝试次数"跨多次 resume 可能超过 3)。execution_event(event_type='planner_review_verdict')已经把每一轮的真实结果记下来,为将来需要跨进程持久化计数时提供数据基础,但 Phase 1 不读它做判断。 - 不接入
org_level_loop.py/bug_record.py的根因路由——复核失败走的是PlannerReviewLoopLimitReachedError→blocked_pending_human,不产生bug_record行,不经过root_cause_trace的三视角陪审团判断。这套机制要不要接进来是 Phase 2(FR-4,节点级验证 skill 统一路由)的范围。 looks_like_narration()是软信号,不是硬拦截——见 §5.1 的ponytail注释,误判成本导致选择不做机械硬拦截,Phase 2 关联的结构性修复(validator-test-script-narrative-output-issue.md§8)才是真正的上游方案。- 不改 Coder/Validator 的自规划——
plan_and_review_documents()/review_planner_output()都是 flat 调用,不是 Work Graph 节点,不涉及plan_work_graph()的通用化(Phase 2 范围)。
12. 实施顺序建议
schema.py两个迁移函数 +backend_server.py接线 +test_schema.py迁移测试(独立、无依赖,先落地)。roles/planner_reviewer/role.json+agents/planner_reviewer/SOUL.md/AGENTS.md+skills/planner-output-review/SKILL.md(角色发现是自动的,但没有这些文件真实调用会失败)。org_node_lifecycle.py的_PRE_STATE/_POST_STATE/_REQUIRED_ARTIFACTS改动 +test_org_node_lifecycle.py。e2e_pipeline.py新增函数(§5)+run_task_lifecycle()改动 +test_e2e_pipeline.py(这一步体量最大,建议拆两次提交:先加新函数+测试,再改run_task_lifecycle()接入)。task_execution.py改动(§6)+test_task_execution.py(依赖第 4 步的新函数已存在)。scheduler.py的_BLOCKED_REASON_FOR_EXCEPTION一行 +test_scheduler.py。- 全量回归
discover -s src+discover -s supervisor,确认零回归。 - (可选,需要真实 API 额度,用户决定是否现在跑)
RUN_REAL_AGENT_E2E=1针对新流程的一次真实端到端验证。