# 唤醒 · 无人值守可行性(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: │ ▲ 静默退出:无 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. 未决 / 留给下一棒(本棒不做完) 1. **`code=1` 静默退出的根因未定** —— 已知:`stderr tail: `、`signal=none`、非人为 kill;未知:是"回合结束被主动回收"还是"崩溃"。**这是唯一能改变上面判定的未知量**:若它是"回合结束即回收",则 **B 路线结构性不成立**(不是概率问题);若它是崩溃且**为周期性**,则要另找稳定性讨论。建议下一棒只做一件事:拿 `6ecf6d98`(今天退出最频的会话)在其一次退出前后取全套四件套 + 它的 `sdk/conversations/6ecf6d98….log`,判定"退出是主动还是被动"。 2. `resolve_main` 前缀判据缺陷 A/B(旧账,机制层,须独占锁单独一棒)。 3. `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`