commit 19101acd65a4fee03be25d5bb82a4122eec7c218 Author: maogeigei Date: Mon Oct 5 14:13:24 2026 +0800 init: workbuddy_skills 重建,仅收录 session-mechanism - 按用户指示清空原有 25 技能内容,只提交 session-mechanism(57 文件) - 附 .gitignore(产物 + 本机凭据) - 令牌明文已脱敏(历史 .neodata_token 与 pitfalls 引用均不入库) - 本提交为孤儿提交(父提交为空),历史自此重新开始 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b1ac13b --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# 运行期产物与备份(不入库) +*.log +install.log +*_collabd.log +__pycache__/ +*.pyc +*.pyo +*.bak-* +*.bak +*.orig +*.rej +.DS_Store +Thumbs.db +*.swp +*~ + +# 本机凭据(⛔ 一律不入库) +.neodata_token +.neodata_* +*.token + +# 刻意入库的:roots.env(只有路径、无密钥,是 install.py 生成的部署契约) diff --git a/session-mechanism/SKILL.md b/session-mechanism/SKILL.md new file mode 100644 index 0000000..7d57767 --- /dev/null +++ b/session-mechanism/SKILL.md @@ -0,0 +1,856 @@ +--- +name: session-mechanism +description: 「**会话机制 + 多会话执行**」的合并总入口(原 `multi-session-collab` + `workbuddy-session-forensics` 已并入本包)。🔴 **2026-10-05 起会话只三类:主会话 + 任务会话 + 检查会话**(唤醒/跟进/队列上报**已整套退役**;**任务会话=旧称「执行会话」,同一个角色**)|🔴 **2026-10-03 起「上报」整套真删**——现行机制=**常驻程序 `collabd.py` 接收(`--report` 写台账)+ 处理(建检查会话排期)**(⛔ 「协作程序」是 10-03 已退役的旧称,见文首术语表),⛔ **队列变化不再自动通知任何人**,要落事得**显式建执行会话**。两段可分别加载:① **会话机制** —— 管「会话怎么活下去、怎么不哑掉、怎么不失忆、跨机器怎么一键装好」:治「会话卡住 / 一直转圈 / 发消息没反应 / 界面不刷新」「会话日志涨到上限把界面顶死」「上下文爆了要换会话 / 接续会话怎么开」「抢锁 / 执行锁 / 并发 / 别的会话在动」「钩子没生效 / 钩子把我也拦住了」「换一台电脑要重装这一堆钩子」「某个历史会话当时到底干了啥」。② **多会话执行** —— 管「一个主会话带多个执行会话把需求做完」:治「多个会话协同但工期被拖长 / 有会话在干等 / 链条断了没人接 / 分不清『真完成』还是『只排了下一棒』/ 问清主会话·执行会话两类怎么分(**接续会话是撞阈值时的「形态」,⛔ 不是第 3 类**)」。🔴 **主触发句(2026-10-03 用户定案)**:用户说「**使用执行会话完成 XXXX 目标**」「**继续 XXXX 目标**」时**直接走本技能**(=要派执行会话去把一个目标做完/接着做)。其他触发:「怎么协同多个会话」「别的会话都在干等」「任务没推进」「链条断了」「你监督这些会话」「会话卡住」「日志要爆了」「换机器怎么配」「升级 / 安装这套机制」。核心=**会话机制三件套(钩子 + 锁 + 日志闸)全局生效** + **执行两条通道各走各的**(派活靠自动化/收结果直读宿主库/机械判定下沉到常驻程序/人只看一个看板)+ **一键配置 `install.py`**(换机器不手抄绝对路径)。 +version: 1.3.0 +updated_at: 2026-10-05 +last_change: 2026-10-05 10:5x · 🔴🔴 **`lifecycle` 判定收敛为「单一事实源」**:抽 `goal_life_of(root)`(剥全角/半角括号后缀 + 认不出时写日志),`goal_life()` 降为薄封装,`peer_supervise_sweep()` 改调它 —— 此前它**自己又抄了一遍**精确匹配(不剥后缀)⇒ **同一份 `goal.json` 两条路两个答案**。⛔ 判「目标状态」一律问代码(`--check-status`/`goal_life_of()`),⛔ 不许肉眼读原文比对。⚠️ 端到端实测坐实**夹具坑**:合成区只复制配置字节、不改 `workspace` 字段 ⇒ 钩子读的是**真区心跳** ⇒ 正确地"在位沉默",而我差点误判成"钩子失效"(P0-53)。 🔴 起不来先报错这条红线已落进 T 表 §1.6(用户原话「必须首先启动好在执行」)+ **`supervise-ensure-hook.py` 两个真缺陷已修并变异验证报红**:① 工作区原先只读 `DSH_WS_ROOT`(宿主 env 三级全空 ⇒ **钩子长期空转**)② `env=dict(os.environ)` 对 `exec_module` 里的 `load_cfg()` **无效** ⇒ 会验到别的区。⛔ 教训=**「零输出」既可能是"正确沉默"也可能是"根本没看见"⇒ 必须造合成区做反向对照**。 🔴 10-04 17:3x · **文档索引已建**:`references/01-文档索引.md`(干什么事→看哪篇 + **文档四条规则**:分类索引/结论在最前/历史倒排·新的在前/单条 ≤6 KB)+ SKILL.md 第一屏挂「改机制前必读三篇」(⛔ 只写进工作区日志的规矩=没立:10-03 立的「⛔ 写会怎样前先取证」10-04 复发)。 🔴 **本行是「现行口径台账」,⛔ 不是变更日记**:只记**现行结论**,实体与来路在 `references/`(下面每条都给指向)。⛔ **不再在这里堆叠「本条为准」式的旧口径**(那会让同一件事在两处各存一份、改一处忘另一处就打架)。 +· **常驻形态(2026-10-03 定案)**:每个工作区的常驻**由它自己的主会话起**(主会话开工第0 步:确认 pid 活 ∧ 心跳 <90 s);看板**全平台只保留一份**,其它区用 `peer_workspaces` 并列看。→ `architecture.md`《运行形态》+`pitfalls.md` **P0-41**(载体与三条封死路)。 +· **常驻存活唯一机读判据**:`pid 活 ∧ 心跳新鲜(<90 s)`—— ⛔ 别看退役旧戳。→ `pitfalls.md`。 +· **看板 tab 跨工作区并列**:一样数据源跟着格走(⛔ 只换标题不换数据源 ⇒ 看板在说假话)。→ `pitfalls.md` **P0-40**。 +· **改看板要不要重启?四类答案**(读前部的真相)。→ `pitfalls.md` **P0-38**。 +· **钩子超时是「拦截」不是「变慢」**:会报阻塞、不会只慢。→ `pitfalls.md`。 +· **开工第 0 步:会话机制体检**(不是规划)**—— 用户原话「不是规划 是 规则」。→ `references/collab.md`。 +· **诊断口径**:删看板里「负责类别:—」那行(它会说假话)、会话类别与主体的两轴消歧。→ `references/collab-detail.md`。 +· **看板标签用「简称」**:简称只是压缩版面,⛔ 不是又多了角色。→ `collab-detail.md`。 +· **常驻被杀的真因结案**:「释放锁」≠「删文件」(**P0-6**);载体与长期在线→ `references/supervise-persistence.md`。 +· **投赴与队列投递**:「投递」整套已真删(2026-10-03,用户原话「没用了就删除」);实际机制=**常驻接收(`--report` 写台账)+处理(建检查排期)**,⛔没有「推送」环节。→ `pitfalls.md`。 +· 🔴🔴 **术语统一**(用户「语言全部统一」)——**同一件事只许有一个词,现行以第三代为准**: + + | 代 | 时间 | 被派的会话 | 常驻程序 | 状态 | + |---|---|---|---|---| + | 一 | 10-02 前 | 协作会话 | **协作程序** | ⛔ **退役**(10-03 用户下令改,⛔ 包内未同步) | + | 二 | 10-03 | 执行会话 | 目标检查 | ⚠️ **过渡名**(代码与看板仍在用) | + | 三 | **10-05** | **主会话/任务会话/检查会话** | **常驻程序** | ✅ **现行** | + + 🔴 **任务会话 = 旧称「执行会话」,同一个角色**(⛔ 不是第四类)。 + 🔴 **待办(未做)**:一/二代旧词在包内仍有 ~174 处(`collabd.py` 37、`board.py` 27、`board.html` 28、`selftest.py` 22、`goalctl.py` 15…)。 + ⛔ **改名不能盲替换**,先分三类:① 注释/文档 ⇒ 可直接改;② `selftest.py` 的 `@case` 标题 ⇒ 改了会断 `-k` 用例引用;③ 看板显示文案 ⇒ 改了用户可见,且可能有字面判据(`board.py:191` 那条就是防两处漂移的)。→ 待办清单 `references/manifest.md`。 +· **本包自身**:`install.py`(干跑/安装/校验)+`references/manifest.md`(清单);⛔ 包外**不应**有第二套会话机制代码。 +agent_created: true +--- + +# session-mechanism — 会话机制(含多会话执行) + +## 🔴🔴 改流程/改机制前,**先看这三篇**(10 分钟,⛔ 别通读文档) + +| 序 | 必读 | 为什么是它 | +|---|---|---| +| 1 | **`references/00-动手前必过.md`** | 六条动作红线(⛔ 开工前只读这一篇就够) | +| 2 | **`references/rules.md`** | 现行规则本体(⛔ 历史在 `pitfalls.md`) | +| 3 | **`references/manifest.md`** | 清单:哪个文件是**权威**(改之前先确认) | + +**📇 完整索引(干什么事 → 看哪篇):`references/01-文档索引.md`** +⚠️ 那篇里还有**文档四条规则**(分类索引/结论在最前/历史倒排·新的在前/单条 ≤6 KB)—— +**⛔ 只写进工作区日志的规矩 = 没立**(10-04 实证:一条红线立在对的地方,换会话照样读不到)。 + +## 🚨 十条禁令(2026-10-05 立 · ⛔ 每条都栽过,栽一次就重来一遍) + +> 用户 2026-10-05 原话:「**你不要给我知道了,你给我记下来,每次都是知道了,知道了,过一会儿又忘了**」。 +> ⇒ 所以这些话不放日志、不放事后复盘,**放在每次加载都会读到的地方**。⛔ 改任何东西之前先过一遍。 + +1、⛔ **不清楚就先读代码/日志/任务定义,⛔ 不许推断后当结论说**。栽过:把"5 分钟一次"编出来(实际每分钟)、把"已经停了"说出口(实际任务还在跑)。 +2、⛔ **报数字必须先数**(日志行数、次数、间隔),⛔ 不许从现象反推节奏。 +3、⛔ **说"已修复"之前必须复核一次现场**(进程/心跳/端口/日志时间戳),复核读数贴进回复。 +4、⛔ **⛔ 不许在用户机器上做起停实验**。为取证起一个可能弹窗的东西 = 本末倒置。栽过:为"证明留不住"起了个循环脚本。 +5、⛔ **任何"改成交给用户做"的方案,必须先有用户原话**。栽过两次:脑补"开机自启"、脑补"手动启动"。 +6、⛔ **改动只许往一个方向收敛**:先只读盘点 → 说明 → 再动手。⛔ 不许一边分析一边改,那样会把现场越改越乱。 +7、⛔ **禁用不等于停止**(任务禁了老进程还在跑);**杀进程不等于停止**(任务到点又拉起)。要么都做,要么不做。 +8、⛔ **凡"东西自己反复触发",第一步读它的调度定义**(触发条件/重复间隔),⛔ 别从现象反推。 +9、⛔ **验证通过的结论,落点只能是技能**(`SKILL.md` 第一屏 或 `references/`),⛔ 不许只写工作区日志。 +10、⛔ **用户说"你知道了"时,⛔ 不许回答"知道了"** —— 去把它写成上面这种条文。 + +## 🗺️ 现状地图(2026-10-05 · ⛔ 看机制**先读这一屏**,读完再往下) + +**三类会话(⛔ 只有三类,没有第四类)** + +| 角色 | **谁创建它** | 它干什么 | 怎么停 | +|---|---|---|---| +| **主会话** | 用户手动开 | 管目标与方向、**派活** | 用户关窗 | +| **任务会话**(旧称执行会话) | 🔴 **派活产生** —— 主会话判缺口后登记自动化 → 宿主到点拉起(⛔ **不是常驻建的**) | 一棒一线、一次一件,做完即上报 | 做完自止 | +| **检查会话** | 🔴 **常驻程序** `collabd.py`(`maybe_spawn_check_agent()`,六道闸) | 核对结果/目标,缺口再派活 | 做完自止 | + +**三条线各归各的(⛔ 别混谈)** + +1. **派活线**:主会话 → 任务会话。排期名 `[协作]-[<类别>]-<具体>`。🔴 **不受总开关影响**(开关只挡常驻)。 +2. **检查线**:常驻 → 检查会话。排期名 `[检查]-[结果检查/目标检查]-…-第N棒`。🔴 **受总开关管**。 +3. **常驻线**:一工作区一条 `collabd.py --supervise` + 看板**全局只一条**(端口 20099)。 + +**常驻的唯一管理入口**:`python collabctl.py `(加 `--out <文件>` 取结果) +**用户可见入口(双击)**:`会话机制-一键开关.bat`(工作区根 + 桌面各一份)= 启动/全部停止/看状态 + +**🔴🔴 常驻机制的真实形态(2026-10-05 实测定性 + 用户拍板 · ⛔ 别再凭印象设计)** + +**定案形态(两层,⛔ 结束"三层嵌套"):** + +1、**常驻本体** `collabd.py --supervise`:**内部自带循环**(每 10~30 秒一轮),这是干活的那层,✅ 必要。 +2、**计划任务**(`collabd-keepalive-<工作区>` / 看板 `dsh-board-keepalive`): + 动作 = **`pythonw.exe` 起"启动器"**(⛔ **不许直起 `collabd.py`/`board.py`**),每 **5 分钟**触发一次**判活**。 + +🔴🔴🔴 **为什么要夹一个"启动器"(2026-10-05 血的教训 · P0-72+P0-73)**: +**计划任务的"动作"里没有 env 字段** ⇒ 一切环境变量**只能由启动器在进程内设**。两个启动器**对称存在**,缺一个就静默半瘫: + +| 启动器 | 服务的程序 | 进程内必须设 | ⛔ 缺了会怎样 | +|---|---|---|---| +| `scripts/supervise-launch.py` | 协作程序(`collabd.py --supervise`) | **`CODEBUDDY_CONFIG_DIR`** + `COLLABD_CONFIG` + `PYTHONIOENCODING` + 兜 `stdout/stderr` | **检查程序静默失效**(读 0 字节空库 ⇒ `no such table: sessions` ⇒ fail-safe 恒判"有会话"⇒ **再也不建检查会话**) | +| `scripts/board-launch.py` | 看板(`board.py --serve 20099`) | `COLLABD_CONFIG` + `PYTHONIOENCODING` + 兜 `stdout/stderr` | **看板崩溃重启循环**(`已拒跑`)、**20099 从没绑上** | + +🔴🔴 **两条"起法"必须收敛成同一个(P0-74)**:机制里**曾经有第二条起法** —— +`collabd.py` 的 `_escalate_to_keeper()`(自我供给旁路)会建 `collabd-supervise-<区>` 任务, +动作写死 **`powershell.exe -WindowStyle Hidden -File start-supervise.ps1`** +⇒ PowerShell 是**控制台程序** ⇒ 每次触发分配 `conhost.exe` ⇒ **闪黑窗**(用户 2026-10-05 报「又弹了窗口」)。 +✅ 已改指 `pythonw.exe + supervise-launch.py`。⇒ **凡"同一种东西有第二条起法",收口时必须全文搜一遍**: +判据 = `grep -n "New-ScheduledTaskAction"` 与 `grep -n "\\.ps1"`(本次就是靠这个抓到的)。 + +- ⚠️ **变量名坑**:`roots.env` 给的是 **`COLLABD_PROD_CONFIG`**,而 `board.py`/`collabd.py` 读的是 **`COLLABD_CONFIG`** ⇒ 名字对不上 ⇒ `roots.env` **兜不住**。 +- 🔴 **启动器里写死最稳**(⛔ 别指望 `roots.env` 兜住各区 —— 它只在技能目录,而各区副本在 `/.workbuddy/collab/`,`_sm_load_roots()` 向上 4 层**找不到**)。 +- 🔴 **重构"起法"时,旧起法里的 env 必须逐条搬过去**(P0-73 就是改成两层形态时**丢了 `CODEBUDDY_CONFIG_DIR` 这一句**)。 +- 🔴 **各区 config 的 `host_db` 写死绝对路径最稳**(vibe 一直这么写 ⇒ 只有 ai1net 炸)。 + +🔴🔴 **三条硬约束(用户 2026-10-05 逐字重述:「这些常驻程序每次创建之前,要先检查是否已经存在。如果已经存在了,就不需要重复创建,而且各工作区是各工作区的。不共用,共用的只有看板。」)**: + +1、**先查后建(幂等)**:创建前必须判「已经有了吗」;已有 ⇒ **什么也不做**。 + 载体=`--supervise` 进场即判(`supervise_alive()`:`pid` 活 ∧ 心跳新 ∧ `_pid_is_collabd` 身份核验), + 已在 ⇒ 新起的**写心跳前原子抢位失败 ⇒ 立刻让位退出**(实测连触两次,进程数恒为 3,⛔ 不叠加)。 +2、**各工作区各工作区的**:一区一条 `collabd-keepalive-<工作区>`,动作指向**本区副本** `.workbuddy/collab/collabd.py`; + 心跳里的 `argv0` 必须指向本区路径(实测 ai1net/vibe 各自 argv0 互不相同)⇒ **⛔ 不许一区去起另一区**。 +3、**共用的只有看板**:看板任务**全局唯一一条** `dsh-board-keepalive`(端口 20099,动作指向技能目录那份共用 `board.py`)⇒ + ⛔ 不许每区各起一个(每区一个 = 白多 N 个进程 + N 个端口)。 + +🔴🔴 **一句判据(这是本条的**全部价值**)**:**常驻靠什么活着 —— 不在「在不在作业对象里」,在「谁拉起它」。** +- 会话树里起的(父链穿到 `WorkBuddy.exe`)⇒ **会话一收工就死**(表现为「有些区能常驻、有些不能」)。 +- **计划任务起的**(父链**断在自己身上**,父进程已退出)⇒ **真常驻**。 +- ⚠️ **`IsProcessInJob` 在本机恒为真、没有鉴别力**(四组对照全部 `IN-JOB`)⇒ ⛔ 别拿它当判据。 + +**⛔ 两个致命细节(掉一个就"看着配好了、其实没跑"):** + +1、🔴 **`WorkingDirectory` 必须是「工作区根」**,⛔ **不是脚本所在目录**。 + 设成脚本目录 ⇒ 配置查找路径变成 `/.workbuddy/collab/.workbuddy/collab/…` ⇒ **找不到** ⇒ 拒跑。 +2、🔴 **常驻不需要网关口令**(`--supervise` 只写 SQLite,不走网关)⇒ **保活用 `--supervise`**, + ⛔ 用 `--ensure` 会在任务环境里"起了就退"。 + +**⛔ 一条虚警(别误判成故障)**:看板任务的 `LastResult=**1**` 是**正常的** —— +`board.py` 有全局单例守卫("看板全局只允许一个"),已在跑时它打印一行后 `return 0`, +但 **`pythonw` 在任务环境下没有 stdout** ⇒ 退出码被顶成 1。⇒ **判据看「端口在不在听」,⛔ 不看 `LastResult`**。 + +🔴 **弹窗真因(已封死)**:旧形态任务动作是 `powershell.exe -WindowStyle Hidden -File …` —— PowerShell 是**控制台程序**, +`-WindowStyle Hidden` 只是把窗口藏起来,**仍会分配控制台并闪一下** ⇒ 要完全不闪,必须**由 `pythonw.exe` 起**(GUI 子系统,不分配控制台)。 +🔴 **判据**:任何"自动触发"的东西,**只许由 `pythonw.exe` 启动**;⛔ 不许拿 `powershell.exe` / `cmd.exe` 当动作。 +⛔ **绝不允许再出现"每分钟创建一次进程"**(那本身就是设计错误,不是参数没调好)。 + +🔴🔴🔴 **怎么判「两套程序都正常」(2026-10-05 用户点名 · 判据写死)**: +⛔ **"进程活着" ≠ "它在干活"** —— 常驻本体活得好好的,内部某一环读错库、被 fail-safe 兜成"什么都不做",**外表零报错**。 + +| 要验的 | ✅ 看什么(唯一判据) | ⛔ 别只看 | +|---|---|---| +| **协作程序** | 心跳文件新(`logs/supervise-heartbeat.json` 的 `ts` < 90 s)∧ `pid` 活 ∧ **`argv0` 指本区** | 进程在不在、日志在不在走(钩子也写同一日志,**会骗人**) | +| **检查程序** | `logs/_collabd.log` 里**有没有产出预期分支**:`检查会话:目标状态=… ⇒ 不建/已建` 或 `已建检查会话排期 …` | 同上;尤其**别只看"没报错"** | + +🔴 **fail-safe 读法**:`all_sessions_idle` 读库失败 ⇒ 判「有会话在跑」⇒ 不建检查会话(安全但**不可见**) +⇒ **凡出现 `读库失败`/`no such table` 就是真故障**,不是"安静很正常"。 +🔴 **前置闸是合法的**:本区有 `status='working'` 的会话(含**你自己**被排掉后仍有别人的)⇒ **正确地不建** +⇒ 想观察 `检查会话:…` 分支,**得等本区会话全结束**(判据:`_collabd.log` 出现 `all_sessions_idle:还有 N 条 working`)。 + +🔴 **用户可见入口(用户点名要的「一键能关掉」)**: +`会话机制-一键开关.bat`(工作区根 + **桌面各一份**)⇒ 双击选 `1 启动 / 2 全部停止 / 3 看状态`。 +它的"停止"=**一个动作做全**:关开关 + 禁任务 + 杀进程 + 复核(⛔ 不再"禁了任务却没杀进程")。 +→ 章法与事故复盘:`references/pitfalls.md` **P0-70 / P0-71**(P0-71 是载体定案的完整实测)。 + +> 🔴🔴 **2026-10-03 07:2x 口径改(本条为准,排在 10-02 那条之上)——「上报」整套退役 + 看板两处几何改动** +> +> 用户逐字:「**没用了就删除,现在的机制是 协作程序接收和处理队列**」。 +> +> **一、现行机制只剩两条腿(都已实测活着)**: +> · **接收** = `--report` 把任务会话的执行状态写进 `tasks.json`(四态台账,唯一权威)—— `task_report()`。 +> · **处理** = 建**检查会话排期**(`maybe_spawn_check_agent()`,五道闸)让会话去读队列干活 +> —— 常驻 `--supervise` 每 2 轮判一次,是这条腿的**唯一载体**(⛔ 检查会话只能靠排期开)。 +> 🔴🔴 **2026-10-04 用户口径(本节为准)——「检查程序必然要去处理队列」**: +> 逐字:「首先执行程序要把执行结果 的文本地址或引用写入检查程序的队列, +> **检查程序必然要去处理队列的情况**(是等待工作区所有会话停止后,把队列情况一并处理)」 +> ⇒ 三条逐条对: +> | 用户的话 | 机制里的对应 | 状态 | +> |---|---|---| +> | 执行结果要写**地址或引用**入队列 | `--report --state done` 缺 `--artifact` **直接拒收** | ✅ 早已落地 | +> | 检查程序**必然**处理队列 | → 见下面「闸① 收窄」 | ✅ 2026-10-04 修 | +> | **等所有会话停止后**一并处理 | `_all_sessions_idle()`(闸②) | ✅ 早已存在 | +> 🔴 **闸① 收窄(当天修)**:原来 `if life != GOAL_LIFE_RUN: return None` 让 +> 「目标已标完成」成为「**处理队列**」的前置条件 ⇒ 队列非空时**照样不建** ⇒ **活永远没人接**。 +> ✅ 现口径: +> · **`sessions-ended`(队列非空 = 有活没人干)⇒ ⛔ 不受目标状态限制**(活没干完就是没干完); +> · **`queue-empty`(队列空)⇒ 仍须「进行中」**(它的职责正是**判目标该不该收口**,只在进行中才有意义)。 +> 📌 台账实体 ⇒ `references/pitfalls.md` **P0-56**。 +> · ⛔ **没有"推送"这一环**:队列变化**不再自动通知任何人**。要落一件事,**显式建一条任务会话**。 +> +> **二、本轮真删了哪些(⛔ 不是"标成已退役")**: +> · `collabd.py::supervise()` 里的 **①′待反馈序列 + ②③单条握手 + ④唤醒** 四段, +> 以及两个 `_deliver_str()` 调用点 —— **203 行 → 71 行(-132)**。 +> (原文随历史备份一并清理;⛔ 要恢复得按 `collabd.py::supervise()` docstring 里那四条清单重写。) +> · `--tick` 里的 `check_delivery_consumed()` 调用(判据 `st["wake"]["expect"]` 只由 `_deliver_str()` 写 +> ⇒ 投递删后**恒返回 `{}`**)。 +> · 看板「**最近上报 `wakeups.jsonl`**」整张卡(该文件**已不再被写**、实测根本不存在 ⇒ 恒空假面板)。 +> · 看板两处几何改动,**都是用户当面纠正后落地的**(⛔ 不是我第一版那么写的): +> ① 架构图 ④ 那格 —— 我第一版把「上报」**改名成「常驻」**,用户逐字驳回: +> 「**怎么又把上报 改成常驻了 常驻什么,不是 协作程序常驻吗**」。 +> **根因=把「角色名」与「运行形态」混成一个词**:那一格的角色本来就叫**常驻程序**(`collabd.py`), +> "常驻"只是它的一种跑法(`--supervise`)—— 用模糊词替代具体词 = 越改越糊。 +> ✅ **处置=两格合并成一格**(`PX=280, PW=720`;删 `UX`、删随之悬空的 `RKX`、`TICKX` 改算为 `PX+PW/2+120`): +> 标题「**协作程序**」/副标题「**接收:--report 写台账 · 处理:建检查会话排期 · 常驻一直运行**」 +> /右侧小字「⛔ 队列投递已于 2026-10-03 退役(曾叫「上报」)」。 +> ⚠️ 删变量在**代码行**零引用(用自写检查器逐个 `grep` 过 `UX`/`RKX`)。 +> ② 「最近上报 `wakeups.jsonl`」整张卡删除(该文件**已不再被写**、实测根本不存在 ⇒ 恒空假面板)+其渲染块。 +> · 另 4 处用户可见「上报」文本替换:`② 上报 · --report` → 「② 写台账 · --report」(空态/有会话两处); +> `Hook进程` 框内 `--tick(上报)` → 「--tick(补检查排期)」;台账空态文案 → 「用 --report 把状态写进这里(四态…)」; +> 常驻程序格副标题「只维护不上报」→「**只维护不推送**」。 +> · `collabd.config.json`:`wake_enable` **true → false**(第二道闸,第一道是代码里已删那两段)。 +> +> **二之二、🔴 顺带查清的判据:「改看板要不要重启」有四类答案,混成一句话就是误导** +> (用户第二句纠正:「**看板每次修改都不处理看板**」⇒ 我把"改配置要重启"说成了"改看板都要重启",**是分类错误**): +> · 改 `assets/board.html` ⛔ **不用**(`board.py:1293` `serve()` 里 `html_p.read_bytes()` **每请求实时读盘**; +> `/board.json` 带 `html_sig`(`mtime.size`)⇒ 页面自动重载)—— 现跑实证 `tmp/_probe_hotreload.py`: +> 改 `` 插标记 → ⛔ 未重启 → 页面立即含标记(md5 已还原一致 ✔)。 +> · 改 `.workbuddy/collab/board_ext.py` ⛔ **不用**(`EXT_CACHE` 按 `(mtime,size)` 签名**热重载**,`board.py:855-870`)。 +> · 改 `scripts/board.py` 自身 ✅ **必须**(进程里是启动那一刻载入的旧代码)。 +> · 改 `collabd.config.json` ✅ **必须**(`C = _cfg()` 在 `board.py:81` **模块级执行一次**,⛔ 无 re-read 路径)。 +> ⇒ **判据=先答"这个文件是谁在读、什么时候读"**;⛔ 别把"改了 X"与"改看板"当同一件事。 +> ⇒ 全文 ⇒ `references/pitfalls.md` **P0-38**(与 P0-17/P0-33 同族但**方向相反**:那两条讲"该重启却没重启", +> 本条讲"⛔ 不该重启却去重启了" —— **重启不是万能药**)。 +> +> **三、🔴 删除前的实测读数(这才是"为什么该删"的证据)**: +> | 读数 | 值 | +> |---|---| +> | 日志 `follow-retired` 行数 | **1 313 行且每 20 s +1**(每轮试投、每轮失败) | +> | `notify_pending` | `["S8=done","S9=done","S5=done","S12=done"]` —— **4 条全 `done` 堆在队首** | +> | `wakeups.jsonl` | **文件不存在**(投递早已没成功过) | +> | `TO-MAIN.md` | 每轮被覆写成 `S8 -> done`(那份通知永远送不出去) | +> | `NEED-USER.md` | 每轮刷新「投递链路没有收件人」 | +> +> **四、🔴 它是三重死锁,不是一条腿断**: +> ① 投递失败 → `collabd.py`「**只有真投出去才允许消费队列**」⇒ 不消费; +> ② 不消费 ⇒ 队首永驻一条投不出去的死件(`S8=done` 从 23:55 堵到 07:08); +> ③ 队首非空 ⇒ `no_fb` 恒 False ⇒ **唤醒的条件②「队列无待反馈」永不成立** ⇒ 唤醒那条路跟着一起死。 +> +> **五、⛔ 保留的零调用点函数(本体不动,是"将来重建收件人"的实现)**: +> `_deliver_str()` / `follow_for_topic()` / `wake_round()` / `check_delivery_consumed()`。 +> 🔴 **为什么不删**:`selftest.py` 有 4 处**真调用**前两个 ⇒ 删函数体 = `NameError` 崩自测。 +> ⚠️ **判据必须是 AST 而不是字面 grep** —— 退役说明的 docstring 里**必然**提到被删的函数名 +> ⇒ 任何 `grep "_deliver_str(" in <函数体切片>` 都**永久假红**(见 `selftest.py::_calls_in()`)。 +> +> **六、验收(全部现算)**:`py_compile` 4 份全过 | `board.html` 两个 script 块 `new Function()` 全过 +> | **`selftest` PASS 47 / FAIL 1**(回到基线;那个 FAIL 是域锁锚点词表的项目名,⛔ 既有问题) +> | **60 秒观察窗内 `follow-retired` 增量 = 0**(改前每 20 s +1) +> | 常驻新实例 pid 49324、心跳 `round` 递增、心跳 JSON 里 `wake` 键**已消失** ✔ +> | 看板 `--takeover` 后 `/board.json` 的 `meta.deliver_retired == "2026-10-03"` ✔ +> | Edge headless 截图(`tmp/board_merged.png`)实证:④ **一格「协作程序」**+「队列 2 件 · 已完成 2 · 未完 0」 +> +「接收:--report 写台账 · 处理:建检查会话排期 · 常驻一直运行」+ 右侧退役小字; +> `--once`(x=440) 与 `--tick`(x=640) **两条线都落在框内**;**「最近上报」卡已消失** ✔ +> | 两格合一后**再跑一次 `selftest` 终检**= PASS 47 / FAIL 1(与合并前同基线,⛔ 合并没引入新问题) ✔ + +> 🔴🔴 **2026-10-02 23:4x 口径改(本条为准,全文其余「四类」表述按此读)** +> +> **会话类别从四类收敛为两类**:① **主会话** ② **任务会话**。 +> **唤醒会话 / 跟进会话 / 队列上报机制 —— 整套退役**(用户逐字,三句): +> ① 「按照之前的讨论 唤醒会话 跟进会话 和 上报程序 都去掉才对」 +> ② 「上报机制也不需要了」 +> ③ 「按照新的逻辑整体修改」 +> (口径**早就在** `scripts/collabd.py:2293` 写着:「创建检查会话的为 协作程序 +> **(现在不需要上报机制了、之前已经去掉 唤醒会话和跟进会话机制)**」—— 本轮是**把代码与文档对齐到已有口径**。) +> +> **已落地(现算)**: +> · 角色表 `collabd.py::parse_session_name()` 与 `board.py::_role_of_title()` **两处同款**收到 `主/协作` +> ⇒ `[唤醒]`/`[跟进]` **判空**(旧 `sessions` 行不删,但不再是活类别,也**不会被当主会话候选**)。 +> · 主会话候选排除元组**两处**收到 `("worker",)`。 +> · `follow_for_topic()` **短路退役**(恒返回 `why="follow-retired"`、`sid=""`)⇒ +> ⛔ **不投主会话**(主会话只由用户触发),⛔ 不盲投。 +> · `goalctl.py::_WHY` 登记新档 `follow-retired`(「需人看」)⛔ 不降级去抢锁自己干。 +> · 两台周期钟 `[唤醒]-…-脉冲` / `[跟进]-…-队列上报` + 3 条一次性跟进排期 ⇒ `ACTIVE→PAUSED`、 +> **零删除**、`integrity_check=ok`、周期排期未误伤。 +> · **检查会话由常驻程序建**(`maybe_spawn_check_agent()` 五道闸),⛔ 不经唤醒/跟进转手。 +> · `selftest` **PASS 47 / FAIL 1**(那个 FAIL 是域锁锚点词表里的项目名,⛔ 既有问题、⛔ 故意不动)。 +> +> ⚠️ **旧正文里「四类」「跟进会话」「唤醒会话」的大量表述按本块读** ⛔ 不是"没改", +> 是**刻意保留**(那是历史证据与踩坑记录;与本块冲突处以本块为准)。 +> 🔴 **`board_ext.py` 里的「链路前置」已停用**(讲的是 2026-09-30 随概念退役的手机接入线 20090 链路) +> ⇒ `collabd.config.json` 的 `board_ext` 指向 `.RETIRED-20260930.py`。 +> ⚠️ **改了 `collabd.config.json` 必须重启看板才生效** —— `C` 是**模块级加载一次**, +> ⛔ 不每次快照重读(这是 P0-17「改了看不见」漏记的一面)。 + +> 🔴 **一句话**:**会话要先活得下去(钩子 + 锁 + 日志闸),再谈协作(派活靠自动化、收结果直读宿主库、机械判定下沉、人只看一个看板)。** + +> ⚠️ **包自包含**:所有脚本、参考件、资产都在本包内(`scripts/` `references/` `assets/`),⛔ 不再依赖文档库 `07-scripts/`。换机器 = 拷本包 + 跑一次 `install.py`。 + +--- + +## §0 怎么用(先读这一节) + +**① 我要装 / 要换机器** ⇒ 跑 `python install.py --dry-run` 看 diff,再 `python install.py --apply`,最后 `python install.py --verify`。 +(`--apply` 会:写 `roots.env` → 按**声明表**接线全局钩子 → 初始化工作区 → 记 `install.log`。⛔ 不硬编码 python 路径与盘符。) + +**⓪ 我要动手(建判据/写验收/报"已完成"/任何带"现在·已·还差"的结论)** ⇒ **先读 `references/00-动手前必过.md`**(🔴 **动手层**,2026-10-04 建,只有三条动作:① 查状态**问程序自己**,⛔ 不许 glob/手拼路径 ② 引用任何读数**先看它什么时候写的** ③ 写判据**先让基线全绿**再谈抓得住 —— 每条都对应当天真实翻车,且都已落判据不许退化)。 + +**② 我要查会话机制** ⇒ 读 `references/architecture.md`(唯一权威:机制全貌)+ `references/rules.md`(规矩与判据)。 + +**③ 我要查多会话执行** ⇒ 读 `references/collab.md`(执行四条通道 + 派活模板)。 +常驻程序可独立使用:`python scripts/collabd.py --where` / `--tick` / `--report`。 + +**④ 我要复盘某个历史会话** ⇒ 读 `references/forensics.md`,取证脚本 `scripts/forensics/proc-parent.py`。 + +**⑤ 踩过坑 / 要避坑** ⇒ `references/pitfalls.md`;**包内文件清单与来源** ⇒ `references/manifest.md`(逐文件 md5 + provenance)。 +**⑥ 我要让常驻长期在线(⛔ 会话/工具调用起的活活不过当轮)** ⇒ 读 **`references/supervise-persistence.md`**(唯一权威:Windows 计划任务 + 守护循环 · 三条封死路 · 三个秒退坑 · `LastTaskResult` 验收)。 + 🔴 **先分场景再动手**(该文档 §〇 有对照表):**用户手动创建主会话** / **定时任务创建主会话** —— 两者都要**同一个载体**(计划任务 + 永不返回的守护循环);⛔ **排期代替不了载体**(跑完即 `completed`,下一跳之前是空窗);⚠️ `status=ACTIVE` **≠ 在跑**(`once` 排期过期即哑:`next_run_at=None`)。 + +**⑦ 我要判断「这件事该自己定,还是该问用户」/ 要给用户一个「待拍板」清单** ⇒ 读 **`references/02-功能优先协作协议.md`**(2026-10-04 由 `dsh-decision` 搬入本包,**内容守恒**)。 + 它答的是**会话里做事时的自决策口径**:**功能卡 4 问**(用户只填「谁用/在哪用/要做什么/怎样算成功」)· **9 类自决策白名单**(技术选型 / 实现路径 / 命名 / 调参 / 部署 / 排查 / 版本 / 兼容降级 / 文档技术内容 ⇒ **永不问**)· **只准提报用户 3 类**(功能语义分叉 / 红线门禁 / 超出决策边界)· **提报用户格式**(⛔ 不许出现包名 / 路径 / commit / 代码标识符)· **拆包提报用户**(红线问题⛔ 不得与技术方案捆着问)· **答复与交付格式**。 + ✅ **判据实体就在本档正文里**(2026-10-04 改,原文写「权威在 `agent-operating-rules §1.6`」——现已改为**本档即权威**)⇒ 🔴 **只复制这一个技能到别的机器,这些功能全部可用,⛔ 不依赖任何其他技能**。📌 来路:2026-10-04 从 `dsh-decision/references/01` 逐行搬入;⛔ `dsh-decision` 那份视为副本,**改判据以本档为准**。 + 🔴 **配合任务会话的六阶段一起用**(见 `references/collab.md` §5):**需求识别 → 调研 → 规划 → 执行 → 验证 → 归档**——每个阶段都能撞上"该自己定还是该问用户",本档就是那一刻的判据。 + +--- + +## 🔴🔴 T 表 · 状态 → 我该做什么(2026-10-04 用户口述,⛔ **唯一权威,不许 AI 推断**) + +> 📌 **为什么有这张表**:技能原先只有「你要干什么 → 去看哪篇」(知识组织), +> **没有「现在什么状态 → 下一步做什么」(行动组织)** ⇒ AI 查到一条知识 +> 就以为「知道该做什么了」⇒ **2026-10-04 一天连栽三次**(凭空造出「开机自启」 +> 「三类会话」「两类会话」三个需求,全是 AI 自己推断的,⛔ 用户从没要求过)。 +> ⇒ 这张表是**堵这个洞**的。⛔ **不在表里的状态 ⇒ 问用户,⛔ 不许自己推断。** + +### 一、执行会话的**生命周期**(用户原话逐字) + +> 「**使用执行会话完成目标的时候 主会话 才建立一轮执行会话, +> 后续无意外都是 检查进程创建**」 + +``` +主会话收到「用执行会话完成 X」 + │ + ├─ ① **建一轮任务会话** ← 🔴 只有这一步是「主会话建执行会话」 + │ └─ 它们干活 → 完成后**向检查程序队列投递执行情况**(`--report`) + │ + ├─ ② **后续无意外 ⇒ 只建检查会话** ← 🔴 检查会话**由常驻建**(`maybe_spawn_check_agent()`) + │ └─ ⛔ **主会话到这一步就收手**,⛔ 不再自己建任务会话 + │ + └─ ③ **有意外**(链条断了 / 推不动 / 检查会话判不过) + └─ 才需要再建任务会话去接 —— ⛔ 建不建、建几条 ⇒ **问用户** +``` + +### 🔴 1.5 前置条件(2026-10-04 用户补充,⛔ **顺序不能颠倒**) + +> 用户原话:「**不只是创建执行会话,如果主会话下没有 后台任务 或 检查程序 +> 还需要启动常驻程序**」 + +⇒ **三层是串起来的**,⛔ 不许跳过中间那层直接建会话: + +``` +第 0 层:常驻(collabd.py --supervise) ← ⛔ 一切的前提 + └─ 它是「建检查会话」那条腿的**唯一载体**(`maybe_spawn_check_agent()`,每 2 轮判一次) + └─ 🔴 实测依据:`collabd.py` 文首「处理 = 建检查会话排期 …… 由常驻 --supervise + 每 2 轮判一次,是这条腿的**唯一载体**」⇒ **没有常驻 ⇒ 检查会话永远建不出来** +第 1 层:检查会话([结果检查]/[目标检查]) ← 常驻建的,⛔ 你自己建不了 +第 2 层:任务会话([协作]-…) ← ⛔ **只有主会话在第①步建一轮**,之后不再建 +``` + +🔴 **因此开工第 0 步的真顺序是**: + +| 步 | 查什么(**现取,⛔ 别看快照**) | 不在位时 | +|---|---|---| +| **0-a** | **常驻在不在**:`supervise_alive()` = `pid 活 ∧ 心跳新鲜(<90 s)` | 🔴 **先起常驻**,⛔ 不许直接建检查会话 | +| **0-b** | **有没有目标** + 生命周期 | 🔴 **没有目标 ⇒ 用户在用「基础会话方式」,⛔ 那不是故障、⛔ 不必修**; +| | | 有目标但 `lifecycle` =「等待」⇒ **`queue-empty` 那条不动**(闸①,⛔ 别替他改); +| | | 🔴 `lifecycle` =「已完成」(**含带后缀**形态)⇒ **先看台账**:还有未完成件 ⇒ **照建 `[结果检查]`**;全 `done`/空 ⇒ **收工** | +| **0-c** | 五道闸其余(会话全结束/队列对得上 reason/无待执行排期/同名未在册) | ⛔ 缺一即静默不动 | + +🔴🔴 **0-b 的读数必须看"标准值",⛔ 不看原文**(10-04 实测栽到,`vibe-product`): +`goal.json` 里可能写着 **带后缀**的 `已完成(机器可判部分)`,而 `goal_life()` 只做精确匹配 +⇒ **静默回落成「等待」** ⇒ 「已完成」被读成「等待」(**语义相反**,排查被直接带偏)。 +✅ 已修:`goal_life()` **剥掉全角/半角括号后缀再判**,且**认不出时写日志**(⛔ 不静默回落)。 +⇒ 📌 判 0-b 一律**问代码**(`python collabd.py --check-status` 或 `goal_life()`), +⛔ **不要自己读 `goal.json` 的原文再肉眼比对** —— 那正是被带偏的入口。 + +📌 **「没有目标」不是缺口,是「用户用基础会话方式」(用户 2026-10-04 口径)**: +⛔ **别把「没启用机制」说成「没配好」**。 +代码同向:`goal_life()` 读不到 `goal.json` ⇒ 判「等待」,注释原话 +「**默认必须是「等待」而不是「进行中」**(只有进行中才建检查会话)⇒ 默认给进行中 ⇒ +用户还没开口机制就开始自动建会话 ⇒ **越权**。**宁可等,不可动**」。 +⇒ 🔴 **不设目标就不会有检查会话,这是设计**;⛔ 想让它有 ⇒ **用户开目标**,⛔ **不许我代设**。 +⚠️ 另:`acceptance_state` 记着 **pid 8024 / 10-02 13:44**(两天前快照), +实际是 **pid 19424 / 心跳 4 s 前** ⇒ ⛔ 判据只用 `started_ts` 与 `ts`,⛔ 别读那个字段。 + +📌 **一句话**:`⛔ 顺序是「先常驻 → 再检查会话 → 才是主会话那一轮执行会话」; +⛔ **跳过常驻直接建会话 = 建了也不会被接上**(检查会话只能靠排期开,而排期只有常驻会建)。 + +### 🔴 1.6 起不来 = **先报错、先解决,再执行目标**(10-04 用户原话) + +> 用户原话:「**加个规则 后台任务和检查程序如果不能启动,就先报错解决了问题 +> 在执行完成目标的任务**」「**必须首先启动好在执行**」 + +⇒ 这是**顺序红线**,⛔ 不是"尽量起一下": + +``` +判断「常驻/检查程序在不在位」 + │ + ├─ 在位 ────────────────────────────▶ 才允许往下建会话、派活 + │ + └─ 不在位 ──▶ 🔴 **先起** + │ + ├─ 起成功(pid 活 ∧ 心跳新鲜)──▶ 才允许往下 + │ + └─ ⛔ **起不来 ⇒ 立刻停下、如实报错** + ⛔ 不许"边起边干"(建了会话也没人接) + ⛔ 不许只说一句"起好了"就往下走 + ⛔ 不许编原因(只报**读到的现象**,⛔ 不猜病因) + 🔴 **先解决问题,再执行完成目标的任务** +``` + +⚖️ **三条边界(越界就是又一次"凭空造需求")**: +1. ⛔ **没目标 ⇒ 不起、也不报**(那是用户在用**基础会话方式**,正常态) +2. ⛔ **只管「常驻在不在位」这一件事** —— ⛔ 不代用户开目标、⛔ 不自己建检查会话 +3. 🔴 **在位判据只有一条**:`pid 活 ∧ 心跳新鲜(<90 s)`,⛔ 别看 `supervise.pid` 文件本身 + +📌 **谁来执行这条**:**不是靠我记得**,是 `scripts/hooks/supervise-ensure-hook.py` +(挂 `UserPromptSubmit`)—— 它每轮自动问 `supervise_alive()`,不在位就起, +**起不来就注入一条报错**让本轮对话看见。 +⚠️ 🔴 **2026-10-04 实测:这个钩子曾长期空转**(只读 `DSH_WS_ROOT`, +而宿主 env 三级全空)⇒ 详见 `references/pitfalls.md`。 + + +### 二、**「这轮结束了」的判据**(用户原话:靠投递,不靠我判断) + +| 判据 | 怎么查 | +|---|---| +| 任务会话**做完了** | 台账出现它那条的 `state=done` | +| ⛔ **`done` 必须带 `--artifact`** | `collabd.py --report` 拒收没产物的 `done`(10-03 用户逐字要求)⇒ **`done` 存在 = 产物已登记** | +| 🔴 **`done` 的产物要真读得到** | `artifact_state()`(**唯一事实源**)判 `ok`/`missing`/`gone` ⇒ **读不到也拒收**(10-04 加,见 `pitfalls.md` P0-58) | +| 卡住了 | `state=blocked` **必须**带 `--reason`(⛔ 不许只标「卡了」不说卡在哪、谁在等) | +| **这轮结束** | 台账该轮全部 `done` ⇒ ⛔ 此时**零活任务会话是正常的**,⛔ **不许报「缺执行会话」** | + +### 二·补 🔴 **文档合同 —— 「谁写哪份文档」**(用户 2026-10-04 口径逐字) + +> 「之前还说过 **执行会话的结果要形成文档,目标也要完成情况的文档**, +> 这样后续检查会话和后续执行会话都可根据文档继续处理,**避免全工作区到处找信息**」 + +🔴 **为什么必须有这份合同**(实测**三区三种形态** ⇒ 缺的**不是能力,是合同**): +某区只有 `目标执行状态.md`(420 B,**执行产物没落这儿**)/某区有**手工**写的「过程记录」(⛔ 非机制要求)/ +本区 `S12_*.md` 落对了 —— **同一套机制、三个区三种形态**。 + +| 文档 | 谁写 | 什么时候写 | 谁读 | +|---|---|---|---| +| `<目标目录>/目标执行状态.md` | **目标检查会话** | 核对完目标状态后 | 下一个检查会话/用户 | +| `<目标目录>/<棒次>_<事项>_<日期>.md` | **任务会话(协作棒)自己** | 本轮做完时(=`--report done` **之前**) | 检查会话照 `artifact` 读 | +| `tasks.json` | `--report`(机器) | 每次状态转移 | 全部环节 | + +🔴 **落点是死的**:执行产物的 `--artifact` **必须**指进 `<目标目录>/` 里 +(⛔ 不许写回 `交付物/`、`docs/`、工作区根 —— **那正是「到处找」的由来**)。 +⚠️ **落点=软判据**(`artifact_dir_ok()` 只**提示**):存量里有落在别处但有效的产物, +硬拒会误杀既有工作流;但**必须说出来 + 写日志**(⛔ 静默通过=合同等于没立)。 +🔴 **存在性=硬闸**(`artifact_state()`):**读不到就拒收**(两层判据 ⛔ 别混)。 + +📌 **`artifact_state()` 是唯一事实源** —— ⛔ 不许在 prompt/看板/CLI 里各写一段 `if not artifact`。 + +📌 **台账是唯一权威**:`tmp/supervise-inbox/tasks.json`(四态 `pending/running/blocked/done`)。 +⛔ 判「有没有在做的事」一律现取它,⛔ 别读看板/文档里的快照(`acceptance_state` 是快照, +⚠️ 实测它记着 10-02 的 `pid 8024`,而 10-04 实际是 `pid 19424`)。 + +### 三、检查会话**不能固定席位**(用户原话) + +> 「**没办法固定要是有好了,为什么不行有记录吗**」 + +⇒ 🔴 **不许预先摆一条「固定检查会话」**。理由(有记录就不怕丢): +**记录在台账里** ⇒ 检查会话是**可丢弃的** ⇒ 有好了就换一条,⛔ 不必留一个常设席位。 +⇒ ⛔ **反过来的推论**:台账空 = **没有在做的事** ⇒ ⛔ **不许**因此去建任务会话。 + +### 四、状态 → 动作(**这张表就是「什么时候该做什么」的答案**) + +| 当前状态(**现取台账**) | 我该做的 | ⛔ 不许做的 | +|---|---|---| +| 台账有 `pending` | 取那条执行;没意外就交给检查会话 | ⛔ 另起一批任务会话 | +| 台账有 `running` | 让它跑;检查会话去核 | ⛔ 催它、⛔ 重建 | +| 台账全 `done` | **这轮结束** ⇒ 报「已完成 + 剩什么」 | ⛔ **不许**编「欠项」、⛔ 不许找活干 | +| 台账空 | 报「**没有在做的事**」 | ⛔ **不许**建任务会话、⛔ 不许造需求 | +| **目标 `lifecycle=已完成`**(含 **带后缀**形态,如 `已完成(机器可判部分)`) | 🔴 **先看台账,⛔ 不是一律收工**:<br>· 台账**还有未完成件**(`pending`/`running`/`blocked`)⇒ **判定=还有活没人干** ⇒ **照常建 `[结果检查]`**(闸① **不**拦它)<br>· 台账**全 `done`/空** ⇒ **判定=收工** ⇒ 报「做完了 + 还差什么人工确认」 | ⛔ **不许**把「已完成」说成「等待/没点头」(**语义相反**);⛔ 不许自行续活 | +| **没有目标**(`goal.json` 无 / `lifecycle=等待`) | **判定=用户在用「基础会话方式」** ⇒ 正常,⛔ 什么都不用起 | ⛔ **不许**说成「缺目标/缺配置」、⛔ **不许**代他设目标、⛔ 不许起检查会话 | +| 有 `blocked` | 报「卡在 X,等谁」⇒ **问用户** | ⛔ 不许自己替用户决定绕过去 | +| 用户明确说「用执行会话完成 X」 | 才走上面第 ① 步建一轮 | ⛔ 不许替用户决定开不开 | + +### 五、⛔ 三条由此推出来的红线(2026-10-04 当天栽出来的) + +1. ⛔ **凡不在上表的状态 ⇒ 问用户,⛔ 不许推断后当成需求或欠项。** +2. ⛔ **「实体里存在」⛔ 不等于「该有」** —— 台账里 8 条任务会话不在跑 = **上一轮的遗留**, + ⛔ **不是**「缺 8 条」。⚠️ 实测:2026-10-04 我正是把「遗留」读成「欠项」, + 而那 8 条对应的轮次早已完成。 +3. ⛔ **⛔ 不许把自己以为该有的东西摊成「欠项」/「风险」/「待办」** —— + 那是**凭空造需求**(当天三次:开机自启/三类会话/两类会话)。 + + +### 六、🔴🔴 **完成情况判据 · 唯一事实源**(2026-10-04 用户定案) + +> 用户原话:「**是不是应该统一完成情况的 状态标准,不要换个工作区换个目标,就统计不准确**」 + +**病根**:同一条「这条验收算不算过」的判据,曾在**三处各写一遍**,于是必然漂移: + +1. `scripts/board.py` → `acc_is_pass()`(**唯一实现,改判据只改这里**) +2. `scripts/collabd.py` → `_acc_is_pass()`(**只转发 board,⛔ 不许自己写词表**) +3. `assets/board.html` → `accIsPass()`(前端跑不了 Python ⇒ **抄同一份白名单**) + +**白名单(全库唯一)**:`pass` / `过` / `通过` / `达` / `达标` / `合格` / `完成` +(Python 侧常量 `ACC_PASS_WORDS`;JS 侧常量 `ACC_PASS_WORDS` —— 两处**必须逐字相同**。) + +**算法(三处同款)**:① 掐掉括注(`(`/`(` 起)② 按 `:`/`:` 切段 ③ 逐段 `startswith` 白名单词 +④ 一段都没命中 ⇒ `False`(**fail-closed**)。 +⚠️ ⛔ **不许「只取最后一段」** —— 那是 collabd 的一版漂移(`过:1440` 与 `过:390` 会判相反)。 + +**分母口径(三处同款)**:`_` 开头的键是**说明行**(`_说明`/`_更新`…), +⛔ **不许进分母** ⇒ 分母 = **有效判据条数**(否则通过率永远到不了 100%)。 + +**新增判定词的正确姿势**:改 `board.py::ACC_PASS_WORDS` **一处** ⇒ 同步 `board.html` 同名字符串 +⇒ 跑 `selftest.py`(`t_acc_is_pass_chinese` 已含 `达/达标/合格/完成` 与反例 `未达/待达标`) +⇒ 跑 `sync` 到各工作区副本 ⇒ **看板要重启**才吃到 `board.py`(`board.html` 实时读盘、不用重启)。 + +**为什么当初没抓到**:自检用例**只喂了 11 种写法、⛔ 没有 `达`**,而真源恰恰主用 `达` +⇒ **用例盲区 = 判据盲区**(同族:`pitfalls.md` P0-13/P0-20「判据写死期望值 ⇒ 永远不命中真源」)。 + + +## §1 第一段 · 会话机制 + +> 这一段**不依赖**多会话协作;只把「会话活得下去」这套装上。 + +**三件套** +1. **钩子**(`settings.json` 全局生效,`scripts/hooks/` 6 份):日志闸(`session-log-guard.py`)/锁闸(`lock-guard-hook.py`)/输出闸(`bash-output-guard.py`)/叫停闸(`stop-dialog-guard.py`)/技能闸(`skill-load-guard.py`)/结果回报(`wb-result-hook.py`)。 +2. **锁**(`scripts/lock/`): + - 开工三步=`scripts/dsh.py open`(本项目入口,内部跑 ①状态 ②preflight ③抢锁); + - 抢锁 `handoff-guard.sh --claim-exec "<会话名>" [--domains <域>]` —— **⛔ 机制层必须独占(不带 `--domains`)**;抢不到 ⇒ **停手 + 报告**(红线 R9:⛔ 不删锁、不接管)。 + - 释放必须反序:`--release` → `--release-exec "<会话名>"`;⛔ 不带名 ⇒ 拒释放。 +3. **日志闸**:文件软 **5** / 硬 **8 MiB**;工具调用软 **200** / 硬 **250**。命中 ⇒ 开接续会话。 + +**🔴🔴 执行会话「独立域」硬规则**(用户 2026-10-02 定案,逐字) +> 「创建协作会话还要加个判断:协作会话必须是独立域运行的,就是做所有修改操作都在单独的文件下运行 +> (比如某个项目要开发 webserver,desktop,phone app,独立插件或产品原型)这些文件都可以放在工作区对应 +> 独立文件夹下,**只能只读的方式访问别的文件夹内容**。应为会话有锁的机制,开多个会话都操作一个域的文件 +> 只有一个会话能执行,别的只能干等。」 + +- **域目录=工作区下的第一层目录**(`<工作区>/webserver/`、`<工作区>/desktop/`…)。 + 🔴⛔ **绝不许套公共父目录**(`domains/xxx`、`projects/xxx` 那种)—— 实测那样所有子目录会算出 + **同一个域键** ⇒ 域锁等于没有、并行直接失效(本机制实测踩过并已改正)。 +- **域键算法只认第一层**:域键=`<工作区名>/<第一层目录名>`,与再往下钻几层**无关**。 + ⇒ 想让两个会话真并行,就给它们**两个不同的第一层目录**;⛔ 在同一目录里再分层**不能**解锁并行。 +- **开工第 0 步必须先抢域锁**:`handoff-guard.sh --claim-exec "<会话名>" --domains "<域目录>"`;抢不到 ⇒ **停手报告**,⛔ 不许硬写。 +- **写只许在域目录内,跨目录一律只读**;必须写到外面时 ⇒ **不写**,在 `tmp/supervise-inbox/NEED-USER.md` 写明要谁批准。 +- 三条命令(`collabd.py`): + ```bash + python "<包>/scripts/collabd.py" --domain-status # 域现状体检(在册域锁 + 锚点词表一致性) + python "<包>/scripts/collabd.py" --domain-suggest "<类别>" # 推荐一个**当前空闲**的域名 + python "<包>/scripts/collabd.py" --domain-check "<域名>" # 判这个域能不能派(⛔ 被占时 rc=1) + python "<包>/scripts/collabd.py" --domain-block "<域名>" # 打出派活要嵌的门禁块,⛔ 别手抄 + ``` +- **门禁已自动嵌进派活**:任务会话 prompt(`GAP_PROMPT["worker"]`)与检查会话 prompt(`CHECK_PROMPT`)都带上了这段, + ⛔ 跟进/唤醒**不带**(它们只读+建排期,给域目录是反向约束)。 + +**关键判据(踩过才写在这)** +- 🔴🔴 **用这套技能的第一件事=查环境配置,配没配决定后面全部动作**(用户 2026-10-02 定案:「重点是**技能的使用时要检查环境配置是否已配置,如果没有配置就要先配置**」)。 + ```bash + # 体检(只读,⛔ 无副作用)—— 判技能库根能否定位 + 已注册钩子逐条目标是否存在 + python "<包>/scripts/hooks/_env.py" --ws "<工作区绝对路径>" + # 写环境标记(状态变量+时间),scope 决定落在哪个文件夹 + python "<包>/scripts/hooks/_env.py" --scope global --stamp --fixed "<修了什么>" + python "<包>/scripts/hooks/_env.py" --ws "<WS>" --scope workspace --stamp + ``` + - **作用域要问用户**(用户 2026-10-02 原话:「可以询问是配置在**全局**还是**本工作区**」)—— + 🔴 **默认问、别默认写**:**全局**=一次配好所有工作区共用(改它影响**所有**工作区 ⇒ 属影响面变更); + **工作区**=只管本工作区(换机器/别的项目要各自重配)。 + 判据:影响面超出本工作区 ⇒ **必须问**;纯本工作区 ⇒ 自决策并**一句话说明**。 + - 标记落点:全局 ⇒ `<配置目录>/env-stamp.json`;工作区 ⇒ `<工作区>/.workbuddy/env-stamp.json`。 + 字段=`scope`/`checked_at`(ISO 到秒)|`ok`|`config_dir`|`skills_root`|`hooks`(逐条存在性)|`fixed`。 + - 🔴 **定位不到技能库根 ⇒ 报错 + 非零退出,⛔ 绝不「静默零输出」**(2026-10-02 实测事故的病根): + `reply-style-guard.py` 被注册成文档库里的旧副本 ⇒ 沿 `__file__` 上溯够不到 `skills/` + ⇒ 落进 `os.path.expanduser('~/.workbuddy/skills')`,而 **Windows 上 `~` 不是真配置目录**(真值在 + `CODEBUDDY_CONFIG_DIR`)⇒ **静默零输出**,日志只留一行 `core=0 字符` + ⇒ **「机制坏了」与「没配规则」表现完全一样**(真实代价:另一工作区为此绕了两轮,在「规则文件在不在」上打转)。 + ⛔ `~` 回落已从 `_skills_root()` 删除;**每一档 env 都要验目录真存在**(env 给错要继续往下找,⛔ 不猜)。 + - 工作区 `state.py` 已有 `§5c [钩子环境]` 一项跑它 ⇒ **开工跑状态就能看见**,⛔ 不必另记命令。 +- 🔴 **各脚本的根目录一律先读包内 `roots.env`,再回落按位置推导**(已贯通 **16 份**:9 钩子/锁 + 7 非钩子脚本)—— 因为 `settings.json` 的 hook 条目**没有 `env` 字段**,包内脚本**搬一次就会静默指错**(历史事故:台账写到别处、测试却全绿;近期又复现一次:`wb-result-hook.py` 按 `__file__` 推三层 ⇒ **把技能包当工作区**,在包里长出 `tmp/supervise-inbox/`)。**⛔ 不要靠 `mv` 搬迁,⛔ 不要靠"改壳转发"**(转发会改 `$0`,同样挪走根)。**⛔ 代码里不留盘符字面量**:外部根只能来自 `roots.env`/宿主 env。**🔴 2026-10-02 起再收一层**:「配置目录/技能库根」的取法统一走 `scripts/hooks/_env.py`(7 个钩子已改),⛔ 各脚本不再自己拼 `~`。 +- 🔴 **出口一律声明编码**:钩子脚本写 stdout 走 `sys.stdout.buffer.write(bytes)`(文案含 `⛔` ⇒ 否则 `UnicodeEncodeError` ⇒ stdout 空 ⇒ **静默放行**);**非钩子脚本**(`collabd` / `board` 等,会被重定向到文件或 `DEVNULL`)在文件头加**输出编码兜底**(`sys.stdout/stderr.reconfigure(encoding="utf-8", errors="replace")`)—— 实测:重定向 + 本地 GBK ⇒ `print("⛔…")` 抛异常 ⇒ 被顶层 handler 记成 `fatal`、**整轮失败**(常驻必踩)。 +- ⚠️ **本机**:裸 `bash` 可能落到 WSL 启动器 ⇒ 要跑 shell 一律显式用 `PortableGit/.../usr/bin/bash.exe`。 +- 🔴🔴 **钩子总预算(2026-10-02 事故)**:**钩子超时 ≠ 钩子变慢,而是用户这一句话被拦下**(`UserPromptSubmit operation blocked by hook: Hook timed out after 20000ms` ⇒ 提交失败,不是慢)。根因=**`UserPromptSubmit`(宿主注册 20 s)上一个脚本里串了三个子进程**:`collabd --gap` **13.5 s** + `--once` 0.5 s + `--tick` **13.5 s** ≈ **27.5 s** ⇒ 必超。四条硬规则(已落进 `wb-result-hook.py`): + 1. **开局认领预算**:`BUDGET = {UserPromptSubmit: 18, PreToolUse: 25, SessionEnd: 8}`(各比注册值少 2 s)⇒ 任何**要等**的子进程,先问 `_left()` 够不够,不够 ⇒ 跳过并留痕。 + 2. **门槛按"实测耗时"给,⛔ 不按硬超时给**:`sweep`/`supervisor` 实测都是 **0.95 s**,若按硬超时 8 s/6 s 设门槛,在 8 s 预算下**永远跑不到**(判据看着在、其实恒假)。 + 3. **不需要结果的活 ⇒ 后台**(`_bg()`:`Popen` + `DETACHED_PROCESS|NEW_PROCESS_GROUP` + stdout **落文件**,⛔ 不用 PIPE):实测**父进程退出后仍能跑完**(15 s 写完 11 KB)。⛔ `CREATE_BREAKAWAY_FROM_JOB` 在本机**必失败**(`PermissionError 13`)⇒ 别加。 + 4. **一轮只允许一个贵活**(`_HEAVY_DONE`),其余后台;**慢的产物落缓存**(`gap-cache.json`,后台写 `.tmp` → 下轮 `os.replace` 收割,钩子只读缓存=毫秒级)⇒ 实测钩子 **27 s → 0.5~1.9 s**。 + ⚠️ 附带发现:`SessionEnd` 注册只有 **10 s**,而它上面挂着 `--tick`(13.5 s)+`--once`(25 s 硬超时) ⇒ 那条**从来就没跑完过**(被掐)⇒ 别拿它的缺失当"机制没装好"。 + +- 🔴🔴 **后台子进程一律无窗口(pythonw)**:`collabd.py --supervise`(投递常驻,每 10 s 一轮)+ 看门狗 `guard.py`(每 15 s 探活、子进程一死就重生)原本以 `python.exe`(**控制台子系统**)起来,被宿主/钩子/看门狗拉起时 Windows **新分配一个控制台窗口** ⇒ 每次重生 / 每轮 `netstat` 就闪一下黑窗(用户原话「一会弹出来一会弹出来的,影响我操作」)。🔴 **根因**:`guard.py Child.ensure()` 拉起子进程 `creationflags=0x00000008`(**只有 DETACHED_PROCESS,漏 CREATE_NO_WINDOW**);`wake-session.py` 的 `netstat` **没有任何 creationflags**。`collabd.py` 的 `netstat` 已于 09-29 修(带 `0x08000000`)。✅ **根治(2026-10-02 落地)**:所有常驻/后台 `subprocess` spawn 一律改用同目录 `pythonw.exe`(**GUI 子系统,Windows 永不为其分配控制台**)+ `creationflags` 补 `NO_WINDOW|DETACHED|NEW_PROCESS_GROUP`:① 三个脚本加 `_win_pythonw()` 解析器(模块级 `PYW`);② `collabd.py ensure_supervise` 与 `guard.py Child.ensure` 的 `sys.executable`→`PYW`;③ `wb-result-hook.py` 5 处 spawn(含 `_bg`/sweep/once/tick)`sys.executable`→`PYW`;④ `wake-session.py netstat` 补 `0x08000000`。⛔ **今后任何新加的常驻/后台 spawn 都走 `PYW` + NO_WINDOW**,⛔ 别再用 `sys.executable` 起会长期存活的子进程(备份 `*.bak-flicker-20261002.py` ×4)。 + +- 🔴🔴🔴 **R 红线:⛔ 严禁用「排期/自动任务」当常驻的载体或替身**(2026-10-04 用户定案,当场立) + + **一句话**:「**常驻要死**」⛔ **不是**建一条排期去续它的命。 + ✅ **正确处置=按需重起**(`collabd.py --supervise` + `pythonw` + `NO_WINDOW`)。 + 📌 **口径(2026-10-04 用户订正)**:**常态=调用技能完成目标时起后台任务 + 检查程序**; + 「开机自启/计划任务」只是**想做"目标做完程序还继续跑"时**的可选做法,⛔ **不是需求、不是欠项**。 + + 🔴 **实测的反面案例**(`vibe-product`,`id=e181b51b`):`[执行]-界面交互-常驻续命`, + `FREQ=HOURLY;INTERVAL=1` —— prompt 明写「不在 ⇒ **后台任务**起一条 `collabd.py --supervise`」。 + **15:23 真跑过一次**,会话结论是「**✅ 常驻存活,本轮未做续命**」 + ⇒ **每小时唤醒一个新会话,只为看一眼常驻活没活**;而常驻本来就活(pid 19424 连跑 17.7 h)。 + ⇒ **零产出、纯烧钱,且违背本技能自己写的 `supervise-persistence.md`「⛔ 排期代替不了载体」**。 + + ⛔ **四条禁止**(逐条都有实测/文档依据): + 1. ⛔ **不许建 `recurring` 排期去「续命/保活/巡检常驻」** —— + ✅ 要续命就**按需重起**(`pythonw` + `NO_WINDOW`,见 `references/00-动手前必过.md`)。 + ⚠️ 需不需要"目标做完还继续跑" ⇒ **问用户**,⛔ 别自己假设要。 + 2. ⛔ **不许把排期当"谁来按点喊一次"的常驻替身** —— 排期喊完会话就结束 ⇒ **跳与跳之间必有空窗**,静默窗内无人。 + 3. ⛔ **不许在排期 prompt 里写「不在就起一条 `--supervise`」** —— 那是把「换载体」偷换成「每次重拉」,且起于**会话/工具调用**的子进程**活不过当轮**。 + 4. ⛔ **常驻真死时不许用排期兜底**,先查三样:停止标志 `guard.stop`(在=正常收工,⛔ 不是故障)| + 心跳年龄(`supervise-heartbeat.json` 的 `ts`)|pid 还在不在(`_pid_alive`)。 + + ✅ **常驻真死时的正确四条**: + ① 先看**停止标志** `guard.stop`(在则是「被正常收工」,⛔ 不是故障); + ② 看**心跳年龄**(`ts` 距今 > `SUPERVISE_STALE` 即已陈旧,⛔ 别看退役旧戳); + ③ 宿主钩子按需**一次性**唤起 `collabd.py --tick`(**补充**,⛔ 不是「常驻」); + ④ 需要长期跑 ⇒ 问用户要不要登记**计划任务**(`references/supervise-persistence.md`,⛔ **不默认要**)。 + + 📌 **判据**:`selftest.py` 的 `t_no_schedule_as_supervisor`(⛔ 扫技能文档里"排期当常驻载体"的表述 + 扫脚本里"排期里起 --supervise"的写法),改动前后都报红即通过。 + 📌 **用户 2026-10-01 已定**:「**协作与投递一直运行(常驻)**」+**「自动任务当闹钟」方案已废弃** —— 本条只是把它**升格为红线 + 落判据**。 + +- 🔴🔴🔴 **S 红线:⛔ 严禁把「被监控对象所在的工作区」当成「目标归属的工作区」**(2026-10-04 用户当场纠正) + + **一句话**:**目标在哪个工作区下达,执行它的会话就属于那个工作区** —— 被它操作/观察的**别的**区只是**对象**,⛔ 不是归属。 + + 🔴 **实测的反面案例**:用户在本工作区(`ai1net-dsh-server`)下达 + 「使用执行会话完成目标:持续监控 **vibe-product** 工作区主会话使用会话技能的情况」, + 我把排期 `cwds` 写成 `E:/ProgramData/AIProject/vibe-product` + ⇒ 会话 `c88a157c` 落在 `vibe-product` ⇒ **本工作区看板/台账里直接看不见它**, + 而本工作区恰恰是机制问题最集中、最需要它的地方。 + ⇒ 用户原话:「**本工作区下面的目标 为什么执行会话要创建到 vibe-product 工作区下面**」。 + + ⛔ **三条禁止**: + 1. ⛔ **不许把「要去看/要改的那个区」写成 `cwds`** —— 那是**对象**,不是**归属**。 + 2. ⛔ **不许用「目标文本里出现了某区名」来定 `cwds`** —— 出现的是**被操作对象**, + 归属看的是「**这条命令从哪个工作区发出**」(`sessions.cwd` 逐字同形,正斜杠)。 + 3. ⛔ **交付物、台账、修复动作**一律落在**归属区**,⛔ 不许顺手落到对象区。 + + ✅ **正确做法**:目标跨区时,`cwds`=**下达目标的那个工作区**;prompt 里显式写 + 「⛔ `<对象区>` 是被监控对象,**不是**你的工作区 —— 别去那儿建会话、别改那儿的东西」; + 同步技能改动用 `scripts/workspace_mirror.py --sync <对象区副本>`(⛔ 只读对象区 + 写**全局**技能)。 + + ⚠️ **唯一例外**(需用户在**当轮**明确要求):用户说「在 X 工作区建」⇒ 才建在 X。 + ⚠️ 机械背景:`automations.cwds` ⛔ **不落** `sessions.cwd`(后台会话可带独立 cwd)⇒ 判断归属看 `cwds` 字面 + 会话 `cwd`。 + + 📌 **判据**:`selftest.py` 的 `t_ws_attribution`(扫技能文档里「对象区 / 归属区」的口径是否在位)。 + + +--- + +## §2 第二段 · 多会话执行 + +> 这一段**可单独使用**(不装钩子与锁也能跑常驻程序本体)。 + +**四条通道各走各的** +· **派活** ⇒ 自动化(**唯一能开新会话的通道**;⛔ 钩子做不到)。 +· **收结果** ⇒ 直读宿主库(0 token)。 +· **机械判定** ⇒ 下沉到本地只读程序(`collabd.py` / `board.py` / `goalctl.py`)。 +· **人看的** ⇒ 只有一个看板(`assets/board.html` + `board_ext.py`)。 + +**铁律(全部实测得来)** +- ⚠️ **【2026-10-03 口径已改 · 本条「四类」部分作废】** 现行=**两类**(① 主会话 ② 任务会话;唤醒会话/跟进会话/队列上报**整套退役**)⇒ 以**文首口径块**为准,⛔ **别照本条去建会话**。(原文保留仅为留痕,⛔ 不删。) +- 🔴 **同工作区 = 四类会话**(2026-10-01 用户口径,⛔ **已于 2026-10-03 作废**):**① 主会话(只管目标和方向)② 执行会话 ③ 唤醒会话 ④ 队列上报的跟进会话**。四类**同处一个目录**,**靠标题两级前缀区分**(第 1 级=角色/第 2 级=任务类别),⛔ **不按 `cwd`**。✅ 第 ④ 类**解析层 + 投递路由都已落地** —— 投递目标=**跟进会话**(`follow_for_topic()`),解析不出 ⇒ **喊用户**,⛔ **不降级投主会话**(用户:「**换新会话 唤醒的是 跟进会话,主会话只能是用户触发**」)。🔴🔴 **第④类的职责只有一件事:创建执行会话**(用户 2026-10-01 22:5x 细化,逐字:「**跟进会话 只负责 ,创建协作会话(1、跟进上报后判断是否创建 2、被唤醒后 跟进目标情况 判断是否创建)**」)⇒ 两条触发、同一个动作:**收到队列上报** 或 **被唤醒** ⇒ 跟进目标情况 ⇒ **判断是否建一条 `[协作]` 会话**。⛔ **它自己不做具体活**(⛔ 不改台账 `state`、⛔ 不写 `blocked.json`、⛔ 不派活、⛔ 不抢锁)—— 那些是**它建出来的那条执行会话**的事。细则 ⇒ `architecture.md §2.3.0c`。⚠️ **看板图上这三处写的是简称**(用户 2026-10-01 23:1x 定):**唤醒**=唤醒会话、**跟进**=队列上报的跟进会话、**协作**=目标检查 —— 简称**只是压缩版面**,⛔ **不是又多了角色**(尤其「**协作**」⛔ 别读成「**协作会话**」,那是另一层)。对照表 ⇒ `architecture.md §2.3.0c-2` / `collab-detail.md`。 +- 🔴 **`board.html` 的"虚线大框"=分组,⛔ 不是节点、也⛔ 不是"新一层"**(用户 2026-10-01 第四改:「用一个**虚线大框**把 主会话 唤醒会话 和 跟进会话都框起来,这个虚线大框 **连接 协作会话 虚线大框**就好」)⇒ `UA`=主会话+唤醒+跟进、`UB`=任务会话那一排,两组之间**只有一条线 = ① 派活**(⛔ 不再从主会话往每一格画射线)。⚠️ 两个框的尺寸**都从里面的格子现推**(⛔ 不写死坐标)—— 改了格宽不重算框 ⇒ 虚线**横穿文字**,而**那种图照样能渲染**(两道自检守着它)。细则 ⇒ `architecture.md §2.3.0c-2`。 +- 🔴🔴 **开工第 0 步 = 会话规则机制体检**(用户 2026-10-02 明令)⇒ **先查清,再动手**(补建会话是它**后面**一步)。用户给了**两句,第二句是纠正**: + · ① 原话逐字:「**这个会话和协作会话的技能包 运行的第一件事 ,就应该是检查清楚 所有会话规划是否配置完整且生效,然后标记一个状态**」 + · ② 原话逐字:「**就应该是检查清楚 所有会话规则机制 是否配置完整且生效, 不是规划 是 规则**」⇒ 对象 = **规则机制**(钩子 / 闸门 / 技能指针 / 常驻 / 编排…),⛔ 不是"排期规划"。🔴 首版按①的字面做成「会话规划体检」、**只查排期那一面** ⇒ 当天实测出的三类失效(钩子注入指向**已退役技能名** / 快照**写进幽灵目录** / **每轮注入的记忆**里指针悬空)**一条都查不到** ⇒ 旧脚本已退役到 `<WS>/归档/技能包-旧件-20261002/`,**同包内只剩一个入口**(两个入口 = 「在册 ≠ 生效」本身)。 + ⇒ **怎么跑**:`"$PY" "<本包>/scripts/session-rules-check.py" [--ws <工作区>]` —— ✅ **已接进工作区 `state.py`**(跑状态快照就自带这一段,⛔ 不必另记一条命令)。 + ⇒ **查三类、十二项**:**A 机制装没装好** ① 关键钩子在册 ② 钩子脚本路径存在 ③ 钩子注入里引用的技能名**是否还存在** ④ 钩子**真在被调用**没(闸门日志新鲜度)|**B 规则载体同没同步** ⑤ **每轮注入的记忆**里引用的技能名存在 ⑥ 常驻规则快照**不比权威旧**|**C 编排在不在跑** ⑦ 唤醒 / 跟进两台**周期钟**(缺 = 没人推 / 没人收)(⚠️ 其中「唤醒」这台是**代偿形态** —— 定案的唤醒时钟=**常驻投递**(⑩ 那一项查的才是它);「唤醒排期在册」⛔ **不等于唤醒时钟已就位**,两件事要分开读)⑧ 排期绑的模型**会不会被服务端拒**(`model_is_thinking=0` + flash 系 ⇒ 每触发必拒,2026-10-02 实测)⑨ `cwds` **归属同形**(错一字面 ⇒ 裂组且自我强化)⑩ **投递(常驻)**心跳 ⑪ 三类会话**当前有没有活的** ⑫ 有没有「**从未运行**就失效」的一次性排期。 + ⇒ **标记**:结论写成 `<WS>/.workbuddy/collab/session-rules.json`(`verdict` = `ok`/`warn`/`fail` + 逐项 `detail`)—— 后续会话与看板**读它**,⛔ 不靠人复述。 + 🔴 **为什么必须是第一件事**:2026-10-02 实测——排期**都在册**、模型**都可用**、cwds **都同形**,**却三类会话一条活的都没有**(=配置在、机制没在跑);同一天还查出钩子注入文本指着**已合并退役的技能名**、常驻快照脚本**写到没人读的幽灵目录** ⇒ 全是「**看着有配置、其实没生效**」。这类状态**不问就不会知道**,等它表现成"卡住"时已经晚了。 + 🔴 **判据本身也要能报出问题**:⑨ / ⑫ 这两项(以及 `cwds` 判据的边界)用**合成样本 + 四个变异体**做过红绿对照(夹具 `<WS>/tmp/rules-check-mutate.py`,跑完即弃)—— 变异体=判据恒空 / 判据放宽成"同父目录即报" / 把"从未运行"当"跑完了" / 不排除"还有下次触发"的排期,**逐一按预期报红**。⛔ **别拿"实跑一次没报错"当验收** —— 判据恒空时那次实跑**同样是绿的**。 + ⛔ **只标记、不设卡**:体检 `rc≠0` 也照常开工 —— 它的职责是**把状态问清楚**,不是拦人。 +- 🔴🔴 **开工清单:主会话开工的第 0 步不是"派活",是"把三类会话摆好"**(用户 2026-10-01 明令:「**开始会话完成需求的时候,主会话需要创建 唤醒会话 以及根据分工类别 创建 协作会话 和 跟进会话呢 不然整个机制跑不起来**」)⇒ 建 **唤醒会话** `[唤醒]-<类别>-<具体>`(少建=**没人推**,需求原地静着)+ **每个分工类别一条任务会话** `[协作]-<类别>-<具体>`(少建=**没人干**)+ **跟进会话** `[跟进]-<具体>`(少建=**没人收**)。🔴 **跟进会话是全局唯一席位、⛔ 不按类别各建一条**(2026-10-02 用户订正);它是**收口者**:任务会话干完活把**待核对状态**写进执行队列,上报给**这固定的一个**跟进会话处理。⚠️ **只有自动化能开新会话** ⇒ "建会话"=登记一条自动化,⛔ 不是自己 spawn;标题**第 2 级必须带方括号、值取 `goal.json` 的 `topics`**(⛔ 用 `short` ⇒ 静默漏管)。细则 ⇒ `architecture.md §2.3.0d`。 +- 🔴🔴 **缺会话 ⇒ 自动拉起**(用户 **2026-10-02** 口径,逐字:「**是用户说 使用协作会话方式 完成目标 或 继续完成目标**」) + ⇒ 用户说这两句(或队列堵住)时:**先查三类会话齐不齐、活不活**;**缺 ⇒ 机制自己补建排期把它拉起来**, + ⛔ **不许把"你去开一条会话"甩给用户**(旧行为=写 `NEED-USER.md` 喊人开会话,本条**取代**它)。 + · 判据 + 现成排期参数 ⇒ `collabd.py --gap [--json]`(**只读**:判缺 + 给 `automation_update` 的 name/prompt/scheduledAt) + · 把结论**送进会话** ⇒ 钩子 `wb-result-hook.py::maybe_inject_session_gap()`(`UserPromptSubmit` 注入,触发词命中即查) + · ⛔ **脚本不许写 `automations` 表**(双红线)⇒ 建排期只能由**会话**用 `automation_update` 执行 + —— 这一步**就是"自动"的全部通路**(用户只需照常说那句话,什么都不用做)。 + · 🔴 **跟进会话只有一条、不带类别**(2026-10-02 用户订正,逐字:「**跟进会话只创建一个,跟进的内容 + 来自 执行会话执行完成 后 把 待核对状态 写入 执行队列,上报给那个 固定的 跟进会话处理**」) + ⇒ `collabd.py --gap` 对 `follow` **不按类别分桶**(桶键恒 `(follow, "")`,显示成「全类别(固定席位)」), + 拉起的排期名=`[跟进]-队列上报(固定席位·不分类别)`;`worker`/`waker` **仍按类别**分。 + 细则 ⇒ `architecture.md §2.3.0g`。 +- 🔴 **「接续会话」是「形态」,⛔ 不是第 5 类**(用户 2026-10-01 原话:「**接续会话 不是单独的一类会话,是这几类会话到达阈值时 创建的接续会话**」)⇒ **角色继承被接续的那条**(主会话的接续仍是主会话候选)。 +- 🔴 **执行与投递「一直运行」(常驻)** —— 09-29 定案,**2026-10-01 用户再确认**(理由:「**可能不是所有队列都是钩子产生的**」)。 + ⛔ **不要用自动任务当闹钟**(用户 2026-10-01:「**定时任务的方案已经废弃了**」);宿主钩子只作**补充**,⛔ 不是主路径。 +- **一棒一线**;**派活 ≠ 结束**,要建监管棒并跟进。 +- **自动化四律**:开机第 0 步跑状态 | prompt ⛔ 不抄任务细节 | **下一棒 id 只来自工具返回值** | 排期=收口+3~4 分钟、每条线只挂一个。 +- **`cwds` 逐字同形**(去重键=`path.trim().toLowerCase()`);**入口头部声明的「工作区」决定归属**。 +- 🔴 **⛔ 别用 OS 文件锁做并发**:本机实测「隔离目录全绿、上生产即 `rc=124` 卡死」⇒ 用**临时文件 + `os.replace` 原子替换 + 回读核对(最多 3 次)**。 +- 🔴 **⛔ 不在「会干活的会话」里起常驻后台任务**:它会**压制该会话的 idle 钩子**(实测被僵尸任务压 6h20m); + 要常驻 ⇒ 用**专用容器会话** + `stdout` **全重定向**(⛔ 否则输出反复唤醒宿主 ⇒ 界面静默哑掉)。 + - 🔴🔴 **2026-10-02 23:0x 实测订正(⛔ 推翻当天早些时候的错误结论,本条为准)**: + 一句判据:**不是「本机不存在长跑进程」,是「载体不同」—— 从工具调用进程树里起的活不过当轮,宿主后台任务/用户自己从桌面起的活能长跑。** + ① **实测坐实**:同一时刻用 `start /b` 与 `Popen+DETACHED` 各起一条每秒打点的探针 ⇒ 两条**活到约 11 分钟后停在同一 tick**(67/64 行)⇒ **差别不在起法关键字,在父链**。 + ② **反证(⛔ 就是它让我误判的)**:MCN 工作台 `<mcn-short-video>/.../mcn-work-shop/start.bat` = `start "" node.exe server.js 8900`,用户**从桌面双击**、属用户登录会话 ⇒ **一直活着**;另有 4 个 `node.exe`(会话名 `Console`)长期存活。 + ③ ✅ **正解(本轮已跑通,两条都现算)**:走**WorkBuddy 自己的后台任务**(工具的 `run_in_background`,⛔ 别用 `subprocess` 自己造)⇒ 载体由宿主管理、不由当轮工具调用决定: + `python tmp/start_board.py 8788` → `board.py --serve --takeover` ⇒ **`127.0.0.1:8788 LISTENING`(pid 43176)/`HTTP=200`/119 704 B**; + `python tmp/start_mcn_board.py 8900` → **`LISTENING`(pid 14056)/`HTTP=200`/1 797 B**。 + ⚠️ 一次**带 `--takeover`**(静默并存会看到旧图);⚠️ 用 `pythonw.exe` 起子进程(GUI 子系统 ⇒ Windows 永不分配控制台 ⇒ ⛔ 不闪窗);⚠️ `pythonw` 无 stdout ⇒ **显式重定向到 `tmp/board-serve.log`**,否则静默无痕、连"起没起"都查不到。 + ④ ⛔ **因此撤回**当天那条「改用排期当时钟」的建议 —— 它建立在错误前提上。**排期仍保留,但身份是复活兜底**,⛔ 不是时钟。唤醒时钟**回到常驻进程本身**。 + ⑤ ✅ **S8 那套自愈仍有效**(`--supervise` 心跳 + `--tick` 顺手续命 + 存活判据 `pid 活 ∧ 心跳新鲜(<90 s)`)—— ⛔ 但它**不是**因为"没法长跑"才需要,而是**常驻总会被各种事打断**(重启、换会话、用户手动收),所以要能自动补回来。 + 🔴 **判"常驻在不在"只看两样**:`pid 活 ∧ 心跳新鲜(<90 s)`(`logs/supervise-heartbeat.json`); + ⛔ **不许拿 `_tick.stamp`/投递日志当证据** —— 那些轮次是**钩子**写的(`pitfalls.md P0-22`)。 + 🔴 **启完必查三样**(⛔ 别凭"打印了启动消息"当成了):`netstat` 里有 **`LISTENING`**(⛔ `TIME_WAIT`/`FIN_WAIT_2` 是历史连接残留,不算)+ `curl` 有 `200` + `tasklist` 里进程在。 +- 🔴 **自动唤醒任务的排期名必须带 `[执行]`**,否则被认成"主会话"把通知投给自己。 + ⚠️ 🔴 **2026-10-02 标注(⛔ 不是口径变更)**:该规则**仅代偿形态适用**(定案时钟=常驻投递,不开会话);🔴 **定案口径**=「协作与投递**一直运行(常驻)**」+「**定时任务的方案已废弃**」(2026-10-01 用户原话)。 + +- 🔴 **看板 tab 可并列查看别的「工作区」的目标**(用户 2026-10-03 报障,逐字:「**为什么 会话协作看板 tab 选项不能切换看另外两个工作区的目标**」)。 + · **真因(看代码,不猜)**:`board.py::goal_files()` 只扫 `INBOX/goal.json` + `INBOX/goals/*.json`,而 `INBOX` 由**部署配置的 `workspace`** 决定 ⇒ **一个 `--serve` 实例天生只看见一个工作区**。 + · **修法**:部署配置加 `peer_workspaces`(要并列查看的其它工作区,**正斜杠**)⇒ 把对方的 `goal.json` 读进来当**额外一格 tab**(打 `peer` 标记,前端显示在标题旁)。 + · 🔴 **严格只读**:⛔ 不写对方文件、⛔ 不起对方进程、⛔ 不改对方状态;**活跃目标仍然只有本工作区那份** ⇒ `collabd.py` 行为零改动。 + · 🔴🔴 **那一格的数据源必须跟着格走**(⛔ 否则=**假数据**,见 `pitfalls.md` **P0-40**):peer 格只喂 `_peer_tasks()`/`_peer_srows()`/`_peer_state()`(**只读对方目录**),读不到 ⇒ **空 + 界面如实说明**,⛔ **绝不拿本工作区的台账/会话/状态补位**(2026-10-03 实测:三格 `labor`/`sessions` md5 完全相同 ⇒ 把本区执行情况挂到了别人名下)。⚠️ 会话还要**单独补捞**(`_session_rows()` 只取全库最近 50 条,对方会话不在里面)+ 把 `sc["workspace"]` 换成对方根(`_sessions()` 有 `cwd` 硬过滤)。 + · ⚠️ **它只是「只读概览」,⛔ 不是对方的完整看板**:peer 格的 `sessions` 目前为空(卡在 `in_project()` 判据,⛔ 不改那条 —— 它与 `collabd.py` 有**逐条同款**约束,历史已踩三次)。 + · 🔴🔴 **2026-10-03 16:3x 用户定案:⛔ 不再为每个工作区各起一个看板** —— **看板只保留一份**(就是主工作区这一个),其它工作区靠 `peer_workspaces` 并列查看 ⇒ 各区的目标、验收、台账、常驻心跳**都在这份上看**(实测:三区常驻同时在跑,各自的 `heartbeat_age_min` 都能在这份看板上读到)。 + · ⛔ 配置里**不含本工作区**(它已是 active 那格,重复列 ⇒ 出现两格);⛔ 去重键必须带**工作区前缀**(同名目标会互相顶掉)。 + · 🔴🔴 **某格工作区「已经不在会话列表里」⇒ ⛔ 不再占 tab**(用户 2026-10-04 20:59 报障,逐字: + 「**tab 要把已经不再 会话列表的工作区 目标移除,不然都放不下了**」)。 + - 🔴 **判据(唯一)**:宿主库 `sessions` 表里该 `cwd` 的**未删会话数**(`deleted_at is null or 0`) + = **0** ⇒ 这个工作区在会话列表里**已经不存在了** ⇒ 不列 tab。 + 实现在 `board.py::goal_files()`(peer 循环里 `_peer_session_rows()` 为空就 `continue`)。 + - 🔴🔴 **⛔ 别拿「心跳新不新鲜」当判据**:心跳只说明那个区的**常驻进程**在不在, + 与「这个工作区还有没有人在用」**是两件事**。实测反例:测试2/3 心跳早停 ⇒ 但**未删会话也为 0** + (用户把会话全删了)⇒ 确实退场;而 vibe-product 心跳 1.5 小时前、**未删会话 5 条** ⇒ **还在用** ⇒ 必须留。 + - ⚠️ **读不到库 ⇒ 保留**(fail-open):宁可多一格,⛔ 不因自己的读数失败就把别人的格子吞掉。 + - ✅ **零删除、可逆**:只影响"要不要画这一格",⛔ 不动对方任何文件、⛔ 不替用户改 `peer_workspaces`。 + - ⚠️ 改 `board.py` ⇒ **必须重启看板**(本节上一条);只改 `collabd.config.json` 里的 `peer_workspaces` 也**必须重启**(`C = _cfg()` 模块级只执行一次)。 + · 🔴🔴 **「已退役角色」⛔ 不许当主会话候选**(用户 2026-10-04 21:5x 拍板「候选一」)。 + - 🔴 **判据(唯一)**:标题的**一级方括号里是退役角色词**(`[跟进]`/`[唤醒]`)⇒ ⛔ 不进主会话候选。 + 实现在 `collabd.py::is_retired_role_title()`(+表 `_RETIRED_PFX`;`board.py` 从该模块**取**,⛔ 不另写一份)。 + - 🔴 **病根**:`[跟进]`/`[唤醒]` 两键在 2026-10-02 从映射表摘掉后 ⇒ 角色解析成 **`""`** + ⇒ 而候选排除元组是 `_role not in ("worker",)` ⇒ **`""` 恰好放行** ⇒ 这两类**已退役的干活的棒** + 被收进主会话候选 ⇒ `_role_label()` 判据①命中 ⇒ 看板「主会话」位上坐着它。 + **实测现网**:`main_by_topic` = `{"机制排查与修复": "6ab1463e", "会话协作自检": "57f58ecf"}` + —— 两个任务类别**全都指向 `[跟进]` 退役会话**(`6ab1463e`「[跟进]-机制排查与修复-队列跟进」等 11 条)。 + - ⛔ **修法不是「排除空串」**:空串里还有**真主会话**(现役 `a80f300d`「复盘失败并避免重犯」无前缀) + ⇒ 一刀切会把真主会话一起排掉。也⛔ **不是「恢复 `[跟进]`/`[唤醒]` 映射」**(那等于让退役类别复活,与 10-02 口径相反)。 + - ⚠️ 闸**只认一级方括号里的整词**:⛔ 不许误杀「类别名里恰好含『唤醒』」的在役会话 + (`[协作]-[唤醒机制]-…` 一级是「协作」⇒ 是 worker,本就该排,但**理由不该是"退役角色"**)。 + - 🔴🔴 **⛔ 危害不止"标签难看"**:主会话候选=**投递/派活的收件人** ⇒ 会**投错窗口**。 + - ⚠️ 判据**两侧同源**(`board.py` import 而非抄写)+ 用例是**行为级**(真造宿主库+真跑扫描)—— + ⛔ 只测「那个小函数返回什么」= **测了零件没测装配**(2026-10-04 变异验证当场抓到: + 拆掉闸后单函数断言**照样全绿**)。⇒ 见 `pitfalls.md` P0-54。 + · ✅ 现算:`/board.json` 的 `goals`=**3 格**,`peer`=`会话协作测试1`/`会话协作测试2`(⛔ 区名是历史名,不改),`active` 恰好 **1** 个。 + · ⚠️ 改 `board.py`/配置 ⇒ **必须重启**(**P0-38**),且用 `--takeover`(⛔ 否则新旧实例静默并存、同一个 URL 随机应答)。 +- 🔴🔴 **自测的「现网真读数」类判据 ⛔ 不许复用 `imp()` 注入的测试环境**(2026-10-03 实测栽的,同族第 2 次):`imp()` 会把 `COLLABD_CONFIG`/`DSH_COLLAB_WS` 指到 `tmp/selftest/` ⇒ 用例里**无论怎么加载 `board.py` 都只读到测试那 1 格**(同一时刻命令行直接加载是 3 格)⇒ **恒绿与恒红都是假象**。正解三条:**加载前换 env、用完 `finally` 还原** + **在真磁盘上造目标**(`tempfile.mkdtemp()`)+ **变异对照**(打掉接线必须报红;还原后 md5 校回原值)。⚠️ 「配置缺省 ⇒ 回落旧行为」那条**只守配置侧**(代码被破坏时它照样绿)⇒ ⛔ 别把它当证伪项写进标题。详 ⇒ `references/pitfalls.md` **P0-39**。 +- 🔴🔴 **每个工作区的常驻由「它自己的主会话」起**(用户 2026-10-03 定案,逐字:「**每个工作区 会话协作机制的主会话自己创建后台任务 启动常驻协作程序**」,同句「**后台看板不用运行这么多 共享一份就可以**」)。 + · **主会话开工第 0 步 = 确认本工作区的常驻在跑**:判据=`.workbuddy/collab/logs/supervise-heartbeat.json` 里 **`pid` 活 ∧ 心跳距今 < 90 秒**;在跑 ⇒ **跳过**(⛔ 别起第二个,同端口两个实例会互相顶掉);不在 ⇒ 用**后台任务**启动一次,**输出必须重定向到文件**(⛔ 否则它的日志会把会话日志顶满)。 + · ⛔ **不是由别的会话代起** —— 谁的工作区谁负责:跨区代起会让「哪个进程属于哪个区」彻底糊涂,而且**代起方一结束,被代起的那个区立刻失联**(实测就是这么断的)。 + · 🔴🔴 **本工作区的会话 ⛔ 绝不许去起「别的区」的常驻**(用户 2026-10-03 16:4x 当场纠正,逐字:「**不是 为什么这个会话 要创建别的会话的 常驻任务,让那边的会话自己创建啊**」)—— ⚠️ **我自己就违反了这一条**:写完禁令后转头在本会话里替两个测试工作区起了后台任务(看着是"帮忙让它跑起来",实际是**代起**),当场被纠正后已停掉。 + ⇒ **正确做法**:那边的常驻**只能由那边的会话起**;我这边唯一能做的是**把它那条排期的触发时间提前**(改 `scheduledAt`,或等它周期触发),⛔ **不是替它执行**。 + ⇒ **怎么判断有没有越界**:问一句「**这个进程归哪个工作区?起它的会话又归哪个工作区?**」—— 两个答案不一致 ⇒ 就是代起。 + · 🔴 **看板只保留一份**:各工作区配置里**没有** `board_port`;只有主工作区起一个 `board.py --serve`,靠 `peer_workspaces` 并列看其它区。⛔ 每区一个看板=白白多 N 个进程+N 个端口。 + · 🔴🔴🔴 **全局只允许一个看板 / 一个常驻(用户 2026-10-04 21:0x 定案,逐字:「看板 包括常驻 按现在的方式启动,全局只允许一个,把规则记录下来」)**: + **现状即标准** —— 就是本轮已经跑通并取证的那一套,⛔ 不要再发明第二套。 + - **看板载体**:计划任务 **`dsh-board-keepalive`(🔴 全局唯一一条,⛔ 不每区一个 —— 2026-10-05 收敛,旧名 `dsh-board-20099` 已禁用)**。 + · 🔴🔴 **动作 = `pythonw.exe` 起"启动器" `scripts/board-launch.py`**(⛔ **不许直起 `board.py`**)。 + **为什么(2026-10-05 实测)**:直起 ⇒ **崩溃重启循环、20099 从没绑上**。现场输出: + `⚠️ collabd:未找到部署配置(COLLABD_CONFIG 未设)⇒ 已拒跑` —— `board.py` 顶层用 + **`COLLABD_CONFIG`** 定位配置(它要 `import collabd.py`),而**计划任务的动作里没有 env 字段** + ⇒ 该变量只能由**启动器在进程内设**。⚠️ **名字别混**:`roots.env` 给的是 `COLLABD_PROD_CONFIG`, + 而 `board.py` 读的是 `COLLABD_CONFIG` ⇒ 名字对不上,`roots.env` **兜不住它**。 + · ⚠️ 启动器的 `WorkingDirectory` =**技能 `scripts/` 目录**(启动器自己定位 `board.py`); + 看板**配置恒指主工作区**那份(看板是全局共用一份)——这与 `--supervise` 必须指**工作区根**是两回事。 + · 🔴 **`ExecutionTimeLimit` 必须 `0`(无时限)** —— 看板是**长驻服务**; + ⛔ 设 2 分钟 ⇒ 到点被调度器掐死 ⇒ 又变成"每 5 分钟重建一次"的抖动(与 `--supervise` 同理)。 + · 🔴🔴 **⛔ 动作绝不许写成 `.cmd`/`.bat`/`powershell.exe`** —— 它们是**控制台程序**, + 计划任务每次触发都分配 `conhost.exe` ⇒ **弹黑窗**;更糟:`.cmd` 里若**前台**跑 pythonw + (没 `start`)⇒ `cmd.exe` **不退出** ⇒ 黑窗**常驻**(实测 `cmd.exe` + `conhost.exe` 挂在看板树上)。 + · 🔴 **为什么用 launcher `.py` 而不是直接起 `board.py`**:`COLLABD_CONFIG` 等环境变量 + 原本靠 `.cmd` 的 `set` 传(`board.py` 在**模块顶层**读它们,`roots.env` 只兜底 `COLLABD_PROD_CONFIG`, + **名字对不上**)⇒ 现在改在**进程内** `os.environ[...] = ...`,等价且零 shell。 + · 🔴 **launcher 必须用 `runpy.run_path(BOARD, run_name="__main__")`,⛔ 不许用 `exec(compile(...))`** —— + 实测:`exec` 版从 `C:\Windows\System32`(=任务默认 cwd)起时 **rc=1 且零输出**, + 换 `runpy` 后从任意 cwd 都正常(`runpy` 会正确设好 `__file__`/`__name__`/`sys.path[0]`, + `board.py` 顶部的 `_sm_load_roots()` 靠 `__file__` 往上找 `roots.env`)。 + · 🔴 **任务设置**:`ExecutionTimeLimit=0`(无时限)|`MultipleInstances=IgnoreNew`| + `RestartCount=999`/`RestartInterval=1min`|`RunLevel=Highest`(⚠️ `Limited` 实测 `LastTaskResult=1`)。 + · ✅ **验收(三条全绿才算成,⛔ "打印了启动消息"不算)**:`netstat` 见 `127.0.0.1:<port> LISTENING` ∧ + `curl /` = 200 ∧ **父进程是 `svchost.exe -s Schedule`**(⇒ 已脱离会话,⛔ 不是会话树里的 bash)。 + 🔴 **且要看进程树下**:**零 `cmd.exe`/零 `conhost.exe`** ⇒ 黑窗彻底消除,这才算对。 + - **常驻载体**:同规矩 —— **`pythonw.exe` 起"启动器" `scripts/supervise-launch.py`**(⛔ **不许直起 `collabd.py --supervise`**), + ⚠️ 启动器的 `WorkingDirectory` =**工作区根**(⛔ 与看板相反,看板指 `scripts/`);参数由启动器给(⛔ 动作里不再自带 `--supervise`); + 存活判据仍是 `pid 活 ∧ 心跳 <90 s ∧ argv0 指本区`(⛔ 不换判据)。**为什么必须经启动器**:见本节开头「为什么要夹一个启动器」(P0-73)。 + - 🔴 **「全局只允许一个」怎么守**:看板=`board.py` 内置单实例护栏(端口上已有实例 ⇒ **拒绝启动**并提示; + 换代码要 `--takeover`)。**端口即互斥锁**,⛔ 别再加一层自造锁。 + 常驻=`ensure_supervise()` 心跳判据(已有活的 ⇒ 幂等 `exit 0`)。 + - ⛔ **看着"要起两个"时的正解不是多起,是查为什么第一个没起**(`LastTaskResult`/launcher 日志/端口占用)。 + · 🔴🔴 **实测警告(决定这个口径能不能落地)**:**后台任务的寿命 ≈ 发起它的那个会话的寿命** —— 2026-10-03 实测两条:由**一次性会话**启动的那份只活 **5 分钟**(14:42:00 起 → 14:47:10 最后心跳,`round=32`);由**长期存在的会话**启动的那份已连续运行 **4.5 小时**(12:03 起仍在跑,`round=1624`)。⇒ 所以主会话必须是**周期性**的(现配 `FREQ=HOURLY;INTERVAL=1`)才能反复补,⛔ **别指望"起一次就一直活着"**。 + · ⚠️ 由此推出的**残留**:仅靠周期性主会话 ⇒ 常驻每小时只在主会话那几分钟在线 ⇒ 要**真正长期在线**还得有一层**脱离会话**的载体(计划任务/桌面启动/系统服务)⇒ **尚未定,见本节末待定项**。 +- 🔴🔴 **各工作区的「程序」也独立 —— 看板是唯一共用的一份**(用户 2026-10-03 17:0x 定案,逐字:「**跨工作区使用会话协作技能,除了看板共用,其余都是独立的,包括程序和相关文件**」)。 + · **形态**:技能目录 `scripts/collabd.py` = **源(唯一真身)**;每个工作区 `.workbuddy/collab/collabd.py` = **它自己的一份副本**(另含 `goalctl.py`)。⛔ 各区**不再跑技能目录那份**。 + · **一键分发**:`python scripts/deploy_code.py --ws <工作区>` —— 覆盖前**自动备份**、打印源与副本的 md5;支持 `--dry-run` 与 `--only collabd.py`。 + · 🔴 **代价(明说)**:一处改动要分发 N 处 ⇒ **改完代码必须重跑分发**;⛔ 漏了**不报错**,只是"某个区行为不对",最难查。 + · ✅ **可观测性(关键)**:`collabd.py` 每次写心跳都带 **`argv0`**(启动入口绝对路径)⇒ 「这个区跑的到底是哪份文件」一眼可查,**也能证伪**"改了副本却没重启"。 + ⚠️ **改副本不重启=没生效**(P0-17 同族)⇒ 主会话开工第 0 步要核 `argv0`:指向的不是本区路径就**自己换掉**。 + · ⛔ **`board.py` 不进副本清单** —— 看板是**共用一份**的(用户同句定案);⛔ 每区一个看板=白白多 N 个进程与端口。 + · 🔴🔴 **启动脚本/载体也必须在清单内 —— 只分发程序、不修启动入口 = 这条规则形同虚设**(2026-10-04 实测事故,P0-57): + - **病根**:某区的 `tmp/start_supervise.py` 第 11 行写死 `WS + "/.workbuddy/skills/session-mechanism/scripts/collabd.py"` + ⇒ **它指的是「技能副本」,不是「本区发布物」** ⇒ `.workbuddy/collab/collabd.py` 成了**孤儿副本** + (**全工作区只有 0 处引用它**,落后一版也没人发现)。判据②(`argv0` 指向本区)实测**不过**。 + - 🔴 **这是"旧口径的启动脚本 + 新口径的目录结构"的典型症状**:目录改对了、**引用没跟着改**。 + ⇒ ⛔ **别只盯 `.workbuddy/collab/` 里文件在不在** —— 文件在 ≠ 有人跑它。 + - ✅ **正解**:启动脚本里**必须**写**本区发布物**路径,并**优先+回落**两段式(缺了才用技能副本,且要**打印告警**,⛔ 静默回落=用户以为跑的是副本)。 + ```python + COLLABD_OWN = WS + "/.workbuddy/collab/collabd.py" # ✅ 首选 + COLLABD_SKILL = WS + "/.workbuddy/skills/session-mechanism/scripts/collabd.py" # ⚠️ 仅回落 + COLLABD = COLLABD_OWN if os.path.isfile(COLLABD_OWN) else COLLABD_SKILL + ``` + - 🔴 **开工第 0 步的自查(三条一起做,⛔ 少一条就漏)**: + ① `deploy_code.py --ws <工作区> --dry-run` ⇒ 副本与源 **md5 一致**; + ② 心跳 **`argv0` 指向本区发布物**(⛔ 指向 `skills/` 或全局技能都算**没接线**); + ③ **grep 启动脚本**(`tmp/*.py`、`*.ps1`、计划任务的 `Args`)⇒ 引用的路径**逐字是本区发布物**。 + ⛔ 只做①② ⇒ 会漏掉「副本是孤儿、跑的是另一份」这种形态 —— 今天就是这么漏的。 + - 🔴🔴 **分发是"手动"的 —— 这是 2026-10-05 用户拍板(选 A,⛔ 不做"保存即分发"钩子)**: + **技能目录是源、各区 `.workbuddy/collab/` 是发布物**;改完源**必须显式跑** `deploy_code.py --ws <区>` + (覆盖前自动备份、打印 md5 对照),⛔ **没有 watcher、不会自己流到各区**。 + · 为什么不自动:`collab/` 是**生产面**,静默覆盖一旦**源改错**会**同时打歪所有区**,且不备份时机难控; + 显式一条命令=**可回滚、可审计、不易误伤**。 + · ⇒ **收口口径**:改完本包脚本 ⇒ ①跑 `deploy_code.py`(**两区都跑**)②**重启常驻**(见下)。 + - 🔴🔴 **"部署完" ≠ "生效了" —— 必须重启常驻**(2026-10-05 实测): + 常驻进程**启动时就把代码读进内存**,改文件**不影响正在跑的进程**。 + · ✅ **判据**:心跳里的 **`started_h`(进程启动时间)必须晚于部署时间**; + ⛔ 只看副本 md5 一致 ⇒ 会误判成"已生效"(实测 vibe 副本 md5 早就是新的,但进程还是 12:15 起的旧代码)。 + · ✅ **重启做法**:`Stop-Process` 杀掉常驻 ⇒ 触发该区计划任务(或等 5 分钟自动判活)⇒ 新进程读新副本。 + · ⚠️ 重启前确认 **无 `guard.stop`**(有 ⇒ 拉起后立即自停,看着像"没起来")。 + · 🔴🔴 **跑自测的位置(2026-10-05 实测踩到)**:**⛔ 别在本区 `.workbuddy/collab/` 目录里跑 `selftest.py`**。 + - **为什么**:`collab/` 是**生产部署面**,只有 `collabd.py` + `goalctl.py`(⛔ **不含** `board.py`/ + `judge_audit.py` 等**包内依赖** —— 看板是共用一份的)⇒ 在那里跑会大面积 `FileNotFoundError`, + 实测 **PASS 42 / FAIL 45**,看着像"回归",其实是**跑错地方**(⛔ 假警报,会把人带偏去查不存在的 bug)。 + - ✅ **正确跑法**:在**技能目录**跑源那份、把 `cwd` 设成目标工作区即可(`selftest.py` 用 `cwd` 推工作区): + `cd <工作区> && python <技能目录>/scripts/selftest.py` + - 📌 **验收基线**:本区正式截面应 **PASS ≥92 / FAIL 0**(另 1 条报告型)。 + - ⚠️ 判据:**"FAIL 一片" 先问一句"我是不是在 `collab/` 里跑的"**,再去怀疑代码。 + +--- + +## §3 目录与依赖 + +``` +SKILL.md install.py roots.env(装后生成) install.log(装后生成) +references/ architecture collab collab-detail rules deploy pitfalls taskgraph forensics manifest +scripts/hooks/ 6 份宿主钩子(10 条接线) +scripts/lock/ handoff-guard.sh preflight-lock.sh handoff-status.py op-lock.sh +scripts/ collabd.py board.py board_ext.py goalctl.py guard.py wake-session.py + stop-collab.py deliver-gateway-token.py selftest.py + collabd.config.example.json +scripts/forensics/ proc-parent.py(查进程父链 · 纯 ctypes · 只读) +assets/ board.html design-tokens.css +``` + +⚠️ 逐文件清单 + 来源(provenance)⇒ `references/manifest.md`(**唯一权威**,⛔ 别按本树猜)。 + +**⛔ 已无必需外部依赖(2026-10-04)**:原先声明「依赖 `agent-operating-rules`」——现已把它的**排版核心块内联**为 `references/03-回复排版-核心块.md`、把 `_env.py` 的技能库识别特征改为**候选数组(首个=本包自己)** ⇒ **本包自包含**。📌 那个技能若同时装着,属**可选增强**(多了跨项目排版/去 AI 味的完整版),⛔ 不装也能跑。 + +--- + +## §4 验证(装完必跑) + +``` +python install.py --dry-run # 看 settings.json 将怎么变 +python install.py --apply # 真装 +python install.py --verify # 10 条接线空载荷 rc=0 + collabd --where + selftest PASS 39/0 +python install.py --manifest --note "<本轮:…>" # 重算 references/manifest.md 的逐文件表(**改完包必跑**) +python install.py --uninstall # 还原 settings.json(应与装前备份逐字节相同) +``` diff --git a/session-mechanism/assets/board-launch.py.tpl b/session-mechanism/assets/board-launch.py.tpl new file mode 100644 index 0000000..34b53a8 --- /dev/null +++ b/session-mechanism/assets/board-launch.py.tpl @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +"""看板启动器(**无控制台**)· 模板。 + +> 🔴 用户 2026-10-04 21:0x 定案:「**看板 包括常驻 按现在的方式启动,全局只允许一个,把规则记录下来**」 +> 本文件=本文档所述「现在的方式」的可复制载体。装法见 `references/supervise-persistence.md`。 + +## 为什么必须有它(⛔ 别再退回 `.cmd`) + +计划任务的动作若写 `.cmd`/`.bat`/`powershell.exe`,那些都是**控制台程序** +⇒ 每次触发都分配一个 `conhost.exe` ⇒ **屏幕上弹黑窗**(用户已投诉两次)。 +更糟:`.cmd` 里若**前台**跑 `pythonw`(没有 `start`)⇒ `cmd.exe` **不返回** +⇒ 黑窗**常驻**挂着(实测 `cmd.exe 62932` + `conhost.exe 54928` 挂在看板进程树上)。 + +## 解法 + +计划任务动作 **直接指向 `pythonw.exe`**(GUI 子系统 ⇒ 操作系统层面就不分配控制台 ⇒ 零黑窗); +本来靠 `.cmd` 的 `set` 传的环境变量,改在**本进程内**设好,再 `runpy` 起 `board.py`。 + +## 装法(⚠️ 三处必改:{{WS}} / {{BOARD}} / {{PORT}}) + +```powershell +$pyw = '<managed-python>\pythonw.exe' +$a = New-ScheduledTaskAction -Execute $pyw -Argument '"<本文件>"' ` + -WorkingDirectory '<工作区根>' +$s = New-ScheduledTaskSettingsSet -ExecutionTimeLimit ([TimeSpan]::Zero) ` + -MultipleInstances IgnoreNew -RestartCount 999 ` + -RestartInterval (New-TimeSpan -Minutes 1) +$p = New-ScheduledTaskPrincipal -UserId '<当前用户>' -LogonType Interactive -RunLevel Highest +Register-ScheduledTask -TaskName 'dsh-board-<端口>' -Action $a -Settings $s -Principal $p -Force +``` + +🔴 **`RunLevel=Highest` 不是可选项**:`Limited` 实测 `LastTaskResult=1`(写日志/读库被拒)。 + +## ⛔ 两条封死的写法(都亲手栽过) + +1. **`exec(compile(...))`** —— 从 `C:\Windows\System32`(任务默认 cwd)起时 **rc=1 且零输出**。 + ✅ 用 `runpy.run_path(BOARD, run_name="__main__")`:它会正确设好 + `__file__`/`__name__`/`sys.path[0]`,`board.py` 顶部的 `_sm_load_roots()` + (靠 `__file__` 往上找 `roots.env`)才找得到。 +2. **`pythonw.exe` 下 `sys.stdout/stderr` 可能是 `None`** ⇒ 任何 `print` 抛 `AttributeError` 秒退。 + ✅ 起 `board.py` 前先给两个流兜底(本文件已做)。 + +## 验收(三条全绿才算成,⛔ "打印了启动消息"不算) + +- `netstat` 见 `127.0.0.1:<port> LISTENING` +- `curl /` = 200 +- 🔴 **父进程是 `svchost.exe -s Schedule`**(⇒ 已脱离会话)**且进程树下零 `cmd.exe`/零 `conhost.exe`** +""" +import os +import sys + +# ── 三处按实际改 ──────────────────────────────────────────────── +WS = r"<工作区根,例:E:\ProgramData\AIProject\ai1net-dsh-server>" +BOARD = r"<技能脚本,例:E:\ProgramData\.workbuddy\skills\session-mechanism\scripts\board.py>" +PORT = "8788" +# ─────────────────────────────────────────────────────────────── + +LOG = os.path.join(WS, "tmp", "board-serve.err.log") + +# 🔴 必须**先设好 env 再起 board** —— `board.py` 在**模块顶层**读这些名字。 +# ⚠️ `roots.env` 只兜底 `COLLABD_PROD_CONFIG`,而 `board.py` 读的是 `COLLABD_CONFIG` +# ⇒ **名字对不上,不设就落到 `${脚本目录}/collabd.config.json`(不存在)⇒ 起不来**。 +os.environ["COLLABD_CONFIG"] = os.path.join(WS, ".workbuddy", "collab", "collabd.config.json") +os.environ.setdefault("CODEBUDDY_CONFIG_DIR", os.path.expanduser("~/.workbuddy")) +os.environ["PYTHONIOENCODING"] = "utf-8" +os.environ["PYTHONUNBUFFERED"] = "1" + +# 🔴 `pythonw.exe` 下两个流可能是 None ⇒ 兜底,否则 print 抛异常秒退 +try: + _f = open(LOG, "a", encoding="utf-8", errors="replace") +except Exception: + _f = None +if _f is not None: + if sys.stdout is None: + sys.stdout = _f + if sys.stderr is None: + sys.stderr = _f + +# 🔴 ⛔ 别用 `exec(compile(...))`(见文件头「两条封死的写法」)⇒ `runpy` 才会正确设好 `__file__` 等 +import runpy + +sys.argv = [BOARD, "--serve", PORT] +runpy.run_path(BOARD, run_name="__main__") diff --git a/session-mechanism/assets/board-render-probe.js b/session-mechanism/assets/board-render-probe.js new file mode 100644 index 0000000..52a0018 --- /dev/null +++ b/session-mechanism/assets/board-render-probe.js @@ -0,0 +1,235 @@ +// 真跑 render():把 board.html 的页面脚本放进 vm 的**全局作用域**执行, +// 这样顶层 `function render(){}` 会挂到 sandbox 对象上,能真调到。 +// ⛔ 上次用 `new Function()` 是假绿 —— 函数关在局部,取到 undefined,等于没跑。 +const fs = require('fs'), http = require('http'), vm = require('vm'); + +const path = require('path'); +const P = process.env.BOARD_HTML || path.join(__dirname, 'board.html'); +const SNAP_URL = process.env.BOARD_SNAP || 'http://127.0.0.1:20099/board.json'; +// 🔴 2026-10-05:支持**盘上快照**(自测/离线用)—— ⛔ 不必起服务,⛔ 不依赖网络。 +const SNAP_FILE = process.env.BOARD_SNAP_FILE || ''; +// 分母口径断言可被外部指定(自测会传期望值;不传则只报数不判) +const WANT_PASS = process.env.BOARD_WANT_PASS || ''; +const html = fs.readFileSync(P, 'utf8'); +const blocks = [...html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/gi)].map(m => m[1]); +const code = blocks.join('\n;\n'); + +const els = {}; +function mkEl(id) { + const e = { + id, hidden: false, textContent: '', innerHTML: '', style: {}, children: [], + dataset: {}, // ← 页面用 dataset.base 存静态文案 + classList: { toggle(){}, add(){}, remove(){}, contains(){ return false; } }, + appendChild(c){ this.children.push(c); return c; }, + insertBefore(c){ this.children.push(c); return c; }, + setAttribute(){}, getAttribute(){ return null; }, addEventListener(){}, + removeAttribute(){}, focus(){}, blur(){}, click(){}, + querySelector(){ return null; }, querySelectorAll(){ return []; }, + getBoundingClientRect(){ return {width:900,height:500,top:0,left:0}; }, + getContext(){ const noop=()=>{}; + return {clearRect:noop,fillRect:noop,beginPath:noop,moveTo:noop,lineTo:noop, + stroke:noop,fill:noop,arc:noop,fillText:noop,save:noop,restore:noop, + translate:noop,scale:noop,closePath:noop,setLineDash:noop,bezierCurveTo:noop, + measureText:()=>({width:10})}; }, + parentNode: null, offsetWidth: 900, offsetHeight: 500, offsetTop: 0, + }; + return e; +} +const document = { + getElementById(id){ return els[id] || (els[id] = mkEl(id)); }, + createElement(t){ return mkEl('__'+t); }, + createElementNS(){ return mkEl('__ns'); }, + querySelector(){ return null; }, querySelectorAll(){ return []; }, + addEventListener(){}, removeEventListener(){}, + body: mkEl('body'), documentElement: mkEl('html'), head: mkEl('head'), + createTextNode(t){ return {textContent:t}; }, +}; +let rafQ = []; +const sandbox = { + document, console, + window: null, + navigator: { userAgent: 'node' }, + location: { protocol: 'http:', hash: '', search: '', href: 'http://127.0.0.1:20099/' }, + requestAnimationFrame: fn => { rafQ.push(fn); return rafQ.length; }, + cancelAnimationFrame: () => {}, + setTimeout: () => 0, // ⛔ 不开轮询 + clearTimeout: () => {}, + setInterval: () => 0, + clearInterval: () => {}, + fetch: () => Promise.reject(new Error('probe: fetch disabled')), + localStorage: { getItem(){return null;}, setItem(){}, removeItem(){} }, + matchMedia: () => ({ matches:false, addEventListener(){} }), + addEventListener(){}, removeEventListener(){}, + alert(){}, getComputedStyle: () => ({ getPropertyValue: () => '' }), +}; +sandbox.window = sandbox; +sandbox.globalThis = sandbox; +sandbox.self = sandbox; + +let bootErr = null; +try { + vm.createContext(sandbox); + vm.runInContext(code, sandbox, { filename: 'board.html', timeout: 15000 }); +} catch (e) { bootErr = e; } + +console.log('脚本装载 :', bootErr ? ('❌ ' + bootErr.message) : '✅ OK'); +console.log('render 类型 :', typeof sandbox.render); +console.log('renderProject :', typeof sandbox.renderProject); +console.log('paintScope :', typeof sandbox.paintScope); +console.log(''); + +function run(snap) { + console.log('快照顶层键数 :', Object.keys(snap).length); + console.log('顶层有 acc? :', Object.prototype.hasOwnProperty.call(snap,'acc')); + console.log('goal.acceptance 条数:', Object.keys((snap.goal||{}).acceptance||{}).length); + console.log(''); + let err=null, out=''; + // 清掉桩里 fetch reject 留下的残留(那是**测试桩**造成的,⛔ 不是页面 bug) + if (els['err']) els['err'].textContent = ''; + try { + const rp = sandbox.renderProject || sandbox.render; + out = rp(snap) || ''; // ← 原来炸的就是这条 + } catch(e){ err = e; } + const errEl = els['err']; + // 🔴 renderProject 直接写 `#proj`(⛔ 不是返回值、⛔ 也不是 #project) + // ⚠️ 2026-10-05:**目标 id / 归属判据 / 工作区**那几项落在**右上角 `?` 的 `#projTip`** 里, + // ⛔ 不在 `#proj` ⇒ 只读 `#proj` 会漏掉它们(实测"目标 id 没渲染"就是这么假红的)。 + const _tip = (els['projTip'] && els['projTip'].innerHTML) || ''; + const outHtml = (out || (els['proj'] ? (els['proj'].innerHTML||'') : '')) + '\n' + _tip; + console.log('render() 抛异常:', err ? ('❌ ' + err.message) : '✅ 无'); + if (err) console.log(' 位置:', (err.stack||'').split('\n')[1]); + console.log('#err 文案 :', errEl && errEl.textContent ? ('❌ ' + errEl.textContent.slice(0,160)) : '✅ 空(未报错)'); + console.log('#proj 产物长度 :', outHtml.length); + console.log('产物含 accRow :', outHtml.indexOf('accRow') >= 0 ? '✅' : '❌'); + console.log('产物含 完成情况:', outHtml.indexOf('完成情况') >= 0 ? '✅' : '❌'); + const m = outHtml.match(/(\d+)\s*(?:\/|/)\s*(\d+)\s*通过/); + console.log('通过率数值 :', m ? m[0] : '(未渲染出)'); + + /* 🔴🔴 2026-10-05 加:**静默错文案**这一族(⛔ 不抛异常,只画错值 ⇒ 上面那些断言全绿漏过)。 + 用户报障逐字:「看板中目标还是空的 本项目 目标(未声明主题)(未声明目标)」。 + 真因=目标数据在 `d.project` / `d.goal` 下,而代码裸写 `d.short` / `d.criteria` / `d.id` … + ⇒ 取到 `undefined` ⇒ **悄悄**显示兜底文案与 '—',⛔ 一句异常都不抛。 + 🔴 判据=**拿快照里的真值去比对渲染产物**("没炸" ≠ "画对了")。 */ + let silent = 0; + ['(未声明主题)', '(未声明目标)'].forEach((t) => { + if (outHtml.indexOf(t) >= 0) { console.log('兜底文案 : ❌ 出现了 ' + t + ' ⇒ 取值层级错'); silent++; } + }); + if (!silent) console.log('兜底文案 : ✅ 未出现'); + const _pj = snap.project || {}, _gj = snap.goal || {}; + const _title = _gj.title || _pj.title; + /* 🔴🔴 2026-10-05 **「主题真值」口径已改**(用户逐字:「目标 vibe-product 提取 … + 改为 vibe-product 目标:提取 …」)⇒ 目标行**不再**渲染 `goal.short`(主题简称), + 改为渲染【所属工作区真实目录名 `ws_name`】+【目标全称 `goal.title`】。 + ⚠️ 旧断言查的是 `goal.short` ⇒ 本区格(`goal.short='本机协作'` ≠ `ws_name='ai1net-dsh-server'`) + 会**假红**;而 peer 格恰好两者同字面 ⇒ **假绿**。⇒ 旧断言已失效,⛔ 不许留。 + ✅ 新判据=**双查**:pill 必须画出 `ws_name`,标题必须画出 `title`(各查一次,互不遮掩)。 */ + const _wsn = String(snap.ws_name || '').trim(); + if (_wsn) { + if (outHtml.indexOf(_wsn) >= 0) console.log('工作区真值 : ✅ 渲染出「' + _wsn + '」'); + else { console.log('工作区真值 : ❌ 快照有 ws_name「' + _wsn + '」却没渲染出来'); silent++; } + } else { + console.log('工作区真值 : ⚠️ 快照没有 ws_name(后端没给?目标行 pill 会退化成「—」)'); + } + if (_title) { + if (outHtml.indexOf(_title) >= 0) console.log('目标真值 : ✅ 渲染出(' + _title.slice(0, 24) + '…)'); + else { console.log('目标真值 : ❌ 快照有 title 却没渲染出来'); silent++; } + } + /* 🔴🔴 2026-10-05 加:**目标行必须是「目标 [工作区] 标题」这个形态**(用户逐字: + 「vibe-product 目标:提取 …」)。 + ⛔ 判据是**产物形态**,不是"两个值分别出现过" —— 两个值都渲染了,但顺序/标签错了 + (比如 pill 跑到标题后面、或「目标」标签丢了)同样不合用户口径。 + ✅ 真跑:拿**真产物**,去标签后**按令牌顺序**咬住 `目标` → `ws_name` → 标题。 + 🔴🔴 坑(本轮实测):⛔ **不能**用 `/<\/?[^>]*>/g` 去标签 —— 标题里带 `https://…` + 时,正则会把 `<` 到下一个 `>` 之间整段吃掉(`//www.tiaoyue.com/ 的设计风格…` 全没了) + ⇒ `indexOf(标题)` 恒 -1 ⇒ **假红**("标题没渲染"其实渲染了)。 + ⇒ ✅ 只剥**本文件已知的真实标签**(`<b>`/`<span …>`/`<div …>` 之类), + ⛔ 不用宽泛的 `<[^>]*>`。 */ + if (_wsn && _title) { + const _flat = outHtml + .replace(/<\/?(?:b|i|u|em|strong|span|div|code|ul|li|a|br|p|small|sup|sub|svg|path|canvas)(?:\s[^<>]*)?\/?>/gi, ' ') + .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') + .replace(/\s+/g, ''); + const _needle = _title.replace(/\s+/g, ''); + const _iLead = _flat.indexOf('目标'), _iWs = _flat.indexOf(_wsn), _iTi = _flat.indexOf(_needle); + if (_iLead >= 0 && _iWs > _iLead && _iTi > _iWs) { + console.log('目标行形态 : ✅ 目标 → ' + _wsn + ' → 标题(顺序正确)'); + } else { + console.log('目标行形态 : ❌ 期望「目标 → 工作区 → 标题」,实得 idx(目标)=' + _iLead + + ' idx(工作区)=' + _iWs + ' idx(标题)=' + _iTi); + silent++; + } + } + if (_pj.id) { + if (outHtml.indexOf(String(_pj.id)) >= 0) console.log('目标 id : ✅ ' + _pj.id); + else { console.log('目标 id : ❌ 没渲染(criteria/id 取错层级)'); silent++; } + } + /* 🔴🔴 2026-10-05 加:**「任务类别」整行必须不在版面上**(用户逐字: + 「任务类别(声明任务类别)这个没用就删除 看着烦」)。 + ⚠️ 判据是「**版面上没有**」而不是「函数里没有」—— 所以拿 `renderProject()` 的**真产物**判, + ⛔ 不 grep 源码(源码里 `'任务类别'` 字样还在:`?` 提示里那句「任务类别的来源」**必须保留**)。 + ✅ 同时钉住「`?` 提示里那条来源仍在」⇒ 防**删过头**(把该留的信息一起删了)。 */ + let dashOk = true; + if (outHtml.indexOf('>任务类别<') >= 0 || outHtml.indexOf('(未声明任务类别)') >= 0 + || outHtml.indexOf('class="topics"') >= 0) { + console.log('任务类别行 : ❌ 版面上还有(用户已要求删除)'); + dashOk = false; + } else { + console.log('任务类别行 : ✅ 版面已无该行(按用户口径删除)'); + } + if (_tip.indexOf('任务类别的来源') >= 0) { + console.log('类别信息留存 : ✅ `?` 提示里仍有「任务类别的来源」(⛔ 没删过头)'); + } else { + console.log('类别信息留存 : ❌ `?` 提示里也没有了 ⇒ 删过头(类别来源无处可查)'); + dashOk = false; + } + /* 🔴🔴🔴 2026-10-05 加:**主会话 chip 不许画成「⚠ 无主会话」**(用户报障逐字: + 「peer 工作区的主会话还是找不到对应的吗」)。 + ⛔ 判据不是"源码里有没有这个词" —— 源码**必然**有那句兜底文案(`board.html:1353`), + 所以只能查**渲染产物**:快照的 `project.main_by_topic` 有值 ⇒ 产物里就该有 id8; + 只有**取不到**时才允许出现「无主会话」。 + ✅ 双向断言:**快照有值 ⇒ 产物不许说"无主会话"**;**快照确实空 ⇒ 必须说"无主会话"** + (⛔ 否则把兜底整个删掉也能"绿",那是假绿)。 */ + const _mbt = ((snap.project || {}).main_by_topic) || {}; + const _hasMain = Object.keys(_mbt).some(k => { + const v = _mbt[k]; + return (typeof v === 'object' && v && v.sid) || (typeof v === 'string' && v); + }); + const _said = outHtml.indexOf('无主会话') >= 0; + if (_hasMain && _said) { + console.log('主会话渲染 : ❌ 快照有 `main_by_topic` 却仍画「无主会话」'); + dashOk = false; + } else if (!_hasMain && !_said) { + console.log('主会话渲染 : ❌ 快照无主会话,产物也没提「无主会话」(兜底被删?)'); + dashOk = false; + } else { + console.log('主会话渲染 : ✅ ' + (_hasMain ? '快照有主会话 ⇒ 产物未画兜底文案' + : '快照确无主会话 ⇒ 如实画了兜底文案')); + } + console.log('静默错文案 :', silent ? ('❌ ' + silent + ' 项') : '✅ 无'); + + let ok = !err && !(errEl && errEl.textContent) + && outHtml.indexOf('accRow') >= 0 && outHtml.indexOf('完成情况') >= 0 + && (typeof sandbox.renderProject==='function') + && silent === 0 && dashOk; + if (WANT_PASS && (!m || m[0].replace(/\s/g,'') !== WANT_PASS.replace(/\s/g,''))) { + console.log('期望通过率 : ❌ 应得 ' + WANT_PASS + ',实得 ' + (m?m[0]:'—')); + ok = false; + } else if (WANT_PASS) { + console.log('期望通过率 : ✅ ' + WANT_PASS); + } + console.log('\n' + (ok ? '✅ 真跑通了(render 真被调用、零异常、#proj 真渲染出完成情况、无静默错文案)' : '❌ 仍有问题')); + process.exit(ok ? 0 : 1); +} + +// 快照来源两选一:盘上文件(自测/离线)优先,否则打活服务。 +if (SNAP_FILE) { + let raw; + try { raw = fs.readFileSync(SNAP_FILE, 'utf8'); } + catch (e) { console.log('读盘上快照失败:', e.message); process.exit(1); } + run(JSON.parse(raw)); +} else { + http.get(SNAP_URL, res => { + let buf = ''; res.on('data', c => buf += c); + res.on('end', () => { run(JSON.parse(buf)); }); + }).on('error', e => { console.log('取快照失败:', e.message); process.exit(1); }); +} diff --git a/session-mechanism/assets/board.html b/session-mechanism/assets/board.html new file mode 100644 index 0000000..d064574 --- /dev/null +++ b/session-mechanism/assets/board.html @@ -0,0 +1,1831 @@ +<!DOCTYPE html> +<html lang="zh-CN"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width,initial-scale=1"> +<title>多会话协作 · 实时架构看板 + + + + +
+
+

多会话协作 · 实时架构看板

+
+ +
+ +
+ + + +
+
+
+ +
+ + + + + + +
+

本项目

+ ? + 上面每一格 tab 是一个需求目标;这一块画的是当前那格的目标。
+ 跟着 tab 走的还有:协作架构、需求台账、会话明细 —— 因为它们都按"任务类别"归属到某个目标。
+ 上面那一排「前置 / 队列通知」是整个工作区的,⛔ 不随 tab 变。
+ 怎么多一格:在收件箱的 goals/ 目录里放一份与 goal.json 同字段的 + <目标id>.json,刷新即多一格。只有一格时,就是本工作区只登记了一个目标。
+ 看板只读:后台隔一会儿生成一份快照,页面直接读缓存(间隔见「协作架构」那块的问题说明)。 +
+ +
+
+ +
+

协作架构用户 · 会话 · 任务会话 · 程序 · WorkBuddy

+ +
+ — + 延迟 — +
+ ? + 🔴 2026-10-03 改版:会话只剩两类(用户逐字:「让你把 架构图里面的 唤醒和跟进 以及 + 上报都删除」)—— 唤醒会话 / 跟进会话 / 队列上报机制整套退役,图上整行删除 + (⛔ 不是画成灰格占位)。⇒ 图形顺序=用户 → 主会话 → 任务会话 → 常驻程序。
+ 主会话只管判断 + 派活,⛔ 只由用户触发(图上指进主会话的线只有上面那一条)。
+ 虚线大框=分组(⛔ 不是节点、⛔ 不是"新的一层"):上面框住主会话, + 下面框住任务会话;两个大框之间只有一条线 = ① 派活(⛔ 不从主会话往每格画射线)。
+ 🔴 ① 派活的特别之处:它是主会话建一条自动化排期、宿主到点开新会话 —— + 这是唯一能开新会话的通道(钩子开不了会话)。
+ 主会话下方那一排是任务会话 —— 一格一条,横着排、有几条画几条, + 一条都没有时画个空框写明「暂无协作目标」。
+ 任务类别是同一个工作区里用会话名称前缀区分的(每个类别各有自己的主会话)。
+ 会话共两类:主会话 · 任务会话,靠标题两级前缀区分、⛔ 不看目录;
+ 某条会话撞到上限时由它自己建出的接续是形态、⛔ 不是第 3 类 + (它继承被接续那条的角色)。
+ 协作与投递按定案一直运行(常驻),宿主钩子只作补充;⛔ 不再用"自动任务当闹钟"。 + ⚠️ 唤醒时钟=常驻程序(--supervise),⛔ 不再靠"唤醒会话"那一套。
+ 状态都是实读的,会话读 WorkBuddy 的库,程序读它自己留下的时间记录。哪一格停着不动,看框的颜色就知道。
+ 看板只读。后台每 — 秒生成一份快照,页面直接读缓存。 +
+
+
+ 有会话在跑 + 有件没人在跑 + 空闲 / 件已全完 + 已停 + WorkBuddy +
+ +
+
+ +
+ +

前置

? + 看什么、叫什么名字,由**使用方**的看板扩展(`board_ext.py`)决定。
+ 组件名一律写成「系统 · 模块 · 功能名」,不用自造简称。
+ 状态只做探测:读组件自己留下的时间记录和日志,不发请求、不改任何东西。 +
+

队列通知 / 握手

+
+ +
+

需求台账tasks.json

+

会话明细本项目 · 含历史

? + 分工看架构图第三层。本表列本项目全部会话,含已完成的。 +
+
+
+ + + + diff --git a/session-mechanism/assets/design-tokens.css b/session-mechanism/assets/design-tokens.css new file mode 100644 index 0000000..d2efdec --- /dev/null +++ b/session-mechanism/assets/design-tokens.css @@ -0,0 +1,307 @@ +:root { + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 24px; + --space-6: 32px; + --space-7: 48px; + --space-8: 64px; + + --text-xs: 12px; + --text-sm: 13px; + --text-base: 14px; + --text-md: 16px; + --text-lg: 20px; + --text-xl: 24px; + --text-2xl: 32px; + + --weight-normal: 400; + --weight-medium: 500; + + --leading-body: 1.6; + --leading-heading: 1.3; + + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-pill: 999px; + + --duration-fast: 120ms; + --duration-base: 200ms; + --ease: cubic-bezier(0.2, 0, 0.2, 1); + + --z-base: 0; + --z-sticky: 10; + --z-dropdown: 20; + --z-overlay: 30; + --z-modal: 40; + --z-toast: 50; + + --font-sans: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; + --font-mono: ui-monospace, "Cascadia Code", Consolas, monospace; +} + +:root, +[data-theme="light"] { + --color-bg-page: #f7f7f5; + --color-bg-surface: #ffffff; + --color-bg-subtle: #f1efe8; + --color-bg-inverse: #2c2c2a; + + --color-text-primary: #2c2c2a; + --color-text-secondary: #5f5e5a; + --color-text-tertiary: #6f6e6a; + --color-text-inverse: #ffffff; + + --color-border-subtle: rgba(0, 0, 0, 0.08); + --color-border-default: rgba(0, 0, 0, 0.15); + --color-border-strong: rgba(0, 0, 0, 0.3); + + --color-accent: #185fa5; + --color-accent-hover: #0c447c; + --color-accent-subtle: #e6f1fb; + --color-on-accent: #ffffff; + + --color-success: #0f6e56; + --color-success-subtle: #e1f5ee; + --color-warning: #854f0b; + --color-warning-subtle: #faeeda; + --color-danger: #a32d2d; + --color-danger-subtle: #fcebeb; + + --color-focus-ring: #185fa5; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08); +} + +[data-theme="dark"] { + --color-bg-page: #1b1b19; + --color-bg-surface: #262624; + --color-bg-subtle: #2f2f2c; + --color-bg-inverse: #f1efe8; + + --color-text-primary: #f1efe8; + --color-text-secondary: #b4b2a9; + --color-text-tertiary: #9a9890; + --color-text-inverse: #2c2c2a; + + --color-border-subtle: rgba(255, 255, 255, 0.1); + --color-border-default: rgba(255, 255, 255, 0.18); + --color-border-strong: rgba(255, 255, 255, 0.32); + + --color-accent: #85b7eb; + --color-accent-hover: #b5d4f4; + --color-accent-subtle: #0c447c; + --color-on-accent: #042c53; + + --color-success: #5dcaa5; + --color-success-subtle: #085041; + --color-warning: #ef9f27; + --color-warning-subtle: #633806; + --color-danger: #f09595; + --color-danger-subtle: #791f1f; + + --color-focus-ring: #85b7eb; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--color-bg-page); + color: var(--color-text-primary); + font-family: var(--font-sans); + font-size: var(--text-base); + font-weight: var(--weight-normal); + line-height: var(--leading-body); +} + +h1, h2, h3, h4 { + margin: 0; + font-weight: var(--weight-medium); + line-height: var(--leading-heading); +} + +h1 { font-size: var(--text-2xl); } +h2 { font-size: var(--text-xl); } +h3 { font-size: var(--text-lg); } +h4 { font-size: var(--text-md); } + +p { margin: 0; } + +:focus-visible { + outline: 2px solid var(--color-focus-ring); + outline-offset: 2px; +} + +button, +a, +input, +select, +textarea { + font: inherit; + color: inherit; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + min-height: 44px; + padding: 0 var(--space-4); + border: 1px solid transparent; + border-radius: var(--radius-sm); + font-size: var(--text-base); + font-weight: var(--weight-medium); + cursor: pointer; + transition: background var(--duration-fast) var(--ease); +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-primary { + background: var(--color-accent); + color: var(--color-on-accent); +} + +.btn-primary:hover:not(:disabled) { + background: var(--color-accent-hover); +} + +.btn-secondary { + background: var(--color-bg-surface); + border-color: var(--color-border-default); + color: var(--color-text-primary); +} + +.btn-secondary:hover:not(:disabled) { + border-color: var(--color-border-strong); +} + +.btn-ghost { + background: transparent; + color: var(--color-text-secondary); +} + +.btn-ghost:hover:not(:disabled) { + background: var(--color-bg-subtle); + color: var(--color-text-primary); +} + +.card { + padding: var(--space-5); + background: var(--color-bg-surface); + border: 1px solid var(--color-border-default); + border-radius: var(--radius-md); +} + +.field { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.field-label { + font-size: var(--text-sm); + font-weight: var(--weight-medium); + color: var(--color-text-secondary); +} + +.input { + min-height: 44px; + padding: 0 var(--space-3); + background: var(--color-bg-surface); + border: 1px solid var(--color-border-default); + border-radius: var(--radius-sm); + font-size: var(--text-base); +} + +.input:focus-visible { + border-color: var(--color-accent); + outline: 2px solid var(--color-focus-ring); + outline-offset: 1px; +} + +.input[aria-invalid="true"] { + border-color: var(--color-danger); +} + +.field-hint { + font-size: var(--text-xs); + color: var(--color-text-tertiary); +} + +.field-error { + font-size: var(--text-xs); + color: var(--color-danger); +} + +.stack { + display: flex; + flex-direction: column; +} + +.stack-2 { gap: var(--space-2); } +.stack-3 { gap: var(--space-3); } +.stack-4 { gap: var(--space-4); } +.stack-5 { gap: var(--space-5); } +.stack-6 { gap: var(--space-6); } + +.row { + display: flex; + align-items: center; +} + +.row-2 { gap: var(--space-2); } +.row-3 { gap: var(--space-3); } +.row-4 { gap: var(--space-4); } + +.muted { color: var(--color-text-secondary); } +.hint { color: var(--color-text-tertiary); font-size: var(--text-xs); } + +.truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.skeleton { + background: var(--color-bg-subtle); + border-radius: var(--radius-sm); + animation: pulse 1.2s var(--ease) infinite; +} + +@media (prefers-reduced-motion: no-preference) { + @keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.55; } + } +} + +@media (prefers-reduced-motion: reduce) { + * { + animation: none !important; + transition: none !important; + } +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} diff --git a/session-mechanism/assets/start-supervise.ps1.tpl b/session-mechanism/assets/start-supervise.ps1.tpl new file mode 100644 index 0000000..c06a740 --- /dev/null +++ b/session-mechanism/assets/start-supervise.ps1.tpl @@ -0,0 +1,132 @@ +# collabd supervisor keeper -- NEVER returns on purpose. +# +# 🔴🔴 2026-10-04 修:首行原本多打了三个双引号(即「三连双引号 + #」开头, +# 把整份 `.ps1` 变成了「Python 看着合法、PowerShell 看着语法错」的畸形文件 +# ⇒ 生成物被 PowerShell 判定 9 处语法错、**一秒内秒退** ⇒ 常驻彻底没人续命。 +# ⛔ 这个文件是 **PowerShell**(不是 Python)⇒ 注释一律用 `#`,⛔ 不要三引号。 +# +# ⚙️ 本文件由 `init_workspace.py` **按工作区变量自动生成**(⛔ 请勿手改:要改改模板 + 重跑脚本)。 +# 生成时间:__STAMP__ +# 适用工作区:__WS__ +# +# Why this file exists(⛔ 下面几条是踩出来的,⛔ 别"简化"掉): +# ① 计划任务**只在任务失败(非零退出)时重启**。早前版本末尾直接前台调 pythonw, +# pythonw 死掉后脚本返回 0 ⇒ 任务被判"成功"⇒ RestartCount=999 永不触发 +# ⇒ **常驻死了就永远死着**。⇒ **重启逻辑必须住在脚本里**。 +# ② ⛔ **绝不能用 `& $pyw ...`** 起常驻:pythonw 是 GUI 子系统程序,PowerShell 的 +# 调用运算符**不阻塞**它 ⇒ 立刻返回 ⇒ 脚本以为"collabd 退出了"⇒ 每几秒就重拉 +# ⇒ **叠出一堆重复常驻**。⇒ 用 `Start-Process -Wait`(真阻塞)。 +# ③ **存活判据=心跳,不是退出码**:collabd 发现有活的常驻时会**故意 exit 0**(幂等), +# 那个 0 退出**绝不能**触发重拉风暴。 +# ④ UTF-8 **带 BOM**(⛔ 不带 ⇒ PowerShell 5.1 按 ANSI/GBK 解码 ⇒ CJK 路径被毁 +# ⇒ 任务一秒内 Result=1 退出)。 + +$ErrorActionPreference = "Continue" + +$env:CODEBUDDY_CONFIG_DIR = "__CFGDIR__" +$env:COLLABD_CONFIG = "__COLLABDCFG__" +$env:PYTHONIOENCODING = "utf-8" +$env:PYTHONUNBUFFERED = "1" # stdout 重定向到文件,Python 会块缓冲 ⇒ 不设它诊断全压在 4KB 之后 + +$root = "__WS__" +$pyw = "__PYW__" +$script = "__SCRIPT__" +$hbPath = "__HBPATH__" +$logPath = "__LOG__" +$stampPath = "__STAMP__" +# 🔴 停止标志:机制侧 `--set-life 已完成` ⇒ 写它 ⇒ 常驻优雅退出。 +# ⚠️ 改前缺陷:keeper 不知道它 ⇒ 目标收口后**每 60 秒重拉一次、每次秒退** +# (实测**空转 370 次/6 小时**)⇒ "完成即收工"只做到"停掉常驻", +# ⛔ **没做到"别再拉它起来"** —— 那是同一件事的两半。 +$stopFlag = "__STOPFLAG__" + +$staleSec = 90 # 与 collabd.supervise_alive() 同一陈旧阈值 +Set-Location $root + +function Say([string]$m) { + $ts = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") + Add-Content -LiteralPath $logPath -Value ("[" + $ts + "] " + $m) -Encoding UTF8 +} + +function Get-HbAge { + try { + if (-not (Test-Path -LiteralPath $hbPath)) { return -1 } + $raw = Get-Content -LiteralPath $hbPath -Raw -Encoding UTF8 + if (-not $raw) { return -1 } + $ts = [double](($raw | ConvertFrom-Json).ts) + if ($ts -le 0) { return -1 } + return [int]((Get-Date).ToString("U") - $ts) + } catch { return -1 } +} + +function Wait-HeartbeatFresh([int]$maxWait) { + $waited = 0 + while ($waited -lt $maxWait) { + $age = Get-HbAge + if ($age -ge 0 -and $age -lt $staleSec) { + Say ("another supervisor is alive (heartbeat " + $age + "s old) => standing down") + return $true + } + Start-Sleep -Seconds 5 + $waited += 5 + } + return $false +} + +Say ("keeper up. keeper_pid=" + $PID + " script=" + $script) + +$backoff = 5 +while ($true) { + # ① 循环开头:目标已完成 ⇒ 收工,不再重拉 + if (Test-Path -LiteralPath $stopFlag) { + Say "guard.stop present => target finished; keeper stands down (no more respawn)" + exit 0 + } + $ageBefore = Get-HbAge + if ($ageBefore -ge 0 -and $ageBefore -lt $staleSec) { + if (Wait-HeartbeatFresh 300) { continue } + } + + Say ("spawn collabd --supervise (backoff=" + $backoff + "s)") + $outLog = "__OUTLOG__" + $errLog = "__ERRLOG__" + try { + # -Wait 给的是**真阻塞**(`&` 给不了 GUI 子系统程序);-Redirect* 让证据落盘 + $proc = Start-Process -FilePath $pyw ` + -ArgumentList @("-u", $script, "--supervise") ` + -WorkingDirectory $root ` + -WindowStyle Hidden ` + -RedirectStandardOutput $outLog ` + -RedirectStandardError $errLog ` + -PassThru -Wait + $code = $proc.ExitCode + } catch { + Say ("spawn threw: " + $_.Exception.Message + " => retry in " + $backoff + "s") + Start-Sleep -Seconds $backoff + if ($backoff -lt 60) { $backoff = [Math]::Min(60, $backoff * 2) } + continue + } + + $ageAfter = Get-HbAge + if ($ageAfter -ge 0 -and $ageAfter -lt $staleSec) { + $verdict = "heartbeat still fresh (" + $ageAfter + "s) => idle exit, no respawn yet" + } elseif ($ageAfter -ge 0) { + $verdict = "heartbeat stale (" + $ageAfter + "s) => supervisor really died" + } else { + $verdict = "heartbeat unreadable" + } + Say ("collabd exited (exit=" + $code + ", " + $verdict + ")") + Set-Content -LiteralPath $stampPath -Value ((Get-Date).ToString("yyyy-MM-dd HH:mm:ss")) -Encoding UTF8 + + # ② 退避**期间**标志可能出现 ⇒ 别再睡满一轮(⛔ 漏这里 ⇒ 收工后还会多拉一次) + $waitLeft = $backoff + while ($waitLeft -gt 0) { + if (Test-Path -LiteralPath $stopFlag) { + Say "guard.stop appeared during backoff => keeper stands down early" + exit 0 + } + Start-Sleep -Seconds 5 + $waitLeft -= 5 + } + if ($backoff -lt 60) { $backoff = [Math]::Min(60, $backoff * 2) } +} diff --git a/session-mechanism/install.py b/session-mechanism/install.py new file mode 100644 index 0000000..794b82b --- /dev/null +++ b/session-mechanism/install.py @@ -0,0 +1,573 @@ +#!/usr/bin/env python3 +""" +session-mechanism · 一键配置(换机器只需跑这一个) + +用法 + python install.py --dry-run # 只打印将改什么(settings.json 的 diff),⛔ 不写盘 + python install.py --apply # 真装:写 roots.env → 按声明表接线全局钩子 → 初始化工作区 + python install.py --verify # 装完自检:每个钩子空载荷 rc=0 + collabd --where + selftest + python install.py --uninstall # 还原 settings.json(与装前备份逐字节相同) + python install.py --manifest # 重算 references/manifest.md 的逐文件表(改完包**必跑**) + +设计要点(都由实测倒逼,⛔ 不要"优化"掉) + 1. **自解析**:解释器一律 `sys.executable`;配置目录按 `CODEBUDDY_CONFIG_DIR` 推导。 + ⛔ 不硬编码 python 路径、⛔ 不硬编码盘符 —— 这正是「换机器必碎」的根因。 + 2. **声明表驱动**:本包 **10 条**接线收敛成一张 HOOKS 表(`--verify` 逐条空载荷复测)。 + ⛔ 表里**不含** `decision_bridge.py`(属 `ai1net-decision-laya` 另一条线)—— 它在 settings.json 里 + 另有 4 条、与本包同处一个 `hooks` 段 ⇒ 极易误读成"本包 14 条"(`--dry-run` 会打印被排除条数供目视核对)。 + 3. **幂等**:先删「本包自己的旧条目」再插;装两遍结果相同。 + 4. **可逆**:首次安装先留**原状**备份 `settings.json.bak-session-mechanism-orig`(⛔ 已存在不覆盖), + 其后每次 `--apply` 另写**带微秒**的时间戳备份(防同秒同名互相覆盖); + `--uninstall` **优先**用 `-orig`,还原后做**逐字节**比对,不符即报错。 + 5. **外置根目录**:写 `<包根>/roots.env` —— 因为 `settings.json` 的 hook 条目**没有 env 字段**, + 包内脚本搬一次就会静默指错(历史事故:台账写到别处、测试却全绿)。 + 6. **清单自算**:`--manifest` 只重写 `references/manifest.md` 的**表 + 计数行 + 重算时间**, + ⛔ 不碰上面的「最近改动」散文 —— 那是人写的结论,脚本代笔会把它冲成流水账。 + 包一改就得跑一次,否则表里的字节/md5 立刻过期(本项目已因此误判过两次)。 +""" + +from __future__ import annotations + +import argparse +import difflib +import hashlib +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +PKG = Path(__file__).resolve().parent +ROOTS_ENV = PKG / "roots.env" +LOG = PKG / "install.log" +# 🔴 原状备份的**固定名**(首次安装、且当前 settings.json 无本包痕迹时才写;⛔ 已存在不覆盖)。 +# 有了它,`--uninstall` 不需要"从一堆时间戳备份里猜哪份是原状"。 +ORIG_BAK_NAME = "settings.json.bak-session-mechanism-orig" +BAK_PREFIX = "settings.json.bak-session-mechanism-" +# 🔴 判定"是不是本包的"一律**按脚本名**,⛔ 不用路径片段(`session-mechanism`)—— +# 路径片段在"包被改名 / 拷到别的目录"时**认不出自己**(实测教训:§2 #4)。 +# ⚠️ 这些脚本名**必须连旧落点一起认**(文档库 `07-scripts/` + 工作区 `.workbuddy/tools/`), +# 否则旧接线删不掉 ⇒ 同一件事挂两条钩子、同一秒各跑一次(`agent-operating-rules §10.2` 实测故障)。 +# ⛔ `decision_bridge.py` 不在此列。 +OWN_BASENAMES = ( + "session-log-guard.py", + "lock-guard-hook.py", + "bash-output-guard.py", + "stop-dialog-guard.py", + "skill-load-guard.py", + "wb-result-hook.py", +) + +# ── 声明表:本包负责的钩子(⛔ decision_bridge 不在此表内)─────────────────── +# (事件, matcher, 包内脚本相对路径, 额外参数, 超时秒) +HOOKS: list[tuple[str, str | None, str, list[str], int]] = [ + ("PostToolUse", None, "scripts/hooks/session-log-guard.py", ["-S"], 10), + ("PreToolUse", "^Bash$", "scripts/hooks/wb-result-hook.py", [], 30), + ("PreToolUse", "Write|Edit", "scripts/hooks/lock-guard-hook.py", [], 10), + ("PreToolUse", "Bash|Read", "scripts/hooks/bash-output-guard.py", [], 10), + ("SessionEnd", None, "scripts/hooks/wb-result-hook.py", [], 10), + ("SessionStart", "startup|resume", "scripts/hooks/lock-guard-hook.py", [], 10), + ("UserPromptSubmit", None, "scripts/hooks/wb-result-hook.py", [], 20), + ("UserPromptSubmit", None, "scripts/hooks/stop-dialog-guard.py", [], 10), + ("UserPromptSubmit", None, "scripts/hooks/skill-load-guard.py", [], 10), + ("UserPromptSubmit", None, "scripts/hooks/session-log-guard.py", ["-S"], 10), +] + + +def config_dir() -> Path: + v = os.environ.get("CODEBUDDY_CONFIG_DIR") + if v: + return Path(v) + return Path.home() / ".workbuddy" + + +def settings_path() -> Path: + return config_dir() / "settings.json" + + +def log(msg: str) -> None: + line = f"{time.strftime('%Y-%m-%d %H:%M:%S')} {msg}" + print(line) + try: + with open(LOG, "a", encoding="utf-8") as f: + f.write(line + "\n") + except Exception: + pass + + +def read_roots() -> dict[str, str]: + d: dict[str, str] = {} + if ROOTS_ENV.is_file(): + for ln in ROOTS_ENV.read_text(encoding="utf-8").splitlines(): + ln = ln.strip() + if ln and not ln.startswith("#") and "=" in ln: + k, v = ln.split("=", 1) + d[k.strip()] = v.strip() + return d + + +def detect_workspace(explicit: str | None) -> Path | None: + if explicit: + return Path(explicit).resolve() + for k in ("DSH_WS_ROOT", "COLLABD_WORKSPACE"): + if os.environ.get(k): + return Path(os.environ[k]).resolve() + prev = read_roots().get("DSH_WS_ROOT") + if prev and Path(prev).is_dir(): + return Path(prev) + cwd = Path.cwd().resolve() + for cand in (cwd, *cwd.parents): + if (cand / ".workbuddy").is_dir() and (cand / "state.py").is_file(): + return cand + return None + + +def detect_docs_root(explicit: str | None) -> Path | None: + if explicit: + return Path(explicit).resolve() + if os.environ.get("DSH_DOCS_ROOT"): + return Path(os.environ["DSH_DOCS_ROOT"]).resolve() + prev = read_roots().get("DSH_DOCS_ROOT") + if prev and Path(prev).is_dir(): + return Path(prev) + # 启发式:文档库必须同时具备「05-交接单」与「07-scripts」两个标志目录 + # ⚠️ 只看同名会误选到工作区里的 `dsh-server-docs/` 副本 ⇒ 两个条件都要满足。 + seeds = [Path.cwd(), *Path.cwd().parents] + for k in ("DSH_CODE_REPO",): + if os.environ.get(k): + seeds.insert(0, Path(os.environ[k])) + for s in seeds[:6]: + for cand in (s / "dsh-server-docs", s.parent / "dsh-server-docs"): + if (cand / "05-交接单").is_dir() and (cand / "07-scripts").is_dir(): + return cand.resolve() + return None + + +def py() -> str: + return sys.executable + + +def build_command(rel: str, extra: list[str]) -> str: + script = (PKG / rel).resolve() + parts = [f'"{py()}"', *[f'"{a}"' for a in extra], f'"{script}"'] + return " ".join(parts) + + +def has_our_trace(text: str) -> bool: + """**这条 hook 归不归本包管** —— 按**脚本名**判。 + ⚠️ 必须连旧落点一起认(文档库 `07-scripts/` + 工作区 `.workbuddy/tools/`),否则旧接线删不掉。 + ⚠️ ⛔ 不可拿它当"这份文件是否已装过本包"的判据 —— 真机 settings.json **本来就有**同名脚本 + 挂在 07-scripts 上(实测:6 个脚本名全 True)⇒ 会**永远写不出 `-orig`**、`--uninstall` **永远拒做**。 + 那个判据用 `points_into_pkg()`。""" + return any(b in text for b in OWN_BASENAMES) + + +def points_into_pkg(text: str) -> bool: + """**这份文件是否已被本包接管** —— 有没有钩子**指向本包目录**。 + 🔴 路径**运行时从 `__file__` 推导**(`PKG`)⇒ 包被改名 / 拷到别的目录都认得出自己。 + 这正是 §3.1b 要修的病:⛔ 旧写法硬编码路径片段 `session-mechanism`。 + 旧落点(07-scripts / tools)**不算**"已装本包"。""" + return PKG.as_posix().lower() in text.replace("\\", "/").lower() + + +def is_ours(entry: dict) -> bool: + for h in entry.get("hooks", []): + if has_our_trace(str(h.get("command", ""))): + return True + return False + + +def unique_backup_path(sp: Path) -> Path: + """备份落点:**带微秒**的时间戳 ⇒ 连装两遍不会同秒同名互相覆盖(§6 第 4 条实测踩到)。 + 极端情况下同名仍存在 ⇒ 追加序号,⛔ 绝不覆盖既有备份。""" + ts = time.strftime("%Y%m%d-%H%M%S") + f"-{time.time_ns() // 1000 % 1_000_000:06d}" + cand = sp.with_name(f"{BAK_PREFIX}{ts}") + n = 1 + while cand.exists(): + cand = sp.with_name(f"{BAK_PREFIX}{ts}-{n}") + n += 1 + return cand + + +def make_backup(sp: Path, before_text: str) -> tuple[Path, Path | None]: + """写备份。返回 (本次时间戳备份, 本次新写的原状备份或 None)。 + · 当前 settings.json **尚未被本包接管**(无钩子指向本包)⇒ 另写固定名 `-orig`,已存在则⛔不覆盖。 + · 每次 `--apply` 都另写一份带微秒的时间戳备份(逐次可回滚)。 + """ + orig = sp.with_name(ORIG_BAK_NAME) + wrote_orig: Path | None = None + if not points_into_pkg(before_text): + if orig.exists(): + log(f"⚠ 原状备份已存在,⛔ 不覆盖(若怀疑原件已换,请人工核对):{orig}") + else: + shutil.copy2(sp, orig) + wrote_orig = orig + log(f"已写**原状**备份(本包接管前)→ {orig}") + else: + log(f"ℹ 当前 settings.json 已被本包接管 ⇒ 跳过 `-orig`(原状备份只在首次安装时留)") + bak = unique_backup_path(sp) + shutil.copy2(sp, bak) + log(f"已备份 → {bak}") + return bak, wrote_orig + + +def desired_hooks() -> dict[str, list[dict]]: + out: dict[str, list[dict]] = {} + for event, matcher, rel, extra, timeout in HOOKS: + item: dict = {"hooks": [{"type": "command", "command": build_command(rel, extra), "timeout": timeout}]} + if matcher is not None: + item["matcher"] = matcher + out.setdefault(event, []).append(item) + return out + + +def merge(settings: dict) -> dict: + """先删本包旧条目 → 再插本包新条目;⛔ 其他条目(含 decision_bridge)原样保留。""" + new = json.loads(json.dumps(settings)) + hooks = new.setdefault("hooks", {}) + want = desired_hooks() + for event in set(list(hooks.keys()) + list(want.keys())): + keep = [e for e in hooks.get(event, []) if not is_ours(e)] + keep.extend(want.get(event, [])) + if keep: + hooks[event] = keep + elif event in hooks: + del hooks[event] + return new + + +def write_roots(ws: Path, docs: Path | None, code: str | None, dry: bool) -> None: + lines = [ + "# session-mechanism · 根目录(由 install.py 生成,⛔ 请勿手改)", + "# 说明:settings.json 的 hook 条目没有 env 字段 ⇒ 包内脚本靠本文件定位根目录。", + "# 读取:各脚本头部 `_sm_load_roots()` 以 setdefault 注入 ⇒ **宿主 env 优先,本文件兜底**。", + f"DSH_WS_ROOT={ws.as_posix()}", + f"WB_RESULT_HOOK_WS={ws.as_posix()}", + # 🔴 2026-10-04 换机演练实测后加:`selftest.py` 的「生产工作区」判据要定位 + # **使用方的部署配置**(它验的是生产数据)。⛔ 不可拿 `DSH_WS_ROOT` 拼 —— + # 那条是**自测自己的测试工作区根**(`WS = ... or DSH_WS_ROOT`)⇒ 会把夹具当生产。 + f"COLLABD_PROD_CONFIG={(ws / '.workbuddy' / 'collab' / 'collabd.config.json').as_posix()}", + "CODEBUDDY_CONFIG_DIR=%s" % (os.environ.get("CODEBUDDY_CONFIG_DIR") + or os.path.expanduser("~/.workbuddy")).replace(os.sep, "/"), + ] + # 可选键:仅当宿主 env 里已有值才落盘(⛔ 不猜、⛔ 不写死盘符) + for _k in ("DSH_OVERLAY_LOG_GLOB", "DSH_OVERLAY_DEV_ROOT"): + if os.environ.get(_k): + lines.append("%s=%s" % (_k, os.environ[_k].replace(os.sep, "/"))) + if docs: + lines.append(f"DSH_DOCS_ROOT={docs.as_posix()}") + if code: + lines.append(f"DSH_CODE_REPO={code.as_posix()}") + text = "\n".join(lines) + "\n" + if dry: + log(f"[dry-run] 将写 {ROOTS_ENV}:\n{text}") + return + ROOTS_ENV.write_text(text, encoding="utf-8") + log(f"已写 {ROOTS_ENV}") + + +def init_workspace(ws: Path, dry: bool) -> None: + """由 example 生成 collabd.config.json(已存在则不覆盖 ⇒ 保护现场活配置)。""" + src = PKG / "scripts" / "collabd.config.example.json" + dst = ws / ".workbuddy" / "collab" / "collabd.config.json" + if dst.is_file(): + log(f"工作区配置已存在,⛔ 不覆盖:{dst}") + return + if not src.is_file(): + log(f"⚠ 未找到模板 {src} ⇒ 跳过工作区初始化") + return + try: + cfg = json.loads(src.read_text(encoding="utf-8")) + except Exception as e: + log(f"⚠ 模板解析失败({e})⇒ 跳过") + return + cfg["workspace"] = ws.as_posix() + if dry: + log(f"[dry-run] 将写 {dst}") + return + dst.parent.mkdir(parents=True, exist_ok=True) + dst.write_text(json.dumps(cfg, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + log(f"已写 {dst}") + + +def cmd_apply(args) -> int: + sp = settings_path() + if not sp.is_file(): + log(f"🔴 找不到 {sp} ⇒ 停手") + return 2 + ws = detect_workspace(args.workspace) + if ws is None: + log("🔴 未能推断工作区 ⇒ 请显式 `--workspace <路径>`(⛔ 不猜)") + return 2 + docs = detect_docs_root(args.docs_root) + log(f"包根={PKG} 工作区={ws} 文档库根={docs} 配置目录={config_dir()}") + + raw = sp.read_text(encoding="utf-8") + before = json.loads(raw) + after = merge(before) + + diff = "\n".join( + difflib.unified_diff( + json.dumps(before, ensure_ascii=False, indent=2).splitlines(), + json.dumps(after, ensure_ascii=False, indent=2).splitlines(), + fromfile="settings.json (before)", tofile="settings.json (after)", lineterm="", + ) + ) + print(diff) + excluded = [ + str(h.get("command", "")) + for arr in before.get("hooks", {}).values() + for it in arr + for h in it.get("hooks", []) + if "decision_bridge" in str(h.get("command", "")) + ] + log(f"(目视核对)⛔ 被排除、未被本包触碰的接线条数:{len(excluded)}(应 >0,且都在 decision_bridge 线上)") + + write_roots(ws, docs, args.code_repo, args.dry_run) + init_workspace(ws, args.dry_run) + + if args.dry_run: + log("[dry-run] ⛔ 未写 settings.json") + return 0 + + bak, wrote_orig = make_backup(sp, raw) + sp.write_text(json.dumps(after, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + log(f"已写 {sp}") + with open(LOG, "a", encoding="utf-8") as f: + f.write(f"BACKUP={bak}\n") + if wrote_orig is not None: + f.write(f"ORIG_BACKUP={wrote_orig}\n") + return 0 + + +def cmd_verify(args) -> int: + fails: list[str] = [] + # 🔴 ①②③ 一律在**临时目录**里跑:钩子与 collabd 都会按 cwd 推导"工作区"。 + # 若直接用调用者的 cwd(常见=包根),它们会把**技能包本身**当成工作区, + # 于是在 `<包>/tmp/supervise-inbox/` 里写出运行态日志 ⇒ **污染技能包**(实测踩到)。 + scratch = tempfile.mkdtemp(prefix="sm-verify-") + try: + print("== ① 包内钩子空载荷自检(期望 rc=0)==") + for event, _m, rel, extra, _t in HOOKS: + p = PKG / rel + if not p.is_file(): + fails.append(f"{rel} 不存在") + print(f" 🔴 {event:16} {rel} ← 文件不存在") + continue + r = subprocess.run([py(), *extra, str(p)], input=b"{}", capture_output=True, cwd=scratch) + tag = "ok" if r.returncode == 0 else f"rc={r.returncode}" + if r.returncode != 0: + fails.append(f"{rel} rc={r.returncode}") + print(f" {tag:6} {event:16} {rel}") + + print("== ② collabd --where ==") + collabd = PKG / "scripts" / "collabd.py" + r = subprocess.run([py(), str(collabd), "--where"], capture_output=True, + cwd=scratch, env={**os.environ, **read_roots()}) + out = (r.stdout or b"").decode("utf-8", "replace").strip() + print(f" rc={r.returncode}\n {out[:300]}") + if r.returncode != 0: + fails.append("collabd --where 非 0") + + print("== ③ selftest ==") + st = PKG / "scripts" / "selftest.py" + if st.is_file(): + r = subprocess.run([py(), str(st)], capture_output=True, + cwd=scratch, env={**os.environ, **read_roots()}) + tail = (r.stdout or b"").decode("utf-8", "replace").strip().splitlines()[-6:] + for ln in tail: + print(" " + ln) + if r.returncode != 0 or "FAIL 0" not in " ".join(tail): + fails.append("selftest 未通过(未见 FAIL 0)") + else: + print(" ⚠ 无 selftest.py") + finally: + shutil.rmtree(scratch, ignore_errors=True) + + print("== 结论 ==") + if fails: + for f in fails: + print(" 🔴 " + f) + log(f"--verify 失败:{fails}") + return 1 + print(" ✅ 全绿") + log("--verify 全绿") + return 0 + + +def cmd_uninstall(args) -> int: + sp = settings_path() + orig = sp.with_name(ORIG_BAK_NAME) + ts_baks = sorted(p for p in sp.parent.glob(f"{BAK_PREFIX}*") if p.name != ORIG_BAK_NAME) + + # 🔴 优先级:① 固定名 `-orig`(本包接管前的原状,最可信)→ ② 最早那份「未被本包接管」的时间戳备份。 + # ⛔ 不能取 `sorted(...)[-1]`:`--apply` 每次都备份 ⇒ 最新那份其实是**已装状态**, + # 拿它还原 = 还原了个寂寞,而且脚本自比自还会报"逐字节相同"(**假绿**,上一棒实测撞到)。 + pristine = orig if orig.is_file() else None + if pristine is not None: + log(f"采用固定名原状备份:{pristine.name}") + else: + for b in ts_baks: + try: + if not points_into_pkg(b.read_text(encoding="utf-8")): + pristine = b + break + except Exception: + continue + if pristine is not None: + log(f"⚠ 无 `-orig` 备份 ⇒ 回落用最早的原状时间戳备份:{pristine.name}") + if pristine is None: + log(f"🔴 {len(ts_baks)} 份时间戳备份里没有一份是「未被本包接管」的原状,且无 `-orig` ⇒ 停手(⛔ 不拿已装状态糊弄)") + return 2 + + # 还原前先把"当前状态"留一份 ⇒ `--uninstall` 本身也可回退。 + stamp = unique_backup_path(sp).name[len(BAK_PREFIX):] + pre = sp.with_name(f"{BAK_PREFIX}preuninstall-{stamp}") + n = 1 + while pre.exists(): + pre = sp.with_name(f"{BAK_PREFIX}preuninstall-{stamp}-{n}") + n += 1 + shutil.copy2(sp, pre) + log(f"(还原前存照)→ {pre}") + + shutil.copy2(pristine, sp) + same = hashlib.md5(pristine.read_bytes()).hexdigest() == hashlib.md5(sp.read_bytes()).hexdigest() + clean = not points_into_pkg(sp.read_text(encoding="utf-8")) + log(f"已用 {pristine.name} 还原 {sp}") + log(f" · 与备份逐字节相同:{'✅' if same else '🔴'}") + log(f" · 还原后已无指向本包的钩子:{'✅' if clean else '🔴'}") + return 0 if (same and clean) else 1 + + +# ── --manifest:重算 references/manifest.md 的逐文件表 ────────────────────── +# 🔴 为什么固化进 install.py:包一改,表里的字节/md5 立刻过期 ⇒ **每轮都得重算**。 +# 以前每轮临时手搓一个脚本、跑完即删 —— 既浪费又会漏(本项目已因此误判过两次)。 +MANIFEST = PKG / "references" / "manifest.md" +# ⛔ 不入表:运行日志 + 表自身(写完即失真) +MANIFEST_SKIP = {"install.log", "references/manifest.md"} + + +def _manifest_syntax(p: Path) -> str: + if p.suffix == ".py": + try: + compile(p.read_text(encoding="utf-8"), str(p), "exec") + return "ok" + except Exception: + return "🔴 py" + if p.suffix == ".json": + try: + json.loads(p.read_text(encoding="utf-8")) + return "ok" + except Exception: + return "🔴 json" + return "—" + + +def manifest_files() -> list[Path]: + keep: list[Path] = [] + for p in PKG.rglob("*"): + if not p.is_file(): + continue + rel = p.relative_to(PKG).as_posix() + if rel in MANIFEST_SKIP or "__pycache__" in p.parts: + continue + if p.suffix in (".pyc", ".pyo") or rel.startswith("tmp/") or "/tmp/" in rel: + continue + keep.append(p) + return sorted(keep, key=lambda q: q.relative_to(PKG).as_posix()) + + +def cmd_manifest(args) -> int: + if not MANIFEST.is_file(): + log(f"🔴 找不到 {MANIFEST}") + return 2 + files = manifest_files() + rows: list[str] = [] + bad: list[str] = [] + for p in files: + rel = p.relative_to(PKG).as_posix() + b = p.read_bytes() + st = _manifest_syntax(p) + if st.startswith("🔴"): + bad.append(rel) + rows.append(f"| `{rel}` | {len(b)} | `{hashlib.md5(b).hexdigest()}` | {st} |") + + # 🔴 行尾按**原文件**来:本表历史上是 CRLF,写完变 LF 会成为一次无声的全文件 diff。 + raw = MANIFEST.read_bytes() + crlf = b"\r\n" in raw + lines = raw.decode("utf-8").replace("\r\n", "\n").split("\n") + + # ① 计数行 + for i, ln in enumerate(lines): + if ln.startswith("文件总数:"): + lines[i] = f"文件总数:**{len(files)}** | 语法 / 结构检查失败:**{len(bad)}**" + break + # ② 重算时间(`--note` 给括注;不给则保留原括注,⛔ 不抹掉人写的结论) + stamp = time.strftime("%Y-%m-%d %H:%M") + note = getattr(args, "note", None) + # 🔴🔴 2026-10-01 修一处**逐轮累积的写坏**(实测把这一行套成了 4 层): + # 原写法 `re.sub(r"最近一次全量重算:.*?)", …)` —— `.*?` **非贪婪**,只吃到**第一个 `)`**。 + # `--note` 里一旦出现全角括号(例如「…行(会说出假话)+ …」),第一个 `)` 就是 + # **括注内部**的那个 ⇒ 只替换了前半截,**旧括注的尾巴原样留在原地**, + # 下一轮再套一层 ⇒ 越写越长,新结论和旧结论粘成一句(再看分不清哪句是本次的)。 + # ⇒ 改成**用 `**` 收尾界定整段**(模板里 `**最近一次全量重算:…**` 的那对 `**` 是唯一的), + # `--note` 含什么括号都不受影响。 + # ⚠️ 不给 `--note` 时**仍保留原括注**(那是人写的结论,⛔ 不抹),但顺手把已经累积出来的 + # 多余 `)` 收敛成一个 —— 否则坏行会一直坏下去。 + for i, ln in enumerate(lines): + if "最近一次全量重算:" in ln: + _k = ln.find("最近一次全量重算:") + _end = ln.rfind("**") + _tail = ln[_end:] if _end > _k else "" # 正常=`**`;模板被改过则不吃行尾 + _body = ln[_k + len("最近一次全量重算:"):(_end if _end > _k else len(ln))] + if note: + _body = f"{stamp} ({note})" + else: + _body = re.sub(r"){2,}", ")", re.sub(r"^\S+ \S+", stamp, _body)) + lines[i] = ln[:_k] + "最近一次全量重算:" + _body + _tail + break + # ③ 表:自 `| 包内路径 |` 表头起整段替换(⛔ 上方散文一字不动) + head = next((i for i, ln in enumerate(lines) if ln.startswith("| 包内路径 |")), None) + if head is None: + log("🔴 manifest.md 里找不到 `| 包内路径 |` 表头 ⇒ 停手(⛔ 不猜、不追加)") + return 2 + new = lines[:head] + ["| 包内路径 | 字节 | md5 | 语法检查 |", "|---|---|---|---|", *rows] + out = "\n".join(new) + "\n" + if crlf: + out = out.replace("\n", "\r\n") + MANIFEST.write_bytes(out.encode("utf-8")) + + log(f"--manifest 已重算:{len(files)} 份文件,语法失败 {len(bad)}") + for r in bad: + log(f" 🔴 {r}") + return 0 if not bad else 1 + + +def main() -> int: + ap = argparse.ArgumentParser(description="session-mechanism 一键配置") + g = ap.add_mutually_exclusive_group(required=True) + g.add_argument("--apply", action="store_true", help="真装") + g.add_argument("--dry-run", action="store_true", help="只打印 diff") + g.add_argument("--verify", action="store_true", help="装完自检") + g.add_argument("--uninstall", action="store_true", help="还原 settings.json") + g.add_argument("--manifest", action="store_true", help="重算 references/manifest.md 的逐文件表") + ap.add_argument("--note", help="写进「最近一次全量重算」括注(仅 --manifest 用)") + ap.add_argument("--workspace", help="工作区根(不传则自动推断)") + ap.add_argument("--docs-root", help="文档库根(不传则自动推断/沿用 roots.env)") + ap.add_argument("--code-repo", help="代码仓根(可选)") + args = ap.parse_args() + if args.apply or args.dry_run: + return cmd_apply(args) + if args.verify: + return cmd_verify(args) + if args.manifest: + return cmd_manifest(args) + return cmd_uninstall(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/session-mechanism/references/00-动手前必过.md b/session-mechanism/references/00-动手前必过.md new file mode 100644 index 0000000..56e40be --- /dev/null +++ b/session-mechanism/references/00-动手前必过.md @@ -0,0 +1,114 @@ +# 动手前必过 · 六条(动手层) + +> `pitfalls.md`=**知识**(47 条,问「为什么」时查)。 +> 本档=**动作**(开工前照做)。**⛔ 别开工前通读 pitfalls。** + +同一天栽过四次,同一个形状:**判据锚点自己没先验 ⇒ 推出了错结论。** + +## ⓿ 🔴🔴 说「会怎样」之前,先取证那个后果真的存在 + +> **这条是 2026-10-03 23:20 立的,当天我又犯了一次(10-04 17:1x)。** + +⛔ **不许把自己推测的后果写成事实。** 否则「讲清后果」会退化成 +「**编一个后果、说得更像回事**」——**比原句更坏**(原句至少只是啰嗦;编的后果是**假情报**, +会被当真话用)。 + +**今天(10-04)栽的那次**:我说「没启动脚本 ⇒ 常驻挂在会话进程树上 ⇒ 会话一关就被带走」。 +**实测**:pid 19424 连跑 **24.2 h / 8695 轮**,父进程早已退出(孤儿进程)⇒ **不受会话结束影响**。 +⇒ 我说的后果**不存在**。而**正确答案早写在 `supervise-persistence.md` 里**(还明写 +「⛔ 不能用它论证这种起法也能长期」⇒ 我把它读反了)。 + +✅ **动手前问两句**: +1. 我要写的这个后果,**能不能拿一条读数证明**? +2. 判据在哪?(**先查判据 ⇒ 再写结论**,⛔ 不是反过来) + +📌 同源的第二条红线(也是 10-03 立的): +⚠️ **改文案/解释改到第三轮还说不清 ⇒ 该问的是「这行要不要留」,⛔ 不是继续润色。** +(「看不懂」往往不是措辞问题,是**这行本身就不该在** —— 10-03 与 10-04 两天各撞一次。) + +## ① 查状态 → 问那个程序自己 + +⛔ **不许用 `glob`/手拼路径证明"某文件不存在"**(`glob('**/x')` **不穿透 `.workbuddy` 这类点目录** ⇒ 零命中 ≠ 不存在。今天就是这么把"常驻没跑"报错的)。 + +```python +import collabd +ok, det = collabd.supervise_alive() # ✅ 让它自己判 +hb = collabd.SUP_HB # ✅ 路径问常量 +``` + +| 东西 | 真身 | 程序自判 | +|---|---|---| +| 常驻心跳 | `/.workbuddy/collab/logs/supervise-heartbeat.json` | `collabd.supervise_alive()` | +| 停止标志 | `/tmp/supervise-inbox/guard.stop` | `collabd._guard_says_stop()` | +| 目标状态 | `/tmp/supervise-inbox/goal.json` | `collabd.goal_lifecycle()` | +| 台账四态 | `/tmp/supervise-inbox/tasks.json` | ⛔ 唯一权威,⛔ 别读 `queue.json`(已退役) | +| 配置 | `/.workbuddy/collab/collabd.config.json` | — | + +## ② 引用读数 → 先看它什么时候写的 + +台账 V5 写「PASS 48」,真跑 73;V1 写「pid 8024」,现活 19424。`acceptance_state` 每条带 `_更新`(本例停在 **10-02 13:58**)=**"当时验过",不是"现在仍成立"**。 + +> **凡带「现在/已/还差」的结论,读数一律现取。** pid · 心跳 · 计数 · pass 数 · 端口 · 排期状态——全都会随时间变。 + +## ③ 写判据 → 先让基线全绿,再谈抓得住 + +判据正则 `width:\s*\d` 误伤 CSS 里正当的 `max-width:420px` ⇒ 这条**永远红** ⇒ 跑任何变异都"报红" ⇒ **5/5 全 ✅ 全是假象**。 + +> **恒红比漏网更坏**:它让所有变异都"红" ⇒ 验证作废,而**表面看起来完美**。 +> 跑完变异先问「**基线绿吗**」,不是「几条报红」。 + +三条硬规矩: +1. **基线先全绿** —— 数 `✗` 且 ⛔ 排除「报告型」那些行(既有存量不计入成败) +2. **锚在「结构」或「唯一位置」** —— ⛔ 锚在"文本出现过"上(判 CSS 要**剥注释**+只取那条规则块) +3. **变异脚本带两道护栏** —— `ast.parse` 判据源码 + 必须真找到那条用例(否则"无输出"会被当成"全绿") + +④ 🔴 **每个变异改完先验「被检对象真被改了」**(长度差/md5)—— 今天栽过:脚本里锚点写错 ⇒ +`replace` 什么都没改 ⇒ 变体被 `⏭` 跳过 ⇒ 汇总却按总数打"6/6 全 ✅" ⇒ **整轮验证作废**。 +⇒ **⏭ 跳过必须计入失败**,⛔ 不许从分母里去掉。 + +### 🔴🔴 写了一天纪律还是栽 ⇒ 那就**上机器**(2026-10-04 收口) + +上面 ①~⑤ 是**纪律**。纪律拦不住 —— 我写完当天照样栽。**根因收敛成两条**(⛔ 不是六条各自独立的失误): + +| 根因 | 今天的表现(≥6 次) | 机器抓手 | +|---|---|---| +| **A. 判据与被检对象之间的「连接」没被验证** | 判据里 `lambda` **自己重算**;锚在「文本出现过」;夹具只覆盖顺带跑到的分支;锚点凭记忆写 | `judge_audit.py --audit selftest.py` | +| **B. 验证动作本身出错,而我不检查它** | 变异脚本自己 `IndexError` 却照样打 PASS;只重置一份文件 ⇒ 累积污染 | `judge_audit.py --mutate` | + +⚠️ **两者的共同签名**:**判据绿灯与被检对象状态无关**。所以解法不是「更小心」, +是**让验证器自己的失败变成读数**(rc≠0 且明说原因),⛔ 不许它输出得像结论。 + +```bash +# 🔴 每改完判据必跑(⛔ 别手写变异脚本 —— 手写的今天栽了三次) +python scripts/judge_audit.py --audit scripts/selftest.py # 元体检:扫弱锚点写法 +python scripts/judge_audit.py --mutate <被检文件> '旧文本' '新文本' -k <用例关键字> +# ↳ rc=2 = 变异没生效/等价(⛔ 作废,⛔ 不许当通过)|报红才算数|自动核对还原 md5 +``` + +⚠️ **元规则表也会假红**:`--audit` 扫的是**写法**,分不清「引用教训」与「犯错误」 +(实测把「自建夹具证明 glob 会漏」那条**教训本身**判成了错误用法)⇒ 误报要进 +`FALSE_POSITIVE` 且**写清为什么**,⛔ 不许默默加。 + +## ④ 写沉淀 → 简明,**判据要点一个不许丢** + +用户 2026-10-04 明令:「**沉淀的经验不要记成流水账,要简明扼要有效**」(**这条本身也是经验**)。 +体检:`pitfalls.md` 47 条 **140 KB**,单条最长 **11 KB**;**我当天新写的两条各 3 KB** —— 比真复杂的 P0-41/22/23 还长 ⇒ **拿流水账充数**。 + +> **单条上限 6 KB。超了就是流水账**:论证过程、对比表格、逐条展开,⛔ 全删。 +> **判据要点不许丢** —— 精简后必须**关键词对账**(长度达标但判据被删 = 更坏,假绿)。 + +⚠️ 精简的顺序:**先删论证 → 再压表格 → 最后才动措辞**;⛔ 别一上来就重写(会把判据改丢)。 + +## ⑤ 报结论前 → 四问 + +1. 这个数字是**现取**的,还是从文档/台账抄的? +2. 我判"不存在/没在跑"时,**问过程序自己了吗**(还是只 glob 了)? +3. 判据改完了吗,**基线绿吗**(不是"几条报红")? +4. 🔴 **我写的每个「会怎样」,都有读数证明吗?**(⛔ 拿不出读数的 ⇒ 删掉那句, + 别让它"说得更像回事" —— **编出来的后果比原句更坏**。10-03 立,10-04 又犯。) + +--- + +🔴 **读这份的时机**:开工前 | 报「已完成」之前。**其余时候问「为什么」才去 `pitfalls.md`。** + + diff --git a/session-mechanism/references/01-文档索引.md b/session-mechanism/references/01-文档索引.md new file mode 100644 index 0000000..601d1c1 --- /dev/null +++ b/session-mechanism/references/01-文档索引.md @@ -0,0 +1,53 @@ +# 文档索引 · 干什么事该看哪一篇(★ 2026-10-04 建) + +> 🔴 **本档只解决一件事**:**别通读文档。** 按下表跳,⛔ 不许「先都读一遍」。 +> ⚠️ 落它的起因:文档已 14 篇 + `SKILL.md`,**改流程/改机制时不知道必看哪几篇** +> ⇒ 出现过「**昨天写好的红线,今天换个会话又踩一遍**」(2026-10-03 立的「⛔ 写会怎样前先取证」, +> 只躺在工作区日志里,换会话读不到 ⇒ 10-04 复发)。 + +## ⓿ 🔴 改流程/改机制前的**必读三篇**(顺序别换) + +| 序 | 必读 | 读多久 | 为什么是它 | +|---|---|---|---| +| 1 | **`00-动手前必过.md`** | **3 分钟** | 六条动作红线。⛔ **开工前只读这一篇就够**,⛔ 别通读 pitfalls | +| 2 | **`rules.md`** | 5 分钟 | 现行规则本体(⛔ 不是过程记录;历史在 `pitfalls.md`) | +| 3 | **`manifest.md`** | 2 分钟 | 清单:哪些文件是什么、**哪个是权威**。⛔ 改之前先确认你改的那份是权威 | + +**一句话**:⓿ 三篇加起来 **10 分钟**,能避开今天栽的每一类。 + +## 一、按「你要干什么」跳 + +| 你要干的事 | 必看 | ⛔ 不用看 | +|---|---|---| +| **改流程/改机制/改正则** | `00-动手前必过.md` + `rules.md` + `manifest.md` | `pitfalls.md` 全篇 | +| 说错了话、想找根因 | `pitfalls.md`(**按编号查**,⛔ 别通读) | — | +| 常驻挂了/要开机自启 | `supervise-persistence.md`(唯一权威) | `architecture.md` | +| 看板显示不对 | `collab-detail.md` | `architecture.md` | +| 换机器/装钩子 | `deploy.md` + `install.py` | 其余全部 | +| 会话卡住/日志爆了/抢锁 | `SKILL.md` §1 加载块 | `pitfalls.md` | +| 查某个历史会话干了啥 | `forensics.md` | — | +| 任务图/多会话分工 | `taskgraph.md` + `collab.md` | — | +| 怎么回话/排版 | `03-回复排版-核心块.md` + `02-功能优先协作协议.md` | — | +| 对方甩来一句没头绪的话 | `02-功能优先协作协议.md` | — | +| 只想要速查 | `99-速查清单.md` | — | +| **问「这是什么、为什么这样」** | `architecture.md` | — | + +## 二、🔴 文档四条规则(写文档/改文档时必守) + +1. **分类索引** —— 新增文档必须在这张表里登记(⛔ 没登记 = 别人找不到 = 白写)。 +2. **结论在最前,过程记录在后** —— 读者要的是「现在是什么样」,⛔ 不是「我改了几轮」。 +3. **历史记录按时间倒排** —— **新的在前面,旧的在后面**(⛔ 追加只能往前插,⛔ 不许接在末尾)。 +4. **简明扼要有效** —— **单条 ≤6 KB**;⛔ 论证过程/对比表格/逐条展开全删; + ⚠️ 但**判据要点一个不许丢**(长度达标而判据被删 = **更坏**,那是假绿)。 + +## 三、🔴 为什么「存档」不等于「读得到」(10-04 实证) + +| 档位 | 装什么 | 跨会话可见 | +|---|---|---| +| `references/*.md` + `SKILL.md` | **规矩** | ✅ 技能会自动加载 | +| `.workbuddy/memory/<日期>.md` | **过程记录**(当天做了什么) | ❌ 只有那个工作区翻才看得到 | +| 源码注释 + git 历史 | 细节与来路 | ⛔ 没人会去看 | + +⚠️ **10-04 的教训**:一条红线立在对的地方(工作区日志 197 KB),仍然等于没立 +⇒ **凡是「下次必须做到」的事,必须落在 `references/` 或 `SKILL.md`,⛔ 不能只写日志。** +判据:`selftest.py::t_no_invented_consequence` 量的就是这个(红线在动手层**第一段**)。 diff --git a/session-mechanism/references/02-功能优先协作协议.md b/session-mechanism/references/02-功能优先协作协议.md new file mode 100644 index 0000000..56fca1e --- /dev/null +++ b/session-mechanism/references/02-功能优先协作协议.md @@ -0,0 +1,350 @@ +# 功能优先协作协议(用户只提功能卡 · AI 自主决策) + +> ## 🔴🔴 归属:**2026-10-04 起本档归 `session-mechanism`**(用户定案逐字:「这个属于会话的自决策方法,应该属于会话逻辑」) +> +> **为什么搬**:这套协议答的是「**在会话里做事时,该自己定还是该问用户**」—— +> 而 `session-mechanism` 管的就是「**主会话 ↔ 执行会话怎么协作、活怎么派、结果怎么验**」。 +> 判据落在会话机制里 ⇒ **主会话派活时与任务会话执行时读的是同一份**,⛔ 不会两处口径打架。 +> **provenance**:原技能 `dsh-feature-first`(**已退役**)全文 + `dsh-decision/references/01` +>  (2026-10-04 **逐行搬入,内容守恒**;⛔ 只改档名与 skill 名,**判据正文一字未动**)。 +> ✅ **本档即权威**(2026-10-04 改,用户定案:"只想复制一个技能到别的机器,这些功能都可以正常使用"): +>  **自决策白名单(§2)· 只准提报用户 3 类(§3)· 功能卡 4 问(§1)· 语言转换表(§3.3)** +>  的**判据实体就在本档正文里** ⇒ 只复制 `session-mechanism` 一个技能即可用,⛔ 不依赖任何其他技能。 +> 📌 **来路**(保留以便追溯):本档 2026-10-04 从 `dsh-decision/references/01` 逐行搬入; +>  其判据 2026-09-12 起的「完整版」曾寄放在 `agent-operating-rules §1.6/§1/§2/§2.5`(跨项目通用技能)。 +>  ⇒ **本机若同时装了那个技能,以本档为准**(避免两处各存一份、改一处忘另一处)。 +> ⚠️ **`dsh-decision/references/01` 侧的残留摘要仍在**(那份技能本机也有)⇒ 若两处都读, +>  **改判据以本档为准**;`dsh-decision` 那份视为副本,不同步。 + +--- + + +# dsh-feature-first — 功能优先协作协议 + +> **一句话**:把「事前请示」改成「**默认自主 + 事后可推翻**」。 +> 用户的输入只到**功能层**(谁、在哪、做什么、怎样算成功);**技术层全部由 AI 自主决定并记录**。 + +--- + +## 0. 为什么要有这条协议(复盘证据,2026-09-12) + +### 0.1 事实:用户的时间被技术问题吃掉了 + +对 `dsh-server-docs/04-调整方案/` 全部档案按「触发来源」分类(含「触发」字段的 42 份): + +| 类别 | 份数 | 占比 | 含义 | +|---|---|---|---| +| **用户的功能 / 交互需求**(用户的主场) | 15 | 36% | 16 / 18 / 19 / 31 / 34 / 36 / 37b / 38b / 53 / 54 / 56① / 57 / 60 / 61 / 62 | +| **技术类**(用户被拉进技术判断,或 AI 做技术排查) | 17 | 40% | 12 / 14 / 22 / 23 / 26 / 27 / 29 / 32 / 33 / 35 / 37a / 38a / 42 / 55 / 58 / 64 / 65 | +| **用户报障**(用户只能看到现象,被迫当测试) | 10 | 24% | 15 / 17 / 21 / 24 / 25 / 30 / 49 / 51 / 56② / 59 | + +⇒ **技术类 + 报障类 = 27/42 ≈ 64%** —— 用户 2/3 的注意力花在自己不擅长的领域。 +> 复核命令:`cd dsh-server-docs/调整方案 && grep -Hn "触发" *.md`(分类可按档案号逐一核验) +> **⚠️ 该比例是快照,会随档案增长变化;引用时须重新取数。** + +### 0.2 五个根因(按影响排序) + +| # | 根因 | 表现 | +|---|---|---| +| **1** | **技术问题被"决策化"了** | AI 把"我不确定选哪条技术路径"包装成「决策点」提报用户:走 CF 代理还是改 DNS、要不要 watchdog、内存能否降到 512M、要不要 enablePatch。用户没有判断依据,只能凭感觉点头 | +| **2** | **需求没有"规格层"** | 用户说「AI 生成的文件看不到、下不了」→ AI 直接跳到技术方案(新增端点 + 注入脚本 + 面板)。中间的"谁在什么场景要看到什么、怎样算成功"没有沉淀 → 每次都要重新谈技术 | +| **3** | **报障驱动** | 平台上线后用户输入大量来自"坏了"而不是"我要什么";报障的根因排查天然是技术沟通 | +| **4** | **没有"技术默认值"** | 项目其实有大量技术原则(红线 R1-R8、只准收窄、最小代价路径、能一行代码就不改平台),但**没有一条"AI 自主技术决策的授权边界"** → 每次都重新分析、重新提报用户 | +| **5** | **交付语言是技术语言** | AI 汇报用 commit / 文件 / md5 / 端点;用户只能靠技术语言参与验收 → 反过来推高了技术沟通量 | + +--- + +## 1. 用户的输入:只填「功能卡」4 问 + +> 用户**不需要**描述技术方案、不需要指定改哪个文件、不需要选技术路径。只回答这 4 件事(缺的 AI 自己补默认值并标注)。 + +``` +1. 谁用? (角色:admin / 普通用户 / 两者) +2. 在哪用? (哪个界面:门户某页 / 实例内设置 / 会话页 / 右下角…) +3. 要做什么? (用户视角的动作与结果,一句话) +4. 怎样算成功? (用户能亲眼看到/亲手验证的验收点) +``` + +**示例(用户侧的正确写法)** +> 「普通用户要能在设置里看到自己能启停哪些功能插件,勾选后确认,重启后生效。」 + +**示例(不要这样写)** +> ~~「在 business-plugins 的 settings.section 里加一个 id、调 /api/plugins/mine/apply、重启实例生效」~~ +> —— 这是实现,不是需求。 + +**AI 侧**:拿到功能卡后,缺什么自己定;定完在档案里记「技术选择」段(见 §5)。 + +--- + +## 2. AI 自主决策的 9 类白名单(**永不问用户**) → **本档 §2 即权威** + +> ✅ **判据实体=本档下方那张表**(2026-10-04 起;⛔ 不再是"内联摘要",它就是权威)。 +> 命中以下任一类 ⇒ **直接定、直接做**,只在档案里记一句「我选了什么(可推翻)」—— + +| # | 类别 | +|---|---| +| 1 | 技术选型(API / 表 / 库 / 协议 / 存储格式) | +| 2 | 实现路径(改哪一层、配置还是改码、复用哪个扩展点) | +| 3 | 命名与结构(变量 / 路由 / 表名 / 目录 / 文件拆分) | +| 4 | 性能与资源调参(内存 / 并发 / 超时 / 缓存 TTL / 退避) | +| 5 | 部署与同步流程(scp 姿势 / 权限位 / 行尾 / 对账 / 脚本) | +| 6 | 排查方法(journalctl 还是埋点、怎么复现与取证) | +| 7 | 版本与依赖(选版本 / 预装还是按需拉 / 打包方式) | +| 8 | 兼容与降级(显式抛 `unsupported`,不假装支持) | +| 9 | 文档与档案的技术内容(编号 / 模板 / 章节 / 脚本实现) | + +**判断口诀**:**「用户能不能从功能视角判断这个选项的好坏?」** 能 → 可提报用户(见 §3);不能 → **这就是 AI 的工作,不要问**。 + +## 3. 只准提报用户的 3 类(+ 语言转换表) + +> **唯一判据(用户 2026-09-12 原话):只问「超过现有判断方法边界」的问题。** +> 边界内 → 一律自决策;边界外(§3.1–§3.4)→ 必须问。**没有第三条路。** + +### 3.1 (a) 功能语义分叉 —— 选项之间存在**用户能感知的差别** + +例:技能随插件包投放 vs 走平台共享层(差别 = 用户"要不要分开管理");新开会话 vs 迁移老会话(差别 = 用户"要不要重开一个");提示 vs 自动改档位(差别 = "AI 会不会悄悄改我的设置")。 + +### 3.2 (b) 红线门禁 —— 必须用户点头 + +- **扩大**权限/可见面(R5) +- **批量 / 全仓写入**(R7,>10 文件) +- **不可逆的破坏性操作**(删数据 / 迁 DB / 清目录 ⇒ 先出清单) +- ⛔ **「中断在线用户」已不是门禁**(2026-09-13 用户明令 + 2026-09-16 收口):`R8` 原文 —— **47 是「开发环境服务器」,不用担心中断用户** ⇒ 重启服务 / 停实例 scope / drain / 改配额·env / 改 nginx·nft **均可直接做**,只需**动手前一句话说明**在动什么。 + ⚠️ **原表述「只有真会中断的(重启服务 / 停实例 / drain / 改配额·env / 改 nginx·nft)才提报用户」是会话 `ddea70b7` 提报用户的直接依据** —— AI 据此把"106 旧控制面要不要停"拿去问用户,而**用户随后两次(U5 / U7)自己给出"删除"**。⛔ 别再拿"重启 / 停实例 / nginx"当提报用户理由。 +- ⚠️ **判据:按「实际影响面」判,不按「动作名字」判**(2026-09-13 用户纠正)—— 看到「部署 / 上线 / 生产 / 铺包」就提报用户是**过度套用**: + **不中断在线用户的上线**(传产物 / 换 profile 包 / 改静态页 / 候选池投放)**属边界内的执行细节 ⇒ 做完即上线,自决策**。详见 `dsh-decision-method` **U20 / X9**。 +- 🔑 **"平台级" ≠ "别人的"**(2026-09-16 收口):`dshs*`·`dsh-*` systemd 单元、`/var/lib/dshs/**`、nginx·nft、端口,**在我们自己的 47 / 106 / 本工作区上 ⇒ 本平台自己的资源 ⇒ 直接做**。只有**别人的 / 归属不明**的才"只报告不动手"。**判据看"归属",不看"是不是平台组件"。** + +### 3.3 提报用户格式(**强制**) → **本档 §3.3 即权威**(下方即全表) + +> **完整对照表已收敛到通用技能**。**最小摘要**(3 条铁律): +> ① **提报给用户时不出现**包名 / 环境变量 / 文件路径 / commit / API 路径 / 代码标识符 —— 出现即是没转换。 +> ② 翻译方向 = **从"技术选项"翻成"用户能感知的差别"**。例:不问「要不要启用 `enablePatch`」,而问「要让用户**自己装插件**,还是只由管理员统一装?」 +> ③ **一轮最多一个问题**,且**同类不连问两次**。 + +### 3.4 (c) 超出决策方法边界的事 —— **必须问**(用户 2026-09-12 明确) + +> **用户原话**:「有超过现有决策方法边界的事情,还是需要问的。」 +> 即:**方法覆盖得到 → 自己定;覆盖不到 → 必须问**。这条是 §2 的**例外出口**,也是闸门的 fail-open 依据。 +> ⚠️ 判据是「**有没有客观可判的优劣**」:有 → 方法能判,自己定;没有 → 那是用户的取向,必须问。 + +| # | 边界外的类别 | 例子 | 为什么方法判不了 | +|---|---|---|---| +| 1 | **业务目标与优先级** | 先做哪个功能、这个功能要不要做、值不值得投入 | 方法只能判"**怎么做得最优**",判不了"**该不该做**"。
⚠️ **出口(2026-09-16 加)**:**方向已定**(用户已说"要做这几个功能 / 该谁处理就开发对应功能")时,"**先做哪个**"若候选有**客观排序**(如"先做能闭环真实缺陷的那项"、"3 项 P0 阻塞真实场景")⇒ **属边界内,自决策**。只有"几个都该做、用户对**节奏/取舍**有偏好"才提报用户。
**实证**:会话 `ddea70b7` 把"D1–D6 先做哪些"归进本类 ⇒ 提报用户 ⇒ 用户回「**按照你的规划执行**」(AI 自己其实已排出顺序并写了"我倾向先 D1+D2")| +| 2 | **成本与资源承诺** | 买云资源 / 开第三方账号 / 花钱买额度 | 涉及用户的钱,不是技术优劣问题 | +| 3 | **对外承诺与合规** | ICP 备案、资质、合同、对外 SLA、对外品牌文案 | 法律与商业后果,超出工程判断 | +| 4 | **需要用户提供的第三方凭据 / 审批** | API Key、DNS 权限、企业审批、账号授权 | 只有用户有 | +| 5 | **用户体验偏好(无客观优劣)** | 审美与文案语气、默认值取向、提示语措辞 | 没有"更优",只有用户"更喜欢" | +| 6 | **影响面超出本平台** | 会波及其他系统 / 他人数据 / 不可逆的对外影响 | 影响半径超出我能判断的范围 | +| 7 | **红线门禁**(R5 扩大 / R7 批量>10 / 不可逆破坏性操作) | — | 安全与影响面变更**必须**用户知情同意。⛔ **不含 R8** —— "中断在线用户"已于 2026-09-13 修正(开发环境服务器 ⇒ 直接做,只须动手前一句话说明) | +| 8 | **方法确实判不准** | 两边都无依据、事实不足以判断 | **宁可问,不要卡死** | + +**提报用户方式**:这 8 类**照实说人话**说明「你为什么需要他决定」,不要包装成技术选项。 + +### 3.5 方法本身的演进 —— 改为自决策(用户 2026-09-12 明确) + +> **用户原话**:「以后决策方法的问题就不需要再问了。」 + +- **方法的日常应用不问**:边界内的一切判断,按 §2 + `dsh-decision-method §4.4` 自己定。 +- **方法本身的修订也不问**:发现方法有缺口 / 有反例 / 需要加规则时,**直接改、直接记录**(改完在交付回执里说一句"我更新了方法:因为 X"),不要拿"要不要加这条规则""这么写好不好"去问用户。 +- **唯一例外**:方法修订若**扩大**了 AI 的自主权或收窄了用户的门禁 → 属边界外的第 1/7 类,**必须问**。 + +### 3.6 ⛔ 拆包提报用户:红线问题**不得**与技术方案捆在一起问(2026-09-12 实证) + +> **实证**(会话「任务执行2」14:39):AI 把「**要不要现在重启服务**(R8,该问)」和「**用 A 还是 B 实现**(技术项,不该问)」**捆成一个提问**,用户被迫先读懂两套技术方案才能回答那个红线问题 +> → 用户的体感依然是"**又在让我确认技术问题**"。**这才是"设置了却没用"的真正形态**:不是问得太多,而是**把该问的和不该问的混在一次提问里**。 + +**三条规则**: + +1. **剥出红线问题单独问**,且只问用户能判断的维度:**要不要现在动生产 / 影响谁 / 断多久 / 能否避开**。 +2. **技术形态自己定**,作为**已定项**写进回复("我按 B 做,因为…;可推翻"),**不要做成选项让用户选**。 +3. **一轮最多一个问题**;同一个问题**不要连问两次**(实证:11:16 与 11:42 两次问"用哪个账号验收",第二次只是细化 → 本可合并成一次,或直接自定)。 + +**该会话 6 次提报用户的逐条判定(反例对照)**: + +| 时间 | 问的什么 | 判定 | +|---|---|---| +| 10:34 | 21 处 Windows 专属指引怎么处理(一并改 / 只改单子 / 先出清单) | ❌ **纯技术项**(范围 + 实现路径)——该自己定 | +| 10:21 | 行尾 CRLF 怎么处理 | ✅ 该问(命中 **R7**:批量换行符转换需授权) | +| 11:16 / 11:42 | 验收用哪个实例 / 账号 | ✅ 该问(影响面)· ❌ **连问两次**,可合并 | +| 13:32 | admin 崩溃怎么修 | ✅ 该问(R8 + 跨会话)· ❌ 选项里混了"我拆 / 转给对方 / 只给命令"三种**技术路径** | +| 14:39 | 选 A/B 方案 **+** 要不要重启 | ✅ 红线该问 · ❌ 技术方案不该问 → **典型捆包** | + +**正确写法(照这个格式写)**: + +``` +我先按 B 做(平台侧自动摘除坏插件,覆盖所有装法)——**已定,可推翻**。 +但它要重启 dshs: +· 影响谁:当前所有在线用户(刚实测 guest 实例在跑) +· 断多久:数秒;实例"访问即拉起",会话数据不丢 +· 能否避开:可以等空闲;或并入 T04 那批改动只重启一次 +**请定:现在就重启,还是等窗口?** +``` + +--- + +## 4. 报障闭环前置(让用户**不必**报障) + +报障类占 24%,大部分可以从源头消掉。凡涉及"用户会撞上的状态",**必须同时交付**: + +| # | 要求 | 已落地先例 | +|---|---|---| +| 1 | **错误页给人话 + 下一步**,禁止裸 JSON / 英文原文 | 档案 25(`not_running` 不再吐 JSON) | +| 2 | **等待必有可见反馈**(导航路径 + XHR 路径都要有) | 档案 59(3 秒后亮覆盖层) | +| 3 | **状态可自查**(能力清单 / 我的文件 / 档位提示) | 档案 56 | +| 4 | **能自愈就不报错**(401 透明重放、回收后自动恢复) | 档案 24 / 45 / 49 / 51 | +| 5 | **缓存/后台任务的状态可见**("更新于 X 前"、可手动重拉) | 档案 62 | + +> 判断口诀:**「用户遇到这个情况,会不会只能来问我?」** 会 → 先把反馈做出来。 + +--- + +## 5. 答复与交付格式(功能性语言优先) +### 5.1 结论骨架(回答「是否已实现 / 能不能 / 为什么不行」类提问) + +> **2026-09-13 用户纠正原话**:「**需要告诉我的是 是否已实现,如果未实现:为什么不能,需要我拍板可以问我**」 +> 触发场景:用户问「是否已实现」,AI 却用「我自己的失误(一并交代)+ 探针怎么被污染 + 版本流水 + 下一步三步计划」作答 —— **要的结论被埋在第 5 段之后**,用户只能再追问一句。 + +**固定四节,顺序不许换;没有的节整节删掉,不要留空标题:** + +> ⚠️ **「需要你拍板」必须是整条回复的最后一节**(2026-09-15 用户明令:「**要把需要我确认或决策的内容放在 最后,别隐藏在回复内容中间** | **按照有序段落展示**」)—— 它后面**不许再有任何节**,且必须**逐条编号**(有序段落),不写成散文。理由:夹在中间 ⇒ 用户扫不到、漏答。 +> ✅ **配套的反向要求(同一句原话前半段)**:「**能根据决策方法 自行决策的就自决策继续处理**」⇒ **能自决策的不要停下来问**,直接做完并陈述;**只有不能自决策的**(真门禁 / 需你给凭据或窗口)才收进这一节。 +> 🎯 **提报用户标准 = 存在"真取舍"**(2026-09-15 用户明令:「**需要我确认的方案需要说明优点和缺点,现在没法判断,假如只有优点或只有缺点那不需要我判断**」):把候选各写 **优点 + 缺点** —— 某个**只有优点**(明显更优)或**只有缺点** ⇒ **不需要用户判断**,自己拍掉再陈述;只有**各有优有劣、且客观标准分不出高下**才算真取舍,才提报用户,且**必须逐项列出优点与缺点**(只写"差别在哪"不算)。 +> 📐 **候选方案必须"竖排成段"**(2026-09-15 用户明令:「**每个需要我决策的问题的潜在解决方案 A B C 也按照段落式排版,别横着排列**」):**A / B / C 各占一行(各自成段)**;⛔ **不许**写成 `A:… · B:…` 一行横排,⛔ **也不许**把候选做成**表格的列**。段内「优点…;缺点…」连写即可 —— 不必每个字段再拆行,否则撞 §5.4 硬约束 3「每节 ≤7 行」。 + +```markdown +## 判定 +❌ 未实现 / ⚠️ 部分可用 / ✅ 已实现 —— <一句话> + +| 目标 | 状态 | +|---|---| +| <用户列的第 1 项> | ✅/❌ + 半句依据 | +| <第 2 项> | … | + +## 为什么不行(只在有 ❌ 时写;最多 3 层,结论层零技术标识) +- 已经排除的:<今天已经修完、不再是原因的> —— 一句话带过 +- 当前唯一卡点:<一句人话> +- 为什么难(可选):<1–2 句;不确定性要写进**结论句**,例:「还不能说做不到,只能说这一步还没试」> + +## 我接着做 +- 下一步 —— **陈述句,不是征询句** + +## 需要你拍板(**最后一节**;真需要才写,不需要 → 整节删掉;**逐条编号**) + +**1. <问题一句话>** + +**A** —— 优点:…;缺点:… + +**B** —— 优点:…;缺点:… + +我倾向 **A**(一句话理由,可推翻) + +**2. <下一件>** —— 同上 +``` + + +### 5.2 交付回执(已完成功能的交付) + +AI 交付时**先说功能,技术细节折叠在后**: + +```markdown +## 做了什么(功能) +<2-3 句:现在多了一个什么能力,在哪儿> + +## 你现在能看到 +- <具体位置> 出现了 <什么>;点它会发生 <什么> +- 验证方式:<用户亲手可做的一步> + +## 不用你决策的技术选择(已定,可随时推翻) +- <易感知的一条,一句话>(若你希望反过来,说一声即可) +… + +## 技术附录(可选读) +<文件 / commit / md5 / 端点 / 档案号> +``` + +> 第 3 段是这条协议的关键:**把"事前请示"改成"事后可推翻"** —— 用户获得了知情权与推翻权,但不需要在读之前做判断。 +--- + +### 5.3 五条铁律(都来自本工作区的真实失手) + +| # | 铁律 | 反例(实证) | +|---|---|---| +| 1 | **回答主位 = 用户问的那件事**;AI 的进度 / 失误 / 计划**不得占前两节** | 用户问「是否已实现」,AI 先写「五、我自己的失误(一并交代)」+ 版本流水 → 用户被迫再追问一句才拿到结论 | +| 2 | **结论层零技术标识**(版本号 / commit / 包名 / 内部函数名 / 路径 / 探针名)—— 最多放「技术附录」 | `0.2.19 / 0.2.20 / 0.2.23`、`TDZ`、`chr(10)`、`requestOverUnixSocket` 全在主线,用户读不出结论 | +| 3 | **禁止征询式收尾**:「要我接着做吗 / 说一声即可 / 你看怎么弄」—— 下一步**已定**且不命中门禁(现行门禁 = **不可逆破坏性操作**,见 `CODEBUDDY.md §3 R8`)⇒ **直接做**,用陈述句交代。⚠️ 本条禁的是**形态**;**位置**要求见铁律 4 | 「要我现在接着做,说一声即可」—— 把本该自己拍的执行细节又推回用户(同 **X9** 一族) | +| 4 | **能自决策的继续做;不能自决策的收到最后一节** —— 能按决策方法自决策 ⇒ **自决策 + 继续处理**(不为"要不要继续"而停);不能自决策(真门禁 / 需凭据·窗口)⇒ **收进整条回复的最后一节,按有序段落逐条编号**(每条 = 问题 + 选项**优缺点** + 我的倾向),⛔ 不许夹在中间,也不许散在正文里问 | 2026-09-15 用户原话:「**能根据决策方法 自行决策的就自决策继续处理,不能决策的问题和需确认内容放在回复的最后,按照有序段落展示**」—— 待拍板项夹在「进度 + 下一步」之间 ⇒ 用户扫不到、漏答 | +| 5 | **提报给用户前先过「取舍筛」**:把候选各写 **优点 + 缺点** —— ① 某个**只有优点**(明显更优)或**只有缺点** ⇒ **不需要用户判断**,自己拍掉再陈述;② 只有**各有优有劣、客观标准分不出高下**(真取舍)才提报用户;③ 提报给用户时**必须逐项列出优点与缺点**(只写"差别在哪"不算);④ 候选**竖排成段**(A / B / C 各占一行),⛔ 不横排、不做成表格的列 | 2026-09-15 用户原话两段:「**需要我确认的方案需要说明优点和缺点,现在没法判断,假如只有优点或只有缺点那不需要我判断**」+「**每个需要我决策的问题的潜在解决方案 A B C 也按照段落式排版,别横着排列**」—— 此前只写"可感知差别"且**横排**,用户**没法判断** | + +> **自己失误的交代**:只在两种情况下写 —— ① 它**改变了结论**(例:某次改动引入了新问题);② 用户**问根因**。否则放最后一节一行,或先不提。 + +--- + +### 5.4 排版规范(让长回答**可扫读**) → **本包 `references/03-回复排版-核心块.md`(随包,⛔ 不依赖外部)** + +> ✅ **排版契约随包自带** ⇒ `references/03-回复排版-核心块.md`(`reply-style-guard.py` **每轮现读**注入,⛔ 不设冷却)。 +> 完整十条硬约束 + 十四条反模式 + 去 AI 味(删填充词/破公式/变节奏/信任读者/删金句)见本档 §5.3 自检与下方骨架表。 +> **最小摘要 —— 十条硬约束的自检问法**: + +| # | 自检怎么数 | +|---|---| +| 1 | 前 3 行有没有判定(✅/⚠️/❌ + 一句) | +| 2 | 有没有第 4 级标题(层级须 ≤3) | +| 3 | 有没有连续 >12 行的文字墙(每节 ≤7 行) | +| 4 | 每节加粗是否 ≤2 处、有没有整句加粗 | +| 5 | 表格是否 >5 列、单元格塞整句 | +| 6 | 同一条信息有没有重复出现 | +| 7 | 翻到最后一节 —— 是不是拍板项、有没有编号 | +| 8 | 每个候选是否优缺点各至少一条 | +| 9 | 有没有 `A:… · B:…` 一行塞多个候选(须竖排) | +| 10 | 有没有 `①…;②…;③…` 挤在一段(须逐条独占一段) | + +**待用户拍板项**:位置 = **最后一节**|形态 = **逐条编号**|语气 = **陈述句**。 +**去 AI 味**(说话像人):**本档 §5.3** —— 删填充词、破公式、变节奏、信任读者、删金句。 + +## 6. AI 自检清单(每次动手前 / 交付前) + +**动手前** +0. **用户本轮点名了某个技能 / 方法吗**(如「参考决策方法」「按你的规划」「别问我」「自行决策」)?—— 点名了 ⇒ **必须先用 Skill 工具加载对应技能**,⛔ 不得以"我已经知道判据"为由跳过。 + (机制层有 `dsh-server-docs/07-scripts/skill-load-guard.py` 钩子强制注入提醒 · 2026-09-16 加;实证:会话 `ddea70b7` 用户点名后 AI 全程 `Skill` 调用 **0 次**,仍按旧判据提报用户) +1. 我手上有一张**功能卡**吗(谁/在哪/做什么/怎样算成功)?没有 → 先问这 4 条,**不要问技术**。 +2. 我准备提报用户的每一件事,**用户能从功能视角判断吗**?不能 → 撤掉,自己定。 +3. 我提报用户的文案里,有没有包名 / 环境变量 / 路径 / commit?有 → 翻译成"能感知的差别"。 +4. 有没有命中红线(**R5 扩大 / R7 批量>10 / 不可逆破坏性操作**)?**按实际影响面判,不按动作名字判**(X9)—— 命中 → 必须提报用户并说明影响面;⛔ **R8 不算门禁**(开发环境服务器 ⇒ 重启 / 停实例 / drain / nginx·nft **直接做**,动手前一句话说明即可);**没命中就别拿「这是生产操作 / 会中断用户」当理由停下**。 + +**交付前** +5. 我有没有按 §5 的格式给"做了什么 / 你能看到什么"?还是又只给了技术清单? +6. 用户会不会因为**缺少某个反馈**又来报障?(对照 §4 五条) +7. 技术选择我**记进档案**了吗(§2 的 9 类,做完要留痕,否则下次重新吵)。 +8. 用户问的是「是否 / 能不能 / 为什么不行」吗?—— 是 → 用 §5.1 结论骨架(**判定在前,过程在后**)。 +9. 结论层有没有技术标识(版本号 / commit / 包名 / 内部函数名 / 路径)?有 → 挪进「技术附录」。 +10. 我有没有用「要我接着做吗 / 说一声即可」收尾?—— 有 → 改成陈述句,并**直接去做**(除非命中门禁 = 不可逆破坏性操作)。 +11. 这条回复**能被扫吗**?—— 首屏 3 行给判定 / 层级 ≤3 / 每节 ≤7 行 / 加粗 ≤2 处每节 / 表格 ≤5 列 / 一条信息只说一次 / **待拍板项在末节**(§5.4;执行信息·报障·提问**各有骨架**) +12. **需要用户确认 / 决策的内容,在整条回复的最后一节吗**?—— 它后面若还有别的节 ⇒ **挪到最后**(2026-09-15 用户明令);且必须**逐条编号**(有序段落),不是散文一段;形态是**陈述句**,不是征询句。**反向也查一遍**:能自决策的事,我是不是停下来问了? +13. 我要提报用户的每一项,**优缺点都写了吗**?—— 若某个候选**只有优点**或**只有缺点** ⇒ **不该问**,自己拍掉再陈述(§5.3 铁律 5)。 +14. 候选**竖排成段**了吗(A / B / C **各占一行**)?—— 横排成 `A:… · B:…` 或塞进表格的列 ⇒ **改成竖排**(§5.4 硬约束 9 · 2026-09-15 用户明令)。 + +--- + +## 7. 与其他约定/技能的关系 + +- **红线优先级最高**:R1–R7 / R9–R11 命中一律先停手 —— 本协议不构成豁免,只约束"该不该问"。 + ⛔ **唯一例外 = R8**:它已按用户 2026-09-13 明令**修正为"开发环境服务器 ⇒ 不必等确认"** ⇒ **不构成提报用户理由**(只保留"动手前一句话说明")。把 R8 当门禁 = 本站最常见的过度提报用户(`ddea70b7` 实证)。 +- **`dsh-decision-method`**:管"怎么想、怎么定"(判定矩阵、十问、验收分级)。本协议管"**谁定什么、用什么语言问**"。 +- **`dsh-change-workflow`**:管"怎么落地"(六阶段、档案模板、并行调度)。 +- **`01-规范/06-工作台UI规范.md`**:前端强制基线,冲突以其实测 Token 为准。 +- **单一来源**:本协议即唯一来源(技能必须本地加载才生效);文档库 `INDEX.md` 只放**指针**,不复制全文。 + + +--- + +## 变更历史(原 frontmatter · 逐字保留) + +```text +name: dsh-feature-first +description: dsh 多租户平台(ai1net.com)的「功能优先」协作协议 —— **用户只提功能需求,AI 自主完成全部技术决策**。当用户提出任何平台功能/交互需求、报障、说「我要把精力放在功能搭建上」,或问「**先做哪个 / 优先级怎么排 / 做不做**」(⇒ 属边界外第①类,该问)时使用;也用于 AI 自查「这件事该不该拿去问用户」。核心 = 复盘证据(技术类+报障类占 64%)+ 用户的「功能卡」4 问 + **AI 自主决策的 9 类白名单(永不问)** + **只准提报用户的 3 类(功能语义分叉 / 红线门禁 / 超出方法边界)** + **决策方法边界定义(边界外必须问的 8 类)** + 提报用户格式 + 报障闭环前置 + 交付回执格式 + 方法本身演进改为自决策 + **§3.6 拆包提报用户:红线问题不得与技术方案捆在一起问** · **§3.2 红线按「实际影响面」判、不按「动作名字」判** · **§5.1 结论骨架(答「是否已实现 / 为什么不行」)** + **§5.3 铁律 4–5 / §5.4 硬约束 7–9:待确认项 = 回复的最后一节、逐条编号;每个候选各占一段(竖排、不横排)且必须写「优点 / 缺点」;只有优点或只有缺点 ⇒ 自决策、自决策**(2026-09-15 用户明令)。**配套:`dsh-decision-method`(怎么想)· `dsh-change-workflow`(怎么落地)。** +version: 1.0.0 +updated_at: 2026-09-22 +last_change: 【2026-09-22 按要求统一版本号】frontmatter `version` → `1.0.0`(原 v1.8.1);正文与历史中的版本号为当时记录,未改动。此前 v1.8.1(2026-09-22):补触发说法「先做哪个 / 优先级怎么排 / 做不做」—— 实测盲区:这三类说法原本命不中本技能(而它们恰是「该不该问用户」的典型场景);顺带补齐缺失的 `last_change` 字段(12 个技能里只有本技能没有)。 +agent_created: true +``` diff --git a/session-mechanism/references/03-回复排版-核心块.md b/session-mechanism/references/03-回复排版-核心块.md new file mode 100644 index 0000000..64190fd --- /dev/null +++ b/session-mechanism/references/03-回复排版-核心块.md @@ -0,0 +1,18 @@ +# 回复排版与格式 · 核心块(**机器可读 · 权威源**) + +> **这份是权威源**(跨工作区、跨机器)。两个消费者: +> ① 钩子 `scripts/hooks/reply-style-guard.py` **每轮现读本块**注入会话; +> ② 注入器 `scripts/apply-reply-rules.py` 把它写进**各环境的规则文件**(`CODEBUDDY.md` / `AGENTS.md`)。 +> ⛔ **不要在本文件之外再抄一份规则文本** —— 抄了就成第二真相源,两边必然漂。 +> 用户令(2026-10-02):「所有会话中回复排版和格式要求和规则,也要整合到会话技能中,使用时配置到对应环境文件中」。 + + +- **骨架**:拆两层 —— `#` 大类别(已完成/待处理任务)→ `##` 具体事项。每件事先写「当前状态」(每条一个圆点,用**一句陈述句**说重点,依据与细节放**句末圆括号**),再写「待处理事项」(用序号 `1、2、3、`,每条可不止一句)。 +- **层级与顺序**:大类标题必须比任务名大一号;**已完成的大类放最前**,待处理放最后;附件写在**所属板块最末一行**;⛔ 不出四级标题。 +- **首屏**:开头 3 行内先给判定(✅/⚠️/❌ + 一句),细节放后面。 +- **三禁**(⛔ 任一命中 = 该条回复**作废、重写后再发**):**表格** / **长散文** / **碎标签堆叠**。 +- **并列内容竖排**:多个候选、多项并列各占一段、逐条编号;⛔ 不横排、⛔ 不挤进一段、⛔ 不塞成表格的列。 +- **待拍板项**:放回复**最后一节**,逐条编号;每条写清「问题 + 说明(影响谁/断多久/花多少钱/有无不可逆)+ 各候选的优点与缺点 + 倾向」。 +- ⛔ **不用征询句收尾**(「要我…吗/请确认/你看怎么办」);能自决策的直接做完,只留一句"我选了什么(可推翻)"。 +- ⚠️ 若**本工作区另有更新的定稿**(环境文件里有更细的排版节)⇒ **以那份为准**,本块是通用形态。 + diff --git a/session-mechanism/references/99-速查清单.md b/session-mechanism/references/99-速查清单.md new file mode 100644 index 0000000..35c71f9 --- /dev/null +++ b/session-mechanism/references/99-速查清单.md @@ -0,0 +1,110 @@ +# 速查清单 · 常见坑一句话版 + +> 📌 **来路**:原嵌在 `pitfalls.md` 的 **P0-2** 条里(占那条 79% 的篇幅 ⇒ 把一个条目撑成 11.6 KB)。 +> **2026-10-04 拆出**:⛔ **内容逐字保留**(一字未改),只是搬家。 +> **什么时候用**:已知坑名、想快速确认「这个形状是不是老坑」⇒ 先扫这份; +> 要**根因与实证** ⇒ 去 `pitfalls.md` 查对应编号。 +> 📌 **注意**:这��条只给「现象 / 根因 / 修法」三行,⛔ 不含证据; +> **别拿它当结论** —— 当年 P0-2 里的旧结论就因为"只有弱相关性"被作废过。 + +## P0 ⛔ 子进程不加 `CREATE_NO_WINDOW` ⇒ 桌面**反复闪黑窗**(用户:"一会弹出来一会弹出来的,影响我操作电脑") +- **现象**:守护/常驻程序跑起来后,桌面每隔 10~30 秒**闪一个控制台窗口**。 +- **根因**:程序里有 `subprocess.run(["netstat", "-ano"], stdout=PIPE, …)`(**认网关端口**用)—— + **`netstat` 是控制台程序**,不带 `creationflags=0x08000000` 就会**新建一个控制台窗口**;而该函数**每轮都被调**(常驻程序 ~10 s/投递 ~30 s)⇒ 桌面反复闪。 +- **修法**:✅ **所有** `subprocess` 调用一律带 `creationflags=0x08000000`(CREATE_NO_WINDOW)。 + ⛔ **只重定向 `stdout`/`stderr` 是没用的** —— 窗口照样会建。 +- **验收**:桌面从"反复闪"变为"零窗口";**功能侧**用"能否照常认出网关端口"核对(日志/`wakeups.jsonl` 仍有记录)。 +- 🔑 **推广**:任何**长期循环**里的外部命令调用,先过一遍"**它会不会开窗**"。 + +> 本档给**现象 / 根因 / 修法**。**架构层判据 ⇒ `references/architecture.md`**;原技能存档 ⇒ `references/collab-detail.md §6`。 + +## P1 ⛔ 用「会话后台任务」跑常驻 ⇒ 宿主"卡死" + +- **现象**:用户说"卡住了/发消息不恢复";会话永不回 idle。 +- **根因**:会话后台任务**每轮输出都会唤醒宿主会话**。 +- **修法**:🔴 **首选=根本不要常驻** —— 由**宿主钩子按需唤起**一次性进程(见 **P0-3**)。 + ⛔ 旧建议"用 `run_in_background` 起常驻"是**错的**:那正是 **P0-2**(任务记在该会话名下 ⇒ 该会话卡输出)。 + 真需要常驻时,只能**会话之外**起(启动文件夹/独立窗口),并接受它**拿不到口令 ⇒ 只能落盘、不能投递**。 +- ⚠️ **`cmd start` / `wmic process call create` / PowerShell `Start-Process` 常被安全策略拦** ⇒ ⛔ 别在"独立起进程"上耗时间。 + +## P2 ⛔ 自愈没有去抖 ⇒ 反复杀掉正在服务的好实例("自愈比故障更伤") + +- **现象**:监控到的端口**反复通/断**;日志里"launching → 又被杀"循环。 +- **根因**:启动器常有**幂等闸**("进程活着但端口不通 ⇒ 杀掉旧的再拉一份");若守护进程**每次探测失败就触发**,就变成**抖动**,把可能正在服务的好实例打死。 +- **修法**:**连续 N 次(≥3)失败才动手** + **冷却(数百秒)** + 探测用**socket 连一下**(⛔ 别发真请求)。 + +## P3 ⛔ 粗粒度域锁 ⇒ 自造串行瓶颈,整轮白开 + +- **现象**:别的会话**抢锁失败 ⇒ 什么都不做就退出**(纯浪费的会话)。 +- **根因**:把**整个工作区**声明成一个域 ⇒ 所有写操作串行。 +- **修法**:**按节点涉及的文件/子目录声明域**;不重叠即可并行。**机制层才独占**。 + +## P4 ⛔ `fail-open` 掩盖字段名错误 ⇒ 视图静默为空 + +- **现象**:`rc=0`、日志无异常,但**某个区块一直是空的**。 +- **根因**:异常被 `except: pass` 吞掉;查询写错列名(真实例:给 `sessions` 查了不存在的 `name` 列)。 +- **修法**:① **必须核对输出非空**(⛔ 不能只看退出码)② 关键查询先 `pragma table_info(<表>)` 核对列名 ③ 定期 `diff` 视图一眼。 + +## P5 ⛔ 把"接续任务"当成果 ⇒ 被"空转链条"骗 + +- **现象**:看起来一直在推进("下一棒已派"),实际**总工期不动**。 +- **根因**:**排期 ≠ 成果**。 +- **修法**:**证据分级**(真成果/接续任务/刚开跑/哑火);"在跑的棒 N 个"是排期数,⛔ 不是成果数。 + +## P6 ⛔ 用"加自动化"回应一切需求 ⇒ 会话洪泛 + +- **现象**:一个需求挂上 7 个自动化,其中 5 个同用途。 +- **修法**:**白名单+确认制**(只有"接续会话/派活"可免确认)+ **配额 ≤2**。建前自问:**非得开新会话吗?已有的能不能覆盖?** + +## P7 ⚠️ 一次性自动化跑完 `status` 仍为 ACTIVE ⇒ 统计虚高 + +- **现象**:数"在挂的棒"= 6,实际只有 2 个真在等。 +- **修法**:**一律用 `next_run_at > now` 过滤**;⛔ 不看 `status` 单独判断。清理僵尸旧件(已跑完的 once)。 + +## P8 ⛔ 诊断数据不落盘/不落库 ⇒ 只能靠"自述" + +- **根因**:把"谁干完了、结论是什么"寄托在文件扫描/人报告上。 +- **修法**:**读宿主已落的运行记录**(`automation_runs.thread_title` 等)⇒ **0 token、不轮询**。 +- ⚠️ 但要**核对该列是否真有内容**(`IN_PROGRESS` 时标题可能为空 ⇒ 判"在跑"看 `status`)。 + +## P9 ⛔ 校验"可用"时只看"能跑起来" + +- **现象**:结论写"八项判据起停各一遍全绿",但**端口此刻并不通**。 +- **判据**:**"能跑起来" ≠ "可用"**。**可用 = 现在这一刻服务可达,且能被维持住**。 + +## P10 ⚠️ 长前台命令被沙箱杀,连带杀掉先前后台起的进程 + +- **现象**:命令无输出、`Exit -1`;随后发现后台进程也没了。 +- **修法**:**单条前台命令控制在 ~90 秒内**;长活**拆短步**或**走异步**。 + +## P12 ⛔ 反复重启常驻程序 ⇒ **把主会话自己弄卡**(最容易被忽视的一条) + +- **现象**:**每次进入"改常驻程序"的阶段,主会话就变卡、被反复打断**(用户原话:"为什么每次一改到这里就把自己的会话弄卡")。 +- **根因(两条叠加 + 一条次因)**: + 1. 🔴 用**会话后台任务**起常驻 ⇒ 该进程成为**本会话的附带物**;**每次 kill / launch 都产生一条 `failed` 任务通知** ⇒ **每次都打断会话**。实测:一个阶段里重启 **7~8 次** ⇒ **7~8 次打断**。 + 2. 🔴 **同一会话里做了 500+ 次工具调用** + 反复读写长文件 ⇒ 上下文极大 ⇒ 每轮推理显著变慢。 + 3. ⚠️ 注入钩子每轮往上下文加 ~1KB(应压在几百字符)。 +- **修法**: + 1. **攒批重启**:代码改动**攒到一次**再重启(≈3 处以上/或等功能自测全过)。⛔ **不要"改一行重启一次"**。 + 2. **优先热加载**:把**规则/配置/任务图/队列**做成**程序每轮读文件** ⇒ 改这些**根本不用重启**。 + 3. **长任务换会话**:一个会话**不要既"建系统"又"跑长验收"** ⇒ 到阈值就**交棒接续**(这正是本机制存在的意义)。 + 4. **注入瘦身**:`additionalContext` 压到几百字符。 + 5. 🔴 **改成"拉取模型"**(最根治):**程序只写队列/文件,⛔ 不调用会话、不通知、不起后台任务**;会话在"用户发话 / 棒收尾 / 需要时"三个时机**主动拉**队首 ⇒ **没有"推"就没有打断**。详见 `references/collab-detail.md` §1.3。 + +> 🔑 **一句话**:**"改代码 → 重启常驻 → 产生失败通知 → 打断自己"这个循环,是主会话变卡的机制性原因**,而不是外部故障。 +> ⇒ **"拉"取代"推",是这个问题的根治解。** + + +- **现象**:`SyntaxError: unterminated string literal`。 +- **根因**:在双引号字符串里写了 ASCII 双引号。 +- **修法**:文案里的引号一律用 **`「」`**;改完 **`ast.parse` 校验**(比 `py_compile` 报错更清楚)。 + +## P13 ⛔ 常驻"活不长" ∧ 钩子指向无唤醒码的旧副本 ⇒ 机制**静默停摆**(最阴的一条:没人会发现) + +- **现象**:唤醒回路代码写完、也实测到 1 次真投递,但此后**再也不投**。查下去:常驻进程**已消失**、单例端口 `Connection refused`、日志停在某一刻;而"唯一会自动跑"的钩子那条路,跑的是**另一份旧的精简副本**(没有队列闸门、没有唤醒码)⇒ 整个机械层**静默停摆**,且**没有任何告警**——因为告警也是那个程序发的。 +- **根因**:① **从会话里起的常驻会随其宿主会话结束被回收**(实测:`13:09` 起的 pid,`13:12` 之后再无一轮);② **同目录另存过一份旧副本且钩子指向它** ⇒ "代码在 A、钩子在 B" ⇒ **接了线等于没接**;③ 认口取"`uptime` 最大"的口,而**那个口可能没有活会话** ⇒ 有活会话也照样落 `no-live-session` 跳过。 +- **修法**: + 1. 🔴 **"能自动跑起来"的路只有一条 = 钩子**(每个会话收尾跑一轮、零 token、不占会话)⇒ **把机制的关键环节挂在这条路上**,⛔ 别押在"常驻一直活着"上。 + 2. **钩子必须指向最新那份**(含队列/唤醒);**同目录⛔ 不要留旧副本**,或让旧副本显式**转调**新版(fail-open + 静默 + 超时,⛔ 不改自己原有行为)。 + 3. **认口=「第一个带活会话的口」**,⛔ 不是 `uptime` 最大者(实测两者常不同)—— 同 cwd 多实例时,这条正是**投得出去 / 投不出去**的分水岭。 + 4. **判"接线成立"看产物**:每轮覆写的视图/队列/状态文件 **mtime 是否跟着"会话收尾"推进** —— 比读代码可靠得多(本节即靠这一条定位的)。 diff --git a/session-mechanism/references/architecture.md b/session-mechanism/references/architecture.md new file mode 100644 index 0000000..89f2f43 --- /dev/null +++ b/session-mechanism/references/architecture.md @@ -0,0 +1,1136 @@ +# 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`)⇒ "常驻拿不到口令"的说法**不成立**。 + · 🔑 **真正的教训(流程,不是技术)**:**⛔ 不得擅自改"用户定案"**。要改 ⇒ 必须**先在对话里说清"哪里坏了 + 证据"**, + 再给建议,**等用户拍板**;⛔ 不许只在文档里留一句就当作"已定案"。详见作业规矩 `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` 原因在册 + diff --git a/session-mechanism/references/collab-detail.md b/session-mechanism/references/collab-detail.md new file mode 100644 index 0000000..01d03f1 --- /dev/null +++ b/session-mechanism/references/collab-detail.md @@ -0,0 +1,966 @@ +> ## ⚠️ 本件=**原 `multi-session-collab` 技能的 `SKILL.md`**(2026-10-01 并入本包) +> · 🔴🔴 **2026-10-03 07:2x 新增状态块(本条最优先,⛔ 排在下面 10-01 那条之上)——「上报/投递」整套退役**: +> ⛔ **队列投递已真删**(`supervise()` 203→71 行、两个 `_deliver_str()` 调用点删除、`wake_enable` true→false) +> ⇒ 本件里凡讲**投递/上报/唤醒回路/跟进会话收件**的段落,**读到即跳过**(含 §1.4a、§0.5 里的 `wakeups.jsonl` 面板)。 +> ✅ **现行机制只有两条腿**:**接收**=`--report` 写四态台账;**处理**=建 `[检查]-…` 排期。 +> 🔴 **「常驻投递」这个角色名也别再用** —— 角色本来就叫「**协作程序**」,"常驻"是**运行形态、不是角色名**(P0-37)。 +> ⚠️ 本件 §0.5 的**看板版面纪律仍然全部有效**(2026-10-03 两格合一就是按它做的);⛔ 只有"图上画着投递"那部分作废。 +> ⇒ 冲突时以 `architecture.md` 当前结论节 + `SKILL.md` 文首 10-03 状态块为准。 +> · 为什么带进来:本包 `references/{architecture,deploy,pitfalls,taskgraph}.md` 都写着「**主干判据在 `SKILL.md §x`**」 +> —— 指的就是**这一份**。不带进来,包内就有 8 处悬空引用。 +> · 🔴 **权威关系**:`references/architecture.md` 是**唯一权威**(其头部有「当前结论」节)。 +> **凡本件与它冲突,一律以 `architecture.md` 为准**;本件只作**细则补充 + 溯源**。 +> · 🔴🔴 **本件内所有「零常驻/按需唤起/跑完即退/不需要常驻/不用常驻描述主体」的表述,一律已被推翻** +> (2026-10-01 定案:**协作与投递一直运行(常驻)**;理由「可能不是所有队列都是钩子产生的」+「定时任务的方案已经废弃了」 +> —— ⚠️ **2026-10-03 订正**:"投递"那半边已退役,⛔ 只剩"常驻程序一直运行") +> ⇒ **读到即跳过**,现行口径见 `architecture.md §4/§5-1`。 +> · 原件 mtime `2026-10-01 13:56`;原件全文(含 YAML 头)⇒ `<工作区>/归档/技能-退役-20261001/multi-session-collab/SKILL.md`。 + +### 🔴 读法索引(**先看这张表,⛔ 别从头顺读**) + +| 章节 | 性质 | 怎么读 | +|---|---|---| +| §0.5 · §0.5.0–§0.5.6(看板) | **本件独有 = 活判据** | ✅ 必读:看板总则、版面纪律、文案纪律、形状语义、改完怎么验 | +| §1.3 拉取模型 · §1.4a 唤醒回路判据 · §6 自愈纪律 · §8 派活前自检 · §9 `collabd.py` 维护要点 | **本件独有 = 活判据** | ✅ 用到时读 | +| §3 任务图 · §4 证据分级 · §5 自动化白名单 · §2 主会话只做两件 · §7 落地三个件 | **细则** | ✅ 用到时读(§3 亦见 `taskgraph.md`;§5 亦见项目层 `CODEBUDDY.md §F`) | +| §0 五个主体 · §0.05 目标 · §1 四条通道 · §10 防打转判据 · §11 术语表 | **已被取代** | ⛔ 跳过 ⇒ 读 `architecture.md` 的 §1/§0.1/§3/§6/§7 | +| §11.1 命名铁律 · §12 文档权威 | 摘要 | ⚠️ 略读即可 | + +--- + +> ⚠️ **存档说明**:本件正文取自原技能 `SKILL.md` 的**主体**。 +> 原件的 **YAML 头部**(`name` / `description` / `version` / `updated_at` / **逐轮 `last_change` 变更流水** / `agent_created`)**已于 2026-10-01 删除** —— +> 那是技能加载器的元数据 + 发布流水,放在参考件里对使用零价值、且会随时间腐坏。 +> 原件全文(含该 YAML 头)见 `<工作区>/归档/技能-退役-20261001/multi-session-collab/SKILL.md`。 +# multi-session-collab — 主会话 × 多子会话执行机制 + +> 🔴 **一句话**:**派活用自动化(唯一能开新会话的通道)|收结果直读宿主库(0 token)|机械判定下沉到本地只读脚本|AI 只在需要判断时被叫起|用任务图+关键路径防干等。** + +> ## 🔴 当前结论(**先读这里** · 最后更新 2026-09-30 12:30) +> ⚠️ 本文件**通篇是追加式写法** ⇒ 下文可能残留**已被取代**的内容(已就地标注)。 +> **冲突时以「本节 + `references/architecture.md` 的「当前结论」节 + 两文件 §迭代记录最新条」为准**,并请顺手把冲突处就地标注。 +> +> | 项 | **当前结论** | +> |---|---| +> | **唯一权威** | 架构 ⇒ `references/architecture.md`(⛔ 别处⛔ 不再写第二份)。`交付物/` 里 4 份"多会话协同"平行件 + `落地清单-唤醒回路` ⇒ **2026-09-30 已加退役标注**,⛔ 不作用判据。 | +> | **运行形态** | 🔴 **本条已订正(2026-09-30)· 原 09-29 定案「协作程序与监督程序一直运行」已废弃**。实测判据(用户原话「**你把监督程序停掉关了 整个流程还是照样跑**」):`guard` 自 09-29 23:59 停到现在,**投递一路正常**(当日 8 条 `http=200`)⇒ 停掉常驻,流程照跑。⇒ **现形态 = 两个模式都由宿主钩子按需唤起**:`--once`(协作程序 · 维护队列)/`--tick`(**投递** · 唯一投递方)。⚠️ 「监督程序」**这个名字已退役** —— 它从来不是「一个该常驻的进程」(用户:「**跟监督程序没有半毛钱关系**」)。 | +> | **本机起法** | 唯一可行 = **宿主后台任务机制 + `stdout` 全重定向到文件**。⚠️ `detached` 活不过工具调用边界;`schtasks`/`reg`/`wmic`/`sc`/`wsl` 被**内置程序黑名单**硬拦。 | +> | **派活模板必带** | ① **`开工第 0 步:抢域锁`**(域按**显式 domain** 切,⛔ 不按 cwd/⛔ 不按工作区)② **`收尾自判 ⇒ 有缺口就接下一棒;blocked 则⛔ 不接、改喊用户`**。 | +> | **看板(三层)** | 🔴 **只读旁路观测**(用户 2026-09-30:「看板**不能影响程序执行**,可以**异步**、可以**延迟**」)⇒ `board.py --serve`:**后台线程产快照,请求线程只读内存缓存**(⛔ 不碰 DB/⛔ 不读文件),开几个标签页都不加宿主负载。🔴 架构图**三层**:用户/主会话/**分工板块(按线,不是历史会话列表)**/常驻程序+投递 + 宿主地基带;每格带**真状态**(会话取宿主库、程序取状态戳 mtime)。详见 §0.5。 | +> | 🔴 **看板=单实例**(2026-09-30 加护栏) | Windows 的 `SO_REUSEADDR` 允许**同端口重复绑定且不报错** ⇒ 多个实例**静默并存**、同一 URL 被**随机**应答 ⇒ 快照/代码版本**打架**(实拍:已有看板时第二个照样打印"看板已起")。⇒ `board.py --serve` 起前探 `/healthz` 签名,已有 ⇒ **拒绝启动**;换新代码用 **`--takeover`**。停机 ⇒ `stop-collab.py` ④。详 `references/pitfalls.md` **P0-9**。 | +> | **已知待修** | ① ✅ `goals_open()` 已补读 `acceptance_state`(2026-09-30)② ⚠️ `wake_round()` 已停用未删 ③ 常驻与钩子的**投递唯一性**未按新方案收口(现靠三重去重兜着)④ ⚠️ `board.py` 改完**必须重启服务**(进程内已 import,不重启就跑旧代码)⑤ ✅ **`--once` 已认「已停」总闸**(2026-10-01 修:`goal_paused()`/`paused_round()` + 自测 6 项)⑥ ⚠️ **"全节点 done 但验收未声明"** ⇒ 已补验收判据(V1–V5)+ 日志节流 600 s;⚠️ **判据仍由对话说明**(`goalctl declare`),⛔ 别拿本次这五条当项目常量 ⑦ ✅ **多目标 tab**(2026-10-01 加:`build()` 出 `goals[]` + 看板 `renderTabs()`/`paintScope()`;**tab 第一行=完整目标名**,⛔ 不许退回"只给简称")。数据源=`/goals/*.json`(一个文件一个目标,**活跃排第一**);⛔ **没有 `goals/` 时行为与旧版逐字一致**(`goals[]` 只有 1 项)。⚠️ 台账按 `line ∈ 目标 topics` 切到各目标;**不属任何目标**的件归**活跃目标**且照常显示(⛔ 不静默丢)。改 `board.py` 记得走 ④ 的重启 | +| 🔴 **2026-10-01 体检新增三修** | ① **`--once`/`--tick` 认总闸**(`run != active` ⇒ 只写一行"已停"、清告警投影、⛔ 不投递)② **主会话解析只认活会话**(`_pick_live`)—— 实测断点:解析出一条**已 `completed`** 的「主控」当投递目标 ⇒ 一路 `main-not-live`(**程序在跑、通知永远发不出去**);修后判读转为 `target-busy` ③ **退役线清队列**:台账/任务图 18 条全是**已退役的手机接入线** ⇒ 队首长期指向退役需求(已归档、按当前类别重建) | +| 🔴 **唤醒主会话:只画了格,会话没建**(2026-10-01 实测) | 会话表里**零条** `[唤醒]` 前缀会话(带「唤醒」字样的 6 条全是 `completed` 的旧棒)⇒ 技能 §1.4a 那条"**随需求确定时创建**"从未执行 ⇒ 看板上「还没建」虽然按定性是**正常态**,但**那条腿实际是空的**。叠加"网关排期=死能力"(`CODEBUDDY_DISABLE_CRON=1`)⇒ **全员静止时零唤醒**。**处置:本次未擅自建**(属"派活/接续"白名单外 ⇒ 待用户拍板) | +> +> 🔴 **⑤ 已停 ≠ 静默**(2026-10-01 实测 · ⛔ 见 `tmp/协作机制体检-2026-10-01.md`): +> `goal.json.run=paused` 时,`collabd.py` 全文**零处**引用 `run`/`paused`(只有 `wake_enable` 管**投递**) +> ⇒ `--once` 照跑、照写 `STALL.md`/`NEXT.md`/`digest.md`/`TO-MAIN.md`,而 `digest.md` 的内容 +> **经钩子注入每个会话的每一轮** ⇒ 一个"人为停掉"的需求被读成"机制坏了"。 +> **正解**:`--once` 开头加总闸,`run != active` ⇒ 只写一行"已停",⛔ 不产告警。 +> 🔴 **⑥ 判不出完成 ⇒ 唤醒永不暂停**:任务图 18/18 done、队列 0 待办,但 `acceptance_state` 只有说明行、无判据 +> ⇒ `goal_state` 判「判不出来」⇒ `goals_open()=true` ⇒ §1.4a ②「已完成 ⇒ 唤醒任务暂停」**永不成立**, +> `TO-MAIN.md` 每轮照发。**正解**:补判据,或改成「全 done 且未声明 ⇒ 喊一次就静默」。 + +> 🔴🔴 **架构正文不在本文件** ⇒ **`references/architecture.md`** =「多会话协作」的**唯一架构文档**(2026-09-29 用户定案)。 +> 🔴🔴 **本机制的一切迭代都在本 skill 内进行**(用户 2026-09-29 明令:「都要在技能中去迭代」)—— +> 架构 ⇒ `references/architecture.md`|部署与起法 ⇒ `references/deploy.md`|踩坑 ⇒ `references/pitfalls.md`|操作 ⇒ 本文件|代码与配置 ⇒ `scripts/`。 +> ⛔ **不在工作区或别处另开平行文档**;⛔ 不把机制文档散进业务件(业务件只留"用了哪条机制"的指针)。 +> 🔴🔴 **改完必跑回归自测**:`python scripts/selftest.py` —— **全绿(rc=0)才算改完**。 +> 它把**所有异常情况**做成了用例(⚠️ **条数别写死** —— 会随迭代增长,`grep -c '^@case(' scripts/selftest.py` 数一下才准: +> **看板与收尾确认的归属判据必须同款**/投递成对重复/**纯投影不消费队列**/**投不出不消费**/ +> 两级命名/目标闸/台账四态/启动对账/僵尸/不显窗/假绿/**会话哑掉(诊断日志撞上限)**/常量完整/优雅退出 …), +> **⛔ 测试不碰生产**(用 `tmp/selftest/` 独立工作区)。 +> 🔴 **写用例的两条纪律**(2026-10-01 各踩到一次): +> ① **"前提不成立就 SKIP"要把前提判「全」,⛔ 别靠副作用反推一半** —— 例:僵尸用例只看 `adopted` 就断言, +> 漏了「无 IN_PROGRESS 运行」那半 ⇒ **自动化会话跑自测时必然假红**(它自己就是那条运行)。 +> ② **用例自造的假数据每轮清干净**(放 `_prepare()` 里清),⛔ 别让上一轮的留在原地 —— +> 断言"精确集合"的用例会被跨轮残留多算几条 ⇒ **第二次跑才红**(最难查的一型)。 +> 📌 用例是从**真实事故**里长出来的 ⇒ 每踩一个新坑,**先把它加成一条用例**,再改代码。 +> · **改架构 ⇒ 只改那一份**(五个主体 / 任务队列=**上报制** / 唤醒=**静默判据** / 四条通道 / 三种节奏 / 防打转判据 / 术语表)。 +> · **本文件 = 操作入口**(怎么派活、怎么收尾、怎么排坑);下文出现的架构内容为**就近速查**,**冲突一律以 `architecture.md` 为准**。 +> · ⚠️ **【已作废 · 2026-09-30 就地标注】** 此处原写"⛔ **零常驻** —— 凡见『常驻程序』字样均属旧说法"。 +> **该说法已被用户 2026-09-29 定案推翻**(见上表「运行形态」:常驻程序与投递**一直运行**)。 +> ⛔ 保留此行只为拦住"翻到旧文又照做";**判据一律以上表 + `architecture.md §5` 为准**。 + +## 0 第 0 步:先认清**五个主体**(本机制的**唯一主体清单**) + +> 🔴 **架构里只有这 4 个主体**:**用户 · 主会话 · 任务会话 · 常驻程序**。 +> 🔴 **「主会话」是*解析出来的*,不是*登记出来的***(2026-09-30 用户定则:「**以 一个工作区 为主会话的工作区**」): +> 登记(`roles[sid]=="main"`)只在**该会话确实活着**时才有效;失效则按**工作区**重新认 +> (`cwd == 本工作区` 且标题**不带** `[协作]`),仍认不出 ⇒ **⛔ 拒绝盲投**、报 `no-main-session` 并喊用户。 +> ⛔ 曾有「目标不在活会话里就随手取第一条口」的**盲选回落**(=零判据投递,会投错窗口),**已删除**。 +> 判据唯一实体=`resolve_main()`;完整分析见 `references/architecture.md §7`。 +> +> 🔴 **命名定则(2026-09-30 用户)**:**主会话 / 接续会话的标题前缀 = `主控`**,形如 +> **`主控 · <任务类别> · <具体>`** —— 例:`主控 · 机制线(查唤醒为什么断)`。 +> · **⛔ 中段写「任务类别」,不是目标名**(用户原话:「而且后面也不叫 手机接入」); +> · 🔴 **「任务类别」是同一个词的四处**(⛔ 别当成四件事):**`goal.json.topics` 的一项 ≡ +> 会话标题里的 `<任务类别>` ≡ 台账条目的 `line` ≡ 分工板的一行**。⛔ `topics` 缺省才回落 `short`。 +> · 🔴 **同一工作区可有多个任务类别**(2026-09-30 用户:「之前的协作会话是**跨工作区**的, +> 要能支持**同一个工作区** 多会话协作(…**通过协作会话名称前缀**区分具体执行会话, +> 所有主会话,协作会话,**自动唤醒任务**,都在**一个工作区**)」)⇒ +> **每个类别各有自己的主会话**;投递**按件的类别**选主会话(`resolve_mains()` + `main_for_topic()`)。 +> ⛔ **自动唤醒任务的排期名也必须带类别前缀**(`[协作]-<类别>-<具体>`)(⚠️ 仅**代偿形态**下适用;定案时钟=常驻投递,不开会话、无排期名)—— +> 实测踩到:`[协作]-唤醒A · <工作区>` 缺第 2 级 ⇒ 解析不出类别。 +> 全文 ⇒ `references/architecture.md §2.3.0`。 +> · **⛔ 主会话的接续会话不许带 `[协作]` 前缀** —— `[协作]` 是**协作棒**的标记,带上它会被本机制 +> 当成棒**排除掉**(我犯过:把接续会话建成 `[协作]-[手机接入]-接续…` ⇒ 自己排除自己); +> · `主控` 前缀是**显式标记** ⇒ `resolve_main()` **优先认它**(比"最近活动"可信:那是用户自己标的,不是猜的); +> · ⚠️ 实测坑:网关 `POST /api/v1/sessions/{id}/rename` **回 204 但不落宿主库**(显示名与库标题可能是两套) +> ⇒ 改名后**必须回读 `sessions.title` 确认**,⛔ 别只看 204。 +> ⚠️ **2026-09-30 订正**:原来的第 5 个主体「投递」**已退役** —— 它其实只是**同一个 `collabd.py` 的 `--tick` 模式**(投递),⛔ **从来不是一个独立进程**。 +> ⚠️ **宿主(WorkBuddy 本体)是地基,不是第 6 个主体**;它只提供四样:状态(只读 3 表)/**调度(自动化排期 = 唯一能开新会话的通道)**/事件(钩子)/互斥(域锁)。 +> 🔴 **⛔ 不用"角色"这个词**(历史文档里"三个角色"的说法已被**本表「主体」**取代,见 §12);也 ⛔ 不用"常驻"描述任何主体 —— 本机制的设计前提是**零常驻**。 +> ⚠️ **但「主体」与「会话类别」是两根不同的轴**,⛔ 别混: +> · **本表(§12)= 主体** —— 谁负责什么(含**用户**与**两个程序**:常驻程序/投递)。 +> · **会话类别 = 四类**(2026-10-01 用户口径)—— **① 主会话 ② 协作会话 ③ 唤醒会话 ④ 队列上报的跟进会话**, +> 同工作区、靠**标题两级前缀**区分 ⇒ 见 `collab.md §4` = `architecture.md §2.3.0b`(接续=**形态**,⛔ 不是类别)/**§2.3.0c**(第④类,未落地)。 +> ⚠️ **已知缺口**:第 ③④ 类**尚未列进本表**(本表是"主体"视角,唤醒会话/跟进会话都是**会话**,落点在会话类别那根轴上)。 + +| # | 主体 | 只做什么 | ⛔ 不做什么 | 与谁接口 | +|---|---|---|---|---| +| 1 | **用户** | 🔴 **职责(2026-09-30 用户原话):制定目标 · 调整方向 · 做决策**。可做的动作:看板 · 放行授权 · 改目标 · 喊停 | ⛔ 不管过程;⛔ 不必催进度 | → 主会话:需求/判据/授权 | +| 2 | **主会话** | **判断 + 派活**:读库+看板判缺口 ⇒ **写一行排期**;收尾自判 | ⛔ 不替别线干活;⛔ 不做机械判定;⛔ 不搬砖(除非最靠前那步自己就能做) | → 宿主:一行排期;→ 用户:看板/待授权 | +| 3 | **任务会话** | **一棒一线,一次一件**:做本棒;**收尾两件**=写产出 + 判本线缺口**接下一棒** | ⛔ 不跨线;⛔ 不夹带;⛔ 不常驻 | → 宿主:运行结论(自动落库) | +| 4 | **目标检查** | 持有**需求台账**;收上报/判定/告警/体检/看板覆写/单例(`--once` **投影轮**) | 🔴 **⛔ 不派活**(不写排期、不开会话);🔴 **⛔ 不投递、⛔ 不推进队列**(`mutate=False`) | → 用户:看板;→ 主会话:机械摘要 | +| 5 | **投递**(⚠️ 旧名「监督程序」,2026-09-30 退役。**它是同一个 `collabd.py` 的 `--tick` 模式**,⛔ 不是一个独立主体/进程) | **逐条读队列** ⇒ **投给主会话**(单条+握手/三条件唤醒)⇒ **唯一的投递方+唯一的队列推进方** | ⛔ 不开会话;⛔ 不派活;⛔ 口令不落盘/不进日志/不回显 | → 主会话:通知 | + +**🔴 判定你是哪一个**:你是被派出去干一件具体事的那个 ⇒ **任务会话**;你是"入口"、负责判缺口与派活 ⇒ **主会话**;你是脚本 ⇒ **投递**(只读只判)或**目标检查**(其余全部,但不派活)。 + +--- + +## 0.05 🔴🔴 **目标不是固定配置 —— 它在「调用本技能的那次对话」里说明**(2026-10-01 用户订正) + +> 用户原话:「**目标是 通过对话在调用 会话协作skill时说明的,不是固定的**」 + +**这条规矩治的是本技能 2026-09-30 犯过的一个错**:把 `goal.json.topics` 按**工作区里现有的 +接续入口/接续包文件名**填了 4 类,还当成"待拍板事项"去问用户要不要**定死**。 +⇒ 那是**猜**,不是**说明**;而且一旦写进 `goal.json`,机制就会照它跑(**判归属、选主会话**)—— +**猜错了不会报错,只会静默漏管/错投**。 + +| 要说明什么 | 落到哪 | 谁来说 | +|---|---|---| +| **目标**(一句话说清要做成什么) | `goal.json.title` | 用户(对话里) | +| **为什么 / 边界** | `goal.json.why` | 用户(对话里) | +| **任务类别**(同一工作区靠它分前缀) | `goal.json.topics` | 用户说 / 主会话梳理 | +| **验收判据** | `goal.json.acceptance_state` | 用户说 / 主会话提 | + +**🔴 三条硬规矩** +1. **技能侧与脚本 ⛔ 不许预设目标与类别,⛔ 更不许从目录名/文件名/接续入口名去"推"** + —— 推出来的东西会**静默决定**「哪些会话算本项目、投递往哪条主会话去」。 +2. **登记有唯一落点**(="说明"这个动作的**唯一出口**): + ``` + python .workbuddy/collab/goalctl.py declare --title "…" [--why "…"] [--topics "A,B"] [--kpi "V1=pass"] --yes + ``` + ⛔ 默认**干跑**;`--title` **必填**(脚本**不替你编目标**);省略某个参数 ⇒ **不动那一项** + (⛔ 不拿旧值凑数);`--topics ""` ⇒ 显式清空(回到单类别回落)。 +3. **可以被覆盖** —— 下次调用技能时在对话里再说一遍 ⇒ 用 `declare` 覆盖即可; + ⛔ 别把上一次说明的目标当成**项目常量**(这正是出错的根源)。 + +**⚠️ 未说明时的行为(⛔ 不许静默)**:`topics` 缺省 ⇒ 回落 `goal.short`(单类别,向后兼容), +但**看板必须显式标出「⚠ 未声明 ⇒ 暂回落目标简称」**,⛔ 不许让人以为"这就是定下来的类别"。 +落点:`board.py::_goal_topics_source()` → `project.topics_source.kind ∈ {declared, fallback, none}` +(`declared` 时一并显示说明时间/说明人)。回归用例:`selftest.py::看板:任务类别来源必须说得出口`。 + +## 0.5 🔴 看板(`scripts/board.py` + `assets/board.html`) + +### 0.5.0 🔴🔴 「看板」默认指**那个实时动态看板** —— 它是**要起着的**(2026-10-01 用户点破) + +> 用户原话:「**我说的看板是 实时动态看板 现在被关闭了**」 + +⚠️ 别用"一份离线快照页"去顶 —— 快照只是**复核手段**(见 §0.5.4),**用户要看的是活着的那一个**。 + +| 项 | 值 | +|---|---| +| 地址 | **`http://127.0.0.1:8788/`**(⛔ 只绑回环) | +| 起法 | **会话后台任务 + stdout 落文件**(本机唯一可行的起法) | +| 命令 | `COLLABD_CONFIG=/.workbuddy/collab/collabd.config.json DSH_COLLAB_WS= "" "<技能>/scripts/board.py" --serve 8788 --takeover > /tmp/board-serve.out.log 2>&1` | +| 换代码重起 | 加 **`--takeover`**(先停旧的再接管)。⛔ **别裸起第二个** —— Windows `SO_REUSEADDR` 会让两实例**静默并存**、同一 URL 随机应答("改了、也重启了、却还是旧的"就是这么来的)。单实例护栏:已有看板 ⇒ **拒绝启动** | +| 改完必须重启 | ⚠️ 服务进程**已 import** 技能里的 `board.py` ⇒ 改快照内容后**不重启就跑旧代码** | +| 停机出口 | `.workbuddy/collab/stop-collab.py`(dry-run 只报告)|`goalctl stop --yes` 会顺带停它 | + +**⚠️ 两条如实登记的边界(⛔ 别当没这回事)** +1. 它是**会话后台任务** ⇒ **关会话/关宿主就停**。本机**没有**真正的常驻手段(detached spawn 活不过 + 工具调用边界;`schtasks`/`reg` 等持久化工具在内置程序黑名单里)⇒ **这不是"忘了常驻",是做不到**。 +2. ⚠️ **该会话只要挂着 `pending`/`running` 的后台任务,宿主的 `idle` 钩子会被静默压制** + (2026-09-30 实测被一个僵尸任务压过 6h20m)⇒ 起看板的那个会话,其**唤醒/投递回路会哑**。 + ⇒ 正解=**让"起看板"这个动作落在一个不承担派活职责的会话里**,⛔ 别让主会话干这件事。 + + +### 0.5.1 铁律:**看板不能影响程序执行**(用户 2026-09-30 明令) + +> 用户原话:「**看板不能影响程序执行,可以异步 可以延迟**」。 + +三条落地(照这个改,⛔ 别把看板做成"第二个常驻程序"): + +| # | 约束 | 做法 | +|---|---|---| +| ① | **解耦** | 常驻程序/守护程序**一行都不引用** `board.py`(可用 `grep -rn "board" scripts/*.py` 复核);`board.py` **从不写任何账本**(只写 `board.json`,且仅在 CLI 模式)。 | +| ② | **异步** | `--serve` 起**一个后台线程**按 `--interval`(默认 3 s)产快照 ⇒ **请求线程只吐内存缓存字节**(⛔ 不碰 DB、⛔ 不读文件)⇒ 宿主库查询频率**恒定 1/interval**,与标签页数量无关。页面轮询节奏**跟随** `board.refresh_interval`(⛔ 不自行加频)。 | +| ③ | **降级不静默** | 宿主库只读连接 + `busy_timeout=300`(撞写锁 **0.3 s 就放弃**,⛔ 不排队);任一块读不到 ⇒ 记进 `board.json.warn` 并在界面顶部黄条显示,⛔ **不伪装成"0 个会话"**(那是假情报)。刷新失败 ⇒ **保留上一份快照**,界面显示「数据延迟 N 秒」。 | + +⚠️ **改完 `board.py` 必须重启看板服务**(进程内已 import,不重启跑的还是旧代码)。⚠️ 页面每请求重读 `board.html` ⇒ **只改 HTML 不用重启**。 +⚠️ 起服务时把 `stdout` 重定向到文件(`> tmp/board-serve.log 2>&1`)—— 会话后台任务的每轮 stdout 会**唤醒宿主会话**。 + +### 0.5.2 架构图三层布局(用户 2026-09-30 指定) + +> 🔴🔴 **2026-10-01:第三层语义又变了一次(第三次)⇒ 本节正文里"分工板块(按线)"已作废。** +> 用户原话:「**主会话 下面 那一排只放协作会话**,横着排 有几个放几个,当前没有对应协作会话 就空着」 +> +「**放一个空的框 说明 暂无协作会话**」+「**唤醒机制如果是主会话的 事就放到主会话框里去**」。 +> ⇒ **以 §0.5.6 为准**(那一排 = 任务会话排;任务类别搬进主会话框)。下文保留作历史。 + +``` + 用户 + ↓ 发消息 · 看板 + 主会话 + ↓ ① 派活 · 自动化排期 + 分工板块(按线)…每格:最近协作任务 / 承接会话 / 件进度 + ↓ ② 上报 · --report + 目标检查 ──队列──▶ 投递 ──③ 投递 · reply/唤醒──▶ (右侧竖井回主会话) + ↑ ④ 收结果 · 直读宿主库 + 宿主 WorkBuddy(地基) +``` + +- 🔴 **第三层是「分工板块」,⛔ 不是历史会话列表**(用户:「协作会话**不是历史记录**,是**展示分工**的板块」)。 + **分工位 = 线**(`goal.json.lines` ∪ 台账里出现过的线);每格展示该线**最近的协作任务**(用户追加要求)、 + 当前**承接会话**、**件汇总**。三色:**有会话在跑**(绿)/**⚠ 有件没人在跑 = 缺口**(黄,最有用的信号)/**件已全完**(灰)。 +- 🔴 **承接会话怎么认**:拿"在跑会话标题里的件 id"匹配(长 id 优先,防 `N1` 抢 `N10`)。 + ⛔ **不用 cwd 推断** —— §2.3 明令。匹配不上就如实写「当前无会话在跑」,⛔ 不硬凑。 +- 🔴 **会话归属判据(本项目 vs 别的项目)**:`board.py` 与 `collabd.py` 各有一份 `in_project()`/`_in_project()`, + **必须逐字同款** —— 一旦漂移就会出现「看板说没人跑、`--ready-next` 却被别的项目拦死」。 + 已有回归用例 `selftest.py::归属判据:看板 ≡ 收尾确认` 逐例比对(⛔ 别删)。 +- 🔴 **常驻程序节点带「队列计数」**(用户 2026-09-30 追加要求):**队列 = 需求台账 `tasks.json`**(不是 `queue.json`, + `architecture.md` §迭代记录明载)⇒ 显示 `队列 N 件 · 已完成 X · 未完 Y(· 受阻 Z)`,四态 `pending/running/done/blocked`。 + ⚠️ **机制里没有「待验收」这个态** —— 用户问「待验收队列数量」时**按四态如实给数,⛔ 不臆造一个数字**; + 要不要真加一道「验收」关口,属**机制变更,待用户拍板**。 +- ⚠️ **【本条已作废 · 2026-09-30】** 原文讲「投递显示『已停』是正常的」—— **前提已不存在**:该名字已退役,看板不再显示「已停」(见 §0.5.2b/§11.1)。以下原文仅作历史: +- ~~🔴🔴 **投递显示「已停」是正常的、有据的止损,⛔ 别当故障去"修"**(出处 `.workbuddy/memory/2026-09-29.md` 2424–2435 行 + + `architecture.md §5-1`,代码常量 `board.py::GUARD_STOP_REASON`): + 2026-09-29 23:59 主会话 `guard.py --stop` 主动停 —— 守护当时是用「**会话内后台任务**」起的 ⇒ 任务**归属发起会话** + ⇒ 该会话被判定"一直挂着长跑任务" ⇒ 实测「**一启动会话就卡消息输出**」。 + 🔑 **根因 =「有网关口令」与「不占会话」不可兼得**。**正解 = 拆两件**:「发现」走**会话外**常驻(启动文件夹/独立窗口, + ⛔ 但拿不到口令)、「投递」走**宿主起的**通道(钩子 `--tick` / 低频排期 —— 宿主起的子进程**天生有口令**)。 + ~~⇒ 现投递**已由钩子 `--tick` 事件驱动**,守护常驻**按设计不再需要**;⚠️ **代价 = 没有独立唤醒时钟** + (即用户点破的「心跳成摆设」)—— **此点仍待用户拍板**是否恢复常驻。~~ + 🔴 **2026-10-02 标注(⛔ 不是口径变更)**:**这段已作废** —— 用户 **2026-10-01 已拍板**:「**协作与投递一直运行(常驻)**」+「**定时任务的方案已经废弃了**」⇒ **常驻必须回来**(见 §5-1)。⛔ 别再把上面那句当现状读。 +### 0.5.2b 🔴 看板版面纪律:**小字描述只进右上角 `?`** + +用户 2026-09-30:「把**各板块的小字描述**放到各板块对应**右上角 `?` 号图标**中,鼠标移上去显示 +(**只保留标题,主体,类别标签**这类信息)」,并点名**删除**三处。 + +| 版面只留 | 进 `?`(`.qtip`) | +|---|---| +| **标题**(`h2`)· **主体**(表格/chips/架构图)· **类别标签**(`h2 .hint`,如 `tasks.json`/`组件状态`) | 该板块的**说明性小字**(为什么这样、判据是什么、口径提醒) | + +- 实现:`.qh`(右上角圆点 `?`)+ 内嵌 `.qtip`,CSS `:hover / :focus-visible` 才显示 ⇒ **纯 CSS,零 JS**; + 用 `` ⇒ **键盘也能看**。 +- 🔴 **动态的长解释也要进来**(如「投递为什么是停的」)—— 做法:给 `.qtip` 一个 id, + 每轮渲染先 `tip.innerHTML = tip.dataset.base`(首轮存静态原文)再按条件追加,⛔ 防重复追加。 +- ⛔ **别再往版面摊解释性小字**;警告/读数这类**主体内容**不算小字,可以留在版面。 +- **回归用例**:`selftest.py::看板:版面纪律`(`?` 数量与 `.qtip` 配对 + 被点名删除的三句不得复活)。 +- 🔴 **「延迟」与「保留的快照时刻」是例外:必须**固定显示**在「协作架构」板块**右上角**,⛔ 不许收进 `?` + (用户 2026-09-30:「延迟 = 你看到的时间 − `<快照时刻>` **保留时间** 放在**协作架构板块右上角**」)。 + 表单:`.herometa` 两行 —— 第 1 行**只放快照时刻**(``),第 2 行放 **`延迟 `**。 + ⚠️ 2026-09-30 用户又划掉一次:**「延迟 = 你看到的时间 −」这句不要**,时间下面直接写「延迟 X 秒」即可 + ⇒ ⛔ 不要再加解释性前缀。由 `paintAge()` 每秒刷新,**超过 15 秒整块转警示色**。 + ⛔ 别在 header 再放第二份时刻(已去掉)。 + +### 0.5.1b 🔴🔴 技能 / 使用方分离(用户 2026-09-30 定则) + +> 用户原话:「**技能就是技能 程序就是程序,谁用产生的文件 放在他自己那里**」 + +**技能 = 通用能力**(`SKILL.md` / `references/` / `scripts/` 的代码 / `assets/` / 范例配置)。 +**使用方产生的东西 ⇒ 放使用方自己那里**:部署配置、运行日志、编译产物、看板扩展、部署启动器。 + +| 件 | 归谁 | 落点 | +|---|---|---| +| **部署配置** | 使用方 | `<工作区>/.workbuddy/collab/collabd.config.json`(技能里⛔ 不放;只有 `collabd.config.example.json` 范例) | +| **运行日志** | 使用方 | 配置键 `log`(默认落在工作区内);⛔ `LOG = HERE / …` 是旧写法,**已禁** | +| **看板「前置」块** | 使用方 | 配置键 `board_ext` → `.workbuddy/collab/board_ext.py`,契约 `build(ws) -> {title,tag,chips,paragraphs,tip}` | +| **部署启动器** | 使用方 | 工作区自己的 `start-guard.cmd`(里面写死本机 python 路径 ⇒ 属部署件) | + +🔴 **配置查找链(`collabd.py`)**:① 环境变量 `COLLABD_CONFIG` → ② `/.workbuddy/collab/collabd.config.json` → ③ **都没有 ⇒ 拒跑 rc=2**。 +⚠️ **为什么 ③ 必须拒跑**:不配配置时 `workspace` 会回落 **cwd**,而 cwd 常常就是**技能目录** ⇒ 技能里长出 `tmp/supervise-inbox/` 与 `_collabd.log`。 +⛔ 连"未找到配置"那条告警**都不许写文件**(它自己就会造成同样的污染)⇒ 只打 stderr。 +🔴 **接线方(钩子/启动器)必须显式传 `COLLABD_CONFIG` + `COLLABD_WORKSPACE`**;⛔ **不许做"回落同目录旧副本"的兜底** —— 那是静默换版本的经典来源。 +🔴 **回归用例**:`selftest.py::技能侧零项目串`(技能目录无结构性产物/日志不挂 HERE/`board.py` 代码零项目串/看板不回读废弃项目键)。 +⚠️ **迁移期尾巴**:改完这套后,**迁移前起的旧进程**仍会用旧代码路径写文件(实测 `wb-supervisor-watch.py --interval 10`)⇒ 等它结束才彻底干净。 + +### 0.5.2c 🔴 文案纪律(**去 AI 味**)—— **看板 + 注入给会话的正文** + +用户 2026-09-30:「**现在很多文案不是抓不住重点,就是描述太AI味**」。 +用户 2026-10-01:「**会话的反馈信息排版 非常不利于阅读,改为段落排版**」+ 给出目标形态: +**一行 header + 其后每行「标签:一段话」**。 + +🔴 **适用范围(2026-10-01 扩写 · 原来只管看板,是个漏洞)**:本节纪律**同时管**两处 —— +① **看板板面**;② 🔴 **注入给会话的正文**:`digest.md`(机械摘要)+ 三个信号文件 +(`STALL.md` / `VACUUM.md` / `READY.md`)。为什么必须一致:后者是**每轮钩子注入给每个会话**的, +排版差 = **每个会话每轮都要多花注意力去拆句**,是**全平台共用的阅读成本**; +而且用户**根本分不清**哪句来自看板、哪句来自注入 —— 两处形态不一致就等于纪律没落地。 +⚠️ **载体**:注入正文的唯一生成处 = `scripts/collabd.py::digest_text()`(`digest.md`)与 `signals()`(三个信号文件)。 +**改文案=改这两处**,⛔ 别去改生成的 `.md`(下一轮就被覆写)。 + +**规则来源**:技能 `humanizer-zh`(24 类 AI 写作模式)+ `agent-operating-rules §2.5`。两处文案都按这两份过。 + +| ⛔ 别写 | ✅ 改成 | 实例 | +|---|---|---| +| **否定式排比**「不是 X,而是 Y」 | **只说后半句** | 「⛔ 不是历史会话列表」→「按线分工」 | +| **金句**(听着能被引用) | 说具体事实 | 「一眼看得出来」→「看格子颜色就知道」 | +| **`⇒` 满屏**(每段好几个) | 逗号 / 句号 | 「两段都在线 ⇒ 端到端未验证」→「两个组件都在线,链路还没通」 | +| **`≠` 符号** | 写成句子 | 「在线 ≠ 通过」→「都在线,链路也可能是断的」 | +| **加粗滥用** | 只留关键词 | 每段加粗 ≤2 处 | +| **emoji 装饰** | 只在真警示处留一个 | 图例里的 `⚠` 删掉(色块已表意) | +| **三段式凑数** | 有几项写几项 | 「最近任务 / 承接会话 / 件进度」→「最近做完的件、现在谁在做、还剩几件没完」 | +| **一条信息说两次** | 只留一处 | chip 只给状态(「已停」),后果写在正文段落里 | +| **解释性前后缀** | 直接给值 | 「延迟 = 你看到的时间 − 07:13:12」→「07:13:12 / 延迟 4 秒」 | +| 🔴 **`- ` 列表碎片 + `;`/`⇒` 串联短句** | **段落排版**:一行 header + 每行「标签:一段话」 | 「`- 🔴 **中断/脱节** —— 没有会话在跑,且 0 分钟 无成果;⛔ 未来 1 小时内零排期 ⇒ 不等人发消息就是**确定性静默**`」→「`状态:没有任何会话在跑,而且已经 3 分钟没有出新成果。未来 1 小时内也没有任何排期,只要没人主动开口,这里就会一直静默下去。`」 | + +🔴 **判据(写文案时的唯一一问)**:**这是给人读的,还是给程序读的?** +给人读 ⇒ **段落**(完整的句子、标签打头、一行一件事);给程序读(注释 / 日志 / 解析用字段)⇒ 随便。 +⚠️ 现有实现里有反面先例:`verdicts()` 里的 `V["verdict"]` 就是「`;` + `⇒` 串联」形态 —— +它**保留**给 `realtime.md` 与通知用,但**摘要⛔ 不许直接复用它**(要按段落重讲,见 `digest_text()`), +⛔ 也⛔ **不许对它做字符串反解**(反解会随措辞改动**静默失效**)⇒ 事实要由 `verdicts()` **另带原始键**(如 `zero_sched` / `probe`)。 + +**自检(改完文案后跑一遍)**:搜 `⇒` `≠` `一眼看得出来` `⛔ 不是` 在**非注释行**里的出现,应为 0。 +⚠️ 判据要**排除注释**(`//` 与 `/* */`、Python 的 `#`)—— 注释是给未来的我看的,可以带标记;**给人看的才是受众**。 + +### 0.5.3 🔴 看板风格系统(**右上角切换**) + +用户 2026-09-30:「画架构图**参考 archify 的样式**(), +**右上角加个风格切换(保留当前风格)**」。 + +| 风格 | `data-style` | 说明 | +|---|---|---| +| **当前风格**(默认·⛔ 不许删) | *(无属性)* / `base` | 原风格,**跟随系统深浅色** | +| **Archify 暗** | `archify-dark` | 令牌**逐字取自** archify `assets/template.html` 的 dark 主题(MIT) | +| **Archify 亮** | `archify-light` | 同上,light 主题 | + +🔴 **实现铁律(违反就会"换风格换出花")**: +- **切换只改 ``** —— 颜色/字体/网格**全走 CSS 令牌**(`--color-*` / `--font-ui` / `--canvas-dot` / `--node-stroke-w`) + ⇒ **换风格不需要重画 SVG**。 +- ⛔ **组件与 SVG 里一律不许出现硬编码颜色**(`#hex` / `rgba(...)`)—— 只许出现在令牌块里。 + **已有回归用例守着**:`selftest.py::看板:风格系统`(三风格令牌在位 + 组件零硬编码色)。 +- 选 `base` 时**要把 `data-style` 属性摘掉**(不是设成 `"base"`),好让它继续吃 + `@media (prefers-color-scheme: dark)`。 +- 选择存 `localStorage['dsh-board-style']`,且**在 `` 里尽早套用**(⛔ 别等 `load`,否则先闪一下原风格)。 +- archify 的关键令牌备忘:画布 `#020617`(暗)/`#f4f5f7`(亮);点阵 `rgba(148,163,184,.16)`/`#d9dee5`; + 强调色 `#34d399`/`#059669`;语义色 backend `#34d399`、cloud `#fbbf24`、security `#fb7185`、external `#94a3b8`; + 字体 **JetBrains Mono(拉丁)+ 系统 CJK 回退** ⇒ 本地用 `ui-monospace, Consolas, "Microsoft YaHei"` 近似。 + +⚠️ **等宽字体的字宽与比例字体不同** ⇒ 改风格后**必须重跑"文字越界 + 压框"检查**(本轮三风格均 0 溢出 / 0 压框)。 + +- ⚠️ **图上文案宽度**:中日韩字符 ≈ 1 em(13 px 字号 ⇒ 约 13 px/字),ASCII ≈ 7 px。 + **⛔ 别用"每字符 8.4 px"那种估法**(会把标题算长一倍 ⇒ **文字溢出框外**,第一版就栽在这)。 + 用 `fitText()` 逐字符累加宽度来截断。**验收**:跑一遍"逐个 `text` 量 `getBBox()` 是否越界"的检查,必须为空数组。 + +### 0.5.4 🔴 改完看板怎么验(⛔ 别只跑自测 —— 2026-09-30 收尾棒踩出来的) + +**问题**:本机(Windows 客户端)**没有独立的浏览器实例**给这个技能用,而本机**明令禁止自起 +headless chrome /附着用户 Chrome** ⇒ **不许擅自开浏览器**。但"改了看板没验"=没交付。 +⇒ 三步取证的组合拳(都在 `tmp/` 现做,不用常驻件): + +| 步 | 做什么 | 判据 | +|---|---|---| +| ① **语法** | 抽出 `board.html` 的主脚本 `node --check` | 通过 | +| ② **渲染** | 极简 **DOM 桩**跑真 `render()`:喂一份真实快照,断言**渲染出来的 DOM 里该有的都有、该没的都没** | 断言全绿 | +| ③ **几何** | 解析渲染出的 SVG:**所有 rect/text 在下界内**、**行带互不重叠**、**格间零重叠**、右沿对齐 | 全部 0 越界 | + +🔴🔴 **两条最容易踩的坑**: +1. **桩必须"当场从 `board.html` 现取"主脚本** —— ⛔ **不许读上一轮导出的副本**。 + 副本可能是**带补丁的形态**(本轮实测:旧副本是"内联快照"版 ⇒ 换新脚本后 10/12 全 ✗, + 一查不是代码坏了、是**副本形态不对**)⇒ 读副本 = **改动越多、假绿越稳**。 +2. **离线预览页是给人眼复核的,不是替代品**:把快照**内联**进 `board.html`(并让 `load()` 优先吃内联件) + ⇒ `file://` 双击即看,⛔ 不依赖本地服务、⛔ 不依赖网络。**桩验"内容与几何",人眼验"观感"**,两件都要做。 + +🔴🔴 **改之前必须知道的两件位置事实**(2026-10-01 实测,⛔ 不知道就会白改): +- **`board.html` 从「包内」读**(`board.py` 用 `HERE.parent/assets/board.html`)⇒ 改包内**即生效**。 +- **`board_ext.py` 从「使用方工作区」读**(配置项 `board_ext`,默认 `<工作区>/.workbuddy/collab/board_ext.py`) + ⇒ **只改包内那份等于没改** —— 必须 `cp` 同步过去,并**核 md5 一致**。 + ⚠️ 同理还有 `goalctl.py` / `stop-collab.py` / `deliver-gateway-token.py` 等**工作区副本**。 + ⇒ **改任何一件前先 `diff` 两份**:若差异只有你这次改动 ⇒ 直接 `cp`;否则先弄清谁是谁。 + +⚠️ **回归工具当前住在 `tmp/`**(`tmp/render-check.mjs` + `tmp/arch-geom-check.mjs` + 快照 `tmp/board-verify*.json`) +—— 而 `tmp/` 按规矩是**要清的草稿区** ⇒ **清掉后"改完看板怎么验"这条路就断了**(`selftest.py` 盖不住渲染与几何)。 +⇒ 要用之前**先确认它们还在**;⛔ 也别把它们当长期资产(**待迁移进包**,登记为结构性问题)。 +⚠️ 两个脚本的路径常量是**写死**的:`render-check.mjs` 的 `BOARD` 曾长期指向**已退役**的 +`skills/multi-session-collab/`(合并后没人改)⇒ 一跑就崩。**换机器/换技能名后必查这一行。** + +⚠️ **同理适用于"台账/验收"这类状态**:本轮两个真 bug 都属「**不崩溃,只是少说一句话**」 +(见 `references/architecture.md`),**"跑一遍没报错"永远验不出来** —— 只能用**两处独立读数对账**。 + +### 0.5.5 🔴 架构图的**形状语义**:这一格**是一条会话**,不是"调度配置"(2026-10-01 · **二次改形状**) + +**形状改过两次(⛔ 别照旧文档做事)**: + +| 时点 | 画成什么 | 为什么 | +|---|---|---| +| 2026-10-01 上午 | **六边形 + 外圈虚线**(``) | 用户当时说「**给新建的唤醒定时任务换个样式,和协作会话区分开**」⇒ 用**异形**表达"它不是会话" | +| **2026-10-01 晚间(现行)** | **圆角矩形 `` + 外圈虚线** | 用户随后定性:「**唤醒脉冲会话,本质还是会话**」(**现名:唤醒会话** —— 2026-10-01 用户再改名,原话「**唤醒主会话 改为 唤醒会话**」)⇒ 与主会话/执行会话**同族** | + +🔴 **结论:形状不再是区分手段,位置才是** —— 它在**主会话下面单独一行(`RW` 行)的左格**, +与右格「**跟进会话**」**等宽并列**(用户 2026-10-01:「**唤醒会话和 跟进会话 单独放一行**」+ +「**唤醒会话 和 跟进会话 框一样大小**」),⛔ **不进下方那一排**。全图**没有六边形**。 +⚠️ **位置改过一次**:上一版是"贴在**主会话左边**";拆行后主会话那一行**只剩主会话独占** +(="主会话只能是用户触发"在图上成立:指进它的线只有上面"用户"那一条)。 + +🔴 **三条硬定性**(2026-10-01 用户原话:「**唤醒会话 是独立会话,不要在主会话上处理,是随着需求确定时创建的**」): +① **独立会话**(自己一条,⛔ 不是主会话的职能);② ⛔ **不在主会话上处理**(叫醒的执行体是它自己); +③ **随需求确定时创建**(⇒ 看板上「**还没建**」是**正常态**,⛔ 不是故障)。详见 **§1.4a ⑤**。 + +| 形状 | 含义 | 出现在哪 | +|---|---|---| +| **圆角矩形** `` | **会话/程序/地基**(一直在那儿的角色) | 主会话 · 主会话**下面那一行左格**的唤醒会话 · 同行右格的跟进会话 · 下方那排任务会话 · 目标检查 · 上报 · Hook进程 · WorkBuddy | +| **外圈虚线** `.trig-halo` | 叠加在唤醒会话上 ⇒ "它靠**脉冲**被叫醒,不是一直在跑"(⚠️ 「**脉冲**」是 **2026-10-01 及更早**的说法;现行口径里**负责叫醒的是常驻投递**。⛔ 但**图形保留** —— 这一格确实**不是一直在跑**,虚线仍然如实) | 只有这一格有 | +| 🆕 **大圆角 + 细虚线 + 不填色** `.grp` | **分组**(⛔ 不是节点、也⛔ 不是"新一层") | 上框=主会话+唤醒+跟进;下框=任务会话那一排(见 §0.5.7) | + +- 🔴 **状态用"颜色 + 徽章"两重表达**,⛔ 不能只靠颜色(色盲/黑白打印会丢信息)。 + `front.triggers[].state` 三态:`running` **绿 ●运行中** / `paused` **黄 ‖已暂停** / `none` **灰 ○未登记**; + ⚠️ **缺 `state` 时回落 `up`**(向后兼容只给 `up` 的老使用方)。 +- 🔴 **徽章用 SVG 图形,⛔ 不用 `●‖○` 这类字符** —— 字符依赖字体,缺 CJK 字体的环境会渲染成方框。 +- ⛔ **技能里不写死这一格叫什么** —— 名字由使用方经 `front.triggers[].name` 给。 + 🔴 **用户 2026-10-01 定的名(现行)=「唤醒会话」**;同日更早叫过「唤醒主会话」(**已作废**,用户原话「**唤醒主会话 改为 唤醒会话**」),再早叫「唤醒脉冲会话」「唤醒定时任务」(均**已作废**)。 + 属**项目侧**,`board.html` 里⛔ 不写死这个名字 —— 由使用方经 `front.triggers[].name` 给。 + **判据:`assets/board.html` 的渲染逻辑里不该出现具体项目名。** +- 🔴 **2026-10-01 23:1x 图上改「简称」(用户第六条口径)**:`唤醒会话 →` **唤醒**、`队列上报的跟进会话 →` **跟进** + (⛔ 用户明令**两者字号一致**,`n-title-sm` 13px)、`目标检查 →` **协作**(`n-title` 16px)。 + ⚠️ **简称 ≠ 改角色** —— 正文/代码里的**全称不变**;图里 `?`/`aria-label` **必须带简称对照** + (否则「**协作**」会被误读成「**协作会话**」)。⚠️ 探针别拿 `唤醒` 当子串判据(见 `architecture.md §2.3.0c-2`)。 + 项目侧 `front.triggers[].name` 也随之给了 `唤醒`(`board_ext.py`)⇒ 图格标题取的是**简称**。 +- 🔴 **读数来源随定性一起改了**:它**是一条会话** ⇒ 状态读 **`sessions` 表** + (`title` / `custom_title` 前缀 `[唤醒]`,且须排掉 `deleted_at`),**⛔ 不再读 `automations`**。 + ⚠️ 旧口径(读 `automations.status`/`next_run_at`,并限定 `schedule_type='recurring'`)**已作废** —— + 但它作为**教训**留着:一次性派棒(如「接续 · …(唤醒回路实战)」)名字里也带「唤醒」二字, + 混进来会把状态判成 `running` = **假绿**(2026-10-01 实测:捞出的 11 条里 8 条是一次性)。 +- 🔴 **2026-10-02 订正**:原文写「**下次几点到点**」程序**读不到**(理由=脉冲是会话自己起的一次性后台任务)—— **两半都不对**:① 定案的时钟是**常驻投递**(⛔ 不是「会话自己起后台任务」——那条是**明令禁区**,见 §4.0 末);② 「下次到点」**读得到** —— 代偿形态下它就在排期表 `automations.next_run_at`。 + ⚠️ 但**看板那一格目前还没读它**(=已知缺口)⇒ 眼下仍只给「**上次真的响过是什么时候**」;⛔ 不许编一个「下次到点」出来。 +- **回归**:`selftest.py` 覆盖不到图形 ⇒ 靠 `tmp/render-check.mjs`(形状/徽章/图外说明/帮助文本,**8 条断言**) + + `tmp/arch-geom-check.mjs`(唤醒会话**必须贴在「唤醒+跟进」那一行(`RW`)** —— 它**已不再贴主会话那一行**; + ⚠️ 这条"贴在哪一行"的判据 2026-10-01 拆行时**必须跟着改**:不改 ⇒ 拿 R2 顶去比 ⇒ **假红**)。 + 🔴🔴 **改形状必须同步改断言**:否则过时断言 = **假红**; + ⛔ 更阴的是**恒真断言 = 假绿**(本轮实测:形状改回 `` 后,几何自检里那条查 + `` 的断言**恒不命中** ⇒ 恒真 ⇒ 假绿,改了形状却"看起来还是绿的")。 + **断言里的数字一律从快照现算**,⛔ 不写死;**新增断言要做一次反向对照**(故意造错,确认它真会红)。 + +### 0.5.6 🔴 第三层 = **任务会话排**;任务类别搬进主会话框(2026-10-01 用户定 · 这一排第三次换语义) + +**来历(三次换语义,⛔ 别照旧文档做事)**: + +| 版本 | 那一排画什么 | 为什么换 | +|---|---|---| +| ≤ 2026-09-30 上午 | **按线**(`goal.lines`/台账 `line`) | 跨工作区时代的维度 | +| 2026-09-30 晚 | **按任务类别**(`goal.topics`) | 为治"同一工作区多类别塌成一行"——⚠️ **当时不是错的**;但类别**本质是主会话自己的事**,占着"任务会话"的位置会误导 | +| **2026-10-01(现行)** | **按任务会话**(`sessions` 里 `role !== '主会话'`) | 用户:「**那一排只放协作会话**,横着排 有几个放几个,当前没有对应协作会话 就空着」 | + +**触发这次改动的三连问**:「唤醒定时任务 和 主会话下方的 唤醒机制是不是 重复了」→ +「你说的唤醒机制 是不是 主会话根据目标创建的 协作会话」→「**那你的唤醒机制 是啥意思嘛 +跟主会话都一个 ID,难道是主会话?**」⇒ 病根 = **那一排把"类别"和"会话"混着画**,读者分不清谁是谁。 + +#### 三条硬规矩 + +| # | 规矩 | 为什么 | +|---|---|---| +| ① | 那一排**只放执行会话**,⛔ 不塞类别、⛔ 不塞台账旧线 | 用户原话。混着画就分不清"谁在干活"与"分了几类活" | +| ② | **一条都没有时也要画空框**(虚线框 +「暂无协作会话」+一句说明) | 用户追加:「**放一个空的框 说明 暂无协作会话**」。⛔ 不许因为"没内容"就把这层省掉 —— 读者会以为图缺了一块 | +| ③ | 🔴 **2026-10-01 同日已删** —— 原「任务类别搬进主会话框」(一行「负责类别:…」,按 `main_by_topic` **反查**)**不复存在**。任务类别现在**只在两处**说:顶部「本项目」卡的**任务类别 chips**(每个 chip 带「主会话 ``」/「⚠️ 无主会话」)+ 图外说明「当前」块第 1 条 | 先有用户:「**唤醒机制如果是主会话的 事就放到主会话框里去**」⇒ 搬进去;随后用户指着那行问:「**这个是什么意思,如果没用就删掉**」⇒ 删。**两条删的理由**(实测,⛔ 不是口味):① **它会说出假话** —— `topics_source.kind='declared'`(类别在对话里说明过)而 `main_by_topic` 为空时,它渲染成「未在对话里说明」,**把"这条主会话没对应到任何类别"说成了"类别没声明过"**(两件事被一个判据混在一起);② 同一事实**版面上已说过两遍**,且方向更对(类别→谁负责),这行是第三遍、还是反方向。⚠️ 顺带:`R2.h` 由 112 **回落到 84**,`tmp/arch-geom-check.mjs` 的 `rows.R2` 必须同步 | + +🔵 **「自动化排期」是主会话的\*\*动作\*\*,⛔ 不是独立角色** —— 用户 2026-10-01 点破: +「**自动化排期开新会话:这个不就是主会话创建和派活吗 也是自动任务的方式**」⇒ **对**。 +⇒ 它画在**连线标签**里(`① 派活 · 自动化排期`)就够,⛔ **别单开节点**。 +⚠️ 但"它是**开新会话的唯一通道**(钩子开不了会话)"这条硬约束**光看线看不出来** +⇒ 在**图外说明**里点一句即可,⛔ 不动图结构。 +(**这条踩过**:2026-10-01 我先把它当成"图上缺了一环"报上去,被用户当场纠正 —— +判据:**角色才配节点,动作只配标签/说明**。) + +🔵 **空态时 ①② 两个标签都要画** —— 原来只画 ①,② 的标签在 `else` 分支里 +⇒ **一条任务会话都没有时,图上只剩 ①**,看不出还有 ② 上报这条回程。 +⛔ 空态 ≠ 这两条通道不存在,只是当前没有会话可连(2026-10-01 修)。 + +🔴 **「未归类」也⛔ 不占这一排** —— 台账里不在当前类别清单的旧线,**汇总改由图外说明**说清 +(「N 条线 · 共 M 件 · 已完成 K」)⇒ ⛔ 不许因为"没地方画"就静默丢(本线红线:读到了就要说出口)。 + +🔴 **任务会话格画什么**(2026-10-03 定稿):**只画三行** —— 会话名 / 状态·`id8` / 多久没活动。 +⛔ **不画「所属类别」那一行**(原本有 `类别:xxx`,无类别时又加了一串提示)—— + **这行已被用户指示删除**(逐字:「越写越看不懂 还是删除了把」)。 + 留档(三次踩坑,别再犯):① 原文案「(标题里没读类别前缀)」**只说现象、没信息量**; + ② 我改成「⚠ 无类别 ⇒ 不会被派活」——🔴 **那个后果是我编的**(取证:派活读 `tasks.json`, + `topic` 在派活链路上**根本没出现**;而那排格子**本身就是正在执行的任务会话**); + ③ ⇒ 结论:**这行信息量不足,⛔ 不值得占版面、更不值得编文案**。 +📌 **两条通用红线**:⚠️ **写「会怎样」前必须先取证那个后果真的存在**(否则=编); + ⚠️ **改文案改到第三轮还说不清 ⇒ 该问的是「这行要不要留」,⛔ 不是继续润色**。 + +#### 前一个版本留下的两处真缺陷(代码仍在,⛔ 别照抄回去) + +- 🔴 **`board.py::_labor()` 的回落分支 ⛔ 不许再排除主会话**:旧判据 + `… and str(s.get("role")) != "主会话"` 本意是"最近在做的=承接会话",但**该类只有主会话自己在跑**时 + 会把候选**整类判空** ⇒ 同一格里 ③ 说「无可归到它的会话」、④ 说「承接 `<同一条>`」= **自相矛盾** + (用户看到的正是这个画面)。⇒ 候选**不排除任何角色**,`working` 排最前。 +- ⚠️ **`fmtAge()` 的返回值自带"前"字**(`4 分钟前`)⇒ ⛔ 别再加一个 —— 2026-10-01 实测拼出过 + 「4 分钟前**前**有活动」。**这是造正例快照时才暴露的**(见下)。 + +#### 回归 + +- `tmp/render-check.mjs`(数字一律从快照现算): + ① 🔴 **主会话框⛔ 不许再写「负责类别」**(2026-10-01 删了那行 —— 它会说出假话,见 §0.5.6 表 ⓷) + + 架构图⛔ 不许再出现「未在对话里说明」(同族假话:类别声明过却说没说过); + ② 第三层**只**出现任务会话的 `id8`(0 条时必须是「暂无协作会话」+虚线框); + ③ ⛔ 不许再有「收件主会话」这类旧类别格残留(防回潮); + ④ 🆕 「当前」块必须按 **`topics_source.kind`** 说类别(`declared` ⇒ 点类别名 + 对应主会话; + 否则 ⇒ 明说「还没在对话里说明过」)—— ⛔ 不许拿**回落值**当已声明的类别报出去。 +- 🔴 **断言必须"改前能报红"**(⛔ 不许写成恒真 —— 那是假绿)。**做法:拿改前的备份跑同一套桩**, + 同一份快照下"改前红、改后绿"才算真判定。2026-10-01 删那行时就是这么验的: + 线上真实快照 **改前 6 红 → 改后 4 红**、合成 `kind='fallback'` 样本 **改前 7 红 → 改后 4 红** + (残留那 4 条是**写死给样本快照的内容型断言** ⇒ 拿**线上快照**跑就假红,属**桩的已知弱点**,登记为 P2)。 +- 🔴 **正例快照必须造**:真实工作区经常 0 条任务会话 ⇒ **只跑默认快照,"有几个放几个"那条分支 + 从没被执行过**(=未验证)。做法:把 `sessions` 追加几条 `role='任务会话'`(含一条 `topic` 为空的), + 再 `node render-check.mjs <该快照>` 跑一遍。**2026-10-01 就是用这招抓出「前前」那个拼接 bug 的。** + ⚠️ 同理:`topics_source.kind='fallback'` 也要**合成**一份样本 —— 现成两份快照都是 `declared` + ⇒ 不合成就**验不到**「没声明过」那条分支(2026-10-01 实测踩着)。 +- `tmp/arch-geom-check.mjs`:内置层高表 `rows` **必须与 `board.html` 同步** + (R2 84→112→**84** —— 2026-10-01 那天来回过,⛔ 改 `R2.h` 必须同时改这里)—— ⛔ 不同步 ⇒ 几何自检是**假绿**。 + +--- + +## 1 🔴 四条通道(**哪件事走哪条 —— 走错就是今天最大的浪费来源**) + +| 要做的事 | 走哪条 | 成本 | **为什么必须是它** | +|---|---|---|---| +| **派活 / 唤醒会话的首次创建** | **自动化**(宿主排期) | 一个会话 | 🔴 **钩子开不了新会话** —— 自动化是**唯一**通道(宿主硬边界);⚠️ 派活**首次创建**会话走这条是对的,⚠️ 但「**周期性叫醒**」⛔ **不该**走这条(那是常驻投递的活,见下行) | +| 🔴 **收结果 / 检测** | **直读宿主库**(`automation_runs`,只读 SQL) | **0 token** | 宿主**每次跑完就把它自己的收官结论落库** ⇒ ⛔ 不必轮询、⛔ 不必扫文件、⛔ 不必叫 AI | +| ~~🔴 **通知主会话(投递)**~~ | ~~**宿主钩子唤起投递**(`collabd.py --tick`)~~
🔴 **2026-10-02 标注(⛔ 不是口径变更)**:**本行已作废** —— 用户 **2026-10-01**:「**协作与投递一直运行(常驻)**」+「定时任务的方案已经废弃了」⇒ 定案=**常驻投递**(0 token · 0 会话 · 1 常驻),钩子只作**补充**(§5-1) | ~~0 常驻~~ | 钩子是**宿主子进程** ⇒ 自带口令 + 不占会话 + 跑完即退;而"需要投递的时刻"**全都伴随会话在动** ⇒ 那一刻钩子必然响(`architecture.md §4.1`) | +| **机械判定**(文件在不在 / rc / 哈希 / 锁 / 端口) | **本地脚本**(**由钩子按需唤起**) | **0 token** | 这些判断几毫秒可做;塞进"定时叫 AI"是又慢又贵 | +| **人看进度** | **一个看板**(每轮覆写) | — | 用户只该看这一处 | + +⚠️ **推论(最容易搞错)**:**"发消息给另一个会话"和"读它的结果"不是一回事** —— 前者要**网关投递**,后者**只要读库**。 +⚠️ **判据**:任何"我需要(或用户需要)持续监控"的诉求 ⇒ **第一反应是「钩子按需唤起本地脚本」**,⛔ 不是自动化、⛔ 也不是常驻。 + +--- + +### 1.1 🔴 与主会话的**双向同步**(⛔ **不需要"监管棒"这个角色**) + +| 方向 | 怎么做 | 要点 | +|---|---|---| +| **主会话 → 程序** | **无需同步**:改 `任务图.json` / 看板 / 产出文件即可,程序下一轮自然读到 | **文件即接口** | +| **程序 → 主会话** | ① **钩子注入**:`UserPromptSubmit` 时把程序摘要作为 `additionalContext` 注入 ⇒ **用户每次发话就顺手带上最新状态**(**零自动化**)② 需要时主会话主动读实时状态 / `digest.md` | 程序**开不了会话**,但**能把状态送进会话** | +| **派活** | **仍由会话做**(白名单内) | 只有会话能创建自动化 | + +🔴 **结论:不需要独立的「监管棒」角色** —— 判断 + 派活**归主会话**;程序负责"**把状态摆到主会话眼前**"。 +⇒ 若发现自己在建"另一个会话来监管",**先问三句**:① 机械判定能否下沉到程序?② 状态能否用钩子注入?③ 派活能否由主会话顺手做? + +**注入实现要点(照抄)**: +1. 钩子脚本对 `UserPromptSubmit` **允许写 stdout**(stdout 正是钩子协议通道):输出 + `{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"…"}}`; + **其他事件仍保持零输出**(⛔ 别破坏原有纪律)。 +2. 顺带把**信号文件**(`VACUUM.md` / `READY.md` / `STALL.md`)的存在也拼进上下文 ⇒ 主会话一眼看到"有东西待处理"。 +3. 注册:`settings.json` 的 `hooks.UserPromptSubmit` **只能追加条目**(⛔ 禁整段覆盖 —— 会抹掉别人的钩子)。 +4. ⚠️ **钩子=会话启动时快照** ⇒ 改完必须**完全重启宿主**才生效(关窗 ≠ 退出);⛔ 别默认它已生效。 + +--- + +### 1.2 🔴 严格队列(**避免打架 · 处理好一个再处理下一个**) + +> 用户原话:「**严格用队列的方式处理,避免打架,处理好一个,再处理下一个**」 + +**队列与域锁分工不同(两者都要)**: +- **队列** 管「**谁做下一件**」—— 调度串行 +- **域锁** 管「**能不能动这个资源**」—— 资源互斥 + +| 规矩 | 做法 | +|---|---| +| **① 一次一件(调度串行)** | 队列**只呈现一个"队首"**;⛔ 不许一次派多件 | +| **② 原子取件** | 取件动作 = `mkdir /claims/` ⇒ **建不成 ⇒ 别人取走了** ⇒ 换队首/等待(这就是"不打架"的硬保证) | +| **③ "谁在做"唯一权威** | 取到后写 `claims//holder`(内容 `<会话名>@<线>`) | +| **④ 出队=删 claim** | **做完必须删 `claims/`** ⇒ 下一个才可能成为队首(⛔ 不删 ⇒ 该线一直被占 ⇒ 队列卡住) | +| **⑤ 同线互斥、跨线并行** | 同一条线同时只允许一件;**不同线可并行**(队列按 `line` 判)⇒ 既"不打架"又**不干等** | +| **⑥ 卡死回退** | claim 超 **20 分钟**未续 ⇒ 程序自动移到 `claims-stale/`(**可追溯,⛔ 不删**)⇒ 队列自动放行 | +| **⑦ 优先级** | **"在关键路径上游闭包内" ⇒ 优先**(做它能解锁关键路径);⛔ **不能只看"是不是关键路径节点本身"** —— 那会漏掉它的前置,跑去干无关的活(实测踩过:队首一度推荐非关键路径的 N11) | + +**产出**:程序写 `queue.md`(人看:队首/在做/待办+冲突提示)+ `queue.json`(机读)。 +⚠️ 命令里**别用 ASCII 双引号**写中文文案(会 `SyntaxError`):一律用 `「」`(见 P11)。 + +--- + +### 1.3 🔴 拉取模型(pull):**队列是"拉"的接口 · ⛔ 不要后台任务** + +> 用户原话:「**不是程序直接调用你,只产生待处理队列,你开个后台任务去获取然后处理**」 +> ⇒ 方向对(把"推"改成"拉"),但**"开后台任务"这一步可以去掉** —— 见下。 + +**为什么这里不需要"后台任务"**:它会**产生任务状态通知/失败通知**,每次都是一次**打断**;叠加"改代码→重启"就变成 P12 那种反复折腾。 +⇒ **正解(本机制):不需要任何后台任务,程序也不调用任何人 —— 只写文件。** +🔴 **⚠️ 两处口径必须分清(2026-09-30 用户反证后定稿)**: +1. ⛔ **"卡消息输出"的真因⛔ 不是"任务挂在会话名下"** —— 真因=**会话日志撞 ~10 MiB 被 `dropped`**(实物读数见 `references/pitfalls.md P0-2`)。 + 用户反证:挂着后台任务的会话照样能随便发消息。 +2. ⛔ **"后台任务不能用来投递"也⛔ 不成立** —— 它**有口令**、能长期活,是**合法次选**(见 `references/architecture.md §4.1.1`)。 + 本机制选"钩子"的理由只是三条**次要优势**:不需容器会话 · **必须活着的进程数=0** · 重启后自动生效。 + +| 角色 | 做什么 | ⛔ 不做什么 | +|---|---|---| +| **程序**(**钩子按需唤起**·只读投影) | 只**维护队列**(`queue.md`/`queue.json`)+ 信号文件(`VACUUM`/`READY`/`STALL`)+ 视图 | ⛔ 不派活、⛔ 不起后台任务;⚠️ **投递另归投递(`--tick`)** | +| **会话** | 在**三个时机主动拉**(见下)⇒ 取队首一件 ⇒ 原子 `claim` ⇒ 派活或自己做 | ⛔ 不轮询(拉的是"被唤醒的时机",不是定时器) | + +**三个"拉"的时机(够用,且零通知)**: +1. **用户发话时** —— 钩子把摘要 + **队首** 注入 `additionalContext`(钩子在**会话侧**发生 ⇒ 本质就是"拉") +2. **每个棒收尾时** —— 顺手读队首 ⇒ 取一件接着做(**收尾自判**,零额外会话) +3. **需要时** —— 主会话主动读 `queue.md` + +⇒ **闭环**:程序只"摆件";会话只"取件";**没有任何一方被对方打断**。 +⚠️ 代价(如实说):**没有会话活跃时,队列里的件不会被自动取走** ⇒ 这时才需要"叫一次"(那属于白名单的**接续/派活**,⛔ 不要为此建轮询式自动化)。 + +### 1.4 🔴 主会话必须"**用户不发消息也能自己处理**"(2026-09-29 用户定案) + +> 用户原话:「**主会话就是要用户不发消息自己能处理,用户发消息是需求上的事**」 +> ⇒ **用户的消息 = 需求输入(新增/变更需求),⛔ 不是推进的动力源。** + +🔴 **硬边界**:会话被唤醒只有两条路 —— ① 用户发消息 ② **自动化(宿主排期)**;钩子**开不了会话**。 +⇒ 所以"主会话自己会动"的**唯一合规形态 = 主会话自己续自己**: + +``` +主会话跑完一轮(判断 → 取队首 → 派活/自己做 → 更新任务图) + │ + └─ 自续期:再排一条一次性自动化,scheduledAt = now + 40 分钟,prompt = 本段原文(逐字复制) + ⇒ 形成「自续链」;⛔ 全节点 done(或用户要求停)⇒ 不再自续 +``` + +🔑 **关键判据**:**"续主会话"属于白名单里的「接续会话」⇒ 免确认** ✅ +(这正是"接续会话"这一类的本义 —— **把链条接上**,包括把主会话这条链接上。) + +**纪律**: +0. 🔴 **间隔必须动态**(2026-09-29 用户点破"整套机制都是短的,都要我来说一句你才执行一下"): + **队列里有可派件 ⇒ `now + 5 分钟`**(有人在等 ⇒ 快叫醒);**无 ⇒ `now + 30 分钟`**。 + ⛔ **固定长间隔(如 40 分钟)= 出现"可派未派"窗口**(实测:42~43 分钟毫无变化,用户以为"只有我说话才动")。 +1. 频率上限 5 分钟(别更密 —— 更密就是"轮询式自动化",属非白名单) +2. **每轮只做一件**(严格队列:取队首一件)⇒ 跑完即退,**⛔ 不留常驻** +3. **终止条件必须写进 prompt**(全节点 done ⇒ 停),否则会永远续下去 +4. ⛔ **不为"推进"另建轮询式自动化** —— 自续链就是那个机制 + +⇒ **与 §1.3 的分工**:**自续链负责"醒来"**(没人发消息也会醒来);**队列负责"做什么"**(醒来后取队首一件);**钩子注入负责"用户发话时顺带汇报"**(不是动力源)。 + +### 1.4a 🔴 **唤醒回路的判据 + 投递路径**(2026-10-01 用户两条批注 · 现行口径) + +**① 判据 = 三条件合取**(⛔ 不是计时器): + +| # | 条件 | 实现现状 | +|---|---|---| +| (a) | **主会话 和 任务会话都空闲** | ✅ **已按此收窄**(`_main_state()` ∨ `_anyone_busy()`,排除规则见 ④) | +| (b) | **队列没有待反馈**(无 `awaiting` ∧ 无 `pending`) | ✅ 已实现(`no_fb`) | +| (c) | **需求仍未完成** | ✅ 已实现(`goals_open()`) | + +**② 需求三态 → 唤醒任务**(用户原话:未完成=继续/有阻碍=暂停/已完成=暂停): + +| 需求状态 | 唤醒任务 | +|---|---| +| 未完成 | **继续**(照三条件判) | +| 🔴 **有阻碍** | **暂停** | +| 已完成 | **暂停** | + +✅ 已实现(`goal_stuck` = 只剩 `blocked` 项、没有 `pending`/`running` ⇒ **不叫**)。 + +**③ 投递路径**(用户第二条批注): + +- ✅ **应**:提交到**目标检查队列** ⇒ 走既有的「**上报 → 主会话处理**」流程(见 §1.2/§1.3)。 +- ⛔ **现状**:唤醒**绕开队列**,直接 `_deliver_str(txt, "上报·唤醒", st)` 投一句给主会话 + ⇒ 主会话收到后**无处标完成**(它不在队列里)⇒ **没有闭环**。 + +> ⚠️ ③ 会改**队列语义**(唤醒件算不算一件待办、谁给它标 `done`、会不会淹没队列) +> ⇒ ⛔ 未擅自改,先把口径钉在这里。 + +**④ 🔴 自指死结 + 解法**(2026-10-01 用户点破 · **本节最要紧**) + +**死结**:唤醒的就是**这条会话自己** ⇒ 它醒来那一刻,**自己正在跑** +⇒ 条件①「都空闲」被**它自己**否掉 ⇒ **永远不叫** +(=醒一次、白判一次、接着睡;用户原话追问「**谁把他顶掉了**」)。 + +用户给的解法(原话): + +> 「**把自己排除不就行了,通过会话名称前缀区分**,还有**也不用他自己判断,可以通过代码判断**」 + +⇒ 落地两条硬规矩: + +| # | 规矩 | 实现 | +|---|---|---| +| A | **判据在代码里**,⛔ 不让会话自查 | 在 `supervise()` 里算;会话只负责被叫醒后干活 | +| B | **排除自己** —— 两道**互补,都要** | ① `SELF_SID`(`CODEBUDDY_SESSION_ID`)**精确**:程序跑在会话里时=自己
② **名字前缀 `[唤醒]`**:程序在会话外常驻时拿不到 `SELF_SID`,只能认名字 | + +🔴 **新增第三类名字前缀**:`[唤醒]-[<类别>]-<具体>` ⇒ `parse_session_name()` 返回 `role="waker"` +(原只有 `[主]` / `[协作]` 两类)。 +⚠️ **2026-10-01 用户口径已扩到「四类」**(新增 **④ 队列上报的跟进会话** ⇒ 第 1 级 `[跟进]`)—— +但**这一类的解析与路由尚未落地**(全库零命中)⇒ ⛔ 本节的 A/B 两条规矩**只覆盖已落地的前三类**, +第 ④ 类的落地清单一律以 `references/architecture.md §2.3.0c` 为准。 + +🔴 **第四形态:接续会话**(2026-10-01 落 · 详见 `references/architecture.md §2.3.0b`) +🔴 **定性:它是「形态」,⛔ 不是第 5 类会话** —— 用户原话:「**接续会话 不是单独的一类会话,是这几类会话到达阈值时 创建的接续会话**」 +⇒ **角色继承被接续的那条**(主会话的接续**仍是主会话候选**),⛔ 不要把它当成一种并列的类别。 +—— **由会话自己建出来的下一棒**,标题由**建它的那条会话**写 ⇒ 常写成 +`[<类别>] 接续 · …` 或 `接续棒:…`(**没有角色方括号**)。判据:**含"接续"二字** ⇒ 判 **worker** +(⛔ **不是主会话**);另把 `主控 · …`(中点分隔、无括号)判 **main**。 +⚠️ **不改会怎样**:旧判据只排 `[协作]` ⇒ 接续棒**进主会话候选**、又因标题含 `[<类别>]` 被认成 +"该类别的主会话" ⇒ **投递把通知投给它自己**(2026-10-01 实测)。 +⇒ **主会话候选的排除判据=「角色不是 worker/waker」**(⛔ 不再写 `[协作] not in t`); +实现处**两处必须同款**:`collabd.py::parse_session_name()`(权威)/`board.py::_role_of_title()`(镜像), +已有**真对账用例**在 `selftest.py` 守着。 +🔴 **建接续棒时必须按形态命名**(否则新会话要么冒充主会话、要么在看板上归不了类别): +`[协作]-[<类别>]-<具体>` / `主控 · <类别> · <具体>` / `[唤醒]-[类别]-<具体>`。 +⚠️ 已知边界:`[<类别>] <具体>` **不带"接续"二字**的仍判**角色未知**(⛔ 不猜),但会被看板点名。 + +⚠️ **实测暴露的另一件事**(2026-10-01):现役会话名实际写成 `主控 · 唤醒机制线 · 棒:…` +(**中点分隔、无方括号**)⇒ `parse_session_name()` **解析不出来** ⇒ 光靠名字判会漏。 +**这正是 `SELF_SID` 那道排除不能省的原因**(该会话的 id 与 `SELF_SID` 一致)。 + +**⑤ 🔴 唤醒会话的三条硬定性**(2026-10-01 用户**第三次**定义 · **覆盖前两次**) + +> 用户原话:「**唤醒会话 是独立会话,不要在主会话上处理,是随着需求确定时创建的**」 + +| # | 定性 | 意思(⛔ 别读错) | +|---|---|---| +| (a) | **独立会话** | 它**自己一条会话**,⛔ **不是主会话的一个"职能"/一种模式**。⇒ 看板上它是独立一格;判"都空闲"时它**也必须被排除**(见 ④) | +| (b) | ⛔ **不在主会话上处理** | 叫醒这件事的**执行体是它自己** ⇒ ⛔ 不许把"该它干的活"挂到主会话身上(主会话只管**判断与派活**)。⇒ 那个脉冲任务起在**它**里面,⛔ 不是起在主会话里 | +| (c) | **随需求确定时创建** | 创建时机 = **需求确定的那一刻**(不是常设件;⛔ 不提前建,没需求不建)。⇒ 看板上「**还没建**」是**正常态**,⛔ 不是故障;此时链路缺口敞开,**如实说** | + +🔴 **`board_ext.py` 落地**:`_waker_session()` 读 `sessions`(前缀 `[唤醒]`);`_triggers()` 的三态 + tips +把这三条**写进图例**("还没建"那一支要说明**这是按定性的正常态**)。回归断言:`render-check.mjs` +两条(「独立会话/⛔ 不在主会话上处理」+「随需求确定时才创建」)。 + +### 1.4b 🔴 **换主会话(交接)的确切触发条件**(2026-09-30 实测,⛔ 别再靠猜) + +**换不换,只看一件事:`roles` 里登记为 main 的那条,此刻**在不在活会话集合里**。** + +| 登记那条 | 结果 | 依据 | +|---|---|---| +| **还在**活会话里(桌面还开着) | 🔴 **钉死在它身上** —— 新建的主控会话**完全被无视** | `roles:main` | +| 不在(已关/已切走) | ✅ 立刻按"显式前缀 → 最近活动"换过去 | `prefix:主控` / `workspace` | + +⚠️ **另一条老会话开着不影响**(只要它不是登记的那条)—— 别把"旧会话还开着"当成不换的原因。 + +**交接的正确姿势(三步,缺一不可)**:① 新标题以 `主控` 开头 ② **先把旧主会话从桌面切走**(⛔ 不是删会话、⛔ 不是杀进程)③ **同时把新主会话打开**(它必须出现在某个网关口口的 `live=` 上,否则投递被拒 `main-not-live`)。 +⛔ **两条主会话同时开着 ⇒ 一定被钉回旧的那条。** + +🔴 **两个已知坑(待修,改前先看 §9)**: +- **前缀判据不认方括号**:代码认 `startswith("主控")`,而实际标题常写成 **`[主控]-…`** ⇒ 判 False ⇒ 那条主会话**只能靠"最近活动"兜底**,**本工作区任何一条会话活动更新就会静默把它顶掉**。⇒ 命名一律用 `主控 · …`(⛔ 别加方括号)。 +- **"登记还活着"压过"显式前缀"**:短路顺序是「登记」在「前缀」**之前** ⇒ 旧窗口还开着时新主会话**永远接管不了**。正确优先级应是 **显式前缀 > 登记 > 最近活动**。 + +⚠️ 活会话集合为**空**时(枚举失败)会**反而信任登记**(解析出一条早关掉的会话)—— 不误投(后续仍拒投),但**归因会误导**。 + +⚠️ **一条正在跑的会话会自带一个网关口**(`live=它自己`),**跑完口就消失** ⇒ 别把"某个口曾经 live 过它"当成"现在投得进去"。 + +--- + +## 2 🔴 主会话只做两件(其余下沉) + +- **判断**:按 `任务图.json` 的判据判"节点是否真的完成"(⛔ 不看自述,**看可核对产物**) +- **派活**:只派**依赖已满足**的节点,**优先关键路径** + +**⛔ 主会话不该做的**(做了就是走错通道):持续监控(⇒ 常驻程序)/轮询别的会话干完没(⇒ 读库)/机械核对(⇒ 常驻程序)。 + +--- + +## 3 🧭 防干等 = 显式任务图 + 关键路径(**总工期问题的唯一正解**) + +`任务图.json`:每节点写 `id / title / line / deps / status / evidence|blocker`,并给 `critical_path`。四条派活规则: + +1. **只派「依赖已满足」的节点** ⇒ 能并行的**立刻并行** +2. **优先关键路径**(它决定总工期;非关键路径押后不拖工期) +3. **一条线同时只挂一个**,**多条线可同时挂** +4. **收口即派(+3~4 分钟)**,⛔ 不要 +5~8 分钟空窗(🔴 2026-10-01 用户口径:基准 = 收口 + **3~4 分钟**) + +🔴 **两个必检的浪费信号**: +- **可派未派**:可派集合非空 ∧ 没有任何棒在跑 ⇒ **有活没人干** ⇒ 立即派 +- **关键路径单线化**:关键路径上只有一个执行主体 ⇒ 把它**拆成更小的可验步骤**,能交给别的线的部分**并行出去** + +📄 规范 + 模板 ⇒ **`references/taskgraph.md`**(含节点写法与"可派/等待"算法) + +--- + +## 4 🔴 证据分级:**真成果 ≠ 接续任务**(不区分就会被"空转链条"骗) + +| 类 | 判据 | 算不算进展 | +|---|---|---| +| **✅ 真成果** | 运行记录为**完成且有结论**,**最好有可核对产物/读数**(文件 / rc / 端口 / 数据对照) | ✅ 算 | +| **🟡 接续任务** | 只是**新增了一条排期**(执行方自己排的下一棒) | ⛔ **不算** —— 只证明「有下一步」,不证明「这步干成了」 | +| **🏃 刚开始跑** | 有运行记录但**尚无结论** | ⛔ 还不算 | +| **⛔ 哑火** | 到点却没产生运行记录 | 🔴 **当异常**(链条断了) | + +⚠️ **"在跑的棒 N 个" 是排期数,⛔ 不是成果数。** + +--- + +## 5 🔴 新建自动化 = 白名单 + 确认制(否则会退化到"啥都用自动化") + +**只有两类可不经确认直接建**: +1. **接续会话** —— 把某条链/某一线的**下一棒接上** +2. **给其他会话安排任务** —— 派活 + +⇒ **其余一切用途**(监管轮/巡检/检查点/体检/观测/清理/日报…)**必须先取得用户确认**。 +**执行**:建前自问这两问;**答不上 ⇒ 报给用户等确认**(⛔ 不许先建后报)。✅ 删冗余**不属新建** ⇒ 可直接做但须报告。 +🔴 **配额**:一个需求线的常驻自动化 **≤ 2**(1 唤醒 + 必要时 1 截止类)。 + ⚠️ 🔴 **2026-10-02 标注(⛔ 不是口径变更)**:这里那个「**1 唤醒**」**只在代偿形态下成立**(常驻停机 ⇒ 用排期顶替时钟);定案的唤醒时钟是**常驻投递**(⛔ 不占排期名额)。 +⚠️ **但"连通知主会话都要靠自动化"是错的** —— 投递走**宿主钩子**(`--tick`),⛔ 不加排期(`pitfalls.md P0-3`)。 + +--- + +## 6 ⚠️ 自愈/守护的硬纪律(都是踩出来的) + +1. **自愈必须带去抖**:**连续 N 次(≥3)失败才动手** + **冷却**。否则"自愈"会**反复杀掉正在服务的好实例** —— **比故障本身更伤**(实测)。 +2. **⛔ 不用会话后台任务跑长跑**:它每轮输出会**唤醒宿主会话** ⇒ 会话永不空闲 ⇒ 用户看到「卡死」。 + ✅ **可行形态**:由**用户在自己的独立窗口**起(`cmd start` / `wmic` / `Start-Process` 常被安全策略拦,⛔ 别在这上面耗时间)+ **完全静默**(只写自己的日志)。 + 🔴 **实测补充(2026-09-29)**:**从会话里起的常驻活不长** —— 它会随**其宿主会话结束被回收**(实测:`13:09` 起的 pid 在会话收尾后消失,`13:12` 后再无一轮,单例端口变 `Connection refused`)。 + ⇒ **两条一起用**:常驻只当"锦上添花";**机制必须有一条「钩子事件驱动」的腿**(每个会话收尾跑一轮),否则常驻一死,监控/队列/唤醒**全部静默停摆**(而没有人会发现)。 +3. **单例**:常驻程序独占一个本地端口 ⇒ 防重复实例双写。 +4. **fail-open 会掩盖字段错误**:异常全吞 ⇒ 查询写错也"rc=0" ⇒ **必须核对输出非空**,⛔ 不能只看退出码。⚠️ **视图文件要定期 diff 一眼**。 +5. **域锁要切细**:按**节点涉及的文件/子目录**声明;⛔ **禁止整工作区域粗域** —— 那是**自造串行瓶颈**(实测造成多次「抢锁失败 ⇒ 整轮白开」的纯浪费)。 +6. 🔴 **别"改一行重启一次"常驻程序**:用**会话后台任务**起常驻**本身已被禁**(见第 2 条);而每次 kill/launch 都会留下 `failed` 通知 ⇒ **反复打断主会话,把自己弄卡**(实测一个阶段重启 7~8 次)。⇒ **攒批重启**(≥3 处改动一次)+ **规则/配置做成热加载**(每轮读文件)+ **长任务交棒换会话**。详见 **`references/pitfalls.md` P12**。 + +📄 完整踩坑清单(含每条的现象/根因/修法)⇒ **`references/pitfalls.md`** + +--- + +## 7 落地:三个件(部署顺序) + +| # | 件 | 作用 | +|---|---|---| +| 1 | **常驻程序** `scripts/collabd.py --once`(**投影轮**) | 判定(证据分级/推进/真空/哑火)+ 任务图「可派未派」+ 体检 + 视图 + 需求台账。**⛔ 不派活、⛔ 不投递、⛔ 不推进队列** | +| 2 | **投递** 同文件 `--tick`(**投递轮**) | **逐条读队列 ⇒ 投给主会话**(单条+握手/三条件唤醒)⇒ **唯一投递方+唯一队列推进方** | +| 3 | **任务图** `任务图.json`(模板见 `references/taskgraph.md`) | 显式依赖 + 关键路径 ⇒ 防干等 | +| 4 | 🔴 **钩子接线**:宿主在 **`UserPromptSubmit`** + **`PreToolUse`(matcher `^Bash$`)** 上调用本地脚本 ⇒ 脚本内部再跑 `--once` 与 `--tick` | **这是"投递"的全部** —— ⛔ **不需要排期、⛔ 不需要常驻**。钩子是宿主子进程 ⇒ 自带网关口令(能投递)、⛔ 不占会话、零 token。⚠️ **接线必修**:工作区若另存**旧副本**,钩子指向它 ⇒ **回路等于没接**;⚠️ **新加的钩子事件需宿主重启后才生效**(`UserPromptSubmit` 那条立即生效) | + +**配置**:`scripts/collabd.config.example.json`(workspace / 行名 / 目标件 / 任务图 / 看板 …)⇒ 复制成 `collabd.config.json` 改路径即可复用。 +**用法**:`--once`(目标检查·投影轮)|`--tick`(投递·**投递轮**)|`--report`(任务会话上报)|`--reconcile`(启动对账)|`--where`(路径自证)。 +🔴 **投递真绿的判据**:⛔ 不是 `rc=0`,而是 **`wakeups.jsonl` 新增了一行 `ok:true`**。 + +--- + +## 8 自检(每次派活前问这四句) + +1. 这件事**非得开新会话**吗?(机械 ⇒ 常驻程序;判断 ⇒ 已有唤醒) +2. 我要派/建的东西**在白名单**里吗?不在 ⇒ **先问用户**。 +3. **依赖满足了吗**?它**在关键路径上**吗?不在 ⇒ 它能不能押后? +4. 我是靠**可核对产物**判"完成",还是靠**自述/排期**?(后者 ⇒ 那是"接续任务",⛔ 不算成果) + +--- + +## 9 🔧 维护 `scripts/collabd.py` 的硬要点(2026-09-29 实测,⛔ 别再踩) + +**两条实测事实(推翻了原设计的隐含假设)** +- `automation_runs.status` 实测**全是 `ACCEPTED`,从来没有 `IN_PROGRESS`** ⇒ ⛔ 不能当「有人在跑」信号。 +- 一次性棒跑完 `next_run_at` **归零**(实测全 `0/None`)⇒ ⛔ 也不能当信号。 +- ⇒ **唯一可得的「有人在跑」= `sessions.status='working'`**(取值域:`archived/completed/error/working`)。 + ⚠️ 用它判「有没有人在推进」时**必须排除观察者自己**(否则主会话自己跑着 ⇒ 永远报"有人在跑",用户问的"是不是又发呆了"就永远答不出来)。 + +**改本程序的标准手法(⛔ 别手改单点)** +1. 先备份到工作区 `tmp/bak-collabd-<日期>/`(⛔ 不在技能目录里留副本)。 +2. 用**断言式补丁脚本**:`old/new` 成对替换,**任一锚点 `count != 1` ⇒ 整份不写盘**;写完 `ast.parse` 门禁 ⇒ 杜绝"半改状态"。 +3. ⚠️ **锚点必须先用 `repr()` 核过** —— Read 工具的行号切分与本文件真实字节**不一致**(长行会被折),照抄 Read 的显示会静默失配。 +4. ⚠️ `Path.write_text()` 在 Windows **静默把 `\n` 转成 `\r\n`** ⇒ 写完必比对 `md5sum` 与内存 md5;不一致就按 `newline="\n"` 归一化回 LF。 +5. 🔴 **它不是常驻进程**(由 `UserPromptSubmit` 钩子按需 `--once` 调起)⇒ **改完下一轮钩子即生效,⛔ 无需重启**。 +6. 验证:连跑 `--once` 看 `rc` + `queue.json`/`digest.md`/信号文件(`NEXT`/`STALL`/`VACUUM`/`READY`)是否与预期一致。 + +**队列的四个部件(各有唯一权威)** +- `claims//holder` = `<会话名>@<线>@<完整会话id>` —— 「谁在做」的唯一权威;**第 3 段别省**(有它才能判持有者是否已终结 ⇒ **立即出队**;没有就只能等 20 分钟超时,期间队首会僵尸化并**反复复活**)。 +- `blocked.json` = `{"<节点id>": "<原因·谁在等>"}` —— 受阻件 ⛔ 不当队首(否则每次唤醒都白跑一遍同一个受阻件)。**解除后记得摘掉**。 +- `gate-done.stamp` + `claims` 缺失即 `gate=free`:闸门靠**钩子事件**驱动,⛔ 不靠轮询。 +- 🔴 **「同线互斥」取 `holder` 的第 2 段**(线名)** —— 取第 1 段(会话名)会恒不相等 ⇒ 该机制**静默失效**(实测:同一条线可被同时派多件)。 +- 🔴🔴 **「算不算做完」以需求台账(`tmp/supervise-inbox/tasks.json`)为准,⛔ 不是以任务图节点为准**(**2026-10-01 加**)。 + 病根:**任务图是纯手维护件 —— 全仓无任何代码回写它**(实测 `TG` 在 `collabd.py` 里只于 3 处被**读**,无一处写), + 而会话「上报完成」只写台账 ⇒ **两处必然漂**,且症状极隐蔽:`claims/` 是空的、没人占线, + 但**队首出不来** ⇒ 它被反复当"可派未派"喊,`READY.md`/`STALL.md` 每小时喊一遍同一件,控制台判「目标未完成」。 + 实测(M5):台账 `done`、上报单 `done`、产物已核对(`wakeups.jsonl` 第 82 行 `http:200 ok:true`), + 任务图仍留 `running` ⇒ **整条链被一个早已做完的件卡死**,`goalctl` 只会说「目标状态 = **未完成**(任务图 M5)」,⛔ 不告诉你是"早就做完了"。 + ⇒ **现行口径**:`图 done ∪ 台账 done` 都算 done(`deps` 解析同理,前置满足即算满足)。 + ⚠️ 实现落在**两处、必须同口径**:`collabd.taskgraph()` 与 `goalctl.goals_open()`(⛔ 只改一处 ⇒ 控制台与看板两边打架)。 + ⚠️ **不改任务图本身** —— 它仍是「分解件」,人可手改;只让"算不算做完"以台账为准。 +- 🔴 **目标「三路全过」⇒ 终态:⛔ 不产任何告警**(**2026-10-01 加**)。原实现只认 `goal.json.run != active`, + 目标是全过了、`run` 却仍是 `active` ⇒ 照产 `STALL`/`VACUUM`/`READY` ⇒ **把「做完了」读成「坏了」** + (实测:M5 解卡后立刻喊「卡住:有会话在跑但 52 分钟无成果」)。 + ⇒ 现口径:`--once` / `--tick` 在 `goal_paused() or not goals_open()` 时走 `paused_round()` —— + 只留一行**终态说明**、清掉四个信号投影、⛔ 不投递;**看板也一并收口**(否则它会永久停在最后一张"还在跑"的快照)。 + +**🔴 它到底什么时候才会跑(最容易误解的一条 · 2026-09-30 更新)** +- `--once`(投影)挂在 **`UserPromptSubmit`**(节流 180 s)+ **`PreToolUse ^Bash$`** 上 ⇒ **有人发话、或任何会话跑一次 Bash** 就会被调起。 +- `--tick`(**投递**)挂同样两个事件(节流 120 s)⇒ **投递与"有事发生"同刻**。 +- ⇒ **不再需要靠宿主排期"让机制活着"**(旧结论「没有宿主排期 = 确定性静默」**已作废**)。 + ⚠️ **剩下的唯一真相**:**"全员静止"期间**(没人发话、任何会话都不跑 Bash)**不会有通知飞出去** —— 那是**为摆脱排期而明确接受的代价**; + 停滞类信号由**会话外**的守护落 `STALL.md`/`NEED-USER.md`,**下一次任意宿主事件时补投**。 + +**`idle`("多久没成果")只认「真成果」**:服务探针的状态变化属**服务层** —— 既不重置 `idle`、也不进 `🟢`。 +(实测教训:4 小时静默里程序那唯一一轮恰好赶上探针「不通→通」⇒ 摘要写「1 分钟无成果」而不是「4.5 小时」⇒ 连一声告警都没有。) + +**受阻 ⇒ ⛔ 不能静默**:选不出队首但 `blocked.json` 非空时**仍写 `NEXT.md`**(受阻通报)—— 否则就是「有活 ∧ 谁都动不了 ∧ 程序一声不响」。⚠️ 写 `NEXT.md` 时**内容不得含时间戳**,否则唤醒回路的**内容哈希去重**失效 ⇒ 每轮都投一遍(刷屏)。 + +**唤醒回路的两个触发面**:① `NEXT.md` 存在且 `gate=free`;② `NEXT.md` 不存在但**停滞 / 可派未派**(哈希须用**粗粒度键**:种类 + 空闲按 30 分钟取整,⛔ 不能用带时间戳的正文)。 + +**🔴 链条不能只靠「上一棒派下一棒」**:棒中途死掉(实测发生过:探针回环打到自己会话)⇒ 链条**静默断掉**,只能等下一次有人发消息。 +⇒ ⛔ **不要靠发明新件来兜底**(我发明过"+30 分钟看门狗",已废弃)。**正解在架构原文里**:**收尾自判**(在**已存在**的会话里判本线缺口并写下一行 `automations`)+ **每小时兜底唤醒**(定稿 §3②,专防链条断掉)。改前先过 §10。 + +--- + +## 10 🔴 改本机制前必须过的「防打转判据」(架构原文 · 顶层设计 §7) + +> 原文原话:**"以后凡是给这套系统加东西,先过这张表 —— 只要让『必须存在的东西』或『需要记得清理的东西』变多,就要停下来重新想,而不是继续补。"** + +| 判据 | 目标 | +|---|---| +| 自造件数 | **≤3**(机械判定脚本 / 看板 / 规则文档) | +| 🔴 **自造协议数**("需要记得清理"的) | **0** —— ⛔ 不自造 `mkdir` / flag 去抖:**域锁就是单例** | +| 必须活着的**进程**数 | **0**(⛔ 不用会话后台任务) | +| 必须存在的**会话**数 | **0** | +| **额外会话 / 棒** | **0**(收尾自判在**已存在**的会话里完成) | + +**四条地基(顶层设计 §1/§2)**:① 状态**只读宿主库**(`mode=ro`)② 调度**只靠 `automations` 一行**(⛔ 不自建调度器)③ 互斥**只用既有域锁**(⛔ 不自造第二把锁/第二套去抖)④ 执行体**无状态**(会话死了状态不能跟着死)。 +**正确形态 = 2 个声明式操作 + 1 份规则**:**派活** = 往 `automations` 写一行(不"叫会话");**收结果** = 读 `automation_runs`(⛔ 不轮询、不催、不问)。⇒ **「监管者」这个角色不存在**,只有**两个动作**:**收尾自判**(在已跑的会话里)+ **兜底唤醒**(⚠️ 原文写「宿主排期」—— 🔴 **2026-10-02 标注(⛔ 不是口径变更)**:定案的兜底唤醒走**常驻投递**,排期只是**代偿**,见 §5-1)。 + +**🔴 权威清单(改任何判定前先看它 —— 权威只在这四处)** + +| 要判什么 | 权威(唯一) | ⛔ 不许 | +|---|---|---| +| 谁在跑 / 哪条线被占 | `sessions.status='working'` + 其 `cwd`(末段=线名) | 自己写 `holder` 文本再解析 | +| **多久没成果** | `automation_runs.updated_at`(毫秒 epoch) | 自己维护 `last_progress_at` 时钟 | +| 有没有排期(会不会有人自动跑) | `automations.next_run_at` **未来 1 小时内** | 看"全库有没有排期" | +| 一棒做完没有 / 结论是什么 | `automation_runs`(`thread_title`;⚠️ `thread_id` 形如 `:1`,**不是**会话 id) | 轮询别的会话 | + +⇒ **自己写的文件只有两种合法身份**:**投影**(可删可重建:看板 / `NEXT.md` / `queue.json`)或**人工输入**(如 `blocked.json`)。⚠️ 凡"需要记得清理"的一律不许(`claims`/TTL/僵尸目录都属此类)。 + +--- + +## 11 🔴 术语表(**说话/写文档一律用「正式名」这一列**) + +> 2026-09-29 用户指出"宿主是什么/机械程序又是什么,之前只提过**投递**和**常驻程序**" —— 属实:**同一个程序在文件里有 4 个名字**,图当然读不懂。**以本表为准。** + +| 正式名 | 实体(在哪) | 干什么 | ⛔ 别再叫它 | +|---|---|---|---| +| **宿主** | WorkBuddy 本体 | 提供五样:状态(只读 3 表)/**调度**(自动化排期)/**事件**(钩子)/执行体(会话)/互斥(域锁) | 「平台」「服务端」 | +| **投递** | `advance-watch.py`(`.workbuddy/tools/`) | 零 token 机械判定:直读 `automation_runs`、判靶点/锁,写 `advance.md` + `needs-ai.json`。⛔ 不派活/不开会话/不联网/不读口令 | 「机械层程序」「机械程序」「看门狗」 | +| **常驻程序** | `collabd.py`(自述"协作守护程序") | 队列闸门(`NEXT.md`)+ **唤醒投递** + 证据分级/真空/断链告警/体检/看板覆写。**⛔ 不做派活** | 「判定层」「常驻程序」「机械层」 | +| **会话** | `sessions` 表 | 执行体:「读—判—写」;**一次性**、可替换 | —— | +| **棒** | 一次派活 = 一个会话 | 同一件事的执行单位(「一棒一线」) | —— | +| **看板** | `scripts/board.py --serve` → `assets/board.html` | **用户唯一入口**(只读旁路 · 异步快照) | ⛔ 别把 `digest.md`(机械摘要)当用户入口;⛔ 也别再提已退役的 `交付物/手机接入-实时状态.md` | +| **设备接入 · 本地反代** | `dsh-client` · 真身 `@dsh-client/device-shim`(`127.0.0.1:20090`) | 只绑回环的 HTTP+WS 反代,**在进程内注入本机 DSH 的会话凭据**,供覆盖网络中继从设备入口访问桌面 DSH。⚠️ 该包已标「已退役」,计划并入 `@dsh-local/ai1net` 的第 6 块模块**M6「设备接入」**,但**尚未落地**(仓库里还没有 `ai1net` 包) | ⛔ 别再叫它「**垫片**」「shim」「薄垫片」 | +| **覆盖网络节点 · 中继客户端** | `dsh-client` · `/lib/net/relay/main.js --client`,由 `overlay-node-daemon.ps1` 监护 | 本机作为**节点**接入覆盖网络:向中继注册(`registered host=… accepted=[20090]`)、维护长连、转发流 | ⛔ 别再叫它「**设备侧 worker**」「worker」「节点进程」 | + +### 11.1 🔴 命名铁律(用户 2026-09-30 明令) + +> 用户原话:「垫片 20090 在线,设备侧 worker 在线:**禁止用这么抽象的词**, +> 用 **系统-模块-功能名**(系统-功能名)」 + +- 🔴 **凡涉及外部系统/组件,一律写成「`系统` · `模块` · `功能名`」** —— + 系统=它属于哪个系统/仓库(如 `dsh-client`、`ai1net`);模块=在这个系统里算哪一块(如 **设备接入**、**覆盖网络节点**); + 功能=它干的活(如 **本地反代**、**中继客户端**)。简写时至少保留 **`系统 · 功能名`**。 +- ⛔ **不许自造简称**("垫片"/"worker"/"两条腿"/"有腿掉了" 这种),**⛔ 也别只报端口或只报英文包名**当名字。 +- ⚠️ **报"在线"必须说清是哪一段的什么状态**,且 **⛔ 不许由"两段都在线"推出"链路通过"** —— + 实测教训:中继客户端 `state=up` 而 `streams=0`(**从未有请求真正被转发**)⇒ 那时写"这条链是通的"=**过度断言**。 + ⇒ 判「端到端通过」的**唯一判据**是**中继真转发过流**(`streams>0`);否则一律写「**未验证**」。 +- 📌 落地处:`board.py` 顶部有本铁律的注释块 + `front` 字段全部改用真名;看板「链路前置」卡按真名渲染。 + +> ⚠️ **以下为 2026-09-29 的史实快照**(当时的"现状打架"),**⛔ 不是现状**;现状以 `architecture.md` 头部「当前结论」为准。 + +1. ~~`advance-watch.py` 死活之争~~ —— `协同监管棒-SOP.md §276` 说它「已吸收退役」,而 `顶层设计`/`定稿`/`实施方案`/`CODEBUDDY.md` 仍把它当活件。**已随退役件一并作废。** +2. **同一程序多个名字**:任务图叫「协作程序」、它自述「协作守护程序」、原技能叫「机械层/常驻程序」、实施方案叫「机械层脚本」⇒ 统一口径见 `architecture.md §7 术语`。 +3. ~~同一程序 2 个路径~~ —— 旧副本 `~/.workbuddy/skills/multi-session-collab/` **已于 2026-10-01 归档退役**,现只剩一份:本包 `scripts/collabd.py`。 + +--- + +## 12 文档权威(治"架构都是乱的" —— 原技能时代的问题) + +> 🔴 **本节原声明「本技能 = 协作机制的唯一权威」—— 已作废**。并入本包后,**唯一权威是 `references/architecture.md`**(见本件头部)。 + +**当时的病**:曾有 **5 份"架构"并存且互不认** —— `交付物/多会话协同机制-定稿`/`…顶层设计`/`…实施方案`/`协同监管棒-SOP`/原技能本身; +实测出 **5 处互相矛盾**(一棒做完怎么办 / 「监管棒」算不算角色 / 投递的死活 / 钩子生效了没 / 钩子调的是哪一份程序)。 + +⇒ **现已收口**:架构**只认 `architecture.md`**;那 4 份业务件 **2026-09-30 已加退役标注**,可作背景,⛔ 不作用判据。 diff --git a/session-mechanism/references/collab.md b/session-mechanism/references/collab.md new file mode 100644 index 0000000..425de18 --- /dev/null +++ b/session-mechanism/references/collab.md @@ -0,0 +1,175 @@ +# 多会话执行 · 一页纸(怎么派活、怎么收活、怎么不干等) + +> 🔴🔴 **2026-10-03 07:2x 状态块(本条为准,排在下面所有小节之上)——「上报/投递」整套退役** +> +> · ⛔ **投递已真删**(`supervise()` 203→71 行、`wake_enable` true→false)⇒ **§3 里"唯一投递方"那半条作废**、 +> §4 的**唤醒会话/跟进会话两类已整套退役**(⇒ **§4 现只剩两类:主会话 + 任务会话**)。 +> · ✅ **现行机制只有两条腿**:**接收**=`--report` 写 `tasks.json` 四态台账(唯一权威); +> **处理**=建 `[检查]-…` 排期(`maybe_spawn_check_agent()`,唯一载体=常驻 `--supervise`)。⛔ **没有"推送"这一环**。 +> · 🔴 **「常驻投递」这个角色名也别再用** —— 角色本来就叫「**协作程序**」,"常驻"是**运行形态、不是角色名**。 +> · ⇒ 逐条改未做(散在 4 份约 200 处);**冲突时以本块 + `architecture.md` 当前结论节为准**。 +> 机制细节 ⇒ `SKILL.md` 文首 10-03 状态块;坑 ⇒ `pitfalls.md` P0-35 ~ P0-38。 + +> ⚠️ **深度以 `references/architecture.md` 为唯一权威**(本文件是**入口与判据**,⛔ 不另立第二份架构)。 +> 踩过的坑以 `references/pitfalls.md` 为准;任务图细则见 `references/taskgraph.md`。 + +## 1 一句话 + +**派活用自动化(唯一能开新会话的通道)|收结果直读宿主库(0 token)|机械判定下沉到本地只读脚本|AI 只在需要判断时被叫起|用任务图 + 关键路径防干等。** + +## 2 四条通道(哪件事走哪条 —— 走错就是最大的浪费来源) + +1. **派活 ⇒ 只能走自动化**。宿主钩子**开不了新会话**,这是硬事实;所以「叫起下一个干活的人」只有自动化这一条路。 +2. **收结果 ⇒ 直读宿主库**(`automation_runs` 等,宿主已把各线结论写好)。⛔ 不要为了"问一句进展"去叫一个会话 —— 那是 0 收益的开销。 +3. **机械判定 ⇒ 下沉到本地只读脚本**(`collabd.py --once` 等)。这类事不需要 AI 判断,⛔ 不要占用会话。 +4. **人只看一个看板** ⇒ 只读旁路观测,异步、可延迟,**⛔ 不许影响程序执行**。 + +## 3 节奏:常驻为主,钩子作补充 + +- 🔴 **触发源 = 常驻的目标检查 + 投递**(09-29 定案,**2026-10-01 用户再确认**:「**协作与投递一直运行(常驻)**」)。 + 理由:**⛔ 不是所有队列都由钩子产生** ⇒ 纯事件驱动**会漏**。 +- ⛔ **自动任务("当闹钟")方案已废弃**(用户 2026-10-01:「**定时任务的方案已经废弃了**」)⇒ ⛔ 不要再建。 +- `collabd.py --once` = **投影轮**(维护队列,**不投递、不推进**)。 +- `collabd.py --tick` = **投递轮**,🔴 **唯一投递方 / 唯一推进方** ⇒ **由常驻投递按周期跑**。 +- 🟡 **宿主钩子只作补充**(事件驱动的即时性),⛔ **不是主路径,⛔ 不得用它取代常驻**。 + +## 4 四个"会话类别",别混(🔴 2026-10-01 用户口径 · 由「三个角色」改) + +> 🔴 **四类都在同一个工作区**(`config.workspace` 那一个目录),**靠标题两级前缀区分**(第 1 级=角色、第 2 级=任务类别),⛔ **不按 `cwd`、⛔ 不按 `workspaces` 表**。 + +- **主会话**:🔴 **只管目标和方向**(判断与派活)。它是**解析出来的、不是登记出来的** —— 三层规则全在 `resolve_main()` 一处(见 `architecture.md §7`)。🔴 **只由用户触发**,⛔ 不接机制推送。 +- **任务会话**:承接具体活。看板上=**下面那个虚线大框**里那一排(有几个排几个;一个都没有时画**虚线空框**,⛔ 不许省略整层)。 +- **唤醒会话**:🔴 三条硬定性 —— **本质还是会话** / **独立会话(⛔ 不在主会话上处理)** / **随需求确定时才创建**。看板上它在**主会话下面那一行(`RW`)左格**、与右格**跟进会话等宽**,靠**位置**区分、位置不变;「还没建」是**正常态**、⛔ 不是故障。 +- 🆕 **队列上报的跟进会话**:🔴 用户 2026-10-01 口径 —— **主会话只管目标和方向**,「**队列上报的会话 让专门的 跟进会话处理**」。✅ **投递路由已落地**:按件的类别选「**跟进会话**」(`follow_for_topic()`),**⛔ 绝不投主会话**;连一条跟进都解析不出/已有但不活 ⇒ **喊用户**(⛔ **不降级投主会话**)⇒ 详见 `architecture.md §2.3.0c`。 + +🔴 **看板上的两个「虚线大框」= 分组,⛔ 不是节点、也⛔ 不是新一层**: +上面那个框住「**主会话 + 唤醒会话 + 跟进会话**」(=**用户那一面 + 机制那一面**),下面那个框住**任务会话**; +**两个大框之间只有一条线 = ① 派活**(⛔ 不再从主会话画多条扇形线出去)。⚠️ 改框宽/改格宽必须**重算框**(框由该排布局现推,⛔ 不写死)。 +⇒ 位置、形状、命名沿革与两道自检:`collab-detail.md §0.5.5`;布局常量与两道自检:`architecture.md §2.3.0c-2`。 + +### 4.1 🔴🔴 **开工清单:主会话开工时**必须**把三类会话建齐**(用户 2026-10-01 明令) + +> 用户原话:「**开始会话完成需求的时候,主会话需要创建 唤醒会话 以及根据分工类别 创建 协作会话 +> 和 跟进会话呢 不然整个机制跑不起来**」 + +| 该建什么 | 标题形态 | 少建 ⇒ 断在哪 | +|---|---|---| +| **唤醒会话** | `[唤醒]-<类别>-<具体>` | 没人推 → 需求停在原地(实测 3.5 h 零成果) | +| **任务会话**(**每个分工类别一条**) | `[协作]-<类别>-<具体>` | 没人干 → 该类别解析不出承接者 | +| **跟进会话** | `[跟进]-<类别>-<具体>` | 没人收 → 队列上报全挤回主会话 | + +**三条硬约束**:① **只有自动化能开新会话** ⇒ "建会话"=**登记一条自动化**,⛔ 不是自己 spawn; +② 标题**第 2 级必须带方括号**、值取 `goal.json` 的 `topics`(⛔ 用 `short` ⇒ 静默漏管); +③ **"还没建"是正常态**,但**开工就必须建**,看板上如实显示成灰 ○。 +⇒ 细则与踩坑:`architecture.md §2.3.0d`。 + +### 4.1b 🔴🔴 **缺会话 ⇒ 自动拉起**(用户 2026-10-02 口径 · **运行期也要做**,⛔ 不只是开工那一刻) + +> 用户原话(触发面,逐字):「**是用户说 使用协作会话方式 完成目标 或 继续完成目标**」 + +- **触发**:用户说这两句(等价说法「继续执行」也认),**或**队列堵住(投递报 `follow-not-live` / `no-follow-session`)。 +- **动作**:**先查齐备度** ⇒ **缺 ⇒ 自动拉起**。 + ⛔ **不许**写 `NEED-USER.md` 请用户自己去开一条会话 —— 那是把机制该干的活推给人。 +- 🔴 **齐备度怎么算(2026-10-02 订正)**:**协作/唤醒按类别**各算一条;**跟进是全类别唯一席位**—— + `collabd.py --gap` 对它**不按类别分桶**(桶键恒 `(follow, "")`),一个工作区从头到尾只应有**一条**活的 + 跟进会话。⛔ 别按类别建出 N 条 `[跟进]-[<类别>]-…`(本棒初版就建错成 2 条,已订正)。 +- **判据 + 现成参数**:`python scripts/collabd.py --gap`(只读)⇒ 列出缺口 + 每条该建的排期 + (name / prompt / scheduleType / scheduledAt / cwds)。硬缺=**跟进/协作**会话没有活着的; + **唤醒**会话算"软缺"(用户定性「随需求确定时才创建」)。 +- **谁执行**:**会话**(`automation_update`)。⛔ 脚本**不许**写 `automations` 表(双红线) + ⇒ 这是唯一通路,也是"自动"的全部含义。 +- **怎么送到会话**:钩子 `wb-result-hook.py` 在 `UserPromptSubmit` 把缺口**注入当前会话上下文** + (零 token;触发词命中即查,否则 120 s 节流查一次)⇒ 用户照常说那句话即可,**什么都不用做**。 + +🔴 **「接续会话」⛔ 不是第 5 类,是「形态」**:上面**任一类**撞到阈值(上下文/日志)时,**由它自己**建出下一棒 —— **角色继承被接续的那条**(主会话的接续仍是主会话候选)。详见 `architecture.md §2.3.0b`。 +🔴 **接续棒必须带类别**(`接续 · [<类别>] · <具体>` 或标题里出现类别名)—— +否则归属判据(角色+**类别子串**)认不出它,它会被**静默漏管**(不进看板、`--ready-next` 也不算它在跑)。 +⚠️ 主会话的续棒要**保住 `主控` 前缀**(`主控 · <类别> · 接续自 `)—— 否则它被判成干活的棒, +主会话候选会**变空**(P0-11 实测踩过)。 + +### 4.2 🔴🔴 **接续退位:建出下一棒之后,把前棒**退场**(用户 2026-10-01 立) + +> 用户原话:「**通过接续会话的时候,如何处理过期的会话,避免越堆越多**」 + +**为什么必需**:接续=会话自己建下一棒,而**前棒不会自动消失** —— 它在宿主库里永远是 +一条 `completed` 记录、标题同族。实测本工作区**已堆 5 条**「接续 · 会话机制合并…」⇒ +看板与状态稿每轮都为旧棒占位,**真在跑的棒被埋在里面**(越用越糊)。 + +**两条退场路径**(⛔ 一条都不许少): + +| 路径 | 怎么做 | 什么时候生效 | +|---|---|---| +| **显式**(准确) | 建出下一棒后立刻 `collabd.py --retire self --by <接棒会话id> --why 接续` | **立刻** | +| **窗口兜底** | 自动:`completed` 且超 `session_live_min`(默认 90)分钟未动 | 等窗口到 | + +⛔ **两条闸门都绕开 `working`**(在跑的棒一定要摊在版面上)+ **主会话是版面锚点**(⛔ 不按窗口抽掉)。 +⛔ **退场 ≠ 删除**:宿主库原样在、`retired.json` 全留痕(**可逆、可追溯**)⇒ 但**必须报数** +(看板/状态稿都会写「已收起 N 条」—— 收起而不说=读者把"收起了"读成"本来就没有",同族红线)。 +⚠️ **前棒中途死掉时显式那步不会发生** ⇒ 靠窗口兜底收(同"每小时兜底唤醒"的设计口径)。 + +### 4.3 🔴 主会话生病了就**换一条**,别指望它自愈 + +- **哑会话**(诊断日志撞 ~10 MiB ⇒ 宿主拒写 ⇒ **界面永远刷不出内容**)**照样在"活会话"列表里** + ⇒ 判据必须是「**活着 ∧ 不哑**」(`_pick_live()` 同时管这两条,`resolve_main` 的**每条路**都得走它)。 +- **处置(标准 runbook)**:把 `.log`(连同 `.log.1`)**改名挪开**(⛔ 不删,用 Python `os.rename`, + ⛔ 别用 `mv`)⇒ 宿主数十秒内重建并恢复写入 ⇒ 详见 `references/forensics.md §4`。 + ⚠️ **它必然复发**(每个会话涨到上限都会重演)⇒ 靠扫描器 + 钩子兜底。 +- **换主会话的逃生口**:`collabd.py --declare --role main` —— 🔴 它的语义是「**换**」: + 会把**其它登记为 main 的一律降级为 worker**(一个工作区一条主会话)+ **明确说出口**。 + ⛔ 旧语义是"加",等于**什么也没换**(`_main_sid()` 取第一条 main,而 Python dict + 对已存在的键赋值**不改位置** ⇒ 老那条永远排前面,逃生口形同虚设 —— 2026-10-01 实测踩到)。 + +🔴 **两个极易踩的读数坑**: +1. **唤醒会话的读数源是 `sessions` 表**(标题前缀 `[唤醒]`),⛔ 不是 `automations` 表。 +2. 🔴 **自动唤醒任务 / 协作轮的排期名必须带 `[协作]` 前缀** —— 否则会被 `resolve_main` 认成"主会话",**通知投给它自己**。 + ⚠️ 🔴 **2026-10-02 标注(⛔ 不是口径变更)**:本规则**只在「唤醒用排期」这个代偿形态下才有对象** —— 定案的唤醒时钟是**常驻投递**(⛔ 不开会话、没有排期名);⛔ 别因为这条规则在,就反过来认定「唤醒本该用排期」。 + +## 5 一次派活要带什么(模板要点) + +1. **开工第 0 步:抢域锁**(域按**显式 domain** 切,⛔ 不按 cwd、⛔ 不按工作区)。 +2. **本轮只做一件事,做完即停**(无人值守必写;否则它会一路做下去把预算烧光)。 +3. **收尾自判**:还有缺口 ⇒ 接下一棒;`blocked` ⇒ ⛔ 不接,改喊用户。 +4. 任务细节⛔ **不要抄进 prompt**(prompt 只写"去哪读",⛔ 不复制正文)—— 抄进去等于每轮都全量重发。 + +## 5.5 🔴 **任务会话的标准动作:六阶段**(用户 2026-10-04 定案) + +> 用户原话:「这个是**会话解决问题的步骤**(需求识别、需求调研、方案规划、任务执行、结果验证、归档清理)这几个步骤 **正式执行会话执行任务的流程**」。 +> 📌 **来源**:这套六阶段原是 `dsh-workflow §1`「平台改造六阶段」(DSH 平台改造流程); +> 用户把它**收进会话机制** ⇒ 它现在是**任务会话的正式执行流程**,⛔ 不再只是平台改造专用。 + +**六阶段**(任务会话从接手到收口的标准动作,⛔ 跳步是本机制最常见的失手): + +| # | 阶段 | 这一阶段要做什么 | 缺了会怎样(⛔ 后果) | +|---|---|---|---| +| **0** | **需求识别** | 先盘点现状再动手:派活说清「要做什么 / 怎样算成功」,能落到**功能卡 4 问**就落到 4 问 | ⛔ 直接开工 ⇒ 做的是"我以为的",不是"要的" | +| **1** | **需求调研** | **源码级实证优先** ⛔ 禁止只靠文档推断;查清现状与既有约束 | ⛔ 照着过时文档改 ⇒ 改错对象、返工 | +| **2** | **方案规划** | 文档先行;方案定完再动工;🔴 判"该不该问用户"就在此刻(见下) | ⛔ 边做边想 ⇒ 方向错了已写了一半代码 | +| **3** | **任务执行** | 小步推进 + 每步可验;按 §4 派活通道执行,⛔ 不越界抢别的域 | ⛔ 一口气做完 ⇒ 中途错了不知道错在哪 | +| **4** | **结果验证** | **看产出物** ⛔ 不看任务图 `done`(那是两件事,见 §6);能用浏览器验就浏览器验 | ⛔ 拿"标了完成"当"真完成" ⇒ 假收口 | +| **5** | **归档清理** | 文档同步 + 产物落到**本目标的交付物** ⛔ 不散在工作区根 | ⛔ 下棒找不到上棒产物 ⇒ 链条静默断掉 | + +🔴 **每个阶段都能撞上"该自己定还是该问用户"** ⇒ 那一刻的判据读 +**`references/02-功能优先协作协议.md`**(9 类自决策白名单 / 只准提报用户 3 类 / 语言转换表 / 拆包提报用户)。 +⚠️ **最常提报用户的是阶段 2(方案规划)**:功能语义分叉 ⇒ 必须问;技术选型 ⇒ ⛔ 不许问,自己定并记一句"我选了什么(可推翻)"。 + +📌 **与 `dsh-workflow` 的分工**(⛔ 别把两处当同一件事): +本节 = **任务会话在会话机制里干活的标准动作**(六阶段,落地口径在本包); +`dsh-workflow §1` = **DSH 平台改造的完整手册**(六阶段之外还有红线 R1–R11、并行调度三把锁、档案模板、浏览器验证栈) +⇒ 需要红线 / 锁 / 模板时 ⇒ 读 `dsh-workflow`;⛔ **只做会话内的活,本节足够**。 + +## 6 反馈协议与唤醒(防"干等"与"空转") + +- **反馈协议 = 单条 + 握手**(⛔ 不是群发、⛔ 不是广播)。 +- **唤醒 = 三条件合取**才触发(条件见 `architecture.md §2.2`)。 +- **需求内闭环**:一个需求内的活由该需求自己的会话闭环收口(定义见 `architecture.md`「需求内闭环」节)。 +- 🔴 **防"空转链条"**:**「只是排了下一棒」≠「有成果」**。判完成必须看**产出物**,⛔ 不看任务图节点标了 `done`(那是两件事)。 + +## 7 任务图(防干等的核心件) + +细则 ⇒ `references/taskgraph.md`(文件格式、`collabd.py taskgraph()` 算法、四条派活规则、维护纪律)。 +一句话:**关键路径单线化 ⇒ 必须拆**,否则整条链被一个慢节点卡住,其他人全在干等。 + +## 8 加东西之前必须过的判据 + +`architecture.md §6`「加东西前必须过的判据(防打转)」。⛔ 没过的就⛔ 不要加。 +⚠️ 但**「协作与投递的常驻」是已定案项**,⛔ **不得拿"进程数=0"去否它**(2026-09-30 踩过:丢掉唤醒时钟 ⇒ 用户点破"成摆设")。 diff --git a/session-mechanism/references/deploy.md b/session-mechanism/references/deploy.md new file mode 100644 index 0000000..62b61f3 --- /dev/null +++ b/session-mechanism/references/deploy.md @@ -0,0 +1,212 @@ +# 换一台机器怎么用(部署手册) + +> ## 🔴 当前结论(**先读这里** · 最后更新 2026-09-30 05:55) +> | 项 | **当前结论** | +> |---|---| +> | **本机起法** | 唯一可行 = **宿主后台任务机制 + `stdout` 全重定向到文件**(完全静默)。⛔ `detached` 活不过工具调用边界;⛔ `schtasks` 被**内置程序黑名单**硬拦。 | +> | **投递** | 🔴 **定案:协作与投递「一直运行」(常驻)** —— 09-29 定案,**2026-10-01 用户再确认**(原话「**协作与投递一直运行(常驻)**」;理由「**可能不是所有队列都是钩子产生的**」)。⚠️ **现状:守护处于停机**(2026-09-29 23:59 止损;其归因已于 09-30 审计修正 —— 真因是**日志撞 ~10 MiB 被丢写**,⛔ 不是"挂在会话名下")⇒ **拉起常驻见 §5**。⛔ 「用自动任务当闹钟」**2026-10-01 已废弃**。 | +> | **前置(覆盖网络节点中继客户端 + 设备接入本地反代)** | **会周期性掉**(实测:设备接入本地反代约 1h 被 `reg.exe` 拦死)⇒ **探测与自起见 §5b**;⚠️ **AI 侧无法自动重起**,只能报警 + 人工/会话拉起。 ⚠️ **两段都在线 ≠ 链路通过**(判据=中继真转发过流 `streams>0`)。 | +> | **三条死路(都实测过,⛔ 别再试)** | `detached` spawn · `schtasks` · 从 bash 调 `powershell.exe` | +> ⚠️ 本文件为**追加式** ⇒ 与本节冲突时**以本节 + `architecture.md` 的「当前结论」节为准**。 + +> 目标:**整套「多会话协作机制」只在这一个 skill 里**(代码 + 架构 + 规范 + 手册),换机器=拷这个目录 + 改一份配置 + 接一次钩子。 +> ⛔ 换机器**不需要**拷贝任何工作区里的脚本 —— 若发现机制代码出现在工作区,那就是旧副本,按 §4 退役。 + +--- + +## 0 这个 skill 里有什么(机制本体) + +| 文件 | 是什么 | +|---|---| +| `SKILL.md` | 操作入口:怎么派活、怎么收尾、怎么排坑 | +| `references/architecture.md` | 🔴 **唯一架构文档**(五主体/需求台账四态/单条握手/三条件唤醒/红线/判据/术语) | +| `references/deploy.md` | 本文件(换机器) | +| `references/pitfalls.md` | 踩坑清单(每条都真实发生过) | +| `references/taskgraph.md` | 任务图规范 | +| `scripts/collabd.py` | **常驻程序**(`--once` 投影轮)+ **投递**(`--tick` 投递轮)+ 🔴 **常驻形态**(`--supervise`,**定案=一直运行**,见 §5)+ 上报 / 台账 / 看板 | +| `scripts/guard.py` | 🟡 **可选**:守护(只做**发现+落盘**,⛔ 不承担投递)。⛔ 不是投递的前置 | +| `scripts/selftest.py` | **回归自测**(20 条用例 · 不碰生产):**改完必跑,全绿才算改完** | +| `scripts/collabd.config.example.json` | 配置模板(拷成 `collabd.config.json` 改路径) | + +**⛔ 不进 skill 的**:运行态产物(台账/看板/通知/日志)—— 它们属于**每个工作区自己**,路径由配置的 `workspace` + `inbox` 决定。 + +--- + +## 1 前置(一次性) + +| 项 | 要求 | +|---|---| +| Python | 3.11+(实测 3.13);`ast.parse` 可用的标准库即可,**⛔ 无第三方依赖** | +| 宿主 | WorkBuddy 桌面版(需要它的三张只读表 + 自动化排期 + 钩子) | +| 目录 | 把本 skill 放到 `<配置目录>/skills/session-mechanism/`(如 `E:/ProgramData/.workbuddy/skills/`) | + +--- + +## 2 三分钟部署 + +``` +⓪ 声明**任务目标**(🔴 没有目标 ⇒ guard **拒绝启动**,rc=3): + /scripts/guard.py --goal "<一句话任务目标>" + 也支持手写 //goal.json(title 必需;可附 acceptance_doc / taskgraph / lines) +① 拷 skill 目录到新机器的 skills/ 下 +② 在 scripts/ 下:cp collabd.config.example.json collabd.config.json + 改 4 处必填:workspace / inbox / live / taskgraph (其余按需,见 §3) +③ 🔴 **拉起常驻(投递本体)**:` /scripts/collabd.py --supervise` —— **它同时就是唤醒时钟**。 + ⚠️ 起法约束见 §5;⛔ 「用自动任务当闹钟」已于 2026-10-01 废弃。 +④ 🔴 **接钩子(只作"即时性"补充,⛔ 不是主路径)** + 让宿主在**两个**事件上调用一个**本地脚本**(脚本内部再去调 collabd): + 事件 A:`UserPromptSubmit` → .../wb-result-hook.py + 事件 B:`PreToolUse`(matcher `^Bash$`) → .../wb-result-hook.py + 脚本内部做两件事(都**节流**、都**隐窗**): + · `collabd.py --once` ⇒ 常驻程序跑一轮**投影**(⛔ 不投递) + · `collabd.py --tick` ⇒ 投递跑一轮 ⇒ **投递 + 推进队列** + 🔴 **它的定位 = 补充**:钩子是**宿主起的子进程** ⇒ 自带网关口令 + ⛔ 不占会话 + 零 token ⇒ 有事件时**立刻**投一版; + 但 ⛔ **不能靠它兜底** —— 「需要投递的时刻必然伴随会话在动」**是错假设**(队列可能由**非钩子来源**产生,那时没有事件 ⇒ 漏)。 + ⚠️ **新加的钩子事件要宿主重启后才生效**(`UserPromptSubmit` 那条**立即生效**); + ⚠️ 钩子**只指向 skill/工作区里那一份**脚本,⛔ 不许有第二份(见 §4)。 +⑤ 🟡 **守护:可选**(⛔ 不是投递前置;**投递本体= ③ 的常驻**)。 + 它的职责只剩**发现**:长时间没人动 ⇒ 落 `STALL.md`/`NEED-USER.md`。 + · ⛔ 别在会话里起(会被回收);要起 ⇒ 独立窗口或「启动」文件夹 ⇒ 但它**拿不到口令 ⇒ 投递不了**(设计使然)。 +``` + +**验收(逐条可测)** +1. ` collabd.py --where` ⇒ 打印出正确的 workspace / inbox / 任务图路径; +2. ` collabd.py --once` ⇒ `rc=0`,`inbox/` 下出现看板与机械摘要,**无异常栈**; +3. 造一条上报:`collabd.py --report T1 --state running --line <线> --by <会话名>` ⇒ + `collabd.py --reqs` 能看到 `T1 执行中`; +4. ` collabd.py --tick` ⇒ `rc=0`,打印一行 `tick: … deliver=…`; + 🔴 **真投递的判据 ⛔ 不是 rc=0**:看 `wakeups.jsonl` **有没有新增一行 `ok:true`**; + 打印 `no-token` ⇒ 说明这个进程**不在宿主进程树内**(手工跑属正常;由钩子唤起时出现 ⇒ **异常**)。 +5. 让一个会话声明角色:`collabd.py --declare --role main` ⇒ 输出 `已声明角色: = main`; +6. ` selftest.py` ⇒ **PASS 39 / FAIL 0**。 +7. 🔴 **常驻投递在跑**(定案项,见 §5):进程活着 + `collabd.py --where` 解析到正确 workspace; + 判「真投递」⛔ **不看 rc**,看 `wakeups.jsonl` **有没有新增 `ok:true`**。 + +--- + +## 3 配置项(`collabd.config.json`) + +| 键 | 必填 | 说明 | +|---|---|---| +| `workspace` | ✅ | 你的工作区绝对路径(正斜杠);台账/看板/通知都相对于它 | +| `inbox` | ✅ | 运行态目录(相对 `workspace`),建议 `tmp/supervise-inbox` | +| `live` / `taskgraph` | ✅ | 看板文件 / 任务图 JSON(相对 `workspace`) | +| `lines` | 建议 | 线名 → 中文名 | +| `host_db` | 否 | 宿主库路径;空 ⇒ 用 `CODEBUDDY_CONFIG_DIR` 推导 | +| `shim_port` / `client_entry` / `client_runner` | 否 | 服务自愈用;**不做自愈就留空** | +| `wake_enable` / `wake_min_gap` / `wake_text` | 否 | 唤醒投递(需要网关口令在环境里) | + +⚠️ **本机配置(`collabd.config.json`)不要提交到公共仓库** —— 它含本机绝对路径。 + +--- + +## 4 旧副本退役(⛔ 否则"代码在 A、钩子在 B") + +症状:改了 skill 里的程序却没生效;或 `advance.md` 等产物由**另一个副本**写出(内容与你预期不同)。 + +``` +① 工作区里若有 collabd.py / advance-watch.py / keepalive.py / thin-consumer.py + ⇒ 全是旧副本(职责已被本 skill 吸收)⇒ 改名加 `.retired-<日期>`,⛔ 不删(留回滚) +② 确认钩子指向的是 **skill 里的** collabd.py(`--where` 自证路径) +③ 重启后看 `inbox/` 产物的 mtime 是否随会话事件刷新 ⇒ 是则接线正确 +``` + +🔴 **同名的两份实现是"最难查的故障"**(实测踩过:两份同时跑,同一秒给出互相矛盾的读数)。 + +--- + +## 5 投递的边界(🔴 一句话:**投递 = 一个常驻进程**(它同时就是唤醒时钟);钩子只作补充,⛔ 不排期、⛔ 不用自动任务) + +| | 有口令(能投递) | 需要容器会话 | 必须活着的进程数 | 跨重启自动生效 | 投递延迟 | 零 token | +|---|---|---|---|---|---|---| +| 🔴 **宿主后台任务(现定案的载体)** | ✅ | ✅ 需 1 个 | ≥1 | ⛔ 需再拉起 | ✅ **可调(轮询间隔)** | ✅ | +| 宿主钩子唤起的一次性进程(**降为补充**) | ✅ | ⛔ 不需要 | **0** | ✅ | ⛔ **依赖事件 ⇒ 会漏** | ✅ | +| 会话外常驻(启动文件夹/独立窗口) | ⛔ **否** | ⛔ 不需要 | ≥1 | ✅ | ✅ 可调 | ✅ | + +⇒ 🔴 **载体 = 宿主后台任务**(本机实测:`detached` **活不过工具调用边界**、`schtasks` **被内置程序黑名单硬拦**) +⇒ ✅ **唯一可行 = 宿主后台任务机制 + `stdout` 全重定向到文件(完全静默)**。 +⚠️ 「必须活着的进程数 = 0」**⛔ 不得再拿来否掉常驻**(2026-09-30 踩过:丢掉时钟 ⇒ 用户点破"成摆设")。 + +**三条硬约束**: +1. 🔴 **必须完全静默** —— `stdout` 重定向到文件。⚠️ 真风险是**输出/事件量把会话日志推过 ~10 MiB 上限 ⇒ 宿主 `diagnostic-log:dropped` ⇒ 界面不再显示**(⇒ `pitfalls.md P0-2`); + ⛔ **不是"任务挂在会话名下"**(该归因已作废)。 +2. ⚠️ **别从会话/工具调用里"直接"起**(`detached spawn`)—— 调用一结束就被回收(实测:唤醒停在调用结束那一秒); + 要用**宿主后台任务机制**起,`stdout` 重定向到文件。 +3. ⛔ **计划任务(`schtasks`)在部分机器被安全策略硬拦** ⇒ 兜底走「启动」文件夹(⚠️ 但那**拿不到口令 ⇒ 投递不了**,只能做发现)。 + +⚠️ **口令边界的正确表述**:由会话之外起的常驻**拿不到网关口令** ⇒ 它只**发现 + 落盘**(`STALL.md`/`NEED-USER.md`/看板)—— +**这不是故障,是设计**:`collabd.py` 用 `FROM_HOOK` 分辨两种"没口令"(钩子唤起却没口令 ⇒ 异常 ⇒ 落 `NEED-USER.md`;常驻没口令 ⇒ 只记日志)。 + +--- + +## 5b 🔴 「覆盖网络节点 · 中继客户端 + 设备接入 · 本地反代」的**探测与自起**(2026-09-30 实测定型 · 任何棒都要会) + +> 背景:手机接入链路的**最前一格**是"设备接入本地反代 + 覆盖网络节点中继客户端 都在线"。它不在 ⇒ relay 无通道 ⇒ 入口④闸 `503 device-unreachable` ⇒ 整条链断。 +> ⚠️ 这两个进程**不跨 WorkBuddy 重启**,且**本机无法做成计划任务**(`schtasks` 被内置程序黑名单硬拦)。 + +**① 先探测(⛔ 在跑就别重复起 —— 两份会抢同一 host 注册/同一端口)** +```bash +netstat -ano | grep ":20090" # 设备接入 · 本地反代在听否 +tail -1 E:/dsh-worker-dev/logs/overlay-bg-*.out.log # 最近一行 state=up(for …) ⇒ 覆盖网络节点中继客户端在跑 +curl -s --noproxy '*' -o /dev/null -w "%{http_code}\n" http://127.0.0.1:20090/ # 期望 200 +``` + +**② 不在才起 —— 🔴 必须"宿主后台任务机制 + stdout 全重定向到文件"(完全静默)** +| 件 | 命令 | +|---|---| +| **覆盖网络节点 · 中继客户端** | `node D:/github/dsh_shenxian/lib/net/relay/main.js --client --url wss://ai1net.com/dshs-relay --host --network --keys-file E:/dsh-worker-dev/overlay/relay-keys.local.json --ports 20090`(env:`DSHS_OVERLAY_NODE_KEY_FILE` / `DSHS_OVERLAY_NODE_GRANT_FILE`;`/` 从 `E:/dsh-worker-dev/overlay/overlay-node.local.json` 读) | +| **设备接入 · 本地反代** | `node E:/github/dsh-desktop-0.1.7rc2/node_modules/tsx/dist/cli.mjs E:/ProgramData/AIProject/ai1net-dsh-desktop/.workbuddy/_devkit/launch-desktop-dev-017.mts`(cwd=`E:/github/dsh-desktop-0.1.7rc2`;⚠️ **必须先 `unset ELECTRON_RUN_AS_NODE`**,否则 Electron 退化成纯 Node) | + +**成功判据(两条都要)**:覆盖网络节点中继客户端 ⇒ 日志出现**本轮新增**的 `registered host=… accepted=[20090]` 且 `state=up`;设备接入本地反代 ⇒ `20090 LISTENING` + `curl` **200**。 + +**⛔ 三条死路,别再试**(都实测过) +1. **`detached` spawn** —— 活不过工具调用边界(日志 0 字节即死);`wb-overlay-node-launch.mjs --detached` 注释里"本机实测不可用"是真的。 +2. **`schtasks`** —— 内置程序黑名单硬拦(见 `pitfalls` 里那六项),⛔ 命令内不可放行。 +3. **从 bash 调 `powershell.exe`** —— 被策略拦("绕过 PowerShell 安全检查");而 `overlay-node-daemon.ps1 -Action run` 是 **powershell 前台长跑**,也活不下来。 + +**⚠️ 遗留单点(如实登记)**:这两条后台任务**挂在"起它的那个会话"名下** ⇒ **起它的会话被回收 / WorkBuddy 退出 ⇒ 链路断**。 +⇒ 起它们的会话**在那段时间内不要关**;断了就按上面"② 不在才起"重起一次。 + +## 6 换机器后必查的 5 件事 + +1. `--where` 的三个路径是否都对; +2. 钩子接线是否指向 skill(§4); +3. 宿主库能否只读打开(`sessions` / `automation_runs` / `automations` 三张表读得到); +4. 有没有**别的实现**在抢同一个 inbox(§4); +5. `guard.py` 是否真的起来了(`--status`)+ 它是否会随开机自启。 + + +--- + +## 5d 🔴 「会话整体停止」后怎么恢复(2026-09-30 立 · 用户追问逼出来的) + +> 用户原话:「所以现在的问题就是 **会话整体停止了怎么办** 的问题」。 + +**先分清三档 —— 只有第三档才需要动手:** + +| 档 | 场景 | 会怎样 | 怎么办 | +|---|---|---|---| +| ① | **只有某个会话停了**(WorkBuddy 还开着) | 机制照跑 —— 钩子是**全局**注册的,**任何**会话跑 Bash/发消息都会唤起它 | **不用办** | +| ② | **所有会话都 idle**(WorkBuddy 还开着) | 钩子不被唤起 ⇒ 不投递 | **不用办** —— 那时没人在看;一有动作,积压立刻补投 | +| ③ | **WorkBuddy 退出 / 机器休眠** | **整条协作链停摆** —— 唯一能"开新会话"的通道就是宿主自动化 | **按下面清单恢复** | + +### 恢复清单(重开 WorkBuddy 后按序跑) + +1. **看前置两段**:`netstat -ano | grep ":20090"`(判据**只认 LISTENING 行**,⛔ 别用 curl/connect —— 本机 Proxifier 会代理回环,返回是假的)。 + 缺了 ⇒ 按 **§5b** 拉。⚠️ **中继客户端那半边已计划任务化**,通常自己会回来。 +2. **查过期未跑的一次性排期**(`status='ACTIVE' and schedule_type='once'` 且 `next_run_at < 现在`): + 有 ⇒ **新建一条**(⛔ **改时间不会触发**)。 +3. **跑一轮投影** `collabd.py --once`,让队列/看板/`NEXT.md` 刷新。 +4. **看 `NEED-USER.md`**:有没有等你拍板的事。 +5. **看 `blocked.json`**:受阻件是否已随状态更新解除(⛔ 别让它一直当队首、每次唤醒白跑)。 + +### 🔴 为什么"不需要防"(实测结论,⛔ 别再想做常驻去扛) + +- **实测(2026-09-30 08:2x)**:11 条一次性排期里 **9 条准点跑**;唯一 0 次那条的排期在**创建时就已过期** —— + ⇒ **宿主在跑,排期就准点**;**宿主不在,排期不会自己跑**(**调度器就是宿主本身**)。 +- **能扛过 WorkBuddy 退出的**:只有**不需要口令**的长跑 —— 实测就是**覆盖网络节点中继客户端**(已由计划任务持有, + 父链 `powershell ← svchost ← services ← wininit`,⛔ 无 bash/无 WorkBuddy)。 +- **扛不过的**:任何**要投递**的东西 —— 投递要**网关口令**,而**口令的唯一合法来源是 WorkBuddy 进程 env** + (08:06:31 实测:计划任务上下文 `envPresent=false / envLen=0`)⇒ **「不占会话」与「有口令」二选一**。 +- ⇒ **结论**:**「会话整体停止」不需要"防",只需要"恢复流程"**。 + ⛔ 不要再设计"常驻监督进程"去扛 —— 那条路已被实测堵死(要么没口令、要么拖住会话)。 diff --git a/session-mechanism/references/forensics.md b/session-mechanism/references/forensics.md new file mode 100644 index 0000000..c6bc8c1 --- /dev/null +++ b/session-mechanism/references/forensics.md @@ -0,0 +1,73 @@ +# 会话复盘 · 取证手册("某个会话当时到底干了啥 / 为什么卡住") + +> 取证脚本:`scripts/forensics/proc-parent.py`(纯 ctypes 查进程父链,⛔ 不依赖 psutil)。 +> 深度内容(每条的前因后果、实测读数)⇒ `references/pitfalls.md`。 + +## 1 先定位会话 + +- 会话表:`sessions`(`title` / `custom_title` / `created_at` / `last_activity_at` / `cwd` / `status`)。 +- 用户说「找**最新的**那个」时,**两个指标都算、取更大者**:`createdAtMs`(创建)与 `lastActivityAtMs`(最后活动)。 + ⚠️ **两个指标打架 ⇒ 两条都列出来问用户,⛔ 别猜**。 +- ⚠️ **同名多会话**在这一层很常见(标题会被复用)⇒ 必须靠 id / 时间 / cwd 三者交叉确认。 + +## 2 读转录(原文在哪、怎么抽) + +- 转录是 JSON 行文件(`.jsonl`)。抽四类东西:**用户原话** / **AI 正文** / **reasoning 思考** / **工具调用**。 +- ⚠️ **转录会被清理**(保留窗口有限)⇒ 过期后只能走 §6 的复原路径。 +- 🔴 **别拿转录 mtime 当"有产出"**:agent 在跑时转录正文可能**滞后甚至不落盘**;反之活干完了也可能因推送失败而看起来没动。 + +## 3 🔴 四种"卡住"必须分开判(这是本节的核心) + +| 型 | 症状 | 决定性判据 | +|---|---|---| +| **中断** | 那一轮从中间断了 | 转录尾部有**宿主注入的错误**(如 `## Model error retry: Tool Not Found`)⇒ 中断点;其后长时间零 `reasoning` / `function_call`。**瞬时工具不可用 ≠ 会话来死** | +| **工具循环不收敛** | 消息派发了、agent 真在跑,但**永不回话** | 工作区日志状态机:`finish_reason=` **反复是 `tool_calls`,从不出现 `stop`**;`busy=true` 恒不回 idle | +| **干完了推不出去** | 界面显示"一直运行",但看不到任何执行 | 日志里 `sendToClient: … SSE is closed` 大量堆积 + 该轮 `finish_reason="stop"` ⇒ **活干完了,结果送不出去** | +| **投递悬挂** | 消息收到了、也入队了,但**没有任何东西去排空队列** | 🔴 **`route=parkInQueue` + `hasWaiter=false`**(正常是 `route=resolveWaiter` + `hasWaiter=true`) | + +🔴 **投递悬挂的定量指纹**(把"症状"变"确诊"):`hasWaiter=false` 只会在出事那段出现、`queueLen` **只进不出**(每再投一次就再压一条)、同一小时 `No state found for connectionId` **飙升**。 +✅ **处置=让客户端重新挂上该会话**(切走再切回 / 重开该会话窗口);⛔ **别反复发消息试探**(只往队尾再堆一条)。 + +⚠️ **两条别混**:同一次事故里常常**同时**出现"日志层异常"与"投递异常" ⇒ **分别归因、分别取证**,⛔ 不要合成一个因。 +⚠️ **别拿会话状态字段当判据**:客户端会把 `working` **基于错误假设主动写进库**,而宿主那边根本 idle;`last_activity_at` 的刷新也可能只是"UI 打开了这个会话"。 + +## 4 日志撞上限(10 MiB)⇒ 整批丢写 + +**三条同时成立即可确诊**: +1. 守护日志反复出现 `diagnostic log write failed … EPERM`,且 `droppedLines` **持续增长**; +2. 该会话的 `.log` **大小卡在 ≈10 MiB 且 mtime 冻结**,而同期**其他会话的同名日志正常在长**(对照排除"全盘坏了"); +3. 同目录 `.log.1` 也是满的 ⇒ **轮转无空位**。 + +**修法(非破坏、可逆)**:把卡死的那个 `.log` 与 `.log.1` **改名挪开**(⛔ 不要删)。 +🔴 用 Python `os.rename`,⛔ **不要用 `mv`**(MSYS 的 `mv` 在长路径/中文名下更易踩坑)。 +**验收必做**:① 数十秒内被宿主**自动重建** ② 它**再次长大** ③ 告警**不再新增**。 +⚠️ **它必然复发**(每个会话涨到上限都会重演)⇒ 需要**持续兜底**(本工作区有扫描器 + 钩子兜底)。 +⚠️ 这两个文件**并没有被锁**(可正常打开)⇒ ⛔ 别再往"查谁占用文件"上耗时间。 + +## 5 进程定性("它是不是某个会话的后台任务") + +``` +netstat -ano | grep ":<端口>" # 只认 LISTENING 那行 ⇒ 拿 pid +python <本包>/scripts/forensics/proc-parent.py # pid ⇒ 父链 +``` +- 父链出现 `… ← sandbox-cli.exe ← WorkBuddy.exe` ⇒ ✅ **是会话/宿主托管的后台任务**。 +- 父链是 `services.exe` / `svchost.exe` / `explorer.exe` ⇒ ⛔ 不是后台任务(独立进程)。 +- 更硬的证据在宿主日志里搜 `executeInBackground` / `background task created` / `processExit`。 +- ⚠️ 日志里的中文常是「UTF-8 字节被按 GBK 解」的乱码,还原一行:`s.encode("gbk","replace").decode("utf-8","replace")`。 + +## 6 转录已被清理时的复原路径(四条腿) + +1. **全工作区 memory 日志批量搜"行为的独有名词"**(工具名 / 产物名 / 作品名)⇒ 一次命中就给出「工作区 + 日期」,比逐会话读 jsonl 快一个量级。 +2. 读命中的那天日志(它通常写了工具链、命令要点、产出**绝对路径**)。 +3. 按**独有产物名**跨盘搜文件系统(⚠️ 必须剪枝:跳过 `node_modules/.git/Windows/Program Files/AppData`,限制下钻深度;⛔ 别用全盘递归 `glob`,实测直接超时被杀)。 +4. 与规范化文档互证(文档里常写「基于 <日期> 实测沉淀」)。 + +⚠️ **交付时必须同时说明「原产出目录是否已失效」与「现存可核验的替代物」**,别让用户去翻一个不存在的路径。 + +## 7 ⛔ 别做 + +- ⛔ 别因为 `status=working` 就断定卡死(当前正在跑的会话也是它)。 +- ⛔ 别因为"抢不到锁"就去**删锁 / 接管**(红线,只能持有者释放)。 +- ⛔ 别"再发一条试试"救投递悬挂的会话(会重开一轮同样的循环 / 只往队尾堆一条)。 +- ⛔ 别在自动化会话里手动续聊当正式驱动。 +- ⛔ 别为"恢复它"随手重启应用(会打断其他线正在跑的棒)。 diff --git a/session-mechanism/references/manifest.md b/session-mechanism/references/manifest.md new file mode 100644 index 0000000..19e783b --- /dev/null +++ b/session-mechanism/references/manifest.md @@ -0,0 +1,134 @@ +# manifest · 包内文件清单 + +> 生成方式:逐文件 `compile()` / `json.loads` + md5 | **最近一次全量重算:2026-10-04 17:4x (2026-10-04 单包权威:02 档改为「本档即权威」(⛔ 不再指向外部技能当权威),保留来路说明;SKILL.md 撤回『必需外部依赖』声明改为自包含)** +> ⛔ 本表**不含** `install.log`(运行日志)与 `references/manifest.md`(自引用,写完即失真)。 +> ⚠️ **provenance 列里的 `skills/multi-session-collab/…`、`skills/workbuddy-session-forensics/…` 已是历史路径** +> —— 那两个目录 2026-10-01 已移到 `<工作区>/归档/技能-退役-20261001/`(⛔ 不在技能根了)。 + +文件总数:**49** | 语法 / 结构检查失败:**0** + +## ✅ 已完成 · 2026-10-05 三代术语收敛(本节替代原「待办」) + +- **已执行**:包内 + 两个工作区副本三方同步,md5 一致(`collabd.py`/`goalctl.py`/`board.py`/`board_ext.py`/ + `selftest.py`/`collabctl.py`/`guard.py`/`init_workspace.py`/`session-rules-check.py`/`board.html` 全部 OK)。 + `selftest.py` 基线 **PASS 97 / FAIL 0**(报告型 1 条不计入);改名的 5 条 `@case` 均能按新名字 `-k` 找到。 +- **⛔ 三类不动,是判据不是遗漏**(下一次改名前照抄): + ① **「」引述内容逐字不动**(含跨行引述块)—— 引用用户口径只写原话; + ② **历史段整段不动** —— `SKILL.md` 19–27 行代际表、含 `旧称/旧词/一代/二代/第三代` 的行、` 历史…` 段落 + (要讲清三代分别叫什么,就必须留着旧名); + ③ **`.py` 里 `目标检查` 一律不动** —— 它是**检查会话类别名**(`_CHECK_TAGS`/`_CHECK_TOPICS`、 + `[检查]-[结果检查/目标检查]`),并散在字面断言里(`CHECK_KINDS["queue-empty"][1] == "目标检查"`、 + `rows.append(...)`、`【目标检查】` prompt 头)⇒ 改了直接断判据。 +- ⚠️ **一处同改对**:`board.py::_ROLE_LABEL["worker"]` 与 `board.html` 的 `role==='…'` 是同一处判据的两侧。 +- ⚠️ **本条暴露的存量缺陷(已一并修)**:`selftest.py` 的期望串原写 `才建立一轮任务会话`,而 `SKILL.md` + 引述里逐字是 `才建立一轮执行会话` ⇒ 该子项**改前就是红的**(被基线里的 97/0 掩盖)。 +- 工具:`tmp/term-apply-v3-20261005.py`(dry-run/`--apply`,写盘前自动备份)+ `tmp/term-sync-20261005.py`。 + + +## 2026-10-04 新增文件(⛔ 由 manifest 重算登记) + +- `references/01-文档索引.md` — 3604 B |md5 `825999a411e2a4f6b740063f81c01b03` +- `scripts/judge_audit.py` — 13562 B |md5 `8f14a973fa5709a3b9a60b065dc22d7d` +- `scripts/workspace_mirror.py` — 17263 B |md5 `60f104b37df3133785abb13f686eea4f` +- `assets/start-supervise.ps1.tpl` — 5360 B |md5 `3805f1a82a1747269212287e9dfde369` +- `assets/design-tokens.css` — 6364 B |md5 `0668feb3a4729f1022e579ac0c5d2569` + +## 最近改动(⛔ 只记「什么时候改了什么」,不写流水账) + +- **2026-10-02 07:0x · 开工第 0 步改名:会话「规划」→ 会话「规则机制」(本轮)**: + ① **用户纠正原话**:「**就应该是检查清楚 所有会话规则机制 是否配置完整且生效, 不是规划 是 规则**」 + ⇒ 首版体检脚本**只查排期那一面**,当天实测出的三类失效(钩子注入指向已退役技能名 / 常驻快照写进幽灵目录 / + 每轮注入的记忆指针悬空)**一条都没覆盖** ⇒ 名字还叫「规划」=第二次「在册 ≠ 生效」。 + ② **收敛成一个入口**:新增 `scripts/session-rules-check.py`(查 **三类十二项**:A 钩子在册·路径存在· + 注入技能名存在·闸门日志新鲜 | B 每轮注入记忆的技能指针·常驻快照不比权威旧 | C 周期钟·模型可用性· + `cwds` 同形·投递心跳·活会话·`once` 从未运行的死排期);旧 `scripts/session-plan-check.py` **退役** + 到 `/归档/技能包-旧件-20261002/`,**能力不回退**。 + ③ **状态标记改名**:`session-plan.json` → **`session-rules.json`**(旧的属过期件,⛔ 别拿来对照)。 + ④ **判据收紧**:`cwds` 近失配原写成「同父目录 + 字面不同」⇒ 在 `AIProject/` 这种**多业务线平级目录**下 + 把**别的线**报成失配(实跑当场 2 条假红)⇒ 改为「同父目录 + **名字去 `-`/`_` 后仍相同**」/「同名不同父目录」。 + ⑤ **判据自验**:⑨⑫ 抽成**纯函数** + 合成样本夹具(`/tmp/rules-check-mutate.py`)—— 原版 10/0 绿、 + **四个变异体**逐一按预期报红。⚠️ 夹具**自己先红两次**(期望值写死:漏算样本 + 有序列表比中文名)。 +- **2026-10-01 18:5x · 外置根贯通 + 两处真缺陷(本轮)**: + ① `roots.env` 引导块从 9 份扩到 **16 份**(补齐 7 个非钩子脚本:`board` / `board_ext` / `collabd` / + `deliver-gateway-token` / `goalctl` / `selftest` / `stop-collab`); + ② **盘符字面量清零**:配置目录兜底改 `~/.workbuddy`,工作区兜底改 `DSH_WS_ROOT`/cwd, + 覆盖网日志 glob 改 `DSH_OVERLAY_LOG_GLOB`(原为死路径 —— 本机 `E:/dsh-worker-dev` 不存在); + ③ 7 份加**输出编码兜底**;④ `roots.env` 增写 `CODEBUDDY_CONFIG_DIR`; + ⑤ 🔴 **修「钩子把技能包当工作区」**:`wb-result-hook.py` 的 `_WS_ROOT` 原按 `3×dirname(__file__)` 推, + 包内这份推出来=技能包自己 ⇒ 包里长出 `tmp/supervise-inbox/`;改为复用 `resolve_ws()`; + ⑥ 🔴 **修「重定向 + GBK ⇒ print 抛异常 ⇒ 顶层记 fatal、整轮失败」**(本机实测 4 次; + 这正是**常驻**跑不起来的拦路石 —— 常驻必须重定向 stdout); + ⑦ `install.py` 工作区模板路径纠错(原指 `scripts/collab/`,实际在 `scripts/`); + ⑧ `collabd.py` docstring + 两处活注释改**常驻定案**(上一轮漏传导); + ⑨ `lock/preflight-lock.sh` + `hooks/lock-guard-hook.py` 的盘符字面量清零; + ⑩ 新增 `install.py --manifest`(见下条); + ⑪ 🔴 **`scripts/selftest.py` 去掉最后一处项目绝对路径**:`t_tick_wired` 原先写死 + `<某工作区>/.workbuddy/tools/wb-result-hook.py` ⇒ 两处都不对:既违反「技能里只用相对路径」, + 又因宿主接线已改指**包内**那份而**恒走"文件不在,跳过"** ⇒ 看着绿、其实什么都没验(静默假绿)。 + 现改按包内相对路径取 `hooks/wb-result-hook.py`,该用例从"跳过"变**真检**(4 项实跑通过)。 +- **2026-10-01 19:0x · `install.py --manifest`**:把「重算本表」固化成子命令 + (包一改表就过期 ⇒ 以前每轮临时手搓脚本、跑完即删,已因此误判两次)。 + 只重写**表 + 计数行 + 重算时间**,⛔ 不碰上方散文;**行尾随原文件**(本表是 CRLF,写成 LF 会造成一次无声全文件 diff)。 +- **🔴 两份同源脚本「有意不一致」(⛔ 别当 bug 去修平)**:`preflight-lock.sh` 与 `lock-guard-hook.py` 在 + **技能包**与**文档库 `07-scripts/`** 各有一份。包内那份住在技能包里、**推不出文档库根** ⇒ 只能走 + `roots.env` +(`lock-guard-hook.py` 的)末位字面量兜底;库内那份住在 `<文档库>/07-scripts/` ⇒ + **按 `__file__` 往上两级即文档库根**。⇒ 包内有引导块、库内没有,是**位置决定的**,不是漏改。 +- **2026-10-01 18:2x · 瘦身(本轮)**:`references/collab-detail.md` 删去**原件 YAML 变更流水**(约 9.4 KB)+ 加**读法索引**+ 压缩 §12; + `references/{manifest,taskgraph,pitfalls}.md` 去掉过期条目与旧抬头;包内 `__pycache__/`、`hooks/bak-*/`、`tmp/` 已清。 + **⛔ 本轮无机制代码改动。** +- **2026-10-01 16:5x**:`scripts/hooks/session-log-guard.py` 新增「**硬档就地回收**」—— 到 8 MiB 先试**一次**把本会话 + `logs/<日期>/sdk/conversations/.log` 改名(后缀 `.recycled-<时间戳>`,⛔ 不删、⛔ 不以 `.log` 结尾故与清扫器不重叠) + ⇒ 宿主立刻重建并继续写 ⇒ **会话不必再因撞 10 MiB 而停手**;失败则**逐字退回**原「停手+建接续」口径。 + 开关 `DSH_SLG_NO_RECYCLE=1`(只关回收、保留叫停)。⚠️ 文档库原件 `07-scripts/session-log-guard.py` **已同语义同步**。 +- **2026-10-01 16:0x**:原 `multi-session-collab` 的 `SKILL.md` 全文并入 `references/collab-detail.md`(治 8 处悬空引用: + `§0.05` `§0.5.2` `§0.5.4` `§0.5.5` `§1.3` `§1.4a` `§3` `§6`);清掉 9 处旧技能路径/名的活文本; + 两个原技能目录移入 `<工作区>/归档/技能-退役-20261001/`。 + +## 逐文件清单 + +| 包内路径 | 字节 | md5 | 语法检查 | +|---|---|---|---| +| `SKILL.md` | 52451 | `c304346227e3d2b217e442a471b6c10d` | ok | +| `assets/board.html` | 136715 | `bf0e560a0420c833e94f329958e64a3c` | ok | +| `assets/design-tokens.css` | 6364 | `0668feb3a4729f1022e579ac0c5d2569` | ok | +| `install.py` | 26132 | `9522ec34773385a638efdeb3df0090d2` | ok | +| `references/00-动手前必过.md` | 3923 | `33bc86e442ccd4fcbb930c79c4ad0641` | ok | +| `references/02-功能优先协作协议.md` | 30502 | `eeeab3dcad47ad01380c8b05fee6fb3f` | ok | +| `references/03-回复排版-核心块.md` | 2187 | `201e7f112ee432b89e38f82f49301966` | ok | +| `references/99-速查清单.md` | 9832 | `17cc8717649164fa3b9e26da3be9405b` | ok | +| `references/architecture.md` | 119291 | `c78f5cba060efc79db5d160f7df0cca6` | ok | +| `references/collab-detail.md` | 98760 | `6bd9cc7e28596789d55dcd3da22548fe` | ok | +| `references/collab.md` | 16560 | `e7024a823d14c5aa808c7be3a7b00eda` | ok | +| `references/deploy.md` | 16754 | `66342ae0e32ecb499b4c72e4ccfabb9b` | ok | +| `references/forensics.md` | 6067 | `1fb6deca9bb05bebe950d4f4e3adf043` | ok | +| `references/pitfalls.md` | 132523 | `9bd6509df320833a41313848009aeb9a` | ok | +| `references/rules.md` | 6931 | `d654e9dac68a6f44b93898ba98e49221` | ok | +| `references/supervise-persistence.md` | 19245 | `1d4bbb08f02a661124886578cb700af5` | ok | +| `references/taskgraph.md` | 3521 | `be6c6540be86475bb3430688c2987afc` | ok | +| `roots.env` | 624 | `ec38f15f9a4101d69c815aa2cdd7b477` | ok | +| `scripts/board.py` | 123843 | `339d38fd579c602c20ce59f4aa106408` | ok | +| `scripts/board_ext.py` | 45655 | `f138d944145b276e65f6aa86bd8baa07` | ok | +| `scripts/collabd.config.example.json` | 1158 | `6655c15c411a84e3ca12fd136dbe2c60` | ok | +| `scripts/collabd.py` | 355568 | `58ffc3e52a52445e5b0e782fc99d9323` | ok | +| `scripts/deliver-gateway-token.py` | 5685 | `cbba9648d404ecd2683061e19dbdb24a` | ok | +| `scripts/deploy_code.py` | 4391 | `c22969124dd937ddaa66db09738df4a4` | ok | +| `scripts/forensics/proc-parent.py` | 2347 | `8cdfff2dbb88303298752c3777da7b20` | ok | +| `scripts/goalctl.py` | 39314 | `b3c4428b3aa7e4e3093e96105d1bc4dd` | ok | +| `scripts/guard.py` | 13653 | `378e2a388aa14bc55f83e17b5ea61053` | ok | +| `scripts/hooks/_env.py` | 11082 | `d3a557f22286bffb62ff29ee85c71799` | ok | +| `scripts/hooks/bash-output-guard.py` | 17741 | `3f2fdfa17f66f7fcac42e06853433a26` | ok | +| `scripts/hooks/lock-guard-hook.py` | 20363 | `641457de0297ee7a812ccde57f67dc2b` | ok | +| `scripts/hooks/reply-style-guard.py` | 10779 | `a05ca0d6ebef2be55a3d6f96a4c712da` | ok | +| `scripts/hooks/session-log-guard.py` | 24636 | `0bd6865e1a3ab97315c64631f37963a3` | ok | +| `scripts/hooks/skill-load-guard.py` | 19380 | `682019c18af80991b14c3f3505ecab26` | ok | +| `scripts/hooks/stop-dialog-guard.py` | 40247 | `4e5f9889717d4ed37a6384c643b4d05e` | ok | +| `scripts/hooks/wb-result-hook.py` | 65810 | `94bc3c781b43be6c2a4da645fe008639` | ok | +| `scripts/init_workspace.py` | 8840 | `0581b3864d35461f2fd8f76ea4c66d4d` | ok | +| `scripts/lock/handoff-guard.sh` | 34616 | `f38b2ec92fdc1dab9abdaca0e0bb001a` | ok | +| `scripts/lock/handoff-status.py` | 3923 | `3edb20f9c4021ebe2323478860bf8f6f` | ok | +| `scripts/lock/op-lock.sh` | 4690 | `1a31eda3642e23693a65e1519860c744` | ok | +| `scripts/lock/preflight-lock.sh` | 8673 | `e985ec1cb853fae4c351cd03b171355d` | ok | +| `scripts/selftest.py` | 281959 | `f68dcb48173f2321527d54bc30703bc5` | ok | +| `scripts/session-rules-check.py` | 28650 | `484b4e7370ff7f8377e725471bdfd88a` | ok | +| `scripts/stop-collab.py` | 10462 | `d8043bedd23b52b6642aaf7ec8b8c980` | ok | +| `scripts/wake-session.py` | 11011 | `32c6038b541249730f322f933ee287c1` | ok | diff --git a/session-mechanism/references/pitfalls.md b/session-mechanism/references/pitfalls.md new file mode 100644 index 0000000..69ed430 --- /dev/null +++ b/session-mechanism/references/pitfalls.md @@ -0,0 +1,2598 @@ +# 踩坑清单(每条都真实发生过) + +> ## 🔴 当前结论(**先读这里** · 最后更新 2026-10-02 05:1x) +> ⚠️ 本文件**通篇追加式** ⇒ 旧条可能已被取代(已就地标注)。**冲突时以「本节 + `architecture.md` 的「当前结论」节」为准**; +> 历史只留最近 5 轮、更早的归档(例外:**教训类不受 5 轮限制** —— 见 `agent-operating-rules §1.7a`)。 +> +> **最该先记住的几条**(其余按 P 编号往下读) +> | 编号 | 一句话 | 为什么它排前面 | +> |---|---|---| +> | **P0-71** | 🔴🔴🔴 **常驻靠什么活着:不在「作业对象」(恒真、没鉴别力),在「谁拉起它」** —— 会话树里起的(父链穿到 `WorkBuddy.exe`)一收工就死;**只有计划任务起的能活**(父链断在自己身上)。✅ 定案形态=**计划任务 → `pythonw.exe` → `--supervise`,每 5 分钟判活**;⚠️ 两个致命细节:**`WorkingDirectory` 必须是工作区根**(⛔ 脚本目录 ⇒ 找不到配置 ⇒ 拒跑)、**常驻不需要网关口令**(⛔ 所以别用 `--ensure`)。🔴 看板 `LastResult=1` 是**虚警**(pythonw 无 stdout),判据看**端口**。🔴 **三条硬约束**(先查后建/各区独立/只有看板共用)+ **`deploy_code.py DEFAULT_FILES` 必须含 `collabctl.py` 等入口脚本**(⛔ 漏了 ⇒ 各区副本永不更新=P0-57 同族) | 🔴 **同一件事栽到第四次**(用户:「**从1号搞到5号 还起个程序都启动不起来**」);错一次=常驻全死、兜底全失 | +> | **P0-72** | 🔴🔴🔴 **两个静默失败**:① `kill_all()` 的 `taskkill` 带 `HIDE`(含 breakaway)⇒ 必被拒 ⇒ `except` 吞掉 ⇒ **"已杀进程 0 个"却一个没死=假停**(✅ 改 `creationflags=0`);② 看板任务**直起 `board.py`** ⇒ 无 `COLLABD_CONFIG` ⇒ `已拒跑` ⇒ **崩溃重启循环、端口从没绑上**(✅ 新增 `board-launch.py` 启动器在进程内设 env)。🔴 长驻服务的 `ExecutionTimeLimit` 必须 `0`(⛔ 设 2 分钟 ⇒ 到点被掐)|🔴 `list_procs` 的 `python*` 会**连调用方一起匹配到** ⇒ 必须算保护集 | 🔴 **"做了动作" ≠ "动作生效"**:这两件事**都不报错**,只表现为"停止没停""看板没起" ⇒ 关键动作**必须有动作后复核** | +> | **P0-73** | 🔴🔴🔴 **常驻启动器缺一个环境变量 ⇒ 检查程序静默失效**:任务**直起 `collabd.py --supervise`** ⇒ 任务环境**没有 `CODEBUDDY_CONFIG_DIR`** ⇒ `_wb_db()` 落到 `C:\\Users\\Administrator\\.workbuddy\\workbuddy.db`(**0 字节空库**)⇒ `all_sessions_idle` 每轮报 **`no such table: sessions`** ⇒ fail-safe **恒判"有会话在跑"** ⇒ **检查会话再也不建**(外表完全安静、零报错)。✅ 新增 `supervise-launch.py`(进程内 `setdefault("CODEBUDDY_CONFIG_DIR", ...)`)+任务动作改指它+`DEFAULT_FILES` 补入。🔴 **"进程活着" ≠ "它在干活"** ⇒ 检查程序必须看**有没有产出预期分支**(`检查会话:…`)|🔴 **重构"起法"时旧起法的 env 要逐条搬**(本坑就是丢了这一句)|🔴 各区 config 的 `host_db` **写死绝对路径最稳**(vibe 一直这么写 ⇒ 只有 ai1net 炸) | 🔴 **同一件事栽到第五次**(用户点名「检查协作程序和检查程序运行是否正常」才抓到);fail-safe 失败方向安静 ⇒ **凡 fail-safe 必须打可检索日志** | +> | **P0-75** | 🔴🔴 **远端技能总仓里躺着明文令牌**(`workbuddy_skills.git` 的 `.neodata_token`,`tk_` 明文 72 B,自首次入库 `43b83b0` 就在):首次入库 `git add -A` 整目录收录、`.gitignore` 只挡了产物 ⛔ 没挡凭据。✅ `git rm --cached` + 补忽略规则(`031b312`)|⚠️ **历史仍有该 blob**,彻底清须 `push --force` 重写(牵连 25 技能)⇒ 等用户拍板;根治是**服务端吊销令牌**。🔴 入库前必扫凭据文件名 + 已知令牌串;核验远端必**比差异**(vs 备份)| +> | **P0-74** | 🔴🔴🔴 **`_escalate_to_keeper()` 残留旧形态 ⇒ 计划任务里躺着 `powershell.exe` ⇒ 闪黑窗**(用户原话「刚才又弹了窗口看看是什么」):自我供给**旁路**没跟着新形态一起改 ⇒ 任务动作还是 `powershell.exe -WindowStyle Hidden -File start-supervise.ps1`(PowerShell = **控制台程序** ⇒ 每次触发分配 `conhost.exe` ⇒ **闪一下**;`-AtLogOn` + `RestartCount 999` ⇒ **反复**闪)。✅ 改指 `pythonw.exe + supervise-launch.py`,并删掉 `start-supervise.ps1.tpl` 前置门槛。🔴 **"改了主路径" ≠ "把旁路也改了"** ⇒ 收口时全文 `grep New-ScheduledTaskAction` 与 `.ps1` | 🔴 它是**钩子路径**上的(`UserPromptSubmit` → `--ensure` → 失败 → 升级),**平时不吭声、专挑你在用时闪** | +> | **P0-24** | 🔴🔴🔴 **注入物里写死命令** ⇒ 会话被逼着做**用户没授权**的事。缓存里存的是**成品文案**(含「⛔ 不要问用户」),逐轮复用 ⇒ 跟你当轮说了什么**无关**。硬规:**缓存只存原始数据,文本按当轮授权现算**;未授权时**只通报、不派活** | 🔴 **越权比超时严重**:超时只是慢,越权是**替你做决定**;且表现像"机制很勤快",**最不易被发现** | +> | **P0-23** | 🔴🔴 **钩子超载 ⇒ 用户每句话都被拦下**(10-02 全工作区事故):宿主注册 20s,钩子里**同步**串了 `--gap` 13.5s。硬规:**拿不到结果就没用的活 ⇒ 一律后台**;⛔ **"兜底同步"是伪需求**(改了一版没改净就是因为留了它) | 🔴 这是**唯一能让整个工作区所有会话同时不能说话**的一类故障 —— 优先级最高,无之一 | +> | **P0-6** | 常驻进程 **⛔ 别做高频删文件**(`unlink`/`rename`)⇒ 宿主 **SafeDelete 护栏会直接杀掉进程**(实测:跑 48m43s 后 `failed`;**10-01 又复发一次,只活 8 分钟**)。🔴 **"降低频率"不是修法** —— 判据是「稳态下每轮删除次数 = **0**」 | **唤醒时钟就是这么断的**(10-01 已治本:锁文件永久存在、释放=改内容) | +> | **P0-5** | 「**消息卡住**」指纹 = **`parkInQueue` + `hasWaiter=false`** | 🔴 **AI 侧修不了**,只能让客户端重挂该会话 | +> | **P0-2** | 「卡消息输出」真因 = **会话日志撞 ~10 MiB 被 `dropped`**(⛔ **不是**"任务挂在会话名下") | 曾误归因,白折腾一晚上 | +> | **P0-5a** | 探针 **⛔ 不许"在日志里搜字符串"** ⇒ 会命中**你自己的取证回声** ⇒ 假阳性 | 认结构(记录行),⛔ 不认词 | +> | **P0-19** | **「在不在执行」⛔ 别拿库里的 `status` 判** —— 它只有 `working/completed/error/archived`,**没有"空闲"档**,活着的会话**恒 `working`** ⇒ 投递**永远等不到空闲**(死结)。判据=宿主日志 `[SessionRunStateMachine]` 的 **`busy=`**(⛔ 不进数据库) | 🔴 用户报「**队列一直没有上报**」的真因;换对判据后拦截原因立刻变成**真状态** `no-follow-session` | +> | **P0-20** | `automation-request-refused` **⛔ 别从错误名 `refusal` 推「内容审查」** —— 实测真因是**排期绑的模型不支持关闭思考**(`deepseek-v4.1-flash` + `model_is_thinking=0` ⇒ 服务端 `-32603`);对照 `hy4-preview + is_thinking=1` **从未被拒**,且当日 **11 条**失败会话的 `details` **逐字同因** ⇒ 🔴 **非偶发、是系统性**。✅ 治本=打开思考档(`modelIsThinking=true`,**改完必须回读宿主库核对**);🔴 **2026-10-02 05:5x 已全量收口:未删除的 23 条排期全开,回读 `is_thinking=0` = 0 条**。🔴 **判据:第一步就读那条会话自己的日志拿 `details`** | 🔴 我上一条正是**错误归因**(推成"措辞触发审查"并去改 prompt)⇒ 教训=**"看着有判据" ≠ "判据指向真因"** | +> | **P0-13** | **判据必须能"改前报红"**:空样本上 `every()` 恒真(假绿)|**写死期望值**遇数据一换就恒红(**假红淹掉真红**) | 🔴 同轮两条,**都是"看着有判据、其实没有"**;⚠️ 同族:**变异法跑完必须回读"变异体被哪几条用例跑了"**(本轮我自己的对照脚本就是空的) | +> | **P0-17** | 常驻服务(看板等)**跑的是启动时那份旧代码** ⇒ **改完看不见变化**。判据=直读 `/board.json` 的 `ts`/条数/类别 **对磁盘**,⛔ 不看页面像不像 | 🔴 凡"改完没效果" ⇒ **先查进程启动时间 vs 改动时间**(且重起必须 `--takeover`) | +> | **P0-38** | 「改看板要不要重启」**有四类答案**:改 `board.html`/`board_ext.py` ⛔ **不用**(实时读盘/按签名热重载);改 `board.py`/`collabd.config.json` ✅ **必须** | 🔴 我把"改配置要重启"说成"改看板都要重启" ⇒ **分类错误**;重启不是万能药 | +> | **P0-16** | 告警**每轮重写同一条** ⇒ 噪音;**整体重写**会**静默抹掉人写的「## 解除条件」**(人唯一的回信口) | 🔴 程序"喊了"不算对 —— **喊的姿势**(频率+覆盖)才是问题,**"写成功了吗"这类断言查不出来** | +> | **P0-22** | 🔴 **「进程还活着」⛔ 不能从"日志/戳在动"推** —— 常驻 `--supervise` 历次只活 **8/12/20 分钟**,而**日志照旧在走**(那些轮次是**宿主钩子**的 `--tick` 写的)⇒ **四棒都被这条骗过**。唯一机读判据=`pid 活 ∧ 心跳新鲜(<90 s)`(心跳 `logs/supervise-heartbeat.json`)。⚠️ 同轮还踩到:`tasklist` 输出是 **GBK** ⇒ `text=True` 抛 `UnicodeDecodeError` ⇒ 存活判据**静默变假**(改用内核句柄) | 🔴 「看着在跑」≠「在跑」;修法=**事件驱动的常驻自愈**(`--tick` 顺手续命) | +> | **P0** | 所有 `subprocess` 必须带 **`CREATE_NO_WINDOW`** | 否则桌面反复闪黑窗 | + +## P0-22 🔴🔴 **「进程还活着」⛔ 不能从"日志/戳在动"推 —— 常驻也是这样被冤枉了四棒**(★ 2026-10-02 实测 · S8 治本) + +**症状**:`collabd.py --supervise`(唤醒时钟本体)**反复在几分钟内消失**(历次读数 **8 / 12 / 20 分钟**), +而 `_collabd.log`**照旧每几十秒一行** ⇒ 前四棒都据此认为"常驻在跑",只有**全量进程表**才发现「早就没了」。 + +**根因(本棒实测,⛔ 非推断)**: +1. 🔴 **日志的写者不止常驻** —— 宿主钩子(`PreToolUse ^Bash$` + `UserPromptSubmit`)每轮都会跑 `--tick`, + 它写的是**同一个日志**。⇒ **「日志在走」根本不能推出「常驻在跑」**(本条的最大教训)。 +2. 🔴 **本机不存在"能一直活着"的进程**:① `CREATE_BREAKAWAY_FROM_JOB` **被宿主作业对象拒绝** + (`PermissionError(13,'拒绝访问。')`)⇒ 脱不出回收;② 普通子进程**能**活过**工具调用边界** + (三探针跨调用打点 40 s+、父进程早已消失),但**迟早被回收**(载体会话结束/该轮结束)。 + ⇒ 上一版把载体押在"容器会话别关"上,**结构上就不可能兑现**。 + +**修法**(`collabd.py`): +- `--supervise` 每轮写**心跳**(`/.workbuddy/collab/logs/supervise-heartbeat.json`,**原子替换/零删除**, + 承接 P0-6 判据)+ 节拍追加 `…-heartbeat.log`(超 512 KB **重写**保留末 1500 行,⛔ 不 unlink); + 启动时**单例让位**(判据同下)。 +- **`--tick` 顺手续命**(`ensure_supervise()`:幂等 · 30 s 节流 · **无口令不起** · 无配置不起) + ⇒ 常驻掉了,**下一次事件自动补回来**(=「跨 turn 存活」的兑现方式)。另有 `--ensure` 供显式调用。 +- 🔴 **存活唯一判据**:**`pid 活 ∧ 心跳新鲜(<90 s)`**(`_pid_alive` 走内核句柄)。 + +**同轮第二个坑(同族:判据静默变假)**:`_pid_alive` 第一版用 `tasklist /FI "PID eq N"` + `text=True`, +而 `tasklist` 输出是**本地代码页(本机 GBK,首字节 0xd0)** ⇒ 在**读取线程**里抛 `UnicodeDecodeError` +(还会冒成未捕获线程异常写进 `supervise.out.log`)⇒ 判据**看着在、其实不可靠**。 +✅ 改用 `OpenProcess(QUERY_LIMITED_INFORMATION)` + `GetExitCodeProcess == 259(STILL_ACTIVE)`; +⚠️ 打不开且 `ERROR_ACCESS_DENIED(5)` ⇒ **保守判"在"**(宁可不起第二条,也不双写)。 +用例 `selftest.py::t_supervise_ensure`(8 项:四读数 + 心跳新鲜/陈旧 + 两条不起闸 + 接线与路径一致性)。 + +**第三个坑(自己踩的)**:不给自测设闸时,自测的 `--tick` 会在**测试工作区**起一条**真的**常驻 ⇒ +它持续重写测试夹具 ⇒ `已停总闸` 用例报「告警投影被清掉」**假红**、且 **FAIL 数每轮不同**。 + +**第四个坑(P0-22 同族 · 2026-10-03 目标检查会话实测)**:**手搓探针验「pid 活」时,`WaitForSingleObject` 会读出假阴性。** +症状:心跳 `round` 每 10 s 正常上涨、`ts_h` 新鲜(<90 s),但自写探针里 +`OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION)` + `WaitForSingleObject(h, 0)` 判出 **DEAD** ⇒ +「心跳新鲜 ∧ 进程已死」**自相矛盾**。 +根因:`WaitForSingleObject` 在该调用形态下返回 **-1**(非 0 / 非 `WAIT_TIMEOUT`)⇒ **不是「已退出」的信号**, +把它当布尔判就得到假阴性。✅ 正确口径=**P0-22 技能已采用的那条**: +`GetExitCodeProcess(h, byref(code))` + 判 `code.value == 259 (STILL_ACTIVE)`;打不开且 `ERROR_ACCESS_DENIED(5)` ⇒ 保守判「在」。 +✅ 本次实测坐实:同一 pid `GetExitCodeProcess=259`(**在**)+ `WaitForSingleObject=-1`(误判死)+ +`Win32_Process` 进程表独立佐证 **CreationDate 12:03:13 在** ⇒ **以 259 口径为准,代码无须改**。 +🔴 **通用教训**:🔴 **判据读到「自相矛盾」(心跳新鲜 ∧ 进程已死)时,⛔ 别急着判"机制真死"** —— +先怀疑**判据自己**(探针写错 / 手搓口径 ≠ 代码口径),并**找第二路独立读数**(进程表 / 看板 `/board.json` 的 `runtime.prog`)交叉验。 +本机即 `Get-CimInstance Win32_Process`(⚠️ 须走 PowerShell 工具,⛔ 从 Bash 调 `powershell -Command` 会被安全网关拦)+ +`curl --noproxy '*' http://127.0.0.1:8788/board.json` 的 `runtime.prog.{up,heartbeat_pid}`。 + +✅ 修法=`selftest.py::_mk_env()` 显式设 `COLLABD_NO_ENSURE=1`(⛔ 不是把断言写松)。 + +## P0-21 🔴🔴 **改「概念/口径」时最容易漏的两处:把口径当实测废掉、只改正文不查指针目标**(★ 2026-10-02 实测 · 用户两问点破) + +**缘起**:用户先问「为什么唤醒会话 还是定时任务呢,不应该是一个会话靠自己的后台任务 定时唤醒吗」,再令「**修复错误概念的时候 要排查相关引用 确保更新完全**」。 + +**① 「口径过时」与「实现没跟上」是**相反**的两件事,⛔ 别混** +- **口径过时** ⇒ **改口径**;**实现没跟上** ⇒ **口径照旧 + 记偏差**。 +- 判据:**改口径前必须先找到「用户原话 + 日期」**;找不到原话 ⇒ **只能记偏差,⛔ 不许动口径**。 +- 反面实例:见"定案说投递**同时提供唤醒时钟**"与"现状常驻停机、由排期代偿"不一致 ⇒ 误判成"定案过时",把两处**定案口径就地作废** ⇒ 用户一句反问点破。正确做法=**原文保留** + 加「**实测偏差(⛔ 不是口径变更)**」行。 + +**② 查残留 ⛔ 不能只查正文 —— 指针目标(`⇒ 全文`/`⇒ 细则`/`接续入口_*`)必须一起查** +- 反面实例:状态层**压缩版**的 `⇒ 全文` 指针,其目标文件里对应条目**整条与定案相反**(「不常驻」/「常驻走不通」/「两个拨钟方含自动任务」)⇒ 读者点进去读到的就是**错的**。**压缩版改对了、指针目标没改 = 等于没改。** +- 做法:**全量枚举 ⇒ 逐处只加标注/改写 ⇒ 复核**。复核判定窗口=该行**或其后 3 行**内是否带标注(**标注常写在下一行,只看单行会假性漏报**)。 + +**③ 顺带两条操作纪律** +- 「**只加标注、⛔ 不删原字**」—— 保留取证;要划掉用 `~~删除线~~`。 +- 多份副本(**技能侧模板 ⇄ 工作区实跑份**)改完**必须 `md5sum` 对表**,且 `diff` 只该出现你这次改的那几处。 + +## P0-20 🔴🔴 **`automation-request-refused`:⛔ 别从错误名 `refusal` 推「内容审查」—— 真因常常是「模型不支持关闭思考」**(★ 2026-10-02 实测定性 · **含同轮一次错误归因的完整订正**) + +> ### 🔴🔴 订正块(2026-10-02 05:4x)——**文首那条真因,本条的措辞推断已被推翻** +> +> **真因(逐字证据)**:会话日志 `E:/ProgramData/.workbuddy/logs/<日期>/sdk/conversations/.log` 里 +> `prompt:dispatch-failed:terminalError` 的 `details` 字段写着: +> `"Current model does not support disabling thinking (modelId=deepseek-v4.1-flash)"` +> ⇒ **模型能力与排期配置冲突**:这两条排期是 `model_id=deepseek-v4.1-flash` + **`model_is_thinking=0`(关闭思考)** +> ⇒ 该模型不支持关思考 ⇒ 服务端回 `-32603` ⇒ 被上层记成 `failure_code=automation-request-refused`/`name:"refusal"`。 +> +> **对照组(同一台机、同一时段)**:`WorkBuddy 日志定时清理` 与 `决策线体检` 用 **`hy4-preview` + `is_thinking=1`** +> ⇒ **从未被拒过**(02:00 ✓、04:00 ✓)。⇒ 指向配置,不指向措辞。 +> +> **"概率性"的真解释**:宿主有 **`thought-level-fallback`** 分支(05:06 那次 `requestId` 逐字带 +> `…:thought-level-fallback:attempt:1`)⇒ 走不走这条分支决定了同一份 prompt 有时过、有时被拒 +> ⇒ **这正是"同一份文本 02:47 ✓/03:55 ✗/05:06 ✓/05:17 ✗"的来源**,与文本内容无关。 +> +> **✅ 治本**:把两条周期排期的思考档打开 ⇒ `automation_update` 传 **`modelIsThinking=true`** +> (⚠️ 该字段**不在工具的文档 schema 里**,但 `additionalProperties` 接受它、**实测生效**: +> 回读库 `model_is_thinking` 由 **0 → 1**)。**⛔ 改完必须回读宿主库核对**,⛔ 别凭"我发过指令了"当已生效。 +> +> **🔴🔴 这次的教训(比结论本身重要)**:我先前只看 `runResult.error.name == "refusal"` + `usage` 全 0, +> 就推断成「**输入侧内容审查**」,据此写了一整条"措辞红线"并去改 prompt —— **方向是错的**。 +> **判据**:遇到 `automation-request-refused`,**第一步就是打开那条会话自己的日志读 `details`**, +> ⛔ **不许从错误名推断成因**("refusal" 是**上层对失败的错误分类**,不是内容命中)。 +> ⚠️ 这也再次印证本文件的老话:**"看着有判据"≠"判据指向真因"**。 +> +> **❓ 那"措辞红线"还要不要**:要,但它**是独立的一条**(见下「附带纪律」), +> ⛔ **不要再把它挂在"被拒绝执行"这个因果上**——那是我这轮的错误归因。 +> +> --- +> +> #### 📏 量纲 + 收口(2026-10-02 05:5x 复核) +> +> **① 这不是偶发** —— 当日 `logs/2026-10-02/sdk/conversations/*.log` 里 **11 条会话**命中 `dispatch-failed`, +> `details` **逐字相同**(全是 "does not support disabling thinking"): +> `00:33:50/00:36:58/00:44:33/01:47:23/01:50:24/02:55:41/02:57:51/05:09:37/05:10:58/05:30:48/05:36:35`(本地) +> ⇒ **凡「flash + 关思考」的排期,每次触发必被拒**(「概率性」的表象另见上面 `thought-level-fallback`)。 +> +> **② 已全量收口**:`automations` 表未删除的 **23 条排期**全部打开思考档 +> (改前 `is_thinking=0` 有 **18 条**)⇒ 回读 **`select count(*) … where deleted_at is null and model_is_thinking=0` = 0**。 +> 🔴 **唯一可信的复核判据**(该字段**不在 `automation_update` 的返回里**): +> ```sql +> select name, model_id, model_is_thinking from automations +> where deleted_at is null order by model_is_thinking, name; +> ``` +> +> **③ 措辞中性化:按用户要求执行了 —— 🔴 但⛔ 与"被拒"因果无关**(2026-10-02 05:5x 二次订正) +> —— 我先前写"**不予采用**"是**又一次想当然**:用户当天回「**需要**」⇒ 已把 +> `WorkBuddy 日志定时清理`(id `07976988-1c69-41b2-9547-1264a1a24bf7`)的 prompt 按改写稿**落库**。 +> 5 处:删掉「无需二次确认/**有权解除**删除过程中的阻断(含摘除 Deny ACL、解除批量删除熔断)」的口吻 +> ⇒ 改为「**按用户既有授权**执行删除与截断;遇到阻断时**按 A0 段既定步骤**处理,并写明处理了哪些、跳过了哪些」。 +> ✅ **逐字校验通过**:`md5(库) == md5(稿) == 27df82bfc0777d86949eb9eac9d2bf13`(5757 字符)。 +> 🔴 **别把它当成"修好了被拒"** —— 它的收益是**文本不把下一棒引向歧路**(见下「附带纪律」),**不是修复**。 +> ⚠️ **两条搬运纪律**:① 动手前先探明库中换行形态(本条是**字面 `\n`**,不是真实换行)⇒ 否则静默改坏格式 +> (JSON 里要写 `\\n`);② 5.7 KB 长文本改完**必须回读库逐字比对**(工具返回的转义形态只能看个大概)。 +> +> **④ ⚠️ 尚未验完**:以上只证实「**配置已改**」,**没有**证实「**不再被拒**」。 +> 验证点=周期排期的下一次触发,判据=其会话日志**不再出现** `prompt:dispatch-failed`; +> 若仍出现 ⇒ 本条结论仍不完整,**必须重查**(⛔ 不得默认已修好)。 + +**附带纪律(独立成立,⛔ 与上面的真因无关)**:无人值守 prompt 与其自动化记忆文件,**别写成"对抗平台/持久化自维持/自我繁殖/探查平台内部"的口吻** —— +理由不是"会被拒",而是**这种文本会把下一棒引向歧路**(记手法而不记结论)。 + +- **形状**:排期到点 ⇒ 会话没产出,`automation_runs` 表里落一行 + `failure_code=automation-request-refused`,`runResult.error={"code":-32603,"error":{"name":"refusal",…}}`,**`usage` 全 0**。 +- 🔴 **判据(怎么认出是它,而不是"排期没触发")**:查宿主库 `automation_runs` 的 `failure_code`; + ⛔ **别去猜"是不是排期坏了/是不是程序挂了"** —— 那是另一套症状(有 `thread_id`、有 token 消耗)。 +- 🔴🔴 **最关键的一条实测(决定处置方式)**:**同一份 prompt 文本** + `10-02 02:47 ✓ / 03:55 ✗ / 05:06 ✓` ⇒ **不是"含某个词就必拒"**,是**阈值型/概率型**输入判定。 + ⇒ ⛔ **不要去"找出那个唯一的触发词"**(找不出来;就算这轮找到了,下轮也不复现) + ⇒ ✅ 正解=**把风险面整体压低**:换掉一整类措辞,而不是抠掉某一个词。 +- **四类要换掉的措辞**(按"读起来像什么"排序,⛔ 都是**语义像**,不是"词被拉黑"): + | 类 | 原措辞(例) | 换成 | + |---|---|---| + | ① 像对抗/规避平台 | `被宿主回收`、`静默 X 分钟`、`绕过`、`监管` | 不提这类因果;只写"现在的做法是什么" | + | ② 像持久化/自维持 | `自维持脉冲`、`常驻`、`长活进程`、`后台 sleep`、`spawn 下一棒`、`守护` | `本排期每小时自动触发一次;本会话只做本轮这一遍:不重复触发、不循环等待、不新建周期排期、不起后台进程` | + | ③ 像自我繁殖 | 强调"会话自己登记新的周期排期" | `开新会话走一次性排期入口;一次只开一条` | + | ④ 像探查平台内部 | 教它直连应用库写 SQL、查平台调度表、看别的会话的内部状态 | `取数只用 state.py 与上列文件` | +- 🔴 **最容易被漏掉的一面 —— 自动化自己的记忆文件**: + `.workbuddy/memory/automations//memory.md` **下一轮会被一起读进上下文** ⇒ 那里写的"手法"**同样算输入**。 + 本轮实测:**风险最集中的不是 prompt,是这份记忆**(存着"怎么直连应用库查会话""怎么观察平台有没有点火")。 + ⇒ **纪律**:记忆文件**只写结论、数值与判据**;不写操作手法、不写平台内部结构的探查方式。 + ⇒ ⛔ **这条纪律必须同时写进 prompt 里**,否则下一棒会自己把"手法"又写回去(闭环)。 +- ⛔ **禁令别写太密**:满屏 `⛔/🔴🔴/不许/不得`(本轮原稿近 20 处)会把整份 prompt 渲染成一叠"约束平台"的指令 + ⇒ 只保留 2~4 处真正关键的,其余改成中性的陈述句。 +- ✅ 本轮处置:两条周期排期的 prompt 重写 + 两份自动化记忆改写(2026-10-02 05:0x)。 + ⚠️ **残留**:被拒的那两轮**没有任何产出**(`resultEvidence=none`)⇒ 那一小时的空档**补不回来**,只能靠下一跳。 + +## P0-18 🔴🔴 **回路挂在「宿主从不投递的事件」上 ⇒ 整条回路静默失效,而它「看着像在跑」**(★ 2026-09-29 记 · 2026-10-01 复验并确认后果) + +- **形状**:`wb-result-hook.py` 的「**会话收尾 ⇒ 通知协作程序放行下一条**」挂在 **`SessionEnd`** 上 + (写 `gate-done.stamp`)。而**宿主从不投递 `SessionEnd`**(同文件 243 行 09-29 就记下了) + ⇒ 那个 stamp **至今不存在**(2026-10-01 复验:仍不存在)⇒ **这条回路从装上起一次都没跑过**。 +- 🔴 **为什么它骗了很久**: + ① 钩子**确实被调用了**(日志里有行)⇒ 看着"钩子在跑"; + ② 那些行是 `skip: event=''` ⇒ 来源是 `install.py --verify` 的**空载荷自检**、或宿主不带 payload 的调用; + ③ 真正会被投递的两个事件(`UserPromptSubmit`/`PreToolUse`)的分支**各自 `return 0`、不写日志** + ⇒ **它越正常,日志里越查不到** ⇒ 单看日志必然误判。 +- **怎么定死真因(本轮用的三步,可复用)**: + 1. **去找那条回路该产出的文件**:`gate-done.stamp` ⇒ **不存在** ⇒ 回路没跑过(**比读日志硬**); + 2. **看消费方**:`collabd.py` **真读它**(判 `gate=busy`)⇒ 不是死代码,**是有害的沉默**; + 3. **分辨"日志行"的来源**:`grep " done " hook.log` ⇒ 全部带着**自测专用的 `session=` 值** + (`scopeche`/`q2`/`e2e-line`)⇒ 证明**全是 `--selftest`**,无一条真实调用。 +- **判据**:⛔ **不许拿"日志里有行"证明回路在工作** —— 必须**指出该回路该产出的文件/记录**, + 并**确认它存在且新鲜**。⚠️ 同族:**空载荷自检会污染生产日志** ⇒ 排查前先把自检噪音剔掉。 +- **后果(本轮实测)**:不是"永久卡死"(claim 会被「持有人失活立即出队」或 20 分钟陈旧兜住), + 而是**纯延迟** + **NEXT.md 里那句话是假话**(原写「SessionEnd 会自动放行下一条」) + ⇒ **照着做就不删 claim** ⇒ 那才真卡住。✅ 已把 NEXT.md 第 6 条改成 + 「**收尾=自己删 `claims/`**」并注明"别指望 SessionEnd"。 + +## P0-19 🔴🔴 **「状态窗口」死结:拿库里的 `status` 判"在不在执行" ⇒ 恒真 ⇒ 投递永远等不到空闲**(★ 2026-10-01 实测定性 · 用户报障「队列一直没有上报」) + +- **症状**:队列一直压着不出去。`_collabd.log` **每 ~20 秒**打一遍 + `延后投递:跟进会话 e2ccdea3 正在执行 ⇒ 等它空闲` + `投递未成(target-busy)⇒ 保留 M5=done 在队首`, + 连打 6 分钟不停(21:58 → 22:44 之间同类信息 20+ 轮)。 +- **根因(死结的形状)**:判据是 `_session_status(目标) == "working"`。而 `sessions.status` 的**取值域** + 实测只有 **`working` / `completed` / `error` / `archived` —— ⛔ 没有"空闲"这一档** + (实测分布:`completed 123 / working 2 / archived 2 / error 1`)。 + ⇒ 一条**活着的**会话跑完一轮、停在等下一轮时,**status 仍然是 `working`** + ⇒ **活着 ⇒ 判忙不投;跑完 ⇒ `completed` ⇒ 不 live 也不投** + ⇒ **不存在任何一个能投进去的时刻**。**旁证**:`wakeups.jsonl` 里唯一投成的那次(08:55 `http:200 ok:true`) + 目标是 `f8a792ab` —— 一条**日志已冻结的"死"会话** ⇒ **只有"死"会话才投得进去**。 +- 🔴 **真正区分得开的东西在宿主自己的状态机日志里**(`[SessionRunStateMachine]`,**只写工作区日志、 + ⛔ 不进数据库** —— 所以查库永远查不到): + | 事件 | 结果 | + |---|---| + | `event=AGENT_STARTED` / `RUN_ACCEPTED` | `lifecycle=running` **`busy=true`** | + | `event=AGENT_ENDED` | `to=idle` `lifecycle=idle` **`busy=false`** `queueBusy=false` | +- **修法**(`collabd.py::_session_busy()` + `_target_busy()`,**语义不变、只换判据形态** —— + 用户口径原话「**跟进会话 执行完了 在上报没问题,执行中就等待上报**」,⛔ **不是**"超时即投"): + 1. 库里 `status` ∈ `SID_DEAD` ⇒ **直接判"没在跑"**(⛔ 不看日志)。 + 🔴 **必须有这一道**:实测**自动化拉起的会话跑完不写 `busy=false`** + (整份日志里 `busy=false` 只出现过 12 次、**全是主会话的**),它直接在库里变 `completed`, + 而**日志末条仍是 `busy=true`**(`e2ccdea3`:末条 `22:42:10 … busy=true`,库里 `22:45:53` 已 `completed`) + ⇒ 只看日志会把**早就结束的会话**再"忙"上十几分钟。 + 2. `status == working` ⇒ **才**读日志细分:**末条** `busy=` 取**最后一条**记录, + `busy=true` **且新鲜**(`SRSM_FRESH`)⇒ 忙;否则 ⇒ 空闲。 + 🔴 「新鲜」这半**必须有**:会话真在跑时状态机**每几百毫秒写一行** ⇒ + 「末条 `busy=true` 却已是 N 分钟前」**只可能**是它早停了 ⇒ **不必依赖"正好抓到 `AGENT_ENDED`"** + (那一条会被挤出尾部窗口)。⚠️ `SRSM_FRESH` 取 **900 s**:一轮里跑**很长的单个工具调用**时 + 状态机**全程静默**(实测一次 8 MB 日志扫描静默 ~6 分钟)⇒ 窗口太短会把**正在跑**的误判成**空闲** + (方向相反的错:往正在跑的会话里插话)。多等 ≤15 分钟**不算损失**(队列件本来就压着)。 + 3. 读不到 ⇒ **按"没在跑"处理**并**留一行日志**:⛔ 不能按"忙"处理 —— 那等于把死结换个形状留着。 +- 🔴 **同一轮我自己写出的两个"判据看着在、其实恒假/恒真"**(都已修,并写成断言): + - **(a) 正则位置组错位**:写成 `(?:\.(\d+))?` 时**内层 `(\d+)` 仍是捕获组** ⇒ 后面 `m.group(8)` + **整个错位一位** ⇒ `busy` **恒读成 `False`** ⇒ **恒判"空闲"**(方向相反:会往正在跑的会话里插话)。 + ✅ 改用**命名组** `(?P…)`,并加**源码级反回归断言**(`_session_busy` 段内零位置组引用)。 + ⚠️ 这条断言**不是"看代码像不像"** —— 它是**唯一**能在这类错位再发生时立刻报红的东西。 + - **(b) 同一秒内"后出现的没胜出"**:只比 `t > last[0]` ⇒ 同一秒(甚至同毫秒)的两条, + **前一条胜出** ⇒ 取到**旧状态**。✅ 改成比较 `(时间, 行序)` 二元组。 + - **(c) 我自己的红绿对照脚本是空的**:变异体跑的是 `-k 忙判据`,而那条 `target-busy` 用例的**名字里没有"忙判据"** + ⇒ **变异体根本没被试**,报"全绿" ⇒ 差点当成"断言恒真"。✅ 过滤词改成能覆盖两条用例的词。 + ⇒ 🔴 **判据**:**变异法跑完必须回读"这个变异体到底被哪几条用例跑了"**,⛔ 不看"合计 PASS"就当证毕。 +- **残留(⛔ 不是本条的修法能解决的)**:判据换对之后,拦截原因从 `target-busy` 变成 + **`no-follow-session`** —— 那是**真状态**:此刻**一条活的 `[跟进]` 会话都没有** + (`e2ccdea3` 跑完变 `completed` 就退出了网关的活会话集)。⇒ **"推"只在目标活着时能用**; + 目标不在线时靠**排期到点拉起一条**(`[跟进]-…` 每小时,`nextRunAt` 23:21:35)。 + + + +- **形状**:看板是**一个常驻 HTTP 服务**(`board.py --serve `),代码**载入内存后一直跑**。 + 于是**改完 `board.py` / `assets/board.html` / `board_ext.py`,页面上一点变化都没有** —— + 服务还是**改之前**那份代码,它**不会自己重载**。 +- **实测**:用户报「看板中协作会话区域**还是**看不到协作会话」。反复核对页面与代码都对得上, + 最后发现**病根是进程**:服务是 **20:24:38** 起的,而改代码发生在 **21:xx** + ⇒ 它返回的 `board.json` 是**旧结果**(`[跟进]-…` 被标成「主会话」、**只 2 条会话**)。 + 按文档姿势 **`--takeover`** 重起后 ⇒ **6 条会话、三类齐全**(3 条执行会话可见)。 +- **判据(⛔ 不看"页面像不像")**: + 1. 直接读 **`/board.json`**:看 `ts`(快照时间)**是不是刚才**、**会话条数**、**每条会话的类别**, + 再与**磁盘上的真实快照**(宿主库/`sessions` 表)**逐条对照**。 + 2. 🔴 **`--takeover` 是硬要求** —— 同 P0-9:Windows 允许**同端口重复绑定且不报错** + ⇒ ⛔ 不 `--takeover` 就会**静默并存**,你看到的很可能仍是**旧进程**画的那张图。 +- **自查**:凡"改完看不到变化" ⇒ **先问"我看的是哪个进程画的图"**,⛔ 别先怀疑自己改错。 + ⚠️ 同族:凡**改完看不到效果**的服务(看板/沙箱实例/常驻),**一律先查进程启动时间 vs 改动时间**。 + +## P0-16 🔴 **每轮重写同一条告警 ⇒ 噪音 + 静默抹掉「人写的回信口」**(★ 2026-10-01 实测) + +- **形状**:`need_user()` 遇阻就落 `NEED-USER.md`。而它在**常驻**里每轮(~10 s)都会被走到 + ⇒ **每 10 秒整体重写一次**,**时间戳一直变**(现场:22:06–22:12 之间被刷了 20+ 次)。 + 人看到的是「这条消息一直在喊、一直在变」⇒ **当噪音忽略掉**。 + 🔴 **副作用更严重**:人手工在文件里写了「## 解除条件」——那是**人唯一的回信口** —— + 下一轮**整体重写**把它**静默抹掉** ⇒ 人写一次被抹一次,**就再也不写了**(实测:21:03 手写的当场被覆盖)。 +- **判据(两条纪律,都落在文件上)**: + 1. **同一句话要节流**:窗口(`DSH_NEED_GAP`,默认 600 s)内**同原因** ⇒ **不重写**(⛔ 不刷屏)。 + ⚠️ 判据必须落在**文件**上(⛔ 不是内存)—— 钩子每次都是**新进程**,内存节流**跨不了进程**。 + 2. **人写的内容必须原样带走**:重写时把 `## 解除条件` 及其后整段**保留**。 +- 🔴 **为什么这条难自己发现**:程序侧"我明明喊了"是**对的** —— 问题出在**喊的姿势**(频率 + 覆盖), + ⛔ 任何"写成功了吗"的断言都**查不出来**。⇒ 得换问法:**"人看到这条会怎么想?"** +- **判据落地**:`selftest.py::t_need_user_throttle_keep`(4 项),已按 P0-13 用**变异法**证明非空: + 打掉节流 ⇒ ③ 报红;打掉保留 ⇒ ④ 报红。 + +## P0-15 🔴 **判「样式对不对」时去查内联属性 ⇒ 两条假红**(样式其实写在 CSS 里)(★ 2026-10-01 实测) + +- **形状**:给新画的「分组大框」加样式时,`.grp{fill:none;stroke:…;stroke-dasharray:8 7}` **写在 `