zhuyuan-ice-gl-zy001/BRIDGE/compaction-watcher.py
2026-08-20 00:01:53 +08:00

381 lines
17 KiB
Python
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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/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 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")