#!/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` 为空 ⇒ 已消耗、**不会再触发**(静默消失的活) 产出(**标记一个状态**): `/.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)