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