Commit 98f0d5cd59931e23fbcb6bff5ae3093ae9ae5e44
1 parent
002bba46
U-040: c_deploy_supervisor_monitor (four post-cutover signals)
Adds supervisor/deploy_monitor.py: run_monitor_cycle() is one sampling pass, called repeatedly by Deploy Supervisor's own poll loop (03-C's own text: not a one-shot call). process_alive/http_health/canary_task/ validator_approval_rate sampled in order, stopping at the first anomaly. validator_approval_rate's "approve rate" has no literal definition anywhere in 01-M/03-C/04-P (no such execution_event type exists) -- derived instead from a verified structural fact in scheduler.finish_task_lifecycle: PASS closes straight to done with no bug_record ever created, FAIL/INCONCLUSIVE always creates one via judge_root_cause. A task counts as approved iff no bug_record(source='validator') exists for it. Baseline is computed by the same function looking back MONITORING_WINDOW_HOURS from cutover_at rather than persisted as a new column; baseline=None (cold start) never flags an anomaly rather than defaulting to 0. Crash-count/consecutive-http-fail windows are approximated by sample count (N = CRASH_WINDOW / MONITOR_SAMPLE_INTERVAL) rather than a true rolling time window -- documented simplification, correct as long as sampling stays roughly interval-paced. Caught my own design misreading via the tests: process_alive's monitoring- phase anomaly condition is MAX_CRASH_COUNT crashes within CRASH_WINDOW (a crash-loop detector), not "currently not alive" -- unlike U-038's health check where any single failure fails closed. A single dead reading during monitoring is recorded but must not trigger an immediate rollback by itself. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
4 changed files
with
471 additions
and
1 deletions
docs/development-plan.md
| ... | ... | @@ -383,6 +383,8 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 |
| 383 | 383 | - **U-040 [Risk Gate]** `c_deploy_supervisor_monitor`:post-cutover 监控窗口内四类信号采样(`process_alive`/`http_health`/`canary_task`/`validator_approval_rate`) |
| 384 | 384 | - 依赖:U-039 |
| 385 | 385 | - Done:`validator_approval_rate` 信号能从 `execution_event`/`work_node_execution` 正确计算滚动窗口内 Validator 的 approve 占比,人为把该占比推到偏离基线阈值之外时能触发 `anomaly_detected` |
| 386 | + - **实现补充(2026-08-02)**:新增 `supervisor/deploy_monitor.py`,`run_monitor_cycle()` 是"一次采样"的最小单元,由 Deploy Supervisor 自己的调度循环反复调用(03-C 原文就是这么写的,不是一次性调用)。**`validator_approval_rate` 的"approve 占比"具体怎么算,01-M/03-C/04-P 都没有字面定义**(`execution_event.event_type` 枚举里根本没有"validator_approved"这类事件)——核对了 `scheduler.finish_task_lifecycle()` 的真实代码后确认:Validator 判 PASS 时直接 `close_task_done()`,从不产生 `bug_record`;判 FAIL/INCONCLUSIVE 时 `org_level_loop.run_fix_loop()` 必然会通过 `root_cause_trace.judge_root_cause()` 产生一条 `bug_record(source='validator')`。所以"这个任务 Validator 是否一次通过"可以从"这个任务有没有对应的 `source='validator'` bug_record"精确推导,不是猜的,是核对真实代码逻辑后得出的结构性事实。基线("与该 deployment 之前的历史基线比较")不额外建表存,直接用同一个函数在 cutover 时间点往前回溯 `MONITORING_WINDOW_HOURS` 小时现算,`baseline_rate=None`(没有足够历史数据)时约定为"不判定异常"而不是当成 0,避免冷启动阶段被误判。崩溃计数/连续失败窗口按 05 §5.9 字面是"时间窗口内计数",这里简化成"按最近 N 次采样计数"(N = CRASH_WINDOW / MONITOR_SAMPLE_INTERVAL),前提是采样节奏基本均匀——这是有意简化,注释里写清楚了,不是漏做。process_alive 信号在监控阶段的语义和 U-038 健康检查阶段不同:健康检查阶段任何一次不活着就直接 fail;监控阶段按设计文档字面"连续崩溃 ≥ MAX_CRASH_COUNT 次才算 anomaly",单次挂掉只记录不触发——第一版测试写反了这条语义(以为单次挂掉就该立刻 anomaly),跑起来才意识到这是"崩溃循环检测"不是"瞬时存活检测",改正后测试反而更准确地锁定了这条容易读错的规则。 | |
| 387 | + - 覆盖:03-C `c_deploy_supervisor_monitor`;01-M `deploy_health_signal`(monitoring 阶段持续写入) | |
| 386 | 388 | |
| 387 | 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` |
| 388 | 390 | - 依赖:U-038, U-040 | ... | ... |
docs/sdd/unit_matrix.json
| ... | ... | @@ -57,7 +57,7 @@ |
| 57 | 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 | 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 | - {"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(见 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 | 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 冲突有明确处理"}, |
| 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 状态机所处状态与最近一次健康信号采样结果"} | ... | ... |
supervisor/deploy_monitor.py
0 → 100644
| 1 | +"""c_deploy_supervisor_monitor (03-C §3.15 / 04-P §4.6 / design doc §6): | |
| 2 | +post-cutover monitoring -- four signals sampled periodically while | |
| 3 | +`deployment.status='monitoring'`, called repeatedly by Deploy Supervisor's | |
| 4 | +own poll loop (U-036's `poll_fn`), not a one-shot call (03-C's own text: | |
| 5 | +"由 Deploy Supervisor 自身的调度循环周期性调用"). | |
| 6 | + | |
| 7 | +`run_monitor_cycle()` is ONE sampling pass: process_alive + http_health + | |
| 8 | +validator_approval_rate every cycle, canary_task only when the caller says | |
| 9 | +`run_canary=True` (real scheduling of "every CANARY_INTERVAL" is the | |
| 10 | +caller's job -- this module just runs whatever it's told to run once, | |
| 11 | +matching `c_deploy_supervisor_health_check`'s own "idempotent, doesn't | |
| 12 | +itself decide cadence" framing). | |
| 13 | + | |
| 14 | +**`validator_approval_rate` operationalization (documented interpretation, | |
| 15 | +not literally spelled out anywhere in 03-C/04-P/design doc -- there's no | |
| 16 | +`event_type='validator_approved'` or similar in 01-M's execution_event enum, | |
| 17 | +so "approved 占比" has to be derived from what IS actually persisted)**: a | |
| 18 | +task counts as Validator-approved if it reached `task_status_changed( | |
| 19 | +to_status='validating')` and NO `bug_record(source='validator')` was ever | |
| 20 | +created for it afterward (Validator's real judgment: PASS -> straight to | |
| 21 | +`close_task_done`, no bug_record at all; FAIL/INCONCLUSIVE -> `org_level_ | |
| 22 | +loop.run_fix_loop` always creates one via `root_cause_trace.judge_root_cause` | |
| 23 | +-> `bug_record.create_bug_record`). This is a real, always-true structural | |
| 24 | +fact about how this codebase's Validator path already works (scheduler.py's | |
| 25 | +`finish_task_lifecycle`), not a guess -- verified against that code before | |
| 26 | +writing this, not merely assumed. | |
| 27 | + | |
| 28 | +**Crash-count / consecutive-fail windows are approximated by sample COUNT, | |
| 29 | +not wall-clock TIME (documented simplification)**: `MAX_CRASH_COUNT`/ | |
| 30 | +`CRASH_WINDOW` and `MAX_CONSECUTIVE_HTTP_FAIL` are defined in 05 §5.9 as | |
| 31 | +count-within-a-time-window, but this module counts among the most recent N | |
| 32 | +persisted `deploy_health_signal` rows (N = CRASH_WINDOW / | |
| 33 | +MONITOR_SAMPLE_INTERVAL) instead of re-deriving a true rolling time window | |
| 34 | +per sample -- correct as long as sampling stays roughly interval-paced (the | |
| 35 | +whole point of a periodic monitor loop); revisit only if sampling cadence | |
| 36 | +ever becomes genuinely irregular. | |
| 37 | +""" | |
| 38 | + | |
| 39 | +import json | |
| 40 | +import time | |
| 41 | +import uuid | |
| 42 | +from datetime import datetime, timezone | |
| 43 | + | |
| 44 | +from supervisor import deploy_health_check, deploy_supervisor | |
| 45 | + | |
| 46 | +MONITOR_SAMPLE_INTERVAL = 5 * 60 | |
| 47 | +CANARY_INTERVAL = 60 * 60 | |
| 48 | +MAX_CRASH_COUNT = 2 | |
| 49 | +CRASH_WINDOW = 10 * 60 | |
| 50 | +MAX_CONSECUTIVE_HTTP_FAIL = 3 | |
| 51 | +VALIDATOR_RATE_DEVIATION_THRESHOLD = 0.30 # fraction; 05 §5.9's "±30 个百分点" | |
| 52 | +MONITORING_WINDOW_HOURS = 24 | |
| 53 | +MONITORING_MIN_TASK_COUNT = 10 | |
| 54 | + | |
| 55 | + | |
| 56 | +def _parse_sqlite_timestamp(text): | |
| 57 | + """Same convention as work_node_run.py's own helper of the same name -- | |
| 58 | + duplicated (not imported) since it's a 2-line stdlib parse and this | |
| 59 | + module has no other reason to depend on work_node_run.py.""" | |
| 60 | + return datetime.strptime(text, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc).timestamp() | |
| 61 | + | |
| 62 | + | |
| 63 | +def _format_sqlite_timestamp(epoch_seconds): | |
| 64 | + return datetime.fromtimestamp(epoch_seconds, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S") | |
| 65 | + | |
| 66 | + | |
| 67 | +def compute_validator_approval_rate(conn, project_id, since_text, until_text=None): | |
| 68 | + """Returns (rate: float in [0,1], sample_size: int), or None if no task | |
| 69 | + reached 'validating' in the window (not enough data to mean anything).""" | |
| 70 | + query = ( | |
| 71 | + "SELECT task_id, payload FROM execution_event " | |
| 72 | + "WHERE project_id = ? AND event_type = 'task_status_changed' AND created_at >= ?" | |
| 73 | + ) | |
| 74 | + params = [project_id, since_text] | |
| 75 | + if until_text is not None: | |
| 76 | + query += " AND created_at < ?" | |
| 77 | + params.append(until_text) | |
| 78 | + rows = conn.execute(query, params).fetchall() | |
| 79 | + | |
| 80 | + task_ids = set() | |
| 81 | + for task_id, payload_json in rows: | |
| 82 | + payload = json.loads(payload_json) | |
| 83 | + if payload.get("to_status") == "validating": | |
| 84 | + task_ids.add(task_id) | |
| 85 | + if not task_ids: | |
| 86 | + return None | |
| 87 | + | |
| 88 | + approved = sum( | |
| 89 | + 1 for task_id in task_ids | |
| 90 | + if conn.execute( | |
| 91 | + "SELECT 1 FROM bug_record WHERE task_id = ? AND source = 'validator' LIMIT 1", (task_id,) | |
| 92 | + ).fetchone() is None | |
| 93 | + ) | |
| 94 | + return approved / len(task_ids), len(task_ids) | |
| 95 | + | |
| 96 | + | |
| 97 | +def compute_baseline_validator_approval_rate(conn, project_id, before_text, lookback_hours=MONITORING_WINDOW_HOURS): | |
| 98 | + """Historical rate over the `lookback_hours` immediately preceding | |
| 99 | + `before_text` (typically `cutover_at`) -- "与该 deployment 之前的历史基线 | |
| 100 | + 比较" (design doc §6), computed from the same persisted data rather than | |
| 101 | + requiring a separately-stored baseline field. Returns None (same as | |
| 102 | + compute_validator_approval_rate) if there's no prior data -- callers | |
| 103 | + should treat a None baseline as "can't judge deviation yet", not as 0.""" | |
| 104 | + since_text = _format_sqlite_timestamp(_parse_sqlite_timestamp(before_text) - lookback_hours * 3600) | |
| 105 | + return compute_validator_approval_rate(conn, project_id, since_text, until_text=before_text) | |
| 106 | + | |
| 107 | + | |
| 108 | +def _record_signal(conn, deployment_id, signal): | |
| 109 | + conn.execute( | |
| 110 | + "INSERT INTO deploy_health_signal (signal_id, deployment_id, sampled_at, signal_type, status, detail) " | |
| 111 | + "VALUES (?, ?, CURRENT_TIMESTAMP, ?, ?, ?)", | |
| 112 | + (str(uuid.uuid4()), deployment_id, signal["signal_type"], signal["status"], signal["detail"]), | |
| 113 | + ) | |
| 114 | + conn.commit() | |
| 115 | + | |
| 116 | + | |
| 117 | +def _recent_statuses(conn, deployment_id, signal_type, limit): | |
| 118 | + rows = conn.execute( | |
| 119 | + "SELECT status FROM deploy_health_signal WHERE deployment_id = ? AND signal_type = ? " | |
| 120 | + "ORDER BY rowid DESC LIMIT ?", | |
| 121 | + (deployment_id, signal_type, limit), | |
| 122 | + ).fetchall() | |
| 123 | + return [r[0] for r in rows] | |
| 124 | + | |
| 125 | + | |
| 126 | +def _sample_process_alive(pid): | |
| 127 | + alive = not deploy_supervisor.reap_if_exited(pid) | |
| 128 | + return {"signal_type": "process_alive", "status": "ok" if alive else "fail", "detail": None} | |
| 129 | + | |
| 130 | + | |
| 131 | +def _sample_validator_approval_rate(conn, project_id, baseline_rate, window_start_text, threshold): | |
| 132 | + if baseline_rate is None: | |
| 133 | + return { | |
| 134 | + "signal_type": "validator_approval_rate", "status": "ok", | |
| 135 | + "detail": "no baseline yet -- insufficient history to judge deviation", | |
| 136 | + } | |
| 137 | + result = compute_validator_approval_rate(conn, project_id, window_start_text) | |
| 138 | + if result is None: | |
| 139 | + return { | |
| 140 | + "signal_type": "validator_approval_rate", "status": "ok", | |
| 141 | + "detail": "no validated tasks in window yet", | |
| 142 | + } | |
| 143 | + rate, sample_size = result | |
| 144 | + deviation = abs(rate - baseline_rate) | |
| 145 | + status = "fail" if deviation > threshold else "ok" | |
| 146 | + return { | |
| 147 | + "signal_type": "validator_approval_rate", "status": status, | |
| 148 | + "detail": "rate={:.2f} n={} baseline={:.2f} deviation={:.2f}".format(rate, sample_size, baseline_rate, deviation), | |
| 149 | + } | |
| 150 | + | |
| 151 | + | |
| 152 | +def _real_task_count_since(conn, project_id, since_text): | |
| 153 | + return conn.execute( | |
| 154 | + "SELECT COUNT(*) FROM task WHERE project_id = ? AND created_at >= ?", (project_id, since_text) | |
| 155 | + ).fetchone()[0] | |
| 156 | + | |
| 157 | + | |
| 158 | +def run_monitor_cycle( | |
| 159 | + conn, deployment_id, pid, port, project_id, baseline_validator_rate, window_start_text, | |
| 160 | + run_canary=True, crash_window_samples=None, consecutive_http_fail_limit=MAX_CONSECUTIVE_HTTP_FAIL, | |
| 161 | + validator_threshold=VALIDATOR_RATE_DEVIATION_THRESHOLD, min_task_count=MONITORING_MIN_TASK_COUNT, | |
| 162 | + window_hours=MONITORING_WINDOW_HOURS, now=time.time, | |
| 163 | +): | |
| 164 | + """One sampling pass. Returns {status, latest_signal}. `window_start_text` | |
| 165 | + is `deployment.cutover_at` (a sqlite TEXT timestamp) -- used both for | |
| 166 | + the elapsed-time-since-cutover math and as the SQL lower bound for | |
| 167 | + "real tasks since the window opened"/"validator judgments since the | |
| 168 | + window opened". Stops sampling further signals the moment one comes | |
| 169 | + back fail (same fail-closed, don't-pile-on-more-checks pattern as | |
| 170 | + `c_deploy_supervisor_health_check`) and transitions | |
| 171 | + `deployment.status='anomaly_detected'` immediately -- 04-P's own state | |
| 172 | + machine has no intermediate "still checking" state distinct from | |
| 173 | + 'monitoring' itself. | |
| 174 | + """ | |
| 175 | + if crash_window_samples is None: | |
| 176 | + crash_window_samples = max(1, CRASH_WINDOW // MONITOR_SAMPLE_INTERVAL) | |
| 177 | + | |
| 178 | + process_signal = _sample_process_alive(pid) | |
| 179 | + _record_signal(conn, deployment_id, process_signal) | |
| 180 | + latest_signal = process_signal | |
| 181 | + anomaly = _recent_statuses(conn, deployment_id, "process_alive", crash_window_samples).count("fail") >= MAX_CRASH_COUNT | |
| 182 | + | |
| 183 | + if not anomaly: | |
| 184 | + http_signal = deploy_health_check.check_http_health(port) | |
| 185 | + _record_signal(conn, deployment_id, http_signal) | |
| 186 | + latest_signal = http_signal | |
| 187 | + recent_http = _recent_statuses(conn, deployment_id, "http_health", consecutive_http_fail_limit) | |
| 188 | + anomaly = len(recent_http) >= consecutive_http_fail_limit and all(s == "fail" for s in recent_http) | |
| 189 | + | |
| 190 | + if not anomaly and run_canary: | |
| 191 | + canary_signal = deploy_health_check.check_canary_task(port) | |
| 192 | + _record_signal(conn, deployment_id, canary_signal) | |
| 193 | + latest_signal = canary_signal | |
| 194 | + anomaly = canary_signal["status"] == "fail" # single failure -- no grey area (design doc §6) | |
| 195 | + | |
| 196 | + if not anomaly: | |
| 197 | + rate_signal = _sample_validator_approval_rate( | |
| 198 | + conn, project_id, baseline_validator_rate, window_start_text, validator_threshold | |
| 199 | + ) | |
| 200 | + _record_signal(conn, deployment_id, rate_signal) | |
| 201 | + latest_signal = rate_signal | |
| 202 | + anomaly = rate_signal["status"] == "fail" | |
| 203 | + | |
| 204 | + if anomaly: | |
| 205 | + conn.execute( | |
| 206 | + "UPDATE deployment SET status = 'anomaly_detected', rollback_reason = ? WHERE deployment_id = ?", | |
| 207 | + (json.dumps(latest_signal), deployment_id), | |
| 208 | + ) | |
| 209 | + conn.commit() | |
| 210 | + return {"status": "anomaly_detected", "latest_signal": latest_signal} | |
| 211 | + | |
| 212 | + elapsed_hours = (now() - _parse_sqlite_timestamp(window_start_text)) / 3600.0 | |
| 213 | + real_task_count = _real_task_count_since(conn, project_id, window_start_text) | |
| 214 | + if elapsed_hours >= window_hours or real_task_count >= min_task_count: | |
| 215 | + conn.execute( | |
| 216 | + "UPDATE deployment SET status = 'monitoring_window_clean', monitoring_closed_at = CURRENT_TIMESTAMP " | |
| 217 | + "WHERE deployment_id = ?", | |
| 218 | + (deployment_id,), | |
| 219 | + ) | |
| 220 | + conn.commit() | |
| 221 | + return {"status": "monitoring_window_clean", "latest_signal": latest_signal} | |
| 222 | + | |
| 223 | + conn.execute("UPDATE deployment SET status = 'monitoring' WHERE deployment_id = ?", (deployment_id,)) | |
| 224 | + conn.commit() | |
| 225 | + return {"status": "monitoring", "latest_signal": latest_signal} | ... | ... |
supervisor/test_deploy_monitor.py
0 → 100644
| 1 | +"""U-040 tests: c_deploy_supervisor_monitor. Real HTTP server for http_health/ | |
| 2 | +canary_task signals (same pattern as test_deploy_health_check.py), real | |
| 3 | +sqlite for the validator_approval_rate derivation and window-closure logic. | |
| 4 | + | |
| 5 | +Run: python3 -m unittest discover -s supervisor -p "test_*.py" -v | |
| 6 | +""" | |
| 7 | + | |
| 8 | +import json | |
| 9 | +import os | |
| 10 | +import sys | |
| 11 | +import threading | |
| 12 | +import unittest | |
| 13 | +import uuid | |
| 14 | + | |
| 15 | +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) | |
| 16 | + | |
| 17 | +from supervisor import deploy_monitor as dm | |
| 18 | +from src import backend_server, schema | |
| 19 | + | |
| 20 | + | |
| 21 | +def _ensure_task(conn, project_id, task_id): | |
| 22 | + if conn.execute("SELECT 1 FROM task WHERE task_id = ?", (task_id,)).fetchone() is None: | |
| 23 | + conn.execute( | |
| 24 | + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) " | |
| 25 | + "VALUES (?, ?, 'g', 'goal', 'validating', 'validator')", | |
| 26 | + (task_id, project_id), | |
| 27 | + ) | |
| 28 | + conn.commit() | |
| 29 | + | |
| 30 | + | |
| 31 | +def _status_event(conn, project_id, task_id, to_status, created_at=None): | |
| 32 | + _ensure_task(conn, project_id, task_id) | |
| 33 | + conn.execute( | |
| 34 | + "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload, created_at) " | |
| 35 | + "VALUES (?, ?, ?, 'task_status_changed', ?, COALESCE(?, CURRENT_TIMESTAMP))", | |
| 36 | + (str(uuid.uuid4()), project_id, task_id, json.dumps({"to_status": to_status}), created_at), | |
| 37 | + ) | |
| 38 | + conn.commit() | |
| 39 | + | |
| 40 | + | |
| 41 | +def _validator_bug(conn, task_id): | |
| 42 | + conn.execute( | |
| 43 | + "INSERT INTO bug_record (bug_id, task_id, source, description, root_cause_category, status) " | |
| 44 | + "VALUES (?, ?, 'validator', 'x', 'coder', 'routed')", | |
| 45 | + (str(uuid.uuid4()), task_id), | |
| 46 | + ) | |
| 47 | + conn.commit() | |
| 48 | + | |
| 49 | + | |
| 50 | +class ValidatorApprovalRateTestBase(unittest.TestCase): | |
| 51 | + def setUp(self): | |
| 52 | + self.conn = schema.connect(":memory:") | |
| 53 | + schema.create_schema(self.conn) | |
| 54 | + self.conn.execute( | |
| 55 | + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) " | |
| 56 | + "VALUES ('proj-1', 'P', '/tmp/proj-1', 1, 'active')" | |
| 57 | + ) | |
| 58 | + self.conn.commit() | |
| 59 | + self.addCleanup(self.conn.close) | |
| 60 | + | |
| 61 | + | |
| 62 | +class ComputeValidatorApprovalRateTest(ValidatorApprovalRateTestBase): | |
| 63 | + def test_no_validating_events_returns_none(self): | |
| 64 | + self.assertIsNone(dm.compute_validator_approval_rate(self.conn, "proj-1", "2000-01-01 00:00:00")) | |
| 65 | + | |
| 66 | + def test_all_approved_gives_rate_1(self): | |
| 67 | + _status_event(self.conn, "proj-1", "t1", "validating") | |
| 68 | + _status_event(self.conn, "proj-1", "t2", "validating") | |
| 69 | + rate, n = dm.compute_validator_approval_rate(self.conn, "proj-1", "2000-01-01 00:00:00") | |
| 70 | + self.assertEqual((rate, n), (1.0, 2)) | |
| 71 | + | |
| 72 | + def test_bug_record_marks_task_as_not_approved(self): | |
| 73 | + _status_event(self.conn, "proj-1", "t1", "validating") | |
| 74 | + _status_event(self.conn, "proj-1", "t2", "validating") | |
| 75 | + _validator_bug(self.conn, "t1") | |
| 76 | + rate, n = dm.compute_validator_approval_rate(self.conn, "proj-1", "2000-01-01 00:00:00") | |
| 77 | + self.assertEqual((rate, n), (0.5, 2)) | |
| 78 | + | |
| 79 | + def test_non_validator_bug_record_does_not_count_against_approval(self): | |
| 80 | + _status_event(self.conn, "proj-1", "t1", "validating") | |
| 81 | + self.conn.execute( | |
| 82 | + "INSERT INTO bug_record (bug_id, task_id, source, description, status) " | |
| 83 | + "VALUES ('b1', 't1', 'human', 'x', 'open')" | |
| 84 | + ) | |
| 85 | + self.conn.commit() | |
| 86 | + rate, n = dm.compute_validator_approval_rate(self.conn, "proj-1", "2000-01-01 00:00:00") | |
| 87 | + self.assertEqual((rate, n), (1.0, 1)) | |
| 88 | + | |
| 89 | + def test_events_before_since_are_excluded(self): | |
| 90 | + _status_event(self.conn, "proj-1", "t1", "validating", created_at="2000-01-01 00:00:00") | |
| 91 | + result = dm.compute_validator_approval_rate(self.conn, "proj-1", "2020-01-01 00:00:00") | |
| 92 | + self.assertIsNone(result) | |
| 93 | + | |
| 94 | + | |
| 95 | +class ComputeBaselineTest(ValidatorApprovalRateTestBase): | |
| 96 | + def test_baseline_looks_back_from_before_text(self): | |
| 97 | + _status_event(self.conn, "proj-1", "t-old", "validating", created_at="2026-01-01 00:00:00") | |
| 98 | + _status_event(self.conn, "proj-1", "t-too-old", "validating", created_at="2020-01-01 00:00:00") | |
| 99 | + rate, n = dm.compute_baseline_validator_approval_rate( | |
| 100 | + self.conn, "proj-1", "2026-01-02 00:00:00", lookback_hours=24 | |
| 101 | + ) | |
| 102 | + self.assertEqual(n, 1) # only t-old falls in the 24h lookback window | |
| 103 | + | |
| 104 | + | |
| 105 | +class RealServerTestBase(unittest.TestCase): | |
| 106 | + def setUp(self): | |
| 107 | + self.server = backend_server.create_server(db_path=":memory:") | |
| 108 | + self.port = self.server.server_address[1] | |
| 109 | + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) | |
| 110 | + self.thread.start() | |
| 111 | + | |
| 112 | + def tearDown(self): | |
| 113 | + self.server.shutdown() | |
| 114 | + self.server.server_close() | |
| 115 | + self.thread.join(timeout=5) | |
| 116 | + | |
| 117 | + | |
| 118 | +class RunMonitorCycleTest(RealServerTestBase): | |
| 119 | + def setUp(self): | |
| 120 | + super().setUp() | |
| 121 | + self.conn = schema.connect(":memory:") | |
| 122 | + schema.create_schema(self.conn) | |
| 123 | + self.conn.execute( | |
| 124 | + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) " | |
| 125 | + "VALUES ('proj-1', 'P', '/tmp/proj-1', 1, 'active')" | |
| 126 | + ) | |
| 127 | + self.conn.execute( | |
| 128 | + "INSERT INTO deployment (deployment_id, git_commit_hash, previous_commit_hash, status, " | |
| 129 | + "previous_instance_dir, cutover_at) " | |
| 130 | + "VALUES ('dep-1', 'c1', 'c0', 'monitoring', '/prev', '2020-01-01 00:00:00')" | |
| 131 | + ) | |
| 132 | + self.conn.commit() | |
| 133 | + self.addCleanup(self.conn.close) | |
| 134 | + | |
| 135 | + def test_all_clear_and_window_not_yet_closed_stays_monitoring(self): | |
| 136 | + result = dm.run_monitor_cycle( | |
| 137 | + self.conn, "dep-1", os.getpid(), self.port, "proj-1", | |
| 138 | + baseline_validator_rate=None, window_start_text="2099-01-01 00:00:00", # future -> 0 elapsed hours | |
| 139 | + now=lambda: dm._parse_sqlite_timestamp("2099-01-01 00:00:00") + 60, | |
| 140 | + ) | |
| 141 | + self.assertEqual(result["status"], "monitoring") | |
| 142 | + row = self.conn.execute("SELECT status FROM deployment WHERE deployment_id = 'dep-1'").fetchone() | |
| 143 | + self.assertEqual(row[0], "monitoring") | |
| 144 | + | |
| 145 | + def test_window_closes_via_elapsed_hours(self): | |
| 146 | + result = dm.run_monitor_cycle( | |
| 147 | + self.conn, "dep-1", os.getpid(), self.port, "proj-1", | |
| 148 | + baseline_validator_rate=None, window_start_text="2020-01-01 00:00:00", | |
| 149 | + now=lambda: dm._parse_sqlite_timestamp("2020-01-01 00:00:00") + 25 * 3600, | |
| 150 | + window_hours=24, | |
| 151 | + ) | |
| 152 | + self.assertEqual(result["status"], "monitoring_window_clean") | |
| 153 | + row = self.conn.execute( | |
| 154 | + "SELECT status, monitoring_closed_at FROM deployment WHERE deployment_id = 'dep-1'" | |
| 155 | + ).fetchone() | |
| 156 | + self.assertEqual(row[0], "monitoring_window_clean") | |
| 157 | + self.assertIsNotNone(row[1]) | |
| 158 | + | |
| 159 | + def test_window_closes_via_real_task_count(self): | |
| 160 | + for i in range(10): | |
| 161 | + self.conn.execute( | |
| 162 | + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) " | |
| 163 | + "VALUES (?, 'proj-1', 'g', 'goal', 'queued', 'planner')", ("t{}".format(i),) | |
| 164 | + ) | |
| 165 | + self.conn.commit() | |
| 166 | + result = dm.run_monitor_cycle( | |
| 167 | + self.conn, "dep-1", os.getpid(), self.port, "proj-1", | |
| 168 | + baseline_validator_rate=None, window_start_text="2020-01-01 00:00:00", | |
| 169 | + now=lambda: dm._parse_sqlite_timestamp("2020-01-01 00:00:00") + 60, | |
| 170 | + window_hours=999999, min_task_count=10, | |
| 171 | + ) | |
| 172 | + self.assertEqual(result["status"], "monitoring_window_clean") | |
| 173 | + | |
| 174 | + def test_single_dead_process_reading_alone_does_not_yet_anomaly(self): | |
| 175 | + # design doc §6: process_alive's anomaly condition is MAX_CRASH_COUNT | |
| 176 | + # crashes within CRASH_WINDOW, not a single dead reading -- a lone | |
| 177 | + # blip records the signal but must NOT immediately roll back. | |
| 178 | + result = dm.run_monitor_cycle( | |
| 179 | + self.conn, "dep-1", 2**30, self.port, "proj-1", | |
| 180 | + baseline_validator_rate=None, window_start_text="2020-01-01 00:00:00", | |
| 181 | + window_hours=999999, | |
| 182 | + ) | |
| 183 | + self.assertNotEqual(result["status"], "anomaly_detected") | |
| 184 | + signal_row = self.conn.execute( | |
| 185 | + "SELECT status FROM deploy_health_signal WHERE deployment_id = 'dep-1' AND signal_type = 'process_alive'" | |
| 186 | + ).fetchone() | |
| 187 | + self.assertEqual(signal_row[0], "fail") | |
| 188 | + | |
| 189 | + def test_repeated_process_crashes_within_window_trigger_anomaly(self): | |
| 190 | + # Pre-seed one prior process_alive fail -- this cycle's fail makes 2, | |
| 191 | + # meeting MAX_CRASH_COUNT=2 even though the process IS alive right now. | |
| 192 | + self.conn.execute( | |
| 193 | + "INSERT INTO deploy_health_signal (signal_id, deployment_id, sampled_at, signal_type, status) " | |
| 194 | + "VALUES (?, 'dep-1', CURRENT_TIMESTAMP, 'process_alive', 'fail')", (str(uuid.uuid4()),) | |
| 195 | + ) | |
| 196 | + self.conn.commit() | |
| 197 | + result = dm.run_monitor_cycle( | |
| 198 | + self.conn, "dep-1", 2**30, self.port, "proj-1", | |
| 199 | + baseline_validator_rate=None, window_start_text="2020-01-01 00:00:00", | |
| 200 | + crash_window_samples=5, | |
| 201 | + ) | |
| 202 | + self.assertEqual(result["status"], "anomaly_detected") | |
| 203 | + | |
| 204 | + def test_consecutive_http_failures_trigger_anomaly(self): | |
| 205 | + for _ in range(3): | |
| 206 | + self.conn.execute( | |
| 207 | + "INSERT INTO deploy_health_signal (signal_id, deployment_id, sampled_at, signal_type, status) " | |
| 208 | + "VALUES (?, 'dep-1', CURRENT_TIMESTAMP, 'http_health', 'fail')", (str(uuid.uuid4()),) | |
| 209 | + ) | |
| 210 | + self.conn.commit() | |
| 211 | + self.server.conn.close() # this cycle's own http_health check also fails | |
| 212 | + result = dm.run_monitor_cycle( | |
| 213 | + self.conn, "dep-1", os.getpid(), self.port, "proj-1", | |
| 214 | + baseline_validator_rate=None, window_start_text="2020-01-01 00:00:00", | |
| 215 | + consecutive_http_fail_limit=4, | |
| 216 | + ) | |
| 217 | + self.assertEqual(result["status"], "anomaly_detected") | |
| 218 | + self.assertEqual(result["latest_signal"]["signal_type"], "http_health") | |
| 219 | + | |
| 220 | + def test_validator_rate_deviation_triggers_anomaly(self): | |
| 221 | + for i in range(4): | |
| 222 | + _status_event(self.conn, "proj-1", "t{}".format(i), "validating") | |
| 223 | + _validator_bug(self.conn, "t{}".format(i)) # 0% approval this window | |
| 224 | + result = dm.run_monitor_cycle( | |
| 225 | + self.conn, "dep-1", os.getpid(), self.port, "proj-1", | |
| 226 | + baseline_validator_rate=0.9, window_start_text="2020-01-01 00:00:00", | |
| 227 | + ) | |
| 228 | + self.assertEqual(result["status"], "anomaly_detected") | |
| 229 | + self.assertEqual(result["latest_signal"]["signal_type"], "validator_approval_rate") | |
| 230 | + | |
| 231 | + def test_no_baseline_yet_never_flags_validator_anomaly(self): | |
| 232 | + for i in range(4): | |
| 233 | + _status_event(self.conn, "proj-1", "t{}".format(i), "validating") | |
| 234 | + _validator_bug(self.conn, "t{}".format(i)) | |
| 235 | + result = dm.run_monitor_cycle( | |
| 236 | + self.conn, "dep-1", os.getpid(), self.port, "proj-1", | |
| 237 | + baseline_validator_rate=None, window_start_text="2020-01-01 00:00:00", | |
| 238 | + ) | |
| 239 | + self.assertNotEqual(result["status"], "anomaly_detected") | |
| 240 | + | |
| 241 | + | |
| 242 | +if __name__ == "__main__": | |
| 243 | + unittest.main() | ... | ... |