Files
dsh_ai1net_server/归档/技能包快照/session-mechanism-20261004/scripts/board.py
T
admin c1b5e4d966 chore(工作区): 全量入库 + 补齐 .gitignore(以工作区为准)
- 变更规模:新增 514 / 修改 62 / 重命名 155 / 删除 4(归档重组与文档轮次)
- .gitignore 修:`归档/**/db-cwd归一-备份-*/` —— 原规则写绝对层级(归档/db-cwd归一-…),
  目录搬进 归档/配置与备份/ 后**静默失效**,43 MB 的 DB 备份又变成未跟踪
- .gitignore 补:嵌套 git 内部数据(归档/内嵌git-20261008/、归档/skills-git-旧线-20261007/dotgit-原样移出/)
- .gitignore 补:运行态与部署副本(.workbuddy/collab/、.workbuddy/tools/、.workbuddy/.load-pending、.workbuddy/tmp-*)
- .gitignore 补:备份件(*.bak-*)
- 未跟踪文件从 2190 降到 890(其余为 归档/ 归档件与 .workbuddy/memory/ 知识文件,按口径入库)
2026-10-10 23:13:22 +08:00

1949 lines
119 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 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-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 ("[结果检查]", "[目标检查]"))
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) -> bool:
"""`cwd` 是否就是**本工作区**(斜杠/大小写无关)。⛔ 仅用于**主会话解析**,⛔ 不用于"会话归属本项目"。"""
a = str(cwd or "").replace("\\", "/").rstrip("/").lower()
b = str(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,不是人看的词)。
_PFX = {"主": "main",
"执行": "worker", # 🆕 新前缀(当前口径)
"协作": "worker", # ⛔ 旧前缀,保留兼容(库里有 53+45 条在用)
"协作目标": "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) -> dict:
"""🔴 扫一遍宿主库,挑出**本工作区里所有"可能是主会话"的会话**(按最近活动倒序)。
🔴 与 `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": {}}
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 "")) and _role_of_title(t) not in ("worker",)):
continue
sid = str(r[0] or "")
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) -> 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 推断**项目归属**」不冲突 —— 那条管的是"会话属于哪个需求项目",
本函数管的是"这条工作区里**哪条是主会话**",是**另一件事**。
"""
reg = _main_sid(st)
sc = _scan_ws_mains(st, topics)
named, cand = sc["named"], sc["cand"]
pick = named[0] if named else (cand[0] if cand else "")
# 🔴 `all_sids` = **解析出来的那些**(默认 + 登记 + 各任务类别),⛔ **不是**"本工作区所有会话" ——
# 只有解析出来的才算主会话,否则"本工作区随便开个无关会话"会被当成本项目(判据被无声放宽)。
_all: list = []
for _s in ([pick, reg] + [str((_v or {}).get("sid") or "") for _v in sc["by_topic"].values()]):
if _s and _s not in _all:
_all.append(_s)
out: dict = {"all_sids": _all, "by_topic": dict(sc["by_topic"])}
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) -> dict:
"""🔴 **本需求目标项目的身份 + 归属判据**(用户 2026-09-30 明示两件事:
① 「**要明确哪些会话是属于某个需求目标项目的**」
② 「**这个看板上应该明确显示是哪个需求目标项目**」)。
🔴 **判据与 `collabd.py::_in_project()` 同款** ⇒ 看板列的"在跑会话" ≡ `--ready-next` 认的"在跑会话"
(⛔ 否则两边各说各话:看板显示没人跑、收尾确认却被拦)。
⛔ **刻意不用 cwd 推断** —— 架构 §2.3 明令「绝不回落到 cwd 推断」。
🔴 2026-10-01 多目标:可选传入 `g`(某个目标)⇒ **按这个目标的类别算归属**(tab 用)。
⛔ 不传 ⇒ 回落活跃目标 `goal.json` ⇒ **单目标部署行为逐字不变**。
"""
g = _goal() if g is None else g
st = _j(INBOX / "collabd-state.json", {}) or {}
# 🔴 2026-09-30:主会话**解析**(⛔ 不再是"读登记/上次投给谁"那种静态值)——
# 与 `collabd.py::resolve_main()` 同源,投递侧跟随谁、看板就显示谁。
_rm = _resolve_main(st, _goal_topics(g))
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` 行为**零改动**。
for _pw in _peer_workspaces():
try:
_root = Path(_pw).resolve()
if not _root.is_dir():
continue
_add(_root / "tmp" / "supervise-inbox" / "goal.json", 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) -> dict:
"""🔴 **队列计数**(用户 2026-09-30:「协作程序 也要显示**当前待验收队列数量**」)。
🔴 **队列 = 需求台账 `tasks.json`**(`architecture.md` §迭代记录明载),**四态**:
`pending 待执行` / `running 执行中` / `done 已完成` / `blocked 有阻碍`。
⚠️ **机制里没有「待验收」这个态**(⛔ 不臆造一个数字出来)⇒ 这里如实给**四态明细 + 未完结数**:
`open`(未完结)= 待执行 + 执行中 + 有阻碍 + 其它;`done` = 已完成。
⚠️ 另附 `notify_n`(= `queue_info.n`,**待主会话反馈**的通知条数)—— 它与台账是两码事,⛔ 别混。
"""
by = {"pending": 0, "running": 0, "done": 0, "blocked": 0, "other": 0}
for v in (tasks or {}).values():
s = str((v or {}).get("state") or "").strip().lower()
by[s if s in by else "other"] += 1
qi = st.get("queue_info") or {}
try:
_nn = int(qi.get("n") or 0)
except Exception:
_nn = 0
return {"total": len(tasks or {}), "by": by,
"open": by["pending"] + by["running"] + by["blocked"] + by["other"],
"notify_n": _nn}
# ⛔ 「监督守护为什么停」是**某个项目的历史事实** ⇒ 归**使用方**(写在其 `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)}
def _acc_is_pass(v) -> bool:
"""🔴 一条验收判据「算不算过」。
2026-10-02 实测修的**假红**:原判据是 `str(v).lower() != "pass"`,而**台账的真实写法是中文**
—— `goal.json::acceptance_state` 的值形如 `过(PID 3552 …)`/`待重验(判据随口径变更作废 …)`
⇒ **6 条已过被判成「非 pass」** ⇒ 看板「完成情况」显示 **7 条全非 pass**,与真源(仅 V2 待重验)**相反**。
⛔ 同族=**判据写死期望值**(见 `pitfalls.md` P0-13 / P0-20):写成 `== "pass"` 看着有判据,
实际**永远不命中真源** ⇒ 恒红。⇒ 判据必须**认真源真的会出现的那几种写法**。"""
import re as _re
s = str(v or "").strip()
if not s:
return False # 空 ⇒ 判不出来 ⇒ ⛔ 不算过(fail-closed)
# 🔴 2026-10-02 残留假红(同族第 2 处):真源里有 `**复核:过(形态已治本…)**` 这种写法
# —— 判定词**不在开头**(前面还有「复核:」)⇒ 原来的 startswith 判不出来 ⇒ 已过被判非 pass。
# 判据=**先切出"判定词"那一段**(丢掉 `(…)` 之后的说明),再在 `:` 后取最后一段。
head = _re.split(r"[((]", s, 1)[0] # 丢掉括注说明
# 🔴🔴 2026-10-03 18:5x **改成「逐段找判定词」,不再「只取最后一段」** ——
# 实测假红(2026-10-03,两个测试区各中一条):值 =
# `过|协作常驻已恢复:pid=15876 存活,心跳自 2026-10-03 16:53:59 起 10 秒一轮…`
# ⇒ 里面**有好几个冒号**(说明文字 + **时间里的**)⇒ 取最后一段切到了 `59 起 10 秒一轮…`
# ⇒ 明明写着「过」却被判**非过** ⇒ 看板把已达成显示成「非 pass(1/4)」。
# ⚠️ 旧写法是 2026-10-02 为治「复核:过」加的(方向对、**取段方式错**)——
# 「复核:过」只需要"某一段以判定词开头",⛔ 不需要"最后一段"。
# ⛔ 仍 fail-closed:`不过` / `未过` / `待重验` 都不以「过」开头 ⇒ 自然判非过。
for _seg in _re.split(r"[::]", head):
_t = _seg.strip().lstrip("*").strip().lower()
if _t.startswith("pass") or _t.startswith("过") or _t.startswith("通过"):
return True
return False # 一段都没命中 ⇒ 判不出来 ⇒ ⛔ 不算过
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)
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("_")}
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},
"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),
"queue": _queue(tasks, st), # 🔴 队列计数(协作程序节点用)
"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))
_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 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`:显式接管 —— 先停掉旧实例,再起本实例
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())