Files
workbuddy_skills/session-mechanism/references/supervise-persistence.md
T

469 lines
34 KiB
Markdown
Raw Normal View History

# 常驻程序长期在线(Windows)
> ✅ **2026-10-06 已按收口后的形态重写完毕**(本档头部原先挂的「📌 整份重写待办」已完成,过期警告块已撤)。
>
> 🔴🔴 **现行形态(照这个做,⛔ 别自己发明)**:
> **计划任务 `collabd-keepalive-<区>`(每 5 分钟触发)→ `pythonw.exe` → `supervise-launch.py` → `collabd.py --supervise`**
>
> ⛔ 现行形态里**没有 `.ps1`**,也**没有"守护循环脚本"** —— 复活由「计划任务每 5 分钟重触发」
> +「常驻本体幂等(发现已有活常驻就 `exit 0`)」两件一起承担。
> 📌 权威同源:`SKILL.md`「常驻机制的真实形态」+ `pitfalls.md` **P0-73**(启动器为什么必须有)
> /**P0-74**("两条起法"收口)。
> 📌 旧 `.ps1` 形态(2026-10-03 ~ 10-05)的完整留痕在**第四、五节**,⛔ **只当历史读,别照着做**。
>
> 📌 **本文档怎么读**:
> · 只想**把技能用起来** ⇒ 读到「〇」就够了(**常态用不着计划任务**)。
> · 明确要"**目标做完了程序还得继续跑 / 崩了自己爬起来**" ⇒ 从「一」往下照着做。
> · 只想**查历史**("以前那套 ps1 是怎么回事")⇒ 直接跳**第四节**。
> 📌 **本档来源**(2026-10-03 夜里整理,2026-10-06 重写)。
> 证据源=测试3 的复盘 `目标-会话协作测试3-7cd276/S3_常驻启动成功复盘_20261003.md`
> + `S4_常驻自愈闭环复盘_20261003.md` + `S5_A6自指修复与目标收口_20261004.md`
> + 2026-10-05/06 的 P0-72/P0-73/P0-74 实测。
## 〇、🔴🔴 先分场景:两种"长期"不是一回事
**两种都要长期在线,但它们要的东西不同** —— ⛔ 别混用,混用就会出现
"看起来在跑、其实随时会死"或"该停时还在跑"。
| | **场景 A · 用户手动创建主会话** | **场景 B · 定时任务创建主会话** |
|---|---|---|
| 典型形态 | 用户在 WorkBuddy 里自己开一条 `[主]-…` 会话,盯着它推进 | 排期按点拉起 `[主]-…`(`recurring`,如 `FREQ=HOURLY;INTERVAL=1`) |
| 会话寿命 | **随用户何时收工**(可能几分钟,也可能几小时) | 跑完即 `completed`,**与下一跳之间有静默窗** |
| 🔴 **对"常驻"的硬要求** | ⛔ **不能靠会话活着** —— 一收工就没人拉 ⇒ **必须脱离会话** | ✅ 会话自己周期性回来 ⇒ 但**每跳之间仍有空窗** ⇒ **同样要脱离会话** |
| 唯一合格载体 | **计划任务**(第一~三节) | 同左(⛔ **排期代替不了载体** —— 排期只是"谁按点喊一次",喊完会话就结束) |
| 怎么验它真活着 | 心跳 `pid` 活 ∧ `ts` 距今 < 90 s + `round` 递增 | 同左,⚠️ **且要静置 ≥ 12 分钟**(⛔ 短观察证明不了事) |
| 谁负责"目标完成后关它" | 常驻主循环见 `guard.stop` 即收工(第七节) | 同左 —— ⛔ **排期不会帮你关** |
### 🔴 常态口径(2026-10-04 用户定案,当场订正)
> 用户原话:「**从来没说过什么开机自启,只有调用技能完成目标时启动 后台任务和检查程序**」。
⇒ **常态是:调用技能完成目标时,起后台任务 + 检查程序。** 这套一直好好在跑。
⇒ ⛔ **不许**把"没装计划任务"当"欠项 / 待办 / 风险"往用户面前摊(2026-10-03 夜里就是这么摊错的)。
> 🟠 **本档 2026-10-04 曾据此写过一句「计划任务 / 载体脚本不是需求,是本文档自己推演出来的可选项」**
> —— **这句已被 2026-10-05 用户拍板推翻**。
> 用户原话:「**常驻崩了,拉起来这个事儿,你不能也用计划任务起个程序吗?**」
> ⇒ 计划任务现在是**既定形态**(每 5 分钟兜底),开关是 `supervise.switch`(`collabctl.py on|off`)。
> ⚠️ 两句话不矛盾:**日常不必操心**(机制自己装),但它**是既定形态**,⛔ 不是"可有可无的选项"。
### 🔴 两个都踩过的坑(**与场景无关**,因为根子在载体)
1. **"一次性工具调用起的子进程"在两种场景下都不合格** ——
实测测试3 开工时 pid 69080 已死;本区两个后台任务(`collabd --supervise` 活 842 分钟、
`board.py --serve` 活 634 分钟)父链都是
`bash → bash → bash → sandbox-cli → WorkBuddy.exe`(**会话树内**),
⛔ `.workbuddy/collab/` 里**既无载体脚本也无 keeper** ⇒ **没走本档这套**。
⚠️ **它们当时活着,只因为发起它们的那条会话进程树至今没被回收**
⇒ 「**存活时长 = 发起会话的存活时长**」(测试3 `S4` 第六节原话),
**⛔ 不能用它论证"这种起法也能长期"**(该文明确写「这条『反例』**不成立**」)。
2. **"排期是 `once` 且已过期"=哑排期** —— 实测 `[主]-会话协作测试3-主会话` 是
`schedule_type=once`/`scheduled_at=2026-10-03T20:33`/`next_run_at=None`/
`last_run_at=None` ⇒ **它一次都没被触发过**,而界面上 `status=ACTIVE` **看起来像在跑**。
⚠️ **`status=ACTIVE` ≠ "在跑"**;判"真会触发"要看 `schedule_type='recurring'` ∧ `next_run_at` 非空。
## 一、🔴 现行载体:`pythonw.exe` + 启动器(⛔ 别再自己发明)
> 🔴 **「载体」= 那个"负责把常驻程序拉起来、并盯着它别死"的东西。**
> 说白了就一件事:**谁来把它叫起来。**
> 现行答案=**计划任务**;它**每 5 分钟**触发一次,动作是
> `pythonw.exe "<区>/.workbuddy/collab/supervise-launch.py"`。
### 1.1 为什么"计划任务"是唯一合格答案(判据=父链根在谁)
| 启动方式 | 能不能长期 | 实证 |
|---|---|---|
| 工具调用起的子进程 | ❌ | 父进程一退出就被回收(活不过当轮/当会话) |
| 宿主排期拉的会话起 | ❌ | 测试3 开工时 pid 69080 已死(心跳停在 22:55:31) |
| `CREATE_BREAKAWAY_FROM_JOB` | ❌ | **`PermissionError(13,'拒绝访问。')`** —— 本机被作业对象拒绝,**这条路封死** |
| `cmd /c start /B` | ❌ | 本机沙箱拦截 |
| 排期到点起一次会话 | ❌ | **跑完即退** ⇒ 静默窗内无人(`once` 更糟:可能**永不触发**) |
| **计划任务 + 启动器** | ✅ | 进程根在 `svchost -s Schedule`(任务计划服务)⇒ 与会话树无关;实测 23:16:37 起活到 23:29 仍在跑,`round` 1→74;死亡后 **5–6 秒**自动恢复 |
⚠️ **本表判据是"父链根在谁",⛔ 不是 `in_job` 标志**:本机**所有**被起的进程 `in_job` 都是 `Y`,
含计划任务起的那个 ⇒ **标志位判不出死活**。
**关键差别=父链根在谁**:计划任务创建的进程**根在 `svchost -s Schedule`** ⇒ 与 WorkBuddy
会话树**无关** ⇒ 不会被会话回收。
> 🔴🔴 **2026-10-04 深夜实测更正:⛔ 别再用 `IsProcessInJob` 判死活 —— 本机它对所有进程都返回 `Y`。**
>
> 起因:vibe 会话用 `proc_chain.py` 看到常驻 `in_job=True` ⇒ 判"它在会话的作业对象里、所以会被回收"。
> **结论(「会话树内的会死」)是对的,但引用的判据是错的** —— 三组对照:
>
> | 进程 | `in_job` | 实际命运 |
> |---|---|---|
> | 工具调用直接起(`DETACHED_PROCESS`) | **Y** | 会话边界即死 |
> | **计划任务起**(`svchost→cmd→pythonw`) | **Y** | ✅ **长期存活**(看板实测 634+ 分钟) |
> | 零创建 flags(仅 `STARTUPINFO`) | **Y** | 死 |
> | `svchost -s Schedule`(服务本体) | N | — |
> | 系统进程(System / explorer) | N | — |
>
> ⇒ 🔴 **`in_job=Y` 在本机是"全局容器",⛔ 不区分"会话专属"与"服务创建"** ⇒ **标志位判不出死活**。
> ⭐ 这与 `workbuddy-resident-service/scripts/job_lifetime_probe.py` 开头那句自述完全一致:
> 「**让行为说话,而不是只看 `IsProcessInJob` 的标志位**」。
>
> ✅ **正解判据=看父链里有没有 `svchost -s Schedule`**:
> 有 ⇒ 服务创建 ⇒ 长活;根在会话树(`WorkBuddy.exe`/`bash`/已退出的会话进程)⇒ 会话收回时一起死。
> 实测:看板 62992 父链 `pythonw→cmd.exe→svchost(3924)→services.exe→wininit.exe` ⇒ 活;
> 常驻 63628 父链 `pythonw→71800(已退出)→父已退出` ⇒ 会话回收即死。
> 🔴🔴 **2026-10-04 再补一条(判据已落 `t_supervise_lifespan_not_invented`)**
> ⛔ **别把「现在活着」当成"这种起法也能长期"**。实测:pid 19424 从 10-03 17:05 活到 10-04 17:15
> = **24.2 h / 8695 轮**,而它的**父进程早已退出** ⇒ 它是**孤儿进程**,孤儿化后**不受会话结束影响**。
> ⇒ ⚠️ **「会话/工具调用起的活一定活不过当轮」是错的说法**:能不能活取决于**父链还在不在**,
> ⛔ **不许凭这个推断编造后果**。要判"还能活多久"=**先验父链 + 读心跳 `started_ts`**。
### 1.2 🔴🔴 为什么必须夹一个"启动器"(2026-10-05 血的教训 · P0-73)
**计划任务的"动作"里没有 env 字段** ⇒ 一切环境变量**只能由启动器在进程内设**。
⇒ 动作**⛔ 不许直起 `collabd.py`**,必须经 `<区>/.workbuddy/collab/supervise-launch.py`。
直起的后果(实测踩到):任务环境**没有 `CODEBUDDY_CONFIG_DIR`** ⇒ `_wb_db()` 落到
`~/.workbuddy/workbuddy.db`(**0 字节空库**)⇒ `_all_sessions_idle()` 每轮报 **`no such table: sessions`**
⇒ 闸② 恒判"有会话在跑" ⇒ **检查会话再也不建**(实测日志 36 次读库失败)。
⚠️ 为什么有的区没炸:那区 `collabd.config.json` 里 `host_db` **写死了绝对路径**(兜住了)。
**两个启动器对称存在,缺一个就静默半瘫**(`scripts/` 下,由 `deploy_code.py` 分发到各区):
| 启动器 | 服务的程序 | 进程内必须设 | ⛔ 缺了会怎样 |
|---|---|---|---|
| `supervise-launch.py` | 协作程序(`collabd.py --supervise`) | **`CODEBUDDY_CONFIG_DIR`** + `COLLABD_CONFIG` + `PYTHONIOENCODING` + `PYTHONUNBUFFERED` + 兜 `stdout/stderr` | **检查程序静默失效**(读 0 字节空库 ⇒ `no such table: sessions` ⇒ fail-safe 恒判"有会话"⇒ 再也不建检查会话) |
| `board-launch.py` | 看板(`board.py --serve 20099`) | `COLLABD_CONFIG` + `PYTHONIOENCODING` + 兜 `stdout/stderr` | **看板崩溃重启循环**(`已拒跑`)、**20099 从没绑上** |
**`supervise-launch.py` 的四条实现要点**(见该文件头注,⛔ 别自己另写一份):
1. **它所在目录即本区 `.workbuddy/collab`** —— `collabd.py`/`collabd.config.json` 都在同目录,**用相对自身定位**(⛔ 别写死 `E:\`);
2. `CODEBUDDY_CONFIG_DIR` 用 `setdefault`(宿主 env 优先,兜底才写死);`COLLABD_CONFIG` **必须指向本区**那份配置;
3. `pythonw.exe` 下 `sys.stdout/stderr` 可能是 `None` ⇒ **任何 `print` 都会 `AttributeError` 秒退** ⇒ 启动器自己兜到 `logs/_supervise-launch.log`;
4. 用 `runpy.run_path(CB, run_name="__main__")`(⛔ **不用 `exec`**)—— `runpy` 会把 `__name__`/`__file__`/`sys.path[0]` 按"真在跑那个脚本"设好;
且 `sys.argv` **由启动器自己补 `--supervise`** ⇒ **任务参数里 ⛔ 不要再写**。
> 🔴 **重构"起法"时,旧起法里的 env 必须逐条搬**(P0-73 就是改成两层形态时**丢了 `CODEBUDDY_CONFIG_DIR` 这一句**)。
> 🔴 **启动器里写死最稳**(⛔ 别指望 `roots.env` 兜住各区 —— 它只在技能目录,而各区副本在
> `<ws>/.workbuddy/collab/`,`_sm_load_roots()` 向上 4 层**找不到**)。
> ⚠️ **变量名坑**:`roots.env` 给的是 **`COLLABD_PROD_CONFIG`**,而 `board.py`/`collabd.py` 读的是
> **`COLLABD_CONFIG`** ⇒ 名字对不上 ⇒ `roots.env` **兜不住**。
## 二、装法(⛔ 别自己写 `Register-ScheduledTask`)
### 2.1 🔴 建任务**只许有一处实现**
| 处 | 谁 | 说明 |
|---|---|---|
| ✅ **唯一正门** | `scripts/collabctl.py::ensure_keepalive_tasks()` | 受 `supervise.switch` **总电闸**管辖;`on` / `ensure` 都走它 |
| ✅ 自愈旁路(**同形态、同任务名**) | `scripts/collabd.py::_escalate_to_keeper()` | 常驻发现本区没任务时自己补一条 |
| ⛔ 已删 | `scripts/init_workspace.py::_register_keeper_task()` | 2026-10-06 整份去掉(含 `--with-keeper`/`--no-task` 开关与 `_ps_check()`)—— 建任务**只许有一处实现**,自己建就绕过了总电闸 |
⚠️ **两处必须同形态同名** —— 否则自愈起的常驻会被下一次 `off` 当"旧形态"顺手干掉(2026-10-06 实测踩到)。
### 2.2 🔴 推荐做法:一句话让机制自己装
```powershell
# 开关/盘点一律走这个入口(pythonw 没有 stdout ⇒ 必须 --out 落盘再读)
& "<pythonw.exe>" "<技能目录>\scripts\collabctl.py" on --out "E:\tmp\collabctl-on.txt"
```
- `on` ⇒ 建齐保活任务 + 立刻触发 + 开关 `on`;
- `off` ⇒ **一键全关**(开关 `off` + 禁用任务 + 杀进程,幂等);
- `ensure` ⇒ **幂等收敛**:`on` 就补齐缺失的、`off` 就全停;
- 用户可见入口(双击):`会话机制-一键开关.bat`(工作区根 + 桌面各一份)。
⚠️ **本机一律 `pythonw.exe` 跑**(`python.exe` 是控制台程序 ⇒ **闪黑窗**,用户 2026-10-01 投诉过);
而 `pythonw` **没有 stdout** ⇒ **不落盘就完全看不到结果**(判据不可信=没章法)。
### 2.3 动作/触发器/设置(照抄 `collabctl.py::ensure_keepalive_tasks()`)
```powershell
$a=New-ScheduledTaskAction -Execute '<pythonw.exe>' -Argument '"<区>/.workbuddy/collab/supervise-launch.py"' -WorkingDirectory '<区>'
$t=New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes 5)
$s=New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
-StartWhenAvailable -MultipleInstances IgnoreNew `
-ExecutionTimeLimit (New-TimeSpan -Seconds 0) -Hidden
$pr=New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Highest
Register-ScheduledTask -TaskName 'collabd-keepalive-<区名>' -Action $a -Trigger $t -Settings $s -Principal $pr -Force
```
| 设置 | 值 | 漏了/写错会怎样 |
|---|---|---|
| `-Execute` | **`pythonw.exe`** | ⛔ `powershell.exe`/`cmd.exe` 是**控制台程序** ⇒ 每次触发分配 `conhost.exe` ⇒ **闪黑窗**(10-05 事故真因);⛔ `python.exe` 同病 |
| `-Argument` | 启动器路径 | ⛔ **不许直起 `collabd.py`**(见 1.2);⚠️ 参数里 ⛔ 别再写 `--supervise`(启动器自己补) |
| `-WorkingDirectory` | 🔴 **工作区根**(反斜杠形态) | ⛔ 写成脚本所在目录 ⇒ `load_cfg()` 去找 `<ws>/.workbuddy/collab/.workbuddy/collab/…` ⇒ **找不到** ⇒ `CFG_MISSING` ⇒ `--supervise` 拒跑(实测 `rc=2`、任务侧 `LastTaskResult=1`) |
| `-RepetitionInterval` | **5 分钟** | 这就是"复活"的全部来源;⛔ 别改成每分钟 |
| `-ExecutionTimeLimit` | `0`(无时限) | `--supervise` 是**长驻**进程,默认 72 h ⇒ 到点被掐死;设 2 分钟 ⇒ 每 2 分钟重建一次=另一种抖动 |
| `-MultipleInstances` | `IgnoreNew` | 重复起 ⇒ 双写台账 |
| `-Hidden` + `Interactive` + `Highest` | — | 缺 ⇒ 开机/登录后不启动;权限不足 |
| 🟠 `-RestartCount 999`/`-RestartInterval 1min` | **已去掉** | 旧写法。**每 5 分钟重复触发本身就提供重拉**;每分钟重启是多余的抖动(P0-74 记过"反复"的成因) |
### 2.4 ⚠️ 本机两条操作纪律
1. **⛔ `schtasks.exe` 在本机沙箱被黑名单拦截**(Security Center → Command Security)
⇒ **建/查/改/停计划任务全部走 PowerShell 的 `ScheduledTasks` 模块**
(`Get-/New-/Set-/Start-/Unregister-ScheduledTask`)+ `Out-File` 落盘再读,
⛔ **别指望命令回显**。
2. **改"起法"必须两边都改** —— 判据 = `grep -n "New-ScheduledTaskAction"` + `grep -n "\.ps1"`。
⚠️ **"改了主路径" ≠ "把旁路也改了"**(2026-10-05 只换动作、没换名字与旁路 ⇒ 2026-10-06 二次收口)。
## 三、🔴 两个仍然有效的坑(现行形态下照样会踩)
### 坑① 🔴 不设 `CODEBUDDY_CONFIG_DIR` ⇒ 读到 **0 字节空库**
- 计划任务**不继承**会话的环境变量 ⇒ `_wb_db()` 退回 `Path.home()/.workbuddy/workbuddy.db`
(本机那个是 **0 字节、无 `sessions` 表**)。
- 症状:每 20 秒刷一次 `all_sessions_idle 读库失败 no such table: sessions`
⇒ 闸二(所有会话都结束)**永久失效** ⇒ fail-safe 判"有会话在跑" ⇒ **检查会话永远建不出来**。
- ✅ **两道兜底都要有**:
· **启动器进程内设**(通用,见 1.2);
· **本区 `collabd.config.json` 写死 `host_db`**(指真库 `E:/ProgramData/.workbuddy/workbuddy.db`,单区保险)。
⚠️ 实测只有 `ai1net` 炸、`vibe` 没事 —— 差别就在 `vibe` 那份配置里 `host_db` 写了绝对路径。
### 坑② 🔴 配置查找靠 `cwd` ⇒ 传错 cwd 当场拒跑
`_cfg_candidates()` = `COLLABD_CONFIG` → `<cwd>/.workbuddy/collab/collabd.config.json`。
⛔ **不要把 `cwd` 设成 `collab/` 目录** —— 那会拼出多一级 `.workbuddy` ⇒ 当场拒跑。
✅ 设成**工作区根** + 同时显式给 `COLLABD_CONFIG`(双保险)。
(对应到任务侧就是 `-WorkingDirectory` **必须是工作区根**,见 2.3。)
> 🟠 **原坑③「PowerShell 5.1 读无 BOM 的 UTF-8 `.ps1` ⇒ 中文路径全毁 ⇒ 秒退」**
> —— **随 `.ps1` 形态整套废弃而失效**,⛔ 现行形态已无 `.ps1` 可读。
> 留痕见第四节 5.1 条(本机所有含中文路径的 `.ps1` 仍应带 BOM,是通用经验)。
> ⚠️ 判据口径也跟着变了:旧形态 `LastTaskResult` **必须 0**;
> **现行形态是长驻进程 ⇒ 正在跑时 `LastTaskResult=267009`(`0x41301`="任务正在运行")**,
> ⛔ **别再拿"必须 0"当判据**(见第五节的正确判据)。
## 四、🟠 旧形态留痕(2026-10-03 ~ 10-05 · ⛔ 只当历史读)
> ⛔ **本节所有写法都已废弃,⛔ 不许照着做。** 保留只为两件事:
> ① 出问题时能读懂老日志/老进程;② 里面有若干**通用教训**(下面标了 📌)。
### 4.1 旧载体=`<区>\.workbuddy\collab\start-supervise.ps1`(永不返回的守护循环)
⚠️ 旧版要求"必须是永不返回的守护循环版",⛔ 别写成"跑一次就退" —— 那样常驻死掉时脚本
**正常返回 0** ⇒ 任务被判「成功」⇒ `Restart*` 永不触发(测试3 实测:活 12 分钟后再不复活)。
```powershell
$ErrorActionPreference = "Continue"
# 🔴 坑①:必须设,否则 _wb_db() 退回读 0 字节空库 ⇒ 闸二永久失效
$env:CODEBUDDY_CONFIG_DIR = "E:\ProgramData\.workbuddy"
$env:COLLABD_CONFIG = "<工作区>\.workbuddy\collab\collabd.config.json" # 🔴 坑②
$env:PYTHONIOENCODING = "utf-8" # 中文日志不乱码
$env:PYTHONUNBUFFERED = "1" # ⛔ 否则重定向到文件时 Python 块缓冲、诊断不可见
$root = "<工作区>"; Set-Location $root
$hbPath = "<工作区>\.workbuddy\collab\logs\supervise-heartbeat.json"
$stopFlag = "<工作区>\tmp\supervise-inbox\guard.stop"
$logPath = "<工作区>\tmp\keeper.log"
$staleSec = 90 # 与 collabd.supervise_alive() 同口径
function Say([string]$m) { Add-Content -LiteralPath $logPath `
-Value ("[" + (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") + "] " + $m) -Encoding UTF8 }
function Get-HbAge { try { $o = (Get-Content -LiteralPath $hbPath -Raw -Encoding UTF8) | ConvertFrom-Json
$ts = [double]$o.ts; if ($ts -le 0) { return -1 }; return [int]((Get-Date).ToString("U") - $ts)
} catch { return -1 } }
$backoff = 5
while ($true) {
if (Test-Path -LiteralPath $stopFlag) { Say "guard.stop present => stands down"; exit 0 } # 🔴
$age = Get-HbAge
if ($age -ge 0 -and $age -lt $staleSec) { Start-Sleep -Seconds 5; continue } # 别人在跑 ⇒ 让位
Say ("spawn collabd --supervise (backoff=" + $backoff + "s)")
# 🔴 -Wait 必须:& 对 pythonw.exe(GUI 子系统)**不阻塞** ⇒ 会叠出多个常驻
Start-Process -FilePath "<pythonw.exe>" `
-ArgumentList @("-u", "<工作区>\.workbuddy\collab\collabd.py", "--supervise") `
-WorkingDirectory $root -WindowStyle Hidden `
-RedirectStandardOutput "<工作区>\.workbuddy\collab\logs\supervise.out.log" `
-RedirectStandardError "<工作区>\.workbuddy\collab\logs\supervise.err.log" `
-PassThru -Wait | Out-Null
Say ("collabd exited (heartbeat age=" + (Get-HbAge) + "s)")
$w = $backoff
while ($w -gt 0) { if (Test-Path -LiteralPath $stopFlag) { Say "stop flag during backoff"; exit 0 }
Start-Sleep -Seconds 5; $w -= 5 }
if ($backoff -lt 60) { $backoff = [Math]::Min(60, $backoff * 2) }
}
```
⚠️ 旧形态落盘**必须带 BOM**:`[System.IO.File]::WriteAllText($p,$text,[System.Text.UTF8Encoding]::new($true))`。
**旧形态的建任务**(⛔ 与 2.3 的区别就是 `-Execute` 与 `-Argument`):
```powershell
$a = New-ScheduledTaskAction -Execute "C:\windows\System32\WindowsPowerShell\v1.0\powershell.exe" `
-Argument '-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "<工作区>\.workbuddy\collab\start-supervise.ps1"'
$t = New-ScheduledTaskTrigger -AtLogOn -User "Administrator"
$s = New-ScheduledTaskSettingsSet -ExecutionTimeLimit ([TimeSpan]::Zero) `
-MultipleInstances IgnoreNew -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1)
New-ScheduledTask -TaskName "collabd-supervise-<区名>" -Action $a -Trigger $t -Settings $s -Principal $p
```
⚠️ 旧文档曾写「动作一律用 `powershell.exe`(不是 `pythonw.exe`)—— 动作里直接跑解释器没有'设环境变量'这一步」
—— 🔴 **这句是 P0-72/P0-73 的病根**:env 应该由**启动器**在进程内设,⛔ 不该靠 `powershell.exe` 去设;
而 `powershell.exe` 是控制台程序 ⇒ **闪黑窗**。
### 4.2 旧形态的三个坑(5.1 是通用经验,另两条随 `.ps1` 一并废弃)
**5.1 🔴 PowerShell 5.1 读无 BOM 的 UTF-8 `.ps1` ⇒ 中文路径全毁 ⇒ 秒退**
- 症状:任务 `LastTaskResult=1` **秒退**,心跳毫无动静。
- 定位:`Get-Content -Encoding Byte -TotalCount 3` ⇒ `36 69 114`(ASCII 的 `$Er`)⇒ **无 BOM**。
PowerShell 5.1 对无 BOM 文件按 **ANSI(GBK)** 解码 ⇒ 脚本里 `会话协作测试3` 变乱码 ⇒ 路径不存在。
- 📌 **通用经验(仍然有效)**:本机所有含中文路径的 `.ps1` 一律带 BOM,否则一律秒退。
⚠️ 现行形态不用 `.ps1` 了,所以这条只在"你自己还要写 .ps1"时才相关。
**5.2 🟠 keeper 是 `powershell.exe -File <ps1>` ⇒ `Stop/Start-ScheduledTask` 不足以让它生效**
PowerShell **启动时把脚本读进内存** ⇒ 磁盘上改了、任务重启了,**旧 keeper 进程可能仍在跑老脚本**。
实测:同时存在两个 keeper(57680 旧的 + 20180 新的),**旧的按老路径拉常驻** ⇒ "我明明改好了,起来还是老样子"。
✅ 旧做法:重铺后**显式杀 keeper 进程**(`taskkill /F /T /PID <keeper_pid>`)再 `Start-ScheduledTask`。
📌 **通用教训(仍有效)**:**改完副本必须重启常驻**,判据是**心跳里的 `argv0`**,⛔ 不是文件 `md5`
(md5 一致只证明文件换了,⛔ 不证明进程换了。P0-17/P0-74 同族)。
**5.3 🟠 从工作区副本发起时,找载体模板不能写死包根**
旧 `_escalate_to_keeper()` 写死 `Path(__file__).resolve().parent.parent / "assets"`:
- 技能目录里对(`<pkg>/scripts/collabd.py` ⇒ `.parent.parent` = `<pkg>`);
- **工作区副本里错**(`.workbuddy/collab/collabd.py` ⇒ `.parent.parent` = `<WS>/.workbuddy`
⇒ 去找 `<WS>/.workbuddy/assets/`,**不存在**)。
⇒ 症状**极具误导性**:返回值写着「⛔ 缺模板 `assets/start-supervise.ps1.tpl`」,
看着像仓库少了个文件,其实只是**算错了包根**;而模板好好躺在
`<WS>/.workbuddy/skills/session-mechanism/assets/`。
📌 **通用教训(仍有效)**:**凡"从 `__file__` 往上推包根"的代码,副本换布局后都会错** ——
副本是 `<WS>/.workbuddy/collab/`(**不是**包树里的 `scripts/`),别套用同一套相对层数。
🔴 2026-10-05 起 **`_find_keeper_tpl()` 与 `assets/start-supervise.ps1.tpl` 依赖已整套删除**。
### 4.3 🔴 为什么被换掉(两个事故,都在用户眼前犯的)
1. **闪黑窗**:任务动作写死 `powershell.exe -WindowStyle Hidden -File start-supervise.ps1`,
而 PowerShell 是**控制台程序** ⇒ 每 5 分钟触发一次就**闪一个黑窗**。用户 2026-10-05 报
「**又弹了窗口**」/2026-10-01 「**程序在界面一会弹出一会弹出的,影响我操作**」,
实测抓到 `powershell.exe` + `conhost.exe` 这对进程。
✅ 修法:动作改 `pythonw.exe`(GUI 子系统 ⇒ Windows **永不为它分配控制台窗口**)+ 启动器。
2. **两条起法并存**:`collabd.py::_escalate_to_keeper()` 这条**自我供给旁路**还在建旧形态任务
⇒ "改了主路径没改旁路"。2026-10-06 二次收口:**任务名**统一 `collabd-keepalive-<区>`、
**旁路清除**(`init_workspace.py`)、**探针扩成扫全族**。
⚠️ **教训**:凡"这一族文件都不许有 X",必须**枚举整族**,⛔ 不许只挑一个代表 —— 挑一个就等于没扫。
## 五、验收(⛔ "打印已启动"不算,三样都要机读)
```powershell
# ⚠️ schtasks 被拦;一律 PowerShell 模块 + 落盘看结果
Get-ScheduledTask -TaskName "collabd-keepalive-<区名>" > E:\tmp\t1.txt
Get-ScheduledTaskInfo -TaskName "collabd-keepalive-<区名>" > E:\tmp\t2.txt
```
| # | 判据 | 现值应该是 | ⚠️ 别搞错 |
|---|---|---|---|
| 1 | `State` | **`Running`** | `Ready` 只说明"此刻没在跑",⛔ **不代表机制坏**(5 分钟触发窗内常是 Ready);要配合 ③ 才算稳 |
| 2 | `LastRunTime` | 近 5 分钟内 | — |
| 3 | `LastTaskResult` | **`267009`**(`0x41301`="任务正在运行") | ⛔ **别再拿"必须 0"当判据**(那是"跑一次就退"时代的);`1` = 秒退(旧形态八成是 BOM) |
**存活唯一机读判据**(=机制里那条,⛔ 别用"日志在更新"代替):
`.workbuddy/collab/logs/supervise-heartbeat.json` 里 **`pid` 活着 ∧ `ts` 距今 < 90 秒 ∧ `round` 递增**。
```powershell
$p = Get-Process -Id <pid> -ErrorAction SilentlyContinue # $null = 已死
# ⛔ tasklist 走 bash 会被当路径;一律用 PowerShell
```
**静置观察(≥ 12 分钟,⛔ 短于 10 分钟证明不了任何事 —— 常驻就死在第 12 分钟)**:
`round` 持续递增、心跳里 `argv0` **逐字等于本区** `.workbuddy/collab/collabd.py`、
并且 `已有活的常驻 ⇒ 本实例退出(幂等)` 出现一次(证明没双写)。
**判"改的代码真生效了"**:心跳里 `started_h`(或 `started_ts`)**晚于**你改完/分发完的时刻
(⛔ 只看文件 md5 不算 —— 见 4.2 的 5.2 条)。
## 六、⚠️ 还没有它就不算长期(两条,别自欺)
1. **静默基准**:检查会话要 `静默 ≥ 20 分钟` 才建,而基准取台账 `tasks.json` 的 mtime
⇒ **新建/改动任何排期都会把计时归零** ⇒ 刚派完棒必然等满 20 分钟(实测 0.2/4.3/…/12.6 分钟,**从未越过 20**)。
2. **闸二会自锁(正确行为,不是 bug)**:`_all_sessions_idle()` 要求"所有会话都结束",
而**发起这一切的那条会话自己也在里面** ⇒ 只要它活着,检查会话就建不出来。
⇒ **要让检查会话跑起来,必须先结束发起会话**(fail-safe 方向=宁可不建,不误建)。
## 七、还有一条(已由机制收口,⛔ 别再自己写)
- **目标完成 ⇒ 关后台+常驻程序**:`collabd.py --set-life 已完成` 现在**真的会停常驻**
(`supervise_stop()`:先写 `guard.stop` 走优雅退出 → 等 `STOP_GRACE`(45 s)→ 不死才 `taskkill /T /F`)。
⛔ **受阻 / 已暂停不关**(口径如此)。
- 🔴 **停止标志由常驻本体自己看**(`collabd.py::_guard_says_stop()`,**对所有实例生效**)——
⛔ 现行形态**不再有"守护循环/ps1 去看标志"这一层**。
⚠️ 于是"任务每 5 分钟还在触发"≠"还在徒劳重拉":常驻起来见 `guard.stop` 即**优雅退出**,
不会空转(旧 ps1 时代那 6 小时空转 370 次的毛病,结构上已不存在)。
- **"完成"可逆**:`ensure_supervise()` 续命前**清掉 `guard.stop`** ⇒ 新目标/目标调整后能再起来。
⛔ 不清就永远起不来。
- **跨区自愈**:本区常驻每 6 轮顺带扫一遍 `peer_workspaces`,**只补"目标进行中"的区**
(已完成/受阻/有停止标志的 ⛔ 一律不拉)⇒ 但**计划任务是更靠前的第一道**
(⛔ 别把跨区自愈当载体的替代品:它要求本区常驻先活着)。
## 八、按场景对照(选哪条 / 怎么验 / 常见坑)
| 场景 | 主会话怎么来 | 常驻载体 | 目标完成后谁关 | 常见坑 |
|---|---|---|---|---|
| **A · 用户手动创建主会话** | 用户自己开 `[主]-…`,盯着推进 | 计划任务 → `pythonw` → `supervise-launch.py` | 常驻见 `guard.stop` → 优雅退出 | 🔴 用户一收工就没人拉 ⇒ 若没装载体,常驻会在会话结束时**静默死掉**(看板还显示"在线") |
| **B · 定时任务创建主会话** | 排期 `recurring` 到点拉 `[主]-…` | 同上(⛔ **排期代替不了载体**) | 同上 | 🔴 **`once` 排期过期即哑**(`next_run_at=None`、界面仍显示 ACTIVE)⇒ **静默窗内无人**;🔴 排期跑完即 `completed`,**下一跳之前是空窗** |
**共同判据(两种场景都一样,⛔ 每次改完都验)**:
1. `State` = **`Running`**(或 5 分钟窗内 `Ready`);
2. `LastTaskResult` = **`267009`**(长驻中);⛔ `1` 也是信号(秒退);
3. 心跳 `pid` 活 ∧ `ts` 距今 **< 90 s** ∧ `round` 递增;
4. **静置 ≥ 12 分钟**再看 ③(⛔ 短观察证明不了事 —— 常驻常死在第 12 分钟);
5. 目标完成后:常驻日志出现收工行 + **心跳不再更新但进程已退**(验"真的停了",
⛔ 不许只看"心跳停了" —— 那也可能是死了而不是收工;旧形态的判据是 keeper 日志出现
`stands down` + **重拉计数不再增长**)。
## 九、任务名(⛔ 名字别撞,也别再改名)
| 对象 | 任务名 | 备注 |
|---|---|---|
| 常驻(协作程序) | `collabd-keepalive-<工作区名>` | 例:`collabd-keepalive-vibe-product` |
| 看板 | `dsh-board-keepalive` | 动作 = `pythonw.exe` + `board-launch.py` |
| 🟠 旧名 | `collabd-supervise-<区>` | **已废弃**;`collabctl.py::SCHED_TASKS` 把它列进**禁用名单**,见一条禁一条 |
⚠️ 一个区一个任务;⛔ 多个区**共用一个**任务名会被 `IgnoreNew` 挡掉第二个。
🔴 **⛔ 别再改任务名** —— 它同时是 `collabctl.py` 的"启用名单"和"禁用名单"的键;
改名必须**同时**改两边,否则自愈起的常驻会被 `off` 顺手干掉(2026-10-06 实测踩到)。
## 十、🔴🔴 「各区都能常驻」怎么一次做齐(2026-10-04 用户点问)
> 用户原话:「看看是否有**各工作区都能启动常驻程序**的最好办法,
> 而不是现在这种**只能某个工作区才能常驻**的办法」
✅ **答案:能,而且就是本节那套 —— 每区各建一条任务,同一条命令换个区名/换条路径即可。**
⛔ **不存在"一个载体管所有区"的写法**,⛔ 也**不需要**:任务天然可以并存,
`IgnoreNew` 只在**同一个任务名**上生效(⛔ 撞名才挡)。
### 一区四步(把 `<区>` 换成工作区绝对路径)
1. **确认启动器在位**:`<区>\.workbuddy\collab\supervise-launch.py`
(⛔ 别自己铺 —— 缺就跑 `deploy_code.py --ws <区>` 分发)。
`collabd.config.json` 里补两项:`host_db`(指真库,⛔ 别依赖环境变量,见坑①)+ 本区 `workspace`。
2. **建任务**:走 `collabctl.py on`(正门)或等 `_escalate_to_keeper()` 自愈;
任务名 `collabd-keepalive-<名>`,动作/触发器/设置按 2.3 那四条。
3. **立即 `Start-ScheduledTask`**(⛔ 别等下次登录/别等 5 分钟)。
4. **验收**:按第五节的 5 条 ⇒ **静置 ≥ 12 分钟**再看一次。
### 各区之间的隔离(为什么"能各建各的")
- **任务名不同** ⇒ `IgnoreNew` 互不影响;
- **`COLLABD_CONFIG` 指向各区自己的配置** ⇒ 各读各的 `goal.json`/台账;
- **跨区自愈是"顺带",⛔ 不是主靠**:本区常驻每 6 轮扫一遍 `peer_workspaces`,
只补"目标进行中"的区(已完成/受阻/有 `guard.stop` 的 ⛔ 一律不拉)。
⚠️ 所以**别把跨区自愈当载体的替代品** —— 它要求本区常驻先活着,是第二道。
### ⛔ 别犯的三个错
1. **⛔ 用一条任务带多个区**(一个动作只能跑一个区的启动器);
2. **⛔ 一区多条任务**(会双写台账);
3. **⛔ 以为"某区现在活着"就不用建任务** —— 那是孤儿化幸存,父链一断就没了(第一节末尾那条实测更正)。
---
📌 **本文档的事实在哪**:
· 2026-10-03/04 部分(判据表、`in_job` 更正、静置 12 分钟、自锁)= 三份复盘 + 当晚实测;
· 2026-10-05/06 部分(启动器、任务名统一、旁路清除、探针扩族)= **P0-72/P0-73/P0-74** 实测,
权威源在 `SKILL.md`「常驻机制的真实形态」与本技能 `scripts/`。
⛔ **任何"应该行得通但没实测"的写法都别往里加。**