269 lines
12 KiB
Python
269 lines
12 KiB
Python
# -*- 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
|
|||
|
|
|
|||
|
|
PYW = r"E:\ProgramData\.workbuddy\binaries\python\versions\3.13.12\pythonw.exe"
|
|||
|
|
AIROOT = r"E:\ProgramData\AIProject"
|
|||
|
|
|
|||
|
|
# 🔴 登记制:工作区 → 保活任务名(⛔ 只在这里写一份)
|
|||
|
|
WS = {
|
|||
|
|
"ai1net-dsh-server": "collabd-keepalive-ai1net-dsh-server",
|
|||
|
|
"vibe-product": "collabd-keepalive-vibe-product",
|
|||
|
|
}
|
|||
|
|
# 看板:全局只一条
|
|||
|
|
BOARD_PY = r"E:\ProgramData\.workbuddy\skills\session-mechanism\scripts\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())
|