# 唤醒机制 · 实现方案对比与选型(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+ 次全成功** 后台任务的 ``(§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. **仍不出现** ⇒ 坐实「本机外部投递这条路不通」⇒ **转向已通的那半**(后台任务完成通知只唤醒它自己的会话;跨会话需求另议)。