- 变更规模:新增 514 / 修改 62 / 重命名 155 / 删除 4(归档重组与文档轮次) - .gitignore 修:`归档/**/db-cwd归一-备份-*/` —— 原规则写绝对层级(归档/db-cwd归一-…), 目录搬进 归档/配置与备份/ 后**静默失效**,43 MB 的 DB 备份又变成未跟踪 - .gitignore 补:嵌套 git 内部数据(归档/内嵌git-20261008/、归档/skills-git-旧线-20261007/dotgit-原样移出/) - .gitignore 补:运行态与部署副本(.workbuddy/collab/、.workbuddy/tools/、.workbuddy/.load-pending、.workbuddy/tmp-*) - .gitignore 补:备份件(*.bak-*) - 未跟踪文件从 2190 降到 890(其余为 归档/ 归档件与 .workbuddy/memory/ 知识文件,按口径入库)
127 KiB
踩坑清单(每条都真实发生过)
🔴 当前结论(先读这里 · 最后更新 2026-10-02 05:1x)
⚠️ 本文件通篇追加式 ⇒ 旧条可能已被取代(已就地标注)。冲突时以「本节 +
architecture.md的「当前结论」节」为准; 历史只留最近 5 轮、更早的归档(例外:教训类不受 5 轮限制 —— 见agent-operating-rules §1.7a)。最该先记住的几条(其余按 P 编号往下读)
编号 一句话 为什么它排前面 P0-24 🔴🔴🔴 注入物里写死命令 ⇒ 会话被逼着做用户没授权的事。缓存里存的是成品文案(含「⛔ 不要问用户」),逐轮复用 ⇒ 跟你当轮说了什么无关。硬规:缓存只存原始数据,文本按当轮授权现算;未授权时只通报、不派活 🔴 越权比超时严重:超时只是慢,越权是替你做决定;且表现像"机制很勤快",最不易被发现 P0-23 🔴🔴 钩子超载 ⇒ 用户每句话都被拦下(10-02 全工作区事故):宿主注册 20s,钩子里同步串了 --gap13.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-sessionP0-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照旧每几十秒一行 ⇒ 前四棒都据此认为"常驻在跑",只有全量进程表才发现「早就没了」。
根因(本棒实测,⛔ 非推断):
- 🔴 日志的写者不止常驻 —— 宿主钩子(
PreToolUse ^Bash$+UserPromptSubmit)每轮都会跑--tick, 它写的是同一个日志。⇒ 「日志在走」根本不能推出「常驻在跑」(本条的最大教训)。 - 🔴 本机不存在"能一直活着"的进程:①
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 日志定时清理(id07976988-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、不写日志 ⇒ 它越正常,日志里越查不到 ⇒ 单看日志必然误判。 - 怎么定死真因(本轮用的三步,可复用):
- 去找那条回路该产出的文件:
gate-done.stamp⇒ 不存在 ⇒ 回路没跑过(比读日志硬); - 看消费方:
collabd.py真读它(判gate=busy)⇒ 不是死代码,是有害的沉默; - 分辨"日志行"的来源:
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:55http:200 ok:true) 目标是f8a792ab—— 一条日志已冻结的"死"会话 ⇒ 只有"死"会话才投得进去。 -
🔴 真正区分得开的东西在宿主自己的状态机日志里(
[SessionRunStateMachine],只写工作区日志、 ⛔ 不进数据库 —— 所以查库永远查不到):事件 结果 event=AGENT_STARTED/RUN_ACCEPTEDlifecycle=runningbusy=trueevent=AGENT_ENDEDto=idlelifecycle=idlebusy=falsequeueBusy=false -
修法(
collabd.py::_session_busy()+_target_busy(),语义不变、只换判据形态 —— 用户口径原话「跟进会话 执行完了 在上报没问题,执行中就等待上报」,⛔ 不是"超时即投"):- 库里
status∈SID_DEAD⇒ 直接判"没在跑"(⛔ 不看日志)。 🔴 必须有这一道:实测自动化拉起的会话跑完不写busy=false(整份日志里busy=false只出现过 12 次、全是主会话的),它直接在库里变completed, 而日志末条仍是busy=true(e2ccdea3:末条22:42:10 … busy=true,库里22:45:53已completed) ⇒ 只看日志会把早就结束的会话再"忙"上十几分钟。 status == working⇒ 才读日志细分:末条busy=取最后一条记录,busy=true且新鲜(SRSM_FRESH)⇒ 忙;否则 ⇒ 空闲。 🔴 「新鲜」这半必须有:会话真在跑时状态机每几百毫秒写一行 ⇒ 「末条busy=true却已是 N 分钟前」只可能是它早停了 ⇒ 不必依赖"正好抓到AGENT_ENDED" (那一条会被挤出尾部窗口)。⚠️SRSM_FRESH取 900 s:一轮里跑很长的单个工具调用时 状态机全程静默(实测一次 8 MB 日志扫描静默 ~6 分钟)⇒ 窗口太短会把正在跑的误判成空闲 (方向相反的错:往正在跑的会话里插话)。多等 ≤15 分钟不算损失(队列件本来就压着)。- 读不到 ⇒ 按"没在跑"处理并留一行日志:⛔ 不能按"忙"处理 —— 那等于把死结换个形状留着。
- 库里
-
🔴 同一轮我自己写出的两个"判据看着在、其实恒假/恒真"(都已修,并写成断言):
- (a) 正则位置组错位:写成
(?:\.(\d+))?时内层(\d+)仍是捕获组 ⇒ 后面m.group(8)整个错位一位 ⇒busy恒读成False⇒ 恒判"空闲"(方向相反:会往正在跑的会话里插话)。 ✅ 改用命名组(?P<busy>…),并加源码级反回归断言(_session_busy段内零位置组引用)。 ⚠️ 这条断言不是"看代码像不像" —— 它是唯一能在这类错位再发生时立刻报红的东西。 - (b) 同一秒内"后出现的没胜出":只比
t > last[0]⇒ 同一秒(甚至同毫秒)的两条, 前一条胜出 ⇒ 取到旧状态。✅ 改成比较(时间, 行序)二元组。 - (c) 我自己的红绿对照脚本是空的:变异体跑的是
-k 忙判据,而那条target-busy用例的名字里没有"忙判据" ⇒ 变异体根本没被试,报"全绿" ⇒ 差点当成"断言恒真"。✅ 过滤词改成能覆盖两条用例的词。 ⇒ 🔴 判据:变异法跑完必须回读"这个变异体到底被哪几条用例跑了",⛔ 不看"合计 PASS"就当证毕。
- (a) 正则位置组错位:写成
-
残留(⛔ 不是本条的修法能解决的):判据换对之后,拦截原因从
target-busy变成no-follow-session—— 那是真状态:此刻一条活的[跟进]会话都没有 (e2ccdea3跑完变completed就退出了网关的活会话集)。⇒ "推"只在目标活着时能用; 目标不在线时靠排期到点拉起一条([跟进]-…每小时,nextRunAt23: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 条执行会话可见)。 -
判据(⛔ 不看"页面像不像"):
- 直接读
/board.json:看ts(快照时间)是不是刚才、会话条数、每条会话的类别, 再与磁盘上的真实快照(宿主库/sessions表)逐条对照。 - 🔴
--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 手写的当场被覆盖)。 - 判据(两条纪律,都落在文件上):
- 同一句话要节流:窗口(
DSH_NEED_GAP,默认 600 s)内同原因 ⇒ 不重写(⛔ 不刷屏)。 ⚠️ 判据必须落在文件上(⛔ 不是内存)—— 钩子每次都是新进程,内存节流跨不了进程。 - 人写的内容必须原样带走:重写时把
## 解除条件及其后整段保留。
- 同一句话要节流:窗口(
- 🔴 为什么这条难自己发现:程序侧"我明明喊了"是对的 —— 问题出在喊的姿势(频率 + 覆盖), ⛔ 任何"写成功了吗"的断言都查不出来。⇒ 得换问法:"人看到这条会怎么想?"
- 判据落地:
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 红(符合预期)。 - 判据:
- 先想清楚"这个样式从哪来"再写判据 —— 三种来源要分开查:内联属性 /
<style>里的类规则 / SVG 的 presentation attribute 默认值。⛔ 别默认"属性应该挂在标签上"。 - 🔴 判据要能在改前报红 ⇒ 参与对照的备份文件路径必须跟着一起换(本轮靠
BOARD_HTML环境变量指过去)。 ⚠️ 备份里根本没有.grp规则 ⇒ 报红正是预期,⛔ 别看到红就以为判据坏了。
- 先想清楚"这个样式从哪来"再写判据 —— 三种来源要分开查:内联属性 /
- 自查:新增一条「样式类」判据时,问一句「这条规则写在哪个文件里?我读的是那个文件吗?」
P0-14 🔴 替换注释块时漏掉结尾 */ ⇒ 整段注释吞掉后面代码,而「数括号」查不出来(★ 2026-10-01 实测)
- 形状:用编辑工具换掉一段
/* … */注释时,替换的两端都没带上结尾*/⇒ 新注释与它下面那一行代码粘连成一条注释,从那行往下的代码全被吃掉。 - 实测:
assets/board.html改完,node --check只报一句SyntaxError: Unexpected token '}', 并说「多余的}在 772 行」—— 而真正的病灶在 ~1000 行(那里少了一个*/)。 当场表现为渲染断言 35 条全红,看着像"改坏了半个文件"。 - 🔴 为什么这个坑难查:第一反应是数
{和}的个数。没用 —— 被吞掉的那段里本来就有{,它从「代码」变成了「注释里的字符」⇒ 两边计数照样相等; 同理,字符串字面量里的}(含中文引号包住的文案)也会干扰朴素计数。 ⛔ 别用字数统计判括号平衡。 - 判据(两条,任选):
- ✅ 最省事:改完立刻跑一次语法检查 —— JS
node --check <f>/ Pythonpython -m py_compile <f>。 ⚠️ 本坑当时确实跑了node --check也报了错,只是它报的行号把人带偏了 ⇒ 报了错也别只信行号。 - ✅ 要精确定位:用能识别字符串/注释/正则字面量的扫描器做嵌套深度统计 ——
本轮落在
tmp/_brace4.js。它同时跑「改前备份」与「当前文件」:备份depth=0、当前depth=-1⇒ 一眼看出「多的不是括号,是注释没闭合」。
- ✅ 最省事:改完立刻跑一次语法检查 —— JS
- 自查:凡改动跨
/* … */边界 ⇒ 必跑语法检查 +(大文件)深度扫描器对照备份。 ⛔ 别只信「我只改了一处、不可能影响别处」。
P0-13 🔴🔴 断言在「空样本」上恒真 ⇒ 看着绿其实从没执行过(★ 2026-10-01 实测 · 空样本一出现就翻红)
- 形状:
arr.every(...) === true这种写法,arr为空时恒真 ⇒ 用例一条都没验却是绿的。 🔴 与 P9「校验"可用"时只看"能跑起来"」同族,但更阴:它不是不严,是根本没跑。 - 实测:
tmp/render-check.mjs里那条「唤醒会话/跟进会话⛔ 不许混进第三层」写成_notWorker.every(s => !arch.includes(s.id8))—— 而当时线上一条唤醒/跟进会话都还没有 ⇒_notWorker恒空 ⇒ 恒真。⚠️ 当天真建出唤醒会话后它立刻翻红, 而且红得没道理:它查的是「整张图里有没有这个 id」,而唤醒会话本来就该画在主会话左侧 (那是它的正式位置)⇒ 同一处两个毛病:空样本假绿 + 判据写错了地方。 - 判据(两条):
- 空样本不许算通过 —— 要么 SKIP(像本文件里"前提不成立就 SKIP"那几条),
要么用合成样本把分支逼出来(
render-check.mjs的unrecBad/retBad/wlBad三个注射块就是这个手法); - 🔴 断言必须能"改前报红" —— 拿改前备份跑同一套桩做红绿对照; 备份已被后续改动覆盖时,用变异法(把判据回退成旧写法,看它是否报红)。
- 空样本不许算通过 —— 要么 SKIP(像本文件里"前提不成立就 SKIP"那几条),
要么用合成样本把分支逼出来(
- ⚠️ 连带教训(同轮同时踩到的第二大坑):别把期望值写死在断言里。
同一批断言里有 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✅ —— 说明只要标题带主控就没问题, 坏的是"规范没被执行"(实际建出来的主会话续棒写成了接续 · <线> · <具体>,没带主控)。 - 两条正解(⛔ 都属方案变更,先拍板再动):
- 补登记(零代码改动):
collabd.py --declare --role main登记该会话 ⇒ 走resolve_main()第①层。 ✅ 立刻可用;⚠️ 登记是静态的,换主会话后不会自己变。 - 改判据 + 收紧命名(治本):
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/。 - 两个真因(同一条链,都要修):
- 🔴 钩子把技能包当工作区:
wb-result-hook.py的_WS_ROOT原按3×dirname(__file__)推 —— 包内这份(<包根>/scripts/hooks/)推出来正好=技能包自己 ⇒ 交给collabd的COLLABD_WORKSPACE是包目录 ⇒ 运行产物落进包里。同文件里本来就有正确的resolve_ws()(env 优先 + 内容标志上溯)⇒ 这不只是"推错",是同一事实有两套实现。 ✅ 改为复用resolve_ws(),并加「未知工作区 ⇒ 停手」(⛔ 不拿 cwd/包目录顶替)。 - 🔴 重定向 ⇒ 编码崩:
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 connectionId303 次,其他小时 0 次 ⇒ 客户端连接状态在该小时反复丢失。 - 与相邻几型的区别:②f 有中断证据 · ②g 工具在循环 · ②h 干完了推不出去 · 本型=消息在队列里、没有消费者(
session-mechanism(references/forensics.md§3))。 - ⚠️ 窗口里可能同时叠着另一层:本次 22:58:40–22:59:50 还有
diagnostic log write failed(droppedLines496→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 ⛔ 子进程不加 CREATE_NO_WINDOW ⇒ 桌面反复闪黑窗(用户:"一会弹出来一会弹出来的,影响我操作电脑")
- 现象:守护/执行检查跑起来后,桌面每隔 10~30 秒闪一个控制台窗口。
- 根因:程序里有
subprocess.run(["netstat", "-ano"], stdout=PIPE, …)(认网关端口用)——netstat是控制台程序,不带creationflags=0x08000000就会新建一个控制台窗口;而该函数每轮都被调(执行检查 ~10 s/投递 ~30 s)⇒ 桌面反复闪。 - 修法:✅ 所有
subprocess调用一律带creationflags=0x08000000(CREATE_NO_WINDOW)。 ⛔ 只重定向stdout/stderr是没用的 —— 窗口照样会建。 - 验收:桌面从"反复闪"变为"零窗口";功能侧用"能否照常认出网关端口"核对(日志/
wakeups.jsonl仍有记录)。 - 🔑 推广:任何长期循环里的外部命令调用,先过一遍"它会不会开窗"。
本档给现象 / 根因 / 修法。架构层判据 ⇒
references/architecture.md;原技能存档 ⇒references/collab-detail.md §6。
P1 ⛔ 用「会话后台任务」跑常驻 ⇒ 宿主"卡死"
- 现象:用户说"卡住了/发消息不恢复";会话永不回 idle。
- 根因:会话后台任务每轮输出都会唤醒宿主会话。
- 修法:🔴 首选=根本不要常驻 —— 由宿主钩子按需唤起一次性进程(见 P0-3)。
⛔ 旧建议"用
run_in_background起常驻"是错的:那正是 P0-2(任务记在该会话名下 ⇒ 该会话卡输出)。 真需要常驻时,只能会话之外起(启动文件夹/独立窗口),并接受它拿不到口令 ⇒ 只能落盘、不能投递。 - ⚠️
cmd start/wmic process call create/ PowerShellStart-Process常被安全策略拦 ⇒ ⛔ 别在"独立起进程"上耗时间。
P2 ⛔ 自愈没有去抖 ⇒ 反复杀掉正在服务的好实例("自愈比故障更伤")
- 现象:监控到的端口反复通/断;日志里"launching → 又被杀"循环。
- 根因:启动器常有幂等闸("进程活着但端口不通 ⇒ 杀掉旧的再拉一份");若守护进程每次探测失败就触发,就变成抖动,把可能正在服务的好实例打死。
- 修法:连续 N 次(≥3)失败才动手 + 冷却(数百秒) + 探测用socket 连一下(⛔ 别发真请求)。
P3 ⛔ 粗粒度域锁 ⇒ 自造串行瓶颈,整轮白开
- 现象:别的会话抢锁失败 ⇒ 什么都不做就退出(纯浪费的会话)。
- 根因:把整个工作区声明成一个域 ⇒ 所有写操作串行。
- 修法:按节点涉及的文件/子目录声明域;不重叠即可并行。机制层才独占。
P4 ⛔ fail-open 掩盖字段名错误 ⇒ 视图静默为空
- 现象:
rc=0、日志无异常,但某个区块一直是空的。 - 根因:异常被
except: pass吞掉;查询写错列名(真实例:给sessions查了不存在的name列)。 - 修法:① 必须核对输出非空(⛔ 不能只看退出码)② 关键查询先
pragma table_info(<表>)核对列名 ③ 定期diff视图一眼。
P5 ⛔ 把"接续任务"当成果 ⇒ 被"空转链条"骗
- 现象:看起来一直在推进("下一棒已派"),实际总工期不动。
- 根因:排期 ≠ 成果。
- 修法:证据分级(真成果/接续任务/刚开跑/哑火);"在跑的棒 N 个"是排期数,⛔ 不是成果数。
P6 ⛔ 用"加自动化"回应一切需求 ⇒ 会话洪泛
- 现象:一个需求挂上 7 个自动化,其中 5 个同用途。
- 修法:白名单+确认制(只有"接续会话/派活"可免确认)+ 配额 ≤2。建前自问:非得开新会话吗?已有的能不能覆盖?
P7 ⚠️ 一次性自动化跑完 status 仍为 ACTIVE ⇒ 统计虚高
- 现象:数"在挂的棒"= 6,实际只有 2 个真在等。
- 修法:一律用
next_run_at > now过滤;⛔ 不看status单独判断。清理僵尸旧件(已跑完的 once)。
P8 ⛔ 诊断数据不落盘/不落库 ⇒ 只能靠"自述"
- 根因:把"谁干完了、结论是什么"寄托在文件扫描/人报告上。
- 修法:读宿主已落的运行记录(
automation_runs.thread_title等)⇒ 0 token、不轮询。 - ⚠️ 但要核对该列是否真有内容(
IN_PROGRESS时标题可能为空 ⇒ 判"在跑"看status)。
P9 ⛔ 校验"可用"时只看"能跑起来"
- 现象:结论写"八项判据起停各一遍全绿",但端口此刻并不通。
- 判据:"能跑起来" ≠ "可用"。可用 = 现在这一刻服务可达,且能被维持住。
P10 ⚠️ 长前台命令被沙箱杀,连带杀掉先前后台起的进程
- 现象:命令无输出、
Exit -1;随后发现后台进程也没了。 - 修法:单条前台命令控制在 ~90 秒内;长活拆短步或走异步。
P12 ⛔ 反复重启常驻程序 ⇒ 把主会话自己弄卡(最容易被忽视的一条)
- 现象:每次进入"改常驻程序"的阶段,主会话就变卡、被反复打断(用户原话:"为什么每次一改到这里就把自己的会话弄卡")。
- 根因(两条叠加 + 一条次因):
- 🔴 用会话后台任务起常驻 ⇒ 该进程成为本会话的附带物;每次 kill / launch 都产生一条
failed任务通知 ⇒ 每次都打断会话。实测:一个阶段里重启 7~8 次 ⇒ 7~8 次打断。 - 🔴 同一会话里做了 500+ 次工具调用 + 反复读写长文件 ⇒ 上下文极大 ⇒ 每轮推理显著变慢。
- ⚠️ 注入钩子每轮往上下文加 ~1KB(应压在几百字符)。
- 🔴 用会话后台任务起常驻 ⇒ 该进程成为本会话的附带物;每次 kill / launch 都产生一条
- 修法:
- 攒批重启:代码改动攒到一次再重启(≈3 处以上/或等功能自测全过)。⛔ 不要"改一行重启一次"。
- 优先热加载:把规则/配置/任务图/队列做成程序每轮读文件 ⇒ 改这些根本不用重启。
- 长任务换会话:一个会话不要既"建系统"又"跑长验收" ⇒ 到阈值就交棒接续(这正是本机制存在的意义)。
- 注入瘦身:
additionalContext压到几百字符。 - 🔴 改成"拉取模型"(最根治):程序只写队列/文件,⛔ 不调用会话、不通知、不起后台任务;会话在"用户发话 / 棒收尾 / 需要时"三个时机主动拉队首 ⇒ 没有"推"就没有打断。详见
references/collab-detail.md§1.3。
🔑 一句话:"改代码 → 重启常驻 → 产生失败通知 → 打断自己"这个循环,是主会话变卡的机制性原因,而不是外部故障。 ⇒ "拉"取代"推",是这个问题的根治解。
- 现象:
SyntaxError: unterminated string literal。 - 根因:在双引号字符串里写了 ASCII 双引号。
- 修法:文案里的引号一律用
「」;改完ast.parse校验(比py_compile报错更清楚)。
P13 ⛔ 常驻"活不长" ∧ 钩子指向无唤醒码的旧副本 ⇒ 机制静默停摆(最阴的一条:没人会发现)
- 现象:唤醒回路代码写完、也实测到 1 次真投递,但此后再也不投。查下去:常驻进程已消失、单例端口
Connection refused、日志停在某一刻;而"唯一会自动跑"的钩子那条路,跑的是另一份旧的精简副本(没有队列闸门、没有唤醒码)⇒ 整个机械层静默停摆,且没有任何告警——因为告警也是那个程序发的。 - 根因:① 从会话里起的常驻会随其宿主会话结束被回收(实测:
13:09起的 pid,13:12之后再无一轮);② 同目录另存过一份旧副本且钩子指向它 ⇒ "代码在 A、钩子在 B" ⇒ 接了线等于没接;③ 认口取"uptime最大"的口,而那个口可能没有活会话 ⇒ 有活会话也照样落no-live-session跳过。 - 修法:
- 🔴 "能自动跑起来"的路只有一条 = 钩子(每个会话收尾跑一轮、零 token、不占会话)⇒ 把机制的关键环节挂在这条路上,⛔ 别押在"常驻一直活着"上。
- 钩子必须指向最新那份(含队列/唤醒);同目录⛔ 不要留旧副本,或让旧副本显式转调新版(fail-open + 静默 + 超时,⛔ 不改自己原有行为)。
- 认口=「第一个带活会话的口」,⛔ 不是
uptime最大者(实测两者常不同)—— 同 cwd 多实例时,这条正是投得出去 / 投不出去的分水岭。 - 判"接线成立"看产物:每轮覆写的视图/队列/状态文件 mtime 是否跟着"会话收尾"推进 —— 比读代码可靠得多(本节即靠这一条定位的)。
P0-23 🔴🔴 钩子超载 ⇒ 用户每一句话都被拦下(2026-10-02 全工作区级事故,三次出手才修干净)
- 现象:
UserPromptSubmit operation blocked by hook: …wb-result-hook.py: Hook timed out after 20000ms⇒ 工作区所有会话无法执行(不是变慢,是提交失败)。 - 根因(实测读数,⛔ 非推断):
UserPromptSubmit宿主注册 20s,而该钩子在这条路上同步串联了三个子进程 ——--gap实测 13.5s |--once硬超时 12s |--tick实测 13.5s(hook.log留痕:16:12:38 done in 12819ms); 同一事件还挂着另外 5 个钩子并发抢同一颗 CPU ⇒ 均值 13.5s 的活只要抖动一次就越线。 🔴 第二大致命点(同一雷的另一种形态):PreToolUse ^Bash$上同步跑口令投递timeout=12⇒ 本机每条 Bash 命令执行前先等它。 - 为什么"修了一版"没修好(15:31 那版):引入了
_BUDGET���算 +_bg后台机制方向是对的, 但为--gap保留了_sync兜底同步分支(判据:缓存为空 ∨ 过期 >2×TTL)⇒ 最坏路径依然存在。 ⇒ 🔴 教训:在秒级预算的钩子里,"兜底同步"是伪需求。注入物是建议不是判据 —— 本轮拿不到就少注入一段(下一轮自然补上),⛔ 绝不为此等待。这是 fail-open 的正确姿势。 - 修法(三条硬规则 · 此后新增钩子一律照此办):
- 🔴 拿到结果才有用 ⇒ 才允许同步等;否则一律后台(
_bg:Popen+ stdout 落文件 +DETACHED_PROCESS,⛔ 不用 PIPE、⛔ 不 wait)。 判断句:「这个子过程的返回值,钩子会用吗?」答不上来 ⇒ 后台。 - 🔴 算术题先算再写:
宿主注册值 - 解释器冷启动(实测 0.3~1.0s,波动不可控) - Σ子过程实测耗时 > 50%⇒ ⛔ 不许同步。 本机为证:20 − 1 − 13.5 = 5.5s 余量,而同事件另 5 个钩子在抢 CPU ⇒ 必越线。 - 🔴 ⛔ 别信"实测均值",要信"硬超时":
--once均值 0.5s 看着安全,但它的timeout是 12s ⇒ 抖动时照样吃掉大半预算。 - 🔴🔴 脚本自己必须带"自身硬闸"(本次没做这一步就是没根治):前三式只修掉了这一次的那三个具体活;
任何人(含之后的另一个会话)只要再往这条路上加一个同步子进程,故障就原样复发,
而复发的第一现场是「用户说不了话」——不可能等到事后排查。
⇒ 落地:
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字段逐轮复用 ⇒ 无论用户当轮说了什么,每轮都在下同一条命令。三个后果: ① 越权(用户没要求就建排期)② 重复(同名无去重,越排越多)③ 无上限/无过期(用户原话「越排越多」)。 - 修法(三条,⛔ 改文案治不了,必须动结构):
- 🔴 授权闸:
_authorized_now()—— 只认用户当轮原话里的触发词(GAP_TRIGGERS)。 ⚠️ 刻意不用「上一轮说过」「队列有缺口」「机制建议」当授权 —— 那些都是系统自己的诉求,不是用户的。 ⚠️ 必须把当轮payload存进模块级_CUR_PAYLOAD,否则判据读到空串、闸门形同虚设。 - 🔴 缓存存原始数据、不存成品文案:
gap-cache.json改存_raw(--gap原始 JSON), 文本在读取时现算(_gap_text(raw, authorized))⇒ 呈现永远跟当轮授权一致。 旧版缓存(只有_text无_raw)判为无效自动重刷,⛔ 不做兼容迁移(迁移=把旧文案再投一次)。 - 🔴 未授权时只通报、不派活:标题写「ℹ️ 现状通报(⛔ 不构成行动指令)」并明写「⛔ 不要建任何排期」;
授权时才输出「⚠️ 缺会话 ⇒ 自动拉起」,且要求在回复里说清是依据本轮授权做的。
另加
_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。 - ✅ 两条硬纪律:
- ⛔ 绝不把
delete的success:true当已删 —— 每次删完必须回读deleted_at(coalesce(deleted_at,'')=''才算未删)核对。 - ⛔
list的返回行数 ⛔ 不能当在册总数(本例list2 条 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/webserverai1net-dsh-server/domains❌ 全部撞键 …/ai1net-dsh-server/domains/desktopai1net-dsh-server/domains❌ 同上 …/ai1net-dsh-server/webserverai1net-dsh-server/webserver✅ 独立 …/ai1net-dsh-server/交付物/webserverai1net-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_id300 秒内只放行一次。 三个场景用同一个sid⇒ 后两个被静默挡在早退分支 ⇒ctx恒空。 - 🔴 危害不在这一个钩子:「没输出」与「没检查」长得一模一样 ⇒ 一旦顺着"钩子坏了"往下查, 会把时间全花在读代码上,而真因是自己造的自检环境。本轮就是这样绕了一圈。
- ✅ 两条硬纪律:
- 验任何带节流/去重的钩子,每次调用必须换一个唯一标识(本例=
session_id)。 - 验收脚本里加一道硬门:
注入为空 ⇒ 立即判失败(本轮做成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 实测根治)
- 两类药,缺一不可:
- 常驻/会重生的 daemon ⇒ 用
pythonw.exe(GUI 子系统,Windows 永不为其分配控制台)。sys.executable⇒python.exe(控制台子系统)⇒ 被 detach 后必新开控制台 ⇒ 每次重生闪一次。 ⚠️DETACHED_PROCESS单独用不够:它只是"不继承父控制台",控制台 exe 仍会被新分配窗口。 - 一次性短 spawn(
netstat/taskkill/--tick)⇒ 补creationflags=CREATE_NO_WINDOW(0x08000000)。 ⚠️ 常驻那批可加| DETACHED_PROCESS(0x8) | NEW_PROCESS_GROUP(0x200); ⛔ 不要CREATE_BREAKAWAY_FROM_JOB(会被拒)。
- 常驻/会重生的 daemon ⇒ 用
- 🔴 自检必须用 AST 全量扫(⛔ 别 grep):本轮靠它捞出
board.py:1212的taskkill—— 那是七文件修完后最后一处漏网(--takeover路径上的)。 - ⚠️ 不是所有缺 flags 都该死:
NeoData金融搜索服务/scripts/query.py:51是 pip 装包 (只在缺 requests 时跑一次、非周期)⇒ 与闪窗无关;selftest.py7 处是自检脚本。 ⇒ 判据="这个调用会不会被周期/重生路径反复执行",⛔ 不是"缺了就是错"。 ⚠️ 第三方技能包缺 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)长期存活 |
🔴 教训(判据级):
- ⛔ 别用「这一种起法失败」推「本机不可能」 —— 至少验三种载体(工具调用树/宿主后台任务/用户登录会话)再下全称否定。
- 反证优先于推断:用户一句「那个工作台是如何一直运行的」就是现成反例 ⇒ 该先去找它怎么起的(
start.bat一眼看到start "" node.exe),⛔ 别先写一段"本机不可能"的理论。 - 查证方向错了要整条撤回,不只是改措辞:基于假前提给的建议(「改用排期当时钟」)⇒ 整条撤,⛔ 不许"保留但加个注释"—— 那会让下一个人照着错的前提做决策。
- 改了代码 ≠ 改了知识:这类结论至少落在 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 实测)
现象:两处「改了配置但看板纹丝不动」,成因完全不同,⛔ 若不取证会把两次都归成同一个错。
-
配置根本不重读 ——
board.py的C(配置字典)是模块级加载一次,⛔ 不每次快照重读。 ⇒ 改collabd.config.json后必须重启看板。 ⚠️ 这是 P0-17「改了看不见」漏记的一面 —— 之前只记了「改board.py代码要重起」,⛔ 漏了「改配置同样要重起」。 ⇒ 判据:看进程启动时间 vs 源文件 mtime(board.py的C无 re-read 路径)。 -
🔴🔴 读的是另一份配置(更隐蔽)—— 启动服务时没设
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)。 -
连带发现:
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 认领方式对机制层不成立)。
🔴 判据:
- 锁的生命周期 = 任务的生命周期(2026-09-14 用户明令)—— 做完必须
--release-exec。 ⚠️ 干等自己那把锁是最贵的浪费:别人进不来、我也忘了它在谁手上。 - 机制层文件 ⇒ 认领必须 ⛔ 不带
--domains(带域=域锁,对机制层不成立); 正确姿势:--claim-exec "<名>"(不带域)= 全局独占。 - 派棒前先
--status看锁;⛔ 别让自己成为别人的路障。 - 🔴 执行棒"零改动"先查锁,别急着判它失败 —— 判据=
automation_runs.thread_title里它自己写的停手原因(本轮那句「状态已跑…域锁抢锁失败…」是完整取证)。
P0-35 🔴🔴 「每轮 unlink 一个文件」会撞宿主 SafeDelete 批量删除护栏 ⇒ 常驻被系统终止(★ 2026-10-03 实测,同 P0-6 的根因第二次复发)
症状(本轮现场):
- 常驻
--supervise起来 21 秒后rc=1退出,supervise-bg.log只有一行:[safe-delete][SAFE_DELETE_BULK_CONFIRM_REQUIRED] {"count":172,"threshold":50,"scope":"turn","targets":["…\\TO-MAIN.md"],"targetCount":1} - 而心跳、pid 文件都还在(最后一条
round=3)⇒ 第一反应会误判成"我改坏了代码"。
真因:宿主有 SafeDelete 批量删除护栏 —— 按"本轮删除动作"计数,达 50 即要求确认并拒绝。
我写的退役清理是 if TO_MAIN.exists(): TO_MAIN.unlink(),看着很安全(文件不存在就不删),
但实际是172 次删除动作。三个原因叠加:
exists()→unlink()之间有窗口(TOCTOU);- 投影轮(
mutate=False)与常驻轮(mutate=True)是两个进程各跑一遍 ⇒ 判据被重复执行; - 护栏计的是动作数,⛔ 不是"真的删掉几个" ⇒ 失败/异常也算一次。
修法(判据级):"一辈子只删一次"落成状态位,⛔ 不是靠"检查文件在不在"。
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)
🔴 判据(写下来防复发):
- 常驻里任何"周期性写/删"的文件,稳态下每轮的写删次数必须 = 0 —— 「降低频率」不是修法(P0-6 已经吃过一次:那套"快速否决"省不掉,因为内容一直变)。
- ⛔ 别用"文件在不在"当幂等判据(TOCTOU + 跨进程重复 + 异常也计数)⇒ 用状态位。
- 🔴 本条与 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>"}
🔴 判据:
- 判"有没有调用" ⇒ AST(只认
Call节点的func位);⛔ 判"有没有出现"才是字面 grep。 - ⚠️ 滤
#开头行不够 —— docstring、'''块注释、字符串字面量里的字样都会骗过你。 - ⛔ 不许因为字面判据假红就把断言改弱("改成
deliver=False"=换个说法说同一件事,P0-13 同族) ⇒ 换可证伪的判据(本轮用的是「真调用supervise(deliver=True, mutate=True)⇒deliver.skipped必须为retired-20261003」——⛔ 改实现就会红,不是恒真)。
P0-37 ⚠️ 「一个角色退役了」与「承载它的那个进程也退役了」是两件事(★ 2026-10-03 实测)
现场:常驻 --supervise 原本只有两个职责(① 队列投递 + 唤醒 ② 建检查会话排期)。
投递退役后我一度想连常驻一起删(既然它不投递了)。查完发现 ② 那条腿只有它承载 ——
maybe_spawn_check_agent() 是唯一建 [检查]-… 排期的地方,而检查会话只能靠排期开(⛔ 开不了别的通道)。
🔴 判据:
- 删一个组件前先答一句:「它现在还承担什么?那些职责另有谁承载?」 ——「它以前主要做 X」⛔ 不等于「它只做 X」(本轮就是靠这句话漏看了 ②)。
- 职责退役 ⇒ 改名 + 改副标题,⛔ 不等于删节点 —— 架构图上那格画着「常驻投递 · 一直运行」 而实际那两段已删,就是 **P0-25「服务自报健康 ≠ 服务有内容」**的活标本。 本轮改成「建检查会话排期 · 一直运行」+「⛔ 投递已于 2026-10-03 退役」两行。
- ⚠️ 反例同样要认:
--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 里还留着测试值。
修法(三条,缺一不可)
- 加载前换成目标配置、用完还原(
_old = {k: os.environ.get(k) …}+try/finally逐个还原) —— ⛔ 不还原 ⇒ 污染后续用例,且这种污染不报错、只让别的用例莫名飘。 - 在真磁盘上造目标(
tempfile.mkdtemp()里各放一份goal.json) ⇒ 读数来自真文件,⛔ 不从夹具推导。 - 加变异对照:把
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」的名字 ⇒ 把本工作区的执行情况说成了对方的。 ⛔ 假数据比空白危险:空白会被追问,假数据会被当成结论直接用。
修法(三条,缺一不可)
- 每格的数据源必须跟着格走 ⇒ peer 格只喂
_peer_tasks()/_peer_srows()/_peer_state(), 三者只读对方目录下的文件;读不到 ⇒ 空 + 界面如实说明(peer_scope+ 前端renderPeerNote()), ⛔ 绝不拿本工作区的数据补位。 - 必须补捞 ⇒
_session_rows()取的是全库最近 50 条(按last_activity_at倒序) ⇒ 对方工作区的会话一条都不在里面 ⇒ peer 格显示「0 条」=假象(对方主会话明明working)。 ⇒ 单独_peer_session_rows()按cwd精确补捞(SQL 里replace(cwd,'\','/')=?,⛔ 别拉到 Python 侧全表扫)。 ⛔ 不许为了捞它把全局limit放大 —— 那会让本工作区的others_running计数暴涨(为一个新功能改坏老行为)。 - 作用域要跟着换 ⇒
_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 仍带🔴⇒ 假红)。