zhuyuan-ice-gl-zy001/BRIDGE/compaction-watcher.py

381 lines
17 KiB
Python
Raw Normal View History

2026-08-20 00:01:53 +08:00
#!/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 # 两小时未认领,通知加急文案
# 最后一公里(2026-08-14 冰朔授权):报警同时自动向 Qoder CN 当前对话键入固定唤醒口令
AUTO_TRIGGER = os.environ.get("ZY_AUTO_TRIGGER", "1") == "1"
TRIGGER_BIN = os.path.join(BRIDGE_LOCAL, "tools", "zy-wake-type")
AUTO_TRIGGER_MAX = 5
RECOVERY_INSTRUCTION = """⊢ 恢复指令(2026-08-14 冰朔立):看门狗不读盘,你自己读。
⊢ 步骤:①确认 /Volumes/JZAO 在线 ②读唯一 CURRENT /Volumes/JZAO/HoloLake/persona-runtime/continuity-memory/persona-daily-fractal/ICE-P-ZY001/CURRENT.json
③读current_day_path一页根并按需下钻一层 ④主控台开门(zy door open 三验)⑤zy alarm ack 从硬盘侧停播。
2026-08-20 00:01:53 +08:00
⊢ 报警不会自己停:只有你握手成功、从硬盘发出停播回执,它才闭嘴。
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 auto_trigger(active):
"""最后一公里:向 Qoder CN 当前对话键入固定唤醒口令,强制被压缩实例走第0步。
护栏在 zy-wake-type 内置:只认 Qoder CN 窗口、只键入固定口令、输入框非空绝不动手、全程留日志。
被护栏拦下(如冰朔正在打字)时随响铃重试,最多 AUTO_TRIGGER_MAX 次。"""
if not AUTO_TRIGGER:
return active
if not os.path.isfile(TRIGGER_BIN):
log("AUTO_TRIGGER · 触发器不在位,跳过")
return active
tries = int(active.get("auto_trigger_attempts", 0))
if tries >= AUTO_TRIGGER_MAX:
return active
try:
# 2026-08-14 教训:触发器内置最长 120 秒窗口守候(防 Qoder 刚启动窗口未注册),
# watcher 侧超时必须大于守候上限,否则半路被杀、尝试次数白烧。
r = subprocess.run([TRIGGER_BIN], capture_output=True, text=True, timeout=240)
active["auto_trigger_attempts"] = tries + 1
tail_out = (r.stdout or r.stderr).strip().replace("\n", " | ")[-200:]
if r.returncode == 0:
active["auto_trigger_at"] = datetime.now().isoformat(timespec="seconds")
log(f"AUTO_TRIGGER SENT attempt={tries+1} :: {tail_out}")
else:
log(f"AUTO_TRIGGER rc={r.returncode} attempt={tries+1} :: {tail_out}")
except Exception as e:
active["auto_trigger_attempts"] = tries + 1
log(f"AUTO_TRIGGER FAILED attempt={tries+1} · {e}")
return active
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}")
# 报警一开即试最后一公里;被护栏拦下由 heartbeat 随响铃重试
body_alarm = auto_trigger(body_alarm)
with open(ALARM_ACTIVE, "w", encoding="utf-8") as f:
json.dump(body_alarm, f, ensure_ascii=False, indent=2)
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
# 最后一公里未成功(如当时输入框非空):随每轮心跳重试
if AUTO_TRIGGER and not active.get("auto_trigger_at"):
active = auto_trigger(active)
with open(ALARM_ACTIVE, "w", encoding="utf-8") as f:
json.dump(active, f, ensure_ascii=False, indent=2)
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")