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

194 lines
13 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 12:0x–12:2x
- 输入口径:用户原话「**还是会发呆,上次唤醒 56分钟前**」「**处理完成后 创建接续会话继续 排查为什么唤醒断了**」
- 接续包:`交付物/接续包-查唤醒为什么断-20260930.md`
- 🔴 **只做了这一件**:查清定时闹钟为什么停
---
## 0. 一句话结论
**「唤醒」不是断了 —— 它从头到尾一次都没响过。**
三个任务(`bbaf9baf` 08:47 / `b08da6c4` 11:47 / `91f8baeb` 11:53)都是「**建了就等于没建**」:
网关 API 回 `200` + 一个 id,但任务**既没落盘、也没进内存**,任何东西都不会触发它。
**根因(源码级,两处独立证据)**:
1. WorkBuddy **桌面端**在给每个 CLI agent 进程拼环境时**硬编码** `CODEBUDDY_DISABLE_CRON: "1"`
(`app.asar → /main/code-cache.js`:`buildAgentCliRuntimeEnv()` 与 `buildCliEnvFromResolved()` **两处**)。
2. CLI 侧调度器 `CronSchedulerService.start(session)` 的**第一行**就是:
`if ("1" === process.env["CODEBUDDY_DISABLE_CRON"]) return;`
⇒ **调度器根本不启动** ⇒ 排进去的任务永远不会到点。
3. 并且 `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`):
```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. 把这条"隐形执行体"的路封掉 —— 建议直接做(低成本、防再次踩坑)
1. `gateway-schedules.json`:现役 `91f8baeb`(及 `_历史` 里两条)标注「cron 在桌面端被 env 关闭 ⇒ 从未触发」,
**现役栏清空**(它已是幽灵)。
2. 技能 `workbuddy-extension-surface` §1.4:补一句硬提示 —— 「桌面宿主硬关 cron ⇒ 该 API 在桌面端是死能力,
只能在 CLI `--serve` 且未设该 env 的场景下用」。(本棒已就地补上。)
3. 以后**不要再用网关 `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 才不被截断) |