- 变更规模:新增 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/ 知识文件,按口径入库)
13 KiB
13 KiB
查「唤醒为什么断了」· 结论与读数
- 出具:接续会话(接主会话)· 2026-09-30 12:0x–12:2x
- 输入口径:用户原话「还是会发呆,上次唤醒 56分钟前」「处理完成后 创建接续会话继续 排查为什么唤醒断了」
- 接续包:
交付物/接续包-查唤醒为什么断-20260930.md - 🔴 只做了这一件:查清定时闹钟为什么停
0. 一句话结论
「唤醒」不是断了 —— 它从头到尾一次都没响过。
三个任务(bbaf9baf 08:47 / b08da6c4 11:47 / 91f8baeb 11:53)都是「建了就等于没建」:
网关 API 回 200 + 一个 id,但任务既没落盘、也没进内存,任何东西都不会触发它。
根因(源码级,两处独立证据):
- WorkBuddy 桌面端在给每个 CLI agent 进程拼环境时硬编码
CODEBUDDY_DISABLE_CRON: "1"(app.asar → /main/code-cache.js:buildAgentCliRuntimeEnv()与buildCliEnvFromResolved()两处)。 - CLI 侧调度器
CronSchedulerService.start(session)的第一行就是:if ("1" === process.env["CODEBUDDY_DISABLE_CRON"]) return;⇒ 调度器根本不启动 ⇒ 排进去的任务永远不会到点。 - 并且
start()同时是唯一调用setStorageDir()的地方 —— 被这行return跳过之后,durable分支写入undefined目录 ⇒ 静默空操作(下节)。
⇒ 结论:WorkBuddy 桌面端下,网关 scheduled-tasks 是一条「返回成功但什么都不发生」的死能力。
⇒ 因此「发呆 57 分钟」不是"闹钟坏了",而是这台机器上从来没有任何时间驱动的叫醒器。
1. 决定性读数(三条,全部实测)
| # | 要查的点 | 读数 | 判读 |
|---|---|---|---|
| 1 | 新任务会不会响(接续包第 1 步) | ls tmp/supervise-inbox/_wake.stamp → No such file or directory |
到 12:0x 为止,b08da6c4(11:47)与 v3 91f8baeb(11:53)一次都没来过 |
| 2 | 宿主是否关掉了 cron | 本机进程环境 CODEBUDDY_DISABLE_CRON = '1' |
见 §0 证据 1(宿主拼 env 时写死) |
| 3 | 三个网关口查任务 | 55283 / 56510 / 56975 全部 {"data":{"tasks":[]}} |
任务确实不在任何存储里(§2 解释为什么"空列表"这次不是假象) |
2. durable 的真相 —— 推翻接续包里的假设
CLI 源码 CronStateManagerService.createTask(codebuddy-headless.js):
async createTask(sessionId, cron, prompt, recurring, durable) {
const id = randomUUID().slice(0, 8);
const task = { id, cron, prompt, createdAt: Date.now(),
...(recurring ? {recurring:true} : {}),
...(durable ? {durable:true} : {}) };
if (durable) {
if (this.storageDir) { // 🔴 只有一个 if,**没有 else**
const list = await readTasks(this.storageDir);
list.push(task);
await writeTasks(list, this.storageDir);
}
} else {
this.addSessionTask(sessionId, task); // ← durable 走不到这里
}
this.setEnabled(true);
return id; // ⇒ 照样返回 id,照样 200
}
storageDir只在CronSchedulerService.start()里被赋值,而start()已被DISABLE_CRON提前return掉 ⇒storageDir === undefined⇒durable:true⇒ 两个分支都不进 ⇒ 返回 id,然后什么都没有。- ⚠️ 接续包事实 3「
durable的永远不显示在列表里」不准确:源码getAllTasks()明确会[...会话内存任务, ...(storageDir ? 读文件 : []) 并标 permanent:true]。 正确说法是:列表空 ⇒ 文件里真没有(因为 durable 分支压根没写)。 这反而是佐证:.codebuddy/scheduled_tasks.json全盘不存在(find无命中,.codebuddy/下只有rules/)。 - 🔴 另发现:
durable:false(默认值)是纯内存、且按进程隔离 —— 会话结束即消失。
实测复现(12:04,用完即删)
| 动作 | 结果 | 说明 |
|---|---|---|
POST non-durable,cron="0 3 1 1 *"(1月1日,今天绝不可能触发) |
200 + id;随后 GET 立刻看得到 |
控制器本身正常 |
| 在 55283 / 56975 / 56510 各建一条 | 每个口只看到自己建的那条 | ⇒ 任务状态是进程内的,投到哪个口只在那个进程有效 |
POST durable:true(55283) |
200 + id 95087b61;GET 看不到;.codebuddy/scheduled_tasks.json 仍不存在 |
🔴 静默空操作,与 bbaf9baf/b08da6c4/91f8baeb 完全同型 |
DELETE 三条 non-durable |
204,列表回 [] |
已清理干净 |
3. 顺带纠正的两处旧读数(会误导后续判断)
| 旧读数 | 实际 | 影响 |
|---|---|---|
| 登记册写「08:53 端口已变成 28317」 | 28317 不是网关 —— 它 /api/v1/health 返回 {"ret":1,"version":"3"},是别家的服务。真网关=56975(pid 50716),uptime≈41,850s(≈11.6 h)从未重启 |
那条旧读数会让人以为"网关重启导致任务丢",是假线索;真正的死因是 env(§0) |
「_gw_port() 扫到的口就是主会话的口」 |
本机有 3 个都回 AUTH_REQUIRED 的网关口:55283(pid 56072) / 56510(pid 54364) / 56975(pid 50716);_gw_port() 升序取第一个命中 ⇒ 实际命中 55283(一个 --prewarm 池进程),不是托管主会话的那个进程 |
⛔ 即便将来 cron 能用,往"扫到的第一个口"投任务也是错的 |
4. 泳道图 —— 问题落在哪一格
阶段 用户/主会话 网关 API 进程 CLI 调度器 磁盘
────────────────────────────────────────────────────────────────────────────────────────
① 建设 │ POST scheduled- │ 路由命中,校验通过 │ │
│ tasks(durable=1) │ createTask() │ │
│ │ storageDir === undef │ │
│ ← 200 {id:...} ✅ │ ⇒ 什么也没写 🔴 │ │ 无文件 🔴
────────────────────────────────────────────────────────────────────────────────────────
② 到点 │ (发呆 57 分钟) │ (无关) │ start() 第一行 │
│ │ │ DISABLE_CRON=1 ⇒ │
│ │ │ ⛔ return,未启动 │
│ ← 没有心跳 🔴 │ │ ⇒ 从不触发 🔴 │
────────────────────────────────────────────────────────────────────────────────────────
③ 排查 │ GET scheduled- │ getAllTasks() │ │ 读文件
│ tasks?sessionId= │ → 内存[] + 文件[] │ │ → 空
│ ← {"tasks":[]} ❌ │ ⇒ 回空列表 🟡 │ │ ⇒ 看着像"消失了"
────────────────────────────────────────────────────────────────────────────────────────
🔴 红格①:写入被静默吞掉(durable 无 storageDir)
🔴 红格②:调度器被 env 提前 return
🟡 黄格③:读接口回空 ⇒ 与"任务丢了"无法区分
5. 事故链图 —— 这次实际怎么坏的一步步
T1 08:47 主会话 POST 建 bbaf9baf(*/5, durable:true)
└─ 命中 55283(prewarm 池进程,非主会话宿主)
└─ storageDir=undefined ⇒ 不落盘、不进内存 🔴 ← 真正的失事点,此刻已经完蛋
T2 08:47→10:44 期间主会话被 --tick(钩子事件驱动)偶尔叫醒 ⇒ 误以为"闹钟在响"
T3 10:44 --supervise 孤儿进程最后一次投递后死亡 ⇒ 唯一还在响的东西没了
T4 10:44→11:42 主会话闲置 57 分钟,一条心跳都没有 ← 用户看到「上次唤醒 56 分钟前」
T5 11:43 排查:GET /scheduled-tasks → tasks:[] ⇒ 误判为"任务被网关弄丢了"
T6 11:47 重建 b08da6c4(同样 durable:true)⇒ 同样静默空操作 🔴 白建
T7 11:53 再建 v3 91f8baeb ⇒ 同样 🔴 白建
T8 12:0x 本棒:直接读源码 + 本机进程 env ⇒ 定位到 DISABLE_CRON 硬编码
本该拦住却缺席的三道防线:
① 缺"建完立刻回读" —— POST 后没有 GET 一次核实;有的话 T1 当场就暴露(列表本来就该能看到)
② 缺"到点自证" —— 11:47 重建时没先做一次 2 分钟后的最小实验,而是又建了一条 5 分钟循环件
③ 缺"成败判据" —— 把"_gw_port 扫到第一个口"当成"主会话的口",从未验证过投递目标
6. 「能」与「不能」—— 下一步建议(⛔ 我没动手,只报)
A. 5 分钟级时间驱动唤醒 —— 当前支持面里做不到
| 通道 | 能不能 5 分钟 | 为什么 |
|---|---|---|
网关 scheduled-tasks |
❌ 设计上关闭 | 桌面端硬编码 CODEBUDDY_DISABLE_CRON=1(本报告结论) |
| WorkBuddy 自动化 | ❌ 最小 1 小时 | rrule 只支持 HOURLY / DAILY / WEEKLY / MONTHLY / YEARLY(且 HOURLY 的 INTERVAL≥1) |
钩子(collabd --tick) |
❌ 闲时必不响 | 事件驱动:只有"有会话在跑工具调用"时才有事件 |
| 常驻进程 | ❌ 拿不到口令 | 不在 WorkBuddy 进程树内 ⇒ 读不到网关口令 ⇒ 只能做只读判定/告警,不能派活/投递 |
B. 1 小时级唤醒 —— 可行,且是官方路径(推荐先报用户拍板)
做法:建一条自动化(recurring,每小时),prompt 只做两步:
① 写 tmp/supervise-inbox/_wake.stamp(让看板可见)
② 用网关 POST /api/v1/sessions/fe146dd9-f5d1-40a2-80e8-113618dc0f63/reply { "text": "…" } 把主会话叫醒
(自动化进程在宿主进程树内 ⇒ 有 CODEBUDDY_GATEWAY_PASSWORD;本棒已实测该变量存在,长度 43)
- 优点:官方通道、不碰网关 cron、能自动跟随(写死 sessionId 即可)
- 缺点:最小粒度 1 小时(用户的"5 分钟"满足不了);每小时都会烧一次 token;与用户既有口径 「又给我整到自动任务去了」直接冲突 ⇒ 必须用户拍板,不许自行创建
C. 把这条"隐形执行体"的路封掉 —— 建议直接做(低成本、防再次踩坑)
gateway-schedules.json:现役91f8baeb(及_历史里两条)标注「cron 在桌面端被 env 关闭 ⇒ 从未触发」, 现役栏清空(它已是幽灵)。- 技能
workbuddy-extension-surface§1.4:补一句硬提示 —— 「桌面宿主硬关 cron ⇒ 该 API 在桌面端是死能力, 只能在 CLI--serve且未设该 env 的场景下用」。(本棒已就地补上。) - 以后不要再用网关
scheduled-tasks当定时器;要时间驱动就走 §B(自动化)或承认做不到(§A)。
7. 边界遵守情况(逐条)
| 边界 | 是否守住 |
|---|---|
⛔ 不动看板 127.0.0.1:8788 |
✅ 未触碰 |
⛔ 不动中继客户端 DSH-Overlay-Node-Dev |
✅ 未触碰 |
| ⛔ 口令不落盘 / 不进日志 / 不回显 | ✅ 只从 env 读、只放请求头;全部输出均已确认无口令 |
| ⛔ 不改 A 方案口令入站通道 | ✅ 未改 |
| ⚠️ 重启网关/宿主先问用户 | ✅ 未重启(未做任何重启动作) |
| 探测用任务用完即删 | ✅ 3 条 non-durable 已 DELETE → 204,残留列表均回 [];1 条 durable 本就是空操作 |
| 锁 | ✅ --claim-exec 接续-查唤醒断因-20260930 --domains ai1net-dsh-server/,收尾已释放 |
8. 关键文件 / 命令索引
| 用途 | 位置 |
|---|---|
| 本报告 | 交付物/查唤醒为什么断-结论-20260930.md |
| 要盯的触发戳(它不存在 = cron 从没响) | tmp/supervise-inbox/_wake.stamp |
| 隐形执行体登记册(3 条全部作废) | .workbuddy/collab/gateway-schedules.json |
| 探测脚本(可重跑,安全:cron 指向 1月1日) | tmp/_wake_probe.py(探)· tmp/_cleanup.py(清) |
| 复现源码证据(已按收尾规矩删掉抽取件,重取只需两条命令) | ① CLI:node tmp/wb-phone/asar-peek.mjs find "DISABLE_CRON" 2 "codebuddy-headless";slice "codebuddy-headless" 3813542 26000 / 4828300 14000 / 6587000 9000 ② 宿主:slice "main/code-cache.js" 3366800 3600 与 3459800 3600(命中 buildAgentCliRuntimeEnv / buildCliEnvFromResolved) |
| 源码读取工具 | tmp/wb-phone/asar-peek.mjs(按需读 asar,不整包解压)+ reflow.mjs(折行后再 Read 才不被截断) |