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

225 KiB
Raw Blame History

踩坑清单(每条都真实发生过)

🔴 当前结论(先读这里 · 最后更新 2026-10-02 05:1x)

⚠️ 本文件通篇追加式 ⇒ 旧条可能已被取代(已就地标注)。冲突时以「本节 + architecture.md 的「当前结论」节」为准; 历史只留最近 5 轮、更早的归档(例外:教训类不受 5 轮限制 —— 见 agent-operating-rules §1.7a)。

最该先记住的几条(其余按 P 编号往下读)

编号 一句话 为什么它排前面
P0-71 🔴🔴🔴 常驻靠什么活着:不在「作业对象」(恒真、没鉴别力),在「谁拉起它」 —— 会话树里起的(父链穿到 WorkBuddy.exe)一收工就死;只有计划任务起的能活(父链断在自己身上)。✅ 定案形态=计划任务 → pythonw.exe → --supervise,每 5 分钟判活;⚠️ 两个致命细节:WorkingDirectory 必须是工作区根(⛔ 脚本目录 ⇒ 找不到配置 ⇒ 拒跑)、常驻不需要网关口令(⛔ 所以别用 --ensure)。🔴 看板 LastResult=1 是虚警(pythonw 无 stdout),判据看端口。🔴 三条硬约束(先查后建/各区独立/只有看板共用)+ deploy_code.py DEFAULT_FILES 必须含 collabctl.py 等入口脚本(⛔ 漏了 ⇒ 各区副本永不更新=P0-57 同族) 🔴 同一件事栽到第四次(用户:「从1号搞到5号 还起个程序都启动不起来」);错一次=常驻全死、兜底全失
P0-72 🔴🔴🔴 两个静默失败:① kill_all() 的 taskkill 带 HIDE(含 breakaway)⇒ 必被拒 ⇒ except 吞掉 ⇒ "已杀进程 0 个"却一个没死=假停(✅ 改 creationflags=0);② 看板任务直起 board.py ⇒ 无 COLLABD_CONFIG ⇒ 已拒跑 ⇒ 崩溃重启循环、端口从没绑上(✅ 新增 board-launch.py 启动器在进程内设 env)。🔴 长驻服务的 ExecutionTimeLimit 必须 0(⛔ 设 2 分钟 ⇒ 到点被掐)|🔴 list_procs 的 python* 会连调用方一起匹配到 ⇒ 必须算保护集 🔴 "做了动作" ≠ "动作生效":这两件事都不报错,只表现为"停止没停""看板没起" ⇒ 关键动作必须有动作后复核
P0-73 🔴🔴🔴 常驻启动器缺一个环境变量 ⇒ 检查程序静默失效:任务直起 collabd.py --supervise ⇒ 任务环境没有 CODEBUDDY_CONFIG_DIR ⇒ _wb_db() 落到 C:\\Users\\Administrator\\.workbuddy\\workbuddy.db(0 字节空库)⇒ all_sessions_idle 每轮报 no such table: sessions ⇒ fail-safe 恒判"有会话在跑" ⇒ 检查会话再也不建(外表完全安静、零报错)。✅ 新增 supervise-launch.py(进程内 setdefault("CODEBUDDY_CONFIG_DIR", ...))+任务动作改指它+DEFAULT_FILES 补入。🔴 "进程活着" ≠ "它在干活" ⇒ 检查程序必须看有没有产出预期分支(检查会话:…)|🔴 重构"起法"时旧起法的 env 要逐条搬(本坑就是丢了这一句)|🔴 各区 config 的 host_db 写死绝对路径最稳(vibe 一直这么写 ⇒ 只有 ai1net 炸) 🔴 同一件事栽到第五次(用户点名「检查协作程序和检查程序运行是否正常」才抓到);fail-safe 失败方向安静 ⇒ 凡 fail-safe 必须打可检索日志
P0-75 🔴🔴 远端技能总仓里躺着明文令牌(workbuddy_skills.git 的 .neodata_token,tk_ 明文 72 B,自首次入库 43b83b0 就在):首次入库 git add -A 整目录收录、.gitignore 只挡了产物 ⛔ 没挡凭据。✅ git rm --cached + 补忽略规则(031b312)|⚠️ 历史仍有该 blob,彻底清须 push --force 重写(牵连 25 技能)⇒ 等用户拍板;根治是服务端吊销令牌。🔴 入库前必扫凭据文件名 + 已知令牌串;核验远端必比差异(vs 备份)
P0-74 🔴🔴🔴 _escalate_to_keeper() 残留旧形态 ⇒ 计划任务里躺着 powershell.exe ⇒ 闪黑窗(用户原话「刚才又弹了窗口看看是什么」):自我供给旁路没跟着新形态一起改 ⇒ 任务动作还是 powershell.exe -WindowStyle Hidden -File start-supervise.ps1(PowerShell = 控制台程序 ⇒ 每次触发分配 conhost.exe ⇒ 闪一下;-AtLogOn + RestartCount 999 ⇒ 反复闪)。✅ 改指 pythonw.exe + supervise-launch.py,并删掉 start-supervise.ps1.tpl 前置门槛。🔴 "改了主路径" ≠ "把旁路也改了" ⇒ 收口时全文 grep New-ScheduledTaskAction 与 .ps1 🔴 它是钩子路径上的(UserPromptSubmit → --ensure → 失败 → 升级),平时不吭声、专挑你在用时闪
P0-24 🔴🔴🔴 注入物里写死命令 ⇒ 会话被逼着做用户没授权的事。缓存里存的是成品文案(含「⛔ 不要问用户」),逐轮复用 ⇒ 跟你当轮说了什么无关。硬规:缓存只存原始数据,文本按当轮授权现算;未授权时只通报、不派活 🔴 越权比超时严重:超时只是慢,越权是替你做决定;且表现像"机制很勤快",最不易被发现
P0-23 🔴🔴 钩子超载 ⇒ 用户每句话都被拦下(10-02 全工作区事故):宿主注册 20s,钩子里同步串了 --gap 13.5s。硬规:拿不到结果就没用的活 ⇒ 一律后台;⛔ "兜底同步"是伪需求(改了一版没改净就是因为留了它) 🔴 这是唯一能让整个工作区所有会话同时不能说话的一类故障 —— 优先级最高,无之一
P0-6 常驻进程 ⛔ 别做高频删文件(unlink/rename)⇒ 宿主 SafeDelete 护栏会直接杀掉进程(实测:跑 48m43s 后 failed;10-01 又复发一次,只活 8 分钟)。🔴 "降低频率"不是修法 —— 判据是「稳态下每轮删除次数 = 0」 唤醒时钟就是这么断的(10-01 已治本:锁文件永久存在、释放=改内容)
P0-5 「消息卡住」指纹 = parkInQueue + hasWaiter=false 🔴 AI 侧修不了,只能让客户端重挂该会话
P0-2 「卡消息输出」真因 = 会话日志撞 ~10 MiB 被 dropped(⛔ 不是"任务挂在会话名下") 曾误归因,白折腾一晚上
P0-5a 探针 ⛔ 不许"在日志里搜字符串" ⇒ 会命中你自己的取证回声 ⇒ 假阳性 认结构(记录行),⛔ 不认词
P0-19 「在不在执行」⛔ 别拿库里的 status 判 —— 它只有 working/completed/error/archived,没有"空闲"档,活着的会话恒 working ⇒ 投递永远等不到空闲(死结)。判据=宿主日志 [SessionRunStateMachine] 的 busy=(⛔ 不进数据库) 🔴 用户报「队列一直没有上报」的真因;换对判据后拦截原因立刻变成真状态 no-follow-session
P0-20 automation-request-refused ⛔ 别从错误名 refusal 推「内容审查」 —— 实测真因是排期绑的模型不支持关闭思考(deepseek-v4.1-flash + model_is_thinking=0 ⇒ 服务端 -32603);对照 hy4-preview + is_thinking=1 从未被拒,且当日 11 条失败会话的 details 逐字同因 ⇒ 🔴 非偶发、是系统性。✅ 治本=打开思考档(modelIsThinking=true,改完必须回读宿主库核对);🔴 2026-10-02 05:5x 已全量收口:未删除的 23 条排期全开,回读 is_thinking=0 = 0 条。🔴 判据:第一步就读那条会话自己的日志拿 details 🔴 我上一条正是错误归因(推成"措辞触发审查"并去改 prompt)⇒ 教训="看着有判据" ≠ "判据指向真因"
P0-13 判据必须能"改前报红":空样本上 every() 恒真(假绿)|写死期望值遇数据一换就恒红(假红淹掉真红) 🔴 同轮两条,都是"看着有判据、其实没有";⚠️ 同族:变异法跑完必须回读"变异体被哪几条用例跑了"(本轮我自己的对照脚本就是空的)
P0-17 常驻服务(看板等)跑的是启动时那份旧代码 ⇒ 改完看不见变化。判据=直读 /board.json 的 ts/条数/类别 对磁盘,⛔ 不看页面像不像 🔴 凡"改完没效果" ⇒ 先查进程启动时间 vs 改动时间(且重起必须 --takeover)
P0-38 「改看板要不要重启」有四类答案:改 board.html/board_ext.py ⛔ 不用(实时读盘/按签名热重载);改 board.py/collabd.config.json ✅ 必须 🔴 我把"改配置要重启"说成"改看板都要重启" ⇒ 分类错误;重启不是万能药
P0-16 告警每轮重写同一条 ⇒ 噪音;整体重写会静默抹掉人写的「## 解除条件」(人唯一的回信口) 🔴 程序"喊了"不算对 —— 喊的姿势(频率+覆盖)才是问题,"写成功了吗"这类断言查不出来
P0-22 🔴 「进程还活着」⛔ 不能从"日志/戳在动"推 —— 常驻 --supervise 历次只活 8/12/20 分钟,而日志照旧在走(那些轮次是宿主钩子的 --tick 写的)⇒ 四棒都被这条骗过。唯一机读判据=pid 活 ∧ 心跳新鲜(<90 s)(心跳 logs/supervise-heartbeat.json)。⚠️ 同轮还踩到:tasklist 输出是 GBK ⇒ text=True 抛 UnicodeDecodeError ⇒ 存活判据静默变假(改用内核句柄) 🔴 「看着在跑」≠「在跑」;修法=事件驱动的常驻自愈(--tick 顺手续命)
P0 所有 subprocess 必须带 CREATE_NO_WINDOW 否则桌面反复闪黑窗

P0-22 🔴🔴 「进程还活着」⛔ 不能从"日志/戳在动"推 —— 常驻也是这样被冤枉了四棒(★ 2026-10-02 实测 · S8 治本)

症状:collabd.py --supervise(唤醒时钟本体)反复在几分钟内消失(历次读数 8 / 12 / 20 分钟), 而 _collabd.log照旧每几十秒一行 ⇒ 前四棒都据此认为"常驻在跑",只有全量进程表才发现「早就没了」。

根因(本棒实测,⛔ 非推断):

  1. 🔴 日志的写者不止常驻 —— 宿主钩子(PreToolUse ^Bash$ + UserPromptSubmit)每轮都会跑 --tick, 它写的是同一个日志。⇒ 「日志在走」根本不能推出「常驻在跑」(本条的最大教训)。
  2. 🔴 本机不存在"能一直活着"的进程:① CREATE_BREAKAWAY_FROM_JOB 被宿主作业对象拒绝 (PermissionError(13,'拒绝访问。'))⇒ 脱不出回收;② 普通子进程能活过工具调用边界 (三探针跨调用打点 40 s+、父进程早已消失),但迟早被回收(载体会话结束/该轮结束)。 ⇒ 上一版把载体押在"容器会话别关"上,结构上就不可能兑现。

修法(collabd.py):

  • --supervise 每轮写心跳(<WS>/.workbuddy/collab/logs/supervise-heartbeat.json,原子替换/零删除, 承接 P0-6 判据)+ 节拍追加 …-heartbeat.log(超 512 KB 重写保留末 1500 行,⛔ 不 unlink); 启动时单例让位(判据同下)。
  • --tick 顺手续命(ensure_supervise():幂等 · 30 s 节流 · 无口令不起 · 无配置不起) ⇒ 常驻掉了,下一次事件自动补回来(=「跨 turn 存活」的兑现方式)。另有 --ensure 供显式调用。
  • 🔴 存活唯一判据:pid 活 ∧ 心跳新鲜(<90 s)(_pid_alive 走内核句柄)。

同轮第二个坑(同族:判据静默变假):_pid_alive 第一版用 tasklist /FI "PID eq N" + text=True, 而 tasklist 输出是本地代码页(本机 GBK,首字节 0xd0) ⇒ 在读取线程里抛 UnicodeDecodeError (还会冒成未捕获线程异常写进 supervise.out.log)⇒ 判据看着在、其实不可靠。 ✅ 改用 OpenProcess(QUERY_LIMITED_INFORMATION) + GetExitCodeProcess == 259(STILL_ACTIVE); ⚠️ 打不开且 ERROR_ACCESS_DENIED(5) ⇒ 保守判"在"(宁可不起第二条,也不双写)。 用例 selftest.py::t_supervise_ensure(8 项:四读数 + 心跳新鲜/陈旧 + 两条不起闸 + 接线与路径一致性)。

第三个坑(自己踩的):不给自测设闸时,自测的 --tick 会在测试工作区起一条真的常驻 ⇒ 它持续重写测试夹具 ⇒ 已停总闸 用例报「告警投影被清掉」假红、且 FAIL 数每轮不同。

第四个坑(P0-22 同族 · 2026-10-03 目标检查会话实测):手搓探针验「pid 活」时,WaitForSingleObject 会读出假阴性。 症状:心跳 round 每 10 s 正常上涨、ts_h 新鲜(<90 s),但自写探针里 OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION) + WaitForSingleObject(h, 0) 判出 DEAD ⇒ 「心跳新鲜 ∧ 进程已死」自相矛盾。 根因:WaitForSingleObject 在该调用形态下返回 -1(非 0 / 非 WAIT_TIMEOUT)⇒ 不是「已退出」的信号, 把它当布尔判就得到假阴性。✅ 正确口径=P0-22 技能已采用的那条: GetExitCodeProcess(h, byref(code)) + 判 code.value == 259 (STILL_ACTIVE);打不开且 ERROR_ACCESS_DENIED(5) ⇒ 保守判「在」。 ✅ 本次实测坐实:同一 pid GetExitCodeProcess=259(在)+ WaitForSingleObject=-1(误判死)+ Win32_Process 进程表独立佐证 CreationDate 12:03:13 在 ⇒ 以 259 口径为准,代码无须改。 🔴 通用教训:🔴 判据读到「自相矛盾」(心跳新鲜 ∧ 进程已死)时,⛔ 别急着判"机制真死" —— 先怀疑判据自己(探针写错 / 手搓口径 ≠ 代码口径),并找第二路独立读数(进程表 / 看板 /board.json 的 runtime.prog)交叉验。 本机即 Get-CimInstance Win32_Process(⚠️ 须走 PowerShell 工具,⛔ 从 Bash 调 powershell -Command 会被安全网关拦)+ curl --noproxy '*' http://127.0.0.1:8788/board.json 的 runtime.prog.{up,heartbeat_pid}。

✅ 修法=selftest.py::_mk_env() 显式设 COLLABD_NO_ENSURE=1(⛔ 不是把断言写松)。

P0-21 🔴🔴 改「概念/口径」时最容易漏的两处:把口径当实测废掉、只改正文不查指针目标(★ 2026-10-02 实测 · 用户两问点破)

缘起:用户先问「为什么唤醒会话 还是定时任务呢,不应该是一个会话靠自己的后台任务 定时唤醒吗」,再令「修复错误概念的时候 要排查相关引用 确保更新完全」。

① 「口径过时」与「实现没跟上」是相反的两件事,⛔ 别混

  • 口径过时 ⇒ 改口径;实现没跟上 ⇒ 口径照旧 + 记偏差。
  • 判据:改口径前必须先找到「用户原话 + 日期」;找不到原话 ⇒ 只能记偏差,⛔ 不许动口径。
  • 反面实例:见"定案说投递同时提供唤醒时钟"与"现状常驻停机、由排期代偿"不一致 ⇒ 误判成"定案过时",把两处定案口径就地作废 ⇒ 用户一句反问点破。正确做法=原文保留 + 加「实测偏差(⛔ 不是口径变更)」行。

② 查残留 ⛔ 不能只查正文 —— 指针目标(⇒ 全文/⇒ 细则/接续入口_*)必须一起查

  • 反面实例:状态层压缩版的 ⇒ 全文 指针,其目标文件里对应条目整条与定案相反(「不常驻」/「常驻走不通」/「两个拨钟方含自动任务」)⇒ 读者点进去读到的就是错的。压缩版改对了、指针目标没改 = 等于没改。
  • 做法:全量枚举 ⇒ 逐处只加标注/改写 ⇒ 复核。复核判定窗口=该行或其后 3 行内是否带标注(标注常写在下一行,只看单行会假性漏报)。

③ 顺带两条操作纪律

  • 「只加标注、⛔ 不删原字」—— 保留取证;要划掉用 ~~删除线~~。
  • 多份副本(技能侧模板 ⇄ 工作区实跑份)改完必须 md5sum 对表,且 diff 只该出现你这次改的那几处。

P0-20 🔴🔴 automation-request-refused:⛔ 别从错误名 refusal 推「内容审查」—— 真因常常是「模型不支持关闭思考」(★ 2026-10-02 实测定性 · 含同轮一次错误归因的完整订正)

🔴🔴 订正块(2026-10-02 05:4x)——文首那条真因,本条的措辞推断已被推翻

真因(逐字证据):会话日志 E:/ProgramData/.workbuddy/logs/<日期>/sdk/conversations/<sid>.log 里 prompt:dispatch-failed:terminalError 的 details 字段写着: "Current model does not support disabling thinking (modelId=deepseek-v4.1-flash)" ⇒ 模型能力与排期配置冲突:这两条排期是 model_id=deepseek-v4.1-flash + model_is_thinking=0(关闭思考) ⇒ 该模型不支持关思考 ⇒ 服务端回 -32603 ⇒ 被上层记成 failure_code=automation-request-refused/name:"refusal"。

对照组(同一台机、同一时段):WorkBuddy 日志定时清理 与 决策线体检 用 hy4-preview + is_thinking=1 ⇒ 从未被拒过(02:00 ✓、04:00 ✓)。⇒ 指向配置,不指向措辞。

"概率性"的真解释:宿主有 thought-level-fallback 分支(05:06 那次 requestId 逐字带 …:thought-level-fallback:attempt:1)⇒ 走不走这条分支决定了同一份 prompt 有时过、有时被拒 ⇒ 这正是"同一份文本 02:47 ✓/03:55 ✗/05:06 ✓/05:17 ✗"的来源,与文本内容无关。

✅ 治本:把两条周期排期的思考档打开 ⇒ automation_update 传 modelIsThinking=true (⚠️ 该字段不在工具的文档 schema 里,但 additionalProperties 接受它、实测生效: 回读库 model_is_thinking 由 0 → 1)。⛔ 改完必须回读宿主库核对,⛔ 别凭"我发过指令了"当已生效。

🔴🔴 这次的教训(比结论本身重要):我先前只看 runResult.error.name == "refusal" + usage 全 0, 就推断成「输入侧内容审查」,据此写了一整条"措辞红线"并去改 prompt —— 方向是错的。 判据:遇到 automation-request-refused,第一步就是打开那条会话自己的日志读 details, ⛔ 不许从错误名推断成因("refusal" 是上层对失败的错误分类,不是内容命中)。 ⚠️ 这也再次印证本文件的老话:"看着有判据"≠"判据指向真因"。

❓ 那"措辞红线"还要不要:要,但它是独立的一条(见下「附带纪律」), ⛔ 不要再把它挂在"被拒绝执行"这个因果上——那是我这轮的错误归因。


📏 量纲 + 收口(2026-10-02 05:5x 复核)

① 这不是偶发 —— 当日 logs/2026-10-02/sdk/conversations/*.log 里 11 条会话命中 dispatch-failed, details 逐字相同(全是 "does not support disabling thinking"): 00:33:50/00:36:58/00:44:33/01:47:23/01:50:24/02:55:41/02:57:51/05:09:37/05:10:58/05:30:48/05:36:35(本地) ⇒ 凡「flash + 关思考」的排期,每次触发必被拒(「概率性」的表象另见上面 thought-level-fallback)。

② 已全量收口:automations 表未删除的 23 条排期全部打开思考档 (改前 is_thinking=0 有 18 条)⇒ 回读 select count(*) … where deleted_at is null and model_is_thinking=0 = 0。 🔴 唯一可信的复核判据(该字段不在 automation_update 的返回里):

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 是毫秒级写小文件,且棒通常不会精确同时上报 ⇒ 实际概率低,但不是零。 ✅ 立刻可用的规避(⛔ 不改代码):上报后回读核对 ——

<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 运行 试试呢**
而 DB 里 sessions.title = 接续 · 机制线(钩子锚点真实投递取证)
🔴 不一致
③ 且长期停在旧值 05:51:37 / 05:53:04 / 05:54:16 / 05:57:51 / 06:02:15 ⇒ 五次推送全是同一个旧标题 不是瞬时抖动,是缓存陈旧

⇒ 结论(⛔ 严格划清"可证"与"未证" —— 别学 P0-2 的老毛病)

内容 状态
✅ 可证 ① 用户那条消息确实丢了(转录、PromptIterator 双无)⇒ 丢在「客户端 → 宿主」这一跳(服务端无任何记录)
② 宿主推给前端的元数据是旧的:推送 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。 探针只要"行里同时含这两个子串"就命中 ⇒ 命中了自己的回声。
  • 修法:判据必须只认真正的记录行,并显式排除回显行:
    def _is_park_line(ln):
        return ("parkInQueue" in ln and "hasWaiter=false" in ln
                and "[AcpView][PromptIterator] received prompt" in ln
                and "ProcessOutput" not in ln and "content=" not in ln)
    
  • 🔑 推广(比这条本身重要):任何"扫日志找关键字"的探针都有这个自污染风险 —— 只要有人(或你自己)为了排查而把那个关键字 打进日志一次,探针就会永久看见它。⇒ 一、认结构不认词(要求"记录行"的固定字段组合);二、排除回显容器 (ProcessOutput / content= / Sandbox);三、必须先拿一条真记录 + 一条回声行做对照用例(已固化进 selftest.py)。

P0-5b 🔴 本轮新增的两道自动护栏(collabd.py --tick)

护栏 做什么 判据(⛔ 不看 rc,看这个)
宿主卡住探针 probe_host_park() 每轮只读日志尾部 400 KB,找 park 指纹(最近 30 分钟内才算)⇒ 落 NEED-USER.md(含"切走再切回"),节流 10 分钟 打印 tick: … park=<n> 次 最近 <HH:MM:SS>
投递消费回查 check_delivery_consumed() 投递成功后不当作成功:4 分钟(CONSUME_GRACE)内目标会话 updated_at 没晚于投递时刻 ⇒ 判「没被消费」⇒ 落 NEED-USER.md 打印 tick: … consume=waiting/consumed/unconsumed

🔴 口径:「投出去」≠「它跑起来了」 —— 网关回 200 只证明对方收下,不证明有人执行。 ⚠️ 这两条护栏只能"发现 + 告诉用户点哪一下";根因(宿主客户端连接状态丢失)AI 侧修不了,⛔ 不许因此承诺"自动恢复"。

P0-2 🔴 「卡消息输出」的真因:会话日志撞上限被 dropped —— ⛔ 与"后台任务归属"无关(2026-09-30 用户反证后重写)

  • 现象:会话里发消息后窗口不显示内容 / 像是没反应;用户看到的是"卡消息输出"。⚠️ 我自己(主会话 fe146dd9)就是当事人。
  • 🔴 实证真因(实物在档,⛔ 不是推断):宿主的会话对话日志撞 ~10 MiB 上限后开始丢事件。 · 该会话日志尾部原文:diagnostic-log:dropped {"droppedLines":14261,"droppedBytes":1731465}(UTC 12:06:44 = 本地 20:06:44); · 其前最后一批正常记录是同一 requestId 的 tool_call_update 在毫秒级反复落盘(本地 16:29:53,completeAssistantStream:false); · 该文件 10,485,606 字节,本地 23:00 被 logcap sweep 挪为 *.log.stuck-20260929-230043;同日全机共 6 个这类满额文件。 ⇒ 高频工具事件 / 大量输出 ⇒ 日志膨胀过上限 ⇒ 宿主丢弃 ⇒ 对话不再显示。
  • ⛔ 已作废的旧结论(我 2026-09-29 写的,留着会误人):曾断言"任务记在会话名下 ⇒ 宿主认为该会话一直有长跑任务 ⇒ 拖住它", 而当时唯一的"证据"只是"起守护的任务 id 随停工变 completed"—— 那只说明任务结束,⛔ 不构成因果。 🔴 用户反证(2026-09-30):mcn-short-video 的 :8900 就是会话后台任务,一直挂着;用户在那个会话里照样随便发消息。
  • ✅ 正确口径:卡不卡看"输出/事件量",与"是不是后台任务"无关。 ⇒ 会话后台任务只要完全静默(stdout 重定向到文件、⛔ 不往标准输出打长跑日志)就可以安全长期运行。 ⇒ 会话内后台任务 vs 会话外起,在"会不会拖累会话"这一项上没有差别(旧表作废)。
  • 处置(日志已撞上限时):把 <sid>.log 改名挪开(⛔ 不删),宿主数十秒内重建并恢复写入 ⇒ 详见技能 session-mechanism(references/forensics.md §4)。
  • 🔴 ⚠️ 本条先后被我改错过两次:① 曾把"投递"写成"一条低频排期"(已由 P0-3 纠正);② 曾把"卡消息输出"归因到"后台任务归属"(本次纠正)。⇒ 写根因前先拿实物,⛔ 别拿"相关性 + 一个弱信号"当因果。
  • 📌 本条原先内嵌一份「P0/P1/P2 常见坑速查清单」(占本条 79% 篇幅,把一条撑成 11.6 KB) ⇒ 已拆成独立文件 references/99-速查清单.md(⛔ 内容逐字保留,只搬家)。

P0-23 🔴🔴 钩子超载 ⇒ 用户每一句话都被拦下(2026-10-02 全工作区级事故,三次出手才修干净)

  • 现象:UserPromptSubmit operation blocked by hook: …wb-result-hook.py: Hook timed out after 20000ms ⇒ 工作区所有会话无法执行(不是变慢,是提交失败)。
  • 根因(实测读数,⛔ 非推断):UserPromptSubmit 宿主注册 20s,而该钩子在这条路上同步串联了三个子进程 —— --gap 实测 13.5s | --once 硬超时 12s | --tick 实测 13.5s(hook.log 留痕:16:12:38 done in 12819ms); 同一事件还挂着另外 5 个钩子并发抢同一颗 CPU ⇒ 均值 13.5s 的活只要抖动一次就越线。 🔴 第二大致命点(同一雷的另一种形态):PreToolUse ^Bash$ 上同步跑口令投递 timeout=12 ⇒ 本机每条 Bash 命令执行前先等它。
  • 为什么"修了一版"没修好(15:31 那版):引入了 _BUDGET ���算 + _bg 后台机制方向是对的, 但为 --gap 保留了 _sync 兜底同步分支(判据:缓存为空 ∨ 过期 >2×TTL)⇒ 最坏路径依然存在。 ⇒ 🔴 教训:在秒级预算的钩子里,"兜底同步"是伪需求。注入物是建议不是判据 —— 本轮拿不到就少注入一段(下一轮自然补上),⛔ 绝不为此等待。这是 fail-open 的正确姿势。
  • 修法(三条硬规则 · 此后新增钩子一律照此办):
    1. 🔴 拿到结果才有用 ⇒ 才允许同步等;否则一律后台(_bg:Popen + stdout 落文件 + DETACHED_PROCESS,⛔ 不用 PIPE、⛔ 不 wait)。 判断句:「这个子过程的返回值,钩子会用吗?」答不上来 ⇒ 后台。
    2. 🔴 算术题先算再写:宿主注册值 - 解释器冷启动(实测 0.3~1.0s,波动不可控) - Σ子过程实测耗时 > 50% ⇒ ⛔ 不许同步。 本机为证:20 − 1 − 13.5 = 5.5s 余量,而同事件另 5 个钩子在抢 CPU ⇒ 必越线。
    3. 🔴 ⛔ 别信"实测均值",要信"硬超时":--once 均值 0.5s 看着安全,但它的 timeout 是 12s ⇒ 抖动时照样吃掉大半预算。
    4. 🔴🔴 脚本自己必须带"自身硬闸"(本次没做这一步就是没根治):前三式只修掉了这一次的那三个具体活; 任何人(含之后的另一个会话)只要再往这条路上加一个同步子进程,故障就原样复发, 而复发的第一现场是「用户说不了话」——不可能等到事后排查。 ⇒ 落地:set_budget() 里起 threading.Timer(daemon=True) 守 预算-0.5s,到点 log() 后 os._exit(0)。 ⚠️ 必须 daemon=True(否则解释器退出时被卡住 ⇒ 换一种形式超时);必须用 os._exit(主线程卡在 subprocess.run 时正常退出路径走不到)。 fail-open 方向:宁可本轮少注入一段(=什么都没发生),也绝不让会话被拦下。
  • 验证(⛔ 必须出厂实测,⛔ 不用"看代码觉得快了"代替):tmp/_verify_hook_timing.py(含"缓存已被删"的最坏路径) + tmp/_probe_all_hooks.py(同事件上每一个钩子全量计时,⛔ 别只测报错点名的那一个) + tmp/_health_after_hookfix.py(止血 25 分钟后复检:常驻活着吗/后台堆积吗/耗时稳态吗/功能还在吗) + tmp/_verify_hard_gate.py(硬闸实测:注入一个 30s 同步慢活的副本 ⇒ 17.85s 自行退出,距 20s 仍有 2.2s 余量)。 修后:最坏 1.00s / 常规 0.35s(25 分钟后复检仍 1~27ms 稳态)/ 全 8 个钩子 0.23~0.65s 全绿。
  • 🔴 配套发现(影响面,未处置、已上报):wb-result-hook.py 装在 ~/.workbuddy/settings.json(全局) ⇒ 跑在所有工作区的全部会话上, 而该文件自己的 docstring 明写「⛔ 不要装到全局」。这就是「所有会话都中招」的放大器。 止血后(0.35s)全局已无害 ⇒ 未挪动注册位置(避免影响面变更);若要收回成本工作区,改一处即可(⛔ 属影响面变更,需用户点头)。

P0-24 🔴🔴 排期「哑火」判据:补跑 ≠ 哑火,且**「延迟」⛔ 不能拿「距今」冒充**(★ 2026-10-02 实测 · S10 取证 + S11 修)

  • 病灶(不是"容差不够",是"判据把两件事混成一类"):本机一次性排期不到点即时触发,由主机轮询/唤醒补跑(automation_runs.metadata_json.runKind=missed)⇒ 实测延迟 0~11 分钟。 原 collabd.py health() 只写「到点未触发」一个词 ⇒ 补跑被一律判成哑火(717cd210 计划 13:42、实跑 13:42:15,真延迟 0 分钟却被报哑火)。
  • ✅ 判据(三条缺一不可):① 计划时刻已过 ≥ 容差(misfire_grace_min,默认 30 覆盖实测最坏 11 分钟)② automation_runs 里零记录 ③ 才判哑火;有记录 ⇒ 写「延迟 N 分钟跑完」并单列一段(⛔ 别塞进 issues,否则下一个人又照着当故障查一遍)。
  • 🔴🔴 「延迟」必须 = 首次运行时刻 − 计划时刻(min(automation_runs.updated_at) − scheduled_at)—— ⛔ 不可用「距今」代替。首版就犯了这个错:把 30 小时前跑完的排期写成「延迟 1954 分钟」(真值 7 分钟)⇒ 修判据时制造了新的假数据。 ⇒ 为此 d["have"] 必须从 set 升级成 {aid: 首次运行时刻};时刻读不出就写「不猜」,⛔ 不填 0。
  • ⚠️ scheduled_at 实测只到分钟(2026-10-02T14:26,无秒)⇒ 解析要 %Y-%m-%dT%H:%M 与 %Y-%m-%dT%H:%M:%S 两试,只写一种会整批 age=-1(我第一版就吃了这个,候选数一度算成 0)。
  • 堆积是另一件事,⛔ 别和判据混:once 排期跑完不回收(status 仍 ACTIVE、next_run_at=None,含义是「没有下一次」不是「未调度」)⇒ 实测 30 条已跑完仍挂在册。 清理判据 = schedule_type=once ∧ next_run_at 为空 ∧ 有 run 记录 ∧ 距计划 >30min。 ⚠️ automation_update 一次只能删一条 ⇒ 批量清理(29 条)⛔ 别塞进无人值守棒(不可逆 + 含主控线)⇒ 出清单、留一棒专做。
  • 验证:tmp/_verify_misfire_fix.py 六档(延迟补跑/真哑火/未到点/容差内/busy 跳过/时刻读不出)+ 真实库 --once 后读 tmp/supervise-inbox/体检报告.md ⇒ 修后 issues 由「满屏假红」收敛为 2 条真哑火(87d55715、5e335e01),29 条转 notes 且真实延迟 0~11 分钟。
  • 🔴 同源教训:改判据的同一轮里,我先按"读错了列"下了结论(r[0] 其实是 id 不是 rowid)并据此改了 SQL —— 回读原文件才发现前提不成立。 ⇒ 先回读被改的那段,再落结论;⛔ 别让"看起来讲得通的诊断"替代核对。

P0-24 🔴🔴🔴 注入物里写死命令 ⇒ 会话被逼着做用户没授权的事(比超时更隐蔽,且伤信任)

  • 现象(17:49 用户转述另一个会话的实况):用户问「我没有说使用协作会话完成目标,为什么会创建会话」。 那个会话答得对:它是照着每轮自动注入的指令做的 —— 注入里写死「⛔ 不要问用户、立即建排期」, 它照做并已删掉。根在钩子,不在那个会话。
  • 真凶(⛔ 不是措辞不当,是结构缺陷):_gap_text() 生成的文案里含「⛔ 不要问用户」这种祈使句, 而这段成品文案被原样存进 gap-cache.json 的 _text 字段逐轮复用 ⇒ 无论用户当轮说了什么,每轮都在下同一条命令。三个后果: ① 越权(用户没要求就建排期)② 重复(同名无去重,越排越多)③ 无上限/无过期(用户原话「越排越多」)。
  • 修法(三条,⛔ 改文案治不了,必须动结构):
    1. 🔴 授权闸:_authorized_now() —— 只认用户当轮原话里的触发词(GAP_TRIGGERS)。 ⚠️ 刻意不用「上一轮说过」「队列有缺口」「机制建议」当授权 —— 那些都是系统自己的诉求,不是用户的。 ⚠️ 必须把当轮 payload 存进模块级 _CUR_PAYLOAD,否则判据读到空串、闸门形同虚设。
    2. 🔴 缓存存原始数据、不存成品文案:gap-cache.json 改存 _raw(--gap 原始 JSON), 文本在读取时现算(_gap_text(raw, authorized))⇒ 呈现永远跟当轮授权一致。 旧版缓存(只有 _text 无 _raw)判为无效自动重刷,⛔ 不做兼容迁移(迁移=把旧文案再投一次)。
    3. 🔴 未授权时只通报、不派活:标题写「ℹ️ 现状通报(⛔ 不构成行动指令)」并明写「⛔ 不要建任何排期」; 授权时才输出「⚠️ 缺会话 ⇒ 自动拉起」,且要求在回复里说清是依据本轮授权做的。 另加 _known_plan_names() 读 automations 判同名在册 ⇒ 提示里标「⛔ 别再排一遍」。
  • 判据(⛔ 别靠"文案读起来像没问题"验收):tmp/_verify_auth_gate.py 跑三个场景 —— A 未提协作 ⇒ 必须出现「不要建任何排期」且不得出现「自动拉起」;B 明确授权 ⇒ 才出现派活指令; C 回灌旧版缓存 ⇒ 旧文案不得再注入(专门防"改了代码但旧缓存还在投")。修后 A/B/C 全 ✅,耗时 0.37~1.10s。
  • 🔴 顺带查出的老毛病:排期越删越多 —— automations 在册 140 条、ACTIVE 135 条, 且两条 17:41/17:48 的越权排期仍为 ACTIVE(那个会话以为删了,其实没删成功); 17:48 那条已被触发 ⇒ 删排期拦不住已开的会话(这是它自己提示的那点,已核实为真)。 ⇒ 清扫脚本 tmp/_purge_unauthorized_schedules.py(先 SELECT 再 UPDATE,busy_timeout=15000): 停用越权族 4 条 + 回收僵尸排期(ACTIVE 且 next_run_at IS NULL 且 scheduled_at < 当天)89 条 ⇒ ACTIVE 135 → 42,越权残留 0。
  • 🔴 越权比超时严重:超时只是慢/被拦,越权是替你做决定 ⇒ 消耗信任,且不易被发现 (表现像"机制很勤快",甚至像在帮忙)。

P0-26 🔴🔴 automation_update 的 delete/list按「属主」过滤 ⇒ 越界时「报成功、实则零动作」(★ 2026-10-02 实测 · S12 清理一次性排期)

  • 现象:S12 要清29~30 条「跑完却仍在册」的一次性排期。逐条 automation_update(mode=delete) 删我这条属主名下的 6 条 ⇒ 真删掉了(回读 deleted_at 有值);但对其余条同样返回 success:true + "already deleted or does not exist", 回读 deleted_at 恒为 NULL ⇒ 一条都没删。若只看返回值,会误判"全清完了"。
  • 真凶= automations.owner_user_id(属主边界):本机并存两个属主账号。实测 list 只回当前属主名下的排期 (本例 2 条),而库里未删共 30 条;delete 也只能删当前属主的。跨属主 ⇒ 工具看不见、也删不掉,却仍回 success:true。
  • ✅ 两条硬纪律:
    1. ⛔ 绝不把 delete 的 success:true 当已删 —— 每次删完必须回读 deleted_at(coalesce(deleted_at,'')='' 才算未删)核对。
    2. ⛔ list 的返回行数 ⛔ 不能当在册总数(本例 list 2 条 vs 真实 30 条)—— 判断"还有没有"要用只读 SQL 扫 automations。 🔴 顺带:list 还可能只回最近若干条(本例早期返回 8 条)⇒ 别把"list 变短了"当"删掉了"。
  • ⚠️ 盘点前先分桶,别按 prompt 里的数字照抄:本棒prompt 说「清单里有 12 条属主控线(主控 · 开头)」, 实测按前缀只有 4 条 ⇒ 数字必须自己只读盘点复核(否则会误删/误保)。
  • 🔴 同类静默假成功(同源教训):本棒 --report 被当「刷新体检报告」用,其实是「上报需求状态(pending|running|done|blocked)」子命令 ⇒ 命令名 ≠ 用途,⛔ 用前先看 usage。
  • ⚠️ 验收门也会挡住刷新:collabd.py 的体检报告受两道门控制 —— ① health() 开头 skipped=V["busy"](自动化会话在跑 ⇒ 恒 skipped)+ ② health_every(默认 300s)节流戳 (<WS>/.workbuddy/collab/collabd-state.json 的 health_at;置 0 可强刷,但 busy 门仍拦) ⇒ 报告不刷新时,改按同一判据独立复算读数,⛔ 别把「报告还是旧的」当「删除没生效」。
  • 遗留:S12 因此标 blocked-by-owner(⛔ 不标 done)—— 余 24 条候选(非主控 20 + 主控 4)须在另一属主账号下执行。

P0-27 🔴🔴 域目录摆错位置 ⇒ 所有独立域压成同一个键,域锁彻底失效(★ 2026-10-02 实测 · 执行会话独立域)

  • 背景:用户定案「执行会话必须独立域运行 —— 所有写操作都在单独文件夹下(webserver / desktop / phone app /独立插件 / 产品原型各自独立目录,放在工作区对应独立文件夹下),跨目录只能只读」, 理由逐字:「开多个会话都操作一个域的文件只有一个会话能执行,别的只能干等」。

  • 病根(实测,非推断):域键=「路径里第一个出现在锚点词表里的段 + 它的下一段」, 而工作区目录名本身就在锚点词表里(handoff-guard.sh:66 _ANCHOR_SEGS,ai1net-dsh-server 排最前) ⇒ 域键恒=<工作区名>/<工作区下的第一层目录>,与再往下钻几层无关。四条实测读数:

    路径 域键 结论
    …/ai1net-dsh-server/domains/webserver ai1net-dsh-server/domains ❌ 全部撞键
    …/ai1net-dsh-server/domains/desktop ai1net-dsh-server/domains ❌ 同上
    …/ai1net-dsh-server/webserver ai1net-dsh-server/webserver ✅ 独立
    …/ai1net-dsh-server/交付物/webserver ai1net-dsh-server/交付物 ❌ 同样撞键
  • 🔴 我第一版就踩了这个坑:把域目录设计成 domains/<域名>(一个整齐的公共父目录)⇒ 看着最合理、实际把所有域压成一个键 ⇒ 等于把用户方案判死。是验收脚本当场抓到的 (断言「两个不同域算出同一键」)。⇒ 教训:域键类判据 ⛔ 必须写"算出来是什么"的断言, ⛔ 不能只验"函数返回了非空" —— 前者抓到真缺陷,后者一路假绿。

  • ✅ 正解:域目录直接占工作区第一层(<WS>/webserver/、<WS>/desktop/)—— 恰好就是用户原话 「放在工作区对应独立文件夹下」。collabd.py 里DS_DOMAIN_SUBDIR = ""(刻意留空,⛔ 别改成 "domains")。

  • 🔴 推论(同样重要):想在同一项目里并行两个会话,⛔ 在域目录里再分层没用(还是同域)—— 必须给它们两个不同的第一层目录。

  • ✅ 配套已落地:ds_domain_key() ⛔ 必须与 handoff-guard.sh 的 norm_domain() 逐字一致 (验收时真调 shell侧那条函数,⛔ 别在Python 里重写一遍自验自己);派活前check_domain_free() 读锁表判占用,被占 ⇒ 换域名(⛔ 不是等),且必须排除自己的锁(否则锁主本人被自己挡住); 锁表读不到 ⇒ fail-safe 判全部不可派(这正是用户那句「别的只能干等」的机制面)。

  • ⚠️ 顺带查出的既有不一致:handoff-guard.sh 用 scripts skills, lock-guard-hook.py:58 用 07-scripts 08-skills 05-交接单。 ⇒ 工作区下的路径两边一致(都先命中工作区名)⇒ 不影响工作区内的判据; 但代码仓根目录下的路径两边会算出不同键 ⇒ 已存在假绿风险。collabd.py --domain-status 会报这个差异。

P0-28 ⚠️ CLI 分支缺 return ⇒ 一次性命令落进常驻模式、永久挂住(★ 2026-10-02 实测)

  • 现象:collabd.py --where 打印完路径后不退出,进程永久挂住(timeout 25 ⇒ rc=124)。
  • 病根:if "--where" in sys.argv: 分支打印完漏了 return ⇒ 继续往下走 ⇒ 命中"无参数=常驻" 的兜底分支 ⇒ --supervise 式循环跑起来。
  • 🔴 怎么确认是既有缺陷而不是自己改坏的:拿改动前的备份跑同一条命令, 同样 rc=124 ⇒ 结论坐实(本条正是靠这个对照法才没误判成回归)。
  • 🔴 通则:任何 --xxx 这种一次性查询分支,打印完必须 return; 否则它会静默变成长跑进程 —— 症状是"命令没输出完/卡住",很容易误判成环境问题。

P0-29 🔴 验钩子必被「300 秒节流」吃掉 ⇒ 看起来像"代码没生效"(★ 2026-10-02 实测 · 同族第 2 次)

  • 现象:验收脚本三个场景连跑,场景②注入完全为空。第一反应="判据写错了 / 代码没生效", 手工复现那一段逻辑却全对(read_env_stamp 确实返回 {}、分支确实会进)。
  • 真凶:skill-load-guard.py 的 _cooling(workdir, sid) —— 同 session_id 300 秒内只放行一次。 三个场景用同一个 sid ⇒ 后两个被静默挡在早退分支 ⇒ ctx 恒空。
  • 🔴 危害不在这一个钩子:「没输出」与「没检查」长得一模一样 ⇒ 一旦顺着"钩子坏了"往下查, 会把时间全花在读代码上,而真因是自己造的自检环境。本轮就是这样绕了一圈。
  • ✅ 两条硬纪律:
    1. 验任何带节流/去重的钩子,每次调用必须换一个唯一标识(本例=session_id)。
    2. 验收脚本里加一道硬门:注入为空 ⇒ 立即判失败(本轮做成 need_inject()), ⛔ 绝不许把"没输出"当成"没打扰 / 一切正常"静默通过。
  • ⚠️ 同族第 1 次:验钩子前先看触发词表(prompt 不含触发词 ⇒ 根本不进注入分支)。 ⇒ 合起来一条通则:钩子的任何"没动静",先怀疑自检环境(触发词/节流/作用域/cwd 形态), 再怀疑代码。

P0-30 🔴 「pid 文件写着」≠「进程活着」;查显窗/作用域只能看 AST,看 grep 与注释都会被骗(★ 2026-10-02 实测)

用户问「一闪一闪的程序是不是协作会话开的」,顺手挖出三条同源的误判路径:

  • ① 陈旧 pid 文件:交付结论说「当前存活的 supervise(pid 40900)心跳新鲜(已稳跑 10 分钟)」, 实测 tasklist //FI "PID eq 40900" ⇒ 「没有运行的任务匹配指定标准」。 而 supervise.pid 至今仍写着 40900、_supervise.log 停在 4h43m 前 ⇒ 常驻早就死了。 ✅ 判存活必须双查:tasklist + 心跳戳新鲜度(stat -c %Y 比date +%s), ⛔ 绝不只读 .pid 文件(它只记录"上次认为在跑",⛔ 不代表现在)。
  • ② 「常驻」与「后台任务」是两回事:现象看着像"有个程序在常驻闪",实际常驻 17:12 就死了, 21:2x 的动静是钩子每轮用户提交拉起的一次性 --tick/--once(跑 1~2 秒就退)。 ⇒ 「一闪一闪」的频率=你说话的次数,不是某个常驻进程的轮次 —— 判据完全不同。 🔴 故:排期/自愈判据里「pid 活 ∧ 心跳新鲜」两条都要,缺一条就是这种假活。
  • ③ 🔴🔴 grep 单行判不了跨行参数:grep -v creationflags 把 board.py:1177、 board_ext.py:555 这两处已经带 flags 的调用误报成"漏网"(参数写在下一行)—— 我第一遍就被骗了。✅ 必须用 AST:遍历 subprocess.run 节点看有没有 creationflags 关键字。 ⚠️ 同族:查"是不是限死在本工作区"也不能看注释(四处 ai1net-dsh-server 里三处只是文档字符串)。 ⇒ 通则:判"某能力有没有生效/被限死" ⇒ 一律看可执行代码(AST/真跑),⛔ 不看注释、不看单行 grep。

P0-31 ⚠️ 闪窗(控制台程序弹黑窗)在 Windows 上的完整修法清单(★ 2026-10-02 实测根治)

  • 两类药,缺一不可:
    1. 常驻/会重生的 daemon ⇒ 用 pythonw.exe(GUI 子系统,Windows 永不为其分配控制台)。 sys.executable ⇒ python.exe(控制台子系统)⇒ 被 detach 后必新开控制台 ⇒ 每次重生闪一次。 ⚠️ DETACHED_PROCESS 单独用不够:它只是"不继承父控制台",控制台 exe 仍会被新分配窗口。
    2. 一次性短 spawn(netstat/taskkill/--tick)⇒ 补 creationflags=CREATE_NO_WINDOW(0x08000000)。 ⚠️ 常驻那批可加 | DETACHED_PROCESS(0x8) | NEW_PROCESS_GROUP(0x200); ⛔ 不要 CREATE_BREAKAWAY_FROM_JOB(会被拒)。
  • 🔴 自检必须用 AST 全量扫(⛔ 别 grep):本轮靠它捞出 board.py:1212 的 taskkill—— 那是七文件修完后最后一处漏网(--takeover 路径上的)。
  • ⚠️ 不是所有缺 flags 都该死:NeoData金融搜索服务/scripts/query.py:51 是 pip 装包 (只在缺 requests 时跑一次、非周期)⇒ 与闪窗无关;selftest.py 7 处是自检脚本。 ⇒ 判据="这个调用会不会被周期/重生路径反复执行",⛔ 不是"缺了就是错"。 ⚠️ 第三方技能包缺 flags ⇒ 只报不动(⛔ 越界改别人的东西)。

P0-32 🔴🔴 把「起法不对」误推成「本机不可能有长跑进程」⇒ 建议整个建立在假前提上(★ 2026-10-02 实测,同族最贵的一次)

现象:起常驻看板失败三次(start /b/start "标题" /D/cmd /c start.bat)⇒ 我下了结论 「本机不存在跨静默期存活的进程」,并据此建议「改用排期当时钟」。

用户两次纠正(逐字):

  • 「谁告诉你的 后台进程活不过几十秒,那协作看板如何开启的 …这个工作台是如何一直运行的」
  • 「怎么启动进程 技能中都没有记录吗,之前都启动了这么多次」

真因(实测坐实):⛔ 不是「本机不可能长跑」,是「载体不同」——

载体 实测结果
subprocess / start /b / start.bat(从工具调用进程树里起) ⛔ 活不过当轮;两条探针(start /b 与 Popen+DETACHED)在同一时刻一起停(tick 67/64)⇒ 与起法关键字无关,是父链
WorkBuddy 自己的后台任务(工具 run_in_background) ✅ 能长跑:看板 8788(pid 43176/HTTP 200/119 704 B)+ MCN 8900(pid 14056/HTTP 200/1 797 B)均现算
用户从桌面双击起(在用户登录会话里) ✅ 能长跑:mcn-work-shop/start.bat;另有 4 个 node.exe(会话名=Console)长期存活

🔴 教训(判据级):

  1. ⛔ 别用「这一种起法失败」推「本机不可能」 —— 至少验三种载体(工具调用树/宿主后台任务/用户登录会话)再下全称否定。
  2. 反证优先于推断:用户一句「那个工作台是如何一直运行的」就是现成反例 ⇒ 该先去找它怎么起的(start.bat 一眼看到 start "" node.exe),⛔ 别先写一段"本机不可能"的理论。
  3. 查证方向错了要整条撤回,不只是改措辞:基于假前提给的建议(「改用排期当时钟」)⇒ 整条撤,⛔ 不许"保留但加个注释"—— 那会让下一个人照着错的前提做决策。
  4. 改了代码 ≠ 改了知识:这类结论至少落在 4 处(SKILL.md §2 正文/SKILL.md frontmatter last_change/architecture.md 结论表+§5-1/collabd.py 用法头)。⛔ 漏掉 last_change 最坑 —— 它是别人加载技能时第一眼看到的。

🔴 配套一:「起完了」不等于「起了」—— 必查三样 netstat 有 LISTENING | curl 得 200 | tasklist 进程在。

  • ⛔ TIME_WAIT/FIN_WAIT_2 不算(历史连接残留)—— 本轮就差点把 FIN_WAIT_2 当"服务在"。
  • ⛔ 打印了"已启动"不算(start.bat 那次打印了启动消息、8900 始终无 LISTENING)。
  • 🔴 pid 文件写着不算(P0-30 同族):supervise.pid 写着 40900,实际 17:12 就死了。

🔴 配套二:pythonw.exe 必须配日志重定向 GUI 子系统 ⇒ 无 stdout ⇒ 静默无痕,连"起没起"都查不到 ⇒ subprocess.Popen([PYW,…], stdout=log, stderr=log)。 (tmp/board-serve.log 里那句 看板已起:http://127.0.0.1:8788/ 就是这么验的。)

P0-33 ⚠️ 「改了没生效」的第三种形态:配置读的不是你以为的那份 / 配置根本不重读(★ 2026-10-02 实测)

现象:两处「改了配置但看板纹丝不动」,成因完全不同,⛔ 若不取证会把两次都归成同一个错。

  1. 配置根本不重读 —— board.py 的 C(配置字典)是模块级加载一次,⛔ 不每次快照重读。 ⇒ 改 collabd.config.json 后必须重启看板。 ⚠️ 这是 P0-17「改了看不见」漏记的一面 —— 之前只记了「改 board.py 代码要重起」,⛔ 漏了「改配置同样要重起」。 ⇒ 判据:看进程启动时间 vs 源文件 mtime(board.py 的 C 无 re-read 路径)。

  2. 🔴🔴 读的是另一份配置(更隐蔽)—— 启动服务时没设 COLLABD_CONFIG ⇒ 回落技能包内的 scripts/collabd.config.json,⛔ 而不是工作区那份。 ⇒ 我改了工作区的 board_ext 却"没变",根因在此。 ⚠️ 与「goalctl.py 按 __file__ 推层级 ⇒ 静默指向技能包上级目录」同族。 ✅ 修法:启动脚本显式 env["COLLABD_CONFIG"] = <WS>/.workbuddy/collab/collabd.config.json。 ✅ 判据:改完直读 /board.json 里那个由配置决定的字段(这里是 front._missing), ⛔ 不看页面像不像(P0-17)。

  3. 连带发现:selftest.py 每轮跑完会在技能目录自产自消一个 scripts/collabd.config.json ⇒ 「技能侧零项目串」那条 FAIL 的来源。⚠️ 它是自消的,⛔ 不是遗留垃圾; 但它恰好是 2 的受害者(服务回落读到它)。

🔴 判据(通用):凡「我改了 X,行为没变」⇒ ⛔ 别先怀疑 X 没改对, 先查三样:① 进程/服务启动时间 vs 改动时间;② 它读的到底是哪一份配置/文件(⛔ 打印路径,别凭"应该是我改的那份"); ③ 有没有缓存(模块级加载/结果缓存/ETag)。

P0-34 ⚠️ 自己手里的独占锁会变成别人的路障(★ 2026-10-02 实测)

现象:派出去的执行棒跑 30 分钟零改动,只报「旧的全局锁仍被占([主会话]-记录常驻起法)」。 ⇒ 那把锁是我自己锁了 64 分钟没释放。

真因不是它偷懒 —— 它按 prompt 的「抢不到锁 ⇒ 停手报告」执行,连试 4 次都失败就正确退出, 甚至还额外报出真问题(preflight-lock.sh 把目标文件判为【E】机制层 ⇒ 我给的 --domains 认领方式对机制层不成立)。

🔴 判据:

  1. 锁的生命周期 = 任务的生命周期(2026-09-14 用户明令)—— 做完必须 --release-exec。 ⚠️ 干等自己那把锁是最贵的浪费:别人进不来、我也忘了它在谁手上。
  2. 机制层文件 ⇒ 认领必须 ⛔ 不带 --domains(带域=域锁,对机制层不成立); 正确姿势:--claim-exec "<名>"(不带域)= 全局独占。
  3. 派棒前先 --status 看锁;⛔ 别让自己成为别人的路障。
  4. 🔴 执行棒"零改动"先查锁,别急着判它失败 —— 判据= automation_runs.thread_title 里它自己写的停手原因(本轮那句「状态已跑…域锁抢锁失败…」是完整取证)。

症状(本轮现场):

  • 常驻 --supervise 起来 21 秒后 rc=1 退出,supervise-bg.log 只有一行: [safe-delete][SAFE_DELETE_BULK_CONFIRM_REQUIRED] {"count":172,"threshold":50,"scope":"turn","targets":["…\\TO-MAIN.md"],"targetCount":1}
  • 而心跳、pid 文件都还在(最后一条 round=3)⇒ 第一反应会误判成"我改坏了代码"。

真因:宿主有 SafeDelete 批量删除护栏 —— 按"本轮删除动作"计数,达 50 即要求确认并拒绝。 我写的退役清理是 if TO_MAIN.exists(): TO_MAIN.unlink(),看着很安全(文件不存在就不删), 但实际是172 次删除动作。三个原因叠加:

  1. exists() → unlink() 之间有窗口(TOCTOU);
  2. 投影轮(mutate=False)与常驻轮(mutate=True)是两个进程各跑一遍 ⇒ 判据被重复执行;
  3. 护栏计的是动作数,⛔ 不是"真的删掉几个" ⇒ 失败/异常也算一次。

修法(判据级):"一辈子只删一次"落成状态位,⛔ 不是靠"检查文件在不在"。

if mutate and not st.get("to_main_retired"):
    try:
        if TO_MAIN.exists():
            TO_MAIN.unlink()
        st["to_main_retired"] = True     # ← 关键:删过(或已不存在)就**再不碰这个文件**
    except Exception as e:
        log("…(⛔ 不重试,避免撞删除护栏):%s" % e)

🔴 判据(写下来防复发):

  1. 常驻里任何"周期性写/删"的文件,稳态下每轮的写删次数必须 = 0 —— 「降低频率」不是修法(P0-6 已经吃过一次:那套"快速否决"省不掉,因为内容一直变)。
  2. ⛔ 别用"文件在不在"当幂等判据(TOCTOU + 跨进程重复 + 异常也计数)⇒ 用状态位。
  3. 🔴 本条与 P0-6 是同一个坑的第二次复发(wake.lock 那次也是"稳态下每轮都在 unlink") ⇒ 见到 SAFE_DELETE_BULK_CONFIRM_REQUIRED,第一反应是"我在每轮删东西",⛔ 不是查代码是否崩了。

P0-36 ⚠️ 判"某函数体内没有调用 X"必须走 AST,字面 grep 会被 docstring 永久假红(★ 2026-10-03 实测)

现场:退役投递段后,我写了个判据「supervise() 函数体内零个 _deliver_str( 调用点」, 用 src.split("def supervise(")[1].split("\ndef ")[0] 切函数体再 grep —— 报红。 真因:函数体第一段就是那段 30 行的退役说明 docstring,里面必然提到 _deliver_str() (不写清楚"删了什么"= 读者不知道删了什么)。

🔴 这是"判据"层的自相矛盾:要说明"已删除 X"就得提到 X ⇒ 任何 grep "X(" in <该函数体> 永久假红。

正解(已落地 selftest.py::_calls_in()):

def _calls_in(src: str, fname: str) -> set:
    import ast as _ast
    tree = _ast.parse(src)
    for node in _ast.walk(tree):
        if isinstance(node, _ast.FunctionDef) and node.name == fname:
            out = set()
            for sub in _ast.walk(node):
                if isinstance(sub, _ast.Call):
                    f = sub.func
                    if isinstance(f, _ast.Name):   out.add(f.id)
                    elif isinstance(f, _ast.Attribute): out.add(f.attr)
            return out
    return {"<no-such-function>"}

🔴 判据:

  1. 判"有没有调用" ⇒ AST(只认 Call 节点的 func 位);⛔ 判"有没有出现"才是字面 grep。
  2. ⚠️ 滤 # 开头行不够 —— docstring、''' 块注释、字符串字面量里的字样都会骗过你。
  3. ⛔ 不许因为字面判据假红就把断言改弱("改成 deliver=False"=换个说法说同一件事,P0-13 同族) ⇒ 换可证伪的判据(本轮用的是「真调用 supervise(deliver=True, mutate=True) ⇒ deliver.skipped 必须为 retired-20261003」——⛔ 改实现就会红,不是恒真)。

P0-37 ⚠️ 「一个角色退役了」与「承载它的那个进程也退役了」是两件事(★ 2026-10-03 实测)

现场:常驻 --supervise 原本只有两个职责(① 队列投递 + 唤醒 ② 建检查会话排期)。 投递退役后我一度想连常驻一起删(既然它不投递了)。查完发现 ② 那条腿只有它承载 —— maybe_spawn_check_agent() 是唯一建 [检查]-… 排期的地方,而检查会话只能靠排期开(⛔ 开不了别的通道)。

🔴 判据:

  1. 删一个组件前先答一句:「它现在还承担什么?那些职责另有谁承载?」 ——「它以前主要做 X」⛔ 不等于「它只做 X」(本轮就是靠这句话漏看了 ②)。
  2. 职责退役 ⇒ 改名 + 改副标题,⛔ 不等于删节点 —— 架构图上那格画着「常驻投递 · 一直运行」 而实际那两段已删,就是 **P0-25「服务自报健康 ≠ 服务有内容」**的活标本。 本轮改成「建检查会话排期 · 一直运行」+「⛔ 投递已于 2026-10-03 退役」两行。
  3. ⚠️ 反例同样要认:--tick 分支里的 check_delivery_consumed() 真该删 —— 它的判据 st["wake"]["expect"] 只由已删的 _deliver_str() 写 ⇒ 恒返回 {} ⇒ 纯空转。 ⇒ 判据是「它的输入还有没有生产者」,⛔ 不是「它看起来还有用吗」。

P0-38 🔴 「改看板要不要重启」有四类答案,混成一句话就是误导(★ 2026-10-03 实测)

症状(我自己的错):用户问「看板每次修改都不处理看板」,而我上一条回复把 「改了 collabd.config.json 必须重启」写成了「改看板都要重启」⇒ 把 HTML 和配置/代码混成一类。 用户一句话点破的是分类错误,不是"我忘了重启"。

真因(现跑取证,不是推断):判据不在"文件是什么",在那个文件被谁怎么读。

改什么 要重启吗 依据(代码行)
assets/board.html ⛔ 不用(只需刷新页面) board.py:1293 serve() 里 html_p.read_bytes() —— 每次请求实时读盘;且 /board.json 带 html_sig(mtime.size)⇒ 页面自动重载
scripts/board_ext.py ⛔ 不用 EXT_CACHE 按 (mtime, size) 签名热重载(board.py:855-870)
scripts/board.py 自身 ✅ 必须 进程里跑的是启动那一刻载入的旧代码(P0-17 本体)
collabd.config.json ✅ 必须 C = _cfg() 在 board.py:81 模块级执行一次,⛔ 无 re-read 路径(P0-33 第 1 条)

现跑实证(tmp/_probe_hotreload.py,改 <title> 插标记再还原,⛔ 未重启任何进程):

① 改前 页面含标记? False(应为 False)
② 已改 HTML(⛔ 未重启任何进程)
③ 改后 页面含标记? True
   ⇒ 结论:改 board.html **不需要**重启看板
④ 已还原 HTML(md5 校验:一致 ✔)

⚠️ 选 <title> 是因为 selftest 的版面纪律判据一条都不查它 ⇒ 不会被自测噪音干扰。

🔴 判据(通用):回答"要不要重启"之前,先答一句**「这个文件是谁在读、什么时候读」** —— read_bytes() 每请求读 ⇒ 不用重启;模块级加载一次 ⇒ 必须重启; 按 (mtime,size) 缓存 ⇒ 不用重启但要等签名变。 ⛔ 别把「改了 X」与「改看板」当成同一件事(本轮就是被这句话带偏的)。 ⚠️ 与 P0-17/P0-33 同族但方向相反:那两条讲"该重启却没重启",本条讲"⛔ 不该重启却去重启了" —— 重启不是万能药,没分清就重启=白花一轮 + 打断正在看页面的人。


P0-39 🔴🔴 自测判据钉在「测试夹具」上 ⇒ 用例内读数 ≠ 现网读数(恒绿与恒红都是假象)(★ 2026-10-03 实测,同族第 2 次:上一次是 t_execution_doc)

症状:新增用例 t_tab_peer_workspace 的「现网真读数」项报 goal_files()=1 格(peer 0 个,active 1 个),而同一时刻命令行直接加载是 3 格。 (⛔ 第一反应「是不是代码没生效」=错:代码是好的,是判据读错了世界。)

真因(看代码,不猜):selftest.py::imp() 会先设 env 再加载模块 ——

os.environ["COLLABD_CONFIG"] = <TEST_WS>/collabd.config.json
os.environ["DSH_COLLAB_WS"]  = <TEST_WS>

而 board.py:92/103 的 CFG_P/C = _cfg() 是模块级求值 ⇒ 用例里无论怎么 exec_module 加载 board.py,读到的都是测试那份配置 ⇒ 眼里当然只有一个测试 INBOX(1 格)。⚠️ 用例前面调过 imp() ⇒ env 里还留着测试值。

修法(三条,缺一不可)

  1. 加载前换成目标配置、用完还原(_old = {k: os.environ.get(k) …} + try/finally 逐个还原) —— ⛔ 不还原 ⇒ 污染后续用例,且这种污染不报错、只让别的用例莫名飘。
  2. 在真磁盘上造目标(tempfile.mkdtemp() 里各放一份 goal.json) ⇒ 读数来自真文件,⛔ 不从夹具推导。
  3. 加变异对照:把 goal_files() 里的 for _pw in _peer_workspaces(): 改成 for _pw in []: ⇒ 用例必须报红;还原后 md5 校回原值再复绿。

现跑实证(本轮,-k 跨工作区)

变异后:
  ✗ ⑧ 现网真读数:登记 1 个 peer ⇒ goal_files()=1 格(peer=[],active 1 个)
  ✗ ⑩ 生产侧:生产配置登记 2 个 peer ⇒ 1 格(peer=[],active 1)
  ✓ ⑨(按设计仍绿 —— 它只守「配置可关」那一侧,抓不住代码坏 ⇒ 证伪只看 ⑧)
还原后:md5 与变异前逐字一致 ⇒ PASS 61 / FAIL 0

🔴 判据(通用) · 「现网真读数」类用例 ⛔ 不许复用 imp() 注入的环境 —— 那套环境是给代码路径用的,⛔ 不是给读数用的。 · 判「配置缺省 ⇒ 回落旧行为」的那一条只守配置侧:代码被破坏时它照样绿(两边都退到同一结果) ⇒ ⛔ 别把它当证伪项写进标题;证伪必须另有一条「配置开着、代码却没接 ⇒ 报红」的断言。 · 生产侧 ⛔ 不硬编码路径:读不到就记 SKIP:…(⛔ 不假装通过);跑法 DSH_COLLAB_WS=<真工作区> python selftest.py -k <关键字>。 · 🔴 一个用例绿了,先反问一句:它读的是生产,还是测试夹具?

⚠️ 与 P0-13(判据要能证伪)/P0-36(AST 才可判"函数体内没调用 X") 同族: 那两条治「判据恒真」,本条治「判据读的不是它以为的那个世界」—— 恒真和读错世界,表现出的"绿"一模一样。


P0-40 🔴🔴 加了 tab 却只换了标题 —— 数据源没跟着「格」走 ⇒ 看板在说假话(★ 2026-10-03 实测)

症状(用户报障逐字):「选择另一个工作区目标 tab 下面没有显示对应工作区目标和执行情况」 ⇒ 我第一反应是「没显示」—— 方向就猜错了。实测是显示了别的工作区的数字。

真因(看代码,不猜):build() 里的 tasks/srows/st 只取一次、所有格共用 (board.py:1443 只读一次库、1449 那个列表推导里逐格传的都是同一份), 只有 goal 跟着格换 ⇒ 三格的 labor/sessions/progress md5 完全相同:

[0] 本区    labor md5=95c77dbf  sessions md5=8be394bd
[1] peer-1  labor md5=95c77dbf  sessions md5=8be394bd   ← 与本区逐字相同
[2] peer-2  labor md5=95c77dbf  sessions md5=8be394bd

界面上那两格挂着「会话协作测试1/2」的名字 ⇒ 把本工作区的执行情况说成了对方的。 ⛔ 假数据比空白危险:空白会被追问,假数据会被当成结论直接用。

修法(三条,缺一不可)

  1. 每格的数据源必须跟着格走 ⇒ peer 格只喂 _peer_tasks()/_peer_srows()/_peer_state(), 三者只读对方目录下的文件;读不到 ⇒ 空 + 界面如实说明(peer_scope + 前端 renderPeerNote()), ⛔ 绝不拿本工作区的数据补位。
  2. 必须补捞 ⇒ _session_rows() 取的是全库最近 50 条(按 last_activity_at 倒序) ⇒ 对方工作区的会话一条都不在里面 ⇒ peer 格显示「0 条」=假象(对方主会话明明 working)。 ⇒ 单独 _peer_session_rows() 按 cwd 精确补捞(SQL 里 replace(cwd,'\','/')=?,⛔ 别拉到 Python 侧全表扫)。 ⛔ 不许为了捞它把全局 limit 放大 —— 那会让本工作区的 others_running 计数暴涨(为一个新功能改坏老行为)。
  3. 作用域要跟着换 ⇒ _sessions() 里有一道 _ct == _wstail(cwd 末段 == 工作区名)的硬过滤, 而 _wstail 来自 project_scope() 的全局 WS ⇒ 对方会话必然被排除 ⇒ peer 格要把 sc["workspace"] 覆盖成对方根(只覆盖这一个键,其余判据仍按对方 goal.json 算)。

🔴 判据(通用):凡做「一格一视图」(tab/分栏/多租户面板), 先答一句「这一格的数据源是不是跟着格走」 —— 只换标题、不换数据源 ⇒ 必然是假数据。 验收要逐格比对关键字段的 md5(我这次就是靠它抓出来的):

改造后:本区 tasks=323 字节 / peer tasks=2 字节(= {} )⇒ 两侧不再同源 ✔

⛔ 「没显示」和「显示了错的」必须先分清再动手 —— 本次第一反应猜错方向,白绕了一轮。

现状(如实报,⛔ 未解决的部分):peer 格的 sessions 目前仍是空 —— 卡在 in_project() 那道判据(主会话登记/任务类别都在对方的 INBOX 里,而 project_scope() 读的是本工作区)。 ⛔ 我没有为它去改 in_project():那条判据与 collabd.py::_in_project() 有「逐条同款」硬约束 (历史已因此踩过三次)⇒ 为一个"只读概览"去动它,风险远大于收益。 ⇒ 正解在架构层:各工作区开自己的看板(各自进程、各自判据),跨区只做总览 + 跳转。

⚠️ 与 P0-39(判据读错世界) 同族:那条是测试读到了夹具,本条是产品读到了别格的数据。

P0-41 🔴🔴 常驻的载体:会话/工具调用起的活一定活不长,且三条"看似可行"的路全被封

症状:collabd.py --supervise 历次只活几分钟到几十分钟,日志照旧在走 (写它的是宿主钩子的 --tick,⛔ 不是常驻自己)⇒ 看板显示"在线"而载体早就没了。

三条封死路(2026-10-03 实测,别再试):

  • CREATE_BREAKAWAY_FROM_JOB ⇒ PermissionError(13,'拒绝访问。')(被作业对象拒绝)
  • cmd /c start /B ⇒ "拒绝访问"(本机沙箱拦截)
  • .bat 包装 ⇒ ⛔ 路径含中文时 cmd 按 GBK 读 UTF-8 ⇒ garbled ⇒ 不可用

✅ 正解=Windows 计划任务 + 永不返回的守护循环。关键差别=父链: 计划任务创建的进程由 Task Scheduler 服务创建,不在 WorkBuddy 会话的 Job Object 里 ⇒ 不会被会话回收(实测 5–6 秒自动恢复、无叠加)。 ⛔ 计划任务的增/删/改/查一律走 ScheduledTasks 模块(schtasks.exe 在本机被黑名单拦截)。

🔴🔴 两种"长期"都要同一个载体,⛔ 别拿排期当载体(2026-10-04 实测):

场景 主会话来源 载体 特有坑
A 用户手动创建 用户自己开 [主]-… 同左 用户一收工就没人拉 ⇒ 没装载体就会静默死掉
B 定时任务创建 排期 recurring 到点拉 同左 🔴 跑完即 completed ⇒ 下一跳之前是空窗;🔴 once 过期即哑
⇒ ⚠️ status=ACTIVE ≠ "在跑":实测 [主]-会话协作测试3-主会话 是 once/
scheduled_at=2026-10-03T20:33/next_run_at=None/last_run_at=None
⇒ 一次都没触发过,界面却显示 ACTIVE。**判"真会触发"要看
schedule_type='recurring' ∧ next_run_at 非空**。

三个秒退坑(详 ⇒ supervise-persistence.md): ① 不设 CODEBUDDY_CONFIG_DIR ⇒ 退回读 0 字节空库 ⇒ no such table: sessions ⇒ 闸二永久失效 ⇒ 检查会话永远建不出来(看着在跑,其实在瞎报) ⇒ ✅ 正解用机制自带配置项 host_db(它本就优先于环境变量); ② cwd 设成 collab/ ⇒ 配置查找拼出多一级 .workbuddy ⇒ 当场拒跑(设成工作区根); ③ 🔴 PowerShell 5.1 读无 BOM 的 UTF-8 .ps1 ⇒ 按 ANSI(GBK) 解码 ⇒ 中文路径全毁 ⇒ 秒退。 ⚠️ 本机所有含中文路径的 .ps1 一律带 BOM(落盘用 UTF8Encoding($true))。

判据:LastTaskResult 必须 0(1 = 秒退,八成是 BOM)+ 心跳 pid 活 ∧ ts 距今 < 90 s。 📌 ⛔ 打印"已启动"不算数(同 P0-22 家族:启完必查真读数)。

🔴🔴 追加实测(同日 23:32):RestartCount=999 救不了"被外部收走" —— 测试3 常驻 23:16:37 起、23:28:47 停(活 12 分钟),而计划任务 LastRunTime=23:17:03/LastTaskResult=0/State=Ready ⇒ 它没重拉。 根因:Restart* 只在本任务自己非零退出时生效;而脚本里若是前台阻塞调用常驻, 常驻死掉 ⇒ 脚本正常返回 0 ⇒ 任务被判「成功」⇒ 永不触发 (测试3 复盘原话:「自愈机制被成功退出这三个字废掉了」)。 ⇒ ✅ 正解=脚本里写「永不返回的守护循环」(while($true) 包住 + Start-Process -Wait), ⛔ 不是靠计划任务的 Restart*。⚠️ & 对 pythonw.exe(GUI 子系统)不阻塞 ⇒ 循环会误判"刚起的已退出"⇒ 叠出多个常驻(测试3 实测 5/10/20/40s 连续重拉)。

🔴🔴 追加实测(2026-10-04 07:03):守护循环必须看停止标志 guard.stop** —— 目标已完成(--set-life 已完成 已写标志、常驻已优雅退出),但守护循环仍每 60 秒重拉、每次秒退 ⇒ 实测空转 370 次/6 小时。根因=守卫只问「心跳新鲜吗」,⛔ 不问「目标还活着吗」 ⇒ "完成即收工"只做到"停掉常驻",没做到"别再拉它"(同一件事的两半)。 ✅ 循环开头 + 退避等待中都查标志,在则 Say 一行后 exit 0(⛔ 不 Say ⇒ 又成"静默消失")。 实测:新 keeper 0 秒识别 ⇒ 任务 State=Ready(⛔ 不是 Running)⇒ 重拉 372 → 372。 ⚠️ 验「是否被回收」必须只做单一变量(⛔ 不许同时停/起任务)—— 否则因果会搞错。 ⚠️ 验收要静置 ≥ 12 分钟(⛔ 短于 10 分钟证明不了任何事 —— 本轮就死在第 12 分钟)。 📌 另两条正常行为别当故障:静默基准取台账 mtime ⇒ 改排期会归零计时; _all_sessions_idle() 要求"所有会话都结束" ⇒ 发起会话自己也在里面 ⇒ 结束它检查会话才能建。

P0-42 🔴🔴 判据里"起真进程写夹具"会写坏真数据 —— 隔离必须用 COLLABD_CONFIG(★ 2026-10-04 实测,代价=真 goal.json 被覆盖)

症状:给自检加"端到端真跑 goal_state()"的判据,跑完发现生产 goal.json 变成 230 字节、 只剩 1 条判据,目标文件夹 名从 目标-本机协作-3e3182 变成 目标-未命名目标-35279e。

根因:load_cfg() 的候选顺序是 ① COLLABD_CONFIG → ② <ws>/.workbuddy/collab/collabd.config.json ⇒ 只设 DSH_COLLAB_WS/DSH_WS_ROOT 会被忽略(真源压根不读它们定 workspace)⇒ 回落读到真配置 ⇒ 判据的 goal.json 写进了真工作区。⚠️ 连 TG(任务图)也是同一份 config 决定的,一并读到真的。 ⚠️⚠️ 更隐蔽的一层:DSH_WS_ROOT/DSH_COLLAB_WS 在 selftest.py 里是它自己的测试工作区根 (WS = ... or DSH_WS_ROOT,_prepare() 拿它建 tmp/selftest)⇒ 拿它拼"生产路径"=把夹具当生产; 演练时我设了个 X:/nonexistent ⇒ 直接 FileNotFoundError: 'X://' 崩在 _prepare()。

✅ 正解(t_acc_selfref_excluded 的端到端段照此写): ① 写一份临时 config 并 os.environ["COLLABD_CONFIG"] = <它>(含 workspace/inbox/taskgraph); ② 加载模块后先自检 str(m.INBOX) 落在临时目录内,不在就直接报红退出(⛔ 宁可红也别再写真文件); ③ 需要"生产工作区"的判据(如 t_execution_doc)另认专用 COLLABD_PROD_CONFIG, 由 install.py 写进 roots.env;⛔ 不用 DSH_WS_ROOT 拼。 📌 验收纪律:凡新增"会写文件的自检",跑前先 md5sum 目标真文件、跑后再核一次。 (本轮恢复源=.workbuddy/collab/bak-goalctl-20261002/goal.json,⛔ 那份缺 execution_doc 字段, 恢复后要按 exec_doc_rel() 算出的真值补回,否则 t_execution_doc 假红。)

P0-43 🔴 判据写成"函数体里含某串" ⇒ 同一个符号在函数里出现两次就抓不住变异(★ 2026-10-04 变异验证实测)

症状:判据写 "_acc_is_selfref" in fn_goal_state(fn=函数源码文本)⇒ 变异把 goal_state() 里 _real 那处的调用删掉,判据照样 PASS ⇒ 假绿。

根因:goal_state() 里 _acc_is_selfref 出现两次(_real 的推导式 + _dropped 的推导式)。 同族第二例:判据写 re.search(r"if not _real.*?undeclared", fn) ⇒ 变异把中间那个 return "undeclared" 拆掉照样 PASS —— 因为函数尾部 return "pass" if eff else "undeclared" 里还有一个 undeclared(正则一路匹配过去了)。 ✅ 正解=上 AST,按结构定位(本轮 t_acc_selfref_excluded 照此):

  • 「X = [...] 里有没有调 f」⇒ 找 Assign(targets[0].id == "X"), ⛔ 不是找 ListComp —— 推导式的 target 是 (_k, _v)/k,_real 只在赋值左侧。
  • 过滤条件在生成器的 ifs 子句里(if ... and not f(...)),⛔ 不在 elt。 ⚠️⚠️ ListComp.elt 是单个节点(有时还是 Tuple)⇒ list(elt) 抛 TypeError: 'Tuple' object is not iterable(本轮真踩)。
  • 「if not X: 的 body 里有没有 return "..."」⇒ 走 If 节点的 body,⛔ 不用跨节点正则。 📌 另:行为级判据最硬 —— 真造 goal.json 跑真 goal_state(),断言返回值 (本轮 4 个场景:全自指⇒undeclared/自指+真判据全过⇒pass/真判据没过⇒open/ 自指写过但真判据没过⇒open)。⚠️ 夹具判据文本必须用真源真会写的形状(过|…, ⛔ 不带 🔴 前缀 —— _acc_is_pass 取竖线左边那段判,🔴 过 的 head 仍带 🔴 ⇒ 假红)。

P0-44 🔴 技能包没有版本控制兜底 ⇒ 清理前必须先建包外快照(★ 2026-10-04 实测)

症状:用户问「技能相关文件有没有可以整合、精简、移除的重复冗余」, 体检算出 10.7 MB 里9.1 MB 是冗余(68 个 *.bak-* 占 8.6 MB),我上一轮顺口说过 「git 已有历史」—— 🔴 这句是错的。

取证(三级都查了):.workbuddy//E:/ProgramData//E:/ 全都没有 .git; 文档库 dsh-server-docs/08-skills/ 只收了另外 7 个技能,没有 session-mechanism ⇒ 在这个机器上,删掉的文件就是唯一副本没了。

✅ 正解(清包三步,顺序⛔ 不许换): ① 先建包外快照:cp -r <包> <WS>/归档/技能包快照/session-mechanism-<日期>/ + md5 抽检; ② 再删(*.bak-*/__pycache__/.pyc/install.log/零引用文件); ③ 删完必须现算验收:selftest.py + install.py --verify + 看板 headless 渲染。 📌 死证据引用要一起改:注释里写「原文照抄见备份 X.bak-…」⇒ 备份一删那句话就成了假话 (本轮改掉 6 处:collabd.py×1、board.html×2、SKILL.md×2…含 L6)。判据见 selftest.py::t_pkg_hygiene。

P0-45 🔴 判据别去判"运行时产物"和"该长的东西",否则永远红(★ 2026-10-04 实测栽了三轮)

给包体卫生写判据时,连踩四个坑(都是判据错,不是代码错):

想判的 ⛔ 错误写法 为什么必然红 ✅ 正确写法
缓存 「包里不许有 __pycache__/.pyc」 hooks/_env.py 一被导入就生成 scripts/hooks/__pycache__/_env.cpython-313.pyc ⇒ 跑一次 selftest 就长回来 判「无散落 .pyc」(只能在 __pycache__ 内)
运行日志 「包里不许有 install.log」 install.py:96,340 每次运行都 append 判「不超 64 KB」
文档超长行 「SKILL.md 无超 2 KB 的行」 L3 description: 本来就 2.2 KB(技能描述就该长) 豁免 description: + 含「作废」的历史段(它长是因为把作废原因写全了)
「本条为准」字样 「台账里不许有『本条为准』」 新台账 L6 自己在解释这个机制,L24/L98/L267 各段也都合法用它 判行体积(当年 L6 单行 55 KB = 全文 66%),⛔ 不判字样

⚠️ 另:死证据扫描会自我误报 —— 判据自己的注释里就写着 settings.json.bak-session-mechanism-* ⇒ 扫全包必然命中自己。✅ 判据要显式排除 install.py(备份在 .workbuddy/,是活机制名) + selftest.py 自己(P0-16 同族:判据把自己当被检对象)。 📌 变异验证时也栽过一次:注入死证据用的锚点 """使用方 压根不存在 ⇒ 变异没注入成功 却 Prints 出一条「漏网」结论。⇒ 变异必须断言 assert 新串 in 文件,⛔ 别让"没注入"伪装成"判据有洞"。

P0-46 🔴🔴 "单包自包含"的两个静默失效点(★ 2026-10-04 实测,只搬一个技能到空目录演练坐实)

症状:只复制 session-mechanism 一个技能到别的机器 → 机制看起来都在(7 个 hook 全 rc=0), 但回复排版闸门整条静默失效 —— reply-style-guard.py 输出空 JSON,日志里看不出是"机制坏"还是"没配规则"。

🔴 失效点 ①:hook 读的是「另一个技能」的文件 reply-style-guard.py::_core() 第②级回退硬指向 agent-operating-rules/references/回复排版-核心块.md。 ✅ 正解=把那份核心块内联进本包 references/03-回复排版-核心块.md(内容守恒、逐字搬), _core() 改三级回退:工作区规则文件 → 包内件 → 外部兜底。⚠️ 顺序⛔ 不许换。

🔴 失效点 ②:技能库定位拿「别的技能」当识别特征(更隐蔽) hooks/_env.py::_SNIFF = "agent-operating-rules" —— 它是技能库在不在的判据, skills_root() / env_check() 三处都拿 (root)/_SNIFF).is_dir() 当真源。 ⇒ 只装本包时永远判否 ⇒ 全部降级。✅ 正解=改候选数组 _SNIFFS = ("session-mechanism", "agent-operating-rules"), 新增 _has_skill()(任一候选存在即认)。⚠️ 第一个元素必须 = 本包自己。

⚠️ 改造时我自己踩了两个坑(都是"文件在、读不到"型):

  • 上溯少一级:__file__ = <包>/scripts/hooks/x.py ⇒ dirname①=hooks/ ②=scripts/ ③=包根。 写成两层就落到 scripts/ ⇒ 找不到 references/ ⇒ 静默空输出。 ⇒ 判据必须查 os.path.dirname(os.path.dirname(_hooks)) 这个确切形状,⛔ 查"文件存在"是假绿。
  • 取证本身也有缺口:第一轮单包演练"注入 654 字节✅",但日志显示源是工作区的 CODEBUDDY.md (第①级回退)⇒ 根本没验到包内件。✅ 正解=在一个没有 CODEBUDDY.md 的干净工作区里重验, 源日志必须显示包内件路径才算数。

📌 判据 t_single_package_selfcontained 的两个教训(变异验证实测): ① 查标记必须用 _extract() 的完整字面量 <!-- REPLY-CORE:END -->, ⛔ 查子串 REPLY-CORE:END ⇒ 把它改成 END(改坏) 子串仍命中、而真提取器已认不出(假绿)。 ② 变异脚本自己的过滤也会造假绿:按用例标题过滤时把"用例标题行"一起滤掉 ⇒ 明明报红了却统计成 0。 ⇒ 变异计数**⛔ 不用标题行**,只数子项的 ✗。

P0-47 🔴🔴 变异脚本把「判据自身语法错」当成了「判据抓不住」(★ 2026-10-04 实测,判据编写者的自保要点)

症状:给判据加完新项,跑变异验证 → 4 个变异全部「0 报红」,看起来像"判据不可证伪"。 真因=新写的判据行里嵌了中文直角引号((⛔ 那是"去别处找判据"的形状)) ⇒ selftest.py 整份语法错、跑不出任何输出 ⇒ 变异脚本统计「子项 ✗ = 0」 ⇒ "没有输出"被当成"没有报红" ⇒ 结论完全反了(真身是"判据根本没跑")。

🔴 根因有两层,缺一不可: ① 中文引号坑(与既有铁律「含反引号一律 Write/Edit」同类):"…" 嵌进 "…" 字符串即语法错。 ② 变异脚本没有防空转护栏 —— ⚠️ 这是更值钱的一半: "判据没输出" 与 "判据全绿" 在计数上长得一模一样(都是 0 个 ✗)。

✅ 变异脚本必须带两道护栏(缺任一道,一次误判就够):

# 护栏 1:判据源文件必须语法正确
ast.parse(st.read_text(encoding="utf-8"))        # SyntaxError ⇒ 直接判「运行异常」
# 护栏 2:必须真找到那条用例
if not any("用例标题" in l for l in out.splitlines()):
    return 99# ⛔ 过滤失配 ≠ 全绿

📌 另:别用 grep -A N 数子项(窗口会切掉长输出,实测"6 项"其实是 28 项)⇒ ⛔ 用 Python 按 "下一条 ✓/ ✗ 标题行为止"切块。

⚠️ 同族第三发(今天同一天栽的另两个假绿):P0-43(判据写"函数体含某串"⇒ 同符号出现两次就抓不住)· 本条 P0-45 的排版判据(查子串 ⇒ 同一文件里出现两次 ⇒ 改一处仍命中)。 ⇒ 一句话总纲:判据的每个断言都要锚在「结构」或「唯一位置」上;⛔ 锚在"文本出现过"上必出假绿。


P0-48 🔴🔴 判样式表:注释里正当引用着「旧写法」,不剥注释必假绿;等宽要解析式(★ 2026-10-04)

📌 只管「样式表里带注释的那类判据」(⛔ 不是所有代码判据);带注释是因为注释里常要写"为什么改"。

症状:给 .tab 加"等宽"判据 ⇒ 变异「flex:1 1 0 改回 min-width:180px」🟢 绿; 判据里写「⛔ 不许有 min-width:1xxpx」,那串明明就在文件里 —— 在注释里。 根因:那段说明注释正当引用了改之前的旧写法("为什么改"的证据,⛔ 不许删)⇒ 全文件查旧写法永远命中。

✅ 正解(缺一步仍假绿):

css = re.sub(r"/\*.*?\*/", "", html, flags=re.S)   # ① 剥注释
tab = re.search(r"\.tab\{[^}]*\}", css).group(0)   # ② 只取那一条规则块(⛔ 不量全文件)

③ 判"等宽"必须解析式:flex:1 1 0(basis=0 ⇒ 等分,✅)对比三种不等的写法 —— flex:1(省略 basis ⇒ 默认 1 1 auto,按内容)· flex:1 1 auto(按内容)· flex:0 1 auto(不生长,宽=内容)—— ⛔ 子串法与正确写法分不开 ⇒ re.search(r"flex:\s*(\d+)\s+(\d+)\s+([0-9a-z%]+)") 判 grow>=1 and basis=="0"。 ④ 🔴 配套:min-width:0 缺了会被子元素 min-content 顶开 ⇒ 看着仍不等宽 ⇒ 必须同时断它。 📌 _R = [...] 是列表字面量,⛔ 不能在中间插 _R.append(语法错 ⇒ 触发 P0-47 静默)。 📌 附带(同型可独立用):⛔ 扫文本找"裸断言"的判据不成立 —— 会扫到自己的扫描代码(实测 "== 0) or" in ln 命中那行本身)⇒ 恒红。


P0-49 🔴🔴 台账/文档里的读数是「快照」——引用前必须看它什么时候写的(★ 2026-10-04)

症状:acceptance_state 说「自测 PASS 48」⇒ 真跑 73;说「pid 8024 活」⇒ 现活 19424。 根因:每条带 _更新 时间戳(本例停在 10-02)⇒ 那是**"当时验过"**,⛔ 不是"现在仍成立"。

凡带「现在/已/还差」的结论,读数一律现取。 pid · 心跳 · 计数 · pass 数 · 端口 · 排期状态——全会变。

🔴 同日更阴的一发(我据此报错结论,用户当场质疑):由"读数旧"推出「常驻没在跑」——是我错。 错在取证手段:① 落点猜错(照旧文案找 tmp/supervise-inbox/,真身是 collabd.SUP_HB 指向的 .workbuddy/collab/logs/);② 🔴 glob("**/x") 不穿透 .workbuddy 这类点目录 ⇒ 零命中 ⇒ 把「查不到」当成「不存在」。 ⇒ 同型(不是同一条):P0-48 讲的是样式表判据,这里讲的是取证锚点 —— 共同教训是「锚点自己没先验 ⇒ 结论错」,⛔ 别把 P0-48 当成也管取证。

✅ 取证纪律:查机制状态只许问那个程序自己(import collabd; collabd.supervise_alive()、 读路径常量 SUP_HB),⛔ 不许 glob、不许手拼路径、不许照抄文档旧落点。 📌 已落判据 t_supervise_ensure(在 session-mechanism 包的 scripts/selftest.py,⛔ 不在本工作区)。

📌 另一类:制度性不可能项 —— 判据要求的对象已整套退役(配置 wake_enable=false + 源码里投递段已真删) ⇒ 它会永远 🔴,不是坏了。⛔ 这种只能用户拍板(作废/改写口径/保持),AI 不得擅改验收口径。



P0-50 🔴🔴 忙判据只读一个日志源 ⇒ 宿主不写那份时「必然误判空闲」⇒ 往正在跑的会话插话(★ 2026-10-04 vibe-product 实测坐实)

症状:vibe-product 一条会话(b232218f…)明明在转 —— state-machine:transition → working 358 次、 idle 仅 3 次、用户只发言 4 次、跨 2h46m —— 而 _target_busy() 返回 False(没在跑)。

⚠️ 2026-10-04 晚间复验:这组数字对不上,⛔ 别再当证据引用(结论"在转"仍成立)。 实测:日志已续写,现为 to:"working" 438 次(含 from:"working" 自环 326)、 to:"idle" 4 次、method:sendPrompt 4 次(✅ 这个对得上)。 ⚠️ 大概率是当初数的是另一个口径(如只数某个时间窗/某个 input 值)—— ⇒ 教训:数字结论必须连口径一起记(数的是哪个字段、哪个范围),⛔ 只留结果不留口径。

🔴 根因(可证伪):_session_busy() 只读工作区级日志 <ws>__<hash>.log。 实测那份日志里 SessionRunStateMachine 615 条,但这个 sid 一条都没有(全文件 0 次) ⇒ 宿主不为该会话写工作区级状态机日志 ⇒ 恒得 '' ⇒ 老逻辑「读不到 ⇒ 按没在跑处理」必然误判。 ⚠️ 这个 fail 方向比"恒真"更隐蔽:恒真是"永远不敢投"(用户看得见);误判空闲是"往正在跑的会话里插话"(静默)。

✅ 正解=双源:工作区读不到 ⇒ 回落会话级日志 <logs>/<日期>/sdk/conversations/<sid>.log。 ⚠️ 两个源字段完全不同(⛔ 拿错正则 = 恒得 0 条 = 白加):

源 事件名 取值字段
工作区级 [SessionRunStateMachine] … busy=true busy=
会话级 state-machine:transition {"from":…,"to":…} to(working/planning=忙)

⚠️ sid 形态不一致(实测栽过):文件名叫 b232218f-c8e6-…(36 位带连字符), 而调用方常拿库里 b232218fc8e6…(32 位无连字符)⇒ 只拼一种 恒不命中 ⇒ 回落源恒 '' ⇒ 修了个寂寞。 ⇒ 两种形态都试 + 前缀唯一命中兜底。

📌 判据:t_srsm_busy ⑦~⑬(6 条)+ 变异 6/6 报红(删回落/写死不救/正则混用/删形态兼容/取首条/越权)。 📌 顺带:会话级日志格式不是 JSONL,是 ISO时间 事件名 {JSON} ⇒ 按 JSONL 解析会得 0 条(⛔ 已栽)。


P0-51 🔴🔴 技能副本与全局会分叉:修完全局忘了同步,副本继续用旧缺陷(★ 2026-10-04 vibe-product 实测)

症状:全局技能库修好一个 P0 级缺陷(忙判据双源),用户那边照旧出错 —— 因为那个工作区用的是复制到工作区的副本(.workbuddy/skills/<包>/),⛔ 不会自动跟随全局。

🔴 本轮实测的量:副本 47 个文件里 落后 3 个(collabd.py/selftest.py/pitfalls.md), 其余 45 个 md5 已一致。这 3 个不补,副本就带着旧缺陷跑。

✅ 同步四步(⛔ 别整目录覆盖):

  1. 先逐文件 md5 双向 diff —— ⛔ 用文件数当判据会错(副本多一份《副本使用说明.md》)。
  2. 只覆盖真差异 —— ⛔ roots.env 绝不覆盖:它是工作区专属指针(setdefault 兜底 ⇒ 覆盖会让副本去读写另一个仓库)。
  3. 备份挪出包体 —— 同步完把备份移到 <工作区>/交付物/;留在包内会被 t_pkg_hygiene 的「⛔ 不许长回备份」判红(实测副本自测 FAIL 1)。
  4. 🔴 副本必须真跑一次 —— ⛔「编译过」≠「能跑」。本轮副本首跑 FAIL 4(修完仍 FAIL 2 ⇒ 逐条查)。

🔴 副本自测的独立坑(比同步本身更隐蔽):有些判据量的是**「生产工作区部署齐全」** (交付物/目标执行状态.md、跨区 peer_workspaces 配置、本工作区的 collabd.py 副本) ⇒ 在没启用机制的工作区(无 tmp/supervise-inbox/goal.json)里必然报红, 而那不是技能有错。 ✅ 正解=加 MECH_ON 开关(判「真源文件在不在」,⛔ 不看 env 不猜),未启用时跳过并说明; ⛔ 不许改成"永远绿" —— 那是把判据废掉。 📌 另一例:判据「glob 会漏点目录」耦合了「心跳文件必须存在」 ⇒ 副本没起常驻就必红 ⇒ 改成自建夹具(造一个 .workbuddy/x/y 文件,验 glob 查不到而 exists 为真)⇒ 任何环境都成立。

📌 收口验收:双向 md5 45/45 一致 + 全局 PASS 75/FAIL 0 + 副本 PASS 75/FAIL 0。 📌 副本《副本使用说明.md》是权威口径(⛔ 别凭猜同步:它写明了「⛔ 不要整目录覆盖」的真实原因)。


P0-52 🔴 忙判据第二源:跨日文件按遍历顺序覆盖 + 没有新鲜度闸 ⇒ 已收工会话静默饿死(★ 2026-10-04 vibe-product 实测坐实)

症状:skipped="target-busy" 反复出现,目标会话明明已收工/已死活,却永远收不到上报,且无任何告警。

🔴 根因(两个,同向) —— 都在 _session_busy_conv()(会话级日志源):

  1. 跨日覆盖:_conv_log() 返回 [今天, 昨天],而取状态用的是遍历到的最后一条 ⇒ 昨天卡住的 working 把今天已经 idle 的盖掉。
  2. 无新鲜度闸:_session_busy() 有 SRSM_FRESH(900s),本源一个都没有 ⇒ 3 小时前卡住的 working 永久判"在跑"。

⚠️ 两个缺陷 fail 方向完全相同(都判busy)⇒ 后果同一条: busy ⇒ skipped="target-busy" ⇒ 静默饿死。比"恒真"更隐蔽:恒真是"不敢投"(用户看得见)。

✅ 正解:跨文件取时间戳最新的一条(⛔ 不是遍历到的最后一条)+ 补 SRSM_FRESH 新鲜度闸。 ⚠️ 反方向也要修:闸不能写太狠(窗口=0)⇒ 会把正在跑的判成空闲 ⇒ 变成"往在跑的会话插话"。 ⇒ 必须同时有反向回归判据:2 分钟前的 working 仍须判 busy。

🔴🔴 时基不一致(隐蔽坑):会话级日志行尾是 Z(UTC),工作区级是 [2026/10/4 14:15:31] (本地时间、无 Z)⇒ 解析必须分别按各自口径,⛔ 混用 time.mktime 会差 8 小时(东八区) ⇒ 「2 分钟前」被算成「8 小时前」⇒ 新鲜度闸恒判过期 ⇒ 真在跑的会话被误判空闲。 ⇒ 会话级用 calendar.timegm(UTC 语义),⛔ 不是 time.mktime。

📌 判据:t_srsm_busy ⑭~⑰(4 条:跨日取最新 / 新鲜度闸 / 反向回归 / UTC 时区) + 变异 9/9 报红(每条判据 ≥2 个变异体)。脚本 scripts/mut_run.py。

🔴🔴 本条同时纠正 P0-50 的一个错误结论(⛔ 错误结论必须从代码里删掉,不能只加新条): P0-50 与 _target_busy 注释都写「该 sid 在工作区日志里一条都没有(宿主不写)」—— 实测是错的:同一份工作区日志 SessionRunStateMachine 1935 条、含该 sid 695 条 (末条 14:15:31 AGENT_ENDED … busy=false)。 ✅ 真实成因是候选集为空:_log_dirs() 靠 WS 反推工作区名,WS 一旦落在别处 (如从别的目录 import)就拼不出任何 <ws>__*.log ⇒ 返回 [] ⇒ 恒 ''。 ⇒ 双源仍然必要(第二源不依赖 WS),但理由要换,⛔ 别再引用"宿主不写"。

📌 变异验证自身栽的两处(比判据本身更值得记): ① 等价变异:key[1] >= last_t[1](只把元组比较换成 seq)恒绿 —— 样本里每个文件内部 本来就升序,任何"取最后一条"的写法都等价 ⇒ 变异必须动语义(时间戳参与/不参与)。 ② 🔴🔴 测的不是被检对象:变异脚本把 selftest.py 复制到临时目录,却用原版绝对路径调用 ⇒ 而 selftest.py 里 imp() 加载的是 HERE/"collabd.py"(HERE 来自 __file__)⇒ 测的一直是原版 ⇒ 7/7 全"假绿"且表面毫无异常。 ✅ 正解:用工作目录里的那份 selftest.py,并断言输出里能看到该工作目录; 再加一道变异标记(# MUTANT:)⇒ 证明跑的那份真含变异。

这就是「判据自己恒绿」最隐蔽的一种:不是判据写错,是根本没测到它。


P0-52 🔴🔴 知识层记过了,执行层照栽 ⇒ 缺的不是文档,是「机器抓手」(★ 2026-10-04 收口)

症状:P0-51 早就把「变异脚本要测被检对象、⛔ 别拷到临时目录」写得清清楚楚, references/00-动手前必过.md 也立了五条纪律 —— 当天我自己照栽三次。 ⇒ ⚠️ 不是「不知道」,是「记不住」。文档对检索有效,对执行无效。

🔴 把当天的坑收敛,只有两个根因(⛔ 不是六条各自独立的失误,写流水账等于没归因):

根因 当天表现 机器抓手
A. 判据与被检对象的「连接」没被验证 判据里 lambda 自己重算;锚在「文本出现过」;夹具只覆盖顺带跑到的分支;锚点凭记忆写 judge_audit.py --audit
B. 验证动作本身出错,而我不检查它 变异脚本自己 IndexError 却照样打 PASS;多轮变异只重置一份文件 ⇒ 累积污染 judge_audit.py --mutate

⚠️ 两者的共同签名:判据绿灯与被检对象状态无关。 ✅ 正解=让验证器自己的失败变成读数(rc≠0 + 明说原因),⛔ 不许它输出得像结论。 📌 当天实测:工具报「⛔ 没报红 ❌」并 rc=1,比假的「PASS 2/FAIL 0」有用得多 —— 至少我知道要重查变异体。

🔴 ⛔ 元规则本身也会假红:--audit 扫写法,分不清「引用教训」与「犯错误」 (实测把「自建夹具证明 glob 会漏」这条教训本身判成错误用法)⇒ 误报进 FALSE_POSITIVE 且必须写清为什么,⛔ 不许默默加(默默加 = 把体温计砸了)。

📌 附带坐实:glob-existence 这条规则在 selftest.py 里命中 2759 行, 而那处正是该教训的判据本体 ⇒ 元规则会指向「反例教材」,⛔ 别把它当 bug 修掉。


P0-53 🔴🔴 合成区夹具必须把配置里的 workspace 也改成自己(★ 2026-10-04 端到端实测,白跑一轮)

症状:造合成区喂钩子 stdin,钩子 0.4 s 零输出,看着像 「钩子没生效 / 判据失效」;而同一份代码在真区是好的。

根因(实测坐实,⛔ 非推断):夹具只复制了 collabd.config.json 的字节, 没改里面的 workspace 字段 —— 它还指着真区。 ⇒ collabd.load_cfg() 加载的是真区配置 ⇒ CFG_USED 校验通过(因为 env 指合成区、 但配置内容读的是真区的 workspace)⇒ supervise_alive() 读到真区的心跳。 ⇒ 而真区常驻确实活着 ⇒ 钩子正确地判定「在位」⇒ 正确地 _noop()。

🔴🔴 最危险的地方:钩子的行为完全正确,是我的夹具在说谎。 ⇒ 如果不查下去,结论就会写成「钩子没生效」——又一次错误归因(同 P0-20 的形状)。

✅ 判据/夹具要点:

  • 合成区配置必须深拷贝后改 workspace = 合成区自己,⛔ 不许 read_bytes()/write_bytes() 原样搬。
  • 反向自检:夹具建好后先断言 supervise_alive() 在合成区报"不在位", 再去喂钩子 —— 否则测的是"真区刚好活着"。
  • 🔴 通用形状:凡是"复制一份配置来隔离"的做法,都要检查配置里有没有指向原环境的绝对路径。

P0-54 🔴🔴 判据里搜「某个字符串出现过」要剥注释;等宽要按函数体切片(★ 2026-10-04,P0-48 的同族第 2 次)

症状:新写的「单一事实源」判据假红 —— 明明 peer_supervise_sweep() 已经改成 调 goal_life_of(root),判据却报「它自己又抄了一遍 lifecycle 判定」。

根因:该函数紧接着就有一段注释,正当引用着旧写法当反面教材 (「原来这里自己判 get("lifecycle")…」)⇒ 裸串搜命中的是注释。

✅ 正解=只留代码行(line.lstrip().startswith("#") 之外的行)再判; ⚠️ 本文件通篇充满「旧写法作证」的注释 ⇒ 这条在全库范围内都会复发。

📌 与 P0-48 合起来是一条通用纪律: 判源码时,「注释」既不是代码(⛔ 别当证据),也不该被删(它是教材)⇒ 只能在判据侧剥掉。


P0-55 🔴🔴 测了零件、没测装配 —— 断言只测「那个函数返回什么」,⛔ 不测「它被用上了没」(★ 2026-10-04 变异验证当场抓到)

症状:为守新加的「退役角色闸」写了 4 条断言,PASS 88 / FAIL 0 全绿; 把闸从 _scan_mains() 的筛子里整段拆掉(变异①)⇒ 判据照样全绿。 grep -c MUTANT = 1 证明变异真植进去了 ⇒ 那个"全绿"是假的。

根因:4 条断言全部形如 m.is_retired_role_title("[跟进]-X") —— 测的是零件本身(小函数对几个样本返回什么),没有任何一条测到 「这个闸有没有被接到筛选逻辑上」。⇒ 一个恒绿的判定标准。

✅ 正解=行为级用例:造一个真宿主库(含一条 [跟进]-… + 一条真主会话 + 一条正牌 worker), 真跑一遍 _scan_mains(),看结果里有没有那条退役角色。 三条断言缺一不可:① 退役角色不进候选 ② 真主会话不许被误排(反例对照)③ worker 仍被排(旧口径未被破坏)。

⚠️ 同族第 2 个坑(同一轮里连踩):夹具 insert 时把 custom_title 写成 "", 而查询是 coalesce(custom_title, title, '') —— 空串不是 NULL ⇒ 不回落到 title ⇒ 每条会话拿到的标题都是空串 ⇒ 用例恒绿而毫无意义(第二次"测了零件没测装配")。 真库里该列实测是 None ⇒ 夹具必须照抄真库形态(= P0-53 的同一个形状)。

📌 通用形状:加了「闸/过滤/开关」这类东西,必须有一条断言走完整装配路径; 只测开关函数本身 ⇒ 拆掉接线也能全绿。变异验证是唯一能抓到它的手段 (⛔ 别用"我读了一遍代码觉得接上了"代替)。

P0-57 🔴🔴 启动脚本没跟着改口径 ⇒ 本区发布物成了「孤儿副本」,规则形同虚设(★ 2026-10-04 用户点破:「让它跑本工作的那份,不是跑全局技能里的那份」)

症状:某工作区常驻反复死亡重起(_collabd.log 7 次「常驻续命」), 主会话开工第 0 步自查时发现:心跳 argv0 指的既不是本区发布物,也不是「全局技能」, 而是「本区技能副本」 —— 看起来像"跑对了",实际判据②不过。

根因(两层,缺一不可):

  1. 引用没跟着口径改:tmp/start_supervise.py 第 11 行写死 WS + "/.workbuddy/skills/session-mechanism/scripts/collabd.py"(技能副本), 而口径(deploy_code.py 开头,用户 10-03 定案)要求各跑 <工作区>/.workbuddy/collab/collabd.py(本区发布物)。
  2. deploy_code.py 只管分发、⛔ 不管"谁引用它" ⇒ 文件铺下去了,没人指向它。

🔴 全工作区 grep 的实测结果:引用本区发布物的只有 0 处(除了注释与文档里的说明文字)。 ⚠️ 唯一引用它的载体 start-supervise.ps1 又从没执行过(首行被误打成三引号 ⇒ 语法错 9 处秒退 + 从没登记计划任务)。

为什么难查:文件在 ≠ 有人跑它。三条自查只做「md5 一致 + 心跳存在」会全绿, 必须加第三条:grep 启动脚本引用的路径。今天就是这么漏的。

✅ 修法(三层一起补,⛔ 少一层就复发):

  • ① 启动脚本改成两段式:优先本区发布物,缺了才回落技能副本,且必须打印告警(⛔ 静默回落=用户以为跑的是副本)。
  • ② 修完必须重启常驻 —— 判据仍看 argv0(⛔ 改文件不重启=没生效,P0-17 同族)。
  • ③ 开工第 0 步自查加第三条:grep 启动脚本(tmp/*.py、*.ps1、计划任务 Args)⇒ 路径逐字是本区发布物。

📌 通用形状:「改了目录结构/改了落点」之后,必须回头检查「谁引用它」 —— 引用点常在配置、启动脚本、计划任务参数里,⛔ 不在被移动的那个文件里。 同族:P0-51(副本分叉)、P0-17(改完不重启)。

P0-62 🔴🔴 常驻「自我繁殖」:续命时照着「当前这份」抄 ⇒ 弹窗洪水 + CPU 白烧(★ 2026-10-05 用户投诉:「越创建越多 要创建 1W个嘛」)

症状:用户屏幕上不断弹终端窗口 —— 实测一次盘点出 7 条 keeper + 10 条常驻 + 1 个 .cmd, 且技能目录那份 collabd.py --supervise 反复复现(父进程每次都不同、且已消失 ⇒ 看着像幽灵自启)。 用户追问:「这个常驻一直在运行 它不占用CPU 不占用资源吗」。

铁证(_collabd.log 逐字):

常驻续命:新起 --supervise pid=69464(原因:pid 12672 在、心跳新鲜,但**身份对不上**(不是本区 collabd 常驻))

🔴 注意这行的荒诞之处:本区常驻(12672)明明活着、心跳新鲜,却被判「身份对不上」⇒ 又拉一条。

根因(两层,同为 Path(__file__).resolve() 一个写法):

  1. ensure_supervise() 起常驻时填 Path(__file__).resolve() =「当前正在跑的那一份」; 若它本身是技能目录那份(P0-57 形态)⇒ 续命又起技能目录那份 ⇒ 互为镜像、生生不息。
  2. _pid_is_collabd() 判「这 pid 是不是本区常驻」的基准同样被技能目录那份顶着 ⇒ 真的本区常驻反被判「不是我」 ⇒ 每轮 --ensure 都觉得"没人跑" ⇒ 再拉一条。 (与 P0-57 是同族病、不同位置:那个在"生成 ps1",这个在"起进程"与"判身份"。)

放大链路(为什么用户看到的是"弹窗洪水"而非单纯多几条进程): 每条常驻都挂在一条 keeper(start-supervise.ps1 → powershell.exe)下, keeper 用 Start-Process 拉起子进程时闪一次窗 ⇒ 常驻越多 ⇒ keeper 越多 ⇒ 弹窗越多。 🔴 CPU:主循环每 supervise_interval(本区 10 秒)转一圈 ⇒ 每多一条常驻就多一份 10s 节拍 ⇒ 17 条同时转=用户感知的"高占用"。 ⚠️ 单条其实很轻(实测 0.020.09 s CPU / 20 s ≈ 0.10.5% 单核)⇒ 「常驻吃 CPU」的真因是条数,⛔ 不是单条本身 —— 排查时先数条数,⛔ 别去优化循环体。

✅ 修法=抽唯一基准 _own_path()(发布物优先),三处共用:

def _own_path() -> Path:
    """本区「正宗」的 collabd.py —— 发布物优先,⛔ 不是 `__file__`。"""
    pub = Path(WS) / ".workbuddy" / "collab" / "collabd.py"
    if pub.is_file():
        return pub
    return Path(__file__).resolve()   # ⚠️ 回落必须打告警(⛔ 静默=用户以为跑的是副本)

接线三处:① ensure_supervise() 的 Popen 目标 ② _pid_is_collabd() 的身份基准 ③ _escalate_to_keeper() 的 __SCRIPT__。

🔴🔴 一句话判据(记住这条就够):

凡是回答「以哪份程序为准」的地方,一律走 _own_path(); ⛔ 严禁 Path(__file__).resolve() —— 它答的是「正在跑的那份」,而那可能正是错的那份。

📌 通用形状:「当前这份」不是一个可靠的基准 —— 进程可能由错的路径被拉起。 任何「自我复制 / 自我补起 / 自我校验」的逻辑,都必须先钉一个外部锚点 (这里是"本区发布物路径"),⛔ 不能拿"我自己的位置"当锚。同族:P0-57、P0-51。

🔴 回归资产:t_ensure_spawns_published_copy(6 项)+ 变异对照已验红 (把 str(_own_path()) 换回 str(Path(__file__).resolve()) ⇒ 必红)。

⚠️ 排查顺序(省时间):症状是"弹窗多/CPU 高"时,按此序取数,⛔ 别一上来就读代码: ① Get-CimInstance Win32_Process 数 collabd 常驻条数(⛔ 不是看"有没有在跑"); ② 看每条的第 4 个参数文件路径 —— 有没有技能目录那份(有 ⇒ 就是本条); ③ 数 keeper 条数(start-supervise.ps1)—— 它≈弹窗数; ④ 读 _collabd.log grep「身份对不上」—— 出现即本条确诊。

P0-56 🔴🔴 把「目标状态」当成「该不该处理队列」的前置条件 ⇒ 队列里的活永远没人接(★ 2026-10-04 用户点破)

症状:某工作区 _collabd.log 刷屏同一行 —— 检查会话:目标状态=已完成(非进行中)⇒ 不建; 而它的 tasks.json 里躺着没干完的件。⇒ 表面上"检查程序在正常运行、只是没到条件", 实际是有活没人干,且永远不会有人干。

病根(一行):maybe_spawn_check_agent() 的闸① 写成了

if life != GOAL_LIFE_RUN:        # ⛔ 错
    log(...); return None

它让「目标已被标成完成」成为「处理队列」的前置条件 —— 逻辑不通。

用户口径(逐字,2026-10-04):

「首先执行程序要把执行结果 的文本地址或引用写入检查程序的队列, 检查程序必然要去处理队列的情况(是等待工作区所有会话停止后,把队列情况一并处理)」

⇒ 拆成三条,逐条对:

用户的话 机制里的对应 当时状态
执行结果要写地址或引用入队列 --report --state done 缺 --artifact 直接拒收 ✅ 早已落地
检查程序必然要去处理队列 闸①(目标须「进行中」) 🔴 被挡死 —— 就是要改的那一处
等所有会话停止后一并处理 _all_sessions_idle() ✅ 早已存在,对得上

🔴 闸① 为什么只该捆在 queue-empty 上:两类检查会话职责不同 ——

  • sessions-ended(队列非空)= 面对「有活没人干」⇒ 与目标状态无关:活没干完就是没干完。
  • queue-empty(队列为空 + 静默 ≥20 min)= 面对「没活了但目标可能没完」⇒ 它的职责正是判目标该不该收口 ⇒ 目标都不是「进行中」了,这条没有意义 ⇒ 保留闸①。

为什么难查:日志说的是实话("目标状态=已完成"),措辞也像正常的分支说明 ⇒ 一眼看过去像"按设计拦下了",⛔ 不像故障。当天有会话据此写下"这是设计,不是故障"的结论 —— 被用户当场否掉。 📌 教训:日志如实描述条件,⛔ 不等于那个条件是对的。读到"某条闸拦下了"时, 必须回头问一句:这条闸该不该捆在这个条件上?

✅ 修法:

  • ① 闸① 收窄 ⇒ if reason == "queue-empty" and life != GOAL_LIFE_RUN:(唯此一处)。
  • ② 跨区自愈同理:peer_supervise_sweep() 里"目标非进行中就不拉"的判据一并收窄, 改成先看队列有没有未完成件(有活仍拉)。
  • ③ 🔴 抽唯一事实源:新增 queue_pending_of(root) —— 跨区队列计数只此一份实现。 ⛔ 别在自愈代码里另写一段读 tasks.json 的代码(那正是 goal_life_of() 当初被抽出来要治的病; 用户口径「禁止重复判定标准」)。
  • ④ 试算(--dry-run)必须与真实闸逐条对齐 —— 改闸不改试算 ⇒ 试算说"不建"、实际"建"(自查发现)。
  • ⑤ 端到端 + 变异对照:⛔ 只 grep 源码不算证。造临时区真跑两条 reason (同场景对拍:sessions-ended 必须放行/queue-empty 必须拦下), 再植变异回写 if life != GOAL_LIFE_RUN: ⇒ 断言必须报红(证明判据不恒绿)。

📌 通用形状:「闸」捆错条件是静默故障的高发区 —— 闸描述的是「条件不满足就不动」,⛔ 它不告诉你条件本身选得对不对。 同族:P0-50(忙判据只读一个源)、P0-55(测了零件没测装配)。

P0-58 🔴🔴 只查「有没有写」⛔ 不查「那个文件在不在」⇒ 台账里的 done 指不到东西(★ 2026-10-04 用户点破第二条)

用户口径(逐字):

「之前还说过 执行会话的结果要形成文档,目标也要完成情况的文档, 这样后续检查会话和后续执行会话都可根据文档继续处理,避免全工作区到处找信息」

半落地的形态(这是本条最值得记的地方 —— 不是"没做",是"只做了一半"):

  • ✅ 写入侧有闸:--report --state done 缺 --artifact 拒收(10-03 立)。
  • 🔴 读取侧不验:只查 artifact 有没有写(非空即放行),⛔ 不查文件是否真存在。 ⇒ 路径打错的/产物被移走的/绕过闸写进去的旧数据,照样算 done ⇒ 检查会话照它读 读空 ⇒ 又回去到处找 —— 正是要避免的那件事。

🔴 活证据(本区台账,实测):

{"t": {"state": "done"}}          ← 没有 artifact/by/t_end,task-events.jsonl 里也没记录

它比拒收闸(10-03)还早 ⇒ 是绕过闸写进去的。 📌 教训:写入侧的闸管不了"已在库的" —— 闸只对新写的生效。 凡"加了闸"的地方,都要再问一句:库里已有的那些怎么办?

当年为什么没查(旧注释逐字,保留以示"理由也会过时"):

「判据只查台账里有没有产物字段,⛔ 不判断文件是否真存在 —— 那是检查会话的活; 在这里查路径会把「相对工作区」写法全误杀」

⇒ 那是把两件事混成一件:「相对路径」从来不是问题,路径拼错/产物没落盘才是。 ⇒ 用"怕误杀"换"不验收",代价是缺口一直开着。

✅ 修法(存在性=硬闸,落点=软判据,⛔ 两层别混):

  • ① artifact_state(tid, rec) = 唯一事实源(ok/missing/gone): 按工作区解析后真去 stat(两种斜杠形态都试),⛔ 不抛异常(一律落 gone 并附原因)。

  • ② --report done 加硬闸:artifact_state() != ok ⇒ 拒收并打印试过的路径 (⛔ 不猜、⛔ 不静默放行)。

  • ③ artifact_dir_ok() 落点判据 = 软判据(⛔ 不拒收): ⚠️ 为什么软的:存量里有落在别处但有效的产物(designs/*.html 等)⇒ 硬拒=误杀既有工作流。 但必须打印提醒 + 写日志 —— ⛔ 静默通过=这个合同等于没立。

  • ④ 🔴 artifacts_unverifiable() 由机制侧预挑,渲染进检查会话 prompt: ⛔ 别让会话自己扫台账判断(每条会话重算、标准还可能不同 ⇒ 与 goal_life_of() 同病; 用户口径「禁止重复判定标准」)。

  • ⑤ 文档合同写进机制(DOC_CONTRACT_ROWS)—— 三份文档各有唯一作者与唯一时机:

    文档 谁写 什么时候
    <目标目录>/目标执行状态.md 目标检查会话 核对完目标状态后
    <目标目录>/<棒次>_<事项>_<日期>.md 执行会话自己 本轮做完时(--report done 之前)
    tasks.json --report(机器) 每次状态转移

    🔴 为什么合同必须有:实测三区三种形态 —— 某区只有状态文档(420 B,执行产物没落这儿)/某区有手工写的「过程记录」(非机制要求)/ 本区 S12_*.md 落对了。同一套机制、三个区三种形态 ⇒ 缺的不是能力,是合同。

判据(本条的验收,缺一不可):

  • 行为级:临时区里真跑 —— 真产物 → ok;没写 → missing;路径不存在 → gone; --report 对这三种分别 放行/拒收/拒收。
  • 变异对照:把「验存在」短路 ⇒ 「给了不存在路径」必须从 rc=3 变 rc=0(证明判据不恒绿)。
  • 🚨 夹具必须真造出那个文件 —— ⛔ 不能再用 artifact="x/T3.md" 这种占位路径 (那正是"报了个打算写的路径"这个要治的病本身;本条的旧自检就栽在这)。

📌 通用形状:「加了写入闸」≠「数据干净了」 —— 闸只拦新写的;存量要靠"读取时判"来兜。同族:P0-52(缺的不是文档,是机器抓手)、 P0-54(剥注释要用 AST,⛔ 不用正则 —— 本条连带修出第 3 次)。


P0-59 🔴🔴 判据与设计目标互斥 ⇒ 每轮必报红:口径改了两周,体检里还在找"已退役的周期钟"(★ 2026-10-04 vibe-product 实测)

症状:session-rules-check.py 每轮都报 ✗ [C] 周期钟缺失 ⇒ 没人在推 / 收 —— 唤醒、跟进, 而这两类会话用户早在 2026-10-03 就要求整套退役(SKILL.md 文首口径块写着「唤醒会话/跟进会话/队列上报 整套退役」)⇒ 判据惩罚的正是"按用户要求删对了的东西"。

🔴 这是"判据与设计目标互斥"最纯粹的一例(与 P0-24 的"不可满足的判据自己就是逃生门"同族): 判据在任何工作区都过不了 ⇒ 每轮红 ⇒ 读报告的人学会的不是"去改",而是"这条不用管" ⇒ 一条恒红的判据会把整张体检表一起废掉。

✅ 修法=换尺子(⛔ 不是删判据): · 旧:"唤醒/跟进周期钟必须存在" —— 设计要求它们不存在 ⇒ 互斥。 · 新:"在册的退役周期钟必须已 PAUSED" —— 还在 ACTIVE 才说明退役没做干净。 即把"要求有"翻成"有了就不许是活的",判据方向跟着口径一起翻。 · 🔴 同时要修它的下游:⑧「模型可用性」原写 for lst in clocks.values(), 退役后 clocks 直接不存在 ⇒ ② 若只改 ⑦ 不改 ⑧ ⇒ NameError 让整个体检抛异常 (实测:⚠️ 体检自身异常:name 'clocks' is not defined,体检静默失效——比报红更糟)。 ⇒ ⑧ 改为扫本工作区全部 recurring 排期(覆盖面只增不减)。

⭐ 顺带治掉一个"恒绿假通过":⑧ 原本只扫"周期钟",而退役后本区 recurring 可能一条都没有 ⇒ clocks 为空 ⇒ deaf=[] ⇒ 恒判 ok,哪怕真有 flash + thinking=0 的排期在跑。 ⇒ 改成扫全部 recurring 之后才抓得住(变异对照:合成库放一条 glm-4-flash ⇒ 正确报 ✗)。

📌 变异对照(本区真数据测不出,因 recurring=0 ⇒ 必须用合成库):

场景 期望 实测
退役排期 status=ACTIVE ⑦ 报 ✗ ✅ ✗ 已退役角色的周期钟仍在 ACTIVE
退役排期 status=PAUSED ⑦ 报 ✓ ✅ ✓ 已全部 PAUSED
recurring + flash + thinking=0 ⑧ 报 ✗ ✅ ✗ 周期排期会被服务端拒

⚠️ 测法自身的坑(我又踩了一次):首版变异脚本三个场景复用同名临时目录 ⇒ 场景 B 的文件没清,场景 C 的库里混进两条 ⇒ _recur 计数被覆盖 ⇒ 假报"⑧ 恒绿"。 ⇒ 造合成库必须一场景一目录;⛔ 别拿"上一次的输出"当这一场景的结论。 (这与 P0-39「自测不许复用被测环境」是同一个病:隔离不彻底 ⇒ 绿红都不可信。)

🔴 推广判据:凡改了口径(退役 / 改名 / 换供给),必须回扫所有"体检 / 自检 / 门禁"里 是否还有指向旧口径的判据 —— 它们不会自己报错,只会每轮安静地红着(或更糟:安静地绿着)。 本次真因就是:改 SKILL.md 文首口径块时,没有连带扫 scripts/session-rules-check.py 的判据数组。


P0-60 🔴🔴 DETACHED_PROCESS 不脱离作业对象 ⇒ 常驻的「5 秒回验」量不出真相(★ 2026-10-04 vibe-product 实测)

症状:用户报「你的检查程序怎么老停止啊」。日志呈现固定节律 —— 起一条 → 心跳正常跑 20+ 轮(≈10 分钟)→ 下一轮消息进来时报「pid XXXX 已不在进程表」→ 续命新 pid。 看着像:常驻自己在崩。实际是:被外力杀。

真因(一句话):ensure_supervise()(collabd.py:547)用的 flags 是 NO_WINDOW | DETACHED_PROCESS(0x8) | NEW_PROCESS_GROUP(0x200)。 🔴 DETACHED_PROCESS 只脱离控制台,⛔ 不脱离作业对象(Job Object) ⇒ 它只是「看起来脱离了」, 实际仍挂在 WorkBuddy 会话的作业对象上 ⇒ 会话树一被回收,它一起被杀。 (CREATE_BREAKAWAY_FROM_JOB 被本机拒绝 ⇒ 这条路封死,别再试改 flags。)

取证(唯一可信) —— workbuddy-resident-service/scripts/proc_chain.py <pid>:

PID 72088   in_job=True  在作业对象内      ← 🔴 决定性:还在作业里
  └─ pythonw.exe
  PID 468 —— 已退出

🔴🔴 本条最值钱的一句:回验窗口必须 ≥ 被观察现象的时间尺度。 supervise-ensure-hook.py 的 verify=True 路径只回验 5 秒(for _ in range(12) × 0.4 s)。 而本现象要 10 分钟才显形 ⇒ 5 秒窗口里它「验证通过」了,然后 10 分钟后死掉 —— 这是恒绿的假通过(与 P0-59 同族:判据本身没问题,量法的时间尺度错了)。

⇒ 判「常驻健不健康」的正确量法:静置 ≥2 个会话轮次后,再问 supervise_alive()。 ⛔ 不许用「起完立刻验一次活着」当结论。

同型的另外两条(一并记住):

  1. 别把「现在活着」当「这种起法能长期」 —— pid 19424 曾活 24.2 h,只因为它的父链已退出 (孤儿化后不受会话结束影响)。⇒ 要判「还能活多久」=先验父链 + 读心跳 started_ts。
  2. 判「是不是被正常停的」要看 guard.stop —— 它在 ⇒ supervise_stop() 走的收工流程(正当); 它不在 + 进程没了 ⇒ 被外力杀(本轮的判定路径就是这条)。

口径边界(⛔ 别搞反):本现象不是故障。supervise-persistence.md 口径明写: 常态=「调用技能完成目标时起后台任务 + 检查程序」,会话一收工就断,断了自己会补 (实测补起延迟 1~2 分钟)。⭐ 只有「目标做完还要继续跑」才需要计划任务那一层 —— 且那层是可选,⛔ 不许当欠项摊(用户 2026-10-04 当场订正过)。


P0-61 🔴🔴🔴 全局钩子只认「一个」工作区 ⇒ 其余区的常驻全靠会话 cli 拉,会话一收工就被回收(★ 2026-10-04 vibe-product 实测)

用户原话:「为什么就这个工作区一直在出问题,别的会话我看一直没有停止过?」

症状差异(实测同刻对比,ai1net-dsh-server vs vibe-product):

ai1net-dsh-server(一直活着) vibe-product(每 10 分钟被续命)
心跳 round 38(23:22:44 起) 13(23:22:44 起,同一时刻起的)
续命 why tick cli(10 次全是)
本区 hook.log 有 🔴 根本不存在(钩子从没往这区写过一行)

真因(一行):wb-result-hook.py(全局 hooks 目录唯一一份)用 resolve_ws() 解析出一个工作区,然后 INBOX / TICK_STAMP / HOME_WS 全部按它定死:

WS = resolve_ws()                       # 🔴 全局只有一份钩子 ⇒ 只会解出一个区
INBOX = os.path.join(WS, "tmp", "supervise-inbox")
TICK_STAMP = os.path.join(INBOX, "_tick.stamp")

实测该函数解析结果:

WS    = 'E:/ProgramData/AIProject/ai1net-dsh-server'      ← 只认这个区
INBOX = '…\ai1net-dsh-server\tmp\supervise-inbox'

⇒ --tick(顺手续命)只对解析出来的那一个区生效。 其余区(含本区)的 classify() 落到 scope="other",钩子不为其续命, 常驻只能靠 ensure_supervise("cli")(=从会话钩子/我的命令行起)⇒ 挂在会话的作业对象上 ⇒ 会话一收工就被回收(实测:起后跑 20+ 轮 ≈10 分钟,下一轮消息进来时已死)。

⚠️ 别再往"作业对象"上归因:实测两区的常驻都是 in_job=True(proc_chain.py 现跑)—— 作业对象是共同机制,不是差异点。差异点=「谁拉起它」: tick 起=脱离会话(拉它的常驻长期活着);cli 起=绑在会话上。

取证方法(三步,缺一不可):

  1. 同刻对比两区心跳:round 与 started_h —— 同一时刻起、round 差一倍 ⇒ 一区被回收过。
  2. 看续命 why:grep "常驻续命" <区>/logs/_collabd.log | tail —— why=tick 健康,why=cli 病态。
  3. 看该区有没有 hook.log:tmp/supervise-inbox/hook.log 不存在 = 全局钩子从没管过这个区。 🔴 这是最快的一步(一条 ls 定案)。

修法(⛔ 别改 flags、别改 creationflags —— 那治不了):让常驻由常驻拉起,而不是由会话拉起。 即本区要有自己的"顺手续命"入口:要么把本区纳入 resolve_ws() 的解析范围(多区支持), 要么本区自建一个不依赖会话的入口(见 supervise-persistence.md 场景 A/B 的载体)。 ⚠️ 在修好之前,本现象=口径内的常态(会话收工时断、下次事件补回),⛔ 不是"机制坏了"。

同族推广判据:凡「全局唯一一份脚本 + 内部按某个环境量解析出'当前工作区'」的写法,都要问一句: "它解析出的到底是谁?其余实例怎么办?" —— 这类缺陷不会报错, 只会让非默认的那一方长期以"降级方式"运行,且看起来一切正常(心跳在跳、日志在写)。


P0-63 🔴🔴 计划任务直起 pythonw.exe ⇒ 秒退 Result=1、端口从没绑上(★ 2026-10-05 看板载体实测)

现象(连复现 3 次):dsh-board-20099 的动作= pythonw.exe -u "…\board.py" --serve 20099 ⇒ 每次 Start-ScheduledTask 都 秒退、LastTaskResult=1、端口从未听到。任务 State 立刻回到 Ready。

排除项(逐个做掉,别跳):

  1. 代码没问题 —— 加一层 runpy wrapper(同一 pythonw.exe、同一参数、同一工作目录) 跑 board.py ⇒ LastTaskResult=**267009**(=0x41301「任务正在运行」), 日志里明写 看板已起:http://127.0.0.1:20099/。
  2. 命令行没问题 —— (Get-ScheduledTask).Actions[0] 逐字打出来(含 ConvertTo-Json) 与手工串完全一致,无多余引号、无 BOM 残留、无转义问题。
  3. 同一串命令由 Python Popen 起 ⇒ 正常绑端口(poll=None、netstat 见 LISTENING)。 ⇒ 差别不在命令、不在代码,在「谁拉起它」。

真因:任务计划程序直接拉起 GUI 子系统程序(pythonw.exe)这条路不可靠。 同族证据就在本技能的 start-supervise.ps1 文件头(早被踩过): ⛔ & $pyw … 是调用运算符、不阻塞 GUI 子系统程序 ⇒ 脚本以为"它退出了"; ⛔ .ps1 不带 BOM ⇒ PS 5.1 按 GBK 解码 CJK 路径 ⇒ 任务一秒内 Result=1 退出。

✅ 正解=照 collabd 已验证的 keeper 形状(⛔ 别自创):

  1. 任务动作改成 powershell.exe: -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "<keeper>.ps1" (⛔ 不加 -NonInteractive;⛔ 动作里不要出现 pythonw)。
  2. .ps1 里用 Start-Process -FilePath <pythonw> -ArgumentList … -PassThru -Wait —— -Wait 给的是真阻塞(这是 keeper 能"守着"子进程的前提)。
  3. .ps1 必须存成 UTF-8 带 BOM(encoding="utf-8-sig",行尾 \r\n)。 落盘后立刻自检前 3 字节 = \xef\xbb\xbf,并用 [System.Management.Automation.Language.Parser]::ParseFile() 过一遍语法。
  4. keeper 内部:端口上已有本看板 ⇒ 只睡不重拉(判据=/healthz 同时含 "ok" 与 "snapshots", ⛔ 不是「端口开着」)⇒ 避免叠实例。

验收判据(全绿才算完,⛔ 缺一不可):

  • (Get-ScheduledTask).State = Running
  • LastTaskResult = 267009(0x41301)—— ⛔ 不是 1、⛔ 不是 0
  • 端口 LISTENING 的 PID 属主是 pythonw.exe,且 CommandLine 指向该跑的那一份脚本
  • 业务面:目标真值/main_by_topic 等在快照里真的出现(⛔ 不看"端口在听"就当成功)

排查顺序(最快路径): ① (Get-ScheduledTask -TaskName X | Get-ScheduledTaskInfo).LastTaskResult ② 看是不是 1 且任务秒回 Ready ⇒ 基本就是这个坑 ③ 用 wrapper(runpy)复跑一次:若 wrapper 绿、直起红 ⇒ 定案

同族推广判据:凡「计划任务/服务管理器/CI runner 直接拉起"无控制台"的程序」的写法,都要问一句: "宿主真能把它的生命周期管住吗?" —— 这类缺陷不报错、不留日志, 只在调度器那侧留一个 Result=1,而手工跑一切正常,极易误判成"代码坏了"。

P0-64 🔴🔴🔴 改完技能包 ≠ 生效:常驻跑的是工作区里的「部署副本」,副本没同步 ⇒ 修复对运行中的进程完全不可见(★ 2026-10-05 看板通过率实测)

现象:board.py::acc_is_pass 已改好、selftest 全绿、跑探针也绿, 但页面上「完成情况」依旧 0 / 9 通过;直接 import board 调 acc_is_pass('已过|…') 返回 True,可服务端 /board.json 的 acc_summary 仍写着 非 pass:['V1'…'V9']。

真因(两层,都要治):

  1. 同一份代码存在「技能包」与「工作区部署副本」两份,而常驻进程加载的是副本:
    • board.py 由计划任务起,argv0 = 技能目录那份 ⇒ 改了立刻生效 ✅
    • collabd.py 由 start-supervise.ps1 起,argv0 = .workbuddy/collab/collabd.py(工作区副本)⇒ 改了技能包它不知道 ❌
    • 实测 Get-CimInstance Win32_Process 逐条 argv0 才看清: pid 66620 → …\ai1net-dsh-server\.workbuddy\collab\collabd.py --supervise。
  2. 副本可能停在很久以前:本区副本的 board.py md5 与技能包差 46 行、 collabd.py 少 718 字节,副本里 ACC_ADV_PREFIX 一次都没出现(grep -c = 0)。 ⚠️ 副本不是被人手改过(diff 显示差异只有我们那几处新代码)⇒ 是纯陈旧快照, 属于 P0-51「修完全局忘了同步」的同一族第 N 次,只是这次栽的是运行中的常驻不是副本自检。

✅ 正解(四步,⛔ 缺一不可):

  1. 先取证「谁在跑哪一份」:Get-CimInstance Win32_Process 列 python% 的 ProcessId + CommandLine(⛔ 别靠"我以为它读技能目录")—— 一个工作区里 board 与 collabd 可能来自两个不同位置。
  2. 同步副本:技能包 → <ws>/.workbuddy/collab/{board.py,collabd.py}, 先 shutil.copy2 备份成 *.bak-sync-<时间戳>,再写临时文件 + os.replace 原子替换, 最后逐字 md5 复核(⛔ 不靠"我复制过了")。 ⚠️ workspace_mirror.py 管不了这个目录 —— 它要求副本里有 SKILL.md, 而 .workbuddy/collab/ 是部署目录不是技能副本 ⇒ 会直接 ⛔ …不像技能副本 ⇒ 不动 (本目录曾因此静默跳过,--check 报 97 项漂移却没人看)。
  3. 重启常驻:Stop-ScheduledTask → 杀旧 pid → Start-ScheduledTask; 验收=心跳里出现新 pid + argv0 指向本区副本 + 任务 Running/267009。 ⚠️ 只同步不重启 ⇒ 进程内存里仍是旧 bytecode(实测就是这个状态。 铁证:本机 board._acc_summary() 返回「全部 pass(9 条)」而服务端返回旧的 非 pass 列表 —— 同一份磁盘代码,两个结果 ⇒ 差的一定是"谁在跑")。
  4. 判据落到进程级:确认新 pid 用的副本真含新逻辑(在本区副本目录里 import 它自己的模块跑一批用例 + 与 board.py 交叉核对零打架)。

排查顺序(最快路径): ① 页面/接口不对,但 import 直接调对 ⇒ 立刻怀疑"跑的不是我改的那份" ② Get-CimInstance Win32_Process 抓 argv0(比猜快 100 倍) ③ 对副本与技能包做 md5 + grep 新代码关键字(grep -c 为 0 ⇒ 定案) ④ 同步 → 重启 → 复核

同族推广判据:凡"技能包改了、跑起来却没变",一律先问三句: ① 这个进程的 argv0 指向哪份文件? ② 那份文件 md5 对不对? ③ 它重启过没有? —— 这三问能一次排掉「改错地方 / 副本陈旧 / 没重启」三类,而它们症状完全一样(都不报错、都静默)。

P0-65 📌 「一次性排期」(schedule_type='once')= 机制唯一开新会话的通道,闸④ 防它把闸卡死(★ 2026-10-05 只读取证)

  • 它是什么:automations 表里 schedule_type='once' 的行 —— 排一条未来某刻开一条新会话的「闹钟」, 触发一次即作废(⛔ 不像 recurring 会再来)。本机实测在册 37 条。
  • 谁排的(两个创建者,⛔ 都不是"会话自己"):
    1. 常驻程序 collabd.py::create_check_schedule()(第 ~3894 行)—— 唯一的检查会话建排期入口 (用户第④条:「创建者是协作程序,不是会话」)。固定 schedule_type='once'、延迟默认 90s。 实测产物=[检查]-[结果检查]-selftest-第N棒(11 条)。
    2. _gap_plan(role, topic, topics)(第 ~5331 行)—— 缺会话时给出"该怎么拉起"的现成参数 (scheduleType='once' + scheduledAt=now+90s)。⚠️ 它只生成参数,真正落库的那一步在别处。
  • 干什么用的:开新会话。🔴 关键口径(本包红线):自动化是"开新会话"的唯一通道 ⇒ 想让某件事在未来某个时刻由新会话接手,就必须落一条 once 排期。 实测用途:检查会话(自检棒)、任务会话([协作]-[界面交互]-第三步重跑)、跟进会话。
  • 闸④ 是干什么的:ws_pending_schedules() 的判据之一 —— 本工作区只要还有"待执行"的排期,就 ⛔ 不再建检查会话。 防止:排期已排好、会话还没到点开 ⇒「会话全结束」成立 ⇒ 再建检查会话=同一件事两遍。 ⚠️ fail-safe 方向=不建(读库失败 ⇒ 返回非空 ⇒ 当"有排期"处理)。
  • 为什么必须"跳过不会再触发的一次性排期":once 排期到点触发后,宿主会把 next_run_at 清成 NULL, 但 status 仍 ACTIVE、last_run_at 仍 None ⇒ 一条已经开完的排期看起来还像"待执行" ⇒ 它会永远把闸④ 卡住 ⇒ 本工作区再也建不出检查会话(静默、无报错)。 ✅ 两种"不会再触发"都判:(a) next_run_at 已过 15 分钟宽限;(b) next_run_at 被清成 NULL。 ⚠️ 只对 once 生效(recurring 永远会再触发,⛔ 不按过期排除)。
  • 实测取证(2026-10-05 只读):闸④ 对 selftest 工作区跳过 11 条、vibe-product 跳过 1 条, 全部是 next_run_at=NULL(已消费) 这一类 —— 正是 (b)。若不跳过,这两个工作区各自永远卡死。
  • 🔴 已知隐患(未修,待拍板):工作区判据是 子串匹配 p.lower() in _s.lower()(第 ~4464 行), 而 pats 里有裸工作区名 ⇒ vibe-product 会命中 vibe-product/tmp/selftest 这类子目录的 cwds ⇒ 可能把别的目标的排期算成本区的 ⇒ 闸④ 误拦(少建检查会话)。 ✅ 正解方向=比 cwds 数组的逐字同形(同 _same_ws,两侧都 norm 后 ==), ⛔ 不是子串 in。⚠️ 改它要同步 _all_sessions_idle 的同类写法(同一族判据)。

P0-66 · --tick 已删(2026-10-05):判定的权威在常驻,⛔ 不在钩子

用户原话:「检查程序 常驻 自己不会判断吗,非要什么 tick once 去触发?」

判据:需要「什么时候该开检查会话」这种判定 ⇒ 只许由 --supervise 主循环承担; 钩子⛔ 不得是任何判定的触发源(钩子只是"事件到了顺手续命")。

删 tick 时必须搬走的三件事(漏一件就断链)

  1. 建检查会话排期的 sessions-ended 腿 ⛔ 它原先只挂在 --tick ⇒ 删了 tick 就永远建不出"结果检查"会话(队列非空=有活没人干)。 ✅ 现在在 --supervise 主循环里:if queue_pending() > 0: sessions-ended else: queue-empty
  2. park 指纹探针(parkInQueue + hasWaiter=false)⇒ 抽成 _tick_park_probe(),常驻每 20 轮调 ⚠️ 它不依赖投递 ⇒ 投递退役后仍有效,⛔ 不许跟着 tick 一起删
  3. 顺手续命常驻 ⇒ 钩子改调 maybe_ensure_supervise() ⇒ collabd.py --ensure 🔴 只留 UserPromptSubmit 一处(PreToolUse 极高频 ⛔ 不补;SessionEnd 从不被投递=死代码)

⛔ 参数表里不许留 "--tick"

留着 ⇒ 传 --tick 会不被拒绝、落进"无分支匹配"⇒ 静默退出/超时(实测 TimeoutExpired)。

🔴 once ⛔ 不许删(与 tick 无关,是主干)

create_check_schedule() 落 automations(schedule_type='once') = 唯一「开新会话」的通道。 删它 ⇒ 检查会话永远开不出来。闸④(ws_pending_schedules)防的是它卡闸,见 P0-65。

自测判据的两条死穴(本轮实测踩到)

  1. ⛔ 字面判 "--tick" not in <源码> ⇒ 永久假红:删除说明的注释里必然提到它 (P0-13 同族)⇒ ✅ 只认带引号的 "--tick",⛔ 不认裸串。
  2. ⛔ 判据由环境状态决定 ⇒ 合成测试区残留真常驻时,ensure_supervise 走闸①幂等让位、压根 不到口令闸 ⇒ startswith("no-token") 恒红 ⇒ ✅ 两条闸的返回都要接受(目标同为"不起新的")。

P0-67 · 排期不是「定时任务」,是开会话的申请书(2026-10-05 用户点破,⛔ 别再说错)

用户原话:「你就算是 once 不也是走的定时任务吗,难道能直接创建会话?」

完全正确。⛔ 我此前说「once 是唯一开新会话的通道」容易被误读成"once 有开会话的能力"—— 它没有。准确表述:

  • once 自己也是排期,也是被宿主到点拾取的;它同样不能直接创建会话。
  • 能开会话的永远只有宿主。AI 侧(本程序是宿主之外的 python 进程)没有任何开会话的能力。
  • ⇒ once 的真实身份 = AI 侧唯一能递到宿主手里的"纸条": 「请你在 X 时刻开一条会话,prompt 是 …」。宿主扫描到它 ⇒ 才开会话。

由此纠正两个长期绕圈的说法

  1. ⛔ 「为什么会产生排期/能不能不排期」—— 不能。要开会话就必须写这张纸条, ⛔ 没有第二种投递方式。排期不是"定时需求",是**"开会话的申请书"**。
  2. ⛔ 「把 once 删了、程序自己判断直接创建会话」—— 判断可以自己做(常驻五道闸已经在做), 但创建那一步做不到,与删不删 once 无关。

那"排期堆积"到底是什么

⇒ 申请书副本的堆积:申请书被宿主取走后 next_run_at 清 NULL,但记录永不删除 (deleted_at 恒 null)。闸④ 为了不卡闸必须跳过它 ⇒ 也就拦不住再开一张新的。 ⇒ 正解不是"不用申请书",而是 "申请书用完归档",或 "同一件事在冷却期内只递一次"。

一个可优化的点(⛔ 未实施)

create_check_schedule(delay_s=90):既然排期只是申请书,⛔ 延迟 90 秒不是机制需要 (宿主扫描周期实测 ≤30 s)⇒ 理论上可缩到 ~30 s,让检查会话更早开工。

P0-68 🔴🔴 MCN 工作台的按钮正是走 once —— 我此前「MCN 不是走 once」结论错误,已纠正(★ 2026-10-05 源码级实测)

用户原话:「净瞎说 … 难道 MCN 工作台也是走的 once 吗」/「我问你 MCN 是不是走 ONCE 开会话的」。

答案:是。MCN 就是走 once,与我方 create_check_schedule() 是同一条路。 ⛔ 我此前据「automations.cwds 含 mcn 0 条 + 该区无 .workbuddy/collab/」推出 「MCN 压根没用协作机制,不构成反例」—— 前半对(它不经过我的常驻), 后半错(它照样写 once)。错因:拿「有没有用我的程序」当「有没有用这条通道」。

源码级证据(逐字)

前端:public/data-pages.js:735 点「更新榜单数据」⇒ POST /api/dsh/ranking-update {prompt, force} ⇒ 轮询 /api/run/status?id=… ⇒ 提示「请到左侧会话栏查看执行」。 后端:server.js:677 ⇒ createOnceAutomation(RANK_UPDATE_NAME, prompt, ['mcn-data-insight']) ⇒ server.js:339 INSERT INTO automations (..., status, schedule_type, scheduled_at, next_run_at, ...) VALUES (?,?,?,'ACTIVE','once',?,?,...)。

与我方实现的逐项对照(同一张表、同一个 schedule_type、同一套三条硬规则)

MCN createOnceAutomation() 我方 create_check_schedule()
落表 automations automations
schedule_type 'once' "once"
status 'ACTIVE' "ACTIVE"
next_run_at now + 5_000(未来毫秒) now_ms + delay_s*1000
scheduled_at ISO 串(YYYY-MM-DDTHH:MM:SS) 同(ISO 串)
cwds JSON.stringify([cwd]) 正斜杠 json.dumps([ws]) 正斜杠
延迟 5 秒 90 秒(下限 30)
触发者 前端按钮(绕开常驻) 常驻五道闸判过之后

⇒ 🔴 「写这张表」就是宿主对外唯一的"开会话"入口,谁都能写: node 进程能写(server.js)、python 常驻能写(collabd.py)——与"用不用协作机制"无关。 ⇒ 这条实证同时坐实 P0-67:once 是申请书,MCN 交的也是同一式样的申请书。

由此定死两条结论

  1. 🔴🔴 once ⛔ 不能删。删它不是"去掉定时任务",是掐断 AI 侧开会话的唯一通道 ⇒ 我的检查会话 + MCN 的按钮任务同时断掉(两处都是 INSERT … 'once')。
  2. 🔴 "程序自己判断状态、创建会话"已经做到了——判断在常驻五道闸(P0-66), 创建=写申请书。MCN 只是把"判断"这一步交给了人(点按钮),我方交给了常驻。 ⛔ 差别在"谁判断",不在"要不要申请书"。

⛔ 实验方法上的坑:删存量 ≠ 掐断能力,且"没重建"可能是假阴性

2026-10-05 做过一次:软删全库 37 条 once,5 分钟后回读 count=0 未见重建。 ⛔ 不能据此说"程序不能建排期" —— 回读同时查得: · ai1net:tmp/supervise-inbox/tasks.json = {"t":{"state":"done"}} ⇒ queue_pending()=0 ⇒ 走 queue-empty 腿;goal.json 无 lifecycle 字段 ⇒ 判「等待」⇒ 闸①「须进行中」不过。 · vibe-product:queue_pending()=0;lifecycle='已完成(机器可判部分)' ⇒ 闸① 不过。 ⇒ 两个区当前本来就"不该建",不建是闸的正确行为。 🔴 判据:先查闸门条件,再看有没有产物;⛔ 只看产物 ⇒ 把"条件不满足"误读成"机制断了"。

可优化点(已由 MCN 实证,仍待拍板)

delay_s:MCN 用 5 秒被宿主正常拾取 ⇒ 我方的 90 秒不是机制需要(宿主扫描 ≤30 s)。 缩到 ~5–10 s ⇒ 申请书在册时间从 ≥90 s 压到 ~5 s ⇒ 堆积窗口同步收窄(治标,⛔ 不治根因)。

回滚

37 条软删记录完整备份在 ai1net-dsh-server/tmp/once-backup-20261005-093856.json(157640 B)。 ⛔ 但那是已被宿主消费过的残骸(next_run_at 已 NULL)⇒ 恢复只是把 37 条僵尸放回表, ⛔ 不建议恢复;真要恢复再清一次即可。

P0-69 🔴🔴 「有需要才建」是对的 —— 37 条里真正堆积的只有 11 条,且全在合成自测区(★ 2026-10-05 备份数据实证,用户点破)

用户原话:「有需要的时候才会创建定时任务开会话,哪里来的排期呢」

这句话是对的,实测把 37 条拆开后结论完全变样(tmp/once-backup-20261005-093856.json):

37 条的构成(实证)

  1. ai1net 25 条(10-01 09:05 → 10-02 10:49)= 正常,不是堆积。 名字各不相同(主控 · …/接续 · …/[协作]-…/[跟进]-…/[唤醒机制] …), 间隔 2.6 ~ 420 分钟 ⇒ 一次一个需求、有需要才建,正是用户说的那种。 ⛔ 其中没有一条是 [检查]([检查] 体系是后来才进常驻的,时间线问题,⛔ 不是卡闸)。
  2. vibe-product 11 条 [检查]-[结果检查]-selftest-第N棒(10-04 22:34 → 10-05 00:33)= 真堆积。 同一个 kind、同一个合成自测区 vibe-product/tmp/selftest,2 小时内等距产出 (间隔 3.6 / 11.9 / 16.4 / 7.8 / 13.5 / 17.0 / 12.9 / 9.5 / 22.6 / 4.1 分钟)。
  3. vibe-product 1 条 [协作]-[界面交互]-… = 正常需求。

⇒ 堆积占比 11/37,且 100% 来自自测区。真实业务线零堆积。 ⛔ 此前把 37 条统称"堆积"是口径错误,会误导修法(差点去动正常的那 25 条)。

那 11 条为什么堆:电平触发(状态判据当事件判据用)

常驻每 2 轮(≈60 s)判一次 ⇒ 自测区的条件持续成立(队列空 · 会话全结束 · 目标"等待") ⇒ 每一轮都判"有需要" ⇒ 每轮都想建。此时两道去抖闸同时失效,且是同一处设计导致的:

  • 闸④ ws_pending_schedules():collabd.py:4519 起 必须排除 next_run_at 已 NULL 的 once (否则它永远卡闸)—— 排除了就不再拦重复。
  • 闸⑤ 同名去抖:名字是 第%d棒(_check_stamp()["round"]+1),N 递增 ⇒ 永远不同名 ⇒ 形同虚设。

🔴 根因一句话:判的是"状态"(电平),却想要"变化"(边沿)。 ⛔ 此前记的"申请书副本堆积"是表象,不是根因;根因是没有边沿/冷却记忆。

正解(治本,⛔ 未实施)

边沿触发:记下上次判定时"是否有活"的指纹,只有状态发生变化才建 (如 sessions-ended:从"有会话在跑"→"全结束";queue-empty:队列从"非空"→"空")。 退化方案(治标):按 kind + 工作区 加冷却期,窗口内只递一次(用现成的 CHECK_STAMP)。 ⛔ 只做"消费后归档"不治本 —— 那治的是表变脏,治不了反复建。

⛔ 一条差点说错的(自我纠偏)

曾据"S7 排期 next_run_at 还在、且 37 条 last_run_at 全为空"推断"闸④ 被卡了 3 天"—— 错:闸④ 的 last_run_at 口径只是初筛,后面 4519 行起会排除 next_run_at 已 NULL/已过宽限的 once ⇒ 那批记录不卡闸。⇒ 🔴 判闸行为必须读完整个函数,⛔ 不能只看 SQL 那一段就下结论。

P0-70 🔴🔴🔴 「程序窗口一直弹出」事故:三条根因 + 总开关(★ 2026-10-05 用户当场发火三连「关掉程序/马上停止/提供启动开关」)

⛔ 这是事故,不是小毛病。用户屏幕被黑窗口反复打断,连发三条要求停止。

三条根因(逐条实测)

1、起进程前没盘点。--ensure 连起两区常驻;跑 selftest.py 时它 spawn 的常驻跑完不回收 ⇒ 一度 7 个 python 进程并发(其中 4 个是技能包目录下的野 --supervise)。 🔴 铁律:先盘点(Get-CimInstance Win32_Process)再起;selftest 跑完要回收。 2、用错解释器。collabd.py:585 跨区补起写死 sys.executable(python.exe=控制台子系统) ⇒ 每补起一个闪一个黑窗。模块里早有 PYW(_win_pythonw())却没用上 ⇒ 同族坑"改一处漏另一处"。✅ 已改 _py = PYW。 🔴 CREATE_NO_WINDOW flag 只是补救,⛔ 不能替代 pythonw.exe。 3、最隐蔽、最要命:宿主钩子 UserPromptSubmit 每次都 maybe_ensure_supervise() 补起常驻 ⇒ 用户每发一条消息就补起一次 ⇒ 只杀进程永远止不住(杀完下一条消息又起)。

已落的总开关(用户点名要的)

<工作区>/.workbuddy/collab/supervise.switch: 1、内容 on ⇒ 开。 2、文件缺失/读不到/内容非 on ⇒ 关(🔴 fail-safe:⛔ 绝不许"读不到就当开"—— 那正是"用户没让它跑、它却一直在弹"的成因)。 3、管住两处自动驱动:maybe_run_collabd_once()(--once)与 maybe_ensure_supervise()。 ⛔ 只管一处会漏(另一处照样起进程)。

开关与开会话的关系(用户问「后续如何创建会话」)

开关关 ⇒ 常驻不跑 ⇒ 不会有任何排期被创建(排期是常驻判闸后写的)。 开关开 ⇒ 常驻五道闸+闸⑥ ⇒ 才可能写 automations 表(schedule_type='once')⇒ 宿主拾取开会话。 ⇒ 🔴 开关是总电闸,⛔ 它不是"只关弹窗"(顺带也不再自动建任何会话)。

其余止血

  • 禁用 3 个 collabd-supervise-* 计划任务(它们触发 start-supervise.ps1)。 ⚠️ 连带后果:开机自启/常驻兜底没了 ⇒ 要不要、用什么载体,必须问用户,⛔ 不再自作主张。
  • 杀光全部 python 进程(含 board/两区常驻/4 个野进程)。

🔴 2026-10-05 晚 · 载体定案(用户拍板 + 全链实测,P0-71 详载)

用户逐字:「常驻崩了,拉起来这个事儿,你不能也用计划任务起个程序吗?」 ⇒ 本节那句"用什么载体必须问用户"已问、已答:计划任务 → pythonw.exe → --supervise,每 5 分钟兜底。 配套「一键全关」= collabctl.py off(开关+任务+进程,一次做完并复核)。 ⚠️ P0-70 说"禁用任务是止血"没错,但止痛不等于治病 —— 停掉载体就没人兜底了,见 P0-71。

P0-71 🔴🔴🔴 常驻到底靠什么活着:不在"作业对象",在"谁拉起它"(★ 2026-10-05 全链实测 · 同一件事栽到第四次)

用户连问三轮才逼出真相: 「待拍板 · 常驻到底靠什么保证长期运行,我就没搞懂 在你清理之前常驻不都是运行的好好的吗」 「启动个程序要什么看守循环,我就没明白了,启动bat程序 一直运行需要看护循环吗」 「从1号搞到5号 还起个程序都启动不起来」

一、四条实测读数(⛔ 全是跑出来的,没有一条是推的)

# 做法 读数 结论
1 会话里 Popen 起循环 bat 约 70 秒后停止写入(t+40s 60 行 → t+70s 仍 60 行) 活不过工具调用边界
2 加 CREATE_BREAKAWAY_FROM_JOB(0x01000000) PermissionError(13, 拒绝访问) 本机作业对象不允许脱离
3 四组对照(任务 Highest/Interactive、任务 Limited/S4U、任务 Highest/S4U、subprocess 直起) 全部 IN-JOB 🔴 IsProcessInJob 在此环境没有鉴别力 —— 给谁都套作业对象
4 能长期活着的那条常驻 父进程已退出、父链断在自己身上 ✅ 真判据=父链根:会话树里的是 bash→sandbox-cli→WorkBuddy.exe→explorer;任务起的断链

⇒ 🔴🔴 一句判据:别看"在不在作业对象里"(恒真,没用);看"父链有没有穿到 WorkBuddy.exe"。 穿到了 ⇒ 会话一收工它就没;没穿到 ⇒ 真常驻。

二、形态定案(用户拍板)

计划任务 → pythonw.exe → collabd.py --supervise,每 5 分钟一次。

1、🔴 动作必须是 pythonw.exe(GUI 子系统 ⇒ 不分配控制台 ⇒ 不弹窗)。 ⛔ powershell.exe / cmd.exe 都是控制台程序,一启动就分配控制台 ⇒ 闪窗(P0-70 弹窗事故真因)。 2、🔴 ⛔ 不要"看守循环"。旧形态是三层嵌套: ① 常驻本体(自带循环,✅必要)② start-supervise.ps1(永不退出的看守,⚠️多余) ③ 计划任务每 60 秒叫醒看守(❌这才是弹窗源,且看守自己不会退 ⇒ 纯浪费)。 ✅ 现在两层:常驻本体(自带循环)+ 计划任务(每 5 分钟判活,判到就走)。 3、🔴 --supervise 是长驻的,进场即判活:supervise_alive()(pid 活 + 心跳新 + 身份核验) ⇒ 已在 ⇒ 新起的立刻让位退出 ⇒ ⛔ 不会叠加成两条(实测:触发后进程数恒为 3)。

三、🔴🔴 两个必须记住的实现细节(掉一个就"看着配好了、其实没跑")

1、🔴🔴 WorkingDirectory 必须是「工作区根」,⛔ 不是脚本所在目录。 病根:collabd.load_cfg() 按 <cwd>/.workbuddy/collab/collabd.config.json 找配置。 设成脚本目录(<ws>/.workbuddy/collab)⇒ 它去找 <ws>/.workbuddy/collab/.workbuddy/collab/collabd.config.json ⇒ 必然找不到 ⇒ CFG_MISSING ⇒ --supervise 拒跑(实测 rc=2;任务侧记 LastResult=1)。 ✅ 改成工作区根 ⇒ State=Running、LastResult=267009、心跳正常(实测)。 2、🔴 常驻不需要网关口令(这条反直觉,必须记住): --supervise 唯一职责=建检查会话排期(maybe_spawn_check_agent → create_check_schedule → 直连 SQLite 写 automations 表)⇒ 全程不走网关。 口令只在 --ensure 的 spawn 分支才是前置门。 ✅ 隔离区实测:不含口令的计划任务起 --supervise ⇒ 15 秒内写出心跳(pid 3944 · round=2)。 ⚠️ 所以保活要用 --supervise,⛔ 用 --ensure 会在任务环境里"起了就退"(无口令)。

四、看板的 LastResult=1 是虚警,⛔ 别当故障

dsh-board-keepalive(pythonw board.py --serve 20099)实测 LastResult=**1**, 但看板其实好好的。真因:board.py 有全局单例守卫("看板全局只允许一个"), 已在跑时它打印一行"已有看板在跑 ⇒ 本次不启动"后 return 0; 而 pythonw 在任务环境下没有 stdout 可写 ⇒ 退出码被顶成 1。 ⇒ 🔴 判据:看板看"端口在不在听",⛔ 不看 LastResult。

五、验收读数(2026-10-05 11:4x 实测,全绿)

  • 常驻任务:State=Running · LastResult=**267009**(=0x41301 正在运行)
  • 崩了能拉起(隔离区实测):杀掉常驻 → 删心跳 → 触发任务 ⇒ 15 秒内心跳重现(pid 69544 · round=1)· State=Running ⇒ ✅ 用户要的就是这一条
  • 跨周期观察 11 分钟(两个 5 分钟周期):进程数恒 4(三生产 + 一观察)、 两区心跳恒新鲜(1~23 s,⛔ 远离 90 s 阈值)、看板每一次采样都在听 ⇒ 无抖动、无重复、无掉线

六、⛔ 三条硬规(别再犯)

1、⛔ 不许在用户机器上做"停生产"实验 —— 要验"崩了能拉起", 去隔离区(tmp/selftest + 空闲端口),⛔ 别把生产的看板停掉试。 2、⛔ 停止=任务 + 进程一起(只禁任务老进程还在;只杀进程任务到点又拉)—— 这就是为什么必须有 collabctl.py off 这种"一键做全"的入口。 3、⛔ "禁用任务"不是修复,是拆载体。10-05 我禁了 4 个任务, 用户当场问"在你清理之前常驻不都是运行的好好的吗" —— 对,就是我拆的。 正确修法是换成对的载体(pythonw + 正确 cwd),⛔ 不是把载体删掉。

七、🔴🔴 三条硬约束(用户 2026-10-05 逐字重述,本轮逐条取证)

用户原话:「这些常驻程序每次创建之前,要先检查是否已经存在。如果已经存在了,就不需要重复创建, 而且各工作区是各工作区的。不共用,共用的只有看板。」

1、先查后建(幂等) ⇒ 载体=--supervise 的两段式守卫: ① 进场 supervise_alive()=pid 活 ∧ 心跳新 ∧ _pid_is_collabd 身份核验(⚠️ pid 会复用, 光看"号还活着"会被骗 ⇒ 必须核身份)⇒ 已在即 return 0; ② 原子抢位 + sleep 1.5 回验兜住"两条同时启动"。 ✅ 变异对照实测(⛔ 不是"应该绿"):手动再起第二个 --supervise ⇒ 0.4 秒 rc=0 让位退出; 日志留痕 154 条「已有活的常驻(pid 30560 活、心跳 2 s 前、身份已核)⇒ 本实例退出(幂等,⛔ 不双写)」; 连触两次 ⇒ 进程数恒 3(⛔ 不叠加)。 2、各工作区独立 ⇒ 一区一条 collabd-keepalive-<ws>,动作指本区副本; 判据=心跳里的 argv0 必须指本区路径(实测 ai1net↔30560、vibe↔60596,两串互不相同)。 ⛔ 不许一区去起另一区(P0-57 同族,用户 10-03 当场纠正过)。 3、只有看板共用 ⇒ 看板任务全局唯一一条 dsh-board-keepalive(端口 20099,动作指技能目录那份共用 board.py)。 ⚠️ 专项澄清(两条 cwd 要求恰好相反,⛔ 别互相套用): · --supervise 任务 WorkingDirectory 必须是工作区根(首节细节①); · 看板任务 WorkingDirectory 指技能 scripts/ 即可 —— board.py 全按 __file__ 定位,⛔ 不依赖 cwd。

八、🔴🔴 本轮新抓的部署缺口(P0-57 同族第 N 次,必须并入 deploy 清单)

  • 症状:collabctl.py status 把新任务全报「未知」、且列的是已禁用的旧任务名。
  • 病根:deploy_code.py 的 DEFAULT_FILES 只有 ["collabd.py", "goalctl.py"] ⇒ 用户双击 .bat 调的 collabctl.py 从来没被分发 ⇒ 各区副本停在 10:36 旧版(16982 B), 技能目录已是新版(23176 B)⇒ "看着改了、其实各区没吃到"。
  • ✅ 修法:DEFAULT_FILES 补全所有被计划任务/.bat/hook 直接执行的本包脚本: collabd.py · goalctl.py · collabctl.py · guard.py · session-rules-check.py · init_workspace.py。
  • ✅ 判据(写死):凡是被自动触发物直接执行的本包脚本,都必须在 DEFAULT_FILES 里; ⛔ 改完代码必须重跑 deploy_code.py --ws <区>(覆盖前自动备份),否则各区吃不到。

P0-72 🔴🔴🔴 「一键停止」没真停+看板起不来:两个静默失败(★ 2026-10-05 端到端实测抓到 · 用户原话「我关不掉这些东西,我还要投诉你呢」)

用户三条口径(逐字):「常驻崩了,拉起来这个事儿,你不能也用计划任务起个程序吗?」/ 「我总要以一键能关掉这些东西吧」/「这些常驻程序每次创建之前,要先检查是否已经存在……各工作区是各工作区的,不共用,共用的只有看板。」

一、🔴🔴 缺陷 A:kill_all() 静默漏杀 —— 「停止」看着成功,进程一个没死

  • 症状:跑 collabctl.py off ⇒ 打印「计划任务已禁用:7 个」+「已杀进程:0 个」, 但两区常驻(pythonw)与看板全都还活着。⇒ 假停。
  • 病根:kill_all() 里的 taskkill 用了 creationflags=HIDE,而 HIDE 含 CREATE_BREAKAWAY_FROM_JOB —— 本机实测必被拒(PermissionError(13))⇒ 每次 taskkill 都抛异常、被 except: pass 吞掉 ⇒ 函数照常 return(计数 0)⇒ 「已杀进程:0 个」既没报错、也不起任何疑心。
  • ✅ 修法:taskkill 是短命控制台程序且 capture_output 走管道 ⇒ 不会弹窗 ⇒ 用 creationflags=0 + stdin=DEVNULL;返回值按 rc==0 计成功(⛔ 不再"无脑 +1")。
  • 🔴🔴 同族第二条(修完才露出来):list_procs() 的过滤是 python* ⇒ 连调用它的 python 一起匹配到 ⇒ 实测把调用方自己杀了(症状=脚本 rc=1、零输出)。✅ 修法=先算保护集 (_self_and_ancestors():自己 + 全部祖先 pid)+ 跳过 collabctl.py 与"正在列举进程的那个 ps"。

二、🔴🔴 缺陷 B:看板任务直起 board.py ⇒ 崩溃重启循环、端口从没绑上

  • 症状:dsh-board-keepalive 的 State 一直在 Ready/Running 抖、LastResult=1、 20099 Listen 计数恒 0、进程 pid 每十几秒换一次(崩→5 分钟触发→再崩)。
  • 真因(抓到的现场输出): ⚠️ collabd:未找到部署配置(COLLABD_CONFIG 未设,且 <scripts>\.workbuddy\collab\collabd.config.json 不存在)⇒ 已拒跑 board.py 顶层用 COLLABD_CONFIG 定位配置(它要 import collabd.py)—— 而计划任务的"动作"里根本没有 env 字段 ⇒ 这个变量只能由启动器在进程内设。
  • 🔴 注意变量名别混:包内 roots.env 提供的是 COLLABD_PROD_CONFIG, 而 board.py 读的是 COLLABD_CONFIG —— 名字对不上 ⇒ roots.env 兜不住它。
  • ✅ 修法:新增 scripts/board-launch.py(启动器)=先在进程内设 COLLABD_CONFIG=<主区>/.workbuddy/collab/collabd.config.json + PYTHONIOENCODING + 兜住 sys.stdout/stderr(pythonw 下可能是 None),再 runpy.run_path(board.py, run_name="__main__"); 看板任务的动作改指 pythonw.exe board-launch.py。
  • 🔴 ExecutionTimeLimit 必须按"是不是长驻服务"分: · 看板(长驻服务)⇒ 0(无时限);· 常驻 --supervise(也是长驻)⇒ 0。 ⛔ 我一度把两者都设 2 分钟 ⇒ 到点被调度器掐死 ⇒ 又一种"每 2 分钟重建"的抖动(已改)。
  • ⚠️ 看板的配置恒指主工作区(看板是全局共用一份);主区由 roots.env 的 DSH_WS_ROOT 决定,启动器里兜底写死 ai1net-dsh-server。

三、✅ 验收(2026-10-05 12:0x 端到端实测,全绿)

  1. off ⇒ 打印「已杀进程:3 个」;5 秒后 list_procs 只剩"诊断脚本自己+列举进程的 ps" ⇒ 真停。
  2. on ⇒ 三任务重建触发;25 秒后两区常驻活、心跳新鲜;看板 State=Running/267009。
  3. 看板 curl --noproxy '*' http://127.0.0.1:20099/ ⇒ HTTP 200 · 148274 B;端口 Listen 计数 = 1。
  4. 用户可见入口 会话机制-一键开关.bat(工作区根+桌面)⇒ 1 启动 / 2 全部停止 / 3 看状态。

四、⛔ 教训(一句话)

"做了动作" ≠ "动作生效" —— taskkill 带错 creationflags、计划任务动作里没有 env 字段, 这两件事都不报错,只表现为"停止没停""看板没起"。⇒ 凡关键动作,必须有"动作后复核" (杀完看进程表、起完看端口),⛔ 不许拿"函数返回了"当成功。

五、⚠️ 诊断期又踩一个(值得单列):进程查询会"自匹配"

查"有没有残留的看守进程"时,Get-CimInstance ... -like '*start-supervise*' 会命中正在执行这条查询的 那个 PowerShell 自己(它的命令行里就含 start-supervise 这串)。 ⇒ 症状=每次查都有"残留"、pid 每次都变(其实是每次查询自己)。我差点据此去"清理一个不存在的东西"。

  • ✅ 判据:查出来的那条 command line 是不是"正在列举进程"的那条?是 ⇒ 就是自匹配,不是真进程。
  • ✅ 做法:把关键字拆开拼接("start-superv" + "ise.ps1")再比对;或按 -File 形态精确匹配 ($_.Name -eq 'powershell.exe' -and $_.CommandLine -like '*-File*<拆开的关键字>*')。
  • 🔗 同源:collabctl.kill_all() 的 list_procs() 也踩过同族(python* 匹配到自己)⇒ 已用 保护集 _self_and_ancestors() 兜住。

P0-73 🔴🔴🔴 常驻启动器缺一个环境变量 ⇒ 检查程序静默失效(★ 2026-10-05 用户点名「检查之前创建的协作程序和检查程序运行是否正常」时抓到)

用户口径(逐字):「处理完毕后,检查之前创建的协作程序和检查程序运行是否正常。」

一、先分清"两套程序"(⛔ 别混为一谈)

协作程序 检查程序
是什么 collabd.py --supervise(常驻本体,自带循环,每 10~30 s 一轮) --supervise 循环内建的检查会话排期(maybe_spawn_check_agent() → create_check_schedule() → 直连 SQLite 写 automations)
判活 心跳文件新不新 + argv0 指不指本区 日志里有没有 检查会话:… / 已建检查会话排期 … 分支
载体 collabd-keepalive-<ws> 计划任务 同一个进程(⛔ 没有独立任务)

⇒ 🔴 检查程序跟着协作程序走 ⇒ 协作程序"活着"不代表检查程序在工作(本坑正是如此)。

二、🔴🔴🔴 症状:协作程序活得好好的,检查程序悄悄地不干活了

  • 协作程序侧一切正常:两区心跳新鲜、argv0 各指本区、进程恒 3 个。
  • 检查程序侧(ai1net):logs/_collabd.log 每轮刷一行 all_sessions_idle 读库失败 no such table: sessions(累计 36 次); 最后一次成功建检查会话排期停在 11:54(重建任务之前)⇒ 此后一次都没再建过。
  • 对照组(vibe)完全正常:读库失败 0 次,一路在跑 检查会话:目标状态=已完成(非进行中)⇒ 不建。
  • 🔴 为什么"静默":_all_sessions_idle() 的 fail-safe 是 读库失败 ⇒ 判「有会话在跑」⇒ 不建检查会话(return False)。 ⇒ 失败方向恰好是"什么都不做" ⇒ 表现成"机制很安静",⛔ 不会报错、不会告警。

三、🔴🔴🔴 根因:常驻任务直起 collabd.py --supervise,任务环境没有 CODEBUDDY_CONFIG_DIR

  • _wb_db() 逐字:
    cfg = os.environ.get("CODEBUDDY_CONFIG_DIR") or ""
    return Path(cfg) / "workbuddy.db" if cfg else Path.home() / ".workbuddy" / "workbuddy.db"
    
    ⇒ 环境变量没设 ⇒ 落到 C:\Users\Administrator\.workbuddy\workbuddy.db。
  • 🔴 那个文件是 0 字节的空库(无任何表)⇒ 查询 sessions ⇒ no such table: sessions。
库 大小 sessions 表
E:\ProgramData\.workbuddy\workbuddy.db(活动库) 34.9 MB ✅ 247 行
C:\Users\Administrator\.workbuddy\workbuddy.db(旧库) 0 字节 ❌ 无
  • 🔴 为什么只有 ai1net 炸、vibe 没事:vibe 的 config 里 host_db 写死了绝对路径 E:/ProgramData/.workbuddy/workbuddy.db;ai1net 的 host_db 一直是空字符串(历史备份也空)。 ⇒ 同一份代码,两区行为不同,差别只在 config 这一格。
  • 🔴 "计划任务的『动作』里没有 env 字段"(P0-72 §二 同一条硬约束)⇒ CODEBUDDY_CONFIG_DIR 只能由启动器在进程内设。
  • 🔴 我是怎么把它弄丢的(诚实记录):旧看守 start-supervise.ps1 里本有 $env:CODEBUDDY_CONFIG_DIR = "E:\ProgramData\.workbuddy"; P0-72 改成"两层形态"(计划任务 → pythonw → 启动器)时,只保了 COLLABD_CONFIG,丢了这一句。 ⇒ 重构"起法"时,旧起法里的 env 必须逐条搬过去(⛔ 不许凭记忆只搬"我记得的那个")。

四、✅ 修法:新增常驻启动器(与 board-launch.py 对称)

  • 新增 scripts/supervise-launch.py:进程内 os.environ.setdefault("CODEBUDDY_CONFIG_DIR", r"E:\ProgramData\.workbuddy") + 设 COLLABD_CONFIG=<本区>/collabd.config.json + 兜住 sys.stdout/stderr(pythonw 下可能是 None) + sys.argv=[collabd.py, "--supervise"] + runpy.run_path(CB, run_name="__main__")。
  • 常驻任务动作改指 pythonw.exe "<区>/.workbuddy/collab/supervise-launch.py" (⛔ 不再自带 --supervise —— 参数由启动器给),-WorkingDirectory 仍是工作区根, -ExecutionTimeLimit 恒 0(长驻服务)。
  • deploy_code.py 的 DEFAULT_FILES 补入 supervise-launch.py(否则各区吃不到,P0-57 同族)。
  • ⚠️ roots.env 的位置缺口(连带发现):_sm_load_roots() 从 __file__ 向上找 roots.env(here/../..×4); 工作区副本在 <ws>/.workbuddy/collab/collabd.py ⇒ 若工作区里没有 roots.env ⇒ 找不到; 而 roots.env 只在技能目录(E:/ProgramData/.workbuddy/skills/session-mechanism/roots.env)。 ⇒ 别指望靠 roots.env 兜住各区,启动器里写死最稳。

五、✅ 验收(2026-10-05 12:15 实测,全部留痕)

① 分界线干净得可当判据(grep -c + awk 分界):

[2026-10-05 12:14:49] all_sessions_idle 读库失败 no such table: sessions   ← 旧进程最后一次
[2026-10-05 12:15:02] supervise loop start pid=58860                        ← 新形态进程起来
[2026-10-05 12:15:14] all_sessions_idle:还有 1 条 working(a80f300d)⇒ 不算全结束

⇒ pid=58860 之后 no such table: sessions 计数 = 0(awk 分界实测), 从 fail-safe 的"恒判有会话"变成真实读库判定。

② 那条 working(a80f300d) 是真的(sqlite3 直查活动库): ('a80f300d-…', '复盘排期堆积与一次性排期问题', 'working', 'E:/ProgramData/AIProject/ai1net-dsh-server') ⇒ 就是当前这条会话本身 ⇒ 闸②「本区有会话在跑 ⇒ 不建检查会话」是正确的合法拦截 (⛔ 不是 bug。要观察 检查会话:… 分支,得等本区会话全结束)。

③ 变异对照证明判据非恒绿(tmp/mutate_dbpath.py,两档):

档 环境 落的库 结果
A 无 CODEBUDDY_CONFIG_DIR C:\…\.workbuddy\workbuddy.db(0 B) FAIL no such table: sessions
B 有(注入后) E:\ProgramData\.workbuddy\workbuddy.db(34.9 MB) OK sessions=247 working=1

⇒ A 必 FAIL、B 必 OK ⇒ 路径来源真实决定成败(⛔ 不是"应该绿")。

④ 运行时三件套(Get-CimInstance 实测,恒 3 进程、ppid=3924=调度器、父链断 ⇒ 真常驻):

pid 启动器 归属
58860 <ai1net>/.workbuddy/collab/supervise-launch.py 协作程序 ai1net
61956 <vibe>/.workbuddy/collab/supervise-launch.py 协作程序 vibe
63684 <技能>/scripts/board-launch.py 看板

⑤ 启动器三处 md5 一致 f416a2f1(技能目录 + 两区副本);看板 curl --noproxy '*' http://127.0.0.1:20099/ ⇒ HTTP 200(1.5 ms)、netstat 实测 127.0.0.1:20099 LISTENING。

六、⛔ 教训(一句话)

"进程活着" ≠ "它在干活" —— 常驻本体活着,但它内部某一环读错了库、被 fail-safe 兜成"什么都不做", 外表完全安静、零报错。⇒

  1. 检查"程序是否正常",必须看它有没有产出预期的分支(检查会话:…),⛔ 不能只看进程在不在;
  2. fail-safe 的方向要选对(本处是"不建",安全但不可见)⇒ 凡 fail-safe,必须同时打一条可检索的日志;
  3. 重构"起法"时,旧起法里的 env/inner 参数要逐条搬(本坑就是丢了一句 CODEBUDDY_CONFIG_DIR);
  4. 各区 config 的 host_db 写死绝对路径最稳(vibe 一直这么写,所以只有 ai1net 炸)。

P0-74 🔴🔴🔴 _escalate_to_keeper() 残留旧形态 ⇒ 计划任务里躺着 powershell.exe ⇒ 闪黑窗(★ 2026-10-05 用户原话「刚才又弹了窗口看看是什么」)

一、症状:屏幕上反复闪一下

  • 用户报「刚才又弹了窗口」。
  • 实测抓到的进程对(Get-CimInstance Win32_Process):
    pid 55880  ppid 3924   powershell.exe  -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "...\start-supervise.ps1"
    pid 67784  ppid 55880  conhost.exe     \??\C:\windows\system32\conhost.exe 0x4
    
    ⇒ powershell.exe + conhost.exe —— 这就是闪窗的铁证。

二、🔴 真凶:collabd.py 的 _escalate_to_keeper() 没跟着新形态一起改

  • 它是自我供给函数(2026-10-04 加的:就地起失败时,转去建本区计划任务)。
  • 但它的实现停留在旧形态:任务动作写死 powershell.exe -WindowStyle Hidden -File start-supervise.ps1 + 还要铺一份 start-supervise.ps1(模板 assets/start-supervise.ps1.tpl)。
  • 🔴 为什么它就"闪":-WindowStyle Hidden 只藏窗口、不省控制台 —— PowerShell 是控制台程序 ⇒ 每次触发都分配 conhost.exe ⇒ 闪一下。
  • 🔴 为什么"反复"闪:该任务触发器 -AtLogOn + RestartCount 999 / RestartInterval 1min ⇒ 起来就不会安静。
  • 🔴 触发链:宿主钩子 supervise-ensure-hook.py(每次 UserPromptSubmit)→ collabd.py --ensure → 就地起失败 → _escalate_to_keeper() → 建/启旧任务 → 闪窗。 实测日志逐字:常驻自我供给(计划任务):✅ 已建本区计划任务 collabd-supervise-ai1net-dsh-server。

三、✅ 修法(收敛成同一个形态,⛔ 不留第二套)

在 _escalate_to_keeper() 里:

  1. ⛔ 不再铺 start-supervise.ps1、⛔ 不再做 ps1 语法校验、⛔ 不再依赖 start-supervise.ps1.tpl (删掉 _find_keeper_tpl() 前置门槛 —— 否则缺模板就直接"没建",与启动器形态无关了)。
  2. 任务动作改指 pythonw.exe + <区>/.workbuddy/collab/supervise-launch.py (-WorkingDirectory =工作区根;pythonw 是 GUI 子系统 ⇒ 零控制台 ⇒ 不闪窗)。
  3. 任务名保持 collabd-supervise-<区>(⛔ 不改名 ⇒ collabctl 的名单不用动)。
  4. _escalate_to_keeper 里被删段用 ~~删除线~~ 在 docstring 里标注(⛔ 别留下与实现不符的说明)。

四、✅ 验收(2026-10-05 12:42 实测)

  • 跑一次 _escalate_to_keeper() ⇒ 回读任务动作:
    EXE=E:\ProgramData\.workbuddy\binaries\python\versions\3.13.12\pythonw.exe
    ARG="E:\ProgramData\AIProject\ai1net-dsh-server\.workbuddy\collab\supervise-launch.py"
    WD=E:\ProgramData\AIProject\ai1net-dsh-server
    
    ⇒ 不再是 powershell.exe(grep New-ScheduledTaskAction -Execute 'powershell.exe' ⇒ 0 命中)。
  • 零 PowerShell 看守残留:Get-CimInstance 筛 powershell.exe 且命令行含 start-superv ⇒ 空。
  • 三常驻全部 ppid=3924(调度器),且三个都是启动器形态: · 48372 <ai1net>/…/supervise-launch.py|· 61956 <vibe>/…/supervise-launch.py|· 63684 <技能>/…/board-launch.py ⇒ 父链断在调度器 ⟹ 真常驻;形态统一 ⟹ 不会再长出第二套起法。

五、⛔ 教训(一句话)

"改了主路径" ≠ "把旁路也改了" —— 上一轮把常驻主体收敛成"启动器形态",却漏了这个自我供给旁路; 它平时不吭声,只在你最不希望的时候闪你一下(还带着 RestartCount 999)。 ⇒ 凡"同一种东西有第二条起法",收口时必须 grep 全文找一遍(本次判据 = 全文搜 New-ScheduledTaskAction 与 .ps1)。


P0-75 🔴🔴 远端技能总仓里躺着明文令牌(.neodata_token)(★ 2026-10-05 收尾核验时发现)

一、症状

核验 [email protected]:maogeigei/workbuddy_skills.git 的推送结果时, 把「远端 HEAD 顶层条目」与「更早做的备份」用 comm 比差异 ⇒ 多出一个 .neodata_token:

{"token": "tk_<REDACTED·首2位 tk_><共 35 字符>", "saved_at": 1790920232}

明文、72 B、可直接被任何能 clone 的人读走。

二、根因

  • 引入提交=43b83b0 chore(skills): 首次入库 workbuddy_skills(25 个技能 / 419 文件) ⇒ 是首次整仓入库时带进去的,⛔ 不是后来某次改技能推的。
  • 首次入库用 git add -A 整目录收录;当时 .gitignore 只挡了运行产物 (*.log/__pycache__//*.bak-*)⇒ 完全没挡凭据文件。
  • 本机同源=<CODEBUDDY_CONFIG_DIR>/.neodata_token(NeoData 金融搜索技能运行时写的令牌落点, 跟 roots.env 是同一类东西,但一个是"部署契约"、一个是"密钥")。

三、修(最小改动 · ⛔ 未改写历史)

git rm --cached .neodata_token          # 只出索引,保留工作区文件
#  .gitignore 追加:
#    .neodata_token
#    .neodata_*
#    *.token
git commit -m "chore(security): 移除误入库的 .neodata_token 令牌 + 补忽略规则"
git push origin master                  # 13a300e..031b312

四、验收

  • 远端 HEAD = 031b312;工作树无该文件;git ls-files 索引已清。
  • 25 个技能全在;session-mechanism 57 文件(⛔ 没被波及)。
  • .gitignore 尾部新规则就位。

五、⚠️ 残留风险(必须如实告知用户)

历史里仍有那个 blob(55941e52)—— git log --all -- .neodata_token 还能查到, git show 43b83b0:.neodata_token 仍能打印明文。 ⇒ 彻底清除 = push --force 重写全部历史(会改写 25 个技能的全部提交哈希,牵连所有 clone) ⇒ ⛔ 不当场做,必须用户拍板。 (更根本的处置是在服务端吊销/轮换该令牌 —— 重写历史管不住已经 clone 走的副本。)

六、⛔ 教训(可复用判据)

  1. 🔴 技能仓入库前必扫两样:① 名字含 token|secret|credential|password|\.env 的文件; ② 已知令牌串出现在哪些文件(grep -rl "<串>" <目录>)。
  2. 🔴 .gitignore 必须显式挡本机令牌落点(.neodata_token 这类), ⛔ 别只挡"日志/缓存/备份" —— 产物与凭据是两回事。
  3. 🔴 核验远端别只比"技能在不在",要比差异(现远端 vs 备份,comm -23/-13)。 本次就是靠差异比对才发现;只看"25 个技能都在"会直接漏掉。
  4. ⚠️ 首次入库用 git add -A 是高危动作(整目录收录)⇒ 收录前先跑一遍第 1 条的两扫。