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