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:
@@ -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. 死 cron:jobs.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()
|
||||
Reference in New Issue
Block a user