228 lines
10 KiB
Python
228 lines
10 KiB
Python
# -*- 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())
|