Files
workbuddy_skills/session-mechanism/scripts/init_workspace.py
T
admin 64dd82073b session-mechanism: 修复钩子静默失效 + 3 处判据缺陷;禁「变相征询」
1) stop-dialog-guard: session_budget() 早退路径返回 2 值、末尾返回 3 值,调用方按 3 值解包
   ⇒ transcript > 64 MiB 时每轮 ValueError。因 fail-open(异常仍 exit 0),
   宿主零报错、install.py --verify 只判 rc=0 ⇒ 假绿;实测 86 条 EXCEPTION,
   死掉的是整条(水位/收口、接续机制起点、预算告警、门禁自检、路径自检)。

2) session-rules-check 三处判据:
   · hook_reg  按旧文件名找 ⇒ 合并成 prompt-guards.py 后每轮假红 ⇒ 改为一组可接受名
   · snap_sync 拿 mtime 当内容判据 ⇒ 连续 4 天假红 ⇒ 改为复用抽取器本体比对内容
               (变异对照:截断快照能报 fail,非恒绿)
   · mem_ptr   只查全局技能根 ⇒ 工作区自带技能被判悬空 ⇒ 改查「全局 ∪ 工作区」

3) pitfalls 新增 P0-95(改判据必须重跑变异对照;fail-open + 只看 rc=0 = 假绿温床)

4) 回复排版核心块新增「变相征询同样禁止」(先只报不动/等你发话/我倾向X你看呢
   这类不带选项的待定清单,一律按待拍板项写:问题+说明+各候选优缺点+倾向)
2026-10-06 22:27:03 +08:00

584 lines
37 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python
"""🔴 **新建协作工作区**(技能内脚本,用户 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 io
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 _register_keeper_task(ws: Path, python_exe: Path) -> str:
"""🔴🔴 给本工作区**登记一条计划任务**(="各工作区都能自己常驻"的落点)。
## 为什么必须有这一步(2026-10-04 用户原话)
> 「我需要的是**各工作区会话 完成目标时 自己能建立 常驻程序**」
病根链(逐层实测):
① `ensure_supervise()` 能起常驻,但起法(`DETACHED_PROCESS`)**不脱离会话作业对象**
⇒ 会话一收工就被回收(vibe 会话查出,我已复验);
② 唯一合格载体=**计划任务**(父链根在 `svchost -s Schedule`,与会话树无关);
③ 而 `init_workspace.py` 原来**只铺脚本、⛔ 不登记任务**(当时口径:"
开机自启不是需求")⇒ 于是"只有某个区能常驻"。
## ⚖️ 边界(⛔ 不许越)
· **本函数是"登记"的唯一实现** —— ⛔ 别在别处再写一份(那是漂移的源头);
· ⛔ **只登记本区**(任务名带区名,各区互不干扰);
· ⛔ **不代用户开目标** —— 没目标就没有常驻,那是正常态;
· ✅ **幂等**:重复跑=覆盖同一条任务,⛔ 不产生第二条。
## 返回
人类可读的一行结论(调用方直接打印)。⛔ 不抛异常(登记失败要能继续跑完别的步骤)。
"""
_task = "collabd-keepalive-%s" % (ws.name or "ws")
# 🔴🔴 2026-10-06 收口(P0-74):动作=**`pythonw.exe` + `supervise-launch.py`**。
# 旧形态是 `powershell.exe -File start-supervise.ps1` —— PowerShell 是**控制台程序**
# ⇒ 每次触发分配 `conhost.exe` ⇒ **闪一下黑窗**(用户 2026-10-05:「又弹了窗口」)。
# ⚠️ 旧注释的理由「keeper 里要 set 那几个 env ⇒ 直接跑解释器没有设环境变量这一步」
# **已被 `supervise-launch.py` 推翻** —— 那个启动器存在的**全部目的**就是在进程内设 env。
# ⛔ **登记动作仍走 PowerShell**(那是在**登记**,不是在**当看守**,两者别混)。
_pyw = Path(python_exe).with_name("pythonw.exe")
if not _pyw.is_file(): # ⛔ 没同版本 pythonw ⇒ 找全局那份
_cands = sorted(Path(python_exe).parent.parent.parent.glob("*/pythonw.exe"))
_pyw = _cands[-1] if _cands else Path(python_exe)
_launcher = ws / ".workbuddy" / "collab" / "supervise-launch.py"
# 🔴 `-WorkingDirectory` **必须是「工作区根」**(⛔ 不是脚本目录):
# `collabd.load_cfg()` 按 `<cwd>/.workbuddy/collab/collabd.config.json` 找配置;
# cwd 设成脚本目录 ⇒ 它去找 `<ws>/.workbuddy/collab/.workbuddy/collab/…` ⇒ **找不到**
# ⇒ `CFG_MISSING` ⇒ `--supervise` 拒跑(实测 rc=2;任务侧 `LastTaskResult=1`)。
_wd = str(ws).replace("/", "\\")
_ps = (
"$ErrorActionPreference='Stop';"
"$n='%s';"
# 🔴 幂等:先注销同名(`-Confirm:$false` 免交互;宿主可能 -NonInteractive)
"if(Get-ScheduledTask -TaskName $n -ErrorAction SilentlyContinue){"
"Unregister-ScheduledTask -TaskName $n -Confirm:$false};"
# 🔴 动作=`pythonw.exe` + 启动器(GUI 子系统 ⇒ 零 conhost ⇒ **不闪窗**)
"$a=New-ScheduledTaskAction -Execute '%s' -Argument '\"%s\"' -WorkingDirectory '%s';"
# 🔴 `RepetitionInterval` 5 分钟(与 `collabctl.py` 的既定口径一致)
"$t=New-ScheduledTaskTrigger -Once -At (Get-Date) "
"-RepetitionInterval (New-TimeSpan -Minutes 5);"
# 🔴 四个设置缺一不可(逐条理由见 references/supervise-persistence.md §二②):
# ExecutionTimeLimit=0 ⇒ 默认 72h 会把常驻杀掉;
# ⚠️ ⛔ 不许设 2 分钟(那会被调度器到点掐死 ⇒ 每 2 分钟重建一次 = 抖动)
# MultipleInstances=IgnoreNew ⇒ 防双写台账
# -Hidden ⇒ 计划任务库里不显眼
"$s=New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries "
"-DontStopIfGoingOnBatteries -StartWhenAvailable "
"-ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew -Hidden;"
"$pr=New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Highest;"
"Register-ScheduledTask -TaskName $n -Action $a -Trigger $t -Settings $s -Principal $pr -Force|Out-Null;"
# 🔴 立刻起一次(⛔ 别等下次触发 —— 否则"配好了"这个结论当场不可验)
"Start-ScheduledTask -TaskName $n;"
"'OK ' + $n"
) % (_task, str(_pyw), str(_launcher), _wd)
try:
_r = subprocess.run(["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass",
"-Command", _ps],
capture_output=True, timeout=180,
# 🔴 `CREATE_NO_WINDOW`(⛔ 缺了每次登记都闪一下)
creationflags=0x08000000)
_o = _r.stdout.decode("utf-8", "replace").strip()
_e = _r.stderr.decode("utf-8", "replace").strip()
if "OK " in _o:
return ("✅ 已登记计划任务 `%s`(pythonw + supervise-launch.py · 每 5 分钟 · "
"ExecutionTimeLimit=0 · IgnoreNew)并已启动" % _task)
# ⛔ 失败要**报原文**,⛔ 不许编原因
_msg = (_e or _o or "无回显").splitlines()
return ("⛔ 登记失败:%s\n ⚠️ 若提示权限/策略 ⇒ 走 PowerShell 工具手动建"
"(`Register-ScheduledTask`;⛔ 别用 `schtasks.exe` —— 本机黑名单拦它)"
% (_msg[0][:160] if _msg else "无"))
except Exception as _ex:
return "⛔ 登记失败(异常):%r" % (_ex,)
def _keeper_task_state(ws: Path) -> str:
"""读本区计划任务状态(⛔ 只读;用来做"到底建成了没"的行为级验收)。"""
_task = "collabd-keepalive-%s" % (ws.name or "ws")
_ps = ("$n='%s';"
"$t=Get-ScheduledTask -TaskName $n -ErrorAction SilentlyContinue;"
"if(-not $t){'MISSING'}else{"
"$i=Get-ScheduledTaskInfo -TaskName $n;"
"'State=' + $t.State + ' Last=' + $i.LastTaskResult}"
) % _task
try:
_r = subprocess.run(["powershell.exe", "-NoProfile", "-Command", _ps],
capture_output=True, timeout=60)
return _r.stdout.decode("utf-8", "replace").strip() or "(无回显)"
except Exception as _ex:
return "(读不到:%r)" % (_ex,)
def _ps_check(ps1: Path) -> str:
"""🔴🔴 验收一份 `.ps1` **确实是合法 PowerShell**(返回''=没问题,否则返回提示串)。
病根(2026-10-04 事故,P0-56):模板首行被误打成「三连双引号 + #」开头,
生成物被 PowerShell 判 9 处语法错、**一秒内秒退** ⇒ 常驻零续命。
⚠️ 而当时自检只查 ①UTF-8 BOM ②模板变量是否填完 —— **两个表层特征全绿** ⇒ 静默通过。
⇒ 判据必须**行为级**:真调 PowerShell 自己的解析器(`Parser::ParseFile`),
⛔ 不查关键字、⛔ 不用正则猜。拿不到解析器时**返回"没验"**(⛔ 不当通过)。
"""
_exe = None
for _c in ("powershell.exe", "pwsh.exe"):
try:
subprocess.run([_c, "-NoProfile", "-Command", "exit 0"],
capture_output=True, timeout=20)
_exe = _c
break
except Exception:
continue
if not _exe:
return " ⚠️ 没法验语法(本机没有 PowerShell)"
_cmd = (
"[Console]::OutputEncoding=[Text.Encoding]::UTF8;"
"$e=$null;$t=$null;"
"[System.Management.Automation.Language.Parser]::ParseFile('%s',[ref]$t,[ref]$e)|Out-Null;"
"if($e -and $e.Count -gt 0){'ERR '+$e.Count+' @L'+$e[0].Extent.StartLineNumber+': '+$e[0].Message}"
"else{'OK'}"
) % str(ps1).replace("'", "''")
try:
_r = subprocess.run([_exe, "-NoProfile", "-Command", _cmd],
capture_output=True, timeout=60)
_o = _r.stdout.decode("utf-8", "replace").strip()
if _o.startswith("OK"):
return ""
if _o.startswith("ERR"):
# ⚠️ PowerShell 的报错文案**含换行与中文** ⇒ 只取**第一行**、截断到 60 字,
# ⛔ 否则整个自检输出被它冲垮(实测回显里混着源码行)。
_msg = _o[4:].splitlines()[0].strip() if _o[4:].strip() else ""
if len(_msg) > 60:
_msg = _msg[:60] + "…"
return " ⛔ **语法不过**(%s)⇒ 计划任务会秒退 ⇒ 常驻没人续命" % _msg
return " ⚠️ 语法没验成(回显异常:%s)" % ((_o.splitlines() or [""])[0][:60] or "(空)")
except Exception as _e:
return " ⚠️ 语法没验成(%r)" % (_e,)
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)
ap.add_argument("--no-task", action="store_true",
help="只铺 start-supervise.ps1,⛔ 不登记计划任务(要手动登记时用)")
# 🔴 2026-10-04 口径订正:**默认不铺载体**(常态用不着;见 SKILL.md R 红线口径段)
ap.add_argument("--with-keeper", action="store_true",
help="铺 start-supervise.ps1(目标做完还要让程序继续跑时才用)")
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:用户「任何工作区都要能自己配好」)
# 病根(实测):全机 262 个计划任务里**只有 `collabd-supervise-ws3` 一个**是本机制的
# ⇒ 5 个部署区**只有测试3 有载体** ⇒ 其余区的常驻**没人续命**(实测停摆 15~20 小时)。
# 根因再深一层:那份 `start-supervise.ps1` 里**工作区路径硬编码了 11 处**
# ⇒ 别的区**没法直接用**,于是就都没建。
# ✅ 做法=**模板 + 按区填变量**:模板在 `assets/start-supervise.ps1.tpl`。
_tpl = SK.parent / "assets" / "start-supervise.ps1.tpl" # ⛔ `SK` 指向 **scripts/**,包根要上一层
_keeper = ws / ".workbuddy" / "collab" / "start-supervise.ps1"
_cdir = ws / ".workbuddy" / "collab"
_logs = _cdir / "logs"
_tmp = ws / "tmp"
for _d in (_logs, _tmp):
_d.mkdir(parents=True, exist_ok=True)
# 🔴🔴 2026-10-04 **口径订正**(用户原话:「从来没说过什么开机自启,
# 只有调用技能完成目标时启动 后台任务和检查程序」):
# **常态不需要载体脚本,也不需要计划任务。**
# ⛔ 本步改成**默认不铺**,只有显式 `--with-keeper` 才铺
# (⛔ 原来无条件铺 = 把"我以为你要长期在线"当需求做掉了,
# 还在输出里说「还差一步:登记计划任务」⇒ 凭空造出一个欠项)。
if not a.with_keeper:
print("⑥ 常驻载体:**跳过**(⛔ 默认不铺)")
print(" 📌 常态=调用技能完成目标时起后台任务 + 检查程序,⛔ 用不着载体。")
print(" 📌 若确实要「目标做完程序还继续跑」⇒ 加 `--with-keeper` 重跑本脚本,")
print(" 它会铺 `start-supervise.ps1` 并**打印**计划任务参数(⛔ 仍不代建)。")
elif _tpl.is_file():
# pythonw 必须**同版本**(⛔ 用 sys.executable 推不出同目录 pythonw)
_pyw_exe = Path(a.python)
_pyw = _pyw_exe.with_name("pythonw.exe")
if not _pyw.exists(): # ⛔ 没同版本 pythonw ⇒ 找全局那份
_cands = sorted(_pyw_exe.parent.parent.parent.glob("*/pythonw.exe"))
_pyw = _cands[-1] if _cands else _pyw_exe
_ws_native = str(ws) # ps1 里要**原生反斜杠**路径
_vals = {
"__WS__": _ws_native,
"__CFGDIR__": os.environ.get("CODEBUDDY_CONFIG_DIR") or str(Path.home() / ".workbuddy"),
"__COLLABDCFG__": str(cfgp),
"__PYW__": str(_pyw),
"__SCRIPT__": str(_own if _own.is_file() else (SK / "collabd.py")),
"__HBPATH__": str(_logs / "supervise-heartbeat.json"),
"__LOG__": str(_tmp / "keeper.log"),
"__STAMP__": str(_tmp / "keeper-last-restart.txt"),
"__STOPFLAG__": str(ws / "tmp" / "supervise-inbox" / "guard.stop"),
"__OUTLOG__": str(_logs / "supervise.out.log"),
"__ERRLOG__": str(_logs / "supervise.err.log"),
}
_t = io.open(_tpl, encoding="utf-8").read()
for _k, _v in _vals.items():
_t = _t.replace(_k, _v)
# 🔴 必须 **UTF-8 带 BOM**(⛔ 不带 ⇒ PowerShell 5.1 按 GBK 解码 ⇒ CJK 路径被毁
# ⇒ 任务一秒内 Result=1 退出 —— 这是实测踩过的)
io.open(_keeper, "w", encoding="utf-8-sig", newline="\r\n").write(_t)
print("⑥ 常驻载体已铺:%s" % _keeper.relative_to(ws))
# 🔴🔴 2026-10-04 用户口径:「我需要的是**各工作区会话 完成目标时 自己能建立 常驻程序**」
# ⇒ **登记这一步由本脚本自己做掉**(⛔ 不再只是打印参数让人去手工建 ——
# 实测结果就是"谁也没建",全机只剩 1 条指向已删区的任务)。
# ⚠️ 幂等:重复跑=覆盖同一条任务名(`_register_keeper_task` 内先 Unregister)。
# ⚠️ 只登记**本区**;⛔ 不动别的区、⛔ 不动用户的其它任务。
if a.no_task:
print(" ⏭ `--no-task` ⇒ **跳过登记计划任务**(只铺了脚本)")
else:
print(" " + _register_keeper_task(ws, _keeper))
print(" 🔎 复核:%s" % _keeper_task_state(ws))
else:
print("⑥ ⚠️ 找不到载体模板 `assets/start-supervise.ps1.tpl` ⇒ **未铺**"
"(⚠️ 只有传了 `--with-keeper` 才需要它)")
print(" 🔴 **登记范围**:⛔ 本脚本只登记**本工作区**那一条任务(任务名带区名)——")
print(" ⛔ 不碰别的区、⛔ 不动机器上其它任务;重复跑=覆盖同一条(幂等)。")
# ⑤ 自检收口放在⑥ 之后(⛔ 顺序:先铺载体再体检,否则体检会漏掉"载体有没有")
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-04 补:**载体**(实测全机只有 1 个区有 ⇒ 其余区常驻没人续命)
_kb = ws / ".workbuddy" / "collab" / "start-supervise.ps1"
if _kb.is_file():
_raw = _kb.read_bytes()
_bom = _raw[:3] == b"\xef\xbb\xbf"
_no_var = "__" in _raw.decode("utf-8-sig", "ignore")
# 🔴🔴 2026-10-04(P0-56):**必须验「这是不是合法 PowerShell」**
# 病根:原自检只查 ①BOM ②模板变量填完没 —— 两个都是**表层特征**。
# 实测事故:模板首行被我误打成「三连双引号 + #」⇒ 生成的 `.ps1`
# 被 PowerShell 判 **9 处语法错**、一秒内秒退 ⇒ 常驻 0 续命。
# ⚠️ 而 BOM 在、变量填完了、文件也在 ⇒ **原自检全绿** ⇒ 静默通过。
# ⇒ 判据必须**行为级**:真拿 PowerShell 的解析器解析一遍,⛔ 不查关键字。
_syn = _ps_check(_kb)
print(" 载体脚本 = %s%s%s%s"
% ("✅ 在位", "" if _bom else "(⛔ **缺 UTF-8 BOM** ⇒ PowerShell 5.1 会按 GBK 解码"
"⇒ CJK 路径被毁 ⇒ 任务秒退)",
" ⛔ 模板变量没填完" if _no_var else "",
_syn))
else:
# 🔴 2026-10-04 口径订正:⛔ **不许**把「没有载体」说成缺陷。
# 常态=调用技能完成目标时起后台任务 + 检查程序,⛔ 用不着载体;
# 「目标做完程序还继续跑」是**可选需求** ⇒ 没铺只是「没选它」,⛔ 不是「坏了」。
# ⛔ 旧文案「常驻跨会话必被带走」是**编造的后果**(实测常驻是孤儿进程,
# 独立存活 24.7 h)⇒ 本轮订正。
print(" 载体脚本 = ⛔ 没铺(📌 **这不是问题**:常态用不着它;"
"确实要「目标做完程序还继续跑」⇒ 加 `--with-keeper` 重跑)")
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())