# 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` =**纯投影**,⛔ 不写。
**处理**=`maybe_spawn_check_agent()` 建 `[检查]-…` 排期(五道闸),由常驻 `--supervise` 每 2 轮判一次 —— ⛔ 这是这条腿的**唯一载体**(**检查会话只能靠排期开**)。
⛔ **没有第三环**:队列变化**不再自动通知任何人**。 | | **⛔ 已废弃** | **「用自动任务当闹钟」**(2026-10-01 用户明确废弃,原话:「**定时任务的方案已经废弃了**」)⇒ §4.0/§4.1 只作历史。|🔴 **2026-10-03 新增**:**队列投递段 + 唤醒回路**(载体就是投递)—— 见本节上方状态块。 | | **本机起法** | 🔴🔴 **2026-10-02 23:0x 实测订正**:**不是"本机不存在长跑进程",是"载体不同"。**
① 同一时刻用 `start /b` 与 `Popen+DETACHED` 各起一条每秒打点的探针 ⇒ 两条**活到约 11 分钟后停在同一 tick**(67/64 行)⇒ **差别不在起法关键字,在父链**(从**工具调用进程树**里起的活不过当轮)。
② 反证:MCN 工作台 `mcn-work-shop/start.bat` 由**用户从桌面双击**、属用户登录会话 ⇒ **一直活着**。
③ ✅ **正解=走 WorkBuddy 自己的后台任务**(`run_in_background`;⛔ 别用 `subprocess` 自己造)。⚠️ 子进程用 `pythonw.exe`(GUI 子系统 ⇒ ⛔ 不闪窗)|⚠️ `pythonw` 无 stdout ⇒ **必须显式重定向到文件**。
④ **S8 自愈仍保留**(`--supervise` 心跳 + `--tick` 顺手续命 + 判据 `pid 活 ∧ 心跳新鲜(<90 s)`),⛔ 但理由已换:**常驻总会被打断** ⇒ 要能自动补回来。⛔ **别拿 `_tick.stamp`/投递日志当证据**(那些轮次是钩子写的 —— P0-21)。
⑤ 🔴 **启完必查三样**:`netstat` 有 **LISTENING**(⛔ `TIME_WAIT`/`FIN_WAIT_2` 不算)+ `curl` 得 **200** + `tasklist` 进程在。
⑥ 🔴 **改看板要不要重启有四类答案(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` 找不到目标(`/goal.json` 缺 `title`)⇒ **拒绝启动(rc=3)**,并给出两种声明方式。 🔴 **宁可不开,也不空转** —— 没有目标,机制根本不知道自己在为什么跑。 | 字段 | 作用 | |---|---| | `title` | **必需**:一句话目标(摘要/看板/唤醒都显示它) | | `acceptance_doc` + `acceptance` | 验收判据(如 `V1–V7`)与其文档 ⇒ **判「完成」的依据** | | `taskgraph` + `lines` | 任务图、涉及的线 | **声明 / 改写**:`guard.py --goal "<一句话任务目标>"`(或直接编辑 `/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、被唤醒后 跟进目标情况 判断是否创建)**」 ⇒ **它只做一件事:判断「要不要创建协作会话」,要就建一条。** 两条触发、同一个动作: | # | 触发 | 它做什么 | |---|---|---| | ① | **收到队列上报**(目标检查投递进来) | 跟进目标情况 ⇒ 判断是否创建任务会话 | | ② | **被唤醒**(宿主排期定时拉起)
🔴 **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 …` ⇒ **立刻**生效(接续协议的必做一步)
② **窗口兜底**:`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 条」
(⚠️ 收起而不说 ⇒ 读者把"收起了"读成"本来就没有"=同族红线) | | **渲染侧** | `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)**:把 `.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 唤醒判据)—— 由**下一次任意宿主钩子**补投 | 事件驱动(⛔ 不承诺固定延迟) | | ③ 观察 | **钩子即时**:宿主事件里跑本地只读判定 | 即时 | | ~~④ **时钟**~~ | ~~**自动任务(周期排期)**~~
🔴 **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` 每轮写**心跳** (`/.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`)⇒ "常驻拿不到口令"的说法**不成立**。 · 🔑 **真正的教训(流程,不是技术)**:**⛔ 不得擅自改"用户定案"**。要改 ⇒ 必须**先在对话里说清"哪里坏了 + 证据"**, 再给建议,**等用户拍板**;⛔ 不许只在文档里留一句就当作"已定案"。详见本包作业规矩 `references/作业规矩/`。 --- ## 需求内闭环(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` 原因在册