Files
admin c1b5e4d966 chore(工作区): 全量入库 + 补齐 .gitignore(以工作区为准)
- 变更规模:新增 514 / 修改 62 / 重命名 155 / 删除 4(归档重组与文档轮次)
- .gitignore 修:`归档/**/db-cwd归一-备份-*/` —— 原规则写绝对层级(归档/db-cwd归一-…),
  目录搬进 归档/配置与备份/ 后**静默失效**,43 MB 的 DB 备份又变成未跟踪
- .gitignore 补:嵌套 git 内部数据(归档/内嵌git-20261008/、归档/skills-git-旧线-20261007/dotgit-原样移出/)
- .gitignore 补:运行态与部署副本(.workbuddy/collab/、.workbuddy/tools/、.workbuddy/.load-pending、.workbuddy/tmp-*)
- .gitignore 补:备份件(*.bak-*)
- 未跟踪文件从 2190 降到 890(其余为 归档/ 归档件与 .workbuddy/memory/ 知识文件,按口径入库)
2026-10-10 23:13:22 +08:00

196 lines
8.6 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
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 r"E:\ProgramData\.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").stdout or ""
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").stdout or ""
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")
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())