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/ 知识文件,按口径入库)
This commit is contained in:
1 parent
30b46dbd0c
commit
c1b5e4d966
735 files changed
+153192
-2415
No files matched your search
@@ -0,0 +1,228 @@
|
||||
# -*- 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())
|
||||
Reference in new issue
Block a user