Files
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

1158 lines
66 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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
import glob
import json
import os
import re
import socket
import sqlite3
import sys
import time
from pathlib import Path
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 r"E:\ProgramData\.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` 的角色 ② 退回最近一次投递到的会话 ③ 都取不到 ⇒ 空串,⛔ 不猜)。"""
for _sid, _r in (st.get("roles") or {}).items():
if str(_r) == "main" and str(_sid).startswith(tuple("0123456789abcdef")):
return str(_sid)
return str(((st.get("wake") or {}).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`/`""`)。
🔴 **⛔ 与 `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"
return "worker" if "接续" in nm else ""
_m0 = re.match(r"^\[([^\]]*)\]\s*(.*)$", nm)
r = _m0.group(1).strip() if _m0 else ""
if r in ("主", "协作", "唤醒"):
return {"主": "main", "协作": "worker", "唤醒": "waker"}[r]
if _m0 and "接续" in _m0.group(2):
return "worker" # `[<类别>] 接续 · …` —— 方括号里是类别,⛔ 不是角色
return ""
def _scan_ws_mains(st: dict, topics=None) -> dict:
"""🔴 扫一遍宿主库,挑出**本工作区里所有"可能是主会话"的会话**(按最近活动倒序)。
🔴 与 `collabd.py::_scan_mains()` **同款判据**(⛔ **改一处必须改两处** —— 漂了就没人能发现主会话换了):
· 只看 `cwd == 本工作区`(⛔ 不回落其他推断)
· ⛔ **排除全部"协作侧"命名**:`[协作]-…`/`[唤醒]-…`/**接续会话**(`[<类别>] 接续 · …`)——
三者都是**干活的棒**,⛔ 不是主会话候选(🔴 2026-10-01 扩:旧版只排 `[协作]` ⇒ 接续棒冒充主会话)
· 角色判据见 `_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()` **逐条同款**,理由见那边注释(接续棒冒充主会话=自指死结)。
if not (_same_ws(str(r[1] or "")) and _role_of_title(t) not in ("worker", "waker")):
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`。
"""
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 [])
for _t in _tps:
if _t and ("[%s]" % _t) in ti:
return True
return False
def _goal() -> dict:
return _j(INBOX / "goal.json", {}) or {}
GOALS_DIR = INBOX / "goals" # 🔴 多目标:一个文件=一个目标(字段与 goal.json 同款)
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) -> None:
g = _j(p, {}) or {}
if not g:
return
k = str(g.get("id") or "").strip() or p.stem
if k in seen:
return
seen.add(k)
out.append({"path": str(p), "goal": g, "active": active, "key": k})
_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
return out
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
rows = con.execute(
"select id,title,custom_title,status,cwd,updated_at,last_activity_at "
"from sessions order by updated_at desc limit 60").fetchall()
con.close()
return rows
def _sessions(limit: int = 8, sc: dict | None = None, rows=None) -> dict:
"""返回 `{"mine": [...], "others_running": n, "err": ""}` —— **只把本项目的会话列进看板**。
`mine` 里**主会话排最前**(看板第一眼要能看到"哪个是主会话")。
🔴 **不许拖慢宿主**(用户 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()
main, work, others, unrec = [], [], 0, []
_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 "")
if not 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`),⛔ 不是"唯一那一条"
"role": ("主会话" if (sid == sc.get("main_sid") or sid in (sc.get("main_sids") or []))
else "协作会话"),
# 🔴 任务类别(=标题二级前缀 `[主题]`)—— 分工板按它归位
# (用户:「通过协作会话名称前缀的方式区分具体任务会话」⇒ 同一个词,四处同义)
"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),
}
(main if rec["role"] == "主会话" else work).append(rec)
mine = (main + work)[:limit]
return {"mine": mine, "others_running": others, "unrecognized": unrec, "err": ""}
except Exception as e:
return {"mine": [], "others_running": 0, "unrecognized": [],
"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 _runtime() -> dict:
"""🔴 **协作程序 / 监督程序的实时状态**(用户 2026-09-30 要求架构图「**要能展示实时状态**」)。
取的全是**真痕迹**(状态戳 mtime),⛔ 不猜、⛔ 不按"配置里写着要常驻"就当它活着:
· **协作程序**:`_tick.stamp`(宿主钩子唤起的一次性投递轮)/`collabd-once.stamp`(投影轮)取更新时间
· **投递**(旧名「监督程序」):**不是一个该常驻的进程** —— 它就是**宿主钩子唤起的一次性
`--tick`**。所以这里报的是「**这条链通不通**」(看 `--tick` 戳的新鲜度),⛔ 不是"启动没启动"。
⚠️ 教训仍在:图上必须能一眼看出**谁其实没在跑** —— 但"没在跑"得先说清**它本来该不该跑**。
"""
tick = _age_min(INBOX / "_tick.stamp")
proj = _age_min(INBOX / "collabd-once.stamp")
glog = _age_min(INBOX / "guard.log")
stopped = (INBOX / "guard.stop").exists()
_a = [a for a in (tick, proj) if a is not None]
prog_up = bool(_a) and min(_a) < 15.0 # 15 分钟内有轮 ⇒ 算在线
prog_age = min(_a) if _a else None
prog_by = ""
if prog_up:
prog_by = "钩子 --tick" if (tick is not None and (proj is None or tick <= proj)) else "投影轮 --once"
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,
"tick_age_min": tick, "proj_age_min": proj,
"label": ("在线" if prog_up else
(("在线 · 已 %s 分钟没轮" % prog_age) if prog_age is not None
else "在线 · 本轮还没跑过")),
"how": ("由 WorkBuddy 钩子按需唤起:--once(协作程序)/--tick(上报)"
+ ((";最近一轮=%s" % prog_by) if prog_by else ""))},
# 🔴🔴 **「投递」这个词取代了旧的「监督程序」**(2026-09-30 改口径 · 用户连问四次
# "监督程序为什么打不开 / 它一直停着能起什么作用 / 还需要保留吗 / 跟它有关系吗"):
# · 「监督程序」是**投递这条职责的旧名** —— 它从来不是一个"该常驻的进程";
# · ⛔ **别再报"已停"** —— 那会让人以为有东西坏了,而实际上**没有东西该在跑**;
# · 真正该报的是「**投递这条链通不通**」⇒ 判据用 `--tick` 戳的新鲜度(投递轮就是它)。
# 🔴 **必须原样保留的**:投递方唯一性(只有 `--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": ""}, # ⛔ 不再给"停因"——那个问题已经不存在了
}
# ⛔ 上一版这里还有 `_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_summary(acc: dict) -> str:
"""🔴 验收状态一行话。⛔ 与 goalctl / collabd 同一条红线:
**一条有效判据都没有 ⇒ 判不出来**,⛔ 不许静默显示成「无」(那读起来像"全过")。"""
if not acc:
return "⚠️ 未声明验收判据(只有说明行)⇒ **判不出来**,⛔ 不因此判完成"
bad = [k for k, v in acc.items() if str(v).lower() != "pass"]
if not bad:
return "全部 pass(%d 条)" % len(acc)
return "非 pass:%s" % bad
def _goal_block(g: dict, active: bool, tasks_all: dict, srows, st: dict, warn: list,
multi: bool = False, all_topics=None) -> 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)
try:
_ss = _sessions(sc=sc, rows=srows)
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 ""),
"active": bool(active),
"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`)。
"sessions_unrecognized": _ss.get("unrecognized") 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() # 🔴 只读一次库 ⇒ 所有目标共用
except Exception as e:
srows = []
warn.append("WorkBuddy 库暂不可读:%s" % e)
_multi = len(_gfs) > 1
_all_tps = [t for it in _gfs for t in _goal_topics(it["goal"])]
blocks = [_goal_block(it["goal"], it["active"], tasks, srows, st, warn, _multi, _all_tps)
for it in _gfs]
_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),
# 🔴 多目标:看板据此知道"要不要画 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").stdout or ""
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")
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())