Files
admin 19101acd65 init: workbuddy_skills 重建,仅收录 session-mechanism
- 按用户指示清空原有 25 技能内容,只提交 session-mechanism(57 文件)
- 附 .gitignore(产物 + 本机凭据)
- 令牌明文已脱敏(历史 .neodata_token 与 pitfalls 引用均不入库)
- 本提交为孤儿提交(父提交为空),历史自此重新开始
2026-10-05 14:13:24 +08:00

229 lines
10 KiB
Python
Raw Permalink 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 生成;缺失则回落到按位置推导)
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())