Files
dsh_ai1net_server/交付物/唤醒机制-实现方案对比与选型-20260930.md
T
admin c1b5e4d966 chore(工作区): 全量入库 + 补齐 .gitignore(以工作区为准)
- 变更规模:新增 514 / 修改 62 / 重命名 155 / 删除 4(归档重组与文档轮次)
- .gitignore 修:`归档/**/db-cwd归一-备份-*/` —— 原规则写绝对层级(归档/db-cwd归一-…),
  目录搬进 归档/配置与备份/ 后**静默失效**,43 MB 的 DB 备份又变成未跟踪
- .gitignore 补:嵌套 git 内部数据(归档/内嵌git-20261008/、归档/skills-git-旧线-20261007/dotgit-原样移出/)
- .gitignore 补:运行态与部署副本(.workbuddy/collab/、.workbuddy/tools/、.workbuddy/.load-pending、.workbuddy/tmp-*)
- .gitignore 补:备份件(*.bak-*)
- 未跟踪文件从 2190 降到 890(其余为 归档/ 归档件与 .workbuddy/memory/ 知识文件,按口径入库)
2026-10-10 23:13:22 +08:00

239 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 唤醒机制 · 实现方案对比与选型(2026-09-30)
> ## 🔴 修正(2026-09-30 17:2x · 用户否决「限制了使用」)
> **上一版把通道定成 `POST /sessions/{id}/reply`(只认 live)⇒ 只能唤醒"桌面当前打开的那一个会话" ⇒ 这就是"限制使用",用户已否决。**
> **修正:通道改用 `/api/v1/jobs` —— 限制不存在了。** 实测与源码级依据见 **§8**。一句话:
> **任意会话(含非 live / 归档)都能 `POST /jobs/resume?cwd=` 拉成 headless worker(idle、不重放 prompt)→ 再 `POST /jobs/{id}/reply` 派活;不会把用户桌面的对话切走。**
> **结论先给**:**最佳 = 「常驻静默判定 + 条件触发投递」(D4)**,而不是"每 5 分钟叫醒一次"。
> 理由一句话:**任何"唤醒会话"都等于开一轮 AI,必然烧一轮 token**;所以最优设计不是每隔 5 分钟**叫醒**,而是每隔 5 分钟**看一眼**(判定≈0 token),**只在真有事时才叫醒**。
> ⛔ 次优才是"纯闹钟"(每次必醒);⛔ 最差是自动化(粒度只有 1 小时 + 每次还开一个新会话)。
---
## §1 先把一条成本律钉死(它决定了选型)
```
唤醒会话 ⇒ 注入一条消息 ⇒ 会话开新的一轮 ⇒ 🔴 必然消耗一轮 AI
```
**推论**:
- 「5 分钟一次」= **288 轮/天**,这是**无条件**的 token 成本 —— 不管用哪条通道(自动化 / gateway / 后台任务脉冲)都一样,因为贵的是**唤醒这件事本身**,不是通道。
- ⇒ **真正的杠杆是"减少唤醒次数",不是"换更省钱的通道"**。
- ⇒ 由此得到本选型的核心思想:**把"看"和"叫醒"拆开** —— 判定用**进程内代码**(≈0 token、可任意快),叫醒才投递(1 轮 AI)。
---
## §2 候选机制清单(M1–M7,全部有实测依据)
| # | 机制 | 粒度 | 新增会话 | 每轮 AI | 生命周期 | 实测依据 |
|---|---|---|---|---|---|---|
| **M1** | **自动化(rrule)** | ⛔ **最小 1 小时** | 🔴 **每次 1 个新会话** | 有 | 宿主排期,**持久** | rrule 是**硬白名单**(`FREQ` 只认 HOURLY/DAILY/WEEKLY/MONTHLY/YEARLY;全包 `MINUTELY`/`SECONDLY` 命中 0);`automation_runs` **74/74** ⇒ 一次运行=一个新会话 |
| **M2** | **本机 gateway `reply` 直连** | 任意(由调用方决定) | **0** | 有 | 靠调用方 | 实测 `POST /reply` → **`200 {"delivered":true}`**;⛔ 只认 **live**(非 live ⇒ 409) |
| **M3** | **会话自建一次性后台任务(脉冲)** | 任意(实测 300 s) | **0** | 有(每棒一轮) | ⚠️ **绑该会话**(会话/宿主一关就没) | A/B/C/D 四组:**完成即唤醒**(与 stdout 无关);静默安全;300 s 不被回收 |
| **M4** | **常驻静默 + 内部主动 `reply`** | 任意(判定间隔) | **0** | **只在实际投递时** | ⚠️ 绑该会话 | 常驻**能长跑**(跨轮存活实测);但常驻**自己不唤醒**(实测)⇒ 唤醒必须来自它调的 `reply` |
| **M5** | **外部定时器(服务器 47 / 手机快捷指令)→ 443 → `reply`** | 任意 | **0** | 有 | ✅ **不绑会话**(宿主重启后仍可投递) | 443 通道已建、V2 已验收 |
| **M6** | **钩子驱动(有事就投递)** | 事件触发,**不是闹钟** | **0** | 有 | 宿主在跑就有效 | 钩子是宿主起的子进程,有网关口令、不占会话、零 token;⚠️ **闲时无事件** ⇒ 不能当定时器 |
| **M7** | **官方手机助理(微信/企微)** | 人驱动(任意) | 0(固定"助理专属文件夹"一个会话) | 有 | 官方维护 | 官方文档原文:「助理(远程任务):**仅一个会话**」;本机 **未启用**;粒度看人 |
**一句话排除**:
- ⛔ **M1** 排除 —— 粒度差(1 小时)+ 每次开新会话(会话列表污染,旧坑"会话洪泛")。
- ⛔ **M6** 不能单独用 —— 它只会在"你已经在动"时才有事件;**你不动它就不响**。
- ⛔ **M7** 是"人驱动",不是自动唤醒;且只能进它自己那一个会话。
- ⚠️ **M3 的"常驻循环 + 周期 echo"变体已实测排除** —— 永不"完成" ⇒ 永不唤醒。
---
## §3 对比矩阵(按"能否当 5 分钟心跳"评)
| 维度 | M1 自动化 | M2 gateway 直连 | M3 一次性脉冲 | **M4 常驻+条件投递** | M5 外部定时器 |
|---|---|---|---|---|---|
| 5 分钟粒度 | ⛔ 不行 | ✅ | ✅ | ✅ | ✅ |
| 新增会话 | 🔴 每次 1 个 | ✅ 0 | ✅ 0 | ✅ 0 | ✅ 0 |
| **AI 轮数/天**(5 分钟) | 288(且额外开 288 会话) | **288** | **288** | ✅ **只在实际有事时** | **288** |
| 常驻进程 | 1(宿主内) | — | ✅ 0 | ⚠️ 1(必须完全静默) | — |
| 宿主重启后 | ✅ 还在 | ✅ | ⛔ 没了 | ⛔ 没了(除非钩子自拉起) | ✅ 还在 |
| 需要口令 | ⛔(在进程树内,天然有) | ✅ 需要 | ✅ 不需要 | ✅ 需要 | ✅ 需要(且要跨网传) |
| 跨网依赖 | ✅ 无 | ✅ 无 | ✅ 无 | ✅ 无 | ⚠️ 有(443/覆盖网络) |
| 落地复杂度 | 低 | **低(工具已建)** | **低(工具已建)** | 中(要写判定逻辑) | 中高(外部一端+令牌) |
| 可一键关停 | ✅ | — | ✅ | ✅ | ✅ |
| 红线对账 | ✅ | ✅ | ✅ | ⚠️ **T1 字面冲突**(本意满足:完全静默) | ✅ |
---
## §4 ✅ 最佳选择:D4「常驻静默判定 + 条件触发投递」
### 4.1 架构
```
┌──────────────── 常驻(1 个进程 · 完全静默 · stdout 全重定向)────────────────┐
│ 每 N 秒(建议 60–300 s)做一次【纯代码判定】—— ≈0 token、不进会话: │
│ ① 有没有待处理的活?(台账 / advance.md / automation_runs) │
│ ② 目标会话是不是 live?(GET /api/v1/sessions/live) │
│ ③ 距上次投递是否够久?(去抖,⛔ 防同一条重复投) │
│ ④ 任务图是否已全 done?(是 ⇒ 收工,不再投) │
└───────────────────────────────┬───────────────────────────────────────────┘
│ 只有 ①②③④ 全过 才走这一步 ↓(贵的那一步)
▼
POST /api/v1/sessions/{live}/reply ← 本机 127.0.0.1,无代理
│
▼
会话被唤醒,开 1 轮 AI 去推进
```
**关键点**:
- **判定在进程内**(≈0 token、可 10 秒一次)⇒ 响应快,但**不烧钱**。
- **投递才是开销** ⇒ 用四道闸门把它压到"真有活才干"。
- **常驻必须完全静默**:输出全重定向到文件;⛔ 一旦往 stdout 吐,就会把转录推过 10 MiB ⇒ diagnostic-log dropped ⇒ 界面像卡(09-29 实物)。
- **常驻形态**:本机**唯一可行**=**会话后台任务 + stdout 全重定向**(沙箱回收子进程 + 程序黑名单封死了计划任务/服务那条路)。
### 4.2 为什么它胜过其它
| 对比 | 差在哪 |
|---|---|
| vs **M3 一次性脉冲** | M3 是"**每棒一轮 AI**",288 轮/天照烧;D4 把"看"变便宜,**只在有事时才叫醒**。M3 还有一个硬伤:**链断即停**(某轮我没续棒就永久停了)。 |
| vs **M5 外部定时器** | M5 同样 288 轮/天,且多一层跨网依赖与令牌传递;但 M5 有一个 M4 没有的优点:**宿主重启后仍在**。⇒ **M5 作为 D4 的降级备份**。 |
| vs **M1 自动化** | 粒度差一个数量级,还每次开新会话。 |
| vs **M6 钩子** | 钩子只在有事件时响,**没有事件就不会有"该干活了"的提示**;但它是 D4 的**最佳加速器**(有人动过 ⇒ 立刻追加判定,不必等下一个 N 秒)。 |
### 4.3 ⚠️ 前提与代价(必须一起接受)
1. **只能唤醒"桌面当前打开的那一个会话"** —— `reply` 只认 live;非 live 只能走 ACP 借用,而**借用会把用户正在看的对话切走**(红线 T2 ⇒ 禁止)。**跨会话唤醒实质上不可做**,需求要按这个收窄。
2. **绑会话/宿主**:宿主重启后就没了 ⇒ 需要一台"重启后自动拉起"的机制(**钩子 `SessionStart` 能否拉起常驻的子进程:未验证**,列为待测)。
3. **T1 字面冲突**:T1 写的是"⛔ 垫片/桥不得放进会话后台任务"。D4 属于"常驻判定器放进会话后台任务" ⇒ **文字上撞**,但 **T1 的本意**(别让输出回流拖死宿主)**靠"完全静默"已满足** ⇒ **采纳前需用户明确覆盖**。
4. **必须能一键关停**(T7):停掉进程即彻底停 —— 因为**不投递就不会有任何反应**(天然的 fail-safe 方向)。
5. 每轮唤醒的 token 成本 ≠ 0 —— 只是从"288 次"降到"实际需要几次"。
---
## §5 落地步骤(可执行)
| 步 | 动作 | 验收 |
|---|---|---|
| 1 | 定**判定间隔 N** 与**四道闸门**的具体条件(默认 300 s;闸门照抄既有 `collabd.wake_round` 的四条) | 闸门逐条可复现 |
| 2 | 写判定器(零依赖单文件;口令**只从环境变量读**,⛔ 不落盘) | 冷启动即产出正确判定 |
| 3 | 以**一次性能跑满 N 秒**的形态试跑(先跑 10 分钟) | 期间 `sessions.updated_at` **只在真的该投递时才动** |
| 4 | 加**去抖+终止条件**(任务图全 done ⇒ 自行退出) | 全 done 后不再投递、进程自行退出 |
| 5 | 接 M6 钩子做加速(人一动手就追加一次判定) | 有事件时判定提前触发 |
| 6 | 过 **V7 验收**(连续 1 小时/≥50 次操作:宿主 pid 恒定、无 429、桌面"当前对话"未被改变、无新增对外端口、配置哈希零差异) | 六项全过 |
| 7 | 备降级路径 **M5**(服务器 47 或手机快捷指令 → 443 → `reply`) | 宿主重启后仍能投递 |
---
## §6 一句话答复"如何实现最好"
> **不要做"每 5 分钟叫醒"的闹钟,要做"每 5 分钟看一眼、有事才叫醒"的门卫。**
> 门卫 = 一个完全静默的常驻判定进程(会话后台任务起)+ 它自己调本机 gateway `reply`;
> 判定走代码(≈0 token),投递才花 AI;关掉它 = 功能消失、宿主无感。
> 「纯闹钟」只有在**明确接受 288 轮/天**时才考虑,且形态选 **M3 一次性脉冲链**(最简、不要口令)。
---
## §7 出处
| 是什么 | 在哪 |
|---|---|
| 四组唤醒实验(A/B/C/D)+ E1/E2 + 链式续棒 | `交付物/唤醒-会话自建后台任务方案评估-20260930.md` |
| gateway 直连实测 + 实现 | `交付物/唤醒-本机网关直连测试-20260930.md` ・ `.workbuddy/collab/wake-session.py` |
| 一次性静默脉冲工具 | `.workbuddy/collab/wake-pulse.sh` |
| rrule 硬白名单(M1 排除依据) | 技能 `workbuddy-extension-surface` §1.4 |
| T1–T7 + V7 验收判据 | `交付物/手机App经覆盖网络操作WorkBuddy-架构定稿v3-20260929.md` §0.5 |
| 「唤醒为什么断」原始定案 | `交付物/查唤醒为什么断-结论-20260930.md` |
---
## §8 修正(17:2x · 解除「只能唤醒 live 会话」这条限制)
### 8.1 用户否掉的是什么
> 用户原话:「**这个方案限制了使用 不行**」
上一版把通道定死成 `POST /api/v1/sessions/{id}/reply` —— 而它**只认 live** ⇒ **只能唤醒"桌面当前打开的那一个会话"**。要求"手机能看/能回桌面**任意**会话"时,这条就是硬限制 ⇒ 被否决。
### 8.2 🔴 正解:宿主原生有 `/api/v1/jobs`(智能体实例),不受 live 限制
**源码级依据**(`app.asar` → `/cli/dist/codebuddy-headless.js`,OpenAPI 描述原文):
> `/api/v1/jobs/resume`:summary=「恢复归档会话为智能体」,**description=「使用 --resume 启动常驻 worker,**不重放 prompt**;恢复后以 **idle 状态**出现在智能体列表。」**,`cwd` 参数在 **query**(不是 body)。
**两条关键含义**:
- ✅ **不重放 prompt** ⇒ 拉起来**不会自动跑一轮**、不偷烧 token。
- ✅ **它是 headless 常驻 worker**(`kind:"background"`、独立 `pid`)⇒ **不接管桌面** ⇒ **不会把用户正在看的对话切走**。
**实测读数(本机 59486,17:19)**:
| 端点 | 实测返回 |
|---|---|
| `GET /api/v1/sessions` | **全部 20 个会话**(`id / name / createdAt / updatedAt / messageCount / isCurrent`)⇒ 会话列表本来就不受 live 限制 |
| `GET /api/v1/jobs` | `{"jobs":[]}`(当前无活跃 worker) |
| `GET /api/v1/jobs/resumable` | **20 条候选**(含 `fe146dd9` / `9ad1f801` / `44226c16` / `4d22f079` … 全是**非 live** 会话)+ `hasMore` / `nextOffset` 分页 |
| `GET /api/v1/status` | `{status:"ok", busy:true, activeSessionId, runStatus:"tool_executing"}` ⇒ **T2「记原活会话并归还核对」的现成判据字段** |
| `POST /api/v1/jobs/resume?cwd=…` | 返回 job 对象:`{id, state:"working", tempo:"idle", cwd, **kind:"background"**, alive:true, sessionId, **pid**} |
| `POST /api/v1/jobs/{id}/stop` | `{"stopped":true}` ⇒ 可一键清掉 |
### 8.3 修正后的通道(三层,按目标自动选)
| 目标状态 | 走哪条 | 代价 |
|---|---|---|
| **live**(桌面当前打开) | `POST /api/v1/sessions/{id}/reply` | 最省(0 新进程) |
| **非 live / 归档** | `POST /api/v1/jobs/resume?cwd=<项目目录>` body `{sessionId}` ⇒ 得 job(**idle**)⇒ `POST /api/v1/jobs/{id}/reply` | 1 个 headless worker |
| **看列表** | `GET /api/v1/sessions`(全部)+ `GET /api/v1/jobs`(活跃)+ `GET /api/v1/jobs/resumable`(可分页) | 只读 |
⇒ **「只能唤醒桌面打开的那一个」这条限制不存在。** 触发者(外部定时器 / 常驻判定器 / 一次性脉冲)与投递目标彻底解耦 —— 手机端因此能**列出全部会话并挑任意一个派活**。
### 8.4 ⚠️ 三个新踩到的坑(已登记,⛔ 别再犯)
1. 🔴 **`/jobs/resume` 不校验 `sessionId`** —— 我拿一个全 0 的假 id 探契约,它**照样起了一个真 worker 进程**(`kind:"background"`、`pid=44444`)。
⇒ ① **这不是零副作用探针**,⛔ 别当只读接口试;② 调用方必须**自己先校验 id 有效**。
✅ 已清理:`POST /jobs/00000000/stop` → `{"stopped":true}`;复核 `jobs=[]`,两个 pid 均已退出。
2. ⚠️ **`/jobs` 按 id 前缀聚合**:两个 worker 同属一个 sessionId ⇒ 列表里只出现 1 条 ⇒ **stop 完必须 `GET /jobs` 复核**,别以为停一次就干净。
3. ⚠️ **`cwd` 不强制正斜杠**:传 `e:\ProgramData\...` 也接受(会被原样存进 job)。
### 8.5 🔴🔴 更硬的一条:**「投递成功」≠「被唤醒」** —— 目标必须有人消费
**来自本机实测(`tmp/supervise-inbox/NEED-USER.md`,2026-09-30 10:50)**:
> 原因:投给会话 `fe146dd9` 的通知**没被消费**(投出后 **5.9 分钟该会话零活动**)。
> 典型的**宿主侧卡住**:`parkInQueue` + **`hasWaiter=false`** —— 消息进了队列、**没有消费者**。
> ⇒ 请把窗口**切走再切回**,或**关掉再打开该会话窗口** ⇒ 队列随即会被排空。⛔ 别反复发消息试探。
**含义(必须写进验收判据)**:
- `POST …/reply`(含 `/jobs/{id}/reply`)返回 **200 `delivered:true` 只代表「已入队」**;**真正被唤醒还需要该会话有一个"消费者"(waiter)**。
- 没有消费者时:消息**静静躺在 `parkInQueue` 里**,会话**零活动** ⇒ 从外面看就是"投递成功了但没反应"。
- ⇒ 🔴 **这就是"看着能唤醒、实际不唤醒"的那道隐性门**;也是最容易被误判成"通道没打通"的真因。
- ⇒ **任何唤醒方案都必须带一条"消费确认"**:投递后回查 `sessions.updated_at` / `status.activeSessionId` 是否前移,**⛔ 不能拿 200 当闭环**(此判据与本报告 §9.2 的链式续棒取证方式一致)。
- ⇒ 且**这条 AI 侧不能自救**(需要人在客户端把窗口切走再切回)⇒ 方案设计上要**给出"人可见的告警"**,而不是静默重试(⛔ 反复发消息只会在队尾再堆一条)。
#### 8.5.1 本机自查:我唯一一次真投递(13:55)**也没被消费**
| 取证 | 读数 |
|---|---|
| 13:55:11 `POST /sessions/6ecf6d98…/reply` | HTTP **200** `{"data":{"delivered":true}}` |
| `GET /api/v1/sessions/6ecf6d98…/history` 里搜标记「本机网关唤醒测试」 | **0 命中** |
| `GET /api/v1/jobs/resumable` 里本会话 `lastUserMessage`(17:2x) | `意思就是走不通对吗`(**用户 17:22 的原话**,注入那条**从未成为用户消息**) |
| 时间跨度 | **4.5 小时**(其间有多次空闲窗口)仍未出现 |
⇒ 🔴 **结论:「程序投递 ⇒ 会话被唤醒」这条,目前是零成功实例**;`delivered:true` 只证明了「入队」。
⚠️ **诚实标注**:单次样本,且期间用户插了一条真消息,**不能严格排除"被后续消息覆盖/顶掉"** ⇒ 要坐实**必须重跑一次干净测试**(见下)。
#### 8.5.2 与之对照:**宿主自己产生的唤醒,实测 6+ 次全成功**
后台任务的 `<task-notification>`(§3 结论 1 / §9.2 链式续棒)**不需要 waiter** —— 它是**宿主自身**投给该会话的,实测 A/B/C/D/E1/E2 + 两棒链全部唤醒成功。
⇒ 🔴 **两半要分开看**:
| 半 | 状态 |
|---|---|
| **宿主 → 自己的会话**(后台任务完成通知) | ✅ **走通**(6+ 次实测) |
| **外部程序 → 任意会话**(`reply` / `jobs reply`) | ⛔ **按现有证据走不通**(零成功实例;被 `hasWaiter=false` 卡住) |
**分水岭** = **目标会话有没有"消费者(waiter)"**,即**客户端有没有把它挂在当前窗口**。人在用 / 刚切到它 ⇒ 有;没人挂着它 ⇒ 消息永远躺在 `parkInQueue`。
#### 8.5.3 一次就能定性的验证(需用户在客户端配合,约 1 分钟)
1. 用户在客户端把目标会话**切走再切回**(或关掉再打开);
2. 立刻投一条**带时间戳**的测试消息;
3. **出现** ⇒ 坐实「挂载即可消费」,这道门打开;
4. **仍不出现** ⇒ 坐实「本机外部投递这条路不通」⇒ **转向已通的那半**(后台任务完成通知只唤醒它自己的会话;跨会话需求另议)。