Commit 98f0d5cd59931e23fbcb6bff5ae3093ae9ae5e44

Authored by bboonnee
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>
docs/development-plan.md
@@ -383,6 +383,8 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 @@ -383,6 +383,8 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。
383 - **U-040 [Risk Gate]** `c_deploy_supervisor_monitor`:post-cutover 监控窗口内四类信号采样(`process_alive`/`http_health`/`canary_task`/`validator_approval_rate`) 383 - **U-040 [Risk Gate]** `c_deploy_supervisor_monitor`:post-cutover 监控窗口内四类信号采样(`process_alive`/`http_health`/`canary_task`/`validator_approval_rate`)
384 - 依赖:U-039 384 - 依赖:U-039
385 - Done:`validator_approval_rate` 信号能从 `execution_event`/`work_node_execution` 正确计算滚动窗口内 Validator 的 approve 占比,人为把该占比推到偏离基线阈值之外时能触发 `anomaly_detected` 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 - **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` 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 - 依赖:U-038, U-040 390 - 依赖:U-038, U-040
docs/sdd/unit_matrix.json
@@ -57,7 +57,7 @@ @@ -57,7 +57,7 @@
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(见 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,不只是进程存活)"}, 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(见 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())"}, 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 {"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 冲突有明确处理"},
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 用例默认跳过,需显式环境变量开启"}, 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 {"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 状态机所处状态与最近一次健康信号采样结果"} 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()