Files
workbuddy_skills/session-mechanism/scripts/guard.py
T

293 lines
13 KiB
Python
Raw Normal View History

# -*- coding: utf-8 -*-
"""guard —— **守护程序**:保证「协作程序」与「监督程序」**一直在运行**。
🔴 架构(2026-09-29 用户定案):**投递与协作程序应该一直运行,用守护程序去保护** —— 本件即那个守护程序。
职责只有一件:**拉起 + 看护 + 挂了重拉 + 单例**。
⛔ 边界(红线):
· ⛔ 不开新会话、⛔ 不派活(不写排期)—— 那是主会话/自动化的事
· ⛔ 不读口令、⛔ 不联网、⛔ 不写宿主库、⛔ 不碰用户的会话
· 全静默(只写自己的日志);fail-safe:本程序异常 ⇒ 只退出自己,⛔ 不影响 WorkBuddy
⚠️ **起法(实测约束,⛔ 别从会话里起)**:
· ⛔ 从 WorkBuddy 会话/工具调用里起的进程,**调用一结束就被回收**(2026-09-29 当场实测:唤醒停在调用结束那一秒,ps 也查不到)
· ⛔ `schtasks`(计划任务)被本机安全策略**硬拦**,且明令不得绕过
· ✅ 正确起法:**在独立窗口里跑一次**(`python guard.py`,或用同目录的 `启动守护.cmd`),
或把它的快捷方式放进「启动」文件夹(`%APPDATA%\\Microsoft\\Windows\\Start Menu\\Programs\\Startup`)⇒ 开机自启
⇒ 由**会话之外**起的进程**不挂在任何 WorkBuddy 会话下** ⇒ 不会被回收。
用法:
python guard.py # 常驻守护(前台;独立窗口里跑)
python guard.py --status # 只报当前状态
python guard.py --stop # 让守护程序退出(写停止标志;子程序由守护收尾时一并结束)
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import time
from pathlib import Path
HERE = Path(__file__).resolve().parent
def _cfg() -> dict:
"""读部署配置(与 collabd.py 同一份)—— 🔴 **inbox 必须两边一致**,否则两个程序不在同一个
inbox 工作(自测挖出:guard 原先把它硬编码成 `tmp/supervise-inbox`,与配置里的 `inbox` 可能不同)。"""
p = Path(os.environ.get("COLLABD_CONFIG") or (Path(__file__).resolve().parent / "collabd.config.json"))
try:
d = json.loads(p.read_text(encoding="utf-8"))
return d if isinstance(d, dict) else {}
except Exception:
return {}
_C = _cfg()
_ws_env = os.environ.get("DSH_COLLAB_WS") or ""
WS = Path(_ws_env) if _ws_env else (Path(_C["workspace"]) if _C.get("workspace") else Path.cwd())
INBOX = (WS / str(_C.get("inbox") or "tmp/supervise-inbox"))
LOG = INBOX / "guard.log"
STOP = INBOX / "guard.stop"
PIDF = INBOX / "guard.json"
COLLABD = os.path.join(os.path.dirname(os.path.abspath(__file__)), "collabd.py")
def _win_pythonw():
"""常驻/后台子进程优先 pythonw.exe(GUI 子系统,Windows 永不为其分配控制台)⇒ 根治闪窗。
⛔ 不存在时回退 sys.executable。"""
if os.name != "nt":
return sys.executable
_d = os.path.dirname(os.path.abspath(sys.executable))
_pw = os.path.join(_d, "pythonw.exe")
return _pw if os.path.isfile(_pw) else sys.executable
PY = _win_pythonw()
CHECK_EVERY = 15 # 秒:看护周期
GOALF = INBOX / "goal.json" # 已确认的目标
GOALP = INBOX / "goal.pending.json" # **待确认**的目标(用户还没点头)
GOAL_NAME = "目标守护进程" # 对外正式名(文件名仍 guard.py,⛔ 不为改名掀引用)
STALE = 3 * CHECK_EVERY # 秒:唤醒过期 ⇒ 判前一个守护已死
def log(m: str) -> None:
try:
LOG.parent.mkdir(parents=True, exist_ok=True)
with open(LOG, "a", encoding="utf-8") as f:
f.write("[%s] %s\n" % (time.strftime("%Y-%m-%d %H:%M:%S"), m))
except Exception:
pass
def _shutdown(kids, wait_s: int = 20) -> None:
"""**优雅收尾**:先让子程序**自己退出**(它们每轮看停止标志/守护唤醒),等不到才兜底结束。"""
for k in kids:
for _ in range(wait_s):
if not k.alive():
break
time.sleep(1)
if k.alive():
log("%s 未在 %d 秒内优雅退出 ⇒ 兜底结束" % (k.name, wait_s))
try:
k.p.terminate()
except Exception:
pass
else:
log("%s 已优雅退出" % k.name)
class Child:
"""一个被看护的子程序(同一个文件的不同模式)。"""
def __init__(self, name: str, args: list):
self.name, self.args, self.p = name, args, None
def alive(self) -> bool:
return self.p is not None and self.p.poll() is None
def ensure(self) -> None:
if self.alive():
return
try:
self.p = subprocess.Popen([PY, "-u", COLLABD] + self.args, cwd=str(HERE),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
close_fds=True,
creationflags=0x08000000 | 0x00000008 | 0x00000200, # NO_WINDOW | DETACHED | NEW_PROCESS_GROUP
env=dict(os.environ, DSH_GUARDED="1")) # 打标:子程序据此做优雅退出
log("%s 已拉起 pid=%s" % (self.name, getattr(self.p, "pid", "?")))
except Exception as e:
log("%s 拉起失败:%s" % (self.name, e))
def _stale() -> bool:
"""用"唤醒是否过期"判前一个守护是否已死 —— ⛔ 不依赖任何进程 API、⛔ 不自造锁协议。"""
try:
d = json.loads(PIDF.read_text(encoding="utf-8"))
return (time.time() - float(d.get("ts") or 0)) > STALE
except Exception:
return True
def main() -> int:
if "--goal" in sys.argv: # 声明/改写**任务目标**(一句话)
def _ga(k, dv=""):
return sys.argv[sys.argv.index(k) + 1] if (k in sys.argv and sys.argv.index(k) + 1 < len(sys.argv)) else dv
t = _ga("--goal").strip()
if not t:
print("用法:guard.py --goal \"<一句话任务目标>\" (直接声明;推荐先 --propose 再 --confirm)")
return 2
try:
cur = {}
if (INBOX / "goal.json").exists():
cur = json.loads((INBOX / "goal.json").read_text(encoding="utf-8")) or {}
cur["title"] = t
cur["declared_at"] = time.strftime("%Y-%m-%dT%H:%M")
INBOX.mkdir(parents=True, exist_ok=True)
(INBOX / "goal.json").write_text(json.dumps(cur, ensure_ascii=False, indent=2), encoding="utf-8")
except Exception as e:
print("写目标失败:%s" % e)
return 2
print("OK 已声明任务目标:%s" % t)
return 0
if "--propose" in sys.argv: # ① 用户说明目标 ⇒ 落成**待确认**,并回述
def _gp(k, dv=""):
return sys.argv[sys.argv.index(k) + 1] if (k in sys.argv and sys.argv.index(k) + 1 < len(sys.argv)) else dv
raw = _gp("--propose").strip()
if not raw:
print("用法:guard.py --propose \"<用户对目标的原话>\"")
return 2
rec = {"title": raw, "proposed_at": time.strftime("%Y-%m-%dT%H:%M"),
"confirmed": False,
"_下一步": "把这份理解回述给用户 ⇒ 用户点头后跑 --confirm(之后才允许启动)"}
try:
INBOX.mkdir(parents=True, exist_ok=True)
GOALP.write_text(json.dumps(rec, ensure_ascii=False, indent=2), encoding="utf-8")
except Exception as e:
print("写待确认目标失败:%s" % e)
return 2
print("⏳ 已记录**待确认**目标(%s):\n %s\n"
" ⇒ 请把理解回述给用户,用户确认后再跑: guard.py --confirm" % (GOAL_NAME, raw))
return 0
if "--confirm" in sys.argv: # ② 用户点头 ⇒ 转正(**这一步之后才允许启动**)
try:
rec = json.loads(GOALP.read_text(encoding="utf-8"))
except Exception:
print("没有待确认的目标(`%s` 不存在)⇒ 先跑 --propose \"<用户原话>\"" % GOALP)
return 2
rec["confirmed"] = True
rec["confirmed_at"] = time.strftime("%Y-%m-%dT%H:%M")
rec["confirmed_by"] = "用户"
rec.pop("_下一步", None)
try:
GOALF.write_text(json.dumps(rec, ensure_ascii=False, indent=2), encoding="utf-8")
GOALP.unlink()
except Exception as e:
print("写已确认目标失败:%s" % e)
return 2
print("✅ 目标已确认:%s\n ⇒ 现在可以启动%s了(独立窗口跑 start-guard.cmd,或用宿主后台机制起)"
% (rec.get("title"), GOAL_NAME))
return 0
if "--status" in sys.argv:
try:
print(json.dumps(json.loads(PIDF.read_text(encoding="utf-8")), ensure_ascii=False, indent=1))
except Exception:
print("守护程序未在运行(无 %s)" % PIDF)
return 0
if "--stop" in sys.argv:
try:
STOP.parent.mkdir(parents=True, exist_ok=True)
STOP.write_text("stop", encoding="utf-8")
print("已写停止标志:%s" % STOP)
except Exception as e:
print("写停止标志失败:%s" % e)
return 0
# 🔴 2026-09-29 用户定案(新逻辑):**用户说明目标 ⇒ 机制理解并回述 ⇒ 与用户确认 ⇒ 才允许启动**。
# 闸 ①:还有"待确认"的目标 ⇒ ⛔ 拒绝启动(不拿没确认过的东西当运行中心)
if (INBOX / "goal.pending.json").exists():
try:
_p = json.loads(GOALP.read_text(encoding="utf-8"))
_pt = str(_p.get("title") or "")
except Exception:
_pt = ""
print("⛔ 拒绝启动:**目标还没和用户确认**(待确认内容:%s)\n"
" 流程:把理解回述给用户 ⇒ 用户点头 ⇒ 跑 `guard.py --confirm` ⇒ 再启动。\n"
" ⛔ 未经确认的目标不得作为运行中心。" % (_pt[:80] or "(空)"))
return 4
# 🔴 闸 ②:**没有已确认目标就拒绝启动** —— 机制的一切判定(需求是否完成/派活/唤醒)
# 都围绕目标;没目标 ⇒ 不知道该做什么,宁可**不开**(⛔ 不空转)。
try:
_goal = json.loads((INBOX / "goal.json").read_text(encoding="utf-8")) if (INBOX / "goal.json").exists() else {}
except Exception:
_goal = {}
if not str((_goal or {}).get("title") or "").strip():
print("⛔ 拒绝启动:**没有任务目标** —— 这套机制围绕目标运行,无目标就不知道在为什么跑。\n"
" 两种声明方式:\n"
" ① 一句话写入: <python> guard.py --goal \"<任务目标>\"\n"
" ② 手写文件: %s\n"
" (至少要有 title;可另附 acceptance_doc / taskgraph / lines / topics)" % (INBOX / "goal.json"))
return 3
if PIDF.exists() and not _stale():
try:
old = json.loads(PIDF.read_text(encoding="utf-8"))
except Exception:
old = {}
print("已有守护程序在跑(pid=%s,唤醒 %ss 前)⇒ 本进程退出"
% (old.get("pid"), int(time.time() - float(old.get("ts") or 0))))
return 0
try:
STOP.unlink()
except Exception:
pass
info = {"pid": os.getpid(), "since": time.strftime("%Y-%m-%dT%H:%M:%S"), "ts": time.time(),
"ws": str(WS)}
kids = [Child("常驻程序", []), Child("投递", ["--supervise"])]
log("%s 启动 pid=%d ws=%s 目标=%s" % (GOAL_NAME, os.getpid(), WS, _goal.get("title")))
# 启动对账:收编"队列开启前就已在跑的棒" + 标出僵尸(不投递、幂等)
# fail-safe:超时 30 秒、吞错、不显窗(CREATE_NO_WINDOW)
try:
subprocess.run([PY, "-u", COLLABD, "--reconcile"], cwd=str(HERE),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30,
creationflags=0x08000000)
log("启动对账已跑")
except Exception as e:
log("启动对账失败(不影响启动):%s" % e)
n = 0
while True:
if STOP.exists():
log("收到停止标志 ⇒ 守护程序退出")
break
for k in kids:
k.ensure()
info["ts"] = time.time()
info["children"] = {"%s" % k.name: ("alive" if k.alive() else "dead") for k in kids}
try:
PIDF.write_text(json.dumps(info, ensure_ascii=False, indent=1), encoding="utf-8")
except Exception:
pass
n += 1
if n % 20 == 0:
log("看护中 %s" % " | ".join("%s=%s" % (k.name, "活" if k.alive() else "死") for k in kids))
try:
time.sleep(CHECK_EVERY)
except KeyboardInterrupt: # Ctrl+C 也要走**优雅收尾**(⛔ 不能直接死掉丢下孩子)
log("收到 Ctrl+C ⇒ 优雅收尾")
break
_shutdown(kids) # 🔴 守护关 ⇒ 两个程序**优雅退出**(等它们自己退,再兜底)
try:
PIDF.unlink()
except Exception:
pass
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
sys.exit(0)
except Exception as e:
log("fatal %s" % e)
sys.exit(0)