492 lines
30 KiB
Python
492 lines
30 KiB
Python
#!/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, keeper: Path) -> str:
|
|||
|
|
"""🔴🔴 给本工作区**登记一条计划任务**(="各工作区都能自己常驻"的落点)。
|
|||
|
|
|
|||
|
|
## 为什么必须有这一步(2026-10-04 用户原话)
|
|||
|
|
|
|||
|
|
> 「我需要的是**各工作区会话 完成目标时 自己能建立 常驻程序**」
|
|||
|
|
|
|||
|
|
病根链(逐层实测):
|
|||
|
|
① `ensure_supervise()` 能起常驻,但起法(`DETACHED_PROCESS`)**不脱离会话作业对象**
|
|||
|
|
⇒ 会话一收工就被回收(vibe 会话查出,我已复验);
|
|||
|
|
② 唯一合格载体=**计划任务**(父链根在 `svchost -s Schedule`,与会话树无关);
|
|||
|
|
③ 而 `init_workspace.py` 原来**只铺脚本、⛔ 不登记任务**(当时口径:"
|
|||
|
|
开机自启不是需求")⇒ 于是"只有某个区能常驻"。
|
|||
|
|
|
|||
|
|
## ⚖️ 边界(⛔ 不许越)
|
|||
|
|
|
|||
|
|
· **本函数是"登记"的唯一实现** —— ⛔ 别在别处再写一份(那是漂移的源头);
|
|||
|
|
· ⛔ **只登记本区**(任务名带区名,各区互不干扰);
|
|||
|
|
· ⛔ **不代用户开目标** —— 没目标就没有常驻,那是正常态;
|
|||
|
|
· ✅ **幂等**:重复跑=覆盖同一条任务,⛔ 不产生第二条。
|
|||
|
|
|
|||
|
|
## 返回
|
|||
|
|
|
|||
|
|
人类可读的一行结论(调用方直接打印)。⛔ 不抛异常(登记失败要能继续跑完别的步骤)。
|
|||
|
|
"""
|
|||
|
|
_task = "collabd-supervise-%s" % (ws.name or "ws")
|
|||
|
|
_ps = (
|
|||
|
|
"$ErrorActionPreference='Stop';"
|
|||
|
|
"$n='%s';"
|
|||
|
|
# 🔴 幂等:先注销同名(`-Confirm:$false` 免交互;宿主可能 -NonInteractive)
|
|||
|
|
"if(Get-ScheduledTask -TaskName $n -ErrorAction SilentlyContinue){"
|
|||
|
|
"Unregister-ScheduledTask -TaskName $n -Confirm:$false};"
|
|||
|
|
# 🔴 动作=**powershell.exe -File <本区 keeper>**(⛔ 不是 pythonw):
|
|||
|
|
# keeper 里要 set 那几个 env(CODEBUDDY_CONFIG_DIR / COLLABD_CONFIG)⇒
|
|||
|
|
# 直接跑解释器没有"设环境变量"这一步。
|
|||
|
|
"$a=New-ScheduledTaskAction -Execute 'powershell.exe' "
|
|||
|
|
"-Argument '-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File \"%s\"' "
|
|||
|
|
"-WorkingDirectory '%s';"
|
|||
|
|
# 🔴 `AtLogOn` = 开机/登录即起(这一步让"跨会话"成立:⛔ 不依赖任何 WorkBuddy 会话)
|
|||
|
|
"$t=New-ScheduledTaskTrigger -AtLogOn;"
|
|||
|
|
# 🔴 四个设置缺一不可(少一个就前功尽弃,逐条理由见 references/supervise-persistence.md §二②):
|
|||
|
|
# ExecutionTimeLimit=0 ⇒ 默认 72h 会把常驻杀掉
|
|||
|
|
# MultipleInstances=IgnoreNew ⇒ 防双写台账
|
|||
|
|
# RestartCount/Interval ⇒ 脚本非零退出时重拉(⚠️ 真正的看护在 keeper 的 while 里)
|
|||
|
|
"$s=New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries "
|
|||
|
|
"-DontStopIfGoingOnBatteries -StartWhenAvailable "
|
|||
|
|
"-ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew "
|
|||
|
|
"-RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1);"
|
|||
|
|
"Register-ScheduledTask -TaskName $n -Action $a -Trigger $t -Settings $s -Force|Out-Null;"
|
|||
|
|
# 🔴 立刻起一次(⛔ 别等下次登录 —— 否则"配好了"这个结论当场不可验)
|
|||
|
|
"Start-ScheduledTask -TaskName $n;"
|
|||
|
|
"'OK ' + $n"
|
|||
|
|
) % (_task, str(keeper), str(ws))
|
|||
|
|
try:
|
|||
|
|
_r = subprocess.run(["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass",
|
|||
|
|
"-Command", _ps],
|
|||
|
|
capture_output=True, timeout=180)
|
|||
|
|
_o = _r.stdout.decode("utf-8", "replace").strip()
|
|||
|
|
_e = _r.stderr.decode("utf-8", "replace").strip()
|
|||
|
|
if "OK " in _o:
|
|||
|
|
return "✅ 已登记计划任务 `%s`(AtLogOn · ExecutionTimeLimit=0 · IgnoreNew · Restart×999)并已启动" % _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-supervise-%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()]
|
|||
|
|
port = a.port or (20000 + (abs(hash(ws_s)) % 5000)) # ⛔ 每区唯一
|
|||
|
|
# 🔴🔴 端口**跨进程稳定**(2026-10-04 修):原来用 `hash(ws_s)`,而
|
|||
|
|
# **Python 3.11+ 字符串 hash 每进程随机化**(PYTHONHASHSEED)⇒
|
|||
|
|
# **同一工作区每跑一次就换一个端口** ⇒ 旧守卫/防火墙/别的配置里的端口全部对不上。
|
|||
|
|
# ⇒ 改成 `zlib.crc32`(确定性、与版本无关);已有配置里写了端口就沿用它。
|
|||
|
|
import zlib
|
|||
|
|
port = port or (20000 + (zlib.crc32(ws_s.encode("utf-8")) % 5000))
|
|||
|
|
|
|||
|
|
print("=== 新建协作工作区:%s ===" % ws_s)
|
|||
|
|
# ① 骨架
|
|||
|
|
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 = {}
|
|||
|
|
if _old_cfg.get("singleton_port"):
|
|||
|
|
port = int(_old_cfg["singleton_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 # 这两项必须以本次为准(工作区可能搬过家)
|
|||
|
|
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, " 沿用既有" if _old_cfg.get("singleton_port") else ""))
|
|||
|
|
|
|||
|
|
# ③ 目标(**补缺式**:已有 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"
|
|||
|
|
_own_ba = ws / ".workbuddy" / "collab" / "board.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
|
|||
|
|
_bd_show = (SK / "board.py") if not (ws / ".workbuddy" / "collab" / "board.py").is_file() else _own_ba
|
|||
|
|
print(" python %s --supervise" % _cd_show)
|
|||
|
|
print(" python %s --serve 8789 --takeover" % _bd_show)
|
|||
|
|
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())
|