You need to sign in before continuing.

Commit 48e384418175b17167af225587023d1457a32cda

Authored by bboonnee
1 parent 5a046a4f

U-038: c_deploy_supervisor_health_check (process_alive/http_health/canary_task)

Adds supervisor/deploy_health_check.py: run_health_check() runs the three
pre-cutover checks in order, stopping at the first failure (03-C: "任一检查项
fail 立即整体判定 health_check_failed,不等待其余检查项跑完") and persisting
a deploy_health_signal row for every check that actually ran.

- process_alive: polls deploy_supervisor.is_alive() up to HEALTH_CHECK_STARTUP_TIMEOUT.
- http_health: hits the candidate's /api/health -- added to backend_server.py
  (U-037 deliberately left this for U-038). Not just "process exists" --
  runs a real SELECT 1 to catch a wedged sqlite connection a live-but-stuck
  process would otherwise pass.
- canary_task: a real (not mocked) project+task creation round-trip against
  the candidate over HTTP -- no real Org Graph/claude calls, since
  c_task_create's synchronous 'queued' landing is already a deterministic,
  always-should-pass result, which is what a canary needs.

Timeouts are the MVP-fixed drafts from 05 §5.9, explicitly flagged there as
needing real calibration later, not hardcoded as authoritative.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docs/development-plan.md
@@ -368,6 +368,8 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 @@ -368,6 +368,8 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。
368 - **U-038 [Risk Gate]** `c_deploy_supervisor_health_check`:`process_alive`/`http_health`/`canary_task` 三项 pre-cutover 检查 368 - **U-038 [Risk Gate]** `c_deploy_supervisor_health_check`:`process_alive`/`http_health`/`canary_task` 三项 pre-cutover 检查
369 - 依赖:U-037 369 - 依赖:U-037
370 - Done:三项检查全部通过 → `status=health_check_passed`;任一失败/超时 → `status=health_check_failed`,Live 全程未受影响(对应回滚路径 A) 370 - Done:三项检查全部通过 → `status=health_check_passed`;任一失败/超时 → `status=health_check_failed`,Live 全程未受影响(对应回滚路径 A)
  371 + - **实现补充(2026-08-02)**:新增 `supervisor/deploy_health_check.py`。`process_alive` 复用 U-036 `deploy_supervisor.is_alive()`(`os.kill(pid,0)`),按 `HEALTH_CHECK_STARTUP_TIMEOUT` 轮询;`http_health` 请求候选的 `/api/health`——**这个端点 U-037 当时特意留空,这里补上**:`backend_server.py` 新增 `GET /api/health`,不只是"进程活着就 200",还真的执行一次 `SELECT 1` 确认 sqlite 连接本身没卡死(连接卡死但进程还活着的场景,只测 `process_alive` 会漏检,这正是"health check"要抓的那类问题);`canary_task` 是design doc §6"结果可预期的最小任务"字面实现——对候选发起一次真实的"建临时 project(临时目录 `git init` + 一个空提交)+ 建一个 goal 任务 + 读回确认 `status=queued`/`goal_text` 匹配",不跑真实 Org Graph(不产生真实 `claude` 调用/花费),因为 `c_task_create` 本身落 `queued` 就是同步、确定性、"已知会通过"的最小验证,够用。三项检查按 03-C 原文"任一 fail 立即整体判定,不等其余项跑完"实现,`run_health_check()` 用 tuple-of-lambda 顺序短路,每项检查结果都真实写一条 `deploy_health_signal`(哪怕后续项因为短路没跑,已跑的那几条也留痕)。
  372 + - 覆盖:03-C `c_deploy_supervisor_health_check`;`backend_server.py` 新增 `GET /api/health`(U-037 留空的口子,这里补上);01-M `deploy_health_signal`(首次被真正写入)
371 373
372 - **U-039 [Risk Gate]** `c_deploy_supervisor_cutover`:蓝绿切换本体(Live 停止接受新触发 → 候选切换到生产端口/生产库 → 旧 Live checkout 保留为 `previous_instance_dir`) 374 - **U-039 [Risk Gate]** `c_deploy_supervisor_cutover`:蓝绿切换本体(Live 停止接受新触发 → 候选切换到生产端口/生产库 → 旧 Live checkout 保留为 `previous_instance_dir`)
373 - 依赖:U-038 375 - 依赖:U-038
docs/sdd/unit_matrix.json
@@ -55,7 +55,7 @@ @@ -55,7 +55,7 @@
55 {"id": "U-035", "phase": "PH5", "sub_phase": "PH5-S1", "name": "self-target worktree 无条件强制 + core_engine 分类审计(见 development-plan.md 2026-08-02 实现补充)", "classification": "Risk Gate", "depends_on": ["U-013", "U-016", "U-034"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": ["REQ-03"]}, "done": "self-target 时无条件走 worktree(work_graph_orchestrate.run_graph 内强制);core_engine 分类(src/core_engine_classify.py,路径规则)在 self-target 任务 PASS 收尾时记一条审计用 bug_record;且改动落地后重跑 U-013/REQ-03(worktree 隔离)现有回归用例全部通过。**2026-08-02 用户决策修正**:Deploy Supervisor 触发条件按 supervisor-auto-rollback-design.md §1 字面设计(self-target+PASS 即触发,bug_record 是旁路产物不是前提),而非 03-C/04-P 字面文本要求的\"先有 core_engine bug_record\"——后者会让从不产生 bug_record 的 goal 型自我改进任务永远触发不了 Deploy Supervisor,已用 AskUserQuestion 与用户确认此订正。c_org_loop_route 未改动(fast_lane/full_replan 是 FAIL 路径重试路由,与此处 PASS 路径的蓝绿触发正交),deployment 行创建仍是 U-037 的范围,不在本 Unit 提前做"}, 55 {"id": "U-035", "phase": "PH5", "sub_phase": "PH5-S1", "name": "self-target worktree 无条件强制 + core_engine 分类审计(见 development-plan.md 2026-08-02 实现补充)", "classification": "Risk Gate", "depends_on": ["U-013", "U-016", "U-034"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": ["REQ-03"]}, "done": "self-target 时无条件走 worktree(work_graph_orchestrate.run_graph 内强制);core_engine 分类(src/core_engine_classify.py,路径规则)在 self-target 任务 PASS 收尾时记一条审计用 bug_record;且改动落地后重跑 U-013/REQ-03(worktree 隔离)现有回归用例全部通过。**2026-08-02 用户决策修正**:Deploy Supervisor 触发条件按 supervisor-auto-rollback-design.md §1 字面设计(self-target+PASS 即触发,bug_record 是旁路产物不是前提),而非 03-C/04-P 字面文本要求的\"先有 core_engine bug_record\"——后者会让从不产生 bug_record 的 goal 型自我改进任务永远触发不了 Deploy Supervisor,已用 AskUserQuestion 与用户确认此订正。c_org_loop_route 未改动(fast_lane/full_replan 是 FAIL 路径重试路由,与此处 PASS 路径的蓝绿触发正交),deployment 行创建仍是 U-037 的范围,不在本 Unit 提前做"},
56 {"id": "U-036", "phase": "PH5", "sub_phase": "PH5-S2", "name": "supervisor/isolation 补齐单测 + Deploy Supervisor 进程骨架(见 development-plan.md 2026-08-02 实现补充)", "classification": "Enabler", "depends_on": [], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": []}, "done": "四模块各有 test_*.py(供 python3 -m unittest discover -s supervisor/isolation 运行,52 测试全绿);Deploy Supervisor 骨架(supervisor/deploy_supervisor.py)独立进程可启停(真实子进程验证,非 mock),不受 Live/Candidate 崩溃影响;本 Unit 只做进程生命周期骨架,不含任何真实部署决策逻辑(U-037+ 的 poll_fn 挂载点)"}, 56 {"id": "U-036", "phase": "PH5", "sub_phase": "PH5-S2", "name": "supervisor/isolation 补齐单测 + Deploy Supervisor 进程骨架(见 development-plan.md 2026-08-02 实现补充)", "classification": "Enabler", "depends_on": [], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": []}, "done": "四模块各有 test_*.py(供 python3 -m unittest discover -s supervisor/isolation 运行,52 测试全绿);Deploy Supervisor 骨架(supervisor/deploy_supervisor.py)独立进程可启停(真实子进程验证,非 mock),不受 Live/Candidate 崩溃影响;本 Unit 只做进程生命周期骨架,不含任何真实部署决策逻辑(U-037+ 的 poll_fn 挂载点)"},
57 {"id": "U-037", "phase": "PH5", "sub_phase": "PH5-S2", "name": "c_deploy_supervisor_stage(见 development-plan.md 2026-08-02 实现补充)", "classification": "Risk Gate", "depends_on": ["U-034", "U-036"], "coverage": {"model": ["deployment"], "view": [], "controller": ["c_deploy_supervisor_stage"], "process": [], "requirement": []}, "done": "能生成 deployment 记录并拉起监听临时端口、连一次性 DB 副本的候选进程(supervisor/deploy_stage.py,真实 git worktree + 真实子进程验证过);顺带补上 deployment.candidate_instance_dir 缺字段 + backend_server.py 首个 CLI 入口(此前只能进程内启动,从未真正作为独立 OS 进程跑过)"}, 57 {"id": "U-037", "phase": "PH5", "sub_phase": "PH5-S2", "name": "c_deploy_supervisor_stage(见 development-plan.md 2026-08-02 实现补充)", "classification": "Risk Gate", "depends_on": ["U-034", "U-036"], "coverage": {"model": ["deployment"], "view": [], "controller": ["c_deploy_supervisor_stage"], "process": [], "requirement": []}, "done": "能生成 deployment 记录并拉起监听临时端口、连一次性 DB 副本的候选进程(supervisor/deploy_stage.py,真实 git worktree + 真实子进程验证过);顺带补上 deployment.candidate_instance_dir 缺字段 + backend_server.py 首个 CLI 入口(此前只能进程内启动,从未真正作为独立 OS 进程跑过)"},
58 - {"id": "U-038", "phase": "PH5", "sub_phase": "PH5-S2", "name": "c_deploy_supervisor_health_check", "classification": "Risk Gate", "depends_on": ["U-037"], "coverage": {"model": [], "view": [], "controller": ["c_deploy_supervisor_health_check"], "process": [], "requirement": []}, "done": "process_alive/http_health/canary_task 三项检查全过 -> health_check_passed;任一失败 -> health_check_failed 且 Live 未受影响"}, 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 {"id": "U-039", "phase": "PH5", "sub_phase": "PH5-S2", "name": "c_deploy_supervisor_cutover", "classification": "Risk Gate", "depends_on": ["U-038"], "coverage": {"model": ["deployment"], "view": [], "controller": ["c_deploy_supervisor_cutover"], "process": [], "requirement": []}, "done": "健康检查通过后完成真实切换;在途 work_node_execution 重启后可恢复;previous_instance_dir 保留至监控窗口结束"}, 59 {"id": "U-039", "phase": "PH5", "sub_phase": "PH5-S2", "name": "c_deploy_supervisor_cutover", "classification": "Risk Gate", "depends_on": ["U-038"], "coverage": {"model": ["deployment"], "view": [], "controller": ["c_deploy_supervisor_cutover"], "process": [], "requirement": []}, "done": "健康检查通过后完成真实切换;在途 work_node_execution 重启后可恢复;previous_instance_dir 保留至监控窗口结束"},
60 {"id": "U-040", "phase": "PH5", "sub_phase": "PH5-S3", "name": "c_deploy_supervisor_monitor", "classification": "Risk Gate", "depends_on": ["U-039"], "coverage": {"model": ["deploy_health_signal"], "view": [], "controller": ["c_deploy_supervisor_monitor"], "process": [], "requirement": []}, "done": "四类信号采样正常;validator_approval_rate 偏离基线阈值时触发 anomaly_detected"}, 60 {"id": "U-040", "phase": "PH5", "sub_phase": "PH5-S3", "name": "c_deploy_supervisor_monitor", "classification": "Risk Gate", "depends_on": ["U-039"], "coverage": {"model": ["deploy_health_signal"], "view": [], "controller": ["c_deploy_supervisor_monitor"], "process": [], "requirement": []}, "done": "四类信号采样正常;validator_approval_rate 偏离基线阈值时触发 anomaly_detected"},
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", "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 冲突有明确处理"},
src/backend_server.py
@@ -132,6 +132,9 @@ _SKILLS_PREFIX = &quot;/api/skills/&quot; @@ -132,6 +132,9 @@ _SKILLS_PREFIX = &quot;/api/skills/&quot;
132 class _Handler(BaseHTTPRequestHandler): 132 class _Handler(BaseHTTPRequestHandler):
133 def do_GET(self): 133 def do_GET(self):
134 parsed = urllib.parse.urlsplit(self.path) 134 parsed = urllib.parse.urlsplit(self.path)
  135 + if parsed.path == "/api/health":
  136 + self._handle_health()
  137 + return
135 if parsed.path.startswith(_WS_WORK_NODES_PREFIX) and parsed.path.endswith(_LOG_SUFFIX): 138 if parsed.path.startswith(_WS_WORK_NODES_PREFIX) and parsed.path.endswith(_LOG_SUFFIX):
136 node_exec_id = parsed.path[len(_WS_WORK_NODES_PREFIX):-len(_LOG_SUFFIX)] 139 node_exec_id = parsed.path[len(_WS_WORK_NODES_PREFIX):-len(_LOG_SUFFIX)]
137 self._handle_ws_log_tail(node_exec_id) 140 self._handle_ws_log_tail(node_exec_id)
@@ -235,6 +238,22 @@ class _Handler(BaseHTTPRequestHandler): @@ -235,6 +238,22 @@ class _Handler(BaseHTTPRequestHandler):
235 except json.JSONDecodeError: 238 except json.JSONDecodeError:
236 return None, {"error": "INVALID_INPUT", "detail": "body is not valid JSON"} 239 return None, {"error": "INVALID_INPUT", "detail": "body is not valid JSON"}
237 240
  241 + def _handle_health(self):
  242 + """c_deploy_supervisor_health_check's `http_health` probe (U-038,
  243 + 03-C §3.15/§6). Confirms both "the HTTP server thread answers" and
  244 + "the sqlite connection is actually usable" -- a process that's alive
  245 + but whose DB connection got wedged somehow would otherwise pass a
  246 + process_alive check and fail every real request afterward; catching
  247 + that here, not just accepting a 200 for existing, is the point of a
  248 + health check in the first place."""
  249 + try:
  250 + with self.server.conn_lock:
  251 + self.server.conn.execute("SELECT 1").fetchone()
  252 + except sqlite3.Error as e:
  253 + self._send_json(503, {"status": "error", "detail": str(e)})
  254 + return
  255 + self._send_json(200, {"status": "ok"})
  256 +
238 def _handle_project_create(self): 257 def _handle_project_create(self):
239 payload, error = self._read_json_body() 258 payload, error = self._read_json_body()
240 if error: 259 if error:
src/test_backend_server.py
@@ -117,6 +117,40 @@ class StaticFileServingTest(unittest.TestCase): @@ -117,6 +117,40 @@ class StaticFileServingTest(unittest.TestCase):
117 self.assertEqual(json.loads(body)["error"], "NOT_FOUND") 117 self.assertEqual(json.loads(body)["error"], "NOT_FOUND")
118 118
119 119
  120 +class HealthRouteTest(unittest.TestCase):
  121 + """U-038: c_deploy_supervisor_health_check's http_health probe target."""
  122 +
  123 + def setUp(self):
  124 + self.server = backend_server.create_server(db_path=":memory:")
  125 + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
  126 + self.thread.start()
  127 +
  128 + def tearDown(self):
  129 + self.server.shutdown()
  130 + self.server.server_close()
  131 + self.thread.join(timeout=5)
  132 +
  133 + def _get(self, path):
  134 + host, port = self.server.server_address
  135 + conn = http.client.HTTPConnection(host, port, timeout=5)
  136 + conn.request("GET", path)
  137 + resp = conn.getresponse()
  138 + body = resp.read()
  139 + conn.close()
  140 + return resp.status, body
  141 +
  142 + def test_returns_200_ok_when_db_connection_is_usable(self):
  143 + status, body = self._get("/api/health")
  144 + self.assertEqual(status, 200)
  145 + self.assertEqual(json.loads(body), {"status": "ok"})
  146 +
  147 + def test_returns_503_when_db_connection_is_broken(self):
  148 + self.server.conn.close() # simulate a wedged/closed connection
  149 + status, body = self._get("/api/health")
  150 + self.assertEqual(status, 503)
  151 + self.assertEqual(json.loads(body)["status"], "error")
  152 +
  153 +
120 class PickFolderTest(unittest.TestCase): 154 class PickFolderTest(unittest.TestCase):
121 def test_success_returns_selected_path(self): 155 def test_success_returns_selected_path(self):
122 with patch("src.backend_server.platform.system", return_value="Darwin"), \ 156 with patch("src.backend_server.platform.system", return_value="Darwin"), \
supervisor/deploy_health_check.py 0 → 100644
  1 +"""c_deploy_supervisor_health_check (03-C §3.15 / 04-P §4.6 / design doc §6):
  2 +three pre-cutover checks against a staged candidate -- `process_alive`,
  3 +`http_health`, `canary_task`. All confirmed fail-closed and non-retrying per
  4 +03-C's own text: "任一检查项 fail 立即整体判定 health_check_failed,不等待
  5 +其余检查项跑完" and "不重试——重试属于'给坏版本第二次机会'". Timeouts are the
  6 +MVP-fixed draft values from 05 §5.9 (`HEALTH_CHECK_STARTUP_TIMEOUT`=30s,
  7 +`HEALTH_CHECK_HTTP_TIMEOUT`=10s, `HEALTH_CHECK_CANARY_TIMEOUT`=120s) --
  8 +05 §5.9's own text already flags these as engineering drafts needing
  9 +real-world calibration, not authoritative constants to bake into assertions
  10 +elsewhere.
  11 +"""
  12 +
  13 +import json
  14 +import shutil
  15 +import subprocess
  16 +import tempfile
  17 +import time
  18 +import urllib.error
  19 +import urllib.request
  20 +import uuid
  21 +
  22 +from supervisor import deploy_supervisor
  23 +
  24 +HEALTH_CHECK_STARTUP_TIMEOUT = 30
  25 +HEALTH_CHECK_HTTP_TIMEOUT = 10
  26 +HEALTH_CHECK_CANARY_TIMEOUT = 120
  27 +
  28 +_SIGNAL_TYPES = ("process_alive", "http_health", "canary_task")
  29 +
  30 +
  31 +def check_process_alive(pid, timeout=HEALTH_CHECK_STARTUP_TIMEOUT, sleep=time.sleep, now=time.time):
  32 + deadline = now() + timeout
  33 + while now() < deadline:
  34 + if deploy_supervisor.is_alive(pid):
  35 + return {"signal_type": "process_alive", "status": "ok", "detail": None}
  36 + sleep(0.5)
  37 + return {
  38 + "signal_type": "process_alive", "status": "fail",
  39 + "detail": "pid {} not alive within {}s".format(pid, timeout),
  40 + }
  41 +
  42 +
  43 +def check_http_health(port, timeout=HEALTH_CHECK_HTTP_TIMEOUT):
  44 + try:
  45 + with urllib.request.urlopen("http://127.0.0.1:{}/api/health".format(port), timeout=timeout) as resp:
  46 + if resp.status == 200:
  47 + return {"signal_type": "http_health", "status": "ok", "detail": None}
  48 + return {
  49 + "signal_type": "http_health", "status": "fail",
  50 + "detail": "unexpected HTTP status {}".format(resp.status),
  51 + }
  52 + except urllib.error.HTTPError as e:
  53 + # HTTPError IS the response object (a non-2xx status raises rather
  54 + # than returning) -- close it explicitly, `except` doesn't run our
  55 + # `with` block's __exit__ for us.
  56 + detail = "unexpected HTTP status {}".format(e.code)
  57 + e.close()
  58 + return {"signal_type": "http_health", "status": "fail", "detail": detail}
  59 + except (urllib.error.URLError, OSError) as e:
  60 + return {"signal_type": "http_health", "status": "fail", "detail": str(e)}
  61 +
  62 +
  63 +def _post_json(port, path, payload, timeout):
  64 + body = json.dumps(payload).encode("utf-8")
  65 + req = urllib.request.Request(
  66 + "http://127.0.0.1:{}{}".format(port, path), data=body,
  67 + headers={"Content-Type": "application/json"}, method="POST",
  68 + )
  69 + with urllib.request.urlopen(req, timeout=timeout) as resp:
  70 + return resp.status, json.loads(resp.read())
  71 +
  72 +
  73 +def _get_json(port, path, timeout):
  74 + with urllib.request.urlopen("http://127.0.0.1:{}{}".format(port, path), timeout=timeout) as resp:
  75 + return resp.status, json.loads(resp.read())
  76 +
  77 +
  78 +def check_canary_task(port, timeout=HEALTH_CHECK_CANARY_TIMEOUT):
  79 + """"结果可预期的最小任务" (design doc §6): a trivial project + task
  80 + creation round-trip. Doesn't run a real Org Graph (no real `claude`
  81 + calls, no real cost/latency) -- `c_task_create` itself lands
  82 + `status='queued'` synchronously, which is exactly the kind of
  83 + deterministic, always-should-pass result a canary needs; the point is
  84 + proving the candidate's HTTP+DB write path genuinely works, not
  85 + exercising the full pipeline."""
  86 + canary_dir = tempfile.mkdtemp(prefix="deploy-canary-")
  87 + try:
  88 + subprocess.run(["git", "init", "-q"], cwd=canary_dir, check=True, capture_output=True)
  89 + subprocess.run(
  90 + ["git", "-c", "user.name=graphlemon", "-c", "user.email=graphlemon@localhost",
  91 + "commit", "--allow-empty", "-q", "-m", "canary"],
  92 + cwd=canary_dir, check=True, capture_output=True,
  93 + )
  94 +
  95 + status, project = _post_json(
  96 + port, "/api/projects", {"target_folder_path": canary_dir, "name": "deploy-canary"}, timeout
  97 + )
  98 + if status != 200:
  99 + return {"signal_type": "canary_task", "status": "fail", "detail": "project create: HTTP {}".format(status)}
  100 +
  101 + goal_text = "deploy-supervisor canary " + uuid.uuid4().hex
  102 + status, task = _post_json(
  103 + port, "/api/tasks", {"project_id": project["project_id"], "goal_text": goal_text}, timeout
  104 + )
  105 + if status != 200:
  106 + return {"signal_type": "canary_task", "status": "fail", "detail": "task create: HTTP {}".format(status)}
  107 +
  108 + status, fetched = _get_json(port, "/api/tasks/" + task["task_id"], timeout)
  109 + if status != 200 or fetched.get("status") != "queued" or fetched.get("goal_text") != goal_text:
  110 + return {
  111 + "signal_type": "canary_task", "status": "fail",
  112 + "detail": "unexpected task state after create: HTTP {} {!r}".format(status, fetched),
  113 + }
  114 + return {"signal_type": "canary_task", "status": "ok", "detail": None}
  115 + except urllib.error.HTTPError as e:
  116 + detail = "unexpected HTTP status {}".format(e.code)
  117 + e.close()
  118 + return {"signal_type": "canary_task", "status": "fail", "detail": detail}
  119 + except (urllib.error.URLError, OSError, subprocess.CalledProcessError, KeyError) as e:
  120 + return {"signal_type": "canary_task", "status": "fail", "detail": str(e)}
  121 + finally:
  122 + shutil.rmtree(canary_dir, ignore_errors=True)
  123 +
  124 +
  125 +def _record_signal(conn, deployment_id, signal):
  126 + conn.execute(
  127 + "INSERT INTO deploy_health_signal (signal_id, deployment_id, sampled_at, signal_type, status, detail) "
  128 + "VALUES (?, ?, CURRENT_TIMESTAMP, ?, ?, ?)",
  129 + (str(uuid.uuid4()), deployment_id, signal["signal_type"], signal["status"], signal["detail"]),
  130 + )
  131 + conn.commit()
  132 +
  133 +
  134 +def run_health_check(
  135 + conn, deployment_id, pid, port,
  136 + startup_timeout=HEALTH_CHECK_STARTUP_TIMEOUT, http_timeout=HEALTH_CHECK_HTTP_TIMEOUT,
  137 + canary_timeout=HEALTH_CHECK_CANARY_TIMEOUT, sleep=time.sleep, now=time.time,
  138 +):
  139 + """c_deploy_supervisor_health_check. Returns {status, signals}. Marks
  140 + `deployment.status='health_check_running'` on entry, then
  141 + 'health_check_passed'/'health_check_failed' once done -- fails closed
  142 + and stops at the FIRST failing check (no point running http_health/
  143 + canary_task against a process that isn't even alive yet)."""
  144 + conn.execute("UPDATE deployment SET status = 'health_check_running' WHERE deployment_id = ?", (deployment_id,))
  145 + conn.commit()
  146 +
  147 + signals = []
  148 + checks = (
  149 + lambda: check_process_alive(pid, timeout=startup_timeout, sleep=sleep, now=now),
  150 + lambda: check_http_health(port, timeout=http_timeout),
  151 + lambda: check_canary_task(port, timeout=canary_timeout),
  152 + )
  153 + for check in checks:
  154 + signal = check()
  155 + _record_signal(conn, deployment_id, signal)
  156 + signals.append(signal)
  157 + if signal["status"] != "ok":
  158 + conn.execute(
  159 + "UPDATE deployment SET status = 'health_check_failed' WHERE deployment_id = ?", (deployment_id,)
  160 + )
  161 + conn.commit()
  162 + return {"status": "health_check_failed", "signals": signals}
  163 +
  164 + conn.execute("UPDATE deployment SET status = 'health_check_passed' WHERE deployment_id = ?", (deployment_id,))
  165 + conn.commit()
  166 + return {"status": "health_check_passed", "signals": signals}
supervisor/test_deploy_health_check.py 0 → 100644
  1 +"""U-038 tests: c_deploy_supervisor_health_check. Real HTTP server (in-process
  2 +thread, same pattern src/test_backend_server.py already uses), real project/
  3 +task creation round trips for the canary check -- nothing here is mocked
  4 +except process liveness timing (injectable sleep/now, same convention as
  5 +work_node_run's own quota-wait tests).
  6 +
  7 +Run: python3 -m unittest discover -s supervisor -p "test_*.py" -v
  8 +"""
  9 +
  10 +import os
  11 +import sys
  12 +import unittest
  13 +from unittest.mock import patch
  14 +
  15 +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
  16 +
  17 +import threading
  18 +
  19 +from supervisor import deploy_health_check as dhc
  20 +from src import backend_server, schema
  21 +
  22 +
  23 +class RealServerTestBase(unittest.TestCase):
  24 + def setUp(self):
  25 + self.server = backend_server.create_server(db_path=":memory:")
  26 + self.port = self.server.server_address[1]
  27 + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
  28 + self.thread.start()
  29 +
  30 + def tearDown(self):
  31 + self.server.shutdown()
  32 + self.server.server_close()
  33 + self.thread.join(timeout=5)
  34 +
  35 +
  36 +class CheckProcessAliveTest(unittest.TestCase):
  37 + def test_alive_process_returns_ok_immediately(self):
  38 + signal = dhc.check_process_alive(os.getpid())
  39 + self.assertEqual(signal, {"signal_type": "process_alive", "status": "ok", "detail": None})
  40 +
  41 + def test_dead_pid_times_out_and_fails(self):
  42 + clock = {"t": 0.0}
  43 + sleeps = []
  44 + signal = dhc.check_process_alive(
  45 + 2**30, timeout=5, sleep=lambda s: (sleeps.append(s), clock.__setitem__("t", clock["t"] + s)),
  46 + now=lambda: clock["t"],
  47 + )
  48 + self.assertEqual(signal["signal_type"], "process_alive")
  49 + self.assertEqual(signal["status"], "fail")
  50 + self.assertTrue(sleeps)
  51 +
  52 + def test_process_that_becomes_alive_mid_poll_still_passes(self):
  53 + calls = {"n": 0}
  54 +
  55 + def fake_is_alive(pid):
  56 + calls["n"] += 1
  57 + return calls["n"] >= 3
  58 +
  59 + with patch("supervisor.deploy_supervisor.is_alive", side_effect=fake_is_alive):
  60 + signal = dhc.check_process_alive(99999, timeout=5, sleep=lambda s: None, now=lambda: 0.0)
  61 + self.assertEqual(signal["status"], "ok")
  62 + self.assertEqual(calls["n"], 3)
  63 +
  64 +
  65 +class CheckHttpHealthTest(RealServerTestBase):
  66 + def test_healthy_server_returns_ok(self):
  67 + signal = dhc.check_http_health(self.port)
  68 + self.assertEqual(signal, {"signal_type": "http_health", "status": "ok", "detail": None})
  69 +
  70 + def test_broken_connection_returns_fail(self):
  71 + self.server.conn.close()
  72 + signal = dhc.check_http_health(self.port)
  73 + self.assertEqual(signal["signal_type"], "http_health")
  74 + self.assertEqual(signal["status"], "fail")
  75 +
  76 + def test_nothing_listening_on_port_returns_fail(self):
  77 + signal = dhc.check_http_health(1) # privileged/unbound port, nothing listening
  78 + self.assertEqual(signal["status"], "fail")
  79 +
  80 +
  81 +class CheckCanaryTaskTest(RealServerTestBase):
  82 + def test_real_project_and_task_round_trip_passes(self):
  83 + signal = dhc.check_canary_task(self.port)
  84 + self.assertEqual(signal, {"signal_type": "canary_task", "status": "ok", "detail": None})
  85 +
  86 + conn = self.server.conn
  87 + count = conn.execute("SELECT COUNT(*) FROM task WHERE goal_text LIKE 'deploy-supervisor canary%'").fetchone()[0]
  88 + self.assertEqual(count, 1)
  89 +
  90 + def test_nothing_listening_returns_fail(self):
  91 + signal = dhc.check_canary_task(1)
  92 + self.assertEqual(signal["signal_type"], "canary_task")
  93 + self.assertEqual(signal["status"], "fail")
  94 +
  95 +
  96 +class RunHealthCheckTest(RealServerTestBase):
  97 + def setUp(self):
  98 + super().setUp()
  99 + self.conn = schema.connect(":memory:")
  100 + schema.create_schema(self.conn)
  101 + self.conn.execute(
  102 + "INSERT INTO deployment (deployment_id, git_commit_hash, previous_commit_hash, status, "
  103 + "previous_instance_dir) VALUES ('dep-1', 'c1', 'c0', 'candidate_starting', '/prev')"
  104 + )
  105 + self.conn.commit()
  106 + self.addCleanup(self.conn.close)
  107 +
  108 + def test_all_checks_pass_marks_health_check_passed(self):
  109 + result = dhc.run_health_check(self.conn, "dep-1", os.getpid(), self.port)
  110 + self.assertEqual(result["status"], "health_check_passed")
  111 + self.assertEqual([s["signal_type"] for s in result["signals"]], ["process_alive", "http_health", "canary_task"])
  112 + self.assertTrue(all(s["status"] == "ok" for s in result["signals"]))
  113 +
  114 + row = self.conn.execute("SELECT status FROM deployment WHERE deployment_id = 'dep-1'").fetchone()
  115 + self.assertEqual(row[0], "health_check_passed")
  116 +
  117 + signal_count = self.conn.execute(
  118 + "SELECT COUNT(*) FROM deploy_health_signal WHERE deployment_id = 'dep-1'"
  119 + ).fetchone()[0]
  120 + self.assertEqual(signal_count, 3)
  121 +
  122 + def test_process_alive_failure_stops_before_http_and_canary_checks(self):
  123 + result = dhc.run_health_check(
  124 + self.conn, "dep-1", 2**30, self.port,
  125 + startup_timeout=0, sleep=lambda s: None, now=lambda: 0.0,
  126 + )
  127 + self.assertEqual(result["status"], "health_check_failed")
  128 + self.assertEqual(len(result["signals"]), 1)
  129 + self.assertEqual(result["signals"][0]["signal_type"], "process_alive")
  130 +
  131 + row = self.conn.execute("SELECT status FROM deployment WHERE deployment_id = 'dep-1'").fetchone()
  132 + self.assertEqual(row[0], "health_check_failed")
  133 +
  134 + signal_count = self.conn.execute(
  135 + "SELECT COUNT(*) FROM deploy_health_signal WHERE deployment_id = 'dep-1'"
  136 + ).fetchone()[0]
  137 + self.assertEqual(signal_count, 1) # http_health/canary_task never ran
  138 +
  139 + def test_http_health_failure_stops_before_canary_check(self):
  140 + self.server.conn.close()
  141 + result = dhc.run_health_check(self.conn, "dep-1", os.getpid(), self.port)
  142 + self.assertEqual(result["status"], "health_check_failed")
  143 + self.assertEqual([s["signal_type"] for s in result["signals"]], ["process_alive", "http_health"])
  144 +
  145 +
  146 +if __name__ == "__main__":
  147 + unittest.main()