Files
workbuddy_skills/session-mechanism/scripts/board.py
T
admin 64dd82073b session-mechanism: 修复钩子静默失效 + 3 处判据缺陷;禁「变相征询」
1) stop-dialog-guard: session_budget() 早退路径返回 2 值、末尾返回 3 值,调用方按 3 值解包
   ⇒ transcript > 64 MiB 时每轮 ValueError。因 fail-open(异常仍 exit 0),
   宿主零报错、install.py --verify 只判 rc=0 ⇒ 假绿;实测 86 条 EXCEPTION,
   死掉的是整条(水位/收口、接续机制起点、预算告警、门禁自检、路径自检)。

2) session-rules-check 三处判据:
   · hook_reg  按旧文件名找 ⇒ 合并成 prompt-guards.py 后每轮假红 ⇒ 改为一组可接受名
   · snap_sync 拿 mtime 当内容判据 ⇒ 连续 4 天假红 ⇒ 改为复用抽取器本体比对内容
               (变异对照:截断快照能报 fail,非恒绿)
   · mem_ptr   只查全局技能根 ⇒ 工作区自带技能被判悬空 ⇒ 改查「全局 ∪ 工作区」

3) pitfalls 新增 P0-95(改判据必须重跑变异对照;fail-open + 只看 rc=0 = 假绿温床)

4) 回复排版核心块新增「变相征询同样禁止」(先只报不动/等你发话/我倾向X你看呢
   这类不带选项的待定清单,一律按待拍板项写:问题+说明+各候选优缺点+倾向)
2026-10-06 22:27:03 +08:00

2268 lines
143 KiB
Python
Raw 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.
#!/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())