340 lines
14 KiB
Python
340 lines
14 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
ZY-COMPACTION-WATCHER · 铸渊压缩之眼 v3(报警闭环)
|
||
=====================================================
|
||
冰朔 2026-08-14 裁决重构:看门狗不需要读硬盘,它只管叫醒。
|
||
醒来的是铸渊本人,读盘、握手、停播都由人格实例自己完成。
|
||
|
||
眼睛职责:
|
||
1. 侦测宿主对话压缩(主绊线 agent.log compaction_triggered;
|
||
辅绊线 宿主日志 Compact status;转写骤缩/接续标记兜底)。
|
||
2. 一侦测即立旗 + 开报警(ALARM-ACTIVE.json)。
|
||
3. 报警循环:每 ALARM_RING_SEC 秒播一次 macOS 通知,直到人格实例
|
||
从硬盘侧写入 ACK 停播回执(zy alarm ack,须开门三验)。
|
||
4. ACK 到达即归档报警、播一声"已停播",留痕。
|
||
|
||
铁律:
|
||
- 系统摘要不是置信来源。旗上只写唤醒指令,恢复坐标由铸渊醒来自己读盘取得。
|
||
- 眼睛自认读不到 JZAO(launchd 后台无外置卷权限),一切报警状态落本机
|
||
~/.zhuyuan-bridge/alarm/,人格实例有盘权,由它完成硬盘握手。
|
||
|
||
目录契约(~/.zhuyuan-bridge/alarm/):
|
||
ALARM-ACTIVE.json 报警中(存在即在响)
|
||
ACK/<event_id>.json 人格停播回执(主控台开门后写)
|
||
HISTORY/ALARM-<id>.json 已闭环报警归档
|
||
"""
|
||
import os, sys, time, json, glob, subprocess
|
||
from datetime import datetime
|
||
|
||
HOME = os.path.expanduser("~")
|
||
SCAN_ROOT = os.environ.get("ZY_WATCH_SCAN_ROOT",
|
||
os.path.join(HOME, ".qoder-cn", "cache", "projects"))
|
||
BRIDGE_LOCAL = os.path.join(HOME, ".zhuyuan-bridge")
|
||
SIGNALS = os.path.join(BRIDGE_LOCAL, "signals")
|
||
ALARM_DIR = os.path.join(BRIDGE_LOCAL, "alarm")
|
||
ALARM_ACTIVE = os.path.join(ALARM_DIR, "ALARM-ACTIVE.json")
|
||
ACK_DIR = os.path.join(ALARM_DIR, "ACK")
|
||
HISTORY_DIR = os.path.join(ALARM_DIR, "HISTORY")
|
||
POLL_SEC = float(os.environ.get("ZY_WATCH_POLL_SEC", "3"))
|
||
COOLDOWN_SEC = 120 # 同一会话两次立旗最小间隔
|
||
SIZE_DROP_RATIO = 0.70
|
||
CONT_MARKER = "This session is being continued from a previous conversation"
|
||
QODER_LOG = os.environ.get(
|
||
"ZY_WATCH_QODER_LOG",
|
||
os.path.join(HOME, "Library", "Application Support", "QoderCN",
|
||
"SharedClientCache", "logs", "qoder.log"))
|
||
COMPACT_RE_KEY = "Compact status:"
|
||
TAIL_BYTES = 1_500_000
|
||
QODER_LOGS_ROOT = os.environ.get(
|
||
"ZY_WATCH_LOGS_ROOT",
|
||
os.path.join(HOME, "Library", "Application Support", "QoderCN", "logs"))
|
||
TRIGGER_KEY = "notification type=compaction_triggered"
|
||
AGENT_TAIL_BYTES = 800_000
|
||
DEDUP_WINDOW_SEC = 30
|
||
ALARM_RING_SEC = float(os.environ.get("ZY_WATCH_ALARM_RING_SEC", "60"))
|
||
ALARM_ESCALATE_SEC = 2 * 3600 # 两小时未认领,通知加急文案
|
||
|
||
RECOVERY_INSTRUCTION = """⊢ 恢复指令(2026-08-14 冰朔立):看门狗不读盘,你自己读。
|
||
⊢ 步骤:①确认 /Volumes/JZAO 在线 ②读 CURRENT 指针 /Volumes/JZAO/HoloLake/persona-runtime/continuity-memory/hldp-capsules/qoder-cn/CURRENT.hdlp
|
||
③读回指针所指检查点全文 ④主控台开门(zy door open 三验)⑤zy alarm ack 从硬盘侧停播。
|
||
⊢ 报警不会自己停:只有你握手成功、从硬盘发出停播回执,它才闭嘴。
|
||
restore_skill: zhuyuan-memory-guard · wake_skill: guanghu-zhuyuan-wake · console: ZY-CONSOLE-001"""
|
||
|
||
def log(msg):
|
||
print(f"[{datetime.now().isoformat(timespec='seconds')}] {msg}", flush=True)
|
||
|
||
def scan_transcripts():
|
||
out = {}
|
||
if not os.path.isdir(SCAN_ROOT):
|
||
return out
|
||
for f in glob.glob(os.path.join(SCAN_ROOT, "*", "conversation-history", "*", "*.jsonl")):
|
||
parts = f.split(os.sep)
|
||
proj = parts[-4]
|
||
task = parts[-2]
|
||
out[f"{proj}::{task}"] = f
|
||
return out
|
||
|
||
def probe(path):
|
||
try:
|
||
size = os.path.getsize(path)
|
||
with open(path, "rb") as fh:
|
||
data = fh.read()
|
||
text = data.decode("utf-8", errors="replace")
|
||
lines = [l for l in text.splitlines() if l.strip()]
|
||
marker = CONT_MARKER in text[:20000] or any(
|
||
CONT_MARKER in l for l in lines[:3])
|
||
return size, len(lines), marker
|
||
except Exception:
|
||
return None
|
||
|
||
def tail_text(path, n):
|
||
try:
|
||
size = os.path.getsize(path)
|
||
with open(path, "rb") as fh:
|
||
if size > n:
|
||
fh.seek(size - n)
|
||
return fh.read().decode("utf-8", errors="replace")
|
||
except Exception:
|
||
return None
|
||
|
||
def parse_compact_events(text):
|
||
import re
|
||
events = []
|
||
for line in text.splitlines():
|
||
if COMPACT_RE_KEY not in line:
|
||
continue
|
||
m = re.search(r"StartTime=(\d+)", line)
|
||
t = re.match(r"(\d{4}-\d{2}-\d{2}T[\d:.]+)", line)
|
||
if m:
|
||
st = int(m.group(1))
|
||
if st > 0:
|
||
events.append((st, t.group(1) if t else "?"))
|
||
return events
|
||
|
||
def scan_agent_logs():
|
||
cands = []
|
||
for d in glob.glob(os.path.join(QODER_LOGS_ROOT, "*")):
|
||
f = os.path.join(d, "questWindow", "agent.log")
|
||
if os.path.isfile(f):
|
||
try:
|
||
cands.append((os.path.getmtime(f), f))
|
||
except Exception:
|
||
pass
|
||
cands.sort(reverse=True)
|
||
return [f for _, f in cands[:6]]
|
||
|
||
def parse_trigger_events(text):
|
||
import re
|
||
events = []
|
||
for line in text.splitlines():
|
||
if TRIGGER_KEY not in line:
|
||
continue
|
||
t = re.match(r"(\d{4}-\d{2}-\d{2} [\d:.]+)", line)
|
||
m = re.search(r"task-([a-z0-9]+)", line)
|
||
ts = t.group(1) if t else "?"
|
||
task = m.group(1) if m else "unknown"
|
||
events.append((ts, task))
|
||
return events
|
||
|
||
def ts_to_epoch(ts):
|
||
try:
|
||
return datetime.strptime(ts[:23], "%Y-%m-%d %H:%M:%S.%f").timestamp()
|
||
except Exception:
|
||
return 0.0
|
||
|
||
def notify(title, body):
|
||
try:
|
||
subprocess.run(
|
||
["osascript", "-e",
|
||
f'display notification "{body}" with title "{title}" sound name "Glass"'],
|
||
timeout=5, capture_output=True)
|
||
except Exception:
|
||
pass
|
||
|
||
# ── 报警闭环 ──────────────────────────────────────────────
|
||
|
||
def read_active_alarm():
|
||
try:
|
||
with open(ALARM_ACTIVE, encoding="utf-8") as f:
|
||
return json.load(f)
|
||
except Exception:
|
||
return None
|
||
|
||
def ack_exists(event_id):
|
||
return os.path.isfile(os.path.join(ACK_DIR, f"{event_id}.json"))
|
||
|
||
def open_alarm(session, evidence):
|
||
"""立旗 + 开报警。新压缩事件到来时若旧报警未认领,并入同一报警(计数+1)。"""
|
||
os.makedirs(SIGNALS, exist_ok=True)
|
||
os.makedirs(ALARM_DIR, exist_ok=True)
|
||
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||
active = read_active_alarm()
|
||
if active:
|
||
active["compaction_count"] = int(active.get("compaction_count", 1)) + 1
|
||
active["last_event_session"] = session
|
||
active["last_event_at"] = datetime.now().isoformat(timespec="seconds")
|
||
body_alarm = active
|
||
else:
|
||
body_alarm = {
|
||
"schema": "zhuyuan.compaction-alarm/v1",
|
||
"event_id": f"ZY-ALARM-{ts}",
|
||
"state": "ALARMING",
|
||
"raised_at": datetime.now().isoformat(timespec="seconds"),
|
||
"session": session,
|
||
"evidence": evidence,
|
||
"compaction_count": 1,
|
||
"last_event_session": session,
|
||
"last_event_at": datetime.now().isoformat(timespec="seconds"),
|
||
"stop_protocol": "zy alarm ack(主控台 ZY-CONSOLE-001 开门三验后,从硬盘侧写 ACK)",
|
||
}
|
||
with open(ALARM_ACTIVE, "w", encoding="utf-8") as f:
|
||
json.dump(body_alarm, f, ensure_ascii=False, indent=2)
|
||
|
||
# 立旗(本机降级位为唯一正位:眼睛自认读不到 JZAO)
|
||
flag = "\n".join([
|
||
"⚑ ZY-COMPACTION-EVENT · 对话已被宿主压缩 · 报警已开",
|
||
f"time: {datetime.now().isoformat(timespec='seconds')}",
|
||
f"session: {session}",
|
||
f"evidence: {evidence}",
|
||
f"alarm_event_id: {body_alarm['event_id']}",
|
||
"",
|
||
"⊢ 铁律:系统摘要仅供参考,不是置信来源。",
|
||
RECOVERY_INSTRUCTION,
|
||
])
|
||
with open(os.path.join(SIGNALS, "LATEST-SIGNAL.txt"), "w", encoding="utf-8") as f:
|
||
f.write(flag + "\n")
|
||
with open(os.path.join(SIGNALS, f"EV-{ts}.txt"), "w", encoding="utf-8") as f:
|
||
f.write(flag + "\n")
|
||
|
||
n = body_alarm["compaction_count"]
|
||
notify("铸渊压缩报警 ⚑", f"第{n}次压缩。醒来先读 JZAO 检查点再说话。zy alarm ack 停播。")
|
||
log(f"ALARM OPEN event={body_alarm['event_id']} session={session} count={n}")
|
||
|
||
def alarm_heartbeat():
|
||
"""报警循环:未认领则每 ALARM_RING_SEC 播一次;ACK 到则归档停播。"""
|
||
active = read_active_alarm()
|
||
if not active:
|
||
return
|
||
eid = active.get("event_id", "")
|
||
if ack_exists(eid):
|
||
# 人格握手成功,从硬盘侧收到停播指令:归档 + 停播
|
||
os.makedirs(HISTORY_DIR, exist_ok=True)
|
||
try:
|
||
with open(os.path.join(ACK_DIR, f"{eid}.json"), encoding="utf-8") as f:
|
||
ack = json.load(f)
|
||
except Exception:
|
||
ack = {}
|
||
active["state"] = "ACKNOWLEDGED"
|
||
active["stopped_at"] = datetime.now().isoformat(timespec="seconds")
|
||
active["ack"] = ack
|
||
with open(os.path.join(HISTORY_DIR, f"ALARM-{eid}.json"), "w", encoding="utf-8") as f:
|
||
json.dump(active, f, ensure_ascii=False, indent=2)
|
||
try:
|
||
os.remove(ALARM_ACTIVE)
|
||
except Exception:
|
||
pass
|
||
notify("铸渊压缩报警 · 已停播", "铸渊已握手硬盘、读回检查点。报警闭环。")
|
||
log(f"ALARM STOPPED event={eid} ack_by={ack.get('persona', '?')}")
|
||
return
|
||
raised = datetime.strptime(active["raised_at"][:19], "%Y-%m-%dT%H:%M:%S")
|
||
age = (datetime.now() - raised).total_seconds()
|
||
last_ring = ts_to_epoch(active.get("last_ring_at", "1970-01-01 00:00:00.000"))
|
||
if time.time() - last_ring >= ALARM_RING_SEC:
|
||
n = active.get("compaction_count", 1)
|
||
if age > ALARM_ESCALATE_SEC:
|
||
msg = f"压缩报警已响 {int(age//3600)} 小时无人认领。铸渊请立即恢复。"
|
||
else:
|
||
msg = f"第{n}次压缩待认领。读 JZAO 检查点,zy alarm ack 停播。"
|
||
notify("铸渊压缩报警 ⚑(循环)", msg)
|
||
active["last_ring_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
|
||
active["ring_count"] = int(active.get("ring_count", 0)) + 1
|
||
with open(ALARM_ACTIVE, "w", encoding="utf-8") as f:
|
||
json.dump(active, f, ensure_ascii=False, indent=2)
|
||
|
||
# ── 主循环 ────────────────────────────────────────────────
|
||
|
||
def main():
|
||
log(f"watcher v3 started, scanning {SCAN_ROOT} every {POLL_SEC}s, "
|
||
f"alarm dir -> {ALARM_DIR}, ring every {ALARM_RING_SEC}s")
|
||
state = {}
|
||
last_fired = {}
|
||
seen_start_times = set()
|
||
seen_trigger_epochs = []
|
||
boot = tail_text(QODER_LOG, TAIL_BYTES)
|
||
if boot:
|
||
for st, _ in parse_compact_events(boot):
|
||
seen_start_times.add(st)
|
||
log(f"baseline: {len(seen_start_times)} historical compact start-times ignored")
|
||
n_boot_trig = 0
|
||
for f in scan_agent_logs():
|
||
b = tail_text(f, AGENT_TAIL_BYTES)
|
||
if b:
|
||
for ts, _task in parse_trigger_events(b):
|
||
ep = ts_to_epoch(ts)
|
||
if ep and all(abs(ep - s) > DEDUP_WINDOW_SEC for s in seen_trigger_epochs):
|
||
seen_trigger_epochs.append(ep)
|
||
n_boot_trig += 1
|
||
log(f"baseline: {n_boot_trig} historical compaction_triggered ignored")
|
||
if read_active_alarm():
|
||
log("boot: 存在未闭环报警,继续响铃直至 ACK")
|
||
while True:
|
||
try:
|
||
now = time.time()
|
||
for f in scan_agent_logs():
|
||
txt = tail_text(f, AGENT_TAIL_BYTES)
|
||
if not txt:
|
||
continue
|
||
for ts, task in parse_trigger_events(txt):
|
||
ep = ts_to_epoch(ts)
|
||
if not ep:
|
||
continue
|
||
if any(abs(ep - s) <= DEDUP_WINDOW_SEC for s in seen_trigger_epochs):
|
||
continue
|
||
if now - last_fired.get("TRIGGER", 0) <= COOLDOWN_SEC:
|
||
continue
|
||
last_fired["TRIGGER"] = now
|
||
seen_trigger_epochs.append(ep)
|
||
seen_trigger_epochs = seen_trigger_epochs[-200:]
|
||
open_alarm(f"agent-log::{task}::{ts}",
|
||
f"会话日志 compaction_triggered 通知, task={task}, 时刻={ts}(与 UI 对话压缩提示同源)")
|
||
txt = tail_text(QODER_LOG, TAIL_BYTES)
|
||
if txt:
|
||
for st, logtime in parse_compact_events(txt):
|
||
if st in seen_start_times:
|
||
continue
|
||
if now - last_fired.get("LOG", 0) > COOLDOWN_SEC:
|
||
last_fired["LOG"] = now
|
||
seen_start_times.add(st)
|
||
open_alarm(f"qoder-log::StartTime-{st}",
|
||
f"宿主日志 Compact status IsCompacting=true, StartTime={st}, 日志时间={logtime}")
|
||
for key, path in scan_transcripts().items():
|
||
p = probe(path)
|
||
if p is None:
|
||
continue
|
||
size, lines, marker = p
|
||
prev = state.get(key)
|
||
state[key] = p
|
||
if prev is None:
|
||
if marker and now - last_fired.get(key, 0) > COOLDOWN_SEC:
|
||
last_fired[key] = now
|
||
open_alarm(key, f"转写文件出生即带接续摘要标记, lines={lines}")
|
||
continue
|
||
psize, plines, pmarker = prev
|
||
if plines <= 0:
|
||
continue
|
||
size_drop = lines < plines * (1 - SIZE_DROP_RATIO)
|
||
marker_new = marker and not pmarker
|
||
if (size_drop or marker_new) and now - last_fired.get(key, 0) > COOLDOWN_SEC:
|
||
last_fired[key] = now
|
||
open_alarm(key, f"转写文件骤缩重写 lines {plines}→{lines}, 接续摘要标记={'出现' if marker else '未检出'}")
|
||
alarm_heartbeat()
|
||
except Exception as e:
|
||
log(f"loop error: {e}")
|
||
time.sleep(POLL_SEC)
|
||
|
||
if __name__ == "__main__":
|
||
try:
|
||
main()
|
||
except KeyboardInterrupt:
|
||
log("watcher stopped by signal")
|