feat(hygiene): anti-redundancy enforcement — spec rules + weekly audit

Root cause analysis of the 2026-07-20 redundancy incident:
1. No single-source-of-truth rule -> same file legitimately lived in 4+
   locations, diverging silently
2. Relative path resolution (Path(__file__).parent/'data') -> each
   hardlinked copy of mofin_db.py pointed to a DIFFERENT database
3. 'Backup habit' left .bak/legacy files in production dirs, which
   monitoring then scanned and reported as false alarms
4. Half-done migrations: DB tables created but old JSON writers/readers
   stayed (price_events), old files stayed
5. Dead modules never got buried: xiaoguo 'dead' but bot ran 8 days
   as root eating 2.5GB
6. Monitoring checked 'does it exist' not 'is it alive' -> stale file
   mtime reported as 'pipeline stalled 14 days' (false alarm)
7. No 'system hygiene' as a check category at all

Prevention implemented:
- dev-spec.md v2.0: 五条红线 -> 十条红线
  #6 single source of truth (hardlink only, no independent copies)
  #7 absolute data paths only (no __file__-relative data resolution)
  #8 no backups/legacy in production data dirs (archive immediately)
  #9 dead module burial checklist (6 mandatory steps)
  #10 monitor liveness (DB table freshness) not existence
- File Location Constitution: canonical location per content type
- NEW system_hygiene_audit.py: weekly Monday 07:30 cron checking
  diverged copies / broken hardlinks / zombie processes / orphan data
  files / dead cron scripts / DB freshness -> hygiene_report.json + XMPP
- specs/hygiene.json: module spec per red line #1
- Verified: audit found 5 real issues on first run, all fixed, re-run clean
This commit is contained in:
hmo
2026-07-20 19:04:05 +08:00
parent d5b8bec897
commit 4f83ee8a01
21 changed files with 1286 additions and 3 deletions
@@ -0,0 +1,266 @@
#!/usr/bin/env python3
"""system_hygiene_audit.py — 系统卫生审计(防冗余复发)
每周运行。检查六类问题,输出 hygiene_report.json,有问题时推 XMPP。
红线6/7/8/9/10 的自动化 enforcement。
检查项:
1. 分叉副本:同名 .py 在不同权威位置内容不一致
2. 断裂硬链接:deploy/profile-scripts vs profile scripts 内容不一致(cron 会跑旧代码!)
3. 僵尸进程:>3 天的 python/node 进程不在白名单
4. 孤儿数据文件:生产数据目录 >14 天未修改且不在活文件注册表
5. 死 cronjobs.json 中 script 不存在
6. DB 表新鲜度:核心表 >24h 无新记录(交易时间)
"""
import os, sys, json, glob, hashlib, sqlite3, subprocess
from datetime import datetime, timedelta
from pathlib import Path
sys.path.insert(0, '/home/hmo/MoFin')
DEPLOY = '/home/hmo/MoFin/deploy/profile-scripts'
PA_SCRIPTS = '/home/hmo/.hermes/profiles/position-analyst/scripts'
MOFIN_ROOT = '/home/hmo/MoFin'
DATA_DIR = '/home/hmo/MoFin/data'
REPORT = '/home/hmo/MoFin/gateway/logs/hygiene_report.json'
# 活文件注册表(生产数据目录允许存在的非数据文件)
LIVE_DATA_FILES = {
'mofin.db', 'mofin.db-shm', 'mofin.db-wal', 'mofin_health.json', 'portfolio.json',
'preflight_result.json', 'growth_registry.json', 'hardcode_audit.json',
'health_checklist.json', 'macro_divergence_state.json', 'macro_risk_state.json',
'market.json', 'scanner_state.json', 'state.db', 'strategy_staleness_report.json',
'system_audit_report.json', 'price_history.json', 'evaluation.json',
'accuracy_stats.json', 'format_error_library.json', 'candidate_pool.json',
'pipeline_registry.json', 'analyst-knowledge-log.md', 'mofin_health.html',
'evaluation_input.json', 'push_cooldown.json', 'system_audit.json',
'system_inventory.json', 'stocks',
}
PROCESS_WHITELIST = [
'hermes_cli.main', 'server.py', 'xmpp_zhiwei_bot.py', 'xmpp_mohe_bot.py',
'shadowsocks', 'unattended-upgrades', 'dashboard.py', 'kanban_api.py',
'main_dsa.py', 'vc-webhook.py', 'obsidian-api.py', 'http.server',
'wechat_webhook.py', 'qq-poll', 'afw', 'mcp_server', 'uvicorn',
'agentmemory', 'kimi_collect', 'mohe_knowledge_relay', 'wechat_watchdog',
'todo_scanner', 'miner.py',
]
CORE_TABLES = [
('live_prices', 'updated_at', '实时价格'),
('market_snapshots', 'created_at', '市场快照'),
('mtf_cache', 'updated_at', '多周期缓存'),
('macro_context_log', 'created_at', '宏观上下文'),
('price_events', 'created_at', '价格事件'),
]
def md5(p):
try:
return hashlib.md5(open(p, 'rb').read()).hexdigest()
except Exception:
return 'ERR'
def check_diverged():
"""检查 deploy vs MoFin/scripts vs MoFin根 的分叉副本"""
issues = []
compare_dirs = [f'{MOFIN_ROOT}/scripts', MOFIN_ROOT, '/home/hmo/web-dashboard']
for f in os.listdir(DEPLOY):
if not f.endswith('.py'):
continue
dp = os.path.join(DEPLOY, f)
d_md5 = md5(dp)
for d in compare_dirs:
p = os.path.join(d, f)
if os.path.exists(p) and not os.path.islink(p):
try:
if os.path.samefile(p, dp):
continue
except Exception:
pass
if md5(p) != d_md5:
issues.append({
'type': 'diverged_copy', 'file': f,
'canonical': dp, 'stale_copy': p,
'action': f'归档 {p} 或硬链接到权威版',
})
return issues
def check_broken_hardlinks():
"""deploy vs pa/scripts 内容不一致 = cron 跑旧代码"""
issues = []
for f in os.listdir(DEPLOY):
if not f.endswith('.py'):
continue
dp = os.path.join(DEPLOY, f)
pp = os.path.join(PA_SCRIPTS, f)
if os.path.exists(pp):
try:
if os.path.samefile(dp, pp):
continue
except Exception:
pass
if md5(dp) != md5(pp):
issues.append({
'type': 'broken_hardlink', 'file': f,
'action': 'bash deploy/profile-scripts/sync_profile_scripts.sh',
})
else:
issues.append({
'type': 'missing_profile_link', 'file': f,
'action': 'bash deploy/profile-scripts/sync_profile_scripts.sh',
})
return issues
def check_zombies():
""">3 天的 python/node 进程不在白名单(docker 容器内进程豁免)"""
issues = []
try:
r = subprocess.run(['ps', '-eo', 'pid,etime,args'], capture_output=True, text=True, timeout=10)
for line in r.stdout.splitlines()[1:]:
parts = line.split(None, 2)
if len(parts) < 3:
continue
pid, etime, cmd = parts
if 'python' not in cmd and 'node' not in cmd:
continue
# docker 容器内进程豁免(cgroup 含 docker
try:
cg = open(f'/proc/{pid}/cgroup').read()
if 'docker' in cg:
continue
except Exception:
pass
# etime 格式: dd-hh:mm:ss 或 hh:mm:ss
days = 0
if '-' in etime:
days = int(etime.split('-')[0])
if days >= 3:
if not any(w in cmd for w in PROCESS_WHITELIST):
issues.append({
'type': 'zombie_process', 'pid': pid, 'days': days,
'cmd': cmd[:120],
'action': f'确认后 kill {pid}(红线9 收尸流程)',
})
except Exception:
pass
return issues
def check_orphan_files():
"""生产数据目录的孤儿文件"""
issues = []
now = datetime.now()
for f in os.listdir(DATA_DIR):
p = os.path.join(DATA_DIR, f)
if not os.path.isfile(p) or f.startswith('.'):
continue
if f in LIVE_DATA_FILES:
continue
age_d = (now.timestamp() - os.path.getmtime(p)) / 86400
if age_d > 14:
issues.append({
'type': 'orphan_data_file', 'file': f,
'age_days': round(age_d),
'action': f'归档到 archive/(红线8',
})
return issues
def check_dead_cron():
"""cron job 指向不存在的脚本"""
issues = []
for jf, sdir in [('/home/hmo/.hermes/profiles/position-analyst/cron/jobs.json', PA_SCRIPTS),
('/home/hmo/.hermes/cron/jobs.json', '/home/hmo/.hermes/scripts')]:
try:
d = json.load(open(jf))
jobs = d if isinstance(d, list) else d.get('jobs', [])
for j in jobs:
s = j.get('script')
if s and j.get('enabled', True) and not os.path.exists(os.path.join(sdir, s)):
issues.append({
'type': 'dead_cron_script', 'job': j.get('name'), 'script': s,
'action': '删除 job 或补齐脚本',
})
except Exception:
pass
return issues
def check_db_freshness():
"""核心表新鲜度(红线10"""
issues = []
try:
c = sqlite3.connect(os.path.join(DATA_DIR, 'mofin.db'), timeout=10)
now = datetime.now()
is_trading_time = now.weekday() < 5 and 9 <= now.hour <= 16
for table, col, label in CORE_TABLES:
try:
row = c.execute(f"SELECT MAX({col}) FROM {table}").fetchone()
if row and row[0]:
last = datetime.fromisoformat(str(row[0]).replace('Z', ''))
age_h = (now - last).total_seconds() / 3600
threshold = 4 if is_trading_time else 48
if age_h > threshold:
issues.append({
'type': 'stale_table', 'table': table, 'label': label,
'age_hours': round(age_h, 1), 'threshold': threshold,
'action': '查对应采集脚本的 cron 状态',
})
else:
issues.append({'type': 'empty_table', 'table': table, 'label': label,
'action': '查采集链路'})
except Exception:
pass
c.close()
except Exception as e:
issues.append({'type': 'db_error', 'error': str(e)[:100]})
return issues
def main():
print('🧹 系统卫生审计', datetime.now().strftime('%Y-%m-%d %H:%M'))
all_issues = []
for name, fn in [('分叉副本', check_diverged), ('断裂硬链接', check_broken_hardlinks),
('僵尸进程', check_zombies), ('孤儿文件', check_orphan_files),
('死cron', check_dead_cron), ('DB新鲜度', check_db_freshness)]:
found = fn()
status = f'{len(found)}' if found else ''
print(f' {status} {name}')
all_issues.extend(found)
report = {
'generated_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'issue_count': len(all_issues),
'issues': all_issues,
'status': 'warn' if all_issues else 'ok',
}
os.makedirs(os.path.dirname(REPORT), exist_ok=True)
with open(REPORT, 'w', encoding='utf-8') as f:
json.dump(report, f, ensure_ascii=False, indent=2)
if all_issues:
# 推 XMPP
try:
import urllib.request
lines = [f"🧹 系统卫生审计发现 {len(all_issues)} 个问题:"]
for i in all_issues[:8]:
lines.append(f"• [{i['type']}] {i.get('file') or i.get('job') or i.get('table') or i.get('pid')}: {i.get('action','')[:60]}")
if len(all_issues) > 8:
lines.append(f'… 共 {len(all_issues)} 个,详见 hygiene_report.json')
payload = json.dumps({'to': 'hmo@yoin.fun', 'body': '\n'.join(lines), 'type': 'chat'}).encode()
req = urllib.request.Request('http://127.0.0.1:5805/', data=payload,
headers={'Content-Type': 'application/json'})
urllib.request.urlopen(req, timeout=5)
print(' 📨 已推 XMPP')
except Exception as e:
print(f' XMPP 推送失败: {e}')
else:
print(' ✅ 系统卫生良好')
if __name__ == '__main__':
main()