Files
workbuddy_skills/session-mechanism/references/supervise-persistence.md
T
admin a3935b5fc5 修「检查会话落到未分组」+ 检查程序静默阈值 20 → 10 分钟
一、检查会话跑到未分组(用户报障逐字)
「vibe-product 的检查会话没有创建到工作区下,跑到未分组的会话中去了」。
根因(实测坐实):collabd.py::create_check_schedule() 的 INSERT **少了 workspace_scope 列**
⇒ 落库 NULL ⇒ 排期触发出来的会话被宿主当**游乐场**建
(sessions 行:is_playground=1 + source_mode='craft' + use_sandbox_cli=0)⇒ 不进工作区分组。
对照读数(同一晚两条排期):会话侧 automation_update 建的是 'workspace' ✓,
机制自建的是 None ❌;近 3 天 59 条排期里只有机制那条是 None。
修法:INSERT 补 workspace_scope 并以 'workspace' 落库。
配套:新增 selftest 用例 t_check_schedule_scope(3 项),含变异对照
(把列删掉 ⇒ 报红;还原 ⇒ 逐字节一致)。

二、检查程序等待时间 20 → 10 分钟(用户令逐字)
「把检查程序的等待时间由 20分钟 改为 10分钟」。
改法:新增常量 CHECK_IDLE_MIN = 10,**闸门与 --dry-run 试算都读它**(原来两处各写死 20);
并同步**写给检查会话看的 prompt 文案**(≥20 → ≥10)与 4 处注释/文档
(references/supervise-persistence.md §六;顺带订正该节「基准取台账 tasks.json 的 mtime」——
与代码不符,实际是「本工作区排期的 updated_at 最大值」)。
配套:新增 selftest 用例 t_check_idle_min(4 项,**不锁数值**、只锁"常量/闸门/试算/文案同源"),
含变异对照(把文案改回 20 ⇒ 报红)。

三、旧数据归位与分发
- 今晚那条歪掉的检查会话已改回工作区档(is_playground 1→0、source_mode craft→work、
  use_sandbox_cli 0→1;排期 workspace_scope → 'workspace');改前原值已备份到
  归档/检查会话归位-20261007/;PRAGMA integrity_check = ok。
- 副本分发:ai1net-dsh-server 与 vibe-product 各 7 个文件;两区常驻已重启
  (判据=心跳 started_h 07:54:35 / 07:54:37,晚于分发时刻 07:54:07)。

验收:selftest.py rc=0 PASS 102 / FAIL 0(改前 99);manifest 70 份、语法失败 0。
2026-10-07 07:55:29 +08:00

473 lines
34 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.
# 常驻程序长期在线(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. **静默基准**:检查会话要 `静默 ≥ 10 分钟` 才建(🔴 **2026-10-07 由 20 改 10**,用户原话
「把检查程序的等待时间由 20分钟 改为 10分钟」;阈值=`collabd.py::CHECK_IDLE_MIN`,闸门与 `--dry-run` 试算同源)。
基准=**本工作区排期的 `updated_at` 最大值**(`_last_progress_ts()`);
⚠️ 本行原先写「基准取台账 `tasks.json` 的 mtime」——**那句与代码不符**,已按代码订正。
⇒ **新建/改动任何排期都会把计时归零** ⇒ 刚派完棒必须再等满阈值(实测 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/`。
⛔ **任何"应该行得通但没实测"的写法都别往里加。**