- 变更规模:新增 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/ 知识文件,按口径入库)
18 KiB
唤醒机制 · 实现方案对比与选型(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 ⚠️ 前提与代价(必须一起接受)
- 只能唤醒"桌面当前打开的那一个会话" ——
reply只认 live;非 live 只能走 ACP 借用,而借用会把用户正在看的对话切走(红线 T2 ⇒ 禁止)。跨会话唤醒实质上不可做,需求要按这个收窄。 - 绑会话/宿主:宿主重启后就没了 ⇒ 需要一台"重启后自动拉起"的机制(钩子
SessionStart能否拉起常驻的子进程:未验证,列为待测)。 - T1 字面冲突:T1 写的是"⛔ 垫片/桥不得放进会话后台任务"。D4 属于"常驻判定器放进会话后台任务" ⇒ 文字上撞,但 T1 的本意(别让输出回流拖死宿主)靠"完全静默"已满足 ⇒ 采纳前需用户明确覆盖。
- 必须能一键关停(T7):停掉进程即彻底停 —— 因为不投递就不会有任何反应(天然的 fail-safe 方向)。
- 每轮唤醒的 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 ⚠️ 三个新踩到的坑(已登记,⛔ 别再犯)
- 🔴
/jobs/resume不校验sessionId—— 我拿一个全 0 的假 id 探契约,它照样起了一个真 worker 进程(kind:"background"、pid=44444)。 ⇒ ① 这不是零副作用探针,⛔ 别当只读接口试;② 调用方必须自己先校验 id 有效。 ✅ 已清理:POST /jobs/00000000/stop→{"stopped":true};复核jobs=[],两个 pid 均已退出。 - ⚠️
/jobs按 id 前缀聚合:两个 worker 同属一个 sessionId ⇒ 列表里只出现 1 条 ⇒ stop 完必须GET /jobs复核,别以为停一次就干净。 - ⚠️
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)返回 200delivered: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 分钟)
- 用户在客户端把目标会话切走再切回(或关掉再打开);
- 立刻投一条带时间戳的测试消息;
- 出现 ⇒ 坐实「挂载即可消费」,这道门打开;
- 仍不出现 ⇒ 坐实「本机外部投递这条路不通」⇒ 转向已通的那半(后台任务完成通知只唤醒它自己的会话;跨会话需求另议)。