- 变更规模:新增 514 / 修改 62 / 重命名 155 / 删除 4(归档重组与文档轮次) - .gitignore 修:`归档/**/db-cwd归一-备份-*/` —— 原规则写绝对层级(归档/db-cwd归一-…), 目录搬进 归档/配置与备份/ 后**静默失效**,43 MB 的 DB 备份又变成未跟踪 - .gitignore 补:嵌套 git 内部数据(归档/内嵌git-20261008/、归档/skills-git-旧线-20261007/dotgit-原样移出/) - .gitignore 补:运行态与部署副本(.workbuddy/collab/、.workbuddy/tools/、.workbuddy/.load-pending、.workbuddy/tmp-*) - .gitignore 补:备份件(*.bak-*) - 未跟踪文件从 2190 降到 890(其余为 归档/ 归档件与 .workbuddy/memory/ 知识文件,按口径入库)
1256 lines
72 KiB
Python
1256 lines
72 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
|
||
|
||
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 r"E:/ProgramData/AIProject/ai1net-dsh-server")
|
||
TEST_WS = WS / "tmp" / "selftest"
|
||
TIN = TEST_WS / "inbox"
|
||
CFG = TEST_WS / "collabd.config.json"
|
||
PY = sys.executable
|
||
RESULTS = []
|
||
|
||
|
||
def _mk_env() -> dict:
|
||
env = dict(os.environ)
|
||
env["COLLABD_CONFIG"] = str(CFG)
|
||
env["DSH_COLLAB_WS"] = str(TEST_WS)
|
||
return env
|
||
|
||
|
||
def _prepare() -> None:
|
||
TIN.mkdir(parents=True, exist_ok=True)
|
||
for _f in ("wake.lock", "guard.stop", "goal.pending.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 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",
|
||
"下载 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),
|
||
("🔴 collabd 的主会话候选判据=**按角色排**(⛔ 不再只排 `[协作]`)",
|
||
'_role not in ("worker", "waker")' in sa),
|
||
("🔴 board 同款", '_role_of_title(t) not in ("worker", "waker")' in sb),
|
||
]
|
||
|
||
|
||
@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("目标三态:『没有判据』⛔ 不等于『全过』(防『读不到被当成已完成』)")
|
||
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("台账四态: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 只写一行、--tick ⛔ 不投递(防『人为停掉被读成机制坏了』)")
|
||
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` 经钩子**注入每个会话每一轮**。
|
||
⇒ 本用例钉死两件事:① 已停时**只留一行"已停"** 并把告警投影清掉 ② 已停时 `--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"))
|
||
rc2, out2 = run_cli(["--tick"])
|
||
ok_no_wake = wakeups_n() == w0
|
||
ok_tick = ("paused" in out2)
|
||
# 恢复 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),
|
||
("已停时 --tick ⛔ 不投递(%d→%d)" % (w0, wakeups_n()), ok_no_wake and rc2 == 0),
|
||
("--tick 明确印 paused(⛔ 不静默)", 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():
|
||
m = imp()
|
||
sid = os.environ.get("CODEBUDDY_SESSION_ID") or ""
|
||
if not sid:
|
||
return [("SKIP:环境里取不到会话 id", True)]
|
||
# 🔴 2026-09-30 修(**同族假红**):本用例的前提是"主会话**此刻**在 `working`"——
|
||
# 而主会话在**空闲回落**时 `sessions.status='completed'`(实测:`_session_status(fe146dd9)='completed'`)
|
||
# ⇒ 前提不成立 ⇒ 我原来会判**假红**。⇒ 与"对账/幂等"两条同族处理:**前提不成立就 SKIP**
|
||
# (⛔ 不假装通过、也⛔ 不判假红)—— ✅ 正确的红是"目标在跑却没被延后"。
|
||
_st_now = m._session_status(sid)
|
||
if _st_now != "working":
|
||
return [("SKIP:主会话当前 `%s`(≠working)⇒ target-busy 前提不成立" % (_st_now or "?"), True)]
|
||
st = {"roles": {sid: "main"}}
|
||
r = m._deliver_str("自测:不该被投出", "selftest", st)
|
||
return [("skipped==target-busy", r.get("skipped") == "target-busy")]
|
||
|
||
|
||
@case("投递:跨进程互斥锁存在且会被释放")
|
||
def t_lock_released():
|
||
m = imp()
|
||
m._deliver_str("自测", "selftest", {"roles": {}})
|
||
return [("wake.lock 未残留", not (TIN / "wake.lock").exists())]
|
||
|
||
|
||
# ── 🔴 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 _fake_cfg(sizes: dict, day_offset: int = 0) -> Path:
|
||
"""在**测试区内**造一份假 `logs/<日期>/sdk/conversations/`(⛔ 生产日志目录一个字节不动)。
|
||
|
||
`_deaf_sids()` 只读 `CODEBUDDY_CONFIG_DIR/logs/...` ⇒ 换掉配置根即可**完全离线**复现本坑。
|
||
用 `truncate` 建**稀疏文件**:秒级拿到 10 MiB 的**体积读数**(`getsize`),⛔ 不真写 10 MiB。
|
||
`day_offset`:0=今天(默认)、1=昨天 —— 🔴 **按天分目录**正是"永久判哑"那个坑的土壤。
|
||
"""
|
||
_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():
|
||
with open(root / (_sid + ".log"), "wb") as f:
|
||
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():
|
||
"""🔴 这条钉住的正是**假绿**:旧实现向已哑会话投递**仍记 `ok:true · http:200`**。
|
||
判据三件:① 判读是 `target-deaf` ② 投递台账**零增量** ③ 已把"该点哪一下"落到 `NEED-USER.md`。"""
|
||
DEAF = "deadbeef-0000-4000-8000-000000000001"
|
||
cfg = _fake_cfg({DEAF: DEAF_MIB + 100_000})
|
||
w0 = wakeups_n()
|
||
restore = _with_fake_cfg(cfg)
|
||
try:
|
||
m = imp()
|
||
# `roles` 登记成 main ⇒ 第一道闸(按登记目标)即应命中(⛔ 不必等网关那一遍)
|
||
r = m._deliver_str("自测:哑会话不该被投", "selftest-deaf", {"roles": {DEAF: "main"}})
|
||
_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)
|
||
rc, out = run_cli_env(["--tick"], env)
|
||
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("投递(新):--tick 在位、可跑、且**只由它投递**")
|
||
def t_tick_wired():
|
||
m = imp()
|
||
known = "--tick" in {"--where", "--once", "--supervise", "--tick", "--report", "--reqs"}
|
||
rc, out = run_cli(["--tick"])
|
||
src = (HERE / "collabd.py").read_text(encoding="utf-8", errors="replace")
|
||
hook = Path(r"E:/ProgramData/AIProject/ai1net-dsh-server/.workbuddy/tools/wb-result-hook.py")
|
||
hook_ok, hook_note = True, "(钩子文件不在,跳过)"
|
||
if hook.exists():
|
||
hs = hook.read_text(encoding="utf-8", errors="replace")
|
||
hook_ok = ("--tick" in hs) and ("maybe_run_supervisor_tick" in hs)
|
||
hook_note = "钩子里有 `--tick` 调用"
|
||
return [("`--tick` 属已知参数", known),
|
||
("`--tick` 可跑 rc==0", rc == 0),
|
||
("投递方唯一:`--tick` 用 mutate=True 调用 supervise", "deliver=True)" in src or "deliver=True, mutate" in src or "supervise(st, deliver=True)" in src),
|
||
(hook_note, hook_ok)]
|
||
|
||
|
||
@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("结构:投递方唯一(只有 --tick 能推进队列)+ 旧投递路径已停用")
|
||
def t_deliver_switch():
|
||
src = (HERE / "collabd.py").read_text(encoding="utf-8")
|
||
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),
|
||
("`--tick` 是唯一投递方(supervise(st, deliver=True))",
|
||
re.search(r"supervise\(st,\s*deliver=True\)", src) is not None),
|
||
("wake_round 已标注停用", "已停用" in src)]
|
||
|
||
|
||
@case("主会话:**解析出来的**(不只是读登记)+ ⛔ 拒绝盲投(防『投错窗口』回潮)")
|
||
def t_main_resolution():
|
||
"""用户 2026-09-30:「协作机制能发现 主会话 换了吗」——旧答案是**不能**(登记静态+路径依赖,
|
||
且看板与投递共用同一份登记 ⇒ 一起钉在旧 id 上)。本用例把修法钉住。"""
|
||
src = (HERE / "collabd.py").read_text(encoding="utf-8")
|
||
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-01 从「只排 `[协作]`」扩到**按角色排**)"
|
||
"—— 接续会话/唤醒会话都是干活的棒,⛔ 不是主会话候选(否则接续棒冒充主会话 ⇒ 通知投给它自己)",
|
||
'_role not in ("worker", "waker")' 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),
|
||
("🆕 投递侧已接 `resolve_mains`(⛔ 不再是「唯一那一条」)",
|
||
re.search(r"_rms = resolve_mains\(st,", src) is not None),
|
||
("🆕 投递目标经 `main_for_topic()` **按类别**选", "main_for_topic(_rms, topic)" in src),
|
||
("🆕 `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),
|
||
("🆕 上报投递**带上件所属类别**(`topic=` 取台账条目的 `line`)",
|
||
'topic=str((v or {}).get("line") or "")' in src),
|
||
("🆕 归属判据能吃**类别集合**(`_in_project` 的 main_sid / short 都走 `_as_list()`)",
|
||
"def _as_list(" in src and "for _t in _as_list(short):" in src),
|
||
("🆕 收尾确认**排除全部主会话**(⛔ 否则别类别的主会话被算成「在跑」⇒ 永远拦住)",
|
||
'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_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("看板:任务类别**来源**必须说得出口(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]
|
||
return [
|
||
("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),
|
||
]
|
||
|
||
|
||
@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))
|
||
# ② board.py 源码零项目串
|
||
src = (SK / "board.py").read_text(encoding="utf-8")
|
||
proj_pat = re.compile(r"(dsh-worker|dsh-client|ai1net|手机接入|20090|bdf89014)")
|
||
hits = []
|
||
for i, line in enumerate(src.splitlines(), 1):
|
||
s = line.strip()
|
||
if s.startswith("#"): # 注释里可以讲"为什么这样分层",但代码里不行
|
||
continue
|
||
if proj_pat.search(line):
|
||
hits.append("board.py:%d" % 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 代码里零项目串(命中:%s)" % (hits[:5] or "无"), not hits),
|
||
("看板不回读已废弃的项目键(残留:%s)" % (stale or "无"), not stale)]
|
||
|
||
|
||
@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)]
|
||
|
||
|
||
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 = 0
|
||
print("=" * 66)
|
||
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]
|
||
if bad:
|
||
nfail += 1
|
||
print(" ✗ %s" % fn._case)
|
||
for c, ok in checks:
|
||
print(" %s %s" % ("✓" if ok else "✗", c))
|
||
else:
|
||
npass += 1
|
||
print(" ✓ %s (%d 项)" % (fn._case, len(checks)))
|
||
print("-" * 66)
|
||
print(" 合计:PASS %d / FAIL %d" % (npass, nfail))
|
||
print("=" * 66)
|
||
return 0 if nfail == 0 else 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|