Commit 4f4b185339b2c6b9808fd54e757a6c0e4649fe6e

Authored by bboonnee
1 parent 98f0d5cd

U-041: c_deploy_supervisor_rollback (paths A/B + the rollback-fails fallback)

Adds supervisor/deploy_rollback.py: rollback_path_a (pre-cutover, health
check failed -- terminate candidate, delete its one-time DB copy, git
revert, record a bug_record(core_engine) audit trail) and rollback_path_b
(post-cutover, anomaly detected -- stop the bad Live, revive
previous_instance_dir, re-run the same three health-check signals directly
against it, git revert only once re-verification passes).

Design choices follow established conventions rather than inventing new
ones: no unified rollback() entry point (deployment has no PID columns,
same reason deploy_stage/deploy_cutover already require callers to pass
pids explicitly); ROLLBACK_FAILED is the raised error name while the
persisted deployment.status is 'blocked_pending_human' (same pattern
CheckoutFailedError/CutoverFailedError already use). git revert conflicts
fold into the same rollback-itself-failed fallback both paths share, per
design doc §10 open question 3's own recommendation, with `git revert
--abort` run afterward so a failed attempt doesn't leave the repo mid-conflict.

This is PH5's highest-rework-risk Unit per development-plan.md's own flag,
so its own "rollback itself fails" fallback is tested for real, not assumed:
a genuinely broken previous_instance_dir (a real empty directory with no
Python package in it) that really fails to bind its port, and a genuinely
conflicting git history (a later commit touching the same line the
self-modification changed) that really fails `git revert` -- both correctly
land on blocked_pending_human without retrying forever or silently
succeeding, and the conflict case leaves the repo clean afterward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docs/development-plan.md
... ... @@ -389,6 +389,9 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。
389 389 - **U-041 [Risk Gate]** `c_deploy_supervisor_rollback`:回滚路径 A(pre-cutover,终止候选+`git revert`+发 `bug_record`)与路径 B(post-cutover,换回 `previous_instance_dir`+健康检查+`git revert`)完整实现,含"回滚本身失败"兜底转 `blocked_pending_human`
390 390 - 依赖:U-038, U-040
391 391 - Done:两条回滚路径各自跑通一次;人为制造"旧版本也起不来"的场景能正确终态到 `blocked_pending_human` 而不是无限重试或静默失败;`git revert` 冲突场景(`supervisor-auto-rollback-design.md` §10 开放问题 3)有明确处理,不能是本 Unit 唯一遗留的未定义行为
  392 + - **实现补充(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` 收尾避免仓库卡在冲突中间态。
  393 + - **真实验证,四个场景全部对着真实子进程/真实 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`(提交内容原样保留,因为回滚在到达那一步之前就已经失败)。
  394 + - 覆盖:03-C `c_deploy_supervisor_rollback`;`git revert` 冲突场景(design doc §10 开放问题 3,已有明确处理)
392 395  
393 396 - **U-042 [Risk Gate]** 自举闭环端到端验收:真实复现 `self-development-feasibility.md` 第 3 节的 9 步场景(修复 graphlemon 自己 `p_org_task_lifecycle` 状态机里的一个真实植入 bug),走完整"部署→健康检查→cutover→监控→finalize"或"检测异常→自动回滚"两条路径中至少一条
394 397 - 依赖:U-035, U-041
... ...
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", "classification": "Risk Gate", "depends_on": ["U-038", "U-040"], "coverage": {"model": ["deployment"], "view": [], "controller": ["c_deploy_supervisor_rollback"], "process": [], "requirement": []}, "done": "回滚路径 A/B 各跑通一次;旧版本也起不来时正确终态到 blocked_pending_human;git revert 冲突有明确处理"},
  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 清理)"},
62 62 {"id": "U-042", "phase": "PH5", "sub_phase": "PH5-S3", "name": "自举闭环端到端验收", "classification": "Risk Gate", "depends_on": ["U-035", "U-041"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": []}, "done": "真实复现 self-development-feasibility.md 9 步场景,全程无需人工评审代码内容;真实双实例 E2E 用例默认跳过,需显式环境变量开启"},
63 63 {"id": "U-043", "phase": "PH5", "sub_phase": "PH5-S4", "name": "控制台展示 deployment 状态与健康信号", "classification": "Value Path", "depends_on": ["U-024", "U-040"], "coverage": {"model": [], "view": ["v_deployment_status"], "controller": [], "process": [], "requirement": []}, "done": "能看到当前 deployment 状态机所处状态与最近一次健康信号采样结果"}
64 64 ],
... ...
supervisor/deploy_rollback.py 0 → 100644
  1 +"""c_deploy_supervisor_rollback (03-C §3.15 / 04-P §4.6 / design doc §7):
  2 +path A (pre-cutover, health check failed -- Live was never touched) and
  3 +path B (post-cutover, anomaly detected during monitoring -- Live IS the bad
  4 +version, has to actually be swapped back), plus the one fallback both paths
  5 +share: rollback itself failing.
  6 +
  7 +**No unified `rollback(conn, deployment_id)` entry point (documented design
  8 +choice, matches this subsystem's established convention)**: `deployment`
  9 +persists `candidate_instance_dir` (U-037) but no PID column for either the
  10 +candidate or the live process -- `deploy_stage.stage_deployment()` and
  11 +`deploy_cutover.cutover()` both already require their caller to hand in the
  12 +relevant pid(s) explicitly rather than look them up from the DB, since
  13 +that's only ever meaningful within one continuous Deploy Supervisor process
  14 +lifetime (the one that actually spawned them). `rollback_path_a`/
  15 +`rollback_path_b` follow the same convention rather than inventing a
  16 +lookup mechanism the rest of this subsystem doesn't have either.
  17 +
  18 +**`deployment.status` vs the ROLLBACK_FAILED return value (documented
  19 +interpretation)**: 03-C's `output_contract` for `c_deploy_supervisor_rollback`
  20 +lists only `{status: 'rolled_back' | 'health_check_failed' | 'rollback_failed'}`
  21 +-- `blocked_pending_human` never appears there, but IS the literal
  22 +`error_state` 04-P's own transition table names for this row, and IS one of
  23 +`deployment.status`'s valid enum values (01-M 1.15). Read together: 'rollback_
  24 +failed' is the ERROR NAME (what gets raised / what 03-C's contract lists),
  25 +while the actually-PERSISTED terminal `deployment.status` is
  26 +'blocked_pending_human' -- exactly the same pattern `deploy_stage.
  27 +CheckoutFailedError` and `deploy_cutover.CutoverFailedError` already use
  28 +(raise a *FailedError, persist status='blocked_pending_human'), so this
  29 +follows the same convention rather than introducing a new one.
  30 +
  31 +**`git revert` conflict handling (design doc §10 open question 3, this
  32 +Unit's own explicit "有明确处理" Done requirement)**: any `git revert`
  33 +failure (conflict or otherwise) folds into the SAME `RollbackFailedError` ->
  34 +`blocked_pending_human` fallback both paths already have -- not a new
  35 +state-machine branch, per the design doc's own recommendation ("应该并入
  36 +7.3 兜底路径,而不是新开一个分支"). `git revert --abort` is run best-effort
  37 +afterward so a failed attempt doesn't leave the repo mid-conflict for
  38 +whoever investigates the resulting `blocked_pending_human`.
  39 +"""
  40 +
  41 +import os
  42 +import subprocess
  43 +
  44 +from supervisor import deploy_cutover, deploy_health_check, deploy_stage
  45 +from src import bug_record, paths
  46 +
  47 +
  48 +class RollbackFailedError(Exception):
  49 + """ROLLBACK_FAILED -- 03-C §3.15: the automatic recovery mechanism
  50 + itself failed (old version won't start either, or `git revert`
  51 + conflicts) -- NOT a judgment about whose code is right. Not retried;
  52 + `deployment.status='blocked_pending_human'` is already applied by the
  53 + time this is raised."""
  54 +
  55 +
  56 +def _git_revert(repo_root, commit_hash):
  57 + subprocess.run(
  58 + ["git", "revert", "--no-edit", commit_hash],
  59 + cwd=repo_root, check=True, capture_output=True, text=True,
  60 + )
  61 +
  62 +
  63 +def _abort_revert_if_in_progress(repo_root):
  64 + """Best-effort: restores the pre-revert state so a failed attempt
  65 + doesn't leave the repo mid-conflict."""
  66 + subprocess.run(["git", "revert", "--abort"], cwd=repo_root, capture_output=True, text=True)
  67 +
  68 +
  69 +def _block(conn, deployment_id, reason):
  70 + conn.execute(
  71 + "UPDATE deployment SET status = 'blocked_pending_human', rollback_reason = ? WHERE deployment_id = ?",
  72 + (reason, deployment_id),
  73 + )
  74 + conn.commit()
  75 +
  76 +
  77 +def rollback_path_a(
  78 + conn, deployment_id, candidate_pid, candidate_db_path, git_commit_hash,
  79 + repo_root=None, description=None, task_id=None, stop_timeout=10,
  80 +):
  81 + """Pre-cutover rollback (design doc §7.1): Live was never touched, this
  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'}."""
  86 + repo_root = repo_root or paths.repo_root()
  87 + description = description or "health check failed for deployment {}".format(deployment_id)
  88 +
  89 + try:
  90 + deploy_cutover._stop_process(candidate_pid, timeout=stop_timeout)
  91 + if candidate_db_path and os.path.exists(candidate_db_path):
  92 + os.remove(candidate_db_path)
  93 + _git_revert(repo_root, git_commit_hash)
  94 + except subprocess.CalledProcessError as e:
  95 + _abort_revert_if_in_progress(repo_root)
  96 + _block(conn, deployment_id, "rollback_failed (path A): git revert failed: {}".format(e.stderr))
  97 + raise RollbackFailedError(
  98 + "rollback path A failed for deployment {}: git revert error: {}".format(deployment_id, e.stderr)
  99 + ) from e
  100 +
  101 + bug_record.record_core_engine_change(conn, task_id, description)
  102 + conn.execute("UPDATE deployment SET status = 'rolled_back' WHERE deployment_id = ?", (deployment_id,))
  103 + conn.commit()
  104 + return {"status": "rolled_back"}
  105 +
  106 +
  107 +def rollback_path_b(
  108 + conn, deployment_id, live_pid, previous_instance_dir, prod_db_path, git_commit_hash,
  109 + repo_root=None, description=None, task_id=None, live_port=deploy_cutover.LIVE_PORT,
  110 + python_executable=None, stop_timeout=10, bind_timeout=15, startup_check_timeout=30,
  111 +):
  112 + """Post-cutover rollback (design doc §7.2): Live IS the bad version, so
  113 + the previously-known-good checkout has to be revived and re-verified
  114 + before this can be trusted as the new Live. Returns {status:
  115 + 'rolled_back'}. Raises RollbackFailedError (deployment already marked
  116 + blocked_pending_human) if the revived previous_instance_dir fails to
  117 + bind live_port OR fails its own re-verification (same three checks
  118 + c_deploy_supervisor_health_check runs, called directly here rather than
  119 + through that module's deployment-row-mutating wrapper -- this is a
  120 + distinct concern from the original candidate's own staged health check
  121 + trail and shouldn't be interleaved with it)."""
  122 + repo_root = repo_root or paths.repo_root()
  123 + description = description or "post-cutover anomaly detected for deployment {}".format(deployment_id)
  124 +
  125 + conn.execute("UPDATE deployment SET status = 'rollback_in_progress' WHERE deployment_id = ?", (deployment_id,))
  126 + conn.commit()
  127 +
  128 + try:
  129 + deploy_cutover._stop_process(live_pid, timeout=stop_timeout)
  130 +
  131 + revived = deploy_stage._launch_candidate(previous_instance_dir, live_port, prod_db_path, python_executable)
  132 + if not deploy_cutover._wait_for_live_http(live_port, bind_timeout):
  133 + raise RollbackFailedError(
  134 + "previous_instance_dir {} never bound live_port {} during rollback".format(
  135 + previous_instance_dir, live_port
  136 + )
  137 + )
  138 +
  139 + signals = [
  140 + deploy_health_check.check_process_alive(revived.pid, timeout=startup_check_timeout),
  141 + deploy_health_check.check_http_health(live_port),
  142 + deploy_health_check.check_canary_task(live_port),
  143 + ]
  144 + failing = [s for s in signals if s["status"] != "ok"]
  145 + if failing:
  146 + raise RollbackFailedError(
  147 + "revived previous_instance_dir failed re-verification for deployment {}: {}".format(
  148 + deployment_id, failing
  149 + )
  150 + )
  151 +
  152 + _git_revert(repo_root, git_commit_hash)
  153 + except subprocess.CalledProcessError as e:
  154 + _abort_revert_if_in_progress(repo_root)
  155 + _block(conn, deployment_id, "rollback_failed (path B): git revert failed: {}".format(e.stderr))
  156 + raise RollbackFailedError(
  157 + "rollback path B failed for deployment {}: git revert error: {}".format(deployment_id, e.stderr)
  158 + ) from e
  159 + except RollbackFailedError as e:
  160 + _block(conn, deployment_id, "rollback_failed (path B): {}".format(e))
  161 + raise
  162 +
  163 + bug_record.record_core_engine_change(conn, task_id, description)
  164 + conn.execute("UPDATE deployment SET status = 'rolled_back' WHERE deployment_id = ?", (deployment_id,))
  165 + conn.commit()
  166 + return {"status": "rolled_back"}
... ...
supervisor/test_deploy_rollback.py 0 → 100644
  1 +"""U-041 tests: c_deploy_supervisor_rollback. Real subprocesses, real git
  2 +repos, real filesystem operations throughout -- this is the highest-rework-
  3 +risk Unit on PH5's critical path (development-plan.md's own note: "回滚本身
  4 +失败没有再下一层的自动兜底"), so its own "rollback itself fails" fallback is
  5 +tested for real (a genuinely broken previous_instance_dir, a genuinely
  6 +conflicting git revert), not assumed to work because the happy path does.
  7 +
  8 +Run: python3 -m unittest discover -s supervisor -p "test_*.py" -v
  9 +"""
  10 +
  11 +import os
  12 +import shutil
  13 +import subprocess
  14 +import sys
  15 +import tempfile
  16 +import time
  17 +import unittest
  18 +
  19 +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
  20 +
  21 +from supervisor import deploy_cutover, deploy_health_check, deploy_rollback, deploy_stage, deploy_supervisor
  22 +from src import paths, schema
  23 +
  24 +
  25 +def _init_git_repo(path):
  26 + subprocess.run(["git", "init", "-q"], cwd=path, check=True)
  27 + subprocess.run(["git", "config", "user.email", "test@graphlemon.local"], cwd=path, check=True)
  28 + subprocess.run(["git", "config", "user.name", "test"], cwd=path, check=True)
  29 +
  30 +
  31 +def _write(path, name, content):
  32 + with open(os.path.join(path, name), "w", encoding="utf-8") as f:
  33 + f.write(content)
  34 +
  35 +
  36 +def _commit_all(path, message):
  37 + subprocess.run(["git", "add", "-A"], cwd=path, check=True)
  38 + subprocess.run(["git", "commit", "-q", "-m", message], cwd=path, check=True)
  39 + return subprocess.run(
  40 + ["git", "rev-parse", "HEAD"], cwd=path, capture_output=True, text=True, check=True
  41 + ).stdout.strip()
  42 +
  43 +
  44 +class DeployRollbackTestBase(unittest.TestCase):
  45 + def setUp(self):
  46 + self.home_tmp = tempfile.mkdtemp(prefix="deploy-rollback-home-")
  47 + self.addCleanup(shutil.rmtree, self.home_tmp, ignore_errors=True)
  48 + self.old_home = os.environ.get("GRAPHLEMON_HOME")
  49 + os.environ["GRAPHLEMON_HOME"] = self.home_tmp
  50 +
  51 + self.prod_db_path = os.path.join(self.home_tmp, "prod.db")
  52 + prod_conn = schema.connect(self.prod_db_path)
  53 + schema.create_schema(prod_conn)
  54 + prod_conn.close()
  55 +
  56 + self.conn = schema.connect(":memory:")
  57 + schema.create_schema(self.conn)
  58 + self.addCleanup(self.conn.close)
  59 +
  60 + self._cleanup_pids = []
  61 +
  62 + def tearDown(self):
  63 + if self.old_home is None:
  64 + os.environ.pop("GRAPHLEMON_HOME", None)
  65 + else:
  66 + os.environ["GRAPHLEMON_HOME"] = self.old_home
  67 + for pid in self._cleanup_pids:
  68 + if deploy_supervisor.is_alive(pid):
  69 + os.kill(pid, 9)
  70 +
  71 + def _seed_deployment(self, deployment_id, status, **extra):
  72 + cols = ["deployment_id", "git_commit_hash", "previous_commit_hash", "status", "previous_instance_dir"]
  73 + vals = [deployment_id, extra.get("git_commit_hash", "c1"), extra.get("previous_commit_hash", "c0"), status,
  74 + extra.get("previous_instance_dir", "/prev")]
  75 + self.conn.execute(
  76 + "INSERT INTO deployment ({}) VALUES ({})".format(", ".join(cols), ", ".join("?" * len(cols))), vals
  77 + )
  78 + self.conn.commit()
  79 +
  80 +
  81 +class RollbackPathAHappyPathTest(DeployRollbackTestBase):
  82 + def setUp(self):
  83 + super().setUp()
  84 + self.repo_dir = tempfile.mkdtemp(prefix="rollback-a-repo-")
  85 + self.addCleanup(shutil.rmtree, self.repo_dir, ignore_errors=True)
  86 + _init_git_repo(self.repo_dir)
  87 + _write(self.repo_dir, "f.txt", "v1\n")
  88 + self.commit_v1 = _commit_all(self.repo_dir, "v1")
  89 + _write(self.repo_dir, "f.txt", "v2 (self-modification)\n")
  90 + self.commit_v2 = _commit_all(self.repo_dir, "v2")
  91 +
  92 + def test_stops_candidate_deletes_db_reverts_commit_records_bug(self):
  93 + candidate_db = os.path.join(self.home_tmp, "candidate.db")
  94 + shutil.copy2(self.prod_db_path, candidate_db)
  95 + proc = subprocess.Popen(["sleep", "300"])
  96 + self._cleanup_pids.append(proc.pid)
  97 +
  98 + self._seed_deployment("dep-a1", "health_check_failed", git_commit_hash=self.commit_v2)
  99 +
  100 + result = deploy_rollback.rollback_path_a(
  101 + self.conn, "dep-a1", proc.pid, candidate_db, self.commit_v2, repo_root=self.repo_dir,
  102 + description="canary_task failed: connection refused",
  103 + )
  104 +
  105 + self.assertEqual(result["status"], "rolled_back")
  106 + self.assertFalse(deploy_supervisor.is_alive(proc.pid))
  107 + self.assertFalse(os.path.exists(candidate_db))
  108 +
  109 + with open(os.path.join(self.repo_dir, "f.txt"), encoding="utf-8") as f:
  110 + self.assertEqual(f.read(), "v1\n") # revert landed
  111 +
  112 + row = self.conn.execute("SELECT status FROM deployment WHERE deployment_id = 'dep-a1'").fetchone()
  113 + self.assertEqual(row[0], "rolled_back")
  114 +
  115 + bug = self.conn.execute(
  116 + "SELECT root_cause_category, status, description FROM bug_record"
  117 + ).fetchone()
  118 + self.assertEqual(bug[0], "core_engine")
  119 + self.assertEqual(bug[1], "resolved")
  120 + self.assertIn("canary_task failed", bug[2])
  121 +
  122 +
  123 +class RollbackPathAFailureTest(DeployRollbackTestBase):
  124 + def test_git_revert_conflict_marks_blocked_pending_human_and_raises(self):
  125 + repo_dir = tempfile.mkdtemp(prefix="rollback-a-conflict-repo-")
  126 + self.addCleanup(shutil.rmtree, repo_dir, ignore_errors=True)
  127 + _init_git_repo(repo_dir)
  128 + _write(repo_dir, "f.txt", "v1\n")
  129 + _commit_all(repo_dir, "v1")
  130 + _write(repo_dir, "f.txt", "v2 (self-modification)\n")
  131 + commit_v2 = _commit_all(repo_dir, "v2")
  132 + # A later commit that touches the SAME line v2 changed -- reverting
  133 + # v2 now conflicts, the exact scenario design doc §10 open question
  134 + # 3 describes ("主分支又有新的提交,revert 可能冲突").
  135 + _write(repo_dir, "f.txt", "v3 (unrelated later change, same line)\n")
  136 + _commit_all(repo_dir, "v3")
  137 +
  138 + self._seed_deployment("dep-a2", "health_check_failed", git_commit_hash=commit_v2)
  139 +
  140 + with self.assertRaises(deploy_rollback.RollbackFailedError):
  141 + deploy_rollback.rollback_path_a(
  142 + self.conn, "dep-a2", candidate_pid=None, candidate_db_path=None,
  143 + git_commit_hash=commit_v2, repo_root=repo_dir,
  144 + )
  145 +
  146 + row = self.conn.execute(
  147 + "SELECT status, rollback_reason FROM deployment WHERE deployment_id = 'dep-a2'"
  148 + ).fetchone()
  149 + self.assertEqual(row[0], "blocked_pending_human")
  150 + self.assertIn("git revert failed", row[1])
  151 +
  152 + # repo left clean, not mid-conflict, for whoever investigates
  153 + status_output = subprocess.run(
  154 + ["git", "status", "--porcelain"], cwd=repo_dir, capture_output=True, text=True, check=True
  155 + ).stdout
  156 + self.assertEqual(status_output.strip(), "")
  157 +
  158 +
  159 +class RollbackPathBHappyPathTest(DeployRollbackTestBase):
  160 + def setUp(self):
  161 + super().setUp()
  162 + self.repo_dir = tempfile.mkdtemp(prefix="rollback-b-repo-")
  163 + self.addCleanup(shutil.rmtree, self.repo_dir, ignore_errors=True)
  164 + _init_git_repo(self.repo_dir)
  165 + _write(self.repo_dir, "f.txt", "v1\n")
  166 + self.commit_v1 = _commit_all(self.repo_dir, "v1")
  167 + _write(self.repo_dir, "f.txt", "v2 (self-modification, buggy)\n")
  168 + self.commit_v2 = _commit_all(self.repo_dir, "v2")
  169 +
  170 + # The "previously known-good" checkout -- a REAL worktree of THIS
  171 + # actual graphlemon repo (needs real src/backend_server.py to relaunch),
  172 + # not repo_dir (which has no real graphlemon source in it).
  173 + self._prev_stash = subprocess.run(
  174 + ["git", "stash", "create"], cwd=paths.repo_root(), capture_output=True, text=True, check=True
  175 + ).stdout.strip() or deploy_stage._current_commit(paths.repo_root())
  176 + self.prev_dir = os.path.join(self.home_tmp, "previous-instance")
  177 + subprocess.run(
  178 + ["git", "worktree", "add", "--detach", self.prev_dir, self._prev_stash],
  179 + cwd=paths.repo_root(), check=True, capture_output=True, text=True,
  180 + )
  181 + self.addCleanup(
  182 + lambda: subprocess.run(
  183 + ["git", "worktree", "remove", "--force", self.prev_dir], cwd=paths.repo_root(), capture_output=True
  184 + )
  185 + )
  186 +
  187 + def test_stops_bad_live_revives_previous_reverts_commit_records_bug(self):
  188 + bad_live_port = deploy_stage._find_free_port()
  189 + bad_live = deploy_stage._launch_candidate(paths.repo_root(), bad_live_port, self.prod_db_path)
  190 + self._cleanup_pids.append(bad_live.pid)
  191 + self._wait_for_port(bad_live_port)
  192 +
  193 + self._seed_deployment(
  194 + "dep-b1", "anomaly_detected", git_commit_hash=self.commit_v2,
  195 + previous_instance_dir=self.prev_dir,
  196 + )
  197 +
  198 + rollback_port = deploy_stage._find_free_port()
  199 + result = deploy_rollback.rollback_path_b(
  200 + self.conn, "dep-b1", bad_live.pid, self.prev_dir, self.prod_db_path, self.commit_v2,
  201 + repo_root=self.repo_dir, live_port=rollback_port,
  202 + )
  203 + self._cleanup_pids.append( # find the revived process's pid via port not tracked -- best-effort cleanup
  204 + bad_live.pid
  205 + )
  206 +
  207 + self.assertEqual(result["status"], "rolled_back")
  208 + self.assertFalse(deploy_supervisor.is_alive(bad_live.pid))
  209 +
  210 + with open(os.path.join(self.repo_dir, "f.txt"), encoding="utf-8") as f:
  211 + self.assertEqual(f.read(), "v1\n")
  212 +
  213 + row = self.conn.execute("SELECT status FROM deployment WHERE deployment_id = 'dep-b1'").fetchone()
  214 + self.assertEqual(row[0], "rolled_back")
  215 +
  216 + bug = self.conn.execute("SELECT root_cause_category FROM bug_record").fetchone()
  217 + self.assertEqual(bug[0], "core_engine")
  218 +
  219 + # revived instance genuinely serving on rollback_port
  220 + health = deploy_health_check.check_http_health(rollback_port)
  221 + self.assertEqual(health["status"], "ok")
  222 +
  223 + def _wait_for_port(self, port, timeout=10):
  224 + deadline = time.time() + timeout
  225 + while time.time() < deadline:
  226 + if deploy_health_check.check_http_health(port, timeout=1)["status"] == "ok":
  227 + return
  228 + time.sleep(0.2)
  229 + self.fail("port {} never came up".format(port))
  230 +
  231 +
  232 +class RollbackPathBFailureTest(DeployRollbackTestBase):
  233 + """The Unit's own highest-risk scenario, tested for real: the old
  234 + version ALSO can't start."""
  235 +
  236 + def test_previous_instance_also_fails_to_start_marks_blocked_pending_human(self):
  237 + repo_dir = tempfile.mkdtemp(prefix="rollback-b-fail-repo-")
  238 + self.addCleanup(shutil.rmtree, repo_dir, ignore_errors=True)
  239 + _init_git_repo(repo_dir)
  240 + _write(repo_dir, "f.txt", "v1\n")
  241 + _commit_all(repo_dir, "v1")
  242 + _write(repo_dir, "f.txt", "v2\n")
  243 + commit_v2 = _commit_all(repo_dir, "v2")
  244 +
  245 + broken_previous_dir = os.path.join(self.home_tmp, "broken-previous")
  246 + os.makedirs(broken_previous_dir) # a real directory, but with NO python package in it at all
  247 +
  248 + self._seed_deployment(
  249 + "dep-b2", "anomaly_detected", git_commit_hash=commit_v2, previous_instance_dir=broken_previous_dir,
  250 + )
  251 +
  252 + with self.assertRaises(deploy_rollback.RollbackFailedError):
  253 + deploy_rollback.rollback_path_b(
  254 + self.conn, "dep-b2", live_pid=None, previous_instance_dir=broken_previous_dir,
  255 + prod_db_path=self.prod_db_path, git_commit_hash=commit_v2, repo_root=repo_dir,
  256 + live_port=deploy_stage._find_free_port(), bind_timeout=3,
  257 + )
  258 +
  259 + row = self.conn.execute(
  260 + "SELECT status, rollback_reason FROM deployment WHERE deployment_id = 'dep-b2'"
  261 + ).fetchone()
  262 + self.assertEqual(row[0], "blocked_pending_human")
  263 + self.assertIn("rollback_failed", row[1])
  264 +
  265 + # commit was NOT reverted -- rollback failed before it ever got there
  266 + with open(os.path.join(repo_dir, "f.txt"), encoding="utf-8") as f:
  267 + self.assertEqual(f.read(), "v2\n")
  268 +
  269 +
  270 +if __name__ == "__main__":
  271 + unittest.main()
... ...