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

150 lines
9.9 KiB
Markdown
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.
# 看板「多实例打架」 —— 排查、根因与护栏(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`(唤醒=从未响过) |