Files
dsh_ai1net_server/交付物/看板多实例打架-排查与护栏-20260930.md
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

9.9 KiB
Raw Permalink Blame History

看板「多实例打架」 —— 排查、根因与护栏(2026-09-30)

用户原话:「要把其他看板服务关了 避免打架」 一句话结论:「打架」是真的,但原因不是"有很多看板服务在跑"—— 而是 Windows 允许它们"悄悄"全都绑同一个端口。 现已加单实例护栏(再起会被拒绝)+ --takeover(显式换代码)+ 停机入口第 ④ 项(一键停掉所有看板实例)。 现场实况:当前只有 1 个实例(127.0.0.1:8788 · PID 29648)—— ⛔ 没有别的要关。


1 现场清点(先回答"有几个")

查什么 命令/判据 读数
本机所有 python 进程 全量进程命令行导出后筛 只有 2 个:board.py --serve 8788(29648)、collabd.py --supervise(55332,协作程序,不是看板)
所有监听端口 netstat -ano 全端口 只有 127.0.0.1:8788 归看板(8964…其它口属别的程序:8900=短视频工作台、3080=wslrelay、8430=llama-server …)
别的端口还有看板吗 逐口探 /healthz 签名 没有(8790-8799 等全空)
别的工作区有看板吗 扫 AIProject/* 找 collab/ 与 board*-serve* 只有本工作区有
端口冲突报错 看板日志里搜 10048/already in use 零命中 —— ⚠️ 注意:这不是"没冲突"的证据,恰恰相反(见 §2)
现在有几个在应答 netstat + /healthz 连打 5 次 1 个;snapshots 计数单调递增(108→111),没有乱跳

⇒ 结论:此刻没有"其他看板服务"可以关。 但机制上允许它们并存 —— 所以真正该做的是把机制堵上(§3)。


2 根因:Windows 的 SO_REUSEADDR 允许同端口重复绑定且不报错

board.py --serve 用的是 ThreadingHTTPServer(scripts/board.py §serve()),它继承 allow_reuse_address = 1 ⇒ 落到 socket 上就是 SO_REUSEADDR。

  • 在 Windows 上,SO_REUSEADDR 的语义与 Linux 不同:它允许另一个进程绑同一个 地址:端口, 不报 WSAEADDRINUSE(10048)。⇒ 于是"再起一个看板"这件事会成功,而不是大声报错。
  • 结果:多个看板实例静默并存,同一个 URL 被随机交给其中一个进程应答 ⇒ 你看到的可能是旧进程的旧快照、甚至旧代码。"改了、也重启了、却还是旧的"就是这么来的。

判决性实验(我当场做的,⛔ 不是推断)

# 前提:8788 已由 PID 29648 在服务
$ python board.py --serve 8788 --interval 9
看板已起:http://127.0.0.1:8788/  (只绑回环 · 每 9.0s 异步快照 · 请求零阻塞)   ← 居然起·来·了
rc=124   (124 = 我拿 timeout 把它收走了;期间它一直活着)

⇒ 同端口重复绑定 = 静默成功,已证实。

现场旁证

看板启动日志 tmp/board-serve.log 开头是连续 6 行「看板已起」、中间零报错。 ⚠️ 诚实标注:仅凭日志无法区分"先后重启"与"同时并存"(两种情形都长这样)—— 所以 §2 的实验才是判据,日志只作旁证。


3 已做处置(把"避免打架"从"叮嘱"变成"机制")

# 改动 效果
① scripts/board.py:serve() 起服务之前探同端口 /healthz;认签名(body 同时含 "ok" 与 "snapshots") 端口上已有本看板 ⇒ 拒绝启动,并打印"直接看现成的那个 / 要换代码加 --takeover"。⛔ 不误判:只认"端口开着"会撞上别的服务,只认 HTTP 200 会撞上"返回 200 但不是看板"的服务(本机真有过)
② scripts/board.py:新增 --takeover 显式接管:先 taskkill 掉旧实例、再起新的 ⇒ 换代码的正规姿势(⛔ 不会像以前那样"两个都在")
③ .workbuddy/collab/stop-collab.py:新增 ④ 看板服务 按端口逐个探签名,把全部实例列出来并停掉(dry-run 只报告,--yes 真停)—— 这就是"把看板服务关了"的正式出口
④ 文档:references/pitfalls.md P0-9 + references/architecture.md「当前结论」+ SKILL.md「当前结论」+ 版本 1.1.1 让后来的会话不用重新踩一遍
⑤ 回归自测 scripts/selftest.py PASS 28 / FAIL 0,rc=0

验收读数(都是真跑出来的):

① 护栏:8788 已有看板时再起 ⇒
   ⛔ 已有看板在跑(127.0.0.1:8788,PID 29648)⇒ 本次不启动,避免多实例打架。   rc=0 ✅

② 接管:临时口 8799 上先起一个旧实例(PID 22768),再 --takeover ⇒
   ✓ 接管:已停旧看板 PID 22768(成功)
   看板已起:http://127.0.0.1:8799/                                            ✅
   接管后 8799 = 空(干净释放)

③ 停机入口:④ 看板服务(board.py --serve):1 个
   [dry-run] 会停 127.0.0.1:8788(PID 29648)
   ✓ 只有一个实例(正常)。                                                     ✅

⚠️ 没有重启现役看板(PID 29648),理由:本次改的是启动期的逻辑(护栏/接管), 快照内容一个字没改 ⇒ 正在跑的实例行为不变,而"拒绝重复启动"这条由被探测方(我的实例)作答,与它自己的代码无关。 ⇒ ⛔ 不做无谓重启(重启才是"看板闪一下"的来源)。


4 复盘配图

4.1 泳道图(角色 × 阶段)—— 问题落在哪一格

        环境起点            起看板               应答请求             用户看到
      ┌──────────────┐  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐
会话A │              │→ │ 起实例① :8788│→ │ 在跑(旧代码)│  │              │
      └──────────────┘  └──────────────┘  └──────┬───────┘  └──────────────┘
                                                 │
      ┌──────────────┐  ┌──────────────┐  ┌──────▼───────┐  ┌──────────────┐
会话B │ 改完 board.py│→ │ 起实例② :8788│→ │🔴也绑上了·无报错│→│🔴随机打到①或②│
      └──────────────┘  └──────────────┘  └──────────────┘  └──────────────┘
                                           ↑
                                   ❌ 红格就在这里:
                             「第二个实例居然绑得上,且没人报错」

4.2 事故链图 —— 怎么坏的 + 哪几道防线缺席

[1] 会话按需起看板          →  [2] 换代码后想重启        →  [3] 起第二个实例
    (架构就这样:起法=           (skill 自己写着"改完              (没人拦)
      宿主后台任务)                必须重启服务")
                                                                        │
        ┌───────────────────────────────────────────────────────────────┘
        ▼
[4] Windows SO_REUSEADDR ⇒ 同端口重复绑定【成功】、零报错
        │
        ▼
[5] 两个实例并存 ⇒ 同一 URL 被【随机】应答
        │
        ▼
[6] 用户看到旧快照/旧代码 ⇒「改了也没用」「还是没识别主会话」   ← ★ 你当时的感受

── 本该拦住的防线 ────────────────────────────────────────────────
  ❌ 防线①「绑定失败会报错」:Windows 语义不同 ⇒ **不报错** ⇒ 靠"没看到报错"判断=反向误导
  ❌ 防线②「启动前检查端口占用」:**根本没有这道检查**
  ❌ 防线③「停掉旧的再起新的」:只有口头约定,⛔ 不是机制
  ✅ 现已补齐:① 起前探签名(拒绝重复)② --takeover(停旧再起)③ stop-collab ④(能一键停干净)

5 边界与纪律

  • ✅ 只动本平台自己的机制件(协作技能脚本 + 本工作区的停机入口 + 其文档)。
  • ✅ 改动前取了机制层独占执行锁(接续-修唤醒与看板-20260930),完工释放。
  • ⛔ 没动看板 HTML/CSS、⛔ 没动协作程序 collabd.py、⛔ 没动中继客户端、⛔ 没动别的 workspace。
  • ⛔ 没删任何别的服务;找不到"其他看板服务"就如实说没有,⛔ 不为了交差去杀无辜进程。
  • ⚠️ 顺带发现(未处理,另一件事):stop-collab.py 的 _gw_port() 这次把 20090(设备接入垫片) 认成了网关 (真网关是 56975)。因登记册为空 ⇒ 本轮没有误删。⇒ 建议另开一棒核(本棒⛔ 不扩范围)。

6 关键文件 / 命令索引

用途 位置 / 命令
看板服务(有护栏) ~/.workbuddy/skills/multi-session-collab/scripts/board.py --serve 8788 [--interval 3]
换新代码重启 … board.py --serve 8788 --takeover
停掉所有看板实例 python .workbuddy/collab/stop-collab.py(先看)→ --yes(真停)
看板上线自检 curl -s http://127.0.0.1:8788/healthz ⇒ {"ok":true,"snapshots":N,"age":…}
本次改动 scripts/board.py|.workbuddy/collab/stop-collab.py|references/pitfalls.md(P0-9)|references/architecture.md|SKILL.md
上一件事的结论 交付物/查唤醒为什么断-结论-20260930.md(唤醒=从未响过)