You need to sign in before continuing.
Commit 48e384418175b17167af225587023d1457a32cda
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>
Showing
6 changed files
with
369 additions
and
1 deletions
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 = "/api/skills/" | @@ -132,6 +132,9 @@ _SKILLS_PREFIX = "/api/skills/" | ||
| 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() |