- 变更规模:新增 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/ 知识文件,按口径入库)
1303 lines
127 KiB
Markdown
1303 lines
127 KiB
Markdown
# 踩坑清单(每条都真实发生过)
|
||
|
||
> ## 🔴 当前结论(**先读这里** · 最后更新 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 仍带 `🔴` ⇒ 假红)。
|