- 变更规模:新增 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/ 知识文件,按口径入库)
9.9 KiB
9.9 KiB
看板「多实例打架」 —— 排查、根因与护栏(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(唤醒=从未响过) |