Files

2267 lines
143 KiB
Python
Raw Permalink Normal View History

#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""board.py —— 生成**看板快照** `board.json`(供 `assets/board.html` 实时渲染)。
设计原则:
· **只读**:只读台账/状态/投递台账/宿主库(三张只读表)。(组件探测属**使用方**,见 board_ext)⛔ 不写任何账本。
· **零依赖**:只用标准库。⛔ 不引第三方。
· **快**:一轮 < 0.1 s(socket 探测 0.3 s 超时上限;宿主库只读一条 SQL)。
🔴 **总则:看板不能影响程序执行**(用户 2026-09-30 明令)。三条落地:
① **解耦**:常驻程序/守护程序**一行都不引用本文件**(已核);本文件**从不写任何账本**。
② **异步**:`--serve` 由**后台线程**按 `--interval` 秒产快照,**请求线程只吐内存缓存**
⇒ 请求路径 ⛔ 不碰 DB/⛔ 不读文件 ⇒ 开多少标签页都不增加宿主负载。
③ **降级不静默**:任何一块读不到 ⇒ 记进 `warn` 并在界面显示,⛔ 不伪装成"0 个会话"。
用法:
python board.py # 产一次 board.json(默认写到 inbox/board.json)
python board.py --out <路径>
python board.py --serve [端口] [--interval 秒] # 起本地只读看板(默认 8788 / 3 秒)
python board.py --serve 8788 --takeover # 显式"接管":先停旧实例再起(换新代码时用)
🔴🔴 **单实例护栏**(2026-09-30 加,治「多实例打架」):
Windows 的 `SO_REUSEADDR`(`ThreadingHTTPServer` 默认 `allow_reuse_address=1`)允许**同端口重复绑定
且不报错** ⇒ 多个 `--serve` 会**静默并存**,同一个 URL 被不同进程随机应答 ⇒ 快照/代码版本互相打架
(实拍:8788 已被占用时,第二个实例照样打印"看板已起")。
⇒ 现规则:**端口上已有本看板 ⇒ 默认拒绝启动**(提示复用现成那个);要换代码用 `--takeover`。
"""
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 glob
import hashlib
import json
import os
import re
import socket
import sqlite3
import sys
import time
from pathlib import Path
# 🔴🔴 2026-10-03 10:2x **命名判据与 `collabd.py` 共用同一实现**(不是抄一遍)。
# 为什么:`_role_of_title()` ⇄ `parse_session_name()` 这两侧**已漂过三次**
# (唤醒/跟进/接续各一次,每次都要靠对账用例才发现)⇒ 这次从**结构**上断掉漂移的可能。
# ⚠️ 走 `importlib` 按路径加载同目录 `collabd.py`:⛔ 不写进 `sys.path`
# (那会污染宿主进程能 import 到的名字空间);⛔ 也不复制判据(那就是第 N 次漂移的种子)。
# ⚠️ 加载失败 ⇒ `_is_check_agent` 回落成"只认新名"的保守实现(⛔ 宁可少判、不可崩)。
try: # pragma: no cover - 环境相关
import importlib.util as _ilu
_sp = _ilu.spec_from_file_location("_cb_shared", str(Path(__file__).resolve().parent / "collabd.py"))
_cb = _ilu.module_from_spec(_sp)
_sp.loader.exec_module(_cb) # ⛔ collabd 顶层不读配置(配置在函数里懒加载)⇒ 安全
_is_check_agent = _cb.is_check_agent
# 🔴🔴 2026-10-04 21:5x **「已退役角色」闸也从同一模块取**(用户拍板「候选一」)。
# 为什么从模块取而不是抄一份字面量:`_RETIRED_PFX` 是**两侧共用的唯一事实源**,
# 抄一份 ⇒ 立刻变成"第 N 次漂移"的种子(这两侧已漂过三次)。
# ⚠️ 判据本身=**一级方括号是退役角色词**(`[跟进]`/`[唤醒]`),⛔ 不是"角色解析为空"
# —— 空串里还有真主会话(现役 `a80f300d`「复盘失败并避免重犯」就是无前缀的)。
_is_retired_role_title = _cb.is_retired_role_title
# 🔴 2026-10-03 11:40x:`_pid_alive` 也**从同一模块取**(⛔ 同一模块名 `_cb_shared`
# 在本文件里被重复 spec 两次会拿到**两个独立模块对象** ⇒ ⛔ 判据会漂)。
# ⇒ 这里统一取模块、再从它上面取函数。
_cb_shared = _cb
except Exception: # pragma: no cover
def _is_check_agent(name: str) -> bool:
"""⚠️ 兜底:只认**新名**(带方括号)—— ⛔ 宁可少判一条,不可让看板崩。"""
return any(t in str(name or "") for t in ("[结果检查]", "[目标检查]"))
def _is_retired_role_title(name: str) -> bool:
"""⚠️ 兜底(与 `collabd.py` 同判据的保守复刻):只认**一级方括号里的退役角色词**。
⛔ 只在 `collabd.py` 加载失败时才会用到这里 —— 宁可少判,不可让看板崩。"""
m = re.match(r"^\[([^\]]*)\]\s*", str(name or "").strip())
return bool(m) and m.group(1).strip() in ("跟进", "唤醒")
HERE = Path(__file__).resolve().parent
CFG_P = Path(os.environ.get("COLLABD_CONFIG") or (HERE / "collabd.config.json"))
WS = Path(os.environ.get("DSH_COLLAB_WS") or "").resolve() if os.environ.get("DSH_COLLAB_WS") else None
def _cfg() -> dict:
try:
return json.loads(CFG_P.read_text(encoding="utf-8"))
except Exception:
return {}
C = _cfg()
if WS is None:
WS = Path(str(C.get("workspace") or "")).resolve()
INBOX = WS / str(C.get("inbox") or "tmp/supervise-inbox")
def _j(p: Path, dv=None):
try:
return json.loads(p.read_text(encoding="utf-8"))
except Exception:
return dv
def _tail_jsonl(p: Path, n: int = 10) -> list:
try:
with open(p, "rb") as f:
f.seek(max(0, os.path.getsize(p) - 65536))
ls = f.read().decode("utf-8", "replace").strip().splitlines()
out = []
for ln in ls[-n:]:
try:
out.append(json.loads(ln))
except Exception:
pass
return out
except Exception:
return []
def _host_db() -> Path | None:
p = str(C.get("host_db") or "")
if p and os.path.isfile(p):
return Path(p)
d = os.environ.get("CODEBUDDY_CONFIG_DIR") or os.path.expanduser("~/.workbuddy")
q = Path(d) / "workbuddy.db"
return q if q.is_file() else None
def _main_sid(st: dict) -> str:
"""主会话 sid。🔴 **与 `collabd.py::_main_sid()` 必须逐字同款**(判据只此一处权威:
① 声明为 `main` 的角色 ② 退回最近一次投递到的会话 ③ 都取不到 ⇒ 空串,⛔ 不猜)。
🔴🔴 2026-10-01 修(**两处同批改**):回落**只认"上次投的是主会话"那一次** ——
`wake.target == "follow"` 说明 `wake.sessionId` 记的是**跟进会话**(投递目标已改指跟进会话)
⇒ 直接**当没有回落**(返回空串)。⛔ 不改的话,投过一次之后**跟进会话会被读成主会话**。
"""
for _sid, _r in (st.get("roles") or {}).items():
if str(_r) == "main" and str(_sid).startswith(tuple("0123456789abcdef")):
return str(_sid)
_w = st.get("wake") or {}
if str(_w.get("target") or "") == "follow":
return ""
return str(_w.get("sessionId") or "")
def _same_ws(cwd: str, ws_root=None) -> bool:
"""`cwd` 是否就是**本工作区**(斜杠/大小写无关)。⛔ 仅用于**主会话解析**,⛔ 不用于"会话归属本项目"。
🔴🔴 2026-10-05 **加 `ws_root` 形参**(peer 格修复,见 `_scan_ws_mains()` docstring):
⛔ 不传 ⇒ 仍比对本看板的 `WS`(**本区行为逐字不变**);
✅ 传了 ⇒ 比对**那个工作区**的根 —— peer 格必须用它,否则对方主会话**永远解析不出**。
"""
a = str(cwd or "").replace("\\", "/").rstrip("/").lower()
b = str(ws_root if ws_root is not None else WS).replace("\\", "/").rstrip("/").lower()
return bool(a) and a == b
# 🔴 主会话的**显式前缀**(用户 2026-09-30 定名:「主会话前缀 可以叫 主控」)。
# ⚠️ 实测现役主会话写成 `主控 · 协作机制 · …` —— **中点分隔、无方括号**。
MAIN_PREFIX = "主控"
def _role_of_title(name: str) -> str:
"""标题 ⇒ 会话角色(`main`/`worker`/`waker`/`follow`/`""`)。
🔴 **⛔ 与 `collabd.py::parse_session_name()` 必须逐条同款**(本文件此后是"两处同款"的第 2 处)。
`selftest.py::看板与常驻程序:命名判据不许漂` 会**真的把两边的函数拉出来对账**,⛔ 不许一边改一边不改。
**四种形态**(判据只看标题:⛔ 不看 cwd 归属、⛔ 不看 status)
① `[角色]-[类别]-<具体>`(合规) ② 一级前缀 `[协作]…`
③ 🆕 **接续会话**:`[<类别>] 接续 · …` / `接续棒:…` ⇒ **worker**
④ 🆕 **主控前缀**:`主控 · <类别> · …`(中点分隔、无方括号)⇒ **main**
🔴 为什么必须认 ③:接续会话是**会话自己建的新会话**,标题常**没有角色方括号**;
旧判据只排 `[协作]` ⇒ 它会被当**主会话候选**(且标题里的 `[<类别>]` 又让它变成
"该类别的主会话")⇒ 通知**投给它自己**(自指死结,2026-10-01 实测)。
⛔ 认不出 ⇒ 空串(⛔ 不猜、⛔ 不回落 cwd 推断 —— 架构 §2.3)。
"""
nm = str(name or "").strip()
if not nm.startswith("["):
head = nm.split("·")[0].split(":")[0].split(":")[0].strip()
if head.startswith(MAIN_PREFIX):
return "main"
# 🔴🔴 2026-10-03 10:2x **接续棒与检查会话都判 worker**(⛔ 顺序有讲究:接续在前)。
# 检查会话=**常驻程序**建、它自己派执行棒 ⇒ 是干活的那条。
# ⚠️ 旧式光杆名(`结果检查-…`,10:1x 之前建的)会落到这里 ⇒ 之前返 `""`
# ⇒ 被 `_scan_ws_mains()` 收进**主会话候选** ⇒ 本文件 `_role_label()` 判据①命中
# ⇒ 看板显示「主会话」(实测 `44b547d4`)⇒ 可能**投错窗口**。
# ✅ 判据**直接复用 `collabd.py` 的实现**(`import` 同一个函数,不是抄一遍)
# —— 这两侧历史上已漂过三次 ⇒ 这次从结构上断掉漂移的可能。
# 🔴 2026-10-03 11:1x `worker` ⇒ **`check`**(属常驻程序,⛔ 不是任务会话)
if _is_check_agent(nm):
return "check"
return "worker" if "接续" in nm else ""
_m0 = re.match(r"^\[([^\]]*)\]\s*(.*)$", nm)
r = _m0.group(1).strip() if _m0 else ""
# 🔴🔴 2026-10-02 23:4x **会话类别从四类收敛为两类**(用户:「按照新的逻辑整体修改」/
# 「唤醒会话 跟进会话 和 上报程序 都去掉才对」/「上报机制也不需要了」)。
# ⇒ 只认 `主` / `协作` 两键;`唤醒` / `跟进` **不再映射**。
# ⛔ 历史 `sessions` 旧行(那些标题还在)**照旧显示在 `sessions` 列表里**,
# 但**不再是活类别** ⇒ 解析出 `""` ⇒ 不会被误收成主会话候选。
# 🔴 **必须与 `collabd.py::parse_session_name()` 逐条同款** —— 见上文那行红线。
# 🔴 2026-10-03 11:1x 新增 `检查` 键(⛔ 与 `collabd.py::parse_session_name()` 同款)
# 🔴🔴 2026-10-03 19:5x **「白名单」与「映射表」合并成一个 dict** ——
# 原来这里是 `if r in ("主","协作","检查"):` + 下一行一张映射表,**两处都要改**:
# 我只改了映射表 ⇒ 新前缀 `[任务会话]` 被白名单挡在门外 ⇒ 解析出 `""`
# ⇒ 看板认不出这条会话(**本轮实测当场发现**)。
# 🔴 **判据纪律**:前缀表**只留一处**(dict 本身就是白名单),⛔ 不许再拆成"先判在不在、再查映射"。
# ⚠️ 必须与 `collabd.py::parse_session_name()` 逐条同款(它用 `.get(r, "")` ⇒ 白名单即 dict 本身)。
# 🔴🔴 2026-10-03 23:4x **「协作」→「执行」改名(用户定案:「**把协作会话 改为 执行会话,协作 改为 执行**」)**
# —— **沿用 19:2x 那次「显示层改名、机制层不动」的正确范式**,但**这次多留一个新前缀**:
# · 显示层:`worker` 标签 → **「执行会话」**;`main` → 「主会话」不变。
# · 识别层:**新增 `执行` 前缀**(新排期用它),⛔ **`协作`/`任务会话` 两个旧前缀必须保留**
# (⚠️ 实测宿主库有 **53 条排期 + 45 条会话**标题带 `[协作]` ⇒ 删掉映射
# ⇒ 它们全部解析成 `""` ⇒ **看板认不出、派活漏管**,且**不可逆**)。
# 这正是 19:2x 注释里那条「改前缀会让现存历史会话认不出来」的教训。
# · 机制内部标识 `worker` 一律不动(⛔ 它是代码里的角色 id,不是人看的词)。
# 🔴🔴 2026-10-05 **用户定案:「协作 全部 改为执行」(连说两遍)** —— 新建前缀一律 `[执行]`。
# ⛔⛔ 下表**只许增、不许删**:删任一条 ⇒ 存量会话/排期解析成 `role=""`
# ⇒ **看板画不出、派活漏管**,且**不可逆**。
# ⚠️ 必须与 `collabd.py::parse_session_name()` 的 role 表**逐条同款**
# (`collabd.py:4863` 附近 + `selftest.py` 逐样本对账,⛔ 漂了就红)。
_PFX = {"主": "main",
"执行": "worker", # 🆕 新前缀(当前口径)
"协作": "worker", # ⛔ 旧前缀,保留兼容(库里有 53+45 条在用)
"协作目标": "worker", # ⛔ 旧前缀,保留兼容(二代 10-03 短暂形态)
"任务会话": "worker", # ⛔ 旧前缀,保留兼容
"检查": "check"}
if r in _PFX:
return _PFX[r]
# 🔴🔴 2026-10-01 修**第三处判据漂移**(实测扫出来的,不是推理):
# 原来只查 `_m0.group(2)`(方括号**后面**那段),于是
# `[接续] 会话机制合并技能包(第 1 棒)` —— 方括号里是「接续」、后面那段里没有
# ⇒ 本函数返回 **空**,而 `collabd.py::is_continuation()` 判据是
# `"接续" in name`(**整串**)⇒ 返回 **worker**。
# 后果(线上正发生):本函数返回空 ⇒ `_scan_ws_mains()` 的排除元组拦不住它 ⇒
# 它被收进**主会话候选** ⇒ 看板的「主会话」位上坐着一条 **接续棒**(`3aa35bae`,355 分钟没动)
# —— 而它其实是**干活的棒**,正是 `is_continuation()` 注释里那个"自指死结"的同一族。
# ⇒ 判据改成**整串**判:`"接续" in nm` ——
# 逐字同款 `collabd.py::is_continuation()`(⛔ 两处必须同款,`selftest.py` 有对账用例)。
if "接续" in nm:
return "worker"
return ""
# 🔴 角色标签(**2026-10-02 两类口径**:① 主会话 ② 任务会话 —— 唤醒/跟进/上报整套退役)。
# ⚠️ 与 `board.html` 的渲染判据**必须同款**(那边按角色挑格子)。
# ⛔ `waker` / `follow` 两个键**刻意保留在表里**(⛔ 不删,删了旧会话渲成空标签更难读),
# 但**任何新会话都不会再被判成这两个角色**(见 `_role_of_title`)。
# 🔴🔴 2026-10-03 11:1x **新增 `check`=「检查会话」**(用户纠正逐字:
# 「检查会话**不是协作会话**,**不应该出现在看板协作会话区域中**。
# 检查会话属于**协作程序的会话**(本来也是协作程序创建),**可以放在协作程序框图中展示」)。
# ⚠️ 之前 `collabd.py` 把它判成 `worker` ⇒ 混进任务会话那一排 ⇒ **正是用户指出的错误**。
# ⛔ 不并入 `协作`(并进去前端就会画错地方)⇒ **独立一类**。
_ROLE_LABEL = {"main": "主会话",
# 🔴🔴 2026-10-03 23:4x **显示层继续改名:「协作目标」→「执行会话」**
# (用户定案:「**把协作会话 改为 执行会话,协作 改为 执行**」;
# 19:2x 那次改的「协作目标」正是这条链上的中间态)。
# ⚠️⚠️ **这个值同时是前端判据**(`board.html` 里 `role==='任务会话'` 那一排)⇒
# **改它必须连前端一起改**,⛔ 只改一边 ⇒ 那一排会**整排空掉**(19:2x 已踩过)。
# ⛔ **机制内部标识一律不动**:`worker` 角色 id、`[协作]`/`[执行]` 前缀的**识别映射**
# 全部保持 —— 前缀映射见上面 `_PFX`(旧名兼容不能删,否则存量失管)。
"worker": "任务会话",
"check": "检查会话",
"waker": "唤醒会话(已退役)", "follow": "跟进会话(已退役)"}
def _role_label(sid: str, title: str, sc: dict) -> str:
"""会话角色标签 —— **四类**(⛔ 不是"主会话 / 其它"两档)。
🔴🔴 2026-10-01 修:原来这里是**二分**(`"主会话" if 登记/解析出的主会话 else "任务会话"`)
⇒ **唤醒会话与第④类「队列上报的跟进会话」一律被标成「协作会话」** ⇒
`board.html` 第三层(按 `role` 挑格子)会把它们**误画进"任务会话"那一排**:
· 唤醒会话会**画两遍**(一次在主会话左侧的触发源位、一次在这排里);
· 跟进会话则**顶着一个错名字**(读者以为它是任务会话 ⇒ 分不清"谁在干活")。
⚠️ 这个缺陷**一直存在但看不见** —— 直到真的建出这两类会话才会显形。
判据优先级(⛔ 只看标题与主会话登记,⛔ 不回落 cwd —— 架构 §2.3):
① 命中主会话登记/解析结果(`main_sid` / `main_sids`)⇒ **主会话**;
② 否则按标题解析(`_role_of_title()`)⇒ 唤醒会话/跟进会话/任务会话;
③ 解析不出(老式命名、接续棒)⇒ 回落**协作会话** —— 它们本来就是干活的棒,旧行为逐字不变。
"""
if sid == sc.get("main_sid") or sid in (sc.get("main_sids") or []):
return "主会话"
# 🔴 2026-10-03 19:2x **回落值跟着角色表走**(⛔ 原来写死 `"任务会话"`;改名后它会与
# `_ROLE_LABEL["worker"]` 不一致 ⇒ 同一个人被标成两个词)。
return _ROLE_LABEL.get(_role_of_title(title), _ROLE_LABEL["worker"])
def _scan_ws_mains(st: dict, topics=None, ws_root=None) -> dict:
"""🔴 扫一遍宿主库,挑出**本工作区里所有"可能是主会话"的会话**(按最近活动倒序)。
🔴🔴🔴 2026-10-05 **加 `ws_root` 形参**(用户报障逐字:「**peer 工作区的主会话还是找不到对应的吗**」)。
**根因**:本函数(及 `_resolve_main()`)**只认本看板的 `WS`**,而 peer 格(跨工作区 tab)
渲染的是**别的**工作区 —— 它的会话 `cwd` 指向**对方**目录
(实测:peer 主会话的 `cwd` = `<对方工作区根>`,而本看板 `WS` = `<本工作区根>`)
⇒ `_same_ws()` 恒 `False` ⇒ `cand/named/by_topic` **全空**
⇒ 前端每个类别 chip 都画成「⚠ 无主会话」,而**那条主会话明明活着**。
⚠️ **这是"改一处漏一处"的又一次**(本包已复发多次):`_sessions()` 早就为 peer 修过
(走 `bypass=True` + `sc["workspace"] = ws_root`,见 `_goal_block()` 注释),
**但同一条数据流上的 `_scan_ws_mains()/_resolve_main()` 没跟上** ⇒ 会话列表对了、主会话仍空。
⛔ 不传 `ws_root` ⇒ 比对本看板 `WS`(**本区行为逐字不变**);
✅ 传了 ⇒ 只把**"哪个工作区"**换成那个根,**其余判据逐条不动**
(角色排除/接续会话/退休角色/类别取自标题 —— 全部照旧)。
🔴 与 `collabd.py::_scan_mains()` **同款判据**(⛔ **改一处必须改两处** —— 漂了就没人能发现主会话换了):
· 只看 `cwd == 目标工作区`(⛔ 不回落其他推断)
· ⛔ **排除全部"协作侧"命名**:`[协作]-…`/`[唤醒]-…`/🆕 `[跟进]-…`/**接续会话**
(`[<类别>] 接续 · …`)—— 四者都是**干活的棒**,⛔ 不是主会话候选
(🔴 2026-10-01 两次扩:先只排 `[协作]` ⇒ 接续棒冒充主会话;再到**按角色排** + 补第④类 `follow`)
· 角色判据见 `_role_of_title()`(⛔ 与 `collabd.py::parse_session_name()` **逐条同款**)
· ⚠️ **不按 `status='working'` 筛**(主会话两轮之间是空闲 ⇒ 筛了就永远漏掉它)
· 类别取自**标题里出现的 `goal.topics` 项**(最长优先,见 `_topic_in_title()`)
@returns `{"cand": [sid…], "named": [sid…], "by_topic": {类别: {"sid","source","explicit"}}}`
⚠️ `by_topic` 的值是 **dict**(与 `collabd.py::_scan_mains()` **同款**)—— ⛔ 不是"类别→sid"的裸映射,
否则调用方会把整个 dict 当成 sid 用(**实测踩到过**)。
"""
out: dict = {"cand": [], "named": [], "by_topic": {}, "titles": {}}
db = _host_db()
if not db:
return out
# 🔴 `topics` 传入 ⇒ **按这个目标的类别认主会话**(多目标 tab 用);
# ⛔ 不传 ⇒ 回落活跃目标(`goal.json`)⇒ **单目标部署行为逐字不变**。
tps = _as_list(topics) if topics is not None else _goal_topics()
try:
con = sqlite3.connect("file:%s?mode=ro" % str(db).replace("\\", "/"), uri=True, timeout=0.3)
try:
con.execute("pragma busy_timeout=300")
except Exception:
pass
rows = con.execute(
"select id,cwd,coalesce(custom_title,title,'') from sessions "
"where deleted_at is null order by last_activity_at desc limit 50").fetchall()
con.close()
for r in rows:
t = str(r[2] or "")
# 🔴 2026-10-01 改:从"只排 `[协作]`"改成"**排掉全部"协作侧"命名**"(接续会话 + 唤醒会话),
# 同日再补**第④类 `[跟进]`** —— ⛔ 与 `collabd.py::_scan_mains()` **逐条同款**,
# 理由见那边注释(接续棒/跟进棒冒充主会话=自指死结)。
# 🔴 2026-10-02 两类改造:排除元组收到 `("worker",)` —— 同 `collabd.py` 那行。
if not (_same_ws(str(r[1] or ""), ws_root) and _role_of_title(t) not in ("worker",)
and not _is_retired_role_title(t)):
continue
sid = str(r[0] or "")
out["titles"][sid] = t # 🔴 2026-10-05:顺手带回标题(供默认类别兜底判"属不属于别类")
out["cand"].append(sid)
ts = t.strip()
_ex = ts.startswith("主控") or ts.startswith("[主控]")
if _ex:
out["named"].append(sid)
_tp = _topic_in_title(t, tps)
if _tp:
_cur = out["by_topic"].get(_tp)
# 同一类别多条候选 ⇒ ① 显式「主控」优先 ② 否则取**最近活动**(首见即最近)
if _cur is None or (_ex and not _cur.get("explicit")):
out["by_topic"][_tp] = {
"sid": sid, "source": ("prefix:主控" if _ex else "topic"), "explicit": _ex}
except Exception:
pass
return out
def _resolve_main(st: dict, topics=None, ws_root=None) -> dict:
"""🔴 主会话**解析**(⛔ 不只是读登记)—— **与 `collabd.py::resolve_main()` 必须同款**。
2026-09-30 用户实测报障:「**还是没有把这个会话识别为主会话**」。
根因:board.py 当时只走**旧的静态判据** `_main_sid()`(① 登记为 main ② **退回"上次投给谁"**),
而投递侧 `collabd.py` 早已改成 `resolve_main()` 的**动态解析** ⇒ **两边不同源**:
主会话一换(或旧主会话停了),投递会跟随、**看板却继续指着旧会话**,
并且因为 `in_project()` 的①号判据就是 `main_sid` ⇒ **正在用的那个会话连列都进不来**。
⇒ 本函数把「按**工作区**认」补上,判据与 collabd 逐条对齐:
① 本工作区里**显式标了 `主控`** 的 ⇒ 认(用户自己标的,比"最近活动"可信)
② 否则本工作区里**标题不带 `[协作]`** 且**最近活动**的那条
③ 都取不到 ⇒ 退回登记 `_main_sid()`;再没有 ⇒ `""`(⛔ 不猜)
⚠️ 用户口径(2026-09-30):「以**一个工作区**为主会话的工作区」「下面**应该都可能是主会话**」。
⛔ 这与 §2.3「不回落 cwd 推断**项目归属**」不冲突 —— 那条管的是"会话属于哪个需求项目",
本函数管的是"这条工作区里**哪条是主会话**",是**另一件事**。
🔴🔴 2026-10-05 **加 `ws_root` 形参**(peer 格修复):⛔ 不传 ⇒ 走本看板 `WS`(**本区逐字不变**);
✅ 传了 ⇒ 主会话候选换成**那个工作区**的会话(否则 peer 格永远是 0 条主会话,见 `_scan_ws_mains()`)。
⚠️ **注意 `reg`(登记值)的取向**:peer 格传入的 `st` 来自**对方的**
`collabd-state.json`(`_peer_state()`)⇒ 那份登记值本就是对方的 ⇒ 两边一致,⛔ 不会串台。
"""
reg = _main_sid(st)
sc = _scan_ws_mains(st, topics, ws_root)
named, cand = sc["named"], sc["cand"]
pick = named[0] if named else (cand[0] if cand else "")
# 🔴🔴 2026-10-05 修 **「主会话在,看板却显示无主会话」**(用户报障逐字:
# 「**现在goal创建成功了 还是没有显示主会话**」)。
#
# 真因=**同一个事实两套实现**(`collabd.py` 有、`board.py` 没有):
# `by_topic` 只在「**该会话标题里含类别名**」时才被填(`_topic_in_title()`)。
# 而**建目标的那条会话**(=天然的主会话)标题往往是**口语式、不带类别** ——
# 实测本工作区:主会话标题「排查任务不执行的原因」,`topics=[会话协作自检, 机制排查与修复]`
# ⇒ `by_topic = {}` ⇒ 每个类别 chip 都画成「⚠️ 无主会话」,
# 而**同一个快照里 `main_sid8` 明明就是 `a80f300d`** ⇒ 自相矛盾。
#
# 投递侧早就定过这条规则(`collabd.py::resolve_mains()` docstring ②,用户 2026-09-30):
# > 某类别没扫到 且 它就是**默认类别**(`topics[0]`)⇒ 回落 `default`
# > (这样单类别时的行为与旧版完全一致,向后兼容)
# > 不是默认类别 ⇒ **sid 留空**(⛔ 绝不回落到别类别 —— 那正是"投错窗口")
# ⇒ 看板侧**漏了同一段兜底** ⇒ 两边对同一份数据给出两套答案。本函数补齐。
# 🔴 判据:**只兜底默认类别 `topics[0]`,且只在"该主会话确实不属于别的类别"时兜**
# —— ⛔ 不许把主会话安到它没标的**非默认**类别上(那会把看板变成"每个类别都有主会话"的假象)。
_bt = dict(sc["by_topic"])
_tps = list(_as_list(topics) if topics is not None else _goal_topics())
if pick and _tps:
_dft_tp = _tps[0]
if not str((_bt.get(_dft_tp) or {}).get("sid") or ""):
# 该主会话标题若**明确标了别的类别** ⇒ 不兜(避免张冠李戴)
_own_tp = _topic_in_title(str(sc.get("titles", {}).get(pick) or ""), _tps)
if _own_tp in ("", _dft_tp):
_bt[_dft_tp] = {"sid": pick, "source": ("prefix:主控" if named else "workspace"),
"explicit": bool(named)}
# 🔴 `all_sids` = **解析出来的那些**(默认 + 登记 + 各任务类别),⛔ **不是**"本工作区所有会话" ——
# 只有解析出来的才算主会话,否则"本工作区随便开个无关会话"会被当成本项目(判据被无声放宽)。
_all: list = []
for _s in ([pick, reg] + [str((_v or {}).get("sid") or "") for _v in _bt.values()]):
if _s and _s not in _all:
_all.append(_s)
out: dict = {"all_sids": _all, "by_topic": _bt}
if pick:
out.update({"sid": pick,
"source": ("prefix:主控" if named else "workspace"),
"switched_from": (reg if (reg and reg != pick) else "")})
return out
out.update({"sid": reg, "source": ("register" if reg else "no-register"), "switched_from": ""})
return out
def project_scope(g: dict | None = None, ws_root=None) -> dict:
"""🔴 **本需求目标项目的身份 + 归属判据**(用户 2026-09-30 明示两件事:
① 「**要明确哪些会话是属于某个需求目标项目的**」
② 「**这个看板上应该明确显示是哪个需求目标项目**」)。
🔴 **判据与 `collabd.py::_in_project()` 同款** ⇒ 看板列的"在跑会话" ≡ `--ready-next` 认的"在跑会话"
(⛔ 否则两边各说各话:看板显示没人跑、收尾确认却被拦)。
⛔ **刻意不用 cwd 推断** —— 架构 §2.3 明令「绝不回落到 cwd 推断」。
🔴 2026-10-01 多目标:可选传入 `g`(某个目标)⇒ **按这个目标的类别算归属**(tab 用)。
⛔ 不传 ⇒ 回落活跃目标 `goal.json` ⇒ **单目标部署行为逐字不变**。
🔴🔴 2026-10-05 加 `ws_root`(peer 格修复):
· `ws_root` 传空 ⇒ `st` 取**本区**收件箱、主会话按**本区** `WS` 解析(**本区逐字不变**);
· `ws_root` 传了 ⇒ `st` 取**对方**的 `collabd-state.json`、主会话按**对方**根解析
(⛔ 否则 peer 格永远「无主会话」,而那条主会话明明活着 —— 用户报障逐字:
「**peer 工作区的主会话还是找不到对应的吗**」)。
⚠️ **`workspace` 键仍必须回本区 `WS`**:它被 `in_project()` 当"本看板自己的归属依据"用,
改它会连带把**会话归属**判错(那是另一条判据,⛔ 不许在本函数里顺手改)。
"""
g = _goal() if g is None else g
if ws_root:
# peer 格:状态取对方的(⛔ 不是本区 —— 否则登记值串台)
st = _peer_state(str(ws_root)) or {}
else:
st = _j(INBOX / "collabd-state.json", {}) or {}
# 🔴 2026-09-30:主会话**解析**(⛔ 不再是"读登记/上次投给谁"那种静态值)——
# 与 `collabd.py::resolve_main()` 同源,投递侧跟随谁、看板就显示谁。
_rm = _resolve_main(st, _goal_topics(g), ws_root)
msid = str(_rm.get("sid") or "")
msids = list(_rm.get("all_sids") or []) # 🔴 多任务类别 ⇒ 本工作区可能有多条主会话
short = str(g.get("short") or "")
topics = _goal_topics(g) # 🔴 任务类别清单(缺省回落 [short])
gid = str(g.get("id") or "")
crit = [] # 给人读的判据清单(看板上原样展示)
if msid:
crit.append("主会话 %s(%s)" % (msid[:8], _rm.get("source") or "?"))
if len(msids) > 1:
crit.append("本工作区另有主会话 %d 条(共 %d)" % (len(msids) - 1, len(msids)))
for _t in topics:
crit.append("标题含 [%s]" % _t)
if gid:
crit.append("显式声明 --declare --goal %s" % gid)
return {"goal_id": gid, "short": short, "topics": topics, "title": str(g.get("title") or ""),
"topics_source": _goal_topics_source(g), # 🔴 类别来源(declared/fallback/none)
"main_sid": msid, "main_sids": msids, "workspace": str(WS), "criteria": crit,
# 🆕 每个**任务类别**各自的主会话(`{类别: {sid,source,explicit}}`)——
# 看板"按类别"渲染要用它(用户:「通过协作会话名称前缀区分具体执行会话」)
"main_by_topic": dict(_rm.get("by_topic") or {})}
def in_project(sid: str, title: str, sc: dict) -> bool:
"""🔴 **一个会话是否属于本项目**。⛔ **与 `collabd.py::_in_project()` 同款 —— 改一处必须改两处**。
🔴 2026-09-30 **泛化**:归属判据的"标题含 `[<主题>]`"从**单个** `goal.short` 扩到
**`goal.topics` 清单** —— 用户要求「**同一个工作区**多会话协作(主会话根据任务**自动梳理任务类别**:
**通过协作会话名称前缀的方式区分具体执行会话**)」⇒ 同一工作区里**多个任务类别**的会话
都必须被认成"本项目"(⛔ 否则除一个之外**静默漏管**:看板不列、收尾确认也不把它们算进来)。
⛔ `sc` 里没有 `topics` ⇒ **回落 `short`**(旧调用点/自测用例原样可用)。
⛔ `sc` 里没有 `main_sids` ⇒ 只看 `main_sid`。
🔴 2026-10-01 **判据放宽为三条并列**(详见函数体内的长注释):
① 主会话 sid 命中
② **角色可解析 + 标题出现任一类别**(子串,⛔ 不要求方括号)← 🆕 治「看板没显示协作会话」
③ 标题含 `[<任一类别>]`(老写法,向后兼容)
"""
if sid and (sid == sc.get("main_sid") or sid in (sc.get("main_sids") or [])):
return True
ti = str(title or "")
_tps = sc.get("topics") or ([sc["short"]] if sc.get("short") else [])
# 🔴🔴 2026-10-01 修(用户报「**看板没有正常显示协作会话**」)。
# 真因:原来**只认带方括号**的写法(`"[%s]" % 类别 in 标题`),而**实际派活出来的棒名是**
# `[协作]-机制排查与修复-常驻投递容器`(第 2 级**没带方括号**)
# ⇒ 判 `False` ⇒ 它落到 `sessions_unrecognized` ⇒ `_sessions()` 的 `mine` 里没有它
# ⇒ `board.html` 第三层(按 `role==='任务会话'` 挑格子)**画空框「暂无协作会话」**,
# 而那条会话**正在 working**。同族红线:**读到了却不说**(更糟:说反了)。
# ⚠️ 而且这和 `_topic_in_title()` **自相矛盾** —— 那个函数注释白纸黑字写着
# 「判据是**子串**(不要求 `[...]` 包裹)…**两种写法都要认**」,
# 于是**同一个标题,一个函数说命中、另一个说不命中**(判据打架,2026-10-01 实测)。
# ⇒ 判据统一成**两条并列**(任一命中即算):
# ② **角色可解析**(`_role_of_title()` 非空)**且** 标题里出现任一类别(`_topic_in_title()` 子串)
# —— 这是**放宽**,⛔ 不是放弃精度:加这道"角色前缀闸"就是为了⛔ 不让
# "正文里恰好提到类别名的无关会话"混进来(那种标题解析不出角色)。
# ③ 老写法带方括号 `[<类别>]` **原样保留**(向后兼容:跨工作区时代与 `[<类别>] 接续 · …`)。
# 🔴 **⛔ 与 `collabd.py::_in_project()` 必须逐条同款**(`selftest.py` 会真的把两边拉出来对账)。
if _topic_in_title(ti, _tps) and _role_of_title(ti):
return True
# 🔴🔴 2026-10-03 10:2x **检查会话无条件归本项目**(用户报障连带查出的第二个缺口)。
# 现象:`44b547d4`「结果检查-…」修完 role 后**从「主会话」列表移出了**,
# 却落进 `sessions_unrecognized` ⇒ 任务会话那一排**仍然画不出它**。
# 根因:上面判据②要求「角色可解析 **且** 标题含 `goal.topics` 里的类别」,
# 而 `topics` 现网**未声明**(`None` ⇒ 回落 `short`=「本机协作」),
# `结果检查`/`目标检查` **不在里面** ⇒ 判 False。
# 🔴 **为什么该无条件**:检查会话是**常驻程序自己建的**(`maybe_spawn_check_agent()`),
# 它**服务于本工作区的目标**,⛔ 不该因为"用户还没声明任务类别"而在看板上消失
# —— 那正是 2026-10-01「创建了协作会话但看板没展示」同族。
# ⚠️ 这是**第三条判据**(①②③ 之外),⛔ 不改①②③ ⇒ 老写法全部原样保留。
# ⚠️ 与 `collabd.py::_in_project()` **必须同款**(改一处漏另一处已吃过三次)⇒ 已在对账用例里。
if _is_check_agent(ti) and _same_ws_cwd_for_check(sc):
return True
for _t in _tps:
if _t and ("[%s]" % _t) in ti:
return True
return False
def _same_ws_cwd_for_check(sc: dict) -> bool:
"""🔴 检查会话**是否属于本工作区**(只按 `sc["workspace"]` 这一条真痕迹)。
🔴 为什么单独一个函数:`_in_project()` 的入参里**没有 cwd**(只有 `sid`/`title`/`sc`),
⛔ 而"别的线也在建检查会话"是可能的(同一台机多工作区)⇒ **不能只凭标题就认领**。
⇒ 判据=`sc["workspace"]` 存在(它由 `board.py` 从**部署配置**读出,代表"本看板自己的工作区")
+ 调用方已确认这条会话 `cwd` 命中本工作区(见 `_sessions()` 里的 `_ct == _wstail` 那道)。
"""
return bool(str(sc.get("workspace") or "").strip())
def _goal() -> dict:
return _j(INBOX / "goal.json", {}) or {}
# 🔴🔴 2026-10-01 立 · **会话退场**(治「接续棒越堆越多」)—— 看板侧。
# 病因与设计见 `collabd.py` 那段 `RETIRED` 长注释(权威在那边,本处是**第 2 处消费者**)。
# ⛔ **本文件只读不写**:写退场由 `collabd.py --retire self` 负责(一个状态只有一个写者)。
def _retired() -> dict:
"""读退场表(`<inbox>/retired.json`)。读不到/坏了 ⇒ 空表(⛔ 不让它把看板搞挂)。"""
try:
d = _j(INBOX / "retired.json", {}) or {}
return d if isinstance(d, dict) else {}
except Exception:
return {}
def _retired_hit(sid: str, table: dict) -> bool:
"""这条会话是否已退场。⚠️ 比对**前 8 位**(看板各处只存 8 位短 id)。"""
k = str(sid or "").strip()[:8].lower()
if not k:
return False
for s in table:
if str(s)[:8].lower() == k:
return True
return False
# 会话在版面上算不算"活的"(分钟):`completed` 且距上次活动超过它 ⇒ 视同退场。
# 🔴 与 `collabd.py` 用**同一个配置键** `session_live_min`(⛔ 否则两个渲染器口径又会打架)。
LIVE_MIN = float(C.get("session_live_min") or 90)
GOALS_DIR = INBOX / "goals" # 🔴 多目标:一个文件=一个目标(字段与 goal.json 同款)
def _peer_workspaces() -> list:
"""🔴 要**并列查看**的其它工作区(只读),来自部署配置 `peer_workspaces`。
🔴 2026-10-03 15:44x 用户报障:「tab 不能切换看另外两个工作区的目标」
⇒ 看板原本只认**一个** `INBOX`(=本工作区)⇒ 天生看不见别的目录。
⚠️ 配置写法:`"peer_workspaces": ["E:/ProgramData/AIProject/会话协作测试1", …]`(**正斜杠**)。
🔴 **严格只读** —— 这条边界必须守住:
· ⛔ 不写对方任何文件;⛔ 不起对方的常驻/看板;⛔ 不改对方的目标状态;
· 只把对方的 `goal.json` **读进来当作一个 tab**。
⚠️ 没有这个键/值非法 ⇒ 返回 `[]` ⇒ **行为与旧版逐字一致**(向后兼容)。
"""
try:
v = (C or {}).get("peer_workspaces") or []
except Exception:
return []
if isinstance(v, str):
v = [v]
out = []
for x in v or []:
s = str(x or "").strip().replace("\\", "/")
if not s or s == str(WS or "").replace("\\", "/"):
continue # ⛔ 本工作区不重复列(它已是 active 那格)
out.append(s)
return out
def goal_files() -> list:
"""🔴 **所有目标文件**(活跃的排第一):`goal.json` + `goals/*.json`。
用户 2026-10-01:「把协作实时看板改为 **tab 支持多个目标**执行协作状态展示」
⇒ 数据模型(我的决定,可推翻):
· `INBOX/goal.json` = **当前活跃目标** —— 唯一权威,`collabd.py` 只认它 ⇒ ⛔ 零改动;
· `INBOX/goals/<id>.json` = **其余目标**(同一套字段:`id`/`title`/`short`/`topics`/
`acceptance_state`/`run`);
· 看板把两者合成一个**有序列表** ⇒ **一个 tab 一个目标**。
⛔ 没有 `goals/` 目录 ⇒ 列表里只有 1 项 ⇒ **与旧版逐字一致**(向后兼容)。
⚠️ 去重键=`id`(缺 `id` ⇒ 用文件名)—— 同一个目标两处都有 ⇒ **以 `goal.json` 为准**。
@returns `[{"path","goal","active","key"}]`(⛔ 读不到任何目标 ⇒ `[]`)
"""
out: list = []
seen: set = set()
def _add(p: Path, active: bool, ws_name: str = "", ws_root: str = "") -> None:
g = _j(p, {}) or {}
if not g:
return
k = str(g.get("id") or "").strip() or p.stem
if ws_name:
k = "%s/%s" % (ws_name, k) # ⛔ 去重键带工作区前缀 ⇒ 同名目标不互相顶掉
if k in seen:
return
seen.add(k)
rec = {"path": str(p), "goal": g, "active": active, "key": k}
if ws_name:
# 🔴 标出"来自哪个工作区" ⇒ 前端能显示、也**不会被误当成本区活跃目标**
rec["peer"] = ws_name
rec["ws_root"] = ws_root
out.append(rec)
_add(INBOX / "goal.json", True)
try:
if GOALS_DIR.is_dir():
for p in sorted(GOALS_DIR.glob("*.json")):
_add(p, False)
except Exception:
pass
# 🔴🔴 2026-10-03 15:44x **新增:并列查看其它工作区的目标**(用户报障)。
# ⚠️ **只读**:⛔ 不写对方文件、⛔ 不起对方进程、⛔ 不改对方状态。
# ⇒ 活跃目标仍然只有本工作区的 `goal.json` ⇒ `collabd.py` 行为**零改动**。
# 🔴🔴 2026-10-04 21:0x **新增:已经在会话列表里消失的工作区 ⇒ 不再占 tab**。
# 用户报障逐字:「tab 要把已经不再 会话列表的工作区 目标移除,不然都放不下了」。
# 🔴 **判据(唯一)**:宿主库 `sessions` 表里该 `cwd` 的**未删会话数**(`deleted_at is null or 0`)
# = 0 ⇒ 这个工作区在会话列表里**已经不存在了** ⇒ ⛔ 不列 tab。
# 🔴 **为什么用"未删会话数"而不是"心跳新不新鲜"**:心跳只说明那个区的**常驻进程**在不在,
# 与"这个工作区还有没有人在用(会话列表里还有没有它)"**是两件事** ——
# 实测反例:会话协作测试2/3 的常驻挂在别的机器上、心跳早停,但其**未删会话为 0**
# (用户把会话全删了)⇒ 该工作区确实已从列表里退场;而 vibe-product 心跳 1.5 小时
# 但未删会话 5 条 ⇒ **还在用** ⇒ 必须留。⇒ 判据只能是会话数,⛔ 不是心跳。
# ⚠️ **严格只读、零删除**:只影响"要不要把这一格画出来",
# ⛔ 不动对方任何文件、⛔ 不从 `peer_workspaces` 里替用户删配置(那是用户的配置)。
# ⚠️ 读不到库 ⇒ **保留**(fail-open:宁可多一格,⛔ 不因读数失败就把别人的格子吞掉)。
for _pw in _peer_workspaces():
try:
_root = Path(_pw).resolve()
if not _root.is_dir():
continue
_g = _root / "tmp" / "supervise-inbox" / "goal.json"
if not _g.is_file():
continue
try:
if not _peer_session_rows(str(_root)):
continue # 未删会话 0 条 ⇒ 该工作区已不在会话列表里 ⇒ 跳过(不占 tab)
except Exception:
pass # ⚠️ 读数失败 ⇒ 保留(fail-open)
_add(_g, False, _root.name, str(_root))
except Exception:
continue
return out
# ── 🔴🔴 2026-10-03 16:1x **peer 格的取数**(⛔ 只用对方工作区自己的文件) ────────────
# 背景(用户报障逐字):「**选择另一个工作区目标 tab 下面没有显示对应工作区目标和执行情况**」。
# 🔴 实测真因(比"没显示"更糟 —— **在说假话**):`build()` 里的 `tasks`/`srows`/`st`
# **只取一次、所有格共用** ⇒ 切到 peer 格时,`labor`/`sessions`/`progress` 的 md5
# **三格完全相同** ⇒ 显示的是**本工作区**的执行情况,却挂着对方工作区的名字。
# ⇒ 修法:**peer 格一律只喂"那个工作区目录下的文件"**;读不到 ⇒ **空 + 如实说明**,
# ⛔ **绝不拿本工作区的数据补位**(同族红线:**读到了就要说**,⛔ 不许说假话)。
def _peer_inbox(ws_root: str) -> Path:
"""对方工作区的 `INBOX`(同构约定 ⇒ `<ws>/tmp/supervise-inbox/`)。⛔ **只读**。"""
return Path(str(ws_root or "")) / "tmp" / "supervise-inbox"
def _peer_tasks(ws_root: str) -> dict:
"""🔴 对方工作区的**台账**(只读它自己的 `tasks.json`)。读不到 ⇒ `{}`(⛔ 不拿本区的补)。"""
try:
return _j(_peer_inbox(ws_root) / "tasks.json", {}) or {}
except Exception:
return {}
def _peer_state(ws_root: str) -> dict:
"""🔴 对方工作区常驻写的 `collabd-state.json`(只读)。读不到 ⇒ `{}`(⛔ 不拿本区的补)。"""
try:
return _j(_peer_inbox(ws_root) / "collabd-state.json", {}) or {}
except Exception:
return {}
def _peer_session_rows(ws_root: str) -> list:
"""🔴 **单独补捞**对方工作区的会话(只读宿主库,⛔ 不写)。
⚠️ 为什么不能只靠 `_session_rows()`:它取的是**全库最近 50 条**(按 `last_activity_at` 倒序)
⇒ 对方工作区的会话**大概率一条都不在里面** ⇒ peer 格显示「0 条会话」=**假象**
(2026-10-03 实测:两个测试工作区的主会话明明 `working`,peer 格却 0 条)。
⛔ 也⛔ 不许为了捞它把全局 limit 放大 —— 那会让**本工作区**的 `others_running` 计数暴涨
(`_sessions()` 拿全 rows 算"别的项目在跑几条"),等于为一个新功能改坏老行为。
"""
db = _host_db()
if not db or not ws_root:
return []
out = []
try:
con = sqlite3.connect("file:%s?mode=ro" % str(db).replace("\\", "/"), uri=True, timeout=0.3)
con.row_factory = sqlite3.Row
try:
con.execute("pragma busy_timeout=300")
except Exception:
pass
# 🔴 库里 `cwd` 可能是反斜杠形态 ⇒ 用 `replace()` 归一成正斜杠再比(⛔ 别在 Python 侧比 ⇒ 要全表扫)
# 🔴 2026-10-03 17:0x **列必须与 `_session_rows()` 同款(7 列)** ——
# 少选 `status`/`custom_title`/`updated_at` ⇒ `_sessions()` 按键取值时抛
# `No item with that key` ⇒ 被兜底成「库不可读」+空列表
# ⇒ **peer 格永远 0 条**,而 `err` 里只有一句看不出来的"库不可读"(本轮实测踩到)。
out = con.execute(
"select id,title,custom_title,status,cwd,updated_at,last_activity_at "
"from sessions where (deleted_at is null or deleted_at=0) "
"and replace(cwd,'\\','/')=? "
"order by updated_at desc limit 60",
(str(ws_root).replace("\\", "/").rstrip("/"),)).fetchall()
con.close()
except Exception:
return []
return list(out or [])
def _peer_srows(srows, ws_root: str) -> list:
"""🔴 对方工作区的会话行 = **已有 srows 里筛出的** + **单独补捞的**(按 `id` 去重)。
⚠️ 用 `cwd` 判只在**这一处**合法:判的是「这条会话属于哪个**工作区**」(≡ `cwd` 的字面含义),
⛔ **不是**架构 §2.3 禁止的那条「拿 `cwd` 推断会话属于哪个**需求项目**」。
"""
b = str(ws_root or "").replace("\\", "/").rstrip("/").lower()
if not b:
return []
seen: set = set()
out: list = []
def _push(r) -> None:
try:
_id = str(r["id"] or "")
except Exception:
return
if _id and _id in seen:
return
if _id:
seen.add(_id)
out.append(r)
for r in srows or []:
try:
c = str(r["cwd"] or "") # `sqlite3.Row`(`_session_rows` 设了 row_factory)
except Exception:
continue
if c.replace("\\", "/").rstrip("/").lower() == b:
_push(r)
for r in _peer_session_rows(ws_root):
_push(r)
return out
def _peer_scope_note(ws_root: str) -> dict:
"""🔴 peer 那格的**来源说明** —— 看板必须说得出「这格的数字哪来的、多旧」。
⛔ 没有这份说明 ⇒ 读者会把"读不到"当成"对方没在跑",或把别的数字当成它的。
"""
_ib = _peer_inbox(ws_root)
_hb = Path(str(ws_root or "")) / ".workbuddy" / "collab" / "logs" / "supervise-heartbeat.json"
_age = None
try:
_age = round((time.time() - _hb.stat().st_mtime) / 60.0, 1)
except Exception:
pass
return {
"read_only": True,
"tasks_present": (_ib / "tasks.json").is_file(),
"state_present": (_ib / "collabd-state.json").is_file(),
"heartbeat_age_min": _age,
}
def _peer_runtime_min(ws_root: str, n_check: int) -> dict:
"""🔴 peer 格的**常驻程序实时状态**(只含架构图真正用到的那几个字段)。
⚠️ **为什么不给 peer 直接复用 `_runtime()`**:那个函数内部**多处直接用本区 `INBOX`**
(`_tick.stamp`/`guard.log`/`guard.stop`…)⇒ 参数化要动一串路径,风险高;
而这里**只需要"对方常驻在不在跑" + "对方有几次检查"** ⇒ 读**对方心跳**就够。
⚠️ 与 `_runtime()` 是**两份实现**(同族风险:同一条规则写两处必然漂)⇒
**判据口径必须一致**:心跳 `ts` 距今 < 90 s = 在线(1.5 分钟),文案沿用 `_label_when_down()`。
⛔ 改 `_runtime()` 的在线判据时**必须**同步改这里。
"""
hb = Path(str(ws_root or "")) / ".workbuddy" / "collab" / "logs" / "supervise-heartbeat.json"
age = pid = rnd = None
try:
d = _j(hb, {}) or {}
if d:
pid, rnd = d.get("pid"), d.get("round")
age = round((time.time() - float(d.get("ts") or 0)) / 60.0, 2)
except Exception:
pass
up = (age is not None) and (age <= 1.5) # 90 秒,与本区同口径
return {
"prog": {"up": up, "age_min": age, "heartbeat_age_min": age, "heartbeat_pid": pid,
"by": (("常驻 --supervise(round %s)" % rnd) if rnd is not None else ""),
"label": ("在线" if up else _label_when_down(age, pid, age is None))},
"check": {"n": n_check,
"label": ("无检查在跑" if not n_check else "%d 条检查在册" % n_check)},
}
def _goal_topics(g: dict | None = None) -> list:
"""🔴 **本需求下的"任务类别"清单**(`goal.json.topics`)。
2026-09-30 用户要求:「要能支持**同一个工作区** 多会话协作(主会话根据任务**自动梳理任务类别**:
**通过协作会话名称前缀的方式区分具体执行会话**)」
⇒ **任务类别 ≡ 会话标题二级前缀 `[主题]` ≡ 台账条目的 `line` ≡ 分工板的一行**(同一个词,四处同义)。
⛔ `topics` 缺省 ⇒ 回落 `[goal.short]` ⇒ **单类别时的行为与旧版逐字一致**(向后兼容)。
🔴 与 `collabd.py::_goal_topics()` **同款**(⛔ 改一处必须改两处)。
"""
g = _goal() if g is None else g
out: list = []
for _t in ((g or {}).get("topics") or []):
_t = str(_t or "").strip()
if _t and _t not in out:
out.append(_t)
if not out:
_s = str((g or {}).get("short") or "").strip()
if _s:
out.append(_s)
return out
def _goal_topics_source(g: dict | None = None) -> dict:
"""🔴 **任务类别清单是"说明来的"还是"回落来的"** —— 看板必须说得出来源。
用户 2026-10-01 订正:「**目标是 通过对话在调用 会话协作skill时说明的,不是固定的**」
⇒ `topics` 缺省回落 `short` 是**兼容**(单类别部署照旧),但**看板不许把"没声明过"
显示成"就是这么定的"** —— 同族红线:**读到了却不说,或者没有却说有**。
返回 `{"kind": "declared"|"fallback"|"none", "at": "...", "by": "...", "candidates": [...]}`
"""
g = _goal() if g is None else g
g = g or {}
raw = [str(x or "").strip() for x in (g.get("topics") or []) if str(x or "").strip()]
if raw:
kind = "declared"
elif str(g.get("short") or "").strip():
kind = "fallback"
else:
kind = "none"
_cand = ((g.get("_topics候选") or {}) or {}).get("候选") if isinstance(g.get("_topics候选"), dict) else None
return {"kind": kind,
"at": str(g.get("topics_declared_at") or ""),
"by": str(g.get("topics_declared_by") or ""),
"candidates": _as_list(_cand),
# 🔴 目标本身**是不是**"在对话里说明过"(`declared_at` 由 `goalctl declare` 写)
"goal_declared_at": str(g.get("declared_at") or ""),
"goal_declared_by": str(g.get("declared_by") or "")}
def _as_list(v) -> list:
"""把 `str | list | tuple | set` 归一成 `list[str]`(⛔ 空值 ⇒ `[]`)。"""
if v is None:
return []
if isinstance(v, str):
return [v] if v.strip() else []
try:
return [str(x) for x in v if str(x or "").strip()]
except Exception:
return [str(v)]
def _topic_in_title(title: str, topics=None) -> str:
"""标题里出现的**任务类别**(🔴 **最长优先**);⛔ 都不命中 ⇒ 空串。
⚠️ 判据是**子串**(不要求 `[...]` 包裹):主会话是口语式命名(「主控 · 唤醒机制」),
执行棒是严格 `[执行]-<类别>-<具体>` —— 两种写法都要认。
🔴 与 `collabd.py::_topic_in_title()` **同款**(⛔ 改一处必须改两处)。
"""
ti = str(title or "")
tps = _as_list(topics) if topics is not None else _goal_topics()
hit = ""
for _t in tps:
if _t and _t in ti and len(_t) > len(hit):
hit = _t
return hit
def _labor(tasks: dict, sess: list, goal: dict) -> list:
"""🔴 **分工板**(用户 2026-09-30 两条明示:
① 「协作会话**不是历史记录**,是**展示分工**的板块」
② 「每个分工板块可以**展示最近的协作任务**」)。
**分工位 = 线**(`goal.topics` ∪ `goal.lines` ∪ 台账里出现过的线)—— 线是本机制里真实存在的分工维度。
每格给四样:**最近的协作任务**/**当前承接会话**/**件汇总**/**状态色**。
🔴 **三类"归属"别混(2026-09-30 修 · 用户报「桌面线有会话在跑、看板却说无协作任务」)**:
· **会话 → 本项目**:按 §2.3 的三级判据(主会话 sid / 标题含 `[任务类别]` / `--declare`),
⛔ **绝不回落到 cwd**。这个判据在 `project_scope()` 里,本函数**收进来的 sess 已经过它筛**。
· **会话 → 哪条线**(🆕 2026-09-30 多类别):**优先按任务类别归位** ——
会话标题二级前缀 `[主题]` 命中的类别(`s["topic"]`)等于该线 ⇒ 归这条线。
🔴 这是"**同一个工作区**多会话协作"的正确判据:所有会话 cwd 相同,⛔ 按 cwd 分必然全塌成一行。
· **会话 → 哪条线**(旧/兼容):**线也可能就是工作区名**(跨工作区时代的 `goal.lines`)⇒
用 `cwd_tail ∈ lines` 判线归属。⚠️ 两条判据**并列**(`topic == 线 ∨ cwd_tail == 线`),
因为同一个部署里可能**同时**存在"按类别命名的新会话"和"按工作区命名的旧数据"。
🔴 **修掉的两种误报**(旧版只看台账里的件 ⇒ 会漏):
① 「(该线暂无协作任务)」:台账没件 **≠** 该线没活 —— 该线正在跑棒时这话是错的。
⇒ 台账无件时,**回落到"该线最近的一个会话"**当"最近的协作任务"。
② 「当前无会话在跑」:旧版只认"在跑会话标题里出现**台账件 id**" ⇒
棒在做台账里还没有的件(如 V6)时**匹配不到** ⇒ 明明在跑却说没人跑。
⇒ 承接判据改成:**topic == 线 ∨ cwd_tail == 线** ∨ 标题命中该线的台账件。
🔴 **状态色三值**:`busy`=该线有会话在跑|`gap`=**该线还有未完成的件却没人在跑**|`idle`=没活也没人跑。
"""
lines: list = []
# 🔴 2026-09-30 改:**任务类别(`goal.topics`)排在最前** —— 用户要求
# 「**同一个工作区**多会话协作(主会话根据任务**自动梳理任务类别**:**通过协作会话名称前缀
# 的方式区分具体执行会话**,所有主会话,协作会话,自动唤醒任务,都在**一个工作区**)」
# ⇒ 分工板的"一 行"从**工作区**改成**任务类别**(⛔ 同工作区多类别时,按工作区分会全塌成一行)。
# ⚠️ 旧的两路(`goal.lines` + 台账 `line`)**保留** ⇒ 跨工作区时代的既有部署照旧显示。
for _tp in _goal_topics(goal):
if _tp and _tp not in lines:
lines.append(_tp)
for _ln in (goal.get("lines") or []):
_ln = str(_ln or "")
if _ln and _ln not in lines:
lines.append(_ln)
for _v in (tasks or {}).values():
_ln = str((_v or {}).get("line") or "")
if _ln and _ln not in lines:
lines.append(_ln)
if not lines:
lines = ["(未标注线)"]
_tps = [t for t in _goal_topics(goal) if t] # 🔴 当前"任务类别"清单(`kind` 判据用它)
_running = [s for s in (sess or []) if str(s.get("status")) == "working"]
def _item_in_title(title: str) -> str:
for _tid in sorted((tasks or {}).keys(), key=len, reverse=True):
if _tid and re.search(r"(?<![0-9A-Za-z])%s(?![0-9A-Za-z])" % re.escape(str(_tid)),
str(title or "")):
return str(_tid)
return ""
_now = time.time()
out = []
for ln in lines:
items = {k: (v or {}) for k, v in (tasks or {}).items()
if str((v or {}).get("line") or "") == ln}
done = [k for k, v in items.items() if str(v.get("state")) == "done"]
n_open = len(items) - len(done)
_latest = None
for k, v in items.items():
_ts = float(v.get("t_end") or v.get("t") or 0)
if _latest is None or _ts > _latest[0]:
_latest = (_ts, k, v)
# 承接会话:① 会话就在这条线的工作区里(cwd_tail == 线) ② 或标题命中该线的台账件
holders = []
for s in _running:
_ct = str(s.get("cwd_tail") or "")
_tp = str(s.get("topic") or "")
_tid = _item_in_title(s.get("title"))
# 🔴 2026-09-30:`_tp == ln` = **按任务类别归位**(新,同工作区多类别)
# `_ct == ln` = 按工作区归位(旧,向后兼容跨工作区时代的部署)
if _tp == ln or _ct == ln or (_tid and _tid in items):
holders.append({"id8": str(s.get("id8") or ""),
"what": (_tid or str(s.get("title") or ""))[:26]})
# 最近的协作任务:台账有件 ⇒ 用最新那件;**台账无件 ⇒ 回落到该线最近的会话**
# (⛔ 别因为"台账没件"就说"暂无协作任务" —— 该线可能正在跑一件还没上报的活)
latest = None
if _latest:
latest = {"item": _latest[1], "state": str(_latest[2].get("state") or "?"),
"by": str(_latest[2].get("by") or ""),
"age_min": round((_now - _latest[0]) / 60.0, 1)}
else:
# 🔴 2026-10-01 修:⛔ **别再排除 `role == "主会话"` 的会话**。
# 原判据(`… and role != "主会话"`)本意是"最近在做的=承接会话,不含主会话",
# 但**该类只有主会话自己在跑**时会把候选整类判空 ⇒ 同一格里 ③ 说
# 「(该类还没有记录,也无可归到它的会话)」、④ 却说「承接 <同一个会话>」
# = **自相矛盾**(用户 2026-10-01 看到的正是这个画面,由此追问
# 「唤醒机制跟主会话都一个 ID,难道是主会话?」)。
# ⇒ 候选**不排除任何角色**,只把 `working` 排前 ——
# "正在跑的"就是"最近在做的",③④ 指向同一条会话时是**真相**,⛔ 不是重复。
_cand = [s for s in (sess or [])
if (str(s.get("topic") or "") == ln or str(s.get("cwd_tail") or "") == ln)]
_cand.sort(key=lambda x: (0 if str(x.get("status")) == "working" else 1,
float(x.get("age_min") or 1e9)))
if _cand:
_s0 = _cand[0]
latest = {"item": str(_s0.get("title") or "")[:28],
"state": ("working" if str(_s0.get("status")) == "working"
else str(_s0.get("status") or "?")),
"by": str(_s0.get("id8") or ""),
"age_min": float(_s0.get("age_min") or 0), "from_session": True}
out.append({
"line": ln,
"name": str((C.get("lines") or {}).get(ln) or ln),
"items": {k: str(items[k].get("state") or "?") for k in sorted(items)},
"done": len(done), "total": len(items), "open": n_open,
"latest": latest,
"running": holders,
"state": ("busy" if holders else ("gap" if n_open else "idle")),
# 🔴 2026-09-30:**这一行到底是不是"任务类别"**。
# `kind="topic"` ⇒ 命中 `goal.topics`(=当前类别,架构图第三层的正式分工位);
# `kind="legacy"` ⇒ **只在台账/`goal.lines` 里出现过,不在当前类别清单**(跨工作区
# 时代的旧线名,或已退役的类别)⇒ 它**不是**类别,⛔ 不许让工作区名冒充类别。
# ⛔ 也**不许把它静默丢掉**(同族红线:读到了却不显示 = 少说一句话)⇒
# 看板把它**折叠成一格「未归类」**画出来(见 build() 的 `orphan`)。
"kind": ("topic" if ln in _tps else "legacy"),
})
return out
def _session_rows() -> list:
"""🔴 宿主库里的**原始会话行**(只读**一次** ⇒ 多目标共用,⛔ 不 N 个目标读 N 次)。
⛔ 读不到 ⇒ **抛异常**(调用方升级成 `err`,⛔ 不静默伪装成"0 个会话" —— 那是假情报)。
"""
db = _host_db()
if not db:
return []
con = sqlite3.connect("file:%s?mode=ro" % str(db).replace("\\", "/"), uri=True, timeout=0.3)
con.row_factory = sqlite3.Row
try:
con.execute("pragma busy_timeout=300")
except Exception:
pass
# 🔴 2026-10-02 用户报障:「**刚才删除历史会话 也被当作有活动 显示在 协作会话中**」
# ⇒ **真因**:本查询漏了 `deleted_at` 过滤 —— 宿主的"删除会话"是**软删除**
# (只写 `deleted_at` 时间戳,**行还在表里**;实测库里 `deleted_at` 非 0 的有 101 条)。
# ⚠️ 同文件 `_deliverable` 那条(`:248`)**有**这个过滤,所以**只漏了这里**
# ⇒ 后果:已删会话照样被读进看板、出现在第三层「协作会话」里(用户看到的就是这个)。
# ⚠️ 判据与 `board_ext.py:396` **统一**:`(deleted_at is null or deleted_at=0)`。
rows = con.execute(
"select id,title,custom_title,status,cwd,updated_at,last_activity_at "
"from sessions where (deleted_at is null or deleted_at=0) "
"order by updated_at desc limit 60").fetchall()
con.close()
return rows
def _sessions(limit: int = 8, sc: dict | None = None, rows=None, bypass: bool = False) -> dict:
"""返回 `{"mine": [...], "others_running": n, "unrecognized": [...], "hidden": {...}, "err": ""}`
—— **只把本项目的会话列进看板**。
`mine` 里**主会话排最前**(看板第一眼要能看到"哪个是主会话")。
🔴 2026-10-01 加 `hidden`:**已退场/超窗口**的会话,形状
`{"retired": n, "stale": m, "live_min": X, "recent": [{id8,title,age_min,why}]}`。
⚠️ `recent` 只留最近 3 条明细(版面精简),**计数是全量**。
🔴 **不许拖慢宿主**(用户 2026-09-30:「看板不能影响程序执行」):
· 只读连接(`mode=ro`)⇒ WAL 下**读者不阻塞写者**,宿主的写事务该多快还是多快
· `busy_timeout=300` ⇒ 万一撞上写锁,**0.3 秒就放弃**,⛔ 不排队、⛔ 不长时间占着
· 失败 ⇒ 返回 `err` 让界面显示「宿主库暂不可读」,⛔ **不静默伪装成"0 个会话"**(那是假情报)
🔴 2026-10-01 多目标:`sc` 传入 ⇒ 按该目标算归属;`rows` 传入 ⇒ 复用已读的行(⛔ 不重复读库)。
"""
if sc is None:
sc = project_scope()
try:
if rows is None:
rows = _session_rows()
now = time.time()
# 🔴 2026-10-03 11:1x 加 `checks`(检查会话=常驻程序侧,⛔ 不进 `work`)
main, work, checks, others, unrec = [], [], [], 0, []
_ret = _retired()
_hid = {"retired": 0, "stale": 0, "live_min": LIVE_MIN, "recent": []}
_wstail = str(sc.get("workspace") or "").replace("\\", "/").rstrip("/").split("/")[-1]
for r in rows:
title = str(r["custom_title"] or "") or str(r["title"] or "")
cwd = str(r["cwd"] or "")
st = str(r["status"] or "")
sid = str(r["id"] or "")
# 🔴 2026-10-03 17:0x **`bypass`**:peer 格专用(见函数头与 `_goal_block()` 调用点)。
# 本分支恒为 False ⇒ **本工作区的判据逐字不变**(向后兼容)。
if not (bypass or in_project(sid, title, sc)):
if st == "working":
others += 1 # ⚠️ 别的线在跑 ⇒ **只计数**,⛔ 不混进列表
# 🔴 2026-10-01 加(用户报「创建了协作会话但看板没展示」):**同工作区、
# 但按命名判据认不出来**的会话必须**说出口**(同族红线:读到了却不说)。
# ⚠️⚠️ 这里**只用 cwd 决定"要不要提醒",⛔ 绝不用它决定"归不归本项目"** ——
# 归属判据仍是 §2.3 那三级(主会话 sid / 标题含 `[<类别>]` / `--declare`),
# ⛔ 不回落 cwd。这批会话**不进 `mine`、不进分工板、不算"本项目会话"**。
# 为什么必须有:棒名写成 `[协作]-<昵称>-…`(漏方括号/用 short)时,
# `parse_session_name()` 与 `in_project()` **双双认不出** ⇒ 它会**静默消失**。
_ct = cwd.replace("\\", "/").rstrip("/").split("/")[-1]
if _wstail and _ct == _wstail:
# ⚠️ 带 `age_min`:看板**只把"在跑的 / 近 3 小时的"摊在版面上**,
# 陈年历史只计一个数 —— 否则半个版面上全是早已完成的旧棒(用户反复要求"精简")。
unrec.append({"id8": sid[:8], "title": title[:60], "status": st,
"age_min": round((now - float(r["updated_at"] or 0) / 1000.0) / 60.0, 1)})
continue
rec = {
"id8": sid[:8],
"title": title[:60],
"status": st,
# 🔴 2026-09-30:多任务类别 ⇒ "主会话"是**一组**(`main_sids`),⛔ 不是"唯一那一条"
# 🔴🔴 2026-10-01 修:原来是**二分**(主会话/任务会话)⇒ 唤醒会话、第④类跟进会话
# 一律被标成「协作会话」。详见 `_role_label()` 的注释。
"role": _role_label(sid, title, sc),
# 🔴 任务类别(=标题二级前缀 `[主题]`)—— 分工板按它归位
# (用户:「通过协作会话名称前缀的方式区分具体执行会话」⇒ 同一个词,四处同义)
"topic": _topic_in_title(title, sc.get("topics") or []),
"cwd_tail": cwd.replace("\\", "/").rstrip("/").split("/")[-1],
"age_min": round((now - float(r["updated_at"] or 0) / 1000.0) / 60.0, 1),
}
# 🔴🔴 2026-10-03 11:1x **三类分拣**(用户纠正:检查会话不进协作会话那一排):
# 主会话 / 任务会话 / **检查会话**(常驻程序侧)。
# ⚠️ 改前是二分(`== 主会话` ⇒ main,否则 work)⇒ 检查会话被塞进 `work`
# ⇒ 前端 `role==="任务会话"` 那排就把它画成任务会话 ⇒ 用户看到的就是那个错。
# ⛔ 三类**都显式列**:加第四类时若走 else 会静默混进某一类。
# 🔴🔴 2026-10-03 19:2x **比较值跟着 `_ROLE_LABEL` 走** —— 原来写死 `"任务会话"` 字面量,
# 而 19:2x 把 worker 的**显示标签**改成了「协作目标」⇒ 两边对不上
# ⇒ **worker 落进 `else` 分支被当成"检查会话"** ⇒ `work` 为空、`mine` 为空
# ⇒ 自检「会话退场」四条假红(本轮实测踩到)。
# 🔴 **判据纪律**:标签是**数据**(`_ROLE_LABEL`),⛔ **不许在比较处再写一遍字面量**。
if rec["role"] == _ROLE_LABEL["main"]:
main.append(rec)
elif rec["role"] == _ROLE_LABEL["worker"]:
work.append(rec)
else:
checks.append(rec) # 检查会话(常驻程序侧)
# 🔴 2026-10-01:**退场筛**(治"接续棒越堆越多")——
# ⚠️ **两条闸门都绕开 `working`**:在跑的棒一定要摊在版面上(⛔ 被"太旧了"吃掉最糟)。
# ⚠️ 也绕开 **主会话**:它是版面上的**锚点**(第一眼要看到"哪个是主会话"),
# ⛔ 不许因为"它 4 小时没动"就把锚点抽掉 —— 那会让版面结构整块塌掉。
# ⚠️ **收起 ≠ 删除**:宿主库里原样在;这里只影响呈现,并**必须报数**(同族红线)。
_keep = []
for rec in work:
if rec["status"] != "working":
if _retired_hit(rec["id8"], _ret):
_hid["retired"] += 1
_hid["recent"].append({"id8": rec["id8"], "title": rec["title"],
"age_min": rec["age_min"], "why": "显式退场"})
continue
if rec["status"] == "completed" and float(rec["age_min"] or 0) > LIVE_MIN:
_hid["stale"] += 1
_hid["recent"].append({"id8": rec["id8"], "title": rec["title"],
"age_min": rec["age_min"], "why": "超 %d 分钟" % int(LIVE_MIN)})
continue
_keep.append(rec)
work = _keep
# ⚠️ 只留最近 3 条明细(版面精简),但**计数是全量**(⛔ 不许因为"它很旧"就少说一句)
_hid["recent"] = sorted(_hid["recent"], key=lambda x: float(x.get("age_min") or 0))[:3]
# 🔴🔴 2026-10-01:**四类各保底一条**再截断 —— 看板上**每一类都有它自己的位置**
# (主会话独占一行 / 唤醒会话+跟进会话单独一行 / 任务会话那一排),
# ⇒ 哪一类被 `limit` 截掉,版面上就会出现**假话**:
# · 跟进会话被截掉 ⇒ 那一格渲染成「**(未登记跟进会话)**」,而它其实正在跑;
# · 唤醒会话被截掉 ⇒ `front.triggers` 照样画,但 `sessions` 里没有它(两处读数打架)。
# ⚠️ **不能让"最近 8 条"这种排序决定版面上有没有某一类** —— 那是"读到了却不说"的同族。
_all = main + work
_pin, _seen = [], set()
for _rec in _all:
if _rec["role"] not in _seen:
_seen.add(_rec["role"]); _pin.append(_rec)
for _rec in _all:
if _rec not in _pin:
_pin.append(_rec)
mine = _pin[:max(limit, len(_pin))]
# 🔴 2026-10-03 11:1x `checks` 一并回传(前端要画进常驻程序框)
return {"mine": mine, "checks": checks, "others_running": others,
"unrecognized": unrec, "hidden": _hid, "err": ""}
except Exception as e:
return {"mine": [], "others_running": 0, "unrecognized": [], "hidden": {},
"err": "WorkBuddy 库暂不可读:%s" % e}
# ══════════════════════════════════════════════════════════════════════════════
# 🔴 职责边界(用户 2026-09-30 定则):
# 「**技能就是技能 程序就是程序,谁用产生的文件 放在他自己那里**」
# +「**禁止用抽象词,用 系统-模块-功能名**」
#
# ⇒ **本文件(技能侧)⛔ 不含任何项目的路径、端口、真名、历史**。
# 凡是"某个项目要看哪些组件、它们叫什么真名、端到端怎么算通过"这类**项目知识**,
# 一律由**使用方自己的扩展文件**提供,本文件只负责**把它读进来并渲染**:
#
# 配置项 `board_ext`(工作区相对路径,默认 `.workbuddy/collab/board_ext.py`)
# ⇒ 那个文件暴露 `build(ws: str) -> dict`(契约见其文件头)
#
# ⛔ 没配 / 读不到 / 抛异常 ⇒ **降级**(这一块不显示,看板照常)+ 记进 `warn`,
# ⛔ 绝不让使用方的脚本把看板搞挂。
# ══════════════════════════════════════════════════════════════════════════════
EXT_CACHE: dict = {} # ⚠️ 缓存 **(mtime, size, 模块)**;⛔ 绝不缓存 `build()` 的**结果**(见下)
def _ext(ws: Path, warn: list) -> dict:
"""🔴 读**使用方自己的**看板扩展(⛔ 技能侧不含任何项目知识)。
契约:`build(ws) -> {"title","tag","chips":[{label,up,ok,bad,tip}],"paragraphs":[{text,tone}],"tip",
"triggers":[{name,detail,detail2,label,up,edge,tip}]}`。
⚠️ `triggers` 为**可选**:表示**外部触发源(时间驱动)** ⇒ 画进架构图 R5 行右半。
(用户 2026-09-30:「把心跳的节点也放到看板协作架构图中」。)空或缺 ⇒ 退回"只有钩子子进程"的老版面。
任何异常都吞掉并降级 —— 看板**不能因为使用方的脚本坏了就打不开**。
🔴🔴 **⛔ 不许缓存 `build()` 的结果**(2026-09-30 我自己踩的坑):
第一版把**结果 dict** 缓存了 ⇒ 扩展里的**探针只跑了一次** ⇒ 前置状态**永久冻结在服务启动那一刻**,
而且**不报错**(表现为"看板说该端口未监听,而 netstat 明明确有 LISTENING")。
⇒ 只缓存**模块对象**(省一次 import),**每次快照都重新调用 `build(ws)`**。
(⛔ 本文件的注释与 docstring 里也**不写任何项目的端口/路径** —— 有静态用例守着。)
"""
rel = str(C.get("board_ext") or ".workbuddy/collab/board_ext.py")
p = (ws / rel)
out: dict = {"title": "前置", "tag": "", "chips": [], "paragraphs": [], "tip": ""}
if not p.is_file():
out["_missing"] = "未配看板扩展(board_ext):%s" % p
return out
try:
key = str(p)
try:
sig = (os.path.getmtime(p), os.path.getsize(p)) # 🔴 文件签名
except OSError:
sig = (0.0, -1)
hit = EXT_CACHE.get(key)
if hit is None or hit[0] != sig:
# 🔴 2026-09-30 改:**按文件签名重载**。旧实现只按路径缓存模块 ⇒ 改了 `board_ext.py`
# 必须**重启服务**才生效,而"没生效"在界面上表现为"我改了但看板没变化"
# (用户当天连问两次)⇒ 改成 mtime+size 变了就重载,⛔ 不再需要重启。
import importlib.util
spec = importlib.util.spec_from_file_location("_board_ext_%d" % abs(hash(key)), str(p))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
EXT_CACHE[key] = (sig, mod)
mod = EXT_CACHE[key][1]
got = mod.build(str(ws)) # 🔴 每次快照都重跑探针(⛔ 结果不缓存)
if isinstance(got, dict):
out.update(got)
except Exception as e:
out["_err"] = str(e)
warn.append("看板扩展加载失败(已降级):%s" % e)
return out
def _queue(tasks: dict, st: dict, cur_fp: str = "") -> dict:
"""🔴 **队列计数**(用户 2026-09-30:「协作程序 也要显示**当前待验收队列数量**」)。
🔴 **队列 = 需求台账 `tasks.json`**(`architecture.md` §迭代记录明载),**四态**:
`pending 待执行` / `running 执行中` / `done 已完成` / `blocked 有阻碍`。
⚠️ **机制里没有「待验收」这个态**(⛔ 不臆造一个数字出来)⇒ 这里如实给**四态明细 + 未完结数**:
`open`(未完结)= 待执行 + 执行中 + 有阻碍 + 其它;`done` = 已完成。
⚠️ 另附 `notify_n`(= `queue_info.n`,**待主会话反馈**的通知条数)—— 它与台账是两码事,⛔ 别混。
## 🔴🔴 2026-10-06 改:**按当前目标过滤**(用户逐字:
「看板中 常驻程序 改为 目标检查,里面的队列 **应该是随着目标的不是目标累积的**」)
**改前**:这一格报的是 `tasks.json` **全量累计**(`total=len(tasks)`)⇒ 换过目标之后
它把**历史目标的活**也一起数进来 ⇒ 读者**分不清"当前目标在跑多少"**。
(实测本区台账里躺着跨时代的旧条目 —— 代码注释里也早写过这个现象。)
**改后**(⛔ **不动数据**,纯显示层过滤;台账**仍然全留**,历史可回溯):
· 传进来的 `cur_fp` = 当前目标指纹(`collabd.goal_fp()`,= `sha1(title)[:6]`)。
· 只把 `goal_fp == cur_fp` 的条目算进 `total`/`by`/`open`。
· 🔴 **无归属的存量条目**(早期写的、没 `goal_fp` 字段)⇒ 算进 `legacy`,**⛔ 不混进当前目标**:
把它们算进来=**伪造归属**(旧条目本属旧目标),会让"当前目标未完数"虚高 ⇒ 又回到改前的毛病。
· ⚠️ `cur_fp` 为空(未声明目标)⇒ **如实把全部当 `legacy`**,⛔ 不假装"队列为空"
(那会把"没有目标"说成"没有活" —— 同族:降级不静默那条红线)。
"""
by = {"pending": 0, "running": 0, "done": 0, "blocked": 0, "other": 0}
legacy = {"pending": 0, "running": 0, "done": 0, "blocked": 0, "other": 0}
n_legacy = 0
for v in (tasks or {}).values():
s = str((v or {}).get("state") or "").strip().lower()
k = s if s in by else "other"
_fp = str((v or {}).get("goal_fp") or "").strip()
# ⚠️ `cur_fp` 空 ⇒ 全部按 legacy 计(⛔ 不许因为"没有当前目标"就把它们当成当前目标的)
if cur_fp and _fp == cur_fp:
by[k] += 1
else:
legacy[k] += 1
n_legacy += 1
qi = st.get("queue_info") or {}
try:
_nn = int(qi.get("n") or 0)
except Exception:
_nn = 0
_cur_total = sum(by.values())
return {"total": _cur_total, "by": by,
"open": by["pending"] + by["running"] + by["blocked"] + by["other"],
"notify_n": _nn,
# 🔴 新增:给渲染层"源数据有多宽"用 —— 看板那格据此决定要不要提"另有历史 N 件"
"all_total": len(tasks or {}),
"legacy": n_legacy,
"legacy_by": legacy,
"fp": cur_fp}
# ⛔ 「监督守护为什么停」是**某个项目的历史事实** ⇒ 归**使用方**(写在其 `board_ext.py` 里,
# 经 `ext["guard_stop_reason"]` 取回);技能侧⛔ 不落任何项目的止损史。
def _age_min(p: Path):
"""文件 mtime 距今多少分钟。取不到 ⇒ None(⛔ 不拿 0 冒充"刚更新")。"""
try:
return round((time.time() - p.stat().st_mtime) / 60.0, 1)
except Exception:
return None
def _label_when_down(prog_age, hb_pid, degraded: bool) -> str:
"""🔴 **常驻不在线时的文案**(四态,⛔ 每态都说清"依据是什么")。
🔴 2026-10-03 11:35x 用户报障后确立(原文:报「已 849.6 分钟没轮」而常驻明明活着):
那条文案把**两件不同的事**混成一句 ⇒ 读者无法判断该做什么。
四态:
· **已停** = pid 不在了(心跳停 ⇒ 进程真死)⇒ ⛔ 别写成"没轮"(那是假绿)。
· **没轮** = pid 还在但心跳旧(活着却卡住)⇒ 这是"该叫醒它"的场景。
· **判据降级**= 心跳**读不到**(⛔ 不是"停")⇒ 只报读数,⛔ **不许提进程**(提了就是编)。
· **状态未知**= 什么都读不到。
"""
if degraded:
if prog_age is None:
return "心跳读不到 · 状态未知"
return "心跳读不到 · 判据已降级(旧戳 %d 分钟前)" % round(prog_age)
if prog_age is None:
return "心跳读不到 · 状态未知"
if hb_pid and not _pid_alive(hb_pid):
return "已停 · 进程不在(心跳停在 %d 秒前)" % round(prog_age * 60)
return "在线 · 已 %d 秒没轮(进程还在)" % round(prog_age * 60)
def _pid_alive(pid) -> bool:
"""🔴 pid 是否活着 —— **直接复用 `collabd.py` 的实现**(⛔ 不重复写第二份)。
🔴 2026-10-03 11:39~11:40x 两次踩坑后的决定(**两份同名实现=活例**):
① 本文件第一版**没声明 `argtypes`/`restype`** ⇒ 64 位 `HANDLE` 被 ctypes 截断
⇒ 实测心跳正在更新、却 `OpenProcess` 返回 0 且 `GetLastError=87`
⇒ **活进程被判死**(方向①假读数)。
② 第二版补了签名,但对 `ACCESS_DENIED` 返回"不在"
⇒ **常驻活着却被说成「已停」**(方向②假读数)。
⇒ `collabd.py::_pid_alive` 是踩过坑的成熟版:`WinDLL` + 显式类型 +
**`ACCESS_DENIED` ⇒ 保守判「在」** + 判不出来 ⇒ 保守判「在」。
⚠️ 复用方式=`importlib` 按路径加载同目录 `collabd.py`(与 `_is_check_agent` 同款);
⛔ 不写进 `sys.path`(污染宿主能 import 的名字空间)。
⚠️ 加载失败 ⇒ 回落成「保守判在」—— ⛔ 宁可不说"已停"(那会误报故障),
也**不许**回落成"判不在"(那会误报停机)。
"""
try:
return bool(_cb_shared._pid_alive(pid))
except Exception:
return True # ⛔ 判不出来 ⇒ 保守判「在」(宁可漏报,⛔ 不误报停机)
def _supervise_heartbeat() -> tuple:
"""🔴 读**常驻心跳**(`logs/supervise-heartbeat.json`)⇒ 返回 `(龄_分钟, pid, round)`。
🔴 **这是"常驻程序在不在跑"的唯一真源**(用户 2026-10-03 11:35x 报障后确立):
旧判据读 `_tick.stamp`/`collabd-once.stamp` —— 那是**已退役投递机制**的遗留戳,
实测停在 2026-10-02 21:24 ⇒ 报"849.6 分钟没轮",而常驻明明 10 s/轮。
⚠️ **只判"心跳新鲜"还不够**:pid 可能已死而文件还在(宿主回收不删文件)⇒
**pid 活 ∧ 心跳新鲜**才是完整判据(与 `collabd.py::supervise_alive()` 同款)。
⚠️ 读不到 ⇒ 返回 `(None, None, None)` ⇒ ⛔ **调用方不许直接判"没在跑"**(那是"读不到")。
"""
import json as _j
hb = None
for cand in (INBOX.parent.parent / ".workbuddy" / "collab" / "logs" / "supervise-heartbeat.json",
WS / ".workbuddy" / "collab" / "logs" / "supervise-heartbeat.json"):
try:
if cand.exists():
hb = cand
break
except Exception:
continue
if hb is None:
return (None, None, None)
try:
d = _j.loads(hb.read_text(encoding="utf-8"))
age = max(0.0, (time.time() - float(d.get("ts") or 0)) / 60.0)
return (age, d.get("pid"), d.get("round"))
except Exception:
return (None, None, None)
def _runtime() -> dict:
"""🔴 **常驻程序 / 投递的实时状态**(用户 2026-09-30 要求架构图「**要能展示实时状态**」)。
取的全是**真痕迹**(状态戳 mtime),⛔ 不猜、⛔ 不按"配置里写着要常驻"就当它活着:
· **常驻程序**:`_tick.stamp`(宿主钩子唤起的一次性投递轮)/`collabd-once.stamp`(投影轮)取更新时间
· **投递**(旧名「监督程序」):**不是一个该常驻的进程** —— 它就是**宿主钩子唤起的一次性
`--tick`**。所以这里报的是「**这条链通不通**」(看 `--tick` 戳的新鲜度),⛔ 不是"启动没启动"。
⚠️ 教训仍在:图上必须能一眼看出**谁其实没在跑** —— 但"没在跑"得先说清**它本来该不该跑**。
"""
# 🔴🔴 2026-10-03 11:35x **判据改读常驻心跳**(用户报障:「已 849.6 分钟没轮」,
# 而常驻明明 10 秒一轮)。真因:下面这两个戳是**已退役投递机制**的遗留,
# 实测都停在 **2026-10-02 21:24**(无人再写)⇒ 报"849 分钟没轮"=**假读数**。
# ⇒ 主判据换成**心跳**(⛔ 与 `collabd.py::supervise_alive()` 同一口径)。
# ⚠️ 旧戳**保留但降级**为附注(`legacy_*`),⛔ **不再参与判定** —— 它们仍记录着
# "最后一条旧机制痕迹在哪",将来要查历史时用得上。
tick = _age_min(INBOX / "_tick.stamp")
proj = _age_min(INBOX / "collabd-once.stamp")
glog = _age_min(INBOX / "guard.log")
stopped = (INBOX / "guard.stop").exists()
# ① 主判据=常驻心跳(pid 活 ∧ ts 新鲜)
hb_age, hb_pid, hb_round = _supervise_heartbeat()
HEARTBEAT_FRESH = 90.0 # 秒。⛔ 与 `collabd.py::SRSM_FRESH` 同量级(那边是 900s,
# 但那是给"长工具调用"留的;⛔ 看板这里要"看起来在线",
# 90s 与常驻 10s/轮匹配 ⇒ 够灵敏又不误报)
if hb_age is not None:
prog_up = hb_age <= (HEARTBEAT_FRESH / 60.0)
prog_age = hb_age
prog_by = "常驻 --supervise(round %s)" % (hb_round if hb_round is not None else "?")
else:
# ② 心跳读不到 ⇒ **回落**旧戳,⛔ **不直接判"没跑"**(那会把"读不到"说成"没在跑")。
# 🔴 2026-10-03 11:35x 变异 M3 抓到:回落时**不能沿用 pid 相关的文案**
# (实测报出「已 51210 秒没轮(进程还在)」—— pid 根本没读到,那句"进程还在"是**假的**)。
# ⇒ 回落分支用**独立**的 `_hb_degraded` 标记,让 label 说"判据已降级"⛔ 不说进程。
_a = [a for a in (tick, proj) if a is not None]
prog_up = bool(_a) and min(_a) < 15.0
prog_age = min(_a) if _a else None
prog_by = ("旧机制戳(⚠️ 常驻心跳读不到,判据已降级)" if prog_up else "")
_hb_degraded = (hb_age is None)
_hb_note = "" if not _hb_degraded else "(⚠️ 常驻心跳读不到,本行判据已降级为旧机制戳)"
guard_up = (not stopped) and (glog is not None and glog < 5.0)
return {
# 🔴 2026-09-30 用户:「**协作程序 应该一直运行,改成 在线**」⇒ 正常态就报两个字「在线」,
# ⛔ 不再堆"钩子 --tick · N 分钟前"那种细节长串(细节进 `how`,给界面做 tooltip 用)。
# ⚠️ **但不放假绿**:真没轮动就说清"在线 · 已 N 分钟没轮"——机制还在,只是没被唤起;
# ⛔ 也不写「已停」——它不是一个"该常驻、可被停"的进程,那个词本身就会误导。
"prog": {"up": prog_up, "age_min": prog_age, "by": prog_by,
# 🔴 心跳真源读数(前端 tooltip 可显示;⛔ 旧戳另存 `legacy_*`)
"heartbeat_age_min": hb_age, "heartbeat_pid": hb_pid,
"legacy_tick_age_min": tick, "legacy_proj_age_min": proj,
# ⚠️ 文案三态(2026-10-03 改):「在线」/「已 N 秒没轮」/「心跳读不到」
# ⛔ 原来只有两态,且判据指错文件 ⇒ 报"849.6 分钟没轮"(假读数)。
# ⚠️🔴 2026-10-03 11:35x **文案必须区分「死了」与「没被唤起」**:
# 实测踩到⇒常驻真死时,旧文案报「已 890 秒没轮」⇒ **把「进程死了」
# 说成「没被唤起」=假绿**(读者会以为机制正常只是闲着)。
# ⇒ pid 不在 ⇒ 明说「已停」;pid 在但心跳旧 ⇒ 才是「没轮」。
# ⛔ 仍不写「已停」给**读不到心跳**的情形(那是读不到,⛔ 不是停)。
"label": ("在线" if prog_up else
(_label_when_down(prog_age, hb_pid, _hb_degraded))),
"how": ("常驻程序**常驻一直运行**(collabd.py --supervise,10 s/轮);"
"判据=心跳文件 `logs/supervise-heartbeat.json` 的 ts"
"(pid 活 ∧ 心跳 <%d s),⛔ 与 `supervise_alive()` 同一口径。"
% int(HEARTBEAT_FRESH)
+ ((";最近一轮=%s" % prog_by) if prog_by else "")
+ _hb_note
+ (";⚠️ 旧投递机制戳(_tick/--once)已**退役、不再写**,"
"仅作历史痕迹保留(%s/%s 分钟前)。"
% (tick if tick is not None else "无",
proj if proj is not None else "无")))},
# 🔴🔴 **「投递」这个词取代了旧的「监督程序」**(2026-09-30 改口径 · 用户连问四次
# "投递为什么打不开 / 它一直停着能起什么作用 / 还需要保留吗 / 跟它有关系吗"):
# · 「监督程序」是**投递这条职责的旧名**;
# · 真正该报的是「**投递这条链通不通**」⇒ 判据用 `--tick` 戳的新鲜度(投递轮就是它)。
# 🔴 2026-10-01 晚订正:常驻定案**回归**(用户「一直运行(常驻)」+「定时任务方案已废弃」)
# ⇒ 投递**应当一直运行** ⇒「长时间未触发」**是一个真读数**(常驻该起没起);
# ⛔ 不再是 09-30 那句「没有东西该在跑、别报已停」。`stopped`(`guard.stop`)随之恢复意义。
# 🔴 **必须原样保留的**:投递方唯一性(只有 `--tick` 能推进队列)—— 22:53 事故的修法。
"deliver": {"up": (tick is not None and tick < 15), "stopped": stopped, "age_min": tick,
# ⚠️ 这行窄(`PW-36` 还要给 12px 字号)⇒ 文案要短:机制那句在下面一行已经有了,
# 这里只报**新鲜度**,⛔ 别重复"常驻/钩子"(会溢出成"…前…"被截断)。
"label": (("就绪 · %s 分钟前跑过" % tick)
if (tick is not None and tick < 15) else "长时间未触发(投递没在跑)"),
"reason": ""}, # ⛔ 不再给"停因"——那个问题已经不存在了
# 🔴🔴 2026-10-03 10:1x **新增 `check`:检查会话在不在跑**(用户报障逐字:
# 「协作程序这个时候应该显示 **有检查程序在运行**」)。
# ⚠️ 之前常驻程序那格只有**静态职责文案** + `prog.label`(在线/停),
# **完全看不到"它刚建的检查会话跑到哪一步了"** ⇒ 图上看着像"什么都没发生"。
# 🔴 判据=**与会话明细同一个口径**(`status='working'` ⇒ 看板那格会亮):
# **⛔ 不另造判据**(同"统一一个标准"那条口径)⇒ 看板亮 ⇔ 这里说"在运行"。
# ⚠️ 类别从**标题**解析(`[协作]-[结果检查]-…`/`[协作]-[目标检查]-…`)
# ⇒ ⚠️ **依赖检查会话按两级前缀命名**(2026-10-03 10:1x 才把旧名 `结果检查-…`
# 改成 `[协作]-[结果检查]-…`)⇒ 旧名会话 `topic` 为空 ⇒ 只报"检查中"不报种类。
"check": _check_runtime(),
}
def _check_runtime() -> dict:
"""🔴 **检查会话在不在跑**(常驻程序那格显示「有检查程序在运行」用)。
🔴 **判据=`sessions.status='working'` + 标题带 `[结果检查]`/`[目标检查]`** ——
**与会话明细/看板亮灭同一个口径**(`board.html` 的 `mOn=!!(main&&main.status==='working')`),
⛔ 不另造第二套"在执行"判据(那正是 2026-10-03 上午被统一掉的那件事)。
⚠️ 读库失败 ⇒ 返回 `{"n": 0, "unknown": True}` ⇒ 前端显示「状态读不到」,
⛔ **不假装"没有检查在跑"**(那会把机制故障说成正常)。
"""
out = {"n": 0, "kinds": [], "unknown": False, "label": "无检查在跑"}
try:
db = _host_db()
if not db:
out["unknown"] = True
out["label"] = "检查状态读不到"
return out
con = sqlite3.connect("file:%s?mode=ro" % str(db).replace("\\", "/"), uri=True, timeout=0.3)
try:
con.execute("pragma busy_timeout=300")
except Exception:
pass
# 🔴 `deleted_at` 过滤**必须带**(与 `_sessions()` 同款):宿主「删除会话」是**软删除**
# (行还在表里)⇒ 漏了它 ⇒ 已删的检查会话照样被算成"还在跑"(同 2026-10-02 那个坑)。
# 🔴🔴 2026-10-03 10:1x **两种标题形态都认**(向后兼容,⛔ 不是妥协):
# · 新名(10:1x 起的合规名)`[协作]-[结果检查]-…` / `[协作]-[目标检查]-…`;
# · 旧名(10:1x 之前建的)`结果检查-…` / `目标检查-…`。
# ⚠️ 为什么必须都认:改命名只对**之后新建**的会话生效 ⇒ 若只认新名,
# 那些**改之前就在跑**的检查会话在看板上会**凭空消失**(读数假"无检查在跑")
# ⇒ 又是"把机制故障/在跑说成没有"的同族假绿。
# ⛔ 但**不因此放松命名规范**:新名仍是唯一规范(`collabd.py` 侧已改),
# 这里的双形态只是**读存量**的兼容层。
rows = con.execute(
"select title from sessions where status='working' "
"and (deleted_at is null or deleted_at=0) "
"and (title like '%[结果检查]%' or title like '%[目标检查]%' "
" or title like '结果检查%' or title like '目标检查%')").fetchall()
con.close()
except Exception:
out["unknown"] = True
out["label"] = "检查状态读不到"
return out
kinds = []
for (t,) in rows:
s = str(t or "")
kinds.append("目标检查" if "[目标检查]" in s else "结果检查")
out["n"] = len(kinds)
out["kinds"] = kinds
if out["n"]:
uniq = sorted(set(kinds))
out["label"] = "有检查程序在运行" if len(uniq) > 1 else "有%s在运行" % uniq[0]
return out
# ⛔ 上一版这里还有 `_parse_relay_line` / `_overlay_node` / `_end_to_end_verified` 三个函数 ——
# 它们解析的是**某个项目的中继客户端日志**(glob 路径、字段名、`streams>0` 判据)。
# 按「技能就是技能,谁用产生的文件放在他自己那里」,**已整体搬到使用方的
# `.workbuddy/collab/board_ext.py`**;技能侧只保留「读扩展并渲染」这一层(见 `_ext()`)。
def _orphan(lb: list) -> dict:
"""把 `labor` 里 `kind != "topic"` 的行**汇总成一份可见的"未归类"**(⛔ 不许静默丢)。
🔴 判据同族红线:**"读到了却没显示" = 少说一句话** —— 它不崩溃,所以最难发现。
场景:任务类别清单已迁移(唤醒机制/…),台账里仍有**跨工作区时代的旧线名** ⇒ 旧版把它们
也当"分工位"塞进 `labor`,而架构图只画前 N 格 ⇒ 那几格(连同里面的件)**整块消失**。
⇒ 现在:它们**不是类别**(`kind="legacy"`,⛔ 不占正式分工位),但看板必须**明确说出来**。
"""
_lg = [x for x in (lb or []) if str(x.get("kind")) != "topic"]
if not _lg:
return {"lines": [], "n": 0, "total": 0, "done": 0, "open": 0}
return {"lines": [{"line": str(x.get("line") or ""),
"name": str(x.get("name") or x.get("line") or ""),
"total": int(x.get("total") or 0),
"done": int(x.get("done") or 0),
"open": int(x.get("open") or 0)} for x in _lg],
"n": len(_lg),
"total": sum(int(x.get("total") or 0) for x in _lg),
"done": sum(int(x.get("done") or 0) for x in _lg),
"open": sum(int(x.get("open") or 0) for x in _lg)}
# 🔴🔴🔴 **判定词白名单 —— 全库唯一事实源**(2026-10-04 立,用户报「看板还是 0 通过」)。
#
# **为什么要立它**:这条规则**已经连修 3 次、每次只补一个词**,第 4 次又栽在同一个形状上:
# · 10-02 只认英文 `pass` ⇒ 真源写中文「过」⇒ 全判非 pass(补「过」)
# · 10-03 只取最后一段 ⇒ 「复核:过」被切掉(改逐段找)
# · 10-04 🔴 **真源写的是「达」**(`达(实测 7 个…)`)⇒ 三处判据**一个都不认**
# ⇒ vibe-product 15 条判据显示 **0 通过**(含 14 条已达标)——用户原话「为什么还是0通过」。
# ⛔ 每次修完不立清单 ⇒ 下次换个词**必然再栽**。⇒ 白名单**只留这一处**,三处判据都读它。
#
# ⚠️ **判定词只许在这里增**(改完必须同时改 `assets/board.html` 的 `ACC_PASS_WORDS`)。
# 🔴 **fail-closed 不变**:`不过`/`未过`/`待重验`/`未达` **都不以这些词开头** ⇒ 自然判非过。
ACC_PASS_WORDS = ("pass", "过", "通过", "达", "达标", "合格", "完成")
# 🔴🔴🔴 **判定词前缀「副词」白名单**(2026-10-05 立 —— **第 5 次复发**,这次改成治形状、⛔ 不再补词)。
#
# **病根**:判定词前会带一个**表已完成/确认的副词**,而 `startswith` 要求**从第一个字符**就命中
# ⇒ 带了副词就**永远不命中** ⇒ 明明写着"已过"却判"非过":
# · 2026-10-05 实测(vibe-product 页签):9 条判据**全部写着 `已过|…`**,看板显示 **0 / 9 通过**
# —— 因为 `已过` 的第一个字是 `已`、⛔ 不是 `过`。用户原话:「完成情况 0 / 9 通过,但是
# 我看到都显示已过呀」。
# ⛔ 前四次(10-02「过」/10-03「逐段找」/10-04「达」)都在**补词**,每次换个措辞必然再栽
# —— 这一次**换个方向**:**先把副词剥掉再比**,⛔ 不往词表里堆"已过/已通过/已完成"这种组合。
#
# ✅ 剥离规则:判定词**只在前缀是下列副词时**才剥(⛔ 无脑 lstrip 会把 `未过` 剥成 `过` ⇒ **假绿**)。
# ⚠️ 只收**表"完成/确认"**的副词;⛔ **绝不许收否定副词**(未/不/没/非/待)——
# 它们一律**留给下面 fail-closed**(不剥 ⇒ 开头不是判定词 ⇒ 判非过)。
ACC_ADV_PREFIX = ("已", "已经", "均", "都", "经复核", "复核后")
# 🔴 与 `ACC_PASS_WORDS` 同理:`assets/board.html` 的 `ACC_ADV_PREFIX` 必须**逐字同款**。
def acc_is_pass(v) -> bool:
"""🔴 一条验收判据「算不算过」—— 🔴 **全库唯一实现**。
🔴🔴 **2026-10-04 立白名单**(见上文 `ACC_PASS_WORDS`):本函数**不再各自写死词表**,
三处判据(`board.py` 本函数 / `collabd.py::_acc_is_pass` / `board.html::accIsPass`)
**必须同款** —— 漂了就会出现"看板说通过、常驻程序说不通过"(10-04 实测已发生:
值 `过:1440 与 390 两视口` ⇒ `board.py` 判过、`collabd.py` 判不过)。
🔴🔴 **2026-10-05 第 5 次复发 ⇒ 改治形状**:判定词前常带**表完成的副词**(`已过`/`均已通过`/
`已完成`),而 `startswith` 要求**从第一个字符**就命中 ⇒ **带副词就永远不命中**。
实测(vibe-product 页签):9 条真值全写 `已过|…`,看板却显示 **0 / 9 通过**。
✅ **正解=先剥"完成副词"再比**(`ACC_ADV_PREFIX`),⛔ 不再往词表堆 `已过/已通过` 这种组合
—— 前四次(10-02 `过`/10-03 逐段/10-04 `达`)都在补词,换个措辞必然再栽。
⚠️ 剥副词**只对白名单里的副词**;`未/不/没/非/待` **绝不在白名单** ⇒ `未过`仍判非过(fail-closed 不破)。
沿革(⛔ 保留,因为"理由也会过时"):
2026-10-02 实测修的**假红**:原判据是 `str(v).lower() != "pass"`,而**台账的真实写法是中文**
—— `goal.json::acceptance_state` 的值形如 `过(PID 3552 …)`/`待重验(…)`
⇒ **6 条已过被判成「非 pass」** ⇒ 看板显示 **7 条全非 pass**,与真源**相反**。
⛔ 同族=**判据写死期望值**(`pitfalls.md` P0-13 / P0-20):看着有判据,实际**永远不命中真源**。
2026-10-02 残留假红(同族第 2 处):`**复核:过(形态已治本…)**` 判定词**不在开头**。
2026-10-03 18:5x 改「逐段找」:值里**有好几个冒号**(说明 + **时间里的**)⇒ 取最后一段切错。
"""
import re as _re
s = str(v or "").strip()
if not s:
return False # 空 ⇒ 判不出来 ⇒ ⛔ 不算过(fail-closed)
head = _re.split(r"[((]", s, maxsplit=1)[0] # 丢掉括注说明
# 🔴 **逐段找判定词**(⛔ 不再"只取最后一段")—— 见上沿革 10-03。
for _seg in _re.split(r"[::]", head):
_t = _seg.strip().lstrip("*").strip().lower()
for _w in ACC_PASS_WORDS:
if _t.startswith(_w):
return True
# 🔴🔴 2026-10-05 第 5 次复发:**剥掉"完成副词"再比一次**(治形状,⛔ 不补词)。
# ⚠️ 顺序:**先原样比、再剥副词比** —— 剥了更好命中,且⛔ 不影响原判定。
# ⚠️ **可叠加**:实测真源写 `均已通过`/`都已达标` ⇒ 一个副词不够,要能连剥
# (`已`+`均`/`都`)。⇒ **循环剥到剥不动为止**,每剥一轮都试一次判定词。
# ⚠️ 只剥 `ACC_ADV_PREFIX` 里的(已/已经/均/都/…);`未/不/没/非/待` **不在**里面
# ⇒ `未过`/`不过`/`待重验` **剥不动** ⇒ 仍判非过(fail-closed 不破)。
# ⚠️ 上限 3 轮:防"怪值死循环"(正常最多两级:`均已`)—— ⛔ 不是省事,是**有界**。
_t2 = _t
for _ in range(3):
_cut = ""
for _a in ACC_ADV_PREFIX:
if _t2.startswith(_a) and len(_a) > len(_cut):
_cut = _a # 取**最长**匹配(`已经` 优先于 `已`)
if not _cut:
break
_t2 = _t2[len(_cut):].strip().lstrip("*").strip()
for _w in ACC_PASS_WORDS:
if _t2.startswith(_w):
return True
return False # 一段都没命中 ⇒ 判不出来 ⇒ ⛔ 不算过
# 🔴 **兼容别名**(2026-10-04):本函数原名 `_acc_is_pass`,改名成公开名 `acc_is_pass`
# 是为了让 `collabd.py` 能 import 复用(`_` 前缀跨模块 import 不便,且语义上它确实是
# 公开判据)。此处保留旧名别名 ⇒ 本文件内历史调用点(`_acc_summary` 等)与外部
# 任何仍写 `board._acc_is_pass` 的地方都不会 NameError。⛔ 别名不许各写实现,只指同一对象。
_acc_is_pass = acc_is_pass
def _acc_summary(acc: dict) -> str:
"""🔴 验收状态一行话。⛔ 与 goalctl / collabd 同一条红线:
**一条有效判据都没有 ⇒ 判不出来**,⛔ 不许静默显示成「无」(那读起来像"全过")。"""
if not acc:
return "⚠️ 未声明验收判据(只有说明行)⇒ **判不出来**,⛔ 不因此判完成"
bad = [k for k, v in acc.items() if not _acc_is_pass(v)]
if not bad:
return "全部 pass(%d 条)" % len(acc)
n_pass = len(acc) - len(bad)
return "非 pass(%d/%d):%s" % (len(bad), len(acc), bad) if n_pass else "非 pass:%s" % bad
def _goal_block(g: dict, active: bool, tasks_all: dict, srows, st: dict, warn: list,
multi: bool = False, all_topics=None,
# 🔴 2026-10-03 15:45x 跨工作区 tab:`peer`=来源工作区名,`ws_root`=其绝对路径。
# ⚠️ 这两个形参**必须存在**:调用点已按位置传了 10 个实参,
# 少了它们 ⇒ `TypeError` ⇒ 快照生不出 ⇒ 看板一直 `warming`
# (本轮实测就是这样:脚本报了 OK 但这一处没写进文件)。
peer: str = "", ws_root: str = "") -> dict:
"""🔴 **一个目标的完整快照块** —— 看板的一个 tab 就是它(用户 2026-10-01)。
🔴 **台账怎么分到各目标**:**按任务类别**(类别清单 ≡ 台账条目的 `line`,同一个词,四处同义)。
· **只有一个目标** ⇒ **原样全给**(⛔ 不做任何切分 ⇒ 与旧版逐字一致);
· **多个目标** ⇒ 按 `line ∈ 本目标 topics` 切;`line` 不属**任何**目标的那些件
(=跨工作区时代留下的旧线名)**归到"活跃目标"名下并原样显示** ——
⛔ 不静默丢弃(同族红线:**读到了就要说**,⛔ 别让它们整批不见)。
"""
tps = _goal_topics(g)
tpset = set(tps)
tasks = dict(tasks_all)
if multi:
_at = set(all_topics or [])
tasks = {k: v for k, v in tasks_all.items()
if str((v or {}).get("line") or "") in tpset}
if active:
for k, v in tasks_all.items():
if str((v or {}).get("line") or "") not in _at:
tasks.setdefault(k, v)
sc = project_scope(g, ws_root) if (peer and ws_root) else project_scope(g)
if peer and ws_root:
# 🔴 peer 格:把「哪个工作区」对准**对方** —— 否则 `_sessions()` 里那道
# `_ct == _wstail`(`cwd` 末段 == 工作区名,`:971`)会把对方的会话**全部排除**
# ⇒ 界面显示「0 条会话」= **假象**(2026-10-03 实测:对方主会话明明 `working`)。
# ⚠️ 只覆盖 `workspace` 这一个键:其余判据(主会话登记/任务类别)**仍按对方 goal.json 算**。
sc = dict(sc or {})
sc["workspace"] = ws_root
try:
# 🔴🔴 2026-10-03 17:0x **peer 格走 `bypass=True`** ——
# 对方会话的「项目归属」依据(主会话登记 + 任务类别)存在**对方**的收件箱里,
# 而 `in_project()` 读的是**本工作区** ⇒ 用它 ⇒ 对方会话全被判「不属于本项目」
# ⇒ 格子永远 0 条(2026-10-03 实测:两个新区各 2 条会话,主会话与任务会话都看不见)。
# ⚠️ 角色判据仍是**同一套**(`_role_of_title()` = 标题两级前缀,两侧共用)⇒ 两边说法一致。
_ss = _sessions(sc=sc, rows=srows, bypass=bool(peer and ws_root))
except Exception as e: # ⛔ 兜底:绝不让一个目标把整份快照搞挂
_ss = {"mine": [], "others_running": 0, "err": "会话读取异常:%s" % e}
if _ss.get("err"):
_nm = str(g.get("short") or g.get("id") or "目标")
warn.append("%s:%s" % (_nm, _ss["err"]))
_lb = _labor(tasks, _ss["mine"], g) # 🔴 只算一次:分工板与"未归类"汇总都用它
qi = st.get("queue_info") or {}
acc = {k: v for k, v in (g.get("acceptance_state") or {}).items()
if not str(k).startswith("_")}
# 🔴🔴 2026-10-06 **本块(=本目标)的指纹** —— 队列按目标过滤用。
# 用户逐字:「队列 应该是随着目标的不是目标累积的」。
# ⚠️ **必须从形参 `g` 算**(⛔ 不许读 `INBOX/goal.json`):
# peer 格渲染的是**对方工作区**的目标,读本区文件 ⇒ 用本区指纹过滤 ⇒
# 对方格的队列**恒为 0**(跨工作区静默错)。
# ⚠️ 口径与 `collabd.goal_fp()` / `goal_dir_name()` **逐字同一套**(`sha1(title)[:6]`)——
# 只能有一处口径;这里是**渲染侧**的重算,⛔ 不是第二套标准。
_fp_title = str((g or {}).get("title") or "").strip()
cur_fp = hashlib.sha1(_fp_title.encode("utf-8")).hexdigest()[:6] if _fp_title else ""
return {
"key": str(g.get("id") or g.get("short") or ""),
# 🔴🔴 2026-10-03 15:45x **透传 `peer`** —— ⛔ 上游 `goal_files()` 加了、下游这里不带
# ⇒ 前端永远看不到 ⇒ 三个工作区目标标题相同 ⇒ **tab 等于白加**(读者分不清)。
# ⚠️ 这是"改一处漏一处"的第 N 次复发 ⇒ 加字段必须**顺着数据流查到消费点**。
"peer": peer,
"ws_root": ws_root,
# 🔴 2026-10-03 20:31x **每格都带真实工作区名**(用户:「都用真实名称」+「本工作区没用的东西不要显示」)。
# ⛔ 原来前端对**本区**兜底成"本工作区"三个字 ⇒ 那不是名称、是占位词。
# ⇒ 本区取**本工作区目录名**、对方区取快照给的 `peer`,两者都是**真实目录名**。
"ws_name": (peer or Path(str(WS or "")).name or ""),
"active": bool(active),
# 🔴🔴 2026-10-03 18:5x **把「目标生命周期」透出到块里** ——
# 用户报障:「主会话说目标都已完成,⛔ 为什么看板上还是没完成」。
# 真因:`collabd.py --set-life 已完成` 写的是 **`lifecycle`** 字段,
# 而看板原先**只读 `acceptance_state`/`run`** ⇒ ⛔ 目标早就标完成了,看板却永远看不到。
# ⚠️ 这是"加字段必须**顺着数据流查到消费点**"的又一次(本包第 N 次):只加字段没人渲染 = 白加。
"life": str(g.get("lifecycle") or ""),
"life_at": str(g.get("lifecycle_at") or ""),
"life_by": str(g.get("lifecycle_by") or ""),
"run": str(g.get("run") or "active"),
"paused": str(g.get("run") or "active").strip().lower() != "active",
"goal": {"title": g.get("title") or "", "short": g.get("short") or "", "acceptance": acc},
# 🔴🔴 2026-10-06 **目标声明时间**(tab 排序用,用户「按创建时间顺序」)——
# ⚠️ **必须在这里单独带出来**:上面 `goal` 只留 3 个字段(title/short/acceptance),
# `declared_at` 会被**丢掉** ⇒ 排序处若从 `_b["goal"]` 里取 ⇒ **恒为空**
# (实测栽过:三块 `tab_created` 全是 `'~'`,排序形同没做,而判据"升序"照样绿)。
# ⚠️ 从**形参 `g`** 取(peer 块传的是**对方**的 goal → 取到对方时间,正确);
# ⛔ 不许回读 `INBOX/goal.json`(那样 peer 块会拿到**本区**时间 ⇒ 排序串区)。
"goal_declared_at": str(g.get("declared_at") or ""),
"acc_summary": _acc_summary(acc),
# 🔴 项目身份:看板顶部「本项目」区用它 ⇒ 一眼看清"这个看板是哪个需求目标项目的"
# 🆕 2026-09-30:`topics` / `main_by_topic` —— 用户要求「**同一个工作区**多会话协作
# (主会话根据任务**自动梳理任务类别**:**通过协作会话名称前缀**区分具体执行会话)」⇒
# 看板必须把"**这个工作区有哪几个任务类别、每类的主会话是谁**"直接摆出来
# (⛔ 否则用户只看到一堆会话,看不出"哪条管哪一类")。
"project": {"id": sc["goal_id"], "short": sc["short"], "title": sc["title"],
"main_sid8": (sc["main_sid"] or "")[:8], "criteria": sc["criteria"],
"workspace": sc["workspace"],
"topics": list(sc.get("topics") or []),
# 🔴 2026-10-01:类别清单的**来源**(用户:「目标是**通过对话在调用 会话协作技能时说明的**,
# 不是固定的」)⇒ 看板要能回答"这批类别是**在对话里说明的**,还是**回落**来的"。
"topics_source": dict(sc.get("topics_source") or {}),
"main_sids8": [str(s)[:8] for s in (sc.get("main_sids") or [])],
"main_by_topic": {str(_t): (str(_v.get("sid") or "")[:8] or "")
for _t, _v in ((sc.get("main_by_topic") or {}).items())}},
"others_running": _ss["others_running"],
# 🔴 2026-10-01:**同工作区、但按命名判据没归入本项目**的会话(用户报「创建了协作会话
# 但看板没展示」)—— 它们**不是**本项目会话(⛔ 不进 `sessions`、⛔ 不进分工板),
# 但**必须看得见**(同族红线:读到了却不说)⇒ 交给 `assets/board.html` 在图外说明里点名。
# 典型成因:棒名写成 `[协作]-<昵称>-…`(漏方括号/用 `goal.short` 而非 `topics`)。
# 🔴🔴 2026-10-03 11:1x **新增 **(用户纠正逐字):
# 「检查会话不是协作会话,不应该出现在看板协作会话区域中;
# 它属于协作程序的会话,**可以放在协作程序框图中展示**」
# ⇒ 检查会话**从 里单列**(⛔ 不混进任务会话那一排),
# 前端在**常驻程序那格(R4)里**画它们。
# ⚠️ 仍**保留**在 里(⛔ 不删)—— 会话明细表要看得见有这条会话。
"sessions_checks": _ss.get("checks") or [],
"sessions_unrecognized": _ss.get("unrecognized") or [],
# 🔴 2026-10-01:**已退场/超窗口**的会话(治「接续棒越堆越多」)——
# 它们**不进 `sessions`、不进分工板、不进第三层**,但**必须报数**
# (同族红线:读到了却不说 ⇒ 读者以为那些棒凭空消失了)。
# ⚠️ **退场 ≠ 删除**:宿主库里原样在,⛔ 本文件也不写任何东西。
"sessions_retired": _ss.get("hidden") or {},
"tasks": {k: {"state": str((v or {}).get("state") or "?"),
"line": str((v or {}).get("line") or ""),
"by": str((v or {}).get("by") or ""),
"artifact": str((v or {}).get("artifact") or "")} for k, v in tasks.items()},
"sessions": _ss["mine"],
# 🔴 分工板(架构图「协作会话」层用它渲染)—— ⛔ 不是历史会话列表
"labor": _lb,
# 🔴🔴 2026-09-30 加:**"未归类"汇总** —— 台账里那些**不在当前任务类别清单**的线。
# 为什么必须有:类别清单已换成任务类别(唤醒机制/…),而台账里还躺着**跨工作区时代
# 的旧线名**(`ai1net-dsh-anywhere` / `ai1net-dsh-desktop`,各自 2 件且都已完成)。
# 旧版把这类行也塞进 `labor` 当"分工位",架构图又只画前 4 格 ⇒ **4 件已完成的活整格不见**,
# 读者只看到 4 个"件 0 · 完成 0"。⇒ 两处修正:① 它们**不是类别**(`kind='legacy'`)
# ② 但**必须看得见**(图上折叠成一格 + 表里标注)。
"orphan": _orphan(_lb),
# 🔴🔴 2026-10-06 队列**按当前目标过滤**(用户:「队列 应该是随着目标的不是目标累积的」)。
# ⚠️ 指纹的**计算在本字典之外**(函数体上方 `cur_fp`,紧邻 `return {`)——
# ⛔ 别把赋值语句插进字典字面量里(本轮踩过:`SyntaxError: ':' expected after
# dictionary key`,因为它被当成了 `"key": value` 的一部分)。
"queue": _queue(tasks, st, cur_fp), # 🔴 队列计数(检查程序节点用)
"progress": {"last_progress_at": st.get("last_progress_at"),
"goals_open": bool(qi.get("probe", {}).get("goal_open")),
"main_busy": bool(qi.get("probe", {}).get("main_busy")),
"prog_age_min": qi.get("probe", {}).get("prog_age_min")},
}
def build() -> dict:
"""🔴 **生成一份只读快照**。保证:**永不抛异常**(任何一处读不到 ⇒ 记进 `warn` 并降级),
⇒ 调用方永远拿得到一份可渲染的数据,⛔ 不会因为看板读不到某个文件而连累别人。
🔴 2026-10-01 多目标(用户:「把协作实时看板改为 **tab 支持多个目标**执行协作状态展示」):
· 多出一个 `goals` 数组 —— **一格一个目标**(活跃的排最前,其余按文件名);
· 顶层的 `goal`/`project`/`tasks`/`sessions`/`labor`/`orphan`/`queue`/`progress`
=**活跃目标那一份** ⇒ ⛔ 老渲染器与老断言**逐字照旧可用**(向后兼容);
· `front`/`runtime`/`notify`/`wakeups`/`meta` **不随目标变**(它们是工作区级的)。
"""
warn = []
_gfs = goal_files()
if not _gfs: # ⛔ 连 goal.json 都没有 ⇒ 也要给出一份可渲染的
_gfs = [{"path": str(INBOX / "goal.json"), "goal": {}, "active": True, "key": ""}]
tasks = _j(INBOX / "tasks.json", {}) or {}
st = _j(INBOX / "collabd-state.json", {}) or {}
if not _gfs[0]["goal"]:
warn.append("读不到 goal.json")
if not st:
warn.append("读不到 collabd-state.json")
try:
srows = _session_rows() # 🔴 只读一次库 ⇒ 所有目标共用
# 🔴🔴 2026-10-03 17:0x **只留 `cwd` 命中本工作区的行** ——
# `_session_rows()` 取的是**全库**最近 N 条(按活动时间倒序)⇒ 别的工作区只要
# 任务类别撞上就会混进来(实测:本区格子里出现过 `cwd_tail=会话协作测试2` 的任务会话
# ⇒ 那是**别区数据混在本区名下**,与 P0-40 同族的假数据)。
# ⚠️ 这里用 `cwd` 判的是「这条会话属于哪个**工作区**」(≡ `cwd` 的字面含义),
# ⛔ **不是**架构 §2.3 禁止的那条「拿 cwd 推断会话属于哪个**需求项目**」。
_wtail = str(WS).replace("\\", "/").rstrip("/").split("/")[-1].lower()
_own_rows = []
for _r in srows or []:
try:
_ct = str(_r["cwd"] or "").replace("\\", "/").rstrip("/").split("/")[-1].lower()
except Exception:
continue
if _ct == _wtail:
_own_rows.append(_r)
srows = _own_rows
except Exception as e:
srows = []
warn.append("WorkBuddy 库暂不可读:%s" % e)
# 🔴 2026-10-03 16:1x:本工作区的**切分基准只按本工作区自己的目标**算 ——
# ⛔ 把 peer 目标的 topics 混进 `_all_tps` ⇒ 本区台账里 `line` 命中"对方类别"的那些件
# **两边都不进**(不在本区 tpset、又因为命中 _at 而拿不到"归活跃目标"的兜底)
# ⇒ **静默丢失**(同族红线:读到了就要说,⛔ 不许让它整批不见)。
_own_gfs = [it for it in _gfs if not it.get("peer")]
_multi = len(_own_gfs) > 1
_all_tps = [t for it in _own_gfs for t in _goal_topics(it["goal"])]
blocks = []
for it in _gfs:
_p = it.get("peer") or ""
_wr = it.get("ws_root") or ""
if _p:
# 🔴🔴 peer 格:**只喂那个工作区自己的数据**(⛔ 喂本区的 = 把本区执行情况标到别人名下)
_b = _goal_block(it["goal"], False, _peer_tasks(_wr), _peer_srows(srows, _wr),
_peer_state(_wr), warn, False, _goal_topics(it["goal"]), _p, _wr)
_b["peer_scope"] = _peer_scope_note(_wr)
# 🔴 2026-10-03 20:22x **peer 格补自己的协作程序状态**(用户报障「不同目标的协作程序框内容一样」):
# 框里那两行(常驻程序在线/检查状态)读的是 `runtime` ⇒ ⛔ 块里没有这个键时
# 前端视图会**沿用顶层**(=本工作区那份)⇒ 三个 tab 显示一模一样。
_b["runtime"] = _peer_runtime_min(_wr, len(_b.get("sessions_checks") or []))
blocks.append(_b)
else:
blocks.append(_goal_block(it["goal"], it["active"], tasks, srows, st, warn,
_multi, _all_tps))
# 🔴🔴 2026-10-06 用户定案:「**tab 的排列顺序应该按创建时间顺序**」——
# ⛔ 推翻 2026-10-04 的「最新在运行的排前面」(按活跃度排)。
# 🔴 **为什么要改(活跃度排序的真实毛病)**:`age_min` 读的是**最近一次会话活动**,
# 而会话活动**每次协作都会变** ⇒ tab **顺序会自己漂**:
# 用户看到的是「昨天 A 在前、今天 B 跑到前面」——而**用户脑子里认的是"第几个 tab 是什么"**,
# 位置一变就要**重新找** ⇒ 越用越别扭(且用户**无从判断**为什么顺序变了)。
# ✅ **创建时间的语义**:目标**一旦建立就不动**(`declared_at` 由 `goalctl declare` 写、
# 换目标才会变)⇒ tab 顺序**稳定**,用户能记住"第 1 个是我的老目标"。
# ⚠️ **键取 `declared_at`(目标声明时间)**,⛔ 不取 `topics_declared_at`:
# 两者多数相同,但 `declared_at` 是**目标本身**的建立时间(语义正),
# `topics_declared_at` 是**任务类别清单**的确认时间(可能后补、也可能缺失)。
# ⚠️ **缺 `declared_at` 的块**(老目标/未声明)⇒ 排在**最后**(`"~"` 大于任何 ISO 时间串),
# ⛔ **不许猜**一个时间塞进去(同族红线:读不到就说读不到)。
# ⚠️ **同时间戳的块**保持原相对次序(Python sort 稳定 + key 里带原序号兜底)。
for _i, _b in enumerate(blocks):
_b["tab_created"] = str(_b.get("goal_declared_at") or "") or "~"
_b["tab_i"] = _i # ⛔ 稳定排序的兜底键
blocks.sort(key=lambda b: (b.get("tab_created", "~"), b.get("tab_i", 0)))
for _i, _b in enumerate(blocks):
_b["tab_i"] = _i # 排序后重编,⛔ 别让前端拿旧序号当"第几个 tab"
_b["tab_more"] = max(0, len(blocks) - 6) # 🔴 超出 6 个的部分 ⇒ 前端收进下拉
_ext_d = _ext(WS, warn) # 🔴 使用方自己的扩展(⛔ 技能侧不含项目知识)
_rt = _runtime() # ⚠️ 「投递」不再需要"停因"(那个问题已随改名消失)⇒ 不回填 reason
_ext_d.pop("guard_stop_reason", None) # 使用方若还留着这个键 ⇒ 不渲染(避免又把旧问题带回来)
qi = st.get("queue_info") or {}
out = dict(blocks[0]) # ⇒ 顶层=**活跃目标那一份**(向后兼容)
out.update({
"ts": time.strftime("%Y-%m-%d %H:%M:%S"),
"epoch": round(time.time(), 1),
"goals": blocks, # 🔴 tab 的数据源(一格一个目标)
"wakeups": [{"ts": w.get("ts"), "kind": w.get("kind") or "-", "http": w.get("http"), "ok": w.get("ok")}
for w in _tail_jsonl(INBOX / "wakeups.jsonl", 10)],
# 🔴 「前置」整块由**使用方**提供(见 `board_ext`)—— 技能侧只负责把它渲染出来。
# ⛔ 技能里不许出现任何项目的端口/路径/真名/历史。
"front": _ext_d,
"runtime": _rt, # 🔴 实时状态(架构图用)
"notify": {"awaiting": qi.get("awaiting") or "", "phase": qi.get("phase") or "",
"sent": qi.get("sent") or "", "notice": qi.get("notice") or ""},
"warn": warn, # ⛔ 降级不静默:界面要显示"哪一块没读到"
"meta": {"supervise_interval": C.get("supervise_interval", 30),
"queue_idle_min": 5, "wake_min_gap": C.get("wake_min_gap", 300),
"workspace": str(WS), "inbox": str(INBOX),
# 🔴🔴 2026-10-03 **投递退役标记**(用户口径「没用了就删除」):
# 唤醒会话/跟进会话/队列上报整套退役 ⇒ 队列变化不再自动通知任何人。
# ⛔ 这是**机读判据**:前端靠它把「队列通知」那张卡画成退役说明,而不是三个空格子
# (⛔ 别改成"卡片直接删掉"—— 那会让"机制退出了"变成无声无息)。
"deliver_retired": "2026-10-03",
# 🔴 多目标:看板据此知道"要不要画 tab 条"(1 个目标 ⇒ 不画,与旧版一致)
"goals_dir": str(GOALS_DIR), "goal_count": len(blocks)},
})
return out
def _port_owner(port: int):
"""监听 `127.0.0.1:<port>` 的 PID(字符串);查不到 ⇒ None。只认 LISTENING。
⚠️ 用 `netstat -ano` 而非 `pkill -f`(后者匹配 argv,会误伤)。返回 `None` 与"确无进程"**不区分**
—— 调用方一律按"没能定位到旧实例"处理(⛔ 不据此下"一定没有别人在跑"的结论)。
"""
try:
import subprocess
out = subprocess.run(["netstat", "-ano"], capture_output=True, text=True,
timeout=10, errors="replace",
creationflags=0x08000000).stdout or "" # CREATE_NO_WINDOW:⛔ 闪窗
except Exception:
return None
key = "127.0.0.1:%d " % int(port)
for ln in out.splitlines():
if "LISTENING" in ln and key in ln:
p = ln.split()
if p:
return p[-1]
return None
def _live_board(port: int, timeout: float = 1.2) -> bool:
"""同端口上是否已有**本看板**在跑:`/healthz` 能连上 **且** body 是我们的签名。
⛔ 判据必须收紧:① ⛔ 不能只认「端口开着」(别人也可能占这个口)
② ⛔ 不能只认 HTTP 200(2026-09-30 实测:本机有服务在 `/api/v1/health` 上返 200,
body 却是 `{"ret":1,"version":"3"}`)。✅ 签名 = 同时含 `"ok"` 与 `"snapshots"`
—— 本看板的 `/healthz` 是这两个键,别的服务不会同时给。
"""
import urllib.request
try:
with urllib.request.urlopen("http://127.0.0.1:%d/healthz" % int(port), timeout=timeout) as x:
body = x.read(400).decode("utf-8", "replace")
except Exception:
return False
return ('"ok"' in body) and ("snapshots" in body)
def _stop_pid(pid: str) -> bool:
"""按 PID 强停(只给 `--takeover` 用;⛔ 不在其它路径上调用)。返回是否成功。"""
try:
import subprocess
r = subprocess.run(["taskkill", "/F", "/PID", str(pid)], capture_output=True,
text=True, timeout=15, errors="replace",
# 🔴 `taskkill` 是**控制台程序** ⇒ 不加 CREATE_NO_WINDOW 每次必闪黑窗
#(2026-10-02 实测补上:本文件最后一处漏网的显窗调用,
# AST 全量扫 `subprocess.run` 缺 creationflags 时它被捞出来)
creationflags=0x08000000)
return r.returncode == 0
except Exception:
return False
def _find_other_boards(port: int) -> list:
"""🔴🔴 **本机是不是已经有别的看板实例在跑**(⛔ 跨端口也认)—— 2026-10-05 加。
## 为什么必须有它(用户 2026-10-05 逐字)
> 「**看板全局也只能启动一个**」
🔴 **病根**:原来的护栏 `_live_board(port)` **只按端口判** ⇒
8788 上有实例、你再去起 20099 ⇒ **照起不误** ⇒ 两个实例各答各的快照
(实测撞到:用户书签 8788 拿到的是 09:44 的**陈旧静态快照**、目标显示为空;
而真正在跑的实例在 20099 上,数据是新的)。
⚠️ 更坏的情况:两个实例**不同代码版本** ⇒ 同一个 URL 反复刷新拿到不同结论。
✅ 正解=**全局判据**:扫进程表找**所有** `board.py --serve` 的 pythonw 实例
(⛔ 不只是本端口)⇒ 已有别的 ⇒ 说清楚"它在哪个端口"并按调用方策略处置。
⚠️ 判据取**进程命令行**(`board.py` + `--serve`)而**不是** glob/文件名
—— 与「查常驻只许问程序自己」同族:⛔ 不猜、直接读系统给的事实。
@return `[{"pid","port"}...]`(⛔ 不含 `port` 自己那条)
"""
out = []
try:
import subprocess
import json as _json
# 🔴 用 PowerShell 的 CIM(⛔ 不用 wmic —— 新版 Windows 已移除)
# + `-NoProfile`(⛔ 否则加载用户 profile 慢且可能报错)
ps = ("Get-CimInstance Win32_Process -Filter \"Name='pythonw.exe'\" | "
"Where-Object { $_.CommandLine -like '*board.py*' -and "
"$_.CommandLine -like '*--serve*' } | "
"ForEach-Object { \"$($_.ProcessId)`t$($_.CommandLine)\" }")
r = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps],
capture_output=True, text=True, timeout=15, errors="replace",
creationflags=0x08000000)
for ln in (r.stdout or "").splitlines():
ln = ln.strip()
if not ln or "\t" not in ln:
continue
_p, _cl = ln.split("\t", 1)
try:
_pid = int(_p)
except Exception:
continue
if _pid == os.getpid():
continue
_m = re.search(r"--serve\s+(\d+)", _cl)
_pt = int(_m.group(1)) if _m else 0
if _pt and _pt == int(port):
continue # 同端口那条由 `_live_board` 管,⛔ 这里不重复
out.append({"pid": _pid, "port": _pt})
except Exception:
pass
return out
def serve(port: int = 8788, interval: float = 3.0, takeover: bool = False) -> int:
"""🔴 本地看板服务(**只绑 127.0.0.1**)。⛔ 不引第三方、⛔ 不开对外端口、⛔ 不写任何账本。
🔴🔴 **总则:看板不能影响程序执行**(用户 2026-09-30 明令:「看板不能影响程序执行,可以**异步**、
可以**延迟**」)。据此,请求路径与数据生产**彻底解耦**:
· **一个后台线程**每 `interval` 秒生成一次快照 → 存进**内存缓存**
· **请求线程只吐缓存字节** —— ⛔ 不碰 DB、⛔ 不读文件、⛔ 不做任何可能阻塞的事
⇒ 开 10 个标签页 = 10 次内存读;宿主库的查询频率**恒定**为 `1/interval`,与页面数无关
· 刷新失败 ⇒ **保留上一份快照**(界面显示"延迟 N 秒")⇒ ⛔ 绝不 500、⛔ 绝不给空板
· `--interval` 可取大(实时性换零负担);页面按同一 `interval` 自取,⛔ 不自行加频
"""
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
html_p = HERE.parent / "assets" / "board.html"
cache = {"bytes": None, "at": 0.0, "err": "", "n": 0}
def _refresh_loop():
"""唯一的生产者(后台守护线程)。⛔ 它出任何事都只影响"数据新不新",⛔ 不影响服务存活。"""
while True:
try:
d = build()
try:
_hsig = "%d.%d" % (os.path.getmtime(html_p), os.path.getsize(html_p))
except OSError:
_hsig = "0"
# 🔴 `html_sig`:架构图的**布局与样式全在 board.html 里**,而页面只轮询本 JSON
# ⇒ 改了 HTML,不刷新页面就看不到(用户 2026-09-30 为此连问两次)。
# 页面拿这个签名做"变了就自动重载",⛔ 不再需要用户手动 F5。
d["board"] = {"refresh_interval": interval, "generated_at": round(time.time(), 1),
"readonly": True, "html_sig": _hsig}
cache["bytes"] = json.dumps(d, ensure_ascii=False, indent=1).encode("utf-8")
cache["at"] = time.time()
cache["err"] = ""
cache["n"] += 1
except Exception as e: # ⛔ 不清旧快照:宁可给"旧的"也不给"空的"
cache["err"] = str(e)
time.sleep(max(0.5, float(interval)))
threading.Thread(target=_refresh_loop, daemon=True).start()
class H(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def _send(self, code, body: bytes, ctype: str):
try:
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
except Exception: # 客户端提前断开 ⇒ 静默(⛔ 不刷日志)
pass
def do_GET(self): # noqa: N802
path = self.path.split("?")[0]
if path in ("/", "/board.html", "/index.html"):
try:
self._send(200, html_p.read_bytes(), "text/html; charset=utf-8")
except Exception as e:
self._send(500, ("board.html 读不到:%s" % e).encode("utf-8"),
"text/plain; charset=utf-8")
elif path == "/board.json":
body = cache["bytes"]
if body is None: # 冷启动:第一份还没出来
body = json.dumps({"warming": True,
"board": {"refresh_interval": interval}}, ensure_ascii=False).encode("utf-8")
self._send(200, body, "application/json; charset=utf-8")
elif path == "/healthz":
self._send(200, json.dumps({"ok": True, "snapshots": cache["n"],
"age": round(time.time() - cache["at"], 1),
"err": cache["err"]}, ensure_ascii=False).encode("utf-8"),
"application/json; charset=utf-8")
else:
self._send(404, b"not found", "text/plain; charset=utf-8")
def log_message(self, *a): # ⛔ 静默(不刷屏、不唤醒宿主)
pass
# 🔴🔴 单实例护栏(2026-09-30 加,治「多实例打架」)—— ⛔ 别删,原因见文件头。
# · 默认:端口上已有本看板 ⇒ **拒绝启动**(避免"随机应答不同快照/不同代码版本")
# · `--takeover`:显式接管 —— 先停掉旧实例,再起本实例
# 🔴🔴 2026-10-05 加强:**全局**判据(用户逐字:「**看板全局也只能启动一个**」)。
# 病根:原来只按**端口**判 ⇒ 8788 有实例、去起 20099 **照起** ⇒ 两实例各答各的。
# 实测后果:用户书签 8788 拿到 09:44 的**陈旧快照**(目标显示为空),
# 而真正在跑的实例在 20099(数据是新的)⇒ 用户以为"目标没了"。
_others = _find_other_boards(port)
if _others:
if takeover:
for _o in _others:
_okx = _stop_pid(str(_o["pid"]))
print("%s 接管:已停**别端口**上的看板 PID %s(端口 %s)(%s)"
% ("✓" if _okx else "⚠️", _o["pid"], _o["port"] or "?",
"成功" if _okx else "失败"))
sys.stdout.flush()
time.sleep(1.0)
else:
_o0 = _others[0]
print("⛔ 本机已有看板在跑(127.0.0.1:%s,PID %s)⇒ **本次不启动**"
% (_o0["port"] or "?", _o0["pid"]))
print(" · 看板**全局只允许一个**(用户 2026-10-05 口径)—— 多实例会各答各的快照。")
print(" · 直接用它:http://127.0.0.1:%s/ ,⛔ 不用再起一个。" % (_o0["port"] or "?",))
print(" · 要换到本端口/换新代码 ⇒ 加 `--takeover`(先停旧实例再接管)。")
sys.stdout.flush()
return 0
if takeover:
_own = _port_owner(port)
if _own and _own != str(os.getpid()):
_ok = _stop_pid(_own)
print("%s 接管:已停旧看板 PID %s(%s)" % ("✓" if _ok else "⚠️", _own,
"成功" if _ok else "失败,见 taskkill 输出"))
sys.stdout.flush()
time.sleep(1.0) # 等端口真正释放
elif _live_board(port):
_own = _port_owner(port)
print("⛔ 已有看板在跑(127.0.0.1:%d%s)⇒ **本次不启动**,避免多实例打架。" %
(int(port), (",PID %s" % _own) if _own else ""))
print(" · 只是想看板子 ⇒ 直接用 http://127.0.0.1:%d/ ,⛔ 不用再起一个。" % int(port))
print(" · 要换上新代码 ⇒ 加 `--takeover`(先停旧实例再接管),或先停掉它。")
sys.stdout.flush()
return 0
srv = ThreadingHTTPServer(("127.0.0.1", int(port)), H)
srv.daemon_threads = True
print("看板已起:http://127.0.0.1:%d/ (只绑回环 · 每 %ss 异步快照 · 请求零阻塞)" % (int(port), interval))
sys.stdout.flush()
try:
srv.serve_forever()
except KeyboardInterrupt:
pass
finally:
srv.server_close()
return 0
def main() -> int:
if "--serve" in sys.argv:
i = sys.argv.index("--serve")
p = 8788
if i + 1 < len(sys.argv) and sys.argv[i + 1].isdigit():
p = int(sys.argv[i + 1])
iv = 3.0
if "--interval" in sys.argv: # 想更省 ⇒ 调大(实时性换零负担)
j = sys.argv.index("--interval")
if j + 1 < len(sys.argv):
try:
iv = max(0.5, float(sys.argv[j + 1]))
except Exception:
iv = 3.0
return serve(p, iv, takeover=("--takeover" in sys.argv))
out = INBOX / "board.json"
if "--out" in sys.argv:
i = sys.argv.index("--out")
if i + 1 < len(sys.argv):
out = Path(sys.argv[i + 1])
INBOX.mkdir(parents=True, exist_ok=True)
d = build()
out.write_text(json.dumps(d, ensure_ascii=False, indent=1), encoding="utf-8")
print("板快照已写:%s(%d 字节)" % (out, out.stat().st_size))
print(" 目标:%s | %s" % (d["goal"]["title"][:40], _acc_summary(d["goal"]["acceptance"])))
_f = d.get("front") or {}
_ch = _f.get("chips") or []
print(" 前置(%s):%s" % (
_f.get("title") or "—",
" | ".join("%s=%s" % (c.get("label"), "上线" if c.get("up") else "离线") for c in _ch)
or (_f.get("_missing") or "(使用方未配 board_ext)")))
print(" 台账:%s" % {k: v["state"] for k, v in d["tasks"].items()})
_r = d.get("runtime") or {}
print(" 实时:常驻程序=%s | 上报=%s" % ((_r.get("prog") or {}).get("label"),
(_r.get("deliver") or {}).get("label")))
if d.get("warn"):
print(" ⚠️ 降级(不静默):%s" % ";".join(d["warn"]))
return 0
if __name__ == "__main__":
raise SystemExit(main())