diff --git a/deploy/profile-scripts-sync.path b/deploy/profile-scripts-sync.path new file mode 100644 index 00000000..9f7343a8 --- /dev/null +++ b/deploy/profile-scripts-sync.path @@ -0,0 +1,9 @@ +[Unit] +Description=Watch deploy/profile-scripts for changes and re-sync hardlinks + +[Path] +PathChanged=/home/hmo/MoFin/deploy/profile-scripts +Unit=profile-scripts-sync.service + +[Install] +WantedBy=multi-user.target diff --git a/deploy/profile-scripts-sync.service b/deploy/profile-scripts-sync.service new file mode 100644 index 00000000..587aa7ab --- /dev/null +++ b/deploy/profile-scripts-sync.service @@ -0,0 +1,7 @@ +[Unit] +Description=Re-sync profile scripts hardlinks after deploy dir changes + +[Service] +Type=oneshot +User=hmo +ExecStart=/bin/bash /home/hmo/MoFin/deploy/profile-scripts/sync_profile_scripts.sh diff --git a/deploy/profile-scripts/sync_profile_scripts.sh b/deploy/profile-scripts/sync_profile_scripts.sh index a62832a4..96d81d44 100644 --- a/deploy/profile-scripts/sync_profile_scripts.sh +++ b/deploy/profile-scripts/sync_profile_scripts.sh @@ -10,4 +10,7 @@ for f in "$SRC"/*.py; do ln -f "$f" "$DST/$name" count=$((count+1)) done -echo "synced $count scripts (hardlink)" \ No newline at end of file +ln -f "$SRC/sync_profile_scripts.sh" "$DST/sync_profile_scripts.sh" 2>/dev/null || true +echo "synced $count scripts (hardlink)" +mkdir -p /home/hmo/MoFin/gateway/logs +echo "$(date '+%F %T') synced $count scripts" >> /home/hmo/MoFin/gateway/logs/link_sync.log \ No newline at end of file diff --git a/docs/dev-spec.md b/docs/dev-spec.md index 065f36cd..eb2ee9b9 100644 --- a/docs/dev-spec.md +++ b/docs/dev-spec.md @@ -13,7 +13,11 @@ 3. **不可见即不存在** — 组件不在 Dashboard 中显示 = 等于没部署。离线不告警 = 监控缺陷 4. **实现后同步 Spec** — 每轮开发完毕后,必须将 `specs/{module}.json` 更新为与实际实现一致的状态。文档过期 = 等于没写 5. **部署目标即验收标准** — 所有代码必须以部署目标环境(Linux 246)为基准编写和测试。禁止使用 Windows 专属 API(`tasklist`、`netstat`、`schtasks`、`wmic`)在 246 部署的代码中 -6. **单一事实源(SSOT)** — 每个文件全系统只有一个权威位置,其他位置只允许硬链接(同 inode),**禁止独立副本**。权威位置:`deploy/profile-scripts/`(cron 脚本)、`/home/hmo/MoFin/`(被 import 的库)、`deploy/bot/`(XMPP bot)。修改任何文件后若存在硬链接关系被破坏(scp/编辑器换 inode),必须立即跑 `deploy/profile-scripts/sync_profile_scripts.sh` 重建 +6. **单一事实源(SSOT)** — 每个文件全系统只有一个权威位置,其他位置只允许硬链接(同 inode),**禁止独立副本**。权威位置:`deploy/profile-scripts/`(cron 脚本)、`/home/hmo/MoFin/`(被 import 的库)、`deploy/bot/`(XMPP bot)。硬链接破坏只会发生在部署动作(scp 替换文件 / git checkout/merge),因此部署链路自带三层自动修复,不需要靠记性: + - **systemd path watcher**(`profile-scripts-sync.path`):监听 `deploy/profile-scripts/` 目录变化,任何 scp/替换自动触发 `sync_profile_scripts.sh` + - **git hooks**(`.git/hooks/post-merge` + `post-checkout`):git 操作后自动重链 + - **手动兜底**:`bash deploy/profile-scripts/sync_profile_scripts.sh`(改完文件随手跑) + - 同步日志:`gateway/logs/link_sync.log`;断链检测同时是 L2 卫生审计的检查项 7. **数据路径必须绝对** — 引用数据文件/数据库时,必须写绝对路径并指向权威位置(`/home/hmo/MoFin/data/`)。**禁止**用 `Path(__file__).parent / "data"` 这类相对解析——同一个模块被硬链接到不同位置时会解析出不同的数据库(2026-07-20 三库事件的根因) 8. **备份/遗留物禁止留在生产数据目录** — `.bak`、`decisions_backup_*`、迁移残留 JSON、废弃 DB,必须在迁移/变更完成时移到 `archive/`。生产数据目录(`MoFin/data` = `web-dashboard/data`)只放活文件。监控脚本扫描生产目录时,遗留物就是未来的假警报 9. **死模块必须收尸** — 宣布模块废弃时,必须在同一轮操作中完成收尸六步:①杀进程 ②stop+disable systemd 服务 ③删 cron job ④归档脚本到 `archive/` ⑤归档数据文件 ⑥从期望矩阵/监控中移除。只说"已废弃"不收尸 = 没废弃(小果 bot 以 root 白跑 8 天 2.5GB 的教训) diff --git a/scripts/git-hook-post-merge.sh b/scripts/git-hook-post-merge.sh new file mode 100644 index 00000000..a95e654f --- /dev/null +++ b/scripts/git-hook-post-merge.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# git post-merge / post-checkout hook — 自动重建硬链接 +# git 操作(merge/checkout/rebase)会替换文件、破坏硬链接,钩子自动修复 +REPO="/home/hmo/MoFin" +if [ -x "$REPO/deploy/profile-scripts/sync_profile_scripts.sh" ]; then + bash "$REPO/deploy/profile-scripts/sync_profile_scripts.sh" >/dev/null 2>&1 + echo "$(date '+%F %T') auto-synced by git $1 hook" >> "$REPO/gateway/logs/link_sync.log" +fi +exit 0 diff --git a/scripts/notify_user7.py b/scripts/notify_user7.py new file mode 100644 index 00000000..26f7e02f --- /dev/null +++ b/scripts/notify_user7.py @@ -0,0 +1,69 @@ +import json, urllib.request + +msg = """[笑笑] 自检体系 L0-L4 分层架构 — 建成并验证 + +按你的四点要求重构完成: + +■ 责任矩阵(每层职责单一,不重叠不疏漏) + +L0 执行心跳 — agents_health_check.py(5min) + 职责:端口/HTTP/DB 存活 + auto_heal 执行(gateway重启/key切换/bot重启) + +L1 功能健康 — functional_health_check.py(交易时段15min) + 职责:判据是"功能是否达成"不是"进程活着"。9个核心模块各有注册判据: + · price_monitor → live_prices 最新记录 <6min + · market_watch → market_snapshots <40min + · mtf_cache → <75min + · macro_context → <45min + · 健康采集 → mofin_health.json <20min + · 盘前重评 → summary <26h + · LLM → agent.log 有成功调用 + · bot → systemctl active + 无断线循环 + · cron引擎 → 最近12min有job运行 + +L2 系统卫生 — system_hygiene_audit.py(每日08:20,已从每周改每日) + 职责:分叉副本/断裂硬链接/僵尸进程/孤儿文件/死cron/DB新鲜度 + +L3 LLM修复循环 — self_repair.py(30min) + 职责:读 L1/L2 失败项 → LLM 诊断 → 直接修复(报备制) + +L4 元监控 — meta_watchdog.py(每小时) + 职责:自检系统的自检——L0-L3 输出物新鲜度 + L3注册状态 + XMPP桥。 + 任何一层死亡直接 XMPP 点名(最后兜底) + +已退休重叠组件:Cron监护-高频(并入L3)、全局cron健康监控(并入L1) + +■ 报备制(不是报告了事) + +L3 的纪律:发现问题 → 直接修 → 记录 repair_log.jsonl → XMPP 报备。 +LLM 只许选白名单动作(rerun_script/restart_service/sync_links/switch_llm_key/none), +不许任意执行代码;每模块每天≤2次防修复循环;白名单兜不住会明说"需要人工"。 + +■ 端到端验证(真实测试) + +1. 我手动 stop xmpp bot → L1 立即报 fail(bot 服务非 active) +2. systemd Restart=always 自动拉起 → L3 LLM 诊断后正确判定"无需动作"并记录日志 +3. rerun_script 白名单路径实测:真实重跑 mofin_health.py 成功 +4. meta_watchdog 复检:L0-L4 全绿 +5. 第一版 bot 判据被我自己抓出 bug(信30分钟前日志不看当前状态),已修正为"先看 is-active" + +■ F健康整合 + +mofin_health.html 新增 🩺自检体系 Tab(在 功能树/全部Cron/数据实体/数据流 之后): +· L4 元监控各层状态表 +· L1 九项功能判据逐项状态 +· L2 卫生问题清单 +· L3 最近修复动作时间线(报备记录) + +你打开 Dashboard → 健康 → 🩺自检体系 就能看到系统各方各面的健康状态, +以及自检系统自身的运转情况。责任矩阵已写入 dev-spec.md 第三章。 + +代码已提交合并(246: e69109fd)。""" + +payload = json.dumps({"to": "hmo@yoin.fun", "body": msg, "type": "chat"}).encode() +req = urllib.request.Request("http://127.0.0.1:5805/", data=payload, + headers={"Content-Type": "application/json"}) +try: + print("XMPP:", urllib.request.urlopen(req, timeout=10).read().decode()[:80]) +except Exception as e: + print("XMPP fail:", e) \ No newline at end of file diff --git a/scripts/verify_deployment.py b/scripts/verify_deployment.py new file mode 100644 index 00000000..7d277dd2 --- /dev/null +++ b/scripts/verify_deployment.py @@ -0,0 +1,118 @@ +import json, os, subprocess, hashlib +from datetime import datetime + +now = datetime.now() +ok_count = 0 +fail_items = [] + +def check(name, ok, detail=''): + global ok_count + icon = '✅' if ok else '❌' + print(f'{icon} {name}: {detail}') + if ok: + ok_count += 1 + else: + fail_items.append(name) + +def md5(p): + try: + return hashlib.md5(open(p, 'rb').read()).hexdigest()[:10] + except Exception: + return 'ERR' + +print('=== 1. 新脚本文件部署(deploy + profile scripts 硬链接一致)===') +NEW_SCRIPTS = ['functional_health_check.py', 'self_repair.py', 'meta_watchdog.py', + 'system_hygiene_audit.py', 'sync_profile_scripts.sh', 'mofin_health.py'] +for f in NEW_SCRIPTS: + dp = f'/home/hmo/MoFin/deploy/profile-scripts/{f}' + pp = f'/home/hmo/.hermes/profiles/position-analyst/scripts/{f}' + d_ok = os.path.exists(dp) + p_ok = os.path.exists(pp) + same = d_ok and p_ok and md5(dp) == md5(pp) + check(f, same, f'deploy={d_ok} profile={p_ok} 内容一致={same}') + +print() +print('=== 2. cron 注册(L1/L2/L3/L4 jobs)===') +d = json.load(open('/home/hmo/.hermes/profiles/position-analyst/cron/jobs.json')) +jobs = d if isinstance(d, list) else d.get('jobs', []) +expect = { + 'functional_health_check.py': 'L1功能健康(15min)', + 'self_repair.py': 'L3修复循环(30min)', + 'meta_watchdog.py': 'L4元监控(每小时)', + 'system_hygiene_audit.py': 'L2卫生(每日)', +} +for script, label in expect.items(): + j = next((x for x in jobs if x.get('script') == script), None) + if not j: + check(label, False, '未注册') + else: + sched = j.get('schedule_display') or str(j.get('schedule')) + en = j.get('enabled', True) + check(label, bool(j and en), f'registered sched={sched} enabled={en}') + +# 退休的 job 不在 +for dead in ['Cron监护-高频', '全局cron健康监控-每10分']: + j = next((x for x in jobs if x.get('name') == dead), None) + check(f'退休job[{dead}]已删除', j is None, '已删除' if j is None else '仍存在!') + +print() +print('=== 3. 输出物生成 ===') +for name, path, max_h in [ + ('functional_health.json', '/home/hmo/MoFin/gateway/logs/functional_health.json', 1), + ('hygiene_report.json', '/home/hmo/MoFin/gateway/logs/hygiene_report.json', 26), + ('meta_watchdog.json', '/home/hmo/MoFin/gateway/logs/meta_watchdog.json', 2), + ('mofin_health.json', '/home/hmo/web-dashboard/static/mofin_health.json', 1), +]: + if not os.path.exists(path): + check(name, False, '不存在') + else: + age_h = (now.timestamp() - os.path.getmtime(path)) / 3600 + check(name, age_h < max_h, f'{age_h:.1f}h前生成(阈值{max_h}h)') + +print() +print('=== 4. mofin_health.json 含 self_check 区块 ===') +try: + mh = json.load(open('/home/hmo/web-dashboard/static/mofin_health.json')) + sc = mh.get('self_check', {}) + check('self_check.functional', bool(sc.get('functional')), str(bool(sc.get('functional')))) + check('self_check.meta_watchdog', bool(sc.get('meta_watchdog')), str(bool(sc.get('meta_watchdog')))) + check('self_check.hygiene', bool(sc.get('hygiene')), str(bool(sc.get('hygiene')))) +except Exception as e: + check('mofin_health.json', False, str(e)) + +print() +print('=== 5. Dashboard 页面含自检体系 Tab ===') +html = open('/home/hmo/web-dashboard/static/mofin_health.html', encoding='utf-8').read() +check('🩺自检体系 tab', '自检体系' in html and 'panel4' in html, '') +r = subprocess.run(['curl', '-s', '-o', '/dev/null', '-w', '%{http_code}', + 'http://127.0.0.1:8899/mofin_health.html'], capture_output=True, text=True, timeout=10) +check('dashboard 页面 200', r.stdout.strip() == '200', f'HTTP {r.stdout.strip()}') + +print() +print('=== 6. systemd 超时配置 ===') +r1 = subprocess.run(['sudo', '-n', 'systemctl', 'show', 'hermes-gateway-zhiwei', '-p', 'Environment'], + capture_output=True, text=True, timeout=10) +check('zhiwei HERMES_CRON_SCRIPT_TIMEOUT=600', 'HERMES_CRON_SCRIPT_TIMEOUT=600' in r1.stdout, '') +r2 = subprocess.run(['systemctl', '--user', 'show', 'hermes-gateway', '-p', 'Environment'], + capture_output=True, text=True, timeout=10) +check('default HERMES_CRON_SCRIPT_TIMEOUT=600', 'HERMES_CRON_SCRIPT_TIMEOUT=600' in r2.stdout, '') + +print() +print('=== 7. 小果残留 ===') +r = subprocess.run(['ps', 'aux'], capture_output=True, text=True, timeout=10) +xg = [l for l in r.stdout.splitlines() if 'xiaoguo' in l.lower() and 'grep' not in l] +check('小果进程清零', len(xg) == 0, f'{len(xg)}个残留' if xg else '无') +r = subprocess.run(['ss', '-tlnp'], capture_output=True, text=True, timeout=10) +check('8645 已关闭', '8645' not in r.stdout, '') + +print() +print('=== 8. 服务全景 ===') +r = subprocess.run(['ss', '-tlnp'], capture_output=True, text=True, timeout=10) +for port, label in [('8642', 'default gw'), ('8643', 'zhiwei gw'), ('8646', 'mohe gw'), + ('8899', 'dashboard'), ('5222', 'ejabberd'), ('5805', 'xmpp桥')]: + check(f'{label}:{port}', f':{port}' in r.stdout, '') + +print() +print(f'=== 汇总: {ok_count} 项通过, {len(fail_items)} 项失败 ===') +if fail_items: + print('失败项:', fail_items) \ No newline at end of file