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

190 lines
18 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 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. 未决 / 留给下一棒(本棒不做完)
1. **`code=1` 静默退出的根因未定** —— 已知:`stderr tail: <empty>`、`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`