Files
workbuddy_skills/session-mechanism/scripts/keepalive.py.retired-20261012
T
admin 60a6e30ba2 session-mechanism:建任务的 RunLevel 改成按账户权限自适应(非管理员机不再被拒)
现象:常驻每次尝试给自己装保活任务,日志都留一行「常驻自我供给(计划任务):⛔ 登记失败:
Register-ScheduledTask : 拒绝访问」⇒ 非管理员账户的机器上常驻没有自愈载体(死了没人拉)。
真因(2026-10-12 实测坐实):建任务时把运行权限写死成 `-RunLevel Highest`,而该机账户不是
管理员,Windows 直接拒绝注册;同一个任务去掉这个参数就建得成。

改法(一次性判定,⛔ 不用「先按 Highest 建、失败再重试」—— 那会让每次多一次失败注册):
新增 `_admin_principal_flag()`:管理员(进程已提权)⇒ ` -RunLevel Highest`(原行为不变);
非管理员 ⇒ 空串(整段去掉 `-RunLevel`);判不出来 ⇒ 按普通权限走(宁可少一次提权,也要建得成)。
落点三处:`collabctl.py::ensure_keepalive_tasks()` 两处(保活任务 + 看板任务)、
`collabd.py::_escalate_to_keeper()` 一处(自我供给旁路)。

同批退役(定案 A):`scripts/keepalive.py` → `keepalive.py.retired-20261012`。
它零 importer、不在 `deploy_code.py` 分发清单,`references/deploy.md §4` 早已写明它是
「旧副本、职责已被本 skill 吸收」;留着就是第二份建任务实现,与「建任务只许一处」相抵。
⚠️ 它的 `-ExecutionTimeLimit (New-TimeSpan -Minutes 2)` 确实与另三处(无时限)不一致,
退役后该不一致一并消失;工作区侧无同名副本,无需清理。

文档订正(旧结论是误归因):
  · `assets/board-launch.py.tpl` 那条「`Highest` 不是可选项:`Limited` 实测 `LastTaskResult=1`」
    ⇒ 订正为「`RunLevel` 按账户权限定」。看板 `LastResult=1` 的真因是单例守卫 + `pythonw`
    无 stdout 把退出码顶成 1(`references/pitfalls.md` 已写明),与 `RunLevel` 无关;
    非管理员机上 `Limited` 实测建得成、也跑得起来。
  · `SKILL.md`、`references/supervise-persistence.md` 同步改口径。
  · `references/deploy.md` 包内脚本清单去掉 `keepalive.py`。

验证(本机实跑,⛔ 不真建任务:把脚本调用拦下来只看文本)
  · 两分支对照:管理员分支 6/6 段带 `-RunLevel Highest`;强制非管理员分支 6/6 段无 `RunLevel`。
  · 与改动前逐字对照:管理员分支生成的脚本归一化后完全一致(6/6 段)⇒ 原行为不变。
  · `selftest.py`:改前 110/0、改后 110/0(无回归)。
  · `install.py --manifest`:77 份文件、语法失败 0。
  · 副本已分发到本机 4 个工作区(md5 与源一致)。
⚠️ 非管理员分支本机验不到(本机是 Administrator)⇒ 该档验收需在非管理员账户的机器上跑。
2026-10-12 11:07:02 +08:00

332 lines
15 KiB
Plaintext
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.
# -*- coding: utf-8 -*-
"""🔴 保活机制收敛(2026-10-05 用户定案)——「计划任务起个程序」+「一键能关掉」。
用户口径(逐字):
· 「**常驻崩了,拉起来这个事儿,你不能也用计划任务起个程序吗?**」
· 「**我总要以一键能关掉这些东西吧,我关不掉这些东西,我还要投诉你呢**」
⇒ 正解=**计划任务直接起 `collabd.py --supervise`**(**常驻本体**,自带内部循环 10~30 s 一轮),
动作**必须是 `pythonw.exe`**(GUI 子系统 ⇒ 不分配控制台 ⇒ 不弹窗)。
🔴🔴 **2026-10-05 实测推翻了两条旧结论(都在本机跑出来的,不是推的)**:
① **`IsProcessInJob` 在本机没有鉴别力** —— 四组对照(任务 Highest/Interactive、
任务 Limited/S4U、任务 Highest/S4U、subprocess 直起)**全部返回 IN-JOB**
⇒ 本沙箱给谁都套作业对象 ⇒ **不能用它判断"会不会被会话树带走"**。
✅ 真判据=**父链根在哪**:会话树里的进程父链一路是
`bash → sandbox-cli → WorkBuddy.exe → explorer`;
任务起的进程**父进程已退出、链断在自己身上**(实测 30560/60596/70228 三件皆如此)。
② **常驻不需要网关口令** —— `--supervise` 唯一职责=**建检查会话排期**
(`maybe_spawn_check_agent` ⇒ `create_check_schedule` ⇒ **直连 SQLite 写 `automations` 表**),
**全程不走网关**。口令只在 `--ensure` 的 **spawn 分支**才是前置门(`ensure_supervise` 里那条)。
✅ **隔离区实测**:**不含口令**的计划任务起 `--supervise` ⇒ 15 秒内写出心跳(pid 3944 · round=2)。
⇒ 所以**保活必须用 `--supervise`**,⛔ 不是 `--ensure`(那在任务环境下会因无口令而"起了就退")。
**为什么不是 `powershell.exe`**:它是控制台程序,启动必然分配控制台 ⇒ 闪窗(10-05 弹窗事故的真因)。
**为什么不是 `start-supervise.ps1` 那个看守循环**:常驻**自带内部循环**(`--supervise` 本身不退)
⇒ 外层再套"永不退出的看守"纯属多余,那层看守才是当初每 60 秒创建进程的来源。
**为什么是 5 分钟**:`--supervise` 是**长驻**的,**不需要周期叫醒**;
这个 5 分钟只是**兜底**——万一常驻真被杀,最多 5 分钟内被重新拉起(一次性判活,判到就走)。
⚠️ 判活由 `--supervise` 自己**进场即判**(`supervise_alive()`:pid 活 + 心跳新 + 身份核验),
**已在 ⇒ 新起的立刻让位退出**(幂等,⛔ 不会"叠加成两条")。
**一键关掉(新增能力)**:本脚本支持 `off` ⇒ 一个动作做完四件事:
① 关各区开关 ② 禁用所有相关计划任务 ③ 杀掉所有相关进程 ④ 打印复核结果
⇒ ⛔ 不再需要"禁用任务 + 杀进程"分开记(10-05 就是因为只禁任务没杀进程,以为停了其实没停)。
用法:
pythonw setup_keepalive.py status # 只读盘点(开关/存活/任务/进程)
pythonw setup_keepalive.py on --apply # 建立保活(写计划任务)+ 起一次
pythonw setup_keepalive.py off --apply # 🔴 一键全关(幂等,可反复跑)
⛔ 不带 --apply 一律干跑,只打印动作。
"""
import os
import subprocess
import sys
# 🔴 2026-10-11:环境配置外置(「脚本是脚本 配置是配置」)⇒ 本包 `env/roots.env`。
# ⛔ 本文件**不写死**任何本机路径 / 本机工作区清单。
def _sm_load_roots():
import os as _os
_here = _os.path.dirname(_os.path.abspath(__file__))
_cands = []
for _up in range(4):
_base = _os.path.normpath(_os.path.join(_here, *([".."] * _up)))
for _rel in (("env", "roots.env"), ("roots.env",)):
_cands.append(_os.path.join(_base, *_rel))
# 🔴 本机技能库那份兜底:工作区副本(`<ws>/.workbuddy/collab/`)靠它拿到「这台机器」的配置
# ⇒ 全机单一真源(副本目录里⛔ 不放第二份,避免各区分叉)。
_conf = _os.environ.get("CODEBUDDY_CONFIG_DIR") or _os.path.expanduser("~/.workbuddy")
_cands.append(_os.path.join(_conf, "skills", "session-mechanism", "env", "roots.env"))
for _p in _cands:
if _os.path.isfile(_p):
try:
with open(_p, encoding="utf-8") as _f:
for _ln in _f:
_ln = _ln.strip()
if _ln and not _ln.startswith("#") and "=" in _ln:
_k, _v = _ln.split("=", 1)
_os.environ.setdefault(_k.strip(), _v.strip())
except Exception:
pass
return
_sm_load_roots()
def _resolve_airoot() -> str:
"""工作区根所在的**父目录**。**DSH_AIROOT 优先**(`env/roots.env` 注入);
兜底 = `DSH_WS_ROOT` 的父目录。⛔ 不在代码里写死盘符/目录。"""
cand = (os.environ.get("DSH_AIROOT") or "").strip()
if not cand:
ws = (os.environ.get("DSH_WS_ROOT") or "").strip()
if ws:
cand = os.path.dirname(ws.rstrip("/\\"))
return os.path.normpath(cand) if cand else ""
def _discover_workspace_names(airoot: str) -> list:
"""**自动发现**要管的工作区名:扫 `AIROOT` 下装了 `.workbuddy/collab/` 的目录。"""
if not airoot or not os.path.isdir(airoot):
return []
return sorted(n for n in os.listdir(airoot)
if os.path.isdir(os.path.join(airoot, n, ".workbuddy", "collab")))
def _resolve_pyw() -> str:
"""`pythonw.exe`:优先**当前解释器**(`sys.executable`);其次宿主 env;
兜底才在同目录**探测**兄弟 `pythonw.exe`。⛔ 不写死版本目录。"""
exe = os.path.abspath(sys.executable or "")
if exe and exe.lower().endswith("pythonw.exe") and os.path.isfile(exe):
return exe
for _k in ("DSH_PYTHONW", "PYTHONW", "DSH_PYTHON"):
_v = (os.environ.get(_k) or "").strip()
if _v and os.path.isfile(_v):
return _v
_sib = os.path.join(os.path.dirname(exe), "pythonw.exe") if exe else ""
if _sib and os.path.isfile(_sib):
return _sib
return exe or "pythonw.exe"
AIROOT = _resolve_airoot()
PYW = _resolve_pyw()
# 🔴 工作区名 → 保活任务名:**由目录名推导**(`collabd-keepalive-<名>`)—— 原为写死字典 ⇒ 换机器/改名即失效。
WS = {n: "collabd-keepalive-" + n for n in _discover_workspace_names(AIROOT)}
# 看板:全局只一条;**技能包内相对定位**(⛔ 不写死绝对路径)
BOARD_PY = os.path.join(os.path.dirname(os.path.abspath(__file__)), "board.py")
BOARD_TASK = "dsh-board-keepalive"
BOARD_PORT = 20099
# 旧任务(10-05 弹窗事故的那批)⇒ 一律禁用,⛔ 不删除(可逆)
OLD_TASKS = [
"collabd-supervise-ai1net-dsh-server",
"collabd-supervise-vibe-product",
"collabd-supervise-selftest",
"dsh-board-20099",
]
HIDE = 0x08000000 # NO_WINDOW:本脚本自己起 powershell 时也不许闪窗
INTERVAL_MIN = 5
APPLY = "--apply" in sys.argv
CMD = next((a for a in sys.argv[1:] if not a.startswith("--")), "status")
def ps(script, timeout=90):
try:
r = subprocess.run(["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script],
capture_output=True, timeout=timeout, check=False, creationflags=HIDE)
return (r.stdout or b"").decode("utf-8", "replace")
except Exception as e:
return "⛔ ps 失败:%r" % (e,)
def switch_path(ws):
return os.path.join(AIROOT, ws, ".workbuddy", "collab", "supervise.switch")
def set_switch(ws, val):
p = switch_path(ws)
try:
os.makedirs(os.path.dirname(p), exist_ok=True)
with open(p, "w", encoding="utf-8", newline="\n") as f:
f.write(val)
return True
except Exception as e:
print(" ⛔ 写开关失败 %s:%r" % (p, e))
return False
def switch_of(ws):
try:
with open(switch_path(ws), encoding="utf-8", errors="replace") as f:
return "on" if f.read().strip().lower() == "on" else "off"
except Exception:
return "off"
def hb_of(ws):
import json
import time
p = os.path.join(AIROOT, ws, ".workbuddy", "collab", "logs", "supervise-heartbeat.json")
try:
with open(p, encoding="utf-8-sig") as f:
d = json.load(f)
ts = float(d.get("ts") or 0)
return int(d.get("pid") or 0), (time.time() - ts if ts else None)
except Exception:
return 0, None
def pid_alive(pid):
import ctypes
try:
h = ctypes.windll.kernel32.OpenProcess(0x1000, False, int(pid))
if not h:
return False
ctypes.windll.kernel32.CloseHandle(h)
return True
except Exception:
return False
def alive_of(ws):
pid, age = hb_of(ws)
if not pid or age is None:
return False, "无心跳"
if age > 90:
return False, "心跳停了 %.0f 分钟" % (age / 60.0)
if not pid_alive(pid):
return False, "心跳新但进程已死"
return True, "pid %d · 心跳 %.0f 秒前" % (pid, age)
def task_state(name):
st = ps("(Get-ScheduledTask | Where-Object { $_.TaskName -eq '%s' }).State" % name).strip()
return st or "未知"
def make_task(task, exe, args, workdir):
"""🔴 建保活任务:动作=**pythonw.exe**(⛔ 不是 powershell/cmd)。
🔴🔴 2026-10-05 实测补丁:`workdir` **必须是「工作区根」**,⛔ **不是脚本所在目录**。
病根:`collabd.load_cfg()` 按 `<cwd>/.workbuddy/collab/collabd.config.json` 找配置。
若把 cwd 设成**脚本目录**(`<ws>/.workbuddy/collab`)⇒ 它去找
`<ws>/.workbuddy/collab/.workbuddy/collab/collabd.config.json` ⇒ **必然找不到**
⇒ `CFG_MISSING` ⇒ **`--supervise` 直接拒跑**(实测 rc=2;任务侧记 `LastResult=1`)。
✅ 改成**工作区根** ⇒ `State=Running`、`LastResult=267009`、心跳正常(实测)。
"""
script = (
"$a = New-ScheduledTaskAction -Execute '%s' -Argument '%s' -WorkingDirectory '%s';"
"$t = New-ScheduledTaskTrigger -Once -At (Get-Date) "
"-RepetitionInterval (New-TimeSpan -Minutes %d);"
"$s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries "
"-StartWhenAvailable -MultipleInstances IgnoreNew "
"-ExecutionTimeLimit (New-TimeSpan -Minutes 2) -Hidden;"
"$p = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Highest;"
"Register-ScheduledTask -TaskName '%s' -Action $a -Trigger $t -Settings $s -Principal $p "
"-Force | Out-Null; 'ok %s'"
) % (exe, args, workdir, INTERVAL_MIN, task, task)
return ps(script).strip()
def kill_all():
"""🔴 杀干净:**先把心跳里记的 pid 杀掉**(不依赖 PowerShell 读进程 —— 那条路读不到会漏杀)。"""
n = 0
for ws in WS:
pid, _ = hb_of(ws)
if pid and pid_alive(pid):
subprocess.run(["taskkill", "/F", "/PID", str(pid)], capture_output=True,
timeout=20, check=False, creationflags=HIDE)
n += 1
out = ps("Get-CimInstance Win32_Process | Where-Object { "
"$_.CommandLine -like '*collabd.py*' -or $_.CommandLine -like '*start-supervise*' "
"-or $_.CommandLine -like '*board.py*' -or $_.CommandLine -like '*board-keeper*' } | "
"ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue; $_.ProcessId }")
n += len([x for x in out.split() if x.strip().isdigit()])
return n
def cmd_status():
print("=" * 62)
for ws in WS:
ok, why = alive_of(ws)
print(" %-22s 开关=%-3s %s %s" % (ws, switch_of(ws), "✅活" if ok else "⛔停", why))
print("")
print(" 计划任务:")
for ws, t in WS.items():
print(" %-38s = %s" % (t, task_state(t)))
print(" %-38s = %s" % (BOARD_TASK, task_state(BOARD_TASK)))
for t in OLD_TASKS:
print(" %-38s = %s(旧,应保持禁用)" % (t, task_state(t)))
print("=" * 62)
def cmd_on():
print("── 建立保活:计划任务 → pythonw collabd.py --supervise(每 %d 分钟兜底判活)──"
% INTERVAL_MIN)
for ws, task in WS.items():
script = os.path.join(AIROOT, ws, ".workbuddy", "collab", "collabd.py")
if not os.path.isfile(script):
print(" ⚠️ 跳过(副本不存在):%s" % script)
continue
print(" " + make_task(task, PYW, '"%s" --supervise' % script,
os.path.join(AIROOT, ws)))
set_switch(ws, "on")
print(" 开关 on:%s" % ws)
if os.path.isfile(BOARD_PY):
print(" " + make_task(BOARD_TASK, PYW, '"%s" --serve %d' % (BOARD_PY, BOARD_PORT),
os.path.dirname(BOARD_PY)))
for t in OLD_TASKS:
ps("Disable-ScheduledTask -TaskName '%s' -ErrorAction SilentlyContinue | Out-Null" % t)
print(" 旧任务已禁用:%d 个" % len(OLD_TASKS))
# 立刻起一次,不用等 5 分钟
for t in list(WS.values()) + [BOARD_TASK]:
ps("Start-ScheduledTask -TaskName '%s' -ErrorAction SilentlyContinue | Out-Null" % t)
print(" 已触发一次(立即生效,不必等 5 分钟)")
def cmd_off():
"""🔴 **一键全关**(用户要的那个按钮):开关+任务+进程,一次做完,并复核。"""
print("── 一键全关 ──")
for ws in WS:
set_switch(ws, "off")
print(" ① 开关 off:%s" % ws)
for t in list(WS.values()) + [BOARD_TASK] + OLD_TASKS:
ps("Disable-ScheduledTask -TaskName '%s' -ErrorAction SilentlyContinue | Out-Null" % t)
print(" ② 计划任务已禁用:%d 个" % (len(WS) + 1 + len(OLD_TASKS)))
n = kill_all()
print(" ③ 已杀进程:%d 个" % n)
print("")
print("── 复核(⛔ 不许只说『已停』就完事)──")
cmd_status()
def main():
if not APPLY and CMD in ("on", "off"):
print("(干跑)将要执行:%s —— 加 --apply 真做" % CMD)
if CMD == "off":
print(" ① 关各区开关 ② 禁用 %d 个计划任务 ③ 杀相关进程 ④ 复核"
% (len(WS) + 1 + len(OLD_TASKS)))
else:
for ws, t in WS.items():
print(" 建任务 %s → 每 %d 分钟 pythonw collabd.py --supervise" % (t, INTERVAL_MIN))
print(" 建任务 %s → pythonw board.py --serve %d" % (BOARD_TASK, BOARD_PORT))
print(" 禁用旧任务 %d 个;已发现区的开关置 on" % len(OLD_TASKS))
return 0
if CMD == "status":
cmd_status()
elif CMD == "on":
cmd_on()
elif CMD == "off":
cmd_off()
else:
print(__doc__)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())