Files
workbuddy_skills/session-mechanism/references/pitfalls.md
T
admin e5dfb6e4ef 发布:常驻文档按现行形态重写 + 作业规矩/01 先并后删
一、常驻机制文档 references/supervise-persistence.md(整份重写 410→469 行)
- 照抄段由废弃的 start-supervise.ps1 形态,改为现行形态:
  计划任务 collabd-keepalive-<区>(每 5 分钟)→ pythonw.exe → supervise-launch.py → collabd.py --supervise
- 旧 ps1 形态整体降级为「§四 旧形态留痕」(逐条标 🟠,内容一字未删,供读老日志/老进程用)
- 判据订正:LastTaskResult 在长驻运行中是 267009(旧口径「必须 0」属"跑一次就退"时代,已作废);
  BOM 那条随 ps1 形态一并降为历史;另两条仍有效(CODEBUDDY_CONFIG_DIR 空库 / WorkingDirectory 必须是工作区根)
- 删除 assets/start-supervise.ps1.tpl —— SKILL.md、manifest.md、collabd.py、selftest.py 四处早已按「该模板已删除」口径,本次把磁盘与文档对齐

二、references/作业规矩/01 先并后删(用户选定 A 方案:不丢内容 + 只剩一处)
- 判据类并入 references/02-功能优先协作协议.md:
  §3.2 补「规则冲突裁决顺序」「冲突 ≠ 门禁」;§3.3 补回完整语言转换表(原处"已收敛到通用技能"在并包后成了悬空指针);
  §5.3 铁律由五条补到六条 + 接上配套硬红线「只做正向迭代」;新增 §5.5「提报前必答三问 + 回话前自检」
- 排版类并入 references/03-回复排版-核心块.md 的「完整版」段(十四条反模式 / 骨架路由表 / 文案点名主体 / 文档直入主题)
  该段放在 REPLY-CORE 标记之外 ⇒ 每轮注入块逐字未变(实测仍 1172 字符)
- 删除 references/作业规矩/01-协作与提报用户判据.md,并跟改 6 处引用(SKILL.md 第一屏表、作业规矩/00 索引表与两处指针、
  作业规矩/03-多棒接力编排、00 的 frontmatter 变更记录)——含一处二级指针「见 §6 第 0 条」,已改指 03 完整版段

三、判据与卫生
- pitfalls.md:P0-74 去冗余(7107 → 5982 B,重复叙述合并),「沉淀纪律」由 FAIL 1 转绿
- install.py:manifest_files() 补排 logs/ —— 与它自己「运行日志不入表」的口径一致,否则跑一次钩子表就过期
- .gitignore:补 logs/;撤跟踪 logs/_dr_probe.json(钩子输出转储,全包零引用)
- references/manifest.md 用生成器重算:66 份文件,语法失败 0

验收读数:selftest.py rc=0 PASS 99 / FAIL 0(另 1 条报告型不计入);
session-rules-check.py fail 0 / warn 1("此刻无活会话",正常态)/ ok 13;改动文件行尾全 LF。
2026-10-07 00:32:47 +08:00

3728 lines
304 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 踩坑清单(每条都真实发生过)
> ## 🔴 当前结论(**先读这里** · 最后更新 2026-10-02 05:1x)
> ⚠️ 本文件**通篇追加式** ⇒ 旧条可能已被取代(已就地标注)。**冲突时以「本节 + `architecture.md` 的「当前结论」节」为准**;
> 历史只留最近 5 轮、更早的归档(例外:**教训类不受 5 轮限制** —— 见 `agent-operating-rules §1.7a`)。
>
> **最该先记住的几条**(其余按 P 编号往下读)
> | 编号 | 一句话 | 为什么它排前面 |
> |---|---|---|
> | **P0-71** | 🔴🔴🔴 **常驻靠什么活着:不在「作业对象」(恒真、没鉴别力),在「谁拉起它」** —— 会话树里起的(父链穿到 `WorkBuddy.exe`)一收工就死;**只有计划任务起的能活**(父链断在自己身上)。✅ 定案形态=**计划任务 → `pythonw.exe` → `--supervise`,每 5 分钟判活**;⚠️ 两个致命细节:**`WorkingDirectory` 必须是工作区根**(⛔ 脚本目录 ⇒ 找不到配置 ⇒ 拒跑)、**常驻不需要网关口令**(⛔ 所以别用 `--ensure`)。🔴 看板 `LastResult=1` 是**虚警**(pythonw 无 stdout),判据看**端口**。🔴 **三条硬约束**(先查后建/各区独立/只有看板共用)+ **`deploy_code.py DEFAULT_FILES` 必须含 `collabctl.py` 等入口脚本**(⛔ 漏了 ⇒ 各区副本永不更新=P0-57 同族) | 🔴 **同一件事栽到第四次**(用户:「**从1号搞到5号 还起个程序都启动不起来**」);错一次=常驻全死、兜底全失 |
> | **P0-72** | 🔴🔴🔴 **两个静默失败**:① `kill_all()` 的 `taskkill` 带 `HIDE`(含 breakaway)⇒ 必被拒 ⇒ `except` 吞掉 ⇒ **"已杀进程 0 个"却一个没死=假停**(✅ 改 `creationflags=0`);② 看板任务**直起 `board.py`** ⇒ 无 `COLLABD_CONFIG` ⇒ `已拒跑` ⇒ **崩溃重启循环、端口从没绑上**(✅ 新增 `board-launch.py` 启动器在进程内设 env)。🔴 长驻服务的 `ExecutionTimeLimit` 必须 `0`(⛔ 设 2 分钟 ⇒ 到点被掐)|🔴 `list_procs` 的 `python*` 会**连调用方一起匹配到** ⇒ 必须算保护集 | 🔴 **"做了动作" ≠ "动作生效"**:这两件事**都不报错**,只表现为"停止没停""看板没起" ⇒ 关键动作**必须有动作后复核** |
> | **P0-73** | 🔴🔴🔴 **常驻启动器缺一个环境变量 ⇒ 检查程序静默失效**:任务**直起 `collabd.py --supervise`** ⇒ 任务环境**没有 `CODEBUDDY_CONFIG_DIR`** ⇒ `_wb_db()` 落到 `C:\\Users\\Administrator\\.workbuddy\\workbuddy.db`(**0 字节空库**)⇒ `all_sessions_idle` 每轮报 **`no such table: sessions`** ⇒ fail-safe **恒判"有会话在跑"** ⇒ **检查会话再也不建**(外表完全安静、零报错)。✅ 新增 `supervise-launch.py`(进程内 `setdefault("CODEBUDDY_CONFIG_DIR", ...)`)+任务动作改指它+`DEFAULT_FILES` 补入。🔴 **"进程活着" ≠ "它在干活"** ⇒ 检查程序必须看**有没有产出预期分支**(`检查会话:…`)|🔴 **重构"起法"时旧起法的 env 要逐条搬**(本坑就是丢了这一句)|🔴 各区 config 的 `host_db` **写死绝对路径最稳**(vibe 一直这么写 ⇒ 只有 ai1net 炸) | 🔴 **同一件事栽到第五次**(用户点名「检查协作程序和检查程序运行是否正常」才抓到);fail-safe 失败方向安静 ⇒ **凡 fail-safe 必须打可检索日志** |
> | **P0-76** | 🔴🔴🔴 **技能加载闸门的词表漏了「主触发句」** ⇒ 用户说了「使用任务会话完成目标」,钩子**全程 0 命中**(旧词表 22 词全是「决策方法/回复排版」族)⇒ 技能从未强制加载 ⇒ **自决策白名单没进上下文** ⇒ AI 拿"注入通报"当准、把已授权的事**反复要签字**(用户连问三次「你去执行不行啊」「会话技能里没告诉你自决策的规则和机制嘛」)。🔴 **⛔ 不是作用域问题**(实测 `_in_scope=True`);✅ 修法=**扩已有钩子的触发面**(新增会话/派活族 17 词 + 第三条路由 `_LOAD_SESSION`)+ `SKILL.md` 首屏加「第 0 步加载门槛」。🔴 **闸门类机制必须做「触发面覆盖审计」**:光验"闸门能响"不够,要验"**该响的场景是否都在词的覆盖面上**"|🔴 **日志里全是 `entry` 没有 `HIT` = "听见了但没认出"**(比"没被调用"更难发现)| 🔴 它是**"机制看着在跑、其实没在管事"**的典型 —— 闸门本身完好,只是听不见你说话 |
> | **P0-75** | 🔴🔴 **远端技能总仓里躺着明文令牌**(`workbuddy_skills.git` 的 `.neodata_token`,`tk_` 明文 72 B,自首次入库 `43b83b0` 就在):首次入库 `git add -A` 整目录收录、`.gitignore` 只挡了产物 ⛔ 没挡凭据。✅ `git rm --cached` + 补忽略规则(`031b312`)|⚠️ **历史仍有该 blob**,彻底清须 `push --force` 重写(牵连 25 技能)⇒ 等用户拍板;根治是**服务端吊销令牌**。🔴 入库前必扫凭据文件名 + 已知令牌串;核验远端必**比差异**(vs 备份)|
> | **P0-74** | 🔴🔴🔴 **`_escalate_to_keeper()` 残留旧形态 ⇒ 计划任务里躺着 `powershell.exe` ⇒ 闪黑窗**(用户原话「刚才又弹了窗口看看是什么」):自我供给**旁路**没跟着新形态一起改 ⇒ 任务动作还是 `powershell.exe -WindowStyle Hidden -File start-supervise.ps1`(PowerShell = **控制台程序** ⇒ 每次触发分配 `conhost.exe` ⇒ **闪一下**;`-AtLogOn` + `RestartCount 999` ⇒ **反复**闪)。✅ 改指 `pythonw.exe + supervise-launch.py`,并删掉 `start-supervise.ps1.tpl` 前置门槛。🔴 **"改了主路径" ≠ "把旁路也改了"** ⇒ 收口时全文 `grep New-ScheduledTaskAction` 与 `.ps1` | 🔴 它是**钩子路径**上的(`UserPromptSubmit` → `--ensure` → 失败 → 升级),**平时不吭声、专挑你在用时闪** |
> | **P0-24** | 🔴🔴🔴 **注入物里写死命令** ⇒ 会话被逼着做**用户没授权**的事。缓存里存的是**成品文案**(含「⛔ 不要问用户」),逐轮复用 ⇒ 跟你当轮说了什么**无关**。硬规:**缓存只存原始数据,文本按当轮授权现算**;未授权时**只通报、不派活** | 🔴 **越权比超时严重**:超时只是慢,越权是**替你做决定**;且表现像"机制很勤快",**最不易被发现** |
> | **P0-23** | 🔴🔴 **钩子超载 ⇒ 用户每句话都被拦下**(10-02 全工作区事故):宿主注册 20s,钩子里**同步**串了 `--gap` 13.5s。硬规:**拿不到结果就没用的活 ⇒ 一律后台**;⛔ **"兜底同步"是伪需求**(改了一版没改净就是因为留了它) | 🔴 这是**唯一能让整个工作区所有会话同时不能说话**的一类故障 —— 优先级最高,无之一 |
> | **P0-6** | 常驻进程 **⛔ 别做高频删文件**(`unlink`/`rename`)⇒ 宿主 **SafeDelete 护栏会直接杀掉进程**(实测:跑 48m43s 后 `failed`;**10-01 又复发一次,只活 8 分钟**)。🔴 **"降低频率"不是修法** —— 判据是「稳态下每轮删除次数 = **0**」 | **唤醒时钟就是这么断的**(10-01 已治本:锁文件永久存在、释放=改内容) |
> | **P0-5** | 「**消息卡住**」指纹 = **`parkInQueue` + `hasWaiter=false`** | 🔴 **AI 侧修不了**,只能让客户端重挂该会话 |
> | **P0-2** | 「卡消息输出」真因 = **会话日志撞 ~10 MiB 被 `dropped`**(⛔ **不是**"任务挂在会话名下") | 曾误归因,白折腾一晚上 |
> | **P0-5a** | 探针 **⛔ 不许"在日志里搜字符串"** ⇒ 会命中**你自己的取证回声** ⇒ 假阳性 | 认结构(记录行),⛔ 不认词 |
> | **P0-19** | **「在不在执行」⛔ 别拿库里的 `status` 判** —— 它只有 `working/completed/error/archived`,**没有"空闲"档**,活着的会话**恒 `working`** ⇒ 投递**永远等不到空闲**(死结)。判据=宿主日志 `[SessionRunStateMachine]` 的 **`busy=`**(⛔ 不进数据库) | 🔴 用户报「**队列一直没有上报**」的真因;换对判据后拦截原因立刻变成**真状态** `no-follow-session` |
> | **P0-20** | `automation-request-refused` **⛔ 别从错误名 `refusal` 推「内容审查」** —— 实测真因是**排期绑的模型不支持关闭思考**(`deepseek-v4.1-flash` + `model_is_thinking=0` ⇒ 服务端 `-32603`);对照 `hy4-preview + is_thinking=1` **从未被拒**,且当日 **11 条**失败会话的 `details` **逐字同因** ⇒ 🔴 **非偶发、是系统性**。✅ 治本=打开思考档(`modelIsThinking=true`,**改完必须回读宿主库核对**);🔴 **2026-10-02 05:5x 已全量收口:未删除的 23 条排期全开,回读 `is_thinking=0` = 0 条**。🔴 **判据:第一步就读那条会话自己的日志拿 `details`** | 🔴 我上一条正是**错误归因**(推成"措辞触发审查"并去改 prompt)⇒ 教训=**"看着有判据" ≠ "判据指向真因"** |
> | **P0-13** | **判据必须能"改前报红"**:空样本上 `every()` 恒真(假绿)|**写死期望值**遇数据一换就恒红(**假红淹掉真红**) | 🔴 同轮两条,**都是"看着有判据、其实没有"**;⚠️ 同族:**变异法跑完必须回读"变异体被哪几条用例跑了"**(本轮我自己的对照脚本就是空的) |
> | **P0-17** | 常驻服务(看板等)**跑的是启动时那份旧代码** ⇒ **改完看不见变化**。判据=直读 `/board.json` 的 `ts`/条数/类别 **对磁盘**,⛔ 不看页面像不像 | 🔴 凡"改完没效果" ⇒ **先查进程启动时间 vs 改动时间**(且重起必须 `--takeover`) |
> | **P0-38** | 「改看板要不要重启」**有四类答案**:改 `board.html`/`board_ext.py` ⛔ **不用**(实时读盘/按签名热重载);改 `board.py`/`collabd.config.json` ✅ **必须** | 🔴 我把"改配置要重启"说成"改看板都要重启" ⇒ **分类错误**;重启不是万能药 |
> | **P0-16** | 告警**每轮重写同一条** ⇒ 噪音;**整体重写**会**静默抹掉人写的「## 解除条件」**(人唯一的回信口) | 🔴 程序"喊了"不算对 —— **喊的姿势**(频率+覆盖)才是问题,**"写成功了吗"这类断言查不出来** |
> | **P0-22** | 🔴 **「进程还活着」⛔ 不能从"日志/戳在动"推** —— 常驻 `--supervise` 历次只活 **8/12/20 分钟**,而**日志照旧在走**(那些轮次是**宿主钩子**的 `--tick` 写的)⇒ **四棒都被这条骗过**。唯一机读判据=`pid 活 ∧ 心跳新鲜(<90 s)`(心跳 `logs/supervise-heartbeat.json`)。⚠️ 同轮还踩到:`tasklist` 输出是 **GBK** ⇒ `text=True` 抛 `UnicodeDecodeError` ⇒ 存活判据**静默变假**(改用内核句柄) | 🔴 「看着在跑」≠「在跑」;修法=**事件驱动的常驻自愈**(`--tick` 顺手续命) |
> | **P0** | 所有 `subprocess` 必须带 **`CREATE_NO_WINDOW`** | 否则桌面反复闪黑窗 |
## P0-22 🔴🔴 **「进程还活着」⛔ 不能从"日志/戳在动"推 —— 常驻也是这样被冤枉了四棒**(★ 2026-10-02 实测 · S8 治本)
**症状**:`collabd.py --supervise`(唤醒时钟本体)**反复在几分钟内消失**(历次读数 **8 / 12 / 20 分钟**),
而 `_collabd.log`**照旧每几十秒一行** ⇒ 前四棒都据此认为"常驻在跑",只有**全量进程表**才发现「早就没了」。
**根因(本棒实测,⛔ 非推断)**:
1. 🔴 **日志的写者不止常驻** —— 宿主钩子(`PreToolUse ^Bash$` + `UserPromptSubmit`)每轮都会跑 `--tick`,
它写的是**同一个日志**。⇒ **「日志在走」根本不能推出「常驻在跑」**(本条的最大教训)。
2. 🔴 **本机不存在"能一直活着"的进程**:① `CREATE_BREAKAWAY_FROM_JOB` **被宿主作业对象拒绝**
(`PermissionError(13,'拒绝访问。')`)⇒ 脱不出回收;② 普通子进程**能**活过**工具调用边界**
(三探针跨调用打点 40 s+、父进程早已消失),但**迟早被回收**(载体会话结束/该轮结束)。
⇒ 上一版把载体押在"容器会话别关"上,**结构上就不可能兑现**。
**修法**(`collabd.py`):
- `--supervise` 每轮写**心跳**(`<WS>/.workbuddy/collab/logs/supervise-heartbeat.json`,**原子替换/零删除**,
承接 P0-6 判据)+ 节拍追加 `…-heartbeat.log`(超 512 KB **重写**保留末 1500 行,⛔ 不 unlink);
启动时**单例让位**(判据同下)。
- **`--tick` 顺手续命**(`ensure_supervise()`:幂等 · 30 s 节流 · **无口令不起** · 无配置不起)
⇒ 常驻掉了,**下一次事件自动补回来**(=「跨 turn 存活」的兑现方式)。另有 `--ensure` 供显式调用。
- 🔴 **存活唯一判据**:**`pid 活 ∧ 心跳新鲜(<90 s)`**(`_pid_alive` 走内核句柄)。
**同轮第二个坑(同族:判据静默变假)**:`_pid_alive` 第一版用 `tasklist /FI "PID eq N"` + `text=True`,
而 `tasklist` 输出是**本地代码页(本机 GBK,首字节 0xd0)** ⇒ 在**读取线程**里抛 `UnicodeDecodeError`
(还会冒成未捕获线程异常写进 `supervise.out.log`)⇒ 判据**看着在、其实不可靠**。
✅ 改用 `OpenProcess(QUERY_LIMITED_INFORMATION)` + `GetExitCodeProcess == 259(STILL_ACTIVE)`;
⚠️ 打不开且 `ERROR_ACCESS_DENIED(5)` ⇒ **保守判"在"**(宁可不起第二条,也不双写)。
用例 `selftest.py::t_supervise_ensure`(8 项:四读数 + 心跳新鲜/陈旧 + 两条不起闸 + 接线与路径一致性)。
**第三个坑(自己踩的)**:不给自测设闸时,自测的 `--tick` 会在**测试工作区**起一条**真的**常驻 ⇒
它持续重写测试夹具 ⇒ `已停总闸` 用例报「告警投影被清掉」**假红**、且 **FAIL 数每轮不同**。
**第四个坑(P0-22 同族 · 2026-10-03 目标检查会话实测)**:**手搓探针验「pid 活」时,`WaitForSingleObject` 会读出假阴性。**
症状:心跳 `round` 每 10 s 正常上涨、`ts_h` 新鲜(<90 s),但自写探针里
`OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION)` + `WaitForSingleObject(h, 0)` 判出 **DEAD** ⇒
「心跳新鲜 ∧ 进程已死」**自相矛盾**。
根因:`WaitForSingleObject` 在该调用形态下返回 **-1**(非 0 / 非 `WAIT_TIMEOUT`)⇒ **不是「已退出」的信号**,
把它当布尔判就得到假阴性。✅ 正确口径=**P0-22 技能已采用的那条**:
`GetExitCodeProcess(h, byref(code))` + 判 `code.value == 259 (STILL_ACTIVE)`;打不开且 `ERROR_ACCESS_DENIED(5)` ⇒ 保守判「在」。
✅ 本次实测坐实:同一 pid `GetExitCodeProcess=259`(**在**)+ `WaitForSingleObject=-1`(误判死)+
`Win32_Process` 进程表独立佐证 **CreationDate 12:03:13 在** ⇒ **以 259 口径为准,代码无须改**。
🔴 **通用教训**:🔴 **判据读到「自相矛盾」(心跳新鲜 ∧ 进程已死)时,⛔ 别急着判"机制真死"** ——
先怀疑**判据自己**(探针写错 / 手搓口径 ≠ 代码口径),并**找第二路独立读数**(进程表 / 看板 `/board.json` 的 `runtime.prog`)交叉验。
本机即 `Get-CimInstance Win32_Process`(⚠️ 须走 PowerShell 工具,⛔ 从 Bash 调 `powershell -Command` 会被安全网关拦)+
`curl --noproxy '*' http://127.0.0.1:8788/board.json` 的 `runtime.prog.{up,heartbeat_pid}`。
✅ 修法=`selftest.py::_mk_env()` 显式设 `COLLABD_NO_ENSURE=1`(⛔ 不是把断言写松)。
## P0-21 🔴🔴 **改「概念/口径」时最容易漏的两处:把口径当实测废掉、只改正文不查指针目标**(★ 2026-10-02 实测 · 用户两问点破)
**缘起**:用户先问「为什么唤醒会话 还是定时任务呢,不应该是一个会话靠自己的后台任务 定时唤醒吗」,再令「**修复错误概念的时候 要排查相关引用 确保更新完全**」。
**① 「口径过时」与「实现没跟上」是**相反**的两件事,⛔ 别混**
- **口径过时** ⇒ **改口径**;**实现没跟上** ⇒ **口径照旧 + 记偏差**。
- 判据:**改口径前必须先找到「用户原话 + 日期」**;找不到原话 ⇒ **只能记偏差,⛔ 不许动口径**。
- 反面实例:见"定案说投递**同时提供唤醒时钟**"与"现状常驻停机、由排期代偿"不一致 ⇒ 误判成"定案过时",把两处**定案口径就地作废** ⇒ 用户一句反问点破。正确做法=**原文保留** + 加「**实测偏差(⛔ 不是口径变更)**」行。
**② 查残留 ⛔ 不能只查正文 —— 指针目标(`⇒ 全文`/`⇒ 细则`/`接续入口_*`)必须一起查**
- 反面实例:状态层**压缩版**的 `⇒ 全文` 指针,其目标文件里对应条目**整条与定案相反**(「不常驻」/「常驻走不通」/「两个拨钟方含自动任务」)⇒ 读者点进去读到的就是**错的**。**压缩版改对了、指针目标没改 = 等于没改。**
- 做法:**全量枚举 ⇒ 逐处只加标注/改写 ⇒ 复核**。复核判定窗口=该行**或其后 3 行**内是否带标注(**标注常写在下一行,只看单行会假性漏报**)。
**③ 顺带两条操作纪律**
- 「**只加标注、⛔ 不删原字**」—— 保留取证;要划掉用 `~~删除线~~`。
- 多份副本(**技能侧模板 ⇄ 工作区实跑份**)改完**必须 `md5sum` 对表**,且 `diff` 只该出现你这次改的那几处。
## P0-20 🔴🔴 **`automation-request-refused`:⛔ 别从错误名 `refusal` 推「内容审查」—— 真因常常是「模型不支持关闭思考」**(★ 2026-10-02 实测定性 · **含同轮一次错误归因的完整订正**)
> ### 🔴🔴 订正块(2026-10-02 05:4x)——**文首那条真因,本条的措辞推断已被推翻**
>
> **真因(逐字证据)**:会话日志 `E:/ProgramData/.workbuddy/logs/<日期>/sdk/conversations/<sid>.log` 里
> `prompt:dispatch-failed:terminalError` 的 `details` 字段写着:
> `"Current model does not support disabling thinking (modelId=deepseek-v4.1-flash)"`
> ⇒ **模型能力与排期配置冲突**:这两条排期是 `model_id=deepseek-v4.1-flash` + **`model_is_thinking=0`(关闭思考)**
> ⇒ 该模型不支持关思考 ⇒ 服务端回 `-32603` ⇒ 被上层记成 `failure_code=automation-request-refused`/`name:"refusal"`。
>
> **对照组(同一台机、同一时段)**:`WorkBuddy 日志定时清理` 与 `决策线体检` 用 **`hy4-preview` + `is_thinking=1`**
> ⇒ **从未被拒过**(02:00 ✓、04:00 ✓)。⇒ 指向配置,不指向措辞。
>
> **"概率性"的真解释**:宿主有 **`thought-level-fallback`** 分支(05:06 那次 `requestId` 逐字带
> `…:thought-level-fallback:attempt:1`)⇒ 走不走这条分支决定了同一份 prompt 有时过、有时被拒
> ⇒ **这正是"同一份文本 02:47 ✓/03:55 ✗/05:06 ✓/05:17 ✗"的来源**,与文本内容无关。
>
> **✅ 治本**:把两条周期排期的思考档打开 ⇒ `automation_update` 传 **`modelIsThinking=true`**
> (⚠️ 该字段**不在工具的文档 schema 里**,但 `additionalProperties` 接受它、**实测生效**:
> 回读库 `model_is_thinking` 由 **0 → 1**)。**⛔ 改完必须回读宿主库核对**,⛔ 别凭"我发过指令了"当已生效。
>
> **🔴🔴 这次的教训(比结论本身重要)**:我先前只看 `runResult.error.name == "refusal"` + `usage` 全 0,
> 就推断成「**输入侧内容审查**」,据此写了一整条"措辞红线"并去改 prompt —— **方向是错的**。
> **判据**:遇到 `automation-request-refused`,**第一步就是打开那条会话自己的日志读 `details`**,
> ⛔ **不许从错误名推断成因**("refusal" 是**上层对失败的错误分类**,不是内容命中)。
> ⚠️ 这也再次印证本文件的老话:**"看着有判据"≠"判据指向真因"**。
>
> **❓ 那"措辞红线"还要不要**:要,但它**是独立的一条**(见下「附带纪律」),
> ⛔ **不要再把它挂在"被拒绝执行"这个因果上**——那是我这轮的错误归因。
>
> ---
>
> #### 📏 量纲 + 收口(2026-10-02 05:5x 复核)
>
> **① 这不是偶发** —— 当日 `logs/2026-10-02/sdk/conversations/*.log` 里 **11 条会话**命中 `dispatch-failed`,
> `details` **逐字相同**(全是 "does not support disabling thinking"):
> `00:33:50/00:36:58/00:44:33/01:47:23/01:50:24/02:55:41/02:57:51/05:09:37/05:10:58/05:30:48/05:36:35`(本地)
> ⇒ **凡「flash + 关思考」的排期,每次触发必被拒**(「概率性」的表象另见上面 `thought-level-fallback`)。
>
> **② 已全量收口**:`automations` 表未删除的 **23 条排期**全部打开思考档
> (改前 `is_thinking=0` 有 **18 条**)⇒ 回读 **`select count(*) … where deleted_at is null and model_is_thinking=0` = 0**。
> 🔴 **唯一可信的复核判据**(该字段**不在 `automation_update` 的返回里**):
> ```sql
> select name, model_id, model_is_thinking from automations
> where deleted_at is null order by model_is_thinking, name;
> ```
>
> **③ 措辞中性化:按用户要求执行了 —— 🔴 但⛔ 与"被拒"因果无关**(2026-10-02 05:5x 二次订正)
> —— 我先前写"**不予采用**"是**又一次想当然**:用户当天回「**需要**」⇒ 已把
> `WorkBuddy 日志定时清理`(id `07976988-1c69-41b2-9547-1264a1a24bf7`)的 prompt 按改写稿**落库**。
> 5 处:删掉「无需二次确认/**有权解除**删除过程中的阻断(含摘除 Deny ACL、解除批量删除熔断)」的口吻
> ⇒ 改为「**按用户既有授权**执行删除与截断;遇到阻断时**按 A0 段既定步骤**处理,并写明处理了哪些、跳过了哪些」。
> ✅ **逐字校验通过**:`md5(库) == md5(稿) == 27df82bfc0777d86949eb9eac9d2bf13`(5757 字符)。
> 🔴 **别把它当成"修好了被拒"** —— 它的收益是**文本不把下一棒引向歧路**(见下「附带纪律」),**不是修复**。
> ⚠️ **两条搬运纪律**:① 动手前先探明库中换行形态(本条是**字面 `\n`**,不是真实换行)⇒ 否则静默改坏格式
> (JSON 里要写 `\\n`);② 5.7 KB 长文本改完**必须回读库逐字比对**(工具返回的转义形态只能看个大概)。
>
> **④ ⚠️ 尚未验完**:以上只证实「**配置已改**」,**没有**证实「**不再被拒**」。
> 验证点=周期排期的下一次触发,判据=其会话日志**不再出现** `prompt:dispatch-failed`;
> 若仍出现 ⇒ 本条结论仍不完整,**必须重查**(⛔ 不得默认已修好)。
**附带纪律(独立成立,⛔ 与上面的真因无关)**:无人值守 prompt 与其自动化记忆文件,**别写成"对抗平台/持久化自维持/自我繁殖/探查平台内部"的口吻** ——
理由不是"会被拒",而是**这种文本会把下一棒引向歧路**(记手法而不记结论)。
- **形状**:排期到点 ⇒ 会话没产出,`automation_runs` 表里落一行
`failure_code=automation-request-refused`,`runResult.error={"code":-32603,"error":{"name":"refusal",…}}`,**`usage` 全 0**。
- 🔴 **判据(怎么认出是它,而不是"排期没触发")**:查宿主库 `automation_runs` 的 `failure_code`;
⛔ **别去猜"是不是排期坏了/是不是程序挂了"** —— 那是另一套症状(有 `thread_id`、有 token 消耗)。
- 🔴🔴 **最关键的一条实测(决定处置方式)**:**同一份 prompt 文本**
`10-02 02:47 ✓ / 03:55 ✗ / 05:06 ✓` ⇒ **不是"含某个词就必拒"**,是**阈值型/概率型**输入判定。
⇒ ⛔ **不要去"找出那个唯一的触发词"**(找不出来;就算这轮找到了,下轮也不复现)
⇒ ✅ 正解=**把风险面整体压低**:换掉一整类措辞,而不是抠掉某一个词。
- **四类要换掉的措辞**(按"读起来像什么"排序,⛔ 都是**语义像**,不是"词被拉黑"):
| 类 | 原措辞(例) | 换成 |
|---|---|---|
| ① 像对抗/规避平台 | `被宿主回收`、`静默 X 分钟`、`绕过`、`监管` | 不提这类因果;只写"现在的做法是什么" |
| ② 像持久化/自维持 | `自维持脉冲`、`常驻`、`长活进程`、`后台 sleep`、`spawn 下一棒`、`守护` | `本排期每小时自动触发一次;本会话只做本轮这一遍:不重复触发、不循环等待、不新建周期排期、不起后台进程` |
| ③ 像自我繁殖 | 强调"会话自己登记新的周期排期" | `开新会话走一次性排期入口;一次只开一条` |
| ④ 像探查平台内部 | 教它直连应用库写 SQL、查平台调度表、看别的会话的内部状态 | `取数只用 state.py 与上列文件` |
- 🔴 **最容易被漏掉的一面 —— 自动化自己的记忆文件**:
`.workbuddy/memory/automations/<id>/memory.md` **下一轮会被一起读进上下文** ⇒ 那里写的"手法"**同样算输入**。
本轮实测:**风险最集中的不是 prompt,是这份记忆**(存着"怎么直连应用库查会话""怎么观察平台有没有点火")。
⇒ **纪律**:记忆文件**只写结论、数值与判据**;不写操作手法、不写平台内部结构的探查方式。
⇒ ⛔ **这条纪律必须同时写进 prompt 里**,否则下一棒会自己把"手法"又写回去(闭环)。
- ⛔ **禁令别写太密**:满屏 `⛔/🔴🔴/不许/不得`(本轮原稿近 20 处)会把整份 prompt 渲染成一叠"约束平台"的指令
⇒ 只保留 2~4 处真正关键的,其余改成中性的陈述句。
- ✅ 本轮处置:两条周期排期的 prompt 重写 + 两份自动化记忆改写(2026-10-02 05:0x)。
⚠️ **残留**:被拒的那两轮**没有任何产出**(`resultEvidence=none`)⇒ 那一小时的空档**补不回来**,只能靠下一跳。
## P0-18 🔴🔴 **回路挂在「宿主从不投递的事件」上 ⇒ 整条回路静默失效,而它「看着像在跑」**(★ 2026-09-29 记 · 2026-10-01 复验并确认后果)
- **形状**:`wb-result-hook.py` 的「**会话收尾 ⇒ 通知协作程序放行下一条**」挂在 **`SessionEnd`** 上
(写 `gate-done.stamp`)。而**宿主从不投递 `SessionEnd`**(同文件 243 行 09-29 就记下了)
⇒ 那个 stamp **至今不存在**(2026-10-01 复验:仍不存在)⇒ **这条回路从装上起一次都没跑过**。
- 🔴 **为什么它骗了很久**:
① 钩子**确实被调用了**(日志里有行)⇒ 看着"钩子在跑";
② 那些行是 `skip: event=''` ⇒ 来源是 `install.py --verify` 的**空载荷自检**、或宿主不带 payload 的调用;
③ 真正会被投递的两个事件(`UserPromptSubmit`/`PreToolUse`)的分支**各自 `return 0`、不写日志**
⇒ **它越正常,日志里越查不到** ⇒ 单看日志必然误判。
- **怎么定死真因(本轮用的三步,可复用)**:
1. **去找那条回路该产出的文件**:`gate-done.stamp` ⇒ **不存在** ⇒ 回路没跑过(**比读日志硬**);
2. **看消费方**:`collabd.py` **真读它**(判 `gate=busy`)⇒ 不是死代码,**是有害的沉默**;
3. **分辨"日志行"的来源**:`grep " done " hook.log` ⇒ 全部带着**自测专用的 `session=` 值**
(`scopeche`/`q2`/`e2e-line`)⇒ 证明**全是 `--selftest`**,无一条真实调用。
- **判据**:⛔ **不许拿"日志里有行"证明回路在工作** —— 必须**指出该回路该产出的文件/记录**,
并**确认它存在且新鲜**。⚠️ 同族:**空载荷自检会污染生产日志** ⇒ 排查前先把自检噪音剔掉。
- **后果(本轮实测)**:不是"永久卡死"(claim 会被「持有人失活立即出队」或 20 分钟陈旧兜住),
而是**纯延迟** + **NEXT.md 里那句话是假话**(原写「SessionEnd 会自动放行下一条」)
⇒ **照着做就不删 claim** ⇒ 那才真卡住。✅ 已把 NEXT.md 第 6 条改成
「**收尾=自己删 `claims/<id>`**」并注明"别指望 SessionEnd"。
## P0-19 🔴🔴 **「状态窗口」死结:拿库里的 `status` 判"在不在执行" ⇒ 恒真 ⇒ 投递永远等不到空闲**(★ 2026-10-01 实测定性 · 用户报障「队列一直没有上报」)
- **症状**:队列一直压着不出去。`_collabd.log` **每 ~20 秒**打一遍
`延后投递:跟进会话 e2ccdea3 正在执行 ⇒ 等它空闲` + `投递未成(target-busy)⇒ 保留 M5=done 在队首`,
连打 6 分钟不停(21:58 → 22:44 之间同类信息 20+ 轮)。
- **根因(死结的形状)**:判据是 `_session_status(目标) == "working"`。而 `sessions.status` 的**取值域**
实测只有 **`working` / `completed` / `error` / `archived` —— ⛔ 没有"空闲"这一档**
(实测分布:`completed 123 / working 2 / archived 2 / error 1`)。
⇒ 一条**活着的**会话跑完一轮、停在等下一轮时,**status 仍然是 `working`**
⇒ **活着 ⇒ 判忙不投;跑完 ⇒ `completed` ⇒ 不 live 也不投**
⇒ **不存在任何一个能投进去的时刻**。**旁证**:`wakeups.jsonl` 里唯一投成的那次(08:55 `http:200 ok:true`)
目标是 `f8a792ab` —— 一条**日志已冻结的"死"会话** ⇒ **只有"死"会话才投得进去**。
- 🔴 **真正区分得开的东西在宿主自己的状态机日志里**(`[SessionRunStateMachine]`,**只写工作区日志、
⛔ 不进数据库** —— 所以查库永远查不到):
| 事件 | 结果 |
|---|---|
| `event=AGENT_STARTED` / `RUN_ACCEPTED` | `lifecycle=running` **`busy=true`** |
| `event=AGENT_ENDED` | `to=idle` `lifecycle=idle` **`busy=false`** `queueBusy=false` |
- **修法**(`collabd.py::_session_busy()` + `_target_busy()`,**语义不变、只换判据形态** ——
用户口径原话「**跟进会话 执行完了 在上报没问题,执行中就等待上报**」,⛔ **不是**"超时即投"):
1. 库里 `status` ∈ `SID_DEAD` ⇒ **直接判"没在跑"**(⛔ 不看日志)。
🔴 **必须有这一道**:实测**自动化拉起的会话跑完不写 `busy=false`**
(整份日志里 `busy=false` 只出现过 12 次、**全是主会话的**),它直接在库里变 `completed`,
而**日志末条仍是 `busy=true`**(`e2ccdea3`:末条 `22:42:10 … busy=true`,库里 `22:45:53` 已 `completed`)
⇒ 只看日志会把**早就结束的会话**再"忙"上十几分钟。
2. `status == working` ⇒ **才**读日志细分:**末条** `busy=` 取**最后一条**记录,
`busy=true` **且新鲜**(`SRSM_FRESH`)⇒ 忙;否则 ⇒ 空闲。
🔴 「新鲜」这半**必须有**:会话真在跑时状态机**每几百毫秒写一行** ⇒
「末条 `busy=true` 却已是 N 分钟前」**只可能**是它早停了 ⇒ **不必依赖"正好抓到 `AGENT_ENDED`"**
(那一条会被挤出尾部窗口)。⚠️ `SRSM_FRESH` 取 **900 s**:一轮里跑**很长的单个工具调用**时
状态机**全程静默**(实测一次 8 MB 日志扫描静默 ~6 分钟)⇒ 窗口太短会把**正在跑**的误判成**空闲**
(方向相反的错:往正在跑的会话里插话)。多等 ≤15 分钟**不算损失**(队列件本来就压着)。
3. 读不到 ⇒ **按"没在跑"处理**并**留一行日志**:⛔ 不能按"忙"处理 —— 那等于把死结换个形状留着。
- 🔴 **同一轮我自己写出的两个"判据看着在、其实恒假/恒真"**(都已修,并写成断言):
- **(a) 正则位置组错位**:写成 `(?:\.(\d+))?` 时**内层 `(\d+)` 仍是捕获组** ⇒ 后面 `m.group(8)`
**整个错位一位** ⇒ `busy` **恒读成 `False`** ⇒ **恒判"空闲"**(方向相反:会往正在跑的会话里插话)。
✅ 改用**命名组** `(?P<busy>…)`,并加**源码级反回归断言**(`_session_busy` 段内零位置组引用)。
⚠️ 这条断言**不是"看代码像不像"** —— 它是**唯一**能在这类错位再发生时立刻报红的东西。
- **(b) 同一秒内"后出现的没胜出"**:只比 `t > last[0]` ⇒ 同一秒(甚至同毫秒)的两条,
**前一条胜出** ⇒ 取到**旧状态**。✅ 改成比较 `(时间, 行序)` 二元组。
- **(c) 我自己的红绿对照脚本是空的**:变异体跑的是 `-k 忙判据`,而那条 `target-busy` 用例的**名字里没有"忙判据"**
⇒ **变异体根本没被试**,报"全绿" ⇒ 差点当成"断言恒真"。✅ 过滤词改成能覆盖两条用例的词。
⇒ 🔴 **判据**:**变异法跑完必须回读"这个变异体到底被哪几条用例跑了"**,⛔ 不看"合计 PASS"就当证毕。
- **残留(⛔ 不是本条的修法能解决的)**:判据换对之后,拦截原因从 `target-busy` 变成
**`no-follow-session`** —— 那是**真状态**:此刻**一条活的 `[跟进]` 会话都没有**
(`e2ccdea3` 跑完变 `completed` 就退出了网关的活会话集)。⇒ **"推"只在目标活着时能用**;
目标不在线时靠**排期到点拉起一条**(`[跟进]-…` 每小时,`nextRunAt` 23:21:35)。
- **形状**:看板是**一个常驻 HTTP 服务**(`board.py --serve <port>`),代码**载入内存后一直跑**。
于是**改完 `board.py` / `assets/board.html` / `board_ext.py`,页面上一点变化都没有** ——
服务还是**改之前**那份代码,它**不会自己重载**。
- **实测**:用户报「看板中协作会话区域**还是**看不到协作会话」。反复核对页面与代码都对得上,
最后发现**病根是进程**:服务是 **20:24:38** 起的,而改代码发生在 **21:xx**
⇒ 它返回的 `board.json` 是**旧结果**(`[跟进]-…` 被标成「主会话」、**只 2 条会话**)。
按文档姿势 **`--takeover`** 重起后 ⇒ **6 条会话、三类齐全**(3 条执行会话可见)。
- **判据(⛔ 不看"页面像不像")**:
1. 直接读 **`/board.json`**:看 `ts`(快照时间)**是不是刚才**、**会话条数**、**每条会话的类别**,
再与**磁盘上的真实快照**(宿主库/`sessions` 表)**逐条对照**。
2. 🔴 **`--takeover` 是硬要求** —— 同 P0-9:Windows 允许**同端口重复绑定且不报错**
⇒ ⛔ 不 `--takeover` 就会**静默并存**,你看到的很可能仍是**旧进程**画的那张图。
- **自查**:凡"改完看不到变化" ⇒ **先问"我看的是哪个进程画的图"**,⛔ 别先怀疑自己改错。
⚠️ 同族:凡**改完看不到效果**的服务(看板/沙箱实例/常驻),**一律先查进程启动时间 vs 改动时间**。
## P0-16 🔴 **每轮重写同一条告警 ⇒ 噪音 + 静默抹掉「人写的回信口」**(★ 2026-10-01 实测)
- **形状**:`need_user()` 遇阻就落 `NEED-USER.md`。而它在**常驻**里每轮(~10 s)都会被走到
⇒ **每 10 秒整体重写一次**,**时间戳一直变**(现场:22:06–22:12 之间被刷了 20+ 次)。
人看到的是「这条消息一直在喊、一直在变」⇒ **当噪音忽略掉**。
🔴 **副作用更严重**:人手工在文件里写了「## 解除条件」——那是**人唯一的回信口** ——
下一轮**整体重写**把它**静默抹掉** ⇒ 人写一次被抹一次,**就再也不写了**(实测:21:03 手写的当场被覆盖)。
- **判据(两条纪律,都落在文件上)**:
1. **同一句话要节流**:窗口(`DSH_NEED_GAP`,默认 600 s)内**同原因** ⇒ **不重写**(⛔ 不刷屏)。
⚠️ 判据必须落在**文件**上(⛔ 不是内存)—— 钩子每次都是**新进程**,内存节流**跨不了进程**。
2. **人写的内容必须原样带走**:重写时把 `## 解除条件` 及其后整段**保留**。
- 🔴 **为什么这条难自己发现**:程序侧"我明明喊了"是**对的** —— 问题出在**喊的姿势**(频率 + 覆盖),
⛔ 任何"写成功了吗"的断言都**查不出来**。⇒ 得换问法:**"人看到这条会怎么想?"**
- **判据落地**:`selftest.py::t_need_user_throttle_keep`(4 项),已按 P0-13 用**变异法**证明非空:
打掉节流 ⇒ ③ 报红;打掉保留 ⇒ ④ 报红。
## P0-15 🔴 **判「样式对不对」时去查内联属性 ⇒ 两条假红**(样式其实写在 CSS 里)(★ 2026-10-01 实测)
- **形状**:给新画的「分组大框」加样式时,`.grp{fill:none;stroke:…;stroke-dasharray:8 7}` **写在 `<style>` 的 CSS 规则里**;
而自检脚本去渲染产物的 `<rect class="grp" …>` 标签上找 `stroke-dasharray`/`fill=` 的**内联属性** ⇒
**必然找不到** ⇒ 报「分组框不是虚线」「分组框有填色」——**两条假红**(两条都是"我从没见过这个属性"导致的)。
- **实测**:几何自检 `tmp/arch-geom-check.mjs` 第 ⑦ 段新增两条,当场双红;
改成**回源文件读 `.grp{…}` 那条 CSS 规则**后转绿。拿**改前备份**跑同一套 ⇒ **8 红**(符合预期)。
- **判据**:
1. **先想清楚"这个样式从哪来"再写判据** —— 三种来源要分开查:**内联属性** / **`<style>` 里的类规则** /
**SVG 的 presentation attribute 默认值**。⛔ 别默认"属性应该挂在标签上"。
2. 🔴 **判据要能在改前报红** ⇒ 参与对照的**备份文件路径必须跟着一起换**(本轮靠 `BOARD_HTML` 环境变量指过去)。
⚠️ 备份里根本没有 `.grp` 规则 ⇒ **报红正是预期**,⛔ 别看到红就以为判据坏了。
- **自查**:新增一条「样式类」判据时,问一句「**这条规则写在哪个文件里?我读的是那个文件吗?**」
## P0-14 🔴 **替换注释块时漏掉结尾 `*/` ⇒ 整段注释吞掉后面代码,而「数括号」查不出来**(★ 2026-10-01 实测)
- **形状**:用编辑工具换掉一段 `/* … */` 注释时,替换的两端**都没带上结尾 `*/`** ⇒
新注释与它下面那一行代码**粘连成一条注释**,从那行往下的代码**全被吃掉**。
- **实测**:`assets/board.html` 改完,`node --check` 只报一句 `SyntaxError: Unexpected token '}'`,
并说「多余的 `}` 在 772 行」—— 而**真正的病灶在 ~1000 行**(那里少了一个 `*/`)。
当场表现为**渲染断言 35 条全红**,看着像"改坏了半个文件"。
- 🔴 **为什么这个坑难查**:第一反应是数 `{` 和 `}` 的个数。**没用** ——
被吞掉的那段里本来就有 `{`,它从「代码」变成了「注释里的字符」⇒ **两边计数照样相等**;
同理,字符串字面量里的 `}`(含中文引号包住的文案)也会干扰朴素计数。
⛔ **别用字数统计判括号平衡**。
- **判据(两条,任选)**:
1. ✅ **最省事**:改完**立刻**跑一次语法检查 —— JS `node --check <f>` / Python `python -m py_compile <f>`。
⚠️ 本坑当时**确实跑了** `node --check` 也报了错,**只是它报的行号把人带偏了** ⇒ 报了错也别只信行号。
2. ✅ **要精确定位**:用**能识别字符串/注释/正则字面量**的扫描器做嵌套深度统计 ——
本轮落在 `tmp/_brace4.js`。它同时跑「改前备份」与「当前文件」:备份 `depth=0`、当前 `depth=-1`
⇒ 一眼看出「多的不是括号,是**注释没闭合**」。
- **自查**:凡改动跨 `/* … */` 边界 ⇒ **必跑语法检查** +(大文件)**深度扫描器对照备份**。
⛔ 别只信「我只改了一处、不可能影响别处」。
## P0-13 🔴🔴 **断言在「空样本」上恒真 ⇒ 看着绿其实从没执行过**(★ 2026-10-01 实测 · 空样本一出现就翻红)
- **形状**:`arr.every(...) === true` 这种写法,**`arr` 为空时恒真** ⇒ 用例**一条都没验**却是绿的。
🔴 与 P9「校验"可用"时只看"能跑起来"」同族,但更阴:**它不是不严,是根本没跑**。
- **实测**:`tmp/render-check.mjs` 里那条「唤醒会话/跟进会话⛔ 不许混进第三层」写成
`_notWorker.every(s => !arch.includes(s.id8))` —— 而当时**线上一条唤醒/跟进会话都还没有**
⇒ `_notWorker` 恒空 ⇒ **恒真**。⚠️ 当天真建出唤醒会话后它**立刻翻红**,
而且红得**没道理**:它查的是「**整张图**里有没有这个 id」,而唤醒会话**本来就该画在主会话左侧**
(那是它的正式位置)⇒ **同一处两个毛病**:空样本假绿 + 判据写错了地方。
- **判据(两条)**:
1. **空样本不许算通过** —— 要么 **SKIP**(像本文件里"前提不成立就 SKIP"那几条),
要么**用合成样本**把分支逼出来(`render-check.mjs` 的 `unrecBad` / `retBad` / `wlBad` 三个注射块就是这个手法);
2. 🔴 **断言必须能"改前报红"** —— 拿**改前备份**跑同一套桩做**红绿对照**;
备份已被后续改动覆盖时,用**变异法**(把判据回退成旧写法,看它是否报红)。
- ⚠️ **连带教训(同轮同时踩到的第二大坑)**:**别把期望值写死在断言里**。
同一批断言里有 4 条写死了旧数据(线名 `ai1net-dsh-`、类别值 `唤醒机制`),
线上数据一换就**恒红**(假红)—— **假红会把真红淹掉**(实测实时快照 5 红里 4 条是假红)。
⇒ 期望值一律**从样本现算**(`_lgNames` / `_sessTopics` 那种写法)。
- **自查**:跑一遍断言,**数一下每条真跑过没有**(样本数 > 0?分支可达?);再数**红的有几条是真的**。
## P0-12 🔴 **写 `.py`/`.json` 时嵌了 ASCII 双引号 ⇒ 截断字符串/破坏 JSON,而且多半是「静默」(★ 2026-10-01 一天犯 3 次)
- **形状**:在人写的自由文本里用 ASCII `"…"`(例如 `本图管"该做什么"`)。同一句下去,两种后果:
· **Python**:`"…用"该做"…"` ⇒ 字符串**在第二个 `"` 处截断** ⇒ `SyntaxError`(**好抓**);
· **JSON**:字符串非法闭合 ⇒ `json.loads` 抛 `Expecting ',' delimiter`,**而调用方往往把异常吞掉**
(`except Exception` → 只写一行日志,**`rc` 仍是 0**)⇒ **命令看着成功、文件其实没被读进去**。
🔴 **实测**:目标检查读不到任务图 ⇒ **`NEXT.md` 静默不产生** ⇒ 四道闸门第一道就断(排查绕了一圈才发现)。
- **一天犯的三次**:① `board_ext.py` 的文案(截断 Python 字符串);② `selftest.py` 的用例描述(同款);
③ `交付物/任务图-会话协作自检.json` 的 `notes` 字段(破坏 JSON)。
- **判据(一条)**:**凡写 `.py`/`.json`,落盘后立刻验一遍** —— `python -m py_compile <f>` / `json.loads(...)`。
⛔ 不许靠「我看着对」;⛔ 更不许因为「命令 `rc=0`」就认定写成功 —— **吞异常的调用方会让它假绿**。
- **怎么写**:中文引号一律 `「」`/`『』`;确需 ASCII 引号 ⇒ 用转义 `\"`,或在 Python 里换外层引号。
⚠️ 这不是「手滑」,是**缺一道工序**(写完多跑一次编译/解析,成本趋近 0)。
## P0-11 🔴 **「接续」被当成角色 ⇒ 主会话的接续被判 worker ⇒ 主会话候选 = 0**(★ 2026-10-01 实测 · 用户口径点破)
- **用户口径(2026-10-01 原话)**:「**接续会话 不是单独的一类会话,是这几类会话到达阈值时 创建的接续会话**」
⇒ **接续是形态,不是类别** —— 它**继承被接续那条的角色**(主会话的接续**仍是主会话**,执行棒的接续仍是 worker)。
- **现象(本机实测,一刻钟内可复现)**:`_scan_mains()` 返回 **`cand=0 / named=0`**
⇒ `resolve_main()` 返回 `{"sid": "", "source": "no-register"}` ⇒ **投递解析不出主会话**(`no-main-session`)。
- **逐条读数(本工作区 8 条会话,全部判 worker)**:
`接续 · 会话机制合并包 · 任务4b-4d-6`/`接续 · 会话机制合并包 · §9.3 与遗留收尾`/
`接续 · 会话机制合并技能包(任务 2/3/4 + 归档)`/`[接续] 会话机制合并技能包(第 1 棒)`/
`接续棒:日志增长治理(任务 0 → 任务 A)`/`[唤醒机制] 接续 · 日志事前叫停钩子落地(第 2 棒)`/
`[唤醒机制] 接续 · 规则形态入口化接线 + 技能整合(第 3 棒)` ⇒ `form=continuation`、
以及 `[协作]-[唤醒机制]-日志事前叫停钩子落地(第4棒)` ⇒ `form=prefix` —— **全部 `role=worker`**。
- **根因**:`parse_session_name()` 里「标题含『接续』⇒ 一律 `role="worker"`」,
而 `_scan_mains()` 的排除判据是「**角色不是 worker/waker**」⇒ **主会话的接续被当干活的棒排掉**。
- 🔴 **为什么会写成这样(不是随手写错)**:当初是为了堵**自指死结** ——
`[<类别>] 接续 · …` 的第一对方括号装的是**类别**,不收成 worker 就会被 `_topic_in_title()` 认成
"该类别的主会话" ⇒ **通知投给它自己**(已实测坐实)。⇒ 兜底方向对,**但把主会话的续棒一起吞了**。
- 🔴 **真正的根因是"信息不足",⛔ 不是"解析器不够聪明"**:`continuation` 形态的标题里**根本没有角色信息**
⇒ 解析器**无从继承**。⚠️ 实测反证:`主控 · 机制线(…)` ⇒ 判 `main` ✅ —— 说明**只要标题带 `主控` 就没问题**,
坏的是"**规范没被执行**"(实际建出来的主会话续棒写成了 `接续 · <线> · <具体>`,没带 `主控`)。
- **两条正解(⛔ 都属方案变更,先拍板再动)**:
1. **补登记**(零代码改动):`collabd.py --declare --role main` 登记该会话 ⇒ 走 `resolve_main()` 第①层。
✅ 立刻可用;⚠️ 登记是**静态**的,换主会话后不会自己变。
2. **改判据 + 收紧命名**(治本):`continuation` **不再无条件判 worker** —— 带 `主控` ⇒ `main`;否则 ⇒
**角色未知**(点名,⛔ 不猜)。⚠️ **必须同时堵自指死结**(缺角色方括号 ⇒ 一律不进候选、只点名)。
- **⛔ 反面判据(同族老毛病)**:「自测全绿 ⇒ 判据没问题」—— **错**。本缺口**在自测里完全看不出来**
(用例只喂人工样本,不读宿主库真标题)。⇒ 判据只能是**拿宿主库真标题跑一遍并看 `cand` 计数**。
- **自查命令(复制即用)**:
`COLLABD_CONFIG=<使用方>/collabd.config.json python -c "import importlib.util;…"` ——
逐条打印 `parse_session_name(title)['role']` + `_scan_mains()['cand']` 长度。
⚠️ **⛔ 不带 config 跑会得到 `cand=0` 的假读数**(连 `_same_ws` 都比不上,是"没配置"而不是"判据坏了")。
## P0-10 🔴 脚本被**重定向** ⇒ 本地 GBK 编不出 `⛔` ⇒ `print` 抛异常 ⇒ 顶层记 `fatal`、**整轮失败**(★ 2026-10-01 实测)
- **现象**:`<工作区>/tmp/supervise-inbox/_collabd.log` 与**技能包内**同时出现
`fatal 'gbk' codec can't encode character '\u26d4' in position 0`(本机实测连续 4 次),
且包里**长出了 `tmp/supervise-inbox/`**。
- **两个真因(同一条链,都要修)**:
1. 🔴 **钩子把技能包当工作区**:`wb-result-hook.py` 的 `_WS_ROOT` 原按 `3×dirname(__file__)` 推 ——
包内这份(`<包根>/scripts/hooks/`)推出来**正好=技能包自己** ⇒ 交给 `collabd` 的
`COLLABD_WORKSPACE` 是**包目录** ⇒ 运行产物落进包里。**同文件里本来就有正确的 `resolve_ws()`**
(env 优先 + 内容标志上溯)⇒ 这不只是"推错",是**同一事实有两套实现**。
✅ 改为复用 `resolve_ws()`,并加「**未知工作区 ⇒ 停手**」(⛔ 不拿 cwd/包目录顶替)。
2. 🔴 **重定向 ⇒ 编码崩**:`collabd` 拒跑时会 `print("⛔ 未找到部署配置…")`;钩子用
`stdout=DEVNULL` 起它 ⇒ 子进程 stdout 按**本地编码(GBK)** ⇒ `⛔` 编不出 ⇒
`UnicodeEncodeError` ⇒ 被顶层 `except` 记成 `fatal` ⇒ **整轮失败**。
✅ 7 个非钩子脚本文件头加**输出编码兜底**(`stdout/stderr` 改 UTF-8 + `errors="replace"`)。
- **🔴 为什么这条特别要紧**:**常驻必须把 stdout 全重定向到文件**(本机唯一可行的"长活"载体)
⇒ 不修这条,**常驻一定起不来** —— 它会死在第一句带 `⛔` 的输出上。这不是边角,是**拦路石**。
- **⛔ 反面判据**:「外层 `rc=0` ⇒ 没崩」—— **错**。钩子把子进程输出**全丢弃** ⇒ 外面只看到 `rc=0`,
而日志里已经是 `fatal`。判据只能是**看日志里有没有 `fatal`** + **看包里有没有长出 `tmp/`**。
- **✅ 处置(已落地)**:修根推导 + 加编码兜底 + `collabd.log()` 在「未找到配置」时**拒写任何文件**
(对齐它自己"不落任何文件"的承诺)。验证:修后同一路径连跑 **0 次 `fatal`**、包内保持干净。
## P0-9 🔴 Windows `SO_REUSEADDR` 允许**同端口重复绑定且不报错** ⇒ 多个看板服务**静默并存**(★ 2026-09-30 实测)
- **现象**:用户说「**要把其他看板服务关了 避免打架**」;此前还连问两次「看板还是没有把会话识别为主会话」——
即**改了代码、重启了看板,用户看到的却还是旧的**。
- **根因(实测,不是推断)**:`board.py --serve` 用 `ThreadingHTTPServer`,它继承 `allow_reuse_address = 1`
(= `SO_REUSEADDR`)。**Windows 上这个选项允许两个进程绑同一个 `127.0.0.1:8788` 而不报 `WSAEADDRINUSE`**
⇒ **谁都可以起**、**同一 URL 被随机应答** ⇒ 快照/代码版本互相打架。
· 判决性实验:8788 已有看板在跑时,再起一个 ⇒ 照样打印「看板已起」,**rc=0/无报错**。
· 现场证据:看板日志连续 6 行「看板已起」**中间零报错**。
· ⇒ 于是"改了看不到" ≠ 改错了,而是**请求打到了另一个进程**。
- **⛔ 反面判据**:「日志里没有 `10048` ⇒ 没有重复实例」—— **错**。**没有报错恰恰是这个坑的特征**。
- **✅ 处置(已落地)**:`board.py --serve` 加**单实例护栏** —— 起之前探 `/healthz`,**认签名**
(body 同时含 `"ok"` 与 `"snapshots"`;⛔ 不能只认"端口开着"、⛔ 不能只认 HTTP 200)⇒ 已有看板则**拒绝启动**;
换新代码用 **`--takeover`**(先停旧的再接管)。停机入口 `stop-collab.py` 加 **④ 看板服务**
(按端口逐个探签名,把**所有**实例列出来并停)。
- **⛔ 别顺手把 `allow_reuse_address` 关掉**:进程被强杀时**服务端**会留下 `TIME_WAIT` ⇒ 不设 reuse 会导致
**重启必失败**。护栏放在"起之前探测"这一层,⛔ 不动 socket 选项。
## P0-3 🔴🔴 **⛔ 不许拿"排期"去撑投递**(用户:"又给我整到自动任务去了")
> 🔴 **2026-10-01 订正**:本条原写「投递⛔ 不许靠"加一条排期**/常驻**"」—— **"常驻"那一半是错的**。
> 投递**本来就该常驻**(09-29 定案,2026-10-01 用户再确认);用户当时否定的**只有"用排期撑投递"**。以下保留原始推理,**结论以本框为准**。
- **现象**:反复把"怎么把通知送到主会话"这件事,收敛成"**开一条自动化排期**"。用户对此明确不满(原话:**「又给我整到自动任务去了」**)。
- **根因**:把「**投递要有口令**」错推成「**必须有排期**」⇒ **把可选手段当成了唯一手段**。
- **为什么排期不对**:排期**烧 token**,且会让判断分裂到排期那一侧 ⇒ 用户要的是"**唤醒永远只有目标检查一个出口**"。
- **修法**:✅ **投递 = 常驻进程按周期跑**(`collabd.py --supervise`);⛔ **不建"当闹钟"的自动任务**(2026-10-01 用户:「**定时任务的方案已经废弃了**」)。
- 🔑 **推广**:**先问"这个能力宿主已经在哪里提供了?"再问"要不要新增常驻"** —— 但**"常驻"本身不再是禁忌**:
判据=「**它是不是唤醒时钟这类必须有的东西**」;⛔ 拿"进程数 = 0"当理由去否掉时钟,2026-09-30 已经踩过一次(用户点破「**投递的心跳成摆设了**」)。
- ⚠️ **载体选择**(2026-09-30 用户追问"那用会话后台任务当守护+投递行不行?")⇒ ✅ **能用,且是本机唯一可行的载体**:
实测 `detached spawn` **活不过工具调用边界**、`schtasks` **被内置程序黑名单硬拦** ⇒ **唯一解 = 宿主后台任务 + `stdout` 全重定向**。
⚠️ **代价如实讲**:会话后台任务会**压制它所属会话的 idle 钩子**(实测被僵尸任务压 6h20m)⇒ 载体要用**专用容器会话**。
## P0-4 🔴 **两类「静默丢件」:程序在跑,主会话什么也没收到**(2026-09-30 同轮修掉)
- **型一:投影轮"消费掉却不投递"**
· 现象:队列里有待反馈项,程序每轮都在跑,**主会话一次都收不到**。
· 根因:钩子在 `UserPromptSubmit` 上**先**跑 `--once`(节流 3 分钟,**谁发话都会跑**);而 `--once` 也调 `supervise()`,
它**无条件**把待反馈项标记为"已通知"并从 pending 摘掉 ⇒ 紧接着的 `--tick` 看到**空队列** ⇒ 永远不发。
· 修法:✅ `supervise(deliver=False, mutate=False)` —— 投影轮**只算、只写 `TO_MAIN.md`,⛔ 绝不推进队列**;
**只有投递方(`--tick`)才推进**。(`mutate` 参数见 `architecture.md §4.2`)
- **型二:投递被挡下,却仍标记"已通知"**
· 根因:`_deliver_str` 可能因 `target-busy`(目标会话正在执行)/`too-soon`(距上次 <`wake_min_gap`)/
`locked`(跨进程互斥)/`no-token` 而**放弃投递**;旧代码**不看返回值**就 `notified[kid]=stt` 并摘队列
⇒ 这一条**从此消失**(既没送到、也不再重试)。
· 修法:✅ **未投出 ⇒ 队列原样保留,下一轮重试**;唯一例外=`same-item`(内容哈希逐字相同 ⇒ 主会话本就收到了)。
- **验收(怎么分辨"真绿"和"看起来绿")**:⛔ 不看 `rc=0`,看 **`wakeups.jsonl` 有没有新增一行 `ok:true`**
+ `tasks.json` 里的项**是否还在 pending**。自测里已固化三条用例(纯投影不消费/投不出不消费/`--tick` 在位且唯一)。
## P0-8 🔴 **`collabd.py` 被多个会话并发调用时的冲突面**(★ 2026-09-30 · 用户问「多会话同时调用会冲突吧」)
**逐条给判据(⛔ 不含糊)**
| 调用路径 | 写什么 | 有锁吗 | 结论 |
|---|---|---|---|
| **投递**(`--tick` / `--supervise`) | `wake.lock` + 网关 `reply` | ✅ **有**(跨进程互斥 + 内容哈希 + `wake_min_gap`) | ✅ **不会重复投递**(今晚整晚无成对记录) |
| **`--report` / `--reconcile`** | **读改写 `tasks.json`** | 🔴 **无锁** | ⚠️ **可能丢更新**:两条上报**精确同时** ⇒ 后写覆盖前者 ⇒ **台账少一条** |
| **`--tick` / `--once` / `--supervise` / `--declare`** | **整份覆写 `collabd-state.json`** | 🔴 **无锁** | ⚠️ **last-writer-wins** ⇒ 可能丢 `notified`/`notify_pending` 等字段 |
| **`--ready-next` / `--reqs` / `--where`** | 只读 | — | ✅ 安全 |
**风险评估(如实)**:`--report` 是**毫秒级写小文件**,且棒通常**不会精确同时**上报 ⇒ **实际概率低,但不是零**。
**✅ 立刻可用的规避(⛔ 不改代码)**:**上报后回读核对** ——
```bash
<python> collabd.py --report N9 --state done --by "[协作]-…" --line <线> --artifact <产物>
<python> collabd.py --reqs # ← 回读:确认自己那条在、状态对(防被别的上报覆盖)
```
⚠️ 若回读发现**自己那条被覆盖/丢失** ⇒ **立刻重报**(把它写回去),并在上报里提一句。
**🔴 未解决(如实登记)**:并发写锁**还没做**。
⚠️ **我 2026-09-30 06:06 试过一次**(换成 OS 级文件锁 `msvcrt.locking` + 给 6 处 state 写加锁)⇒ **导致 `--once` 与 `--report` 全部卡死(rc=124 超时)** ⇒ **已从备份回退**(`tmp/bak-concurrency-20260930-060649/`)。
⇒ 结论:**这个改造必须在"隔离环境先验证 `msvcrt.locking` 行为"之后再做**,⛔ **别在"用户在等"的状态下赶工**(本次教训)。
⇒ 正确的下一步:① 先写一个**两进程并发压测脚本**(隔离目录)② 验证锁真能互斥且**不卡** ③ 再改进生产。
## P0-7 🔴🔴 **宿主推给前端的「会话元数据」是**陈旧缓存** ⇒ 前端与实际不匹配 ⇒ 用户消息**静默蒸发**(★ 2026-09-30 实测定型 · **用户凭直觉指出,被证实**)
- **症状(用户原话)**:「**我发消息发不出去卡住,看上去是发出去了 实际没有**(这种情况消息**应该出现在待发送框中**)」
- **用户的关键判断(✅ 被证实)**:「**我的感觉是会话状态不对,导致前端界面和会话实际动作不匹配**」
**取证链(三条,全部实测)**
| # | 读数 | 说明 |
|---|---|---|
| ① **消息确实丢了** | 转录里搜用户原话关键词 ⇒ **只有他重发的那条**(06:01),**05:57–06:00 那条完全不存在**;同期 `PromptIterator` 也没有 | ⛔ **不是队列(不是 park)**、⛔ **不是服务端丢** ⇒ **丢在「客户端 → 宿主」这一跳** |
| ② **前端拿到的是旧元数据** | 宿主 `[AcpView] Sent session_info_update with title: **用powershell 运行 试试呢**`<br>而 DB 里 `sessions.title` = **`接续 · 机制线(钩子锚点真实投递取证)`** | 🔴 **不一致** |
| ③ **且长期停在旧值** | 05:51:37 / 05:53:04 / 05:54:16 / 05:57:51 / 06:02:15 ⇒ **五次推送全是同一个旧标题** | 不是瞬时抖动,是**缓存陈旧** |
⇒ **结论(⛔ 严格划清"可证"与"未证" —— 别学 P0-2 的老毛病)**
| | 内容 | 状态 |
|---|---|---|
| ✅ **可证** | ① **用户那条消息确实丢了**(转录、`PromptIterator` 双无)⇒ **丢在「客户端 → 宿主」这一跳**(服务端无任何记录)<br>② **宿主推给前端的元数据是旧的**:推送 `title`=旧值,DB `sessions.title`=真值,**五次推送同值** | **已实测** |
| ⚠️ **未证** | ② 是否**就是**①的原因("陈旧元数据 ⇒ 发送走偏 ⇒ 蒸发")—— **我没有任何直接证据** | 🔴 **⛔ 不得当结论说**(这正是 P0-2 的教训:相关性 ≠ 因果) |
**源码级补证(`app.asar` 实读)**:`session_info_update` 的 schema 定义写着是 **"update session information like **title**"**、由 **agent 侧推送**(⛔ 不是前端自己算的)
⇒ 所以"推旧值"**确实不对**(它本应反映当前会话名)⇒ **但"它导致了消息丢失"仍未证**。
- 🔴 **归属(如实)**:这是**产品侧缺陷**(宿主 ↔ 前端的状态同步)。
⛔ **机制侧看不到、也修不了**(消息根本没到服务端 ⇒ 服务端无任何记录 ⇒ **对账也发现不了**)。
⇒ 机制侧唯一能做的是**降低触发概率**(例如本次已把唤醒从"定期噪音"改成"真停滞才发",减少主会话 busy 占比)。
- ✅ **可做的缓解**:**重启 WorkBuddy**(清掉陈旧元数据缓存)。
⚠️ 代价:**会杀掉所有会话后台任务**(覆盖网络节点中继客户端/设备接入本地反代/投递守护)⇒ **重启后必须按 `deploy.md §5b` 重起那两条腿**。
- 📌 **上报要点(给产品)**:附 ①转录缺失 ②`session_info_update` 与 `sessions.title` 的对照 ③五次同值的推送时间线。
- 🔑 **推广**:**"消息发出去了但没到",先查三处**——① 转录有没有(有没有进会话)② `PromptIterator` 有没有(有没有进队列)③ **宿主推给前端的元数据是不是旧的**(前端与实际是否一致)。⚠️ ⛔ **别只数"成功的条数"就下结论"没丢"**(本次我犯过:只统计到 14 条全成功,却没核对"应该有多少条")。
## P0-6 🔴 **宿主 SafeDelete 护栏会"杀掉"高频删文件的常驻进程 —— 唤醒时钟断掉的真正原因**(★ 2026-09-30 01:50 实测 · **2026-10-01 再复发并治本**)
- **现象**:常驻投递进程(已停用)**跑约 48 分钟后 `failed`**(后台任务 `Syz5DD`,`Duration: 48m 43s`),**唤醒从此消失**;`stderr` 为空、`stdout` 只有一行。
- **读数(⛔ 不是推断,是 stdout 原文)**
```
[safe-delete][SAFE_DELETE_BULK_CONFIRM_REQUIRED] {"count":50,"threshold":50,"scope":"turn",
"targets":["E:\\…\\tmp\\supervise-inbox\\wake.lock"],"targetCount":1}
```
- **根因**:`_deliver_str` **每轮尝试**都会在 `finally` 里 `unlink(wake.lock)` 释放锁;
而宿主有 **SafeDelete 批量删除护栏** —— 按"**本轮删除次数**"计数,**第 50 次即要求确认并拒绝** ⇒ 进程被终止。
⚠️ **不是"锁写错了",而是"释放锁的方式是高危操作"**。日志里成片的 `投递未成(too-soon)` ⇒ 绝大多数轮次都在空转取锁+删锁。
- **第一次修法(2026-09-30 · ⚠️ 事实证明**不充分**)**:把 **hash / 最小间隔的预判提到取锁之前** ⇒ 高频的 `same-item` / `too-soon` 路径**根本不碰锁文件**。
⚠️ 取舍如实登记:锁内**仍会重读 STATE 再判一次**(那才是权威判据),竞态窗口由 `wake_min_gap`(300 s)兜底 ⇒ ⛔ 不会双投。
- 🔴🔴 **2026-10-01 复发 ⇒ 才找到真因:上面那招只在"锁**不需要**取"时才省掉删除**;一旦每轮内容都在变
(队列头/告警读数天天动 ⇒ **哈希轮轮不同**),"快速否决"**一次都命中不了** ⇒ **每轮都取锁 ⇒ 每轮删一次锁文件**。
实测读数:常驻 **20:52 起、20:56 查已无此进程**(`Get-CimInstance` 全机只剩看板那一个 python);
`_supervise-bg.out.log` 原文 `{"count":50,"threshold":50,"targetCount":1,"targets":[…\wake.lock]}`。
⇒ 换成 **≈10 s/轮 ⇒ 50 次删除 ≈ 8 分钟**就被杀。**"降到只在可能真投时"这个判断本身就是错的**:
真投的判据是"内容变了",而**内容会一直变**。
- **✅ 治本修法(2026-10-01 已落)**:**锁文件永久存在,释放 = 原地改写内容**(`pid` ↔ `free`)⇒ **零删除**。
· 判据随之从「文件在不在」改成「**内容 + 陈旧度**」(内容为空/`free`/mtime > 120 s ⇒ 可抢);
· 原地写**非原子** ⇒ 并发读可能看到半截串 ⇒ 那**不算** `free` ⇒ **fail-closed**(宁可少投,⛔ 不乱投);
抢锁后**回读核对**(本项目既有的乐观重试手法)⇒ 核对不上就不算拿到。
· 🔴 **回归(两条,缺一不可,且做了变异法红绿对照)**:`selftest` 加 ①「锁已释放(不存在 **或** 内容=`free`)」
+ ②**反向**「`_deliver_str` 体内 ⛔ 不再出现 `_lk.unlink`」(源码级判据,且**要求源码真读到**,
⛔ 不许 `getsource` 失败时恒真),③ 新增真互斥例「**别的进程持有时 ⇒ 不投(`locked`)**」。
⚠️ **为什么非要那条反向的**:只判"已释放"的话,**旧写法(每轮 unlink)照样绿** —— 变异法实测证实:
把 `unlink` 放回去,① 仍 ✓、**② 报红** ⇒ ① 单独存在=空判据(P0-13 同族)。
改后 **PASS 43 / FAIL 0**;变异体 **PASS 42 / FAIL 1**。
- 🔑 **推广(比本条重要)**:**常驻进程里 ⛔ 别做高频的"文件删除/改名/清目录"**(`unlink` / `rename` / `rmtree`)——
护栏按**次数**计,⛔ 不按"意图好坏"。要"释放/标记"优先**改内容或改时间戳**(写标记),⛔ 不用删文件;
非删不可 ⇒ 改成**低频/惰性**,并把"删了多少次"记进自己的日志(否则你只会看到"莫名 failed")。
⚠️ 🔴 更狠的一条:**"降低频率"不是修法** —— 只要有一条"每轮都会删一次"的路径,它**早晚**会凑满 50 次。
判据是「**稳态下每轮的删除次数 = 0**」,⛔ 不是"比以前少"。
- **复发信号(下次一眼认)**:① stdout 出现 `SAFE_DELETE_BULK_CONFIRM_REQUIRED`;② 常驻**莫名 failed 且 stderr 为空**;
③ **存活时长 ≈ 8 分钟(10 s/轮 × 50)或 ≈ 48 分钟**(取决于是哪条删除路径在凑数)。
⚠️ 旧版进程被杀时 `wake.lock` 会**残留**(01:49 留过一个)—— 它 >120 s 会被自动抢占,⛔ 不必手删。
⚠️ 新版(删除-free)**锁文件会一直躺着、内容是 `free`** ⇒ 那**不是**残留,⛔ 别当故障去删它。
## P0-5 🔴 **「消息卡住」的指纹 = `parkInQueue` + `hasWaiter=false`**(★ 2026-09-30 实测定型 · 用户给了窗口 22:55–23:20)
- **症状**:界面上发了消息,**一直转、没有回复**;会话没有任何执行迹象。
- **指纹(一条 grep 就能认)**——工作区日志 `<日志根>/<日期>/<工作区名>__*.log`:
```
grep -E "PromptIterator\].*route=|No state found for connectionId" <该日志>
```
| 读数 | 含义 |
|---|---|
| `route=resolveWaiter … hasWaiter=true` | ✅ 正常:有人等 ⇒ 立即执行 |
| 🔴 `route=parkInQueue … hasWaiter=false` | **消息入队但没有消费者** ⇒ **会一直停着**(=用户看到的"卡住") |
| `[ACP StreamManager] sendToClient: No state found for connectionId=…` | 客户端连接状态丢了 ⇒ 同源旁证 |
- **本次实测(2026-09-29 · 主会话 `fe146dd9`)**
| 时间 | route | queueLen | hasWaiter |
|---|---|---|---|
| 22:53:00 | **parkInQueue** | 0 | **false** |
| 23:05:01 | **parkInQueue** | 1 | **false** |
| 23:05:37 | **parkInQueue** | 2 | **false** |
| **23:12:16** | **resolveWaiter** | 0 | **true** ⇒ 队列被排空、恢复 |
⇒ 队列 **0 → 1 → 2 逐条堆积、无人消费**,直到 23:12:16 客户端重新挂上(宿主 pid 同时换到新实例)。
- **定量旁证(这才是"指纹"的分量)**:全天 `hasWaiter=false` **只有 3 次**,**全部**落在这 25 分钟里;
同一小时 `No state found for connectionId` **303 次,其他小时 0 次** ⇒ **客户端连接状态在该小时反复丢失**。
- **与相邻几型的区别**:②f 有中断证据 · ②g 工具在循环 · ②h 干完了推不出去 · **本型=消息在队列里、没有消费者**(`session-mechanism`(`references/forensics.md` §3))。
- ⚠️ **窗口里可能同时叠着另一层**:本次 22:58:40–22:59:50 还有 `diagnostic log write failed`(`droppedLines` 496→2580)—— 那是**日志层**,
⛔ **它不解释"消息卡住"**,别把两层混成一个因(同类错误见 P0-2 的教训)。
- 🔴 **对"程序化投递"的直接后果(本机制必须知道)**:经 `/api/v1/acp`/网关 `reply` 投进去的消息,**本来就没有 UI 等待者**
⇒ **天然带 `hasWaiter=false` 风险**。⇒ "往正在执行的会话投要延后"(`target-busy`)+"同一内容成对重复投要拦"(`wake.lock`+哈希+`wake_min_gap`)**不是可选项**。
⚠️ **但本次这 3 条是 `adopt upstream promptRequestId`,⛔ 不是本机制投的**(`wakeups.jsonl` 显示本机制当日最后两次投递是 22:51:01)——
**⛔ 别把这次卡住算到投递头上**(教训同 P0-2:先要实物,再定因果)。
- **处置**:① 先按指纹确认是不是这一型;② 是 ⇒ **让客户端重新挂上该会话**(切走再切回/重开该会话窗口)通常即可排空;
③ ⛔ **别反复发消息试探**(那只会往队尾再堆一条,延长卡住时间)。
### P0-5a 🔴 **探针⛔ 不许"在日志里搜字符串" —— 它会命中你自己的取证回声**(★ 2026-09-30 当场踩到)
- **现象**:刚做好的 park 探针**立刻报了一次假命中**(`park=1 次 最近 00:36:15`),而那一刻并没有任何会话在卡。
- **根因**:**取证命令的输出会被写进同一份工作区日志** —— 我为了查这个坑跑了一次
`grep … parkInQueue …`,宿主把它记成 `[SandboxShell] ProcessOutput … content=… route=parkInQueue … hasWaiter=false`。
探针只要"行里同时含这两个子串"就命中 ⇒ **命中了自己的回声**。
- **修法**:判据必须**只认真正的记录行**,并**显式排除回显行**:
```python
def _is_park_line(ln):
return ("parkInQueue" in ln and "hasWaiter=false" in ln
and "[AcpView][PromptIterator] received prompt" in ln
and "ProcessOutput" not in ln and "content=" not in ln)
```
- 🔑 **推广(比这条本身重要)**:**任何"扫日志找关键字"的探针都有这个自污染风险** —— 只要有人(或你自己)为了排查而把那个关键字
打进日志一次,探针就会**永久**看见它。⇒ 一、**认结构不认词**(要求"记录行"的固定字段组合);二、**排除回显容器**
(`ProcessOutput` / `content=` / `Sandbox`);三、**必须先拿一条真记录 + 一条回声行做对照用例**(已固化进 `selftest.py`)。
### P0-5b 🔴 **本轮新增的两道自动护栏(`collabd.py --tick`)**
| 护栏 | 做什么 | 判据(⛔ 不看 rc,看这个) |
|---|---|---|
| **宿主卡住探针** `probe_host_park()` | 每轮**只读日志尾部 400 KB**,找 park 指纹(最近 30 分钟内才算)⇒ 落 `NEED-USER.md`(含"切走再切回"),节流 10 分钟 | 打印 `tick: … park=<n> 次 最近 <HH:MM:SS>` |
| **投递消费回查** `check_delivery_consumed()` | 投递成功后**不当作成功**:4 分钟(`CONSUME_GRACE`)内目标会话 `updated_at` 没晚于投递时刻 ⇒ 判「**没被消费**」⇒ 落 `NEED-USER.md` | 打印 `tick: … consume=waiting/consumed/unconsumed` |
🔴 **口径**:**「投出去」≠「它跑起来了」** —— 网关回 200 只证明对方**收下**,不证明**有人执行**。
⚠️ 这两条护栏**只能"发现 + 告诉用户点哪一下"**;根因(宿主客户端连接状态丢失)**AI 侧修不了**,⛔ 不许因此承诺"自动恢复"。
## P0-2 🔴 **「卡消息输出」的真因:会话日志撞上限被 dropped —— ⛔ 与"后台任务归属"无关**(2026-09-30 用户反证后重写)
- **现象**:会话里发消息后窗口**不显示内容 / 像是没反应**;用户看到的是"卡消息输出"。⚠️ 我自己(主会话 `fe146dd9`)就是当事人。
- 🔴 **实证真因(实物在档,⛔ 不是推断)**:**宿主的会话对话日志撞 ~10 MiB 上限后开始丢事件**。
· 该会话日志尾部原文:`diagnostic-log:dropped {"droppedLines":14261,"droppedBytes":1731465}`(UTC 12:06:44 = 本地 **20:06:44**);
· 其前最后一批正常记录是同一 `requestId` 的 `tool_call_update` **在毫秒级反复落盘**(本地 **16:29:53**,`completeAssistantStream:false`);
· 该文件 10,485,606 字节,本地 23:00 被 logcap sweep 挪为 `*.log.stuck-20260929-230043`;同日全机共 **6 个**这类满额文件。
⇒ **高频工具事件 / 大量输出 ⇒ 日志膨胀过上限 ⇒ 宿主丢弃 ⇒ 对话不再显示**。
- ⛔ **已作废的旧结论**(我 2026-09-29 写的,**留着会误人**):曾断言"任务记在会话名下 ⇒ 宿主认为该会话一直有长跑任务 ⇒ 拖住它",
**而当时唯一的"证据"只是"起守护的任务 id 随停工变 `completed`"—— 那只说明任务结束,⛔ 不构成因果**。
🔴 **用户反证(2026-09-30)**:`mcn-short-video` 的 `:8900` **就是会话后台任务**,一直挂着;**用户在那个会话里照样随便发消息**。
- ✅ **正确口径**:**卡不卡看"输出/事件量",与"是不是后台任务"无关。**
⇒ 会话后台任务**只要完全静默**(`stdout` 重定向到文件、⛔ 不往标准输出打长跑日志)**就可以安全长期运行**。
⇒ 会话内后台任务 vs 会话外起,**在"会不会拖累会话"这一项上没有差别**(旧表作废)。
- **处置**(日志已撞上限时):把 `<sid>.log` 改名挪开(⛔ 不删),宿主数十秒内重建并恢复写入 ⇒ 详见技能 `session-mechanism`(`references/forensics.md` §4)。
- 🔴 **⚠️ 本条先后被我改错过两次**:① 曾把"投递"写成"一条低频排期"(已由 **P0-3** 纠正);② 曾把"卡消息输出"归因到"后台任务归属"(本次纠正)。⇒ **写根因前先拿实物,⛔ 别拿"相关性 + 一个弱信号"当因果。**
- 📌 **本条原先内嵌一份「P0/P1/P2 常见坑速查清单」(占本条 79% 篇幅,把一条撑成 11.6 KB)** ⇒ 已拆成独立文件 **`references/99-速查清单.md`**(⛔ **内容逐字保留**,只搬家)。
## P0-23 🔴🔴 钩子超载 ⇒ **用户每一句话都被拦下**(2026-10-02 全工作区级事故,三次出手才修干净)
- **现象**:`UserPromptSubmit operation blocked by hook: …wb-result-hook.py: Hook timed out after 20000ms` ⇒ **工作区所有会话无法执行**(不是变慢,是**提交失败**)。
- **根因(实测读数,⛔ 非推断)**:`UserPromptSubmit` 宿主注册 **20s**,而该钩子在这条路上**同步串联**了三个子进程 ——
`--gap` 实测 **13.5s** | `--once` 硬超时 **12s** | `--tick` 实测 **13.5s**(`hook.log` 留痕:`16:12:38 done in 12819ms`);
同一事件还挂着**另外 5 个**钩子并发抢同一颗 CPU ⇒ 均值 13.5s 的活只要抖动一次就**越线**。
🔴 第二大致命点(同一雷的另一种形态):`PreToolUse ^Bash$` 上**同步**跑口令投递 `timeout=12` ⇒ **本机每条 Bash 命令执行前先等它**。
- **为什么"修了一版"没修好(15:31 那版)**:引入了 `_BUDGET` ���算 + `_bg` 后台机制方向是对的,
但为 `--gap` 保留了 `_sync` **兜底同步分支**(判据:缓存为空 ∨ 过期 >2×TTL)⇒ **最坏路径依然存在**。
⇒ 🔴 **教训:在秒级预算的钩子里,"兜底同步"是伪需求**。注入物是**建议**不是判据 ——
本轮拿不到就**少注入一段**(下一轮自然补上),⛔ 绝不为此等待。这是 fail-open 的正确姿势。
- **修法(三条硬规则 · 此后新增钩子一律照此办)**:
1. 🔴 **拿到结果才有用 ⇒ 才允许同步等**;否则**一律后台**(`_bg`:`Popen` + stdout 落**文件** + `DETACHED_PROCESS`,⛔ 不用 PIPE、⛔ 不 wait)。
判断句:「这个子过程的返回值,钩子会用吗?」答不上来 ⇒ 后台。
2. 🔴 **算术题先算再写**:`宿主注册值 - 解释器冷启动(实测 0.3~1.0s,波动不可控) - Σ子过程实测耗时 > 50%` ⇒ ⛔ 不许同步。
本机为证:20 − 1 − 13.5 = 5.5s 余量,而同事件另 5 个钩子在抢 CPU ⇒ **必越线**。
3. 🔴 **⛔ 别信"实测均值",要信"硬超时"**:`--once` 均值 0.5s 看着安全,但它的 `timeout` 是 **12s** ⇒ 抖动时照样吃掉大半预算。
4. 🔴🔴 **脚本自己必须带"自身硬闸"**(本次**没做这一步就是没根治**):前三式只修掉了**这一次的那三个具体活**;
**任何人(含之后的另一个会话)只要再往这条路上加一个同步子进程,故障就原样复发**,
而复发的第一现场是「用户说不了话」——**不可能等到事后排查**。
⇒ 落地:`set_budget()` 里起 `threading.Timer(daemon=True)` 守 `预算-0.5s`,到点 `log() 后 os._exit(0)`。
⚠️ 必须 `daemon=True`(否则解释器退出时被卡住 ⇒ 换一种形式超时);必须用 `os._exit`(主线程卡在 `subprocess.run` 时正常退出路径走不到)。
**fail-open 方向**:宁可本轮少注入一段(=什么都没发生),也绝不让会话被拦下。
- **验证(⛔ 必须出厂实测,⛔ 不用"看代码觉得快了"代替)**:`tmp/_verify_hook_timing.py`(**含"缓存已被删"的最坏路径**)
+ `tmp/_probe_all_hooks.py`(**同事件上每一个钩子**全量计时,⛔ 别只测报错点名的那一个)
+ `tmp/_health_after_hookfix.py`(**止血 25 分钟后复检**:常驻活着吗/后台堆积吗/耗时稳态吗/功能还在吗)
+ `tmp/_verify_hard_gate.py`(**硬闸实测**:注入一个 30s 同步慢活的副本 ⇒ **17.85s 自行退出**,距 20s 仍有 2.2s 余量)。
修后:最坏 **1.00s** / 常规 **0.35s**(25 分钟后复检仍 **1~27ms** 稳态)/ 全 8 个钩子 0.23~0.65s 全绿。
- 🔴 **配套发现(影响面,未处置、已上报)**:`wb-result-hook.py` 装在 **`~/.workbuddy/settings.json`(全局)** ⇒ 跑在**所有工作区的全部会话**上,
而该文件自己的 docstring 明写「⛔ 不要装到全局」。这就是「**所有**会话都中招」的放大器。
止血后(0.35s)全局已无害 ⇒ **未挪动注册位置**(避免影响面变更);若要收回成本工作区,改一处即可(⛔ 属影响面变更,需用户点头)。
## P0-24 🔴🔴 排期「哑火」判据:**补跑 ≠ 哑火**,且**「延迟」⛔ 不能拿「距今」冒充**(★ 2026-10-02 实测 · S10 取证 + S11 修)
- **病灶(不是"容差不够",是"判据把两件事混成一类")**:本机一次性排期**不到点即时触发**,由主机轮询/唤醒**补跑**(`automation_runs.metadata_json.runKind=missed`)⇒ 实测延迟 **0~11 分钟**。
原 `collabd.py health()` 只写「到点未触发」**一个词** ⇒ 补跑被一律判成哑火(`717cd210` 计划 13:42、实跑 13:42:15,**真延迟 0 分钟**却被报哑火)。
- **✅ 判据(三条缺一不可)**:① 计划时刻已过 **≥ 容差**(`misfire_grace_min`,默认 **30** 覆盖实测最坏 11 分钟)② `automation_runs` 里**零记录** ③ 才判哑火;有记录 ⇒ 写「延迟 N 分钟跑完」并**单列一段**(⛔ 别塞进 issues,否则下一个人又照着当故障查一遍)。
- 🔴🔴 **「延迟」必须 = 首次运行时刻 − 计划时刻**(`min(automation_runs.updated_at)` − `scheduled_at`)——
⛔ **不可用「距今」代替**。首版就犯了这个错:把 30 小时前跑完的排期写成「延迟 1954 分钟」(真值 7 分钟)⇒ **修判据时制造了新的假数据**。
⇒ 为此 `d["have"]` 必须从 `set` 升级成 **`{aid: 首次运行时刻}`**;时刻读不出就写「不猜」,⛔ 不填 0。
- ⚠️ **`scheduled_at` 实测只到分钟**(`2026-10-02T14:26`,无秒)⇒ 解析要 `%Y-%m-%dT%H:%M` 与 `%Y-%m-%dT%H:%M:%S` **两试**,只写一种会整批 `age=-1`(我第一版就吃了这个,候选数一度算成 **0**)。
- **堆积是另一件事,⛔ 别和判据混**:once 排期**跑完不回收**(`status` 仍 `ACTIVE`、`next_run_at=None`,含义是「没有下一次」不是「未调度」)⇒ 实测 **30 条已跑完仍挂在册**。
清理判据 = `schedule_type=once ∧ next_run_at 为空 ∧ 有 run 记录 ∧ 距计划 >30min`。
⚠️ `automation_update` **一次只能删一条** ⇒ 批量清理(29 条)**⛔ 别塞进无人值守棒**(不可逆 + 含主控线)⇒ 出清单、留一棒专做。
- **验证**:`tmp/_verify_misfire_fix.py` 六档(延迟补跑/真哑火/未到点/容差内/busy 跳过/时刻读不出)+ 真实库 `--once` 后读 `tmp/supervise-inbox/体检报告.md`
⇒ 修后 issues 由「满屏假红」收敛为 **2 条真哑火**(`87d55715`、`5e335e01`),29 条转 notes 且**真实延迟 0~11 分钟**。
- 🔴 **同源教训**:改判据的同一轮里,我先按"读错了列"下了结论(`r[0]` 其实是 `id` 不是 `rowid`)并据此改了 SQL —— **回读原文件才发现前提不成立**。
⇒ **先回读被改的那段,再落结论**;⛔ 别让"看起来讲得通的诊断"替代核对。
## P0-24 🔴🔴🔴 **注入物里写死命令** ⇒ 会话被**逼着做用户没授权的事**(比超时更隐蔽,且伤信任)
- **现象**(17:49 用户转述另一个会话的实况):用户问「**我没有说使用协作会话完成目标,为什么会创建会话**」。
那个会话答得对:它是照着**每轮自动注入的指令**做的 —— 注入里写死「⛔ **不要问用户**、立即建排期」,
它照做并已删掉。**根在钩子,不在那个会话。**
- **真凶(⛔ 不是措辞不当,是结构缺陷)**:`_gap_text()` 生成的文案里含「⛔ 不要问用户」这种**祈使句**,
而这段**成品文案被原样存进 `gap-cache.json` 的 `_text` 字段**逐轮复用
⇒ **无论用户当轮说了什么,每轮都在下同一条命令**。三个后果:
① 越权(用户没要求就建排期)② 重复(同名无去重,越排越多)③ 无上限/无过期(用户原话「越排越多」)。
- **修法(三条,⛔ 改文案治不了,必须动结构)**:
1. 🔴 **授权闸**:`_authorized_now()` —— **只认用户当轮原话里的触发词**(`GAP_TRIGGERS`)。
⚠️ 刻意**不用**「上一轮说过」「队列有缺口」「机制建议」当授权 —— 那些都是**系统自己的诉求,不是用户的**。
⚠️ 必须把当轮 `payload` 存进模块级 `_CUR_PAYLOAD`,否则判据读到空串、闸门形同虚设。
2. 🔴 **缓存存原始数据、不存成品文案**:`gap-cache.json` 改存 `_raw`(`--gap` 原始 JSON),
文本在**读取时现算**(`_gap_text(raw, authorized)`)⇒ 呈现永远跟当轮授权一致。
旧版缓存(只有 `_text` 无 `_raw`)**判为无效自动重刷**,⛔ 不做兼容迁移(迁移=把旧文案再投一次)。
3. 🔴 **未授权时只通报、不派活**:标题写「ℹ️ 现状通报(⛔ 不构成行动指令)」并**明写「⛔ 不要建任何排期」**;
授权时才输出「⚠️ 缺会话 ⇒ 自动拉起」,且要求**在回复里说清是依据本轮授权做的**。
另加 `_known_plan_names()` 读 `automations` 判同名在册 ⇒ 提示里标「⛔ 别再排一遍」。
- **判据(⛔ 别靠"文案读起来像没问题"验收)**:`tmp/_verify_auth_gate.py` 跑三个场景 ——
A 未提协作 ⇒ 必须出现「不要建任何排期」且**不得**出现「自动拉起」;B 明确授权 ⇒ 才出现派活指令;
C **回灌旧版缓存** ⇒ 旧文案不得再注入(专门防"改了代码但旧缓存还在投")。修后 A/B/C 全 ✅,耗时 0.37~1.10s。
- 🔴 **顺带查出的老毛病**:排期**越删越多** —— `automations` 在册 140 条、ACTIVE 135 条,
且两条 17:41/17:48 的越权排期**仍为 ACTIVE**(那个会话以为删了,其实没删成功);
**17:48 那条已被触发** ⇒ 删排期**拦不住已开的会话**(这是它自己提示的那点,已核实为真)。
⇒ 清扫脚本 `tmp/_purge_unauthorized_schedules.py`(先 SELECT 再 UPDATE,`busy_timeout=15000`):
停用越权族 4 条 + 回收僵尸排期(`ACTIVE` 且 `next_run_at IS NULL` 且 `scheduled_at < 当天`)**89 条**
⇒ ACTIVE **135 → 42**,越权残留 **0**。
- 🔴 **越权比超时严重**:超时只是**慢/被拦**,越权是**替你做决定** ⇒ 消耗信任,且**不易被发现**
(表现像"机制很勤快",甚至像在帮忙)。
## P0-26 🔴🔴 **`automation_update` 的 delete/list按「属主」过滤 ⇒ 越界时「报成功、实则零动作」**(★ 2026-10-02 实测 · S12 清理一次性排期)
- **现象**:S12 要清29~30 条「跑完却仍在册」的一次性排期。逐条 `automation_update(mode=delete)` 删**我这条属主名下**的 6 条
⇒ 真删掉了(回读 `deleted_at` 有值);但对其余条**同样返回 `success:true` + `"already deleted or does not exist"`**,
回读 `deleted_at` **恒为 NULL** ⇒ **一条都没删**。若只看返回值,会误判"全清完了"。
- **真凶= `automations.owner_user_id`(属主边界)**:本机并存**两个属主账号**。实测 `list` 只回**当前属主**名下的排期
(本例 2 条),而库里未删共 30 条;`delete` 也只能删当前属主的。跨属主 ⇒ 工具**看不见、也删不掉**,却仍回 `success:true`。
- ✅ **两条硬纪律**:
1. ⛔ **绝不把 `delete` 的 `success:true` 当已删** —— 每次删完必须回读 `deleted_at`(`coalesce(deleted_at,'')=''` 才算未删)核对。
2. ⛔ **`list` 的返回行数 ⛔ 不能当在册总数**(本例 `list` 2 条 vs 真实 30 条)—— 判断"还有没有"要用只读 SQL 扫 `automations`。
🔴 顺带:`list` 还可能**只回最近若干条**(本例早期返回 8 条)⇒ **别把"list 变短了"当"删掉了"**。
- ⚠️ **盘点前先分桶,别按 prompt 里的数字照抄**:本棒prompt 说「清单里有 12 条属主控线(`主控 ·` 开头)」,
实测按前缀只有 **4 条** ⇒ **数字必须自己只读盘点复核**(否则会误删/误保)。
- 🔴 **同类静默假成功(同源教训)**:本棒 `--report` 被当「刷新体检报告」用,其实是「上报需求状态(`pending|running|done|blocked`)」子命令
⇒ **命令名 ≠ 用途**,⛔ 用前先看 usage。
- ⚠️ **验收门也会挡住刷新**:`collabd.py` 的体检报告受两道门控制 ——
① `health()` 开头 `skipped=V["busy"]`(**自动化会话在跑 ⇒ 恒 skipped**)+ ② `health_every`(默认 300s)节流戳
(`<WS>/.workbuddy/collab/collabd-state.json` 的 `health_at`;置 0 可强刷,但 busy 门仍拦)
⇒ **报告不刷新时,改按同一判据独立复算读数**,⛔ 别把「报告还是旧的」当「删除没生效」。
- **遗留**:S12 因此标 `blocked-by-owner`(⛔ 不标 done)—— 余 24 条候选(非主控 20 + 主控 4)须在另一属主账号下执行。
## P0-27 🔴🔴 **域目录摆错位置 ⇒ 所有独立域压成同一个键,域锁彻底失效**(★ 2026-10-02 实测 · 执行会话独立域)
- **背景**:用户定案「**执行会话必须独立域运行** —— 所有写操作都在单独文件夹下(webserver / desktop /
phone app /独立插件 / 产品原型各自独立目录,放在工作区对应独立文件夹下),**跨目录只能只读**」,
理由逐字:「**开多个会话都操作一个域的文件只有一个会话能执行,别的只能干等**」。
- **病根(实测,非推断)**:域键=「路径里**第一个**出现在锚点词表里的段 + 它的**下一段**」,
而**工作区目录名本身就在锚点词表里**(`handoff-guard.sh:66` `_ANCHOR_SEGS`,`ai1net-dsh-server` 排最前)
⇒ **域键恒=`<工作区名>/<工作区下的第一层目录>`,与再往下钻几层无关**。四条实测读数:
| 路径 | 域键 | 结论 |
|---|---|---|
| `…/ai1net-dsh-server/domains/webserver` | `ai1net-dsh-server/domains` | ❌ 全部撞键 |
| `…/ai1net-dsh-server/domains/desktop` | `ai1net-dsh-server/domains` | ❌ 同上 |
| `…/ai1net-dsh-server/webserver` | `ai1net-dsh-server/webserver` | ✅ 独立 |
| `…/ai1net-dsh-server/交付物/webserver` | `ai1net-dsh-server/交付物` | ❌ 同样撞键 |
- 🔴 **我第一版就踩了这个坑**:把域目录设计成 `domains/<域名>`(一个整齐的公共父目录)⇒
**看着最合理、实际把所有域压成一个键** ⇒ 等于把用户方案判死。是**验收脚本当场抓到的**
(断言「两个不同域算出同一键」)。⇒ **教训:域键类判据 ⛔ 必须写"算出来是什么"的断言,
⛔ 不能只验"函数返回了非空"** —— 前者抓到真缺陷,后者一路假绿。
- ✅ **正解**:域目录**直接占工作区第一层**(`<WS>/webserver/`、`<WS>/desktop/`)—— 恰好就是用户原话
「放在工作区对应独立文件夹下」。`collabd.py` 里`DS_DOMAIN_SUBDIR = ""`(**刻意留空,⛔ 别改成 "domains"**)。
- 🔴 **推论(同样重要)**:想在同一项目里并行两个会话,⛔ **在域目录里再分层没用**(还是同域)——
必须给它们**两个不同的第一层目录**。
- ✅ **配套已落地**:`ds_domain_key()` ⛔ 必须与 `handoff-guard.sh` 的 `norm_domain()` **逐字一致**
(验收时真调 shell侧那条函数,⛔ 别在Python 里重写一遍自验自己);派活前`check_domain_free()`
读锁表判占用,被占 ⇒ **换域名**(⛔ 不是等),且**必须排除自己的锁**(否则锁主本人被自己挡住);
锁表读不到 ⇒ **fail-safe 判全部不可派**(这正是用户那句「别的只能干等」的机制面)。
- ⚠️ **顺带查出的既有不一致**:`handoff-guard.sh` 用 `scripts skills`,
`lock-guard-hook.py:58` 用 `07-scripts 08-skills 05-交接单`。
⇒ **工作区下的路径两边一致**(都先命中工作区名)⇒ 不影响工作区内的判据;
但**代码仓根目录下**的路径两边会算出不同键 ⇒ **已存在假绿风险**。`collabd.py --domain-status` 会报这个差异。
## P0-28 ⚠️ **CLI 分支缺 `return` ⇒ 一次性命令落进常驻模式、永久挂住**(★ 2026-10-02 实测)
- **现象**:`collabd.py --where` 打印完路径后**不退出**,进程永久挂住(`timeout 25` ⇒ `rc=124`)。
- **病根**:`if "--where" in sys.argv:` 分支打印完**漏了 `return`** ⇒ 继续往下走 ⇒ 命中"无参数=常驻"
的兜底分支 ⇒ `--supervise` 式循环跑起来。
- 🔴 **怎么确认是既有缺陷而不是自己改坏的**:拿**改动前的备份**跑同一条命令,
同样 `rc=124` ⇒ 结论坐实(本条正是靠这个对照法才没误判成回归)。
- 🔴 **通则**:任何 `--xxx` 这种**一次性查询分支**,打印完**必须 `return`**;
否则它会静默变成长跑进程 —— 症状是"命令没输出完/卡住",很容易误判成环境问题。
## P0-29 🔴 **验钩子必被「300 秒节流」吃掉 ⇒ 看起来像"代码没生效"**(★ 2026-10-02 实测 · 同族第 2 次)
- **现象**:验收脚本三个场景连跑,场景②注入**完全为空**。第一反应="判据写错了 / 代码没生效",
手工复现那一段逻辑却**全对**(`read_env_stamp` 确实返回 `{}`、分支确实会进)。
- **真凶**:`skill-load-guard.py` 的 `_cooling(workdir, sid)` —— **同 `session_id` 300 秒内只放行一次**。
三个场景用同一个 `sid` ⇒ 后两个被**静默挡在早退分支** ⇒ `ctx` 恒空。
- 🔴 **危害不在这一个钩子**:「没输出」与「没检查」**长得一模一样** ⇒ 一旦顺着"钩子坏了"往下查,
会把时间全花在读代码上,而真因是**自己造的自检环境**。本轮就是这样绕了一圈。
- ✅ **两条硬纪律**:
1. 验任何带节流/去重的钩子,**每次调用必须换一个唯一标识**(本例=`session_id`)。
2. 验收脚本里加一道**硬门**:`注入为空 ⇒ 立即判失败`(本轮做成 `need_inject()`),
⛔ **绝不许**把"没输出"当成"没打扰 / 一切正常"静默通过。
- ⚠️ 同族第 1 次:验钩子前**先看触发词表**(prompt 不含触发词 ⇒ 根本不进注入分支)。
⇒ 合起来一条通则:**钩子的任何"没动静",先怀疑自检环境(触发词/节流/作用域/cwd 形态),
再怀疑代码。**
## P0-30 🔴 **「pid 文件写着」≠「进程活着」;查显窗/作用域只能看 AST,看 grep 与注释都会被骗**(★ 2026-10-02 实测)
用户问「一闪一闪的程序是不是协作会话开的」,顺手挖出三条**同源**的误判路径:
- ① **陈旧 pid 文件**:交付结论说「当前存活的 supervise(pid 40900)心跳新鲜(已稳跑 10 分钟)」,
实测 `tasklist //FI "PID eq 40900"` ⇒ **「没有运行的任务匹配指定标准」**。
而 `supervise.pid` **至今仍写着 40900**、`_supervise.log` 停在 4h43m 前 ⇒ **常驻早就死了**。
✅ **判存活必须双查**:`tasklist` + **心跳戳新鲜度**(`stat -c %Y` 比`date +%s`),
⛔ **绝不只读 `.pid` 文件**(它只记录"上次认为在跑",⛔ 不代表现在)。
- ② **「常驻」与「后台任务」是两回事**:现象看着像"有个程序在常驻闪",实际常驻 17:12 就死了,
21:2x 的动静是**钩子每轮用户提交拉起的一次性 `--tick`/`--once`**(跑 1~2 秒就退)。
⇒ **「一闪一闪」的频率=你说话的次数,不是某个常驻进程的轮次** —— 判据完全不同。
🔴 故:**排期/自愈判据里「pid 活 ∧ 心跳新鲜」两条都要**,缺一条就是这种假活。
- ③ 🔴🔴 **grep 单行判不了跨行参数**:`grep -v creationflags` 把 `board.py:1177`、
`board_ext.py:555` 这两处**已经带** flags 的调用误报成"漏网"(参数写在下一行)——
我第一遍就被骗了。✅ **必须用 AST**:遍历 `subprocess.run` 节点看有没有 `creationflags` 关键字。
⚠️ 同族:查"是不是限死在本工作区"**也不能看注释**(四处 `ai1net-dsh-server` 里三处只是文档字符串)。
⇒ **通则:判"某能力有没有生效/被限死" ⇒ 一律看可执行代码(AST/真跑),⛔ 不看注释、不看单行 grep。**
## P0-31 ⚠️ **闪窗(控制台程序弹黑窗)在 Windows 上的完整修法清单**(★ 2026-10-02 实测根治)
- **两类药,缺一不可**:
1. **常驻/会重生的 daemon ⇒ 用 `pythonw.exe`**(GUI 子系统,Windows **永不**为其分配控制台)。
`sys.executable` ⇒ `python.exe`(控制台子系统)⇒ 被 detach 后**必新开控制台** ⇒ 每次重生闪一次。
⚠️ **`DETACHED_PROCESS` 单独用不够**:它只是"不继承父控制台",控制台 exe 仍会被新分配窗口。
2. **一次性短 spawn(`netstat`/`taskkill`/`--tick`)⇒ 补 `creationflags=CREATE_NO_WINDOW(0x08000000)`**。
⚠️ 常驻那批可加 `| DETACHED_PROCESS(0x8) | NEW_PROCESS_GROUP(0x200)`;
⛔ **不要** `CREATE_BREAKAWAY_FROM_JOB`(会被拒)。
- 🔴 **自检必须用 AST 全量扫**(⛔ 别 grep):本轮靠它捞出 `board.py:1212` 的 `taskkill`——
那是七文件修完后**最后一处漏网**(`--takeover` 路径上的)。
- ⚠️ **不是所有缺 flags 都该死**:`NeoData金融搜索服务/scripts/query.py:51` 是 **pip 装包**
(只在缺 requests 时跑一次、非周期)⇒ 与闪窗无关;`selftest.py` 7 处是**自检脚本**。
⇒ **判据="这个调用会不会被周期/重生路径反复执行"**,⛔ 不是"缺了就是错"。
⚠️ 第三方技能包缺 flags ⇒ **只报不动**(⛔ 越界改别人的东西)。
---
## P0-32 🔴🔴 **把「起法不对」误推成「本机不可能有长跑进程」⇒ 建议整个建立在假前提上**(★ 2026-10-02 实测,同族最贵的一次)
**现象**:起常驻看板失败三次(`start /b`/`start "标题" /D`/`cmd /c start.bat`)⇒ 我下了结论
「**本机不存在跨静默期存活的进程**」,并据此建议「**改用排期当时钟**」。
**用户两次纠正(逐字)**:
- 「**谁告诉你的 后台进程活不过几十秒,那协作看板如何开启的 …这个工作台是如何一直运行的**」
- 「**怎么启动进程 技能中都没有记录吗,之前都启动了这么多次**」
**真因(实测坐实)**:⛔ 不是「本机不可能长跑」,是「**载体不同**」——
| 载体 | 实测结果 |
|---|---|
| `subprocess` / `start /b` / `start.bat`(**从工具调用进程树里起**) | ⛔ 活不过当轮;两条探针(`start /b` 与 `Popen+DETACHED`)**在同一时刻一起停**(tick 67/64)⇒ **与起法关键字无关**,是父链 |
| **WorkBuddy 自己的后台任务**(工具 `run_in_background`) | ✅ **能长跑**:看板 8788(pid 43176/HTTP 200/119 704 B)+ MCN 8900(pid 14056/HTTP 200/1 797 B)均现算 |
| **用户从桌面双击起**(在用户登录会话里) | ✅ **能长跑**:`mcn-work-shop/start.bat`;另有 4 个 `node.exe`(`会话名=Console`)长期存活 |
**🔴 教训(判据级)**:
1. **⛔ 别用「这一种起法失败」推「本机不可能」** —— 至少验**三种载体**(工具调用树/宿主后台任务/用户登录会话)再下全称否定。
2. **反证优先于推断**:用户一句「那个工作台是如何一直运行的」就是现成反例 ⇒ **该先去找它怎么起的**(`start.bat` 一眼看到 `start "" node.exe`),⛔ 别先写一段"本机不可能"的理论。
3. **查证方向错了要整条撤回,不只是改措辞**:基于假前提给的建议(「改用排期当时钟」)⇒ **整条撤**,⛔ 不许"保留但加个注释"—— 那会让下一个人照着错的前提做决策。
4. **改了代码 ≠ 改了知识**:这类结论**至少落在 4 处**(`SKILL.md §2` 正文/`SKILL.md frontmatter last_change`/`architecture.md` 结论表+§5-1/`collabd.py` 用法头)。⛔ **漏掉 `last_change` 最坑** —— 它是别人加载技能时**第一眼**看到的。
**🔴 配套一:「起完了」不等于「起了」—— 必查三样**
`netstat` 有 **`LISTENING`** | `curl` 得 **`200`** | `tasklist` 进程在。
- ⛔ **`TIME_WAIT`/`FIN_WAIT_2` 不算**(历史连接残留)—— 本轮就差点把 `FIN_WAIT_2` 当"服务在"。
- ⛔ **打印了"已启动"不算**(`start.bat` 那次打印了启动消息、8900 始终无 LISTENING)。
- 🔴 **pid 文件写着不算**(P0-30 同族):`supervise.pid` 写着 40900,实际 17:12 就死了。
**🔴 配套二:`pythonw.exe` 必须配日志重定向**
GUI 子系统 ⇒ 无 stdout ⇒ **静默无痕**,连"起没起"都查不到 ⇒ `subprocess.Popen([PYW,…], stdout=log, stderr=log)`。
(`tmp/board-serve.log` 里那句 `看板已起:http://127.0.0.1:8788/` 就是这么验的。)
---
## P0-33 ⚠️ **「改了没生效」的第三种形态:配置读的不是你以为的那份 / 配置根本不重读**(★ 2026-10-02 实测)
**现象**:两处「改了配置但看板纹丝不动」,成因**完全不同**,⛔ 若不取证会把两次都归成同一个错。
1. **配置根本不重读** —— `board.py` 的 `C`(配置字典)是**模块级加载一次**,⛔ **不每次快照重读**。
⇒ 改 `collabd.config.json` 后**必须重启看板**。
⚠️ **这是 P0-17「改了看不见」漏记的一面** —— 之前只记了「改 `board.py` 代码要重起」,⛔ 漏了「改配置同样要重起」。
⇒ 判据:看进程启动时间 vs 源文件 mtime(`board.py` 的 `C` 无 re-read 路径)。
2. 🔴🔴 **读的是另一份配置**(更隐蔽)—— 启动服务时**没设 `COLLABD_CONFIG`** ⇒ 回落**技能包内**的
`scripts/collabd.config.json`,⛔ 而不是工作区那份。
⇒ 我改了工作区的 `board_ext` 却"没变",根因在此。
⚠️ 与「`goalctl.py` 按 `__file__` 推层级 ⇒ 静默指向技能包上级目录」**同族**。
✅ **修法**:启动脚本**显式** `env["COLLABD_CONFIG"] = <WS>/.workbuddy/collab/collabd.config.json`。
✅ **判据**:改完直读 `/board.json` 里那个由配置决定的字段(这里是 `front._missing`),
⛔ 不看页面像不像(P0-17)。
3. **连带发现**:`selftest.py` 每轮跑完会在**技能目录**自产自消一个 `scripts/collabd.config.json`
⇒ 「技能侧零项目串」那条 FAIL 的来源。⚠️ 它是**自消**的,⛔ 不是遗留垃圾;
但它恰好是 2 的**受害者**(服务回落读到它)。
**🔴 判据(通用)**:凡「我改了 X,行为没变」⇒ ⛔ 别先怀疑 X 没改对,
**先查三样**:① 进程/服务**启动时间 vs 改动时间**;② 它读的**到底是哪一份**配置/文件(⛔ 打印路径,别凭"应该是我改的那份");
③ 有没有**缓存**(模块级加载/结果缓存/ETag)。
## P0-34 ⚠️ **自己手里的独占锁会变成别人的路障**(★ 2026-10-02 实测)
**现象**:派出去的执行棒跑 30 分钟**零改动**,只报「旧的全局锁仍被占(`[主会话]-记录常驻起法`)」。
⇒ 那把锁是**我自己**锁了 **64 分钟**没释放。
**真因不是它偷懒** —— 它按 prompt 的「抢不到锁 ⇒ 停手报告」执行,**连试 4 次都失败就正确退出**,
甚至还额外报出真问题(`preflight-lock.sh` 把目标文件判为【E】机制层 ⇒ 我给的 `--domains` 认领方式对机制层不成立)。
**🔴 判据**:
1. **锁的生命周期 = 任务的生命周期**(2026-09-14 用户明令)—— 做完**必须** `--release-exec`。
⚠️ 干等自己那把锁是最贵的浪费:别人进不来、我也忘了它在谁手上。
2. **机制层文件 ⇒ 认领必须 ⛔ 不带 `--domains`**(带域=域锁,对机制层不成立);
正确姿势:`--claim-exec "<名>"`(不带域)= 全局独占。
3. **派棒前先 `--status` 看锁**;⛔ 别让自己成为别人的路障。
4. 🔴 **执行棒"零改动"先查锁,别急着判它失败** —— 判据=
`automation_runs.thread_title` 里**它自己写的停手原因**(本轮那句「状态已跑…域锁抢锁失败…」是完整取证)。
---
## P0-35 🔴🔴 **「每轮 unlink 一个文件」会撞宿主 SafeDelete 批量删除护栏 ⇒ 常驻被系统终止**(★ 2026-10-03 实测,同 P0-6 的根因第二次复发)
**症状(本轮现场)**:
- 常驻 `--supervise` 起来 **21 秒后 `rc=1` 退出**,`supervise-bg.log` 只有一行:
`[safe-delete][SAFE_DELETE_BULK_CONFIRM_REQUIRED] {"count":172,"threshold":50,"scope":"turn","targets":["…\\TO-MAIN.md"],"targetCount":1}`
- 而**心跳、pid 文件都还在**(最后一条 `round=3`)⇒ 第一反应会误判成"我改坏了代码"。
**真因**:宿主有 **SafeDelete 批量删除护栏** —— 按"**本轮删除动作**"计数,达 **50** 即要求确认并**拒绝**。
我写的退役清理是 `if TO_MAIN.exists(): TO_MAIN.unlink()`,**看着很安全**(文件不存在就不删),
但实际是**172 次删除动作**。三个原因叠加:
1. `exists()` → `unlink()` 之间有**窗口**(TOCTOU);
2. **投影轮(`mutate=False`)与常驻轮(`mutate=True`)是两个进程各跑一遍** ⇒ 判据被重复执行;
3. 护栏计的是**动作数**,⛔ 不是"真的删掉几个" ⇒ **失败/异常也算一次**。
**修法(判据级)**:**"一辈子只删一次"落成状态位**,⛔ 不是靠"检查文件在不在"。
if mutate and not st.get("to_main_retired"):
try:
if TO_MAIN.exists():
TO_MAIN.unlink()
st["to_main_retired"] = True # ← 关键:删过(或已不存在)就**再不碰这个文件**
except Exception as e:
log("…(⛔ 不重试,避免撞删除护栏):%s" % e)
**🔴 判据(写下来防复发)**:
1. **常驻里任何"周期性写/删"的文件,稳态下每轮的写删次数必须 = 0** ——
「降低频率」**不是修法**(P0-6 已经吃过一次:那套"快速否决"省不掉,因为内容一直变)。
2. ⛔ **别用"文件在不在"当幂等判据**(TOCTOU + 跨进程重复 + 异常也计数)⇒ 用**状态位**。
3. 🔴 **本条与 P0-6 是同一个坑的第二次复发**(`wake.lock` 那次也是"稳态下每轮都在 unlink")
⇒ 见到 `SAFE_DELETE_BULK_CONFIRM_REQUIRED`,**第一反应是"我在每轮删东西"**,⛔ 不是查代码是否崩了。
---
## P0-36 ⚠️ **判"某函数体内没有调用 X"必须走 AST,字面 grep 会被 docstring 永久假红**(★ 2026-10-03 实测)
**现场**:退役投递段后,我写了个判据「`supervise()` 函数体内零个 `_deliver_str(` 调用点」,
用 `src.split("def supervise(")[1].split("\ndef ")[0]` 切函数体再 `grep` —— **报红**。
真因:**函数体第一段就是那段 30 行的退役说明 docstring,里面必然提到 `_deliver_str()`**
(不写清楚"删了什么"= 读者不知道删了什么)。
🔴 **这是"判据"层的自相矛盾**:**要说明"已删除 X"就得提到 X** ⇒ **任何 `grep "X(" in <该函数体>` 永久假红**。
**正解(已落地 `selftest.py::_calls_in()`)**:
def _calls_in(src: str, fname: str) -> set:
import ast as _ast
tree = _ast.parse(src)
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>"}
**🔴 判据**:
1. **判"有没有调用"** ⇒ **AST**(只认 `Call` 节点的 `func` 位);⛔ 判"有没有出现"才是字面 grep。
2. ⚠️ 滤 `#` 开头行**不够** —— docstring、`'''` 块注释、字符串字面量里的字样都会骗过你。
3. ⛔ **不许因为字面判据假红就把断言改弱**("改成 `deliver=False`"=换个说法说同一件事,P0-13 同族)
⇒ 换**可证伪的判据**(本轮用的是「**真调用** `supervise(deliver=True, mutate=True)` ⇒ `deliver.skipped`
必须为 `retired-20261003`」——⛔ 改实现就会红,不是恒真)。
---
## P0-37 ⚠️ **「一个角色退役了」与「承载它的那个进程也退役了」是两件事**(★ 2026-10-03 实测)
**现场**:常驻 `--supervise` 原本**只有两个职责**(① 队列投递 + 唤醒 ② 建检查会话排期)。
投递退役后我一度想**连常驻一起删**(既然它不投递了)。查完发现 ② 那条腿**只有它承载** ——
`maybe_spawn_check_agent()` 是**唯一**建 `[检查]-…` 排期的地方,而检查会话**只能靠排期开**(⛔ 开不了别的通道)。
**🔴 判据**:
1. 删一个组件前先答一句:**「它现在还承担什么?那些职责另有谁承载?」**
——「它以前主要做 X」⛔ 不等于「它只做 X」(本轮就是靠这句话漏看了 ②)。
2. **职责退役 ⇒ 改名 + 改副标题,⛔ 不等于删节点** —— 架构图上那格画着「常驻投递 · 一直运行」
而实际那两段已删,就是 **P0-25「服务自报健康 ≠ 服务有内容」**的活标本。
本轮改成「建检查会话排期 · 一直运行」+「⛔ 投递已于 2026-10-03 退役」两行。
3. ⚠️ **反例同样要认**:`--tick` 分支里的 `check_delivery_consumed()` **真该删** ——
它的判据 `st["wake"]["expect"]` **只由已删的 `_deliver_str()` 写** ⇒ 恒返回 `{}` ⇒ 纯空转。
⇒ **判据是「它的输入还有没有生产者」**,⛔ 不是「它看起来还有用吗」。
## P0-38 🔴 **「改看板要不要重启」有四类答案,混成一句话就是误导**(★ 2026-10-03 实测)
**症状(我自己的错)**:用户问「看板每次修改都不处理看板」,而我上一条回复把
「改了 `collabd.config.json` **必须**重启」写成了「改看板都要重启」⇒ **把 HTML 和配置/代码混成一类**。
用户一句话点破的是**分类错误**,不是"我忘了重启"。
**真因(现跑取证,不是推断)**:判据不在"文件是什么",在**那个文件被谁怎么读**。
| 改什么 | 要重启吗 | 依据(代码行) |
|---|---|---|
| `assets/board.html` | ⛔ **不用**(只需刷新页面) | `board.py:1293` `serve()` 里 `html_p.read_bytes()` —— **每次请求实时读盘**;且 `/board.json` 带 `html_sig`(`mtime.size`)⇒ 页面自动重载 |
| `scripts/board_ext.py` | ⛔ **不用** | `EXT_CACHE` 按 `(mtime, size)` 签名**热重载**(`board.py:855-870`) |
| `scripts/board.py` 自身 | ✅ **必须** | 进程里跑的是**启动那一刻载入**的旧代码(P0-17 本体) |
| `collabd.config.json` | ✅ **必须** | `C = _cfg()` 在 `board.py:81` **模块级执行一次**,⛔ 无 re-read 路径(P0-33 第 1 条) |
**现跑实证**(`tmp/_probe_hotreload.py`,改 `<title>` 插标记再还原,⛔ 未重启任何进程):
```
① 改前 页面含标记? False(应为 False)
② 已改 HTML(⛔ 未重启任何进程)
③ 改后 页面含标记? True
⇒ 结论:改 board.html **不需要**重启看板
④ 已还原 HTML(md5 校验:一致 ✔)
```
⚠️ 选 `<title>` 是因为 `selftest` 的版面纪律判据**一条都不查它** ⇒ 不会被自测噪音干扰。
**🔴 判据(通用)**:回答"要不要重启"之前,先答一句**「这个文件是谁在读、什么时候读」** ——
`read_bytes()` 每请求读 ⇒ 不用重启;**模块级加载一次** ⇒ 必须重启;
**按 `(mtime,size)` 缓存** ⇒ 不用重启但要等签名变。
⛔ **别把「改了 X」与「改看板」当成同一件事**(本轮就是被这句话带偏的)。
⚠️ 与 P0-17/P0-33 同族但**方向相反**:那两条讲"该重启却没重启",本条讲"⛔ 不该重启却去重启了" ——
**重启不是万能药,没分清就重启=白花一轮 + 打断正在看页面的人**。
---
## P0-39 🔴🔴 **自测判据钉在「测试夹具」上 ⇒ 用例内读数 ≠ 现网读数(恒绿与恒红都是假象)**(★ 2026-10-03 实测,同族第 2 次:上一次是 `t_execution_doc`)
**症状**:新增用例 `t_tab_peer_workspace` 的「现网真读数」项报
`goal_files()=1 格(peer 0 个,active 1 个)`,而**同一时刻命令行直接加载是 3 格**。
(⛔ 第一反应「是不是代码没生效」=错:代码是好的,**是判据读错了世界**。)
**真因(看代码,不猜)**:`selftest.py::imp()` 会**先设 env 再加载模块** ——
```
os.environ["COLLABD_CONFIG"] = <TEST_WS>/collabd.config.json
os.environ["DSH_COLLAB_WS"] = <TEST_WS>
```
而 `board.py:92/103` 的 `CFG_P`/`C = _cfg()` 是**模块级**求值 ⇒
用例里**无论怎么 `exec_module` 加载 `board.py`,读到的都是测试那份配置**
⇒ 眼里当然只有一个测试 INBOX(1 格)。⚠️ 用例前面调过 `imp()` ⇒ env 里**还留着**测试值。
**修法(三条,缺一不可)**
1. **加载前换成目标配置、用完还原**(`_old = {k: os.environ.get(k) …}` + `try/finally` 逐个还原)
—— ⛔ 不还原 ⇒ 污染后续用例,且这种污染**不报错、只让别的用例莫名飘**。
2. **在真磁盘上造目标**(`tempfile.mkdtemp()` 里各放一份 `goal.json`)
⇒ 读数来自**真文件**,⛔ 不从夹具推导。
3. **加变异对照**:把 `goal_files()` 里的 `for _pw in _peer_workspaces():` 改成 `for _pw in []:`
⇒ 用例**必须报红**;还原后 **md5 校回原值**再复绿。
**现跑实证**(本轮,`-k 跨工作区`)
```
变异后:
✗ ⑧ 现网真读数:登记 1 个 peer ⇒ goal_files()=1 格(peer=[],active 1 个)
✗ ⑩ 生产侧:生产配置登记 2 个 peer ⇒ 1 格(peer=[],active 1)
✓ ⑨(按设计仍绿 —— 它只守「配置可关」那一侧,抓不住代码坏 ⇒ 证伪只看 ⑧)
还原后:md5 与变异前逐字一致 ⇒ PASS 61 / FAIL 0
```
**🔴 判据(通用)**
· 「现网真读数」类用例 ⛔ **不许复用 `imp()` 注入的环境** —— 那套环境是给**代码路径**用的,⛔ 不是给**读数**用的。
· 判「配置缺省 ⇒ 回落旧行为」的那一条**只守配置侧**:代码被破坏时它**照样绿**(两边都退到同一结果)
⇒ ⛔ **别把它当证伪项写进标题**;证伪必须另有一条「配置开着、代码却没接 ⇒ 报红」的断言。
· 生产侧 ⛔ **不硬编码路径**:读不到就记 `SKIP:…`(⛔ 不假装通过);跑法
`DSH_COLLAB_WS=<真工作区> python selftest.py -k <关键字>`。
· 🔴 **一个用例绿了,先反问一句:它读的是生产,还是测试夹具?**
⚠️ 与 **P0-13(判据要能证伪)**/**P0-36(AST 才可判"函数体内没调用 X")** 同族:
那两条治「判据恒真」,本条治「**判据读的不是它以为的那个世界**」——
恒真和读错世界,**表现出的"绿"一模一样**。
---
## P0-40 🔴🔴 **加了 tab 却只换了标题 —— 数据源没跟着「格」走 ⇒ 看板在说假话**(★ 2026-10-03 实测)
**症状(用户报障逐字)**:「**选择另一个工作区目标 tab 下面没有显示对应工作区目标和执行情况**」
⇒ 我第一反应是「没显示」—— **方向就猜错了**。实测是**显示了别的工作区的数字**。
**真因(看代码,不猜)**:`build()` 里的 `tasks`/`srows`/`st` **只取一次、所有格共用**
(`board.py:1443` 只读一次库、`1449` 那个列表推导里逐格传的都是同一份),
**只有 `goal` 跟着格换** ⇒ 三格的 `labor`/`sessions`/`progress` **md5 完全相同**:
```
[0] 本区 labor md5=95c77dbf sessions md5=8be394bd
[1] peer-1 labor md5=95c77dbf sessions md5=8be394bd ← 与本区逐字相同
[2] peer-2 labor md5=95c77dbf sessions md5=8be394bd
```
界面上那两格挂着「会话协作测试1/2」的名字 ⇒ **把本工作区的执行情况说成了对方的**。
⛔ **假数据比空白危险**:空白会被追问,假数据会被当成结论直接用。
**修法(三条,缺一不可)**
1. **每格的数据源必须跟着格走** ⇒ peer 格只喂 `_peer_tasks()`/`_peer_srows()`/`_peer_state()`,
三者**只读对方目录下的文件**;读不到 ⇒ **空 + 界面如实说明**(`peer_scope` + 前端 `renderPeerNote()`),
⛔ **绝不拿本工作区的数据补位**。
2. **必须补捞** ⇒ `_session_rows()` 取的是**全库最近 50 条**(按 `last_activity_at` 倒序)
⇒ 对方工作区的会话**一条都不在里面** ⇒ peer 格显示「0 条」=**假象**(对方主会话明明 `working`)。
⇒ 单独 `_peer_session_rows()` 按 `cwd` 精确补捞(SQL 里 `replace(cwd,'\','/')=?`,⛔ 别拉到 Python 侧全表扫)。
⛔ **不许**为了捞它把全局 `limit` 放大 —— 那会让**本工作区**的 `others_running` 计数暴涨(为一个新功能改坏老行为)。
3. **作用域要跟着换** ⇒ `_sessions()` 里有一道 `_ct == _wstail`(`cwd` 末段 == 工作区名)的**硬过滤**,
而 `_wstail` 来自 `project_scope()` 的**全局 `WS`** ⇒ 对方会话**必然**被排除
⇒ peer 格要把 `sc["workspace"]` 覆盖成对方根(**只覆盖这一个键**,其余判据仍按对方 `goal.json` 算)。
**🔴 判据(通用)**:凡做「**一格一视图**」(tab/分栏/多租户面板),
**先答一句「这一格的数据源是不是跟着格走」** —— 只换标题、不换数据源 ⇒ **必然是假数据**。
验收要**逐格比对关键字段的 md5**(我这次就是靠它抓出来的):
```
改造后:本区 tasks=323 字节 / peer tasks=2 字节(= {} )⇒ 两侧不再同源 ✔
```
⛔ **「没显示」和「显示了错的」必须先分清再动手** —— 本次第一反应猜错方向,白绕了一轮。
**现状(如实报,⛔ 未解决的部分)**:peer 格的 `sessions` 目前**仍是空** ——
卡在 `in_project()` 那道判据(主会话登记/任务类别都在**对方**的 INBOX 里,而 `project_scope()` 读的是本工作区)。
⛔ **我没有为它去改 `in_project()`**:那条判据与 `collabd.py::_in_project()` 有「**逐条同款**」硬约束
(历史已因此踩过三次)⇒ 为一个"只读概览"去动它,风险远大于收益。
⇒ 正解在**架构层**:各工作区开**自己的看板**(各自进程、各自判据),跨区只做**总览 + 跳转**。
⚠️ 与 **P0-39(判据读错世界)** 同族:那条是**测试**读到了夹具,本条是**产品**读到了别格的数据。
## P0-41 🔴🔴 常驻的载体:会话/工具调用起的活**一定活不长**,且三条"看似可行"的路全被封
**症状**:`collabd.py --supervise` 历次只活几分钟到几十分钟,日志照旧在走
(写它的是**宿主钩子**的 `--tick`,⛔ 不是常驻自己)⇒ 看板显示"在线"而**载体早就没了**。
**三条封死路(2026-10-03 实测,别再试)**:
- `CREATE_BREAKAWAY_FROM_JOB` ⇒ **`PermissionError(13,'拒绝访问。')`**(被作业对象拒绝)
- `cmd /c start /B` ⇒ **"拒绝访问"**(本机沙箱拦截)
- `.bat` 包装 ⇒ ⛔ 路径含中文时 `cmd` 按 GBK 读 UTF-8 ⇒ garbled ⇒ 不可用
**✅ 正解=Windows 计划任务 + 永不返回的守护循环**。关键差别=**父链**:
计划任务创建的进程由 **Task Scheduler 服务**创建,**不在 WorkBuddy 会话的 Job Object 里**
⇒ 不会被会话回收(实测 5–6 秒自动恢复、无叠加)。
⛔ 计划任务的增/删/改/查**一律走 `ScheduledTasks` 模块**(`schtasks.exe` 在本机被黑名单拦截)。
🔴🔴 **两种"长期"都要同一个载体,⛔ 别拿排期当载体**(2026-10-04 实测):
| 场景 | 主会话来源 | 载体 | 特有坑 |
|---|---|---|---|
| A 用户手动创建 | 用户自己开 `[主]-…` | **同左** | 用户一收工就没人拉 ⇒ **没装载体就会静默死掉** |
| B 定时任务创建 | 排期 `recurring` 到点拉 | **同左** | 🔴 跑完即 `completed` ⇒ **下一跳之前是空窗**;🔴 `once` 过期即哑 |
⇒ ⚠️ **`status=ACTIVE` ≠ "在跑"**:实测 `[主]-会话协作测试3-主会话` 是 `once`/
`scheduled_at=2026-10-03T20:33`/`next_run_at=None`/`last_run_at=None`
⇒ **一次都没触发过**,界面却显示 ACTIVE。**判"真会触发"要看
`schedule_type='recurring'` ∧ `next_run_at` 非空**。
**三个秒退坑**(详 ⇒ `supervise-persistence.md`;⚠️ ③ 随 `.ps1` 形态 2026-10-06 整套废弃,⛔ 仅存历史):
① 不设 `CODEBUDDY_CONFIG_DIR` ⇒ 退回读 **0 字节空库** ⇒ `no such table: sessions`
⇒ 闸二永久失效 ⇒ **检查会话永远建不出来**(**看着在跑,其实在瞎报**)
⇒ ✅ 正解用机制自带配置项 **`host_db`**(它本就优先于环境变量);
② `cwd` 设成 `collab/` ⇒ 配置查找拼出多一级 `.workbuddy` ⇒ **当场拒跑**(设成**工作区根**);
③ 🔴 **PowerShell 5.1 读无 BOM 的 UTF-8 `.ps1` ⇒ 按 ANSI(GBK) 解码 ⇒ 中文路径全毁 ⇒ 秒退**。
⚠️ **本机所有含中文路径的 `.ps1` 一律带 BOM**(落盘用 `UTF8Encoding($true)`)。
**判据**:`LastTaskResult` **必须 0**(`1` = 秒退,八成是 BOM)+ 心跳 `pid` 活 ∧ `ts` 距今 < 90 s。
📌 **⛔ 打印"已启动"不算数**(同 P0-22 家族:启完必查真读数)。
🔴🔴 **追加实测(同日 23:32):`RestartCount=999` 救不了"被外部收走"** ——
测试3 常驻 `23:16:37` 起、`23:28:47` 停(活 12 分钟),而计划任务
`LastRunTime=23:17:03`/`LastTaskResult=0`/`State=Ready` ⇒ **它没重拉**。
**根因**:`Restart*` **只在本任务自己非零退出时生效**;而脚本里若是**前台阻塞**调用常驻,
常驻死掉 ⇒ 脚本**正常返回 0** ⇒ 任务被判「成功」⇒ **永不触发**
(测试3 复盘原话:「自愈机制被**成功退出**这三个字废掉了」)。
⇒ ✅ **正解=脚本里写「永不返回的守护循环」**(`while($true)` 包住 + `Start-Process -Wait`),
**⛔ 不是**靠计划任务的 `Restart*`。⚠️ **`&` 对 `pythonw.exe`(GUI 子系统)不阻塞**
⇒ 循环会误判"刚起的已退出"⇒ **叠出多个常驻**(测试3 实测 5/10/20/40s 连续重拉)。
🔴🔴 **追加实测(2026-10-04 07:03):守护循环**必须看停止标志 `guard.stop`** ——
目标已完成(`--set-life 已完成` 已写标志、常驻已优雅退出),**但守护循环仍每 60 秒重拉、每次秒退**
⇒ 实测**空转 370 次/6 小时**。根因=守卫只问「心跳新鲜吗」,⛔ 不问「目标还活着吗」
⇒ **"完成即收工"只做到"停掉常驻",没做到"别再拉它"**(同一件事的两半)。
✅ 循环开头 + 退避等待中**都查标志**,在则 `Say` 一行后 `exit 0`(⛔ 不 `Say` ⇒ 又成"静默消失")。
实测:新 keeper **0 秒**识别 ⇒ 任务 `State=Ready`(⛔ 不是 `Running`)⇒ 重拉 **372 → 372**。
⚠️ 验「是否被回收」**必须只做单一变量**(⛔ 不许同时停/起任务)—— 否则因果会搞错。
⚠️ **验收要静置 ≥ 12 分钟**(⛔ 短于 10 分钟证明不了任何事 —— 本轮就死在第 12 分钟)。
📌 另两条**正常行为**别当故障:静默基准取台账 mtime ⇒ 改排期会归零计时;
`_all_sessions_idle()` 要求"所有会话都结束" ⇒ **发起会话自己也在里面** ⇒ 结束它检查会话才能建。
## P0-42 🔴🔴 **判据里"起真进程写夹具"会写坏真数据 —— 隔离必须用 `COLLABD_CONFIG`**(★ 2026-10-04 实测,代价=真 `goal.json` 被覆盖)
**症状**:给自检加"端到端真跑 `goal_state()`"的判据,跑完发现**生产 `goal.json` 变成 230 字节、
只剩 1 条判据**,`目标文件夹` 名从 `目标-本机协作-3e3182` 变成 `目标-未命名目标-35279e`。
**根因**:`load_cfg()` 的候选顺序是 ① `COLLABD_CONFIG` → ② `<ws>/.workbuddy/collab/collabd.config.json`
⇒ **只设 `DSH_COLLAB_WS`/`DSH_WS_ROOT` 会被忽略**(真源压根不读它们定 workspace)⇒ 回落读到真配置
⇒ 判据的 `goal.json` 写进了**真工作区**。⚠️ 连 `TG`(任务图)也是同一份 config 决定的,一并读到真的。
⚠️⚠️ **更隐蔽的一层**:`DSH_WS_ROOT`/`DSH_COLLAB_WS` 在 `selftest.py` 里是**它自己的测试工作区根**
(`WS = ... or DSH_WS_ROOT`,`_prepare()` 拿它建 `tmp/selftest`)⇒ **拿它拼"生产路径"=把夹具当生产**;
演练时我设了个 `X:/nonexistent` ⇒ 直接 `FileNotFoundError: 'X://'` 崩在 `_prepare()`。
✅ 正解(`t_acc_selfref_excluded` 的端到端段照此写):
① 写一份**临时 config** 并 `os.environ["COLLABD_CONFIG"] = <它>`(含 `workspace`/`inbox`/`taskgraph`);
② 加载模块后**先自检 `str(m.INBOX)` 落在临时目录内**,不在就**直接报红退出**(⛔ 宁可红也别再写真文件);
③ 需要"生产工作区"的判据(如 `t_execution_doc`)另认**专用** `COLLABD_PROD_CONFIG`,
由 `install.py` 写进 `roots.env`;**⛔ 不用 `DSH_WS_ROOT` 拼**。
📌 **验收纪律**:凡新增"会写文件的自检",跑前先 `md5sum` 目标真文件、跑后再核一次。
(本轮恢复源=`.workbuddy/collab/bak-goalctl-20261002/goal.json`,⛔ 那份缺 `execution_doc` 字段,
恢复后要按 `exec_doc_rel()` 算出的真值补回,否则 `t_execution_doc` 假红。)
## P0-43 🔴 **判据写成"函数体里含某串" ⇒ 同一个符号在函数里出现两次就抓不住变异**(★ 2026-10-04 变异验证实测)
**症状**:判据写 `"_acc_is_selfref" in fn_goal_state`(`fn`=函数源码文本)⇒ 变异把
`goal_state()` 里 **`_real` 那处**的调用删掉,判据**照样 PASS** ⇒ 假绿。
**根因**:`goal_state()` 里 `_acc_is_selfref` 出现**两次**(`_real` 的推导式 + `_dropped` 的推导式)。
同族第二例:判据写 `re.search(r"if not _real.*?undeclared", fn)` ⇒ 变异把中间那个
`return "undeclared"` 拆掉**照样 PASS** —— 因为函数尾部 `return "pass" if eff else "undeclared"`
里还有一个 `undeclared`(正则一路匹配过去了)。
✅ 正解=**上 AST,按结构定位**(本轮 `t_acc_selfref_excluded` 照此):
- 「`X = [...]` 里有没有调 `f`」⇒ 找 **`Assign`**(`targets[0].id == "X"`),
⛔ **不是找 `ListComp`** —— 推导式的 target 是 `(_k, _v)`/`k`,**`_real` 只在赋值左侧**。
- 过滤条件在**生成器的 `ifs` 子句**里(`if ... and not f(...)`),⛔ 不在 `elt`。
⚠️⚠️ `ListComp.elt` 是**单个节点**(有时还是 `Tuple`)⇒ `list(elt)` 抛
`TypeError: 'Tuple' object is not iterable`(本轮真踩)。
- 「`if not X:` 的 body 里有没有 `return "..."`」⇒ 走 `If` 节点的 `body`,⛔ 不用跨节点正则。
📌 另:**行为级判据最硬** —— 真造 `goal.json` 跑**真** `goal_state()`,断言返回值
(本轮 4 个场景:`全自指⇒undeclared`/`自指+真判据全过⇒pass`/`真判据没过⇒open`/
`自指写过但真判据没过⇒open`)。⚠️ 夹具判据文本必须用**真源真会写的形状**(`过|…`,
⛔ 不带 `🔴` 前缀 —— `_acc_is_pass` 取竖线左边那段判,`🔴 过` 的 head 仍带 `🔴` ⇒ 假红)。
## P0-44 🔴 **技能包没有版本控制兜底 ⇒ 清理前必须先建包外快照**(★ 2026-10-04 实测)
**症状**:用户问「技能相关文件有没有可以整合、精简、移除的重复冗余」,
体检算出 **10.7 MB 里9.1 MB 是冗余**(68 个 `*.bak-*` 占 8.6 MB),我上一轮顺口说过
「git 已有历史」—— 🔴 **这句是错的**。
**取证**(三级都查了):`.workbuddy/`/`E:/ProgramData/`/`E:/` **全都没有 `.git`**;
文档库 `dsh-server-docs/08-skills/` 只收了**另外 7 个**技能,**没有 session-mechanism**
⇒ **在这个机器上,删掉的文件就是唯一副本没了**。
✅ 正解(清包三步,顺序⛔ 不许换):
① **先建包外快照**:`cp -r <包> <WS>/归档/技能包快照/session-mechanism-<日期>/` + `md5` 抽检;
② 再删(`*.bak-*`/`__pycache__`/`.pyc`/`install.log`/**零引用文件**);
③ 删完**必须现算验收**:`selftest.py` + `install.py --verify` + 看板 headless 渲染。
📌 **死证据引用要一起改**:注释里写「原文照抄见备份 `X.bak-…`」⇒ 备份一删那句话就成了假话
(本轮改掉 6 处:collabd.py×1、board.html×2、SKILL.md×2…含 L6)。判据见 `selftest.py::t_pkg_hygiene`。
## P0-45 🔴 **判据别去判"运行时产物"和"该长的东西",否则永远红**(★ 2026-10-04 实测栽了三轮)
给包体卫生写判据时,连踩四个坑(都是**判据错**,不是代码错):
| 想判的 |⛔ 错误写法 | 为什么必然红 | ✅ 正确写法 |
|---|---|---|---|
| 缓存 | 「包里不许有 `__pycache__`/`.pyc`」 | `hooks/_env.py` 一被导入就生成 `scripts/hooks/__pycache__/_env.cpython-313.pyc` ⇒ **跑一次 selftest 就长回来** | 判「**无散落 `.pyc`**」(只能在 `__pycache__` 内) |
| 运行日志 | 「包里不许有 `install.log`」 | `install.py:96,340` **每次运行都 append** | 判「**不超 64 KB**」 |
| 文档超长行 | 「`SKILL.md` 无超 2 KB 的行」 | L3 `description:` 本来就 2.2 KB(技能描述就该长) | **豁免 `description:`** + **含「作废」的历史段**(它长是因为把作废原因写全了) |
| 「本条为准」字样 | 「台账里不许有『本条为准』」 | 新台账 L6 自己在**解释这个机制**,L24/L98/L267 各段也都合法用它 | 判**行体积**(当年 L6 单行 55 KB = 全文 66%),⛔ 不判字样 |
⚠️ 另:**死证据扫描会自我误报** —— 判据自己的注释里就写着 `settings.json.bak-session-mechanism-*`
⇒ 扫全包必然命中自己。✅ 判据要**显式排除 `install.py`(备份在 `.workbuddy/`,是活机制名)
+ `selftest.py` 自己**(P0-16 同族:判据把自己当被检对象)。
📌 变异验证时也栽过一次:注入死证据用的锚点 `"""使用方` **压根不存在** ⇒ 变异**没注入成功**
却 Prints 出一条「漏网」结论。⇒ **变异必须断言 `assert 新串 in 文件`**,⛔ 别让"没注入"伪装成"判据有洞"。
## P0-46 🔴🔴 **"单包自包含"的两个静默失效点**(★ 2026-10-04 实测,只搬一个技能到空目录演练坐实)
**症状**:只复制 `session-mechanism` 一个技能到别的机器 → 机制看起来都在(7 个 hook 全 rc=0),
**但回复排版闸门整条静默失效** —— `reply-style-guard.py` 输出**空 JSON**,日志里看不出是"机制坏"还是"没配规则"。
🔴 **失效点 ①:hook 读的是「另一个技能」的文件**
`reply-style-guard.py::_core()` 第②级回退硬指向 `agent-operating-rules/references/回复排版-核心块.md`。
✅ 正解=**把那份核心块内联进本包** `references/03-回复排版-核心块.md`(**内容守恒、逐字搬**),
`_core()` 改**三级回退**:工作区规则文件 → **包内件** → 外部兜底。⚠️ 顺序⛔ 不许换。
🔴 **失效点 ②:技能库定位拿「别的技能」当识别特征**(更隐蔽)
`hooks/_env.py::_SNIFF = "agent-operating-rules"` —— 它是**技能库在不在**的判据,
`skills_root()` / `env_check()` 三处都拿 `(root)/_SNIFF).is_dir()` 当真源。
⇒ 只装本包时**永远判否** ⇒ 全部降级。✅ 正解=改**候选数组** `_SNIFFS = ("session-mechanism", "agent-operating-rules")`,
新增 `_has_skill()`(任一候选存在即认)。⚠️ **第一个元素必须 = 本包自己**。
⚠️ **改造时我自己踩了两个坑(都是"文件在、读不到"型)**:
- **上溯少一级**:`__file__` = `<包>/scripts/hooks/x.py` ⇒ `dirname`①=hooks/ ②=scripts/ ③=**包根**。
写成两层就落到 `scripts/` ⇒ 找不到 `references/` ⇒ **静默空输出**。
⇒ 判据必须查 `os.path.dirname(os.path.dirname(_hooks))` 这个**确切形状**,⛔ 查"文件存在"是假绿。
- **取证本身也有缺口**:第一轮单包演练"注入 654 字节✅",但日志显示源是**工作区的 `CODEBUDDY.md`**
(第①级回退)⇒ **根本没验到包内件**。✅ 正解=在**一个没有 `CODEBUDDY.md` 的干净工作区**里重验,
源日志必须显示包内件路径才算数。
📌 **判据 `t_single_package_selfcontained` 的两个教训**(变异验证实测):
① 查标记必须用 `_extract()` 的**完整字面量** `<!-- REPLY-CORE:END -->`,
⛔ 查子串 `REPLY-CORE:END` ⇒ 把它改成 `END(改坏)` 子串仍命中、而**真提取器已认不出**(假绿)。
② **变异脚本自己的过滤也会造假绿**:按用例标题过滤时把"用例标题行"一起滤掉 ⇒ 明明报红了却统计成 0。
⇒ 变异计数**⛔ 不用标题行**,只数子项的 `✗`。
## P0-47 🔴🔴 **变异脚本把「判据自身语法错」当成了「判据抓不住」**(★ 2026-10-04 实测,判据编写者的自保要点)
**症状**:给判据加完新项,跑变异验证 → **4 个变异全部「0 报红」**,看起来像"判据不可证伪"。
真因=**新写的判据行里嵌了中文直角引号**(`(⛔ 那是"去别处找判据"的形状)`)
⇒ `selftest.py` **整份语法错、跑不出任何输出** ⇒ 变异脚本统计「子项 ✗ = 0」
⇒ **"没有输出"被当成"没有报红"** ⇒ 结论完全反了(真身是"判据根本没跑")。
🔴 **根因有两层,缺一不可**:
① **中文引号坑**(与既有铁律「含反引号一律 Write/Edit」同类):`"…"` 嵌进 `"…"` 字符串即语法错。
② **变异脚本没有防空转护栏** —— ⚠️ 这是**更值钱的一半**:
"判据没输出" 与 "判据全绿" 在计数上**长得一模一样**(都是 0 个 ✗)。
✅ 变异脚本**必须**带两道护栏(缺任一道,一次误判就够):
```python
# 护栏 1:判据源文件必须语法正确
ast.parse(st.read_text(encoding="utf-8")) # SyntaxError ⇒ 直接判「运行异常」
# 护栏 2:必须真找到那条用例
if not any("用例标题" in l for l in out.splitlines()):
return 99# ⛔ 过滤失配 ≠ 全绿
```
📌 另:**别用 `grep -A N` 数子项**(窗口会切掉长输出,实测"6 项"其实是 28 项)⇒ ⛔ 用 Python 按
"下一条 ` ✓ `/` ✗ ` 标题行为止"切块。
⚠️ 同族第三发(今天同一天栽的另两个假绿):**P0-43**(判据写"函数体含某串"⇒ 同符号出现两次就抓不住)·
**本条 P0-45 的排版判据**(查子串 ⇒ 同一文件里出现两次 ⇒ 改一处仍命中)。
⇒ 一句话总纲:**判据的每个断言都要锚在「结构」或「唯一位置」上;⛔ 锚在"文本出现过"上必出假绿。**
---
## P0-48 🔴🔴 **判样式表:注释里正当引用着「旧写法」,不剥注释必假绿;等宽要解析式**(★ 2026-10-04)
> 📌 **只管「样式表里带注释的那类判据」**(⛔ 不是所有代码判据);带注释是因为注释里常要写"为什么改"。
**症状**:给 `.tab` 加"等宽"判据 ⇒ 变异「`flex:1 1 0` 改回 `min-width:180px`」**🟢 绿**;
判据里写「⛔ 不许有 `min-width:1xxpx`」,**那串明明就在文件里** —— 在**注释**里。
**根因**:那段说明注释**正当**引用了改之前的旧写法("为什么改"的证据,⛔ 不许删)⇒ 全文件查旧写法**永远命中**。
✅ 正解(缺一步仍假绿):
```python
css = re.sub(r"/\*.*?\*/", "", html, flags=re.S) # ① 剥注释
tab = re.search(r"\.tab\{[^}]*\}", css).group(0) # ② 只取那一条规则块(⛔ 不量全文件)
```
③ **判"等宽"必须解析式**:`flex:1 1 0`(basis=0 ⇒ **等分**,✅)对比三种**不等的**写法 ——
`flex:1`(省略 basis ⇒ 默认 `1 1 auto`,按内容)· `flex:1 1 auto`(按内容)·
`flex:0 1 auto`(**不生长**,宽=内容)—— ⛔ 子串法与正确写法**分不开**
⇒ `re.search(r"flex:\s*(\d+)\s+(\d+)\s+([0-9a-z%]+)")` 判 `grow>=1 and basis=="0"`。
④ 🔴 **配套**:`min-width:0` 缺了会被子元素 `min-content` 顶开 ⇒ **看着仍不等宽** ⇒ 必须同时断它。
📌 `_R = [...]` 是**列表字面量**,⛔ 不能在中间插 `_R.append`(语法错 ⇒ 触发 P0-47 静默)。
📌 **附带**(同型可独立用):⛔ **扫文本找"裸断言"的判据不成立** ——
会扫到**自己的扫描代码**(实测 `"== 0) or" in ln` 命中那行本身)⇒ 恒红。
---
## P0-49 🔴🔴 **台账/文档里的读数是「快照」——引用前必须看它什么时候写的**(★ 2026-10-04)
**症状**:`acceptance_state` 说「自测 PASS 48」⇒ 真跑 **73**;说「pid 8024 活」⇒ 现活 19424。
**根因**:每条带 `_更新` 时间戳(本例停在 **10-02**)⇒ 那是**"当时验过"**,⛔ 不是"现在仍成立"。
> **凡带「现在/已/还差」的结论,读数一律现取。** pid · 心跳 · 计数 · pass 数 · 端口 · 排期状态——全会变。
🔴 **同日更阴的一发(我据此报错结论,用户当场质疑)**:由"读数旧"推出「**常驻没在跑**」——**是我错**。
错在**取证手段**:① 落点**猜错**(照旧文案找 `tmp/supervise-inbox/`,真身是 `collabd.SUP_HB` 指向的
`.workbuddy/collab/logs/`);② 🔴 **`glob("**/x")` 不穿透 `.workbuddy` 这类点目录** ⇒ 零命中
⇒ 把「**查不到**」当成「**不存在**」。
⇒ **同型(不是同一条)**:P0-48 讲的是**样式表判据**,这里讲的是**取证锚点** ——
**共同教训**是「**锚点自己没先验 ⇒ 结论错**」,⛔ 别把 P0-48 当成也管取证。
✅ **取证纪律**:**查机制状态只许问那个程序自己**(`import collabd; collabd.supervise_alive()`、
读路径常量 `SUP_HB`),⛔ 不许 glob、不许手拼路径、不许照抄文档旧落点。
📌 已落判据 `t_supervise_ensure`(在 **`session-mechanism` 包**的 `scripts/selftest.py`,⛔ 不在本工作区)。
📌 **另一类:制度性不可能项** —— 判据要求的对象**已整套退役**(配置 `wake_enable=false` + 源码里投递段**已真删**)
⇒ 它会永远 🔴,**不是坏了**。⛔ 这种只能**用户拍板**(作废/改写口径/保持),AI 不得擅改验收口径。
---
---
## P0-50 🔴🔴 **忙判据只读一个日志源 ⇒ 宿主不写那份时「必然误判空闲」⇒ 往正在跑的会话插话**(★ 2026-10-04 `vibe-product` 实测坐实)
**症状**:`vibe-product` 一条会话(`b232218f…`)明明在转 —— `state-machine:transition → working` **358 次**、
`idle` 仅 **3 次**、用户只发言 **4 次**、跨 **2h46m** —— 而 `_target_busy()` 返回 **`False`(没在跑)**。
>⚠️ **2026-10-04 晚间复验:这组数字对不上,⛔ 别再当证据引用**(结论"在转"仍成立)。
>实测:日志已续写,现为 `to:"working"` **438** 次(含 `from:"working"` 自环 **326**)、
>`to:"idle"` **4** 次、`method:sendPrompt` **4** 次(✅ 这个对得上)。
>⚠️ 大概率是当初**数的是另一个口径**(如只数某个时间窗/某个 `input` 值)——
>⇒ **教训**:数字结论必须连**口径**一起记(数的是哪个字段、哪个范围),⛔ 只留结果不留口径。
🔴 **根因(可证伪)**:`_session_busy()` 只读**工作区级**日志 `<ws>__<hash>.log`。
实测那份日志里 `SessionRunStateMachine` **615 条**,但**这个 sid 一条都没有**(全文件 0 次)
⇒ 宿主**不为该会话写**工作区级状态机日志 ⇒ 恒得 `''` ⇒ 老逻辑「读不到 ⇒ 按没在跑处理」**必然误判**。
⚠️ 这个 fail 方向**比"恒真"更隐蔽**:恒真是"永远不敢投"(用户看得见);误判空闲是"**往正在跑的会话里插话**"(静默)。
✅ **正解=双源**:工作区读不到 ⇒ 回落**会话级**日志 `<logs>/<日期>/sdk/conversations/<sid>.log`。
⚠️ **两个源字段完全不同**(⛔ 拿错正则 = 恒得 0 条 = 白加):
| 源 | 事件名 | 取值字段 |
|---|---|---|
| 工作区级 | `[SessionRunStateMachine] … busy=true` | `busy=` |
| 会话级 | `state-machine:transition {"from":…,"to":…}` | `to`(`working/planning`=忙)|
⚠️ **sid 形态不一致**(实测栽过):文件名叫 `b232218f-c8e6-…`(**36 位带连字符**),
而调用方常拿库里 `b232218fc8e6…`(**32 位无连字符**)⇒ 只拼一种 **恒不命中** ⇒ 回落源恒 `''` ⇒ 修了个寂寞。
⇒ **两种形态都试** + 前缀唯一命中兜底。
📌 判据:`t_srsm_busy` ⑦~⑬(6 条)+ 变异 6/6 报红(删回落/写死不救/正则混用/删形态兼容/取首条/越权)。
📌 顺带:`会话级日志格式不是 JSONL`,是 `ISO时间 事件名 {JSON}` ⇒ 按 JSONL 解析会得 0 条(⛔ 已栽)。
---
## P0-51 🔴🔴 **技能副本与全局会分叉:修完全局忘了同步,副本继续用旧缺陷**(★ 2026-10-04 `vibe-product` 实测)
**症状**:全局技能库修好一个 P0 级缺陷(忙判据双源),**用户那边照旧出错** ——
因为那个工作区用的是**复制到工作区的副本**(`.workbuddy/skills/<包>/`),**⛔ 不会自动跟随全局**。
🔴 **本轮实测的量**:副本 47 个文件里 **落后 3 个**(`collabd.py`/`selftest.py`/`pitfalls.md`),
其余 45 个 md5 已一致。**这 3 个不补,副本就带着旧缺陷跑。**
✅ **同步四步**(⛔ 别整目录覆盖):
1. **先逐文件 md5 双向 diff** —— ⛔ 用**文件数**当判据会错(副本多一份《副本使用说明.md》)。
2. **只覆盖真差异** —— ⛔ **`roots.env` 绝不覆盖**:它是**工作区专属**指针(`setdefault` 兜底
⇒ 覆盖会让副本去读写**另一个仓库**)。
3. **备份挪出包体** —— 同步完把备份移到 `<工作区>/交付物/`;留在包内会被 `t_pkg_hygiene`
的「⛔ 不许长回备份」判红(实测副本自测 FAIL 1)。
4. 🔴 **副本必须真跑一次** —— ⛔「编译过」≠「能跑」。本轮副本首跑 **FAIL 4**(修完仍 FAIL 2 ⇒ 逐条查)。
🔴 **副本自测的独立坑(比同步本身更隐蔽)**:有些判据量的是**「生产工作区部署齐全」**
(`交付物/目标执行状态.md`、跨区 `peer_workspaces` 配置、**本工作区的 `collabd.py` 副本**)
⇒ 在**没启用机制的工作区**(无 `tmp/supervise-inbox/goal.json`)里**必然报红**,
**而那不是技能有错**。
✅ 正解=加 `MECH_ON` 开关(判「真源文件在不在」,⛔ 不看 env 不猜),未启用时**跳过并说明**;
⛔ **不许改成"永远绿"** —— 那是把判据废掉。
📌 另一例:判据「glob 会漏点目录」**耦合了「心跳文件必须存在」** ⇒ 副本没起常驻就必红
⇒ 改成**自建夹具**(造一个 `.workbuddy/x/y` 文件,验 glob 查不到而 `exists` 为真)⇒ 任何环境都成立。
📌 **收口验收**:双向 md5 **45/45 一致** + 全局 `PASS 75/FAIL 0` + **副本 `PASS 75/FAIL 0`**。
📌 副本《副本使用说明.md》是**权威口径**(⛔ 别凭猜同步:它写明了「⛔ 不要整目录覆盖」的真实原因)。
---
## P0-52 🔴 忙判据第二源:**跨日文件按遍历顺序覆盖 + 没有新鲜度闸 ⇒ 已收工会话静默饿死**(★ 2026-10-04 `vibe-product` 实测坐实)
**症状**:`skipped="target-busy"` 反复出现,目标会话明明已收工/已死活,却**永远收不到上报,且无任何告警**。
🔴 **根因(两个,同向)** —— 都在 `_session_busy_conv()`(会话级日志源):
1. **跨日覆盖**:`_conv_log()` 返回 `[今天, 昨天]`,而取状态用的是**遍历到的最后一条**
⇒ **昨天**卡住的 `working` 把**今天**已经 `idle` 的盖掉。
2. **无新鲜度闸**:`_session_busy()` 有 `SRSM_FRESH`(900s),本源**一个都没有**
⇒ 3 小时前卡住的 `working` **永久**判"在跑"。
⚠️ **两个缺陷 fail 方向完全相同**(都判`busy`)⇒ 后果同一条:
`busy` ⇒ `skipped="target-busy"` ⇒ **静默饿死**。比"恒真"更隐蔽:恒真是"不敢投"(用户看得见)。
✅ **正解**:**跨文件取时间戳最新的一条**(⛔ 不是遍历到的最后一条)+ 补 `SRSM_FRESH` 新鲜度闸。
⚠️ **反方向也要修**:闸不能写太狠(窗口=0)⇒ 会把**正在跑的**判成空闲 ⇒ 变成"往在跑的会话插话"。
⇒ 必须同时有**反向回归判据**:2 分钟前的 `working` **仍须判 `busy`**。
🔴🔴 **时基不一致(隐蔽坑)**:会话级日志行尾是 `Z`(**UTC**),工作区级是 `[2026/10/4 14:15:31]`
(**本地时间、无 Z**)⇒ 解析必须分别按各自口径,⛔ 混用 `time.mktime` 会差 **8 小时**(东八区)
⇒ 「2 分钟前」被算成「8 小时前」⇒ 新鲜度闸恒判过期 ⇒ **真在跑的会话被误判空闲**。
⇒ 会话级用 `calendar.timegm`(UTC 语义),⛔ **不是** `time.mktime`。
📌 **判据**:`t_srsm_busy` ⑭~⑰(4 条:跨日取最新 / 新鲜度闸 / **反向回归** / UTC 时区)
+ 变异 **9/9 报红**(每条判据 ≥2 个变异体)。脚本 `scripts/mut_run.py`。
🔴🔴 **本条同时纠正 P0-50 的一个错误结论**(⛔ 错误结论必须从代码里删掉,不能只加新条):
P0-50 与 `_target_busy` 注释都写「该 sid 在工作区日志里**一条都没有**(宿主不写)」——
**实测是错的**:同一份工作区日志 `SessionRunStateMachine` **1935 条**、含该 sid **695 条**
(末条 `14:15:31 AGENT_ENDED … busy=false`)。
✅ 真实成因是**候选集为空**:`_log_dirs()` 靠 `WS` 反推工作区名,`WS` 一旦落在别处
(如从别的目录 import)就拼不出任何 `<ws>__*.log` ⇒ 返回 `[]` ⇒ 恒 `''`。
⇒ **双源仍然必要**(第二源不依赖 `WS`),但**理由要换**,⛔ 别再引用"宿主不写"。
📌 **变异验证自身栽的两处**(比判据本身更值得记):
① **等价变异**:`key[1] >= last_t[1]`(只把元组比较换成 seq)**恒绿** —— 样本里每个文件内部
本来就升序,**任何"取最后一条"的写法都等价** ⇒ 变异必须动**语义**(时间戳参与/不参与)。
② 🔴🔴 **测的不是被检对象**:变异脚本把 `selftest.py` 复制到临时目录,却用**原版绝对路径**调用
⇒ 而 `selftest.py` 里 `imp()` 加载的是 `HERE/"collabd.py"`(`HERE` 来自 `__file__`)⇒
**测的一直是原版** ⇒ 7/7 全"假绿"且**表面毫无异常**。
✅ 正解:**用工作目录里的那份 `selftest.py`**,并断言输出里能看到该工作目录;
再加一道**变异标记**(`# MUTANT:`)⇒ 证明**跑的那份**真含变异。
> 这就是「**判据自己恒绿**」最隐蔽的一种:不是判据写错,是**根本没测到它**。
---
## P0-52 🔴🔴 **知识层记过了,执行层照栽 ⇒ 缺的不是文档,是「机器抓手」**(★ 2026-10-04 收口)
**症状**:P0-51 早就把「变异脚本要测被检对象、⛔ 别拷到临时目录」写得清清楚楚,
`references/00-动手前必过.md` 也立了五条纪律 —— **当天我自己照栽三次**。
⇒ ⚠️ **不是「不知道」,是「记不住」**。文档对**检索**有效,对**执行**无效。
🔴 **把当天的坑收敛,只有两个根因**(⛔ 不是六条各自独立的失误,写流水账等于没归因):
| 根因 | 当天表现 | 机器抓手 |
|---|---|---|
| **A. 判据与被检对象的「连接」没被验证** | 判据里 `lambda` **自己重算**;锚在「文本出现过」;夹具只覆盖顺带跑到的分支;锚点凭记忆写 | `judge_audit.py --audit` |
| **B. 验证动作本身出错,而我不检查它** | 变异脚本自己 `IndexError` 却照样打 PASS;多轮变异**只重置一份文件** ⇒ 累积污染 | `judge_audit.py --mutate` |
⚠️ **两者的共同签名**:**判据绿灯与被检对象状态无关**。
✅ 正解=**让验证器自己的失败变成读数**(rc≠0 + 明说原因),⛔ 不许它输出得像结论。
📌 当天实测:工具报「⛔ 没报红 ❌」并 rc=1,比假的「PASS 2/FAIL 0」**有用得多** —— 至少我知道要重查变异体。
🔴 **⛔ 元规则本身也会假红**:`--audit` 扫**写法**,分不清「引用教训」与「犯错误」
(实测把「自建夹具证明 glob 会漏」这条**教训本身**判成错误用法)⇒ 误报进 `FALSE_POSITIVE`
且**必须写清为什么**,⛔ 不许默默加(默默加 = 把体温计砸了)。
📌 **附带坐实**:`glob-existence` 这条规则在 `selftest.py` 里命中 2759 行,
而那处正是**该教训的判据本体** ⇒ **元规则会指向「反例教材」**,⛔ 别把它当 bug 修掉。
---
## P0-53 🔴🔴 **合成区夹具必须把配置里的 `workspace` 也改成自己**(★ 2026-10-04 端到端实测,白跑一轮)
**症状**:造合成区喂钩子 stdin,钩子 **0.4 s 零输出**,看着像
「钩子没生效 / 判据失效」;而**同一份代码在真区是好的**。
**根因(实测坐实,⛔ 非推断)**:夹具只复制了 `collabd.config.json` 的**字节**,
没改里面的 **`workspace`** 字段 —— 它还指着**真区**。
⇒ `collabd.load_cfg()` 加载的是真区配置 ⇒ `CFG_USED` 校验**通过**(因为 env 指合成区、
但配置内容读的是真区的 `workspace`)⇒ `supervise_alive()` 读到**真区的心跳**。
⇒ 而真区常驻**确实活着** ⇒ 钩子**正确地**判定「在位」⇒ **正确地** `_noop()`。
🔴🔴 **最危险的地方**:钩子的行为**完全正确**,是我的**夹具**在说谎。
⇒ 如果不查下去,结论就会写成「钩子没生效」——**又一次错误归因**(同 P0-20 的形状)。
✅ **判据/夹具要点**:
- 合成区配置**必须**深拷贝后改 `workspace` = 合成区自己,⛔ 不许 `read_bytes()/write_bytes()` 原样搬。
- 反向自检:夹具建好后**先断言** `supervise_alive()` 在合成区**报"不在位"**,
再去喂钩子 —— 否则测的是"真区刚好活着"。
- 🔴 通用形状:**凡是"复制一份配置来隔离"的做法,都要检查配置里有没有指向原环境的绝对路径**。
---
## P0-54 🔴🔴 **判据里搜「某个字符串出现过」要剥注释;等宽要按函数体切片**(★ 2026-10-04,P0-48 的同族第 2 次)
**症状**:新写的「单一事实源」判据**假红** —— 明明 `peer_supervise_sweep()` 已经改成
调 `goal_life_of(root)`,判据却报「它自己又抄了一遍 lifecycle 判定」。
**根因**:该函数**紧接着**就有一段注释,**正当引用着旧写法当反面教材**
(「原来这里自己判 `get("lifecycle")`…」)⇒ 裸串搜命中的是**注释**。
✅ 正解=**只留代码行**(`line.lstrip().startswith("#")` 之外的行)再判;
⚠️ 本文件通篇充满「旧写法作证」的注释 ⇒ **这条在全库范围内都会复发**。
📌 与 P0-48 合起来是一条通用纪律:
**判源码时,「注释」既不是代码(⛔ 别当证据),也不该被删(它是教材)⇒ 只能在判据侧剥掉。**
---
## P0-55 🔴🔴 **测了零件、没测装配** —— 断言只测「那个函数返回什么」,⛔ 不测「它被用上了没」(★ 2026-10-04 变异验证当场抓到)
**症状**:为守新加的「退役角色闸」写了 4 条断言,`PASS 88 / FAIL 0` 全绿;
把闸**从 `_scan_mains()` 的筛子里整段拆掉**(变异①)⇒ **判据照样全绿**。
`grep -c MUTANT` = 1 证明变异**真植进去了** ⇒ 那个"全绿"是**假的**。
**根因**:4 条断言全部形如 `m.is_retired_role_title("[跟进]-X")` ——
测的是**零件本身**(小函数对几个样本返回什么),**没有任何一条**测到
**「这个闸有没有被接到筛选逻辑上」**。⇒ 一个**恒绿**的判定标准。
✅ **正解=行为级用例**:造一个**真宿主库**(含一条 `[跟进]-…` + 一条真主会话 + 一条正牌 worker),
**真跑一遍 `_scan_mains()`**,看结果里有没有那条退役角色。
三条断言缺一不可:① 退役角色**不进**候选 ② 真主会话**不许被误排**(反例对照)③ worker 仍被排(旧口径未被破坏)。
⚠️ **同族第 2 个坑(同一轮里连踩)**:夹具 `insert` 时把 `custom_title` 写成 **`""`**,
而查询是 `coalesce(custom_title, title, '')` —— **空串不是 NULL ⇒ 不回落到 `title`**
⇒ 每条会话拿到的标题都是空串 ⇒ 用例**恒绿而毫无意义**(第二次"测了零件没测装配")。
真库里该列实测是 **`None`** ⇒ 夹具必须照抄真库形态(= P0-53 的同一个形状)。
📌 **通用形状**:加了「闸/过滤/开关」这类东西,**必须**有一条断言走**完整装配路径**;
只测开关函数本身 ⇒ 拆掉接线也能全绿。**变异验证是唯一能抓到它的手段**
(⛔ 别用"我读了一遍代码觉得接上了"代替)。
## P0-57 🔴🔴 **启动脚本没跟着改口径 ⇒ 本区发布物成了「孤儿副本」,规则形同虚设**(★ 2026-10-04 用户点破:「让它跑本工作的那份,不是跑全局技能里的那份」)
**症状**:某工作区常驻反复死亡重起(`_collabd.log` 7 次「常驻续命」),
主会话开工第 0 步自查时发现:**心跳 `argv0` 指的既不是本区发布物,也不是「全局技能」,
而是「本区技能副本」** —— 看起来像"跑对了",实际**判据②不过**。
**根因(两层,缺一不可)**:
1. **引用没跟着口径改**:`tmp/start_supervise.py` 第 11 行写死
`WS + "/.workbuddy/skills/session-mechanism/scripts/collabd.py"`(**技能副本**),
而口径(`deploy_code.py` 开头,用户 10-03 定案)要求各跑
`<工作区>/.workbuddy/collab/collabd.py`(**本区发布物**)。
2. **`deploy_code.py` 只管分发、⛔ 不管"谁引用它"** ⇒ 文件铺下去了,**没人指向它**。
🔴 **全工作区 grep 的实测结果**:引用本区发布物的**只有 0 处**(除了注释与文档里的说明文字)。
⚠️ 唯一引用它的载体 `start-supervise.ps1` 又**从没执行过**(首行被误打成三引号 ⇒ 语法错 9 处秒退 + 从没登记计划任务)。
**为什么难查**:**文件在 ≠ 有人跑它**。三条自查只做「md5 一致 + 心跳存在」会**全绿**,
必须加第三条:**grep 启动脚本引用的路径**。今天就是这么漏的。
✅ **修法(三层一起补,⛔ 少一层就复发)**:
- ① 启动脚本改成**两段式**:优先本区发布物,缺了才回落技能副本,**且必须打印告警**(⛔ 静默回落=用户以为跑的是副本)。
- ② 修完**必须重启常驻** —— 判据仍看 `argv0`(⛔ 改文件不重启=没生效,P0-17 同族)。
- ③ **开工第 0 步自查加第三条**:`grep` 启动脚本(`tmp/*.py`、`*.ps1`、计划任务 `Args`)⇒ 路径**逐字**是本区发布物。
📌 **通用形状**:**「改了目录结构/改了落点」之后,必须回头检查「谁引用它」** ——
引用点常在**配置、启动脚本、计划任务参数**里,⛔ 不在被移动的那个文件里。
同族:P0-51(副本分叉)、P0-17(改完不重启)。
## P0-62 🔴🔴 **常驻「自我繁殖」:续命时照着「当前这份」抄 ⇒ 弹窗洪水 + CPU 白烧**(★ 2026-10-05 用户投诉:「越创建越多 要创建 1W个嘛」)
**症状**:用户屏幕上不断弹终端窗口 —— 实测一次盘点出 **7 条 keeper + 10 条常驻 + 1 个 `.cmd`**,
且**技能目录那份 `collabd.py --supervise` 反复复现**(父进程每次都不同、且已消失 ⇒ 看着像幽灵自启)。
用户追问:「**这个常驻一直在运行 它不占用CPU 不占用资源吗**」。
**铁证(`_collabd.log` 逐字)**:
```
常驻续命:新起 --supervise pid=69464(原因:pid 12672 在、心跳新鲜,但**身份对不上**(不是本区 collabd 常驻))
```
🔴 注意这行的荒诞之处:**本区常驻(12672)明明活着、心跳新鲜**,却被判「身份对不上」⇒ **又拉一条**。
**根因(两层,同为 `Path(__file__).resolve()` 一个写法)**:
1. `ensure_supervise()` 起常驻时填 `Path(__file__).resolve()` =「**当前正在跑的那一份**」;
若它本身是**技能目录那份**(P0-57 形态)⇒ 续命又起技能目录那份 ⇒ **互为镜像、生生不息**。
2. `_pid_is_collabd()` 判「这 pid 是不是本区常驻」的**基准**同样被技能目录那份顶着
⇒ **真的本区常驻反被判「不是我」** ⇒ 每轮 `--ensure` 都觉得"没人跑" ⇒ **再拉一条**。
(与 P0-57 是**同族病、不同位置**:那个在"生成 ps1",这个在"起进程"与"判身份"。)
**放大链路(为什么用户看到的是"弹窗洪水"而非单纯多几条进程)**:
每条常驻都挂在一条 keeper(`start-supervise.ps1` → `powershell.exe`)下,
keeper 用 `Start-Process` 拉起子进程时**闪一次窗** ⇒ **常驻越多 ⇒ keeper 越多 ⇒ 弹窗越多**。
🔴 **CPU**:主循环每 `supervise_interval`(本区 `10` 秒)转一圈 ⇒
**每多一条常驻就多一份 10s 节拍** ⇒ 17 条同时转=用户感知的"高占用"。
⚠️ **单条其实很轻**(实测 **0.02~0.09 s CPU / 20 s ≈ 0.1~0.5% 单核**)⇒
「常驻吃 CPU」的**真因是条数**,⛔ 不是单条本身 —— 排查时先数条数,⛔ 别去优化循环体。
✅ **修法=抽唯一基准 `_own_path()`(发布物优先),三处共用**:
```python
def _own_path() -> Path:
"""本区「正宗」的 collabd.py —— 发布物优先,⛔ 不是 `__file__`。"""
pub = Path(WS) / ".workbuddy" / "collab" / "collabd.py"
if pub.is_file():
return pub
return Path(__file__).resolve() # ⚠️ 回落必须打告警(⛔ 静默=用户以为跑的是副本)
```
接线三处:① `ensure_supervise()` 的 Popen 目标 ② `_pid_is_collabd()` 的身份基准
③ `_escalate_to_keeper()` 的 `__SCRIPT__`。
🔴🔴 **一句话判据(记住这条就够)**:
> **凡是回答「以哪份程序为准」的地方,一律走 `_own_path()`;
> ⛔ 严禁 `Path(__file__).resolve()` —— 它答的是「正在跑的那份」,而那可能正是错的那份。**
📌 **通用形状**:**「当前这份」不是一个可靠的基准** —— 进程可能由**错的路径**被拉起。
任何「自我复制 / 自我补起 / 自我校验」的逻辑,都必须先钉一个**外部锚点**
(这里是"本区发布物路径"),⛔ 不能拿"我自己的位置"当锚。同族:P0-57、P0-51。
🔴 **回归资产**:`t_ensure_spawns_published_copy`(6 项)+ **变异对照已验红**
(把 `str(_own_path())` 换回 `str(Path(__file__).resolve())` ⇒ 必红)。
⚠️ **排查顺序(省时间)**:症状是"弹窗多/CPU 高"时,按此序取数,⛔ 别一上来就读代码:
① `Get-CimInstance Win32_Process` 数 **collabd 常驻条数**(⛔ 不是看"有没有在跑");
② 看每条的第 4 个参数**文件路径** —— **有没有技能目录那份**(有 ⇒ 就是本条);
③ 数 **keeper 条数**(`start-supervise.ps1`)—— 它≈弹窗数;
④ 读 `_collabd.log` grep「**身份对不上**」—— 出现即本条确诊。
## P0-56 🔴🔴 **把「目标状态」当成「该不该处理队列」的前置条件 ⇒ 队列里的活永远没人接**(★ 2026-10-04 用户点破)
**症状**:某工作区 `_collabd.log` 刷屏同一行 ——
`检查会话:目标状态=已完成(非进行中)⇒ 不建`;
而它的 `tasks.json` 里躺着**没干完的件**。⇒ 表面上"检查程序在正常运行、只是没到条件",
实际是**有活没人干,且永远不会有人干**。
**病根(一行)**:`maybe_spawn_check_agent()` 的闸① 写成了
```python
if life != GOAL_LIFE_RUN: # ⛔ 错
log(...); return None
```
它让「目标已被标成完成」成为「**处理队列**」的前置条件 —— 逻辑不通。
**用户口径(逐字,2026-10-04)**:
> 「首先执行程序要把执行结果 的文本地址或引用写入检查程序的队列,
> **检查程序必然要去处理队列的情况**(是等待工作区所有会话停止后,把队列情况一并处理)」
⇒ 拆成三条,逐条对:
| 用户的话 | 机制里的对应 | 当时状态 |
|---|---|---|
| 执行结果要写**地址或引用**入队列 | `--report --state done` 缺 `--artifact` **直接拒收** | ✅ 早已落地 |
| 检查程序**必然**要去处理队列 | 闸①(目标须「进行中」) | 🔴 **被挡死 —— 就是要改的那一处** |
| 等**所有会话停止后**一并处理 | `_all_sessions_idle()` | ✅ 早已存在,对得上 |
🔴 **闸① 为什么只该捆在 `queue-empty` 上**:两类检查会话职责不同 ——
- `sessions-ended`(**队列非空**)= 面对「**有活没人干**」⇒ 与目标状态**无关**:活没干完就是没干完。
- `queue-empty`(**队列为空 + 静默 ≥20 min**)= 面对「**没活了但目标可能没完**」⇒
它的职责正是**判目标该不该收口** ⇒ 目标都不是「进行中」了,这条没有意义 ⇒ **保留闸①**。
**为什么难查**:日志**说的是实话**("目标状态=已完成"),措辞也像**正常的分支说明**
⇒ 一眼看过去像"按设计拦下了",⛔ 不像故障。**当天有会话据此写下"这是设计,不是故障"的结论** —— 被用户当场否掉。
📌 **教训**:日志**如实描述条件**,⛔ **不等于**那个条件是**对的**。读到"某条闸拦下了"时,
必须回头问一句:**这条闸该不该捆在这个条件上?**
✅ **修法**:
- ① 闸① 收窄 ⇒ `if reason == "queue-empty" and life != GOAL_LIFE_RUN:`(唯此一处)。
- ② **跨区自愈同理**:`peer_supervise_sweep()` 里"目标非进行中就不拉"的判据**一并收窄**,
改成先看队列有没有未完成件(有活仍拉)。
- ③ 🔴 **抽唯一事实源**:新增 `queue_pending_of(root)` —— 跨区队列计数**只此一份实现**。
⛔ 别在自愈代码里**另写一段读 `tasks.json` 的代码**(那正是 `goal_life_of()` 当初被抽出来要治的病;
用户口径「**禁止重复判定标准**」)。
- ④ **试算(`--dry-run`)必须与真实闸逐条对齐** —— 改闸不改试算 ⇒ 试算说"不建"、实际"建"(自查发现)。
- ⑤ **端到端 + 变异对照**:⛔ 只 `grep` 源码不算证。造临时区真跑两条 `reason`
(同场景对拍:`sessions-ended` 必须放行/`queue-empty` 必须拦下),
再植变异回写 `if life != GOAL_LIFE_RUN:` ⇒ **断言必须报红**(证明判据不恒绿)。
📌 **通用形状**:**「闸」捆错条件是静默故障的高发区** ——
闸描述的是「条件不满足就不动」,⛔ 它不告诉你**条件本身选得对不对**。
同族:P0-50(忙判据只读一个源)、P0-55(测了零件没测装配)。
## P0-58 🔴🔴 **只查「有没有写」⛔ 不查「那个文件在不在」⇒ 台账里的 `done` 指不到东西**(★ 2026-10-04 用户点破第二条)
**用户口径(逐字)**:
> 「之前还说过 **执行会话的结果要形成文档,目标也要完成情况的文档**,
> 这样后续检查会话和后续执行会话都可根据文档继续处理,**避免全工作区到处找信息**」
**半落地的形态**(这是本条最值得记的地方 —— **不是"没做",是"只做了一半"**):
- ✅ **写入侧有闸**:`--report --state done` 缺 `--artifact` **拒收**(10-03 立)。
- 🔴 **读取侧不验**:**只查 `artifact` 有没有写**(非空即放行),**⛔ 不查文件是否真存在**。
⇒ 路径打错的/产物被移走的/**绕过闸写进去的旧数据**,照样算 `done`
⇒ 检查会话照它读 **读空** ⇒ **又回去到处找** —— 正是要避免的那件事。
🔴 **活证据(本区台账,实测)**:
```json
{"t": {"state": "done"}} ← 没有 artifact/by/t_end,task-events.jsonl 里也没记录
```
它**比拒收闸(10-03)还早** ⇒ **是绕过闸写进去的**。
📌 **教训**:**写入侧的闸管不了"已在库的"** —— 闸只对**新写的**生效。
凡"加了闸"的地方,都要再问一句:**库里已有的那些怎么办?**
**当年为什么没查(旧注释逐字,保留以示"理由也会过时")**:
> 「判据只查台账里有没有产物字段,⛔ **不判断文件是否真存在** —— 那是检查会话的活;
> 在这里查路径会把「相对工作区」写法全误杀」
⇒ **那是把两件事混成一件**:**「相对路径」从来不是问题**,**路径拼错/产物没落盘**才是。
⇒ 用"怕误杀"换"不验收",代价是**缺口一直开着**。
✅ **修法(存在性=硬闸,落点=软判据,⛔ 两层别混)**:
- ① **`artifact_state(tid, rec)`** = **唯一事实源**(`ok`/`missing`/`gone`):
按工作区解析后**真去 stat**(两种斜杠形态都试),⛔ 不抛异常(一律落 `gone` 并附原因)。
- ② **`--report done` 加硬闸**:`artifact_state() != ok` ⇒ **拒收**并打印**试过的路径**
(⛔ 不猜、⛔ 不静默放行)。
- ③ **`artifact_dir_ok()` 落点判据 = 软判据(⛔ 不拒收)**:
⚠️ 为什么软的:**存量里有落在别处但有效的产物**(`designs/*.html` 等)⇒ 硬拒=误杀既有工作流。
但**必须打印提醒 + 写日志** —— ⛔ **静默通过=这个合同等于没立**。
- ④ 🔴 **`artifacts_unverifiable()` 由机制侧预挑**,渲染进检查会话 prompt:
⛔ **别让会话自己扫台账判断**(每条会话重算、标准还可能不同 ⇒ 与 `goal_life_of()` 同病;
用户口径「**禁止重复判定标准**」)。
- ⑤ **文档合同写进机制**(`DOC_CONTRACT_ROWS`)—— **三份文档各有唯一作者与唯一时机**:
| 文档 | 谁写 | 什么时候 |
|---|---|---|
| `<目标目录>/目标执行状态.md` | **目标检查会话** | 核对完目标状态后 |
| `<目标目录>/<棒次>_<事项>_<日期>.md` | **执行会话自己** | 本轮做完时(`--report done` 之前) |
| `tasks.json` | `--report`(机器) | 每次状态转移 |
🔴 **为什么合同必须有**:实测**三区三种形态** ——
某区只有状态文档(420 B,**执行产物没落这儿**)/某区有手工写的「过程记录」(**非机制要求**)/
本区 `S12_*.md` 落对了。**同一套机制、三个区三种形态** ⇒ 缺的**不是能力,是合同**。
**判据(本条的验收,缺一不可)**:
- **行为级**:临时区里真跑 —— 真产物 → `ok`;没写 → `missing`;路径不存在 → `gone`;
`--report` 对这三种分别 **放行/拒收/拒收**。
- **变异对照**:把「验存在」短路 ⇒ 「给了不存在路径」必须**从 rc=3 变 rc=0**(证明判据不恒绿)。
- 🚨 **夹具必须真造出那个文件** —— ⛔ 不能再用 `artifact="x/T3.md"` 这种占位路径
(**那正是"报了个打算写的路径"这个要治的病本身**;本条的旧自检就栽在这)。
📌 **通用形状**:**「加了写入闸」≠「数据干净了」** ——
闸只拦新写的;**存量要靠"读取时判"来兜**。同族:P0-52(缺的不是文档,是机器抓手)、
P0-54(剥注释要用 AST,⛔ 不用正则 —— 本条连带修出第 3 次)。
---
## P0-59 🔴🔴 **判据与设计目标互斥 ⇒ 每轮必报红**:口径改了两周,体检里还在找"已退役的周期钟"(★ 2026-10-04 `vibe-product` 实测)
**症状**:`session-rules-check.py` **每轮都报** `✗ [C] 周期钟缺失 ⇒ 没人在推 / 收 —— 唤醒、跟进`,
而这两类会话**用户早在 2026-10-03 就要求整套退役**(SKILL.md 文首口径块写着「唤醒会话/跟进会话/队列上报
**整套退役**」)⇒ **判据惩罚的正是"按用户要求删对了的东西"**。
🔴 **这是"判据与设计目标互斥"最纯粹的一例**(与 P0-24 的"不可满足的判据自己就是逃生门"同族):
判据在**任何**工作区都过不了 ⇒ 每轮红 ⇒ 读报告的人学会的不是"去改",而是"这条不用管"
⇒ **一条恒红的判据会把整张体检表一起废掉**。
✅ **修法=换尺子(⛔ 不是删判据)**:
· 旧:*"唤醒/跟进周期钟**必须存在**"* —— 设计要求它们不存在 ⇒ 互斥。
· 新:*"**在册的**退役周期钟**必须已 PAUSED**"* —— 还在 `ACTIVE` 才说明退役没做干净。
即把"要求有"翻成"**有了就不许是活的**",判据方向跟着口径一起翻。
· 🔴 **同时要修它的下游**:⑧「模型可用性」原写 `for lst in clocks.values()`,
退役后 `clocks` 直接不存在 ⇒ ② 若只改 ⑦ 不改 ⑧ ⇒ **`NameError` 让整个体检抛异常**
(实测:`⚠️ 体检自身异常:name 'clocks' is not defined`,**体检静默失效**——比报红更糟)。
⇒ ⑧ 改为扫**本工作区全部 `recurring` 排期**(覆盖面只增不减)。
⭐ **顺带治掉一个"恒绿假通过"**:⑧ 原本只扫"周期钟",而退役后本区 recurring 可能一条都没有
⇒ `clocks` 为空 ⇒ `deaf=[]` ⇒ **恒判 ok**,哪怕真有 `flash + thinking=0` 的排期在跑。
⇒ 改成扫全部 recurring 之后才抓得住(变异对照:合成库放一条 `glm-4-flash` ⇒ 正确报 ✗)。
📌 **变异对照(本区真数据测不出,因 recurring=0 ⇒ 必须用合成库)**:
| 场景 | 期望 | 实测 |
|---|---|---|
| 退役排期 `status=ACTIVE` | ⑦ 报 ✗ | ✅ `✗ 已退役角色的周期钟仍在 ACTIVE` |
| 退役排期 `status=PAUSED` | ⑦ 报 ✓ | ✅ `✓ 已全部 PAUSED` |
| recurring + `flash` + `thinking=0` | ⑧ 报 ✗ | ✅ `✗ 周期排期会被服务端拒` |
⚠️ **测法自身的坑(我又踩了一次)**:首版变异脚本三个场景复用同名临时目录 ⇒
场景 B 的文件没清,**场景 C 的库里混进两条** ⇒ `_recur` 计数被覆盖 ⇒ **假报"⑧ 恒绿"**。
⇒ **造合成库必须一场景一目录**;⛔ 别拿"上一次的输出"当这一场景的结论。
(这与 P0-39「自测不许复用被测环境」是同一个病:**隔离不彻底 ⇒ 绿红都不可信**。)
🔴 **推广判据**:**凡改了口径(退役 / 改名 / 换供给),必须回扫所有"体检 / 自检 / 门禁"里
是否还有指向旧口径的判据** —— 它们不会自己报错,只会**每轮安静地红着**(或更糟:安静地绿着)。
本次真因就是:**改 SKILL.md 文首口径块时,没有连带扫 `scripts/session-rules-check.py` 的判据数组。**
---
## P0-60 🔴🔴 **`DETACHED_PROCESS` 不脱离作业对象 ⇒ 常驻的「5 秒回验」量不出真相**(★ 2026-10-04 `vibe-product` 实测)
**症状**:用户报「**你的检查程序怎么老停止啊**」。日志呈现固定节律 ——
起一条 → 心跳正常跑 20+ 轮(≈10 分钟)→ 下一轮消息进来时报「pid XXXX 已不在进程表」→ 续命新 pid。
**看着像:常驻自己在崩。实际是:被外力杀。**
**真因(一句话)**:`ensure_supervise()`(`collabd.py:547`)用的 flags 是
`NO_WINDOW | DETACHED_PROCESS(0x8) | NEW_PROCESS_GROUP(0x200)`。
🔴 **`DETACHED_PROCESS` 只脱离控制台,⛔ 不脱离作业对象(Job Object)** ⇒ 它只是「看起来脱离了」,
实际仍挂在 WorkBuddy 会话的作业对象上 ⇒ **会话树一被回收,它一起被杀**。
(`CREATE_BREAKAWAY_FROM_JOB` 被本机拒绝 ⇒ **这条路封死**,别再试改 flags。)
**取证(唯一可信)** —— `workbuddy-resident-service/scripts/proc_chain.py <pid>`:
```
PID 72088 in_job=True 在作业对象内 ← 🔴 决定性:还在作业里
└─ pythonw.exe
PID 468 —— 已退出
```
**🔴🔴 本条最值钱的一句:回验窗口必须 ≥ 被观察现象的时间尺度。**
`supervise-ensure-hook.py` 的 `verify=True` 路径只回验 **5 秒**(`for _ in range(12)` × 0.4 s)。
而本现象要 **10 分钟**才显形 ⇒ **5 秒窗口里它「验证通过」了,然后 10 分钟后死掉** ——
这是**恒绿的假通过**(与 P0-59 同族:判据本身没问题,**量法的时间尺度错了**)。
⇒ **判「常驻健不健康」的正确量法**:**静置 ≥2 个会话轮次后,再问 `supervise_alive()`**。
⛔ 不许用「起完立刻验一次活着」当结论。
**同型的另外两条(一并记住)**:
1. **别把「现在活着」当「这种起法能长期」** —— pid 19424 曾活 24.2 h,只因为**它的父链已退出**
(孤儿化后不受会话结束影响)。⇒ 要判「还能活多久」=**先验父链 + 读心跳 `started_ts`**。
2. **判「是不是被正常停的」要看 `guard.stop`** —— 它在 ⇒ `supervise_stop()` 走的收工流程(正当);
**它不在 + 进程没了 ⇒ 被外力杀**(本轮的判定路径就是这条)。
**口径边界(⛔ 别搞反)**:本现象**不是故障**。`supervise-persistence.md` 口径明写:
常态=「调用技能完成目标时起后台任务 + 检查程序」,**会话一收工就断,断了自己会补**
(实测补起延迟 1~2 分钟)。⭐ **只有「目标做完还要继续跑」才需要计划任务那一层**。
🟠 本行原写「那层**是可选,⛔ 不许当欠项摊**」(2026-10-04 订正)——**已被 2026-10-05 用户拍板推翻**:
计划任务现在是**既定形态**(`collabd-keepalive-<区>` 每 5 分钟兜底,开关 `collabctl.py on|off`)。
⚠️ 两句话不矛盾:**日常不必操心**(机制自己装),但它**是既定形态**,⛔ 不是"可有可无的选项"。
---
## P0-61 🔴🔴🔴 **全局钩子只认「一个」工作区 ⇒ 其余区的常驻全靠会话 `cli` 拉,会话一收工就被回收**(★ 2026-10-04 `vibe-product` 实测)
**用户原话**:「为什么就这个工作区一直在出问题,**别的会话我看一直没有停止过**?」
**症状差异(实测同刻对比,`ai1net-dsh-server` vs `vibe-product`)**:
| | `ai1net-dsh-server`(一直活着) | `vibe-product`(每 10 分钟被续命) |
|---|---|---|
| 心跳 `round` | 38(23:22:44 起) | 13(23:22:44 起,**同一时刻起的**) |
| 续命 `why` | **`tick`** | **`cli`(10 次全是)** |
| 本区 `hook.log` | 有 | 🔴 **根本不存在**(钩子从没往这区写过一行) |
**真因(一行)**:`wb-result-hook.py`(全局 hooks 目录**唯一一份**)用
`resolve_ws()` 解析出**一个**工作区,然后 `INBOX` / `TICK_STAMP` / `HOME_WS` **全部按它定死**:
```python
WS = resolve_ws() # 🔴 全局只有一份钩子 ⇒ 只会解出一个区
INBOX = os.path.join(WS, "tmp", "supervise-inbox")
TICK_STAMP = os.path.join(INBOX, "_tick.stamp")
```
**实测该函数解析结果**:
```
WS = 'E:/ProgramData/AIProject/ai1net-dsh-server' ← 只认这个区
INBOX = '…\ai1net-dsh-server\tmp\supervise-inbox'
```
⇒ **`--tick`(顺手续命)只对解析出来的那一个区生效。**
其余区(含本区)的 `classify()` 落到 `scope="other"`,**钩子不为其续命**,
常驻只能靠 `ensure_supervise("cli")`(=**从会话钩子/我的命令行起**)⇒
**挂在会话的作业对象上** ⇒ **会话一收工就被回收**(实测:起后跑 20+ 轮 ≈10 分钟,下一轮消息进来时已死)。
**⚠️ 别再往"作业对象"上归因**:实测**两区的常驻都是 `in_job=True`**(`proc_chain.py` 现跑)——
作业对象是**共同机制**,不是差异点。**差异点=「谁拉起它」**:
`tick` 起=脱离会话(拉它的常驻长期活着);`cli` 起=绑在会话上。
**取证方法(三步,缺一不可)**:
1. **同刻对比两区心跳**:`round` 与 `started_h` —— 同一时刻起、round 差一倍 ⇒ 一区被回收过。
2. **看续命 `why`**:`grep "常驻续命" <区>/logs/_collabd.log | tail` —— `why=tick` 健康,`why=cli` 病态。
3. **看该区有没有 `hook.log`**:`tmp/supervise-inbox/hook.log` **不存在** = 全局钩子从没管过这个区。
🔴 这是最快的一步(一条 `ls` 定案)。
**修法(⛔ 别改 flags、别改 creationflags —— 那治不了)**:**让常驻由常驻拉起,而不是由会话拉起。**
即本区要有自己的"顺手续命"入口:要么把本区纳入 `resolve_ws()` 的解析范围(多区支持),
要么本区自建一个**不依赖会话**的入口(见 `supervise-persistence.md` 场景 A/B 的载体)。
⚠️ **在修好之前,本现象=口径内的常态**(会话收工时断、下次事件补回),⛔ **不是"机制坏了"**。
**同族推广判据**:**凡「全局唯一一份脚本 + 内部按某个环境量解析出'当前工作区'」的写法,都要问一句:
"它解析出的到底是谁?其余实例怎么办?"** —— 这类缺陷**不会报错**,
只会让**非默认的那一方**长期以"降级方式"运行,且**看起来一切正常**(心跳在跳、日志在写)。
---
## P0-63 🔴🔴 **计划任务直起 `pythonw.exe` ⇒ 秒退 `Result=1`、端口从没绑上**(★ 2026-10-05 看板载体实测)
**现象(连复现 3 次)**:`dsh-board-20099` 的动作=
`pythonw.exe -u "…\board.py" --serve 20099` ⇒ 每次 `Start-ScheduledTask` 都
**秒退、`LastTaskResult=1`、端口从未听到**。任务 `State` 立刻回到 `Ready`。
**排除项(逐个做掉,别跳)**:
1. **代码没问题** —— 加一层 `runpy` wrapper(同一 `pythonw.exe`、同一参数、同一工作目录)
跑 `board.py` ⇒ `LastTaskResult=**267009**`(=`0x41301`「任务正在运行」),
日志里明写 `看板已起:http://127.0.0.1:20099/`。
2. **命令行没问题** —— `(Get-ScheduledTask).Actions[0]` 逐字打出来(含 `ConvertTo-Json`)
与手工串**完全一致**,无多余引号、无 BOM 残留、无转义问题。
3. **同一串命令由 Python `Popen` 起 ⇒ 正常绑端口**(`poll=None`、`netstat` 见 LISTENING)。
⇒ **差别不在命令、不在代码,在「谁拉起它」**。
**真因**:**任务计划程序直接拉起 GUI 子系统程序(`pythonw.exe`)这条路不可靠。**
同族证据就在本技能的 `start-supervise.ps1` 文件头(早被踩过):
⛔ `& $pyw …` 是调用运算符、**不阻塞** GUI 子系统程序 ⇒ 脚本以为"它退出了";
⛔ `.ps1` 不带 BOM ⇒ PS 5.1 按 GBK 解码 CJK 路径 ⇒ **任务一秒内 Result=1 退出**。
**✅ 正解=照 `collabd` 已验证的 keeper 形状**(⛔ 别自创):
1. 任务动作改成 **`powershell.exe`**:
`-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "<keeper>.ps1"`
(⛔ 不加 `-NonInteractive`;⛔ 动作里**不要**出现 `pythonw`)。
2. `.ps1` 里用 **`Start-Process -FilePath <pythonw> -ArgumentList … -PassThru -Wait`**
—— `-Wait` 给的是**真阻塞**(这是 keeper 能"守着"子进程的前提)。
3. `.ps1` **必须存成 UTF-8 带 BOM**(`encoding="utf-8-sig"`,行尾 `\r\n`)。
落盘后立刻自检前 3 字节 = `\xef\xbb\xbf`,并用
`[System.Management.Automation.Language.Parser]::ParseFile()` 过一遍语法。
4. keeper 内部:**端口上已有本看板 ⇒ 只睡不重拉**(判据=`/healthz` 同时含 `"ok"` 与 `"snapshots"`,
⛔ 不是「端口开着」)⇒ 避免叠实例。
**验收判据(全绿才算完,⛔ 缺一不可)**:
- `(Get-ScheduledTask).State` = `Running`
- `LastTaskResult` = **267009**(`0x41301`)—— ⛔ 不是 `1`、⛔ 不是 `0`
- 端口 `LISTENING` 的 PID 属主是 `pythonw.exe`,且 `CommandLine` 指向**该跑的那一份**脚本
- 业务面:目标真值/`main_by_topic` 等**在快照里真的出现**(⛔ 不看"端口在听"就当成功)
**排查顺序(最快路径)**:
① `(Get-ScheduledTask -TaskName X | Get-ScheduledTaskInfo).LastTaskResult`
② 看是不是 `1` 且任务秒回 `Ready` ⇒ 基本就是这个坑
③ 用 wrapper(`runpy`)复跑一次:若 wrapper 绿、直起红 ⇒ **定案**
**同族推广判据**:**凡「计划任务/服务管理器/CI runner 直接拉起"无控制台"的程序」的写法,都要问一句:
"宿主真能把它的生命周期管住吗?"** —— 这类缺陷**不报错、不留日志**,
只在调度器那侧留一个 `Result=1`,而**手工跑一切正常**,极易误判成"代码坏了"。
## P0-64 🔴🔴🔴 **改完技能包 ≠ 生效:常驻跑的是工作区里的「部署副本」,副本没同步 ⇒ 修复对运行中的进程完全不可见**(★ 2026-10-05 看板通过率实测)
**现象**:`board.py::acc_is_pass` 已改好、`selftest` 全绿、跑探针也绿,
但**页面上「完成情况」依旧 0 / 9 通过**;直接 `import board` 调 `acc_is_pass('已过|…')`
返回 `True`,可**服务端 `/board.json` 的 `acc_summary` 仍写着 `非 pass:['V1'…'V9']`**。
**真因**(两层,都要治):
1. **同一份代码存在「技能包」与「工作区部署副本」两份**,而**常驻进程加载的是副本**:
- `board.py` 由计划任务起,argv0 = **技能目录**那份 ⇒ 改了立刻生效 ✅
- `collabd.py` 由 `start-supervise.ps1` 起,argv0 = **`.workbuddy/collab/collabd.py`**(工作区副本)⇒ **改了技能包它不知道** ❌
- 实测 `Get-CimInstance Win32_Process` 逐条 argv0 才看清:
`pid 66620 → …\ai1net-dsh-server\.workbuddy\collab\collabd.py --supervise`。
2. **副本可能停在很久以前**:本区副本的 `board.py` md5 与技能包**差 46 行**、
`collabd.py` **少 718 字节**,副本里 `ACC_ADV_PREFIX` **一次都没出现**(`grep -c` = 0)。
⚠️ 副本**不是**被人手改过(`diff` 显示差异**只有**我们那几处新代码)⇒ 是**纯陈旧快照**,
属于 P0-51「修完全局忘了同步」的**同一族第 N 次**,只是这次栽的是**运行中的常驻**不是副本自检。
**✅ 正解(四步,⛔ 缺一不可)**:
1. **先取证「谁在跑哪一份」**:`Get-CimInstance Win32_Process` 列 `python%` 的
`ProcessId + CommandLine`(⛔ 别靠"我以为它读技能目录")—— 一个工作区里
`board` 与 `collabd` **可能来自两个不同位置**。
2. **同步副本**:技能包 → `<ws>/.workbuddy/collab/{board.py,collabd.py}`,
先 `shutil.copy2` 备份成 `*.bak-sync-<时间戳>`,再**写临时文件 + `os.replace` 原子替换**,
最后**逐字 md5 复核**(⛔ 不靠"我复制过了")。
⚠️ `workspace_mirror.py` **管不了这个目录** —— 它要求副本里有 `SKILL.md`,
而 `.workbuddy/collab/` 是**部署目录不是技能副本** ⇒ 会直接 `⛔ …不像技能副本 ⇒ 不动`
(本目录曾因此静默跳过,`--check` 报 97 项漂移却没人看)。
3. **重启常驻**:`Stop-ScheduledTask` → 杀旧 pid → `Start-ScheduledTask`;
验收=**心跳里出现新 pid** + `argv0` 指向本区副本 + 任务 `Running`/`267009`。
⚠️ 只同步**不重启** ⇒ 进程内存里仍是旧 bytecode(实测就是这个状态。
铁证:本机 `board._acc_summary()` 返回「全部 pass(9 条)」而服务端返回旧的 `非 pass` 列表
—— **同一份磁盘代码,两个结果 ⇒ 差的一定是"谁在跑"**)。
4. **判据落到进程级**:确认新 pid 用的副本**真含新逻辑**(在本区副本目录里
`import` 它自己的模块跑一批用例 + 与 `board.py` **交叉核对零打架**)。
**排查顺序(最快路径)**:
① 页面/接口不对,但 `import` 直接调**对** ⇒ 立刻怀疑"跑的不是我改的那份"
② `Get-CimInstance Win32_Process` 抓 argv0(比猜快 100 倍)
③ 对副本与技能包做 `md5` + `grep 新代码关键字`(`grep -c` 为 0 ⇒ 定案)
④ 同步 → 重启 → 复核
**同族推广判据**:**凡"技能包改了、跑起来却没变",一律先问三句:
① 这个进程的 argv0 指向哪份文件? ② 那份文件 md5 对不对? ③ 它重启过没有?**
—— 这三问能一次排掉「改错地方 / 副本陈旧 / 没重启」三类,而它们**症状完全一样**(都不报错、都静默)。
## P0-65 📌 **「一次性排期」(`schedule_type='once'`)= 机制唯一开新会话的通道,闸④ 防它把闸卡死**(★ 2026-10-05 只读取证)
- **它是什么**:`automations` 表里 `schedule_type='once'` 的行 —— **排一条未来某刻开一条新会话的「闹钟」**,
触发一次即作废(⛔ 不像 `recurring` 会再来)。本机实测在册 **37 条**。
- **谁排的(两个创建者,⛔ 都不是"会话自己")**:
1. **常驻程序** `collabd.py::create_check_schedule()`(第 ~3894 行)—— 唯一的**检查会话**建排期入口
(用户第④条:「创建者是协作程序,不是会话」)。固定 `schedule_type='once'`、延迟默认 90s。
实测产物=`[检查]-[结果检查]-selftest-第N棒`(11 条)。
2. **`_gap_plan(role, topic, topics)`**(第 ~5331 行)—— 缺会话时**给出"该怎么拉起"的现成参数**
(`scheduleType='once'` + `scheduledAt=now+90s`)。⚠️ 它**只生成参数**,真正落库的那一步在别处。
- **干什么用的**:**开新会话**。🔴 关键口径(本包红线):**自动化是"开新会话"的唯一通道**
⇒ 想让某件事在**未来某个时刻**由**新会话**接手,就必须落一条 `once` 排期。
实测用途:检查会话(自检棒)、任务会话(`[协作]-[界面交互]-第三步重跑`)、跟进会话。
- **闸④ 是干什么的**:`ws_pending_schedules()` 的判据之一 ——
**本工作区只要还有"待执行"的排期,就 ⛔ 不再建检查会话**。
防止:排期已排好、会话还没到点开 ⇒「会话全结束」成立 ⇒ 再建检查会话=**同一件事两遍**。
⚠️ **fail-safe 方向=不建**(读库失败 ⇒ 返回非空 ⇒ 当"有排期"处理)。
- **为什么必须"跳过不会再触发的一次性排期"**:`once` 排期到点触发后,**宿主会把 `next_run_at` 清成 NULL**,
但 `status` 仍 `ACTIVE`、`last_run_at` 仍 `None` ⇒ 一条**已经开完**的排期看起来还像"待执行"
⇒ 它会**永远**把闸④ 卡住 ⇒ 本工作区**再也建不出检查会话**(静默、无报错)。
✅ 两种"不会再触发"都判:**(a)** `next_run_at` 已过 15 分钟宽限;**(b)** `next_run_at` 被清成 NULL。
⚠️ **只对 `once` 生效**(`recurring` 永远会再触发,⛔ 不按过期排除)。
- **实测取证(2026-10-05 只读)**:闸④ 对 `selftest` 工作区跳过 **11 条**、`vibe-product` 跳过 **1 条**,
全部是 `next_run_at=NULL(已消费)` 这一类 —— 正是 (b)。若不跳过,这两个工作区**各自永远卡死**。
- 🔴 **已知隐患(未修,待拍板)**:工作区判据是 **子串匹配** `p.lower() in _s.lower()`(第 ~4464 行),
而 `pats` 里有裸工作区名 ⇒ `vibe-product` **会命中** `vibe-product/tmp/selftest` 这类**子目录**的 cwds
⇒ 可能把**别的目标**的排期算成本区的 ⇒ 闸④ **误拦**(少建检查会话)。
✅ 正解方向=比 `cwds` 数组的**逐字同形**(同 `_same_ws`,两侧都 `norm` 后 `==`),
⛔ 不是子串 `in`。⚠️ 改它要同步 `_all_sessions_idle` 的同类写法(同一族判据)。
## P0-66 · `--tick` 已删(2026-10-05):判定的权威在**常驻**,⛔ 不在钩子
用户原话:「检查程序 常驻 自己不会判断吗,非要什么 tick once 去触发?」
**判据**:需要「**什么时候该开检查会话**」这种判定 ⇒ **只许由 `--supervise` 主循环承担**;
钩子⛔ 不得是任何判定的触发源(钩子只是"事件到了顺手续命")。
### 删 tick 时**必须**搬走的三件事(漏一件就断链)
1. **建检查会话排期的 `sessions-ended` 腿**
⛔ 它原先**只**挂在 `--tick` ⇒ 删了 tick 就**永远建不出"结果检查"会话**(队列非空=有活没人干)。
✅ 现在在 `--supervise` 主循环里:`if queue_pending() > 0: sessions-ended else: queue-empty`
2. **park 指纹探针**(`parkInQueue` + `hasWaiter=false`)⇒ 抽成 `_tick_park_probe()`,常驻每 20 轮调
⚠️ 它**不依赖投递** ⇒ 投递退役后仍有效,⛔ 不许跟着 tick 一起删
3. **顺手续命常驻** ⇒ 钩子改调 `maybe_ensure_supervise()` ⇒ `collabd.py --ensure`
🔴 **只留 `UserPromptSubmit` 一处**(`PreToolUse` 极高频 ⛔ 不补;`SessionEnd` 从不被投递=死代码)
### ⛔ 参数表里**不许**留 `"--tick"`
留着 ⇒ 传 `--tick` 会**不被拒绝**、落进"无分支匹配"⇒ **静默退出/超时**(实测 TimeoutExpired)。
### 🔴 `once` ⛔ 不许删(与 tick 无关,是主干)
`create_check_schedule()` 落 `automations(schedule_type='once')` = **唯一「开新会话」的通道**。
删它 ⇒ 检查会话永远开不出来。闸④(`ws_pending_schedules`)防的是它卡闸,见 **P0-65**。
### 自测判据的两条死穴(本轮实测踩到)
1. ⛔ **字面判 `"--tick" not in <源码>`** ⇒ **永久假红**:删除说明的注释里**必然**提到它
(P0-13 同族)⇒ ✅ 只认**带引号的 `"--tick"`**,⛔ 不认裸串。
2. ⛔ **判据由环境状态决定** ⇒ 合成测试区残留真常驻时,`ensure_supervise` 走**闸①幂等让位**、压根
不到口令闸 ⇒ `startswith("no-token")` 恒红 ⇒ ✅ 两条闸的返回**都要接受**(目标同为"不起新的")。
## P0-67 · 排期不是「定时任务」,是**开会话的申请书**(2026-10-05 用户点破,⛔ 别再说错)
用户原话:「你就算是 once 不也是走的定时任务吗,难道能直接创建会话?」
**完全正确**。⛔ 我此前说「`once` 是唯一开新会话的通道」**容易被误读**成"once 有开会话的能力"——
它没有。准确表述:
- **`once` 自己也是排期**,也是被宿主到点拾取的;它**同样不能**直接创建会话。
- **能开会话的永远只有宿主**。AI 侧(本程序是宿主之外的 python 进程)**没有任何开会话的能力**。
- ⇒ `once` 的真实身份 = **AI 侧唯一能递到宿主手里的"纸条"**:
「请你在 X 时刻开一条会话,prompt 是 …」。宿主扫描到它 ⇒ 才开会话。
### 由此纠正两个长期绕圈的说法
1. ⛔ 「为什么会产生排期/能不能不排期」—— **不能**。要开会话就必须写这张纸条,
⛔ 没有第二种投递方式。排期**不是"定时需求"**,是**"开会话的申请书"**。
2. ⛔ 「把 once 删了、程序自己判断直接创建会话」—— 判断可以自己做(常驻五道闸已经在做),
但**创建那一步做不到**,与删不删 once 无关。
### 那"排期堆积"到底是什么
⇒ **申请书副本的堆积**:申请书被宿主取走后 `next_run_at` 清 NULL,但记录**永不删除**
(`deleted_at` 恒 null)。闸④ 为了不卡闸必须跳过它 ⇒ 也就拦不住再开一张新的。
⇒ 正解不是"不用申请书",而是 **"申请书用完归档"**,或 **"同一件事在冷却期内只递一次"**。
### 一个可优化的点(⛔ 未实施)
`create_check_schedule(delay_s=90)`:既然排期只是申请书,⛔ 延迟 90 秒**不是机制需要**
(宿主扫描周期实测 ≤30 s)⇒ 理论上可缩到 ~30 s,让检查会话更早开工。
## P0-68 🔴🔴 **MCN 工作台的按钮**正是走 `once` —— 我此前「MCN 不是走 once」**结论错误,已纠正**(★ 2026-10-05 源码级实测)
用户原话:「净瞎说 … 难道 MCN 工作台也是走的 once 吗」/「我问你 MCN 是不是走 ONCE 开会话的」。
**答案:是。MCN 就是走 once,与我方 `create_check_schedule()` 是同一条路。**
⛔ 我此前据「`automations.cwds` 含 mcn 0 条 + 该区无 `.workbuddy/collab/`」推出
「MCN 压根没用协作机制,不构成反例」—— **前半对(它不经过我的常驻),
后半错(它照样写 `once`)**。错因:拿「有没有用我的程序」当「有没有用这条通道」。
### 源码级证据(逐字)
前端:`public/data-pages.js:735` 点「更新榜单数据」⇒ `POST /api/dsh/ranking-update {prompt, force}`
⇒ 轮询 `/api/run/status?id=…` ⇒ 提示「请到**左侧会话栏**查看执行」。
后端:`server.js:677` ⇒ `createOnceAutomation(RANK_UPDATE_NAME, prompt, ['mcn-data-insight'])`
⇒ `server.js:339` `INSERT INTO automations (..., status, schedule_type, scheduled_at,
next_run_at, ...) VALUES (?,?,?,'ACTIVE','once',?,?,...)`。
### 与我方实现的逐项对照(**同一张表、同一个 `schedule_type`、同一套三条硬规则**)
| | MCN `createOnceAutomation()` | 我方 `create_check_schedule()` |
|---|---|---|
| 落表 | `automations` | `automations` |
| `schedule_type` | `'once'` | `"once"` |
| `status` | `'ACTIVE'` | `"ACTIVE"` |
| `next_run_at` | `now + 5_000`(**未来毫秒**) | `now_ms + delay_s*1000` |
| `scheduled_at` | **ISO 串**(`YYYY-MM-DDTHH:MM:SS`) | 同(ISO 串) |
| `cwds` | `JSON.stringify([cwd])` 正斜杠 | `json.dumps([ws])` 正斜杠 |
| 延迟 | **5 秒** | 90 秒(下限 30) |
| 触发者 | 前端按钮(**绕开常驻**) | 常驻五道闸判过之后 |
⇒ 🔴 **「写这张表」就是宿主对外唯一的"开会话"入口**,谁都能写:
node 进程能写(`server.js`)、python 常驻能写(`collabd.py`)——**与"用不用协作机制"无关**。
⇒ 这条实证**同时坐实 P0-67**:`once` 是申请书,MCN 交的也是同一式样的申请书。
### 由此定死两条结论
1. 🔴🔴 **`once` ⛔ 不能删**。删它不是"去掉定时任务",是**掐断 AI 侧开会话的唯一通道**
⇒ 我的检查会话 + MCN 的按钮任务**同时断掉**(两处都是 `INSERT … 'once'`)。
2. 🔴 **"程序自己判断状态、创建会话"已经做到了**——判断在常驻五道闸(P0-66),
创建=写申请书。**MCN 只是把"判断"这一步交给了人(点按钮)**,我方交给了常驻。
⛔ 差别在"谁判断",**不在"要不要申请书"**。
### ⛔ 实验方法上的坑:删存量 ≠ 掐断能力,且"没重建"可能是**假阴性**
2026-10-05 做过一次:软删全库 37 条 `once`,5 分钟后回读 **count=0 未见重建**。
⛔ **不能据此说"程序不能建排期"** —— 回读同时查得:
· ai1net:`tmp/supervise-inbox/tasks.json` = `{"t":{"state":"done"}}` ⇒ `queue_pending()=0`
⇒ 走 `queue-empty` 腿;`goal.json` **无 `lifecycle` 字段** ⇒ 判「等待」⇒ **闸①「须进行中」不过**。
· vibe-product:`queue_pending()=0`;`lifecycle='已完成(机器可判部分)'` ⇒ **闸① 不过**。
⇒ **两个区当前本来就"不该建"**,不建是闸的**正确行为**。
🔴 判据:**先查闸门条件,再看有没有产物**;⛔ 只看产物 ⇒ 把"条件不满足"误读成"机制断了"。
### 可优化点(已由 MCN 实证,**仍待拍板**)
`delay_s`:MCN 用 **5 秒**被宿主正常拾取 ⇒ 我方的 90 秒**不是机制需要**(宿主扫描 ≤30 s)。
缩到 ~5–10 s ⇒ 申请书**在册时间**从 ≥90 s 压到 ~5 s ⇒ **堆积窗口同步收窄**(治标,⛔ 不治根因)。
### 回滚
37 条软删记录完整备份在 `ai1net-dsh-server/tmp/once-backup-20261005-093856.json`(157640 B)。
⛔ 但那是**已被宿主消费过**的残骸(`next_run_at` 已 NULL)⇒ 恢复只是把 37 条僵尸放回表,
**⛔ 不建议恢复**;真要恢复再清一次即可。
## P0-69 🔴🔴 **「有需要才建」是对的 —— 37 条里真正堆积的只有 11 条,且全在合成自测区**(★ 2026-10-05 备份数据实证,用户点破)
用户原话:「有需要的时候才会创建定时任务开会话,哪里来的排期呢」
**这句话是对的**,实测把 37 条拆开后结论完全变样(`tmp/once-backup-20261005-093856.json`):
### 37 条的构成(实证)
1. **ai1net 25 条(10-01 09:05 → 10-02 10:49)= 正常,不是堆积**。
名字**各不相同**(`主控 · …`/`接续 · …`/`[协作]-…`/`[跟进]-…`/`[唤醒机制] …`),
间隔 2.6 ~ 420 分钟 ⇒ **一次一个需求、有需要才建**,正是用户说的那种。
⛔ 其中**没有一条**是 `[检查]`(`[检查]` 体系是后来才进常驻的,时间线问题,⛔ 不是卡闸)。
2. **vibe-product 11 条 `[检查]-[结果检查]-selftest-第N棒`(10-04 22:34 → 10-05 00:33)= 真堆积**。
同一个 kind、同一个**合成自测区** `vibe-product/tmp/selftest`,2 小时内等距产出
(间隔 3.6 / 11.9 / 16.4 / 7.8 / 13.5 / 17.0 / 12.9 / 9.5 / 22.6 / 4.1 分钟)。
3. vibe-product 1 条 `[协作]-[界面交互]-…` = 正常需求。
⇒ **堆积占比 11/37,且 100% 来自自测区**。真实业务线**零堆积**。
⛔ 此前把 37 条统称"堆积"是**口径错误**,会误导修法(差点去动正常的那 25 条)。
### 那 11 条为什么堆:**电平触发**(状态判据当事件判据用)
常驻每 2 轮(≈60 s)判一次 ⇒ 自测区的条件**持续成立**(队列空 · 会话全结束 · 目标"等待")
⇒ **每一轮都判"有需要"** ⇒ 每轮都想建。此时**两道去抖闸同时失效,且是同一处设计导致的**:
- 闸④ `ws_pending_schedules()`:`collabd.py:4519` 起 **必须排除** `next_run_at` 已 NULL 的 `once`
(否则它永远卡闸)—— 排除了就**不再拦重复**。
- 闸⑤ 同名去抖:名字是 `第%d棒`(`_check_stamp()["round"]+1`),**N 递增** ⇒ 永远不同名 ⇒ 形同虚设。
🔴 根因一句话:**判的是"状态"(电平),却想要"变化"(边沿)**。
⛔ 此前记的"申请书副本堆积"是**表象**,不是根因;根因是**没有边沿/冷却记忆**。
### 正解(治本,⛔ 未实施)
**边沿触发**:记下上次判定时"是否有活"的指纹,只有**状态发生变化**才建
(如 `sessions-ended`:从"有会话在跑"→"全结束";`queue-empty`:队列从"非空"→"空")。
退化方案(治标):按 `kind + 工作区` 加**冷却期**,窗口内只递一次(用现成的 `CHECK_STAMP`)。
⛔ 只做"消费后归档"**不治本** —— 那治的是表变脏,治不了反复建。
### ⛔ 一条差点说错的(自我纠偏)
曾据"S7 排期 `next_run_at` 还在、且 37 条 `last_run_at` 全为空"推断"闸④ 被卡了 3 天"——
**错**:闸④ 的 `last_run_at` 口径只是**初筛**,后面 4519 行起会**排除** `next_run_at` 已 NULL/已过宽限的 `once`
⇒ 那批记录**不卡闸**。⇒ 🔴 **判闸行为必须读完整个函数,⛔ 不能只看 SQL 那一段就下结论**。
## P0-70 🔴🔴🔴 **「程序窗口一直弹出」事故:三条根因 + 总开关**(★ 2026-10-05 用户当场发火三连「关掉程序/马上停止/提供启动开关」)
⛔ **这是事故,不是小毛病**。用户屏幕被黑窗口反复打断,连发三条要求停止。
### 三条根因(逐条实测)
1、**起进程前没盘点**。`--ensure` 连起两区常驻;跑 `selftest.py` 时它 spawn 的常驻**跑完不回收**
⇒ 一度 **7 个 python 进程**并发(其中 4 个是**技能包目录**下的野 `--supervise`)。
🔴 铁律:**先盘点(`Get-CimInstance Win32_Process`)再起**;selftest 跑完要回收。
2、**用错解释器**。`collabd.py:585` 跨区补起写死 `sys.executable`(**python.exe**=控制台子系统)
⇒ 每补起一个闪一个黑窗。模块里早有 `PYW`(`_win_pythonw()`)却没用上
⇒ **同族坑"改一处漏另一处"**。✅ 已改 `_py = PYW`。
🔴 `CREATE_NO_WINDOW` flag 只是**补救**,⛔ 不能替代 `pythonw.exe`。
3、**最隐蔽、最要命**:宿主钩子 `UserPromptSubmit` 每次都 `maybe_ensure_supervise()` 补起常驻
⇒ **用户每发一条消息就补起一次** ⇒ **只杀进程永远止不住**(杀完下一条消息又起)。
### 已落的总开关(用户点名要的)
`<工作区>/.workbuddy/collab/supervise.switch`:
1、内容 `on` ⇒ 开。
2、**文件缺失/读不到/内容非 `on` ⇒ 关**(🔴 **fail-safe**:⛔ 绝不许"读不到就当开"——
那正是"用户没让它跑、它却一直在弹"的成因)。
3、管住**两处**自动驱动:`maybe_run_collabd_once()`(`--once`)与 `maybe_ensure_supervise()`。
⛔ 只管一处会漏(另一处照样起进程)。
### 开关与开会话的关系(用户问「后续如何创建会话」)
开关**关** ⇒ 常驻不跑 ⇒ **不会有任何排期被创建**(排期是常驻判闸后写的)。
开关**开** ⇒ 常驻五道闸+闸⑥ ⇒ 才可能写 `automations` 表(`schedule_type='once'`)⇒ 宿主拾取开会话。
⇒ 🔴 **开关是总电闸**,⛔ 它不是"只关弹窗"(顺带也不再自动建任何会话)。
### 其余止血
- 禁用 3 个 `collabd-supervise-*` 计划任务(它们触发 `start-supervise.ps1`)。
⚠️ 连带后果:**开机自启/常驻兜底没了** ⇒ 要不要、用什么载体,**必须问用户**,⛔ 不再自作主张。
- 杀光全部 python 进程(含 board/两区常驻/4 个野进程)。
### 🔴 2026-10-05 晚 · 载体定案(用户拍板 + 全链实测,P0-71 详载)
用户逐字:「**常驻崩了,拉起来这个事儿,你不能也用计划任务起个程序吗?**」
⇒ 本节那句"用什么载体必须问用户"**已问、已答**:**计划任务 → `pythonw.exe` → `--supervise`**,每 5 分钟兜底。
配套「一键全关」= `collabctl.py off`(开关+任务+进程,一次做完并复核)。
⚠️ P0-70 说"禁用任务是止血"没错,但**止痛不等于治病** —— 停掉载体就没人兜底了,见 P0-71。
## P0-71 🔴🔴🔴 **常驻到底靠什么活着:不在"作业对象",在"谁拉起它"**(★ 2026-10-05 全链实测 · 同一件事栽到第四次)
**用户连问三轮才逼出真相**:
「**待拍板 · 常驻到底靠什么保证长期运行,我就没搞懂 在你清理之前常驻不都是运行的好好的吗**」
「**启动个程序要什么看守循环,我就没明白了,启动bat程序 一直运行需要看护循环吗**」
「**从1号搞到5号 还起个程序都启动不起来**」
### 一、四条实测读数(⛔ 全是跑出来的,没有一条是推的)
| # | 做法 | 读数 | 结论 |
|---|---|---|---|
| 1 | 会话里 `Popen` 起循环 bat | **约 70 秒后停止写入**(t+40s 60 行 → t+70s 仍 60 行) | **活不过工具调用边界** |
| 2 | 加 `CREATE_BREAKAWAY_FROM_JOB`(0x01000000) | `PermissionError(13, 拒绝访问)` | 本机作业对象**不允许脱离** |
| 3 | 四组对照(任务 Highest/Interactive、任务 Limited/S4U、任务 Highest/S4U、subprocess 直起) | **全部 `IN-JOB`** | 🔴 **`IsProcessInJob` 在此环境没有鉴别力** —— 给谁都套作业对象 |
| 4 | 能长期活着的那条常驻 | 父进程**已退出**、父链**断在自己身上** | ✅ **真判据=父链根**:会话树里的是 `bash→sandbox-cli→WorkBuddy.exe→explorer`;任务起的**断链** |
⇒ 🔴🔴 **一句判据**:**别看"在不在作业对象里"(恒真,没用);看"父链有没有穿到 WorkBuddy.exe"**。
穿到了 ⇒ 会话一收工它就没;没穿到 ⇒ 真常驻。
### 二、形态定案(用户拍板)
**计划任务 → `pythonw.exe` → `collabd.py --supervise`,每 5 分钟一次。**
1、🔴 **动作必须是 `pythonw.exe`**(GUI 子系统 ⇒ 不分配控制台 ⇒ **不弹窗**)。
⛔ `powershell.exe` / `cmd.exe` 都是**控制台程序**,一启动就分配控制台 ⇒ **闪窗**(P0-70 弹窗事故真因)。
2、🔴 **⛔ 不要"看守循环"**。旧形态是三层嵌套:
① 常驻本体(自带循环,✅必要)② `start-supervise.ps1`(永不退出的看守,⚠️多余)
③ 计划任务每 60 秒叫醒看守(❌**这才是弹窗源**,且看守自己不会退 ⇒ 纯浪费)。
✅ 现在**两层**:常驻本体(自带循环)+ 计划任务(每 5 分钟**判活**,判到就走)。
3、🔴 **`--supervise` 是长驻的,进场即判活**:`supervise_alive()`(pid 活 + 心跳新 + 身份核验)
⇒ **已在 ⇒ 新起的立刻让位退出** ⇒ ⛔ 不会叠加成两条(实测:触发后进程数恒为 3)。
### 三、🔴🔴 两个**必须**记住的实现细节(掉一个就"看着配好了、其实没跑")
1、🔴🔴 **`WorkingDirectory` 必须是「工作区根」,⛔ 不是脚本所在目录**。
病根:`collabd.load_cfg()` 按 `<cwd>/.workbuddy/collab/collabd.config.json` 找配置。
设成脚本目录(`<ws>/.workbuddy/collab`)⇒ 它去找
`<ws>/.workbuddy/collab/.workbuddy/collab/collabd.config.json` ⇒ **必然找不到**
⇒ `CFG_MISSING` ⇒ **`--supervise` 拒跑**(实测 `rc=2`;任务侧记 `LastResult=1`)。
✅ 改成工作区根 ⇒ `State=Running`、`LastResult=267009`、心跳正常(实测)。
2、🔴 **常驻不需要网关口令**(这条反直觉,必须记住):
`--supervise` 唯一职责=**建检查会话排期**(`maybe_spawn_check_agent` → `create_check_schedule`
→ **直连 SQLite 写 `automations` 表**)⇒ **全程不走网关**。
口令只在 `--ensure` 的 **spawn 分支**才是前置门。
✅ 隔离区实测:**不含口令**的计划任务起 `--supervise` ⇒ 15 秒内写出心跳(pid 3944 · round=2)。
⚠️ 所以**保活要用 `--supervise`**,⛔ 用 `--ensure` 会在任务环境里"起了就退"(无口令)。
### 四、看板的 `LastResult=1` 是**虚警**,⛔ 别当故障
`dsh-board-keepalive`(`pythonw board.py --serve 20099`)实测 `LastResult=**1**`,
但**看板其实好好的**。真因:`board.py` 有**全局单例守卫**("看板全局只允许一个"),
已在跑时它打印一行"已有看板在跑 ⇒ 本次不启动"后 `return 0`;
而 **`pythonw` 在任务环境下没有 stdout 可写** ⇒ 退出码被顶成 `1`。
⇒ 🔴 **判据:看板看"端口在不在听",⛔ 不看 `LastResult`**。
### 五、验收读数(2026-10-05 11:4x 实测,全绿)
- 常驻任务:`State=Running` · `LastResult=**267009**`(=`0x41301` 正在运行)
- **崩了能拉起**(隔离区实测):杀掉常驻 → 删心跳 → 触发任务 ⇒
15 秒内心跳重现(pid 69544 · round=1)· `State=Running` ⇒ ✅ **用户要的就是这一条**
- 跨周期观察 **11 分钟**(两个 5 分钟周期):进程数恒 **4**(三生产 + 一观察)、
两区心跳恒新鲜(1~23 s,⛔ 远离 90 s 阈值)、看板**每一次采样都在听**
⇒ **无抖动、无重复、无掉线**
### 六、⛔ 三条硬规(别再犯)
1、⛔ **不许在用户机器上做"停生产"实验** —— 要验"崩了能拉起",
**去隔离区**(`tmp/selftest` + 空闲端口),⛔ 别把生产的看板停掉试。
2、⛔ **停止=任务 + 进程一起**(只禁任务老进程还在;只杀进程任务到点又拉)——
这就是为什么必须有 `collabctl.py off` 这种"一键做全"的入口。
3、⛔ **"禁用任务"不是修复,是拆载体**。10-05 我禁了 4 个任务,
用户当场问"**在你清理之前常驻不都是运行的好好的吗**" —— 对,就是我拆的。
正确修法是**换成对的载体**(`pythonw` + 正确 cwd),⛔ 不是把载体删掉。
### 七、🔴🔴 三条硬约束(用户 2026-10-05 逐字重述,本轮逐条取证)
用户原话:「**这些常驻程序每次创建之前,要先检查是否已经存在。如果已经存在了,就不需要重复创建,
而且各工作区是各工作区的。不共用,共用的只有看板。**」
1、**先查后建(幂等)** ⇒ 载体=`--supervise` 的**两段式守卫**:
① 进场 `supervise_alive()`=`pid` 活 ∧ 心跳新 ∧ **`_pid_is_collabd` 身份核验**(⚠️ pid 会复用,
光看"号还活着"会被骗 ⇒ 必须核身份)⇒ 已在即 `return 0`;
② **原子抢位 + `sleep 1.5` 回验**兜住"两条同时启动"。
✅ **变异对照实测**(⛔ 不是"应该绿"):手动再起第二个 `--supervise` ⇒ **0.4 秒 `rc=0` 让位退出**;
日志留痕 154 条「已有活的常驻(pid 30560 活、心跳 2 s 前、**身份已核**)⇒ 本实例退出(幂等,⛔ 不双写)」;
连触两次 ⇒ 进程数恒 **3**(⛔ 不叠加)。
2、**各工作区独立** ⇒ 一区一条 `collabd-keepalive-<ws>`,动作指**本区副本**;
判据=心跳里的 **`argv0` 必须指本区路径**(实测 ai1net↔30560、vibe↔60596,两串互不相同)。
⛔ **不许一区去起另一区**(P0-57 同族,用户 10-03 当场纠正过)。
3、**只有看板共用** ⇒ 看板任务**全局唯一一条** `dsh-board-keepalive`(端口 20099,动作指技能目录那份共用 `board.py`)。
⚠️ 专项澄清(两条 cwd 要求**恰好相反**,⛔ 别互相套用):
· `--supervise` 任务 `WorkingDirectory` **必须是工作区根**(首节细节①);
· 看板任务 `WorkingDirectory` **指技能 `scripts/` 即可** —— `board.py` 全按 `__file__` 定位,⛔ 不依赖 cwd。
### 八、🔴🔴 本轮新抓的部署缺口(P0-57 同族第 N 次,**必须并入 deploy 清单**)
- **症状**:`collabctl.py status` 把新任务全报「未知」、且列的是**已禁用的旧任务名**。
- **病根**:`deploy_code.py` 的 `DEFAULT_FILES` **只有 `["collabd.py", "goalctl.py"]`**
⇒ 用户双击 `.bat` 调的 **`collabctl.py` 从来没被分发** ⇒ 各区副本停在 10:36 旧版(16982 B),
技能目录已是新版(23176 B)⇒ **"看着改了、其实各区没吃到"**。
- ✅ **修法**:`DEFAULT_FILES` 补全所有**被计划任务/`.bat`/hook 直接执行的本包脚本**:
`collabd.py` · `goalctl.py` · **`collabctl.py`** · `guard.py` · `session-rules-check.py` · `init_workspace.py`。
- ✅ **判据(写死)**:**凡是被自动触发物直接执行的本包脚本,都必须在 `DEFAULT_FILES` 里**;
⛔ 改完代码**必须重跑 `deploy_code.py --ws <区>`**(覆盖前自动备份),否则各区吃不到。
---
## P0-72 🔴🔴🔴 **「一键停止」没真停+看板起不来:两个静默失败**(★ 2026-10-05 端到端实测抓到 · 用户原话「我关不掉这些东西,我还要投诉你呢」)
**用户三条口径**(逐字):「**常驻崩了,拉起来这个事儿,你不能也用计划任务起个程序吗?**」/
「**我总要以一键能关掉这些东西吧**」/「**这些常驻程序每次创建之前,要先检查是否已经存在……各工作区是各工作区的,不共用,共用的只有看板。**」
### 一、🔴🔴 缺陷 A:`kill_all()` **静默漏杀** —— 「停止」看着成功,进程一个没死
- **症状**:跑 `collabctl.py off` ⇒ 打印「计划任务已禁用:7 个」+「**已杀进程:0 个**」,
但两区常驻(pythonw)与看板**全都还活着**。⇒ **假停**。
- **病根**:`kill_all()` 里的 `taskkill` 用了 `creationflags=HIDE`,而 `HIDE` 含
`CREATE_BREAKAWAY_FROM_JOB` —— 本机实测**必被拒**(`PermissionError(13)`)⇒
**每次 `taskkill` 都抛异常、被 `except: pass` 吞掉** ⇒ 函数照常 `return`(计数 0)⇒
「已杀进程:0 个」既没报错、也不起任何疑心。
- ✅ **修法**:`taskkill` 是**短命控制台程序且 `capture_output` 走管道** ⇒ **不会弹窗** ⇒
用 **`creationflags=0` + `stdin=DEVNULL`**;返回值按 `rc==0` 计成功(⛔ 不再"无脑 +1")。
- 🔴🔴 **同族第二条(修完才露出来)**:`list_procs()` 的过滤是 `python*` ⇒ **连调用它的 python 一起匹配到**
⇒ 实测把**调用方自己杀了**(症状=脚本 `rc=1`、零输出)。✅ 修法=先算**保护集**
(`_self_and_ancestors()`:自己 + 全部祖先 pid)+ 跳过 `collabctl.py` 与"正在列举进程的那个 ps"。
### 二、🔴🔴 缺陷 B:看板任务**直起 `board.py`** ⇒ 崩溃重启循环、端口从没绑上
- **症状**:`dsh-board-keepalive` 的 `State` 一直在 `Ready/Running` 抖、`LastResult=1`、
**20099 `Listen` 计数恒 0**、进程 pid **每十几秒换一次**(崩→5 分钟触发→再崩)。
- **真因(抓到的现场输出)**:
`⚠️ collabd:未找到部署配置(COLLABD_CONFIG 未设,且 <scripts>\.workbuddy\collab\collabd.config.json 不存在)⇒ 已拒跑`
`board.py` 顶层用 **`COLLABD_CONFIG`** 定位配置(它要 `import collabd.py`)——
而**计划任务的"动作"里根本没有 env 字段** ⇒ 这个变量只能由**启动器在进程内设**。
- 🔴 **注意变量名别混**:包内 `roots.env` 提供的是 **`COLLABD_PROD_CONFIG`**,
而 `board.py` 读的是 **`COLLABD_CONFIG`** —— 名字对不上 ⇒ `roots.env` 兜不住它。
- ✅ **修法**:新增 **`scripts/board-launch.py`**(启动器)=先在进程内设
`COLLABD_CONFIG=<主区>/.workbuddy/collab/collabd.config.json` + `PYTHONIOENCODING` +
兜住 `sys.stdout/stderr`(pythonw 下可能是 `None`),再 `runpy.run_path(board.py, run_name="__main__")`;
看板任务的动作改指 **`pythonw.exe board-launch.py`**。
- 🔴 **`ExecutionTimeLimit` 必须按"是不是长驻服务"分**:
· 看板(长驻服务)⇒ **`0`(无时限)**;· 常驻 `--supervise`(也是长驻)⇒ **`0`**。
⛔ 我一度把两者都设 2 分钟 ⇒ 到点被调度器掐死 ⇒ **又一种"每 2 分钟重建"的抖动**(已改)。
- ⚠️ **看板的配置恒指主工作区**(看板是**全局共用一份**);主区由 `roots.env` 的
`DSH_WS_ROOT` 决定,启动器里兜底写死 `ai1net-dsh-server`。
### 三、✅ 验收(2026-10-05 12:0x 端到端实测,全绿)
1. `off` ⇒ 打印「**已杀进程:3 个**」;5 秒后 `list_procs` 只剩"诊断脚本自己+列举进程的 ps" ⇒ **真停**。
2. `on` ⇒ 三任务重建触发;25 秒后两区常驻**活、心跳新鲜**;看板 `State=Running`/`267009`。
3. 看板 **`curl --noproxy '*' http://127.0.0.1:20099/` ⇒ HTTP 200 · 148274 B**;端口 `Listen` 计数 = 1。
4. 用户可见入口 `会话机制-一键开关.bat`(工作区根+桌面)⇒ `1 启动 / 2 全部停止 / 3 看状态`。
### 四、⛔ 教训(一句话)
**"做了动作" ≠ "动作生效"** —— `taskkill` 带错 `creationflags`、计划任务动作里没有 env 字段,
这两件事**都不报错**,只表现为"停止没停""看板没起"。⇒ **凡关键动作,必须有"动作后复核"**
(杀完看进程表、起完看端口),⛔ 不许拿"函数返回了"当成功。
### 五、⚠️ 诊断期又踩一个(值得单列):**进程查询会"自匹配"**
查"有没有残留的看守进程"时,`Get-CimInstance ... -like '*start-supervise*'` 会**命中正在执行这条查询的
那个 PowerShell 自己**(它的命令行里就含 `start-supervise` 这串)。
⇒ 症状=**每次查都有"残留"、pid 每次都变**(其实是每次查询自己)。我差点据此去"清理一个不存在的东西"。
- ✅ **判据**:查出来的那条 command line **是不是"正在列举进程"的那条**?是 ⇒ 就是自匹配,**不是真进程**。
- ✅ **做法**:把关键字**拆开拼接**(`"start-superv" + "ise.ps1"`)再比对;或按 `-File` 形态精确匹配
(`$_.Name -eq 'powershell.exe' -and $_.CommandLine -like '*-File*<拆开的关键字>*'`)。
- 🔗 同源:`collabctl.kill_all()` 的 `list_procs()` 也踩过同族(`python*` 匹配到自己)⇒ 已用
**保护集 `_self_and_ancestors()`** 兜住。
---
## P0-73 🔴🔴🔴 **常驻启动器缺一个环境变量 ⇒ 检查程序静默失效**(★ 2026-10-05 用户点名「检查之前创建的协作程序和检查程序运行是否正常」时抓到)
**用户口径**(逐字):「**处理完毕后,检查之前创建的协作程序和检查程序运行是否正常。**」
### 一、先分清"两套程序"(⛔ 别混为一谈)
| | 协作程序 | 检查程序 |
|---|---|---|
| 是什么 | `collabd.py --supervise`(常驻本体,自带循环) | **循环内建的检查会话排期**(`maybe_spawn_check_agent()`) |
| 判活 | 心跳新不新 + `argv0` 指不指本区 | 日志里**有没有 `检查会话:…` 分支** |
| 载体 | `collabd-keepalive-<ws>` 计划任务 | **同一个进程**(⛔ 无独立任务) |
⇒ 🔴 **检查程序跟着协作程序走** ⇒ 协作程序"活着"**不代表**检查程序在工作。
### 二、症状:协作程序活得好好的,检查程序**悄悄地不干活了**
- **协作侧全正常**:两区心跳新鲜、`argv0` 各指本区、进程恒 3 个。
- **检查侧(ai1net)**:`_collabd.log` **每轮刷一行** `all_sessions_idle 读库失败 no such table: sessions`(累计 **36 次**);最后一次成功建排期**停在 11:54**,此后一次都没再建。
- **对照组(vibe)正常**:读库失败 **0 次**,一路在跑 `检查会话:目标状态=已完成 ⇒ 不建`。
- 🔴 **为什么"静默"**:`_all_sessions_idle()` 的 fail-safe 是**读库失败 ⇒ 判「有会话在跑」⇒ 不建**(`return False`)。失败方向**恰好是"什么都不做"** ⇒ 表现成"机制很安静",**⛔ 不报错、不告警**。
### 三、根因:常驻任务**直起 `collabd.py --supervise`**,任务环境**没有 `CODEBUDDY_CONFIG_DIR`**
- `_wb_db()` 逐字:`cfg = os.environ.get("CODEBUDDY_CONFIG_DIR") or ""` ⇒ 没设 ⇒ 落到 `C://Users//Administrator//.workbuddy//workbuddy.db`。
- 🔴 **那个文件是 0 字节空库**(无任何表)⇒ 查询 `sessions` ⇒ **`no such table: sessions`**。
| 库 | 大小 | `sessions` 表 |
|---|---|---|
| `E://ProgramData//.workbuddy//workbuddy.db`(**活动库**) | 34.9 MB | ✅ 247 行 |
| `C://Users//Administrator//.workbuddy//workbuddy.db`(旧库) | **0 字节** | ❌ 无 |
- 🔴 **为什么只有 ai1net 炸**:vibe 的 config 里 `host_db` **写死绝对路径**;ai1net 的 `host_db` **一直是空字符串**。⇒ **同一份代码,两区行为不同,差别只在 config 这一格**。
- 🔴 **"计划任务的『动作』里没有 env 字段"**(P0-72 §二 同一条硬约束)⇒ `CODEBUDDY_CONFIG_DIR` **只能由启动器在进程内设**。
- 🔴 **怎么弄丢的(诚实记录)**:旧看守 `start-supervise.ps1` 本有 `$env:CODEBUDDY_CONFIG_DIR = ...`;P0-72 改成"两层形态"时**只保了 `COLLABD_CONFIG`,丢了这一句**。⇒ **重构"起法"时,旧起法里的 env 必须逐条搬**(⛔ 不许凭记忆只搬"我记得的那个")。
### 四、✅ 修法:新增常驻启动器(与 `board-launch.py` 对称)
- 新增 **`scripts/supervise-launch.py`**:进程内 `os.environ.setdefault("CODEBUDDY_CONFIG_DIR", ...)` + 设 `COLLABD_CONFIG=<本区>/collabd.config.json` + 兜住 `sys.stdout/stderr`(pythonw 下可能是 `None`)+ `runpy.run_path(collabd.py, run_name="__main__")`。
- 常驻任务动作改指 `pythonw.exe "<区>/.workbuddy/collab/supervise-launch.py"`(⛔ **不再自带 `--supervise`**),`-WorkingDirectory` 仍是**工作区根**,`-ExecutionTimeLimit` 恒 `0`。
- `deploy_code.py` 的 `DEFAULT_FILES` **补入该启动器**(否则各区吃不到,P0-57 同族)。
- ⚠️ **`roots.env` 位置缺口(连带发现)**:`_sm_load_roots()` 从 `__file__` **向上**找 `roots.env`;工作区副本在 `<ws>/.workbuddy/collab/collabd.py` ⇒ 工作区里没有该文件 ⇒ **找不到** ⇒ **启动器里写死最稳**,⛔ 别指望 `roots.env` 兜住各区。
### 五、✅ 验收(2026-10-05 12:15 实测)
**① 分界线干净得可当判据**:
```
[12:14:49] all_sessions_idle 读库失败 no such table: sessions ← 旧进程最后一次
[12:15:02] supervise loop start pid=58860 ← 新形态进程起来
[12:15:14] all_sessions_idle:还有 1 条 working(a80f300d)⇒ 不算全结束
```
⇒ **`pid=58860` 之后 `no such table` 计数 = 0**(`awk` 分界**实测**),从 fail-safe 的"恒判有会话"变成**真实读库判定**。
**② 那条 `working(a80f300d)` 是真的**(直查活动库):就是**当前这条会话本身** ⇒ 闸②「本区有会话在跑 ⇒ 不建检查会话」是**正确的合法拦截**(⛔ 不是 bug)。
**③ 变异对照证明判据非恒绿**(两档):
| 档 | 环境 | 落的库 | 结果 |
|---|---|---|---|
| A | **无** `CODEBUDDY_CONFIG_DIR` | `C://…`(0 B) | **FAIL no such table: sessions** |
| B | **有**(注入后) | 活动库(34.9 MB) | **OK sessions=247 working=1** |
⇒ **A 必 FAIL、B 必 OK** ⇒ 路径来源**真实决定成败**(⛔ 不是"应该绿")。
**④ 运行时**(恒 3 进程、父链断 ⇒ 真常驻):两台**启动器**(各指本区)+ 一个**看板**。
**⑤ 三处 md5 一致**;看板 `curl --noproxy '*' :20099/` ⇒ **HTTP 200**、`netstat` 实测 **LISTENING**。
### 六、⛔ 教训(一句话)
**"进程活着" ≠ "它在干活"** —— 常驻本体活着,但内部某一环读错了库、被 fail-safe 兜成"什么都不做",**外表完全安静、零报错**。⇒
1. **检查"程序是否正常",必须看它有没有产出预期的分支**(`检查会话:…`),⛔ 不能只看进程在不在;
2. **fail-safe 的方向要选对**;凡 fail-safe,**必须同时打一条可检索的日志**(否则"安全"就等于"不可见");
3. **重构"起法"时,旧起法里的 env/inner 参数要逐条搬**(本坑就是丢了一句 `CODEBUDDY_CONFIG_DIR`);
4. 各区 config 的 `host_db` **写死绝对路径最稳**(vibe 一直这么写,所以只有 ai1net 炸)。
---
## P0-74 🔴🔴🔴 **`_escalate_to_keeper()` 残留旧形态 ⇒ 计划任务里躺着 `powershell.exe` ⇒ 闪黑窗**(★ 2026-10-05 用户原话「刚才又弹了窗口看看是什么」)
### 一、症状:屏幕上反复闪一下
- 用户报「**刚才又弹了窗口**」。
- **实测抓到的进程对**(`Get-CimInstance Win32_Process`):
```
pid 55880 ppid 3924 powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "...\start-supervise.ps1"
pid 67784 ppid 55880 conhost.exe \??\C:\windows\system32\conhost.exe 0x4
```
⇒ **`powershell.exe` + `conhost.exe`** —— 这就是闪窗的铁证。
### 二、🔴 真凶:`collabd.py` 的 `_escalate_to_keeper()` **没跟着新形态一起改**
- 它是**自我供给**函数(2026-10-04 加的:就地起失败时,转去建本区计划任务)。
- 但它的实现停留在**旧形态**:任务动作写死 **`powershell.exe -WindowStyle Hidden -File start-supervise.ps1`**
+ 还要铺一份 `start-supervise.ps1`(模板 `assets/start-supervise.ps1.tpl`)。
- 🔴 **为什么它就"闪"**:`-WindowStyle Hidden` **只藏窗口、不省控制台** ——
PowerShell 是**控制台程序** ⇒ 每次触发都分配 `conhost.exe` ⇒ **闪一下**。
- 🔴 **为什么"反复"闪**:该任务触发器 `-AtLogOn` + `RestartCount 999 / RestartInterval 1min`
⇒ **起来就不会安静**。
- 🔴 **触发链**:钩子 `supervise-ensure-hook.py`(每次 `UserPromptSubmit`)→ `collabd.py --ensure`
→ **就地起失败** → `_escalate_to_keeper()` → 建/启旧任务 → 闪窗。
实测日志逐字:`常驻自我供给(计划任务):✅ 已建本区计划任务 collabd-supervise-ai1net-dsh-server`。
### 三、✅ 修法(收敛成**同一个形态**,⛔ 不留第二套)
在 `_escalate_to_keeper()` 里:
1. **⛔ 不再铺 `start-supervise.ps1`、⛔ 不再做 ps1 语法校验、⛔ 不再依赖 `start-supervise.ps1.tpl`**
(删掉 `_find_keeper_tpl()` 前置门槛 —— 否则缺模板就直接"没建",与启动器形态无关了)。
2. 任务动作改指 **`pythonw.exe` + `<区>/.workbuddy/collab/supervise-launch.py`**
(`-WorkingDirectory` =**工作区根**;pythonw 是 **GUI 子系统 ⇒ 零控制台 ⇒ 不闪窗**)。
3. ~~**任务名保持** `collabd-supervise-<区>`(⛔ 不改名 ⇒ `collabctl` 的名单不用动)~~。
🔴 **2026-10-06 订正 —— 这条结论是错的,已改**:
当时以为"不改名就不用动 `collabctl` 的名单",但**正好反过来** ——
`collabctl.py` 的 `SCHED_TASKS` 里列的 `collabd-supervise-<区>` 是**"旧形态 ⇒ 一律禁用"**的名单,
⇒ 保留旧名的后果是:**`_escalate_to_keeper()` 自愈建起来的常驻,会被下一次 `collabctl off` 当旧形态干掉**。
✅ 现行口径:**统一用 `collabd-keepalive-<区>`**(与 `KEEPALIVE_TASKS` 同名)——
这样 `off` 认得它、`on` 会重建它,**开关才真正管得住这条自愈路径**。
⚠️ **教训**:判"改不改名"时,⛔ 不能只看"改名的成本",要**查这个名字在别处被怎么用**
—— 这里它同时是"检查名单"和"禁用名单"的键。
4. `_escalate_to_keeper` 里**被删段**用 `~~删除线~~` 在 docstring 里标注(⛔ 别留下与实现不符的说明)。
### 三·补、🔴 2026-10-06 二次收口(第一次只改了动作,没改名字、也没扫旁路)
**三个漏网**(本轮实测抓到;探针当时只扫 `collabd.py` ⇒ 全没报警):
1、**任务名两套并存**(见上条第 3 项订正)⇒ 已统一 `collabd-keepalive-<区>`,
触发形态同步对齐(`-AtLogOn` → **每 5 分钟重复触发**;去掉 `RestartCount 999/1min`)。
2、**`init_workspace.py` 是第二个建任务者** —— 自带 `_register_keeper_task()`/`_keeper_task_state()`/
`_ps_check()`(验 ps1 语法),还铺 `assets/start-supervise.ps1.tpl`。**整份删掉**
(含 `--with-keeper`/`--no-task` 开关),改为只检查 `supervise-launch.py` 在位。
⛔ **建任务只许有一处实现** = `collabctl.py`(受总电闸 `supervise.switch` 管辖);
自己建就绕过开关,就是"第二条起法"。
3、**守形态的探针打偏** —— `t_keeper_legacy_ps1_form_gone` 原来只读 `collabd.py`。
✅ 已扩成扫**全族**(`collabd.py`/`init_workspace.py`/`collabctl.py`),并新增两条判据:
「**任务名口径唯一**」「旧形态死代码已清」。
⚠️ **探针自己的坑**:新判据"任务名唯一"**扫文本** ⇒ 第一版**误红**,命中了 `collabctl.py` 里
`SCHED_TASKS = [...]`(那是**故意**列旧名去禁用的)⇒ 必修 `re.sub` 剥掉该块再扫。
**本项目第三次踩"判据扫到自己的否定句"**(前两次:查 PRD 视觉词命中免责声明、查六份旧产出命中禁令文本)。
⚠️ **另一个操作坑**:脚本读写这两个 `.py` 后行尾由**纯 LF 变纯 CRLF**(同族其余 7 个都是 LF)⇒ 已还原。
**凡用脚本改技能源码,改完必核行尾**(否则整个文件显示为"全改",审不出真改动)。
### 四、✅ 验收(2026-10-05 12:42 实测)
- 回读任务动作:`EXE=…\pythonw.exe`/`ARG=…/.workbuddy/collab/supervise-launch.py`/`WD=<工作区根>`
⇒ **不再是 `powershell.exe`**(`grep "New-ScheduledTaskAction -Execute 'powershell.exe'"` ⇒ **0 命中**)。
- **零 PowerShell 看守残留**:`Get-CimInstance` 筛 `powershell.exe` 且命令行含 `start-superv` ⇒ **空**。
- **三常驻全部 `ppid=3924`(调度器)且都是启动器形态**:`48372` ai1net/`61956` vibe/`63684` 看板
⇒ 父链断在调度器 ⟹ **真常驻**;形态统一 ⟹ **不会再长出第二套起法**。
### 五、⛔ 教训
**"改了主路径" ≠ "把旁路也改了"** —— 漏掉的那条**自我供给旁路**平时不吭声,专挑你在用时闪一下。
⇒ **凡"同一种东西有第二条起法",收口时必须全文搜一遍**(判据=`grep New-ScheduledTaskAction` 与 `\.ps1`)。
---
## P0-75 🔴🔴 **远端技能总仓里躺着明文令牌(`.neodata_token`)**(★ 2026-10-05 收尾核验时发现)
### 一、症状
核验 `git@work.alotbuy.com:maogeigei/workbuddy_skills.git` 的推送结果时,
把「远端 HEAD 顶层条目」与「更早做的备份」用 `comm` 比差异 ⇒ 多出一个 `.neodata_token`:
```
{"token": "tk_<REDACTED·首2位 tk_><共 35 字符>", "saved_at": 1790920232}
```
**明文、72 B、可直接被任何能 clone 的人读走**。
### 二、根因
- **引入提交**=`43b83b0 chore(skills): 首次入库 workbuddy_skills(25 个技能 / 419 文件)`
⇒ **是首次整仓入库时带进去的**,⛔ 不是后来某次改技能推的。
- 首次入库用 `git add -A` **整目录收录**;当时 `.gitignore` 只挡了**运行产物**
(`*.log`/`__pycache__/`/`*.bak-*`)⇒ **完全没挡凭据文件**。
- 本机同源=`<CODEBUDDY_CONFIG_DIR>/.neodata_token`(NeoData 金融搜索技能运行时写的令牌落点,
跟 `roots.env` 是**同一类东西**,但一个是"部署契约"、一个是"密钥")。
### 三、修(最小改动 · ⛔ 未改写历史)
```bash
git rm --cached .neodata_token # 只出索引,保留工作区文件
# .gitignore 追加:
# .neodata_token
# .neodata_*
# *.token
git commit -m "chore(security): 移除误入库的 .neodata_token 令牌 + 补忽略规则"
git push origin master # 13a300e..031b312
```
### 四、验收
- 远端 HEAD = `031b312`;**工作树无该文件**;`git ls-files` **索引已清**。
- **25 个技能全在**;`session-mechanism` **57 文件**(⛔ 没被波及)。
- `.gitignore` 尾部新规则就位。
### 五、⚠️ 残留风险(必须如实告知用户)
**历史里仍有那个 blob**(`55941e52`)—— `git log --all -- .neodata_token` 还能查到,
`git show 43b83b0:.neodata_token` 仍能打印明文。
⇒ 彻底清除 = **`push --force` 重写全部历史**(会改写 25 个技能的全部提交哈希,牵连所有 clone)
⇒ **⛔ 不当场做,必须用户拍板**。
(更根本的处置是**在服务端吊销/轮换该令牌** —— 重写历史管不住已经 clone 走的副本。)
### 六、⛔ 教训(可复用判据)
1. 🔴 **技能仓入库前必扫两样**:① 名字含 `token|secret|credential|password|\.env` 的文件;
② **已知令牌串**出现在哪些文件(`grep -rl "<串>" <目录>`)。
2. 🔴 `.gitignore` 必须显式挡**本机令牌落点**(`.neodata_token` 这类),
⛔ 别只挡"日志/缓存/备份" —— 产物与凭据是**两回事**。
3. 🔴 **核验远端别只比"技能在不在"**,要比**差异**(现远端 vs 备份,`comm -23`/`-13`)。
本次就是靠差异比对才发现;只看"25 个技能都在"会**直接漏掉**。
4. ⚠️ 首次入库用 `git add -A` 是**高危动作**(整目录收录)⇒ 收录前先跑一遍第 1 条的两扫。
## P0-76 🔴🔴🔴 **技能加载闸门的词表漏了「主触发句」⇒ 用户说了「使用任务会话完成目标」,钩子全程 0 命中 ⇒ AI 靠记忆干活 ⇒ 把已授权的事反复要签字**(★ 2026-10-05 实测取证 · 用户连问三次「你去执行不行啊」)
**症状**(用户视角):用户明明说了「**2、使用任务会话完成目标:XXX**」,AI 却回头问「要我现在派任务会话,还是先自己执行?」。
用户连问三次:「任务会话的目的不就是建立任务会话执行嘛,不然我调用任务会话技能干什么」「我想知道你哪来的这个问题啊,你去执行不行啊」
「我想知道你哪来的这么多问题啊,你去执行不行啊,**会话技能里面没有告诉你自决策的规则和机制嘛**」。
**取证过程**(会话 `b232218f` · vibe-product 主会话 · 20.7 MB / 5023 行 jsonl):
| 步 | 读数 | 结论 |
|---|---|---|
| ① 钩子日志 | 10-05 该会话 **155 条 entry、6 次 HIT**,且 6 次全在 10-02~10-04 | 10-05 当天 **零命中** |
| ② 会话内 `Skill` 调用 | 全文件 8 次,最近一次在 **L3530**(更早的一轮);**下发目标那一刻(L4959)前后完全没加载** | 靠记忆干活 |
| ③ 独立复现 | 喂 `vibe-product` cwd + 用户原话 ⇒ rc=0、**stdout 空** | 静默放行 |
| ④ 内部判定 | `_in_scope()=True`、`_workdirs()=('*',)` | **⛔ 不是作用域问题** |
| ⑤ 词表逐条比对 | 旧 `TRIGGERS` 22 词**全是「决策方法」族 + 「回复排版」族** | **「使用任务会话完成目标」一个词都匹配不上** |
**根因(一句话)**:**词表只覆盖「点名方法论」,漏了「点名派活」—— 而后者才是 `session-mechanism` 的主入口。**
「使用任务会话」「任务会话完成」「执行会话完成」「继续完成目标」「会话技能」这些**主触发句里一个词都没有**;
用户那句「会话技能里面没有告诉你自决策的规则和机制嘛」是**反问**(含"规则"二字但那是质问,⛔ 不构成点名)。
**为什么后果这么重**:技能没被强制加载 ⇒ **自决策白名单没进上下文** ⇒ AI 拿"机制层旁路信号"(注入通报里那句「本轮没有要求使用协作会话」)当了准,而**用户原话优先级高于它** ⇒ 于是把"怎么执行"当成待拍板项。
**修法(三处,都在技能内)**:
1. `scripts/hooks/skill-load-guard.py`:新增**分组 3 触发词**(会话机制/派活族 17 词)+ 第三条路由 `_LOAD_SESSION`(→ 加载 `session-mechanism`,⛔ 不是 `dsh-decision`)+ 注入文本里写明「用户说『使用任务会话完成目标』本身就是授权,⛔ 不许再问『要不要建任务会话』」。
2. `SKILL.md` 首屏:新增 **「🔴🔴 第 0 步:加载门槛」**(在十条禁令之前)—— 三张「⛔ 禁止的跳过理由」表 +「**用户原话 > 注入通报**」冲突裁决 + 白名单九类指向。
3. 副本同步:`vibe` 副本从落后 28 文件 → 全对齐(仅 `roots.env` / `install.log` 两处刻意差异)。
**验收(全部实测)**:
| 项 | 结果 |
|---|---|
| 端到端 7 用例(逐字复刻 settings.json 那条命令) | **7/7 通过**(5 应命中+2 反例正确放行) |
| 跨工作区(vibe / mcn-short-video / ai1net-decision-laya) | 三个区**全部 HIT**(钩子挂全局 ⇒ 全机生效) |
| **变异对照**(把分组 3 词表删回旧状态) | **5 个应命中全部报红(2/7)** ⇒ 判定**不是恒绿** |
| 副本一致性 | 源 58 文件 / 副本 59(多一份 `副本使用说明.md`),**仅 `roots.env`+`install.log` 差异** |
**🔴 教训(可迁移)**:
1. **闸门类机制必须做「触发面覆盖审计」** —— 光验证"闸门能响"不够,要验证"**该响的场景是否真的都在词的覆盖面上**"。本坑就是"闸门本身好的,但它听不见你说话"。
2. **钩子日志里全是 `entry` 而没有 `HIT`,就是"听见了但没认出"** —— 这比"完全没被调用"更难发现(前者看起来机制在跑)。
3. **反问句不算点名** —— 用户质问「没告诉你规则吗」时含关键词,但语义是追责不是请求;靠关键词匹配的闸门**必须把这类误判考虑进去**(本次靠"命中才注入"的保守设计避开了,代价是漏掉了真正的点名)。
4. ⛔ **别在钩子旁边再造第二套** —— 本次是**扩已有钩子的覆盖面**,不是新建钩子(用户明示前提:「从机制上兜住"忘了加载"这一主因」)。
---
## P0-77 🔴🔴🔴 **前缀发源地「一对多」:实测 4 类落点,改一处只改一半 ⇒ 半新半旧**(★ 2026-10-05 · 用户连说两遍「**协作 全部改为 执行 不要我在说第二遍**」)
### 一、症状
用户 10-03 已下过令「协作 → 执行」,10-05 发现**新建会话前缀仍叫 `[协作]`** ⇒ 追问「**协作 全部改为 执行 不要我在说第二遍**」。
### 二、根因 A — 发源地是「一对多」,共 4 类落点
| # | 发源地 | 位置 | 漏改的后果 |
|---|---|---|---|
| ① | `_zh` 字典(**新建前缀唯一出处**) | `collabd.py::_gap_plan()` | 不改 ⇒ 新建的**永远是旧前缀** |
| ② | 提示词模板 **9 处** | `collabd.py` 1417/1419/1421/1869/3262/4145/4160/4189/4206 | 不改 ⇒ **派活的指令文本**还在教 AI 写旧前缀 |
| ③ | 扫描面(`SESS_PREFIXES` + SQL) | `session-rules-check.py` | 不改 ⇒ **新建的 `[执行]` 会话被体检整体漏掉**(**静默失管**) |
| ④ | 识别映射表(`role`/`_PFX`) | `collabd.py` + `board.py` | **⛔ 这个不许改** —— 一删,存量全部解析成 `role=""` ⇒ 看板画不出、派活漏管,**不可逆** |
🔴 **最阴的是 ③** —— 它是**静默**的:体检照跑、照出 pass,只是**新前缀的会话从来没被扫进来**。与 P0-76 同型:**闸门本身是好的,但它看不见新东西**。
### 三、修法(全量,含存量)
① `_zh` → `"执行"`|② 9 处提示词模板 → `[执行]`|③ `SESS_PREFIXES` + SQL 补齐|④ 映射表**只加注释加固、一个键不动**。
- 存量**一并改写**(用户要「全部」):宿主库 `sessions` **55 条** + `automations` **5 条活排期**,事务内 `replace()`,`busy_timeout=30s`,**改后 `integrity_check = ok`**。
- 备份走官方 `backup()` API(⛔ 禁文件级 `cp`)+ `snapshot_titles.json` 逐条留痕。
### 四、顺带修的真缺陷(第三个)
**类别名自带方括号 ⇒ 拼出双层**:`goal.json` 的 `topics` 写成 `["[暗色主题补抓]", …]`(**值里自带方括号**),套进模板 `"[%s]-[%s]-%s"` ⇒ 拼出 `[执行]-[[暗色主题补抓]]-承接队列` ⇒ **形不合规**。
- 修法:`_gap_plan()` 里**只剥一层**首尾方括号(⛔ 不全 strip,免得把 `[a][b]` 也吞了);**归一化只作用于拼装名,不回写 `goal.json`**(那是用户的输入)。
- 实测三种输入 `[暗色主题补抓]`/`暗色主题补抓`/`[a][b]` → 输出全合规。
### 五、验收(全部实测 · 2026-10-05 15:2x)
| 项 | 结果 |
|---|---|
| `_zh` 发源地读数 | `{"follow":"跟进","worker":"执行","waker":"唤醒"}` ✅ |
| `_gap_plan()` 实测产物 | `[执行]-[机制排查与修复]-承接队列` ✅ |
| 识别映射表(**5 样本**,含死前缀) | `[协作]`/`[协作目标]`/`[任务会话]`/`[执行]` → 全 `worker`;`[检查]` → `check` ✅ |
| **变异对照**(`_zh` 改回 `"协作"`) | **FAIL 2 按预期报红** ⇒ 判据**不是恒绿**;还原复验全绿 ✅ |
| 宿主库改写 | `[协作]%` 会话 **55→0**、活排期 **5→0**;`[执行]%` 55→56;**`integrity_check = ok`** ✅ |
| 两处映射表**逐条同款** | `collabd.py::role` ⇄ `board.py::_PFX` **6 键全等** ✅ |
| 扫描面覆盖 | SQL 前缀集合 ⊇ 活类别前缀集合 ✅ |
| 副本同步(4 份) | 源包/vibe 技能副本/ai1net 运行副本/vibe 运行副本 **md5 全同** ✅ |
| 常驻重启(部署≠生效) | ai1net 48372→**41468**、vibe 51308→**39440**;`started_h` **晚于**部署时间 ✅ |
| **自测基线对比** | **改前 PASS 94 / FAIL 3 = 改后 94 / 3**(那 3 条**改前既有**,⛔ 非本次回归) ✅ |
| 踩到的**自造回归** | 把 `协作目标`/`任务会话` 加进活类别表 ⇒ 体检报「类别缺失」(那是**纯兼容死前缀**)⇒ 已拆成 `SESS_PREFIXES`(扫描面)⇄ `ROLES_LIVE`(活类别)**两层** ✅ |
### 六、⛔ 教训(可迁移)
1. **「发源地唯一」是句口号,落地要清单** —— 光"改前缀"就牵出 **4 类落点**(新建/提示词/扫描面/识别表),其中**识别表必须反向保留**。凡"XX 只有一处"的断言,**必须 grep 数一遍**再信。
2. **改名类改动,读的一侧比写的一侧更危险** —— 写入面改错**看得见**(新建名不对),**扫描面改错是静默的**(旧的照扫、新的没进,体检还全绿)。⛔ 改名前先把"谁会读这个前缀"**列全**。
3. **「兼容层」与「活类别」必须分成两张表** —— 混成一张 ⇒ 死前缀被当成"必须有活的" ⇒ **每轮必报假缺口**。判据:**只有"要求它现在活着"的才进活类别表**。
4. **改了代码 ≠ 生效** —— 保活任务判定"已活"就**不会重启**;必须**终止进程 + 触发保活**,以 `started_h 晚于部署时间` 作**唯一判据**(⛔ 副本 md5 对 ≠ 进程换了代码)。
5. **活库改写的安全顺序**:官方 `backup()` 备份 → 导出待改行快照 → `BEGIN IMMEDIATE` + `busy_timeout` → `replace()` → `COMMIT` → `integrity_check`。⛔ **全程不用文件级 `cp`**(WAL 三件套 salt 世代错位 ⇒ `file is not a database`)。
---
## P0-78 🔴🔴🔴 **「一个工作区同时只能一个目标 + 换目标要用户确认」在代码里几乎零实现**(★ 2026-10-05 用户口径 · `goalctl.py` 全文搜 `goals/` 零命中)
### 一、用户口径(逐字,两条)
> **「一个工作区 历史的旧的目标会有很多个,当前要求完成什么目标 就因该关注和检查那个目标」**
> **「一个工作区 同时只能执行一个目标,如果要切换目标,需要用户确认,然后切换和关注检查切换后的目标」**
### 二、改前取证(三条,全部实测)
| # | 项 | 实测读数 | 判定 |
|---|---|---|---|
| ① | 当前目标载体 | `tmp/supervise-inbox/goal.json`(**单文件**) | ✅ 天然满足"同时只有一个" |
| ② | 历史目标归档 | `goalctl.py`/`collabd.py` **全文搜 `goals/` = 零命中** | 🔴 **归档机制度:零实现** |
| ③ | 换目标确认闸 | `declare` **直接覆盖 `title`**,无任何准入判断 | 🔴 **确认闸:零实现** |
- `vibe-product/tmp/supervise-inbox/goals/机制自检-20261004.json` 那份归档件是**手工造的**(代码从不写这个目录)。
- `ai1net-dsh-server` 区**连 `goals/` 目录都没有**(从未归档过)—— 它换过目标,旧目标只剩 `goal.json` 里一句手写的 `_旧验收作废说明`。
- ⇒ **换目标 = 旧目标当场消失**,靠着人自觉补一行字留痕。
- ⇒ **换目标无需任何人同意** —— 只要有人跑 `declare --title`,目标就换了。
### 三、修法(两道闸,都落在 `goalctl.py::declare()`)
**① 确认闸**:标题与现有不同 ⇒ **默认拒绝**(`rc=3`,**零写入**),必须带 `--switch-goal` 才放行。
- 🔴 **`--switch-goal` ⛔ 不等于 `--yes`**:前者=「**用户已同意换目标**」,后者只是「干跑转真写」。**两者绝不合并** —— 合并就把"确认"降级成"顺手加个 yes",闸门失效。
- ⚠️ **只改措辞/补 `--why`/补 `--kpi`(标题没变)⇒ ⛔ 不触发本闸**(那是同一目标在细化)。
**② 旧目标归档**:确认后把旧 `goal.json` **整份**写到 `goals/<旧标题≤40字>__<YYYYMMDD-HHMM>.json`(文件名净化 Windows 非法字符、重名自动加序号),并在新目标里留 `_前身目标` 指向归档件。
- ⚠️ 归档失败 ⇒ **不阻断换目标**(用户已确认),但**必须喊出来**(⛔ 不许静默)。
### 四、过程中踩到并修掉的第二个真缺陷
**`declare` 没给 `--title` 时回落成工作区名 ⇒ 把"纯细化"误判成"换目标"。**
- 旧写法 `title = _opt("title") or NAME`:只想补 `--why`(标题根本不动)时,`title` 变成 **`NAME`=工作区名** ⇒ 与现有标题不同 ⇒ **触发换目标闸** ⇒ 一个纯细化动作被拦成"未确认换目标"。
- ✅ 正解:**没给 `--title` ⇒ 保持现有标题**(与 `--topics`/`--kpi` 同一规矩:不给=不动);只有**一个目标都没有**(首次声明)时才回落 `NAME`。
- ⛔ **这是"给旧行为加闸"的典型连带伤** —— 加闸前该行为是"静默改标题",加了闸才暴露成"硬拒绝"。**加闸必须连带检查旧行为里被掩盖的缺陷**。
### 五、验收(全部实测 · 2026-10-05 15:3x)
| 项 | 结果 |
|---|---|
| ① 换目标未确认 | **rc=3、零写入**,并打印用户口径原文与正确命令 ✅ |
| ② 同目标细化(补 `--why`,标题不变) | **rc=0 正常放行**(⛔ 不被拦)✅ |
| ③ 确认后真换(隔离测试区) | 旧目标归档成功(**32 字段 / 6186 B**),新目标 `_前身目标` 指向归档件 ✅ |
| ④ 归档件内容完整性 | 与旧目标**同源**(title/why/acceptance/topics 全在),字段数 32 vs 新 33 ✅ |
| ⑤ **变异对照**(闸短路 `if False and ...`) | **放行 rc=0** ⇒ 判据会失败、**不是恒绿**;还原后 **rc=3** 复验 ✅ |
| ⑥ 真区未被污染 | `title` 未变、`lifecycle` 未变、无 `goals/` 残留 ✅ |
| ⑦ 副本同步 | 源包 + 两工作区运行副本 + vibe 技能副本 **md5 全同** ✅ |
### 六、⛔ 教训(可迁移)
1. **"用户口径"与"代码事实"必须分开核** —— 用户说"一个工作区同时只能一个目标"时,**载体确实满足**(单文件),但**配套的归档与确认两条腿是空的**。⛔ 别因为①对了就以为整条口径都落地了,**要逐条拆开验**。
2. **「文档里写了」≠「代码里做了」** —— `goals/` 目录**真实存在且有文件**,极易让人以为机制在跑;真相是**那份文件是手工造的、代码从不写它**。⇒ **凡"某目录/某文件存在"就当证据 ⇒ 必错**,要**搜写入方**(本次:搜 `goals/` 零命中)。
3. **默认拒绝(fail-closed)是"用户确认"类闸的唯一正确形态** —— 若做成"默认放行 + 提示",等于没闸(本次若默认放行,AI 顺手一跑就换了)。
4. **闸的开关必须专用、⛔ 不与通用开关复用** —— `--switch-goal`(人的授权)与 `--yes`(干跑转真写)**语义正交**,复用即失效。
5. **给旧行为加闸时,务必回归旧行为的所有分支** —— 本次就是加闸后才发现"没给 `--title` 会静默改标题"这个**既存**缺陷被放大成硬拒绝。
## P0-79 🔴🔴🔴 **`declare` 换目标不重置 `lifecycle` ⇒ 检查机制静默空转 ~17 小时**(★ 2026-10-05 · 从 P0-77 拆出,两个主题原本塞在一条里)
### 一、症状
`vibe-product` 目标明摆着没完成(`acceptance_state` N1/N2/N3 全「未过」),却**十几小时不建检查会话**。用户质问「**vibe 目标没完成,检查机制也没运行 到底什么问题怎么解决**」。
### 二、根因 B — `lifecycle` 的唯一写入点是 `--set-life`,`declare` 一个字都不碰
- `vibe-product` 实测时间线:10-04 19:2x 前 `等待` → **22:02 起 `已完成`** → **10-05 14:04 `declare` 了新目标**,但 `lifecycle` **仍是「已完成」**,`lifecycle_at` 停在 10-04(**残留**)。
- 拦它的只有**一行**:`collabd.py:4557` `if reason == "queue-empty" and life != GOAL_LIFE_RUN: return None`。
- 常驻于是**每 60 秒打一条** `检查会话:目标状态=已完成(非进行中)⇒ 不建`,**从 10-04 22:02 空转约 17 小时**(日志 14760+ 行**全是这一句**)。
- 为什么设计成"绝不自动改":`GOAL_LIFE_WAIT="等待"` 的语义是「**用户没点头就不动**」,代码原话「宁可等,不可动」⇒ **⛔ 不许把这条闸拆掉**。
### 三、止血(已做)/机制(未定)
- **已做**:手工把 `lifecycle` 由「已完成」改回「进行中」(`--set-life`,`by=主会话`)⇒ **90 秒内**日志即出现 `已建检查会话排期 … fire=+90s`。
- ⛔ **`declare` 是否该自动重置 `lifecycle` 仍未拍板**(三候选:A 仅终态重置/B 只警告/C 只写文档)—— 触及「机制会不会自作主张」红线,**须用户定**。
### 四、⛔ 教训(可迁移)
1. **目标状态是"人的决定",⛔ 不许机制替它改** —— `lifecycle` 残留会让检查机制**静默空转**(日志每 60s 一条、看着"在跑");但修法**不能是"自动重置"**,那会把「用户没点头就不动」这条红线一起拆掉。**方向:让残留可见(告警/体检项),而不是让它自动消失。**
2. **两个主题别塞进一条** —— 本条原与 P0-77(前缀改造)合写,7.4 KB;拆开后各自 <5 KB。判据:**单条讲一件事,跨主题就该拆**(P0-2 同型先例)。
---
## P0-80 🔴🔴🔴 **机制新建的会话/排期「模型写死」⇒ 与主会话不一致;且已有判据只扫 `recurring` 恰好看不见它**(★ 2026-10-05 用户问「为什么 vibe-product 检查进程创建的 执行和检查会话没有使用主会话相同的模型」)
### 一、症状(用户视角)
vibe-product 常驻新建的 `[执行]`/`[检查]` 会话,模型全是 `space-bunny`;而**主会话是 `deepseek-v4.1-flash`(thought=high)**
⇒ **同一件活在两种模型上跑**,能力档位、思考开关全不一致。
### 二、实测读数(2026-10-05 16:0x · 只读取证)
| 对象 | 模型 | 条数 |
|---|---|---|
| **本区主会话**(`a80f300d`=本条会话) | `deepseek-v4.1-flash`(thought=**high**) | 1 |
| 机制新建的会话(`bg=1` 且 `[执行]`/`[检查]`) | `space-bunny` | **49** |
| 同上(历史,10-02 那批) | `deepseek-v4.1-flash` | 28 |
| 排期表 `automations` | `space-bunny` | **9** |
| 排期表对照:**手工建**的那条 | `deepseek-v4.1-flash` | 1 |
⇒ **10-04 起从 `deepseek…` 整体变成 `space-bunny`** —— 分界线清楚。
### 三、根因(两条,各自独立)
**根因 A — 模型是硬编码字面量,写在唯一一处**
- `collabd.py:3941`:`create_check_schedule()` 的 INSERT 里**字面写死** `"space-bunny"`,同时 `model_is_thinking=0`。
- 🔴 **关键是"两条建会话的路,模型来源不同"**:
· **常驻程序直连 SQLite 建**(`create_check_schedule()`)⇒ 模型 = **代码里写死的那个**
· **会话侧走宿主工具建**(`automation_update`)⇒ 模型 = **宿主默认**(跟主会话一致,实测那条 `deepseek-v4.1-flash` 就是这么来的)
- ⇒ 用户看到的"检查进程创建的会话模型不对",正因它走的是**第一条路**;而 `_gap_plan()` **自己不写库**(只产出参数给会话照抄)⇒ 执行会话若由常驻直建,同样中招。
**根因 B — 已有「模型可用性」判据只扫 `recurring`,而机制新建的全是 `once`**
- `session-rules-check.py` ⑧ 逐字:`_recur = [a for a in mine if a["schedule_type"] == "recurring"]`。
- 机制新建的排期 **`schedule_type` 恒为 `once`**(P0-65)⇒ **一条都不进扫描面** ⇒ 该判据恒判 ok。
- 🔴 与 **P0-76 / P0-77 的 ③ 完全同型**:**闸门本身是好的,但它看不见这类东西**(扫描面漏 ⇒ 静默失效)。
### 四、修法(待落地,方向已定)
- **A**:`create_check_schedule()` 的模型**不许写死** ⇒ 从**本区最近一条人开会话**取 `model` + `thought_level`
(实测取数可行:`sessions WHERE is_background_automation<>1 AND cwd LIKE <本区> ORDER BY created_at DESC`),
取不到时**回落**到一个显式常量并**打日志**(⛔ 不许静默)。
- **B**:判据 ⑧ 的扫描面**从 `recurring` 扩到「本区全部排期」**(含 `once`)。
- ⚠️ **改完必须带变异对照**:把取数短路成常量 ⇒ 判据须报红;还原 ⇒ 复绿。
### 五、⛔ 教训(可迁移)
1. **凡"机制替你决定"的参数,都要先问「该跟谁一致」** —— 模型、思考档、上下文窗口、权限模式这几格,
用户在界面上选的是**一次选择**;机制另建会话时**不继承**就相当于**偷偷替用户换了**。
2. **硬编码的参数,本质是「把一次实测当成了永恒」** —— `space-bunny` 当时能用,但用户后来换成了
`deepseek-v4.1-flash` ⇒ 机制还在按几个月前的选择跑。**判据:凡写死的业务参数,一律问"它会不会变"。**
3. **两条独立代码路径做同一件事,就会有"两条不一致的默认值"** —— 修的时候 **⛔ 别只修一条**
(本次:常驻直连 vs 会话侧工具,两者的模型默认值必须**同源**)。
4. **判据的扫描面要按「这类东西实际长什么样」定,⛔ 不能按"我记得的那一种"定** ——
⑧ 只写 `recurring` 是因为"当初只想到周期钟";机制后来改成 `once` 了,扫描面**没跟着改** ⇒
**同一类事故第 N 次复发**(P0-76 ①、P0-77 ③、本条 ⑧ 是同族)。
## P0-81 🔴🔴🔴 **判据拿「被测的同一个常量」当下界 ⇒ 拆掉被测对象竟然全绿**(★ 2026-10-05 实测,变异对照当场抓到)
**场景**:用户定案「目标文件夹收进 `<工作区根>/执行会话/目标-xxx-xxxxxx/`」⇒ 新增常量 `_GOAL_DIR_PARENT = "执行会话"`
+ 新增自检判据「目录名在 `执行会话/` 一层下」。
**第一版判据(❌ 恒绿)**:
```python
("🔴 目标文件夹在 `执行会话/` 一层下", d1.startswith(m._GOAL_DIR_PARENT + "/"))
```
**变异对照**(把 `_GOAL_DIR_PARENT` 改成 `""` 模拟"父层被拆掉")⇒ 结果 **PASS 95 / FAIL 2**,
与**正确实现逐字相同** —— **拆掉被测对象竟然全绿**。
**根因**:判据的**下界**取自**被测的同一个常量** ⇒ `_GOAL_DIR_PARENT=""` 时判据变成
`startswith("/")` ⇒ 而返回值是 `"/目标-甲-8b984a"`(父层空 ⇒ 多一个前导斜杠)⇒ **照样为真**。
⇒ **判据与实现同源 ⇒ 一起错 ⇒ 恒绿**(这就是"判据恒绿"最隐蔽的一种:它**不是写死了期望值**,
而是**把期望值挂在了被测对象身上**)。
**✅ 正解**:判据里**写死期望字面**(`"执行会话/"`),⛔ 不引用被测常量:
```python
("🔴 目标文件夹在 `执行会话/` 一层下", d1.startswith("执行会话/") and d1.split("/")[-1].startswith("目标-"))
```
修完再跑同一变异体 ⇒ **FAIL 3**(三条新判据全红)⇒ 判据真抓得住。
**另两条同批教训**:
1. **判据的假设会过期**:老判据 `not re.search(r"[\\/:*?\"<>|]", clean)` 假设"目录名里没有 `/`",
加了父层后 `clean` **必然含一个 `/`** ⇒ **误报**。✅ 正解=只清洗/只判**末级目录名**:
`clean.split("/")[-1]`。⇒ 同族第 2 次(**判据自身的假设过期 ⇒ 伪装成产品缺陷**)。
2. **落点判据要同时认新旧形态**:`parts[0].startswith("目标-")` → 补
`parts[0]=="执行会话" and parts[1].startswith("目标-")`。⛔ **不许硬切新形态** ——
那会把存量旧目录里的产物全判成"散在别处"(**误报**,不是事实)。实测两态并存时
`130 在 / 22 散`,与改前**逐字相同** ⇒ 证零新增散落。
📌 **纪律**:凡判据里出现 `m.<常量>` 当作**期望值**,先问一句「**把它改坏,判据会红吗**」;
答不上来 ⇒ 立刻做变异对照。**"跑一次是绿的"不构成验收**(恒绿的那次也绿)。
## P0-82 🔴🔴🔴 **自检夹具「静默写到真工作区」+「判据剔注释却漏了 docstring」+「过期判据被当缺陷」**(★ 2026-10-05 · 同一天连栽三次,`goal.json` 被真实改写两次)
**场景**:给 `goalctl.py` 加「换目标自动复位 `lifecycle`」,配一条**行为级**自检用例
(真跑 `declare --switch-goal --yes`,读回落盘的 `lifecycle`)。
### 坑①:夹具隔离失效 ⇒ **静默改写真工作区**
**现象**:用例 `rc=0`,但读回 `title` **还是旧值**、`lifecycle` **也没变**
—— 看着像"功能没生效"。**真相是它写到别处去了**:真工作区
`<ws>/tmp/supervise-inbox/goal.json` 的 `title` 被改成了测试值「新目标乙」,
并生成了一条归档件。**跑一次自检 = 改一次真目标**。
**根因(两层,缺一不可)**:
1. `goalctl.py:_resolve_ws()` 的候选顺序是
**`DSH_COLLAB_WS` → `DSH_WS_ROOT` → cwd(含 `.workbuddy/collab/`)**,
⛔ **完全不看 `COLLABD_CONFIG`,⛔ 也不认 `COLLABD_WORKSPACE`**。
而夹具第一版只设了后两个 ⇒ `_resolve_ws()` 两个都不认、cwd 又没有 `.workbuddy/collab/`
⇒ **回落到技能包上级目录**。⚠️ 那里若叠着真工作区 ⇒ **静默写真**。
2. `goalctl.py` 的 `INBOX` 是 **硬编码** `WS/"tmp"/"supervise-inbox"`
—— ⛔ **不读配置里的 `inbox` 字段**(实测 `{"inbox":"inbox"}` 被无视)。
夹具第一版把 `goal.json` 写在 `<tmp>/inbox/` ⇒ 脚本读到「(无)」、写到 `<tmp>/tmp/supervise-inbox/`
⇒ **读回当然还是旧值**(伪装成"功能没生效")。
**✅ 正解(三层,缺一不可)**:
```python
# ① 落点跟着脚本走(⛔ 别照配置猜)
inbox = _pl.Path(tmp) / "tmp" / "supervise-inbox"
# ② 变量必须是 _resolve_ws() 的第一顺位
env = {**os.environ, "COLLABD_CONFIG": str(cfg), "DSH_COLLAB_WS": tmp.replace("\\", "/")}
# ③ **兜底断言**:从子进程 stdout 抠出它自报的落点,核对前缀;不在 tmp 内 ⇒ 直接记红
```
**③ 是关键**:⛔ 别靠"我记得传对变量"(**人的记性不是判据**)。
子进程自报 + 前缀核对 ⇒ 把「隔离失效」从**静默事故**变成**当场报红**。
另加一道**外部护栏**:跑写动作前后量**真文件 md5**,必须逐字不动(这次实测
`30ec31d7…` 前后一致 ⇒ 通过)。
### 坑②:判据「已剔除注释」却**漏了 docstring**
**现象**:判「旧形态已清除」用 `"字面" not in src` ⇒ **误红**。第一版修法是「剔掉 `#` 注释行」,
换了另一条判据又误红 —— 因为**该字面在 docstring(三引号)里**。
📌 **纪律**:**「剔注释」≠「剔 docstring」,这是两件事**。
本仓库的考古纪律要求废弃形态的说明**必须写在注释/docstring 里**
⇒ 那些字面**注定存在** ⇒ 拿全文判「已清除」**必然误红**。
**✅ 正解**:用 `tokenize` + `ast` **精确剔两样**:
用 `ast` 抓 docstring 的起止行 → `tokenize` 逐 token,只剔 `COMMENT`
和「**位于 docstring 的** `STRING`」。
⛔ **别把 `STRING` 全剔** —— 第一版这么干过,结果把「写在命令**字符串里**的字面」
(任务动作名、启动器文件名)也剔了 ⇒ **由误红变另一种误红**。
⇒ "代码干了什么"经常就写在字符串里,剔掉等于**看不见真实现**。
### 坑③:**过期判据被当成产品缺陷**
**现象**:全量自检 `FAIL 2`,都指向 `collabd.py` 里 `_escalate_to_keeper()` / `_own_path()`。
看着像真 bug,实际是**判据判的形态已经整套废弃**(10-05 已把「旧看守形态」换成
`pythonw.exe` + 启动器)⇒ 连**被测对象都不存在了** ⇒ 恒红、且**无法修复**。
**✅ 正解(两条动作,都要做)**:
1. **删掉死代码**(`_find_keeper_tpl()` 零调用点)—— ⛔ 留着只会**再招一批过期判据**;
2. **把过期用例换成形态守卫**:判「旧形态已彻底清除 + 不许回潮」
(壳脚本动作不许再现、动作必须是 `pythonw.exe` + 启动器、`CREATE_NO_WINDOW` 在位)。
变异对照:把动作改回壳脚本 ⇒ **红**;还原 ⇒ **绿**。
📌 **纪律**:判据红了,**先问「被测对象还在不在」**。
不在了 ⇒ 是判据过期,⛔ 不是"改断言把它弄绿"(那是把判据废掉),而是
**删死代码 + 换一条守新形态的判据**。
### 坑④(并列):**夹具隔离失效时的备份不可信**
`bak-goalctl-*` 是**覆盖前一刻**的内容 —— 若前一步**已经写坏**,备份**也坏**
(实测本轮备份与坏件**逐字相同**,不可作恢复源)。
✅ **可用的恢复源 = 被污染的归档件本身**(`goals/<旧标题>__<时间>.json` 里存着**改前的完整 34 键**)
⇒ 逐字还原、复核 `title`/`lifecycle` 四件套/`acc keys`/顶层键数全部复原。
📌 **纪律**:**验收前必须 md5 目标真文件**("跑完自检没报错"≠"真文件没被动")。
---
## P0-83 🔴🔴🔴 **「兼容」二字是大杂烩的孵化器 —— 凡旧物必答"删了会坏在哪"**(★ 2026-10-05 · 用户当场点破)
**用户原话(逐字)**:「**兼容个毛线,今天兼容一个明天兼容一个 过不了一周就成大杂烩了**」。
**触发场景**:报障「vibe 区怎么还有一条 `[协作]` 前缀的会话,是不是技能没更新完全」。
AI 第一版给的方案是「把 `[协作]` 从**活角色**降为**兼容扫描**」—— **被用户当场否掉**。
⚠️ 要害不是"降级"这个动作错了,而是**用了「兼容」这个词**:它把一件有硬理由的事
(**删了就坏**)说成了一件可以拖的事(**先兼容着**)⇒ 后人读到就继续拖。
### 判据:一句话分清两种"留"
面对任何旧前缀/旧字段/旧值,**必须能一句话答出它属于哪一种**:
| 类型 | 判据(一句话能答出) | 处置 |
|---|---|---|
| **① 删了会坏** | 「删它 ⇒ **谁变成什么**」(点名受害对象 + 后果 + 可逆性) | **必须留**,但注释里⛔ **不许写「兼容」**,要写**受害事实** |
| **② 留着没用** | 答不出受害对象,或只能说"万一以后要用" | **当场删**(不给"兼容期") |
🔴 **"答不出 ⇒ 就是该删的"** —— 这是本条的可操作形式。
⚠️ **别把 ① 也写成"兼容"**:那是**把有据的保留伪装成没据的拖延**,
下一个读代码的人会照拖,于是**真的变成大杂烩**。
### 本仓实测(把「删了会坏在哪」答出来)
`board.py::_PFX` 里 `协作`/`协作目标`/`任务会话` 三条 —— 2026-10-05 **当场跑函数验的**(⛔ 非推理):
```
删任一条 ⇒
[协作]-[手机接入]-N9 复测 ⇒ role = ''
[协作]N9 派活 · V2 手机发出的消息 ⇒ role = ''
[协作目标]-xxx ⇒ role = ''
[任务会话]-会话机制取证… ⇒ role = ''
⇒ 看板画不出、派活漏管,且不可逆
```
⇒ 这就是"删了会坏在哪"的答案 ⇒ **留,但注释必须写这个,不写"兼容"**。
### ✅ 正解(三件一起做,缺一仍是拖)
1. **活类别表只留在用前缀**:`session-rules-check.py::ROLES_LIVE` **只剩 `[执行]`**。
旧前缀从"活"里**彻底踢出**,只保留在**扫描面** `SESS_PREFIXES`(捞历史行用)。
⚠️ 踢出的收益是**实打实的**:踢之前 ⑪ 判据每轮把 `唤醒、协作、跟进` 三个报成"类别缺失"
(**假红**),踢之后只剩 `执行` 一项,指标准确了。
2. **加机制闸门,防"明天又长一条"**:`session-rules-check.py` **第 ⑬ 项自检**
—— 扫**活排期名**+**活会话标题**,出现旧前缀 ⇒ **当场 fail**。
🔴 判据**只扫活件**(⛔ 不扫注释/docstring/考古段/软删的历史行)——
后者是"记录过去发生了什么",扫它们就是**误红**。
**变异对照(已跑)**:全 `[执行]`⇒ok|混 1 条 `[协作]`⇒fail|混 1 条 `[任务会话]`⇒fail|
历史行但**不以旧前缀开头**(`接续:…`)⇒ok ⇒ **非恒绿非恒红**。
3. **存量活件改名,⛔ 不删行**:改 `automations.name` / `sessions.title` 的**前缀**
(`[协作]`→`[执行]`),**先备份原样(3 行)**,改后 `PRAGMA integrity_check` = `ok`。
⚠️ 改名前**先查 `deleted_at`** —— 别把**软删的死单**也算进"存量"(实测 59 条 `[协作]` 里
**58 条早已软删**,真正的活件只有 1 条;把软删的算进来 ⇒ 报出 59 条的假规模)。
### 坑(两个,都踩到了)
- **坑①:`sqlite3` 的 `LIKE` 转义写法失效** ⇒ **假"0 条"**。
`WHERE title LIKE '[[]协作]%'` 实测**恒返回 0**(同一时刻 `GLOB '[[]协作]*'` 与
`instr(title,'[协作]')>0` 都返回 1)。
✅ **不要用 `LIKE` 转义方括号** —— 改 `instr(title, '[协作]') > 0`(最稳)或 `GLOB`。
📌 **纪律**:**"查到 0 条"≠"没有"** —— 换个写法复验再下结论(本次差点据此报"库里已清干净")。
- **坑②:把"软删的历史行"当成"当前存量"报给用户** ⇒ 规模虚高 59 倍。
✅ 数存量**必须带 `deleted_at IS NULL`**;报"活排期/活会话"与"历史痕迹"要**分开说**。
---
## P0-84 · 回收站「每半天十几个 G」—— 两个叠加根因(宿主 safe-delete shim × Windows `truncate` 不稀疏)
**用户原话(2026-10-05)**:
> 你再看一看,现在每半天就得创建十几个 G 的文件在回收站里。有必要创建这么多吗?看看是不是都是这个绘画创建的。
### 现象
E 盘回收站 **15.36 GB / 32,557 文件**,其中:
| 实占 | 占比 | 路径 |
|---|---|---|
| **12.02 GB** | **77.7%** | `ai1net-dsh-server\tmp\selftest`(6,966 件) |
| **1.79 GB** | **11.6%** | `vibe-product\tmp\selftest`(1,185 件) |
| 0.35 GB | 2.2% | `ai1net\.workbuddy\collab` |
⇒ **两工作区 `tmp\selftest` 合计 13.81 GB = 89.3%**。删除时刻 **10-04 11:08 → 10-05 10:06 仅 23 小时**。
### 根因一:宿主 safe-delete shim 把工作区 `tmp/` 的删除**全送回收站**
- 载体:`<WorkBuddy>\resources\app.asar.unpacked\cli\vendor\shim\sitecustomize.py`
- 机制:WorkBuddy 把该目录**前置到 `PYTHONPATH` 上** —— `sitecustomize` 是 Python **保留模块名**,
解释器启动时**自动 import** ⇒ 它 monkey-patch:
`os.remove` / `os.unlink` / `os.rmdir` / `shutil.rmtree` / `pathlib.Path.unlink|rmdir`
→ 全部改道 **`shell32.SHFileOperationW(wFunc=FO_DELETE, fFlags=FOF_ALLOWUNDO|NOCONFIRMATION|NOERRORUI|SILENT)`**。
- **实测坐实(可复跑)**:`shutil.rmtree(<某探针目录>)` ⇒ 回收站 `$I` 条数 **9705 → 9706**。
⚠️ 直觉"`rmtree` 是真删、不过回收站" **在本机是错的**。
- 触发:`CODEBUDDY_SESSION_ID` 非空(=在 WorkBuddy 会话里跑 Python)+ `CODEBUDDY_SAFE_DELETE_ENABLED != "0"`。
- 🔴 **豁免名单只有三类**:`_is_under_os_tmp_dir`(**系统** temp)/ pip site-packages 临时目录 / WorkBuddy 托管 venv。
⇒ **`E:/ProgramData/AIProject/<线>/tmp/` 不在内** ⇒ 工作区里所有 `tmp/` 删除**照单进回收站**。
- ⇒ **这是宿主的"防误删"安全设计,不是 bug**;但**它不认识"本工作区的 tmp/ 是一次性自检残渣"**。
### 根因二:`selftest.py::_fake_cfg()` 的"稀疏文件"假设在 Windows 上**不成立**
原注释(`selftest.py:1123`):
> 用 `truncate` 建**稀疏文件**:秒级拿到 10 MiB 的**体积读数**(`getsize`),**⛔ 不真写 10 MiB**。
🔴 **实测反证**:
```python
with open(p, "wb") as f: f.truncate(9_600_000)
# 10 个这样的档:逻辑 91.6 MB ⇒ 实占 91.6 MB (1:1,比值 100%)
```
⇒ **NTFS 上 Python 的 `f.truncate()` 不产生稀疏文件**(真稀疏要 `CreateFileW` 带
`FILE_ATTRIBUTE_SPARSE_FILE` + `SetFilePointerEx` + `SetEndOfFile`;实测该法才得 `logical 9.2 MB / real 9.2 MB`… 也不省,须配合 `FSCTL_SET_ZERO_DATA`)。
**结论:注释里的假设是错的**,每个"假 10 MiB 档"都真占 10 MiB。
- 单次 selftest 造:`9.6MB×3 + 10.0MB×3 + 4.2MB×1 ≈ 60 MB` **真占盘**。
- 13.81 GB ÷ 60 MB ≈ **235 次 selftest**(23 小时内!)。
- 触发源:**`install.py --verify` 每次都跑 selftest**(`install.py:376`)。⛔ **无任何自动排期** ——
全是**人工/会话每改一次就跑一遍 `--verify`**。
### 判据(怎么量才不骗自己)
- 🔴 **`du -sh` 在回收站上会虚高**:顶层 `$R` 是**目录**,按"顶层条目"逐条量会漏掉子树 ⇒ 得到**假 2.17 GB**。
✅ **正解**:`$I` 解析原路径 → 定位 `$R` 子树 → **`os.walk` 逐文件** `GetCompressedFileSizeW`。
实测实占 **15.19 GB ≈ 逻辑 15.46 GB**(98%)⇒ **没有稀疏红利**。
- 🔴 **`$I` 解析**(Win10/11):`ver(8B) + size(8B) + FILETIME(8B) + plen(4B)` ⇒ 路径在 **offset 28**、长 `plen*2` 字节、`utf-16-le`。
- 🔴 **⛔ 别在生产工作区裸跑 `selftest.py` 做"量体积"实验**:本轮实测跑 **16m56s / rc=15**,
且部分用例会 `m.WS = str(tb)`(`selftest.py:3683`)**改写工作区指向** ⇒ 真改状态。
### 正解(按优先级)
1. **治本·改造档方式**:`_fake_cfg()` 不必造真 9.5 MiB 文件 —— `_deaf_sids()` 只读 `getsize`,
可 **mock `os.path.getsize`** 或在用例里**注入体积读数** ⇒ 单次从 **60 MB → ~0**。
⚠️ 须保持"判据读的是体积数字"这层语义,⛔ 不许把判据改成恒真。
2. **治本·子进程关掉 safe-delete**:`selftest` 起的子进程 env 加
`CODEBUDDY_SAFE_DELETE_ENABLED=0` ⇒ 真删不进回收站(只对**自检残渣**用,⛔ 别全局关)。
3. **治标**:定期清空回收站(这些全是**可再生的**自检残渣)。⚠️ 由用户在资源管理器里做。
4. 🔴 **别忘了**:给**工作区 `tmp/`** 加豁免 → 除非宿主支持(当前**不支持**路径级豁免),
否则**唯一可控的就是"少造"**(第 1 条)。
### 🔴 已修(2026-10-05 · 当轮落地,附读数)
**正解第 1 条落地了**:`selftest.py::_fake_cfg()` 现在**先标 `FSCTL_SET_SPARSE`、再 `truncate`**
(新增 `_make_sparse()`;⛔ 顺序不能反 —— 先 truncate 再加标志,已分配的簇不会自动归还)。
| | 逻辑 | 实占 | 比 |
|---|---|---|---|
| 老写法(只 `truncate`) | 9,600,000 | 9,600,000 | **100.00%** |
| 新写法(先标稀疏) | 9,600,000 | 65,536 | **0.68%** |
5 档合计:48,000,000 → 327,680 字节(**省 99.3%**)。
⇒ 单次 `selftest` 的假配置从 ~60 MB 降到 ~0.4 MB;`install.py --verify` 不再往回收站搬几十 MB。
⚠️ **⛔ 不追求零占用**:文件系统不支持稀疏时 `_make_sparse()` 返回 False,行为**回落成真写**
(与老实现同),但**会打 stderr 提示** —— ⛔ 不许静默退化(否则又是"看着省了、其实没省")。
⚠️ 正解第 2 条(子进程 `CODEBUDDY_SAFE_DELETE_ENABLED=0`)**仍未接线**,第 1 条已足够按住主量。
## P0-85 🔴🔴🔴 **装机制时「工作区解析」静默错归属 —— `roots.env` 残留顶替了脚下的目录**(★ 2026-10-05 用户报「新工作区加载会话和执行会话技能遇到的问题」)
### 现象
在 `E:/ProgramData/AIProject/agent-product` 里跑 `install.py --dry-run`,
解析出的工作区 **= `ai1net-dsh-server`**(⛔ 不是当前目录),踩技能 S 红线(归属工作区不能错)。
⚠️ **`--dry-run` 全程零告警** —— 如果把 `--dry-run` 换成 `--apply`,就是**静默装错地方**。
### 根因(两层,各自都是"判据写错")
**① 优先级颠倒**(`install.py::detect_workspace` 老顺序):
```
显式 --workspace → 宿主 env → ⚠ roots.env 里上次装的值 → 才轮到 cwd 向上找
↑ 排在这里是错的
```
🔴 **`roots.env` 是「本包全局单例」**(一个包只此一份,`install.py:22` 设计如此),
它记的是**上次装到哪**,⛔ 不是"当前工作区"。把它排在 cwd 之前 ⇒
**只要装过一次,此后在任何目录跑都会继承同一个值**。
**② cwd 判据硬要 `state.py` ⇒ 非 ai1net 线全判死**(`install.py` 老写法):
`(cand/".workbuddy").is_dir() and (cand/"state.py").is_file()`。
`state.py` 只是 **ai1net 那条线**的现状快照脚本 —— `vibe-product` / `agent-product` **本来就没有**
⇒ 老判据在那些工作区里**永远返回 None** ⇒ 于是**恰好**由根因①的残留顶替。两条一起才成灾。
⚠️ 另有一处**易误认**:`E:/ProgramData/AIProject/.workbuddy` **也存在**
(早期遗留:`automations/` + `memory/2026-08-30.md`)⇒
"只看 `.workbuddy` 存不存在"会把**父目录**认成工作区。
### 判据(怎么验才算数)
- 基线必须**四个真实目录全绿**(缺一个就是假绿):
`ai1net-dsh-server` ✅ / `vibe-product` ✅ / `agent-product` ✅ / `AIProject`(父目录)❌
- 三个变异**必须都能翻**(否则判据恒绿):
① 退化成"有 `.workbuddy` 即算" ⇒ `AIProject` 翻
② 退化成老判据(+`state.py`)⇒ `vibe-product`、`agent-product` 双双翻
③ 优先级退化成"残留 > cwd" ⇒ `vibe-product` 里解析成 `ai1net-dsh-server`(**原病复现**)
- 🔴 **⛔ 别只测"能解析"** —— 本轮第一版基线就写错了期望值(把 `agent-product` 期望成 False),
结果**基线自己先红**;若当时偷懒"看到有红就当判据生效",就是把**期望错**读成**判据对**。
### 正解(已落地 · `install.py`)
1. **重排优先级**:`显式 > 宿主 env > cwd 就近 > roots.env 残留`。
2. **放宽 cwd 判据**:有 `.workbuddy/collab/`,或 `state.py`,或(`.workbuddy/memory/` + `state.py`)
⇒ 三选一即算工作区。⛔ 不再把 `state.py` 当必要条件。
3. **加归属自检(关键)**:`cmd_apply` 里把「cwd 判据」与「最终选用」并排打出来,⛔ 不许静默:
- `cwd 判据 is None` + 未显式传 `--workspace` ⇒ 🔴 **最危险的一档**:
"现在的工作区是从 `roots.env` **残留**推出来的,⛔ 不是从你脚下的目录认出来的"。
⚠️ **老写法只判 `here != ws`,恰好漏掉这一档**(`None != ws` 为真但语义不对,
第一版就是这么写的,实测告警**没触发**)⇒ 这正是"判据绿灯与被检对象状态无关"的老病。
- `roots.env` 原指向 ≠ 本次将写 ⇒ 🔴 警示「改指之后,原工作区侧也会跟着指过来」。
### 🔴 一条**必须说给用户**的结构事实
`roots.env` 是**包级单例**,而钩子条目**没有 `cwd` / `env` 字段**
(实测 `settings.json` 里本包条目只有 `command`/`timeout`/`type`)⇒
所有脚本都靠 `__file__` 反推包根 → 读**同一份** `roots.env`。
⇒ **一个技能包同时只服务一个工作区**;「两个工作区都要跑机制」是**已知不支持**的形态。
(此结论有读数支撑:`grep hooks` 字段 + 各脚本 `_sm_load_roots()` 同源。)
### ⚠️ 本轮另发现(未处理,留给后续)
- `settings.json` 里有**两条不属于本包声明表**的钩子:`reply-style-guard.py`、`supervise-ensure-hook.py`
⇒ `--dry-run` 的"被排除条数"只数了 `decision_bridge`(=4),**没覆盖这两条**。
- `agent-product/.workbuddy/collab/collabd.config.json` 里带着 `_说明` / `_形态` 富文本字段
(内容:「由 install 流程本地部署生成(未改全局 roots.env,看板仍由 ai1net-dsh-server 主视图承担)」),
⛔ **不是** `collabd.config.example.json` 的产物(该模板**无** `_形态` 字段)
⇒ 是**另一次会话手搓的规避性 workaround**。⚠️ 这种"本地部署"是不是该收进 `install.py`,未定。
### 🔴 已修(2026-10-05 · 当轮落地,附读数)
**正解第 1 条落地了**:`selftest.py::_fake_cfg()` 现在**先标 `FSCTL_SET_SPARSE`、再 `truncate`**
(新增 `_make_sparse()`;⛔ 顺序不能反 —— 先 truncate 再加标志,已分配的簇不会自动归还)。
| | 逻辑 | 实占 | 比 |
|---|---|---|---|
| 老写法(只 `truncate`) | 9,600,000 | 9,600,000 | **100.00%** |
| 新写法(先标稀疏) | 9,600,000 | 65,536 | **0.68%** |
5 档合计:48,000,000 → 327,680 字节(**省 99.3%**)。
⇒ 单次 `selftest` 的假配置从 ~60 MB 降到 ~0.4 MB;`install.py --verify` 不再往回收站搬几十 MB。
⚠️ **⛔ 不追求零占用**:文件系统不支持稀疏时 `_make_sparse()` 返回 False,行为**回落成真写**
(与老实现同),但**会打 stderr 提示** —— ⛔ 不许静默退化(否则又是"看着省了、其实没省")。
⚠️ 正解第 2 条(子进程 `CODEBUDDY_SAFE_DELETE_ENABLED=0`)**仍未接线**,第 1 条已足够按住主量。
## P0-86 🔴🔴🔴 **`goal.json` 的 `execution_doc` 是「只读不写」的字段 —— 无人维护 ⇒ 必然漂移,且能漂成跨区污染**(★ 2026-10-05 修 P0-85 时顺手查出)
### 现象
`install.py --verify` 报「生产工作区该文件**真存在**」FAIL,路径 `…/ai1net-dsh-server/目标-本机协作-c8154d/目标执行状态.md`。
⚠️ 文件**其实存在**,真身多一层 `执行会话/`。
### 根因:整条链路只有一个读者、零个作者
| 文件 | 对该键做了什么 |
|---|---|
| `collabd.py` | 只在 `CHECK_PROMPT_GOAL` 说明文字里提一句,**无写入代码** |
| `goalctl.py` | 一次都没出现 |
| `init_workspace.py` | 建 goal 模板时只有 `_唯一来源` **注释**,**不含该字段** |
| `selftest.py` | **唯一读者**(判据按它定位文件) |
⇒ 代码口径会改(10-05 加了 `执行会话/` 一层),登记值永远不跟着改(无作者 ⇒ 只能手工改 JSON ⇒ 迟早忘)。
### 比"假红"更坏:漂成了跨区污染
| 区 | 登记的 `execution_doc` | 按本区 `title` 算出的真路径 |
|---|---|---|
| `ai1net-dsh-server` | `目标-本机协作-c8154d/…` | `执行会话/目标-本机协作-c8154d/…` |
| `vibe-product` | `目标-vibe-product-3e3182/…` | `执行会话/目标-vibe-product-5d27fe/…` |
| `agent-product` | **(未登记)** | `执行会话/目标-agent-product-3e3182/…` |
🔴 `vibe-product` 那行最毒:`3e3182` = **`agent-product` 目标标题的 `sha1` 前 6 位**,正确值是 `5d27fe`
⇒ 该值是**从别区串写来的**,只看这一行**推不出该找哪个文件**(不是"前缀写错",是**指向了别人的目标**)。
⚠️ 噪音:`3e3182` 在 `ai1net-dsh-server/执行会话/` 下也有同名空壳目录(建于 10-03 12:12,早于 10-05 口径变更)⇒ **旧算法遗留,与本条无关**。
### 原判据设计是对的,缺的是倒数第二环
`t_execution_doc` **故意读生产 `goal.json` 登记值**(⛔ 不拿测试夹具算法重算 —— 上一版那样做过,问生产文件系统**必然不存在 ⇒ 假红**)。
⇒ 设计意图没错;缺的是**没人保证登记值跟得上代码**。判据没错、数据错了,而数据的错是代码不改它造成的。
### 正解(当轮已落地)
**① 接线:`collabd.py --ensure-goal-dir` 里顺手对齐**(按现行口径算真路径的唯一一步):
```python
_want = exec_doc_rel() # 🔴 唯一真源(与 prompt/goal_dir_rel() 同源)
_gj = INBOX / "goal.json"
if _gj.is_file():
_gd = json.loads(_gj.read_text(encoding="utf-8")) or {}
if isinstance(_gd, dict) and _gd.get("title"):
_have = str(_gd.get("execution_doc") or "")
if _have != _want:
_gd["execution_doc"] = _want
_gd["execution_doc_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
_gj.write_text(json.dumps(_gd, ensure_ascii=False, indent=1),
encoding="utf-8", newline="")
out["doc_sync"] = ("✅ `execution_doc` 已对齐:%r → %r" % (_have or "(未登记)", _want))
```
⚠️ 放这里的理由:`--ensure-goal-dir` **幂等**、`init_workspace.py` 每次初始化必调 ⇒ 每区自动追平,⛔ 不靠人记。
⚠️ **只改这一个键**(整份读回原样写回,其余字段一字不动 —— 那是状态真源,覆盖=毁进度)。
⚠️ **失败不影响建目录**:整段包 `try`,出错只记 `out["doc_sync"]`。
**② 出声:CLI 把对齐结果印出来**
```python
if _r.get("doc_sync"):
print("状态文档登记:%s" % _r["doc_sync"])
```
⛔ 不许静默对齐(静默 ⇒ 用户以为本来就对,下次再漂仍查不出)。
**③ 判据加两条**(一条判数据、一条判接线,缺一条都会"改完数据又漂回去"):
```python
sync_ok = (not _exp_rel) or (_nrm(prod_g_rel) == _nrm(_exp_rel)) # 数据:登记值 == 按本区算出的
_ensure_sync = "_want = exec_doc_rel()" in _cd_code # 接线:对齐代码还在不在
```
⚠️ 比对**必须归一化**(`/`↔`\`、大小写),否则 Windows 上加一条假红。
⚠️ 期望值**用生产那份 `title`/`short` 现算**(`goal_dir_name()` 纯函数、不依赖 `WS`)。
### 实测读数
| 区 | 对齐前 | 对齐后 |
|---|---|---|
| `ai1net-dsh-server` | `目标-本机协作-c8154d/…` | `执行会话/目标-本机协作-c8154d/…` |
| `vibe-product` | `目标-vibe-product-3e3182/…` | `执行会话/目标-vibe-product-5d27fe/…` |
| `agent-product` | `(未登记)` | `执行会话/目标-agent-product-3e3182/…` |
`selftest`:`PASS 95 / FAIL 2` → **`PASS 97 / FAIL 0`**;`install.py --verify` ⇒ **✅ 全绿**。
### 变异对照(⛔ 必修,否则可能是恒绿)
| 变异 | 预期 | 实测 |
|---|---|---|
| A 基线 | 绿 | ✅ `PASS 1 / FAIL 0` |
| B 把 `execution_doc` 改回旧值 | 红 | ✅ `PASS 0 / FAIL 1` |
| C 还原 | 绿 | ✅ `PASS 1 / FAIL 0` |
| D 把 `_want = exec_doc_rel()` 改成 `_want = ""` | 红 | ✅ `PASS 0 / FAIL 1` |
| E 还原 | 绿 | ✅ `PASS 1 / FAIL 0` |
### 同轮另一处 P0:判据「全文 `in` 一把梭」=恒绿假修
修 `t_init_points_to_own_copy` 末条时,第一版写成 `("…", "不起看板" in src and "peer_workspaces" in src)`。
🔴 **实测:把那段 `print` 整块删掉(137 字节),判据照样绿。**
原因:`peer_workspaces` 全文出现 3 次、`不起看板` 2 次,**多数在注释里**(`src` 是**未剥注释**的全文)⇒ 全文 `in` = **在注释里找证据**。
✅ 正解:**锚定到那段代码**(`code` 已剥注释 ⇒ 注释字样不命中):
```python
def _tail_has_board_note(code: str) -> bool:
i = code.find("✅ 完成。后续协作")
if i < 0:
return False
seg = code[i:i + 1500]
return ("不起看板" in seg) and ("peer_workspaces" in seg)
```
⇒ 变异:删整段 ⇒ 红;只删「要并看」那一句(84 字节)⇒ 红;还原 ⇒ 绿。
**教训:判据的"作用域"必须跟着被判对象走,⛔ 不能拿一个大得多的范围去 `in`。**
---
## P0-87 🔴🔴🔴 **同一个技能里,同一份数据读三处、口径各写各的 —— `goalctl` 两处漏网**(★ 2026-10-06 · 用户「vibe-product 最新会话反应新问题 看看如何解决」)
### 症状(两条都不报错、不崩溃,只是"少说一句真话")
| # | 现象 | 真因 |
|---|---|---|
| ① | 目标**四条验收全过**,`goalctl.py status` 仍报「未完成(验收 V1=过;V2=过;…)」——**字面自相矛盾** | `goalctl.py:262` 写成 `if str(acc[k]) != "pass":` —— **死板比字面值**,而存值**真源就是中文** |
| ② | 配置里**已改指**新目标的任务图,`goals_open()` 仍报 `bad=['任务图读不到(taskgraph.json)']` ⇒ 目标判不出来 | `goalctl.py:136` 写成 `CFG = HERE / "collabd.config.json"` —— `HERE` = **技能包目录**,而按定则「技能目录里⛔ 不放生产配置」⇒ 该文件**恒不存在** |
### 🔴🔴 病根是同一个:**「唯一实现」的约定只写在注释里,没有强制**
`board.py::acc_is_pass()` 的 docstring 明写:
> 🔴 三处判据(`board.py` 本函数 / `collabd.py::_acc_is_pass` / `board.html::accIsPass`)**必须同款**
而 `board.py:1618` 与 `collabd.py:3022` **都做了中文归一化**(`ACC_PASS_WORDS = ("pass","过","通过","达","达标","合格","完成")` + 剥完成副词)。**实际是四处,`goalctl.py` 被漏掉了。**
⇒ 同一个字段(`goal.json.acceptance_state`)在**同一技能内有三套读法**:两处归一化、一处死板。
⇒ 后果:**看板说通过、常驻程序说不通过、`goalctl` 说未完成** —— 三张嘴各说各的。
**同轮第二处同型**:`collabd.py` 有完整的配置三级解析(`_cfg_candidates()`:env → 工作区标准落点),
而 `goalctl.py` **写死技能目录** ⇒ 配置改了不生效。**又是"一个技能两套读法"。**
### ✅ 正解
1. **判定层复用真身,⛔ 不复制词表**(复制就是下一次漂的来源):
`goalctl` 新增 `_acc_is_pass()` ⇒ 首选 `collabd._acc_is_pass()`(它自己又首选 `board.acc_is_pass()`);
拿不到 ⇒ **fail-closed 声明式兜底** + stderr 留一行(⛔ 绝不静默返回 True/False)。
2. **配置解析与 `collabd._cfg_candidates()` 同款三级**,⛔ 不新造顺序。
3. **提示文案必须与判定层一致**:`:605` 原写「要 `名字=pass` 或 `名字=未过`」⇒ **误导**(`未过` 与任意中文值在旧判定层同样恒判未过)⇒ 改为说明"值可写 `pass`,也可写中文 `过`/`已过`/`达`——判定层会归一化"。
### 实测读数
| 项 | 修前 | 修后 |
|---|---|---|
| vibe-product `goals_open()` | `{'open': True, 'why': [], 'bad': ['任务图读不到']}` | **`{'open': False, 'why': [], 'bad': []}`** |
| `selftest` | PASS 97 / FAIL 0 | **PASS 99 / FAIL 0** |
| `--verify` | — | **rc=0 ✅** |
### 变异对照(⛔ 必修,否则可能是恒绿)
| 变异 | 预期 | 实测 |
|---|---|---|
| A 基线 | 绿 | ✅ `PASS 99 / FAIL 0` |
| B `goalctl` 判定改回 `!= "pass"` | 红 | ✅ `PASS 97 / FAIL 1`(**命中本用例**) |
| C 还原 | 绿 | ✅ `PASS 99 / FAIL 0` |
### 🔴 写用例时连踩三个坑(都是"我写错了,不是代码错")
1. **`imp()` 加载的是 `collabd`,不是 `goalctl`** —— 两者 `goals_open()` **同名但返回类型不同**
(collabd 返 `bool`、goalctl 返 `dict`)⇒ 直接用 ⇒ `TypeError: 'bool' object is not subscriptable`。
2. **两个模块的 `INBOX` 不是同一个目录** —— `collabd.INBOX` 来自**配置**;
`goalctl.INBOX = WS/"tmp"/"supervise-inbox"`(**硬编码**)。写错落点 ⇒ `bad=['任务图读不到']` ⇒ **用例恒红**。
3. **`CFG` 修好之后,用例的图落点又变了** —— 修前 `CFG` 读不到 ⇒ `taskgraph` 回落默认 `INBOX/taskgraph.json`,
修后 ⇒ 跟着配置走 `TEST_WS/tg.json`。⇒ **用例不许硬编码任一侧,要从配置里那个值算**。
> ⭐ **教训:用例红了先分"是被测代码错,还是我喂错了"。** 三次都是后者,若当时直接去"修代码",会把好的代码改坏。
> ⭐ **教训:`selftest` 的夹具要跟着"被测模块自己算出来的路径"走**(`_gc.INBOX` / 配置里的 `taskgraph`),
> ⛔ 不要按"我以为的目录"写死 —— 同一技能里两个模块的落点可以完全不同。
---
## P0-88 🔴🔴🔴 本机**没有**任何回收站工具 —— `gio` 不存在,`Add-Type` 被拦,只剩 `SHFileOperationW`
> 症状:要"把目录送回收站"(可逆删除),三条常规路**全断**;且唯一能走的那条**判据写错会假报错**。
### 症状(本机实测)
| 途径 | 结果 |
|---|---|
| `gio trash <dir>` | ❌ `gio: command not found`(PortableGit 里 `gio`/`trash-put`/`trash` **一个都没有**) |
| PowerShell `[Microsoft.VisualBasic.FileIO.FileSystem]::DeleteDirectory(...,'SendToRecycleBin')` | ❌ **被安全策略拦**("Add-Type compiles and loads .NET code at runtime") |
| PowerShell `New-Object -ComObject Shell.Application`(`Namespace(10)`) | ❌ **被安全策略拦**("COM object instantiation can run arbitrary code") |
| **纯 `ctypes` 调 `SHFileOperationW`** | ✅ **可用**(不编译代码 / 不碰 .NET / 不弹 UAC) |
### ✅ 正解(可直接抄)
```python
import ctypes, os
from ctypes import wintypes
class SHFILEOPSTRUCTW(ctypes.Structure):
_fields_ = [
("hwnd", wintypes.HWND),
("wFunc", wintypes.UINT),
("pFrom", ctypes.c_void_p), # 🔴 必须 c_void_p,见坑 ①
("pTo", ctypes.c_void_p),
("fFlags", ctypes.c_uint16),
("fAnyOperationsAborted", wintypes.BOOL),
("hNameMappings", ctypes.c_void_p),
("lpszProgressTitle", ctypes.c_void_p),
]
FO_DELETE, FOF_ALLOWUNDO = 0x0003, 0x0040 # 🔴 有 ALLOWUNDO 才进回收站
FOF_SILENT, FOF_NOCONFIRMATION = 0x0004, 0x0010
FOF_NOERRORUI, FOF_NOCONFIRMMKDIR = 0x0400, 0x0200
abspath = str(p.resolve()).replace("/", "\\")
buf = ctypes.create_unicode_buffer(abspath, len(abspath) + 2) # 🔴 双 \0 结尾
op = SHFILEOPSTRUCTW()
op.wFunc = FO_DELETE
op.pFrom = ctypes.cast(buf, ctypes.c_void_p)
op.fFlags = (FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_SILENT
| FOF_NOERRORUI | FOF_NOCONFIRMMKDIR)
rc = ctypes.windll.shell32.SHFileOperationW(ctypes.byref(op))
# 🔴🔴 判据:不是 rc == 0!
ok = (not p.exists()) and (not op.fAnyOperationsAborted)
```
### 🔴 三个坑
1. **`pFrom` 必须手搓双 `\0` 结尾缓冲。** ctypes 的 `LPCWSTR` **会在第一个 `\0` 处截断**
⇒ 结构体字段必须声明成 `c_void_p`,用 `create_unicode_buffer(s, len(s)+2)` 再 `cast` 进去。
⛔ 写成 `wintypes.LPCWSTR` 并直接赋 `str` ⇒ 静默只删半截路径(或直接 `rc=2`)。
2. **判据⛔ 不是 `rc == 0`。** 实测**送回收站成功**(目录已消失、回收站 `$I` 元数据能解出原路径),
返回值却**恒为 `rc=2`**(`ERROR_FILE_NOT_FOUND`)—— 这是 Shell 内部探测残留的 `GetLastError`,
**不是真失败**。⇒ 拿 `rc` 当判据 ⇒ **假报错**(用户看到"失败",但东西已经进回收站了,以为没删掉会再点一次)。
✅ 真判据只有两条:**① 原路径不存在了 ② `fAnyOperationsAborted` 为假**。
3. **验证"是否真进了回收站"要解 `$I` 元数据。** 格式:`v2`、路径 **UTF-16LE**、起点 **offset 24**
(⛔ 不是 26;开头 4 字节是长度前缀,解出来会多一个乱字符,`.replace("\x00","").strip()` 后再比对)。
⛔ 别拿 `ls $RECYCLE.BIN` 的条数当证据 —— `$R*` 才是数据、`$I*` 是元数据,两两配对,容易数错。
### 本机取证(2026-10-06)
`_pmtest-{verify,trash,wsdel,wsdel2,http}` 五个测试目录全部送进 `E:/$RECYCLE.BIN/S-1-5-21-…-500/`,
共 **29+ 条 `$I` 元数据**,逐条解出的原路径与送入路径**一致** ⇒ **确认可恢复**。
> ⭐ **教训:判"操作成功没有",要挑那个"跟操作语义直接对应的观测量",⛔ 不是挑接口返回码。**
> 返回码是**实现细节**(这里还带 Shell 的脏状态),`exists` / `aborted` 才是**语义**。
---
## P0-89 🔴🔴 **CSS 逐字正确 ≠ 渲染正确** —— 「容器自己就是那个布局类」时内层再套一次 = 宽度被吃两层
**症状**(2026-10-06 项目管理界面,用户肉眼先发现):
四列看板整块挤在页面左边一条,每列只有 ~89 px、卡片 67 px,右侧一大片空白,页高 3192 px。
**第一诊断陷阱**:`grep` 样式表 ⇒ **CSS 全对**
(`.cols{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px}` 逐字正确)
⇒ 读者会以为"样式没问题,是不是内容/数据的问题"。**实际是结构问题,读样式表永远查不出来。**
**真因 —— 嵌套 grid**(自己上一轮改造时引入):
```html
<!-- 容器**本身**就带 class="cols" ⇒ 它已经是那个 4 列 grid -->
<div class="cols" id="cols"></div>
```
```javascript
// ✗ 错:又包了一层 .cols
cols.innerHTML = '<div class="cols">' + cols.map(renderCol).join("") + '</div>';
```
内层 div 成了**外层 grid 的一个 grid item** ⇒ 先被 `min-width:auto` 收缩到**内容宽**
(实测 399 px,=4×89+3×14),再在 399 px 里**自己又切 4 列** ⇒ 89 px/列。
**两层 grid 叠着吃宽度**,而两层各自看都是"对的"。
**✅ 正解**:容器是布局类时,**内层只铺子元素,⛔ 不再套同名类**。
```javascript
cols.innerHTML = colsData.map(function(c){ return _colHtml(c.key, c.label, …); }).join("");
```
**🔴 判据(同族通用)**:当 `#X` 的 `class` 里**已经含**你正要写进 `innerHTML` 的那个类名
⇒ 立刻停下,**这就是嵌套信号**。别写第二层。
**✅ 取数姿势 —— 量,不读**:
```javascript
var el = document.querySelector('#cols > .col');
var r = el.getBoundingClientRect();
// 再 getComputedStyle(el).gridTemplateColumns ⇐ 看实际生效的列宽
```
实测读数(改前→改后):`col 89px → ~400px`、`card 67px → 381px`、`页高 3192 → 1415px`。
⚠️ **探针选择器要跟着结构改**:去掉内层 `.cols` 之后 `#cols > .cols > .col` **恒为 0 条**
—— 那是**选择器过期**,⛔ 不是"布局又坏了"(差一步就误判成回归)。
---
## P0-90 🔴 布局「虚胖」的默认值:grid 的 `align-items:stretch` 会把空列拉到和高列一样高
**症状**(同日,用户:「卡片上下之间的距离太远」):
12 张卡片全在「已完成」列,另三列**空着**,但四列被拉成**等高** ⇒ 整页虚胖、空列白占 1100 px。
**真因**:grid 默认 `align-items:stretch` ⇒ 每个 grid item(=列)**拉满行高**,
而行高由最高那列决定。
**✅ 正解**:`.cols{align-items:start}` ⇒ 每列**各自撑自己的高度**,空列就是一个小盒子。
**连带压紧**:卡片 `gap` 9→6 px、`.cards` padding 10→8、列的 `min-height:160px`→0。
⚠️ **别用「把空列删掉」来治** —— 四列是**固定四态**(待执行/执行中/受阻/已完成),
空态本身是信息。✅ 治的是**高度**,⛔ 不是**列本身**。
> ⭐ **教训:`display:grid` 有一堆"看起来对、实际在拉橡皮"的默认值**(`stretch`/
> `min-width:auto`/`min-height:auto`)。凡是"某个方向被撑大或压扁"的怪相,
> 先怀疑这三条默认值,⛔ 别先怀疑自己的 `gap`/`width` 数值写错。
---
## P0-91 🔴 **"工作区" 与 "目标" 是两个维度** —— 计数、选择器、文案,混用必错
**症状**(同日,用户连问三句才问出来):
选择器写「全部(**3 个目标**)」,用户:「不对吧,本机不是只有一个目标吗」。
**追问两层**:① 计数用的是 `projects.length` = **工作区条数**(3);
② 用户真实口径是「**下拉框是选工作区的,跟目标没关系**」—— 连"选目标"这个定位都是我加的。
**事实**(同一份 `/api/projects`):
| 维度 | 数 | 说明 |
|---|---|---|
| **工作区**(装了 `.workbuddy/collab` 的目录) | **3** | `ai1net-dsh-server` / `vibe-product` / `agent-product` |
| **目标**(工作区里登记的那件事) | **2** | 前两个才有 `goal.json.title` |
⇒ 「3」既不是"3 个目标"也不是错的 —— 它只是**工作区数**,被贴了"目标"的标签。
**🔴 两条口径**:
1. **计数与文案必须标清维度**:说"工作区"时数目录,说"目标"时数 `has_goal` 为真的。
⛔ 别拿 `projects.length` 去填「N 个目标」(**这是"张冠李戴"型错误,看起来还挺自洽**)。
2. **没登记目标的工作区 ⛔ 不许藏**(用户原话:「加载机制就可以显示」)。
⚠️ 我一度把它判成"空壳"、还提议从选择器里去掉 —— **被否**。
实况:它**装了机制只是没登记目标**(有 `collabd-state.json` + `_ensure.stamp`,
**只是没有 `goal.json`**)⇒ 它是**合法状态**,不是垃圾。
⇒ 同族「作用域⛔ 不许静默排除」:**列不出来**比**列出一个"未登记"**危险得多。
**✅ 呈现**:选项文字=「目录名(目标名)」;没有目标就只印目录名
(⛔ 别印「(未命名 · xxx)」——用户原话「未命名 看不懂个是什么」;
改印 **「未登记目标 · <dir>」**,直接点出缺什么 + 怎么补)。
**⚠️ 命名优先级**:`name = short or title` ⇒ 登记过 `short` 的区在界面上**一律显示短名**
(`vibe-product` 这种目录名)。用户:「显示的是目录名,应该是真正名称」。
⇒ **`short` 是给文件名/路径用的短标识,⛔ 不是给人读的名字** ⇒ `title or short`。
> ⭐ **教训:凡是"张冠李戴"型错误(拿 A 维度的数去填 B 维度的标签),**
> **它不会报错、不会崩、看起来还挺自洽** —— 只有**用户对照现实**才看得出来。
> ⇒ 报数字时**连维度一起报**("工作区 N 个,其中已登记目标 M 个"),把两个数都摆出来。
---
## P0-92 🔴 报「疑似是我刚才改坏的」之前,**先 diff 备份** —— 别让怀疑变成向用户上报的事实
**经过**(同日):用户说「显示的是目录名,应该是真正名称」。我一看,`vibe-product` 的分支名
变成 `vibe-product` 了(此前显示的是目标短名 `本机协作` 那一套)⇒ **我立刻怀疑是自己刚才
执行「收工」操作时改坏的**,并准备这么向用户报告。
**✅ 一 diff 就清楚了**:`goal.json` 的 `title` / `short` 在**收工前后逐字一致**
(对比 `goal.json.bak-setlife-20261006-0926`)⇒ 是**老口径**(`short or title`),
⛔ 与收工操作**毫无关系**。
**为什么值得单列一条**:差一点我就把"**我猜的**"当"**事实**"报上去了。
在用户那边的效果是:「你自己改坏的吧」—— **一次未经核实的自责,比不说更糟**:
它会把排查方向**从真因上带偏**,而且**看起来还很坦诚**(所以不会被人拦下)。
**🔴 规矩**:
- 动过某个文件/某份状态 **之后**,任何"怎么变成这样了"的疑问 ⇒ **第一步 diff 备份**
(本轮正好有:动手前备了 `goal.json.bak-setlife-<时间戳>`)。
- ⛔ **⛔ 不许把"疑似副作用"直接写成结论**。措辞必须落到
「**我核过了/我没核**」二选一:核过 ⇒ 报事实;没核 ⇒ **先核再说**。
- ✅ 通则:**做变更前先留一份可比对的快照** —— 它的价值不在回滚,而在**事后能自证清白**。
## P0-93 「未发现」≠「不存在」—— 报缺失前先证明你的检测器能看见它
**2026-10-06 一轮里连撞三次,同一个病。**
### 现场
一天之内,我三次向用户/自己报"东西缺了",三次都是**假红**:
| # | 我报的 | 真相 | 我的判据错在哪 |
|---|---|---|---|
| 1 | 「`dsh-diagnose` 三个退役名**无独立详情档**,只在正文里被提到」 | 三档**都有**,且每档档头都写了 `本档覆盖:原技能 XXX 全文` + `原行段` + `逐行未改` | 检测判据=**文件名/目录名含退役名** ⇒ ⛔ 认不出「档头声明 provenance」这种落点 |
| 2 | 「`dsh-decision`/`dsh-workflow` 的清单表**没登记子目录档**」 | **都登记了**(`references/dsh-decision-method/` 那一行) | 正则只写 `references/xxx.md` ⇒ ⛔ 认不出目录行 `references/xxx/` |
| 3 | 「界面**四列全空**」(上一轮) | 界面好的,已完成列 **12 张卡** | 探针打了 `/api/board`(**404**)⇒ 拿到空 JSON 就当"数据是空的"。真端点=`/api/projects`,且卡片在 `projects[].cards`,⛔ 不在 `cols[].items` |
### 为什么这个病特别毒
1. **它长得像"发现了问题"** —— 报告读起来是"我查得很细,发现 N 处缺失",**比说"没发现问题"更像在工作**;
2. **下一步动作是破坏性的** —— 你会去"修"那个不存在的缺失:给已经有好档的地方再补一份(**制造重复**)、
或者按错判去删/改(**制造事故**);
3. **它自我强化** —— 一旦你按假红动手,改完"验证"还是红的(因为判据本身就错),
于是你会**继续加码修**,越修越乱。
### 根因(一句话)
**判据写窄了。** 我写检查脚本时,脑子里只有**一种**落点形态(文件名、`.md` 文件、某个端点),
而实际系统的落点形态**不止一种**(档头声明、目录、`projects[].cards`)。
⇒ **检测器的表达能力 < 系统的真实形态** ⇒ 看不见的就被报成"不存在"。
同族已知条:`dsh-local-env` 里记的「**判据写窄=假红**」、`dsh-diagnose` 的「**静默失败当默认假设**」。
### 规矩(动手前过一遍)
1. 🔴🔴 **报"缺失"之前,先给检测器做一次「正例测试」** ——
拿一个**你已知存在**的东西喂进去,看它**认不认**。
· 测试 1:拿 `dsh-diagnose/references/00-服务器实例故障.md`(**确定有 provenance 头**)
喂给"找独立档"的脚本 ⇒ 它报 ❌ ⇒ **判据就是坏的**,⛔ 不许把结果上抛。
· 测试 2:在界面上**肉眼**看到 12 张卡之后,才允许说"某处为空"。
**认不出的正例,比认得出的反例更有诊断价值。**
2. 🔴 **"我在 A 处没看到" ≠ "A 处没有"** —— 先问一句:**"它还可能在哪?"**
同一件事的落点常有多种形态:文件名 / 目录名 / **档头声明** / 表格某列 / 另一个 API 字段。
3. 🔴 **拿到"空结果"时,先证伪"我的取数姿势"** ——
空 JSON、404、0 条,**优先怀疑端点/路径/键名写错**,而不是"对方是空的"。
· 本轮实例:`curl /api/board` 返回空 ⇒ 正解是 **`grep 路由定义`** 找真端点,**⛔ 不是**下结论"没数据"。
4. ⚠️ **报"我没发现"时,把判据一并写出来** —— 让用户能一眼看出你的判据窄不窄。
⛔ 禁止只写「未发现缺失」这种**无法证伪**的表述。
5. ✅ **措辞三态**(不许含混):
· 「**我核了 X 处,判据是 Y,全部命中**」=有结论
· 「**我核了 X 处,判据是 Y,有 N 处没命中**」=有发现
· 「**我只核了 X,没核 Y**」=**边界声明**(⛔ 不许写成"没问题")
### 一句话
> **报"缺了"的举证责任,比报"有"更重** —— 因为你接下来要动的手,是按这个结论去改。
---
## P0-94 钩子超时:先算「几条 × 冷启动」,别去改脚本
**事故形状(2026-10-06 实测)**
`UserPromptSubmit` 一次报 4 条 hook `timed out after 10000ms`,看着像"这几个脚本卡住了"。
**先做的一件事:把每条单独计时。** 实测:
| 跑法 | 耗时 |
|---|---|
| 任一条单跑 | 0.23–0.36 s |
| 4 条各单跑(累计) | 1.247 s |
| 7 条**并发** | 0.84 s |
⇒ **单条都不慢,也没有并发争抢** ⇒ **不是脚本的问题**。
**真因**:同一事件上**串行挂了 7 条 hook**,每条都是**一个独立进程**
(`python.exe <hook>.py`)⇒ 宿主每轮**冷启 7 次解释器**。前 3 条预算 15+30+20 s,
后 4 条 `timeout=10` **被排队挤破**。
🔴 **判据**:`单条耗时 × 条数` 远小于超时值、但集群一起超时 ⇒ **问题在"条数 × 冷启动",
不在任何单个脚本**。⛔ 此时去优化某个脚本是白费(它本来只要 0.3 s)。
**✅ 正解:同一事件、同一 stdin 契约、fail-open、无副作用的守卫 ⇒ 合并成一个进程。**
- 本包实现:`scripts/hooks/prompt-guards.py`(一次读 stdin,`runpy` 本进程内依次跑,
临时接管 stdout 收 JSON 再合并)。实测 1.247 s → **0.357 s(3.5×)**,注入内容**逐字一致**。
- ⛔ **不许合并**有**副作用**的(常驻确保 / 结果投递 / 决策桥)—— 合并会改语义。
- 合并时**只能加信息、不能减判定**:`additionalContext` 拼接;
`decision`/`permissionDecision` 等冲突时**保留先到者并留痕**(⛔ 不静默丢阻止)。
**⚠️ 顺带两个坑**
1. 🔴 **别被报错里的路径带偏**:本次有一条写 `E:/ProgramData.workbuddy/...`(**少一个点**),
实际该目录**不存在** —— 它只是历史 trace 里的残影,⛔ 不是当前接线。
判据:**报错里的每个路径都先 `ls` 一下**,别直接顺着它去"修"。
2. 🔴 **合并入口收 stdout 的坑**:`_Tee` 继承 `TextIOBase` 时若写 `self.buffer = self`
⇒ guard 的 `sys.stdout.buffer.write(bytes)` **撞上 `TextIOBase.write`**
⇒ bytes 被 `str()` 成 `b'{"..."}'` ⇒ **JSON 解析必失败**(现象:报"输出了非 JSON(6228 字节)")。
✅ `buffer` 必须是**独立的 `io.BytesIO`**,文本/字节两路**各收各的**。
**⚠️ 另一个附带教训**:手工接的线(⛔ 不在 `install.py::OWN_BASENAMES` 里的)
`--uninstall` **认不出** ⇒ 换包/重装后**同一件事挂两条钩子**。
本次实测 `reply-style-guard.py` 就是漏网的 ⇒ 已补登记。
### 一句话
> **钩子超时,先算「几条 × 冷启动」,再决定动谁** ——
> 单条 0.3 s 却被判超时的,要治的是**条数**,不是那个脚本。
---
## P0-95 🔴🔴🔴 **「换了个看起来更对的调用」≠「判据变强了」—— 改判据后不重跑变异对照,会把假红换成恒绿**(★ 2026-10-06 当场被自己的变异对照抓住)
### 现象(本轮三连)
一天之内,`session-rules-check.py` 里查出**三个判据缺陷**,性质完全一样:**判错了**。
| 判据 | 错法 | 表象 |
|---|---|---|
| `hook_reg` | 按**旧文件名**找钩子 | 合并成 `prompt-guards.py` 后,**每轮报「关键钩子不在册」**(假红,惩罚的正是做过的合并) |
| `snap_sync` | 拿 **mtime** 当内容判据 | 权威 `CODEBUDDY.md` 被 touch ⇒ 报「快照比权威旧」;**实测 md5 逐字节相同、diff 0 行** ⇒ 连续 **4 天**假红 |
| `mem_ptr` | 只查**全局**技能根 | 工作区自带的技能(`product-planning`)被判「悬空」——判据问错了问题 |
### 🔴🔴 本轮最该记住的一幕:**我修 `snap_sync` 时差点交付一个恒绿判据**
- 第一版修法:把 mtime 比对**换成** `resident-rules.py --check` —— **看起来更对**(内容口径嘛)。
- 按老规矩跑**变异对照**:把快照文件**截断到前 400 字节** ⇒ 期望报 fail。
- 实测:**它报 `✅ 关键规则齐备`(rc=0)** ⇒ **判据恒绿**。
⚠️ 恒绿**比假红更坏**:假红至少还在喊,恒绿是**彻底哑掉**,且看起来更"先进"。
- 真因:`--check` 校验的是「**目标 `CODEBUDDY.md` 里关键规则齐不齐**」,
**根本不含「与快照比对」这一步** —— 我没读它的实现就假设了语义。
- ✅ 正解:**导入抽取器本体**(`importlib` 加载 `resident-rules.py`,把它的 `SNAP` 常量临时改指临时文件,
调**它自己的** `snapshot()` 拿"应有内容"),再与磁盘真快照比对。
⛔ 不抄第二份抽取逻辑(两处 ⇒ 漂移);⛔ 不让它写真快照(体检不改资产)。
- 修完四段复验:正常→ok|**变异→fail**|还原→ok|前提不成立(无 `CODEBUDDY.md`)→warn「本项不适用」。
### 🔴 同族第二个坑:**fail-open + 只看 `rc=0` 的体检 = 假绿温床**
`stop-dialog-guard.py` 因 `session_budget()` **返回元组长度不一致**
(两条早退 `return None, None`=2 值,末尾 `return a, b, c`=3 值,调用方按 3 值解包)
⇒ transcript **> 64 MiB**(本会话实测 **192.8 MB**)时**每轮 `ValueError`**。
- 而它 **fail-open**(异常仍 `sys.exit(0)`)⇒ **宿主零报错**;
- `install.py --verify` 只判 `rc=0` ⇒ **判它 "ok"**;
- 实际死的是**整条钩子**:水位与收口 / 接续机制起点 / 预算告警 / 门禁自检 / 路径自检。
⇒ **判「钩子活着」,⛔ 不看 `rc`**,要看**它自己的日志尾部**:
有没有 `EXCEPTION`、有没有写出**完整的 `invoked(...)` 行**(崩在半路的只会写 `entry`)。
### 判据(下次改判据前照做)
1. **改判据 ⇒ 必须重跑变异对照**:**真去破坏一次**(截断 / 改一字 / 删一节 / 造成前提不成立),
看它**会不会报**。⛔ **"换了个看起来更对的调用" 不算验证。**
2. **先读实现再信语义**:调别人的脚本当判据前,**读它的实现**(或至少读它到底比什么);
⛔ 别按函数名/文档一句话就假设它做了你想的那件事(本轮 `--check` 就是这么坑的)。
3. **同一函数的所有 `return` 必须同长**(长**短不一致**是"防护性早退"最常见的漏)。
4. **判据要问对问题**:`mem_ptr` 该问「**本机取不取得到这个名字**」(全局 ∪ 工作区),
⛔ 不是「全局库目录里有没有」。
5. **判据别拿"元数据"当"内容"**:mtime 只说明**谁最后被写过**,不说明**内容差没差**。
6. **前提不成立时给 `warn` 并写明「本项不适用」**,⛔ 不给 `fail`(那会把合法状态判成故障),
⛔ 也不给 `ok`(那是假绿)。
### 一句话
> **改判据的核心动作不是"换个更对的比法",是"再破坏一次看它会不会喊"。**
> 变异对照不做 ⇒ 你的"修复"很可能只是把**假红**换成了**恒绿**。