Files
dsh_ai1net_server/归档/技能包快照/session-mechanism-20261004/references/pitfalls.md
T
admin c1b5e4d966 chore(工作区): 全量入库 + 补齐 .gitignore(以工作区为准)
- 变更规模:新增 514 / 修改 62 / 重命名 155 / 删除 4(归档重组与文档轮次)
- .gitignore 修:`归档/**/db-cwd归一-备份-*/` —— 原规则写绝对层级(归档/db-cwd归一-…),
  目录搬进 归档/配置与备份/ 后**静默失效**,43 MB 的 DB 备份又变成未跟踪
- .gitignore 补:嵌套 git 内部数据(归档/内嵌git-20261008/、归档/skills-git-旧线-20261007/dotgit-原样移出/)
- .gitignore 补:运行态与部署副本(.workbuddy/collab/、.workbuddy/tools/、.workbuddy/.load-pending、.workbuddy/tmp-*)
- .gitignore 补:备份件(*.bak-*)
- 未跟踪文件从 2190 降到 890(其余为 归档/ 归档件与 .workbuddy/memory/ 知识文件,按口径入库)
2026-10-10 23:13:22 +08:00

1303 lines
127 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-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 ⛔ 子进程不加 `CREATE_NO_WINDOW` ⇒ 桌面**反复闪黑窗**(用户:"一会弹出来一会弹出来的,影响我操作电脑")
- **现象**:守护/执行检查跑起来后,桌面每隔 10~30 秒**闪一个控制台窗口**。
- **根因**:程序里有 `subprocess.run(["netstat", "-ano"], stdout=PIPE, …)`(**认网关端口**用)——
**`netstat` 是控制台程序**,不带 `creationflags=0x08000000` 就会**新建一个控制台窗口**;而该函数**每轮都被调**(执行检查 ~10 s/投递 ~30 s)⇒ 桌面反复闪。
- **修法**:✅ **所有** `subprocess` 调用一律带 `creationflags=0x08000000`(CREATE_NO_WINDOW)。
⛔ **只重定向 `stdout`/`stderr` 是没用的** —— 窗口照样会建。
- **验收**:桌面从"反复闪"变为"零窗口";**功能侧**用"能否照常认出网关端口"核对(日志/`wakeups.jsonl` 仍有记录)。
- 🔑 **推广**:任何**长期循环**里的外部命令调用,先过一遍"**它会不会开窗**"。
> 本档给**现象 / 根因 / 修法**。**架构层判据 ⇒ `references/architecture.md`**;原技能存档 ⇒ `references/collab-detail.md §6`。
## P1 ⛔ 用「会话后台任务」跑常驻 ⇒ 宿主"卡死"
- **现象**:用户说"卡住了/发消息不恢复";会话永不回 idle。
- **根因**:会话后台任务**每轮输出都会唤醒宿主会话**。
- **修法**:🔴 **首选=根本不要常驻** —— 由**宿主钩子按需唤起**一次性进程(见 **P0-3**)。
⛔ 旧建议"用 `run_in_background` 起常驻"是**错的**:那正是 **P0-2**(任务记在该会话名下 ⇒ 该会话卡输出)。
真需要常驻时,只能**会话之外**起(启动文件夹/独立窗口),并接受它**拿不到口令 ⇒ 只能落盘、不能投递**。
- ⚠️ **`cmd start` / `wmic process call create` / PowerShell `Start-Process` 常被安全策略拦** ⇒ ⛔ 别在"独立起进程"上耗时间。
## P2 ⛔ 自愈没有去抖 ⇒ 反复杀掉正在服务的好实例("自愈比故障更伤")
- **现象**:监控到的端口**反复通/断**;日志里"launching → 又被杀"循环。
- **根因**:启动器常有**幂等闸**("进程活着但端口不通 ⇒ 杀掉旧的再拉一份");若守护进程**每次探测失败就触发**,就变成**抖动**,把可能正在服务的好实例打死。
- **修法**:**连续 N 次(≥3)失败才动手** + **冷却(数百秒)** + 探测用**socket 连一下**(⛔ 别发真请求)。
## P3 ⛔ 粗粒度域锁 ⇒ 自造串行瓶颈,整轮白开
- **现象**:别的会话**抢锁失败 ⇒ 什么都不做就退出**(纯浪费的会话)。
- **根因**:把**整个工作区**声明成一个域 ⇒ 所有写操作串行。
- **修法**:**按节点涉及的文件/子目录声明域**;不重叠即可并行。**机制层才独占**。
## P4 ⛔ `fail-open` 掩盖字段名错误 ⇒ 视图静默为空
- **现象**:`rc=0`、日志无异常,但**某个区块一直是空的**。
- **根因**:异常被 `except: pass` 吞掉;查询写错列名(真实例:给 `sessions` 查了不存在的 `name` 列)。
- **修法**:① **必须核对输出非空**(⛔ 不能只看退出码)② 关键查询先 `pragma table_info(<表>)` 核对列名 ③ 定期 `diff` 视图一眼。
## P5 ⛔ 把"接续任务"当成果 ⇒ 被"空转链条"骗
- **现象**:看起来一直在推进("下一棒已派"),实际**总工期不动**。
- **根因**:**排期 ≠ 成果**。
- **修法**:**证据分级**(真成果/接续任务/刚开跑/哑火);"在跑的棒 N 个"是排期数,⛔ 不是成果数。
## P6 ⛔ 用"加自动化"回应一切需求 ⇒ 会话洪泛
- **现象**:一个需求挂上 7 个自动化,其中 5 个同用途。
- **修法**:**白名单+确认制**(只有"接续会话/派活"可免确认)+ **配额 ≤2**。建前自问:**非得开新会话吗?已有的能不能覆盖?**
## P7 ⚠️ 一次性自动化跑完 `status` 仍为 ACTIVE ⇒ 统计虚高
- **现象**:数"在挂的棒"= 6,实际只有 2 个真在等。
- **修法**:**一律用 `next_run_at > now` 过滤**;⛔ 不看 `status` 单独判断。清理僵尸旧件(已跑完的 once)。
## P8 ⛔ 诊断数据不落盘/不落库 ⇒ 只能靠"自述"
- **根因**:把"谁干完了、结论是什么"寄托在文件扫描/人报告上。
- **修法**:**读宿主已落的运行记录**(`automation_runs.thread_title` 等)⇒ **0 token、不轮询**。
- ⚠️ 但要**核对该列是否真有内容**(`IN_PROGRESS` 时标题可能为空 ⇒ 判"在跑"看 `status`)。
## P9 ⛔ 校验"可用"时只看"能跑起来"
- **现象**:结论写"八项判据起停各一遍全绿",但**端口此刻并不通**。
- **判据**:**"能跑起来" ≠ "可用"**。**可用 = 现在这一刻服务可达,且能被维持住**。
## P10 ⚠️ 长前台命令被沙箱杀,连带杀掉先前后台起的进程
- **现象**:命令无输出、`Exit -1`;随后发现后台进程也没了。
- **修法**:**单条前台命令控制在 ~90 秒内**;长活**拆短步**或**走异步**。
## P12 ⛔ 反复重启常驻程序 ⇒ **把主会话自己弄卡**(最容易被忽视的一条)
- **现象**:**每次进入"改常驻程序"的阶段,主会话就变卡、被反复打断**(用户原话:"为什么每次一改到这里就把自己的会话弄卡")。
- **根因(两条叠加 + 一条次因)**:
1. 🔴 用**会话后台任务**起常驻 ⇒ 该进程成为**本会话的附带物**;**每次 kill / launch 都产生一条 `failed` 任务通知** ⇒ **每次都打断会话**。实测:一个阶段里重启 **7~8 次** ⇒ **7~8 次打断**。
2. 🔴 **同一会话里做了 500+ 次工具调用** + 反复读写长文件 ⇒ 上下文极大 ⇒ 每轮推理显著变慢。
3. ⚠️ 注入钩子每轮往上下文加 ~1KB(应压在几百字符)。
- **修法**:
1. **攒批重启**:代码改动**攒到一次**再重启(≈3 处以上/或等功能自测全过)。⛔ **不要"改一行重启一次"**。
2. **优先热加载**:把**规则/配置/任务图/队列**做成**程序每轮读文件** ⇒ 改这些**根本不用重启**。
3. **长任务换会话**:一个会话**不要既"建系统"又"跑长验收"** ⇒ 到阈值就**交棒接续**(这正是本机制存在的意义)。
4. **注入瘦身**:`additionalContext` 压到几百字符。
5. 🔴 **改成"拉取模型"**(最根治):**程序只写队列/文件,⛔ 不调用会话、不通知、不起后台任务**;会话在"用户发话 / 棒收尾 / 需要时"三个时机**主动拉**队首 ⇒ **没有"推"就没有打断**。详见 `references/collab-detail.md` §1.3。
> 🔑 **一句话**:**"改代码 → 重启常驻 → 产生失败通知 → 打断自己"这个循环,是主会话变卡的机制性原因**,而不是外部故障。
> ⇒ **"拉"取代"推",是这个问题的根治解。**
- **现象**:`SyntaxError: unterminated string literal`。
- **根因**:在双引号字符串里写了 ASCII 双引号。
- **修法**:文案里的引号一律用 **`「」`**;改完 **`ast.parse` 校验**(比 `py_compile` 报错更清楚)。
## P13 ⛔ 常驻"活不长" ∧ 钩子指向无唤醒码的旧副本 ⇒ 机制**静默停摆**(最阴的一条:没人会发现)
- **现象**:唤醒回路代码写完、也实测到 1 次真投递,但此后**再也不投**。查下去:常驻进程**已消失**、单例端口 `Connection refused`、日志停在某一刻;而"唯一会自动跑"的钩子那条路,跑的是**另一份旧的精简副本**(没有队列闸门、没有唤醒码)⇒ 整个机械层**静默停摆**,且**没有任何告警**——因为告警也是那个程序发的。
- **根因**:① **从会话里起的常驻会随其宿主会话结束被回收**(实测:`13:09` 起的 pid,`13:12` 之后再无一轮);② **同目录另存过一份旧副本且钩子指向它** ⇒ "代码在 A、钩子在 B" ⇒ **接了线等于没接**;③ 认口取"`uptime` 最大"的口,而**那个口可能没有活会话** ⇒ 有活会话也照样落 `no-live-session` 跳过。
- **修法**:
1. 🔴 **"能自动跑起来"的路只有一条 = 钩子**(每个会话收尾跑一轮、零 token、不占会话)⇒ **把机制的关键环节挂在这条路上**,⛔ 别押在"常驻一直活着"上。
2. **钩子必须指向最新那份**(含队列/唤醒);**同目录⛔ 不要留旧副本**,或让旧副本显式**转调**新版(fail-open + 静默 + 超时,⛔ 不改自己原有行为)。
3. **认口=「第一个带活会话的口」**,⛔ 不是 `uptime` 最大者(实测两者常不同)—— 同 cwd 多实例时,这条正是**投得出去 / 投不出去**的分水岭。
4. **判"接线成立"看产物**:每轮覆写的视图/队列/状态文件 **mtime 是否跟着"会话收尾"推进** —— 比读代码可靠得多(本节即靠这一条定位的)。
## 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 仍带 `🔴` ⇒ 假红)。