- 变更规模:新增 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 19:4x)
线:机制线 · 唤醒机制探索 | 域:
ai1net-dsh-server/| 会话:a202550c(19:33 起的接续会话) 上游:交付物/唤醒-idle钩子坐实-20260930.md(本棒执行其 §9.1 留下的决定性一问) 配图:交付物/唤醒-无人值守可行性-20260930-图.html(泳道图 + 事故链图,SVG 版)
0. 判定(先给答案)
🔴 不能。 「把已经退出的那个会话从外部叫起来续跑」——做不到,不是"没做好",是结构上没有这条路。
依据一句话:会话的网关口就长在那个 worker 进程里(/api/v1/health 回的 pid 就是 worker 的 pid)⇒
worker 一退出,端口跟着消失;而唯一的外部投递入口 POST /api/v1/runs 根本不带"选哪个会话"的能力(它的 id 只当消息 id/conversation.id,路由先取该宿主自己的 primarySession)。
但"无人值守"本身仍然做得到 —— 只是它的形态不是"叫醒旧会话",而是"无人值守地新起一个人/一个新会话",也就是今天已经在跑的东西:自动化派棒。代价是粒度最细 1 小时(FREQ 硬白名单)。
⇒ 结论对上一棒 §7「闭环方案③(idle 钩子 → runs)」的影响:钩子只能投给"此刻活着的"会话,而 worker 存活时长实测中位数约 4–5 分钟、最短 28 秒、且宿主不保证复活⇒ 该闭环不具备无人值守所需的稳定性。
1. 三个小问,逐条作答
| # | 问题 | 答案 | 判据级别 |
|---|---|---|---|
| 1 | worker 退出后,该会话的网关口还在不在? | 不在。端口随进程消失。 实测本机只剩 3 个网关,与"此刻正在跑的会话"一一对应;706d2476 / 12732a10 / ac8de40d 在任何网关上都不是 live,它们的 CLI 进程也不存在了 |
实测+源码 |
| 2 | 对 worker 已退出的会话 POST /api/v1/runs({id,type:"message",text})会怎样? |
既不是排队、也不是把它拉起来 —— 是"被接受,然后落到投递方自己那个会话"。实测 id=706d2476 的请求 → HTTP 202 {runId,status:"accepted"}、GET /runs/{runId} = active:true,但 /status.activeSessionId 全程=本网关自己的会话;706d2476 的 transcript 一字未变(230 行 / md5 7c404bfd… / mtime 19:09:15.929,与投递前完全一致) |
端到端 |
| 3 | 是不是只剩"POST /api/v1/process/start"或"重新起一个会话"? |
process/start 不是这条路的解(它是"在网关宿主里起一个任意进程",对齐 E2B Process.Start,与"启动/唤醒某个会话"无关;GET 打它 → 404 ⇒ 路由确实是 POST)。唯一出路 = 新起一个会话 —— 且这已经是现状:自动化是唯一能开新会话的外部通道(钩子开不了会话) |
实测+源码 |
2. 端到端证据链(本棒实测,19:40–19:45)
| # | 证据 | 读数 |
|---|---|---|
| 1 | 投递前基线 | 706d2476…jsonl:230 行,md5 7c404bfd6c297c2bfe350f4b1554431e,mtime 19:09:15.929(冻结) |
| 2 | POST /api/v1/runs(body 的 id = 706d2476-93fb-439a-9742-22e009915331) |
HTTP 202 {"data":{"runId":"c2f4941d-3a0e-42bf-88af-d84790c85ffa","status":"accepted"}} |
| 3 | GET /api/v1/runs/c2f4941d-… |
{"runId":"c2f4941d-…","active":true} ⇒ 任务确实进了该宿主的任务表(不是被拒) |
| 4 | 投递后 /sessions/live 与 /status |
sessionId / activeSessionId 仍是 a202550c(本会话),没有切到 706d2476 |
| 5 | 投递后基线复测(4 分钟后) | 706d2476…jsonl:仍是 230 行,md5 仍 7c404bfd…,mtime 仍 19:09:15.929 ⇒ 目标会话零反应 |
| 6 | 网关集合复测(19:44) | 3 个网关::55283→11a263a2(忙)、:64288→a202550c(本会话,忙)、:53480 pid=59080 → live=(none)。⚠️ 6ecf6d98 昨天还占着 :54800,19:35:42 其 worker 再死,现在它没有网关了 —— 现场就抓到了 worker 走 ⇒ 网关走 |
🔴 证据 6 的那个 live=(none) 网关是白送的一条硬证据:它证明"宿主可以活着、但没有任何会话"。
按源码(§3 第 2 条),此时 getOrCreateSession() 不命中 primarySession ⇒ 会走 sessionManager.create() 新建会话。
⇒ 这就是"退路=新起一个会话"的机制面貌。⚠️ 本棒有意没有对它投递(它是池里的条目,投进去等于劫持一个待分配宿主、还会平白多出一个会话),只作机制判据记录。
3. 源码判据(为什么做不到)
| # | 事实(源码位置均为 app.asar → cli/dist/codebuddy-headless.js) |
含义 |
|---|---|---|
| 1 | 网关路由与 worker 同体:api/v1/{sessions, sessions/live, sessions/:id/reply, sessions/:id/{history,replay}, runs, runs/:runId, status, health, info} 全部注册在同一个 CLI 进程内;/api/v1/health 回的 pid = 该 worker 的 pid(实测 81372 = 本会话的 prewarm 条目 pid) |
worker 退出 ⇒ 端口关闭 ⇒ 无入口可投 |
| 2 | runs 不选会话:createRun() → generic 适配器 parseInbound() 只校验 id 与 type,把 id 拿去做消息 id/conversation.id;随后 getOrCreateSession(conversation.id) 的第一句就是 if (this.primarySession) return this.primarySession |
请求体里写谁的 id 都不能把消息投给那个会话 |
| 3 | primarySession 怎么来的:网关起来时 sessionManager.sessionSubject.pipe(filter(L=>L!==undefined), take(1)).subscribe(L=>gatewayBridge.setPrimarySession(L)) —— 只取第一个非空值,一辈子不变 |
一个 worker 只服务它自己那一个会话;runs 实际语义 = "把消息发给这个宿主自己的会话" |
| 4 | replySession() 硬闸门:canDeliverFollowReply(live.id, target) = (live && live===target),不等则 409 SESSION_FOLLOW_NOT_LIVE |
与既有结论一致:reply 只认 live,且必须是它自己 |
| 5 | 宿主不自愈:CliPrewarmPool 的 child.on("exit") 里,status==="activated" 的分支只 this.log("warn", "activated prewarm entry exited unexpectedly …") —— 不补池、不重拉、不通知(replenish 只在 status!=="activated" 的 idle/spawning 分支里调) |
没人会因为"某个会话死了"而把它重新拉起来 |
| 6 | 复活动因在桌面端,不在 HTTP 面:api/v1 下没有任何"打开/切换/激活会话"的端点(会话控制器只有 list/live/across-projects/workspaces/pins/remove/rename/history/replay/reply);而 daemon app server 与主进程之间走 pipe(源码注释:"均为 pipe"),不是 TCP |
实测到"某会话被反复重新激活"(§4)确有其事,但那根线外部程序接不上 |
4. 泳道图(角色 × 阶段)
①会话被激活 ②回合进行中 ③回合结束 ④worker 自行退出 ⑤外部想叫醒它
──────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────
宿主主进程 │ tryAcquire→doActivate (不干预) (不干预) child.on("exit") ✗ 无入口
(CliPrewarmPool)│ +spawn 补池 ⇒ 只打一条 warn ⛔ 不补池/不重拉
│ ⛔ 不补池/不重拉 ⛔ 不通知任何东西
│
CLI worker │ 起进程·挂网关口 处理消息·跑工具 生成收尾 ◄── code=1 静默退出 ── 已不存在
(= 会话宿主) │ :PORT 随进程诞生 (addHistory 不停) addHistory ✔ stderr 空·非人为 kill ⇒ 端口也没了
│
网关(HTTP) │ /live = 本会话 writerOccupied=true (同左) ✗ 随进程消失 ✗ 连不上
│
外部程序 │ — — — — ┌──────────────┐
(想唤醒的一方) │ │ POST /runs │
│ │ → 202 接受 │
│ │ → 落到「它 │
│ │ 自己那个 │
│ │ 会话」 │
│ └──────┬───────┘
│ ▼
目标旧会话 │ ←── 唯一一次被激活 ── (跑完了它的回合) transcript 冻结 transcript 就此冻结 ▓▓ 零反应 ▓▓
│ (md5 一字未变)
红格:第 ④ 列(worker 退出)与第 ⑤ 列(外部叫醒)—— 前者把网关口带走,后者因此没有可投的地址; 即使对别的活着的网关投递,也只会落在它自己的会话上(右下角那格)。
5. 事故链图(这次"想唤醒旧会话"实际怎么断的 + 哪几道防线缺席)
19:00:19.720 宿主 tryAcquire + doActivate ⇒ 会话 706d2476 拿到 worker(pid=47188),网关口起来
│
▼
19:00–19:09 该会话跑完它的整轮工作(会话日志连续增长)
│
19:09:16.304 [SessionRuntimeConfigResolver] resolveConfig input {sessionId:706d2476…}
│ ▲ 宿主这边**确实**为它准备过一次运行配置(=有人在请求给它派活)
▼
19:09:16.570 ⛔ [CliPrewarmPool] activated prewarm entry exited unexpectedly
(pid=47188, sessionId=706d2476…, code=1, signal=none) | stderr tail: <empty>
│ ▲ 静默退出:无 stderr、非人为 kill ⇒ 那次"派活"的准备**就地蒸发**
▼
19:09:16.570→ 此后再无任何 706d2476 的 doActivate / resolveConfig
│ ⇒ 宿主**没有**把它重新拉起来(符合 §3 第 5 条:activated 退出不补不拉)
▼
19:40:56 本棒把 id=706d2476 的 POST /api/v1/runs 打到**另一个活着的网关**
│ ▲ 期望:把它叫起来
▼
19:40:56 回包 202 accepted、run 表 active=true …… 但 activeSessionId 仍是**投递方自己的会话**
│
▼
19:45:00 706d2476 的 transcript 230 行 / md5 7c404bfd… / mtime 19:09:15.929
▲ **一字未变** ⇒ 这一棒想做的事,结构上就做不成
哪几道防线缺席(关键)
| # | 缺席的防线 | 后果 |
|---|---|---|
| ① | "消息投给了谁"没有回执:runs 回 202 只说明"受理了",不回"最终落到哪个会话"。/status.activeSessionId 也不会因为这次投递而变 |
调用方完全无法从响应判断"它到底有没有投到我想要的那个会话" —— 这正是"以为唤醒了、其实投给自己"的来源 |
| ② | activated 条目意外退出=只写一条 warn(不补池、不重拉、不通知) | 会话的宿主没了,系统里没有任何一环会察觉,更不会自愈 |
| ③ | 网关没有"打开/激活会话"的端点;桌面端那根复活动因走的是 pipe,不是 TCP | 外部程序拿不到那条唯一有效的路 ⇒ "无人值守"只能退化成"新起会话" |
| ④ | worker 存活时长无下限保障(实测 28 s 起) | 任何依赖"这个会话此刻活着"的方案(含 idle 钩子)都建在一段随时会断的窗口上 |
6. worker 能活多久(回答上棒 §9.1 的"是周期性常态还是偶发")
⇒ 是常态,不是偶发。 activated prewarm entry exited unexpectedly 三天计数:
| 日期 | 次数 |
|---|---|
| 2026-09-28 | 40 |
| 2026-09-29 | 55 |
| 2026-09-30(今天) | 28(08:09 → 19:36 分布在整日) |
今日"激活 → 退出"配对(activate 时刻取自 doActivate,退出时刻取自 exit warn):
| 会话 | 激活 | worker pid | 退出 | 存活 |
|---|---|---|---|---|
706d2476 |
19:00:19.720 | 47188 | 19:09:16.570 | 8m57s(🔴 此后永未再激活) |
12732a10 |
19:09:59.762 | 63272 | 19:10:27.935 | 28 s |
ac8de40d |
19:24:22.463 | 85352 | 19:30:18.186 | 5m56s |
6ecf6d98 |
19:31:13.556 | 62308 | 19:35:42.381 | 4m29s |
6ecf6d98 |
19:36:10.229 | 82656 | 19:36:41.683 | 31 s |
a202550c(本会话) |
19:33:26.129 | 81372 | 仍活(>12 min) | — |
读法:
- 典型存活 ≈ 30 s ~ 6 min,多数落在 4–5 min;正在干活的会话可以活很久(本会话 >12 min)。
- ⇒ idle 钩子要的"连续空闲 ≥60 s",正好要落在 worker 生命的尾巴上。今天全天唯一一次成功(
18:55:00.210)就是这个窗口的一次幸运命中。 - ⚠️ 唯一一次"该活没活"的反例:
706d2476在 19:09:16.304 有过一次resolveConfig(有人在给它派活),0.27 s 后 worker 静默退出,那次派活就地丢失、且没有任何重试 ⇒ 这是"无人值守"最怕的失败形态:静默丢单。
7. 替代路径与代价("能做"的那一半)
| 路径 | 能做什么 | 代价 / 硬约束 |
|---|---|---|
| A 自动化派棒(现状,唯一在用) | 可以无人值守地让"下一棒"跑起来 —— 新起一个会话,不是续跑旧会话 | 粒度最细 1 小时(FREQ 硬白名单 HOURLY/DAILY/WEEKLY/MONTHLY/YEARLY);会话跑完即 completed,不保留为长期 main |
B idle 钩子 → runs(上一棒 §7 方案③) |
只能投给"此刻活着的"会话;钩子侧 0 token、0 会话 | 🔴 命中率受 §6 的窗口限制;被活的后台任务静默压制;且投递目标必须活着 ⇒ 不能当无人值守的主干,最多当"兜底加急" |
C 外部常驻 → runs |
同样只能投给活着的会话 | 要长期养一个常驻进程(本项目一向慎入:独立进程读不到宿主口令) |
| D 把某个会话"钉住"不让它退 | 若能让目标会话的 worker 不退出,A/B 才有稳定落点 | 目前没有外部手段能做到(复活动因在桌面端 pipe 上,外部接不上);且"钉住"本身需要改宿主行为 ⇒ 属改全局/改产品,须用户拍板 |
我选了什么(可推翻):以 A 为唯一主干(继续用"自动化派棒",本棒就是它叫起来的),B 降级为可选兜底、且不建任何新的常驻。 理由:A 的可靠性来自它不依赖任何"活着的会话";B/C/D 三条都建在同一段随时会断的窗口上,把它们当主干等于把整条链的稳定性押在 28 s–6 min 的运气上。
8. 未决 / 留给下一棒(本棒不做完)
code=1静默退出的根因未定 —— 已知:stderr tail: <empty>、signal=none、非人为 kill;未知:是"回合结束被主动回收"还是"崩溃"。这是唯一能改变上面判定的未知量:若它是"回合结束即回收",则 B 路线结构性不成立(不是概率问题);若它是崩溃且为周期性,则要另找稳定性讨论。建议下一棒只做一件事:拿6ecf6d98(今天退出最频的会话)在其一次退出前后取全套四件套 + 它的sdk/conversations/6ecf6d98….log,判定"退出是主动还是被动"。resolve_main前缀判据缺陷 A/B(旧账,机制层,须独占锁单独一棒)。stop-collab.py::_gw_port()会把20090(设备接入垫片)误认成网关(真网关是动态口)。⚠️ 本棒复测又踩到同一型:20090的根页同样带网关标记,但health(no-auth)=200⇒ 唯一分水岭仍是"不带凭据时 health 必须 401"。
9. 出处(按需读)
- 端到端脚本(本棒临时件):
tmp/_wake4_runs_e2e.py;网关枚举:.workbuddy/collab/wake-session.py --list - 目标会话基线:
E:/ProgramData/.workbuddy/projects/e-ProgramData-AIProject-ai1net-dsh-server/706d2476-93fb-439a-9742-22e009915331.jsonl - 宿主日志:
E:/ProgramData/.workbuddy/logs/2026-09-30/workbuddyMainThread__22be230a230dcc2f1d23d3d261812acb.log([CliPrewarmPool] doActivate/activated prewarm entry exited unexpectedly;今天 28 条) - 源码:
app.asar → cli/dist/codebuddy-headless.js(网关控制器 ≈@7.10M;createRun≈@6435743;GatewayAcpBridge.getOrCreateSession≈@6232k) +app.asar → main/cli-prewarm-pool.js(child.on("exit")≈@922 行·折行后) - 取证工具:
tmp/wb-phone/asar-peek.mjs(find/slice/extract)+tmp/wb-phone/reflow.mjs - 上游:
交付物/唤醒-idle钩子坐实-20260930.md(§3 机制全貌 / §6 修正 / §9.1)|接续包_唤醒机制探索_20260930.md