Commit 04aa7971d1cab1256b97756859f3f556082231d5
1 parent
43c3f050
graphlemon: auto-commit uncommitted changes for 18a9005c-aba8-4e35-88a6-184de4b9…
…4843-main-checkout-drift
Showing
15 changed files
with
2633 additions
and
203 deletions
docs/todo/task-execution-resume-assumes-planner-artifacts-exist.md
0 → 100644
| 1 | +# `task_execution.py` 恢复逻辑假设 Planner 一定已产出 sdd/prd/benchmark artifact——问题记录 | ||
| 2 | + | ||
| 3 | +**日期**: 2026-08-08 | ||
| 4 | +**性质**: 问题记录(真实复现,根因已定位,未修复)。发现于 `docs/todo/work-graph-self-planning-and-loop-back-requirements.md`(Planner 自规划)上线后的第一次真实端到端验证。 | ||
| 5 | +**状态**: 待修复,未立项。 | ||
| 6 | + | ||
| 7 | +## 1. 现象(真实复现) | ||
| 8 | + | ||
| 9 | +任务 `2084590a`(目标:修复 `work_node_run.py` 的 `execution_log_file` UNIQUE 约束 bug): | ||
| 10 | + | ||
| 11 | +1. 第一次真实调用:Planner 自规划出 `sdd → benchmark → test_script` 三节点图(没有 brd/prd——goal 是个小 bug 修复,Planner 判断不需要),`sdd` 节点的 `build_prompt()` 因为 `_DOC_PROMPTS["sdd"]` 硬编码的 `{prd_content}` 占位符找不到值而抛 `KeyError('prd_content')`(已修复,见 `e2e_pipeline.py::_MissingContentDefault`)。此时 `sdd` 节点本身从未成功过,`artifact` 表里这个 task 一行都没有。 | ||
| 12 | +2. 通过 `/route` 的 `manual_resume` 恢复:`task.status` 被设置回 `planning_brd_prd_sdd`。 | ||
| 13 | +3. 重新执行时走的是 `task_execution.py::run_or_resume_task_lifecycle()` 的"从 `planning_brd_prd_sdd` 恢复"分支,而不是从头重跑 `plan_documents()`——这个分支无条件调用 `_artifact_content(conn, ..., "sdd")`/`"prd"`/`"benchmark"`,读不到就直接 `TaskExecutionError`。因为这个 task 的 Planner 图从未成功过一个节点,三个 artifact 全部不存在,报错为 `"task ... has no sdd artifact to resume from"`,任务再次卡住。 | ||
| 14 | + | ||
| 15 | +## 2. 根因 | ||
| 16 | + | ||
| 17 | +`org_node_lifecycle.trigger_org_node_full(conn, "planner", ...)` 在 `run_task_lifecycle()` 里**先于** `plan_documents()` 执行,并把 `task.status` 设成 `_POST_STATE["planner"] = "planning_brd_prd_sdd"`(`org_node_lifecycle.py:32`)——这个状态转移只代表"Planner 的 ready 确认调用成功了",不代表"Planner 的真实规划(`plan_documents()`)已经产出了任何东西"。 | ||
| 18 | + | ||
| 19 | +`task_execution.py::run_or_resume_task_lifecycle()` 里 `planning_brd_prd_sdd` 分支(`task_execution.py:126-129`)的注释写的是"Planner done, Coder not yet triggered"——这个假设在 Work Graph 自规划(`docs/todo/work-graph-self-planning-and-loop-back-requirements.md`)上线前基本成立:固定链的第一个节点是 brd,几乎不可能在第一个节点就失败到零产出。自规划之后这个假设不再可靠——自规划出的图可能把一个更容易失败的节点(比如需要更复杂 judgment 的 sdd)排在第一位,如果它在第一次尝试就彻底失败(耗尽 loop_back/retry 上限,或者像这次一样是一个跟自规划本身相关的新 bug),Planner 的 `work_graph_run` 会整个失败,不会有任何 artifact 落盘,但 `task.status` 已经先一步被置成了 `planning_brd_prd_sdd`。 | ||
| 20 | + | ||
| 21 | +`test_dataset_content`/`test_script_content` 已经用 `_artifact_content_or_default()`(读不到就给默认值,不硬失败)处理过同一类问题(2026-08-06,那次是"这两个 artifact 类型本来就允许不存在"),但 `sdd`/`prd`/`benchmark` 三个仍然用会硬失败的 `_artifact_content()`——这个区分在固定链时代是对的(sdd/prd/benchmark 曾经是保证存在的),自规划之后不再成立。 | ||
| 22 | + | ||
| 23 | +## 3. 跟已实现的两个相关修复的区别 | ||
| 24 | + | ||
| 25 | +- 不是 `work-graph-partial-artifact-persistence-requirements.md` 那次修复能覆盖的场景——那次解决的是"图跑到一半、后面节点失败,前面成功节点的产出要保留",这次是"图的第一个节点就彻底失败,压根没有'前面成功节点'可保留",`_write_completed_node_artifacts` 在这种情况下正确地什么都不落盘(因为真的什么都没成功),不是它的 bug。 | ||
| 26 | +- 不是这次 session 里刚修的 `_MissingContentDefault`(`e2e_pipeline.py::_planner_prompt_builder`)能覆盖的——那个修的是"build_prompt 拼提示词时崩溃",这次是崩溃之后**恢复逻辑**本身的假设过时了,是下游的另一层问题。 | ||
| 27 | + | ||
| 28 | +## 4. 可能的修复方向(未验证,仅供后续参考) | ||
| 29 | + | ||
| 30 | +在 `run_or_resume_task_lifecycle()` 的 `planning_brd_prd_sdd` 分支里,先检查这个 task 是否**真的有任何 doc 类 artifact**(`_latest_artifact_ids(conn, task_id, _DOC_ARTIFACT_TYPES)` 是否为空): | ||
| 31 | + | ||
| 32 | +- 如果有(哪怕只有部分,比如这次自规划场景下可能只有 sdd 没有 prd/benchmark),按现有逻辑继续,但 `prd`/`benchmark` 也应该跟 `test_dataset`/`test_script` 一样改用 `_artifact_content_or_default`,不再假设它们一定存在(自规划场景下 prd/benchmark 本来就可能被 Planner 判断为不需要,不是"缺失的异常情况")。 | ||
| 33 | +- 如果一个都没有(这次真实复现的情况——Planner 的图从第一个节点就彻底失败,什么都没留下),说明"从这个状态恢复"这个假设本身不成立,应该等价于从 `queued` 重新完整跑一次 `run_task_lifecycle()`(即重新调用 `plan_documents()`),而不是尝试读取不存在的 artifact。 | ||
| 34 | + | ||
| 35 | +## 5. 临时规避(已用于解决这次真实卡住的任务) | ||
| 36 | + | ||
| 37 | +直接把 `task.status` 手动改回 `queued`(`UPDATE task SET status='queued', blocked_reason=NULL WHERE task_id=...`),让下一次执行走 `run_task_lifecycle()` 的"queued"分支、重新完整调用一次 `plan_documents()`——不是通用修复,只是这一次真实卡住的任务的人工解法。 |
docs/todo/validator-test-script-narrative-output-issue.md
| @@ -2,7 +2,7 @@ | @@ -2,7 +2,7 @@ | ||
| 2 | 2 | ||
| 3 | ****: 2026-08-07 | 3 | ****: 2026-08-07 |
| 4 | **扯捶**: 憸扇敶 + 敺,銝撌脩&霈斗瘙﹝嚗鈭敶隞遢遢撌脩雿誨 bug 撟嗡耨憭,餈遢"閫鞊",蝖桀 | 4 | **扯捶**: 憸扇敶 + 敺,銝撌脩&霈斗瘙﹝嚗鈭敶隞遢遢撌脩雿誨 bug 撟嗡耨憭,餈遢"閫鞊",蝖桀 |
| 5 | -****: 歇摰洵 6 蝻圾撌脣嚗洵 7 _NARRATION_RETRY_HINT`嚗活銝曆遙 test_script 餈葵蝻圾鈭颲蝏找耨憭洵銝甈∠膩嚗憸**2026-08-07 嚗洵鈭蔭嚗 Validator 憸911861fe` 餈葵蝥臬垢隞餃 Planner 撌梁 `n_test_script` 銝鈭璅∪敞霈 3 銝芯遙6 甈∪颲 test_script 蝐餉嚗6 甈∪憭嚗100%嚗赤頝 Planner Validator 銝斤遣霈格漣嚗** **2026-08-07 嚗洵銝蔭嚗嚗 08-06 "Benchmark 膩鈭"嚗ommit `07a0a27`嚗掩憭est_script 蝐餉敶撌脩內霂璇唳蝵嚗 6/6 憭"self-hosting 霂情"餈葵瘜 test_script 內霂頨怨"極蝝W漣捆"嚗芋蝝W撠曇憭拍"瘙/鈭銋"滲璅∪elf-hosting 憭批嚗悟膩霂餉絲蝎曄&嚗蛹蝝W笆鞊∪停 graphlemon 撌梧”蝎曄&撖孵敹隞嗚RD/PRD/SDD/Benchmark/test_dataset 餈葵銝閬蝝Y滲蝏嚗 08-06 憭嚗迨** | 5 | +****: 歇摰洵 6 蝑2026-08-07嚗誧蝏剖內霂/‘銝**蝳 test_script/validator_test_script **嚗lanner `work_graph_plan._PROMPT_TEMPLATE` 銝葵嚗alidator `e2e_pipeline._VALIDATOR_MANIFEST` 隞 3 蝻拇 2 嚗賒憒閬霈曇恣嚗洵 8 找耨憭"蝝""蝏"舅甈∠蝡嚗_NARRATION_RETRY_HINT`嚗洵 7 誨 `_test_script_verify_fn` ,敶頝臬◤閫血誑餈葵,餈葵蝻圾靘**2026-08-08 嚗洵蔭嚗閫 Work Graph 瘙漱蝑**嚗docs/todo/work-graph-self-planning-and-loop-back-requirements.md`嚗lanner 閫 + 憭梯揖楝瘙瑪est_script 隡雿蛹撘蝏 Planner 閫&摰**銝﹝找耨憭**嚗arrative-output 憸迨嚗撌脩銝◤﹝霈啣洵 6 迤蝏找耨憭**銝隞賢鈭"蝏 Coder agent test_script 璅∪"﹝**韏瑁圾嚗﹝蝏抒賒頝**2026-08-07 嚗洵鈭蔭嚗 Validator 憸911861fe` 餈葵蝥臬垢隞餃 Planner 撌梁 `n_test_script` 銝鈭璅∪敞霈 3 銝芯遙6 甈∪颲 test_script 蝐餉嚗6 甈∪憭嚗100%嚗赤頝 Planner Validator 銝斤遣霈格漣嚗** **2026-08-07 嚗洵銝蔭嚗嚗 08-06 "Benchmark 膩鈭"嚗ommit `07a0a27`嚗掩憭est_script 蝐餉敶撌脩內霂璇唳蝵嚗 6/6 憭"self-hosting 霂情"餈葵瘜 test_script 內霂頨怨"極蝝W漣捆"嚗芋蝝W撠曇憭拍"瘙/鈭銋"滲璅∪elf-hosting 憭批嚗悟膩霂餉絲蝎曄&嚗蛹蝝W笆鞊∪停 graphlemon 撌梧”蝎曄&撖孵敹隞嗚RD/PRD/SDD/Benchmark/test_dataset 餈葵銝閬蝝Y滲蝏嚗 08-06 憭嚗迨** |
| 6 | 6 | ||
| 7 | ## 1. 鞊 | 7 | ## 1. 鞊 |
| 8 | 8 | ||
| @@ -79,3 +79,17 @@ self-hosting 嚗target_folder_path` graphlemon 撌梧餈葵 | @@ -79,3 +79,17 @@ self-hosting 嚗target_folder_path` graphlemon 撌梧餈葵 | ||
| 79 | 霈曇恣銝隞嗅嚗"If your previous output was..."嚗閮,餈撖寧迤祗瘜秤嚗膩,誨 typo 撖潸蝻仃韐伐漣秤撖潭扳,銝銝芣拿內_test_script_verify_fn` Planner/Validator 銝芸嚗script_artifact_type` 舅颲),銝憭舅颲寥 reflect prompt feedback 捆,銝蜓內霂 `check_script_compiles()` 璇唳餉頨怒 | 79 | 霈曇恣銝隞嗅嚗"If your previous output was..."嚗閮,餈撖寧迤祗瘜秤嚗膩,誨 typo 撖潸蝻仃韐伐漣秤撖潭扳,銝銝芣拿內_test_script_verify_fn` Planner/Validator 銝芸嚗script_artifact_type` 舅颲),銝憭舅颲寥 reflect prompt feedback 捆,銝蜓內霂 `check_script_compiles()` 璇唳餉頨怒 |
| 80 | 80 | ||
| 81 | 賒憒葵蝻圾隞活銝曆遙活憭),銝甇亥砲洵 6.2 "蝝W撠曆"敶餃獢閬 test_script 內霂頨恍,"蝝U漣""蝏◆蝥臭誨"餈舅銝芷畾菜&,撠曇蕭蝥 | 81 | 賒憒葵蝻圾隞活銝曆遙活憭),銝甇亥砲洵 6.2 "蝝W撠曆"敶餃獢閬 test_script 內霂頨恍,"蝝U漣""蝏◆蝥臭誨"餈舅銝芷畾菜&,撠曇蕭蝥 |
| 82 | + | ||
| 83 | +## 8. 撌脣嚗2026-08-07嚗洵鈭活蝑蝳 test_script/validator_test_script | ||
| 84 | + | ||
| 85 | +蝑蝏抒賒內霂‘銝洵 7 圾撌脩扳銝活&霈歹,銝舅銝芾隞,賒瘙霈曇恣," goal 隞餃摰葵"銝銝芣憭抒憸lanner 撌梁﹝嚗work_graph_plan._PROMPT_TEMPLATE`嚗餈撅 LLM 閫,雿活靚內霂′蝻"敹◆銝 brd/prd/sdd/benchmark/test_dataset/test_script 銝銝芾"銝銝芸摰虜2026-08-06 挽霈∪摰 `docs/done/dynamic-work-graph-design-skill-design.md 禮3`嚗,銝 LLM goal 摰alidator 撌梁嚗_VALIDATOR_MANIFEST`嚗敶餃,餈"餈撅 LLM 閫"瘝⊥,蝖祉 Python 摮迤"LLM 撌勗摰葵"歇蝏鈭 `_GENERIC_PROMPT_TEMPLATE`嚗 Coder/Validator 隞遙),雿瓷餈舅﹝銝閬 Planner/Validator 撌梁﹝銝,銝銝芣憭抒閬蝑賒,銝甈⊥ | ||
| 86 | + | ||
| 87 | +**雿**嚗 | ||
| 88 | +- `src/work_graph_plan.py::_PROMPT_TEMPLATE`嚗 test_script 嚗閬 brd/prd/sdd/benchmark/test_dataset 5 銝迎 | ||
| 89 | +- `src/work_graph_plan.py::_CANONICAL_DOC_CHAIN`嚗 6 憬 5 ,郊銝蝏 test_script 瘜典"憸 benchmark"畾萎誨_normalize_graph_manifest` | ||
| 90 | +- `src/e2e_pipeline.py::_VALIDATOR_MANIFEST`嚗 3 嚗alidator_benchmarkalidator_test_datasetalidator_test_script嚗憬 2 | ||
| 91 | +- `validate_implementation_with_own_graph()`嚗outputs["validator_test_script"]` 挪 `in` ,銝 `script_execution` 韏"N/A: 撌脩",verdict 摰 LLM 撌梁摰蝖祇蝳 | ||
| 92 | +- 銝虜瘨晶run_task_lifecycle`/`task_execution.py` 粉 test_script artifact 捆 Coder 餉撠勗歇蝏 `"test_script" in artifact_ids`/`_artifact_content_or_default` 捆 2026-08-06 test_script 瑪,銝箔摰"餈葵銋隞餃"敺⊥找誨,餈活銝閬1餈舅憭 | ||
| 93 | +- `_DOC_PROMPTS["test_script"]`/`_VALIDATOR_DOC_PROMPTS["validator_test_script"]`/`check_script_compiles()`/`_test_script_verify_fn`/`_NARRATION_RETRY_HINT` 蝑虜隞靽,銝◤閫血誑,閬舅憭摰 | ||
| 94 | + | ||
| 95 | +**瘚**嚗src/test_work_graph_plan.py`/`src/test_e2e_pipeline.py` 靘 6/3 摰靘摨嚗銝"仃韐亥銝敞鈭批"嚗artial-artifact-persistence 靘"仃韐亙撩 FAIL"嚗ard-gate 靘撘銝銝芯葩銵乩 test_script/validator_test_script anifest 瘚蛹餈舅頨思誨,暺恕銝◤閫,銝砲蛹暺恕蝳撠勗仃python3 -m unittest discover -s src`嚗643 tests嚗+ `discover -s supervisor -p "test_*.py"`嚗126 tests嚗蝏踴 |
docs/todo/work-graph-self-planning-and-loop-back-design.md
0 → 100644
| 1 | +# Planner 动态自规划 Work Graph(含节点判断与失败回路)——详细设计(SDD) | ||
| 2 | + | ||
| 3 | +**日期**: 2026-08-08 | ||
| 4 | +**性质**: 详细设计,对应需求文档 `docs/todo/work-graph-self-planning-and-loop-back-requirements.md`(已确认)。本设计给出可直接实施的 schema/接口/算法/测试方案,逐条落实需求文档「8. 已确认的决策」。 | ||
| 5 | +**依赖代码**:`src/work_graph_orchestrate.py`(`run_graph`/`topological_layers`/`_run_one_node`/`_verify_and_maybe_regenerate`/`_ensure_node_row`/`_LockedConn`)、`src/work_graph_plan.py`(`_PROMPT_TEMPLATE`/`_normalize_graph_manifest`/`GRAPH_MANIFEST_SCHEMA`)、`src/e2e_pipeline.py`(`plan_documents`/`check_script_compiles`/`_check_prd_consistency`)、`src/settings_config.py`(`get_/upsert_concurrency_config` 先例)、`src/schema.py`(`migrate_expand_artifact_types` 先例)。 | ||
| 6 | +**状态**: ✅ 已实现(2026-08-08)。实施顺序按第 11 节执行,`python3 -m unittest discover -s src`(692 tests)+ `discover -s supervisor -p "test_*.py"`(126 tests)全绿。实现过程中用真实测试发现并修正了本设计原文的几处问题(设计文档本身未回填,实现是最终准确来源): | ||
| 7 | +- §2 最初的 `on_fail.target_node_id` 字段设计,在实现前按用户反馈改成了从 `on_failure` 边读取(已反映在正文),但 §4.0(`topological_layers()` 必须过滤 `on_failure` 边)和 §8.3(`_validate_loop_back_targets` 改成校验边而不是字段)是**开始写代码之后**才发现遗漏并现场修的两处,不是设计阶段就想全的。 | ||
| 8 | +- `_next_exec_node_id`(原设计称 `_count_prior_attempts`,按行数计数)在真实测试中暴露两个问题:① 按 DB 行数计数会把"resume 一个从未失败过的节点"误判成需要新开 attempt 后缀;② 只统计"自己是不是直接 loop_back 目标"会漏掉"作为下游节点被牵连重跑"的情况。最终实现改成按 `execution_event` 里真实的 loop_back 触发次数、且用 `_transitive_dependents` 判断是否被牵连来计数,两个问题都已修复并有专门测试覆盖。 | ||
| 9 | +- `_run_one_node` 最初设计里"先统一调用 verify_fn 判断、再决定走哪条分支"的写法,会导致legacy(无 judgment 声明)节点的 verify_fn 被调用两次(`_verify_and_maybe_regenerate` 内部自己还会再调一次)——测试跑出来直接暴露,改成先判断 `has_own_judgment` 决定路径,只有新路径才提前调用 verify_fn。 | ||
| 10 | +- `work_graph_plan.py::_PROMPT_TEMPLATE` 最初设计的"先 `.format()` 填 catalog_json、留 `{goal_text}` 给以后再填一次"写法有真实的转义 bug(catalog JSON 自带的花括号会被第二次 `.format()` 误当成占位符),实现时改成用字符串拼接嵌入 catalog、最终用 `.replace("{goal_text}", goal_text)` 而不是 `.format()` 填充目标文本,彻底绕开转义问题。 | ||
| 11 | +- 顺带在真实测试中发现一个**不在本设计范围内、`work_node_run.py` 自己的既有缺口**:`run_work_node()` 每次调用都无条件往 `execution_log_file` 插入一行新记录,没有检查该 `node_exec_id` 是否已有记录——如果一个节点在 `execute_work_graph()` 真正返回之前就进程崩溃(不是"干净的 done",也不是全新节点),下次对同一个 `node_exec_id` 调用 `run_work_node()` 会撞 `execution_log_file.node_exec_id` 的 UNIQUE 约束报错。这个缺口在 loop_back 机制之前就存在(任何节点崩溃在这个时间窗口都会触发,不是 loop_back 特有的),只是这次因为测试真实构造了"进程崩溃后重启"的场景才被发现——不在本次范围内修复,记录在案供后续单独立项。 | ||
| 12 | + | ||
| 13 | +--- | ||
| 14 | + | ||
| 15 | +## 1. 设计概览 | ||
| 16 | + | ||
| 17 | +现状(`work_graph_orchestrate.py`,见其自身 docstring 与代码):`run_graph()` 把 `graph_manifest` 用 `topological_layers()` 分层,层间屏障、层内(G,2026-08-06)用 `ThreadPoolExecutor` 并发;每个节点的判断逻辑完全靠调用方传入的单一 `verify_fn(node, output) -> (ok, feedback)` 回调,失败处理固定死在 `_verify_and_maybe_regenerate()` 里——原地重新生成同一节点恰好一次,还失败就 `ConsistencyCheckFailedError`,没有"跳回更早节点"这条路径。 | ||
| 18 | + | ||
| 19 | +改造后新增两条能力,**都以 `graph_manifest` 节点自身携带的声明式字段驱动,不再要求调用方为每种 artifact_type 手写 Python 分支**: | ||
| 20 | + | ||
| 21 | +1. **`judgment`**:节点自己说"怎么判断我的输出对不对"(机械检查 or 触发一次额外 LLM 判断)。 | ||
| 22 | +2. **`on_fail`**:节点自己说"判断失败了怎么办"(原地重试 / 跳回更早节点重新执行 / 直接判失败)。 | ||
| 23 | + | ||
| 24 | +**不改变的部分**(刻意保持稳定): | ||
| 25 | +- 每个节点仍然是"渲染成一个单节点 workflow 脚本 + 独立 `claude -p` 子进程调用"(`_make_run_fn`/`_render_node_script` 不变)。 | ||
| 26 | +- G(并行执行)的层内并发机制、`_LockedConn`、`WorkGraphLayerError` 完全不变——回路是"层与层之间的控制流",跟"层内节点并发执行"是正交的两层机制,互不冲突(详见第 4 节)。 | ||
| 27 | +- 不声明 `judgment`/`on_fail` 的现有 `graph_manifest`(Coder 的合成单节点图)逐字节行为不变(FR-A8,第 8 节有完整核对表)。 | ||
| 28 | +- `run_work_node()`/`work_node_run.py` 的断点续跑状态机完全不变,回路复用它现成的"新 node_id → 新行 → 真实重跑"机制(第 5 节),不需要改这一层。 | ||
| 29 | + | ||
| 30 | +## 2. `graph_manifest` 节点 schema 扩展 | ||
| 31 | + | ||
| 32 | +```jsonc | ||
| 33 | +{ | ||
| 34 | + "node_id": "benchmark", | ||
| 35 | + "artifact_type": "benchmark", // 沿用现状;_GENERIC_PROMPT_TEMPLATE 路径用 task_prompt 代替,同样适用 | ||
| 36 | + "depends_on": ["sdd"], | ||
| 37 | + | ||
| 38 | + // 新增,可选。缺省等价于 {"kind": "none"} —— 不做任何自动判断, | ||
| 39 | + // 节点输出直接被接受(如果调用方仍然传了外部 verify_fn,外部的继续生效,见第 3 节)。 | ||
| 40 | + "judgment": { | ||
| 41 | + "kind": "mechanical", // "mechanical" | "llm" | "none" | ||
| 42 | + "check_ref": "check_script_compiles" // 仅 kind="mechanical" 时必填,见第 3.1 节的白名单机制 | ||
| 43 | + // kind="llm" 时改用: | ||
| 44 | + // "judge_prompt": "Review the following content for ...: {content}" | ||
| 45 | + // —— 由规划者(Planner 自己,或人工在固定链里手写)自己撰写, | ||
| 46 | + // self-contained,不依赖调用方额外拼接上下文(同 _GENERIC_PROMPT_TEMPLATE | ||
| 47 | + // 的 task_prompt 设计原则)。 | ||
| 48 | + }, | ||
| 49 | + | ||
| 50 | + // 新增,可选。缺省等价于 {"action": "hard_fail"} 当没有 judgment 时; | ||
| 51 | + // 当声明了 judgment 但没声明 on_fail 时,缺省等价于 {"action": "retry_self", "max_attempts": 1} | ||
| 52 | + // ——即完全复刻现状 _verify_and_maybe_regenerate() 的行为(原地重试恰好一次)。 | ||
| 53 | + "on_fail": { | ||
| 54 | + "action": "loop_back", // "retry_self" | "loop_back" | "hard_fail" | ||
| 55 | + "max_attempts": 3 // 可选,覆盖 FR-A5 配置表给出的默认值 | ||
| 56 | + // 注意:没有 target_node_id 字段——action="loop_back" 的目标节点不是 | ||
| 57 | + // node 自己声明的一个独立字段,而是从 graph_manifest 的 edges 里读出来 | ||
| 58 | + // 的(见下)。retry_self/hard_fail 不需要任何 edge,target 就是自己/无。 | ||
| 59 | + } | ||
| 60 | +} | ||
| 61 | +``` | ||
| 62 | + | ||
| 63 | +**2026-08-08 修订(用户反馈)**:失败路径的目标节点不应该是节点自己声明的、跟 `edges` 脱节的一个独立字段——"下一个节点是谁(成功路径)、失败了回到哪个节点(失败路径),都应该在规划这个节点的时候,用边(edge)定义好",跟成功路径的 `depends_on`/`edges` 是同一套结构,不是节点身上另开的一个影子字段。所以 `on_fail.loop_back` 的目标改成**从一条新的 `edge_type: "on_failure"` 边读出来**,不再单独存 `target_node_id`: | ||
| 64 | + | ||
| 65 | +```jsonc | ||
| 66 | +// graph_manifest 顶层 edges 数组: | ||
| 67 | +{"from": "sdd", "to": "benchmark", "edge_type": "needs_judgment"}, // 成功路径:sdd 产出喂给 benchmark(既有语义,depends_on 的另一种表达) | ||
| 68 | +{"from": "benchmark", "to": "sdd", "edge_type": "on_failure"} // 失败路径:benchmark 判断失败时回到 sdd 重新执行 | ||
| 69 | +``` | ||
| 70 | + | ||
| 71 | +即:一个节点的"下一步去哪"完全由它参与的 `edges` 决定——`needs_judgment`/`zero_token` 两种既有类型继续表达"成功之后数据往哪流",新增的 `on_failure` 表达"判断失败之后控制流跳去哪"。节点自己的 `on_fail.action` 只声明"失败了要不要 loop_back"这个决策本身,不重复存目标是谁,避免两处数据不一致的风险(比如 `on_fail.target_node_id` 和实际 `edges` 没对上)。`GRAPH_MANIFEST_SCHEMA` 的 `edge_type` 枚举(`work_graph_plan.py:60`)加上 `"on_failure"`。 | ||
| 72 | + | ||
| 73 | +`GRAPH_MANIFEST_SCHEMA`(`work_graph_plan.py:33`)同步补上 `judgment`/`on_fail` 的形状 + `on_failure` edge_type(文档性质,跟现状一样不接入真正的 jsonschema 校验——`--json-schema` 和 `--agent` 不能同时用,见该文件已有说明)。 | ||
| 74 | + | ||
| 75 | +## 3. `run_graph()` 改造:judgment 解析 | ||
| 76 | + | ||
| 77 | +### 3.1 机械检查白名单——注入而不是硬编码 | ||
| 78 | + | ||
| 79 | +`work_graph_orchestrate.py` 现在对 `e2e_pipeline.py` **零依赖**(依赖方向是反过来的),不能直接 `import e2e_pipeline` 去拿 `check_script_compiles`(会成环)。机械检查改成调用方注入的白名单字典: | ||
| 80 | + | ||
| 81 | +```python | ||
| 82 | +def run_graph( | ||
| 83 | + conn, run_id, org_node, skill_id, target_folder_path, graph_manifest, | ||
| 84 | + build_prompt, input_schema, output_schema, timeout=600, needs_worktree=False, | ||
| 85 | + verify_fn=None, sleep=time.sleep, now=time.time, max_workers=_DEFAULT_MAX_WORKERS, | ||
| 86 | + mechanical_check_registry=None, # 新增:{check_ref: callable(content) -> (ok, detail)} | ||
| 87 | +): | ||
| 88 | +``` | ||
| 89 | + | ||
| 90 | +`e2e_pipeline.py` 的调用点传 `mechanical_check_registry={"check_script_compiles": check_script_compiles}`。`judgment.kind="mechanical"` 但 `check_ref` 不在注册表里,视为配置错误,直接在 `run_graph()` 一开始(还没跑任何节点前)校验并抛 `ValueError`——不要等跑到那个节点才发现拼错了名字,浪费一整条链的 token。**LLM 只能从白名单里选,不能发明新的机械检查**——这是有意的限制,不做"LLM 生成检查代码后拿去执行"这种沙箱执行的大工程。 | ||
| 91 | + | ||
| 92 | +### 3.2 LLM 判断——复用 `_check_prd_consistency` 的隔离机制,泛化提示词 | ||
| 93 | + | ||
| 94 | +```python | ||
| 95 | +def _llm_judgment_verify_fn(judgment, org_node, target_folder_path, timeout=180): | ||
| 96 | + """通用化的 _check_prd_consistency(e2e_pipeline.py):同样的 self-target | ||
| 97 | + worktree 隔离 + 独立 agent() 调用机制,但判断提示词来自 judgment['judge_prompt'] | ||
| 98 | + (节点自己声明的,不是硬编码的 PRD 专属提示词),返回 schema 也通用化成 | ||
| 99 | + {"ok": bool, "issues": [str, ...]}(PRD 检查原来的 "consistent"/"issues" 改名 | ||
| 100 | + 成更通用的 "ok",语义不变)。""" | ||
| 101 | + is_self = paths.is_self_target(target_folder_path) | ||
| 102 | + | ||
| 103 | + def verify_fn(node, output): | ||
| 104 | + node_exec_id = "judgment-" + uuid.uuid4().hex[:12] | ||
| 105 | + worktree_path = work_node_run.create_worktree(target_folder_path, node_exec_id) if is_self else None | ||
| 106 | + cwd = worktree_path or target_folder_path | ||
| 107 | + try: | ||
| 108 | + prompt = judgment["judge_prompt"].format(content=output["content"]) | ||
| 109 | + script_name = "graphlemon-judgment-" + uuid.uuid4().hex | ||
| 110 | + script_content = ( | ||
| 111 | + "export const meta = {{ name: {name}, description: 'graphlemon node judgment' }}\n\n" | ||
| 112 | + "const result = await agent({prompt}, {{ agentType: {org_node_json}, schema: {schema} }})\n\n" | ||
| 113 | + "return result\n" | ||
| 114 | + ).format( | ||
| 115 | + name=json.dumps(script_name), prompt=json.dumps(prompt), | ||
| 116 | + org_node_json=json.dumps(org_node), schema=json.dumps(_JUDGMENT_SCHEMA), | ||
| 117 | + ) | ||
| 118 | + work_graph_execute.write_workflow_script(cwd, script_name, script_content) | ||
| 119 | + result = work_graph_execute.execute_work_graph(cwd, script_name, {}, _JUDGMENT_SCHEMA, timeout=timeout) | ||
| 120 | + if worktree_path: | ||
| 121 | + work_node_run.ensure_worktree_committed(worktree_path, node_exec_id) | ||
| 122 | + work_node_run.ensure_worktree_committed(target_folder_path, node_exec_id + "-main-checkout-drift") | ||
| 123 | + work_node_run.merge_worktree(target_folder_path, node_exec_id) | ||
| 124 | + ok = _coerce_bool(result["ok"]) | ||
| 125 | + return ok, (result.get("issues") or []) | ||
| 126 | + finally: | ||
| 127 | + if worktree_path: | ||
| 128 | + work_node_run.remove_worktree(target_folder_path, worktree_path) | ||
| 129 | + | ||
| 130 | + return verify_fn | ||
| 131 | + | ||
| 132 | + | ||
| 133 | +_JUDGMENT_SCHEMA = { | ||
| 134 | + "type": "object", "required": ["ok", "issues"], | ||
| 135 | + "properties": {"ok": {"type": "boolean"}, "issues": {"type": "array", "items": {"type": "string"}}}, | ||
| 136 | +} | ||
| 137 | +``` | ||
| 138 | + | ||
| 139 | +`_coerce_bool`(原属 `e2e_pipeline.py`,见其 2026-08-02 的"LLM 把 boolean 回成字符串"教训)需要下沉成 `work_graph_orchestrate.py` 自己的小工具函数(或 `paths.py` 这类共享工具模块),两边都用同一份,不重复实现。`e2e_pipeline.py::_check_prd_consistency`/`_prd_consistency_verify_fn` 保留不动(FR-A8 向后兼容,现有固定链路径不强制迁移),新代码只是提供一个可选的、更通用的平行实现。 | ||
| 140 | + | ||
| 141 | +### 3.3 节点级 verify_fn 解析 | ||
| 142 | + | ||
| 143 | +```python | ||
| 144 | +def _resolve_node_verify_fn(node, external_verify_fn, org_node, target_folder_path, mechanical_check_registry): | ||
| 145 | + judgment = node.get("judgment") | ||
| 146 | + if not judgment or judgment.get("kind", "none") == "none": | ||
| 147 | + return external_verify_fn # 完全现状:没声明 judgment,谁传了外部 verify_fn 就用谁的(可能是 None) | ||
| 148 | + if judgment["kind"] == "mechanical": | ||
| 149 | + check_fn = (mechanical_check_registry or {}).get(judgment["check_ref"]) | ||
| 150 | + if check_fn is None: | ||
| 151 | + raise ValueError("unknown mechanical check_ref: {!r}".format(judgment["check_ref"])) | ||
| 152 | + def verify_fn(node, output, _check_fn=check_fn): | ||
| 153 | + ok, detail = _check_fn(output["content"]) | ||
| 154 | + return ok, ([detail] if detail else None) | ||
| 155 | + return verify_fn | ||
| 156 | + if judgment["kind"] == "llm": | ||
| 157 | + return _llm_judgment_verify_fn(judgment, org_node, target_folder_path) | ||
| 158 | + raise ValueError("unknown judgment.kind: {!r}".format(judgment["kind"])) | ||
| 159 | +``` | ||
| 160 | + | ||
| 161 | +`_run_one_node()` 改成对每个节点各自调用 `_resolve_node_verify_fn(...)` 拿到"这个节点该用哪个 verify_fn",而不是像现在这样整张图共用调用方传入的同一个 `verify_fn`。**向后兼容关键点**:现有调用方(`plan_documents`/`validate_implementation_with_own_graph`)继续传它们现在的 `_combined_planner_verify_fn(...)`/`_test_script_verify_fn(...)` 作为 `external_verify_fn`,只要节点没有 `judgment` 字段(现状 100% 如此),`_resolve_node_verify_fn` 原样返回这个外部函数,行为逐字节不变(FR-A8 验收标准 5)。 | ||
| 162 | + | ||
| 163 | +## 4. `on_fail` 执行:回路引擎 | ||
| 164 | + | ||
| 165 | +### 4.0 前提修复:`topological_layers()` 必须过滤 `on_failure` 边(FR-A7) | ||
| 166 | + | ||
| 167 | +**这是把 loop_back 目标从节点字段改成 `edges` 里的一条边(第 2 节 2026-08-08 修订)之后,必须同步修的一处,否则会引入真环**:现有 `topological_layers()`(`work_graph_orchestrate.py:196-200`)把 `graph_manifest["edges"]` 里**每一条边、不分 `edge_type`**都当成数据依赖合并进 `deps`: | ||
| 168 | + | ||
| 169 | +```python | ||
| 170 | +for edge in graph_manifest.get("edges", []): | ||
| 171 | + to_id, from_id = edge.get("to"), edge.get("from") | ||
| 172 | + if to_id in deps and from_id in nodes: | ||
| 173 | + deps[to_id].add(from_id) | ||
| 174 | +``` | ||
| 175 | + | ||
| 176 | +如果不改,一条 `{"from": "benchmark", "to": "sdd", "edge_type": "on_failure"}` 会被这段代码当成"`sdd` 依赖 `benchmark`"塞进 `deps["sdd"]`——而 `benchmark` 本来就依赖 `sdd`(正常数据依赖),两条方向相反的依赖凑在一起就是一个真环,`topological_layers()` 会在图规划阶段还没触发任何回路之前,就先被静态校验直接拒绝,FR-A7 的验收标准 4 会失败。 | ||
| 177 | + | ||
| 178 | +修复:合并 `deps` 时跳过 `edge_type == "on_failure"` 的边——**只有 `on_failure` 需要特殊处理**,`zero_token`/`needs_judgment` 两种既有类型继续按现状合并(它们表达的就是真实数据依赖): | ||
| 179 | + | ||
| 180 | +```python | ||
| 181 | +def topological_layers(graph_manifest): | ||
| 182 | + nodes = {n["node_id"]: n for n in graph_manifest["nodes"]} | ||
| 183 | + deps = {node_id: set(n.get("depends_on") or []) for node_id, n in nodes.items()} | ||
| 184 | + for edge in graph_manifest.get("edges", []): | ||
| 185 | + if edge.get("edge_type") == "on_failure": | ||
| 186 | + continue # 新增:控制流边不参与静态拓扑排序 | ||
| 187 | + to_id, from_id = edge.get("to"), edge.get("from") | ||
| 188 | + if to_id in deps and from_id in nodes: | ||
| 189 | + deps[to_id].add(from_id) | ||
| 190 | + ... # 其余逻辑不变 | ||
| 191 | +``` | ||
| 192 | + | ||
| 193 | +`topological_order()`(薄封装)不用改。这条修复是本设计里**唯一一处直接改动 G(并行执行)已经上线的现有函数**的地方,改动是纯粹的"多一个 `continue` 条件",不影响任何现有(不含 `on_failure` 边的)`graph_manifest` 的分层结果——`src/test_work_graph_orchestrate.py` 里 G 自己的既有测试用例不需要改一行断言即可继续通过。 | ||
| 194 | + | ||
| 195 | +### 4.1 关键简化:`retry_self` 是 `loop_back` 的特例 | ||
| 196 | + | ||
| 197 | +`retry_self` ≡ `loop_back` 且 `target_node_id` = 自己。原因:`retry_self` 触发时这个节点本身还没成功过,不可能已经有下游节点跑完并读取过它的(不存在的)输出,所以"重跑 target 到失败节点之间的下游链"这条规则应用到"target=自己"时,受影响集合就是 `{自己}`,跟现状的"原地重跑"完全等价。**一套机制两种声明方式**,不用维护两条独立代码路径。 | ||
| 198 | + | ||
| 199 | +### 4.2 受影响子图:只重跑真正的下游依赖,不是整层 | ||
| 200 | + | ||
| 201 | +需求文档非目标已声明"不追求完全通用的图执行框架",但"回路触发时把同层不相关的兄弟节点也无谓重跑一遍"没必要,也和已完成节点白白重花 token 相悖,成本可控且实现不复杂: | ||
| 202 | + | ||
| 203 | +```python | ||
| 204 | +def _transitive_dependents(graph_manifest, node_id): | ||
| 205 | + """node_id 自身 + 所有直接/间接依赖它的下游节点 id 集合(沿 depends_on/edges | ||
| 206 | + 正向遍历)。用于 loop_back 时判断哪些节点的输出已经"过期"、必须重跑。""" | ||
| 207 | + dependents = {} # node_id -> [直接依赖它的节点 id, ...] | ||
| 208 | + for node in graph_manifest["nodes"]: | ||
| 209 | + dependents.setdefault(node["node_id"], []) | ||
| 210 | + deps = {n["node_id"]: set(n.get("depends_on") or []) for n in graph_manifest["nodes"]} | ||
| 211 | + for edge in graph_manifest.get("edges", []): | ||
| 212 | + to_id, from_id = edge.get("to"), edge.get("from") | ||
| 213 | + if to_id in deps and from_id in deps: | ||
| 214 | + deps[to_id].add(from_id) | ||
| 215 | + for node_id_, dep_set in deps.items(): | ||
| 216 | + for dep in dep_set: | ||
| 217 | + dependents.setdefault(dep, []).append(node_id_) | ||
| 218 | + | ||
| 219 | + affected, stack = set(), [node_id] | ||
| 220 | + while stack: | ||
| 221 | + current = stack.pop() | ||
| 222 | + if current in affected: | ||
| 223 | + continue | ||
| 224 | + affected.add(current) | ||
| 225 | + stack.extend(dependents.get(current, [])) | ||
| 226 | + return affected | ||
| 227 | + | ||
| 228 | + | ||
| 229 | +def _on_failure_target(graph_manifest, node_id): | ||
| 230 | + """节点 node_id 声明了 on_fail.action='loop_back' 时,目标节点不是节点 | ||
| 231 | + 自己存的字段,而是 graph_manifest['edges'] 里唯一一条 | ||
| 232 | + {"from": node_id, "to": ..., "edge_type": "on_failure"} 边的 "to"—— | ||
| 233 | + 2026-08-08 修订:失败路径跟成功路径一样,都是"边",不是节点身上另开 | ||
| 234 | + 的一个跟 edges 脱节的影子字段。恰好 0 条或 2+ 条这样的边是规划错误, | ||
| 235 | + 在 _validate_loop_back_targets(8.3 节)里提前拦截,这里假设已经过 | ||
| 236 | + 校验,直接找第一条匹配的。""" | ||
| 237 | + for edge in graph_manifest.get("edges", []): | ||
| 238 | + if edge.get("from") == node_id and edge.get("edge_type") == "on_failure": | ||
| 239 | + return edge["to"] | ||
| 240 | + raise ValueError("node {} declares on_fail.action='loop_back' but has no on_failure edge".format(node_id)) | ||
| 241 | +``` | ||
| 242 | + | ||
| 243 | +### 4.3 层级回退循环——`run_graph()` 主循环改造 | ||
| 244 | + | ||
| 245 | +```python | ||
| 246 | +class _LoopBackRequested(Exception): | ||
| 247 | + """节点内部信号:这个节点的 on_fail.action=loop_back 触发了,需要外层 | ||
| 248 | + run_graph() 主循环回退重跑,不是真正的失败——不通过正常返回值传递是 | ||
| 249 | + 因为它可能发生在 ThreadPoolExecutor 的 worker 线程里(层内并发场景), | ||
| 250 | + 异常是唯一能干净地从 future.result() 里带出这个信号的方式。""" | ||
| 251 | + def __init__(self, source_node_id, target_node_id, feedback): | ||
| 252 | + self.source_node_id = source_node_id | ||
| 253 | + self.target_node_id = target_node_id | ||
| 254 | + self.feedback = feedback | ||
| 255 | + super().__init__("node {} requests loop_back to {}".format(source_node_id, target_node_id)) | ||
| 256 | + | ||
| 257 | + | ||
| 258 | +def run_graph(..., mechanical_check_registry=None): | ||
| 259 | + ensure_placeholder_skill(conn, skill_id, org_node) | ||
| 260 | + effective_needs_worktree = needs_worktree or paths.is_self_target(target_folder_path) | ||
| 261 | + layers = topological_layers(graph_manifest) | ||
| 262 | + node_layer_index = {node["node_id"]: i for i, layer in enumerate(layers) for node in layer} | ||
| 263 | + outputs = {} | ||
| 264 | + locked_conn = _LockedConn(conn) | ||
| 265 | + attempt_counts = {} # (target_node_id, source_node_id) -> 已尝试次数,本次 run_graph() 调用内的内存计数 | ||
| 266 | + # 断点续跑时从 execution_event 重建,见第 6.3 节 | ||
| 267 | + | ||
| 268 | + layer_index = 0 | ||
| 269 | + while layer_index < len(layers): | ||
| 270 | + layer = layers[layer_index] | ||
| 271 | + try: | ||
| 272 | + _run_layer( | ||
| 273 | + conn, locked_conn, run_id, org_node, skill_id, target_folder_path, layer, outputs, | ||
| 274 | + build_prompt, input_schema, output_schema, timeout, effective_needs_worktree, | ||
| 275 | + verify_fn, mechanical_check_registry, max_workers, sleep, now, | ||
| 276 | + ) | ||
| 277 | + except _LoopBackRequested as sig: | ||
| 278 | + key = (sig.target_node_id, sig.source_node_id) | ||
| 279 | + attempt_counts[key] = attempt_counts.get(key, 0) + _loop_attempt_count_from_events(conn, run_id, key) | ||
| 280 | + limit = _resolve_loop_limit(conn, org_node, graph_manifest, sig.target_node_id) | ||
| 281 | + if attempt_counts[key] > limit: | ||
| 282 | + conn.execute("UPDATE work_graph_run SET status = 'blocked' WHERE run_id = ?", (run_id,)) | ||
| 283 | + conn.commit() | ||
| 284 | + raise ConsistencyCheckFailedError( | ||
| 285 | + "loop_back from {} to {} exceeded max_attempts={}".format( | ||
| 286 | + sig.source_node_id, sig.target_node_id, limit | ||
| 287 | + ), | ||
| 288 | + node_id=sig.source_node_id, | ||
| 289 | + ) | ||
| 290 | + _record_loop_back_event(conn, run_id, sig, attempt_counts[key]) | ||
| 291 | + affected = _transitive_dependents(graph_manifest, sig.target_node_id) | ||
| 292 | + for affected_id in affected: | ||
| 293 | + outputs.pop(affected_id, None) # 强制这些节点在重跑时被当成"未完成" | ||
| 294 | + layer_index = node_layer_index[sig.target_node_id] | ||
| 295 | + continue | ||
| 296 | + layer_index += 1 | ||
| 297 | + | ||
| 298 | + return outputs | ||
| 299 | +``` | ||
| 300 | + | ||
| 301 | +`_run_layer(...)` 是现有 `run_graph()` 主循环体(单节点同步 / 多节点 `ThreadPoolExecutor`)原样搬过来的辅助函数,唯一的改动是:**只重跑本轮 `affected` 集合里的节点**(第一次进入某层时 `affected` 概念不适用,正常跑全部;loop_back 回退重入某层时,只有 `outputs` 里被 `.pop()` 掉的节点需要重新执行,其余节点直接复用已有的 `outputs[node_id]`,不重新调用 `_run_one_node`)——`outputs.pop()` 天然标记了"这个需要重跑",`_run_layer` 判断 `node["node_id"] not in outputs` 决定要不要真的执行这个节点,跳过的节点保留原值。 | ||
| 302 | + | ||
| 303 | +`_run_one_node()` 里判断 `verify_fn` 失败后该走哪条路径(原地重试 vs 抛 `_LoopBackRequested`): | ||
| 304 | + | ||
| 305 | +```python | ||
| 306 | +def _run_one_node(..., node, outputs, ...): | ||
| 307 | + node_id = node["node_id"] | ||
| 308 | + # 每次真正执行都用一个新的 attempt 后缀 node_id,绝不复用同一个已经 | ||
| 309 | + # 'done' 的 node_id——_ensure_node_row 按 (run_id, node_id) 去重,复用 | ||
| 310 | + # 同一个 node_id 会被 run_work_node 的断点续跑短路成"已完成,直接返回 | ||
| 311 | + # 旧输出",这正是必须避免的(详见 5 节)。 | ||
| 312 | + attempt = _count_prior_attempts(conn, run_id, node_id) + 1 | ||
| 313 | + exec_node_id = node_id if attempt == 1 else "{}-attempt{}".format(node_id, attempt) | ||
| 314 | + | ||
| 315 | + node_exec_id = _ensure_node_row(conn, run_id, exec_node_id, org_node, skill_id, ..., {"node_id": node_id}) | ||
| 316 | + prompt = build_prompt(node, outputs) + _loop_history_prompt_suffix(conn, run_id, node_id) | ||
| 317 | + run_fn = _make_run_fn(conn, target_folder_path, node_exec_id, org_node, prompt, output_schema, timeout) | ||
| 318 | + output = work_node_run.run_work_node(conn, node_exec_id, {"node_id": node_id}, input_schema, output_schema, run_fn, ...) | ||
| 319 | + | ||
| 320 | + resolved_verify_fn = _resolve_node_verify_fn(node, verify_fn, org_node, target_folder_path, mechanical_check_registry) | ||
| 321 | + if resolved_verify_fn is None: | ||
| 322 | + return output | ||
| 323 | + ok, feedback = resolved_verify_fn(node, output) | ||
| 324 | + if ok: | ||
| 325 | + return output | ||
| 326 | + | ||
| 327 | + on_fail = node.get("on_fail") or {"action": "retry_self" if node.get("judgment") else "hard_fail"} | ||
| 328 | + action = on_fail.get("action", "retry_self") | ||
| 329 | + if action == "hard_fail" or (action == "retry_self" and not node.get("judgment")): | ||
| 330 | + # 完全现状路径:没声明 judgment 的老节点,外部 verify_fn 失败时走 | ||
| 331 | + # 原有的 _verify_and_maybe_regenerate()(恰好一次原地重试),逐字节不变。 | ||
| 332 | + return _verify_and_maybe_regenerate(conn, run_id, org_node, skill_id, target_folder_path, | ||
| 333 | + node, node_id, prompt, output, input_schema, output_schema, | ||
| 334 | + timeout, needs_worktree, resolved_verify_fn, sleep, now) | ||
| 335 | + target = node_id if action == "retry_self" else _on_failure_target(graph_manifest, node_id) | ||
| 336 | + raise _LoopBackRequested(source_node_id=node_id, target_node_id=target, feedback=feedback) | ||
| 337 | +``` | ||
| 338 | + | ||
| 339 | +(`_run_one_node` 需要拿到 `graph_manifest` 才能查 `on_failure` 边——现有签名没有这个参数,改造时顺带传进去;`retry_self` 完全不查边,`target_node_id` 直接等于自己,第 4.1 节已经说明原因。) | ||
| 340 | + | ||
| 341 | +**关键取舍**:`hard_fail`、以及"没声明 `judgment` 的老节点走 `retry_self`"这两种情况,继续复用现有的 `_verify_and_maybe_regenerate()` 一字不改——不是所有失败路径都要改造成新机制,只有节点**主动声明了 `judgment`**(即 Part B 自规划出的新节点,或 Part A 上线后手工升级的固定链节点)才会走新的 `_LoopBackRequested` 回路路径。这保证 FR-A8 的向后兼容不是"理论上兼容"而是"代码路径都没变过"。 | ||
| 342 | + | ||
| 343 | +## 5. 断点续跑兼容性:新 attempt 后缀就是全部答案 | ||
| 344 | + | ||
| 345 | +不需要专门为回路设计新的续跑逻辑。原理:`_run_one_node()` 每次真正执行前,都先查询"这个 node_id 已经有几次真正的执行尝试"(`_count_prior_attempts`,扫 `work_node_execution` 里 `node_id` 匹配 `original_id` 或 `original_id-attemptN` 模式的行数),用下一个序号构造 `exec_node_id`。进程崩溃重启后重新调用 `run_graph()`: | ||
| 346 | + | ||
| 347 | +- 还没跑到的层:`outputs` 里没有对应 key,`_run_layer` 判断需要执行,正常走一遍——`_count_prior_attempts` 会看到之前留下的部分尝试记录(如果崩溃发生在某次 attempt 执行中途),用下一个序号继续,不会跟已存在的行冲突(`_ensure_node_row` 按 `exec_node_id` 去重,`exec_node_id` 本身按 attempt 序号递增,天然不冲突)。 | ||
| 348 | +- 已经 `done` 的 attempt:`run_work_node()` 现有的"已完成短路"逻辑对每个 `exec_node_id` 各自生效,不需要改。 | ||
| 349 | +- `attempt_counts`(内存字典,第 4.3 节):崩溃后是空的,但 `run_graph()` 一旦真的触发 `_LoopBackRequested` 时会先调用 `_loop_attempt_count_from_events(conn, run_id, key)`(第 6.3 节,从 `execution_event` 里查询真实发生过的次数),不依赖内存状态——重启后第一次遇到某个 loop_back key 时,从 DB 查出来的次数就是崩溃前的真实累计值,不会被内存清零重置为 0。 | ||
| 350 | + | ||
| 351 | +## 6. 循环上限配置(FR-A5) | ||
| 352 | + | ||
| 353 | +### 6.1 新表(`settings_config.py` 风格) | ||
| 354 | + | ||
| 355 | +```sql | ||
| 356 | +-- schema.py 新增 | ||
| 357 | +CREATE TABLE work_graph_loop_limit_config ( | ||
| 358 | + config_id TEXT PRIMARY KEY, -- "{org_node}:{node_type}" / "{org_node}:*" / "default" | ||
| 359 | + max_loop_attempts INTEGER NOT NULL, | ||
| 360 | + updated_by TEXT, | ||
| 361 | + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP | ||
| 362 | +); | ||
| 363 | +``` | ||
| 364 | + | ||
| 365 | +```python | ||
| 366 | +# settings_config.py 新增,跟 get_/upsert_concurrency_config 完全同构 | ||
| 367 | +def get_loop_limit_config(conn, config_id): | ||
| 368 | + row = conn.execute( | ||
| 369 | + "SELECT config_id, max_loop_attempts, updated_at FROM work_graph_loop_limit_config WHERE config_id = ?", | ||
| 370 | + (config_id,), | ||
| 371 | + ).fetchone() | ||
| 372 | + return None if row is None else {"config_id": row[0], "max_loop_attempts": row[1], "updated_at": row[2]} | ||
| 373 | + | ||
| 374 | + | ||
| 375 | +def upsert_loop_limit_config(conn, config_id, max_loop_attempts, updated_by): | ||
| 376 | + if max_loop_attempts < 1: | ||
| 377 | + raise InvalidParamError("max_loop_attempts must be >= 1, got {!r}".format(max_loop_attempts)) | ||
| 378 | + exists = conn.execute( | ||
| 379 | + "SELECT 1 FROM work_graph_loop_limit_config WHERE config_id = ?", (config_id,) | ||
| 380 | + ).fetchone() is not None | ||
| 381 | + if exists: | ||
| 382 | + conn.execute( | ||
| 383 | + "UPDATE work_graph_loop_limit_config SET max_loop_attempts = ?, updated_by = ?, " | ||
| 384 | + "updated_at = CURRENT_TIMESTAMP WHERE config_id = ?", | ||
| 385 | + (max_loop_attempts, updated_by, config_id), | ||
| 386 | + ) | ||
| 387 | + else: | ||
| 388 | + conn.execute( | ||
| 389 | + "INSERT INTO work_graph_loop_limit_config (config_id, max_loop_attempts, updated_by) VALUES (?, ?, ?)", | ||
| 390 | + (config_id, max_loop_attempts, updated_by), | ||
| 391 | + ) | ||
| 392 | + conn.commit() | ||
| 393 | + return get_loop_limit_config(conn, config_id) | ||
| 394 | +``` | ||
| 395 | + | ||
| 396 | +### 6.2 查找顺序 + 硬编码兜底 | ||
| 397 | + | ||
| 398 | +```python | ||
| 399 | +# work_graph_orchestrate.py | ||
| 400 | +_DEFAULT_LOOP_LIMIT = 3 # 只在配置表连 "default" 行都没有时才会用到(比如全新数据库还没跑过 seed) | ||
| 401 | + | ||
| 402 | +def _resolve_loop_limit(conn, org_node, graph_manifest, target_node_id): | ||
| 403 | + node = next(n for n in graph_manifest["nodes"] if n["node_id"] == target_node_id) | ||
| 404 | + node_level = (node.get("on_fail") or {}).get("max_attempts") | ||
| 405 | + if node_level is not None: | ||
| 406 | + return node_level # 节点自己声明的优先级最高(FR-A5) | ||
| 407 | + node_type = node.get("artifact_type", target_node_id) | ||
| 408 | + for config_id in ("{}:{}".format(org_node, node_type), "{}:*".format(org_node), "default"): | ||
| 409 | + config = settings_config.get_loop_limit_config(conn, config_id) | ||
| 410 | + if config: | ||
| 411 | + return config["max_loop_attempts"] | ||
| 412 | + return _DEFAULT_LOOP_LIMIT | ||
| 413 | +``` | ||
| 414 | + | ||
| 415 | +初始 seed:`schema.create_schema()`/迁移里插入一条 `config_id="default", max_loop_attempts=3` 的行(跟 `org_loop_route._LOOP_LIMIT=3` 取值一致,作为一个"暂时先抄现有跨 Org loop 的经验值,后续按真实调用数据调整"的起点——呼应需求文档「7. 风险」第二条,这不是拍脑袋,是有意选择一个已经在生产验证过的数量级作为起点,且明确设计成之后可以不改代码调整)。 | ||
| 416 | + | ||
| 417 | +## 7. loop 历史持久化(FR-A4):`execution_event` 迁移 | ||
| 418 | + | ||
| 419 | +### 7.1 新增 `event_type`:`loop_back_triggered` | ||
| 420 | + | ||
| 421 | +`execution_event.event_type` 是 CHECK 约束枚举(`schema.py:262-266`),SQLite 无法直接 `ALTER`,沿用 `migrate_expand_artifact_types` 的 rebuild-copy-swap 手法: | ||
| 422 | + | ||
| 423 | +```python | ||
| 424 | +# schema.py 新增 | ||
| 425 | +def migrate_add_loop_back_event_type(conn): | ||
| 426 | + """2026-08-08: 给 execution_event.event_type 的 CHECK 枚举加 | ||
| 427 | + 'loop_back_triggered',用于记录 Work Graph 内部回路——见 | ||
| 428 | + docs/todo/work-graph-self-planning-and-loop-back-design.md §7。 | ||
| 429 | + 幂等,安全对已有数据库无条件调用(同 migrate_expand_artifact_types 的写法)。 | ||
| 430 | + """ | ||
| 431 | + row = conn.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='execution_event'").fetchone() | ||
| 432 | + if row is None or "'loop_back_triggered'" in row[0]: | ||
| 433 | + return | ||
| 434 | + conn.executescript( | ||
| 435 | + """ | ||
| 436 | + CREATE TABLE execution_event_new ( | ||
| 437 | + event_id TEXT PRIMARY KEY, | ||
| 438 | + project_id TEXT NOT NULL REFERENCES project(project_id), | ||
| 439 | + task_id TEXT REFERENCES task(task_id), | ||
| 440 | + run_id TEXT REFERENCES work_graph_run(run_id), | ||
| 441 | + node_exec_id TEXT REFERENCES work_node_execution(node_exec_id), | ||
| 442 | + event_type TEXT NOT NULL CHECK (event_type IN ( | ||
| 443 | + 'task_status_changed', 'node_status_changed', 'artifact_created', | ||
| 444 | + 'bug_recorded', 'loop_routed', 'budget_updated', 'skill_iterated', | ||
| 445 | + 'log_appended', 'quota_wait_started', 'loop_back_triggered' | ||
| 446 | + )), | ||
| 447 | + payload TEXT NOT NULL, | ||
| 448 | + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP | ||
| 449 | + ); | ||
| 450 | + INSERT INTO execution_event_new SELECT * FROM execution_event; | ||
| 451 | + DROP TABLE execution_event; | ||
| 452 | + ALTER TABLE execution_event_new RENAME TO execution_event; | ||
| 453 | + CREATE INDEX idx_execution_event_project_created ON execution_event(project_id, created_at); | ||
| 454 | + CREATE INDEX idx_execution_event_task_created ON execution_event(task_id, created_at); | ||
| 455 | + """ | ||
| 456 | + ) | ||
| 457 | + conn.commit() | ||
| 458 | +``` | ||
| 459 | + | ||
| 460 | +`_ensure_schema()`(`backend_server.py:1020`)加一行无条件调用,跟 `migrate_expand_artifact_types`/`migrate_relax_org_node_constraints` 并列——这正是 2026-08-06 那次"忘记接进 `_ensure_schema`导致生产 500"的教训要求的(该函数自己的注释已经把这条约定写死)。 | ||
| 461 | + | ||
| 462 | +### 7.2 写入 + 查询 | ||
| 463 | + | ||
| 464 | +```python | ||
| 465 | +def _record_loop_back_event(conn, run_id, sig, attempt_number): | ||
| 466 | + task_id, project_id = conn.execute( | ||
| 467 | + "SELECT task_id, project_id FROM work_graph_run wgr JOIN task t ON wgr.task_id = t.task_id " | ||
| 468 | + "WHERE wgr.run_id = ?", (run_id,) | ||
| 469 | + ).fetchone() | ||
| 470 | + conn.execute( | ||
| 471 | + "INSERT INTO execution_event (event_id, project_id, task_id, run_id, event_type, payload) " | ||
| 472 | + "VALUES (?, ?, ?, ?, 'loop_back_triggered', ?)", | ||
| 473 | + (str(uuid.uuid4()), project_id, task_id, run_id, json.dumps({ | ||
| 474 | + "source_node_id": sig.source_node_id, "target_node_id": sig.target_node_id, | ||
| 475 | + "attempt_number": attempt_number, "feedback": sig.feedback, | ||
| 476 | + })), | ||
| 477 | + ) | ||
| 478 | + conn.commit() | ||
| 479 | + | ||
| 480 | + | ||
| 481 | +def _loop_attempt_count_from_events(conn, run_id, key): | ||
| 482 | + target_node_id, source_node_id = key | ||
| 483 | + rows = conn.execute( | ||
| 484 | + "SELECT payload FROM execution_event WHERE run_id = ? AND event_type = 'loop_back_triggered'", (run_id,) | ||
| 485 | + ).fetchall() | ||
| 486 | + return sum( | ||
| 487 | + 1 for (payload,) in rows | ||
| 488 | + if json.loads(payload)["target_node_id"] == target_node_id | ||
| 489 | + and json.loads(payload)["source_node_id"] == source_node_id | ||
| 490 | + ) | ||
| 491 | + | ||
| 492 | + | ||
| 493 | +def _loop_history_prompt_suffix(conn, run_id, node_id): | ||
| 494 | + """target_node_id 重新执行时,把它过去每一轮被打回的完整历史(不只 | ||
| 495 | + 最近一轮)拼进提示词——FR-A4。""" | ||
| 496 | + rows = conn.execute( | ||
| 497 | + "SELECT payload FROM execution_event WHERE run_id = ? AND event_type = 'loop_back_triggered' " | ||
| 498 | + "ORDER BY created_at", (run_id,) | ||
| 499 | + ).fetchall() | ||
| 500 | + history = [json.loads(p) for (p,) in rows if json.loads(p)["target_node_id"] == node_id] | ||
| 501 | + if not history: | ||
| 502 | + return "" | ||
| 503 | + lines = [ | ||
| 504 | + "Attempt {}: rejected because: {}".format(h["attempt_number"], "; ".join(h["feedback"] or [])) | ||
| 505 | + for h in history | ||
| 506 | + ] | ||
| 507 | + return "\n\nThis node was sent back for revision before, with this history:\n" + "\n".join(lines) | ||
| 508 | +``` | ||
| 509 | + | ||
| 510 | +不新增表,`execution_event` 一张表回答"这个节点被打回几次、每次谁触发的、为什么、按什么顺序"——满足决策 #4,且天然带 `run_id`/`node_exec_id`/`created_at`,现有图可视化机制(`v_graph_visualizer`,若按 `run_id` 拉取 `execution_event` 渲染)不需要新的数据源就能把 `source_node_id → target_node_id` 画成一条回路边。 | ||
| 511 | + | ||
| 512 | +## 8. Part B:Planner 自规划节点集合 | ||
| 513 | + | ||
| 514 | +### 8.1 节点类型清单——数据,不是硬编码提示词字符串(FR-B2) | ||
| 515 | + | ||
| 516 | +```python | ||
| 517 | +# work_graph_plan.py 新增 | ||
| 518 | +_PLANNER_NODE_CATALOG = [ | ||
| 519 | + {"artifact_type": "brd", "description": "商业需求文档...", "judgment": {"kind": "none"}}, | ||
| 520 | + {"artifact_type": "prd", "description": "产品需求文档...", | ||
| 521 | + "judgment": {"kind": "llm", "judge_prompt": _PRD_CONSISTENCY_JUDGE_PROMPT}}, | ||
| 522 | + {"artifact_type": "sdd", "description": "详细设计文档...", "judgment": {"kind": "none"}}, | ||
| 523 | + {"artifact_type": "benchmark", "description": "验收基准...", "judgment": {"kind": "none"}}, | ||
| 524 | + {"artifact_type": "test_dataset", "description": "测试数据集(可选,goal 不需要时回复 N/A)...", | ||
| 525 | + "judgment": {"kind": "none"}}, | ||
| 526 | + {"artifact_type": "test_script", "description": "可执行验收脚本(可选,goal 不需要时回复 N/A)...", | ||
| 527 | + "judgment": {"kind": "mechanical", "check_ref": "check_script_compiles"}}, | ||
| 528 | +] | ||
| 529 | +``` | ||
| 530 | + | ||
| 531 | +`_PRD_CONSISTENCY_JUDGE_PROMPT` 是现有 `_PRD_CONSISTENCY_PROMPT`(`e2e_pipeline.py:72`)改写成 `judge_prompt` 的通用形状(`{content}` 占位符替代原来的 `{prd_content}`),供 Planner 在自规划时"抄"进它给 prd 节点声明的 `judgment` 里——这份清单只是给 LLM 的**参考建议**,不是强制:Planner 完全可以给某个节点声明跟清单建议不同的 `judgment`/`on_fail`(FR-B4 已确认"全部自由规划"),清单只是降低它每次都要从零发明判断方式的成本。 | ||
| 532 | + | ||
| 533 | +### 8.2 新 `_PROMPT_TEMPLATE` | ||
| 534 | + | ||
| 535 | +```python | ||
| 536 | +_PROMPT_TEMPLATE = ( | ||
| 537 | + "Plan (do not execute) the Work Graph for this goal: {goal_text}\n" | ||
| 538 | + "Available node types (use any subset, in any order that makes sense -- " | ||
| 539 | + "this goal may not need all of them):\n{catalog_json}\n" | ||
| 540 | + "For each node you include, decide: node_id, artifact_type, depends_on, " | ||
| 541 | + "a judgment config (how to tell if this node's output is acceptable -- you " | ||
| 542 | + "may reuse a catalog entry's suggested judgment, or write your own 'llm' " | ||
| 543 | + "judge_prompt), and an on_fail config (what happens if judgment fails: " | ||
| 544 | + "retry_self, loop_back, or hard_fail). If a node's on_fail.action is " | ||
| 545 | + "'loop_back', you MUST also add a corresponding edge in the top-level " | ||
| 546 | + "'edges' array with edge_type 'on_failure', from that node to the exact " | ||
| 547 | + "earlier node_id it should return to -- the target must be a real " | ||
| 548 | + "upstream dependency of this node (something that genuinely produced " | ||
| 549 | + "input this node used), not an arbitrary node. This mirrors how the " | ||
| 550 | + "success path already works: every node's 'yes, this succeeded, what's " | ||
| 551 | + "next' is expressed as a normal dependency edge, and 'no, this failed, " | ||
| 552 | + "go back to X' is expressed the same way, just with edge_type " | ||
| 553 | + "'on_failure' instead. Every other edge needs edge_type: 'zero_token' " | ||
| 554 | + "for pure aggregation/reduce steps, 'needs_judgment' for anything " | ||
| 555 | + "requiring the agent to actually reason about content.\n" | ||
| 556 | + "End your reply with the graph_manifest as a single fenced ```json code " | ||
| 557 | + "block shaped exactly like {{\"nodes\": [...], \"edges\": [...]}} -- no " | ||
| 558 | + "extra wrapper key." | ||
| 559 | +).format(catalog_json=json.dumps(_PLANNER_NODE_CATALOG), goal_text="{goal_text}") | ||
| 560 | +``` | ||
| 561 | + | ||
| 562 | +(`.format()` 两次是因为 `catalog_json` 在模块加载时就能确定、`goal_text` 要留到调用时才填——用 `{{`/`}}` 转义加上先格式化一次 `catalog_json`,再对外暴露带 `{goal_text}` 占位符的最终模板字符串,写法上等价于现有 `_GENERIC_PROMPT_TEMPLATE` 的处理方式,不是新模式。) | ||
| 563 | + | ||
| 564 | +### 8.3 `_normalize_graph_manifest` 的变化:删除,不是加固 | ||
| 565 | + | ||
| 566 | +`_CANONICAL_DOC_CHAIN` 常量、以及"当六个/五个规范类型全部出现时强制线性依赖顺序"那段逻辑(`work_graph_plan.py`,2026-08-07 已经因为禁用 test_script 缩成 5 元组)**整体删除**——FR-B4 已确认"全部自由规划,不保留任何代码级固定顺序兜底"。`_normalize_graph_manifest` 保留的部分: | ||
| 567 | +- `id`/`node_id` 键名兼容(2026-08-01 真实教训)。 | ||
| 568 | +- 从 `edges` 反推 `depends_on`(同上)。 | ||
| 569 | + | ||
| 570 | +新增的部分: | ||
| 571 | +- **2026-08-08 修订(用户反馈)**:失败路径的目标节点不是节点身上一个跟 `edges` 脱节的独立字段,而是跟成功路径一样,由规划这个节点的时候一并定好的边决定——"yes 走哪个节点、no 走哪个节点,都是边定义好的"。所以校验对象变成:每个声明了 `on_fail.action="loop_back"` 的节点,必须**恰好有一条**从它出发、`edge_type="on_failure"` 的边(见第 4.2 节 `_on_failure_target`),且这条边指向的节点必须是该节点的(直接或间接)上游依赖——LLM 规划时可能"跳"到一个跟自己毫无依赖关系的节点(逻辑上没有意义:loop_back 的语义是"回到更早、真正影响了我的节点"),这种情况在 `_normalize_graph_manifest` 里直接拒绝并抛 `PlanningFailedError`(复用现有异常类),比让它在 `run_graph()` 执行到一半才报错更早暴露问题。 | ||
| 572 | + | ||
| 573 | +```python | ||
| 574 | +def _validate_loop_back_targets(graph_manifest): | ||
| 575 | + node_ids = {n["node_id"] for n in graph_manifest["nodes"]} | ||
| 576 | + for node in graph_manifest["nodes"]: | ||
| 577 | + on_fail = node.get("on_fail") or {} | ||
| 578 | + if on_fail.get("action") != "loop_back": | ||
| 579 | + continue | ||
| 580 | + node_id = node["node_id"] | ||
| 581 | + on_failure_edges = [ | ||
| 582 | + e for e in graph_manifest.get("edges", []) | ||
| 583 | + if e.get("from") == node_id and e.get("edge_type") == "on_failure" | ||
| 584 | + ] | ||
| 585 | + if len(on_failure_edges) != 1: | ||
| 586 | + raise PlanningFailedError( | ||
| 587 | + "node {} declares on_fail.action='loop_back' but has {} on_failure edge(s), expected exactly 1".format( | ||
| 588 | + node_id, len(on_failure_edges) | ||
| 589 | + ) | ||
| 590 | + ) | ||
| 591 | + target = on_failure_edges[0]["to"] | ||
| 592 | + if target not in node_ids: | ||
| 593 | + raise PlanningFailedError( | ||
| 594 | + "node {}'s on_failure edge targets {!r}, which does not exist in this graph_manifest".format( | ||
| 595 | + node_id, target | ||
| 596 | + ) | ||
| 597 | + ) | ||
| 598 | + if target not in _transitive_ancestors(graph_manifest, node_id): | ||
| 599 | + raise PlanningFailedError( | ||
| 600 | + "node {}'s on_failure edge targets {!r}, which is not an upstream dependency -- " | ||
| 601 | + "loop_back must target a node that genuinely influenced this one".format(node_id, target) | ||
| 602 | + ) | ||
| 603 | +``` | ||
| 604 | + | ||
| 605 | +(`_transitive_ancestors` 是 `_transitive_dependents`——第 4.2 节,`work_graph_orchestrate.py`——的反向版本;两边各自独立实现一份即可,`work_graph_plan.py` 不需要因此新引入对 `work_graph_orchestrate.py` 的依赖,两个模块现在也没有互相 import,保持这个边界。`_on_failure_target`——第 4.2 节——运行时可以放心假设"恰好一条"已经在这里校验过,不用再处理 0 条/多条的情况。) | ||
| 606 | + | ||
| 607 | +### 8.4 `plan_documents()` 调用点改动 | ||
| 608 | + | ||
| 609 | +`e2e_pipeline.py::plan_documents()` 不再需要 `_combined_planner_verify_fn`(PRD 一致性检查 + test_script 编译检查两个手写分支)——改成 `verify_fn=None`,因为自规划出的每个节点都自带 `judgment`,`run_graph()` 内部会自动解析。`mechanical_check_registry={"check_script_compiles": check_script_compiles}` 照常传入。旧的 `_combined_planner_verify_fn`/`_prd_consistency_verify_fn`/`_test_script_verify_fn` 函数**不删除**——`validate_implementation_with_own_graph()`(Validator 自己的硬编码 3/2 节点图,本轮不改,见需求文档非目标)继续用它们,且它们仍然是"外部 verify_fn 向后兼容路径"的真实调用方,删除会破坏 FR-A8。 | ||
| 610 | + | ||
| 611 | +## 9. 向后兼容性核对表(FR-A8) | ||
| 612 | + | ||
| 613 | +| 现有调用点 | 是否声明 `judgment`/`on_fail` | 改造后走的路径 | | ||
| 614 | +|---|---|---| | ||
| 615 | +| `implement_code()` 的合成单节点图 | 否 | `_resolve_node_verify_fn` 直接返回 `None`(该调用点本来就不传 `verify_fn`),零改动 | | ||
| 616 | +| `validate_implementation_with_own_graph()`(`_VALIDATOR_MANIFEST`) | 否 | `_resolve_node_verify_fn` 返回外部 `_test_script_verify_fn(...)`,失败时走 `hard_fail`/现状 `_verify_and_maybe_regenerate`,逐字节不变 | | ||
| 617 | +| `work_graph_fork.py`(`_GENERIC_PROMPT_TEMPLATE` 路径) | 否 | 同上,无 `verify_fn` 传入,无判断 | | ||
| 618 | +| `plan_documents()`(Part B 改造后) | 是(每个节点自带) | 走新的 `judgment`/`on_fail` 解析路径 | | ||
| 619 | + | ||
| 620 | +## 10. 测试计划(对应需求文档验收标准 1-10) | ||
| 621 | + | ||
| 622 | +| 验收标准 | 测试方式 | | ||
| 623 | +|---|---| | ||
| 624 | +| 1-2. loop_back 累积历史 + 触发上限 | mock `execute_work_graph`,构造 A→B、B 声明 `on_fail=loop_back(target=A)`,B 连续失败 N 次;断言 A 被重跑 N 次、`execution_event` 里有 N 条 `loop_back_triggered`、A 第 N 次收到的 prompt 包含前 N-1 次的 feedback 文本 | | ||
| 625 | +| 3. 崩溃重启后正确恢复轮次 | 跑到第 2 次 loop_back 时中断(mock 抛异常模拟进程退出),重新调用 `run_graph()`,断言 `_loop_attempt_count_from_events` 从 DB 读到 2,不是从 0 开始 | | ||
| 626 | +| 4. 静态图校验不因 loop_back 报环 | 一个声明了 `on_fail.action="loop_back"` 且带一条指向上游节点的 `on_failure` 边的 `graph_manifest`,`topological_layers()` 正常返回分层,不抛 `ValueError`(因为 `on_failure` 边类型根本不参与 `topological_layers` 的依赖计算,只有 `depends_on`/普通数据依赖 edge 参与) | | ||
| 627 | +| 5. 未声明字段的图逐字节不变 | 现有 `test_work_graph_orchestrate.py`/`test_e2e_pipeline.py` 全部既有用例不改断言即通过 | | ||
| 628 | +| 6-7. Part B 自由规划 + 健壮性兜底 | 同需求文档,mock LLM 规划响应缺 test_dataset / 缺 depends_on,断言分别正确处理 | | ||
| 629 | +| 8. test_script 可选 | mock 规划响应包含 test_script 节点,断言正常规划执行 | | ||
| 630 | +| 9. 按 org_node/node_type 配置上限生效 | `settings_config.upsert_loop_limit_config` 写两条不同配置,构造两个 loop_back 场景,断言各自在配置的上限触发 | | ||
| 631 | +| 10. loop 历史只在 `execution_event` | 触发若干轮 loop_back 后,只查 `execution_event`(不查任何新表)复原完整历史 | | ||
| 632 | +| 新增:`_validate_loop_back_targets` 拒绝非法目标 | 分别构造"`on_failure` 边指向的节点不是上游依赖"、"声明 `loop_back` 但没有 `on_failure` 边"、"同一个节点有 2 条 `on_failure` 边"三种场景,断言 `plan_work_graph()` 都抛 `PlanningFailedError` | | ||
| 633 | +| 新增:mechanical_check_registry 未注册报错 | `judgment.check_ref` 指向一个不存在的名字,断言 `run_graph()` 在执行任何节点前就报错,不是跑到那个节点才发现 | | ||
| 634 | +| 新增:`topological_layers()` 正确忽略 `on_failure` 边(4.0 节修复) | 构造 A→B(普通依赖)+ B→A(`on_failure` 边)的 `graph_manifest`,断言 `topological_layers()` 返回 `[[A], [B]]`(不因为 B→A 那条边被误判成环);另跑一遍 G 既有的 `test_work_graph_orchestrate.py` 全部用例确认零回归 | | ||
| 635 | + | ||
| 636 | +## 11. 实施步骤建议 | ||
| 637 | + | ||
| 638 | +1. `schema.py` 两处迁移(`work_graph_loop_limit_config` 建表 + `migrate_add_loop_back_event_type`)+ `settings_config.py` 两个新函数 + `_ensure_schema()` 接入——纯数据层改动,先落地,不碰任何执行逻辑。 | ||
| 639 | +2. `work_graph_orchestrate.py`:`_resolve_node_verify_fn`/机械检查注册表注入/`_llm_judgment_verify_fn`——先只做"judgment 解析",不做 loop_back(这一步验证:没有 `on_fail` 时行为完全不变,第 9 节核对表跑一遍)。 | ||
| 640 | +3. `work_graph_orchestrate.py`:`_transitive_dependents`/`_LoopBackRequested`/`run_graph()` 主循环回退逻辑/attempt 后缀机制——loop_back 的核心,单独用 mock 场景验证第 10 节测试计划 1-5、9、10。 | ||
| 641 | +4. `work_graph_plan.py`:删除 `_CANONICAL_DOC_CHAIN` 强制顺序逻辑、`_PLANNER_NODE_CATALOG`、新 `_PROMPT_TEMPLATE`、`_validate_loop_back_targets`——Part B,第 10 节测试计划 6-8、新增两项。 | ||
| 642 | +5. `e2e_pipeline.py::plan_documents()` 切到 `verify_fn=None` + `mechanical_check_registry`,端到端跑一次 mock 场景确认整条链路。 | ||
| 643 | +6. 真实场景验证(`RUN_REAL_AGENT_E2E=1`,小心控制次数/成本):观察一次真实 Planner 自规划调用产出的 `graph_manifest` 长什么样,是否合理、是否触发过 `PlanningFailedError`。 |
docs/todo/work-graph-self-planning-and-loop-back-requirements.md
0 → 100644
| 1 | +# Planner 动态自规划 Work Graph(含节点判断与失败回路)——需求文档 | ||
| 2 | + | ||
| 3 | +**日期**: 2026-08-08 | ||
| 4 | +**性质**: 需求文档(BRD/PRD 层面),不含详细技术设计。回应用户 2026-08-07/08-08 两轮讨论:先是"要不要让 Planner 自己判断要不要有 test_script 节点",深入后明确为一个更完整的诉求——Planner 第一步应该是自己规划 Work Graph(要不要 brd/prd/sdd/benchmark/test 的组合、未来还有更多 tool/skill 可组合调用),并为每个节点规划输入输出边约束、判断成功/失败的方式,失败时把结果送回更早的节点形成回路(loop),并把 loop 过程中的产出记录下来供后续轮次参考改进。详细设计(SDD 层面:graph_manifest 的具体 JSON 形状、`topological_layers()`/`run_graph()` 的具体改造点、循环历史的具体持久化方式)留到本文档确认后单独产出。 | ||
| 5 | +**状态**: ✅ 已实现(2026-08-08)。原「8. 开放问题」的 7 个分叉点均已拍板,记录见「8. 已确认的决策」,正文相应章节已按决策更新。详细设计见 `docs/todo/work-graph-self-planning-and-loop-back-design.md`;Part A(判断+回路引擎)与 Part B(Planner 自规划)均已实现,`python3 -m unittest discover -s src`(692 tests)+ `discover -s supervisor -p "test_*.py"`(126 tests)全绿。尚未做真实 `RUN_REAL_AGENT_E2E=1` 端到端验证(需要真实 API 额度,等待用户决定是否现在跑)。 | ||
| 6 | + | ||
| 7 | +## 1. 背景 | ||
| 8 | + | ||
| 9 | +### 1.1 现状:三条独立的、能力有限的机制 | ||
| 10 | + | ||
| 11 | +- **Planner 自己的文档链**(`work_graph_plan.py::_PROMPT_TEMPLATE`):过一层真实的 LLM 规划调用,但提示词硬编码要求"必须为 brd/prd/sdd/benchmark/test_dataset 各出一个节点"(test_script 已于 2026-08-07 从这个固定清单里移除,见 `docs/todo/validator-test-script-narrative-output-issue.md`)——节点集合本质是固定的,规划这一步只是在确认依赖关系,不是真的自由决策"要不要有某个节点"。这是 2026-08-06 的一次明确设计决定(`docs/done/dynamic-work-graph-design-skill-design.md` §3 原话:"不是让 Claude 完全自由决定 Planner 要生成哪些节点——Planner 那条链是'架构常量'")。 | ||
| 12 | +- **`_GENERIC_PROMPT_TEMPLATE`**(同文件,服务 Coder/Validator 的子任务图):真正让 LLM 自由规划 `{node_id, depends_on, task_prompt}`,但目前只有 `work_graph_fork.py`(人工发起的 time-travel 调试功能)在用,没有接入任何生产路径的自动执行;且和 Planner 的固定链一样,只是一张纯 DAG,没有判断/回路概念。 | ||
| 13 | +- **`work_graph_orchestrate.py::run_graph()` 的验证-重试机制**:`_verify_and_maybe_regenerate()` 只支持"某节点验证失败 → 原地重新生成同一个节点一次 → 还失败就整体 `ConsistencyCheckFailedError`"。这不是回路——失败结果不会被送回更早的节点,历史反馈也只保留最近一轮(`"; ".join(feedback)` 直接拼进 reflect prompt,不会累积多轮)。 | ||
| 14 | + | ||
| 15 | +### 1.2 两个此前未被充分利用的既有事实 | ||
| 16 | + | ||
| 17 | +- **`edge_type`(`zero_token`/`needs_judgment`)字段已经存在于 `GRAPH_MANIFEST_SCHEMA`,但从未被任何执行逻辑读取**——`topological_layers()` 只看 `depends_on`/`from-to` 决定分层,完全不管 `edge_type` 的值,是纯文档字段。 | ||
| 18 | +- **`topological_layers()` 对任何环直接 `raise ValueError`**——现在的执行引擎从数据结构到算法都是严格 DAG,没有"跳回更早节点"这种控制流的容身之处。`docs/sdd/01-M-模型设计.md:60` 里 `work_graph_run.graph_manifest` 字段的文档说明其实从一开始就写了"概念上应涵盖 fan-out/fan-in 分组、loop 规则",只是从未真正实现过。 | ||
| 19 | + | ||
| 20 | +### 1.3 一个必须正面回应的架构决定 | ||
| 21 | + | ||
| 22 | +`docs/graphlemon_architecture.md`"方案 A"明确设想过:Work Graph 内部的 fan-out/fan-in/loop/verifier 应该直接用 Claude Code 原生 Dynamic Workflow 原语(`agent()`/`parallel()`/`pipeline()`,类似 loop-until-dry)编排,而不是手撸 Python 引擎。但 `work_graph_orchestrate.py` 的实际实现故意背离了这个设想(一个节点一个独立 `claude -p` 子进程 + Python 拓扑引擎逐节点持久化状态),原因写在该模块自己的 docstring 里:REQ-06 断点续跑——原生原语跑在一次不透明的 `claude -p` 调用里,中途崩溃就会丢失全部循环历史和已完成节点状态;现在的设计换来的是"进程重启后能从任意已完成节点继续,不重跑"。 | ||
| 23 | + | ||
| 24 | +本需求延续这个既有背离,在 Python 引擎里新增回路能力,而不是回退去用原生原语——因为 REQ-06 是已经在生产里被真实依赖的能力(self-hosting 崩溃恢复、worktree resume),原生原语给不了这个保证。 | ||
| 25 | + | ||
| 26 | +## 2. 目标 | ||
| 27 | + | ||
| 28 | +1. `graph_manifest` 的节点能声明式地表达"怎么判断这个节点成功/失败"(机械检查引用,或另一次 LLM 判断),不再要求调用方在 `e2e_pipeline.py` 里为每种 artifact_type 手写专门的 `verify_fn` 分支。 | ||
| 29 | +2. `graph_manifest` 的节点能声明式地表达"失败之后怎么办":原地重试(现状)、跳回更早的某个指定节点重新执行(新增)、或直接判定整图失败(现状的显式化)。 | ||
| 30 | +3. 触发"跳回更早节点"时,被跳回节点到失败节点之间的整条下游链要被重新执行(下游数据是基于坏输入产出的,不能继续用);同时,这个节点每一轮被拒绝的历史(不只是最近一轮)要被完整地喂给它自己的下一次重新生成,让它能看到"过去几次分别为什么被拒绝",而不是每次都从零开始猜。 | ||
| 31 | +4. 这套回路能力必须有可配置的上限,且在进程中途崩溃重启后能正确恢复"当前是第几轮",不能死循环也不能重置归零。 | ||
| 32 | +5. 在回路执行引擎具备之后,让 Planner 自己的规划调用能真正决定"这个 goal 需要哪些节点",而不是像现在这样被提示词硬编码死一份固定清单——这份清单本身也应该是可扩展的数据,为未来接入更多 tool/skill 节点类型留出空间(这轮不要求真的新增新类型,只要求机制本身可扩展)。 | ||
| 33 | +6. **长期方向(本轮不实施,仅作为设计约束)**:用户明确的最终目标是"Planner 改造成功后,Coder、Validator、甚至未来新增的 org graph node,都需要具备同样的能力——先 plan 自己的 work graph,再按照 work graph 执行"。本轮的 Part A(judgment/on_fail 机制)设计必须是 org-agent 无关的通用能力,不能是 Planner 专属的特殊逻辑,即使这轮只把它接到 Planner 一个 org node 上(见「3. 非目标」第 1 条、「8. 已确认的决策」第 6 条)。 | ||
| 34 | + | ||
| 35 | +## 3. 非目标(本轮明确不做) | ||
| 36 | + | ||
| 37 | +1. **本轮实施范围仍只覆盖 Planner,不把 Coder/Validator 也接上自规划**。`_VALIDATOR_MANIFEST` 现在完全是硬编码的 Python 字面量,连"过一层 LLM 规划"都没有;Coder 现在是合成单节点图。用户已确认长期方向是所有 org agent(含未来新增的)都要具备同样能力(见「2. 目标」第 6 条),但落地顺序是"Planner 改造验证成功后,再依次推广"——不是本轮的实施范围,本轮只是设计时不能把机制设计成 Planner 专属。 | ||
| 38 | +2. **不回退到 Claude Code 原生 Dynamic Workflow 原语**(见 1.3)。继续在 Python 层的 `work_graph_orchestrate.py` 里扩展执行引擎,保住 REQ-06 断点续跑。 | ||
| 39 | +3. **不追求完全通用的图执行框架**。回路的触发方式限定为"某节点判断失败 → 跳回自己声明的 `on_fail.target_node_id`",不支持任意节点间的条件分支、多目标跳转、或图结构在运行时被重新规划——这些是更大的能力,不在这轮范围内。 | ||
| 40 | +4. **不重新设计跨 Org 的既有 loop 机制**(`org_level_loop.py`/`org_loop_route.py`:Validator FAIL/INCONCLUSIVE → 判根因 → 路由回 Planner/Coder,`loop_round` 封顶 3 轮)。本需求的回路是"同一个 Org Agent 自己的 Work Graph 内部"这一层,比跨 Org 的 loop 粒度更细,两者独立计数、不共享额度、不互相替代——内层耗尽自己的上限后照旧抛 `ConsistencyCheckFailedError`,外层机制不变地接管。 | ||
| 41 | +5. **不在本需求里解决 test_script/validator_test_script 节点"叙述文字而非代码"的结构性根因**。这个节点已于 2026-08-07 因该问题禁用(`docs/todo/validator-test-script-narrative-output-issue.md`),根因是"探索后收尾习惯",跟本需求要解决的问题(节点集合是否可以自由规划、失败能否形成回路)完全正交。用户已确认(见「8. 已确认的决策」第 7 条):Part B 上线后,test_script 可以直接作为可选节点提供给 Planner 自由规划选择,不需要等这个结构性问题修复;该结构性问题的真正修复安排在另一份文档里,跟"给 Coder agent 加入 test_script 模块"一起解决——本需求只负责不阻塞它,不负责修它。 | ||
| 42 | +6. **不修改现有 `_GENERIC_PROMPT_TEMPLATE` 路径本身**(Coder/Validator 子任务图、`work_graph_fork.py` 用的那条)。如果这轮的 judgment/on_fail 字段设计得足够通用,未来可以顺带让这条路径也受益,但不是本轮的验收范围。 | ||
| 43 | + | ||
| 44 | +## 4. 功能需求 | ||
| 45 | + | ||
| 46 | +分两部分:Part A 是执行引擎扩展(判断 + 回路能力),Part B 是 Planner 自规划节点集合。**Part B 依赖 Part A 先完成**——自由规划出的图如果没有真正的失败回路可用,只是重新引入"LLM 自由吐图结构"的脆弱性(2026-08-03 那次真实故障:LLM 漏给 `depends_on`/`edges`,`benchmark` 排到 `sdd` 前面执行,直接 `KeyError` 崩溃),却拿不到新能力。 | ||
| 47 | + | ||
| 48 | +### Part A:节点判断 + 失败回路执行引擎(`work_graph_orchestrate.py`) | ||
| 49 | + | ||
| 50 | +| # | 需求描述 | 优先级 | | ||
| 51 | +|---|---|---| | ||
| 52 | +| FR-A1 | `graph_manifest` 的节点应支持声明式的 `judgment` 配置(机械检查引用,或触发一次额外 LLM 判断调用),替代现在"调用方在 Python 里为每种 artifact_type 手写 `verify_fn` 分支"的模式。 | 必须 | | ||
| 53 | +| FR-A2 | `graph_manifest` 的节点应支持声明式的 `on_fail` 配置,至少支持三种行为:`retry_self`(现状:原地重新生成同一节点一次)、`loop_back`(跳转到指定的更早节点重新执行)、`hard_fail`(不重试,直接判定整图失败——现状默认行为的显式化)。 | 必须 | | ||
| 54 | +| FR-A3 | 触发 `loop_back` 时,不只重跑 `target_node_id` 本身,还要重跑它到触发失败的节点之间的整条下游链,因为这条链上的产出都是基于(即将被修正的)过期输入生成的。 | 必须 | | ||
| 55 | +| FR-A4 | `loop_back` 的每一轮反馈(这一轮产出了什么、为什么被判定失败)必须可完整还原,且 `target_node_id` 重新执行时,累积的完整历史(不只是最近一轮)要被喂进它的提示词。**不新增专门的历史表**——复用现有 `execution_event` 日志机制(`node_start`/`node_completed` 等既有事件),每次 `loop_back` 触发/重新执行时在事件里记录 predecessor/successor 节点关联,让"这个节点被打回过几次、每次因为什么"可以直接从现有事件流重建,同时让现有的图可视化机制(`v_graph_visualizer`)能把回路自然地呈现成图上的一条边,不需要另外维护一份历史、也不需要事后做双份数据的一致性对账。 | 必须 | | ||
| 56 | +| FR-A5 | 每一对 `(loop_back 目标节点, 触发回路的失败节点)` 需要独立计数,并有上限;超限时整个 `run_graph()` 调用判定失败(复用/扩展现有 `ConsistencyCheckFailedError` 语义),不允许无限循环。**上限不是写死的全局常量**——复用 `settings_config.py`(现有 `org_concurrency_config` 的同类模式:DB 存储、`get_/upsert_` 读写接口)新增一张按 `(org_node, node 类型或 node_id 模式)` 维度可配置的上限表,允许后续根据真实调用经验(哪些节点类型容易被反复打回、上限设多少更合理)动态调整,不需要改代码重新部署;节点自身也可以在 `on_fail.max_attempts` 里覆盖这个默认值。 | 必须 | | ||
| 57 | +| FR-A6 | 循环计数与历史必须能扛得住 REQ-06 式的断点续跑:进程在循环中途崩溃重启后,应正确恢复"当前是第几轮、历史反馈有哪些",不能从零重置,也不能因为状态丢失而死循环。 | 必须 | | ||
| 58 | +| FR-A7 | `topological_layers()` 的静态分层只应基于"数据依赖边"(`depends_on`/`consumes`);节点声明的 `on_fail.target_node_id`(运行时才可能触发的控制流跳转)不参与静态拓扑排序,不应被当成图里的环而报错。 | 必须 | | ||
| 59 | +| FR-A8 | 不声明 `judgment`/`on_fail` 字段的既有 `graph_manifest`(例如 Coder 现在的合成单节点图)行为必须完全不变——这两个字段可选,缺省等价于现状(`judgment=none` 直接通过、`on_fail=hard_fail` 即现在的"验证失败重试一次后硬失败")。 | 必须 | | ||
| 60 | +| FR-A9 | `judgment`/`on_fail` 机制的实现(`graph_manifest` schema、`run_graph()` 的执行逻辑、FR-A5 的上限配置表)必须是 org-agent 无关的通用能力——不能依赖任何 `org_node == "planner"` 的特判。这轮只有 Planner 会真正产出带这些字段的 `graph_manifest`,但 Coder/Validator 未来接入时,不应该需要改 `work_graph_orchestrate.py` 本身,只需要它们自己的规划提示词开始产出这些字段(呼应「2. 目标」第 6 条的长期方向)。 | 必须 | | ||
| 61 | + | ||
| 62 | +### Part B:Planner 自规划节点集合(依赖 Part A) | ||
| 63 | + | ||
| 64 | +| # | 需求描述 | 优先级 | | ||
| 65 | +|---|---|---| | ||
| 66 | +| FR-B1 | Planner 的规划提示词从"必须产出固定清单里的每一个节点"改为:给定一份可用节点类型清单,由 LLM 自己判断这个 goal 需要哪些节点、它们的依赖关系,以及(复用 Part A)每个节点的 `judgment`/`on_fail` 配置。 | 必须 | | ||
| 67 | +| FR-B2 | 可用节点类型清单本身应是可扩展的数据(而不是硬编码在提示词字符串里的固定枚举),为未来接入更多 tool/skill 节点类型留出空间;本轮不要求真的新增除现有文档类型之外的新类型。 | 应该 | | ||
| 68 | +| FR-B3 | 自由规划出的 `graph_manifest` 必须经过与现有 `_normalize_graph_manifest` 同等严格的健壮性校验/兜底,防止 2026-08-03 那次真实故障重演(LLM 输出遗漏 `depends_on`/`edges`,导致执行顺序错误甚至崩溃)——节点集合变自由不代表可以降低这层鲁棒性要求。 | 必须 | | ||
| 69 | +| FR-B4 | **brd/prd/sdd/benchmark/test_dataset(以及 test_script,一旦被规划出来)全部由 LLM 自由规划,不保留任何代码级的固定顺序/固定节点集合兜底**——已确认(「8. 已确认的决策」第 1 条):完全推翻 `docs/done/dynamic-work-graph-design-skill-design.md` §3"Planner 链是架构常量"的决定。FR-B3 的健壮性校验兜底(防止 LLM 漏给 `depends_on`/`edges`)因此变得更重要:没有任何硬编码的顺序可以兜底,健壮性完全依赖 FR-B3。 | 必须 | | ||
| 70 | + | ||
| 71 | +## 5. 验收标准 | ||
| 72 | + | ||
| 73 | +1. 构造一个 `graph_manifest`:节点 A 产出,节点 B 依赖 A、声明 `judgment` 机械检查、`on_fail=loop_back` 指向 A;用 mock 让 B 连续两轮判断失败、第三轮通过。断言:A 被重新执行了两次(不是原地重试 B),且 A 第二次、第三次重新执行时收到的提示词里包含前面所有轮次的失败反馈(不只是最近一轮)。 | ||
| 74 | +2. 同上场景,把 B 的失败改成"每一轮都失败",断言在达到可配置上限后,`run_graph()` 判定整图失败并明确抛出(而不是无限循环挂起),且失败前 A 已经产出的内容仍按现有"部分成功节点落盘"机制(`_write_completed_node_artifacts`)被正确保留。 | ||
| 75 | +3. 模拟"回路进行到第 2 轮时进程中断重启",断言恢复执行后能正确识别"已经是第 2 轮"并继续,而不是把计数器归零重新从第 1 轮开始,也不是把已经持久化的历史反馈丢掉。 | ||
| 76 | +4. 一个声明了 `on_fail.target_node_id` 指向更早节点的 `graph_manifest`,`topological_layers()`/图校验逻辑不应把它当作静态环拒绝(不抛 `ValueError`)。 | ||
| 77 | +5. 一个完全不使用 `judgment`/`on_fail` 字段的既有 `graph_manifest`(例如 Coder 当前用的合成单节点图),跑一遍 `run_graph()`,行为与改造前逐字节一致——现有 `src/test_work_graph_orchestrate.py`/`src/test_e2e_pipeline.py` 里未显式测试新字段的用例,不修改断言即可继续通过。 | ||
| 78 | +6. (Part B)给 Planner 一个明显不需要 test_dataset 的 goal(例如纯配置项调整),断言自规划产出的 `graph_manifest` 里确实没有 test_dataset 节点,且整条链路(Coder 拿到的 `test_dataset_content` 走 `"N/A: not provided"` 兜底)不因为节点缺失而报错——复用现有的 `"test_dataset" in artifact_ids` 容错逻辑,不需要新代码。 | ||
| 79 | +7. (Part B)人为构造一个自由规划回复"遗漏 `depends_on`"的场景(模拟 2026-08-03 那次真实故障的输入),断言健壮性兜底仍能纠正执行顺序,不崩溃。 | ||
| 80 | +8. (Part B)给 Planner 一个明显需要机械化验收脚本的 goal,可用节点类型清单里包含 test_script,断言 Planner 能够(不是必须,是"能够")自由规划出一个 test_script 节点——确认 test_script 已按「8. 已确认的决策」第 7 条重新开放为可选节点,不再被清单排除。 | ||
| 81 | +9. (FR-A5)验证上限配置确实按 `(org_node, node 类型)` 生效:给同一个 org_node 下两种不同 node 类型分别配置不同上限,构造两个各自持续失败的 loop_back 场景,断言各自在自己配置的上限触发失败,不是共用同一个全局值;再验证节点自身 `on_fail.max_attempts` 能覆盖配置表里的默认值。 | ||
| 82 | +10. (FR-A4)验证 loop_back 历史确实通过 `execution_event` 而非新表重建:触发一次多轮 loop_back 后,仅查询 `execution_event` 表(不查询任何新增表)即可还原"这个节点被打回几次、每次的 predecessor/successor 是谁、每次的反馈内容"。 | ||
| 83 | + | ||
| 84 | +## 6. 约束与假设 | ||
| 85 | + | ||
| 86 | +- 复用 REQ-06 已经建立的"`work_node_execution.output_payload` 是节点成功后的权威持久化数据"这一既有约定;回路历史**不新增专门的表**,完整复用 `execution_event` 现有机制(见 FR-A4),必须遵循"重启后可完整恢复"这一约束,不引入新的、易丢失的临时状态。 | ||
| 87 | +- 复用 `org_loop_route.py::_LOOP_LIMIT` 的既有模式("计数 + 达到上限即转 `blocked_pending_human`")作为 Part A 回路上限触发后果的参照,但两者是独立的计数器,不共享同一个 `task.loop_round` 字段;上限的具体数值来源改为复用 `settings_config.py` 的 DB 配置模式(见 FR-A5),不是抄一个固定常量。 | ||
| 88 | +- `judgment.kind="llm"` 复用 `_check_prd_consistency` 现有模式:额外发起一次 `agent()` 调用(`agentType='planner'`),self-target 场景下走独立 worktree 隔离,避免重新设计一套判断机制。 | ||
| 89 | +- Part A 完成后,Coder 现在的合成单节点图、`_GENERIC_PROMPT_TEMPLATE` 路径都不强制迁移使用新字段——新字段是可选能力,不是所有 `graph_manifest` 生产者都必须立刻采用;但机制本身必须 org-agent 无关(见 FR-A9),为后续推广铺路。 | ||
| 90 | + | ||
| 91 | +## 7. 风险与已知权衡 | ||
| 92 | + | ||
| 93 | +- **用户已知情并接受:Part B 上线后 test_script 类节点可能被自由规划回来,narrative-output 问题可能重现**。`docs/todo/validator-test-script-narrative-output-issue.md` 记录的根因是"探索后收尾习惯",跟本需求解决的问题(节点集合能不能自由规划、失败能否形成回路)完全正交,本需求不修它。用户决策(「8. 已确认的决策」第 7 条):不等结构性修复,真正的修复安排在"给 Coder agent 加入 test_script 模块"那份文档里一起解决——本需求上线后,如果自举/真实任务里又复现了叙述文字问题,这是已知的、被接受的风险,不是本需求的回归。 | ||
| 94 | +- **回路能力本身可能被滥用成隐藏的高成本重试**:一个设计不当的 `judgment`(例如过于严格的 LLM 判断标准)配合 `loop_back`,可能导致某个节点反复被打回、消耗大量 token 才勉强通过上限。FR-A5 的按 `(org_node, node 类型)` 可配置上限缓解了"一刀切"的问题,但初始默认值仍需要设计阶段给出具体理由,不能只是"抄一个数字";上线后应该根据真实调用数据回头调整(这正是选择"DB 配置表"而不是"硬编码常量"的原因)。 | ||
| 95 | +- **Part B 是对已有架构决定的正式推翻**,不是渐进式增强。`docs/done/dynamic-work-graph-design-skill-design.md` §3 需要在 Part B 实施后同步更新,避免文档和代码再次出现本 session 里多次遇到的"文档记录的意图与实际代码不一致"的问题。 | ||
| 96 | +- **Part A + Part B 一并实施(已确认,见「8. 已确认的决策」第 5 条),没有中间的"仅 Part A 先跑一段时间验证稳定性"的缓冲阶段**——意味着 Part A 的回路引擎第一次被真实使用,就是在"节点集合本身也是自由规划、不受固定链条约束"这个更不可预测的场景下,两个新变量同时引入,出问题时定位"是回路引擎的 bug 还是自由规划出了一个奇怪的图结构"会比分阶段更难。这是用户已知情的权衡,不是本需求遗漏。 | ||
| 97 | + | ||
| 98 | +## 8. 已确认的决策(2026-08-08) | ||
| 99 | + | ||
| 100 | +1. **Part B 的推翻力度**:全部自由规划。brd/prd/sdd/benchmark/test_dataset(以及重新开放的 test_script)不保留任何代码级固定顺序/固定节点集合兜底,完全推翻 `docs/done/dynamic-work-graph-design-skill-design.md` §3 的既有决定(对应 FR-B4)。健壮性完全依赖 FR-B3 的校验兜底。 | ||
| 101 | +2. **回路上限**:不设单一全局默认值,按 `(org_node, node 类型)` 可配置,复用 `settings_config.py` 的 DB 配置模式,根据实际调用经验动态调整(对应 FR-A5)。 | ||
| 102 | +3. **`judgment.kind="llm"` 的实现**:复用现有 `_check_prd_consistency` 模式(额外发起一次 `agent()` 调用,`agentType='planner'`,self-target 场景走独立 worktree)。 | ||
| 103 | +4. **回路历史持久化**:不新增专门的表,完整复用 `execution_event` 现有日志机制,记录 predecessor/successor 节点关联,天然可被现有图可视化机制呈现为有向图上的边(对应 FR-A4)。 | ||
| 104 | +5. **落地顺序**:Part A、Part B 一起做,不分阶段(见「7. 风险」最后一条,这是已知权衡,不是遗漏)。 | ||
| 105 | +6. **Validator/Coder 是否一起做**:本轮仍只落地 Planner,但 Part A 的机制设计必须 org-agent 无关(FR-A9)——用户明确的长期方向是 Planner 验证成功后,Coder、Validator、乃至未来新增的 org graph node 都要具备"先 plan 自己的 work graph,再按图执行"的同一能力(见「2. 目标」第 6 条)。 | ||
| 106 | +7. **test_script 是否要等结构性修复**:不需要等。Part B 上线后,test_script 直接作为可选节点开放给 Planner 自由规划;narrative-output 问题的真正结构性修复安排在"给 Coder agent 加入 test_script 模块"的另一份文档里一起解决,不是本需求的阻塞项(见「7. 风险」第一条)。 |
src/backend_server.py
| @@ -1036,6 +1036,8 @@ def _ensure_schema(conn): | @@ -1036,6 +1036,8 @@ def _ensure_schema(conn): | ||
| 1036 | # startup). Run all three every time, not just on first-ever creation. | 1036 | # startup). Run all three every time, not just on first-ever creation. |
| 1037 | schema.migrate_expand_artifact_types(conn) | 1037 | schema.migrate_expand_artifact_types(conn) |
| 1038 | schema.migrate_relax_org_node_constraints(conn) # also runs migrate_add_work_graph_fork_columns internally | 1038 | schema.migrate_relax_org_node_constraints(conn) # also runs migrate_add_work_graph_fork_columns internally |
| 1039 | + schema.migrate_add_work_graph_loop_limit_config(conn) | ||
| 1040 | + schema.migrate_add_loop_back_event_type(conn) | ||
| 1039 | 1041 | ||
| 1040 | 1042 | ||
| 1041 | class _Server(ThreadingHTTPServer): | 1043 | class _Server(ThreadingHTTPServer): |
src/e2e_pipeline.py
| @@ -208,6 +208,10 @@ _VALIDATOR_DOC_PROMPTS = { | @@ -208,6 +208,10 @@ _VALIDATOR_DOC_PROMPTS = { | ||
| 208 | ), | 208 | ), |
| 209 | } | 209 | } |
| 210 | 210 | ||
| 211 | +# validator_test_script node temporarily disabled (2026-08-07, same | ||
| 212 | +# stopgap as work_graph_plan._PROMPT_TEMPLATE -- see its comment for the | ||
| 213 | +# full rationale/upgrade path): 6/6 real repro rate for narrative-instead- | ||
| 214 | +# of-code output, docs/todo/validator-test-script-narrative-output-issue.md. | ||
| 211 | _VALIDATOR_MANIFEST = { | 215 | _VALIDATOR_MANIFEST = { |
| 212 | "nodes": [ | 216 | "nodes": [ |
| 213 | {"node_id": "validator_benchmark", "artifact_type": "validator_benchmark", "depends_on": []}, | 217 | {"node_id": "validator_benchmark", "artifact_type": "validator_benchmark", "depends_on": []}, |
| @@ -215,15 +219,6 @@ _VALIDATOR_MANIFEST = { | @@ -215,15 +219,6 @@ _VALIDATOR_MANIFEST = { | ||
| 215 | "node_id": "validator_test_dataset", "artifact_type": "validator_test_dataset", | 219 | "node_id": "validator_test_dataset", "artifact_type": "validator_test_dataset", |
| 216 | "depends_on": ["validator_benchmark"], | 220 | "depends_on": ["validator_benchmark"], |
| 217 | }, | 221 | }, |
| 218 | - { | ||
| 219 | - # depends on BOTH -- its own prompt needs | ||
| 220 | - # {validator_benchmark_content} directly, not just what | ||
| 221 | - # survives into validator_test_dataset_content (same reasoning | ||
| 222 | - # as the Planner's own test_script node, see | ||
| 223 | - # work_graph_plan._normalize_graph_manifest). | ||
| 224 | - "node_id": "validator_test_script", "artifact_type": "validator_test_script", | ||
| 225 | - "depends_on": ["validator_test_dataset", "validator_benchmark"], | ||
| 226 | - }, | ||
| 227 | ], | 222 | ], |
| 228 | "edges": [], | 223 | "edges": [], |
| 229 | } | 224 | } |
| @@ -240,6 +235,24 @@ _VALIDATOR_VERDICT_PROMPT = ( | @@ -240,6 +235,24 @@ _VALIDATOR_VERDICT_PROMPT = ( | ||
| 240 | ) | 235 | ) |
| 241 | 236 | ||
| 242 | 237 | ||
| 238 | +class _MissingContentDefault(dict): | ||
| 239 | + """2026-08-08 (found via a real self-planned run, task involving a | ||
| 240 | + src/work_node_run.py bug fix): `_DOC_PROMPTS` entries were all written | ||
| 241 | + for the old fixed brd->prd->sdd->benchmark->... chain and hardcode | ||
| 242 | + placeholders like {prd_content} assuming that upstream node always | ||
| 243 | + exists. Work Graph self-planning (FR-B4 "全部自由规划") legitimately | ||
| 244 | + lets the Planner skip nodes that don't apply -- this real run's SDD | ||
| 245 | + node correctly had no PRD node at all (a small bug-fix goal doesn't | ||
| 246 | + need one), so `_DOC_PROMPTS["sdd"]`'s `{prd_content}` placeholder had | ||
| 247 | + nothing to fill it and `.format()` raised a bare KeyError, landing the | ||
| 248 | + task on `blocked_pending_human` with the unhelpful generic | ||
| 249 | + 'execution_error' reason. `str.format_map` with this as the mapping | ||
| 250 | + substitutes a clear placeholder for any content key the self-planned | ||
| 251 | + graph didn't actually produce, instead of crashing.""" | ||
| 252 | + def __missing__(self, key): | ||
| 253 | + return "N/A (no corresponding upstream node was included in this Work Graph)" | ||
| 254 | + | ||
| 255 | + | ||
| 243 | def _planner_prompt_builder(goal_text, node_artifact_types): | 256 | def _planner_prompt_builder(goal_text, node_artifact_types): |
| 244 | """node_artifact_types: dict[node_id] -> artifact_type, read off the | 257 | """node_artifact_types: dict[node_id] -> artifact_type, read off the |
| 245 | real graph_manifest before running it (build_prompt only ever sees one | 258 | real graph_manifest before running it (build_prompt only ever sees one |
| @@ -247,13 +260,13 @@ def _planner_prompt_builder(goal_text, node_artifact_types): | @@ -247,13 +260,13 @@ def _planner_prompt_builder(goal_text, node_artifact_types): | ||
| 247 | def build_prompt(node, outputs): | 260 | def build_prompt(node, outputs): |
| 248 | node_id = node["node_id"] | 261 | node_id = node["node_id"] |
| 249 | artifact_type = node_artifact_types.get(node_id, node_id) | 262 | artifact_type = node_artifact_types.get(node_id, node_id) |
| 250 | - content_kwargs = {} | 263 | + content_kwargs = {"goal_text": goal_text} |
| 251 | for dep_id in (node.get("depends_on") or []): | 264 | for dep_id in (node.get("depends_on") or []): |
| 252 | dep_output = outputs.get(dep_id) | 265 | dep_output = outputs.get(dep_id) |
| 253 | if dep_output is not None: | 266 | if dep_output is not None: |
| 254 | dep_type = node_artifact_types.get(dep_id, dep_id) | 267 | dep_type = node_artifact_types.get(dep_id, dep_id) |
| 255 | content_kwargs[dep_type + "_content"] = dep_output["content"] | 268 | content_kwargs[dep_type + "_content"] = dep_output["content"] |
| 256 | - return _DOC_PROMPTS[artifact_type].format(goal_text=goal_text, **content_kwargs) | 269 | + return _DOC_PROMPTS[artifact_type].format_map(_MissingContentDefault(content_kwargs)) |
| 257 | return build_prompt | 270 | return build_prompt |
| 258 | 271 | ||
| 259 | 272 | ||
| @@ -558,6 +571,20 @@ def plan_documents(conn, task_id, goal_text, target_folder_path, timeout=300): | @@ -558,6 +571,20 @@ def plan_documents(conn, task_id, goal_text, target_folder_path, timeout=300): | ||
| 558 | re-raised unchanged after salvaging -- this does not change whether or | 571 | re-raised unchanged after salvaging -- this does not change whether or |
| 559 | when a task ends up blocked_pending_human, only what's left behind for | 572 | when a task ends up blocked_pending_human, only what's left behind for |
| 560 | a human's Resume to build on when it does. | 573 | a human's Resume to build on when it does. |
| 574 | + | ||
| 575 | + 2026-08-08 (Work Graph self-planning + loop-back, FR-B1): `verify_fn` | ||
| 576 | + is no longer `_combined_planner_verify_fn` -- each self-planned node | ||
| 577 | + now carries its own `judgment` field (work_graph_plan.py's | ||
| 578 | + `_PLANNER_NODE_CATALOG` suggests one per node type, the Planner can | ||
| 579 | + reuse or override it), which `run_graph()` resolves itself per node. | ||
| 580 | + `_combined_planner_verify_fn`/`_prd_consistency_verify_fn`/ | ||
| 581 | + `_test_script_verify_fn` are NOT deleted -- `validate_implementation_ | ||
| 582 | + with_own_graph()` (Validator's still-hardcoded manifest, unaffected by | ||
| 583 | + this round) continues to use them (FR-A8). `mechanical_check_registry` | ||
| 584 | + is still passed unconditionally: a self-planned node declaring | ||
| 585 | + `judgment.kind == "mechanical"` (e.g. reusing the catalog's suggested | ||
| 586 | + `check_script_compiles` for a test_script node) needs it registered, | ||
| 587 | + same as before. | ||
| 561 | """ | 588 | """ |
| 562 | plan_result = work_graph_plan.plan_work_graph( | 589 | plan_result = work_graph_plan.plan_work_graph( |
| 563 | conn, "planner", task_id, goal_text, target_folder_path, timeout=timeout | 590 | conn, "planner", task_id, goal_text, target_folder_path, timeout=timeout |
| @@ -571,7 +598,7 @@ def plan_documents(conn, task_id, goal_text, target_folder_path, timeout=300): | @@ -571,7 +598,7 @@ def plan_documents(conn, task_id, goal_text, target_folder_path, timeout=300): | ||
| 571 | work_graph_orchestrate.run_graph( | 598 | work_graph_orchestrate.run_graph( |
| 572 | conn, run_id, "planner", "planner-doc-writing", target_folder_path, graph_manifest, | 599 | conn, run_id, "planner", "planner-doc-writing", target_folder_path, graph_manifest, |
| 573 | _planner_prompt_builder(goal_text, node_artifact_types), _DOC_INPUT_SCHEMA, _DOC_OUTPUT_SCHEMA, | 600 | _planner_prompt_builder(goal_text, node_artifact_types), _DOC_INPUT_SCHEMA, _DOC_OUTPUT_SCHEMA, |
| 574 | - verify_fn=_combined_planner_verify_fn(node_artifact_types, target_folder_path), | 601 | + mechanical_check_registry={"check_script_compiles": check_script_compiles}, |
| 575 | ) | 602 | ) |
| 576 | except Exception as e: | 603 | except Exception as e: |
| 577 | run_graph_error = e | 604 | run_graph_error = e |
| @@ -792,10 +819,16 @@ def validate_implementation_with_own_graph( | @@ -792,10 +819,16 @@ def validate_implementation_with_own_graph( | ||
| 792 | if run_graph_error is not None: | 819 | if run_graph_error is not None: |
| 793 | raise run_graph_error | 820 | raise run_graph_error |
| 794 | 821 | ||
| 795 | - script_content = outputs["validator_test_script"]["content"] | ||
| 796 | - script_execution = run_test_script_for_real( | ||
| 797 | - script_content, target_folder_path, task_id, "validator_test_script", timeout=timeout, | ||
| 798 | - ) | 822 | + if "validator_test_script" in outputs: |
| 823 | + script_content = outputs["validator_test_script"]["content"] | ||
| 824 | + script_execution = run_test_script_for_real( | ||
| 825 | + script_content, target_folder_path, task_id, "validator_test_script", timeout=timeout, | ||
| 826 | + ) | ||
| 827 | + else: | ||
| 828 | + script_execution = { | ||
| 829 | + "ran": False, "exit_code": None, "stdout": "", "stderr": "", | ||
| 830 | + "note": "N/A: validator_test_script node is currently disabled (see _VALIDATOR_MANIFEST)", | ||
| 831 | + } | ||
| 799 | 832 | ||
| 800 | response = org_node_trigger.trigger_org_node( | 833 | response = org_node_trigger.trigger_org_node( |
| 801 | "validator", | 834 | "validator", |
src/schema.py
| @@ -2,7 +2,8 @@ | @@ -2,7 +2,8 @@ | ||
| 2 | 2 | ||
| 3 | U-001 scope: the 15 platform entities from docs/sdd/01-M-模型设计.md §1.0-1.14 | 3 | U-001 scope: the 15 platform entities from docs/sdd/01-M-模型设计.md §1.0-1.14 |
| 4 | (PH0-PH4). `deployment`/`deploy_health_signal` (§1.15/§1.16, added here in U-034, | 4 | (PH0-PH4). `deployment`/`deploy_health_signal` (§1.15/§1.16, added here in U-034, |
| 5 | -2026-08-02) are PH5-only -- 17 tables total as of PH5. | 5 | +2026-08-02) are PH5-only. `work_graph_loop_limit_config` (§1.11.1, 2026-08-08, |
| 6 | +Work Graph self-planning + loop-back) added post-PH5 -- 18 tables total. | ||
| 6 | 7 | ||
| 7 | Interpretive conventions used below, where 01-M leaves the physical type implicit: | 8 | Interpretive conventions used below, where 01-M leaves the physical type implicit: |
| 8 | - Booleans are stored as INTEGER CHECK(x IN (0, 1)) -- sqlite3 has no native bool type. | 9 | - Booleans are stored as INTEGER CHECK(x IN (0, 1)) -- sqlite3 has no native bool type. |
| @@ -46,6 +47,7 @@ TABLE_NAMES = ( | @@ -46,6 +47,7 @@ TABLE_NAMES = ( | ||
| 46 | "agent_profile", | 47 | "agent_profile", |
| 47 | "budget_guardrail_config", | 48 | "budget_guardrail_config", |
| 48 | "org_concurrency_config", | 49 | "org_concurrency_config", |
| 50 | + "work_graph_loop_limit_config", | ||
| 49 | "execution_event", | 51 | "execution_event", |
| 50 | "execution_log_file", | 52 | "execution_log_file", |
| 51 | "log_retention_config", | 53 | "log_retention_config", |
| @@ -262,7 +264,7 @@ CREATE TABLE execution_event ( | @@ -262,7 +264,7 @@ CREATE TABLE execution_event ( | ||
| 262 | event_type TEXT NOT NULL CHECK (event_type IN ( | 264 | event_type TEXT NOT NULL CHECK (event_type IN ( |
| 263 | 'task_status_changed', 'node_status_changed', 'artifact_created', | 265 | 'task_status_changed', 'node_status_changed', 'artifact_created', |
| 264 | 'bug_recorded', 'loop_routed', 'budget_updated', 'skill_iterated', | 266 | 'bug_recorded', 'loop_routed', 'budget_updated', 'skill_iterated', |
| 265 | - 'log_appended', 'quota_wait_started' | 267 | + 'log_appended', 'quota_wait_started', 'loop_back_triggered' |
| 266 | )), | 268 | )), |
| 267 | payload TEXT NOT NULL, | 269 | payload TEXT NOT NULL, |
| 268 | created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP | 270 | created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP |
| @@ -271,6 +273,15 @@ CREATE TABLE execution_event ( | @@ -271,6 +273,15 @@ CREATE TABLE execution_event ( | ||
| 271 | CREATE INDEX idx_execution_event_project_created ON execution_event(project_id, created_at); | 273 | CREATE INDEX idx_execution_event_project_created ON execution_event(project_id, created_at); |
| 272 | CREATE INDEX idx_execution_event_task_created ON execution_event(task_id, created_at); | 274 | CREATE INDEX idx_execution_event_task_created ON execution_event(task_id, created_at); |
| 273 | 275 | ||
| 276 | +-- 1.11.1 work_graph_loop_limit_config (Work Graph self-planning + loop-back, | ||
| 277 | +-- docs/todo/work-graph-self-planning-and-loop-back-design.md §6) | ||
| 278 | +CREATE TABLE work_graph_loop_limit_config ( | ||
| 279 | + config_id TEXT PRIMARY KEY, | ||
| 280 | + max_loop_attempts INTEGER NOT NULL, | ||
| 281 | + updated_by TEXT, | ||
| 282 | + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP | ||
| 283 | +); | ||
| 284 | + | ||
| 274 | -- 1.13 execution_log_file | 285 | -- 1.13 execution_log_file |
| 275 | CREATE TABLE execution_log_file ( | 286 | CREATE TABLE execution_log_file ( |
| 276 | log_file_id TEXT PRIMARY KEY, | 287 | log_file_id TEXT PRIMARY KEY, |
| @@ -581,3 +592,57 @@ def migrate_relax_org_node_constraints(conn): | @@ -581,3 +592,57 @@ def migrate_relax_org_node_constraints(conn): | ||
| 581 | ) | 592 | ) |
| 582 | finally: | 593 | finally: |
| 583 | conn.execute("PRAGMA foreign_keys = ON") | 594 | conn.execute("PRAGMA foreign_keys = ON") |
| 595 | + | ||
| 596 | + | ||
| 597 | +def migrate_add_work_graph_loop_limit_config(conn): | ||
| 598 | + """2026-08-08 (Work Graph self-planning + loop-back, | ||
| 599 | + docs/todo/work-graph-self-planning-and-loop-back-design.md §6): adds | ||
| 600 | + work_graph_loop_limit_config on an ALREADY-POPULATED database. Plain | ||
| 601 | + `CREATE TABLE IF NOT EXISTS` -- a brand-new table, no CHECK constraint | ||
| 602 | + to rebuild around, unlike migrate_expand_artifact_types. Idempotent and | ||
| 603 | + safe to call unconditionally, including against a freshly | ||
| 604 | + create_schema()'d database (which already has it).""" | ||
| 605 | + conn.execute( | ||
| 606 | + "CREATE TABLE IF NOT EXISTS work_graph_loop_limit_config (" | ||
| 607 | + "config_id TEXT PRIMARY KEY, max_loop_attempts INTEGER NOT NULL, " | ||
| 608 | + "updated_by TEXT, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)" | ||
| 609 | + ) | ||
| 610 | + conn.commit() | ||
| 611 | + | ||
| 612 | + | ||
| 613 | +def migrate_add_loop_back_event_type(conn): | ||
| 614 | + """2026-08-08 (Work Graph self-planning + loop-back, | ||
| 615 | + docs/todo/work-graph-self-planning-and-loop-back-design.md §7): adds | ||
| 616 | + 'loop_back_triggered' to execution_event.event_type's CHECK enum on an | ||
| 617 | + ALREADY-POPULATED database -- same create-copy-drop-rename dance as | ||
| 618 | + migrate_expand_artifact_types (SQLite has no ALTER-based constraint | ||
| 619 | + migration). Idempotent and safe to call unconditionally, including | ||
| 620 | + against a freshly create_schema()'d database (which already has the | ||
| 621 | + expanded constraint).""" | ||
| 622 | + row = conn.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='execution_event'").fetchone() | ||
| 623 | + if row is None or "'loop_back_triggered'" in row[0]: | ||
| 624 | + return | ||
| 625 | + conn.executescript( | ||
| 626 | + """ | ||
| 627 | + CREATE TABLE execution_event_new ( | ||
| 628 | + event_id TEXT PRIMARY KEY, | ||
| 629 | + project_id TEXT NOT NULL REFERENCES project(project_id), | ||
| 630 | + task_id TEXT REFERENCES task(task_id), | ||
| 631 | + run_id TEXT REFERENCES work_graph_run(run_id), | ||
| 632 | + node_exec_id TEXT REFERENCES work_node_execution(node_exec_id), | ||
| 633 | + event_type TEXT NOT NULL CHECK (event_type IN ( | ||
| 634 | + 'task_status_changed', 'node_status_changed', 'artifact_created', | ||
| 635 | + 'bug_recorded', 'loop_routed', 'budget_updated', 'skill_iterated', | ||
| 636 | + 'log_appended', 'quota_wait_started', 'loop_back_triggered' | ||
| 637 | + )), | ||
| 638 | + payload TEXT NOT NULL, | ||
| 639 | + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP | ||
| 640 | + ); | ||
| 641 | + INSERT INTO execution_event_new SELECT * FROM execution_event; | ||
| 642 | + DROP TABLE execution_event; | ||
| 643 | + ALTER TABLE execution_event_new RENAME TO execution_event; | ||
| 644 | + CREATE INDEX idx_execution_event_project_created ON execution_event(project_id, created_at); | ||
| 645 | + CREATE INDEX idx_execution_event_task_created ON execution_event(task_id, created_at); | ||
| 646 | + """ | ||
| 647 | + ) | ||
| 648 | + conn.commit() |
src/settings_config.py
| @@ -45,6 +45,44 @@ def upsert_concurrency_config(conn, max_concurrent_goals, updated_by, config_id= | @@ -45,6 +45,44 @@ def upsert_concurrency_config(conn, max_concurrent_goals, updated_by, config_id= | ||
| 45 | return get_concurrency_config(conn, config_id) | 45 | return get_concurrency_config(conn, config_id) |
| 46 | 46 | ||
| 47 | 47 | ||
| 48 | +def get_loop_limit_config(conn, config_id): | ||
| 49 | + """Work Graph self-planning + loop-back (docs/todo/work-graph-self- | ||
| 50 | + planning-and-loop-back-design.md §6): no single 'default' config_id -- | ||
| 51 | + callers look up "{org_node}:{node_type}" then "{org_node}:*" then | ||
| 52 | + "default", falling back to a hardcoded constant if even "default" is | ||
| 53 | + unseeded (same pattern scheduler.py already uses for | ||
| 54 | + get_concurrency_config: `config["x"] if config else <constant>`).""" | ||
| 55 | + row = conn.execute( | ||
| 56 | + "SELECT config_id, max_loop_attempts, updated_at FROM work_graph_loop_limit_config WHERE config_id = ?", | ||
| 57 | + (config_id,), | ||
| 58 | + ).fetchone() | ||
| 59 | + if row is None: | ||
| 60 | + return None | ||
| 61 | + return {"config_id": row[0], "max_loop_attempts": row[1], "updated_at": row[2]} | ||
| 62 | + | ||
| 63 | + | ||
| 64 | +def upsert_loop_limit_config(conn, config_id, max_loop_attempts, updated_by): | ||
| 65 | + if max_loop_attempts < 1: | ||
| 66 | + raise InvalidParamError("max_loop_attempts must be >= 1, got {!r}".format(max_loop_attempts)) | ||
| 67 | + | ||
| 68 | + exists = conn.execute( | ||
| 69 | + "SELECT 1 FROM work_graph_loop_limit_config WHERE config_id = ?", (config_id,) | ||
| 70 | + ).fetchone() is not None | ||
| 71 | + if exists: | ||
| 72 | + conn.execute( | ||
| 73 | + "UPDATE work_graph_loop_limit_config SET max_loop_attempts = ?, updated_by = ?, " | ||
| 74 | + "updated_at = CURRENT_TIMESTAMP WHERE config_id = ?", | ||
| 75 | + (max_loop_attempts, updated_by, config_id), | ||
| 76 | + ) | ||
| 77 | + else: | ||
| 78 | + conn.execute( | ||
| 79 | + "INSERT INTO work_graph_loop_limit_config (config_id, max_loop_attempts, updated_by) VALUES (?, ?, ?)", | ||
| 80 | + (config_id, max_loop_attempts, updated_by), | ||
| 81 | + ) | ||
| 82 | + conn.commit() | ||
| 83 | + return get_loop_limit_config(conn, config_id) | ||
| 84 | + | ||
| 85 | + | ||
| 48 | def get_log_retention_config(conn, config_id="default"): | 86 | def get_log_retention_config(conn, config_id="default"): |
| 49 | row = conn.execute( | 87 | row = conn.execute( |
| 50 | "SELECT config_id, retention_days, max_total_size_mb, updated_at FROM log_retention_config " | 88 | "SELECT config_id, retention_days, max_total_size_mb, updated_at FROM log_retention_config " |
src/test_e2e_pipeline.py
| @@ -22,6 +22,7 @@ or work_graph_orchestrate's own mechanics (test_work_graph_orchestrate.py). | @@ -22,6 +22,7 @@ or work_graph_orchestrate's own mechanics (test_work_graph_orchestrate.py). | ||
| 22 | Run: python3 -m unittest src.test_e2e_pipeline -v | 22 | Run: python3 -m unittest src.test_e2e_pipeline -v |
| 23 | """ | 23 | """ |
| 24 | 24 | ||
| 25 | +import contextlib | ||
| 25 | import json | 26 | import json |
| 26 | import os | 27 | import os |
| 27 | import subprocess | 28 | import subprocess |
| @@ -37,6 +38,13 @@ _FAKE_DOCS = { | @@ -37,6 +38,13 @@ _FAKE_DOCS = { | ||
| 37 | "test_script": "assert True # fake but real, compilable script", | 38 | "test_script": "assert True # fake but real, compilable script", |
| 38 | } | 39 | } |
| 39 | 40 | ||
| 41 | +# test_script disabled 2026-08-07 (docs/todo/validator-test-script- | ||
| 42 | +# narrative-output-issue.md) -- _PROMPT_TEMPLATE no longer asks for it, so | ||
| 43 | +# the realistic fake planning response matches that. Tests that still need | ||
| 44 | +# to exercise a test_script node (e.g. the partial-failure salvage | ||
| 45 | +# mechanism, which is generic and doesn't care whether test_script is | ||
| 46 | +# currently enabled by default) build their own local manifest instead of | ||
| 47 | +# using this shared fixture. | ||
| 40 | _FAKE_GRAPH_MANIFEST = { | 48 | _FAKE_GRAPH_MANIFEST = { |
| 41 | "nodes": [ | 49 | "nodes": [ |
| 42 | {"node_id": "brd", "artifact_type": "brd", "depends_on": []}, | 50 | {"node_id": "brd", "artifact_type": "brd", "depends_on": []}, |
| @@ -44,14 +52,44 @@ _FAKE_GRAPH_MANIFEST = { | @@ -44,14 +52,44 @@ _FAKE_GRAPH_MANIFEST = { | ||
| 44 | {"node_id": "sdd", "artifact_type": "sdd", "depends_on": ["prd"]}, | 52 | {"node_id": "sdd", "artifact_type": "sdd", "depends_on": ["prd"]}, |
| 45 | {"node_id": "benchmark", "artifact_type": "benchmark", "depends_on": ["sdd"]}, | 53 | {"node_id": "benchmark", "artifact_type": "benchmark", "depends_on": ["sdd"]}, |
| 46 | {"node_id": "test_dataset", "artifact_type": "test_dataset", "depends_on": ["benchmark"]}, | 54 | {"node_id": "test_dataset", "artifact_type": "test_dataset", "depends_on": ["benchmark"]}, |
| 47 | - {"node_id": "test_script", "artifact_type": "test_script", "depends_on": ["test_dataset"]}, | ||
| 48 | ], | 55 | ], |
| 49 | "edges": [ | 56 | "edges": [ |
| 50 | {"from": "brd", "to": "prd", "edge_type": "needs_judgment"}, | 57 | {"from": "brd", "to": "prd", "edge_type": "needs_judgment"}, |
| 51 | {"from": "prd", "to": "sdd", "edge_type": "needs_judgment"}, | 58 | {"from": "prd", "to": "sdd", "edge_type": "needs_judgment"}, |
| 52 | {"from": "sdd", "to": "benchmark", "edge_type": "needs_judgment"}, | 59 | {"from": "sdd", "to": "benchmark", "edge_type": "needs_judgment"}, |
| 53 | {"from": "benchmark", "to": "test_dataset", "edge_type": "needs_judgment"}, | 60 | {"from": "benchmark", "to": "test_dataset", "edge_type": "needs_judgment"}, |
| 61 | + ], | ||
| 62 | +} | ||
| 63 | + | ||
| 64 | +# Only used by tests that need a test_script node present despite it being | ||
| 65 | +# disabled by default (see _FAKE_GRAPH_MANIFEST's comment above). | ||
| 66 | +_FAKE_GRAPH_MANIFEST_WITH_TEST_SCRIPT = { | ||
| 67 | + "nodes": _FAKE_GRAPH_MANIFEST["nodes"] + [ | ||
| 68 | + # depends_on both -- _DOC_PROMPTS["test_script"] needs | ||
| 69 | + # {benchmark_content} directly, not just what survives into | ||
| 70 | + # test_dataset_content. work_graph_plan._normalize_graph_manifest | ||
| 71 | + # used to auto-inject this second dependency for the canonical | ||
| 72 | + # chain's test_script node; that logic was removed along with | ||
| 73 | + # test_script from _CANONICAL_DOC_CHAIN (2026-08-07), so this fixture | ||
| 74 | + # sets it explicitly instead of relying on any auto-injection. | ||
| 75 | + # 2026-08-08: `judgment` is required now -- plan_documents() no | ||
| 76 | + # longer passes an external verify_fn at all (Work Graph | ||
| 77 | + # self-planning + loop-back, FR-B1); a node only gets checked if it | ||
| 78 | + # declares its own judgment, same as a real self-planned node would | ||
| 79 | + # by reusing work_graph_plan._PLANNER_NODE_CATALOG's suggestion. | ||
| 80 | + { | ||
| 81 | + "node_id": "test_script", "artifact_type": "test_script", | ||
| 82 | + "depends_on": ["test_dataset", "benchmark"], | ||
| 83 | + "judgment": {"kind": "mechanical", "check_ref": "check_script_compiles"}, | ||
| 84 | + # max_attempts=1 -- "1 retry allowed" (matches the legacy exactly- | ||
| 85 | + # one-retry default): exactly 2 real executions before exceeding, | ||
| 86 | + # matching this fixture's tests providing exactly 2 mock responses. | ||
| 87 | + "on_fail": {"action": "retry_self", "max_attempts": 1}, | ||
| 88 | + }, | ||
| 89 | + ], | ||
| 90 | + "edges": _FAKE_GRAPH_MANIFEST["edges"] + [ | ||
| 54 | {"from": "test_dataset", "to": "test_script", "edge_type": "needs_judgment"}, | 91 | {"from": "test_dataset", "to": "test_script", "edge_type": "needs_judgment"}, |
| 92 | + {"from": "benchmark", "to": "test_script", "edge_type": "needs_judgment"}, | ||
| 55 | ], | 93 | ], |
| 56 | } | 94 | } |
| 57 | 95 | ||
| @@ -61,6 +99,14 @@ def _fake_plan_subprocess_run(*args, **kwargs): | @@ -61,6 +99,14 @@ def _fake_plan_subprocess_run(*args, **kwargs): | ||
| 61 | return subprocess.CompletedProcess(args=["claude"], returncode=0, stdout=json.dumps(payload), stderr="") | 99 | return subprocess.CompletedProcess(args=["claude"], returncode=0, stdout=json.dumps(payload), stderr="") |
| 62 | 100 | ||
| 63 | 101 | ||
| 102 | +def _fake_plan_subprocess_run_with_test_script(*args, **kwargs): | ||
| 103 | + payload = { | ||
| 104 | + "is_error": False, | ||
| 105 | + "result": "```json\n" + json.dumps(_FAKE_GRAPH_MANIFEST_WITH_TEST_SCRIPT) + "\n```", | ||
| 106 | + } | ||
| 107 | + return subprocess.CompletedProcess(args=["claude"], returncode=0, stdout=json.dumps(payload), stderr="") | ||
| 108 | + | ||
| 109 | + | ||
| 64 | def _fake_ready_trigger(org_node, prompt, target_folder_path, timeout=300): | 110 | def _fake_ready_trigger(org_node, prompt, target_folder_path, timeout=300): |
| 65 | assert prompt == e2e_pipeline._READY_PROMPT | 111 | assert prompt == e2e_pipeline._READY_PROMPT |
| 66 | return "Ready." | 112 | return "Ready." |
| @@ -80,7 +126,7 @@ class PipelineTestBase(unittest.TestCase): | @@ -80,7 +126,7 @@ class PipelineTestBase(unittest.TestCase): | ||
| 80 | 126 | ||
| 81 | 127 | ||
| 82 | class PlanDocumentsTest(PipelineTestBase): | 128 | class PlanDocumentsTest(PipelineTestBase): |
| 83 | - def test_produces_six_artifacts_with_correct_lineage(self): | 129 | + def test_produces_five_artifacts_with_correct_lineage(self): |
| 84 | self.conn.execute( | 130 | self.conn.execute( |
| 85 | "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) " | 131 | "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) " |
| 86 | "VALUES ('task-1', (SELECT project_id FROM project), '实现贪吃蛇游戏', 'goal', " | 132 | "VALUES ('task-1', (SELECT project_id FROM project), '实现贪吃蛇游戏', 'goal', " |
| @@ -95,12 +141,12 @@ class PlanDocumentsTest(PipelineTestBase): | @@ -95,12 +141,12 @@ class PlanDocumentsTest(PipelineTestBase): | ||
| 95 | {"content": _FAKE_DOCS["brd"]}, {"content": _FAKE_DOCS["prd"]}, | 141 | {"content": _FAKE_DOCS["brd"]}, {"content": _FAKE_DOCS["prd"]}, |
| 96 | {"consistent": True, "issues": []}, # PRD consistency check (REQ-02 boundary) | 142 | {"consistent": True, "issues": []}, # PRD consistency check (REQ-02 boundary) |
| 97 | {"content": _FAKE_DOCS["sdd"]}, {"content": _FAKE_DOCS["benchmark"]}, | 143 | {"content": _FAKE_DOCS["sdd"]}, {"content": _FAKE_DOCS["benchmark"]}, |
| 98 | - {"content": _FAKE_DOCS["test_dataset"]}, {"content": _FAKE_DOCS["test_script"]}, | 144 | + {"content": _FAKE_DOCS["test_dataset"]}, |
| 99 | ] | 145 | ] |
| 100 | plan_result = e2e_pipeline.plan_documents(self.conn, "task-1", "实现贪吃蛇游戏", self.tmp.name) | 146 | plan_result = e2e_pipeline.plan_documents(self.conn, "task-1", "实现贪吃蛇游戏", self.tmp.name) |
| 101 | 147 | ||
| 102 | artifact_ids = plan_result["artifact_ids"] | 148 | artifact_ids = plan_result["artifact_ids"] |
| 103 | - self.assertEqual(set(artifact_ids), {"brd", "prd", "sdd", "benchmark", "test_dataset", "test_script"}) | 149 | + self.assertEqual(set(artifact_ids), {"brd", "prd", "sdd", "benchmark", "test_dataset"}) |
| 104 | prd_deps = json.loads( | 150 | prd_deps = json.loads( |
| 105 | self.conn.execute( | 151 | self.conn.execute( |
| 106 | "SELECT depends_on_artifact_ids FROM artifact WHERE artifact_id = ?", (artifact_ids["prd"],) | 152 | "SELECT depends_on_artifact_ids FROM artifact WHERE artifact_id = ?", (artifact_ids["prd"],) |
| @@ -127,6 +173,12 @@ class PlanDocumentsTest(PipelineTestBase): | @@ -127,6 +173,12 @@ class PlanDocumentsTest(PipelineTestBase): | ||
| 127 | # still propagate (failure semantics unchanged), but BRD/PRD/SDD/ | 173 | # still propagate (failure semantics unchanged), but BRD/PRD/SDD/ |
| 128 | # Benchmark/TestDataset -- all genuinely produced and accepted -- | 174 | # Benchmark/TestDataset -- all genuinely produced and accepted -- |
| 129 | # must be persisted as artifacts, not silently discarded. | 175 | # must be persisted as artifacts, not silently discarded. |
| 176 | + # | ||
| 177 | + # test_script itself is disabled by default as of the same date | ||
| 178 | + # (docs/todo/validator-test-script-narrative-output-issue.md) -- this | ||
| 179 | + # test uses _fake_plan_subprocess_run_with_test_script to keep | ||
| 180 | + # exercising the salvage mechanism itself (generic, not specific to | ||
| 181 | + # test_script) rather than losing this coverage entirely. | ||
| 130 | self.conn.execute( | 182 | self.conn.execute( |
| 131 | "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) " | 183 | "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) " |
| 132 | "VALUES ('task-1', (SELECT project_id FROM project), 'goal', 'goal', " | 184 | "VALUES ('task-1', (SELECT project_id FROM project), 'goal', 'goal', " |
| @@ -134,23 +186,23 @@ class PlanDocumentsTest(PipelineTestBase): | @@ -134,23 +186,23 @@ class PlanDocumentsTest(PipelineTestBase): | ||
| 134 | ) | 186 | ) |
| 135 | self.conn.commit() | 187 | self.conn.commit() |
| 136 | 188 | ||
| 137 | - # _fake_plan_subprocess_run ignores its args and always returns a | ||
| 138 | - # canned success payload -- fine for the "claude" planning call it's | ||
| 139 | - # meant for, but `patch("src.work_graph_plan.subprocess.run", ...)` | ||
| 140 | - # patches the ONE global `subprocess` module every other importer | ||
| 141 | - # shares too (work_graph_plan does `import subprocess`, not | ||
| 142 | - # `from subprocess import run`), so it would ALSO silently hijack | ||
| 143 | - # check_script_compiles's real `py_compile`/`node --check` calls, | ||
| 144 | - # making them always report success regardless of actual content -- | ||
| 145 | - # exactly defeating the failure this test needs to trigger. Only | ||
| 146 | - # intercept the "claude" planning call; let everything else | ||
| 147 | - # (including this test's real compile checks) run for real. | 189 | + # _fake_plan_subprocess_run_with_test_script ignores its args and |
| 190 | + # always returns a canned success payload -- fine for the "claude" | ||
| 191 | + # planning call it's meant for, but `patch("src.work_graph_plan. | ||
| 192 | + # subprocess.run", ...)` patches the ONE global `subprocess` module | ||
| 193 | + # every other importer shares too (work_graph_plan does `import | ||
| 194 | + # subprocess`, not `from subprocess import run`), so it would ALSO | ||
| 195 | + # silently hijack check_script_compiles's real `py_compile`/`node | ||
| 196 | + # --check` calls, making them always report success regardless of | ||
| 197 | + # actual content -- exactly defeating the failure this test needs to | ||
| 198 | + # trigger. Only intercept the "claude" planning call; let everything | ||
| 199 | + # else (including this test's real compile checks) run for real. | ||
| 148 | real_subprocess_run = subprocess.run | 200 | real_subprocess_run = subprocess.run |
| 149 | 201 | ||
| 150 | def _discriminating_fake(*args, **kwargs): | 202 | def _discriminating_fake(*args, **kwargs): |
| 151 | cmd = args[0] if args else kwargs.get("args") | 203 | cmd = args[0] if args else kwargs.get("args") |
| 152 | if cmd and cmd[0] == "claude": | 204 | if cmd and cmd[0] == "claude": |
| 153 | - return _fake_plan_subprocess_run(*args, **kwargs) | 205 | + return _fake_plan_subprocess_run_with_test_script(*args, **kwargs) |
| 154 | return real_subprocess_run(*args, **kwargs) | 206 | return real_subprocess_run(*args, **kwargs) |
| 155 | 207 | ||
| 156 | with patch("src.work_graph_plan.subprocess.run", side_effect=_discriminating_fake), \ | 208 | with patch("src.work_graph_plan.subprocess.run", side_effect=_discriminating_fake), \ |
| @@ -184,20 +236,19 @@ class RunEndToEndTest(PipelineTestBase): | @@ -184,20 +236,19 @@ class RunEndToEndTest(PipelineTestBase): | ||
| 184 | {"content": _FAKE_DOCS["brd"]}, {"content": _FAKE_DOCS["prd"]}, | 236 | {"content": _FAKE_DOCS["brd"]}, {"content": _FAKE_DOCS["prd"]}, |
| 185 | {"consistent": True, "issues": []}, # PRD consistency check (REQ-02 boundary) | 237 | {"consistent": True, "issues": []}, # PRD consistency check (REQ-02 boundary) |
| 186 | {"content": _FAKE_DOCS["sdd"]}, {"content": _FAKE_DOCS["benchmark"]}, | 238 | {"content": _FAKE_DOCS["sdd"]}, {"content": _FAKE_DOCS["benchmark"]}, |
| 187 | - {"content": _FAKE_DOCS["test_dataset"]}, {"content": _FAKE_DOCS["test_script"]}, | 239 | + {"content": _FAKE_DOCS["test_dataset"]}, |
| 188 | {"response": "Implemented. Ran `pytest`: 3 passed."}, | 240 | {"response": "Implemented. Ran `pytest`: 3 passed."}, |
| 189 | - # Validator's OWN independent benchmark/dataset/script (2026-08-06) -- | 241 | + # Validator's OWN independent benchmark/dataset (2026-08-06) -- |
| 190 | # deliberately different content from the Planner's, to also prove | 242 | # deliberately different content from the Planner's, to also prove |
| 191 | # nothing here accidentally reuses the Planner's copies. | 243 | # nothing here accidentally reuses the Planner's copies. |
| 192 | {"content": "# Validator's own Benchmark\nfake"}, | 244 | {"content": "# Validator's own Benchmark\nfake"}, |
| 193 | {"content": "N/A: validator judged no dataset needed either"}, | 245 | {"content": "N/A: validator judged no dataset needed either"}, |
| 194 | - {"content": "assert True # validator's own real, compilable script"}, | ||
| 195 | ] | 246 | ] |
| 196 | result = e2e_pipeline.run_end_to_end(self.conn, self.tmp.name, "实现贪吃蛇游戏") | 247 | result = e2e_pipeline.run_end_to_end(self.conn, self.tmp.name, "实现贪吃蛇游戏") |
| 197 | 248 | ||
| 198 | self.assertEqual(result["verdict"]["verdict"], "PASS") | 249 | self.assertEqual(result["verdict"]["verdict"], "PASS") |
| 199 | self.assertEqual( | 250 | self.assertEqual( |
| 200 | - set(result["doc_artifact_ids"]), {"brd", "prd", "sdd", "benchmark", "test_dataset", "test_script"} | 251 | + set(result["doc_artifact_ids"]), {"brd", "prd", "sdd", "benchmark", "test_dataset"} |
| 201 | ) | 252 | ) |
| 202 | self.assertEqual(set(result["code_artifact_ids"]), {"code_diff", "test_report"}) | 253 | self.assertEqual(set(result["code_artifact_ids"]), {"code_diff", "test_report"}) |
| 203 | 254 | ||
| @@ -209,9 +260,10 @@ class RunEndToEndTest(PipelineTestBase): | @@ -209,9 +260,10 @@ class RunEndToEndTest(PipelineTestBase): | ||
| 209 | all_artifacts = self.conn.execute( | 260 | all_artifacts = self.conn.execute( |
| 210 | "SELECT COUNT(*) FROM artifact WHERE task_id = ?", (result["task_id"],) | 261 | "SELECT COUNT(*) FROM artifact WHERE task_id = ?", (result["task_id"],) |
| 211 | ).fetchone()[0] | 262 | ).fetchone()[0] |
| 212 | - # brd, prd, sdd, benchmark, test_dataset, test_script, code_diff, test_report, | ||
| 213 | - # validator_benchmark, validator_test_dataset, validator_test_script | ||
| 214 | - self.assertEqual(all_artifacts, 11) | 263 | + # brd, prd, sdd, benchmark, test_dataset, code_diff, test_report, |
| 264 | + # validator_benchmark, validator_test_dataset | ||
| 265 | + # (test_script/validator_test_script disabled, see _VALIDATOR_MANIFEST) | ||
| 266 | + self.assertEqual(all_artifacts, 9) | ||
| 215 | 267 | ||
| 216 | # planner, coder, AND validator (2026-08-06: validator now has its own | 268 | # planner, coder, AND validator (2026-08-06: validator now has its own |
| 217 | # real work_graph_run too, not a flat direct call) each got their own | 269 | # real work_graph_run too, not a flat direct call) each got their own |
| @@ -338,6 +390,33 @@ class TestScriptVerifyFnTest(unittest.TestCase): | @@ -338,6 +390,33 @@ class TestScriptVerifyFnTest(unittest.TestCase): | ||
| 338 | self.assertTrue(ok) | 390 | self.assertTrue(ok) |
| 339 | 391 | ||
| 340 | 392 | ||
| 393 | +class PlannerPromptBuilderTest(unittest.TestCase): | ||
| 394 | + """2026-08-08: found via a real self-planned run whose SDD node had no | ||
| 395 | + PRD node at all (a legitimate Work Graph self-planning choice, FR-B4 | ||
| 396 | + "全部自由规划") -- _DOC_PROMPTS["sdd"] hardcodes a {prd_content} | ||
| 397 | + placeholder left over from the old fixed chain, and plain .format() | ||
| 398 | + raised a bare KeyError, landing the task on blocked_pending_human with | ||
| 399 | + an unhelpful generic 'execution_error' reason instead of a real result.""" | ||
| 400 | + | ||
| 401 | + def test_missing_upstream_dependency_content_does_not_raise(self): | ||
| 402 | + node_artifact_types = {"sdd": "sdd"} | ||
| 403 | + build_prompt = e2e_pipeline._planner_prompt_builder("fix a bug", node_artifact_types) | ||
| 404 | + # sdd's depends_on is empty -- no prd node was ever planned, so | ||
| 405 | + # {prd_content} has nothing to fill it. Must not raise KeyError. | ||
| 406 | + prompt = build_prompt({"node_id": "sdd", "depends_on": []}, {}) | ||
| 407 | + self.assertIn("N/A (no corresponding upstream node was included in this Work Graph)", prompt) | ||
| 408 | + self.assertIn("fix a bug", prompt) | ||
| 409 | + | ||
| 410 | + def test_present_upstream_dependency_content_is_used_normally(self): | ||
| 411 | + node_artifact_types = {"prd": "prd", "sdd": "sdd"} | ||
| 412 | + build_prompt = e2e_pipeline._planner_prompt_builder("goal", node_artifact_types) | ||
| 413 | + prompt = build_prompt( | ||
| 414 | + {"node_id": "sdd", "depends_on": ["prd"]}, {"prd": {"content": "real prd text"}} | ||
| 415 | + ) | ||
| 416 | + self.assertIn("real prd text", prompt) | ||
| 417 | + self.assertNotIn("N/A (no corresponding", prompt) | ||
| 418 | + | ||
| 419 | + | ||
| 341 | class CombinedPlannerVerifyFnTest(unittest.TestCase): | 420 | class CombinedPlannerVerifyFnTest(unittest.TestCase): |
| 342 | """Dispatches to the right per-node-type check -- both real checks | 421 | """Dispatches to the right per-node-type check -- both real checks |
| 343 | (PRD consistency and script-compiles) are exercised elsewhere in | 422 | (PRD consistency and script-compiles) are exercised elsewhere in |
| @@ -494,6 +573,28 @@ class RunTestScriptForRealTest(unittest.TestCase): | @@ -494,6 +573,28 @@ class RunTestScriptForRealTest(unittest.TestCase): | ||
| 494 | self.assertTrue(os.path.exists(expected)) | 573 | self.assertTrue(os.path.exists(expected)) |
| 495 | 574 | ||
| 496 | 575 | ||
| 576 | +# validator_test_script disabled by default 2026-08-07 (see | ||
| 577 | +# e2e_pipeline._VALIDATOR_MANIFEST's own comment) -- tests below that need | ||
| 578 | +# to exercise the hard-gate/salvage mechanisms involving that node patch | ||
| 579 | +# _VALIDATOR_MANIFEST back to this 3-node shape for their own duration, so | ||
| 580 | +# that real code (still in the codebase, just not planned by default) stays | ||
| 581 | +# covered instead of silently rotting. | ||
| 582 | +_VALIDATOR_MANIFEST_WITH_TEST_SCRIPT = { | ||
| 583 | + "nodes": [ | ||
| 584 | + {"node_id": "validator_benchmark", "artifact_type": "validator_benchmark", "depends_on": []}, | ||
| 585 | + { | ||
| 586 | + "node_id": "validator_test_dataset", "artifact_type": "validator_test_dataset", | ||
| 587 | + "depends_on": ["validator_benchmark"], | ||
| 588 | + }, | ||
| 589 | + { | ||
| 590 | + "node_id": "validator_test_script", "artifact_type": "validator_test_script", | ||
| 591 | + "depends_on": ["validator_test_dataset", "validator_benchmark"], | ||
| 592 | + }, | ||
| 593 | + ], | ||
| 594 | + "edges": [], | ||
| 595 | +} | ||
| 596 | + | ||
| 597 | + | ||
| 497 | class ValidateImplementationWithOwnGraphTest(unittest.TestCase): | 598 | class ValidateImplementationWithOwnGraphTest(unittest.TestCase): |
| 498 | """2026-08-06: the safety-critical piece -- a real non-zero exit from | 599 | """2026-08-06: the safety-critical piece -- a real non-zero exit from |
| 499 | Validator's own script must force FAIL no matter what the LLM's verdict | 600 | Validator's own script must force FAIL no matter what the LLM's verdict |
| @@ -515,21 +616,28 @@ class ValidateImplementationWithOwnGraphTest(unittest.TestCase): | @@ -515,21 +616,28 @@ class ValidateImplementationWithOwnGraphTest(unittest.TestCase): | ||
| 515 | def tearDown(self): | 616 | def tearDown(self): |
| 516 | self.conn.close() | 617 | self.conn.close() |
| 517 | 618 | ||
| 518 | - def _run(self, script_content, verdict_line): | ||
| 519 | - with patch.object(work_graph_execute, "write_workflow_script"), \ | ||
| 520 | - patch.object(work_graph_execute, "execute_work_graph") as mock_execute, \ | ||
| 521 | - patch.object(org_node_trigger, "trigger_org_node", return_value=verdict_line): | ||
| 522 | - mock_execute.side_effect = [ | ||
| 523 | - {"content": "# Validator's own Benchmark"}, | ||
| 524 | - {"content": "N/A: no dataset needed"}, | ||
| 525 | - {"content": script_content}, | ||
| 526 | - ] | 619 | + def _run(self, script_content, verdict_line, with_test_script=False): |
| 620 | + mock_responses = [ | ||
| 621 | + {"content": "# Validator's own Benchmark"}, | ||
| 622 | + {"content": "N/A: no dataset needed"}, | ||
| 623 | + ] | ||
| 624 | + if with_test_script: | ||
| 625 | + mock_responses.append({"content": script_content}) | ||
| 626 | + with contextlib.ExitStack() as stack: | ||
| 627 | + stack.enter_context(patch.object(work_graph_execute, "write_workflow_script")) | ||
| 628 | + mock_execute = stack.enter_context(patch.object(work_graph_execute, "execute_work_graph")) | ||
| 629 | + stack.enter_context(patch.object(org_node_trigger, "trigger_org_node", return_value=verdict_line)) | ||
| 630 | + if with_test_script: | ||
| 631 | + stack.enter_context( | ||
| 632 | + patch.object(e2e_pipeline, "_VALIDATOR_MANIFEST", _VALIDATOR_MANIFEST_WITH_TEST_SCRIPT) | ||
| 633 | + ) | ||
| 634 | + mock_execute.side_effect = mock_responses | ||
| 527 | return e2e_pipeline.validate_implementation_with_own_graph( | 635 | return e2e_pipeline.validate_implementation_with_own_graph( |
| 528 | self.conn, "task-1", "sdd content", "prd content", "goal text", self.tmp.name, | 636 | self.conn, "task-1", "sdd content", "prd content", "goal text", self.tmp.name, |
| 529 | ) | 637 | ) |
| 530 | 638 | ||
| 531 | def test_llm_says_pass_and_script_really_passes_stays_pass(self): | 639 | def test_llm_says_pass_and_script_really_passes_stays_pass(self): |
| 532 | - result = self._run("import sys\nsys.exit(0)\n", "VERDICT: PASS\nlooks good") | 640 | + result = self._run("import sys\nsys.exit(0)\n", "VERDICT: PASS\nlooks good", with_test_script=True) |
| 533 | self.assertEqual(result["verdict"], "PASS") | 641 | self.assertEqual(result["verdict"], "PASS") |
| 534 | self.assertTrue(result["script_execution"]["ran"]) | 642 | self.assertTrue(result["script_execution"]["ran"]) |
| 535 | self.assertEqual(result["script_execution"]["exit_code"], 0) | 643 | self.assertEqual(result["script_execution"]["exit_code"], 0) |
| @@ -538,7 +646,9 @@ class ValidateImplementationWithOwnGraphTest(unittest.TestCase): | @@ -538,7 +646,9 @@ class ValidateImplementationWithOwnGraphTest(unittest.TestCase): | ||
| 538 | # the exact case the user's hard-gate rule exists for: a script | 646 | # the exact case the user's hard-gate rule exists for: a script |
| 539 | # that objectively fails must not be argued away by the LLM's own | 647 | # that objectively fails must not be argued away by the LLM's own |
| 540 | # verdict line. | 648 | # verdict line. |
| 541 | - result = self._run("import sys\nsys.exit(1)\n", "VERDICT: PASS\nlooks good to me") | 649 | + result = self._run( |
| 650 | + "import sys\nsys.exit(1)\n", "VERDICT: PASS\nlooks good to me", with_test_script=True | ||
| 651 | + ) | ||
| 542 | self.assertEqual(result["verdict"], "FAIL") | 652 | self.assertEqual(result["verdict"], "FAIL") |
| 543 | 653 | ||
| 544 | def test_llm_says_fail_and_script_fails_stays_fail(self): | 654 | def test_llm_says_fail_and_script_fails_stays_fail(self): |
| @@ -550,8 +660,21 @@ class ValidateImplementationWithOwnGraphTest(unittest.TestCase): | @@ -550,8 +660,21 @@ class ValidateImplementationWithOwnGraphTest(unittest.TestCase): | ||
| 550 | self.assertEqual(result["verdict"], "INCONCLUSIVE") | 660 | self.assertEqual(result["verdict"], "INCONCLUSIVE") |
| 551 | self.assertFalse(result["script_execution"]["ran"]) | 661 | self.assertFalse(result["script_execution"]["ran"]) |
| 552 | 662 | ||
| 553 | - def test_writes_three_validator_artifacts_with_lineage(self): | 663 | + def test_writes_two_validator_artifacts_with_lineage(self): |
| 664 | + # test_script disabled by default -- see _VALIDATOR_MANIFEST_WITH_ | ||
| 665 | + # TEST_SCRIPT variant below for the 3-artifact/script-lineage case. | ||
| 554 | result = self._run("import sys\nsys.exit(0)\n", "VERDICT: PASS\nlooks good") | 666 | result = self._run("import sys\nsys.exit(0)\n", "VERDICT: PASS\nlooks good") |
| 667 | + self.assertEqual(set(result["artifact_ids"]), {"validator_benchmark", "validator_test_dataset"}) | ||
| 668 | + dataset_deps = json.loads( | ||
| 669 | + self.conn.execute( | ||
| 670 | + "SELECT depends_on_artifact_ids FROM artifact WHERE artifact_id = ?", | ||
| 671 | + (result["artifact_ids"]["validator_test_dataset"],), | ||
| 672 | + ).fetchone()[0] | ||
| 673 | + ) | ||
| 674 | + self.assertIn(result["artifact_ids"]["validator_benchmark"], dataset_deps) | ||
| 675 | + | ||
| 676 | + def test_writes_three_validator_artifacts_with_lineage_when_test_script_re_enabled(self): | ||
| 677 | + result = self._run("import sys\nsys.exit(0)\n", "VERDICT: PASS\nlooks good", with_test_script=True) | ||
| 555 | self.assertEqual( | 678 | self.assertEqual( |
| 556 | set(result["artifact_ids"]), | 679 | set(result["artifact_ids"]), |
| 557 | {"validator_benchmark", "validator_test_dataset", "validator_test_script"}, | 680 | {"validator_benchmark", "validator_test_dataset", "validator_test_script"}, |
| @@ -619,8 +742,12 @@ class ValidateImplementationWithOwnGraphTest(unittest.TestCase): | @@ -619,8 +742,12 @@ class ValidateImplementationWithOwnGraphTest(unittest.TestCase): | ||
| 619 | # -- ConsistencyCheckFailedError must still propagate, but | 742 | # -- ConsistencyCheckFailedError must still propagate, but |
| 620 | # validator_benchmark/validator_test_dataset (genuinely produced | 743 | # validator_benchmark/validator_test_dataset (genuinely produced |
| 621 | # and accepted) must be persisted, not discarded along with it. | 744 | # and accepted) must be persisted, not discarded along with it. |
| 745 | + # validator_test_script itself is disabled by default (see | ||
| 746 | + # _VALIDATOR_MANIFEST_WITH_TEST_SCRIPT above) -- re-enabled here to | ||
| 747 | + # keep the salvage mechanism (generic, not test_script-specific) covered. | ||
| 622 | with patch.object(work_graph_execute, "write_workflow_script"), \ | 748 | with patch.object(work_graph_execute, "write_workflow_script"), \ |
| 623 | - patch.object(work_graph_execute, "execute_work_graph") as mock_execute: | 749 | + patch.object(work_graph_execute, "execute_work_graph") as mock_execute, \ |
| 750 | + patch.object(e2e_pipeline, "_VALIDATOR_MANIFEST", _VALIDATOR_MANIFEST_WITH_TEST_SCRIPT): | ||
| 624 | mock_execute.side_effect = [ | 751 | mock_execute.side_effect = [ |
| 625 | {"content": "# Validator's own Benchmark"}, | 752 | {"content": "# Validator's own Benchmark"}, |
| 626 | {"content": "N/A: no dataset needed"}, | 753 | {"content": "N/A: no dataset needed"}, |
src/test_schema.py
| @@ -2,6 +2,8 @@ | @@ -2,6 +2,8 @@ | ||
| 2 | Done criterion (unique / foreign key / enum) are actually enforced. | 2 | Done criterion (unique / foreign key / enum) are actually enforced. |
| 3 | U-034 (PH5, 2026-08-02) added `deployment`/`deploy_health_signal` (17 tables | 3 | U-034 (PH5, 2026-08-02) added `deployment`/`deploy_health_signal` (17 tables |
| 4 | total) and widened `bug_record.root_cause_category` to accept `core_engine`. | 4 | total) and widened `bug_record.root_cause_category` to accept `core_engine`. |
| 5 | +2026-08-08 added `work_graph_loop_limit_config` (18 tables total, Work | ||
| 6 | +Graph self-planning + loop-back). | ||
| 5 | 7 | ||
| 6 | Run: python3 -m unittest src.test_schema -v | 8 | Run: python3 -m unittest src.test_schema -v |
| 7 | """ | 9 | """ |
| @@ -21,13 +23,13 @@ class SchemaCreationTest(unittest.TestCase): | @@ -21,13 +23,13 @@ class SchemaCreationTest(unittest.TestCase): | ||
| 21 | def tearDown(self): | 23 | def tearDown(self): |
| 22 | self.conn.close() | 24 | self.conn.close() |
| 23 | 25 | ||
| 24 | - def test_all_17_tables_created(self): | 26 | + def test_all_18_tables_created(self): |
| 25 | rows = self.conn.execute( | 27 | rows = self.conn.execute( |
| 26 | "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'" | 28 | "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'" |
| 27 | ).fetchall() | 29 | ).fetchall() |
| 28 | actual = {row[0] for row in rows} | 30 | actual = {row[0] for row in rows} |
| 29 | self.assertEqual(actual, set(schema.TABLE_NAMES)) | 31 | self.assertEqual(actual, set(schema.TABLE_NAMES)) |
| 30 | - self.assertEqual(len(schema.TABLE_NAMES), 17) | 32 | + self.assertEqual(len(schema.TABLE_NAMES), 18) |
| 31 | 33 | ||
| 32 | def test_schema_is_idempotent_to_reopen_on_disk(self): | 34 | def test_schema_is_idempotent_to_reopen_on_disk(self): |
| 33 | # not required by the Done criterion, but a schema that only works once | 35 | # not required by the Done criterion, but a schema that only works once |
| @@ -737,5 +739,115 @@ class MigrateRelaxOrgNodeConstraintsTest(unittest.TestCase): | @@ -737,5 +739,115 @@ class MigrateRelaxOrgNodeConstraintsTest(unittest.TestCase): | ||
| 737 | fresh.close() | 739 | fresh.close() |
| 738 | 740 | ||
| 739 | 741 | ||
| 742 | +class MigrateAddWorkGraphLoopLimitConfigTest(unittest.TestCase): | ||
| 743 | + """2026-08-08: real upgrade path for an already-populated database that | ||
| 744 | + predates work_graph_loop_limit_config (plain CREATE TABLE IF NOT | ||
| 745 | + EXISTS, no CHECK constraint to rebuild around).""" | ||
| 746 | + | ||
| 747 | + def setUp(self): | ||
| 748 | + self.conn = schema.connect(":memory:") | ||
| 749 | + old_sql = schema.SCHEMA_SQL.replace( | ||
| 750 | + "-- 1.11.1 work_graph_loop_limit_config (Work Graph self-planning + loop-back,\n" | ||
| 751 | + "-- docs/todo/work-graph-self-planning-and-loop-back-design.md §6)\n" | ||
| 752 | + "CREATE TABLE work_graph_loop_limit_config (\n" | ||
| 753 | + " config_id TEXT PRIMARY KEY,\n" | ||
| 754 | + " max_loop_attempts INTEGER NOT NULL,\n" | ||
| 755 | + " updated_by TEXT,\n" | ||
| 756 | + " updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP\n" | ||
| 757 | + ");\n\n", | ||
| 758 | + "", | ||
| 759 | + ) | ||
| 760 | + self.assertNotIn("work_graph_loop_limit_config", old_sql) # sanity: substitution matched | ||
| 761 | + self.conn.executescript(old_sql) | ||
| 762 | + self.conn.commit() | ||
| 763 | + | ||
| 764 | + def tearDown(self): | ||
| 765 | + self.conn.close() | ||
| 766 | + | ||
| 767 | + def test_migration_creates_the_table(self): | ||
| 768 | + schema.migrate_add_work_graph_loop_limit_config(self.conn) | ||
| 769 | + self.conn.execute( | ||
| 770 | + "INSERT INTO work_graph_loop_limit_config (config_id, max_loop_attempts, updated_by) " | ||
| 771 | + "VALUES ('default', 3, 'test')" | ||
| 772 | + ) | ||
| 773 | + self.conn.commit() | ||
| 774 | + row = self.conn.execute( | ||
| 775 | + "SELECT max_loop_attempts FROM work_graph_loop_limit_config WHERE config_id = 'default'" | ||
| 776 | + ).fetchone() | ||
| 777 | + self.assertEqual(row[0], 3) | ||
| 778 | + | ||
| 779 | + def test_idempotent_on_a_database_already_migrated(self): | ||
| 780 | + schema.migrate_add_work_graph_loop_limit_config(self.conn) | ||
| 781 | + schema.migrate_add_work_graph_loop_limit_config(self.conn) # must not raise | ||
| 782 | + | ||
| 783 | + def test_noop_on_a_freshly_created_database(self): | ||
| 784 | + fresh = schema.connect(":memory:") | ||
| 785 | + try: | ||
| 786 | + schema.create_schema(fresh) | ||
| 787 | + schema.migrate_add_work_graph_loop_limit_config(fresh) # must not raise | ||
| 788 | + finally: | ||
| 789 | + fresh.close() | ||
| 790 | + | ||
| 791 | + | ||
| 792 | +class MigrateAddLoopBackEventTypeTest(unittest.TestCase): | ||
| 793 | + """2026-08-08: real upgrade path for an already-populated database whose | ||
| 794 | + execution_event table still has the pre-expansion CHECK constraint.""" | ||
| 795 | + | ||
| 796 | + def setUp(self): | ||
| 797 | + self.conn = schema.connect(":memory:") | ||
| 798 | + old_sql = schema.SCHEMA_SQL.replace( | ||
| 799 | + "'log_appended', 'quota_wait_started', 'loop_back_triggered'", | ||
| 800 | + "'log_appended', 'quota_wait_started'", | ||
| 801 | + ) | ||
| 802 | + self.assertNotIn("loop_back_triggered", old_sql) # sanity: substitution matched | ||
| 803 | + self.conn.executescript(old_sql) | ||
| 804 | + self.conn.commit() | ||
| 805 | + self.conn.execute( | ||
| 806 | + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) " | ||
| 807 | + "VALUES ('proj-1', 'P', '/tmp/proj-1', 1, 'active')" | ||
| 808 | + ) | ||
| 809 | + self.conn.execute( | ||
| 810 | + "INSERT INTO execution_event (event_id, project_id, event_type, payload) " | ||
| 811 | + "VALUES ('evt-1', 'proj-1', 'log_appended', '{}')" | ||
| 812 | + ) | ||
| 813 | + self.conn.commit() | ||
| 814 | + | ||
| 815 | + def tearDown(self): | ||
| 816 | + self.conn.close() | ||
| 817 | + | ||
| 818 | + def test_old_check_constraint_rejects_new_type_before_migration(self): | ||
| 819 | + with self.assertRaises(sqlite3.IntegrityError): | ||
| 820 | + self.conn.execute( | ||
| 821 | + "INSERT INTO execution_event (event_id, project_id, event_type, payload) " | ||
| 822 | + "VALUES ('evt-2', 'proj-1', 'loop_back_triggered', '{}')" | ||
| 823 | + ) | ||
| 824 | + | ||
| 825 | + def test_migration_preserves_existing_rows_and_accepts_new_type(self): | ||
| 826 | + schema.migrate_add_loop_back_event_type(self.conn) | ||
| 827 | + | ||
| 828 | + row = self.conn.execute("SELECT event_type FROM execution_event WHERE event_id = 'evt-1'").fetchone() | ||
| 829 | + self.assertEqual(row[0], "log_appended") | ||
| 830 | + | ||
| 831 | + self.conn.execute( | ||
| 832 | + "INSERT INTO execution_event (event_id, project_id, event_type, payload) " | ||
| 833 | + "VALUES ('evt-2', 'proj-1', 'loop_back_triggered', '{}')" | ||
| 834 | + ) | ||
| 835 | + self.conn.commit() | ||
| 836 | + count = self.conn.execute("SELECT COUNT(*) FROM execution_event").fetchone()[0] | ||
| 837 | + self.assertEqual(count, 2) | ||
| 838 | + | ||
| 839 | + def test_idempotent_on_a_database_already_migrated(self): | ||
| 840 | + schema.migrate_add_loop_back_event_type(self.conn) | ||
| 841 | + schema.migrate_add_loop_back_event_type(self.conn) # must not raise | ||
| 842 | + | ||
| 843 | + def test_noop_on_a_freshly_created_already_expanded_database(self): | ||
| 844 | + fresh = schema.connect(":memory:") | ||
| 845 | + try: | ||
| 846 | + schema.create_schema(fresh) | ||
| 847 | + schema.migrate_add_loop_back_event_type(fresh) # must not raise | ||
| 848 | + finally: | ||
| 849 | + fresh.close() | ||
| 850 | + | ||
| 851 | + | ||
| 740 | if __name__ == "__main__": | 852 | if __name__ == "__main__": |
| 741 | unittest.main() | 853 | unittest.main() |
src/test_settings_config.py
| @@ -49,6 +49,43 @@ class ConcurrencyConfigTest(SettingsConfigTestBase): | @@ -49,6 +49,43 @@ class ConcurrencyConfigTest(SettingsConfigTestBase): | ||
| 49 | settings_config.upsert_concurrency_config(self.conn, 50, "admin") | 49 | settings_config.upsert_concurrency_config(self.conn, 50, "admin") |
| 50 | 50 | ||
| 51 | 51 | ||
| 52 | +class LoopLimitConfigTest(SettingsConfigTestBase): | ||
| 53 | + """2026-08-08: Work Graph self-planning + loop-back (docs/todo/ | ||
| 54 | + work-graph-self-planning-and-loop-back-design.md §6). No implicit | ||
| 55 | + 'default' config_id -- callers always pass one explicitly (they look up | ||
| 56 | + "{org_node}:{node_type}", "{org_node}:*", "default" in turn).""" | ||
| 57 | + | ||
| 58 | + def test_get_returns_none_when_unconfigured(self): | ||
| 59 | + self.assertIsNone(settings_config.get_loop_limit_config(self.conn, "planner:benchmark")) | ||
| 60 | + | ||
| 61 | + def test_creates_config_on_first_use(self): | ||
| 62 | + result = settings_config.upsert_loop_limit_config(self.conn, "planner:benchmark", 5, "admin") | ||
| 63 | + self.assertEqual(result["max_loop_attempts"], 5) | ||
| 64 | + self.assertEqual( | ||
| 65 | + settings_config.get_loop_limit_config(self.conn, "planner:benchmark")["max_loop_attempts"], 5 | ||
| 66 | + ) | ||
| 67 | + | ||
| 68 | + def test_updates_existing_config(self): | ||
| 69 | + settings_config.upsert_loop_limit_config(self.conn, "planner:benchmark", 5, "admin") | ||
| 70 | + result = settings_config.upsert_loop_limit_config(self.conn, "planner:benchmark", 2, "admin2") | ||
| 71 | + self.assertEqual(result["max_loop_attempts"], 2) | ||
| 72 | + self.assertEqual( | ||
| 73 | + self.conn.execute("SELECT COUNT(*) FROM work_graph_loop_limit_config").fetchone()[0], 1 | ||
| 74 | + ) | ||
| 75 | + | ||
| 76 | + def test_different_config_ids_are_independent(self): | ||
| 77 | + settings_config.upsert_loop_limit_config(self.conn, "planner:benchmark", 5, "admin") | ||
| 78 | + settings_config.upsert_loop_limit_config(self.conn, "planner:*", 2, "admin") | ||
| 79 | + self.assertEqual( | ||
| 80 | + settings_config.get_loop_limit_config(self.conn, "planner:benchmark")["max_loop_attempts"], 5 | ||
| 81 | + ) | ||
| 82 | + self.assertEqual(settings_config.get_loop_limit_config(self.conn, "planner:*")["max_loop_attempts"], 2) | ||
| 83 | + | ||
| 84 | + def test_below_one_rejected(self): | ||
| 85 | + with self.assertRaises(settings_config.InvalidParamError): | ||
| 86 | + settings_config.upsert_loop_limit_config(self.conn, "default", 0, "admin") | ||
| 87 | + | ||
| 88 | + | ||
| 52 | class LogRetentionConfigTest(SettingsConfigTestBase): | 89 | class LogRetentionConfigTest(SettingsConfigTestBase): |
| 53 | def test_get_returns_none_when_unconfigured(self): | 90 | def test_get_returns_none_when_unconfigured(self): |
| 54 | self.assertIsNone(settings_config.get_log_retention_config(self.conn)) | 91 | self.assertIsNone(settings_config.get_log_retention_config(self.conn)) |
src/test_work_graph_orchestrate.py
| @@ -130,6 +130,78 @@ class TopologicalLayersTest(unittest.TestCase): | @@ -130,6 +130,78 @@ class TopologicalLayersTest(unittest.TestCase): | ||
| 130 | flat = [n["node_id"] for layer in layers for n in layer] | 130 | flat = [n["node_id"] for layer in layers for n in layer] |
| 131 | self.assertEqual([n["node_id"] for n in wgo.topological_order(manifest)], flat) | 131 | self.assertEqual([n["node_id"] for n in wgo.topological_order(manifest)], flat) |
| 132 | 132 | ||
| 133 | + def test_on_failure_edge_does_not_create_a_spurious_cycle(self): | ||
| 134 | + # 2026-08-08: an on_failure edge commonly points BACKWARD (from a | ||
| 135 | + # node to one of its own upstream dependencies) -- the old | ||
| 136 | + # unfiltered edge merge would have folded this in as a (reversed, | ||
| 137 | + # incorrect) data dependency, creating a real cycle with the | ||
| 138 | + # existing forward depends_on and making this raise ValueError. | ||
| 139 | + manifest = { | ||
| 140 | + "nodes": [ | ||
| 141 | + {"node_id": "sdd", "depends_on": []}, | ||
| 142 | + {"node_id": "benchmark", "depends_on": ["sdd"]}, | ||
| 143 | + ], | ||
| 144 | + "edges": [{"from": "benchmark", "to": "sdd", "edge_type": "on_failure"}], | ||
| 145 | + } | ||
| 146 | + layers = wgo.topological_layers(manifest) | ||
| 147 | + self.assertEqual([[n["node_id"] for n in layer] for layer in layers], [["sdd"], ["benchmark"]]) | ||
| 148 | + | ||
| 149 | + | ||
| 150 | +class TransitiveDependentsTest(unittest.TestCase): | ||
| 151 | + def test_includes_self_and_all_downstream(self): | ||
| 152 | + manifest = { | ||
| 153 | + "nodes": [ | ||
| 154 | + {"node_id": "a", "depends_on": []}, | ||
| 155 | + {"node_id": "b", "depends_on": ["a"]}, | ||
| 156 | + {"node_id": "c", "depends_on": ["b"]}, | ||
| 157 | + {"node_id": "d", "depends_on": []}, # unrelated branch | ||
| 158 | + ], | ||
| 159 | + "edges": [], | ||
| 160 | + } | ||
| 161 | + self.assertEqual(wgo._transitive_dependents(manifest, "a"), {"a", "b", "c"}) | ||
| 162 | + self.assertEqual(wgo._transitive_dependents(manifest, "c"), {"c"}) | ||
| 163 | + self.assertEqual(wgo._transitive_dependents(manifest, "d"), {"d"}) | ||
| 164 | + | ||
| 165 | + def test_ignores_on_failure_edges(self): | ||
| 166 | + manifest = { | ||
| 167 | + "nodes": [ | ||
| 168 | + {"node_id": "sdd", "depends_on": []}, | ||
| 169 | + {"node_id": "benchmark", "depends_on": ["sdd"]}, | ||
| 170 | + ], | ||
| 171 | + "edges": [{"from": "benchmark", "to": "sdd", "edge_type": "on_failure"}], | ||
| 172 | + } | ||
| 173 | + # sdd's dependents must be {sdd, benchmark} via the real depends_on | ||
| 174 | + # edge, not affected by the on_failure edge going the other way. | ||
| 175 | + self.assertEqual(wgo._transitive_dependents(manifest, "sdd"), {"sdd", "benchmark"}) | ||
| 176 | + | ||
| 177 | + | ||
| 178 | +class TransitiveAncestorsTest(unittest.TestCase): | ||
| 179 | + def test_includes_self_and_all_upstream(self): | ||
| 180 | + manifest = { | ||
| 181 | + "nodes": [ | ||
| 182 | + {"node_id": "a", "depends_on": []}, | ||
| 183 | + {"node_id": "b", "depends_on": ["a"]}, | ||
| 184 | + {"node_id": "c", "depends_on": ["b"]}, | ||
| 185 | + ], | ||
| 186 | + "edges": [], | ||
| 187 | + } | ||
| 188 | + self.assertEqual(wgo._transitive_ancestors(manifest, "c"), {"a", "b", "c"}) | ||
| 189 | + self.assertEqual(wgo._transitive_ancestors(manifest, "a"), {"a"}) | ||
| 190 | + | ||
| 191 | + | ||
| 192 | +class OnFailureTargetTest(unittest.TestCase): | ||
| 193 | + def test_finds_the_on_failure_edge_target(self): | ||
| 194 | + manifest = { | ||
| 195 | + "nodes": [{"node_id": "sdd"}, {"node_id": "benchmark"}], | ||
| 196 | + "edges": [{"from": "benchmark", "to": "sdd", "edge_type": "on_failure"}], | ||
| 197 | + } | ||
| 198 | + self.assertEqual(wgo._on_failure_target(manifest, "benchmark"), "sdd") | ||
| 199 | + | ||
| 200 | + def test_missing_edge_raises(self): | ||
| 201 | + manifest = {"nodes": [{"node_id": "benchmark"}], "edges": []} | ||
| 202 | + with self.assertRaises(ValueError): | ||
| 203 | + wgo._on_failure_target(manifest, "benchmark") | ||
| 204 | + | ||
| 133 | 205 | ||
| 134 | class RunGraphTestBase(unittest.TestCase): | 206 | class RunGraphTestBase(unittest.TestCase): |
| 135 | def setUp(self): | 207 | def setUp(self): |
| @@ -297,6 +369,54 @@ class RunGraphClassifierBlockedTest(RunGraphTestBase): | @@ -297,6 +369,54 @@ class RunGraphClassifierBlockedTest(RunGraphTestBase): | ||
| 297 | self.assertEqual(status, "blocked") | 369 | self.assertEqual(status, "blocked") |
| 298 | 370 | ||
| 299 | 371 | ||
| 372 | +class RunGraphUnanticipatedExceptionMarksRunBlockedTest(RunGraphTestBase): | ||
| 373 | + """2026-08-08: found via a real self-planned run -- a bare exception | ||
| 374 | + from build_prompt() (a caller bug, not one of the three specifically- | ||
| 375 | + handled failure paths: loop_back limit exceeded, on_fail='hard_fail', | ||
| 376 | + legacy _verify_and_maybe_regenerate double-failure) used to propagate | ||
| 377 | + straight out of run_graph() leaving work_graph_run stuck at its | ||
| 378 | + non-terminal status forever, permanently blocking every future retry | ||
| 379 | + for this (task_id, org_node) via ux_work_graph_run_active's UNIQUE | ||
| 380 | + index -- the real incident this test reproduces.""" | ||
| 381 | + | ||
| 382 | + def test_build_prompt_keyerror_still_marks_run_blocked(self): | ||
| 383 | + manifest = {"nodes": [{"node_id": "sdd", "depends_on": []}], "edges": []} | ||
| 384 | + | ||
| 385 | + def bad_build_prompt(node, outputs): | ||
| 386 | + raise KeyError("prd_content") | ||
| 387 | + | ||
| 388 | + with patch.object(work_graph_execute, "write_workflow_script"), \ | ||
| 389 | + patch.object(work_graph_execute, "execute_work_graph"): | ||
| 390 | + with self.assertRaises(KeyError): | ||
| 391 | + wgo.run_graph( | ||
| 392 | + self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, | ||
| 393 | + bad_build_prompt, _IN_SCHEMA, _OUT_SCHEMA, | ||
| 394 | + ) | ||
| 395 | + status = self.conn.execute("SELECT status FROM work_graph_run WHERE run_id = 'run-1'").fetchone()[0] | ||
| 396 | + self.assertEqual(status, "blocked") | ||
| 397 | + | ||
| 398 | + def test_already_terminal_status_is_left_alone(self): | ||
| 399 | + # if some inner handler already set a more specific terminal | ||
| 400 | + # status before the exception reaches run_graph()'s own catch-all, | ||
| 401 | + # the catch-all's WHERE clause must be a no-op, not overwrite it. | ||
| 402 | + self.conn.execute("UPDATE work_graph_run SET status = 'done' WHERE run_id = 'run-1'") | ||
| 403 | + self.conn.commit() | ||
| 404 | + manifest = {"nodes": [{"node_id": "sdd", "depends_on": []}], "edges": []} | ||
| 405 | + | ||
| 406 | + def bad_build_prompt(node, outputs): | ||
| 407 | + raise RuntimeError("boom") | ||
| 408 | + | ||
| 409 | + with patch.object(work_graph_execute, "write_workflow_script"), \ | ||
| 410 | + patch.object(work_graph_execute, "execute_work_graph"): | ||
| 411 | + with self.assertRaises(RuntimeError): | ||
| 412 | + wgo.run_graph( | ||
| 413 | + self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, | ||
| 414 | + bad_build_prompt, _IN_SCHEMA, _OUT_SCHEMA, | ||
| 415 | + ) | ||
| 416 | + status = self.conn.execute("SELECT status FROM work_graph_run WHERE run_id = 'run-1'").fetchone()[0] | ||
| 417 | + self.assertEqual(status, "done") | ||
| 418 | + | ||
| 419 | + | ||
| 300 | class VerifyFnTest(RunGraphTestBase): | 420 | class VerifyFnTest(RunGraphTestBase): |
| 301 | """REQ-02 boundary: verify_fn intercepts a bad node output before it | 421 | """REQ-02 boundary: verify_fn intercepts a bad node output before it |
| 302 | propagates downstream, via one regeneration attempt.""" | 422 | propagates downstream, via one regeneration attempt.""" |
| @@ -722,5 +842,420 @@ class RunGraphRealSqliteConcurrencyTest(unittest.TestCase): | @@ -722,5 +842,420 @@ class RunGraphRealSqliteConcurrencyTest(unittest.TestCase): | ||
| 722 | conn.close() | 842 | conn.close() |
| 723 | 843 | ||
| 724 | 844 | ||
| 845 | +class CoerceBoolTest(unittest.TestCase): | ||
| 846 | + def test_real_bool_passes_through(self): | ||
| 847 | + self.assertTrue(wgo._coerce_bool(True)) | ||
| 848 | + self.assertFalse(wgo._coerce_bool(False)) | ||
| 849 | + | ||
| 850 | + def test_string_false_is_not_truthy(self): | ||
| 851 | + # regression: a real call has returned "false" as a JSON STRING | ||
| 852 | + # despite "type": "boolean" in the schema -- `if "false":` is | ||
| 853 | + # truthy in Python (same class of bug e2e_pipeline._coerce_bool | ||
| 854 | + # was written for). | ||
| 855 | + self.assertFalse(wgo._coerce_bool("false")) | ||
| 856 | + | ||
| 857 | + def test_string_true_is_treated_as_true(self): | ||
| 858 | + self.assertTrue(wgo._coerce_bool("true")) | ||
| 859 | + | ||
| 860 | + | ||
| 861 | +class ResolveNodeVerifyFnTest(unittest.TestCase): | ||
| 862 | + """2026-08-08: node-declared `judgment` takes over verify_fn | ||
| 863 | + resolution; a node with no `judgment` falls through to whatever the | ||
| 864 | + caller passed as `verify_fn` (FR-A8 -- every pre-existing graph_manifest | ||
| 865 | + has no `judgment` field at all).""" | ||
| 866 | + | ||
| 867 | + def test_no_judgment_falls_through_to_external_verify_fn(self): | ||
| 868 | + external = lambda node, output: (True, None) | ||
| 869 | + resolved = wgo._resolve_node_verify_fn({"node_id": "x"}, external, "planner", "/repo", None) | ||
| 870 | + self.assertIs(resolved, external) | ||
| 871 | + | ||
| 872 | + def test_judgment_kind_none_falls_through_to_external_verify_fn(self): | ||
| 873 | + external = lambda node, output: (True, None) | ||
| 874 | + node = {"node_id": "x", "judgment": {"kind": "none"}} | ||
| 875 | + resolved = wgo._resolve_node_verify_fn(node, external, "planner", "/repo", None) | ||
| 876 | + self.assertIs(resolved, external) | ||
| 877 | + | ||
| 878 | + def test_no_judgment_and_no_external_verify_fn_returns_none(self): | ||
| 879 | + resolved = wgo._resolve_node_verify_fn({"node_id": "x"}, None, "planner", "/repo", None) | ||
| 880 | + self.assertIsNone(resolved) | ||
| 881 | + | ||
| 882 | + def test_mechanical_judgment_dispatches_to_registered_check(self): | ||
| 883 | + node = {"node_id": "script", "judgment": {"kind": "mechanical", "check_ref": "my_check"}} | ||
| 884 | + registry = {"my_check": lambda content: (content == "good", None if content == "good" else "bad content")} | ||
| 885 | + resolved = wgo._resolve_node_verify_fn(node, None, "planner", "/repo", registry) | ||
| 886 | + ok, feedback = resolved(node, {"content": "good"}) | ||
| 887 | + self.assertTrue(ok) | ||
| 888 | + ok, feedback = resolved(node, {"content": "bad"}) | ||
| 889 | + self.assertFalse(ok) | ||
| 890 | + self.assertEqual(feedback, ["bad content"]) | ||
| 891 | + | ||
| 892 | + def test_mechanical_judgment_unknown_check_ref_raises(self): | ||
| 893 | + node = {"node_id": "script", "judgment": {"kind": "mechanical", "check_ref": "typo"}} | ||
| 894 | + with self.assertRaises(ValueError): | ||
| 895 | + wgo._resolve_node_verify_fn(node, None, "planner", "/repo", {"real_check": lambda c: (True, None)}) | ||
| 896 | + | ||
| 897 | + def test_unknown_judgment_kind_raises(self): | ||
| 898 | + node = {"node_id": "x", "judgment": {"kind": "astrology"}} | ||
| 899 | + with self.assertRaises(ValueError): | ||
| 900 | + wgo._resolve_node_verify_fn(node, None, "planner", "/repo", None) | ||
| 901 | + | ||
| 902 | + | ||
| 903 | +class ValidateMechanicalCheckRefsTest(unittest.TestCase): | ||
| 904 | + """Fail before running any node, not when the offending node is reached.""" | ||
| 905 | + | ||
| 906 | + def test_unregistered_check_ref_raises(self): | ||
| 907 | + manifest = { | ||
| 908 | + "nodes": [{"node_id": "script", "judgment": {"kind": "mechanical", "check_ref": "typo"}}], | ||
| 909 | + "edges": [], | ||
| 910 | + } | ||
| 911 | + with self.assertRaises(ValueError): | ||
| 912 | + wgo._validate_mechanical_check_refs(manifest, {"real_check": lambda c: (True, None)}) | ||
| 913 | + | ||
| 914 | + def test_registered_check_ref_passes(self): | ||
| 915 | + manifest = { | ||
| 916 | + "nodes": [{"node_id": "script", "judgment": {"kind": "mechanical", "check_ref": "real_check"}}], | ||
| 917 | + "edges": [], | ||
| 918 | + } | ||
| 919 | + wgo._validate_mechanical_check_refs(manifest, {"real_check": lambda c: (True, None)}) # must not raise | ||
| 920 | + | ||
| 921 | + def test_no_judgment_nodes_pass_with_no_registry(self): | ||
| 922 | + manifest = {"nodes": [{"node_id": "brd"}], "edges": []} | ||
| 923 | + wgo._validate_mechanical_check_refs(manifest, None) # must not raise | ||
| 924 | + | ||
| 925 | + | ||
| 926 | +class LlmJudgmentVerifyFnTest(unittest.TestCase): | ||
| 927 | + def test_non_self_target_calls_directly_without_worktree(self): | ||
| 928 | + judgment = {"judge_prompt": "Review: {content}"} | ||
| 929 | + verify_fn = wgo._llm_judgment_verify_fn(judgment, "planner", "/repo") | ||
| 930 | + with patch.object(wgo.paths, "is_self_target", return_value=False), \ | ||
| 931 | + patch.object(work_graph_execute, "write_workflow_script") as mock_write, \ | ||
| 932 | + patch.object( | ||
| 933 | + work_graph_execute, "execute_work_graph", | ||
| 934 | + return_value={"ok": True, "issues": []}, | ||
| 935 | + ) as mock_execute: | ||
| 936 | + ok, issues = verify_fn({"node_id": "x"}, {"content": "some content"}) | ||
| 937 | + self.assertTrue(ok) | ||
| 938 | + self.assertEqual(issues, []) | ||
| 939 | + self.assertEqual(mock_write.call_args[0][0], "/repo") | ||
| 940 | + self.assertEqual(mock_execute.call_args[0][0], "/repo") | ||
| 941 | + | ||
| 942 | + def test_string_ok_is_coerced(self): | ||
| 943 | + judgment = {"judge_prompt": "Review: {content}"} | ||
| 944 | + verify_fn = wgo._llm_judgment_verify_fn(judgment, "planner", "/repo") | ||
| 945 | + with patch.object(wgo.paths, "is_self_target", return_value=False), \ | ||
| 946 | + patch.object(work_graph_execute, "write_workflow_script"), \ | ||
| 947 | + patch.object( | ||
| 948 | + work_graph_execute, "execute_work_graph", | ||
| 949 | + return_value={"ok": "false", "issues": ["bad"]}, | ||
| 950 | + ): | ||
| 951 | + ok, issues = verify_fn({"node_id": "x"}, {"content": "some content"}) | ||
| 952 | + self.assertFalse(ok) | ||
| 953 | + self.assertEqual(issues, ["bad"]) | ||
| 954 | + | ||
| 955 | + def test_self_target_isolates_in_its_own_worktree(self): | ||
| 956 | + # same pattern as e2e_pipeline.py's PrdConsistencySelfTargetWorktreeTest | ||
| 957 | + judgment = {"judge_prompt": "Review: {content}"} | ||
| 958 | + verify_fn = wgo._llm_judgment_verify_fn(judgment, "planner", "/repo") | ||
| 959 | + with patch.object(wgo.paths, "is_self_target", return_value=True), \ | ||
| 960 | + patch.object(wgo.work_node_run, "create_worktree", return_value="/repo/.graphlemon-worktrees/x") as mock_create, \ | ||
| 961 | + patch.object(wgo.work_node_run, "ensure_worktree_committed") as mock_ensure, \ | ||
| 962 | + patch.object(wgo.work_node_run, "merge_worktree") as mock_merge, \ | ||
| 963 | + patch.object(wgo.work_node_run, "remove_worktree") as mock_remove, \ | ||
| 964 | + patch.object(work_graph_execute, "write_workflow_script") as mock_write, \ | ||
| 965 | + patch.object( | ||
| 966 | + work_graph_execute, "execute_work_graph", | ||
| 967 | + return_value={"ok": True, "issues": []}, | ||
| 968 | + ) as mock_execute: | ||
| 969 | + ok, issues = verify_fn({"node_id": "x"}, {"content": "some content"}) | ||
| 970 | + self.assertTrue(ok) | ||
| 971 | + mock_create.assert_called_once_with("/repo", unittest.mock.ANY) | ||
| 972 | + self.assertEqual(mock_write.call_args[0][0], "/repo/.graphlemon-worktrees/x") | ||
| 973 | + self.assertEqual(mock_execute.call_args[0][0], "/repo/.graphlemon-worktrees/x") | ||
| 974 | + self.assertEqual(mock_ensure.call_count, 2) | ||
| 975 | + mock_merge.assert_called_once() | ||
| 976 | + mock_remove.assert_called_once() | ||
| 977 | + | ||
| 978 | + | ||
| 979 | +class RunGraphMechanicalJudgmentTest(RunGraphTestBase): | ||
| 980 | + """End-to-end: a node's own judgment field (not an externally-passed | ||
| 981 | + verify_fn) drives verification through run_graph().""" | ||
| 982 | + | ||
| 983 | + def test_node_declared_mechanical_judgment_regenerates_on_failure(self): | ||
| 984 | + manifest = { | ||
| 985 | + "nodes": [{"node_id": "script", "judgment": {"kind": "mechanical", "check_ref": "compiles"}}], | ||
| 986 | + "edges": [], | ||
| 987 | + } | ||
| 988 | + registry = {"compiles": lambda content: (content == "real code", None if content == "real code" else "not code")} | ||
| 989 | + with patch.object(work_graph_execute, "write_workflow_script"), \ | ||
| 990 | + patch.object(work_graph_execute, "execute_work_graph") as mock_execute: | ||
| 991 | + mock_execute.side_effect = [{"content": "narrative text"}, {"content": "real code"}] | ||
| 992 | + outputs = wgo.run_graph( | ||
| 993 | + self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, | ||
| 994 | + lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, | ||
| 995 | + mechanical_check_registry=registry, | ||
| 996 | + ) | ||
| 997 | + self.assertEqual(outputs["script"], {"content": "real code"}) | ||
| 998 | + | ||
| 999 | + def test_unregistered_check_ref_fails_before_any_node_runs(self): | ||
| 1000 | + manifest = { | ||
| 1001 | + "nodes": [{"node_id": "script", "judgment": {"kind": "mechanical", "check_ref": "typo"}}], | ||
| 1002 | + "edges": [], | ||
| 1003 | + } | ||
| 1004 | + with patch.object(work_graph_execute, "write_workflow_script"), \ | ||
| 1005 | + patch.object(work_graph_execute, "execute_work_graph") as mock_execute: | ||
| 1006 | + with self.assertRaises(ValueError): | ||
| 1007 | + wgo.run_graph( | ||
| 1008 | + self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, | ||
| 1009 | + lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, | ||
| 1010 | + mechanical_check_registry={"compiles": lambda c: (True, None)}, | ||
| 1011 | + ) | ||
| 1012 | + mock_execute.assert_not_called() | ||
| 1013 | + | ||
| 1014 | + | ||
| 1015 | +class RunGraphLoopBackTest(RunGraphTestBase): | ||
| 1016 | + """2026-08-08, Work Graph self-planning + loop-back design §4.""" | ||
| 1017 | + | ||
| 1018 | + def test_retry_self_regenerates_with_accumulating_history(self): | ||
| 1019 | + # A node with on_fail.action='retry_self' loops back to itself -- | ||
| 1020 | + # fails twice, succeeds the third time. Each retry's prompt must | ||
| 1021 | + # include the FULL history of prior rejections, not just the last one. | ||
| 1022 | + manifest = { | ||
| 1023 | + "nodes": [{ | ||
| 1024 | + "node_id": "benchmark", | ||
| 1025 | + "judgment": {"kind": "mechanical", "check_ref": "compiles"}, | ||
| 1026 | + "on_fail": {"action": "retry_self", "max_attempts": 5}, | ||
| 1027 | + }], | ||
| 1028 | + "edges": [], | ||
| 1029 | + } | ||
| 1030 | + contents = ["bad v1", "bad v2", "good"] | ||
| 1031 | + prompts_seen = [] | ||
| 1032 | + | ||
| 1033 | + def build_prompt(node, outputs): | ||
| 1034 | + return "base prompt" | ||
| 1035 | + | ||
| 1036 | + def fake_execute(cwd, name, args, output_schema, timeout=600): | ||
| 1037 | + return {"content": contents.pop(0)} | ||
| 1038 | + | ||
| 1039 | + registry = {"compiles": lambda content: (content == "good", "not real code: " + content)} | ||
| 1040 | + | ||
| 1041 | + with patch.object(work_graph_execute, "write_workflow_script") as mock_write, \ | ||
| 1042 | + patch.object(work_graph_execute, "execute_work_graph", side_effect=fake_execute): | ||
| 1043 | + outputs = wgo.run_graph( | ||
| 1044 | + self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, | ||
| 1045 | + build_prompt, _IN_SCHEMA, _OUT_SCHEMA, mechanical_check_registry=registry, | ||
| 1046 | + ) | ||
| 1047 | + self.assertEqual(outputs["benchmark"], {"content": "good"}) | ||
| 1048 | + | ||
| 1049 | + # 3 real executions happened (2 failures + 1 success) -- 3 distinct | ||
| 1050 | + # work_node_execution rows, not 1 (which would mean a stale 'done' | ||
| 1051 | + # row got silently reused instead of genuinely re-running). | ||
| 1052 | + rows = self.conn.execute( | ||
| 1053 | + "SELECT node_id FROM work_node_execution WHERE run_id = 'run-1' ORDER BY node_id" | ||
| 1054 | + ).fetchall() | ||
| 1055 | + self.assertEqual([r[0] for r in rows], ["benchmark", "benchmark-attempt2", "benchmark-attempt3"]) | ||
| 1056 | + | ||
| 1057 | + events = self.conn.execute( | ||
| 1058 | + "SELECT payload FROM execution_event WHERE event_type = 'loop_back_triggered' ORDER BY created_at" | ||
| 1059 | + ).fetchall() | ||
| 1060 | + self.assertEqual(len(events), 2) | ||
| 1061 | + payloads = [json.loads(p) for (p,) in events] | ||
| 1062 | + self.assertEqual(payloads[0]["attempt_number"], 1) | ||
| 1063 | + self.assertEqual(payloads[1]["attempt_number"], 2) | ||
| 1064 | + | ||
| 1065 | + # the THIRD (successful) execution's prompt must carry both prior | ||
| 1066 | + # rejections' feedback, not just the most recent one. | ||
| 1067 | + third_script_prompt = json.loads( | ||
| 1068 | + self.conn.execute( | ||
| 1069 | + "SELECT input_payload FROM work_node_execution WHERE node_id = 'benchmark-attempt3'" | ||
| 1070 | + ).fetchone()[0] | ||
| 1071 | + ) | ||
| 1072 | + # the real prompt text lives in the rendered workflow script, not | ||
| 1073 | + # input_payload (that's just {"node_id": ...}) -- inspect what got | ||
| 1074 | + # passed to write_workflow_script for the third attempt instead. | ||
| 1075 | + third_call_script = mock_write.call_args_list[2].args[2] | ||
| 1076 | + self.assertIn("not real code: bad v1", third_call_script) | ||
| 1077 | + self.assertIn("not real code: bad v2", third_call_script) | ||
| 1078 | + | ||
| 1079 | + def test_loop_back_to_earlier_node_reruns_target_and_downstream(self): | ||
| 1080 | + # benchmark declares on_fail.action='loop_back' with an on_failure | ||
| 1081 | + # edge back to sdd -- sdd must be genuinely re-executed (not | ||
| 1082 | + # short-circuited to its stale 'done' output), and benchmark's | ||
| 1083 | + # second attempt must see the NEW sdd output. | ||
| 1084 | + manifest = { | ||
| 1085 | + "nodes": [ | ||
| 1086 | + {"node_id": "sdd", "depends_on": []}, | ||
| 1087 | + { | ||
| 1088 | + "node_id": "benchmark", "depends_on": ["sdd"], | ||
| 1089 | + "judgment": {"kind": "mechanical", "check_ref": "compiles"}, | ||
| 1090 | + "on_fail": {"action": "loop_back", "max_attempts": 3}, | ||
| 1091 | + }, | ||
| 1092 | + ], | ||
| 1093 | + "edges": [{"from": "benchmark", "to": "sdd", "edge_type": "on_failure"}], | ||
| 1094 | + } | ||
| 1095 | + sdd_outputs = ["sdd v1", "sdd v2 (fixed)"] | ||
| 1096 | + benchmark_seen_sdd = [] | ||
| 1097 | + | ||
| 1098 | + def build_prompt(node, outputs): | ||
| 1099 | + if node["node_id"] == "benchmark": | ||
| 1100 | + benchmark_seen_sdd.append(outputs["sdd"]["content"]) | ||
| 1101 | + return "benchmark prompt using " + outputs["sdd"]["content"] | ||
| 1102 | + return "sdd prompt" | ||
| 1103 | + | ||
| 1104 | + # sdd runs before benchmark in each pass (topological order), so | ||
| 1105 | + # call order alone deterministically identifies which node this is: | ||
| 1106 | + # sdd v1, bad benchmark, sdd v2 (re-run via loop_back), good benchmark. | ||
| 1107 | + call_order = [] | ||
| 1108 | + | ||
| 1109 | + def side_effect(cwd, name, args, output_schema, timeout=600): | ||
| 1110 | + call_order.append(name) | ||
| 1111 | + call_index = len(call_order) | ||
| 1112 | + if call_index == 1: | ||
| 1113 | + return {"content": sdd_outputs[0]} | ||
| 1114 | + if call_index == 2: | ||
| 1115 | + return {"content": "bad benchmark"} | ||
| 1116 | + if call_index == 3: | ||
| 1117 | + return {"content": sdd_outputs[1]} | ||
| 1118 | + return {"content": "good benchmark"} | ||
| 1119 | + | ||
| 1120 | + registry = {"compiles": lambda content: (content == "good benchmark", "rejected: " + content)} | ||
| 1121 | + | ||
| 1122 | + with patch.object(work_graph_execute, "write_workflow_script"), \ | ||
| 1123 | + patch.object(work_graph_execute, "execute_work_graph", side_effect=side_effect): | ||
| 1124 | + outputs = wgo.run_graph( | ||
| 1125 | + self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, | ||
| 1126 | + build_prompt, _IN_SCHEMA, _OUT_SCHEMA, mechanical_check_registry=registry, | ||
| 1127 | + ) | ||
| 1128 | + | ||
| 1129 | + self.assertEqual(outputs["sdd"], {"content": sdd_outputs[1]}) | ||
| 1130 | + self.assertEqual(outputs["benchmark"], {"content": "good benchmark"}) | ||
| 1131 | + self.assertEqual(benchmark_seen_sdd, [sdd_outputs[0], sdd_outputs[1]]) | ||
| 1132 | + | ||
| 1133 | + sdd_rows = self.conn.execute( | ||
| 1134 | + "SELECT node_id FROM work_node_execution WHERE run_id = 'run-1' AND " | ||
| 1135 | + "(node_id = 'sdd' OR node_id LIKE 'sdd-attempt%') ORDER BY node_id" | ||
| 1136 | + ).fetchall() | ||
| 1137 | + self.assertEqual([r[0] for r in sdd_rows], ["sdd", "sdd-attempt2"]) | ||
| 1138 | + | ||
| 1139 | + def test_exceeding_limit_raises_and_blocks_run(self): | ||
| 1140 | + manifest = { | ||
| 1141 | + "nodes": [{ | ||
| 1142 | + "node_id": "benchmark", | ||
| 1143 | + "judgment": {"kind": "mechanical", "check_ref": "compiles"}, | ||
| 1144 | + "on_fail": {"action": "retry_self", "max_attempts": 2}, | ||
| 1145 | + }], | ||
| 1146 | + "edges": [], | ||
| 1147 | + } | ||
| 1148 | + registry = {"compiles": lambda content: (False, "always rejected")} | ||
| 1149 | + with patch.object(work_graph_execute, "write_workflow_script"), \ | ||
| 1150 | + patch.object(work_graph_execute, "execute_work_graph", return_value={"content": "always bad"}): | ||
| 1151 | + with self.assertRaises(wgo.ConsistencyCheckFailedError): | ||
| 1152 | + wgo.run_graph( | ||
| 1153 | + self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, | ||
| 1154 | + lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, mechanical_check_registry=registry, | ||
| 1155 | + ) | ||
| 1156 | + status = self.conn.execute("SELECT status FROM work_graph_run WHERE run_id = 'run-1'").fetchone()[0] | ||
| 1157 | + self.assertEqual(status, "blocked") | ||
| 1158 | + | ||
| 1159 | + def test_hard_fail_raises_immediately_with_no_retry(self): | ||
| 1160 | + manifest = { | ||
| 1161 | + "nodes": [{ | ||
| 1162 | + "node_id": "benchmark", | ||
| 1163 | + "judgment": {"kind": "mechanical", "check_ref": "compiles"}, | ||
| 1164 | + "on_fail": {"action": "hard_fail"}, | ||
| 1165 | + }], | ||
| 1166 | + "edges": [], | ||
| 1167 | + } | ||
| 1168 | + registry = {"compiles": lambda content: (False, "bad")} | ||
| 1169 | + with patch.object(work_graph_execute, "write_workflow_script"), \ | ||
| 1170 | + patch.object(work_graph_execute, "execute_work_graph", return_value={"content": "x"}) as mock_execute: | ||
| 1171 | + with self.assertRaises(wgo.ConsistencyCheckFailedError): | ||
| 1172 | + wgo.run_graph( | ||
| 1173 | + self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, | ||
| 1174 | + lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, mechanical_check_registry=registry, | ||
| 1175 | + ) | ||
| 1176 | + self.assertEqual(mock_execute.call_count, 1) # no retry at all | ||
| 1177 | + | ||
| 1178 | + def test_resume_after_restart_continues_from_the_real_attempt_count(self): | ||
| 1179 | + # Simulates a PRIOR process that ran attempt 1 (rejected), recorded | ||
| 1180 | + # the loop_back event, then died before attempt 2 ever began -- | ||
| 1181 | + # seeded directly via SQL rather than literally raising mid-flight | ||
| 1182 | + # inside execute_work_graph, which would instead exercise a | ||
| 1183 | + # separate, pre-existing gap in work_node_run.py's own resume | ||
| 1184 | + # logic (it unconditionally INSERTs a new execution_log_file row | ||
| 1185 | + # every call, with no existing-row check, so resuming a node_exec_id | ||
| 1186 | + # that already has one hits a real UNIQUE constraint violation -- | ||
| 1187 | + # a genuine bug, but in a different module and out of scope here). | ||
| 1188 | + manifest = { | ||
| 1189 | + "nodes": [{ | ||
| 1190 | + "node_id": "benchmark", | ||
| 1191 | + "judgment": {"kind": "mechanical", "check_ref": "compiles"}, | ||
| 1192 | + "on_fail": {"action": "retry_self", "max_attempts": 5}, | ||
| 1193 | + }], | ||
| 1194 | + "edges": [], | ||
| 1195 | + } | ||
| 1196 | + registry = {"compiles": lambda content: (content == "good", "bad: " + content)} | ||
| 1197 | + | ||
| 1198 | + wgo.ensure_placeholder_skill(self.conn, "planner-doc-writing", "planner") | ||
| 1199 | + self.conn.execute( | ||
| 1200 | + "INSERT INTO work_node_execution (node_exec_id, run_id, node_id, subagent_type, skill_ref, " | ||
| 1201 | + "input_schema_ref, output_schema_ref, input_payload, output_payload, status) " | ||
| 1202 | + "VALUES ('exec-1', 'run-1', 'benchmark', 'planner', 'planner-doc-writing', " | ||
| 1203 | + "'work_graph_node_input.schema.json', 'work_graph_node_output.schema.json', " | ||
| 1204 | + "'{}', '{\"content\": \"bad v1\"}', 'done')" | ||
| 1205 | + ) | ||
| 1206 | + self.conn.execute( | ||
| 1207 | + "INSERT INTO execution_event (event_id, project_id, task_id, run_id, event_type, payload) " | ||
| 1208 | + "VALUES ('evt-1', 'proj-1', 'task-1', 'run-1', 'loop_back_triggered', ?)", | ||
| 1209 | + (json.dumps({ | ||
| 1210 | + "source_node_id": "benchmark", "target_node_id": "benchmark", | ||
| 1211 | + "attempt_number": 1, "feedback": ["bad: bad v1"], | ||
| 1212 | + }),), | ||
| 1213 | + ) | ||
| 1214 | + self.conn.commit() | ||
| 1215 | + | ||
| 1216 | + # "restart": a fresh run_graph() call for the same run_id, this | ||
| 1217 | + # time succeeding -- must resume as attempt 2 (reading the true | ||
| 1218 | + # count from execution_event), not restart from attempt 1 or lose | ||
| 1219 | + # the earlier rejection's history. | ||
| 1220 | + with patch.object(work_graph_execute, "write_workflow_script") as mock_write, \ | ||
| 1221 | + patch.object(work_graph_execute, "execute_work_graph", return_value={"content": "good"}): | ||
| 1222 | + outputs = wgo.run_graph( | ||
| 1223 | + self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, | ||
| 1224 | + lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, mechanical_check_registry=registry, | ||
| 1225 | + ) | ||
| 1226 | + self.assertEqual(outputs["benchmark"], {"content": "good"}) | ||
| 1227 | + rows = self.conn.execute( | ||
| 1228 | + "SELECT node_id FROM work_node_execution WHERE run_id = 'run-1' ORDER BY node_id" | ||
| 1229 | + ).fetchall() | ||
| 1230 | + self.assertEqual([r[0] for r in rows], ["benchmark", "benchmark-attempt2"]) | ||
| 1231 | + self.assertIn("bad: bad v1", mock_write.call_args[0][2]) | ||
| 1232 | + | ||
| 1233 | + def test_org_node_type_scoped_config_limit_used_when_no_node_level_override(self): | ||
| 1234 | + from src import settings_config | ||
| 1235 | + settings_config.upsert_loop_limit_config(self.conn, "planner:benchmark", 1, "test") | ||
| 1236 | + manifest = { | ||
| 1237 | + "nodes": [{ | ||
| 1238 | + "node_id": "benchmark", "artifact_type": "benchmark", | ||
| 1239 | + "judgment": {"kind": "mechanical", "check_ref": "compiles"}, | ||
| 1240 | + "on_fail": {"action": "retry_self"}, # no max_attempts -- falls back to config | ||
| 1241 | + }], | ||
| 1242 | + "edges": [], | ||
| 1243 | + } | ||
| 1244 | + registry = {"compiles": lambda content: (False, "bad")} | ||
| 1245 | + with patch.object(work_graph_execute, "write_workflow_script"), \ | ||
| 1246 | + patch.object(work_graph_execute, "execute_work_graph", return_value={"content": "x"}) as mock_execute: | ||
| 1247 | + with self.assertRaises(wgo.ConsistencyCheckFailedError): | ||
| 1248 | + wgo.run_graph( | ||
| 1249 | + self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, | ||
| 1250 | + lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, mechanical_check_registry=registry, | ||
| 1251 | + ) | ||
| 1252 | + # max_attempts=1 means "1 retry allowed" (matches the legacy | ||
| 1253 | + # exactly-one-retry default, design doc §2's on_fail comment) -- | ||
| 1254 | + # attempt_number=1 on the first failure is not > 1, so one retry | ||
| 1255 | + # happens; attempt_number=2 on THAT failure is > 1 and exceeds. | ||
| 1256 | + # Two real executions total, not one. | ||
| 1257 | + self.assertEqual(mock_execute.call_count, 2) | ||
| 1258 | + | ||
| 1259 | + | ||
| 725 | if __name__ == "__main__": | 1260 | if __name__ == "__main__": |
| 726 | unittest.main() | 1261 | unittest.main() |
src/test_work_graph_plan.py
| @@ -133,14 +133,15 @@ class NormalizeGraphManifestTest(unittest.TestCase): | @@ -133,14 +133,15 @@ class NormalizeGraphManifestTest(unittest.TestCase): | ||
| 133 | result = work_graph_plan._normalize_graph_manifest(manifest) | 133 | result = work_graph_plan._normalize_graph_manifest(manifest) |
| 134 | self.assertEqual(result["nodes"][0]["depends_on"], []) | 134 | self.assertEqual(result["nodes"][0]["depends_on"], []) |
| 135 | 135 | ||
| 136 | - def test_canonical_doc_chain_enforced_even_with_zero_dependency_info(self): | ||
| 137 | - # 2026-08-03 regression: a real call omitted depends_on AND edges | ||
| 138 | - # entirely -- worse than test_depends_on_derived_from_edges_when_absent, | ||
| 139 | - # which at least had edges to derive from. With nothing to | ||
| 140 | - # normalize, topological_order() scheduled 'benchmark' before 'sdd' | ||
| 141 | - # (alphabetically first among zero-in-degree nodes), and | ||
| 142 | - # e2e_pipeline's build_prompt() crashed formatting a template that | ||
| 143 | - # needed sdd's not-yet-produced output. | 136 | + def test_zero_dependency_info_leaves_nodes_independent(self): |
| 137 | + # 2026-08-08 (FR-B4 "全部自由规划"): the canonical-doc-chain forced- | ||
| 138 | + # order enforcement that used to backfill this exact case (2026-08-03 | ||
| 139 | + # incident) is deliberately removed, not extended -- Planner's node | ||
| 140 | + # set is no longer a fixed architectural constant. A real response | ||
| 141 | + # with zero dependency info now genuinely produces zero depends_on, | ||
| 142 | + # same as any other node set; robustness rests entirely on the | ||
| 143 | + # edge-derived backfill (test_depends_on_derived_from_edges_when_absent) | ||
| 144 | + # plus _validate_loop_back_targets, not a hardcoded chain to guess from. | ||
| 144 | manifest = { | 145 | manifest = { |
| 145 | "nodes": [ | 146 | "nodes": [ |
| 146 | {"node_id": "brd", "artifact_type": "brd"}, | 147 | {"node_id": "brd", "artifact_type": "brd"}, |
| @@ -148,59 +149,142 @@ class NormalizeGraphManifestTest(unittest.TestCase): | @@ -148,59 +149,142 @@ class NormalizeGraphManifestTest(unittest.TestCase): | ||
| 148 | {"node_id": "sdd", "artifact_type": "sdd"}, | 149 | {"node_id": "sdd", "artifact_type": "sdd"}, |
| 149 | {"node_id": "benchmark", "artifact_type": "benchmark"}, | 150 | {"node_id": "benchmark", "artifact_type": "benchmark"}, |
| 150 | {"node_id": "test_dataset", "artifact_type": "test_dataset"}, | 151 | {"node_id": "test_dataset", "artifact_type": "test_dataset"}, |
| 151 | - {"node_id": "test_script", "artifact_type": "test_script"}, | ||
| 152 | ], | 152 | ], |
| 153 | "edges": [], | 153 | "edges": [], |
| 154 | } | 154 | } |
| 155 | result = work_graph_plan._normalize_graph_manifest(manifest) | 155 | result = work_graph_plan._normalize_graph_manifest(manifest) |
| 156 | - by_type = {n["artifact_type"]: n for n in result["nodes"]} | ||
| 157 | - self.assertEqual(by_type["brd"]["depends_on"], []) | ||
| 158 | - self.assertEqual(by_type["prd"]["depends_on"], ["brd"]) | ||
| 159 | - self.assertEqual(by_type["sdd"]["depends_on"], ["prd"]) | ||
| 160 | - self.assertEqual(by_type["benchmark"]["depends_on"], ["sdd"]) | ||
| 161 | - self.assertEqual(by_type["test_dataset"]["depends_on"], ["benchmark"]) | ||
| 162 | - # test_script needs both -- its own prompt references | ||
| 163 | - # {benchmark_content} directly, not just what survives into | ||
| 164 | - # test_dataset_content (see work_graph_plan._normalize_graph_manifest). | ||
| 165 | - self.assertEqual(by_type["test_script"]["depends_on"], ["test_dataset", "benchmark"]) | ||
| 166 | - | ||
| 167 | - def test_canonical_chain_enforcement_uses_real_node_ids_not_artifact_types(self): | ||
| 168 | - # node_id and artifact_type can differ (e.g. real LLM output naming | ||
| 169 | - # nodes "n1"/"n2"/... while still setting artifact_type correctly) -- | ||
| 170 | - # the enforced dependency must reference the real node_id. | 156 | + for node in result["nodes"]: |
| 157 | + self.assertEqual(node["depends_on"], []) | ||
| 158 | + | ||
| 159 | + def test_on_failure_edge_does_not_get_folded_into_depends_on(self): | ||
| 160 | + # 2026-08-08: an on_failure edge is control-flow, not a data | ||
| 161 | + # dependency -- the edge-derived depends_on backfill above must | ||
| 162 | + # skip it (same reasoning as work_graph_orchestrate.py's | ||
| 163 | + # topological_layers()/_transitive_dependents). | ||
| 171 | manifest = { | 164 | manifest = { |
| 172 | "nodes": [ | 165 | "nodes": [ |
| 173 | - {"node_id": "step-1", "artifact_type": "brd"}, | ||
| 174 | - {"node_id": "step-2", "artifact_type": "prd"}, | ||
| 175 | - {"node_id": "step-3", "artifact_type": "sdd"}, | ||
| 176 | - {"node_id": "step-4", "artifact_type": "benchmark"}, | ||
| 177 | - {"node_id": "step-5", "artifact_type": "test_dataset"}, | ||
| 178 | - {"node_id": "step-6", "artifact_type": "test_script"}, | 166 | + {"node_id": "sdd", "artifact_type": "sdd"}, |
| 167 | + { | ||
| 168 | + "node_id": "benchmark", "artifact_type": "benchmark", "depends_on": ["sdd"], | ||
| 169 | + "on_fail": {"action": "loop_back"}, | ||
| 170 | + }, | ||
| 179 | ], | 171 | ], |
| 180 | - "edges": [], | 172 | + "edges": [{"from": "benchmark", "to": "sdd", "edge_type": "on_failure"}], |
| 181 | } | 173 | } |
| 182 | result = work_graph_plan._normalize_graph_manifest(manifest) | 174 | result = work_graph_plan._normalize_graph_manifest(manifest) |
| 183 | - by_type = {n["artifact_type"]: n for n in result["nodes"]} | ||
| 184 | - self.assertEqual(by_type["prd"]["depends_on"], ["step-1"]) | ||
| 185 | - self.assertEqual(by_type["sdd"]["depends_on"], ["step-2"]) | ||
| 186 | - self.assertEqual(by_type["benchmark"]["depends_on"], ["step-3"]) | ||
| 187 | - self.assertEqual(by_type["test_dataset"]["depends_on"], ["step-4"]) | ||
| 188 | - self.assertEqual(by_type["test_script"]["depends_on"], ["step-5", "step-4"]) | ||
| 189 | - | ||
| 190 | - def test_partial_canonical_set_does_not_trigger_enforcement(self): | ||
| 191 | - # only brd/prd/sdd present, no benchmark -- not a recognized | ||
| 192 | - # canonical 4-node doc chain, must not guess at a shape. | 175 | + by_id = {n["node_id"]: n for n in result["nodes"]} |
| 176 | + self.assertEqual(by_id["sdd"]["depends_on"], []) | ||
| 177 | + | ||
| 178 | + | ||
| 179 | +class TransitiveAncestorsTest(unittest.TestCase): | ||
| 180 | + def test_includes_self_and_all_upstream(self): | ||
| 193 | manifest = { | 181 | manifest = { |
| 194 | "nodes": [ | 182 | "nodes": [ |
| 195 | - {"node_id": "brd", "artifact_type": "brd"}, | ||
| 196 | - {"node_id": "prd", "artifact_type": "prd"}, | ||
| 197 | - {"node_id": "sdd", "artifact_type": "sdd"}, | 183 | + {"node_id": "a", "depends_on": []}, |
| 184 | + {"node_id": "b", "depends_on": ["a"]}, | ||
| 185 | + {"node_id": "c", "depends_on": ["b"]}, | ||
| 198 | ], | 186 | ], |
| 199 | "edges": [], | 187 | "edges": [], |
| 200 | } | 188 | } |
| 201 | - result = work_graph_plan._normalize_graph_manifest(manifest) | ||
| 202 | - for node in result["nodes"]: | ||
| 203 | - self.assertEqual(node["depends_on"], []) | 189 | + self.assertEqual(work_graph_plan._transitive_ancestors(manifest, "c"), {"a", "b", "c"}) |
| 190 | + self.assertEqual(work_graph_plan._transitive_ancestors(manifest, "a"), {"a"}) | ||
| 191 | + | ||
| 192 | + def test_ignores_on_failure_edges(self): | ||
| 193 | + manifest = { | ||
| 194 | + "nodes": [{"node_id": "sdd"}, {"node_id": "benchmark"}], | ||
| 195 | + "edges": [{"from": "benchmark", "to": "sdd", "edge_type": "on_failure"}], | ||
| 196 | + } | ||
| 197 | + # an on_failure edge alone (no real depends_on/data edge) must NOT | ||
| 198 | + # make benchmark an ancestor of sdd. | ||
| 199 | + self.assertEqual(work_graph_plan._transitive_ancestors(manifest, "sdd"), {"sdd"}) | ||
| 200 | + | ||
| 201 | + | ||
| 202 | +class ValidateLoopBackTargetsTest(unittest.TestCase): | ||
| 203 | + def test_valid_loop_back_passes(self): | ||
| 204 | + manifest = { | ||
| 205 | + "nodes": [ | ||
| 206 | + {"node_id": "sdd", "depends_on": []}, | ||
| 207 | + { | ||
| 208 | + "node_id": "benchmark", "depends_on": ["sdd"], | ||
| 209 | + "on_fail": {"action": "loop_back"}, | ||
| 210 | + }, | ||
| 211 | + ], | ||
| 212 | + "edges": [{"from": "benchmark", "to": "sdd", "edge_type": "on_failure"}], | ||
| 213 | + } | ||
| 214 | + work_graph_plan._validate_loop_back_targets(manifest) # must not raise | ||
| 215 | + | ||
| 216 | + def test_no_on_failure_edge_raises(self): | ||
| 217 | + manifest = { | ||
| 218 | + "nodes": [ | ||
| 219 | + {"node_id": "sdd", "depends_on": []}, | ||
| 220 | + {"node_id": "benchmark", "depends_on": ["sdd"], "on_fail": {"action": "loop_back"}}, | ||
| 221 | + ], | ||
| 222 | + "edges": [], | ||
| 223 | + } | ||
| 224 | + with self.assertRaises(work_graph_plan.PlanningFailedError): | ||
| 225 | + work_graph_plan._validate_loop_back_targets(manifest) | ||
| 226 | + | ||
| 227 | + def test_two_on_failure_edges_from_same_node_raises(self): | ||
| 228 | + manifest = { | ||
| 229 | + "nodes": [ | ||
| 230 | + {"node_id": "sdd", "depends_on": []}, | ||
| 231 | + {"node_id": "prd", "depends_on": []}, | ||
| 232 | + { | ||
| 233 | + "node_id": "benchmark", "depends_on": ["sdd", "prd"], | ||
| 234 | + "on_fail": {"action": "loop_back"}, | ||
| 235 | + }, | ||
| 236 | + ], | ||
| 237 | + "edges": [ | ||
| 238 | + {"from": "benchmark", "to": "sdd", "edge_type": "on_failure"}, | ||
| 239 | + {"from": "benchmark", "to": "prd", "edge_type": "on_failure"}, | ||
| 240 | + ], | ||
| 241 | + } | ||
| 242 | + with self.assertRaises(work_graph_plan.PlanningFailedError): | ||
| 243 | + work_graph_plan._validate_loop_back_targets(manifest) | ||
| 244 | + | ||
| 245 | + def test_target_not_an_upstream_dependency_raises(self): | ||
| 246 | + # sdd and unrelated are siblings with no dependency relationship -- | ||
| 247 | + # benchmark depends on sdd but its on_failure edge points at the | ||
| 248 | + # unrelated node instead, which never influenced benchmark at all. | ||
| 249 | + manifest = { | ||
| 250 | + "nodes": [ | ||
| 251 | + {"node_id": "sdd", "depends_on": []}, | ||
| 252 | + {"node_id": "unrelated", "depends_on": []}, | ||
| 253 | + { | ||
| 254 | + "node_id": "benchmark", "depends_on": ["sdd"], | ||
| 255 | + "on_fail": {"action": "loop_back"}, | ||
| 256 | + }, | ||
| 257 | + ], | ||
| 258 | + "edges": [{"from": "benchmark", "to": "unrelated", "edge_type": "on_failure"}], | ||
| 259 | + } | ||
| 260 | + with self.assertRaises(work_graph_plan.PlanningFailedError): | ||
| 261 | + work_graph_plan._validate_loop_back_targets(manifest) | ||
| 262 | + | ||
| 263 | + def test_target_not_in_manifest_at_all_raises(self): | ||
| 264 | + manifest = { | ||
| 265 | + "nodes": [{"node_id": "benchmark", "depends_on": [], "on_fail": {"action": "loop_back"}}], | ||
| 266 | + "edges": [{"from": "benchmark", "to": "nonexistent", "edge_type": "on_failure"}], | ||
| 267 | + } | ||
| 268 | + with self.assertRaises(work_graph_plan.PlanningFailedError): | ||
| 269 | + work_graph_plan._validate_loop_back_targets(manifest) | ||
| 270 | + | ||
| 271 | + def test_nodes_without_loop_back_are_unaffected(self): | ||
| 272 | + manifest = { | ||
| 273 | + "nodes": [ | ||
| 274 | + {"node_id": "brd", "depends_on": []}, | ||
| 275 | + {"node_id": "prd", "depends_on": ["brd"]}, | ||
| 276 | + ], | ||
| 277 | + "edges": [], | ||
| 278 | + } | ||
| 279 | + work_graph_plan._validate_loop_back_targets(manifest) # must not raise | ||
| 280 | + | ||
| 281 | + def test_normalize_graph_manifest_calls_validation_and_raises_on_bad_loop_back(self): | ||
| 282 | + manifest = { | ||
| 283 | + "nodes": [{"node_id": "benchmark", "on_fail": {"action": "loop_back"}}], | ||
| 284 | + "edges": [], | ||
| 285 | + } | ||
| 286 | + with self.assertRaises(work_graph_plan.PlanningFailedError): | ||
| 287 | + work_graph_plan._normalize_graph_manifest(manifest) | ||
| 204 | 288 | ||
| 205 | 289 | ||
| 206 | class HappyPathTest(PlanWorkGraphTestBase): | 290 | class HappyPathTest(PlanWorkGraphTestBase): |
| @@ -288,15 +372,21 @@ _CODER_THREE_MODULE_MANIFEST = { | @@ -288,15 +372,21 @@ _CODER_THREE_MODULE_MANIFEST = { | ||
| 288 | 372 | ||
| 289 | 373 | ||
| 290 | class GenericPlanningPromptTest(PlanWorkGraphTestBase): | 374 | class GenericPlanningPromptTest(PlanWorkGraphTestBase): |
| 291 | - def test_planner_still_gets_fixed_doc_chain_template(self): | 375 | + def test_planner_gets_the_self_planning_catalog_template(self): |
| 376 | + # 2026-08-08 (FR-B4 "全部自由规划"): Planner no longer gets a fixed | ||
| 377 | + # "produce exactly BRD/PRD/SDD/Benchmark" instruction -- it gets the | ||
| 378 | + # node type catalog and decides itself which nodes this goal needs. | ||
| 292 | with patch("src.work_graph_plan.subprocess.run") as run: | 379 | with patch("src.work_graph_plan.subprocess.run") as run: |
| 293 | run.return_value = _completed_process( | 380 | run.return_value = _completed_process( |
| 294 | stdout_obj={"is_error": False, "result": _fenced_result(_MANIFEST)} | 381 | stdout_obj={"is_error": False, "result": _fenced_result(_MANIFEST)} |
| 295 | ) | 382 | ) |
| 296 | work_graph_plan.plan_work_graph(self.conn, "planner", "task-1", "goal", "/tmp/proj-1") | 383 | work_graph_plan.plan_work_graph(self.conn, "planner", "task-1", "goal", "/tmp/proj-1") |
| 297 | prompt = run.call_args.args[0][run.call_args.args[0].index("-p") + 1] | 384 | prompt = run.call_args.args[0][run.call_args.args[0].index("-p") + 1] |
| 298 | - self.assertIn("BRD, PRD, SDD, Benchmark", prompt) | 385 | + self.assertIn("Available node types", prompt) |
| 386 | + self.assertIn("\"artifact_type\": \"benchmark\"", prompt) | ||
| 387 | + self.assertIn("on_failure", prompt) | ||
| 299 | self.assertNotIn("design-dynamic-work-graph", prompt) | 388 | self.assertNotIn("design-dynamic-work-graph", prompt) |
| 389 | + self.assertNotIn("{goal_text}", prompt) # the placeholder itself must have been substituted | ||
| 300 | 390 | ||
| 301 | def test_coder_gets_generic_template_referencing_the_skill(self): | 391 | def test_coder_gets_generic_template_referencing_the_skill(self): |
| 302 | with patch("src.work_graph_plan.subprocess.run") as run: | 392 | with patch("src.work_graph_plan.subprocess.run") as run: |
src/work_graph_orchestrate.py
| @@ -70,7 +70,7 @@ import threading | @@ -70,7 +70,7 @@ import threading | ||
| 70 | import time | 70 | import time |
| 71 | import uuid | 71 | import uuid |
| 72 | 72 | ||
| 73 | -from src import execution_log, paths, work_graph_execute, work_node_run | 73 | +from src import execution_log, paths, settings_config, work_graph_execute, work_node_run |
| 74 | 74 | ||
| 75 | 75 | ||
| 76 | def _log_event(conn, node_exec_id, event_type, **payload): | 76 | def _log_event(conn, node_exec_id, event_type, **payload): |
| @@ -111,6 +111,22 @@ class WorkGraphLayerError(Exception): | @@ -111,6 +111,22 @@ class WorkGraphLayerError(Exception): | ||
| 111 | super().__init__("{} node(s) failed in one layer: {}".format(len(errors), summary)) | 111 | super().__init__("{} node(s) failed in one layer: {}".format(len(errors), summary)) |
| 112 | 112 | ||
| 113 | 113 | ||
| 114 | +class _LoopBackRequested(Exception): | ||
| 115 | + """Internal signal (2026-08-08, Work Graph self-planning + loop-back): | ||
| 116 | + a node's on_fail.action='loop_back' fired -- run_graph()'s main loop | ||
| 117 | + catches this and rewinds to source_node_id's target layer, it's not a | ||
| 118 | + real failure. Raised as an exception (not returned) because it can | ||
| 119 | + originate inside a ThreadPoolExecutor worker thread (a multi-node | ||
| 120 | + layer) -- an exception is the only clean way to carry this out through | ||
| 121 | + future.result().""" | ||
| 122 | + | ||
| 123 | + def __init__(self, source_node_id, target_node_id, feedback): | ||
| 124 | + self.source_node_id = source_node_id | ||
| 125 | + self.target_node_id = target_node_id | ||
| 126 | + self.feedback = feedback | ||
| 127 | + super().__init__("node {} requests loop_back to {}".format(source_node_id, target_node_id)) | ||
| 128 | + | ||
| 129 | + | ||
| 114 | class _FetchedRows: | 130 | class _FetchedRows: |
| 115 | """A pre-materialized stand-in for a sqlite3.Cursor, holding rows | 131 | """A pre-materialized stand-in for a sqlite3.Cursor, holding rows |
| 116 | already fetched while _LockedConn's lock was held (see _LockedConn's | 132 | already fetched while _LockedConn's lock was held (see _LockedConn's |
| @@ -191,10 +207,21 @@ def topological_layers(graph_manifest): | @@ -191,10 +207,21 @@ def topological_layers(graph_manifest): | ||
| 191 | might populate one and not the other; trusting only one would silently | 207 | might populate one and not the other; trusting only one would silently |
| 192 | mis-order nodes rather than raise. Raises ValueError on a cycle or a | 208 | mis-order nodes rather than raise. Raises ValueError on a cycle or a |
| 193 | dependency referencing an unknown node_id. | 209 | dependency referencing an unknown node_id. |
| 210 | + | ||
| 211 | + 2026-08-08 (Work Graph self-planning + loop-back): edges with | ||
| 212 | + `edge_type == "on_failure"` are control-flow (a node's failure path, | ||
| 213 | + see `_on_failure_target`), not data dependencies -- they're excluded | ||
| 214 | + here. An `on_failure` edge commonly points from a node back to one of | ||
| 215 | + its own upstream dependencies, which the OLD unfiltered merge would | ||
| 216 | + have folded in as an (incorrect, reversed) data dependency, creating a | ||
| 217 | + spurious cycle that would have made this function reject the graph | ||
| 218 | + before any node ever ran. | ||
| 194 | """ | 219 | """ |
| 195 | nodes = {n["node_id"]: n for n in graph_manifest["nodes"]} | 220 | nodes = {n["node_id"]: n for n in graph_manifest["nodes"]} |
| 196 | deps = {node_id: set(n.get("depends_on") or []) for node_id, n in nodes.items()} | 221 | deps = {node_id: set(n.get("depends_on") or []) for node_id, n in nodes.items()} |
| 197 | for edge in graph_manifest.get("edges", []): | 222 | for edge in graph_manifest.get("edges", []): |
| 223 | + if edge.get("edge_type") == "on_failure": | ||
| 224 | + continue | ||
| 198 | to_id, from_id = edge.get("to"), edge.get("from") | 225 | to_id, from_id = edge.get("to"), edge.get("from") |
| 199 | if to_id in deps and from_id in nodes: | 226 | if to_id in deps and from_id in nodes: |
| 200 | deps[to_id].add(from_id) | 227 | deps[to_id].add(from_id) |
| @@ -232,6 +259,73 @@ def topological_order(graph_manifest): | @@ -232,6 +259,73 @@ def topological_order(graph_manifest): | ||
| 232 | return [node for layer in topological_layers(graph_manifest) for node in layer] | 259 | return [node for layer in topological_layers(graph_manifest) for node in layer] |
| 233 | 260 | ||
| 234 | 261 | ||
| 262 | +def _transitive_dependents(graph_manifest, node_id): | ||
| 263 | + """node_id itself + every node that (directly or transitively) depends | ||
| 264 | + on it, via `depends_on`/normal data-dependency edges (`on_failure` | ||
| 265 | + edges excluded, same reasoning as topological_layers()). Used when a | ||
| 266 | + loop_back fires: node_id's output is about to be regenerated, so | ||
| 267 | + everything downstream of it was computed from what's now stale input | ||
| 268 | + and must be re-run too (2026-08-08, Work Graph self-planning + | ||
| 269 | + loop-back design §4.2).""" | ||
| 270 | + dependents = {n["node_id"]: [] for n in graph_manifest["nodes"]} | ||
| 271 | + deps = {n["node_id"]: set(n.get("depends_on") or []) for n in graph_manifest["nodes"]} | ||
| 272 | + for edge in graph_manifest.get("edges", []): | ||
| 273 | + if edge.get("edge_type") == "on_failure": | ||
| 274 | + continue | ||
| 275 | + to_id, from_id = edge.get("to"), edge.get("from") | ||
| 276 | + if to_id in deps and from_id in deps: | ||
| 277 | + deps[to_id].add(from_id) | ||
| 278 | + for node_id_, dep_set in deps.items(): | ||
| 279 | + for dep in dep_set: | ||
| 280 | + dependents.setdefault(dep, []).append(node_id_) | ||
| 281 | + | ||
| 282 | + affected, stack = set(), [node_id] | ||
| 283 | + while stack: | ||
| 284 | + current = stack.pop() | ||
| 285 | + if current in affected: | ||
| 286 | + continue | ||
| 287 | + affected.add(current) | ||
| 288 | + stack.extend(dependents.get(current, [])) | ||
| 289 | + return affected | ||
| 290 | + | ||
| 291 | + | ||
| 292 | +def _transitive_ancestors(graph_manifest, node_id): | ||
| 293 | + """Reverse of _transitive_dependents: node_id itself + every node it | ||
| 294 | + (directly or transitively) depends on. Used to validate that a | ||
| 295 | + declared on_failure edge's target is a genuine upstream influence, not | ||
| 296 | + an arbitrary node (work_graph_plan.py's _validate_loop_back_targets).""" | ||
| 297 | + deps = {n["node_id"]: set(n.get("depends_on") or []) for n in graph_manifest["nodes"]} | ||
| 298 | + for edge in graph_manifest.get("edges", []): | ||
| 299 | + if edge.get("edge_type") == "on_failure": | ||
| 300 | + continue | ||
| 301 | + to_id, from_id = edge.get("to"), edge.get("from") | ||
| 302 | + if to_id in deps and from_id in deps: | ||
| 303 | + deps[to_id].add(from_id) | ||
| 304 | + | ||
| 305 | + ancestors, stack = set(), [node_id] | ||
| 306 | + while stack: | ||
| 307 | + current = stack.pop() | ||
| 308 | + if current in ancestors: | ||
| 309 | + continue | ||
| 310 | + ancestors.add(current) | ||
| 311 | + stack.extend(deps.get(current, [])) | ||
| 312 | + return ancestors | ||
| 313 | + | ||
| 314 | + | ||
| 315 | +def _on_failure_target(graph_manifest, node_id): | ||
| 316 | + """The node_id that `node_id`'s on_fail.action='loop_back' should | ||
| 317 | + return to -- read from the single `{"from": node_id, "to": ..., | ||
| 318 | + "edge_type": "on_failure"}` edge, not a separate node field (2026-08-08 | ||
| 319 | + design revision: the failure path is expressed the same way the | ||
| 320 | + success path already is, via `edges`). Callers should have already | ||
| 321 | + validated (work_graph_plan.py's _validate_loop_back_targets) that | ||
| 322 | + exactly one such edge exists before relying on this at runtime.""" | ||
| 323 | + for edge in graph_manifest.get("edges", []): | ||
| 324 | + if edge.get("from") == node_id and edge.get("edge_type") == "on_failure": | ||
| 325 | + return edge["to"] | ||
| 326 | + raise ValueError("node {} declares on_fail.action='loop_back' but has no on_failure edge".format(node_id)) | ||
| 327 | + | ||
| 328 | + | ||
| 235 | def ensure_placeholder_skill(conn, skill_id, owner_org_node): | 329 | def ensure_placeholder_skill(conn, skill_id, owner_org_node): |
| 236 | """No Skill content has actually been migrated from the Hermes profiles | 330 | """No Skill content has actually been migrated from the Hermes profiles |
| 237 | yet (skill_update.py's own documented gap) -- work_node_execution. | 331 | yet (skill_update.py's own documented gap) -- work_node_execution. |
| @@ -302,6 +396,221 @@ def _make_run_fn(conn, target_folder_path, node_exec_id, org_node, prompt, outpu | @@ -302,6 +396,221 @@ def _make_run_fn(conn, target_folder_path, node_exec_id, org_node, prompt, outpu | ||
| 302 | return run_fn | 396 | return run_fn |
| 303 | 397 | ||
| 304 | 398 | ||
| 399 | +def _coerce_bool(value): | ||
| 400 | + """Same fix as e2e_pipeline.py's own _coerce_bool (2026-08-02): a real | ||
| 401 | + call has returned e.g. "false" as a JSON STRING despite "type": | ||
| 402 | + "boolean" in the schema, and `if "false":` is truthy in Python. Kept as | ||
| 403 | + its own small copy here rather than importing e2e_pipeline (which | ||
| 404 | + imports this module -- importing back would be circular) or promoting | ||
| 405 | + it to a shared utils module for a 3-line helper.""" | ||
| 406 | + if isinstance(value, bool): | ||
| 407 | + return value | ||
| 408 | + if isinstance(value, str): | ||
| 409 | + return value.strip().lower() == "true" | ||
| 410 | + return bool(value) | ||
| 411 | + | ||
| 412 | + | ||
| 413 | +_JUDGMENT_SCHEMA = { | ||
| 414 | + "type": "object", | ||
| 415 | + "required": ["ok", "issues"], | ||
| 416 | + "properties": {"ok": {"type": "boolean"}, "issues": {"type": "array", "items": {"type": "string"}}}, | ||
| 417 | +} | ||
| 418 | + | ||
| 419 | + | ||
| 420 | +def _llm_judgment_verify_fn(judgment, org_node, target_folder_path, timeout=180): | ||
| 421 | + """judgment.kind == 'llm': generalizes e2e_pipeline.py's | ||
| 422 | + _check_prd_consistency -- same self-target worktree isolation (a real | ||
| 423 | + agent() call with memory:project writes an uncommitted change wherever | ||
| 424 | + it runs; self-target runs need their own worktree so that doesn't land | ||
| 425 | + on the main checkout, see that function's own docstring for the | ||
| 426 | + 2026-08-03 incident this guards against), but the judge prompt comes | ||
| 427 | + from judgment['judge_prompt'] (node-declared, self-contained -- same | ||
| 428 | + design principle as _GENERIC_PROMPT_TEMPLATE's task_prompt) instead of | ||
| 429 | + a hardcoded PRD-specific prompt, and the schema is the generic | ||
| 430 | + {"ok": bool, "issues": [...]} shape instead of PRD's {"consistent": ...}. | ||
| 431 | + e2e_pipeline.py's own _check_prd_consistency/_prd_consistency_verify_fn | ||
| 432 | + are untouched and remain the path Validator's fixed manifest uses | ||
| 433 | + (FR-A8) -- this is a parallel, more general implementation, not a | ||
| 434 | + replacement. `is_self_target` is checked fresh on each call (not cached | ||
| 435 | + at closure-creation time) -- matches _check_prd_consistency's own | ||
| 436 | + behavior, and means a caller building this verify_fn once and reusing | ||
| 437 | + it always reflects the real current state, not a stale snapshot.""" | ||
| 438 | + | ||
| 439 | + def verify_fn(node, output): | ||
| 440 | + is_self = paths.is_self_target(target_folder_path) | ||
| 441 | + node_exec_id = "judgment-" + uuid.uuid4().hex[:12] | ||
| 442 | + worktree_path = work_node_run.create_worktree(target_folder_path, node_exec_id) if is_self else None | ||
| 443 | + cwd = worktree_path or target_folder_path | ||
| 444 | + try: | ||
| 445 | + prompt = judgment["judge_prompt"].format(content=output["content"]) | ||
| 446 | + script_name = "graphlemon-judgment-" + uuid.uuid4().hex | ||
| 447 | + script_content = ( | ||
| 448 | + "export const meta = {{ name: {name}, description: 'graphlemon node judgment' }}\n\n" | ||
| 449 | + "const result = await agent({prompt}, {{ agentType: {org_node_json}, schema: {schema} }})\n\n" | ||
| 450 | + "return result\n" | ||
| 451 | + ).format( | ||
| 452 | + name=json.dumps(script_name), prompt=json.dumps(prompt), | ||
| 453 | + org_node_json=json.dumps(org_node), schema=json.dumps(_JUDGMENT_SCHEMA), | ||
| 454 | + ) | ||
| 455 | + work_graph_execute.write_workflow_script(cwd, script_name, script_content) | ||
| 456 | + result = work_graph_execute.execute_work_graph(cwd, script_name, {}, _JUDGMENT_SCHEMA, timeout=timeout) | ||
| 457 | + if worktree_path: | ||
| 458 | + work_node_run.ensure_worktree_committed(worktree_path, node_exec_id) | ||
| 459 | + work_node_run.ensure_worktree_committed(target_folder_path, node_exec_id + "-main-checkout-drift") | ||
| 460 | + work_node_run.merge_worktree(target_folder_path, node_exec_id) | ||
| 461 | + ok = _coerce_bool(result["ok"]) | ||
| 462 | + return ok, (result.get("issues") or []) | ||
| 463 | + finally: | ||
| 464 | + if worktree_path: | ||
| 465 | + work_node_run.remove_worktree(target_folder_path, worktree_path) | ||
| 466 | + | ||
| 467 | + return verify_fn | ||
| 468 | + | ||
| 469 | + | ||
| 470 | +def _resolve_node_verify_fn(node, external_verify_fn, org_node, target_folder_path, mechanical_check_registry): | ||
| 471 | + """A node's own `judgment` field (if declared) takes over verify_fn | ||
| 472 | + resolution for that node; a node with no `judgment` (every existing | ||
| 473 | + graph_manifest today) falls through to whatever the caller passed as | ||
| 474 | + `verify_fn` -- possibly None, meaning no verification at all -- exactly | ||
| 475 | + like current behavior (FR-A8).""" | ||
| 476 | + judgment = node.get("judgment") | ||
| 477 | + if not judgment or judgment.get("kind", "none") == "none": | ||
| 478 | + return external_verify_fn | ||
| 479 | + if judgment["kind"] == "mechanical": | ||
| 480 | + check_fn = (mechanical_check_registry or {}).get(judgment["check_ref"]) | ||
| 481 | + if check_fn is None: | ||
| 482 | + raise ValueError("unknown mechanical check_ref: {!r}".format(judgment["check_ref"])) | ||
| 483 | + | ||
| 484 | + def mechanical_verify_fn(node, output, _check_fn=check_fn): | ||
| 485 | + ok, detail = _check_fn(output["content"]) | ||
| 486 | + return ok, ([detail] if detail else None) | ||
| 487 | + | ||
| 488 | + return mechanical_verify_fn | ||
| 489 | + if judgment["kind"] == "llm": | ||
| 490 | + return _llm_judgment_verify_fn(judgment, org_node, target_folder_path) | ||
| 491 | + raise ValueError("unknown judgment.kind: {!r}".format(judgment["kind"])) | ||
| 492 | + | ||
| 493 | + | ||
| 494 | +def _validate_mechanical_check_refs(graph_manifest, mechanical_check_registry): | ||
| 495 | + """Fail fast before running ANY node -- a typo'd check_ref caught only | ||
| 496 | + when that node is finally reached would waste every token spent on the | ||
| 497 | + nodes before it.""" | ||
| 498 | + registry = mechanical_check_registry or {} | ||
| 499 | + for node in graph_manifest["nodes"]: | ||
| 500 | + judgment = node.get("judgment") or {} | ||
| 501 | + if judgment.get("kind") == "mechanical" and judgment.get("check_ref") not in registry: | ||
| 502 | + raise ValueError( | ||
| 503 | + "node {}'s judgment.check_ref {!r} is not in mechanical_check_registry".format( | ||
| 504 | + node["node_id"], judgment.get("check_ref") | ||
| 505 | + ) | ||
| 506 | + ) | ||
| 507 | + | ||
| 508 | + | ||
| 509 | +_DEFAULT_LOOP_LIMIT = 3 # only reached if even the "default" config_id row is unseeded | ||
| 510 | + | ||
| 511 | + | ||
| 512 | +def _resolve_loop_limit(conn, org_node, graph_manifest, source_node_id): | ||
| 513 | + """How many times `source_node_id` (the node whose judgment failed and | ||
| 514 | + declared on_fail.action in {'retry_self', 'loop_back'}) may trigger a | ||
| 515 | + loop before run_graph() gives up. Lookup order (2026-08-08 design §6): | ||
| 516 | + the failing node's own on_fail.max_attempts, then a settings_config.py | ||
| 517 | + row keyed by "{org_node}:{node_type}", then "{org_node}:*", then | ||
| 518 | + "default", then a hardcoded fallback.""" | ||
| 519 | + source_node = next(n for n in graph_manifest["nodes"] if n["node_id"] == source_node_id) | ||
| 520 | + node_level = (source_node.get("on_fail") or {}).get("max_attempts") | ||
| 521 | + if node_level is not None: | ||
| 522 | + return node_level | ||
| 523 | + node_type = source_node.get("artifact_type", source_node_id) | ||
| 524 | + for config_id in ("{}:{}".format(org_node, node_type), "{}:*".format(org_node), "default"): | ||
| 525 | + config = settings_config.get_loop_limit_config(conn, config_id) | ||
| 526 | + if config: | ||
| 527 | + return config["max_loop_attempts"] | ||
| 528 | + return _DEFAULT_LOOP_LIMIT | ||
| 529 | + | ||
| 530 | + | ||
| 531 | +def _record_loop_back_event(conn, run_id, sig, attempt_number): | ||
| 532 | + """Persists one 'loop_back_triggered' execution_event row -- this IS | ||
| 533 | + the loop history (2026-08-08 design §7): no separate table, so a | ||
| 534 | + node's full "sent back N times, here's why each time" story is | ||
| 535 | + reconstructed purely by querying execution_event.""" | ||
| 536 | + row = conn.execute( | ||
| 537 | + "SELECT wgr.task_id, t.project_id FROM work_graph_run wgr " | ||
| 538 | + "JOIN task t ON wgr.task_id = t.task_id WHERE wgr.run_id = ?", (run_id,) | ||
| 539 | + ).fetchone() | ||
| 540 | + task_id, project_id = row | ||
| 541 | + conn.execute( | ||
| 542 | + "INSERT INTO execution_event (event_id, project_id, task_id, run_id, event_type, payload) " | ||
| 543 | + "VALUES (?, ?, ?, ?, 'loop_back_triggered', ?)", | ||
| 544 | + (str(uuid.uuid4()), project_id, task_id, run_id, json.dumps({ | ||
| 545 | + "source_node_id": sig.source_node_id, "target_node_id": sig.target_node_id, | ||
| 546 | + "attempt_number": attempt_number, "feedback": sig.feedback, | ||
| 547 | + })), | ||
| 548 | + ) | ||
| 549 | + conn.commit() | ||
| 550 | + | ||
| 551 | + | ||
| 552 | +def _loop_back_events(conn, run_id): | ||
| 553 | + rows = conn.execute( | ||
| 554 | + "SELECT payload FROM execution_event WHERE run_id = ? AND event_type = 'loop_back_triggered' " | ||
| 555 | + "ORDER BY created_at", (run_id,) | ||
| 556 | + ).fetchall() | ||
| 557 | + return [json.loads(p) for (p,) in rows] | ||
| 558 | + | ||
| 559 | + | ||
| 560 | +def _loop_attempt_count_from_events(conn, run_id, target_node_id, source_node_id): | ||
| 561 | + """Real count of prior loop_back triggers for this exact (target, | ||
| 562 | + source) pair, read from execution_event -- not an in-memory counter, | ||
| 563 | + so a process restart mid-loop resumes with the true count (REQ-06).""" | ||
| 564 | + return sum( | ||
| 565 | + 1 for e in _loop_back_events(conn, run_id) | ||
| 566 | + if e["target_node_id"] == target_node_id and e["source_node_id"] == source_node_id | ||
| 567 | + ) | ||
| 568 | + | ||
| 569 | + | ||
| 570 | +def _loop_history_prompt_suffix(conn, run_id, node_id): | ||
| 571 | + """When node_id is about to be (re-)executed, append the full history | ||
| 572 | + of every prior time it was sent back for revision (not just the most | ||
| 573 | + recent) -- empty string if node_id was never a loop_back target.""" | ||
| 574 | + history = [e for e in _loop_back_events(conn, run_id) if e["target_node_id"] == node_id] | ||
| 575 | + if not history: | ||
| 576 | + return "" | ||
| 577 | + lines = [ | ||
| 578 | + "Attempt {}: rejected because: {}".format(h["attempt_number"], "; ".join(h["feedback"] or [])) | ||
| 579 | + for h in history | ||
| 580 | + ] | ||
| 581 | + return "\n\nThis node was sent back for revision before, with this history:\n" + "\n".join(lines) | ||
| 582 | + | ||
| 583 | + | ||
| 584 | +def _next_exec_node_id(conn, run_id, node_id, graph_manifest): | ||
| 585 | + """The exec node_id to use for node_id's NEXT real execution. A node | ||
| 586 | + that has never had its cached output invalidated by a loop_back (the | ||
| 587 | + overwhelming common case, and every node in a graph_manifest with no | ||
| 588 | + judgment/on_fail at all) always reuses the plain node_id -- preserving | ||
| 589 | + run_work_node's normal breakpoint-resume short-circuit (a 'done' row | ||
| 590 | + for node_id returns its persisted output instead of re-running) | ||
| 591 | + completely unchanged, including across a process restart on a run | ||
| 592 | + that never looped at all. | ||
| 593 | + | ||
| 594 | + 2026-08-08 (found via a real test failure, not just theory): counting | ||
| 595 | + only "was node_id EVER the direct loop_back target" undercounts -- | ||
| 596 | + every node in `_transitive_dependents(target)` gets its `outputs` | ||
| 597 | + entry popped and genuinely re-executed too (run_graph()'s own | ||
| 598 | + control flow), not just the target itself. A downstream node that's | ||
| 599 | + never itself a `target_node_id` but IS swept up by some earlier | ||
| 600 | + node's loop_back still needs its own fresh '-attemptN' row each time, | ||
| 601 | + or it silently short-circuits to its own stale pre-invalidation | ||
| 602 | + output on the second pass. So this counts every loop_back event | ||
| 603 | + whose target's `_transitive_dependents` set includes node_id, not | ||
| 604 | + just events where node_id itself is the literal target.""" | ||
| 605 | + prior_invalidations = sum( | ||
| 606 | + 1 for e in _loop_back_events(conn, run_id) | ||
| 607 | + if node_id in _transitive_dependents(graph_manifest, e["target_node_id"]) | ||
| 608 | + ) | ||
| 609 | + if prior_invalidations == 0: | ||
| 610 | + return node_id | ||
| 611 | + return "{}-attempt{}".format(node_id, prior_invalidations + 1) | ||
| 612 | + | ||
| 613 | + | ||
| 305 | _DEFAULT_MAX_WORKERS = 4 # see this module's own docstring section on G for rationale | 614 | _DEFAULT_MAX_WORKERS = 4 # see this module's own docstring section on G for rationale |
| 306 | 615 | ||
| 307 | 616 | ||
| @@ -309,6 +618,7 @@ def run_graph( | @@ -309,6 +618,7 @@ def run_graph( | ||
| 309 | conn, run_id, org_node, skill_id, target_folder_path, graph_manifest, | 618 | conn, run_id, org_node, skill_id, target_folder_path, graph_manifest, |
| 310 | build_prompt, input_schema, output_schema, timeout=600, needs_worktree=False, | 619 | build_prompt, input_schema, output_schema, timeout=600, needs_worktree=False, |
| 311 | verify_fn=None, sleep=time.sleep, now=time.time, max_workers=_DEFAULT_MAX_WORKERS, | 620 | verify_fn=None, sleep=time.sleep, now=time.time, max_workers=_DEFAULT_MAX_WORKERS, |
| 621 | + mechanical_check_registry=None, | ||
| 312 | ): | 622 | ): |
| 313 | """Runs every node in `graph_manifest` through `run_work_node()`, each | 623 | """Runs every node in `graph_manifest` through `run_work_node()`, each |
| 314 | backed by a real one-node dynamic-workflow `agent()` call using | 624 | backed by a real one-node dynamic-workflow `agent()` call using |
| @@ -372,75 +682,225 @@ def run_graph( | @@ -372,75 +682,225 @@ def run_graph( | ||
| 372 | even after every sibling has finished -- Python threads have no clean | 682 | even after every sibling has finished -- Python threads have no clean |
| 373 | preemptible cancel, and building one is out of scope for this pass. | 683 | preemptible cancel, and building one is out of scope for this pass. |
| 374 | 684 | ||
| 685 | + `mechanical_check_registry` (2026-08-08, Work Graph self-planning + | ||
| 686 | + loop-back): {check_ref: callable(content) -> (ok, detail)}, consulted | ||
| 687 | + by any node declaring `judgment: {"kind": "mechanical", "check_ref": | ||
| 688 | + ...}` -- see `_resolve_node_verify_fn`. A node with no `judgment` field | ||
| 689 | + (every graph_manifest that predates this) ignores this entirely and | ||
| 690 | + keeps using whatever `verify_fn` the caller passed, unchanged (FR-A8). | ||
| 691 | + | ||
| 692 | + **loop_back (2026-08-08, Work Graph self-planning + loop-back)**: a | ||
| 693 | + node declaring its own `judgment` can also declare `on_fail.action`: | ||
| 694 | + `hard_fail` (fail the whole run immediately, no retry), `retry_self` | ||
| 695 | + (loop back to itself), or `loop_back` (jump to an earlier node named | ||
| 696 | + by that node's own `edge_type: "on_failure"` edge -- see | ||
| 697 | + `_on_failure_target`). `retry_self`/`loop_back` share one mechanism: | ||
| 698 | + the target node (and everything transitively depending on it, since | ||
| 699 | + their input is now stale) is re-executed with the full accumulated | ||
| 700 | + history of every prior rejection appended to its prompt | ||
| 701 | + (`_loop_history_prompt_suffix`), bounded per (target, source) pair by | ||
| 702 | + `_resolve_loop_limit` -- exceeding it raises `ConsistencyCheckFailedError` | ||
| 703 | + same as the legacy single-retry path. A node with NO `judgment` field | ||
| 704 | + (every graph_manifest that predates this) is entirely unaffected -- | ||
| 705 | + it keeps going through `_verify_and_maybe_regenerate` exactly as | ||
| 706 | + before, regardless of any of this (FR-A8). | ||
| 707 | + | ||
| 375 | Returns {node_id: output_payload}. | 708 | Returns {node_id: output_payload}. |
| 376 | """ | 709 | """ |
| 710 | + _validate_mechanical_check_refs(graph_manifest, mechanical_check_registry) | ||
| 377 | ensure_placeholder_skill(conn, skill_id, org_node) | 711 | ensure_placeholder_skill(conn, skill_id, org_node) |
| 378 | effective_needs_worktree = needs_worktree or paths.is_self_target(target_folder_path) | 712 | effective_needs_worktree = needs_worktree or paths.is_self_target(target_folder_path) |
| 379 | layers = topological_layers(graph_manifest) | 713 | layers = topological_layers(graph_manifest) |
| 714 | + node_layer_index = {node["node_id"]: i for i, layer in enumerate(layers) for node in layer} | ||
| 380 | outputs = {} | 715 | outputs = {} |
| 381 | locked_conn = _LockedConn(conn) | 716 | locked_conn = _LockedConn(conn) |
| 382 | 717 | ||
| 383 | - for layer in layers: | ||
| 384 | - if len(layer) == 1: | ||
| 385 | - node = layer[0] | ||
| 386 | - outputs[node["node_id"]] = _run_one_node( | ||
| 387 | - conn, run_id, org_node, skill_id, target_folder_path, node, outputs, | ||
| 388 | - build_prompt, input_schema, output_schema, timeout, | ||
| 389 | - effective_needs_worktree, verify_fn, sleep, now, | ||
| 390 | - ) | 718 | + try: |
| 719 | + return _run_layers( | ||
| 720 | + conn, run_id, org_node, skill_id, target_folder_path, layers, node_layer_index, outputs, | ||
| 721 | + graph_manifest, build_prompt, input_schema, output_schema, timeout, effective_needs_worktree, | ||
| 722 | + verify_fn, sleep, now, max_workers, mechanical_check_registry, locked_conn, | ||
| 723 | + ) | ||
| 724 | + except Exception: | ||
| 725 | + # 2026-08-08 (found via a real self-planned run): only three specific, | ||
| 726 | + # anticipated failure paths (loop_back exceeding its limit, | ||
| 727 | + # on_fail.action='hard_fail', the legacy _verify_and_maybe_regenerate | ||
| 728 | + # double-failure) ever mark work_graph_run terminal. Any OTHER | ||
| 729 | + # exception -- e.g. a bare KeyError from a caller's build_prompt(), | ||
| 730 | + # which is exactly what a real run hit -- used to propagate straight | ||
| 731 | + # out of run_graph() leaving the row stuck at its non-terminal status | ||
| 732 | + # forever, permanently blocking every future attempt for this | ||
| 733 | + # (task_id, org_node) via ux_work_graph_run_active's UNIQUE index | ||
| 734 | + # (schema.py) -- the retry itself would fail immediately on the | ||
| 735 | + # INSERT, with no clean error explaining why. This is a catch-all | ||
| 736 | + # safety net, not a substitute for the three specific handlers above | ||
| 737 | + # (which run first and set more specific end-states along the way); | ||
| 738 | + # the WHERE clause is a no-op if one of them (or a nested call) had | ||
| 739 | + # already marked this run terminal. | ||
| 740 | + conn.execute( | ||
| 741 | + "UPDATE work_graph_run SET status = 'blocked' WHERE run_id = ? " | ||
| 742 | + "AND status IN ('planning', 'running', 'merging', 'verifying', 'reflecting')", | ||
| 743 | + (run_id,), | ||
| 744 | + ) | ||
| 745 | + conn.commit() | ||
| 746 | + raise | ||
| 747 | + | ||
| 748 | + | ||
| 749 | +def _run_layers( | ||
| 750 | + conn, run_id, org_node, skill_id, target_folder_path, layers, node_layer_index, outputs, | ||
| 751 | + graph_manifest, build_prompt, input_schema, output_schema, timeout, effective_needs_worktree, | ||
| 752 | + verify_fn, sleep, now, max_workers, mechanical_check_registry, locked_conn, | ||
| 753 | +): | ||
| 754 | + """run_graph()'s actual node-by-node/layer-by-layer execution loop -- | ||
| 755 | + split out so run_graph() itself can wrap the whole thing in one | ||
| 756 | + try/except (see its own comment) without deeply nesting this loop.""" | ||
| 757 | + layer_index = 0 | ||
| 758 | + while layer_index < len(layers): | ||
| 759 | + pending = [node for node in layers[layer_index] if node["node_id"] not in outputs] | ||
| 760 | + if not pending: | ||
| 761 | + layer_index += 1 | ||
| 391 | continue | 762 | continue |
| 392 | 763 | ||
| 393 | - worker_count = min(max_workers, len(layer)) | ||
| 394 | - errors = [] | ||
| 395 | - with concurrent.futures.ThreadPoolExecutor(max_workers=worker_count) as pool: | ||
| 396 | - futures = { | ||
| 397 | - pool.submit( | ||
| 398 | - _run_one_node, locked_conn, run_id, org_node, skill_id, target_folder_path, | ||
| 399 | - node, outputs, build_prompt, input_schema, output_schema, timeout, | ||
| 400 | - effective_needs_worktree, verify_fn, sleep, now, | ||
| 401 | - ): node | ||
| 402 | - for node in layer | ||
| 403 | - } | ||
| 404 | - done, _ = concurrent.futures.wait(futures, return_when=concurrent.futures.ALL_COMPLETED) | ||
| 405 | - for future in done: | ||
| 406 | - node = futures[future] | ||
| 407 | - try: | ||
| 408 | - outputs[node["node_id"]] = future.result() | ||
| 409 | - except Exception as e: | ||
| 410 | - errors.append((node["node_id"], e)) | ||
| 411 | - | ||
| 412 | - if errors: | ||
| 413 | - raise WorkGraphLayerError(errors) | 764 | + loop_back_signal = None |
| 765 | + if len(pending) == 1: | ||
| 766 | + node = pending[0] | ||
| 767 | + try: | ||
| 768 | + outputs[node["node_id"]] = _run_one_node( | ||
| 769 | + conn, run_id, org_node, skill_id, target_folder_path, node, outputs, graph_manifest, | ||
| 770 | + build_prompt, input_schema, output_schema, timeout, | ||
| 771 | + effective_needs_worktree, verify_fn, sleep, now, mechanical_check_registry, | ||
| 772 | + ) | ||
| 773 | + except _LoopBackRequested as sig: | ||
| 774 | + loop_back_signal = sig | ||
| 775 | + else: | ||
| 776 | + worker_count = min(max_workers, len(pending)) | ||
| 777 | + errors = [] | ||
| 778 | + loop_back_signals = [] | ||
| 779 | + with concurrent.futures.ThreadPoolExecutor(max_workers=worker_count) as pool: | ||
| 780 | + futures = { | ||
| 781 | + pool.submit( | ||
| 782 | + _run_one_node, locked_conn, run_id, org_node, skill_id, target_folder_path, | ||
| 783 | + node, outputs, graph_manifest, build_prompt, input_schema, output_schema, timeout, | ||
| 784 | + effective_needs_worktree, verify_fn, sleep, now, mechanical_check_registry, | ||
| 785 | + ): node | ||
| 786 | + for node in pending | ||
| 787 | + } | ||
| 788 | + done, _ = concurrent.futures.wait(futures, return_when=concurrent.futures.ALL_COMPLETED) | ||
| 789 | + for future in done: | ||
| 790 | + node = futures[future] | ||
| 791 | + try: | ||
| 792 | + outputs[node["node_id"]] = future.result() | ||
| 793 | + except _LoopBackRequested as sig: | ||
| 794 | + loop_back_signals.append(sig) | ||
| 795 | + except Exception as e: | ||
| 796 | + errors.append((node["node_id"], e)) | ||
| 797 | + | ||
| 798 | + if loop_back_signals: | ||
| 799 | + # A layer can have at most one *winning* rewind per pass -- | ||
| 800 | + # pick deterministically (lowest source_node_id). Any plain | ||
| 801 | + # `errors` from OTHER siblings in this same pass are | ||
| 802 | + # dropped, not raised: they'll surface again naturally on | ||
| 803 | + # the next pass if still genuinely wrong after the rewind | ||
| 804 | + # (same "accept a known, documented limitation rather than | ||
| 805 | + # build a bigger coordinator" philosophy as the quota-wait | ||
| 806 | + # limitation already documented above for G). | ||
| 807 | + loop_back_signal = sorted(loop_back_signals, key=lambda s: s.source_node_id)[0] | ||
| 808 | + elif errors: | ||
| 809 | + raise WorkGraphLayerError(errors) | ||
| 810 | + | ||
| 811 | + if loop_back_signal is not None: | ||
| 812 | + sig = loop_back_signal | ||
| 813 | + attempt_number = _loop_attempt_count_from_events( | ||
| 814 | + conn, run_id, sig.target_node_id, sig.source_node_id | ||
| 815 | + ) + 1 | ||
| 816 | + limit = _resolve_loop_limit(conn, org_node, graph_manifest, sig.source_node_id) | ||
| 817 | + if attempt_number > limit: | ||
| 818 | + conn.execute("UPDATE work_graph_run SET status = 'blocked' WHERE run_id = ?", (run_id,)) | ||
| 819 | + conn.commit() | ||
| 820 | + raise ConsistencyCheckFailedError( | ||
| 821 | + "loop_back from {} to {} exceeded max_attempts={}".format( | ||
| 822 | + sig.source_node_id, sig.target_node_id, limit | ||
| 823 | + ), | ||
| 824 | + node_id=sig.source_node_id, | ||
| 825 | + ) | ||
| 826 | + _record_loop_back_event(conn, run_id, sig, attempt_number) | ||
| 827 | + for affected_id in _transitive_dependents(graph_manifest, sig.target_node_id): | ||
| 828 | + outputs.pop(affected_id, None) | ||
| 829 | + layer_index = node_layer_index[sig.target_node_id] | ||
| 830 | + continue | ||
| 831 | + | ||
| 832 | + layer_index += 1 | ||
| 414 | 833 | ||
| 415 | return outputs | 834 | return outputs |
| 416 | 835 | ||
| 417 | 836 | ||
| 418 | def _run_one_node( | 837 | def _run_one_node( |
| 419 | - conn, run_id, org_node, skill_id, target_folder_path, node, outputs, | 838 | + conn, run_id, org_node, skill_id, target_folder_path, node, outputs, graph_manifest, |
| 420 | build_prompt, input_schema, output_schema, timeout, needs_worktree, verify_fn, sleep, now, | 839 | build_prompt, input_schema, output_schema, timeout, needs_worktree, verify_fn, sleep, now, |
| 840 | + mechanical_check_registry=None, | ||
| 421 | ): | 841 | ): |
| 422 | """One node's full lifecycle (row creation, prompt, run, optional | 842 | """One node's full lifecycle (row creation, prompt, run, optional |
| 423 | - verify+regenerate) -- shared by both the synchronous single-node-layer | ||
| 424 | - path and the ThreadPoolExecutor multi-node-layer path in run_graph(), | ||
| 425 | - so there's exactly one implementation of "run a node" to maintain.""" | 843 | + verify+regenerate/loop_back) -- shared by both the synchronous |
| 844 | + single-node-layer path and the ThreadPoolExecutor multi-node-layer | ||
| 845 | + path in run_graph(), so there's exactly one implementation of "run a | ||
| 846 | + node" to maintain. | ||
| 847 | + | ||
| 848 | + 2026-08-08: uses `_next_exec_node_id` rather than always the plain | ||
| 849 | + node_id -- see that function's own docstring for why (a loop_back | ||
| 850 | + target must never collide with, and short-circuit against, its own | ||
| 851 | + stale pre-loop_back 'done' row; every other node keeps resuming | ||
| 852 | + exactly like before).""" | ||
| 426 | node_id = node["node_id"] | 853 | node_id = node["node_id"] |
| 854 | + exec_node_id_str = _next_exec_node_id(conn, run_id, node_id, graph_manifest) | ||
| 855 | + | ||
| 427 | node_exec_id = _ensure_node_row( | 856 | node_exec_id = _ensure_node_row( |
| 428 | - conn, run_id, node_id, org_node, skill_id, | 857 | + conn, run_id, exec_node_id_str, org_node, skill_id, |
| 429 | "work_graph_node_input.schema.json", "work_graph_node_output.schema.json", | 858 | "work_graph_node_input.schema.json", "work_graph_node_output.schema.json", |
| 430 | {"node_id": node_id}, | 859 | {"node_id": node_id}, |
| 431 | ) | 860 | ) |
| 432 | - prompt = build_prompt(node, outputs) | 861 | + prompt = build_prompt(node, outputs) + _loop_history_prompt_suffix(conn, run_id, node_id) |
| 433 | run_fn = _make_run_fn(conn, target_folder_path, node_exec_id, org_node, prompt, output_schema, timeout) | 862 | run_fn = _make_run_fn(conn, target_folder_path, node_exec_id, org_node, prompt, output_schema, timeout) |
| 434 | output = work_node_run.run_work_node( | 863 | output = work_node_run.run_work_node( |
| 435 | conn, node_exec_id, {"node_id": node_id}, input_schema, output_schema, run_fn, | 864 | conn, node_exec_id, {"node_id": node_id}, input_schema, output_schema, run_fn, |
| 436 | needs_worktree=needs_worktree, repo_path=target_folder_path, sleep=sleep, now=now, | 865 | needs_worktree=needs_worktree, repo_path=target_folder_path, sleep=sleep, now=now, |
| 437 | ) | 866 | ) |
| 438 | - if verify_fn is not None: | ||
| 439 | - output = _verify_and_maybe_regenerate( | 867 | + |
| 868 | + judgment = node.get("judgment") | ||
| 869 | + has_own_judgment = bool(judgment) and judgment.get("kind", "none") != "none" | ||
| 870 | + resolved_verify_fn = _resolve_node_verify_fn( | ||
| 871 | + node, verify_fn, org_node, target_folder_path, mechanical_check_registry | ||
| 872 | + ) | ||
| 873 | + if resolved_verify_fn is None: | ||
| 874 | + return output | ||
| 875 | + | ||
| 876 | + if not has_own_judgment: | ||
| 877 | + # Legacy path (FR-A8): this node has no judgment of its own -- | ||
| 878 | + # whatever external verify_fn the caller passed behaves exactly as | ||
| 879 | + # before (_verify_and_maybe_regenerate does its OWN verify_fn | ||
| 880 | + # call internally -- checking here too would call it twice for | ||
| 881 | + # every node, once redundantly), regardless of any on_fail field | ||
| 882 | + # (legacy nodes don't have one). | ||
| 883 | + return _verify_and_maybe_regenerate( | ||
| 440 | conn, run_id, org_node, skill_id, target_folder_path, node, node_id, prompt, | 884 | conn, run_id, org_node, skill_id, target_folder_path, node, node_id, prompt, |
| 441 | - output, input_schema, output_schema, timeout, needs_worktree, verify_fn, sleep, now, | 885 | + output, input_schema, output_schema, timeout, needs_worktree, resolved_verify_fn, sleep, now, |
| 886 | + ) | ||
| 887 | + | ||
| 888 | + ok, feedback = resolved_verify_fn(node, output) | ||
| 889 | + if ok: | ||
| 890 | + return output | ||
| 891 | + | ||
| 892 | + action = (node.get("on_fail") or {}).get("action", "retry_self") | ||
| 893 | + if action == "hard_fail": | ||
| 894 | + conn.execute("UPDATE work_graph_run SET status = 'blocked' WHERE run_id = ?", (run_id,)) | ||
| 895 | + conn.commit() | ||
| 896 | + raise ConsistencyCheckFailedError( | ||
| 897 | + "node {} failed judgment with on_fail.action='hard_fail': {}".format( | ||
| 898 | + node_id, "; ".join(feedback or []) | ||
| 899 | + ), | ||
| 900 | + node_id=node_id, | ||
| 442 | ) | 901 | ) |
| 443 | - return output | 902 | + target = node_id if action == "retry_self" else _on_failure_target(graph_manifest, node_id) |
| 903 | + raise _LoopBackRequested(source_node_id=node_id, target_node_id=target, feedback=feedback) | ||
| 444 | 904 | ||
| 445 | 905 | ||
| 446 | def _verify_and_maybe_regenerate( | 906 | def _verify_and_maybe_regenerate( |
src/work_graph_plan.py
| @@ -46,6 +46,24 @@ GRAPH_MANIFEST_SCHEMA = { | @@ -46,6 +46,24 @@ GRAPH_MANIFEST_SCHEMA = { | ||
| 46 | "enum": ["brd", "prd", "sdd", "benchmark", "test_dataset", "test_script"], | 46 | "enum": ["brd", "prd", "sdd", "benchmark", "test_dataset", "test_script"], |
| 47 | }, | 47 | }, |
| 48 | "depends_on": {"type": "array", "items": {"type": "string"}}, | 48 | "depends_on": {"type": "array", "items": {"type": "string"}}, |
| 49 | + # 2026-08-08 (Work Graph self-planning + loop-back) -- both optional, | ||
| 50 | + # documentation only (this schema isn't jsonschema-enforced, see | ||
| 51 | + # module docstring's --json-schema/--agent note). | ||
| 52 | + "judgment": { | ||
| 53 | + "type": "object", | ||
| 54 | + "properties": { | ||
| 55 | + "kind": {"type": "string", "enum": ["none", "mechanical", "llm"]}, | ||
| 56 | + "check_ref": {"type": "string"}, | ||
| 57 | + "judge_prompt": {"type": "string"}, | ||
| 58 | + }, | ||
| 59 | + }, | ||
| 60 | + "on_fail": { | ||
| 61 | + "type": "object", | ||
| 62 | + "properties": { | ||
| 63 | + "action": {"type": "string", "enum": ["retry_self", "loop_back", "hard_fail"]}, | ||
| 64 | + "max_attempts": {"type": "integer"}, | ||
| 65 | + }, | ||
| 66 | + }, | ||
| 49 | }, | 67 | }, |
| 50 | }, | 68 | }, |
| 51 | }, | 69 | }, |
| @@ -57,24 +75,87 @@ GRAPH_MANIFEST_SCHEMA = { | @@ -57,24 +75,87 @@ GRAPH_MANIFEST_SCHEMA = { | ||
| 57 | "properties": { | 75 | "properties": { |
| 58 | "from": {"type": "string"}, | 76 | "from": {"type": "string"}, |
| 59 | "to": {"type": "string"}, | 77 | "to": {"type": "string"}, |
| 60 | - "edge_type": {"type": "string", "enum": ["zero_token", "needs_judgment"]}, | 78 | + # "on_failure" (2026-08-08): a node's failure path, expressed the |
| 79 | + # same way its success path already is -- see _on_failure_target | ||
| 80 | + # in work_graph_orchestrate.py. | ||
| 81 | + "edge_type": {"type": "string", "enum": ["zero_token", "needs_judgment", "on_failure"]}, | ||
| 61 | }, | 82 | }, |
| 62 | }, | 83 | }, |
| 63 | }, | 84 | }, |
| 64 | }, | 85 | }, |
| 65 | } | 86 | } |
| 66 | 87 | ||
| 88 | +# 2026-08-08 (Work Graph self-planning + loop-back, docs/todo/work-graph- | ||
| 89 | +# self-planning-and-loop-back-requirements.md, FR-B4 "全部自由规划"): | ||
| 90 | +# Planner's doc-writing chain is no longer a fixed architectural constant | ||
| 91 | +# (formally reverses docs/done/dynamic-work-graph-design-skill-design.md | ||
| 92 | +# §3's decision) -- the LLM decides which node types this goal needs, in | ||
| 93 | +# what order, and how each one's judgment/on_fail should work. This | ||
| 94 | +# catalog is reference DATA the prompt hands the LLM, not a requirement | ||
| 95 | +# that every entry appears -- FR-B2. test_script (disabled 2026-08-07, | ||
| 96 | +# docs/todo/validator-test-script-narrative-output-issue.md) is back in | ||
| 97 | +# this catalog per that decision's §8 item 7: Part B doesn't wait for the | ||
| 98 | +# narrative-output structural fix, that fix is tracked separately | ||
| 99 | +# alongside a future Coder-side test_script module. | ||
| 100 | +_PRD_JUDGE_PROMPT = ( | ||
| 101 | + "Review the following PRD for internally CONTRADICTORY scenario assumptions -- e.g. two " | ||
| 102 | + "requirements that cannot both hold at once, or the same scenario described two incompatible " | ||
| 103 | + "ways. Do NOT flag mere incompleteness, ambiguity, or style issues, only genuine " | ||
| 104 | + "self-contradiction. Reply via the required schema: ok=false only if you found a real " | ||
| 105 | + "contradiction, and list each one in issues.\n\nPRD:\n\n{content}" | ||
| 106 | +) | ||
| 107 | + | ||
| 108 | +_PLANNER_NODE_CATALOG = [ | ||
| 109 | + {"artifact_type": "brd", "description": "Business Requirements Document -- why this goal matters, who it's for."}, | ||
| 110 | + { | ||
| 111 | + "artifact_type": "prd", "description": "Product Requirements Document -- concrete scenarios/behavior.", | ||
| 112 | + "suggested_judgment": {"kind": "llm", "judge_prompt": _PRD_JUDGE_PROMPT}, | ||
| 113 | + }, | ||
| 114 | + {"artifact_type": "sdd", "description": "Solution Design Document -- how it will be implemented."}, | ||
| 115 | + {"artifact_type": "benchmark", "description": "Concrete acceptance scenarios with observable pass criteria."}, | ||
| 116 | + { | ||
| 117 | + "artifact_type": "test_dataset", | ||
| 118 | + "description": "Concrete test data (inputs + expected outputs) if this goal has a natural one -- " | ||
| 119 | + "many goals (UI wiring, config changes) don't, that's fine, omit this node then.", | ||
| 120 | + }, | ||
| 121 | + { | ||
| 122 | + "artifact_type": "test_script", | ||
| 123 | + "description": "A real, runnable script that mechanically checks the Benchmark's scenarios -- " | ||
| 124 | + "only include this if a genuine mechanical check is possible for this goal.", | ||
| 125 | + "suggested_judgment": {"kind": "mechanical", "check_ref": "check_script_compiles"}, | ||
| 126 | + }, | ||
| 127 | +] | ||
| 128 | + | ||
| 129 | +# NOTE: no `str.format()` involved in building or filling this template | ||
| 130 | +# (unlike _GENERIC_PROMPT_TEMPLATE) -- the embedded catalog JSON and the | ||
| 131 | +# instructional JSON examples below (e.g. {"kind": "none"}) are full of | ||
| 132 | +# their own literal braces, which .format() would try to parse as format | ||
| 133 | +# fields and crash on. plan_work_graph() fills in goal_text with a plain | ||
| 134 | +# `.replace("{goal_text}", goal_text)` instead, which treats every other | ||
| 135 | +# brace in this string as inert literal text -- no escaping needed at all. | ||
| 67 | _PROMPT_TEMPLATE = ( | 136 | _PROMPT_TEMPLATE = ( |
| 68 | - "Plan (do not execute) the Work Graph needed to produce BRD, PRD, SDD, Benchmark, " | ||
| 69 | - "Test Dataset, and Test Script artifacts for this goal: {goal_text}\n" | ||
| 70 | - "Output a graph_manifest with one node per artifact_type (brd, prd, sdd, benchmark, " | ||
| 71 | - "test_dataset, test_script) and edges expressing their dependency chain (sdd depends " | ||
| 72 | - "on prd depends on brd, benchmark depends on sdd, test_dataset depends on benchmark, " | ||
| 73 | - "test_script depends on test_dataset). Every edge needs edge_type: 'zero_token' for " | ||
| 74 | - "pure aggregation/reduce steps, 'needs_judgment' for anything requiring the agent to " | ||
| 75 | - "actually reason about content.\n" | 137 | + "Plan (do not execute) the Work Graph for this goal: {goal_text}\n" |
| 138 | + "Available node types (use any subset, in any order that makes sense -- this goal may not " | ||
| 139 | + "need all of them, and 'suggested_judgment' entries are starting points you can reuse or " | ||
| 140 | + "override, not requirements):\n" + json.dumps(_PLANNER_NODE_CATALOG) + "\n" | ||
| 141 | + "For each node you include, decide: node_id, artifact_type, depends_on (which earlier nodes' " | ||
| 142 | + "output it genuinely needs), a judgment config (how to tell if this node's output is " | ||
| 143 | + "acceptable -- {\"kind\": \"none\"} if nothing needs checking, {\"kind\": \"mechanical\", " | ||
| 144 | + "\"check_ref\": ...} for a named mechanical check, or {\"kind\": \"llm\", \"judge_prompt\": " | ||
| 145 | + "\"...{content}...\"} for your own judgment prompt with a {content} placeholder), and an " | ||
| 146 | + "on_fail config (what happens if judgment fails: {\"action\": \"retry_self\"}, " | ||
| 147 | + "{\"action\": \"loop_back\"}, or {\"action\": \"hard_fail\"}, each optionally with " | ||
| 148 | + "\"max_attempts\"). If a node's on_fail.action is 'loop_back', you MUST also add a " | ||
| 149 | + "corresponding edge in the top-level 'edges' array with edge_type 'on_failure', from that " | ||
| 150 | + "node to the exact earlier node_id it should return to -- the target must be a real upstream " | ||
| 151 | + "dependency of this node (something that genuinely produced input this node used), not an " | ||
| 152 | + "arbitrary node. This mirrors how the success path already works: every node's 'yes, this " | ||
| 153 | + "succeeded, what's next' is expressed as a normal dependency edge, and 'no, this failed, go " | ||
| 154 | + "back to X' is expressed the same way, just with edge_type 'on_failure' instead. Every other " | ||
| 155 | + "edge needs edge_type: 'zero_token' for pure aggregation/reduce steps, 'needs_judgment' for " | ||
| 156 | + "anything requiring the agent to actually reason about content.\n" | ||
| 76 | "End your reply with the graph_manifest as a single fenced ```json code block " | 157 | "End your reply with the graph_manifest as a single fenced ```json code block " |
| 77 | - "shaped exactly like {{\"nodes\": [...], \"edges\": [...]}} -- no extra wrapper key." | 158 | + "shaped exactly like {\"nodes\": [...], \"edges\": [...]} -- no extra wrapper key." |
| 78 | ) | 159 | ) |
| 79 | 160 | ||
| 80 | # E (2026-08-06): the generic, cross-Org-Agent path -- used for every | 161 | # E (2026-08-06): the generic, cross-Org-Agent path -- used for every |
| @@ -134,7 +215,72 @@ def _extract_graph_manifest(text): | @@ -134,7 +215,72 @@ def _extract_graph_manifest(text): | ||
| 134 | return None | 215 | return None |
| 135 | 216 | ||
| 136 | 217 | ||
| 137 | -_CANONICAL_DOC_CHAIN = ("brd", "prd", "sdd", "benchmark", "test_dataset", "test_script") | 218 | +def _transitive_ancestors(graph_manifest, node_id): |
| 219 | + """node_id itself + every node it (directly or transitively) depends | ||
| 220 | + on, via `depends_on`/normal data-dependency edges (`on_failure` edges | ||
| 221 | + excluded -- they're control-flow, not data dependency, same reasoning | ||
| 222 | + as work_graph_orchestrate.py's own `_transitive_dependents`/ | ||
| 223 | + `topological_layers`). Deliberately reimplemented here rather than | ||
| 224 | + imported from work_graph_orchestrate.py -- the two modules don't | ||
| 225 | + depend on each other today (work_graph_orchestrate.py is a lower-level | ||
| 226 | + execution engine, work_graph_plan.py is planning-only) and this is a | ||
| 227 | + small enough function that duplicating it doesn't justify introducing | ||
| 228 | + that coupling.""" | ||
| 229 | + deps = {n.get("node_id"): set(n.get("depends_on") or []) for n in graph_manifest.get("nodes", [])} | ||
| 230 | + for edge in graph_manifest.get("edges", []): | ||
| 231 | + if edge.get("edge_type") == "on_failure": | ||
| 232 | + continue | ||
| 233 | + from_id, to_id = edge.get("from"), edge.get("to") | ||
| 234 | + if to_id in deps and from_id in deps: | ||
| 235 | + deps[to_id].add(from_id) | ||
| 236 | + | ||
| 237 | + ancestors, stack = set(), [node_id] | ||
| 238 | + while stack: | ||
| 239 | + current = stack.pop() | ||
| 240 | + if current in ancestors: | ||
| 241 | + continue | ||
| 242 | + ancestors.add(current) | ||
| 243 | + stack.extend(deps.get(current, [])) | ||
| 244 | + return ancestors | ||
| 245 | + | ||
| 246 | + | ||
| 247 | +def _validate_loop_back_targets(manifest): | ||
| 248 | + """2026-08-08 (Work Graph self-planning + loop-back): every node | ||
| 249 | + declaring `on_fail.action == 'loop_back'` must have exactly one | ||
| 250 | + `edge_type: 'on_failure'` edge originating from it (see | ||
| 251 | + work_graph_orchestrate.py's `_on_failure_target`), and that edge's | ||
| 252 | + target must be a genuine upstream dependency -- looping back to a node | ||
| 253 | + that never influenced this one has no coherent meaning. Raises | ||
| 254 | + PlanningFailedError (not a generic ValueError) so this surfaces the | ||
| 255 | + same way every other planning failure does, before any node ever runs | ||
| 256 | + -- catching a malformed loop_back here is far cheaper than discovering | ||
| 257 | + it mid-execution.""" | ||
| 258 | + node_ids = {n.get("node_id") for n in manifest.get("nodes", [])} | ||
| 259 | + for node in manifest.get("nodes", []): | ||
| 260 | + if (node.get("on_fail") or {}).get("action") != "loop_back": | ||
| 261 | + continue | ||
| 262 | + node_id = node.get("node_id") | ||
| 263 | + on_failure_edges = [ | ||
| 264 | + e for e in manifest.get("edges", []) | ||
| 265 | + if e.get("from") == node_id and e.get("edge_type") == "on_failure" | ||
| 266 | + ] | ||
| 267 | + if len(on_failure_edges) != 1: | ||
| 268 | + raise PlanningFailedError( | ||
| 269 | + "node {} declares on_fail.action='loop_back' but has {} on_failure edge(s), " | ||
| 270 | + "expected exactly 1".format(node_id, len(on_failure_edges)) | ||
| 271 | + ) | ||
| 272 | + target = on_failure_edges[0].get("to") | ||
| 273 | + if target not in node_ids: | ||
| 274 | + raise PlanningFailedError( | ||
| 275 | + "node {}'s on_failure edge targets {!r}, which does not exist in this graph_manifest".format( | ||
| 276 | + node_id, target | ||
| 277 | + ) | ||
| 278 | + ) | ||
| 279 | + if target not in _transitive_ancestors(manifest, node_id): | ||
| 280 | + raise PlanningFailedError( | ||
| 281 | + "node {}'s on_failure edge targets {!r}, which is not an upstream dependency -- " | ||
| 282 | + "loop_back must target a node that genuinely influenced this one".format(node_id, target) | ||
| 283 | + ) | ||
| 138 | 284 | ||
| 139 | 285 | ||
| 140 | def _normalize_graph_manifest(manifest): | 286 | def _normalize_graph_manifest(manifest): |
| @@ -150,23 +296,22 @@ def _normalize_graph_manifest(manifest): | @@ -150,23 +296,22 @@ def _normalize_graph_manifest(manifest): | ||
| 150 | depends_on or edges" fallback logic repeated independently. Mutates and | 296 | depends_on or edges" fallback logic repeated independently. Mutates and |
| 151 | returns `manifest`. | 297 | returns `manifest`. |
| 152 | 298 | ||
| 153 | - **Canonical doc-chain enforcement (2026-08-03, real self-hosting run)**: | ||
| 154 | - a real call was observed to omit the brd->prd->sdd->benchmark | ||
| 155 | - dependency entirely -- no `depends_on`, no `edges` connecting them at | ||
| 156 | - all, not just the wrong key names the edge-derived backfill above can | ||
| 157 | - already recover from. With no dependency info to normalize from, | ||
| 158 | - `topological_order()` scheduled `benchmark` before `sdd` even ran | ||
| 159 | - (alphabetically first among nodes with an empty in-degree), and | ||
| 160 | - `e2e_pipeline.build_prompt()` crashed with `KeyError: 'sdd_content'` | ||
| 161 | - trying to format a template that needed sdd's not-yet-produced output. | ||
| 162 | - `_PROMPT_TEMPLATE` always asks for exactly this fixed 4-node linear | ||
| 163 | - chain -- it's a constant architectural fact about what the Planner's | ||
| 164 | - doc-writing graph looks like, not something that genuinely varies call | ||
| 165 | - to call, so don't leave it to chance: whenever all four canonical | ||
| 166 | - artifact_types are present, deterministically enforce the known linear | ||
| 167 | - order regardless of what edges/depends_on the LLM did or didn't | ||
| 168 | - provide. Same "机制用代码" principle already applied elsewhere | ||
| 169 | - (`work_node_run.ensure_worktree_committed`, etc.). | 299 | + 2026-08-08 (Work Graph self-planning + loop-back, FR-B4 "全部自由规划"): |
| 300 | + the canonical-doc-chain forced-order enforcement that used to live here | ||
| 301 | + (2026-08-03 incident: a real call omitted brd->prd->sdd->benchmark's | ||
| 302 | + dependency entirely, scheduling nodes out of order) is REMOVED, not | ||
| 303 | + extended -- Planner's node set/order is no longer a fixed architectural | ||
| 304 | + constant (formally reverses docs/done/dynamic-work-graph-design-skill- | ||
| 305 | + design.md §3). Robustness against that same failure mode now rests | ||
| 306 | + entirely on the edge-derived depends_on backfill below (unchanged) plus | ||
| 307 | + `_validate_loop_back_targets` (new, below) -- there is no longer a | ||
| 308 | + hardcoded canonical chain to fall back on if an LLM response omits | ||
| 309 | + dependency info, so a real reproduction of the 2026-08-03 incident | ||
| 310 | + shape is a genuine, currently-unmitigated risk this design accepted | ||
| 311 | + (see docs/todo/work-graph-self-planning-and-loop-back-requirements.md | ||
| 312 | + §7's risk list) -- FR-B3's own acceptance criterion only covers the | ||
| 313 | + "wrong key names" failure mode this backfill already handles, not a | ||
| 314 | + complete absence of dependency info with no canonical chain to guess from. | ||
| 170 | """ | 315 | """ |
| 171 | nodes = manifest.get("nodes", []) | 316 | nodes = manifest.get("nodes", []) |
| 172 | for node in nodes: | 317 | for node in nodes: |
| @@ -176,33 +321,15 @@ def _normalize_graph_manifest(manifest): | @@ -176,33 +321,15 @@ def _normalize_graph_manifest(manifest): | ||
| 176 | 321 | ||
| 177 | node_ids = {n.get("node_id") for n in nodes} | 322 | node_ids = {n.get("node_id") for n in nodes} |
| 178 | for edge in manifest.get("edges", []): | 323 | for edge in manifest.get("edges", []): |
| 324 | + if edge.get("edge_type") == "on_failure": | ||
| 325 | + continue # control-flow, not a data dependency -- see _transitive_ancestors | ||
| 179 | from_id, to_id = edge.get("from"), edge.get("to") | 326 | from_id, to_id = edge.get("from"), edge.get("to") |
| 180 | if from_id in node_ids and to_id in node_ids: | 327 | if from_id in node_ids and to_id in node_ids: |
| 181 | for node in nodes: | 328 | for node in nodes: |
| 182 | if node.get("node_id") == to_id and from_id not in node["depends_on"]: | 329 | if node.get("node_id") == to_id and from_id not in node["depends_on"]: |
| 183 | node["depends_on"].append(from_id) | 330 | node["depends_on"].append(from_id) |
| 184 | 331 | ||
| 185 | - by_artifact_type = {n.get("artifact_type"): n for n in nodes} | ||
| 186 | - if all(t in by_artifact_type for t in _CANONICAL_DOC_CHAIN): | ||
| 187 | - for prev_type, cur_type in zip(_CANONICAL_DOC_CHAIN, _CANONICAL_DOC_CHAIN[1:]): | ||
| 188 | - prev_id = by_artifact_type[prev_type]["node_id"] | ||
| 189 | - cur_node = by_artifact_type[cur_type] | ||
| 190 | - if prev_id not in cur_node["depends_on"]: | ||
| 191 | - cur_node["depends_on"].append(prev_id) | ||
| 192 | - | ||
| 193 | - # 2026-08-06: test_script's own prompt needs {benchmark_content} | ||
| 194 | - # directly (not just what survives into test_dataset_content, which | ||
| 195 | - # can be a terse 'N/A: ...' with none of the original scenario | ||
| 196 | - # detail) -- the plain linear chain above only gives it | ||
| 197 | - # test_dataset as an immediate dependency, so add benchmark as a | ||
| 198 | - # second one. e2e_pipeline._planner_prompt_builder already threads | ||
| 199 | - # every *immediate* dependency's content through generically, this | ||
| 200 | - # is the one canonical node that genuinely needs two. | ||
| 201 | - benchmark_id = by_artifact_type["benchmark"]["node_id"] | ||
| 202 | - script_node = by_artifact_type["test_script"] | ||
| 203 | - if benchmark_id not in script_node["depends_on"]: | ||
| 204 | - script_node["depends_on"].append(benchmark_id) | ||
| 205 | - | 332 | + _validate_loop_back_targets(manifest) |
| 206 | return manifest | 333 | return manifest |
| 207 | 334 | ||
| 208 | 335 | ||
| @@ -210,14 +337,18 @@ def plan_work_graph(conn, org_node, task_id, goal_text, target_folder_path, time | @@ -210,14 +337,18 @@ def plan_work_graph(conn, org_node, task_id, goal_text, target_folder_path, time | ||
| 210 | """Returns {"run_id", "graph_manifest"}. Creates one `work_graph_run` row | 337 | """Returns {"run_id", "graph_manifest"}. Creates one `work_graph_run` row |
| 211 | (status='planning') -- moving it to 'running'/executing it is out of scope. | 338 | (status='planning') -- moving it to 'running'/executing it is out of scope. |
| 212 | 339 | ||
| 213 | - E (2026-08-06): `org_node == 'planner'` still gets the fixed | ||
| 214 | - brd/prd/sdd/benchmark/... chain prompt (_PROMPT_TEMPLATE, unchanged). | ||
| 215 | - Every other org_node gets _GENERIC_PROMPT_TEMPLATE -- the cross-Org- | ||
| 216 | - Agent dynamic planning path described in the design-dynamic-work-graph | ||
| 217 | - Skill, whose nodes carry their own task_prompt instead of a fixed | ||
| 218 | - artifact_type. Function signature/return shape are unchanged either way.""" | ||
| 219 | - template = _PROMPT_TEMPLATE if org_node == "planner" else _GENERIC_PROMPT_TEMPLATE | ||
| 220 | - prompt = template.format(goal_text=goal_text) | 340 | + 2026-08-08: `org_node == 'planner'` gets `_PROMPT_TEMPLATE` (now a |
| 341 | + genuine self-planning prompt over `_PLANNER_NODE_CATALOG`, see that | ||
| 342 | + template's own comment for why goal_text is filled in via `.replace()` | ||
| 343 | + rather than `.format()`). Every other org_node gets | ||
| 344 | + `_GENERIC_PROMPT_TEMPLATE` -- the cross-Org-Agent dynamic planning path | ||
| 345 | + described in the design-dynamic-work-graph Skill, whose nodes carry | ||
| 346 | + their own task_prompt instead of a fixed artifact_type. Function | ||
| 347 | + signature/return shape are unchanged either way.""" | ||
| 348 | + if org_node == "planner": | ||
| 349 | + prompt = _PROMPT_TEMPLATE.replace("{goal_text}", goal_text) | ||
| 350 | + else: | ||
| 351 | + prompt = _GENERIC_PROMPT_TEMPLATE.format(goal_text=goal_text) | ||
| 221 | 352 | ||
| 222 | result = subprocess.run( | 353 | result = subprocess.run( |
| 223 | [ | 354 | [ |