Files
workbuddy_skills/session-mechanism/references/pitfalls.md
T
admin 19101acd65 init: workbuddy_skills 重建,仅收录 session-mechanism
- 按用户指示清空原有 25 技能内容,只提交 session-mechanism(57 文件)
- 附 .gitignore(产物 + 本机凭据)
- 令牌明文已脱敏(历史 .neodata_token 与 pitfalls 引用均不入库)
- 本提交为孤儿提交(父提交为空),历史自此重新开始
2026-10-05 14:13:24 +08:00

2599 lines
225 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-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`):
① 不设 `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 当场订正过)。
---
## 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`(常驻本体,自带循环,每 10~30 s 一轮) | **`--supervise` 循环内建的检查会话排期**(`maybe_spawn_check_agent()` → `create_check_schedule()` → 直连 SQLite 写 `automations`) |
| 判活 | 心跳文件新不新 + `argv0` 指不指本区 | **日志里有没有 `检查会话:…` / `已建检查会话排期 …` 分支** |
| 载体 | `collabd-keepalive-<ws>` 计划任务 | **同一个进程**(⛔ 没有独立任务) |
⇒ 🔴 **检查程序跟着协作程序走** ⇒ 协作程序"活着"**不代表**检查程序在工作(本坑正是如此)。
### 二、🔴🔴🔴 症状:协作程序活得好好的,检查程序**悄悄地不干活了**
- **协作程序侧一切正常**:两区心跳新鲜、`argv0` 各指本区、进程恒 3 个。
- **检查程序侧(ai1net)**:`logs/_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()` 逐字:
```python
cfg = os.environ.get("CODEBUDDY_CONFIG_DIR") or ""
return Path(cfg) / "workbuddy.db" if cfg else Path.home() / ".workbuddy" / "workbuddy.db"
```
⇒ 环境变量**没设** ⇒ 落到 `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 没事**:vibe 的 config 里 `host_db` **写死了绝对路径**
`E:/ProgramData/.workbuddy/workbuddy.db`;ai1net 的 `host_db` **一直是空字符串**(历史备份也空)。
⇒ **同一份代码,两区行为不同**,差别只在 config 这一格。
- 🔴 **"计划任务的『动作』里没有 env 字段"**(P0-72 §二 同一条硬约束)⇒
`CODEBUDDY_CONFIG_DIR` **只能由启动器在进程内设**。
- 🔴 **我是怎么把它弄丢的(诚实记录)**:旧看守 `start-supervise.ps1` 里本有
`$env:CODEBUDDY_CONFIG_DIR = "E:\ProgramData\.workbuddy"`;
P0-72 改成"两层形态"(计划任务 → `pythonw` → 启动器)时,**只保了 `COLLABD_CONFIG`,丢了这一句**。
⇒ **重构"起法"时,旧起法里的 env 必须逐条搬过去**(⛔ 不许凭记忆只搬"我记得的那个")。
### 四、✅ 修法:新增常驻启动器(与 `board-launch.py` 对称)
- 新增 **`scripts/supervise-launch.py`**:进程内 **`os.environ.setdefault("CODEBUDDY_CONFIG_DIR", r"E:\ProgramData\.workbuddy")`**
+ 设 `COLLABD_CONFIG=<本区>/collabd.config.json` + 兜住 `sys.stdout/stderr`(pythonw 下可能是 `None`)
+ `sys.argv=[collabd.py, "--supervise"]` + `runpy.run_path(CB, run_name="__main__")`。
- 常驻任务动作改指 **`pythonw.exe "<区>/.workbuddy/collab/supervise-launch.py"`**
(⛔ **不再自带 `--supervise`** —— 参数由启动器给),`-WorkingDirectory` 仍是**工作区根**,
`-ExecutionTimeLimit` 恒 **`0`**(长驻服务)。
- `deploy_code.py` 的 `DEFAULT_FILES` **补入 `supervise-launch.py`**(否则各区吃不到,P0-57 同族)。
- ⚠️ **`roots.env` 的位置缺口(连带发现)**:`_sm_load_roots()` 从 `__file__` **向上**找 `roots.env`(`here/../..`×4);
工作区副本在 `<ws>/.workbuddy/collab/collabd.py` ⇒ 若工作区里没有 `roots.env` ⇒ **找不到**;
而 `roots.env` 只在**技能目录**(`E:/ProgramData/.workbuddy/skills/session-mechanism/roots.env`)。
⇒ **别指望靠 `roots.env` 兜住各区**,启动器里**写死**最稳。
### 五、✅ 验收(2026-10-05 12:15 实测,全部留痕)
**① 分界线干净得可当判据**(`grep -c` + `awk` 分界):
```
[2026-10-05 12:14:49] all_sessions_idle 读库失败 no such table: sessions ← 旧进程最后一次
[2026-10-05 12:15:02] supervise loop start pid=58860 ← 新形态进程起来
[2026-10-05 12:15:14] all_sessions_idle:还有 1 条 working(a80f300d)⇒ 不算全结束
```
⇒ **`pid=58860` 之后 `no such table: sessions` 计数 = 0**(`awk` 分界实测),
从 fail-safe 的"恒判有会话"变成**真实读库判定**。
**② 那条 `working(a80f300d)` 是真的**(`sqlite3` 直查活动库):
`('a80f300d-…', '复盘排期堆积与一次性排期问题', 'working', 'E:/ProgramData/AIProject/ai1net-dsh-server')`
⇒ 就是**当前这条会话本身** ⇒ 闸②「本区有会话在跑 ⇒ 不建检查会话」是**正确的合法拦截**
(⛔ 不是 bug。要观察 `检查会话:…` 分支,得等本区会话全结束)。
**③ 变异对照证明判据非恒绿**(`tmp/mutate_dbpath.py`,两档):
| 档 | 环境 | 落的库 | 结果 |
|---|---|---|---|
| A | **无** `CODEBUDDY_CONFIG_DIR` | `C:\…\.workbuddy\workbuddy.db`(0 B) | `FAIL no such table: sessions` |
| B | **有**(注入后) | `E:\ProgramData\.workbuddy\workbuddy.db`(34.9 MB) | `OK sessions=247 working=1` |
⇒ **A 必 FAIL、B 必 OK** ⇒ 路径来源**真实决定成败**(⛔ 不是"应该绿")。
**④ 运行时三件套(`Get-CimInstance` 实测,恒 3 进程、`ppid=3924`=调度器、父链断 ⇒ 真常驻)**:
| pid | 启动器 | 归属 |
|---|---|---|
| 58860 | `<ai1net>/.workbuddy/collab/supervise-launch.py` | 协作程序 ai1net |
| 61956 | `<vibe>/.workbuddy/collab/supervise-launch.py` | 协作程序 vibe |
| 63684 | `<技能>/scripts/board-launch.py` | 看板 |
**⑤ 启动器三处 md5 一致** `f416a2f1`(技能目录 + 两区副本);看板 `curl --noproxy '*' http://127.0.0.1:20099/` ⇒ **HTTP 200**(1.5 ms)、`netstat` 实测 `127.0.0.1:20099 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` 的名单不用动)。
4. `_escalate_to_keeper` 里**被删段**用 `~~删除线~~` 在 docstring 里标注(⛔ 别留下与实现不符的说明)。
### 四、✅ 验收(2026-10-05 12:42 实测)
- 跑一次 `_escalate_to_keeper()` ⇒ 回读任务动作:
```
EXE=E:\ProgramData\.workbuddy\binaries\python\versions\3.13.12\pythonw.exe
ARG="E:\ProgramData\AIProject\ai1net-dsh-server\.workbuddy\collab\supervise-launch.py"
WD=E:\ProgramData\AIProject\ai1net-dsh-server
```
⇒ **不再是 `powershell.exe`**(grep `New-ScheduledTaskAction -Execute 'powershell.exe'` ⇒ **0 命中**)。
- **零 PowerShell 看守残留**:`Get-CimInstance` 筛 `powershell.exe` 且命令行含 `start-superv` ⇒ **空**。
- **三常驻全部 `ppid=3924`(调度器)**,且**三个都是启动器形态**:
· `48372` `<ai1net>/…/supervise-launch.py`|· `61956` `<vibe>/…/supervise-launch.py`|· `63684` `<技能>/…/board-launch.py`
⇒ 父链断在调度器 ⟹ **真常驻**;形态统一 ⟹ **不会再长出第二套起法**。
### 五、⛔ 教训(一句话)
**"改了主路径" ≠ "把旁路也改了"** —— 上一轮把常驻主体收敛成"启动器形态",却漏了这个**自我供给旁路**;
它平时不吭声,**只在你最不希望的时候闪你一下**(还带着 `RestartCount 999`)。
⇒ **凡"同一种东西有第二条起法",收口时必须 `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 条的两扫。