Files
dsh_ai1net_server/归档/技能包-旧件-20261002/session-plan-check.py
T
admin c1b5e4d966 chore(工作区): 全量入库 + 补齐 .gitignore(以工作区为准)
- 变更规模:新增 514 / 修改 62 / 重命名 155 / 删除 4(归档重组与文档轮次)
- .gitignore 修:`归档/**/db-cwd归一-备份-*/` —— 原规则写绝对层级(归档/db-cwd归一-…),
  目录搬进 归档/配置与备份/ 后**静默失效**,43 MB 的 DB 备份又变成未跟踪
- .gitignore 补:嵌套 git 内部数据(归档/内嵌git-20261008/、归档/skills-git-旧线-20261007/dotgit-原样移出/)
- .gitignore 补:运行态与部署副本(.workbuddy/collab/、.workbuddy/tools/、.workbuddy/.load-pending、.workbuddy/tmp-*)
- .gitignore 补:备份件(*.bak-*)
- 未跟踪文件从 2190 降到 890(其余为 归档/ 归档件与 .workbuddy/memory/ 知识文件,按口径入库)
2026-10-10 23:13:22 +08:00

324 lines
15 KiB
Python
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 -*-
"""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)