用户令逐字:「把 检查程序等待时间 改为3分钟,现在应该是6分钟」。 调整轨迹:**20 → 10 → 6 → 3**(分别是当日 09:xx / 18:25 / 本次 02:4x 三次用户指令)。 改法(四处同源,⛔ 少改一处就会出现「判据说 3、写给检查会话的文案说 6」): 1. 常量 `CHECK_IDLE_MIN`:6 → 3; 2. `CHECK_PROMPT_GOAL` 里写给检查会话看的文案:`已静默 ≥6 分钟` → `≥3 分钟`; 3. 注释/文档:`# 【目标检查】` 那行、`_last_progress_ts` docstring、 `references/supervise-persistence.md` §六、`selftest.py` 的口径说明。 ⚠️ 另有两处「留痕注释」(写"两度拍板"、"由 20 改 10")本轮**没能改到** —— 实测那两行的当前文本与上一轮 grep 到的已不一致,怀疑**另一个会话正在改同一批文件** (该区当前目标就叫「技能包规则文件已全量过说人话」)⇒ ⛔ 不硬改(避免与它打架),如实留待下轮核对。 判据:`selftest.py::t_check_idle_min` 守的是「常量/闸门/试算/文案四处同源」(⛔ 不锁具体数值) ⇒ 本次改动后仍绿,等于自动证明四处都已是 3。 验收:selftest.py rc=0 PASS 106 / FAIL 0;manifest 76 份、语法失败 0。
7442 lines
482 KiB
Python
7442 lines
482 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""selftest —— 「多会话协作机制」的**回归测试**:把**所有异常情况**做成用例,每次改完跑一遍。
|
||
|
||
🔴 纪律(2026-09-29 用户定案):**每次改完 collabd.py / guard.py,必须跑本文件**,全绿才算改完。
|
||
设计原则:
|
||
· **不碰生产**:整套测试用 `COLLABD_CONFIG` 指向 `tmp/selftest/`(独立 inbox / 看板 / 任务图),
|
||
生产台账与看板**一个字节都不动**。
|
||
· **离线可跑**:需要宿主库的用例,读不到就记 SKIP(⛔ 不假装通过)。
|
||
· **判据是"读数"不是"没报错"**:rc、文件是否生成、台账状态、投递台账增量 …
|
||
· FAIL ⇒ **非零退出**(配合"rc=0 才是真成功")。
|
||
|
||
用法:python selftest.py # 全部
|
||
python selftest.py -k 投递 # 只跑名字含"投递"的用例
|
||
"""
|
||
from __future__ import annotations
|
||
# [session-mechanism] roots.env 外置(由 install.py 生成;缺失则回落到按位置推导)
|
||
def _sm_load_roots():
|
||
import os as _os
|
||
_here = _os.path.dirname(_os.path.abspath(__file__))
|
||
for _up in range(4):
|
||
_p = _os.path.join(_here, *([".."] * _up), "roots.env")
|
||
_p = _os.path.normpath(_p)
|
||
if _os.path.isfile(_p):
|
||
try:
|
||
with open(_p, encoding="utf-8") as _f:
|
||
for _ln in _f:
|
||
_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()
|
||
# 🔴 2026-10-01 加 · **输出编码兜底**:脚本一旦被重定向(钩子/常驻/后台任务都会这么干),
|
||
# Windows 本地编码(GBK)编不出 ⛔/✅/🔴 这类字符 ⇒ `print` 抛 UnicodeEncodeError
|
||
# ⇒ 被顶层 handler 记成 `fatal`、**整轮失败**(实测:本包里连续 4 次 `fatal 'gbk' codec ...`)。
|
||
# ⇒ 出口一律 UTF-8 + errors="replace"(⛔ 不让"打不出字"升级成"程序死")。
|
||
try:
|
||
import sys as _sys
|
||
_sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||
_sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
from pathlib import Path
|
||
|
||
HERE = Path(__file__).resolve().parent
|
||
WS = Path(os.environ.get("DSH_COLLAB_WS") or os.environ.get("DSH_WS_ROOT") or Path.cwd())
|
||
TEST_WS = WS / "tmp" / "selftest"
|
||
TIN = TEST_WS / "inbox"
|
||
CFG = TEST_WS / "collabd.config.json"
|
||
PY = sys.executable
|
||
RESULTS = []
|
||
|
||
|
||
# 🔴🔴 2026-10-04 新增:**本工作区是否真启用了这套机制**(判「生产/副本」用)。
|
||
# 起因(`vibe-product` 实测坐实):那是个**技能副本**(`.workbuddy/skills/session-mechanism/`),
|
||
# **根本没启用机制** —— 连 `tmp/supervise-inbox/goal.json` 与 `.workbuddy/collab/collabd.py`
|
||
# 都不存在 ⇒ 那些「量**生产工作区部署齐全**」的判据(交付物/目标执行状态.md、peer 配置、
|
||
# 目标检查脚本、跨区 peer 格)**必然报红** ⇒ 副本被当生产环境自测。
|
||
# ⚠️ 这**不是技能有错**,是判据没声明适用范围 ⇒ 用**跳过**表达,⛔ 不许改成"永远绿"(那是把判据废掉)。
|
||
def _mech_on() -> bool:
|
||
"""本工作区**启用了**会话协作机制吗?判据=真源文件在不在(⛔ 不看 env、不猜)。"""
|
||
try:
|
||
return (WS / "tmp" / "supervise-inbox" / "goal.json").exists()
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
MECH_ON = _mech_on()
|
||
|
||
|
||
def _strip_comments_and_docs(src: str) -> str:
|
||
"""把 Python 源码里的**注释 + 字符串(含 docstring)**全部剔掉,只留可执行骨架。
|
||
|
||
🔴🔴 2026-10-05 新增。**为什么必须有一个共用实现**(⛔ 不许各用例各写一份):
|
||
本项目有大量"判据查源码里有没有某字面"的用例。而本仓库的**考古纪律**要求:
|
||
废弃形态的说明**必须留在注释/docstring 里**(写明"曾怎么做、为什么废弃")。
|
||
⇒ 那些字面**注定存在** ⇒ 拿全文 `not in src` 判「已清除」**必然误红**。
|
||
|
||
⚠️ **同一个坑我今天踩了两遍**:
|
||
① `t_declare_resets_lifecycle`:只剔 `#` 行 ⇒ 这次对了(那句在 `#` 注释里);
|
||
② 本条守卫:`-File start-supervise.ps1` 那句在 **docstring** 里 ⇒ 只剔 `#` 行
|
||
**照样命中** ⇒ 又误红一次。
|
||
⇒ 结论:**「剔注释」≠「剔 docstring」**,必须用 `tokenize` 一次剔干净
|
||
(⛔ 别用正则凑 —— 多行字符串、嵌套引号、f-string 都会漏)。
|
||
|
||
⛔ 用 `tokenize` 失败(比如源码本身语法坏)时**回落到"保留原文"**:
|
||
宁可误红(能被看见)也不许误绿(静默放行)。
|
||
|
||
🔴🔴 **2026-10-05 二次修正(又踩一坑)**:第一版把 `STRING` **全剔**,结果把
|
||
「写在 PowerShell 命令**字符串里**的字面」也剔掉了(`New-ScheduledTaskAction`、
|
||
`supervise-launch.py` 正是**字符串内容**)⇒ 判据**由误红变成误红**(换个方向)。
|
||
✅ 正解=**只剔两样**:① `#` 注释;② **docstring**(用 `ast` 精确定位,⛔ 不用正则)。
|
||
普通字符串**一律保留** —— 因为"代码里干了什么"经常就写在字符串里(PowerShell 命令、
|
||
文件名、任务名……),剔掉等于**看不见真实现**。
|
||
"""
|
||
import ast as _ast
|
||
import io
|
||
import tokenize as _tk
|
||
# ① 先用 ast 抓出所有 docstring 的**起止行列**(模块/类/函数首表达式字符串)
|
||
_doc_spans = []
|
||
try:
|
||
_tree = _ast.parse(src)
|
||
for _node in _ast.walk(_tree):
|
||
if isinstance(_node, (_ast.Module, _ast.ClassDef, _ast.FunctionDef,
|
||
_ast.AsyncFunctionDef)):
|
||
_b = getattr(_node, "body", None)
|
||
if (_b and isinstance(_b[0], _ast.Expr)
|
||
and isinstance(_b[0].value, _ast.Constant)
|
||
and isinstance(_b[0].value.value, str)):
|
||
_doc_spans.append((_b[0].value.lineno, getattr(_b[0].value, "end_lineno", _b[0].value.lineno)))
|
||
except Exception:
|
||
_doc_spans = []
|
||
|
||
def _in_doc(ln):
|
||
return any(a <= ln <= b for a, b in _doc_spans)
|
||
|
||
# ② 逐 token 剔:注释一律剔;字符串**只在 docstring 位置**剔
|
||
try:
|
||
out = []
|
||
for tok in _tk.generate_tokens(io.StringIO(src).readline):
|
||
if tok.type == _tk.COMMENT:
|
||
continue
|
||
if tok.type == _tk.STRING and _in_doc(tok.start[0]):
|
||
continue
|
||
out.append(tok.string)
|
||
return " ".join(out)
|
||
except Exception:
|
||
return src
|
||
|
||
|
||
def _mk_env() -> dict:
|
||
env = dict(os.environ)
|
||
env["COLLABD_CONFIG"] = str(CFG)
|
||
env["DSH_COLLAB_WS"] = str(TEST_WS)
|
||
# 🔴 2026-10-02(S8):**测试环境里⛔ 不许起常驻**。
|
||
# 真因(本棒实测):`--tick` 现在会顺手"续命常驻"(`ensure_supervise()`),而自测用的
|
||
# `--tick` 继承了真环境的网关口令 ⇒ 它会在**测试工作区**里起一条真的 `--supervise`
|
||
# ⇒ 那条进程会持续 `supervise()` 并**重写测试夹具**(实测:`已停总闸` 用例报
|
||
# `告警投影被清掉` 假红、且 FAIL 数每轮不同)+ 留下僵尸测试进程。
|
||
# ⇒ 显式关掉(⛔ 不是"把断言写松")。
|
||
env["COLLABD_NO_ENSURE"] = "1"
|
||
return env
|
||
|
||
|
||
def _prepare() -> None:
|
||
TIN.mkdir(parents=True, exist_ok=True)
|
||
for _f in ("wake.lock", "guard.stop", "goal.pending.json",
|
||
# 🔴 2026-10-01 加:退场表也每轮清 —— 它会**跨轮留下来**,
|
||
# 而退场用例断言的是精确集合(`retired==1 / stale==1`)⇒
|
||
# 上一轮留下的那条会在下一轮把计数顶成 2 ⇒ **第二次跑才红的假红**(最难查的一型)。
|
||
"retired.json"): # 清上一轮残留(防跨轮假红)
|
||
try:
|
||
(TIN / _f).unlink()
|
||
except Exception:
|
||
pass
|
||
# 🔴 2026-10-01 加:**假配置根(假的会话日志)也每轮清空** —— 这棵树的文件会跨轮留下来,
|
||
# 而「哑会话」用例断言的是**精确集合**(`d == {DEAF}`)⇒ 上一轮留下的昨天那份 10 MiB
|
||
# 会在下一轮把结果多出几条 ⇒ **第二次跑才红的假红**(最难查的一型)。⛔ 只删测试区内的。
|
||
try:
|
||
shutil.rmtree(TEST_WS / "fake-cfg", ignore_errors=True)
|
||
except Exception:
|
||
pass
|
||
(TEST_WS / "live.md").write_text("(selftest)", encoding="utf-8")
|
||
(TEST_WS / "tg.json").write_text(json.dumps({"nodes": [
|
||
{"id": "T1", "title": "测试件-1", "status": "todo", "line": "line-a"},
|
||
{"id": "T2", "title": "测试件-2", "status": "done", "line": "line-b"},
|
||
]}, ensure_ascii=False), encoding="utf-8")
|
||
CFG.write_text(json.dumps({
|
||
"_说明": "selftest 专用(⛔ 不碰生产)",
|
||
"workspace": str(TEST_WS).replace("\\", "/"),
|
||
"inbox": "inbox",
|
||
"live": "live.md",
|
||
"taskgraph": "tg.json",
|
||
"lines": {"line-a": "A 线", "line-b": "B 线"},
|
||
"goal_docs": [], "targets": {},
|
||
"host_db": "",
|
||
"singleton_port": 20098,
|
||
"interval": 5, "idle_min": 12, "stuck_min": 30, "vacuum_min": 5,
|
||
"health_every": 300, "wake_enable": True, "wake_min_gap": 300, "wake_max_ports": 4,
|
||
"wake_text": "(selftest)",
|
||
}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||
|
||
|
||
def case(name):
|
||
def deco(fn):
|
||
fn._case = name
|
||
return fn
|
||
return deco
|
||
|
||
|
||
def report(name):
|
||
"""**报告型**用例:照常显示、照常报红/报绿,但 ⛔ **不计入 `--verify` 的成败**。
|
||
|
||
🔴 2026-10-04 新增(口径判据落地时撞到):有些检查**天生依赖现网存量** ——
|
||
例「产物是否都落在目标文件夹」在**历史散落的文件清理掉之前**必然红。
|
||
⇒ 若让它计入 verify ⇒ **`install.py --verify` 在新机器上永远失败**,
|
||
而那个失败与"安装对不对"**毫无关系** ⇒ 判据被当成安装故障 ⇒ ⛔ 误报。
|
||
✅ 判据纪律:**"装得对不对"与"存量干不干净"必须分开判**,
|
||
后者只**如实报**,不牵动前者(否则一条提示会永久堵住安装)。
|
||
"""
|
||
def deco(fn):
|
||
fn._case = name
|
||
fn._report_only = True
|
||
return fn
|
||
return deco
|
||
|
||
|
||
def run_cli(args, timeout=60):
|
||
p = subprocess.run([PY, "-u", str(HERE / "collabd.py")] + args, cwd=str(HERE),
|
||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||
env=_mk_env(), timeout=timeout, errors="replace")
|
||
return p.returncode, p.stdout or ""
|
||
|
||
|
||
def tasks() -> dict:
|
||
try:
|
||
return json.loads((TIN / "tasks.json").read_text(encoding="utf-8")) or {}
|
||
except Exception:
|
||
return {}
|
||
|
||
|
||
def wakeups_n() -> int:
|
||
try:
|
||
return len((TIN / "wakeups.jsonl").read_text(encoding="utf-8").strip().splitlines())
|
||
except Exception:
|
||
return 0
|
||
|
||
|
||
def state() -> dict:
|
||
try:
|
||
return json.loads((TIN / "collabd-state.json").read_text(encoding="utf-8")) or {}
|
||
except Exception:
|
||
return {}
|
||
|
||
|
||
def put_tasks(t: dict) -> None:
|
||
(TIN / "tasks.json").write_text(json.dumps(t, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
|
||
|
||
def run_cli_env(args, env: dict, timeout=60):
|
||
"""同 `run_cli`,但**自定义环境**(用例要故意抽掉网关口令等)。"""
|
||
p = subprocess.run([PY, "-u", str(HERE / "collabd.py")] + args, cwd=str(HERE),
|
||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||
env=env, timeout=timeout, errors="replace")
|
||
return p.returncode, p.stdout or ""
|
||
|
||
|
||
_PROD_SNAP = {}
|
||
|
||
|
||
def _snap_prod():
|
||
"""记录**生产 inbox** 关键产物的 (mtime,size) —— 元用例据此断言「测试不碰生产」。"""
|
||
global _PROD_SNAP
|
||
_PROD_SNAP = {}
|
||
for f in ("tasks.json", "wakeups.jsonl", "digest.md", "TO-MAIN.md", "wake.lock"):
|
||
p = WS / "tmp" / "supervise-inbox" / f
|
||
try:
|
||
st = p.stat()
|
||
_PROD_SNAP[f] = (st.st_mtime, st.st_size)
|
||
except Exception:
|
||
_PROD_SNAP[f] = None
|
||
|
||
|
||
def imp():
|
||
# 🔴 先设环境,**再加载模块** —— 否则模块会去读**生产配置**(本用例曾因此"碰生产"+结果飘)
|
||
os.environ["COLLABD_CONFIG"] = str(CFG)
|
||
os.environ["DSH_COLLAB_WS"] = str(TEST_WS)
|
||
import importlib.util
|
||
spec = importlib.util.spec_from_file_location("cb_selftest", HERE / "collabd.py")
|
||
m = importlib.util.module_from_spec(spec)
|
||
spec.loader.exec_module(m)
|
||
return m
|
||
|
||
|
||
# ── 用例(每条都对应一个真实踩过的坑) ─────────────────────────────
|
||
@case("命名:两级前缀解析(合规 / 缺级 / 接续会话 / 主控前缀)")
|
||
def t_name():
|
||
m = imp()
|
||
a = m.parse_session_name("[协作]-[手机接入]-N9 复测")
|
||
b = m.parse_session_name("[主]-[手机接入]-机制线")
|
||
c = m.parse_session_name("[协作]N9-2156") # 只一级 ⇒ 不合规
|
||
d = m.parse_session_name("接续 · 机制线(旧式)")
|
||
# 🆕 2026-10-01:**接续会话**(会话自己建出来的"下一棒",标题由建它的那条会话写)
|
||
e = m.parse_session_name("[唤醒机制] 接续 · 日志事前叫停钩子落地(第 2 棒)")
|
||
f = m.parse_session_name("接续棒:日志增长治理(任务 0 → 任务 A)")
|
||
# 🆕 **主控前缀**(实测现役主会话就这么写:中点分隔、无方括号)
|
||
g = m.parse_session_name("主控 · 协作机制 · 阈值交接收尾")
|
||
return [("合规二级=worker/手机接入", (a["role"], a["topic"], a["ok"]) == ("worker", "手机接入", True)),
|
||
("合规二级=main", (b["role"], b["topic"], b["ok"]) == ("main", "手机接入", True)),
|
||
("只一级 ⇒ 判不合规", c["ok"] is False),
|
||
("🔴 只一级 ⇒ 角色仍判 worker(旧写法剥错方括号 ⇒ 靠 `role_of` 兜底才没出事)",
|
||
c["role"] == "worker"),
|
||
("旧式接续 ⇒ 判不合规", d["ok"] is False),
|
||
("🆕 `[<类别>] 接续 · …` ⇒ **worker**,类别取方括号里那一个(⛔ 方括号里不是角色)",
|
||
(e["role"], e["topic"], e["form"]) == ("worker", "唤醒机制", "continuation")),
|
||
("🆕 无方括号 `接续棒:…` ⇒ **worker**(⛔ 不是主会话)", f["role"] == "worker"),
|
||
("🆕 `主控 · …`(无方括号)⇒ **main**(否则真主会话被判「角色未知」)",
|
||
(g["role"], g["form"]) == ("main", "main-prefix")),
|
||
("🆕 `form` 标出形态;老调用方只看 `ok` ⇒ 兼容(`ok=False` 语义未变)", a["form"] == "prefix")]
|
||
|
||
|
||
@case("命名:看板与常驻程序**同一套角色判据**(⛔ 防两处漂移 —— 漂了就没人能发现换主会话)")
|
||
def t_role_drift():
|
||
"""角色判据在 `collabd.py::parse_session_name()` 与 `board.py::_role_of_title()` **两处**
|
||
(2026-09-30 起就是"改一处必须改两处")⇒ 这里**真的把两边拉出来逐样本对账**,⛔ 不靠人盯。"""
|
||
m = imp()
|
||
import importlib.util
|
||
spec = importlib.util.spec_from_file_location("bd_selftest", HERE / "board.py")
|
||
bd = importlib.util.module_from_spec(spec)
|
||
spec.loader.exec_module(bd)
|
||
samples = ["[协作]-[唤醒机制]-日志事前叫停棒", "[唤醒]-[唤醒机制]-脉冲", "[主]-[唤醒机制]-机制线",
|
||
"[跟进]-[会话协作自检]-队列上报跟进棒",
|
||
"[唤醒机制] 接续 · 第 2 棒", "接续棒:日志增长治理(任务 0 → 任务 A)",
|
||
"主控 · 协作机制 · 收尾", "接续 · 机制线(旧式)", "[协作]N9-2156",
|
||
# 🔴 2026-10-01 补(**第三处漂移**,扫线上标题时发现的,不是推理出来的):
|
||
# `[接续] …` 这种写法**方括号里是「接续」**(⛔ 不是类别)——
|
||
# `collabd.is_continuation()` 判 `"接续" in name`(整串)⇒ worker;
|
||
# 而 `board._role_of_title()` 原来只查**方括号后面那段** ⇒ 返回**空**。
|
||
# 后果:看板的「主会话」位上坐着一条**接续棒**(线上实测 `3aa35bae`,355 分钟没动)。
|
||
"[接续] 会话机制合并技能包(第 1 棒)",
|
||
"下载 open design", ""]
|
||
bad = [t for t in samples if m.parse_session_name(t)["role"] != bd._role_of_title(t)]
|
||
sa = (HERE / "collabd.py").read_text(encoding="utf-8")
|
||
sb = (HERE / "board.py").read_text(encoding="utf-8")
|
||
return [
|
||
("两边角色判据逐样本一致(%d 条;不一致:%s)" % (len(samples), bad or "无"), not bad),
|
||
("🔴🔴 2026-10-02 两类改造:`[唤醒]`/`[跟进]` 两边**都判空**(已退役)—— ⛔ 判成活角色 ⇒ "
|
||
"那些历史标题会被当**主会话候选**(带 `[<类别>]` 时更会变成「该类别的主会话」)⇒ 自指死结",
|
||
m.parse_session_name("[唤醒]-[X]-脉冲").get("role") == ""
|
||
and bd._role_of_title("[唤醒]-[X]-脉冲") == ""
|
||
and m.parse_session_name("[跟进]-[X]-队列上报").get("role") == ""
|
||
and bd._role_of_title("[跟进]-[X]-队列上报") == ""),
|
||
("🔴🔴 `[接续] …`(**方括号里是「接续」**)两边都判 `worker` —— ⛔ 判空 ⇒ 接续棒"
|
||
"会被当**主会话候选** ⇒ 看板「主会话」位上坐着一条干活的棒(线上实测 `3aa35bae`)",
|
||
m.parse_session_name("[接续] 会话机制合并技能包(第 1 棒)").get("role") == "worker"
|
||
and bd._role_of_title("[接续] 会话机制合并技能包(第 1 棒)") == "worker"),
|
||
("🔴 collabd 的主会话候选判据=**只排 `worker`**(2026-10-02 两类改造,`waker`/`follow` 已退役)"
|
||
"+ **退役角色闸**(2026-10-04,见下条)",
|
||
'_role not in ("worker",)' in sa and 'is_retired_role_title' in sa),
|
||
("🔴 board 同款", '_role_of_title(t) not in ("worker",)' in sb
|
||
and '_is_retired_role_title' in sb),
|
||
# 🔴🔴 2026-10-04 21:5x 新增(**用户拍板「候选一」**):退役角色**不许当主会话候选**。
|
||
# 病根:`[跟进]`/`[唤醒]` 在 2026-10-02 从映射表摘掉后 ⇒ `role=""` ⇒
|
||
# 而排除元组是 `not in ("worker",)` ⇒ **`""` 恰好放行** ⇒ 这两类**已退役的干活的棒**
|
||
# 被收进主会话候选 ⇒ 看板「主会话」位上坐着它。**实测现网**:
|
||
# `main_by_topic = {"机制排查与修复": "6ab1463e", "会话协作自检": "57f58ecf"}`
|
||
# —— 两个任务类别**全都指向 `[跟进]` 退役会话**。
|
||
# ⚠️ 修法⛔ 不是"排除空串"(空串里还有真主会话 `a80f300d`「复盘失败并避免重犯」)。
|
||
# ⚠️ 也⛔ 不是"恢复 `[跟进]`/`[唤醒]` 映射"(那等于让退役类别复活,与 10-02 口径相反)。
|
||
("🔴🔴 2026-10-04 **退役角色不许当主会话候选**(用户拍板「候选一」)—— 判据="
|
||
"「一级方括号是退役角色词」(⛔ 不是『角色解析为空』,空串里还有真主会话)",
|
||
m.is_retired_role_title("[跟进]-机制排查与修复-队列跟进")
|
||
and m.is_retired_role_title("[唤醒]-会话协作自检-脉冲")
|
||
and not m.is_retired_role_title("复盘失败并避免重犯")
|
||
and not m.is_retired_role_title("主控 · 协作机制 · 收尾")),
|
||
("🔴 退役角色闸**只认一级方括号里的整词** —— ⛔ 不许误杀「类别名里恰好含『唤醒』」的"
|
||
"在役会话(`[协作]-[唤醒机制]-…` 一级是「协作」⇒ 是 worker)",
|
||
not m.is_retired_role_title("[协作]-[唤醒机制]-日志事前叫停棒")
|
||
and not m.is_retired_role_title("[主]-[唤醒机制]-机制线")),
|
||
("🔴 两侧**共用同一实现**:`board.py` 从 `collabd.py` 取该判据(⛔ 不各写一份字面量 —— "
|
||
"这两侧已漂过三次)",
|
||
# ⚠️ ⛔ 不能用 `bd._is_retired_role_title is m.is_retired_role_title` 来判:
|
||
# 本用例把两个文件**各自按路径 exec 一遍**(各自 `importlib` 一次 collabd)
|
||
# ⇒ 拿到的是**两个模块对象** ⇒ 函数身份比较**恒 False**(实测踩到:判据没错、是写法不成立)。
|
||
# ✅ 正解①**行为逐样本一致**(真正要守的东西);②`board.py` 里是**取**不是**重定义**
|
||
# (`__module__` 指向 board 加载的那份 collabd ⇒ 证明"从模块取"这条路走通了)。
|
||
all(bd._is_retired_role_title(s) == m.is_retired_role_title(s) for s in
|
||
("[跟进]-X-队列上报", "[唤醒]-X-脉冲", "[协作]-[唤醒机制]-棒",
|
||
"复盘失败并避免重犯", "主控 · A · B", ""))
|
||
and bd._is_retired_role_title.__module__ == "_cb_shared"),
|
||
]
|
||
|
||
|
||
@case("目标:无目标 ⇒ 拒绝启动 rc=3")
|
||
def t_goal_missing():
|
||
g = TIN / "goal.json"
|
||
bak = g.read_text(encoding="utf-8") if g.exists() else None
|
||
if g.exists():
|
||
g.unlink()
|
||
p = subprocess.run([PY, "-u", str(HERE / "guard.py")], cwd=str(HERE),
|
||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||
env=_mk_env(), timeout=60, errors="replace")
|
||
if bak is not None:
|
||
g.write_text(bak, encoding="utf-8")
|
||
return [("rc==3", p.returncode == 3), ("提示含『没有任务目标』", "没有任务目标" in (p.stdout or ""))]
|
||
|
||
|
||
@case("目标:有待确认 ⇒ 拒绝启动 rc=4;--propose/--confirm 能走通")
|
||
def t_goal_pending():
|
||
g, gp = TIN / "goal.json", TIN / "goal.pending.json"
|
||
bak = g.read_text(encoding="utf-8") if g.exists() else None
|
||
if g.exists():
|
||
g.unlink()
|
||
subprocess.run([PY, "-u", str(HERE / "guard.py"), "--propose", "自测目标"],
|
||
cwd=str(HERE), env=_mk_env(), timeout=60,
|
||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, errors="replace",
|
||
creationflags=0x08000000)
|
||
p = subprocess.run([PY, "-u", str(HERE / "guard.py")], cwd=str(HERE),
|
||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||
env=_mk_env(), timeout=60, errors="replace")
|
||
rc_pending = p.returncode
|
||
subprocess.run([PY, "-u", str(HERE / "guard.py"), "--confirm"], cwd=str(HERE), env=_mk_env(),
|
||
timeout=60, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, errors="replace",
|
||
creationflags=0x08000000)
|
||
ok_confirmed = g.exists() and not gp.exists()
|
||
if bak is not None:
|
||
g.write_text(bak, encoding="utf-8")
|
||
return [("有 pending ⇒ rc==4", rc_pending == 4), ("--confirm 后 goal.json 生成且 pending 清除", ok_confirmed)]
|
||
|
||
|
||
@case("🔴 `goalctl` 的**配置落点**必须跟工作区走(⛔ 不许写死技能目录)")
|
||
def t_goalctl_cfg_location():
|
||
"""🔴🔴 2026-10-06 立(vibe-product 实测踩到)。
|
||
|
||
**病根**:`goalctl.py` 原为 `CFG = HERE / "collabd.config.json"`(`HERE` = **技能包目录**)。
|
||
而定则「**技能就是技能、程序就是程序,谁用产生的文件放在他自己那里**」⇒
|
||
**技能目录里⛔ 不放生产配置** ⇒ 该文件**恒不存在** ⇒ `_load(CFG,{})` 恒 `{}`。
|
||
|
||
**症状(不报错、不崩溃,只是那一路永远读不到)**:
|
||
· `taskgraph` 取默认 `INBOX/taskgraph.json` ⇒ **改了配置不生效**
|
||
(实测:vibe-product 配置里已改指 proto-board,`goals_open()` 仍报
|
||
`bad=['任务图读不到(taskgraph.json)']` ⇒ 目标**判不出来**);
|
||
· `wake_enable`/`targets`/写回配置等**全部作用在一个不存在的文件上**。
|
||
|
||
✅ **判据**:`goalctl.CFG` 必须命中
|
||
①`COLLABD_CONFIG`(若有)或 ②`<工作区>/.workbuddy/collab/collabd.config.json`,
|
||
⛔ **不得**落在技能包目录下。
|
||
"""
|
||
import importlib.util as _ilu
|
||
rows = []
|
||
# ① 显式 env(钩子/启动器路径)
|
||
os.environ["COLLABD_CONFIG"] = str(CFG)
|
||
os.environ["DSH_COLLAB_WS"] = str(TEST_WS)
|
||
_s1 = _ilu.spec_from_file_location("gc_cfg1", HERE / "goalctl.py")
|
||
_m1 = _ilu.module_from_spec(_s1)
|
||
_s1.loader.exec_module(_m1)
|
||
rows.append(("给 `COLLABD_CONFIG` ⇒ 用它(实得 %s)" % _m1.CFG,
|
||
_m1.CFG == CFG))
|
||
# ② 不给 env ⇒ 必须是**工作区标准落点**(⛔ 不是技能目录)
|
||
# ⚠️ 先**真造**一份工作区标准落点的配置 —— 否则该路径不存在 ⇒ 按设计回落到技能目录
|
||
# (那是"三路都不中"的告警分支,⛔ 不是本用例要测的形状)。
|
||
_hold = os.environ.pop("COLLABD_CONFIG", None)
|
||
_std = TEST_WS / ".workbuddy" / "collab" / "collabd.config.json"
|
||
_std_bak = _std.read_text(encoding="utf-8") if _std.exists() else None
|
||
_std.parent.mkdir(parents=True, exist_ok=True)
|
||
try:
|
||
if not _std.exists():
|
||
_std.write_text(json.dumps({"workspace": str(TEST_WS)},
|
||
ensure_ascii=False), encoding="utf-8")
|
||
_s2 = _ilu.spec_from_file_location("gc_cfg2", HERE / "goalctl.py")
|
||
_m2 = _ilu.module_from_spec(_s2)
|
||
_s2.loader.exec_module(_m2)
|
||
rows.append(("不给 env ⇒ 工作区标准落点(实得 %s)" % _m2.CFG, _m2.CFG == _std))
|
||
rows.append(("🔬 反例:⛔ 不许落在技能包目录下(`HERE/collabd.config.json`)",
|
||
_m2.CFG != (HERE / "collabd.config.json")))
|
||
finally:
|
||
if _std_bak is None:
|
||
try:
|
||
_std.unlink()
|
||
except Exception:
|
||
pass
|
||
else:
|
||
_std.write_text(_std_bak, encoding="utf-8")
|
||
if _hold is not None:
|
||
os.environ["COLLABD_CONFIG"] = _hold
|
||
return rows
|
||
|
||
|
||
@case("🔴 `goalctl.goals_open()` 也必须认中文验收存值(⛔ 别只有 collabd/board 认)")
|
||
def t_goalctl_acc_chinese():
|
||
"""🔴🔴 2026-10-06 立(vibe-product 实测踩到,属"判据分叉"复发)。
|
||
|
||
**病根**:`board.py::acc_is_pass` 自称「**全库唯一实现**」,注释里点明三处
|
||
(`board.py` / `collabd.py::_acc_is_pass` / `board.html::accIsPass`)必须同款。
|
||
🔴 **实际是四处** —— `goalctl.py::goals_open()` 里那句 `str(acc[k]) != "pass"`
|
||
**被漏掉了**。而存值**真源就是中文** ⇒ 已通过的验收被计入 why ⇒ **恒判「未完成」**。
|
||
|
||
**症状**:`goalctl.py status` 报「未完成(验收 V1=过;V2=过;…)」—— **字面自相矛盾**,
|
||
而那正是判据与存值对不上的直接体现(不报错、不崩溃,只少说一句真话)。
|
||
|
||
✅ **本用例的判据能"改前报红"**:喂中文 `过`/`已过|…`/`达(…)`,
|
||
旧写法 `!= "pass"` ⇒ `goals_open()` 必为 `True`(红);修后为 `False`(绿)。
|
||
⛔ 不喂英文 `pass`(那种写法旧代码也能过 ⇒ 恒绿,等于没测)。
|
||
"""
|
||
m = imp()
|
||
# ⚠️ `imp()` 加载的是 **collabd**(它的 `goals_open()` 返回 **bool**)。
|
||
# 本用例要测的是 **goalctl** 的同名函数(返回 **dict**,含 `why`)⇒ 必须**单独加载**。
|
||
# (实测踩到:直接用 `m.goals_open()` ⇒ `TypeError: 'bool' object is not subscriptable`)
|
||
import importlib.util as _ilu
|
||
_spec = _ilu.spec_from_file_location("gc_selftest_acc", HERE / "goalctl.py")
|
||
_gc = _ilu.module_from_spec(_spec)
|
||
_spec.loader.exec_module(_gc)
|
||
# 🔴🔴 **图落点必须跟着 `CFG` 走,⛔ 不许猜**:
|
||
# · 测试配置(`imp()` 设的 `COLLABD_CONFIG`)里写的是 `"taskgraph": "tg.json"`
|
||
# ⇒ 相对落点 = `TEST_WS/tg.json`;
|
||
# · 而 `goal.json` 走 `goalctl.INBOX`(= `WS/tmp/supervise-inbox`,**硬编码**)。
|
||
# ⚠️ 两者**不是同一个目录**,实测各踩过一次:
|
||
# 写 `TEST_WS/inbox/` ⇒ `bad=['任务图读不到','无有效验收判据']`;
|
||
# 写 `TEST_WS/tg.json` 而旧版 `CFG` 读不到 ⇒ 回落到 `INBOX/taskgraph.json` 才"恰好"过。
|
||
# ⇒ 用**配置里那个值**算,⛔ 不硬编码任一侧。
|
||
_tg_rel = str((json.loads(CFG.read_text(encoding="utf-8")) if CFG.is_file() else {})
|
||
.get("taskgraph") or "")
|
||
tg = (TEST_WS / _tg_rel) if _tg_rel else (_gc.INBOX / "taskgraph.json")
|
||
tg.parent.mkdir(parents=True, exist_ok=True)
|
||
gj = _gc.INBOX / "goal.json"
|
||
gj.parent.mkdir(parents=True, exist_ok=True)
|
||
bak_g = gj.read_text(encoding="utf-8") if gj.exists() else None
|
||
tg_bak = tg.read_text(encoding="utf-8") if tg.exists() else None
|
||
tk_bak = tasks()
|
||
try:
|
||
# 前两路(台账/任务图)清成全 done ⇒ **只剩"验收那一路"能说话**
|
||
put_tasks({})
|
||
tg.write_text(json.dumps({"nodes": [
|
||
{"id": "T1", "title": "测试件-1", "status": "done", "line": "line-a"}]},
|
||
ensure_ascii=False), encoding="utf-8")
|
||
|
||
def _o(acc):
|
||
gj.write_text(json.dumps({"acceptance_state": acc}, ensure_ascii=False),
|
||
encoding="utf-8")
|
||
return _gc.goals_open()
|
||
|
||
# ── 真源的中文写法(全部应判"过" ⇒ goals_open 的 why 为空)
|
||
_cn = [("裸「过」", {"V1": "过"}),
|
||
("「过|…」带竖线后缀", {"V1": "过|pid 8024 活(13:30:55)"}),
|
||
("🆕 副词:「已过|…」", {"V1": "已过|四条全在"}),
|
||
("🆕 副词:「均已通过」", {"V1": "均已通过"}),
|
||
("🆕 「达(…)」", {"V1": "达(1440 与 390 两视口都有)"}),
|
||
("四条中文全过", {"V1": "过", "V2": "已过|x", "V3": "达(y)", "V4": "均完成"})]
|
||
# ── 必须仍判"不过"的(fail-closed 不破;⛔ 这是防"改成恒 True"的变异对照)
|
||
# 🔴 **emoji 前缀是刻意的 fail-closed**(2026-10-06 核实):本项目约定
|
||
# `🔴`/`⛔` = **表有问题**的标记,所以真源里**否定值才带 emoji**
|
||
# (例 `🔴 不过|…`),肯定值一律写 `过|…`/`pass`。
|
||
# ⇒ `🔴 已过` 判 False 是**当前三处一致的正确行为**,⛔ 不是缺陷;
|
||
# 本用例把它钉成 fail-closed 期望,防有人"顺手"改成 lstrip 掉任意非字母。
|
||
_no = [("「未过」", {"V1": "未过"}),
|
||
("「不过」", {"V1": "\U0001f534 不过|没做"}),
|
||
("「待重验」", {"V1": "待重验(等复跑)"}),
|
||
("「未达」", {"V1": "未达(缺两视口)"}),
|
||
("空值", {"V1": ""}),
|
||
("英文 fail", {"V1": "fail"}),
|
||
("🔴 emoji+肯定词 ⇒ ⛔ 仍判非过(约定:emoji=表有问题)",
|
||
{"V1": "\U0001f534 已过|四条全在"})]
|
||
rows, o_cn, o_no = [], [], []
|
||
for lab, acc in _cn:
|
||
o = _o(acc)
|
||
o_cn.append(o["open"])
|
||
rows.append(("中文「%s」⇒ ⛔ 不判未完成(why=%s)" % (lab, o["why"][:2]), o["open"] is False))
|
||
for lab, acc in _no:
|
||
o = _o(acc)
|
||
o_no.append(o["open"])
|
||
rows.append(("「%s」⇒ 仍判未完成(fail-closed)" % lab, o["open"] is True))
|
||
finally:
|
||
if tg_bak is not None:
|
||
tg.write_text(tg_bak, encoding="utf-8")
|
||
else:
|
||
try:
|
||
tg.unlink()
|
||
except Exception:
|
||
pass
|
||
put_tasks(tk_bak)
|
||
if bak_g is not None:
|
||
gj.write_text(bak_g, encoding="utf-8")
|
||
else:
|
||
try:
|
||
gj.unlink()
|
||
except Exception:
|
||
pass
|
||
# ── 变异对照:两边**必须同时出现**(全 True 或全 False ⇒ 判据是恒的,没测出东西)
|
||
rows.append(("🔬 变异对照:中文过 ⇒ 全 False,非过 ⇒ 全 True(⛔ 防恒绿/恒红)",
|
||
(not any(o_cn)) and all(o_no)))
|
||
return rows
|
||
|
||
|
||
@case("目标三态:『没有判据』⛔ 不等于『全过』(防『读不到被当成已完成』)")
|
||
def t_goal_undeclared():
|
||
"""🔴 2026-09-30 实测踩到(**跑整体验证时发现**):`acceptance_state` 里**只有 `_说明`/`_更新`
|
||
两个说明行**时,旧写法 `if not acc`(判**字典空不空**)**永不触发** ⇒ `goalctl status` 显示
|
||
「三路全过」。形状同族:**不崩溃,只是少说一句话**。
|
||
⇒ 判据改成「**有没有有效项**」(键不以 `_` 开头),并把状态分成**三态**:
|
||
`open`(有未过项)/`pass`(有判据且全过)/`undeclared`(一条有效判据都没有 ⇒ ⛔ 不算过)。
|
||
"""
|
||
m = imp()
|
||
gj, tg = TIN / "goal.json", TEST_WS / "tg.json"
|
||
bak_g = gj.read_text(encoding="utf-8") if gj.exists() else None
|
||
tg_bak, tk_bak = tg.read_text(encoding="utf-8"), tasks()
|
||
try:
|
||
# 把台账清空 + 任务图全 done ⇒ **只剩"验收那一路"能说话**(否则前两路恒为 open,测不出区别)
|
||
put_tasks({})
|
||
tg.write_text(json.dumps({"nodes": [
|
||
{"id": "T1", "title": "测试件-1", "status": "done", "line": "line-a"},
|
||
{"id": "T2", "title": "测试件-2", "status": "done", "line": "line-b"}]},
|
||
ensure_ascii=False), encoding="utf-8")
|
||
gj.write_text(json.dumps({"acceptance_state": {"_说明": "x", "_更新": "y"}},
|
||
ensure_ascii=False), encoding="utf-8")
|
||
s1, o1, r1 = m.goal_state(), m.goals_open(), m._acc_short()[0]
|
||
gj.write_text(json.dumps({"acceptance_state": {"V1": "pass"}}, ensure_ascii=False), encoding="utf-8")
|
||
s2, o2 = m.goal_state(), m.goals_open()
|
||
gj.write_text(json.dumps({"acceptance_state": {"V1": "fail"}}, ensure_ascii=False), encoding="utf-8")
|
||
s3, o3 = m.goal_state(), m.goals_open()
|
||
finally:
|
||
tg.write_text(tg_bak, encoding="utf-8")
|
||
put_tasks(tk_bak)
|
||
if bak_g is not None:
|
||
gj.write_text(bak_g, encoding="utf-8")
|
||
else:
|
||
try:
|
||
gj.unlink()
|
||
except Exception:
|
||
pass
|
||
return [("只有说明行 ⇒ `undeclared`(实得 %s)" % s1, s1 == "undeclared"),
|
||
("…且 `goals_open() is True`(⛔ 不判完成)", o1 is True),
|
||
("…且 `_acc_short` 明说『没有有效判据』(%s)" % r1[:38], "有效判据" in r1),
|
||
("有判据且全过 ⇒ `pass`(实得 %s)" % s2, s2 == "pass"),
|
||
("…且 `goals_open() is False`", o2 is False),
|
||
("有判据且未过 ⇒ `open`(实得 %s)" % s3, s3 == "open"),
|
||
("…且 `goals_open() is True`", o3 is True)]
|
||
|
||
|
||
@case("验收判据认**真源的中文写法**(⛔ 别只喂英文 `pass` —— 那等于判据恒绿)")
|
||
def t_acc_is_pass_chinese():
|
||
"""🔴🔴 2026-10-03 实测坐实(同族第三发:`pitfalls.md` P0-13/P0-20)。
|
||
|
||
**上一条用例(`t_goal_undeclared`)只喂英文 `{"V1": "pass"}`** ⇒ 判据**恒命中** ⇒ **永远绿**。
|
||
而**真源写的全是中文**:`goal.json::acceptance_state` 的值形如
|
||
`过|pid 8024 活(…)` / `🔴 不过|…` / `**复核:过(形态已治本…)**`。
|
||
⇒ `board.py` 10-02 已修(认中文)但 **`collabd.py` 的 `goal_state()`/`_acc_short()` 没跟修**
|
||
⇒ 现算坐实 **7 条有效判据里 5 条两判据相反** ⇒ `goal_state()` 恒判 `open`
|
||
⇒ `--once` 的 `if not goals_open()` 走 `paused_round(why="目标三路全过")`
|
||
⇒ **即使真完成也会被说成"已完成"**。
|
||
|
||
✅ **本用例的判据是"能改前报红"的那种**:先验**英文**(旧写法也能过 ⇒ 证明不是恒真),
|
||
再验**中文三种真源写法**(旧写法会红 ⇒ 证明真能抓)。
|
||
"""
|
||
m = imp()
|
||
rows = []
|
||
# ── 层 1:`_acc_is_pass()` 本身逐写法(⛔ 判据主体,⛔ 不依赖任何文件状态)
|
||
for label, val, want in [
|
||
("英文 pass", "pass", True),
|
||
("英文 PASS", "PASS", True),
|
||
("中文「过」", "过", True),
|
||
("中文「过|…」带竖线后缀", "过|pid 8024 活(13:30:55)", True),
|
||
("🔴 2026-10-03 实测补:竖线右边**带 json 串自带冒号**(V4 真实值)",
|
||
'过|board.py --serve 8788 起得来,body={"ok":true,"err":""};探针结束后端口已释放', True),
|
||
("中文「通过」", "通过", True),
|
||
("🆕 2026-10-04 实测补:中文「达」(vibe-product 真源 14 条主用写法!)",
|
||
"达(1440 与 390 两视口都有)", True),
|
||
("🆕 2026-10-04:「达」裸写法", "达", True),
|
||
("🆕 2026-10-04:「达标」", "达标(零残留)", True),
|
||
("🆕 2026-10-04:「合格」", "合格(复核通过)", True),
|
||
("🆕 2026-10-04:「完成」", "完成(已实测)", True),
|
||
# 🔴🔴 2026-10-05 **第 5 次复发**:判定词前带**表完成的副词**(`已`/`均`/`都`)。
|
||
# 实测(vibe-product 页签):9 条真值**全写 `已过|…`**,看板显示 **0 / 9 通过**
|
||
# —— 因为 `已过` 第一个字是 `已`、⛔ 不是 `过`。用户原话:
|
||
# 「完成情况 0 / 9 通过,但是我看到都显示已过呀」。
|
||
# 前四次都在**补词** ⇒ 这次改成**剥副词再比**(`ACC_ADV_PREFIX`)。
|
||
("🆕 2026-10-05:「已过」⇒ 必须判过(本轮报障真值)",
|
||
"已过|clipper 扩展已装入浏览器并可连 daemon", True),
|
||
("🆕 2026-10-05:「已过(本类别)|…」", "已过(本类别)|会话协作自检:唤醒 9", True),
|
||
("🆕 2026-10-05:「已通过」", "已通过|x", True),
|
||
("🆕 2026-10-05:「已完成」", "已完成|y", True),
|
||
("🆕 2026-10-05:「均已通过」⇒ **两级副词**都要剥", "均已通过(实测)", True),
|
||
("🆕 2026-10-05:「都已达标」⇒ 两级副词", "都已达标|z", True),
|
||
("🆕 2026-10-05:「已`*`达」⇒ 剥副词后再去星号", "已 * 达(两视口)", True),
|
||
("🔴 2026-10-05:`未过` ⇒ ⛔ 剥不动、必须判非过(fail-closed 不许破)",
|
||
"未过|还没测", False),
|
||
("🔴 2026-10-05:`已未过` ⇒ ⛔ 剥掉 `已` 还剩 `未过` ⇒ 仍非过", "已未过|x", False),
|
||
("🔴 2026-10-05:`已不过` ⇒ ⛔ 剥掉 `已` 还剩 `不过` ⇒ 仍非过", "已不过|x", False),
|
||
("🔴 2026-10-05:`非pass` ⇒ ⛔ 非过", "非pass|x", False),
|
||
("🆕 2026-10-04:白名单词前带 `*` 强调符", "* 达(两视口)", True),
|
||
("🆕 2026-10-04:「未达」⇒ ⛔ 不许因含『达』判过", "未达(还差 2 条)", False),
|
||
("🆕 2026-10-04:「待达标」⇒ ⛔ 不许判过", "待达标", False),
|
||
("「复核:过」判定词不在开头", "**复核:过(形态已治本)**", True),
|
||
("中文「不过|…」⇒ 未过", "🔴 不过|零条活着", False),
|
||
("「待重验」⇒ 未过", "待重验(判据随口径变更作废)", False),
|
||
("英文 fail", "fail", False),
|
||
("空值 ⇒ 判不出来 ⇒ 未过", "", False),
|
||
]:
|
||
got = m._acc_is_pass(val)
|
||
rows.append(("_acc_is_pass(%s) = %s(应 %s)" % (label, got, want), got is want))
|
||
# ── 层 2:端到端(喂中文进真源 ⇒ `goal_state()` 必须是 `pass`,⛔ 不是 `open`)
|
||
gj, tg = TIN / "goal.json", TEST_WS / "tg.json"
|
||
bak_g = gj.read_text(encoding="utf-8") if gj.exists() else None
|
||
tg_bak, tk_bak = tg.read_text(encoding="utf-8"), tasks()
|
||
try:
|
||
put_tasks({}) # 前两路清空 ⇒ 只剩验收那一路能说话
|
||
tg.write_text(json.dumps({"nodes": [
|
||
{"id": "T1", "title": "测试件-1", "status": "done", "line": "line-a"}]},
|
||
ensure_ascii=False), encoding="utf-8")
|
||
gj.write_text(json.dumps({"acceptance_state": {
|
||
"V1-中文写法": "过|pid 1234 活,心跳 8 秒前",
|
||
"V2-带括注": "过(复核:已实测)",
|
||
"_说明": "这行不参与判定"}}, ensure_ascii=False), encoding="utf-8")
|
||
s_cn, short_cn = m.goal_state(), m._acc_short()
|
||
gj.write_text(json.dumps({"acceptance_state": {
|
||
"V1-真不过": "🔴 不过|零条活着"}}, ensure_ascii=False), encoding="utf-8")
|
||
s_bad = m.goal_state()
|
||
finally:
|
||
tg.write_text(tg_bak, encoding="utf-8")
|
||
put_tasks(tk_bak)
|
||
if bak_g is not None:
|
||
gj.write_text(bak_g, encoding="utf-8")
|
||
else:
|
||
try:
|
||
gj.unlink()
|
||
except Exception:
|
||
pass
|
||
rows.append(("真源全中文且全过 ⇒ `goal_state()` = pass(实得 %s)" % s_cn, s_cn == "pass"))
|
||
rows.append(("…且 `_acc_short` 报『全部 pass』(%s)" % short_cn[0][:34],
|
||
"全部 pass" in short_cn[0]))
|
||
rows.append(("真源有一条中文「不过」⇒ `open`(实得 %s)" % s_bad, s_bad == "open"))
|
||
# ── 层 3:🔴🔴 **三处实现必须同款**(2026-10-05 加 —— 这是防"漂"的真判据)
|
||
# 技能文档明令:`board.py` / `collabd.py` / `board.html` 三处判据必须逐字同款
|
||
# (**已漂过 4 次**,每次都只修一处 ⇒ 前四次修完照样再栽)。
|
||
# ⚠️ 判据要**能改前报红**:撤掉任一处的新逻辑 ⇒ 本条必须红。
|
||
# ✅ 做法=**抽三处的"判定词表 + 副词表",逐字比对**(⛔ 不靠"我记得三处一样")。
|
||
_W_WANT = ("pass", "过", "通过", "达", "达标", "合格", "完成")
|
||
_A_WANT = ("已", "已经", "均", "都", "经复核", "复核后")
|
||
# ① board.py —— 🔴🔴 **唯一真身**(`collabd.py::_acc_is_pass` 是转发,⛔ 不再是第二份实现)。
|
||
# ⚠️ 2026-10-05 修:此处原写 `m.ACC_PASS_WORDS`,而 `m` = **collabd 模块**
|
||
# (`imp()` 把 collabd.py 装成 `cb_selftest`)⇒ `AttributeError` ⇒ **本用例整条红**。
|
||
# 真因=**拿错了"真身"**:判定词表住在 `board.py`,collabd 只是 import 它。
|
||
# ⇒ 判据必须**去 board.py 里读**(这才是"三处同款"要守的那个源)。
|
||
_rows_ok = []
|
||
_bp = HERE / "board.py"
|
||
if not _bp.is_file():
|
||
_bp = HERE / "scripts" / "board.py"
|
||
try:
|
||
_bs = _bp.read_text(encoding="utf-8")
|
||
_bw = re.search(r"^ACC_PASS_WORDS\s*=\s*\(([^)]*)\)", _bs, re.M)
|
||
_ba = re.search(r"^ACC_ADV_PREFIX\s*=\s*\(([^)]*)\)", _bs, re.M)
|
||
_tw = tuple(re.findall(r'"([^"]*)"', _bw.group(1))) if _bw else ()
|
||
_ta = tuple(re.findall(r'"([^"]*)"', _ba.group(1))) if _ba else ()
|
||
_rows_ok.append(("① `board.py::ACC_PASS_WORDS` 逐字同款", _tw == _W_WANT))
|
||
_rows_ok.append(("① `board.py::ACC_ADV_PREFIX` 逐字同款", _ta == _A_WANT))
|
||
# 🔴 `board.py` 也必须真含"剥副词再比"那段(⛔ 只对表不够)
|
||
_rows_ok.append(("① `board.py::acc_is_pass` 真含「剥副词再比」逻辑",
|
||
"ACC_ADV_PREFIX" in _bs and "len(_cut)" in _bs))
|
||
except Exception as _e:
|
||
_rows_ok.append(("① `board.py` 读取失败 %r" % (_e,), False))
|
||
# ② board.html 的 ACC_PASS_WORDS / ACC_ADV_PREFIX(抽源码)
|
||
# ⚠️ 2026-10-05 修:`HERE` = **本脚本所在目录(scripts/)**,
|
||
# 而 `board.html` 住在**技能根**下的 `assets/`(= `HERE.parent/assets`)
|
||
# ⇒ 原写 `HERE/"assets"` 必 `FileNotFoundError`(本轮实测就是这么红的)。
|
||
_html = HERE.parent / "assets" / "board.html"
|
||
if not _html.is_file():
|
||
_html = HERE / "assets" / "board.html"
|
||
try:
|
||
_hs = _html.read_text(encoding="utf-8")
|
||
_mw = re.search(r"var\s+ACC_PASS_WORDS\s*=\s*(\[[^\]]*\])", _hs)
|
||
_ma = re.search(r"var\s+ACC_ADV_PREFIX\s*=\s*(\[[^\]]*\])", _hs)
|
||
# ⚠️ JS 数组用**单引号**(`['pass','过']`)⇒ ⛔ `json.loads` 会 `JSONDecodeError`
|
||
# (本轮实测就是这么红的)⇒ 统一**按引号抽词**(双引号/单引号都认)。
|
||
_jw = tuple(re.findall(r"""['"]([^'"]*)['"]""", _mw.group(1))) if _mw else ()
|
||
_ja = tuple(re.findall(r"""['"]([^'"]*)['"]""", _ma.group(1))) if _ma else ()
|
||
_rows_ok.append(("② `board.html::ACC_PASS_WORDS` 逐字同款", _jw == _W_WANT))
|
||
_rows_ok.append(("② `board.html::ACC_ADV_PREFIX` 逐字同款", _ja == _A_WANT))
|
||
# 🔴 前端 HTML **必须**出现"剥副词"那段逻辑(⛔ 只对表不够:改了表但没改算法也白搭)
|
||
_rows_ok.append(("② `board.html::accIsPass` 真含「剥副词再比」逻辑",
|
||
"ACC_ADV_PREFIX" in _hs and "slice(cut.length)" in _hs))
|
||
except Exception as _e:
|
||
_rows_ok.append(("② `board.html` 读取失败 %r" % (_e,), False))
|
||
# ③ collabd.py 的**兜底白名单**(转发路径自动跟 board.py;兜底必须同款)
|
||
_cb = HERE / "scripts" / "collabd.py"
|
||
if not _cb.is_file():
|
||
_cb = HERE / "collabd.py"
|
||
try:
|
||
_cs = _cb.read_text(encoding="utf-8")
|
||
_i = _cs.index("def _acc_is_pass")
|
||
_j = _cs.index("\n# 🔴🔴 2026-10-04 加", _i)
|
||
_body = _cs[_i:_j]
|
||
_rows_ok.append(("③ `collabd.py::_acc_is_pass` 转发 `board.acc_is_pass`",
|
||
"board" in _body and "acc_is_pass(" in _body))
|
||
_rows_ok.append(("③ `collabd.py` 兜底含「剥副词再比」逻辑",
|
||
'"已"' in _body and '"已", "已经"' in _body))
|
||
except Exception as _e:
|
||
_rows_ok.append(("③ `collabd.py` 解析失败 %r" % (_e,), False))
|
||
rows.extend(_rows_ok)
|
||
return rows
|
||
|
||
|
||
@case("两类检查会话是**两件事**:prompt 必须不同 + 闸④「本项目无待执行排期」")
|
||
def t_two_kinds_of_check_agent():
|
||
"""🔴🔴 2026-10-03 08:0x 用户明令:「**这里面是两件事 提示词应该不一样**」。
|
||
|
||
用户原话两条:
|
||
1、会话全结束(**还要加上 定时任务中没有本项目待执行的任务**)且**队列不为空** ⇒
|
||
**结果检查会话**去跟进任务会话执行结果,判断是否创建对应任务会话继续完成目标;
|
||
2、会话全结束(同上)且**队列为空 >3 分钟** ⇒ **目标检查会话**去核对目标完成状态,
|
||
判断是建任务会话还是**修改目标状态**,并执行。
|
||
|
||
⇒ 改前**两套 prompt 完全共用一个模板** ⇒ 结果检查会话拿到了"改目标状态"的出口(越权),
|
||
目标检查会话拿到的是"看队列 head"的开场(串味)。本用例钉死"两套必须不同"。
|
||
|
||
✅ 判据**能改前报红**:把 `_check_prompt` 改成无视 `reason` 恒返回同一份 ⇒ 本条必红。
|
||
"""
|
||
import re as _re
|
||
m = imp()
|
||
rows = []
|
||
pr = m._check_prompt(1, "自检目标", "sessions-ended")
|
||
pg = m._check_prompt(1, "自检目标", "queue-empty")
|
||
rows.append(("两套 prompt 渲染都不炸(各 %d/%d 字符)" % (len(pr), len(pg)), True))
|
||
rows.append(("⛔ 两套 prompt **不是同一份**(实得 %s)" % (pr == pg), pr != pg))
|
||
rows.append(("结果检查:标题是「结果检查会话」(%s)", "【结果检查会话" in pr))
|
||
rows.append(("目标检查:标题是「目标检查会话」(%s)", "【目标检查会话" in pg))
|
||
# 职责边界:结果检查 ⛔ 不许有改目标状态的出口
|
||
rows.append(("结果检查 ⛔ 不含「改目标状态」的出口", "没有「改目标状态」" in pr))
|
||
rows.append(("⛔ 结果检查里不该出现 `--set-life`", "--set-life" not in pr))
|
||
# 目标检查必须有改状态的出口
|
||
rows.append(("目标检查含 `--set-life 已完成` 出口", "--set-life 已完成" in pg))
|
||
rows.append(("目标检查讲了三路完成度(含 `acceptance_state`)", "acceptance_state" in pg))
|
||
rows.append(("目标检查有「不确定就不改」的 fail-safe",
|
||
"不确定就不改" in pg))
|
||
# 排期名分类
|
||
rows.append(("排期名分类:结果检查 → [%s]" % m.CHECK_KINDS["sessions-ended"][1],
|
||
m.CHECK_KINDS["sessions-ended"][1] == "结果检查"))
|
||
rows.append(("排期名分类:目标检查 → [%s]" % m.CHECK_KINDS["queue-empty"][1],
|
||
m.CHECK_KINDS["queue-empty"][1] == "目标检查"))
|
||
# 闸④
|
||
rows.append(("闸④ 函数存在:`ws_pending_schedules()`", callable(m.ws_pending_schedules)))
|
||
ps = m.ws_pending_schedules(exclude="__不存在的排期名__")
|
||
rows.append(("闸④ 现网能读出本工作区待执行排期(%d 条,测试工作区应为 0)" % len(ps),
|
||
isinstance(ps, list)))
|
||
# ⚠️ 判据必须读**函数体**而不是 docstring —— docstring 里写"fail-safe"只是作者的说明,
|
||
# ⛔ 拿它当判据=判"作者记得写这四个字"(同 P0-36 的教训:别拿字面量当事实)。
|
||
# 真判据=**读不到库时返回非空** ⇒ 只能靠"把 RO 连接打坏"来验,见下面那条。
|
||
rows.append(("闸④ 判据注释里写明 fail-safe 方向=不建",
|
||
"fail-safe" in (m.ws_pending_schedules.__doc__ or "")))
|
||
# 真判据:临时把只读连接打坏 ⇒ 必须返回**非空**(当"有排期"⇒ 闸拦 ⇒ 不建)
|
||
_orig = m._ro_conn
|
||
try:
|
||
def _boom():
|
||
raise RuntimeError("自测:故意让读库失败")
|
||
m._ro_conn = _boom
|
||
broken = m.ws_pending_schedules()
|
||
finally:
|
||
m._ro_conn = _orig
|
||
rows.append(("🔴 闸④ 读库失败时返回**非空**(fail-safe=当有排期⇒不建),实得 %r"
|
||
% (broken[:1],), bool(broken)))
|
||
rows.append(("🔴 闸④ 读库失败时**不**返回空列表(否则闸会放行)", broken != []))
|
||
# 非法 reason 必须被拒
|
||
rows.append(("非法 reason 不在 CHECK_KINDS(%s)", "瞎写的" not in m.CHECK_KINDS))
|
||
# 旧名必须已废(⛔ 静默变成"什么都给"更坏)
|
||
try:
|
||
m.CHECK_PROMPT(1, "x")
|
||
rows.append(("旧名 `CHECK_PROMPT` 已废(会抛)", False))
|
||
except RuntimeError:
|
||
rows.append(("旧名 `CHECK_PROMPT` 已废(会抛)", True))
|
||
return rows
|
||
|
||
|
||
|
||
@case("🔴🔴 闸⑥ 同类检查冷却期:两种情况 × 每类最多一条 ⇒ 不堆积(用户「不该有任何堆积」)")
|
||
def t_check_cooldown_no_pileup():
|
||
"""🔴 2026-10-05 立(用户口径逐字:「只有两种情况会创建 定时任务创建会话,不应该又任何堆积」)。
|
||
|
||
⛔ **病根**(实测,见 `references/pitfalls.md` P0-69):排期名带递增棒次(`第%d棒`)
|
||
⇒ 闸⑤「同名去抖」**永不命中** ⇒ 同一种情况可被反复建 ⇒ 自测区 2 小时堆 11 条。
|
||
|
||
🔴 **判据必须能「改前报红」**(P0-13 同族):
|
||
· 删掉闸⑥ 那段 ⇒ ②③④ 全红;
|
||
· 把 `_stamp.update(...)` 改回整体覆盖 ⇒ ③ 红(另一类的冷却记忆被抹);
|
||
· 把 `CHECK_COOLDOWN_S` 改成 0 ⇒ ① 红。
|
||
"""
|
||
import inspect as _ins
|
||
m = imp()
|
||
src = _ins.getsource(m.maybe_spawn_check_agent)
|
||
cd = getattr(m, "CHECK_COOLDOWN_S", None)
|
||
# ① 冷却常量在位且是「真窗口」(⛔ 0 / None 等于没冷却)
|
||
ok_const = isinstance(cd, (int, float)) and cd >= 600
|
||
# ② 闸⑥ 在:按 reason 记冷却键(⛔ 只认带引号的字符串拼装,别认注释里的裸串)
|
||
ok_key = ('"last_" + reason' in src) or ("'last_' + reason" in src)
|
||
# ③ 合并写:⛔ 不许整体覆盖 stamp(覆盖 ⇒ 另一类的 last_* 被抹 ⇒ 闸⑥ 对另一情况失效)
|
||
ok_merge = "_stamp.update(" in src
|
||
# ④ 顺序:闸⑥ 必须在闸⑤(同名去抖)**之后**(⛔ 顺序颠倒 ⇒ 语义变成"先拦粗的")
|
||
p5 = src.find("active_schedule_exists(name)")
|
||
p6 = src.find('"last_" + reason')
|
||
if p6 < 0:
|
||
p6 = src.find("'last_' + reason")
|
||
ok_order = p5 >= 0 and p6 >= 0 and p5 < p6
|
||
return [("冷却窗口 CHECK_COOLDOWN_S 在位且 ≥10 分钟(实测=%s)" % cd, ok_const),
|
||
("闸⑥ 按 reason 记冷却键(同名去抖失效时的兜底)", ok_key),
|
||
("stamp 合并写(⛔ 整体覆盖会抹掉另一类的冷却记忆)", ok_merge),
|
||
("闸⑥ 排在闸⑤ 之后(先精确后兜底)", ok_order)]
|
||
|
||
|
||
@case("🔴🔴 闸⑥ 同类检查冷却期:两种情况 × 每类最多一条 ⇒ 不堆积(用户「不该有任何堆积」)")
|
||
def t_check_cooldown_no_pileup():
|
||
"""🔴 2026-10-05 立(用户口径逐字:「只有两种情况会创建 定时任务创建会话,不应该又任何堆积」)。
|
||
|
||
⛔ **病根**(实测,见 `references/pitfalls.md` P0-69):排期名带递增棒次(`第%d棒`)
|
||
⇒ 闸⑤「同名去抖」**永不命中** ⇒ 同一种情况可被反复建 ⇒ 自测区 2 小时堆 11 条。
|
||
|
||
🔴 **判据必须能「改前报红」**(P0-13 同族):
|
||
· 删掉闸⑥ 那段 ⇒ ②③④ 全红;
|
||
· 把 `_stamp.update(...)` 改回整体覆盖 ⇒ ③ 红(另一类的冷却记忆被抹);
|
||
· 把 `CHECK_COOLDOWN_S` 改成 0 ⇒ ① 红。
|
||
"""
|
||
import inspect as _ins
|
||
m = imp()
|
||
src = _ins.getsource(m.maybe_spawn_check_agent)
|
||
cd = getattr(m, "CHECK_COOLDOWN_S", None)
|
||
# ① 冷却常量在位且是「真窗口」(⛔ 0 / None 等于没冷却)
|
||
ok_const = isinstance(cd, (int, float)) and cd >= 600
|
||
# ② 闸⑥ 在:按 reason 记冷却键(⛔ 只认带引号的字符串拼装,别认注释里的裸串)
|
||
ok_key = ('"last_" + reason' in src) or ("'last_' + reason" in src)
|
||
# ③ 合并写:⛔ 不许整体覆盖 stamp(覆盖 ⇒ 另一类的 last_* 被抹 ⇒ 闸⑥ 对另一情况失效)
|
||
ok_merge = "_stamp.update(" in src
|
||
# ④ 顺序:闸⑥ 必须在闸⑤(同名去抖)**之后**(⛔ 顺序颠倒 ⇒ 语义变成"先拦粗的")
|
||
p5 = src.find("active_schedule_exists(name)")
|
||
p6 = src.find('"last_" + reason')
|
||
if p6 < 0:
|
||
p6 = src.find("'last_' + reason")
|
||
ok_order = p5 >= 0 and p6 >= 0 and p5 < p6
|
||
return [("冷却窗口 CHECK_COOLDOWN_S 在位且 ≥10 分钟(实测=%s)" % cd, ok_const),
|
||
("闸⑥ 按 reason 记冷却键(同名去抖失效时的兜底)", ok_key),
|
||
("stamp 合并写(⛔ 整体覆盖会抹掉另一类的冷却记忆)", ok_merge),
|
||
("闸⑥ 排在闸⑤ 之后(先精确后兜底)", ok_order)]
|
||
|
||
@case("检查会话的 prompt:**引用的命令必须真存在**(⛔ 引用不存在的命令 ⇒ 静默当成功)")
|
||
def t_check_prompt_cmds_exist():
|
||
"""🔴🔴 2026-10-03 实测坐实:`CHECK_PROMPT` 让检查会话用
|
||
`goalctl.py set-life已完成` 改生命周期 —— ⛔ **该命令在 `goalctl.py` 里根本不存在**。
|
||
实跑后果:只打印用法、**rc=0 静默放行** ⇒ 检查会话**以为改成功了**,实际一个字没改。
|
||
✅ 真源=`collabd.py --set-life`(`L4200` 的 CLI 分支 ⇒ `goal_life_set()`)。
|
||
|
||
✅ 本用例判据**能改前报红**:占位符个数对不上会当场 `TypeError`;
|
||
prompt 里仍写 `goalctl.py set-life` 则本条**必红**。
|
||
"""
|
||
import re as _re
|
||
m = imp()
|
||
rows = []
|
||
# 🔴 2026-10-03 08:0x:`_check_prompt` 已拆两套(`reason` 必传)⇒ **两套都要验**,
|
||
# ⛔ 只验默认那套=漏掉"结果检查里不该有 --set-life"这类越权。
|
||
for reason, want_life in (("queue-empty", True), ("sessions-ended", False)):
|
||
tag = "目标检查" if want_life else "结果检查"
|
||
try:
|
||
p = m._check_prompt(1, "自检目标", reason)
|
||
ok = True
|
||
except TypeError as e:
|
||
p, ok = "", False
|
||
rows.append(("%s:`_check_prompt()` 渲染不炸(实得 %s)"
|
||
% (tag, e.__class__.__name__), False))
|
||
if not ok:
|
||
continue
|
||
rows.append(("%s:渲染成功,无残留占位符(实得 %s)"
|
||
% (tag, _re.findall(r"%[sd]", p)), not _re.findall(r"%[sd]", p)))
|
||
rows.append(("%s:坏引用 `用...goalctl.py set-life` 不在正文里" % tag,
|
||
not _re.search(r"用`[^`]*goalctl\.py set-life", p)))
|
||
if want_life:
|
||
rows.append(("目标检查:指向 `collabd.py --set-life`(⛔ 不是不存在的命令)",
|
||
"--set-life" in p and "collabd.py" in p))
|
||
rows.append(("目标检查:`--set-life` 与值之间有空格",
|
||
"--set-life 已完成" in p))
|
||
else:
|
||
rows.append(("结果检查:⛔ 不含 `--set-life`(那是目标检查的出口)",
|
||
"--set-life" not in p))
|
||
return rows
|
||
|
||
|
||
@case("台账四态:running → blocked(带原因) → 解除后原因清掉")
|
||
def t_states():
|
||
run_cli(["--report", "C1", "--state", "running", "--by", "t", "--line", "line-a"])
|
||
ok1 = (tasks().get("C1") or {}).get("state") == "running"
|
||
run_cli(["--report", "C1", "--state", "blocked", "--by", "t", "--reason", "缺一份对接单"])
|
||
v = tasks().get("C1") or {}
|
||
ok2 = v.get("state") == "blocked" and "对接单" in str(v.get("block_reason"))
|
||
run_cli(["--report", "C1", "--state", "pending", "--by", "t"])
|
||
v = tasks().get("C1") or {}
|
||
ok3 = v.get("state") == "pending" and not v.get("block_reason")
|
||
t = tasks(); t.pop("C1", None); (TIN / "tasks.json").write_text(json.dumps(t, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
return [("上报 running 生效", ok1), ("blocked 带原因", ok2), ("解除后原因被清掉", ok3)]
|
||
|
||
|
||
@case("已停总闸:goal.run=paused ⇒ --once 只写一行、⛔ 不产告警(防『人为停掉被读成机制坏了』)")
|
||
def t_goal_paused():
|
||
"""🔴 2026-10-01 体检修实测:**暂停中的需求被当成"机制故障"** 报给用户。
|
||
|
||
真因:`goal.json.run=paused` 时 `collabd.py` **零处**引用 `run` ⇒ `--once` 照跑、照写
|
||
`NEXT.md`/`STALL.md`/`NEED-USER.md`/`digest.md`,而 `digest.md` 经钩子**注入每个会话每一轮**。
|
||
⇒ 本用例钉死两件事:① 已停时**只留一行"已停"** 并把告警投影清掉 ② 已停时**不产任何唤醒/排期**。
|
||
|
||
🔴🔴 2026-10-05 `--tick` **已删**(用户:「检查程序 常驻 自己不会判断吗,非要什么 tick once
|
||
去触发?」)⇒ 原先「已停时 `--tick` ⛔ 不投递」这条断言**失去对象**(分支不存在了)。
|
||
⛔ 替换判据=**已停时 `--once` 不产唤醒**(这才是那条红线现在仅存的载体),
|
||
+**钉死 `--tick` 确已删除**(⛔ 防止有人把它加回来却不补这条闸)。
|
||
"""
|
||
gp = TIN / "goal.json"
|
||
_had = gp.exists()
|
||
_old = gp.read_text(encoding="utf-8") if _had else ""
|
||
try:
|
||
# 埋脏投影:已停时它们**必须被清掉**(⛔ 留着就被钩子继续注入)
|
||
for _n in ("NEXT.md", "STALL.md", "NEED-USER.md", "queue.md"):
|
||
(TIN / _n).write_text("(selftest 脏投影)", encoding="utf-8")
|
||
gp.write_text(json.dumps({"run": "paused", "acceptance_state": {}}, ensure_ascii=False),
|
||
encoding="utf-8")
|
||
w0 = wakeups_n()
|
||
rc1, out1 = run_cli(["--once"])
|
||
_txt = ""
|
||
try:
|
||
_txt = (TIN / "digest.md").read_text(encoding="utf-8")
|
||
except Exception:
|
||
pass
|
||
ok_line = ("已停" in _txt) and len([_l for _l in _txt.splitlines() if _l.strip()]) <= 6
|
||
ok_clear = all(not (TIN / _n).exists() for _n in ("NEXT.md", "STALL.md", "NEED-USER.md", "queue.md"))
|
||
# 🔴🔴 2026-10-05:`--tick` 已删 ⇒ 改成两件事 ——
|
||
# ① 已停时 `--once` 不产唤醒(红线载体只剩它) ② `--tick` 确已从参数表与代码里消失
|
||
rc2, out2 = run_cli(["--once"])
|
||
ok_no_wake = wakeups_n() == w0
|
||
_src = (HERE / "collabd.py").read_text(encoding="utf-8", errors="replace")
|
||
_seg = _src.split("_KNOWN = {")[1][:400] if "_KNOWN = {" in _src else ""
|
||
ok_tick = ('if "--tick" in sys.argv' not in _src) and ('"--tick"' not in _seg)
|
||
# 恢复 active ⇒ 必须**退出已停态**(`paused` 标记被清),⛔ 不许一次暂停就永久停摆
|
||
gp.write_text(json.dumps({"run": "active", "acceptance_state": {}}, ensure_ascii=False),
|
||
encoding="utf-8")
|
||
rc3, _ = run_cli(["--once"])
|
||
ok_resume = (not state().get("paused")) and rc3 == 0
|
||
return [("已停时 --once rc==0", rc1 == 0),
|
||
("digest 只有一行『已停』(⛔ 不产告警)", ok_line),
|
||
("告警投影被清掉(NEXT/STALL/NEED-USER/queue)", ok_clear),
|
||
("已停时 ⛔ 不产唤醒(%d→%d)" % (w0, wakeups_n()), ok_no_wake and rc2 == 0),
|
||
("🔴 `--tick` 确已删除(参数表与分支都不在位)", ok_tick),
|
||
("恢复 active ⇒ 退出已停态", ok_resume)]
|
||
finally:
|
||
if _had:
|
||
gp.write_text(_old, encoding="utf-8")
|
||
else:
|
||
try:
|
||
gp.unlink()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
@case("投递:常驻程序(--once / --report)**零投递**")
|
||
def t_no_deliver():
|
||
w0 = wakeups_n()
|
||
run_cli(["--once"])
|
||
run_cli(["--report", "C2", "--state", "running", "--by", "t"])
|
||
w1 = wakeups_n()
|
||
t = tasks(); t.pop("C2", None); (TIN / "tasks.json").write_text(json.dumps(t, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
return [("--once 与 --report 合计零投递(%d→%d)" % (w0, w1), w1 == w0)]
|
||
|
||
|
||
@case("投递:目标会话(**跟进会话**)在跑 ⇒ 必须延后(target-busy)")
|
||
def t_target_busy():
|
||
"""🔴🔴 2026-10-02 23:4x **本用例已随「跟进会话整套退役」失效**(⛔ 保留代码,⛔ 删了=没有用例=假绿)。
|
||
|
||
🔴 **为什么整组退役**(不是改断言、不是修代码):用户逐字「按照新的逻辑整体修改」/
|
||
「按照之前的讨论 唤醒会话 跟进会话 和 上报程序 都去掉才对」/「上报机制也不需要了」。
|
||
⇒ `follow_for_topic()` 已**短路**返回 `why="follow-retired"` ⇒ 这条路径**根本走不到**:
|
||
`target-busy`/`target-deaf`/「真投出」/「台账增条」这四组断言**永久拿不到**那个状态。
|
||
⚠️ ⛔ **不许把这四条改成"预期 follow-retired"** —— 那等于**把断言改恒真**(P0-13 同族):
|
||
改完之后任何实现都能过,等于没有守卫。
|
||
✅ **正确做法**:整组标记退役,⛔ 不动断言(将来口径变回时可直接复活)。
|
||
⚠️ 退役判据(机读,⛔ 不看代码注释):`follow_for_topic()` **真跑一次**返回 `why == "follow-retired"`。
|
||
—— 本用例**后半段(真调用)已改判这条**;前半段的旧断言**刻意留着不改**。
|
||
"""
|
||
m = imp()
|
||
# ✅ 退役判据(真调用,⛔ 不是 grep 源码)
|
||
r = m.follow_for_topic({}, "任意类别", [])
|
||
if r.get("why") != "follow-retired" or str(r.get("sid") or ""):
|
||
return [("🔴 两类改造:`follow_for_topic()` 必须短路成 `follow-retired` 且 `sid` 为空"
|
||
"(现 = %s / sid=%r)" % (r.get("why"), r.get("sid")), False)]
|
||
return [("✅ 两类改造:`follow_for_topic()` 短路成 `follow-retired`(唤醒/跟进整套退役)", True),
|
||
("⏸ 下面 2 条为**已退役**的旧断言(target-busy 的 ① 恒真 / ② 才有分量),"
|
||
"⛔ 刻意不改 —— 路径已死,改成新预期=断言恒真。判据见下方源码注释", True)]
|
||
|
||
# ═════════════ 以下=旧实现,⛔ 刻意保留勿删(口径若变回,直接复活这段即可) ═════════════
|
||
FAKE = "f0117000-0000-4000-8000-00000000000f"
|
||
_old_scan, _old_st, _old_busy = m._scan_follows, m._session_status, m._session_busy
|
||
try:
|
||
m._scan_follows = lambda topics=None: {"by_topic": {}, "sids": [FAKE], "cand": [],
|
||
"topics": list(topics or []), "err": ""}
|
||
m._session_status = lambda sid: ("working" if str(sid) == FAKE else _old_st(sid))
|
||
m._session_busy = lambda sid: ("busy" if str(sid) == FAKE else _old_busy(sid))
|
||
r1 = m._deliver_str("自测:不该被投出", "selftest-busy", {"roles": {}})
|
||
m._session_busy = lambda sid: ("idle" if str(sid) == FAKE else _old_busy(sid))
|
||
r2 = m._deliver_str("自测:不该被投出", "selftest-busy", {"roles": {}})
|
||
finally:
|
||
m._scan_follows, m._session_status, m._session_busy = _old_scan, _old_st, _old_busy
|
||
return [("① 目标 `busy` ⇒ skipped==target-busy(现 = %s)" % (r1.get("skipped") or "空"),
|
||
r1.get("skipped") == "target-busy"),
|
||
("② 目标 `idle` ⇒ ⛔ 不延后,往下走到 live 判(现 = %s)" % (r2.get("skipped") or "空"),
|
||
r2.get("skipped") != "target-busy")]
|
||
|
||
|
||
@case("投递:错名修复 —— `why` 透传(有候选但不活 ⇒ follow-not-live,⛔ 不再记 no-follow-session)")
|
||
def t_deliver_why_passthrough():
|
||
"""🔴 2026-10-02 修(投递链路真因):`_deliver_str` 旧实现只按 `_want==""` 分档 ⇒ 把
|
||
**(a) 真·一条候选都没有** 与 **(b) 有候选、但都不在活会话里** **合并记成同一个 `no-follow-session`**
|
||
—— 字面意思与事实**相反**(现场实况:名册里 **6 条 `[跟进]` 都在、一条都不活**),
|
||
⇒ **把两天的排查引向"再多建一条"**,而多建**不管用**。
|
||
⇒ 本用例钉住:`skipped` 必须**透传 `follow_for_topic()` 已算准的 `why`**,两档分得开。
|
||
⚠️ 用**假的 `follow_for_topic`** 造前提(真实路径要求"网关探到活会话、且候选不在其中",
|
||
本机不可控 ⇒ 会变成天天 SKIP 的摆设用例);只替 `follow_for_topic` 与 `need_user`,
|
||
并临时补上网关口令过掉 `no-token` 闸,`finally` 里**逐项还原**。
|
||
"""
|
||
m = imp()
|
||
FAKE = "f0f0f0f0-0000-4000-8000-00000000000e"
|
||
_old_rft, _old_nu = m.follow_for_topic, m.need_user
|
||
_old_tok = os.environ.get(m.GW_ENV)
|
||
_seen = []
|
||
os.environ[m.GW_ENV] = "selftest-token" # ⛔ 不设 ⇒ 早退成 `no-token`,走不到本分支
|
||
try:
|
||
m.need_user = lambda t: _seen.append(t)
|
||
m.follow_for_topic = lambda st, topic="", live_sids=None: {
|
||
"sid": "", "source": "", "topic": topic, "why": "follow-not-live",
|
||
"cand": [FAKE], "all": [FAKE]}
|
||
r1 = m._deliver_str("自测:有候选但不活", "selftest-why-live", {"roles": {}})
|
||
m.follow_for_topic = lambda st, topic="", live_sids=None: {
|
||
"sid": "", "source": "", "topic": topic, "why": "no-follow-session",
|
||
"cand": [], "all": []}
|
||
r2 = m._deliver_str("自测:一条候选都没有", "selftest-why-none", {"roles": {}})
|
||
finally:
|
||
m.follow_for_topic, m.need_user = _old_rft, _old_nu
|
||
if _old_tok is None:
|
||
os.environ.pop(m.GW_ENV, None)
|
||
else:
|
||
os.environ[m.GW_ENV] = _old_tok
|
||
return [
|
||
("① 有候选·都不活 ⇒ skipped==follow-not-live(现 = %s)" % (r1.get("skipped") or "空"),
|
||
r1.get("skipped") == "follow-not-live"),
|
||
("② 真·无候选 ⇒ skipped==no-follow-session(现 = %s)" % (r2.get("skipped") or "空"),
|
||
r2.get("skipped") == "no-follow-session"),
|
||
("③ 两档确实分开(⛔ 不再合并成一个名字)",
|
||
(r1.get("skipped") or "") != (r2.get("skipped") or "")),
|
||
("④ 两档各喊一次用户(⛔ 不静默丢件)", len(_seen) == 2),
|
||
]
|
||
|
||
|
||
@case("投递:粗判第一条候选已哑、同类别另有活候选 ⇒ ⛔ 不许终局判 target-deaf(修『走不到精确重算』)")
|
||
def t_coarse_deaf_not_terminal():
|
||
# 🔴🔴 2026-10-02 23:4x **本用例随「跟进会话整套退役」失效**(⛔ 保留代码,⛔ 删了=没有用例=假绿)。
|
||
# `follow_for_topic()` 已短路成 `follow-retired` ⇒ "粗判 vs 精确"那条判据链**永久走不到**。
|
||
# ⚠️ ⛔ 不许把断言改成"预期 follow-retired"(=断言恒真,P0-13 同族);判据见 `t_target_busy`。
|
||
return [("⏸ 已退役(2026-10-02 两类改造):`follow_for_topic()` 短路 ⇒ 粗判/精确重算那条链走不到", True),
|
||
("✅ 退役判据:真调用返回 `follow-retired`",
|
||
imp().follow_for_topic({}, "X", []).get("why") == "follow-retired")]
|
||
# ═════════════ 以下=旧实现,⛔ 刻意保留勿删 ═════════════
|
||
"""🔴 2026-10-02(S5 · 验收 V2)钉死**两条判据打架**:粗判(`live_sids=[]`)与精确
|
||
(拿锁后用活网关列表重算)对**同一条**会话给出相反结论。
|
||
|
||
真因两处,**同族**、必须同批修(本用例同时钉住):
|
||
① **终局短路错位**:旧实现拿**粗判的 `_tgt`** 去 `_deaf_sids()` 判定并**直接返回**
|
||
`target-deaf`(还在取锁之前)⇒ 只要"最近活动的那条候选"恰好哑了,**每轮都短路**,
|
||
永远走不到拿锁后的精确重算 ⇒ "候选里还有活的"这个事实**一辈子没机会被发现**。
|
||
② **候选收窄过早**:`follow_for_topic()` 旧实现先把候选收窄成**单条**(`by_topic` 首见
|
||
=最近活动),再交给 `_pick_live()` ⇒ "最近活动那条死了" ≡ "这个类别投不出去",
|
||
同类别里活着的候选**轮不到**。
|
||
|
||
⚠️ 造前提的手法(⛔ 不依赖现场恰好有活会话 —— 那会让用例天天 SKIP=没测):
|
||
伪造 `_scan_follows`(两条同类候选:`DEAF` 最近活动、`LIVE` 活着)+假日志根把 `DEAF` 判哑
|
||
+伪造 `discover_gateways()`/`_gw_http()`。**真实 `follow_for_topic()` / `_pick_live()` /
|
||
`_deliver_str()` 全程在位**——被替掉的只有"外部世界",判据链一条不换。
|
||
🎯 **回退反证成立**:把 `follow_for_topic()` 的 `_lst` 换回 `[_lst[0]]`(或把粗判恢复成
|
||
`_tgt in _deaf_sids()`)⇒ 下面①~⑤ 立即变红。
|
||
"""
|
||
m = imp()
|
||
TP = "机制排查与修复"
|
||
DEAF = "deadbeef-0000-4000-8000-0000000000d1" # 最近活动 ⇒ 粗判的 `c[0]`
|
||
LIVE = "deadbeef-0000-4000-8000-0000000000d2" # 同类别、**活着** ⇒ 本该投它
|
||
# ⚠️ 假日志根**用完必须清**(`_fake_cfg` 是**追加**写入同一棵树):本用例的 DEAF 那份 10 MiB
|
||
# 会被后面的 `t_deaf_detect`(断言"结果恰好只有那一条哑的")读到 ⇒ **假红**(本轮实测踩到:
|
||
# 47 PASS / 1 FAIL,红的正是 `t_deaf_detect`)。与 `t_deaf_latest_day_only` 同一手法。
|
||
shutil.rmtree(TEST_WS / "fake-cfg", ignore_errors=True)
|
||
cfg = _fake_cfg({DEAF: DEAF_MIB + 100_000}) # 只把 DEAF 判哑(LIVE 无日志 ⇒ 不哑)
|
||
restore = _with_fake_cfg(cfg)
|
||
_old = (m._scan_follows, m.discover_gateways, m._gw_http, m.need_user)
|
||
_old_tok = os.environ.get(m.GW_ENV)
|
||
_seen, _st_in = [], {"roles": {}}
|
||
try:
|
||
# 清掉 wake:⛔ 否则 `same-item`/`too-soon` 会先短路,走不到精确阶段(假红)
|
||
(TIN / "collabd-state.json").write_text(
|
||
json.dumps({"wake": {"hash": "", "ts": 0}}), encoding="utf-8")
|
||
(TIN / "wake.lock").write_text("free", encoding="utf-8")
|
||
(TIN / "wakeups.jsonl").unlink(missing_ok=True)
|
||
os.environ[m.GW_ENV] = "selftest-token" # ⛔ 不给 ⇒ 早退成 `no-token`,走不到投递
|
||
m._scan_follows = lambda topics=None: {
|
||
"by_topic": {TP: DEAF}, "by_topic_all": {TP: [DEAF, LIVE]},
|
||
"sids": [DEAF, LIVE], "cand": [], "topics": [TP], "err": ""}
|
||
m.discover_gateways = lambda *a, **k: [{"port": 1, "sessionId": LIVE}]
|
||
m._gw_http = lambda *a, **k: (200, "{}")
|
||
m.need_user = lambda t: _seen.append(t)
|
||
r = m._deliver_str("自测:粗判第一条哑", "selftest-coarse-deaf", _st_in, topic=TP)
|
||
_w = (r.get("delivered") or {})
|
||
_sid_in_wake = str((_st_in.get("wake") or {}).get("sessionId") or "")
|
||
_n = wakeups_n()
|
||
finally:
|
||
m._scan_follows, m.discover_gateways, m._gw_http, m.need_user = _old
|
||
if _old_tok is None:
|
||
os.environ.pop(m.GW_ENV, None)
|
||
else:
|
||
os.environ[m.GW_ENV] = _old_tok
|
||
restore()
|
||
shutil.rmtree(TEST_WS / "fake-cfg", ignore_errors=True) # ⛔ 不留残渣给后面用例
|
||
return [
|
||
("① ⛔ 不许短路成 target-deaf(现 = %s)" % (r.get("skipped") or "空(↓已投出)"),
|
||
r.get("skipped") != "target-deaf"),
|
||
("② 走到精确阶段并**真投出**(http=%r)" % (_w.get("http"),), _w.get("http") == 200),
|
||
("③ 投给**活着的那条** `%s…`(⛔ 不是粗判的第一条 `%s…`)" % (LIVE[:8], DEAF[:8]),
|
||
_sid_in_wake == LIVE),
|
||
("④ 投递台账**真增 1 条**(现 %d)" % _n, _n == 1),
|
||
("⑤ ⛔ 没对用户喊话(粗判假警报已消除;现 %d 次)" % len(_seen), len(_seen) == 0),
|
||
]
|
||
|
||
|
||
@case("投递:跨进程互斥锁存在且会被释放")
|
||
def t_lock_released():
|
||
m = imp()
|
||
m._deliver_str("自测", "selftest", {"roles": {}})
|
||
_p = TIN / "wake.lock"
|
||
try:
|
||
_txt = _p.read_text(encoding="utf-8", errors="replace").strip()
|
||
except Exception:
|
||
_txt = ""
|
||
try:
|
||
import inspect as _ins
|
||
_src = _ins.getsource(m._deliver_str)
|
||
except Exception:
|
||
_src = ""
|
||
return [
|
||
# 🔴 2026-10-01 **契约变更**:旧契约="释放就是把文件删掉";新契约=
|
||
# "**文件永久存在,释放 = 原地把内容写成 `free`**"。为什么必须变:
|
||
# `_deliver_str` 每轮取锁后都在 `finally` 里 `unlink(wake.lock)`,而宿主有
|
||
# **SafeDelete 批量删除护栏**(按"本轮删除次数"计数,达 50 即要求确认并**终止进程**)
|
||
# ⇒ 常驻 ≈8 分钟(10 s/轮 × 50)就被杀(现场 `_supervise-bg.out.log` 原文
|
||
# `{"count":50,"threshold":50,"targetCount":1,"targets":[…\wake.lock]}`)。
|
||
# ⚠️ 断言随之改判**语义**而非存在性:`不存在`(从未取锁)**或** `内容=free` 都算"已释放"。
|
||
("锁已释放(不存在 或 内容=free;实得 %r)" % _txt, (not _p.exists()) or _txt == "free"),
|
||
# 🔴 反向那条**非有不可**:只判"已释放"的话,"每轮把文件 unlink 掉"这种旧写法**照样绿** ——
|
||
# 而它正是护栏杀进程的来源。⚠️ 且必须要求**源码真读到**(`_src` 为空=没验,⛔ 不许恒真)。
|
||
("锁释放 ⛔ 不用 unlink(源码读到 %d 字符,%s `_lk.unlink`)"
|
||
% (len(_src), "无" if "_lk.unlink" not in _src else "仍有"),
|
||
bool(_src) and "_lk.unlink" not in _src),
|
||
]
|
||
|
||
|
||
@case("投递:锁被**别的进程**持有时 ⇒ ⛔ 不投(`locked`)")
|
||
def t_lock_held():
|
||
# 🔴 为什么非有此例:上面那条只验"**能释放**",⛔ 验不到"**真能互斥**"。
|
||
# 而"跨进程互斥"是本机制**避免同一内容成对重复投递**的唯一屏障(实测事故:22:24:04/30 等
|
||
# 四组成对投递把主会话压成 `parkInQueue`「只进不出」⇒ 用户看到"卡死")。
|
||
# ⚠️ 判据必须是**真的把锁占住**再调 —— 伪造"别的进程正持有"(内容=假 pid + mtime=现在 ⇒ 不陈旧)。
|
||
m = imp()
|
||
_p = TIN / "wake.lock"
|
||
_p.parent.mkdir(parents=True, exist_ok=True)
|
||
try:
|
||
m._deliver_str("自测", "selftest", {"roles": {}}) # 先走一遍:把锁建起来、并释放成 free
|
||
_p.write_text("999999", encoding="utf-8") # 伪造"别的进程正持有"(⛔ 不陈旧 ⇒ 不许抢占)
|
||
r = m._deliver_str("自测-锁被别人拿着", "selftest", {"roles": {}})
|
||
finally:
|
||
try:
|
||
_p.write_text("free", encoding="utf-8") # 收尾:还原成未持有(⛔ 不 unlink)
|
||
except Exception:
|
||
pass
|
||
return [("skipped==locked(现 = %s)" % (r.get("skipped") or "空"), r.get("skipped") == "locked")]
|
||
|
||
|
||
# ── 🔴 2026-10-01 加:**会话哑掉(诊断日志撞 ~10 MiB 上限)** 的回归面 ──────────────
|
||
# 真因(本机实测):宿主**单会话诊断日志有 10 MiB 硬上限**,撞上即**拒写**(EPERM)
|
||
# ⇒ 该会话**界面不再刷新**(=哑);而投递只认 `http=200` ⇒ 向已哑会话投递**仍记
|
||
# `ok:true` =假绿**,通知全落空机制却毫无察觉(实测主会话 f8a792ab 停在 10,485,715 B,
|
||
# 其后累计 45,422 行被丢)。
|
||
# ⇒ 修法:`collabd.py` 加 `_deaf_sids()`(≥9.5 MiB 判哑)+ 投递**两道闸**
|
||
# (粗判按登记目标 · **出手前再按网关实际目标**)⇒ 记 `skipped=target-deaf` + 喊用户。
|
||
# ⚠️ 阈值分工(⛔ 别混):**9.5 MiB** =「已哑、不许再投」(`_deaf_sids` 的拦截线);
|
||
# **5 MiB** =「该交接了」(会话主动换,见 CODEBUDDY.md §1.5 G)。
|
||
DEAF_MIB = 9_500_000 # ⚠️ 与 `collabd._deaf_sids()` 的阈值**逐字同源**(那边改了这里必须跟)
|
||
|
||
|
||
def _make_sparse(f) -> bool:
|
||
"""把已打开的**文件对象**标成 NTFS 稀疏文件(Windows);非 Windows 直接返回 True。
|
||
|
||
🔴🔴 2026-10-05 修一处**实测坐实了的假认知**(pitfalls P0-84):
|
||
本函数的老注释写着「用 `truncate` 建**稀疏文件**……⛔ 不真写 10 MiB」。
|
||
**在 Windows 上这是错的** —— `truncate()` 走的是 `SetEndOfFile`,
|
||
⛔ 不会自动把文件标成稀疏 ⇒ **逻辑大小与实占 1:1**。
|
||
实测:`open(p,"wb").truncate(9_600_000)` 建 10 个档,
|
||
逻辑 91.6 MB/实占 91.6 MB(不是"看起来大、实际很小")。
|
||
⇒ 单次 `selftest` 真占 ~60 MB;而 `install.py --verify` 每跑一次就跑一次 selftest,
|
||
**再叠加宿主的 safe-delete shim**(删工作区文件一律改道回收站)⇒
|
||
那些字节**不会消失,只是搬进回收站**,实测攒到 13.8 GB(`tmp/selftest`)。
|
||
⛔ 不许再靠"truncate 不真写"这个假设省事。
|
||
|
||
正解:写数据**之前**先 `FSCTL_SET_SPARSE`,之后再 truncate 才真稀疏。
|
||
⚠️ 顺序不能反 —— 先 truncate 再加标志,已分配的簇不会自动归还。
|
||
"""
|
||
if os.name != "nt":
|
||
return True
|
||
try:
|
||
import ctypes
|
||
import msvcrt
|
||
from ctypes import wintypes
|
||
|
||
FSCTL_SET_SPARSE = 0x000900C4
|
||
GENERIC_WRITE = 0x40000000
|
||
FILE_SHARE_RW = 0x00000001 | 0x00000002
|
||
|
||
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||
k32.CreateFileW.restype = wintypes.HANDLE
|
||
k32.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD,
|
||
wintypes.LPVOID, wintypes.DWORD, wintypes.DWORD,
|
||
wintypes.HANDLE]
|
||
k32.DeviceIoControl.argtypes = [wintypes.HANDLE, wintypes.DWORD, wintypes.LPVOID,
|
||
wintypes.DWORD, wintypes.LPVOID, wintypes.DWORD,
|
||
ctypes.POINTER(wintypes.DWORD), wintypes.LPVOID]
|
||
OPEN_EXISTING = 3
|
||
h = k32.CreateFileW(str(f.name), GENERIC_WRITE, FILE_SHARE_RW, None,
|
||
OPEN_EXISTING, 0x00000080, None) # FILE_ATTRIBUTE_NORMAL
|
||
if h == wintypes.HANDLE(-1).value or not h:
|
||
return False
|
||
try:
|
||
br = wintypes.DWORD(0)
|
||
ok = k32.DeviceIoControl(h, FSCTL_SET_SPARSE, None, 0, None, 0,
|
||
ctypes.byref(br), None)
|
||
return bool(ok)
|
||
finally:
|
||
k32.CloseHandle(h)
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def _fake_cfg(sizes: dict, day_offset: int = 0) -> Path:
|
||
"""在**测试区内**造一份假 `logs/<日期>/sdk/conversations/`(⛔ 生产日志目录一个字节不动)。
|
||
|
||
`_deaf_sids()` 只读 `CODEBUDDY_CONFIG_DIR/logs/...` ⇒ 换掉配置根即可**完全离线**复现本坑。
|
||
`day_offset`:0=今天(默认)、1=昨天 —— 🔴 **按天分目录**正是"永久判哑"那个坑的土壤。
|
||
|
||
🔴 体积用「**先标稀疏 → 再 `truncate`**」造(见 `_make_sparse`):
|
||
目标是秒级拿到 10 MiB 的**体积读数**(`getsize`),而**实占尽量小**。
|
||
⚠️ 老实现只 `truncate`、⛔ 没标稀疏 ⇒ 在 Windows 上**真写**(P0-84 实测 1:1)。
|
||
⚠️ 这里**不追求零占用**:只要"不真写满"即可 —— 某些文件系统不支持稀疏时
|
||
`_make_sparse` 返回 False,此时行为与老实现相同(真写),
|
||
**但会打一条 stderr 提示**,⛔ 不许静默退化。
|
||
"""
|
||
_d = time.strftime("%Y-%m-%d", time.localtime(time.time() - 86400 * int(day_offset)))
|
||
root = TEST_WS / "fake-cfg" / "logs" / _d / "sdk" / "conversations"
|
||
root.mkdir(parents=True, exist_ok=True)
|
||
for _sid, _n in (sizes or {}).items():
|
||
_p = root / (_sid + ".log")
|
||
with open(_p, "wb") as f:
|
||
if not _make_sparse(f):
|
||
print(f"⚠ _fake_cfg: {_p.name} 未能标成稀疏文件 ⇒ 本次将真占用 {_n} 字节",
|
||
file=sys.stderr)
|
||
f.truncate(int(_n))
|
||
return TEST_WS / "fake-cfg"
|
||
|
||
|
||
def _with_fake_cfg(cfg: Path):
|
||
"""把 `CODEBUDDY_CONFIG_DIR` 临时指到假配置根,返回**还原函数**(⛔ 必须放在 finally 里调)。
|
||
|
||
为什么非还原不可:`_db()` 也读同一个变量 —— 不还原会把**后面所有用例**(主会话解析等)
|
||
一起带偏(那是"改一个用例弄红一片"的经典假红)。
|
||
"""
|
||
old = os.environ.get("CODEBUDDY_CONFIG_DIR")
|
||
|
||
def _restore():
|
||
if old is None:
|
||
os.environ.pop("CODEBUDDY_CONFIG_DIR", None)
|
||
else:
|
||
os.environ["CODEBUDDY_CONFIG_DIR"] = old
|
||
os.environ["CODEBUDDY_CONFIG_DIR"] = str(cfg)
|
||
return _restore
|
||
|
||
|
||
@case("哑会话:诊断日志 ≥ 9.5 MiB ⇒ _deaf_sids() 判哑(只读 · 判据=读数、含边界)")
|
||
def t_deaf_detect():
|
||
"""⚠️ 本用例走 **Python 直调**(不起子进程):`_deaf_sids()` 每次调用**现读**环境变量
|
||
⇒ 换 `CODEBUDDY_CONFIG_DIR` 即生效,无需重启进程。"""
|
||
DEAF = "deadbeef-0000-4000-8000-000000000001"
|
||
NEAR = "deadbeef-0000-4000-8000-000000000002" # 差 1 字节 ⇒ 验**边界不含**
|
||
OKAY = "cafe0000-1111-4222-8333-444444444444"
|
||
cfg = _fake_cfg({DEAF: DEAF_MIB + 100_000, NEAR: DEAF_MIB - 1, OKAY: 4096})
|
||
restore = _with_fake_cfg(cfg)
|
||
try:
|
||
d = imp()._deaf_sids()
|
||
finally:
|
||
restore()
|
||
return [("≥9.5 MiB ⇒ 判哑(%s…)" % DEAF[:8], DEAF in d),
|
||
("9,499,999 B(差 1 字节)⇒ ⛔ 不判哑(边界不含)", NEAR not in d),
|
||
("KB 级日志 ⇒ ⛔ 不判哑", OKAY not in d),
|
||
("⛔ 不误伤:结果里恰好只有那一条哑的", d == {DEAF})]
|
||
|
||
|
||
@case("哑会话:投递命中已哑目标 ⇒ skipped=target-deaf(⛔ 不投 + 喊用户;⛔ 不记假绿)")
|
||
def t_deaf_skip():
|
||
# 🔴🔴 2026-10-02 23:4x **本用例随「跟进会话整套退役」失效**(⛔ 保留代码,⛔ 删了=没有用例=假绿)。
|
||
# 投给「已哑跟进会话」这条路已不存在(`follow-retired`)⇒ `skipped==target-deaf` 拿不到。
|
||
# ⚠️ ⛔ 不许改成"预期 follow-retired"(=断言恒真);判据见 `t_target_busy`。
|
||
# ✅ 但**「不记假绿」这条原则仍活着**,改由退役判据承担:真调用拿 `sid=""` ⇒ 投不出去 ⇒ 台账不增。
|
||
m0 = imp()
|
||
_r = m0.follow_for_topic({}, "X", [])
|
||
return [("⏸ 已退役(2026-10-02 两类改造):投给已哑跟进会话这条路不存在了", True),
|
||
("✅ 退役判据:`sid` 为空 ⇒ **投不出去**(⛔ 记 `ok:true` 就是假绿,退役后天然满足)",
|
||
(not str(_r.get("sid") or "")) and _r.get("why") == "follow-retired")]
|
||
# ═════════════ 以下=旧实现,⛔ 刻意保留勿删 ═════════════
|
||
"""🔴 这条钉住的正是**假绿**:旧实现向已哑会话投递**仍记 `ok:true · http:200`**。
|
||
判据三件:① 判读是 `target-deaf` ② 投递台账**零增量** ③ 已把"该点哪一下"落到 `NEED-USER.md`。"""
|
||
DEAF = "deadbeef-0000-4000-8000-000000000001"
|
||
cfg = _fake_cfg({DEAF: DEAF_MIB + 100_000})
|
||
try:
|
||
(TIN / "NEED-USER.md").unlink() # ⚠️ 先清:否则上一轮留下的那份会让"喊过用户"**恒真**
|
||
except Exception:
|
||
pass
|
||
w0 = wakeups_n()
|
||
restore = _with_fake_cfg(cfg)
|
||
try:
|
||
m = imp()
|
||
# 🔴 2026-10-01 改:登记成 `main` 已经没用(投递**只看跟进会话**,用户口径见 `_deliver_str`)
|
||
# ⇒ 改成**伪造一条跟进会话**,让这道闸真的被走到(⛔ 否则只会落 `no-follow-session`)。
|
||
_old_scan = m._scan_follows
|
||
m._scan_follows = lambda topics=None: {"by_topic": {}, "sids": [DEAF], "cand": [],
|
||
"topics": list(topics or []), "err": ""}
|
||
try:
|
||
r = m._deliver_str("自测:哑会话不该被投", "selftest-deaf", {"roles": {}})
|
||
finally:
|
||
m._scan_follows = _old_scan
|
||
_sk = str(r.get("skipped") or "")
|
||
_nu = (TIN / "NEED-USER.md").exists()
|
||
_w1 = wakeups_n()
|
||
finally:
|
||
restore()
|
||
return [("skipped==target-deaf(现 = %s)" % (_sk or "空"), _sk == "target-deaf"),
|
||
("⛔ 零投递(wakeups %d→%d)" % (w0, _w1), _w1 == w0),
|
||
("已喊用户(NEED-USER.md 落盘)", _nu)]
|
||
|
||
|
||
@case("哑会话:**只认最新一天** —— 昨天撞过上限、今天已正常轮转 ⇒ ⛔ 不判哑(修『永久判哑』)")
|
||
def t_deaf_latest_day_only():
|
||
"""🔴 本用例钉住的正是**方向相反的新假绿**:旧实现扫「今天+昨天」**任一**命中即判哑,
|
||
而宿主会话日志**按天分目录**、昨天那份**永不再增长** ⇒ **昨天撞过 10 MiB、今天已轮转**的
|
||
会话被**永久判哑** ⇒ 本该投出去的唤醒被静默拦下。
|
||
实证 `a202550c`:昨天 10.0 MB、**今天 4.2 MB 且宿主仍在写它**。
|
||
⇒ 判据改为「**每个会话取它最新一天那份日志**」。⛔ 今天没有日志 ⇒ 仍退到昨天(保守兜底,不许丢)。
|
||
"""
|
||
ROTATED = "a202550c-0000-4000-8000-00000000000a" # 昨 10 MiB / 今 4.2 MB ⇒ 已轮转 ⇒ ⛔ 不判哑
|
||
OLDONLY = "0badf00d-0000-4000-8000-00000000000b" # 只有昨天、且撞上限 ⇒ 仍判哑(⛔ 兜底不许丢)
|
||
NEWDEAF = "feedface-0000-4000-8000-00000000000c" # 今天才撞上限 ⇒ 判哑
|
||
HEALTHY = "5ca1ab1e-0000-4000-8000-00000000000d" # 今 4 KB ⇒ ⛔ 不判哑
|
||
shutil.rmtree(TEST_WS / "fake-cfg", ignore_errors=True) # ⛔ 先清空:前面两条用例也在同一棵树下造过文件
|
||
_fake_cfg({ROTATED: DEAF_MIB + 500_000, OLDONLY: DEAF_MIB + 500_000}, day_offset=1) # 昨天那份
|
||
cfg = _fake_cfg({ROTATED: 4_200_000, NEWDEAF: DEAF_MIB + 500_000, HEALTHY: 4096}) # 今天那份
|
||
restore = _with_fake_cfg(cfg)
|
||
try:
|
||
d = imp()._deaf_sids()
|
||
finally:
|
||
restore()
|
||
shutil.rmtree(TEST_WS / "fake-cfg", ignore_errors=True)
|
||
return [("昨 10 MB / 今 4.2 MB(已轮转)⇒ ⛔ 不判哑", ROTATED not in d),
|
||
("今天才撞上限 ⇒ 判哑", NEWDEAF in d),
|
||
("只有昨天那份、且撞上限 ⇒ 仍判哑(兜底不丢)", OLDONLY in d),
|
||
("今天 KB 级 ⇒ ⛔ 不判哑", HEALTHY not in d),
|
||
("⛔ 不误伤:结果恰好={今天哑的, 只有昨天的}(现 %d 条)" % len(d),
|
||
d == {NEWDEAF, OLDONLY})]
|
||
|
||
|
||
# ── 🔴 2026-09-30 加:**投递=宿主钩子唤起(`--tick`)** 的回归面 ────────────────
|
||
# 背景(用户口径「又给我整到自动任务去了」):投递改由**宿主钩子**唤起 `collabd.py --tick`
|
||
# (钩子是宿主子进程 ⇒ 自带网关口令)。
|
||
# ⚠️ **2026-09-30 同日用户改口**(取代上面那句里的"⛔ 不靠自动化排期"):
|
||
# 「自动任务(符合条件时)是通过 协作程序去唤醒 主会话,这样流程统一」
|
||
# ⇒ **允许自动任务**当"闹钟"(`goalctl.py wake` ⇒ `--tick`);⛔ 但判断与投递**仍只在本程序一处**。
|
||
# 🔴 下面两条约束**与"谁拨这一下"无关**,**仍然有效**(改触发源⛔ 不免除它们):
|
||
# ① `--once`(常驻程序/投影轮)**⛔ 不许消费队列** —— 它在 UserPromptSubmit 上先跑;
|
||
# ② `--tick` **投不出去就不许消费队列** —— 旧实现无条件 `notified[kid]=stt` ⇒ **静默丢件**。
|
||
|
||
@case("投递(新):纯投影 --once ⛔ 不消费队列")
|
||
def t_projection_no_consume():
|
||
put_tasks({"P1": {"state": "running", "by": "t", "line": "line-a"}})
|
||
(TIN / "collabd-state.json").write_text(json.dumps(
|
||
{"notified": {}, "notify_pending": ["P1=running"]}, ensure_ascii=False), encoding="utf-8")
|
||
rc, _ = run_cli(["--once"])
|
||
s = state()
|
||
kept = "P1=running" in list(s.get("notify_pending") or [])
|
||
no_aw = not s.get("notify_awaiting")
|
||
put_tasks({})
|
||
return [("rc==0", rc == 0),
|
||
("--once 后 notify_pending 原样保留(现 = %s)" % (s.get("notify_pending"),), kept),
|
||
("--once 未设 notify_awaiting", no_aw)]
|
||
|
||
|
||
@case("投递(新):投不出去(no-token)⇒ ⛔ 不消费队列(防静默丢件)")
|
||
def t_no_consume_when_skipped():
|
||
put_tasks({"Q1": {"state": "running", "by": "t", "line": "line-a"}})
|
||
(TIN / "collabd-state.json").write_text(json.dumps(
|
||
{"notified": {}, "notify_pending": ["Q1=running"]}, ensure_ascii=False), encoding="utf-8")
|
||
env = _mk_env()
|
||
env.pop("CODEBUDDY_GATEWAY_PASSWORD", None) # 抽掉口令 ⇒ 投递必被挡(no-token)
|
||
# 🔴🔴 2026-10-05 `--tick` 已删 ⇒ 载体换成 `--supervise` 的**判定本体** `supervise()`:
|
||
# 本用例的红线是「**投不出去就不许消费队列**」(防静默丢件),⛔ 与 tick 这个入口无关
|
||
# ⇒ 直接真调 `supervise(st, deliver=True, mutate=True)` 并抽掉口令(⛔ 不换个入口就丢掉红线)。
|
||
m = imp()
|
||
try:
|
||
_st = json.loads((TIN / "collabd-state.json").read_text(encoding="utf-8"))
|
||
except Exception:
|
||
_st = {}
|
||
_old = os.environ.pop("CODEBUDDY_GATEWAY_PASSWORD", None)
|
||
try:
|
||
m.supervise(_st, deliver=True, mutate=True)
|
||
except Exception:
|
||
pass
|
||
finally:
|
||
if _old is not None:
|
||
os.environ["CODEBUDDY_GATEWAY_PASSWORD"] = _old
|
||
rc, out = 0, ""
|
||
s = state()
|
||
pend = list(s.get("notify_pending") or [])
|
||
notified = dict(s.get("notified") or {})
|
||
put_tasks({})
|
||
return [("rc==0", rc == 0),
|
||
("队列项仍在(现 = %s)" % (pend,), "Q1=running" in pend),
|
||
("⛔ 未被标记 notified", notified.get("Q1") != "running"),
|
||
("⛔ 未设 notify_awaiting", not s.get("notify_awaiting"))]
|
||
|
||
|
||
@case("宿主卡住探针:只读尾部、测试环境零命中、⛔ 不误报")
|
||
def t_park_probe():
|
||
# 2026-09-30 立(用户给的窗口 22:55–23:20 实测定型):指纹 = parkInQueue + hasWaiter=false。
|
||
# ⚠️ 测试工作区名是 `selftest` ⇒ 候选日志目录里没有它的日志 ⇒ 必须**零命中且不报错**(⛔ 不碰生产日志)。
|
||
m = imp()
|
||
r = m.probe_host_park()
|
||
real = ('[2026/9/29 22:53:00.208] [Info] [pid=32356] [AcpView] [AcpView][PromptIterator] '
|
||
'received prompt session=fe146dd9-… route=parkInQueue runState=idle queueLen=0 hasWaiter=false')
|
||
echo = ('[2026/9/30 00:36:15.002] [Info] [pid=50716] [SandboxShell] ProcessOutput | processId=pipe-18 | '
|
||
'content=Info] [pid=32356] [AcpView] route=parkInQueue queueLen=1 hasWaiter=false | stderr(0)=""')
|
||
ok_line = ('[2026/9/29 23:12:16.901] [Info] [pid=55508] [AcpView] [AcpView][PromptIterator] '
|
||
'received prompt session=fe146dd9-… route=resolveWaiter runState=idle queueLen=0 hasWaiter=true')
|
||
return [("返回 dict 且含 n/last/file", all(k in r for k in ("n", "last", "file"))),
|
||
("测试环境零命中(不读生产日志)", r["n"] == 0),
|
||
("只读尾部(常量 ≤ 512KB)", 0 < m.PARK_TAIL <= 512 * 1024),
|
||
("⛔ 不命中「取证命令的回声行」(防假阳性)", m._is_park_line(echo) is False),
|
||
("✅ 命中真正的 parkInQueue 记录行", m._is_park_line(real) is True),
|
||
("✅ 不误判正常行(resolveWaiter/hasWaiter=true)", m._is_park_line(ok_line) is False)]
|
||
|
||
|
||
@case("投递消费回查:投出去 ≠ 跑起来了 ⇒ 未消费必须升级为 NEED-USER")
|
||
def t_consume_check():
|
||
m = imp()
|
||
nu = TIN / "NEED-USER.md"
|
||
if nu.exists():
|
||
nu.unlink()
|
||
# ① 刚投出去(宽限期内)⇒ waiting,⛔ 不报警
|
||
st1 = {"wake": {"expect": {"sid": "0" * 36, "at": time.time()}}}
|
||
r1 = m.check_delivery_consumed(st1)
|
||
quiet = not nu.exists() # ⚠️ 必须在跑 ② 之前取快照(② 会写这个文件 ⇒ 否则本项必假红)
|
||
# ② 投出很久且目标会话零活动 ⇒ unconsumed,且必须落 NEED-USER.md(喊人)
|
||
st2 = {"wake": {"expect": {"sid": "0" * 36, "at": time.time() - 9999}}}
|
||
r2 = m.check_delivery_consumed(st2)
|
||
shouted = nu.exists() and "切走再切回" in nu.read_text(encoding="utf-8", errors="replace")
|
||
return [("宽限期内 = waiting 且不报警", r1.get("waiting") is True and quiet),
|
||
("超期未动 = unconsumed", r2.get("unconsumed") is True),
|
||
("落 NEED-USER 且写明处置动作(切走再切回)", shouted),
|
||
("回查后清掉 expect(⛔ 不重复报)", "expect" not in (st2.get("wake") or {}))]
|
||
|
||
|
||
@case("need_user:同句节流(⛔ 不每轮刷时间戳)+ 人写的「## 解除条件」两路都必须原样保留")
|
||
def t_need_user_throttle_keep():
|
||
"""🔴 2026-10-01 立(治两处现场事故,均已在生产实测):
|
||
· **噪音**:常驻每 ~10 s 用同一句话重写 NEED-USER.md ⇒ 时间戳一直变、人看不出「这条是新的还是旧的」
|
||
(现场:22:06–22:12 之间被刷了 20+ 次)。
|
||
· **抹掉人的回信口**:人手工写了「## 解除条件」,下一轮被机器**静默抹掉** ⇒ 人就再也不写了
|
||
(现场:21:03 手写的解除条件当场被覆盖)。
|
||
⚠️ 判据必须能「改前报红」(P0-13):把节流去掉 ⇒ ③ 报红;把保留 `_keep` 去掉 ⇒ ④ 报红。"""
|
||
import inspect as _ins
|
||
m = imp()
|
||
nu = TIN / "NEED-USER.md"
|
||
human = "\n## 解除条件\n- 用户手写:这是人唯一的回信口,⛔ 不许抹\n"
|
||
try:
|
||
if nu.exists():
|
||
nu.unlink()
|
||
# ① 第一次喊 ⇒ 必须落文件(⛔ 不许"喊了但没落")
|
||
m.need_user("自测原因-A")
|
||
got1 = nu.exists() and ("自测原因-A" in nu.read_text(encoding="utf-8", errors="replace"))
|
||
# ② 模拟人回信:追加「## 解除条件」
|
||
nu.write_text(nu.read_text(encoding="utf-8", errors="replace").rstrip() + human,
|
||
encoding="utf-8")
|
||
mt2 = nu.stat().st_mtime
|
||
# ③ 同一句话、节流窗口内再喊 ⇒ ⛔ 不重写(mtime 不变)+ 人的话必须还在
|
||
m.need_user("自测原因-A")
|
||
t3 = nu.read_text(encoding="utf-8", errors="replace")
|
||
quiet = (nu.stat().st_mtime == mt2) and ("用户手写" in t3)
|
||
# ④ 换一个原因 ⇒ 允许重写,但**人的话仍要原样保留**
|
||
m.need_user("自测原因-B")
|
||
t4 = nu.read_text(encoding="utf-8", errors="replace")
|
||
keep = ("自测原因-B" in t4) and ("## 解除条件" in t4) and ("用户手写" in t4)
|
||
# ⑤ 源码级:need_user 段内零 unlink(删文件 = 撞宿主 SafeDelete 批量删除护栏,P0-6)
|
||
try:
|
||
_src = _ins.getsource(m.need_user)
|
||
except Exception:
|
||
_src = ""
|
||
no_unlink = bool(_src) and (".unlink" not in _src)
|
||
return [("① 首次喊 ⇒ NEED-USER.md 落盘且含原因", got1),
|
||
("③ 同原因在窗口内 ⇒ ⛔ 不重写(mtime 未变)且人的话还在", quiet),
|
||
("④ 换原因才重写,且人的「## 解除条件」原样保留", keep),
|
||
("⑤ need_user 段内零 unlink", no_unlink)]
|
||
finally:
|
||
if nu.exists():
|
||
nu.unlink()
|
||
|
||
|
||
@case("投递忙判据:读宿主状态机日志的 busy=(⛔ 不再用 `status=='working'` 那档恒真的判据)")
|
||
def t_srsm_busy():
|
||
"""🔴 2026-10-01 立(治「状态窗口死结」,现场已在生产实测)。
|
||
|
||
病根:`sessions.status` 取值域只有 `working/completed/error/archived` —— **没有"空闲"这一档**,
|
||
活着的会话跑完一轮、停在等下一轮时 status **仍是 `working`** ⇒ 拿它判"忙"**恒真**
|
||
⇒ 投递永远等不到空闲(实测 `e2ccdea3` 连打 6 分钟 `延后投递…等它空闲`+`投递未成(target-busy)`)。
|
||
正解:宿主自己的 `[SessionRunStateMachine]` 日志(⛔ 不进数据库,查库查不到)。
|
||
⚠️ 判据必须能「改前报红」(P0-13):把"新鲜度"那半去掉 ⇒ ③ 报红;把「先判终结态」去掉 ⇒ ⑤ 报红。
|
||
"""
|
||
m = imp()
|
||
tmpd = TEST_WS / "logs"
|
||
tmpd.mkdir(parents=True, exist_ok=True)
|
||
f = tmpd / "fakews__abcd.log"
|
||
SID = "11111111-2222-3333-4444-555555555555"
|
||
OTHER = "99999999-2222-3333-4444-555555555555"
|
||
|
||
def line(ts, sid, busy):
|
||
return ("[%s] [Info] [pid=1] [SessionRunStateMachine] transition | sessionId=%s | event=X | "
|
||
"from=a | to=b | lifecycle=running | busy=%s | queueBusy=true" % (ts, sid, busy))
|
||
|
||
now = time.strftime("%Y/%m/%d %H:%M:%S")
|
||
old = time.strftime("%Y/%m/%d %H:%M:%S", time.localtime(time.time() - 7200))
|
||
orig_dirs, orig_status = m._log_dirs, m._session_status
|
||
try:
|
||
m._log_dirs = lambda: [f]
|
||
# ① 末条 `busy=true` 且新鲜 ⇒ busy(正对照)
|
||
f.write_text(line(now, SID, "true") + "\n", encoding="utf-8")
|
||
c1 = m._session_busy(SID) == "busy"
|
||
# ② 末条 `busy=false` ⇒ idle(⛔ 不能被前一条 busy=true 带偏 ⇒ 必须取**最后一条**)
|
||
f.write_text(line(now, SID, "true") + "\n" + line(now, SID, "false") + "\n", encoding="utf-8")
|
||
c2 = m._session_busy(SID) == "idle"
|
||
# ③ 末条 `busy=true` 但已是 2 小时前 ⇒ idle —— 这正是"会话早停了、只是没写 busy=false"那一类
|
||
f.write_text(line(old, SID, "true") + "\n", encoding="utf-8")
|
||
c3 = m._session_busy(SID) == "idle"
|
||
# ④ 日志里**没有**该 sid ⇒ ''(⛔ 不猜、⛔ 不回落成 busy 或 idle)
|
||
f.write_text(line(now, OTHER, "true") + "\n", encoding="utf-8")
|
||
c4 = m._session_busy(SID) == ""
|
||
# ⑤ 库里已**终结** ⇒ 即便日志末条还是 `busy=true` 也**不判忙**
|
||
# (实测:自动化拉起的会话跑完**不写 `busy=false`**,直接在库里变 completed)
|
||
f.write_text(line(now, SID, "true") + "\n", encoding="utf-8")
|
||
m._session_status = lambda s: "completed"
|
||
c5 = (m._target_busy(SID) is False)
|
||
finally:
|
||
m._log_dirs, m._session_status = orig_dirs, orig_status
|
||
try:
|
||
f.unlink()
|
||
except Exception:
|
||
pass
|
||
# ⑥ 源码级**反回归**:正则必须用**命名组**。
|
||
# 本轮真踩过:写成 `(?:\.(\d+))?` 时内层 `(\d+)` **仍是捕获组** ⇒ 后面 `m.group(8)` 全错位一位
|
||
# ⇒ `busy` 恒读成 False ⇒ **恒判"空闲"**(方向相反:会往正在跑的会话里插话)。
|
||
# ⛔ 这条断言不是"看代码像不像" —— 它是**唯一**能在这类错位再发生时立刻报红的东西。
|
||
# ⚠️ 判据范围必须**从正则定义处起**(`getsource(_session_busy)` 只给函数体 ⇒ 看不见正则本身);
|
||
# 末端取 `def _target_busy` ⇒ 恰好覆盖「正则 + `_session_busy`」这一段。
|
||
_all = (HERE / "collabd.py").read_text(encoding="utf-8", errors="replace")
|
||
_i = _all.find("_SRSM_RE = re.compile(")
|
||
_j = _all.find("def _target_busy", _i if _i >= 0 else 0)
|
||
seg = _all[_i:_j] if (_i >= 0 and _j > _i) else ""
|
||
named = bool(seg) and ("?P<busy>" in seg) and not re.search(r"group\(\s*[\"']?[1-9]", seg)
|
||
# ===== 🔴🔴 2026-10-04 第 2 源(`vibe-product` 实测坐实):会话级日志兜底 =====
|
||
# 🔴 病根:工作区日志 `<ws>__<hash>.log` 里 `SessionRunStateMachine` 有 615 条,
|
||
# 但**那个 sid 一条都没有** ⇒ 宿主**不为它写**工作区级状态机日志
|
||
# ⇒ `_session_busy()` 恒 `''` ⇒ 老逻辑**必然误判「没在跑」⇒ 往正在跑的会话插话**
|
||
# (该会话当时 `working` 358 次 / `idle` 仅 3 次 / 用户只发言 4 次,跨 2h46m)。
|
||
# ✅ 正解=**双源**:工作区读不到时回落到**会话级** `<logs>/<日期>/sdk/conversations/<sid>.log`。
|
||
# ⚠️ 那个文件**字段完全不同**:`state-machine:transition {"to":"working"}`,⛔ 没有 `busy=`。
|
||
# ⚠️ 文件名**带连字符**,而调用方常拿库里 32 位无连字符 id ⇒ 两种形态都得试。
|
||
conv_dir = TEST_WS / "logs" / time.strftime("%Y-%m-%d") / "sdk" / "conversations"
|
||
conv_dir.mkdir(parents=True, exist_ok=True)
|
||
# ⛔ 用**真 sid**:长度 36 且前缀 8 位唯一,否则下面的前缀兜底会走不到
|
||
CSID = "b232218f-c8e6-49cb-911c-f2afe00bcd39"
|
||
CNO = "aaaaaaaa-0000-0000-0000-000000000000"
|
||
cf = conv_dir / ("%s.log" % CSID)
|
||
import datetime as _dt
|
||
orig_conv, orig_st2 = m._conv_log, m._session_status
|
||
# 🔴🔴 2026-10-04修:**时间戳必须相对"现在"生成**,⛔不许写死绝对时刻。
|
||
# 写死 `2026-10-04T03:24:24.996Z` 时,本用例只在**当天凌晨**成立;一旦跨过
|
||
# `SRSM_FRESH`(900s) 就恒定报红(实测 age=12372s)⇒ 这种红是**用例自己过期**,
|
||
# **不是判据坏了** ⛔ 别去改判据迁就它(这正是"基线恒红 ⇒ 变异验证作废"那个坑)。
|
||
def _now_utc(offset_h: float = 0.0) -> str:
|
||
t_ = _dt.datetime.now(_dt.timezone.utc) + _dt.timedelta(hours=offset_h)
|
||
return t_.strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
||
try:
|
||
m._conv_log = lambda sid=None: [cf] if sid and str(sid).replace("-", "") == CSID.replace("-", "") else []
|
||
# ⑦ 末条 to=working ⇒ busy
|
||
cf.write_text('%s state-machine:transition '
|
||
'{"from":"idle","to":"working"}\n' % _now_utc(-0.05), encoding="utf-8")
|
||
c7 = m._session_busy_conv(CSID) == "busy"
|
||
# ⑧ 末条 to=idle ⇒ idle(⛔ 取**最后一条**)
|
||
cf.write_text('%s state-machine:transition {"to":"working"}\n'
|
||
'%s state-machine:transition {"to":"idle"}\n'
|
||
% (_now_utc(-1), _now_utc(-0.02)), encoding="utf-8")
|
||
c8 = m._session_busy_conv(CSID) == "idle"
|
||
# ⑨ ⛔ 拿**工作区正则**去读会话级日志 ⇒ 必得 ''(老行为:恒读不到 ⇒ 恒判空闲)
|
||
_keep = m._CONV_TR
|
||
m._CONV_TR = m._SRSM_RE
|
||
c9 = m._session_busy_conv(CSID) == ""
|
||
m._CONV_TR = _keep
|
||
# ⑩ 🔴 整条链:工作区源读不到 ⇒ **回落源必须救回来**
|
||
# (改前 `_target_busy` = False = 误判空闲;改后必须 True)
|
||
_ob, _os = m._session_busy, m._session_status
|
||
m._session_busy = lambda s: ""
|
||
m._session_status = lambda s: "working"
|
||
cf.write_text('%s state-machine:transition {"to":"working"}\n' % _now_utc(-0.05),
|
||
encoding="utf-8")
|
||
c10 = m._target_busy(CSID) is True
|
||
# ⑪ ⛔ 但库里已**终结** ⇒ 仍不判忙(第 ① 道闸优先级不能被回落源破坏)
|
||
m._session_status = lambda s: "completed"
|
||
c11 = m._target_busy(CSID) is False
|
||
m._session_busy, m._session_status = _ob, _os
|
||
finally:
|
||
m._conv_log, m._session_status = orig_conv, orig_st2
|
||
try:
|
||
cf.unlink()
|
||
except Exception:
|
||
pass
|
||
# ⑫ 源码级:双源接线必须在(⛔ 少一个就退回单源误判)
|
||
_src = _all[_all.find("def _target_busy"):_all.find("def _target_busy") + 2600]
|
||
dual = ("_session_busy_conv" in _src) and ("_session_busy(sid)" in _src)
|
||
# ⑬ sid 形态:库里 32 位无连字符 ⇄ 文件名 36 位带连字符,⛔ 两种都要能命中
|
||
_pathfix = ("replace(\"-\", \"\")" in _all) or ("replace('-', '')" in _all)
|
||
# ===== 🔴🔴 2026-10-04 第 2 源的两个真缺陷(`vibe-product` / `b232218f…` 实测坐实)=====
|
||
#⛔ 两个缺陷**同向**:都让"已收工/已死活的会话"被判`busy` ⇒ 调用方 `skipped="target-busy"`
|
||
# ⇒ 永远收不到上报且**无告警**(静默饿死)⇒ 比"恒真"更隐蔽。
|
||
#① **跨日文件按遍历顺序覆盖**:`_conv_log` 返回 `[今天, 昨天]` ⇒ 昨天那条 `working`
|
||
# 会把今天已经 `idle` 的盖掉。⚠️ 这条**必须真的造两个文件**才测得到 ——
|
||
# 只喂一个文件的话两种实现都过(假绿)。
|
||
#② **完全没有新鲜度闸**(`_session_busy` 有 `SRSM_FRESH`,本源一个都没有)。
|
||
try:
|
||
def _ts_utc(hours_ago: float) -> str:
|
||
t = _dt.datetime.now(_dt.timezone.utc) - _dt.timedelta(hours=hours_ago)
|
||
return t.strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
||
day_t = time.strftime("%Y-%m-%d")
|
||
day_y = time.strftime("%Y-%m-%d", time.localtime(time.time() - 86400))
|
||
d_today = TEST_WS / "logs" / day_t / "sdk" / "conversations"
|
||
d_yest = TEST_WS / "logs" / day_y / "sdk" / "conversations"
|
||
d_today.mkdir(parents=True, exist_ok=True)
|
||
d_yest.mkdir(parents=True, exist_ok=True)
|
||
XSID = "dddddddd-1111-2222-3333-444444444444"
|
||
f_today = d_today / ("%s.log" % XSID)
|
||
f_yest = d_yest / ("%s.log" % XSID)
|
||
orig_conv3 = m._conv_log
|
||
try:
|
||
# 🔴 **顺序照 `_conv_log` 的真形态**:`[今天, 昨天]`(⛔ 顺序反了测不到本缺陷)
|
||
m._conv_log = lambda sid=None: [f_today, f_yest] if sid and XSID in str(sid) else []
|
||
# 今天末条 idle(会话已收工),昨天卡在 working
|
||
f_today.write_text('%s state-machine:transition {"from":"working","to":"idle"}\n'
|
||
% _ts_utc(0.01), encoding="utf-8")
|
||
f_yest.write_text('%s state-machine:transition {"from":"idle","to":"working"}\n'
|
||
% _ts_utc(20), encoding="utf-8")
|
||
# ①跨日:取**时间戳最新**的那条⇒ 今天 idle ⇒判 idle(改前:被昨天覆盖⇒ busy)
|
||
c14 = m._session_busy_conv(XSID) == "idle"
|
||
# ② 新鲜度:单文件末条 working 但已是 3 小时前 ⇒ idle(改前:永久 busy)
|
||
m._conv_log = lambda sid=None: [f_today] if sid and XSID in str(sid) else []
|
||
f_today.write_text('%s state-machine:transition {"from":"idle","to":"working"}\n'
|
||
% _ts_utc(3), encoding="utf-8")
|
||
c15 = m._session_busy_conv(XSID) == "idle"
|
||
# ③ ⛔ 反向回归:末条 working 但**只有 2 分钟** ⇒ 必须仍判 busy
|
||
# (防"新鲜度闸写太狠,把正在跑的也判空闲"⇒ 那是**往在跑的会话插话**的方向)
|
||
f_today.write_text('%s state-machine:transition {"from":"idle","to":"working"}\n'
|
||
% _ts_utc(0.03), encoding="utf-8")
|
||
c16 = m._session_busy_conv(XSID) == "busy"
|
||
# ④ ⛔ 时区:会话级日志行尾是 `Z`(UTC),⛔ 误按本地时区解析会差 8h
|
||
# ⇒ 东八区下"2 分钟前"会被算成"8 小时前"⇒ 恒判过期 ⇒ ③ 变idle。
|
||
# 锚在**真算出来的差值**上:⛔ 不锚"源码里出现了 calendar"(那是文本出现过,不是判定生效)。
|
||
_ln = "%s state-machine:transition {\"to\":\"working\"}" % _ts_utc(0.03)
|
||
_got = m._conv_ts(_ln)
|
||
c17 = isinstance(_got, float) and abs(_got - time.time()) < 300
|
||
finally:
|
||
m._conv_log = orig_conv3
|
||
for _f in (f_today, f_yest):
|
||
try:
|
||
_f.unlink()
|
||
except Exception:
|
||
pass
|
||
except Exception as _e: # ⛔ 造不出样本 ⇒ 判红,⛔ 不许静默当过
|
||
c14 = c15 = c16 = c17 = "造样本失败:%s" % _e
|
||
return [("① 末条 busy=true 且新鲜 ⇒ `busy`", c1),
|
||
("② 末条 busy=false ⇒ `idle`(取的是**最后一条**)", c2),
|
||
("③ 末条 busy=true 但已 2 小时 ⇒ `idle`(新鲜度真在起作用)", c3),
|
||
("④ 日志无该 sid ⇒ `''`(⛔ 不猜)", c4),
|
||
("⑤ 库里已终结 ⇒ 日志仍 busy=true 也**不判忙**", c5),
|
||
("⑥ `_session_busy` 用**命名组**、零位置组引用(防'数括号'错位)", named),
|
||
("⑦ 会话级源 末条 `to=working` ⇒ `busy`", c7),
|
||
("⑧ 会话级源 末条 `to=idle` ⇒ `idle`(取的是**最后一条**)", c8),
|
||
("⑨ ⛔ 拿**工作区正则**读会话级 ⇒ 恒 `''`(= 老行为;两条正则形状不同)", c9),
|
||
("⑩ 🔴 工作区源读不到时**回落源救回来**(改前 False = 往在跑的会话插话)", c10),
|
||
("⑪ 🔴 但库里已终结 ⇒ 回落源**不许**越权判忙(第 ① 道闸优先)", c11),
|
||
("⑫ 源码级:**双源接线**在(⛔ 少一个就退回单源误判)", dual),
|
||
("⑬ sid 形态兼容:32 位无连字符 ⇄ 36 位带连字符都命中", _pathfix),
|
||
("⑭ 🔴 跨日两个文件 ⇒ 取**时间戳最新**那条(⛔ 昨天的工作态不得盖掉今天的已收工)", c14),
|
||
("⑮ 🔴 会话级源也要有**新鲜度闸**(3 小时前的 working ⇒ idle)", c15),
|
||
("⑯ 🔴 反向回归:2 分钟前的 working **仍判 busy**(⛔ 防闸太狠⇒ 往在跑的会话插话)", c16),
|
||
("⑰ 🔴 会话级时间戳按 **UTC** 解析(⛔ 误按本地时区差 8h ⇒ 恒判过期)", c17)]
|
||
|
||
|
||
@case("🔴 `--tick` 已删:参数表/分支/钩子调用**三处皆无**,且投递段仍真删")
|
||
def t_tick_wired():
|
||
"""🔴🔴 2026-10-05 `--tick` **机制删除**(用户:「检查程序 常驻 自己不会判断吗,非要什么 tick
|
||
once 去触发?」)⇒ 本用例**整条反转**:从「tick 在位可跑」改为「tick 三处皆无」。
|
||
|
||
它原先扛的三件事已各归其位,⛔ 判据必须**逐条钉住迁移后的落点**(否则删了没人知道去哪了):
|
||
① 建检查会话排期(两条腿)⇒ **`--supervise` 主循环**里 `queue_pending()>0` 分流判
|
||
② park 指纹探针 ⇒ 抽成 `_tick_park_probe()`,常驻主循环节流调
|
||
③ 顺手续命常驻 ⇒ 钩子里改成 `--ensure`(`maybe_ensure_supervise`)
|
||
⚠️ 同时**保留**原「投递段已真删」的判据(那条红线与 tick 删不删无关,⛔ 不许顺手丢掉)。
|
||
"""
|
||
m = imp()
|
||
src = (HERE / "collabd.py").read_text(encoding="utf-8", errors="replace")
|
||
# ① 参数表:⛔ 不许还在 _KNOWN 里(留着 ⇒ 传 --tick 会静默退出,极难排查)
|
||
_seg = src.split("_KNOWN = {")[1][:400] if "_KNOWN = {" in src else ""
|
||
known = '"--tick"' not in _seg
|
||
# ② 分支:`if "--tick" in sys.argv` 不在
|
||
no_branch = 'if "--tick" in sys.argv' not in src
|
||
# ③ 迁移落点①②必须在(⛔ 只对源码字面 —— 这两处是新写的,字面稳定)
|
||
_sup_seg = src.split('if "--supervise" in sys.argv')[1][:6000] if 'if "--supervise" in sys.argv' in src else ""
|
||
legs_moved = ('maybe_spawn_check_agent("sessions-ended")' in _sup_seg
|
||
and 'maybe_spawn_check_agent("queue-empty")' in _sup_seg
|
||
and "_tick_park_probe()" in _sup_seg)
|
||
rc, out = run_cli(["--tick"]) # ⛔ 传它必须**被拒**(非 0 或报未知参数)
|
||
# 🔴 2026-10-01 改:原先这里写**绝对路径**指到某个具体工作区的 `tools/wb-result-hook.py`
|
||
# ⇒ 两处都不对:① 违反「技能里只用相对路径」(包去依赖"使用方的"项目目录);
|
||
# ② 宿主接线自 2026-10-01 起已改指**包内**那份 ⇒ 老写法在别的机器上恒走"文件不在,跳过",
|
||
# 看着绿、其实**什么都没验**(静默假绿)。现在按包内相对路径取自己那份钩子。
|
||
hook = HERE / "hooks" / "wb-result-hook.py"
|
||
hook_ok, hook_note = True, "(钩子文件不在,跳过)"
|
||
if hook.exists():
|
||
hs = hook.read_text(encoding="utf-8", errors="replace")
|
||
# 🔴🔴 反转:钩子必须**没有** `--tick` 调用,且**要有** `--ensure` 续命(⛔ 少了就没人拉常驻)
|
||
# ⚠️⛔ 判据**只认带引号的字面 `"--tick"`**,⛔ 不认 `--tick` 裸串 ——
|
||
# 删除说明的注释里**必然**提到它(否则读者不知道删了什么)⇒ 裸串判据**永久假红**
|
||
# (P0-13 同族:docstring 提被删项 ⇒ 只能判"调用/带引号",⛔ 不能判"出现")。
|
||
hook_ok = ('"--tick"' not in hs) and ("maybe_run_supervisor_tick" not in hs) \
|
||
and ("maybe_ensure_supervise" in hs) and ('"--ensure"' in hs)
|
||
hook_note = "钩子已无 `--tick` 调用,续命改走 `--ensure`"
|
||
# 🔴🔴 2026-10-03 **投递段退役**:本用例原判据是「`--tick` 用 `mutate=True` 调 `supervise()`
|
||
# ⇒ 它是唯一投递方」——**这条判据已随投递退役而失效**(没有"投递方"这个角色了)。
|
||
# ⚠️ ⛔ **不许把它改成"`--tick` 用 `deliver=False` 调 ⇒ 它是唯一不投递方"** ——
|
||
# 那是**换个说法说同一件事**,仍对着源码字面匹配,一重构就假红(P0-13 同族)。
|
||
# ✅ **正解=退役它,换成"投递段真删"的判据**,且判据必须**可证伪**:
|
||
# ① **真调用** `supervise({}, deliver=True, mutate=True)`(⛔ 传最"能投"的那组参数)
|
||
# ⇒ `deliver.skipped` 必须为 `retired-20261003`。
|
||
# ⚠️⛔ **不许去读生产 state 取证**(本文件 L169 的元用例明令「自测不碰生产」,
|
||
# 且那样判据依赖"生产已被新代码跑过"⇒ 换台机器必假红)。
|
||
# ② **`supervise()` 函数体内零个调用**——判据走 **AST**(⛔ 不是字面 grep:
|
||
# docstring 里**必然**提到 `_deliver_str`(要说明退役了什么)⇒ 字面判据永久假红)。
|
||
try:
|
||
_r = m.supervise({}, deliver=True, mutate=True)
|
||
except Exception as _e:
|
||
_r = {"deliver": {"skipped": "raised:%r" % (_e,)}}
|
||
_sk = str((_r.get("deliver") or {}).get("skipped") or "")
|
||
calls = _calls_in(src, "supervise")
|
||
r_nocall = ("_deliver_str" not in calls) and ("follow_for_topic" not in calls)
|
||
return [("`--tick` ⛔ 已从参数表移除(留着=传它会静默退出)", known),
|
||
("`--tick` 分支已删(源码无 `if \"--tick\" in sys.argv`)", no_branch),
|
||
("⛔ 传 `--tick` 被拒(未知参数,rc=%s)" % (rc,),
|
||
rc != 0 or ("未知参数" in str(out))),
|
||
("🔴 迁移落点:`sessions-ended`/`queue-empty`/park 探针**都在 `--supervise` 内**",
|
||
legs_moved),
|
||
("🔴 2026-10-03 退役判据①:真调用 `supervise(deliver=True, mutate=True)` ⇒ `deliver.skipped` 必为 "
|
||
"`retired-20261003`(实测 = %r)" % (_sk,), _sk == "retired-20261003"),
|
||
("🔴 2026-10-03 退役判据②:`supervise()` 体内**零个** `_deliver_str`/`follow_for_topic` **调用**"
|
||
"(AST 判据,⛔ 不看 docstring;实测调用集 = %s)" % (sorted(calls) or "空",), r_nocall),
|
||
(hook_note, hook_ok)]
|
||
|
||
|
||
def _calls_in(src: str, fname: str) -> set:
|
||
"""🔴 **函数 `fname` 体内真正被调用的名字集合**(走 AST,⛔ 不做字面 grep)。
|
||
|
||
🔴🔴 **为什么必须走 AST**(2026-10-03 实测踩到):退役说明的 docstring 里**必然**提到
|
||
被删的函数名(否则读者不知道删了什么)⇒ 任何 `grep "_deliver_str(" in <函数体切片>`
|
||
都**永久假红** ⇒ 只能判"**调用**",⛔ 不能判"**出现**"。
|
||
⚠️ 附带好处:AST 只认 `Name`/`Attribute` 的 `func` 位 ⇒ 注释、字符串、docstring 一律不算。
|
||
"""
|
||
import ast as _ast
|
||
try:
|
||
tree = _ast.parse(src)
|
||
except Exception:
|
||
return {"<parse-error>"}
|
||
for node in _ast.walk(tree):
|
||
if isinstance(node, _ast.FunctionDef) and node.name == fname:
|
||
out = set()
|
||
for sub in _ast.walk(node):
|
||
if isinstance(sub, _ast.Call):
|
||
f = sub.func
|
||
if isinstance(f, _ast.Name):
|
||
out.add(f.id)
|
||
elif isinstance(f, _ast.Attribute):
|
||
out.add(f.attr)
|
||
return out
|
||
return {"<no-such-function>"}
|
||
|
||
|
||
@case("对账:僵尸(台账 running 但没人跑)⇒ 标 blocked")
|
||
def t_reconcile_zombie():
|
||
# 🔴 2026-09-30 修(**本用例曾假红**):僵尸的判据是「**除观察者外无人跑** ∧ 无 IN_PROGRESS」——
|
||
# 而测试机上**天天有别的工作会话在跑** ⇒ 前提不成立 ⇒ 不能判它红(⛔ 也不许假装绿)。
|
||
# 正确姿势:**前提不成立就 SKIP**(读不出宿主库、或确实有别的会话在跑)。
|
||
m = imp()
|
||
put_tasks({"Z1": {"state": "running", "by": "已经死掉的棒", "t": time.time()}})
|
||
r = m.reconcile({})
|
||
if r.get("skip"):
|
||
put_tasks({})
|
||
return [("SKIP:读不到宿主库(%s)" % r["skip"], True)]
|
||
# 🔴 2026-10-01 加:僵尸分支的**完整前提** =「**除观察者外**无在跑会话」∧「无 IN_PROGRESS 运行」。
|
||
# 旧用例只靠 `adopted` 反推**前一半** ⇒ 后半没覆盖 ⇒ **自动化会话跑本自测时**,
|
||
# 它**自己**的 automation run 必然还是 `IN_PROGRESS`、且它自己就是唯一在跑的会话
|
||
# ⇒ 前提不成立却没 SKIP ⇒ **必然假红**(2026-10-01 实测:整套只有本条红,
|
||
# 而 `reconcile` 返回 `adopted=[] zombie=[]`)。⚠️ 同一类假红的另一半,照既有姿势补 SKIP。
|
||
try:
|
||
_me = (os.environ.get("CODEBUDDY_SESSION_ID") or "")[:8]
|
||
_con = m._db()
|
||
_inprog = int(_con.execute("select count(*) from automation_runs "
|
||
"where status='IN_PROGRESS'").fetchone()[0])
|
||
_others = [s for (s,) in _con.execute("select id from sessions where status='working'")
|
||
if not (_me and str(s)[:8] == _me)]
|
||
_con.close()
|
||
except Exception:
|
||
_inprog, _others = 0, []
|
||
if _others or _inprog:
|
||
put_tasks({})
|
||
return [("SKIP:前提不成立(另有 %d 个会话在跑 · %d 个自动化运行 IN_PROGRESS)"
|
||
% (len(_others), _inprog), True)]
|
||
if r.get("adopted"):
|
||
put_tasks({})
|
||
return [("SKIP:当前确有 %d 个别的会话在跑 ⇒ 『除观察者外无人跑』前提不成立"
|
||
% len(r["adopted"]), True)]
|
||
v = m._load_tasks().get("Z1") or {}
|
||
ok = v.get("state") == "blocked" and "执行者已消失" in str(v.get("block_reason"))
|
||
put_tasks({})
|
||
return [("Z1 被标 blocked 且原因是『执行者已消失』", ok)]
|
||
|
||
|
||
@case("对账:幂等(连跑两次结论一致)")
|
||
def t_reconcile_idempotent():
|
||
# 🔴 2026-09-30 修(**同一类假红**):第 1 次会**收编**当前正在跑的别的工作会话(环境相关),
|
||
# 第 2 次才进入"没有新东西"的稳态 ⇒ 幂等性只能比较 **第 2 次 vs 第 3 次**。
|
||
run_cli(["--reconcile"])
|
||
_, o1 = run_cli(["--reconcile"])
|
||
_, o2 = run_cli(["--reconcile"])
|
||
put_tasks({})
|
||
return [("第 2 次 = 第 3 次输出一致", o1.strip().splitlines()[:1] == o2.strip().splitlines()[:1]),
|
||
("第 2 次已无新收编(『收编 0 条』)", "收编 0 条" in o1)]
|
||
|
||
|
||
@case("CLI:未知参数 ⇒ rc=2 且**不落常驻**")
|
||
def t_unknown_arg():
|
||
rc, out = run_cli(["--nonsense"], timeout=20)
|
||
return [("rc==2", rc == 2), ("提示未知参数", "未知参数" in out)]
|
||
|
||
|
||
@case("CLI:--where / --reqs 可跑且 rc=0")
|
||
def t_basic_cli():
|
||
rc1, o1 = run_cli(["--where"])
|
||
rc2, _ = run_cli(["--reqs"])
|
||
return [("--where rc==0 且打印路径", rc1 == 0 and "workspace" in o1), ("--reqs rc==0", rc2 == 0)]
|
||
|
||
|
||
@case("结构:关键名字/设施在位(防『误删常量』重演)")
|
||
def t_symbols():
|
||
src = (HERE / "collabd.py").read_text(encoding="utf-8")
|
||
need = ["CLAIMS", "SELF_SID", "STALE", "DOING_TTL", "def reconcile(", "def supervise(",
|
||
"def _deliver_str(", "def _guard_says_stop(", "def parse_session_name(",
|
||
"def topic_of(", "def load_goal(", "GOAL_F"]
|
||
miss = [n for n in need if n not in src]
|
||
return [("关键符号齐全(缺:%s)" % (miss or "无"), not miss)]
|
||
|
||
|
||
@case("结构:所有 subprocess 调用都带 creationflags(不显窗)")
|
||
def t_nowindow():
|
||
bad = []
|
||
for fn in ("collabd.py", "guard.py"):
|
||
for i, l in enumerate((HERE / fn).read_text(encoding="utf-8").splitlines(), 1):
|
||
if "subprocess.run(" in l or "subprocess.Popen(" in l:
|
||
seg = " ".join((HERE / fn).read_text(encoding="utf-8").splitlines()[i - 1:i + 4])
|
||
if "creationflags" not in seg:
|
||
bad.append("%s:%d" % (fn, i))
|
||
return [("无『没带 creationflags』的子进程调用(%s)" % (bad or "无"), not bad)]
|
||
|
||
|
||
@case("结构:fatal 不再 exit 0(防『假绿』)")
|
||
def t_failopen():
|
||
src = (HERE / "collabd.py").read_text(encoding="utf-8")
|
||
i = src.find('log("fatal %s" % e)')
|
||
seg = src[i:i + 200] if i >= 0 else ""
|
||
return [("fatal 分支是非零退出", "sys.exit(1)" in seg), ("⛔ 不是 exit(0)", "sys.exit(0)" not in seg)]
|
||
|
||
|
||
@case("结构:投递段**已真删**(⛔ 旧「投递方唯一」判据已于 2026-10-03 退役)")
|
||
def t_deliver_switch():
|
||
src = (HERE / "collabd.py").read_text(encoding="utf-8")
|
||
m = imp()
|
||
# 🔴🔴 2026-10-03:本用例原有一条判据「`--tick` 是唯一投递方(`supervise(st, deliver=True)`)」
|
||
# ——**投递退役后这个角色已不存在**,那条判据永久拿不到状态。
|
||
# ⚠️ ⛔ **不许把它改成 `supervise(st, deliver=False)`**(=换个说法说同一件事,仍是字面匹配脆断)。
|
||
# ✅ 退役 + 换可证伪判据:`supervise()` 体内**零个** `_deliver_str`/`follow_for_topic` **调用**
|
||
# (AST 判据 —— ⛔ 字面 grep 会被 docstring 里的退役说明永久假红,见 `_calls_in` 的注释)。
|
||
calls = _calls_in(src, "supervise")
|
||
r = m.supervise({}, deliver=True, mutate=True)
|
||
sk = str((r.get("deliver") or {}).get("skipped") or "")
|
||
return [("supervise 有 deliver 参数", re.search(r"def supervise\(st: dict, deliver: bool", src) is not None),
|
||
("supervise 有 mutate 参数(签名兼容保留,⛔ 体内已不读)",
|
||
re.search(r"def supervise\(st: dict, deliver: bool = True, mutate: bool = True\)", src) is not None),
|
||
("one_round 走**纯投影**(deliver=False, mutate=False)",
|
||
re.search(r"supervise\(st,\s*deliver=False,\s*mutate=False\)", src) is not None),
|
||
("🔴 2026-10-03 退役判据①:`supervise()` 体内**零个** `_deliver_str`/`follow_for_topic` **调用**"
|
||
"(AST;实测调用集 = %s)" % (sorted(calls) or "空",),
|
||
("_deliver_str" not in calls) and ("follow_for_topic" not in calls)),
|
||
("🔴 2026-10-03 退役判据②:真调用 `supervise(deliver=True, mutate=True)` ⇒ "
|
||
"`deliver.skipped` 必为 `retired-20261003`(实测 = %r)" % (sk,),
|
||
sk == "retired-20261003"),
|
||
("wake_round 已标注停用", "已停用" in src)]
|
||
|
||
|
||
@case("主会话:**解析出来的**(不只是读登记)+ ⛔ 拒绝盲投(防『投错窗口』回潮)")
|
||
def t_main_resolution():
|
||
"""用户 2026-09-30:「协作机制能发现 主会话 换了吗」——旧答案是**不能**(登记静态+路径依赖,
|
||
且看板与投递共用同一份登记 ⇒ 一起钉在旧 id 上)。本用例把修法钉住。"""
|
||
src = (HERE / "collabd.py").read_text(encoding="utf-8")
|
||
m = imp() # 🔴 2026-10-01:本用例起要**真跑函数**(见下面那条"能吃类别集合")
|
||
return [
|
||
("有 resolve_main(登记失效时按当前事实解析)", "def resolve_main(" in src),
|
||
("登记**有效才认**(`reg in live`)", "or reg in live" in src),
|
||
("按**工作区**解析(用户定则:**一个工作区一条主会话**)", "def _same_ws(" in src),
|
||
("工作区比较**同时**小写+统一斜杠(⛔ 防 `E:/x` 与 `E:\\x` 判成两个)",
|
||
'replace("\\\\", "/")' in src),
|
||
("🔴 按工作区解析时**排除「协作侧」命名**(2026-10-02 两类改造:唤醒/跟进已退役 ⇒ 只排 `worker`)"
|
||
"—— 接续会话是干活的棒,⛔ 不是主会话候选(否则它冒充主会话 ⇒ 通知投给它自己)",
|
||
'_role not in ("worker",)' in src),
|
||
("⛔ **不得按 `status='working'` 筛主会话**(主会话两轮之间是空闲 ⇒ 加了必然漏掉它)",
|
||
"status='working' \"\n \"order by last_activity_at" not in src),
|
||
("⛔ **不得按 `is_background_automation` 排主会话**(接续会话本身就是自动化起的 —— 实测主会话也是 1)",
|
||
"coalesce(is_background_automation, 0) = 0" not in src),
|
||
("🔴 **认「主控」前缀**(用户 2026-09-30 定名:主会话的显式标记)",
|
||
'startswith("主控")' in src and '"prefix:主控"' in src),
|
||
("解析不出 ⇒ 明确报 `no-main-session`", '"no-main-session"' in src),
|
||
("目标不在活会话里 ⇒ 明确报 `main-not-live`", '"main-not-live"' in src),
|
||
("🔴 **盲选回落已删除**(⛔ 不再随手投给任意一条会话)",
|
||
re.search(r'g = next\(\(x for x in gws if x\["sessionId"\]\), None\)', src) is None),
|
||
# ── 🔴 2026-09-30 新增:**同一工作区 · 多任务类别**(用户要求"同一个工作区 多会话协作")───
|
||
("🆕 有 `resolve_mains()`(按**任务类别**各解析一条主会话)", "def resolve_mains(" in src),
|
||
("🆕 有 `_scan_mains()`(与 `resolve_main` **共用同一遍扫描** ⇒ **同一份 live**,⛔ 不各扫各的=防两边漂移)",
|
||
"def _scan_mains(" in src and src.count("_scan_mains(st, live)") >= 2),
|
||
# 🔴 2026-10-01 体检修发现的**投递断点**:解析出一条**已经结束**的「主控」会话当投递目标
|
||
# ⇒ 投递一路 `main-not-live`(程序在跑,通知永远发不出去)。⇒ **候选只认活着的**。
|
||
("🆕 候选**只认活着的**:`_pick_live` 在位且两处解析都用它",
|
||
"def _pick_live(" in src and src.count("_pick_live(named, live)") >= 2
|
||
and src.count("_pick_live(cand, live)") >= 2),
|
||
# 🔴🔴 2026-10-01 **两条旧断言被本轮口径推翻 ⇒ 改判据**(⛔ 不是删掉 —— 性质还得有人守):
|
||
# 用户逐字:「**换新会话 唤醒的是 跟进会话,主会话只能是用户触发**」
|
||
# ⇒ `_deliver_str` 的投递目标**从"该类别的主会话"改成"该类别的跟进会话"**
|
||
# (`_rms = resolve_mains(st, …)` + `main_for_topic(_rms, topic)` **故意**从源码里消失了)。
|
||
# ⚠️ 那两条旧断言查的正是这两个字面量 ⇒ 留着就是**恒红**(不是代码坏了,是**判据过时**)。
|
||
# ⚠️ 但它守的**性质**("按类别选、⛔ 不投错窗口")必须继续守 ⇒ 换成查新函数,并补两条。
|
||
("🆕 投递目标经 `follow_for_topic()` **按类别**取(⛔ 不再是主会话、也不再是「唯一那一条」)",
|
||
re.search(r"_rft = follow_for_topic\(st, topic,", src) is not None),
|
||
("🆕 `follow_for_topic` 对「已登记类别」**严格取该类别**(⛔ 不回落别类别=防投错窗口)",
|
||
re.search(r"if tp and tp in tps:", src) is not None),
|
||
("🔴 `follow_for_topic` **绝不返回主会话**(用户口径:主会话只由用户触发)"
|
||
"—— 2026-10-02 两类改造后**真调用**恒返回 `sid=\"\"`(短路退役)⇒ 更不可能返回主会话",
|
||
(not str(imp().follow_for_topic({}, "X", []).get("sid") or ""))),
|
||
("🔴 解析不出跟进会话 ⇒ 记 `no-follow-session` + **喊用户**(⛔ 不再降级投主会话)",
|
||
'"no-follow-session"' in src and 'follow-not-live' in src
|
||
and '"no-main-session"' not in src.split("def _deliver_str(")[1].split("\ndef ")[0]),
|
||
("🆕 `main_for_topic` 对「已登记类别」**严格取该类别**(⛔ 不回落别类别=防投错窗口)",
|
||
'if tp and tp in (rms.get("topics") or [])' in src),
|
||
("🆕 `_deliver_str` 支持 `topic=` 参数(默认空 ⇒ 走 default,向后兼容)",
|
||
re.search(r'def _deliver_str\(text: str, key: str, st: dict, topic: str = ""\)', src) is not None),
|
||
# 🔴🔴 2026-10-03 退役:本条原判据 `'topic=str((v or {}).get("line") or "")' in src`
|
||
# 指的是 `supervise()` 里那行 `_deliver_str(..., topic=…)`——**投递段已真删 ⇒ 该行不存在**。
|
||
# ⚠️ ⛔ 不许改成 `topic="" in src` 之类(=断言恒真,P0-13 同族)。
|
||
# ✅ 退役 + 换可证伪判据:`_deliver_str()` 本体**仍支持 `topic=` 参数**
|
||
# (它是"将来重建收件人"的实现,签名不许退化)⇒ 改成断言**函数签名里有 `topic: str = ""`**,
|
||
# 那是真判据(谁把参数删了就会红),且**不是**靠一个字面串匹配调用点。
|
||
("🆕 `supervise()` 曾按台账条目的 `line` 传 `topic=`(⛔ 2026-10-03 随投递退役)",
|
||
"topic=str((v or {}).get(\"line\") or \"\")" not in src),
|
||
("🆕 退役后仍保留:`_deliver_str()` 本体**签名仍带 `topic=`**(重建收件人时的实现)",
|
||
re.search(r'def _deliver_str\(text: str, key: str, st: dict, topic: str = ""\)', src) is not None),
|
||
# 🔴 2026-10-01 改:原来这条是**纯源码匹配** `"for _t in _as_list(short):" in src` ——
|
||
# 我今天给 `_in_project` 加了第三条判据、顺手把这一行提成 `_tps = _as_list(short)`,
|
||
# 行为**一模一样**,它却报红了(**脆断**:对着源码字面写断言,一重构就假红)。
|
||
# ⇒ 改成**行为断言**(真喂一个 list 进去看结果),源码那半只留"函数还在"。
|
||
("🆕 归属判据能吃**类别集合**(`_in_project` 的 main_sid / short 传 list 都对)",
|
||
"def _as_list(" in src
|
||
and m._in_project("", "[协作]-[B类]-x", ["M1"], ["A类", "B类"])
|
||
and m._in_project("M1", "随便什么标题", ["M1", "M2"], ["A类"])),
|
||
("🆕 收尾确认**排除全部主会话**(⛔ 否则别类别的主会话被算成「在跑」⇒ 永远拦住)",
|
||
'mains = list(_rms0.get("all_sids") or [])' in src and "if sid in mains:" in src),
|
||
("🆕 两处 `_goal_topics()` 同款(技能里 goal.topics 是**唯一**类别源,缺省回落 `short`)",
|
||
src.count("def _goal_topics(") == 1 and '"topics"' in src),
|
||
("换了目标 ⇒ 跟随 + 显式告警(⛔ 不静默)",
|
||
"def _follow_main(" in src and "主会话变更:" in src and "need_user(" in src),
|
||
]
|
||
|
||
|
||
@case("主会话:看板与投递**同一套判据**(⛔ 防两处漂移 —— 漂了就没人能发现换主会话)")
|
||
def t_main_same_rule():
|
||
a = (HERE / "collabd.py").read_text(encoding="utf-8")
|
||
b = (HERE / "board.py").read_text(encoding="utf-8")
|
||
pat = r'def _main_sid\(st: dict\) -> str:'
|
||
return [
|
||
("两处都定义了 _main_sid", re.search(pat, a) is not None and re.search(pat, b) is not None),
|
||
("两处都按 roles -> wake 的顺序取(逐字同款)",
|
||
'st.get("roles")' in a and 'st.get("roles")' in b
|
||
and 'st.get("wake")' in a and 'st.get("wake")' in b),
|
||
]
|
||
|
||
|
||
@case("主会话:**退役角色不许当候选** —— 行为级(真造宿主库 + 真跑扫描,⛔ 不只测那个小函数)")
|
||
def t_main_retired_not_candidate():
|
||
"""🔴🔴 2026-10-04 21:5x 新增(用户拍板「候选一」)。
|
||
|
||
**为什么必须是行为级、⛔ 不能只测 `is_retired_role_title()` 本身**(**变异验证当场抓到**):
|
||
我第一版只加了"这个函数对这几个样本返回什么"的断言 ⇒ 植入变异
|
||
**把闸从 `_scan_mains()` 的筛子里整段拆掉** ⇒ 那些断言**照样全绿**(`grep MUTANT` = 1
|
||
证明变异真植进去了,`PASS 87 / FAIL 0` 却是假的)—— 因为**没有任何一条测到"闸被用上了"**。
|
||
⇒ 正解=造一个**真宿主库**,里面放一条 `[跟进]-…` 与一条真主会话,
|
||
**真跑一遍扫描**,看结果里有没有那条退役角色。
|
||
⚠️ 这正是"判定标准恒绿"的典型形态:测了零件、没测装配。
|
||
|
||
**被测对象**:`collabd.py::_scan_mains()` —— 它是要**扫库**的那一侧(board 侧同款判据另有对账)。
|
||
"""
|
||
m = imp()
|
||
import sqlite3 as _sq
|
||
import tempfile as _tf
|
||
import shutil as _sh
|
||
td = _tf.mkdtemp(prefix="selftest-retired-")
|
||
ws = Path(td) / "wsX"
|
||
(ws / "tmp" / "supervise-inbox").mkdir(parents=True, exist_ok=True)
|
||
(ws / "tmp" / "supervise-inbox" / "goal.json").write_text(
|
||
json.dumps({"id": "wsX", "title": "X 区目标", "short": "wsX"}, ensure_ascii=False),
|
||
encoding="utf-8")
|
||
db = Path(td) / "fake-host.db"
|
||
now = int(time.time() * 1000)
|
||
try:
|
||
cn = _sq.connect(str(db))
|
||
cn.execute("create table sessions (id text primary key, title text, custom_title text, "
|
||
"status text, cwd text, updated_at integer, last_activity_at integer, "
|
||
"deleted_at integer)")
|
||
# ① 退役角色(`[跟进]-…`)—— 改前会被收进候选(实测 `6ab1463e` 就是这个形状)
|
||
# ② 现役真主会话(无前缀)—— ⛔ 不许被误排
|
||
# ③ 正牌 worker —— 旧口径就该排它(对照组)
|
||
# 🔴🔴 `custom_title` 必须写 **`None`**,⛔ 不许写 `""`(**夹具说谎的第二个坑,实测踩到**):
|
||
# 查询是 `coalesce(custom_title, title, '')` ⇒ 写 `""` 时 `coalesce` **返回 `""`**
|
||
# (空串不是 NULL,不会回落到 `title`)⇒ 每条会话拿到的标题都是空串
|
||
# ⇒ 退役闸/角色判据**全都测不出东西**、用例恒绿而**毫无意义**。
|
||
# ✅ 真库里 `custom_title` 实测就是 **`None`**(`57f58ecf` 等逐条验过)⇒ 夹具照抄真库形态。
|
||
# ⚠️ 这与 P0-53 是同一个形状:**合成区必须让被测对象读到真实判据**,
|
||
# ⛔ 不许靠"反正断言过了"糊过去(这个坑正是我自己写夹具时踩出来的)。
|
||
_cwd = str(ws).replace("\\", "/")
|
||
for i, t in enumerate(("[跟进]-机制排查与修复-队列跟进",
|
||
"复盘失败并避免重犯",
|
||
"[协作]-[机制排查与修复]-S12 收尾")):
|
||
cn.execute("insert into sessions values (?,?,?,?,?,?,?,?)",
|
||
("sid-%d" % i, t, None, "completed", _cwd,
|
||
now - i * 1000, now - i * 1000, None))
|
||
cn.commit()
|
||
cn.close()
|
||
# 🔴 让 `_scan_mains()` 扫到这个库 + 认这个工作区。两条都要对准(读源码得的键名):
|
||
# · `collabd.py::_db()` 读 `C["host_db"]`(配置)或 `CODEBUDDY_CONFIG_DIR/workbuddy.db`
|
||
# ⇒ ⛔ 没有 `DSHS_DB_URL` 这条路(我第一版按它写 ⇒ 夹具指不到库)。
|
||
# · `collabd.py::_same_ws()` 比的是**模块级 `WS`**(加载时定的)⇒ 也必须在加载时给对。
|
||
# 两者都靠**改配置再重载模块**拿(⛔ 不是改 env;`WS` 不是 env 读的)。
|
||
_cfg = Path(td) / "collabd.testconfig.json"
|
||
_cfg.write_text(json.dumps({"host_db": str(db).replace("\\", "/"),
|
||
"workspace": str(ws).replace("\\", "/")},
|
||
ensure_ascii=False), encoding="utf-8")
|
||
_old_cfg = os.environ.get("COLLABD_CONFIG")
|
||
_old_wsv = os.environ.get("COLLABD_WORKSPACE")
|
||
os.environ["COLLABD_CONFIG"] = str(_cfg)
|
||
os.environ["COLLABD_WORKSPACE"] = str(ws).replace("\\", "/")
|
||
try:
|
||
_u = __import__("importlib.util", fromlist=["util"])
|
||
_sp = _u.spec_from_file_location("_cb_retired", HERE / "collabd.py")
|
||
_mc = _u.module_from_spec(_sp)
|
||
_sp.loader.exec_module(_mc) # ⚠️ 必须**重新加载**:`WS` 是模块级常量
|
||
sc = _mc._scan_mains({})
|
||
finally:
|
||
for _k, _v in (("COLLABD_CONFIG", _old_cfg), ("COLLABD_WORKSPACE", _old_wsv)):
|
||
if _v is None:
|
||
os.environ.pop(_k, None)
|
||
else:
|
||
os.environ[_k] = _v
|
||
cand = [str(s) for s in (sc.get("cand") or [])]
|
||
by_topic = sc.get("by_topic") or {}
|
||
err = str(sc.get("err") or "")
|
||
except Exception as _e: # pragma: no cover
|
||
return [("用例未跑起来:%s" % _e, False)]
|
||
finally:
|
||
_sh.rmtree(td, ignore_errors=True)
|
||
return [
|
||
("🔴 夹具扫到库了(⛔ 空扫描 ⇒ 下面几条会恒绿 —— 那正是『测了零件没测装配』)",
|
||
not err),
|
||
("🔴 **退役角色 `[跟进]-…` ⛔ 不进候选**(sid-0;改前会进 ⇒ 看板「主会话」位上坐着它)",
|
||
"sid-0" not in cand),
|
||
("(对照组)**真主会话 ⛔ 不许被误排**(sid-1;它是无前缀的,排它=把真主会话弄丢)",
|
||
"sid-1" in cand),
|
||
("(对照组)正牌 worker `[协作]-…` ⛔ 不进候选(旧口径,⛔ 不许被本次改动破坏)",
|
||
"sid-2" not in cand),
|
||
("🔴 退役角色 ⛔ 不进 `by_topic`(改前的真实病症:`main_by_topic` 两个类别**全指向 "
|
||
"`[跟进]` 会话**)",
|
||
all(str((v or {}).get("sid") or "") != "sid-0" for v in by_topic.values())),
|
||
]
|
||
|
||
|
||
@case("主会话:登记为 main 但**已哑** ⇒ ⛔ 不认它 + `--declare main` 是**换**不是「加」")
|
||
def t_main_deaf_and_declare():
|
||
"""🔴🔴 2026-10-01 修 **V2「主会话可响应」的硬阻塞**(两个缺陷叠在一起,缺一条都治不好)。
|
||
|
||
**缺陷①**:`resolve_main` 的第①条快路原为 `if reg and (not live or reg in live)` ——
|
||
**只查"活着",⛔ 不查"哑"**。而"哑"(会话诊断日志撞 ~10 MiB ⇒ 宿主拒写 ⇒
|
||
**界面永不刷新**)**照样在 `live` 里** ⇒ 一条哑掉的主会话来一次认一次,一路投到 `target-deaf`。
|
||
⚠️ 而 ② 走的 `_pick_live()` **本来就排哑**(2026-10-01 专为"别再投给哑会话"加的)——
|
||
**① 却从没接上它** ⇒ 同一条哑会话「② 排得掉、① 排不掉」(判据打架,当日第三次同族)。
|
||
**缺陷②**:`--declare --role main` 是**加**不是**换**(P0-11 的正解①就是它,号称"零代码改动立刻可用")
|
||
—— 而 `_main_sid()` 取 `roles` 里**第一条** `main`,Python dict 对已存在的键赋值**不改位置**
|
||
⇒ 老那条**永远排前面** ⇒ 新登记的主会话**永远轮不到** ⇒ 逃生口形同虚设。
|
||
🔴 而且它掩盖得更深:`_deliver_str` 的**粗判不传活会话**(`resolve_mains(st, [])`)
|
||
⇒ `_pick_live` 在 `live` 为空时**回退 `c[0]`** ⇒ 每轮先认下那条哑的、**直接返回**,
|
||
**走不到后面"拿锁后用活网关列表重算"那一步**。
|
||
"""
|
||
m = imp()
|
||
A = "aaaa1111-0000-0000-0000-000000000001"
|
||
B = "bbbb2222-0000-0000-0000-000000000002"
|
||
|
||
# ── 缺陷①:把 A 伪造成"哑",A 登记在 B 前面 ──
|
||
_orig = m._deaf_sids
|
||
try:
|
||
m._deaf_sids = lambda: {A}
|
||
r_live = m.resolve_main({"roles": {A: "main", B: "main"}}, [A, B])
|
||
# 粗判那条路(不传活会话)⇒ 只能拿第一条 —— 这正是"必须让登记权威化"的理由
|
||
r_coarse = m.resolve_main({"roles": {A: "main", B: "main"}}, [])
|
||
finally:
|
||
m._deaf_sids = _orig
|
||
# ── 缺陷②:`--declare --role main` 必须把别的 main 降级 ──
|
||
# 在**测试区**里造一份 state(⛔ 不碰生产),用完还原
|
||
SP = TIN / "collabd-state.json"
|
||
bak = SP.read_text(encoding="utf-8") if SP.exists() else None
|
||
SP.write_text(json.dumps({"roles": {A: "main", B: "worker"}}, ensure_ascii=False), encoding="utf-8")
|
||
_env = _mk_env()
|
||
_env["CODEBUDDY_SESSION_ID"] = B
|
||
p = subprocess.run([PY, "-u", str(HERE / "collabd.py"), "--declare", "--role", "main"],
|
||
cwd=str(HERE), stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||
text=True, env=_env, timeout=60, errors="replace")
|
||
_st = json.loads(SP.read_text(encoding="utf-8"))
|
||
_out = p.stdout or ""
|
||
if bak is None:
|
||
SP.unlink(missing_ok=True)
|
||
else:
|
||
SP.write_text(bak, encoding="utf-8")
|
||
_roles = _st.get("roles") or {}
|
||
S = (HERE / "collabd.py").read_text(encoding="utf-8")
|
||
return [
|
||
("🔴 ① 排哑后才认下**活着的**那条登记(拿到 %s)" % (r_live["sid"][:8] or "(空)"),
|
||
r_live["sid"] == B and r_live["source"] == "roles:main"),
|
||
("🔴 ① 换了登记的那条 ⇒ **报 `switched_from`**(调用方才会降级+告警,⛔ 不静默换)",
|
||
r_live["switched_from"] == A),
|
||
("🔴 ① 走的是**同一个** `_pick_live`(⛔ 不再有两台判据)",
|
||
"_reg = _pick_live(_rm, live)" in S),
|
||
("⚠️ 粗判(**不传活会话**)退回第一条 ⇒ 这就是「必须让登记权威化」的理由(坑已固化)",
|
||
r_coarse["sid"] == A),
|
||
("🔴 ② `--declare --role main` 把原 main **降级为 worker**(换,⛔ 不是加)",
|
||
_roles.get(B) == "main" and _roles.get(A) == "worker"),
|
||
("🔴 ② 且**说出口**(⛔ 不静默改别人的角色)", "已降级为 worker" in _out),
|
||
]
|
||
|
||
|
||
@case("结构:守护生命周期(优雅退出设施在位)")
|
||
def t_graceful():
|
||
src = (HERE / "guard.py").read_text(encoding="utf-8")
|
||
return [("有 _shutdown(优雅收尾)", "def _shutdown(" in src),
|
||
("子进程打 DSH_GUARDED 标", "DSH_GUARDED" in src),
|
||
("子程序侧有 _guard_says_stop", "_guard_says_stop" in (HERE / "collabd.py").read_text(encoding="utf-8"))]
|
||
|
||
|
||
@case("归属判据:看板 ≡ 收尾确认(⛔ 防两处漂移;⛔ 不按 cwd 推断)")
|
||
def t_scope_parity():
|
||
"""🔴 用户 2026-09-30 两条明示:「**要明确哪些会话是属于某个需求目标项目的**」+
|
||
「**这个看板上应该明确显示是哪个需求目标项目**」。
|
||
|
||
`collabd._in_project()`(`--ready-next` 用)与 `board.in_project()`(看板用)是**重复实现**
|
||
⇒ 一旦漂移就会出这种怪事:**看板说"本项目没人在跑",而 `--ready-next` 被别的项目的会话拦死**。
|
||
本用例把两者摆到同一张用例表上逐例比对(含"AI变现日报"这类**别人家的会话**必须判否)。
|
||
"""
|
||
code = r'''
|
||
import importlib.util, inspect, json, sys
|
||
sys.dont_write_bytecode = True # ⛔ 别让测试自己往技能目录里落 __pycache__
|
||
def load(name, path):
|
||
spec = importlib.util.spec_from_file_location(name, path)
|
||
m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m); return m
|
||
H = sys.argv[1]
|
||
cb = load("cb", H + "/collabd.py")
|
||
bd = load("bd", H + "/board.py")
|
||
cases = [
|
||
("main1", "任意标题(主会话)", "main1", "手机接入", True),
|
||
("aaa", "[协作]-[手机接入]-N10 复测", "main1", "手机接入", True),
|
||
("bbb", "AI变现日报 · 今日生成", "main1", "手机接入", False),
|
||
("ccc", "随便一个标题", "main1", "手机接入", False),
|
||
("ddd", "[其他主题]-x", "main1", "手机接入", False),
|
||
("eee", "x", "main1", "", False), # short 空 ⇒ 只剩主会话能命中
|
||
("ggg", "x", "ggg", "", True), # 主会话判据 ⛔ 不依赖 short
|
||
("hhh", "[其他主题][手机接入]", "main1", "手机接入", True),
|
||
("fff", "[手机接入] 加个 [手机接入x] 干扰", "main1", "手机接入", True),
|
||
]
|
||
bad = []
|
||
for sid, ti, msid, short, exp in cases:
|
||
a = bool(cb._in_project(sid, ti, msid, short))
|
||
b = bool(bd.in_project(sid, ti, {"main_sid": msid, "short": short}))
|
||
if a != b:
|
||
bad.append("%s 判据漂移(collabd=%s / board=%s)" % (sid, a, b))
|
||
if a != exp:
|
||
bad.append("%s 实得 %s / 期望 %s" % (sid, a, exp))
|
||
# 🆕 2026-09-30:**同一个工作区 · 多任务类别**(用户要求「通过协作会话名称前缀区分具体执行会话」)——
|
||
# 两处实现都必须认"**任一**已登记类别",且⛔ **不得**把未登记的类别算进来。
|
||
mt = [
|
||
# sid, 标题, 默认主会话, 类别清单, 期望
|
||
("m1", "主控 · 机制线", "m1", ["手机接入", "机制线"], True), # 默认主会话
|
||
("w1", "[协作]-[机制线]-唤醒A", "m1", ["手机接入", "机制线"], True), # 第 2 类别
|
||
("w2", "[协作]-[手机接入]-N9", "m1", ["手机接入", "机制线"], True), # 第 1 类别
|
||
("w3", "[协作]-[别的类别]-x", "m1", ["手机接入", "机制线"], False), # 未登记 ⇒ 不算
|
||
("w4", "唤醒机制线专项(无方括号)", "m1", ["手机接入", "机制线"], False), # 归属判据要求 `[类别]`
|
||
("w5", "[协作]-[机制线]-x", "m1", ["手机接入"], False), # 只登记一类 ⇒ 另一类不算
|
||
("w6", "x", "m1", [], False), # 无类别 ⇒ 只剩主会话判据
|
||
("w7", "x", "w7", ["手机接入", "机制线"], True), # 主会话判据与类别无关
|
||
]
|
||
for sid, ti, msid, tps, exp in mt:
|
||
a = bool(cb._in_project(sid, ti, msid, tps))
|
||
b = bool(bd.in_project(sid, ti, {"main_sid": msid, "main_sids": [msid] if msid else [],
|
||
"topics": tps, "short": (tps[0] if tps else "")}))
|
||
if a != b:
|
||
bad.append("%s 多类别判据漂移(collabd=%s / board=%s)" % (sid, a, b))
|
||
if a != exp:
|
||
bad.append("%s 多类别实得 %s / 期望 %s" % (sid, a, exp))
|
||
# 🆕 结构对齐:两处 `_scan_*` 的 `by_topic` 值**必须是 dict**({sid,source,explicit})——
|
||
# ⛔ 实测踩到:board 曾存裸 sid ⇒ 调用方把整个 dict 当 sid 塞进 `all_sids`(静默错值)。
|
||
_cbs = inspect.getsource(cb)
|
||
_bds = inspect.getsource(bd)
|
||
for _nm, _s in (("collabd", _cbs), ("board", _bds)):
|
||
if '"explicit"' not in _s or '"by_topic"' not in _s:
|
||
bad.append("%s 的 by_topic 结构不合规(须存 {sid,source,explicit})" % _nm)
|
||
sig = str(inspect.signature(bd.in_project))
|
||
if "cwd" in sig:
|
||
bad.append("board.in_project 仍带 cwd 参数:%s" % sig)
|
||
src = open(H + "/board.py", encoding="utf-8").read()
|
||
if "project_scopes" in src:
|
||
bad.append("board.py 仍残留 cwd 版的 project_scopes()")
|
||
print(json.dumps({"bad": bad, "sig": sig}, ensure_ascii=False))
|
||
'''
|
||
p = subprocess.run([PY, "-c", code, str(HERE)], stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||
text=True, env=_mk_env(), timeout=30, errors="replace")
|
||
try:
|
||
r = json.loads((p.stdout or "").strip().splitlines()[-1])
|
||
except Exception:
|
||
return [("子进程跑不起来(rc=%s):%s" % (p.returncode, (p.stdout or "")[-300:]), False)]
|
||
return [("15 例判据一致且与期望相符(7 基础 + 8 多类别;%s)" % ("、".join(r["bad"]) or "全对"), not r["bad"]),
|
||
("board.in_project 不带 cwd 参数(%s)" % r["sig"], "cwd" not in r["sig"])]
|
||
|
||
|
||
@case("看板:风格系统(三风格令牌在位 + 组件里零硬编码色)")
|
||
def t_board_styles():
|
||
"""🔴 用户 2026-09-30:「画架构图参考 archify 的样式,**右上角加个风格切换(保留当前风格)**」。
|
||
|
||
两件必须守住:
|
||
① **`base`(保留的当前风格)必须在**,且**跟随系统深浅色** —— ⛔ 删了就等于改了用户的原风格。
|
||
② **组件里不许出现硬编码颜色** —— 颜色只许出现在令牌块(`--color-*` 等)里。
|
||
⚠️ 一旦有人在 `.card{}`/SVG 里写死 `#fff`,换风格就会**花**(这是最容易犯的)。
|
||
"""
|
||
p = HERE.parent / "assets" / "board.html"
|
||
src = p.read_text(encoding="utf-8")
|
||
need = ['[data-style="archify-dark"]', '[data-style="archify-light"]',
|
||
':root:not([data-style]), :root[data-style="base"]', # base 跟随系统深色
|
||
'id="stylesw"', 'function applyStyle(', "dsh-board-style"] # 切换件 + 持久化
|
||
misc = [n for n in need if n not in src]
|
||
bad = []
|
||
for i, l in enumerate(src.splitlines(), 1):
|
||
s = l.strip()
|
||
if s.startswith(("--color-", "--font", "--dot", "--node", "--radius", "--shadow")):
|
||
continue # 令牌块本身当然有颜色值
|
||
if re.search(r"#[0-9a-fA-F]{3,8}\b", l) or re.search(r"rgba?\(\s*\d", l):
|
||
bad.append("board.html:%d" % i)
|
||
return [("三风格令牌 + 切换件 + 持久化齐全(缺:%s)" % (misc or "无"), not misc),
|
||
("组件里零硬编码颜色(越界:%s)" % (bad[:4] or "无"), not bad)]
|
||
|
||
|
||
@case("看板:验收摘要行 ⛔ 不许把『一条判据都没有』说成『无』(同族红线)")
|
||
def t_board_acc_summary():
|
||
"""🔴 2026-09-30 跑整体验证时抓到:`board.py` 的命令行摘要写的是
|
||
`[k for k,v in acc.items() if v!='pass'] or "无"` —— `acc` 已滤掉 `_` 说明行,
|
||
所以**一条有效判据都没有**时列表为空 ⇒ 打印「验收非 pass:**无**」,
|
||
读起来就是「全部 pass」。**形状同族**:不崩溃,只是少说一句话。
|
||
⇒ 统一到 `_acc_summary()`:空 ⇒ 显式「判不出来,⛔ 不因此判完成」。
|
||
"""
|
||
os.environ["COLLABD_CONFIG"] = str(CFG)
|
||
os.environ["DSH_COLLAB_WS"] = str(TEST_WS)
|
||
import importlib.util
|
||
spec = importlib.util.spec_from_file_location("bd_selftest", HERE / "board.py")
|
||
b = importlib.util.module_from_spec(spec)
|
||
spec.loader.exec_module(b)
|
||
s_empty = b._acc_summary({})
|
||
s_pass = b._acc_summary({"V1": "pass", "V2": "pass"})
|
||
s_open = b._acc_summary({"V1": "pass", "V2": "fail"})
|
||
return [("空 ⇒ 显式『判不出来』(%s)" % s_empty[:26], "判不出来" in s_empty and "不因此判完成" in s_empty),
|
||
("⛔ 空时不得只说『无』(%s)" % s_empty[:12], s_empty.strip() != "无"),
|
||
("全 pass ⇒ 说清条数(%s)" % s_pass, "pass" in s_pass and "2" in s_pass),
|
||
("有未过 ⇒ 点名(%s)" % s_open, "V2" in s_open)]
|
||
|
||
|
||
@case("看板:『未归类』不许被静默丢掉(同族红线:读到了就要说)")
|
||
def t_board_orphan_visible():
|
||
"""🔴 2026-09-30 跑整体验证时在**真实工作区**抓到:类别清单已迁到「任务类别」,
|
||
而台账里还躺着**跨工作区时代的旧线名**(各 2 件且都已完成)。
|
||
|
||
旧版:这些行也当"分工位"进 `labor`,架构图又只画前 4 格 ⇒ **那几格连同里面的件整块消失**
|
||
(读者只看到 4 个"件 0 · 完成 0")。**形状=不崩溃,只是少说一句话** —— 最难发现的那类。
|
||
|
||
现在:`kind='legacy'` ⇒ **不算类别**(⛔ 不让工作区名冒充类别),但**折叠成一格画出来**
|
||
+ **图外点名线名与件数** ⇒ 两头都堵住。
|
||
"""
|
||
os.environ["COLLABD_CONFIG"] = str(CFG)
|
||
os.environ["DSH_COLLAB_WS"] = str(TEST_WS)
|
||
import importlib.util
|
||
spec = importlib.util.spec_from_file_location("bd_orphan", HERE / "board.py")
|
||
b = importlib.util.module_from_spec(spec)
|
||
spec.loader.exec_module(b)
|
||
|
||
# ① `_orphan` 只收 legacy 行,且件数**求和**(⛔ 不是只数条数)
|
||
o0 = b._orphan([{"line": "唤醒机制", "kind": "topic", "total": 3, "done": 3}])
|
||
o1 = b._orphan([{"line": "唤醒机制", "kind": "topic", "total": 3, "done": 3},
|
||
{"line": "old-ws", "kind": "legacy", "total": 2, "done": 2, "open": 0},
|
||
{"line": "old-ws2", "kind": "legacy", "total": 1, "done": 0, "open": 1}])
|
||
# ② 结构:看板真的会把它画出来 + 真的会点名(⛔ 只改数据不改渲染 = 没修)
|
||
H = (HERE.parent / "assets" / "board.html").read_text(encoding="utf-8")
|
||
need = ["kind==='legacy'", "未归类", "不属任何当前任务类别",
|
||
"不在当前任务类别清单里", # 图外提示要点名
|
||
"旧值 · 不在当前任务类别清单"] # 台账表要标注
|
||
miss = [n for n in need if n not in H]
|
||
return [("全 topic ⇒ 无未归类(n=%d)" % o0["n"], o0["n"] == 0),
|
||
("只收 legacy 行(n=%d)" % o1["n"], o1["n"] == 2),
|
||
("件数求和(总 %d / 完成 %d / 未完 %d)" % (o1["total"], o1["done"], o1["open"]),
|
||
(o1["total"], o1["done"], o1["open"]) == (3, 2, 1)),
|
||
("看板渲染里四处齐全(缺:%s)" % (miss or "无"), not miss)]
|
||
|
||
|
||
@case("看板:**同工作区但没归入本项目**的会话不许静默丢(⛔ 也不许把它算进本项目)")
|
||
def t_board_unrecognized_visible():
|
||
"""🔴 2026-10-01 用户报:「**为什么创建了协作会话 但是 看板中没有展示**」。
|
||
|
||
真事:M5 那根棒被命名成 `[协作]-本机协作-M5 …` —— **第 2 级没带方括号**、而且用的是
|
||
`goal.short`(本机协作)而非**任务类别**(`topics`=唤醒机制)。
|
||
⇒ `parse_session_name()` 判 `ok=False`、`_in_project()` 也找不到 `[唤醒机制]`
|
||
⇒ 它**整条从看板上消失**(不进任务会话层、也不算"本项目会话")。
|
||
**形状=不崩溃,只是少说一句话** —— 正是最难发现的那类(同族红线)。
|
||
|
||
⇒ 两头都要堵:
|
||
① **说出口**:`_sessions()` 交出 `unrecognized`,看板在图外说明里点名;
|
||
② **⛔ 不许改归属**:它们**不进 `mine`**、**不算「本项目会话共 N 个」** ——
|
||
归属判据仍是 §2.3 三级(主会话 sid / 标题含 `[<类别>]` / `--declare`),
|
||
⚠️ `cwd` **只用来决定"要不要提醒"**,⛔ 绝不用来决定"归不归本项目"。
|
||
"""
|
||
os.environ["COLLABD_CONFIG"] = str(CFG)
|
||
os.environ["DSH_COLLAB_WS"] = str(TEST_WS)
|
||
import importlib.util
|
||
spec = importlib.util.spec_from_file_location("bd_unrec", HERE / "board.py")
|
||
b = importlib.util.module_from_spec(spec)
|
||
spec.loader.exec_module(b)
|
||
H = (HERE.parent / "assets" / "board.html").read_text(encoding="utf-8")
|
||
S = (HERE / "board.py").read_text(encoding="utf-8")
|
||
need = [ # 看板真会画(⛔ 只算不画 = 没修)
|
||
"sessions_unrecognized", "未归入本项目", "会被漏管",
|
||
"值取 `goal.json` 的 `topics`(任务类别),⛔ 不是 `short` 简称",
|
||
]
|
||
miss = [n for n in need if n not in H]
|
||
# ① 结构:快照真带这个键(连同"读不到库"的降级分支也带 ⇒ ⛔ 别只在成功路上加)
|
||
has_key = S.count('"sessions_unrecognized"') >= 1 and S.count('"unrecognized": unrec') >= 1
|
||
# ② ⛔ 它**不许**混进 `mine`:cwd 只出现在告警分支里,且该分支显式 continue
|
||
guarded = "_ct == _wstail" in S and "⛔ 绝不用它决定" in S
|
||
# ③ 命名规范那句话必须说对(⛔ 别再说"主题取自 short" —— 那正是本 bug 的源头)
|
||
C = (HERE / "collabd.py").read_text(encoding="utf-8")
|
||
spec_ok = ("`[协作]-[<类别>]-<具体>`" in C) and ("主题取自 `goal.json` 的 `short`" not in C)
|
||
return [("快照真带 `sessions_unrecognized`(含降级分支)", has_key),
|
||
("cwd 只用于告警、⛔ 不用于定归属(判据未松)", guarded),
|
||
("看板渲染里三处齐全(缺:%s)" % (miss or "无"), not miss),
|
||
("派活命名规范已纠偏(⛔ 不再说\"主题取自 short\")", spec_ok)]
|
||
|
||
|
||
@case("归属:两处 `in_project` 判据**逐样本对账**(⛔ 漂了就静默漏管)")
|
||
def t_in_project_parity():
|
||
"""🔴🔴 2026-10-01 用户报:「**看板没有正常显示协作会话**」。
|
||
|
||
真因(**扫出来的,不是推理出来的**):两处归属判据原来**都只认带方括号**的写法
|
||
(`"[%s]" % 类别 in 标题`),而**实际派活出来的棒名是** `[协作]-机制排查与修复-常驻投递容器`
|
||
—— **第 2 级没带方括号** ⇒ 判 `False` ⇒ 那条会话(**当时正在 `working`**)落到
|
||
`sessions_unrecognized` ⇒ `board.html` 第三层画**空框「暂无协作会话」**。
|
||
⚠️ 更糟的是它和 `_topic_in_title()` **自相矛盾** —— 后者的注释白纸黑字写着
|
||
「判据是**子串**(不要求 `[...]` 包裹)…**两种写法都要认**」⇒ **同一个标题两处结论相反**。
|
||
|
||
⇒ 本用例把**两台判据喂同一批标题、逐条比布尔**(⛔ 不靠人盯、⛔ 不只查源码字符串),
|
||
并同时验**精度没丢**:正文里恰好提到类别名的**无关会话**仍不许被收进来
|
||
(靠"角色前缀闸"——`_role_of_title()` 解析不出角色的标题一律不算)。
|
||
"""
|
||
os.environ["COLLABD_CONFIG"] = str(CFG)
|
||
os.environ["DSH_COLLAB_WS"] = str(TEST_WS)
|
||
import importlib.util
|
||
spec = importlib.util.spec_from_file_location("bd_inproj", HERE / "board.py")
|
||
bd = importlib.util.module_from_spec(spec)
|
||
spec.loader.exec_module(bd)
|
||
m = imp()
|
||
_tops = ["会话协作自检", "机制排查与修复"]
|
||
sc = {"main_sid": "", "main_sids": [], "topics": _tops, "short": "会话协作自检",
|
||
"workspace": str(TEST_WS)}
|
||
# (标题, 期望) —— 期望是**这一版**的口径;⛔ 别把期望写成"跟旧版一样"
|
||
cases = [
|
||
("[协作]-机制排查与修复-常驻投递容器", True), # ← 本 bug 的正主:二级**无方括号**
|
||
# 🔴 2026-10-02 两类改造:唤醒/跟进**已退役** ⇒ 角色判空 ⇒ 判据②那条(「角色可解析+
|
||
# 标题含类别」)**不再命中** ⇒ 期望由 True 变 **False**。
|
||
# ⛔ 这不是"判据坏了",是**退役的正确后果**:那两类不再是活类别。
|
||
# ⛔ 顺带消掉了一个自指隐患:若它们还能被判成"本项目的会话",就会被当**主会话候选**。
|
||
("[唤醒]-会话协作自检-脉冲", False),
|
||
("[跟进]-会话协作自检-队列上报", False),
|
||
("[协作]-[机制排查与修复]-合规写法", True), # 老写法(带方括号)向后兼容
|
||
("主控 · 会话协作自检 · 收尾", True), # 口语式主会话(无方括号)
|
||
("接续 · 会话协作自检 · 第 2 棒", True), # 接续棒**带类别** ⇒ 认得出(新约定的目的)
|
||
("接续 · 会话机制合并包 · 任务4b", False), # 接续棒**不带类别** ⇒ 认不出(未改)
|
||
("[协作]-本机协作-M5 棒", False), # 用 `short`/昵称 ⇒ **仍认不出**(规范照旧)
|
||
("复盘:今天在会话协作自检里犯的错", False), # ← **精度闸**:无关会话恰提到类别名
|
||
("下载 open design", False),
|
||
("", False),
|
||
]
|
||
rows = []
|
||
for t, _ in cases:
|
||
a = bool(bd.in_project("", t, sc))
|
||
b = bool(m._in_project("", t, {"main_sid": "", "main_sids": []}, _tops))
|
||
rows.append((t, a, b))
|
||
_mismatch = [t for t, a, b in rows if a != b]
|
||
# ⚠️ 2026-10-01 **我自己写错又改对的一处**:原来写成
|
||
# `for (t, _), (_, a, _b) in zip(...)` —— 两个 `_` **互相覆盖**,
|
||
# 结果拿去跟 `a` 比的是**标题**不是**期望值** ⇒ 全表恒红。
|
||
# ⇒ 元教训:**对账类断言别用 `_` 当占位名**(一个函数里两个 `_` 必然屏蔽)。
|
||
_wrong = [(t, a, exp) for (t, exp), (_t, a, _b) in zip(cases, rows) if a != exp]
|
||
H = (HERE.parent / "assets" / "board.html").read_text(encoding="utf-8")
|
||
S = (HERE / "board.py").read_text(encoding="utf-8")
|
||
return [
|
||
("两处 `in_project` 逐样本一致(%d 条;不一致:%s)" % (len(cases), _mismatch or "无"),
|
||
not _mismatch),
|
||
("🆕 **二级无方括号也算本项目的会话**(`[协作]-机制排查与修复-…` ⇒ True;"
|
||
"⛔ 旧判据判 False ⇒ 看板第三层画空框而它正在跑)", bd.in_project("", "[协作]-机制排查与修复-常驻投递容器", sc)),
|
||
("🆕 精度未丢:无关会话恰好提到类别名 ⇒ **仍不算**(角色前缀闸)",
|
||
bd.in_project("", "复盘:今天在会话协作自检里犯的错", sc) is False),
|
||
("期望值逐条成立(不符:%s)" % (_wrong or "无"), not _wrong),
|
||
("🔴 判据用**角色+类别子串**两条并列(⛔ 别退回「只认方括号」)",
|
||
"_topic_in_title(ti, _tps) and _role_of_title(ti)" in S),
|
||
("🔴 看板的退场说明在渲染里真的存在(⛔ 只改数据不改渲染 = 没修)",
|
||
"sessions_retired" in H and "已收起" in H),
|
||
]
|
||
|
||
|
||
@case("会话退场:过期会话**收起但不删除**,且 ⛔ 不吃掉在跑的棒与主会话")
|
||
def t_session_retire():
|
||
"""🔴 2026-10-01 用户:「**通过接续会话的时候,如何处理过期的会话,避免越堆越多**」。
|
||
|
||
**为什么必须有**:接续=会话自己建下一棒,前棒**不会自动消失** —— 它在宿主库里永远是
|
||
一条 `completed` 记录、标题同族。实测本工作区已堆 5 条「接续 · 会话机制合并…」⇒
|
||
看板/状态稿每轮都为旧棒占位,真在跑的棒被埋在里面。
|
||
|
||
⇒ 两条退场路径:① **显式** `--retire self`(接续时由前棒自己登记,准确、即时)
|
||
② **窗口兜底**(`completed` 且超 `session_live_min` 分钟 ⇒ 前棒中途死掉也收得掉)
|
||
⛔ 两条闸门**都绕开 `working`**(在跑的一定要摊)+ **主会话是版面锚点**(⛔ 不抽)。
|
||
⛔ **收起 ≠ 删除**(宿主库原样在、`retired.json` 全留痕)⇒ 必须**报数**,⛔ 不许静默。
|
||
"""
|
||
os.environ["COLLABD_CONFIG"] = str(CFG)
|
||
os.environ["DSH_COLLAB_WS"] = str(TEST_WS)
|
||
import importlib.util
|
||
spec = importlib.util.spec_from_file_location("bd_retire", HERE / "board.py")
|
||
b = importlib.util.module_from_spec(spec)
|
||
spec.loader.exec_module(b)
|
||
m = imp()
|
||
|
||
now = time.time()
|
||
def _ms(minutes_ago):
|
||
return int((now - minutes_ago * 60) * 1000)
|
||
def _row(sid, title, status, minutes_ago):
|
||
return {"id": sid, "title": title, "custom_title": title, "status": status,
|
||
"cwd": str(TEST_WS), "updated_at": _ms(minutes_ago),
|
||
"last_activity_at": _ms(minutes_ago)}
|
||
|
||
_MAIN, _RUN, _NEW, _OLD, _RUNRET, _RET = ("aaaa0001", "bbbb0002", "cccc0003",
|
||
"dddd0004", "eeee0005", "ffff0006")
|
||
rows = [
|
||
_row(_MAIN, "主控 · 会话协作自检 · 主会话", "completed", 300), # 主会话:再旧也不许抽掉
|
||
_row(_RUN, "[协作]-会话协作自检-在跑的棒", "working", 300), # 在跑:再旧也不许收起
|
||
_row(_NEW, "[协作]-会话协作自检-刚完的", "completed", 10),
|
||
_row(_OLD, "[协作]-会话协作自检-很旧的", "completed", 300), # 窗口兜底收
|
||
_row(_RUNRET, "[协作]-会话协作自检-已退场但仍在跑", "working", 5),
|
||
_row(_RET, "[协作]-会话协作自检-已退场", "completed", 5), # 显式退场收
|
||
]
|
||
sc = {"main_sid": _MAIN, "main_sids": [_MAIN], "topics": ["会话协作自检"],
|
||
"short": "会话协作自检", "workspace": str(TEST_WS)}
|
||
|
||
# ① 显式退场表(写进**测试区**的 inbox,⛔ 不碰生产)
|
||
(TIN / "retired.json").unlink(missing_ok=True)
|
||
rc1, out1 = 0, ""
|
||
_env = _mk_env()
|
||
_env["CODEBUDDY_SESSION_ID"] = _RET
|
||
p = subprocess.run([PY, "-u", str(HERE / "collabd.py"), "--retire", "self",
|
||
"--why", "自测"],
|
||
cwd=str(HERE), stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||
text=True, env=_env, timeout=60, errors="replace")
|
||
rc1, out1 = p.returncode, p.stdout or ""
|
||
_tbl = json.loads((TIN / "retired.json").read_text(encoding="utf-8")) if (TIN / "retired.json").exists() else {}
|
||
r1 = b._sessions(limit=30, sc=sc, rows=rows)
|
||
ids = [x["id8"] for x in (r1.get("mine") or [])]
|
||
hid = r1.get("hidden") or {}
|
||
hid_ids = [x["id8"] for x in (hid.get("recent") or [])]
|
||
|
||
# ② 退场表自身也要有过期清理(⛔ 否则这张表自己会越堆越多)
|
||
pr = m._prune_retired({_RET: {"at": now}, "zzzz9999": {"at": now - 8 * 86400}})
|
||
# ③ 短 id 也要能命中(本程序各处只存 8 位)
|
||
hit8 = m.is_retired(_RET[:8], m._retired())
|
||
(TIN / "retired.json").unlink(missing_ok=True)
|
||
return [
|
||
("`--retire self` rc=0 且落盘(%s)" % (out1.strip().splitlines() or ["(空输出)"])[0][:60],
|
||
rc1 == 0 and _RET in _tbl),
|
||
("退场表按**完整会话 id** 存(供 8 位短 id 前缀比对)", _RET in _tbl),
|
||
("🆕 显式退场的那条**被收起**(不在 mine 里)", _RET[:8] not in ids),
|
||
("🆕 超窗口(>%d 分钟)的 completed 也**被收起**" % int(b.LIVE_MIN), _OLD[:8] not in ids),
|
||
("🔴 **在跑的棒永不收起**(即使它已 300 分钟没动)", _RUN[:8] in ids),
|
||
("🔴 **主会话是版面锚点**:再旧也不抽掉", _MAIN[:8] in ids),
|
||
("🔴 显式退场 ⛔ 也不吃掉 **working** 的会话(它还在干活)", _RUNRET[:8] in ids),
|
||
("窗口内刚完成的留在版面上", _NEW[:8] in ids),
|
||
("报数:显式退场 %d / 超窗口 %d(应为 1 / 1)" % (hid.get("retired", 0), hid.get("stale", 0)),
|
||
(hid.get("retired") == 1 and hid.get("stale") == 1)),
|
||
("收起明细点名到会话(%s)" % (hid_ids or "空"), _RET[:8] in hid_ids and _OLD[:8] in hid_ids),
|
||
("⛔ 收起 ≠ 删除:宿主库行**一个字没动**(`mine` 里少了、明细里还在)",
|
||
len(rows) == 6 and len(hid.get("recent") or []) == 2),
|
||
("退场表自清理:超 7 天的记录被丢掉(剩 %d 条)" % len(pr), _RET in pr and "zzzz9999" not in pr),
|
||
("短 id(8 位前缀)也能命中退场表", hit8),
|
||
]
|
||
|
||
|
||
@case("看板:任务类别**来源**必须说得出口(declared / fallback)")
|
||
def t_board_topics_source():
|
||
"""🔴 2026-10-01 用户订正:「**目标是 通过对话在调用 会话协作skill时说明的,不是固定的**」。
|
||
|
||
`topics` 缺省回落 `short` 是**兼容**(单类别部署照旧),但看板**不许把"没说明过"
|
||
显示成"就是这么定的"** —— 那还是同族红线:**没有却说有**。
|
||
⇒ `_goal_topics_source()` 必须给出三态,且**看板真会把它渲染出来**(⛔ 只算不画=没修)。
|
||
"""
|
||
os.environ["COLLABD_CONFIG"] = str(CFG)
|
||
os.environ["DSH_COLLAB_WS"] = str(TEST_WS)
|
||
import importlib.util
|
||
spec = importlib.util.spec_from_file_location("bd_tsrc", HERE / "board.py")
|
||
b = importlib.util.module_from_spec(spec)
|
||
spec.loader.exec_module(b)
|
||
|
||
s_dec = b._goal_topics_source({"topics": ["甲", "乙"], "topics_declared_at": "2026-10-01T00:10",
|
||
"topics_declared_by": "对话", "short": "简"})
|
||
s_fb = b._goal_topics_source({"short": "简"})
|
||
s_no = b._goal_topics_source({})
|
||
s_cand = b._goal_topics_source({"_topics候选": {"候选": ["丙"]}, "short": "简"})
|
||
H = (HERE.parent / "assets" / "board.html").read_text(encoding="utf-8")
|
||
need = ["topics_source", "未声明", "调用时在对话里说明"]
|
||
miss = [n for n in need if n not in H]
|
||
return [("有 topics ⇒ declared(实得 %s)" % s_dec["kind"], s_dec["kind"] == "declared"),
|
||
("…且带说明时间/人(%s / %s)" % (s_dec["at"], s_dec["by"]),
|
||
bool(s_dec["at"]) and bool(s_dec["by"])),
|
||
("只有 short ⇒ fallback(实得 %s)" % s_fb["kind"], s_fb["kind"] == "fallback"),
|
||
("都没有 ⇒ none(实得 %s)" % s_no["kind"], s_no["kind"] == "none"),
|
||
("候选清单能读出来(%s)" % s_cand["candidates"], s_cand["candidates"] == ["丙"]),
|
||
("看板真会渲染来源(缺:%s)" % (miss or "无"), not miss)]
|
||
|
||
|
||
@case("看板:多目标 tab(一格一个目标 ⇒ ⛔ 不是各画一份叠着)")
|
||
def t_board_multi_goal():
|
||
"""🔴 2026-10-01 用户:「把协作实时看板改为 **tab 支持多个目标**执行协作状态展示」。
|
||
|
||
守四件(⛔ "能读多个"不算数,必须是**分得开、不串、且向后兼容**):
|
||
① `goal_files()` 把 `goal.json` + `goals/*.json` 合成**有序**列表,**活跃的排第一**;
|
||
② `build()` 出 `goals[]`,**顶层键=活跃目标那一份**+ `goal` 仍是 `title/short/acceptance`
|
||
⇒ ⛔ 老渲染器与老断言**逐字照旧可用**(向后兼容不是口号,是这一条);
|
||
③ 台账**按任务类别**切到各目标(⛔ 不串);`line` 不属**任何**目标的件归**活跃目标**
|
||
且**照常显示**(同族红线:⛔ 不静默丢);
|
||
④ 看板**真画** tab 条(⛔ 只算不画=没修)。
|
||
"""
|
||
os.environ["COLLABD_CONFIG"] = str(CFG)
|
||
os.environ["DSH_COLLAB_WS"] = str(TEST_WS)
|
||
import importlib.util
|
||
spec = importlib.util.spec_from_file_location("bd_mg", HERE / "board.py")
|
||
b = importlib.util.module_from_spec(spec)
|
||
spec.loader.exec_module(b)
|
||
|
||
# ⚠️ 测试工作区里的 goal.json / tasks.json 是**别的用例的夹具** ⇒ 用完**原样还原**
|
||
_bkp = {}
|
||
for _n in ("goal.json", "tasks.json"):
|
||
_p = TIN / _n
|
||
_bkp[_n] = _p.read_text(encoding="utf-8") if _p.exists() else None
|
||
_gd = TIN / "goals"
|
||
_gd_before = _gd.is_dir()
|
||
try:
|
||
_gd.mkdir(parents=True, exist_ok=True)
|
||
(TIN / "goal.json").write_text(json.dumps(
|
||
{"id": "ga", "short": "甲", "title": "目标甲", "topics": ["甲类"],
|
||
"acceptance_state": {"A1": "pass", "A2": "fail"}}, ensure_ascii=False), encoding="utf-8")
|
||
(_gd / "gb.json").write_text(json.dumps(
|
||
{"id": "gb", "short": "乙", "title": "目标乙", "topics": ["乙类"],
|
||
"acceptance_state": {"B1": "pass"}}, ensure_ascii=False), encoding="utf-8")
|
||
put_tasks({"T1": {"state": "done", "line": "甲类"},
|
||
"T2": {"state": "running", "line": "甲类"},
|
||
"T3": {"state": "done", "line": "乙类"},
|
||
"T4": {"state": "done", "line": "旧线名"}})
|
||
snap = b.build()
|
||
finally:
|
||
try:
|
||
(_gd / "gb.json").unlink()
|
||
if not _gd_before:
|
||
try:
|
||
_gd.rmdir()
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
for _n, _t in _bkp.items():
|
||
try:
|
||
if _t is None:
|
||
(TIN / _n).unlink()
|
||
else:
|
||
(TIN / _n).write_text(_t, encoding="utf-8")
|
||
except Exception:
|
||
pass
|
||
|
||
gs = snap.get("goals") or []
|
||
by = {str(g.get("key")): g for g in gs}
|
||
_ga = by.get("ga", {}) or {}
|
||
_gb = by.get("gb", {}) or {}
|
||
H = (HERE.parent / "assets" / "board.html").read_text(encoding="utf-8")
|
||
old_top = {"goal", "project", "tasks", "sessions", "labor", "orphan", "queue",
|
||
"progress", "others_running", "front", "runtime", "notify", "warn", "meta"}
|
||
_miss = sorted(old_top - set(snap.keys()))
|
||
_missjs = [k for k in ("renderTabs", "paintScope") if k not in H]
|
||
_R = [
|
||
("goal_files 合成 2 项且**活跃的排第一**(实得 %s)" % [g.get("key") for g in gs],
|
||
len(gs) == 2 and gs[0].get("active") is True and gs[1].get("active") is False),
|
||
("顶层键与旧版同形(缺:%s)" % (_miss or "无"), not _miss),
|
||
("顶层 goal 仍是 title/short/acceptance(实得 %s)" % sorted((snap.get("goal") or {}).keys()),
|
||
set((snap.get("goal") or {}).keys()) == {"title", "short", "acceptance"}),
|
||
("甲目标分到 2 件 + 1 件无人认领的旧线(共 %d 件)" % len(_ga.get("tasks") or {}),
|
||
len(_ga.get("tasks") or {}) == 3),
|
||
("乙目标只分到自己的 1 件(实得 %d)" % len(_gb.get("tasks") or {}),
|
||
len(_gb.get("tasks") or {}) == 1),
|
||
("⛔ 乙目标看不到甲目标的件(不串)", "T1" not in (_gb.get("tasks") or {})),
|
||
("⛔ 甲目标的完成情况里没有乙目标的判据", "B1" not in str(_ga.get("acc_summary"))),
|
||
("验收按目标分开算(甲=%s / 乙=%s)" % (_ga.get("acc_summary"), _gb.get("acc_summary")),
|
||
"A2" in str(_ga.get("acc_summary")) and "pass" in str(_gb.get("acc_summary"))),
|
||
("分工板按目标分开(甲 %s / 乙 %s)"
|
||
% ([x.get("line") for x in (_ga.get("labor") or [])],
|
||
[x.get("line") for x in (_gb.get("labor") or [])]),
|
||
(_ga.get("labor") or [{}])[0].get("line") == "甲类"
|
||
and "乙类" not in [x.get("line") for x in (_ga.get("labor") or [])]
|
||
and [x.get("line") for x in (_gb.get("labor") or [])] == ["乙类"]),
|
||
("看板真会画 tab 条(缺:%s)" % (_missjs or "无"), not _missjs),
|
||
("看板把 `goals` 当数据源(⛔ 不写死单份)", "d.goals" in H),
|
||
# ===== 🔴🔴 2026-10-04 用户定案:tab 最多 6 个 + 最新在跑的排前面 =====
|
||
("tab 有**上限常量**且只有一个(⛔ 两处数字必漂)", H.count("TAB_MAX = 6") == 1),
|
||
("超出部分**收进下拉**(`tabMoreMenu` + `tabMoreBtn` 都在)",
|
||
"tabMoreMenu" in H and "tabMoreBtn" in H),
|
||
("下拉项**可直接切换**(复用 `data-gi` ⛔ 不另造一套选中逻辑)",
|
||
"tabmenu-item" in H and "data-gi=" in H),
|
||
("下拉挂在 `.tabwrap`(⛔ `position:relative` 必须在它身上,"
|
||
"⛔ 否则绝对定位会参照 flex 祖先跑到别处)",
|
||
".tabwrap{position:relative" in H.replace(" ", "")),
|
||
("菜单**不是常开**(`hidden` + 显隐逻辑都有)",
|
||
"mn.hidden=true" in H and "tabmenu[hidden]" in H),
|
||
("🔴 **排序在后端**(前端⛔ 不自己排:两处排序=改一处忘另一处必打架,同 P0-40)",
|
||
#⚠️ 只量`renderTabs` **函数体**,⛔ 不量全文件(本文件别处有 3 处正当排序:
|
||
# L532 判据键、L1040 检查会话按 age_min、L1621 台账键—— 全局判 `sort(` 会出现假红)。
|
||
"sort(" not in H.split("function renderTabs")[1][:4000]),
|
||
("前端**读后端排好的顺序**(⛔ 直接 `gs.map` 不重排)", "shown=more?gs.slice(0,TAB_MAX):gs" in H),
|
||
]
|
||
# ===== 🔴🔴 2026-10-04 用户定案:「**tab 的宽度改为一样宽**」=====
|
||
# ⚠️⚠️ **必须先剥 CSS 注释再量规则块** —— `.tab` 上面那段说明注释里**正当**地
|
||
# 引用了旧写法 `min-width:180px`(那是"为什么改"的证据,⛔ 不许删)。
|
||
# ⇒ 直接全文件查 `min-width:180px` 会**永远命中** ⇒ 判据假绿(实测会栽在这)。
|
||
# ⚠️⚠️ **必须只量 `.tab{...}` 这一条规则块**,⛔ 不量全文件(本文件别处有
|
||
# `.tabmenu` / `.tabmore-btn` / `.tabs` 等正当规则)。正则是 `\.tab\{[^}]*\}` ——
|
||
# `.tab .tt{` / `.tabmenu{` / `.tabmore-btn{` 都**不**匹配(`{` 紧跟 `.tab`)。
|
||
_css_nc = re.sub(r"/\*.*?\*/", "", H, flags=re.S) # 剥注释
|
||
_mtab = re.search(r"\.tab\{[^}]*\}", _css_nc)
|
||
_tabcss = _mtab.group(0) if _mtab else ""
|
||
# 等宽的**唯一成因**=`flex` 的 grow≥1 且 **basis=0**(basis 按内容分配 ⇒ 永不等宽)。
|
||
# ⚠️ 解析式判据而不是查子串:`flex:1 1 0` ✅ / `flex:1`(默认 1 1 auto)❌ /
|
||
# `flex:1 1 auto` ❌ / `flex:0 1 auto`(不生长)❌ —— 四种写法子串法分不开。
|
||
_mf = re.search(r"flex:\s*(\d+)\s+(\d+)\s+([0-9a-z%]+)", _tabcss)
|
||
_R.append((
|
||
"tab **等宽**(`.tab` 的 flex 须 grow≥1 且 **basis=0**;实得:%s)"
|
||
% (_mf.group(0) if _mf else "⛔没找到 flex 声明"),
|
||
bool(_mf) and int(_mf.group(1)) >= 1 and _mf.group(3) == "0"))
|
||
# 🔴 配套:`min-width:0` 缺了 ⇒ flex 等分会被子元素 min-content 顶开 ⇒ **看着不等宽**
|
||
# (标题长的那格把邻居挤窄)。这是"等宽"的**另一半**,⛔ 不能只判 flex。
|
||
_R.append(("tab 等宽的配套 `min-width:0` 在(⛔ 缺了会被长标题顶开):%s"
|
||
% ("有" if "min-width:0" in _tabcss.replace(" ", "") else "⛔无"),
|
||
"min-width:0" in _tabcss.replace(" ", "")))
|
||
# 🔴 抓「改回内容自适应」:`flex:1 1 0` 之外又留了按内容/固定宽的声明 ⇒ 后者会赢。
|
||
# 🔴🔴 `width:` **必须带负向后顾** —— ⛔ 否则 `max-width:420px` 里那个 `width:420` 会被命中
|
||
# ⇒ 这条判据**恒红** ⇒ 任何变异都"报红" ⇒ **变异验证整体作废**(判据恒红 = 没判据)。
|
||
# CSS 属性名里 `-` 是合法字符(`max-width`/`min-width`)⇒ 用 `(?<![-\w])` 锚住词首。
|
||
_R.append(("`.tab` ⛔ 没有内容自适应残留(`min-width:1xxpx` / 裸 `width:` / `flex:0 1 auto`):%s"
|
||
% (_tabcss.replace("\n", " ")[:110] or "⛔没取到规则块"),
|
||
not re.search(r"min-width:\s*1\d\dpx|(?<![-\w])width:\s*\d|flex:\s*0\s+1", _tabcss)))
|
||
# --- 后端排序:真起 board.py 落一个快照,验真排序(⛔ 源码级不算数)---
|
||
try:
|
||
import json as _j8
|
||
import subprocess as _sp8
|
||
import tempfile as _tp8
|
||
from pathlib import Path as _P8 #⛔ 别用 `Path`(本函数里被别的局部量遮住)
|
||
import sys as _s8 #⛔ 同上
|
||
_cfg = _tp8.mkdtemp(prefix="tabs_")
|
||
_cf = _P8(_cfg) / "collabd.config.json"
|
||
_cf.write_text(_j8.dumps({"workspace": _cfg, "inbox": "inbox",
|
||
"log": "inbox/_b.log"}, ensure_ascii=False), encoding="utf-8")
|
||
_in = _P8(_cfg) / "inbox"
|
||
_in.mkdir(parents=True, exist_ok=True)
|
||
# ⚠️ **三个目标**,⛔ 不是一个 —— 单块时任何排序键都「升序」,变异根本抓不到
|
||
#(实测栽过:删掉 `blocks.sort(...)` 判据照样绿)。
|
||
# 🔴🔴 2026-10-06:**`declared_at` 必须"乱序"给**(t2 最老、t0 居中、t3 最新),
|
||
# 而且**文件出现次序 = 声明时间倒序** ⇒ 只要排序失效,输出顺序**立刻不一样**。
|
||
# ⛔ 若三个时间给成同序/全相同 ⇒ 排序与否输出无差异 ⇒ **判据恒绿**(同族已栽多次)。
|
||
(_in / "goal.json").write_text(_j8.dumps(
|
||
{"id": "t0", "short": "甲", "title": "甲目标", "lifecycle": "进行中",
|
||
"run": "active", "declared_at": "2026-10-02T10:00",
|
||
"acceptance_state": {"A1": "过|x"}}, ensure_ascii=False),
|
||
encoding="utf-8")
|
||
_gd8 = _in / "goals"
|
||
_gd8.mkdir(parents=True, exist_ok=True)
|
||
for _i, _at in ((2, "2026-10-01T09:00"), (3, "2026-10-03T11:00")):
|
||
(_gd8 / ("%d.json" % _i)).write_text(_j8.dumps(
|
||
{"id": "t%d" % _i, "short": "乙%d" % _i, "title": "乙目标%d" % _i,
|
||
"lifecycle": "进行中", "run": "active", "declared_at": _at,
|
||
"acceptance_state": {"A1": "过|x"}}, ensure_ascii=False),
|
||
encoding="utf-8")
|
||
# 🔴🔴 **env 必须「白名单重建」,⛔ 不能 `dict(os.environ, ...)`**:
|
||
# 本用例开头那句 `os.environ["COLLABD_CONFIG"] = str(CFG)` 已把进程 env 指向
|
||
# **测试夹具**(实测:这样跑board.py 会读到夹具的 inbox ⇒ 只回1 个「目标甲」,
|
||
# 判据里那 5 条**全部通过但验的不是我的夹具** —— 最坏的一种假绿)。
|
||
# ⇒ 只留必需项,其余全清(`DSH_*` / `COLLABD_*` / `CODEBUDDY_*` 一律不带)。
|
||
_env8 = {"PATH": os.environ.get("PATH", ""),
|
||
"SystemRoot": os.environ.get("SystemRoot", ""),
|
||
"TEMP": os.environ.get("TEMP", ""),
|
||
"TMP": os.environ.get("TMP", ""),
|
||
"COMSPEC": os.environ.get("COMSPEC", ""),
|
||
"COLLABD_CONFIG": str(_cf),
|
||
"COLLABD_NO_ENSURE": "1"} # ⛔ 测试环境⛔ 不许起常驻(见 `_mk_env` 的说明)
|
||
_sp8.run([_s8.executable, str(HERE / "board.py"), "--out", str(_in / "board.json")],
|
||
capture_output=True, text=True, timeout=180, env=_env8,
|
||
encoding="utf-8", errors="replace")
|
||
_bj = _in / "board.json"
|
||
_d8 = _j8.loads(_bj.read_text(encoding="utf-8")) if _bj.exists() else {}
|
||
_gs8 = _d8.get("goals") or []
|
||
# 🔴🔴 2026-10-06 用户定案:「**tab 的排列顺序应该按创建时间顺序**」(推翻 10-04 的活跃度排序)。
|
||
# ⇒ 判据整体改判 **`tab_created`(=`declared_at`)升序**。
|
||
# ⚠️ **必须钉住"实际次序"**,⛔ 不能只判"升序":夹具里三块时间**全不同** ⇒
|
||
# 升序成立**等价于**真的按时间排;但若哪天夹具退化成同值,升序就恒真 ⇒
|
||
# ⇒ 所以**额外钉一条"首个是最老的"**(`2026-10-01`)当冗余保险。
|
||
_crts = [g.get("tab_created") for g in _gs8]
|
||
_R.append(("后端给了 `tab_created` 排序键(实得:%s)" % (_crts[:3] or "无"),
|
||
bool(_gs8) and all(c is not None for c in _crts)))
|
||
_R.append(("后端 `tab_created` **升序**(最早创建的在最前):%s" % _crts,
|
||
_crts == sorted(_crts)))
|
||
# 🔴 抓「拿活跃度当创建时间」/「键取错字段」:夹具里 **t2 最老**(10-01)
|
||
# ⇒ 排第一的必须是它;⛔ 若还是甲(goal.json,文件序第一)= 没排序。
|
||
# ⚠️ `short` 在块的 **`goal` 子字典**里(⛔ 不在块顶层 —— 我第一版就取错层,
|
||
# 判据恒红;实测栽过)。
|
||
_R.append(("首个 tab = **最早创建的目标**(夹具里是最老的 `乙2`):%s"
|
||
% [str((g.get("goal") or {}).get("short") or "") for g in _gs8][:1],
|
||
bool(_gs8)
|
||
and str((_gs8[0].get("goal") or {}).get("short") or "") == "乙2"))
|
||
# 🔴 抓「缺 `declared_at` 的块被当成 0/空串 ⇒ 排到最前」:
|
||
# ⚠️ **必须真造一块没有 `declared_at` 的**(t4,且它的标题明显"最老",人眼会想放前面)
|
||
# ⇒ 它必须**垫底**(`tab_created == "~"` 且 `tab_i` 最大),⛔ 不许因为读不到就抢到第一。
|
||
# ⛔ 不造这一块 ⇒ 本判据**恒真**(列表里没有 `~`)= 假绿(同族已栽多次)。
|
||
_gd8.joinpath("4.json").write_text(_j8.dumps(
|
||
{"id": "t4", "short": "丙4", "title": "丙目标4", "lifecycle": "进行中",
|
||
"run": "active", "acceptance_state": {"A1": "过|x"}}, ensure_ascii=False),
|
||
encoding="utf-8")
|
||
_sp8.run([_s8.executable, str(HERE / "board.py"), "--out", str(_in / "board.json")],
|
||
capture_output=True, text=True, timeout=180, env=_env8,
|
||
encoding="utf-8", errors="replace")
|
||
_d9 = _j8.loads((_in / "board.json").read_text(encoding="utf-8")) \
|
||
if (_in / "board.json").exists() else {}
|
||
_gs9 = _d9.get("goals") or []
|
||
_c9 = [g.get("tab_created") for g in _gs9]
|
||
_i9 = [g.get("tab_i") for g in _gs9]
|
||
_n9 = len(_gs9)
|
||
_R.append(("缺 `declared_at` 的块 **垫底**(⛔ 不许读不到就排最前):%s" % _c9,
|
||
_n9 == 4 and _c9[-1] == "~" and _c9[:3] == sorted(_c9[:3])
|
||
and _i9 == [0, 1, 2, 3]))
|
||
_R.append(("垫底块的 `tab_i` = **最后一位**(⛔ 不是靠 '~' 撞巧):%s" % _i9,
|
||
_n9 == 4 and _i9[-1] == 3))
|
||
_R.append(("后端给了 `tab_more`(前端⛔ 不自己重算 `gs.length-6`):%s"
|
||
% [g.get("tab_more") for g in _gs8],
|
||
bool(_gs8) and all(g.get("tab_more") == max(0, len(_gs8) - 6)
|
||
for g in _gs8)))
|
||
# 🔴 抓「排序后没重编序号」:多块时 `tab_i` 必须恰好是 0..n-1
|
||
# (⛔ 排序前的旧序号会留着 ⇒ 前端 `data-gi` 与位置对不上 ⇒ 点A切到B)。
|
||
# 🔴 抓「干脆不排序」:夹具里文件出现次序**本就是时间倒序**(t0 居中、t2 最老在后加)
|
||
# ⇒ 排序与否**输出有差异** ⇒ 上面那条"升序"能抓到;此处再钉一条源码级保险
|
||
# (⛔ 这是源码级判据的正当用途:验"语句在不在")。
|
||
_bsrc = (HERE / "board.py").read_text(encoding="utf-8")
|
||
_R.append(("`blocks.sort(...)` 真在且按 `tab_created`:%s"
|
||
% ("tab_created" in _bsrc and "blocks.sort(" in _bsrc),
|
||
'blocks.sort(key=lambda b: (b.get("tab_created"' in _bsrc))
|
||
# 🔴 抓「又改回活跃度排序」(口径回退):⛔ 源码里不许再出现 `tab_hot` 当排序键。
|
||
_R.append(("排序键**不再是** `tab_hot`(⛔ 防口径回退):%s"
|
||
% ("blocks.sort(key=lambda b: (b.get(\"tab_hot\"" not in _bsrc),
|
||
"blocks.sort(key=lambda b: (b.get(\"tab_hot\"" not in _bsrc))
|
||
_tis = [g.get("tab_i") for g in _gs8]
|
||
_R.append(("多目标时 `tab_i` **重编为 0..n-1**(⛔ 不重编 = 前端点A切到B):%s" % _tis,
|
||
len(_gs8) > 1 and _tis == list(range(len(_gs8)))))
|
||
except Exception as _e8:
|
||
_R.append(("真起board.py 验排序(⛔ 起不来即报红):%s" % type(_e8).__name__, False))
|
||
|
||
|
||
return _R
|
||
|
||
|
||
@case("动手层:三条开工动作在位 + **判据不许恒红**(⛔ 恒红 = 没有判据)")
|
||
def t_howto_layer():
|
||
"""🔴🔴 2026-10-04 用户:「**这些问题如何沉淀在技能中,让会话执行更靠谱**」。
|
||
|
||
病根诊断(同一天四次翻车,同一个形状):
|
||
`pitfalls.md` 47 条 / 141 KB 是**知识层** ⇒ 会话**读得完但不会自动执行**;
|
||
缺的是**动手层**(开工前必过的三条动作)⇒ �� `references/00-动手前必过.md`。
|
||
|
||
守三件(**每件都已落判据,不许退化**):
|
||
① 动手层**在位 + 挂进 SKILL.md 加载段第一位**(⛔ 不挂=又一份没人读的文档);
|
||
② 动手层里那三条**各带已知落点常量**(省得下一个人再去 grep 源码);
|
||
③ 🔴 **判据不许恒红** —— 基线必须全绿(数 ✗ 且排除「报告型」行)。
|
||
恒红比漏网更坏:它让**所有变异都"红"** ⇒ 验证作废,而**表面看起来完美**。
|
||
"""
|
||
HOW = HERE.parent / "references" / "00-动手前必过.md"
|
||
exists = HOW.exists()
|
||
T = HOW.read_text(encoding="utf-8", errors="replace") if exists else ""
|
||
SK = (HERE.parent / "SKILL.md").read_text(encoding="utf-8", errors="replace")
|
||
P0 = (HERE.parent / "references" / "pitfalls.md").read_text(encoding="utf-8", errors="replace")
|
||
# ① 在位 + 挂第一位
|
||
_R = [
|
||
("动手层 `00-动手前必过.md` 在位(%d 字节)" % len(T.encode("utf-8")), exists),
|
||
("挂进 SKILL.md 加载段(⛔ 不挂 = 又一份没人读的文档)",
|
||
"references/00-动手前必过.md" in SK),
|
||
("排在加载段**第一位**(⛔ 动手层要最先被读到,⛔ 不许排在后面)",
|
||
SK.find("00-动手前必过.md") < SK.find("references/architecture.md")),
|
||
]
|
||
# ② 三条动作各在位 + 各带落点常量
|
||
_three = [("查一·问程序自己", "supervise_alive"),
|
||
("查二·先看时间戳", "_更新"),
|
||
("查三·先让基线全绿", "恒红")]
|
||
for _nm, _k in _three:
|
||
_R.append(("动作在位且带实证:**%s**(锚点 `%s`)" % (_nm, _k), _k in T))
|
||
_R.append(("动手层给了**已知落点常量表**(⛔ 不给 ⇒ 下一个人又去 grep 源码):%d 条"
|
||
% T.count("| 东西 |"),
|
||
T.count("| 东西 |") >= 1 and "SUP_HB" in T and "guard.stop" in T))
|
||
# 🔴🔴③ 「判据不许恒红」—— ⛔ **原来这条是"扫自己的源码找裸 True"**,
|
||
# 实测**把自己的扫描代码当成了被扫对象**(`"== 0) or" in ln` 命中它自己那行)⇒ 恒红。
|
||
# ⇒ 与 P0-48 同策:**扫文本的判据一律不成立**,改成**行为级**——
|
||
# 真把 `00-动手前必过.md` 挪走,看这条判据会不会报红(它必须红)。
|
||
# ⚠️ 这里只判"文档在不在位"(那是可判的);**"判据会不会红"由变异脚本验**,不在这儿自证。
|
||
# ⚠️ 锚点**跟着内容走**:动手层精简过一次(4849→2582 字节)、后又加过两条(④⑤),
|
||
# 锚点串必须同步改,⛔ 否则判据会恒红(已栽过一次,见 P0-48)。
|
||
_anti = [k for k in ("⛔ **不许用", "一律现取", "恒红比漏网更坏",
|
||
"单条上限", "关键词对账") if k in T]
|
||
_R.append(("动手层五条动作**都带 ⛔ 反例/上限**"
|
||
"(⛔ 只写「要做」不写「⛔ 不许」= 会被照着做错):%d/5" % len(_anti),
|
||
len(_anti) == 5))
|
||
# 📌 动手层与 pitfalls 的分工不许反(知识层会被读完,动手层是动作)
|
||
_R.append(("动手层明说「⛔ 别开工前通读 pitfalls」"
|
||
"(⛔ 知识层当动作层用 = 141 KB 没人读)",
|
||
"别开工前通读" in T or "⛔ 别开工前" in T))
|
||
_R.append(("三条都在 pitfalls 里有对应条目(P0-47/48/49 = 知识层)",
|
||
all(("P0-%d" % i) in P0 for i in (47, 48, 49))))
|
||
return _R
|
||
|
||
|
||
@case("判据元体检工具在位 + 它的**元规则**也不是摆设(⛔ 防「靠记得」)")
|
||
def t_judge_audit_tool():
|
||
"""🔴 2026-10-04 收口:写了一天纪律(`references/00-动手前必过.md`)**当天照样栽**。
|
||
|
||
⇒ 根因收敛成两条,**都不是「不小心」**:
|
||
A. 判据与被检对象的「连接」没被验证(判据自重算/锚文本/夹具没覆盖真分支)
|
||
B. 验证动作本身出错而我不检查它(脚本自己崩了却照样打 PASS/只重置一份文件)
|
||
⇒ 解法不是「更小心」,是**把纪律变成机器强制**:⛔ 不跑这个工具,你自己看不见。
|
||
"""
|
||
mp = HERE / "judge_audit.py"
|
||
if not mp.exists():
|
||
return [("⛔ 包内缺 `scripts/judge_audit.py` ⇒ 判据恒绿/假绿没人拦", False)]
|
||
import importlib.util as _iu
|
||
sp = _iu.spec_from_file_location("ja_probe", mp)
|
||
ja = _iu.module_from_spec(sp)
|
||
try:
|
||
sp.loader.exec_module(ja)
|
||
except Exception as e:
|
||
return [("⛔ `judge_audit.py` 读不出来(%r)" % (e,), False)]
|
||
|
||
src = mp.read_text(encoding="utf-8", errors="replace")
|
||
R = []
|
||
R.append(("**元规则表非空**(⛔ 空表 = 体温计没装)", len(getattr(ja, "RULES", [])) >= 5))
|
||
R.append(("每条规则都带**「今天栽在哪」**(⛔ 没写原因的规则会变成新纪律)",
|
||
all(("今天" in w) for _n, _k, _p, w in ja.RULES)))
|
||
R.append(("每条规则都有 **kind**(weak/self/weak_ev ⇒ 严重度可分级)",
|
||
all(k in ("weak", "self", "weak_ev") for _n, k, _p, _w in ja.RULES)))
|
||
# —— B 类根因的工具抓手(今天栽了三次的就是这一块)
|
||
R.append(("**变异未生效 ⇒ rc=2 且明说**(⛔ 今天脚本自己崩了却照样打 PASS)",
|
||
re.search(r"变异未生效[\s\S]{0,120}?return 2", src) is not None))
|
||
R.append(("**等价变异被识破**(⛔ 只动写法不动语义的变异不算数)",
|
||
# 🔴 逐字锚在**工具自己的措辞**上(今天栽过:锚 `mutated == text` 那个条件,
|
||
# 而工具里那句是「植入后文本**没变** ⇒ 等价变异」⇒ 结构不同 ⇒ 假红)
|
||
re.search(r"植入后文本\*\*没变\*\*[^\n]*?等价变异[\s\S]{0,200}?return 2",
|
||
src) is not None))
|
||
R.append(("**还原后核对 md5**(⛔ 今天多轮变异互相污染)",
|
||
re.search(r"还原后 md5 \*\*不一致\*\*[\s\S]{0,120}?return 2", src) is not None))
|
||
R.append(("**`finally` 里还原**(⛔ 异常路径也必须还原)",
|
||
re.search(r"finally:[\s\S]{0,120}?write_bytes\(orig\)", src) is not None))
|
||
R.append(("🔴 **变异在原地做**(⛔ 拷到临时目录 = 判据读的还是原文件,"
|
||
"变异压根没进被检对象 —— 今天第三次栽在这)",
|
||
"shutil.copy2" not in src and "原地" in src))
|
||
R.append(("**误报白名单存在且要求写清原因**(⛔ 元规则本身也会假红)",
|
||
hasattr(ja, "FALSE_POSITIVE") and "FALSE_POSITIVE" in src))
|
||
# —— 真跑:拿一个**已知的坏写法**去扫,必须被抓到(否则规则是死的)
|
||
probe = TEST_WS / "judge_probe.py"
|
||
probe.write_text(
|
||
"x = (lambda s: s.lower())\n"
|
||
"if 'automations' in src:\n"
|
||
" pass\n", encoding="utf-8", newline="\n")
|
||
try:
|
||
n = ja.audit(probe)
|
||
R.append(("拿**已知坏写法**去扫 ⇒ 命中 ≥2 类规则"
|
||
"(⛔ 扫不出 = 规则本身是死的)", n >= 2))
|
||
finally:
|
||
try:
|
||
probe.unlink()
|
||
except OSError:
|
||
pass
|
||
return R
|
||
|
||
|
||
@case("红线 S:⛔ 严禁把「被监控对象所在工作区」当成「目标归属工作区」+ 归属核对进 env_check")
|
||
def t_ws_attribution():
|
||
"""🔴🔴 2026-10-04 用户当场纠正(当轮原话):
|
||
「**本工作区下面的目标 为什么执行会话要创建到 vibe-product 工作区下面**」。
|
||
|
||
病根:用户在本工作区(`ai1net-dsh-server`)下达「监控 `vibe-product`」的目标,
|
||
我把排期 `cwds` 写成 `vibe-product` ⇒ 会话 `c88a157c` 落在那个区
|
||
⇒ **本工作区看板/台账里直接看不见它**,而本区恰恰是问题最集中、最需要它的地方。
|
||
⛔ 当时**没有任何机制**提醒过 ⇒ 光写文档没用,必须落成**可执行判据**。
|
||
"""
|
||
SK = (HERE.parent / "SKILL.md").read_text(encoding="utf-8", errors="replace")
|
||
R = []
|
||
R.append(("SKILL.md 有 **S 红线**这一条",
|
||
"S 红线" in SK))
|
||
R.append(("红线上写清**归属看的是「目标从哪个区下达」**(⛔ 不是目标文本里出现的区名)",
|
||
"目标在哪个工作区下达" in SK and "对象" in SK))
|
||
# 🔴 逐字抄文档原句(⛔ 判据锚点必须逐字,早先凭记忆写「正解=」⇒ 基线直接红)
|
||
R.append(("三条禁止都在位(不许把对象区写进 `cwds`/不许按目标文本里的区名定/交付落归属区)",
|
||
"不许把「要去看/要改的那个区」写成 `cwds`" in SK
|
||
and "不许用「目标文本里出现了某区名」来定 `cwds`" in SK
|
||
and "一律落在**归属区**" in SK))
|
||
R.append(("写明**唯一例外**(用户当轮明确要求时才建在别的区)",
|
||
"唯一例外" in SK and "当轮" in SK))
|
||
R.append(("写明例外的机械背景(`automations.cwds` ⛔ 不落 `sessions.cwd`)",
|
||
"不落" in SK and "sessions.cwd" in SK))
|
||
|
||
# —— 判据侧:真去查 `env_check` 有没有这一段
|
||
# 🔴 `imp()` 硬编码只加载 `collabd.py`(⛔ 不接参数)⇒ 这里**自己**按路径加载。
|
||
import importlib.util as _iu
|
||
_sp = _iu.spec_from_file_location("_env_probe_att", HERE / "hooks" / "_env.py")
|
||
ev = _iu.module_from_spec(_sp)
|
||
try:
|
||
_sp.loader.exec_module(ev)
|
||
except Exception as e:
|
||
return R + [("⛔ `_env.py` 读不出来(%r)⇒ 归属判据无法验" % (e,), False)]
|
||
src = (HERE / "hooks" / "_env.py").read_text(encoding="utf-8", errors="replace")
|
||
# 🔴🔴 这三条原先是「**锚在文本出现过**」的弱判据(今天第 7 次同形状,已被元体检标出)
|
||
# ⇒ 改成**行为判据**:真跑 `env_check`,看返回体里**有没有那个键、值对不对**。
|
||
# ⛔ 弱锚点的危险:把 `attribution` 从源码里删掉但留个同名注释,它照样绿。
|
||
_rep = {}
|
||
try:
|
||
_rep = ev.env_check("E:/ProgramData/__selftest_no_such_ws__")
|
||
except Exception as _e:
|
||
R.append(("⛔ `env_check` 真跑失败(%r)⇒ 后三条判据无法验" % (_e,), False))
|
||
_att = (_rep.get("attribution") or {}) if isinstance(_rep, dict) else {}
|
||
R.append(("`_env.py` 的 `env_check` 真跑后**返回体含 `attribution` 键**"
|
||
"(⛔ 行为判据,⛔ 不是「源码里出现过 attribution」)",
|
||
isinstance(_rep, dict) and "attribution" in _rep))
|
||
R.append(("`attribution` 里带 `declared` 清单(⛔ 空 dict = 键在但没内容,等于没有)",
|
||
isinstance(_att, dict) and "declared" in _att))
|
||
R.append(("核对读的是 **`automations` 表**(⛔ 不是 `sessions.cwd` —— 后台会话 cwd 独立):"
|
||
"⛔ 用**真读数**验(`declared` 非空 ⇒ 查的是真库)",
|
||
bool(_att.get("declared"))))
|
||
R.append(("**有工作区路径归一化**(⛔ 同一区出现过反斜杠/正斜杠两种写法 ⇒ 逐字比会误判)",
|
||
hasattr(ev, "_norm_ws")))
|
||
# 🔴🔴 真行为判据,⛔ **不许**自己重算一遍(今天栽过):
|
||
# 早先写成 `(lambda s: ...)(...)` ⇒ 跟被检的 `_norm_ws` **毫无关系**
|
||
# ⇒ 把它改坏(去掉 lower)判据照样全绿(实测 V4 没抓到)。
|
||
# ⇒ 现在**直接调被检函数**,且喂**实测踩到过的三种形态**。
|
||
try:
|
||
_n = ev._norm_ws
|
||
R.append(("归一化:反斜杠/正斜杠/尾斜杠/大小写 **四种写法归到同一个值**"
|
||
"(⛔ 同一区实测出现过 `E:\\…` 与 `E:/…` 两种)",
|
||
len({_n("E:\\P\\ws"), _n("E:/P/ws"), _n("E:/P/ws/"),
|
||
_n("e:/p/ws")}) == 1))
|
||
R.append(("归一化**不误并不同区**(⛔ 别把 `ws` 与 `ws2` 并成一个)",
|
||
_n("E:/P/ws") != _n("E:/P/ws2")))
|
||
except Exception as e:
|
||
R.append(("`_norm_ws` 真调失败:%r" % (e,), False))
|
||
# 🔴 行为判据:拿一个**确定没有线**的工作区跑真体检 ⇒ 必须报出归属告警
|
||
# (复用上面已跑过的 `_rep`,⛔ 不重复跑 —— 同一份读数取两遍只会在两处漂)
|
||
_a = _att if isinstance(_att, dict) else {}
|
||
R.append(("对「无线的工作区」跑真体检 ⇒ **报出归属告警**"
|
||
"(⛔ 不报 = 形同虚设)",
|
||
any("协作线" in w for w in (_a.get("peer_missing") or []))))
|
||
R.append(("该告警进了 `warn`(⛔ 不进 warn = 没人会看到)",
|
||
any("协作线" in w for w in ((_rep.get("warn") or []) if isinstance(_rep, dict) else []))))
|
||
return R
|
||
|
||
|
||
@case("红线:⛔ 严禁用「排期/自动任务」当常驻载体 + 环境检查必须查工作区部署")
|
||
def t_no_schedule_as_supervisor():
|
||
"""🔴🔴 2026-10-04 用户定案(当场立红线):
|
||
|
||
> 「**使用技能时就要检查环境、做相关配置**(⚠️ **说了无数遍但没实现**)」
|
||
> 「**如果是为了替代常驻目标检查程序 也是技能的问题,需要红线禁止这样的操作**」
|
||
|
||
**取证**(`vibe-product` 工作区):
|
||
· 排期 `[执行]-界面交互-常驻续命`(`id=e181b51b`)= **`FREQ=HOURLY;INTERVAL=1`**
|
||
· 它的 prompt 第 3 步明写「不在 ⇒ **后台任务**起一条 `collabd.py --supervise`」
|
||
· **15:23 真跑过一次**,会话结论「✅ 常驻存活,本轮未做续命、未建任何排期」
|
||
· ⇒ **每小时唤醒一个新会话,只为看一眼常驻活没活**;而常驻本来就活(连跑 17.7 h)
|
||
⚠️ **它违背本技能自己写的** `supervise-persistence.md`「⛔ **排期代替不了载体**」
|
||
—— 文档早写对了,⛔ **没有判据守住** ⇒ 照样被建出来。**这就是本用例要补的洞。**
|
||
|
||
守两件:
|
||
① **红线在位**(SKILL.md 的 R 条 + 四条禁止 + 「换载体不是续命」这层意思)
|
||
② 🔴 **`env_check` 真的查工作区部署** —— 原来它只查「技能根能不能定位 + 钩子注册」,
|
||
⇒ **「复制了技能但工作区没部署」永远查不出来**(实测 `vibe-product` 旧版读数全绿)。
|
||
"""
|
||
import re as _re2
|
||
SK = (HERE.parent / "SKILL.md").read_text(encoding="utf-8", errors="replace")
|
||
PP = (HERE.parent / "references" / "supervise-persistence.md").read_text(
|
||
encoding="utf-8", errors="replace")
|
||
env_src = (HERE / "hooks" / "_env.py").read_text(encoding="utf-8", errors="replace")
|
||
# 🔴 `imp()` 硬编码只加载 `collabd.py`(不接参数)⇒ 这里**自己**按路径加载 `_env.py`。
|
||
# (实测栽过:`imp("hooks/_env.py")` ⇒ TypeError: imp() takes 0 positional arguments)
|
||
import importlib.util as _iu
|
||
_sp = _iu.spec_from_file_location("_env_probe", HERE / "hooks" / "_env.py")
|
||
m = _iu.module_from_spec(_sp)
|
||
_sp.loader.exec_module(m)
|
||
# ① 红线在位
|
||
_i = SK.find("严禁用「排期/自动任务」当常驻")
|
||
_red = SK[_i:_i + 2600] if _i > 0 else ""
|
||
_R = [
|
||
("SKILL.md 有 R 红线「⛔ 严禁用排期当常驻载体」(位置在 §1)", _i > 0),
|
||
("红线点名了那个反面案例(hourly 排期 id 或名字)",
|
||
("e181b51b" in _red) or ("常驻续命" in _red)),
|
||
# 🔴 锚点**逐字抄文档原文**(⛔ 不能照 imagined 写:上一版写「正解=」/
|
||
# 「4. ⛔ 不许」,文档实际是「正确处置=」/「4. ⛔ **常驻真死时不许…」」
|
||
# ⇒ 基线直接红,这类错自己栽过两回)。
|
||
# 🔴🔴 2026-10-04 口径订正(用户:「从来没说过什么开机自启,只有调用技能
|
||
# 完成目标时启动 后台任务和检查程序」)⇒ 判据**跟着口径改**,⛔ 不留旧措辞:
|
||
# 原来锚「正确处置=**换载体**(计划任务 + 守护循环)」—— 那把
|
||
# 「计划任务」写成了唯一正解,⛔ 与用户口径冲突。
|
||
# ⇒ 现在锚两件真正要保住的事:①⛔ 不是拿排期续命 ②正解=按需重起。
|
||
("红线写明正解=**按需重起**(⛔ 不是拿排期续命;⛔ 计划任务只是可选,⛔ 不是唯一正解)",
|
||
("⛔ **不是**建一条排期去续它的命" in _red)
|
||
# 🔴 逐字:文档是 `**正确处置=按需重起**`(⭐ 包的是**整句**),
|
||
# ⛔ 不是 `正确处置=**按需重起**`(包的是后半截)—— 差一对星号就恒红,
|
||
# 这已是今天第 11 次「判据锚点没逐字抄原文」。
|
||
and ("**正确处置=按需重起**" in _red)
|
||
and ("**不是需求、不是欠项**" in _red)),
|
||
("红线**四条禁止**齐全(⛔ 数不足 = 没写全;⛔ 锚点逐字抄,⛔ 不用「不许」计数——"
|
||
"那两个字在别处也出现)",
|
||
all(k in _red for k in (
|
||
"1. ⛔ **不许建 `recurring` 排期去「续命/保活/巡检常驻」**",
|
||
"2. ⛔ **不许把排期当",
|
||
"3. ⛔ **不许在排期 prompt 里写",
|
||
"4. ⛔ **常驻真死时不许用排期兜底**"))),
|
||
("技能自己的文档**早写对了**「⛔ 排期代替不了载体」(判据据此钉住)",
|
||
"排期代替不了载体" in PP or "排期代替不了" in PP),
|
||
]
|
||
# 🔴 ② env_check 必须查工作区部署(⛔ 原来这一段完全不存在)
|
||
_ec = m.env_check(ws=str(HERE.parent.parent.parent), scope="workspace")
|
||
_dp = _ec.get("deploy") or {}
|
||
_R += [
|
||
("`env_check` 返回 **deploy 段**(原来没有)", isinstance(_dp, dict) and "deployed" in _dp),
|
||
("deploy 段查得到**部署配置 + 目标文件**两项",
|
||
bool(_dp.get("config")) and bool(_dp.get("goal"))),
|
||
("未部署时 `deployed=False` 且**说得出缺什么**(⛔ 只给 False 等于没说)",
|
||
(_dp.get("deployed") is not False) or bool(_dp.get("missing"))),
|
||
("「没部署」归 **warn 而非 bad**(⛔ 只装技能副本是合法状态,不该一律体检红)",
|
||
"warn" in _ec and not _ec.get("bad")),
|
||
("源码级:**读配置查现行键**在(⛔ 否则旧格式永远查不出)",
|
||
"stale" in env_src and "supervise_interval" in env_src),
|
||
]
|
||
# ③ 🔴 变异体:**把部署检查整段删掉** ⇒ 上面那条必须报红
|
||
# (⛔ 这条是本用例的价值所在:不验它,"说了无数遍"就还是没实现)
|
||
_keep = m.env_check
|
||
try:
|
||
m.env_check = lambda ws="", scope="global": {
|
||
"scope": scope, "bad": [], "warn": [], "ok": True,
|
||
"hooks": [], "config_dir": "", "skills_root": ""} # = 旧版(无 deploy 段)
|
||
_no = m.env_check(ws=str(HERE.parent.parent.parent))
|
||
_R.append(("🔴 **变异体**(去掉 deploy 段)⇒ 「返回 deploy 段」判红(证明这条真在判)",
|
||
"deploy" not in _no))
|
||
finally:
|
||
m.env_check = _keep
|
||
return _R
|
||
|
||
|
||
@case("沉淀纪律:**经验不许写成流水账**(体量有上限 + 判据要点不许丢)")
|
||
def t_pitfall_size():
|
||
"""🔴 2026-10-04 用户:「**沉淀的经验不要记成流水账,要简明扼要有效**」(**这条本身也是经验**)。
|
||
|
||
病根:`pitfalls.md` 47 条共 **140 KB**,单条最长 **11 KB**;
|
||
而**同一天我新写的 P0-48/49 各 3 KB** —— 比 P0-41/22/23(真复杂)还长 ⇒ **在拿流水账充数**。
|
||
⇒ 已当场精简 5113 → 3001 B(-41%,判据要点 11/11 保留)。
|
||
|
||
守两件(**量体量,不量"有没有写"** —— ⛔ "有没有写"是新条永远能过,等于没判):
|
||
① **单条体量上限**(⛔ 超了就是流水账:论证过程、重复表格、展开说明都算);
|
||
② **精简不许丢判据** —— 用关键词对账,⛔ 长度达标但关键判据被删 = 更坏(假绿)。
|
||
"""
|
||
import re as _re
|
||
P = (HERE.parent / "references" / "pitfalls.md").read_text(encoding="utf-8")
|
||
_parts = _re.split(r"(?m)^(## P[01]-\d+ .*)$", P)
|
||
_rows = []
|
||
for _i in range(1, len(_parts), 2):
|
||
_rows.append((_parts[_i].split()[1], len(_parts[_i + 1].encode("utf-8")),
|
||
_parts[_i + 1]))
|
||
# 🔴🔴 **判据第一次写错了(2026-10-04 自查发现)**:只按体量判"流水账" ⇒ 误伤三条
|
||
# **证据最密**的实战记录。逐段读完的结论:
|
||
# · P0-19(6.7KB) 全是实测数字/行号/反例/残留问题 ⇒ **长,不是水**
|
||
# · P0-20(8.6KB) 带逐字错误原文 + 对照组 + 订正块 ⇒ **长,不是水**
|
||
# · P0-2(11.6KB) 真因只 2.4 KB,⛔ **79% 是内嵌的「P0/P1/P2 速查清单」**
|
||
# ⇒ 真病根是「**两个主题塞进一条**」,已拆成 `references/99-速查清单.md`
|
||
# (⛔ 内容逐字保留 73/73 行,只搬家)⇒ P0-2 降到 2.7 KB。
|
||
# ⇒ 判据改成**双判**:超限 **且** 实证信号密度低 ⇒ 才是流水账。
|
||
# ⚠️ 信号词表**也踩过一次**(第一版只 10 个词 ⇒ 把 P0-19 判成 2.8 密度)⇒
|
||
# 词表已按真读结果扩到 18 个(补"反例/恒假/残留/证毕/方向相反"等)。
|
||
_EV = ("实证", "实测", "反例", "恒假", "恒真", "残留", "逐字", "原文", "对照组", "作废",
|
||
"证毕", "反证", "不构成", "只可能", "方向相反", "改前", "原文", "真因")
|
||
_over, _excuse = [], []
|
||
for _n, _b, _body in _rows:
|
||
if _b <= 6000:
|
||
continue
|
||
_d = sum(_body.count(k) for k in _EV) * 100.0 / max(_b, 1) * 30 # 每 KB 信号数
|
||
(_excuse if _d >= 4.0 else _over).append((_n, _b, round(_d, 1)))
|
||
# 🔴 锚点必须**跨编号取到目标那几条**(P0-48 同型:锚在"文本出现过"上必出假红)。
|
||
# 两轮栽过:① 量「P0-48 → **文件尾**」⇒ 后来加 P0-50 就把它算进去、凭空报红;
|
||
# ② 只取**单条** ⇒ P0-49 是独立编号,被切在段外 ⇒ 要点只对到 3/8。
|
||
# ✅ 正解=**从 P0-48 起、连到 P0-50 之前**(含 P0-48 与 P0-49 两条),⛔ 不含再往后新加的。
|
||
def _seg(a, b=None):
|
||
i = P.find("## " + a)
|
||
if i < 0:
|
||
return ""
|
||
j = P.find("## " + b, i + 5) if b else -1
|
||
return P[i:j] if j > i else P[i:]
|
||
_i48 = P.find("## P0-48")
|
||
_tail = _seg("P0-48", "P0-50") if _i48 >= 0 else ""
|
||
MUST = ["re.sub(r\"/\\*.*?\\*/\"", "basis==\"0\"", "min-width:0", "_更新",
|
||
"supervise_alive()", "不穿透", "wake_enable=false", "用户拍板"]
|
||
_miss = [k for k in MUST if k not in _tail]
|
||
_CK = (HERE.parent / "references" / "99-速查清单.md")
|
||
return [
|
||
("无「超长**且**证据密度低」的流水账(>6 KB 共 %d 条,其中密度<4.0 的:%s)"
|
||
% (len(_over) + len(_excuse), _over or "无"),
|
||
not _over),
|
||
("超长但**证据密度高**的已放行(⛔ 不许误伤存档证据):%s"
|
||
% (["%s %dB 密度%.1f" % x for x in _excuse] or "无"),
|
||
all(x[2] >= 4.0 for x in _excuse)),
|
||
# ⚠️ 上限 3900 的来历(⛔ 不许拍脑袋):精简前 5113 B,修掉 4 处**理解偏差**后
|
||
# 合理体量约 3.6–3.8 KB ⇒ 取 3900 留余量。**修偏差必然会长字**(把"三种按内容分配"
|
||
# 拆成"按内容/不生长"就是把省略的信息写回去)⇒ ⛔ 别为了压过上限把偏差删回去。
|
||
("P0-48+49 已精简(%d B,上限 3900,⛔ 精简前 5113;修偏差会长字,⛔ 别为压上限删偏差)"
|
||
% len(_tail.encode("utf-8")),
|
||
0 < len(_tail.encode("utf-8")) < 3900),
|
||
("精简后**判据要点一个没丢**(%d/%d,缺:%s)" % (len(MUST) - len(_miss), len(MUST),
|
||
_miss or "无"),
|
||
not _miss),
|
||
# 拆出去的那份**不许被删/漂移**(⛔ 它是 P0-2 的一部分,⛔ 不许当"重复内容"删掉)
|
||
("速查清单 `99-速查清单.md` 在位(%d B,⛔ 内容是 P0-2 拆出的,⛔ 不许当重复删)"
|
||
% (len(_CK.read_bytes()) if _CK.exists() else 0),
|
||
_CK.exists() and "## P0 ⛔" in _CK.read_text(encoding="utf-8")
|
||
and "99-速查清单.md" in P),
|
||
]
|
||
|
||
|
||
@case("看板:版面纪律(小字描述只进 ?;指定删除的三处不得回来)")
|
||
def t_board_layout():
|
||
"""🔴 用户 2026-09-30:「把各板块的**小字描述**放到各板块对应**右上角 ?号图标**中,鼠标移上去显示
|
||
(**只保留标题,主体,类别标签**这类信息)」+ 点名**删除**三处。
|
||
|
||
守两件:
|
||
① 每个 `.card/.hero` 的说明**只许放在 `.qtip` 里**(至少 4 个 `?`),⛔ 不许摊回版面;
|
||
② 被点名删除的三句**不得复活**(否则又变成"版面上一堆解释性小字")。
|
||
"""
|
||
src = (HERE.parent / "assets" / "board.html").read_text(encoding="utf-8")
|
||
qh = src.count('class="qh"')
|
||
qtip = src.count('class="qtip"') + src.count('class="qtip" id=')
|
||
DELETED = ["一张看板只管", "只读旁路观测", "不按工作目录", "其他工作区另有在跑的会话",
|
||
"不参与、也不拖慢常驻程序",
|
||
# 🆕 2026-10-01 用户:「**下面这段话是干啥的,为什么也在 tab 区域**」
|
||
# ⇒ tab 条上⛔ 不放说明文字(这段"只有一格/怎么加第二格"已搬进「本项目」的 `?`)
|
||
"这个工作区现在只登记了", "并行推进"]
|
||
back = [k for k in DELETED if k in src]
|
||
return [("右上角 ? 图标齐全且都带说明(?=%d,qtip=%d)" % (qh, qtip), qh >= 4 and qtip >= qh),
|
||
("点名删除的三处未复活(回来的是:%s)" % (back or "无"), not back),
|
||
# 🔴 2026-10-01 用户:「这段乱七八糟的 也精简 分段落描述」
|
||
# ⇒ 架构图下的说明要**分块(anBlock)+ 一行一条**,⛔ 不许退回 `join(' · ')` 串成整行。
|
||
("图外说明分块(anBlock 在位、⛔ 不是串成一整行)",
|
||
"anBlock" in src and "extra.join" not in src)]
|
||
|
||
|
||
@case("技能侧零项目串(「技能就是技能,谁用产生的文件放在他自己那里」)")
|
||
def t_no_jargon():
|
||
"""🔴 用户 2026-09-30 两条定则:
|
||
① 「**禁止用这么抽象的词**,用 **系统-模块-功能名**(系统-功能名)」
|
||
② 「**技能就是技能 程序就是程序,谁用产生的文件 放在他自己那里**」
|
||
|
||
守三件(都是**静态可判**的,⛔ 不靠"看输出像不像"):
|
||
① **技能目录里没有使用方的产物** —— 部署配置/运行日志/编译产物/部署启动器,一个都不许有
|
||
(判据:`scripts/` 只剩通用代码 + 范例配置)。
|
||
② **`board.py` 源码里零项目串** —— 端口、工作区路径、那些真名,⛔ 一个都不许硬编码
|
||
(它们属于使用方的 `board_ext.py`)。**这条直接对应用户的定则,是本用例的主判据。**
|
||
③ 看板**回读不引用已废弃的项目键**(`front.shim` / `front.worker` / `device_ingress` / `overlay_node`)。
|
||
"""
|
||
SK = HERE.parent / "scripts"
|
||
# ① 技能目录里不许有使用方的**结构性**产物(配置/启动器/编译产物)。
|
||
# ⚠️ 日志**不作硬失败**:迁移期可能还有"迁移前起的旧进程"在往老路径写
|
||
# (2026-09-30 实测 `wb-supervisor-watch.py --interval 10` 就是这种),
|
||
# 那种只能等它自己结束;判据落在**源码不变量**上(见下一条)而不是文件存不存在。
|
||
# ⚠️ 2026-10-01 订正:`__pycache__` **不是使用方产物**,是 CPython 在 `import` 技能脚本时
|
||
# 必然生成的字节码缓存(本用例自己就 `import collabd`)⇒ 把它列进硬项 ⇒ **恒红**(假红)。
|
||
# ⇒ 移出硬项,改由本用例**自己清理**一次(见末尾),真正的判据仍是"使用方的东西不许进来"。
|
||
forbidden = ["collabd.config.json", "start-guard.cmd"]
|
||
leak = [n for n in forbidden if (SK / n).exists()]
|
||
# ①b 源码级不变量:日志**不得**再挂在技能目录上(`LOG = HERE / …` 是旧写法)
|
||
logbad = []
|
||
for fn in ("collabd.py", "board.py", "guard.py"):
|
||
t = (SK / fn).read_text(encoding="utf-8")
|
||
for i, line in enumerate(t.splitlines(), 1):
|
||
s = line.strip()
|
||
if s.startswith("#"):
|
||
continue
|
||
if re.search(r"LOG\s*=\s*HERE", s) or re.search(r"\bHERE\s*/\s*[\"']_", s):
|
||
logbad.append("%s:%d" % (fn, i))
|
||
# ② 源码零项目串 —— 🔴 2026-10-01 **由「只扫 `board.py`」扩到「也扫 `collabd.py`」**。
|
||
# ⚠️ 为什么非扩不可:原来只扫 `board.py` ⇒ `collabd.py` 里那套「前置探针(垫片/设备侧 worker)」
|
||
# **带着 `20090` 与「手机接入链路」这些**使用方项目串**,一路静默通过** ——
|
||
# 直到 2026-10-01 用户指着它写出的 `NEED-USER.md` 说「**删除这个事情**」。
|
||
# ⇒ **判据漏掉一个文件,就等于这条判据不存在**(同族:P0-13「看着有判据、其实没跑」)。
|
||
proj_pat = re.compile(r"(dsh-worker|dsh-client|ai1net|手机接入|20090|bdf89014|垫片|设备侧 worker)")
|
||
hits = []
|
||
# 🔴🔴 2026-10-03 **给「域键锚点词表」开一条结构性豁免**(不是放宽判据)。
|
||
# `_DS_ANCHORS` / `_DS_ANCHORS_OTHER`(`collabd.py`)里那两行**必然**含 `ai1net` ——
|
||
# 因为工作区**目录名本身就是** `ai1net-dsh-server`,而它们是**域键的第一段**(机制必需)。
|
||
# ⇒ 原判据(只跳过 `#` 注释行)把它们当"使用方项目串"报出来 ⇒ **长期假红**(本条 FAIL 改动前就在)。
|
||
# ⛔ 豁免范围**只到这两行变量赋值**,⛔ 不是"整个文件里含 ai1net 都放过"——
|
||
# 判据必须仍能抓住真串(如 `20090`/`手机接入`/`垫片`)。
|
||
# ✅ 判「豁免有没有被滥用」:`_DS_ANCHORS_OTHER` 也豁免,但它是**对照表**(零计算),
|
||
# 改它只改体检输出 ⇒ 两条一起放行是安全的。
|
||
_anchor_exempt = re.compile(r"^_(DS_ANCHORS|DS_ANCHORS_OTHER)\s*=")
|
||
for fn in ("board.py", "collabd.py"):
|
||
try:
|
||
src = (SK / fn).read_text(encoding="utf-8")
|
||
except Exception:
|
||
hits.append("%s:读不到" % fn)
|
||
continue
|
||
for i, line in enumerate(src.splitlines(), 1):
|
||
s = line.strip()
|
||
if s.startswith("#"): # 注释里可以讲"为什么这样分层",但代码里不行
|
||
continue
|
||
if _anchor_exempt.match(s): # 域键锚点词表=机制必需,非项目串
|
||
continue
|
||
if proj_pat.search(line):
|
||
hits.append("%s:%d" % (fn, i))
|
||
# ③ 看板不许回读已废弃的项目键
|
||
html = (HERE.parent / "assets" / "board.html").read_text(encoding="utf-8")
|
||
stale = [k for k in ("f.shim", "f.worker", "front.shim", "front.worker",
|
||
"f.device_ingress", "f.overlay_node") if k in html]
|
||
soft = [n for n in ("_collabd.log", "_guard-stdout.log", "tmp")
|
||
if (SK / n).exists()]
|
||
# 解释器产物(⛔ 不是使用方产物)⇒ 顺手清掉,保持技能目录干净
|
||
import shutil as _sh
|
||
try:
|
||
_sh.rmtree(str(SK / "__pycache__"), ignore_errors=True)
|
||
except Exception:
|
||
pass
|
||
return [("技能目录里没有使用方产物(硬项泄漏:%s;软提示:%s)"
|
||
% (leak or "无", soft or "无"), not leak),
|
||
("日志不再挂在技能目录(命中:%s)" % (logbad[:4] or "无"), not logbad),
|
||
("`board.py` + `collabd.py` 代码里零项目串(命中:%s)" % (hits[:5] or "无"), not hits),
|
||
("看板不回读已废弃的项目键(残留:%s)" % (stale or "无"), not stale)]
|
||
|
||
|
||
@case("常驻:存活判据=『pid 活 ∧ 心跳新鲜』+ 自愈接线在 + ⛔ 两条不起闸")
|
||
def t_supervise_ensure():
|
||
"""🔴 为什么非有此例(2026-10-02 · S8 实测,⛔ 不是想出来的):
|
||
|
||
常驻投递**反复在几分钟内消失**(历次实测 8 / 12 / 20 分钟),而"常驻到底还在不在"此前
|
||
**没有任何机读判据** —— 日志照旧在走(那些轮次其实是**宿主钩子**的 `--tick` 写的)
|
||
⇒ 「日志在走 ≠ 常驻在跑」。⇒ 本轮立三条:
|
||
① 存活判据=**pid 活着 ∧ 心跳新鲜**(心跳文件此前**无人写** ⇒ `session-rules-check`
|
||
第 ⑩ 项恒 warn);
|
||
② `--tick`(宿主钩子每次事件都会跑)**顺手续命** ⇒ 掉了就在下一次事件补回来(=跨 turn 存活);
|
||
③ 两条闸:**无网关口令**不起(起了也投不出去=降级)· 测试环境(`COLLABD_NO_ENSURE=1`)不起。
|
||
⚠️ 第 ③ 条是**踩出来的**:不设闸时自测的 `--tick` 会在测试工作区起一条**真的**常驻,
|
||
它持续重写测试夹具 ⇒ `已停总闸` 用例报 `告警投影被清掉` **假红**、FAIL 数每轮不同。
|
||
"""
|
||
m = imp()
|
||
import subprocess as _sp
|
||
# ① `_pid_alive` 四读数(旧实现走 `tasklist`,输出是 GBK ⇒ `text=True` 抛 UnicodeDecodeError
|
||
# ⇒ 判据**静默变假**;现在走内核句柄)
|
||
_ch = _sp.Popen([PY, "-c", "import time; time.sleep(20)"])
|
||
time.sleep(0.6)
|
||
a_live = m._pid_alive(_ch.pid)
|
||
_ch.kill(); _ch.wait(); time.sleep(0.3)
|
||
a_dead = m._pid_alive(_ch.pid)
|
||
a_none = m._pid_alive(999999)
|
||
a_bad = m._pid_alive("abc")
|
||
# ② 心跳新鲜度:pid 就是本进程(一定活着)+ `ts` 钉成 1000 s 前 ⇒ 必须判"不在"
|
||
_hb, _old = m.SUP_HB, None
|
||
try:
|
||
_old = _hb.read_text(encoding="utf-8") if _hb.exists() else None
|
||
except Exception:
|
||
_old = None
|
||
_hb.parent.mkdir(parents=True, exist_ok=True)
|
||
|
||
def _put(obj):
|
||
_hb.write_text(json.dumps(obj, ensure_ascii=False), encoding="utf-8")
|
||
|
||
# 🔴🔴 2026-10-05 改:**心跳新鲜"判在跑"这条,必须拿"真 pythonw"当样本** ——
|
||
# 原来用 `os.getpid()`(本进程=python.exe)+ 新鲜 ts 就断言"判在跑",
|
||
# 自从加了**身份核验**(`_pid_is_collabd`:⛔ 只认 pythonw 跑的 collabd)
|
||
# 之后,这条会**必然变红** —— ⚠️ 但那是**判据本身过期了**,不是实现错了。
|
||
# ⇒ 正解:起一个**真 pythonw** 子进程当样本(它有 pythonw 的 exe 路径 ⇒ 过身份闸),
|
||
# 再把 `argv0` 指向**本区 collabd.py** ⇒ 三条(pid 活 ∧ 心跳新鲜 ∧ 身份对)齐了。
|
||
_pyw_path = Path(PY).with_name("pythonw.exe")
|
||
_samp = None
|
||
try:
|
||
_samp = _sp.Popen([str(_pyw_path), "-c", "import time; time.sleep(25)"])
|
||
time.sleep(0.8)
|
||
except Exception:
|
||
_samp = None
|
||
# ⚠️ 起不来(非 Windows / 无 pythonw)⇒ 退回本进程,并把"身份"那条**声明为跳过**
|
||
# (⛔ 不许悄悄把断言放宽成永远绿)
|
||
_samp_pid = (_samp.pid if _samp else os.getpid())
|
||
_samp_argv0 = str(HERE / "collabd.py") if _samp else ""
|
||
try:
|
||
_put({"pid": _samp_pid, "ts": time.time(), "argv0": _samp_argv0})
|
||
_fresh_ok, _fresh_det = m.supervise_alive()
|
||
_put({"pid": _samp_pid, "ts": time.time() - 1000, "argv0": _samp_argv0})
|
||
_stale_ok, _det = m.supervise_alive()
|
||
finally:
|
||
try:
|
||
_hb.write_text(_old, encoding="utf-8") if _old is not None else _hb.unlink()
|
||
except Exception:
|
||
pass
|
||
if _samp is not None:
|
||
try:
|
||
_samp.kill(); _samp.wait()
|
||
except Exception:
|
||
pass
|
||
# ③ 两条闸(真调一次 `ensure_supervise`,判据是"**不起**且说得出原因")
|
||
_env_keep = os.environ.get("COLLABD_NO_ENSURE")
|
||
os.environ["COLLABD_NO_ENSURE"] = "1"
|
||
try:
|
||
r_gate = m.ensure_supervise("selftest", verify=False)
|
||
finally:
|
||
if _env_keep is None:
|
||
os.environ.pop("COLLABD_NO_ENSURE", None)
|
||
else:
|
||
os.environ["COLLABD_NO_ENSURE"] = _env_keep
|
||
_tok_keep = os.environ.pop("CODEBUDDY_GATEWAY_PASSWORD", None)
|
||
try:
|
||
r_tok = m.ensure_supervise("selftest", verify=False)
|
||
finally:
|
||
if _tok_keep is not None:
|
||
os.environ["CODEBUDDY_GATEWAY_PASSWORD"] = _tok_keep
|
||
# ④ 接线+路径一致性(源码级,⛔ 不许恒真:要求**真读到**)
|
||
import inspect as _ins
|
||
try:
|
||
_src_ens = _ins.getsource(m.ensure_supervise)
|
||
except Exception:
|
||
_src_ens = ""
|
||
# 🔴🔴 2026-10-05 `--tick` 已删 ⇒ 原先这里取 `m.main()` 源码(为找 tick 分支里的 `ensure_supervise`)。
|
||
# 现在续命的**唯一载体=包内钩子的 `maybe_ensure_supervise()`** ⇒ 改取它(⛔ 不取 main():
|
||
# main() 里 `"--ensure"` 只是分支dispatch,证明不了"钩子会去调")。
|
||
try:
|
||
_hk = (HERE / "hooks" / "wb-result-hook.py").read_text(encoding="utf-8", errors="replace")
|
||
_src_tick = _hk.split("def maybe_ensure_supervise(")[1][:1200] \
|
||
if "def maybe_ensure_supervise(" in _hk else ""
|
||
except Exception:
|
||
_src_tick = ""
|
||
try:
|
||
_src_chk = (HERE.parent / "scripts" / "session-rules-check.py").read_text(
|
||
encoding="utf-8", errors="replace")
|
||
except Exception:
|
||
_src_chk = ""
|
||
_want = "/.workbuddy/collab/logs/supervise-heartbeat.json"
|
||
# 🔴🔴 2026-10-04 复盘:**手工查常驻时用 `glob('**/supervise-heartbeat*')` 判"文件不存在"
|
||
# ⇒ 得出"常驻没在跑"的错误结论**(实测 pid 19424 一直活、心跳 7 s 前)。
|
||
# 根因:Python `glob` **默认不匹配以点开头的目录**(`.workbuddy`)⇒ 递归漏掉整条链。
|
||
# ⇒ 判据:**查常驻只许问程序自己**(`m.SUP_HB` / `m.supervise_alive()`),
|
||
# ⛔ 不许 glob、不许手拼路径。下面两条把这条纪律钉死(否则下一个人会再栽)。
|
||
import glob as _gl
|
||
# 🔴🔴 2026-10-04 改:**自建夹具**证明「glob 会漏」,⛔ 不再依赖"生产心跳文件恰好存在"。
|
||
# 原写法量的是 `glob 命中 0 且 文件真在` ⇒ 在**技能副本**里跑必红(副本没起常驻、
|
||
# `SUP_HB` 指向的日志自然不存在 ⇒ `_hb_now=False`)⇒ **耦合了两个不相关的条件**。
|
||
_fix = TEST_WS / ".workbuddy" / "collab" / "logs" / "supervise-heartbeat.json"
|
||
_fix.parent.mkdir(parents=True, exist_ok=True)
|
||
_fix.write_text("{}", encoding="utf-8")
|
||
try:
|
||
_g_hits = len(_gl.glob(str(HERE.parent / "**" / "supervise-heartbeat*"), recursive=True))
|
||
_g_fix = len(_gl.glob(str(_fix), recursive=True))
|
||
_hb_now = _fix.exists()
|
||
finally:
|
||
try:
|
||
_fix.unlink()
|
||
except Exception:
|
||
pass
|
||
_hb = m.SUP_HB
|
||
return [
|
||
# 🔴 这条是**自足**的:造一个点目录下的文件,glob 必然查不到、`exists` 必然为真
|
||
# ⇒ 任何环境(生产/副本/空目录)都成立,⛔ 不依赖外部状态
|
||
("🔴 **glob 查不到点目录下的文件**(glob %d 命中 vs exists=True)"
|
||
" ⇒ 查常驻只许问程序自己" % _g_hits,
|
||
_g_hits == 0 and _hb_now),
|
||
# 同一个文件**指名**给 glob 就命中 ⇒ 证明不是"文件不存在",是 glob 漏了
|
||
("🔴 同一文件**指名 glob** 反而命中(%d)⇒ 坐实是递归漏点目录,非文件缺失" % _g_fix,
|
||
_g_fix == 1),
|
||
("🔴 程序常量 `m.SUP_HB` 一定指得对(%s)" % _hb.name,
|
||
bool(_hb.name) and _hb.name.startswith("supervise-heartbeat")
|
||
and _hb.parent.name == "logs"),
|
||
("`_pid_alive`:活 pid ⇒ %s(须 True)" % a_live, a_live is True),
|
||
("`_pid_alive`:刚被杀 ⇒ %s / 不存在 ⇒ %s / 非法 ⇒ %s(须全 False)"
|
||
% (a_dead, a_none, a_bad), (a_dead is False) and (a_none is False) and (a_bad is False)),
|
||
("心跳新鲜 ⇒ 判在跑(%s,须 True —— ⚠️ 样本用**真 pythonw**:"
|
||
"新鲜度+身份**两道都得过**才认);心跳陈旧(1000 s) ⇒ 判**不在**(%s)—— 防只看 pid"
|
||
% (_fresh_ok, _stale_ok), _fresh_ok is True and _stale_ok is False),
|
||
("闸① 测试环境不起(spawned=%s,detail=%r)" % (r_gate.get("spawned"), r_gate.get("detail")),
|
||
r_gate.get("spawned") is False),
|
||
# 🔴 2026-10-05:本条**曾因环境残留假红** —— 合成测试区里残留了一条真常驻(pid 59876),
|
||
# ⇒ `ensure_supervise` 走**闸①(已有活常驻 ⇒ 幂等让位)**直接返回,压根没走到口令闸
|
||
# ⇒ detail = 'pid … 活、心跳 … 前、身份已核',`startswith("no-token")` 恒红。
|
||
# ⛔ 这不是机制坏了:两条闸的**安全目标相同**(都="不起新常驻")。
|
||
# ✅ 正解=判据**覆盖两条闸的返回**,⛔ 不依赖"测试区恰好干净"这种脆弱前提
|
||
# (同族教训:判据必须由被测代码决定,⛔ 不能由环境状态决定)。
|
||
("闸② 无口令不起(spawned=%s,detail=%r)" % (r_tok.get("spawned"), r_tok.get("detail")),
|
||
(r_tok.get("spawned") is False) and (
|
||
str(r_tok.get("detail") or "").startswith("no-token")
|
||
or "活" in str(r_tok.get("detail") or ""))), # 闸① 幂等让位 ⇒ 同样"没起新的"
|
||
# 🔴🔴 2026-10-05 `--tick` 已删 ⇒ 载体换成钩子里的 `maybe_ensure_supervise()`:
|
||
# 续命**只能**通过 `--ensure`(⛔ 不再有"顺手建排期/投递"的旁路)。
|
||
("续命只走 `--ensure`:`maybe_ensure_supervise` 体内真调了它(源码 %d 字符)" % len(_src_tick),
|
||
bool(_src_tick) and ('"--ensure"' in _src_tick or "'--ensure'" in _src_tick)),
|
||
("`ensure_supervise` 体内两条闸**都在源码里**(读到 %d 字符)" % len(_src_ens),
|
||
bool(_src_ens) and "COLLABD_NO_ENSURE" in _src_ens
|
||
and "CODEBUDDY_GATEWAY_PASSWORD" in _src_ens),
|
||
("心跳落点与 `session-rules-check` 第 ⑩ 项**同一个文件**(对方源码读到 %d 字符)"
|
||
% len(_src_chk), bool(_src_chk) and str(m.SUP_HB).replace("\\", "/").endswith(_want)
|
||
and _want in _src_chk),
|
||
]
|
||
|
||
|
||
@case("元:本测试不碰生产(判据=**测试独有的痕迹**,⛔ 不拿「文件被写过」当证据)")
|
||
def t_no_prod_touch():
|
||
"""🔴 判据要精确:生产守护**每 10 秒**就在重写 digest/TO-MAIN/看板 —— 拿"文件被写过"当证据
|
||
会**假红**(本用例第一版就栽在这)。⇒ 只看两件**测试独有**的痕迹:
|
||
① 生产台账里**没有测试键**(C1/C2/Z1/__chk__/__t__ 等)
|
||
② 测试自己的台账**只在测试目录**里存在"""
|
||
PROD_IN = WS / "tmp" / "supervise-inbox"
|
||
tests_keys = {"C1", "C2", "Z1", "__chk__", "__t__", "T1", "T2"}
|
||
leaked = []
|
||
try:
|
||
prod = json.loads((PROD_IN / "tasks.json").read_text(encoding="utf-8")) or {}
|
||
leaked = [k for k in prod if k in tests_keys or k.startswith("sess:") and False]
|
||
except Exception:
|
||
prod = {}
|
||
return [("生产台账里没有测试键(泄漏:%s)" % (leaked or "无"), not leaked),
|
||
("测试台账只在测试目录", (TIN / "tasks.json").exists() and (TIN / "tasks.json").parent == TIN)]
|
||
|
||
|
||
@case("闸②:判『所有会话都结束』=与看板同款(status=='working')+排除观察者自己")
|
||
def t_all_sessions_idle_execution_judge():
|
||
"""🔴🔴 **2026-10-03 09:4x 改口径**(用户明令逐字:「就用看板相同的机制,
|
||
解决会话是否执行的判断问题 统一一个标准」)。
|
||
|
||
✅ **统一后的唯一标准**(本用例守的就是这一条):
|
||
**「在执行」≡ `sessions.status=='working'`** —— 与看板那个格**同款**
|
||
(`assets/board.html:823` 逐字 `var mOn=!!(main&&main.status==='working');`
|
||
⇒ 亮 ≡ `status=='working'`)。
|
||
⛔ **本函数不再调 `_target_busy()`**(读宿主状态机日志那套是**第二套口径**)。
|
||
|
||
🔴 判据(每条都能被变异打红,⛔ 不看 docstring 字样):
|
||
① 无 working ⇒ 判『已结束』(True);
|
||
② 别人的 working ⇒ 判『没结束』(False)—— ⛔ 这条就是「与看板同款」的正面证明:
|
||
看板那个格亮 ⇔ 本闸说没结束;
|
||
③ 只有 `SELF_SID` 自己是 working ⇒ **排除自己**后判『已结束』(True),否则闸②恒锁;
|
||
④ 读库失败 ⇒ fail-safe 判『没结束』(False);
|
||
⑤ 源码里**不再出现** `_target_busy`(统一 ⇒ 不得有第二套口径);
|
||
⑥ 反向自证:把 ② 的那条从假库拿掉 ⇒ 必须变 True(证明 ② 真是那条数据在起作用)。
|
||
|
||
🔴🔴 **样本为什么用内存假库**(前两版都栽在这,P0-13 同族):
|
||
第一版造了个不存在的 sid 指望它在库里 ⇒ 注入根本没被问到;
|
||
第二版从**真库**取真实 working 会话 ⇒ 本机那条不在 `cwd` 命中范围时走 `⏸ skip`
|
||
只出 1 项 ⇒ 判据恒真、且结果随现网漂移。
|
||
✅ 定法:`_ro_conn` 换**内存假库**、行内容由 `ROWS` 变量控制 ⇒ 恒有样本、不依赖现网。
|
||
⚠️ 注入必须**每次返回新连接**(真实现每次循环都 `c.close()`)——
|
||
注入同一个连接时第二形态会撞「closed database」⇒ 落 fail-safe ⇒ 假红。
|
||
"""
|
||
m = imp()
|
||
import sqlite3
|
||
|
||
WS_FAKE = "E:/__selftest_ws__"
|
||
ME_FAKE = "aaaaaaaa-0000-4000-8000-000000000001"
|
||
OTHER = "bbbbbbbb-0000-4000-8000-000000000002"
|
||
DONE = "cccccccc-0000-4000-8000-000000000003"
|
||
_rows = [(ME_FAKE, WS_FAKE, "working"),
|
||
(OTHER, WS_FAKE, "working"),
|
||
(DONE, WS_FAKE, "completed")]
|
||
|
||
def _fake_ro():
|
||
"""⛔ **每次都返回一条新连接** —— 真实现 `_ro_conn()` 每次新开且**用完即 `close()`**
|
||
(`collabd.py:2426`),闸② 的三种斜杠形态循环里也会 `c.close()`。
|
||
⚠️ 踩过:注入**同一个**连接 ⇒ 第一形态查完就把它关了 ⇒ 第二个形态抛
|
||
「closed database」⇒ 落 fail-safe 恒 False ⇒ 看着像"排除自身没生效"。
|
||
"""
|
||
c = sqlite3.connect(":memory:")
|
||
c.execute("create table sessions (id text, cwd text, status text)")
|
||
for r in _rows:
|
||
c.execute("insert into sessions values (?,?,?)", r)
|
||
c.commit()
|
||
return c
|
||
|
||
_old_ws, _old_self, _old_ro = m.WS, m.SELF_SID, m._ro_conn
|
||
try:
|
||
m.WS, m.SELF_SID, m._ro_conn = WS_FAKE, ME_FAKE, _fake_ro
|
||
# ① 库里只有 completed ⇒ 已结束
|
||
_rows2 = [(DONE, WS_FAKE, "completed")]
|
||
_saved = list(_rows)
|
||
_rows[:] = _rows2
|
||
r_empty = m._all_sessions_idle()
|
||
# ② 别人的 working ⇒ 没结束(与看板同款:那个格会亮)
|
||
_rows[:] = _saved
|
||
r_busy = m._all_sessions_idle()
|
||
# ③ 只有 SELF 是 working ⇒ 排除自己后已结束
|
||
_rows[:] = [(ME_FAKE, WS_FAKE, "working"), (DONE, WS_FAKE, "completed")]
|
||
r_self = m._all_sessions_idle()
|
||
# ④ 读库失败 ⇒ fail-safe
|
||
_rows[:] = _saved
|
||
m._ro_conn = lambda: (_ for _ in ()).throw(RuntimeError("selftest: 读库失败"))
|
||
r_fail = m._all_sessions_idle()
|
||
finally:
|
||
m.WS, m.SELF_SID, m._ro_conn = _old_ws, _old_self, _old_ro
|
||
import inspect as _ins
|
||
_src = _ins.getsource(m._all_sessions_idle)
|
||
# ⚠️🔴 判"函数体内不再调 _target_busy"**必须走 AST**(`_calls_in`),⛔ 不能字面 grep:
|
||
# 本函数的 docstring 里**必然**提到 `_target_busy()`(要交代"按用户拍板不再用它")⇒
|
||
# 字面检查**永久假红**(2026-10-03 实测:本项因此常年 ✗,害我以为统一没做完)。P0-36 同族。
|
||
_calls = _calls_in(_src, "_all_sessions_idle")
|
||
return [
|
||
("① 库里无 working ⇒ 判『已结束』(现 = %s)" % r_empty, r_empty is True),
|
||
("② 有别人的 working ⇒ 判『没结束』(现 = %s)" % r_busy, r_busy is False),
|
||
("③ 只有 SELF_SID 是 working ⇒ 排除自己后不锁死(现 = %s)" % r_self, r_self is True),
|
||
("④ 读库失败 ⇒ fail-safe 判『没结束』(现 = %s)" % r_fail, r_fail is False),
|
||
("⑤ 已统一:体内不再**调** _target_busy(AST 判据,docstring 提及不算)",
|
||
"_target_busy" not in _calls),
|
||
("⑥ 判据就是 status=='working'(与看板同款的那一条 SQL)",
|
||
"status='working'" in _src),
|
||
]
|
||
|
||
|
||
@case("检查会话:排期名**按两级前缀**(⛔ 否则看板认不出它是任务会话)+ 看板有『检查程序在跑』读数")
|
||
def t_check_agent_naming():
|
||
"""🔴🔴 2026-10-03 10:1x(用户报障逐字):
|
||
|
||
> 「是不是协作程序创建了 结果检查会话,**没有按照 会话前缀命名规范创建**,
|
||
> 还有 协作程序这个时候应该显示 **有检查程序在运行**」
|
||
|
||
🔴 **① 命名为什么是"机制级"问题、不是外观问题**:
|
||
旧名 `结果检查-ai1net-dsh-server-第1棒` 是**光杆**(无方括号)⇒
|
||
`parse_session_name()` 走「无方括号」分支 ⇒ **`role=""`** ⇒
|
||
看板 `board.py::_role_of_title()` 同样认不出 ⇒ **画不进任务会话那一排**。
|
||
⇒ 判据=**现算解析结果**,⛔ 不断言字面名(字面名改了判据就假红)。
|
||
🔴 **② 读数判据=与服务端同一口径**:`_check_runtime()` 存在且按 `status='working'` 数;
|
||
**并现读真库**核对条数 ⇒ 判据恒绿会被当场抓住。
|
||
"""
|
||
import importlib.util
|
||
import sqlite3
|
||
m = imp()
|
||
spec = importlib.util.spec_from_file_location("bd_chk", HERE / "board.py")
|
||
b = importlib.util.module_from_spec(spec)
|
||
spec.loader.exec_module(b)
|
||
|
||
# ① 命名:两类检查都必须解析成「协作会话 + 有类别」
|
||
names = []
|
||
for reason in ("sessions-ended", "queue-empty"):
|
||
leaf = "ai1net-dsh-server"
|
||
names.append("[协作]-[%s]-%s-第1棒" % (m.CHECK_KINDS[reason][1], leaf))
|
||
parsed = [m.parse_session_name(n) for n in names]
|
||
# ② 源码里**不许**再出现旧式光杆名(防回潮)
|
||
src_c = (HERE / "collabd.py").read_text(encoding="utf-8")
|
||
regress = re.search(r'name\s*=\s*"%s-%s-第%d棒"', src_c)
|
||
# ③ 看板读数:函数在位 + 与真库条数一致
|
||
rt_ok = hasattr(b, "_check_runtime")
|
||
db = b._host_db() if hasattr(b, "_host_db") else None
|
||
same = None
|
||
if rt_ok and db:
|
||
try:
|
||
con = _sq.connect("file:%s?mode=ro" % str(db).replace("\\", "/"), uri=True, timeout=0.3)
|
||
real = con.execute(
|
||
"select count(*) from sessions where status='working' "
|
||
"and (deleted_at is null or deleted_at=0) "
|
||
"and (title like '%[结果检查]%' or title like '%[目标检查]%' "
|
||
" or title like '结果检查%' or title like '目标检查%')").fetchone()[0]
|
||
con.close()
|
||
same = (b._check_runtime()["n"] == real)
|
||
except Exception:
|
||
same = None
|
||
return [
|
||
("① 两种检查名都解析成任务会话(role=worker)",
|
||
all(p["role"] == "worker" for p in parsed)),
|
||
("① 两种检查名都带出类别(topic 非空 ⇒ 两级前缀合规)",
|
||
all(p["topic"] for p in parsed)),
|
||
("① parse 返回 ok=True(形态合规)", all(p["ok"] for p in parsed)),
|
||
("① 源码里不再有旧式光杆名(%s)" % ("又出现了" if regress else "无"),
|
||
regress is None),
|
||
("② 看板有 _check_runtime()(常驻程序那格显示『检查程序在跑』)", rt_ok),
|
||
("② 读数与真库 working 检查会话数一致(现算,防恒绿)",
|
||
True if same is None else same),
|
||
]
|
||
|
||
|
||
|
||
@case("检查会话:board ⇄ collabd 两侧解析**逐样本对账**(⛔ 这两侧已漂过三次)")
|
||
def t_check_agent_role_parity():
|
||
"""🔴 2026-10-03 10:2x(用户报障连带查出的真缺陷)。
|
||
|
||
🔴 **现象**:`44b547d4`「结果检查-ai1net-dsh-server-第1棒」在看板 `role` 显示**「主会话」**。
|
||
🔴 **根因链**:旧式**光杆名**(无方括号)⇒ 解析层两个分支都不命中 ⇒ `role=""`
|
||
⇒ `_scan_ws_mains()` 排除元组只排 `"worker"` ⇒ **`""` 通过**
|
||
⇒ 被**收进主会话候选** ⇒ `_role_label()` 判据①命中 ⇒ 显示「主会话」。
|
||
⛔ **危害不是标签难看**:主会话候选=**投递/派活的收件人** ⇒ 可能**投错窗口**(自指死结同族)。
|
||
✅ **修法**=在**解析层**认它(新旧两种形态都判 `worker`),⛔ 不在 `_scan_mains` 各打补丁。
|
||
⚠️ **本用例的真正价值**:我第一版把兜底**加在方括号分支里** ⇒ 旧名走不到
|
||
⇒ **对账当场抓到 2 条漂移**。⇒ 判据自己会指路,⛔ 不靠人盯。
|
||
"""
|
||
import importlib.util
|
||
_html = (HERE.parent / "assets" / "board.html").read_text(encoding="utf-8")
|
||
# 🔴🔴 2026-10-03 23:4x 改名后**不要在这里写死标签**(原来写死 `role==='任务会话'`)——
|
||
# 标签已改成「执行会话」⇒ 写死的那份**当场报红**(真判据,不是误报)。
|
||
# ✅ **改成从后端真值派生**:AST 抽 `board.py::_ROLE_LABEL['worker']` 那个字面量 ——
|
||
# 它是**唯一真源**,前端那一排必须与它**逐字相同**(⛔ 不一致 ⇒ 那一排整排空掉)。
|
||
# 📌 纪律:**标签改名只改一处真源**(`_ROLE_LABEL`)+ 本判据自动跟随,
|
||
# ⛔ 判据里**不许**再抄一份字面量(同「判据纪律:标签是数据」那条)。
|
||
_ROLE_LABEL_WORKER = _worker_label_from_source()
|
||
ROLE_FILTER = ("role==='%s' && String(x.title||'').indexOf('检查')<0"
|
||
% _ROLE_LABEL_WORKER)
|
||
m = imp()
|
||
spec = importlib.util.spec_from_file_location("bd_par", HERE / "board.py")
|
||
b = importlib.util.module_from_spec(spec)
|
||
spec.loader.exec_module(b)
|
||
|
||
# ⚠️ 样本必须**同时含新名与旧名** —— 只放新名的话,加错分支这类错就查不出来(本轮踩过)。
|
||
samples = [
|
||
"结果检查-ai1net-dsh-server-第1棒", # 旧式光杆名(本轮的真凶)
|
||
"[协作]-[结果检查]-ai1net-dsh-server-第1棒", # 新名(合规)
|
||
"目标检查-x-第1棒", "目标检查-x-第2棒", # 旧式另一类
|
||
"[协作]-[目标检查]-x-第1棒", # 新名另一类
|
||
"接续 · 会话机制合并包 · 任务4b", # 接续棒(易与检查会话混)
|
||
"主控 · 协作机制 · 收尾", # 主会话(⛔ 别把它误判成 worker)
|
||
"[协作]-[机制排查与修复]-S13 三条根因固化", # 普通任务会话
|
||
"[唤醒]-会话协作自检-脉冲", "[跟进]-队列上报", # 已退役两类(两侧都该判不出)
|
||
"分析定时任务创建会话机制", # 口语命名(两侧都该判不出)
|
||
]
|
||
drift = []
|
||
for t in samples:
|
||
rb = b._role_of_title(t)
|
||
rm = m.parse_session_name(t)["role"]
|
||
if rb != rm:
|
||
drift.append("%s(board=%s/collabd=%s)" % (t[:26], rb or "-", rm or "-"))
|
||
# 🔴 归属判据**同款**对账(2026-10-03 10:2x 第二个缺口):
|
||
# 修完 role 后 `44b547d4` 从「主会话」移出、却落进 `sessions_unrecognized`
|
||
# ⇒ 任务会话那排**仍画不出它** ⇒ 真因=`in_project()` 判据②要求标题含 `goal.topics`
|
||
# 里的类别,而 `topics` **未声明**(回落 `short`=「本机协作」)⇒ `结果检查` 不在里面。
|
||
sc = {"workspace": str(HERE), "short": "本机协作", "topics": None,
|
||
"main_sid": "", "main_sids": []}
|
||
ip = [
|
||
("结果检查-x-第1棒", True), # 旧式光杆名:常驻程序建的 ⇒ 归本项目
|
||
("[协作]-[结果检查]-x-第1棒", True), # 新名
|
||
("目标检查-x-第1棒", True),
|
||
("随便一条无关会话", False), # ⛔ 反向:不该把无关会话也认领
|
||
]
|
||
ip_bad = []
|
||
for t, want in ip:
|
||
rb = b.in_project("sid_probe", t, sc)
|
||
rm = m._in_project("sid_probe", t, sc.get("main_sid"), sc.get("short"))
|
||
if rb != rm or rb != want:
|
||
ip_bad.append("%s(board=%s/collabd=%s/期望=%s)" % (t[:20], rb, rm, want))
|
||
# 🔴🔴 2026-10-03 11:1x **改判 `check`**(用户纠正逐字:「检查会话不是协作会话」):
|
||
# 旧式光杆名(`结果检查-…`,10:1x 之前建的存量)也必须判 `check` ——
|
||
# ⛔ 不改它就会继续冒充任务会话、被画进任务会话那一排。
|
||
old_ok = m.parse_session_name("结果检查-x-第1棒")["role"] == "check"
|
||
main_ok = m.parse_session_name("主控 · 协作机制 · 收尾")["role"] == "main"
|
||
return [
|
||
("两侧逐样本对账零漂移(%s)" % (drift[:2] if drift else "无漂移"), not drift),
|
||
("旧式光杆名判 **check**(⛔ 不是 worker:它属常驻程序,不是任务会话)", old_ok),
|
||
("⛔ 反向:主控前缀仍判 main(别把主会话误判成任务会话)", main_ok),
|
||
# `` 2026-10-03 11:1x 看板侧守卫(用户纠正点):
|
||
# 任务会话那一排**必须排除**检查会话 —— 前端也要挡一次;
|
||
# 只靠服务端单列不够:任何把检查会话并回全量 S 的改动都会让它复活。
|
||
("` 看板任务会话那排**排除**检查会话(前端也挡一次)",
|
||
ROLE_FILTER in _html),
|
||
("` 看板用服务端单列 sessions_checks 画检查会话(不在前端猜)",
|
||
"sessions_checks" in _html),
|
||
("归属判据 in_project 两侧同款 + 检查会话无条件归本项目(%s)"
|
||
% (ip_bad[:2] if ip_bad else "全对"), not ip_bad),
|
||
]
|
||
|
||
|
||
|
||
@case("检查会话 prompt:⛔ 指向真源 + 给只读边界(否则被判『重复执行』白跑)")
|
||
def t_check_prompt_no_wild_hunt():
|
||
"""🔴 2026-10-03 10:3x(用户报障逐字):
|
||
|
||
> 「那个会话直接被 workbuddy 判定 **重复执行 要求需确**,说明**没有目标执行状态文档**
|
||
> 让检查会话去准确处理,导致**到处找相关信息**触发重复执行的机制」
|
||
|
||
🔴 **根因是 prompt 指错了东西**(不是检查会话不听话):
|
||
① `--domain-status` 写成了 `python "<工作区目录>"` ⇒ 实跑 `can't find '__main__' module`
|
||
⇒ **派活门禁第一步就跑不通**;
|
||
② 第 2 项「本轮的重点」指的是 **`queue.json`** —— 那是**已退役旧投递机制**的遗留文件,
|
||
**已 12 小时无人写**,停在过期的 `S12=blocked-by-owner`;而 `tasks.json` 里 S12 **早已 done**
|
||
⇒ **读它会得出相反的结论**;
|
||
③ ⛔ **没给只读边界** ⇒ 检查会话到处翻文件 ⇒ 宿主判「重复执行、要求人工确认」。
|
||
"""
|
||
m = imp()
|
||
out = []
|
||
for reason in ("sessions-ended", "queue-empty"):
|
||
try:
|
||
p = m._check_prompt(1, "自检目标", reason)
|
||
except Exception as e:
|
||
out.append(("%s 渲染**抛异常**(%s)" % (reason, e), False))
|
||
continue
|
||
head = p.split("## 三")[0] # 「先读这五样」那一段
|
||
# 🔴🔴 判据必须**只查代码行**(P0-36:字面 grep 撞注释 ⇒ 恒绿)。
|
||
# 踩过:原判据 `'collabd.py" --domain-status' in p` —— 而 prompt 的**注释行里**
|
||
# 本身就写着这串字面("早前这里写错了"那段说明)⇒ 把实参换回工作区目录也照样通过。
|
||
# ⇒ ① 取「以 `python ` 开头的可执行命令行」;② 再反向断言工作区目录形态不存在。
|
||
# 🔴🔴 判据只认**可执行命令行**,⛔ 不认注释行/模板占位符(P0-36:字面 grep 撞注释 ⇒ 恒绿)。
|
||
# 踩过两次:① 原判据含 `python ` 前缀 ⇒ 漏掉真实形态(第 5 项是 `5. \`python …`)
|
||
# ⇒ **基线也报红**;② 只查 `collabd.py` 字面 ⇒ 注释里那串字面让它恒绿。
|
||
# ✅ 现在的口径:**行内真的带 `collabd.py` 路径**,且 ⛔ 不带 `<工作区目录>`/`<COLLABD_PY>`。
|
||
_dom = [ln.strip() for ln in p.split("\n")
|
||
if "--domain-status" in ln or "--domain-block" in ln]
|
||
_exec = [ln for ln in _dom
|
||
if "<COLLABD_PY>" not in ln and "<工作区目录>" not in ln]
|
||
_has_cb = any("collabd.py" in ln for ln in _exec)
|
||
_bad_ws = [ln for ln in _exec if "ai1net-dsh-server" in ln and "collabd.py" not in ln]
|
||
out += [
|
||
("%s ① domain 命令本体=`collabd.py`(⛔ 不是工作区目录,**只认可执行行**)" % reason,
|
||
_has_cb and not _bad_ws),
|
||
("%s ② 第2项指 `tasks.json`(唯一权威台账)" % reason, "tasks.json" in head),
|
||
("%s ② 明确**禁读** `queue.json`(已退役、内容相反)" % reason,
|
||
"不要读" in p and "queue.json" in p),
|
||
("%s ③ 给了『只读这五个文件』边界(⛔ 无边界=到处找信息)" % reason,
|
||
"只读这五个文件" in p),
|
||
("%s ③ 给了 `goal.json` **字段级**读法(lifecycle/acceptance_state/topics)" % reason,
|
||
all(k in p for k in ("lifecycle", "acceptance_state", "topics"))),
|
||
("%s ⛔ 无未替换的 `%%s`/`%%d` 占位符" % reason,
|
||
"%s" not in p and "%d" not in p),
|
||
]
|
||
# 🔴 队列口径:`queue_pending()` 必须读 `tasks.json` 真源
|
||
t = m._load_tasks()
|
||
n_real = sum(1 for v in t.values()
|
||
if isinstance(v, dict) and str(v.get("state") or v.get("status") or "").lower() != "done")
|
||
out.append(("🔴 `queue_pending()` 读真源 `tasks.json`(现算 = %d,⛔ 不读已退役的 queue.json)"
|
||
% n_real, m.queue_pending() == n_real))
|
||
src_c = (HERE / "collabd.py").read_text(encoding="utf-8")
|
||
fn = src_c[src_c.index("def queue_pending"):src_c.index("def queue_pending") + 2000]
|
||
out.append(("⛔ `queue_pending()` 函数体内不再读 `queue.json`",
|
||
'INBOX / "queue.json"' not in fn))
|
||
return out
|
||
|
||
|
||
|
||
@case("台账纪律:`done` 必须带产物文档 + `blocked` 必须带原因(否则检查会话只能去翻)")
|
||
def t_done_requires_artifact():
|
||
"""🔴 2026-10-03 10:5x(用户要求逐字):
|
||
|
||
> 1、任务会话完成时 **要写文档**,**常驻程序队列中要有对应文档的说明**,
|
||
> 这样检查会话处理队列时**有明确的信息**
|
||
|
||
🔴 **原缺陷(实测)**:`--report --state done` **完全不校验 `artifact`**
|
||
⇒ 任务会话可以「报完成但没写文档」,台账里 `done` 却**没有产物指向**
|
||
⇒ 检查会话读到 `done` **不知道去哪核实** ⇒ 只能自己翻(用户报障:到处找信息)。
|
||
🔴 **同款漏拦**:`blocked` 的 docstring 写「**必须**带 `--reason`」,
|
||
而**代码里根本没拦** ⇒ 实测 `block_reason` 写成空串 `""` ⇒ 受阻却不说卡在哪。
|
||
🔴🔴 **2026-10-04 改(原注释的理由已被推翻,逐字保留以示"为什么当年没做")**:
|
||
原注释写「判据只查台账里有没有产物字段,⛔ **不判断文件是否真存在** ——
|
||
那是检查会话的活;在这里查路径会把「相对工作区」写法全误杀」。
|
||
⇒ **那是把两件事混成一件**:「相对路径」从来不是问题,**路径拼错/产物没落盘**才是。
|
||
⇒ 现在 `--report done` **真验文件存在**(`artifact_state()`)——
|
||
夹具因此必须**真造出那个文件**,⛔ 不能再用 `artifact="x/T3.md"` 这种占位路径
|
||
(那正是"报了个打算写的路径"这个**要治的病**本身)。
|
||
"""
|
||
import json as _j
|
||
import pathlib as _pl
|
||
m = imp()
|
||
# 🔴🔴 2026-10-03 11:0x **改成独立临时目录**(原来用**共享** `tmp/selftest/...`):
|
||
# 实测缺陷=本用例往共享 `tasks.json` 写 `T3/T4` ⇒ **第二轮跑时它们已在表里**
|
||
# ⇒ 「拒收后不许写进台账」这项**假红**(我手工清了一次才绿 ⇒ 判据不可信)。
|
||
# ⇒ 判据必须**自足**:每次跑用**全新**目录 ⇒ ⛔ 与其它用例互不污染、⛔ 跑几次结果都一样。
|
||
import tempfile as _tf
|
||
tb = _pl.Path(_tf.mkdtemp(prefix="dsh-selftest-tasks-"))
|
||
try:
|
||
tb.relative_to(_pl.Path(TEST_WS).parent)
|
||
except Exception:
|
||
pass
|
||
_o = (m.INBOX, m.TASKS, getattr(m, "TASK_EVENTS", None), m.WS)
|
||
m.INBOX, m.TASKS = tb, tb / "tasks.json"
|
||
m.TASK_EVENTS = tb / "task-events.jsonl"
|
||
# 🔴 夹具必须**真造出产物文件**(`--report done` 现在真验存在)
|
||
m.WS = str(tb)
|
||
try:
|
||
p_ok = tb / "x" / "T3.md"
|
||
p_ok.parent.mkdir(parents=True, exist_ok=True)
|
||
p_ok.write_text("夹具产物", encoding="utf-8")
|
||
r_done = m.task_report("T1", "done", by="b") # ⛔ 缺 artifact
|
||
r_blk = m.task_report("T2", "blocked", by="b") # ⛔ 缺 reason
|
||
r_ok = m.task_report("T3", "done", by="b", artifact="x/T3.md")
|
||
r_gone = m.task_report("T5", "done", by="b", artifact="x/没有这个.md") # ⛔ 文件不存在
|
||
r_ok2 = m.task_report("T4", "blocked", by="b", reason="等 X")
|
||
t = m._load_tasks()
|
||
rec = t.get("T1") or {}
|
||
# ⛔ 拒收的**副作用**:T1/T2/T5 **不许**被写进台账
|
||
no_write = all(k not in t for k in ("T1", "T2", "T5"))
|
||
finally:
|
||
m.INBOX, m.TASKS, m.WS = _o[0], _o[1], _o[3]
|
||
if _o[2] is not None:
|
||
m.TASK_EVENTS = _o[2]
|
||
import shutil as _sh
|
||
_sh.rmtree(str(tb), ignore_errors=True) # ⛔ 清临时目录(不留垃圾)
|
||
return [
|
||
("⛔ `done` 缺 `--artifact` ⇒ 拒收(rc=%s)" % r_done, r_done != 0),
|
||
("⛔ 拒收后**不许**写进台账(⛔ 否则等于没拒)", no_write),
|
||
("⛔ `blocked` 缺 `--reason` ⇒ 拒收(rc=%s)" % r_blk, r_blk != 0),
|
||
("🔴 `done` 的 `artifact` **文件不存在** ⇒ 拒收(rc=%s)" % r_gone, r_gone != 0),
|
||
("✅ `done` 带**真存在**的 `--artifact` ⇒ 收下且台账有产物(rc=%s)" % r_ok,
|
||
r_ok == 0 and bool((t.get("T3") or {}).get("artifact"))),
|
||
("✅ `blocked` 带 `--reason` ⇒ 收下且台账有原因(rc=%s)" % r_ok2,
|
||
r_ok2 == 0 and bool((t.get("T4") or {}).get("block_reason"))),
|
||
]
|
||
|
||
|
||
@case("目标执行状态文档:字段在位 + 文件真存在 + 检查会话照它判(⛔ 不许再去翻)")
|
||
def t_execution_doc():
|
||
"""🔴 2026-10-03 10:5x(用户要求逐字):
|
||
|
||
> 2、**目标执行情况也要有文档**,这样检查会话**直接根据文档判断目标状态**,
|
||
> **避免检查会话到处找信息**
|
||
|
||
🔴 **原缺陷**:`acceptance_state` 只存在于 `goal.json` 里(V1–V7),
|
||
⛔ **没有独立的"目标执行状态文档"** ⇒ 检查会话要读整个 `goal.json` 才发现。
|
||
⚠️ 且原 V2/V6/V7 三条判据描述的是**已整套退役的跟进/唤醒会话**
|
||
⇒ 永远红 ⇒ 目标状态**永远判不出来** ⇒ 文档里必须带「旧判据作废清单」。
|
||
|
||
🔴 2026-10-04:本条量的是**生产工作区是否部署齐全** ⇒ 在**技能副本**里跑必然红
|
||
(`vibe-product` 实测:那工作区**根本没启用机制**,连 `goal.json` 都没有)
|
||
⇒ 未启用时**跳过**并说明(⛔ 不许改成"永远绿",那是把判据废掉)。
|
||
"""
|
||
if not MECH_ON:
|
||
return [("⏭ 本工作区**未启用**会话机制(⛔ 无 `tmp/supervise-inbox/goal.json`)"
|
||
" ⇒ 本条量的是生产部署,**不适用于技能副本**", True)]
|
||
import json as _j
|
||
import pathlib as _pl
|
||
m = imp()
|
||
# 🔴🔴 判「字段在位」必须**验代码接线**,⛔ 不能读测试工作区的 goal.json
|
||
# (`imp()` 把 INBOX 指向测试夹具 ⇒ 那里没这个字段 ⇒ **永久假红**)。
|
||
_code = (_pl.Path(HERE) / "collabd.py").read_text(encoding="utf-8")
|
||
# ⚠️ 2026-10-03 11:0x 重构后判据要跟:`EXEC_DOC_REL` **常量已改成 `exec_doc_rel()` 函数**
|
||
# (按目标算目录 ⇒ 换目标/多目标时不会照错目标的文档判状态)。
|
||
has_const = ("EXEC_DOC_NAME" in _code) and ("def exec_doc_rel()" in _code)
|
||
has_wiring = "os.path.join(_g, exec_doc_rel())" in _code
|
||
# 「文件真存在」按**生产工作区**定位。
|
||
# 🔴 踩过:上一版读 `Path(CFG)` 的 `workspace` —— 而 `CFG` 是**测试工作区**的配置
|
||
# (`imp()` 设了 `COLLABD_CONFIG`),那份**没有 workspace 键** ⇒ 回落成测试目录
|
||
# ⇒ 判据指错地方 ⇒ 又是假红。
|
||
# ✅ 正解:**不走测试夹具** ⇒ 用本机部署配置的真实路径;找不到就**如实说定位不到**,
|
||
# ⛔ 绝不默默回落到测试目录(那会让判据"看起来在验、其实验错对象")。
|
||
prod_ws = ""
|
||
# 🔴 可移植性(2026-10-04 换机演练实测):候选**不许只写死本机一条** ⇒ 换电脑后
|
||
# 这一条会因"定位不到生产工作区"而**永久假红**,而它验的是生产数据、不是安装。
|
||
# ⇒ 认一个**专用**环境变量 `COLLABD_PROD_CONFIG`(指向使用方的部署配置)+ 老安装兜底。
|
||
# 🔴🔴 **⛔ 不可拿 `DSH_WS_ROOT`/`DSH_COLLAB_WS` 拼生产路径**(本轮实测踩过):
|
||
# 那两个是**本脚本自己的测试工作区根**(见 `WS = ... or DSH_WS_ROOT`,`_prepare()`
|
||
# 拿它建 `tmp/selftest`)⇒ 用它拼生产配置=**把测试夹具当生产**,
|
||
# 演练时甚至会因 `X:/` 建目录而 FileNotFoundError 直接崩在 `_prepare()`。
|
||
# ⇒ 找不到就**如实报"定位不到"**(见下),⛔ 绝不回落测试目录。
|
||
_cands = []
|
||
if os.environ.get("COLLABD_PROD_CONFIG"):
|
||
_cands.append(Path(os.environ["COLLABD_PROD_CONFIG"]))
|
||
_cands.append(Path(r"E:/ProgramData/AIProject/ai1net-dsh-server"
|
||
r"/.workbuddy/collab/collabd.config.json"))
|
||
for _cand in _cands:
|
||
try:
|
||
_cfg = _j.loads(_cand.read_text(encoding="utf-8"))
|
||
prod_ws = str(_cfg.get("workspace") or "")
|
||
if prod_ws:
|
||
break
|
||
except Exception:
|
||
continue
|
||
# 🔴🔴 2026-10-04 修(**假红**,今天第 8 次同形状:判据没跟上机制):
|
||
# 原来这两条**写死** `交付物/目标执行状态.md`,而现行机制把它放在
|
||
# **目标文件夹**里(实测 `目标-本机协作-3e3182/目标执行状态.md`)
|
||
# ⇒ 文件**明明建出来了**,判据却报「真存在 = 否」⇒ 假红。
|
||
# ⇒ 改为**跟着机制走**:用 `exec_doc_rel()` 现算(上面两条判据已经在查它存在)
|
||
#
|
||
# 🔴🔴 再修一处(**验错对象**):`exec_doc_rel()` 属 `m`(`imp()` 加载的**测试工作区**
|
||
# 那份 collabd)⇒ 它按**测试夹具的 title** 算目录名(实测算出 `目标-甲-8b984a`)
|
||
# ⇒ 拿着测试目录名去问生产文件系统,**必然不存在**。
|
||
# ⇒ 正解:**先读生产 `goal.json` 里登记的 `execution_doc`**(那才是生产的真路径);
|
||
# 没登记才回落 `exec_doc_rel()`,⛔ 绝不拿测试夹具的算法去问生产(判据最忌"验错对象")。
|
||
prod_g_rel = ""
|
||
if prod_ws:
|
||
try:
|
||
prod_g_rel = str((_j.loads((_pl.Path(prod_ws) / "tmp/supervise-inbox/goal.json")
|
||
.read_text(encoding="utf-8"))).get("execution_doc") or "")
|
||
except Exception:
|
||
prod_g_rel = ""
|
||
if prod_g_rel:
|
||
rel = prod_g_rel
|
||
else:
|
||
try:
|
||
rel = m.exec_doc_rel()
|
||
except Exception:
|
||
rel = "目标执行状态.md" # 兜底:⛔ 取不到就退回文件名,⛔ 不假装算对
|
||
doc = (_pl.Path(prod_ws) / rel) if prod_ws else None
|
||
exists = bool(doc and doc.exists())
|
||
# 生产 goal.json 里该字段是否已登记(⚠️ 只报,⛔ 不作为断言:那是数据、不是代码)
|
||
# 🔴 复用上面已读到的 `prod_g_rel`(⛔ 同一份读数取两遍只会在两处漂)
|
||
prod_g = prod_g_rel
|
||
p_goal = m._check_prompt(1, "目标", "queue-empty")
|
||
p_res = m._check_prompt(1, "目标", "sessions-ended")
|
||
# 🔴🔴 2026-10-05 补(见 P0-86):**登记值必须与代码口径一致**。
|
||
# 实测三区全中:`execution_doc` 是 10-05 改口径(加 `执行会话/` 一层)**之前**写进
|
||
# `goal.json` 的旧路径,而**全脚本没有任何代码会写这个字段**
|
||
# (`collabd.py`/`goalctl.py`/`init_workspace.py` 都不写)⇒ 它永远停在旧值上。
|
||
# ⇒ 后果不止假红:`vibe-product` 登记成 `目标-vibe-product-3e3182/…`,
|
||
# 而 `3e3182` 是**别的区的标题哈希**(跨区污染)⇒ 看登记值根本推不出该找哪个文件。
|
||
# ✅ 判据=「生产登记值 == 按本区 title/short 算出的值」;对不上就**报红**(是漂移,⛔ 不是数据)。
|
||
# ⚠️ 归一化(`/`↔`\`、大小写)—— 否则 Windows 上又多一条假红。
|
||
def _nrm(_s):
|
||
return str(_s or "").replace("\\", "/").strip("/").lower()
|
||
_exp_rel = ""
|
||
if prod_ws:
|
||
try:
|
||
# ⚠️ 用**生产那份**的 title/short 现算(`goal_dir_name()` 是纯函数、不依赖 WS)
|
||
# ⛔ 别拿测试夹具的算法去问生产(上一版踩过:验错对象 ⇒ 必然"不存在")。
|
||
_pg = _j.loads((_pl.Path(prod_ws) / "tmp/supervise-inbox/goal.json")
|
||
.read_text(encoding="utf-8"))
|
||
_exp_rel = "%s/%s" % (m.goal_dir_name(str(_pg.get("title") or ""),
|
||
str(_pg.get("short") or "")),
|
||
m.EXEC_DOC_NAME)
|
||
except Exception:
|
||
_exp_rel = ""
|
||
sync_ok = (not _exp_rel) or (_nrm(prod_g_rel) == _nrm(_exp_rel))
|
||
# 光判数据不够 ⇒ 还要判**对齐接线在不在**(否则迟早又漂回去)
|
||
_cd_code = (_pl.Path(HERE) / "collabd.py").read_text(encoding="utf-8")
|
||
_ensure_sync = "_want = exec_doc_rel()" in _cd_code
|
||
return [
|
||
("🔴 `execution_doc` 登记**与代码口径一致**(实测=%s/应为=%s)"
|
||
% (_nrm(prod_g_rel) or "(未登记)", _nrm(_exp_rel) or "-"), sync_ok),
|
||
("🔴 `--ensure-goal-dir` 里**有对齐接线**(⛔ 只改数据不改接线 ⇒ 迟早又漂)",
|
||
_ensure_sync),
|
||
("🔴 有 `exec_doc_rel()` 函数(⛔ 按目标算,不写死字面量路径)", has_const),
|
||
("🔴 prompt 传参用 `os.path.join(_g, exec_doc_rel())`(单一真源)", has_wiring),
|
||
("🔴 定位到生产工作区(⛔ 不许回落测试夹具;%s)" % (prod_ws or "定位不到"), bool(prod_ws)),
|
||
("🔴 生产工作区该文件**真存在**(%s)" % (prod_ws + "/" + rel if prod_ws else "-"), exists),
|
||
# ⚠️ 原第 5 条「生产 `goal.json` 已登记 `execution_doc`」已**并入上面第 4 条**
|
||
# (那条现在既判"登记了"又判"登记对");⛔ 别两条都留 ⇒ 同一个事实两处判,
|
||
# 迟早一处改了另一处忘了(同族 P0:同一规则两份实现)。
|
||
("目标检查 prompt 把它列为**判断依据**", "目标执行状态文档" in p_goal),
|
||
("⛔ 明确禁止再去工作区翻文件(用户报障:到处找⇒被判重复执行)",
|
||
"别再去工作区里翻文件" in p_goal),
|
||
# ⚠️ prompt 里给的是**绝对路径**(`os.path.join(_g, exec_doc_rel())`)
|
||
# ⇒ 判「目标目录名 + 文档名都在文里」,⛔ 别拿 goal.json 的**相对**路径字面比对。
|
||
("🔴 目标目录 + 文档名都已传进 prompt(⛔ 不写死相对路径)",
|
||
m.goal_dir_rel() in p_goal and m.EXEC_DOC_NAME in p_goal),
|
||
("② 结果检查 prompt 写明 `artifact`=完成证明", "完成证明" in p_res),
|
||
("⛔ 结果检查 prompt 禁止自己去找(只照 artifact 读)", "别自己去别处找" in p_res),
|
||
]
|
||
|
||
|
||
@case("🔴 artifact 合同:产物要**真读得到** + 落点提示 + 机制侧预挑不可核实件")
|
||
def t_artifact_contract():
|
||
"""🔴 2026-10-04 立(用户口径逐字):
|
||
|
||
> 「之前还说过 **执行会话的结果要形成文档,目标也要完成情况的文档**,
|
||
> 这样后续检查会话和后续执行会话都可根据文档继续处理,**避免全工作区到处找信息**」
|
||
|
||
**要治的病**(实测三区三种形态 ⇒ 缺的是**合同**不是能力):
|
||
· 写入侧原来只查 `artifact` **有没有写**(非空即放行),
|
||
⛔ **不查文件是否真存在** ⇒ 本区台账 `{"t":{"state":"done"}}`(**绕过闸的旧数据**)
|
||
照样躺库里;路径打错/产物被移走的也照样算 `done` ⇒ 检查会话照它读**读空**
|
||
⇒ **又回去到处找**(正是要避免的)。
|
||
|
||
🔴 **判据分两层,⛔ 别混**:
|
||
· **存在性=硬闸**(`artifact_state()`)—— 读不到 ⇒ `--report` **拒收**。
|
||
· **落点=软判据**(`artifact_dir_ok()`)—— 只**提示**(存量有落在别处但有效的产物,
|
||
硬拒会误杀既有工作流;但仍要**说出来**,⛔ 静默通过=合同等于没立)。
|
||
|
||
🔴 **为什么由机制侧预挑**(`artifacts_unverifiable()`):
|
||
让检查会话"自己扫台账判断哪条不可核实" ⇒ 每条会话重算、标准还可能不同
|
||
⇒ 与 `goal_life_of()` 被抽出来治的病**同形**(用户口径「禁止重复判定标准」)。
|
||
"""
|
||
import json as _j
|
||
import pathlib as _pl
|
||
import tempfile as _tf
|
||
m = imp()
|
||
_code = Path(m.__file__).read_text(encoding="utf-8")
|
||
# ① 三个函数都在(⛔ 只查名字,行为另测)
|
||
has_fn = all(("def %s(" % n) in _code for n in
|
||
("artifact_state", "artifacts_unverifiable", "artifact_dir_ok"))
|
||
# ② prompt 里写明了**文档合同**(三份文档 + 落点)
|
||
p_res = m._check_prompt(1, "目标", "sessions-ended")
|
||
p_goal = m._check_prompt(1, "目标", "queue-empty")
|
||
has_contract = ("文档合同" in p_res and "目标目录" in p_res
|
||
and "别自己另找" in p_res)
|
||
# ③ prompt 里带**不可核实件清单**(机制侧渲染的,⛔ 空也要有那句)
|
||
has_list = ("不可核实" in p_res or "产物读不到" in p_res)
|
||
# ④ `--report done` 的**存在性硬闸**在(文本判据,行为另有独立验证脚本)
|
||
has_hard = ("artifact_state(tid, {\"state\": \"done\", \"artifact\": artifact})"
|
||
in _code)
|
||
# ⑤ 落点是**软**判据(⛔ 不许写成 return 3 ⇒ 那会误杀既有工作流)
|
||
_soft = "⚠️ 提醒:`--artifact`" in _code and "log(\"artifact 落点不合合同(不拒收)" in _code
|
||
# ⑥ 行为级:临时区里真跑三种形态(照 P0-42:隔离必须用 COLLABD_CONFIG)
|
||
_tmp = _pl.Path(_tf.mkdtemp(prefix="dsh-art-"))
|
||
_ok = _miss = _gone = _dirout = None
|
||
_unv = []
|
||
try:
|
||
# ⚠️ 必须**连 TASKS 常量一起换** —— `artifacts_unverifiable()` 读的是 `TASKS`,
|
||
# ⛔ 只换 `WS`/`INBOX` 它照样去读生产台账(我第一版就是这么写错的 ⇒ 恒得 [])。
|
||
_o = (m.WS, m.INBOX, m.TASKS)
|
||
_inbox = _tmp / "tmp" / "supervise-inbox"
|
||
_inbox.mkdir(parents=True, exist_ok=True)
|
||
(_inbox / "goal.json").write_text(
|
||
_j.dumps({"title": "合同夹具目标", "short": "合同夹具"}, ensure_ascii=False),
|
||
encoding="utf-8")
|
||
m.WS, m.INBOX, m.TASKS = str(_tmp), _inbox, _inbox / "tasks.json"
|
||
_gd = m.goal_dir_rel()
|
||
(_tmp / _gd).mkdir(parents=True, exist_ok=True)
|
||
(_tmp / _gd / "S1_真.md").write_text("x", encoding="utf-8")
|
||
_ok = m.artifact_state("a", {"state": "done", "artifact": _gd + "/S1_真.md"})["state"]
|
||
_miss = m.artifact_state("b", {"state": "done"})["state"]
|
||
_gone = m.artifact_state("c", {"state": "done", "artifact": _gd + "/S2_无.md"})["state"]
|
||
_dirout = m.artifact_dir_ok("交付物/别处.md")["ok"]
|
||
# 落盘 tasks.json(`artifacts_unverifiable()` 读盘,⛔ 内存 dict 不算)
|
||
m.TASKS.write_text(_j.dumps({
|
||
"a": {"state": "done", "artifact": _gd + "/S1_真.md"},
|
||
"b": {"state": "done"},
|
||
"c": {"state": "done", "artifact": _gd + "/S2_无.md"},
|
||
"d": {"state": "running"},
|
||
}, ensure_ascii=False), encoding="utf-8")
|
||
_unv = [x["id"] for x in m.artifacts_unverifiable()]
|
||
except Exception as _e:
|
||
_unv = ["<跑挂:%s>" % _e]
|
||
finally:
|
||
try:
|
||
m.WS, m.INBOX, m.TASKS = _o
|
||
except Exception:
|
||
pass
|
||
try:
|
||
import shutil as _sh
|
||
_sh.rmtree(_tmp, ignore_errors=True)
|
||
except Exception:
|
||
pass
|
||
return [
|
||
("🔴 三个函数都在(`artifact_state`/`artifacts_unverifiable`/`artifact_dir_ok`)", has_fn),
|
||
("🔴 结果检查 prompt 写明 **文档合同**(目标目录 + 落点 + ⛔ 别自己另找)", has_contract),
|
||
("🔴 prompt 带**不可核实件清单**(机制侧渲染,⛔ 不让会话自己扫)", has_list),
|
||
("🔴 `--report done` 有**存在性硬闸**(`artifact_state()` 真验)", has_hard),
|
||
("🔴 落点是**软**判据(⛔ 不 return 3 —— 硬拒会误杀既有工作流)", _soft),
|
||
("🔴 行为:真产物 → `ok`", _ok == "ok"),
|
||
("🔴 行为:没写 artifact → `missing`", _miss == "missing"),
|
||
("🔴 行为:路径不存在 → `gone`", _gone == "gone"),
|
||
("🔴 行为:落点判据(写到别处 ⇒ False)", _dirout is False),
|
||
("🔴 行为:`artifacts_unverifiable()` 只挑 done 且不可核实(=%s)" % _unv,
|
||
_unv == ["b", "c"]),
|
||
]
|
||
|
||
|
||
|
||
@case("目标文件夹机制:按目标算目录 + 文档同源 + 派棒产物落点 + CLI 幂等")
|
||
def t_goal_dir_mechanism():
|
||
"""🔴 2026-10-03 11:0x(用户要求逐字):
|
||
|
||
> 「把这个机制**写到技能中**,这样**每次使用协作会话机制都可以有对应的目标文件夹**」
|
||
|
||
🔴 **原痛点(实测)**:产物散在 `交付物/`、`docs/`、工作区根目录
|
||
⇒ 检查会话判断"做完没"只能**到处翻**(用户报障:触发宿主「重复执行、要求人工确认」)。
|
||
✅ **归拢文档、不搬家状态** —— 真源永远是 `goal.json`(生命周期/验收判据)
|
||
+ `tasks.json`(队列四态)⇒ 目标目录里**只放文档**(两处存状态=迟早打架)。
|
||
⚠️ 判据一律**现算**,⛔ 不断言字面目录名(目标标题会变)。
|
||
"""
|
||
import json as _j # 🔴 用到 goal.json 得先导入(上一版漏了 ⇒ NameError)
|
||
import re as _re
|
||
import shutil as _sh
|
||
import tempfile as _tf
|
||
import pathlib as _pl
|
||
m = imp()
|
||
# ① 目录名确定性 + 非法字符清洗 + 无空格
|
||
d1, d2 = m.goal_dir_name("目标甲", "简称甲"), m.goal_dir_name("目标甲", "简称甲")
|
||
d3 = m.goal_dir_name("目标乙", "简称乙")
|
||
clean = m.goal_dir_name(r'a/b:c*d?e|f<g>h|i\j', "s/t")
|
||
# ② 目录名与文档路径**同源**(⛔ 不会出现"目录在一处、文档在另一处")
|
||
doc = m.exec_doc_rel()
|
||
same_src = doc.startswith(m.goal_dir_rel() + "/")
|
||
# ③ prompt:两份都带目标目录 + 产物落点约束
|
||
p_res = m._check_prompt(1, "目标", "sessions-ended")
|
||
p_goal = m._check_prompt(1, "目标", "queue-empty")
|
||
gd = m.goal_dir_rel()
|
||
# ④ CLI 幂等:跑两次,第二次不许覆盖已有文档
|
||
tmpws = _pl.Path(_tf.mkdtemp(prefix="dsh-goaldir-"))
|
||
ok_cli = None
|
||
try:
|
||
_o = (m.WS, m.INBOX)
|
||
m.WS = str(tmpws)
|
||
(tmpws / "tmp" / "supervise-inbox").mkdir(parents=True, exist_ok=True)
|
||
(tmpws / "tmp" / "supervise-inbox" / "goal.json").write_text(
|
||
_j.dumps({"title": "自检目标", "short": "自检"}), encoding="utf-8")
|
||
m.INBOX = tmpws / "tmp" / "supervise-inbox"
|
||
r1 = m._ensure_goal_dir() if hasattr(m, "_ensure_goal_dir") else None
|
||
_dd = tmpws / m.goal_dir_rel()
|
||
_f = _dd / m.EXEC_DOC_NAME
|
||
had = _f.exists()
|
||
if had:
|
||
_f.write_text("SENTINEL", encoding="utf-8")
|
||
r2 = m._ensure_goal_dir() if hasattr(m, "_ensure_goal_dir") else None
|
||
ok_cli = bool(_f.exists() and _f.read_text(encoding="utf-8") == "SENTINEL")
|
||
finally:
|
||
m.WS, m.INBOX = _o
|
||
_sh.rmtree(str(tmpws), ignore_errors=True)
|
||
return [
|
||
("目录名**确定性**(同一目标两次同结果)", d1 == d2),
|
||
("不同目标 ⇒ 不同目录(短哈希消歧)", d1 != d3),
|
||
# ⚠️ 判据拆成**两条**:非法字符 与 空格 分开(踩过:合成一条时 `\\s` 写在 raw 串里
|
||
# ="字面反斜杠+s",⛔ 匹配不到空格 ⇒ **误报**,而产品代码其实完全正确:
|
||
# 实测 `目标-s-t-c245d9` 无非法字符、无空格 ⇒ 教训=**判据自身的转义错误会伪装成产品缺陷**,
|
||
# 改完要回头确认"被测对象到底对不对",⛔ 别直接改产品去迎合判据。
|
||
# 🔴🔴 2026-10-05 **同一课第二遍**:加了 `执行会话/` 父层后,`clean` 里**必然含一个 `/`**
|
||
# ⇒ 老判据 `not re.search(r"[\\/:*?\"<>|]", clean)` **当场误报**(同一族第 2 次:
|
||
# 判据自身的假设过期 ⇒ 伪装成产品缺陷)。
|
||
# ✅ 正解=**只清洗"末级目录名"**再判 —— 父层分隔符是**结构**,⛔ 不是"未清洗的非法字符"。
|
||
("目录名已清洗非法字符(只看末级:实测 %r)" % clean.split("/")[-1],
|
||
not _re.search(r"[\\/:*?\"<>|]", clean.split("/")[-1])),
|
||
("目录名无空格(命令行不必处处加引号)", " " not in clean),
|
||
("目录名无空格(命令行不必处处加引号)", " " not in clean),
|
||
("🔴 文档路径与目录**同源**(%s)" % doc, same_src),
|
||
# 🔴🔴 2026-10-05 新增(用户定案逐字:「<工作区根>/执行会话/目标-xxx-xxxxxx/ 改成这样」)
|
||
# ⇒ 钉住"目标文件夹收在 `执行会话/` 一层下",⛔ 别让它悄悄退回工作区根。
|
||
# ⚠️ 判据写**两级**一起:① 前缀是 `执行会话/` ② 末尾一段仍以 `目标-` 开头
|
||
# —— ⛔ 只判①会把「执行会话/随便什么名」也放行;只判②则管不住这一层。
|
||
# 🔴🔴 **变异对照抓到的自欺**(2026-10-05 实测,第 3 次同族):
|
||
# 第一版写的是 `d1.startswith(m._GOAL_DIR_PARENT + "/")` —— 拿**被测的同一个常量**
|
||
# 当下界。把 `_GOAL_DIR_PARENT` 变异成 `""` 后,判据变成 `startswith("/")`
|
||
# ⇒ **照样为真** ⇒ 拆掉父层竟然全绿(PASS 95/FAIL 2,与正确实现**逐字相同**)。
|
||
# ✅ 正解=**写死期望字面 `"执行会话/"`**:判据必须独立于被测对象,
|
||
# 否则"判据与实现同源 ⇒ 一起错 ⇒ 恒绿"(同族:P0-20/P0-13「判据写死期望值」的反面)。
|
||
("🔴 目标文件夹在 `执行会话/` 一层下(实测 %r)" % d1,
|
||
d1.startswith("执行会话/") and d1.split("/")[-1].startswith("目标-")),
|
||
("🔴 `goal_dir_rel()` 也同形(实测 %r)" % m.goal_dir_rel(),
|
||
m.goal_dir_rel().startswith("执行会话/")),
|
||
("🔴 文档路径跟到新层(`exec_doc_rel()`=%s)" % doc,
|
||
doc.startswith("执行会话/")),
|
||
("⛔ 目录名**不再落在工作区根**(回归护栏:实测 %r)" % d1, "/" in d1),
|
||
("🔴 两份 prompt 都带目标目录(%s)" % gd, gd in p_res and gd in p_goal),
|
||
("🔴 prompt 要求产物落目标目录 + 禁散到 交付物/docs",
|
||
("产物一律落这里" in p_res) and ("别再散到" in p_goal)),
|
||
("🔴 prompt 给了幂等建目录命令", "--ensure-goal-dir" in p_res),
|
||
("🔴 CLI **幂等**(第二次跑⛔ 不覆盖已有文档)", bool(ok_cli)),
|
||
]
|
||
|
||
|
||
|
||
@case("常驻程序在线判据:读**常驻心跳**(⛔ 不是退役旧戳)+ 四态文案 + pid 判据复用")
|
||
def t_prog_online_judge():
|
||
"""🔴 2026-10-03 11:35x(用户报障逐字):
|
||
|
||
> 「看板协作程序 命名创建了检查程序,就算执行了一轮 **在线 · 已 849.6 分钟没轮**」
|
||
|
||
🔴 **① 判据读错文件**(真因):`prog.up` 读的是 `_tick.stamp`/`collabd-once.stamp`
|
||
—— 那是**已退役投递机制**的遗留戳,实测都停在 **2026-10-02 21:24**(无人再写)
|
||
⇒ 而常驻心跳 10 s/轮 ⇒ **报"849 分钟没轮"=假读数**。
|
||
🔴 **② 文案把"死了"说成"没被唤起"** ⇒ 读者以为机制正常只是闲着 ⇒ 假绿。
|
||
🔴 **③ `_pid_alive` 两个方向都错过**:没声明 ctypes 签名 ⇒ 活进程被判死;
|
||
补了签名后对 `ACCESS_DENIED` 返"不在" ⇒ 活着说成已停。
|
||
"""
|
||
import importlib.util as _il
|
||
import json as _j
|
||
import time as _tm
|
||
m = imp()
|
||
spec = _il.spec_from_file_location("bd_hb", HERE / "board.py")
|
||
b = _il.module_from_spec(spec)
|
||
spec.loader.exec_module(b)
|
||
|
||
prog = b._runtime()["prog"]
|
||
# ① 判据**读心跳**:字段在位 + 与产品判据 `supervise_alive()` 对账
|
||
has_hb = "heartbeat_age_min" in prog
|
||
ok_prod, why_prod = m.supervise_alive()
|
||
# ② 旧戳**不再参与判定**(降级为附注 `legacy_*`)
|
||
legacy_demoted = ("legacy_tick_age_min" in prog) and ("tick_age_min" not in prog)
|
||
# ③ 四态文案:函数在位 + 每态都有话说清依据
|
||
four = all(hasattr(b, "_label_when_down") for _ in (0,))
|
||
st = b._label_when_down(10.0, None, True)
|
||
alive_old = b._label_when_down(10.0, 4, False) # pid 4 = 系统 idle,恒在
|
||
dead = b._label_when_down(10.0, 999999, False)
|
||
unknown = b._label_when_down(None, None, False)
|
||
# ④ `_pid_alive` **复用** collabd(⛔ 不是自己重写)
|
||
src_b = (HERE / "board.py").read_text(encoding="utf-8")
|
||
reuses = ("_cb_shared._pid_alive" in src_b)
|
||
return [
|
||
("① 判据读**常驻心跳**(heartbeat_age_min 字段在位)", has_hb),
|
||
("① 旧戳已降级为附注(legacy_*,⛔ 不再参与判定)", legacy_demoted),
|
||
("① 与产品判据 `supervise_alive()` 一致(本轮实测 %s)" % why_prod,
|
||
(not ok_prod) or prog.get("up") is True),
|
||
("② 四态文案函数在位", four),
|
||
("② 心跳读不到 ⇒ 说「判据已降级」⛔ 不提进程(实测 %r)" % st,
|
||
"降级" in st and "进程" not in st),
|
||
("② pid 还在但心跳旧 ⇒ 说「没轮(进程还在)」(实测 %r)" % alive_old,
|
||
"没轮" in alive_old and "进程还在" in alive_old),
|
||
("② pid 不在 ⇒ 说「已停 · 进程不在」(实测 %r)" % dead, "已停" in dead),
|
||
("② 什么都读不到 ⇒ 说「状态未知」(实测 %r)" % unknown, "未知" in unknown),
|
||
("③ `_pid_alive` **复用 collabd.py**(⛔ 两份同名实现=活例)", reuses),
|
||
]
|
||
|
||
|
||
|
||
@case("闸④:排除**已过期的一次性**排期(⛔ 否则它永远卡住闸=自己锁死自己)")
|
||
def t_gate4_stale_once():
|
||
"""🔴 2026-10-03 11:5x 体检发现(**同型第三次**死锁)。
|
||
|
||
🔴 **现象**:检查排期 `[检查]-…第3棒` `fire_at` 已过 15 分钟、`last_run_at` 仍 `None`
|
||
⇒ `ws_pending_schedules()` 判「本工作区还有待执行排期」⇒ **闸④ 永远拦**
|
||
⇒ 机制再也建不出下一棒检查会话(**自己把自己锁死**)。
|
||
前两次同型:改名前的旧名排期、「清理残留排期」那条排期把自己困在闸④。
|
||
🔴 **根因**=**宿主 `last_run_at` 根本不写**(2026-10-01 实测坐实:明明在跑,值仍是 `None`)
|
||
⇒ ⛔ 靠 `last_run_at` 收口**永远收不了**。
|
||
✅ **正解**=排除「**已过期的一次性**排期」,宽限 **15 分钟**
|
||
(宿主扫描周期 ≤30 s、实测到点延迟 20~35 s ⇒ 15 min 是安全余量)。
|
||
⚠️ 四条边界(缺一条就误杀或漏杀):
|
||
① 刚过期 ⇒ **仍算待执行**(马上要开 ⇒ 闸④ 该拦)
|
||
② 过期 >15 min ⇒ **排除**
|
||
③ `next_run_at` 为 NULL ⇒ **仍算待执行**(⛔ 别误杀)
|
||
④ `recurring` 过期 ⇒ **仍算待执行**(它会再触发)
|
||
"""
|
||
import re as _re
|
||
import time as _tm
|
||
m = imp()
|
||
_c = (HERE / "collabd.py").read_text(encoding="utf-8")
|
||
# ① 查得到两列
|
||
two_cols = ("schedule_type" in _c and "next_run_at" in _c)
|
||
# ② 宽限是 15 分钟(⛔ 写成 0 ⇒ 刚过期的也被排除 ⇒ 闸④ 放行"马上要开"的排期 ⇒ 同一件事派两遍)
|
||
# ⚠️⚠️ 判据自身的坑(踩了两次):正则的**捕获组只是因子 `15`**,⛔ **不是** `15*60*1000`。
|
||
# 我曾拿它当毫秒去比 `900000 <= x` ⇒ 永远 False ⇒ **误判产品坏了**。
|
||
# ⇒ 判据直接按**分钟**比:`limit_min` 就是那个因子本身。
|
||
mlim = _re.search(r"_STALE_MS\s*=\s*(\d+)\s*\*\s*60\s*\*\s*1000", _c)
|
||
limit_min = int(mlim.group(1)) if mlim else -1
|
||
# ⚠️ 旧算法残留已删(它引用已被改名的 `limit_ms` ⇒ NameError ⇒ 整条用例假红)。
|
||
# ⛔ 教训:改判据里的变量名,⛔ 一定要 grep 一遍**所有**引用点(同 P0-3 的"改一处漏一处")。
|
||
# ③ 只排 `once`(⛔ recurring 永远不该被"过期排除")
|
||
only_once = 'str(_st or "").lower() == "once"' in _c
|
||
# ④ 🔴 2026-10-03 12:0x **语义已改**:`next_run_at` 为 NULL 的 `once` 排期
|
||
# **也要排除** —— 实测宿主在排期**到点触发后会把 `next_run_at` 清成 NULL**,
|
||
# 而 `status` 仍 `ACTIVE`、`last_run_at` 仍 `None`
|
||
# ⇒ 我上一版写"NULL ⇒ 仍算待执行(别误杀)"**是错的** ⇒ 那样它照样永��卡闸④。
|
||
# (第五种情形:(a) 已过宽限 / (b) 已被消费 ⇒ 两者都排除。)
|
||
null_excluded = "if str(_st or \"\").lower() == \"once\":" in _c and "_gone = (not _na)" in _c
|
||
# ⑤ 真库现读:造一条过期 once 也不能把**别人**的排期算进来(⛔ 只验判据形状,不动生产)
|
||
live = m.ws_pending_schedules()
|
||
return [
|
||
("① 闸④ 查询**取到** `schedule_type` 与 `next_run_at` 两列", two_cols),
|
||
("② 过期宽限 = 15 分钟(实测 %s 分钟;⛔ 写 0 会放行『马上要开』的排期)"
|
||
% limit_min, 10 <= limit_min <= 30),
|
||
("③ 只排 `once`(⛔ `recurring` 会再触发,永不按过期排除)", only_once),
|
||
("④ `once` 且 `next_run_at` 为 NULL(已被宿主消费)⇒ **也排除**", null_excluded),
|
||
("⑤ 判据现读不抛(ws_pending_schedules() = %s)" % (live if live else "[]"), True),
|
||
]
|
||
|
||
|
||
|
||
@case("看板 tab:**跨工作区**并列查看(⛔ 原本只认一个 INBOX,天生看不见别的目录)")
|
||
def t_tab_peer_workspace():
|
||
"""🔴 2026-10-03 15:44x(用户报障逐字):
|
||
|
||
> 「为什么 会话协作看板 tab 选项不能切换看另外两个工作区的目标」
|
||
|
||
🔴 **根因**:`goal_files()` 扫的是 `INBOX/goal.json` + `INBOX/goals/*.json`
|
||
—— **两者都在同一个 INBOX 下**,而 `INBOX` 由部署配置的 `workspace` 决定
|
||
⇒ 一个 `board.py --serve` 实例**天生只看见一个工作区**(注释里也明写"同一个工作区")。
|
||
✅ 修法:配置新增 `peer_workspaces`(要并列查看的其它工作区)⇒ 读它们的 `goal.json` 进 tab。
|
||
🔴 **严格只读**这条边界必须钉住:⛔ 不写对方文件、⛔ 不起对方进程、⛔ 不改对方状态。
|
||
|
||
🔴 2026-10-04:本条要**现读生产配置**的 `peer_workspaces` ⇒ 在**技能副本**里跑,
|
||
那个工作区**没启用机制**(`vibe-product` 实测)⇒ peer 必为空 ⇒ 0 格 ⇒ 报红。
|
||
⇒ 未启用时**跳过**(⛔ 不许改成"永远绿")。
|
||
"""
|
||
if not MECH_ON:
|
||
return [("⏭ 本工作区**未启用**会话机制 ⇒ 跨区 peer 配置无从谈起,本条不适用", True)]
|
||
import inspect as _ins
|
||
import re as _re
|
||
m = imp()
|
||
spec = _ins.getsource # 占位(下面直接读文本)
|
||
src_b = (HERE / "board.py").read_text(encoding="utf-8")
|
||
src_h = (HERE.parent / "assets" / "board.html").read_text(encoding="utf-8")
|
||
|
||
# ① 函数在位
|
||
has_fn = "_peer_workspaces" in src_b
|
||
# ② 排除本工作区(⛔ 否则活跃目标出现两格)
|
||
excludes_self = 's == str(WS or "").replace(' in src_b
|
||
# ③ 去重键带工作区前缀(⛔ 否则同名目标互相顶掉)
|
||
key_prefixed = 'k = "%s/%s" % (ws_name, k)' in src_b
|
||
# ④ _goal_block 透传 peer(⛔ 上游加了下游不带 ⇒ 前端永远看不到)
|
||
block_carries = '"peer": peer' in src_b
|
||
call_passes = 'it.get("peer") or ""' in src_b
|
||
# ⑤ 形参必须存在(⛔ 少了 ⇒ TypeError ⇒ 快照生不出 ⇒ 看板一直 warming —— 本轮实测踩到)
|
||
# 🔴 判据=**只扫 `_goal_block()` 的签名**(⛔ 别用裸子串:命中注释/docstring 就变假绿)
|
||
_sig_m = _re.search(r"def\s+_goal_block\s*\(([^)]*)\)", src_b, _re.S)
|
||
_sig = " ".join((_sig_m.group(1) if _sig_m else "").split())
|
||
sig_ok = ("peer" in _sig and "ws_root" in _sig)
|
||
sig_txt = "_goal_block(%s)" % (_sig[:60] or "!! 找不到该函数")
|
||
# ⑥ 前端显示 peer 标记
|
||
# ⚠️ 2026-10-03 20:30x 判据跟着改版走:peer 标记 20:2x 从**第一行徽章**挪到了**第二行**
|
||
# (用户要的两行式:第一行=目标名+目标状态,第二行=工作区名+判据状态)⇒ 字面量从 `g.peer?` 变 `g.peer||`。
|
||
# 🔴 **剥掉注释再查**(20:2x 踩过:注释里留着旧写法 ⇒ 判据被注释骗、变异不报红)。
|
||
_h_nc = re.sub(r"/\*.*?\*/", "", src_h, flags=re.S)
|
||
_h_nc = re.sub(r"//[^\n]*", "", _h_nc)
|
||
front_shows = ("g.peer||" in _h_nc) or ("g.peer?" in _h_nc)
|
||
# ⑦ 只读取向:源码里 ⛔ 不能有写对方文件的调用
|
||
no_write = not _re.search(r"_root\s*/\s*\"tmp\".*\.write_text", src_b)
|
||
# ⑧~⑩ **现网真读数** —— 🔴 **⛔ 不能沿用 `imp()` 注入的测试环境**:
|
||
# `imp()` 把 `COLLABD_CONFIG`/`DSH_COLLAB_WS` 指到 `tmp/selftest/` ⇒ board 只看见测试那 1 格
|
||
# ⇒ 断言写什么都只是**假象**(2026-10-03 实测:命令行直接加载=3 格,用例内=1 格)。
|
||
# ⇒ 正确姿势:**真磁盘上造两个工作区** + **干净环境**加载 board 现读(P0-13:判据要能证伪)。
|
||
import tempfile
|
||
|
||
def _load_board(cfg_p, ws_env, tag):
|
||
"""在**指定配置**下加载 `board.py`(⛔ 用完还原 env,防污染后续用例)。"""
|
||
_old = {k: os.environ.get(k) for k in ("COLLABD_CONFIG", "DSH_COLLAB_WS")}
|
||
try:
|
||
os.environ["COLLABD_CONFIG"] = str(cfg_p)
|
||
if ws_env is None:
|
||
os.environ.pop("DSH_COLLAB_WS", None)
|
||
else:
|
||
os.environ["DSH_COLLAB_WS"] = str(ws_env)
|
||
_u = __import__("importlib.util", fromlist=["util"])
|
||
_sp = _u.spec_from_file_location(tag, HERE / "board.py")
|
||
_mm = _u.module_from_spec(_sp)
|
||
_sp.loader.exec_module(_mm)
|
||
return _mm
|
||
finally:
|
||
for _k, _v in _old.items():
|
||
if _v is None:
|
||
os.environ.pop(_k, None)
|
||
else:
|
||
os.environ[_k] = _v
|
||
|
||
def _dump(p, obj):
|
||
Path(p).write_text(json.dumps(obj, ensure_ascii=False), encoding="utf-8")
|
||
|
||
def _mk_ws(root, gid, title):
|
||
"""真磁盘上造一个**最小工作区**(只含 `tmp/supervise-inbox/goal.json`)。"""
|
||
_r = Path(root) / gid
|
||
(_r / "tmp" / "supervise-inbox").mkdir(parents=True, exist_ok=True)
|
||
_dump(_r / "tmp" / "supervise-inbox" / "goal.json",
|
||
{"id": gid, "title": title, "short": gid})
|
||
return _r
|
||
|
||
_td = tempfile.mkdtemp(prefix="selftest-peer-") # ⛔ 不用 TEST_WS(会被别的用例清掉)
|
||
try:
|
||
_a = _mk_ws(_td, "wsA", "A 区目标")
|
||
_b = _mk_ws(_td, "wsB", "B 区目标")
|
||
# 🔴🔴 2026-10-04 21:4x **夹具必须补上「这个 peer 还在会话列表里」这个前提**。
|
||
# 病根(我当天加的过滤打红了本条):`board.goal_files()` 现在会**跳过"未删会话为 0"的 peer**
|
||
# (用户口径:「tab 要把已经不再 会话列表的工作区 目标移除」)。
|
||
# 而 `_mk_ws()` 只在真磁盘造 `goal.json`,**宿主库里没有 wsB 的任何会话**
|
||
# ⇒ `_peer_session_rows()` 返回空 ⇒ wsB **被正确地过滤掉** ⇒ `goal_files()`=1 格 ⇒ 本用例 FAIL。
|
||
# ⚠️ **被测代码行为是对的,是夹具在说谎**(= P0-53 同一个形状:合成区必须让被测对象
|
||
# 能读到真实判据,⛔ 不许靠改断言期望值把它"修绿")。
|
||
# ⇒ 正解两条(都必须):
|
||
# ① 造一个**最小的宿主库**(只有 `sessions` 表 + 本用例要用的列),
|
||
# wsB 那条 `cwd` 精确等于 `_b` ⇒ `_peer_session_rows(_b)` 有 1 条 ⇒ 不被过滤。
|
||
# ② 配置里用 **`host_db`** 指过去 —— `board.py::_host_db()` 优先读它
|
||
# (⛔ 别去改 `CODEBUDDY_CONFIG_DIR`:那会把真库暴露给用例,且污染后续用例)。
|
||
# ⚠️ 库要**留在 `_td` 里**(随 `shutil.rmtree` 一起回收),⛔ 不落靠 `tempfile` 的全局清理。
|
||
_db = Path(_td) / "fake-host.db" # ⚠️ `mkdtemp()` 返回的是 **str** ⇒ 必须包 `Path()`
|
||
try:
|
||
import sqlite3 as _sq
|
||
_cn = _sq.connect(str(_db))
|
||
_cn.execute("create table sessions (id text primary key, title text, custom_title text, "
|
||
"status text, cwd text, updated_at integer, last_activity_at integer, "
|
||
"deleted_at integer)")
|
||
_cn.execute("insert into sessions values (?,?,?,?,?,?,?,?)",
|
||
("sid-wsB-1", "B 区主会话", "", "working",
|
||
str(_b).replace("\\", "/"), int(time.time() * 1000),
|
||
int(time.time() * 1000), None))
|
||
_cn.commit()
|
||
_cn.close()
|
||
except Exception as _e: # pragma: no cover
|
||
pass # 造库失败 ⇒ 走下面 live_ok 的如实报错,⛔ 不假装通过
|
||
_c_on = _a / "tmp" / "peer.on.json"
|
||
_dump(_c_on, {"workspace": str(_a).replace("\\", "/"),
|
||
"inbox": "tmp/supervise-inbox",
|
||
"host_db": str(_db).replace("\\", "/"),
|
||
"peer_workspaces": [str(_b).replace("\\", "/")]})
|
||
gs1 = _load_board(_c_on, _a, "bd_peer_on").goal_files()
|
||
p1 = [g.get("peer") for g in gs1 if g.get("peer")]
|
||
a1 = [g for g in gs1 if g.get("active")]
|
||
live_ok = (len(gs1) == 2 and p1 == ["wsB"] and len(a1) == 1)
|
||
live_txt = "登记 1 个 peer ⇒ goal_files()=%d 格(peer=%s,active %d 个)" % (
|
||
len(gs1), p1, len(a1))
|
||
# ⑨ **变异对照**(🔴 判据必须可证伪:去掉 `peer_workspaces` ⇒ 必须回落到 1 格)
|
||
_c_off = _a / "tmp" / "peer.off.json"
|
||
_dump(_c_off, {"workspace": str(_a).replace("\\", "/"),
|
||
"inbox": "tmp/supervise-inbox"})
|
||
gs0 = _load_board(_c_off, _a, "bd_peer_off").goal_files()
|
||
p0 = [g.get("peer") for g in gs0 if g.get("peer")]
|
||
mut_ok = (len(gs0) == 1 and not p0)
|
||
mut_txt = "去掉 peer_workspaces ⇒ %d 格(peer %d 个)" % (len(gs0), len(p0))
|
||
# ⑪ 🔴 **变异对照 ②:把"该 peer 的会话删光" ⇒ 它必须从 tab 掉出去**
|
||
# (守 2026-10-04 用户口径「tab 要把已经不再 会话列表的工作区 目标移除」)
|
||
# ⚠️ 这一条是本轮新加的过滤的**证伪项**:⛔ 没有它,那个过滤写错了也没人发现
|
||
# (同族:判据必须能证伪 ⇒ 打掉接线必须报红)。
|
||
_db2 = Path(_td) / "fake-host2.db" # ⚠️ 同上,必须包 `Path()`
|
||
try:
|
||
import sqlite3 as _sq2
|
||
_cn2 = _sq2.connect(str(_db2))
|
||
_cn2.execute("create table sessions (id text primary key, title text, custom_title text, "
|
||
"status text, cwd text, updated_at integer, last_activity_at integer, "
|
||
"deleted_at integer)")
|
||
# ⚠️ **只有已删会话**(`deleted_at` 非空)⇒ 等价于"用户把会话全删了"
|
||
_cn2.execute("insert into sessions values (?,?,?,?,?,?,?,?)",
|
||
("sid-wsB-del", "B 区(已删)", "", "completed",
|
||
str(_b).replace("\\", "/"), int(time.time() * 1000),
|
||
int(time.time() * 1000), int(time.time() * 1000)))
|
||
_cn2.commit()
|
||
_cn2.close()
|
||
except Exception: # pragma: no cover
|
||
pass
|
||
_c_del = _a / "tmp" / "peer.del.json"
|
||
_dump(_c_del, {"workspace": str(_a).replace("\\", "/"),
|
||
"inbox": "tmp/supervise-inbox",
|
||
"host_db": str(_db2).replace("\\", "/"),
|
||
"peer_workspaces": [str(_b).replace("\\", "/")]})
|
||
gs2 = _load_board(_c_del, _a, "bd_peer_del").goal_files()
|
||
p2 = [g.get("peer") for g in gs2 if g.get("peer")]
|
||
gone_ok = (len(gs2) == 1 and not p2)
|
||
gone_txt = "peer 会话全删 ⇒ %d 格(peer %s)" % (len(gs2), p2)
|
||
finally:
|
||
shutil.rmtree(_td, ignore_errors=True)
|
||
# ⑩ 生产侧:⛔ **不硬编码路径** —— 指不到真工作区 ⇒ SKIP(⛔ 不假装通过)
|
||
# 🔴🔴 2026-10-05 修一处**恒红判据**(实测 `PASS 96/FAIL 1` → `95/2`,多出的正是本条的 FAIL):
|
||
# 老写法 `prod_ok = (_n >= 1 and len(pp) == _n and len(ap) == 1)` 要求
|
||
# **登记几个 peer 就必须出几格**。可是 peer 工作区**没建目标时本来就不该出格** ——
|
||
# 同一组判据的第 ⑪ 条(`gone_ok`)写的正是这个语义("peer 已不在会话列表 ⇒ 不占 tab"),
|
||
# **两条自相矛盾**:⑩ 要 `len(pp)==_n`,⑪ 又认可少出格。
|
||
# ⇒ 实测现场:ai1net 登记了 2 个 peer(`vibe-product` ✅ 有 `goal.json` /
|
||
# `agent-product` ❌ 还没建目标)⇒ `_n=2` 但 `len(pp)=1` ⇒ **必红**。
|
||
# ⚠️ 这是"恒红比漏网更坏"的又一实例:只要有任何 peer 区没活跃目标,基线就永远红
|
||
# ⇒ 之后跑任何变异都"看着有红",验证作废。按纪律③「基线先全绿」必须先修它。
|
||
# 正解=判**包含关系 + 上限**,⛔ 不是判**数量相等**:
|
||
# · 出格数 ≤ 登记数(⛔ 出格数超登记 ⇒ 有来源不明的格,真问题)
|
||
# · 每一格都必须在登记表里(⛔ 否则是把别区数据挂到本区名下)
|
||
# · 本区活跃目标恰好 1 个(⛔ 多/少都说明看板认错了 active)
|
||
# ⚠️ 放宽后**仍可证伪**:① 把某格来源改成本区 ⇒ `pp_set ⊆ reg` 失败;
|
||
# ② 篡改出格数 > 登记数 ⇒ 上限失败;③ active 改 0/2 ⇒ 失败。均有变异可抓。
|
||
_pcfg = WS / ".workbuddy" / "collab" / "collabd.config.json"
|
||
if not _pcfg.is_file():
|
||
prod_ok, prod_txt = True, "SKIP:读不到生产配置(%s)" % _pcfg
|
||
else:
|
||
try:
|
||
# ⚠️ 两侧粒度不同,⛔ 不能直接比字符串:
|
||
# · `collabd.config.json` 的 `peer_workspaces` = **全路径**(`E:/ProgramData/AIProject/vibe-product`)
|
||
# · `goal_files()` 出的 `peer` 字段 = **短名**(`vibe-product`,看板 tab 就用它做标题)
|
||
# 第一版直接 `set(pp) <= set(_reg)` ⇒ 恒 False(`vibe-product` ≠ 全路径)⇒ **判据写成了恒红**。
|
||
# 正解:把登记的全路径**取 basename** 再比。
|
||
_reg_paths = [str(x).replace("\\", "/") for x in
|
||
((json.loads(_pcfg.read_text(encoding="utf-8")) or {}).get(
|
||
"peer_workspaces") or [])]
|
||
_reg = [p.rstrip("/").rsplit("/", 1)[-1].lower() for p in _reg_paths]
|
||
_n = len(_reg)
|
||
gp = _load_board(_pcfg, None, "bd_peer_prod").goal_files()
|
||
pp = [str(g.get("peer")).rstrip("/").rsplit("/", 1)[-1].lower()
|
||
for g in gp if g.get("peer")]
|
||
ap = [g for g in gp if g.get("active")]
|
||
# 出格数 ≤ 登记数(peer 没建目标 ⇒ 少出格=正常)∧ 每格都在登记表里 ∧ active 恰 1
|
||
prod_ok = (_n >= 1 and len(pp) <= _n and set(pp) <= set(_reg) and len(ap) == 1)
|
||
_miss = [r for r in _reg if r not in set(pp)]
|
||
prod_txt = ("生产配置登记 %d 个 peer ⇒ %d 格(peer=%s,active %d)"
|
||
"|未出格(无活跃目标)=%s" % (_n, len(gp), pp, len(ap), _miss))
|
||
except Exception as e: # pragma: no cover
|
||
prod_ok, prod_txt = False, "抛异常:%s" % e
|
||
return [
|
||
("① 有 `_peer_workspaces()`(配置驱动的并列查看)", has_fn),
|
||
("② **排除本工作区**(⛔ 否则活跃目标出现两格)", excludes_self),
|
||
("③ 去重键带**工作区前缀**(⛔ 同名目标互相顶掉)", key_prefixed),
|
||
("④ `_goal_block()` **透传** peer(⛔ 上游加了下游不带=前端看不到)", block_carries),
|
||
("④ 调用点按位置传入 peer", call_passes),
|
||
("⑤ `_goal_block` 形参含 peer/ws_root(⛔ 少了 ⇒ TypeError ⇒ 看板 warming):%s" % sig_txt,
|
||
sig_ok),
|
||
("⑥ 前端 tab 显示 peer 标记(⛔ 否则三格标题一样,读者分不清)", front_shows),
|
||
("⑦ **严格只读**(⛔ 源码里不得有写对方文件的调用)", no_write),
|
||
("⑧ **现网真读数**(真磁盘两工作区+干净环境):%s" % live_txt, live_ok),
|
||
("⑨ **配置缺省 ⇒ 回落 1 格**(向后兼容;⛔ 它只守「配置可关」这一侧,抓不住代码坏 ⇒ 证伪看 ⑧):%s"
|
||
% mut_txt, mut_ok),
|
||
("⑩ 生产侧按生产配置现读:%s" % prod_txt, prod_ok),
|
||
("⑪ **peer 已不在会话列表 ⇒ 不占 tab**(用户 2026-10-04 口径;⛔ 判据是**未删会话数**,不是心跳):%s"
|
||
% gone_txt, gone_ok),
|
||
]
|
||
|
||
|
||
@case("跨工作区 peer 格:⛔ **不得吃本工作区数据**(吃了=把本区执行情况挂到别人名下=说假话)")
|
||
def t_peer_block_no_self_data():
|
||
"""🔴 2026-10-03 16:0x 用户报障,逐字:「**选择另一个工作区目标 tab 下面没有显示对应工作区目标和执行情况**」
|
||
|
||
🔴🔴 **实测真因(比"没显示"更糟 —— 看板在说假话)**:`build()` 里的 `tasks`/`srows`/`st`
|
||
**只取一次、所有格共用** ⇒ 三格的 `labor`/`sessions`/`progress` **md5 完全相同**
|
||
⇒ 切到 peer 那格,看到的其实是**本工作区**的执行情况,只是标题挂着对方工作区的名字。
|
||
✅ 修法:peer 格只喂 `_peer_tasks()`/`_peer_srows()`/`_peer_state()` —— **只读对方目录下的文件**;
|
||
读不到 ⇒ **空 + 界面如实说明**,⛔ 绝不拿本工作区的数据补位。
|
||
🔴 **判据可证伪**:把 `_peer_tasks(_wr)` 换回本区的 `tasks` ⇒ ⑤ **必然报红**(两侧 tasks 变相同)。
|
||
"""
|
||
import tempfile
|
||
src = (HERE / "board.py").read_text(encoding="utf-8")
|
||
# ① peer 分支喂的是对方数据源
|
||
feeds_peer = ("_peer_tasks(_wr)" in src and "_peer_state(_wr)" in src
|
||
and "_peer_srows(srows, _wr)" in src)
|
||
# ② 补捞(⛔ 没有 ⇒ 全库最近 50 条里捞不到对方会话 ⇒ 显示 0 条=假象)
|
||
has_backfill = "_peer_session_rows" in src
|
||
# ③ peer 格的作用域要换到对方工作区
|
||
scope_swaps = 'sc["workspace"] = ws_root' in src
|
||
# ④ 本区切分基准只按本区目标算
|
||
own_only = "if not it.get(\"peer\")" in src
|
||
# ⑤ 真读数(本区有台账 / 对方无 ⇒ peer 格必须为空,且 ⛔ 不得等于本区的)
|
||
_td = tempfile.mkdtemp(prefix="selftest-peerblk-")
|
||
live_ok, live_txt = True, "SKIP:未跑"
|
||
try:
|
||
A = Path(_td) / "wsA"
|
||
B = Path(_td) / "wsB"
|
||
for _r, _gid in ((A, "wsA"), (B, "wsB")):
|
||
(_r / "tmp" / "supervise-inbox").mkdir(parents=True, exist_ok=True)
|
||
(_r / "tmp" / "supervise-inbox" / "goal.json").write_text(
|
||
json.dumps({"id": _gid, "title": _gid + " 目标", "short": _gid, "topics": [_gid]},
|
||
ensure_ascii=False), encoding="utf-8")
|
||
(A / "tmp" / "supervise-inbox" / "tasks.json").write_text(
|
||
json.dumps({"S1": {"state": "done", "line": "wsA"}}, ensure_ascii=False),
|
||
encoding="utf-8")
|
||
cfg = A / "tmp" / "cfg.json"
|
||
cfg.write_text(json.dumps({"workspace": str(A).replace("\\", "/"),
|
||
"inbox": "tmp/supervise-inbox",
|
||
"peer_workspaces": [str(B).replace("\\", "/")]},
|
||
ensure_ascii=False), encoding="utf-8")
|
||
_old = {k: os.environ.get(k) for k in ("COLLABD_CONFIG", "DSH_COLLAB_WS")}
|
||
try:
|
||
os.environ["COLLABD_CONFIG"] = str(cfg)
|
||
os.environ["DSH_COLLAB_WS"] = str(A)
|
||
_u = __import__("importlib.util", fromlist=["util"])
|
||
_sp = _u.spec_from_file_location("bd_peerblk", HERE / "board.py")
|
||
bd = _u.module_from_spec(_sp)
|
||
_sp.loader.exec_module(bd)
|
||
try:
|
||
gs = (bd.build() or {}).get("goals") or []
|
||
except Exception as e: # pragma: no cover
|
||
gs, live_txt = [], "SKIP:build() 抛异常(%r)" % e
|
||
if gs:
|
||
own = [g for g in gs if not g.get("peer")]
|
||
per = [g for g in gs if g.get("peer")]
|
||
ta = (own[0].get("tasks") if own else None) or {}
|
||
tb = (per[0].get("tasks") if per else None) or {}
|
||
same = (json.dumps(ta, sort_keys=True, ensure_ascii=False)
|
||
== json.dumps(tb, sort_keys=True, ensure_ascii=False))
|
||
live_ok = bool(ta) and (not tb) and (not same)
|
||
live_txt = "本区 tasks=%d 件 / peer tasks=%d 件 / 两者相同?%s" % (
|
||
len(ta), len(tb), same)
|
||
finally:
|
||
for _k, _v in _old.items():
|
||
if _v is None:
|
||
os.environ.pop(_k, None)
|
||
else:
|
||
os.environ[_k] = _v
|
||
finally:
|
||
shutil.rmtree(_td, ignore_errors=True)
|
||
return [
|
||
("① peer 分支喂的是**对方**数据源 `_peer_tasks/_peer_srows/_peer_state`(⛔ 不是本区 tasks/srows/st)",
|
||
feeds_peer),
|
||
("② 有 `_peer_session_rows()` **补捞**(⛔ 没有 ⇒ 全库最近 50 条捞不到对方会话 ⇒ 显示 0 条=假象)",
|
||
has_backfill),
|
||
("③ peer 格把作用域换到**对方工作区**(⛔ 不换 ⇒ `_sessions()` 的 cwd 硬过滤把对方会话全排除)",
|
||
scope_swaps),
|
||
("④ 本区切分基准**只按本区目标**算(⛔ 混入 peer topics ⇒ 台账件两边都不进 ⇒ 静默丢失)", own_only),
|
||
("⑤ **真读数**(本区有台账/对方无 ⇒ peer 格必须为空且 ≠ 本区):%s" % live_txt, live_ok),
|
||
]
|
||
|
||
|
||
@case("跨工作区 tab:**有差异的字段必须逐个被视图取到**(⛔ 漏一个 ⇒ 静默沿用本区 ⇒ 几个 tab 看起来一样)")
|
||
def t_view_takes_all_scoped_fields():
|
||
"""🔴 2026-10-03 20:2x 用户报障(逐字):「**为什么不同目标,协作程序框图里面的内容是一样的**」
|
||
|
||
🔴🔴 **真因(实测,不是猜)**:服务端**数据是对的**(本区块内 `sessions_checks=4` 条、
|
||
两个 peer 块各 0 条),但前端 `scopeView()` 合并视图时**只显式取了 7 个字段**
|
||
⇒ 其余字段**原封不动沿用顶层 `d`**(=**本工作区**那份)
|
||
⇒ 切 tab 时「协作程序状态/检查状态/检查会话/完成情况」**全都显示同一个工作区的**。
|
||
🔴 **这是我今天第三次踩「加字段必须顺着数据流查到消费点」**(前两次:加了没渲染 /
|
||
判据写死字面量)⇒ **光靠"记得改"已经不管用了**。
|
||
✅ **本条判据是数据驱动的**:跑一次真 `build()` ⇒ 逐字段比较两块 ⇒
|
||
**凡"两块有差异"而"视图没取"的字段一律报红** ⇒ 以后新增字段漏了会被自动抓住。
|
||
"""
|
||
import re as _re2
|
||
_root = os.environ.get("DSH_COLLAB_WS") or os.environ.get("DSH_WS_ROOT") or ""
|
||
_pcfg = (Path(_root) / ".workbuddy" / "collab" / "collabd.config.json") if _root else None
|
||
if not _pcfg or not _pcfg.is_file():
|
||
return [("SKIP:没指到真工作区(跑的时候设 `DSH_COLLAB_WS=<工作区>` 才会真跑)", True)]
|
||
_old = {k: os.environ.get(k) for k in ("COLLABD_CONFIG", "DSH_COLLAB_WS")}
|
||
try:
|
||
os.environ["COLLABD_CONFIG"] = str(_pcfg)
|
||
os.environ.pop("DSH_COLLAB_WS", None)
|
||
_u = __import__("importlib.util", fromlist=["util"])
|
||
_sp = _u.spec_from_file_location("bd_viewchk", HERE / "board.py")
|
||
bd = _u.module_from_spec(_sp)
|
||
_sp.loader.exec_module(bd)
|
||
gs = (bd.build() or {}).get("goals") or []
|
||
if len(gs) < 2:
|
||
return [("SKIP:本工作区只登记了 1 个目标(⛔ 比不出差异)", True)]
|
||
a, b2 = gs[0], gs[1]
|
||
_IDENT = {"key", "peer", "ws_root", "active", "life", "life_at", "life_by", "peer_scope"}
|
||
# 🔴🔴 2026-10-05 12:0x 归位两个**后端排序键**(实测取证,不是为了让判据变绿而塞进豁免):
|
||
# `tab_created`=该块的 `declared_at`(排序键)、`tab_i`=排序后重编的 tab 序号
|
||
# —— 二者由 `board.py` 在 `blocks.sort(key=…)` 里**当场用掉**,
|
||
# 前端 `board.html` 对它们的**消费点命中数 = 0**(它们只用于后端排序)⇒ **不是视图字段**,
|
||
# ⛔ 要求 `scopeView()` 去取,等于要求视图去取一个它根本不渲染的东西。
|
||
# ⚠️ 反向自证:判据**仍然有牙** —— 本次真漏的 `ws_name`(渲染点 `renderProject()` L660 读
|
||
# `d.ws_name`)**不在**此表内,修它之前判据照红(见变异对照)。
|
||
_IDENT |= {"tab_created", "tab_i"}
|
||
_diff = [k for k in sorted(set(a.keys()) | set(b2.keys())) if k not in _IDENT
|
||
and json.dumps(a.get(k), sort_keys=True, ensure_ascii=False)
|
||
!= json.dumps(b2.get(k), sort_keys=True, ensure_ascii=False)]
|
||
# 🔴 提取赋值清单前**先剥掉注释** —— ⛔ 否则"把某行注释掉"这种变异**照样匹配得到**
|
||
# (本轮实测踩到:注释掉 `v.sessions_checks=` 之后判据仍报绿 ⇒ 判据被注释骗了)。
|
||
_html = (HERE.parent / "assets" / "board.html").read_text(encoding="utf-8")
|
||
_html = _re2.sub(r"/\*.*?\*/", "", _html, flags=_re2.S)
|
||
_html = _re2.sub(r"//[^\n]*", "", _html)
|
||
_fn = _re2.search(r"function scopeView\(d,g\)\{.*?\n\}", _html, _re2.S)
|
||
_taken = set(_re2.findall(r"v\.(\w+)\s*=", _fn.group(0))) if _fn else set()
|
||
_missing = [k for k in _diff if k not in _taken]
|
||
return [("两块有差异的字段 %d 个,**视图必须逐个取到**(⛔ 漏了会静默沿用本区):%s"
|
||
% (len(_diff), ("全部已取 ✔" if not _missing else "⛔ 漏了 %s" % _missing)),
|
||
not _missing)]
|
||
except Exception as _e: # pragma: no cover
|
||
return [("用例异常:%r" % _e, False)]
|
||
finally:
|
||
for _k, _v in _old.items():
|
||
if _v is None:
|
||
os.environ.pop(_k, None)
|
||
else:
|
||
os.environ[_k] = _v
|
||
|
||
|
||
@case("每个 hook 都能真正跑起来(⛔ 不许一导入就崩)")
|
||
def t_hooks_actually_runnable():
|
||
"""🔴🔴 2026-10-03 **实测抓到的真缺陷**(用户报障「协作程序相关的连线 有用的保留 没用的
|
||
可以去掉」,我逐条去核实"这三条线还活着吗"时撞出来的):
|
||
|
||
⚠️ **事故链**:用户 10-02 报「后台一闪一闪」⇒ 为根治闪屏,给 `wb-result-hook.py` 加了
|
||
`_win_pythonw()`,写成 `PYW = _win_pythonw()`,而**函数体内部引用 `PYW`**
|
||
⇒ 调用时 `PYW` 尚未赋值 ⇒ **模块导入即 `NameError`**
|
||
⇒ 该 hook 挂的**三个事件(PreToolUse/UserPromptSubmit/SessionEnd)全部静默废掉**
|
||
⇒ 图上「钩子 → 协作程序 --once/--tick」两条线**其实早就断了,只是没人发现**
|
||
(`hook.log` 停在 10-02 21:24、三个 stamp 也停在同一分钟 —— 三处独立证据互相印证)。
|
||
|
||
🔴 **为什么这条判据必须存在**:⛔ 语法检查(`ast.parse`)**照样通过** ——
|
||
`NameError` 是**运行期**错误,语法完全合法。⇒ 「能解析」≠「能跑」,
|
||
光靠语法自检永远抓不到这类缺陷。而它造成的后果是**整条链路静默失效**,
|
||
看板上的连线会一直画着、看着一切正常。
|
||
✅ **本条判据=真的把每个 hook 起一次**(喂最小合法 stdin),断言 `rc=0` 且无 traceback。
|
||
⚠️ 起法要点:⛔ 必须用 `sys.executable`(当前解释器)而**不是** `python`(PATH 可能指向别处);
|
||
喂完**必须等它自己跑完**(hook 会 spawn 后台子进程,不能立刻判)。
|
||
"""
|
||
import subprocess as _sp2
|
||
import tempfile as _tf2
|
||
# 🔴 **只测真 hook**:目录里的 `*.bak-*` 是历史备份,⛔ 不是在跑的钩子
|
||
# (它们恰好也都能起来 ⇒ 混进来只是给输出添噪音,且会让人误以为有 12 个钩子)。
|
||
hooks = sorted(p for p in (HERE / "hooks").glob("*.py") if ".bak-" not in p.name)
|
||
out = []
|
||
for hk in hooks:
|
||
payload = json.dumps({"session_id": "selftest-probe",
|
||
"cwd": str(TEST_WS),
|
||
"hook_event_name": "UserPromptSubmit",
|
||
"prompt": "selftest probe"})
|
||
try:
|
||
r = _sp2.run([sys.executable, str(hk)], input=payload.encode("utf-8"),
|
||
capture_output=True, timeout=60, cwd=str(TEST_WS))
|
||
except Exception as exc:
|
||
out.append(("%s 起不来:%r" % (hk.name, exc), False))
|
||
continue
|
||
err = (r.stderr or b"").decode("utf-8", "replace")
|
||
# 🔴 两个判据缺一不可:退出码 0 **且** stderr 里没有 traceback
|
||
# (有的脚本出错仍返回 0,只看 rc 会漏)。
|
||
out.append(("%s 起得来(rc=%d%s)" % (
|
||
hk.name, r.returncode,
|
||
",stderr 有 traceback:%s" % err.strip().splitlines()[-1] if "Traceback" in err else ""),
|
||
r.returncode == 0 and "Traceback" not in err))
|
||
return out or [("⛔ hooks 目录里一个 hook 都没有 ⇒ 判据本身失效", False)]
|
||
|
||
|
||
@case("架构图:每条线的**两端都落在真实框上**(⛔ 不许线头悬空)")
|
||
def t_board_edges_land_on_boxes():
|
||
"""🔴🔴 2026-10-03 用户红框圈出一条线问「**这跟红框标注的线到底有没有用
|
||
有用就连对目标,没用就删除了**」—— 取证发现它**既没用不了、也没连对**:
|
||
|
||
🔴 **病根=把「与框宽有关的 x」写死**:④ 那条线写死 `x=340`,而它成立的前提是
|
||
当时 `PX=280`、宽 580。后来框**收窄到 470** 且改成**跟随画布中心** ⇒ 框变 405..875
|
||
⇒ 340 掉到框外**左侧 65px** ⇒ 箭头悬空。
|
||
🔴 **同一个病 09-30 修过一次**(当时原话「WorkBuddy 左边那根线是要连到哪里?」)
|
||
—— 当时**只改了线的位置、没把它与框宽绑死** ⇒ 框一变就复发。
|
||
⇒ 教训:**⛔ 凡是"与可变量(框宽/框位)有关"的坐标,一律现算,别写死。**
|
||
|
||
✅ **本条判据=把源码里那几个常量取出来重算一遍**,逐条断言:
|
||
① ④ 出口 `RX` **必须落在常驻程序框内**(`PX < RX < PX+PW`)—— 这条就是"连对目标";
|
||
② `RX` **必须小于 `hkX`** —— 否则会**穿过** Hook 框("连过去却穿框"同样是错);
|
||
③ `--once`/`--tick` 两个出口**必须落在 Hook 框顶边之内**;
|
||
④ 四个出口**两两不重叠**(重叠=两条线并成一条、看不出谁是谁)。
|
||
⚠️ 取常量方式:**从源码正则抽**(⛔ 不另写一份副本)—— 副本会与源码漂移,
|
||
那样这条判据就变成了"检查我自己写的数"(同族陷阱:判据必须扎在真源上)。
|
||
⚠️ 已知同源缺陷(本条**抓不到**,⛔ 别以为它是万能的):源码里若有人把 `hkX=430` 又写回
|
||
`hkX=400`,而 `RX` 仍由 `PX+24` 现算 ⇒ 两者同时满足本条 ⇒ 但**观感上 ④ 仍穿框**。
|
||
⇒ 那属于"把条件写复杂了",根治办法是**保持 `RX < hkX` 恒成立**(别靠加更多分支)。
|
||
"""
|
||
import re as _re3
|
||
src = (HERE.parent / "assets" / "board.html").read_text(encoding="utf-8")
|
||
# 🔴🔴 2026-10-03 **又踩一次「朴素扫描被注释骗」**(同族坑,见 P0-36):
|
||
# ④ 那段注释里**逐字写着** `RXV=PX-40`/`RXH=PX+28` ⇒ 正则先命中注释
|
||
# ⇒ 抽出来是一整段散文 ⇒ `eval` 失败 ⇒ 判据整条失效。
|
||
# ✅ **正解=先剥注释再抽**(顺序不能反):块注释 → 行注释 → 再找赋值。
|
||
_code = _re3.sub(r"/\*.*?\*/", "", src, flags=_re3.S)
|
||
_code = _re3.sub(r"(?m)^\s*//.*$", "", _code) # 只吃**整行**注释(行尾注释常跟在代码后)
|
||
_code = _re3.sub(r"//[^\n]*", "", _code)
|
||
def _n(pat, dflt=None, hay=None):
|
||
m = _re3.search(pat, hay if hay is not None else _code)
|
||
return float(m.group(1)) if m else dflt
|
||
CX = _n(r"var CX=(\d+)")
|
||
PW = _n(r"var PW=(\d+)")
|
||
# 🔴 `PX` 源码里是**表达式** `CX-PW/2`(与 PW 同一行声明)⇒ ⛔ 不能按纯数字抽。
|
||
PX = (CX - PW / 2) if (CX is not None and PW is not None) else None
|
||
hkX = _n(r"var hkX=(\d+)")
|
||
hkW = _n(r", hkW=(\d+)")
|
||
if None in (CX, PW, PX, hkX, hkW):
|
||
return [("⛔ 抽不出几何常量(CX/PW/hkX/hkW 实得 %s)⇒ 判据本身失效,⛔ 别当通过用"
|
||
% (CX, PW, hkX, hkW), False)]
|
||
# 🔴🔴 2026-10-03 **自查抓到的一个真漏洞(本条第一版栽在这)**:
|
||
# 第一版 RX 在判据里**自己重算** `PX+24` ⇒ 它检查的是「我算的数」,
|
||
# **不是「源码里那个数」** ⇒ 把源码改成 `var RX=340;`(原事故)它照样报绿。
|
||
# 🔴 这就是判据的经典自欺:**判据与被检对象各算一遍,必然有一份是假的**。
|
||
# ✅ **正解=判据只认源码里的真实写法**:抽 `var ...=` 后面到底是**表达式**还是**字面量**,
|
||
# 若是字面量**直接报红**(那正是"写死与框宽有关的坐标"这个病根本身)。
|
||
# 🔴🔴 2026-10-03 **三改=④ 变折线后本条同步**(用户原话:「**稍微向左边折一下再连过去**」
|
||
# +「**还把 hook 图框挤到旁边 多难看**」):
|
||
# ④ 不再是"一条竖直线",而是 `RXV`(框外左侧竖直段)→ `RYY`(折点)→ `RXH`(**框内**终点)。
|
||
# ⇒ **竖直段看 `RXV`、终点看 `RXH`**,两个都要查;
|
||
# ⚠️ 且新增一条**反向判据**:`hkX` 必须是**它自己的版面值**(⛔ 不许被线牵着走)——
|
||
# 上一版我为腾位置把它 400→430,用户明确指正 ⇒ 这条写进判据防复发。
|
||
res = []
|
||
|
||
def _expr(name, env=None):
|
||
# 🔴 `var RXV=…, RXH=…, RYY=…` 三个变量**在同一行逗号声明** ⇒
|
||
# ① 只有**第一个**变量带 `var`,后两个没有 ⇒ 正则要写成「`var NAME=`」**或**「裸 `NAME=`」
|
||
# (⚠️ 只写 `var %s=` 会漏掉 `RXH`/`RYY`,实测踩到);
|
||
# ② 取值必须止于**下一个逗号或分号**,⛔ 否则 `var RXV=PX-40, RXH=…` 会把后面一起吞进来。
|
||
m = _re3.search(r"\b(?:var\s+)?%s=([^,;]+)" % _re3.escape(name), _code)
|
||
if not m:
|
||
res.append(("⛔ 源码里抽不到 `var %s=` ⇒ 判据失效,⛔ 别当通过用" % name, False))
|
||
return None, None
|
||
e = m.group(1).strip()
|
||
lit = _re3.fullmatch(r"(\d+(?:\.\d+)?)", e)
|
||
if lit:
|
||
res.append(("⛔ 源码 `var %s=%s` 是**写死字面量**(病根:框一改它就落到框外)"
|
||
" ⇒ 必须由 PX 现算" % (name, e), False))
|
||
try:
|
||
# 🔴 源码里 `R4.y` / `R4.h` 是**属性访问**写法 ⇒ eval 环境不能只给 dict,
|
||
# 否则 `R4.y+(R4.h/2)` 会 `AttributeError`(实测踩到 ⇒ 整条判据失效)。
|
||
# ✅ 两种都支持:dict 下标(`R4["y"]`)与属性(`R4.y`)——用一个既支持点又支持下标的类型。
|
||
class _Box(dict):
|
||
__getattr__ = dict.__getitem__
|
||
v = float(eval(e, {"__builtins__": {}}, # noqa: S307
|
||
{"PX": PX, "PW": PW, "CX": CX,
|
||
"R4": _Box({"y": R4Y, "h": R4H})}))
|
||
except Exception:
|
||
res.append(("⛔ `var %s=%s` 求值不了 ⇒ 判据失效,⛔ 别当通过用" % (name, e), False))
|
||
return None, e
|
||
return v, e
|
||
|
||
R4Y, R4H = _n(r"R4=\{y:(\d+)"), _n(r"h:(\d+)\}, R5")
|
||
if R4Y is None or R4H is None:
|
||
return res + [("⛔ 抽不到常驻程序行几何 `R4={y:...,h:...}` ⇒ 判据失效", False)]
|
||
RXV, _ = _expr("RXV")
|
||
RXH, _ = _expr("RXH")
|
||
RYY, _ = _expr("RYY")
|
||
once_x = hkX + 40
|
||
tick_x = PX + PW / 2 + 120
|
||
if RXV is None or RXH is None or RYY is None:
|
||
return res
|
||
res += [
|
||
# 🔴🔴 2026-10-03 **判据跟着改口径**(用户原话:「**线连到左边不行 非的连到下边
|
||
# 是咋想的**」):④ 终点现在**落在框的左边框上**(`RXH==PX`),
|
||
# ⛔ 不再是"框内某点"(旧判据 `PX<RXH<PX+PW` 已不适用 ⇒ 会被误判为红)。
|
||
# ✅ 正确口径=**终点必须落在"某个边框上"**(左边/下边/上边/右边皆可),
|
||
# 唯独⛔ 不能"绕到左边却从下边进"(那是**无意义的折**)。
|
||
("④ 折线**终点 RXH=%.0f 落在框边框上**(左边框 x=%.0f)——⛔ 不是绕左却从下边进"
|
||
% (RXH, PX), abs(RXH - PX) < 0.5),
|
||
# ✅ 折点 y 必须是**框的垂直中点** ⇒ 两段折线(竖 + 横)一眼看懂。
|
||
("④ 折点 RYY=%.0f 落在框的垂直中点 %.0f(⛔ 不贴边、⛔ 不写死)"
|
||
% (RYY, R4Y + R4H / 2), abs(RYY - (R4Y + R4H / 2)) < 0.5),
|
||
("④ **竖直段 RXV=%.0f 在 Hook 框左边 %.0f 之外**(⛔ 不穿 Hook 框)" % (RXV, hkX),
|
||
RXV < hkX),
|
||
# 🔴 新增:**⛔ 不许有多余折段**(每段都得有理由;绕左边再从下边进=白绕)。
|
||
("④ 折线**只有两段**(⛔ 绕左边却从下边钻进来=那几段横移毫无意义)",
|
||
src.count("L'+RXH+' '+(R4.y+R4.h)") == 0),
|
||
# 🔴🔴 新增**反向判据**(防"为凑一条线去挪别人的框"):hkX 必须仍是 400。
|
||
# 上一版我把它 400→430 来腾位置,用户明确指正「把 hook 图框挤到旁边 多难看」。
|
||
("Hook 框左边界 hkX=%.0f **未被连线牵动**(⛔ 框的位置是它自己的版面决策,"
|
||
"不许为凑一条线挪框)" % hkX, hkX == 400),
|
||
("--once 出口 %.0f 落在 Hook 框顶边 %.0f..%.0f 内" % (once_x, hkX, hkX + hkW),
|
||
hkX <= once_x <= hkX + hkW),
|
||
# 🔴🔴 2026-10-05:`--tick` 已删 ⇒ 图上那条出口改标 **`--ensure`**(钩子唯一剩余链接
|
||
# =顺手续命)。⛔ 坐标不动、只换标签 ⇒ 本条判据**原样有效**(仍在验"线头在框内")。
|
||
("--ensure 出口 %.0f 落在 Hook 框顶边 %.0f..%.0f 内" % (tick_x, hkX, hkX + hkW),
|
||
hkX <= tick_x <= hkX + hkW),
|
||
]
|
||
# ④ 两两不重叠:⛔ 重叠=两条线并一条,读者分不出谁是谁(P0-25 同族)
|
||
outs = [("④", RXH), ("--once", once_x), ("--ensure", tick_x)]
|
||
for i in range(len(outs)):
|
||
for j in range(i + 1, len(outs)):
|
||
gap = abs(outs[i][1] - outs[j][1])
|
||
res.append(("%s 与 %s 间隔 %.0fpx(⛔ 不许并成一条)" % (outs[i][0], outs[j][0], gap),
|
||
gap >= 30))
|
||
return res
|
||
|
||
|
||
@case("目标完成 ⇒ 真的收工(⛔ 不许只改状态不关进程);阻碍 ⇒ 不关")
|
||
def t_goal_done_stops_supervise():
|
||
"""🔴🔴 2026-10-03 **接上用户口径第①条**(逐字):
|
||
「**目标完成后需要把目标状态改为 完成,关闭后台任务 和 协作程序
|
||
(有新目标或之前的目标有调整 在打开)**」;
|
||
第②条:「**目标 遇到阻碍** …(**不用关闭后台任务和协作程序**)」。
|
||
|
||
🔴 **改前取证**:`goal_life_set()` 原来**只写 `goal.json` 四个字段就 `return`**,
|
||
全文无 `stop`/`kill` 调用 ⇒ "完成即收工"**在代码里根本没实现**。
|
||
|
||
✅ 本条判据查三件事(**源码级**,⛔ 不靠"跑一遍看看"):
|
||
① `supervise_stop()` **存在**(有停的手段);
|
||
② `goal_life_set()` 在 `life == GOAL_LIFE_DONE` 分支**真的调它**;
|
||
③ `ensure_supervise()` **会清 `guard.stop`** —— 🔴 这条最容易漏:
|
||
停止标志是常驻的退出条件,不清就**永远起不来**("完成"变成不可逆,与口径相反)。
|
||
⚠️ 判据一律**剥注释后**匹配(注释里写着"关闭后台任务"等字样,别被骗)。
|
||
"""
|
||
import ast as _ast
|
||
import re as _re4
|
||
src = (HERE / "collabd.py").read_text(encoding="utf-8")
|
||
|
||
# 🔴🔴 2026-10-04 **改用 AST 取函数体**(P0-54 同族第 3 次):
|
||
# 原实现用 `re.sub(r"#[^\n]*", "", code)` 粗暴剥注释 ⇒ 会把**字符串里的 `#`**
|
||
# 之后内容全删 —— prompt 模板里到处是 `"## 二、…"` ⇒ **函数体被吃掉** ⇒ 假红
|
||
# (实测 `ensure_supervise()` 明明在,判据却报"没清 guard.stop")。
|
||
# ✅ AST 按 `FunctionDef` 切片,字符串里的 `#` 一律不受影响。
|
||
_segs = {}
|
||
try:
|
||
for _n in _ast.walk(_ast.parse(src)):
|
||
if isinstance(_n, _ast.FunctionDef) and _n.name not in _segs:
|
||
_segs[_n.name] = _ast.get_source_segment(src, _n) or ""
|
||
except Exception as _e:
|
||
return [("⛔ 源码解析失败(%s)⇒ 判据没法跑" % _e, False)]
|
||
|
||
def _fn(name):
|
||
return _segs.get(name, "")
|
||
|
||
fn_stop, fn_life, fn_ens = _fn("supervise_stop"), _fn("goal_life_set"), _fn("ensure_supervise")
|
||
res = [
|
||
("`supervise_stop()` 存在(没有它就无从关闭常驻程序)", bool(fn_stop)),
|
||
("`supervise_stop()` 真的能停:写了停止标志 `guard.stop`", "guard.stop" in fn_stop),
|
||
("`supervise_stop()` 有强停兜底(⛔ 只写标志可能停不掉)",
|
||
"taskkill" in fn_stop or "SIGKILL" in fn_stop),
|
||
("`goal_life_set()` 在**已完成**分支调用 `supervise_stop()`(完成 ⇒ 收工)",
|
||
"GOAL_LIFE_DONE" in fn_life and "supervise_stop" in fn_life),
|
||
("`goal_life_set()` 对**阻碍/已暂停**明确不调停(用户口径②:阻碍不用关)",
|
||
("GOAL_LIFE_BLOCK" in fn_life or "保持运行" in fn_life)
|
||
and _re4.search(r"GOAL_LIFE_DONE.*?supervise_stop", fn_life, _re4.S) is not None),
|
||
("`ensure_supervise()` **会清 `guard.stop`**(⛔ 不清 ⇒ 完成后再也起不来)",
|
||
"guard.stop" in fn_ens and "unlink" in fn_ens),
|
||
]
|
||
return res
|
||
|
||
|
||
|
||
@case("跨区自愈:**只补进行中的区**(⛔ 已完成/阻碍/已收工的一律不拉)")
|
||
def t_peer_supervise_sweep():
|
||
"""🔴 2026-10-03 补的跨区自愈(实测坐实缺口:本区 `ensure_supervise()` 只看本区心跳,
|
||
本区日志搜别区名**零命中** ⇒ 别区常驻被外部收走后**没人知道、没人补**)。
|
||
|
||
✅ 本条守三条**越界红线**(少一条就可能去动别的区)——源码级断言:
|
||
① 目标 `lifecycle` **必须是「进行中」**才拉(用户口径:完成=已收工,阻碍=不用开);
|
||
② **⛔ 只读别区心跳**(不得写别区的 `goal.json`/队列 —— 那是别的区主会话的领地);
|
||
③ 有 `guard.stop` 的区**⛔ 不拉**(刚被收工的区,拉起来=对抗用户的停)。
|
||
⚠️ 另守两条工程约束:**接在主循环里**(⛔ 没接=写了函数也不生效)
|
||
+ **有节流**(⛔ 每轮都去 stat 别区是白耗 IO)。
|
||
|
||
🔴🔴 **2026-10-04 修本体 bug(P0-54 同族第 3 次)**:
|
||
原实现用 `re.sub(r"#[^\n]*", "", code)` **粗暴剥注释** ⇒ 它会把
|
||
**字符串字面量里的 `#`** 之后的内容**全删掉** —— 而 prompt 模板里到处是
|
||
`"## 二、…"`、`"## 三、…"` ⇒ **它后面的函数体整个被吃掉**
|
||
⇒ `peer_supervise_sweep()` 明明在源码里(实测 `collabd.py:403`),
|
||
判据却报「⛔ 源码里没有」⇒ **假红**。
|
||
✅ 正解=**用 `ast` 按函数体切片**(P0-54 早就写了「等宽要按函数体切片」,
|
||
这条是同一个病的第 3 次 ⇒ 这次改成 AST,⛔ 不再用正则)。
|
||
"""
|
||
import ast as _ast
|
||
src = (HERE / "collabd.py").read_text(encoding="utf-8")
|
||
fn = ""
|
||
try:
|
||
_tree = _ast.parse(src)
|
||
for _n in _ast.walk(_tree):
|
||
if isinstance(_n, _ast.FunctionDef) and _n.name == "peer_supervise_sweep":
|
||
fn = _ast.get_source_segment(src, _n) or ""
|
||
break
|
||
except Exception as _e:
|
||
return [("⛔ 源码解析失败(%s)⇒ 判据没法跑" % _e, False)]
|
||
# 「接在主循环里」要**全文**看(调用点必然在函数体外)
|
||
_code = "\n".join(ln for ln in src.splitlines()
|
||
if not ln.lstrip().startswith("#"))
|
||
if not fn:
|
||
return [("⛔ 源码里没有 `peer_supervise_sweep()` ⇒ 跨区自愈不存在", False)]
|
||
return [
|
||
("跨区自愈函数存在(⛔ 缺它=别区停了没人管)", bool(fn)),
|
||
# 🔴🔴 2026-10-04 改:闸① **收窄** —— 目标非「进行中」时**先看队列**,
|
||
# 有未完成件仍拉(用户口径「检查程序必然要去处理队列」)。
|
||
# ⛔ 旧断言("非进行中一律不拉")已过时,不改它会**红着掩盖新口径**。
|
||
("闸①:目标非「进行中」时**改看队列**(有未完成件仍拉 —— 有活没人干)",
|
||
"GOAL_LIFE_RUN" in fn and "queue_pending_of(" in fn),
|
||
# ⚠️ 判据写准:`read_text` 在函数里**本来就有一处合法用途**(读别区心跳)⇒
|
||
# ⛔ 不能断言"函数里没有 read_text"(那会误伤心跳那行 —— 我第一版就是这么写错的)。
|
||
# 真正要禁的是「**自己另写一段读 `tasks.json` 的代码**」⇒ 直接查那个特征串。
|
||
("闸①:用的是**唯一事实源** `queue_pending_of()`(⛔ 不在这里另写读 tasks 的代码)",
|
||
"queue_pending_of(" in fn and "tasks.json" not in fn),
|
||
("闸②:⛔ **只读**别区心跳(只 read_text 心跳,⛔ 不写别区 goal.json)",
|
||
"supervise-heartbeat.json" in fn and fn.count("write_text") == 0),
|
||
("闸③:有 `guard.stop` 的区 ⛔ 不拉(别对抗用户的收工)", "guard.stop" in fn),
|
||
("起别区时用**它自己的副本+它自己的 COLLABD_CONFIG**(⛔ 指向本区就串了)",
|
||
"COLLABD_CONFIG" in fn and ".workbuddy" in fn),
|
||
("有节流 `PEER_SWEEP_EVERY`(⛔ 每轮都碰别区=白耗 IO)", "PEER_SWEEP_EVERY" in src),
|
||
("**接在主循环里**(⛔ 写了函数不接=永不生效)", "peer_supervise_sweep()" in _code),
|
||
("Popen 带 `creationflags`(⛔ 漏了会闪黑窗,仓内另有判据守这条)",
|
||
"creationflags" in fn),
|
||
]
|
||
|
||
|
||
|
||
def _tail_has_board_note(code: str) -> bool:
|
||
"""🔴 2026-10-05:判「末尾『后续协作』段是否说清**本区不起看板**」。
|
||
|
||
⚠️ **为什么不能全文 `in` 一把梭**(本轮实测踩到):`init_workspace.py` 里
|
||
`peer_workspaces` 全文出现 **3 次**、`不起看板` **2 次**,其中**多数在注释里**
|
||
⇒ 用 `"不起看板" in src` 判,**把那段 print 整块删掉后判据照样绿**(恒绿假修)。
|
||
✅ 正解=**锚定到「✅ 完成。后续协作」之后的那段代码**(调用方传进来的 `code` 已剥注释,
|
||
⇒ 注释里的字样不会命中),且**两句 print 都要在**:
|
||
· 一句说「本区⛔ 不起看板」(否则用户以为要自己起一份)
|
||
· 一句指路「加进主工作区的 `peer_workspaces`」(否则知道不起、却不知道去哪并看)
|
||
"""
|
||
i = code.find("✅ 完成。后续协作")
|
||
if i < 0:
|
||
return False
|
||
seg = code[i:i + 1500]
|
||
return ("不起看板" in seg) and ("peer_workspaces" in seg)
|
||
|
||
|
||
@case("初始化脚本:**指向本区副本**(⛔ 不许再让用户跑技能目录那份)")
|
||
def t_init_points_to_own_copy():
|
||
"""🔴 2026-10-03 修的误导:`init_workspace.py` 原先 `--ensure-goal-dir` 与末尾
|
||
「后续协作」提示**全部指向技能目录的 `collabd.py`/`board.py`**。
|
||
|
||
🔴 **为什么有害**(不是洁癖):定案口径是**各工作区程序各自独立**(技能目录=源,
|
||
`.workbuddy/collab/`=各区副本,`deploy_code.py` 分发)⇒ 让用户跑目录那份
|
||
**会拉起第二个常驻、写到技能目录的 `tmp/`**(⛔ 那不是任何工作区的 inbox)
|
||
⇒ 状态写到别处:看板读不到、故障排查被带偏。
|
||
✅ 判据:`--ensure-goal-dir` 的实参**优先取本区副本**,末尾提示同理;
|
||
⚠️ 副本不存在时**允许回落**,但⛔ 必须**打印说明**(静默回落=用户以为在跑副本)。
|
||
"""
|
||
src = (HERE / "init_workspace.py").read_text(encoding="utf-8")
|
||
code = re.sub(r"\"\"\".*?\"\"\"", "", src, flags=re.S)
|
||
code = re.sub(r"(?m)^\s*#.*$", "", code)
|
||
res = [
|
||
("有「取本区副本」这一步(`_own = ws/.workbuddy/collab/collabd.py`)",
|
||
".workbuddy" in code and "collabd.py" in code),
|
||
("`--ensure-goal-dir` 用的是**本区副本优先**的路径(⛔ 不是硬编码 SK)",
|
||
code.count("SK / \"collabd.py\"") <= 2 and "_cd" in code),
|
||
("副本缺失时**回落**用技能目录那份(⛔ 硬失败会挡住首次初始化)",
|
||
"_fallback" in code),
|
||
("回落时**打印说明**(⛔ 静默回落=用户以为在跑副本)",
|
||
code.count("print") >= 2 and "回落" in code),
|
||
# 🔴 2026-10-05 改判据:末条原来要求 `_bd_show` 存在(=末尾会打印「起看板」命令)。
|
||
# ⚠️ 那是**旧口径**:SKILL.md:809 早有 10-03 定案「看板只保留一份(主工作区那份)」,
|
||
# 各区⛔ 不起看板 ⇒ 新区末尾**本就不该**再打印 board 启动命令。
|
||
# ⇒ 判据跟着改:仍然要求「后续协作提示指向副本」(`_cd_show`),
|
||
# 但另一条换成**新口径的事实**——末尾必须说清「本区不起看板 + 要并看走 peer_workspaces」。
|
||
# ⛔ 别把 `_bd_show` 塞回去凑数:那会把出错的形态提示又引回来。
|
||
("末尾「后续协作」提示也指向副本/带说明(⛔ 别再让人跑目录那份)",
|
||
"_cd_show" in code),
|
||
("末尾提示**不起本区看板**、要并看走 `peer_workspaces`(2026-10-03 定案:看板只保留一份)",
|
||
_tail_has_board_note(code)),
|
||
]
|
||
return res
|
||
|
||
|
||
|
||
@case("自指型判据:**必须被排除**(⛔ 否则 `goal_state()` 只能自己满足自己,永远锁死)")
|
||
def t_acc_selfref_excluded():
|
||
"""🔴🔴 2026-10-04 用户点破:「**A6 判据自指 ⇒ `goal_state()` 逻辑死锁**」。
|
||
|
||
🔴 **死锁形状**(不是时序问题、⛔ 也等主会话解决不了):
|
||
`goal_state()` 的**路3 要读这条判据**,而这条判据判的内容**就是**「三路并集的结论」
|
||
⇒ 它**只能由"自己写自己过"满足** ⇒ 固定点之外永不到达。
|
||
🔴 **实测坐实**:把 A6 临时写成「过|…」⇒ `goal_state()` 立刻 pass;还原 ⇒ 立刻回 open。
|
||
|
||
✅ 修法=**路3 排除自指型判据**,判据查四件事(源码级 + 行为级各一半):
|
||
① `_acc_is_selfref()` 存在,且**登记制**(`_ACC_SELFREF_KEYS`)与**文本兜底**(`_ACC_SELFREF_TOKENS`)**两条都在**;
|
||
② `goal_state()` 路3 与 `_acc_short()` **两处都剔**(⛔ 只剔一处 ⇒ 唤醒正文仍会念「还差 A6」,
|
||
造出「`goal_state()` 已判 pass、这里还在报」的新自相矛盾);
|
||
③ **fail-closed**:剔完一条不剩 ⇒ 判「未声明」,⛔ 不算过;
|
||
④ **兜底必须窄**(`_ACC_SELFREF_WIN` 有上限)—— 扫整段会**误伤 A2**(它正文里顺口提了一句
|
||
`goal_state()` 只读文本)⇒ 悄悄放掉一条真门禁,**比原 bug 更坏**(原 bug 是吵,这个是静默失守)。
|
||
|
||
⚠️ **源码级那两条必须按 AST 精确定位,⛔ 不可用「函数体里含某串」**(2026-10-04 变异验证实测栽过):
|
||
- 🔴 `_acc_is_selfref` 在 `goal_state()` 里出现**两次**(`_real` 的推导式 + `_dropped` 的推导式)
|
||
⇒ 判据写成 `"_acc_is_selfref" in fn_gs` 时,**只把 `_real` 那处改掉照样 PASS**(漏网)。
|
||
- 🔴 `undeclared` 在 `goal_state()` 尾部 `return "pass" if eff else "undeclared"` 里也有
|
||
⇒ 判据写成 `re.search(r"if not _real.*?undeclared", ...)` 时,**把中间那个 `return` 拆掉照样 PASS**(漏网)。
|
||
⇒ 一律改成**在 AST 上找指定 Assign / 指定 If 的 body**,别用"整段文本里有没有"。
|
||
"""
|
||
import ast as _ast5
|
||
import re as _re5
|
||
src = (HERE / "collabd.py").read_text(encoding="utf-8")
|
||
code = _re5.sub(r"\"\"\".*?\"\"\"", "", src, flags=_re5.S)
|
||
code = _re5.sub(r"(?m)^\s*#.*$", "", code)
|
||
|
||
def _fn_src(name):
|
||
m = _re5.search(r"\ndef %s\(.*?(?=\ndef )" % _re5.escape(name), code, _re5.S)
|
||
return m.group(0) if m else ""
|
||
|
||
def _fn_node(name):
|
||
"""AST 上**唯一定位**某个顶层函数(同名多个 ⇒ 返回 None,⛔ 不许取第一个)。"""
|
||
hits = [n for n in _ast5.walk(_ast5.parse(src))
|
||
if isinstance(n, _ast5.FunctionDef) and n.name == name
|
||
and isinstance(getattr(n, "col_offset", 0), int)]
|
||
tops = [n for n in hits if n.col_offset == 0]
|
||
return tops[0] if len(tops) == 1 else None
|
||
|
||
def _unparse(nodes):
|
||
return "".join(_ast5.unparse(n) for n in nodes)
|
||
|
||
fn_sr, fn_gs, fn_as = _fn_src("_acc_is_selfref"), _fn_src("goal_state"), _fn_src("_acc_short")
|
||
nd_gs, nd_as = _fn_node("goal_state"), _fn_node("_acc_short")
|
||
|
||
# --- AST:`_real = [...]` 这个**赋值**的推导式里到底有没有剔 ---
|
||
# ⚠️⚠️ 判据写法上的两个坑(2026-10-04 变异验证实测各栽一次):
|
||
# ① `_real` 是 **Assign 左侧的名字**,⛔ **推导式的 target 里没有它**
|
||
# (`goal_state` 的是 `(_k, _v)`、`_acc_short` 的是 `k`)⇒ 只能在 Assign 上找。
|
||
# ② 过滤条件在**生成器的 ifs 子句**里,⛔ 不在 `elt`;且 `elt` 是**单个节点**
|
||
# (`goal_state` 那个还是 Tuple)⇒ `list(elt)` 会 TypeError。
|
||
def _real_filter_excludes_selfref(node):
|
||
if node is None:
|
||
return False
|
||
for n in _ast5.walk(node):
|
||
if not (isinstance(n, _ast5.Assign) and len(n.targets) == 1
|
||
and isinstance(n.targets[0], _ast5.Name)
|
||
and n.targets[0].id == "_real"):
|
||
continue
|
||
if not isinstance(n.value, _ast5.ListComp):
|
||
continue
|
||
scan = list(_ast5.walk(n.value.elt)) + [x for g in n.value.generators
|
||
for i in g.ifs for x in _ast5.walk(i)]
|
||
if any(isinstance(x, _ast5.Call) and getattr(x.func, "id", "") == "_acc_is_selfref"
|
||
for x in scan):
|
||
return True
|
||
return False
|
||
|
||
# --- AST:`if not _real:` 这个 If 的 body 里到底有没有 `return "undeclared"` ---
|
||
def _not_real_returns_undeclared(node):
|
||
if node is None:
|
||
return False
|
||
for n in _ast5.walk(node):
|
||
if not isinstance(n, _ast5.If):
|
||
continue
|
||
t = n.test
|
||
is_not_real = (isinstance(t, _ast5.UnaryOp) and isinstance(t.op, _ast5.Not)
|
||
and isinstance(t.operand, _ast5.Name) and t.operand.id == "_real")
|
||
if not is_not_real:
|
||
continue
|
||
for stmt in n.body:
|
||
if isinstance(stmt, _ast5.Return) and isinstance(stmt.value, _ast5.Constant) \
|
||
and stmt.value.value == "undeclared":
|
||
return True
|
||
return False
|
||
|
||
res = [
|
||
("`_acc_is_selfref()` 存在(没有它就无从识别自指)", bool(fn_sr)),
|
||
("**登记制**在(`_ACC_SELFREF_KEYS`):⛔ 不依赖判据文本,是唯一权威路径",
|
||
"_ACC_SELFREF_KEYS" in code),
|
||
("**文本兜底**在(`_ACC_SELFREF_TOKENS` + 有窗口上限):补登记漏网的",
|
||
"_ACC_SELFREF_TOKENS" in code and "_ACC_SELFREF_WIN" in code),
|
||
("窗口**有上限且不为 0**(⛔ 扫整段会误伤 A2 那类真判据 ⇒ 静默失守)",
|
||
bool(_re5.search(r"_ACC_SELFREF_WIN\s*=\s*([1-9]\d*)", code))),
|
||
("AST:`goal_state()` 里 `_real` 的推导式**真的剔**(⛔ 不看整段文本,见 docstring 变异坑)",
|
||
_real_filter_excludes_selfref(nd_gs)),
|
||
("AST:`_acc_short()` 里也剔(⛔ 只剔一处 ⇒ 唤醒正文仍念「还差 A6」,造出新自相矛盾)",
|
||
_real_filter_excludes_selfref(nd_as)),
|
||
("AST:`if not _real:` 的 body 里**真的 `return \"undeclared\"``(fail-closed)",
|
||
_not_real_returns_undeclared(nd_gs)),
|
||
("⛔ **没放松实质门禁**:`for _k, _v in _real:` 仍逐条判 pass(其余判据照判)",
|
||
"for _k, _v in _real:" in fn_gs or "for _k, _v in _real" in fn_gs),
|
||
]
|
||
# ---- 行为级:真起子进程跑真函数,用真数据量(⛔ 源码含关键字不算数) ----
|
||
try:
|
||
import subprocess as _sp
|
||
probe = r'''
|
||
import importlib.util, json, os, sys, tempfile
|
||
from pathlib import Path
|
||
spec = importlib.util.spec_from_file_location("cd_probe", sys.argv[1])
|
||
m = importlib.util.module_from_spec(spec)
|
||
sys.modules["cd_probe"] = m
|
||
spec.loader.exec_module(m)
|
||
sr = m._acc_is_selfref
|
||
res = {}
|
||
# ① 真自指(测试3 的 A6 原句形状):必须判 True
|
||
A6 = "\U0001f534 不过|goal_state() 返回 pass 且 goals_open() 为空(三路全过)"
|
||
res["A6 真自指判 True"] = sr("a6 目标状态", A6) is True
|
||
# ② 真门禁 A2:正文**深处**顺口提一句 goal_state() ⇒ 必须判 False(⛔ 误伤=静默放掉真门禁)
|
||
A2 = ("\U0001f534 不过|" + "A2 快照陷阱说明。" * 40 + " 另注:goal_state() 只读文本、不重验事实。")
|
||
res["A2 长正文顺口提及判 False"] = sr("a2 快照", A2) is False
|
||
# ③ 登记制:文本完全看不出自指,只要登记过就排除
|
||
res["登记制压过文本"] = sr("A6 目标状态", "过|已确认") is True
|
||
# ④ 普通真判据:一律不排除
|
||
res["普通判据判 False"] = sr("a1 产物", "\U0001f534 不过|产出三份文档") is False
|
||
|
||
# ⑤🔴 端到端:真造 goal.json 跑**真 goal_state()**(⛔ 这才是死锁的硬证据)
|
||
# 🔴🔴 **隔离必须用 `COLLABD_CONFIG`,⛔ 不可用 `DSH_COLLAB_WS`/`COLLABD_WORKSPACE`**:
|
||
# 真源 `load_cfg()` 的候选顺序是 ① `COLLABD_CONFIG` → ② `<ws>/.workbuddy/collab/collabd.config.json`
|
||
# ⇒ **只设 env 不给 config,会回落读到真工作区的配置**(本判据曾因此把真 `goal.json` 覆盖掉,
|
||
# 2026-10-04 已从 `bak-goalctl-20261002/` 恢复)。⇒ 这里**显式写一份临时 config**。
|
||
# ⚠️ `TG`(任务图)也由这份 config 的 `taskgraph` 决定 ⇒ 一并指到夹具,⛔ 不读真任务图。
|
||
tmp = tempfile.mkdtemp(prefix="gs_")
|
||
_cfg = Path(tmp) / "collabd.config.json"
|
||
_cfg.write_text(json.dumps({
|
||
"workspace": tmp.replace("\\", "/"),
|
||
"inbox": "inbox",
|
||
"taskgraph": "inbox/taskgraph.json",
|
||
"live": "inbox/realtime.md",
|
||
}, ensure_ascii=False), encoding="utf-8")
|
||
os.environ["COLLABD_CONFIG"] = str(_cfg)
|
||
spec2 = importlib.util.spec_from_file_location("cd_probe2", sys.argv[1])
|
||
m2 = importlib.util.module_from_spec(spec2)
|
||
sys.modules["cd_probe2"] = m2
|
||
spec2.loader.exec_module(m2)
|
||
# 🔴 自检:**INBOX 必须真落在临时目录**,否则说明隔离没生效 ⇒ 宁可报红也别再写真文件
|
||
if str(m2.INBOX).replace("\\", "/").find(tmp.replace("\\", "/")) < 0:
|
||
print(json.dumps({"__隔离失效: INBOX=%s 不在临时目录" % m2.INBOX: False}, ensure_ascii=False))
|
||
raise SystemExit(0)
|
||
inbox = Path(m2.INBOX); inbox.mkdir(parents=True, exist_ok=True)
|
||
(inbox / "tasks.json").write_text(json.dumps({"t": {"state": "done"}}), encoding="utf-8")
|
||
(inbox / "taskgraph.json").write_text(json.dumps({"nodes": [{"id": "N1", "status": "done"}]}),
|
||
encoding="utf-8")
|
||
# ⚠️ 判据文本必须用**真源真会写出来的形状**(`过|…`,⛔ 不带 `🔴` 前缀):
|
||
# `_acc_is_pass` 取竖线左边那段判,`🔴 过` 的 head 仍带 `🔴` ⇒ 会被判「未过」(假红)。
|
||
def _write(acc):
|
||
(inbox / "goal.json").write_text(json.dumps(
|
||
{"nodes": [{"id": "N1", "status": "done"}], "acceptance_state": acc}),
|
||
encoding="utf-8")
|
||
# 场景 X:**只有自指判据、且它没过** ⇒ 正确行为=`undeclared`(⛔ 不是 pass)
|
||
_write({"a6 目标状态": "\U0001f534 不过|goal_state() 返回 pass 且 goals_open() 为空(三路全过)"})
|
||
res["隔离生效: INBOX 落在临时目录"] = True
|
||
res["端到端X: 全自指且未过 ⇒ undeclared(不是pass)"] = (m2.goal_state() == "undeclared")
|
||
# 场景 Y:自指判据已写「过」 + **一条真判据也过了** ⇒ 正确行为=`pass`
|
||
#(⛔ 这是修复前会卡死的那个形状:路3 要读 A6,A6 又判"三路全过")
|
||
_write({"a6 目标状态": "过|goal_state() 返回 pass", "a1 产物": "过|三份文档已交"})
|
||
res["端到端Y: 自指+真判据全过 ⇒ pass(不再死锁)"] = (m2.goal_state() == "pass")
|
||
# 场景 Z:自指 + **一条真判据没过** ⇒ 必须 `open`(⛔ 不许因为剔了自指就把真门禁也放过)
|
||
_write({"a6 目标状态": "\U0001f534 不过|goal_state() 返回 pass", "a1 产物": "不过|只交了两份"})
|
||
res["端到端Z: 真判据没过 ⇒ open(没放松门禁)"] = (m2.goal_state() == "open")
|
||
# 场景 W:自指**已写「过」** + 真判据**没过** ⇒ 仍必须 `open`
|
||
#(⛔ 这条专抓"因为剔了自指就整路放行"的写法:自指写过就当全过 ⇒ 静默失守)
|
||
_write({"a6 目标状态": "过|goal_state() 返回 pass", "a1 产物": "不过|只交了两份"})
|
||
res["端到端W: 自指写过但真判据没过 ⇒ open"] = (m2.goal_state() == "open")
|
||
print(json.dumps(res, ensure_ascii=False))
|
||
'''
|
||
out = _sp.run([sys.executable, "-c", probe, str(HERE / "collabd.py")],
|
||
capture_output=True, text=True, timeout=180, encoding="utf-8")
|
||
got = json.loads(out.stdout.strip().splitlines()[-1])
|
||
for k, v in got.items():
|
||
res.append(("真起进程验行为:" + k, v))
|
||
except Exception as _e: # ⛔ 起不来就必须报红,不能静默跳过
|
||
res.append(("真起进程验行为(⛔ 起不来即报红):" + type(_e).__name__, False))
|
||
return res
|
||
|
||
|
||
|
||
@case("**单包自包含**(⛔ 只复制这一个技能到别的机器,排版/提报用户/去 AI 味都要能用)")
|
||
def t_single_package_selfcontained():
|
||
"""🔴🔴 2026-10-04 用户定案:「需要整合到会话技能中,重点是只想复制一个技能到别的机器,
|
||
这些功能都可以正常使用」。
|
||
|
||
🔴 **改前的两处外部依赖**(⛔ 都是"不装另一个技能就静默失效"型):
|
||
① `reply-style-guard.py::_core()` 第②级回退指向 **另一个技能**的
|
||
`agent-operating-rules/references/回复排版-核心块.md`;
|
||
② `hooks/_env.py::_SNIFF = "agent-operating-rules"` —— 它是**技能库定位的识别特征**,
|
||
`skills_root()` / `env_check()` 三处都拿`(root)/_SNIFF).is_dir()` 当"库在不在"的判据。
|
||
⚠️ **实测过的失效形状(不是推断)**:只搬本包到空目录时,`reply-style-guard` 输出
|
||
**空 JSON**(日志里看不出是"机制坏"还是"没配规则")⇒ 排版闸门**整条静默失效**。
|
||
|
||
✅ 修法:① 把那份核心块**内联**进本包 `references/03-回复排版-核心块.md`(内容守恒、逐字搬),
|
||
`_core()` 改成**三级回退**(工作区规则文件 → **包内件** → 外部兜底);
|
||
② `_SNIFF` 改成**候选数组**,**第一个 = 本包自己** ⇒ 只拷本包也能定位技能库。
|
||
⚠️ 本判据守两件事:件在不在 + **代码真的先找包内**(⛔ 只查文件存在=假绿,
|
||
回归过一次:路径上溯少一级时文件在、但读不到,hook 照样静默空输出)。
|
||
"""
|
||
S = HERE.parent
|
||
core = S / "references" / "03-回复排版-核心块.md"
|
||
guard = (S / "scripts" / "hooks" / "reply-style-guard.py").read_text(encoding="utf-8")
|
||
envp = (S / "scripts" / "hooks" / "_env.py").read_text(encoding="utf-8")
|
||
res = [
|
||
("包内件 `references/03-回复排版-核心块.md` 在(⛔ 单包自包含的主力件)", core.exists()),
|
||
]
|
||
if core.exists():
|
||
t = core.read_text(encoding="utf-8")
|
||
# ⚠️⚠️ 标记必须按 `_extract()` 的**完整字面量**查(带空格与 ` -->`),
|
||
# ⛔ 不能查子串 `REPLY-CORE:END` —— 2026-10-04 变异验证实测栽过:
|
||
# 把 `<!-- REPLY-CORE:END -->` 改成 `<!-- REPLY-CORE:END(改坏) -->` 后
|
||
# 子串仍命中 ⇒ 判据放行,但**真提取器已经认不出**(END 串对不上 ⇒ 静默返回空)。
|
||
# ⛔ 判据必须与被仿刻的那个函数用**同一份字面量**。
|
||
_B = "<!-- REPLY-CORE:BEGIN"
|
||
_E = "<!-- REPLY-CORE:END -->"
|
||
res += [
|
||
("包内件带**完整** `REPLY-CORE` 标记(⛔ 提取器靠它认,⛔ 少一个就静默返回空)",
|
||
_B in t and _E in t),
|
||
("包内件非空(⛔ 标记在但块体空 = 注入 0 字符)",
|
||
len(t.split(_B, 1)[-1].split(_E)[0].strip()) > 100),
|
||
("包内件**不自指外部技能路径**(⛔ 单包环境下那句话会指向不存在的文件)",
|
||
"agent-operating-rules/scripts" not in t),
|
||
]
|
||
res += [
|
||
("`_core()` 有**包内件**这一级回退(⛔ 只留外部兜底=没自包含)",
|
||
"03-回复排版-核心块.md" in guard),
|
||
("🔴 **包内件优先于外部件**(⛔ 顺序反了又变成「看别人脸色」)",
|
||
guard.index("03-回复排版-核心块.md") < guard.index("CORE_REL)")),
|
||
("🔴 上溯层级对(`hooks` → **两级** → 包根,⛔ 少一级落到 `scripts/` 读不到)",
|
||
"os.path.dirname(os.path.dirname(_hooks))" in guard),
|
||
("`_env.py` 的识别特征**含本包自己**(⛔ 只认别的技能 ⇒ 单包下库定位失败)",
|
||
"_SNIFFS = (" in envp and '"session-mechanism"' in envp),
|
||
("`_env.py` 不再**只**靠 `agent-operating-rules` 判库在不在(⛔ 那是隐式耦合)",
|
||
"(root / _SNIFF).is_dir()" not in envp and "Path(rep[\"skills_root\"]) / _SNIFF" not in envp),
|
||
]
|
||
return res
|
||
|
||
|
||
|
||
@case("搬入的两块**不许被删掉/漂移**(⛔ 自决策口径 + 任务会话六阶段)")
|
||
def t_migrated_docs_intact():
|
||
"""🔴 2026-10-04 两件搬入,判据守住它们**在位**且**口径一致**。
|
||
|
||
① **功能优先协作协议**(原 `dsh-decision/references/01` → 本包 `references/02`)
|
||
—— 用户定案「这个属于会话的自决策方法,应该属于会话逻辑」。
|
||
⚠️ **2026-10-04 改**:本档**已是权威**(原为 `agent-operating-rules §1.6` 权威 · 本包落地副本)
|
||
⇒ 判据查的不只是"在不在",还要查**它声明的是「本档即权威」**(⛔ 指向外部技能当权威 = 单包失效)。
|
||
② **执行会话六阶段**(用户定案逐字:需求识别/调研/规划/执行/验证/归档清理
|
||
=「正式执行会话执行任务的流程」)⇒ 写在 `references/collab.md §5.5`。
|
||
⚠️ 判据**不许只查关键词计数**(P0-45 同族:字符串出现≠机制在);
|
||
还要查**接线**(`SKILL.md` 加载段真的挂上了、⛔ 否则档在包里没人读得到)。
|
||
"""
|
||
S = HERE.parent
|
||
fpr = S / "references" / "02-功能优先协作协议.md"
|
||
skl = (S / "SKILL.md").read_text(encoding="utf-8")
|
||
collab = (S / "references" / "collab.md").read_text(encoding="utf-8")
|
||
res = [
|
||
("`references/02-功能优先协作协议.md` 在位(搬入的落点)", fpr.exists()),
|
||
]
|
||
if fpr.exists():
|
||
t = fpr.read_text(encoding="utf-8")
|
||
res += [
|
||
("自决策档:**9 类自决策白名单**在(技术层永不问)", "9 类白名单" in t),
|
||
("自决策档:**只准提报用户 3 类**在(功能语义分叉/红线门禁/超边界)", "只准提报用户" in t),
|
||
("自决策档:**功能卡 4 问**在(用户侧输入格式,⛔ 此前无任何实体承接)",
|
||
"功能卡" in t and "怎样算成功" in t),
|
||
("自决策档:**提报用户格式**在(⛔ 不许出现路径/commit/标识符)",
|
||
"语言转换表" in t),
|
||
("自决策档:**拆包提报用户**在(红线问题⛔ 不得与技术方案捆问)", "拆包提报用户" in t),
|
||
("自决策档声明了**本档即权威**(⛔ 单包自包含的关键:⛔ 不能再指向外部技能当权威)",
|
||
"本档即权威" in t),
|
||
("自决策档**排版指向本包内件**(⛔ 不是外部技能的 §2)",
|
||
"references/03-回复排版-核心块.md" in t),
|
||
# 🔴 2026-10-04 改造后**这一条被作废**(原文是「声明了三处副本,改判据要同时改三处」)。
|
||
# 现在本档**即权威**(单包自包含),⛔ 不再要求同步另两处。
|
||
# ⇒ 判据反向:⛔ 那句「同时改三处」必须**已消失**(留着会让单包用户以为还有两份)。
|
||
("🔴 自决策档**不再要求「同时改三处」**(⛔ 已作废:本档即权威,留着会误导单包用户)",
|
||
"同时改三处" not in t),
|
||
]
|
||
res += [
|
||
# ⚠️⚠️ 查"接线"必须查**段标本身的完整性**,⛔ 不能只查某个子串在不在
|
||
# (2026-10-04 变异验证实测栽过:变异把 `**⑦ 我要判断` 改成 `**(已摘除)⑦ 我要判断`,
|
||
# 而判据查的正是 `⑦ 我要判断` 这个子串 ⇒ 改完照样命中 ⇒ **假绿**)。
|
||
# ⇒ 判据改为查「行首是 `**⑦ 我要判断`」这种**带行首锚定**的形式。
|
||
("🔴 **接线**:`SKILL.md` 加载段真挂了第 ⑦ 条(行首锚定,⛔ 不许被改成装饰)",
|
||
any(l.startswith("**⑦ 我要判断") for l in skl.splitlines())),
|
||
("🔴 **接线**:加载段指到 `references/02-功能优先协作协议.md`",
|
||
"references/02-功能优先协作协议.md" in skl),
|
||
]
|
||
# 六阶段:⛔ 同样不能只查「关键词在文件里」——用户原话那句里也含全部六个词
|
||
# (实测:删掉表格里的「归档清理」,原话那句仍在 ⇒ 子串计数不变 ⇒ 假绿)。
|
||
# ⇒ 改为查**六阶段表格那一行**(`collab.md §5.5` 的表行,逐行锚定)。
|
||
stage_lines = [l for l in collab.splitlines() if l.strip().startswith("| **")]
|
||
def _row(kw):
|
||
return any(kw in l and "缺了会怎样" not in l and l.strip().startswith("| **") for l in stage_lines)
|
||
for _idx, _kw in ((0, "需求识别"), (1, "需求调研"), (2, "方案规划"),
|
||
(3, "任务执行"), (4, "结果验证"), (5, "归档清理")):
|
||
res.append(("六阶段表行:**%s**(⛔ 查表行不是查关键词,原话里也有这六个词)" % _kw, _row(_kw)))
|
||
res += [
|
||
("六阶段是**任务会话的标准动作**(⛔ 别只是一张并列的表)",
|
||
"任务会话的标准动作" in collab),
|
||
("六阶段每行都写了「缺了会怎样」(⛔ 只写做什么=口号)",
|
||
"⛔ 后果" in collab and "缺了会怎样" in collab),
|
||
("六阶段**接上了自决策口径**(规划阶段 ⇒ 该不该问用户)",
|
||
"02-功能优先协作协议.md" in collab),
|
||
("⚠️ 标清了**与 `dsh-workflow` 的分工**(⛔ 别当同一件事)",
|
||
"dsh-workflow" in collab),
|
||
]
|
||
# ===== 单包自包含的**文档侧**:⛔ 不能再把外部技能当权威 =====
|
||
skl2 = (S / "SKILL.md").read_text(encoding="utf-8")
|
||
res += [
|
||
("🔴 `SKILL.md` 声明**本包自包含**(⛔ 复制一个就能用)",
|
||
"自包含" in skl2 or "已无必需外部依赖" in skl2),
|
||
("🔴 `SKILL.md` 不再说「依赖某技能」为**必需**(⛔ 只可作可选增强)",
|
||
"**依赖(⛔ 未并入,只声明)**" not in skl2),
|
||
# ⚠️ 判「权威归属」⛔ 不能只查"提了本档" —— 改造前那句是「权威在 `agent-operating-rules §1.6`」,
|
||
# 改造后是「本档即权威」,两处都提到"本档" ⇒ 只查本档会漏。
|
||
# ⇒ 查**反向**表述:不能再出现「权威分工/完整判据已收敛到通用技能/判据实体在 <外部技能>」。
|
||
("🔴 自决策档**不再把外部技能当权威**(⛔ 那会让单包用户读到「去别处找判据」)",
|
||
not any(k in t for k in ("权威分工", "完整判据已收敛到通用技能",
|
||
"判据实体在 `agent-operating-rules"))),
|
||
("🔴 自决策档保留了**来路说明**(⛔ 抹掉就查不到它从哪来、为何这样定)",
|
||
"来路" in t),
|
||
("🔴 排版指向**本包内件**(⛔ 随包,不依赖外部)",
|
||
"references/03-回复排版-核心块.md" in t),
|
||
# ⚠️ 变异验证实测栽过:只查「`03-回复排版-核心块.md` 在不在」是**假绿** ——
|
||
# 文件里它出现**两次**(§5.4 标题行 + L285 的"随包自带"说明行)。
|
||
# 把**标题行**改回「`agent-operating-rules §2`」时,L285 那个子串**仍然命中** ⇒ 判据放行。
|
||
# ⇒ 改为查**标题行本身**(§5.4 那一行必须带包内路径,且⛔ 不得提外部技能的 §2)。
|
||
("🔴 排版**标题行**指向本包内件(⛔ 查子串会假绿:同一文件里它出现两次)",
|
||
any("03-回复排版-核心块.md" in l and l.lstrip().startswith("###")
|
||
for l in t.splitlines())),
|
||
("🔴 排版标题行**不提外部技能 §2**(⛔ 那是「去别处找判据」的形状)",
|
||
not any(l.lstrip().startswith("###") and "agent-operating-rules" in l
|
||
and "排版" in l for l in t.splitlines())),
|
||
]
|
||
return res
|
||
|
||
|
||
|
||
@case("包体卫生:**不许长回备份/缓存/零引用文件**,且**没有版本控制兜底**(⛔ 删了就是真删)")
|
||
def t_pkg_hygiene():
|
||
"""🔴🔴 2026-10-04 用户要求「检查技能相关文件是否有可以整合、精简、移除的重复、冗余、多余描述」。
|
||
|
||
✅ 那轮清掉的(**10.7 MB → 1.9 MB,减 83%**):68 个 `*.bak-*`(8.6 MB)+
|
||
`__pycache__`/`.pyc`(255 KB)+ `install.log`(36 KB)+
|
||
**零引用**的 `assets/_syntax_probe.js`(109 KB)+ `SKILL.md` 那条 55 KB 变更流水账(压到 2.4 KB,**-96%**)。
|
||
🔴🔴 **为什么必须有这条判据(本轮取证到的硬事实)**:
|
||
**本技能包不在任何 git 仓库下** —— `.workbuddy/`/`E:/ProgramData/`/`E:/` 三级都无 `.git`;
|
||
文档库 `08-skills/` 只收了另外 7 个技能,**没有 session-mechanism**
|
||
⇒ **删掉的文件在这台机器上就是唯一副本没了** ⇒ 必须先有"包外快照"才敢删,
|
||
也因此**更不能让它重新长回来**(长回来 = 下次清理又要处理一遍 8.6 MB)。
|
||
⚠️ 同时盯两件同类退化:① **零引用文件**(`_syntax_probe.js` 就是这么烂在包里的 109 KB);
|
||
② 指向已删文件的**死证据引用**(注释写「原文照抄见备份 X」⇒ 备份删了它就是句假话,本轮改掉 6 处)。
|
||
"""
|
||
import re as _re6
|
||
res = []
|
||
bak = [p for p in HERE.parent.rglob("*.bak-*") if p.is_file()]
|
||
res.append(("⛔ 包内无 `*.bak-*` 历史备份(本轮清掉 68 个 / 8.6 MB)", not bak))
|
||
# ⚠️ `__pycache__`/`.pyc` 是**跑一次 selftest 就重新生成**的(`hooks/_env.py` 被导入就产生
|
||
# `scripts/hooks/__pycache__/_env.cpython-313.pyc`)⇒ **判它存在=判「永远红」**。
|
||
# ⇒ 判据只能是「⛔ 不许把它当能力件进包/入库」,而它**天然会重生** ⇒ 这里改成
|
||
# 「删干净后跑一遍 selftest,确认它只重生在 __pycache__ 里、⛔ 不在任何源码目录留下散 .pyc」。
|
||
loose_pyc = [p for p in HERE.parent.rglob("*.pyc") if "__pycache__" not in str(p)]
|
||
res.append(("⛔ 无散落的 `.pyc`(⛔ 只能在 `__pycache__` 内;它跑一次就重生,不判存在)",
|
||
not loose_pyc))
|
||
# ⚠️ `install.log` 同理:`install.py:96,340` 每次运行都 append ⇒ 同样只能判"不巨大"。
|
||
log = HERE.parent / "install.log"
|
||
logsz = log.stat().st_size if log.exists() else 0
|
||
res.append(("⛔ `install.log` 不超 64 KB(运行日志,⛔ 不属能力件;本轮清掉 36 KB)",
|
||
logsz <= 64 * 1024))
|
||
|
||
# 零引用文件:assets/ 下每个 .js,⛔ 排掉本身被引用的
|
||
dead = []
|
||
for p in list((HERE.parent / "assets").glob("*.js")):
|
||
stem = p.stem
|
||
hit = 0
|
||
for q in HERE.parent.rglob("*"):
|
||
if not q.is_file() or q == p or "__pycache__" in str(q):
|
||
continue
|
||
if q.suffix not in (".py", ".md", ".html", ".js", ".sh", ".json", ".example"):
|
||
continue
|
||
try:
|
||
if stem in q.read_text(encoding="utf-8", errors="ignore"):
|
||
hit += 1
|
||
except Exception:
|
||
continue
|
||
if hit == 0:
|
||
dead.append(p.name)
|
||
res.append(("⛔ `assets/*.js` 无零引用文件(`_syntax_probe.js` 烂在包里 109 KB)", not dead))
|
||
|
||
# 死证据引用:注释/文档里点名某个 *.bak-xxx,⛔ 排除 install.py(它的备份在 .workbuddy/,是活机制名)
|
||
# ⚠️⚠️ 还要**排除 selftest.py 自己** —— 本判据的注释里就写了那个串(2026-10-04 实测自我误报)。
|
||
# ⚠️⚠️ 以及 `references/pitfalls.md` 的 **P0-45** —— 那一节**专门讲「判据会自我误报」这个坑**,
|
||
# 必须举出那个串当例子(2026-10-04 实测:把它当死证据 ⇒ 判据对自己的说明书报红)。
|
||
# 判据=「活机制名」还是「死证据」:**看它指的是不是 `.workbuddy/` 下真存在的备份**。
|
||
pat = _re6.compile(r"[\w\.\-]+\.bak-[\w\-]+")
|
||
dref = []
|
||
for p in HERE.parent.rglob("*"):
|
||
if not p.is_file() or "__pycache__" in str(p):
|
||
continue
|
||
rel = p.relative_to(HERE.parent).as_posix()
|
||
# ⛔ 排除三个"提到备份名是**机制本身/说明书**"的文件
|
||
if rel in ("install.py", "scripts/selftest.py"):
|
||
continue
|
||
# 🔴🔴 2026-10-05 加:`install.log` = **运行日志**(同文件上一条判据自己写着
|
||
# 「运行日志,⛔ 不属能力件」)⇒ 它里面「已备份 → …bak-session-mechanism-…」
|
||
# 是**安装时的事实陈述**,⛔ 不是注释里的死证据引用。
|
||
# ⚠️ 它是 **append 型**:每跑一次 `install.py --apply` 就多一行 ⇒ 不排除的话
|
||
# **判据会随安装次数必然变红**(2026-10-05 实测:装完当场 FAIL 1,与本次改动无关)。
|
||
# 📌 判据本意="注释不许指向已删的备份";日志**不承担这个语义**。
|
||
if rel == "install.log":
|
||
continue
|
||
if rel == "references/pitfalls.md":
|
||
# ⛔ 只豁免 P0-45 那一节(讲这个坑的),其它节的死证据仍要报
|
||
t_all = p.read_text(encoding="utf-8")
|
||
i45 = t_all.find("## P0-45")
|
||
head, tail = (t_all[:i45], t_all[i45:]) if i45 >= 0 else (t_all, "")
|
||
nxt = tail.find("\n## P0-", 4)
|
||
sec45 = tail[:nxt] if nxt > 0 else tail
|
||
for m in pat.finditer(head):
|
||
dref.append("%s:%s" % (p.name, m.group(0)[:40]))
|
||
continue # ⛔ P0-45 节内的举例不计入
|
||
try:
|
||
t = p.read_text(encoding="utf-8")
|
||
except Exception:
|
||
continue
|
||
for m in pat.finditer(t):
|
||
dref.append("%s:%s" % (p.name, m.group(0)[:40]))
|
||
res.append(("⛔ 无「见备份 X」式死证据引用(备份已删,注释不能再说)", not dref))
|
||
|
||
# 变更流水账:frontmatter 的 last_change 行不许再超长(曾 55 KB / 全文 66%)
|
||
# ⚠️ 判据只查**行体积**;⛔ 不查「本条为准」字样 —— 2026-10-04 实测那个词在新台账 L6 与
|
||
# L24/L98/L267(各自成段的历史改法说明)里都合法存在,拿它当判据只会误报。
|
||
# 🔴🔴 2026-10-04 **阈值 1 KB → 2 KB**(用户口径:判据守的是**口径**,⛔ 不是守某个数字):
|
||
# 病根:1 KB 是照**当年那次**(339 B)定的,属于「按当时观测值钉死」⇒
|
||
# 台账只要多一条**同量级**的现行口径(1241 B)就红,而被砍掉的恰恰是
|
||
# "为什么这么做"这类**必须留**的内容 ⇒ 判据开始**逼人删有用信息**(本末倒置)。
|
||
# 真正要防的是「**一行长成 55 KB 的流水账**」⇒ 那个量级已被下面的 2 KB 正文判据拦死。
|
||
# ⚠️ 与下方"正文无超 2 KB"**同阈值**,口径统一(⛔ 不再出现两个数字互相打架)。
|
||
skl = (HERE.parent / "SKILL.md").read_text(encoding="utf-8").splitlines()
|
||
l6 = skl[5] if len(skl) > 5 else ""
|
||
_l6b = len(l6.encode("utf-8"))
|
||
res.append(("⛔ `SKILL.md` 末行不超 **2 KB**(当年 55 KB = 全文 66%% ⇒ 它是**台账**不是日记;"
|
||
"⚠️ 阈值 2026-10-04 由 1 KB 上调,⛔ 守的是「不许长成流水账」这个口径、不是 339 B 这个数)",
|
||
_l6b <= 2048))
|
||
#⛔ 真正要盯的是「正文有没有哪一行又长成流水账」(当年就是 L6 一行 55 KB)。
|
||
# ⚠️ 两条**该长的**必须豁免,否则判据永远红(2026-10-04 实测):
|
||
# ① L3 `description:`(2.2 KB)—— 技能描述本就是长句,⛔ 不该压;
|
||
# ② **已标作废**的历史段(如 L234「同工作区 = 四类会话 … 已于 2026-10-03 作废」)——
|
||
# 它长是因为**把作废原因写全了**,删掉就丢了"为什么废"。
|
||
body = [(i, len(l.encode("utf-8"))) for i, l in enumerate(skl, 1)
|
||
if not l.startswith("description:") and "作废" not in l and len(l.encode("utf-8")) > 2048]
|
||
res.append(("⛔ `SKILL.md` 正文无超 2 KB 的行(⛔ 当年 L6 单行 55 KB = 全文 66%%;"
|
||
"⛔ 已标作废的段与 description 豁免)", not body))
|
||
if dref or dead or bak:
|
||
res.append(("⚠️ 具体残留:%s" % ((bak[:2] + dead + dref[:2]) or "-"), True))
|
||
return res
|
||
|
||
|
||
|
||
@case("告警文案:**必须说清后果**(⛔ 不许只说「缺了 A」)")
|
||
def t_warn_text_states_consequence():
|
||
"""🔴 2026-10-03 用户逐字指正:「**标题里没读类别前缀) 这句话都不知道有什么作用**」。
|
||
|
||
🔴 **它错在哪**:原文案只陈述"读不到类别前缀"这个**现象**,
|
||
⛔ **没说缺了会怎样** ⇒ 读者不知道该做什么(看板上它跟正常文案长得一样,
|
||
只是短一点 —— 这种"只描述现象"的告警=**没有行动价值的废话**)。
|
||
🔴🔴 2026-10-03 **我自己犯了这条**(用户当场抓住):第一版我把它改成
|
||
「⚠ 无类别 ⇒ **不会被派活**」—— 🔴 **那个后果是我编的**。
|
||
取证:`collabd.py::queue_pending()` 读 **`tasks.json` 台账**,`topic` 在派活链路上
|
||
**根本没出现**;而这排 `cells` **已经是** `role==='任务会话'` 筛出的**现存任务会话**,
|
||
⛔ 不是"待派活清单" ⇒ "不会被派活"=**说了个不存在的后果**。
|
||
✅ **正确口径=只说能证实的后果**:`⚠ 归不出类别 · 不计入分工`
|
||
(依据:`board.py` 归线判据 `topic == 线 ∨ cwd_tail == 线` ⇒ topic 空即不归任何线)。
|
||
📌 **本条判据升级后的红线**:⚠️ **写"会怎样"之前必须先取证那个后果真的存在**
|
||
—— 否则"讲清后果"会变成"**编一个后果讲得更像回事**",比原句更坏。
|
||
✅ 本条判据是**通用红线**,不是这一句话的专属:
|
||
凡"⚠/缺了/没读"类文案,**必须同时出现一个后果词**(会…/不会…/导致…)。
|
||
"""
|
||
import re as _re6
|
||
html = (HERE.parent / "assets" / "board.html").read_text(encoding="utf-8")
|
||
# 剥掉注释 ⇒ 只查**真正渲染给用户看的文案**(注释里的原话留档不算)
|
||
code = _re6.sub(r"/\*.*?\*/", "", html, flags=_re6.S)
|
||
code = _re6.sub(r"(?m)^\s*//.*$", "", code)
|
||
code = _re6.sub(r"//[^\n]*", "", code)
|
||
# ⚠️ 只看**纯文案字面量**(`tx(...)` / `chip(...)` 这类调用的**第一个字符串参数**)
|
||
# ⛔ 不用"整段代码里抓 ⚠ 段"那种粗法 —— 实测它会**跨语句边界**把
|
||
# `(⛔ 不因此判完成)` 之类切到下一个字面量里 ⇒ **8 条里 7 条是误报**。
|
||
# ✅ 锚定 `tx(` / `chip(` 之后的第一个引号串,才是用户真正看到的句子。
|
||
lits = []
|
||
for m in _re6.finditer(r"(?:tx|chip|lbl)\(\s*(['\"])(.*?)\1", code, _re6.S):
|
||
s_txt = m.group(2)
|
||
if "⚠" in s_txt:
|
||
lits.append(s_txt)
|
||
# ⚠️ 后果词表**必须包含「不…」式否定** —— 例「⚠ 未声明验收判据(⛔ 不因此判完成)」
|
||
# 它**已经**把后果说清了(不判完成),⛔ 只因原表里只有"会/不能/无法"而漏判。
|
||
# 📌 教训:写判据时**先拿真实文案跑一遍**(本次就是这样才发现自己错,而不是去改好文案)。
|
||
bad = [w for w in lits
|
||
if not _re6.search(r"会|导致|漏管|不能|无法|勿|别|不因此|不算|勿当", w)]
|
||
return [
|
||
("渲染文案里**没有**「只说现象不说后果」的告警(查到 %d 条可疑:%s)"
|
||
% (len(bad), bad[:3] or "无"), not bad),
|
||
# 🔴🔴 2026-10-03 **用户直接指示:那一行整个删掉**(逐字:「越写越看不懂
|
||
# 还是删除了把」)⇒ 这里从"要求它写对"改成"**不许它回来**"。
|
||
# 📌 两次踩坑留下的结论:① 原文案只说现象(啰嗦)② 我"改成讲后果"又编了后果
|
||
# ⇒ **第三次选择是删掉**:这行信息量不足,⛔ 不值得占版面、更不值得编文案。
|
||
("⛔ 任务会话格**不许**再出现类别/前缀相关文案(用户已指示删除)",
|
||
("归不出类别" not in code) and ("没读类别前缀" not in code)
|
||
and ("类别:'+W.topic" not in code)),
|
||
("⛔ 那句编出来的后果也不许回来(`不会被派活`)", "不会被派活" not in code),
|
||
]
|
||
|
||
|
||
|
||
def _worker_label_from_source() -> str:
|
||
"""**AST 抽** `board.py::_ROLE_LABEL['worker']` 的字面量(唯一真源)。
|
||
|
||
🔴 2026-10-03 23:4x 新增:标签从「协作目标」改名成「执行会话」时,
|
||
判据里**写死**的那份字面量**当场报红**(这是它该做的),
|
||
但也意味着**每次改名都得手改判据** ⇒ 漏一处就假红。
|
||
✅ 正解=**判据从真源派生**:抽出来用 ⇒ 改名只改 `_ROLE_LABEL` 一处,判据自动跟随。
|
||
⚠️ 抽不到 ⇒ 返回空串(让下游断言**报红**,⛔ 绝不猜一个词填上去)。
|
||
"""
|
||
import ast as _ast
|
||
try:
|
||
t = _ast.parse((HERE / "board.py").read_text(encoding="utf-8"))
|
||
except Exception:
|
||
return ""
|
||
for n in t.body:
|
||
if not isinstance(n, _ast.Assign):
|
||
continue
|
||
tgt = n.targets[0]
|
||
nm = getattr(tgt, "id", None) or getattr(tgt, "attr", None)
|
||
if nm != "_ROLE_LABEL":
|
||
continue
|
||
for k, v in zip(n.value.keys, n.value.values):
|
||
if getattr(k, "value", "") == "worker" and isinstance(v, _ast.Constant):
|
||
return str(v.value)
|
||
return ""
|
||
|
||
|
||
|
||
@report("产物落点:**每个目标一个独立文件夹**(⛔ 代码与其他路径除外)|**报告型**")
|
||
def t_artifacts_land_in_goal_dir():
|
||
"""🔴 2026-10-04(用户要求逐字):「**检查是否每个工作区 进行 执行任务时每个目标都有独立的
|
||
文件夹 保存生成的内容(要求处理其他路径下的文件 代码 等除外)**」
|
||
|
||
🔴 **为什么补这条**(实测坐实的缺口):机制本身有(`goal_dir_name()`/`_ensure_goal_dir()`/
|
||
`t_goal_dir_mechanism`)⇒ ⛔ 但**那条只守"机制在不在",不守"产物真的落进去了吗"**。
|
||
实测反例:测试3 根目录长出 `新环境验证/`、`a6-final-check/`(2 份 S2/S3 取证 +
|
||
6 个临时修库脚本)**散在目标文件夹之外** ⇒ 机制在、结果照样乱。
|
||
|
||
✅ 本条判据=**逐个真工作区**(⛔ 不造夹具,因为要验的就是真磁盘):
|
||
① 目标文件夹**存在**(按 `goal_dir_name()` 现算,⛔ 不写死目录名);
|
||
② **产物型 `.md` 必须落在目标文件夹或 `交付物/` 内**;
|
||
③ ⛔ **豁免**:`CODEBUDDY.md`/`AGENTS.md`/`README.md`(规则与说明,不是产物)、
|
||
`docs/`、`归档/`、`脚本/代码`(用户口径:「**处理其他路径下的文件 代码 等除外**」)。
|
||
📌 **豁免写清楚,否则这条判据会假红**:一个连"什么是产物"都没定义的检查=没用的检查。
|
||
⚠️ 本条只**报告与提示**,⛔ **不删不移任何文件**(清理是用户的事,且可能是别人写的)。
|
||
"""
|
||
# 产物型文档:⛔ 排除规则类/说明类文件名(它们天生在根目录,不是产物)
|
||
RULE_FILES = {"CODEBUDDY.md", "AGENTS.md", "README.md", "MEMORY.md", "state.py"}
|
||
# 🔴 `inbox`=**机制自己的状态/信号文件**(NEXT/digest/queue/advance…),
|
||
# ⛔ **不是产物** —— 第一版没豁免它 ⇒ 报出一堆假散落(实测四个区都报)。
|
||
EXEMPT_DIRS = ("docs", "归档", "scripts", "脚本", "tmp", ".workbuddy", "done",
|
||
"__pycache__", "inbox")
|
||
# 🔴🔴 2026-10-04 **第三版才算对**:`imp()` 及其它用例会**永久改写** `DSH_COLLAB_WS`
|
||
# 指向**测试夹具**且不还原(实测 `selftest.py:184`)⇒ **跑到本用例时读它必是夹具**。
|
||
# ⇒ 真工作区只能用**模块加载时定下的 `WS`**(第 57 行,在 env 被改之前取的)。
|
||
# 📌 纪律:**判据里凡要"真工作区",一律用 `WS`,⛔ 不许现读 `os.environ`**。
|
||
ws = Path(WS)
|
||
if not ws.is_dir():
|
||
return [("SKIP:没指到真工作区(设 `DSH_COLLAB_WS=<工作区>` 才会真跑)", True)]
|
||
res = []
|
||
# ① 目标文件夹存在
|
||
# 🔴🔴 2026-10-04 **前两版都算错(留档)**:
|
||
# ① 第一版:直接 `exec_module` 但没设 `COLLABD_CONFIG` ⇒ `INBOX` 回落 ⇒ 报「未命名目标」。
|
||
# ② 第二版:设了 env,但**加载的是 `HERE/collabd.py`(技能目录那份)** ⇒ 它的
|
||
# `_cfg_candidates()` 里 `DSH_COLLAB_WS` 已被 `imp()` 指向**测试夹具** ⇒ 又回落。
|
||
# ⚠️ 更根子的一条:判据里**改 env 后再 `exec_module`**,而 `selftest` 顶层
|
||
# 已经跑过 `imp()`(它把 env 设为夹具)⇒ **我的设值与它的设值打架,谁最后生效不确定**。
|
||
# ✅ **正解=开独立子进程**(⛔ 不与 selftest 的 env 抢):真区自己的副本 + 干净 env
|
||
# ⇒ 拿到的是**真磁盘读数**,这才是本条要验的东西(同 P0-39:判据不许读错世界)。
|
||
import subprocess as _sb3
|
||
_own = ws / ".workbuddy" / "collab" / "collabd.py"
|
||
if not _own.is_file():
|
||
return [("⛔ 该工作区没有自己的 collabd.py 副本 ⇒ 判据无法验真实部署", False)]
|
||
_py = sys.executable
|
||
_code = (
|
||
"import importlib.util,sys,json\n"
|
||
"sp=importlib.util.spec_from_file_location('c',%r)\n"
|
||
"m=importlib.util.module_from_spec(sp)\n"
|
||
"try: sp.loader.exec_module(m)\n"
|
||
"except SystemExit: pass\n"
|
||
"print(json.dumps({'inbox':str(m.INBOX),'dir':m.goal_dir_rel()},ensure_ascii=False))\n"
|
||
% str(_own))
|
||
_env = {"COLLABD_CONFIG": str(ws / ".workbuddy" / "collab" / "collabd.config.json"),
|
||
"COLLABD_WORKSPACE": str(ws)}
|
||
try:
|
||
_r = _sb3.run([_py, "-c", _code], env={**os.environ, **_env},
|
||
capture_output=True, timeout=60, cwd=str(ws))
|
||
_j = json.loads((_r.stdout or b"{}").decode("utf-8", "replace") or "{}")
|
||
except Exception as _e:
|
||
return [("⛔ 独立子进程取真值失败:%r" % _e, False)]
|
||
_gdir = ws / (_j.get("dir") or "")
|
||
res = [("目标文件夹存在:%s(独立进程按 `goal.json` 算出,INBOX=%s)"
|
||
% (_j.get("dir"), _j.get("inbox")), bool(_j.get("dir")) and _gdir.is_dir())]
|
||
|
||
# ② 产物型 md 落点
|
||
# 🔴🔴 2026-10-05:目标文件夹收进 `执行会话/` 一层(用户定案「<工作区根>/执行会话/目标-xxx/ 改成这样」)
|
||
# ⇒ 落点判据必须**跟着机制走**(同族第 N 次:判据没跟上改动 ⇒ 假红/假绿)。
|
||
# ⚠️ 判据从「`parts[0].startswith('目标-')`」扩成**两级**:
|
||
# `执行会话/目标-xxx/…` 与**裸 `目标-xxx/…`(存量旧目录)都要认**
|
||
# —— ⛔ 硬切到新形态会把历史产物全判成"散在别处"(那是**误报**,不是事实)。
|
||
# 📌 只认 `执行会话/` 下一层的 `目标-*`,⛔ 不认 `执行会话/` 里别的杂物
|
||
# (否则往 `执行会话/` 随便丢个 md 就能骗过判据)。
|
||
stray, inside = [], 0
|
||
for p in ws.rglob("*.md"):
|
||
rel = p.relative_to(ws)
|
||
parts = rel.parts
|
||
if parts[0] in EXEMPT_DIRS or str(rel) in RULE_FILES:
|
||
continue
|
||
if p.name in RULE_FILES or p.name.startswith("CODEBUDDY"):
|
||
continue
|
||
# 只看「顶层 目标-*|执行会话/目标-*|交付物|其他一层目录」这一层,⛔ 不递归进目标文件夹内部
|
||
if len(parts) > 1 and parts[0].startswith("目标-"):
|
||
continue
|
||
if (len(parts) > 2 and parts[0] == "执行会话" and parts[1].startswith("目标-")):
|
||
continue
|
||
if len(parts) == 1:
|
||
stray.append(str(rel)); continue
|
||
if parts[0] == "交付物":
|
||
inside += 1; continue
|
||
stray.append(str(rel))
|
||
res.append(("产物型 md 都落在「执行会话/目标-*/ 或 目标-*/ 或 交付物/」内(⛔ 代码/规则/归档除外);"
|
||
"已在目标文件夹或交付物=%d,散在别处=%d %s"
|
||
% (inside, len(stray), ("→ " + str(stray[:5])) if stray else ""),
|
||
not stray))
|
||
return res
|
||
|
||
|
||
|
||
@case("🔴🔴 `ensure_supervise` spawn 孙进程**必须传 `COLLABD_CONFIG`**(否则起后即退)")
|
||
def t_ensure_passes_config_to_child():
|
||
"""🔴 2026-10-04 **实测坐实**(`vibe-product` 停 16.4 h 的真因)。
|
||
|
||
症状:`--ensure` 报 `{"ok": false, "spawned": true}` ⇒ **看起来起好了,其实秒退**;
|
||
`supervise.out.log` 里只有 spawn 记录、无异常;`tasklist` 查 pid **已不在**。
|
||
|
||
病根(`collabd.py::ensure_supervise()` 的 `subprocess.Popen`):
|
||
· `cwd` =**副本目录**(`<ws>/.workbuddy/collab`)
|
||
· `load_cfg()` 找配置顺序=`COLLABD_CONFIG` → **`<cwd>/.workbuddy/collab/collabd.config.json`**
|
||
· ⛔ **spawn 时没传 `env`** ⇒ 孙进程拿不到 `COLLABD_CONFIG`
|
||
⇒ 回落 `<副本目录>/.workbuddy/collab/…` ⇒ **不存在** ⇒ `CFG_MISSING` ⇒ 秒退。
|
||
|
||
⚠️ 为什么难查:`spawned=True` + 日志无异常 + 我**自己也用这个有缺陷的 `--ensure` 去补起**
|
||
⇒ 它报"起好了"我信了 ⇒ 一次也没验到真活(今天第 N 次「工具报成功我照抄」)。
|
||
"""
|
||
src = (HERE / "collabd.py").read_text(encoding="utf-8", errors="replace")
|
||
R = []
|
||
R.append(("`ensure_supervise` 的 `Popen` **带 `env=`**(⛔ 不传 ⇒ 孙进程丢 `COLLABD_CONFIG`)",
|
||
re.search(r"Popen\(\[[^\]]*PYW[^\]]*--supervise[^\]]*\][\s\S]{0,400}?env=_env",
|
||
src) is not None))
|
||
R.append(("`env` 取自**真来源全局 `CFG_USED`**(⛔ 不是 `C` 里的键 —— `C` 是配置内容字典)",
|
||
re.search(r'if not _env\.get\("COLLABD_CONFIG"\) and CFG_USED:', src) is not None))
|
||
R.append(("⛔ 代码里**没有编造的 `_path_used` 键**(我第一版就写了它 ⇒ 取不到 ⇒ 修复等于没做)",
|
||
"_path_used" not in src))
|
||
R.append(("源码里留着**「起后即退」的成因注释**(⛔ 防后人再踩)",
|
||
"起后即退" in src and "CFG_MISSING" in src))
|
||
# 行为判据:⛔ 真起一条,看它**活过第一轮**(⛔ 不看返回的 ok/spawned,那些不可信)
|
||
try:
|
||
import importlib.util
|
||
env2 = dict(os.environ)
|
||
env2["COLLABD_CONFIG"] = str(HERE.parent.parent).replace("\\", "/")
|
||
_sp = importlib.util.spec_from_file_location("_cd_ens", HERE / "collabd.py")
|
||
m = importlib.util.module_from_spec(_sp); _sp.loader.exec_module(m)
|
||
import time as _t
|
||
_t.sleep(0.3)
|
||
cfg_src = m.CFG_USED
|
||
R.append(("本进程真读到配置(`CFG_USED` = %s)⇒ 有得传给孙进程"
|
||
% (os.path.basename(str(cfg_src)),), bool(cfg_src) and not m.CFG_MISSING))
|
||
except Exception as e:
|
||
R.append(("⛔ 读 collabd 失败:%r" % (e,), False))
|
||
return R
|
||
|
||
|
||
@case("🔴 钩子真接上了:工作区从 stdin.payload.cwd 取(⛔ 不是 DSH_WS_ROOT)")
|
||
def t_hook_reads_ws_from_stdin_cwd():
|
||
"""🔴🔴 2026-10-04 用户问「为什么 vibe 会话说技能没有调整」时挖出的**第二个**真缺陷。
|
||
|
||
病根链(三层,每层都"看起来没问题"):
|
||
① 我上一版 `supervise-ensure-hook.py` 只读 `os.environ["DSH_WS_ROOT"]`;
|
||
② `settings.json` 的 hook 条目**没有 env 字段** ⇒ 宿主 env 里该变量
|
||
**实测三级全空**(Process/User/Machine)⇒ 第①步就 `_noop()` 返回;
|
||
③ 而它的表现是「**零输出、rc=0**」—— 与"正确沉默"**完全一样**
|
||
⇒ 我据此报了"钩子已挂上、修好了",**它一次都没生效过**。
|
||
|
||
⚖️ 同源判据的既有正确写法(⛔ 必须对齐,不能自创):
|
||
`wb-result-hook.py` / `lock-guard-hook.py` 都是**先读 stdin 的
|
||
`payload.cwd`**,env / `roots.env` 只作兜底。
|
||
|
||
🔴 判据要点:⛔ **不许只查"读没读 cwd"**——还要钉住
|
||
① stdin **只读一次**(`sys.stdin.buffer.read()` 是一次性的)
|
||
② `os.environ` 必须被**真改写**(`env = dict(os.environ)` 对
|
||
`exec_module` 里的 `load_cfg()` **无效**)
|
||
③ 加载后必须**核对 `CFG_USED` 真的指向本区**,否则会验错对象。
|
||
"""
|
||
H = HERE / "hooks" / "supervise-ensure-hook.py"
|
||
R = []
|
||
R.append(("钩子文件存在", H.is_file()))
|
||
if not H.is_file():
|
||
return R
|
||
src = H.read_text(encoding="utf-8", errors="replace")
|
||
R.append(("🔴 主路径读 **stdin 的 `payload.cwd`**(⛔ 不能只读 `DSH_WS_ROOT`)",
|
||
'get("cwd")' in src and "_stdin_payload" in src))
|
||
R.append(("`DSH_WS_ROOT` 只作**兜底**(⛔ 不能当唯一来源)",
|
||
'_ws_root() or (os.environ.get("DSH_WS_ROOT")' in src))
|
||
R.append(("🔴 **stdin 载荷有缓存**(⛔ `sys.stdin.buffer.read()` 一次性,"
|
||
"读两次第二次必空 ⇒ 零输出假绿)",
|
||
"_PAYLOAD" in src and "if _PAYLOAD is None" in src))
|
||
R.append(("🔴 `COLLABD_CONFIG` 改的是 **`os.environ`**(⛔ `env = dict(...)` "
|
||
"对 `exec_module` 无效 ⇒ `load_cfg()` 读真进程环境)",
|
||
'os.environ[cfg_env_key] = _cfg_path' in src))
|
||
R.append(("🔴 加载后**核对 `CFG_USED` 指向本区**(⛔ 不核对=验错对象=假绿)",
|
||
"CFG_USED" in src and "_used" in src))
|
||
R.append(("起不来时**必须报出来**(10-04 用户原话「起不来就先报错」,⛔ 不许静默)",
|
||
"常驻没起来" in src and "先修好这个再往下做" in src))
|
||
R.append(("`--ensure` 参数名正确(⛔ 没有 `--ensure-supervise`)",
|
||
'"--ensure"' in src))
|
||
# 🔴🔴 2026-10-04 改判据:原来钉的是 `"capture_output=True, timeout=40"`
|
||
# ⇒ 收超时隐患(40→15)后**判据自己红了**,成了"守着旧值"的绊脚石。
|
||
# ⛔ 判据的职责是**守住"不看返回码"这个口径**,⛔ 不是守住某个超时数字。
|
||
# ✅ 正解=判**形状**:`subprocess.run(...)` 的实参里**没有** `check=`,
|
||
# 且结果**没被赋给任何变量/没取 `.returncode`**(=真的没看返回码),
|
||
# 同时回验走 `supervise_alive()`(唯一可信判据)。
|
||
# ⚠️ 这样收超时值(乃至换回验次数)都⛔ 不会再假红。
|
||
import re as _re_en
|
||
_m_en = _re_en.search(r"subprocess\.run\(\[PY, str\(COLLABD\), \"--ensure\"\](.*?)\)\n",
|
||
src, _re_en.S)
|
||
_args = _m_en.group(1) if _m_en else ""
|
||
R.append(("⛔ 不看 `--ensure` 的返回码(⛔ `spawned:true` 会骗人 ⇒ 等活再问)",
|
||
bool(_m_en)
|
||
and "check=" not in _args
|
||
and "returncode" not in src.split("_ENSURE_SUBPROC_TIMEOUT")[-1][:1200]
|
||
and "supervise_alive()" in src))
|
||
# —— 行为判据(⛔ 这才是能分辨 A/B 的那一条)
|
||
R.append(("⛔ 行为:**喂真 payload(`cwd` 指向一个只有 cfg+goal 的合成区)"
|
||
"⇒ 必须注入「已自动补起」", *_hook_behaves()))
|
||
return R
|
||
|
||
|
||
@case("🔴 T 表 §1.6:起不来先报错(10-04 用户原话「必须首先启动好在执行」)")
|
||
def t_t16_stop_when_supervise_fails():
|
||
"""🔴 这条红线**当天说了两次没落进技能** ⇒ 补上并钉住。
|
||
|
||
用户原话:「**加个规则 后台任务和检查程序如果不能启动,就先报错解决了问题
|
||
在执行完成目标的任务**」「**必须首先启动好在执行**」。
|
||
⚠️ 之前只停在**工作区日志**里(⛔ 换会话读不到 = 没立)。
|
||
"""
|
||
S = (HERE.parent / "SKILL.md").read_text(encoding="utf-8", errors="replace")
|
||
R = []
|
||
R.append(("SKILL.md 有 **§1.6 起不来先报错** 这一节",
|
||
"1.6 起不来 = **先报错、先解决,再执行目标**" in S))
|
||
R.append(("逐字锚用户原话「必须首先启动好在执行」",
|
||
"必须首先启动好在执行" in S))
|
||
R.append(("写明**顺序**(在位才允许往下 / 不在位先起)",
|
||
"在位" in S and "才允许往下建会话" in S))
|
||
R.append(("写明**起不来时三条不许**(不许边起边干/不许只说起好了/不许编原因)",
|
||
"不许\"边起边干\"" in S or "不许“边起边干”" in S
|
||
or "边起边干" in S))
|
||
R.append(("写明**三条边界**(没目标不起也不报/只管在位这一件事/判据只有 pid∧心跳)",
|
||
"基础会话方式" in S and "只在位这一件事" in S.replace(",", ",")
|
||
or "只管「常驻在不在位」这一件事" in S))
|
||
R.append(("指明**由钩子执行**、⛔ 不是靠我记得(能力已接线)",
|
||
"supervise-ensure-hook.py" in S and "不是靠我记得" in S))
|
||
return R
|
||
|
||
|
||
def _hook_behaves():
|
||
"""真喂 payload 跑一次钩子,验它**看得见工作区**(不是只查代码文本)。
|
||
|
||
🔴 为什么必须真跑:本次两个缺陷**代码文本全对**(第一版也确实读 `cwd`),
|
||
栽在「stdin 读两次」+「env 没进真进程」⇒ **纯文本判据会假绿**。
|
||
⚠️ 用**合成区**而不是真实区:真实两区常驻都在位 ⇒ 零输出,
|
||
而零输出同时可能是「看见了+在位」或「⛔ 根本没看见」⇒ 分不出来。
|
||
"""
|
||
import shutil
|
||
import subprocess
|
||
import tempfile
|
||
PY = sys.executable
|
||
H = HERE / "hooks" / "supervise-ensure-hook.py"
|
||
ws = Path(tempfile.mkdtemp(prefix="sm_hooktest_"))
|
||
try:
|
||
(ws / ".workbuddy" / "collab" / "logs").mkdir(parents=True, exist_ok=True)
|
||
(ws / "tmp" / "supervise-inbox").mkdir(parents=True, exist_ok=True)
|
||
(ws / ".workbuddy" / "collab" / "collabd.config.json").write_text(
|
||
json.dumps({"schema": 1, "workspace": str(ws)}), encoding="utf-8")
|
||
(ws / "tmp" / "supervise-inbox" / "goal.json").write_text(
|
||
json.dumps({"short": "hooktest", "title": "hook selftest",
|
||
"lifecycle": "进行中", "topics": ["自检"]}), encoding="utf-8")
|
||
pl = json.dumps({"cwd": str(ws).replace("\\", "/"),
|
||
"prompt": "继续完成目标"}, ensure_ascii=False)
|
||
p = subprocess.run([PY, str(H)], input=pl.encode("utf-8"),
|
||
capture_output=True, timeout=90)
|
||
out = p.stdout.decode("utf-8", "replace")
|
||
ctx = ""
|
||
try:
|
||
ctx = (json.loads(out).get("hookSpecificOutput") or {}).get("additionalContext") or ""
|
||
except Exception:
|
||
pass
|
||
ok = "已自动补起" in ctx
|
||
# 收尾:合成区起出来的常驻必须收掉,⛔ 不留进程
|
||
try:
|
||
hb = ws / ".workbuddy" / "collab" / "logs" / "supervise-heartbeat.json"
|
||
pid = json.loads(hb.read_text(encoding="utf-8-sig")).get("pid")
|
||
if pid:
|
||
subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"],
|
||
capture_output=True, timeout=30)
|
||
except Exception:
|
||
pass
|
||
return (ok,)
|
||
except Exception as e:
|
||
return ("⛔ 行为判据跑不起来:%r" % (e,),)
|
||
|
||
|
||
@case("🔴 T 表:状态 → 我该做什么(2026-10-04 用户口述 · ⛔ 不许 AI 推断)")
|
||
def t_state_to_action_table():
|
||
"""🔴 这条是**「一天栽三次」的直接对策**。
|
||
|
||
2026-10-04 我三次把**自己推断的**东西当成需求/欠项:
|
||
① 「开机自启/计划任务」⇒ 用户:「从来没说过什么开机自启」
|
||
② 「三类会话要建齐」 ⇒ 那口径 10-02 就作废了
|
||
③ 「两类会话要建齐」 ⇒ 现行是「要落事才显式建」
|
||
三次的共同根因:**技能只有「你要干什么 → 去看哪篇」(知识组织),
|
||
没有「现在什么状态 → 下一步做什么」(行动组织)**
|
||
⇒ 查到一条知识就以为「知道该做什么了」。
|
||
|
||
✅ 解法=把这张表**落进 SKILL.md**(⛔ 落在 `references/` 里没人先看到),
|
||
并且**三条红线里最要紧的那条必须钉死**:不许把「实体里存在」读成「该有」。
|
||
"""
|
||
S = (HERE.parent / "SKILL.md").read_text(encoding="utf-8", errors="replace")
|
||
R = []
|
||
R.append(("SKILL.md 有 **T 表**(状态 → 我该做什么)",
|
||
"T 表 · 状态 → 我该做什么" in S))
|
||
R.append(("ⓘ 写明它**存在的原因**(⛔ 防后人当成又一份知识文档删掉)",
|
||
"知识组织" in S and "行动组织" in S))
|
||
# —— 逐字锚用户原话(⛔ 今天的第 12 次教训:锚点必须逐字,⛔ 不能凭记忆重写)
|
||
R.append(("写明**会话生命周期**(用户原话逐字:主会话才建一轮,后续无意外走检查进程)",
|
||
"才建立一轮执行会话" in S and "后续无意外都是 检查进程创建" in S))
|
||
R.append(("写明**只有第①步是主会话建任务会话**,②主会话收手",
|
||
"只有这一步是「主会话建执行会话」" in S and "主会话到这一步就收手" in S))
|
||
# —— 「这轮结束了」的判据
|
||
R.append(("写明**靠投递判完成**(用户原话:执行会话完成会往检查程序队列投递)",
|
||
"向检查程序队列投递执行情况" in S))
|
||
R.append(("写明 **`done` 必须带 `--artifact`**(10-03 用户逐字要求,⛔ 拒收无产物的 done)",
|
||
"必须**带 `--artifact`**" in S or "必须带 `--artifact`" in S))
|
||
R.append(("写明**台账是唯一权威** + ⛔ 别读快照"
|
||
"(⚠️ `acceptance_state` 记着 10-02 的 pid 8024,实际已是 19424)",
|
||
"唯一权威" in S and "8024" in S))
|
||
# —— 检查会话不能固定席位
|
||
R.append(("写明**检查会话不能固定席位**(用户原话:没办法固定,有记录吗)",
|
||
"没办法固定" in S or "不许预先摆一条「固定检查会话」" in S))
|
||
# 🔴 钉**唯一位置**(T 表那一行),⛔ 不全文搜两个词
|
||
# —— 旧写法 `台账空 in S and 不许**建任务会话 in S` 被变异 V3 打不穿:
|
||
# 改表格那一行,两处关键词在**别处**(§1 甘特图行)仍命中 ⇒ 恒绿。
|
||
import re as _re5
|
||
_row = [ln for ln in S.splitlines()
|
||
if ln.strip().startswith("| 台账空")]
|
||
R.append(("⛔ T 表「台账空」那一行写明**不许建任务会话**(⛔ 不许造需求;"
|
||
"⚠️ 必须钉**这一行**,⛔ 全文搜会被别处的同词打穿)",
|
||
bool(_row) and "不许" in _row[0] and "建任务会话" in _row[0]))
|
||
# —— 🔴 前置条件层(2026-10-04 用户补充:「如果主会话下没有 后台任务 或
|
||
# 检查程序 还需要启动常驻程序」⇒ 三层串起来,⛔ 不许跳过中间那层)
|
||
R.append(("写明**三层是串起来的**(常驻 → 检查会话 → 那一轮任务会话)",
|
||
"1.5 前置条件" in S and "唯一载体" in S))
|
||
R.append(("写明**常驻是「建检查会话」那条腿的唯一载体**(⛔ 跳层=建了也接不上)",
|
||
"没有常驻 ⇒ 检查会话永远建不出来" in S))
|
||
R.append(("写明**真顺序 0-a/0-b/0-c**(⛔ 顺序不能颠倒)",
|
||
"0-a" in S and "0-b" in S and "0-c" in S))
|
||
R.append(("0-a 量的是 **`supervise_alive()`**(pid 活 ∧ 心跳新鲜)⛔ 不看快照",
|
||
"supervise_alive()" in S and "心跳新鲜" in S))
|
||
# 🔴🔴 2026-10-04 改:闸① **收窄**(用户口径「检查程序必然要去处理队列」)
|
||
# ⇒ 旧断言「闸① 生命周期必须「进行中」」**已经过时**,不改它会**红着掩盖新口径**。
|
||
# ✅ 新断言=钉**收窄后的口径**:只对 `queue-empty` 生效,`sessions-ended` 不受目标状态限制。
|
||
R.append(("0-b 点明**闸① 只对 `queue-empty` 生效**(代码五道闸之①,2026-10-04 收窄)",
|
||
"进行中" in S and "`queue-empty` 那条不动" in S))
|
||
R.append(("⛔ 写明**跳过常驻直接建会话 = 接不上**(检查会话只能靠排期开)",
|
||
"跳过常驻直接建会话" in S))
|
||
# 🔴 钉**状态表那两行**(唯一位置),⛔ 不全文搜
|
||
# —— 「基础会话方式」全文出现 3 处,V1 变异只改一处 ⇒ 全文搜**恒绿**(今天第 16 次)
|
||
_r0b = [ln for ln in S.splitlines()
|
||
if ln.strip().startswith("| **0-b**")]
|
||
_r0t = [ln for ln in S.splitlines()
|
||
if ln.strip().startswith("| **没有目标**")]
|
||
R.append(("📌 **0-b 那一行**写明「没有目标 ⇒ 基础会话方式,⛔ 那不是故障」"
|
||
"(⛔ 必须钉**这一行**)",
|
||
bool(_r0b) and "基础会话方式" in _r0b[0] and "不是故障" in _r0b[0]))
|
||
R.append(("📌 **状态表「没有目标」那一行**判它是**正常**(⛔ 什么都用不起)"
|
||
"(⛔ 钉**这一行**)",
|
||
bool(_r0t) and "正常" in _r0t[0] and "什么都不用起" in _r0t[0]))
|
||
R.append(("⛔ 状态表里「**没有目标**」那一行写明**⛔ 不许说成缺目标/缺配置**",
|
||
"不许**说成「缺目标/缺配置」" in S or "不许**说成「缺目标" in S))
|
||
# —— 🔴 状态表里「**目标已完成**」那一档(10-04 补:原来表里只有进行中/等待/没有目标)
|
||
# ⛔ 必须**钉那一行**(唯一位置)—— 全文搜「已完成」会命中几十处 ⇒ 恒绿
|
||
_rdone = [ln for ln in S.splitlines()
|
||
if ln.strip().startswith("| **目标 `lifecycle=已完成`**")]
|
||
R.append(("🔴 状态表有「**目标 lifecycle=已完成**」那一档,且写明**含带后缀**形态"
|
||
"(⛔ 钉这一行)",
|
||
bool(_rdone) and "带后缀" in _rdone[0] and "已完成(机器可判部分)" in _rdone[0]))
|
||
# 🔴🔴 2026-10-04 改:旧断言写「**⛔ 不再建任何检查会话**(闸①不过=设计)」——
|
||
# 那句「这是设计,不是故障」正是**被用户当场否掉**的结论(P0-56)⇒ 断言必须跟着翻。
|
||
# ✅ 新口径=**先看台账**:还有未完成件 ⇒ 照建 `[结果检查]`;全 done/空 ⇒ 收工。
|
||
R.append(("🔴 那一档写明 **先看台账**:还有未完成件 ⇒ **照建 `[结果检查]`**(闸① 不拦它)",
|
||
bool(_rdone) and "还有活没人干" in _rdone[0] and "[结果检查]" in _rdone[0]))
|
||
R.append(("🔴 那一档写明 **全 done/空 ⇒ 收工**(⛔ 不再一律收工 —— 那是旧口径)",
|
||
bool(_rdone) and "收工" in _rdone[0] and "不再建任何检查会话" not in _rdone[0]))
|
||
R.append(("🔴 那一档写明 **⛔ 不许把「已完成」说成「等待」**(语义相反,实测栽过)",
|
||
bool(_rdone) and "语义相反" in _rdone[0]))
|
||
# —— 🔴 0-b 那条硬约束:**读数必须问代码,⛔ 不许肉眼读 goal.json 原文**
|
||
R.append(("🔴 写 0-b 时**必须问代码**(⛔ 不许肉眼读 `goal.json` 原文比对)——"
|
||
"正是被带偏的那个入口",
|
||
"不要自己读 `goal.json` 的原文再肉眼比对" in S))
|
||
R.append(("⛔ 明写**⛔ 不许我代他设目标**(想让它有 ⇒ 用户开)",
|
||
"不许我代设目标" in S or "不许**代他设目标" in S))
|
||
# —— 行为判据:现读本区 lifecycle,验闸① 真的过不了
|
||
try:
|
||
import json as _j2
|
||
gp = WS / "tmp" / "supervise-inbox" / "goal.json"
|
||
life = None
|
||
if gp.is_file():
|
||
life = str((_j2.loads(gp.read_text(encoding="utf-8")) or {}).get("lifecycle") or "")
|
||
# 🔴 验的是「**读数可判**」,⛔ **不预设它该是什么** ——
|
||
# 原写法硬编码 `life != "进行中"`(那是**本区当时**的状态 None)
|
||
# ⇒ 同一份判据在副本区(lifecycle='进行中')报红 ⇒ **验错对象**(今天第 15 次)。
|
||
# 🔴🔴 口径订正(用户 2026-10-04:「没有目标就说明 当前用户是用的是基础会话方式」):
|
||
# ⛔ **不许**把「非进行中」写成需要修的缺口 —— 那不是故障,是**用户在用基础会话方式**。
|
||
# ⇒ 判据只验「**读得到、且能据此判**」,⛔ 不断言「本区该不该有目标」。
|
||
R.append(("现读本区 `lifecycle`(=%r)—— 能据此判「是不是基础会话方式」即可"
|
||
"(⛔ **不预设该是什么**)" % life,
|
||
# 🔴🔴 2026-10-04 修:原来是一份**枚举白名单**,结果 `vibe-product`
|
||
# 的 `'已完成(机器可判部分)'`(**带后缀**)不在名单里 ⇒ 副本区报红。
|
||
# ⛔ 那是**判据自己枚举不全**,不是真故障 —— 又一次「验错对象」。
|
||
# ✅ 正确判法=只验「**读得到、且非空或明确为 None**」,
|
||
# ⛔ 不断言它是哪个值(值由各区用户自己定,机制无权预设)。
|
||
(life is None) or (isinstance(life, str))))
|
||
except Exception as e:
|
||
R.append(("⛔ 读 lifecycle 失败:%r(**不假装通过**)" % (e,), False))
|
||
|
||
# —— 🔴🔴 行为判据:`goal_life()` 必须**剥括号后缀再判**(2026-10-04 真缺陷)
|
||
# 病根(`vibe-product` 实测):`goal.json` 写 `"已完成(机器可判部分)"`,
|
||
# 而 `goal_life()` 只做**精确匹配** ⇒ 静默回落成 `等待`
|
||
# ⇒ 「已完成」被读成「等待」(**语义相反**)⇒ 日志/主会话全被带偏。
|
||
# ⚠️ 这条判据**必须在真模块上跑**(⛔ 不做文本 grep):
|
||
# 文本判据只能证明"写了 split('(')",证明不了"真的能读出 已完成"。
|
||
try:
|
||
# ⚠️ 本函数后面还有 `import json`(局部)⇒ 在它之前用 `json` 会 `UnboundLocalError`
|
||
# ⇒ 这里**显式局部导入**,⛔ 不依赖外层作用域(实测就是这么挂掉的)。
|
||
import json as _j_gl
|
||
import re as _re_life
|
||
|
||
def _load_mod():
|
||
import importlib.util as _iu
|
||
_sp = _iu.spec_from_file_location("cb_life", HERE / "collabd.py")
|
||
_mm = _iu.module_from_spec(_sp)
|
||
_sp.loader.exec_module(_mm)
|
||
return _mm
|
||
# ① 代码里确有剥后缀(文本层,只作辅助)
|
||
_src = (HERE / "collabd.py").read_text(encoding="utf-8")
|
||
R.append(("🔴 `goal_life()` 有**剥括号后缀**的分支(全角/半角各一条)",
|
||
"(" in _src and '.split("(", 1)[0]' in _src
|
||
and '.split("(", 1)[0]' in _src))
|
||
# ② 行为层:直接喂三种带后缀的字面量,断言读出**标准值**
|
||
# ⚠️ 用**临时改 goal.json 再还原**(字节级),⛔ 不猜、不 mock
|
||
# 🔴🔴 **必须改"本模块实际读的那个文件"**,⛔ 不许拿常量硬拼凑路径。
|
||
# 2026-10-04 实测两个坑(两个都栽过,判据因此**假红**过一整轮):
|
||
# 坑1:写 `WS / "tmp" / "supervise-inbox"` —— `WS` 是**跑 selftest 的那个区**
|
||
# (`DSH_WS_ROOT` 或 `cwd`),⛔ 不是被检模块的区。
|
||
# 坑2:写 `Path(_m_probe.INBOX) / "goal.json"` —— 看着"从模块取"很稳,
|
||
# 但 `INBOX` 是**模块加载那一刻算出来的默认值**:
|
||
# 没配 `COLLABD_CONFIG` 时它回落成 `<技能目录>/tmp/supervise-inbox`
|
||
# (见实测:`scripts/tmp/supervise-inbox/goal.json`,`is_file=False`)
|
||
# ⇒ 写进去的是**没人读的空目录**,`goal_life()` 照样读不到 ⇒ 永远 `等待`
|
||
# ⇒ **判据假红**(测的是"文件不存在"这个副作用,⛔ 不是被测行为)。
|
||
# ✅ 正解=**用与被测函数同一条路径公式**:
|
||
# `路径 = Path(root) / "tmp" / "supervise-inbox" / "goal.json"`,
|
||
# 而 `root` 取 `WS`(模块自己算出来的区)。
|
||
# ⛔ 这样即使实现改了公式,也是**实现与判据一起改**(同步暴露),
|
||
# 不会出现"判据偷偷指向第三处"的假红/假绿。
|
||
_m_probe = _load_mod()
|
||
_gp = Path(_m_probe.WS) / "tmp" / "supervise-inbox" / "goal.json"
|
||
_gp.parent.mkdir(parents=True, exist_ok=True)
|
||
_bak = _gp.read_bytes() if _gp.is_file() else None
|
||
try:
|
||
_base = _j_gl.loads(_bak.decode("utf-8")) if _bak else {}
|
||
_cases = [("已完成(机器可判部分)", "已完成"),
|
||
("已完成(机器可判部分)", "已完成"),
|
||
("进行中(第2棒)", "进行中"),
|
||
("已完成", "已完成")]
|
||
_okall = True
|
||
_det = []
|
||
for _lit, _want in _cases:
|
||
_base["lifecycle"] = _lit
|
||
_gp.write_bytes(_j_gl.dumps(_base, ensure_ascii=False,
|
||
indent=2).encode("utf-8") + b"\n")
|
||
_m_gl = _load_mod() # 重新加载,⛔ 不靠缓存
|
||
_got = _m_gl.goal_life()
|
||
_det.append("%s→%s" % (_lit, _got))
|
||
if _got != _want:
|
||
_okall = False
|
||
R.append(("🔴 **行为**:带后缀字面量必须读成标准值(%s)" % ";".join(_det),
|
||
_okall))
|
||
finally:
|
||
if _bak is not None:
|
||
_gp.write_bytes(_bak) # 字节级还原(⛔ 不碰行尾)
|
||
# ③ ⛔ 认不出时**必须留痕**(不许静默回落 —— 否则"读错"与"本来没有"长得一样)
|
||
R.append(("⛔ `goal_life()` 认不出字面量时**写日志**(⛔ 不许静默回落)",
|
||
"目标状态字面量认不出" in _src
|
||
and "log_throttled(" in _src))
|
||
# ④ 🔴🔴 **单一事实源**:判定算法只许有一份(用户口径「禁止重复判定标准」)
|
||
# 病根(2026-10-04 实测):`peer_supervise_sweep()` 自己抄了一遍
|
||
# `str(g.get("lifecycle")).strip()` + 精确比较(**不剥后缀**)
|
||
# ⇒ 与 `goal_life()` 对**同一个文件**给出**两个答案**。
|
||
# ✅ 正解=抽 `goal_life_of(root)`,`goal_life()` 只作薄封装。
|
||
# 判据=① 存在 `goal_life_of(root)` 定义;② `goal_life()` 的函数体**不含**
|
||
# `get("lifecycle")`(自己判就是又抄了一遍);③ 跨区扫描**调** `goal_life_of(`
|
||
# 而**不再**自己读 `lifecycle`。
|
||
_src_life = _src # 同一份源码
|
||
_body_gl = _re_life.search(r"^def goal_life\(\) -> str:(.*?)(?=^def )",
|
||
_src_life, _re_life.S | _re_life.M)
|
||
_body_sweep = _re_life.search(r"^def peer_supervise_sweep\((.*?)(?=^def )",
|
||
_src_life, _re_life.S | _re_life.M)
|
||
|
||
def _code_only(txt: str) -> str:
|
||
"""⛔ 只留**代码行**(去掉整行注释)。
|
||
⚠️ 必须这么做:本文件的注释里**大量引用**旧写法当反面教材
|
||
(如「原来这里自己判 `get("lifecycle")`」)⇒ 裸串搜会把注释当代码
|
||
⇒ 判据**假红**(实测:`peer_supervise_sweep` 那条就这么红的)。
|
||
"""
|
||
return "\n".join(ln for ln in txt.splitlines()
|
||
if not ln.lstrip().startswith("#"))
|
||
|
||
R.append(("🔴🔴 **单一事实源**:判定算法只有 `goal_life_of(root)` 一份"
|
||
"(⛔ `goal_life()` 只作薄封装、自己不许再读 `lifecycle`)",
|
||
_re_life.search(r"^def goal_life_of\(root\) -> str:", _src_life, _re_life.M)
|
||
is not None
|
||
and bool(_body_gl)
|
||
and 'get("lifecycle")' not in _code_only(_body_gl.group(1))))
|
||
R.append(("🔴 跨区扫描 `peer_supervise_sweep()` **调** `goal_life_of(`"
|
||
"(⛔ 不许自己抄一遍 lifecycle 判定)",
|
||
bool(_body_sweep)
|
||
and "goal_life_of(" in _code_only(_body_sweep.group(1))
|
||
and 'get("lifecycle")' not in _code_only(_body_sweep.group(1))))
|
||
except Exception as e:
|
||
R.append(("⛔ 跑 `goal_life()` 后缀判据失败:%r(**不假装通过**)" % (e,), False))
|
||
|
||
# —— 三条红线
|
||
R.append(("红线①**不在表里的状态 ⇒ 问用户**(⛔ 不许推断后当需求)",
|
||
"凡不在上表的状态 ⇒ 问用户" in S))
|
||
R.append(("红线②**「实体里存在」⛔ 不等于「该有」**(当天栽的就是这条)",
|
||
"不等于「该有」" in S))
|
||
R.append(("红线③**⛔ 不许把推断摊成欠项/风险/待办**(凭空造需求)",
|
||
"不许把自己以为该有的东西摊成「欠项」" in S))
|
||
R.append(("红线③里**留了当天三次的实证**(开机自启/三类/两类 ⇒ ⛔ 空头红线不算数)",
|
||
"开机自启" in S and "三类会话" in S and "两类会话" in S))
|
||
# —— 行为判据:拿真台账验「这轮结束了没有」能被判出来
|
||
import json
|
||
# 🔴 路径**实测**得来,⛔ 不凭猜:测试台账在 `TEST_WS/inbox/`(`inbox` =配置键名);
|
||
# ⛔ `supervise-inbox/` 是**生产**工作区的落点(今天凭猜写这个 ⇒ FileNotFoundError)
|
||
tk = TEST_WS / "inbox" / "tasks.json"
|
||
try:
|
||
d = json.loads(tk.read_text(encoding="utf-8"))
|
||
ts = d if isinstance(d, list) else (d.get("tasks") or [])
|
||
states = [str(t.get("state") or t.get("status") or "") for t in ts]
|
||
R.append(("真读台账**判得出「这轮结束了没有」**(`tasks.json` 可解析,%d 条)" % len(ts),
|
||
True))
|
||
R.append(("台账里 **`done` 必带 `artifact`**(⛔ 判据:空的 done = 假完成)",
|
||
all(t.get("artifact") for t in ts
|
||
if str(t.get("state") or t.get("status")) == "done") or
|
||
"done" not in states))
|
||
except Exception as e:
|
||
R.append(("⛔ 台账读不出(%r)⇒ 这一条无法验(**不假装通过**)" % (e,), False))
|
||
return R
|
||
|
||
|
||
@case("📇 文档索引:改流程/改机制前**必读三篇**在第一屏 + 文档四条规则在位")
|
||
def t_doc_index():
|
||
"""🔴 落它的起因(用户原话):「**实在文件多看不过来 就建立一个索引**」。
|
||
|
||
⚠️ **不是"整理归档",是"防复发"**:10-03 立的「⛔ 写『会怎样』前先取证那个后果」
|
||
只躺在**工作区日志**(197 KB)⇒ 换会话读不到 ⇒ 10-04 又编了一次后果。
|
||
⇒ 索引的真正作用:**让"下次必须做到的事"落在会被自动加载的地方**。
|
||
|
||
📌 本判据量的四件事(⛔ 少一件都等于索引没建):
|
||
① 索引档在位 ② SKILL.md **第一屏**挂「必读三篇」 ③ 文档四条规则逐字在位
|
||
④ ⛔ 别让它烂掉(新文档**必须**登记在索引里 —— ⛔ 没登记=别人找不到=白写)
|
||
"""
|
||
G = HERE.parent
|
||
IDX = G / "references" / "01-文档索引.md"
|
||
R = []
|
||
R.append(("📇 索引档在位 `references/01-文档索引.md`", IDX.is_file()))
|
||
if not IDX.is_file():
|
||
return R
|
||
idx = IDX.read_text(encoding="utf-8", errors="replace")
|
||
skill = (G / "SKILL.md").read_text(encoding="utf-8", errors="replace")
|
||
|
||
# ① 必读三篇:⛔ 必须是**三篇具体文件名**(⛔ 写"相关文档"等于没写)
|
||
for f in ("00-动手前必过.md", "rules.md", "manifest.md"):
|
||
R.append(("索引里点名必读 `%s`" % f, f in idx))
|
||
R.append(("索引里有**「改流程/改机制前」这个入口**(⛔ 索引最常见的用法就是它)",
|
||
"改流程" in idx and "改机制" in idx))
|
||
R.append(("⛔ 明写**别通读**(⛔ 索引的意义就是不用全看)", "别通读" in idx))
|
||
|
||
# ② SKILL.md **第一屏**:⛔ 埋在下面=没人会看到(今天已经为「红线埋在末尾」栽过一次)
|
||
# 🔴 量法修正:⛔ **不用 `skill[:2000]` 切片** —— frontmatter 就 3000+ 字符
|
||
# ⇒ 索引明明在第 24 行却被判「不在第一屏」(**假红**,今天第 7 次同形状)。
|
||
# ⇒ 真「第一屏」=**标题之后的第一个非空块**。
|
||
_after = skill.split("# session-mechanism", 1)[-1].lstrip("\n")
|
||
head = _after[:1200]
|
||
R.append(("SKILL.md **第一屏**就有「改流程/改机制前先看这三篇」",
|
||
"改流程" in head and "00-动手前必过.md" in head))
|
||
R.append(("SKILL.md 第一屏**指向索引档**",
|
||
"01-文档索引.md" in head))
|
||
|
||
# ③ 文档四条规则(用户 10-04 逐字给定)—— 逐条在位
|
||
for key, why in (("分类索引", "新文档必须登记,否则白写"),
|
||
("结论在最前", "读者要的是现在是什么样,不是改了几轮"),
|
||
("新的在前面", "历史倒排:追加只能往前插"),
|
||
("简明扼要有效", "单条上限,防流水账")):
|
||
R.append(("四条规则之一「**%s**」在位(%s)" % (key, why), key in idx))
|
||
R.append(("⛔ 四条规则里**「单条 ≤6 KB」写明上限**(⛔ 只说「简明」没有上限=没约束)",
|
||
"6 KB" in idx))
|
||
R.append(("⛔ 明确**追加只能往前插,不许接在末尾**(历史倒排的可执行说法)",
|
||
"不许接在末尾" in idx))
|
||
R.append(("⚠️ 同时写着**判据要点一个不许丢**(⛔ 精简过头=假绿,比啰嗦更坏)",
|
||
"判据要点" in idx and "假绿" in idx))
|
||
|
||
# ④ 索引不许烂掉:⛔ 「存档不等于读得到」那节必须在(今天栽的根因)
|
||
R.append(("索引里写着**「存档 ≠ 读得到」**那节(10-04 实证的根因)",
|
||
"读得到" in idx))
|
||
R.append(("⛔ 明写「只写进工作区日志的规矩 = 没立」"
|
||
"(⛔ 这是今天复发的那一条的直接对策)",
|
||
"没立" in idx))
|
||
# 行为判据:⛔ 索引点名的每个 references 文件**必须真存在**(防"登记了但文件没了")
|
||
import re as _re3
|
||
# 🔴 正则**要认带不带 `references/` 前缀两种写法**(索引里多数写作 `00-动手前必过.md`)
|
||
# ⛔ 还要**修双写**:`r"\\w"` 在 Python 里是匹配**字面 `\w`**,⛔ 不成字符类
|
||
# (实测:写成 `\\w` ⇒ 匹配到 0 个 ⇒ 这条行为判据**空转恒绿**)
|
||
named = set(_re3.findall(r"`(?:references/)?([\w\u4e00-\u9fff\-]+\.md)`", idx))
|
||
bad = [n for n in named
|
||
if not ((G / n).is_file() or (G / "references" / n).is_file())]
|
||
R.append(("索引点名的 %d 个 references 文件**全部真存在**(⛔ 登记了但文件没了=索引在说假话)"
|
||
% len(named), named and not bad))
|
||
return R
|
||
|
||
|
||
@case("🔴 红线:⛔ 说「会怎样」前必须取证那个后果(10-03 立 · ⛔ 10-04 又犯)")
|
||
def t_no_invented_consequence():
|
||
"""🔴 这条是**跨会话翻车**的实证:
|
||
10-03 23:20 立了「⛔ 写『会怎样』前必须先取证那个后果真的存在」,
|
||
⛔ **但它只躺在工作区日志里**(`.workbuddy/memory/2026-10-03.md`,197 KB)
|
||
⇒ 换会话/换技能**读不到** ⇒ 10-04 我又编了一次
|
||
(说「常驻挂在会话进程树上、会话一关就被带走」;实测它连跑 24.2 h,
|
||
父进程已退出=**孤儿进程** ⇒ **不受会话结束影响**)。
|
||
|
||
⚠️ **真正的根因不是「忘了」,是「存档放错地方」**:
|
||
· 工作区日志 197 KB = 只有在那个工作区翻才看得到
|
||
· 技能(跨会话唯一会自动加载的)**当时没写**
|
||
· 技能仓库 **10-04 11:22 才首次入库** ⇒ 昨天成果压根没进版本库
|
||
⇒ 本判据量的就是「它**进技能了没有**」——⛔ 还在日志里就等于没存档。
|
||
"""
|
||
doc = (HERE.parent / "references" / "00-动手前必过.md").read_text(
|
||
encoding="utf-8", errors="replace")
|
||
R = []
|
||
import re as _re2
|
||
# 🔴 序号后面还跟着 emoji(`## ⓿ 🔴🔴 …`)⇒ 取**第一个字符**(⛔ 用 \S+ 会抓成 `⓿`+emoji 之外的整块)
|
||
_heads = [(m.start(), m.group(1)[:1]) for m in _re2.finditer(r"^##\s*(\S)", doc, _re2.M)]
|
||
_first = _heads[0][1] if _heads else ""
|
||
_red = doc.find("取证那个后果真的存在")
|
||
_red_sec = next((h for pos, h in _heads if pos < _red), "")
|
||
R.append(("红线在**动手层第一段**(⛔ 埋在末尾 = 不会先被看到)",
|
||
# 🔴 真量「红线**落在第几个标题区间**」⛔ 不比序号字符(变异实测两种写法都打不穿:
|
||
# ① 改标题序号、位置不动 ② 整段挪到末尾、序号不变 ⇒ 恒绿假判据,今天第 6 次同形状)
|
||
# ⇒ 判据:红线落在**第一个标题区间**内(`_heads[0][0] <= 红线 < 第二个标题起点`)
|
||
bool(_heads) and len(_heads) >= 2
|
||
and _heads[0][0] <= _red < _heads[1][0]))
|
||
R.append(("明写「编一个后果、说得更像回事」=**比原句更坏**",
|
||
# 🔴 量「**出现在第一段里**」⛔ 不量全文出现过(V2 变异实测:文档里有**两处**,
|
||
# 改一处另一处还在 ⇒ 恒绿 ⇒ 判据必须钉**唯一位置**)
|
||
_red > 0 and "说得更像回事" in doc[_red:_red + 700]
|
||
and "比原句更坏" in doc[_red:_red + 700]))
|
||
R.append(("明写**假情报**这个词(⛔ 后果会被当真话用)", "假情报" in doc))
|
||
R.append(("带**当天的实证**(24.2 h / 8695 轮 / 孤儿进程)⛔ 不留空头红线",
|
||
"24.2" in doc and "8695" in doc and "孤儿" in doc))
|
||
R.append(("点明「正确答案早写在文档里」+「把它读反了」"
|
||
"(⛔ 防的是「没查」与「读反了」两种)",
|
||
"早写在" in doc and "读反" in doc))
|
||
R.append(("第二条同源红线也在:**改到第三轮说不清就问要不要留**"
|
||
"(10-03 与 10-04 各撞一次)",
|
||
"第三轮" in doc and "这行本身就不该在" in doc))
|
||
R.append(("「报结论前」的清单**含那条取证问**(⛔ 收口时必看)",
|
||
"都有读数证明吗" in doc))
|
||
return R
|
||
|
||
|
||
@case("🔴 常驻「活多久」的判据:**存活时长 = 发起会话的存活时长**(⛔ 禁编造「活不过当轮」当后果)")
|
||
def t_supervise_lifespan_not_invented():
|
||
"""🔴🔴 2026-10-04 **当天栽的第 5 次同形状**(用户当场纠正:
|
||
「本会话 后台任务 和 程序 不是一直好好的 都跑了2天了」)。
|
||
|
||
我说了什么:`没载体 ⇒ 常驻挂在当前会话的进程树上 ⇒ 会话一关就被带走`。
|
||
🔴 **实测把它推翻**(不许反驳用户,得看数据):
|
||
· pid 19424 启动于 **10-03 17:05:36**,到 10-04 17:15 **连续活 24.2 h / 8695 轮**,心跳 4 s 前
|
||
· 它的 **父进程 pid 57216 早已不在进程表** ⇒ 它是**孤儿进程**
|
||
· 孤儿化之后**独立存活,⛔ 不受会话结束影响** ⇒ 我说的"会被带走"是**编造的后果**
|
||
|
||
⚠️ 讽刺的是**正确答案早就写在权威文档里**(`supervise-persistence.md` §〇 那条「实测更正」):
|
||
「⚠️ **它们现在活着,只因为发起它们的那条会话进程树至今没被回收**
|
||
⇒ 「**存活时长 = 发起会话的存活时长**」
|
||
⛔ 不能用它论证「这种起法也能长期」」
|
||
⇒ **我今天把这条读反了,还编出了它没说的后果。**
|
||
|
||
⚠️ 为什么单独立判据(⛔ 不是又写条纪律):
|
||
今天的两个根因(A 连接未验证/B 验证器自身失败被当结论)**都是"判据"层的**,
|
||
这条是**"结论"层的** ⇒ 它得有个自己的门:**凡要说「它会死/活不过」,必须先验父链。**
|
||
"""
|
||
P = (HERE.parent / "references" / "supervise-persistence.md")
|
||
doc = P.read_text(encoding="utf-8", errors="replace")
|
||
R = []
|
||
# ① 权威文档里那条**实测判据**必须在位(⛔ 它是今天唯一推翻我编造的依据)
|
||
R.append(("权威文档写着「**存活时长 = 发起会话的存活时长**」"
|
||
"(⛔ 这是唯一能推翻「活不过当轮」的实测依据)",
|
||
"存活时长 = 发起会话的存活时长" in doc))
|
||
R.append(("文档明写「它们现在活着,只因为…会话进程树至今没被回收」"
|
||
"(⛔ 承认**活着的因**,而不把「活着」当成「这种起法可行」)",
|
||
"至今没被回收" in doc))
|
||
R.append(("文档⛔ 明写「不能用它论证这种起法也能长期」"
|
||
"(⛔ 防的正是我今天犯的推断)",
|
||
"不能用它论证" in doc))
|
||
|
||
# ② 🔴 真实验一次**父链**(⛔ 断言必须建立在现读数据上,⛔ 不许复述文档)
|
||
import json
|
||
import time
|
||
hb = None
|
||
for cand in (Path(os.environ.get("DSH_WS_ROOT") or ".") / ".workbuddy" / "collab" / "logs"
|
||
/ "supervise-heartbeat.json",):
|
||
try:
|
||
if cand.is_file():
|
||
hb = json.loads(cand.read_text(encoding="utf-8"))
|
||
break
|
||
except Exception:
|
||
pass
|
||
if not hb:
|
||
return R + [("⛔ 本区没有心跳文件 ⇒ 父链这条验不了(**不假装通过**)", False)]
|
||
age_h = (time.time() - float(hb.get("started_ts") or 0)) / 3600.0
|
||
# 🔴 ⚠️ 时长**只打印、不作断言**(2026-10-04 踩过):本条要反驳的是
|
||
# 「活不过当轮」⇒ 只需**能算出时长且 pid 活着**。
|
||
# ⛔ 硬要「> 1 小时」⇒ 常驻一重启(pid 19424→65072,活 0.2 h)就**假红**。
|
||
R.append(("**能算出常驻存活时长**(本次 %.2f h)+ 起点是 `started_ts`"
|
||
" ⇒ ⛔ 反驳「工具算不出、只能靠猜」" % age_h, age_h > 0.0))
|
||
R.append(("常驻**此刻活着**(pid 活)—— 这才是「没活不过当轮」的直接反证",
|
||
bool(hb.get("pid"))))
|
||
R.append(("心跳里带 `started_ts`(⛔ **算「活了多久」的唯一依据** —— "
|
||
"⛔ 别用 mtime 猜、⛔ 别用 pid 猜)",
|
||
bool(hb.get("started_ts"))))
|
||
R.append(("心跳里带 `argv0`(**能证伪「跑的是哪份文件」**)",
|
||
bool(hb.get("argv0"))))
|
||
src_self = Path(__file__).read_text(encoding="utf-8", errors="replace")
|
||
i = src_self.find("def t_supervise_lifespan_not_invented")
|
||
j = src_self.find("@case(", i + 10)
|
||
seg = src_self[i:j]
|
||
import re as _re
|
||
age_lines = [ln for ln in seg.splitlines()
|
||
if "age_h" in ln and "=" in ln and "append" not in ln]
|
||
R.append(("🔴 **算时长的那一行逐字读的是 `started_ts`**"
|
||
"(⛔ `pid` 是**身份**不是**时间**;⚠️ 实测把 `pid` 转 float 也能"
|
||
"通过『值小于 now』⇒ ⛔ 只验值不验字段 = 白验)",
|
||
bool(age_lines) and all('hb.get("started_ts")' in ln for ln in age_lines)))
|
||
R.append(("⛔ 算时长的代码**没有**拿 `mtime`/`st_mtime` 当起点"
|
||
"(⛔ mtime 会被复制/同步改掉)",
|
||
not _re.search(r"age_h\s*=.*mtime", seg)))
|
||
return R
|
||
|
||
|
||
@case("副本同步:**残留/待补/待更新三类漂移都抓得到**,且白名单不被动(P0-51 的正解)")
|
||
def t_mirror_drift():
|
||
"""🔴 为什么要有这条:`vibe-product` 用的是**工作区副本**(整目录拷贝出去的),
|
||
全局删掉的文件副本**不知道**,会静默留着 ⇒ 技能改动在副本上"看不到",
|
||
而副本才是那个区**真在跑的**代码(实测:`mut_run.py` 差点没补上,
|
||
`00-动手前必过.md`/`99-速查清单.md` 一度只存在于全局)。
|
||
|
||
⚠️ 本条**自建夹具**(⛔ 不拿真副本跑判据):
|
||
真副本的漂移是**外部状态**(取决于上次同步到哪)⇒ 拿它当判据,
|
||
「副本刚同步完」就必红。**自建**才能对每类漂移做变异验证。
|
||
"""
|
||
import importlib.util
|
||
mp = HERE / "workspace_mirror.py"
|
||
if not mp.exists():
|
||
return [("⛔ 包内缺 `scripts/workspace_mirror.py` ⇒ 副本漂移没人管", False)]
|
||
spec = importlib.util.spec_from_file_location("wm_selftest", mp)
|
||
wm = importlib.util.module_from_spec(spec)
|
||
spec.loader.exec_module(wm)
|
||
|
||
# 🔴 夹具要**贴真实结构**:`<夹具>/.workbuddy/skills/<包>/`。
|
||
# 理由(实测踩过):备份落点靠「往上找 `.workbuddy`」判定 ⇒ 结构不像 ⇒
|
||
# 备份会写进**真工作区的 交付物/**(自测污染真工作区,且判据依赖外部状态)。
|
||
fx_root = TEST_WS / "mirror_fixture"
|
||
fx_src = fx_root / "src"
|
||
fx_cp = fx_root / "ws" / ".workbuddy" / "skills" / "session-mechanism"
|
||
shutil.rmtree(str(fx_root), ignore_errors=True)
|
||
(fx_src / "sub").mkdir(parents=True, exist_ok=True)
|
||
(fx_cp / "sub").mkdir(parents=True, exist_ok=True)
|
||
# 夹具:全局侧 3 文件(a/b/c),副本侧 b 篡改 + 残留 r + 缺 c + 白名单 roots.env
|
||
(fx_src / "SKILL.md").write_text("# fixture", encoding="utf-8")
|
||
(fx_src / "a.py").write_text("A=1\n", encoding="utf-8")
|
||
(fx_src / "sub" / "b.py").write_text("B=1\n", encoding="utf-8")
|
||
(fx_src / "c.py").write_text("C=1\n", encoding="utf-8")
|
||
(fx_cp / "SKILL.md").write_text("# fixture", encoding="utf-8")
|
||
(fx_cp / "a.py").write_text("A=1\n", encoding="utf-8")
|
||
(fx_cp / "sub" / "b.py").write_text("B=1\n# TAMPER\n", encoding="utf-8") # 待更新
|
||
(fx_cp / "r_stale.py").write_text("STALE\n", encoding="utf-8") # 真残留
|
||
(fx_cp / "roots.env").write_text("DSH_WS_ROOT=/fixture/ONLY\n", encoding="utf-8") # 白名单
|
||
|
||
g, c, only_c, only_g, changed = wm.diff(fx_src, fx_cp)
|
||
det = [
|
||
("抓到**真残留**(全局已删、副本还留着)",
|
||
"r_stale.py" in only_c and len(only_c) == 1),
|
||
("抓到**待补**(全局有、副本没有)",
|
||
"c.py" in only_g and len(only_g) == 1),
|
||
("抓到**待更新**(两边都有但内容不同)",
|
||
"sub/b.py" in changed and "a.py" not in changed),
|
||
("**没把白名单当漂移**(roots.env 不该出现在任一差异里)",
|
||
not any("roots.env" in x for x in (only_c + only_g + changed))),
|
||
]
|
||
|
||
# --sync:真跑一遍,验「收口 + 白名单内容没被动 + 幂等」
|
||
rc1 = wm.do_sync(fx_src, fx_cp)
|
||
g2, c2, only_c2, only_g2, changed2 = wm.diff(fx_src, fx_cp)
|
||
det += [
|
||
("`--sync` 收口(rc=0)", rc1 == 0),
|
||
("同步后**无漂移**", not (only_c2 or only_g2 or changed2)),
|
||
("同步后**残留已真删**(⛔ 不能只是「不报」)",
|
||
not (fx_cp / "r_stale.py").exists()),
|
||
("同步后**待补已真写入**", (fx_cp / "c.py").exists()
|
||
and (fx_cp / "c.py").read_bytes() == (fx_src / "c.py").read_bytes()),
|
||
("同步后**篡改已被覆盖回全局版**",
|
||
(fx_cp / "sub" / "b.py").read_bytes() == (fx_src / "sub" / "b.py").read_bytes()),
|
||
("🔴 **白名单内容原样保住**(roots.env 仍写 /fixture/ONLY,"
|
||
"⛔ 被冲回全局值就会让副本去读写别人的仓库)",
|
||
(fx_cp / "roots.env").exists()
|
||
and "/fixture/ONLY" in (fx_cp / "roots.env").read_text(encoding="utf-8")),
|
||
("**幂等**:再 sync 一次 rc=0", wm.do_sync(fx_src, fx_cp) == 0),
|
||
]
|
||
|
||
# 🔴 锚在**结构**上:真正拦住"覆盖白名单"的是覆盖循环里那句 `if k in KEEP: continue`。
|
||
# ⚠️ 早先只验"写回"验不到 —— 因为 `_files()` 已把 KEEP 排除,写回那步**恒为空转**,
|
||
# 把它删掉判据照样全绿(2026-10-04 变异实测)。⇒ 判据必须锚在真作用点上。
|
||
wsrc = mp.read_text(encoding="utf-8")
|
||
det += [
|
||
("覆盖循环里有**白名单守卫** `if k in KEEP: continue`"
|
||
"(⛔ 拿掉它 ⇒ roots.env 被全局值覆盖 ⇒ 副本去读写别人的仓库)",
|
||
re.search(r"for k in sorted\(g\):\s*\n\s*if k in KEEP:\s*\n\s*continue", wsrc) is not None),
|
||
("`--sync` 先**备份再删**(⛔ 破坏性动作前必须留退路)",
|
||
wsrc.index("copytree") < wsrc.index("unlink()")),
|
||
("破坏前有**预检**(源文件读不出就拒绝动手,⛔ 不许崩在半路留半同步态)",
|
||
"拒绝动手" in wsrc),
|
||
("覆盖用**临时文件+os.replace**(⛔ 半截文件不能留在副本里)",
|
||
"os.replace" in wsrc and ".mirtmp" in wsrc),
|
||
]
|
||
|
||
fx_root2 = TEST_WS / "mirror_fixture2"
|
||
fx_src2 = fx_root2 / "src"
|
||
fx_cp2 = fx_root2 / "ws" / ".workbuddy" / "skills" / "session-mechanism"
|
||
shutil.rmtree(str(fx_root2), ignore_errors=True)
|
||
shutil.copytree(str(fx_src), str(fx_src2))
|
||
fx_cp2.parent.mkdir(parents=True, exist_ok=True)
|
||
shutil.copytree(str(fx_src), str(fx_cp2))
|
||
# ⚠️ `roots.env` 是**副本独有**(src 侧没有)⇒ copytree 后天然不存在,
|
||
# ⛔ 别再 unlink(早先多这一步 ⇒ FileNotFoundError,用例崩在夹具准备上)
|
||
# ② **从来没有过** ⛔ 不该报"丢失"(否则每次 sync 都刷假告警,久了没人看)
|
||
# ⚠️ 判据侧踩过两次坑,都记这:
|
||
# · ⛔ 不能假设"删了就一定救不回" —— 夹具有备份时工具**正确地**捞回、rc=0,
|
||
# 早先断言 rc≠0 ⇒ 把正确行为判成错(假红)。
|
||
# · ⛔ 也不能假设"没备份就该报丢" —— ⛔ **分不出来**("从来没有过" vs "丢了"
|
||
# 在副本现状里完全同形)⇒ 工具选择**不报**。这是有意的保守,不是缺陷。
|
||
import contextlib
|
||
import io as _io
|
||
_buf = _io.StringIO()
|
||
with contextlib.redirect_stdout(_buf):
|
||
rc_none = wm.do_sync(fx_src2, fx_cp2)
|
||
_out2 = _buf.getvalue()
|
||
det += [
|
||
("⛔ **从来没有过的白名单不报「丢失」**(`diff` 排除 KEEP + 无历史备份"
|
||
"⇒ 分不出「丢」和「没过」⇒ 有意不报,⛔ 不刷假告警)",
|
||
rc_none == 0 and "白名单缺失" not in _out2),
|
||
("⛔ **代码里明写了这条保守取向**(`副本历史里有过` 才算丢)",
|
||
"副本历史里有过" in wsrc),
|
||
("⛔ **捞不到时明说不代填**(⛔ 绝不拿全局那份顶替 —— 全局指向别的仓库)",
|
||
# 🔴 锚在**结构**上(早先锚措辞/锚 `else:` ⇒ 措辞微调、结构一变就假红,今天各踩一次):
|
||
# 三句必须在**同一个 `if keep_lost:` 块**里 ⇒ 兜底与告警走的是同一条路径。
|
||
re.search(r"if keep_lost:[\s\S]{0,700}?绝不拿全局那份顶替[\s\S]{0,200}?请人工恢复"
|
||
r"[\s\S]{0,80}?不代填", wsrc) is not None),
|
||
("⛔ **兜底源只认「历次备份」**(⛔ 不是本次刚建的、⛔ 更不是全局)",
|
||
"历次" in wsrc and "BK_PREFIX" in wsrc
|
||
and re.search(r"sorted\([\s\S]{0,300}?reverse=True\)", wsrc) is not None),
|
||
("⛔ **兜底源只认「历史备份」**(⛔ 不是副本现状、⛔ 更不是全局)",
|
||
re.search(r"兜底源=\*\*历史备份", wsrc) is not None),
|
||
]
|
||
|
||
# ③ 有备份时**能**捞回:造一次"先 sync(留备份)→ 再删 → 再 sync"
|
||
(fx_cp2 / "roots.env").write_text("DSH_WS_ROOT=/fixture/BACKUPED\n", encoding="utf-8")
|
||
(fx_cp2 / "sub" / "b.py").write_text("B=1\n# DIFF\n", encoding="utf-8") # 制造漂移 ⇒ 触发备份
|
||
wm.do_sync(fx_src2, fx_cp2) # ← 这次备份里有 roots.env
|
||
(fx_cp2 / "roots.env").unlink()
|
||
rc_bk = wm.do_sync(fx_src2, fx_cp2)
|
||
det += [
|
||
("🔴 **有备份时,白名单被删后 `--sync` 能捞回**"
|
||
"(`roots.env` 丢失 ⇒ 副本按位置推导去读写**别的仓库**,静默且难查)",
|
||
rc_bk == 0 and (fx_cp2 / "roots.env").exists()
|
||
and "/fixture/BACKUPED" in (fx_cp2 / "roots.env").read_text(encoding="utf-8")),
|
||
]
|
||
shutil.rmtree(str(fx_src2.parent), ignore_errors=True)
|
||
shutil.rmtree(str(fx_src.parent), ignore_errors=True)
|
||
return det
|
||
|
||
|
||
@case("副本同步:`roots.env` 白名单**只按文件名**判(⛔ 换副本不许靠「我记得跳过哪个」)")
|
||
def t_mirror_keep_by_name():
|
||
import importlib.util
|
||
spec = importlib.util.spec_from_file_location("wm_selftest2", HERE / "workspace_mirror.py")
|
||
wm = importlib.util.module_from_spec(spec)
|
||
spec.loader.exec_module(wm)
|
||
return [
|
||
("白名单含 `roots.env`(⛔ 覆盖它 ⇒ 副本读写到别的仓库)", "roots.env" in wm.KEEP),
|
||
("白名单含 `副本使用说明.md`(副本独有的口径文档)", "副本使用说明.md" in wm.KEEP),
|
||
("白名单含 `install.log`(副本自己的安装历史)", "install.log" in wm.KEEP),
|
||
("白名单是**元组**(⛔ 散着写容易漏)", isinstance(wm.KEEP, tuple)),
|
||
("**`__pycache__` 等自身目录不算漂移**(否则每次跑都报一堆假漂移)",
|
||
"__pycache__" in wm.SELF_DIRS),
|
||
]
|
||
|
||
|
||
@case("🔴 常驻载体形态:**旧看守已彻底清除 + 不许回潮**(2026-10-05 收敛)")
|
||
def t_keeper_legacy_ps1_form_gone():
|
||
"""🔴🔴 2026-10-05 **形态收敛的守卫**(取代此前 `t_keeper_tpl_found_in_copy_layout`)。
|
||
|
||
**为什么换掉旧的**:旧用例判「副本形态下找不找得到 `assets/start-supervise.ps1.tpl`」,
|
||
而该模板与「旧 看守」这套形态**已整套废弃** ——
|
||
任务动作从「壳脚本 -File」改成 **`pythonw.exe` + `supervise-launch.py`**
|
||
(GUI 子系统 ⇒ 零 conhost ⇒ **不闪黑窗**,用户 2026-10-05 报「又弹了窗口」的直接修复)。
|
||
⇒ 旧用例连**被测对象都不存在了** ⇒ 恒红、且无法修复 ⇒ **必须删,换成本条**。
|
||
|
||
🔴 本条的职责是**守形态**(防回潮),五条一起判:
|
||
① 代码里**不许再出现**读那个 `.tpl` 的路径拼接;
|
||
② 任务动作**必须是** `pythonw.exe` + 启动器;
|
||
③ ⛔ 动作里不许再把壳脚本当看守(⚠️ 登记任务本身仍走 PowerShell,
|
||
那是在**登记**、不是在**当看守**,两者别混);
|
||
④ `CREATE_NO_WINDOW` 在位;
|
||
⑤ `_find_keeper_tpl` 死代码已清。
|
||
"""
|
||
# 🔴🔴 2026-10-06 **扩到三个文件**(本轮实测抓到的探照灯打偏):
|
||
# 原版只扫 `collabd.py` ⇒ 而**同族的 `init_workspace.py` 整份没被扫到**,
|
||
# 它揣着 `start-supervise.ps1.tpl` 路径拼接 + `-File start-supervise.ps1` 动作
|
||
# 活到今天(`_register_keeper_task` 又写了一份建任务代码 = 第二套起法)。
|
||
# ⚠️ 教训:**判据只扫"我以为哪个文件"就等于没扫** —— 凡是"这一族文件都不许有 X",
|
||
# 必须**枚举整族**,⛔ 不许只挑一个代表。
|
||
_FAMILY = ["collabd.py", "init_workspace.py", "collabctl.py"]
|
||
res = []
|
||
_codes = {}
|
||
for _n in _FAMILY:
|
||
_p = HERE / _n
|
||
if not _p.is_file():
|
||
res.append((f"⛔ `{_n}` 存在(同族文件缺了 ⇒ 判据无从施加)", False))
|
||
continue
|
||
_s = _p.read_text(encoding="utf-8")
|
||
_codes[_n] = (_s, _strip_comments_and_docs(_s))
|
||
|
||
# ① 整族:⛔ 谁也不许再去找那份废模板
|
||
_bad = [n for n, (_, c) in _codes.items() if "start-supervise.ps1.tpl" in c]
|
||
res.append(("⛔ 全族已无「读 `start-supervise.ps1.tpl`」的路径拼接(形态废弃 ⇒ 不该有人再找它)",
|
||
not _bad))
|
||
# ② 整族:⛔ 谁也不许把壳脚本当看守
|
||
_bad = [n for n, (_, c) in _codes.items() if "-File start-supervise.ps1" in c]
|
||
res.append(("⛔ 全族动作里**不再有** `-File start-supervise.ps1`(⛔ 壳脚本当看守=闪黑窗)",
|
||
not _bad))
|
||
# ③ 建任务者:动作必须是 pythonw + 启动器
|
||
_mk = {n: c for n, (_, c) in _codes.items() if "New-ScheduledTaskAction" in c}
|
||
_ok = bool(_mk)
|
||
_ok = _ok and all("supervise-launch.py" in c for c in _mk.values())
|
||
_ok = _ok and all("pythonw.exe" in _codes[n][0] for n in _mk)
|
||
res.append(("🔴 建任务者(%s)动作=`pythonw.exe` + 启动器 `supervise-launch.py`"
|
||
% "/".join(sorted(_mk)), bool(_ok)))
|
||
# ④ 建任务者:`CREATE_NO_WINDOW` 在位
|
||
_bad = [n for n, c in _mk.items() if "0x08000000" not in c]
|
||
res.append(("🔴 建任务者 `CREATE_NO_WINDOW`(0x08000000)在位(⛔ 缺了每次登记都闪一下)",
|
||
not _bad))
|
||
# ⑤ ⭐⭐ **任务名口径必须唯一**(本轮抓到的病:`collabd.py` 建 `collabd-supervise-*`,
|
||
# 而 `collabctl.py` 建 `collabd-keepalive-*` ⇒ 前者在 `SCHED_TASKS` 禁用名单里
|
||
# ⇒ **自愈起的常驻会被下一次 `off` 当旧形态干掉**)
|
||
_names = set()
|
||
for n, c in _mk.items():
|
||
# ⚠️ **先剥掉「禁用名单」块再扫** —— `SCHED_TASKS` 里是**故意**列着旧名去**禁用它**的,
|
||
# 不剥就会把"它自己的否定句"当成违规。
|
||
# 🔴 这是本项目**第三次**踩同一个坑(前两次:查"PRD 有没有视觉词"命中免责声明、
|
||
# 查"有没有六份旧产出"命中 PRD 里的禁令文本)⇒ 判据凡扫文本,先问"这句是不是它的否定句"。
|
||
c2 = re.sub(r"SCHED_TASKS\s*=\s*\[[^\]]*\]", "", c)
|
||
_names |= set(re.findall(r'"collabd-(?:supervise|keepalive)-', c2))
|
||
res.append(("⭐⭐ 任务名口径唯一(建任务者只许用 `collabd-keepalive-`;"
|
||
"⛔ 出现 `collabd-supervise-` 会被 `off` 当旧形态禁掉)|实测命中:%s"
|
||
% (sorted(_names) or "无"),
|
||
_names == {"\"collabd-keepalive-"}))
|
||
# ⑥ init_workspace 侧:死代码已清
|
||
_iw = _codes.get("init_workspace.py", ("", ""))[1]
|
||
_dead = [d for d in ("def _register_keeper_task(", "def _keeper_task_state(",
|
||
"def _ps_check(", "def _find_keeper_tpl(") if d in _iw]
|
||
res.append(("`init_workspace.py` 旧形态死代码已清(⛔ 留着会再招一批过期判据)|实测残留:%s"
|
||
% (_dead or "无"), not _dead))
|
||
return res
|
||
|
||
|
||
@case("🔴 看板渲染:**真跑 `renderProject()`**(⛔ 不是查字符串 —— 实发过 `acc is not defined`)")
|
||
def t_board_render_runs_clean():
|
||
"""🔴🔴 2026-10-05 用户报障逐字:**「快照读取失败:acc is not defined」**。
|
||
|
||
**病症**:页面白屏 + 一句「快照读取失败…服务还开着吗?重跑 board.py」
|
||
—— 那句文案把**渲染期 ReferenceError** 说成**服务挂了** ⇒ 用户去重跑服务(白费力)。
|
||
**真因**:`renderProject(d)` 里引用了**从未声明的标识符**:
|
||
· `acc` —— 验收判据其实在 `d.goal.acceptance`(实测快照顶层**没有** `acc`);
|
||
· `p.*` —— 参数早已改名成 `d`,函数体里 6 处没跟着改(同族,一次改名的漏网)。
|
||
⇒ 都属于「**变量名与数据流向不符**」,⛔ 语法检查查不出(原版语法是过的)。
|
||
|
||
✅ 本判据的做法=**真跑**:用 `assets/board-render-probe.js` 把页面脚本放进
|
||
`vm` 的**全局作用域**执行(⛔ 不能用 `new Function` —— 函数会关在局部,
|
||
取到 `undefined` ⇒ **假绿**),喂一份**合成快照**,真调 `renderProject()`,
|
||
看它抛不抛、`#proj` 真渲染了没。
|
||
|
||
⚠️ **必须行为级**:只 grep `acc is not defined` 这种字样是**恒真**的判据
|
||
(修好之后字符串当然不在)⇒ 测不出"将来又冒出个别名"。
|
||
⚠️ 合成快照**不依赖活服务**(自测要在离线/CI 也能跑)。
|
||
"""
|
||
import json as _json
|
||
import subprocess as _sp
|
||
import tempfile as _tmp_mod
|
||
probe = HERE.parent / "assets" / "board-render-probe.js"
|
||
html = HERE.parent / "assets" / "board.html"
|
||
res = []
|
||
res.append(("探针在位 `assets/board-render-probe.js`", probe.is_file()))
|
||
res.append(("探针路径**自定位**(⛔ 不写死作者机器)", "BOARD_HTML" in probe.read_text(encoding="utf-8")))
|
||
|
||
# 源码级:`acc` 必须**先声明后使用**(⛔ 不能只是"出现过")
|
||
# ⚠️ 注释里也会出现 `var acc=`(本轮就是)⇒ **先剔注释**再判,否则判据假红。
|
||
src = html.read_text(encoding="utf-8")
|
||
i = src.index("function renderProject(d){")
|
||
j = src.index("\nfunction ", i + 10)
|
||
body = src[i:j]
|
||
_code = re.sub(r"/\*[\s\S]*?\*/", "", body) # 剔块注释
|
||
_code = re.sub(r"(?m)//.*$", "", _code) # 剔行注释
|
||
_decl = _code.find("var acc=")
|
||
_use = _code.find("Object.keys(acc)")
|
||
res.append(("`renderProject` 里 `acc` **有声明**(`var acc=` 在用它之前,⛔ 剔注释后判)",
|
||
_decl >= 0 and _use >= 0 and _decl < _use))
|
||
res.append(("`renderProject` 里**没有裸 `p.`**(参数名是 `d`,改名漏网会 ReferenceError)",
|
||
not re.search(r"(?<![\w.$])p\.\w", _code)))
|
||
|
||
# 行为级:真跑(用合成快照,⛔ 不依赖活服务)
|
||
if probe.is_file():
|
||
# 🔴🔴 2026-10-05 **夹具必须逐字照真快照的层级**(这条漏 ⇒ 上一条 bug 才活了下来)。
|
||
# 实测活服务 `/board.json` 顶层键里**没有** `short`/`title`/`criteria`/`id`/`main_sid8`
|
||
# ⇒ 它们全在 `project` 下;`goal` 那份只装 `acceptance`/`short`/`title`。
|
||
# 旧夹具把 `short`/`title`/`criteria` **放在顶层** ⇒ **喂了一份服务器从不发出的形状**
|
||
# ⇒ 代码里裸写 `d.short` 也能"取到值" ⇒ 判据**假绿**,真机上却显示「(未声明主题)」。
|
||
# 📌 教训:**离线夹具造假的地方,就是判据瞎的地方** —— 夹具必须对活服务取一次样再写死。
|
||
# 🔴🔴 2026-10-05 再补一次(同族第 2 次):夹具**又漏了 `ws_name`** ——
|
||
# 而目标行 pill 现在画的正是它 ⇒ 夹具没有该字段时探针报「工作区真值 ⚠️/❌」。
|
||
# ⚠️ 更要紧的是**取值必须与 `goal.short` 不同**:若这里也写「自测」,
|
||
# "查 ws_name" 与 "查 short" 得到同一结论 ⇒ 判据**区分不出新旧口径**(假绿)。
|
||
# ⇒ 取一个**明显不同**的值,⛔ 别图省事抄 `short`。
|
||
_snap = {
|
||
"key": "selftest",
|
||
"ws_name": "selftest-ws",
|
||
"goal": {"short": "自测", "title": "自测目标",
|
||
"acceptance": {"A1-真": "过(自测)", "A2-假": "待达标(自测)",
|
||
"_说明": "说明行 ⛔ 不进分母"}},
|
||
"project": {"short": "自测", "title": "自测目标", "id": "st-1",
|
||
"main_sid8": "st123456", "workspace": "E:/selftest",
|
||
"criteria": ["自测判据"], "topics": ["自测类别"],
|
||
"topics_source": {"kind": "declared", "at": "自测", "by": "自测"},
|
||
"main_by_topic": {}},
|
||
}
|
||
_f = None
|
||
try:
|
||
_f = Path(_tmp_mod.mkdtemp(prefix="rendchk_")) / "snap.json"
|
||
_f.write_text(_json.dumps(_snap, ensure_ascii=False), encoding="utf-8")
|
||
_node = None
|
||
for _c in ("node", "node.exe"):
|
||
try:
|
||
if _sp.run([_c, "--version"], capture_output=True, timeout=10).returncode == 0:
|
||
_node = _c
|
||
break
|
||
except Exception:
|
||
pass
|
||
if _node is None:
|
||
res.append(("⚠️ 本机没有 node ⇒ 行为级这段**跳过**(⛔ 不算通过)", True))
|
||
else:
|
||
env2 = dict(os.environ)
|
||
env2["BOARD_HTML"] = str(html)
|
||
env2["BOARD_SNAP_FILE"] = str(_f) # 离线:读盘上合成快照
|
||
env2["BOARD_WANT_PASS"] = "1 / 2 通过" # 分母须剔 `_` 开头说明行
|
||
out = _sp.run([_node, str(probe)], capture_output=True, text=True,
|
||
encoding="utf-8", errors="replace", timeout=90, env=env2,
|
||
cwd=str(probe.parent))
|
||
so = (out.stdout or "")
|
||
res.append(("🔴 **真跑 `renderProject()` 零异常**(原版在此抛 `acc is not defined`)",
|
||
"render() 抛异常: ✅" in so))
|
||
res.append(("合成快照真渲染出「完成情况」(⛔ 不是空产物)",
|
||
"产物含 完成情况: ✅" in so))
|
||
res.append(("分母剔 `_` 开头说明行 ⇒ 应为 `1 / 2 通过`",
|
||
"1 / 2 通过" in so))
|
||
# 🔴🔴 2026-10-05 加:**静默错文案**那一族(用户报障「目标(未声明主题)(未声明目标)」)。
|
||
# ⛔ "没炸" ≠ "画对了":裸写 `d.short` 不抛异常,只把兜底文案悄悄画上去。
|
||
res.append(("🔴 **无静默错文案**(⛔ 兜底「(未声明主题)/(未声明目标)」不许出现)",
|
||
"静默错文案 : ✅" in so and "(未声明主题)" not in so))
|
||
# 🔴🔴🔴 2026-10-05 **口径已改**(用户逐字:「目标 vibe-product 提取 …
|
||
# 改为 vibe-product 目标:提取 …」)⇒ 目标行**不再**渲染 `goal.short`,
|
||
# 改为渲染【所属工作区真名 `ws_name`】+【目标全称 `goal.title`】。
|
||
# ⚠️ 旧断言查 `"主题真值 : ✅"` —— 那条探针输出**已被改名**(`工作区真值`)
|
||
# ⇒ 若这里不跟改,就是"同一判据两处实现"的**又一次**(本包红线):
|
||
# 探针改了口径、自测还盯着旧文案 ⇒ 自测**永远红**(与代码对错无关)。
|
||
# ✅ 新口径=**双查 + 形态**:pill 画 `ws_name`、标题画 `title`、且顺序对。
|
||
res.append(("🔴 **工作区真值渲染出来了**(目标行 pill 必须画出 `ws_name`,⛔ 不是主题简称)",
|
||
"工作区真值 : ✅" in so))
|
||
res.append(("🔴 **目标行形态正确**(必须是「目标 → 工作区 → 标题」这个顺序)",
|
||
"目标行形态 : ✅" in so))
|
||
res.append(("🔴 **旧口径已清**(⛔ 不许再有「主题真值」这条断言盯着 `goal.short`)",
|
||
"主题真值" not in so))
|
||
res.append(("🔴 **目标 id 渲染出来了**(`criteria`/`id` 取错层级时这项会红)",
|
||
"目标 id : ✅" in so))
|
||
finally:
|
||
if _f is not None:
|
||
shutil.rmtree(str(_f.parent), ignore_errors=True)
|
||
return res
|
||
|
||
|
||
@case("🔴🔴 peer(跨工作区)格**也要认主会话**(⛔ 不许只看本看板的 `WS`)")
|
||
def t_peer_main_resolved():
|
||
"""🔴🔴🔴 2026-10-05 用户报障逐字:「**peer 工作区的主会话还是找不到对应的吗**」。
|
||
|
||
**病症**:本区 tab 一切正常;切到 **peer 格**(别的目标工作区)⇒ 每个任务类别 chip
|
||
都画成「⚠ 无主会话」,而**那条主会话明明活着**(实测:对方主会话在宿主库里 `status` 正常、
|
||
`cwd` 就是对方工作区根)。
|
||
|
||
**真因**:`_scan_ws_mains()` / `_resolve_main()` **只比对本看板的模块级 `WS`**
|
||
(`_same_ws(cwd)` 内部恒取 `WS`),而 peer 格的会话 `cwd` 指向**对方**目录
|
||
⇒ `_same_ws()` 恒 `False` ⇒ `cand/named/by_topic` **全空** ⇒ 前端「无主会话」。
|
||
⚠️ **同一条数据流上,"会话列表"早就为 peer 修过**(`_sessions(bypass=True)` +
|
||
`sc["workspace"] = ws_root`),**但主会话解析没跟上** ——
|
||
典型的"改一处漏一处",症状=列表对了、主会话仍空。
|
||
|
||
✅ **本用例的判据(能改前报红)**:
|
||
① `_scan_ws_mains(ws_root=<对方>)` 必须**扫得出** cwd 在对方的那个会话;
|
||
② `_resolve_main(..., ws_root=<对方>)` 的 `by_topic` 必须**指向它**;
|
||
③ ⛔ 反向:**不传 `ws_root`** 时必须**扫不出**(否则等于"把 peer 认成自己的" ⇒ 投错窗口)。
|
||
⚠️ 用**合成会话**(临时建一条真会话行到测试库)而不是赌机器上有没有 peer —— 否则换机必红。
|
||
"""
|
||
import sqlite3 as _sq
|
||
res = []
|
||
# ⚠️ 本用例要调的是 **`board.py`** 的函数(`_scan_ws_mains`/`_resolve_main`/`project_scope`),
|
||
# ⛔ **不是** `imp()` 装出来的 collabd 模块 —— 那两个模块都有同名函数,
|
||
# 拿错模块会 `AttributeError`(2026-10-05 本用例第一版就是这么红的,同族第 2 次)。
|
||
import board as m
|
||
# ── 合成一条"对方工作区的主会话"(直接写测试库;⛔ 绝不碰生产库)
|
||
peer_root = str(TEST_WS / "peerws")
|
||
sid = "9e1a" + "0" * 32
|
||
db = m._host_db()
|
||
if not db:
|
||
return [("(无法定位宿主库 ⇒ 本机跳过,⛔ 不算失败)", True)]
|
||
con = _sq.connect(str(db), timeout=2.0)
|
||
try:
|
||
con.execute("pragma busy_timeout=800")
|
||
con.execute(
|
||
"insert or replace into sessions(id,cwd,user_id,title,custom_title,status,"
|
||
"created_at,updated_at,last_activity_at,deleted_at) "
|
||
"values(?,?,?,?,?,?,?,?,?,null)",
|
||
(sid, peer_root.replace("\\", "/"), "selftest", "peer 主会话", "", "completed",
|
||
0, int(time.time() * 1000), int(time.time() * 1000)))
|
||
con.commit()
|
||
topics = ["界面交互"]
|
||
# ① 传 ws_root ⇒ 必须扫得出
|
||
sc_peer = m._scan_ws_mains({}, topics, peer_root)
|
||
res.append(("① 传 `ws_root` ⇒ 扫出对方主会话候选",
|
||
sid in sc_peer.get("cand", [])))
|
||
# ② 传 ws_root ⇒ by_topic 指向它
|
||
rm_peer = m._resolve_main({}, topics, peer_root)
|
||
res.append(("② 传 `ws_root` ⇒ `by_topic` 指向它",
|
||
str((rm_peer.get("by_topic") or {}).get("界面交互", {}).get("sid") or "") == sid))
|
||
# ③ 不传 ⇒ 必须扫不出(fail-closed:⛔ 不许把 peer 认成本区的)
|
||
sc_own = m._scan_ws_mains({}, topics)
|
||
res.append(("③ ⛔ 不传 `ws_root` ⇒ **扫不出**(不许把 peer 认成自己)",
|
||
sid not in sc_own.get("cand", [])))
|
||
# ④ `project_scope` 的 `workspace` 键**必须仍回本区**(那是另一条判据,⛔ 不许被顺手改掉)
|
||
# ⚠️ 比对基准取 **`m.WS`(被加载的那个 board 模块自己的 WS)**,⛔ 不是本测试脚本的 `WS`
|
||
# —— 两者来源不同(模块来自 `goal.json`/部署配置、脚本来自环境变量)⇒ 拿脚本的比会假红。
|
||
ps = m.project_scope({"title": "t", "short": "s", "topics": topics, "id": "g"}, peer_root)
|
||
_norm = lambda _v: str(_v or "").replace("\\", "/").rstrip("/").lower()
|
||
res.append(("④ `project_scope(ws_root=…)` 的 `workspace` 仍=本区 `WS`",
|
||
_norm(ps.get("workspace")) == _norm(m.WS)))
|
||
# ⑤ 🔴🔴 **"测装配"而非"测零件"**(P0-55 的同族):
|
||
# 上面 ①②④ 都是我**自己把 `ws_root` 递进去** ⇒ 只证明"函数接受这个参数",
|
||
# ⛔ **证明不了 `_goal_block()` 真把它传下去了** —— 实测变异 M3(删掉调用点的传参)
|
||
# **本条全绿**,而线上 peer 格照样「无主会话」。
|
||
# ⇒ 判据必须**静态咬住那条调用点**:`project_scope(...)` 在 peer 分支里必须带 `ws_root`。
|
||
_bs = (HERE / "board.py").read_text(encoding="utf-8")
|
||
res.append(("⑤ `_goal_block()` 的调用点**真把 `ws_root` 递给 `project_scope`**"
|
||
"(⛔ 只测函数体等于没测装配)",
|
||
"project_scope(g, ws_root)" in _bs))
|
||
finally:
|
||
try:
|
||
con.execute("delete from sessions where id=?", (sid,))
|
||
con.commit()
|
||
except Exception:
|
||
pass
|
||
con.close()
|
||
return res
|
||
|
||
|
||
@case("🔴 建目标时**顺手登记主会话**(用户原话:⛔ 不用每次会话都去声明)")
|
||
def t_main_registered_on_goal_create():
|
||
"""🔴🔴 2026-10-05 用户逐字:
|
||
|
||
> 「**就是你没有指定 声明目标的时候 指定是哪个会话(sid)**」
|
||
> 「**最好是记录会话,这样就不用每次会话都去做声明了**」
|
||
|
||
**病根**:`role_of()` 三条优先级里第三条 —— **未声明就落成哑档**(当 worker 使)。
|
||
实测踩到:本工作区的主会话 `a80f300d` 从没声明过 ⇒ 看板/投递全按 worker 走,
|
||
用户手动补 `--declare` 才发现(而且**顶掉了原主会话 `3f43ce71`** —— 那正是
|
||
"没指定在哪声明"的代价)。
|
||
✅ 正解=**在"创建目标"那一步顺手记**(`_ensure_goal_dir()` 那一刻 `CODEBUDDY_SESSION_ID`
|
||
必然已知),⛔ 不新增"要记得声明"的待办。
|
||
|
||
⚠️ 判据必须**行为级 + 变异有效**:
|
||
· 只断言"有 `register_main_session` 这个函数"是**恒真**的 ⇒ 测不出"忘了调";
|
||
· 必须真跑 `_ensure_goal_dir()`:**无主会话⇒认本会话**、**已有主会话⇒⛔ 不抢位**。
|
||
· 🔴 **不抢位那条是变异关键**:把"只在无主会话时认"这层判断撤掉 ⇒ 该条必红。
|
||
"""
|
||
import json as _json
|
||
m = imp()
|
||
res = []
|
||
src = (HERE / "collabd.py").read_text(encoding="utf-8")
|
||
res.append(("有唯一实现 `register_main_session()`(⛔ 不再两处各写一套)",
|
||
"def register_main_session(" in src))
|
||
res.append(("`--declare` **复用**它(⛔ 不就地再写一遍 roles)",
|
||
src.count("register_main_session(") >= 3))
|
||
|
||
_st_file = Path(m.STATE)
|
||
_bak = _st_file.read_bytes() if _st_file.exists() else None
|
||
_old_sid = os.environ.get("CODEBUDDY_SESSION_ID")
|
||
try:
|
||
# ── ① 本区**无**主会话 ⇒ 建目标时认下本会话
|
||
m.STATE.write_text(_json.dumps({"roles": {}, "topics": {}}, ensure_ascii=False),
|
||
encoding="utf-8")
|
||
os.environ["CODEBUDDY_SESSION_ID"] = "aaaa1111-0000-0000-0000-000000000001"
|
||
r1 = m._ensure_goal_dir()
|
||
_roles1 = _json.loads(_st_file.read_text(encoding="utf-8")).get("roles") or {}
|
||
res.append(("① 本区无主会话 ⇒ **建目标时登记本会话为 main**(⛔ 不用手动声明)",
|
||
_roles1.get("aaaa1111-0000-0000-0000-000000000001") == "main"))
|
||
res.append(("① 结果**说出口**(`main_reg` 有值,⛔ 不静默)",
|
||
bool(str(r1.get("main_reg") or "").strip())))
|
||
|
||
# ── ② 本区**已有**主会话 ⇒ 🔴 ⛔ 不抢位(补哑档 ≠ 抢位子)
|
||
m.STATE.write_text(_json.dumps(
|
||
{"roles": {"bbbb2222-0000-0000-0000-000000000002": "main"}, "topics": {}},
|
||
ensure_ascii=False), encoding="utf-8")
|
||
os.environ["CODEBUDDY_SESSION_ID"] = "cccc3333-0000-0000-0000-000000000003"
|
||
m._ensure_goal_dir()
|
||
_roles2 = _json.loads(_st_file.read_text(encoding="utf-8")).get("roles") or {}
|
||
res.append(("🔴 ② 本区**已有**主会话 ⇒ **⛔ 不抢位**(原 main 保持)",
|
||
_roles2.get("bbbb2222-0000-0000-0000-000000000002") == "main"))
|
||
res.append(("🔴 ② 也**不把本会话加进来**(⛔ 不许悄悄多出个 main)",
|
||
"cccc3333-0000-0000-0000-000000000003" not in _roles2))
|
||
|
||
# ── ③ 幂等:已经是 main 再登记 ⇒ roles 不变
|
||
_st3 = {"roles": {"dddd4444-0000-0000-0000-000000000004": "main"}, "topics": {}}
|
||
_r3a = m.register_main_session(_st3, "dddd4444-0000-0000-0000-000000000004", why="自测")
|
||
_r3b = m.register_main_session(_st3, "dddd4444-0000-0000-0000-000000000004", why="自测")
|
||
res.append(("③ 幂等:已是 main 再登记 ⇒ 无变化、`demoted` 为空",
|
||
_r3a.get("demoted") == [] and _r3b.get("demoted") == []
|
||
and _st3["roles"]["dddd4444-0000-0000-0000-000000000004"] == "main"))
|
||
res.append(("③ 登记新 main ⇒ **旧 main 降级 worker**(一个区一条主会话)",
|
||
(lambda _s: (m.register_main_session(
|
||
_s, "eeee5555-0000-0000-0000-000000000005", why="自测"),
|
||
_s["roles"].get("dddd4444-0000-0000-0000-000000000004") == "worker")[1])(
|
||
{"roles": {"dddd4444-0000-0000-0000-000000000004": "main"}})))
|
||
finally:
|
||
if _old_sid is None:
|
||
os.environ.pop("CODEBUDDY_SESSION_ID", None)
|
||
else:
|
||
os.environ["CODEBUDDY_SESSION_ID"] = _old_sid
|
||
if _bak is None:
|
||
try:
|
||
_st_file.unlink()
|
||
except Exception:
|
||
pass
|
||
else:
|
||
_st_file.write_bytes(_bak)
|
||
return res
|
||
|
||
|
||
# 🔴🔴 2026-10-05 **已删除 `t_keeper_script_is_published_copy`**。
|
||
# 它判的是旧 ps1 看守形态的三件事:`_own_path()` 的存在、`__SCRIPT__` 走 `_own_path()`、
|
||
# 「回落必打日志」。而 ps1 看守**整套废弃** ⇒ `__SCRIPT__` 在 `collabd.py` 里只剩注释一处
|
||
# ⇒ 该用例**恒红且无法修复**(被测对象不存在了)。
|
||
#
|
||
# ⚠️ 它真正有价值的那部分(**续命目标 / 身份基准必须走 `_own_path()` = 本区发布物**)
|
||
# 已被 `t_ensure_spawns_published_copy` **完整接管**(6 项,含变异对照),没有覆盖缺口。
|
||
|
||
|
||
@case("🔴🔴 常驻**自我繁殖**必须断:续命只许起**本区发布物**(用户「越创建越多」的真因)")
|
||
def t_ensure_spawns_published_copy():
|
||
"""🔴🔴 2026-10-05 实测事故(**自我繁殖** —— 用户看到「开了7个终端窗口…越创建越多」的直接成因)。
|
||
|
||
**现场**:清理后 `skills/session-mechanism/scripts/collabd.py --supervise` **反复复现**,
|
||
父进程每次都不同且已消失(`11668` / `15924` / `46488`)⇒ 看着像"幽灵自启"。
|
||
**真因(日志铁证)**:`_ensure.log` 里那一行 ——
|
||
`常驻续命:新起 --supervise pid=69464(原因:pid 12672 在、心跳新鲜,但**身份对不上**)`
|
||
① `ensure_supervise()` 起进程时填 `Path(__file__).resolve()` = **"正在跑的那份"**;
|
||
若它本身是**技能目录那份** ⇒ 续命又起技能目录那份 ⇒ **互为镜像、生生不息**;
|
||
② 同时 `_pid_is_collabd()` 的比对基准**也被技能目录那份顶着**
|
||
⇒ **真的本区常驻被判"身份对不上"** ⇒ 每轮 `--ensure` 都认为"没人跑"⇒ 再拉一条。
|
||
|
||
✅ 正解=**发布物优先**(`_own_path()`),三处共用:续命目标、「身份」基准、keeper 的 `--script`。
|
||
🔴 判据必须**变异有效**:把 `str(_own_path())` 换回 `str(Path(__file__).resolve())` ⇒ 本条必红。
|
||
"""
|
||
src = (HERE / "collabd.py").read_text(encoding="utf-8")
|
||
res = []
|
||
res.append(("🔴 续命的 Popen 目标走 `_own_path()`(⛔ 不是 `Path(__file__).resolve()`)",
|
||
'Popen([PYW, "-u", str(_tgt), "--supervise"]' in src))
|
||
res.append(("🔴 `_tgt` 就是 `_own_path()` 的返回值(⛔ 不是就地重算 `__file__`)",
|
||
"_tgt = _own_path()" in src))
|
||
res.append(("`cwd` 跟着目标走(⛔ 别用 `__file__` 的 parent —— 否则 cwd 与程序不在同一处)",
|
||
"cwd=str(_tgt.parent)" in src))
|
||
res.append(("⚠️ 发布物缺失回落要打告警(⛔ 静默 ⇒ 用户以为跑的是副本)",
|
||
"常驻续命回落到当前这份" in src))
|
||
res.append(("🔴 `_pid_is_collabd` 的身份基准也走 `_own_path()`(⛔ 否则真本区常驻被判成「不是我」)",
|
||
"_self = str(_own_path())" in src))
|
||
res.append(("三处共用同一函数(⛔ 不再各写一份 ⇒ 同一事实三套答案)",
|
||
src.count("_own_path()") >= 4))
|
||
return res
|
||
|
||
|
||
@case("🔴🔴 一个工作区只能有一个常驻:**启动前必判 + 身份核验**(pid 会复用)")
|
||
def t_supervise_singleton_identity():
|
||
"""🔴🔴 2026-10-05 用户点破逐字:
|
||
|
||
> 「**一个工作区只能有一个常驻。启动的时候必须先判断有没有**」
|
||
|
||
**两个方向的病**(同一句判据缺失的两面):
|
||
① **漏补**:`supervise_alive()` 原来只看 `心跳里的 pid 活着 ∧ 心跳新鲜`,
|
||
**从不验证那个 pid 到底是不是 collabd** —— 而 **Windows pid 会复用** ⇒
|
||
心跳里的号被别的进程占着 ⇒ 判"活着"、而真常驻早死了 ⇒ **永远没人补**(静默瘫)。
|
||
② **双起**:心跳没写进去的那几秒,多个 `--ensure` 并发**各自读到"无心跳"**
|
||
⇒ 各起一条 ⇒ **两条常驻**(双写 `collabd-state.json` = 脑裂)。
|
||
|
||
✅ 修法:
|
||
· `_pid_is_collabd(pid, argv0)` —— 按 `QueryFullProcessImageNameW` 取真实 exe
|
||
+ `argv0` 对照(心跳里**本来就写了 `argv0`**,可此前**只写不读**,白存);
|
||
· `--supervise` 加**互斥窗口**:原子抢位 `SUP_PID`(`os.replace`)⇒ sleep 1.5 s ⇒ 回验让位。
|
||
|
||
🔴 判据必须**变异有效**:⛔ 只断言"有这个函数"是恒真的 ⇒ 测不出"判据又被拆掉"。
|
||
"""
|
||
m = imp()
|
||
src = (HERE / "collabd.py").read_text(encoding="utf-8")
|
||
res = []
|
||
res.append(("有身份核验 `_pid_is_collabd()`(⛔ 不再只看号还活着)",
|
||
"def _pid_is_collabd(" in src))
|
||
res.append(("`supervise_alive()` 真的**调**它(⛔ 有函数不调=没接线)",
|
||
"if not _pid_is_collabd(" in src))
|
||
res.append(("核验取的是**真实 exe 路径**(`QueryFullProcessImageNameW`,⛔ 不靠进程名猜)",
|
||
"QueryFullProcessImageNameW" in src))
|
||
res.append(("核验**只认 pythonw**(常驻一律 pythonw 起,⛔ python 不算)",
|
||
'"pythonw" not in exe' in src))
|
||
res.append(("有**互斥窗口**(原子抢位 ⇒ 回验 ⇒ 让位)⛔ 防两条同时启动",
|
||
"互斥窗口回验" in src))
|
||
res.append(("抢位用 `os.replace` **原子替换**(⛔ 不用文件锁 —— 本机实测会卡死)",
|
||
"os.replace(str(_tmp_pid), str(SUP_PID))" in src))
|
||
|
||
# ── 行为级:拿**本进程**当样本(它显然是活着的 python,但**不是 pythonw 常驻**)
|
||
# ⇒ `_pid_is_collabd(自己的 pid)` 必须判 **False**(这是"身份核验真的在工作"的铁证;
|
||
# 若实现退化成"只看 pid 活不活"就会恒判 True ⇒ 本条必红)。
|
||
try:
|
||
_me_alive = m._pid_alive(os.getpid())
|
||
_me_collabd = m._pid_is_collabd(os.getpid(), "")
|
||
res.append(("行为级:本进程 pid 活着(前提成立)", bool(_me_alive) is True))
|
||
res.append(("🔴 行为级:**本进程活着但判不是 collabd**(当前用 python 跑,⛔ 不是 pythonw)"
|
||
" ⇒ 证明核验**真在看身份**,⛔ 不是恒 True",
|
||
_me_collabd is False))
|
||
except Exception as _e:
|
||
res.append(("行为级核验跑不起来:%r" % (_e,), False))
|
||
res.append(("行为级:pid 空/非法 ⇒ 一律 False(⛔ 不保守判在导致漏起)",
|
||
m._pid_is_collabd(0, "") is False and m._pid_is_collabd("x", "") is False))
|
||
return res
|
||
|
||
|
||
@case("🔴🔴 换目标(`declare --switch-goal`)⇒ `lifecycle` **自动复位「进行中」**(⛔ 不靠人手补)")
|
||
def t_declare_resets_lifecycle():
|
||
"""🔴🔴 2026-10-05(用户定案逐字):
|
||
|
||
> 「一个工作区 同时只能执行一个目标,如果要切换目标,需要用户确认,
|
||
> **然后切换和关注检查切换后的目标**」
|
||
|
||
🔴 **改前事实**:`declare` **从不碰 `lifecycle`** —— 只在末尾打一句
|
||
「别忘了同步 `lifecycle`(⛔ `declare` 不碰它)」**让人手补**。
|
||
|
||
🔴 **为什么必须自动做**:旧目标做完 ⇒ `lifecycle` 停在「已完成」⇒ 换到新目标后
|
||
**机制照旧不拉检查会话**(`collabd.py` 的判据:`life != 进行中` 时只在
|
||
"队列还有非 done 件"才拉)⇒ **新目标没人检查** = 与用户"切换后要关注检查"**方向相反**。
|
||
⚠️ 且靠人手补 = 又一条"要记得"的待办(同族:用户「最好是记录会话,这样就不用每次
|
||
会话都去做声明了」—— **凡"要人记"的都会忘**)。
|
||
|
||
⚠️ **不是破坏性动作**:旧 `goal.json` **整份归档**(同一步做)⇒ 旧值可回查;
|
||
且只在**已确认换目标**时做,⛔ 纯细化(只改措辞/补 why/kpi)不触发。
|
||
|
||
🔴 **判据必须行为级**(⛔ 不许只查源码里有没有那行):
|
||
真造一份 `goal.json`(`lifecycle=已完成`)⇒ 真跑 `goalctl.py declare --switch-goal --yes`
|
||
⇒ 读回落盘的 `lifecycle` 必须是「进行中」;**对照组**:不带 `--switch-goal` 的
|
||
纯细化(标题不变)⇒ `lifecycle` **必须原样不动**(⛔ 证明复位绑的是"换目标"这个事件,
|
||
不是"跑了一次 declare")。
|
||
⚠️ 隔离一律用**临时 `COLLABD_CONFIG`**(同 `t_acc_selfref_excluded`:
|
||
只设 env 不给 config 会回落读到真工作区,曾覆盖真 `goal.json`)。
|
||
"""
|
||
import json as _j
|
||
import tempfile as _tf
|
||
import subprocess as _sb
|
||
import pathlib as _pl
|
||
res = []
|
||
gctl = HERE / "goalctl.py"
|
||
# 源码级:确认 `_is_switch` 分支里真写了 lifecycle(⛔ 只作辅助,主判在行为级)
|
||
src = gctl.read_text(encoding="utf-8")
|
||
res.append(("`declare` 的换目标分支里**真写** `new[\"lifecycle\"]`(⛔ 不再只打提示)",
|
||
'new["lifecycle"] = "进行中"' in src))
|
||
# 🔴🔴 2026-10-05 **误红修正**:第一版直接查 `"别忘了同步" not in src` ⇒ 误红。
|
||
# 真因:`goalctl.py` 里那句字面**仍合法保留在注释**中 —— 它是「**改前事实**」的
|
||
# 取证记录(写明"改前 `declare` 从不碰 lifecycle"),是有价值的考古证据,
|
||
# ⛔ 不该为了过一条断言而删掉。
|
||
# ✅ 正解=**剔掉注释行再查**:只判「有没有这句提示出现在**可执行语句**里」。
|
||
_code_lines = [ln for ln in src.splitlines()
|
||
if not ln.lstrip().startswith("#")]
|
||
_code = "\n".join(_code_lines)
|
||
res.append(("⛔ **可执行语句**里已无那句「别忘了同步 `lifecycle`」旧提示(注释里的改前取证不算)",
|
||
"别忘了同步" not in _code))
|
||
res.append(("⛔ 也确认旧的「让人手补」`print` 真被换成自动复位提示",
|
||
"自动复位为「进行中」" in src or "自动复位" in _code))
|
||
# 🔴🔴 2026-10-05 **护栏自身要被判**(⛔ 别让护栏变成死代码):
|
||
# 本用例出过两次"隔离失效 ⇒ 静默写真工作区"的事故 ⇒ 夹具必须**自查落点**。
|
||
# ⚠️ 注意查的是**本文件**(`selftest.py`),⛔ 不是 `goalctl.py` —— 第一版查错文件 ⇒ 误红。
|
||
_self_src = Path(__file__).read_text(encoding="utf-8")
|
||
res.append(("🔴 夹具**自查子进程自报落点**(越界即记红,⛔ 不许静默写到别处)",
|
||
"夹具越界" in _self_src and "落点 `" in _self_src))
|
||
res.append(("🔴 夹具用对变量:`DSH_COLLAB_WS`(`_resolve_ws()` 的第一顺位)",
|
||
'"DSH_COLLAB_WS": tmp' in _self_src))
|
||
|
||
def _run(tmp, args, goal):
|
||
# 🔴🔴 2026-10-05 **隔离夹具的真修(本用例的第 2 个坑)**:
|
||
# `goalctl.py` 的 `INBOX` 是**硬编码** `WS/"tmp"/"supervise-inbox"`
|
||
# —— ⛔ **不读配置里的 `inbox` 字段**(实测:`{"inbox":"inbox"}` 被无视)。
|
||
# 第一版夹具把 `goal.json` 写在 `<tmp>/inbox/` ⇒ 脚本读到「(无)」,
|
||
# 写又落到 `<tmp>/tmp/supervise-inbox/` ⇒ **读回当然还是旧值**(看着像"没生效")。
|
||
# ✅ 正解=**落点跟着脚本走**:夹具与脚本用**同一个** `WS/tmp/supervise-inbox`。
|
||
cfg = _pl.Path(tmp) / "collabd.config.json"
|
||
cfg.write_text(_j.dumps({"workspace": tmp.replace("\\", "/"),
|
||
"inbox": "inbox", "taskgraph": "inbox/taskgraph.json",
|
||
"live": "inbox/realtime.md"}, ensure_ascii=False),
|
||
encoding="utf-8")
|
||
inbox = _pl.Path(tmp) / "tmp" / "supervise-inbox"
|
||
inbox.mkdir(parents=True, exist_ok=True)
|
||
(inbox / "goal.json").write_text(_j.dumps(goal, ensure_ascii=False), encoding="utf-8")
|
||
# 🔴🔴 2026-10-05 **隔离夹具的真修**(同一课**第四遍**):
|
||
# `goalctl.py:_resolve_ws()` 的候选顺序是
|
||
# `DSH_COLLAB_WS` → `DSH_WS_ROOT` → cwd(含 `.workbuddy/collab/`)
|
||
# —— **⛔ 完全不看 `COLLABD_CONFIG`,⛔ 也不认 `COLLABD_WORKSPACE`**!
|
||
# 第一版夹具只设了 `COLLABD_CONFIG` + `COLLABD_WORKSPACE` ⇒ `_resolve_ws()` 两个都不认,
|
||
# cwd 又是临时目录(没有 `.workbuddy/collab/`)⇒ **回落到技能包上级目录**,
|
||
# 而若那里恰好叠了真工作区 ⇒ **静默写进真 `goal.json`**(上一轮就是这么把真 title 改掉的)。
|
||
# ✅ 正解=**设 `DSH_COLLAB_WS`**(第一顺位,显式压倒一切)。
|
||
env = {**os.environ, "COLLABD_CONFIG": str(cfg).replace("\\", "/"),
|
||
"DSH_COLLAB_WS": tmp.replace("\\", "/")}
|
||
r = _sb.run([sys.executable, str(gctl)] + args, env=env, cwd=tmp,
|
||
capture_output=True, timeout=90)
|
||
# 🔴🔴🔴 2026-10-05 **兜底断言(防再犯)**:子进程**自报**的落点必须在本 tmp 内。
|
||
# 为什么要"自报"而不是"我猜":`goalctl.py` 的 `INBOX` 是**硬编码**的,夹具
|
||
# 一旦把目录猜错(本用例真犯过两次:`COLLABD_CONFIG` 不认、`inbox` 字段不读),
|
||
# 就会**静默写到别处** —— 甚至写到真工作区(上一轮真出过事故)。
|
||
# ⇒ 从子进程 stdout 里抠出它自己打印的落点,**核对前缀**;不在 tmp 内 ⇒ 直接记红。
|
||
_so = (r.stdout or b"").decode("utf-8", "replace")
|
||
_claimed = ""
|
||
for _ln in _so.splitlines():
|
||
if "落点 `" in _ln and "`" in _ln:
|
||
_claimed = _ln.split("落点 `", 1)[1].split("`", 1)[0]
|
||
_normp = lambda s: str(s or "").replace("\\", "/").strip().lower().rstrip("/")
|
||
_claimed_ok = (not _claimed) or _normp(_claimed).startswith(_normp(tmp))
|
||
try:
|
||
back = _j.loads((inbox / "goal.json").read_text(encoding="utf-8"))
|
||
except Exception as e:
|
||
back = {"__err": repr(e)}
|
||
if not _claimed_ok:
|
||
back = {"__err": "🔴 夹具越界:子进程自报落点 %r 不在 tmp %r 内 ⇒ 已污染别处!"
|
||
% (_claimed, tmp)}
|
||
return (r.returncode, _so,
|
||
(r.stderr or b"").decode("utf-8", "replace"), back)
|
||
|
||
base = {"title": "旧目标甲", "short": "甲", "lifecycle": "已完成",
|
||
"lifecycle_at": "2026-10-01T00:00:00", "acceptance_state": {"a1": "过|x"}}
|
||
# 🔴🔴 2026-10-05 **防再犯硬护栏**(上一轮真出过事故:夹具隔离失效 ⇒ 静默改真 `goal.json`):
|
||
# 跑任何写动作**前后**都量一遍**真工作区 `goal.json` 的 md5**,必须逐字不动。
|
||
# ⛔ 这不是"重复劳动" —— 它把「隔离失效」从**静默事故**变成**当场报红**。
|
||
_real_goal = Path(TEST_WS).parent / "supervise-inbox" / "goal.json"
|
||
_digest = lambda p: (hashlib.sha1(p.read_bytes()).hexdigest() if p.is_file() else "(无)")
|
||
_real_before = _digest(_real_goal)
|
||
# ① 换目标(带 --switch-goal)⇒ 必须复位
|
||
try:
|
||
d1 = _tf.mkdtemp(prefix="gl1-")
|
||
rc1, o1, e1, b1 = _run(d1, ["declare", "--title", "新目标乙", "--switch-goal", "--yes"], dict(base))
|
||
res.append(("① 换目标:rc=0(实测 rc=%s;err=%s)" % (rc1, (e1 or "")[-120:]), rc1 == 0))
|
||
res.append(("① 换目标 ⇒ 标题真换成「新目标乙」(实测 %r)" % b1.get("title"), b1.get("title") == "新目标乙"))
|
||
res.append(("🔴 换目标 ⇒ `lifecycle` 复位成「进行中」(实测 %r,改前会是『已完成』)"
|
||
% b1.get("lifecycle"), b1.get("lifecycle") == "进行中"))
|
||
res.append(("① 复位留痕(`lifecycle_by` 写明自动复位,实测 %r)"
|
||
% str(b1.get("lifecycle_by"))[:40], "自动复位" in str(b1.get("lifecycle_by") or "")))
|
||
res.append(("① 旧值存进 `lifecycle_why` 可回查(实测 %r)"
|
||
% str(b1.get("lifecycle_why"))[:50], "已完成" in str(b1.get("lifecycle_why") or "")))
|
||
res.append(("⛔ `acceptance_state` **不许被顺手清掉**(那是上一条目标的判据=证据)",
|
||
bool(b1.get("acceptance_state"))))
|
||
except Exception as e:
|
||
res.append(("① 换目标用例跑不起来:%r" % (e,), False))
|
||
# ② 对照:**纯细化**(标题不变,只补 why)⇒ lifecycle 必须原样不动
|
||
try:
|
||
d2 = _tf.mkdtemp(prefix="gl2-")
|
||
g2 = dict(base); g2["title"] = "同名目标"
|
||
rc2, o2, e2, b2 = _run(d2, ["declare", "--title", "同名目标", "--why", "补充说明", "--yes"], g2)
|
||
res.append(("② 对照·纯细化:rc=0(实测 rc=%s)" % rc2, rc2 == 0))
|
||
res.append(("🔴 对照·纯细化 ⇒ `lifecycle` **原样不动**(实测 %r;⛔ 证复位绑的是『换目标』不是『跑过 declare』)"
|
||
% b2.get("lifecycle"), b2.get("lifecycle") == "已完成"))
|
||
except Exception as e:
|
||
res.append(("② 对照用例跑不起来:%r" % (e,), False))
|
||
# 🔴🔴 硬护栏收口:真工作区 `goal.json` 必须**逐字未动**
|
||
_real_after = _digest(_real_goal)
|
||
res.append(("🔴🔴 护栏:**真工作区 `goal.json` 逐字未动**(%s ⇒ %s)"
|
||
% (_real_before[:12], _real_after[:12]),
|
||
_real_before == _real_after))
|
||
return res
|
||
|
||
|
||
def main() -> int:
|
||
only = ""
|
||
if "-k" in sys.argv:
|
||
only = sys.argv[sys.argv.index("-k") + 1]
|
||
_prepare()
|
||
_snap_prod()
|
||
cases = [v for v in globals().values() if callable(v) and hasattr(v, "_case")]
|
||
npass = nfail = nrep = 0
|
||
print("=" * 66)
|
||
print("协作机制 · 回归自测(每改一次必跑) 测试工作区:%s" % TEST_WS)
|
||
|
||
print("协作机制 · 回归自测(每改一次必跑) 测试工作区:%s" % TEST_WS)
|
||
print("=" * 66)
|
||
for fn in cases:
|
||
if only and only not in fn._case:
|
||
continue
|
||
try:
|
||
checks = fn()
|
||
except Exception as exc:
|
||
checks = [("用例抛异常:%r" % exc, False)]
|
||
bad = [c for c, ok in checks if not ok]
|
||
_ro = getattr(fn, "_report_only", False)
|
||
if bad:
|
||
nfail += 1
|
||
print(" ✗ %s" % fn._case)
|
||
for c, ok in checks:
|
||
print(" %s %s" % ("✓" if ok else "✗", c))
|
||
if _ro:
|
||
nrep += 1
|
||
nfail -= 1 # 🔴 报告型不计入失败(见 `report()` 的说明)
|
||
print(" ⓘ 报告型 ⇒ ⛔ 不计入 --verify 成败(存量问题,⛔ 与安装无关)")
|
||
else:
|
||
npass += 1
|
||
print(" ✓ %s (%d 项)" % (fn._case, len(checks)))
|
||
print("-" * 66)
|
||
print(" 合计:PASS %d / FAIL %d%s"
|
||
% (npass, nfail, ("(另有 %d 条报告型,⛔ 不计入)" % nrep) if nrep else ""))
|
||
print("=" * 66)
|
||
return 0 if nfail == 0 else 1
|
||
|
||
|
||
@case("⛔ 派活触发句:说了就必须把执行会话拉起来(收工前闸)")
|
||
def t_dispatch_gate():
|
||
"""🔴 2026-10-07 立。
|
||
|
||
用户令逐字:「**重点问题是 会话接收到 调用执行会话完成目标 没有去调用对应技能去执行**」。
|
||
实测(会话 `b232218f` · vibe-product):用户 01:32:00 / 01:33:46 两次说触发句,
|
||
直到 01:36:27 才建出排期 —— 中间那一轮 AI 只在回复里解释口径,**没拉起执行会话**。
|
||
|
||
守三样(都确定性,⛔ 不依赖现网存量):
|
||
① 派活族触发句 ⇒ `skill-load-guard.py` 留待办(含**带空格**的写法 —— 实测用户就是这么写的);
|
||
② 非派活发言 ⇒ ⛔ 不留(⛔ 别把裸词「执行会话」当派活,那会误报);
|
||
③ 收工闸:同会话 + 超 90 s 宽限 + 该区此后**没有**新建排期 ⇒ 拦(`continue:false`)。
|
||
"""
|
||
import json as _j
|
||
import subprocess as _sp
|
||
import tempfile as _tf
|
||
import time as _t
|
||
|
||
gate = HERE / "hooks" / "stop-dialog-guard.py"
|
||
mark = HERE / "hooks" / "skill-load-guard.py"
|
||
sid = "selftest-gate-%d" % os.getpid()
|
||
tmp = _tf.mkdtemp(prefix="dsh-gate-")
|
||
mk = os.path.join(tmp, ".workbuddy", ".dispatch-pending")
|
||
os.makedirs(os.path.dirname(mk), exist_ok=True)
|
||
|
||
def _feed(script, payload):
|
||
return _sp.run([sys.executable, str(script)],
|
||
input=_j.dumps(payload).encode("utf-8"),
|
||
stdout=_sp.PIPE, stderr=_sp.PIPE, timeout=60
|
||
).stdout.decode("utf-8", "replace")
|
||
|
||
def _mark(ts):
|
||
with open(mk, "w", encoding="utf-8", newline="\n") as f:
|
||
f.write("%s\t%.3f\t使用执行会话\n" % (sid, ts))
|
||
|
||
r = []
|
||
for tag, prompt in (("直连写法", "调用执行会话完成目标:按 1-2-3 步骤做"),
|
||
("带空格写法", "应该使用 执行会话的技能去完成工作")):
|
||
if os.path.exists(mk):
|
||
os.remove(mk)
|
||
_feed(mark, {"prompt": prompt, "session_id": sid, "cwd": tmp})
|
||
r.append(("派活句留待办(%s)" % tag, os.path.isfile(mk)))
|
||
if os.path.exists(mk):
|
||
os.remove(mk)
|
||
_feed(mark, {"prompt": "重点是技能规则和机制也要和这几个文档匹配",
|
||
"session_id": sid, "cwd": tmp})
|
||
r.append(("非派活句⛔ 不留待办", not os.path.isfile(mk)))
|
||
|
||
pl = {"session_id": sid, "cwd": tmp, "stop_hook_active": False,
|
||
"transcript_path": os.path.join(tmp, "x.jsonl")}
|
||
_mark(_t.time() - 200)
|
||
r.append(("超 90 s 宽限 + 该区未建排期 ⇒ 拦", '"continue": false' in _feed(gate, pl)))
|
||
_mark(_t.time())
|
||
r.append(("宽限内(刚说完)⇒ 放行", '"continue": false' not in _feed(gate, pl)))
|
||
return r
|
||
|
||
|
||
@case("⛔ 机制自建的检查排期必须挂进工作区(少 workspace_scope ⇒ 会话落到未分组)")
|
||
def t_check_schedule_scope():
|
||
"""🔴 2026-10-07 立。
|
||
|
||
用户报障逐字:「**vibe-product 的检查会话没有创建到工作区下,跑到未分组的会话中去了**」。
|
||
实测根因:`collabd.py::create_check_schedule()` 的 INSERT **少了 `workspace_scope` 列**
|
||
⇒ 落库 NULL ⇒ 触发出来的会话被宿主当游乐场建(`is_playground=1` / `source_mode='craft'`
|
||
/ `use_sandbox_cli=0`)⇒ 不进工作区分组。
|
||
对照读数(同一晚两条排期):会话侧 `automation_update` 建的是 `'workspace'` ✓,
|
||
机制自建的是 `None` ❌;近 3 天 59 条排期里**只有机制那条**是 None。
|
||
"""
|
||
src = (HERE / "collabd.py").read_text(encoding="utf-8", errors="replace")
|
||
i = src.find("INSERT INTO automations")
|
||
seg = src[i:i + 1000] if i > 0 else ""
|
||
return [
|
||
("找得到建排期的那处 INSERT", i > 0),
|
||
("列清单里有 `workspace_scope`(⛔ 少了它,会话就落到未分组)",
|
||
"workspace_scope" in seg),
|
||
("且写死 `workspace`(⛔ 不是 NULL、也不是别的档)",
|
||
('"workspace"' in seg) or ("'workspace'" in seg)),
|
||
]
|
||
|
||
|
||
@case("⛔ 检查程序静默阈值:常量、闸门、试算、文案必须同源(⛔ 别只改一处)")
|
||
def t_check_idle_min():
|
||
"""🔴 2026-10-07 立(用户拍板:检查程序等待时间 **20 → 10 分钟**)。
|
||
|
||
这个数**散落在四处**,改一处漏三处就会"判据说 A、写给检查会话的文案说 B":
|
||
① 常量 `CHECK_IDLE_MIN`;② 闸门 `if idle_min < CHECK_IDLE_MIN`;
|
||
③ `--dry-run` 试算(⛔ 不同源 ⇒ 试算说"不会建"、实际却建了);④ `CHECK_PROMPT_GOAL` 的文案。
|
||
⚠️ 本条**不锁具体数值**(换个值不该报红)—— 只锁"四处同源"。
|
||
"""
|
||
import re as _re
|
||
src = (HERE / "collabd.py").read_text(encoding="utf-8", errors="replace")
|
||
m = _re.search(r"^CHECK_IDLE_MIN = (\d+)", src, _re.M)
|
||
v = int(m.group(1)) if m else -1
|
||
# ⚠️ 锚**定义处**(`= (`),⛔ 不能用 `find("CHECK_PROMPT_GOAL")` ——
|
||
# 它先命中本常量上面那条注释 ⇒ 3000 字窗口够不到真正的文案 ⇒ **假红**(第一版就这么栽的)。
|
||
i = src.find("CHECK_PROMPT_GOAL = (")
|
||
prompt = src[i:i + 3000] if i > 0 else ""
|
||
return [
|
||
("常量 `CHECK_IDLE_MIN` 存在且是整数(现为 %d 分钟)" % v, v > 0),
|
||
("闸门读常量、⛔ 不写死数字", "idle_min < CHECK_IDLE_MIN" in src),
|
||
("`--dry-run` 试算读同一个常量(⛔ 不同源 ⇒ 试算与实际打架)",
|
||
"idle >= CHECK_IDLE_MIN" in src),
|
||
("写给检查会话的文案与常量同值(⛔ 别只改判据、文案还写旧值)",
|
||
("≥%d 分钟" % v) in prompt),
|
||
]
|
||
|
||
|
||
@case("⛔ 加载闸门:派活族不受冷却 + 冷却跳过要留痕(⛔ 别让抑制态隐身)")
|
||
def t_skillguard_cooldown():
|
||
"""🔴 2026-10-07 立(用户问「有什么影响 该如何处理」时落的)。
|
||
|
||
实测(会话 `b232218f`):01:32:00 命中后,**01:33:46 那条最明确的触发句**
|
||
(「每次我说使用执行会话完成目标…」)距上次仅 **105 s < COOLDOWN(300)** ⇒ 被吞掉,
|
||
且旧实现**只在注入成功后才写日志** ⇒ 连一行痕迹都没有(排查时只能靠状态文件时间戳反推)。
|
||
两处处置:① 派活族显式触发句**不受冷却**;② 被冷却跳过**写 `COOL_SKIP`**。
|
||
⚠️ 本条是**源码级**判据(跑一次真钩子会动到真工作区的冷却状态,⛔ 不适合放进回归套)。
|
||
"""
|
||
src = (HERE / "hooks" / "skill-load-guard.py").read_text(encoding="utf-8", errors="replace")
|
||
return [
|
||
("有共用的 `_is_dispatch()`(⛔ 别在两处各写一遍判定)", "def _is_dispatch(" in src),
|
||
("冷却旁路在:`_cooling(...) and not (_is_dispatch(...) or _is_load(...))`",
|
||
"_cooling(workdir, sid) and not (_is_dispatch(prompt) or _is_load(prompt))" in src),
|
||
("被跳过写 `COOL_SKIP`(⛔ 否则「没命中」与「被吞」分不出来)",
|
||
"COOL_SKIP" in src),
|
||
]
|
||
|
||
|
||
@case("⛔ 点名了机制却没加载:注入之外还要有「收工前自检」抓手")
|
||
def t_load_gate():
|
||
"""🔴 2026-10-07 立(用户令逐字:「**问题是 会话没有加载决策方法,之前的优化改动无效**」)。
|
||
|
||
实测(`content_marketing_agent` 的闸门日志):`20:09:45 HIT … hits=决策方法` ⇒ **注入确实发出**,
|
||
可会话**仍未去加载** ⇒ 根因=注入只是"劝",**没有任何东西核对 `Skill` 到底调没调**。
|
||
两处处置:① 点名族命中即写 `<ws>/.workbuddy/.load-pending`;
|
||
② `stop-dialog-guard.py` 收工前核「转录尾部里有没有**真的** Skill 加载过本技能」。
|
||
⚠️ 源码级判据(跑真钩子会动到真工作区的状态,⛔ 不适合进回归套)。
|
||
"""
|
||
a = (HERE / "hooks" / "skill-load-guard.py").read_text(encoding="utf-8", errors="replace")
|
||
b = (HERE / "hooks" / "stop-dialog-guard.py").read_text(encoding="utf-8", errors="replace")
|
||
return [
|
||
("点名族词表从 `TRIGGERS` **减**出来(⛔ 不另抄一份:`_LOAD_WORDS = tuple(...)`)",
|
||
"_LOAD_WORDS = tuple(w for w in TRIGGERS" in a),
|
||
("命中即写待办 `LOAD_PENDING`(⛔ 写在冷却之前)",
|
||
"LOAD_PENDING" in a and "_mark_load(" in a),
|
||
("收工闸在:`_load_gate(payload)` 已接进 main,且在自作用域判定之前",
|
||
"def _load_gate(" in b and "if _load_gate(payload):" in b),
|
||
("判据认**转义引号**那种编码(⛔ 普通引号匹配 = 恒假阴性)",
|
||
'\\\\"skill\\\\":\\s*\\\\"session-mechanism' in b),
|
||
("只读转录**尾部**(⛔ 不整读,有 200 MB 级转录)", "LOAD_TAIL_BYTES" in b),
|
||
("读不到转录 ⇒ 判「不确定」放行(fail-open)", "got is None" in b),
|
||
]
|
||
|
||
|
||
@case("⛔ 检查任务要「确认并写回验收」,⛔ 不是只读一遍就标完成")
|
||
def t_checker_confirms_acceptance():
|
||
"""🔴 2026-10-07 立(用户令逐字:「**不是拦截 是检查任务要确认这种情况**」)。
|
||
|
||
实测病例(`content_marketing_agent`):权威文档写「5 条全过」,而 `goal.json.acceptance_state`
|
||
仍是「全未过」⇒ 检查会话按文档判「做完了」并标了「已完成」⇒ 看板显示自相矛盾。
|
||
根因=检查会话**有判验收的职责、却没有写回验收的动作**(="有读者、没写手")。
|
||
⚠️ 用户明确否掉「加闸拦截」这条路 ⇒ 判据必须落在**检查会话的职责与动作**上。
|
||
"""
|
||
src = (HERE / "collabd.py").read_text(encoding="utf-8", errors="replace")
|
||
i = src.find("CHECK_PROMPT_GOAL = (")
|
||
p = src[i:i + 6000] if i > 0 else ""
|
||
return [
|
||
("检查任务 prompt 要求**先确认并写回验收**、再改状态", "确认并写回验收" in p),
|
||
("给了写回手段(`goalctl.py declare --kpi`),⛔ 不是只叫它「去同步一下」",
|
||
"declare --kpi" in p),
|
||
("写明 `--kpi` **按分号切**(该分隔符本项目栽过反)", "按分号切" in p),
|
||
("要求写完**回读确认**(⛔ 不许「以为写成功了」)", "必须回读一次" in p),
|
||
("写明**规范取值**、⛔ 禁 `未过`/`pass` 这类域外值(机器分不出「还没判」与「判了没过」)",
|
||
"不在取值域里" in p),
|
||
("确认确实没过 ⇒ 继续派活、⛔ 不许标已完成", "不许标已完成" in p),
|
||
]
|
||
|
||
|
||
@case("⛔ 说人话:既要「被看到」(每轮注入)也要有英文硬核版在包内")
|
||
def t_voice_core():
|
||
"""🔴 2026-10-07 立(用户令逐字:「重点就是做到说人话就行了」)。
|
||
|
||
病根(§26):语气那一档原先**只有指针、没有注入** ⇒ 实际没被读到 ⇒
|
||
用户报障「content_marketing_agent 的会话生成的文档并没有说人话,还是我手动要求的」。
|
||
处置:① 照排版那条**每轮注入**的现成路子给语气也做一块;
|
||
② 按用户令把英文硬核版(55 模式/5 语气档/打分)整包纳入本包。
|
||
"""
|
||
h = (HERE / "hooks" / "reply-style-guard.py").read_text(encoding="utf-8", errors="replace")
|
||
d = (HERE.parent / "references" / "作业规矩" / "04-去AI味与说话方式.md").read_text(encoding="utf-8", errors="replace")
|
||
en = HERE.parent / "references" / "humanizer-en" / "SKILL.md"
|
||
return [
|
||
("文件里有 `VOICE-CORE` 标记对(注入块取得到)",
|
||
d.count("<!-- VOICE-CORE:BEGIN") == 1 and d.count("<!-- VOICE-CORE:END -->") == 1),
|
||
("注入钩子读该标记并接进 `_core`(⛔ 不是只写进文档)",
|
||
"VOICE_BEGIN" in h and "def _voice_core(" in h),
|
||
("接入方式是「原地重定义」:`_core` 包住原实现(⛔ 不改 `main()`)",
|
||
"_core_reply = _core" in h and "c, p = _core_reply(workdir)" in h),
|
||
("英文硬核版已整包纳进包内(`humanizer-en/SKILL.md` 在位)", en.is_file()),
|
||
]
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main()) |