一、用户令(逐字) 「把关停程序改为 传参数的形式,传那些就关那些,每次都出问题这里」 二、根因(两处,都不在"能不能停"上) 1. 🔴 **双击入口只有"全部停止"**:`会话机制-一键开关.bat`(工作区根 + 桌面各一份)原先只有 1 全部启动/2 全部停止/3 看状态 ⇒ **没有任何"只停某些"的口子** ⇒ 每次关停都会**连全局看板、 连别的区一起停** ⇒ 这正是用户说的「每次都出问题这里」。 2. 🔴 **`--ws` 实测只认全路径**:原判据拿参数与 `E:/…/vibe-product` 整串比 ⇒ 传短名 `vibe-product` **一个都匹配不上**,直接报「不在管辖名单里」并停手(用户要"传哪些"时当然写短名)。 3. ⚠️ 顺带核实:管辖名单里的 `contentm_agent` **不是陈旧项** —— 该工作区已于 2026-10-08 由 `content_marketing_agent` 改名为 `contentm_agent`(旧目录留 `.RETIRED-20261008`)⇒ ⛔ 没动它。 三、改法 1. `collabctl.py`: · `--ws` 支持**多个**(`--ws a,b`,或多次 `--ws a --ws b`); · 区名匹配改为**全路径或短名都认**(`_k1` 全路径/`_k2` 末段),并对不在名单里的项**逐条提示跳过**; · `cmd_off` 头两行改成**先报作用面**(「只关这些 …」/「全部(含全局看板)」), 收尾文案由「一键全关」改为「关停」,避免与"只关一部分"混淆。 2. `会话机制-一键开关.bat`(**两份同步**:工作区根 + 桌面): · 菜单加 `4 = 只停指定工作区(可多个用逗号分隔,⛔ 不动看板与其它区)`; · 新增**免交互参数模式**:`会话机制-一键开关.bat off vibe-product,contentm_agent`(⛔ 不动看板)。 四、验证(只读) · `status --ws "vibe-product,contentm_agent"` ⇒ 作用面**只收敛到这两个**(看板段为空=未触碰)✓; · `status --ws "E:/ProgramData/AIProject/contentm_agent"` ⇒ 旧的全路径写法**仍可用** ✓; · `selftest.py` rc=0 **PASS 106 / FAIL 0**;manifest 76 份、语法失败 0。 · ⛔ 不需要分发/重启(`collabctl.py` 由 `.bat` 与计划任务直读技能目录)。
664 lines
33 KiB
Python
664 lines
33 KiB
Python
#!/usr/bin/env python
|
||
# -*- coding: utf-8 -*-
|
||
"""🔴🔴 **常驻程序唯一管理入口** `collabctl.py <status|on|off|ensure>`(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 <pid> -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()` 按
|
||
# `<cwd>/.workbuddy/collab/collabd.config.json` 找配置;cwd 设成脚本目录
|
||
# ⇒ 去找 `<ws>/.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("── 关停:关开关 → 禁用任务 → 杀进程 ──")
|
||
if WORKSPACES and ONLY_WS:
|
||
_p(" 作用面:**只关这些** %s(⛔ 不碰看板、⛔ 不碰未列出的区)"
|
||
% " , ".join(w.rstrip("/").split("/")[-1] for w in WORKSPACES))
|
||
else:
|
||
_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
|
||
_onlys = []
|
||
# 🔴 2026-10-08 用户令:「把关停程序改为传参数的形式,传哪些就关哪些」——
|
||
# 原先 `--ws` **只收一个区**,而双击入口(`.bat`)**只给"全部停止"** ⇒ 每次关停都全停
|
||
# (连全局看板、连别的区一起停)⇒ 用户原话「每次都出问题这里」。
|
||
# ⇒ 现支持 **多个**:`--ws a,b`(逗号分隔)或多次 `--ws a --ws b`。
|
||
while "--ws" in args:
|
||
i = args.index("--ws")
|
||
if i + 1 < len(args):
|
||
for _x in str(args[i + 1]).replace("\\", "/").split(","):
|
||
_x = _x.strip().rstrip("/").lower()
|
||
if _x:
|
||
_onlys.append(_x)
|
||
del args[i:i + 2]
|
||
if _onlys:
|
||
only = _onlys[0]
|
||
_a = args[0] if args else "status"
|
||
if only is not None and _a not in ("status", "off", "on", "ensure", ""):
|
||
_a = "status"
|
||
if _onlys:
|
||
# 🔴 2026-10-08 实测修:原判据只比**全路径**(`E:/…/vibe-product`),
|
||
# 传短名 `vibe-product` 会**一个都匹配不上** ⇒ 报「不在管辖名单里」而停手
|
||
# (用户口径「传哪些就关哪些」→ 他当然写短名)⇒ 现**全路径与短名都认**。
|
||
def _k1(w):
|
||
return str(w).replace("\\", "/").rstrip("/").lower()
|
||
|
||
def _k2(w):
|
||
return _k1(w).split("/")[-1]
|
||
|
||
_keep = [w for w in WORKSPACES if _k1(w) in _onlys or _k2(w) in _onlys]
|
||
_hit = set()
|
||
for w in _keep:
|
||
_hit.add(_k1(w))
|
||
_hit.add(_k2(w))
|
||
_miss = [x for x in _onlys if x not in _hit]
|
||
if not _keep:
|
||
print("⛔ --ws 一个都不在管辖名单里:%s" % " , ".join(_onlys))
|
||
print(" 名单:" + " | ".join(WORKSPACES))
|
||
return 2
|
||
if _miss:
|
||
print("⚠️ 这几个不在管辖名单里、已跳过:%s" % " , ".join(_miss))
|
||
global ONLY_WS
|
||
ONLY_WS = _keep[0]
|
||
WORKSPACES[:] = _keep
|
||
for _k in [k for k in KEEPALIVE_TASKS if not (_k1(k) in _onlys or _k2(k) in _onlys)]:
|
||
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)
|