初始化提交:contentm_agent 工作区全量快照
内容分四块: 1、产品规划产出 —— MCN 短视频整合营销工作台的①段五份(1a 需求/1b 竞品/1c 画像/1d 策略/1e 场景)、②段两份(2a 功能/2b 布局)、③段界面(DESIGN.md 契约与令牌表 + mcn-workbench.html 原型 + 实测/会诊/审查三份 + 23 张闸门截图)。 2、开源竞品调研 —— 5 个内容工作台项目的取证原始件与 1b 系列分析文档。 3、参考资料 —— 竞品视频抽帧 1145 张 + 2 个源视频 + 功能点截图。 4、机制侧 —— 协作脚本与状态台账、工作区记忆日志、抽帧/OCR 脚本。 .gitignore 只排运行时日志、脚本备份副本与一次性探针输出,其余按原样入库。
This commit is contained in:
commit
df56c2c137
1773 files changed
+205840
No files matched your search
@@ -0,0 +1,625 @@
|
||||
#!/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/content_marketing_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/content_marketing_agent": "collabd-keepalive-content_marketing_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
|
||||
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()
|
||||
_p(" 计划任务已禁用:%d 个(新 %d + 旧 %d + 看板 1)"
|
||||
% (len(SCHED_TASKS) + len(KEEPALIVE_TASKS) + 1, len(KEEPALIVE_TASKS), len(SCHED_TASKS)))
|
||||
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)
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"_说明": "本文件 = content_marketing_agent 工作区的协作机制部署配置(⛔ 不属于技能)。技能里 collabd.py 是通用代码,靠环境变量 COLLABD_CONFIG 指向本文件;不指 ⇒ 才回落到技能目录里的 collabd.config.example.json(范例,⛔ 不是生产配置)。",
|
||||
"_改名说明": "🔴 2026-10-07 本工作区由 agent-product 改名而来。本文件原先 workspace 与 targets 键都还写着旧名 agent-product ⇒ 已改回本名。⛔ 不改的后果:常驻会把台账/目标写到已不存在的目录。",
|
||||
"workspace": "E:/ProgramData/AIProject/content_marketing_agent",
|
||||
"inbox": "tmp/supervise-inbox",
|
||||
"live": "交付物/本机协作-实时状态.md",
|
||||
"taskgraph": "",
|
||||
"targets": {},
|
||||
"singleton_port": 21826,
|
||||
"interval": 10,
|
||||
"idle_min": 12,
|
||||
"stuck_min": 30,
|
||||
"vacuum_min": 5,
|
||||
"health_every": 300,
|
||||
"supervise_interval": 10,
|
||||
"wake_enable": false,
|
||||
"board_ext": "",
|
||||
"log": ".workbuddy/collab/logs/_collabd.log",
|
||||
"goal_docs": [],
|
||||
"host_db": "E:/ProgramData/.workbuddy/workbuddy.db",
|
||||
"_host_db说明": "🔴 2026-10-07 由空串改为绝对路径。空串 ⇒ 常驻读不到宿主库 ⇒ all_sessions_idle() 走 fail-safe 判「有会话在跑」⇒ 检查会话永不建(外表零报错、最难查)。写法参照 vibe-product。",
|
||||
"_形态": "🔴 2026-10-05 由 install 流程本地部署生成。会话只三类(主会话/任务会话/检查会话);唤醒/跟进/队列上报已于 2026-10-03 整套退役。",
|
||||
"_peer说明": "本工作区暂无并列查看的其它工作区;要并看到别的区,加 peer_workspaces(只读)并重启看板。⚠️ 看板全局只一份(端口 20099),本区不起看板。"
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,877 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""goalctl —— 「**一个需求**」的**统一启停控制台**(🔴 属于本工作区,⛔ 不属于技能)。
|
||||
|
||||
由来(用户 2026-09-30):
|
||||
> 「使用协作skill后 某个需求 统一管理 各种对应任务和程序的启停,包括协作程序等」
|
||||
|
||||
## 一个需求到底牵扯哪些"可启停的东西"(本机实测四层,⛔ 别多列)
|
||||
|
||||
| 层 | 物 | 谁能改 |
|
||||
|---|---|---|
|
||||
| ① **排期层(⛔ 2026-10-01 废弃:不用自动任务当闹钟)** | `automations` 表(唤醒 A/B、各线唤醒) | ⛔ **只有会话能改**(`automation_update` 工具)——脚本只读+打印待办 |
|
||||
| ② **程序层** | `collabd.config.json` 的 `wake_enable`(常驻程序**投递总闸**) | ✅ 脚本可改(配置现成消费者:`collabd.py` 启动即读) |
|
||||
| ③ **总开关层** | `goal.json` 的 `run` 段(`active`/`paused`) | ✅ 脚本可改(唤醒第 0 步读它;⛔ `paused` ⇒ 不许自启、不许派活) |
|
||||
| ④ **服务层** | 看板 `board.py --serve`/守护 `guard`/网关唤醒 | ✅ 复用现成 `stop-collab.py`(⛔ 不重写) |
|
||||
|
||||
## 三条硬约束(决定了为什么长这样)
|
||||
|
||||
1. 🔴 **钩子是全局注册、跨项目共用的**(`<配置目录>/settings.json`,里面还混着
|
||||
其它工作区的 `decision_bridge.py`)⇒ ⛔ **不能靠装卸钩子来启停某个需求**(会连累所有项目)
|
||||
⇒ 只能「**开关文件 + 入口早退**」。所以本控制台不碰钩子注册面。
|
||||
2. 🔴 **排期只能由会话改**(脚本改 `workbuddy.db` 的 `automations` 表=改活库+宿主是否重读未验)
|
||||
⇒ 脚本**只打印**该做的两步,由会话执行。⚠️ 降级有保障:即便排期照旧触发,
|
||||
唤醒第 0 步读到 `run=paused` 会**立刻退出**(一次空跑,不派活、不改文件)。
|
||||
3. 🔴 **⛔ 不 kill 任何会话/worker**;⛔ 不重启宿主;⛔ 不改全局配置。停服务一律走
|
||||
「写标志让对端优雅退出」(`guard.stop`)或停自建看板实例。
|
||||
|
||||
## 用法
|
||||
|
||||
python goalctl.py # 状态台(默认,只读,⛔ 不动任何东西)
|
||||
python goalctl.py declare --title "…" [--why "…"] [--topics "A,B"] [--kpi "V1=pass"] [--yes]
|
||||
# 🔴 **登记"调用本技能时在对话里说明的目标"**
|
||||
# (用户 2026-10-01:「目标是**通过对话在调用 会话协作skill时说明的,不是固定的**」
|
||||
# ⇒ ⛔ 技能/脚本不许预设目标与任务类别,⛔ 也不许从目录名猜)
|
||||
python goalctl.py wake # 🔴 **统一唤醒入口**:拨一下闹钟 ⇒ 常驻程序判条件并投递
|
||||
python goalctl.py stop # 干跑:列出"停"会做什么
|
||||
python goalctl.py stop --yes # 真停:① run=paused ② wake_enable=false ③ 调 stop-collab --yes
|
||||
python goalctl.py start "<目标名>" --yes # 真启:① run=active ② wake_enable=true
|
||||
python goalctl.py start --yes # 同(不改标题)
|
||||
|
||||
## 🔴 `wake` —— 「手动补拨一次投递轮」(⛔ 投递目标=**跟进会话**,不是主会话)
|
||||
|
||||
⚠️ **2026-10-01 用户定案**:投递的**主路径=常驻 `collabd --supervise`**;
|
||||
⛔ 不再用「自动任务当闹钟」(用户原话「**定时任务的方案已经废弃了**」)。
|
||||
⇒ 本入口**保留、但不再是主路径** —— 它的现行作用是**手动补拨一次投递轮**(排查/兜底用)。
|
||||
🔴 **2026-10-01 口径订正:目标从「主会话」改指「跟进会话」** —— 用户原话
|
||||
「**换新会话 唤醒的是 跟进会话,主会话只能是用户触发**」⇒ 下面这张图的终点是**跟进会话**。
|
||||
|
||||
常驻投递(`--supervise` · 时钟本体)/ 手拨(`goalctl wake`)
|
||||
└─ collabd --tick(常驻程序的**一次性投递轮**)
|
||||
└─ 常驻程序 = **唯一的投递出口**(判条件 + reply 投递)
|
||||
├─ 条件不符/刚投过/**跟进会话在忙** ⇒ **静默**(零打扰,零 token)
|
||||
├─ 投成(http 2xx) ⇒ **跟进会话**被唤醒 ⇒ 本轮就此结束
|
||||
└─ 没人能收(`no-follow-session` / `follow-not-live` / `target-deaf`)
|
||||
⇒ **喊用户**(写 `NEED-USER.md`),⛔ **不降级**、⛔ 不盲投
|
||||
(`no-live-session` 例外:整机一条活会话都没有 ⇒ 才降级为"自己干")
|
||||
|
||||
🔴 **判读三分**(脚本已内建,别自己猜):
|
||||
· 「投成 / 目标在忙 / 刚投过 / 已停」⇒ **结束**(⛔ 不抢锁、⛔ 不派活、⛔ 不起会话)
|
||||
· 「没人能收」 ⇒ **需人看**(⛔ 不降级 —— 缺的是**跟进会话**,不是"没人干活")
|
||||
· 「一条活会话都没有」 ⇒ 降级自己干(**此时才抢域锁**)
|
||||
⚠️ `target-busy` **不是**"没投出去",是"**跟进会话已经在跑**" ⇒ ⛔ **绝不能降级**(降级=同一件事干两遍)。
|
||||
|
||||
⚠️ 任何写动作前**先备份**(`.bak-goalctl-<日期>/`);⛔ 脚本从不删文件。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
# [session-mechanism] roots.env 外置(由 install.py 生成;缺失则回落到按位置推导)
|
||||
def _sm_load_roots():
|
||||
import os as _os
|
||||
_here = _os.path.dirname(_os.path.abspath(__file__))
|
||||
for _up in range(4):
|
||||
_p = _os.path.join(_here, *([".."] * _up), "roots.env")
|
||||
_p = _os.path.normpath(_p)
|
||||
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()
|
||||
# 🔴 2026-10-01 加 · **输出编码兜底**:脚本一旦被重定向(钩子/常驻/后台任务都会这么干),
|
||||
# Windows 本地编码(GBK)编不出 ⛔/✅/🔴 这类字符 ⇒ `print` 抛 UnicodeEncodeError
|
||||
# ⇒ 被顶层 handler 记成 `fatal`、**整轮失败**(实测:本包里连续 4 次 `fatal 'gbk' codec ...`)。
|
||||
# ⇒ 出口一律 UTF-8 + errors="replace"(⛔ 不让"打不出字"升级成"程序死")。
|
||||
try:
|
||||
import sys as _sys
|
||||
_sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
_sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def _resolve_ws() -> Path:
|
||||
"""🔴 工作区解析(2026-10-01 修)。
|
||||
|
||||
⛔ **别把按 `__file__` 推层级当默认** —— 实测它推出来是**技能包的上级目录**
|
||||
(`.../.workbuddy/skills`)⇒ 不带环境变量手跑时**静默**读写错工作区
|
||||
(读数全「(无)」、还会在技能目录下建 `tmp/supervise-inbox/`)。
|
||||
⚠️ 这与本项目已定的红线同族:「⛔ 别用 `dirname`/`__file__` 推工作区」。
|
||||
|
||||
顺序:`DSH_COLLAB_WS`(显式)→ `DSH_WS_ROOT`(`roots.env` 注入)→ 当前目录(含 `.workbuddy/collab/`)。
|
||||
三路都不中 ⇒ 才回落到旧行为,并且**打醒目告警**(⛔ 不许静默)。
|
||||
"""
|
||||
for _k in ("DSH_COLLAB_WS", "DSH_WS_ROOT"):
|
||||
_v = os.environ.get(_k)
|
||||
if _v:
|
||||
return Path(_v)
|
||||
_c = Path.cwd()
|
||||
if (_c / ".workbuddy" / "collab").is_dir():
|
||||
return _c
|
||||
sys.stderr.write(
|
||||
"⚠️ goalctl:拿不到工作区(`DSH_COLLAB_WS`/`DSH_WS_ROOT` 都空,当前目录也没有 "
|
||||
"`.workbuddy/collab/`)⇒ 回落到 `%s`(技能包上级目录,⚠️ 大概不是你想要的)\n"
|
||||
% HERE.parent.parent)
|
||||
return HERE.parent.parent
|
||||
|
||||
|
||||
WS = _resolve_ws()
|
||||
|
||||
|
||||
def _resolve_cfg() -> Path:
|
||||
"""🔴🔴 部署配置解析(2026-10-06 修)—— ⛔ **不许写死成技能目录**。
|
||||
|
||||
**病根**:原为 `CFG = HERE / "collabd.config.json"`(`HERE` = **技能包目录**),
|
||||
而按定则「**技能就是技能、程序就是程序,谁用产生的文件放在他自己那里**」,
|
||||
**技能目录里⛔ 不放生产配置** ⇒ 该文件恒不存在 ⇒ `_load(CFG,{})` 恒返回 `{}`。
|
||||
|
||||
**症状(不报错、不崩溃,只是判定那一路永远读不到)**:
|
||||
· `taskgraph` 取默认 `INBOX/taskgraph.json` ⇒ 目标换了图没换时**照旧踩**
|
||||
(vibe-product 2026-10-06 实测:配置里已改指 proto-board,而 `goals_open()`
|
||||
仍报 `bad=['任务图读不到(taskgraph.json)']` —— 即**改了配置不生效**);
|
||||
· 其余读 `CFG` 的命令(`wake_enable`/`targets` / 写回配置)**全部作用在一个不存在的文件上**。
|
||||
|
||||
✅ 与 `collabd._cfg_candidates()` **同款三级**(⛔ 不新造顺序,否则又是一处口径分叉):
|
||||
① 环境变量 `COLLABD_CONFIG`(钩子/启动器显式指定,首选)
|
||||
② `<工作区>/.workbuddy/collab/collabd.config.json`(使用方**标准落点**)
|
||||
③ 都不在 ⇒ 回落到旧的 `HERE/collabd.config.json` **并打醒目告警**(⛔ 不静默)。
|
||||
"""
|
||||
_env = os.environ.get("COLLABD_CONFIG")
|
||||
if _env and Path(_env).is_file():
|
||||
return Path(_env)
|
||||
_std = WS / ".workbuddy" / "collab" / "collabd.config.json"
|
||||
if _std.is_file():
|
||||
return _std
|
||||
_legacy = HERE / "collabd.config.json"
|
||||
sys.stderr.write(
|
||||
"⚠️ goalctl:没找到部署配置(`COLLABD_CONFIG` 未设、`%s` 不存在)"
|
||||
"⇒ 回落到 `%s`\n" % (_std, _legacy))
|
||||
return _legacy
|
||||
|
||||
|
||||
CFG = _resolve_cfg()
|
||||
INBOX = WS / "tmp" / "supervise-inbox"
|
||||
GOAL, TASKS = INBOX / "goal.json", INBOX / "tasks.json"
|
||||
# 🔴🔴 2026-10-05 **历史目标归档目录**(用户口径「一个工作区 历史的旧的目标会有很多个」)。
|
||||
# 判据:`goal.json` = **唯一当前目标**(同时只有一个);换目标时**旧的必须移到这里**,
|
||||
# ⛔ 不许原地覆盖丢掉(本目录此前**代码零实现** —— 那份 `机制自检-20261004.json`
|
||||
# 是手工造的 ⇒ 换目标 = 旧目标消失,只留用户手写的 `_前身` 一行字)。
|
||||
GOALS_DIR = INBOX / "goals"
|
||||
STOPPER = HERE / "stop-collab.py"
|
||||
HOSTDB = Path(os.environ.get("CODEBUDDY_CONFIG_DIR") or os.path.expanduser("~/.workbuddy")) / "workbuddy.db"
|
||||
BAK = WS / ".workbuddy" / "collab" / ("bak-goalctl-%s" % time.strftime("%Y%m%d"))
|
||||
|
||||
ARGS = [a for a in sys.argv[1:] if not a.startswith("-")]
|
||||
DO = "--yes" in sys.argv
|
||||
# 🔴🔴 2026-10-05 **「用户已确认切换目标」的显式开关**(用户口径:换目标**需要用户确认**)。
|
||||
# 判据:`--switch-goal` = **人**说"同意放弃当前目标";`--yes` = 只是"干跑转真写"。
|
||||
# ⛔ **两者不许合并** —— 合并就等于把"确认"降级成"顺手加个 yes",闸门失效。
|
||||
SWITCH_OK = "--switch-goal" in sys.argv
|
||||
CMD = (ARGS[0] if ARGS else "status")
|
||||
NAME = (ARGS[1] if len(ARGS) > 1 else "")
|
||||
|
||||
|
||||
def _norm(s) -> str:
|
||||
return str(s or "").replace("\\", "/").strip().lower().rstrip("/")
|
||||
|
||||
|
||||
def _load(p: Path, default=None):
|
||||
try:
|
||||
return json.loads(p.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _rp(rel: str, default: Path) -> Path:
|
||||
"""🔴 与 `collabd.py:_rp` 同规则:**配置里的相对路径按工作区解析**。
|
||||
⚠️ 踩过的坑(2026-09-30 本棒):任务图在配置里是 `交付物/任务图.json`,
|
||||
而 **`tmp/supervise-inbox/taskgraph.json` 根本不存在** —— 照默认点读会静默读到空
|
||||
⇒ `goals_open()` 少一路 ⇒ **可能误判"已完成"**。"""
|
||||
if not rel:
|
||||
return default
|
||||
p = Path(rel)
|
||||
return p if p.is_absolute() else (WS / p)
|
||||
|
||||
|
||||
def _backup(p: Path) -> str:
|
||||
if not p.is_file():
|
||||
return ""
|
||||
BAK.mkdir(parents=True, exist_ok=True)
|
||||
dst = BAK / p.name
|
||||
shutil.copy2(p, dst)
|
||||
return str(dst)
|
||||
|
||||
|
||||
def _cells(v) -> list:
|
||||
"""automations.cwds 可能是 JSON 数组、也可能是逗号串。"""
|
||||
if isinstance(v, list):
|
||||
return [_norm(x) for x in v]
|
||||
s = str(v or "").strip()
|
||||
if s.startswith("["):
|
||||
try:
|
||||
return [_norm(x) for x in json.loads(s)]
|
||||
except Exception:
|
||||
pass
|
||||
return [_norm(x) for x in s.split(",") if x.strip()]
|
||||
|
||||
|
||||
# ── 各层读数 ───────────────────────────────────────────────────────────
|
||||
def read_goal() -> dict:
|
||||
g = _load(GOAL, {}) or {}
|
||||
return g if isinstance(g, dict) else {}
|
||||
|
||||
|
||||
def read_automations() -> list:
|
||||
"""只读宿主库 ⇒ 本工作区相关的排期。⛔ 不写、不开事务。"""
|
||||
if not HOSTDB.is_file():
|
||||
return []
|
||||
try:
|
||||
con = sqlite3.connect("file:%s?mode=ro" % HOSTDB.as_posix(), uri=True, timeout=5)
|
||||
con.execute("PRAGMA busy_timeout=3000")
|
||||
cols = [d[1] for d in con.execute("PRAGMA table_info(automations)")]
|
||||
rows = list(con.execute("select * from automations"))
|
||||
con.close()
|
||||
except Exception as e:
|
||||
return [{"_err": str(e)}]
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(zip(cols, r))
|
||||
if d.get("deleted_at"):
|
||||
continue # ⚠️ 删除是**软删**:判"还在不在跑"要看这列
|
||||
cells = _cells(d.get("cwds"))
|
||||
if cells and _norm(WS) not in cells:
|
||||
continue
|
||||
out.append({
|
||||
"id": str(d.get("id") or ""), "name": str(d.get("name") or ""),
|
||||
"status": str(d.get("status") or ""), "rrule": str(d.get("rrule") or ""),
|
||||
"next": d.get("next_run_at"), "kind": str(d.get("schedule_type") or ""),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _acc_is_pass(v) -> bool:
|
||||
"""🔴 一条验收判据「算不算过」—— 🔴 **本文件唯一实现,且⛔ 不自带词表**。
|
||||
|
||||
🔴🔴 **2026-10-06 立**(vibe-product 实测踩到,属"判据分叉"第 N 次复发):
|
||||
|
||||
**病根**:`board.py::acc_is_pass` 自称「**全库唯一实现**」,并在注释里点明
|
||||
三处判据(`board.py` / `collabd.py::_acc_is_pass` / `board.html::accIsPass`)
|
||||
**必须同款**。🔴 **实际是四处** —— 本文件 `goals_open()` 里那句
|
||||
`if str(acc[k]) != "pass":` **被漏掉了**,既不归一化、也不复用真身。
|
||||
|
||||
**症状(不报错、不崩溃,只少说一句真话)**:本机制的存值**真源就是中文**
|
||||
(`过(PID 3552 …)`/`已过|…`/`达(实测 7 个…)`)⇒ 死板比 `"pass"` ⇒
|
||||
**已通过的验收被全部计入 why** ⇒ 恒判「未完成」。
|
||||
实测(vibe-product 2026-10-06):四条验收**全过**(`V1=pass` 已归一化存值),
|
||||
而 `goalctl.py status` 仍会因历史中文值报「未完成」;用户原话
|
||||
「**为什么完成了还说没完成**」。⇒ 与 `collabd._acc_is_pass()` 的沿革逐字同族。
|
||||
|
||||
**正解=复用真身,⛔ 不复制词表**(复制就是下一次漂的来源):
|
||||
首选 `collabd._acc_is_pass()`(它自己又首选 `board.acc_is_pass()`);
|
||||
拿不到(副本被单独拷走 / 循环导入)⇒ **fail-closed 声明式兜底**:
|
||||
用与 `board.py::ACC_PASS_WORDS` **同款**的白名单判,并在 stderr 留一行。
|
||||
⛔ **绝不静默返回 True/False** —— 那会把"代码没同步"伪装成"判据没过/过了"。
|
||||
"""
|
||||
try:
|
||||
import sys as _sys
|
||||
_sd = str(HERE)
|
||||
if _sd not in _sys.path:
|
||||
_sys.path.insert(0, _sd)
|
||||
import collabd as _cd # ⚠️ 同目录 ⇒ 无副作用
|
||||
return bool(_cd._acc_is_pass(v))
|
||||
except Exception as _e:
|
||||
try:
|
||||
sys.stderr.write("⚠️ goalctl._acc_is_pass: 拿不到 collabd(%s)⇒ 走兜底白名单\n" % _e)
|
||||
except Exception:
|
||||
pass
|
||||
# ── fail-safe 兜底:与 `board.py::ACC_PASS_WORDS` / `ACC_ADV_PREFIX` **同款**
|
||||
# (⛔ 改了那边必须改这里 —— 两处都列出来,免得只改词表漏掉副词)
|
||||
s = str(v or "").strip()
|
||||
if not s:
|
||||
return False # 空 ⇒ 判不出来 ⇒ ⛔ 不算过(fail-closed)
|
||||
_head = re.split(r"[((]", s, maxsplit=1)[0] # 丢掉括注说明
|
||||
for _seg in re.split(r"[::]", _head): # ⛔ 逐段找,⛔ 不"只取最后一段"
|
||||
_t = _seg.strip().lstrip("*").strip().lower()
|
||||
for _w in ("pass", "过", "通过", "达", "达标", "合格", "完成"):
|
||||
if _t.startswith(_w):
|
||||
return True
|
||||
# 🔴 剥"完成副词"再比(与 `board.py::acc_is_pass` 同款)。
|
||||
# ⚠️ 否定副词(未/不/没/非/待)**不在**表里 ⇒ 剥不动 ⇒ 仍 fail-closed。
|
||||
_t2 = _t
|
||||
for _ in range(3):
|
||||
_cut = ""
|
||||
for _a in ("已", "已经", "均", "都", "经复核", "复核后"):
|
||||
if _t2.startswith(_a) and len(_a) > len(_cut):
|
||||
_cut = _a # 取**最长**匹配(`已经` 优先于 `已`)
|
||||
if not _cut:
|
||||
break
|
||||
_t2 = _t2[len(_cut):].strip().lstrip("*").strip()
|
||||
for _w in ("pass", "过", "通过", "达", "达标", "合格", "完成"):
|
||||
if _t2.startswith(_w):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def goals_open() -> dict:
|
||||
"""目标是否仍未完成 —— 三路并集(与 `collabd.goals_open()` 同判据,⛔ 不缩水)。
|
||||
|
||||
⚠️ **一路读不到不许当成"已完成"** —— 只把它标成 `读不到`,避免"误判完成"(源码里的
|
||||
铁证:任务图 N14=done 而它判的正是「V1 未过」⇒ 只看"活干完没"会错)。"""
|
||||
why, bad, t = [], [], _load(TASKS, {}) or {}
|
||||
for k, v in (t if isinstance(t, dict) else {}).items():
|
||||
if str((v or {}).get("state") or "") != "done":
|
||||
why.append("台账 %s=%s" % (k, (v or {}).get("state")))
|
||||
tg = _rp(str((_load(CFG, {}) or {}).get("taskgraph") or ""), INBOX / "taskgraph.json")
|
||||
g = _load(tg, None)
|
||||
if not isinstance(g, dict):
|
||||
bad.append("任务图读不到(%s)" % tg.name)
|
||||
else:
|
||||
# 🔴 2026-10-01 加(与 `collabd.taskgraph` 同口径)—— **台账优先**:
|
||||
# 任务图**无任何代码回写**(纯手维护),会话「上报完成」只写 `tasks.json`
|
||||
# ⇒ 只认图会让「早已做完的件」永久卡住目标判定(实测 M5)。
|
||||
for n in (g.get("nodes") or []):
|
||||
if str(n.get("status") or "") == "done":
|
||||
continue
|
||||
if str(((t.get(n.get("id")) or {}).get("state")) or "") == "done":
|
||||
continue # 台账已 done ⇒ 视同 done
|
||||
why.append("任务图 %s" % n.get("id"))
|
||||
acc = (read_goal().get("acceptance_state") or {})
|
||||
eff = [k for k in acc if not str(k).startswith("_")] # 🔴 **有效项**=键不以 `_` 开头
|
||||
for k in eff:
|
||||
# 🔴🔴 2026-10-06 **改走真身**(原为 `str(acc[k]) != "pass"`,死板比字面值)——
|
||||
# 存值真源是**中文**(`过(…)`/`已过|…`/`达(…)`)⇒ 死板比 ⇒ 已通过的
|
||||
# 验收被计入 why ⇒ 恒判「未完成」。详见 `_acc_is_pass()` 的 docstring。
|
||||
if not _acc_is_pass(acc[k]):
|
||||
why.append("验收 %s=%s" % (k, acc[k]))
|
||||
if not eff:
|
||||
# 🔴 2026-09-30 修(**"读不到"被当成了"全过"**):判据是"**有没有有效项**",
|
||||
# ⛔ 不是"字典空不空" —— 旧写法 `if not acc` 在"只有 `_说明`/`_更新` 说明行"时
|
||||
# **永不触发** ⇒ 控制台显示"三路全过"(**实测踩到**,同一族:不崩溃,只是少说一句话)。
|
||||
bad.append("无有效验收判据(只有说明行)")
|
||||
return {"open": bool(why) or bool(bad), "why": why[:6], "bad": bad}
|
||||
|
||||
|
||||
def stamp_age(p: Path) -> str:
|
||||
if not p.is_file():
|
||||
return "无"
|
||||
d = time.time() - p.stat().st_mtime
|
||||
return "%.0f 分钟前" % (d / 60) if d < 7200 else "%.1f 小时前" % (d / 3600)
|
||||
|
||||
|
||||
def run_stopper(dry: bool) -> str:
|
||||
if not STOPPER.is_file():
|
||||
return "(没找到 stop-collab.py)"
|
||||
cmd = [sys.executable, str(STOPPER)] + ([] if dry else ["--yes"])
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=180,
|
||||
errors="replace", cwd=str(HERE),
|
||||
creationflags=0x08000000) # CREATE_NO_WINDOW:⛔ 闪窗
|
||||
return (r.stdout or "") + (("\n[stderr] " + r.stderr) if r.stderr else "")
|
||||
|
||||
|
||||
# ── 状态台 ─────────────────────────────────────────────────────────────
|
||||
def status() -> int:
|
||||
g, cfg, opens = read_goal(), _load(CFG, {}) or {}, goals_open()
|
||||
autos = read_automations()
|
||||
print("═══ 需求控制台 · %s ═══" % time.strftime("%Y-%m-%d %H:%M"))
|
||||
print("工作区 = %s" % WS)
|
||||
print()
|
||||
run = str(g.get("run") or ("active" if g else "?"))
|
||||
print("【总开关】run = %s %s" % (run, ("" if run != "?" else "(goal.json 里还没有 run 段)")))
|
||||
print(" 目标 = %s" % (g.get("title") or "⚠️ 未声明任务目标"))
|
||||
# 🔴 2026-09-30 改三态(⛔ 旧写法把"**判不出来**"和"三路全过"混成一句 ⇒ 会误报"已达成了")
|
||||
if opens["why"]:
|
||||
_st = "**未完成**"
|
||||
elif opens["bad"]:
|
||||
_st = "⚠️ **判不出来**(⛔ 不因此判完成)"
|
||||
else:
|
||||
_st = "三路全过"
|
||||
print(" 目标状态 = %s%s%s" % (_st,
|
||||
("(%s)" % ";".join(opens["why"])) if opens["why"] else "",
|
||||
(" ⚠️ %s" % ";".join(opens["bad"])) if opens["bad"] else ""))
|
||||
t = _load(TASKS, {}) or {}
|
||||
cnt = {}
|
||||
for v in (t if isinstance(t, dict) else {}).values():
|
||||
k = str((v or {}).get("state") or "?")
|
||||
cnt[k] = cnt.get(k, 0) + 1
|
||||
print(" 队列 = %s" % (cnt or "空"))
|
||||
print()
|
||||
print("【② 程序闸】wake_enable = %s" % cfg.get("wake_enable"))
|
||||
print(" 钩子链最后唤起 = tick %s / once %s" % (
|
||||
stamp_age(INBOX / "_tick.stamp"), stamp_age(INBOX / "collabd-once.stamp")))
|
||||
print()
|
||||
rec = [a for a in autos if a.get("kind") == "recurring"]
|
||||
onc = [a for a in autos if a.get("kind") != "recurring" and not a.get("_err")]
|
||||
err = [a for a in autos if a.get("_err")]
|
||||
print("【① 排期层】周期排期 %d 条(**这才是启停对象**)/一次性棒 %d 条(已耗,⛔ 不是启停对象)"
|
||||
% (len(rec), len(onc)))
|
||||
if err:
|
||||
print(" ⚠️ 读宿主库失败:%s" % err[0]["_err"])
|
||||
for a in rec:
|
||||
st = a["status"]
|
||||
mark = "🟢" if st == "ACTIVE" else "⚪"
|
||||
nx = a.get("next")
|
||||
try:
|
||||
nx = time.strftime("%m-%d %H:%M", time.localtime(int(nx) / (1000 if int(nx) > 10 ** 11 else 1))) if nx else "—"
|
||||
except Exception:
|
||||
nx = "—"
|
||||
print(" %s %s %-9s next=%s %s" % (mark, a["id"][:8], st, nx, a["name"][:44]))
|
||||
if not rec and not err:
|
||||
print(" (没有周期排期 ⇒ 无人值守的「叫醒」通道当前是关着的)")
|
||||
print()
|
||||
print("【④ 服务层】")
|
||||
print(run_stopper(dry=True).rstrip())
|
||||
print()
|
||||
on = (run == "active") and bool(cfg.get("wake_enable"))
|
||||
print("⇒ 一句话:这个需求现在是 **%s**" % ("开着" if on else "停着"))
|
||||
print("⇒ 下一步:%s" % ("按需求推进(有缺口就派棒)" if on else "没有人在推进;要开就 goalctl start --yes,要派活就直接派"))
|
||||
return 0
|
||||
|
||||
|
||||
# ── 启停 ───────────────────────────────────────────────────────────────
|
||||
def _write_json(p: Path, d: dict) -> None:
|
||||
b = _backup(p)
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(json.dumps(d, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
if b:
|
||||
print(" (备份 → %s)" % b)
|
||||
|
||||
|
||||
def _automation_todo(want: str) -> None:
|
||||
# 🔴 2026-10-01 修:原来只认名字含「唤醒轮」的排期 —— 而排期命名口径已改为
|
||||
# `[唤醒]-<类别>-<具体>`(见 collabd.config.json `_默认形态`)⇒ 老判据**一条都找不到**,
|
||||
# 于是 `stop`/`start` 永远打印「没找到名为「唤醒轮」的排期」(**判据跟着旧名字漂了**)。
|
||||
# ⇒ 两道一起认:旧的「唤醒轮」+ 新的 `[唤醒]` 前缀。
|
||||
autos = [a for a in read_automations()
|
||||
if ("唤醒" in a.get("name", "") or a.get("name", "").startswith("[唤醒]"))]
|
||||
print(" ⚠️ 排期层**只能由会话改**(⛔ 脚本不写活库)。请对本工作区的唤醒类排期执行:")
|
||||
if not autos:
|
||||
print(" (没找到「唤醒轮」或 `[唤醒]-…` 排期 —— 可能已删,或改名了)")
|
||||
for a in autos:
|
||||
if a.get("status") == want:
|
||||
print(" ✓ %s 已是 %s,跳过" % (a["id"][:8], want))
|
||||
continue
|
||||
print(" automation_update mode=update id=%s status=%s ← %s"
|
||||
% (a["id"], want, a["name"][:34]))
|
||||
print(" 💡 降级有保障:即便漏改,唤醒第 0 步读到 run=%s 也会立刻退出。"
|
||||
% ("paused" if want == "PAUSED" else "active"))
|
||||
|
||||
|
||||
def stop() -> int:
|
||||
print("═══ 需求控制台 · 停(%s)═══" % ("真做" if DO else "dry-run"))
|
||||
g = read_goal()
|
||||
if DO:
|
||||
g["run"], g["run_at"] = "paused", time.strftime("%Y-%m-%dT%H:%M")
|
||||
g["run_by"] = "goalctl stop"
|
||||
print("① 总开关:goal.json run → paused")
|
||||
_write_json(GOAL, g)
|
||||
else:
|
||||
print("① 总开关:[dry-run] 会写 goal.json run=paused")
|
||||
cfg = _load(CFG, {}) or {}
|
||||
if DO:
|
||||
cfg["wake_enable"] = False
|
||||
print("② 程序闸:collabd.config.json wake_enable → false")
|
||||
_write_json(CFG, cfg)
|
||||
else:
|
||||
print("② 程序闸:[dry-run] 会写 wake_enable=false")
|
||||
print("③ 服务层:%s" % ("调 stop-collab.py --yes" if DO else "[dry-run] 会调 stop-collab.py --yes"))
|
||||
out = run_stopper(dry=not DO)
|
||||
print("\n".join(" " + x for x in out.rstrip().splitlines()))
|
||||
print("④ 排期层:")
|
||||
_automation_todo("PAUSED")
|
||||
print(" 🔴 停机**再起**时:常驻/看板都会**跑启动时那份代码**(⛔ 不热加载)⇒")
|
||||
print(" ① 看板必须 `--takeover`(Windows 同端口可**静默并存**,⛔ 否则看到的是**旧进程**画的那张图);")
|
||||
print(" ② 常驻(`collabd --supervise`)必须先停掉旧 PID 再起,⛔ 别以为重起过就是新代码。")
|
||||
print(" ⇒ 依据 `references/pitfalls.md` P0-17 / P0-9。")
|
||||
print("\n⇒ %s" % ("已停。⛔ 未动任何别的工作区/别的需求。" if DO else "这是干跑;要真停请加 --yes"))
|
||||
return 0
|
||||
|
||||
|
||||
def start() -> int:
|
||||
print("═══ 需求控制台 · 启(%s)═══" % ("真做" if DO else "dry-run"))
|
||||
g = read_goal()
|
||||
if not g.get("title"):
|
||||
print("⛔ goal.json 里没有 title ⇒ 机制不知道围绕什么跑。请先声明目标(不会帮你编一个)。")
|
||||
return 2
|
||||
if NAME and NAME != g.get("title"):
|
||||
print(" (把目标名改成:%s)" % NAME)
|
||||
if DO:
|
||||
g["run"], g["run_at"], g["run_by"] = "active", time.strftime("%Y-%m-%dT%H:%M"), "goalctl start"
|
||||
if NAME:
|
||||
g["title"] = NAME
|
||||
print("① 总开关:goal.json run → active")
|
||||
_write_json(GOAL, g)
|
||||
else:
|
||||
print("① 总开关:[dry-run] 会写 goal.json run=active")
|
||||
cfg = _load(CFG, {}) or {}
|
||||
if DO:
|
||||
cfg["wake_enable"] = True
|
||||
print("② 程序闸:collabd.config.json wake_enable → true")
|
||||
_write_json(CFG, cfg)
|
||||
else:
|
||||
print("② 程序闸:[dry-run] 会写 wake_enable=true")
|
||||
print("③ 服务层:⛔ 不自启任何常驻(常驻在本机已被判**走不通**:detached 活不过工具调用边界)")
|
||||
print(" ⚠️ 但**看板是要看的**(用户 2026-10-01:「我说的看板是**实时动态看板**」)——")
|
||||
print(" 脚本起不了会话后台任务,请在**会话里**跑这一行(本机唯一可行的起法):")
|
||||
_bp = (Path(Path(_collabd()).parent) / "board.py") if str(_collabd()) else (Path("<技能>") / "scripts" / "board.py")
|
||||
print(" COLLABD_CONFIG=%s DSH_COLLAB_WS=%s \\" % (str(CFG).replace("\\", "/"), str(WS).replace("\\", "/")))
|
||||
print(" <python> \"%s\" --serve 8788 --takeover > %s/tmp/board-serve.out.log 2>&1"
|
||||
% (str(_bp).replace("\\", "/"), str(WS).replace("\\", "/")))
|
||||
print(" (`--takeover` ⇒ 换代码重启的正规姿势;单实例护栏:已有看板时会拒绝裸起)")
|
||||
print(" 🔴 常驻(`collabd --supervise`)同理会**跑启动时那份代码** ⇒ 改完代码必须**先停旧 PID 再重起**,")
|
||||
print(" ⛔ 它不是热加载的(现场:22:06 起的那份跑的是 22:06 内存里的旧代码,日志里还在打已删的探针)。见 P0-17。")
|
||||
print("④ 排期层:")
|
||||
_automation_todo("ACTIVE")
|
||||
print(" 🔴 相位校准:两条唤醒 nextRunAt 应相差约 30 分钟;不对就把其中一条 rrule 重送。")
|
||||
print("\n⇒ %s" % ("已启。⛔ 未动任何别的工作区/别的需求。" if DO else "这是干跑;要真启请加 --yes"))
|
||||
return 0
|
||||
|
||||
|
||||
def _opt(name: str, dv: str = "") -> str:
|
||||
"""读 `--name value` 或 `--name=value`(本脚本只用这两种形态,⛔ 不引 argparse)。"""
|
||||
a = sys.argv[1:]
|
||||
for i, x in enumerate(a):
|
||||
if x == "--" + name:
|
||||
return a[i + 1] if i + 1 < len(a) else dv
|
||||
if x.startswith("--" + name + "="):
|
||||
return x.split("=", 1)[1]
|
||||
return dv
|
||||
|
||||
|
||||
def _items(s: str) -> list:
|
||||
"""按逗号切(中英文逗号都吃),**保留原样大小写/空白**(⛔ 不用 `_norm` —— 那是给路径用的)。"""
|
||||
return [x.strip() for x in str(s or "").replace(",", ",").split(",") if x.strip()]
|
||||
|
||||
|
||||
_SURE = "\x00" # 哨兵:区分「没给这个参数」与「给了空串(=显式清空)」
|
||||
|
||||
|
||||
def declare() -> int:
|
||||
"""🔴 **把"调用本技能时在对话里说明的目标"登记下来** —— 目标不是固定配置。
|
||||
|
||||
用户 2026-10-01 原话(订正我上一轮的做法):
|
||||
> 「目标是 **通过对话在调用 会话协作skill时说明的,不是固定的**」
|
||||
|
||||
我上一轮做的**错事**:把 `goal.json.topics` 按**工作区里的目录名/接续入口**填了 4 类,
|
||||
还拿去请用户"拍板定死"。⇒ 那是**猜**,不是**说明**。
|
||||
⇒ 本命令是"说明"的**唯一落点**;⛔ 技能侧不许预设类别、⛔ 不许从文件名推。
|
||||
|
||||
用法:
|
||||
python goalctl.py declare --title "…" [--why "…"] [--topics "A,B"] [--kpi "V1=过;V2=未过"]
|
||||
python goalctl.py declare --title "…" --topics "" # 显式清空类别(回到单类别回落)
|
||||
⛔ 默认 **dry-run**;真写加 `--yes`(与 stop/start 同一个语义开关)。
|
||||
|
||||
🔴 `--kpi` 的**分隔符**(2026-10-01 22:3x 实测订正,⛔ 别再搞反):
|
||||
实现是 `str(kpi).replace(";", ";").split(";")` ⇒ **先把中文 `;` 换成 `;` 再切**
|
||||
⇒ **ASCII `;` 与中文 `;` 都是分隔符**,**两者都不能出现在条目内容里**。
|
||||
⚠️ 我原先记的是"中文 `;` 会被当内容拼进去" —— **正好是反的**。实测写
|
||||
`V1=过(… 被杀);_collabd.log 节拍 …` ⇒ 后半截被切成**独立的一条没有 `=` 的判据**、
|
||||
**静默跳过**(只在 stderr 上印一行「没有 `=` ⇒ 跳过」)⇒ **落库的是被截断的值**,
|
||||
而且**看起来"已登记"**(rc=0)—— 属于「看着成功、其实半丢」那一族。
|
||||
✅ **条目内要断句**:用 `,` / `、` / `。` / `——`(这些**不是**分隔符,随便用)。
|
||||
✅ ⚠️ **两个参数规则不同、别混**:`--topics` 走 `_items()` ⇒ **按逗号切**(中英文逗号都吃);
|
||||
`--kpi` 走上面那条 ⇒ **按分号切**。
|
||||
✅ 稳妥姿势:先 `declare`(dry-run,**不加 `--yes`**)跑一遍,**读回显里「验收判据:…」那一行**,
|
||||
确认条目数与内容都完整,再补 `--yes` 真写。
|
||||
|
||||
三条不许静默的规矩:
|
||||
· `--title` **必填** —— ⛔ 本脚本**不替你编目标**;
|
||||
· `--topics` 省略 ⇒ **不动**(⛔ 不拿旧值凑数);给了 ⇒ 整表替换,并记 `topics_declared_at/by`;
|
||||
· `--kpi` 省略 ⇒ **不动** `acceptance_state`;给了 ⇒ 整表重写,且**会点明丢掉了哪些说明行**。
|
||||
"""
|
||||
# ⚠️ **没给 `--title` ⇒ 保持现有标题**(⛔ 不回落成 `NAME`=工作区名)。
|
||||
# 🔴 2026-10-05 实测踩到:旧写法 `_opt("title") or NAME` 在"只想补 `--why`/`--kpi`"
|
||||
# (即**同一目标在细化**,标题根本没打算动)时,会把标题**悄悄改成工作区名**
|
||||
# ⇒ 触发换目标闸 ⇒ **把一个纯细化动作拦成"未确认换目标"**(放大成事故)。
|
||||
# 判据:`--title` 是**显式动作**;没给就是"不动它"(与 `--topics`/`--kpi` 同一规矩)。
|
||||
# ⚠️ 只有**一个目标都没有**(首次声明)时,才允许回落到 `NAME`。
|
||||
_t_given = _opt("title", _SURE)
|
||||
title = (_t_given if _t_given is not _SURE else "") or str(read_goal().get("title") or "") or NAME
|
||||
why = _opt("why", _SURE)
|
||||
topics = _opt("topics", _SURE)
|
||||
kpi = _opt("kpi", _SURE)
|
||||
by = _opt("by", "对话(调用 session-mechanism 时说明)")
|
||||
print("═══ 需求控制台 · 声明目标(%s)═══" % ("真做" if DO else "dry-run"))
|
||||
if not title:
|
||||
print("⛔ 缺 `--title`。⛔ 本脚本**不替你编目标** —— 目标是**在对话里说明**的。")
|
||||
print(" 例:python goalctl.py declare --title \"<一句话说清要做成什么>\" --yes")
|
||||
return 2
|
||||
|
||||
g = read_goal()
|
||||
old_title = str(g.get("title") or "")
|
||||
print(" 现有目标:%s" % (old_title or "(无)"))
|
||||
print(" 目标改为:%s" % title)
|
||||
|
||||
# 🔴🔴🔴 2026-10-05 **换目标的确认闸 + 旧目标自动归档**(用户口径,逐字:
|
||||
# 「**一个工作区 同时只能执行一个目标,如果要切换目标,需要用户确认,
|
||||
# 然后切换和关注检查切换后的目标**」)。
|
||||
# 🔴 **改前取证**:`declare` **直接覆盖 `title`**、**从不归档旧目标**
|
||||
# (全文搜 `goals/` 零命中)⇒ 换目标 = 旧目标当场消失,只剩人手写的 `_前身` 一行。
|
||||
# ⇒ 补两道:
|
||||
# ① **确认闸**:标题变了 ⇒ **默认拒绝**,必须带 `--switch-goal` 明示"用户已确认切换"
|
||||
# (⛔ 与 `--yes` 分开:`--yes` 是"干跑转真写",⛔ 不是"用户同意换目标")。
|
||||
# ② **归档**:确认后把旧 `goal.json` **整份**移到 `goals/<旧标题>__<时间>.json`,
|
||||
# 并在新目标里留 `_前身目标` 指向归档件(⛔ 不靠人手写)。
|
||||
# ⚠️ 只改措辞/补 why/补 kpi(标题**没变**)⇒ ⛔ **不触发本闸**(那是同一目标在细化)。
|
||||
_is_switch = bool(old_title) and old_title != title
|
||||
if _is_switch and not SWITCH_OK:
|
||||
print("\n⛔ **这是「切换目标」,需要用户确认 —— 已拒绝写入。**")
|
||||
print(" 当前目标:%s" % old_title)
|
||||
print(" 拟换为 :%s" % title)
|
||||
print()
|
||||
print(" 用户口径(2026-10-05 定案,逐字):")
|
||||
print(" 「一个工作区 同时只能执行一个目标,如果要切换目标,**需要用户确认**,")
|
||||
print(" 然后切换和关注检查切换后的目标」")
|
||||
print()
|
||||
print(" ⇒ 请**先向用户确认**「要放弃当前目标、换成新的吗」;")
|
||||
print(" 得到确认后,在原命令上补 `--switch-goal` 重跑:")
|
||||
print(" python goalctl.py declare --title \"%s\" --switch-goal --yes" % title[:40])
|
||||
print(" ⚠️ `--switch-goal` ⛔ **不等于** `--yes` —— 前者是「用户同意换目标」,")
|
||||
print(" 后者只是「干跑转真写」。⛔ 不确认就带上它 = 伪造用户授权。")
|
||||
print("\n⇒ 本轮**一个字节都没写**。")
|
||||
return 3
|
||||
|
||||
if _is_switch:
|
||||
print(" 🔴 换目标已确认(`--switch-goal`)⇒ 旧目标将**整份归档**到 `%s`" % GOALS_DIR)
|
||||
new = dict(g)
|
||||
new["title"] = title
|
||||
# 🔴🔴🔴 2026-10-05 **换目标 ⇒ 自动把 `lifecycle` 复位成「进行中」**(用户定案:
|
||||
# 「一个工作区 同时只能执行一个目标,如果要切换目标,需要用户确认,**然后切换和关注检查
|
||||
# 切换后的目标**」)。
|
||||
# **改前事实**:`declare` **从不碰 `lifecycle`**(全文只在上文末尾打一句
|
||||
# 「别忘了同步 `lifecycle`(⛔ `declare` 不碰它)」让人手补)。
|
||||
# **为什么必须自动做**(不是"顺手"):
|
||||
# ① 旧目标做完了 ⇒ `lifecycle` 停在「已完成」⇒ 换到新目标后**机制照旧不拉**
|
||||
# (判据见 `collabd.py`:`life != 进行中` 时只在"队列还有非 done 件"才拉)
|
||||
# ⇒ **新目标没人检查** —— 与用户"切换后要关注检查"的要求**方向相反**。
|
||||
# ② 靠人手补=**又一条"要记得"的待办**(同族:用户 2026-10-05 原话「最好是记录会话,
|
||||
# 这样就不用每次会话都去做声明了」——凡"要人记"的都会忘)。
|
||||
# ⚠️ **不是破坏性动作**:旧 `goal.json` **整份归档**(下面紧跟的归档步),旧值可回查;
|
||||
# 且只在**已确认换目标**(`--switch-goal`)时做,⛔ 纯细化(只改措辞/补 why/kpi)不触发。
|
||||
# ⚠️ **只动 `lifecycle` 三件套**,⛔ 不碰 `acceptance_state`(那是上一条目标的判据,
|
||||
# 另有静默陷阱告警处理;清它会毁证据 —— 见下面那段 `--kpi` 警告)。
|
||||
if _is_switch:
|
||||
_old_life = str(g.get("lifecycle") or "").strip()
|
||||
new["lifecycle"] = "进行中"
|
||||
new["lifecycle_at"] = time.strftime("%Y-%m-%dT%H:%M:%S")
|
||||
new["lifecycle_by"] = "goalctl declare --switch-goal(换目标自动复位)"
|
||||
new["lifecycle_why"] = ("换目标 ⇒ 复位(改前=%s);旧目标整份归档,可在归档件里回查"
|
||||
% (_old_life or "未声明"))
|
||||
print(" 🔴 `lifecycle` 自动复位:%s ⇒ 进行中" % (_old_life or "(未声明)"))
|
||||
print(" 理由:不复位 ⇒ 新目标**不被检查**(机制只在『进行中』时才拉检查会话)")
|
||||
# 🔴 2026-10-01 加(**静默陷阱告警**):换目标名却不给 `--kpi` ⇒ 上一条目标的验收判据
|
||||
# 会**原样留着** ⇒ 新目标可能**一声明就被判「全过」**、机制完全不排队不投递(实测踩到:
|
||||
# 新目标零件零判据,常驻程序却报「目标三路全过」,digest 写「没有可推进的活」)。
|
||||
# ⛔ 这里**只警告、不擅自清**(清了会让"只想改目标措辞"的人丢掉整表验收判据 —— 那是破坏性动作)。
|
||||
if (g.get("title") or "") != title and kpi is _SURE:
|
||||
_oldacc = [k for k in (g.get("acceptance_state") or {}) if not str(k).startswith("_")]
|
||||
if _oldacc:
|
||||
print(" 🔴 **注意(静默陷阱)**:你没给 `--kpi` ⇒ 上一条目标的验收判据会**原样留下** ——")
|
||||
print(" 现存判据:%s" % "、".join(_oldacc))
|
||||
print(" ⚠️ 新目标可能**一声明就被判『全过』**、机制不排队不投递(判据不绑目标)。")
|
||||
print(" 要换判据:加 `--kpi \"V1=过;V2=未过\"`(留空表也请显式给 `--kpi \"\"`)。")
|
||||
if why is not _SURE:
|
||||
new["why"] = why
|
||||
print(" 为什么 :%s" % (why or "(清空)"))
|
||||
if topics is not _SURE:
|
||||
ts = _items(topics)
|
||||
print(" 任务类别:%s" % (("、".join(ts)) if ts else "(清空 ⇒ 回落目标简称,单类别)"))
|
||||
if "_" in topics:
|
||||
print(" ⚠️ 以 `_` 开头的类别名会被当『说明行』忽略 —— 类别名请取正常词")
|
||||
new["topics"] = ts
|
||||
new["topics_declared_at"] = time.strftime("%Y-%m-%dT%H:%M")
|
||||
new["topics_declared_by"] = by
|
||||
if kpi is not _SURE:
|
||||
acc, dropped = {}, []
|
||||
for seg in [x for x in str(kpi).replace(";", ";").split(";") if x.strip()]:
|
||||
if "=" not in seg:
|
||||
print(" ⚠️ 判据 `%s` 没有 `=` ⇒ 跳过(要 `名字=值`;值可写 `pass`,"
|
||||
"也可写中文 `过`/`已过`/`达` —— 判定层会归一化,⛔ 不是只认 `pass`)"
|
||||
% seg.strip())
|
||||
continue
|
||||
k, v = seg.split("=", 1)
|
||||
acc[k.strip()] = v.strip()
|
||||
for k in (g.get("acceptance_state") or {}):
|
||||
if str(k).startswith("_"):
|
||||
dropped.append(k)
|
||||
if dropped:
|
||||
print(" ⚠️ 重写验收会**丢掉历史说明行**:%s(如需保留,请把它们写进新表)" % "、".join(dropped))
|
||||
new["acceptance_state"] = acc
|
||||
_acc_s = "、".join("%s=%s" % (k, v) for k, v in acc.items()) or "(一条都没有 ⇒ 会显式判『未声明』)"
|
||||
print(" 验收判据:%s" % _acc_s)
|
||||
|
||||
if not DO:
|
||||
print("\n⇒ 这是干跑(⛔ 一个字节都没写)。要真写:末尾加 `--yes`")
|
||||
if _is_switch:
|
||||
print(" ⚠️ 本命令是**换目标** ⇒ 真写时还需要 `--switch-goal`(确认闸已过则不必再加)")
|
||||
return 0
|
||||
|
||||
# 🔴🔴 2026-10-05 **旧目标整份归档**(换目标时;⛔ 不许原地覆盖丢掉)。
|
||||
# 落点:`tmp/supervise-inbox/goals/<旧标题≤40字>__<YYYYMMDD-HHMM>.json`
|
||||
# ⛔ 文件名净化:去掉 `/ \ : * ? " < > |`(Windows 非法字符)+ 折叠空白。
|
||||
if _is_switch:
|
||||
try:
|
||||
GOALS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
_safe = re.sub(r'[\\/:*?"<>|\r\n\t]+', "-", old_title).strip()[:40] or "untitled"
|
||||
_dst = GOALS_DIR / ("%s__%s.json" % (_safe, time.strftime("%Y%m%d-%H%M")))
|
||||
_n = 1
|
||||
while _dst.exists():
|
||||
_dst = GOALS_DIR / ("%s__%s_%d.json" % (_safe, time.strftime("%Y%m%d-%H%M"), _n))
|
||||
_n += 1
|
||||
_dst.write_text(json.dumps(g, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8", newline="\n")
|
||||
new["_前身目标"] = ("%s(已于 %s 换目标时归档至 goals/%s)"
|
||||
% (old_title, time.strftime("%Y-%m-%dT%H:%M"), _dst.name))
|
||||
print(" ✅ 旧目标已归档:goals/%s(%d 字节)" % (_dst.name, _dst.stat().st_size))
|
||||
except Exception as e:
|
||||
# ⚠️ 归档失败 ⇒ **不阻断换目标**(用户已确认),但必须**喊出来**(⛔ 不许静默)
|
||||
print(" 🔴 **旧目标归档失败**:%s" % e)
|
||||
print(" ⚠️ 旧目标**未被保存** —— 若还要它,请先手工备份 `%s`" % GOAL)
|
||||
new["declared_at"] = time.strftime("%Y-%m-%dT%H:%M")
|
||||
new["declared_by"] = by
|
||||
_write_json(GOAL, new)
|
||||
print("\n⇒ 已登记(落点 `%s`)。" % GOAL)
|
||||
if _is_switch:
|
||||
print(" 🔴 **切换完成** —— 从现在起,机制**关注和检查的是新目标**:%s" % title[:60])
|
||||
print(" ✅ `lifecycle` 已**自动复位为「进行中」**(2026-10-05 起 `declare` 自己管它,⛔ 不用手补)")
|
||||
print(" ⚠️ 若新目标其实**还没开工**,再手动改成别的:")
|
||||
print(" python collabd.py --set-life 等待 --by \"主会话\"")
|
||||
else:
|
||||
print(" 🔴 目标**不是固定的**:下次调用技能时在对话里再说一遍 ⇒ 用 `declare` 覆盖即可。")
|
||||
return 0
|
||||
|
||||
|
||||
# ── 统一唤醒入口 ───────────────────────────────────────────────────────
|
||||
def _collabd() -> Path:
|
||||
"""常驻程序本体(**技能里的通用代码**,靠 `COLLABD_CONFIG` 指向本工作区配置)。"""
|
||||
base = Path(os.environ.get("CODEBUDDY_CONFIG_DIR") or os.path.expanduser("~/.workbuddy"))
|
||||
for p in (Path(os.environ.get("COLLABD_SCRIPT") or ""),
|
||||
# 🔴 2026-10-01:原 `multi-session-collab` 已并入 `session-mechanism`(本包即主技能)
|
||||
# ⇒ **新落点优先**;旧落点只作**过渡兜底**(⛔ 别删 —— 包未装/被移走时不至于
|
||||
# 静默返回空 ⇒ 唤醒入口静默停摆。那正是本文件 docstring 记过的原事故)。
|
||||
base / "skills" / "session-mechanism" / "scripts" / "collabd.py",
|
||||
base / "skills" / "multi-session-collab" / "scripts" / "collabd.py"):
|
||||
if p and p.is_file():
|
||||
return p
|
||||
return Path()
|
||||
|
||||
|
||||
# `. deliver=` 字段的读法(值来自 `_deliver_str` / `supervise`)
|
||||
# ⚠️ 本表必须与 `collabd._deliver_str()` 实际写出的 `skipped=` 值**逐字同款**。⛔ 漏登记不是"少一句话":
|
||||
# 它会落进下面的兜底分支、被**静默当成「降级自己干」** —— 实测 2026-10-01:`no-follow-session`/
|
||||
# `follow-not-live`/`target-deaf` 三个新值**一个都没登记** ⇒ 打印一句「未登记的跳过原因」然后**抢锁去干活**。
|
||||
_WHY = {
|
||||
"target-busy": ("结束", "⏸ **跟进会话正在执行** ⇒ 不必打扰,⛔ 绝不能降级(降级=同一件事干两遍)"),
|
||||
"same-item": ("结束", "✓ 同样内容刚投过 ⇒ 不重复"),
|
||||
"too-soon": ("结束", "✓ 距上次投递太近 ⇒ 不重复(去抖)"),
|
||||
"locked": ("结束", "→ 另一进程正在投 ⇒ 让给它"),
|
||||
"disabled": ("结束", "⛔ `wake_enable=false` ⇒ 本需求**已停**(要开:goalctl start --yes)"),
|
||||
"no-token": ("异常", "🔴 拿不到网关口令 —— 说明**没跑在宿主进程树内**,当前架构下不该发生"),
|
||||
# 🔴 2026-10-01 新增三类:**没人能收**(缺的是「跟进会话」,⛔ 不是「没人干活」)⇒ 喊用户,⛔ 不降级
|
||||
"no-follow-session": ("异常", "🔴 本工作区**一条 `[跟进]-<类别>-…` 都没有** ⇒ 上报没人收"
|
||||
"(常驻程序已写 `NEED-USER.md`)⇒ 按开工清单建一条跟进会话"),
|
||||
"follow-not-live": ("异常", "🔴 那条跟进会话**此刻不在活会话里**(已关/已换)⇒ 程序拒绝盲投"
|
||||
"(常驻程序已写 `NEED-USER.md`)"),
|
||||
# 🔴🔴 2026-10-02 两类改造:**跟进会话整套退役**(`follow_for_topic()` 已短路成这一档)。
|
||||
# ⛔ 标「需人看」而非「异常」:⛔ **不许再引导去"建跟进会话"**(那条路已不存在),
|
||||
# ⛔ 也不许降级去抢锁自己干(那会变成"没人派活就自己干",掩盖了"这条没人接"的真状态)。
|
||||
# 🔴🔴 2026-10-03 **投递段已真删** ⇒ 这一档**生产侧已无来源**(`supervise()` 不再调
|
||||
# `_deliver_str()`)⇒ 只在**单测**与**将来重建收件人**时才可能读到它。
|
||||
# ⛔ 保留登记(⛔ 不删):它是"当年这条判据存在过"的机读痕迹,删了将来无法分辨
|
||||
# 「从没这档」与「把这档删了」。**登记文案按投递退役后的口径改**(旧文案里
|
||||
# "别等上报"隐含"还有上报可等",⛔ 现在是**根本没有上报**)。
|
||||
"follow-retired": ("需人看", "⚪ **投递链路已无收件人**(2026-10-02 退役唤醒/跟进;2026-10-03 投递段已真删)"
|
||||
"⇒ ⛔ **不存在「等通知」这条路**了:要落这件事,**显式建一条任务会话**去读队列干活"),
|
||||
"target-deaf": ("异常", "🔴 目标跟进会话**已哑**(诊断日志撞 ~10 MiB ⇒ 界面不再刷新)⇒ 投了也看不见,"
|
||||
"须把它换到一条新会话"),
|
||||
"no-live-session": ("降级", "🔴 **整机没有任何活会话** ⇒ 没有可投的对象(只有这一档才降级:自己就是那条活的)"),
|
||||
"no-gateway": ("异常", "🔴 没扫到网关口"),
|
||||
# ⛔ 下面两个只剩在**已停用**的 `wake_round()`(2026-09-29)里作死代码 ——
|
||||
# `resolve_main()` 现在**只服务看板读数**,⛔ 不再是投递判读 ⇒ 见到它们就说明**有代码在走旧路径**。
|
||||
"main-not-live": ("异常", "⚠️ 旧路径的值(主会话不在活会话里)—— 现行投递⛔ 不产出它,请查谁在走旧路径"),
|
||||
"no-main-session": ("异常", "⚠️ 旧路径的值(解析不出主会话)—— 现行投递⛔ 不产出它,请查谁在走旧路径"),
|
||||
}
|
||||
|
||||
|
||||
def wake() -> int:
|
||||
print("═══ 拨闹钟 · 常驻程序判条件并投递(%s)═══" % time.strftime("%H:%M:%S"))
|
||||
if str((_load(CFG, {}) or {}).get("wake_enable")).lower() == "false":
|
||||
print("⛔ 程序闸已关(wake_enable=false)⇒ 不投递,直接结束。要开:goalctl start --yes")
|
||||
return 0
|
||||
cb = _collabd()
|
||||
if not cb.is_file():
|
||||
print("⛔ 找不到常驻程序 collabd.py(⛔ 不猜路径;可用 COLLABD_SCRIPT 指定)")
|
||||
return 3
|
||||
env = dict(os.environ)
|
||||
env["COLLABD_CONFIG"] = str(CFG)
|
||||
try:
|
||||
r = subprocess.run([sys.executable, str(cb), "--tick"], cwd=str(WS), env=env,
|
||||
capture_output=True, text=True, timeout=180, errors="replace",
|
||||
creationflags=0x08000000) # CREATE_NO_WINDOW:⛔ 闪窗
|
||||
except Exception as e:
|
||||
print("⛔ 调常驻程序失败:%s" % e)
|
||||
return 3
|
||||
out = (r.stdout or "") + (("\n" + r.stderr) if r.stderr else "")
|
||||
line = next((l for l in out.splitlines() if l.startswith("tick:")), "")
|
||||
if not line:
|
||||
print("⚠️ 常驻程序没给出 `tick:` 结论(rc=%s)⇒ 按「没投出去」处理(⛔ 不假装成功)" % r.returncode)
|
||||
print("\n".join(" " + x for x in out.strip().splitlines()[:8]))
|
||||
return 1
|
||||
f = dict(x.split("=", 1) for x in line[6:].split(" ") if "=" in x)
|
||||
dv = f.get("deliver", "-")
|
||||
print(" 常驻程序结论:%s" % line)
|
||||
print()
|
||||
if dv.startswith("http"):
|
||||
code = dv.split("=", 1)[-1]
|
||||
print("✅ 判定:**已投递**(http %s)⇒ 主会话已被唤醒。" % code)
|
||||
print("⇒ 本轮**就此结束**:⛔ 不抢域锁、⛔ 不派活、⛔ 不起新会话(唤醒已由常驻程序完成)。")
|
||||
return 0
|
||||
if dv == "-":
|
||||
print("✓ 判定:**没有需要投的东西**(队列无新变化 / 无停滞)⇒ 静默。")
|
||||
print("⇒ 本轮**就此结束**(这就是「符合条件时才唤」的否定面 —— 零打扰)。")
|
||||
return 0
|
||||
# ⚠️ 兜底=**需人看**,⛔ 不是降级:判读表漏登记时**绝不许静默抢锁去干活**(2026-10-01 实测踩过)。
|
||||
kind, why = _WHY.get(dv, ("异常", "🔴 **未登记的跳过原因 `%s`** —— 投递判读表漏登记(⛔ 不是「没人干活」)" % dv))
|
||||
print("◻ 判定:**未投出**(%s)—— %s" % (dv, why))
|
||||
if kind == "结束":
|
||||
print("⇒ 本轮**就此结束**:⛔ 不抢域锁、⛔ 不派活。")
|
||||
return 0
|
||||
if kind == "异常":
|
||||
print("⇒ 🔴 **需人看**:这不是正常路径(详见 tmp/supervise-inbox/NEED-USER.md)。本轮结束。")
|
||||
return 2
|
||||
print("⇒ **降级**:没有可唤醒的对象 ⇒ 你(本次自动任务起的会话)**就是那个会话**。")
|
||||
print(" 请抢域锁后按状态表干活:")
|
||||
print(" bash D:/github/dsh_shenxian/dsh-server-docs/07-scripts/handoff-guard.sh "
|
||||
"--claim-exec \"<你的会话名>\" --domains ai1net-dsh-server")
|
||||
return 10
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if CMD in ("status", "st", "s"):
|
||||
sys.exit(status())
|
||||
if CMD in ("wake", "wk", "tick"):
|
||||
sys.exit(wake())
|
||||
if CMD in ("stop", "down"):
|
||||
sys.exit(stop())
|
||||
if CMD in ("start", "up"):
|
||||
sys.exit(start())
|
||||
if CMD in ("declare", "set", "goal"):
|
||||
sys.exit(declare())
|
||||
print(__doc__.split("## 用法")[1].split("⚠️")[0].strip())
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,293 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""guard —— **守护程序**:保证「协作程序」与「监督程序」**一直在运行**。
|
||||
|
||||
🔴 架构(2026-09-29 用户定案):**投递与协作程序应该一直运行,用守护程序去保护** —— 本件即那个守护程序。
|
||||
职责只有一件:**拉起 + 看护 + 挂了重拉 + 单例**。
|
||||
|
||||
⛔ 边界(红线):
|
||||
· ⛔ 不开新会话、⛔ 不派活(不写排期)—— 那是主会话/自动化的事
|
||||
· ⛔ 不读口令、⛔ 不联网、⛔ 不写宿主库、⛔ 不碰用户的会话
|
||||
· 全静默(只写自己的日志);fail-safe:本程序异常 ⇒ 只退出自己,⛔ 不影响 WorkBuddy
|
||||
|
||||
⚠️ **起法(实测约束,⛔ 别从会话里起)**:
|
||||
· ⛔ 从 WorkBuddy 会话/工具调用里起的进程,**调用一结束就被回收**(2026-09-29 当场实测:唤醒停在调用结束那一秒,ps 也查不到)
|
||||
· ⛔ `schtasks`(计划任务)被本机安全策略**硬拦**,且明令不得绕过
|
||||
· ✅ 正确起法:**在独立窗口里跑一次**(`python guard.py`,或用同目录的 `启动守护.cmd`),
|
||||
或把它的快捷方式放进「启动」文件夹(`%APPDATA%\\Microsoft\\Windows\\Start Menu\\Programs\\Startup`)⇒ 开机自启
|
||||
⇒ 由**会话之外**起的进程**不挂在任何 WorkBuddy 会话下** ⇒ 不会被回收。
|
||||
|
||||
用法:
|
||||
python guard.py # 常驻守护(前台;独立窗口里跑)
|
||||
python guard.py --status # 只报当前状态
|
||||
python guard.py --stop # 让守护程序退出(写停止标志;子程序由守护收尾时一并结束)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
def _cfg() -> dict:
|
||||
"""读部署配置(与 collabd.py 同一份)—— 🔴 **inbox 必须两边一致**,否则两个程序不在同一个
|
||||
inbox 工作(自测挖出:guard 原先把它硬编码成 `tmp/supervise-inbox`,与配置里的 `inbox` 可能不同)。"""
|
||||
p = Path(os.environ.get("COLLABD_CONFIG") or (Path(__file__).resolve().parent / "collabd.config.json"))
|
||||
try:
|
||||
d = json.loads(p.read_text(encoding="utf-8"))
|
||||
return d if isinstance(d, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
_C = _cfg()
|
||||
_ws_env = os.environ.get("DSH_COLLAB_WS") or ""
|
||||
WS = Path(_ws_env) if _ws_env else (Path(_C["workspace"]) if _C.get("workspace") else Path.cwd())
|
||||
INBOX = (WS / str(_C.get("inbox") or "tmp/supervise-inbox"))
|
||||
LOG = INBOX / "guard.log"
|
||||
STOP = INBOX / "guard.stop"
|
||||
PIDF = INBOX / "guard.json"
|
||||
COLLABD = os.path.join(os.path.dirname(os.path.abspath(__file__)), "collabd.py")
|
||||
|
||||
|
||||
def _win_pythonw():
|
||||
"""常驻/后台子进程优先 pythonw.exe(GUI 子系统,Windows 永不为其分配控制台)⇒ 根治闪窗。
|
||||
⛔ 不存在时回退 sys.executable。"""
|
||||
if os.name != "nt":
|
||||
return sys.executable
|
||||
_d = os.path.dirname(os.path.abspath(sys.executable))
|
||||
_pw = os.path.join(_d, "pythonw.exe")
|
||||
return _pw if os.path.isfile(_pw) else sys.executable
|
||||
PY = _win_pythonw()
|
||||
CHECK_EVERY = 15 # 秒:看护周期
|
||||
GOALF = INBOX / "goal.json" # 已确认的目标
|
||||
GOALP = INBOX / "goal.pending.json" # **待确认**的目标(用户还没点头)
|
||||
GOAL_NAME = "目标守护进程" # 对外正式名(文件名仍 guard.py,⛔ 不为改名掀引用)
|
||||
STALE = 3 * CHECK_EVERY # 秒:唤醒过期 ⇒ 判前一个守护已死
|
||||
|
||||
|
||||
def log(m: str) -> None:
|
||||
try:
|
||||
LOG.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(LOG, "a", encoding="utf-8") as f:
|
||||
f.write("[%s] %s\n" % (time.strftime("%Y-%m-%d %H:%M:%S"), m))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _shutdown(kids, wait_s: int = 20) -> None:
|
||||
"""**优雅收尾**:先让子程序**自己退出**(它们每轮看停止标志/守护唤醒),等不到才兜底结束。"""
|
||||
for k in kids:
|
||||
for _ in range(wait_s):
|
||||
if not k.alive():
|
||||
break
|
||||
time.sleep(1)
|
||||
if k.alive():
|
||||
log("%s 未在 %d 秒内优雅退出 ⇒ 兜底结束" % (k.name, wait_s))
|
||||
try:
|
||||
k.p.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
log("%s 已优雅退出" % k.name)
|
||||
|
||||
|
||||
class Child:
|
||||
"""一个被看护的子程序(同一个文件的不同模式)。"""
|
||||
|
||||
def __init__(self, name: str, args: list):
|
||||
self.name, self.args, self.p = name, args, None
|
||||
|
||||
def alive(self) -> bool:
|
||||
return self.p is not None and self.p.poll() is None
|
||||
|
||||
def ensure(self) -> None:
|
||||
if self.alive():
|
||||
return
|
||||
try:
|
||||
self.p = subprocess.Popen([PY, "-u", COLLABD] + self.args, cwd=str(HERE),
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
close_fds=True,
|
||||
creationflags=0x08000000 | 0x00000008 | 0x00000200, # NO_WINDOW | DETACHED | NEW_PROCESS_GROUP
|
||||
env=dict(os.environ, DSH_GUARDED="1")) # 打标:子程序据此做优雅退出
|
||||
log("%s 已拉起 pid=%s" % (self.name, getattr(self.p, "pid", "?")))
|
||||
except Exception as e:
|
||||
log("%s 拉起失败:%s" % (self.name, e))
|
||||
|
||||
|
||||
def _stale() -> bool:
|
||||
"""用"唤醒是否过期"判前一个守护是否已死 —— ⛔ 不依赖任何进程 API、⛔ 不自造锁协议。"""
|
||||
try:
|
||||
d = json.loads(PIDF.read_text(encoding="utf-8"))
|
||||
return (time.time() - float(d.get("ts") or 0)) > STALE
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if "--goal" in sys.argv: # 声明/改写**任务目标**(一句话)
|
||||
def _ga(k, dv=""):
|
||||
return sys.argv[sys.argv.index(k) + 1] if (k in sys.argv and sys.argv.index(k) + 1 < len(sys.argv)) else dv
|
||||
|
||||
t = _ga("--goal").strip()
|
||||
if not t:
|
||||
print("用法:guard.py --goal \"<一句话任务目标>\" (直接声明;推荐先 --propose 再 --confirm)")
|
||||
return 2
|
||||
try:
|
||||
cur = {}
|
||||
if (INBOX / "goal.json").exists():
|
||||
cur = json.loads((INBOX / "goal.json").read_text(encoding="utf-8")) or {}
|
||||
cur["title"] = t
|
||||
cur["declared_at"] = time.strftime("%Y-%m-%dT%H:%M")
|
||||
INBOX.mkdir(parents=True, exist_ok=True)
|
||||
(INBOX / "goal.json").write_text(json.dumps(cur, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
except Exception as e:
|
||||
print("写目标失败:%s" % e)
|
||||
return 2
|
||||
print("OK 已声明任务目标:%s" % t)
|
||||
return 0
|
||||
if "--propose" in sys.argv: # ① 用户说明目标 ⇒ 落成**待确认**,并回述
|
||||
def _gp(k, dv=""):
|
||||
return sys.argv[sys.argv.index(k) + 1] if (k in sys.argv and sys.argv.index(k) + 1 < len(sys.argv)) else dv
|
||||
|
||||
raw = _gp("--propose").strip()
|
||||
if not raw:
|
||||
print("用法:guard.py --propose \"<用户对目标的原话>\"")
|
||||
return 2
|
||||
rec = {"title": raw, "proposed_at": time.strftime("%Y-%m-%dT%H:%M"),
|
||||
"confirmed": False,
|
||||
"_下一步": "把这份理解回述给用户 ⇒ 用户点头后跑 --confirm(之后才允许启动)"}
|
||||
try:
|
||||
INBOX.mkdir(parents=True, exist_ok=True)
|
||||
GOALP.write_text(json.dumps(rec, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
except Exception as e:
|
||||
print("写待确认目标失败:%s" % e)
|
||||
return 2
|
||||
print("⏳ 已记录**待确认**目标(%s):\n %s\n"
|
||||
" ⇒ 请把理解回述给用户,用户确认后再跑: guard.py --confirm" % (GOAL_NAME, raw))
|
||||
return 0
|
||||
if "--confirm" in sys.argv: # ② 用户点头 ⇒ 转正(**这一步之后才允许启动**)
|
||||
try:
|
||||
rec = json.loads(GOALP.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
print("没有待确认的目标(`%s` 不存在)⇒ 先跑 --propose \"<用户原话>\"" % GOALP)
|
||||
return 2
|
||||
rec["confirmed"] = True
|
||||
rec["confirmed_at"] = time.strftime("%Y-%m-%dT%H:%M")
|
||||
rec["confirmed_by"] = "用户"
|
||||
rec.pop("_下一步", None)
|
||||
try:
|
||||
GOALF.write_text(json.dumps(rec, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
GOALP.unlink()
|
||||
except Exception as e:
|
||||
print("写已确认目标失败:%s" % e)
|
||||
return 2
|
||||
print("✅ 目标已确认:%s\n ⇒ 现在可以启动%s了(独立窗口跑 start-guard.cmd,或用宿主后台机制起)"
|
||||
% (rec.get("title"), GOAL_NAME))
|
||||
return 0
|
||||
if "--status" in sys.argv:
|
||||
try:
|
||||
print(json.dumps(json.loads(PIDF.read_text(encoding="utf-8")), ensure_ascii=False, indent=1))
|
||||
except Exception:
|
||||
print("守护程序未在运行(无 %s)" % PIDF)
|
||||
return 0
|
||||
if "--stop" in sys.argv:
|
||||
try:
|
||||
STOP.parent.mkdir(parents=True, exist_ok=True)
|
||||
STOP.write_text("stop", encoding="utf-8")
|
||||
print("已写停止标志:%s" % STOP)
|
||||
except Exception as e:
|
||||
print("写停止标志失败:%s" % e)
|
||||
return 0
|
||||
|
||||
# 🔴 2026-09-29 用户定案(新逻辑):**用户说明目标 ⇒ 机制理解并回述 ⇒ 与用户确认 ⇒ 才允许启动**。
|
||||
# 闸 ①:还有"待确认"的目标 ⇒ ⛔ 拒绝启动(不拿没确认过的东西当运行中心)
|
||||
if (INBOX / "goal.pending.json").exists():
|
||||
try:
|
||||
_p = json.loads(GOALP.read_text(encoding="utf-8"))
|
||||
_pt = str(_p.get("title") or "")
|
||||
except Exception:
|
||||
_pt = ""
|
||||
print("⛔ 拒绝启动:**目标还没和用户确认**(待确认内容:%s)\n"
|
||||
" 流程:把理解回述给用户 ⇒ 用户点头 ⇒ 跑 `guard.py --confirm` ⇒ 再启动。\n"
|
||||
" ⛔ 未经确认的目标不得作为运行中心。" % (_pt[:80] or "(空)"))
|
||||
return 4
|
||||
# 🔴 闸 ②:**没有已确认目标就拒绝启动** —— 机制的一切判定(需求是否完成/派活/唤醒)
|
||||
# 都围绕目标;没目标 ⇒ 不知道该做什么,宁可**不开**(⛔ 不空转)。
|
||||
try:
|
||||
_goal = json.loads((INBOX / "goal.json").read_text(encoding="utf-8")) if (INBOX / "goal.json").exists() else {}
|
||||
except Exception:
|
||||
_goal = {}
|
||||
if not str((_goal or {}).get("title") or "").strip():
|
||||
print("⛔ 拒绝启动:**没有任务目标** —— 这套机制围绕目标运行,无目标就不知道在为什么跑。\n"
|
||||
" 两种声明方式:\n"
|
||||
" ① 一句话写入: <python> guard.py --goal \"<任务目标>\"\n"
|
||||
" ② 手写文件: %s\n"
|
||||
" (至少要有 title;可另附 acceptance_doc / taskgraph / lines / topics)" % (INBOX / "goal.json"))
|
||||
return 3
|
||||
if PIDF.exists() and not _stale():
|
||||
try:
|
||||
old = json.loads(PIDF.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
old = {}
|
||||
print("已有守护程序在跑(pid=%s,唤醒 %ss 前)⇒ 本进程退出"
|
||||
% (old.get("pid"), int(time.time() - float(old.get("ts") or 0))))
|
||||
return 0
|
||||
try:
|
||||
STOP.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
info = {"pid": os.getpid(), "since": time.strftime("%Y-%m-%dT%H:%M:%S"), "ts": time.time(),
|
||||
"ws": str(WS)}
|
||||
kids = [Child("常驻程序", []), Child("投递", ["--supervise"])]
|
||||
log("%s 启动 pid=%d ws=%s 目标=%s" % (GOAL_NAME, os.getpid(), WS, _goal.get("title")))
|
||||
# 启动对账:收编"队列开启前就已在跑的棒" + 标出僵尸(不投递、幂等)
|
||||
# fail-safe:超时 30 秒、吞错、不显窗(CREATE_NO_WINDOW)
|
||||
try:
|
||||
subprocess.run([PY, "-u", COLLABD, "--reconcile"], cwd=str(HERE),
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30,
|
||||
creationflags=0x08000000)
|
||||
log("启动对账已跑")
|
||||
except Exception as e:
|
||||
log("启动对账失败(不影响启动):%s" % e)
|
||||
n = 0
|
||||
while True:
|
||||
if STOP.exists():
|
||||
log("收到停止标志 ⇒ 守护程序退出")
|
||||
break
|
||||
for k in kids:
|
||||
k.ensure()
|
||||
info["ts"] = time.time()
|
||||
info["children"] = {"%s" % k.name: ("alive" if k.alive() else "dead") for k in kids}
|
||||
try:
|
||||
PIDF.write_text(json.dumps(info, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
n += 1
|
||||
if n % 20 == 0:
|
||||
log("看护中 %s" % " | ".join("%s=%s" % (k.name, "活" if k.alive() else "死") for k in kids))
|
||||
try:
|
||||
time.sleep(CHECK_EVERY)
|
||||
except KeyboardInterrupt: # Ctrl+C 也要走**优雅收尾**(⛔ 不能直接死掉丢下孩子)
|
||||
log("收到 Ctrl+C ⇒ 优雅收尾")
|
||||
break
|
||||
|
||||
_shutdown(kids) # 🔴 守护关 ⇒ 两个程序**优雅退出**(等它们自己退,再兜底)
|
||||
try:
|
||||
PIDF.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
log("fatal %s" % e)
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,377 @@
|
||||
#!/usr/bin/env python
|
||||
"""🔴 **新建协作工作区**(技能内脚本,用户 2026-10-03 要求"每次使用任务会话机制都可以有对应的目标文件夹")。
|
||||
|
||||
用法:
|
||||
python init_workspace.py <工作区绝对路径> [--title 目标标题] [--short 目标简称]
|
||||
[--port 单例端口] [--no-main-session]
|
||||
|
||||
做四件事(⛔ 顺序固定,缺一件机制就跑不起来):
|
||||
① 建工作区骨架(`.workbuddy/collab/`、`tmp/supervise-inbox/`、`交付物/`)
|
||||
② 写**部署配置** `collabd.config.json`(`COLLABD_CONFIG` 指向它 ⇒ `collabd.py` 才认这个工作区)
|
||||
③ 写**目标** `goal.json`(title/short/topics/lifecycle=进行中/taskgraph)
|
||||
③b 写**最小任务图**(⛔ 漏了它 ⇒ 常驻每轮报「读任务图失败」,且目标判据缺一条腿)
|
||||
④ 跑 `collabd.py --ensure-goal-dir` ⇒ **建目标文件夹 + 落《目标执行状态.md》骨架**(幂等)
|
||||
⑤ 建一条**主会话排期**(`automation_update` 由调用方做 ⇒ 本脚本只**打印**它该用的参数,
|
||||
⛔ 不自己写宿主库 —— 创建新会话**只能走自动化入口**,这是宿主规矩)
|
||||
|
||||
🔴 关键约束(都是踩过的):
|
||||
· `workspace` 必须**正斜杠**(⛔ 反斜杠会让 `cwds` 裂组)
|
||||
· `singleton_port` **每个工作区一个**(⛔ 复用 ⇒ 常驻单例互抢、静默只有一个活着)
|
||||
· `board_ext` 指 `.RETIRED-*` ⇒ 新工作区**不带**使用方扩展(技能侧零项目串)
|
||||
· 排期名必须是两级前缀 `[主]-<类别>-<具体>`(⛔ 光杆名 ⇒ 角色判不出,看板认不出)
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
SK = Path(__file__).resolve().parent
|
||||
DEFAULT_TITLE = "检查会话协作是否运行正常 + 协作机制问题排查与修复"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("workspace")
|
||||
ap.add_argument("--title", default=DEFAULT_TITLE)
|
||||
ap.add_argument("--short", default="")
|
||||
ap.add_argument("--topics", default="会话协作自检,机制排查与修复")
|
||||
ap.add_argument("--port", type=int, default=0)
|
||||
ap.add_argument("--python", default=sys.executable)
|
||||
# ⛔ 2026-10-06 删掉 `--no-task` / `--with-keeper` 两个开关:
|
||||
# 它们的存在前提是"本脚本会铺 ps1 并登记计划任务",而那条形态**已整套废弃**(P0-74)。
|
||||
# ⇒ 常驻的开/关/自愈统一走 `collabctl.py on|off|ensure`,⛔ 本脚本不再碰任务。
|
||||
a = ap.parse_args()
|
||||
|
||||
ws = Path(a.workspace).resolve()
|
||||
ws_s = str(ws).replace("\\", "/") # 🔴 正斜杠(红线)
|
||||
short = a.short or ws.name
|
||||
topics = [t.strip() for t in a.topics.split(",") if t.strip()]
|
||||
# 🔴🔴 端口**跨进程稳定**(2026-10-04 修):原来用 `hash(ws_s)`,而
|
||||
# **Python 3.11+ 字符串 hash 每进程随机化**(PYTHONHASHSEED)⇒
|
||||
# **同一工作区每跑一次就换一个端口** ⇒ 旧守卫/防火墙/别的配置里的端口全部对不上。
|
||||
# ⇒ 改成 `zlib.crc32`(确定性、与版本无关);已有配置里写了端口就沿用它。
|
||||
import zlib
|
||||
derived = 20000 + (zlib.crc32(ws_s.encode("utf-8")) % 5000) # 按**工作区路径**算,天然各区不同
|
||||
port = a.port or derived
|
||||
_port_from = "按路径算出" if not a.port else "命令行指定"
|
||||
|
||||
print("=== 新建协作工作区:%s ===" % ws_s)
|
||||
# ⚠️ 端口目录靠后生成(要读旧配置才能定),但**冲突自检**要提前做 —— 见下方 `_port_clash`。
|
||||
_port_clash: list[str] = []
|
||||
|
||||
def _scan_peer_ports() -> dict:
|
||||
"""扫**本机其它工作区**的 `singleton_port`(只读)。
|
||||
|
||||
🔴🔴 2026-10-05 加(用户报「各工作区环境是否该独立」时坐实):
|
||||
实测三个区(`ai1net` / `vibe-product` / `agent-product`)的 `collabd.config.json`
|
||||
**`singleton_port` 全是 20099**(= `collabd.py` 的**默认值**)。
|
||||
真因不是算法 —— 算法按路径算,三区分别该是 `23924` / `24942` / `21826`;
|
||||
真因是**前两个区的 config 是早期手工建的,一直抄着默认值**,而
|
||||
`init_workspace` 的「沿用既有端口」**无条件信任**旧值 ⇒ 抄错的值**永远修不回来**。
|
||||
|
||||
⚠️ 后果(`init_workspace.py` 文件头早就写了):
|
||||
「`singleton_port` 每个工作区一个(⛔ 复用 ⇒ 常驻单例互抢、**静默只有一个活着**)」
|
||||
⇒ 这是**静默故障**:两个区都以为自己起了常驻,实际只有一个绑上端口。
|
||||
|
||||
做法:扫 `AIProject` 下各兄弟工作区的配置(本区**不算**),拿端口 ⇒ 撞了就报警并改用算出来的值。
|
||||
"""
|
||||
out: dict = {}
|
||||
try:
|
||||
parent = ws.parent # 通常= …/AIProject
|
||||
if not parent.is_dir():
|
||||
return out
|
||||
for sib in parent.iterdir():
|
||||
if not sib.is_dir() or sib.resolve() == ws:
|
||||
continue
|
||||
cp = sib / ".workbuddy" / "collab" / "collabd.config.json"
|
||||
if not cp.is_file():
|
||||
continue
|
||||
try:
|
||||
pv = int((json.loads(cp.read_text(encoding="utf-8")) or {}).get(
|
||||
"singleton_port") or 0)
|
||||
except Exception:
|
||||
continue
|
||||
if pv:
|
||||
out.setdefault(pv, []).append(sib.name)
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
# ① 骨架
|
||||
for d in (".workbuddy/collab", "tmp/supervise-inbox", "交付物", ".workbuddy/collab/logs"):
|
||||
(ws / d).mkdir(parents=True, exist_ok=True)
|
||||
print("① 骨架已建(.workbuddy/collab / tmp/supervise-inbox / 交付物 / logs)")
|
||||
|
||||
cfgp = ws / ".workbuddy/collab/collabd.config.json"
|
||||
gp = ws / "tmp/supervise-inbox/goal.json"
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
# 🔴🔴 2026-10-04 改为**幂等自配置**(用户:「我要求在任何工作区使用都能
|
||||
# **自己检查环境配置好环境**」)。原来本脚本**无条件重写** `collabd.config.json`
|
||||
# 与 `goal.json` ⇒ 对**已部署且有真实内容**的工作区是**破坏性**的
|
||||
# (实测 `vibe-product` 的 `targets` 里登记着 5 个真实目标 ⇒ 会被冲掉)。
|
||||
# ⇒ 现在:**先读现状 → 只补缺的 → 已有的一律不动**。
|
||||
_old_cfg = {}
|
||||
if cfgp.exists():
|
||||
try:
|
||||
_old_cfg = json.loads(cfgp.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
_old_cfg = {}
|
||||
# 🔴🔴 端口冲突自检 + 纠偏(2026-10-05 加)——
|
||||
# 「沿用既有」**不能无条件信任**:实测三区 config 里 `singleton_port` 全是 20099
|
||||
# (早期手工建配置时抄了 `collabd.py` 的默认值),而**谁都没报错**
|
||||
# ⇒ 常驻单例互抢、静默只有一个活着。⇒ 沿用前**先比对**:
|
||||
# · 旧值 == 按路径算出的值 ⇒ 正常沿用;
|
||||
# · 旧值 ≠ 算出值,且**别区占了它** ⇒ 🔴 报警 + **改用算出值**(抄错的值该修);
|
||||
# · 旧值 ≠ 算出值,但没有别区占用 ⇒ **沿用**(可能是刻意指定的,⛔ 不擅改)。
|
||||
_bad_port = False
|
||||
if _old_cfg.get("singleton_port"):
|
||||
_old_port = int(_old_cfg["singleton_port"])
|
||||
_peers = _scan_peer_ports()
|
||||
if _old_port == derived:
|
||||
port, _port_from = _old_port, "沿用既有(与算出值一致)"
|
||||
else:
|
||||
_who = _scan_peer_ports().get(_old_port) or []
|
||||
if _who:
|
||||
_bad_port = True
|
||||
print(" 🔴 **端口冲突**:本区既有 `singleton_port=%d` 已被 **%s** 占用。"
|
||||
% (_old_port, "、".join(_who)))
|
||||
print(" 🔴 后果:两区常驻**互抢单例** ⇒ ⛔ 静默只有一个活着(文件头 §关键约束 早写明)。")
|
||||
print(" 🔴 处置:改用按本区路径算出的 **%d**(=%s)。" % (derived, ws_s))
|
||||
port, _port_from = derived, "纠偏(原值与他区冲突)"
|
||||
_port_clash.append("%d ← %s" % (_old_port, "、".join(_who)))
|
||||
else:
|
||||
port, _port_from = _old_port, "沿用既有"
|
||||
_old_goal = {}
|
||||
if gp.exists():
|
||||
try:
|
||||
_old_goal = json.loads(gp.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
_old_goal = {}
|
||||
_reuse = bool(_old_cfg) or bool(_old_goal)
|
||||
if _reuse:
|
||||
print(" ♻️ **检测到既有部署**(配置 %s / 目标 %s)⇒ **只补缺,不覆盖已有**"
|
||||
% ("有" if _old_cfg else "⛔无", "有" if _old_goal else "⛔无"))
|
||||
|
||||
# ② 部署配置(**补缺式**:以旧配置为底,只加现行键;⛔ 不动用户改过的值)
|
||||
cfg = {
|
||||
"_说明": "本文件=**本工作区**的协作机制部署配置(⛔ 不属于技能)。由 `init_workspace.py` 生成;"
|
||||
"`COLLABD_CONFIG` 必须指向它,否则 `collabd.py` 不认这个工作区。",
|
||||
"workspace": ws_s,
|
||||
"inbox": "tmp/supervise-inbox",
|
||||
"live": "交付物/本机协作-实时状态.md",
|
||||
"taskgraph": "交付物/任务图-会话协作自检.json",
|
||||
"targets": {},
|
||||
"singleton_port": port,
|
||||
"interval": 10, "idle_min": 12, "stuck_min": 30, "vacuum_min": 5, "health_every": 300,
|
||||
"supervise_interval": 10,
|
||||
"wake_enable": False, # 🔴 唤醒/投递整套已退役
|
||||
"board_ext": ".workbuddy/collab/board_ext.RETIRED.py", # ⛔ 不带使用方扩展
|
||||
"log": ".workbuddy/collab/logs/_collabd.log",
|
||||
}
|
||||
if _old_cfg:
|
||||
_merged = dict(cfg)
|
||||
_merged.update(_old_cfg) # ⛔ 旧的**优先**(含 targets / 自定义路径)
|
||||
cfg = _merged
|
||||
_added = [k for k in cfg if k not in _old_cfg]
|
||||
cfg["workspace"] = ws_s # 这两项必须以本次为准(工作区可能搬过家)
|
||||
cfg["singleton_port"] = port # ⚠️ 纠偏时**必须以本次为准**(旧值可能在 `_old_cfg` 里,会被 update 盖回去)
|
||||
cfgp.write_text(json.dumps(cfg, ensure_ascii=False, indent=1), encoding="utf-8", newline="")
|
||||
print("② 部署配置:%s%s(端口 %d,%s)"
|
||||
% (cfgp.relative_to(ws), (" 补了 %s" % _added) if _added else " 已最新",
|
||||
port, _port_from))
|
||||
|
||||
# ③ 目标(**补缺式**:已有 goal.json ⛔ 一个字都不改)
|
||||
goal = {
|
||||
"title": a.title, "short": short, "topics": topics,
|
||||
"lifecycle": "进行中",
|
||||
"lifecycle_at": "", "lifecycle_by": "", "lifecycle_why": "init_workspace.py 新建",
|
||||
"taskgraph": "交付物/任务图-会话协作自检.json",
|
||||
"taskgraph_set_at": "", "taskgraph_set_by": "",
|
||||
"declared_at": "", "declared_by": "",
|
||||
"acceptance_state": {},
|
||||
"_唯一来源": "本文件 title/short/topics 是真源;`execution_doc` 指向目标文件夹里的《目标执行状态.md》。",
|
||||
}
|
||||
if _old_goal:
|
||||
print("③ 目标文件:**已存在,⛔ 不覆盖**(title=%r)" % str(_old_goal.get("title"))[:40])
|
||||
else:
|
||||
gp.write_text(json.dumps(goal, ensure_ascii=False, indent=1), encoding="utf-8", newline="")
|
||||
print("③ 目标已写:title=%r short=%r lifecycle=进行中" % (a.title, short))
|
||||
|
||||
# ③b 任务图(🔴 2026-10-03 14:35x 补):配置里 `taskgraph` 指向它,
|
||||
# 但初始化时**没建这个文件** ⇒ 常驻每 10 秒报「goal_state 读任务图失败」
|
||||
# ⇒ 更要命的是**判据第二路(任务图 nodes)永远取不到** ⇒ 目标状态判据缺一条腿。
|
||||
# ⇒ 写一份最小任务图:节点全 `pending`,由任务会话逐个转 `done`。
|
||||
# 🔴 2026-10-04 **同样改成补缺式**(⛔ 别覆盖既有任务图 —— 那是任务会话逐个转 done 的**进度真源**,
|
||||
# 冲掉 = 全部工作量白费;实测 `vibe-product` 的 `taskgraph` 指向 `交付物/任务图.json`,
|
||||
# 与本脚本的默认路径**不同名** ⇒ 更不能碰)。
|
||||
tg_rel = (_old_cfg.get("taskgraph") or "交付物/任务图-会话协作自检.json")
|
||||
tgp = ws / tg_rel
|
||||
if tgp.exists():
|
||||
try:
|
||||
_n = len((json.loads(tgp.read_text(encoding="utf-8")) or {}).get("nodes") or [])
|
||||
except Exception:
|
||||
_n = "?"
|
||||
print("③b 任务图:**已存在,⛔ 不覆盖**(%s,%s 个节点)" % (tg_rel, _n))
|
||||
else:
|
||||
# 🔴🔴 2026-10-04 修:旧配置**可能没有 `taskgraph` 键**(实测 `vibe-product` 就没有)
|
||||
# ⇒ 落回默认名"任务图-会话协作自检.json",而用户真实的图叫**别的名字**(如 `任务图.json`)
|
||||
# ⇒ **新建了一份空图,真实的图被"忽略"** ⇒ 判据第二路(任务图 nodes)仍取不到,
|
||||
# 而用户以为"初始化过了"。⇒ 先在 `交付物/` 里找一张**看起来是任务图的 json** 再决定。
|
||||
_cand = []
|
||||
try:
|
||||
for f in sorted((ws / "交付物").glob("*.json")):
|
||||
try:
|
||||
j = json.loads(f.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(j, dict) and isinstance(j.get("nodes"), list):
|
||||
_cand.append(f)
|
||||
except Exception:
|
||||
pass
|
||||
if _cand:
|
||||
# ⚠️ 有多张 ⇒ **不猜**,只报告并**不新建**(⛔ 猜错等于改用户的目标进度)
|
||||
if len(_cand) == 1:
|
||||
print("③b 任务图:配置里没登记 `taskgraph`,但找到既有任务图 **%s** ⇒ ⛔ 不新建、不改它"
|
||||
% _cand[0].name)
|
||||
tg_rel = None # 🔴🔴 关键:**置 None 才不会落到下面的写入分支**
|
||||
else:
|
||||
print("③b ⚠️ 配置里没登记 `taskgraph`,且 `交付物/` 里有 **%d 张**像任务图的 json(%s)"
|
||||
" ⇒ ⛔ 不猜、**不新建**。请在配置里登记 `taskgraph` 指明哪一张。"
|
||||
% (len(_cand), "、".join(f.name for f in _cand[:3])))
|
||||
tg_rel = None
|
||||
if tg_rel:
|
||||
tg = {"title": a.title, "short": short, "topics": topics,
|
||||
"nodes": [{"id": "N1", "title": "核对目标并完成首轮协作", "status": "pending",
|
||||
"line": topics[0] if topics else "", "note": "init_workspace.py 生成的最小任务图"}]}
|
||||
tgp.parent.mkdir(parents=True, exist_ok=True)
|
||||
tgp.write_text(json.dumps(tg, ensure_ascii=False, indent=1), encoding="utf-8", newline="")
|
||||
print("③b 任务图已写:%s(1 个节点,pending)" % tg_rel)
|
||||
|
||||
# ④ 目标文件夹(走产品入口,⛔ 不自己 mkdir 文档)
|
||||
# 🔴🔴 2026-10-03 **改用「本工作区的副本」,⛔ 不再用技能目录那份**:
|
||||
# 定案口径=**各工作区的程序各自独立**(技能目录=源,`.workbuddy/collab/`=各区副本,
|
||||
# `deploy_code.py` 分发)⇒ 提示用户去跑技能目录那份,等于**又拉起第二个常驻**,
|
||||
# 它会写**技能目录的 tmp/**(⛔ 那不是任何工作区的 inbox)⇒ 状态写到别处,
|
||||
# 看板读不到、故障排查也被带偏(本轮 `SESSION_END`-style 误判的同族)。
|
||||
# ⚠️ 本副本若还不存在(首次初始化、还没分发)⇒ 回落用技能目录那份,
|
||||
# 但**打印时必须说清"这次是回落"**(⛔ 静默回落=用户以为跑的是副本)。
|
||||
_own = ws / ".workbuddy" / "collab" / "collabd.py"
|
||||
_fallback = not _own.is_file()
|
||||
_cd = SK / "collabd.py" if _fallback else _own
|
||||
if _fallback:
|
||||
print(" ⚠️ 本工作区还没有 collabd.py 副本 ⇒ 本次**回落用技能目录那份**;"
|
||||
"请随后跑 deploy_code.py 分发,否则各区程序不独立。")
|
||||
env = dict(os.environ)
|
||||
env["COLLABD_CONFIG"] = str(cfgp).replace("\\", "/")
|
||||
env["COLLABD_NO_ENSURE"] = "1"
|
||||
r = subprocess.run([a.python, str(_cd), "--ensure-goal-dir"],
|
||||
env=env, cwd=str(ws), capture_output=True, timeout=120)
|
||||
out = r.stdout.decode("utf-8", "replace").strip()
|
||||
print("④ --ensure-goal-dir rc=%s" % r.returncode)
|
||||
for ln in out.splitlines():
|
||||
print(" " + ln)
|
||||
gd = [ln for ln in out.splitlines() if ln.startswith("目标目录:")]
|
||||
gdir = gd[0].split(":", 1)[1].strip() if gd else ""
|
||||
|
||||
# ⑤ 主会话排期参数(⛔ 本脚本不建 —— 创建新会话只能走自动化入口)
|
||||
print()
|
||||
print("⑤ 主会话排期(请由调用方用 automation_update 建):")
|
||||
print(" name = [主]-%s-主会话" % short)
|
||||
print(" cwds = %s" % ws_s)
|
||||
print(" 提示:主会话标题必须是**两级前缀**(`[主]-<类别>-<具体>`),⛔ 光杆名看板认不出。")
|
||||
print()
|
||||
print("✅ 完成。后续协作(⛔ **都跑本工作区的副本**,别跑技能目录那份 —— "
|
||||
"各区程序各自独立,跑了目录那份会写错地方):")
|
||||
print(" export COLLABD_CONFIG=%s" % str(cfgp).replace("\\", "/"))
|
||||
_cd_show = _cd
|
||||
print(" python %s --supervise" % _cd_show)
|
||||
# 🔴🔴 2026-10-05 改口径(用户当轮原话:「看板共用一份 各个工作区不是都应该有自己独立的看板」——
|
||||
# 这是在**问**,答案是否定的,且**早有定案**:SKILL.md:809 记的 2026-10-03 16:3x 用户拍板
|
||||
# 「⛔ 不再为每个工作区各起一个看板 —— **看板只保留一份**(就是主工作区这一个),
|
||||
# 其它工作区靠 `peer_workspaces` 并列查看」。
|
||||
# ⇒ 老输出写的是「python board.py --serve 8789 --takeover」(**暗示各区各起一份**)——
|
||||
# 与定案冲突,且 8789 这个端口本身也是随便举的 ⇒ 会把新区的常驻引导到**错形态**上。
|
||||
# ⇒ 现在**只说本区要做什么**(起常驻),看板那件事改为一句**指向主视图**的说明。
|
||||
# ⚠️ 判据:本区**只起常驻**,⛔ 不起看板;要并看 ⇒ 把本区路径加进**主工作区**配置的
|
||||
# `peer_workspaces`(只读),重启主看板即可。
|
||||
print(" 📺 看板:**全平台只保留一份**(主工作区那份)⇒ 本区⛔ 不起看板。")
|
||||
print(" 要并看本区 ⇒ 把 `%s` 加进**主工作区**配置的 `peer_workspaces`(只读),重启主看板。" % ws_s)
|
||||
if _fallback:
|
||||
print(" ⚠️ 上面是**技能目录那份**(副本还没分发)⇒ 先跑 deploy_code.py --ws %s" % ws_s)
|
||||
# ══ ⑥ 🔴🔴 **常驻载体**(2026-10-04:用户「任何工作区都要能自己配好」)
|
||||
# 🔴🔴 2026-10-06 收敛(依据:pitfalls §P0-74 的定案三条 + SKILL.md「两条起法必须收敛」):
|
||||
# ⛔ **不再铺 `start-supervise.ps1`**、⛔ **不再做 ps1 语法校验**、
|
||||
# ⛔ **不再依赖 `assets/start-supervise.ps1.tpl`**。
|
||||
# 旧形态=铺 ps1 + 任务动作 `powershell.exe -File start-supervise.ps1`
|
||||
# ⇒ PowerShell 是**控制台程序** ⇒ 每次触发分配 `conhost.exe` ⇒ **闪黑窗**
|
||||
# (用户 2026-10-05「又弹了窗口」;实测抓到 `powershell.exe` + `conhost.exe` 这对进程)。
|
||||
# 新形态=**计划任务 → `pythonw.exe` → `supervise-launch.py`**(GUI 子系统 ⇒ 零控制台)。
|
||||
# ⚠️ env(`CODEBUDDY_CONFIG_DIR` / `COLLABD_CONFIG`)由**启动器在进程内设** ——
|
||||
# 这正是 `supervise-launch.py` 存在的全部理由(旧注释"直接跑解释器没法设 env"已被它推翻)。
|
||||
# ⛔ **建任务这件事只许有一处实现** = `collabctl.py`(常驻唯一管理入口,受 `supervise.switch`
|
||||
# 总电闸管辖)。本脚本**不建任务** —— 自己建就绕过了开关,是"第二条起法"。
|
||||
_cdir = ws / ".workbuddy" / "collab"
|
||||
_launcher = _cdir / "supervise-launch.py"
|
||||
if _launcher.is_file():
|
||||
print("⑥ 常驻载体:✅ 启动器在位(`%s`)" % _launcher.relative_to(ws))
|
||||
else:
|
||||
print("⑥ 常驻载体:⛔ 缺启动器 `supervise-launch.py`")
|
||||
print(" 📌 先跑 `deploy_code.py --ws %s` 分发副本。" % ws_s)
|
||||
print(" 📌 常驻的开/关/自愈**一律走唯一入口**:`collabctl.py on|off|ensure`")
|
||||
print(" (⛔ 本脚本不建计划任务 —— 那会绕过 `supervise.switch` 总电闸,是「第二条起法」)")
|
||||
|
||||
# ⑤ 自检收口放在⑥ 之后(⛔ 顺序:先铺载体再体检,否则体检会漏掉"载体有没有")
|
||||
print()
|
||||
print("⑤ 自检(问 `_env.env_check`,⛔ 不自己另算一套):")
|
||||
try:
|
||||
import importlib.util as _iu
|
||||
_sp = _iu.spec_from_file_location("_env_chk", SK / "hooks" / "_env.py")
|
||||
_e = _iu.module_from_spec(_sp)
|
||||
_sp.loader.exec_module(_e)
|
||||
_rep = _e.env_check(ws=ws_s, scope="workspace")
|
||||
_dp = _rep.get("deploy") or {}
|
||||
print(" 技能根 = %s" % _rep.get("skills_root"))
|
||||
print(" 已部署 = %s" % _dp.get("deployed"))
|
||||
for _m in (_dp.get("missing") or []):
|
||||
print(" ⛔ %s" % _m)
|
||||
for _m in (_dp.get("stale") or []):
|
||||
print(" ⚠️ %s" % _m)
|
||||
# 🔴 真的按程序判一次「能不能认这个工作区」——⛔ 不用"文件在不在"当通过
|
||||
_r2 = subprocess.run([a.python, str(_cd), "--where"], env=env, cwd=str(ws),
|
||||
capture_output=True, timeout=120)
|
||||
_o2 = _r2.stdout.decode("utf-8", "replace")
|
||||
_ws_ok = ("COLLABD_CONFIG" in _o2) or (ws_s in _o2)
|
||||
print(" `collabd --where` 认这个工作区 = %s(rc=%s)" % ("✅" if _ws_ok else "⛔", _r2.returncode))
|
||||
# 🔴🔴 2026-10-06 收敛(P0-74):载体从 `start-supervise.ps1` 换成 **`supervise-launch.py`**。
|
||||
# ⇒ 自检一并改:**不再验 ps1、不再验 UTF-8 BOM、不再验模板变量填完没** ——
|
||||
# 那三个判据的**被测对象已经不存在了**(留着就是过期的探照灯)。
|
||||
# 判据换成**行为级可核的一条**:启动器在位。
|
||||
# ⚠️ 任务本身**不在这里验** —— 建任务归 `collabctl.py`(唯一实现),
|
||||
# 本脚本自己建/自己验就绕过了 `supervise.switch` 总电闸。
|
||||
_lc = ws / ".workbuddy" / "collab" / "supervise-launch.py"
|
||||
if _lc.is_file():
|
||||
print(" 常驻启动器 = ✅ 在位(`supervise-launch.py`)")
|
||||
else:
|
||||
print(" 常驻启动器 = ⛔ **缺**(先跑 `deploy_code.py --ws %s` 分发副本)" % ws_s)
|
||||
print(" 📌 常驻开/关/自愈走唯一入口:`collabctl.py on|off|ensure`"
|
||||
"(本区任务名 `collabd-keepalive-%s`)" % (ws.name or "ws"))
|
||||
if _dp.get("deployed") and _ws_ok and not (_dp.get("missing") or []) :
|
||||
print(" ✅ **本工作区可以直接用**(配置/目标/任务图齐了,程序也认)")
|
||||
else:
|
||||
print(" ⛔ **还没到位**,按上面 ⛔/⚠️ 逐条补完再跑一次本脚本(幂等,可反复跑)")
|
||||
except Exception as _ex:
|
||||
print(" ⚠️ 自检跑不了:%r(⛔ 不当通过)" % (_ex,))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,766 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""session-rules-check.py —— 「会话规则机制体检」= 开会话后的第一件事
|
||||
|
||||
用户口径(2026-10-02 原话逐字,含一次纠正):
|
||||
① 「这个会话和协作会话的技能包 运行的第一件事 ,就应该是检查清楚
|
||||
所有会话规划是否配置完整且生效,然后标记一个状态」
|
||||
② 「就应该是检查清楚 **所有会话规则机制** 是否配置完整且生效,**不是规划 是 规则**」
|
||||
⇒ 对象是「**规则机制**」(钩子 / 闸门 / 技能指针 / 常驻 / 编排…),⛔ 不是"排期规划"。
|
||||
|
||||
为什么要它(真因,⛔ 不是想当然)
|
||||
──────────────────────────────
|
||||
2026-10-02 一天里连续查出**四例「配置在册、其实没生效」**:
|
||||
· 钩子注入文本指着 **09-28 就已合并退役**的技能名(命中后让人去加载不存在的东西);
|
||||
· 常驻规则快照的生成脚本**写到没人读的幽灵目录** ⇒ 快照停在 09-28、10-01 的定稿进不去;
|
||||
· 工作区记忆(**每轮注入**的那份)里 3 处指针**指向已退役技能名**;
|
||||
· 锚点校验拿**旧版逐字短语**当探针 ⇒ 每次报 4 处「规则丢了」的**假缺失**。
|
||||
⇒ 共同点:**"在册" ≠ "生效"**。这类状态**不问就不会知道**,等它表现成"卡住"就晚了。
|
||||
⇒ 所以开工第一件事=**把规则的"配置"与"生效"两件事都问清楚,并落一枚状态标记**。
|
||||
|
||||
检查三类、十二项(⛔ 每项都必须**能真报出问题** —— 见文末变异对照记录):
|
||||
A 机制装没装好 ① 关键钩子在册 ② 钩子脚本路径存在
|
||||
③ **钩子注入里引用的技能名是否都存在** ④ 钩子**真在被调用**没(闸门日志新鲜度)
|
||||
B 规则载体同没同步 ⑤ **每轮注入的记忆里引用的技能名是否都存在** ⑥ 常驻规则快照是否**比权威文件旧**
|
||||
C 编排在不在跑 ⑦ **已退役角色的周期钟 ⛔ 不再要求**(10-03 口径:唤醒/跟进整套退役)
|
||||
—— 改判「在册的退役周期钟应已 PAUSED」;现行编排载体=⑩ 常驻心跳
|
||||
⑨ `cwds` **归属同形**(错一字面 ⇒ 裂组且自我强化) ⑩ 投递心跳
|
||||
⑪ 三类会话有活的没 ⑫ 有没有「`once` 且**从未运行过**」就失效的排期
|
||||
|
||||
产出:`<WS>/.workbuddy/collab/session-rules.json`(=用户要的那枚「状态标记」)+ stdout 摘要
|
||||
⚠️ stdout **只打非 ok 项**(本脚本每轮都会随 `state.py` 跑 ⇒ 输出本身就是成本)
|
||||
退出码:0 = 全 ok | 1 = 有 warn/fail(⛔ **只标记,不拦开工**)
|
||||
用法:python session-rules-check.py [--ws <工作区>] [--json] [--all]
|
||||
"""
|
||||
import ast
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
|
||||
# ── 根目录外置(roots.env 由 install.py 生成;宿主 env 优先,本文件兜底) ──────
|
||||
def _sm_load_roots():
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
for up in range(4):
|
||||
p = os.path.normpath(os.path.join(here, *([".."] * up), "roots.env"))
|
||||
if os.path.isfile(p):
|
||||
try:
|
||||
for ln in io.open(p, encoding="utf-8"):
|
||||
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()
|
||||
|
||||
ARGV = sys.argv[1:]
|
||||
def _opt(name, default=None):
|
||||
if name in ARGV:
|
||||
i = ARGV.index(name)
|
||||
return ARGV[i + 1] if i + 1 < len(ARGV) else default
|
||||
return default
|
||||
|
||||
WS = (_opt("--ws") or os.environ.get("DSH_COLLAB_WS") or os.environ.get("DSH_WS_ROOT")
|
||||
or os.getcwd()).replace("\\", "/").rstrip("/")
|
||||
CFG = os.environ.get("CODEBUDDY_CONFIG_DIR") or os.path.join(os.path.expanduser("~"), ".workbuddy")
|
||||
DB = os.path.join(CFG, "workbuddy.db")
|
||||
SETTINGS = os.path.join(CFG, "settings.json")
|
||||
SKILLS = os.environ.get("DSH_SKILLS_ROOT") or os.path.join(CFG, "skills")
|
||||
OUT = WS + "/.workbuddy/collab/session-rules.json"
|
||||
AS_JSON = "--json" in ARGV
|
||||
SHOW_ALL = "--all" in ARGV
|
||||
|
||||
# 「会话机制三件套」的**关键钩子**(缺一 ⇒ 该机制不会运行)
|
||||
# 🔴🔴 2026-10-06 修:第 2 元由**单个文件名**改成**一组可接受的文件名**(命中任一即算在册)。
|
||||
# 起因(实测坐实):`UserPromptSubmit` 上原有 4 条独立守卫(`stop-dialog-guard` /
|
||||
# `skill-load-guard` / `reply-style-guard` / `session-log-guard`),每轮**各起一个 Python 进程**
|
||||
# ⇒ 宿主冷启 7 次解释器 ⇒ 预算被排队挤破 ⇒ 报 `Hook timed out after 10000ms`(用户报障)。
|
||||
# ⇒ 已**合并**为一个入口 `prompt-guards.py`(本进程内 `runpy` 依次跑那 4 个,合并输出)。
|
||||
# ⚠️ 本条判据若仍按**旧文件名**找 ⇒ 合并后**每轮必报「关键钩子不在册」(假红)**,
|
||||
# 而它惩罚的正是"按要求做过的合并"。
|
||||
# 🔴 判据要问的是「**这项能力有没有接线**」,⛔ 不是「某个文件名在不在」——
|
||||
# 所以两种形态**都必须认**:独立接线 | 合并入口。
|
||||
KEY_HOOKS = [
|
||||
("SessionStart", ("lock-guard-hook.py",), "无锁不许改代码库 / 文档库"),
|
||||
("PreToolUse", ("lock-guard-hook.py",), "同上(Write|Edit 面)"),
|
||||
("PreToolUse", ("bash-output-guard.py",), "拦下会灌爆上下文的读命令"),
|
||||
("UserPromptSubmit", ("wb-result-hook.py",), "收结果 / 结果回流"),
|
||||
("UserPromptSubmit", ("stop-dialog-guard.py", "prompt-guards.py"), "水位与收口(接续机制起点)"),
|
||||
("UserPromptSubmit", ("skill-load-guard.py", "prompt-guards.py"), "用户点名方法 ⇒ 强制加载技能"),
|
||||
("UserPromptSubmit", ("session-log-guard.py", "prompt-guards.py"), "会话日志闸(防把界面顶死)"),
|
||||
("PostToolUse", ("session-log-guard.py",), "同上(工具后)"),
|
||||
]
|
||||
GATE_LOGS = [("收口", WS + "/.workbuddy/stop-dialog-guard.log"),
|
||||
("技能", WS + "/.workbuddy/skill-load-guard.log"),
|
||||
("限流", WS + "/.workbuddy/bash-guard.log"),
|
||||
# ⚠️ 锁日志落在**文档库的父目录**(lock-guard-hook 管的是文档库 / 代码库,不是本工作区)
|
||||
# ⇒ 首版按 skills 目录往上推两层 ⇒ 算成 `E:/ProgramData/.workbuddy/…` ⇒ **假"缺失"**。
|
||||
("锁", (os.path.join(os.path.dirname(os.environ.get("DSH_DOCS_ROOT", "")),
|
||||
".workbuddy", "lock-hook.log")
|
||||
if os.environ.get("DSH_DOCS_ROOT") else ""))]
|
||||
# 🔴🔴 2026-10-05 **用户定案:「协作 全部 改为 执行」**(连说两遍,第二轮逐字:
|
||||
# 「**兼容个毛线,今天兼容一个明天兼容一个 过不了一周就成大杂烩了**」)。
|
||||
# ⛔⛔ 本文件里 `协作` 二字**不准再出现在任何"活"的语境里**。
|
||||
# 「兼容」这个词**本身就是病** —— 后人读到"兼容"会以为它迟早能删,于是一拖再拖。
|
||||
# ⇒ 凡看到旧前缀,必须能一句话答出**"删了会坏在哪"**;答不出 ⇒ 它就是该删的。
|
||||
# 🔴 分清**两件性质完全不同的事**(本文件唯一的例外,且**不许写"兼容"**):
|
||||
# ① **活类别表 `ROLES_LIVE`** = "必须存在活会话"的类别 ⇒ **只许有当前在用的前缀**。
|
||||
# 多一个过时前缀 ⇒ 每轮必报"类别缺失"(假红)+ 给旧名续命(就是用户说的"大杂烩")。
|
||||
# ② **扫描面 `SESS_PREFIXES`** = "要捞进视野"的前缀 ⇒ 过时前缀**留在这里**。
|
||||
# 性质是**历史行解析正确性**(⛔ 不是"兼容"):宿主库 `sessions` 表里有旧标题的行,
|
||||
# 扫不到 ⇒ 本闸门对它们**静默失明**("读到了却不说"是该机制的既有红线)。
|
||||
# 🔴 **实测判据(2026-10-05 当场跑 `board.py::_role_of_title()` 验的,⛔ 非推理)**:
|
||||
# 删 `_PFX` 里 `协作`/`协作目标`/`任务会话` 三条 ⇒
|
||||
# `[协作]-[手机接入]-N9 复测` / `[协作]N9 派活 · …` / `[协作目标]-xxx` / `[任务会话]-…`
|
||||
# **全部解析成 `''`** ⇒ 看板画不出、派活漏管,**不可逆**。
|
||||
# ⇒ 这就是"删了会坏在哪"的答案。**留着有据,不是人情。**
|
||||
# ⚠️ 与 `collabd.py::parse_session_name()` / `board.py::_role_of_title()` 的映射口径对齐:
|
||||
# worker 侧新建=`执行`(唯一在用)。
|
||||
ROLES_LIVE = [("执行", "[执行]")]
|
||||
# 🔴 扫描面 = 当前活类别 + **历史前缀**(⛔ 语义是「历史行解析正确性」,**不是"兼容"**)。
|
||||
# ⛔⛔ **这三个历史前缀不许删**:删任一条 ⇒ 存量旧标题行**扫不到** ⇒ 本闸门静默失明。
|
||||
# ⚠️ 它们**不参与"活类别"判定**(不判"必须有活的")—— 判了就是给旧名续命。
|
||||
SESS_PREFIXES = [p for _, p in ROLES_LIVE] + ["[协作]", "[协作目标]", "[任务会话]"]
|
||||
# 🔴🔴 2026-10-03 口径后**已退役**的角色前缀 ⇒ **只扫、不判活**(⛔ 不是"兼容",是"还在库里"):
|
||||
# ⛔ 不许放回 `ROLES_LIVE` —— 唤醒/跟进**整套退役**(见 SKILL.md 文首口径块),
|
||||
# 放回去 ⇒ 每轮必报"唤醒/跟进 类别缺失"(惩罚的正是"按要求删掉的东西")。
|
||||
SESS_PREFIXES += ["[唤醒]", "[跟进]"]
|
||||
# 🔴 2026-10-03 口径:唤醒/跟进**整套退役**(见 SKILL.md 文首口径块)
|
||||
# ⇒ ⑦ 「周期钟必须存在」这条旧判据与现口径**互斥**:按旧判据每轮必报 fail,而它惩罚的
|
||||
# 正是"已经按用户要求删掉的东西"。现行「处理」这条腿的载体=常驻 `--supervise`(⑩ 判它)。
|
||||
# ⇒ 本条改为**反向判据**:在册的退役周期钟**必须已 PAUSED**(还在 ACTIVE ⇒ 才报问题)。
|
||||
ROLES_CLOCK = [("唤醒", "[唤醒]"), ("跟进", "[跟进]")] # ⚠️ 仅用于"退役后应处于 PAUSED"的反向检查
|
||||
FLASH_HINT = ("flash",)
|
||||
# 技能名形态(与「技能库引用体检」同源);⚠️ 只认这几个前缀,别把业务线名当技能
|
||||
SKILL_NAME_RE = re.compile(r"\b((?:dsh|agent|session|workbuddy|multi|third-party|stage|product|content-source)"
|
||||
r"[a-z0-9]*(?:-[a-z0-9]+)+)\b")
|
||||
# 「指向技能」的上下文(⛔ 只在这类上下文里判,否则会把业务线名 / localStorage 键当技能名)
|
||||
#
|
||||
# 🔴 2026-10-02 收紧(首版太宽、当场三条误报,全是"把不属于技能的东西当技能名"):
|
||||
# 旧版还含 `^[`「]?\s*$`("整个字符串就是一个技能名形态")⇒ 于是:
|
||||
# · `lock-guard-hook.py` 里的字符串 `"dsh-server-docs"`(那是**文档库目录名**)被报成悬空技能;
|
||||
# · `session-log-guard.py` 里的 `"session-log-guard"`(那是**它自己的脚本名**)同样被报;
|
||||
# · `wb-result-hook.py` 里的 `os.path.join(_base,"skills","multi-session-collab",…)`
|
||||
# (那是**故意留的旧位置回退候选**,代码注释已标明)也被报。
|
||||
# ⇒ 本项要问的其实只有一件事:**钩子在叫 AI「去加载」某个技能时,那个技能还在不在**
|
||||
# ⇒ 判据就只认「加载 / Skill 工具」这一类**动作语境**,⛔ 不认"字符串长得像技能名"。
|
||||
SKILL_CTX_RE = re.compile(r"(?:Skill\s*工具(?:加载|来加载)|调用\s*Skill|先加载|加载|load)\s*[`「]?\s*$")
|
||||
|
||||
CHECKS = []
|
||||
def rec(cid, group, level, title, detail=""):
|
||||
CHECKS.append({"id": cid, "group": group, "level": level, "title": title, "detail": detail})
|
||||
|
||||
# ── 工具 ───────────────────────────────────────────────────────────────
|
||||
def _ro_conn():
|
||||
c = sqlite3.connect("file:%s?mode=ro" % DB.replace("\\", "/"), uri=True, timeout=8)
|
||||
c.execute("PRAGMA busy_timeout=6000")
|
||||
return c
|
||||
|
||||
def _norm_cwd(p):
|
||||
"""与宿主去重键同源:`path.trim().toLowerCase()`(⛔ 别自作聪明归一成 basename)。"""
|
||||
return str(p or "").replace("\\", "/").strip().lower()
|
||||
|
||||
def _safe_list(v):
|
||||
if v is None:
|
||||
return []
|
||||
if isinstance(v, (list, tuple)):
|
||||
return [str(x) for x in v]
|
||||
s = str(v).strip()
|
||||
if s.startswith("["):
|
||||
try:
|
||||
r = json.loads(s)
|
||||
return [str(x) for x in r] if isinstance(r, list) else [s]
|
||||
except Exception:
|
||||
return [s]
|
||||
return [x.strip() for x in s.split(",") if x.strip()]
|
||||
|
||||
def _string_literals(src):
|
||||
"""只取**运行时会用到的字符串字面量** ⇒ 排除注释与 docstring。
|
||||
|
||||
为什么要这么讲究:钩子脚本头部**注释里大量出现旧技能名**(讲历史/讲事故),
|
||||
那是**合规保留**的(改了就是篡改取证记录)。若连注释一起扫 ⇒ 每次都报一堆假阳性 ⇒ 真问题被淹掉。
|
||||
"""
|
||||
out = []
|
||||
try:
|
||||
tree = ast.parse(src)
|
||||
except Exception:
|
||||
return out
|
||||
doc_ids = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
||||
body = getattr(node, "body", [])
|
||||
if (body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant)
|
||||
and isinstance(body[0].value.value, str)):
|
||||
doc_ids.add(id(body[0].value))
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str) and id(node) not in doc_ids:
|
||||
out.append(node.value)
|
||||
return out
|
||||
|
||||
def _dangling_skills_in_text(txts):
|
||||
"""在一组字符串里找「指向不存在的技能名」。返回 {name: 片段}。"""
|
||||
out = {}
|
||||
for t in txts:
|
||||
for m in SKILL_NAME_RE.finditer(t):
|
||||
nm = m.group(1)
|
||||
if nm in EXISTING_SKILLS:
|
||||
continue
|
||||
head = t[max(0, m.start() - 14):m.start()]
|
||||
if not SKILL_CTX_RE.search(head):
|
||||
continue
|
||||
out.setdefault(nm, t[max(0, m.start() - 20):m.end() + 20].replace("\n", " ").strip())
|
||||
return out
|
||||
|
||||
def _skill_names():
|
||||
"""**本机可见的全部技能名** = 全局技能根 ∪ 工作区技能根。
|
||||
|
||||
🔴🔴 2026-10-06 修 **只查全局根 ⇒ 工作区自带技能被判"悬空"(假红,且每轮刷)**。
|
||||
实测(`vibe-product`):其 `MEMORY.md` 里写「`product-planning/`(工作区根)」,
|
||||
而 `product-planning` **只在** `<WS>/.workbuddy/skills/` 里 —— 全局库
|
||||
`E:/ProgramData/.workbuddy/skills/` 下**没有它**(实测 `ls` = No such file)。
|
||||
⇒ 旧判据(只 `listdir(SKILLS)`)每轮报「工作区记忆里的技能指针悬空」,
|
||||
而它惩罚的正是「**工作区自己装的技能**」这种**完全合法**的形态。
|
||||
|
||||
⛔ 判据要问的是「**这个名字在本机取不取得到**」,⛔ **不是**「全局库目录里有没有」。
|
||||
本机 `DSH_SKILLS_ROOT` 未设时 `SKILLS` 落在 `CODEBUDDY_CONFIG_DIR/skills`(全局),
|
||||
本身就**看不见**工作区那一层 ⇒ 漏判是必然而非偶然。
|
||||
"""
|
||||
out = set()
|
||||
for root in (SKILLS, os.path.join(WS, ".workbuddy", "skills")):
|
||||
try:
|
||||
if root and os.path.isdir(root):
|
||||
out |= {n for n in os.listdir(root)
|
||||
if os.path.isdir(os.path.join(root, n))}
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
EXISTING_SKILLS = _skill_names()
|
||||
|
||||
|
||||
# ── A. 机制装没装好 ─────────────────────────────────────────────────────
|
||||
def check_hooks():
|
||||
if not os.path.isfile(SETTINGS):
|
||||
rec("hook_reg", "A", "fail", "读不到 hooks 配置", "settings.json 不在:%s" % SETTINGS)
|
||||
return [], None
|
||||
try:
|
||||
hk = (json.load(io.open(SETTINGS, encoding="utf-8")) or {}).get("hooks") or {}
|
||||
except Exception as e:
|
||||
rec("hook_reg", "A", "fail", "hooks 配置解析失败", str(e)[:140])
|
||||
return [], None
|
||||
cmds = [(ev, str(h.get("command") or ""))
|
||||
for ev, arr in hk.items() for blk in (arr or []) for h in ((blk or {}).get("hooks") or [])]
|
||||
# ⚠️ `k[1]` 是**一组**可接受的文件名 ⇒ 命中任一即算在册(独立接线 或 合并入口)。
|
||||
miss = [k for k in KEY_HOOKS
|
||||
if not any(k[0] == ev and any(n in c for n in k[1]) for ev, c in cmds)]
|
||||
if miss:
|
||||
rec("hook_reg", "A", "fail", "关键钩子不在册(该机制现在不会运行)",
|
||||
"、".join("%s(%s)" % ("/".join(k[1]), k[0]) for k in miss))
|
||||
else:
|
||||
rec("hook_reg", "A", "ok", "关键钩子在册", "共 %d 条钩子注册" % len(cmds))
|
||||
|
||||
bad_path = []
|
||||
for ev, c in cmds:
|
||||
m = re.search(r'([A-Za-z]:[\\/][^"\']*?\.(?:py|sh|mjs))', c)
|
||||
if m and not os.path.exists(m.group(1)):
|
||||
bad_path.append("%s(%s)" % (os.path.basename(m.group(1)), ev))
|
||||
if bad_path:
|
||||
rec("hook_path", "A", "fail", "钩子脚本路径不存在 ⇒ 静默失效", "、".join(sorted(set(bad_path))[:5]))
|
||||
else:
|
||||
rec("hook_path", "A", "ok", "钩子脚本路径均存在", "")
|
||||
return cmds, hk
|
||||
|
||||
|
||||
def check_hook_skillnames(cmds):
|
||||
"""🔴 本体检最有价值的一项:钩子**注入文本**里引用的技能名,现在还存在吗?
|
||||
|
||||
真因(2026-10-02 实测):`skill-load-guard.py` 注入「先调用 Skill 工具加载 `dsh-decision-method`」,
|
||||
而该技能 **2026-09-28 已合并退役**(现名 `dsh-decision`)⇒ 命中后让人去拿一份不存在的东西。
|
||||
"""
|
||||
dangling = {}
|
||||
scanned = 0
|
||||
for ev, c in cmds:
|
||||
m = re.search(r'([A-Za-z]:[\\/][^"\']*?\.py)', c)
|
||||
if not m:
|
||||
continue
|
||||
p = m.group(1)
|
||||
if not os.path.isfile(p):
|
||||
continue
|
||||
try:
|
||||
src = io.open(p, encoding="utf-8", errors="replace").read()
|
||||
except Exception:
|
||||
continue
|
||||
scanned += 1
|
||||
for nm, frag in _dangling_skills_in_text(_string_literals(src)).items():
|
||||
dangling.setdefault("%s → %s" % (os.path.basename(p), nm), frag)
|
||||
if dangling:
|
||||
rec("hook_skillname", "A", "fail", "钩子注入里指向**已不存在的技能名**(命中即空转)",
|
||||
";".join("%s" % k for k in sorted(dangling)[:4]))
|
||||
else:
|
||||
rec("hook_skillname", "A", "ok", "钩子注入引用的技能名均存在", "扫了 %d 份钩子脚本" % scanned)
|
||||
|
||||
|
||||
def check_gate_logs():
|
||||
stale = []
|
||||
rows = []
|
||||
for tag, p in GATE_LOGS:
|
||||
try:
|
||||
with io.open(p, "rb") as f:
|
||||
f.seek(0, 2)
|
||||
n = min(8192, f.tell())
|
||||
f.seek(-n, 2)
|
||||
r = [x for x in f.read().decode("utf-8", "replace").split("\n") if x.strip()]
|
||||
if not r:
|
||||
stale.append(tag + "(空)")
|
||||
continue
|
||||
last = r[-1][:16]
|
||||
rows.append("%s=%s" % (tag, last))
|
||||
age = time.time() - os.path.getmtime(p)
|
||||
if age > 7 * 24 * 3600:
|
||||
stale.append("%s(停 %.0f 天)" % (tag, age / 86400.0))
|
||||
except Exception:
|
||||
stale.append(tag + "(缺)")
|
||||
if stale:
|
||||
rec("gate_fresh", "A", "warn", "部分闸门日志陈旧 / 缺失(可能已掉线)",
|
||||
";".join(stale) + "|" + " ".join(rows))
|
||||
else:
|
||||
rec("gate_fresh", "A", "ok", "各闸门最近都被调用过", " ".join(rows))
|
||||
|
||||
|
||||
# ── B. 规则载体同没同步 ─────────────────────────────────────────────────
|
||||
def check_memory_pointers():
|
||||
"""工作区 `MEMORY.md` 是**每轮注入**的那份 ⇒ 它里面的悬空指针会一直把 AI 引向不存在的东西。"""
|
||||
p = os.path.join(WS, ".workbuddy", "memory", "MEMORY.md")
|
||||
if not os.path.isfile(p):
|
||||
rec("mem_ptr", "B", "warn", "读不到工作区 MEMORY.md", "路径:%s" % p)
|
||||
return
|
||||
try:
|
||||
txt = io.open(p, encoding="utf-8", errors="replace").read()
|
||||
except Exception as e:
|
||||
rec("mem_ptr", "B", "warn", "读 MEMORY.md 失败", str(e)[:120])
|
||||
return
|
||||
bad = {}
|
||||
for m in SKILL_NAME_RE.finditer(txt):
|
||||
nm = m.group(1)
|
||||
if nm in EXISTING_SKILLS:
|
||||
continue
|
||||
head = txt[max(0, m.start() - 14):m.start()]
|
||||
if not (SKILL_CTX_RE.search(head) or "技能" in head):
|
||||
continue
|
||||
bad.setdefault(nm, txt[max(0, m.start() - 26):m.end() + 12].replace("\n", " ").strip())
|
||||
if bad:
|
||||
rec("mem_ptr", "B", "fail", "工作区记忆里的技能指针悬空(每轮注入 ⇒ 一直把人引错)",
|
||||
";".join(sorted(bad)[:4]))
|
||||
else:
|
||||
rec("mem_ptr", "B", "ok", "工作区记忆的技能指针均有效", "")
|
||||
|
||||
|
||||
def check_snapshot_stale():
|
||||
"""常驻规则快照 vs 权威 `CODEBUDDY.md`:**内容**一不一致(快照是副本,权威单向)。
|
||||
|
||||
🔴🔴 2026-10-06 修 **判据从「比 mtime」改成「比内容」**(原判据是**假红**,已实测坐实):
|
||||
原写法比 `getmtime(auth) - getmtime(snap) > 0.01 天` ⇒ 只要权威 CODEBUDDY.md
|
||||
**被 touch 过**(哪怕只是重排、挪归档、加一行注释)就报
|
||||
「快照比权威旧 ⇒ 新规则没进快照」—— 而**快照内容可能一个字都没差**。
|
||||
⚠️ 实测(2026-10-06,ai1net):新旧快照 **md5 逐字节相同**(`f54e48d4…`)、`diff` **0 行**、
|
||||
`resident-rules.py --check` 报「✅ 关键规则齐备」,而本条已**连续 4 天每轮报 fail**。
|
||||
⇒ 病根:**拿文件时间当内容判据**(时间只说明"谁最后被写过",不说明"内容差没差")。
|
||||
|
||||
⇒ 改为**内容口径**,并**复用唯一事实源**:直接调 `dsh-local-env` 的
|
||||
`resident-rules.py --check --goal <CODEBUDDY.md>`(它本来就是干这个的)
|
||||
—— ⛔ **不在这里再抄一份"抽章节再比对"的逻辑**(抄一份 ⇒ 两处漂移 ⇒ 同族事故)。
|
||||
rc=0 ⇒ ok | rc=1 ⇒ **真不一致**(fail)| 工具缺失/跑不起来 ⇒ warn(⛔ 不假装通过)。
|
||||
"""
|
||||
snap = os.path.join(SKILLS, "dsh-local-env", "references", "dsh-env-bootstrap",
|
||||
"常驻规则-快照.md")
|
||||
auth = os.path.join(WS, "CODEBUDDY.md")
|
||||
if not os.path.isfile(auth):
|
||||
# ⚠️ 本区**本来就没有** CODEBUDDY.md(如 vibe-product)⇒ 这是**合法状态**,不是故障。
|
||||
rec("snap_sync", "B", "warn", "本区无权威规则文件(`CODEBUDDY.md` 不存在 ⇒ 本项不适用)", auth)
|
||||
return
|
||||
if not os.path.isfile(snap):
|
||||
rec("snap_sync", "B", "warn", "常驻规则快照不存在", snap)
|
||||
return
|
||||
tool = os.path.join(SKILLS, "dsh-local-env", "references", "dsh-env-bootstrap",
|
||||
"resident-rules.py")
|
||||
if not os.path.isfile(tool):
|
||||
rec("snap_sync", "B", "warn",
|
||||
"**抽取器不在** ⇒ 判不了快照同不同步(⛔ 不算通过)", tool)
|
||||
return
|
||||
# 🔴🔴 真·内容口径:**让抽取器自己算一遍"快照应该长什么样"**,再与磁盘上的真快照比对。
|
||||
# ⛔ 不能用 `resident-rules.py --check` —— 实测(2026-10-06 变异对照):
|
||||
# 它校验的是**目标 CODEBUDDY.md 里关键规则齐不齐**,**根本不含"与快照比对"**。
|
||||
# 我中途误用它 ⇒ 把快照**截断到 400 字节**,它照样报 `✅ 关键规则齐备`(rc=0)
|
||||
# ⇒ 判据变 **恒绿**(比原来的 mtime 假红更坏)。
|
||||
# ⚠️ 教训:**换判据必须重跑变异对照**;"换了个看起来更对的调用" ≠ 判据变强了。
|
||||
# ⛔ 也不在这里自己抄一份"抽章节"逻辑(两处 ⇒ 漂移)。做法=**导入抽取器本体**,
|
||||
# 把它的 `SNAP` 常量**临时改指到临时文件**,调它自己的 `snapshot()` 取"应有内容",
|
||||
# 读完还原常量并删临时文件(⛔ 全程不碰真快照 —— 体检⛔ 不改资产)。
|
||||
try:
|
||||
import importlib.util
|
||||
import tempfile
|
||||
_spec = importlib.util.spec_from_file_location("_rr_probe", tool)
|
||||
_mod = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_mod) # `__name__` ≠ `'__main__'` ⇒ 不会跑 main()
|
||||
_fd, _tmp = tempfile.mkstemp(suffix=".md")
|
||||
os.close(_fd)
|
||||
_orig = _mod.SNAP
|
||||
try:
|
||||
_mod.SNAP = _tmp
|
||||
_mod.snapshot(auth) # 用**它自己**的逻辑生成"应有内容"
|
||||
expected = io.open(_tmp, encoding="utf-8", errors="replace").read()
|
||||
finally:
|
||||
_mod.SNAP = _orig
|
||||
try:
|
||||
os.remove(_tmp)
|
||||
except OSError:
|
||||
pass
|
||||
except Exception as e:
|
||||
rec("snap_sync", "B", "warn",
|
||||
"**内容比对跑不起来** ⇒ 判不了快照同不同步(⛔ 不算通过)", str(e)[:130])
|
||||
return
|
||||
try:
|
||||
actual = io.open(snap, encoding="utf-8", errors="replace").read()
|
||||
except Exception as e:
|
||||
rec("snap_sync", "B", "warn", "读快照失败", str(e)[:120])
|
||||
return
|
||||
if actual == expected:
|
||||
rec("snap_sync", "B", "ok", "常驻规则快照与权威**逐字节一致**", "")
|
||||
return
|
||||
# 定位**第一处**差异,便于一眼看懂差在哪(⛔ 不倒全文)
|
||||
_a, _b = actual.split("\n"), expected.split("\n")
|
||||
_i = 0
|
||||
while _i < min(len(_a), len(_b)) and _a[_i] == _b[_i]:
|
||||
_i += 1
|
||||
_exp_line = (_b[_i][:60].strip() if _i < len(_b) else "(应有多出的行)")
|
||||
_act_line = (_a[_i][:60].strip() if _i < len(_a) else "(快照到此为止 ⇒ 缺内容)")
|
||||
rec("snap_sync", "B", "fail",
|
||||
"常驻规则快照与权威**内容不一致** ⇒ 新规则没进快照(或快照被改坏)",
|
||||
"第 %d 行起不同;快照实际=「%s」/应为=「%s」(快照 %d 行 / 应为 %d 行);"
|
||||
"重生成 ⇒ `python <dsh-local-env>/references/dsh-env-bootstrap/resident-rules.py --snapshot`"
|
||||
% (_i + 1, _act_line, _exp_line, len(_a), len(_b)))
|
||||
|
||||
|
||||
# ── C. 编排在不在跑 ─────────────────────────────────────────────────────
|
||||
def _cwds_nearmiss(autos, ws_norm):
|
||||
"""「与本工作区**几乎同名**、但字面不同」的排期 cwds ⇒ 会**裂成两组**。
|
||||
|
||||
⚠️ 判据只认**近失配**两条(⛔ 不是"同父目录就算" —— 首版就栽在这儿):
|
||||
(a) **同父目录 + 名字只是 `-`/`_`/大小写之别** —— 如 `…/ai1net_dsh_server`
|
||||
↔ `…/ai1net-dsh-server`;⇒ **去标点后逐字相同** 才算近失配(例:`ai1net-decision-laya`
|
||||
与本工作区同父目录,但去标点后不同 ⇒ **是另一条线,⛔ 不是失配**)
|
||||
(b) **同名字、父目录不同** —— 如 `E:/…/ai1net-dsh-server` ↔ `D:/…/ai1net-dsh-server`
|
||||
(多半是从别的机器抄来的排期)
|
||||
⇒ 去重键=`path.trim().toLowerCase()`(宿主原样)⇒ 这两种各裂一组,且**自我强化**
|
||||
(越裂越不像,之后再也归不回来)⇒ 必须**在建的时候**就报出来。
|
||||
⇒ 纯函数:只吃数据,便于用合成样本做红绿对照(⛔ 不靠"实跑一次看着对")。
|
||||
"""
|
||||
def _key(p):
|
||||
return re.sub(r"[^a-z0-9]", "", p)
|
||||
base = os.path.basename(ws_norm.rstrip("/"))
|
||||
parent = os.path.dirname(ws_norm.rstrip("/"))
|
||||
out = []
|
||||
for a in autos:
|
||||
for x in _safe_list(a.get("cwds")):
|
||||
s = str(x or "").strip()
|
||||
if not s:
|
||||
continue
|
||||
n = _norm_cwd(s).rstrip("/")
|
||||
if n == ws_norm:
|
||||
continue
|
||||
same_parent = (n.rsplit("/", 1)[0] if "/" in n else "") == parent
|
||||
name = n.rsplit("/", 1)[-1]
|
||||
if (same_parent and _key(name) == _key(base)) or (not same_parent and name == base):
|
||||
out.append("%s → %s" % ((a.get("name") or "")[:24], s))
|
||||
return out
|
||||
|
||||
|
||||
def _sat_epoch(sat):
|
||||
"""`scheduled_at` → epoch 秒。⚠️ 实测只到**分钟**(`2026-10-02T14:26`)⇒ 两种格式都试;
|
||||
解析不出来返回 `None`(⛔ 调用方据此走"不可核对",**不猜**)。"""
|
||||
s = str(sat or "")
|
||||
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M"):
|
||||
try:
|
||||
return time.mktime(time.strptime(s[:len(time.strftime(fmt))], fmt))
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _once_zombie(mine, ran, grace_min=30.0):
|
||||
"""`once` 排期、已无下次触发、却**从未运行过** ⇒ 那条活会**静默消失**。
|
||||
|
||||
🔴 两条判据要点(都是实测校正出来的,⛔ 别想当然):
|
||||
(a) **"跑完了"不是问题、"从未跑却已失效"才是** —— `once` 到点跑完即被消耗,属**正常痕迹**;
|
||||
把两者混报 ⇒ 首版拿 `last_run_at` 判,把 **18 条正常痕迹**当成 18 个故障。
|
||||
(b) **"有没有跑过"必须查 `automation_runs`,⛔ 不能查 `automations.last_run_at`** ——
|
||||
该字段宿主**根本不写**(实测:本会话自己那条排期**明明在跑**,值仍是 `None`)。
|
||||
🔴 2026-10-02 修(S11 · 与 `collabd.health()` 同一病灶):本机 once 排期**不到点即时触发**
|
||||
而是主机轮询**补跑**(`runKind=missed`,实测延迟 0~11 分钟)⇒ **刚过点不足 `grace_min` 的
|
||||
那条不算「从未运行」**,只是**还在补跑窗口里**。⛔ 无 `scheduled_at` 或解析不出 ⇒ 不判 fail
|
||||
(不可核对 ≠ 有故障),归入第三返回值。
|
||||
⇒ 纯函数:`ran` 传 `None` 表示"读不到运行记录" ⇒ **只报 warn,⛔ 不报 fail**。
|
||||
返回 (never_ran, consumed, unverifiable, in_grace)。
|
||||
"""
|
||||
never, consumed, unver, in_grace = [], 0, 0, 0
|
||||
now = time.time()
|
||||
for a in mine:
|
||||
if a.get("schedule_type") != "once" or a.get("next_run_at"):
|
||||
continue
|
||||
if ran is None:
|
||||
unver += 1
|
||||
continue
|
||||
if a.get("id") in ran:
|
||||
consumed += 1
|
||||
continue
|
||||
# 🔴 到点后仍可能补跑 ⇒ 未超容差先归入"窗口内",⛔ 不当故障
|
||||
t = _sat_epoch(a.get("scheduled_at"))
|
||||
if t is not None and (now - t) / 60.0 <= float(grace_min):
|
||||
in_grace += 1
|
||||
continue
|
||||
never.append((a.get("name") or "")[:30])
|
||||
return never, consumed, unver, in_grace
|
||||
|
||||
|
||||
def check_orchestration():
|
||||
if not os.path.isfile(DB):
|
||||
rec("clock", "C", "fail", "读不到宿主库", DB)
|
||||
return
|
||||
try:
|
||||
c = _ro_conn()
|
||||
autos = [{"id": r[0], "name": r[1], "schedule_type": r[2], "next_run_at": r[3],
|
||||
"model_id": r[4], "model_is_thinking": r[5], "cwds": r[6], "scheduled_at": r[7],
|
||||
"status": r[8]}
|
||||
for r in c.execute(
|
||||
"select id,name,schedule_type,next_run_at,model_id,model_is_thinking,cwds,"
|
||||
" scheduled_at,status "
|
||||
"from automations where deleted_at is null")]
|
||||
sess = list(c.execute(
|
||||
"select id,title,status from sessions where "
|
||||
+ " or ".join(["title like '%s%%'" % p for p in SESS_PREFIXES])))
|
||||
except Exception as e:
|
||||
rec("clock", "C", "fail", "读宿主库失败", str(e)[:140])
|
||||
return
|
||||
ws_norm = _norm_cwd(WS)
|
||||
mine = [a for a in autos if any(_norm_cwd(x) == ws_norm for x in _safe_list(a["cwds"]))]
|
||||
|
||||
# ⑦ 已退役角色的周期钟(**反向判据** —— 2026-10-03 口径后改,⛔ 别再要求"必须存在")
|
||||
# 旧判据("唤醒/跟进周期钟必须都在册")与 10-03 口径**互斥**:那两类会话已整套退役,
|
||||
# 要求它们存在=**惩罚按用户要求删对了的东西** ⇒ 每轮必报 fail(判据与设计目标互斥)。
|
||||
# ✅ 现判据:**在册的退役周期钟必须已 PAUSED**;还在 ACTIVE ⇒ 才说明退役没做干净。
|
||||
retire = [a for a in mine if a["schedule_type"] == "recurring"
|
||||
and any((a["name"] or "").startswith(pre) for _lb, pre in ROLES_CLOCK)]
|
||||
not_paused = [a["name"] for a in retire if (a.get("status") or "") == "ACTIVE"]
|
||||
if not_paused:
|
||||
rec("clock", "C", "fail", "已退役角色的周期钟仍在 ACTIVE ⇒ 退役没做干净",
|
||||
"、".join(not_paused[:3]))
|
||||
else:
|
||||
rec("clock", "C", "ok", "已退役角色的周期钟已全部 PAUSED(或本就无在册)",
|
||||
"在册 %d 条,全部非 ACTIVE" % len(retire))
|
||||
|
||||
# ⑧ 模型一致性(🔴🔴 2026-10-05 P0-80 重写:**扫描面从 `recurring` 扩到全部排期**)
|
||||
# ⚠️ 旧写法只扫 `recurring` ⇒ 而**机制新建的排期 `schedule_type` 恒为 `once`**(见 P0-65)
|
||||
# ⇒ 一条都不进扫描面 ⇒ 该判据**恒判 ok**(同 P0-76 ①/P0-77 ③ 的"闸门看不见新东西")。
|
||||
# ⚠️ 且旧判据只问"flash + thinking=0 会不会被服务端拒",**⛔ 没问"跟主会话是不是同一个"**
|
||||
# —— 这才是用户真正遇到的问题(P0-80:机制写死 `space-bunny`,主会话是 `deepseek-v4.1-flash`)。
|
||||
# ✅ 现判据两条腿:
|
||||
# ① **可用性**(旧):flash 类 + thinking=0 ⇒ 服务端拒;
|
||||
# ② **一致性**(新):本区排期的 `model_id` 必须与本区主会话 `model` 同值(否则报 fail)。
|
||||
_recur = [a for a in mine if a["schedule_type"] == "recurring"]
|
||||
try:
|
||||
_c2 = sqlite3.connect("file:%s?mode=ro" % DB, uri=True, timeout=10)
|
||||
_c2.execute("PRAGMA busy_timeout=5000")
|
||||
_mr = _c2.execute(
|
||||
"select model from sessions "
|
||||
"where (is_background_automation is null or is_background_automation <> 1) "
|
||||
" and model is not null and model <> '' "
|
||||
" and replace(cwd,'\\\\','/') like ? order by created_at desc limit 1",
|
||||
("%" + ws_norm + "%",)).fetchone()
|
||||
_c2.close()
|
||||
_main_model = (_mr[0] if _mr else "") or ""
|
||||
except Exception:
|
||||
_main_model = ""
|
||||
# 可用性:本区**全部**排期(含 once)
|
||||
_allmine = [a for a in mine]
|
||||
deaf = ["%s(%s)" % ((a["name"] or "")[:26], a["model_id"]) for a in _allmine
|
||||
if not a["model_is_thinking"]
|
||||
and any(h in (a["model_id"] or "").lower() for h in FLASH_HINT)
|
||||
and a["schedule_type"] == "recurring"] # ⚠️ 只有 recurring 会被服务端审模型
|
||||
if deaf:
|
||||
rec("model", "C", "fail", "周期排期会被服务端拒(静默失效)",
|
||||
"模型不支持关思考却传 thinking=0:%s" % "、".join(deaf))
|
||||
else:
|
||||
rec("model", "C", "ok", "周期排期模型可用", "本区 recurring %d 条" % len(_recur))
|
||||
# 一致性:机制建的排期(once)模型须与本区主会话同值
|
||||
if not _main_model:
|
||||
rec("model_same", "C", "warn", "取不到本区主会话模型 ⇒ 无法核对一致性(P0-80)",
|
||||
"sessions 里没有本区的人开会话")
|
||||
else:
|
||||
_mech = [a for a in _allmine if (a.get("name") or "").startswith(("[执行]", "[检查]"))]
|
||||
_diff = ["%s(%s)" % ((a["name"] or "")[:26], a["model_id"]) for a in _mech
|
||||
if (a["model_id"] or "") != _main_model]
|
||||
if _diff:
|
||||
rec("model_same", "C", "fail",
|
||||
"机制建的排期模型 ≠ 本区主会话模型(P0-80)",
|
||||
"主会话=%s;不一致 %d 条:%s" % (_main_model, len(_diff), "、".join(_diff[:3])))
|
||||
else:
|
||||
rec("model_same", "C", "ok", "机制排期模型与本区主会话一致(P0-80)",
|
||||
"主会话=%s,机制排期 %d 条全同" % (_main_model, len(_mech)))
|
||||
|
||||
# ⑨ cwds 归属同形(错一字面 ⇒ 裂组且自我强化)
|
||||
off = _cwds_nearmiss(autos, ws_norm)
|
||||
if off:
|
||||
rec("cwd", "C", "fail", "`cwds` 与本工作区**近失配**(差一个字符就裂成两组)",
|
||||
";".join(off[:3]))
|
||||
else:
|
||||
rec("cwd", "C", "ok", "`cwds` 归属同形", "本工作区的排期都写在同一条路径上")
|
||||
|
||||
# ⑩ 投递心跳
|
||||
hbs = [WS + "/.workbuddy/collab/logs/supervise-heartbeat.json",
|
||||
WS + "/.workbuddy/collab/supervise-heartbeat.json"]
|
||||
age = None
|
||||
for h in hbs:
|
||||
if os.path.isfile(h):
|
||||
age = time.time() - os.path.getmtime(h)
|
||||
break
|
||||
if age is None:
|
||||
rec("deliver", "C", "warn", "投递(常驻)未见心跳",
|
||||
"⛔ 不等于它一定没跑;载体=专用容器会话")
|
||||
elif age > 900:
|
||||
rec("deliver", "C", "fail", "投递心跳陈旧 ⇒ 常驻可能已掉线", "最后心跳 %.0f 分钟前" % (age / 60))
|
||||
else:
|
||||
rec("deliver", "C", "ok", "投递心跳新鲜", "%.0f 秒前" % age)
|
||||
|
||||
# ⑪ 活会话
|
||||
# ⑪ 活会话(🔴 只判 `ROLES_LIVE`=**当前在用的前缀**;历史前缀⛔ 不判"必须有活的")
|
||||
live = {lb: [s for s in sess if (s[1] or "").startswith(pre) and (s[2] or "") == "working"]
|
||||
for lb, pre in ROLES_LIVE}
|
||||
dead = [lb for lb, _ in ROLES_LIVE if not live.get(lb)]
|
||||
if dead:
|
||||
rec("live", "C", "warn", "此刻无活会话(按需创建属正常;主会话开工阶段须建齐)", "、".join(dead))
|
||||
else:
|
||||
rec("live", "C", "ok", "在用类别的会话均有活的",
|
||||
";".join("%s×%d" % (lb, len(live[lb])) for lb, _ in ROLES_LIVE))
|
||||
|
||||
# ⑫ 死排期:`once` 已无下次触发、却**从未运行过** ⇒ 活静默消失
|
||||
try:
|
||||
ran = {r[0] for r in c.execute(
|
||||
"select distinct automation_id from automation_runs limit 5000")}
|
||||
except Exception:
|
||||
ran = None
|
||||
never, consumed, unver, in_grace = _once_zombie(mine, ran)
|
||||
if never:
|
||||
rec("zombie", "C", "fail", "一次性排期**从未运行就失效**(那条活会静默消失)",
|
||||
"共 %d 条:%s" % (len(never), "、".join(never[:4])))
|
||||
elif unver:
|
||||
rec("zombie", "C", "warn", "无法核对一次性排期是否运行过",
|
||||
"读不到 `automation_runs`(⛔ 不等于它们没跑);本线 %d 条 once+无下次触发" % unver)
|
||||
else:
|
||||
rec("zombie", "C", "ok", "无「从未运行」的一次性排期",
|
||||
"本线 %d 条已跑完的一次性排期(正常痕迹,不计问题)%s"
|
||||
% (consumed, (";%d 条刚到点、仍在补跑窗口内(⛔ 不是哑火)" % in_grace) if in_grace else ""))
|
||||
|
||||
# ⑬ 🔴🔴 **旧前缀不许在「活的语境」里出现**(2026-10-05 用户定案)
|
||||
# 用户原话:「**兼容个毛线,今天兼容一个明天兼容一个 过不了一周就成大杂烩了**」。
|
||||
# ⇒ 本条把「不许兼容」从**口头要求**变成**机制判据**:任何人在活件里再写旧前缀
|
||||
# ⇒ 本闸门当场报红(⛔ 不靠"下次记得")。
|
||||
# 🔴 判据只扫**能产生/承载新名字的两处**(⛔ 不全文扫 —— 注释与 `references/*.md`
|
||||
# 里的考古记录是**合法保留**的,扫它们就是误红):
|
||||
# ① 活排期名(`automations.name`,未软删)—— 是旧前缀 ⇒ "今天又长出来一条"
|
||||
# ② 活会话标题(`sessions.title`)—— 同上
|
||||
# ⚠️ **不扫**:注释 / docstring / 考古段 / 软删的历史行
|
||||
# —— 那些是"记录过去发生过什么",⛔ 不是"现在还在用"(混为一谈即是误红)。
|
||||
# ✅ **变异对照**(2026-10-05 实跑,证明非恒绿非恒红):
|
||||
# 全 `[执行]` ⇒ ok;混 1 条 `[协作]` ⇒ fail;混 1 条 `[任务会话]` ⇒ fail;
|
||||
# 历史行但**不以旧前缀开头**(如 `接续:…`)⇒ ok。
|
||||
_OLD_WORKER = ("[协作]", "[协作目标]", "[任务会话]")
|
||||
_bad_sched, _bad_sess = [], []
|
||||
for _r in (mine or []):
|
||||
_nm2 = str((_r or {}).get("name") or "")
|
||||
if any(_nm2.startswith(_p) for _p in _OLD_WORKER):
|
||||
_bad_sched.append(_nm2[:44])
|
||||
for _s in (sess or []):
|
||||
_t2 = str(_s[1] or "")
|
||||
if any(_t2.startswith(_p) for _p in _OLD_WORKER):
|
||||
_bad_sess.append(_t2[:44])
|
||||
if _bad_sched or _bad_sess:
|
||||
_det = []
|
||||
if _bad_sched:
|
||||
_det.append("活排期 %d 条:%s" % (len(_bad_sched), "、".join(_bad_sched[:3])))
|
||||
if _bad_sess:
|
||||
_det.append("活会话 %d 条:%s" % (len(_bad_sess), "、".join(_bad_sess[:3])))
|
||||
rec("old_prefix", "C", "fail",
|
||||
"旧前缀又长出来了(新建一律 `[执行]`)", ";".join(_det))
|
||||
else:
|
||||
rec("old_prefix", "C", "ok",
|
||||
"活排期/活会话里无旧前缀(新建一律 `[执行]`)",
|
||||
"⛔ 历史行不在此列:它们是**解析正确性**,删了看板画不出(⛔ 不叫「兼容」)")
|
||||
|
||||
# ── 收口 ───────────────────────────────────────────────────────────────
|
||||
def finish():
|
||||
fails = [c for c in CHECKS if c["level"] == "fail"]
|
||||
warns = [c for c in CHECKS if c["level"] == "warn"]
|
||||
verdict = "fail" if fails else ("warn" if warns else "ok")
|
||||
summary = {"ok": "会话规则机制齐备且生效",
|
||||
"warn": "配置在册,运行未齐(见 warn 项)",
|
||||
"fail": "规则机制有硬缺口(见 fail 项)"}[verdict]
|
||||
state = {"ts": time.strftime("%Y-%m-%d %H:%M:%S"), "ts_epoch": int(time.time()),
|
||||
"ws": WS, "verdict": verdict, "summary": summary,
|
||||
"counts": {"fail": len(fails), "warn": len(warns),
|
||||
"ok": len(CHECKS) - len(fails) - len(warns)},
|
||||
"checks": CHECKS}
|
||||
try:
|
||||
os.makedirs(os.path.dirname(OUT), exist_ok=True)
|
||||
tmp = OUT + ".tmp"
|
||||
with io.open(tmp, "w", encoding="utf-8", newline="\n") as f:
|
||||
f.write(json.dumps(state, ensure_ascii=False, indent=2))
|
||||
os.replace(tmp, OUT) # 原子替换(本机文件锁会卡死 ⇒ 见 MEMORY)
|
||||
except Exception as e:
|
||||
state["write_error"] = str(e)[:160]
|
||||
|
||||
if AS_JSON:
|
||||
sys.stdout.buffer.write((json.dumps(state, ensure_ascii=False, indent=2) + "\n").encode("utf-8"))
|
||||
else:
|
||||
icon = {"ok": "✅", "warn": "⚠️", "fail": "🔴"}[verdict]
|
||||
buf = ["%s [会话规则] %s(fail %d / warn %d / ok %d)"
|
||||
% (icon, summary, len(fails), len(warns), state["counts"]["ok"])]
|
||||
show = CHECKS if SHOW_ALL else [c for c in CHECKS if c["level"] != "ok"]
|
||||
for c in show:
|
||||
m = {"ok": "✓", "warn": "⚠", "fail": "✗"}[c["level"]]
|
||||
buf.append(" %s [%s] %s%s" % (m, c["group"], c["title"],
|
||||
(" —— " + c["detail"]) if c["detail"] else ""))
|
||||
if not SHOW_ALL and not show:
|
||||
buf.append(" (九项全过,明细见状态标记)")
|
||||
buf.append(" · 状态标记 → %s" % OUT)
|
||||
sys.stdout.buffer.write(("\n".join(buf) + "\n").encode("utf-8"))
|
||||
sys.stdout.flush()
|
||||
return 0 if verdict == "ok" else 1
|
||||
|
||||
|
||||
def main():
|
||||
cmds, _ = check_hooks()
|
||||
if cmds:
|
||||
check_hook_skillnames(cmds)
|
||||
check_gate_logs()
|
||||
check_memory_pointers()
|
||||
check_snapshot_stale()
|
||||
check_orchestration()
|
||||
return finish()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except Exception as e:
|
||||
try:
|
||||
sys.stdout.buffer.write(("⚠️ [会话规则] 体检自身异常(⛔ 不影响开工):%s\n"
|
||||
% str(e)[:200]).encode("utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"ts": "2026-10-07 21:48:19",
|
||||
"ts_epoch": 1791380899,
|
||||
"ws": "E:/ProgramData/AIProject/content_marketing_agent",
|
||||
"verdict": "warn",
|
||||
"summary": "配置在册,运行未齐(见 warn 项)",
|
||||
"counts": {
|
||||
"fail": 0,
|
||||
"warn": 4,
|
||||
"ok": 10
|
||||
},
|
||||
"checks": [
|
||||
{
|
||||
"id": "hook_reg",
|
||||
"group": "A",
|
||||
"level": "ok",
|
||||
"title": "关键钩子在册",
|
||||
"detail": "共 14 条钩子注册"
|
||||
},
|
||||
{
|
||||
"id": "hook_path",
|
||||
"group": "A",
|
||||
"level": "ok",
|
||||
"title": "钩子脚本路径均存在",
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"id": "hook_skillname",
|
||||
"group": "A",
|
||||
"level": "ok",
|
||||
"title": "钩子注入引用的技能名均存在",
|
||||
"detail": "扫了 14 份钩子脚本"
|
||||
},
|
||||
{
|
||||
"id": "gate_fresh",
|
||||
"group": "A",
|
||||
"level": "warn",
|
||||
"title": "部分闸门日志陈旧 / 缺失(可能已掉线)",
|
||||
"detail": "锁(缺)|收口=2026-10-07 21:46 技能=2026-10-07 21:46 限流=2026-10-07 21:48"
|
||||
},
|
||||
{
|
||||
"id": "mem_ptr",
|
||||
"group": "B",
|
||||
"level": "warn",
|
||||
"title": "读不到工作区 MEMORY.md",
|
||||
"detail": "路径:E:/ProgramData/AIProject/content_marketing_agent\\.workbuddy\\memory\\MEMORY.md"
|
||||
},
|
||||
{
|
||||
"id": "snap_sync",
|
||||
"group": "B",
|
||||
"level": "warn",
|
||||
"title": "本区无权威规则文件(`CODEBUDDY.md` 不存在 ⇒ 本项不适用)",
|
||||
"detail": "E:/ProgramData/AIProject/content_marketing_agent\\CODEBUDDY.md"
|
||||
},
|
||||
{
|
||||
"id": "clock",
|
||||
"group": "C",
|
||||
"level": "ok",
|
||||
"title": "已退役角色的周期钟已全部 PAUSED(或本就无在册)",
|
||||
"detail": "在册 0 条,全部非 ACTIVE"
|
||||
},
|
||||
{
|
||||
"id": "model",
|
||||
"group": "C",
|
||||
"level": "ok",
|
||||
"title": "周期排期模型可用",
|
||||
"detail": "本区 recurring 0 条"
|
||||
},
|
||||
{
|
||||
"id": "model_same",
|
||||
"group": "C",
|
||||
"level": "ok",
|
||||
"title": "机制排期模型与本区主会话一致(P0-80)",
|
||||
"detail": "主会话=deepseek-v4.1-flash,机制排期 13 条全同"
|
||||
},
|
||||
{
|
||||
"id": "cwd",
|
||||
"group": "C",
|
||||
"level": "ok",
|
||||
"title": "`cwds` 归属同形",
|
||||
"detail": "本工作区的排期都写在同一条路径上"
|
||||
},
|
||||
{
|
||||
"id": "deliver",
|
||||
"group": "C",
|
||||
"level": "ok",
|
||||
"title": "投递心跳新鲜",
|
||||
"detail": "3 秒前"
|
||||
},
|
||||
{
|
||||
"id": "live",
|
||||
"group": "C",
|
||||
"level": "warn",
|
||||
"title": "此刻无活会话(按需创建属正常;主会话开工阶段须建齐)",
|
||||
"detail": "执行"
|
||||
},
|
||||
{
|
||||
"id": "zombie",
|
||||
"group": "C",
|
||||
"level": "ok",
|
||||
"title": "无「从未运行」的一次性排期",
|
||||
"detail": "本线 11 条已跑完的一次性排期(正常痕迹,不计问题)"
|
||||
},
|
||||
{
|
||||
"id": "old_prefix",
|
||||
"group": "C",
|
||||
"level": "ok",
|
||||
"title": "活排期/活会话里无旧前缀(新建一律 `[执行]`)",
|
||||
"detail": "⛔ 历史行不在此列:它们是**解析正确性**,删了看板画不出(⛔ 不叫「兼容」)"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""常驻启动器(无控制台)—— 🔴 计划任务的**常驻载体**指向本文件,⛔ 不许直起 `collabd.py`。
|
||||
|
||||
🔴🔴 2026-10-05 **为什么必须要有这个启动器**(本轮实测踩到的坑,P0-73):
|
||||
新形态(计划任务 → `pythonw.exe` → `collabd.py --supervise`)**直起**时,
|
||||
任务环境里**没有 `CODEBUDDY_CONFIG_DIR`** —— 旧看守 `start-supervise.ps1` 里那句
|
||||
`$env:CODEBUDDY_CONFIG_DIR = "E:\\ProgramData\\.workbuddy"`
|
||||
在我改成两层形态时**被丢掉了** ⇒ 后果:
|
||||
· `_wb_db()` 落到 `~/.workbuddy/workbuddy.db`(**0 字节空库**)⇒
|
||||
· `_all_sessions_idle()` 每轮报 **`no such table: sessions`** ⇒ 闸② 恒判"有会话在跑"
|
||||
· ⇒ **检查会话再也不建**(实测日志 36 次读库失败,最后一次成功建排期停在 11:54)。
|
||||
⚠️ 为什么有的区没炸:那区的 `collabd.config.json` 里 `host_db` **写死了绝对路径**(兜住了)。
|
||||
⇒ **两种兜底都要有**:启动器设 env(通用)+ 配置可写 `host_db`(单区保险)。
|
||||
|
||||
🔴 用 `runpy.run_path`(⛔ 不用 `exec`):`runpy` 会把 `__name__`/`__file__`/`sys.path[0]`
|
||||
都按"真在跑那个脚本"设好 ⇒ `collabd.py` 顶部的 `_sm_load_roots()` 靠 `__file__` 找 `roots.env`
|
||||
与命令行分发(它自己读 `sys.argv`)全对。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 🔴 启动器所在目录即本区 `.workbuddy/collab`(由 init_workspace 分发到各区)
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
CB = os.path.join(HERE, "collabd.py")
|
||||
CFG = os.path.join(HERE, "collabd.config.json")
|
||||
|
||||
# 🔴 活动环境目录(宿主库所在)—— 这是**旧看守里有、新形态里被我漏掉**的那一条。
|
||||
os.environ.setdefault("CODEBUDDY_CONFIG_DIR", r"E:\ProgramData\.workbuddy")
|
||||
os.environ["COLLABD_CONFIG"] = CFG
|
||||
os.environ["PYTHONIOENCODING"] = "utf-8"
|
||||
os.environ["PYTHONUNBUFFERED"] = "1"
|
||||
|
||||
# 🔴 `pythonw.exe` 下 `sys.stdout/stderr` 可能是 `None` ⇒ 任何 `print` 都会 `AttributeError` 秒退。
|
||||
LOG = os.path.join(HERE, "logs", "_supervise-launch.log")
|
||||
try:
|
||||
os.makedirs(os.path.dirname(LOG), exist_ok=True)
|
||||
_f = open(LOG, "a", encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
_f = None
|
||||
if _f is not None:
|
||||
if sys.stdout is None:
|
||||
sys.stdout = _f
|
||||
if sys.stderr is None:
|
||||
sys.stderr = _f
|
||||
|
||||
import runpy
|
||||
|
||||
sys.argv = [CB, "--supervise"]
|
||||
runpy.run_path(CB, run_name="__main__")
|
||||
@@ -0,0 +1 @@
|
||||
on
|
||||
Reference in new issue
Block a user