Files
WorkBuddy eee042c855 ③段第八轮收口:功能入口卡按参照物对齐 + 导航按 tiaoyue 源站重建 + 密度 A 方案
原型(执行会话/目标-…-d85687/ui/mcn-workbench.html)
- 功能入口卡改三段纵向(图标/标题/描述各一行),68 → 111.19px,对齐 mcn-work-shop
  参考卡(同视口 1249×1277 实测 133px),补 --sh-L1 投影;主因是排布不是字号
- 左侧导航重建:由「纯图标 + 悬停提示」改为 64px 药丸浮栏,图标 24 在上、名字常驻在下
  (名字一律 2 字,说明走 data-tip);真值取自 tiaoyue 源站实测
  (栏 64/内衬 3/圆角 100/项 56×78/字 12px-500-行高 16)
- 密度 A 方案:≤1000 卡最小宽 200→160、≤820 统计条最小宽 132→96
  (两处 auto-fit 只在真装不下时才缩)⇒ 768/700/560 三处转绿,其余 10 个视口未动
- 移动档隐藏导航条横向滚动轨(16px 轨道曾吃掉 52px 条高的三成,还白吃 16px 主区)

取证通道
- _tools/cap.py 改为「只连不起」:只连 9223 常驻实例,视口走 Emulation.setDeviceMetricsOverride,
  不再自起 headless Chrome(顺带解除 500px 视口下限,390×844 可测)
- _tools/check_js.py 新增第 5 道门禁(node --check + 模板内注释反引号扫描)

文档
- 复盘-为什么第一版做得很差.md(新建):13 轮时间轴/四类反作用/三重归因/已修与仍欠
- 3d-审查报告.md:更正 §九 那条假 ✓(把「§5.5 五条逐条过完」记成机核,实际零覆盖,
  正是「五道闸门全绿却难看」的开关),补 §十二 功能入口卡高差、§十三 tiaoyue 导航逐条对照
- 3b-实测记录.md/DESIGN.md/3c-GPT 参考版与会诊/布局排版参考 ×2

机制与记忆
- .workbuddy/collab/collabctl.py:off --ws 不再全机扫描(原先一次杀掉他区+看板共 5 个进程)
- .workbuddy/memory/:本轮纪律与实测真值
- .gitignore:补排运行时噪音(ui/_scratch/__pycache__/浏览器取证临时目录/*.bak-*/一次性脚本)

闸门:语法全过/骨架八页 39 板块/变异对照 15-15/逐视口 14 绿(500×844 唯一红,缺口待拍板)/命名 2060-0-27
2026-10-08 22:21:42 +08:00

633 lines
32 KiB
Python
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.
#!/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("── 一键全关:关开关 → 禁用全部任务 → 杀进程 ──")
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)