Files
ai-exchange/scripts/run_monitoring_alert_rollout.py
T

1333 lines
54 KiB
Python

from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
import json
import os
import subprocess
import sys
from typing import Optional
from urllib.parse import quote
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
STATUS_PATH = ROOT / ".claude" / "monitoring_alert_rollout_status.json"
DOC_PATH = ROOT / "docs" / "archive" / "AI_ICT_下一阶段开发任务清单_v1.md"
SESSION_PATH = ROOT / "SESSION.md"
TASKS_PATH = ROOT / "TASKS.md"
ENV_LOCAL_PATH = ROOT / ".env.local"
ENV_PATH = ROOT / ".env"
ENV_EXAMPLE_PATH = ROOT / ".env.example"
DEFAULT_TIMEOUT_SECONDS = 900
def _venv_python_command() -> str:
windows = ROOT / ".venv" / "Scripts" / "python.exe"
unix = ROOT / ".venv" / "bin" / "python"
if windows.exists():
return '"/d/ai-exchange/.venv/Scripts/python.exe"'
if unix.exists():
return ".venv/bin/python"
return "python"
PYTHON_CMD = _venv_python_command()
def _cmd(script_name: str) -> str:
return f"{PYTHON_CMD} scripts/{script_name}"
def _dedupe(items: list[str]) -> list[str]:
seen = set()
out = []
for item in items:
if item in seen:
continue
seen.add(item)
out.append(item)
return out
def _truncate_output(text: str, limit: int = 240) -> str:
cleaned = str(text or "").strip().replace("\r", " ").replace("\n", " ")
return cleaned[:limit]
def _get_bool(env, key: str, default: bool = False) -> bool:
value = env.get(key)
if value is None or value == "":
return default
return str(value).strip().lower() in {"1", "true", "yes", "on"}
def _get_int(env, key: str, default: int) -> int:
value = env.get(key)
if value is None or value == "":
return default
try:
return int(str(value).strip())
except ValueError:
return default
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _read_env_file_value(path: Path, key: str) -> Optional[str]:
if not path.exists():
return None
prefix = f"{key}="
for line in path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if stripped.startswith(prefix):
return stripped[len(prefix):].strip()
return None
def _compose_database_url_from_env(env: dict) -> Optional[str]:
explicit = str(env.get("DATABASE_URL") or "").strip()
if explicit:
return explicit
host = str(env.get("DATABASE_HOST") or "").strip()
port = str(env.get("DATABASE_PORT") or "").strip()
name = str(env.get("DATABASE_NAME") or "").strip()
user = str(env.get("DATABASE_USER") or "").strip()
password = str(env.get("DATABASE_PASSWORD") or "")
if not all([host, port, name, user]):
return None
return f"postgresql+psycopg://{quote(user, safe='')}:{quote(password, safe='')}@{host}:{port}/{name}"
def _merge_env_file_values(env: dict, path: Path, keys: list[str]) -> dict:
merged = dict(env)
for key in keys:
if merged.get(key):
continue
value = _read_env_file_value(path, key)
if value is not None:
merged[key] = value
return merged
def _seed_database_env(env: dict) -> dict:
seeded = dict(env or {})
component_keys = ["DATABASE_HOST", "DATABASE_PORT", "DATABASE_NAME", "DATABASE_USER", "DATABASE_PASSWORD"]
seeded = _merge_env_file_values(seeded, ENV_LOCAL_PATH, component_keys + ["DATABASE_URL"])
local_composed = _compose_database_url_from_env(seeded)
if local_composed:
seeded["DATABASE_URL"] = local_composed
return seeded
seeded = _merge_env_file_values(seeded, ENV_PATH, component_keys + ["DATABASE_URL"])
env_composed = _compose_database_url_from_env(seeded)
if env_composed:
seeded["DATABASE_URL"] = env_composed
return seeded
seeded = _merge_env_file_values(seeded, ENV_EXAMPLE_PATH, ["DATABASE_URL"])
fallback = _compose_database_url_from_env(seeded)
if fallback:
seeded["DATABASE_URL"] = fallback
return seeded
N1_VALIDATION_COMMANDS = [
_cmd("run_launch_readiness.py"),
_cmd("run_live_readiness.py"),
_cmd("run_review_packet.py"),
_cmd("run_trading_runtime.py"),
_cmd("run_offline_checks.py"),
]
N2_VALIDATION_COMMANDS = [
_cmd("run_decision_packet.py"),
_cmd("run_alert_dispatcher.py"),
_cmd("run_trading_runtime.py"),
_cmd("run_offline_checks.py"),
]
N3_VALIDATION_COMMANDS = [
_cmd("run_decision_packet.py"),
_cmd("run_trading_runtime.py"),
_cmd("run_review_packet.py"),
_cmd("run_offline_checks.py"),
]
N4_VALIDATION_COMMANDS = [
_cmd("run_trading_runtime.py"),
_cmd("run_review_packet.py"),
_cmd("run_offline_checks.py"),
]
PHASES = [
{
"code": "N1",
"title": "Launch Readiness 收尾",
"goal": "收敛 assisted-trading readiness 剩余 blocker 与 warning,并硬化 readiness 展示。",
"tasks": [
"N1-1 明确 launch readiness 判定口径",
"N1-2 收敛 trust / live / real-trade 样本 warning",
"N1-3 workbench / API 展示硬化",
],
"files": [
"src/services/readiness/launch_readiness_service.py",
"src/config/settings.py",
"src/api/server.py",
"src/api/trading_runtime.py",
"src/web/app.js",
"src/web/index.html",
"SESSION.md",
"TASKS.md",
],
"validation_commands": N1_VALIDATION_COMMANDS,
"deliverables": [
"clear assisted-vs-real-live readiness boundary",
"reduced launch blockers and warnings",
"actionable next_actions output",
"hardened launch readiness workbench panel",
],
},
{
"code": "N2",
"title": "最小主动消息告警闭环",
"goal": "把当前仅落库的 decision alerts 打通到真实外部消息通道。",
"tasks": [
"N2-1 新增 alert delivery 持久化",
"N2-2 新增 notifier 基础设施",
"N2-3 新增 AlertDispatcherService",
"N2-4 API / CLI / runtime 接入",
],
"files": [
"migrations/*_create_alert_deliveries.sql",
"src/repositories/alert_delivery_repository.py",
"src/services/notifications/__init__.py",
"src/services/notifications/telegram_notifier.py",
"src/services/notifications/webhook_notifier.py",
"src/services/alerts/__init__.py",
"src/services/alerts/alert_dispatcher_service.py",
"src/services/alerts/message_template_service.py",
"src/services/alerts/channel_router_service.py",
"src/services/alerts/delivery_policy_service.py",
"src/api/alerts.py",
"src/api/server.py",
"src/services/runtime/trading_runtime_service.py",
"scripts/run_alert_dispatcher.py",
],
"validation_commands": N2_VALIDATION_COMMANDS,
"deliverables": [
"alert_deliveries persistence",
"single-channel notifier",
"queued-to-delivered alert dispatcher",
"dispatch API and CLI",
"runtime alert_dispatch task",
],
},
{
"code": "N3",
"title": "告警质量与 runtime 硬化",
"goal": "在外发闭环接通后治理噪声、分级状态并统一决策包展示。",
"tasks": [
"N3-1 告警节流与重复抑制",
"N3-2 runtime blockers 分级",
"N3-3 决策包展示一致性硬化",
],
"files": [
"src/services/alerts/delivery_policy_service.py",
"src/services/decision/decision_packet_service.py",
"src/services/runtime/trading_runtime_service.py",
"src/services/readiness/launch_readiness_service.py",
"src/api/trading_runtime.py",
"src/web/app.js",
],
"validation_commands": N3_VALIDATION_COMMANDS,
"deliverables": [
"deduped and throttled alerts",
"graded runtime status semantics",
"consistent packet wording across API, UI, notifier",
],
},
{
"code": "N4",
"title": "视觉盯盘增强预埋",
"goal": "只固化视觉层模型与扩展位,不全面实现截图与视觉复核主线。",
"tasks": [
"N4-1 固化视觉数据模型设计",
"N4-2 仅做代码预留点",
],
"files": [
"docs/archive/AI_ICT_盯盘告警架构图_v1.md",
"docs/archive/AI_ICT_盯盘告警技术设计稿_v1.md",
"docs/archive/AI_ICT_盯盘告警开发任务清单_v1.md",
"src/services/decision/decision_packet_service.py",
"src/services/runtime/trading_runtime_service.py",
],
"validation_commands": N4_VALIDATION_COMMANDS,
"deliverables": [
"stable visual trigger taxonomy",
"reserved visual payload fields",
"reserved runtime extension points",
],
},
]
MAJOR_BLOCKERS = {
"runtime_env_missing": [
"validationerror",
"field required",
"database_url",
"database url",
"input_value={}"
],
"missing_credentials": ["missing_credentials", "缺少凭据", "token", "chat_id", "webhook"],
"external_channel_auth_required": ["401", "403", "forbidden", "unauthorized", "auth"],
"unsafe_migration_failure": ["migration", "sql", "alembic"],
"vendor_or_network_validation_blocked": ["network", "timed out", "connection", "proxy", "dns", "429", "vendor"],
"live_trading_boundary_unclear": ["live trading", "real live", "submit strategy", "boundary"],
}
def build_rollout_plan() -> dict:
return {
"generated_ts": _now_iso(),
"doc_path": str(DOC_PATH),
"major_blockers": list(MAJOR_BLOCKERS.keys()),
"phases": PHASES,
"automation_policy": {
"default_behavior": "run validations and advance when phase passes",
"pause_only_for": list(MAJOR_BLOCKERS.keys()),
"run_mode": "run-until-blocked",
"repair_mode": "repair-loop",
"bridge_mode": "repair-bridge",
},
}
def read_status() -> dict:
if not STATUS_PATH.exists():
return {
"state": "not_started",
"updated_ts": _now_iso(),
"current_phase": PHASES[0]["code"],
"completed_phases": [],
"blocked_reason": None,
"note": "Rollout has not started yet.",
"doc_path": str(DOC_PATH),
"phases": PHASES,
"history": [],
"last_run": None,
"repair_loop": None,
}
payload = json.loads(STATUS_PATH.read_text(encoding="utf-8"))
payload.setdefault("doc_path", str(DOC_PATH))
payload.setdefault("phases", PHASES)
payload.setdefault("history", [])
payload.setdefault("last_run", None)
payload.setdefault("repair_loop", None)
return payload
def write_status(payload: dict) -> None:
STATUS_PATH.parent.mkdir(parents=True, exist_ok=True)
STATUS_PATH.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
def start_rollout() -> dict:
payload = {
"state": "running",
"started_ts": _now_iso(),
"updated_ts": _now_iso(),
"current_phase": PHASES[0]["code"],
"completed_phases": [],
"blocked_reason": None,
"note": "Autonomous rollout started. Continue until a major blocker is recorded.",
"phases": PHASES,
"doc_path": str(DOC_PATH),
"history": [],
"last_run": None,
"repair_loop": None,
}
write_status(payload)
return payload
def _phase_by_code(code: str) -> Optional[dict]:
for phase in PHASES:
if phase["code"] == code:
return phase
return None
def _next_phase_code(completed: list[str]) -> Optional[str]:
for phase in PHASES:
if phase["code"] not in completed:
return phase["code"]
return None
def advance_rollout() -> dict:
status = read_status()
current = status.get("current_phase")
if current is None:
return status
completed = list(status.get("completed_phases") or [])
if current not in completed:
completed.append(current)
next_phase = _next_phase_code(completed)
history = list(status.get("history") or [])
history.append({"ts": _now_iso(), "event": "advance", "from": current, "to": next_phase})
status["completed_phases"] = completed
status["current_phase"] = next_phase
status["updated_ts"] = _now_iso()
status["state"] = "completed" if next_phase is None else "running"
status["blocked_reason"] = None
status["history"] = history[-100:]
status["note"] = "All rollout phases are completed." if next_phase is None else f"Advanced to {next_phase}."
write_status(status)
return status
def block_rollout(*, blocker: str, note: str) -> dict:
status = read_status()
history = list(status.get("history") or [])
history.append({"ts": _now_iso(), "event": "blocked", "phase": status.get("current_phase"), "blocker": blocker, "note": note})
status["state"] = "blocked"
status["blocked_reason"] = blocker
status["updated_ts"] = _now_iso()
status["note"] = note or "Rollout paused because a major blocker was recorded."
status["history"] = history[-100:]
write_status(status)
return status
def complete_rollout() -> dict:
status = read_status()
history = list(status.get("history") or [])
history.append({"ts": _now_iso(), "event": "complete"})
status["state"] = "completed"
status["current_phase"] = None
status["completed_phases"] = [phase["code"] for phase in PHASES]
status["blocked_reason"] = None
status["updated_ts"] = _now_iso()
status["note"] = "Rollout marked completed."
status["history"] = history[-100:]
write_status(status)
return status
def _run_command(*, command: str, timeout_seconds: int, env: Optional[dict] = None) -> dict:
started = _now_iso()
runtime_env = dict(os.environ)
if env is not None:
runtime_env.update(env)
try:
completed = subprocess.run(
["bash", "-lc", command],
cwd=str(ROOT),
env=runtime_env,
capture_output=True,
text=True,
timeout=timeout_seconds,
encoding="utf-8",
errors="replace",
)
return {
"command": command,
"started_ts": started,
"finished_ts": _now_iso(),
"returncode": int(completed.returncode),
"stdout": str(completed.stdout or "")[-8000:],
"stderr": str(completed.stderr or "")[-8000:],
}
except subprocess.TimeoutExpired as exc:
return {
"command": command,
"started_ts": started,
"finished_ts": _now_iso(),
"returncode": 124,
"stdout": str(exc.stdout or "")[-8000:],
"stderr": str(exc.stderr or "")[-8000:],
}
def _combined_output(run: dict) -> str:
return f"{run.get('stdout', '')}\n{run.get('stderr', '')}"
def _detect_major_blocker(run: dict) -> Optional[str]:
haystack = _combined_output(run).lower()
if "adapter_real_live_not_ready" in haystack or "live readiness: false" in haystack:
return None
if "validationerror" in haystack and "database_url" in haystack and "field required" in haystack:
return "runtime_env_missing"
for code, patterns in MAJOR_BLOCKERS.items():
if any(pattern.lower() in haystack for pattern in patterns):
return code
return None
def _seed_rollout_env(env: dict) -> dict:
seeded = dict(env or {})
override_pairs = [
("MONITORING_ALERT_DATABASE_URL", "DATABASE_URL"),
("MONITORING_ALERT_DATABASE_HOST", "DATABASE_HOST"),
("MONITORING_ALERT_DATABASE_PORT", "DATABASE_PORT"),
("MONITORING_ALERT_DATABASE_NAME", "DATABASE_NAME"),
("MONITORING_ALERT_DATABASE_USER", "DATABASE_USER"),
("MONITORING_ALERT_DATABASE_PASSWORD", "DATABASE_PASSWORD"),
("MONITORING_ALERT_BROKER_READ_PROXY_URL", "BROKER_READ_PROXY_URL"),
("MONITORING_ALERT_OKX_API_KEY", "OKX_API_KEY"),
("MONITORING_ALERT_OKX_API_SECRET", "OKX_API_SECRET"),
("MONITORING_ALERT_OKX_API_PASSPHRASE", "OKX_API_PASSPHRASE"),
]
for source_key, target_key in override_pairs:
if seeded.get(source_key):
seeded[target_key] = seeded[source_key]
seeded = _seed_database_env(seeded)
return seeded
def _resolve_rollout_env(env: Optional[dict]) -> dict:
base = dict(os.environ)
if env is not None:
base.update(env)
return _seed_rollout_env(base)
def _is_runtime_env_missing_result(phase_result: dict) -> bool:
return str(phase_result.get("blocker") or "") == "runtime_env_missing"
def _retry_phase_with_seeded_env(*, phase: dict, phase_result: dict, timeout_seconds: int, env: dict) -> dict:
if not _is_runtime_env_missing_result(phase_result):
return phase_result
seeded_env = _seed_rollout_env(env)
if seeded_env == env:
return phase_result
retried = _execute_phase(phase=phase, timeout_seconds=timeout_seconds, env=seeded_env)
retried["summary"] = f"{retried.get('summary')} (retried with seeded runtime env)"
return retried
def _classify_runtime_exit_as_needs_work(phase_result: dict) -> dict:
commands = list(phase_result.get("commands") or [])
if not commands:
return phase_result
failing = next((item for item in commands if int(item.get("returncode") or 0) != 0), None)
if failing is None:
return phase_result
output = _combined_output(failing).lower()
if "trading runtime:" in output and "blockers:" in output:
adjusted = dict(phase_result)
adjusted["result"] = "needs_work"
adjusted["blocker"] = None
adjusted["summary"] = f"Validation failed for phase {phase_result.get('phase')} on command: {phase_result.get('failed_command')}"
return adjusted
return phase_result
def _finalize_phase_result(*, phase: dict, phase_result: dict) -> dict:
result = _classify_runtime_exit_as_needs_work(phase_result)
guidance = _build_next_step_guidance(
phase=phase,
result=result["result"],
blocker=result.get("blocker"),
failed_command=result.get("failed_command"),
command_results=list(result.get("commands") or []),
)
repair_metadata = _build_repair_metadata(
phase=phase,
result=result["result"],
blocker=result.get("blocker"),
failed_command=result.get("failed_command"),
command_results=list(result.get("commands") or []),
guidance=guidance,
)
result["next_step_guidance"] = guidance
result["repair_metadata"] = repair_metadata
return result
def _prioritize_files_for_failed_command(phase: dict, failed_command: Optional[str]) -> list[str]:
files = list(phase.get("files") or [])
if not failed_command:
return files[:6]
command = failed_command.lower()
ranked = []
for file_path in files:
score = 0
lower_path = file_path.lower()
if "launch_readiness" in command and "readiness" in lower_path:
score += 3
if "alert_dispatcher" in command and any(token in lower_path for token in ["alerts", "notifications", "runtime"]):
score += 3
if "trading_runtime" in command and "runtime" in lower_path:
score += 3
if "review_packet" in command and any(token in lower_path for token in ["decision", "operator", "api"]):
score += 3
ranked.append((score, file_path))
ranked.sort(key=lambda item: (-item[0], item[1]))
return [item[1] for item in ranked[:6]]
def _summarize_command_output(command: dict) -> str:
for field in ("stderr", "stdout"):
text = str(command.get(field) or "").strip()
if text:
return _truncate_output(text.splitlines()[0].strip())
return ""
def _infer_fix_hints(*, phase: dict, failed_command: Optional[str], output: str) -> list[str]:
hints = []
haystack = output.lower()
command = str(failed_command or "").lower()
if "modulenotfounderror" in haystack or "importerror" in haystack:
hints.append("Start with missing imports or missing modules in the phase's core files.")
if "no such file or directory" in haystack:
hints.append("Check whether the referenced script, file, or generated artifact exists at the expected path.")
if "assertionerror" in haystack or "failed" in haystack:
hints.append("Read the failing assertion/test output and adjust phase logic before broad re-runs.")
if "not implemented" in haystack:
hints.append("Implement the missing phase behavior in the listed service files before re-running validations.")
if "run_launch_readiness.py" in command:
hints.append("Prioritize readiness service, settings, and workbench readiness display files.")
if "run_alert_dispatcher.py" in command:
hints.append("Prioritize alert delivery repository, notifier, dispatcher service, and runtime dispatch hook.")
if "run_trading_runtime.py" in command:
hints.append("Prioritize runtime task state, scheduling flow, and API/runtime status alignment.")
if "run_review_packet.py" in command:
hints.append("Prioritize decision packet wording consistency and review packet aggregation paths.")
if not hints:
hints.append(f"Focus on the active phase files first: {', '.join((phase.get('files') or [])[:4])}.")
return _dedupe(hints)
def _should_treat_as_informational_failure(*, phase: dict, command: str, run: dict) -> bool:
if phase.get("code") != "N1":
return False
if "run_live_readiness.py" not in str(command or ""):
return False
output = _combined_output(run)
return "adapter_real_live_not_ready" in output or "Live readiness: False" in output
def _build_next_step_guidance(*, phase: dict, result: str, blocker: Optional[str], failed_command: Optional[str], command_results: list[dict]) -> dict:
guidance = {
"priority_tasks": list(phase.get("tasks") or [])[:3],
"suggested_files": _prioritize_files_for_failed_command(phase, failed_command),
"suggested_commands": [],
"reasoning": [],
}
if result == "passed":
guidance["reasoning"].append("Current phase validations passed; advance to the next phase.")
return guidance
if blocker is not None:
guidance["reasoning"].append(f"Major blocker detected: {blocker}.")
guidance["reasoning"].append("Pause implementation and resolve the external or boundary issue first.")
if failed_command:
guidance["suggested_commands"].append(failed_command)
return guidance
failed_run = next((item for item in command_results if item.get("returncode") != 0), None)
if failed_command:
guidance["suggested_commands"].append(failed_command)
if failed_run is not None:
guidance["reasoning"].append("Inspect the first failing validation command and fix the underlying code issue before re-running the phase.")
guidance["reasoning"].extend(_infer_fix_hints(phase=phase, failed_command=failed_command, output=_combined_output(failed_run)))
preview = _summarize_command_output(failed_run)
if preview:
guidance["reasoning"].append(f"First failing signal: {preview}")
if not guidance["suggested_commands"]:
guidance["suggested_commands"] = list(phase.get("validation_commands") or [])[:2]
return guidance
def _build_repair_prompt_context(*, phase: dict, result: str, failed_command: Optional[str], failed_run: Optional[dict], guidance: dict, failure_class: str, recommended_focus: str, phase_patch_targets: list[str]) -> dict:
command_preview = _summarize_command_output(failed_run or {}) if failed_run is not None else ""
prompt_lines = [
f"Active phase: {phase.get('code')} {phase.get('title')}",
f"Goal: {phase.get('goal')}",
f"Failure class: {failure_class}",
f"Recommended focus: {recommended_focus}",
f"Validation entrypoint: {failed_command or 'none'}",
]
if command_preview:
prompt_lines.append(f"First failing signal: {command_preview}")
if phase_patch_targets:
prompt_lines.append(f"Patch targets: {', '.join(phase_patch_targets)}")
if guidance.get("reasoning"):
prompt_lines.append("Repair guidance:")
prompt_lines.extend([f"- {item}" for item in guidance.get("reasoning") or []])
return {
"mode": "repair_loop",
"phase": phase.get("code"),
"phase_title": phase.get("title"),
"result": result,
"failure_class": failure_class,
"recommended_focus": recommended_focus,
"patch_targets": list(phase_patch_targets),
"validation_entrypoint": failed_command,
"phase_tasks": list(phase.get("tasks") or []),
"phase_files": list(phase.get("files") or []),
"suggested_files": list(guidance.get("suggested_files") or []),
"suggested_commands": list(guidance.get("suggested_commands") or []),
"reasoning": list(guidance.get("reasoning") or []),
"failing_command_preview": command_preview,
"failing_stdout_tail": _truncate_output(str((failed_run or {}).get("stdout") or ""), 1200),
"failing_stderr_tail": _truncate_output(str((failed_run or {}).get("stderr") or ""), 1200),
"prompt_text": "\n".join(prompt_lines),
}
def _classify_failure(*, result: str, blocker: Optional[str], failed_command: Optional[str], output: str) -> str:
if result == "passed":
return "none"
if blocker is not None:
return f"blocker:{blocker}"
haystack = output.lower()
command = str(failed_command or "").lower()
if "modulenotfounderror" in haystack or "importerror" in haystack:
return "missing_dependency_or_import"
if "no such file or directory" in haystack:
return "missing_file_or_artifact"
if "assertionerror" in haystack or "failed" in haystack:
return "validation_failure"
if "not implemented" in haystack:
return "missing_implementation"
if "run_launch_readiness.py" in command:
return "readiness_gap"
if "run_alert_dispatcher.py" in command:
return "alert_dispatch_gap"
if "run_trading_runtime.py" in command:
return "runtime_gap"
if "run_review_packet.py" in command:
return "decision_packet_gap"
return "general_phase_failure"
def _recommend_focus_area(*, phase: dict, failed_command: Optional[str], failure_class: str) -> str:
command = str(failed_command or "").lower()
if failure_class.startswith("blocker:"):
return "resolve_external_blocker"
if "launch_readiness" in command or failure_class == "readiness_gap":
return "readiness_and_boundary_hardening"
if "alert_dispatcher" in command or failure_class == "alert_dispatch_gap":
return "alert_delivery_and_dispatch"
if "trading_runtime" in command or failure_class == "runtime_gap":
return "runtime_scheduler_and_status"
if "review_packet" in command or failure_class == "decision_packet_gap":
return "decision_packet_and_operator_view"
return f"phase:{phase.get('code', 'unknown').lower()}"
def _build_phase_patch_targets(*, phase: dict, failed_command: Optional[str]) -> list[str]:
return _prioritize_files_for_failed_command(phase, failed_command)[:4]
def _build_repair_metadata(*, phase: dict, result: str, blocker: Optional[str], failed_command: Optional[str], command_results: list[dict], guidance: dict) -> dict:
failed_run = next((item for item in command_results if item.get("returncode") != 0), None)
combined = _combined_output(failed_run) if failed_run is not None else ""
failure_class = _classify_failure(result=result, blocker=blocker, failed_command=failed_command, output=combined)
recommended_focus = _recommend_focus_area(phase=phase, failed_command=failed_command, failure_class=failure_class)
phase_patch_targets = _build_phase_patch_targets(phase=phase, failed_command=failed_command)
return {
"failure_class": failure_class,
"recommended_focus": recommended_focus,
"phase_patch_targets": phase_patch_targets,
"phase_validation_entrypoint": failed_command,
"repair_loop_ready": result in {"needs_work", "blocked"},
"guidance_version": "v1",
"suggested_files": list(guidance.get("suggested_files") or []),
"repair_prompt_context": _build_repair_prompt_context(
phase=phase,
result=result,
failed_command=failed_command,
failed_run=failed_run,
guidance=guidance,
failure_class=failure_class,
recommended_focus=recommended_focus,
phase_patch_targets=phase_patch_targets,
),
}
def _execute_phase(*, phase: dict, timeout_seconds: int, env: Optional[dict] = None) -> dict:
started_ts = _now_iso()
runtime_env = _resolve_rollout_env(env)
command_results = []
informational_failures = []
result = "passed"
blocker = None
failed_command = None
summary = f"Phase {phase['code']} passed all validation commands."
for command in phase.get("validation_commands") or []:
run = _run_command(command=command, timeout_seconds=timeout_seconds, env=runtime_env)
command_results.append(run)
if run["returncode"] == 0:
continue
if _should_treat_as_informational_failure(phase=phase, command=command, run=run):
informational_failures.append(command)
continue
failed_command = command
detected_blocker = _detect_major_blocker(run)
if detected_blocker is not None:
result = "blocked"
blocker = detected_blocker
summary = f"Major blocker detected while running: {command}"
break
result = "needs_work"
summary = f"Validation failed for phase {phase['code']} on command: {command}"
break
phase_result = {
"phase": phase["code"],
"title": phase["title"],
"goal": phase["goal"],
"started_ts": started_ts,
"finished_ts": _now_iso(),
"result": result,
"blocker": blocker,
"summary": summary,
"tasks": list(phase.get("tasks") or []),
"files": list(phase.get("files") or []),
"commands": command_results,
"deliverables": list(phase.get("deliverables") or []),
"failed_command": failed_command,
}
if phase_result["result"] == "passed" and informational_failures:
phase_result["summary"] = f"Phase {phase['code']} passed core validations with informational failures: {', '.join(informational_failures)}"
phase_result = _retry_phase_with_seeded_env(phase=phase, phase_result=phase_result, timeout_seconds=timeout_seconds, env=runtime_env)
return _finalize_phase_result(phase=phase, phase_result=phase_result)
def run_until_blocked(env=None) -> dict:
env = dict(os.environ if env is None else env)
status = read_status()
if status.get("state") == "not_started":
status = start_rollout()
max_rounds = max(_get_int(env, "MONITORING_ALERT_ROLLOUT_MAX_ROUNDS", 10), 1)
timeout_seconds = max(_get_int(env, "MONITORING_ALERT_COMMAND_TIMEOUT_SECONDS", DEFAULT_TIMEOUT_SECONDS), 30)
for _ in range(max_rounds):
current = status.get("current_phase")
if current is None:
status["state"] = "completed"
status["note"] = "All phases completed."
write_status(status)
return status
phase = _phase_by_code(current)
if phase is None:
raise RuntimeError(f"Unknown current phase: {current}")
phase_result = _execute_phase(phase=phase, timeout_seconds=timeout_seconds, env=env)
status["last_run"] = phase_result
history = list(status.get("history") or [])
history.append({"ts": _now_iso(), "event": "phase_run", "phase": current, "result": phase_result["result"], "blocker": phase_result.get("blocker")})
status["history"] = history[-100:]
status["updated_ts"] = _now_iso()
if phase_result["result"] == "passed":
status = advance_rollout()
status["last_run"] = phase_result
write_status(status)
return status
if phase_result["result"] == "blocked":
status["state"] = "blocked"
status["blocked_reason"] = phase_result.get("blocker")
status["note"] = phase_result.get("summary") or "Major blocker detected."
status["last_run"] = phase_result
write_status(status)
return status
status["state"] = "needs_work"
status["blocked_reason"] = None
status["note"] = phase_result.get("summary") or "Phase validation failed and needs implementation work."
status["last_run"] = phase_result
write_status(status)
return status
status["state"] = "needs_work"
status["note"] = f"Stopped after reaching MONITORING_ALERT_ROLLOUT_MAX_ROUNDS={max_rounds}."
write_status(status)
return status
def _build_bridge_input(loop_payload: dict) -> dict:
context = dict(loop_payload.get("repair_prompt_context") or {})
return {
"protocol_version": "repair-bridge/v1",
"mode": "repair_loop",
"phase": loop_payload.get("phase"),
"phase_title": loop_payload.get("phase_title"),
"failure_class": loop_payload.get("failure_class"),
"recommended_focus": loop_payload.get("recommended_focus"),
"phase_patch_targets": list(loop_payload.get("phase_patch_targets") or []),
"validation_entrypoint": loop_payload.get("validation_entrypoint"),
"repair_prompt_context": context,
}
def _parse_bridge_output(run: dict) -> dict:
stdout = str(run.get("stdout") or "").strip()
if not stdout:
return {"protocol_version": "repair-bridge/v1", "parsed": False, "error": "empty_stdout"}
try:
payload = json.loads(stdout)
return {"protocol_version": payload.get("protocol_version", "repair-bridge/v1"), "parsed": True, "payload": payload}
except json.JSONDecodeError:
return {"protocol_version": "repair-bridge/v1", "parsed": False, "error": "invalid_json_stdout", "stdout_preview": _truncate_output(stdout, 800)}
def _validate_bridge_output_payload(payload: dict) -> list[str]:
errors = []
for key in ["status", "summary", "changed_files"]:
if key not in payload:
errors.append(f"missing_field:{key}")
if "changed_files" in payload and not isinstance(payload.get("changed_files"), list):
errors.append("invalid_field:changed_files")
return errors
def _bridge_output_summary(bridge_output: dict) -> str:
if not bridge_output.get("parsed"):
return f"bridge output parse failed: {bridge_output.get('error')}"
payload = dict(bridge_output.get("payload") or {})
validation_errors = _validate_bridge_output_payload(payload)
if validation_errors:
return f"bridge output schema issues: {', '.join(validation_errors)}"
return str(payload.get("summary") or "bridge output parsed")
def _bridge_changed_files(bridge_output: dict) -> list[str]:
if not bridge_output.get("parsed"):
return []
payload = dict(bridge_output.get("payload") or {})
changed_files = payload.get("changed_files")
if not isinstance(changed_files, list):
return []
return [str(item) for item in changed_files]
def _bridge_tests_run(bridge_output: dict) -> list[str]:
if not bridge_output.get("parsed"):
return []
payload = dict(bridge_output.get("payload") or {})
tests_run = payload.get("tests_run")
if not isinstance(tests_run, list):
return []
return [str(item) for item in tests_run]
def _bridge_success_guess(bridge_output: dict) -> bool:
if not bridge_output.get("parsed"):
return False
payload = dict(bridge_output.get("payload") or {})
if _validate_bridge_output_payload(payload):
return False
return str(payload.get("status") or "").lower() in {"applied", "success", "fixed"}
def _should_rerun_after_bridge(bridge_output: dict, *, env: dict) -> bool:
if not _get_bool(env, "MONITORING_ALERT_AUTO_RERUN_ENABLED", True):
return False
if not bridge_output.get("parsed"):
return False
payload = dict(bridge_output.get("payload") or {})
if _validate_bridge_output_payload(payload):
return False
status = str(payload.get("status") or "").lower()
success_guess = payload.get("success_guess")
changed_files = _bridge_changed_files(bridge_output)
if status not in {"applied", "success", "fixed"}:
return False
if success_guess is False:
return False
return bool(changed_files)
def _select_rerun_commands(bridge_output: dict) -> list[str]:
tests_run = _bridge_tests_run(bridge_output)
if tests_run:
return tests_run
changed_files = _bridge_changed_files(bridge_output)
commands = []
if any("src/services/alerts/" in item or "src/api/alerts.py" in item for item in changed_files):
commands.append(_cmd("run_alert_dispatcher.py"))
if any("src/services/runtime/" in item or "src/api/trading_runtime.py" in item for item in changed_files):
commands.append(_cmd("run_trading_runtime.py"))
if any("src/services/readiness/" in item or "src/web/" in item or "src/config/settings.py" in item for item in changed_files):
commands.append(_cmd("run_launch_readiness.py"))
if any("src/services/decision/" in item or "src/services/operator/" in item for item in changed_files):
commands.append(_cmd("run_review_packet.py"))
if changed_files and all(item.startswith("docs/") for item in changed_files):
return []
return _dedupe(commands)
def _run_focused_rerun(*, commands: list[str]) -> list[dict]:
return [_run_command(command=command, timeout_seconds=DEFAULT_TIMEOUT_SECONDS) for command in commands]
def _bridge_result_passed(payload: dict) -> bool:
result = dict(payload.get("bridge_result") or {})
return int(result.get("returncode") or 1) == 0
def _run_post_repair_validation(*, env: dict, bridge_output: dict) -> dict:
rerun_commands = _select_rerun_commands(bridge_output)
focused_payload = None
if rerun_commands:
focused = _run_focused_rerun(commands=rerun_commands)
focused_payload = {"state": "focused_rerun_completed", "commands": focused, "selected_commands": rerun_commands}
if any(item.get("returncode") != 0 for item in focused):
focused_payload["state"] = "needs_work"
focused_payload["note"] = "Focused rerun failed after repair."
return focused_payload
rerun_env = dict(env)
rerun_env["MONITORING_ALERT_ROLLOUT_MODE"] = "run-until-blocked"
rerun_payload = run_until_blocked(env=rerun_env)
if focused_payload:
rerun_payload = {**rerun_payload, "focused_rerun": focused_payload}
return rerun_payload
def _should_advance_after_rerun(payload: dict) -> bool:
rerun_state = dict(payload.get("rerun_state_snapshot") or {})
return rerun_state.get("state") in {"running", "completed"}
def _build_repair_bridge_protocol_doc() -> dict:
return {
"protocol_version": "repair-bridge/v1",
"stdin": {"required_fields": ["protocol_version", "mode", "phase", "phase_title", "failure_class", "recommended_focus", "phase_patch_targets", "validation_entrypoint", "repair_prompt_context"]},
"stdout": {"required_fields": ["status", "summary", "changed_files"], "optional_fields": ["notes", "success_guess", "tests_run"]},
}
def _build_repair_bridge_example_output() -> dict:
return {
"protocol_version": "repair-bridge/v1",
"status": "applied",
"summary": "Implemented missing alert dispatcher module and updated runtime hook.",
"changed_files": ["src/services/alerts/alert_dispatcher_service.py", "src/services/runtime/trading_runtime_service.py"],
"notes": ["Focused on N2 alert delivery gap"],
"success_guess": True,
"tests_run": [_cmd("run_alert_dispatcher.py")],
}
def _bridge_repair_signal(payload: dict) -> dict:
bridge_output = dict(payload.get("bridge_output") or {})
return {
"summary": _bridge_output_summary(bridge_output),
"success_guess": _bridge_success_guess(bridge_output),
"changed_files": _bridge_changed_files(bridge_output),
"tests_run": _bridge_tests_run(bridge_output),
}
def _bridge_agent_prompt(loop_payload: dict) -> str:
return json.dumps(_build_bridge_input(loop_payload), ensure_ascii=False, indent=2)
def _run_bridge_command(*, command: str, bridge_input: dict) -> dict:
started = _now_iso()
stdin_text = json.dumps(bridge_input, ensure_ascii=False)
try:
completed = subprocess.run(
["bash", "-lc", command],
cwd=str(ROOT),
input=stdin_text,
capture_output=True,
text=True,
timeout=DEFAULT_TIMEOUT_SECONDS,
encoding="utf-8",
errors="replace",
)
return {
"command": command,
"started_ts": started,
"finished_ts": _now_iso(),
"returncode": int(completed.returncode),
"stdin": _truncate_output(stdin_text, 2000),
"stdout": str(completed.stdout or "")[-8000:],
"stderr": str(completed.stderr or "")[-8000:],
}
except subprocess.TimeoutExpired as exc:
return {
"command": command,
"started_ts": started,
"finished_ts": _now_iso(),
"returncode": 124,
"stdin": _truncate_output(stdin_text, 2000),
"stdout": str(exc.stdout or "")[-8000:],
"stderr": str(exc.stderr or "")[-8000:],
}
def _build_repair_agent_prompt(loop_payload: dict) -> str:
return _bridge_agent_prompt(loop_payload)
def _rerun_hint_payload(loop_payload: dict) -> dict:
return {
"phase": loop_payload.get("phase"),
"validation_entrypoint": loop_payload.get("validation_entrypoint"),
"suggested_commands": list((loop_payload.get("repair_prompt_context") or {}).get("suggested_commands") or []),
}
def _repair_bridge_summary(payload: dict) -> str:
if not payload.get("bridge_invoked"):
return "repair bridge ready but not invoked"
result = dict(payload.get("bridge_result") or {})
summary = f"repair bridge invoked with returncode={result.get('returncode')}"
bridge_output = dict(payload.get("bridge_output") or {})
if bridge_output:
summary += f"; {_bridge_output_summary(bridge_output)}"
if payload.get("post_repair_rerun") is not None:
summary += f"; post-repair rerun state={dict(payload.get('post_repair_rerun') or {}).get('state')}"
return summary
def _rerun_state_snapshot(payload: dict) -> dict:
rerun = dict(payload.get("post_repair_rerun") or {})
return {"state": rerun.get("state"), "current_phase": rerun.get("current_phase"), "note": rerun.get("note")}
def _enrich_bridge_payload(payload: dict) -> dict:
loop_payload = dict(payload)
enriched = {
**payload,
"repair_agent_prompt": _build_repair_agent_prompt(loop_payload),
"rerun_hint": _rerun_hint_payload(loop_payload),
"summary": _repair_bridge_summary(payload),
"bridge_protocol": _build_repair_bridge_protocol_doc(),
}
if payload.get("bridge_invoked"):
enriched["bridge_passed"] = _bridge_result_passed(payload)
enriched["bridge_output_signal"] = _bridge_repair_signal(payload)
if payload.get("post_repair_rerun") is not None:
enriched["rerun_summary"] = _repair_bridge_summary(payload)
enriched["rerun_state_snapshot"] = _rerun_state_snapshot(payload)
return enriched
def run_repair_loop(env=None) -> dict:
env = dict(os.environ if env is None else env)
status = read_status()
last_run = dict(status.get("last_run") or {})
repair_metadata = dict(last_run.get("repair_metadata") or {})
prompt_context = dict(repair_metadata.get("repair_prompt_context") or {})
if status.get("state") != "needs_work" or not prompt_context:
return {"state": "repair_loop_unavailable", "updated_ts": _now_iso(), "reason": "No needs_work phase with repair prompt/context is available.", "current_phase": status.get("current_phase")}
iteration = int(((status.get("repair_loop") or {}).get("iteration") or 0)) + 1
payload = {
"state": "repair_loop_ready",
"updated_ts": _now_iso(),
"iteration": iteration,
"phase": last_run.get("phase"),
"phase_title": last_run.get("title"),
"failure_class": repair_metadata.get("failure_class"),
"recommended_focus": repair_metadata.get("recommended_focus"),
"phase_patch_targets": list(repair_metadata.get("phase_patch_targets") or []),
"validation_entrypoint": repair_metadata.get("phase_validation_entrypoint"),
"repair_prompt_context": prompt_context,
"auto_repair_enabled": _get_bool(env, "MONITORING_ALERT_AUTO_REPAIR_ENABLED", False),
"next_action": "hand_off_to_repair_agent",
}
_write_repair_loop_state(payload)
return payload
def run_repair_bridge(env=None) -> dict:
env = dict(os.environ if env is None else env)
loop_payload = run_repair_loop(env=env)
if loop_payload.get("state") != "repair_loop_ready":
return loop_payload
bridge_command = str(env.get("MONITORING_ALERT_REPAIR_BRIDGE_COMMAND") or "").strip()
auto_repair_enabled = _get_bool(env, "MONITORING_ALERT_AUTO_REPAIR_ENABLED", False)
if not auto_repair_enabled or not bridge_command:
payload = _enrich_bridge_payload({**loop_payload, "state": "repair_bridge_ready", "bridge_invoked": False, "next_action": "invoke_external_repair_agent"})
_write_repair_loop_state(payload)
return payload
bridge_input = _build_bridge_input(loop_payload)
run = _run_bridge_command(command=bridge_command, bridge_input=bridge_input)
bridge_output = _parse_bridge_output(run)
payload = {
**loop_payload,
"state": "repair_bridge_invoked",
"bridge_invoked": True,
"bridge_command": bridge_command,
"bridge_input": bridge_input,
"bridge_result": run,
"bridge_output": bridge_output,
"next_action": "rerun_validation_after_repair",
}
if _should_rerun_after_bridge(bridge_output, env=env):
payload["post_repair_rerun"] = _run_post_repair_validation(env=env, bridge_output=bridge_output)
payload = _enrich_bridge_payload(payload)
if payload.get("post_repair_rerun") is not None:
payload["next_action"] = "phase_advanced" if _should_advance_after_rerun(payload) else "inspect_rerun_result"
_write_repair_loop_state(payload)
return payload
def _upsert_anchor_block(content: str, header: str, body: str) -> str:
start = content.find(header)
block = f"{header}\n{body.rstrip()}\n"
if start == -1:
return content.rstrip() + "\n\n" + block
next_index = content.find("\n## ", start + len(header))
if next_index == -1:
return content[:start] + block
return content[:start] + block + content[next_index + 1:]
def _write_session_anchor(status: dict) -> None:
if not SESSION_PATH.exists():
return
current_phase = status.get("current_phase") or "none"
repair_loop = dict(status.get("repair_loop") or {})
body = [f"- state: {status.get('state')}", f"- current_phase: {current_phase}", f"- updated_ts: {status.get('updated_ts')}"]
if repair_loop:
body.append(f"- repair_iteration: {repair_loop.get('iteration')}")
body.append(f"- repair_focus: {repair_loop.get('recommended_focus')}")
body.append(f"- repair_next_action: {repair_loop.get('next_action')}")
content = SESSION_PATH.read_text(encoding="utf-8")
SESSION_PATH.write_text(_upsert_anchor_block(content, "## Automation Status", "\n".join(body)), encoding="utf-8")
def _write_tasks_anchor(status: dict) -> None:
if not TASKS_PATH.exists():
return
current_phase = status.get("current_phase") or "none"
repair_loop = dict(status.get("repair_loop") or {})
body = [f"- [ ] state: {status.get('state')}", f"- [ ] current_phase: {current_phase}", f"- [ ] updated_ts: {status.get('updated_ts')}"]
if repair_loop:
body.append(f"- [ ] repair_iteration: {repair_loop.get('iteration')}")
body.append(f"- [ ] repair_focus: {repair_loop.get('recommended_focus')}")
body.append(f"- [ ] repair_next_action: {repair_loop.get('next_action')}")
content = TASKS_PATH.read_text(encoding="utf-8")
TASKS_PATH.write_text(_upsert_anchor_block(content, "## Automation Status", "\n".join(body)), encoding="utf-8")
def _write_status_anchors(status: dict) -> None:
_write_session_anchor(status)
_write_tasks_anchor(status)
def _append_patch_round(status: dict, payload: dict) -> None:
repair_loop = dict(status.get("repair_loop") or {})
rounds = list(repair_loop.get("patch_rounds") or [])
bridge_output = dict(payload.get("bridge_output") or {})
bridge_payload = dict(bridge_output.get("payload") or {}) if bridge_output.get("parsed") else {}
rounds.append({
"ts": _now_iso(),
"iteration": payload.get("iteration"),
"changed_files": _bridge_changed_files(bridge_output),
"tests_run": _bridge_tests_run(bridge_output),
"success_guess": bridge_payload.get("success_guess"),
"rerun_state": dict(payload.get("rerun_state_snapshot") or {}).get("state"),
"phase_advanced": payload.get("next_action") == "phase_advanced",
})
repair_loop["patch_rounds"] = rounds[-20:]
status["repair_loop"] = repair_loop
def _sync_patch_rounds_to_payload(status: dict, payload: dict) -> dict:
repair_loop = dict(status.get("repair_loop") or {})
if repair_loop.get("patch_rounds"):
payload["patch_rounds"] = list(repair_loop.get("patch_rounds") or [])
return payload
def _write_repair_loop_state(payload: dict) -> None:
status = read_status()
status["repair_loop"] = payload
if payload.get("bridge_invoked"):
_append_patch_round(status, payload)
payload = _sync_patch_rounds_to_payload(status, payload)
status["repair_loop"] = payload
history = list(status.get("history") or [])
history.append({"ts": _now_iso(), "event": "repair_bridge" if payload.get("bridge_invoked") else "repair_loop_ready", "phase": payload.get("phase"), "iteration": payload.get("iteration")})
status["history"] = history[-100:]
status["updated_ts"] = _now_iso()
write_status(status)
_write_status_anchors(status)
def render_text(payload: dict) -> str:
lines = [f"Monitoring alert rollout state: {payload.get('state', 'unknown')}"]
if payload.get("current_phase"):
lines.append(f"Current phase: {payload['current_phase']}")
completed = payload.get("completed_phases") or []
lines.append(f"Completed phases: {', '.join(completed) if completed else 'none'}")
if payload.get("blocked_reason"):
lines.append(f"Blocked reason: {payload['blocked_reason']}")
if payload.get("note"):
lines.append(f"Note: {payload['note']}")
lines.append(f"Task doc: {payload.get('doc_path', str(DOC_PATH))}")
last_run = payload.get("last_run") or {}
if last_run:
lines.append("Last run:")
lines.append(f"- Phase: {last_run.get('phase')} {last_run.get('title', '')}")
lines.append(f"- Result: {last_run.get('result')}")
lines.append(f"- Summary: {last_run.get('summary')}")
for command in last_run.get("commands") or []:
preview = _summarize_command_output(command)
suffix = f" :: {preview}" if preview else ""
lines.append(f" - [{command.get('returncode')}] {command.get('command')}{suffix}")
guidance = last_run.get("next_step_guidance") or {}
if guidance:
lines.append("Next-step guidance:")
if guidance.get("priority_tasks"):
lines.append(f"- Priority tasks: {', '.join(guidance['priority_tasks'])}")
if guidance.get("suggested_files"):
lines.append(f"- Suggested files: {', '.join(guidance['suggested_files'])}")
if guidance.get("suggested_commands"):
lines.append(f"- Suggested commands: {', '.join(guidance['suggested_commands'])}")
for item in guidance.get("reasoning") or []:
lines.append(f"- {item}")
repair_loop = payload.get("repair_loop") or {}
if repair_loop:
lines.append("Repair loop state:")
lines.append(f"- Iteration: {repair_loop.get('iteration')}")
lines.append(f"- Phase: {repair_loop.get('phase')} {repair_loop.get('phase_title', '')}")
lines.append(f"- Failure class: {repair_loop.get('failure_class')}")
lines.append(f"- Recommended focus: {repair_loop.get('recommended_focus')}")
if repair_loop.get("summary"):
lines.append(f"- Bridge summary: {repair_loop.get('summary')}")
return "\n".join(lines)
def main(env=None) -> int:
env = os.environ if env is None else env
output_json = _get_bool(env, "MONITORING_ALERT_ROLLOUT_OUTPUT_JSON", False)
mode = str(env.get("MONITORING_ALERT_ROLLOUT_MODE", "plan")).strip().lower()
if mode == "plan":
payload = build_rollout_plan()
elif mode == "status":
payload = read_status()
elif mode == "start":
payload = start_rollout()
elif mode == "advance":
payload = advance_rollout()
elif mode == "block":
blocker = str(env.get("MONITORING_ALERT_BLOCKER", "manual_blocker")).strip() or "manual_blocker"
note = str(env.get("MONITORING_ALERT_BLOCKER_NOTE", "")).strip()
payload = block_rollout(blocker=blocker, note=note)
elif mode == "complete":
payload = complete_rollout()
elif mode == "run-until-blocked":
payload = run_until_blocked(env=env)
elif mode == "repair-loop":
payload = run_repair_loop(env=env)
elif mode == "repair-bridge":
payload = run_repair_bridge(env=env)
else:
raise SystemExit(f"Unsupported MONITORING_ALERT_ROLLOUT_MODE: {mode}")
if output_json:
print(json.dumps(payload, ensure_ascii=False, sort_keys=True))
else:
print(render_text(payload))
return 0 if payload.get("state") not in {"blocked", "failed"} else 2
if __name__ == "__main__":
raise SystemExit(main())