#!/usr/bin/env python # -*- coding: utf-8 -*- """🔴🔴 **常驻程序唯一管理入口** `collabctl.py `(2026-10-05 立)。 **为什么要有这个东西**(用户原话:「管理个常驻程序都乱七八糟 没个章法」): 起进程的路径曾散在**五处**,各起各的 ⇒ 谁都能拉起常驻、谁都管不住: ① 计划任务 `collabd-supervise-*` → `start-supervise.ps1` ② 计划任务 `dsh-board-20099` → `board-keeper.ps1`(名字里没有 `collabd`,极容易被漏) ③ 宿主钩子 `UserPromptSubmit` → `maybe_ensure_supervise()` ④ 跑 `selftest.py` 时 spawn 的常驻(**跑完不回收**) ⑤ `collabd.py` 内部跨区补起 ⇒ 屏幕被黑窗口反复打断,杀进程都止不住(杀完又被另一条路径拉起)。 **章法(本文件是唯一事实源):** 1、🔴 **开关是总闸**:`<工作区>/.workbuddy/collab/supervise.switch`,内容 `on` 才允许起, 缺失/读不到/非 `on` ⇒ **关**(fail-safe)。 2、🔴 **只有本入口能起进程**:`on`/`ensure` 走这里,`off` 也走这里;其余路径一律**先查开关**。 3、🔴 **载体=计划任务**(2026-10-05 改):`on`/`ensure` 走**计划任务 → `pythonw.exe` → `--supervise`**。 ⛔ 旧口径("不依赖计划任务、直接 pythonw 起")**已被实测推翻**——见下面 `KEEPALIVE_TASKS` 注释: 会话树里起的进程落在作业对象里,会话一收工就跟着死。 ⛔ 动作**不许用 `powershell.exe`**(控制台程序 ⇒ 闪窗,10-05 弹窗事故真因)。 4、🔴 **一个工作区一条常驻 + 看板全局一条**,起之前**先盘点**(`status` 就是盘点结果)。 5、🔴 **存活判据=`pid 活 ∧ 心跳新鲜(<90 s)`**(⛔ 不是"进程在列表里")—— 见过「进程在、心跳停了」的僵尸(P0-70),只看进程会假绿。 用法(在**任意**目录都能跑,它按下面 `WORKSPACES` 逐个处理): pythonw collabctl.py status --out <文件> # 只读盘点 pythonw collabctl.py ensure --out <文件> # 🔴 **幂等收敛**:开关 on⇒补齐缺失的;off⇒全停 pythonw collabctl.py on --out <文件> # 建保活任务 + 触发 + 开关 on pythonw collabctl.py off --out <文件> # 🔴 **一键全关**:开关 off + 禁用任务 + 杀进程(幂等) 🔴 **`--out` 是必需的取证手段**:本机一律用 `pythonw.exe` 跑(⛔ 用 `python.exe` 会闪黑窗), 而 `pythonw` 没有 stdout ⇒ **不落盘就完全看不到结果**(判据不可信=没章法)。 ⛔ 任何异常都不许让它崩(它是运维入口,崩了就等于失控)。 """ import ctypes import json import os import subprocess import sys import time from ctypes import wintypes # ---------------------------------------------------------------- 常量 # 🔴🔴 2026-10-05 **两条旧结论被实测推翻**(都在本机跑出来的,见 skill 的 pitfalls P0-71): # ① **`CREATE_BREAKAWAY_FROM_JOB` 在本机被拒** —— 带上它 `subprocess` 直接 # `PermissionError(13, 拒绝访问)`(作业对象不允许脱离)。 # 旧注释曾说"它是能不能长期活着的分水岭",**错了**。 # ② **即便起得来也不等于能常驻** —— 四组对照(任务 Highest/Interactive、Limited/S4U、 # Highest/S4U、subprocess 直起)**全部 IN-JOB**;会话树里起的进程**跟着会话树一起死**。 # ⇒ 真载体**只有计划任务**(父链与会话树无关:父进程已退出、链断在自己身上 —— 实测)。 # ⇒ 因此:**`HIDE`(带 breakaway)只用于"起进程"**,且必须留 `HIDE_NO_BREAK` 退路; # **只读查询一律用 `HIDE_NO_BREAK`**(带上 breakaway 会把查询也一起弄失败)。 HIDE = (0x08000000 # CREATE_NO_WINDOW(⛔ 没有它 ⇒ 闪黑窗) | 0x00000008 # DETACHED_PROCESS | 0x00000200 # CREATE_NEW_PROCESS_GROUP | 0x01000000) # CREATE_BREAKAWAY_FROM_JOB # 🔴 **必须准备不带 breakaway 的退路**:实测在本机带上它 ⇒ `PermissionError(13, 拒绝访问)` # ⇒ **起进程直接失败**(2026-10-05 实测)。⇒ 起法=**先试带、被拒退回不带**; # **只读查询(`_ps`)一律直接用它**,⛔ 别试 breakaway(试了就是白失败一次)。 HIDE_NO_BREAK = 0x08000000 | 0x00000008 | 0x00000200 AIROOT = "E:/ProgramData/AIProject" HB_FRESH_S = 90 # 🔴 心跳新鲜阈值(与 `collabd.supervise_alive()` 同口径) # 🔴 **登记制**:要管哪些工作区,只在**这一处**登记(⛔ 别在各处各写一份路径清单)。 WORKSPACES = [ "E:/ProgramData/AIProject/ai1net-dsh-server", "E:/ProgramData/AIProject/vibe-product", "E:/ProgramData/AIProject/contentm_agent", ] # 看板:全局只一条(⛔ 不是每区一条) BOARD_PY = "E:/ProgramData/.workbuddy/skills/session-mechanism/scripts/board.py" BOARD_PORT = 20099 # 🔴🔴 2026-10-05 改(用户定案:「**常驻崩了,拉起来这个事儿,你不能也用计划任务起个程序吗?**」): # **载体改了** —— 旧设计(章法第 3 条「⛔ 不依赖计划任务、`on` 时直接 pythonw 起」) # **被实测推翻**:会话树里 `Popen` 起的进程**落在本进程的作业对象里** # ⇒ 会话树一回收连着一起死(「有些区能常驻、有些不能」就是这么来的)。 # ✅ 唯一合格载体=**计划任务**(父链根与会话树无关 —— 实测:父进程已退出、链断在自己身上)。 # ✅ 动作=**`pythonw.exe`**(GUI 子系统 ⇒ 不弹窗;⛔ 不是 `powershell.exe`,那是控制台程序)。 # ⇒ 所以本入口的 `on`/`ensure` 现在**建/启用这两组任务**,`off` **禁用它们+杀进程**。 KEEPALIVE_TASKS = { # 新形态:计划任务 → pythonw collabd.py --supervise "E:/ProgramData/AIProject/ai1net-dsh-server": "collabd-keepalive-ai1net-dsh-server", "E:/ProgramData/AIProject/vibe-product": "collabd-keepalive-vibe-product", "E:/ProgramData/AIProject/contentm_agent": "collabd-keepalive-contentm_agent", } BOARD_TASK = "dsh-board-keepalive" # 旧任务:一律**禁用**(⛔ 不删除,可逆)。它们是弹窗事故的来源(动作=powershell.exe)。 SCHED_TASKS = [ "collabd-supervise-ai1net-dsh-server", "collabd-supervise-vibe-product", "collabd-supervise-selftest", # 🔴 2026-10-07 补:本区改名前的旧任务。它**动作指向已不存在的 # `.../agent-product/.workbuddy/collab/supervise-launch.py`**,状态恒 Ready # ⇒ 到点必失败的静默死任务。先前漏网的原因=**本区不在 WORKSPACES 名单里**, # 而本清单只列已知区 ⇒ 改名残留的旧任务谁都没扫到(判据盲区,同族见本期 memory)。 "collabd-supervise-agent-product", "dsh-board-20099", ] _OUT = None ONLY_WS = None # 🔴 2026-10-07:非 None ⇒ 本次调用已用 `--ws` 收敛到单区(⛔ 跳过看板) def _p(*a): """🔴 输出一律双写:stdout + `--out` 文件(用 pythonw 跑时只有后者能看到)。""" s = " ".join(str(x) for x in a) try: print(s) except Exception: pass if _OUT: try: _OUT.write(s + "\n") _OUT.flush() except Exception: pass def _pyw() -> str: """🔴 解释器一律 `pythonw.exe`(GUI 子系统,Windows 不分配控制台窗口)。""" d = os.path.dirname(os.path.abspath(sys.executable)) p = os.path.join(d, "pythonw.exe") return p if os.path.isfile(p) else sys.executable def _ps(script: str) -> str: r"""跑一段 PowerShell,返回 stdout(失败返回空串,⛔ 不抛)。 🔴🔴 2026-10-05 **逐项实测**(四组对照,见 skill pitfalls P0-71): · `CREATE_BREAKAWAY_FROM_JOB` ⇒ ⛔ `PermissionError(13)`(被拒) · `NO_BREAK`(NO_WINDOW|DETACHED|NEW_GROUP)⇒ rc=0 但 **stdout 全空** · **`creationflags=0`** ⇒ ✅ **rc=0 · stdout=`Ready`** ⇒ 结论(反直觉但实测如此): ① **⛔ 只读查询不许带 `DETACHED_PROCESS`/`NEW_PROCESS_GROUP`** —— 会把回显吞掉; ② **⛔ 更不许带 breakaway** —— 直接被拒; ③ ✅ 查询一律 **`creationflags=0`**。它是短命进程且 `capture_output` 走管道, **不会弹窗**(弹窗来自"计划任务里跑控制台程序",不是这里)。 🔴🔴 2026-10-07 **订正上面 ③ 的后半句**:`creationflags=0` 仍会**闪窗**。 证据链:本机 `collabctl.py status` 由 `pythonw.exe` 起(⚠️ 本入口按章法就必须用 pythonw, pythonw = GUI 子系统 ⇒ **父进程没有控制台**)⇒ 子进程 `powershell.exe` 是**控制台程序** 且**未带 `CREATE_NO_WINDOW`** ⇒ Windows 必须给它**新分配一个控制台** ⇒ **弹窗**。 实测读数:`collabctl.py status` 自报的进程清单里就挂着它自己那条 `powershell.exe -NoProfile -NonInteractive -Command "Get-CimInstance Win32_Process …"`。 ✅ **修法=只加 `CREATE_NO_WINDOW`(0x08000000)** —— ⛔ 不带 `DETACHED`/`NEW_GROUP` (那两样才是吞回显的元凶,见上面 ①/②);`capture_output` 走管道不受影响。 ⚠️ 同族检查:`collabd.py` 的 4 处 powershell/netstat 调用**都已带** `0x08000000`, **全族只有本函数漏了** ⇒ 收口口径=凡 spawn `powershell.exe`/`tasklist`/`netstat` 等**控制台程序**,一律补 `CREATE_NO_WINDOW`。 """ try: r = subprocess.run( ["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script], capture_output=True, timeout=60, check=False, stdin=subprocess.DEVNULL, creationflags=0x08000000) # CREATE_NO_WINDOW(⛔ 缺了会闪窗) return (r.stdout or b"").decode("utf-8", "replace") except Exception: return "" def switch_path(ws: str) -> str: return os.path.join(ws, ".workbuddy", "collab", "supervise.switch") def switch_of(ws: str) -> str: """读开关:🔴 缺失/读不到/非 `on` ⇒ 一律 `off`(fail-safe)。""" 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 set_switch(ws: str, val: str) -> None: try: d = os.path.dirname(switch_path(ws)) if not os.path.isdir(d): os.makedirs(d, exist_ok=True) with open(switch_path(ws), "w", encoding="utf-8", newline="\n") as f: f.write("on" if val == "on" else "off") except Exception as e: _p(" ⛔ 写开关失败 %s:%r" % (ws, e)) def _pid_alive(pid) -> bool: """🔴 Windows 上判 pid 存活:**只能用 OpenProcess**,⛔ 绝不能用 `os.kill(pid, 0)` —— CPython 在 Windows 上 `os.kill` 对非 CTRL 信号会**直接 TerminateProcess(杀进程)**。""" try: pid = int(pid) except Exception: return False h = ctypes.windll.kernel32.OpenProcess(0x1000, False, pid) # QUERY_LIMITED_INFORMATION if not h: return False ctypes.windll.kernel32.CloseHandle(h) return True def hb_of(ws: str): """读该区常驻心跳,返回 `(pid, 心跳时间戳, 距离现在秒数 or None)`。""" p = os.path.join(ws, ".workbuddy", "collab", "logs", "supervise-heartbeat.json") try: with open(p, encoding="utf-8-sig") as f: # 🔴 utf-8-sig:防 BOM 把 json 顶坏 d = json.load(f) ts = float(d.get("ts") or 0) return int(d.get("pid") or 0), ts, (time.time() - ts if ts else None) except Exception: return 0, 0, None def alive_of(ws: str): """🔴 存活判据(章法第 5 条):`pid 活 ∧ 心跳新鲜`。返回 `(bool, 说明)`。""" pid, ts, age = hb_of(ws) if not pid: return False, "无心跳文件" if age is None: return False, "心跳无时间戳" if age > HB_FRESH_S: return False, "心跳停了 %.0f 分钟(pid %d %s)" % (age / 60.0, pid, "还活着" if _pid_alive(pid) else "已死") if not _pid_alive(pid): return False, "心跳新但 pid %d 已死" % pid return True, "pid %d · 心跳 %.0f 秒前" % (pid, age) def _spawn(pyw, args, env=None): """🔴 起进程的**唯一封装**:先试 `HIDE`(带 breakaway),被作业对象拒绝 ⇒ 退回 `HIDE_NO_BREAK`。 ⛔ 别在各处各写一遍 Popen(那样改一处漏一处,10-05 弹窗事故就是这个形状)。""" kw = dict(creationflags=HIDE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) if env: kw["env"] = env try: return subprocess.Popen([pyw, "-u"] + args, **kw) except PermissionError: kw["creationflags"] = HIDE_NO_BREAK return subprocess.Popen([pyw, "-u"] + args, **kw) def board_alive() -> bool: """🔴 看板存活判据=**端口在监听**(2026-10-05 改)。 ⛔ 原来靠 `list_procs()` 扫命令行 —— 实测在受保护环境里它**恒返回空** ⇒ 每次 `on` 都以为"看板没起" ⇒ **重复起看板**(启动不稳的真因之一)。 端口是端到端的:起没起来、监听没监听,一试就知道。""" import socket s = socket.socket() s.settimeout(1.0) try: return s.connect_ex(("127.0.0.1", BOARD_PORT)) == 0 except Exception: return False finally: try: s.close() except Exception: pass def list_procs(): """🔴 盘点:只看**与本机制有关**的进程(python + 那两个 keeper 脚本)。 ⚠️ 实测在受保护环境里**常返回空** ⇒ ⛔ 不许拿它当存活判据 (存活一律问心跳/端口;它只用于 `kill_all` 兜底扫地)。 🔴🔴 2026-10-05 加:**本清单必须连"技能目录那份副本"也扫到** —— 实测残留过**三个孤儿 supervisor**(命令行是 `pythonw -u <技能目录>/scripts/collabd.py --supervise`, 父进程已退出、且**不写任何工作区心跳**)⇒ 它们是**影子实例**:抢锁、吃资源,却对 `alive_of()` 完全不可见(心跳早被任务起的那份占了)。 ⛔ 按用户口径「**只有看板共用,其余各区独立**」⇒ **技能目录那份 `collabd.py` 本就不该在跑** (看板共用是唯一例外,它走 `board-launch.py`)。 """ out = [] txt = _ps( "Get-CimInstance Win32_Process | ForEach-Object { " "$cl=[string]$_.CommandLine; " "if ($_.Name -like 'python*' -or $cl -match 'start-supervise|board-keeper') " "{ $_.Name + '|' + $_.ProcessId + '|' + $cl } }") for ln in (txt or "").splitlines(): ln = ln.strip() if not ln or "|" not in ln: continue nm, pid, cl = ln.split("|", 2) out.append((nm.strip(), pid.strip(), cl.strip())) return out def list_tasks(): out = [] for t in list(SCHED_TASKS) + list(KEEPALIVE_TASKS.values()) + [BOARD_TASK]: # 🔴 用 `Where-Object` 过滤,⛔ 不用 `-TaskName` 直查:实测后者在受保护环境里 # 常返回空 ⇒ status 会显示"不存在" ⇒ **入口自己假绿**(判据不可信=没章法)。 st = _ps("(Get-ScheduledTask | Where-Object { $_.TaskName -eq '%s' }).State" % t).strip() # 🔴 fail-safe:**读不到 ≠ 不存在**。 out.append((t, st or "未知(读不到 ⇒ ⛔ 别当已禁用,用 PowerShell 工具核)")) return out def kill_all(): """杀掉所有与本机制有关的进程(幂等)。 🔴 **先杀心跳里记的 pid**(不依赖 PowerShell —— 那条路实测读不到进程 ⇒ 会漏杀 ⇒ 「停止」看着成功了其实还活着,正是 10-05 弹窗事故止不住的形状)。 🔴🔴 2026-10-05 **修一个大 bug**:原先这里用 `creationflags=HIDE` —— 而 `HIDE` 里含 `CREATE_BREAKAWAY_FROM_JOB`,本机实测**必被拒**(`PermissionError(13)`)⇒ 每次 `taskkill` 都抛异常、被 `except: pass` 吞掉 ⇒ **返回 0 却一个也没杀** ⇒ 「停止了」是假的! ✅ 正解:`taskkill` 是**短命控制台程序且 `capture_output` 走管道** ⇒ **不会弹窗** ⇒ `creationflags=0` 即可(⛔ 同样不许带 `DETACHED`/`BREAKAWAY`,会把结果吞掉或直接失败)。 🔴🔴 2026-10-05 **再修一个大 bug**:`list_procs()` 的过滤是 `python*` ⇒ **会连本进程(及调用方 python) 一起匹配到** ⇒ 实测把调用它的 python 杀了(症状=脚本 `rc=1` 且零输出)。⇒ 必须先算**保护集** (自己 + 所有祖先 pid),命中即跳过。 """ n = 0 protect = _self_and_ancestors() for ws in WORKSPACES: pid, _ts, _age = hb_of(ws) if pid and _pid_alive(pid) and pid not in protect: if _kill_pid(pid): n += 1 if ONLY_WS: # 🔴 收敛模式(`--ws`):只杀本区心跳里记的那个 pid,⛔ 不做全机扫描。 # 2026-10-08 实测事故:全机扫描会连别的区与看板的进程一起杀 # (`off --ws contentm_agent` 一次杀掉 5 个进程,其中 3 个属他区/看板)。 return n for nm, pid, cl in list_procs(): try: ipid = int(pid) except Exception: continue if ipid in protect: # ⛔ 自己 + 祖先 continue if "collabctl.py" in cl: # ⛔ 同门工具 continue if "Get-CimInstance Win32_Process" in cl: # ⛔ 正在列举进程的那个 ps continue if _kill_pid(pid): n += 1 return n def _self_and_ancestors() -> set: """返回**本进程 + 所有祖先 pid**(🔴 防 `kill_all` 把自己/调用方杀掉 —— 实测踩过)。""" s = {os.getpid()} pid = os.getpid() for _ in range(20): # 防环,最多上溯 20 层 ppid = _parent_of(pid) if not ppid or ppid in s or ppid <= 4: break s.add(ppid) pid = ppid return s def _parent_of(pid): """取父 pid(用 PS `Win32_Process`,本机唯一可靠的路)。失败返回 0。""" try: out = _ps("(Get-CimInstance Win32_Process -Filter \"ProcessId=%d\").ParentProcessId" % int(pid)).strip() return int(out) if out.isdigit() else 0 except Exception: return 0 def _kill_pid(pid) -> bool: """杀一个 pid:`taskkill /F` ⇒ 成功返回 True。 🔴 **必须 `creationflags=0`** —— 理由见 `kill_all()` docstring(带 breakaway ⇒ 必被拒 ⇒ 静默漏杀)。""" try: r = subprocess.run(["taskkill", "/F", "/PID", str(pid)], capture_output=True, timeout=20, check=False, stdin=subprocess.DEVNULL, creationflags=0) return r.returncode == 0 except Exception: return False def disable_tasks(): """禁用**全部**旧形态任务 + 本轮涉及区的保活任务。⚠️ 带 `--ws` ⇒ ⛔ 不碰全局看板。 ⚠️ **旧形态清单(`SCHED_TASKS`)不做区过滤,一律全禁**:它的语义就是「旧形态死名单」, 按名单逐条 `Disable-ScheduledTask`;不在册的区是 no-op(已经是 Disabled), 而在册的我方遗留任务(改名前的旧名)**必须**能被禁掉 —— 按区名后缀过滤会正好漏掉它 (`collabd-supervise-agent-product` 不以新工作区名结尾 ⇒ 过滤后一条都匹配不上)。 """ for t in list(SCHED_TASKS) + list(KEEPALIVE_TASKS.values()) + ([] if ONLY_WS else [BOARD_TASK]): _ps("Disable-ScheduledTask -TaskName '%s' -ErrorAction SilentlyContinue | Out-Null" % t) def ensure_keepalive_tasks(): """🔴 **建/启用保活任务**(新的载体形态):计划任务 → `pythonw.exe` → `--supervise`。 ⛔ 动作**必须是 `pythonw.exe`**(GUI 子系统 ⇒ 不分配控制台 ⇒ 不弹窗); ⛔ 不许用 `powershell.exe` / `cmd.exe`(控制台程序 ⇒ 闪窗,10-05 事故真因)。 ⚠️ 建完**立刻触发一次**,不用等下一个周期。 """ pyw = _pyw() made = [] for ws, task in KEEPALIVE_TASKS.items(): # 🔴 常驻**经启动器起**(`supervise-launch.py`),⛔ 不许直起 `collabd.py`。 # 直起 ⇒ 任务环境**没有 `CODEBUDDY_CONFIG_DIR`** ⇒ `_wb_db()` 落到 # `~/.workbuddy/workbuddy.db`(**0 字节空库**)⇒ `_all_sessions_idle()` 每轮报 # `no such table: sessions` ⇒ 闸② 恒判"有会话在跑" ⇒ **检查会话再也不建**。 # (旧看守 `start-supervise.ps1` 里本有 `$env:CODEBUDDY_CONFIG_DIR=...`, # 我改成两层形态时丢了这句 —— 这就是 P0-73。) # ⚠️ 启动器里 `sys.argv` 自己补 `--supervise` ⇒ **任务参数里 ⛔ 不要再写**。 launcher = os.path.join(ws, ".workbuddy", "collab", "supervise-launch.py").replace("\\", "/") cd = os.path.join(ws, ".workbuddy", "collab", "collabd.py").replace("\\", "/") if not os.path.isfile(cd): _p(" ⚠️ 副本不存在,跳过:%s" % cd) continue if not os.path.isfile(launcher): _p(" ⚠️ 缺常驻启动器,跳过:%s(先跑 deploy_code.py 分发)" % launcher) continue # 🔴🔴 2026-10-05 实测补丁:`-WorkingDirectory` **必须是「工作区根」**, # ⛔ 不是脚本所在目录。病根:`collabd.load_cfg()` 按 # `/.workbuddy/collab/collabd.config.json` 找配置;cwd 设成脚本目录 # ⇒ 去找 `/.workbuddy/collab/.workbuddy/collab/…` ⇒ **找不到** ⇒ `CFG_MISSING` # ⇒ `--supervise` 拒跑(实测 `rc=2`;任务侧 `LastTaskResult=1`)。 # ✅ 工作区根 ⇒ `State=Running` · `LastTaskResult=267009` · 心跳正常(实测)。 wd = ws.replace("/", "\\") # 🔴 **`ExecutionTimeLimit=0`(无时限)**:`--supervise` 是**长驻**进程,⛔ 不能设 2 分钟 # (那会被调度器到点掐死 ⇒ 每 2 分钟重建一次 = 又一种"抖动")。 _ps( "$a=New-ScheduledTaskAction -Execute '%s' -Argument '\"%s\"' -WorkingDirectory '%s';" "$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 '%s' -Action $a -Trigger $t -Settings $s -Principal $pr -Force | Out-Null" % (pyw, launcher, wd, task)) made.append(task) _p(" ✓ 保活任务就绪:%s" % task) if os.path.isfile(BOARD_PY) and not ONLY_WS: # 🔴🔴 2026-10-05 修:看板**必须经启动器起**(`board-launch.py`),⛔ 不许直起 `board.py`。 # 直起 ⇒ 崩溃重启循环、20099 从没绑上。真因(抓到的现场输出): # `⚠️ collabd:未找到部署配置(COLLABD_CONFIG 未设 …)⇒ 已拒跑` # `board.py` 顶层用 `COLLABD_CONFIG` 定位配置(它要 import collabd),而 # **计划任务的动作里没有 env 字段** ⇒ 这个变量只能由启动器在**进程内**设。 # ⇒ 动作=`pythonw.exe board-launch.py`,工作目录=脚本目录(启动器自己定位 board.py)。 # 🔴 **`ExecutionTimeLimit=0`(无时限)** —— 看板是**长驻服务**,⛔ 不能像常驻那样设 2 分钟 # (那会把服务到点掐死 ⇒ 又变成"每 5 分钟重建一次")。 launcher = os.path.join(os.path.dirname(BOARD_PY), "board-launch.py").replace("\\", "/") if os.path.isfile(launcher): wd = os.path.dirname(BOARD_PY.replace("\\", "/")) _ps( "$a=New-ScheduledTaskAction -Execute '%s' -Argument '\"%s\"' -WorkingDirectory '%s';" "$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 '%s' -Action $a -Trigger $t -Settings $s -Principal $pr -Force | Out-Null" % (pyw, launcher, wd, BOARD_TASK)) made.append(BOARD_TASK) _p(" ✓ 看板任务就绪:%s(经启动器 board-launch.py)" % BOARD_TASK) else: _p(" ⚠️ 缺看板启动器,跳过:%s(⛔ 直起 board.py 会因无 COLLABD_CONFIG 而拒跑)" % launcher) return made def trigger_tasks(): """立刻触发当前处于启用态的相关任务(常驻本体自带单例让位,⛔ 不会叠加成两条)。""" n = 0 # ⚠️ 带 `--ws` ⇒ ⛔ 不碰全局看板任务(看板是共享件,不属任何单区)。 _ts = list(KEEPALIVE_TASKS.values()) + ([] if ONLY_WS else [BOARD_TASK]) for t in _ts: st = _ps("(Get-ScheduledTask | Where-Object { $_.TaskName -eq '%s' }).State" % t).strip() if st and st.lower() != "disabled": _ps("Start-ScheduledTask -TaskName '%s' -ErrorAction SilentlyContinue | Out-Null" % t) n += 1 return n def start_all(): """🔴 起进程:**唯一通道=计划任务**(⛔ 不在本进程里 spawn —— 那会被作业对象带走)。 2026-10-05 改:旧实现是 `_spawn(pyw, [...])` 就地起 ⇒ 实测会话一收工就死。 ⇒ 现在改为「建/启用任务 + 触发」,**进程由任务调度器创建**(父链与会话树无关)。 """ made = ensure_keepalive_tasks() n = trigger_tasks() _p(" → 已触发 %d 条保活任务(进程由调度器创建,⛔ 不落在本会话作业对象里)" % n) return made # ---------------------------------------------------------------- 命令 def cmd_status(): _p("=" * 66) _p("开关(🔴 缺失/读不到 ⇒ off)") for ws in WORKSPACES: _p(" %-52s = %s" % (ws, switch_of(ws))) _p("") _p("常驻存活(判据=pid 活 ∧ 心跳 <%ds)" % HB_FRESH_S) for ws in WORKSPACES: ok, why = alive_of(ws) _p(" %-52s %s %s" % (ws, "✅ 活" if ok else "⛔ 停", why)) _p("") _p("看板(⛔ 全局只一条,端口 %d)" % BOARD_PORT) _p(" %s" % ("✅ 在监听" if board_alive() else "⛔ 没监听")) _p("") _p("进程(与本机制有关,⚠️ 受保护环境里常读不到 ⇒ ⛔ 别当判据)") ps = list_procs() if not ps: _p(" (无)") for nm, pid, cl in ps: _p(" %-14s %-8s %s" % (nm, pid, cl[:96])) _p("") _p("计划任务(新形态=保活载体;旧形态=一律禁用)") for t, st in list_tasks(): _p(" %-44s = %s" % (t, st)) _p("=" * 66) def cmd_off(): _p("── 一键全关:关开关 → 禁用全部任务 → 杀进程 ──") for ws in WORKSPACES: set_switch(ws, "off") _p(" 开关 off:%s" % ws) disable_tasks() _nb = 0 if ONLY_WS else 1 _p(" 计划任务已禁用:新 %d + 旧 %d + 看板 %d(%s)" % (len(KEEPALIVE_TASKS), len(SCHED_TASKS), _nb, "收敛到单区 ⇒ ⛔ 不碰看板" if ONLY_WS else "全区")) n = kill_all() _p(" 已杀进程:%d 个" % n) _p("✅ 全停完成(可反复跑,幂等)") def cmd_on(): _p("── 启动:开开关 → 建/启用保活任务 → 触发 ──") for ws in WORKSPACES: set_switch(ws, "on") _p(" 开关 on:%s" % ws) for t in start_all(): _p(" 已就绪:%s" % t) _p("✅ 启动完成(载体=计划任务;进程由调度器拉起,⛔ 不落在本会话作业对象里)") def cmd_ensure(): """🔴 **幂等收敛**:把实况收敛到开关声明的状态,可反复跑、结果一致。 —— 这是「启动运行稳定」的核心:⛔ 不看当前进程数,只按「开关说什么」收敛。""" _p("── ensure:按开关收敛(on⇒补齐缺失的,off⇒全停)──") on_ws = [w for w in WORKSPACES if switch_of(w) == "on"] if not on_ws: _p(" 两区开关都 off ⇒ 走全停") cmd_off() return disable_tasks() # 旧形态任务:见一次禁一次(新形态由 start_all 重建) for ws in WORKSPACES: if switch_of(ws) != "on": ok, why = alive_of(ws) if ok: _p(" 开关 off 但还在跑 ⇒ 停:%s(%s)" % (ws, why)) else: _p(" 开关 off 且已停 ✓:%s" % ws) for t in start_all(): _p(" 已就绪:%s" % t) _p("✅ ensure 完成(幂等,可反复跑)") def main(): args = sys.argv[1:] out = None if "--out" in args: i = args.index("--out") if i + 1 < len(args): out = args[i + 1] del args[i:i + 2] # 🔴🔴 2026-10-07 新增:`--ws <工作区>` = 把作用面**收敛到一个工作区**。 # 动机:红线「⛔ 不许一区去起另一区」。原先 `on`/`ensure` 一律遍历全部 WORKSPACES + # 顺带重建全局看板任务 ⇒ 在本区起常驻时**会顺手动别的区**(重建它们的任务/触发它们)。 # ⇒ 带 `--ws` 时:名单只留该区 + **跳过看板**(看板是全局共享件,不属任何单区)。 only = None if "--ws" in args: i = args.index("--ws") if i + 1 < len(args): only = args[i + 1].replace("\\", "/").rstrip("/").lower() del args[i:i + 2] _a = args[0] if args else "status" if only is not None and _a not in ("status", "off", "on", "ensure", ""): _a = "status" if only is not None: _keep = [w for w in WORKSPACES if w.replace("\\", "/").rstrip("/").lower() == only] if not _keep: print("⛔ --ws 不在管辖名单里:%s" % only) print(" 名单:" + " | ".join(WORKSPACES)) return 2 global ONLY_WS ONLY_WS = _keep[0] WORKSPACES[:] = _keep for _k in [k for k in KEEPALIVE_TASKS if k.lower() != only]: del KEEPALIVE_TASKS[_k] a = args[0] if args else "status" global _OUT if out: try: _OUT = open(out, "w", encoding="utf-8", newline="\n") except Exception as e: print("⛔ 打不开 --out %s:%r" % (out, e)) return 1 try: if a == "status": cmd_status() elif a == "off": cmd_off() elif a == "on": cmd_on() elif a == "ensure": cmd_ensure() else: _p(__doc__) return 1 return 0 finally: if _OUT: try: _OUT.close() except Exception: pass if __name__ == "__main__": try: sys.exit(main()) except Exception as e: _p("⛔ collabctl 异常:%r" % (e,)) sys.exit(1)