Commit 0b32b95abee6355a5a7e98b6cac92f4b8e4c88b6
1 parent
c425ab85
U-031 fix: worktree-isolate the PRD consistency check for self-target
The 4th real self-hosting attempt (first to reach the actual doc-writing Work Graph after the previous three bugs) hit a real git merge failure: "local changes would be overwritten by merge", exit code 2. Root cause: _check_prd_consistency() calls execute_work_graph() directly against target_folder_path, completely bypassing work_node_run.run_work_node's worktree isolation. It uses agentType='planner' with memory:project, which writes a real update to .claude/agent-memory/planner/MEMORY.md in whatever cwd it runs with. Every prior real run of this check only ever targeted a throwaway clone, so this never mattered -- until self-targeting, where cwd IS the main checkout: the memory write landed as an uncommitted local change on master, which then blocked the SDD node's own (correctly isolated) worktree merge right after it. Fix: apply 05 §5.8's same unconditional self-target worktree rule here, reusing work_node_run's existing create_worktree/ensure_worktree_committed/ merge_worktree/remove_worktree rather than inventing a new mechanism. Non-self-target calls are unaffected (verified by both regression tests). Also cleaned up the real, now-recovered state from the failed 4th attempt: merged a real SDD-node worktree branch that got stranded uncommitted (conflict was just two near-identical memory file wordings, resolved by hand), and removed three orphaned worktrees left by earlier test runs. 557 tests green (437 src + 120 supervisor). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
4 changed files
with
96 additions
and
16 deletions
docs/development-plan.md
| @@ -304,6 +304,7 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 | @@ -304,6 +304,7 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 | ||
| 304 | 3. **REQ-06 / REQ-07 / REQ-14 三条 failure 用例共享的同一个真实缺口,是这次审计里最重要的发现**:`CorruptPersistedStateError`(REQ-06)、`ClassifierBlockedError`(REQ-07)、`UsageLimitWaitTimeoutError`(REQ-14)都被正确地抛出,也都正确地把各自的 `work_node_execution.status` 标成了 `blocked`(后者是 U-031 自己加的 catch-all),但从来没有任何代码把这个后果真正传导到 `task.status='blocked_pending_human'`——`work_node_run.py` 自己的 docstring 甚至早就写明"callers should route it",但没有一个调用方真的这么做。这正是 U-032 的调度器该管的事:`e2e_pipeline.py` 拆出 `run_task_lifecycle()`(`run_end_to_end()` 变成"建 task + 调它"的两行包装,签名/行为不变),`scheduler.py` 新增 `_block_task()`(照抄 `budget_guardrail.check_budget()` 已有的 UPDATE + `task_status_changed` 事件写法,不是发明新模式),`run_goal_to_completion()` 自己建 task 后捕获这三种异常并正确路由;其余异常仍然原样冒泡,不吞掉。三个新测试类(`CorruptPersistedStateRoutingTest`/`ClassifierBlockedRoutingTest`/`UsageLimitWaitTimeoutRoutingTest`)逐一验证。 | 304 | 3. **REQ-06 / REQ-07 / REQ-14 三条 failure 用例共享的同一个真实缺口,是这次审计里最重要的发现**:`CorruptPersistedStateError`(REQ-06)、`ClassifierBlockedError`(REQ-07)、`UsageLimitWaitTimeoutError`(REQ-14)都被正确地抛出,也都正确地把各自的 `work_node_execution.status` 标成了 `blocked`(后者是 U-031 自己加的 catch-all),但从来没有任何代码把这个后果真正传导到 `task.status='blocked_pending_human'`——`work_node_run.py` 自己的 docstring 甚至早就写明"callers should route it",但没有一个调用方真的这么做。这正是 U-032 的调度器该管的事:`e2e_pipeline.py` 拆出 `run_task_lifecycle()`(`run_end_to_end()` 变成"建 task + 调它"的两行包装,签名/行为不变),`scheduler.py` 新增 `_block_task()`(照抄 `budget_guardrail.check_budget()` 已有的 UPDATE + `task_status_changed` 事件写法,不是发明新模式),`run_goal_to_completion()` 自己建 task 后捕获这三种异常并正确路由;其余异常仍然原样冒泡,不吞掉。三个新测试类(`CorruptPersistedStateRoutingTest`/`ClassifierBlockedRoutingTest`/`UsageLimitWaitTimeoutRoutingTest`)逐一验证。 |
| 305 | 4. **REQ-02 boundary**(PRD 出现自相矛盾的场景假设应被拦截,触发 `reflecting` 而非直接放行):这条此前完全没有实现——`artifact_write.write_artifact()` 一直无条件把 `validation_status` 写成 `'passed'`,`artifact_write.py` 自己的 docstring 也如实记录了这个缺口。因为这是全新的 LLM 判断能力而不是接线修复,就此询问用户是否现在补上,用户选择"现在就建"。新增 `work_graph_orchestrate.run_graph()` 的可选 `verify_fn` 钩子(每个节点跑完、下游节点开始前调用,命中失败时把 `work_graph_run.status` 写成 `reflecting`、对同一节点重新生成一次并再次校验,仍失败则写 `blocked` 并抛 `ConsistencyCheckFailedError`——`work_graph_run.status` 在此之前从未被任何代码真正写过,一直停在初始的 `'planning'`,这次是它第一次被真正用起来,但只做了 `verifying`/`reflecting`/`blocked` 三个和这条用例直接相关的状态,没有借机把 `running`/`merging` 那一整套图级状态机也补全——04-P 没具体描述过那部分,属于超出这次范围的新设计);`e2e_pipeline._check_prd_consistency()` 是新增的一次真实 `agent(agentType:'planner', schema:...)` 调用,只挂在 PRD 节点上(`_prd_consistency_verify_fn`,其余节点直接放行不调用)。真实验证:`test_e2e_pipeline_e2e.RealPrdConsistencyCheckTest` 用一段刻意自相矛盾的撞墙规则("必须立即结束" vs "可以穿墙不结束")+一段真实一致的 PRD 各跑一次真实调用。**第一次真实跑就抓到一个真 bug**:模型把 `consistent` 字段返回成 JSON 字符串 `"false"` 而不是布尔值 `false`(即使 schema 里已经写了 `"consistent": {"type": "boolean"}`——05 §5.4.1.1 早就说过不能盲目信任 schema 真的会被强制),Python 里 `if "false":` 是真值,直接把"发现矛盾"误判成"通过",是全场景里最隐蔽的一类 bug(mock 测试永远返回真的 Python bool,只有真实调用能暴露)。新增 `_coerce_bool()` 做防御性转换(bool 原样、字符串按大小写不敏感比较 "true"、其余走 `bool()`),修复后重跑真实测试通过;同时补了两个 mock 层回归测试锁死这个具体行为(`test_string_false_from_a_real_call_is_not_treated_as_truthy`/`test_string_true_from_a_real_call_is_treated_as_consistent`)。 | 305 | 4. **REQ-02 boundary**(PRD 出现自相矛盾的场景假设应被拦截,触发 `reflecting` 而非直接放行):这条此前完全没有实现——`artifact_write.write_artifact()` 一直无条件把 `validation_status` 写成 `'passed'`,`artifact_write.py` 自己的 docstring 也如实记录了这个缺口。因为这是全新的 LLM 判断能力而不是接线修复,就此询问用户是否现在补上,用户选择"现在就建"。新增 `work_graph_orchestrate.run_graph()` 的可选 `verify_fn` 钩子(每个节点跑完、下游节点开始前调用,命中失败时把 `work_graph_run.status` 写成 `reflecting`、对同一节点重新生成一次并再次校验,仍失败则写 `blocked` 并抛 `ConsistencyCheckFailedError`——`work_graph_run.status` 在此之前从未被任何代码真正写过,一直停在初始的 `'planning'`,这次是它第一次被真正用起来,但只做了 `verifying`/`reflecting`/`blocked` 三个和这条用例直接相关的状态,没有借机把 `running`/`merging` 那一整套图级状态机也补全——04-P 没具体描述过那部分,属于超出这次范围的新设计);`e2e_pipeline._check_prd_consistency()` 是新增的一次真实 `agent(agentType:'planner', schema:...)` 调用,只挂在 PRD 节点上(`_prd_consistency_verify_fn`,其余节点直接放行不调用)。真实验证:`test_e2e_pipeline_e2e.RealPrdConsistencyCheckTest` 用一段刻意自相矛盾的撞墙规则("必须立即结束" vs "可以穿墙不结束")+一段真实一致的 PRD 各跑一次真实调用。**第一次真实跑就抓到一个真 bug**:模型把 `consistent` 字段返回成 JSON 字符串 `"false"` 而不是布尔值 `false`(即使 schema 里已经写了 `"consistent": {"type": "boolean"}`——05 §5.4.1.1 早就说过不能盲目信任 schema 真的会被强制),Python 里 `if "false":` 是真值,直接把"发现矛盾"误判成"通过",是全场景里最隐蔽的一类 bug(mock 测试永远返回真的 Python bool,只有真实调用能暴露)。新增 `_coerce_bool()` 做防御性转换(bool 原样、字符串按大小写不敏感比较 "true"、其余走 `bool()`),修复后重跑真实测试通过;同时补了两个 mock 层回归测试锁死这个具体行为(`test_string_false_from_a_real_call_is_not_treated_as_truthy`/`test_string_true_from_a_real_call_is_treated_as_consistent`)。 |
| 306 | 5. **REQ-13 failure**:一个次要的测试质量问题(不是功能缺口)——`test_ws_nonexistent_node_rejected_before_upgrade` 只检查了 HTTP 状态行是 404,没检查响应体里的 `NODE_EXEC_NOT_FOUND`(生产代码本身是对的)。顺手加了 `_read_http_response_with_json_body` 辅助函数,把这条和同类的 `PROJECT_NOT_FOUND`(REQ-12 failure)测试都补上了 body 断言。 | 306 | 5. **REQ-13 failure**:一个次要的测试质量问题(不是功能缺口)——`test_ws_nonexistent_node_rejected_before_upgrade` 只检查了 HTTP 状态行是 404,没检查响应体里的 `NODE_EXEC_NOT_FOUND`(生产代码本身是对的)。顺手加了 `_read_http_response_with_json_body` 辅助函数,把这条和同类的 `PROJECT_NOT_FOUND`(REQ-12 failure)测试都补上了 body 断言。 |
| 307 | + 6. **真实 bug(2026-08-03,U-042 第四次真实自举压测发现)**:`_check_prd_consistency()` 直接对 `target_folder_path` 调用 `execute_work_graph()`,完全绕开了 `work_node_run.run_work_node()` 的 worktree 隔离机制——之前每一次真实调用这个函数都是对着一次性 target project 克隆做的,从没自我 targeting 过,所以从没暴露过问题。这次真实自举压测里,这个调用用 `agentType:'planner'` + `memory:project`,会真的往调用时的 cwd 写 `.claude/agent-memory/planner/MEMORY.md`——self-target 场景下 cwd 就是 `target_folder_path` 本身(主仓库),于是这次真实写入变成了主分支工作区里一处未提交的本地改动;紧接着 SDD 节点自己的 worktree 合并回主分支时,`git merge` 因为主分支工作区有未提交改动会覆盖同一个文件而直接报错退出(exit 2),SDD 节点的真实产出被卡在一个从未合并成功的 worktree 分支里。修复:`_check_prd_consistency()` 现在按 05 §5.8 同样的"self-target 无条件强制 worktree"规则,复用 `work_node_run.create_worktree`/`ensure_worktree_committed`/`merge_worktree`/`remove_worktree` 把这次调用也隔离到自己的 worktree 里,不再直接碰主仓库工作区;`paths.is_self_target()` 为 False 时(普通 target project)保持原样直接调用,不引入不必要的开销。新增两个回归测试锁死这个行为分支(self-target 走 worktree、非 self-target 不走)。清理过程中还顺手发现并合并了一个被卡住的真实 worktree 分支(SDD 节点当时的真实产出,合并冲突只是两条几乎一样的 memory 文案措辞不同,手工解决),以及三个此前测试遗留下来从未清理的孤儿 worktree(`git worktree remove`)。 | ||
| 307 | - 全部 5 处修复后,`python3 -m unittest discover -s src` 380 个测试全绿(含新增的);REQ-01~14 现在逐条都有真实覆盖,PH4 可以视为真正收口,PH5 排期不再依赖对这份回归的信任假设。 | 308 | - 全部 5 处修复后,`python3 -m unittest discover -s src` 380 个测试全绿(含新增的);REQ-01~14 现在逐条都有真实覆盖,PH4 可以视为真正收口,PH5 排期不再依赖对这份回归的信任假设。 |
| 308 | - 覆盖:01-M `work_graph_run`/`work_node_execution`(真正被生产代码写入,不再只是测试 fixture);03-C `c_work_graph_plan`/`c_work_graph_execute`/`c_work_node_run`(首次真正接通);REQ-01~14 全部 | 309 | - 覆盖:01-M `work_graph_run`/`work_node_execution`(真正被生产代码写入,不再只是测试 fixture);03-C `c_work_graph_plan`/`c_work_graph_execute`/`c_work_node_run`(首次真正接通);REQ-01~14 全部 |
| 309 | 310 |
docs/sdd/unit_matrix.json
| @@ -47,7 +47,7 @@ | @@ -47,7 +47,7 @@ | ||
| 47 | {"id": "U-029", "phase": "PH3", "sub_phase": "PH3-S3", "name": "v_skill_library + v_skill_audit_log(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-018"], "coverage": {"model": [], "view": ["v_skill_library", "v_skill_audit_log"], "controller": ["c_skill_diff", "c_skill_list", "c_skill_get", "c_skill_rollback"], "process": [], "requirement": []}, "done": "Skill 库与变更审计日志可查看;c_skill_rollback 首次接上 HTTP(skill_repo_path 可注入,测试绝不碰真实仓库)"}, | 47 | {"id": "U-029", "phase": "PH3", "sub_phase": "PH3-S3", "name": "v_skill_library + v_skill_audit_log(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-018"], "coverage": {"model": [], "view": ["v_skill_library", "v_skill_audit_log"], "controller": ["c_skill_diff", "c_skill_list", "c_skill_get", "c_skill_rollback"], "process": [], "requirement": []}, "done": "Skill 库与变更审计日志可查看;c_skill_rollback 首次接上 HTTP(skill_repo_path 可注入,测试绝不碰真实仓库)"}, |
| 48 | {"id": "U-030", "phase": "PH3", "sub_phase": "PH3-S4", "name": "并发/预算/日志保留配置页(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-019", "U-021"], "coverage": {"model": ["org_concurrency_config"], "view": ["v_concurrency_settings", "v_budget_settings", "v_log_retention_settings"], "controller": ["c_concurrency_config_update", "c_budget_config_update", "c_log_retention_config_update"], "process": [], "requirement": []}, "done": "三个配置页可用且改动生效;PH3 至此全部完成"}, | 48 | {"id": "U-030", "phase": "PH3", "sub_phase": "PH3-S4", "name": "并发/预算/日志保留配置页(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-019", "U-021"], "coverage": {"model": ["org_concurrency_config"], "view": ["v_concurrency_settings", "v_budget_settings", "v_log_retention_settings"], "controller": ["c_concurrency_config_update", "c_budget_config_update", "c_log_retention_config_update"], "process": [], "requirement": []}, "done": "三个配置页可用且改动生效;PH3 至此全部完成"}, |
| 49 | 49 | ||
| 50 | - {"id": "U-031", "phase": "PH4", "sub_phase": "PH4-S1", "name": "REQ-01~14 全量自动化回归(见 development-plan.md 2026-08-01/2026-08-02 实现补充)", "classification": "Risk Gate", "depends_on": ["U-014", "U-017", "U-020", "U-021", "U-022", "U-023", "U-024.5", "U-025", "U-026", "U-027", "U-028", "U-029", "U-030"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": ["REQ-01", "REQ-02", "REQ-03", "REQ-04", "REQ-05", "REQ-06", "REQ-07", "REQ-08", "REQ-09", "REQ-10", "REQ-11", "REQ-12", "REQ-13", "REQ-14"]}, "done": "06 节全部 14 条 REQ 的全部用例通过一次完整回归;回归审计中发现 c_work_graph_plan/c_work_graph_execute/c_work_node_run 三个模块彼此隔离测试通过但从未被生产代码真正串联(唯一验证过的闭环 e2e_pipeline.py 绕开了三者,直接用 org_node_trigger),已按用户决策现场补上集成:新增 src/work_graph_orchestrate.py(拓扑排序 + 每节点一份独立动态工作流脚本调用 run_work_node),e2e_pipeline.py 的 plan_documents/implement_code 改为真调用 plan_work_graph→run_graph;真实 API 验证过程中发现并修复两个真实 bug(graph_manifest 的 id/node_id 字段名及 depends_on 缺失见 work_graph_plan._normalize_graph_manifest;run_work_node 缺 repo_path 导致 cwd=None)。**2026-08-02(PH5 开工前逐条复核)**:对照 06 节重新逐条核对后又发现并修复 5 处真实缺口——REQ-03 failure 缺失 'reflecting' 状态写入、REQ-05/09 的调度器场景此前从未真正测试过(U-032 建好后才补测)、REQ-06/07/14 三条 failure 共享的同一个缺口(CorruptPersistedStateError/ClassifierBlockedError/UsageLimitWaitTimeoutError 均未路由到 task.status=blocked_pending_human)、REQ-02 boundary(PRD 一致性校验)此前完全未实现现已用新的 verify_fn 机制补上、REQ-13 failure 一处测试断言过弱;全部详见 development-plan.md 对应实现补充,380 测试全绿,此时才是 06 节 REQ-01~14 真正逐条有覆盖"}, | 50 | + {"id": "U-031", "phase": "PH4", "sub_phase": "PH4-S1", "name": "REQ-01~14 全量自动化回归(见 development-plan.md 2026-08-01/2026-08-02 实现补充)", "classification": "Risk Gate", "depends_on": ["U-014", "U-017", "U-020", "U-021", "U-022", "U-023", "U-024.5", "U-025", "U-026", "U-027", "U-028", "U-029", "U-030"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": ["REQ-01", "REQ-02", "REQ-03", "REQ-04", "REQ-05", "REQ-06", "REQ-07", "REQ-08", "REQ-09", "REQ-10", "REQ-11", "REQ-12", "REQ-13", "REQ-14"]}, "done": "06 节全部 14 条 REQ 的全部用例通过一次完整回归;回归审计中发现 c_work_graph_plan/c_work_graph_execute/c_work_node_run 三个模块彼此隔离测试通过但从未被生产代码真正串联(唯一验证过的闭环 e2e_pipeline.py 绕开了三者,直接用 org_node_trigger),已按用户决策现场补上集成:新增 src/work_graph_orchestrate.py(拓扑排序 + 每节点一份独立动态工作流脚本调用 run_work_node),e2e_pipeline.py 的 plan_documents/implement_code 改为真调用 plan_work_graph→run_graph;真实 API 验证过程中发现并修复两个真实 bug(graph_manifest 的 id/node_id 字段名及 depends_on 缺失见 work_graph_plan._normalize_graph_manifest;run_work_node 缺 repo_path 导致 cwd=None)。**2026-08-02(PH5 开工前逐条复核)**:对照 06 节重新逐条核对后又发现并修复 5 处真实缺口——REQ-03 failure 缺失 'reflecting' 状态写入、REQ-05/09 的调度器场景此前从未真正测试过(U-032 建好后才补测)、REQ-06/07/14 三条 failure 共享的同一个缺口(CorruptPersistedStateError/ClassifierBlockedError/UsageLimitWaitTimeoutError 均未路由到 task.status=blocked_pending_human)、REQ-02 boundary(PRD 一致性校验)此前完全未实现现已用新的 verify_fn 机制补上、REQ-13 failure 一处测试断言过弱;全部详见 development-plan.md 对应实现补充,380 测试全绿,此时才是 06 节 REQ-01~14 真正逐条有覆盖。**2026-08-03(U-042 真实自举压测第四次尝试发现)**:`_check_prd_consistency()` 直接对 target_folder_path 调用未做 worktree 隔离,self-target 场景下会把 memory 写入落到主仓库工作区未提交状态,导致下一个节点的真实 worktree 合并失败;已改为按 05 §5.8 同样规则隔离到自己的 worktree,补充两个回归测试"}, |
| 51 | {"id": "U-032", "phase": "PH4", "sub_phase": "PH4-S2", "name": "多 Goal 并发 + 长时间运行稳定性压测(见 development-plan.md 2026-08-02 实现补充)", "classification": "Risk Gate", "depends_on": ["U-031"], "coverage": {"model": ["org_concurrency_config"], "view": [], "controller": [], "process": [], "requirement": ["REQ-09"]}, "done": "并发数调高后多个独立 Goal 可同时稳定运行;发现并补上一个此前从未存在的结构性缺口——04-P/01-M/REQ-09 boundary 反复假设存在的自动调度器实际从未被建过,新增 src/scheduler.py(ThreadPoolExecutor + 每线程独立 sqlite 连接,避免复刻 U-027 的共享连接锁陷阱)真正实现,并顺带补上 e2e_pipeline.run_end_to_end() 自己文档过的缺口(PASS/FAIL 后从不调用 close_task_done/run_fix_loop,卡在 validating 不动)"}, | 51 | {"id": "U-032", "phase": "PH4", "sub_phase": "PH4-S2", "name": "多 Goal 并发 + 长时间运行稳定性压测(见 development-plan.md 2026-08-02 实现补充)", "classification": "Risk Gate", "depends_on": ["U-031"], "coverage": {"model": ["org_concurrency_config"], "view": [], "controller": [], "process": [], "requirement": ["REQ-09"]}, "done": "并发数调高后多个独立 Goal 可同时稳定运行;发现并补上一个此前从未存在的结构性缺口——04-P/01-M/REQ-09 boundary 反复假设存在的自动调度器实际从未被建过,新增 src/scheduler.py(ThreadPoolExecutor + 每线程独立 sqlite 连接,避免复刻 U-027 的共享连接锁陷阱)真正实现,并顺带补上 e2e_pipeline.run_end_to_end() 自己文档过的缺口(PASS/FAIL 后从不调用 close_task_done/run_fix_loop,卡在 validating 不动)"}, |
| 52 | {"id": "U-033", "phase": "PH4", "sub_phase": "PH4-S3", "name": "A-2 假设收口(见 development-plan.md 2026-08-02 实现补充)", "classification": "Risk Gate", "depends_on": ["U-004"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": [], "assumption": ["A-2"]}, "done": "A-2 状态确认为 confirmed 或记录实际偏差;最终结论:维持 U-004 记录的\"部分成立\",PH1~PH4 积累的真实运行数据未暴露新偏差,偏差范围到 U-004 为止已完全摸清(Coder SOUL.md 三处实质性重写),不改判 confirmed,正式收口"}, | 52 | {"id": "U-033", "phase": "PH4", "sub_phase": "PH4-S3", "name": "A-2 假设收口(见 development-plan.md 2026-08-02 实现补充)", "classification": "Risk Gate", "depends_on": ["U-004"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": [], "assumption": ["A-2"]}, "done": "A-2 状态确认为 confirmed 或记录实际偏差;最终结论:维持 U-004 记录的\"部分成立\",PH1~PH4 积累的真实运行数据未暴露新偏差,偏差范围到 U-004 为止已完全摸清(Coder SOUL.md 三处实质性重写),不改判 confirmed,正式收口"}, |
| 53 | 53 |
src/e2e_pipeline.py
| @@ -51,8 +51,8 @@ import subprocess | @@ -51,8 +51,8 @@ import subprocess | ||
| 51 | import uuid | 51 | import uuid |
| 52 | 52 | ||
| 53 | from src import ( | 53 | from src import ( |
| 54 | - artifact_write, org_node_lifecycle, org_node_trigger, task_create, | ||
| 55 | - work_graph_execute, work_graph_orchestrate, work_graph_plan, | 54 | + artifact_write, org_node_lifecycle, org_node_trigger, paths, task_create, |
| 55 | + work_graph_execute, work_graph_orchestrate, work_graph_plan, work_node_run, | ||
| 56 | ) | 56 | ) |
| 57 | 57 | ||
| 58 | _READY_PROMPT = "Confirm you're ready to begin. Take no other action yet." | 58 | _READY_PROMPT = "Confirm you're ready to begin. Take no other action yet." |
| @@ -154,19 +154,44 @@ def _coerce_bool(value): | @@ -154,19 +154,44 @@ def _coerce_bool(value): | ||
| 154 | 154 | ||
| 155 | def _check_prd_consistency(target_folder_path, prd_content, timeout=180): | 155 | def _check_prd_consistency(target_folder_path, prd_content, timeout=180): |
| 156 | """REQ-02 boundary: one real agent() call reviewing the PRD's own | 156 | """REQ-02 boundary: one real agent() call reviewing the PRD's own |
| 157 | - content for genuine internal contradiction. Returns (ok, issues).""" | ||
| 158 | - script_name = "graphlemon-prd-consistency-" + uuid.uuid4().hex | ||
| 159 | - prompt = _PRD_CONSISTENCY_PROMPT.format(prd_content=prd_content) | ||
| 160 | - script_content = ( | ||
| 161 | - "export const meta = {{ name: {name}, description: 'graphlemon PRD consistency check' }}\n\n" | ||
| 162 | - "const result = await agent({prompt}, {{ agentType: 'planner', schema: {schema} }})\n\n" | ||
| 163 | - "return result\n" | ||
| 164 | - ).format(name=json.dumps(script_name), prompt=json.dumps(prompt), schema=json.dumps(_PRD_CONSISTENCY_SCHEMA)) | ||
| 165 | - work_graph_execute.write_workflow_script(target_folder_path, script_name, script_content) | ||
| 166 | - result = work_graph_execute.execute_work_graph( | ||
| 167 | - target_folder_path, script_name, {}, _PRD_CONSISTENCY_SCHEMA, timeout=timeout | ||
| 168 | - ) | ||
| 169 | - return _coerce_bool(result["consistent"]), result.get("issues", []) | 157 | + content for genuine internal contradiction. Returns (ok, issues). |
| 158 | + | ||
| 159 | + **Self-target worktree isolation (2026-08-03, real self-hosting run)**: | ||
| 160 | + this call uses `agentType='planner'` with `memory: project`, which | ||
| 161 | + writes a real, uncommitted change to `.claude/agent-memory/planner/ | ||
| 162 | + MEMORY.md` in whatever `cwd` it runs with. Every prior real run of this | ||
| 163 | + check only ever targeted a throwaway target-project clone, so calling | ||
| 164 | + it directly against `target_folder_path` never caused a problem -- | ||
| 165 | + until a genuine self-hosting run hit it: the memory write landed as an | ||
| 166 | + uncommitted local change on the *main* checkout (bypassing | ||
| 167 | + `work_node_run.run_work_node`'s worktree isolation entirely, since this | ||
| 168 | + call never goes through it), which then made the *next* node's own | ||
| 169 | + worktree merge fail with "local changes would be overwritten by | ||
| 170 | + merge". 05 §5.8's unconditional self-target worktree rule has to apply | ||
| 171 | + here too, not just to `run_work_node`'s own callers.""" | ||
| 172 | + is_self = paths.is_self_target(target_folder_path) | ||
| 173 | + node_exec_id = "prd-consistency-" + uuid.uuid4().hex[:12] | ||
| 174 | + worktree_path = work_node_run.create_worktree(target_folder_path, node_exec_id) if is_self else None | ||
| 175 | + cwd = worktree_path or target_folder_path | ||
| 176 | + try: | ||
| 177 | + script_name = "graphlemon-prd-consistency-" + uuid.uuid4().hex | ||
| 178 | + prompt = _PRD_CONSISTENCY_PROMPT.format(prd_content=prd_content) | ||
| 179 | + script_content = ( | ||
| 180 | + "export const meta = {{ name: {name}, description: 'graphlemon PRD consistency check' }}\n\n" | ||
| 181 | + "const result = await agent({prompt}, {{ agentType: 'planner', schema: {schema} }})\n\n" | ||
| 182 | + "return result\n" | ||
| 183 | + ).format(name=json.dumps(script_name), prompt=json.dumps(prompt), schema=json.dumps(_PRD_CONSISTENCY_SCHEMA)) | ||
| 184 | + work_graph_execute.write_workflow_script(cwd, script_name, script_content) | ||
| 185 | + result = work_graph_execute.execute_work_graph( | ||
| 186 | + cwd, script_name, {}, _PRD_CONSISTENCY_SCHEMA, timeout=timeout | ||
| 187 | + ) | ||
| 188 | + if worktree_path: | ||
| 189 | + work_node_run.ensure_worktree_committed(worktree_path, node_exec_id) | ||
| 190 | + work_node_run.merge_worktree(target_folder_path, node_exec_id) | ||
| 191 | + return _coerce_bool(result["consistent"]), result.get("issues", []) | ||
| 192 | + finally: | ||
| 193 | + if worktree_path: | ||
| 194 | + work_node_run.remove_worktree(target_folder_path, worktree_path) | ||
| 170 | 195 | ||
| 171 | 196 | ||
| 172 | def _prd_consistency_verify_fn(node_artifact_types, target_folder_path): | 197 | def _prd_consistency_verify_fn(node_artifact_types, target_folder_path): |
src/test_e2e_pipeline.py
| @@ -202,6 +202,60 @@ class PrdConsistencyVerifyFnTest(unittest.TestCase): | @@ -202,6 +202,60 @@ class PrdConsistencyVerifyFnTest(unittest.TestCase): | ||
| 202 | self.assertEqual(args[0], "/tmp/proj-1") | 202 | self.assertEqual(args[0], "/tmp/proj-1") |
| 203 | 203 | ||
| 204 | 204 | ||
| 205 | +class PrdConsistencySelfTargetWorktreeTest(unittest.TestCase): | ||
| 206 | + """2026-08-03 regression: a real self-hosting run showed _check_prd_ | ||
| 207 | + consistency() calling execute_work_graph() directly against | ||
| 208 | + target_folder_path, bypassing worktree isolation entirely -- when | ||
| 209 | + self-targeting, its own agentType='planner'+memory:project call writes | ||
| 210 | + a real, uncommitted change straight to the main checkout's | ||
| 211 | + .claude/agent-memory/planner/MEMORY.md, which then made the NEXT | ||
| 212 | + node's own (correctly worktree-isolated) merge fail with "local | ||
| 213 | + changes would be overwritten by merge". This call must isolate the | ||
| 214 | + same way work_node_run.run_work_node() already does for every other | ||
| 215 | + self-target call.""" | ||
| 216 | + | ||
| 217 | + def test_self_target_isolates_the_check_in_its_own_worktree(self): | ||
| 218 | + node_artifact_types = {"prd": "prd"} | ||
| 219 | + verify_fn = e2e_pipeline._prd_consistency_verify_fn(node_artifact_types, "/repo") | ||
| 220 | + with patch.object(e2e_pipeline.paths, "is_self_target", return_value=True), \ | ||
| 221 | + patch.object(e2e_pipeline.work_node_run, "create_worktree", return_value="/repo/.graphlemon-worktrees/x") as mock_create, \ | ||
| 222 | + patch.object(e2e_pipeline.work_node_run, "ensure_worktree_committed") as mock_ensure, \ | ||
| 223 | + patch.object(e2e_pipeline.work_node_run, "merge_worktree") as mock_merge, \ | ||
| 224 | + patch.object(e2e_pipeline.work_node_run, "remove_worktree") as mock_remove, \ | ||
| 225 | + patch.object(work_graph_execute, "write_workflow_script") as mock_write, \ | ||
| 226 | + patch.object( | ||
| 227 | + work_graph_execute, "execute_work_graph", | ||
| 228 | + return_value={"consistent": True, "issues": []}, | ||
| 229 | + ) as mock_execute: | ||
| 230 | + ok, feedback = verify_fn({"node_id": "prd"}, {"content": "some prd"}) | ||
| 231 | + | ||
| 232 | + self.assertTrue(ok) | ||
| 233 | + mock_create.assert_called_once_with("/repo", unittest.mock.ANY) | ||
| 234 | + # both the script write and the real execute call must use the | ||
| 235 | + # WORKTREE path as cwd, never the main checkout, when self-targeting | ||
| 236 | + self.assertEqual(mock_write.call_args[0][0], "/repo/.graphlemon-worktrees/x") | ||
| 237 | + self.assertEqual(mock_execute.call_args[0][0], "/repo/.graphlemon-worktrees/x") | ||
| 238 | + mock_ensure.assert_called_once() | ||
| 239 | + mock_merge.assert_called_once() | ||
| 240 | + mock_remove.assert_called_once() | ||
| 241 | + | ||
| 242 | + def test_non_self_target_does_not_use_a_worktree_at_all(self): | ||
| 243 | + node_artifact_types = {"prd": "prd"} | ||
| 244 | + verify_fn = e2e_pipeline._prd_consistency_verify_fn(node_artifact_types, "/tmp/proj-1") | ||
| 245 | + with patch.object(e2e_pipeline.paths, "is_self_target", return_value=False), \ | ||
| 246 | + patch.object(e2e_pipeline.work_node_run, "create_worktree") as mock_create, \ | ||
| 247 | + patch.object(work_graph_execute, "write_workflow_script") as mock_write, \ | ||
| 248 | + patch.object( | ||
| 249 | + work_graph_execute, "execute_work_graph", | ||
| 250 | + return_value={"consistent": True, "issues": []}, | ||
| 251 | + ) as mock_execute: | ||
| 252 | + verify_fn({"node_id": "prd"}, {"content": "some prd"}) | ||
| 253 | + | ||
| 254 | + mock_create.assert_not_called() | ||
| 255 | + self.assertEqual(mock_write.call_args[0][0], "/tmp/proj-1") | ||
| 256 | + self.assertEqual(mock_execute.call_args[0][0], "/tmp/proj-1") | ||
| 257 | + | ||
| 258 | + | ||
| 205 | class ValidateImplementationTest(unittest.TestCase): | 259 | class ValidateImplementationTest(unittest.TestCase): |
| 206 | def test_parses_pass_verdict(self): | 260 | def test_parses_pass_verdict(self): |
| 207 | with patch("src.org_node_trigger.trigger_org_node", return_value="VERDICT: PASS\nlooks good"): | 261 | with patch("src.org_node_trigger.trigger_org_node", return_value="VERDICT: PASS\nlooks good"): |