Files
workbuddy_skills/session-mechanism/scripts/stop-collab.py
T

228 lines
10 KiB
Python
Raw Normal View History

# -*- 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 生成;缺失则回落到按位置推导)
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 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())