Commit 0b32b95abee6355a5a7e98b6cac92f4b8e4c88b6

Authored by bboonnee
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>
docs/development-plan.md
... ... @@ -304,6 +304,7 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。
304 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 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 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 308 - 全部 5 处修复后,`python3 -m unittest discover -s src` 380 个测试全绿(含新增的);REQ-01~14 现在逐条都有真实覆盖,PH4 可以视为真正收口,PH5 排期不再依赖对这份回归的信任假设。
308 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 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 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 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 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 51 import uuid
52 52  
53 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 58 _READY_PROMPT = "Confirm you're ready to begin. Take no other action yet."
... ... @@ -154,19 +154,44 @@ def _coerce_bool(value):
154 154  
155 155 def _check_prd_consistency(target_folder_path, prd_content, timeout=180):
156 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 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 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 259 class ValidateImplementationTest(unittest.TestCase):
206 260 def test_parses_pass_verdict(self):
207 261 with patch("src.org_node_trigger.trigger_org_node", return_value="VERDICT: PASS\nlooks good"):
... ...