Files
workbuddy_skills/session-mechanism/scripts/stop-collab.py
T
admin d81f75f173 技能包:配置与代码分家 —— 每包自建 env/ 放本机专属配置,scripts/ 不再写死本机路径
用户 2026-10-11 原话:
· 19:22「这个环境都是针对一台机器的,同步到别的机器把环境都搞坏了」
· 19:28「不用挪出 用env文件夹区分就行,重点是 脚本是脚本 配置是配置」
· 19:29「每个技能 需要环境配置的 都有自己的 env文件夹」
· 19:30「修改完成后要仔细检查 避免调整后 各技能脚本运行失败」

改动:
1、session-mechanism 新增 env/(roots.env=本机真身不入库 + roots.env.example=样例入库),
   install.py 的 ROOTS_ENV 改指 env/roots.env;全包脚本/hook 的配置定位统一为
   「向上 4 层 × env/roots.env 优先 → 包根 roots.env 兼容 → 技能库那份兜底」。
2、去硬编码:collabctl / keepalive / supervise-launch / board-launch 里的本机路径与本机工作区
   清单全清,改为 DSH_AIROOT(兜底 dirname(DSH_WS_ROOT))+ 扫 <AIROOT>/*/.workbuddy/collab/
   自动发现;保活任务名按 collabd-keepalive-<工作区名> 现推;启动器默认值改 ~/.workbuddy 形态。
3、browser-harness:bh_chrome_keepalive.py 默认值全部改 env 驱动,新增 env/bh.env.example。
4、.gitignore:env/ 下只放行 *.example(三行三种层级)+ 兜住旧位置的包根 roots.env。
5、改包纪律.md 新增「四、包结构:脚本是脚本,配置是配置」,立为全仓通用规矩。
6、文档同步:SKILL.md / deploy.md(新增 §3b 配置层级、§6 换机器必查 6 件事)/ rules.md /
   pitfalls.md / supervise-persistence.md。

验收(本机):py_compile 34 个 py 全过;selftest PASS 109 / FAIL 0;
逐文件实证 8 个 loader 命中 env/roots.env;preflight-lock.sh 同目录实测解出两个根;
install.py --manifest 77 份、语法失败 0。

异机验证(包拷成「新克隆」(只有 *.example)+ 另造假工作区 ws-alpha/ws-beta):
install.py --apply 自生成该机 env/roots.env;collabctl/keepalive 自动发现的确实是
ws-alpha/ws-beta、任务名现推(写死清单确已消除);部署副本本地无 roots.env 时靠
CODEBUDDY_CONFIG_DIR 兜底读到技能库那份;整套 selftest 有宿主库时 PASS 108 / FAIL 1
(唯一 FAIL=该区没有常驻心跳,环境所致;本机常驻在跑时同一套为 PASS 109 / FAIL 0)。
2026-10-11 21:49:28 +08:00

235 lines
11 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.
# -*- coding: utf-8 -*-
"""「协作机制 · 停机」统一入口 —— 🔴 属于**本工作区**,⛔ 不属于技能。
由来(用户 2026-09-30):
> 「守护进程还在吗 **能否关闭守护时 也关闭这个 schedule**」
背景:那个**网关唤醒**(走 WorkBuddy 网关 `scheduled-tasks`)是**隐形的** ——
它**不进 `automations` 表**,而且 `durable:true` 的**连网关自己的列表都列不出来**(实测)。
⇒ 只认 id ⇒ **必须有个地方记住它、有个入口能停它**,否则将来没人知道它还挂着。
用法:
python stop-collab.py # 只报告(dry-run,默认)
python stop-collab.py --yes # 真停
做四件:
① **删网关唤醒** —— id 从同目录 `gateway-schedules.json` 读(那是它的"登记册")
② **停守护**(若在跑)—— 写 `guard.stop` ⇒ 守护优雅退出(⛔ 不 kill)
③ **报告残留** —— 后台任务戳/端口/状态文件,一眼看清还剩什么
④ **停看板服务**(`board.py --serve`)—— 🔴 2026-09-30 加:Windows 的 `SO_REUSEADDR`
允许**同端口重复绑定且不报错** ⇒ 多个看板实例会**静默并存**、同一 URL 被随机应答
(快照/代码版本互相"打架")。此处按端口逐个探 `/healthz` 签名,把**所有**实例列出来并停掉。
"""
from __future__ import annotations
# [session-mechanism] roots.env 外置(由 install.py 生成 ⇒ `<包>/env/roots.env`)
# 查找顺序:向上 4 层 × (env/roots.env 优先 → 包根 roots.env 兼容) → 技能库那份。
def _sm_load_roots():
import os as _os
_here = _os.path.dirname(_os.path.abspath(__file__))
_cands = []
for _up in range(4):
_base = _os.path.normpath(_os.path.join(_here, *([".."] * _up)))
for _rel in (("env", "roots.env"), ("roots.env",)):
_cands.append(_os.path.join(_base, *_rel))
_conf = _os.environ.get("CODEBUDDY_CONFIG_DIR") or _os.path.expanduser("~/.workbuddy")
_cands.append(_os.path.join(_conf, "skills", "session-mechanism", "env", "roots.env"))
for _p in _cands:
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 subprocess
import sys
import time
from pathlib import Path
HERE = Path(__file__).resolve().parent
WS = Path(os.environ.get("DSH_COLLAB_WS") or HERE.parent.parent)
INBOX = WS / "tmp" / "supervise-inbox"
REG = HERE / "gateway-schedules.json" # 🔴 隐形执行体的**登记册**
CFG = Path(os.environ.get("CODEBUDDY_CONFIG_DIR") or os.path.expanduser("~/.workbuddy"))
DO = "--yes" in sys.argv
def _token() -> str:
# ⛔ 只在进程内用,绝不落盘/回显
return os.environ.get("CODEBUDDY_GATEWAY_PASSWORD") or ""
def _gw_port(tok: str):
"""扫回环找网关(认 /api/v1/health 返 200)。返回端口或 None。"""
import urllib.request
out = subprocess.run(["netstat", "-ano"], capture_output=True, text=True,
timeout=15, errors="replace",
creationflags=0x08000000).stdout or "" # CREATE_NO_WINDOW:⛔ 闪窗
ports = set()
for ln in out.splitlines():
if "LISTENING" in ln and "127.0.0.1:" in ln:
try:
ports.add(int(ln.split("127.0.0.1:")[1].split()[0]))
except Exception:
pass
for p in sorted(ports):
if p < 1024:
continue
try:
r = urllib.request.Request("http://127.0.0.1:%d/api/v1/health" % p)
if tok:
r.add_header("x-access-token", tok)
with urllib.request.urlopen(r, timeout=1.5) as x:
# 🔴 判据必须**收紧**:⛔ 别只认 "HTTP 200" ——
# 2026-09-30 实测踩到:本机有个口在 `/api/v1/health` 上也返 200,
# 但 body 是 `{"ret":1,"version":"3"}`(别的服务)⇒ **误抓成网关**
# ⇒ 后果是"删到别的服务上"或"删不动还以为是网关的问题"。
# ✅ 真网关的 body 形如 `{"data":{"status":"ok","uptime":…,"platforms":[…]}}`。
body = x.read(300).decode("utf-8", "replace")
if '"status"' in body and "ok" in body:
return p
except Exception:
continue
return None
def _board_instances():
"""列出本机所有**看板服务**实例:[(port, pid), ...]。
判据(⛔ 必须收紧):① 只在 `127.0.0.1` 上 LISTENING;② `/healthz` 的 body **同时含
`"ok"` 与 `"snapshots"`**(=本看板签名 —— 别的服务不会同时给这两个键;⛔ 不能只认 200)。
⚠️ 能列出**多个**:这正是本函数存在的意义(Windows 允许同端口并存)。
"""
import urllib.request
out = subprocess.run(["netstat", "-ano"], capture_output=True, text=True,
timeout=15, errors="replace",
creationflags=0x08000000).stdout or "" # CREATE_NO_WINDOW:⛔ 闪窗
seen, res = set(), []
for ln in out.splitlines():
if "LISTENING" not in ln or "127.0.0.1:" not in ln:
continue
try:
port = int(ln.split("127.0.0.1:")[1].split()[0])
pid = ln.split()[-1]
except Exception:
continue
if port < 1024 or port in seen:
continue
seen.add(port)
try:
with urllib.request.urlopen("http://127.0.0.1:%d/healthz" % port, timeout=0.25) as x:
body = x.read(400).decode("utf-8", "replace")
if ('"ok"' in body) and ("snapshots" in body):
res.append((port, pid))
except Exception:
continue
return res
def main() -> int:
tok = _token()
print("=== 协作机制 · 停机(%s)===" % ("真停" if DO else "dry-run(只报告)"))
print("工作区 =", WS)
# ① 网关唤醒
reg = {}
if REG.is_file():
try:
reg = json.loads(REG.read_text(encoding="utf-8")) or {}
except Exception:
reg = {}
tasks = (reg.get("tasks") or {})
print("\n① 网关唤醒(登记册 %s):%d 条" % (REG.name, len(tasks)))
if not tasks:
print(" (登记册为空 —— 没有已知的隐形执行体)")
port = _gw_port(tok)
print(" 网关端口 =", port or "(没扫到)")
if tasks and port and tok:
import urllib.error
import urllib.request
for tid, meta in tasks.items():
sid = str((meta or {}).get("sessionId") or "")
url = "http://127.0.0.1:%d/api/v1/scheduled-tasks/%s?sessionId=%s" % (port, tid, sid)
if not DO:
print(" [dry-run] 会 DELETE %s" % url.replace(tok, "***"))
continue
req = urllib.request.Request(url, method="DELETE")
req.add_header("x-access-token", tok)
try:
with urllib.request.urlopen(req, timeout=8) as x:
print(" DELETE %s → %s" % (tid, x.status))
tasks.pop(tid, None)
except urllib.error.HTTPError as e:
print(" DELETE %s → %s(⛔ 未删成,需人工)" % (tid, e.code))
except Exception as e:
print(" DELETE %s → 异常 %s" % (tid, e))
if DO:
reg["tasks"] = tasks
reg["updatedAt"] = time.strftime("%Y-%m-%d %H:%M:%S")
REG.write_text(json.dumps(reg, ensure_ascii=False, indent=1), encoding="utf-8")
elif tasks and not tok:
print(" ⚠️ 没有网关口令 ⇒ 删不了(**只有宿主进程树内的进程能删**)")
# ② 守护
gstop, gjson = INBOX / "guard.stop", INBOX / "guard.json"
running = gjson.is_file()
print("\n② 守护:%s" % ("在跑" if running else "**没在跑**"))
if running:
if DO:
gstop.write_text("stop\n", encoding="utf-8")
print(" 已写 guard.stop ⇒ 守护会优雅退出(⛔ 未 kill)")
else:
print(" [dry-run] 会写 guard.stop")
# ③ 残留
print("\n③ 残留清点:")
for name in ("_tick.stamp", "collabd-once.stamp", "collabd-state.json",
"guard.stop", "guard.json", "NEXT.md", "NEED-USER.md"):
p = INBOX / name
if p.is_file():
print(" %-22s %s" % (name, time.strftime("%m-%d %H:%M", time.localtime(p.stat().st_mtime))))
else:
print(" %-22s (无)" % name)
# ④ 看板服务(🔴 可为多实例 —— Windows 允许同端口并存,是"打架"的根源)
inst = _board_instances()
print("\n④ 看板服务(board.py --serve):%d 个" % len(inst))
if not inst:
print(" (没扫到 —— ⛔ 只代表「没探到签名」,不代表「端口都空着」)")
for port, pid in inst:
if not DO:
print(" [dry-run] 会停 127.0.0.1:%d(PID %s)" % (port, pid))
continue
r = subprocess.run(["taskkill", "/F", "/PID", str(pid)], capture_output=True,
text=True, timeout=15, errors="replace",
creationflags=0x08000000) # CREATE_NO_WINDOW:⛔ 闪窗
print(" 停 127.0.0.1:%d(PID %s)→ rc=%s" % (port, pid, r.returncode))
if len(inst) > 1:
print(" ⚠️ 多个实例并存 ⇒ 同一 URL 会被**随机**应答 ⇒ 看板内容/代码版本会「打架」。")
elif len(inst) == 1:
print(" ✓ 只有一个实例(正常)。")
print("\n⇒ 完成。⛔ 未动任何别的工作区/别的需求。")
return 0
if __name__ == "__main__":
sys.exit(main())