# -*- 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())