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