Files
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

284 lines
19 KiB
Markdown
Raw Permalink 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-03 23:17–2026-10-04 07:15 实测)**:**长期在线只有一条路** ——
> **Windows 计划任务 + 永不返回的守护循环脚本**(跑 `collabd.py --supervise`)。
> 本文档是**唯一权威**(⛔ 别处不要再写另一套)。
> 证据源=测试3 的复盘 `目标-会话协作测试3-7cd276/S3_常驻启动成功复盘_20261003.md`
> + `S4_常驻自愈闭环复盘_20261003.md` + `S5_A6自指修复与目标收口_20261004.md`。
## 〇、🔴🔴 先分场景:两种"长期"不是一回事
**两种都要长期在线,但它们要的东西不同** —— ⛔ 别混用,混用就会出现
"看起来在跑、其实随时会死"或"该停时还在跑"。
| | **场景 A · 用户手动创建主会话** | **场景 B · 定时任务创建主会话** |
|---|---|---|
| 典型形态 | 用户在 WorkBuddy 里自己开一条 `[主]-…` 会话,盯着它推进 | 排期按点拉起 `[主]-…`(`recurring`,如 `FREQ=HOURLY;INTERVAL=1`) |
| 会话寿命 | **随用户何时收工**(可能几分钟,也可能几小时) | 跑完即 `completed`,**与下一跳之间有静默窗** |
| 🔴 **对"常驻"的硬要求** | ⛔ **不能靠会话活着** —— 一收工就没人拉 ⇒ **必须脱离会话** | ✅ 会话自己周期性回来 ⇒ 但**每跳之间仍有空窗** ⇒ **同样要脱离会话** |
| 唯一合格载体 | **计划任务 + 守护循环**(本文档第三~五节) | 同左(⛔ **排期代替不了载体** —— 排期只是"谁按点喊一次",喊完会话就结束) |
| 怎么验它真活着 | 心跳 `pid` 活 ∧ `ts` 距今 < 90 s + `round` 递增 | 同左,⚠️ **且要静置 ≥ 12 分钟**(⛔ 短观察证明不了事) |
| 谁负责"目标完成后关它" | 守护循环见 `guard.stop` 即 `exit 0`(第五节之二) | 同左 —— ⛔ **排期不会帮你关** |
### 🔴 两个都踩过的坑(**与场景无关**,因为根子在载体)
1. **"一次性工具调用起的子进程"在两种场景下都不合格** ——
实测测试3 开工时 pid 69080 已死;本区两个后台任务(`collabd --supervise` 活 842 分钟、
`board.py --serve` 活 634 分钟)父链都是
`bash → bash → bash → sandbox-cli → WorkBuddy.exe`(**会话树内**),
⛔ `.workbuddy/collab/` 里**既无 `.ps1` 也无 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` 非空。
## 一、为什么必须换载体(⛔ 别再自己发明)
| 载体 | 能不能长期 | 实证 |
|---|---|---|
| 工具调用起的子进程 | ❌ | 父进程一退出就被回收(活不过当轮/当会话) |
| 宿主排期拉的会话起 | ❌ | 测试3 开工时 pid 69080 已死(心跳停在 22:55:31) |
| `CREATE_BREAKAWAY_FROM_JOB` | ❌ | **`PermissionError(13,'拒绝访问。')`** —— 本机被作业对象拒绝,**这条路封死** |
| `cmd /c start /B` | ❌ | 本机沙箱拦截 |
| 排期到点起一次会话 | ❌ | **跑完即退** ⇒ 静默窗内无人(`once` 更糟:可能**永不触发**) |
| **计划任务 + 守护循环** | ✅ | **实测 23:16:37 起活到 23:29 仍在跑**,`round` 1→74;死亡后 **5–6 秒**自动恢复 |
**关键差别=父链**:计划任务创建的进程由 **Task Scheduler 服务**创建,
**不在 WorkBuddy 会话的 Job Object 里** ⇒ 不会被会话回收。
## 二、装法(照抄,⛔ 别自创参数)
### ① 包装脚本 `<工作区>\.workbuddy\collab\start-supervise.ps1`
⚠️ **必须是「永不返回的守护循环」版**。⛔ **别写成"跑一次就退"** —— 那样常驻死掉时脚本
**正常返回 0** ⇒ 任务被判「成功」⇒ `Restart*` 永不触发(测试3 实测:活 12 分钟后再不复活)。
完整可用版见第五节(含 `-Wait`/心跳判据/退避/**停止标志**四项要点)。
```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))`。
### ② 建任务(⚠️ 一律 PowerShell 的 `ScheduledTasks` 模块)
⛔ **`schtasks.exe` 在本机沙箱被黑名单拦截**(Security Center → Command Security)
⇒ **建/查/改/停计划任务全部走 `Get-/New-/Set-/Start-ScheduledTask` + `Out-File` 落盘再读**
(⛔ 别指望命令回显)。
```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
# 改动作不影响已配的触发器 ⇒ 更新时只传 -Action
Set-ScheduledTask -TaskName "..." -Action $a
```
| 设置 | 值 | 漏了会怎样 |
|---|---|---|
| `ExecutionTimeLimit` | `0`(无时限) | 默认 72 h ⇒ 到点被杀 |
| `MultipleInstances` | `IgnoreNew` | 重复起 ⇒ 双写台账 |
| `RestartCount`/`RestartInterval` | `999`/`1 min` | 死了不重拉 |
| Trigger | `AtLogOn`(Interactive・最高权限) | 开机不启动 |
⚠️ **动作一律用 `powershell.exe`(不是 `pythonw.exe`)** —— 动作里直接跑解释器没有"设环境变量"这一步。
(用户长期记忆里那条"`python.exe` 会闪黑窗"针对的是**交互式起进程**;计划任务走 `Hidden`+无控制台,不闪。)
## 三、🔴 三个坑(都可复现,逐个都是"秒退"或"看着在其实没跑")
### 坑① 🔴 不设 `CODEBUDDY_CONFIG_DIR` ⇒ 读到 **0 字节空库**
- 计划任务**不继承**会话的环境变量 ⇒ `_wb_db()` 退回 `Path.home()/.workbuddy/workbuddy.db`
(本机那个是 **0 字节、无 `sessions` 表**)。
- 症状:每 20 秒刷一次 `all_sessions_idle 读库失败 no such table: sessions`
⇒ 闸二(所有会话都结束)**永久失效** ⇒ fail-safe 判"有会话在跑" ⇒ **检查会话永远建不出来**。
- ✅ **正解=用机制自带的配置项 `host_db`**(`collabd.py` 里它本就**优先于**环境变量),
指向真库 `E:/ProgramData/.workbuddy/workbuddy.db`。
⇒ **这比设环境变量更可靠**(配置跟着工作区走,不依赖谁起的它)。
### 坑② 🔴 配置查找靠 `cwd` ⇒ 传错 cwd 当场拒跑
`_cfg_candidates()` = `COLLABD_CONFIG` → `<cwd>/.workbuddy/collab/collabd.config.json`。
⛔ **不要把 `cwd` 设成 `collab/` 目录** —— 那会拼出多一级 `.workbuddy` ⇒ 当场拒跑。
✅ 设成**工作区根** + 同时显式给 `COLLABD_CONFIG`(双保险)。
(`ensure_supervise()` 内部原先传 `cwd=脚本所在目录` —— 已由测试3 主会话修成 `cwd=str(WS)`。)
### 坑③ 🔴 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` 变乱码 ⇒ 路径不存在。
- ✅ 落盘**必须带 BOM**:
`[System.IO.File]::WriteAllText($p,$text,[System.Text.UTF8Encoding]::new($true))`(`$true` = 带 BOM)。
- 📌 **本机所有含中文路径的 `.ps1` 一律带 BOM**,否则一律秒退。
- ⚠️ 判据:**`LastTaskResult`** —— `0` = 成功;`1` = 秒退(八成是 BOM)。
## 四、验收(三样都齐才算成,⛔ 打印"已启动"不算)
```powershell
Get-ScheduledTask -TaskName "collabd-supervise-<区名>" | Select-Object TaskName,State
Get-ScheduledTaskInfo -TaskName "collabd-supervise-<区名>" | Select-Object LastRunTime,LastTaskResult
```
🔴 `LastTaskResult` **必须 0**。
**存活唯一机读判据**(=机制里那条,⛔ 别用"日志在更新"代替):
`.workbuddy/collab/logs/supervise-heartbeat.json` 里 **`pid` 活着 ∧ `ts` 距今 < 90 秒**。
```powershell
# pid 是否真在进程表(⛔ tasklist 走 bash 会被当路径;用 PowerShell)
$p = Get-Process -Id <pid> -ErrorAction SilentlyContinue # $null = 已死
```
**静置观察**(≥ 4 分钟):`round` 持续递增、`argv0` 逐字等于本区 `collabd.py`、
`已有活的常驻 ⇒ 本实例退出(幂等)` 出现一次(证明没双写)。
## 五、🔴 载体是**一个**永不返回的守护循环(⛔ 不是"两层")
⚠️ **旧版这里写的是"必须两层=计划任务 + 周期性复活排期",🔴 已被实测推翻** ——
真正常态兜底**就在这个计划的守护循环里**,⛔ 不需要另一条排期。
**实测 2026-10-03**:单纯"计划任务跑一次脚本"**救不了"被外部收走"** ——
测试3 常驻 `23:16:37` 起、`23:28:47` 停(活 12 分钟),而计划任务
`LastRunTime` 仍 `23:17:03`、`LastTaskResult=0`、`State=Ready` ⇒ **它没重拉**。
**根因**:`RestartCount/RestartInterval` **只在本任务自己非零退出时生效**。
而脚本里若是**前台阻塞**调用常驻,常驻死掉 ⇒ 脚本**正常返回 0** ⇒
任务调度器判定「成功」⇒ **`Restart*` 永不触发**(测试3 复盘原话:「自愈机制被**成功退出**这三个字废掉了」)。
⇒ ✅ **正解=守护循环版脚本**(⛔ 别用"跑一次就退"):
```
while ($true) {
if (Test-Path $stopFlag) { Say "…stands down"; exit 0 } # 🔴 见第五节之二
if (心跳新鲜(<90s)) { 让位等待; continue } # 活性判据=心跳,⛔ 不是退出码
Start-Process -Wait -PassThru pythonw collabd.py --supervise # 🔴 -Wait 必须
# 退避 5→10→20→40→60s 封顶;每轮开头都再查一次 $stopFlag
}
```
| 要点 | 为什么 |
|---|---|
| `while ($true)` 包住 | 任务永远 `Running` ⇒ 调度器**不介入**、自己管复活 |
| 🔴 **`Start-Process -Wait`** | ⛔ **`&` 对 `pythonw.exe`(GUI 子系统)不阻塞**、立即返回 ⇒ 循环误判"刚起的已退出"⇒ **叠出多个常驻**(测试3 实测 5/10/20/40s 连续重拉) |
| 活性判据=**心跳新鲜度**(`<90s`),⛔ 不是退出码 | `collabd` 发现已有活常驻会**幂等 exit 0** ⇒ 按退出码重拉会造出风暴 |
| 退避封顶 60s | 真崩时别刷屏 |
| `.NET Process` + `add_OutputDataReceived` | ⚠️ PowerShell 5.1 **无消息泵 ⇒ 回调不触发**、日志被静默吞掉 ⇒ 用 `Start-Process -RedirectStandardOutput` |
**实测效果**:23:40 第一次死亡 **5 秒恢复**;23:47 单变量 kill 演练 **6 秒恢复**、新 pid、`pythonw` 实例数 `count=1`(无叠加)。
### 之二 🔴🔴 守护循环**必须看停止标志**(2026-10-04 补,本文档初版漏了)
**症状**:目标已完成(`--set-life 已完成` ⇒ `supervise_stop()` 写下 `guard.stop`、常驻已优雅退出),
**但守护循环仍在每 60 秒重拉一次、每次秒退** ⇒ 实测**空转 370 次/6 小时**
(`keeper.log` 连续 `exited (exit=0, heartbeat unreadable)`)。
**根因**:守卫只问「心跳新鲜吗」,**⛔ 不问「目标还活着吗」** ⇒
**"完成即收工"只做到"停掉常驻",没做到"别再拉它"** —— 那是同一件事的两半。
✅ **正解=循环开头(+退避等待中)都先查停止标志**,在则**退出循环**:
```powershell
$stopFlag = "<工作区>\tmp\supervise-inbox\guard.stop"
while ($true) {
if (Test-Path -LiteralPath $stopFlag) { Say "guard.stop present => stands down"; exit 0 }
... # 起常驻
$waitLeft = $backoff
while ($waitLeft -gt 0) { # 🔴 退避期间也可能出现标志
if (Test-Path -LiteralPath $stopFlag) { Say "…stands down early"; exit 0 }
Start-Sleep -Seconds 5; $waitLeft -= 5
}
}
```
📌 退出前**必须 `Say` 一行**(⛔ 否则又变成"静默消失",同族红线:读到了就要说)。
**实测(2026-10-04 07:03,单变量:只换脚本、没动任务)**:
新 keeper 起来 **0 秒**识别标志 ⇒ `keeper stands down` ⇒ 任务 `State=Ready`(⛔ 不是 `Running`)
⇒ 重拉次数 **372 → 372**(一没涨)⇒ **空转彻底止住**。
⚠️ 验「是否被回收」必须**只做单一变量**(⛔ 不许同时停/起任务)—— 否则因果会搞错。
⚠️ **验收要静置 ≥ 12 分钟**(⛔ 短于 10 分钟证明不了任何事 —— 常驻就死在第 12 分钟)。
## 六、⚠️ 还没有它就不算长期(两条,别自欺)
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` 走优雅退出 → 等 45 s → 不死才 `taskkill /T /F`)。
⛔ **受阻 / 已暂停不关**(口径如此)。
- **"完成"可逆**:`ensure_supervise()` 续命前**清掉 `guard.stop`** ⇒ 新目标/目标调整后能再起来。
⛔ 不清就永远起不来。
- **跨区自愈**:本区常驻每 6 轮顺带扫一遍 `peer_workspaces`,**只补"目标进行中"的区**
(已完成/受阻/有停止标志的 ⛔ 一律不拉)⇒ 但**计划任务是更靠前的第一道**。
## 八、按场景对照(选哪条 / 怎么验 / 常见坑)
| 场景 | 主会话怎么来 | 常驻载体 | 目标完成后谁关 | 常见坑 |
|---|---|---|---|---|
| **A · 用户手动创建主会话** | 用户自己开 `[主]-…`,盯着推进 | 计划任务 + 守护循环 | keeper 见 `guard.stop` → `exit 0` | 🔴 用户一收工就没人拉 ⇒ 若没装载体,常驻会在会话结束时**静默死掉**(看板还显示"在线") |
| **B · 定时任务创建主会话** | 排期 `recurring` 到点拉 `[主]-…` | 同上(⛔ **排期代替不了载体**) | 同上 | 🔴 **`once` 排期过期即哑**(`next_run_at=None`、界面仍显示 ACTIVE)⇒ **静默窗内无人**;🔴 排期跑完即 `completed`,**下一跳之前是空窗** |
**共同判据(两种场景都一样,⛔ 每次改完都验)**:
1. `LastTaskResult` = `0`(`1` ⇒ 秒退,八成是 BOM);
2. 任务 `State` = **`Running`**(⛔ `Ready` 只说明"此刻没在跑",**不代表机制坏**,
但要配合 ③ 才算稳);
3. 心跳 `pid` 活 ∧ `ts` 距今 **< 90 s** ∧ `round` 递增;
4. **静置 ≥ 12 分钟**再看 ③(⛔ 短观察证明不了事 —— 常驻常死在第 12 分钟);
5. 目标完成后:keeper 日志出现 `stands down` + **重拉计数不再增长**(验"真的停了",
⛔ 不许只看"心跳停了"—— 那可能是死了而不是收工)。
## 九、四个区各自的任务名(⛔ 名字别撞)
`collabd-supervise-<工作区名>`,例:`collabd-supervise-ws3`。
⚠️ 一个区一个任务;⛔ 多个区**共用一个**任务名会被 `IgnoreNew` 挡掉第二个。
---
📌 **本文档的事实全部来自实测**(计划任务状态/`LastTaskResult=0`/心跳 13 分钟/BOM 字节
`239 187 191`/三条封死路的原始错误)。⛔ 任何"应该行得通但没实测"的写法都别往里加。