Files
workbuddy_skills/session-mechanism/references/architecture.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

1137 lines
117 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# WorkBuddy 多会话执行 · **架构**(唯一文档 · 在此迭代)
> 🔴 **性质(2026-09-29 用户定案)**:**本文件 =「多会话协作」的唯一架构文档。**
> · **改架构 ⇒ 只改本文件**;⛔ 不另开平行文档、⛔ 不在别处再写一份架构。
> · 与它**冲突**的旧件(`交付物/多会话协同机制-定稿`/`…顶层设计`/`…实施方案`/`协同监管棒-SOP`)一律**降级为历史/过程**:可作背景,⛔ 不作用判据。
> · 本文件只讲**"该做成什么样"(架构)**;**怎么操作**(派活模板/作业规程/踩坑)⇒ `SKILL.md` 与 `references/`。
> · 位置:`~/.workbuddy/skills/session-mechanism/references/architecture.md`(随技能走)。
> · ⛔ 与「手机接入」那条业务线**无关**(那是另一件事,见其自己的文档)。
> 🔴 **2026-10-01 用户口径(最新)**:**「接续会话 时间缩短 3-4 分钟即可」** ⇒ 排期基准 **= 收口 + 3~4 分钟**(原「5~8 分钟」**作废**)。
---
## 🔴 当前结论(**先读本节** · 最后更新 2026-10-03 07:2x)
> 🔴🔴 **2026-10-03 07:2x 口径改(本条为准,排在下面 10-01/10-02 那几条之上)——「上报/投递」整套退役**
>
> 用户逐字:「**没用了就删除,现在的机制是 协作程序接收和处理队列**」;
> 又逐字驳回我把架构图上的「上报」改名成「常驻」:「**怎么又把上报 改成常驻了 常驻什么,不是 协作程序常驻吗**」。
>
> **一、投递这件事已经不存在了**(⛔ 不是"降级",是**代码真删**):
> `supervise()` 里的 ①′待反馈序列 + ②③单条握手 + ④唤醒 四段 + 两个 `_deliver_str()` 调用点
> **203 行 → 71 行**;`--tick` 里的 `check_delivery_consumed()` 调用删除;`collabd.config.json` 的
> `wake_enable` **true → false**。⇒ **队列变化不再自动通知任何人**;要落一件事,**显式建一条任务会话**。
> ⇒ 删前实测证据:`follow-retired` 日志 **1 313 行且每 20 s +1**、`notify_pending` 四条**全是 `done`**、
> `wakeups.jsonl` **文件不存在**。⇒ 它是**三重死锁**(投递失败 ⇒ 不消费队列 ⇒ 队首永驻死件 `S8=done`
> ⇒ `no_fb` 恒 False ⇒ 唤醒条件②永不成立),⛔ **不是一条腿断**。
> ⇒ 现行机制只剩两条腿:**接收**=`--report` 写 `tasks.json` 四态台账(唯一权威);
> **处理**=建 `[检查]-…` 排期(`maybe_spawn_check_agent()`,五道闸)。⛔ **没有"推送"这一环**。
> 🔴 **五道闸里的① 于 2026-10-04 收窄**(用户口径:「**检查程序必然要去处理队列**」):
> `sessions-ended`(队列非空=**有活没人干**)**不受目标状态限制**;
> `queue-empty` 仍须「进行中」(它的职责是判目标该不该收口)。实体 ⇒ `pitfalls.md` P0-56。
>
> **二、⛔「常驻投递」这个角色名本身也不该再用** —— 那格的角色**本来就叫「协作程序」**(`collabd.py`);
> "常驻"只是它的一种跑法(`--supervise`),**是运行形态、不是角色名**。
> ⚠️ 架构图上那**两格**(协作/上报)本来就是**同一个进程**,历史上真有两个进程才拆两格
> ⇒ 退役后**合并成一格「协作程序」**,⛔ 不留"常驻"这个第二节点。
> ✅ **判据(P0-37)**:「**一个角色退役了**」≠「**承载它的那个进程也退役了**」——
> `--supervise` 的另一条职责「**建检查会话排期**」(`maybe_spawn_check_agent()`,唯一建 `[检查]-…` 排期处)
> **仍只有它承载** ⇒ **留进程、改副标题,⛔ 不删节点**。
>
> **三、下文那些「常驻投递/唯一投递方/唤醒三条件」一律按此读**:
> 「常驻投递」=**已退役的职责**,读到即跳过;`--supervise` 现存职责=**① 建检查会话排期 ② 写心跳**。
> ⚠️ **「唤醒」那条整条随之失效**(唤醒的载体就是投递)—— ⛔ 别再拿它当现行机制。
> ⇒ 全文逐条改**未做**(散在 4 份约 200 处);⛔ **本块就是判据**,与下文冲突处以本块为准。
> ⇒ 机制级细节与验收清单 ⇒ `SKILL.md` 文首 10-03 状态块 + `references/pitfalls.md` **P0-35 ~ P0-38**。
> ⚠️ **为什么要这一节(用户 2026-09-30 点破)**:本文件此前是**追加式**的 —— **正文是旧的、最新结论沉在文末 §9**,
> 谁先读正文谁就被带偏(我当天因此**反复拿旧结论当现状**)。⇒ 故设本节:**一切以本节为准**;
> 与下文冲突时,**以「本节 + §9 最新一条」为准**,并请**顺手把冲突处就地标注**。
| 项 | **当前结论** |
|---|---|
| **运行形态** | 🔴🔴 **2026-10-03 07:2x 起「投递」整套退役**(见本节上方状态块)—— **目标检查一直运行(常驻),但职责只剩「建检查会话排期 + 写心跳」**。⛔ 「协作与投递一直运行」这句话**里的"投递"半边已作废**,只剩"目标检查一直运行"。起法见 §5-1(⛔ §5-1 里凡讲投递的段落同样按上方状态块读)。 |
| **投递** | ⛔🔴 **2026-10-03 07:2x 已退役,不是"降级"而是真删**:`supervise()` 203→71 行、两个 `_deliver_str()` 调用点删除、`--tick` 的 `check_delivery_consumed()` 调用删除、`collabd.config.json` 的 `wake_enable` **true→false**。⚠️ 保留的 4 个函数(`_deliver_str`/`follow_for_topic`/`wake_round`/`check_delivery_consumed`)是**零调用点**实现,`selftest` 有 4 处**真调用**前两个 ⇒ ⛔ 删函数体=`NameError`;⚠️ 判"函数体内没调用 X"**必须走 AST**(`selftest.py::_calls_in()`),字面 grep 会被退役 docstring **永久假红**(P0-36)。⛔ 「唯一投递方=常驻投递进程」整条**作废**。 |
| **接收/处理(现行两条腿)** | **接收**=`--report` 写 `tasks.json`(**四态台账,唯一权威**,唯一真源);`--once` =**纯投影**,⛔ 不写。<br>**处理**=`maybe_spawn_check_agent()` 建 `[检查]-…` 排期(五道闸),由常驻 `--supervise` 每 2 轮判一次 —— ⛔ 这是这条腿的**唯一载体**(**检查会话只能靠排期开**)。<br>⛔ **没有第三环**:队列变化**不再自动通知任何人**。 |
| **⛔ 已废弃** | **「用自动任务当闹钟」**(2026-10-01 用户明确废弃,原话:「**定时任务的方案已经废弃了**」)⇒ §4.0/§4.1 只作历史。|🔴 **2026-10-03 新增**:**队列投递段 + 唤醒回路**(载体就是投递)—— 见本节上方状态块。 |
| **本机起法** | 🔴🔴 **2026-10-02 23:0x 实测订正**:**不是"本机不存在长跑进程",是"载体不同"。**<br>① 同一时刻用 `start /b` 与 `Popen+DETACHED` 各起一条每秒打点的探针 ⇒ 两条**活到约 11 分钟后停在同一 tick**(67/64 行)⇒ **差别不在起法关键字,在父链**(从**工具调用进程树**里起的活不过当轮)。<br>② 反证:MCN 工作台 `mcn-work-shop/start.bat` 由**用户从桌面双击**、属用户登录会话 ⇒ **一直活着**。<br>③ ✅ **正解=走 WorkBuddy 自己的后台任务**(`run_in_background`;⛔ 别用 `subprocess` 自己造)。⚠️ 子进程用 `pythonw.exe`(GUI 子系统 ⇒ ⛔ 不闪窗)|⚠️ `pythonw` 无 stdout ⇒ **必须显式重定向到文件**。<br>④ **S8 自愈仍保留**(`--supervise` 心跳 + `--tick` 顺手续命 + 判据 `pid 活 ∧ 心跳新鲜(<90 s)`),⛔ 但理由已换:**常驻总会被打断** ⇒ 要能自动补回来。⛔ **别拿 `_tick.stamp`/投递日志当证据**(那些轮次是钩子写的 —— P0-21)。<br>⑤ 🔴 **启完必查三样**:`netstat` 有 **LISTENING**(⛔ `TIME_WAIT`/`FIN_WAIT_2` 不算)+ `curl` 得 **200** + `tasklist` 进程在。<br>⑥ 🔴 **改看板要不要重启有四类答案(P0-38,⛔ 别一律重启)**:改 `assets/board.html` **不用**(`board.py:1293` 每请求 `read_bytes()`)/改 `board_ext.py` **不用**(`EXT_CACHE` 按 `(mtime,size)` 热重载)/改 `board.py` 自身**必须**/改 `collabd.config.json` **必须**(`C = _cfg()` 在 `board.py:81` 模块级执行一次)⇒ **判据=先答"这个文件是谁在读、什么时候读"**。 |
| **看板服务(单实例)** | 🔴 **2026-09-30 加护栏**:Windows 的 `SO_REUSEADDR` 允许**同端口重复绑定且不报错** ⇒ 多个 `board.py --serve` **静默并存**、同一 URL 被**随机**应答 ⇒ 快照/代码版本**打架**。现:检测到已有看板 ⇒ **拒绝启动**;换新代码 ⇒ `--takeover`。停机 ⇒ `stop-collab.py`。🔴 **2026-10-03**:`/board.json` 的 `meta.deliver_retired == "2026-10-03"` = 前端把「队列通知」卡画成退役说明的判据。 |
| **唤醒** | ⛔🔴 **2026-10-03 07:2x:这条整条失效** —— 唤醒的载体就是投递,投递已真删 ⇒ 所谓「三条件合取」(主会话未在处理 ∧ 无待反馈 ∧ 需求未完成)**不再有执行者**。⚠️ 下方 §2.0.1 那条「未读验收判据 ⇒ 误判完成」的缺口**随本条一起冻结**(判据本身还在,⛔ 但没人跑了)。⚠️ **「常驻=唤醒时钟」这个类比 ⛔ 别再沿用。** |
| **同工作区 + 会话类别** | 🔴 **默认形态 = 一个工作区**(2026-10-01 用户定)。🔴🔴 **2026-10-03 起会话只两类:① 主会话 ② 任务会话**(唤醒会话/跟进会话/队列上报**已整套退役**)—— ⛔ 原文写的"四类"**作废**。两类**同工作区**,⛔ 不靠 cwd。⚠️ **「接续会话」=形态,⛔ 不是第三类**(撞阈值时由它自己建出来、**角色继承被接续那条**)。 |
| **已退役** | `交付物/` 里 4 份"多会话协同"平行件 + `落地清单-唤醒回路`(2026-09-30 标注)|🔴 **2026-10-03**:**队列投递段 + 唤醒回路**(本节上方状态块有实测读数与三重死锁分析)。 |
## 0 任务目标(**整套机制的运行中心**)
🔴 **机制的一切判定都围绕目标**:判「需求是否完成」看它、判「接下来该派谁」看它、唤醒与摘要都引用它。
⇒ **启动时必须有目标**:`guard.py` 找不到目标(`<inbox>/goal.json` 缺 `title`)⇒ **拒绝启动(rc=3)**,并给出两种声明方式。
🔴 **宁可不开,也不空转** —— 没有目标,机制根本不知道自己在为什么跑。
| 字段 | 作用 |
|---|---|
| `title` | **必需**:一句话目标(摘要/看板/唤醒都显示它) |
| `acceptance_doc` + `acceptance` | 验收判据(如 `V1–V7`)与其文档 ⇒ **判「完成」的依据** |
| `taskgraph` + `lines` | 任务图、涉及的线 |
**声明 / 改写**:`guard.py --goal "<一句话任务目标>"`(或直接编辑 `<inbox>/goal.json`)。
### 0.1 🔴🔴 **目标不是固定配置 —— 它在「调用本技能的那次对话」里说明**(2026-10-01 用户订正)
> 用户原话:「**目标是 通过对话在调用 会话协作skill时说明的,不是固定的**」
**为什么单列一条**:2026-09-30 本技能曾把 `goal.json.topics` 按**工作区里现有的接续入口/接续包
文件名**填了 4 类,并把它当成"待用户拍板定死"的事项。**那是猜,不是说明。**
🔴 危害不是"填错了会报错",而是 —— 它会**静默决定**「哪些会话算本项目、投递往哪条主会话去」,
且**没有任何一处会说"这批类别是猜的"**(同族红线:`§2.3.2` 那种"不崩溃,只是少说一句话")。
**规矩(三条,全部可判)**
| # | 规矩 | 落点 |
|---|---|---|
| 1 | **目标 / 为什么 / 任务类别 / 验收判据**四样,**都由"调用本技能时的对话"产生**;⛔ 技能侧与脚本**不许预设**,⛔ **不许从目录名、文件名、接续入口名去推** | 本文件 + `references/collab-detail.md §0.05` |
| 2 | **"说明"有唯一落点**:`goalctl.py declare --title … [--why …] [--topics …] [--kpi …] --yes`(⛔ 默认干跑;`--title` 必填,脚本**不替你编目标**;省略 ⇒ 不动那一项) | 使用方 `.workbuddy/collab/goalctl.py` |
| 3 | **可覆盖**:下次在对话里再说一遍 ⇒ `declare` 覆盖即可;⛔ 别把上次说明的当成**项目常量** | 同上 |
**未说明时 ⛔ 不许静默**:`topics` 缺省 ⇒ 回落 `goal.short`(单类别,兼容),
但看板**必须**显示来源:`project.topics_source.kind ∈ {declared, fallback, none}`
(`fallback` ⇒ 明写「⚠ 未声明 ⇒ 暂回落目标简称」)。✅ 已加回归用例。
---
## 1 五个主体(架构里**只有这 5 个**)
| # | 主体 | 只做什么 | ⛔ 不做什么 |
|---|---|---|---|
| 1 | **用户** | 看**一个**看板;只拍"边界外"的板(含**授权类**:放行锁/放行重启) | ⛔ 不管过程、⛔ 不必催进度 |
| 2 | **主会话** | **判断 + 派活**:读库+看板判缺口 ⇒ **写一行排期** | ⛔ 不替别线干活;⛔ 不做机械判定;⛔ 不搬砖(除非最靠前那步自己就能做) |
| 3 | **任务会话** | **一棒一线、一次一件**:做本棒,**做完即上报** | ⛔ 不跨线;⛔ 不夹带;⛔ 不常驻 |
| 4 | **目标检查** | 持有**需求台账**;接收**上报**;判定/告警/体检/看板/单例 | 🔴 **⛔ 不派活**(不写排期、不开会话) |
| 5 | **投递** | **逐条读队列** ⇒ 有新变化/静默 ⇒ **告诉主会话**(**投递方 = 唯一推进队列方**);机械判定 | ⛔ 不开会话、⛔ 不派活、⛔ 不落盘/不回显口令(口令**只进程内**用) |
**宿主(WorkBuddy 本体)=地基,⛔ 不是第 6 个主体**。它只提供四样:**状态**(三张只读表)/**调度**(自动化排期=**唯一能开新会话的通道**)/**事件**(钩子)/**互斥**(域锁)。
🔴 **职责硬边界(2026-09-29 用户定案)**:**「投递(通知主会话)」唯一属于投递** —— 目标检查**只维护队列/台账与投影**,⛔ **不做任何投递**。
· 理由:两条路径各自判重 ⇒ 同一内容**成对重复投递**(实测:同 hash 秒级两次)⇒ 每次投递都往主会话**会话队列里压一条** ⇒ 压满即"卡死假象"(22:53 那次事故的根因)。
· 🔴 **2026-09-30 更新**:常驻程序的投影轮(`--once`)必须传 `mutate=False`,**⛔ 不许消费队列**(否则"消费掉却不投递" ⇒ 通知永远发不出去)。详见 **§4.2**。
⚠️ 同批写的「不再靠常驻/投递改由宿主钩子完成」**已于 2026-10-01 被用户推翻**(定案:**执行与投递一直运行**,见 §5-1)。
🔴 **谁是主会话/谁是任务会话 ⇒ 靠「会话命名前缀」区分**(`[主]` / `[协作]` 开头)—— **同工作区、跨工作区都适用**,⛔ 与 `cwd` 无关(见 §2.3)。
---
## 2 主线:**需求台账**(状态一律**上报**,⛔ 不靠猜)
```
① 任务会话**开始执行** ──上报──▶ 目标检查:该需求置「执行中」
② 任务会话**处理完毕** ──上报──▶ 目标检查:该需求置「已完成」
(被挡住 ──上报──▶ 置「**有阻碍**」+原因 ⇒ 主会话**必须**向用户喊话)
③ 投递**逐条读台账** ──有新变化──▶ **只反馈一条**给主会话(§2.1 握手)
└─(a)主会话未在处理 ∧(b)无待反馈 ∧(c)需求未完成──▶ **唤醒**(§2.2)
④ 主会话:核对产物 ⇒ 判缺口 ⇒ **派活**(写一行排期)⇒ 宿主到点拉起任务会话
⑤ **常驻的投递**逐条读台账 ──有新变化/静默──▶ 投递 + 推进队列(§5-1)
(**常驻程序**同样**常驻**运行;宿主钩子只作**补充**,⛔ 不是主路径)
```
| 项 | 约定 |
|---|---|
| **需求台账**(唯一权威 · **就存在这一个地方**) | `tmp/supervise-inbox/tasks.json`,由**常驻程序**持有。一条记录 = 一条**需求项**(状态 + **线 `line`** + 执行者 + 产物 + **阻碍原因**) |
| 状态(**四态**) | `待执行 pending` → `执行中 running` → `已完成 done`,外加 **`有阻碍 blocked`**(2026-09-29 用户定案:需求状态必须有「有阻碍」这一态) |
| **上报通道** | `python collabd.py --report <需求id> --state pending\|running\|done\|blocked [--line 线] [--by 会话名] [--artifact 产物] [--reason 阻碍原因]`(本地命令 · **零 token**) |
| 审计流 | 每次上报追加一行(append-only,⛔ **不参与判定**) |
| 通知 | 投递写 `TO-MAIN.md` + 用一种能到达主会话的通道投出去(现为网关 reply,不夺会话) |
| **唤醒判据** | 🔴 **三条件合取**(主会话未在处理 ∧ 无待反馈 ∧ 需求未完成)⇒ 发唤醒 ⇒ 主会话核对需求推进状态。详见 **§2.2**(⛔ 不是计时器) |
🔴 **红线:任何"执行中/执行完毕/有阻碍"只能来自上报。** ⛔ 不得用 mtime/超时/文本解析等**推测**代替 —— 历史故障(僵尸认领、超时后队首复活、同线互斥失效)**全部**出自"猜"。
#### 2.0.1 🔴 **收口必同步任务图 —— 否则唤醒会永久发**(★ 2026-09-30 实测定型)
**现象**:N9/N10 都已 `done`(台账已上报、产物已独立核对),但**唤醒仍在发**,且每次都判「**需求仍未完成**」。
**真因**:`goals_open()` 的判据是「**台账** 有非 done 条目 **∪ 任务图** 有非 done 节点」——
而 **`goals_open()` 读任务图(`交付物/任务图.json`)** 那份是**静态人工件**,**棒只上报台账、不会去改任务图** ⇒ 任务图的 `status` **必然长期滞后** ⇒ **并集恒为真 ⇒ 唤醒永久发(变成噪音)**。
**为什么不能"把判据改成只认台账"**(⚠️ 这是本条的**关键取舍**,⛔ 别顺手改):
并集是**有意为之的防漏** —— 任务图里可以存在「**已规划但还没派棒、因而不在台账里**」的节点;
若只认台账 ⇒ 这类节点**不会被唤醒提醒** ⇒ **漏待办**(比噪音严重)。
**✅ 正确处置 = 补"同步纪律",⛔ 不是改判据**
1. **主会话收口时(判需求完成前)必须同步任务图**:把已 `done` 的节点在任务图里**一并标 `done`** ⇒ 两处状态一致 ⇒ `goals_open()` 自然归假 ⇒ **唤醒自然停**(它本来就是这么设计的:三条件之一不再成立 ⇒ 不再发)。
2. **棒上报 `done` 时只动台账**(⛔ 不越界改任务图 —— 那是主会话的规划件)。
3. ⚠️ 反之:**主会话新增任务图节点后,应尽快派棒**,否则该节点长期"已规划待派"⇒ 唤醒会持续提醒(这是**期望行为**,不是噪音)。
4. ⚠️ 判 `goals_open()` 时**两处都读**(现状),读不到任一处 ⇒ **⛔ 不因它判完成**(宁可多提醒,⛔ 不漏)。
**「有阻碍」的四条规矩**
1. **必须带原因**(`--reason`),写清**卡在哪 + 谁在等**;⛔ 不许只标"卡了"。
2. 上报为 `有阻碍` ⇒ 投递会**单独反馈**该条,并要求主会话**明确向用户喊「需用户介入」**。
3. **解除由主会话上报**(拿到用户放行后 ⇒ 上报回 `待执行`/`执行中`);解除时**原因一并清掉**(⛔ 不留过期原因误导判断)。
4. 🔴 **原先外挂的"受阻清单"文件并入本台账**(退役)—— ⛔ **不许两处存状态**(今天的故障之一正是外挂与队列各说一套)。
5. ⚠️ **谁报「已完成」**:由**能核对产物的一方**报(通常主会话;任务会话做完也只能报"我提交了",**最终以产物核对为准**,⛔ 不认自述)。
⚠️ **唤醒不是一个定时自动化** —— 它是**投递的一个静默判据**(架构上不引入任何周期性排期)。
 🔴 **2026-10-02(S8)已治本 —— 以下标注保留作历史**:常驻停机那段**已修**,时钟由 `--tick` 顺手
 `ensure_supervise()` 续命(见 §5-1)。⇒ 宿主排期 `[唤醒]-<类别>-<具体>`(`FREQ=HOURLY;INTERVAL=1`)
 **仍保留**,但它的身份降为「**引擎全静默时的复活兜底**」,⛔ **不再是时钟本体**。
 ⚠️ 与它并列容易被混的一条(🔴 **2026-10-02 按用户口径收敛 —— 旧写法「任何会话都别挂后台任务」属过度泛化**):
 **禁区是「用户会在上面发消息的那条会话(=主会话)」,⛔ 不是「会话后台任务」这个形态本身。**
 用户 2026-10-02 09:59 原话:「**那是因为之前跟进就是跑在主会话的,用户发消息和跟进一起处理会卡,
 所以才把跟进独立成一个会话专门处理**」⇒ 真因=**与用户的轮次抢占**,⛔ 不是"挂在会话名下"。
 ⇒ ① 主会话 ⛔ 不许挂长跑(一挂就挤掉用户的话,实测压成 `parkInQueue`「只进不出」);
  ② **跟进会话独立之后,它自己挂长活载体 ⛔ 不再影响用户在主会话的操作** ⇒ **允许**(形态见 §2.3.0e);
  ③ ⚠️ 但**载体优先级仍是「常驻程序 > 会话后台任务」** —— 后台任务有两个已知代价(§2.3.0e)。
### 2.3 🔴 **「都在同一个工作区」——可以,且区分方式已定**(2026-09-29 用户定案)
**可以**:派活时把排期的工作区都指向同一个即可。好处:会话不再按工作区裂组、台账/看板单一、路径不跨盘。
🔴 **区分方式 =「两级会话命名前缀」** —— **`[角色]-<任务类别>-<具体>`**(例:`[协作]-[手机接入]-N9 复测`)—— **同工作区、跨工作区都适用**,因为它**与 `cwd` 无关**。
· **第 1 级 = 角色**(🔴 **四类**,2026-10-01 用户口径:`[主]`/`[协作]`/`[唤醒]`/**`[跟进]`**;⚠️ `[跟进]`=**口径已定、尚未落地**,见 §2.3.0c)⇒ 程序据此判"谁是谁"(投递目标、握手、唤醒条件)。
· **第 2 级 = 任务类别**(🆕 2026-09-30 用户定:`[手机接入]` 这类,取自 **`goal.json` 的 `topics` 清单**,
⛔ 缺省才回落 `short`)⇒ **一眼看出"在协作什么"**;**同一工作区里多任务并存时按它归类**。
· 🔴 **"任务类别"是一个词,四处同义**(⛔ 别当成四个东西):
**`goal.topics` 的一项 ≡ 会话标题里的 `<任务类别>` ≡ 台账条目的 `line` ≡ 分工板的一行**。
· ⚠️ **缺第 2 级 ⇒ 判「未按约定命名」并告警**(只写一级会混掉"谁 vs 在做什么");⚠️ 标题改不动时用 `--declare --role main|worker --topic <类别>` 补声明。
· ⭐ **只需在派活时给自动化命名加前缀**:实测**会话标题 = 自动化名** ⇒ 前缀自动带进标题,**不用额外握手、不用改会话**。
· 判定优先级:**① 命名前缀 ② 显式声明**(`collabd.py --declare --role main|worker`,sid 自动从环境取;留给"标题没前缀"的场合)**③ 未声明 + 告警** ⇒ ⛔ **绝不回落到 cwd 推断**。
· **类别归属写进台账条目**(`line` 字段,上报时声明)⇒ ⛔ 不从会话 cwd 反推"它属于哪一类"。
#### 2.3.0 🆕 **同一工作区 · 多任务类别**(2026-09-30 用户要求,已落地)
> 用户原话:「之前的协作会话是**跨工作区**的,要能支持**同一个工作区** 多会话协作
> (主会话根据任务**自动梳理任务类别**:**通过协作会话名称前缀**的方式区分具体执行会话,
> 所有主会话,协作会话,自动唤醒任务,**都在一个工作区**)」
**为什么旧版不够**:旧版归属判据只认**一个** `goal.short` ⇒ 同一工作区里跑**两个任务类别**时,
除那一个之外的类别会被判「**不属于本项目**」⇒ 看板不列它们、`--ready-next` 不算它们
⇒ **静默漏管**(不是报错,是"什么也不说")。分工板同理:**"线"就是工作区名** ⇒ 同工作区多类全塌成一行。
**落地后的规则(三条,全部可判)**
| # | 规则 | 落点 |
|---|---|---|
| 1 | **类别清单** 由 `goal.json.topics` 声明(⛔ 缺省回落 `[short]` ⇒ 单类别部署行为不变)。**主会话维护**,改完**下一轮钩子即时生效**(⛔ 不必重启) | `collabd._goal_topics()` / `board._goal_topics()` |
| 2 | **每个类别各有自己的主会话**。解析:① 标题里出现该类别名(**最长优先**)② 同类别多条 ⇒ 显式 `主控` 优先、否则最近活动;**默认类别**(`topics[0]`)取不到时回落 `default`(⛔ 只为兼容单类别) | `collabd.resolve_mains()` / `_scan_mains()` / `board._scan_ws_mains()` |
| 3 | **投递按件的类别选「跟进会话」**(🔴 2026-10-01 第四改;`follow_for_topic()`):类别**已登记 ⇒ 严格投该类别的跟进会话**(取不到就记 `no-follow-session` + **喊用户**,⛔ 不投给别类别);类别**未登记**(空/旧数据里的工作区名)⇒ 只有一条活的就用它、多条取默认类别那条(⛔ 不随机挑)。**⛔ 绝不投主会话**(用户:「主会话只能是用户触发」) | `collabd._deliver_str(..., topic=)` ⇒ `follow_for_topic()` |
| 4 | **解析出一条主会话以后,还要能认出"换人了"**(登记失效 ⇒ 按工作区解析 + 显式告警) | `collabd.resolve_main()` ⇒ 注意:**它现在只服务看板与"谁是主会话"的读数**,⛔ 不再是投递目标 |
**命名(🔴 都在一个工作区,靠前缀分)**
- 执行棒(含**自动唤醒任务**):**`[协作]-<任务类别>-<具体>`**
- 主会话:**`主控 · <任务类别> · <具体>`**(也可写 `[主控]-[类别]-<具体>`)
- ⛔ **自动唤醒任务的排期名也必须带类别前缀**(⚠️ 本规则**只在「唤醒用排期」这个代偿形态下才有对象** —— 定案的时钟是常驻投递、**不开会话、也就没有排期名**;⛔ 别因为这条规则在,就反过来认定「唤醒本该用排期」)—— 否则它自己会被解析成"没有类别",
且活动一多就会被当成主会话候选(实测踩到:`[协作]-唤醒A · <工作区>` 缺第 2 级)。
- 🆕 **唤醒主会话**(2026-10-01 起 · **第三类角色**):**`[唤醒]-<类别>-<具体>`**
⇒ `parse_session_name()` 返回 `role='waker'`(原只 `main`/`worker`)。
🔴 **它自己的三条定性**(用户 2026-10-01):「**唤醒会话 是独立会话,不要在主会话上处理,
是随着需求确定时创建的**」⇒ (a) **独立一条会话**,⛔ 不是主会话的职能;(b) ⛔ 叫醒的活
**不在主会话上处理**(执行体是它自己);(c) **随需求确定时创建** ⇒ 看板上「还没建」是**正常态**。
🔴 它**是一条会话**(用户定性:「**唤醒脉冲会话,本质还是会话**」(现名:唤醒主会话))⇒ 判"主会话与任务会话
**都空闲**"时必须把它**排除**;不排除 ⇒ 它用自己的存在否掉条件 ⇒ **自指死结**(醒一次、白判一次、接着睡)。
⚠️ **两道排除都要**:① `CODEBUDDY_SESSION_ID`(**精确**,程序跑在会话内时="自己")
② 名字前缀 `[唤醒]`(**程序在会话外跑时拿不到前者,只能认名字**)。
实测教训:现役会话名写作 `主控 · …`(**中点分隔、无方括号**)⇒ 解析不出角色 ⇒ **只靠名字会漏**。
详见 `references/collab-detail.md` §1.4a 与 §0.5.5。
**已知边界(诚实标注)**
- ⚠️ **类别名是子串匹配**(主会话是口语式命名,没有方括号)⇒ 类别名**别取太通用的词**(如"机制"),
否则会**误吞**别人的标题。源码里已做"最长优先",但**根本解法是类别名取得足够具体**。
- ⚠️ 台账里的**历史条目** `line` 仍是**工作区名**(跨工作区时代的产物)⇒ **见 §2.3.2**:
它们**不是类别**(`kind='legacy'`),但**不许静默消失**(折叠成「未归类」格照画 + 图外点名 + 表内标注)。
⛔ **不迁移历史数据**(迁移=改写记录);新条目上报时用**类别**即可。
#### 2.3.0b 🔴 **默认=一个工作区 + 「接续会话」是第四种命名形态**(2026-10-01 用户定 · 已落地)
> 用户原话:「将多会话协作机制 **改为默认 在一个工作区下运行**(主会话(所在工作区)、协作会话、唤醒会话),
> **支持会话创建接续会话的情况下 也能正常运行**」
**① 默认形态 = 一个工作区(不是"可支持的选项",是默认)**
| 项 | 定论 |
|---|---|
| **哪些会话在同一个工作区** | **全部会话**:主会话、任务会话、唤醒会话(+第④类「跟进会话」,未落地),**以及它们各自到阈值时产生的「接续」形态** —— 都在 `collabd.config.json` 的 `workspace` 那一个目录下跑。🔴 **「接续」不是第 5 类**(用户 2026-10-01 订正)—— 它**继承被接续那条的角色** |
| **区分方式** | **只看会话标题**(两级前缀)—— ⛔ 不按 cwd 分线、⛔ 不按工作区裂组、⛔ 不回落 cwd 推断 |
| **配置层已清理** | 删掉 `collabd.config.json` 的 `lines`(键=**工作区名** `ai1net-dsh-desktop`/`ai1net_ui`,**跨工作区时代**的产物,与 `goal.topics` 打架)⇒ 分工维度只有**任务类别**一处(`goal.json.topics`) |
| **⛔ 别复活** | ⛔ 不要再加"按工作区分线/按工作区选主会话"的配置 —— 那正是 09-29 之前的老形态 |
**② 「接续会话」=由**会话自己**建出来的下一棒,判据是它**自己的命名形态**(`continuation`)**
> 🔴 **2026-10-01 用户订正 + 实测缺口(先读这条)**
> · **定性**:接续**不是一类会话**,是"**这几类会话到达阈值时创建的接续会话**" ⇒ **角色继承**被接续的那条。
> ⛔ 所以不许把它当第 5 类,也⛔ 不许给它固定角色。
> · 🔴 **现状缺口(实测,2026-10-01 19:5x)**:`parse_session_name()` 把凡是标题含「接续」的一律判
> `role="worker"`(`form="continuation"`),而 `_scan_mains()` 的排除判据是「角色不是 worker/waker」
> ⇒ **主会话的接续被当成干活的棒排掉**。实测本工作区 8 条会话(`接续 · 会话机制合并包 · 任务4b-4d-6`、
> `接续 · 会话机制合并技能包(…)`、`[接续] …`、`接续棒:…`、`[唤醒机制] 接续 · …` 等)**全部判 worker**
> ⇒ `_scan_mains().cand = 0` ⇒ `resolve_main()` 返回 `{"sid": "", "source": "no-register"}`
> ⇒ **投递解析不出主会话**(`no-main-session`)。
> · ⚠️ **为什么不能简单地"把接续都判角色未知"**:那就**重演自指死结** —— `[唤醒机制] 接续 · …` 这类
> 第一对方括号里装的是**类别**,判成"角色未知"后它**又会**被收成主会话候选(原事故:通知投给它自己)。
> · 🔴 **根因是信息不足,⛔ 不是解析器不够聪明**:`continuation` 形态的标题里**根本没有角色信息**
> ⇒ 解析器**无从继承**。⇒ 正解只有两条,**待拍板**(见 §2.3.0b ③ 与 `pitfalls.md P0-11`)。
**它为什么是个真缺口**(2026-10-01 实测坐实,⛔ 不是理论问题):
接续会话是**自动化起的新会话**,标题由**建它的那条会话**写 ⇒ 常写成
`[唤醒机制] 接续 · 日志事前叫停钩子落地(第 2 棒)` / `接续棒:日志增长治理(任务 0 → 任务 A)`
—— **没有角色方括号**。而当时 `_scan_mains()` 只排 `[协作]` ⇒ 这类棒:
1. **进了主会话候选**;
2. 又因为它标题里有 `[<类别>]` ⇒ `_topic_in_title()` 认出类别 ⇒ 直接被解析成"**该类别的主会话**"
⇒ **投递把通知投给这条棒自己**(自己叫自己、白判一次),真主会话被架空。
**判据(四种形态 · 唯一实现处 + 它的镜像)**
| form | 标题形态 | role | `ok` |
|---|---|---|---|
| `prefix` | `[角色]-[类别]-<具体>`(合规) | main/worker/waker | ✅ True |
| `prefix` | 只有一级 `[协作]N9-…`(缺 `[类别]`) | worker | ❌ False |
| 🆕 `continuation` | **接续会话**:`[<类别>] 接续 · …` / `接续棒:…` | **worker** | ❌ False |
| 🆕 `main-prefix` | **主控前缀**:`主控 · <类别> · …`(中点分隔、无方括号) | **main** | ❌ False |
- 🔴 `ok=False` **恒表示"没按两级前缀约定命名"**(语义未变 ⇒ 老调用方行为不变);
🆕 新增的是 `role` 的**兜底判定** —— 形态不合规但角色明确时**照样给出 role**。
- 🔴 **主会话候选的排除判据:从「标题不含 `[协作]`」改成「角色不是 worker/waker」**
⇒ 一处改动同时覆盖 `[协作]-…`、`[唤醒]-…`、**接续会话**三类(⛔ 少一类就会重演上面的自指死结)。
- **实现处(⛔ 两处必须同款,改一处必须改两处)**:`collabd.py::parse_session_name()`(权威)/
`board.py::_role_of_title()`(镜像)。⇒ 已加**真对账用例** `selftest.py::命名:看板与常驻程序**同一套角色判据**`:
它把两边的函数**拉出来逐样本比对**(⛔ 不再靠人盯)。
- ⚠️ **已知边界(诚实标注)**:`[<类别>] <具体>` **不带"接续"二字的**(如 `[手机接入] 复测`)
⇒ 仍判**角色未知**(⛔ 不猜它是执行棒 —— 归类要靠 `goal.topics` 才能判,而解析函数**不读配置**)。
这类会话会被看板点名(`sessions_unrecognized`),⛔ 不静默消失。
**③ 给"建接续会话的一方"的硬规矩(⛔ 派活模板要带上)**
建接续自己的下一棒时,**标题必须按形态写** —— 否则新会话要么冒充主会话、要么在看板上归不了类别:
```
执行棒 / 接续棒: [协作]-[<类别>]-<具体>
主会话的接续: 主控 · <类别> · <具体>
唤醒: [唤醒]-[类别]-<具体>
```
🔴 **上面这两条规范其实早就写着了 —— 问题出在"没被执行"**:主会话的接续**只要标题带 `主控`**,
`parse_session_name()` 就判 `role="main"`(✅ 已实测:`主控 · 机制线(…)` ⇒ `main`/`main-prefix`)。
⇒ **本缺口的两条正解(⛔ 两条都还没做,属方案变更,等拍板)**:
1. **补登记**(零代码改动,最小):把主会话线的 `接续 · …` 会话用 `collabd.py --declare --role main` 登记
⇒ 走 `resolve_main()` 第①层。✅ 立刻可用;⚠️ 登记是**静态**的,换主会话后**不会自己变**(§7.1 那次事故的成因)。
2. **改判据 + 收紧命名**(治本):`continuation` 形态**不再无条件判 worker** —— 标题带 `主控` ⇒ `main`(现在已经如此);
否则 ⇒ **角色未知**(`ok=False` + 落 `sessions_unrecognized` 点名)。⚠️ **必须同时堵住自指死结**:
`[<类别>] 接续 · …` 的第一对方括号装的是**类别**,判"角色未知"后它会**重新**被收成主会话候选
⇒ 需要一条显式排除("缺角色方括号 ⇒ 不进候选,只点名")。⇒ 这条**改的是排除判据本身**,故不擅自动。
#### 2.3.0c 🔴 **第④类会话 =「队列上报的跟进会话」(用户 2026-10-01 口径 · ✅ 解析层已落地)**
> 用户原话:「这里的第4类应该是 **队列上报的跟进会话**(之前考虑**只让主会话管目标和方向**,
> **队列上报的会话 让专门的 跟进会话处理**)」
**它要解决什么**:主会话现在**既管方向、又逐条跟进队列上报**(执行中/已完成/有阻碍)——两件事混在一条会话里
⇒ 主会话被琐碎跟进占住、方向判断被稀释。⇒ 拆出一个**专门的跟进会话**承接队列上报的处置。
🔴🔴 **2026-10-01 22:5x 职责收敛(用户细化口径,⛔ 推翻本节早先"由它处置队列上报"那句)**
> 用户原话:「**跟进会话 只负责 ,创建协作会话(1、跟进上报后判断是否创建 2、被唤醒后 跟进目标情况 判断是否创建)**」
⇒ **它只做一件事:判断「要不要创建协作会话」,要就建一条。** 两条触发、同一个动作:
| # | 触发 | 它做什么 |
|---|---|---|
| ① | **收到队列上报**(目标检查投递进来) | 跟进目标情况 ⇒ 判断是否创建任务会话 |
| ② | **被唤醒**(宿主排期定时拉起)<br>🔴 **2026-10-02 标注(⛔ 不是口径变更)**:定案=**常驻投递**按周期把它叫醒(见 §5-1);现在由排期顶上,因为常驻停机 | 同上(这一条**不依赖推**,是它的主通道) |
⛔ **它自己不做具体活**:⛔ 不改 `tasks.json` 的 `state`、⛔ 不写 `blocked.json`、⛔ 不派活、⛔ 不抢域锁
—— 那些是**它建出来的那条任务会话**的职责。⚠️ **只有自动化能开新会话** ⇒ "建会话"=**登记一条自动化**。
⚠️ 已同步到生产排期 `[跟进]-会话协作自检-队列上报`(2026-10-01 22:5x · `nextRunAt` 未变=23:21:35)。
**✅ 已落地(2026-10-01 20:4x · 实测 `[跟进]-[会话协作自检]-…` ⇒ `follow`)** —— 四处**同批改齐**:
| # | 落点 | 改动 |
|---|---|---|
| ① | `collabd.py::parse_session_name()` | 角色表加 `"跟进": "follow"`(+ 形态表那行同步) |
| ② | `board.py::_role_of_title()` | 同款镜像 |
| ③ | 两边的**主会话候选排除元组** | `("worker","waker")` → `("worker","waker","follow")` |
| ④ | `selftest.py` | 逐样本对账加 `[跟进]-…`(**3 项 → 4 项**)+ 两处字面断言同步 |
🔴 **为什么这四处必须同批改**:`[跟进]-…` 若没进角色表 ⇒ `role` 解析为**空** ⇒ 会被 `_scan_mains()`
收进**主会话候选**(标题里带 `[<类别>]` 时还会被解析成「**该类别的主会话**」)⇒ 通知**投给它自己**
—— 与接续会话同款的**自指死结**(见 `is_continuation()` 那段)。
🔴🔴 **同批修掉的一个"一直存在但看不见"的缺陷**:`board.py` 原来把会话角色**二分**
(登记/解析出的主会话 ⇒「主会话」,**其余一律**「协作会话」)⇒ **唤醒会话与第④类跟进会话都被标成「协作会话」**
⇒ `board.html` 第三层(按 `role` 挑格子)会把它们**误画进"任务会话"那一排**:
唤醒会话**画两遍**(主会话左侧一次 + 这排一次)、跟进会话**顶着错名字**。
**它一直看不见,是因为这两类会话从来还没被真的建出来过** —— 建出来的那一刻就会显形。
修法:新增 `board.py::_role_label()` 输出**四类**,`board.html` 那排判据同步改成 **`role === '任务会话'`**
(⛔ 从 `role !== '主会话'` 改过来 —— 两者必须同款,改一边不改另一边 = 又开始吃错格子)。
**✅ 投递路由 —— 2026-10-01 落地(⛔ 本节早先写的"降级投主会话"已被推翻)**
> 用户原话(本轮口径):「**换新会话 唤醒的是 跟进会话,主会话只能是用户触发**」
⇒ `_deliver_str()` 的目标从"该任务类别的**主会话**"改成"该任务类别的**跟进会话**":
| 场景 | 记什么 | 同时做什么 |
|---|---|---|
| 类别**已登记** 且该类别有跟进会话、活着、不哑 | 正常投(记 `target:"follow"`) | —— |
| 类别已登记但**没有**该类别的跟进会话 | `skipped=no-follow-session` | **喊用户**(`NEED-USER.md`) |
| 找到了但**都不在活会话里** | `skipped=follow-not-live` | **喊用户** |
| 目标正 `working` | `skipped=target-busy` | 等它空闲(⛔ 不降级,见 §4) |
| 目标**已哑**(诊断日志撞 ~10 MiB) | `skipped=target-deaf` | **喊用户**换会话 |
🔴🔴 **为什么"降级投主会话"被推翻(⛔ 不许改回去)**:主会话是**用户与系统交互的那一面** ——
自动化往它推消息=**用机制噪音挤掉用户的话**(实测过:会话被压成 `parkInQueue`「只进不出」,
用户随后发的消息排在后面,表现为"卡住、没反应")。⇒ **解析不出 ⇒ 喊用户**,⛔ 不静默、⛔ 不盲投。
⚠️ 配套两处**必须同批**(改一处=判据漂移):`_main_sid()` 加 `wake.target == "follow"` 的**防污染**判
(否则"上次投给谁"会把**跟进会话**回读成主会话)、以及看板第三层的格子判据。
#### 2.3.0c-2 🔴 **看板的"分组大框"(2026-10-01 用户第四改)**
> 用户原话:「先等等 有个办法更好,用一个**虚线大框**把 主会话 唤醒会话 和 跟进会话都框起来,
> 这个虚线大框 **连接 协作会话 虚线大框**就好」
**图上现在有两个虚线分组大框**(`.grp`,虚线、不填色),**⛔ 不是"新加了两层"**:
| 框 | 套住什么 | 为什么是它俩 |
|---|---|---|
| `UA`(上) | 主会话 + 唤醒会话 + 跟进会话 | **机制侧的三个角色本来就在同一组里**:用户只进主会话;机制侧的"推"只叫醒/投递给跟进会话 |
| `UB`(下) | 任务会话那一排(个数是活的) | 它是**干活的**那一组,与上面那组是**派活 → 干活**的关系 |
两组之间**只有一条线 = ① 派活**(取代了原来"主会话往每一格射线"的扇形)。
🔴🔴 **2026-10-01 23:1x 图上三处标签改「简称」(用户第六条口径)**
> 用户原话:「**唤醒会话 改为 唤醒,跟进会话 改为 跟进 字体和 唤醒一样大小\n协作程序 改为 协作**」
| 格 | 原名(正文全称) | 图上简称 | 字号 |
|---|---|---|---|
| `RW` 左格 | 唤醒会话 | **唤醒** | `n-title-sm`(**13px**) |
| `RW` 右格 | 队列上报的跟进会话 | **跟进** | `n-title-sm`(**13px**)—— 用户明令**与"唤醒"一样大小** |
| `PB` 常驻程序 | 常驻程序 | **协作** | `n-title`(16px,沿用原样) |
⚠️ **简称只是"图上写不下"的压缩,⛔ 不是又多了两个角色**:正文/文档/代码里的**全称不变**
(`唤醒会话`/`队列上报的跟进会话`/`目标检查`)。⇒ 图里 `?` 与 `aria-label` **必须带简称对照**,
否则看图的人(含下一次的自己)会把「**协作**」误读成「**协作会话**」—— 那是**另一层、另一回事**。
⚠️ 给简称做**探针**要小心:`唤醒` 是 `[唤醒]-…`、`唤醒机制` 等一堆串的**子串** ⇒
拿它当"这词还在不在"的判据 ⇒ **必假红**(本轮渲染桩就因此假红一次,已改成**查被删那段的内容** + 加**防恒真对照**)。
🔴 **这一改顺手消掉两个麻烦**(⛔ 不是巧合,是分组本身带来的):
① ① 派活不必再**穿过唤醒/跟进那一行的空档**去找格子(只剩两个大框之间 58px 一段);
② 唤醒 → 跟进那条实线**再也不与任何竖线十字相交**(用户上一句要的"实线"落到实处)。
⚠️ 改里面任何一格的宽度(`TW`/`FW`/`RW_GAP`)都**必须重算框**(`UAx/UAw` 由三格包围盒现推;
`UB` 由该排布局现推)—— 否则虚线横穿格子上的文字,而**那种图照样能渲染**(=没人守就没人发现)。
⇒ 两道自检守着它:`tmp/arch-geom-check.mjs` ⑦ 段、`tmp/render-check.mjs` 的分组框断言。
#### 2.3.0d 🔴🔴 **主会话"开工清单":开工时必须把三类会话建齐(用户 2026-10-01 明令)**
> 用户原话:「**开始会话完成需求的时候,主会话需要创建 唤醒会话 以及根据分工类别 创建 协作会话
> 和 跟进会话呢 不然整个机制跑不起来**」
**一句判据**:**主会话开工的第 0 步不是"派活",是"把三类会话摆好"** —— ⛔ 少建哪一类,机制就断在哪一节:
| 该建什么 | 标题形态 | 少建会怎样(实测后果) |
|---|---|---|
| **唤醒会话** | `[唤醒]-<类别>-<具体>` | **没人推动**:主会话与任务会话空闲时没有任何"叫醒"入口 ⇒ 需求停在原地(本轮实测:需求 3.5 小时零成果、`triggers[].state='none'`) |
| **任务会话**(**每个分工类别一条**) | `[协作]-<类别>-<具体>` | **没人干活**:`resolve_mains()` 按类别找不到承接者 ⇒ 投递降级成"无主会话";类别形同虚设 |
| **跟进会话** | `[跟进]-<类别>-<具体>` | **没人收上报**:队列上报(执行中/已完成/有阻碍)全挤回主会话 ⇒ 主会话被琐碎跟进占住(正是 §2.3.0c 要解决的那件事) |
**三条硬约束(⛔ 踩了就白建)**
1. **只有自动化能开新会话**(宿主钩子开不了)⇒ 主会话**建会话 = 登记一条自动化**,⛔ 不是自己 spawn。
2. **标题必须**两级前缀 `[角色]-[类别]-<具体>`,且**第 2 级带方括号**、值取 `goal.json` 的 `topics`。
⛔ 用 `goal.short`(简称)⇒ `parse_session_name()` 认不回 ⇒ 会话**静默漏管**(§2.3.2 表 ④ 就是这么踩的)。
3. **"还没建"是正常态、⛔ 不是失败** —— 唤醒会话按定性**随需求确定时才创建**。但**一旦开工就必须建**,
且看板上 `state:'none'` 要**如实显示成灰 ○「还没建」**,⛔ 不许美化。
**怎么验**:建完在**两处独立读数**对上 —— `sessions` 表里三条标题的 `role` 非空(`waker`/`worker`/`follow`)
**+** 看板第三层只出现 `[协作]` 那条(唤醒/跟进⛔ 不进这排)。
⚠️ **过 §6 判据**:这条要求**同时存在 3 类会话**,而 §6 那条写着「必须存在的**会话**数=0」
⇒ **有真取舍**(多发几条会话 vs 主会话注意力):取舍结论=**接受**,因为三条各自对应一个**断点**(没人推/没人干/没人收),
⛔ 不是"多发几条好看"。⛔ 但**不许拿"这是用户说的"跳过判据复核。
#### 2.3.2 🔴🔴 **"读到了却没说" —— 本机制最容易犯的一类 bug(2026-09-30 收尾棒各抓到一个)**
> **形状**:**不崩溃,只是少说一句话**。所以"跑一遍没报错 / build 通过 / 自测全绿"**永远验不出来** ——
> 只能靠**两处独立读数对账**。下面逐条记(⛔ 别删旧条 —— 每条都是同一形状的新伪装):
| # | 事故 | 坏在哪 | 修法(防回潮的用例) |
|---|---|---|---|
| ① | **`acceptance_state` 只有说明行时被读成"全过"** | 状态判据写的是 `if not acc`(判**字典空不空**);实际有 `_说明`/`_更新` 两条说明行 ⇒ 条件**永不触发** ⇒ 控制台打「三路全过」 | 判据改成「**有没有有效项**」(键不以 `_` 开头)⇒ `goal_state()` **三态**:`open`/`pass`/**`undeclared`(一条有效判据都没有 ⇒ ⛔ 不算过)**。`board.py` 的命令行摘要同族(`… or "无"` 读起来就是"全过")一并收敛到 `_acc_summary()`。用例 `selftest.py::看板:验收摘要行` |
| ② | **台账里的旧线在图上"整块不见"** | 类别清单已迁走,台账里仍有**跨工作区时代的旧线**(各 2 件且都已完成)。旧版把它们**也当"分工位"**排进 `labor`(6 行),图上 `slice(0,4)` 只画前 4 格 —— 恰好**全是"件 0"的类别** ⇒ **4 件已完成的活一格都看不见**;图外只说「另有 2 个未画」**不点名** | `_labor()` 每行加 **`kind`**(`topic`/`legacy`)⇒ `build()` 出 **`orphan`** 汇总(条数+件数求和)⇒ 看板把 legacy **折叠成一格「未归类」照样画**(`⚠ 不属任何当前任务类别` + 线名 + 件数)+ **图外点名**+ 台账表标注 `(旧值 · 不在当前任务类别清单)`。`MAXW` 4→5。用例 `selftest.py::看板:『未归类』不许被静默丢掉` |
| ③ | **看板一页只能看一个目标;tab 第一行只给**简称** ⇒ 认不出是哪个目标** | 用户 2026-10-01:「把协作实时看板改为 **tab 支持多个目标**执行协作状态展示」+「再确认下**目标名称**显示在哪里的,没看到呢」。根因两条:① 快照只出**单份** goal(顶层 `goal`/`project`/`tasks`/`labor`… 全是**活跃目标那一份**)⇒ 别的目标**根本读不到**;② tab 第一行用 `short`(简称=「本机协作」),**全名只藏在 `title=` 悬停提示里** —— 又一例「**有却不说**」 | ① `board.py`:加 `GOALS_DIR`/`goal_files()`(`goal.json` + `goals/*.json`,**活跃排第一**)+ `_goal_block()`;`build()` 出 **`goals[]`**,**顶层键=活跃那一份**(⇒ ⛔ 老渲染器与老断言**逐字照旧可用**);台账按 **`line ∈ 目标 topics`** 切,**不属任何目标**的件归**活跃目标**且照常显示;`_scan_ws_mains`/`_resolve_main`/`project_scope`/`_sessions` 全部加可选 `topics`/`g`/`rows`(**宿主库只读一次**,N 个目标共用)。② `board.html`:`renderTabs()`/`paintScope()`/`scopeView()`,当前格记进 **URL hash `#g=<目标id>`**(刷新/分享都不丢);切格**只重画不取数**;tab 第一行改**完整目标名**。用例 `selftest.py::看板:多目标 tab`(11 项)+ `tmp/render-check.mjs`(双目标样本 `tmp/board-verify-2goals.json`:**改 hash 再重画**,验"切格**真换数据**",⛔ 不是只数 tab 格数) |
| ④ | **真派出去了、也真在跑的棒,看板上"整条消失"** | 用户 2026-10-01:「**为什么创建了协作会话 但是 看板中没有展示**」。真事:M5 那根棒被命名成 `[协作]-本机协作-M5 …` —— **第 2 级没带方括号**,而且用的是 `goal.short`(本机协作)而非**任务类别**(`topics`=唤醒机制)。⇒ `parse_session_name()` 判 `ok=False`、`in_project()` 也找不到 `[唤醒机制]` ⇒ 它**不进 `sessions`、不进任务会话层、也不算"本项目会话"** ⇒ **静默消失**。🔴 **根因在规范本身**:`collabd.py` 生成的 `NEXT.md` 原文写着「主题取自 `goal.json` 的 `short`」—— **按这句话命名出来的棒,判据必然认不回**(`_in_project` 收的是 `topics`,且要方括号) | ① **规范纠偏**:那句话改成 `[协作]-[<类别>]-<具体>`,**第 2 级带方括号、值取 `goal.json` 的 `topics`**,并把"写错 ⇒ 静默漏管"写在同句里(`collabd.py::_next_md`)。② **说出口**:`_sessions()` 多收一路 `unrecognized`(**同工作区 cwd 尾名相同、但没通过三级归属判据**的会话)⇒ `build()` 出 `sessions_unrecognized` ⇒ 看板在图外说明里**点名**(⚠️ 在跑/近 3h 的**列名字**,更旧的**只计数** —— 兼顾"精简"与"不许少说一句")。③ **⛔ 绝不改归属判据**:它们**不进 `mine`**、**不算「本项目会话共 N 个」**;`cwd` **只用来决定"要不要提醒"**(`_ct == _wstail`),⛔ 绝不用来决定"归不归本项目"(守 §2.3「绝不回落到 cwd 推断」)。用例 `selftest.py::看板:同工作区但没归入本项目的会话不许静默丢`(4 项)+ `tmp/render-check.mjs` 那一条(**灌一份带 `sessions_unrecognized` 的快照再画**,验"说出口 + 不改 N") |
| ⑤ | **归属判据只认"带方括号",而在跑的任务会话标题恰恰没带** ⇒ 看板第三层画**空框「暂无协作会话」**,而那条会话**正在 `working`** | 用户 2026-10-01:「**看板没有正常显示协作会话**」。逐条读数:`[协作]-机制排查与修复-常驻投递容器` —— 第 2 级**没带方括号** ⇒ 两处归属判据(`board.in_project()`/`collabd._in_project()`)都判 `False` ⇒ 它落进 `sessions_unrecognized`、**不进 `mine`** ⇒ 第三层(按 `role==='任务会话'` 挑格)**一条都挑不到**。🔴 而 `_topic_in_title()` 的注释白纸黑字写着「判据是**子串**(不要求 `[...]` 包裹)…**两种写法都要认**」⇒ **同一个标题,一个函数说命中、另一个说不命中**(判据打架)。🔴 **第二个受害者更严重**:`ready_next()` 的判据①(除主会话外没有别的会话在 `working`)**走的就是 `_in_project()`** ⇒ 在跑的执行棒判 `False` ⇒ 它落进 `ignored_others` 而非 `working_others` ⇒ `--ready-next` 会**说"可以排下一棒"** = 用户 2026-09-30 描述的那个病(「**前面还没执行完 就开始下一棒了**」) | 两条判据**统一成三条并列**(任一命中即算):① 主会话 sid ② **角色可解析 + 标题出现任一类别**(子串,⛔ 不要求方括号)③ 老写法 `[<类别>]` 保留。⚠️ ② 里的"角色前缀闸"是**精度保障**(正文里恰好提到类别名的**无关会话**解析不出角色 ⇒ 仍不算)。用例 `selftest.py::归属:两处 in_project 判据逐样本对账`(11 例逐条比布尔 + 精度反例 + 两处源码判据形态) |
| ⑥ | **接续棒冒充「主会话」**(同一处判据的**第三处漂移**) | 2026-10-01 扫线上标题时发现(⛔ 不是推理):`collabd.is_continuation()` 判据是 `"接续" in name`(**整串**),而 `board._role_of_title()` 只查**方括号后面那段** ⇒ `[接续] 会话机制合并技能包(第 1 棒)` 两处结论相反(`worker` vs **空**)⇒ 看板的主会话候选排除元组拦不住它 ⇒ **「主会话」位上坐着一条 355 分钟没动的接续棒**(= `is_continuation()` 注释里那个"自指死结"的同一族) | `board._role_of_title()` 判据改成**整串**:`if "接续" in nm: return "worker"`(逐字同款 `is_continuation()`)。对账样本加 `[接续] …` 一条,并**断言两边都判 `worker`**(⛔ 判空就报红)。用例 `selftest.py::命名:看板与目标检查同一套角色判据` |
🔴 **判据(两条,可直接照做)**
1. **凡"取前 N 个 / 截断 / `or 默认值`"** ⇒ 必须回答:**被截掉的是什么、有几条、说不说得出名字**。
"另有 N 个未画"这种提示**信息量为零** ⇒ **等于没说**。
2. **凡"状态判定"** ⇒ 判据要落在**语义项**上("有没有有效判据"),⛔ **不要落在容器形态**上("字典空不空/列表长不长")——
容器里放两条说明行,形态判据立刻就骗过自己。
**验证场合**:改完这类东西,必须走 `references/collab-detail.md §0.5.4`「改完看板怎么验」的三步(语法/渲染桩/几何),
并**当场从产物里现取**被测脚本(⛔ 不读上一轮导出的副本 —— 副本形态不同会造出**稳定的假绿**)。
#### 2.3.3 🔴🔴 **过期会话退场(治"接续棒越堆越多")+ 主会话"哑了要换一条"**(2026-10-01 立 · 已落地)
> 用户原话:「**通过接续会话的时候,如何处理过期的会话,避免越堆越多**」
**病**:接续=会话自己建下一棒,而**前棒不会自动消失** —— 它在宿主库里永远是一条 `completed`
记录、标题同族。实测本工作区**已堆 5 条**「接续 · 会话机制合并…」⇒ 看板与状态稿每轮都为旧棒占位,
**真在跑的棒被埋在里面**。
| 项 | 结论(**已落地**) |
|---|---|
| **退场两条路径** | ① **显式**:`collabd.py --retire self --by <接棒id> --why …` ⇒ **立刻**生效(接续协议的必做一步)<br>② **窗口兜底**:`completed` 且超 `session_live_min`(默认 90)分钟未动 ⇒ 自动收起(前棒**中途死掉**时靠它) |
| **两条闸门都绕开谁** | **`working` 永不收起**(在跑的一定要摊在版面上)+ **主会话是版面锚点**(⛔ 不按窗口抽掉) |
| **存哪** | `tmp/supervise-inbox/retired.json`(`{完整sid: {at,iso,by,why}}`)—— **原子替换 + 回读核对**,⛔ **不上文件锁**(本机锁接进生产必 `rc=124`,见 `MEMORY.md`)。⚠️ 表自身也过期(7 天)+ 上限 200 条 |
| **谁写谁读** | **写**=`collabd.py --retire`(**一个状态只有一个写者**);**读**=`collabd.py::fetch()`(状态稿)+ `board.py::_sessions()`(看板)。⛔ `board.py` **只读不写** |
| **⛔ 退场 ≠ 删除** | 宿主库原样在、`retired.json` 全留痕(**可逆、可追溯**)⇒ 但**必须报数**:看板图外说明 + 状态稿都会写「已收起 N 条」<br>(⚠️ 收起而不说 ⇒ 读者把"收起了"读成"本来就没有"=同族红线) |
| **渲染侧** | `board.html`:从 `d.sessions_retired` 读,**⛔ 一个字都不许自己判**(判据两处打架是本项目最狠的一类坑)→ 说**条数全量**、**点名**最近 3 条、写清**"不是删除"**、给出**`--retire self` 那条命令**、窗口数值**从载荷现取** |
**主会话"哑了要换一条"**(同轮修 · 这是 V2「主会话可响应」的硬阻塞真因):
- **哑**=会话诊断日志撞 ~10 MiB ⇒ 宿主拒写(EPERM)⇒ **界面永远刷不出内容**。
🔴 **它照样出现在"活会话"列表里** ⇒ 判据必须是「**活着 ∧ 不哑**」。
- **缺陷①**:`resolve_main()` 的第①条快路原为 `if reg and (not live or reg in live)` —— **只查活着、⛔ 不查哑**;
而 ② 走的 `_pick_live()` **本来就排哑**(2026-10-01 专为"别再投给哑会话"加的)⇒
**同一条哑会话「② 排得掉、① 排不掉」**(判据打架,当日第三次同族)⇒ ① 也改成走 `_pick_live()`。
- **缺陷②**:`--declare --role main` 原语义是**加**不是**换** ⇒ 而 `_main_sid()` 取 `roles` 里**第一条** `main`,
Python dict **对已存在的键赋值不改位置** ⇒ 老那条(已哑的)**永远排前面** ⇒ 逃生口形同虚设。
🔴 掩盖得更深的一层:`_deliver_str` 的**粗判不传活会话**(`resolve_mains(st, [])`)⇒
`_pick_live` 在 `live` 为空时**回退 `c[0]`** ⇒ 每轮先认下那条哑的、**直接 `target-deaf` 返回**,
**走不到后面"拿锁后用活网关列表重算"那一步**。
⇒ 语义改成「**声明自己为主会话 ⇒ 其它登记为 `main` 的一律降级为 `worker`**」(一个工作区一条主会话)
+ **明确说出口**。⚠️ 多类别"每类别一条主会话"走**标题 `主控` 前缀**(`main_by_topic` 解析),⛔ 不依赖这里。
- **现场红绿对照(最强证据)**:改前 `resolve_main → {"sid":"","source":"stale-roles"}`、`--tick → deliver=target-deaf`;
改后 `resolve_main → {"sid":"<活着的会话>","source":"roles:main"}`、`--tick → deliver=**target-busy**`
(主会话在跑 ⇒ **正确延后**,⛔ 不是"投不出去")。
- **病症本身的处置(runbook)**:把 `<sid>.log`(连同 `.log.1`)**改名挪开**(⛔ 不删,Python `os.rename`,
⛔ 别用 `mv`)⇒ 宿主**数十秒内重建**并恢复写入 ⇒ 详见 `references/forensics.md §4`。
⚠️ 它**必然复发**(每个会话涨到上限都会重演)⇒ 靠扫描器 + 钩子兜底。
- 用例:`selftest.py::会话退场:过期会话收起但不删除…`(13 项)+
`selftest.py::主会话:登记为 main 但已哑 ⇒ ⛔ 不认它 + --declare main 是换不是加`(6 项)
+ `tmp/render-check.mjs` 的 `retBad`(**合成样本注射**:判"说出口+说清不是删除+不计入",
⛔ 不能靠线上快照 —— 它是 `0/0` 时那条分支**永不执行**,见 `pitfalls.md` **P0-13**)。
**同时必须满足两条**(否则会踩坑)
1. **文件与锁都不依赖 cwd**:动别线文件用**绝对路径**;域锁按**显式 domain** 抢(domain 本来就是显式参数)⇒ 各线仓库虽不同,归属依然清楚。
2. **提交边界按目录判断**(⛔ 不按 cwd)。
#### 2.3.0e 🔴🔴 **跟进会话「长期活着」=默认形态**(用户 2026-10-02 09:59 口径更正 · 本棒落地)
> 用户原话(逐字):「**那是因为之前跟进就是跑在主会话的,用户发消息和跟进一起处理会卡,
> 所以才把跟进独立成一个会话专门处理**」
**① 这条口径更正了什么(⛔ 先读,别按旧结论干活)**
- 旧的 `architecture.md`(§2.3 那一行 + §4 那一条)把「⛔ 禁止把长跑服务放进**会话后台任务**」写成了**普适禁令**
—— 那是**过度泛化**。它的**真实背景只有一条**:**当时"跟进"这件事跑在主会话上**,与用户发消息**抢占同一个轮次** ⇒ 会卡。
而**当时的解法本来就是**「把它**独立成一条会话**」。
- ⇒ **跟进独立成会话之后,"它自己挂着长活载体"⛔ 不再影响用户在主会话的操作** ⇒
上一棒「跟进会话也不能挂」的结论 **作废** —— ⛔ 别再拿它当判据。
- ⚠️ **禁令本身仍然有效,只是对象收窄**:**主会话**(用户会在上面发消息的那条)⛔ 依然不许挂长跑。
判据是**"用户会不会在这条会话里发消息"**,⛔ 不是"它是不是会话后台任务"。
**② 默认形态 = 跟进会话长期活着**
- 🔴 **跟进会话的默认状态是「长期活着」**,⛔ 不是"上报来了才临时起一条"。
理由:它**同时是两条通道的落点** —— ① 队列上报的投递目标(`follow_for_topic()`)
② 周期唤醒的叫醒对象(§2.3.0c 表 ②)。**它不在跑 ⇒ 投递与唤醒双双落空**
(现场实测:名册里 **6 条 `[跟进]` 都在、一条都不活** ⇒ 投递全落 `follow-not-live`)。
- 🔴 **载体优先级(从优到劣,⛔ 不许倒着选)**
| 优先 | 载体 | 为什么 |
|---|---|---|
| ① | **常驻程序**(如 `collabd.py --supervise`,跑在会话**之外**) | 不占会话、⛔ 不吃该会话的 idle 钩子、会话换了也不受影响 |
| ② | **会话后台任务**(挂在跟进会话**自己**名下) | 合法且够用(依据见 §4.1.1 表 ①);但**有两个已知代价**,见下 |
- ⚠️ **会话后台任务的两个已知代价(必须写进派活模板,⛔ 不许省)**
1. 🔴 **该会话自己的 idle 钩子会被静默压制** —— 实测:该会话只要有 `pending`/`running` 的后台任务,
宿主的 idle 钩子就**被无声掐死**(有被僵尸任务压 **6h20m**、**零日志**的记录)。
⇒ 后果:**一切"钩子驱动的唤醒/监管"在这条会话上失效** —— 唤醒必须由**常驻侧**发,⛔ 不能指望它自己醒。
2. 🔴🔴 **载体不同,寿命完全不同(2026-10-02 23:0x 实测订正,⛔ 推翻当天早些时候"活不久"的结论)**
⇒ **⛔ 从本会话的工具调用进程树里起的活(`subprocess` / `start /b` / `start.bat`)活不过当轮**:
两条探针(`start /b` 与 `Popen+DETACHED`)**在同一时刻一起停**(tick 67/64)⇒ 与起法关键字**无关**,是父链。
⇒ ✅ **能长跑的是这两种**:① **WorkBuddy 自己的后台任务**(`run_in_background`,载体由宿主管)——
实测 `board.py --serve 8788` 与 MCN `server.js 8900` 借它起,**端口一直 LISTENING、curl 200**;
② **用户从桌面双击起的**(在用户登录会话里,如 MCN `start.bat`)。
⇒ **⛔ 因此撤回**"改用排期当时钟"那条建议(前提是错的):唤醒时钟**回到常驻进程本身**,排期仍是复活兜底。
⇒ ⚠️ **后台任务照样会打断**(重启/换会话/手动收)⇒ S8 自愈仍该留着。
⇒ ✅ 顺带澄清一条**已被推翻**的旧担心:「任务挂在会话名下 ⇒ 卡消息输出」—— **不成立**。
实测 `mcn-short-video` 的 `:8900` 后台任务**跨会话收尾存活**,用户照常发消息;
当日"卡"的真因是**会话日志被推过 ~10 MiB 后丢写**,与后台任务无关(§4.1.1 表 ①)。
**③ 与常驻 `--supervise` 的分工(谁负责把跟进会话带回来)**
| 谁 | 负责什么 | ⛔ 不负责什么 |
|---|---|---|
| **常驻投递进程**(`--supervise`;含 `--tick` 内的 `ensure_supervise()`) | ① 提供**唤醒时钟**(周期叫醒跟进会话)② 把队列上报**投递**给跟进会话 ③ 保证**常驻自己**活着(掉了由下一次事件续命) | ⛔ **开不了新会话**(宿主硬边界:只有自动化能开会话)⇒ 它**带不回一条已被回收的跟进会话** |
| **跟进会话**(第④类) | 长期活着;收到上报/被唤醒 ⇒ 只做一件事:**判断要不要创建任务会话** | ⛔ 不挂**常驻程序本体**(那是上格的活) |
| **自动化排期**(`[跟进]-<类别>-…`) | **唯一能把跟进会话"带回来"的通道** —— 会话被回收后靠它重新拉起 | ⛔ 不是唤醒时钟(时钟由常驻提供) |
🔴 **一句判据**:**"谁把跟进会话带回来" = 自动化**(只有它能开会话);
**"谁叫醒它、谁投给它" = 常驻投递**。两者⛔ **不可互替** ⇒ **那条"带回来"的排期必须一直在册**
(`automations` 里的 `[跟进]-<类别>-…`)。
⚠️ **诚实标注的缺口(⛔ 本棒未处置)**:`automations` **只新建不复用** ⇒ 现行"需要跟进就排期"的形态会**每轮堆一条**
(用户 2026-10-02 已就此抱怨)。曾列两条方向(取消独立跟进会话角色/允许目标检查直插 `automations` 行 ——
**后者是双红线**)⇒ **待用户拍板,⛔ 不擅自改**。
#### 2.3.0f 🔴🔴 **投递目标的"候选收窄"必须发生在活会话过滤**之后**(2026-10-02 S5 · 验收 V2 修毕)**
🔴 **一句判据**:**"谁活着"是投递目标的唯一决定因素** —— ⛔ 不许在知道"谁活着"之前
先靠"最近活动"把候选**收窄成一条**,否则**那一条死了 / 哑了 = 整个类别投不出去**。
**① 缺陷(同族两处,必须同批修)**
- ① **候选收窄过早**:`follow_for_topic()` 旧实现先取 `by_topic[类别]`(首见=最近活动那**一条**),
再交给 `_pick_live()` ⇒ `_pick_live([死的那条], 活列表)` 判"都不活" ⇒ `sid=""`
⇒ **同类别里明明还有活着的候选,也一辈子轮不到**(现场实测:名册 6 条 `[跟进]` 全 `completed`
⇒ 投递连续落 `follow-not-live`)。
- ② **终局短路错位**:`_deliver_str()` 的**粗判**(`live_sids=[]`,故意不做网关发现)拿它的
`sid` 反过来做**终局判定** —— `_tgt in _deaf_sids()` ⇒ `target-deaf` + `need_user`,**且在取锁之前
就 return**。而 `live` 为空时 `_pick_live()` 按设计**回退 `c[0]`**("探测不到时不许假装知道谁活着")
⇒ `c[0]` = 最近活动那条,**与"活着"无关** ⇒ 只要那条恰好哑了,**每轮都短路**,
**永远走不到**拿锁后那次"用活网关列表重算" ⇒ 判据**自相矛盾**(粗判说"哑、没人能收",
精确说"有活的"),而**矛盾的结果是"不投"**。
📊 实证:`_collabd.log` 里 `target-deaf` 共 **81 次**,末次 `2026-10-01 21:07:53`
(队列里 `M5=done` 被它压住);另 `2026-10-02 10:33`–`10:41` 实测粗判给 `57f58ecf`、
精确给空 —— 两条判据对**同一批候选**给出相反答案。
**② 修法(两条,同批)**
- ① `follow_for_topic()` 的候选改成**该类别候选全表**(`_scan_follows()` 新增 `by_topic_all`,
按 `last_activity_at` 倒序)**整体**交给 `_pick_live()`:
`live` 非空 ⇒ 挑"第一条**活着**且不哑的";`live` 为空 ⇒ 回退 `cands[0]` = 最近活动那条
⇒ **与旧写法逐字等价**(向后兼容不破)。⚠️ ⛔ **只在同类别内放开**,绝不跨类别(那是"投错窗口")。
- ② 粗判的"哑"判据改成**只看候选集合、不看它的第一条**:**该类别候选"全都"哑**才成立
"没人能收"(`target-deaf`);有候选活着 ⇒ ⛔ 不短路,交给精确阶段用活网关列表正挑。
候选集合为空 ⇒ 不判("一条都没有"由 `no-follow-session` 分档,⛔ 不重复告警)。
**③ 取证**
- 钉死用例:`selftest.py::t_coarse_deaf_not_terminal`(5 项)—— 伪造"两条同类候选,最近活动那条哑、
另一条活"+伪造网关,**真实 `follow_for_topic()` / `_pick_live()` / `_deliver_str()` 全程在位**。
- **回退反证**:把 `cand, src = _lst, …` 换回 `[_lst[0]]` ⇒ 该用例 **5 项全红、rc=1**
(① 现 = `target-deaf`、② http=None、③ 投给空、④ 台账 0、⑤ 假警报 1 次)。
- 回归:`selftest.py` 全绿 **PASS 48 / FAIL 0**(新增 1 例)。
- 现场读数:修复并**重启常驻**后 `_collabd.log` 连续 `follow-not-live`(**真值**:确实没有活的
跟进会话),⛔ 不再出现 `target-deaf` 这一档。
⚠️ **剩余缺口(⛔ 本棒未处置,属环境)**:现场 6 条 `[跟进]` 全 `completed` ⇒
**投递与唤醒都到不了人**(机制侧已能正确判定并喊用户)。补齐之道见 §2.3.0e ③:
**"把跟进会话带回来"只有自动化做得到** ⇒ 需在册一条 `[跟进]-<类别>-…` 排期。
⚠️ **操作提醒**:改完 `collabd.py` **必须重启常驻** —— `--supervise` 是长驻进程,**内存里是旧代码**;
只有每次事件新起的 `--tick` 才会载入新代码(实测:改完后旧常驻仍报**已被修掉的** `no-follow-session`,
新起的 `--tick` 报正确的 `follow-not-live`,两者在同一份日志里交替)。
⚠️ 纯 CLI 调 `--ensure` **必须带 `COLLABD_CONFIG`**(钩子会显式传),否则子进程"起后即退"并写
`supervise.out.log` 说"未找到部署配置,拒跑"。
#### 2.3.0g 🔴🔴 **缺会话 ⇒ 自动拉起**(用户 2026-10-02 口径 · 本棒落地)
> 用户原话(逐字,分两次说全):
> ① 「**应该是 用户说 协作会话 完成目标 和 继续执行 的时候 如果没有 相关会话就自动拉起**」
> ② **13:18 订正触发面**:「**是用户说 使用协作会话方式 完成目标 或 继续完成目标**」
**① 这条改了什么(⛔ 先读,别按旧结论干活)**
- **旧行为**:`_deliver_str()` 判到「没人能收」(`follow-not-live` / `no-follow-session` / `target-deaf`)
⇒ 写 `NEED-USER.md`:**"请把「X」那条跟进会话拉起来"** ⇒ **把机制该干的活推给了用户**。
- **新行为**:**缺 ⇒ 机制自己补建排期把它拉起来**。用户只需照常说那句话,⛔ **不要用户动手开会话**。
- ⛔ **它不推翻"不降级投主会话"**:主会话仍只由用户触发(§2.3.0c)—— 变的只是"缺了怎么办"。
**② 为什么"自动"只能是这个形状(架构硬边界,⛔ 别去找第二条)**
| 想让谁开会话 | 行不行 | 依据 |
|---|---|---|
| 脚本/常驻程序自己建排期 | ⛔ **不行** | 写 `automations` 表对脚本是**双红线**(§2.3.0e ⚠️ 那段) |
| `jobs/resume` 拉起一条会话 | ⛔ **不够** | 它给的是 `kind:"background"` worker,**进不了 live** ⇒ 接不到 `sessions/{id}/reply`(2026-10-02 复核) |
| 宿主钩子直接开新会话 | ⛔ **不行** | 宿主硬边界:**只有自动化能开新会话** |
| **自动化排期 + 由会话建它** | ✅ **唯一可行** | 排期是"把一条**能收指令**的会话带回来"的**唯一**通道(§2.3.0e ③) |
⇒ **"自动"= 钩子把缺口注入会话上下文 ⇒ 会话(零判断成本)用 `automation_update` 建排期。**
**③ 落地件(三处,⛔ 缺一处即"在册 ≠ 生效")**
| # | 件 | 职责 |
|---|---|---|
| ① | `collabd.py --gap [--json]`(新) | **只读**判据:四类会话 × 每个任务类别 ⇒ `state`=`ok`/`stale`/`none`/`unknown`;`hard`=缺的**跟进/协作**;`soft`=缺的**唤醒**(用户定性「随需求确定时才创建」⇒ ⛔ 不算硬缺,否则红多必聋)。同时给出**现成排期参数**(name/prompt/scheduleType/scheduledAt/cwds)。 |
| ② | `hooks/wb-result-hook.py::maybe_inject_session_gap()`(新) | 在 `UserPromptSubmit` 把缺口**注入当前会话**(`additionalContext`,零 token)。触发词=「协作会话方式」/「完成目标」/(兜底)「继续执行」,命中即查、否则 120 s 节流;**一切异常吞掉**。 |
| ③ | 规则载体 | 本文件 + `SKILL.md §2 铁律` + `collab.md §4.1b`。 |
**④ 两条判据纪律(⛔ 别打折)**
- 🔴 **活会话探测不到 ⇒ `unknown`,⛔ 不报缺**(与 `_pick_live()` 同一条规矩:探测不到时不许假装知道谁活着)。
否则 token 一缺就满屏假红 ⇒ 红多必聋。
- 🔴 **本命令只读**:⛔ 不建排期、⛔ 不投递、⛔ 不改队列。**建排期=会话的事**(见 ②)。
**⑤ 验收(本棒实测读数,2026-10-02 13:1x–13:2x)**
- `collabd.py --gap` 真跑:类别「会话协作自检」跟进 `stale`(在册 5、活 0)/协作 `none`(在册 0);
类别「机制排查与修复」跟进 `stale`(在册 1)、协作 `stale`(在册 5)⇒ **硬缺 4 条**,唤醒软缺 2 条。
- 钩子 `--selftest` 喂 `{"hook_event_name":"UserPromptSubmit","prompt":"继续完成目标"}` ⇒ 注入文本里出现
「⚠️【缺会话 ⇒ **自动拉起**】…」+ 4 条带 name/scheduledAt 的排期建议(输出 971 B,含机械摘要)。
- ⚠️ 诚实边界:**"注入成功" ≠ "排期已建"** —— 注入只把事推到会话面前,**建排期那一步由会话执行**,
故本条**不能用"钩子跑了没报错"当验收**(同 §2.3.2「读到了却没说」那族坑)。
**⑥ 🔴🔴 跟进会话=**全局唯一席位**,⛔ 不按类别各建一条**(2026-10-02 13:5x 用户订正 · 本棒改判据)
> 用户原话(逐字):「**跟进会话只创建一个,跟进的内容来自 协作会话执行完成 后 把 待核对状态
> 写入 协作队列,上报给那个 固定的 跟进会话处理**」
- **错在哪(本棒初版就犯过)**:把齐备度一律按「角色 × 类别」展开 ⇒ 两个类别就报**两条** `[跟进]`
硬缺 ⇒ 一次建出 2 条跟进排期。用户订正后:**跟进是"收口者",不是"某类别的收口者"**。
- **改法(三处,缺一即回弹)**:
① `GAP_GLOBAL_ROLES = ("follow",)` —— `follow` 在 `scan_roles()` 里桶键恒为 `(follow, "")`,
**不看标题里的类别**;② `session_gap()` 对全局席位**只出一个桶**,⛔ 不按 `topics` 展开;
③ `_gap_plan()` 对全局席位出的排期名=`[跟进]-队列上报(固定席位·不分类别)`,prompt **不绑类别**。
`worker`(协作)/`waker`(唤醒)**仍按类别**分 —— 用户只订正了 `follow` 这一条。
- **配套(任务会话的收尾动作)**:任务会话干完活 ⇒ 把**待核对状态**写进执行队列 ⇒ 由那**一个固定**
跟进会话去消费、核对产物并上报。⛔ 不是"再开一条跟进会话"。
- **验收读数(改后真跑)**:`[跟进会话] 全类别(固定席位):stale|在册 6、活 0` ⇒ 硬缺**从 4 条降到 2 条**
(=1 条跟进 + 1 条协作·机制排查与修复;协作·会话协作自检已 `ok`,在册 1、活 1)。
**⑦ 闭环实测("注入 ⇒ 拉起"是不是真通了,2026-10-02 13:42–13:49)**
- 会话用 `automation_update` 建了一次性排期(`scheduledAt=13:42`)⇒ `automation_runs` 出现
**`IN_PROGRESS`** 行 ⇒ **`sessions` 表里 15 秒后真的多出一条新会话**(标题=排期名)。
- ⚠️ 对照旧读数:此前 8 条 run 一律是 `ACCEPTED` 且 `started_at`/`session_id` **全为 None**
⇒ 「**ACCEPTED ≠ 跑起来了**」;判"有没有真拉起"要看 **`IN_PROGRESS` + `sessions` 里有新会话**,
⛔ 别只看 run 有没有行。
- ⇒ **⑥⑦ 合起来**:缺口判据准了(跟进只 1 条)、拉起通路也真通了(排期 → 会话落地)。
### 2.3.1 🔴 **同工作区下"会冲突"到底指什么、以及怎么解**(2026-09-30 补 · 用户追问)
**先说清:前缀那套解决的是「谁是谁」(投递目标/握手/唤醒),⛔ 它不解决「同时改同一个文件」。**
⇒ 同工作区的**唯一真冲突面 = 文件互斥**:主会话与任务会话**共享同一批文件**,可能**同时改同一份**。
**定案 §2.3 已经给了答案**:**域锁按「显式 `domain`」抢,⛔ 不是按 cwd、⛔ 不是按工作区**。
⇒ 只要两个会话**各报自己的 domain**,**域不重叠 ⇒ 真并行**(这正是 §5 那句"域不重叠即真并行")。
⇒ **实际做法**:**域按「文件 / 子目录」切,⛔ 不按工作区切**。例(本项目的真实分工):
| 会话 | 它动的文件 | 该报的 domain |
|---|---|---|
| 主会话 | `skills/session-mechanism/**`(机制) | 机制域 |
| 任务会话(N9/N10 棒) | `ai1net-dsh-anywhere/**`、`ai1net-dsh-desktop/**`(业务) | 各线域 |
⇒ 两者**天然不重叠** ⇒ **同工作区也不会互相卡**。
**🔴 但实现上有两个真缺口(2026-09-30 实测,必须补)**
1. **没人真的去抢锁** —— 实测全盘**无任何 `.locks` 目录**;且当天**真实发生过**:`collabd.py` 被另一个会话并发改(我只能停手)。
⇒ ⛔ **域锁"定义了"≠"在生效"**:它靠**显式抢锁动作**,谁不抢就没人拦。
2. **派活模板漏写"抢锁"** —— 我当晚派的 N9 棒 prompt 只写了「抢不到锁只报告」,**⛔ 漏了"开工第 0 步先抢域锁"** ⇒ 等于没接这条线。
**✅ 硬规矩(派活模板必须逐字带上,⛔ 不许省)**
```
开工第 0 步:抢域锁 —— preflight-lock.sh "<会话名>" <本棒要动的文件...>
· 域按「显式 domain(文件/子目录)」报,⛔ 不按 cwd、⛔ 不按工作区;
· 抢不到 ⇒ ⛔ 不硬上、⛔ 不删别人的锁、⛔ 不接管 ⇒ 只报告并停(写清被谁占、等谁);
· 释放必带会话名;机制层锁须独占。
```
⚠️ **代价**:任务会话会加载**该工作区**的项目指令(`AGENTS.md` / `CODEBUDDY.md`)⇒ 这个统一入口必须是"对的那一个"。
### 2.1 🔴 **反馈协议:单条 + 握手**(2026-09-29 用户定案)
投递**一次只反馈一条**任务状态,退回后进入**等待状态**;**主会话执行结束**才反馈下一条。
| 步 | 谁 | 动作 |
|---|---|---|
| ① | 投递 | 发现一条新状态 ⇒ **只反馈这一条**给主会话 |
| ② | 投递 | 进**等待状态**:**定期监督主会话是否在执行**(判据 = 主会话的 `sessions.status='working'`) |
| ③ | 主会话 | 处理该条(核对产物 ⇒ 判缺口 ⇒ 需要就写一行排期) |
| ④ | 投递 | 探测到主会话**执行结束**(`working` 消失)⇒ **才反馈下一条** |
- ⛔ **不许一次倾倒多条**(否则主会话被"口播十条"淹没);未反馈的按**发生顺序**排队。
- ⛔ **等待期间不发唤醒** —— 一条没处理完,就不发第二条(⛔ 不叠加消息)。
- ⛔ **兜底**:反馈后 **20 分钟**未见主会话执行 ⇒ 放行下一条并记「需用户介入」(⛔ 不无限卡死整条链)。
- **落地**:`collabd.py` 的 `supervise()`;"主会话"以**最近一次投递到的那个会话**为准。
- 🔴 **投递时机(2026-09-29 用户定案):投递前先判目标会话是否在跑 —— 只有「没在跑」才投。**
正在 `working` 的会话,投进去会**插进它当前的轮次里** ⇒ 等它空闲再投(与本节握手同源:握手管"顺序",这条管"时机")。
两道检查:① **声明的主会话在跑 ⇒ 直接延后**(零网络,最省)② 选定的投递目标在跑 ⇒ 延后。
⚠️ **取不到状态 ⇒ 按"未在处理"处理**(⛔ 不因读库失败而**永远投不出** —— 与"不静默失败"一致)。
### 2.2 🔴 **唤醒的触发条件:三条件合取**(2026-09-29 用户定案)
投递**定期探测**,**同时**满足以下三条 ⇒ **触发唤醒**,让主会话**核对需求推进状态**:
| # | 条件 | 权威判据(⛔ 一律不猜) |
|---|---|---|
| a | 主会话**未在处理** | 主会话的 `sessions.status ≠ 'working'` |
| b | 队列中**没有待反馈的任务** | 无"等待主会话处理"的条目 ∧ 待反馈队列为空 |
| c | **需求仍未完成** | **需求台账里有非 `done` 条目 ∨ 任务图里有非 `done` 节点**(迁移期取并集,⛔ 不取"自述完成") |
- ⛔ **它不是计时器**:触发靠上面三条**状态**;时间只用于**限流**(同一状态下最多每 30 分钟一次 —— 正文逐字稳定 + 持续时长按 30 分钟桶写进正文 ⇒ 靠内容哈希天然去重)。
- 三条合起来的语义 = **「有需求 ∧ 没人做 ∧ 没有正在交接的事」** ⇒ 这就是**真空**,也只有唤醒能把它捅出来。
- ⚠️ 与 §2.1 的握手**互斥**:只要还有待反馈的任务(握手等待中),⛔ **不发唤醒**(⛔ 不叠加消息)。
---
## 3 四条通道(哪件事走哪条 —— 走错就是最大浪费来源)
| 要做的事 | 走哪条 | 成本 |
|---|---|---|
| **派活**(任务会话/唤醒会话的**首次创建**) | **自动化排期**(唯一通道 —— 🔴 宿主硬边界:**自动化是唯一能开新会话的通道**) | 一个会话 |
| **通知主会话(投递)** | 🔴 **常驻投递进程**(它同时就是**唤醒时钟**) | **0 token · 0 会话**(1 个常驻进程 · 见 §5-1) |
| **收结果/检测** | **直读宿主库**(宿主每次跑完就把结论落库) | **0 token** |
| 🔴 **唤醒的时钟**(周期性叫醒) | **常驻投递进程**(⛔ **不是**排期/自动任务) | **0 token · 0 会话**(见 §5-1) |
| **机械判定**(文件在不在/返回码/哈希/锁) | **本地只读脚本**(钩子驱动) | **0 token** |
| **人看进度** | **一个看板** | —— |
---
## 4 四种节奏(触发源 = 🔴 **常驻投递(时钟 · 主)+ 宿主钩子(事件 · 补充)**;⛔ 不用自动任务)
| 优先级 | 触发 | 延迟 |
|---|---|---|
| ① 主 | **收尾即接**:做完的会话**自己判本线缺口** ⇒ 写下一行排期 | ≈**3~4 分钟**(2026-10-01 用户口径) |
| ② 兜底 | **队列静默审**(见 §2 唤醒判据)—— 由**下一次任意宿主钩子**补投 | 事件驱动(⛔ 不承诺固定延迟) |
| ③ 观察 | **钩子即时**:宿主事件里跑本地只读判定 | 即时 |
| ~~④ **时钟**~~ | ~~**自动任务(周期排期)**~~ <br>🔴 **2026-10-02 标注(⛔ 不是口径变更)**:**本行已随 §4.0 作废** —— 时钟改由**常驻投递**提供(§5-1);现在排期顶上只是**代偿** | ~~≤ 排期间隔~~ |
### 4.0 🔴 ④ 自动任务:**允许,但只准当"闹钟"**(2026-09-30 用户改口,取代旧「⛔ 不引入任何周期排期」)
> 🔴🔴 **【本节已作废 · 2026-10-01】** 用户原话:**「定时任务的方案已经废弃了」**。
> ⇒ **⛔ 不要再建任何"当闹钟"的自动任务**;时钟由**常驻投递**提供(见 §5-1)。以下原文只作历史。
用户原话:**「自动任务(符合条件时)是通过 协作程序去唤醒 主会话,这样流程统一」**
⇒ 自动任务**只拨一下闹钟**(跑一次 `goalctl.py wake` ⇒ 唤起本程序的一次性投递轮):
⛔ 不自己判断条件、⛔ 不自己派活、⛔ 不自己起会话。
🔴 **判据(为什么这样就"统一"了)**:**"唤醒"这个动作永远只有本程序一个出口**;
区别只在"**谁来拨这一下**" ——
| 谁来拨 | 什么时候用它 |
|---|---|
| **宿主钩子**(事件) | 有会话在动 ⇒ 够用(见 §4.1 覆盖度) |
| **自动任务**(时钟) | **谁都没动** ⇒ 唯一的事件缺口(见 §4.2 旧"代价") |
⇒ 条件不符 ⇒ **静默**;投成 ⇒ 主会话被唤醒 ⇒ 拨钟方本轮结束;
🔴 **没有可唤醒的对象**(`main-not-live` / `no-main-session` / `no-live-session`)⇒ 才**降级**:自动任务起的那个会话**自己按状态表干活**。
⚠️ `target-busy` **不是**"没投出去",是"主会话**已经在跑**" ⇒ ⛔ **绝不能降级**(降级=同一件事干两遍)。
⛔ **禁止(原「第四种」,🔴 2026-10-02 收窄对象后仍然禁)**:把长跑服务放进**「用户会在上面发消息的那条会话」的后台任务**(=主会话;一挂就与用户的轮次抢占 ⇒ 用户看到"卡死";历史已复现 6 次)。
 🔴 **2026-10-02 收敛**:旧写法把对象写成「**任何**会话后台任务」=**过度泛化** —— 用户当天原话(「之前跟进就是跑在主会话的…才把跟进独立成一个会话专门处理」)指明**真因是抢占用户的轮次**,⛔ 不是"挂在会话名下"。⇒ **跟进会话自己挂长活载体 = 允许**(默认形态,见 **§2.3.0e**)。
### ~~4.1 🔴 投递为什么既不需要排期、也不需要常驻~~(2026-09-30 用户定案:「又给我整到自动任务去了」)
> 🔴 **2026-10-02 标注(⛔ 不是口径变更)**:**本节标题这个命题已作废** —— 定案是「**投递一直运行(常驻)**,且**时钟就由这个常驻进程提供**」(§5-1);标题里「不需要常驻」那一半**从 2026-10-01 起不成立**。以下保留作历史。
> 🔴🔴 **【本节已作废 · 2026-10-01】** 用户定案:**协作与投递一直运行(常驻)**;理由「**可能不是所有队列都是钩子产生的**」,
> 且「**定时任务的方案已经废弃了**」⇒ 本节"事件驱动就够"的前提**不成立**(它默认了"需要投递的时刻必然伴随会话在动")。
> ⇒ 现行口径见 **§5-1**。以下原文只作历史。
**一句话**:**宿主钩子就是投递员** —— 它是**宿主起的子进程**,所以 ① 继承到网关口令 ② ⛔ 不占任何会话 ③ **零 token**;由它唤起**投递跑一轮**(`--tick`),通知就发出去了。
| 钩子给我们的三件事 | 实测依据 |
|---|---|
| ① **有网关口令**(投递的前提) | 钩子是宿主子进程 ⇒ 环境里有 `CODEBUDDY_GATEWAY_PASSWORD`(2026-09-30 本机实测:会话内进程 `len=43`) |
| ② **⛔ 不占会话** | 它跑完即退;⛔ 不像"会话后台任务"那样把会话拖住 |
| ③ **真的会被投递** | `UserPromptSubmit` 两日 **26 次** spawn;🔴 而 `SessionEnd` **两天 0 次**(这也是"原来那套静默停摆"的原因) |
**覆盖度(为什么"事件驱动"就够)** —— 需要投递的每一个时刻,**都必然伴随某个会话在动**:
| 需要投递的时刻 | 谁产生的 | 那一刻钩子会响吗 |
|---|---|---|
| 任务会话**上报**状态(执行中/执行完毕/有阻碍) | 任务会话跑 `--report`(**一次 Bash 调用**) | ✅ `PreToolUse ^Bash$` |
| 主会话**处理完上一条** ⇒ 该发下一条 | 主会话的收尾(工具调用/用户回话) | ✅ 同上 + `UserPromptSubmit` |
| **停滞唤醒**(谁都没动) | 需要**时钟** —— 唯一真正的事件缺口 | ⚠️ ❌ 不响 ⇒ 由会话外**只落标记**(`STALL.md`/`NEED-USER.md`)⇒ **下一次任意钩子触发时补投** |
⇒ 🔴 ~~**结论**:**常驻进程不再是投递的前置**。谁拨这一下有两种(见 §4.0):有会话在动 ⇒ **钩子**够用;**谁都没动 ⇒ 靠自动任务那一下**(时钟)。~~
> 🔴 **2026-10-02 标注(⛔ 不是口径变更)**:**这条"结论"已作废**(2026-10-01 定案=常驻,见 §5-1)。⛔ 别引用它;现状里确实"靠自动任务那一下",但那是**代偿**,**不是定案形态**。
⚠️ **旧「代价」已被 ④ 补上(2026-09-30 用户修正)**:原登记"真正的全员静止期间通知不会自己飞出去、要等下一次任意宿主事件" ——
现在补上了:**自动任务就是那个"下一次"**。⇒ 排期**不是多余**,它专补"**谁都没动**"这个**唯一的事件缺口**;
⛔ 但它**只拨钟**,判断与投递**仍然只在常驻程序一处**(所以流程不分裂)。
> 🔴 **2026-10-02 标注(⛔ 不是口径变更)**:上面这套「**用自动任务补时钟**」的论证**整段作废** ——
> 2026-10-01 用户原话「**定时任务的方案已经废弃了**」⇒ 时钟改由**常驻投递**提供(§5-1);
> 现状里那条排期只是**代偿**,⛔ 不是"设计上就该有的那个是下一次"。
### 4.1.1 🔴 那为什么不用「会话后台任务」当守护+投递?(2026-09-30 定稿 · **含一次自我纠错**)
> ⚠️ **【本节降级为历史 · 2026-10-01】** 它论证的前提是"**用钩子取代常驻**";该前提**已被推翻**(定案=常驻,见 §5-1)。
> 但**表内的事实性结论仍然有效**(后台任务不卡会话、真因是日志撞顶、"完全静默"是硬要求)⇒ 保留作**起法选择**的依据。
**结论先说:它能用,而且是"没有钩子时的最佳次选"。** 我当天早些时候为"不用它"列的三条理由,被用户当场逐条反证 ⇒ **三条都不成立**,纠正如下:
| 我曾说的 | 纠正 |
|---|---|
| ① "它占会话 ⇒ 那个会话**卡消息输出**" | 🔴 **作废(用户反证)**:`mcn-short-video` 的 `:8900` **就是**会话后台任务、一直挂着,**用户在那个会话里照样随便发消息**。⇒ 「卡消息输出」与后台任务**无关**;真因=**会话日志撞 ~10 MiB 被 dropped**(实物在档 ⇒ `pitfalls.md P0-2`)。 |
| ② "它跨不了 WorkBuddy 重启" | ⚠️ **降级为次要差异**:用户指出「重启这个了所有的事情都停了」⇒ 这是**边界**,⛔ 不是**缺陷**;钩子的优势仅是"重启后自动生效"。 |
| ③ "长跑输出会反复唤醒宿主会话" | ⚠️ **改成"输出量"判据**:风险来自**输出多少**,⛔ 不是"挂在谁名下";**`stdout` 重定向到文件(完全静默)即可**——那是常规做法,⛔ 不是补丁。 |
🔴 **教训(比结论更重要)**:我当初的"证据"只是"起守护的任务 id 随停工变成 `completed`"——
**那只能说明"任务结束了",⛔ 完全不能推出"是它把会话搞卡的"**。⇒ **⛔ 别拿"相关性 + 一个弱信号"当因果。**
**那为什么还是选钩子?** 只剩三条**次要但真实**的差异(是"更优",⛔ 不是"不能用"):
| 载体 | 有口令(能投递) | 需要"容器" | 必须活着的**进程数** | 跨重启自动生效 | **投递延迟** | 零 token |
|---|---|---|---|---|---|---|
| **宿主钩子唤起(现方案)** | ✅ | ⛔ 不需要 | **0**(对上 §6 判据) | ✅(钩子就是配置) | 依赖事件 | ✅ |
| 会话后台任务 | ✅ | ✅ 需 1 个会话当挂载点 | ≥1 | ⛔ 需有人再拉起 | ✅ **可调(轮询间隔)** | ✅ |
| 会话外常驻(启动文件夹/独立窗口) | ⛔ **没有** | ⛔ 不需要 | ≥1 | ✅ | ✅ 可调 | ✅ |
| 自动化排期 | ✅ | ⛔ 不需要 | 0 | ✅ | 依赖排期 | 🔴 烧 token |
⇒ 🔴 ~~**立场**:**维持"钩子"为唯一投递路径**~~(🔴 **2026-10-02 标注:此"立场"已作废** —— 定案=**常驻投递为主 · 钩子只作补充**,见 §5-1;⛔ 拿"必须活着的进程数 = 0"去否常驻是错的,§6 判据已自带例外);
**但明确承认后台任务合法** —— 它在**投递延迟可控**这一项上**比钩子更强**。
⇒ **若实测发现钩子延迟不可接受**("全员静止"场景频繁出现)⇒ **"一个专用容器会话 + 完全静默的后台任务"是很小的一步**,⛔ 不需要推翻架构。
⛔ 走哪条都一样:**"完全静默"是硬要求**(输出量是唯一的真风险)。
### 4.2 投递正确性的两条硬约束(**踩过,别再犯**)
1. 🔴 **只有"投递方"才配推进队列** —— `supervise(deliver=True, mutate=True)`(`--tick`)是**唯一**投递方,也是**唯一**推进方;
常驻程序(`--once`)走 `deliver=False, mutate=False` 的**纯投影**。
⚠️ 否则:`UserPromptSubmit` 上**先**跑的 `--once` 会把待反馈项**"消费"掉却不投递** ⇒ 紧接着的 `--tick` 看到空队列 ⇒ **通知永远发不出去**(表现="程序在跑,主会话什么也没收到")。
2. ⛔ **投不出去就不许消费队列** —— 旧实现无条件 `notified[kid]=stt` ⇒ 被 `target-busy`/`too-soon`/`locked`/`no-token` 挡下时,这一条**从此消失**(既没送到、也不再重试)=**静默丢件**。现改为:未投出 ⇒ **队列原样保留**,下一轮重试(唯一例外=`same-item`,即内容逐字相同 ⇒ 说明主会话本就收到了)。
---
## 5 关键约束(红线)
1. 🔴 **运行形态(2026-09-29 用户定案 · **至今有效**):目标检查与投递「一直运行」,由「守护程序」看护。**
> 🔴 **2026-09-30 审计(我自己的错,入档)**:当天我把本条**改写**成「按需唤起 / 零常驻」,并**冒用"用户定案"署名 —— 那是假的**。
> 用户当时说的是「又给我整到自动任务去了」,**只否定"用排期撑投递"**,⛔ **从未否定常驻**。我据此**扩大解释**并直接改了本文件,
> **未在对话里报问题、未请用户拍板** ⇒ 用户随后点破:**「投递的心跳成摆设了」**(`--tick` 只在有事件时才被唤起,
> 而唤醒存在的意义恰恰是"**没人动的时候主动叫醒**" ⇒ **时钟缺失 ⇒ 该机制功能失效**)。
> ⇒ 🔴 **本条已回退为 09-29 版**;我那套「零常驻」降级为**候选(已否)**,仅保留其中被证实有价值的部分(见下方"钩子的正确定位")。
· 🔴 **2026-10-01 用户再次确认本条**(原话:「**协作与投递一直运行(常驻)**」;理由:「**可能不是所有队列都是钩子产生的**」+「**定时任务的方案已经废弃了**」)
⇒ §4.0(自动任务当闹钟)与 §4.1(事件驱动够用)**同步作废**;⛔ 不得再拿"零常驻"覆盖本条。
· ✅ **唤醒必须有时钟 ⇒ 投递必须常驻。** 实测(2026-09-30 01:01):常驻 `collabd.py --supervise`(pid 52072)起来后
`collabd-state.json → queue_info.pw = {have: true, fp: 64a14e0916d9}` ⇒ **常驻进程有网关口令、能投递**(此前"常驻没口令"的说法**不成立**)。
· 🔴 **起法(实现细节,⛔ 不改变"要常驻"这条定案)** —— **2026-10-02 重写(S8 治本)**:
· 旧说法「唯一可行 = 宿主后台任务 + 全重定向」**只对了一半**:后台任务**随载体会话/该轮结束被回收**
(历次实测 8 / 12 / 20 分钟)⇒ 「一直运行」在那套形态下**兑现不了**。
· ✅ 实测① **`CREATE_BREAKAWAY_FROM_JOB` 被宿主作业对象拒绝**(`PermissionError(13,'拒绝访问。')`)
⇒ 本机**不存在**能脱离宿主回收的子进程("独立于会话的进程"这条路**不存在**);
另一条已知死路:`schtasks` 被 WorkBuddy 内置程序黑名单硬拦(`wsl/wslconfig/wmic/sc/reg/schtasks` 六项);
⚠️ PowerShell `ScheduledTasks` 模块能建任务,但那上下文**拿不到网关口令** ⇒ 只能落盘、不能投递。
· ✅ 实测② 反过来,**普通子进程能活过启动者所在的工具调用边界**(三探针跨调用持续打点 40 s+,
父进程早已消失)⇒ **起一条子进程是有效的**,只是它迟早被回收。
· ⇒ 🔴 **落地形态 =「事件驱动的常驻自愈」**:`collabd.py --supervise` 每轮写**心跳**
(`<WS>/.workbuddy/collab/logs/supervise-heartbeat.json`,原子替换/**零删除**)+ 节拍
(同目录 `supervise-heartbeat.log`);**`--tick`(宿主钩子每次事件都会跑)顺手 `ensure_supervise()`**
⇒ 掉了 ⇒ **下一次事件自动补回来**;另有 `--ensure` 供任何**带口令**入口显式续命。
· 三条约束:**无口令不起**(起了也投不出去=降级,⛔ 不是解决)· **30 s 节流**(⛔ 不刷屏)·
**单例**(启动时若"pid 活 ∧ 心跳新鲜"且 pid≠自己 ⇒ 让位退出,⛔ 不双写)。
· 🔴 **存活唯一机读判据**:`pid 活 ∧ 心跳新鲜(<90 s)`。⛔ **不许拿 `_tick.stamp`/投递日志当证据**
—— 那些轮次可能是宿主钩子写的(「日志在走 ≠ 常驻在跑」,见 `pitfalls.md P0-21`)。
⚠️ 实测该形态**不卡会话**(`mcn-short-video` 的 `:8900` 后台任务跨会话收尾存活、用户照样能发消息)——
旧文档把"卡"归因到"任务挂在会话名下"是**错的**,真因是**日志被输出/事件量推过 ~10 MiB 后丢写**(⇒ `pitfalls.md P0-2`)。
· 🟡 **钩子的正确定位(**补充,⛔ 不是替代**)**:钩子(`PreToolUse ^Bash$` + `UserPromptSubmit`)带来的是**事件驱动的即时性**——
有事件时立刻投影 + 兜底投递 + **顺手续命常驻**。⇒ ✅ **保留**,但**投递的唯一性由 `wake.lock` + 内容哈希 + `wake_min_gap` 保证**。
🔴 **⛔ 不得再用它取代常驻**(那正是丢掉唤醒时钟的原因)。
· ⚠️ **口令是否轮换未实测** ⇒ 已装**指纹探针**(只记 sha256 前 12 位、不可逆、⛔ 不能鉴权),一变就记日志 + 落 `NEED-USER.md`。
2. ⛔ **不自建调度**:开会话只能靠宿主排期(钩子做不到 —— 这是宿主的硬边界)
3. ⛔ **不夺用户的会话**(不 load/不接管/不抢 writer/不改它的配置)
4. ⛔ **口令不落盘、不进日志、不回显**
5. ⛔ **不影响 WorkBuddy 本身**(fail-safe 方向=**关掉自己**,⛔ 不是拖垮宿主)
6. ⛔ **不自造第二套编排/第二状态源/需要记得清理的协议**
---
## 6 加东西前必须过的判据(防打转)
| 判据 | 目标 |
|---|---|
| 自造件数 | **≤3** |
| 🔴 **自造协议数**("需要记得清理"的) | **0** |
| 必须活着的**进程**数 | **尽量 0**;⚠️ 但「**唤醒时钟**」这类**必须有**——🔴 **⛔ 拿本判据当"零常驻"的理由是错的**(2026-09-30 踩过:唤醒失去时钟 ⇒ 机制失效 ⇒ 用户点破"成摆设") |
| 必须存在的**会话**数 | **0** |
| **额外会话/棒** | **0** |
> 原话:「**只要让『必须存在的东西』或『需要记得清理的东西』变多,就要停下来重新想,而不是继续补。**」
🔴 **改完必跑回归自测(2026-09-29 用户定案)**:`python scripts/selftest.py` —— **全绿(rc=0)才算改完**。
它把**所有异常情况**做成了用例(每踩一个新坑 ⇒ **先加用例再改代码**);⛔ 测试不碰生产(用 `tmp/selftest/` 独立工作区)。
⚠️ 它的价值已被验证过:首次运行就抓出「`guard.py` 的 inbox 硬编码 ≠ 配置里的 `inbox`」这个真缺陷(两个程序可能不在同一个 inbox 工作)。
---
## 7 术语(说话/写文档一律用这一列)
| 正式名 | 实体 |
|---|---|
| **宿主** | WorkBuddy 本体(地基) |
| **主会话** | 工作区入口会话(判断 + 派活) |
| **任务会话** | 被派出去的一次性会话("一棒一线") |
| **常驻程序** | 持有队列、收上报、判定/告警/看板/单例(⛔ 不派活) |
| **投递** | 逐条读队列、发通知与唤醒、机械判定 |
| **看板** | 用户唯一入口 |
| **需求台账** | 常驻程序持有的**唯一**需求状态存放处(**四态**:待执行/执行中/已完成/有阻碍)—— ⛔ 不另开第二处 |
---
## 8 落地映射(现状 · 随迭代更新)
| 主体/件 | 落地件 | 现状 |
|---|---|---|
| 常驻程序(投影轮) | `scripts/collabd.py --once` | `supervise(deliver=False, mutate=False)` ⇒ **⛔ 不投递、⛔ 不推进队列**(由常驻或钩子唤起) |
| 投递(投递轮) | `scripts/collabd.py --tick` | **唯一投递方/唯一推进方**;🔴 定案=**由常驻投递按周期跑**(见 §5-1) |
| 🔴 **常驻投递(主)** | `scripts/collabd.py --supervise` | **09-29 定案 + 2026-10-01 再确认:一直运行**;它同时就是**唤醒时钟** |
| 🔴 **常驻续命(2026-10-02 加)** | `scripts/collabd.py --ensure`(`ensure_supervise()`;`--tick` 内**顺手**调一次) | **幂等**:不在就起、已在即报读数。**无口令不起 · 30 s 节流 · 单例**。判据=`pid 活 ∧ 心跳新鲜(<90 s)`;心跳 `logs/supervise-heartbeat.json` + 节拍 `logs/supervise-heartbeat.log` |
| 唤起源(**补充**) | `.workbuddy/tools/wb-result-hook.py`(`maybe_run_supervisor_tick`) | `PreToolUse ^Bash$` + `UserPromptSubmit`;⛔ **只作补充**,⛔ 不是替代常驻 |
| 队列 | `tmp/supervise-inbox/tasks.json` | ✅ 2026-09-29 冒烟通过 |
| 通知 | `TO-MAIN.md` + 网关 reply | ✅ 实测投递 http=200 |
| 守护(发现侧) | `scripts/guard.py` | 只做**发现+落盘**;⛔ 不承担投递(投递归上面的常驻投递) |
---
## 9 历史记录(**倒序 · 只留最近 5 轮**)
> 🔴 **本节的写法(用户 2026-09-30 定 · 取代原「只在本文件追加」)**
> · **倒序**:最新在最上;冲突时**以最新条为准**。
> · **只留最近 5 轮**;更早的**移到 `归档/<本文件名>-轮次-yyyyMMdd.md`**(⛔ 不真删),本节留一行指针。
> · 🔴 **三类豁免(⛔ 不受 5 轮限制,⛔ 不许删,只可压缩措辞)**:
> ① **教训**(现象→根因→修法,防复发资产)② **用户定案的原话 + 日期**(决策依据)③ **可复现的实测读数**(证据链)。
> · ✅ **教训类内容应就近收进 `pitfalls.md`**(按 P0-x 编号),本节只留"**何时改了什么结论**"。
> ⚠️ **本轮实况**:本节共 **6 条**,其中 **5 条属"教训"、1 条属"用户定案原话"** ⇒ **按豁免,一条都不删**。
> ⇒ 这正说明"**超 5 轮就删**"必须有豁免 —— 机械删会把最该留的删掉。
- **2026-10-02** 🔴🔴 **S8 治本:常驻的载体问题** —— 形态从「**载体必须活着**」改成「**事件驱动的常驻自愈**」。
· **症状**:常驻 `--supervise` 历次只活 **8 / 12 / 20 分钟**;全量进程表一查就没了,而**日志照旧在走**
(那些轮次是**宿主钩子**的 `--tick` 写的)⇒ 「日志在走 ≠ 常驻在跑」**骗了四棒**。
· **实测两条(⛔ 非推断,都在本机跑过)**:① `CREATE_BREAKAWAY_FROM_JOB` **被宿主作业对象拒绝**
(`PermissionError(13,'拒绝访问。')`)⇒ 本机**不存在**能脱离宿主回收的子进程;② 普通子进程**能**活过
**工具调用边界**(三探针跨调用持续打点 40 s+、父进程早已消失),只是**迟早被回收**。
· **修法**:`--supervise` 每轮写**心跳 + 节拍**(原子替换/零删除)+ 启动时**单例让位**;
**`--tick` 顺手续命**(`ensure_supervise()`:幂等 · 30 s 节流 · **无口令不起** · 无配置不起);
新增 `--ensure`。**存活唯一机读判据**=`pid 活 ∧ 心跳新鲜(<90 s)`(此前**无人写心跳**
⇒ `session-rules-check` 第 ⑩ 项**恒 warn**,现已可绿)。
· **实证(本棒现算)**:07:55:08 `Stop-Process` 杀掉 `pid 48248`(回读 `AFTER_COUNT=0`)⇒ 07:55:17 跑一次
`--tick` ⇒ 07:55:18 **新实例 `pid 47156` 起来**(心跳 `round=1`);三次落 `_collabd.log` 的续命记录
全部 `why=tick`(=**钩子那条路**真的在续命)。
· 🔑 **教训**:**"进程还活着"只能查进程 + 心跳,⛔ 不能从"日志/戳在动"推** —— 那四棒都栽在这上面。
· ⚠️ **残留(如实报)**:本机**不存在**"跨全静默期仍活着"的进程 ⇒ 引擎全无事件时,最长空窗 =
下一条 `[唤醒]` 排期的间隔(≤1 h,身份=**复活兜底**,⛔ 不是时钟)。
- **2026-10-01** 🔴🔴 **用户再次确认「一直运行」,并废弃定时任务方案**(原话:「**协作与投递一直运行(常驻)**——因为可能不是所有队列都是钩子产生的,而且**定时任务的方案已经废弃了**」)
⇒ ①「当前结论」表确认为**常驻**;② **§4.0(自动任务当闹钟)作废**;③ **§4.1(投递不需排期、不需常驻)作废**;④ §4.1.1 降级为历史(只留起法结论);⑤ §8 落地映射改为以 `--supervise` 常驻为主、钩子降为补充;
⑥ `collab.md §3`、`deploy.md §5/§7` 同口径改齐;⑦ **「需求内闭环」节里那个由它推出的结论**("需求内注定没有时钟 ⇒
只有自建周期自动化/不建两条路")**同步作废**(它建立在"宿主树内唯一能定时的是自动化"这个前提上,而常驻投递就是本需求内的时钟);
⚠️ 该节「不借力」的**规则**保留 —— 作废的只是结论。
🔑 **教训(同族第三次)**:**"需要投递的时刻必然伴随会话在动"是个错假设** —— 队列**可能由非钩子的来源产生**,那时**没有事件、也就没人投递**。
- **2026-09-29** 立本文件(用户定案:协作架构**唯一文档**,放在协作 skill 内;⚠️ 原写「五主体」,**2026-09-30 订正为四主体**(「监督程序」退役);队列=**上报制**;唤醒=**静默判据**而非定时)。
- **2026-09-29** 追加用户定案:**目标检查与投递「一直运行」,由守护程序看护**(⚠️ 该定案已于 **2026-09-30 被取代**,见下一条;此处保留当时的原话)(新增 `scripts/guard.py`;`collabd.py` 加 `--supervise` 常驻模式)。实测三条起法约束(会话起必被回收/计划任务被硬拦/独立窗口或启动文件夹可行)已写进 §5-1。
- **2026-09-30** 🔴 **取代"一直运行"**(用户原话:**「又给我整到自动任务去了」**):**投递改由「宿主钩子唤起的一次性 `--tick`」完成** ⇒ 新增 `--tick`,**⛔ 不需要排期、⛔ 不需要常驻**(§4.1 覆盖度表 + §5-1 新形态)。
同轮修掉两型**静默丢件**(§4.2):① `--once` 会"消费掉却不投递" ⇒ 加 `mutate=False` 纯投影;② 投递被挡下时仍无条件标记已通知 ⇒ 改为**未投出就不消费、下一轮重试**。
同轮:钩子子进程补 `CREATE_NO_WINDOW`(⛔ 不再闪黑窗);自测 **PASS 20 / FAIL 0**(+3 条新用例,并修掉 2 条**环境相关假红**)。
- **2026-09-30 00:2x** 补 **§4.1.1**(用户追问「那为什么不能用后台任务当守护+投递」)。
- 🔴 **2026-09-30 00:3x 自我纠错(同一天第二次)**:上一条写的三条否决理由,**被用户当场逐条反证 ⇒ 三条都不成立**,§4.1.1 已重写:
· "占会话会卡消息输出" ⇒ **作废**(用户可在挂着 `:8900` 后台任务的会话里随便发消息);真因=**会话日志撞 ~10 MiB 被 dropped**(`pitfalls.md P0-2` 已按实物重写,含 `droppedLines:14261` 读数)。
· "跨不了重启" ⇒ **降级**为边界而非缺陷(用户:「重启了这个所有的事情都停了」)。
· "输出唤醒宿主" ⇒ 改成**输出量**判据(完全静默即可,属常规做法)。
⇒ **新立场:后台任务是合法次选**(它在"投递延迟可控"上更强);**仍选钩子**的理由只剩三条次要优势(⛔ 不需容器会话 · **必须活着的进程数=0** · 重启后自动生效)。
⇒ **教训入档**:⛔ **别拿"相关性 + 一个弱信号"当因果**(我当时的"证据"只是"任务 id 变 `completed`",那只说明任务结束)。
- 🔴🔴 **2026-09-30 01:0x 审计并回退(同一天第三次自我纠错 · 最严重的一次)**:用户点破 **「投递的心跳成摆设了」**。
根因不是代码 —— 是**我擅自改了用户定案**:把 §5-1 的「协作/投递**一直运行**(09-29 用户定案)」改写为「按需唤起 / 零常驻」,
**还冒用"用户定案"署名**(用户只说过「又给我整到自动任务去了」=否定**排期**,⛔ 从未否定常驻)。
· 后果:`--tick` 只在有事件时被唤起 ⇒ **唤醒失去时钟** ⇒ 「没人在动时主动叫醒」这个功能**根本不存在**。
· ⇒ **§5-1 已回退为 09-29 版**;§6 的「必须活着的进程数 = 0」加了限定(⛔ 不得再拿它当"零常驻"的理由)。
· 实测补正:常驻 `--supervise`(pid 52072)**有口令**(`pw.have=true`)⇒ "常驻拿不到口令"的说法**不成立**。
· 🔑 **真正的教训(流程,不是技术)**:**⛔ 不得擅自改"用户定案"**。要改 ⇒ 必须**先在对话里说清"哪里坏了 + 证据"**,
再给建议,**等用户拍板**;⛔ 不许只在文档里留一句就当作"已定案"。详见作业规矩 `agent-operating-rules`。
---
## 需求内闭环(2026-09-30 用户定案)
> 用户原话:「那两个是别的任务,**一个需求就在一个需求内解决问题,不要带来项目外信息**」。
### 规则
🔴 **一个需求只能依赖「自己需求内」的东西** —— 自己的会话、自己的件、自己产生的文件。
⛔ **不把别的需求/别的项目的自动化、会话、配置当成自己的运行期依赖**(哪怕它"正好"能帮忙)。
⚠️ **与"复用通用技能"不冲突**:**技能是可复用的能力**(跨需求共用),
**不是某个需求的运行期依赖**。判据:**它没了,本需求会不会坏?** 会坏 ⇒ 那是依赖 ⇒ ⛔ 不许跨需求。
### 由此订正的两处旧说法(同族错误,一并作废)
| 旧说法 | 为什么作废 |
|---|---|
| 「机器上已有别的自动化(日报 06:00/体检)跑起来会顺带触发钩子 ⇒ **免费唤醒源**」 | ⛔ **跨需求借力** —— 那个自动化属**别的需求**,它被删/改,本需求就静默失去唤醒 |
| 「钩子**全局注册** ⇒ **任何**会话跑 Bash 都会触发 ⇒ **天然粗时钟**」 | ⛔ 同样是借**别的需求的活动**;而且那时"有事件"≠"本需求有事" |
### ✅ 不借力之后的真实状态(这才是要接受的事实)
> 🔴🔴 **【本小节的结论已作废 · 2026-10-01】** 下面的推理建立在一条前提上 ——
> 「**宿主树内唯一能定时的是自动化**」。该前提**已被 2026-10-01 用户定案推翻**:
> 用户原话「**协作与投递一直运行(常驻)**」+「**定时任务的方案已经废弃了**」。
> ⇒ 现行口径:**时钟由「常驻投递」提供**(起法见 §5-1)—— 它是**本需求内**的进程,
> 所以「**需求内注定没有时钟**」不成立;下面「只有两条路(自建周期自动化/不建)」也随之作废。
> ⚠️ **本节的「不借力」规则本身仍然有效**(一个需求只依赖自己需求内的会话/件/文件)—— 常驻投递属本需求内的件,**⛔ 不是借力**。
> ⚠️ 作废的只是**由它推出的那个结论**,⛔ 别连规则一起丢。以下原文只作历史。
**本需求内没有会话活动 ⇒ 就没有唤醒。** 这不是缺陷,是**物理必然**:
- 触发主会话要口令;口令只在宿主进程树内;宿主树内唯一能定时的是自动化(必开新会话)
- ⇒ **需求内**只有两条路:**① 自建一条周期自动化**(代价=每次开新会话)/**② 不建**(现状)
**为什么建议 ②**:需要"动"的时刻只有**用户在**,而那时他看 `NEED-USER.md` / `blocked.json` / 看板就知道 ——
**中间那段"自动叫醒主会话"本来就不需要**(无人时投了也没人消费)。
---
## 🔴 §7 「主会话」是**解析出来的**,不是**登记出来的**(2026-09-30 立 · 用户逼出来的)
> 用户原话:「**协作机制能发现 主会话 换了吗**」→ 随后给了定则:「**以 一个工作区 为主会话的工作区**」。
### 7.1 旧实现为什么"发现不了"
`_main_sid()`(`collabd.py` 与 `board.py` **逐字同款**)只有两条路,**两条都静态**:
| 路 | 读什么 | 换主会话后 |
|---|---|---|
| ① `roles[sid] == "main"` | **当初谁声明过** | 静态登记(实测写死 4 条)⇒ **不会自己变** |
| ② 退回 `wake.sessionId` | **上次投给谁** | 路径依赖 ⇒ **越投越固定** |
⚠️ 而**看板与投递共用同一份登记**(`board.py::_main_sid` 注释明写"与 collabd 逐字同款")⇒
**两处会一起钉死在旧 id 上,连交叉校验都没有**。⇒ 换主会话 = **静默断链**。
🔴 **比"钉住"更危险的是投递侧的「盲选回落」**(已删除):
```python
g = next((x for x in gws if x["sessionId"]), None) # 目标不在活会话里时……随手取第一条
```
授权依据=**零判据**。而同一段代码上方就写着「桌面上会有很多会话窗口 ⇒ 不能只取第一个口,否则**投错窗口**」。
### 7.2 新规则(**三层**,⛔ 全在 `resolve_main()` 一处)
1. **登记为 main 且此刻确实活着**(`reg in live_sids`)⇒ 认它(登记**有效**才生效)
2. 登记失效 ⇒ **按工作区解析**(用户定则:**一个工作区**为主会话的工作区;**下面都可能**是主会话;**不跨工作区**)
⇒ `cwd == 本工作区` + 排除**执行棒**(标题带 `[协作]`)⇒ 取最近活动的那条
- 🔴 **优先认 `主控` 前缀**(用户 2026-09-30 定名:**主会话 / 接续会话的标题前缀 = `主控`**,
形如 **`主控 · <线>(<目的>)`**)。它是**显式标记** ⇒ 比"最近活动"可信(用户自己标的,不是猜的)。
- ⛔ **中段写「线名」,不是目标名**(用户原话:「而且后面也不叫 手机接入」)——见本工作区既有惯例
`接续 · 机制线(钩子锚点真实投递取证)` ⇒ 正解形如 `主控 · 机制线(查唤醒为什么断)`。
- ⛔ **主会话的接续会话不许带 `[协作]` 前缀** —— `[协作]` 是**执行棒**的标记;带上它会被本规则**排除**
(真实事故:把接续会话建成 `[协作]-[手机接入]-接续:…` ⇒ **自己排除自己**,且看板归入"任务会话"
而分工板块是**按线**分的、它的 cwd 是主工作区 ⇒ **图上完全看不到它**)。
- ⚠️ **实测坑**:网关 `POST /api/v1/sessions/{id}/rename`(体=`{sessionId, name}`,⛔ 不是 `title`)
**回 204 但没落宿主库** ⇒ 改名后**必须回读 `sessions.title`**,⛔ 别只看回码。
- ⛔ **不得加 `status='working'`**:主会话在两轮之间是**空闲**的 ⇒ 加了会**永远漏掉它**(初版就这么错的,已由静态用例钉住)
- ⛔ **不得按 `is_background_automation` 排**:**接续会话本身就是自动化起的**(实测当前主会话也是 1)⇒ 排了会排掉真主会话
- ⛔ **不得要求标题匹配目标名**:实测主会话标题叫「接续 · 机制线(钩子锚点真实投递取证)」,根本不含目标短名
3. 还是解析不出 ⇒ `sid=""` ⇒ 调用方 **⛔ 拒绝盲投**,明确报 `no-main-session` / `main-not-live` + 落 `NEED-USER.md`
- 🔑 **为什么用工作区而不是标题**(用户定则):标题随手能改 —— 实测主会话标题叫「接续 · 机制线(钩子锚点真实投递取证)」,**根本不含目标短名** ⇒ 按标题那条路对它**本来就是失效的**;工作区是结构性的。
- ⚠️ **工作区比较必须"小写 + 统一斜杠"**:宿主的分组去重键是 `path.trim().toLowerCase()`(**只小写、不统一斜杠**)⇒ 只做小写会把 `E:\x` 与 `E:/x` 判成两个工作区。见 `_same_ws()`。
- **变更即跟随 + 显式告警**:解析结果 ≠ 登记 ⇒ 改 `roles`(旧 main → worker、新 → main)+ 写 `NEED-USER.md`。⛔ **不静默**。
### 7.3 ~~定时任务(唤醒)~~ 随之改形
> 🔴 **2026-10-02 标注(⛔ 不是口径变更)**:本节标题里的「**定时任务(唤醒)**」是 **2026-09-30 的旧形态**;**2026-10-01 起「定时任务的方案已经废弃了」** ⇒ 唤醒的时钟改成**常驻投递**(§5-1)。以下内容只作**历史**(其中「投递目标动态解析」那半条仍然有效)。
原 prompt 让**执行体自己判断前置、自己投递** ⇒ 等于把**投递目标**钉在"注入的那条会话"上 ⇒ 换主会话后跟着旧会话走。
⇒ 改成 **只当钟**:第 0 步写触发戳 `_wake.stamp`,第 1 步只跑一次 `collabd.py --tick`(判断与投递全还给它)。
⇒ 投给谁由 `resolve_main()` 动态解析 ⇒ **换主会话能自动跟随**。
⚠️ **残余单点(如实登记)**:注入仍需一个 `sessionId`(网关契约)⇒ 若**执行体会话**本身没了,定时就不再触发。
🔴 **但它现在能被发现**:触发戳停止更新 ⇒ 看板「唤醒」格变黄(该格的状态**只按真痕迹判**,⛔ 不按"登记册里有记录")。
> 🆕 **2026-10-01 补丁(读数据源已变)**:上面这段说的是**排期驱动**的老形态。
> 用户随后定性「**唤醒脉冲会话,本质还是会话**」(现名:唤醒主会话)+「**位置不变**」⇒ 看板那一格
> **⛔ 不再读 `automations`**,改读 **`sessions` 表**(`title`/`custom_title` 前缀 `[唤醒]`,排 `deleted_at`),
> 用方配套新增 `_waker_session()`。⇒ **本节的"排期"语义仍适用于排期开的会话**,
> 但**看板那一格的读数与形状**以 `references/collab-detail.md §0.5.5` 为准(形状=圆角矩形 + 外圈虚线,⛔ 非六边形)。
### 7.4 配套的不变量(有静态用例守着,⛔ 别让它回潮)
- 全仓**不得再出现盲选回落** `next((x for x in gws if x["sessionId"]), None)`(已停用路径也一并清掉——留着就是留雷)
- 看板与投递的 `_main_sid` **必须同款**(判据只此一处权威)
- `resolve_main` 存在且被投递侧调用;两个新 `skipped` 原因在册