Commit 4b293a8eac57a1c1d78e365d92ac72024677bdf4

Authored by bboonnee
1 parent f220d91d

U-041 fix: rollback path A now removes the candidate's worktree checkout

The 7th real self-hosting attempt was the first to actually reach a real
health check against a real candidate process -- it correctly failed and
triggered rollback_path_a for real: candidate terminated, git revert landed
cleanly. But the candidate's `git worktree add --detach` checkout
(candidate_instance_dir, tracked since U-037 specifically to know where
this lives) was never removed -- left permanently on disk and still
registered in `git worktree list`. A confirmed-bad candidate is never
needed again after path A, unlike a successful cutover's candidate (which
correctly becomes previous_instance_dir, kept until the monitoring window
closes) -- these are different lifecycles that shouldn't share the same
"leave it" behavior.

rollback_path_a gains an optional candidate_instance_dir parameter
(default None, existing tests unaffected) and removes the worktree before
git revert when provided. self_hosting_e2e.py now threads through the
value stage_deployment() already returns. New regression test uses a real
`git worktree add --detach` checkout and confirms both the directory and
its git worktree registration are gone after rollback.

Also cleaned up 3 orphaned processes left by earlier test_deploy_rollback.py
runs (backing temp dirs already gone) and the real leftover candidate
worktree from this run.

561 tests green (440 src + 121 supervisor).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docs/development-plan.md
... ... @@ -394,6 +394,7 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。
394 394 - 依赖:U-038, U-040
395 395 - Done:两条回滚路径各自跑通一次;人为制造"旧版本也起不来"的场景能正确终态到 `blocked_pending_human` 而不是无限重试或静默失败;`git revert` 冲突场景(`supervisor-auto-rollback-design.md` §10 开放问题 3)有明确处理,不能是本 Unit 唯一遗留的未定义行为
396 396 - **实现补充(2026-08-02)——本 Unit 是 PH5 关键路径上明确标注返工风险最高的一环,按计划要求真的用人为制造的失败场景验证了兜底路径,没有假设"大概率不会发生"就跳过**:新增 `supervisor/deploy_rollback.py`。**设计上两处判断,都是按已有约定处理,不是新拍的**:① 没有一个统一的 `rollback(conn, deployment_id)` 总入口——`deployment` 表存了 `candidate_instance_dir`(U-037)但从没存过任何 PID 列,`deploy_stage.stage_deployment()`/`deploy_cutover.cutover()` 早就要求调用方直接把 pid 传进来(这些 pid 只在同一个 Deploy Supervisor 进程的生命周期内才有意义),`rollback_path_a`/`rollback_path_b` 延续同一约定,没有另造一套。② 03-C 的 `output_contract` 只写了 `{status: 'rolled_back'|'health_check_failed'|'rollback_failed'}`,从没出现过 `blocked_pending_human`,但 04-P 状态转移表的 `error_state` 列和 03-C 的 `errors` 描述都明确写"转 `blocked_pending_human`"——读作"`rollback_failed` 是错误名/返回值,真正持久化的 `deployment.status` 是 `blocked_pending_human`",和 `deploy_stage.CheckoutFailedError`/`deploy_cutover.CutoverFailedError` 已经在用的同一个模式完全一致,不是新发明。`git revert` 冲突(本 Unit 自己点名要求处理的场景,design doc §10 开放问题 3)不开新分支,直接并入两条路径共用的"回滚本身失败"兜底,`git revert --abort` 收尾避免仓库卡在冲突中间态。
  397 + - **真实 bug(2026-08-03,U-042 第七次真实自举压测第一次真正走到 health_check 环节)**:这是六次前置 bug 修完之后第一次真正让 `stage_deployment→run_health_check→rollback_path_a` 这条链路对着真实候选进程走一遍——候选健康检查真实失败,`rollback_path_a` 真实终止了候选进程、真实 `git revert` 了合并提交,全部正确;但候选自己的 `git worktree add --detach` 检出目录(`candidate_instance_dir`,U-037 就是为了追踪它才补的字段)从始至终没人清理,永久留在磁盘上、还继续注册在 `git worktree list` 里——一个确认失败的候选之后不会再被用到,路径 A 理应清掉它,和成功 cutover 后候选变成 `previous_instance_dir` 需要保留到监控窗口结束是两回事,不能用同一套"先留着"逻辑。修复:`rollback_path_a` 新增可选参数 `candidate_instance_dir`(默认 `None`,不影响任何未使用真实 worktree 检出的既有测试),提供时在终止进程之后、`git revert` 之前先 `git worktree remove --force`;`self_hosting_e2e.py` 把 `stage_deployment()` 自己返回值里早就有的 `candidate_instance_dir` 传过去。补了一个用真实 `git worktree add --detach` 检出目录的回归测试,验证目录和 `git worktree list` 注册都被清理干净。清理过程中顺带发现并杀掉了三个更早的 `test_deploy_rollback.py` 真实进程测试遗留下来、backing 目录早已不存在的孤儿进程。
397 398 - **真实验证,四个场景全部对着真实子进程/真实 git 仓库跑过,没有 mock 关键路径**:路径 A happy(真进程 `sleep 300` 模拟候选、真的删一次性 DB 文件、真实 `git revert` 落地、真的写一条 `bug_record`);路径 A 失败(构造一个真实会冲突的 git 历史——v2 是待回滚的自我修改,v3 是之后touch 同一行的后续提交——`git revert` 真的冲突,验证 `blocked_pending_human` + 仓库被 `--abort` 清理干净、不留冲突中间态);路径 B happy(真实 `git worktree add` 出一份"上一个已知良好版本"的检出目录,真实拉起一个"坏 Live"进程,真实停掉+复活旧版本+真实三项复核+真实 revert,复活的进程最后真的在验证用的端口上对外提供服务,不只是返回值对);**路径 B 失败**(本 Unit 最该较真的场景):`previous_instance_dir` 指向一个真实存在但完全没有 Python 包内容的空目录,真实拉起会真的失败/绑不上端口,验证最终落到 `blocked_pending_human` 而不是卡死重试或悄悄放过,且确认这次真的没有执行 `git revert`(提交内容原样保留,因为回滚在到达那一步之前就已经失败)。
398 399 - 覆盖:03-C `c_deploy_supervisor_rollback`;`git revert` 冲突场景(design doc §10 开放问题 3,已有明确处理)
399 400  
... ...
docs/sdd/unit_matrix.json
... ... @@ -58,7 +58,7 @@
58 58 {"id": "U-038", "phase": "PH5", "sub_phase": "PH5-S2", "name": "c_deploy_supervisor_health_check(见 development-plan.md 2026-08-02 实现补充)", "classification": "Risk Gate", "depends_on": ["U-037"], "coverage": {"model": ["deploy_health_signal"], "view": [], "controller": ["c_deploy_supervisor_health_check"], "process": [], "requirement": []}, "done": "process_alive/http_health/canary_task 三项检查全过 -> health_check_passed;任一失败 -> health_check_failed 且 Live 未受影响(supervisor/deploy_health_check.py,真实 HTTP+真实 project/task 创建验证过);backend_server.py 新增 GET /api/health(真查一次 SELECT 1,不只是进程存活)"},
59 59 {"id": "U-039", "phase": "PH5", "sub_phase": "PH5-S2", "name": "c_deploy_supervisor_cutover(见 development-plan.md 2026-08-02 实现补充)", "classification": "Risk Gate", "depends_on": ["U-038"], "coverage": {"model": ["deployment"], "view": [], "controller": ["c_deploy_supervisor_cutover"], "process": [], "requirement": []}, "done": "健康检查通过后完成真实切换(supervisor/deploy_cutover.py,真实进程停止+重启验证过);在途 work_node_execution 重启后可恢复(复用既有 REQ-06 机制);previous_instance_dir 保留至监控窗口结束;新增 LIVE_PORT=8420 常量(05 §5.9 缺的一个字段);真实测试发现并修复 deploy_supervisor.is_alive() 的僵尸进程判活缺陷(新增 reap_if_exited())"},
60 60 {"id": "U-040", "phase": "PH5", "sub_phase": "PH5-S3", "name": "c_deploy_supervisor_monitor(见 development-plan.md 2026-08-02 实现补充)", "classification": "Risk Gate", "depends_on": ["U-039"], "coverage": {"model": ["deploy_health_signal"], "view": [], "controller": ["c_deploy_supervisor_monitor"], "process": [], "requirement": []}, "done": "四类信号采样正常(supervisor/deploy_monitor.py);validator_approval_rate 偏离基线阈值时触发 anomaly_detected;approve 占比算法基于对 scheduler.finish_task_lifecycle 真实代码逻辑的核实(PASS 从不产生 bug_record,FAIL 必然产生),非字面 SDD 定义"},
61   - {"id": "U-041", "phase": "PH5", "sub_phase": "PH5-S3", "name": "c_deploy_supervisor_rollback(见 development-plan.md 2026-08-02 实现补充)", "classification": "Risk Gate", "depends_on": ["U-038", "U-040"], "coverage": {"model": ["deployment"], "view": [], "controller": ["c_deploy_supervisor_rollback"], "process": [], "requirement": []}, "done": "回滚路径 A/B 各跑通一次(supervisor/deploy_rollback.py,真实进程+真实 git 仓库验证);旧版本也起不来时正确终态到 blocked_pending_human(真实场景验证,非假设);git revert 冲突有明确处理(真实构造冲突历史验证过,含 --abort 清理)"},
  61 + {"id": "U-041", "phase": "PH5", "sub_phase": "PH5-S3", "name": "c_deploy_supervisor_rollback(见 development-plan.md 2026-08-02/03 实现补充)", "classification": "Risk Gate", "depends_on": ["U-038", "U-040"], "coverage": {"model": ["deployment"], "view": [], "controller": ["c_deploy_supervisor_rollback"], "process": [], "requirement": []}, "done": "回滚路径 A/B 各跑通一次(supervisor/deploy_rollback.py,真实进程+真实 git 仓库验证);旧版本也起不来时正确终态到 blocked_pending_human(真实场景验证,非假设);git revert 冲突有明确处理(真实构造冲突历史验证过,含 --abort 清理)。2026-08-03:U-042 第七次真实压测第一次真正走到 health_check 环节,发现 rollback_path_a 从未清理候选的 git worktree 检出目录(永久留在磁盘+git worktree list),已修复并补真实回归测试"},
62 62 {"id": "U-042", "phase": "PH5", "sub_phase": "PH5-S3", "name": "自举闭环端到端验收(见 development-plan.md 2026-08-02 实现补充)", "classification": "Risk Gate", "depends_on": ["U-035", "U-041"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": []}, "done": "真实复现 self-development-feasibility.md 9 步场景,全程无需人工评审代码内容(supervisor/self_hosting_e2e.py 编排层已建好);真实双实例 E2E 用例(RUN_REAL_SELF_HOSTING_E2E=1,独立于常规 RUN_REAL_AGENT_E2E 的单独环境变量)默认跳过,需显式环境变量开启且需额外用户确认才真正执行;日常回归只跑 mock 版本(6 个编排逻辑测试,已跑通)"},
63 63 {"id": "U-043", "phase": "PH5", "sub_phase": "PH5-S4", "name": "控制台展示 deployment 状态与健康信号(见 development-plan.md 2026-08-02 实现补充)", "classification": "Value Path", "depends_on": ["U-024", "U-040"], "coverage": {"model": [], "view": ["v_deployment_status"], "controller": ["c_deployment_list", "c_deployment_current", "c_deployment_get"], "process": [], "requirement": []}, "done": "能看到当前 deployment 状态机所处状态与最近一次健康信号采样结果;02-V 从未定义过这个视图(PH5 后加),DOM/class 约定沿用现有页面;真实服务器 + urllib 验证过全部三条新路由"}
64 64 ],
... ...
supervisor/deploy_rollback.py
... ... @@ -76,13 +76,24 @@ def _block(conn, deployment_id, reason):
76 76  
77 77 def rollback_path_a(
78 78 conn, deployment_id, candidate_pid, candidate_db_path, git_commit_hash,
79   - repo_root=None, description=None, task_id=None, stop_timeout=10,
  79 + repo_root=None, description=None, task_id=None, stop_timeout=10, candidate_instance_dir=None,
80 80 ):
81 81 """Pre-cutover rollback (design doc §7.1): Live was never touched, this
82 82 is the cheapest path. Terminates the candidate, deletes its one-time DB
83   - copy, `git revert`s the merge commit, and records a `bug_record(
84   - root_cause_category=core_engine)` audit trail with the failing signals
85   - as evidence. Returns {status: 'rolled_back'}."""
  83 + copy, removes its worktree checkout, `git revert`s the merge commit,
  84 + and records a `bug_record(root_cause_category=core_engine)` audit trail
  85 + with the failing signals as evidence. Returns {status: 'rolled_back'}.
  86 +
  87 + **`candidate_instance_dir` cleanup (2026-08-03, real self-hosting run)**:
  88 + a real health-check failure exercised this path for the first time and
  89 + left the candidate's `git worktree add --detach` checkout permanently
  90 + on disk (and registered in `git worktree list`) -- a confirmed-bad
  91 + candidate is never needed again after path A, unlike a successful
  92 + cutover's candidate (which becomes `previous_instance_dir`, correctly
  93 + kept until the monitoring window closes). Optional (default None) so
  94 + existing tests that never used a real worktree checkout for their
  95 + candidate are unaffected; the real caller (self_hosting_e2e.py) now
  96 + passes it through from `stage_deployment()`'s own return value."""
86 97 repo_root = repo_root or paths.repo_root()
87 98 description = description or "health check failed for deployment {}".format(deployment_id)
88 99  
... ... @@ -90,6 +101,11 @@ def rollback_path_a(
90 101 deploy_cutover._stop_process(candidate_pid, timeout=stop_timeout)
91 102 if candidate_db_path and os.path.exists(candidate_db_path):
92 103 os.remove(candidate_db_path)
  104 + if candidate_instance_dir:
  105 + subprocess.run(
  106 + ["git", "worktree", "remove", "--force", candidate_instance_dir],
  107 + cwd=repo_root, check=True, capture_output=True, text=True,
  108 + )
93 109 _git_revert(repo_root, git_commit_hash)
94 110 except subprocess.CalledProcessError as e:
95 111 _abort_revert_if_in_progress(repo_root)
... ...
supervisor/self_hosting_e2e.py
... ... @@ -80,6 +80,7 @@ def run_self_hosting_closed_loop(
80 80 conn, staged["deployment_id"], staged["candidate_pid"], candidate_db_path,
81 81 merge_commit, repo_root=target_folder_path,
82 82 description="health check failed: {}".format(health["signals"]),
  83 + candidate_instance_dir=staged["candidate_instance_dir"],
83 84 )
84 85 return {"stage": "health_check", "outcome": "rolled_back_path_a", "deployment_id": staged["deployment_id"]}
85 86  
... ...
supervisor/test_deploy_rollback.py
... ... @@ -119,6 +119,32 @@ class RollbackPathAHappyPathTest(DeployRollbackTestBase):
119 119 self.assertEqual(bug[1], "resolved")
120 120 self.assertIn("canary_task failed", bug[2])
121 121  
  122 + def test_candidate_worktree_checkout_is_removed(self):
  123 + # 2026-08-03 regression: a real self-hosting run's health-check
  124 + # failure exercised this path for the first time and left the
  125 + # candidate's `git worktree add --detach` checkout permanently on
  126 + # disk (and still registered in `git worktree list`) -- confirmed-
  127 + # bad candidates are never needed again after path A.
  128 + candidate_dir = os.path.join(self.home_tmp, "candidate-checkout")
  129 + subprocess.run(
  130 + ["git", "worktree", "add", "--detach", candidate_dir, self.commit_v2],
  131 + cwd=self.repo_dir, check=True, capture_output=True, text=True,
  132 + )
  133 + proc = subprocess.Popen(["sleep", "300"])
  134 + self._cleanup_pids.append(proc.pid)
  135 + self._seed_deployment("dep-a3", "health_check_failed", git_commit_hash=self.commit_v2)
  136 +
  137 + deploy_rollback.rollback_path_a(
  138 + self.conn, "dep-a3", proc.pid, None, self.commit_v2, repo_root=self.repo_dir,
  139 + candidate_instance_dir=candidate_dir,
  140 + )
  141 +
  142 + self.assertFalse(os.path.isdir(candidate_dir))
  143 + worktrees = subprocess.run(
  144 + ["git", "worktree", "list"], cwd=self.repo_dir, check=True, capture_output=True, text=True
  145 + ).stdout
  146 + self.assertNotIn(candidate_dir, worktrees)
  147 +
122 148  
123 149 class RollbackPathAFailureTest(DeployRollbackTestBase):
124 150 def test_git_revert_conflict_marks_blocked_pending_human_and_raises(self):
... ...