#!/bin/bash
# job_watchdog.sh — 元监控：全方位系统健康检查
# 加载环境变量（cron 环境中 OPENCLAW_PHONE/DISCORD_CH_* 等必须从 profile 获取）
source "$HOME/.bash_profile" 2>/dev/null || source "$HOME/.env_shared" 2>/dev/null || true
# 每小时由系统 crontab 触发，检查 8 大维度：
#   1. 定时任务执行状态（15个job，时间戳+状态字段）
#   2. 日志错误扫描（所有job日志，最近50行）
#   3. 三层服务健康（Gateway/Proxy/Adapter HTTP探测）
#   4. 陈旧锁文件检测+自动清理
#   5. Cron 健康（心跳+crontab条目数）
#   6. Proxy 运行时监控（连续错误+context用量+吞吐量）
#   7. 磁盘空间检查
#   8. KB 数据新鲜度
# V31: 从6个job扩展到15个 + 服务健康 + 磁盘 + KB新鲜度 + crontab条目数 + 日志扫描加强
# cron 环境 PATH 极简，必须显式声明（规则 #13）
export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:$PATH"
# V37.9.58-hotfix4 (2026-05-12): set -E (errtrace) 让 ERR trap 在 function 内部生效.
# 血案: V37.9.58-hotfix3 加 trap ERR 但漏 set -E → bash 默认 function 内 fail 不
# propagate ERR trap → fatal_handler 不触发 → silent abort 仍然发生 (16:45 实测).
# `set -eEo pipefail` 三选项缺一不可:
#   -e: errexit, 任一命令失败立即 abort (但 function 内 fail 默认不传播)
#   -E: errtrace (V37.9.58-hotfix4 新增), function 内 fail 真触发 ERR trap
#   -o pipefail: pipe 任一段 fail = pipe fail (防 silent)
set -eEo pipefail

# 防重叠执行（mkdir 原子锁，macOS 兼容）
# 自身锁文件加陈旧检测——超过30分钟则强制清理（防止 watchdog 自身被锁死）
LOCK="/tmp/job_watchdog.lockdir"
NOW_EPOCH=$(date +%s)
if [ -d "$LOCK" ]; then
    if [ "$(uname)" = "Darwin" ]; then
        LOCK_EPOCH=$(stat -f %m "$LOCK" 2>/dev/null || echo "0")
    else
        LOCK_EPOCH=$(stat -c %Y "$LOCK" 2>/dev/null || echo "0")
    fi
    LOCK_AGE=$(( NOW_EPOCH - LOCK_EPOCH ))
    if [ "$LOCK_AGE" -gt 1800 ]; then
        echo "[watchdog] Stale self-lock detected (${LOCK_AGE}s old), force clearing"
        rmdir "$LOCK" 2>/dev/null || rm -rf "$LOCK" 2>/dev/null
    fi
fi
mkdir "$LOCK" 2>/dev/null || { echo "[watchdog] Already running, skip"; exit 0; }

OPENCLAW="${OPENCLAW:-/opt/homebrew/bin/openclaw}"
TO="${OPENCLAW_PHONE:-+85200000000}"
TS="$(TZ=Asia/Hong_Kong date '+%Y-%m-%d %H:%M:%S')"

# ════════════════════════════════════════════════════════════════════
# V37.9.58-hotfix3: 提前 source notify.sh + 装 ERR trap (silent abort 变 loud)
# ════════════════════════════════════════════════════════════════════
# 血案: 5/5 16:30 起 watchdog 因 awk multibyte 错误 + set -e 静默 abort 7 天,
# 用户 5/12 视角发现. MR-19 候选 monitor-must-self-alarm-on-silent-abort 兑现.
NOTIFY_SCRIPT="$(cd "$(dirname "$0")" && pwd)/notify.sh"
if [ -f "$NOTIFY_SCRIPT" ]; then
    # shellcheck disable=SC1090
    source "$NOTIFY_SCRIPT" 2>/dev/null || true
fi

# V37.9.63: ERR trap 走公共 helper cron_monitor_fatal_handler.sh (MR-8/MR-19 抽公共)
# 之前: V37.9.58-hotfix3 inline _watchdog_fatal_handler (V37.9.63 已抽到 helper)
# helper 内含三层 FAIL-OPEN (stderr / 本地告警文件 / notify→openclaw 直发 canonical CLI)
HELPER_DIR="$(cd "$(dirname "$0")" && pwd)"
if [ -f "$HELPER_DIR/cron_monitor_fatal_handler.sh" ]; then
    # shellcheck disable=SC1091
    source "$HELPER_DIR/cron_monitor_fatal_handler.sh"
    CRON_FATAL_LABEL="watchdog"
    CRON_FATAL_LOG="$HOME/job_watchdog.log"
    CRON_FATAL_BASH_X="bash -x ~/job_watchdog.sh"
    CRON_FATAL_REASON="监控自身死亡! 5/5-5/12 silent 7 天血案防回归."
fi

# EXIT trap: rmdir + canary heartbeat 推送 (V37.9.58-hotfix3 元监控)
_watchdog_exit_handler() {
    local final_exit=$?
    rmdir "$LOCK" 2>/dev/null || true
    # V37.9.58-hotfix3 Canary: 写本地状态 + 仅在正常完成时推 canary (避免 abort 时双重推送)
    if [ "$final_exit" -eq 0 ]; then
        local canary_ts="$(TZ=Asia/Hong_Kong date '+%Y-%m-%d %H:%M:%S')"
        cat > "$HOME/watchdog_canary.json" <<EOF 2>/dev/null
{
  "last_completed": "$canary_ts",
  "checks": ${STATS_PASS:-0},
  "warns": ${STATS_WARN:-0},
  "alerts": ${#ALERTS[@]}
}
EOF
        # 仅当 ALERTS 为空时推 canary "alive" (有 alert 时主推送通道已推送告警, 不重复)
        # 这让 watchdog 自身死活可被检测: 用户超过 12h 没收到 alert 也没收到 canary → watchdog 死了
        if [ "${#ALERTS[@]}" -eq 0 ] && command -v notify >/dev/null 2>&1; then
            local canary_msg="✅ [watchdog alive @ $canary_ts] checks=${STATS_PASS:-0} alerts=0 (no issues)"
            # canary 走 alerts topic 但内容是 alive 而非 alert, 频率: 每 4h 一次 cron 跑 = 每天 4 条
            notify "$canary_msg" --topic alerts 2>/dev/null || true
        fi
    fi
}
# V37.9.63: trap ERR 调公共 helper (caller 配置 CRON_FATAL_* 4 变量); EXIT 保留 watchdog-specific
trap '_cron_monitor_fatal_handler $LINENO' ERR
trap '_watchdog_exit_handler' EXIT

# 心跳日志：每次运行都记录
echo "[watchdog] $TS heartbeat — 8-dimension health check"

ALERTS=()
STATS_PASS=0
STATS_WARN=0

# ════════════════════════════════════════════════════════════════════
# 1/8 定时任务执行状态（15个job，时间戳+状态字段）
# ════════════════════════════════════════════════════════════════════
# 格式：job_id | status_file 路径 | 最大允许静默时间(秒) | 显示名 | tier
# 静默时间 = interval × 2 + 缓冲
# tier: core（失败立即告警）/ auxiliary（失败记录警告）/ experiment（失败仅记录）
JOBS=(
    # ── 论文监控矩阵（5源）──
    # ArXiv: 每天2次(08/20) → 最多静默 28h
    "arxiv_monitor|$HOME/.openclaw/jobs/arxiv_monitor/cache/last_run.json|100800|ArXiv论文监控|core"
    # HF Papers: 每天2次(10/20) → 最多静默 28h
    "hf_papers|$HOME/.openclaw/jobs/hf_papers/cache/last_run.json|100800|HF论文监控|core"
    # Semantic Scholar: 每天1次(08:00) → 最多静默 50h
    "semantic_scholar|$HOME/.openclaw/jobs/semantic_scholar/cache/last_run.json|180000|S2论文监控|core"
    # DBLP: 每天1次(12:00) → 最多静默 50h
    "dblp|$HOME/.openclaw/jobs/dblp/cache/last_run.json|180000|DBLP论文监控|auxiliary"
    # ACL: 每周三(09:30) → 最多静默 672h（28天/4周）
    # V37.9.6 调宽: 14 天, V37.9.8 再调宽: 28 天。
    # V37.9.8 根因确定: 脚本 line 176-179 每个 exit 分支都写 last_run（no_volumes/
    # parse_failed/ok new=0），4/8 后 status mtime 不变 = cron 4/15 周三 09:30
    # 偶发 miss（不是脚本 bug）。ACL 是年度顶会周期（7-11 月为主），4 月常 0 新
    # 论文。28 天阈值容忍 2 周 cron miss + 2 周真低频。
    # 4/22 周三观察: 若下次 cron 再漏，查 Mac Mini /var/log/cron 或 launchd 日志。
    "acl_anthology|$HOME/.openclaw/jobs/acl_anthology/cache/last_run.json|2419200|ACL论文监控|auxiliary"

    # ── 应用监控 ──
    # HN: 每天3次(08:45/14:45/20:45) → 最多静默 14h
    # V37.9.28 F3: 修正 schedule-vs-threshold drift —— 注释原写"每3小时 → 7h"
    # 但 jobs_registry.yaml 实际是 "45 8,14,20 * * *" 即每天 3 次, 最大 gap 12h
    # (overnight 20:45 → 次日 08:45). 7h 阈值导致 overnight 必报警 (用户 5/5 观察现场).
    # 14h = 12h max gap + 2h slack, 与 freight_watcher (3次同款 schedule) 阈值对齐.
    "run_hn_fixed|$HOME/.openclaw/jobs/hn_watcher/cache/last_run.json|50400|HN热帖抓取|core"
    # Freight: V37.9.64 每天 1 次(14:00) → 最多静默 50h (24h max gap + 26h slack)
    "freight_watcher|$HOME/.openclaw/jobs/freight_watcher/cache/last_run.json|180000|货代Watcher|core"
    # OpenClaw Releases: V37.9.50-hotfix2 已 disable (registry enabled=false). Tripwire 框架替代 daily 推送, 不再 watchdog 监控
    # Discussions: V37.9.50-hotfix3 已 disable (registry enabled=false). 与 openclaw_run hotfix2 同源减法, 社区 issue 推送价值不高

    # ── KB 处理 ──
    # KB Evening: 每天22:00 → 最多静默 50h
    "kb_evening|$HOME/.kb/last_run_evening.json|180000|KB晚间整理|core"
    # KB Review: 每周五21:00 → 最多静默 192h（8天）
    "kb_review|$HOME/.kb/last_run_review.json|691200|KB周回顾|auxiliary"

    # ── V37.8.13 新增：此前完全无观测的 LLM/数据 job ──
    # GitHub Trending: 每天2次(09/21) → 最多静默 28h
    "github_trending|$HOME/.openclaw/jobs/github_trending/cache/last_run.json|100800|GitHub热门仓库|auxiliary"
    # RSS Blogs: 每天2次(07/19) → 最多静默 28h
    "rss_blogs|$HOME/.openclaw/jobs/rss_blogs/cache/last_run.json|100800|RSS技术博客|auxiliary"
    # AI Leaders X: V37.9.103 降频 每天1次(09:00) → 最多静默 50h (2→1/天, 接受 X 退化; 与 finance_news 1/天阈值对齐)
    "ai_leaders_x|$HOME/.openclaw/jobs/ai_leaders_x/cache/last_run.json|180000|AI领袖X动态|core"
    # KB Inject: 每天2次(07/19) → 最多静默 28h
    "kb_inject|$HOME/.kb/last_run_inject.json|100800|KB每日摘要注入|core"
    # Finance News: 每天07:30 → 最多静默 50h
    "finance_news|$HOME/.openclaw/jobs/finance_news/cache/last_run.json|180000|全球财经新闻|auxiliary"
    # Ontology Sources: 每天2次(10/20) → 最多静默 28h
    "ontology_sources|$HOME/.openclaw/jobs/ontology_sources/cache/last_run.json|100800|本体论信息源|auxiliary"
    # KB Harvest Chat: 每天06:00 → 最多静默 50h
    "kb_harvest_chat|$HOME/.kb/last_run_harvest_chat.json|180000|对话精华提炼|core"
    # V37.8.13 原计划加入 pwc 但 registry 中 enabled=false (已停用: API 302 重定向到 HF,
    # 功能合并到 hf_papers)。保持 watchdog 不监控已停用 job 的契约。

    # ── V37.9.59 新增：补齐 watchdog 监控盲区 (16→21 jobs, 47%→62%) ──
    # 用户 5/12 17:30 询问 watchdog 是否监控所有任务 + 是否准确上报 — 真实数据显示
    # 仅监控 16/34 (47%). 漏监控的 18 个 jobs 任何 silent failure 都不会上报. V37.9.59
    # 立即补齐有 last_run.json 写入的 5 个核心 jobs + V37.9.59 新加 LOG_FRESHNESS_JOBS
    # 数组覆盖剩余仅有 log 的 jobs.
    # KB Deep Dive: 每天22:30 → 最多静默 50h (V37.9.16 新增, 漏监控至 V37.9.58 后)
    "kb_deep_dive|$HOME/.kb/last_run_deep_dive.json|180000|KB每日深度|core"
    # KB Dream: 每天03:00 + 子阶段 map_sources(00:00) + map_notes(00:40) → 最多静默 50h
    # V37.9.60-hotfix2: kb_dream.sh:103 实际写 $DREAM_DIR/.last_run.json (= ~/.kb/dreams/.last_run.json)
    #   V37.9.59 假设 ~/.kb/last_run_dream.json 路径错配, 导致 watchdog 永远 "状态文件不存在" 误报
    "kb_dream|$HOME/.kb/dreams/.last_run.json|180000|Agent Dream|core"
    # Chaspark: 每天11:00 学术抓取 → 最多静默 50h
    "chaspark|$HOME/.openclaw/jobs/chaspark/cache/last_run.json|180000|Chaspark学术|auxiliary"
    # Governance Audit: 每天07:00 → 最多静默 50h
    "governance_audit_cron|$HOME/.kb/last_run_governance_audit.json|180000|治理审计|core"
)

# ── V37.9.59 新增：LOG_FRESHNESS_JOBS — 仅有 log 没 last_run.json 的 jobs ──
# 9 个 jobs (kb_embed/kb_trend/preference_learner/auto_deploy/wa_keepalive/mm_index/
# daily_ops_report/openclaw_backup/health_check/cron_canary). 用 log mtime 替代
# last_run.json (粗粒度新鲜度, 无法区分 跑了但失败 vs 没跑, 但比完全不监控好).
# Format: "job_id|log_path|max_silence_seconds|display_name|tier"
LOG_FRESHNESS_JOBS=(
    # auto_deploy: 每 2min 触发但 V37.9.8 仅在整点 (minute<2) 写心跳 → log mtime 最长 60min 静默.
    # V37.9.72 (i) 修: 阈值 600 (10min) → 4200 (70min, 60min 心跳间隔 + 10min slack).
    # 触发: 今早 watchdog 报 "auto_deploy log 29m 未更新" — watchdog 8:30 跑, auto_deploy 心跳 8:00 写, 8:30-8:00=29min 必然超 10min 阈值, 是 V37.9.59 阈值与 V37.9.8 心跳错配设计 bug.
    "auto_deploy|$HOME/.openclaw/logs/auto_deploy.log|4200|自动部署|core"
    # wa_keepalive: 每 30min → 最多静默 1.5h (3 周期)
    "wa_keepalive|$HOME/wa_keepalive.log|5400|WhatsApp保活|core"
    # cron_canary: 每 10min → 最多静默 30min (3 周期), V37.8.13 后核心
    "cron_canary|$HOME/.cron_canary|1800|cron心跳|core"
    # kb_status_refresh: 每 1h → 最多静默 2h (2 周期)
    "kb_status_refresh|$HOME/kb_status_refresh.log|7200|状态刷新|auxiliary"
    # mm_index: 每 2h → 最多静默 4h
    "mm_index|$HOME/.openclaw/logs/jobs/mm_index.log|14400|多模态索引|auxiliary"
    # kb_embed: 每天03:30 → 最多静默 50h
    "kb_embed|$HOME/kb_embed.log|180000|KB向量索引|core"
    # daily_ops_report: 每天08:15 → 最多静默 50h
    "daily_ops_report|$HOME/daily_ops_report.log|180000|每日运维|auxiliary"
    # openclaw_backup: 每天03:00 → 最多静默 50h
    "openclaw_backup|$HOME/openclaw_backup.log|180000|每日备份|auxiliary"
    # preference_learner: 每天07:30 → 最多静默 50h
    "preference_learner|$HOME/preference_learner.log|180000|偏好学习|auxiliary"
    # kb_trend: 每周六09:00 → 最多静默 14d (336h)
    "kb_trend|$HOME/kb_trend.log|1209600|KB周趋势|auxiliary"
    # health_check: 每周一09:00 → 最多静默 14d
    "health_check|$HOME/health_check.log|1209600|健康周报|auxiliary"
)

CORE_ALERTS=()
AUX_ALERTS=()
EXP_ALERTS=()

for entry in "${JOBS[@]}"; do
    IFS='|' read -r job_id status_file max_silence display_name tier <<< "$entry"
    tier="${tier:-auxiliary}"  # 默认 auxiliary

    # 状态文件不存在 → 可能从未成功运行过
    if [ ! -f "$status_file" ]; then
        case "$tier" in
            core) CORE_ALERTS+=("🔴 $display_name: 状态文件不存在（从未成功执行？）") ;;
            auxiliary) AUX_ALERTS+=("🟡 $display_name: 状态文件不存在") ;;
            *) EXP_ALERTS+=("⚪ $display_name: 状态文件不存在") ;;
        esac
        ALERTS+=("$display_name: 状态文件不存在（从未成功执行？）")
        continue
    fi

    # 读取 last_run.json 中的时间戳和状态
    JOB_INFO=$(python3 -c "
import json, sys
from datetime import datetime, timezone, timedelta
try:
    with open('$status_file') as f:
        d = json.load(f)
    t = d.get('time', '')
    dt = datetime.strptime(t, '%Y-%m-%d %H:%M:%S')
    dt_utc = dt - timedelta(hours=8)
    epoch = int(dt_utc.replace(tzinfo=timezone.utc).timestamp())
    status = d.get('status', 'unknown')
    # 额外字段
    http_code = d.get('http_code', '')
    deep_dive = d.get('deep_dive', '')
    new_count = d.get('new', '')
    print(f'{epoch}|{status}|{http_code}|{deep_dive}|{new_count}')
except Exception as e:
    print(f'0|error|||||{e}')
" 2>/dev/null)

    IFS='|' read -r LAST_TIME LAST_STATUS HTTP_CODE DEEP_DIVE NEW_COUNT <<< "$JOB_INFO"

    if [ "$LAST_TIME" -eq 0 ] 2>/dev/null; then
        ALERTS+=("$display_name: 状态文件格式异常")
        continue
    fi

    # 时间戳超时检查
    ELAPSED=$((NOW_EPOCH - LAST_TIME))
    if [ "$ELAPSED" -gt "$max_silence" ]; then
        HOURS=$((ELAPSED / 3600))
        _msg="$display_name: 已 ${HOURS}h 未更新（阈值 $((max_silence / 3600))h）"
        ALERTS+=("$_msg")
        case "$tier" in
            core) CORE_ALERTS+=("🔴 $_msg") ;;
            auxiliary) AUX_ALERTS+=("🟡 $_msg") ;;
            *) EXP_ALERTS+=("⚪ $_msg") ;;
        esac
    else
        STATS_PASS=$((STATS_PASS + 1))
    fi

    # 状态字段检查（扩展匹配：任何非 ok/unknown 的失败状态）
    _status_msg=""
    case "$LAST_STATUS" in
        ok|unknown)
            ;;  # 正常
        fetch_failed)
            _status_msg="$display_name: 最近一次抓取失败 (HTTP $HTTP_CODE)"
            ;;
        parse_failed|parse_quality_low)
            _status_msg="$display_name: 最近一次解析异常 ($LAST_STATUS)"
            ;;
        send_failed)
            _status_msg="$display_name: 最近一次推送失败"
            ;;
        no_volumes)
            _status_msg="$display_name: 外挂存储不可用 ($LAST_STATUS)"
            ;;
        *)
            # 捕获所有未知的非 ok 状态
            if [ "$LAST_STATUS" != "ok" ] && [ "$LAST_STATUS" != "unknown" ]; then
                _status_msg="$display_name: 异常状态 ($LAST_STATUS)"
            fi
            ;;
    esac
    if [ -n "$_status_msg" ]; then
        ALERTS+=("$_status_msg")
        case "$tier" in
            core) CORE_ALERTS+=("🔴 $_status_msg") ;;
            auxiliary) AUX_ALERTS+=("🟡 $_status_msg") ;;
            *) EXP_ALERTS+=("⚪ $_status_msg") ;;
        esac
    fi

    # 货代 deep_dive 特殊检查
    if [ "$job_id" = "freight_watcher" ] && [ -n "$DEEP_DIVE" ]; then
        case "$DEEP_DIVE" in
            ok) ;;
            no_data)
                ALERTS+=("$display_name: ImportYeti 无数据返回（deep_dive=no_data）")
                ;;
            skipped|missing)
                STATS_WARN=$((STATS_WARN + 1))
                ;;
        esac
    fi
done

# ════════════════════════════════════════════════════════════════════
# 2/8 日志错误扫描（全部 job 日志，最近50行）
# ════════════════════════════════════════════════════════════════════
# 扫描两个日志目录：~/.openclaw/logs/jobs/ 和 ~/（部分 job 日志在 home 目录）
scan_logs() {
    local logfile="$1"
    local job_name="$2"
    [ -f "$logfile" ] || return

    # 跳过超过 24h 未修改的日志文件（避免告警已自愈的陈旧错误）
    if [ "$(uname)" = "Darwin" ]; then
        LOG_MOD=$(stat -f %m "$logfile" 2>/dev/null || echo "0")
    else
        LOG_MOD=$(stat -c %Y "$logfile" 2>/dev/null || echo "0")
    fi
    local LOG_AGE=$(( NOW_EPOCH - LOG_MOD ))
    if [ "$LOG_AGE" -gt 86400 ]; then
        return  # 日志超过24h未更新，跳过扫描
    fi

    # V37.9.6: 行级时间戳过滤——只报最近 24h 内的错误
    # 之前 bug：只过滤文件 mtime，但 tail -50 行里可能含 13 天前的旧错误
    # （V37.9.6 之前 12:30 watchdog 报告 4/8 / 4/14 / 4/15 的陈旧错误就是这个原因）
    # 修复：grep 行级时间戳模式，对带 [YYYY-MM-DD HH:MM:SS] 前缀的行只保留 24h 内的
    # 行内含错误关键字的本身就是错误日志（包括 ERROR/FAIL/Traceback/HTTP 4xx/5xx）
    local cutoff_epoch=$(( NOW_EPOCH - 86400 ))
    local cutoff_date  # YYYY-MM-DD 形式的截止日期（含），用于 grep 过滤
    if [ "$(uname)" = "Darwin" ]; then
        cutoff_date=$(date -r "$cutoff_epoch" '+%Y-%m-%d')
    else
        cutoff_date=$(date -d "@$cutoff_epoch" '+%Y-%m-%d')
    fi
    local today_date
    if [ "$(uname)" = "Darwin" ]; then
        today_date=$(date '+%Y-%m-%d')
    else
        today_date=$(date '+%Y-%m-%d')
    fi
    local err_pattern='推送失败|send_failed|fetch_failed|FAIL(ED)?:|ERROR[: ]|Traceback|HTTP[/ ](4[0-9]{2}|5[0-9]{2})'

    # 抓 tail -200（拉宽窗口防错过近时刻多错误），先按行级时间戳留 24h 内，再 grep 错误
    # 行级时间戳格式 [YYYY-MM-DD HH:MM:SS] 或 [YYYY-MM-DDTHH:MM:SS] 出现在行首附近
    # 无时间戳的行（如 Python Traceback 多行）保留以免漏关键上下文
    # V37.9.58-hotfix3 (2026-05-12): LC_ALL=C 防 macOS awk multibyte conversion failure +
    #   `|| true` 防 set -eo pipefail 让 watchdog silent abort. 5/5 16:30 起 watchdog
    #   每次 cron 触发都因某 log 含无效 UTF-8 字节 awk 崩溃 → set -e abort → 7 天 silent
    #   failure 累积 (用户 5/12 视角发现). 详 ontology/docs/cases/watchdog_silent_abort_case.md.
    local recent_window
    recent_window=$(tail -200 "$logfile" 2>/dev/null | LC_ALL=C awk -v cutoff="$cutoff_date" -v today="$today_date" '
        /\[([0-9]{4}-[0-9]{2}-[0-9]{2})/ {
            if (match($0, /[0-9]{4}-[0-9]{2}-[0-9]{2}/)) {
                ts_date = substr($0, RSTART, RLENGTH)
                if (ts_date >= cutoff && ts_date <= today) {
                    print
                    in_recent = 1
                } else {
                    in_recent = 0
                }
                next
            }
        }
        # 无时间戳行: 跟随上一行状态（Traceback 多行连续输出场景）
        in_recent { print }
    ' 2>/dev/null || true)

    local recent_fails
    recent_fails=$(echo "$recent_window" | grep -ciE "$err_pattern" || true)
    if [ "$recent_fails" -gt 0 ]; then
        local last_err
        # V37.9.58-hotfix4: head -c N + SIGPIPE 关 stdin 可能让 grep 收到 SIGPIPE exit 非 0
        # → pipefail + set -e abort. 加 `|| true` 兜底.
        last_err=$(echo "$recent_window" | grep -iE "$err_pattern" | tail -1 | head -c 120 || true)

        # V37.9.28 F1: 提取错误行的时间戳分布（最早/最新），让告警可读
        # 之前 bug: 只显示 "12条错误 → last_err" 看不出 12 条是分布的还是集中的
        # 用户 5/5 周一观察反馈："主要是一些 health 监控推送没有严格的时间戳"
        # 修复: grep -oE 提取 [YYYY-MM-DD HH:MM] 模式, sort -u 去重, head/tail 取最早最新
        # 边界: 全是 Traceback 多行无时间戳行 → "(时间戳缺失)" 标记
        # V37.9.58-hotfix4 (2026-05-12): 加 `|| true` 兜底 — grep 无匹配 exit 1 +
        #   pipefail + set -e = 整 watchdog abort. 5/5 16:30 起 + V37.9.58-hotfix3
        #   16:45 实测两次重演. 这是 V37.9.28 F1 加 err_ts 时漏的 set-e 容错.
        local err_ts oldest newest ts_info
        err_ts=$(echo "$recent_window" | grep -iE "$err_pattern" | \
                 grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}[T ][0-9]{2}:[0-9]{2}' | sort -u 2>/dev/null || true)
        if [ -n "$err_ts" ]; then
            oldest=$(echo "$err_ts" | head -1)
            newest=$(echo "$err_ts" | tail -1)
            if [ "$oldest" = "$newest" ]; then
                ts_info=" (@ $oldest)"
            else
                ts_info=" (最早 $oldest, 最新 $newest)"
            fi
        else
            ts_info=" (时间戳缺失)"
        fi

        ALERTS+=("$job_name 日志: ${recent_fails}条错误${ts_info} → $last_err")
    fi
}

# 扫描 ~/.openclaw/logs/jobs/ 下的所有日志
LOG_DIR="$HOME/.openclaw/logs/jobs"
if [ -d "$LOG_DIR" ]; then
    for logfile in "$LOG_DIR"/*.log; do
        [ -f "$logfile" ] || continue
        job_name=$(basename "$logfile" .log)
        scan_logs "$logfile" "$job_name"
    done
fi

# 扫描 home 目录下的 job 日志（部分 job 日志不在 .openclaw/logs/jobs/ 下）
HOME_LOGS=(
    "$HOME/kb_evening.log|kb_evening"
    "$HOME/kb_inject.log|kb_inject"
    "$HOME/kb_review.log|kb_review"
    "$HOME/kb_embed.log|kb_embed"
    "$HOME/kb_trend.log|kb_trend"
    "$HOME/kb_dedup.log|kb_dedup"
    "$HOME/conv_quality.log|conv_quality"
    "$HOME/token_report.log|token_report"
    "$HOME/openclaw_backup.log|openclaw_backup"
    "$HOME/preference_learner.log|preference_learner"
    "$HOME/kb_dream.log|kb_dream"
    # V37.8.13: kb_harvest_chat.log 故意不加入 — 内容是 LLM 提炼的其他 log 历史错误
    # 字符串（如 "Proxy错误率达50.7%"），误报率 100%。状态通过 last_run_harvest_chat.json
    # 的 JOBS 数组条目跟踪即可。
)
for entry in "${HOME_LOGS[@]}"; do
    IFS='|' read -r logfile job_name <<< "$entry"
    # V37.9.58-hotfix4 (2026-05-12): scan_logs 内部偶尔 fail (V37.9.28 F1 加 err_ts pipe
    # 引入未防御 grep -oE → 无匹配时 pipefail + set -e abort). caller || ALERT 让
    # scan_logs internal fail 可观察但**不让整 watchdog abort**, 让后续 7 个维度
    # (服务健康/磁盘/lockdir/SLO/cron 心跳/Proxy 监控/KB 新鲜度) 都能跑.
    scan_logs "$logfile" "$job_name" || ALERTS+=("$job_name: scan_logs 内部异常 (V37.9.58-hotfix4: function 内部某 pipe 缺 \`|| true\` 兜底, 追踪中, watchdog 继续跑后续维度)")
done

# ════════════════════════════════════════════════════════════════════
# V37.9.59 新增: 日志 mtime 新鲜度检查 (LOG_FRESHNESS_JOBS)
# ════════════════════════════════════════════════════════════════════
# 9-11 个 jobs 没有 last_run.json 写入 (kb_embed/kb_trend/preference_learner/
# auto_deploy/wa_keepalive/mm_index/daily_ops_report/openclaw_backup/health_check/
# cron_canary/kb_status_refresh). 用 log mtime 替代 last_run 时间戳 — 粗粒度
# (无法区分跑了但失败 vs 没跑), 但比完全不监控好.
# V37.9.58-hotfix4 + V37.9.59 后 watchdog 监控覆盖率从 47% → ~92% (16+11/34).
check_log_freshness() {
    local logfile="$1"
    local max_silence="$2"
    local display_name="$3"
    local tier="$4"
    if [ ! -f "$logfile" ]; then
        # 文件不存在不一定是错 (如 health_check 周一才写). 静默跳过.
        return 0
    fi
    local LOG_MOD
    if [ "$(uname)" = "Darwin" ]; then
        LOG_MOD=$(stat -f %m "$logfile" 2>/dev/null || echo "0")
    else
        LOG_MOD=$(stat -c %Y "$logfile" 2>/dev/null || echo "0")
    fi
    local ELAPSED=$(( NOW_EPOCH - LOG_MOD ))
    if [ "$ELAPSED" -gt "$max_silence" ]; then
        # V37.9.60-hotfix2: 整数除法 UX 修复 — max_silence=600s 在 V37.9.59 永远显示 "0h 阈值 0h"
        # 修法: ELAPSED >= 3600 显示 h, 否则显示 m; max_silence 同理
        local ELAPSED_UNIT MAX_UNIT
        if [ "$ELAPSED" -ge 3600 ]; then
            ELAPSED_UNIT="$(( ELAPSED / 3600 ))h"
        else
            ELAPSED_UNIT="$(( ELAPSED / 60 ))m"
        fi
        if [ "$max_silence" -ge 3600 ]; then
            MAX_UNIT="$(( max_silence / 3600 ))h"
        else
            MAX_UNIT="$(( max_silence / 60 ))m"
        fi
        local msg="${display_name}: log ${ELAPSED_UNIT} 未更新 (阈值 ${MAX_UNIT}, 仅有 log mtime 监控, V37.9.59)"
        case "$tier" in
            core) CORE_ALERTS+=("🔴 ${msg}") ;;
            auxiliary) AUX_ALERTS+=("🟡 ${msg}") ;;
            *) EXP_ALERTS+=("⚪ ${msg}") ;;
        esac
        ALERTS+=("${display_name}: log ${ELAPSED_UNIT} 未更新（阈值 ${MAX_UNIT}）")
        STATS_WARN=$((STATS_WARN + 1))
    else
        STATS_PASS=$((STATS_PASS + 1))
    fi
    return 0
}

for entry in "${LOG_FRESHNESS_JOBS[@]}"; do
    IFS='|' read -r job_id logfile max_silence display_name tier <<< "$entry"
    check_log_freshness "$logfile" "$max_silence" "$display_name" "$tier" || \
        ALERTS+=("$job_id: log_freshness check 内部异常 (V37.9.59)")
done

# Adapter 日志特殊扫描（FALLBACK ALSO FAILED = 双路径全挂）
ADAPTER_LOG="$HOME/adapter.log"
if [ -f "$ADAPTER_LOG" ]; then
    adapter_critical=$(tail -100 "$ADAPTER_LOG" 2>/dev/null | grep -c "FALLBACK ALSO FAILED" || true)
    if [ "$adapter_critical" -gt 0 ]; then
        ALERTS+=("Adapter: 最近 ${adapter_critical} 次主备双失败（LLM 完全不可用）")
    fi
fi

# ════════════════════════════════════════════════════════════════════
# 3/8 三层服务健康（HTTP 探测）
# ════════════════════════════════════════════════════════════════════
check_service() {
    local name="$1"
    local url="$2"
    local timeout="${3:-5}"

    local http_code
    http_code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout "$timeout" --max-time "$timeout" "$url" 2>/dev/null || echo "000")

    if [ "$http_code" = "000" ]; then
        ALERTS+=("$name: 服务无响应（连接超时）")
    elif [ "$http_code" -ge 500 ] 2>/dev/null; then
        ALERTS+=("$name: 服务异常 (HTTP $http_code)")
    else
        STATS_PASS=$((STATS_PASS + 1))
    fi
}

check_service "Adapter(:5001)" "http://localhost:5001/health" 5
check_service "Proxy(:5002)" "http://localhost:5002/health" 5
check_service "Gateway(:18789)" "http://localhost:18789" 5

# ════════════════════════════════════════════════════════════════════
# 4/8 陈旧锁文件检测 + 自动清理
# ════════════════════════════════════════════════════════════════════
# 锁文件超过1小时 = 对应 job 进程已死但锁未释放
STALE_LOCK_DIRS=(
    "/tmp/arxiv_monitor.lockdir|ArXiv监控"
    "/tmp/hf_papers.lockdir|HF论文"
    "/tmp/semantic_scholar.lockdir|S2论文"
    "/tmp/dblp.lockdir|DBLP论文"
    "/tmp/acl_anthology.lockdir|ACL论文"
    "/tmp/hn_watcher.lockdir|HN抓取"
    "/tmp/freight_watcher.lockdir|货代Watcher"
    "/tmp/auto_deploy.lockdir|自动部署"
    "/tmp/openclaw_releases.lockdir|OpenClaw版本"
    "/tmp/openclaw_discussions.lockdir|Issues监控"
    "/tmp/run_discussions.lockdir|Issues监控(alt)"
    "/tmp/kb_review.lockdir|KB回顾"
    "/tmp/kb_evening.lockdir|KB晚间"
    # V37.9.58-hotfix3: 补齐 5 个监控盲区 (5/12 诊断发现 rss_blogs.lockdir 残留 45h 没人管)
    "/tmp/rss_blogs.lockdir|RSS博客"
    "/tmp/github_trending.lockdir|GitHub热门"
    "/tmp/ai_leaders_x.lockdir|AI领袖X"
    "/tmp/finance_news.lockdir|财经新闻"
    "/tmp/ontology_sources.lockdir|本体论信息源"
    # V37.9.59: 加 kb_dream lockdir (kb_dream 是唯一其他用 lockdir 的 KB job, dev grep 确认)
    "/tmp/kb_dream.lockdir|Agent Dream"
    "/tmp/chaspark.lockdir|Chaspark学术"
)

STALE_CLEANED=0
for entry in "${STALE_LOCK_DIRS[@]}"; do
    IFS='|' read -r lock_path name <<< "$entry"
    if [ -d "$lock_path" ]; then
        if [ "$(uname)" = "Darwin" ]; then
            LOCK_EPOCH=$(stat -f %m "$lock_path" 2>/dev/null || echo "0")
        else
            LOCK_EPOCH=$(stat -c %Y "$lock_path" 2>/dev/null || echo "0")
        fi
        LOCK_AGE=$(( NOW_EPOCH - LOCK_EPOCH ))
        if [ "$LOCK_AGE" -gt 3600 ]; then
            LOCK_HOURS=$(( LOCK_AGE / 3600 ))
            rmdir "$lock_path" 2>/dev/null || rm -rf "$lock_path" 2>/dev/null
            ALERTS+=("$name: 陈旧锁文件已清理（存在 ${LOCK_HOURS}h，进程已死）")
            STALE_CLEANED=$((STALE_CLEANED + 1))
        fi
    fi
done

if [ "$STALE_CLEANED" -gt 0 ]; then
    ALERTS+=("🔧 共清理 $STALE_CLEANED 个陈旧锁文件，对应 job 将在下次 cron 触发时恢复")
fi

# ════════════════════════════════════════════════════════════════════
# 5/8 Cron 健康（心跳 + crontab 条目数）
# ════════════════════════════════════════════════════════════════════

# 5a. Cron 心跳金丝雀（cron_canary.sh 每10分钟写一次）
CANARY_FILE="$HOME/.cron_canary"
if [ -f "$CANARY_FILE" ]; then
    CANARY_EPOCH=$(head -1 "$CANARY_FILE" 2>/dev/null | tr -d '[:space:]')
    if [[ "$CANARY_EPOCH" =~ ^[0-9]+$ ]]; then
        CANARY_AGE=$(( NOW_EPOCH - CANARY_EPOCH ))
        if [ "$CANARY_AGE" -gt 1800 ]; then
            CANARY_MINS=$(( CANARY_AGE / 60 ))
            ALERTS+=("⚠️ Cron 心跳已 ${CANARY_MINS}m 未更新（cron daemon 可能已停止！）")
        else
            STATS_PASS=$((STATS_PASS + 1))
        fi
    fi
else
    ALERTS+=("⚠️ Cron 心跳文件不存在（cron_canary.sh 未配置？）")
fi

# 5c. V37.9.59 watchdog 自监控 — 检查上次自己的 canary 时间戳
# 元监控盲区: V37.9.58-hotfix3 加 canary 但只是文件写入, 没有"watchdog 自身死亡"
# 检测路径. V37.9.59 加自检: 上次 watchdog canary 是否在合理周期内 (cron 每 4h 触发,
# 阈值 12h = 3 周期 slack). 若 watchdog 因新 silent bug 死亡, 自监控立即告警.
# 注意: 这是 chicken-and-egg, 若 watchdog 自身彻底死亡此检查也不会跑.
# 真正的元监控应该是 cron_canary.sh / kb_status_refresh.sh 等外部 cron 检查
# watchdog_canary.json mtime, 但避免引入新依赖, V37.9.59 先做 watchdog 内部自检.
WATCHDOG_CANARY="$HOME/watchdog_canary.json"
if [ -f "$WATCHDOG_CANARY" ]; then
    WD_CANARY_MTIME=$(stat -f %m "$WATCHDOG_CANARY" 2>/dev/null || echo "0")
    WD_CANARY_AGE=$(( NOW_EPOCH - WD_CANARY_MTIME ))
    # 阈值: 12h (watchdog cron 每 4h 触发, 允许 2 次 miss 后告警)
    if [ "$WD_CANARY_AGE" -gt 43200 ]; then
        WD_CANARY_HOURS=$(( WD_CANARY_AGE / 3600 ))
        # 注意: 上次 watchdog 跑后, 这次本身就在更新 canary, 所以正常情况下 age 应该
        # 远小于 4h (距上次 cron). 若 > 12h 说明上次或多次 cron 触发 watchdog 但
        # silent abort 没写 canary, 是 V37.9.58-hotfix4 set -E + caller || ALERT 后
        # 仍有未捕获的 silent failure 路径. MR-19 第二次演出.
        ALERTS+=("🚨 watchdog 自身: canary ${WD_CANARY_HOURS}h 未更新 (阈值 12h, 上次 cron 后未写 canary, 疑似 V37.9.58-hotfix4 之后仍有 silent abort 路径). MR-19 第二次演出, V37.9.59+ 治本候选")
    else
        STATS_PASS=$((STATS_PASS + 1))
    fi
else
    # canary 不存在: 可能 V37.9.58-hotfix3/4 还没部署, 或 watchdog 从未正常完成过
    ALERTS+=("⚠️ watchdog 自身: canary 文件不存在 (~/watchdog_canary.json), V37.9.58-hotfix3 元监控未就位或 watchdog 从未正常完成")
fi

# 5b. Crontab 条目数检查（V30 事故防护：crontab 被意外清空）
CRON_COUNT=$(crontab -l 2>/dev/null | grep -cvE "^#|^$" || echo "0")
if [ "$CRON_COUNT" -lt 10 ]; then
    ALERTS+=("⚠️ Crontab 仅 $CRON_COUNT 条有效条目（应≥15，可能被意外清空！）")
elif [ "$CRON_COUNT" -lt 15 ]; then
    STATS_WARN=$((STATS_WARN + 1))
else
    STATS_PASS=$((STATS_PASS + 1))
fi

# ════════════════════════════════════════════════════════════════════
# 6/8 Proxy 运行时监控（连续错误 + context 用量 + 吞吐量）
# ════════════════════════════════════════════════════════════════════
PROXY_STATS="$HOME/proxy_stats.json"
if [ -f "$PROXY_STATS" ]; then
    PROXY_CHECK=$(python3 -c "
import json, time
from datetime import datetime, timedelta
try:
    with open('$PROXY_STATS') as f:
        s = json.load(f)
    alerts = []

    # 连续错误检查
    ce = s.get('consecutive_errors', 0)
    if ce >= 3:
        last_err = s.get('last_error', {})
        alerts.append(f'Proxy 连续 {ce} 次错误 (HTTP {last_err.get(\"code\",\"?\")}: {last_err.get(\"msg\",\"\")[:60]})')

    # Context 用量检查
    pct = s.get('context_usage_pct', 0)
    pt = s.get('last_prompt_tokens', 0)
    if pct >= 90:
        alerts.append(f'Qwen context 临界: {pt:,} tokens ({pct}% of 260K)')
    elif pct >= 75:
        alerts.append(f'Qwen context 预警: {pt:,} tokens ({pct}% of 260K)')

    # stats 文件本身过期（proxy 可能挂了）
    updated = s.get('updated', '')
    if updated:
        try:
            ut = datetime.strptime(updated, '%Y-%m-%d %H:%M:%S')
            age_hours = (datetime.now() - ut).total_seconds() / 3600
            if age_hours > 4:
                alerts.append(f'proxy_stats.json 超过{int(age_hours)}h未更新（proxy可能已停止）')
            elif age_hours > 2:
                alerts.append(f'proxy_stats.json {int(age_hours)}h未更新（proxy活跃度下降）')
        except ValueError:
            pass

    # 请求吞吐量检查（total_requests 为 0 → proxy 可能从未成功处理请求）
    total = s.get('total_requests', 0)
    errors = s.get('total_errors', 0)
    if total > 0 and errors > 0:
        error_rate = errors / total * 100
        if error_rate > 20:
            alerts.append(f'Proxy 错误率 {error_rate:.1f}%（{errors}/{total}）')

    print('\\n'.join(alerts))
except Exception as e:
    print(f'proxy_stats.json 读取失败: {e}')
" 2>/dev/null)

    while IFS= read -r line; do
        [ -n "$line" ] && ALERTS+=("$line")
    done <<< "$PROXY_CHECK"

    # SLO 合规检查（V33: 接入 slo_checker.py）
    SLO_SCRIPT="$HOME/slo_checker.py"
    if [ -f "$SLO_SCRIPT" ]; then
        # V37.9.66-hotfix: bash quirk `cmd && X || Y` + set -eE + ERR trap 即使 set -e 豁免不杀
        # 脚本, ERR trap 仍触发产 false-positive FATAL. 5/13 16:30 实测真触发 line 721 abort.
        # 当时改 if-then-else (bash 文档豁免 if condition + set -e + ERR trap).
        # V37.9.131: V37.9.105-hotfix 同款第 3 处演出 — macOS bash 3.2 + set -E (errtrace)
        # 让 $(...) 子 shell 继承 ERR trap. slo_checker --alert 设计性 exit 2 (SLO 违规 +
        # 告警文本) 时 python 在子 shell 内非零退出 → 子 shell 内 ERR trap 误触发 FATAL 误报
        # (2026-06-10 20:30 line=728 实证: harvest 9 chunks 大请求拉高 p95=62704ms 首次走此
        # 路径; watchdog 主体未死, canary 正常 — V37.9.105-hotfix governance_audit 同款铁证).
        # V37.9.66-hotfix 的 if 形式只豁免外层 condition, 救不了子 shell 内部继承的 trap.
        # 修复: set +E 关 errtrace 让子 shell 不继承; || 捕获让 set -e 安全 (governance_audit
        # line 79-81 同款模式). 横向已扫 7 个 governed scripts 仅此一处漏网.
        SLO_RC=0
        set +E
        SLO_ALERT=$(python3 "$SLO_SCRIPT" --alert 2>/dev/null) || SLO_RC=$?
        set -E
        if [ "$SLO_RC" -eq 2 ] && [ -n "$SLO_ALERT" ]; then
            while IFS= read -r line; do
                [ -n "$line" ] && ALERTS+=("$line")
            done <<< "$SLO_ALERT"
        elif [ "$SLO_RC" -eq 0 ]; then
            STATS_PASS=$((STATS_PASS + 1))
        fi
    fi
fi

# ════════════════════════════════════════════════════════════════════
# 7/8 磁盘空间检查
# ════════════════════════════════════════════════════════════════════
DISK_PCT=$(df -h / 2>/dev/null | awk 'NR==2 {gsub(/%/,""); print $5}')
if [ -n "$DISK_PCT" ] && [ "$DISK_PCT" -ge 95 ] 2>/dev/null; then
    ALERTS+=("磁盘空间严重不足: 已用 ${DISK_PCT}%（>95%，job 可能写入失败）")
elif [ -n "$DISK_PCT" ] && [ "$DISK_PCT" -ge 85 ] 2>/dev/null; then
    STATS_WARN=$((STATS_WARN + 1))
else
    STATS_PASS=$((STATS_PASS + 1))
fi

# ════════════════════════════════════════════════════════════════════
# 8/8 KB 数据新鲜度（全部 ingestion 停止的终极指标）
# ════════════════════════════════════════════════════════════════════
KB_SOURCES="$HOME/.kb/sources"
if [ -d "$KB_SOURCES" ]; then
    # 检查最近 48h 内是否有新的 source 文件生成
    if [ "$(uname)" = "Darwin" ]; then
        RECENT_KB=$(find "$KB_SOURCES" -name "*.md" -mtime -2 2>/dev/null | wc -l | tr -d ' ')
    else
        RECENT_KB=$(find "$KB_SOURCES" -name "*.md" -mmin -2880 2>/dev/null | wc -l | tr -d ' ')
    fi

    if [ "$RECENT_KB" -eq 0 ]; then
        ALERTS+=("KB 数据停滞: 48h 内无新 source 文件（所有 ingestion job 可能已失效）")
    else
        STATS_PASS=$((STATS_PASS + 1))
    fi
fi

# KB 向量索引新鲜度
KB_INDEX="$HOME/.kb/text_index/meta.json"
if [ -f "$KB_INDEX" ]; then
    if [ "$(uname)" = "Darwin" ]; then
        IDX_MOD=$(stat -f %m "$KB_INDEX" 2>/dev/null || echo "0")
    else
        IDX_MOD=$(stat -c %Y "$KB_INDEX" 2>/dev/null || echo "0")
    fi
    IDX_AGE=$(( NOW_EPOCH - IDX_MOD ))
    # kb_embed 每4小时运行，12小时未更新 = 异常
    if [ "$IDX_AGE" -gt 43200 ]; then
        IDX_HOURS=$((IDX_AGE / 3600))
        ALERTS+=("KB 向量索引 ${IDX_HOURS}h 未更新（kb_embed 可能未运行）")
    fi
fi

# ── 9. MOVESPEED incidents 累积监控（V37.9.26 主动告警升级）──────────
# V37.9.14 已落地 20 处 rsync fail-loud → movespeed_incident_capture.sh 写
# JSONL 取证. V37.9.26 升级被动证据收集为主动告警: 24h 内 ≥5 条 incident
# 视为 "B 问题（exfat fskit transient EPERM）连续性复发", 推 [SYSTEM_ALERT].
# 阈值 5 来自 V37.9.4 案例一周内 18 次 rsync 失败的经验值 (24h 平均 ~3 次为
# 噪声基线; ≥5 视为异常爆发).
INCIDENT_FILE="$HOME/.kb/movespeed_incidents.jsonl"
INCIDENT_24H_THRESHOLD=5  # 24h 窗口内 incident ≥ 此值触发告警
INCIDENT_MONITOR="$(cd "$(dirname "$0")" && pwd)/movespeed_incident_monitor.py"
if [ -f "$INCIDENT_FILE" ] && [ -f "$INCIDENT_MONITOR" ]; then
    # 调独立模块 (V37.9.26: 一处真理源 + 单测覆盖) 替代 inline heredoc
    INCIDENT_24H_RESULT=$(python3 "$INCIDENT_MONITOR" "$INCIDENT_FILE" "$NOW_EPOCH" "$INCIDENT_24H_THRESHOLD" 2>/dev/null || echo "ERROR")
    if [ "$INCIDENT_24H_RESULT" = "ERROR" ]; then
        # python 解析失败 (理论不应发生, defensive)
        ALERTS+=("MOVESPEED incidents 解析失败 (非阻塞, 检查 ~/.kb/movespeed_incidents.jsonl 格式)")
    else
        # 解析 result: count|threshold_hit|callers
        INCIDENT_COUNT="${INCIDENT_24H_RESULT%%|*}"
        REST="${INCIDENT_24H_RESULT#*|}"
        THRESHOLD_HIT="${REST%%|*}"
        INCIDENT_CALLERS="${REST#*|}"
        if [ "$THRESHOLD_HIT" = "1" ]; then
            ALERTS+=("MOVESPEED 24h 内 ${INCIDENT_COUNT} 次 rsync 失败（≥${INCIDENT_24H_THRESHOLD} 阈值，B 问题连续性复发）callers: ${INCIDENT_CALLERS}")
        fi
    fi
fi

# ════════════════════════════════════════════════════════════════════
# 汇总告警
# ════════════════════════════════════════════════════════════════════
TOTAL_CHECKS=$((STATS_PASS + STATS_WARN + ${#ALERTS[@]}))

if [ ${#ALERTS[@]} -eq 0 ]; then
    echo "[$TS] watchdog: 全部 $TOTAL_CHECKS 项检查通过（${#JOBS[@]} job + 3 服务 + cron + proxy + 磁盘 + KB）"
    exit 0
fi

# 组装告警消息（带严重程度分级）
CRITICAL_COUNT=0
WARNING_COUNT=0
for a in "${ALERTS[@]}"; do
    case "$a" in
        *"服务无响应"*|*"双失败"*|*"cron daemon"*|*"磁盘空间严重"*|*"被意外清空"*)
            CRITICAL_COUNT=$((CRITICAL_COUNT + 1))
            ;;
        *)
            WARNING_COUNT=$((WARNING_COUNT + 1))
            ;;
    esac
done

if [ "$CRITICAL_COUNT" -gt 0 ]; then
    SEVERITY="🔴 CRITICAL"
else
    SEVERITY="🟡 WARNING"
fi

ALERT_MSG="🚨 系统监控告警 $SEVERITY ($TS)
检查: $TOTAL_CHECKS 项 | 通过: $STATS_PASS | 告警: ${#ALERTS[@]} (core:${#CORE_ALERTS[@]} aux:${#AUX_ALERTS[@]} exp:${#EXP_ALERTS[@]})"

# 按 tier 分组展示：core 先行
if [ ${#CORE_ALERTS[@]} -gt 0 ]; then
    ALERT_MSG+="

🔴 CORE（立即处理）:"
    for a in "${CORE_ALERTS[@]}"; do
        ALERT_MSG+="
• $a"
    done
fi

if [ ${#AUX_ALERTS[@]} -gt 0 ]; then
    ALERT_MSG+="

🟡 AUXILIARY（关注）:"
    for a in "${AUX_ALERTS[@]}"; do
        ALERT_MSG+="
• $a"
    done
fi

if [ ${#EXP_ALERTS[@]} -gt 0 ]; then
    ALERT_MSG+="

⚪ EXPERIMENT（仅记录）:"
    for a in "${EXP_ALERTS[@]}"; do
        ALERT_MSG+="
• $a"
    done
fi

# 非 job 告警（服务/磁盘/cron 等）单独展示
NON_JOB_ALERTS=()
for a in "${ALERTS[@]}"; do
    # 跳过已在 tier 分组中展示的 job 告警
    is_job=false
    for c in "${CORE_ALERTS[@]}" "${AUX_ALERTS[@]}" "${EXP_ALERTS[@]}"; do
        # tier 告警带前缀，去掉前缀后比较
        stripped="${c#🔴 }"
        stripped="${stripped#🟡 }"
        stripped="${stripped#⚪ }"
        if [ "$a" = "$stripped" ]; then
            is_job=true
            break
        fi
    done
    if ! $is_job; then
        NON_JOB_ALERTS+=("$a")
    fi
done

if [ ${#NON_JOB_ALERTS[@]} -gt 0 ]; then
    ALERT_MSG+="

🔧 系统告警:"
    for a in "${NON_JOB_ALERTS[@]}"; do
        ALERT_MSG+="
• $a"
    done
fi

ALERT_MSG+="

排查建议：
1. python3 ~/incident_snapshot.py --list  # 查看故障快照
2. bash cron_doctor.sh     # 全面诊断
3. curl localhost:5002/health  # Proxy健康
4. curl localhost:5001/health  # Adapter健康
5. rmdir /tmp/*.lockdir    # 清除残留锁
6. crontab -l | wc -l     # 确认调度条目"

echo "$ALERT_MSG"

# ── 故障快照：告警时自动收集系统状态 ──
# 仅在有 CORE 告警或 CRITICAL 告警时触发（避免低级别告警频繁快照）
if [ "$CRITICAL_COUNT" -gt 0 ] || [ "${#CORE_ALERTS[@]}" -gt 0 ]; then
    SNAPSHOT_DESC="watchdog: ${#ALERTS[@]} alerts (${CRITICAL_COUNT} critical, ${#CORE_ALERTS[@]} core)"
    python3 "$HOME/incident_snapshot.py" --auto "$SNAPSHOT_DESC" 2>/dev/null && {
        echo "[watchdog] 故障快照已创建"
    } || {
        echo "[watchdog] 故障快照创建失败（非致命）"
    }
fi

# 推送告警（使用 notify.sh 自动重试 + 失败队列）
ALERT_LOG="$HOME/.openclaw_alerts.log"
NOTIFY_SCRIPT="$(cd "$(dirname "$0")" && pwd)/notify.sh"
if [ -f "$NOTIFY_SCRIPT" ]; then
    source "$NOTIFY_SCRIPT"
    notify "$ALERT_MSG" --topic alerts || {
        echo "[$TS] watchdog: ⚠️ 推送失败（已入队待重放），写入本地告警文件"
        echo "=== UNDELIVERED ALERT [$TS] ===" >> "$ALERT_LOG"
        echo "$ALERT_MSG" >> "$ALERT_LOG"
        echo "================================" >> "$ALERT_LOG"
    }
else
    # fallback: 直接发送（notify.sh 不可用时）
    "$OPENCLAW" message send --channel whatsapp --target "$TO" --message "$ALERT_MSG" --json >/dev/null 2>&1 || {
        echo "[$TS] watchdog: ⚠️ WhatsApp 推送失败，写入本地告警文件"
        echo "=== UNDELIVERED ALERT [$TS] ===" >> "$ALERT_LOG"
        echo "$ALERT_MSG" >> "$ALERT_LOG"
        echo "================================" >> "$ALERT_LOG"
    }
    "$OPENCLAW" message send --channel discord --target "${DISCORD_CH_ALERTS:-}" --message "$ALERT_MSG" --json >/dev/null 2>&1 || true
fi

# 本地告警文件始终写入（供 cron_doctor / SSH 检查时查看）
echo "[$TS] ALERT: ${#ALERTS[@]} issues (${CRITICAL_COUNT} critical, ${WARNING_COUNT} warning)" >> "$ALERT_LOG"
# 保留最近 500 行
if [ -f "$ALERT_LOG" ] && [ "$(wc -l < "$ALERT_LOG" | tr -d ' ')" -gt 500 ]; then
    tail -300 "$ALERT_LOG" > "$ALERT_LOG.tmp" && mv "$ALERT_LOG.tmp" "$ALERT_LOG"
fi
