Files

323 lines
15 KiB
Python
Raw Permalink Normal View History

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""session-plan-check.py —— 「会话规划体检」= 开会话后的第一件事
用户口径(2026-10-02 原话逐字):
「这个会话和协作会话的技能包 运行的第一件事 ,就应该是检查清楚
所有会话规划是否配置完整且生效,然后标记一个状态」
为什么要它(真因,不是想当然)
──────────────────────────────
2026-10-02 实测:本线的排期**都在册**(`[唤醒]-…` / `[跟进]-…` 两条 recurring、
`model_is_thinking=1`、`cwds` 正确),**但三类会话一条活的都没有** ——
"配置在册" ≠ "机制在生效"。同一天早些时候还发现过更隐蔽的:
钩子注入文本指着 **09-28 就已合并退役**的技能名、常驻规则快照的生成脚本**写到幽灵目录**
⇒ 全都是"看着有配置、其实没生效"。所以**开工第一件事**必须先把这个状态问清楚,
而不是等它表现出来(那时已经卡住或被用户发现)。
检查六项(⛔ 每条都必须能真报出问题 —— 见 selftest 的红绿对照):
① 周期钟 每类是否有 recurring 排期(缺 ⇒ 没人推 / 没人收)
② 钟会响吗 排期绑的模型能不能跑(`thinking=0` + flash 系 ⇒ 服务端必拒,2026-10-02 实测)
③ 归属同形 `cwds` 与工作区路径**逐字**比(错一字面 ⇒ 裂组且**自我强化**)
④ 活会话 三类(唤醒 / 协作 / 跟进)当前有没有活的
⑤ 投递在跑 协作程序(投递轮)在不在跑 + 心跳新鲜度
⑥ 死排期 `once` 且 `next_run_at` 为空 ⇒ 已消耗、**不会再触发**(静默消失的活)
产出(**标记一个状态**):
`<WS>/.workbuddy/collab/session-plan.json` —— 机器可读(看板 / 后续会话读它)
+ stdout 一行摘要(给人扫)
退出码:0 = 齐备且生效 | 1 = 有缺口(⚠️ **只标记、不拦开工**)
用法:python session-plan-check.py [--ws <工作区>] [--json] [--quiet]
"""
import io
import json
import os
import re
import sqlite3
import sys
import time
# ── 根目录外置(roots.env 由 install.py 生成;宿主 env 优先,本文件兜底) ──────
def _sm_load_roots():
here = os.path.dirname(os.path.abspath(__file__))
for up in range(4):
p = os.path.normpath(os.path.join(here, *([".."] * up), "roots.env"))
if os.path.isfile(p):
try:
for ln in io.open(p, encoding="utf-8"):
ln = ln.strip()
if ln and not ln.startswith("#") and "=" in ln:
k, v = ln.split("=", 1)
os.environ.setdefault(k.strip(), v.strip())
except Exception:
pass
return
_sm_load_roots()
ARGV = sys.argv[1:]
def _opt(name, default=None):
if name in ARGV:
i = ARGV.index(name)
return ARGV[i + 1] if i + 1 < len(ARGV) else default
return default
WS = (_opt("--ws") or os.environ.get("DSH_COLLAB_WS") or os.environ.get("DSH_WS_ROOT")
or os.getcwd()).replace("\\", "/").rstrip("/")
CFG = os.environ.get("CODEBUDDY_CONFIG_DIR") or os.path.join(os.path.expanduser("~"), ".workbuddy")
DB = os.path.join(CFG, "workbuddy.db")
OUT = WS + "/.workbuddy/collab/session-plan.json" # ⛔ 不用 os.path.join(Windows 会出反斜杠,混排)
AS_JSON = "--json" in ARGV
QUIET = "--quiet" in ARGV
# 四类会话的标题前缀(口径 ⇒ skills/session-mechanism · 用户 2026-10-01 定「四类」)
ROLES = [("唤醒", "[唤醒]"), ("协作", "[协作]"), ("跟进", "[跟进]")]
# ⚠️ 只有「唤醒 / 跟进」要求**周期钟** —— 它们是"机制在跑"的两台钟(推 / 收)。
# **协作会话是按需创建的**(用户 2026-10-01 口径「随需求确定时创建」)⇒ 要求它有 recurring
# 排期就是**假警报**(本条判据首版就是这么写错的:首次实跑报了「协作类周期钟缺失」,
# 而那是设计如此)。判据必须跟口径同源,⛔ 不按"四类应当对称"想当然。
ROLES_CLOCK = [("唤醒", "[唤醒]"), ("跟进", "[跟进]")]
# flash 系模型**不支持关闭思考** ⇒ `model_is_thinking=0` 时服务端 -32603(`automation-request-refused`)
FLASH_HINT = ("flash",)
# 排期"已终结"的时长上限:`next_run_at` 距现在超过这么久 ⇒ 判为"钟停了"
STALE_FUTURE_MS = 365 * 24 * 3600 * 1000
CHECKS = [] # [{id, level, title, detail}]
def rec(cid, level, title, detail):
CHECKS.append({"id": cid, "level": level, "title": title, "detail": detail})
def _ro_conn():
"""活库铁律:只读打开 + busy_timeout(⛔ 绝不写宿主库)。"""
c = sqlite3.connect("file:%s?mode=ro" % DB.replace("\\", "/"), uri=True, timeout=8)
c.execute("PRAGMA busy_timeout=6000")
return c
def _norm_cwd(p):
"""与去重键同源:`path.trim().toLowerCase()`(⛔ 逐字比,别自作聪明归一成 basename)。"""
return str(p or "").replace("\\", "/").strip().lower()
def _now_ms():
return int(time.time() * 1000)
def main():
if not os.path.isfile(DB):
rec("db", "fail", "读不到宿主库", "库不在:%s" % DB)
return finish()
try:
c = _ro_conn()
except Exception as e:
rec("db", "fail", "打不开宿主库(只读)", str(e)[:160])
return finish()
# ── 取数:全部未删排期 + 本工作区相关的会话 ─────────────────────────
try:
autos = list(c.execute(
"select id,name,status,schedule_type,next_run_at,rrule,model_id,"
"model_is_thinking,cwds,last_run_at from automations where deleted_at is null"))
except Exception as e:
rec("db", "fail", "读 automations 失败", str(e)[:160])
return finish()
try:
sess = list(c.execute(
"select id,title,status,cwd,updated_at from sessions "
"where title like '[唤醒]%' or title like '[协作]%' or title like '[跟进]%'"))
except Exception:
sess = []
ws_norm = _norm_cwd(WS)
mine = [a for a in autos if any(_norm_cwd(x) == ws_norm
for x in (_safe_list(a[8])))] or []
# ── ① 周期钟:唤醒 / 跟进 两类必须有 recurring 排期 ────────────────────
clocks = {}
for a in autos:
nm = a[1] or ""
if a[3] != "recurring":
continue
if not any(_norm_cwd(x) == ws_norm for x in _safe_list(a[8])):
continue
for label, pre in ROLES_CLOCK:
if nm.startswith(pre):
clocks.setdefault(label, []).append(a)
missing_clock = [lb for lb, _ in ROLES_CLOCK if not clocks.get(lb)]
if missing_clock:
rec("clock", "fail", "周期钟缺失",
"这些类别没有任何 recurring 排期 ⇒ 没人在推 / 收:%s" % "、".join(missing_clock))
else:
rec("clock", "ok", "周期钟在册",
";".join("%s×%d" % (lb, len(clocks[lb])) for lb, _ in ROLES_CLOCK)
+ "(协作按需创建 ⇒ 不要求周期钟)")
# ── ② 钟会响吗:模型可用性(thinking=0 + flash ⇒ 必被拒) ─────────────
deaf = []
for lb, lst in clocks.items():
for a in lst:
mid = (a[6] or "").lower()
think = a[7]
if not think and any(h in mid for h in FLASH_HINT):
deaf.append("%s(%s)" % (a[1][:28], a[6]))
if deaf:
rec("model", "fail", "钟会被服务端拒(静默失效)",
"模型不支持关闭思考却传了 thinking=0 ⇒ 每触发必拒:%s" % "、".join(deaf))
else:
rec("model", "ok", "排期模型可用",
"周期排期均未命中「flash + 关思考」这一必拒组合")
# ── ③ 归属同形:cwds 逐字比 ──────────────────────────────────────────
off = []
for a in autos:
for x in _safe_list(a[8]):
s = str(x or "")
if not s.strip():
continue
if _norm_cwd(s) != s.replace("\\", "/").strip().lower():
pass
# 只报"本线的排期但字面不符"(别人的 cwds 不关我事)
if os.path.basename(ws_norm) in _norm_cwd(s) and _norm_cwd(s) != ws_norm:
off.append("%s → %s" % (a[1][:24], s))
if off:
rec("cwd", "fail", "cwds 与本工作区不同形",
"裂组且会自我强化(去重键=trim+toLowerCase):%s" % ";".join(off[:3]))
else:
rec("cwd", "ok", "cwds 归属同形", "本线排期的 cwds 与工作区路径逐字一致")
# ── ④ 活会话:三类当前有没有活的 ─────────────────────────────────────
live = {}
for lb, pre in ROLES:
hits = [s for s in sess if (s[1] or "").startswith(pre) and (s[2] or "") == "working"]
live[lb] = hits
dead_roles = [lb for lb, _ in ROLES if not live.get(lb)]
if dead_roles:
rec("live", "warn", "当前无活会话",
"这些类别此刻没有任何活着的会话(按需创建属正常;但主会话开工阶段须建齐):%s"
% "、".join(dead_roles))
else:
rec("live", "ok", "三类会话均有活的",
";".join("%s×%d" % (lb, len(live[lb])) for lb, _ in ROLES))
# ── ⑤ 投递在跑:协作程序 / 心跳新鲜度 ────────────────────────────────
hb = os.path.join(WS, ".workbuddy", "collab", "logs", "supervise-heartbeat.json")
hb_age = None
for cand in (hb, os.path.join(WS, ".workbuddy", "collab", "supervise-heartbeat.json")):
try:
if os.path.isfile(cand):
hb_age = time.time() - os.path.getmtime(cand)
break
except Exception:
pass
if hb_age is None:
rec("deliver", "warn", "投递(常驻)未见心跳",
"没找到心跳文件 ⇒ 无法证明投递在跑(⛔ 不等于它一定没跑;载体=专用容器会话)")
elif hb_age > 900:
rec("deliver", "fail", "投递心跳陈旧",
"最后心跳 %.0f 分钟前 ⇒ 常驻可能已掉线" % (hb_age / 60.0))
else:
rec("deliver", "ok", "投递心跳新鲜", "最后心跳 %.0f 秒前" % hb_age)
# ── ⑥ 死排期:`once` 已消耗 **且从未运行** ⇒ 活静默消失 ────────────────
# 🔴 判据要点两条(都是实测校正出来的,⛔ 别想当然):
# (a) **"已跑完"不是问题、"从未跑却失效"才是**(首版把两者混报 ⇒ 18 条正常痕迹被当 18 个问题);
# (b) **"有没有跑过"必须查 `automation_runs`,⛔ 不能查 `automations.last_run_at`** ——
# 该字段宿主**根本不写**(实测:本会话自己那条 `接续 · 会话机制合并包 · 任务4b-4d-6`
# 明明在跑,`last_run_at` 仍是 `None`)⇒ 拿它当判据 = 把"跑过的"全报成"没跑过"。
try:
ran = {r[0] for r in c.execute(
"select distinct automation_id from automation_runs limit 5000")}
except Exception:
ran = None
never_ran, consumed, unverifiable = [], [], []
for a in mine:
if a[3] != "once" or a[4]:
continue
if ran is None:
unverifiable.append((a[1] or "")[:30])
elif a[0] in ran:
consumed.append((a[1] or "")[:30])
else:
never_ran.append((a[1] or "")[:30])
if never_ran:
rec("zombie", "fail", "一次性排期从未运行就失效",
"共 %d 条 ⇒ 这些活会静默消失(消耗掉的一次性排期不会自动重跑):%s"
% (len(never_ran), "、".join(never_ran[:4])))
elif unverifiable:
rec("zombie", "warn", "无法核对一次性排期是否运行过",
"读不到 automation_runs(⛔ 不等于它们没跑);本线有 %d 条 once+无下次触发:%s"
% (len(unverifiable), "、".join(unverifiable[:3])))
elif consumed:
rec("zombie", "ok", "无「从未运行」的一次性排期",
"本线 %d 条已跑完的一次性排期(正常痕迹,不计问题)" % len(consumed))
else:
rec("zombie", "ok", "无残留的一次性排期", "本线没有「once 且不会再触发」的排期")
return finish()
def _safe_list(v):
"""cwds 字段形态不稳(JSON 数组 / 逗号串 / 单值)⇒ 一律解成 list[str]。"""
if v is None:
return []
if isinstance(v, (list, tuple)):
return [str(x) for x in v]
s = str(v).strip()
if s.startswith("["):
try:
r = json.loads(s)
return [str(x) for x in r] if isinstance(r, list) else [s]
except Exception:
return [s]
return [x.strip() for x in s.split(",") if x.strip()]
def finish():
bad = [c for c in CHECKS if c["level"] == "fail"]
warn = [c for c in CHECKS if c["level"] == "warn"]
verdict = "fail" if bad else ("warn" if warn else "ok")
summary = {
"ok": "会话规划齐备且生效",
"warn": "配置在册,运行未齐(见 warn 项)",
"fail": "会话规划有硬缺口(见 fail 项)",
}[verdict]
state = {
"ts": time.strftime("%Y-%m-%d %H:%M:%S"),
"ts_epoch": int(time.time()),
"ws": WS,
"verdict": verdict,
"summary": summary,
"counts": {"fail": len(bad), "warn": len(warn), "ok": len(CHECKS) - len(bad) - len(warn)},
"checks": CHECKS,
}
try:
os.makedirs(os.path.dirname(OUT), exist_ok=True)
tmp = OUT + ".tmp"
with io.open(tmp, "w", encoding="utf-8", newline="\n") as f:
f.write(json.dumps(state, ensure_ascii=False, indent=2))
os.replace(tmp, OUT) # 原子替换(本机文件锁会卡死 ⇒ 见 MEMORY)
except Exception as e:
state["write_error"] = str(e)[:160]
if AS_JSON:
sys.stdout.buffer.write(json.dumps(state, ensure_ascii=False, indent=2).encode("utf-8"))
sys.stdout.buffer.write(b"\n")
elif not QUIET:
icon = {"ok": "✅", "warn": "⚠️", "fail": "🔴"}[verdict]
buf = ["%s [会话规划] %s(fail %d / warn %d)" % (icon, summary, len(bad), len(warn))]
for c in CHECKS:
m = {"ok": "✓", "warn": "⚠", "fail": "✗"}[c["level"]]
buf.append(" %s %s —— %s" % (m, c["title"], c["detail"]))
buf.append(" · 状态标记 → %s" % OUT)
sys.stdout.buffer.write(("\n".join(buf) + "\n").encode("utf-8"))
sys.stdout.flush()
# 退出码语义:**只有"齐备且生效"才 0** —— warn 也要能被机器判出来(⛔ 不用它拦开工,只做标记)
return 0 if verdict == "ok" else 1
if __name__ == "__main__":
try:
sys.exit(main())
except Exception as e:
try:
sys.stdout.buffer.write(("⚠️ [会话规划] 体检自身异常(⛔ 不影响开工):%s\n"
% str(e)[:200]).encode("utf-8"))
except Exception:
pass
sys.exit(0)