# -*- coding: utf-8 -*- """同名重复会话分组 · 全库只读体检 ================================================ 用途:一条命令扫出「同一工作区在磁盘上有几份」,以及由此派生的四类隐患: ① 会话分组重复(projects\\ 与 DB 的 sessions.cwd 不再一一对应) ② 锁命名空间分裂(dsh 锁按 --ws 算键 ⇒ 两处各一把锁、互不排斥) ③ 入口文件双份 / 已分叉(接续棒会读到过期的「本轮动作」) ④ automation 的 cwds 指向非权威侧 只读。绝不写 workbuddy.db、不动任何工作区文件。 🔴 2026-09-28 现状:本脚本默认比对的「镜像侧」`E:\ProgramDSH\AIProject` **已整树删除** ⇒ 脚本**功能已失效**(两侧恒为空对 ⇒ 只走"跳过"分支)。代码保留备查;若日后重建镜像树,可原样复用。 判据与成因分类见 SKILL.md §0.6(甲~己)与「实测事故·四」。 用法: python audit-mirror.py # 全量(默认) python audit-mirror.py --roots E:\\ProgramData\\AIProject E:\\ProgramDSH\\AIProject python audit-mirror.py --only ai1net_ui # 只看名字含该串的工作区 """ from __future__ import annotations import argparse import datetime import hashlib import os import re import sqlite3 import sys BS = chr(92) # 反斜杠;用 chr() 免得被各种 shell 的转义吃掉 DSH_HOME_DEFAULT = r"E:\ProgramDSH\.dsh" WB_HOME_DEFAULT = r"E:\ProgramData\.workbuddy" DEFAULT_ROOTS = [ r"E:\ProgramData\AIProject", r"E:\ProgramDSH\AIProject", r"E:\ProgramData\WorkBuddy", r"C:\Users\Administrator\WorkBuddy", ] ENTRY_KEYS = ("接续入口", "交接单", "接续包") SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "_venv", "dist", "build", ".next"} # ---------------------------------------------------------------- 工具 def norm(p: str) -> str: return p.replace("/", BS).rstrip(BS) def slug_of(path: str) -> str: """复现宿主的 cwd -> 分组目录名 规则:分隔符统一成 '-'、合并连续、去首尾、盘符转小写。 实测(2026-09-27 · 7/7 命中): 'E:/ProgramData/AIProject/ai1net_ui' -> 'e-ProgramData-AIProject-ai1net_ui' 注意:**只小写盘符**,中间路径段保留原大小写。 """ s = path.strip() for ch in (BS, ":", "/", " "): s = s.replace(ch, "-") s = re.sub("-+", "-", s).strip("-") return (s[0].lower() + s[1:]) if len(s) > 1 and s[1] == "-" else s def ts(v) -> str: try: return datetime.datetime.fromtimestamp(v / 1000).strftime("%m-%d %H:%M:%S") except Exception: return str(v) def manifest(root: str, cap: int = 4000) -> dict: out = {} for dirpath, dirnames, filenames in os.walk(root): dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS] for f in filenames: full = os.path.join(dirpath, f) try: st = os.stat(full) except OSError: continue out[os.path.relpath(full, root)] = (st.st_size, int(st.st_mtime)) if len(out) >= cap: out["__TRUNCATED__"] = (1, 0) return out return out def compare(a: str, b: str) -> dict: ma, mb = manifest(a), manifest(b) ka, kb = set(ma) - {"__TRUNCATED__"}, set(mb) - {"__TRUNCATED__"} only_a, only_b = sorted(ka - kb), sorted(kb - ka) size_diff = sorted(k for k in ka & kb if ma[k][0] != mb[k][0]) return { "files_a": len(ka), "files_b": len(kb), "only_a": only_a[:6], "n_only_a": len(only_a), "only_b": only_b[:6], "n_only_b": len(only_b), "size_diff": size_diff[:6], "n_size_diff": len(size_diff), "trunc": ("__TRUNCATED__" in ma or "__TRUNCATED__" in mb), } def md5(p: str) -> str: with open(p, "rb") as fh: return hashlib.md5(fh.read()).hexdigest()[:8] def hr(title: str) -> None: print() print("=" * 78) print(title) print("=" * 78) # ---------------------------------------------------------------- 各节 def read_db(wb_home: str): db = os.path.join(wb_home, "workbuddy.db") if not os.path.isfile(db): print("⛔ 找不到 %s" % db) sys.exit(2) con = sqlite3.connect("file:%s?mode=ro" % db.replace(BS, "/"), uri=True) sessions = list(con.execute( "select id,cwd,is_background_automation,deleted_at,created_at,last_activity_at," "coalesce(custom_title,title) from sessions order by cwd,created_at")) automations = list(con.execute( "select id,name,status,schedule_type,scheduled_at,cwds,created_at from automations order by created_at")) return con, sessions, automations def sec1_sessions(sessions, roots, only): hr("§1 会话 cwd 存在性 + 同名镜像检测") bycw = {} for r in sessions: bycw.setdefault(norm(r[1]) if r[1] else "", []).append(r) pairs = [] for c in sorted(bycw): if not c: continue base = os.path.basename(c) if only and only not in base: continue sibs = [os.path.join(rt, base) for rt in roots if os.path.isdir(os.path.join(rt, base)) and os.path.normcase(os.path.join(rt, base)) != os.path.normcase(c)] print("\n--- cwd = %s" % c) print(" 目录存在: %-5s 会话数: %d" % (os.path.isdir(c), len(bycw[c]))) for r in bycw[c]: print(" %s auto=%s del=%s created=%s last=%s %r" % ( r[0][:8], r[2], r[3], ts(r[4]), ts(r[5]), r[6][:36])) if sibs: for s in sibs: pairs.append((c, s)) print(" [戊] 同名镜像: %s" % s) else: print(" [ok] 无同名镜像") return pairs def sec2_pairs(pairs): hr("§2 镜像对内容比对(真副本 / 空骨架 / 谁更全)") if not pairs: print("(无镜像对)") for a, b in pairs: print("\n--- %s\n vs %s" % (a, b)) if not (os.path.isdir(a) and os.path.isdir(b)): print(" ⚠️ 有一侧不存在 —— 属**己类**(入口声明与磁盘不符),不是戊类") continue r = compare(a, b) print(" 文件数 A=%d B=%d%s" % (r["files_a"], r["files_b"], " (已截断)" if r["trunc"] else "")) print(" 仅 A 有 %d;仅 B 有 %d;同名但大小不同 %d" % ( r["n_only_a"], r["n_only_b"], r["n_size_diff"])) for k, lbl in (("only_a", "A 独有"), ("only_b", "B 独有"), ("size_diff", "大小不同")): if r[k]: print(" %s: %s" % (lbl, r[k])) if not r["trunc"] and r["n_only_a"] == r["n_only_b"] == r["n_size_diff"] == 0: print(" => 逐字节同构(真副本,最危险)") elif r["n_only_a"] > r["n_only_b"]: print(" => A 更全 ⇒ **A 极可能是权威侧**") elif r["n_only_b"] > r["n_only_a"]: print(" => B 更全 ⇒ **B 极可能是权威侧**") def sec3_entries(sessions, roots, only): hr("§3 入口 / 交接类文件:双份与分叉") seen_base = set() for r in sessions: if not r[1]: continue c = norm(r[1]) base = os.path.basename(c) if base in seen_base or (only and only not in base): continue seen_base.add(base) dirs, seen = [c], {os.path.normcase(c)} for rt in roots: p = os.path.join(rt, base) if os.path.isdir(p) and os.path.normcase(p) not in seen: dirs.append(p) seen.add(os.path.normcase(p)) print("\n--- 工作区 %s" % base) found = [] for d in dirs: if not os.path.isdir(d): continue for f in sorted(os.listdir(d)): if f.lower().endswith(".md") and any(k in f for k in ENTRY_KEYS): p = os.path.join(d, f) found.append((p, os.path.getsize(p), datetime.datetime.fromtimestamp(os.path.getmtime(p)).strftime("%m-%d %H:%M"), md5(p))) if not found: print(" (无入口类文件)") continue for p, sz, mt, h in found: print(" %-62s %7dB %s md5=%s" % (p, sz, mt, h)) names = {} for p, sz, mt, h in found: names.setdefault(os.path.basename(p), []).append((p, sz, h)) for nm, lst in names.items(): if len(lst) > 1 and len({x[2] for x in lst}) > 1: print(" 🔴 已分叉: %s 字节 %s" % (nm, [x[1] for x in lst])) def sec4_automations(automations, sessions, roots): hr("§4 automation 的 cwds 归属") import json as _j live = {norm(r[1]) for r in sessions if r[1]} for aid, name, status, stype, at, cwds, created in automations: try: arr = _j.loads(cwds) if cwds else [] except Exception: arr = [cwds] print("\n--- %s %r" % (aid[:8], (name or "")[:44])) print(" status=%s type=%s at=%s created=%s" % (status, stype, at, ts(created))) for c in arr: c2 = norm(c) others = [os.path.join(rt, os.path.basename(c2)) for rt in roots if os.path.isdir(os.path.join(rt, os.path.basename(c2))) and os.path.normcase(os.path.join(rt, os.path.basename(c2))) != os.path.normcase(c2)] ok_exist = os.path.isdir(c2) authoritative = c2 in live or any(norm(o) in live for o in others) marks = [] marks.append("存在" if ok_exist else "🔴不存在(己类:会建幽灵分组)") if others: marks.append("有同名镜像%d个" % len(others)) if not authoritative: marks.append("🔴会话不在该路径(疑似写到非权威侧)") print(" cwds=%s %s" % (c2, " · ".join(marks))) for o in others: print(" 镜像侧: %s" % o) def sec5_locks(dsh_home): hr("§5 dsh 锁命名空间残留(按改动时间倒序)") lk = os.path.join(dsh_home, ".locks") if not os.path.isdir(lk): print(" (无 %s)" % lk) return rows = [] for d in sorted(os.listdir(lk)): p = os.path.join(lk, d) if not os.path.isdir(p): continue files = [] for rt, _, fs in os.walk(p): files += [os.path.relpath(os.path.join(rt, f), p) for f in fs] rows.append((os.path.getmtime(p), d, len(files))) for mt, d, n in sorted(rows, reverse=True): print(" %s %-52s 文件=%d" % ( datetime.datetime.fromtimestamp(mt).strftime("%Y-%m-%d %H:%M:%S"), d, n)) # 同一工作区两套键 print("\n == 同一工作区出现两套键(锁不互斥的判据) ==") fams = {} for _, d, _n in rows: m = re.match(r"^(e|d|c|f)-(programdata|programdsh|github|tmp)?-?(.*?)-[0-9a-f]{8}$", d) if m: fams.setdefault(m.group(3), set()).add(d) hit = {k: v for k, v in fams.items() if len(v) > 1} if not hit: print(" 无") for k, v in sorted(hit.items()): print(" 🔴 %s -> %s" % (k, sorted(v))) def sec6_groups(wb_home, sessions): hr("§6 projects 分组目录 <-> DB cwd 是否一一对应") proj = os.path.join(wb_home, "projects") if not os.path.isdir(proj): print(" (无 %s)" % proj) return cwds = sorted({norm(r[1]) for r in sessions if r[1]}) groups = sorted(d for d in os.listdir(proj) if os.path.isdir(os.path.join(proj, d))) idx = {slug_of(c).lower(): c for c in cwds} print(" 分组数=%d 不同 cwd 数=%d" % (len(groups), len(cwds))) orphan = [] for d in groups: n = len([f for f in os.listdir(os.path.join(proj, d)) if f.endswith(".jsonl")]) hit = idx.get(d.lower()) print(" %-52s jsonl=%-3d %s" % (d, n, ("<= " + hit) if hit else "🔴 无对应 cwd(孤儿分组)")) if not hit: orphan.append(d) miss = [c for c in cwds if slug_of(c).lower() not in [g.lower() for g in groups]] print("\n 孤儿分组 = %d %s" % (len(orphan), orphan)) print(" 未建组的 cwd = %d %s" % (len(miss), miss)) if not orphan and not miss: print(" ✅ 一一对应,无重复分组") def sec7_mirror_tree(roots): hr("§7 根目录整树比对(找「镜像树」,甲/戊/己的共同温床)") ai = [os.path.join(r, "AIProject") for r in roots] a, b = r"E:\ProgramData\AIProject", r"E:\ProgramDSH\AIProject" if not (os.path.isdir(a) and os.path.isdir(b)): print(" (无 ProgramData\\AIProject / ProgramDSH\\AIProject 对,跳过)") return la, lb = set(os.listdir(a)), set(os.listdir(b)) both = sorted(la & lb) print(" A = %s 目录数=%d" % (a, len(la))) print(" B = %s 目录数=%d" % (b, len(lb))) print(" 同名(B 是 A 的镜像候选)= %d 个: %s" % (len(both), both)) print(" 仅 A 有 = %d 个: %s" % (len(la - lb), sorted(la - lb))) print(" 仅 B 有 = %d 个: %s" % (len(lb - la), sorted(lb - la))) if both: print(" ⇒ ⚠️ 上面这些同名目录每多一个,就多一份「工作区归属判错」的机会") def main(): ap = argparse.ArgumentParser() ap.add_argument("--wb-home", default=WB_HOME_DEFAULT) ap.add_argument("--dsh-home", default=DSH_HOME_DEFAULT) ap.add_argument("--roots", nargs="*", default=None) ap.add_argument("--only", default=None, help="只看名字含该串的工作区") ns = ap.parse_args() roots = ns.roots if ns.roots else DEFAULT_ROOTS import json as _j con, sessions, automations = read_db(ns.wb_home) print("workbuddy home = %s" % ns.wb_home) print("dsh home = %s" % ns.dsh_home) print("候选根 = %s" % roots) print("会话 %d 条 / automation %d 条" % (len(sessions), len(automations))) pairs = sec1_sessions(sessions, roots, ns.only) sec2_pairs(pairs) sec3_entries(sessions, roots, ns.only) sec4_automations(automations, sessions, roots) sec5_locks(ns.dsh_home) sec6_groups(ns.wb_home, sessions) sec7_mirror_tree(roots) hr("总判读口径") print(" ① 同一工作区在磁盘上有几个目录?>1 ⇒ 戊类(先定权威再开工)") print(" ② 入口声明的路径 Test-Path 是否成立?不成立 ⇒ 己类(回填入口头部,⛔ 别新建目录)") print(" ③ automation 的 cwds 是否落在「会话实际所在的那一侧」?否 ⇒ 立刻改,否则多一个分组") print(" ④ 旧侧入口是否已改 stub?只写「停维护」不算 ⇒ 两侧必分叉") print(" ⑤ 锁有几套键?一个工作区两套 = 锁不互斥(比 UI 观感危险)") if __name__ == "__main__": main()