# -*- coding: utf-8 -*- """collabd —— **协作守护程序**(通用版 · 一个进程承担"派活之外的全部功能") 配套:`session-mechanism` 技能。配置见同目录 `collabd.config.json`(模板 `collabd.config.example.json`)。 **它承担**:① 通路/服务自愈(连续 N 次不通才动手·带冷却 ⇒ 防误杀)② 监控+**证据分级**+推进判定 ③ 机械靶点判定 ④ **真空判定**(需求未完成 ∧ 无人执行) ⑤ 断链/中断告警 ⑥ **机制体检**(仅空闲) ⑦ **任务图**(可派/等待/关键路径/**可派未派=浪费**) ⑧ 视图覆写(看板/摘要) ⑨ **单例**守护 ⑩ 🔴🔴 **2026-10-03 已退役:唤醒回路 + 队列投递**(原 ⑩)—— 收件人(唤醒会话/跟进会话) 已按用户口径整套退役 ⇒ **本程序不再向任何会话投消息**。退役前它做的事:读队列 ⇒ 有变化就 `POST /api/v1/sessions/{id}/reply` 投给「跟进会话」;静默则发「唤醒」让主会话核对。 ⇒ **代码已真删**(`supervise()` 只剩读队列报个数);`_deliver_str()` / `follow_for_topic()` / `wake_round()` / `check_delivery_consumed()` 四个函数**本体保留但零调用点**(`selftest.py` 真调用前两个做单测;后两个是判据实现,将来重建收件人可直接接回)。详见 `supervise()` docstring。 **⛔ 它不做**:**派活 / 开新会话** —— 那必须由会话做(且"新建自动化"受白名单+确认制约束)。 🔴 **2026-10-03 起本程序的真实现行口径**(用户原话「现在的机制是 协作程序接收和处理队列」): · **接收** = `--report` 把任务会话的执行状态写进 `tasks.json`(四态台账,唯一权威)。 · **处理** = 建**检查会话排期**(`maybe_spawn_check_agent()`,四道闸)让会话去读队列干活 —— 常驻 `--supervise` 每 2 轮判一次,是这条腿的**唯一载体**。 · **推送** = **没有这一环**。队列变化不再自动通知任何人,要落事得**显式建任务会话**。 边界:⛔ 读取宿主库只读 ·⛔ 不写宿主状态 ·⛔ 不碰别人的文件 · 全静默 · fail-open ⚠️ **唯一写操作**(2026-09-29 起)= 队列投递的那一次 `reply` —— 🔴 **2026-10-03 已退役**, 现行版本**零网络写操作**;口令只进程内用(⛔ 不落盘 ⛔ 不进日志 ⛔ 不回显)这条约束**仍然有效** (网关发现仍在读,只是不再拿它发消息)。 用法(🔴🔴 **2026-10-03 投递退役后的现行口径**;⛔ 已废弃「用自动任务当闹钟」): 🔴 **判据:常驻(`--supervise`)现在只干两件事** —— ① **检查会话建排期**(唯一建排期的地方;四道闸在 `maybe_spawn_check_agent()` 内); ② **写心跳**(`supervise-heartbeat.json` + `.log`)——「常驻在不在」的唯一机读判据。 ⛔ **它不再投递、不再唤醒**:那两段已真删(收件人已退役)⇒ **留着常驻的唯一理由是 ①**。 ⚠️ 载体结论(2026-10-02 23:0x 实测订正,推翻更早的"没有进程能一直活着"): **两条探针**(`start /b` 与 `Popen+DETACHED`)在同一时刻一起停(tick 67/64) ⇒ 与起法关键字**无关**,是**从工具调用进程树里起的活不过当轮**。 ✅ **能长跑的两种**:① 走 **WorkBuddy 自己的后台任务**(工具 `run_in_background`; 载体由宿主管,不由当轮工具调用决定)—— 实测 `board.py --serve 8788` 与 `server.js 8900` 借它起,**端口一直 LISTENING、curl 200**; ② **用户从桌面双击起的**(在用户登录会话里,如 `mcn-work-shop/start.bat`)。 ⇒ 「一直运行」仍由**自愈**兜底:常驻被重启/换会话/手动收打断后, **下一次 `--ensure` 调用**(钩子/会话/排期)立刻补起(`--ensure` / `ensure_supervise()`)。 ⚠️ 留着自愈的理由**已换**:⛔ 不是"没法长跑",而是**常驻总会被打断**。 `--once` 常驻程序:读库/判定/信号/看板(**⛔ 不投递** —— 投递已整体退役) 🗑️ `--tick` **2026-10-05 已删**(用户逐字:「检查程序 常驻 自己不会判断吗,非要什么 tick once 去触发?」)。它原先扛的三件事**全部搬进 `--supervise` 常驻主循环**,⛔ 不是"少做": ① **建检查会话排期**(两条腿 `sessions-ended`/`queue-empty`)⇒ 常驻每 2 轮自判 ② **宿主侧 park 指纹探针** ⇒ 抽成 `_tick_park_probe()`,常驻每 20 轮(≈5 分钟)调 ③ **顺手续命常驻** ⇒ 由 `--ensure`(钩子侧保留调用)承担 ⛔ 投递已于 **2026-10-03 整体退役**,本分支删前就只剩这三件。 🔴 **2026-10-02(S8)**:跑完顺手**续命常驻**(`ensure_supervise()`,幂等 + 30 s 节流, ⛔ 本进程无网关口令时不起)⇒ 常驻被宿主回收后,**下一次事件**自动把它带回来。 ⚠️ 判读 `deliver=` 字段:**恒为 `retired-20261003`**(无收件人)—— ⛔ 别再拿它判"投递成功与否"。 旧档位(`target-busy`/`no-follow-session`/`follow-not-live`/`target-deaf`/ `same-item`/`too-soon`/`no-token`…)**只存在于 `_deliver_str()` 本体里**(零调用点)。 `--supervise` **常驻形态(=检查会话建排期 + 心跳)** —— 定案载体;⛔ 不用自动任务、⛔ 不靠宿主排期 🔴 **2026-10-03 职责收窄**:⛔ 不再是"投递本体 + 唤醒时钟"(那两段已真删) ⇒ 留它的**唯一理由**是承载「检查会话建排期」——⛔ 检查会话只能靠排期开,⛔ 开不了别的通道。 🔴 **判据(唯一机读)**:`pid 活着 ∧ 心跳新鲜(<90 s)`; 心跳=`/.workbuddy/collab/logs/supervise-heartbeat.json`(每轮原子替换 / ⛔ 零删除); 节拍追加到同目录 `supervise-heartbeat.log`(超 512 KB 重写保留末 1500 行)。 ⛔ **别拿 `_tick.stamp`/日志当"常驻在跑"的证据** —— 那些轮次是宿主钩子写的。 🔴 **起完必查三样**(⛔ 打印"已启动"≠ 成了):`netstat` 有 **LISTENING**(⛔ `TIME_WAIT`/ `FIN_WAIT_2` 是历史连接残留,不算)+ `curl` 得 200 + `tasklist` 进程在。 `--ensure` 常驻**续命**(幂等):不在就起、已在即返回;带口令的进程(钩子/会话/排期)都可调 `--where` 路径自证 (无参数仍=常驻,保留兼容;**定案形态以 `--supervise` 为准**) """ 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 hashlib import io import json import os import re import socket import sqlite3 import subprocess import sys import time import urllib.error import urllib.request import uuid# 🔴 2026-10-02「检查会话」建排期用(automation id 必须是 36 位 UUID) import datetime # 🔴 同上(scheduled_at / next_run_at 计算) from pathlib import Path HERE = Path(__file__).resolve().parent def _win_pythonw(): """常驻/后台子进程优先用 `pythonw.exe`(GUI 子系统)—— Windows 永不为它分配控制台窗口, 比单靠 CREATE_NO_WINDOW 更稳(2026-10-02 闪屏根治)。⛔ 不存在时回退 sys.executable。""" if os.name != "nt": return sys.executable _d = os.path.dirname(os.path.abspath(sys.executable)) _pw = os.path.join(_d, "pythonw.exe") return _pw if os.path.isfile(_pw) else sys.executable PYW = _win_pythonw() DEFAULTS = { "workspace": "", "inbox": "tmp/supervise-inbox", "live": "", # 视图(看板类产物);空 ⇒ inbox/realtime.md "taskgraph": "", # 任务图 json;空 ⇒ inbox/taskgraph.json "lines": {}, # {"<目录名>": "<友好名>"} 用于靶点判定 "goal_docs": [], # [["显示名", "相对 workspace 的路径"], ...] "targets": {}, # {"<目录名>": [["显示名","glob 相对该目录"], ...]} "host_db": "", # 宿主库路径;空 ⇒ $CODEBUDDY_CONFIG_DIR/workbuddy.db "shim_port": 0, # 服务端口探针(0 ⇒ 关闭) "shim_streak": 3, "client_entry": "", # 端口不通时拉起的启动器(.mjs/.py/.exe) "client_runner": "", # 用什么跑启动器(如 node 绝对路径);空 ⇒ 直接执行 "singleton_port": 20099, "interval": 10, "idle_min": 12, "stuck_min": 30, "vacuum_min": 5, "health_every": 300, # 🔴 2026-10-02 立(S11):**「到点未触发」的补跑容差**(分钟)。 # 实测本机一次性排期由主机轮询/唤醒**补跑**(`runKind=missed`),延迟 2.0~9.7 分钟 # ⇒ 容差 0 必然假红;30 分钟覆盖实测最坏值并留余量。⛔ 别调回 0。 "misfire_grace_min": 30, # 🔴 2026-10-01 立 · **会话在版面上算不算"活的"**(分钟)—— # `completed` 且距上次活动超过它 ⇒ 视同**退场**,看板与状态稿**不再摊**(只计数)。 # ⚠️ `working` 与主会话**永不**按窗口退场(见 `retired.json` 那段长注释)。 "session_live_min": 90, "client_cooldown": 600, # ⑩ 唤醒回路(2026-09-29 立)。⚠️ wake_text 内**一律用「」**,⛔ 别写 ASCII 双引号(P11 引号坑) "wake_enable": True, "wake_min_gap": 180, # 秒:同一件两次投递的最小间隔 "wake_max_ports": 60, # 最多探多少个 loopback 监听口(认网关口用) "wake_text": "读 tmp/supervise-inbox/NEXT.md 处理那一条;收尾时若 NEXT.md 还在才接续(一次只做这一条)", } # 🔴 部署配置**属于使用方**(用户 2026-09-30 定则:「技能就是技能 程序就是程序, # 谁用产生的文件 放在他自己那里」)⇒ **技能目录里⛔ 不放生产配置**。 # 查找顺序(前面的赢): # ① 环境变量 `COLLABD_CONFIG` —— 钩子/启动器显式指定(首选) # ② `<工作区>/.workbuddy/collab/collabd.config.json` —— 使用方的**标准落点** # ③ 都没有 ⇒ 用 DEFAULTS + `COLLABD_WORKSPACE`/cwd,并**明确记一条"未找到配置"**(⛔ 不静默) CFG_USED = "" CFG_MISSING = True def _cfg_candidates() -> list: out = [] env = os.environ.get("COLLABD_CONFIG") if env: out.append(Path(env)) ws = os.environ.get("COLLABD_WORKSPACE") or os.getcwd() out.append(Path(ws) / ".workbuddy" / "collab" / "collabd.config.json") return out def load_cfg() -> dict: global CFG_USED, CFG_MISSING cfg = dict(DEFAULTS) for p in _cfg_candidates(): if p.is_file(): try: cfg.update(json.loads(p.read_text(encoding="utf-8"))) CFG_USED, CFG_MISSING = str(p), False break except Exception: pass if not cfg["workspace"]: cfg["workspace"] = os.environ.get("COLLABD_WORKSPACE") or os.getcwd() return cfg C = load_cfg() WS = Path(C["workspace"]) INBOX = WS / C["inbox"] def _rp(rel: str, default: Path) -> Path: """🔴 把配置里的**相对路径解析到 workspace 下**(⛔ 别按脚本 CWD 解析 —— 踩过:视图被写进技能目录)""" p = Path(rel) if rel else None if p is None: return default return p if p.is_absolute() else (WS / p) LIVE = _rp(C["live"], INBOX / "realtime.md") TG = _rp(C["taskgraph"], INBOX / "taskgraph.json") STALL, VACUUM, READY = INBOX / "STALL.md", INBOX / "VACUUM.md", INBOX / "READY.md" HEALTH = INBOX / "体检报告.md" STATE = INBOX / "collabd-state.json" # ⛔ 日志**不进技能目录**(技能是只读的能力件)⇒ 默认落在使用方的工作区内 LOG = _rp(C.get("log") or "", INBOX / "_collabd.log") def log(m: str) -> None: if CFG_MISSING: # 🔴 不知道"使用方的地方"在哪 ⇒ **一个文件都不许落** return # (否则 cwd/传错的 workspace 会被写成落点 ⇒ 污染技能包) try: LOG.parent.mkdir(parents=True, exist_ok=True) # ⚠️ 日志落点在**使用方**,目录可能还没建 with open(LOG, "a", encoding="utf-8") as f: f.write("[%s] %s\n" % (time.strftime("%Y-%m-%d %H:%M:%S"), m)) except Exception: pass # 🔴 2026-10-01 体检修加:**同一句结论每轮都写一遍 ⇒ 日志被噪声淹没**(实测:连续 40+ 行同一句 # "acceptance_state 没有有效判据",真正的新事件反而看不见)⇒ 同一 key 在 gap 秒内只写一次。 _LOG_LAST: dict = {} def log_throttled(key: str, gap: float, m: str) -> None: """同一 `key` 在 `gap` 秒内**只写一次**(⛔ 不改语义,只是降噪)。""" _now = time.time() if _now - float(_LOG_LAST.get(key) or 0) < float(gap): return _LOG_LAST[key] = _now log(m) if CFG_MISSING: # ⛔ **找不到配置时不许落任何文件** —— 那一刻我们根本不知道"使用方的地方"在哪; # 旧写法 `log(...)` 会按默认落点写进 **cwd**,而 cwd 常常就是技能目录 # ⇒ 恰好又造出它要避免的污染(2026-09-30 实测:一跑就往技能里长出 `tmp/supervise-inbox/_collabd.log`)。 # ⇒ 只打 **stderr**(调用方若 devnull 掉了就当静默;人手动跑时看得见)。 sys.stderr.write("⚠️ collabd:未找到部署配置(COLLABD_CONFIG 未设,且 %s 不存在)\n" " ⇒ 已拒跑,⛔ 不会往任何目录写文件。\n" % (Path(os.environ.get("COLLABD_WORKSPACE") or os.getcwd()) / ".workbuddy" / "collab" / "collabd.config.json")) def _mt(p: Path) -> float: try: return p.stat().st_mtime except Exception: return 0.0 # ───────────────────────────────────────────────────────────────────────────── # 🔴 常驻存活自愈(2026-10-02 · S8 治本 · 本棒实测为据,⛔ 非推断) # # 实测两条(都在本机跑过,读数见 `pitfalls.md`): # ① `CREATE_BREAKAWAY_FROM_JOB` **被宿主作业对象拒绝** ⇒ `PermissionError(13, '拒绝访问。')` # ⇒ **没有任何子进程能脱离宿主的回收**("独立于会话的进程"这条路在本机不存在)。 # ② 反过来,**普通子进程能活过启动者所在的工具调用边界**(三个探针跨调用持续打点 40 s+, # 其父进程早已消失)⇒ 起一条子进程是有效的,问题是它**迟早被回收**(实测 8 / 12 / 20 分钟)。 # ⇒ 结论与修法:**单个进程"永远活着"在本机不成立** ⇒「一直运行」只能由**事件驱动的自愈**兑现: # 常驻掉了 ⇒ **下一次 `--ensure`**(钩子/会话/排期调)立刻把它补回来。 # 判据(可机读,⛔ 不拿"日志在走"当证据 —— 那些轮次可能是钩子写的): # **pid 活着 ∧ 心跳新鲜(< `SUPERVISE_STALE` 秒)**,两样都成立才算在跑。 # ⚠️ 心跳落点 = `/.workbuddy/collab/logs/supervise-heartbeat.json` —— # 与 `session-rules-check.py` 的 ⑩ 检查项**同一个文件**(此前**无人写它** ⇒ 该项恒 warn)。 # ⚠️ 运行时**零删除**(临时文件 + `os.replace`;节拍日志超限**重写**而非 unlink)—— P0-6 判据。 # ───────────────────────────────────────────────────────────────────────────── SUPERVISE_STALE = 90.0 # 秒:心跳超过它即视为已掉线 # 🔴 2026-10-03 新增(用户口径第①条「目标完成 ⇒ 关后台任务与协作程序」的实现参数): # `guard.stop` 写好后,给常驻主循环自己发现并优雅退出的**等待上限**(秒)。 # ⚠️ 主循环每轮 `supervise_interval`(默认 30s)才查一次 `_guard_says_stop()` # ⇒ 30s 是理论下界;取 45s 留余量,⛔ 也不让它无限等(超时就走强停)。 STOP_GRACE = 45.0 # 🔴 2026-10-03 跨区自愈的**检查节流**:每 N 轮才真去 stat 一次别区。 # ⚠️ 别区可能不止一个 ⇒ ⛔ 不每轮都去碰它们的文件(30s/轮 × 多区 = 白耗 IO)。 # 30s/轮 × 6 轮 ≈ 3 分钟查一次 —— 够快(外部收走后几分钟内补回),又不打搅。 PEER_SWEEP_EVERY = 6 SUP_HB = WS / ".workbuddy" / "collab" / "logs" / "supervise-heartbeat.json" SUP_BEAT = WS / ".workbuddy" / "collab" / "logs" / "supervise-heartbeat.log" SUP_PID = INBOX / "supervise.pid" SUP_OUT = WS / ".workbuddy" / "collab" / "logs" / "supervise.out.log" SUP_BEAT_MAX = 512 * 1024 # 节拍日志上限(超 ⇒ 重写保留末 1500 行) _SUP_START_TS = time.time() def _pid_alive(pid) -> bool: """跨平台判进程是否活着。 🔴 **⛔ 不走 `tasklist`**(2026-10-02 实测的坑):它的输出是**本地代码页**(本机 GBK,首字节 0xd0) ⇒ `text=True` 在**读取线程**里抛 `UnicodeDecodeError`(还会冒成未捕获线程异常写进 `supervise.out.log`)⇒ 判据**静默变假**("进程还在"被读成"已不在" ⇒ 会重复起实例)。 ✅ 改用内核句柄:`OpenProcess(QUERY_LIMITED_INFORMATION)` + `GetExitCodeProcess == STILL_ACTIVE`。 ⚠️ **保守方向=判"在"**:打不开且错误是 `ERROR_ACCESS_DENIED(5)` ⇒ 权限问题、⛔ 不等于不存在, 返回"在"(宁可不起第二条,也不双写)。 """ try: pid = int(pid) except Exception: return False if pid <= 0: return False if os.name == "nt": try: import ctypes from ctypes import wintypes k = ctypes.WinDLL("kernel32", use_last_error=True) h = k.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION if not h: return ctypes.get_last_error() == 5 # 5 = ACCESS_DENIED ⇒ 保守判"在" try: code = wintypes.DWORD() if k.GetExitCodeProcess(h, ctypes.byref(code)): return int(code.value) == 259 # 259 = STILL_ACTIVE return True finally: k.CloseHandle(h) except Exception: return True # 判不出来 ⇒ 保守判"在" try: os.kill(pid, 0) return True except Exception: return False def read_heartbeat() -> dict: try: return json.loads(SUP_HB.read_text(encoding="utf-8")) except Exception: return {} def _own_path() -> Path: """🔴🔴 **本区"正宗"的 collabd.py 是哪一份**(2026-10-05 加,治**自我繁殖/自我污染**)。 ## 为什么必须有它(实测根因,⛔ 非推断) 本脚本有**两处**要回答"我该以哪份 collabd.py 为准",而它们原先都写 `Path(__file__).resolve()` = **"当前正在跑的那一份"**: ① `_escalate_to_keeper()` 生成 `start-supervise.ps1` 的 `__SCRIPT__` ② `ensure_supervise()` 起常驻时的目标文件 ③ `_pid_is_collabd()` 判"这 pid 是不是本区常驻"的比对基准 🔴 这个基准**本身可能就是错的**:若当前跑的是**技能目录那份** (`/session-mechanism/scripts/collabd.py`,P0-57 形态), 那么"当前这份"=错的 ⇒ 三处一起把错的当成正宗 ⇒ **自我强化**: · ② 续命时又起一条**技能目录那份** ⇒ 日志实测 `常驻续命:新起 --supervise pid=69464(原因:pid 12672 在、心跳新鲜,但**身份对不上**)` —— 本区副本(12672)明明活得好好的,只因**比对基准被技能目录那份顶着** ⇒ 判"身份对不上" ⇒ **再拉一条** ⇒ 无限繁殖(用户看到的"越创建越多")。 · ③ 判据反向失真:真的本区常驻被判"不是我"。 · ① 重铺 ps1 时把正确的本区路径覆盖成技能目录 ⇒ 错一次回不来。 ✅ 正解=**发布物优先**:只要 `/.workbuddy/collab/collabd.py` 在位, 它就是**唯一基准**(⛔ 不是 `__file__`)。 副本缺失时才回落到当前这份,并且**必须打印告警**(⛔ 静默回落=用户以为跑的是副本)。 """ try: pub = Path(WS) / ".workbuddy" / "collab" / "collabd.py" if pub.is_file(): return pub except Exception: pass return Path(__file__).resolve() def _pid_is_collabd(pid, want_argv0: str = "") -> bool: """🔴🔴 **本进程到底是不是"这个区的 collabd 常驻"**(2026-10-05 加,用户点破)。 ## 为什么必须有它(用户 2026-10-05 逐字) > 「**一个工作区只能有一个常驻。启动的时候必须先判断有没有**」 🔴 **病根**:`supervise_alive()` 只看 `心跳里的 pid 活着 ∧ 心跳新鲜` —— **从不验证那个 pid 到底是不是 collabd**。而 **Windows 的 pid 会复用** ⇒ 心跳里记的 `pid=48540` 完全可能是**另一个不相干的进程**(碰巧复用了这个号) ⇒ 判据说"活着"、而真正的常驻**早已死掉** ⇒ **永远没人补**(静默瘫)。 ⚠️ 镜像的另一面:心跳写不进去/被清掉的那几秒里,多个 `--ensure` 并发 **各自都读到"无心跳"** ⇒ 各起一条 ⇒ **两条常驻**(双写状态文件 = 脑裂)。 ⇒ 两个方向都通向同一句判据缺失:**"这个 pid 是不是我要的那个进程"没人答**。 ✅ 正解=**按名字核验,⛔ 不只看号**: ① 拿 `OpenProcess` 打开 pid,再用 `QueryFullProcessImageNameW` 取**可执行文件全路径** + `argv0` 对照(心跳里本来就写了 `argv0`,但**在此之前只写不读**——白存了); ② 只留**保守方向**:核不出来 ⇒ 判"不是我"(=可以起), 但**起之前**还要过一遍 `ensure_supervise()` 的二次确认(见那里的注释)。 @param want_argv0 心跳里记的入口路径(空 ⇒ 退化为"看进程是不是 pythonw 跑的 collabd") @return True=确认是 collabd 常驻(⛔ 不许再起);False=不是/核不出来 """ try: pid = int(pid) except Exception: return False if pid <= 0: return False if os.name != "nt": return False try: import ctypes from ctypes import wintypes k = ctypes.WinDLL("kernel32", use_last_error=True) h = k.OpenProcess(0x1000, False, pid) # QUERY_LIMITED_INFORMATION if not h: # ⚠️ 打不开(权限等)⇒ 判不出来 ⇒ **保守判"不是我"**: # 交给上层"起之前再确认一次",⛔ 不在这里凭猜说"在"。 return False try: buf = ctypes.create_unicode_buffer(1024) size = wintypes.DWORD(len(buf)) if not k.QueryFullProcessImageNameW(h, 0, buf, ctypes.byref(size)): return False exe = (buf.value or "").lower() finally: k.CloseHandle(h) # ① 可执行文件必须是 pythonw(常驻一律 pythonw 起,⛔ 不是 python) if "pythonw" not in exe: return False # ② 若有 argv0 ⇒ 逐字比对**本区的 collabd 路径**(⛔ 别区的也算"不是我") # 🔴 2026-10-05 改:基准用 `_own_path()`(**本区发布物**),⛔ 不是 `__file__` # —— `__file__` 是"正在跑的那份",它本身就可能是技能目录那份 # ⇒ 拿它当基准会把**真的本区常驻**判成"不是我"(实测见 `_own_path()` docstring)。 if want_argv0: _w = str(want_argv0).replace("\\", "/").lower() _self = str(_own_path()).replace("\\", "/").lower() if _w.endswith("collabd.py") and not _w.endswith(_self): return False return True except Exception: return False def supervise_alive(): """⇒ `(是否在跑, 人可读判据)`。**只看两样**:pid 活着 + 心跳新鲜 + **身份对得上**。""" hb = read_heartbeat() pid = hb.get("pid") if pid is None: try: pid = int(SUP_PID.read_text(encoding="utf-8").strip()) except Exception: pid = None if not pid: return False, "无 pid(心跳与 pid 文件都没有)" if not _pid_alive(pid): return False, "pid %s 已不在进程表" % pid age = time.time() - float(hb.get("ts") or 0) if age > SUPERVISE_STALE: return False, "pid %s 在、但心跳已陈旧 %.0f s" % (pid, age) # 🔴🔴 2026-10-05 加:**身份核验**(pid 会复用 ⇒ 光看"号还活着"会被骗) if not _pid_is_collabd(pid, hb.get("argv0") or ""): return False, "pid %s 在、心跳新鲜,但**身份对不上**(不是本区 collabd 常驻)" % pid return True, "pid %s 活、心跳 %.0f s 前、身份已核" % (pid, age) def write_heartbeat(round_no: int, extra: dict = None) -> None: """原子替换(临时文件 + `os.replace`)⇒ **零删除**。""" if CFG_MISSING: return data = {"pid": os.getpid(), "ts": time.time(), "ts_h": time.strftime("%Y-%m-%d %H:%M:%S"), "started_ts": _SUP_START_TS, "started_h": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(_SUP_START_TS)), # 🔴 2026-10-03 17:0x **跑的是哪一份程序**(用户定案「各工作区的程序独立」后的**可观测性**): # 每个工作区有自己的 `collabd.py` 副本 ⇒ 光看"心跳新鲜"**分不出**是本区副本还是技能目录那份 # ⇒ 把启动入口写进心跳 ⇒ 「这个区跑的到底是哪份文件」一眼可查,也**能证伪**"改了副本却没生效"。 "argv0": str(Path(sys.argv[0] or "").resolve()) if sys.argv and sys.argv[0] else "", "round": round_no, "interval": float(C.get("supervise_interval") or 30)} if extra: data.update(extra) try: SUP_HB.parent.mkdir(parents=True, exist_ok=True) tmp = SUP_HB.with_name(SUP_HB.name + ".tmp") tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") os.replace(tmp, SUP_HB) except Exception: pass def beat_log(line: str) -> None: """节拍日志:一行一轮;超上限 ⇒ **重写**保留末 1500 行(⛔ 不 unlink)。""" if CFG_MISSING: return try: SUP_BEAT.parent.mkdir(parents=True, exist_ok=True) with open(SUP_BEAT, "a", encoding="utf-8") as f: f.write("%s %s\n" % (time.strftime("%Y-%m-%d %H:%M:%S"), line)) if SUP_BEAT.stat().st_size > SUP_BEAT_MAX: keep = SUP_BEAT.read_text(encoding="utf-8", errors="replace").splitlines()[-1500:] tmp = SUP_BEAT.with_name(SUP_BEAT.name + ".tmp") tmp.write_text("\n".join(keep) + "\n", encoding="utf-8") os.replace(tmp, SUP_BEAT) except Exception: pass def peer_supervise_sweep() -> str: """**跨区自愈**:顺带看一眼 `peer_workspaces` 里那些区的常驻,掉了就补一条。 🔴🔴 2026-10-03 **为什么加这个**(实测坐实,不是推断): 常驻**会被外部收走**。实测 2026-10-03 20:33–20:46 期间「会话协作测试3」的常驻 8 分钟内被杀掉 3 次;同日下午本机 `MiService`(Timi PC 助手服务)**每 18 分钟崩一次** (事件 7031,当天 53 次/累计 97 次),系统每次"重新启动服务"即连带清掉一批子进程。 🔴 **改前的缺口**:`ensure_supervise()` 只看**本区**心跳 ⇒ 别区死了**没人知道、没人补** (本区日志里搜不到别区名,实测 0 命中)⇒ 跨区多目标时**别的区静默瘫着**。 ✅ **只补"目标进行中"的区**(三条硬约束,缺一条就可能越界): ① 目标 `lifecycle` 必须是**进行中** —— 目标已完成/阻碍的区 **⛔ 不许被拉起**(用户口径第①条:完成就收工;第②条:阻碍也不用开)。 ② **⛔ 只读别区心跳、⛔ 不改别区任何文件**(不写它的 goal.json、不写它的队列)。 ③ 有 `guard.stop` 停止标志的区**⛔ 不拉**(那是"刚被收工",拉起来就是对抗用户的停)。 ⚠️ **失败一律吞掉**:本区自愈⛔不许因为别区的问题自己出事(fail-safe 方向=只管自己)。 ⚠️ 节流:每 `PEER_SWEEP_EVERY` 轮才真查一次(⛔ 不每轮都去 stat 别人的文件)。 """ peers = (C or {}).get("peer_workspaces") or [] if not isinstance(peers, list) or not peers: return "" out = [] for pws in peers: try: root = Path(str(pws)) if not root.is_dir(): out.append("%s=目录不存在" % root.name) continue # ① 目标状态闸:⛔ 非"进行中"一律不拉(完成=已收工,阻碍=按口径不关也不开) # 🔴🔴 2026-10-04 **改用单一事实源 `goal_life_of()`**(用户口径: # 「**禁止重复判定标准**」)。 # 病根(实测):这里原来**自己又判一遍** —— # `life = str(g.get("lifecycle") or "").strip(); if life != GOAL_LIFE_RUN` # ⇒ **精确匹配、不剥括号后缀** ⇒ 与 `goal_life()` 的结论**不一致**: # `已完成(机器可判部分)` 在 `goal_life()` 里能正确读成「已完成」, # 在这里却当成"未声明的怪值" ⇒ **同一份数据两套答案**。 # ✅ 正解=抽一个 `goal_life_of(root)`(本函数用的那份 `goal_life()` 的 # "指定工作区"版本),跨区判断与主判断**共用同一段算法**。 # 🔴🔴 2026-10-04 再改:**目标已完成 ⇒ 仍要拉**(用户口径: # 「检查程序必然要去处理队列的情况」)。 # 病根:只看 `life != 进行中` ⇒ 目标标了「已完成」后**队列里的活没人接** # (vibe-product 实测:队列 3 条 done 待核实,却永远不建检查会话)。 # ✅ 新判据=**两步**: # ① `life == 进行中` ⇒ 拉(原行为不变); # ② `life != 进行中` ⇒ 只在**该区队列还有非 `done` 件**时才拉 # (="活没干完" ⇒ 与目标状态无关,⛔ 不能因为目标收了工就不管队列)。 life = goal_life_of(root) if life != GOAL_LIFE_RUN: # ⛔ 已完成/阻碍/等待:先看它队列里是不是还有没干完的 # 🔴 用**唯一事实源** `queue_pending_of()`(⛔ 不在这里另写一段读文件的代码 # —— 那正是 `goal_life_of()` 当初被抽出来要治的病,用户口径「禁止重复判定标准」)。 _open_n = queue_pending_of(root) if _open_n <= 0: out.append("%s=目标%s 且队列无未完成件 ⇒ 不拉" % (root.name, life or "未声明")) continue out.append("%s=目标%s 但队列还有 %d 件未完成 ⇒ 仍拉(有活没人干)" % (root.name, life or "未声明", _open_n)) # ③ 停止标志闸:刚被"收工"的区 ⛔ 不许被拉回来 if (root / "tmp" / "supervise-inbox" / "guard.stop").exists(): out.append("%s=有停止标志 ⇒ 不拉" % root.name) continue # ② 只读它的心跳(⛔ 不写别区任何文件) hb_f = root / ".workbuddy" / "collab" / "logs" / "supervise-heartbeat.json" try: hb = json.loads(hb_f.read_text(encoding="utf-8")) except Exception: hb = {} _pid = hb.get("pid") _age = time.time() - float(hb.get("ts") or 0) if _pid and _age <= SUPERVISE_STALE and _pid_alive(_pid): out.append("%s=活(pid %s)" % (root.name, _pid)) continue # 掉了 ⇒ 用别区自己的副本补起(⛔ 传它自己的 COLLABD_CONFIG) # 🔴🔴 2026-10-05 **必须用 `PYW`(pythonw)**:此处原写 `sys.executable` ⇒ # 从 Bash/命令行跑 selftest 时解释器是 `python.exe`(控制台子系统) # ⇒ **每补起一个区就闪一个黑窗**(用户当场报「程序窗口一直弹出」)。 # ⛔ 别再写 `sys.executable` —— 与 `_win_pythonw()` 的既定口径不一致 # (同族坑:改一处漏另一处)。NO_WINDOW flag 只是补救,⛔ 不能替代 pythonw。 _py = PYW _cd = root / ".workbuddy" / "collab" / "collabd.py" _cfg = root / ".workbuddy" / "collab" / "collabd.config.json" if not _cd.is_file(): out.append("%s=副本不存在" % root.name) continue _env = dict(os.environ) _env["COLLABD_WORKSPACE"] = str(root) if _cfg.is_file(): _env["COLLABD_CONFIG"] = str(_cfg) _env.pop("COLLABD_NO_ENSURE", None) # 🔴 flags 照抄 `ensure_supervise()` 的既有写法(**逐字同款**): # NO_WINDOW(0x08000000) | DETACHED_PROCESS(0x8) | NEW_PROCESS_GROUP(0x200) # ⚠️ 漏了 NO_WINDOW 会**每 18 分钟闪一次黑窗**(本机 MiService 崩一次闪一次), # 且仓内自检 `t_subproc_no_window` 会假红(实测:漏了必报)。 _HIDE = 0x08000000 | 0x00000008 | 0x00000200 subprocess.Popen([_py, "-u", str(_cd), "--ensure"], env=_env, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, close_fds=True, creationflags=_HIDE) out.append("%s=已补拉" % root.name) except Exception as _e: out.append("%s=跳过(%s)" % (str(pws)[:24], _e)) return ("跨区自愈:" + "|".join(out)) if out else "" def ensure_supervise(why: str = "", verify: bool = True) -> dict: """**幂等续命**:常驻不在 ⇒ 就地起一条(本进程=宿主树内 ⇒ 继承网关口令)。 · ⛔ **不带口令时不起** —— 起了也投不出去(只会往 `NEED-USER.md` 写噪声)= 降级,不是解决。 · ⛔ 起法只有一种:本进程 fork `--supervise`,`stdin=DEVNULL` + `stdout/stderr` 全重定向到文件 (`DETACHED_PROCESS|NEW_PROCESS_GROUP|NO_WINDOW`;**不用** `CREATE_BREAKAWAY_FROM_JOB`——被拒)。 · 起失败/起后在 30 s 内不再重试(节流),⛔ 不刷屏、⛔ 不空转。 """ ok, det = supervise_alive() if ok: return {"ok": True, "spawned": False, "detail": det} # 🔴🔴 2026-10-03 **清掉停止标志再续命**(用户口径第①条后半句:「(**有新目标或之前的 # 目标有调整 在打开**)」⇒ 停得掉,也必须**起得来**)。 # 🔴 **不修会怎样**:`supervise_stop()` 写下的 `guard.stop` 是**常驻的退出条件** # (`_guard_says_stop()`)⇒ 不清它,`ensure_supervise()` 起一条**起来就退** # ⇒ 目标一完成就**再也起不回来**("完成"变成不可逆 ⇒ 与口径相反)。 # ✅ 只清 `guard.stop`(= "停"这条命令的标志); # ⛔ 不动 `guard.json`(那是守护心跳,本就该由守护自己管)。 _gs = INBOX / "guard.stop" if _gs.exists(): try: _gs.unlink() log("已清停止标志 guard.stop(目标有调整/新目标 ⇒ 允许重新拉起常驻)") except Exception as _e: log("清 guard.stop 失败:%s(⛔ 起得来但可能起来就退)" % _e) if CFG_MISSING: return {"ok": False, "spawned": False, "detail": "无部署配置 ⇒ 拒起"} if (os.environ.get("COLLABD_NO_ENSURE") or "").strip() in ("1", "true", "yes"): # 🔴 显式关闸(自测环境必须设它):否则测试工作区里会被起一条**真的**常驻, # 它会持续重写测试夹具(2026-10-02 实测:`已停总闸` 用例因此假红)。 return {"ok": False, "spawned": False, "detail": "COLLABD_NO_ENSURE=1 ⇒ 不起"} if not (os.environ.get("CODEBUDDY_GATEWAY_PASSWORD") or "").strip(): log_throttled("sup-no-token", 600, "常驻缺席(%s)且本进程无网关口令 ⇒ ⛔ 不起(避免起一条投不出去的常驻);why=%s" % (det, why)) return {"ok": False, "spawned": False, "detail": "no-token:" + det} stamp = INBOX / "_ensure.stamp" if _mt(stamp) and (time.time() - _mt(stamp)) < 30: return {"ok": False, "spawned": False, "detail": "节流中(30 s 内起过一次)"} try: stamp.parent.mkdir(parents=True, exist_ok=True) stamp.write_text(time.strftime("%Y-%m-%d %H:%M:%S"), encoding="utf-8") except Exception: pass HIDE = 0x08000000 | 0x00000008 | 0x00000200 # NO_WINDOW | DETACHED_PROCESS | NEW_PROCESS_GROUP try: SUP_OUT.parent.mkdir(parents=True, exist_ok=True) fh = open(SUP_OUT, "ab", buffering=0) fh.write(("\n===== supervise spawn %s by %s pid=%d(%s)=====\n" % (time.strftime("%Y-%m-%d %H:%M:%S"), why, os.getpid(), det)).encode("utf-8")) # 🔴🔴 2026-10-04 修「起后即退」:**必须把 `COLLABD_CONFIG` 显式传给孙进程**。 # 病根:子进程默认继承父进程 env,但 `--ensure` 走 `COLLABD_CONFIG` 这个**名字** # 传进来时,spawn 出去的孙进程只在 `os.environ` 里**看得到**—— # ⛔ 而本函数的 env 是**局部**变量(`dict(os.environ, ...)`),**没写回 os.environ** # ⇒ 孙进程拿不到 ⇒ `load_cfg()` 回落 `cwd/.workbuddy/collab/collabd.config.json` # 而 `cwd`=**副本目录**(`/.workbuddy/collab`)⇒ 那路径不存在 # ⇒ `CFG_MISSING` ⇒ `supervise()` 秒退 ⇒ 表现为「起了三次、活零次」。 _env = dict(os.environ) if not _env.get("COLLABD_CONFIG") and CFG_USED: # 🔴 真来源=**全局 `CFG_USED`**(`load_cfg()` 写的「实际读到的那份配置」) # ⛔ 不是 `C` 里的键 —— `C` 是**配置内容**字典,没有路径字段(我上一版编了一个) _env["COLLABD_CONFIG"] = str(CFG_USED).replace("\\", "/") # 🔴🔴 2026-10-05 修(**自我繁殖** bug —— 用户看到的"越创建越多"的直接成因): # 病根:起常驻时填的是 `Path(__file__).resolve()` = **"当前正在跑的那份"**。 # 若当前跑的是**技能目录那份** ⇒ 它续命又起技能目录那份 ⇒ **互为镜像、生生不息**; # 且因为 `_pid_is_collabd()` 的基准同样被顶着 ⇒ **真的本区常驻反被判"身份对不上"** # ⇒ 每轮 `--ensure` 都认为"没人跑" ⇒ **再拉一条**。实测日志: # `常驻续命:新起 --supervise pid=69464(原因:pid 12672 在、心跳新鲜,但身份对不上)` # ✅ 正解=**发布物优先**:一律起本区 `.workbuddy/collab/collabd.py`(见 `_own_path()`)。 # 副本不在位才回落当前这份,并**打印告警**(⛔ 静默回落=用户以为跑的是副本)。 _tgt = _own_path() if _tgt.name != "collabd.py" or not _tgt.is_file(): log("⚠️ 本区发布物缺失 ⇒ 常驻续命回落到当前这份:%s(⛔ 可能自我繁殖)" % _tgt) p = subprocess.Popen([PYW, "-u", str(_tgt), "--supervise"], stdin=subprocess.DEVNULL, stdout=fh, stderr=fh, cwd=str(_tgt.parent), env=_env, creationflags=HIDE, close_fds=True) except Exception as e: log("常驻续命失败:%r" % (e,)) return {"ok": False, "spawned": False, "detail": "spawn-error:%r" % (e,)} log("常驻续命:新起 --supervise pid=%d(原因:%s;why=%s)" % (p.pid, det, why)) if not verify: return {"ok": True, "spawned": True, "pid": p.pid, "detail": "已起(未回验)"} for _ in range(12): time.sleep(0.4) if not _pid_alive(p.pid): return {"ok": False, "spawned": True, "pid": p.pid, "detail": "起后即退(看 supervise.out.log)"} if float(read_heartbeat().get("ts") or 0) > 0: ok2, det2 = supervise_alive() return {"ok": bool(ok2), "spawned": True, "pid": p.pid, "detail": det2} return {"ok": False, "spawned": True, "pid": p.pid, "detail": "约 5 s 内未出心跳"} # ── 🔴🔴 常驻**自我供给**:就地起撑不住时,转去建本区的计划任务 ────────────── # 🔴🔴 2026-10-05 **已删除 `_find_keeper_tpl()` 与 `assets/start-supervise.ps1.tpl` 依赖**。 # 原因:本函数唯一用途是给 `_escalate_to_keeper()` 找 **PowerShell 看守模板** # (`start-supervise.ps1.tpl`);而该形态已**整套废弃**(见下面 `_escalate_to_keeper` # 的形态收敛说明:任务动作改 `pythonw.exe` + `supervise-launch.py`,零控制台 ⇒ 不闪窗)。 # ⇒ 本函数**零调用点**、模板文件也不再被读;留着只会让「副本里找不找得到模板」 # 这类**过期判据**继续误红。⛔ 不是"删了省事",是消灭一条**已失效的判据源**。 # # ⚠️ 考古价值仍保留在 `references/pitfalls.md`(P0-57 同族:副本形态下写死包根必红); # 若将来又要"让副本自带载体模板",**照 pitfalls 那条重写**,⛔ 别从 git 历史里翻回来。 def _escalate_to_keeper(detail: str = "") -> str: """**各工作区自我建立常驻**的落点(2026-10-04 用户口径,逐字): > 「**我需要的是各工作区会话 完成目标时 自己能建立 常驻程序**」 ## 为什么必须有它(病根,逐层已实测) 1. `ensure_supervise()` 用 `Popen(... DETACHED_PROCESS ...)` 起常驻 —— `DETACHED_PROCESS` **只脱离控制台、⛔ 不脱离作业对象** ⇒ 仍挂会话树 ⇒ **会话一收工就被回收**; 2. 唯一合格载体=**计划任务**(父链根 `svchost -s Schedule`,与会话树无关;看板实测活 634+ 分钟); 3. 而机制原来**没有"自己建任务"这一步**(`init_workspace.py` 只铺脚本、⛔ 不登记) ⇒ 结果="只有某个区能常驻"。 ## 做什么(幂等、只碰本区) 🔴🔴 **2026-10-05 形态收敛(⛔ 已废弃旧的 PowerShell 看守)**: ① ~~铺/刷新本区 `start-supervise.ps1`~~ **⛔ 不再做** —— 旧形态任务动作是 `powershell.exe -WindowStyle Hidden -File start-supervise.ps1`,而 PowerShell 是 **控制台程序** ⇒ 每次触发都分配 `conhost.exe` ⇒ **闪黑窗**(用户 2026-10-05 报 「又弹了窗口」,实测抓到 `powershell.exe` + `conhost.exe` 这对进程)。 ② 登记计划任务 `collabd-keepalive-<区名>` —— 动作已改指 **`pythonw.exe` + `<区>/.workbuddy/collab/supervise-launch.py`**(GUI 子系统, **零控制台** ⇒ 不闪窗),立即启动; ③ 回读任务状态做**行为级验收**(⛔ 不满足于"命令没报错")。 ## ⛔ 边界 · 只登记**本区**那一条(任务名带区名)⇒ 各区互不干扰; · ⛔ 不代用户开目标、⛔ 不建会话、⛔ 不动别的区; · 缺 `guard.stop` 语义照旧(目标完成 ⇒ keeper 见标志即收工,⛔ 不空转重拉)。 """ try: # 🔴 2026-10-05:⛔ **不再依赖 `assets/start-supervise.ps1.tpl`**(旧 PowerShell 看守模板已废)。 _cdir = Path(WS) / ".workbuddy" / "collab" _logs = _cdir / "logs" _tmp = Path(WS) / "tmp" for _d in (_cdir, _logs, _tmp): _d.mkdir(parents=True, exist_ok=True) # 🔴🔴🔴 2026-10-05 收敛:**新形态(⛔ 不再用 PowerShell 看守)** # 旧形态=铺 `start-supervise.ps1` + 任务动作 `powershell.exe -WindowStyle Hidden -File …` # ⇒ PowerShell 是**控制台程序** ⇒ 每次触发分配 `conhost.exe` ⇒ **闪黑窗** # (用户 2026-10-05 报「又弹了窗口」,实测抓到这对进程)。 # ⇒ 与新定案形态(计划任务 → `pythonw.exe` → 启动器)**必须同一个**: # 动作指 **`supervise-launch.py`**(进程内设 `CODEBUDDY_CONFIG_DIR` 等,见 P0-73)。 # ⚠️ 旧 ps1 文件**不再重铺**(留着不影响;⛔ 但不再由本函数产生)。 _launcher = _cdir / "supervise-launch.py" if not _launcher.is_file(): return "⛔ 缺启动器 supervise-launch.py(%s)⇒ 未登记(请先跑 deploy_code.py)" % _launcher # 🔴 pythonw 必须**同版本**(⛔ 用 sys.executable 推不出同目录 pythonw) _pyw = Path(sys.executable).with_name("pythonw.exe") if not _pyw.exists(): _cands = sorted(Path(sys.executable).parent.parent.parent.glob("*/pythonw.exe")) _pyw = _cands[-1] if _cands else Path(sys.executable) # 🔴🔴 2026-10-06 二次收口:**任务名与触发形态都对齐 `collabctl.py`**。 # 原因(第三处「同一种东西有第二条起法」,本轮实测抓到): # 本函数原建 `collabd-supervise-<区>`,而 `collabctl.py` 建 `collabd-keepalive-<区>` # ⇒ 两个名字**各自对应一套形态**,且 `collabctl.py` 把 `collabd-supervise-*` # 列入 `SCHED_TASKS`(**一律禁用**)⇒ **本函数自我供给起的常驻,会被下一次 # `collabctl off` 当"旧形态"干掉** ⇒ 自愈链断在这里。 # ⇒ 统一到 `collabd-keepalive-<区>`(`KEEPALIVE_TASKS` 的名单口径): # `off` 认得它、`on` 会重建它 ⇒ **开关能真正管住这条自愈路径**。 # ⚠️ 触发形态也一并对齐:`-AtLogOn`(只在登录起)→ **每 5 分钟重复触发** # (常驻中途死了要等下次登录 = 不自愈;5 分钟兜底才是既定口径)。 # ⚠️ 旧的 `collabd-supervise-<区>` 若已存在 ⇒ **不在这里删**(清理归 `collabctl` 的 # `SCHED_TASKS` 禁用名单,那块已覆盖)。 _task = "collabd-keepalive-%s" % (Path(WS).name or "ws") _ps = ( "$ErrorActionPreference='Stop';" "$n='%s';" "if(Get-ScheduledTask -TaskName $n -ErrorAction SilentlyContinue){" "Unregister-ScheduledTask -TaskName $n -Confirm:$false};" # 🔴 动作=pythonw + 启动器:**零控制台、零 PowerSHell** ⇒ 不闪窗 "$a=New-ScheduledTaskAction -Execute '%s' " "-Argument '\"%s\"' " "-WorkingDirectory '%s';" # 🔴 每 5 分钟重复触发(与 `collabctl.py` 同口径) "$t=New-ScheduledTaskTrigger -Once -At (Get-Date) " "-RepetitionInterval (New-TimeSpan -Minutes 5);" # 🔴 ExecutionTimeLimit=0(长驻进程 ⛔ 不能被调度器到点掐死) # ⚠️ 去掉 `RestartCount 999 / RestartInterval 1min` —— 每 5 分钟重复触发 # 本身就提供重拉;每分钟重启是多余的抖动(P0-74 记过"反复"的成因)。 "$s=New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries " "-DontStopIfGoingOnBatteries -StartWhenAvailable " "-ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew -Hidden;" "$pr=New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Highest;" "Register-ScheduledTask -TaskName $n -Action $a -Trigger $t -Settings $s " "-Principal $pr -Force|Out-Null;" "Start-ScheduledTask -TaskName $n;" "$i=Get-ScheduledTaskInfo -TaskName $n;" "'OK State=' + (Get-ScheduledTask -TaskName $n).State + ' Last=' + $i.LastTaskResult" ) % (_task, str(_pyw), str(_launcher), str(WS)) _r = subprocess.run(["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", _ps], capture_output=True, timeout=180, creationflags=0x08000000) # CREATE_NO_WINDOW(⛔ 缺了会闪黑窗) _o = _r.stdout.decode("utf-8", "replace").strip() _e = _r.stderr.decode("utf-8", "replace").strip() if _o.startswith("OK"): return "✅ 已建本区计划任务 `%s`(%s)" % (_task, _o[3:].strip()[:80]) return "⛔ 登记失败:%s" % ((_e or _o or "无回显").splitlines()[0][:150]) except Exception as _ex: return "⛔ 自我供给异常:%r" % (_ex,) def _ps_syntax_error(ps1) -> str: """验一份 `.ps1` 是不是**合法 PowerShell**('' = 没问题)。⛔ 不查关键字,真解析。 ⚠️ 与 `init_workspace.py::_ps_check` 同一判据(P0-56 事故): 模板曾被打成「三连双引号 + #」开头 ⇒ 生成物 9 处语法错、秒退, 而 BOM 在、变量填完了 ⇒ **表层自检全绿**。 """ _cmd = ( "[Console]::OutputEncoding=[Text.Encoding]::UTF8;" "$e=$null;$t=$null;" "[System.Management.Automation.Language.Parser]::ParseFile('%s',[ref]$t,[ref]$e)|Out-Null;" "if($e -and $e.Count -gt 0){'ERR '+$e.Count+' @L'+$e[0].Extent.StartLineNumber+': '+$e[0].Message}" "else{'OK'}" ) % str(ps1).replace("'", "''") try: _r = subprocess.run(["powershell.exe", "-NoProfile", "-Command", _cmd], capture_output=True, timeout=60, creationflags=0x08000000) # CREATE_NO_WINDOW _o = _r.stdout.decode("utf-8", "replace").strip() if _o.startswith("OK"): return "" if _o.startswith("ERR"): return _o[4:].splitlines()[0].strip()[:80] return "语法没验成(回显异常)" except Exception as _e: return "语法没验成(%r)" % (_e,) def _db(): cfg_dir = os.environ.get("CODEBUDDY_CONFIG_DIR") or os.path.expanduser("~/.workbuddy") db = Path(C["host_db"]) if C["host_db"] else Path(cfg_dir, "workbuddy.db") return sqlite3.connect("file:%s?mode=ro" % str(db).replace("\\", "/"), uri=True, timeout=4) # ── 只读取数(宿主库) ─────────────────────────────────────── def fetch() -> dict: d = {"runs": [], "wm": 0, "pending": [], "upcoming": [], "soon": 0, "working": [], "self_working": False, "sessions": [], "locks": [], "due": [], "have": set(), "sessions_hidden": {}} now_ms = int(time.time() * 1000) try: con = _db() rows = list(con.execute("select rowid,status,thread_title,automation_id from automation_runs " "order by rowid desc limit 8")) d["runs"] = [{"rowid": r[0], "status": r[1], "title": (r[2] or "").strip(), "aid": (r[3] or "")[:8]} for r in rows] d["wm"] = max([r["rowid"] for r in d["runs"]], default=0) for r in con.execute("select id,name,cwds,schedule_type,next_run_at,scheduled_at from automations " "where status='ACTIVE' and (deleted_at is null or deleted_at='')"): # 🔴 2026-09-29 修(P1·实测):原实现把「ACTIVE ∧ next_run_at>now」全算 `pending`, # 而两条**周期**自动化(日报 / 体检)恒满足 ⇒ `pending` 恒非空 ⇒「没有会话在跑」 # 那条分支**永远不可达** ⇒ `STALL.md` 从未生成(实测两日零生成)。⇒ 拆开: # · `pending` = **一次性**排活(刚建好、还没跑的下一棒 ⇒ 用于判「只在排活、未见成果」) # · `upcoming` = **周期**自动化(⛔ 只展示,**不代表有人在跑**) if (r[4] or 0) > now_ms: if (r[3] or "").lower() == "recurring": d["upcoming"].append(r) else: d["pending"].append(r) if (r[4] or 0) <= now_ms + 60 * 60 * 1000: # 🔴 「**未来 1 小时内**会不会有人自己动起来」—— 这才是判"确定性静默"的口径。 # ⛔ 不是"全库有没有排期":日报 / 体检那两条明天才跑,与**本线**毫无关系 # (实测 16:34→20:07:全库有 2 条排期,本线 0 条,于是静默 4 小时)。 d["soon"] += 1 if r[5]: # `r[0]`=`automations.id`(uuid)· `r[1]`=name · `r[5]`=scheduled_at d["due"].append((str(r[0]), r[1] or "", r[5])) # 🔴 2026-09-29 修(P1·实测):**唯一可得的「有人在跑」信号 = `sessions.status='working'`**。 # ⛔ 不能用 `automation_runs.status`(实测 54 行**全 ACCEPTED**、零 IN_PROGRESS) # ⛔ 不能用一次性棒的 `next_run_at`(实测**全是 0/None**,跑完即归零) for r in con.execute("select id, coalesce(nullif(custom_title,''),title,'(未命名)'), " " coalesce(last_activity_at, updated_at), coalesce(cwd,'') " "from sessions where status='working'"): # 🔴 2026-09-29:**排除观察者自己**。否则「主会话自己在跑」也会被算成「有人在推进」 # ⇒ 停滞 / 真空永远发现不了(用户问「是不是又发呆了」答不出来的根因之一)。 wid = str(r[0] or "") if SELF_SID and wid.lower().startswith(SELF_SID[:8].lower()): d["self_working"] = True continue d["working"].append({"id": wid, "name": str(r[1] or "")[:34], "at": r[2], "cwd": str(r[3] or "")}) # 🔴 2026-10-02 修(S11):`have` 不只是"跑过没有",还要**首次运行时刻** —— # 否则「延迟 N 分钟」只能拿 `now - scheduled_at` 顶替,那是**距今**不是**延迟** # (实测会把 30 小时前跑完的排期写成「延迟 1954 分钟」⇒ 文案二次造假)。 # ⚠️ `automation_runs.updated_at` 实测是**毫秒 epoch 字符串**。 _ran = {} for r in con.execute("select automation_id, min(updated_at) from automation_runs " "where automation_id is not null and automation_id<>'' " "group by automation_id"): try: _ran[str(r[0])] = int(r[1]) / 1000.0 except Exception: _ran[str(r[0])] = 0.0 d["have"] = _ran # 🔴 2026-09-29(回架构·去自造状态源):**「多久没成果」的权威在宿主库里** —— # `automation_runs.updated_at`(实测有该列,毫秒 epoch,形如 '1790667377928')。 # ⛔ 不要让程序自己维护 `last_progress_at` 这类时钟(那就是"第二状态源":会漂、要清理、 # 丢了就永久失真 —— 今天整天的毛病都长在这类自造状态上)。 try: row = con.execute("select updated_at from automation_runs " "where status='ACCEPTED' and coalesce(thread_title,'')<>'' " "order by updated_at desc limit 1").fetchone() if row and row[0]: v = float(row[0]) d["last_result_at"] = v / (1000.0 if v > 1e11 else 1.0) except Exception as e2: log("last_result_at 取失败 %s" % e2) # ⚠️ sessions 表没有 name 列(正确列:title/custom_title/last_activity_at/unread)——踩过一次 # 🔴 2026-10-01 改:**多取一些行再筛**(原 `limit 10` 是"取完就摊")—— # 现在要先剔除「已退场 / 超窗口」的旧棒,再摊 ⇒ 取 10 行会被旧棒占满、真在跑的反而漏掉。 _live_min = float(C.get("session_live_min") or 90) _ret = _retired() _hidden = {"retired": 0, "stale": 0, "live_min": _live_min} for r in con.execute( "select id, coalesce(nullif(custom_title,''), title, '(未命名)'), " " coalesce(last_activity_at, updated_at), status, unread from sessions " "order by coalesce(last_activity_at, updated_at) desc limit 24"): ts, t = r[2], 0.0 if isinstance(ts, (int, float)): t = float(ts) / (1000.0 if float(ts) > 1e11 else 1.0) elif isinstance(ts, str) and ts: try: t = time.mktime(time.strptime(ts[:19], "%Y-%m-%dT%H:%M:%S")) except Exception: t = 0.0 _age = (time.time() - t) / 60 if t else 1e9 _st = str(r[3] or "") # 🔴 退场筛(治"接续棒越堆越多"):**显式退场**优先;② **窗口兜底**只吃 `completed`。 # ⚠️ **`working` 永不被窗口吃掉**(在跑的一定要摊在版面上)。 # ⚠️ 本列表是**原始会话快照**(不限本项目)⇒ 主会话若在窗口外也会从这里落下; # 这**不影响**看板的「主会话」位 —— 那位由 `board.py` 的**登记判据**决定(⛔ 不看窗口)。 if _st != "working": if is_retired(str(r[0] or ""), _ret): _hidden["retired"] += 1 continue if _st == "completed" and _age > _live_min: _hidden["stale"] += 1 continue if len(d["sessions"]) >= 10: # 摊出来的仍只 10 条(与旧版同量) continue d["sessions"].append({"id": str(r[0] or "")[:8], "name": str(r[1] or "")[:36], "age": _age, "status": _st, "tag": ("未读 %s" % r[4]) if r[4] else _st}) d["sessions_hidden"] = _hidden con.close() except Exception as e: log("fetch err %s" % e) return d def probe_port() -> bool: if not C["shim_port"]: return True try: s = socket.create_connection(("127.0.0.1", int(C["shim_port"])), timeout=1.5) s.close() return True except Exception: return False def targets() -> tuple[bool, list[str]]: rows, ok_all = [], True for line, items in (C["targets"] or {}).items(): for label, pat in items: base = Path(WS).parent / line if not Path(line).is_absolute() else Path(line) hits = sorted(base.glob(pat), key=_mt) if pat else [] ok = bool(hits) ok_all = ok_all and ok rows.append("- %s **%s** %s" % ("✅" if ok else "⛔", line, label)) return ok_all, rows # ── 任务图(防干等) ──────────────────────────────────────── def taskgraph(cur: list, busy: bool) -> dict: try: g = json.loads(TG.read_text(encoding="utf-8")) except Exception: return {} raw = g.get("nodes", []) # 🔴🔴 2026-10-01 加(治「上报完成 ⇒ 队首不消失」)—— **台账优先**。 # 病根:`tasks.json` 的注释自称「**唯一权威**(队列本体)」,会话「上报完成」也只写它; # 但**本仓没有任何代码回写 `交付物/任务图.json`**(全仓 grep:TG 只被读、从不被写) # ⇒ 任务图是**纯手维护**的分解件 ⇒ 两边必然漂。 # 实测(M5):台账 done、上报单 done、产物已核对(wakeups.jsonl 第 82 行 ok:true), # 任务图仍留 `running` ⇒ ① 它永远是队首 ② `READY.md`/`STALL.md` 每小时喊同一件白跑 # ③ 控制台判「目标未完成」⇒ 整条链被一个**早已做完的件**卡死。 # ⇒ 判定改用**有效状态**:`图 done ∪ 台账 done` 都算 done(`deps` 解析同理)。 # ⛔ 不回写任务图(它仍是人可手改的分解件);只让「算不算做完」以台账为准。 try: _led = _load_tasks() except Exception: _led = {} def _eff(n): if (n.get("status") or "") == "done": return "done" return "done" if str(((_led.get(n.get("id")) or {}).get("state")) or "") == "done" \ else (n.get("status") or "") eff = {n.get("id"): _eff(n) for n in raw} ns = {n.get("id"): n for n in raw} out = {"ready": [], "waiting": [], "critical": list(g.get("critical_path") or []), "waste": False, "nodes_raw": raw} for n in raw: if eff.get(n.get("id")) == "done": continue undone = [x for x in (n.get("deps") or []) if eff.get(x) != "done"] (out["waiting"] if undone else out["ready"]).append( (n.get("id"), (n.get("title") or "")[:40], n.get("line"), undone) if undone else (n.get("id"), (n.get("title") or "")[:40], n.get("line"), n.get("status"))) # 🔴 2026-09-29 加:**受阻件不算「可派」** —— 否则 `READY.md` 与机器摘要会把受损件当 # 「可派未派(浪费)」反复喊,与 `queue.json` 的 blocked **自相矛盾**(实测:N9 被标受阻后, # READY.md 仍在喊「N9 可派」)。 try: blk = json.loads(BLOCKED.read_text(encoding="utf-8")) if BLOCKED.exists() else {} if not isinstance(blk, dict): blk = {} except Exception: blk = {} if blk: out["blocked"] = [(r[0], blk.get(r[0], "")) for r in out["ready"] if r[0] in blk] out["ready"] = [r for r in out["ready"] if r[0] not in blk] out["waste"] = bool(out["ready"]) and not cur and not busy return out # ── 判定 ──────────────────────────────────────────────────── def verdicts(d: dict, up: bool, st: dict) -> dict: now = time.time() fresh = [r for r in d["runs"] if r["rowid"] > int(st.get("rowid") or 0)] done = [r for r in fresh if r["status"] == "ACCEPTED" and r["title"]] started = [r for r in fresh if r not in done] # 🔴 2026-09-29 修:原写成 `... if st.get("aids") else []` ⇒ `aids` 一旦为空(实测刚发生过) # 就**永远判不出「新增排活」** ⇒「只在排活、未见成果」这条警戒失效。改成对空集比较。 newp = [r for r in d["pending"] if str(r[0]) not in set(st.get("aids") or [])] # 🔴 2026-09-29 修(P3·实测):探针**翻转不是推进** —— 原实现任何翻转(含 通→不通)都重置 # `last_progress_at` ⇒ 探针每次抖动都把「无成果时长」清零 ⇒ 卡住 / 脱节告警**永不触发**。 # 🔴 2026-09-29 再修(P3b · 20:07 实测):**「恢复」也不得重置** —— 16:31→20:07 整段停滞里 # 程序只跑过一轮(20:07),恰好赶上探针 不通→通 ⇒ idle 被清成 **1 分钟**,摘要写「1 分钟无 # 成果」而不是「4.5 小时」⇒ **连喊一声都没有**。⇒ `idle` **只认真成果**;探针状态变化降级为 # 后置说明(服务层,⛔ 不算成果、⛔ 不清零)。 rec = (st.get("up") is False and up is True) lost = (st.get("up") is True and up is False) # 🔴 权威顺序:本轮有新成果 ⇒ now;否则 **宿主库最后一次有结论的 run**(`last_result_at`); # ⛔ 只有读库失败时才退回 `state.last_progress_at`(那是旧的自维护时钟,已降级为兜底, # 且一取到库值就被忽略)。 src_pa = float(d.get("last_result_at") or 0) if done: pa = now elif src_pa: pa = src_pa else: pa = float(st.get("last_progress_at") or now) idle = (now - pa) / 60 def _dur(m): return ("%.0f 分钟" % m) if m < 90 else ("%.1f 小时" % (m / 60.0)) if done: v, ic = "🟢 **真成果** —— %d 条有结论的完成" % len(done), "ok" elif newp: v, ic = "🟡 **只在排活,未见新成果**(新增 %d 条接续任务·排期≠成果)" % len(newp), "wait" elif d["working"]: v, ic = "🟡 有会话在跑,暂无新成果(在跑 %d 个;%s 无成果)" % ( len(d["working"]), _dur(idle)), "wait" else: v, ic = "🔴 **中断/脱节** —— 没有任何会话在跑,且 %s 无成果" % _dur(idle), "stall" if rec or lost: v += ";探针 %s(服务层变化,⛔ 不算成果、不清零)" % ("不通→通" if rec else "通→不通") vs = float(st.get("vacuum_since") or 0) vac = (not up) and not busy_(d) vs = (vs or now) if vac else 0.0 # 🔴 2026-09-29(回架构):**「零排期」是一等事实,不是背景噪音**。 # 地基(顶层设计 §1/§2):宿主不提供常驻 ⇒ 一切"等待"只能靠**宿主排期** ⇒ # **排期数 = 0 ⇒ 没人发消息时"确定性静默"**(实测 16:31→20:07 四小时零运行,根因即此)。 # ⇒ 单独成一档告警:不等 idle 攒够,直接喊。 zero_sched = int(d.get("soon") or 0) == 0 if zero_sched and not d["working"] and not done: v += ";⛔ **未来 1 小时内零排期** ⇒ 不等人发消息就是**确定性静默**" ic = "stall" alert = None if zero_sched and not d["working"] and not done: alert = "确定性静默(未来 1 小时内**零排期** + 无人接活)" elif ic == "stall" and idle > float(C["idle_min"]): alert = "脱节(无活无人)" if not d["working"] else "中断(有活没人接)" elif ic == "wait" and idle > float(C["stuck_min"]): alert = "卡住(有会话在跑但 %.0f 分钟无成果%s)" % (idle, ",且探针不通" if (lost or not up) else "") return {"fresh": fresh, "done": done, "started": started, "newp": newp, "up": up, "busy": busy_(d), "verdict": v, "icon": ic, "idle": idle, "alert": alert, "vacuum": vac, "vdur": (time.time() - vs) / 60 if vs else 0.0, "vs": vs, # ★ 2026-10-01 加:`digest_text()` 要按「段落」重讲这两条事实 —— 原实现只把它们 # 拼进 `verdict` 的 `;`/`⇒` 长串里 ⇒ 必须把**原始事实**带出来,⛔ 不靠字符串反解。 "zero_sched": zero_sched, "probe": ("rec" if rec else ("lost" if lost else "")), "pa": pa, "wm": d["wm"], "cur": [str(r[0]) for r in d["pending"]]} def busy_(d: dict) -> bool: """🔴 2026-09-29 修(实测):原实现两个信号**恒为假** —— `d["locks"]` 从未被填充; `automation_runs.status` 实测 54 行**全 ACCEPTED**(零 IN_PROGRESS)⇒ `busy` 恒 False ⇒ ① 体检 `health()` 从不跳过 ② `真空` / `可派未派` 判定失真。 改用实测唯一可得的「有人在跑」信号:`sessions.status='working'`(含本会话 ⇒ 语义正确: 「有会话在执行」就不算真空)。""" return bool(d["locks"]) or bool(d.get("working")) def health(d: dict, V: dict) -> dict: # 🔴 2026-10-03 修(S12 · 解真死锁):**⛔ 不再因 `busy` 早退**。 # 实测病根(`目标-本机协作-3e3182/S12_体检刷新解锁_20261003.md`): # `sessions.status` 取值域**只有 `working/completed/error/archived`,⛔ 没有"空闲"档** # (见本文件 1861-1882 行的实测记录)⇒ **只要工作区里存在任意一条活着的会话**, # `V["busy"]` 恒为真 ⇒ `health()` 每轮都早退 ⇒ 体检报告永不刷新。 # 实测后果:报告 mtime 停在 2026-10-02 20:50:22(跨日不刷新), # 而依赖「体检报告确认」的验收判据(任务图 S12 第二条)**永远无法过**。 # ⚠️ 曾经的假设「调用方把自己算进 busy 导致自锁」**经实测不成立**: # `fetch()` 第 495-497 行的 `SELF_SID` 排除**本来就生效** # (探针读数 `self_working=True`、`d["working"]` 里没有自己)⇒ 不必再改 `SELF_SID`。 # ⛔ 判据本身(哑火/延迟/关键件缺失/锁摩擦/并发 IN_PROGRESS)**一条未动** —— # 改的只是「什么时候执行体检」,⛔ 不是放宽判据。 # 并发安全改由**原子替换写盘**保证(`os.replace`),⛔ 不靠「忙时别写」这种时序运气。 out = {"skipped": False, "issues": [], "notes": []} # 🔴 2026-10-02 修(S11 · 判据纠偏):「哑火」必须同时满足三条,缺一条就是假红。 # 实测病根(`交付物/排期哑火真因与堆积治理-20261002.md`):本机一次性排期**不是到点即时触发** # 而由主机轮询/唤醒**补跑**(`runKind=missed`),实测延迟 2.0~9.7 分钟 ⇒ 15 分钟容差不为 0, # 但**原判据把「补跑」和「哑火」混成一类**(文案只写「到点未触发」), # 且把**已跑完却仍挂 ACTIVE 的一次性排期**(实测 30 条)继续当候选。 # ⇒ 改为:① 容差可配 `misfire_grace_min`(默认 30,覆盖实测最坏 9.7 分钟并留余量) # ② 有任何运行记录 ⇒ 改写为「延迟 N 分钟跑完」,⛔ 不喊哑火 # ③ 零运行记录且超容差 ⇒ 才是哑火 grace = float(C.get("misfire_grace_min") or 30) cut = time.strftime("%Y-%m-%dT%H:%M", time.localtime(time.time() - grace * 60)) late = [] for aid, nm, sat in d["due"]: s = (sat or "")[:16] if not s or s >= cut: continue if aid in d["have"]: # 到点后跑过(⛔ 含补跑 `runKind=missed`)⇒ 报**真实延迟**而不是哑火。 # ⚠️ 延迟 = 首次运行时刻 − 计划时刻(⛔ 不是 `now − 计划时刻`,那是"距今")。 lat = _delay_min(sat, d["have"][aid]) late.append("`%s`(%s)%s" % (aid[:8], nm[:24], ("延迟 %d 分钟跑完" % lat) if lat >= 0 else "已跑完(运行时刻读不出,⛔ 不猜延迟)")) continue out["issues"].append("**哑火**:`%s`(%s)到点超 %d 分钟仍无任何运行记录" % (aid[:8], nm[:30], int(grace))) out["notes"] = late f = sum(1 for r in d["runs"] if "抢锁失败" in (r["title"] or "")) if f >= 3: out["issues"].append("近 %d 条里 %d 次抢锁失败(摩擦,非故障)" % (len(d["runs"]), f)) if len([r for r in d["runs"] if r["status"] == "IN_PROGRESS"]) > 1: out["issues"].append("同时多个 IN_PROGRESS(疑似并发)") for nm, rel in (C["goal_docs"] or []): if not (WS / rel).exists(): out["issues"].append("**关键件缺失**:%s" % nm) return out def _late_min(sat) -> float: """排期时间 → 距今分钟数(⛔ 解析不了就返回 -1,不猜)。`scheduled_at` 实测只到分钟。""" s = str(sat or "") for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M"): try: t = time.mktime(time.strptime(s[:len(time.strftime(fmt))], fmt)) return max(0.0, (time.time() - t) / 60.0) except Exception: continue return -1.0 def _delay_min(sat, ran_at) -> float: """**真实延迟** = 首次运行时刻 − 计划时刻(分钟)。 🔴 2026-10-02(S11):⛔ 不可用「距今」冒充「延迟」—— 排期跑完几小时后体检再跑一次, 「距今」会是几十小时 ⇒ 文案变成二次造假。读不出任一端 ⇒ 返回 -1,调用方写「不猜」。 """ s = str(sat or "") base = None for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M"): try: base = time.mktime(time.strptime(s[:len(time.strftime(fmt))], fmt)) break except Exception: continue if base is None or not ran_at: return -1.0 return max(0.0, (float(ran_at) - base) / 60.0) # ── 自愈(带去抖) ─────────────────────────────────────────── QUEUE, QUEUE_MD = INBOX / "queue.json", INBOX / "queue.md" CLAIMS, STALE = INBOX / "claims", INBOX / "claims-stale" DOING_TTL = 20 * 60 # 秒:doing 超过多久没人续 ⇒ 判卡死并回退(⛔ 判活优先,见 _reap_dead) BLOCKED = INBOX / "blocked.json" # {"<节点id>": "<受阻原因·谁在等>"} —— 受阻件 ⛔ 不当队首 _SID_RE = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") SID_DEAD = {"completed", "error", "archived"} # 实测取自 sessions.status 取值域 SELF_SID = os.environ.get("CODEBUDDY_SESSION_ID", "") or "" # 观察者自己 —— 判「有没有人在推进」时 ⛔ 必须排除 # 🔴 主会话的**显式前缀**(用户 2026-09-30 定名:「主会话前缀 可以叫 主控」)。 # ⚠️ 实测现役主会话写成 `主控 · 协作机制 · …` —— **中点分隔、无方括号** # ⇒ `parse_session_name()` 必须把这种形态也认成 main(否则主会话被判"角色未知")。 MAIN_PREFIX = "主控" def _reap_dead(doing: dict) -> dict: """认领持有人**是否还在**(🔴 P5 治本:claims 与「人」绑定)。 契约:`claims//holder` = `<会话名>@<线>@<完整会话id>`(第 3 段可省)。 · holder 里有会话 id ⇒ 查宿主库;状态是**终结态**(completed/error/archived)⇒ 立即把该 claim 移到 `claims-stale/`(⛔ 不删,可追溯)。实测病根:棒已终结、claim 还在 ⇒ 队首被僵尸占住; 等 20 分钟 TTL 回退后队首又**复活** ⇒ 同一件被反复派(N17、N9 各复现一次)。 · ⛔ 无会话 id / 读库失败 / 状态未知 ⇒ **一律不猜**,交给 TTL 兜底(行为=修前)。 """ if not doing: return doing sids = {} for k, v in doing.items(): m = _SID_RE.search(v or "") if m: sids[k] = m.group(0).lower() if not sids: return doing try: con = _db() rows = list(con.execute("select id, status from sessions")) con.close() except Exception as e: log("reap 跳过(宿主库读不到 ⇒ 回退 TTL):%s" % e) return doing smap = {str(r[0] or "").lower(): str(r[1] or "").lower() for r in rows} for k, s in list(sids.items()): if smap.get(s) not in SID_DEAD: continue # 在跑 / 状态未知 ⇒ 不动 try: STALE.mkdir(parents=True, exist_ok=True) p = CLAIMS / k if p.is_dir(): p.rename(STALE / ("%s-持有会话已终结-%s" % (k, time.strftime("%H%M%S")))) doing.pop(k, None) log("claim 出队 %s(持有会话 %s = %s)" % (k, s[:8], smap.get(s))) except Exception as e: log("reap 移出失败 %s:%s" % (k, e)) return doing def queue_view(T: dict, d: dict | None = None) -> dict: """🔴 **严格队列**(2026-09-29 用户要求:"严格用队列的方式处理,避免打架,处理好一个再处理下一个")。 语义(与"域锁"分工不同:**队列管"谁做下一件";域锁管"能不能动这个资源"**): ① **调度串行** —— 一次只呈现**一个队首**;取件必须**原子**(`mkdir claims/` 成功者得) ② **同线互斥、跨线并行** —— 队列里标 `line`:同线已有 doing ⇒ 该线其他件**不呈现为队首** ③ **卡死回退** —— claim 目录超 `DOING_TTL` 未被续 ⇒ 移到 `claims-stale/`(⛔ 不删,可追溯)并记日志 ④ ⛔ 本程序**不派活**:只"排队 + 呈现队首 + 兜卡死";**取件与派活由会话做**。 """ try: CLAIMS.mkdir(parents=True, exist_ok=True) except Exception: pass now = time.time() # ① 卡死回退(只看 claim 目录的 mtime) for c in list(CLAIMS.iterdir()) if CLAIMS.exists() else []: try: if c.is_dir() and now - c.stat().st_mtime > DOING_TTL: STALE.mkdir(parents=True, exist_ok=True) c.rename(STALE / c.name) log("claim stale -> %s(超 %d 分钟未续,已回退)" % (c.name, DOING_TTL // 60)) except Exception: pass doing = {} for c in (CLAIMS.iterdir() if CLAIMS.exists() else []): if c.is_dir(): h = c / "holder" doing[c.name] = (h.read_text(encoding="utf-8", errors="replace").strip() if h.exists() else "?") # 🔴 2026-09-29 修(P5):**持有人失活 ⇒ 立即出队**(⛔ 不再干等 20 分钟 TTL)—— # 病根:claim 只记「有人取了」,不记「那个人还在不在」⇒ 队首被僵尸占住 / TTL 回退后复活。 doing = _reap_dead(doing) # 🔴 2026-09-29 修(P5·`holder` 契约):契约是 `<会话名>@<线>[@<会话id>]`(见 queue.md 模板)。 # 原实现取 `split("@")[0]` = **会话名**,却拿去比 **线名** ⇒ 恒不相等 ⇒ # **「同线互斥」从未生效** ⇒ 同一条线可被同时派多件(打架)。取第 2 段才是线; # 缺第 2 段 ⇒ 回退按节点 id 反查它是哪条线。 line_of = {n.get("id"): n.get("line") for n in (T.get("nodes_raw") or [])} # 🔴 2026-09-29(回架构 · 去自造状态源):「哪条线被占」**由宿主库推导** —— # `sessions.status='working'` 的 `cwd` 末段就是线名(与任务图 `line` 同构)。 # ⛔ 不再依赖解析自己写的 `holder` 文本(那正是"第二状态源":会漂、要清理、今天整天的 # 缺陷都长在这类自造状态上)。`claims` 降级为**仅给程序看的投影提示**,丢了不影响判定。 busy_lines = set() for w in (d or {}).get("working") or []: seg = str(w.get("cwd") or "").replace("\\", "/").rstrip("/").split("/")[-1] if seg: busy_lines.add(seg) for cid, v in doing.items(): # 兜底:宿主库取不到时才看自造件 parts = [x.strip() for x in v.split("@")] busy_lines.add((parts[1] if len(parts) > 1 and parts[1] else "") or line_of.get(cid) or "") busy_lines.discard("") crit = list(T.get("critical") or []) # 🔴 优先级:**"在关键路径的上游闭包内"⇒ 0**(做它能解锁关键路径);否则 1 # ⛔ 不能只看"是不是关键路径节点本身"(那样会漏掉它的前置,反而去干无关的活) crit_up = set(crit) changed = True while changed: # 迭代求上游闭包 changed = False for n in (T.get("nodes_raw") or []): if n.get("id") in crit_up: continue if any(d in crit_up for d in (n.get("deps") or [])): crit_up.add(n.get("id")) changed = True pend, head = [], None for nid, title, line, status in (T.get("ready") or []): if nid in doing: continue num = int("".join(ch for ch in nid if ch.isdigit()) or 9999) # N6 < N7 < N11 pend.append({"id": nid, "title": title, "line": line, "status": status, "prio": 0 if nid in crit_up else 1, "num": num}) pend.sort(key=lambda x: (x["prio"], x["num"])) # 🔴 2026-09-29 加(P4·队列生命周期):**受阻件不得当队首**。否则队首永远是它、`NEXT.md` # 永远是它 ⇒ 每次唤醒都白跑一遍同一个受阻件(实测:N9 被泄漏锁挡住后反复复活)。 # 受阻清单由**会话**维护:`tmp/supervise-inbox/blocked.json` = {"<节点id>": "<原因·谁在等>"} # (⛔ 不改任务图:这是"排队状态",不是"任务状态") try: blocked = json.loads(BLOCKED.read_text(encoding="utf-8")) if BLOCKED.exists() else {} if not isinstance(blocked, dict): blocked = {} except Exception: blocked = {} if blocked: pend = [p for p in pend if p["id"] not in blocked] for p in pend: # ② 同线互斥 ⇒ 只挑一个未被占线的作队首 if p["line"] not in busy_lines: head = p break # 🔴 闸门(gate):用**钩子事件**驱动"继续读下一条",⛔ 不靠轮询 # 规则:有 claim(有人在做)且 `gate-done.stamp` 不新于该 claim ⇒ gate=busy(处理期间不放行); # 收尾钩子会更新 stamp ⇒ gate=free ⇒ 放行下一条(写 NEXT.md,一次只写一条)。 # 🔴🔴 2026-10-01 22:4x 实测订正(**别被上面那两句骗了**):那个「收尾钩子」= `SessionEnd`, # 而宿主**从不投递** `SessionEnd`(`hooks/wb-result-hook.py` 243 行早已记下;本轮复验: # 台账里仅存的 `done` 行**全是 `--selftest` 自测**、无一条真实调用)⇒ `gate-done.stamp` # **至今不存在** ⇒ `done_mt` 恒为 0 ⇒ `0 <= claim_mt` **恒真** ⇒ **只要 `doing` 非空就是 busy**。 # ⚠️ 这**不是**"永久卡死"(claim 会因「持有人失活 ⇒ 立即出队」或 20 分钟陈旧被清 ⇒ 之后自动 free), # 但它是**纯延迟**,而且上面那句注释描述的放行**在本机从未发生过**。 # ✅ **主流程不依赖它**:真正的"放行"=**会话自己删 `claims/`**(见写进 NEXT.md 的第 5/6 条)。 # ⛔ **保持原逻辑不动**(改成"stamp 不存在就 free"会**放松"一次一件"的保护**)—— 只把事实写在这里。 GATE_DONE = INBOX / "gate-done.stamp" NEXT_MD = INBOX / "NEXT.md" gate = "free" try: claim_mt = max([c.stat().st_mtime for c in CLAIMS.iterdir() if c.is_dir()] or [0]) done_mt = GATE_DONE.stat().st_mtime if GATE_DONE.exists() else 0 if doing and done_mt <= claim_mt: gate = "busy" except Exception: pass out = {"head": head, "doing": doing, "pending": len(pend), "blocked_lines": sorted(busy_lines), "blocked": blocked, "gate": gate, "conflict": bool(len(doing) > 1)} try: if gate == "free" and head: NEXT_MD.write_text( "# 📌 下一条(**只此一条** · 处理期间闸门关闭)\n\n- 条目:**%s** %s\n- 线:`%s`\n- 状态:%s\n\n" "## 主会话该做的\n" "1. 抢锁(按此件涉及的文件声明域)\n" "2. `mkdir \"…/tmp/supervise-inbox/claims/%s\"` **原子取件**;建不成 ⇒ 别人取了 ⇒ ⛔ 停\n" "3. 立刻写 `claims/%s/holder` = `<会话名>@<线>@<本会话 id>`(**第 3 段别省**)—— 这是\n" " 「谁在做」的唯一权威;持有人失活程序会**立即出队**,⛔ 不写就只能等 20 分钟超时兜底\n" "4. 派活(白名单「派活」⇒ 免确认)**或**自己做完\n" "5. 更新任务图(**有可核对产物才标 done**)⇒ **删 `claims/%s` 出队**\n" "6. 🔴 **收尾=自己删掉 `claims/<本条 id>`**(=第 5 条那个动作,别漏)—— ⛔ **别指望 `SessionEnd` 钩子**:\n" " 实测宿主**从不投递** `SessionEnd`(`hooks/wb-result-hook.py` 243 行早已记下;2026-10-01 22:4x 复验:\n" " 台账里仅存的 `done` 行**全是 `--selftest` 自测**、无一条真实调用)⇒ 它要写的 `gate-done.stamp`\n" " **至今不存在**,而那正是原设计里「收尾 ⇒ 放行下一条」的信号。\n" " ⇒ **不删 claim = 队列停在你这儿**(同线互斥 ⇒ 那条线再不会派新件,别人只能等它陈旧。\n" " ⚠️ 旧版这行原写「SessionEnd 会自动放行」—— **那是一句假话**,照着做就会卡住)。\n" "7. 🔴 **收尾=自己判本线缺口并接上**(顶层设计 §4「收尾自判」· 定稿 §4.2 必含条款):\n" " ⚠️ **派活命名规范**:新棒的自动化名必须用**两级前缀** `[执行]-[<类别>]-<具体>`\n" " —— **第 2 级要带方括号**,值取 `goal.json` 的 **`topics`(任务类别)**,⛔ **不是 `short`**。\n" " ⚠️ 漏方括号(写成 `[执行]-<类别>-…`)**仍能归属**(归属判据是「角色+类别**子串**」,\n" " 2026-10-01 放宽),但 **`parse_session_name().ok=False`** ⇒ 报「没按约定命名」。\n" " ⛔ 真会**静默漏管**的是**值写错**:用 `short`/昵称(`[执行]-本机协作-…`)⇒ 标题里\n" " 根本没有类别名 ⇒ `_in_project()` 认不出 ⇒ 看板不列它、`--ready-next` 也不算它在跑。\n" " 🔴 **接续退位(2026-10-01 加)**:你**建出下一棒之后**立刻执行一次\n" " `collabd.py --retire self --by <接棒会话id> --why 接续`\n" " —— 把你这条前棒**从版面上退场**(⛔ 不是删除:宿主库原样在、`retired.json` 留痕)。\n" " ⛔ 不做 ⇒ 接续棒**越堆越多**(实测已堆 5 条同族旧棒),版面越用越糊、真在跑的棒被埋掉。\n" " ⚠️ 有**窗口兜底**(`completed` 且超 `session_live_min` 分钟自动收起),但那要等;\n" " 你自己登记是**立刻**生效的。⚠️ 你**中途死掉**时这步不会发生 ⇒ 靠兜底收。\n" " 做完就判「我这条线还有没有缺口」⇒ 有 ⇒ **在收尾那一刻**写下一行一次性 `automations`\n" " (排期 = 现在 + 3~4 分钟)—— ⛔ 不叫「监管会话」(监管棒这个**角色**已退役)。\n" " ⚠️ 棒中途死掉时这步不会发生 ⇒ 链条会静默断掉 ⇒ 靠**每小时兜底唤醒**接(定稿 §3②)。\n" "\n> ⛔ 一次只做这一条:**闸门 gate=%s**;遇阻 ⇒ 写 `NEED-USER.md` 并**明确喊「需用户介入」**,\n" "> 同时把该件写进 `blocked.json`({id: 原因})—— 否则它会一直当队首、每次唤醒都白跑。\n" % (head["id"], head["title"], head["line"], head["status"], head["id"], head["id"], head["id"], gate), encoding="utf-8") elif gate == "free" and blocked: # 🔴 2026-09-29 补(治「受阻即静默」):**受阻不是没活** —— 只是没人能开工。 # 原实现选不出队首就删 NEXT.md ⇒ 唤醒回路的触发条件不再满足 ⇒ 变成 # 「有活 ∧ 谁都动不了 ∧ 程序一声不响」(实测 16:31→20:07 零投递)。 # ⛔ 内容**不得含时间戳**:唤醒回路按**内容哈希**去重,带时间戳会退化成每轮都投一遍。 NEXT_MD.write_text( "# 🛑 队列受阻(**有活,但没人能开工** · 需人处理)\n\n" + "\n".join("- **%s** —— %s" % (k, v) for k, v in blocked.items()) + "\n\n## 主会话该做的\n" "1. 读 `tmp/supervise-inbox/NEED-USER.md`(受阻原因与解除条件都在里面)\n" "2. **明确报告用户**:要哪一句授权、要哪个决定\n" "3. 用户放行后办掉,并**摘掉 `blocked.json` 里对应项** ⇒ 队首自动恢复\n" "4. ⛔ 不要因为受阻就硬派别的棒(那会变成「空转链条」)\n" "\n> ⛔ 一次只处理这一件;**受阻 ≠ 停滞** —— ⛔ 不抢锁、不接管。\n", encoding="utf-8") else: try: NEXT_MD.unlink() except Exception: pass except Exception: pass try: QUEUE.write_text(json.dumps(out, ensure_ascii=False, indent=1), encoding="utf-8") QUEUE_MD.write_text("\n".join([ "# 严格队列(一次一件 · 原子取件 · 同线互斥)", "", "- 生成于:%s" % time.strftime("%Y-%m-%d %H:%M:%S"), "- **队首(下一个该做)**:%s" % ("**%s** %s(线:%s,%s)" % (head["id"], head["title"], head["line"], "在关键路径上游·做它可解锁关键路径" if head["prio"] == 0 else "非关键路径") if head else "(无可派:都在做或等前置)"), "- **正在做**:%s" % ("、".join("%s→%s" % (k, v) for k, v in doing.items()) or "(无)"), "- **受阻(⛔ 不当队首)**:%s" % ("、".join("**%s**(%s)" % (k, v) for k, v in blocked.items()) or "(无)"), "- **待办**:%d 件%s" % (len(pend), " ⚠️ 同时在做多件(可能打架)" if out["conflict"] else ""), "", "## 取件规矩(**严格队列**)", "1. **只取队首那一件**;取件用**原子动作**:`mkdir tmp/supervise-inbox/claims/` ⇒ **建不成就是别人取走了** ⇒ ⛔ 换队首/等待", "2. 取到后写 `claims//holder`(内容 `<会话名>@<线>@<完整会话id>`)⇒ 「谁在做」的唯一权威;**第 3 段别省**(持有人失活 ⇒ 程序立即出队;⛔ 不写只能等超时兜底)", "3. **做完 → 删掉 `claims/`**(出队)⇒ 下一个才可能成为队首(⛔ 不删 ⇒ 该线一直被占)", "4. **同线互斥、跨线并行**:同一条线同时只允许一件;不同线可并行(队列按 `line` 判)", "5. **卡死**:claim 超 %d 分钟未续 ⇒ 程序自动移到 `claims-stale/`(可追溯)⇒ 队列自动放行" % (DOING_TTL // 60), "", ]), encoding="utf-8") except Exception: pass return out # ── ⑩ 唤醒回路(程序把「有活」投给**活会话**) ─────────────────── # 🔴 2026-09-29 立(用户拍板)。事实依据(源码实读 + 本机实测,⛔ 别再试错): # · `POST /api/v1/sessions/{id}/reply` = **官方投递语义:不夺 ACP writer**(桌面不被切换/打断); # ⚠️ 只对**该网关的当前活会话**成立,其余会话会 409 ⇒ 故先 `GET /api/v1/sessions/live` 取 id。 # · 鉴权头**只有** `x-access-token: <口令>` 通(`?password=` 在受保护路径上恒失效); # 口令从 `os.environ[CODEBUDDY_GATEWAY_PASSWORD]` 取(桥跑在 WorkBuddy 进程树内即自动可得)。 # · 本机**并存多个实例**(各带一个网关)⇒ 认口必须加 **cwd == 本工作区** 这一条, # 否则会把「有活」投进**别的工作区**的会话里。同 cwd 多口时取 **uptime 最大**(=最稳的常驻实例)。 NEXT_MD = INBOX / "NEXT.md" WAKEUPS = INBOX / "wakeups.jsonl" NEED_USER = INBOX / "NEED-USER.md" GW_TITLE_KEYS = ("CodeBuddy Gateway", "CodeBuddy Remote Control") GW_HDR = "x-access-token" GW_ENV = "CODEBUDDY_GATEWAY_PASSWORD" # 🔴 2026-09-30 加:本进程**是不是由宿主钩子唤起**(原先是 `--tick`,2026-10-05 该分支已删)—— # 用来区分两种"没口令",两者的处置完全相反: # · 钩子唤起(进程在**宿主进程树**内)却仍没口令 ⇒ **真异常** ⇒ 必须落 `NEED-USER.md` 让人看见; # · 常驻进程(从「启动」文件夹/命令行起,**天然在宿主进程树外**)没口令 ⇒ **设计使然**(⛔ 不是故障) # ⇒ 只记日志,⛔ 不要每小时写一条 `NEED-USER.md` 把真问题淹掉。 FROM_HOOK = False def _norm_p(p) -> str: return str(p or "").replace("\\", "/").rstrip("/").lower() def _gw_http(port: int, method: str, path: str, obj, token: str, timeout: float = 6.0): """直调本机网关。⛔ 口令只作请求头使用:不写日志、不落盘、不回显、不进异常消息。""" h = {"Accept": "application/json"} data = None if obj is not None: h["Content-Type"] = "application/json" data = json.dumps(obj, ensure_ascii=False).encode("utf-8") if token: h[GW_HDR] = token req = urllib.request.Request("http://127.0.0.1:%d%s" % (int(port), path), data=data, headers=h, method=method) try: with urllib.request.urlopen(req, timeout=timeout) as r: return r.status, r.read().decode("utf-8", "replace") except urllib.error.HTTPError as e: try: return e.code, e.read().decode("utf-8", "replace") except Exception: return e.code, "" except Exception as e: return 0, str(e) def _listen_ports(limit: int) -> list: """扫 `netstat -ano` 取 `127.0.0.1: LISTENING` 候选(1024 list: """认网关口:① `GET /` 200 且正文含 CodeBuddy Gateway/Remote Control ② `/api/v1/info.cwd` **等于本工作区**(多实例区分的关键)。返回按 uptime 降序 ⇒ `[0]` 即最稳的那个。""" tok = os.environ.get(GW_ENV) or "" hits = [] for p in _listen_ports(int(C.get("wake_max_ports") or 60)): st, body = _gw_http(p, "GET", "/", None, "", timeout=3.0) if st != 200 or not any(k in body for k in GW_TITLE_KEYS): continue cwd, up = "", 0.0 st2, t2 = _gw_http(p, "GET", "/api/v1/info", None, tok, timeout=5.0) try: dd = json.loads(t2).get("data") or {} cwd, up = dd.get("cwd") or "", float(dd.get("uptime") or 0.0) except Exception: pass if _norm_p(cwd) != _norm_p(WS): continue sid = "" st3, t3 = _gw_http(p, "GET", "/api/v1/sessions/live", None, tok, timeout=5.0) try: sid = (json.loads(t3).get("data") or {}).get("sessionId") or "" except Exception: pass hits.append({"port": p, "cwd": cwd, "uptime": up, "sessionId": sid, "live_http": st3}) hits.sort(key=lambda x: -x["uptime"]) return hits def _wakeup_log(rec: dict) -> None: try: with open(WAKEUPS, "a", encoding="utf-8") as f: f.write(json.dumps(rec, ensure_ascii=False) + "\n") except Exception as e: log("wake: append wakeups err %s" % e) # 🔴 2026-10-01 加:`NEED-USER.md` 的两条纪律(⛔ 别退回去) # ① **同一句话要节流** —— 原来每调一次就整体重写,而 `_deliver_str` 的 `no-follow-session` # 分支在**常驻**里每轮(~10 s)都会走到 ⇒ 实测 `NEED-USER.md` **每 10 秒换一次时间戳** # (用户看到的就是「这条消息一直在喊、一直在变」)⇒ 人只会当噪音忽略掉。 # ② **人写的「## 解除条件」必须原样保留** —— 那是人唯一的回信口;被机器下一轮**静默抹掉**, # 人就再也不写了(前一轮已实测:21:03 手写的解除条件当场被覆盖)。 # 判据落在**文件**上(⛔ 不只在内存里)—— 因为钩子每次都是**新进程**,内存节流跨不了进程。 NEED_GAP = float(os.environ.get("DSH_NEED_GAP") or 600) def need_user(reason: str) -> None: """遇阻 ⇒ 落 `NEED-USER.md`(报告里必须**明确喊「需用户介入」**)。节流与保留规则见上。""" _now = time.time() try: if NEED_USER.exists() and (_now - NEED_USER.stat().st_mtime) < NEED_GAP: _t = NEED_USER.read_text(encoding="utf-8", errors="replace") if ("- 原因:%s" % reason) in _t: # 同一句话、且是刚写的 ⇒ 不重写(⛔ 不刷屏) return except Exception: pass _keep = "" try: _old = NEED_USER.read_text(encoding="utf-8", errors="replace") _i = _old.find("## 解除条件") if _i >= 0: # ② 人加过的段落原样带走 _keep = "\n" + _old[_i:].rstrip() + "\n" except Exception: pass try: NEED_USER.parent.mkdir(parents=True, exist_ok=True) NEED_USER.write_text( "# 🔴 需用户介入\n\n- 时间:%s\n- 原因:%s\n\n## 喊话\n**需用户介入**\n%s" % (time.strftime("%Y-%m-%d %H:%M:%S"), reason, _keep), encoding="utf-8") except Exception: pass def wake_round(st: dict, T: dict, V: dict | None = None) -> dict: """⛔ **已停用(2026-09-29)**:投递已**唯一化到投递**(`supervise()`),本函数不再被任何路径调用; 它依赖的 `NEXT.md` 旧队列一并退役。⛔ 本轮不删(删除要按"函数边界+夹层常量"的规矩单独做, 避免重演误删常量那次),列入 ④ 收敛清单。""" """闸门全满足才投**一次**(一份内容只投一次)。返回本轮判读(写进 state,供视图/排查用)。 触发面(🔴 2026-09-29 扩):① `NEXT.md` 存在且 `gate=free`(有活派) ② **`NEXT.md` 不存在但出现「停滞」或「可派未派」** —— ⛔ 原实现只认 ① ⇒「没人发消息 ⇒ 没人跑程序 ⇒ 永远发现不了停滞」的死循环。""" info = {"skipped": ""} if not C.get("wake_enable", True): info["skipped"] = "disabled" return info nodes = T.get("nodes_raw") or [] if nodes and all((n.get("status") == "done") for n in nodes): info["skipped"] = "all-done" # 终止:任务图全 done ⇒ 不再唤醒 return info synth = "" if NEXT_MD.exists(): try: txt = NEXT_MD.read_text(encoding="utf-8", errors="replace") except Exception: info["skipped"] = "next-unreadable" return info try: gate = (json.loads(QUEUE.read_text(encoding="utf-8")) or {}).get("gate") except Exception: gate = None if gate != "free": info["skipped"] = "gate=%s" % gate return info else: # 🔴 2026-09-29 补(治「没人发消息 ⇒ 彻底静默」):NEXT.md 只是「有活派」的载体, # **不是唯一该把人叫起来的理由**。**停滞**(无人接活 / 有活可派未派)同样必须投一次; # 否则「有活 ∧ 谁都动不了 ∧ 程序一声不响」—— 实测 16:31→20:07 整 4.5 小时零投递, # 用户看到的就是「一直在发呆」。 # ⚠️ 哈希必须用**粗粒度键**(种类 + 空闲按 30 分钟取整):若拿带时间戳的摘要正文去算, # 会退化成每 180 秒投一次(刷屏)。 idle_v = float((V or {}).get("idle") or 0) alert = str((V or {}).get("alert") or "") if alert.startswith("确定性静默"): # ⛔ 这一档是「**要用户拍板**」的状态 ⇒ **只投一次**(用常量键、不带时间桶): # 它不会自己好,每 30 分钟重复喊一遍只会变成噪音;状态一变(排期恢复 / 有人跑起来 / # 新结论落库)键自然变 ⇒ 会再投一次。 synth = "确定性静默" elif alert: synth = "僵住|%d|%s" % (int(idle_v // 30), alert) elif T.get("waste"): synth = "可派未派|%d" % int(idle_v // 30) if not synth: info["skipped"] = "no-next" return info txt = synth info["trigger"] = synth h = hashlib.sha1(txt.encode("utf-8", "replace")).hexdigest()[:16] wtext = str(C.get("wake_text") or "") if synth: wtext = ("常驻程序报警:%s。请读 tmp/supervise-inbox/digest.md 与 NEED-USER.md,判「该谁动」" "——**有活就派、被卡就明确报给用户**;本轮只做这一件,做完即停。" % ((V or {}).get("alert") or "有活可派但无人接")) W = st.get("wake") or {} if W.get("hash") == h: info["skipped"] = "same-item" return info if time.time() - float(W.get("ts") or 0.0) < float(C.get("wake_min_gap") or 180): info["skipped"] = "too-soon" return info tok = os.environ.get(GW_ENV) or "" if not tok: info["skipped"] = "no-token" # ⛔ 口令缺失只跳过,绝不落盘/回显 return info gws = discover_gateways() if not gws: info["skipped"] = "no-gateway" return info # 🔴 2026-09-29 补(实测驱动):**必须挑「有活会话」的那个口**,⛔ 不能只取 `gws[0]`。 # 实测(本机 13:24):同 cwd 两个口 —— `:62213` uptime 更大但 `/sessions/live` 为空, # 有活会话的 `:56944` 排在后面 ⇒ 原逻辑取 `gws[0]` ⇒ 直接落 `no-live-session` 跳过, # **「有活会话也投不出去」**。改法最小:沿用 uptime 降序,取**第一个带 sessionId 的**。 # ⛔ 同上:**不做盲选**(2026-09-30)。本路径虽已停用,但**留着盲选=留着一颗雷** —— # 将来谁把它复活,就会重新开始"随手投给任意一条会话"。 _rm2 = resolve_main(st, [x.get("sessionId") for x in gws] if gws else []) _w2 = str(_rm2.get("sid") or "") g = next((x for x in gws if x["sessionId"] and _w2 and str(x["sessionId"]) == _w2), None) if gws else None if g is None: info["skipped"] = ("no-main-session" if _w2 == "" else "main-not-live") return info if _target_busy(str(g.get("sessionId") or "")): # 🔴 判据已换:状态机日志的 busy=(⛔ 不是 status=='working',那档恒真) log("延后投递(旧路径):目标会话正在执行") info["skipped"] = "target-busy" return info stx, body = _gw_http(g["port"], "POST", "/api/v1/sessions/%s/reply" % g["sessionId"], {"text": wtext}, tok, timeout=20.0) ok = stx in (200, 201, 202) rec = {"ts": time.strftime("%Y-%m-%dT%H:%M:%S"), "epoch": round(time.time(), 1), "port": g["port"], "sessionId": g["sessionId"], "hash": h, "http": stx, "ok": ok} _wakeup_log(rec) st["wake"] = {"ts": time.time(), "hash": h, "port": g["port"], "sessionId": g["sessionId"], "http": stx} log("wake reply -> %s@%s http=%s hash=%s" % (str(g["sessionId"])[:8], g["port"], stx, h)) if not ok: need_user("唤醒投递失败:网关口 %s 回 %s(%s)" % (g["port"], stx, str(body)[:140])) info["skipped"] = "deliver-failed" else: info["delivered"] = rec return info # ── ⑪ 任务队列(🔴 架构定案 2026-09-29 用户):**状态由任务会话上报,⛔ 不靠猜** ────────── # ① 任务会话**开始执行** ⇒ 告诉常驻程序 ⇒ 条目置 `running`(记录执行者+开始时刻) # ② 任务会话**处理完毕** ⇒ 告诉常驻程序 ⇒ 条目置 `done`(记录产物+结束时刻) # ③ **投递**逐条读队列 ⇒ 有新变化 ⇒ 告诉**跟进会话**跟进; # **一段时间没有「执行中/执行完毕」的队列**(=静默)⇒ **发唤醒**让跟进会话检查状态。 # 🔴 2026-10-01 改「主会话 ⇒ **跟进会话**」(用户:「换新会话 唤醒的是 跟进会话, # 主会话只能是用户触发」)⇒ 这一条的投递目标见 `follow_for_topic()`,⛔ 不是主会话。 # ⛔ 本程序只"读队列 + 写通知",⛔ 不派活、⛔ 不开会话。 TASKS = INBOX / "tasks.json" # 🔴 **唯一权威**(队列本体) # 🔴🔴 2026-10-03 10:5x **目标执行状态文档**(用户要求逐字:「目标执行情况也要有文档, # 这样检查会话直接根据文档判断目标状态,避免检查会话到处找信息」)。 # ⚠️ 相对工作区定位(⛔ 不写死盘符)⇒ 换机/搬工作区仍成立。 # ⚠️ 文档内容由**目标检查会话**更新;⛔ 机制**不自动改**它(判据结论要人/会话自己写)。 # 🔴🔴 2026-10-03 11:0x **改按目标算**(原先写死 `交付物/目标执行状态.md`): # ⛔ 写死的问题=换目标/多目标时,那份文档**不属于这个目标** ⇒ 检查会话会照错的目标判状态。 # ✅ 改成 `exec_doc_rel()`(下面)= `<目标目录>/目标执行状态.md`。 EXEC_DOC_NAME = "目标执行状态.md" # 🔴🔴 2026-10-03 11:0x **目标文件夹**(用户要求:「把这个机制写到技能中, # 这样每次使用协作会话机制都可以有对应的目标文件夹」)。 # ⚠️ **归拢文档,不搬家状态** —— 真源永远是 `goal.json`(生命周期/验收判据) # + `tasks.json`(队列四态)⇒ 本目录里只放**文档**,⛔ 不复制状态(两处存=迟早打架)。 # ⚠️ 目录名带**短哈希**(按标题算)⇒ 同名目标不撞目录;⛔ 不用完整标题(长句 + 跨平台路径长)。 _GOAL_DIR_PREFIX = "目标-" # 🔴🔴 2026-10-05 **用户定案**(逐字):「**<工作区根>/执行会话/目标-xxx-xxxxxx/ 改成这样**」 # ⇒ 目标文件夹**统一收进工作区下的 `执行会话/` 一层**,⛔ 不再直接散在工作区根。 # ⚠️ **为什么加这一层**(用户诉求的直接好处):工作区根下每换一个目标就多一个 `目标-*`, # `ls` 出来跟 `docs/`、`交付物/`、`归档/` 混在一起;收进一层 ⇒ 根目录干净、 # 目标文件夹一眼可辨、历史目标归拢在一处。 # ⚠️ **域目录不受影响**:独立域仍是「工作区第一层目录」(`<工作区>/<域名>/`); # `执行会话/` 是**目标文件夹的父层**,⛔ 不是新增的域,⛔ 不参与域键计算。 _GOAL_DIR_PARENT = "执行会话" def goal_dir_name(title: str, short: str = "") -> str: """🔴 目标目录名=`执行会话/目标-<简称或标题前缀>-<短哈希>`(确定性 ⇒ 同一目标永远同一目录)。 ⚠️ 短哈希=`sha1(title)[:6]` —— 只为**消歧**,⛔ 不承载语义(读者靠前半段认它)。 ⚠️ 前缀清洗:去掉路径分隔符与 Windows 非法字符,⛔ 防拼出打不开的路径。 """ t = str(short or "").strip() or str(title or "").strip() or "未命名目标" t = re.sub(r"[\\/:*?\"<>|]+", "-", t) # ⚠️ 空格也去掉:目录名带空格 ⇒ 命令行里处处要加引号,⛔ 任务会话最容易在这里踩空。 t = re.sub(r"\s+", "", t)[:20].strip(" .-") or "未命名目标" h = hashlib.sha1(str(title or t).encode("utf-8")).hexdigest()[:6] # 🔴 单一真源:父层只在这一个地方拼,⛔ 别在调用方各拼一遍(同族 P0:同一条规则两份实现)。 return "%s/%s%s-%s" % (_GOAL_DIR_PARENT, _GOAL_DIR_PREFIX, t, h) def goal_fp() -> str: """🔴 **当前目标的指纹**(用于把台账条目**绑到目标**上)。 ## 为什么要它(用户 2026-10-06 逐字) > 「看板中 常驻程序 改为 目标检查,里面的队列 **应该是随着目标的不是目标累积的**」 🔴 **病根**:`tasks.json` 是**只增不减**的台账 —— 换目标时**旧条目全留着** (实测某工作区里躺着**跨时代的旧线名**,且代码注释里早写过 「台账里还躺着跨工作区时代的旧线名」这类话)。 ⇒ 看板那格报的"队列 N 件 / 已完成 M / 未完 K"是**历史累计**, **读者看不出"这是当前目标在跑多少"** —— 而用户要的正是后者。 🔴 更关键:**机制自己早就知道这个缺陷**(见 `goalctl.py:577` 的「静默陷阱」警告逐字: 「**判据不绑目标**」⇒ 新目标可能一声明就被判「全过」)。 ⚠️ 但那次**只警告、没修**(怕动存量数据)。 ⇒ 本函数是**同一病根的正面修法**:给条目一个**目标归属**,让"随目标"成为可判定的事实, ⛔ 而不是靠"换目标时记得清台账"(凡"要人记"的都会忘 —— 同族已栽多次)。 ## 取什么当指纹(⚠️ 选型理由) · **⛔ 不用 `goal.json` 的 `id`**:实测那个值是**历史手写**的, 换目标时 `goalctl declare` **完全不碰它**(全文无写入)⇒ 换目标后指纹不变 ⇒ **不能用**。 · ✅ **用 `sha1(title)[:6]`** —— 与 `goal_dir_name()` **同一套哈希口径** (`goal_dir_name` 也是 `sha1(title)[:6]`)⇒ **天然对齐**: 同一个目标 ⇒ 目标目录名里的 `-xxxxxx` 与这里返回的**逐字相同**, 看板/日志/目录名三处可以对着读,⛔ 不会各算一套(同族 P0:一条规则两份实现)。 · ⚠️ 标题改了(纯细化措辞)⇒ 指纹会变。**这是可接受的**: `goalctl declare` 在标题变化时**本来就要 `--switch-goal` 确认闸** ⇒ "标题变了"在被允许的那一刻**就等同"换目标"**,语义一致。 @return 6 位十六进制;`goal.json` 缺失/无 title ⇒ `""`(调用方**必须显式处理空值**) """ try: t = str(load_goal().get("title") or "").strip() except Exception: t = "" if not t: return "" return hashlib.sha1(t.encode("utf-8")).hexdigest()[:6] def exec_doc_rel() -> str: """🔴 目标执行状态文档的**相对工作区**路径=`<目标目录>/目标执行状态.md`。 ⚠️ 与 `goal_dir_rel()` **同源**(同一套 `goal.json` 读数)⇒ ⛔ 不会出现 "目录在一处、文档路径在另一处"的漂移。 """ return "%s/%s" % (goal_dir_rel(), EXEC_DOC_NAME) def register_main_session(st: dict, sid: str, topic: str = "", *, demote_others: bool = True, why: str = "") -> dict: """把 `sid` 登记为**主会话**(🔴 2026-10-05 抽出 —— 「创建目标时顺手登记」与 `--declare` 共用一份)。 ## 为什么必须有它(用户 2026-10-05 逐字) > 「**就是你没有指定 声明目标的时候 指定是哪个会话(sid)**」 > 「**最好是记录会话,这样就不用每次会话都去做声明了**」 🔴 **病根**:`role_of()` 的三条优先级里,第三条 **`未声明` 落成了一个哑档** —— 标题没前缀、又没人跑 `--declare` ⇒ 按 `worker` 处理 ⇒ **用户一直在跟"任务会话"说话**, 而机制侧看不到"这本来该是主会话"(⛔ 没有告警、⛔ 也没地方自动记)。 实测踩到:某工作区的主会话从没声明过 ⇒ 看板/投递全按 worker 走, 用户手动补 `--declare` 才发现(而且**顶掉了原来的主会话**)。 ✅ **正解=在"创建目标"那一步顺手记**(那一刻**会话已知**:`CODEBUDDY_SESSION_ID`): ⛔ 不需要用户每次开口声明;⛔ 也不新开一条"要记得声明"的待办(那还是会忘)。 ## 语义(与 `--declare --role main` **逐字同款**,⛔ 两处不许各写一套) · **一个工作区一条主会话**:登记新的 ⇒ 其它 `main` **降级 worker**(用户定则); · ⚠️ 多任务类别的"每类别一条主会话"走**标题 `主控` 前缀**(`main_by_topic` 解析) ⇒ ⛔ 不依赖这里,降级不会破坏多类别部署; · **幂等**:已经是 main 的再登记一次 ⇒ 无变化、`demoted` 为空。 @param why 写进日志的原因(便于回溯"谁在什么时候把它记成主会话的") @return `{"key","demoted":[...],"changed":bool}` """ roles = st.setdefault("roles", dict(st.get("roles") or {})) _key = str(sid or "") out = {"key": _key, "demoted": [], "changed": False} if not _key: return out _demoted = [] if demote_others: for _k, _v in list(roles.items()): if str(_v) == "main" and _k != _key: roles[_k] = "worker" _demoted.append(str(_k)) if str(roles.get(_key) or "") != "main": out["changed"] = True roles[_key] = "main" if topic: st["topics"] = dict(st.get("topics") or {}) st["topics"][_key] = topic out["demoted"] = _demoted if _demoted or out["changed"]: log("登记主会话 %s(%s)%s" % (_key[:8], why or "未注明", (";原 main 降级:%s" % "、".join(x[:8] for x in _demoted)) if _demoted else "")) return out def _ensure_goal_dir() -> dict: """🔴 **建目标文件夹 + 落状态文档骨架**(用户 2026-10-03 11:0x 要求逐字: 「把这个机制写到技能中,这样**每次使用协作会话机制都可以有对应的目标文件夹**」)。 ✅ **幂等**:目录/文档已存在就只报路径,⛔ **绝不覆盖**已有文档 (那是别人写好的产物、别人填的判据 —— 覆盖=毁证据)。 ⚠️ 抽成独立函数(⛔ 不内联在 CLI 分支里):否则自检只能"跑 subprocess 看 stdout", **验不了"第二次跑到底覆不覆盖"这条关键判据**。 ⚠️ 骨架里写死「暂无判据 ⇒ **判不出来**,不许当成已完成」—— 防"空文档被当成目标已达成"(那会让检查会话误报完成)。 @return `{"ok","dir","doc","created","skipped","err"}` """ _rel = goal_dir_rel() _d = (Path(str(WS)) / _rel) if WS else Path(_rel) _doc = _d / EXEC_DOC_NAME out = {"ok": False, "dir": str(_d), "doc": str(_doc), "created": False, "skipped": False, "err": ""} try: _d.mkdir(parents=True, exist_ok=True) except Exception as _e: out["err"] = "建目录失败:%s(%s)" % (_e, _d) return out if _doc.exists(): out["skipped"] = True # ⛔ 绝不覆盖 else: try: _doc.write_text( "# 目标执行状态\n\n" "> 🔴 本文件=**检查会话判断目标状态的唯一依据**(⛔ 别再去工作区翻文件)。\n" "> 由**目标检查会话**(`[执行]-[目标检查]-…`)在核对后更新。\n\n" "## 二、验收判据(值是中文写法:`过|…`/`🔴 不过|…`)\n" "(⛔ 暂无判据 ⇒ 目标状态**判不出来**,不许当成已完成)\n\n" "## 三、结论\n" "- (待填)\n", encoding="utf-8") out["created"] = True except Exception as _e: out["err"] = "写文档失败:%s(%s)" % (_e, _doc) return out out["ok"] = True # ══ 🔴🔴 **对齐 `goal.json` 的 `execution_doc` 登记**(2026-10-05 补 · 见 P0-86)══ # 病根(实测三区全中):`execution_doc` 这个字段**全脚本只有判据在读**—— # `collabd.py`/`goalctl.py`/`init_workspace.py` **没有一个会写它**, # 而写在里面的值**是 10-05 改口径(加 `执行会话/` 一层)之前**留下的旧路径。 # 后果: # ① `selftest` 那条「生产工作区该文件真存在」**永久假红**(拿旧路径问文件系统); # ② 更毒的是 `vibe-product`:登记成 `目标-vibe-product-3e3182/…`, # 而 `3e3182` 是**别的区的标题哈希** ⇒ 登记值属**跨区污染**, # 光看它根本推不出"该找哪个文件"。 # ✅ 修法(陈述句,可推翻):**登记值=缓存,真值永远由 `exec_doc_rel()` 算**—— # 在**建目标目录这唯一一处**顺手对齐(它本来就知道 `_rel` ⇒ 零重复实现)。 # ⚠️ **为什么放这儿**:这是「按现行口径算出真路径」的那一步(`--ensure-goal-dir` 幂等、 # `init_workspace.py` 每次必调)⇒ 放这儿=每个区**每次初始化都会自动追平**, # ⛔ 不需要谁记得手工改 JSON(手工=迟早再漂一次)。 # ⚠️ **只改这一个键**:其余字段(`acceptance_state`/`taskgraph`/`lifecycle`…) # 整份读回来原样写回,⛔ 不许碰(那是状态真源,覆盖=毁进度)。 # ⚠️ **失败不许影响建目录**(那是主职责)⇒ 整段包 try,出错只记 `out`。 try: _want = exec_doc_rel() # 🔴 唯一真源(与 prompt/`goal_dir_rel()` 同源) _gj = INBOX / "goal.json" if _gj.is_file(): _gd = json.loads(_gj.read_text(encoding="utf-8")) or {} if isinstance(_gd, dict) and _gd.get("title"): _have = str(_gd.get("execution_doc") or "") if _have != _want: _gd["execution_doc"] = _want _gd["execution_doc_at"] = time.strftime("%Y-%m-%d %H:%M:%S") _gj.write_text(json.dumps(_gd, ensure_ascii=False, indent=1), encoding="utf-8", newline="") out["doc_sync"] = ("✅ `execution_doc` 已对齐:%r → %r" % (_have or "(未登记)", _want)) log("goal.json execution_doc 对齐:%r → %r" % (_have, _want)) else: out["doc_sync"] = "已是最新(%s)" % _want else: out["doc_sync"] = "⏭ `goal.json` 无 title ⇒ 不是有效目标,⛔ 不动" else: out["doc_sync"] = "⏭ 无 `goal.json`(⛔ 目标未声明)" except Exception as _e: out["doc_sync"] = "⚠️ 对齐 `execution_doc` 失败(⛔ 不影响建目录):%r" % (_e,) # ══ 🔴🔴 **顺手登记主会话**(2026-10-05 用户:「最好是记录会话,这样就不用每次会话都去做声明了」)══ # **为什么放在这儿**:这是"**用户在创建目标**"那一刻 —— 会话**必然已知** # (`CODEBUDDY_SESSION_ID` 由宿主注入)⇒ ⛔ 不需要用户每次开口声明。 # ⚠️ **失败不许影响建目录**(那是主职责):整段包在 try 里,⛔ 出错只记日志。 # ⚠️ **⛔ 不覆盖已有主会话**:本区已经有 main 时**不动**—— # 否则"跑一次 `--ensure-goal-dir` 就抢走主会话"(实测会误伤:`init_workspace` 也会调它)。 # ⇒ 只在**本区还没有主会话**时才认(这是补哑档,⛔ 不是抢位子)。 try: _sid = os.environ.get("CODEBUDDY_SESSION_ID") or "" if _sid: # ⚠️ 读写都用 `STATE`(本文件通用做法)—— ⛔ 别造 `load_state/save_state` 这种不存在的名字 _st = {} try: _st = json.loads(STATE.read_text(encoding="utf-8")) or {} except Exception: _st = {} _has_main = any(str(v) == "main" for v in (_st.get("roles") or {}).values()) if _has_main: out["main_reg"] = "已有主会话 ⇒ ⛔ 不动(补哑档,⛔ 不抢位)" else: _reg = register_main_session(_st, _sid, why="创建目标时顺手登记") STATE.write_text(json.dumps(_st, ensure_ascii=False), encoding="utf-8") out["main_reg"] = ("✅ 已登记本会话为主会话 %s%s" % (_sid[:8], ("(原 main 降级:%s)" % "、".join(x[:8] for x in _reg["demoted"])) if _reg["demoted"] else "")) except Exception as _e: out["main_reg"] = "⚠️ 登记主会话失败(⛔ 不影响建目录):%r" % (_e,) return out def goal_dir_rel() -> str: """🔴 目标目录的**相对工作区**路径(真源=`goal.json` 的 `title`/`short`,⛔ 不猜)。 形态=`执行会话/目标-<简称或标题前缀>-<短哈希>`(2026-10-05 用户定案加 `执行会话/` 一层)。 ⚠️ 读不到 `goal.json` ⇒ 回落成 `执行会话/目标-未命名目标-<空标题哈希>`(**确定性**) ⇒ ⛔ 不会每次调用都变个名(那会让产物散成一堆目录)。 ⚠️ **不 mkdir**:⛔ 定位与建目录分开(读路径的代码⛔ 不该有副作用)。 """ t, s = "", "" try: g = json.loads((INBOX / "goal.json").read_text(encoding="utf-8")) t = str(g.get("title") or "") s = str(g.get("short") or "") except Exception: pass return goal_dir_name(t, s) # ══════════════════════════════════════════════════════════════════════════════ # 🔴🔴 **目标目录 · 文档合同**(2026-10-04 立 —— 用户口径逐字: # 「**之前还说过 执行会话的结果要形成文档,目标也要完成情况的文档**, # 这样后续检查会话和后续执行会话都可根据文档继续处理,**避免全工作区到处找信息**」) # # **要治的病**(实测三区三种形态,坐实"缺的是合同不是能力"): # · `目标-vibe-product-11eabf/` → 只有 `目标执行状态.md`(420B),**执行产物没落这儿** # · `目标-vibe-product-3e3182/` → 状态文档 + `过程记录-验收判据补齐.md`(**手工产物**,非机制要求) # · `目标-本机协作-3e3182/` → `S12_*.md` 两份 + 状态文档(**落对了**) # ⇒ 同一套机制、三个区三种形态 ⇒ 因为**没有任何一步规定「谁写哪份文档」**。 # # ✅ **合同(三份文档,各有唯一作者与唯一时机)**: # # | 文档 | 谁写 | 什么时候写 | 谁读 | # |---|---|---|---| # | `<目标目录>/目标执行状态.md` | **目标检查会话** | 核对完目标状态后 | 下一个检查会话/人 | # | `<目标目录>/<棒次>_<事项>_<日期>.md` | **任务会话(执行棒)自己** | 本轮做完时(=`--report done` 之前) | 检查会话照 `artifact` 读 | # | `tmp/supervise-inbox/tasks.json` | `--report`(机器写) | 每次状态转移 | 全部环节 | # # 🔴 **落点是死的**(⛔ 不许任务会话"顺手"写回 `交付物/`、`docs/`、工作区根 —— 那正是"到处找"的由来): # 执行产物的 `--artifact` **必须**指进 `<目标目录>/` 里(见 `artifact_dir_ok()` 判据)。 # ⚠️ 2026-10-05:目标目录**收进 `执行会话/` 一层**(用户定案「改成这样」)⇒ # 合同表里的 `<目标目录>` 一律读作 `执行会话/目标-xxx-xxxxxx`,⛔ 别按旧的裸 `目标-xxx` 去拼。 # ══════════════════════════════════════════════════════════════════════════════ GOAL_DOC_EXEC = EXEC_DOC_NAME # 目标执行状态.md(目标检查会话写) DOC_CONTRACT_ROWS = ( ("目标执行状态.md", "目标检查会话", "核对完目标状态后", "下一个检查会话/用户"), ("<棒次>_<事项>_<日期>.md", "任务会话自己", "本轮做完时(`--report done` 之前)", "检查会话照 `artifact` 读"), ("tasks.json(机器写)", "`--report`", "每次状态转移", "全部环节"), ) def artifact_dir_ok(artifact: str) -> dict: """🔴 **`artifact` 是否落在本目标的目录里**(合同判据,⛔ 不是"文件存在"判据)。 ⚠️ 与 `artifact_state()` **分工不同,⛔ 别混**: · `artifact_state()` 判 **能不能核实**(文件到底在不在)—— 硬闸。 · `artifact_dir_ok()` 判 **落点对不对**(有没有按合同放进目标目录)—— 软判据(只提示)。 ⇒ 为什么落点只"提示"不"拒收":🔴 **存量里已有落在别处但有效的产物** (`目标-本机协作-*/S12_*.md` 是合法的;但 `designs/…html` 也合法且不该拒) ⇒ 硬拒会误杀既有工作流。**先提示 + 记进 prompt**,让人/会话自己收敛。 @return `{"ok","why","art"}`;`ok=True` = 落在目标目录内(或本来就判不了 ⇒ 不误报) """ _a = str(artifact or "").strip() if not _a: return {"ok": False, "why": "没写 artifact ⇒ 无从判落点", "art": _a} _gd = goal_dir_rel() _n = _a.replace("\\", "/").lstrip("./") if _n.startswith(_gd + "/") or _n == _gd: return {"ok": True, "why": "在目标目录内 ✅", "art": _a} return {"ok": False, "why": "**不在目标目录内**(应在 `%s/` 下;合同:执行产物一律落目标目录," "⛔ 否则下一个会话又要到处找)" % _gd, "art": _a} TASK_EVENTS = INBOX / "tasks-events.jsonl" # 只作审计(append-only,⛔ 不参与判定) TO_MAIN = INBOX / "TO-MAIN.md" # 投递给主会话的通知(投影 · 供人/AI 直接读) TASK_STATES = ("pending", "running", "done", "blocked") # 需求台账四态:待执行/执行中/已完成/有阻碍 QUEUE_IDLE_MIN = 5 # 唤醒**限流桶**(分钟):同一状态下最多每 **5** 分钟发一次(2026-09-30 用户拍板:30→10→**5**);⛔ 触发条件是 supervise() 的**四条件**(不是计时器) CHECK_IDLE_MIN = 3 # 🔴🔴 2026-10-07 用户三度拍板:**20 → 10 → 6 → 3 分钟**(10-08 02:4x 第三次) # 原话一:「把检查程序的等待时间由 20分钟 改为 10分钟」;原话二:「检查程序的等待时间改为6分钟」(当日 18:25)。 # 「检查程序」判"已经静默够久、可以建检查会话"的阈值(`queue-empty` 那条闸)。 # ⚠️ 改这个数必须**一起改三处**,否则文案与判据打架: # ① 本常量(闸门 `maybe_spawn_check_agent()` + `--dry-run` 试算都读它); # ② `CHECK_PROMPT_GOAL` 里写给检查会话看的「已静默 ≥N 分钟」——那是**字面量**,⛔ 不会自动跟; # ③ 文档 `references/supervise-persistence.md` §六(同样写着 20)。 HEARTBEAT_MIN_IDLE = 15 # 🔴 2026-09-30 加:**「真停滞」门槛**(分钟)—— # 距**上次任何进展** ≥ 15 分钟才允许发唤醒;**有进展 ⇒ 一条都不发**。 # 为什么:唤醒原只要求"三条件成立",而"目标未完成"在长任务期长期成立 # ⇒ 变成定期噪音,且**每次投递都会唤起主会话跑一轮 ⇒ 用户发消息撞 busy ⇒ 体感"卡"**。 def _load_tasks() -> dict: try: t = json.loads(TASKS.read_text(encoding="utf-8")) return t if isinstance(t, dict) else {} except Exception: return {} def _save_tasks(t: dict) -> None: try: TASKS.write_text(json.dumps(t, ensure_ascii=False, indent=1), encoding="utf-8") except Exception as e: log("tasks 写失败 %s" % e) # ══════════════════════════════════════════════════════════════════════════════ # 🔴🔴 **会话退场表**(2026-10-01 立 —— 用户报「**通过接续会话的时候,如何处理过期的会话, # 避免越堆越多**」) # # **要治的病**:接续是"**会话自己建出下一棒**",前棒**不会自动消失** —— # 它在宿主库里永远是一条 `completed` 记录,且标题同族(`接续 · <线> · <具体>`)。 # 实测(本工作区 2026-10-01):库里已堆 5 条「接续 · 会话机制合并…」,看板/状态稿 # 每轮都要为它们占位置 ⇒ 版面越用越糊、真在跑的棒被埋在旧棒里。 # # **两条退场路径**(一条都不许少): # ① **显式**(准确·接续时立刻生效):`--retire self [--by <接棒sid>] [--why …]` # —— 由**前棒自己在收尾时**登记"我已被谁接替"。这是接续协议的**必做一步** # (见 SKILL.md §接续协议 / references/collab.md)。 # ② **窗口兜底**(不依赖任何会话配合):`completed` 且**距上次活动 > `session_live_min`** # (默认 90 分钟)⇒ 视同退场。前棒**中途死掉**时①不会发生 ⇒ 靠这条收(同"每小时兜底唤醒"的设计口径)。 # ⚠️ **`working` 永不被退场吃掉** —— 在跑的棒一定要摊在版面上(窗口与显式退场都绕开它)。 # · **显式退场**由会话**自己**发起 ⇒ 主会话不会自己退(⛔ 不用代码拦,也没人会去退它); # · **窗口退场**只吃 `completed`;主会话若在窗口外会从「原始会话快照」里落下, # 但**看板的「主会话」位不受影响** —— 那位由 `board.py` 的**登记判据**决定(⛔ 不看窗口)。 # # **⛔ 退场 ≠ 删除**:只影响**版面呈现**(看板/状态稿不再摊它), # ⛔ 不动宿主库、⛔ 不删任何东西 ⇒ 可逆、可追溯(`retired.json` 里全留着)。 # # **为什么不用文件锁**(用户本机实测,2026-09-30):本机锁原语接进生产后会 `rc=124` 卡住 # ⇒ 一律 **写临时文件 + `os.replace` 原子替换 + 回读核对**(乐观重试,⛔ 不上锁)。 # ══════════════════════════════════════════════════════════════════════════════ RETIRED = INBOX / "retired.json" # {"<完整会话id>": {"at","iso","by","why"}} RETIRED_TTL_DAYS = 7 # 退场记录本身也要过期 ⇒ 否则这张表自己会越堆越多 def _retired() -> dict: """读退场表(读不到/坏了 ⇒ 空表,**⛔ 不让它把程序搞挂**)。""" try: d = json.loads(RETIRED.read_text(encoding="utf-8")) return d if isinstance(d, dict) else {} except Exception: return {} def _retired_save(d: dict) -> bool: """写退场表 —— **原子替换 + 回读核对**(⛔ 不用文件锁,理由见上面那段)。 ⚠️ 回读核对不是形式:本机"写下去但没落稳"踩过 ⇒ 写完后**重新读一遍比对**, 不一致就重试(最多 3 次),全失败 ⇒ 返回 False **并说出口**(⛔ 不静默成功)。 """ for _ in range(3): tmp = RETIRED.with_suffix(".json.tmp%d" % os.getpid()) try: RETIRED.parent.mkdir(parents=True, exist_ok=True) tmp.write_text(json.dumps(d, ensure_ascii=False, indent=1), encoding="utf-8") os.replace(str(tmp), str(RETIRED)) except Exception as e: log("retired 写失败 %s" % e) return False if _retired() == d: return True log("retired 回读核对连续 3 次不一致 ⇒ 放弃本轮写入") return False def _prune_retired(d: dict) -> dict: """退场表**自清理**:① 超 `RETIRED_TTL_DAYS` 天 ② 只留最近 200 条。""" cut = time.time() - RETIRED_TTL_DAYS * 86400 kept = {k: v for k, v in d.items() if float((v or {}).get("at") or 0) >= cut} if len(kept) > 200: kept = dict(sorted(kept.items(), key=lambda kv: -float((kv[1] or {}).get("at") or 0))[:200]) return kept def retire_add(sid: str, by: str = "", why: str = "") -> int: """登记一条会话退场。`sid` 已由调用方解析(支持 `self` ⇒ 环境里的会话 id)。""" sid = str(sid or "").strip() if not sid: print("⛔ 取不到会话 id(环境里没有 `CODEBUDDY_SESSION_ID`)⇒ 请显式给 `--retire <会话id>`") return 2 d = _prune_retired(_retired()) d[sid] = {"at": time.time(), "iso": time.strftime("%Y-%m-%d %H:%M:%S"), "by": str(by or ""), "why": str(why or "")} ok = _retired_save(d) print("%s 会话退场:%s%s%s(现共 %d 条退场记录)" % ("✅" if ok else "⚠️", sid[:8], (",接替者 " + str(by)[:8]) if by else "", (",原因:" + why) if why else "", len(d))) if not ok: print(" ⚠️ 落盘回读核对未通过 ⇒ 本轮退场**可能没生效**,下一轮再来一次") return 0 if ok else 1 def is_retired(sid_or_id8: str, table=None) -> bool: """这条会话是否已退场。⚠️ 比对**前 8 位**(本程序各处只存 8 位短 id)。""" k = str(sid_or_id8 or "").strip()[:8].lower() if not k: return False for s in (table if table is not None else _retired()): if str(s)[:8].lower() == k: return True return False def task_report(tid: str, state: str, by: str = "", artifact: str = "", reason: str = "", line: str = "") -> int: """任务会话**上报**状态转移(需求台账四态)。用法: `python collabd.py --report <需求id> --state pending|running|done|blocked [--by 会话名] [--artifact 产物] [--reason 阻碍原因]` ⚠️ `--state blocked` **必须**带 `--reason`(⛔ 不许只标"卡了"不说卡在哪、谁在等)。 """ tid = (tid or "").strip() state = (state or "").strip().lower() if not tid or state not in TASK_STATES: print("用法:--report <需求id> --state pending|running|done|blocked [--by 会话名] [--artifact 产物] [--reason 原因]") return 2 # 🔴🔴 2026-10-03 10:5x **`done` 必须带 `--artifact`**(用户要求逐字: # 「协作会话完成时 要写文档,**协作程序队列中要有对应文档的说明**」)。 # ⚠️ 为什么必须拒收:台账里一条 `done` 却没有产物指向 ⇒ 检查会话读到时 # **不知道该去核实什么** ⇒ 只能自己在工作区里翻(用户报障:「避免检查会话到处找信息」)。 # 🔴 与 `blocked` 必须带 `--reason` **同款**(不写"卡了"不说卡在哪)/⛔ 不是新规矩。 # ⚠️ 判据只查**台账里最终有没有产物**(`artifact` 或本轮新给的), # ⛔ **不判断文件是否真存在** —— 那是检查会话的活(它才有权核实), # 在这里查路径会把「相对工作区的写法」全部误杀(S5 的 artifact 就是技能内相对路径)。 if state == "done" and not (artifact or "").strip(): print("⛔ 拒收:`--state done` **必须**带 `--artifact <产物文档>`。\n" " 理由:任务会话完成时**要写文档**,台账里要有该文档的说明 ——\n" " 否则检查会话读到 `done` 却**不知道去哪核实**(用户 2026-10-03:「避免检查会话到处找信息」)。\n" " 正确写法:--artifact \"%s/S13_xxx_20261003.md\"\n" " (🔴 **必须落在目标目录里** —— 合同见 `DOC_CONTRACT_ROWS`;\n" " ⛔ 不许写回 `交付物/`、`docs/`、工作区根 ⇒ 那正是「到处找信息」的由来)\n" " 降级写法:若本轮确实没产出文档,**别报 done**,报 `--state blocked --reason \"文档未写\"`。" % goal_dir_rel()) return 3 # 🔴🔴 2026-10-04 **`done` 还要验「那个文件真的在」**(用户口径: # 「执行会话的结果要形成文档 …… 避免全工作区到处找信息」)。 # 病根(实测坐实):原来只查 `artifact` **有没有写**(非空即放行), # ⛔ **不查文件是否真存在** ⇒ 本区台账 `{"t":{"state":"done"}}` 这种**绕过闸的旧数据** # 照样躺在库里;给了路径但文件被删/移走/打错的,也照样算 `done` # ⇒ 检查会话照 `artifact` 去读 ⇒ **读不到 ⇒ 又回去到处找**(正是要避免的)。 # ⚠️ 为什么当年不查(旧注释写"怕误杀相对路径"):**那是把两种写法混成一件事** —— # "相对路径"从来不是问题,**路径拼错/产物没落盘**才是。 # ✅ 正解=**按工作区解析后真去 stat**(`artifact_state()` 已试两种斜杠形态); # 解析不出来 ⇒ **拒收并说清试过哪些路径**(⛔ 不猜、⛔ 不静默放行)。 # ⚠️ 与落点判据分工:**落点只提示**(`artifact_dir_ok()`,存量有合法例外), # **存在性是硬闸**(不存在 ⇒ 这条 `done` 根本不成立)。 if state == "done": _as = artifact_state(tid, {"state": "done", "artifact": artifact}) if _as["state"] != "ok": print("⛔ 拒收:`--state done` 的 `--artifact` **文件读不到**。\n" " 判据:`artifact_state()`(唯一事实源)⇒ state=%s\n" " 原因:%s\n" " 试过的路径:%s\n" " ✅ 正确做法:**先把文档写出来**,再用**相对工作区**的路径报它,例如\n" " --artifact \"%s/S13_xxx_20261003.md\"\n" " ⛔ 别报一个「打算写」或「写错目录」的路径 —— 下一个会话照它读会读空。\n" " ⚠️ 若确实写不了文档 ⇒ `--state blocked --reason \"文档未写\"`。" % (_as["state"], _as["why"], _as["abs"] or "(无)", goal_dir_rel())) return 3 _dk = artifact_dir_ok(artifact) if not _dk["ok"]: # ⚠️ **不拒收**(软判据):存量里有落在别处但有效的产物 ⇒ 硬拒会误杀既有工作流。 # 但**必须说出来** —— ⛔ 静默通过=这个合同等于没立。 print("⚠️ 提醒:`--artifact` %s" % _dk["why"]) log("artifact 落点不合合同(不拒收):%s ← %s" % (_dk["art"], _dk["why"])) if state == "blocked" and not (reason or "").strip(): # 🔴🔴 2026-10-03 10:5x **补上这个漏掉的拒收**(实测:原 docstring 写「`--state blocked` # **必须**带 `--reason`」,而**代码里根本没拦** ⇒ 实跑 `block_reason` 写成空串 `""` # ⇒ 台账里一条"受阻"却不说卡在哪 ⇒ 检查会话读到只能猜 ⇒ 又回去"到处找信息"。 # ⇒ 与 `done` 必须带 `--artifact` **同款**(都是"说清才能算完")。 print("⛔ 拒收:`--state blocked` **必须**带 `--reason <卡在哪、卡在谁>`。\n" " 理由:不写\"卡了\"不说卡在哪 ⇒ 台账里的受阻**没法据此派活**。\n" " 正确写法:--reason \"等 <谁> 回答 <什么>\"") return 3 t = _load_tasks() rec = dict(t.get(tid) or {}) prev = str(rec.get("state") or "") rec.update({"state": state, "by": by or rec.get("by", ""), "t": time.time()}) # 🔴🔴 2026-10-06 **给条目打「目标归属」**(用户:「队列 应该是随着目标的不是目标累积的」)。 # ⚠️ **新条目才打**(`setdefault`):存量条目**没有**这个字段 ⇒ ⛔ 不许补成"当前目标"—— # 那是**伪造归属**(旧条目本来属于旧目标,补成新的=把别人的活算到本目标头上, # 同族:`board.py` 那条「⛔ 不得吃本工作区数据」的红线一个道理)。 # ⇒ 存量条目 `goal_fp` 为空 ⇒ 看板按"无归属"另计(见 `_queue`),⛔ 不混进当前目标。 # ⚠️ 只写一次(`setdefault`)⇒ 同一条目**后续上报不改归属**(⛔ 防"跑到一半换目标 ⇒ 整条倒戈")。 if goal_fp(): rec.setdefault("goal_fp", goal_fp()) if line: rec["line"] = line # 线归属**写进条目**(⛔ 不再从会话 cwd 反推) if artifact: rec["artifact"] = artifact if state == "blocked": rec["block_reason"] = reason or rec.get("block_reason", "") else: rec.pop("block_reason", None) # 解除阻碍 ⇒ 原因一并清掉(⛔ 不留过期原因误导后续判断) if state == "running" and not rec.get("t_start"): rec["t_start"] = time.time() if state == "done": rec["t_end"] = time.time() t[tid] = rec # 🔴🔴 2026-09-30 改:**乐观重试**(治"多个主会话同时调用本 skill ⇒ 台账丢更新") # ⛔ **为什么不用文件锁**:实测在**隔离目录**里 OS 锁三项全绿(同进程反复 lock/unlock 0.01s; # 两进程真互斥 0.00s;释放后立刻拿到 0.00s),但**接进生产后** `--once` 与 6 个并发 `--report` # **全部 rc=124 卡住不退出**(⚠️ 数据其实写成功了)⇒ **本机这个方案不能用** ⇒ 换**零锁**方案。 # ✅ 乐观重试:**写 → 回读核对 → 被覆盖 ⇒ 以磁盘为准重新合并自己这条**(最多 3 次)。 # 适用性:本场景是"**低冲突、写小文件**" ⇒ 足够,且**没有任何卡死风险**。 # ⚠️ 实测登记:**8 并发 `--report` 在"无任何保护"的旧实现下也是 8/8 全成功**(没压出丢更新) # ⇒ 说明这是**低概率事件**;本补丁是"真冲突时能自愈"的保险,⛔ 不是复现过的故障修复。 for _try in range(3): _save_tasks(t) try: _back = _load_tasks() except Exception: _back = {} if str((_back.get(tid) or {}).get("state") or "") == state: break # ✅ 自己这条已写进去 if isinstance(_back, dict) and _back: t = dict(_back) # ⚠️ 被覆盖过 ⇒ 以磁盘为准,重新合并自己这条 t[tid] = rec time.sleep(0.05) try: with open(TASK_EVENTS, "a", encoding="utf-8") as f: f.write(json.dumps({"ts": time.strftime("%Y-%m-%dT%H:%M:%S"), "id": tid, "from": prev, "to": state, "by": by, "artifact": artifact}, ensure_ascii=False) + "\n") except Exception: pass log("task report %s: %s -> %s (%s)" % (tid, prev or "-", state, by or "?")) print("OK 已上报:%s %s -> %s" % (tid, prev or "(新)", state)) return 0 def _deliver_str(text: str, key: str, st: dict, topic: str = "") -> dict: """把一段文字投给**活会话**(网关官方 reply,不夺 ACP writer)。⛔ 口令只进程内用。 🔴 `topic`(2026-09-30 加)=**这条内容属于哪个任务类别**(=台账条目的 `line`)—— 用户要求「**同一个工作区** 多会话协作(通过协作会话名称前缀区分具体执行会话)」 ⇒ 投递**按类别选主会话**(见 `main_for_topic()`)。 · `topic` 省略 ⇒ 取 `default`(⛔ **默认类别**,与旧版行为一致 —— 唤醒这类"全局"内容就该走它) · `topic` 是已登记类别但**没解析出该类别的主会话** ⇒ **降级报告**(⛔ 不投给别类别) """ info = {"skipped": ""} if not C.get("wake_enable", True): info["skipped"] = "disabled" return info # 🔴 2026-09-29 用户定案:**投递前先判目标会话是否在跑 —— 只有"没在跑"才投**。 # (正在 `working` 的会话,投进去会插进它当前的轮次里 ⇒ 等它空闲再投,与 §2.1 握手同源) # 🔴 2026-09-30 改:目标从"唯一主会话"改成"**该任务类别的主会话**"。 # ⚠️ 此处**不做网关发现**(那一步在后面)⇒ 用**不含 live 信息**的解析做粗判; # 精确判定在拿锁后**用活网关列表重算一遍**(下面 `_rft = follow_for_topic(st, topic, # live_sids=[各网关口的 sessionId])` —— ⛔ 粗判结果**不得**当终局判据,见下)。 # 🔴🔴 2026-10-01 改(用户口径):「**换新会话 唤醒的是 跟进会话,主会话只能是用户触发**」 # ⇒ 目标从"该类别的主会话"改成"**该类别的跟进会话**"(见 `follow_for_topic()`)。 # 主会话**不再接机制侧的任何推送** —— 强行投它=用机制噪音挤掉用户的话。 # ⚠️ 这里仍是**不含 live 信息的粗判**(精确判定在拿锁后用活网关列表重算一遍,见下)。 _rft0 = follow_for_topic(st, topic, live_sids=[]) _tgt = str(_rft0.get("sid") or "") if _tgt and _target_busy(_tgt): log("延后投递:跟进会话 %s 正在执行 ⇒ 等它空闲" % _tgt[:8]) info["skipped"] = "target-busy" return info # 🔴 2026-10-01 加:目标会话**已哑**(诊断日志撞 ~10 MiB ⇒ EPERM ⇒ 界面不再刷新) # ⇒ 投了也看不见,记 ok:true 是假绿 ⇒ ⛔ 不投,改喊用户换会话。 # 🔴🔴 2026-10-02(S5 · 验收 V2)修:**粗判的 `_tgt` 不许用来做"终局"判定**。 # 真因(本棒实测,⛔ 非推断):`live_sids=[]` ⇒ `follow_for_topic()` 里 `live` 为空 ⇒ # `_pick_live()` 按设计**回退 `c[0]`**("探测不到时不许假装知道谁活着",见 `_pick_live()`), # 而 `c[0]` =**最近活动的候选**,⛔ 与"活着"无关 ⇒ 旧写法 `_tgt in _deaf_sids()` 拿它做 # **终局短路**(`target-deaf` + `need_user`):现场名册里只要**第一条候选恰好是那条哑的**, # 每轮都**在拿锁之前就返回**,**永远走不到**下面第 1542 行那次"用活网关列表重算"—— # 于是"候选里有活的"这个事实**一辈子没机会被发现**(实测对照见 `tmp/_s5_probe_20261002.py`: # 同一 topic,粗判给 `57f58ecf`,精确给空 ⇒ 两条判据打架)。 # ⇒ 判据改成**只看候选集合、不看它的第一条**:**该类别候选"全部"都哑**才成立"没人能收"。 # ① 只有一条候选(`t_deaf_skip` 场景)⇒ 逐字等价旧行为(绿灯不假红); # ② 有候选活着 ⇒ ⛔ 不短路,交给精确阶段用活网关列表正挑(那才是权威判定); # ③ 候选集合为空 ⇒ 不判("一条都没有"由后面 `no-follow-session` 分档,⛔ 不重复告警)。 # ⚠️ 判据用 `cand` —— 🔴 2026-10-02 S5 起它的语义已改成**该类别候选全表**(按最近活动倒序)。 # ⛔ 修之前它只是"收窄后的那一条" ⇒ 判"全哑"会退化成"那一条哑"(=旧行为,白修)。 # ⚠️ 这两处**必须同批**:`follow_for_topic()` 一收窄,这里的"全哑"就失真。 _cand0 = [str(x) for x in (_rft0.get("cand") or []) if str(x)] _deaf0 = _deaf_sids() if _cand0 and all(_c in _deaf0 for _c in _cand0): _tp0 = (topic or "").strip() or "(默认类别)" need_user("🔴 任务类别「%s」的跟进会话**全都已哑**(诊断日志撞 ~10 MiB 上限 ⇒ 宿主拒写 EPERM ⇒ " "界面再也刷不出内容,投给它的通知全部落空)⇒ **一条能收的都没有**。" "**请给这个类别建一条新的跟进会话**(已哑的那些只能弃用)。" % _tp0) log("停投:类别 %s 的 %d 条候选跟进会话全已哑(日志撞上限)⇒ 改喊用户" % (_tp0, len(_cand0))) info["skipped"] = "target-deaf" return info # 🔴🔴 2026-09-30 加:**快速否决(⛔ 不取锁、⛔ 不删锁)** —— 治"常驻被护栏杀掉"(实测真因): # `_deliver_str` 每轮尝试都会在 finally 里 `unlink(wake.lock)`;宿主有 **SafeDelete 批量删除护栏** # (按"本轮删除次数"计数,达阈值即要求确认并**拒绝**)⇒ 常驻投递跑约 50 次后**被系统终止**。 # 实测:后台任务 `Syz5DD` 跑 **48m43s** 后 `failed`,stdout 原文 # `[safe-delete][SAFE_DELETE_BULK_CONFIRM_REQUIRED] {"count":50,"threshold":50,…targets:[…\wake.lock"]}` # ⇒ **唤醒的时钟就是这么断的**(用户当晚的关切正是"别回来还在发呆")。 # 修法:把 hash / 最小间隔的**预判提到取锁之前** —— 高频的 `same-item` / `too-soon` 路径 # (实测占绝大多数:日志里成片 "投递未成(too-soon)")**根本不碰锁文件** ⇒ 删除次数降到"只在可能真投时"。 # ⚠️ 取舍:锁内仍会**重读 STATE 再判一次**(那是权威判据),所以竞态窗口由 `wake_min_gap` 兜底 ⇒ ⛔ 不会双投。 _h0 = hashlib.sha1((key + "|" + text).encode("utf-8", "replace")).hexdigest()[:16] _W0 = st.get("wake") or {} if _W0.get("hash") == _h0: info["skipped"] = "same-item" return info if time.time() - float(_W0.get("ts") or 0.0) < float(C.get("wake_min_gap") or 300): info["skipped"] = "too-soon" return info # 🔴 2026-09-29 23:12 修("卡 working / 发消息没反应"复盘): # **两个常驻进程(常驻程序 + 投递)各自投递、共用状态文件但无互斥** ⇒ 都读到旧哈希 # ⇒ **同一内容成对重复投递**(实测同一 hash 秒级出现两次:22:24:04/30、22:40:23/26、 # 22:48:32/55、22:51:01×2)⇒ 每次都往主会话的会话队列里**压消息**(22:53 起该会话进入 # parkInQueue「只进不出」⇒ 用户随后发的消息排在后面 ⇒ 表现为"卡死、没反应")。 # ⇒ 三招:**原子取锁(跨进程互斥)+ 拿锁后重读状态 + 投完立刻落盘**。 # 🔴🔴 2026-10-01 再改:**互斥不再删文件** —— 治「常驻被 SafeDelete 护栏杀掉」的**真·高频源**。 # P0-6 记过这一条,但当时只做了「快速否决」(把 same-item/too-soon 挡在取锁之前)。 # 实测**仍会死**:只要每轮内容都在变(队列头/告警读数天天动 ⇒ 哈希轮轮不同), # 「快速否决」就一次都命中不了 ⇒ **每轮都取锁 ⇒ 每轮 `unlink(wake.lock)`** # ⇒ 宿主按「本轮删除次数」计数,**达 50 即判 `SAFE_DELETE_BULK_CONFIRM_REQUIRED` 并终止进程**。 # 现场读数(`tmp/supervise-inbox/_supervise-bg.out.log`,本机实拍): # `[safe-delete][SAFE_DELETE_BULK_CONFIRM_REQUIRED] {"count":50,"threshold":50,"targetCount":1, # "targets":["E:\\…\\tmp\\supervise-inbox\\wake.lock"]}` # ⇒ 常驻约 **8 分钟**(≈10 s/轮 × 50)就被杀 —— 与「20:52 起、20:56 查已无此进程」完全吻合。 # ⇒ 新形态:**锁文件永久存在,释放 = 原地改写内容**(`pid` ↔ `free`)⇒ **零删除**。 # ⚠️ 判据随之从「文件在不在」改成「**内容 + 陈旧度**」(内容为空/`free`/mtime 超 120 s ⇒ 可抢)。 # ⚠️ 原地写**非原子** ⇒ 并发读可能看到半截串 ⇒ 那**不算** `free` ⇒ **fail-closed**(宁可少投,⛔ 不乱投); # 抢锁后**回读核对**(本项目既有的乐观重试手法),核对不上就不算拿到。 _lk = INBOX / "wake.lock" _got = False _me = str(os.getpid()) try: _fd = os.open(str(_lk), os.O_CREAT | os.O_EXCL | os.O_WRONLY) # 只在**首轮**走到(之后文件恒在) os.write(_fd, _me.encode()) os.close(_fd) _got = True except FileExistsError: try: _cur = _lk.read_text(encoding="utf-8", errors="replace").strip() _stale = (time.time() - _lk.stat().st_mtime) > 120 # 残锁 >120 秒 ⇒ 抢占 if _cur in ("", "free") or _stale: _lk.write_text(_me, encoding="utf-8") # 覆盖写=占用(⛔ 不 unlink) _got = (_lk.read_text(encoding="utf-8", errors="replace").strip() == _me) # 回读核对 except Exception: _got = False except Exception: _got = True # 取锁本身出错 ⇒ ⛔ 不因它挡死投递 if not _got: info["skipped"] = "locked" return info try: try: # 拿锁后**重读**状态(⛔ 不用陈旧内存哈希) _st2 = json.loads(STATE.read_text(encoding="utf-8")) or {} if isinstance(_st2, dict) and _st2.get("wake"): st["wake"] = _st2["wake"] except Exception: pass h = hashlib.sha1((key + "|" + text).encode("utf-8", "replace")).hexdigest()[:16] W = st.get("wake") or {} if W.get("hash") == h: info["skipped"] = "same-item" return info if time.time() - float(W.get("ts") or 0.0) < float(C.get("wake_min_gap") or 300): info["skipped"] = "too-soon" return info finally: # 🔴 释放 = **原地改写**成 `free`,⛔ **绝不 unlink** —— 理由见上面那段(SafeDelete 护栏按"本轮删除次数" # 计数,50 次即杀进程;锁文件每轮删一次正是那个计数的来源)。 if _got: try: _lk.write_text("free", encoding="utf-8") except Exception: pass tok = os.environ.get(GW_ENV) or "" if not tok: # 🔴 投递不到 ≠ 可以沉默 —— 但**两种"没口令"要分开处置**(见 `FROM_HOOK` 处说明): if FROM_HOOK: need_user("有内容要投给跟进会话,但**连宿主钩子进程里都拿不到网关口令**(%s)" "—— 属异常(宿主版本/注册面变了?),⛔ 请勿当成常驻进程的正常现象" % key) else: log("投递跳过:本进程不在宿主进程树内(无 %s)—— **设计使然**(投递由宿主钩子唤起)" % GW_ENV) info["skipped"] = "no-token" return info gws = discover_gateways() # 🔴 2026-09-29 修(用户截图暴露):**桌面上会有很多会话窗口** ⇒ 不能只取"第一个带会话 id 的口", # 否则通知会**投错窗口**。 # 🔴 2026-09-30:主会话从"登记的"改成"**解析出来的**";⛔ 删掉过一版盲选回落 # (`next((x for x in gws if x["sessionId"]), None)` —— 随手投给任意一条会话)。 # 🔴🔴 2026-10-01 **再改:目标根本不是主会话**(用户:「换新会话 唤醒的是 跟进会话, # 主会话只能是用户触发」)⇒ 换 `follow_for_topic()`:**按任务类别取跟进会话**。 # ⛔ 解析不出 ⇒ **不降级、不盲投**,直接**喊用户**("没人收上报"必须说出口)。 _rft = follow_for_topic(st, topic, live_sids=[x.get("sessionId") for x in gws] if gws else []) _want = str(_rft.get("sid") or "") g = next((x for x in gws if x["sessionId"] and _want and str(x["sessionId"]) == _want), None) if gws else None if g is None: _tp = (topic or "").strip() or "(默认类别)" # 🔴🔴 2026-10-02 修(本棒 P1①):**把 `follow_for_topic()` 已算准的 `why` 透传进 `skipped`**。 # 旧实现只按 `_want==""` 分档 ⇒ 把 **(a) 真·一条候选都没有** 与 **(b) 有候选、但都不在活会话里** # **合并记成同一个 `no-follow-session`** —— 字面意思与事实**相反**,把两天的排查引向 # 「再多建一条跟进会话」(实测:现场名册里 **6 条都在、一条都不活** ⇒ 多建**不管用**)。 # ⛔ 本处**只改记档名与喊用户文本** —— 投不投 / 喊不喊 / 走哪条分支 **全同旧行为**。 _why = str(_rft.get("why") or "") if _why not in ("no-follow-session", "follow-not-live", "follow-retired"): _why = "follow-not-live" if _want else "no-follow-session" # 兜底:⛔ 不制造新档位 # 🔴 2026-10-02:`follow-retired` 走**自己的**分支(⛔ 别被兜底改写成上面两个档 —— # 那两个档的文案会喊用户"去建跟进会话",而那条路已经不存在了) info["skipped"] = _why # 🔴🔴 2026-10-02 23:4x 两类改造:**跟进会话整套退役** ⇒ 本分支恒成立 # (`follow_for_topic()` 已短路成 `why="follow-retired"`)。 # ⛔ 文案**不许再喊用户「去建跟进会话」** —— 那机制已不存在,喊了就是**说假话指错路**。 if _why == "follow-retired": need_user("⚠️ **投递链路没有收件人** —— 唤醒会话/跟进会话/队列上报机制已于 2026-10-02 " "按用户口径**整套退役**(用户原话:「按照之前的讨论 唤醒会话 跟进会话 和 上报程序 " "都去掉才对」「上报机制也不需要了」)。⇒ **⛔ 不投主会话**(主会话只由用户触发)、" "也不盲投。这条内容要落下去,⛔ 走常驻程序**直接建任务会话**处理,别等上报。") elif _why == "follow-not-live": need_user("⚠️ 有内容要投给跟进会话,但**本工作区在跑的那条跟进会话不在活会话里**" "(找到 %d 条 `[跟进]-…`,都不活)⇒ **不投主会话**(用户口径:主会话只由用户触发)," "也不盲投任意会话。**请把「%s」那条跟进会话拉起来。**" % (len(_rft.get("all") or []), _tp)) else: _n_all = len(_rft.get("all") or []) if _n_all: need_user("⚠️ 有内容要投给跟进会话,但**任务类别「%s」没有对应的跟进会话**" "(本工作区有 %d 条 `[跟进]-…`,都不属这个类别)⇒ **不投主会话**" "(用户口径:主会话只由用户触发)、也不盲投。**没人收上报**就是当前的真实状态 —— " "请给「%s」这个类别建一条跟进会话(§2.3.0d 开工建齐三类)。" % (_tp, _n_all, _tp)) else: need_user("⚠️ 有内容要投给跟进会话,但**本工作区一条 `[跟进]-…` 都没有**" "(任务类别「%s」)⇒ **不投主会话**(用户口径:主会话只由用户触发)、也不盲投。" "**没人收上报**就是当前的真实状态 —— 请建一条跟进会话(§2.3.0d 开工建齐三类)。" % _tp) return info if g is not None and _target_busy(str(g.get("sessionId") or "")): log("延后投递:跟进会话 %s 正在执行 ⇒ 等它空闲" % str(g.get("sessionId"))[:8]) info["skipped"] = "target-busy" return info # 🔴 2026-10-01 加:目标**已哑**(诊断日志撞 ~10 MiB ⇒ EPERM ⇒ 界面不再刷新)⇒ 投了也看不见。 # 实测 f8a792ab:completed 却仍在网关口 live ⇒ 绕过 `_pick_live` 就可能命中它 ⇒ 这里再拦一道。 if g is not None and str(g.get("sessionId") or "") in _deaf_sids(): need_user("🔴 跟进会话 `%s` 的诊断日志已撞 ~10 MiB 上限(宿主拒写 EPERM)⇒ 它的界面再也刷不出内容," "投给它的通知全部落空(记 ok:true 是假绿)。**请把跟进会话换到一条新会话**,旧的那条只能弃用。" % str(g.get("sessionId"))[:8]) log("停投:目标 %s 已哑(日志撞上限)⇒ 改喊用户" % str(g.get("sessionId"))[:8]) info["skipped"] = "target-deaf" return info if g is None: need_user("有内容要投给跟进会话,但**没有活会话**可投(%s)—— 请打开任意工作区会话" % key) info["skipped"] = "no-live-session" return info stx, _body = _gw_http(g["port"], "POST", "/api/v1/sessions/%s/reply" % g["sessionId"], {"text": text}, tok, timeout=20.0) ok = stx in (200, 201, 202) _wakeup_log({"ts": time.strftime("%Y-%m-%dT%H:%M:%S"), "epoch": round(time.time(), 1), "port": g["port"], "sessionId": g["sessionId"], "hash": h, "http": stx, "ok": ok, "kind": key}) # 🔴 2026-09-30 加:投递成功**不等于**被消费 ⇒ 记下"期望"(下一轮回查该会话有没有真的动)。 # 🔴 2026-10-01:多一个 `target` 字段 —— 这次投的是**跟进会话**(⛔ 不是主会话)。 # ⚠️ 必须有它:`_main_sid()` 的**回落**读的就是 `wake.sessionId`("上次投给谁"), # 不标出来的话,投过一次跟进会话之后,**跟进会话就会被当成主会话**(判据被污染)。 st["wake"] = {"ts": time.time(), "hash": h, "port": g["port"], "sessionId": g["sessionId"], "http": stx, "expect": {"sid": g["sessionId"], "at": time.time()}, "target": "follow"} try: STATE.write_text(json.dumps(st, ensure_ascii=False), encoding="utf-8") # 立刻落盘 ⇒ 另一进程读到新哈希 except Exception: pass log("notify(%s) -> %s@%s http=%s hash=%s" % (key, str(g["sessionId"])[:8], g["port"], stx, h)) info["delivered"] = {"http": stx, "kind": key} return info def _session_status(sid: str) -> str: """只读宿主库拿某会话的状态。 🔴🔴 **2026-10-01 订正(这条注释以前写的是假话)**:原文写「`working` = 正在执行」。 实测取值域只有 `working 2 / completed 123 / archived 2 / error 1` —— ⛔ **没有"空闲"这一档**, 而**活着的会话跑完一轮、停在等下一轮时,status 仍然是 `working`** ⇒ 拿它判"忙"=**恒真** ⇒ 投递**永远等不到空闲**。 ✅ 「在不在执行」请用 `_session_busy()`(宿主状态机日志),⛔ **别再用本函数判忙**。 本函数**只**用于「活着 / 已终结」(`SID_DEAD` 那套判死逻辑)。 """ if not sid: return "" try: con = _db() row = con.execute("select status from sessions where id=?", (sid,)).fetchone() con.close() return str(((row or [""])[0]) or "").lower() except Exception as e: log("session_status 读失败 %s" % e) return "" # ── 🔴🔴 2026-10-01 加:「目标到底**在不在执行**」的权威判据(治「状态窗口死结」)────────── # **病根(实测定性)**:`sessions.status` 的取值域只有 `working/completed/error/archived` # —— ⛔ **没有"空闲"这一档**。一条**活着的**会话跑完一轮、停在等下一轮时,**status 仍是 `working`**。 # ⇒ 拿 `status=='working'` 判"忙"**恒真** ⇒ 投递永远等不到"空闲"。 # ⇒ 而等它真的结束(`completed`)时,它又**不 live** ⇒ 更投不进去。 # **实测现场**:`[跟进]-[会话协作自检]-点火验证投递`(`e2ccdea3`)22:39:26 建, # `last_activity_at` 冻在 22:39:27,`status` 一直 `working` ⇒ 常驻程序连打 6 分钟 # `延后投递…正在执行 ⇒ 等它空闲` + `投递未成(target-busy)⇒ 保留 M5=done 在队首`。 # ⚠️ 唯一投成过一次(08:55 `http:200 ok:true`)目标是 `f8a792ab` —— 一条**日志已冻结的"死"会话** # ⇒ 旁证:**只有"死"会话才投得进去**。 # **真正的判据在宿主自己的状态机日志里**(`[SessionRunStateMachine]`,⛔ 只写工作区日志、 # ⛔ **不进数据库**——所以查库永远查不到): # `event=AGENT_STARTED / RUN_ACCEPTED` ⇒ `lifecycle=running busy=true` # `event=AGENT_ENDED` ⇒ `to=idle lifecycle=idle busy=false queueBusy=false` # ⇒ 判据 = **该 sid 的最后一条状态机记录**:`busy=true` 且**新鲜** ⇒ 在跑;否则 ⇒ 空闲。 # 🔴 「新鲜」这一半**必须有**:会话真在跑时状态机**每几百毫秒就写一行**(模型流式唤醒)⇒ # 「最后一条 `busy=true` 却已经是 5 分钟前」**只可能**是它早停了 ⇒ 按空闲处理。 # 这样**不必依赖"正好抓到那条 `AGENT_ENDED`"**(它会随时间被挤出尾部窗口)。 # ⚠️ 已知边界:一轮里跑**很长的单个工具调用**时(实测 60 s 采样器脚本),状态机同样会静默 # ⇒ `SRSM_FRESH` 取得比"最长单次工具调用"更长才安全(现取 300 s)。详见 `pitfalls.md P0-19`。 SRSM_TAIL = 8 * 1024 * 1024 # 只读日志尾部(工作区日志可达几十 MB ⇒ ⛔ 不许整文件扫) SRSM_FRESH = 900 # 秒:最后一条 `busy=true` 超过这个时长 ⇒ 判"早已不在跑" # 🔴 **必须用命名组**(本轮自己踩过、当场报红):写成 `(?:\.(\d+))?` 时内层 `(\d+)` **仍是捕获组** # ⇒ 后面的 `m.group(8)` 全部**错位一位** ⇒ `busy` 恒读成 `False` ⇒ **恒判"空闲"**(恒投,方向相反)。 # 命名组没有"数括号"这一步 ⇒ 从形状上消掉这类错。详见 `pitfalls.md P0-19`。 _SRSM_RE = re.compile( r"\[(?P\d{4})/(?P\d{1,2})/(?P\d{1,2}) (?P\d{2}):(?P\d{2}):(?P\d{2})(?:\.(?P\d+))?\]" r".*?\[SessionRunStateMachine\]" r".*?sessionId=(?P[0-9a-fA-F-]{36}).*?\bbusy=(?Ptrue|false)\b") # 🔴🔴 2026-10-04 **第二个源**(会话级日志)的正则 —— ⛔ 与上面**字段完全不同**: # 那个文件里**没有** `[SessionRunStateMachine]`、**没有** `busy=`(实测各 0 次), # 只有 `state-machine:transition {"from":"working","to":"idle"}`。 # ⛔ 拿 `_SRSM_RE` 去读会话级日志 ⇒ 恒得 0 条 ⇒ 恒判"读不到" ⇒ 恒判"没在跑"。 _CONV_TR = re.compile(r"state-machine:transition.*?\"to\":\"(?P[a-z_]{3,20})\"") # 🔴 2026-10-04 加:会话级日志的**行首时间戳**(`2026-10-04T06:15:31.836Z` ⇒ **UTC 带Z**)。 # ⛔ 与工作区级的 `[2026/10/4 14:15:31.823]`(**本地时间、无 Z**)**不是同一个时基** ⇒ # 解析必须按各自口径,否则跨源比大小会差8 小时(东八区)⇒ 判据恒错。 # ⛔ 解析不出就返回 `None`(调用方按"保守当busy"处理,⛔ 不猜时间)。 _CONV_TS = re.compile(r"^(?P\d{4})-(?P\d{1,2})-(?P\d{1,2})T(?P\d{2}):(?P\d{2}):(?P\d{2})(?:\.(?P\d+))?Z") def _conv_ts(ln: str): """会话级日志行首时间戳 ⇒ unix 秒(float)。解析不出 ⇒ `None`。 🔴 **必须按 UTC 解析**(行尾那个 `Z` 就是 UTC 标记)⇒ 用 `calendar.timegm` 语义, ⛔ **不能用 `time.mktime`**(那是按本地时区解释 ⇒ 东八区会差8 小时 ⇒ 新鲜度闸恒判"过期")。 """ m = _CONV_TS.match(ln) if not m: return None try: import calendar t = float(calendar.timegm((int(m.group("Y")), int(m.group("M")), int(m.group("D")), int(m.group("h")), int(m.group("m")), int(m.group("s")), 0, 0, 0))) except Exception: return None ms = m.group("ms") return t + (float("0." + ms) if ms else 0.0) def _session_busy(sid: str) -> str: """目标会话**此刻在不在执行**。returns `'busy'` / `'idle'` / `''`(读不到 ⇒ **不猜**)。 ⛔ 只读:只 tail 本工作区日志,⛔ 不写任何文件、⛔ 不碰宿主库、⛔ 不回落成"猜"。 ⚠️ **本函数只看日志、⛔ 不看库** ⇒ "已终结的会话日志末条可能仍是 `busy=true`"这种情形 **由调用方 `_target_busy()` 的第一道判负责**(⛔ 别在这里加,保持它可被合成样本单测)。 ⚠️ `SRSM_FRESH` 取 900 s 而不是更短:一轮里跑**很长的单个工具调用**时状态机**全程静默** (实测:本机一次 8 MB 日志扫描就静默了 ~6 分钟)⇒ 窗口取太短会把**正在跑**的会话误判成 **空闲**(方向相反的错:会往正在跑的会话里插话)。多等 ≤15 分钟**不算损失**(队列件本来就压着)。 """ if not sid: return "" last, seq = None, 0 # `seq` = 行序:**同一秒内后出现的必须胜出** for p in _log_dirs(): # (⚠️ 时间戳只到毫秒、同秒同毫秒真会发生 ⇒ try: # 只比 `t > last[0]` 会让**前一条**胜出 ⇒ 取错状态) sz = os.path.getsize(p) with open(p, "rb") as f: if sz > SRSM_TAIL: f.seek(sz - SRSM_TAIL) buf = f.read().decode("utf-8", "replace") except Exception: continue for ln in buf.splitlines(): seq += 1 if "SessionRunStateMachine" not in ln or sid not in ln: continue m = _SRSM_RE.search(ln) if not m: continue try: t = time.mktime((int(m.group("Y")), int(m.group("M")), int(m.group("D")), int(m.group("h")), int(m.group("m")), int(m.group("s")), 0, 0, -1)) t += float("0." + (m.group("ms") or "0")) except Exception: continue if last is None or (t, seq) >= last[0]: last = ((t, seq), m.group("busy") == "true") if last is None: return "" return "busy" if (last[1] and (time.time() - last[0][0]) <= SRSM_FRESH) else "idle" def _conv_log(sid: str): """会话级日志路径:`<配置根>/logs/<今天|昨天>/sdk/conversations/.log`。 🔴🔴 **为什么要有第二个源**(2026-10-04 `vibe-product` 实测):工作区日志 `__.log` 的**候选集**可能整个为空 —— `_log_dirs()` 靠 `WS` 反推工作区名, `WS` 一旦落在别处就拼不出任何`__*.log` ⇒ `_session_busy()` 恒 `''`。 ⚠️ **晚间复验**:曾写成「该 sid 在工作区日志里一条都没有(宿主不写)」—— **这条是错的**(实测同文件含该 sid 的状态机记录 695 条),已从代码里改正。 ⛔ 结论不变但**理由要换**:不是"宿主不写",而是"**候选集可能为空**"⇒ 第二源不依赖 `WS`,仍然必要。 ⚠️ **格式完全不同**(实测,⛔ 两边字段不通用): · 工作区级 → `... SessionRunStateMachine ... busy=true`(找 `busy=`) · 会话级 → `... state-machine:transition {"from":"working","to":"idle"}`(找 `to`) ⚠️ **跨日不换名**(同工作区日志实测)⇒ 两个日期都试。 """ if not sid: return [] # 🔴 取配置根**照抄 `_log_dirs` 的姿势**(`os.environ.get(...) or expanduser`)—— # ⛔ 别写 `Path(CODEBUDDY_CONFIG_DIR)`:那不是全局名,运行时会 NameError。 root = os.path.join(os.environ.get("CODEBUDDY_CONFIG_DIR") or os.path.expanduser("~/.workbuddy"), "logs") out = [] for d in (time.strftime("%Y-%m-%d"), time.strftime("%Y-%m-%d", time.localtime(time.time() - 86400))): conv = Path(root) / d / "sdk" / "conversations" # 🔴🔴 2026-10-04 实测踩坑:**文件名带连字符**(`b232218f-c8e6-49cb-…`), # 而调用方常拿的是**库里的 32 位无连字符 id** ⇒ 直接拼 `.log` 恒不命中 # ⇒ 回落源恒为 `''` ⇒ 修前那个"必然误判"照旧。⇒ **两种形态都试**。 for cand in ("%s.log" % sid, "%s.log" % str(sid).replace("-", "")): p = conv / cand if p.exists(): out.append(p) # 兜底:目录里按前缀找(⛔ 只在**该 sid 前 8 位唯一命中**时采纳,⛔ 不许瞎猜) if not out and conv.is_dir(): try: pre = str(sid)[:8] hits = [f for f in os.listdir(str(conv)) if f.startswith(pre) and f.endswith(".log")] if len(hits) == 1: out.append(conv / hits[0]) except Exception: pass return out def _session_busy_conv(sid: str) -> str: """**第二个源**(会话级日志)判「在不在执行」,returns `'busy'` / `'idle'` / `''`。 🔴 判据:**末条** `state-machine:transition` 的 `to` —— `working`/`planning` 算在跑, `idle`/`completed` 算空闲;⛔ 读不到**返回 `''` 不猜**(与 `_session_busy` 同口径)。 ⚠️ **新鲜度**(对齐 `SRSM_FRESH`):长时间静默的工具调用会让末条停在 `working` ⇒ 那是**偏保守**方向(判成在跑 ⇒ 最多多等,⛔ 不会往在跑的会话插话)⇒ 可接受。 🔴🔴 2026-10-04 修**两个真缺陷**(`vibe-product` / `b232218f…` 实测坐实,两者**同向**): ① **跨日文件按遍历顺序覆盖**(`_conv_log` 返回 `[今天, 昨天]`)⇒ **昨天**那条 `working` 会把**今天**已经 `idle` 的状态盖掉 ⇒ 判`busy`。实测样本:今天末条 `to=idle`、 昨天末条 `to=working` ⇒ 改前判 `busy`(错),改后判 `idle`(对)。 ② **完全没有新鲜度闸**(`_session_busy` 有 `SRSM_FRESH`,本源一个都没有) ⇒ 3 小时前卡在 `working` 的会话**永久判"在跑"**。 ⚠️ ① 和 ② 的后果**完全一致**:`busy` ⇒ 调用方 `skipped="target-busy"` ⇒ **已收工/已死活的会话永远收不到上报,且没有任何告警**(静默饿死)。 ⇒ 修法:**跨文件取时间戳最新的一条**(⛔ 不是遍历到的最后一条), 再套 `SRSM_FRESH` 新鲜度闸。**保留"偏保守"方向**:陈旧 ⇒ 判 `idle`(可投), 因为真正的死会话由 `_target_busy` 第 ① 道闸(库里 `SID_DEAD`)兜底。 """ last_t = None # 末条的**时间戳**(跨文件比大小,⛔ 不比遍历顺序) last_to = None seq = 0 for p in _conv_log(sid): try: sz = p.stat().st_size with open(p, "rb") as f: if sz > SRSM_TAIL: f.seek(sz - SRSM_TAIL) buf = f.read().decode("utf-8", "replace") except Exception: continue for ln in buf.splitlines(): seq += 1 if "state-machine:transition" not in ln: continue m = _CONV_TR.search(ln) if not m: continue ts = _conv_ts(ln) # 🔴 同一秒内也可能有多条 ⇒ 用 `(ts, seq)` 复合键,"出现得晚的胜出" key = (ts if ts is not None else 0.0, seq) if last_t is None or key >= last_t: last_t = key last_to = m.group("to") if not last_to: return "" if last_to in ("working", "planning"): # 🔴 新鲜度闸:末条"在跑"但已经超过窗口 ⇒ 当年那种"早停了却没写 busy=false"的同一类。 # ⛔ 只有解析出时间戳才判;解析不出(格式变了)⇒ **保守当busy**(宁可多等)。 if last_t[0] > 0 and (time.time() - last_t[0]) > SRSM_FRESH: return "idle" return "busy" return "idle" def _target_busy(sid: str) -> bool: """投递判据:**目标在跑 ⇒ 等它空闲再投**。 🔴 **语义不变、只换判据形态**(用户 2026-10-01 口径原话: 「**跟进会话 执行完了 在上报没问题,执行中就等待上报**」)—— ⛔ **不是**"超时即投",⛔ 也没取消"执行中等待"。 换掉的只是**怎么知道它在执行**:原来用 `sessions.status=='working'`(**活着就恒真**,见上), 现在用宿主状态机日志的 `busy=`。 🔴 **两道判(缺一不可)**: ① 库里 `status` 是**终结态**(`SID_DEAD`)⇒ **直接判"没在跑"**,⛔ 不看日志 —— 实测:**自动化拉起的会话跑完不写 `busy=false`**(今天整份日志里 `busy=false` 只出现过 12 次、**全是主会话的**),它直接在库里变 `completed`,而日志里最后一条**仍是 `busy=true`** (`e2ccdea3`:末条 `22:42:10 MODEL_STREAM_STARTED busy=true`,库里 22:45:53 已 `completed`) ⇒ 只看日志会把**早就结束的会话**再"忙"上 15 分钟。 ② 库里是 `working` ⇒ **才**去看日志细分「本轮在跑 / 轮间空闲」。 ⚠️ 读不到(`''`)⇒ **按"没在跑"处理**并**留一行日志**:⛔ 不能按"忙"处理 —— 那等于把原来的恒真死结换个形状留着。⚠️ 但"不 live"另有 `_pick_live` 把关,投给死会话仍会被拦。 🔴🔴 2026-10-04 **修正上面那条 fail 方向**(`vibe-product` 实测坐实,⛔ 原注释写的方向是错的): 工作区日志里 `SessionRunStateMachine` 明明有 **615** 条,但 `b232218f` 这个 sid **一条都没有** (全文件 0 次)⇒ 宿主**根本没为它写状态机日志** ⇒ `_session_busy()` 读 `''` ⇒ 老逻辑**必然误判「没在跑」⇒ 往正在跑的会话里插话**(方向相反的错,比恒真更隐蔽)。 ✅ 正解=**双源**:工作区日志读不到时,回落到**会话级日志** (`<配置根>/logs/<日期>/sdk/conversations/.log`)的状态机转换。 ⚠️ 那个文件里的事件名是 `state-machine:transition`,字段是 `from`/`to` (⛔ **不是** `busy=`;按 `busy=` 找会得 0 条)。 ⚠️⚠️ **2026-10-04 当天晚间复验:上面那个「全文件 0 次」是错的,⛔ 别再照它推理。** 同一份工作区日志实测 `SessionRunStateMachine` **1935 条**、其中含本 sid 的 **695 条** (末条 `14:15:31 AGENT_ENDED … busy=false`)⇒ 宿主**确实写**了工作区级状态机日志。 ⇒「读不到」的真实成因是**候选集为空**:`_log_dirs()` 靠 `WS` 反推工作区名, 一旦 `WS` 落在别处(如从别的目录 import 本模块)就**一个候选都拼不出来** ⇒ 返回 `[]` ⇒ 恒 `''`。⚠️ 这也说明**双源是必要的**:第二源不依赖 `WS`。 (原写法留在下面是为了留痕,⛔ 不要当判据依据。) """ if _session_status(sid) in SID_DEAD: # ① 已终结 ⇒ 早就不在执行(日志可能还停在 busy=true) return False b = _session_busy(sid) # ② 活着 ⇒ 用状态机日志细分 if b == "busy": return True if b == "": # 🔴 2026-10-04 新增第 ② 之半:工作区日志读不到 ⇒ **再问会话级日志**(双源) b2 = _session_busy_conv(sid) if b2 == "busy": log("忙判据回落会话级日志:在跑(%s)" % str(sid)[:8]) return True log("忙判据**双源都读不到**(%s):工作区日志无状态机记录、会话级日志末条=%r" " ⇒ 按**没在跑**处理" % (str(sid)[:8], b2)) return False # ── 🔴 2026-09-30 加:**宿主侧「消息卡住」探针**(用户给出窗口 22:55–23:20,实测定型) ──── # 指纹:工作区日志里 `PromptIterator … route=parkInQueue … hasWaiter=false` # ⇒ **消息进了队列、但没有消费者** ⇒ 界面一直转、发消息没反应。 # 真因=**宿主客户端连接状态丢失**(同窗口 `sendToClient: No state found for connectionId` # 303 次,其他小时 0 次);实测队列 `queueLen` 0→1→2 逐条堆积。 # 🔴 **AI 侧修不了这个根因** —— 唯一解法是**让客户端重新挂上该会话**(切走再切回/重开窗口)。 # ⇒ 我们能做的只有一件:**一发生就发现,并把"该点哪一下"直接写进 `NEED-USER.md`**。 # 详见 `references/pitfalls.md P0-5`。 PARK_TAIL = 400 * 1024 # 只读日志尾部(⛔ 不许整文件扫:工作区日志可达几十 MB) PARK_FRESH = 30 * 60 # 秒:指纹超过这个时长就不算"正在卡" PARK_STAMP = INBOX / "_park.stamp" # 告警节流(⛔ 不每轮刷同一条) PARK_GAP = 180 # 秒 CONSUME_GRACE = 240 # 秒:投递后多久没见目标会话活动 ⇒ 判「没被消费」 def _log_dirs() -> list: """候选工作区日志路径:`<配置根>/logs/<今天|昨天>/<本工作区名>__*.log`。 ⚠️ 日志**跨日不换名**(实测:主会话 09-29 那份一直写到 23:59)⇒ 两个日期都试。 """ root = os.path.join(os.environ.get("CODEBUDDY_CONFIG_DIR") or os.path.expanduser("~/.workbuddy"), "logs") name = os.path.basename(str(WS).replace("\\", "/").rstrip("/")) or "" out = [] for d in (time.strftime("%Y-%m-%d"), time.strftime("%Y-%m-%d", time.localtime(time.time() - 86400))): try: for fn in os.listdir(os.path.join(root, d)): if name and fn.startswith(name + "__") and fn.endswith(".log"): out.append(os.path.join(root, d, fn)) except Exception: continue return out def _is_park_line(ln: str) -> bool: """判据:**只认真正的 AcpView 记录行**。 🔴 **⛔ 不能只搜字符串**(2026-09-30 当场踩到):取证命令(`grep parkInQueue …`)的输出 会**被写进同一份工作区日志**(`SandboxShell ProcessOutput … content=`)⇒ 探针命中自己的回声 ⇒ **假阳性**。 """ return ("parkInQueue" in ln and "hasWaiter=false" in ln and "[AcpView][PromptIterator] received prompt" in ln and "ProcessOutput" not in ln and "content=" not in ln) def probe_host_park() -> dict: """扫日志尾部找「parkInQueue + hasWaiter=false」指纹。⛔ 只读、⛔ 不写任何东西。""" hit = {"n": 0, "last": "", "file": ""} for p in _log_dirs(): try: sz = os.path.getsize(p) with open(p, "rb") as f: if sz > PARK_TAIL: f.seek(sz - PARK_TAIL) buf = f.read().decode("utf-8", "replace") except Exception: continue for ln in buf.splitlines(): if _is_park_line(ln): # ⚠️ 判据见 `_is_park_line`(⛔ 不搜字符串,防"回声假阳性") hit["n"] += 1 hit["file"] = os.path.basename(p) m = re.search(r"\[(\d{4}/\d{1,2}/\d{1,2} (\d\d:\d\d:\d\d))", ln) if m: hit["last"] = m.group(2) return hit def _tick_park_probe() -> dict: """🔴 **宿主侧「消息卡住」指纹探针**(`parkInQueue` + `hasWaiter=false`)。 ⛔ AI 侧**修不了**,只能**发现 + 告诉用户点哪一下**(把会话窗口切走再切回)。 🔴🔴 2026-10-05 **从 `--tick` 搬进常驻**(`--tick` 已删):原先它是 tick 分支里的内联块, 删 tick 即断 ⇒ 抽成函数由 `--supervise` 主循环节流调用(≈每 5 分钟)。 ⚠️ 它**不依赖投递**(判据纯读日志指纹)⇒ 投递退役后**照旧有效**,⛔ 不许跟着删。 ⚠️ 节流靠 `PARK_STAMP`(⛔ 别用"每轮都扫",那是每 30s 全量读日志 tail)。 """ hit = {"n": 0, "last": "", "file": ""} try: hit = probe_host_park() except Exception: return hit if not (hit.get("n") and hit.get("last")): return hit try: if PARK_STAMP.exists() and (time.time() - PARK_STAMP.stat().st_mtime) < PARK_GAP: return hit # 节流窗口内 ⇒ 不重复喊 except Exception: pass try: # 「最近一次」必须新鲜,否则是历史残渣 hh, mm, ss = [int(x) for x in hit["last"].split(":")] if ((time.time() - (hh * 3600 + mm * 60 + ss)) % 86400) >= PARK_FRESH: return hit except Exception: return hit try: need_user( "检测到**宿主侧「消息卡住」指纹**(%s,最近一次 %s):`parkInQueue` + `hasWaiter=false`" " ⇒ 消息进了队列、**没有消费者**。\n🔴 **AI 侧无法自救**。请你**把那个会话窗口切走再切回**" "(或关掉重开)—— 队列随即排空。⛔ 别反复发消息试探。" % (hit["file"], hit["last"])) PARK_STAMP.write_text(time.strftime("%Y-%m-%d %H:%M:%S"), encoding="utf-8") except Exception: pass return hit def _sess_activity(sid: str) -> float: """某会话最后活动时刻(epoch 秒)。⛔ 取不到 ⇒ 0(不猜)。""" if not sid: return 0.0 try: con = _db() row = con.execute("select updated_at from sessions where id=?", (sid,)).fetchone() con.close() return float(((row or [0])[0]) or 0) / 1000.0 except Exception: return 0.0 def check_delivery_consumed(st: dict) -> dict: """🔴 **「投出去」≠「它跑起来了」** —— 投递后必须**回查目标会话有没有真的动**。 判据:目标会话 `updated_at` 是否**晚于**投递时刻。⛔ 不靠"网关回了 200"(那只证明对方收下了)。 超 `CONSUME_GRACE` 秒仍未动 ⇒ 落 `NEED-USER.md`(含**用户唯一该做的那一下**),⛔ 不静默。 """ w = st.get("wake") or {} exp = w.get("expect") or None if not exp: return {} sid, at = str(exp.get("sid") or ""), float(exp.get("at") or 0) if not sid or not at: return {} act = _sess_activity(sid) if act and act > at: w.pop("expect", None) st["wake"] = w return {"consumed": True, "sid": sid[:8], "lag": round(act - at, 1)} if time.time() - at < CONSUME_GRACE: return {"waiting": True, "sid": sid[:8]} w.pop("expect", None) st["wake"] = w need_user( "投给会话 `%s` 的通知**没被消费**(投出后 %.1f 分钟该会话零活动)。\n" "典型的**宿主侧卡住**:`parkInQueue` + `hasWaiter=false` —— 消息进了队列、**没有消费者**。\n" "🔴 **这一步 AI 侧无法自救**。请你**让客户端重新挂上该会话**:把窗口**切走再切回**," "或**关掉再打开该会话窗口** —— 队列随即会被排空。⛔ 别反复发消息试探(只会在队尾再堆一条)。" % (sid[:8], (time.time() - at) / 60.0)) return {"unconsumed": True, "sid": sid[:8]} # ── 🗑️ 2026-10-01 **删除:前置探针(设备侧 worker + 垫片)** ─────────────────────── # 谁删的/为什么删(⛔ 不是它不好用,是它**放错了层**,而且告的是**一条已退役的线**): # · **用户 2026-10-01 明令删除**:「删除这个事情『不是本线的阻塞:…手机接入链路的垫片 # 127.0.0.1:20090 掉了,得起长活进程…』」—— 它每轮把 **别的线**的事写进**本线**的 # `NEED-USER.md`,把本线的"需用户介入"顶成噪音。 # · 🔴 **它违反本项目自己的定则**:「**技能就是技能,谁用产生的文件放在他自己那里**」 # —— 探针里的 `20090`/「垫片」/「设备侧 worker」/「手机接入链路」**全是使用方的项目串**。 # (而那条守规矩的自检只扫 `board.py`,⛔ 扫不到本文件 ⇒ 它一直静默通过;本次已把自检扩到本文件。) # · 它报的那条线**2026-09-30 已退役**(任务图 M1–M7 全 done、手机接入线归档)⇒ 告警本身是**陈旧告警**。 # ↩️ **怎么还原 / 该放哪**:这段代码原样在 2026-10-01 的 git 历史与前一轮日志里; # 若使用方仍要这个提示,**正确落点是使用方自己的 `board_ext.py`**(组件探测本来就归使用方, # 见 `board.py` 文件头"组件探测属使用方"),⛔ 不要放回技能包。 # ⚠️ 注意:删掉的是**探测与报警**;`board_ext.py` 里那块"前置面板"的**展示**不动(那是使用方的画法)。 def _acc_is_pass(v) -> bool: """🔴🔴 一条验收判据「算不算过」—— 🔴 **判据只有一份,这里只是转发**。 🔴🔴🔴 **2026-10-04 治本(用户点破:「同一个判定标准为什么要弄几份代码」)**: **此前本条规则在三处各写一遍**(`board.py::_acc_is_pass` / 本函数 / `board.html::accIsPass`)⇒ **必然漂**,实测已经漂过: · 10-02 只认英文 `pass` ⇒ 真源写中文「过」⇒ 全判非 pass; · 10-03 只取**最后一段** ⇒ 「复核:过」被切掉; · 10-04 真源写 **`达(…)`** ⇒ **三处一个都不认** ⇒ `vibe-product` 15 条判据 显示 **0 通过**(含 14 条已达标,用户原话「为什么还是0通过」); · 同一轮实测**两处已打架**:值 `过:1440 与 390 两视口` ⇒ `board.py` 判过、**本文件判不过**。 ⇒ ✅ **治本=实现只留一份**:白名单与算法都搬进 `board.py`(`ACC_PASS_WORDS` + `acc_all_pass_words()`),本函数**转发**,⛔ 不再各写各的。 ⚠️ **为什么可以这么做**:本文件与 `board.py` **同目录**(技能 `scripts/`), `board.py` 是**无第三方依赖的纯标准库模块** ⇒ 直接 import 无副作用。 ⛔ 不走"复制同一段代码"的老路(那正是漂的来源)。 ⚠️ **拿不到 `board.py` 时**(副本被单独拷走等):**fail-closed 声明式兜底** —— 用与 `board.py` **同款的白名单**判,并在 stderr 留一行;⛔ 绝不静默返回 `False` (那会把"代码没同步"伪装成"判据没过",比报错更难查)。 沿革(⛔ 保留,理由也会过时):本函数 2026-10-03 才在本文件里**首次出现** —— 此前 `goal_state()` / `_acc_short()` 是死板的 `.lower() != "pass"` ⇒ **7 条有效判据里 5 条与 `board.py` 相反** ⇒ `goal_state()` 恒判 `open` ⇒ `--once` 走 `paused_round(why="目标三路全过")` ⇒ **即使真完成也被说成"已完成"**。 🔴 同族=`pitfalls.md` P0-13(判据必须能"改前报红")/P0-20。 """ # 🔴 首选:**用 `board.py` 的实现**(唯一真身) try: import sys as _sys from pathlib import Path as _P _sd = str(_P(__file__).resolve().parent) if _sd not in _sys.path: _sys.path.insert(0, _sd) import board as _b # ⚠️ 同目录 ⇒ 无副作用 return bool(_b.acc_is_pass(v)) except Exception as _e: # ⚠️ **不许静默 fail-open/close** —— 留痕后走声明式兜底。 # (本文件是常驻进程,日志是唯一可观测面;`log()` 在别处定义,此处只写 stderr。) try: import sys as _s2 _s2.stderr.write("⚠️ collabd._acc_is_pass: 拿不到 board.py(%s)⇒ 走兜底白名单\n" % _e) except Exception: pass # ── fail-safe 兜底:与 `board.py::ACC_PASS_WORDS` / `ACC_ADV_PREFIX` **同款** # (⛔ 改了那边必须改这里 —— 两处都列出来,免得只改词表漏掉副词) import re as _re s = str(v or "").strip() if not s: return False _head = _re.split(r"[((]", s, maxsplit=1)[0] for _seg in _re.split(r"[::]", _head): _t = _seg.strip().lstrip("*").strip().lower() for _w in ("pass", "过", "通过", "达", "达标", "合格", "完成"): if _t.startswith(_w): return True # 🔴🔴 2026-10-05 第 5 次复发:**剥"完成副词"再比**(与 `board.py::acc_is_pass` 同款)。 # 实测真源写 `已过|…`/`均已通过` ⇒ 不剥则**永远不命中** ⇒ vibe-product 9 条全判非过。 # ⚠️ 否定副词(未/不/没/非/待)**不在**表里 ⇒ 仍 fail-closed。 _t2 = _t for _ in range(3): _cut = "" for _a in ("已", "已经", "均", "都", "经复核", "复核后"): 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 ("pass", "过", "通过", "达", "达标", "合格", "完成"): if _t2.startswith(_w): return True return False # 🔴🔴 2026-10-04 加(用户点破「A6 判据自指 ⇒ goal_state 逻辑死锁」): # **自指型判据** = 判据内容本身就是"三路并集的结论"(例:「`goal_state()` 返回 `pass`」)。 # 🔴 死锁形状:`goal_state()` 的**路3 要读这条判据**,而这条判据又要等 `goal_state()` 出结论 # ⇒ **只能由"自己写自己过"满足**,实测把 A6 临时改「过|…」goal_state 立刻 pass、还原又 open # ⇒ **等多久都不会过**(这是判据设计的缺陷,⛔ 不是时序问题、⛔ 也不能靠等主会话解决)。 # ✅ 修法=**路3 排除自指型判据**(本函数的两个来源,**主次分明、不可混**): # ① **登记制 = 唯一权威路径**(`_ACC_SELFREF_KEYS`,⛔ 不依赖判据文本): # 登记过的键**无条件排除** —— 即使有人把 A6 的说明文字改得看不出自指(甚至已写成「过」),它仍被排除。 # ② **文本识别制 = 窄口径兜底**(补登记漏网的,⚠️ 刻意收窄,见下方实测)。 # ⚠️ **本条只解决"逻辑死锁",⛔ 不放松任何实质门禁**:A1~A5 一条不少地照判。 # 🔴🔴 **为什么 ② 只看「判定词 + 其后 80 字」**(2026-10-04 实测踩到,两个坑): # 坑1 **只判定词那一段是死代码** —— 真源一律写成 `🔴 不过|<正文>`,而 `goal_state()` 必落在 # 正文里 ⇒ 切完判定词永远匹配不上 ⇒ 兜底形同虚设(实测 `A9|🔴 不过|goal_state() 返回 pass` 漏判)。 # 坑2 **扫整段正文会误伤真判据** —— A2 那条长正文**顺口提了一句**「`goal_state()` 只读 # acceptance_state 文本、不重验事实」⇒ 整段匹配会把 **A2 当成自指排除掉**, # **正好悄悄放掉一条真门禁**(比原 bug 更坏:原 bug 是吵,这个是静默失守)。 # ⇒ 折中=**判定词段 + 其后 200 字窗口**(`_ACC_SELFREF_WIN`):只认「这条判据一开口就在判三路并集」 # 那种写法,**长正文深处的顺口提及不算**。 # 📌 **200 这个数是按真数据量出来的,不是拍的**(2026-10-04 实测 goal.json 两条真值): # A6(真自指)`goal_state(` 首现位置 **122**、`goals_open(` **144**、「三路全过」**177**; # A2(假阳性风险)`goal_state(` 首现位置 **825**(正文末尾提 mechanism 缺陷)。 # ⇒ 200 能覆盖 A6 的全部三个 token,又**够不着** A2 的 825 ⇒ 两者拉得开。 # ⚠️ 若日后判据正文改写、token 位置挪动 ⇒ **以 `_ACC_SELFREF_KEYS` 登记制为准** # (登记制 ⛔ 不依赖任何窗口,是权威路径;本兜底只是省得每次都去登记)。 # ⇒ 偏误一律朝**保守**一侧倒(宁可漏认一条自指 ⇒ 它继续亮红灯、吵但不出错; # 也不可误放一条真判据 ⇒ 静默失守)—— 与 `_acc_is_pass()` 的 fail-closed 同向。 # ⚠️ 兜底漏认的代价可接受:漏认的那条会**继续亮红灯**,人看得见; # 兜底误认的代价不可接受:真判据被**静默排除** ⇒ 门禁形同虚设。 _ACC_SELFREF_KEYS = ("a6 目标状态",) # 登记制:小写子串,匹配判据键(`_acc_is_selfref` 内统一 lower) _ACC_SELFREF_TOKENS = ("goal_state(", "goals_open(", "三路全过") # 文本识别制:查下面的窗口 _ACC_SELFREF_WIN = 200 # 判定词之后看多少字(见上:按真数据 A6=122/144/177 vs A2=825 量出来的) def _acc_is_selfref(k, v) -> bool: """这条验收判据**是不是自指型**(判的内容 = 三路并集本身 ⇒ 只能自己满足自己)。 🔴 **为什么要单独判**:见上方 `_ACC_SELFREF_KEYS` 的说明(死锁形状 + 固定点实验)。 🔴 **主路径是登记制**(`_ACC_SELFREF_KEYS`);文本识别只是**窄口径兜底**,刻意保守(宁可漏认)。 ⚠️ **fail-closed 的边界**:本函数只回答"要不要**排除**",**绝不**回答"算不算过" —— 排除后若一条判据都不剩,`goal_state()` 会退回 `undeclared`(判不出 ⇒ ⛔ 不算过)。 """ ks = str(k or "").strip().lower() if any(_rk in ks for _rk in _ACC_SELFREF_KEYS): return True import re as _re # 判定词段(含判定词本身)+ 其后 _ACC_SELFREF_WIN 字窗口 probe = _re.split(r"[||]", str(v or ""), maxsplit=1)[0][:_ACC_SELFREF_WIN] \ + str(v or "")[:_ACC_SELFREF_WIN] return any(_t in probe for _t in _ACC_SELFREF_TOKENS) def _acc_short() -> list: """读 `goal.json.acceptance_state` ⇒ 返回**非 pass** 的验收项(供唤醒正文直接列出"还差什么")。 ⚠️ 读不到 ⇒ 返回提示行(⛔ 不因它判"全过");`unknown` 视为**未过**。 🔴 2026-09-30 修(**"没判据"被说成了"全部 pass"** —— 与 `goal_state()` 同一个 bug 的另一半): 旧实现在**没有任何有效项**时走 `or [...]` 兜底 ⇒ 打印「(全部 pass —— 目标已达成,可收口)」, 而这恰恰是**最不该说"完成"**的场合(连判据都没有)。⇒ 现在**分开说**。 """ try: _as = (json.loads((INBOX / "goal.json").read_text(encoding="utf-8")).get("acceptance_state") or {}) _eff = [_k for _k in _as if not str(_k).startswith("_")] # 🔴🔴 2026-10-04:与 `goal_state()` 路3 **同口径**剔除自指型判据(`_acc_is_selfref`)。 # ⛔ 不剔的话会造出**新的自相矛盾**:`goal_state()` 已判 `pass`,而这里还在报「还差 A6」 # ⇒ 唤醒正文会把一条**永远不可能自己过**的判据一次次念给会话。 _real = [k for k in _eff if not _acc_is_selfref(k, _as[k])] rows = ["- **%s**:%s" % (k, _as[k]) for k in sorted(_real) if not _acc_is_pass(_as[k])] # 🔴 2026-10-03:认中文「过」(见 `_acc_is_pass` docstring) if rows: return rows if not _real: if not _eff: return ["- ⚠️ **`acceptance_state` 里一条有效判据都没有**(只有说明行)" " ⇒ **判不出来**,⛔ 不因此判完成;请补判据,或明确写「本目标不需要验收判据」。"] # ⚠️ fail-closed:有效判据**全都是自指型** ⇒ 剔完什么都不剩 ⇒ 同 `goal_state()` 判「未声明」。 return ["- ⚠️ **有效判据全都是自指型**(判据判的就是三路并集本身)⇒ 剔完一条不剩 ⇒ **判不出来**," "⛔ 不因此判完成;请把这类判据改写成非自指表述。"] return ["- (全部 pass —— 目标已达成,可收口)"] except Exception: return ["- ⚠️ 读不到 `goal.json.acceptance_state`(⛔ 不因此判完成)"] def _goal_short() -> str: """本需求目标的 `short`(两级命名前缀里的第 2 级)。""" try: return str(json.loads((INBOX / "goal.json").read_text(encoding="utf-8")).get("short") or "") except Exception: return "" def _goal_topics() -> list: """🔴 **本需求下的"任务类别"清单**(`goal.json.topics`)。 2026-09-30 用户要求:「要能支持**同一个工作区** 多会话协作(主会话根据任务**自动梳理任务类别**: **通过协作会话名称前缀的方式区分具体执行会话**,所有主会话,协作会话,自动唤醒任务,都在**一个工作区**)」 ⇒ 归属锚点从"**跨工作区**"(cwd)改成"**一个工作区 + 任务类别前缀**": **任务类别 ≡ 会话标题二级前缀 `[主题]` ≡ 台账条目的 `line` ≡ 分工板的一行**(同一个词,四处同义)。 ⛔ `topics` 缺省 ⇒ 回落 `[goal.short]` ⇒ **单类别时的行为与旧版逐字一致**(向后兼容)。 ⛔ 读不到 goal.json ⇒ 返回 `[]`(调用方按"无类别"处理,⚠️ 此时**只剩主会话判据**能命中)。 """ try: g = json.loads((INBOX / "goal.json").read_text(encoding="utf-8")) or {} except Exception: return [] out: list = [] for _t in (g.get("topics") or []): _t = str(_t or "").strip() if _t and _t not in out: out.append(_t) if not out: _s = str(g.get("short") or "").strip() if _s: out.append(_s) return out def _as_list(v) -> list: """把 `str | list | tuple | set` 归一成 `list[str]`(⛔ 空值 ⇒ `[]`)。 用途:`_in_project()` 的 `main_sid` / `topics` 两个参数都要能吃**单值或集合** —— 单类别(旧版)传 str、多类别传 list,调用点⛔ 不必两套写法。 """ 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 _in_project(sid: str, title: str, main_sid, short) -> bool: """🔴 **判定一个会话是否属于本需求目标项目**(2026-09-30 用户明示:「要明确哪些会话是属于某个需求目标项目的」)。 **三级判据(任一命中即算)—— ⛔ 与 cwd 无关** ① **主会话**:`sid == main_sid`(= 解析出来的主会话;🔴 **多任务类别时 main_sid 可以是多条**, 传 str 或 list/set 都行 —— 见 `resolve_mains()`) ② **任务会话**:**标题含 `[<任一任务类别>]`** —— 即两级命名前缀的**二级** (🔴 2026-09-30 泛化:第 4 个参数 `short` 实际收到的是**任务类别集合** `goal.topics`, 可传 str 或 list;⛔ 传 str 时行为与旧版**逐字一致**) ③ 其它会话:`--declare --goal ` 显式声明(⚠️ 尚未接线,留待需要时) ⛔ **不命中 ⇒ 不算本项目**。 🔴 **⛔ 刻意不用 cwd 推断** —— 架构 §2.3 明令「**绝不回落到 cwd 推断**」(我 2026-09-30 一度用 cwd 判,属违规,已改回)。 🔴 **为什么"多类别"必须在这一层就支持**:同一工作区里跑**多个任务类别**时, 若只认**一个** `goal.short`,则除那一个之外的类别会**全部被判"不算本项目"** ⇒ 看板不列它们、`--ready-next` 也不把它们的在跑会话算进来 ⇒ **静默漏管**。 🔴 2026-10-01 **判据放宽为三条并列**(详见函数体内的长注释): ① 主会话 sid 命中 ② **角色可解析 + 标题出现任一类别**(子串,⛔ 不要求方括号)← 🆕 ③ 标题含 `[<任一类别>]`(老写法,向后兼容) ⛔ **与 `board.py::in_project()` 必须逐条同款** —— `selftest.py` 会把两边真的拉出来对账。 """ if sid and sid in _as_list(main_sid): return True ti = str(title or "") _tps = _as_list(short) # 🔴🔴 2026-10-01 修(用户报「**看板没有正常显示协作会话**」)—— 与 # `board.py::in_project()` **逐条同款**(那条红线上写着"改一处必须改两处")。 # 真因:原来**只认带方括号**的写法,而**实际派活出来的棒名是** # `[协作]-机制排查与修复-常驻投递容器`(第 2 级**没带方括号**)⇒ 判 `False` # ⇒ 看板画空框「暂无协作会话」,而那条会话**正在 working**。 # 本函数还有**第二个受害者**(更严重,因为它会改行为而不只是画错): # `ready_next()` 的判据①「除主会话外没有别的会话在 working」走的**就是本函数** # ⇒ 在跑的执行棒判 `False` ⇒ 落进 `ignored_others` 而不是 `working_others` # ⇒ `--ready-next` 会**说"可以排下一棒"** —— 正是用户 2026-09-30 描述的那个病: # 「5-8 应该是之前出现**前面还没执行完 就开始下一棒了**」。 # ⇒ 判据统一成**两条并列**(任一命中即算): # ② **角色可解析**(`parse_session_name()` 给出 role)**且** 标题出现任一类别 # (`_topic_in_title()` 子串,⛔ 不要求方括号)—— 角色前缀闸⛔ 不放无关会话进来。 # ③ 老写法 `[<类别>]` **原样保留**(向后兼容)。 if _topic_in_title(ti, _tps) and (parse_session_name(ti) or {}).get("role"): return True # 🔴🔴 2026-10-03 10:2x **检查会话无条件归本项目**(与 `board.py::_in_project()` **同款**)。 # 现象:`44b547d4`「结果检查-…」修完 role 后从「主会话」列表移出、却落进 # `sessions_unrecognized` ⇒ 看板任务会话那排**仍画不出它**。 # 根因:上面判据②要求「角色可解析 **且** 标题含 `goal.topics` 里的类别」, # 而 `topics` 现网**未声明**(回落 `short`=「本机协作」)⇒ `结果检查` 不在里面。 # 🔴 **为什么该无条件**:检查会话是**常驻程序自己建的**、服务于本工作区的目标, # ⛔ 不该因"用户还没声明任务类别"而在看板上消失(同 2026-10-01「建了协作会话但看板没展示」)。 # ⚠️ 调用方(`_sessions()`)**已先确认这条会话 `cwd` 命中本工作区**(`_ct == _wstail` 那道) # ⇒ 这里 ⛔ 不再按 cwd 兜底(那会让别的线的检查会话被认领)。 if is_check_agent(ti) and str(short or "").strip(): return True for _t in _tps: if ("[%s]" % _t) in ti: return True return False def ready_next(st: dict) -> dict: """🔴 **收尾确认 —— 用它替代"盲等 5~8 分钟"**(用户 2026-09-30 说明其由来): 用户原话:「5-8 应该是之前出现 **前面还没执行完 就开始下一棒了**, **要是能避免这个问题 可以压缩时间**」 ⇒ 5~8 分钟 = **用固定延迟赌"上一棒已收尾"**;既然能**真的确认**,就该压到"确认即起"。 **三条判据(全满足 ⇒ 可立刻排下一棒,排期 = 现在 + 30~60 秒)** ① 除**主会话**外,**没有别的会话处于 `working`** —— 读**宿主状态**,⛔ 不是"它自己说完了" ② 台账里**没有 `running` 的条目** —— ⛔ 不认"它说做完了",要终态(done/blocked) ③ 上一棒**没有留下待消费的投递**(`wake.expect` 已清)—— ⛔ 不等一个还没被取走的通知 ⚠️ 任一不满足 ⇒ **⛔ 不排**,等下一次唤醒/反馈再判(⛔ 不盲等、也⛔ 不硬上)。 """ out = {"ok": False, "why": [], "working_others": [], "running_tasks": []} # ① 除主会话外还有谁在跑? # 🔴 2026-09-30 改:**多任务类别** ⇒ "主会话"可能不止一条 ⇒ # 判据从"排除**那一条**"改成"排除**解析出来的全部主会话**"(⛔ 否则别类别的主会话会被算成"在跑"⇒ 永远拦住) # ⚠️ 传 `live_sids=[]` ⇒ **不做网关探测**(本命令只需工作区扫描,⛔ 别为它加网络开销) _rms0 = resolve_mains(st, []) mains = list(_rms0.get("all_sids") or []) topics = _goal_topics() try: con = _db() for row in con.execute("select id, coalesce(nullif(custom_title,''),title,'') " "from sessions where status='working'"): sid, ti = str(row[0] or ""), str(row[1] or "") if sid in mains: continue # ⛔ 主会话自己不算(否则永远拦住) if not _in_project(sid, ti, mains, topics): # ⛔ **别的线的会话不算本项目在跑**(2026-09-30 修:曾把「AI变现日报」算进来 ⇒ 误拦派棒) out.setdefault("ignored_others", []).append("%s(%s)" % (sid[:8], ti[:24])) continue out["working_others"].append("%s(%s)" % (sid[:8], ti[:28])) con.close() except Exception as e: out["why"].append("读宿主库失败:%s" % e) if out["working_others"]: out["why"].append("还有会话在跑:%s" % "、".join(out["working_others"])) # ② 台账里还有 running? for k, v in (_load_tasks() or {}).items(): if str((v or {}).get("state") or "") == "running": out["running_tasks"].append(k) if out["running_tasks"]: out["why"].append("台账仍有执行中:%s" % "、".join(out["running_tasks"])) # ③ 上一棒的**单条反馈**是否还等着主会话处理?(握手没走完 ⇒ 本轮别急着重排) # 🔴 2026-09-30 修(**本命令上线 3 分钟就踩到**):原实现查的是 `wake.expect`, # 而**唤醒投递同样会留下 `expect`** ⇒ `--ready-next` 会被"唤醒尚未被消费"拦死 ⇒ # 实测输出「⛔ 先别排:上一棒投递尚未被消费」—— 而那条"未消费的投递"正是**刚发的唤醒**, # 主会话正在处理它 ⇒ **假拦**(若不改,提速全部作废)。 # ⇒ 判据只认**握手等待**(`notify_awaiting`:反馈了一条、等主会话处理),⛔ 不看唤醒的 expect。 _aw = st.get("notify_awaiting") or None if _aw: out["why"].append("上一棒的单条反馈仍在等主会话处理(%s,阶段 %s)" % (_aw.get("item"), _aw.get("phase"))) out["ok"] = not out["why"] return out def _guard_says_stop() -> bool: """⇒ **该停就停**:停止标志 `guard.stop` 存在即返回 True。 🔴🔴 2026-10-03 **改判据**(用户口径第①条「目标完成 ⇒ 关后台任务与协作程序」的落地): 原实现头一行是 `if os.environ.get("DSH_GUARDED") != "1": return False` ⇒ **只有"守护拉起"的实例才看停止标志**;而 `--ensure`/手动起的常驻 **都没有** `DSH_GUARDED` ⇒ 写 `guard.stop` 对它们**完全无效** (实测后果:`supervise_stop()` 的"优雅退出"必然等满 `STOP_GRACE` 才进强停分支)。 ✅ 改为**停止标志对所有实例生效** —— 它是**用户/CLI 显式下的"停"命令**, 本就该压过"我是谁拉起来的"。 ⚠️ **守护原有语义不丢**:`DSH_GUARDED=1` 时的第二个判据(守护心跳过期 ⇒ 守护已死) 仍保留(见下)⇒ 两条判据是**并集**,不是替换。 """ try: if (INBOX / "guard.stop").exists(): return True except Exception: pass if os.environ.get("DSH_GUARDED") != "1": return False try: ts = float(json.loads((INBOX / "guard.json").read_text(encoding="utf-8")).get("ts") or 0) return (time.time() - ts) > 90 except Exception: return False GOAL_F = INBOX / "goal.json" def load_goal() -> dict: """🔴 **任务目标**(整套机制的运行中心)。缺失/无 title ⇒ 返回空 dict —— 调用方**必须显式处理"未声明目标"**,⛔ 不得假装有目标。""" try: g = json.loads(GOAL_F.read_text(encoding="utf-8")) return g if isinstance(g, dict) and str(g.get("title") or "").strip() else {} except Exception: return {} def goal_line() -> str: """摘要/通知里显示的一行目标。 🔴 2026-09-30 改:显示**任务类别清单**(`goal.topics`)而不是单个 `short` —— 用户要求「主会话根据任务**自动梳理任务类别**,通过**协作会话名称前缀**区分具体执行会话」 ⇒ 这一行是**每轮钩子注入**给主会话的,必须把"本工作区里有哪几个类别"直接摆出来, 主会话才好据此给新棒命名(`[执行]-<类别>-<具体>`)并解析"这活属于哪一类"。 """ g = load_goal() if not g: return "⚠️ **未声明任务目标**(机制不知道该围绕什么跑)" tps = _goal_topics() if tps: return "🎯 围绕目标:%s(任务类别前缀:%s)" % ( g["title"], "、".join("`[%s]`" % _t for _t in tps)) return "🎯 围绕目标:%s" % g["title"] def pw_fingerprint() -> str: """网关口令的**指纹**(sha256 前 12 位)—— **只用于判"变没变"**。 ⛔ 绝不落口令本身;指纹不可逆、**不能用于鉴权**(所以不算"口令落盘")。""" tok = os.environ.get(GW_ENV) or "" return hashlib.sha256(tok.encode("utf-8")).hexdigest()[:12] if tok else "" def goal_state() -> str: """🔴 **目标三态**(2026-09-30 加):`"open"`(有未过项)|`"pass"`(有判据且全过)| `"undeclared"`(**一条有效判据都没有**)。 ⛔ **为什么要第三态**:旧实现把"**没有非 pass 项**"直接当成"全过" —— 可 `acceptance_state` 里**全是 `_` 开头的说明行**时(很常见:只写了 `_说明`/`_更新`), 遍历循环**空转** ⇒ **这一路什么也没说**,前三路又都 `done` ⇒ 整体判「已完成」。 ⇒ 这正是红线里那句「**一路读不到 ⇒ ⛔ 不许当成已完成**」被违反的形状: **不是崩溃,是少说一句话**(实测:`goalctl status` 因此显示"三路全过")。 🔴 `"undeclared"` **不算 pass**(调用方一律按"未完成/判不出"处理,⛔ 不臆断为过)。 """ open_, eff = False, 0 try: for _k, v in (_load_tasks() or {}).items(): if str((v or {}).get("state") or "") != "done": open_ = True except Exception as e: log("goal_state 读台账失败 %s" % e) try: g = json.loads(TG.read_text(encoding="utf-8")) for n in (g.get("nodes") or []): if str(n.get("status") or "") != "done": open_ = True except Exception as e: log("goal_state 读任务图失败 %s" % e) # 🔴🔴 2026-09-30 加(用户点破「目标都还未达成,为什么没触发目标状态评估」): # **前两路测的是"活干完了吗",⛔ 不是"目标达成了吗"** —— 铁证:任务图里 **N14 节点是 `done`, # 而 N14 判的正是「V1 ❌ 未过」**。⇒ 必须**再读一路"验收判据的真实状态"**。 # ⛔ 为什么不直接把前两路删掉:它们防的是「**漏待办**」(已规划但还没派棒 ⇒ 不在台账); # 本路防的是「**误判完成**」;二者**互补**,取并集。 # ⚠️ `unknown` 视为**未过**(⛔ 不臆断为过)⇒ 宁可多提醒,⛔ 不漏。 try: _gj = json.loads((INBOX / "goal.json").read_text(encoding="utf-8")) _as = _gj.get("acceptance_state") or {} eff = len([_k for _k in _as if not str(_k).startswith("_")]) if eff == 0: # ⚠️ 节流 600 s:这行**每轮都一样**(实测 40+ 行连刷),留一行就够,⛔ 别淹没真事件。 log_throttled("goal_state-no-kpi", 600.0, "goal_state:acceptance_state **没有有效判据**(只有说明行)⇒ 判「未声明」,⛔ 不算过" "(本行每 10 分钟最多一条)") # 🔴🔴 2026-10-04(用户点破「A6 自指 ⇒ 逻辑死锁」,固定点实验坐实):**先剔掉自指型判据**。 # 不剔的话,这条判据判的正是"三路并集的结论",而本函数的路3 又要读它 ⇒ 只能自己满足自己。 # ⚠️ 只剔**自指**(`_acc_is_selfref`:登记制 + 文本点名 `goal_state(`/`goals_open(`/「三路全过」), # A1~A5 等实质判据**一条不少地照判** ⇒ ⛔ 不是放松门禁,是拆掉死循环。 _real = [(_k, _v) for _k, _v in _as.items() if not str(_k).startswith("_") and not _acc_is_selfref(_k, _v)] _dropped = [str(_k) for _k in _as if not str(_k).startswith("_") and _acc_is_selfref(_k, _as[_k])] if _dropped: # 节流 600 s(同上):这行每轮都一样,只留一行。 log_throttled("goal_state-selfref", 600.0, "goal_state:已排除**自指型判据** %s(判据判的是三路并集本身 ⇒ 只能自己满足自己)" "⇒ ⛔ 不参与本路判定,其余判据照判" % ("、".join(_dropped),)) # ⚠️ fail-closed:剔完一条都不剩 ⇒ 本路**没有可判的** ⇒ 退回「未声明」,⛔ 不算过。 if not _real: log("goal_state:剔除自指判据后**一条有效判据都不剩** ⇒ 判「未声明」,⛔ 不算过") return "undeclared" for _k, _v in _real: if not _acc_is_pass(_v): # 🔴 2026-10-03:认中文「过」(见 `_acc_is_pass` docstring) open_ = True except Exception as e: log("goal_state 读 acceptance_state 失败 %s" % e) return "undeclared" # 🔴 读失败 ⇒ ⛔ 不判完成(旧实现这里只记日志 ⇒ 静默放行) if open_: return "open" return "pass" if eff else "undeclared" def goals_open() -> bool: """**需求是否仍未完成** —— 🔴 `goal_state() != "pass"`(⛔ 包括"未声明判据"那种判不出来的)。 真值表:`open`(有未过项)⇒ True|`undeclared`(一条有效判据都没有)⇒ **True**(判不出来 ⇒ ⛔ 不判完成)| `pass`(有判据且全过)⇒ False。 """ return goal_state() != "pass" def goal_paused() -> bool: """🔴 **需求总闸**(2026-10-01 体检修):`goal.json.run != "active"` ⇒ **已停**。 为什么要这道闸(2026-10-01 实测):`goal.json.run=paused` 时,本程序**零处**引用 `run` ⇒ `--once` 照跑、照写 `NEXT.md`/`STALL.md`/`NEED-USER.md`/`digest.md`,而 `digest.md` 的内容**经钩子注入每个会话的每一轮** ⇒ 一个"人为停掉"的需求被读成"机制坏了"。 ⛔ 读不到 `goal.json` ⇒ 默认**在跑**(⛔ 不因读失败就把活机制判停)。 """ try: _g = json.loads((INBOX / "goal.json").read_text(encoding="utf-8")) except Exception: return False return str(_g.get("run") or "active").strip().lower() != "active" # ════════════════════════════════════════════════════════════════════ # 🔴🔴 目标三态状态机 + 「检查会话」自主建排期(2026-10-02 用户拍板 · 方案 A) # ════════════════════════════════════════════════════════════════════ #用户原话(逐字四条): # ①「必须所有协作会话结束后,一次性读取队列信息,创建检查会话整体检查后 根据情况创建新协作会话」 # ②「所有会话结束时(包括主会话),并且队列为空 20分钟后创建检查会话 检查目标状态, # 根据目标情况创建新协作会话处理,还是修改目标状态」 # ③「只有目标状态为进行中时 才会创建检查会话(由用户说完成XXX目标,继续XXX目标时 # 本目标会话协作项目改为进行中)」 # ④「创建检查会话的为 协作程序(现在不需要上报机制了、之前已经去掉 唤醒会话和跟进会话机制)」 # # 🔴 与 `goal_state()` 的区别(**极易混,必须分清**): # · `goal_state()` = **完成度**(判据有没有全过)—— 判「这个目标做完了吗」 # · `GOAL_LIFE` = **生命周期**(还要不要继续干)—— 判「这个项目还在进行中吗」 # ⇒ 检查会话**只在 `GOAL_LIFE == 进行中` 时才建**(用户第③条),⛔ 与完成度无关: # 目标「做完了」但状态还写「进行中」⇒ 照样要建检查会话去把状态改成「已完成」。 GOAL_LIFE_WAIT = "等待" GOAL_LIFE_RUN = "进行中" GOAL_LIFE_BLOCK = "阻碍" GOAL_LIFE_DONE = "已完成" GOAL_LIFE_PAUSED = "已暂停" GOAL_LIFE_ALL = (GOAL_LIFE_WAIT, GOAL_LIFE_RUN, GOAL_LIFE_BLOCK, GOAL_LIFE_DONE, GOAL_LIFE_PAUSED) #🔴 落哪个字段(用户第③条只给了中文名,没给字段名 ⇒ 这里定,**不改既有语义**): # `goal.json.lifecycle` —— ⛔ **不复用 `run`**(`run` 有"paused=停掉"的旧语义, # 被 `goal_paused()` 读,改它会连带把"进行中"读成"已停"⇒ 机制全线静默)。 _LIFE_ALIAS = {"等待": "waiting", "进行中": "active", "阻碍": "blocked", "已完成": "done", "已暂停": "paused"} def goal_life_of(root) -> str: """**指定工作区**的目标生命周期状态(默认 `等待`)—— 🔴 **全库唯一判定实现**。 🔴🔴 2026-10-04 **抽成"可指定 root"的唯一事实源**(用户口径「**禁止重复判定标准**」): 在此之前 `peer_supervise_sweep()` **自己抄了一遍** `str(g.get("lifecycle")).strip()` + 精确比较(**不剥后缀**)⇒ 与 `goal_life()` 结论不一致: `已完成(机器可判部分)` 在主路径读成 `已完成`、在跨区扫描里读成**原样字符串** ⇒ 两条路对**同一个文件**给出**两个答案**。 ⇒ 后果:主路径"收工不建",跨区扫描却认为"非进行中 ⇒ 不拉"(这次碰巧同果), 但只要出现第三种写法就会**分叉**(一个拉一个不拉)⇒ 这才是真隐患。 ✅ 正解 = 判定算法**只留这一份**,`goal_life()` 只是它的薄封装(⛔ 不复制)。 🔴 **默认必须是「等待」而不是「进行中」**(用户第③条:只有进行中才建检查会话): 默认给「进行中」⇒ 用户还没开口说「继续XX目标」,机制就开始自动建会话 ⇒ **越权**(这正是 P0-24 那类事故的形状)。宁可等,不可动。 ⛔ 读不到 `goal.json` ⇒ 判 `等待`(fail-safe 方向=不动)。 🔴🔴 2026-10-04 **剥括号后缀再判**(`vibe-product` 实测坐实,⛔ 这是真缺陷): 该区 `goal.json` 写的是 **`"lifecycle": "已完成(机器可判部分)"`**(带后缀), 而原实现只做**精确匹配**(`v in GOAL_LIFE_ALL` + `_LIFE_ALIAS` 精确查表) ⇒ 两种都对不上 ⇒ **静默回落成 `等待`**。 ⇒ 后果**不是"小偏差"**:`等待` 与 `已完成` 是**语义相反**的两个状态 (`等待` = 用户还没点头;`已完成` = 收工) ⇒ 日志打「目标状态=**等待**(非进行中)⇒ 不建」、主会话读到"用户没点头", 而真相是"这轮做完了、等人工过目" ⇒ **排查时被直接带偏**(当晚确实被带偏过)。 ✅ 正解 = 先**剥掉全角/半角括号后缀**(`已完成(机器可判部分)` → `已完成`)再判。 ⚠️ 为什么会出现这种值:CLI `--set-life` 只收 5 个标准值(带后缀会被拒), 所以它是**手改 `goal.json`** 写进去的 ⇒ 机读侧必须容忍。 🔴 **认不出时 ⛔ 不许静默回落**(上一版正是"零输出"的老毛病): 回落动作不变(仍是 `等待` = fail-safe 不动),但**必须写一行日志** ⇒ 否则"读错"与"本来就没有"在日志里**长得一模一样**,等于把排查线索抹掉。 """ try: g = json.loads((Path(root) / "tmp" / "supervise-inbox" / "goal.json") .read_text(encoding="utf-8")) except Exception: return GOAL_LIFE_WAIT raw = str(g.get("lifecycle") or "").strip() v = raw # 🔴 剥括号后缀:`已完成(机器可判部分)` / `已完成(机器可判部分)` ⇒ `已完成` if "(" in v: v = v.split("(", 1)[0].strip() elif "(" in v: v = v.split("(", 1)[0].strip() if v in GOAL_LIFE_ALL: return v v = _LIFE_ALIAS.get(v, v) # 容错:接受旧英文写法 for cn in GOAL_LIFE_ALL: if v == _LIFE_ALIAS[cn]: return cn # ⛔ 认不出 ⇒ 只报事实(⛔ 不猜病因),回落方向不变。 # 🔴 用**限流版**:本函数被主循环每 30 s 调一次(`maybe_spawn_check_agent` 里), # 裸 `log()` 会把同一句话刷满日志 ⇒ 把真事件淹掉(2026-10-01 的同一类坑)。 if raw: log_throttled("goal-life-unknown", 600, "目标状态字面量认不出:lifecycle=%r ⇒ 按「等待」处理(fail-safe 不动)" % raw) return GOAL_LIFE_WAIT def queue_pending_of(root) -> int: """**指定工作区**的队列未完成件数 —— 🔴 **全库唯一判定实现**(`queue_pending()` 的跨区版)。 🔴🔴 2026-10-04 **抽成"可指定 root"的唯一事实源**(用户口径「**禁止重复判定标准**」): 起因同 `goal_life_of()` —— 跨区自愈要判"那个区的队列还有没有活", 若各写一段读 `tasks.json` 的代码,**两条路迟早对同一份数据给出两个答案**。 ⇒ 判定算法只留这一份(`queue_pending()` 与本函数共用 `_count_open(nodes)`)。 🔴 **四态口径**(与 `--report` 写入口、`board.py` 分工板、`queue_pending()` **同一套**): `pending`/`running`/`blocked` 计入未完成;**只有 `done` 不计**。 ⚠️ state 缺失(空串)**不计入** —— 与 `queue_pending()` 保持逐字一致 (那里是 `if st and st != "done"`,空 state ⇒ 跳过)。 ⛔ 读不到 ⇒ 返回 **0**(fail-safe 方向="就当没活",⛔ 不返回大数把闸锁死)。 """ try: t = json.loads((Path(root) / "tmp" / "supervise-inbox" / "tasks.json") .read_text(encoding="utf-8")) except Exception: return 0 if not isinstance(t, dict): return 0 n = 0 for _k, v in t.items(): if not isinstance(v, dict): continue st = str(v.get("state") or v.get("status") or "").strip().lower() if st and st != "done": n += 1 return n def goal_life() -> str: """本区 `lifecycle` = `goal_life_of(WS)` 的**薄封装**(⛔ 不复制判定算法)。""" return goal_life_of(WS) def goal_life_set(life: str, by: str = "", why: str = "") -> str: """**改目标生命周期**并落盘。`by`=改它的会话名(留痕)。""" life = str(life or "").strip() if life not in GOAL_LIFE_ALL: return "✗ 状态只能是:" + "、".join(GOAL_LIFE_ALL) try: p = INBOX / "goal.json" g = json.loads(p.read_text(encoding="utf-8")) except Exception as e: return "✗ 读不到 goal.json:%s" % e old = str(g.get("lifecycle") or _LIFE_ALIAS_WAIT_NOTE).strip() g["lifecycle"] = life g["lifecycle_at"] = time.strftime("%Y-%m-%dT%H:%M:%S") if by: g["lifecycle_by"] = by if why: g["lifecycle_why"] = why try: p.write_text(json.dumps(g, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", newline="\n") except Exception as e: return "✗ 写goal.json 失败:%s" % e # 🔴🔴 2026-10-03 **接上「目标完成 ⇒ 收工」**(用户口径第①条,逐字: # 「**目标完成后需要把目标状态改为 完成,关闭后台任务 和 协作程序 # (有新目标或之前的目标有调整 在打开)**」)。 # 🔴 **改前取证**:本函数**原来只写 goal.json 四个字段就 return** # ⇒ `--set-life 已完成` **从不停止任何进程**(查证:无 stop/kill 调用) # ⇒ "完成即收工"这条口径在代码里**根本没实现**(本条把它接上)。 # ✅ 只在真走到「已完成」时才收工;⛔ 其他状态(尤其 `阻碍`/`已暂停`)**一律不动** # —— 用户口径第②条:「目标遇到阻碍 …(**不用关闭后台任务和协作程序**)」。 _tail = "" if life == GOAL_LIFE_DONE: _r = supervise_stop(reason="目标已完成(%s)" % (by or "未署名")) _tail = " |常驻程序:%s" % _r elif life in (GOAL_LIFE_BLOCK, GOAL_LIFE_PAUSED): # 📌 **显式说明为什么不停** —— 免得后来人以为漏了(同族:⛔ 静默省略=读者会瞎猜)。 _tail = " |常驻程序:**保持运行**(%s≠已完成 ⇒ 按口径不停)" % life return "✅ 目标状态:%s → %s%s%s" % ( old or "(未设)", life, ("(%s)" % why) if why else "", _tail) def supervise_stop(reason: str = "") -> str: """**停掉本工作区的常驻程序**(`--supervise` 进程)⇒ 返回**人可读结果**。 🔴 **为什么必须有这个函数**(2026-10-03 用户口径第①条 + 实测坐实): 此前「目标完成 ⇒ 关后台任务与协作程序」这条口径**在代码里没有实现** —— `--set-life 已完成` 只写 `goal.json` 就返回,**从不碰进程** ⇒ 目标完成后常驻仍在跑、仍在建检查会话(浪费,且会让看板显示"目标已完成但机制还在动")。 ⚠️ 这不是"要不要做"的取舍,是**本来就该做而漏了**。 ✅ **停法=先礼后兵**(⛔ 不上来就 `kill -9`): ① 先写 `guard.stop` 停止标志 ⇒ 常驻主循环 `_guard_says_stop()` **优雅退出**; ② 等一小会儿(最多 `STOP_GRACE` 秒)复查存活; ③ 仍不死才按 pid 强停(`terminate` → 仍不行 `kill`)。 🔴 **强停只在"该停却停不掉"时用** —— 宁可慢,不可误杀。 ⚠️ **幂等**:本来就没在跑时返回"本来就停着"⇒ 不报错。 ⚠️ **只停本工作区**:pid 取自**本区** `supervise.pid` / 心跳,⛔ 不碰别的区。 """ ok, detail = supervise_alive() if not ok: # ⛔ 顺手清掉可能残留的停止标志(⛔ 不删 pid 文件:它要留给"复活"判据看) try: (INBOX / "guard.stop").unlink() except Exception: pass return "本来就没在跑(%s)" % detail _hb = read_heartbeat() pid = _hb.get("pid") if pid is None: try: pid = int(SUP_PID.read_text(encoding="utf-8").strip()) except Exception: pid = None # ① 停止标志 ⇒ 走主循环自带的优雅退出路径 try: (INBOX / "guard.stop").write_text( time.strftime("%Y-%m-%d %H:%M:%S") + " " + (reason or "目标已完成"), encoding="utf-8", newline="\n") except Exception as e: return "⛔ 写停止标志失败:%s(pid %s 仍在跑)" % (e, pid) _deadline = time.time() + STOP_GRACE while time.time() < _deadline: time.sleep(0.5) if not _pid_alive(pid): return "已优雅退出(pid %s,因:%s)" % (pid, reason or "目标已完成") # ② 优雅退出没成功 ⇒ 强停 # 🔴🔴 **Windows 上不能用 `os.kill` 发 SIGTERM**(2026-10-03 实测坑): # POSIX 的 `os.kill(pid, SIGTERM)` 在 Windows 上**只支持 SIGKILL**, # 传 15 会抛 `ValueError: signal name or number not valid` ⇒ **静默停不掉**。 # ✅ 分平台:Windows 用 `taskkill /T /F`(`/T`=连子进程,`/F`=强停); # 非 Windows 才用 `os.kill(SIGTERM→SIGKILL)`。 _win = os.name == "nt" for nm in (("TREE/F", "TERM") if _win else ("TERM", "TERM"), ("TREE/F", "KILL")): if _win: try: subprocess.run(["taskkill", "/PID", str(int(pid)), "/T", "/F"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=20, # 🔴 `creationflags=HIDE`(CREATE_NO_WINDOW):⛔ 缺了它 # `taskkill.exe` 会**每停一次闪一个黑窗**(用户 10-02 报过「一闪一闪」)。 # ⚠️ 这条不是洁癖:本仓自检 `t_subproc_no_window` 专门守它, # 漏了就会假红(实测:加了 taskkill 后该用例立刻报红 ⇒ 判据有效)。 creationflags=HIDE, close_fds=True) except Exception: pass else: try: os.kill(int(pid), getattr(signal, "SIGTERM", 15) if nm[1] == "TERM" else getattr(signal, "SIGKILL", 9)) except Exception: pass _dl = time.time() + 3 while time.time() < _dl: time.sleep(0.3) if not _pid_alive(pid): return "已强停(%s,pid %s,因:%s)" % (nm[0], pid, reason or "目标已完成") return "⛔ 停不掉(pid %s 仍在跑)" % pid _LIFE_ALIAS_WAIT_NOTE = "等待" # 旧值显示用的兜底文案 # ════════════════════════════════════════════════════════════════════ # 🔴🔴 建排期(**唯一允许程序建排期的地方** · 2026-10-02 探针实测通过) # ════════════════════════════════════════════════════════════════════ # 源码级硬闸(宿主 `AutomationMainService.decideDueAutomation`): # if (!Number.isFinite(automation.nextRunAt)) return {action:"skip"} ← 决定性 # 实测四版探针:只有「next_run_at 有限值 + scheduled_at ISO 字符串」这一组合被拾取。 # v1 next_run_at ✓ / scheduled_at 毫秒整数 ✗ → 未拾取 # v2 next_run_at NULL / scheduled_at ISO ✗ → 未拾取 # v3 补 runtime_state → 仍未拾取(证明那行没用) # v4 两项都对 → IN_PROGRESS + 真实 conversationId def _wb_db() -> Path: """宿主活动库(⛔ **不是** `~/.workbuddy/workbuddy.db` —— 那是旧库且可能是 0 字节空文件)。""" cfg = os.environ.get("CODEBUDDY_CONFIG_DIR") or "" return Path(cfg) / "workbuddy.db" if cfg else Path.home() / ".workbuddy" / "workbuddy.db" def _ro_conn(): c = sqlite3.connect("file:%s?mode=ro" % _wb_db(), uri=True, timeout=10) c.execute("PRAGMA busy_timeout=5000") return c def local_user_id() -> str: """本机登录用户 id(`owner_user_id` 用)—— 从库里的既有排期反查,取最近用过的那个。""" try: c = _ro_conn() r = c.execute( "select owner_user_id from automations where owner_user_id is not null " "and deleted_at is null order by created_at desc limit 1").fetchone() c.close() return (r[0] if r else "") or "" except Exception: return "" # 🔴🔴 **机制另建会话时,模型该跟谁一致**(2026-10-05 用户点破 P0-80) # 症状:常驻新建的 `[执行]`/`[检查]` 会话全是模型 `space-bunny`,而**主会话是 `deepseek-v4.1-flash`** # ⇒ 同一件活在两种模型上跑(能力档、思考开关全不一致)。 # 真因:`create_check_schedule()` 的 INSERT 里**字面写死** `"space-bunny"`(把一次实测当成了永恒)。 # ✅ 正解:**跟本区最近一条「人开的」会话取同一档** —— 模型 + 思考档一起继承。 # ⚠️ 两条建会话的路必须**同源**:常驻直连 SQLite(本函数所服务的路径)/会话侧走 `automation_update`。 # ⛔ 取不到时**必须打日志**(⛔ 不许静默回落 —— 否则又是"看着改了其实没生效")。 FALLBACK_MODEL = "space-bunny" # 兜底常量:仅当**取不到**主会话模型时才用 FALLBACK_THINKING = 0 def preferred_model(cwd: str = "") -> tuple: """**本区主会话用的(模型, 是否开思考)** —— 机制另建会话时照抄这个。 取数:`sessions` 里**人开的**(`is_background_automation <> 1`)、**本区**、**最近一条**。 ⚠️ 排除 `bg=1` 是必须的 —— 否则会取到「机制自己上次建的那条」⇒ **自我强化**, 永远锁死在历史值上(同 P0-77 那类"错值自我强化")。 ⚠️ `thought_level`:`disabled`/空 ⇒ 0,其余(`high`/`low`/…) ⇒ 1。 """ ws = (str(cwd or WS or os.getcwd())).replace("\\", "/").rstrip("/") try: c = _ro_conn() r = c.execute( "select model, thought_level from sessions " "where (is_background_automation is null or is_background_automation <> 1) " " and model is not null and model <> '' " " and replace(cwd,'\\\\','/') like ? " "order by created_at desc limit 1", ("%" + ws + "%",)).fetchone() c.close() except Exception as e: log("⚠️ preferred_model 取数失败(回落 %s):%s" % (FALLBACK_MODEL, e)) return FALLBACK_MODEL, FALLBACK_THINKING if not r or not (r[0] or "").strip(): log("⚠️ preferred_model 取不到本区主会话模型(cwd=%s)⇒ 回落 %s" % (ws, FALLBACK_MODEL)) return FALLBACK_MODEL, FALLBACK_THINKING m = str(r[0]).strip() tl = str(r[1] or "").strip().lower() th = 0 if tl in ("", "disabled", "off", "none", "0") else 1 log("preferred_model:本区主会话 model=%s thought=%s ⇒ 机制照抄这个(P0-80)" % (m, tl or "-")) return m, th def active_schedule_exists(name: str) -> bool: """**同名排期是否已在册且未跑**(⛔ 去抖:防止每轮重复建同名排期)。""" try: c = _ro_conn() r = c.execute( "select count(*) from automations where name=? and status='ACTIVE' " "and deleted_at is null and (last_run_at is null or last_run_at=0)", (name,)).fetchone() c.close() return bool(r and r[0]) except Exception: return False # ══════════════════════════════════════════════════════════════════════════ # 🔴🔴 任务会话「独立域」判据(2026-10-02 用户拍板) # 用户原话:「创建协作会话还要加个判断:协作会话必须是独立域运行的,就是做所有修改操作都在 # 单独的文件下运行(比如某个项目要开发 webserver,desktop,phone app,独立插件或产品原型) # 这些文件都可以放在工作区对应独立文件夹下,**只能只读的方式访问别的文件夹内容**。应为会话有锁的 # 机制,开多个会话都操作一个域的文件只有一个会话能执行,别的只能干等。」 # # 🔴🔴 **本节的域键算法必须与 `handoff-guard.sh` 的 `norm_domain()` 逐字一致**(否则 shell 侧 # 写进去的域和本节算出来的域对不上 ⇒ **域锁静默失效=假绿**:两边都以为对方没锁)。 # 对侧 = `$DSH_CODE/dsh-server-docs/07-scripts/`(实测锚点词表两边已**不一致**,见下)。 # # ⚠️⚠️ **实测出来的真事实(决定了本节全部设计,⛔ 别凭直觉改)**: # 域键=「路径里**第一个**出现在锚点词表里的段 + 它的**下一段**」。 # 而工作区目录名(如 `ai1net-dsh-server`)**本身就在锚点词表里** ⇒ **工作区下的路径, # 域键恒为 `<工作区名>/<第一层目录名>`**,与再往下钻几层**无关**。实测读数: # `…/ai1net-dsh-server/webserver` → `ai1net-dsh-server/webserver` # `…/ai1net-dsh-server/webserver/api` → `ai1net-dsh-server/webserver` ← 同域! # `…/ai1net-dsh-server/web/api` → `ai1net-dsh-server/web` ← 不同域 # ⇒ **只有「工作区下的第一层目录」才是并行粒度**。这条既是本节的设计依据, # 也是用户那句「别的只能干等」的**根因**:两个会话都去改 `webserver/api` 和 `webserver/db` # ⇒ 域键都是 `…/webserver` ⇒ 第二个会话的写操作被无锁拒写钩子挡住 ⇒ 干等。 # ⇒ **正解不是加锁粒度,而是让每个任务会话认领不同的第一层目录当自己的域。** # # ⚠️ **两端锚点词表当前不一致(2026-10-02 实测,⛔ 属既有缺陷,本节只对齐不擅自改机制层)**: # handoff-guard.sh : dsh-server-docs ai1net-dsh-server aliyun-dsh-server src poc web test # docs scripts skills 交接单 # lock-guard-hook.py: dsh-server-docs ai1net-dsh-server aliyun-dsh-server src poc web test # docs 07-scripts 08-skills 05-交接单 # 差异:`scripts`/`skills` vs `07-scripts`/`08-skills`。⇒ 命中「第一个锚点段」时, # **工作区下的路径两边结果一致**(都先命中工作区名)⇒ 本节的实际判据不受影响; # ⚠️ 但**代码仓根目录下的路径**(无工作区名那段)两边会算出不同键 ⇒ 已存在假绿风险。 # 🔴 本节按 **handoff-guard.sh(抢锁侧 = 真源)** 对齐,并留 `_DS_ANCHORS_OTHER` 记录另一份。 # ══════════════════════════════════════════════════════════════════════════ # 🔴 取自 `handoff-guard.sh:66` `_ANCHOR_SEGS`(**抢锁侧=真源**)。⛔ 改这里必须同时改那边。 _DS_ANCHORS = ("dsh-server-docs", "ai1net-dsh-server", "aliyun-dsh-server", "src", "poc", "web", "test", "docs", "scripts", "skills", "交接单") # ⚠️ 另一份(`lock-guard-hook.py:83 _DOMAIN_SEGS`)—— 🔴 2026-10-03 10:0x **已对齐**: # 原值是 `07-scripts/08-skills/05-交接单`(带数字前缀的**旧仓目录名**)⇒ 与本表不同 # ⇒ 那个文件里的 `domain_key()` 会跟抢锁侧算出**不同域键** ⇒ 域锁静默失效(假绿)。 # ✅ 已改成 `scripts/skills/交接单`,与 `_DS_ANCHORS` 及 shell 侧**三处逐字一致**。 # ⛔ 本变量**不参与任何计算**,只保留作"另一侧读到什么"的对照;自检会逐字比三处。 _DS_ANCHORS_OTHER = ("dsh-server-docs", "ai1net-dsh-server", "aliyun-dsh-server", "src", "poc", "web", "test", "docs", "scripts", "skills", "交接单") # 🔴🔴 任务会话的域目录=**工作区下的第一层目录**(⛔ 不是任意深层,也⛔ 不加公共前缀目录)。 # # ⚠️⚠️⚠️ **实测定案(2026-10-02,⛔ 这是本节最容易做错的一步,别的写法全是自欺)**: # 域键=「路径里**第一个**出现在锚点词表里的段 + 它的**下一段**」,锚点词表里**含工作区名**。 # ⇒ 无论再往下钻几层,域键恒=`<工作区名>/<工作区下的第一层目录名>`。三条实测读数: # `…/ai1net-dsh-server/domains/webserver` → `ai1net-dsh-server/domains` ← ❌ **全部撞键** # `…/ai1net-dsh-server/domains/desktop` → `ai1net-dsh-server/domains` ← ❌ 同上 # `…/ai1net-dsh-server/webserver` → `ai1net-dsh-server/webserver` ← ✅ 独立 # `…/ai1net-dsh-server/交付物/webserver` → `ai1net-dsh-server/交付物` ← ❌ 同样撞键 # ⇒ **加一层公共前缀目录(`domains/`)会把所有独立域压成同一个键 ⇒ 并行机制彻底失效** # (用户原话:「开多个会话都操作一个域的文件只有一个会话能执行,别的只能干等」—— # 这不是锁太粗,是**域目录摆错了位置**)。 # ⇒ **正解:每个并行任务会话直接占工作区下的一个第一层目录当自己的域** # (`<工作区>/webserver/`、`<工作区>/desktop/`、`<工作区>/phone-app/`…)。 DS_DOMAIN_SUBDIR = "" # ⛔ 刻意留空:见上面三条实测;⛔ 别改成 "domains"(会把域键压平) def ds_domain_key(path: str) -> str: """路径 → **域键**(与 `handoff-guard.sh` 的 `norm_domain()` 逐字同规则)。 ⚠️ 两处细节必须一致,否则键对不上: ① `if (seg in parts)` 取**首个**出现的段(`list.index` 语义),⛔ 不是取最深/最浅; ② 未命中任何锚点时取**末两段**(`parts[-2:]`),⛔ 不是取整条路径。 """ n = str(path or "").replace("\\", "/") parts = [p for p in n.split("/") if p] for seg in _DS_ANCHORS: if seg in parts: i = parts.index(seg) return "/".join(parts[i:i + 2]).lower() return "/".join(parts[-2:]).lower() if len(parts) >= 2 else n.lower() def ds_workspace_root() -> str: """本工作区根(正斜杠,无尾斜杠)—— 域键的**计算基准**。""" return str(WS or os.getcwd()).replace("\\", "/").rstrip("/") def ds_domain_dir(name: str) -> str: """任务会话的**域目录**=`<工作区>/<域名>`(直接占第一层 ⇒ 天然独立域)。 ⚠️⛔ **不要再套一层公共父目录**(`domains/`、`projects/` 之类)—— 实测会让所有子目录 算出**同一个域键** ⇒ 域锁等于没有。见上面 `DS_DOMAIN_SUBDIR` 的三条实测读数。 """ return "%s/%s" % (ds_workspace_root(), str(name or "").strip("/")) def ds_domain_key_of(name: str) -> str: """某个域目录名 → 它的域键。""" return ds_domain_key(ds_domain_dir(name)) def ds_active_domain_locks() -> dict: """读**别人**持有的域锁 ⇒ `{域键: 持有人会话名}`(只读,⛔ 不建任何锁)。 🔴 **读不动就当"锁全满"**(fail-safe)—— 这就是用户那句「别的只能干等」: 读不到锁表时宁可不派,也不要派出一堆会互相干等的会话。 ⚠️ **自己的锁必须排除**,判据=`OWNER` 第 1 行会话名逐字相等(与 `handoff-guard.sh` 的 `_my_owner()` 同思路)。⛔ 只比会话名不比自己 ⇒ 抢第二把域锁时会被自己挡住。 """ out = {} _me = str(os.environ.get("DSH_SESSION_NAME") or "").strip() # 锁根=文档库的 `05-交接单/.locks`;找不到就按 `DSH_CODE_REPO` 推一次 _cands = [] if os.environ.get("DSH_LOCKROOT"): _cands.append(os.environ["DSH_LOCKROOT"]) _dr = os.environ.get("DSH_DOCS_REPO") or "" if _dr: _cands.append(os.path.join(_dr, "05-交接单", ".locks")) for _p in (r"D:\github\dsh_shenxian\dsh-server-docs\05-交接单\.locks", "D:/github/dsh_shenxian/dsh-server-docs/05-交接单/.locks"): _cands.append(_p) root = next((c for c in _cands if os.path.isdir(c)), "") if not root: return {"__unreadable__": "(锁根定位不到)"} try: for nm in os.listdir(root): d = os.path.join(root, nm) if nm.startswith(".") or not os.path.isdir(d): continue try: owner = "" op = os.path.join(d, "OWNER") if os.path.isfile(op): with open(op, encoding="utf-8", errors="replace") as f: owner = (f.readline() or "").strip() if _me and owner == _me: continue # ⛔ 自己的锁不放行自己(否则会在派活时误判成"已占用") dp = os.path.join(d, "DOMAINS") if not os.path.isfile(dp): continue with open(dp, encoding="utf-8", errors="replace") as f: for line in f: k = line.strip().lower() if k: out[k] = owner or nm except Exception: continue except Exception as e: return {"__unreadable__": "(锁表读取失败:%s)" % e} return out DOMAIN_CHECK_BLOCK = """🔴🔴 **【独立域 · 硬门禁】本会话的所有写操作必须落在下面这一个目录内** - **你的域目录**:`%(dir)s`(= 工作区下的**第一层**目录) - **对应域键**:`%(key)s` - **开工第 0 步**:先抢到这把域锁 —— `bash handoff-guard.sh --claim-exec "<你的会话名>" --domains "%(--domains)s"`, 抢不到 ⇒ **停手报告**,⛔ 不许硬写。 - **写操作**:只允许写域目录内(在里面新建子目录可以)。 - **读操作**:可以只读访问工作区其它目录(含 `%(root)s/tmp`、`交付物`、`docs`)—— **只读即安全,不要为了"顺手"把产物写回别处**。 - 🔴 **为什么**:会话之间靠域锁互斥。两个会话碰同一个域键 ⇒ **只有一个能写,另一个只能干等** (实测域键=`<工作区名>/<第一层目录名>`,与再往下钻几层**无关**)⇒ 独立域是并行的前提。 - 🔴⛔ **别在域目录外套一层公共父目录**(`domains/xxx`、`projects/xxx` 那样): 实测那样所有子目录会算出**同一个域键** ⇒ 域锁等于没有、并行直接失效。 - 🔴 **越界了怎么办**:发现必须写的文件在域目录外 ⇒ **不要写**, 在 `tmp/supervise-inbox/NEED-USER.md` 写明「要把 X 挪进本域目录,需要谁批准」⇒ 交回,不硬闯。 """ def domain_gate_block(domain_name: str, domains_arg: str = "") -> str: """**独立域门禁块** —— 拼进任务会话 prompt(⛔ 本函数只拼串,不建排期、不抢锁)。""" _dir = ds_domain_dir(domain_name) _arg = domains_arg or _dir return DOMAIN_CHECK_BLOCK % {"dir": _dir, "key": ds_domain_key(_dir), "root": ds_workspace_root(), "--domains": _arg} def check_domain_free(domain_name: str) -> dict: """**这个域现在能不能派** ⇒ `{ok, key, holder, why}`。 ⚠️ 这是**派活前的闸**,不是抢锁 —— 判「已被别人占」时,正确动作是 **换一个域目录名**(`domains/<新名>`),⛔ 不是等。 """ key = ds_domain_key_of(domain_name) locks = ds_active_domain_locks() if "__unreadable__" in locks and len(locks) == 1: return {"ok": False, "key": key, "holder": "?", "why": "锁表读不到 ⇒ fail-safe 判不可派(%s)" % locks["__unreadable__"]} holder = locks.get(key, "") if holder: return {"ok": False, "key": key, "holder": holder, "why": "域键已被会话「%s」持有 ⇒ 换域目录名,⛔ 不要等" % holder} return {"ok": True, "key": key, "holder": "", "why": "域空闲"} #⛔⛔ 域目录名里**不许出现的字符**:路径分隔符与 Windows 保留字符。 # (域目录名会直接拼进路径与域锁命令,脏字符会静默把锁写到别处 ⇒ 假绿) _DS_BAD_NAME = set('/\\:*?"<>|') def _slug(s: str) -> str: """把任意文本压成**安全的单段目录名**(⛔ 非 ASCII 保留——用户的工作区全是中文目录)。""" out = [] for ch in str(s or "").strip(): if ch in _DS_BAD_NAME or ch in (' ', '\t', '\n', '\r'): out.append('-') else: out.append(ch) _s = "".join(out).strip("-.") return _s[:40] or "task" def suggest_domain_name(topic: str = "", extra: str = "") -> str: """按类别推荐一个**当前空闲**的域目录名(读锁表 ⇒ 被占就自动加序号后缀)。 ⚠️ 这只是**推荐**:真正的门禁在任务会话自己抢锁那一步(prompt 里写了命令)。 这里先挑空闲的,是为了**别在第一步就派一个注定干等的活**。 ⚠️ `extra` 为空时⛔ 不拼任何后缀(曾拼出一个多余的 `-task`,域名越堆越长)。 """ base = _slug(topic or "") or "task" if extra: base = "%s-%s" % (base, _slug(extra)) if check_domain_free(base).get("ok"): return base for i in range(2, 20): cand = "%s-%d" % (base, i) if check_domain_free(cand).get("ok"): return cand return "%s-%d" % (base, int(time.time() % 100000)) def ds_domain_report() -> dict: """**域现状体检**(只读)⇒ 供 `--domain-status` 打印,也给 `state.py` 引用。""" locks = ds_active_domain_locks() return { "root": ds_workspace_root(), "domains_dir": "%s/<域名>" % ds_workspace_root(), "anchors": list(_DS_ANCHORS), "anchors_other": list(_DS_ANCHORS_OTHER), "anchors_match": list(_DS_ANCHORS) == list(_DS_ANCHORS_OTHER), "locks": locks, "unreadable": "__unreadable__" in locks and len(locks) == 1, } def create_check_schedule(name: str, prompt: str, delay_s: int = 90, cwd: str = "") -> dict: """🔴 **建一条「检查会话」排期**(用户第④条:创建者是**常驻程序**,不是会话)。 硬规则(缺一条就**静默不拾取**,实测踩过): · `next_run_at` 必须是**有限数值**(epoch 毫秒,未来)—— 决定性变量 · `scheduled_at` 必须是 **ISO 字符串**(写毫秒整数会静默失效) · `cwds` 必须是**绝对路径、正斜杠、逐字同形**(错一字面即裂组) ⚠️ 到点延迟实测 20~35s(宿主扫描周期 ≤30s)⇒ 判「没跑」要留这个余量。 """ delay_s = max(30, int(delay_s or 90)) now_ms = int(time.time() * 1000) fire = datetime.datetime.now() + datetime.timedelta(seconds=delay_s) aid = str(uuid.uuid4()) ws = (cwd or (str(WS) if WS else os.getcwd())).replace("\\", "/") # 🔴 `cwds` **必须正斜杠**(红线:`cwds` 逐字同形,去重键=`path.trim().toLowerCase()`; # 落库若用反斜杠 ⇒ 与 automation_update 建的排期**裂成两个组**,看板上就是"裂组") # 🔴🔴 2026-10-05 **P0-80**:模型⛔ 不许写死 —— 跟**本区主会话**取同一档(模型+思考档一起)。 _model, _thinking = preferred_model(ws) # 🔴🔴 2026-10-07 **本 INSERT 必须写 `workspace_scope='workspace'`**(实测坐实,⛔ 少一列就裂组): # 原来没有这一列 ⇒ 落库是 NULL ⇒ 排期触发出来的会话被宿主当成**游乐场**建 # (`sessions` 行:`is_playground=1` + `source_mode='craft'` + `use_sandbox_cli=0`) # ⇒ **不进工作区分组**。用户报障原话:「**vibe-product 的检查会话没有创建到工作区下, # 跑到未分组的会话中去了**」。 # 对照读数(同一晚两条排期):会话侧 `automation_update` 建的是 `'workspace'` ✓, # 本机制自建的是 `None` ❌;近 3 天 59 条排期里**只有机制自建的那一条**是 None。 c = sqlite3.connect(_wb_db(), timeout=15) try: c.execute("PRAGMA busy_timeout=8000") c.execute( """INSERT INTO automations (id,name,prompt,status,schedule_type,scheduled_at,next_run_at,rrule,cwds, created_at,updated_at,model_id,model_is_thinking,skills_json, connector_ids_json,owner_user_id,owner_status,workspace_scope) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (aid, name, prompt, "ACTIVE", "once", fire.strftime("%Y-%m-%dT%H:%M:%S"), now_ms + delay_s * 1000, "", json.dumps([ws], ensure_ascii=False), now_ms, now_ms, _model, _thinking, "[]", "[]", local_user_id(), "confirmed", "workspace"), ) c.commit() finally: c.close() log("已建检查会话排期 name=%s id=%s fire=+%ds model=%s think=%d" % (name, aid[:8], delay_s, _model, _thinking)) return {"id": aid, "name": name, "fire_at": fire.strftime("%H:%M:%S"), "delay_s": delay_s, "model": _model, "thinking": _thinking} # 已建的检查会话排期(去抖 + 看板读数) CHECK_STAMP = INBOX / "check-agent.json" def _check_stamp() -> dict: try: return json.loads(CHECK_STAMP.read_text(encoding="utf-8")) except Exception: return {} def _check_stamp_save(d: dict) -> None: try: INBOX.mkdir(parents=True, exist_ok=True) CHECK_STAMP.write_text(json.dumps(d, ensure_ascii=False, indent=2), encoding="utf-8", newline="\n") except Exception: pass def _all_sessions_idle() -> bool: """**所有会话都结束了**吗(含主会话 · 用户第②条)。 🔴🔴🔴 **2026-10-03 09:4x 统一口径**(用户明令逐字:「就用看板相同的机制, 解决会话是否执行的判断问题 统一一个标准」)⇒ 本函数改成**与看板同款**。 👉 **「看板的亮」就是 `status=='working'`** —— ⛔ 不是另有一个字段: `assets/board.html:823` 逐字是 `var mOn=!!(main&&main.status==='working');` ⇒ 亮 ≡ `status=='working'`,不亮 ≡ 其余三档。 ⇒ **本机制内「会话是否在执行」统一以这一条为唯一标准**(看板、闸②、看板文案同源)。 🔴 **为什么「统一」比「各自更准」更要紧**(本轮根因): 机制里此前有**三套**判"忙"的口径 —— ① 看板读 `status`/② 本函数数 `status` /③ 投递侧 `_target_busy()` 读宿主状态机日志。**同一个问题三个答案**, 改一处漂一处;口径互相矛盾时会**误判成"有人在推进"从而永不自检**。 ⇒ 用户拍板:**统一到看板那套**,`status` 是唯一真相。 ✅ **两处配套(与看板同款,缺一不可)**: ① **排除 `SELF_SID` 自身**(前缀比法,同 `board.py:495` —— 看板早就是这么做的): 常驻观察者跑在某个会话里(`SELF_SID` 取自 `CODEBUDDY_SESSION_ID`), 那个会话自己的 `working` 会把闸顶死 ⇒ **自锁**(本轮 09:0x 实测正是如此)。 ⚠️ `SELF_SID` 为空(常驻跑在会话外)⇒ 不排除任何东西 ⇒ 行为与看板一致。 ② ⛔ **保留 fail-safe**:读库失败 ⇒ 判「有会话在跑」⇒ 不建检查会话。 ⚠️ **`status` 判不出的那一半(已知代价,按用户拍板接受,不再绕)**: `sessions.status` 取值域只有 `working/completed/error/archived`, ⛔ **没有"空闲"档** —— 一条**活着但停下等下一轮**的会话,**`status` 仍是 `working`**。 ⇒ 本标准下「在执行」≡「这条会话**还没结束**」。 ⇒ 若**别人**有那种会话,本闸会**如实说"没结束"** —— 这是本标准的**已知局限**; 机制 10-01 另有 `_target_busy()`(读状态机日志 `busy=`)能判得更细, 但**本函数按用户拍板 ⛔ 不再用它**(**统一优先于精确**)。 """ _w = str(WS or "") if not _w: return False # 🔴🔴 **判据与看板同款**:`status='working'` ⇔ 看板那个格 `mOn` 为真。 # 三种斜杠形态都试(同 `_last_progress_ts` 的教训:只用一种 ⇒ 匹配 0 行 # ⇒ 恒判"全结束" ⇒ **会在别人还在跑时误建检查会话**)。 # ⚠️ **任一形态命中即停**:三种形态指向同一个库,全部查完会重复计数。 ids = [] for pat in (_w, _w.replace("\\", "/"), _w.replace("/", "\\")): try: c = _ro_conn() ids = [str(r[0]) for r in c.execute( "select id from sessions where status='working' and cwd=?", (pat,)).fetchall()] c.close() except Exception as e: log("all_sessions_idle 读库失败 %s" % e) return False # ⛔ 读不出来 ⇒ 判「有会话在跑」⇒ 不建(fail-safe) if ids: break # 🔴 排除观察者自己(与 `board.py:495` 看板同款;⛔ 不排除 ⇒ 闸② 恒锁) _me = str(SELF_SID or "") _pfx = _me[:8].lower() if _me else "" others = [s for s in ids if not (_pfx and s.lower().startswith(_pfx))] if others: log("all_sessions_idle:还有 %d 条 working(%s)⇒ 不算全结束" % (len(others), ",".join(s[:8] for s in others[:3]))) return False return True # ── 🔴🔴 **两类检查会话:两件事 ⇒ 两套 prompt**(用户 2026-10-03 08:0x 明令)──────── # 用户原话:「这里面是两件事 提示词应该不一样 # 1、所有会话都结束(还要加上 定时任务中没有本项目待执行的任务)且队列不为空时: # 创建**结果检查会话**去跟进 协作会话执行结果,判断是否创建对应协作会话继续完成目标,并创建对应会话 # 2、所有会话都结束(还要加上 定时任务中没有本项目待执行的任务)且队列为空>10分钟时: # 创建**目标检查会话**去跟进 目标完成状态,判断是否创建对应协作会话继续完成目标 或 修改目标状态,并执行对应操作」 # ⛔ **不是同一件事的两种触发** ⇒ ⛔ 不许共用模板(共用=两个会话拿到互相串味的指令)。 # # 映射表:`reason` → (中文名, 排期名前缀, 职责一句话) CHECK_KINDS = { "sessions-ended": ("结果检查", "结果检查", "跟进任务会话执行结果,判断要不要建任务会话接着干"), "queue-empty": ("目标检查", "目标检查", "核对目标完成状态,决定建任务会话还是改目标状态"), } # 🔴🔴 2026-10-05 **同类检查的冷却期**(用户口径逐字:「只有两种情况会创建 定时任务创建会话, # 不应该又任何堆积」)。 # # ⛔ **病根**:排期名带递增棒次(`name` 里的 `第%d棒`,`n = round+1`)⇒ # 闸⑤「同名去抖」**永不命中** ⇒ 同一种情况可以被反复建 ⇒ # 实测自测区 2 小时堆 11 条(逐条证据见 `references/pitfalls.md` P0-69)。 # ⇒ 加**闸⑥**:同一种 `reason` 在上次建过之后 `CHECK_COOLDOWN_S` 内**不再建**。 # 🔴 由此「两种情况」真正收敛成「**两种情况 × 每类最多一条/冷却窗口**」⇒ 不堆积。 # ⚠️ 取值:一轮检查会话从建到跑完实测 4~23 分钟 ⇒ 30 分钟既压得住空转, # 又不至于把"确实需要再检查一轮"的情形长期锁死(⛔ 别设成几小时)。 CHECK_COOLDOWN_S = 30 * 60 # 🔴 域门禁 + 硬约束这尾节**两类完全相同** —— ⛔ 但**逐字抄两遍**: # ⛔ 不抽成一个变量再拼装(一旦共用,又会"改一处漏另一处")。 _CHECK_TAIL_A = """ ## %s、🔴🔴 派执行棒时**必须带上独立域门禁** - 每条新执行棒都要有**自己的域目录**:`<工作区>/<域名>/` —— 🔴⛔ **直接占工作区下的第一层目录**, **⛔ 不许套 `domains/` 这种公共父目录**(实测那样所有子目录会算出**同一个域键** ⇒ 并行直接失效)。 - 🔴 **这两条命令的本体是 `collabd.py`,不是工作区目录**(2026-10-03 实测: 写 `python "<工作区目录>" --domain-status` 会报 `can't find '__main__' module` ⇒ 派活门禁**第一步就跑不通** ⇒ 只能到处找信息)。✅ 正确跑法(`%s` 由代码填真路径): ① 看域占用:`python "" --domain-status` ② 要门禁块:`python "" --domain-block <域名>`(⛔ 别手抄) """ # 🔴🔴 2026-10-03 11:0x **加"产物落点"约束**(用户:「每次使用协作会话机制都可以有对应的目标文件夹」)。 # ⚠️ 实测痛点:产物散在 `交付物/`、`docs/`、工作区根目录 ⇒ 检查会话只能到处翻 # (用户报障:触发宿主「重复执行、要求人工确认」)。 # ⇒ 派出去的棒**产物一律落目标目录**,台账 `artifact` 指到这里面 ⇒ 照着就能核实。 # ⚠️ 占位符第 3 位=**目标目录绝对路径**(由 `_check_prompt()` 传 `os.path.join(_g, goal_dir_rel())`)。 _CHECK_TAIL_B = """ ## %s、硬约束 - 🔴 **只建排期,不代替任务会话干活**;建完就停,⛔ 不要连建多条。 - 🔴 建排期用 `automation_update` 工具,**`cwds` 必须逐字等于** `%s`(⛔ 错一字面即裂组)。 - 🔴🔴 **本目标有专属文件夹**:`%s` - 派出去的棒**产物一律落这里**,⛔ **别再散到 `交付物/`、`docs/` 或工作区根目录**。 - 理由:产物集中 ⇒ 检查会话照台账 `artifact` 就能核实,⛔ 不用到处找 (2026-10-03 实测:到处找会被宿主判「重复执行、要求人工确认」)。 - 目录不存在 ⇒ 先跑 `python "" --ensure-goal-dir`(**幂等**,⛔ 不覆盖已有文档)。 - 🔴 本轮只做上面那一件事,做完即停。 """ # 【结果检查】—— 队列**非空**时:跟进执行结果,决定要不要再建任务会话 def _unverifiable_block() -> str: """🔴 **把「不可核实的 done」渲染成 prompt 里的一段**(⛔ 空则写明"无")。 ⚠️ **为什么由机制侧渲染、不让检查会话自己扫台账**(P0-52「缺的不是文档,是机器抓手」): 让会话"自己看台账判断哪条不可核实" ⇒ 每条会话都要重算一遍、标准还可能不同 ⇒ 与 `goal_life_of()` 被抽出来治的病**同形**(用户口径「**禁止重复判定标准**」)。 ⇒ 判据在 `artifacts_unverifiable()`(唯一实现),本函数**只负责排版**。 """ try: _bad = artifacts_unverifiable() except Exception as _e: return " ⚠️ 清单算不出来(%s)⇒ **自己逐条看台账的 `artifact`**。\n" % _e if not _bad: return " ✅ **无** —— 本期所有 `done` 的 `artifact` **都读得到**。\n" _out = [" 🔴 **%d 条 `done` 的产物读不到**(⛔ **别当已完成**,见三.1 的处置):\n" % len(_bad)] for _b in _bad[:8]: _out.append(" · `%s` ⇒ **%s**:%s\n" % (_b["id"], {"missing": "没写 artifact", "gone": "文件不存在"}.get(_b["state"], _b["state"]), _b["why"])) if len(_bad) > 8: _out.append(" · ……另有 %d 条(⛔ 别全列,按同样方式处置)\n" % (len(_bad) - 8)) return "".join(_out) CHECK_PROMPT_RESULT = ( "**【结果检查会话 · 第 %d 棒】**本轮只做这一件事:**跟进任务会话的执行结果**,**做完即停**。\n" "\n" "目标:%s\n" "\n" "## 一、为什么是你(🔴 触发条件已满足,不必重判)\n" "**所有会话都已结束** + **队列不为空**(有活没人干)+ **本项目没有别的待执行排期**。\n" "\n" "## 二、🔴 先读这五样,⛔ **只读这五个文件**,读完直接判断(⛔ 别到处找信息)\n" "> ⚠️ **不要在本工作区里到处翻文件**:2026-10-03 实测,检查会话因无边界地找资料\n" "> 被宿主判成**重复执行、要求人工确认** ⇒ 本轮白跑。**下面五份就是全部输入。**\n" "> 🔴🔴 **文档合同(本机制的核心约定,⛔ 违反就等于白干)**:\n" "> · **任务会话的产物一律落** `<目标目录>/<棒次>_<事项>_<日期>.md`\n" "> ⇒ 台账 `artifact` 指的就是它,**你照那个路径读就行**(⛔ 别自己另找)。\n" "> · **目标目录**=`%s/`(`goal.json` 的 `title`/`short` 算出来的,**不在里面找东西**)。\n" "> · ⛔ **不许**把产物写回 `交付物/`、`docs/`、工作区根 —— 那正是「到处找信息」的由来。\n" "1. `python \"%s/state.py\"` —— 现状快照(一条命令,⛔ 不用自己拼别的)\n" "2. 🔴 `%s` —— **队列台账(唯一权威,⛔ 四态:pending/running/done/blocked)**\n" " **本轮的重点是它**。每件长这样:`{\"id\", \"state\", \"by\", \"artifact\", \"block_reason\"}`。\n" " 🔴🔴 **本轮已由机制侧**(`artifacts_unverifiable()`)**预先挑出「不可核实的 done」**,\n" " 列在下面 —— **这几条就是你本轮的重点**(⛔ 别自己再扫一遍台账找):\n" "%s" " (⚙️ 若上面写「无」⇒ 本期所有 `done` 的产物都能读到,直接按 2. 往下走。)\n" " ⚠️ **⛔ 不要读 `tmp/supervise-inbox/queue.json`** —— 那是**已退役的旧投递机制**留下的文件,\n" " **已 12 小时无人写入**,内容停在过期的 `S12=blocked-by-owner`;\n" " 而 `tasks.json` 里 S12 **早已 done** ⇒ **读它会得出相反的结论**。\n" "3. `%s` —— 任务图(看 nodes 里还有没有 `status != done` 的)\n" "4. 🔴 `%s` —— **目标**。⛔ **只读这四个字段**,别全文件翻:\n" " · `lifecycle` = 目标生命周期(`进行中`/`已完成`/`等待`)\n" " · `acceptance_state` = **验收判据**(🔴 判断目标达成与否就看它)\n" " · `topics` = 任务类别(派棒时放进 `[执行]-<类别>-…`)\n" " · `title` = 目标标题\n" "5. `python \"%s\" --domain-status` —— **看域占用**(派棒前必跑,⛔ 跳过就等于没门禁)\n" "\n" "## 三、🔴 你的职责:**跟进执行结果 → 决定要不要再建任务会话**\n" "1. **先逐件看队列里那些件的 `state` 与 `artifact`**:\n" " - 🔴 `done`:**`artifact` 就是那件活的「完成证明」** —— 机制侧已强制\n" " (`--report --state done` **缺 `--artifact` 直接拒收**)⇒ 你**只要照它给的路径去读**。\n" " ⛔ **别只看 `state` 就信了**,也 ⛔ **别自己去别处找**(用户 2026-10-03 实测:\n" " 到处找信息会被宿主判成**重复执行、要求人工确认**)。\n" " ⚠️ 若某条 `done` **没有 `artifact`** ⇒ 那是**台账漏了**(旧数据)⇒ 本轮把它当\n" " **「无法核实」**处理:⛔ 别当成已完成,也 ⛔ 别自己去搜,**写明并停手**。\n" " - `blocked`:读它的 `block_reason`;能解 ⇒ 派一条任务会话去解;\n" " 🔴🔴 **阻碍是「域锁被占」时,先跑一次孤儿锁判定,再说「解不了」**" "(用户 2026-10-09 授权,逐字:「如果 工作区 项目机制 对应的会话异常 导致未解锁的情况下," "可以授权 检查会话自解除孤儿锁」):\n" " ① 判定(只读,⛔ 一把锁都不动):`python \"@@ORPHAN_PY@@\" --dry-run`\n" " —— 脚本去宿主库查**持有者会话的真身状态**,⛔ 不靠你目测 `OWNER` 文件猜死活。\n" " ② 判「持有者已退出」⇒ 这就是**孤儿锁**,**你已被授权自行解除**" "(原 R9「AI 一律不得删锁」**不适用**于此情形)⇒ 跑 `python \"@@ORPHAN_PY@@\"` 解除,\n" " 然后**回到本节第 2 条继续派活**,⛔ 别再停手、⛔ 别再把同一件事报一遍。\n" " ③ 判「持有者还活着/判不出」⇒ 照旧:**停手报告**,⛔ 不动它的锁(那是 R9 的地盘)。\n" " ⚠️ **只有这一种阻碍被授权自解**;其它 `blocked` 一律照下面这条走。\n" " ⛔ 解不了 ⇒ **写明阻碍并停手**,⛔ 别硬派一条注定干等的活;\n" " 🔴 「写明」的出口只有两个,缺一个=没报:\n" " · 台账:`--report --state blocked --reason \"<现状一句,含谁在等什么>\"`;\n" " · `tmp/supervise-inbox/NEED-USER.md`:**明确喊「需用户介入」**" "(⛔ 只写在自己域里的那份报告文档 = 用户看不到)。\n" " - 还 `pending`/`running` 但**没有会话在跑** ⇒ 它**没被认领** ⇒ 派一条接手。\n" "2. **派活**:能派 ⇒ 建一条 `[执行]-<类别>-<具体>` 排期接手。\n" "3. 🔴🔴 **你有一个「改目标状态」的出口,但只有一个** —— 用户口径:**遇阻碍 ⇒ 把目标状态改成" "「阻碍」⇒ 机制暂停 ⇒ 让用户决策**。\n" " ⚠️ 为什么必须由你来做这件事:闸① 只在「阻碍」时停建检查棒,而**能看见**这条阻碍的只有你" "(你是靠「队列非空」触发的);「目标检查会话」有权改状态、却要**队列为空**才触发 ⇒ " "队列里躺着 `blocked` 时它**永不触发**。⇒ 不给你这个出口,两条腿会互相锁死、只剩空转。\n" " · **触发条件(三条全中才动)**:① 那条 `blocked` 你**确认解不了**(含跑过孤儿锁判定仍判「不动」的);\n" " ② 它是在**等人**(不是等某个会话);③ 原因已写进台账(`--state blocked --reason`)与 `NEED-USER.md`。\n" " · **动作**:`python \"@@COLLABD_PY@@\" --set-life 阻碍 --by \"<你的会话名>\" --check-why \"<一句现状>\"`\n" " ⚠️ 「为什么」的旗标是 **`--check-why`**,⛔ **不是 `--why`**(那是 `--retire` 的;写错会被**静默忽略**)。\n" " ⚠️ 写完**必须回读** `goal.json` 的 `lifecycle` 确认(本项目栽过「命令不存在却 rc=0 静默放行」)。\n" " ⇒ 之后闸① 生效、**不再建检查棒**(机制暂停),等用户处置;⛔ 你**不负责**把它改回来。\n" " · 🔴 **恢复**:用户处置完 ⇒ **主会话**跑 `--set-life 进行中` ⇒ 机制自然复跑(这不归你管)。\n" " · ⛔ **除这一档,你没有别的改状态出口**:判断「目标其实已经做完了」⇒ **只写明「疑似已完成」并停手**,\n" " ⛔ 不许自己把目标改成「已完成」(那是「目标检查会话」的活 —— 你唯一能写的是「阻碍」那一档)。\n" + _CHECK_TAIL_A + _CHECK_TAIL_B ) # 【目标检查】—— 队列**空**且静默 >3 分钟时:核对目标完成状态(阈值=`CHECK_IDLE_MIN`,2026-10-07/08 三度调整:20 → 10 → 6 → 3) CHECK_PROMPT_GOAL = ( "**【目标检查会话 · 第 %d 棒】**本轮只做这一件事:**核对目标完成状态**,**做完即停**。\n" "\n" "目标:%s\n" "\n" "## 一、为什么是你(🔴 触发条件已满足,不必重判)\n" "**所有会话都已结束** + **队列为空** + **已静默 ≥3 分钟** + **本项目没有别的待执行排期**。\n" "\n" "## 二、🔴 先读这五样,⛔ **只读这五个文件**,读完直接判断(⛔ 别到处找信息)\n" "> ⚠️ **不要在本工作区里到处翻文件**:2026-10-03 实测,检查会话因无边界地找资料\n" "> 被宿主判成**重复执行、要求人工确认** ⇒ 本轮白跑。**下面五份就是全部输入。**\n" "1. `python \"%s/state.py\"` —— 现状快照(一条命令,⛔ 不用自己拼别的)\n" "2. 🔴 `%s` —— **队列台账(唯一权威,⛔ 四态:pending/running/done/blocked)**\n" " 本轮触发条件是**队列空**,仍要看有没有 `running` 却**没会话在跑**的(= 僵尸件)。\n" " ⚠️ **⛔ 不要读 `tmp/supervise-inbox/queue.json`** —— 那是**已退役的旧投递机制**留下的文件,\n" " **已 12 小时无人写入**,内容停在过期的 `S12=blocked-by-owner`;\n" " 而 `tasks.json` 里 S12 **早已 done** ⇒ **读它会得出相反的结论**。\n" "3. `%s` —— 任务图(看 nodes 里还有没有 `status != done` 的)\n" "4. `%s` —— 目标。⛔ **只读这四个字段**,别全文件翻:\n" " · `execution_doc` = 🔴🔴 **目标执行状态文档的路径**(下面第 5 项)\n" " · `lifecycle` = 目标生命周期(本轮**你有权改它**,见三.3)\n" " · `acceptance_state` = 验收判据的**机读副本**(⚠️ 可能与文档不同步 ⇒ **以文档为准**)\n" " · `topics` = 任务类别(派棒时放进 `[执行]-<类别>-…`)\n" "5. 🔴🔴 `%s` —— **目标执行状态文档(本轮判断目标状态的唯一依据)**\n" " ⛔ **别再去工作区里翻文件找目标状态**(用户 2026-10-03 实测:到处找信息\n" " 会被宿主判成**重复执行、要求人工确认**)。**读这一份就够了。**\n" " 照它「二、验收判据」逐条读**结论那一行**(`过`/`🔴 不过`)+ 末尾「三、结论」;\n" " ⚠️ 文档里有「四、旧判据的作废清单」⇒ **⛔ 不要拿里面那些判据判未过**(那会永远红)。\n" " ⚠️ 若**读不到这份文档** ⇒ 写明「判不出来」并停手,⛔ 不许去别处找(宁可停,不可臆断)。\n" "5. `python \"%s\" --domain-status` —— **看域占用**(派棒前必跑,⛔ 跳过就等于没门禁)\n" "\n" "## 三、🔴 你的职责:判「目标到底做完没」→ 二选一,**并执行**\n" "1. **判完成度**(⛔ 三路取并集,任一路有非 done/非过就是「没完」):\n" " ① `tasks.json` 台账(四态:pending/running/done/blocked);\n" " ② 任务图的 nodes(还有 `status != done` 的);\n" " ③ **`goal.json.acceptance_state` 的验收判据** —— 🔴 **这一路最关键**:\n" " 前两路只说明「活干完了吗」,⛔ **不等于**「目标达成了吗」。\n" " ⚠️ 判据值是**中文写法**:`过|…`/`🔴 不过|…`/`待重验(…)` —— ⛔ 别拿「不等于 `pass`」判未过。\n" " ⚠️ 一条**有效判据都没有**(只有 `_` 开头的说明行)⇒ **判不出来,⛔ 不许当成已完成**。\n" "2. **没做完** ⇒ 建一条 `[执行]-<类别>-<具体>` 排期接着做。\n" " 🔴 **派棒前先看域锁是不是「孤儿锁」**(用户 2026-10-09 授权,检查会话可自解除):\n" " `python \"@@ORPHAN_PY@@\" --dry-run` —— 判「持有者已退出」⇒ 跑" " `python \"@@ORPHAN_PY@@\"` 解除,\n" " 再派棒;判「持有者还活着」⇒ ⛔ 不动它的锁,改做不重叠的域或写 `NEED-USER.md` 喊「需用户介入」。\n" "3. **做完了** ⇒ 先 **确认并写回验收**,再改状态(🔴 2026-10-07 用户令:「**不是拦截,是检查任务要确认这种情况**」):\n" " · 把「二、5」那份文档里的验收结论**逐条读出来**(`过` / `🔴 不过`);\n" " · 与 `goal.json.acceptance_state` **对一遍** —— 不一致就**以文档为准、把它改齐**:\n" " 用与 `collabd.py` **同目录**的 `goalctl.py declare --kpi \"判据A=过|依据;判据B=过|依据\"`\n" " (⚠️ `--kpi` **按分号切**,不是等号也不是换行);写完**必须回读一次**确认。\n" " ⛔ 不许「以为写成功了」—— 本项目栽过「命令根本不存在、却只打用法 + rc=0 静默放行」\n" " ⇒ 检查会话以为改成功,实际一个字没改。\n" " ⚠️ 判据值一律用**规范写法**:`过|…` 或 `🔴 不过|…`;⛔ 别写 `未过`/`pass` 这类\n" " **不在取值域里**的值 —— 机器分不出「还没判」与「判了没过」⇒ 看板会一直显示未过。\n" " · 若**确认下来确实没过** ⇒ 走上面第 2 条(继续派活),⛔ **不许标已完成**。\n" " · 确认无误后 ⇒ 用 `%s --set-life 已完成 --by \"<你的会话名>\"` 把目标状态改成「已完成」,\n" " ⛔ 不留「进行中」空转。\n" " 🔴 **2026-10-03 修(实测坐实)**:早前这里写的是 `goalctl.py set-life已完成`,⛔ **该命令根本不存在** ——\n" " 实跑它只打印用法、**rc=0 静默放行** ⇒ 检查会话会**以为改成功了**,实际一个字没改。\n" " ✅ 真源=**本文件自己的 CLI**(`--set-life` 分支 ⇒ 调 `goal_life_set()`)。\n" " ⚠️ `--set-life` 与它的值之间**要有空格**(`--set-life 已完成`),⛔ 粘在一起会被当成未知参数。\n" "4. ⛔ **不确定就不改**:判据读不到/证据不足 ⇒ **只写明「判不出来」并停手**,\n" " ⛔ 既不派活也不改状态(宁可停,不可臆断)。\n" + _CHECK_TAIL_A + _CHECK_TAIL_B ) # ⛔ 旧名留一个显式别名 —— 外部若还有引用 `CHECK_PROMPT`,⛔ 静默变成"什么都给"更坏, # 所以这里让它**直接抛**,逼调用点改成传 reason。 def _retired_check_prompt(*_a, **_k): raise RuntimeError( "CHECK_PROMPT 已拆成 CHECK_PROMPT_RESULT / CHECK_PROMPT_GOAL 两套" "(用户 2026-10-03:这是两件事)⇒ 请改调 `_check_prompt(n, title, reason)`。") CHECK_PROMPT = _retired_check_prompt # 🔴🔴 2026-10-09 加:**孤儿锁判定的脚本绝对路径**(用户当日授权检查会话自解除,见 prompt 里那段)。 # ⚠️ 走「标记 + 事后 replace」,⛔ **不往 `%s` 实参元组里再加一位** —— # 那元组是**位置对齐**的,加错位就是 `not all arguments converted` ⇒ 被顶层记成 fatal # ⇒ **CLI 静默无输出**(本文件已为此炸过两次,见 `_check_prompt` 那段注释)。 _ORPHAN_PY = str((HERE / "lock" / "orphan-lock.py").resolve()) _ORPHAN_MARK = "@@ORPHAN_PY@@" # 🔴 2026-10-09:结果检查会话新增「置阻碍」出口 ⇒ prompt 里也要给出 `collabd.py` 本体路径。 # ⚠️ 同 `_ORPHAN_MARK`:走标记替换,⛔ 不动那个位置对齐的实参元组。 _COLLABD_MARK = "@@COLLABD_PY@@" def _check_prompt(n: int, goal_title: str, reason: str = "queue-empty") -> str: _g = str(WS) if WS else os.getcwd() _me = os.path.abspath(__file__) if "__file__" in globals() else "collabd.py" # ⚠️ 实参顺序**必须逐字对齐各自模板的占位符顺序**(错了报 # "not all arguments converted",而它被顶层 handler 记成 fatal ⇒ CLI 静默无输出) # 🔴🔴 2026-10-03 实测坐实:**两份模板占位符个数不一样**(本轮为此炸过一次)—— # `CHECK_PROMPT_RESULT` 头部 **6** 个(`%d` 1 + `%s` 5,⛔ **没有** `--set-life` 那一位) # + 尾节 5 个 = **11**; # `CHECK_PROMPT_GOAL` 头部 **7** 个(多一个 `--set-life` 的 `_me`)+ 尾节 5 个 = **12**。 # ⇒ ⛔ **不能共用一份实参**。判据=**渲染一次看有没有 `TypeError`**(当场炸);⛔ 别靠"数 `%s`"。 # 🔴🔴 2026-10-03 10:3x **尾节那两个 domain 命令的实参改了**: # 原注释写的「domain-status/domain-block/cwds 都用工作区路径」**是错的** —— # `--domain-status`/`--domain-block` 是 **`collabd.py` 的子命令**,⛔ 不是 `state.py` 的。 # 实测:写 `python "<工作区目录>" --domain-status` 报 # `can't find '__main__' module` ⇒ **派活门禁第一步就跑不通** ⇒ 只能到处找信息。 # ⇒ 现在传 **`_me`(collabd.py 本体路径)**;只有 `cwds` 仍用工作区路径 `_g`。 # 尾节实参语义:`(节号, collabd.py, collabd.py, _g)`。 # ⚠️ 2026-10-03 11:0x 尾节第 3 位=**目标目录**(派棒产物落点)。 # 语义:`(节号, collabd.py, collabd.py, 工作区, 目标目录)` _tail = ("四", _me, _me, _g, os.path.join(_g, goal_dir_rel())) # 🔴🔴 2026-10-03 10:3x **第 2 项改指 `tasks.json`(唯一权威)**,⛔ 不再指 `queue.json`。 # 真因(实测):`queue.json` 是**已退役的旧投递机制**留下的文件,**已 12 小时无人写入**, # 停在过期的 `head=S12 blocked-by-owner`;而 `tasks.json` 里 S12 **早已 done**。 # ⇒ prompt 却说"本轮的重点是它" ⇒ **让人读一个状态相反的过期队列**。 # ⚠️ `queue.json` ⛔ **暂不删**(`queue_pending()` 仍在读它,见那处待修), # 但 ⛔ **不许再让任何会话以它为判断依据**。 _TASKS = os.path.join(_g, "tmp/supervise-inbox/tasks.json") # 🔴🔴 2026-10-04 **结果检查 prompt 头部实参从 6 个变 8 个** —— # 新增两位:③ 目标目录(`os.path.join(_g, goal_dir_rel())`)/ # ④ **不可核实的 done 清单**(`_unverifiable_block()` 渲染)。 # ⚠️ 逐字对齐模板里的占位符顺序(错一位 ⇒ "not all arguments converted" ⇒ # 被顶层 handler 记成 fatal ⇒ **CLI 静默无输出**,踩过两次)。 if reason == "sessions-ended": return (CHECK_PROMPT_RESULT % ((n, goal_title, _g, os.path.join(_g, goal_dir_rel()), _unverifiable_block(), _TASKS, str(TG) if TG else os.path.join(_g, "交付物/任务图.json"), os.path.join(_g, "tmp/supervise-inbox/goal.json"), _me) + _tail)).replace(_ORPHAN_MARK, _ORPHAN_PY ).replace(_COLLABD_MARK, _me) return (CHECK_PROMPT_GOAL % ((n, goal_title, _g, _TASKS, str(TG) if TG else os.path.join(_g, "交付物/任务图.json"), os.path.join(_g, "tmp/supervise-inbox/goal.json"), os.path.join(_g, exec_doc_rel()), _me, _me) + _tail)).replace(_ORPHAN_MARK, _ORPHAN_PY ).replace(_COLLABD_MARK, _me) def goal_title() -> str: try: g = json.loads((INBOX / "goal.json").read_text(encoding="utf-8")) return str(g.get("title") or "(未命名目标)") except Exception: return "(读不到目标)" def queue_pending() -> int: """队列里未完成件数(**读 `tasks.json` 唯一权威**,⛔ 不读 `queue.json`)。 🔴🔴 2026-10-03 10:3x **换真源**(用户报障连带查出,实测坐实): 原来读的是 `tmp/supervise-inbox/queue.json` —— 那是**已退役的旧投递机制**留下的文件, **已 12 小时无人写入**(mtime 停在 10-02 21:24),内容停在过期的 `head=S12 blocked-by-owner`;而 `tasks.json` 里 S12 **早已 done**。 ⇒ ⛔ 它会把"早已做完的活"算成"未完成" ⇒ **闸③(队列状态要对得上 reason)判错** ⇒ 检查会话要么不该建时乱建、该建时反倒被拦住。 🔴 **四态口径**(与 `--report` 写入口、`board.py` 的分工板**同一套**): `pending`/`running`/`blocked` 计入未完成;**只有 `done` 不计**。 ⚠️ 读不到 ⇒ 返回 **0**(⛔ 不返回一个大数把闸永远锁死;`tasks.json` 不存在=还没有队列)。 """ try: t = _load_tasks() except Exception: return 0 n = 0 for _k, v in t.items(): if not isinstance(v, dict): continue st = str(v.get("state") or v.get("status") or "").strip().lower() if st and st != "done": n += 1 return n # ══════════════════════════════════════════════════════════════════════════════ # 🔴🔴 **`done` 的「产物能不能核实」= 唯一事实源 `artifact_state()`**(2026-10-04 立)。 # # **要治的病**(用户口径逐字): # > 「之前还说过 **执行会话的结果要形成文档,目标也要完成情况的文档**, # > 这样后续检查会话和后续执行会话都可根据文档继续处理,**避免全工作区到处找信息**」 # # ⇒ 现状是**半落地**:写入侧有闸(`--state done` 缺 `--artifact` **拒收**), # 但**读取侧不验** —— 台账里躺着空的/指向不存在文件的 `artifact` 时, # 检查会话只能照 prompt 那句嘱咐「当『无法核实』处理」⇒ **又回去到处找**(正是要避免的)。 # # 🔴 **两个缺口(实测坐实,本区台账 `{"t":{"state":"done"}}` 就是活证据)**: # ① **写入侧的闸管不了"已在库的"** —— 那条 `t` 比拒收闸(10-03 立)还早, # 没有 `artifact`/`by`/`t_end`,`task-events.jsonl` 里也没有它的记录 ⇒ **绕过闸写进去的**。 # ② **读取侧不标红** ⇒ 靠"嘱咐"而不是"机器抓手"(P0-52 同形状)。 # # ✅ **本函数=唯一判据**(⛔ 不许在 prompt/看板/CLI 里各写一段 `if not artifact`): # · 四态:`missing`(空)/`gone`(给了路径但文件不存在)/`ok`(真读到)/`-`(非 done,不适用) # · ⚠️ **路径两种形态都试**(相对工作区 / 已是绝对)—— 同 `_all_sessions_idle` 的教训。 # · ⛔ **读不到工作区**(`WS` 空)⇒ 判 `missing`(fail-safe 方向="不可核实", # ⛔ 不判 `ok` —— 那会把"没查到"粉饰成"没问题")。 # · ⛔ **不抛异常**:路径含非法字符、权限不足、超长 ⇒ 一律落 `gone` 并附原因。 # ══════════════════════════════════════════════════════════════════════════════ ARTIFACT_STATES = ("ok", "missing", "gone") def artifact_state(tid: str, rec: dict | None = None) -> dict: """🔴 **一条台账记录的产物可核实性**(唯一实现,见上文长注释)。 @return `{"state","artifact","abs","why"}`;`state ∈ ARTIFACT_STATES` · `ok` 给了路径且**文件真存在** · `missing` `done` 但**没写** `artifact`(旧数据/绕过闸写的) · `gone` 写了路径但**文件不存在**(路径打错、产物被移走/删了) ⚠️ 非 `done` 的记录 ⇒ 返回 `state="missing"` 但 `why` 说明"非 done 不适用" (⛔ 不新增第 4 个状态值 —— 状态越少越不容易判错;调用方自己看 `why`)。 """ _r = rec if isinstance(rec, dict) else {} if rec is None: _r = dict((_load_tasks().get(str(tid or "")) or {})) _st = str(_r.get("state") or _r.get("status") or "").strip().lower() _a = str(_r.get("artifact") or "").strip() out = {"state": "missing", "artifact": _a, "abs": "", "why": ""} if _st != "done": out["why"] = "非 done(state=%s)⇒ 不适用" % (_st or "空") return out if not _a: out["why"] = "done 但**没写** `artifact`(旧数据/绕过拒收闸写的)" return out _w = str(WS or "").strip() if not _w: out["why"] = "工作区未定 ⇒ 无法核实(fail-safe 判不可核实)" return out # ⚠️ 两种形态都试:相对工作区 / 已经是绝对路径(S5 的 artifact 就是技能内相对路径) cands = [] try: _p = Path(_a) if _p.is_absolute(): cands.append(_p) else: cands.append(Path(_w) / _a) cands.append(Path(_w) / _a.replace("\\", "/")) except Exception as _e: out["state"] = "gone" out["why"] = "路径没法解析:%s" % _e return out for _c in cands: try: if _c.exists(): out["state"] = "ok" out["abs"] = str(_c) return out except Exception: continue out["state"] = "gone" out["abs"] = str(cands[0]) if cands else "" out["why"] = "**文件不存在**(试过:%s)" % "/".join(str(c) for c in cands) if cands else "路径为空" return out def artifacts_unverifiable() -> list: """🔴 **台账里所有「不可核实」的 `done` 件** ⇒ 检查会话该重点看的就是这几条。 ⛔ 只列 `done` 且 `artifact_state() != "ok"` 的(`pending`/`running`/`blocked` 不适用)。 @return `[{"id","state","artifact","why"}]`,按 id 排序(⛔ 顺序稳定 ⇒ 判据可复现)。 """ out = [] try: t = _load_tasks() except Exception: return out if not isinstance(t, dict): return out for _k in sorted(t.keys()): _v = t.get(_k) if not isinstance(_v, dict): continue _s = artifact_state(_k, _v) if str(_v.get("state") or _v.get("status") or "").strip().lower() != "done": continue if _s["state"] != "ok": out.append({"id": _k, "state": _s["state"], "artifact": _s["artifact"], "why": _s["why"]}) return out def _last_progress_ts() -> float: """**最近一次有实质进展的时刻**(epoch 秒)—— 判「静默够久没」(阈值 `CHECK_IDLE_MIN`,现 3 分钟)的基准。 🔴 取「本工作区排期的 `updated_at` 最大值」;读不到 ⇒ 判**刚有过活动**(0) ⇒ ⛔ 不建(fail-safe:宁可少建,不误判成"已静默够久")。 🔴🔴 匹配必须**两种斜杠形态都试**(实测踩过):`cwds` 落库形态随写入方而异 (`automation_update` 写正斜杠、`WS` 变量可能是反斜杠)⇒ 只用一种必然匹配 0 行 ⇒ 静默时长恒为 0 ⇒ **那条闸永远不会开**(静默失效,不是误建)。 """ _w = str(WS or "") if not _w: return 0.0 # ⚠️⛔ 拼接**必须用 f-string/+**,⛔ 不许用 `"%s%" % _w`: # Windows 路径里的 `\` 会被 `%` 当转义(`\%`/`\P`)⇒ 实测直接抛 # `ValueError: incomplete format`(第一次跑就踩,且异常被 except 吞成 0.0 静默失效)。 cands = ("%" + _w + "%", "%" + _w.replace("\\", "/") + "%", "%" + _w.replace("/", "\\") + "%") for pat in cands: try: c = _ro_conn() a = c.execute( "select max(updated_at) from automations where cwds like ? " "and deleted_at is null", (pat,)).fetchone()[0] c.close() if a: return float(a) / 1000.0 except Exception: continue return 0.0 def ws_pending_schedules(exclude: str = "") -> list: """🔴🔴 **本工作区还有哪些「待执行」的排期**(用户 2026-10-03 第①②条明令加的判据)。 用户原话(逐字,两条触发源**都**要带): > 「所有会话都结束(**还要加上 定时任务中没有本项目待执行的任务**)且队列不为空时:…」 ⇒ **为什么必须加**:⛔ 只判"所有会话都结束"会漏一种情形 —— **排期已经排好、会话还没到点开**(`next_run_at` 在未来)。 这时"会话全结束"成立,但**马上会自己开一条任务会话** ⇒ 再建检查会话= **同一件事两遍**(检查会话会重复派一条棒,那条排期又会开一条会话 ⇒ 链上多出一节)。 🔴 **"待执行"的判据(三条同时成立才算)**: ① `status='ACTIVE'` 且 `deleted_at is null`; ② `cwds` 命中本工作区(⛔ 两种斜杠形态都试 —— 同 `_all_sessions_idle` 的教训: 只试一种 ⇒ 匹配 0 行 ⇒ **恒判"没排期"⇒ 闸失效**); ③ **还没跑过**(`last_run_at is null or last_run_at=0`)—— 跑过的一次性排期**不再算**。 ⚠️ `next_run_at` **故意不看**:⛔ 一条 `next_run_at` 已过期但仍 ACTIVE 的一次性排期 (实测本工作区有 3 条,其中 2 条 `next_run_at=None`)**仍然会被人点开**(`automation_update` 手动起) ⇒ 用 `next_run_at` 当"待执行"会漏。判据取「**在册 + 没跑过**」这个更宽的口径。 ⛔ 读不到库 ⇒ 返回 `["<读不到>"]`(**非空**)⇒ 闸按"有排期"处理 ⇒ fail-safe 方向=不建检查会话。 """ out = [] try: _w = str(WS or "").strip() if not _w: return ["<工作区未定>"] c = _ro_conn() # 🔴🔴 2026-10-03 11:5x **加 `schedule_type`/`next_run_at` 两列**: # 目的是排除「**已过期的一次性**排期」—— 否则它会**永远**卡住闸④(见下)。 rows = c.execute( "select name, cwds, schedule_type, next_run_at from automations " "where status='ACTIVE' and deleted_at is null " "and (last_run_at is null or last_run_at=0)").fetchall() c.close() pats = {_w, _w.replace("\\", "/"), _w.replace("/", "\\")} # 🔴 过期宽限:宿主扫描周期 ≤30s、实测到点延迟 20~35s ⇒ **15 分钟**是安全余量。 # ⛔ 宽限期内**仍算待执行**(那正是"马上要开"的情形,闸④ 该拦)。 _STALE_MS = 15 * 60 * 1000 _now_ms = int(time.time() * 1000) for _nm, _cd, _st, _na in rows: _s = str(_cd or "").strip() if not _s: continue if any(p and p.lower() in _s.lower() for p in pats): if _nm == exclude: continue # 🔴 一次性排期**已不会再触发** ⇒ 排除,否则它**永远**卡住闸④。 # 两种"不会再触发"(2026-10-03 12:0x 实测,两种都踩过): # (a) `next_run_at` 已过宽限; # (b) `next_run_at` 被**宿主清成 NULL** —— 实测排期到点触发后宿主会清它, # 而 `status` 仍 `ACTIVE`、`last_run_at` 仍 `None` # ⇒ 只判 (a) 会漏掉这一类 ⇒ 它照样永��卡闸。 # ⚠️ 只动 `once`:`recurring` 永远会再触发 ⇒ ⛔ 不按过期排除。 # ⚠️ 只动**本工作区**(`_same_ws` 已在上面命中)⇒ ⛔ 不影响别的工作区。 # 📌 风险登记:若存在"建了但 `next_run_at` 就是 NULL"的 `once` 排期,会被误排除; # 但 `create_check_schedule()` 的**硬规则第一条**就是 `next_run_at` 必须 # 是有限数值 ⇒ 机制自己建的排期不会命中该风险。 if str(_st or "").lower() == "once": _gone = (not _na) # (b) 已被消费 ⇒ 到点时间被清空 if not _gone: try: _gone = int(_na) + _STALE_MS < _now_ms # (a) 已过宽限 except Exception: _gone = False if _gone: log("闸④:跳过不会再触发的一次性排期(⛔ 否则它会永远卡住闸)|%s" % _nm) continue out.append(str(_nm)) except Exception as e: log("ws_pending_schedules 读库失败(⇒ 按有排期处理,不建) %s" % e) return ["<读不到排期表>"] return out def maybe_spawn_check_agent(reason: str) -> dict |None: """🔴🔴 **唯一的检查会话建排期入口**(用户第③④条)。 🔴🔴 **两类检查会话是两件事,prompt 必然不同**(用户 2026-10-03 08:0x 明令): | reason | 排期名 | 触发条件 | 它的职责 | |---|---|---|---| | `sessions-ended` | `[结果检查]-…` | 会话全结束 + **队列非空**(⛔ 不要求静默 20 分钟) | **跟进任务会话执行结果** ⇒ 判断是否**创建对应任务会话**继续完成目标,并创建 | | `queue-empty` | `[目标检查]-…` | 会话全结束 + **队列为空** + 静默 **≥20 分钟** | **核对目标完成状态** ⇒ 判断是**建任务会话**接着做、还是**改目标状态**(已完成),并执行 | ⇒ ⛔ **它们不是同一件事的两种触发**,⛔ 不许共用一个 prompt:前者面对"有活没人干", 后者面对"没活了但目标可能没完";后者多一条**改生命周期**的出口。 🔴 `reason` 非法值 ⇒ **返回 None 并记日志**(⛔ 静默不动,⛔ 不猜它该走哪套)。 **六道闸**,缺一即返回 `None`(⛔ 静默不动,绝不硬闯): ① 目标生命周期必须是「**进行中**」—— 用户第③条;默认「等待」⇒ **用户没点头就不动** 🔴🔴 **2026-10-04 收窄:只对 `queue-empty` 生效**(用户口径逐字: 「**检查程序必然要去处理队列的情况**(是等待工作区所有会话停止后,把队列情况一并处理)」)。 病根(vibe-product 实测):`sessions-ended`(**队列非空 = 有活没人干**)也被这道闸拦死 —— 日志刷屏「检查会话:目标状态=已完成(非进行中)⇒ 不建」,**队列里的活永远没人接**。 ⇒ **队列非空与目标状态无关**:活没干完就是没干完,⛔ 不能因为目标被标了「已完成」就不管。 (`queue-empty` 那条仍须「进行中」—— 它的职责是**判目标该不该收口**,只在进行中才有意义。) 🔴🔴 **2026-10-09 补回「阻碍」档**(用户口径:「**遇到阻碍修改目标状态 暂停机制 让用户决策**」)。 病根(`contentm_agent` 实测坐实):上述收窄把 `sessions-ended` **整条腿**从闸① 摘了出去 ⇒ 队列只剩一条 `blocked`(域锁被孤儿占)时,闸③「队列非空」**恒真** ⇒ 每 30 分钟(`CHECK_COOLDOWN_S`) 空转一棒、**连开 11 棒 5 小时零派活**;同期 `lifecycle` 一直是「进行中」—— **"阻碍 ⇒ 暂停机制" 这条口径在代码里根本没接上**(只在注释里)。⇒ 现在:`sessions-ended` **只拦「阻碍」一档**, 「已完成」**仍放行**(⛔ 别把整道闸搬回来,会把 2026-10-04 修的病重新引入)。 ⚠️ **配对必需**:只加这道闸而没有「谁去置阻碍」= 白加 ⇒ 出口见 `CHECK_PROMPT_RESULT` 第 3 条 (**结果检查会话**是唯一能看见 `blocked` 的那条腿;目标检查要"队列为空"才触发、永不触发)。 ② **所有会话都结束**了(含主会话 · 用户第②条) ③ **队列状态要对得上 reason**:`queue-empty` 必须队列空 + 静默 ≥20 分钟; `sessions-ended` 必须队列**非空**(⛔ 队列也空 ⇒ 那是 `queue-empty` 那条的事,⛔ 不重复建) ④ 🔴🔴 **本工作区没有别的「待执行」排期**(用户 2026-10-03 新增判据,见 `ws_pending_schedules()`) ⑤ **同名排期未在册**(去抖) ⑥ **同类检查冷却期**(`CHECK_COOLDOWN_S`,⛔ 防堆积) """ if reason not in CHECK_KINDS: log("检查会话:⛔ 未知 reason=%r(只认 %s)⇒ 不建" % (reason, "/".join(CHECK_KINDS))) return None life = goal_life() # 🔴🔴 闸① 两档口径(2026-10-04 收窄 + **2026-10-09 补回「阻碍」档**,理由见 docstring): # · `queue-empty`:⛔ 非「进行中」一律不建(原样,那次收窄不动它) # · `sessions-ended`:**只拦「阻碍」一档** —— 用户口径「遇阻碍 ⇒ 改目标状态 ⇒ 机制暂停 ⇒ 让用户决策」; # ⚠️ 「已完成」**仍放行**(2026-10-04 修的病是"被标了已完成、活却没人接")⇒ ⛔ 别把整道闸搬回来。 if reason == "queue-empty": if life != GOAL_LIFE_RUN: log("检查会话:目标状态=%s(非进行中)⇒ 不建" % life) return None elif life == GOAL_LIFE_BLOCK: log("检查会话:目标状态=阻碍 ⇒ 不建(用户口径:阻碍 ⇒ 机制暂停、让用户决策;" "处置完由主会话改回「进行中」即自动复跑)") return None if not _all_sessions_idle(): return None pend = queue_pending() if reason == "queue-empty": if pend > 0: return None lp = _last_progress_ts() if not lp: return None# ⛔ 读不到进展时刻 ⇒ 不建 idle_min = (time.time() - lp) / 60.0 if idle_min < CHECK_IDLE_MIN: log("目标检查:队列空但只静默了 %.1f 分钟(<%d)⇒ 不建" % (idle_min, CHECK_IDLE_MIN)) return None else: # 🔴 `sessions-ended`:队列**必须非空**(用户第①条「且队列不为空时」) if pend <= 0: return None idle_min = 0.0 n = _check_stamp().get("round", 0) + 1 # 🔴 取工作区末段做排期名:**必须先归一斜杠**(`WS` 实测是反斜杠, # `str(WS).split("/")[-1]` 会返回**整条路径** ⇒ 排期名变成一长串路径)。 _leaf = str(WS or "ws").replace("\\", "/").rstrip("/").split("/")[-1] or "ws" # 🔴🔴 2026-10-03 10:1x **排期名必须带两级方括号前缀**(用户报障逐字: # 「是不是协作程序创建了 结果检查会话,**没有按照 会话前缀命名规范创建**」)。 # ⛔ 旧名 `结果检查-ai1net-dsh-server-第1棒` 是**光杆**(无方括号)⇒ 后果不是"难看": # `parse_session_name()` 走「无方括号」分支(`collabd.py:3207`)⇒ 既不匹配 `[协作]` # 也不匹配 `主控 ·`/接续形态 ⇒ **`role=""`** ⇒ 看板**认不出它是任务会话** # ⇒ 画不进任务会话那一排(实测建出的 `44b547d4` 就是这个下场)。 # ✅ 规范形态(`parse_session_name` 的 `prefix` 表):`[角色]-[类别]-<具体>`。 # 🔴 **两段都要方括号**(`collabd.py:3256` 的 `_m1 = ^[-\s]*\[([^\]]*)\]`)—— # 写成 `[协作]-结果检查-…`(第二段无括号)⇒ `topic=""`、`ok=False` ⇒ 类别丢失。 # 现网两种正确形态可对照:`[协作]-[机制排查与修复]-S13 …`(协作)、 # `主控 · 协作机制 · …`(主会话,走另一条 `main-prefix` 分支)。 # 检查会话是**任务会话**(它自己会派执行棒)⇒ 角色=`执行`、类别=检查种类。 # ⚠️ 类别用 `CHECK_KINDS[reason][1]`(`结果检查`/`目标检查`)⇒ 两种在标题上就分得开, # ⛔ 不合并成一个 `[协作]-[检查]-…`(那样看板/台账分不出是哪一种检查)。 # 🔴🔴 2026-10-03 11:1x **改用独立角色前缀 `[检查]`**(用户纠正逐字: # 「检查会话**不是协作会话**,不应该出现在看板协作会话区域中。 # 检查会话属于**协作程序的会话**(本来也是协作程序创建),可以放在协作程序框图中展示」)。 # ⚠️ 我 10:1x 写成 `[协作]-[结果检查]-…` = 判成 `worker` ⇒ **被画进任务会话那一排** # ⇒ 正是用户指出的错误。⇒ 角色词必须是 `检查`,⛔ 不是 `协作`。 # ⚠️ 第二段仍带方括号(`parse_session_name` 的两级前缀约定,⛔ 漏了 `topic` 会空)。 name = "[检查]-[%s]-%s-第%d棒" % (CHECK_KINDS[reason][1], _leaf, n) # ── 闸④:本工作区还有别的待执行排期 ⇒ 不建(⛔ 排在同名去抖之前: # 「已经有活要干」比「同名重复」更根本,且省掉一次查库往返的顺序问题) _pend_sched = ws_pending_schedules(exclude=name) if _pend_sched: log("%s:⚠️ 本工作区还有 %d 条待执行排期 ⇒ 不建(已有活要干,别重复派活):%s" % (CHECK_KINDS[reason][0], len(_pend_sched), _pend_sched[:3])) return None if active_schedule_exists(name): log("%s:同名排期已在册 ⇒ 跳过(%s)" % (CHECK_KINDS[reason][0], name)) return None # ── 闸⑥:同类检查冷却期(2026-10-05 加,依据见 `CHECK_COOLDOWN_S` 处注释) # ⛔ 必须放在闸⑤ 之后:闸⑤ 是"同名重复"(更精确),闸⑥ 是"同类太密"(兜底防堆积)。 _stamp = _check_stamp() _last = float(_stamp.get("last_" + reason) or 0) if _last and (time.time() - _last) < CHECK_COOLDOWN_S: log("%s:⚠️ 距上次同类检查仅 %.1f 分钟(< 冷却 %d 分钟)⇒ 不建(⛔ 防堆积)" % (CHECK_KINDS[reason][0], (time.time() - _last) / 60.0, CHECK_COOLDOWN_S // 60)) return None p = _check_prompt(n, goal_title(), reason) r = create_check_schedule(name, p, delay_s=90, cwd=str(WS) if WS else "") # 🔴 **合并写**,⛔ 不整体覆盖 —— stamp 里还存着另一类检查的 `last_*`, # 覆盖 ⇒ 那一类的冷却记忆被抹掉 ⇒ 闸⑥ 对另一种情况失效(改一处漏另一处的同族坑)。 _stamp.update({"round": n, "name": name, "reason": reason, "kind": CHECK_KINDS[reason][1], "at": time.strftime("%Y-%m-%dT%H:%M:%S"), "fire_at": r["fire_at"], "queue_pending": pend, "idle_min": round(idle_min, 1), "id": r["id"]}) _stamp["last_" + reason] = time.time() _check_stamp_save(_stamp) return r # 已停时**必须清掉**的告警投影(⛔ 留着就会被钩子继续注入 ⇒ "已停还在喊")。 # 依据:这些都是**投影**(可删可重建,见技能 §10),⛔ 不是人工输入(人工输入只有 `blocked.json`)。 PAUSED_SIGNALS = ("NEXT.md", "STALL.md", "NEED-USER.md", "queue.md") def paused_round(st: dict, why: str = "") -> dict: """**静默一轮**:只留一行说明,⛔ 不排队、⛔ 不产告警、⛔ 不投递。 两种入参,都属「**没有可推进的活**」这一大类: · `why` 为空 ⇒ **人为已停**(`goal.json.run != active`)—— ⛔ 不是故障。 · `why` 非空 ⇒ **目标已完成**(三路全过)—— 🔴 2026-10-01 加。 病根(实测 M5 解卡后立刻复现):原实现只认 `run`,目标三路全过而 `run` 仍 `active` ⇒ 照产告警 ⇒ 终态下每轮喊「卡住:有会话在跑但 N 分钟无成果」、`VACUUM`/`READY` 也复活 ⇒ **把「做完了」读成「坏了」**,还会经钩子注入每个会话。 """ _ts = time.strftime("%Y-%m-%d %H:%M:%S") if why: _line = ("✅ **终态**(%s @ %s)⇒ 目标三路全过、没有可推进的活,本轮不排队、不产告警、不投递。" "要开新活 ⇒ 重新声明目标(`goalctl.py declare …`)。" % (why, _ts)) else: _line = ("⏸ **已停**(`goal.json.run != active` @ %s)⇒ 本轮不排队、不产告警、不投递。" "恢复用 `goalctl.py start --yes`。⚠️ 这是人为停掉,⛔ 不是故障。" % _ts) try: (INBOX / "digest.md").write_text( "# 协作机制摘要\n\n- %s\n" % _line, encoding="utf-8", newline="\n") (INBOX / "advance.md").write_text( "# 机械推进快照(与 digest.md 同源)\n\n- %s\n" % _line, encoding="utf-8", newline="\n") # 🔴 2026-10-01 加:**看板也要跟着收口** —— 否则已停/终态之后看板永久停在最后一张 # "还在跑"的快照(实测:目标三路全过后看板仍显示「队首 M5 · 确定性静默」)⇒ 读的人被误导。 LIVE.parent.mkdir(parents=True, exist_ok=True) LIVE.write_text("# 协作实时状态\n\n%s\n" % _line, encoding="utf-8", newline="\n") except Exception: pass for _n in PAUSED_SIGNALS: try: (INBOX / _n).unlink() except Exception: pass try: (INBOX / "collabd-once.stamp").write_text(_ts, encoding="utf-8") except Exception: pass st["paused"] = True try: STATE.write_text(json.dumps(st, ensure_ascii=False), encoding="utf-8") except Exception: pass return st def is_continuation(name: str) -> bool: """🔴 标题是不是一条**接续会话**(=由**会话自己**建出来的"下一棒")。 **为什么必须认得出它**(2026-10-01 实测坐实的自指死结): 接续会话也是**自动化起的新会话**,标题由**建它的那条会话**写 ⇒ 常写成 `[唤醒机制] 接续 · 日志事前叫停钩子落地(第 2 棒)` / `接续棒:日志增长治理(任务 0 → 任务 A)` —— **没有角色方括号**。而旧判据只排 `[协作]` ⇒ 这类棒会被 `_scan_mains()` 收进 **主会话候选**,又因为它标题里有 `[<类别>]` ⇒ 直接被解析成"**该类别的主会话**" ⇒ 投递把通知**投给这条棒自己**(自己叫自己、白判一次)。 ⛔ 判据只用标题里的字面标记,⛔ **不回落 cwd、⛔ 不猜**(守架构 §2.3)。 """ return "接续" in str(name or "") # 🔴🔴 2026-10-03 10:2x **检查会话的两种标题形态**(判据集中在此,⛔ 别在别处再写一遍)。 # 为什么集中:`collabd.py::parse_session_name()` 与 `board.py::_role_of_title()` **必须同款**, # 而这两侧**历史上已经漂过三次**(唤醒/跟进/接续各一次)⇒ 这次让它们**import 同一个实现**。 _CHECK_TOPICS = ("结果检查", "目标检查") def is_check_agent(name: str) -> bool: """🔴 标题是不是一条**检查会话**(**协作程序**建的那种)。 🔴 **为什么它必须是 worker、且必须认得出来**(2026-10-03 10:2x 实测): 检查会话由**常驻程序**建(`maybe_spawn_check_agent()`),它会**自己派执行棒** ⇒ 它是干活的那条。 但 10:1x 之前建的都用了**旧式光杆名**(`结果检查-…`,无方括号)⇒ 解析层两个分支都不命中 ⇒ `role=""` ⇒ `_scan_mains()` 的排除元组只排 `"worker"` ⇒ **`""` 通过** ⇒ 它被**收进主会话候选** ⇒ 看板 `role` 显示**「主会话」**(实测 `44b547d4` 就是)。 ⚠️ 危害=**投递/派活可能投错窗口**(收件人变成这条检查会话自己,自指死结同族)。 ⚠️ **两种形态都认**:新名 `[协作]-[结果检查]-…`(合规,走正常解析路径,**本函数不必参与**) + 旧名 `结果检查-…`。⛔ 新名才是唯一规范;认旧名只为**读存量**,不让在跑的检查会话凭空消失。 """ s = str(name or "") return any(t in s for t in _CHECK_TOPICS) def _check_topic(name: str) -> str: """🔴 从标题里取检查会话的**类别**(`结果检查`/`目标检查`;取不到返回空串)。""" s = str(name or "") for t in _CHECK_TOPICS: if t in s: return t return "" # 🔴🔴 2026-10-04 21:5x **「已退役角色」表 + 主会话候选闸**(用户拍板「候选一」)。 # 一句话判据:**标题里出现过任何一个"曾经是角色、现已退役"的方括号前缀 ⇒ 一律⛔ 不当主会话候选**。 # 🔴 **为什么必须单独建这道闸**(真因,不是口味): # `[跟进]`/`[唤醒]` 两键在 2026-10-02 从映射表里摘掉后 ⇒ `parse_session_name()` **判 `""`** # ⇒ 而主会话候选的排除元组是 `_role not in ("worker",)` ⇒ **`""` 恰好通过** ⇒ # 这两类**已退役的干活的棒**被收进**主会话候选** ⇒ 看板 `_role_label()` 判据①命中 # ⇒ 显示成「主会话」。**实测现网**:`6ab1463e`「[跟进]-机制排查与修复-队列跟进」、 # `57f58ecf`「[跟进]-会话协作自检-队列上报」(60 小时前建)正坐在「主会话」位上。 # ⛔ 危害不止"标签难看":主会话候选=**投递/派活的收件人** ⇒ 会**投错窗口**(自指死结同族)。 # ⚠️ **⛔ 不能靠"恢复映射"来修** —— 那会让它们重新变成**活角色**,与 2026-10-02 的收敛口径相反, # 且 `[跟进]/[唤醒]` 是被整套退役的类别,⛔ 不许复活。 # ⚠️ **⛔ 也不能写成"排除空串"** —— `""` 还包含"老式光杆名/口语命名"等**真可能是主会话**的标题 # (现役主会话 `a80f300d`「复盘失败并避免重犯」就是无前缀的),一刀切会把**真主会话也排掉**。 # ⇒ 正解=**只排"明确是退役角色前缀"的那些**,粒度只到"这几个词",⛔ 不扩大到"所有认不出的"。 # 🔴 两侧同款:本表是**唯一事实源**,`board.py::_role_of_title()` 直接从这里 **import** # (⛔ 不许在 board.py 再抄一份字面量 —— 这两侧历史上已漂过三次)。 _RETIRED_PFX = ("跟进", "唤醒") def is_retired_role_title(name: str) -> bool: """🔴 标题是不是一条**已退役角色**的会话(`[跟进]-…`/`[唤醒]-…`)。 ⚠️ 判据**只看一级方括号里的整词**(⛔ 不看正文里出现过这两个字)—— 否则「[协作]-[唤醒机制]-…」这种"类别名里恰好含『唤醒』"的**合规在役会话**会被误杀 (它一级前缀是 `协作` ⇒ 是 worker,本就该被排,但**排它的理由不该是"退役角色"**)。 ⛔ 解析不出/无方括号 ⇒ False(⛔ 不猜)。 """ s = str(name or "").strip() m = re.match(r"^\[([^\]]*)\]\s*", s) if not m: return False return m.group(1).strip() in _RETIRED_PFX def is_main_side_session(name: str) -> bool: """🔴 是不是「**主会话那一侧**」的会话(主会话/协作会话)—— ⛔ **检查会话不算**。 🔴 **为什么要有这个判据**(用户 2026-10-03 11:1x 纠正实测): 检查会话由**协作程序**创建、属于**协作程序** ⇒ ⛔ **不该出现在「协作会话」那一排**。 而它解析出的角色是 `check` ⇒ 只靠"排 `worker`"已经能排除它; ⚠️ 但**旧名**会话(`结果检查-…`,无方括号)也判 `check` ⇒ 两边一致。 ⇒ 本函数给看板用:**协作会话那一排只收 `主`/`协作`**,⛔ 不收 `检查`。 """ s = str(name or "") if "检查" in s: return False r = re.match(r"^\[([^\]]*)\]", s.strip()) return bool(r) and r.group(1).strip() in ("主", "协作") def parse_session_name(name: str) -> dict: """解析会话命名 —— **四种形态**(返回 `{"role","topic","ok","form"}`): | form | 形态 | role | ok | |---|---|---|---| | `prefix` | `[角色]-[类别]-<具体>`(**合规**) | main/worker/waker/follow | ✅ True | | `prefix` | 只有一级 `[协作]N9-…`(缺 `[类别]`) | worker | ❌ False | | `continuation` | 🆕 **接续会话**:`[<类别>] 接续 · …` / `接续棒:…` | **worker** | ❌ False | | `main-prefix` | 🆕 **主控前缀**:`主控 · <类别> · …`(中点分隔、无方括号) | **main** | ❌ False | 🔴 `ok=False` 恒表示"**没按两级前缀约定命名**"(⛔ 语义不变,老调用方照旧); 🆕 新加 `role` 的**兜底判定**:形态不合规但角色明确时**照样给出 role** —— 否则接续会话/口语命名的主控会被判"角色未知"⇒ 前者冒充主会话、后者解析不出(两条都实测踩过)。 ⛔ 解析不出就返回空(⛔ 不猜);⛔ 绝不回落到 `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 {"role": "main", "topic": "", "ok": False, "form": "main-prefix"} if is_continuation(nm): # 🆕 ③ 无方括号的接续会话(`接续棒:…` / `接续 · 线 · 具体`)⇒ 执行棒 return {"role": "worker", "topic": "", "ok": False, "form": "continuation"} # 🔴🔴 2026-10-03 10:2x **旧式光杆名的检查会话也判 worker**(用户报障连带查出的真缺陷)。 # ⚠️ **位置必须在「无方括号」这个早退分支里**(我第一版误加在下面的方括号分支 # ⇒ 旧名根本走不到 ⇒ 对账立刻抓到 2 条漂移 ⇒ 判据自己会指路)。 # 现象:`44b547d4`「结果检查-ai1net-dsh-server-第1棒」在看板 `role` 显示**「主会话」**。 # 根因链:旧式光杆名 ⇒ 解析层两个分支都不命中 ⇒ `role=""` # ⇒ `_scan_mains()` 排除元组只排 `"worker"` ⇒ **`""` 通过** # ⇒ 被**收进主会话候选** ⇒ `_role_label()` 判据①命中 ⇒ 显示「主会话」。 # ⛔ 危害不是"标签难看":主会话候选=**投递/派活的收件人** ⇒ 可能**投错窗口**(自指死结同族)。 # ⚠️ 新名 `[协作]-[结果检查]-…` **不走这里**(带方括号 ⇒ 走正常路径,`role` 已非空)。 # ✅ 判据实现集中在 `is_check_agent()`,⛔ 不在这里再写一遍字面量。 if is_check_agent(nm): # 🔴 2026-10-03 11:1x `worker` ⇒ **`check`**(用户纠正:它属于协作程序,⛔ 不是协作会话) return {"role": "check", "topic": _check_topic(nm), "ok": False, "form": "check-agent"} return {"role": "", "topic": "", "ok": False, "form": ""} # 🔴 方括号解析改成"**只剥第一组**"(旧写法 `parts[0].strip("[]")` 会把 `[协作]N9-2156` # 剥成 `协作]N9`、角色判空 ⇒ 靠 `role_of` 里的兜底才没出事;现直接在这里判准)。 _m0 = re.match(r"^\[([^\]]*)\]\s*(.*)$", nm) r = _m0.group(1).strip() if _m0 else "" _rest = _m0.group(2) if _m0 else "" # 🔴 2026-10-01 用户加第三、四类:**唤醒会话**(判「都空闲」时要把它排除,见 `_anyone_busy`) # 与 **队列上报的跟进会话**(用户原话:「**第4类应该是 队列上报的跟进会话**」)。 # ⚠️ 加「跟进」的**直接动机**:用户定了「主会话开工时要按分工类别建齐 **唤醒 + 协作 + 跟进**」, # 而 `[跟进]-…` 在旧表里 `role` 解析为**空** ⇒ 会被 `_scan_mains()` 收进**主会话候选** # (标题里若又带 `[<类别>]` 还会被解析成"**该类别的主会话**")⇒ **通知投给它自己** # = 与接续会话同款的**自指死结**(见 `is_continuation()` 那段)。 # 🔴 新增角色**必须四处同款**(改一处不改全部的教训已吃过两次): # `collabd.py::parse_session_name()` ⇄ `board.py::_role_of_title()` # + 两边的**主会话候选排除元组** + `selftest.py` 的逐样本对账。 # 🔴🔴 2026-10-02 23:4x **会话类别从四类收敛为两类**(用户逐字:「按照新的逻辑整体修改」, # 前一条:「按照之前的讨论 唤醒会话 跟进会话 和 上报程序 都去掉才对」, # 再前一条:「上报机制也不需要了」;口径早在本文件 2293 行就写着 # 「创建检查会话的为 协作程序(现在不需要上报机制了、之前已经去掉 唤醒会话和跟进会话机制)」)。 # ⇒ 角色表**只留** `主` / `协作`。 # ⛔ `唤醒` / `跟进` **两键刻意不再映射**:库里那些标题是**历史记录**(`sessions` 表旧行), # ⛔ 不删行;但它们**不再是活类别** ⇒ 新建的这类会话会解析出 `role=""` # ⇒ 落进「角色未知」,⛔ 不会被 `_scan_mains()` 误收成主会话候选。 # 🔴🔴 2026-10-03 11:1x **新增 `检查` 角色**(用户纠正:检查会话是**协作程序自己的会话**, # ⛔ **不是**协作会话 ⇒ 不该混进 worker 那一类)。 # ⚠️ 它是**独立第三类**,⛔ 不并入 `协作`——并进去看板就会把它画进协作会话那一排。 # 🔴🔴 2026-10-03 19:5x **命名同步改名(用户定案「一起改 名称也改」)**: # 新建协作会话的前缀 = **`[协作目标]-`**;⛔ **旧前缀 `[协作]-` 继续认** —— # 理由:改完立刻回溯性地让**现存历史会话全部认不出来**(落进"未识别"桶、看板画不出、 # 分工板对不上)= 不可逆风险。⇒ **两个前缀同映射到 `worker`**,新名生效、旧名不破。 # 🔴🔴 2026-10-03 23:4x **「协作」→「执行」再改名**(用户定案:「**把协作会话 改为 执行会话, # 协作 改为 执行**」;⇒ 新建执行会话的前缀 = **`[执行]-`**): # ⛔ **旧前缀 `[协作]`/`[协作目标]` 两个都继续认** —— # 理由(同 19:5x):宿主库实测有 **53 条排期 + 45 条会话**标题带 `[协作]` # ⇒ 删掉映射 ⇒ 它们全部解析成 `""` ⇒ **看板画不出、派活漏管**,且**不可逆**。 # ⇒ **三个前缀同映射到 `worker`**:新名生效、旧名不破。 # ⚠️ 必须与 `board.py::_role_of_title()` **逐条同款**(`selftest.py` 逐样本对账,⛔ 漂了就红)。 # 🔴🔴 2026-10-05 **用户定案:「协作 全部 改为执行」(连说两遍)** ⇒ **新建前缀一律 `[执行]`** # (发源地 = 本文件 `_zh` 字典,`worker` → `"执行"`;文首提示词模板 9 处已同步)。 # ⛔⛔ **下面这三条兼容映射 ⛔ 一个字都不许动、更不许删** —— 见下一行注释的存量清单: # `执行`(三代新建)/`协作`(二代沿用至 10-05)/`协作目标`(二代 10-03 短暂形态), # **三者同映射到 `worker`**;删任一条 ⇒ 存量会话/排期解析成 `role=""` # ⇒ 看板画不出、派活漏管,**不可逆**。 role = {"主": "main", "执行": "worker", # 🆕 当前口径(前缀) "协作": "worker", # ⛔ 旧前缀,保留兼容 "协作目标": "worker", # ⛔ 旧前缀,保留兼容 "任务会话": "worker", # ⛔ 旧前缀,保留兼容 "检查": "check"}.get(r, "") if not role: # 🆕 ③ `[<类别>] 接续 · …` —— 第一对方括号里装的是**任务类别**(⛔ 不是角色): # 实测库里就有 `[唤醒机制] 接续 · 日志事前叫停钩子落地(第 2 棒)`。 if is_continuation(nm): return {"role": "worker", "topic": r, "ok": False, "form": "continuation"} return {"role": "", "topic": "", "ok": False, "form": ""} _m1 = re.match(r"^[-\s]*\[([^\]]*)\]", _rest) topic = _m1.group(1).strip() if _m1 else "" return {"role": role, "topic": topic, "ok": bool(role and topic), "form": "prefix"} def topic_of(st: dict, sid: str = "") -> str: """会话的**二级前缀="在协作什么"**:① 命名里的 `[主题]` ② 声明(`--declare --topic`)③ 空。""" if sid: try: con = _db() row = con.execute("select coalesce(nullif(custom_title,''), title, '') from sessions where id=?", (sid,)).fetchone() con.close() tp = parse_session_name(str((row or [""])[0] or "")).get("topic") or "" if tp: return tp except Exception as e: log("topic_of 读标题失败 %s" % e) return str((st.get("topics") or {}).get(sid) or "") def role_of(st: dict, sid: str = "") -> str: """会话角色 —— 🔴 **以「会话命名前缀」为准**(2026-09-29 用户定案): **同工作区、跨工作区都适用**,因为前缀与 `cwd` 无关。 优先级:**① 命名前缀(`[主]`/`[协作]`)② 显式声明(`--declare`)③ 未声明** · 命名前缀:**派活时给自动化命名加前缀** ⇒ 宿主把它带成会话标题(实测:会话标题=自动化名) · 显式声明:会话自己跑 `collabd.py --declare --role main|worker`(sid 自动从环境取)—— 留给"主会话自己改名不方便/标题没前缀"的场合 · ⛔ 都取不到 ⇒ 返回空串(=**未声明**)——调用方必须按"未声明"处理, ⛔ **绝不回落到 cwd 推断**(那正是历史故障的来源) """ if sid: try: con = _db() row = con.execute("select coalesce(nullif(custom_title,''), title, '') from sessions where id=?", (sid,)).fetchone() con.close() t = str((row or [""])[0] or "") pr = parse_session_name(t) if pr.get("role"): # 🔴 2026-10-01 改:判据从 `pr["ok"]`(**两级前缀都齐**)放宽到 `pr["role"]`(**角色明确**)—— # 形态不合规但角色明确的有三支:① 一级前缀 `[协作]N9-…` ② **接续会话** ③ **`主控 · …` 前缀**。 # 旧判据把它们一律判"角色未知" ⇒ 接续会话冒充主会话、口语命名的主控解析不出(都实测踩过)。 if pr.get("form") == "continuation": log("⚠️ 会话 %s 是**接续会话**但标题没按 `[协作]-[<类别>]-<具体>` 命名" " ⇒ 已按 worker 处理(建议改名,否则看板归不了类别)" % str(sid)[:8]) elif not pr["ok"]: log("⚠️ 会话 %s 命名缺 `[<类别>]`(只有一级前缀)⇒ 建议改成 [角色]-[类别]-<具体>" % str(sid)[:8]) return str(pr["role"]) except Exception as e: log("role_of 读标题失败 %s" % e) roles = dict(st.get("roles") or {}) if sid and roles.get(sid): return str(roles[sid]) if not sid: return "" return "" def _main_sid(st: dict) -> str: """主会话的 session id:① 首选**声明为 main** 的那个 ② 退回最近一次投递到的会话 ③ 都取不到 ⇒ 空串(⛔ 不猜)。 🔴🔴 2026-10-01 修:回落**只认"上次投的是主会话"那一次** —— `wake.target == "follow"` 时 `wake.sessionId` 记的是**跟进会话**(投递目标已改,见 `_deliver_str`)⇒ 直接**当没有回落** (返回空串),⛔ 不许把跟进会话读成主会话(那是判据被污染,比"没有回落"糟得多)。 """ for _sid, _r in (st.get("roles") or {}).items(): if str(_r) == "main" and str(_sid).startswith(("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f")): 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 是否就是**本工作区**。⚠️ 归一化必须**小写 + 统一斜杠** —— 宿主的分组去重键是 `path.trim().toLowerCase()`(**只小写、不统一斜杠**)⇒ 这里若只做小写,`E:\\x` 与 `E:/x` 会被判成两个工作区。""" a = str(cwd or "").strip().lower().replace("\\", "/").rstrip("/") b = str(WS).strip().lower().replace("\\", "/").rstrip("/") return bool(a) and a == b def _deaf_sids() -> set: """🔴 会话诊断日志已撞 ~10 MiB 上限 ⇒ 宿主拒写(EPERM)⇒ 该会话**界面不再刷新**=哑。 2026-10-01 实测:主会话 f8a792ab 停在 10,485,715 B,累计 45,422 行被丢 (daemon.log `[conversations] diagnostic log write failed` code=EPERM), 而投递仍记 `ok:true · http:200` = **假绿** —— 通知全落空,机制毫无察觉。 🔴 判据(**2026-10-01 修订:只认最新一天**):对**每个会话**,取它**最新一天**的那份日志 (今天有这份就用今天、没有才退到昨天)⇒ ≥ 9.5 MiB 才判哑。 🔴 为什么必须收窄(修「**永久判哑**」):宿主会话日志**按天分目录**(`logs/<日期>/sdk/conversations/`), 昨天那份**永不再增长** ⇒ 旧写法「今天+昨天**任一**命中即判哑」会把 **昨天撞过上限、今天已正常轮转**的会话**永久判哑**(实证 `a202550c`:昨 10.0 MB、 今 4.2 MB 且宿主**仍在写它**)⇒ 本该投出去的唤醒被**静默拦下**(方向相反的新假绿)。 ⚠️ 仍**保留昨天的兜底**:某会话今天**没有**日志(宿主今天没写过它)⇒ 退到昨天那份判 —— 比"直接放行"保守(宁可少投,不可朝已哑界面白投)。 (轮转正常时 .log 会归零重写;到 9.5 说明又快哑了,同样排除 —— 宁可保守。) ⛔ 只读;扫不到 ⇒ 空集(⛔ 不猜)。 """ root = os.path.join(os.environ.get("CODEBUDDY_CONFIG_DIR") or os.path.expanduser("~/.workbuddy"), "logs") size = {} # sid -> 它**最新一天**那份日志的字节数 for d in (time.strftime("%Y-%m-%d", time.localtime(time.time() - 86400)), # ⚠️ 先旧 time.strftime("%Y-%m-%d")): # 后新 ⇒ 新的覆盖旧的 cdir = os.path.join(root, d, "sdk", "conversations") try: for fn in os.listdir(cdir): if not fn.endswith(".log"): continue try: size[fn[:-4]] = os.path.getsize(os.path.join(cdir, fn)) except OSError: continue except Exception: continue return {sid for sid, n in size.items() if n >= 9_500_000} def _live_sids() -> set: """当前**活会话** id 集合(各网关口的 `/sessions/live` 取并集)。取不到 ⇒ 空集(⛔ 不猜)。""" out = set() try: for x in discover_gateways(): sid = str(x.get("sessionId") or "") if sid: out.add(sid) except Exception: pass return out def _topic_in_title(title: str, topics=None) -> str: """标题里出现的**任务类别**(🔴 **最长优先** —— 防止"唤醒机制"被"机制"这种短名抢走)。 ⚠️ 判据是**子串**(不要求 `[...]` 包裹)—— 因为用户给主会话的命名是口语式的 (「主控 · 唤醒机制」没有方括号),而执行棒是严格的 `[执行]-[类别]-<具体>`。 两种写法都要能认出来 ⇒ 统一用"标题里出现该类别名"。 ⛔ 都不命中 ⇒ 空串(="这条会话没标类别" ⇒ 调用方按**默认类别**处理,⛔ 不猜)。 """ 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 _pick_live(cands, live) -> str: """🔴 从候选里挑**活着的那条**(2026-10-01 体检修加)。 为什么要它(实测断点):解析出的主会话 `a202550c`(标题带「主控」)**根本不在活会话里**, 而唯一活着的会话被它压在后面 ⇒ 投递一路报 `main-not-live`(**程序在跑,通知永远发不出去**)。 ⛔ `live` 为空(探测失败)⇒ 回退第一条(**不改旧行为**:探测不到时不许假装知道谁活着)。 🔴 候选**都不活** ⇒ 返回**空**(让调用方落到下一级:主控 ⇒ 工作区最近活动), ⛔ 不许硬认第一条 —— 那正是"解析出来却投不进去(main-not-live)"的来源。 """ c = [str(x) for x in (cands or []) if str(x)] if not c: return "" if not live: return c[0] _deaf = _deaf_sids() # 2026-10-01:哑会话(日志撞上限)⛔ 不当主会话 for x in c: if x in live and x not in _deaf: return x return "" def _scan_mains(st: dict, live=None) -> dict: """🔴 扫一遍宿主库,挑出**本工作区里所有"可能是主会话"的会话**(按最近活动倒序)。 🔴 2026-10-01 加参数 `live`(各网关口的活会话集合):**活着的「主控」排在不活的「主控」前面** (⛔ 传 None ⇒ 行为与旧版逐字一致,`selftest` 的离线用例不受影响)。 ⛔ **排除全部"协作侧"命名**(🔴 2026-10-01 扩):`[执行]-…`(执行棒)、`[唤醒]-…`(唤醒会话)、 以及**接续会话**(`[<类别>] 接续 · …` / `接续棒:…`)—— 三者都是**干活的棒**,⛔ 不是主会话候选。 ⚠️ 旧版只排 `[协作]` ⇒ **接续会话会被收成主会话候选**(实测:`[唤醒机制] 接续 · …` 又被 `_topic_in_title()` 认出类别 ⇒ 直接变成"该类别的主会话")⇒ 通知投给它自己、真主会话被架空。 ⚠️ **不能加 `status='working'`**:主会话在两轮之间是空闲(不是 working)⇒ 加了就**永远漏掉它**。 ⚠️ **也不能按 `is_background_automation` 排**(2026-09-30 实测踩到):**接续会话本身就是自动化起的** —— 连当时那位主会话 `fe146dd9` 都是 `is_background_automation=1` ⇒ 那么排会把**真主会话也排掉**。 @returns `{"cand": [sid…], "named": [sid…], "by_topic": {类别: {sid, source, explicit}}, "topics": [类别…], "err": ""}` · `cand` / `named` 与旧版 `resolve_main()` 的两个列表**逐字同义**(`resolve_main` 就靠它) · `by_topic` = 新增:**按任务类别各挑一条**(用户 2026-09-30「同一个工作区 多会话协作」) """ out = {"cand": [], "named": [], "by_topic": {}, "topics": _goal_topics(), "err": ""} try: con = _db() for sid, cwd, title in con.execute( "select id, cwd, coalesce(custom_title, title, '') from sessions " "where deleted_at is null " "order by last_activity_at desc limit 50"): t = str(title or "") # 🔴 2026-10-01 改:从"只排 `[协作]`"改成"**排掉全部"协作侧"命名**"—— # **接续会话**(`[<类别>] 接续 · …` / `接续棒:…`)与**唤醒会话**(`[唤醒]-…`) # 都是**干活的棒**,⛔ 不是主会话候选。旧判据漏掉它们 ⇒ 接续棒被解析成"该类别 # 的主会话" ⇒ 投递把通知**投给它自己**(**自指死结**,2026-10-01 实测坐实)。 # ⚠️ 判据**只看标题**:⛔ 不看 cwd 归属、⛔ 不看 status(下方两条 ⚠️ 各有一条实测理由)。 # 🔴 2026-10-01 用户加第④类「**队列上报的跟进会话**」⇒ 排除元组同款扩到 `follow` # (`[跟进]-…` 是**干活的棒**,⛔ 不是主会话候选;不排除 ⇒ 它冒充主会话 ⇒ 自指死结)。 _role = str(parse_session_name(t).get("role") or "") # 🔴 2026-10-02 两类改造:排除元组收到 `("worker",)` —— # `waker`/`follow` 已退役,⛔ 保留它们会让「角色未知」的历史标题被当成可当主会话。 # 🔴🔴 2026-10-04 21:5x **补退役角色闸**(用户拍板「候选一」)—— # 上面那条把 `waker`/`follow` 从映射表摘掉后,它们的标题解析出 **`""`** # ⇒ `_role not in ("worker",)` **恰好放行 `""`** ⇒ 「[跟进]-…」这类**已退役的 # 干活的棒**被收进**主会话候选** ⇒ 看板「主会话」位上坐着它(实测 `6ab1463e`/`57f58ecf`)。 # ⇒ 加 `is_retired_role_title()` 一道闸(判据=**一级方括号是退役角色词**, # ⛔ 不是"空串"——空串里还有真主会话,一刀切会把它排掉)。 if _same_ws(str(cwd or "")) and _role not in ("worker",) \ and not is_retired_role_title(t): out["cand"].append(str(sid)) # 🔴 「主控」=主会话的**显式前缀**(用户 2026-09-30 定名:「主会话前缀 可以叫 主控」) _explicit = t.strip().startswith("主控") _alive = True if not live else (str(sid) in live) if _explicit: # 活着的「主控」插到队首(⛔ 不活的排后面 —— 那是"解析出来却投不进去"的元凶) (out["named"].insert(0, str(sid)) if _alive else out["named"].append(str(sid))) _tp = _topic_in_title(t, out["topics"]) if _tp: _cur = out["by_topic"].get(_tp) # 同一类别有多条候选 ⇒ ① 显式「主控」优先 ② 否则取**最近活动**(首见即最近) # 🔴 2026-10-01 加第 ③ 条:**现任不活而新候选活 ⇒ 换**(否则同上那个断点) _better = (_cur is None or (_explicit and not _cur.get("explicit")) or (_cur.get("explicit") and not _cur.get("alive") and _alive)) if _better: out["by_topic"][_tp] = { "sid": str(sid), "source": ("prefix:主控" if _explicit else "topic"), "explicit": _explicit, "alive": _alive} con.close() except Exception as e: out["err"] = str(e) log("resolve_main 按工作区解析失败 %s" % e) return out def resolve_mains(st: dict, live_sids=None) -> dict: """🔴 **按任务类别解析主会话**(= `resolve_main()` 的多类别版)。 2026-09-30 用户要求:「要能支持**同一个工作区** 多会话协作(主会话根据任务**自动梳理任务类别**: **通过协作会话名称前缀的方式区分具体执行会话**)」 ⇒ 同一工作区里会**并存多个任务类别**,**每个类别有自己的主会话** ⇒ 投递必须**按件所属的类别**选主会话,⛔ 否则会**投错窗口**(投到另一条类别的会话里)。 🔑 **规则(简单、可判,不猜)**: ① 每个类别各挑一条:**显式 `主控` 优先 → 否则最近活动**(见 `_scan_mains`) ② 某类别**没扫到** 且 它就是**默认类别**(`topics[0]`)⇒ 回落 `default` (⛔ 这样单类别时的行为与旧版**完全一致**,向后兼容) ③ 某类别**没扫到** 且 不是默认类别 ⇒ **sid 留空** ⇒ 调用方**明确报"找不到该类别的会话"** (⛔ **绝不回落到别类别的主会话** —— 那正是"投错窗口") `default` 的取法与 `resolve_main()` **同源同判据**: 登记有效 → 显式「主控」→ 工作区最近活动 → 空 @returns `{"topics": [...], "mains": {类别: {sid, source, switched_from}}, "default": {sid, source, switched_from}, "all_sids": [...], "err": ""}` """ reg = _main_sid(st) live = _live_sids() if live_sids is None else set(str(s) for s in live_sids if s) sc = _scan_mains(st, live) tps = list(sc["topics"]) named, cand = sc["named"], sc["cand"] # `default`:与 resolve_main 逐条相同(登记有效 ⇒ 认;否则 主控 ⇒ 最近活动), # 🔴 且两条**都只认活着的**(2026-10-01:`_pick_live`)。 _nm = _pick_live(named, live) _cd = _pick_live(cand, live) if reg and (not live or reg in live): dft = {"sid": reg, "source": "roles:main", "switched_from": ""} elif _nm: dft = {"sid": _nm, "source": "prefix:主控", "switched_from": reg if (reg and reg != _nm) else ""} elif _cd: dft = {"sid": _cd, "source": "workspace", "switched_from": reg if (reg and reg != _cd) else ""} else: dft = {"sid": "", "source": ("stale-roles" if reg else "no-register"), "switched_from": ""} mains: dict = {} for _tp in tps: _got = sc["by_topic"].get(_tp) or {} _sid = str(_got.get("sid") or "") if not _sid and tps and _tp == tps[0]: # ② 默认类别兜底(⛔ 只为**向后兼容**单类别部署:那时 topics 只有一项,这里=dft) _sid = str(dft.get("sid") or "") _src2 = str(dft.get("source") or "") or "default" # ⚠️ 只有当"默认那条**确实属于本类别**(或本类别就是唯一类别)"时才认,否则会串类别 if _sid and len(tps) > 1 and _topic_in_title(_title_of(_sid), tps) not in ("", _tp): _sid, _src2 = "", "" else: _src2 = str(_got.get("source") or "") # 🔴 2026-10-01:**该类别选出的那条不活** ⇒ 不能拿它投递(`main-not-live`)。 # · 单类别(≤1)⇒ 用 `dft`(已按活挑过);⛔ 多类别 ⇒ **置空**(宁可报"找不到",⛔ 不串类别) if _sid and live and _sid not in live: _sid, _src2 = (str(dft.get("sid") or ""), "live-fallback") if len(tps) <= 1 else ("", "") if _sid: _src2 = "live-fallback" mains[_tp] = {"sid": _sid, "source": _src2 or ("none" if _tp else ""), "switched_from": (reg if (reg and _sid and _sid != reg) else "")} _all = [] for _m in (list(mains.values()) + [dft]): _s = str(_m.get("sid") or "") if _s and _s not in _all: _all.append(_s) return {"topics": tps, "mains": mains, "default": dft, "all_sids": _all, "err": sc["err"]} def main_for_topic(rms: dict, topic: str = "") -> dict: """从 `resolve_mains()` 的结果里取**某个任务类别的主会话**。 🔑 两分支(⛔ 不猜): · `topic` **是已登记的任务类别** ⇒ **严格**取该类别的解析结果(可能 sid 为空 ⇒ 调用方**降级报告**) · `topic` **不是已登记类别**(空 / 旧数据里的工作区名等)⇒ 回落 `default` (⛔ 这是**向后兼容** `resolve_main()` 的旧调用点,⛔ 不是"盲投" —— default 仍是解析出来的) """ rms = rms or {} tp = str(topic or "").strip() if tp and tp in (rms.get("topics") or []): m = (rms.get("mains") or {}).get(tp) or {} return {"sid": str(m.get("sid") or ""), "source": str(m.get("source") or ""), "switched_from": str(m.get("switched_from") or ""), "topic": tp} d = dict(rms.get("default") or {}) d["topic"] = "" return d def _scan_follows(topics=None) -> dict: """🔴 扫一遍宿主库,挑出**本工作区里第④类「队列上报的跟进会话」**(标题 `[跟进]-…`)。 🔴🔴 为什么要它(2026-10-01 用户口径 · 逐字): 「**换新会话 唤醒的是 跟进会话,主会话只能是用户触发**」 ⇒ 机制侧的**推**(唤醒通知 / 队列上报)**只许往跟进会话投**: · 主会话是**用户与系统交互的那一面** ⇒ 自动化往它推消息=**用机制的噪音挤掉用户的话** (用户自己的定调:主会话**只由用户触发**); · 跟进会话是**专门收上报的那条**(第④类存在的全部理由,见 §2.3.0c)。 ⛔ **不降级投主会话** —— 这一点与 §2.3.0c 早先写的「解析不出就降级投主会话」**相反**: 按用户最新口径改。降级改成**喊用户**(`need_user`),⛔ 不静默、⛔ 不盲投任意会话。 @returns `{"by_topic": {类别: sid}, "by_topic_all": {类别: [sid…]}, "sids": [...], "cand": [{sid,title}], "topics": [...], "err": ""}` · `by_topic` = **每个类别各挑一条**(同类多条 ⇒ 取**最近活动**那条:SQL 已按它倒序,首见即最近) · `by_topic_all` = **每个类别的候选全表**(🔴 2026-10-02 S5 加,按最近活动倒序)—— `follow_for_topic()` 的 `cand` 就是取它(候选收窄必须发生在"活会话过滤"**之后**), `_deliver_str` 粗判也靠它判"该类别的跟进会话**是不是全都哑了**"(哑与否**不需要 live**)。 ⚠️ 用 `by_topic`(单条)会把这两件事都退化成"那一条",见 `_deliver_str` 里那段实测。 · `sids` = 全部跟进的 sid(不分类别)—— 调用方判"到底有没有活的"用它 ⛔ 只按 **cwd ∈ 本工作区 + 标题角色** 判(与 `_scan_mains()` 同款两条闸),⛔ 不按 status 排 (在两轮之间它是空闲的 ⇒ 按 status 排会**永远漏掉它**,与主会话那一处同一个坑)。 """ tps = _as_list(topics) if topics is not None else _goal_topics() out = {"by_topic": {}, "by_topic_all": {}, "sids": [], "cand": [], "topics": tps, "err": ""} try: con = _db() for sid, cwd, title in con.execute( "select id, cwd, coalesce(custom_title, title, '') from sessions " "where deleted_at is null " "order by last_activity_at desc limit 50"): t = str(title or "") if not _same_ws(str(cwd or "")): continue if str(parse_session_name(t).get("role") or "") != "follow": continue _s = str(sid) out["sids"].append(_s) out["cand"].append({"sid": _s, "title": t}) _tp = _topic_in_title(t, tps) if _tp: # 🔴 2026-10-02(S5)加:**该类别候选全表**(⛔ 与 `by_topic` 的单条并存、互不改动) out["by_topic_all"].setdefault(_tp, []).append(_s) if _tp not in out["by_topic"]: # 首见=最近活动 out["by_topic"][_tp] = _s con.close() except Exception as e: out["err"] = str(e) log("扫跟进会话失败 %s" % e) return out def follow_for_topic(st: dict, topic: str = "", live_sids=None) -> dict: """🔴🔴 **2026-10-02 23:4x 已退役:跟进会话整套取消**(用户逐字:「按照新的逻辑整体修改」/「按照之前的讨论 唤醒会话 跟进会话 和 上报程序 都去掉才对」/「上报机制也不需要了」;口径早在本文件 2293 行写着「之前已经去掉 唤醒会话和跟进会话机制」)。 🔴 **处置=短路退役,不是删函数**(⛔ 下方约 300 行本体**刻意原样保留**): · **为什么不删** —— ① `selftest.py` 有 4 处**真调用**本函数做单测(`L432`/`L510`/ `L705` 等)⇒ 删函数= `NameError` 崩自测;② 口径若再变(用户再说要加回来), 改回一行即可,⛔ 不必重写 300 行。 · **🔴 2026-10-03 更新**:原先写的「`--tick`/`--once`/`--gap` 三条钩子链仍在调它」 **已不成立** —— 投递段真删后,生产侧**零调用点**;现在只有 `selftest` 与 `_deliver_str()` 本体(同样零调用点)会碰到它。 · **为什么这样安全** —— `sid=""` 会被调用方落成 `follow-retired` ⇒ **诚实说"没人收"**, ⛔ 不会误投给主会话(那正是"投错窗口"的红线)。 @returns `{sid, source, topic, why, cand, all}` · 恒为 `sid=""` + `why="follow-retired"` ⇒ **没有任何收件人**。 · ⚠️ `no-follow-session` 那个档位**已并入本档**(调用方两处都认,⛔ 不改它们更省事)。 """ return {"sid": "", "source": "retired-20261002", "topic": str(topic or ""), "why": "follow-retired", "cand": [], "all": []} # ═══════════════════════════ 以下=已退役的实现本体,⛔ 刻意保留勿删 ═══════════════════════════ sc = _scan_follows() tps = list(sc.get("topics") or []) tp = str(topic or "").strip() _all = [str(s) for s in (sc.get("sids") or []) if s] cand, src = [], "" # 🔴🔴 2026-10-02(S5 · 验收 V2)修:**候选收窄必须发生在"活会话过滤"之后,⛔ 不是之前。** # 旧写法先把候选收窄成**单条**(`by_topic` 首见=最近活动那条),再交给 `_pick_live()` —— # 于是"这个类别里最近活动的那条已经死了"直接等价于"这个类别投不出去":`_pick_live([死的那条], # 活列表)` ⇒ 判"都不活" ⇒ `sid=""` ⇒ 投递落 `follow-not-live`,**同类别里明明还有活着的候选 # 也一辈子轮不到**(实测:本工作区 6 条 `[跟进]-…` 全 completed ⇒ 真因之一)。 # ⇒ 改成把**该类别候选全表**(`by_topic_all`,按 `last_activity_at` 倒序)**整体**交给 # `_pick_live()` —— 它在 `live` 非空时挑"第一条活着且不哑的",在 `live` **为空**时 # 回退 `cands[0]` = 最近活动那条 ⇒ **与旧写法逐字等价**(向后兼容不破)。 # ⚠️ 只在**同类别内**放开(⛔ 绝不跨类别):`topic` 已登记 ⇒ 取该类别全表;未登记 / 旧数据 # ⇒ 仍走"唯一那条 / 默认类别那条",⛔ 不许拿 `_all`(全工作区)去挑 ⇒ 那是"投错窗口"。 if tp and tp in tps: _lst = [str(s) for s in ((sc.get("by_topic_all") or {}).get(tp) or []) if s] if _lst: cand, src = _lst, "topic" else: if len(_all) == 1: cand, src = _all, "only-one" elif _all: _lst = [str(s) for s in ((sc.get("by_topic_all") or {}).get(tps[0]) or []) if s] if tps else [] if _lst: cand, src = _lst, "default-topic" live = _live_sids() if live_sids is None else set(str(s) for s in live_sids if s) _got = _pick_live(cand, live) return {"sid": _got, "source": (src if _got else ""), "topic": tp, "why": ("" if _got else ("no-follow-session" if not cand else "follow-not-live")), "cand": cand, "all": _all} # ── ⑫ 会话缺口 ⇒ 「**没有相关会话就自动拉起**」(🔴 2026-10-02 用户口径)──────────────── # 用户原话(逐字):「应该是 用户说 协作会话 完成目标 和 继续执行 的时候 # 如果没有 相关会话就自动拉起」 # ⇒ 本条改的是**行为**:缺会话 ⇒ **机制自己补建排期把它拉起来**, # ⛔ 不再走旧行为(写 `NEED-USER.md` 让**用户**去开会话 —— 那是把机制该干的活推给人)。 # 🔴 为什么"带回来"只有**一条**路(⛔ 别再造第二条): # · 「把一条**能收指令**的会话带回来」=**只有自动化排期**做得到: # `jobs/resume` 给的是 `kind:"background"` worker,**进不了 live** ⇒ 接不到 # `sessions/{id}/reply`(实测见 `workbuddy-extension-surface §1.4.4` 2026-10-02 复核)。 # · 🔴 **建排期不需要会话代劳**(2026-10-02 用户纠正 + 四版探针实测坐实): # 常驻 Python **自己就能建排期** —— 直写`workbuddy.db` 的 `automations` 表即可, # 宿主 `AutomationMainService.scheduler.tick()` 会在 ≈30s 内自己拾取并建会话。 # ⛔ 旧注释写的「写 `automations` 表对脚本是双红线」**是错的**: # 它把本机制早年的约定当成了产品限制。判据见下(源码级,已验收)。 # · 🔴 建排期唯一的技术硬闸(`decideDueAutomation` 第一行): # `if (!Number.isFinite(automation.nextRunAt)) return {action:"skip"}` # ⇒ `next_run_at` **必须是有限数值**(SQL NULL / 字符串一律被跳过); # 且 `scheduled_at` **必须是 ISO 字符串**(写毫秒整数会静默不拾取)。 # 四版探针实测:只有「next_run_at 有限值 + scheduled_at ISO」这一组合命中 # (`automation_runs` 出现 IN_PROGRESS + 真实 conversationId)。 # · ⇒ 本函数判缺后**可自行建排期**;是否建、走哪条,仍由授权闸把关。 # · 会话怎么被"叫醒去建":**钩子把缺口注入当前会话上下文**(零 token 的既有通道, # 见 `hooks/wb-result-hook.py` 的 `maybe_inject_session_gap()`)—— 用户什么都不用做。 # 🔴🔴🔴 2026-10-05 **收敛为三类**(用户逐字:「现在只有 主会话 任务会话 和 检查会话」)。 # ⇒ 现役三类:**主会话**(`main`)/**任务会话**(`worker`,看板显示名仍是「执行会话」)/ # **检查会话**(`check`,属常驻程序、⛔ 不算干活的那排)。 # ⇒ `follow`(跟进)与 `waker`(唤醒)**整套退役** ⇒ ⛔ 不再进缺口检测、⛔ 不再生成排期。 # ⚠️⚠️ **只动「检测/拉起」这一侧**:`_PFX` 的 `_RETIRED_PFX` 与前端兜底标签**照旧保留** # (宿主库里存量 `[跟进]/[唤醒]` 标题还有 11 条,删映射 ⇒ 它们解析成 `""` # ⇒ 归入「未识别」、看板更难读,且**不可逆**)。 # 📌 与 2026-10-02 那次收敛同源(当时就退役了唤醒/跟进/上报),此处只是**把缺口检测也跟上** # —— 否则它会一直催建 `[跟进]-…`/`[唤醒]-…` 排期,与用户口径**正好相反**。 # ⚠️ `worker` 的**看板显示名**是「执行会话」(`board.py::_ROLE_LABEL`), # 用户口中的「任务会话」与它**同指**(⛔ 不是第四类,⛔ 不要为它新增角色 id)。 GAP_ROLES = (("worker", "任务会话"),) GAP_HARD = ("worker",) # 🔴🔴 **跟进会话是「全局唯一席位」· ⛔ 不按类别各建一条**(2026-10-02 用户订正,逐字): # 「跟进会话只创建一个,跟进的内容来自 协作会话执行完成 后 把 待核对状态 写入 协作队列, # 上报给那个 固定的 跟进会话处理」 # ⇒ ① 判缺时 `follow` **不按 `topics` 分桶**(全类别算一个席位,桶键恒为 `(follow, "")`); # ② 拉起的排期名**不带类别**、prompt **不绑类别**; # ③ `worker`(协作)/`waker`(唤醒)仍**按类别**分 —— 用户只订正了 `follow` 这一条。 # ⚠️ 任务会话收尾的配套动作(顺带写进两处规则):干完活 ⇒ 把**待核对状态**写进协作队列 # ⇒ 由那一个固定跟进会话去消费、核对手工产物并上报。⛔ 不是"再开一条跟进会话"。 GAP_GLOBAL_ROLES = () GAP_SINGLE_NOTE = "(固定席位·不分类别)" # 🔴🔴 2026-10-05 **`waker` 整段退役**(三类收敛)⇒ 不再有"软缺"这个概念: # 旧的 ⛔「唤醒会话不算硬缺」那段("每次开检都报红 ⇒ 红多必聋")**随角色一起下线** —— # 唤醒会话已不在 `GAP_ROLES` 里,⛔ 既不算硬缺、也不报软缺、更不会生成 `[唤醒]-…` 排期。 # ⚠️ `GAP_GLOBAL_ROLES` **保留但清空**(⛔ 不删常量):`follow` 下线后已无"全局唯一席位"角色; # 留空元组 ⇒ 所有代码路径(`_gap_plan`/`_scan`/`_gap_task`)逐字不改也仍然正确。 # ⚠️ 模板**统一用命名占位**(`%(tp)s`)—— ⛔ 不用位置 `%s`:各角色的占位个数不同 # (跟进那条要两处),位置式一改就 `TypeError`,而它会被顶层 handler 记成 `fatal`、 # **CLI 静默无输出**(本棒实测踩到一次:`--gap` 打不出任何东西)。 GAP_PROMPT = { # 🔴🔴 2026-10-05 **删掉 `follow`/`waker` 两条模板**(三类收敛,见 `GAP_ROLES` 上方那段)。 # ⚠️ `GAP_ROLES` 已不含这两个键 ⇒ 它们**不会被取到**;此处一并删掉, # ⛔ 不留"还能用"的误导(旧版曾被 `_gap_plan` 取用 ⇒ 会照着建退役角色的排期)。 "worker": ("你是本工作区的**任务会话**(任务类别:%(tp)s)。\n" "## 开工第 0 步(三件,顺序固定)\n" "1. 跑 `state.py`、读 `tmp/supervise-inbox/NEXT.md`,按队列队首做**一件可核对产物**的事。\n" "2. 🔴 **先认领你的独立域目录**(= **工作区下的第一层目录**,⛔ 不许套 `domains/` " "公共父目录——实测那样所有域算出同一个键 ⇒ 并行失效)," "并按门禁块给的命令**抢到域锁**;抢不到 ⇒ **停手报告**,⛔ 不许硬写。\n" "3. 只读访问工作区其它目录是允许的(读 `交付物`、`docs`、`tmp` 等)," "**⛔ 但产物一律写进你自己的域目录**。\n" "本轮只做这一件事,做完即停。\n" "%(dom)s"), } GAP_SUFFIX = {"worker": "承接队列"} def _gap_plan(role: str, topic: str, topics: list) -> dict: """缺会话时**该怎么拉起** —— 现成参数(给会话照抄,⛔ 本函数不执行任何写)。""" tp = str(topic or "").strip() or (list(topics)[0] if topics else "") # 🔴🔴 2026-10-05 修**类别名自带方括号 ⇒ 拼出双层**(实测:vibe-product 的 # `goal.json` 里 `topics` 写成 `["[暗色主题补抓]", …]`,套进下面的 # `"[%s]-[%s]-%s"` ⇒ 拼出 `[执行]-[[暗色主题补抓]]-承接队列` ⇒ **形不合规**)。 # 判据:类别名是「值」不是「名字」——模板负责加方括号,值里⛔ 不许自带。 # ⇒ 归一化:**只剥一层**首尾方括号(⛔ 不 strip 所有,免得把 `[a][b]` 也吞了)。 # ⚠️ 归一化**只作用于新建名的拼装**,⛔ 不回写 `goal.json`(那是用户的输入)。 if tp.startswith("[") and tp.endswith("]") and len(tp) > 2: tp = tp[1:-1].strip() # 🔴🔴 2026-10-05 **用户定案:「协作 全部 改为执行」** —— 新建前缀一律 `[执行]`。 # 旧 `[协作]`/`[协作目标]` **继续认**(见 `parse_session_name()` 的 role 映射表, # ⛔ 不许删:宿主库实测有 55 条会话 + 4 条活排期标题带 `[协作]`,删了它们全部解析成 # `role=""` ⇒ 看板画不出、派活漏管,且不可逆)。 # ⚠️ 本字典是**新建前缀的唯一发源地** —— 改这里即生效,别无第二处。 _zh = {"follow": "跟进", "worker": "执行", "waker": "唤醒"}[role] _sch = time.strftime("%Y-%m-%dT%H:%M", time.localtime(time.time() + 90)) if role in GAP_GLOBAL_ROLES: # 🔴 全局唯一席位 ⇒ 名字不带类别 _nm = "[%s]-%s%s" % (_zh, GAP_SUFFIX[role], GAP_SINGLE_NOTE) _why = "缺 %s(全局唯一席位·不分类别)⇒ 拉起(带回来只有排期做得到)" % role else: _nm = "[%s]-[%s]-%s" % (_zh, tp, GAP_SUFFIX[role]) _why = "缺 %s(类别「%s」)⇒ 拉起(带回来只有排期做得到)" % (role, tp) # 🔴🔴 **只有 `worker`(任务会话)带独立域门禁** —— 跟进/唤醒只读+建排期, # ⛔ 不该给它们域目录(给了反而是"逼它们别写",而它们的活本来就不写文件)。 _dom = "" if role == "worker": _dn = suggest_domain_name(tp) _dom = domain_gate_block(_dn) return { "name": _nm, "prompt": GAP_PROMPT[role] % {"tp": tp, "dom": _dom}, "scheduleType": "once", "scheduledAt": _sch, "cwds": str(WS).replace("\\", "/"), "why": _why, } def scan_roles(topics=None) -> dict: """扫库 ⇒ 按 **(角色, 类别)** 分桶(🔴 复用既有判据,⛔ 不立第二套解析)。 与 `_scan_follows()` 同款两条闸:`cwd ∈ 本工作区` + `标题角色`(`parse_session_name`); 类别用 `_topic_in_title()`(⛔ 只认 `goal.topics` 里的字面,认不出 ⇒ 归**默认类别** `""`)。 ⛔ 不按 `status` 排(两轮之间它是空闲的 ⇒ 按 status 排会永远漏掉,与 `_scan_mains` 同一个坑)。 """ tps = _as_list(topics) if topics is not None else _goal_topics() out = {"by": {}, "title": {}, "topics": tps, "err": ""} try: con = _db() for sid, cwd, title in con.execute( "select id, cwd, coalesce(custom_title, title, '') from sessions " "where deleted_at is null order by last_activity_at desc limit 50"): t = str(title or "") if not _same_ws(str(cwd or "")): continue role = str(parse_session_name(t).get("role") or "") if not role: continue _s = str(sid) out["title"][_s] = t # 🔴 全局唯一席位(`follow`)⇒ 桶键恒 `(role, "")`,**不看标题里的类别**; # 其余角色仍按类别分桶。 _tp = "" if role in GAP_GLOBAL_ROLES else _topic_in_title(t, tps) out["by"].setdefault((role, _tp), []).append(_s) con.close() except Exception as e: out["err"] = str(e) log("扫会话角色失败 %s" % e) return out def session_gap(topics=None, live_sids=None) -> dict: """🔴 **有没有缺会话** + 缺的**怎么拉起**(只读;⛔ 不建排期、⛔ 不投递、⛔ 不改队列)。 @returns `{"topics":[…], "live":[…], "rows":[…], "hard":[…], "soft":[…], "err":""}` · 每行 `state` 四态:`ok` 有活的 / `stale` 在册但**都不活** / `none` **一条都没有** / `unknown` **活会话探测不到**(⚠️ 此时**不报缺** —— 与 `_pick_live()` 同一条规矩: 探测不到时⛔ 不许假装知道谁活着;否则 token 缺失就会满屏假红)。 · `hard` = 缺的**跟进/协作**会话(⛔ 缺它必然断链:没人收上报/没人干活); `soft` = 缺的**唤醒**会话(不算硬缺,理由见 `GAP_ROLES` 下那段)。 """ sc = scan_roles(topics) tps = list(sc.get("topics") or []) live = _live_sids() if live_sids is None else set(str(x) for x in (live_sids or []) if x) _deaf = _deaf_sids() rows, hard, soft = [], [], [] for role, zh in GAP_ROLES: # 🔴 全局唯一席位 ⇒ 只出一个桶(`(role, "")`),⛔ 不按类别展开成 N 条 for tp in (([""] if role in GAP_GLOBAL_ROLES else (tps or [""]))): sids = [str(s) for s in (sc["by"].get((role, tp)) or [])] alive = [s for s in sids if (s in live and s not in _deaf)] if not sids: state = "none" elif alive: state = "ok" elif not live: state = "unknown" else: state = "stale" miss = state in ("none", "stale") row = {"topic": tp, "role": role, "role_zh": zh, "state": state, "n_total": len(sids), "n_live": len(alive), "sids": sids, "live_sids": alive, "title": (sc["title"].get(sids[0]) if sids else ""), "plan": (_gap_plan(role, tp, tps) if miss else None)} rows.append(row) if miss: (hard if role in GAP_HARD else soft).append(row) return {"topics": tps, "live": sorted(live), "rows": rows, "hard": hard, "soft": soft, "err": sc.get("err") or ""} def _gap_topic_label(r: dict) -> str: """类别显示:全局唯一席位 ⇒ 「全类别(固定席位)」,⛔ 别显示成"默认类别"误导人。""" if r.get("role") in GAP_GLOBAL_ROLES: return "全类别(固定席位)" return r.get("topic") or "(默认类别)" def gap_lines(g: dict, limit: int = 4) -> list: """把缺口压成**几行**(🔴 它会被注入会话上下文 ⇒ 必须短,⛔ 不许整段 JSON)。""" out = [] for r in (g.get("hard") or [])[:limit]: p = r.get("plan") or {} out.append("🔴 缺**%s**(%s):在册 %d 条、%s ⇒ **立刻拉起** `%s`(一次性 %s)" % (r.get("role_zh"), _gap_topic_label(r), r.get("n_total"), ("都不活" if r.get("state") == "stale" else "一条都没有"), p.get("name") or "?", p.get("scheduledAt") or "?")) return out def gap_report(g: dict) -> str: """给人读的完整报告(`--gap` 的默认输出)。""" L = ["═══ 会话缺口(三类会话齐备度 · 只读)═══", "任务类别:%s" % ("、".join(g.get("topics") or []) or "(无)"), "活会话(网关口并集):%d 条%s" % (len(g.get("live") or []), (" —— ⚠️ 探测不到 ⇒ 缺口判**未知**(不报缺)" if not (g.get("live") or []) else ""))] for r in g.get("rows") or []: L.append(" [%s] %s:%s|在册 %d、活 %d%s" % (r["role_zh"], _gap_topic_label(r), r["state"], r["n_total"], r["n_live"], ("|首条:%s" % str(r.get("title"))[:40]) if r.get("title") else "")) if g.get("hard"): L.append("") L.append("🔴 **硬缺 %d 条**(缺它必然断链:没人干活)⇒ 用 `automation_update` 拉起:" % len(g["hard"])) for r in g["hard"]: p = (r.get("plan") or {}) L.append(" · name=%s" % p.get("name")) L.append(" scheduleType=%s scheduledAt=%s cwds=%s" % (p.get("scheduleType"), p.get("scheduledAt"), p.get("cwds"))) L.append(" prompt=%s" % p.get("prompt")) else: L.append("") L.append("✅ 无硬缺(任务会话都有活着的)。") # ⚠️ `soft` 分支保留(`soft` 恒空 —— 唤醒会话已退役、不再入 `GAP_ROLES`), # ⛔ 不删结构:删了 `task` 的键会让旧的 JSON 消费者取不到值(`--gap --json`)。 if g.get("soft"): L.append("⚠️ 软缺 %d 条(⛔ 不算硬缺)" % len(g["soft"])) if g.get("err"): L.append("⚠️ 扫描报错:%s" % g["err"]) L.append("") L.append("⛔ 本命令**只读**:它不建排期、不投递、不改队列。建排期=**会话**用 `automation_update` 执行。") return "\n".join(L) def _title_of(sid: str) -> str: """按 sid 读会话标题(拿不到 ⇒ 空串,⛔ 不猜)。""" if not sid: return "" try: con = _db() row = con.execute("select coalesce(nullif(custom_title,''), title, '') from sessions where id=?", (sid,)).fetchone() con.close() return str((row or [""])[0] or "") except Exception: return "" def resolve_main(st: dict, live_sids=None) -> dict: """🔴 解析**当前**主会话 —— 不只是读登记(用户 2026-09-30:「协作机制能发现 主会话 换了吗」)。 旧实现的答案是**不能**:`_main_sid()` 只有"① 登记为 main ② 退回上次投给谁"两条路, 两条都**静态/路径依赖**;而且看板与投递**共用同一份登记**(`board.py::_main_sid` 与这里逐字同款) ⇒ 换主会话后**一起继续指向旧会话**,连交叉校验都没有 ⇒ **静默断链**。 更糟的是投递侧原有一处**盲选回落**(目标不在活会话里就取"第一个带会话的口")⇒ 可能**投错窗口**。 本函数把"主会话"从**登记出来的**改成**解析出来的**: ① 登记为 main 且**此刻确实活着** ⇒ 认(登记有效) ② 登记失效(不在活会话里)⇒ 按**工作区**解析:`cwd == 本工作区` 且**不是执行棒**(标题不带 `[执行]`) ⇒ 取最近活动的那条(用户 2026-09-30:「以**一个工作区**为主会话的工作区」) ③ 还是解析不出 ⇒ `sid=""` ⇒ **调用方⛔ 不许盲投**,必须明确报"找不到主会话" @returns `{sid, source, switched_from}`;`switched_from` 非空=**换了**(调用方须跟随+告警) """ reg = _main_sid(st) live = _live_sids() if live_sids is None else set(str(s) for s in live_sids if s) # 🔴🔴 2026-10-01 修(**V2「主会话可响应」的硬阻塞真因**): # 原实现是 `if reg and (not live or reg in live): return reg` —— **只查"活着",⛔ 不查"哑"**。 # 而"哑"(会话诊断日志撞 ~10 MiB 上限 ⇒ 宿主拒写 ⇒ **界面永不刷新**)**照样在 `live` 里** # ⇒ 登记的那条成了哑巴之后,这里**每轮都立刻把它认下来** ⇒ 一路投到 `target-deaf`。 # ⚠️ 与 ② 的判据**自相矛盾**:② 走 `_pick_live()`,而 `_pick_live()` **本来就排哑** # (它是 2026-10-01 为"别再投给哑会话"专门加的)—— ① 却从没接上它。 # 结果:**同一条哑会话,② 排得掉、① 排不掉**(判据打架,今天第三次同族)。 # ⇒ 改成**两台判据合一**:① 也走 `_pick_live()`。 # · `live` 为空(探测失败)⇒ `_pick_live` 回退第一条 ⇒ **老行为逐字不变**(⛔ 不假装知道谁活着); # · 登记的多条里有活着的 ⇒ 挑那条(多主会话部署也对); # · **登记的都哑/都不活** ⇒ 返回空 ⇒ **落到 ② 按工作区解析**(这才是"登记失效"该有的行为, # ⛔ 不是"永远认一个哑巴")。这才是 `resolve_main` 存在的意义:登记失效要能**自己发现**。 _rm = [str(_k) for _k, _v in (st.get("roles") or {}).items() if str(_v) == "main" and str(_k).lower().startswith(tuple("0123456789abcdef"))] _reg = _pick_live(_rm, live) if _reg: # 登记里的"第一条 main" 与真正被认下来的不是同一条 ⇒ **报了它就是换了主会话** # (典型:第一条已哑 ⇒ 跟随到第二条 => 必须走 `_follow_main` 的降级+告警,⛔ 不静默换) return {"sid": _reg, "source": "roles:main", "switched_from": reg if (reg and reg != _reg) else ""} # ② 按**工作区**认(用户 2026-09-30:「以 **一个工作区** 为主会话的工作区」+ # 「是**一个**工作区,下面**应该都可能是主会话**,一般不会切其他工作区」)—— # 所以判据只用**工作区**这一个锚点:本工作区里那一条,就是它。⛔ 不要求标题匹配目标名 # (实测主会话标题叫「接续 · 机制线(…)」,**根本不含目标短名** ⇒ 按标题认必然落空)。 # ⚠️ **不能加 `status='working'`**:主会话在两轮之间是空闲(不是 working)⇒ 加了就**永远漏掉它**。 # ⚠️ **也不能按 `is_background_automation` 排**(2026-09-30 实测踩到):**接续会话本身就是自动化起的** # —— 连当前这位主会话 `fe146dd9` 都是 `is_background_automation=1` ⇒ 那么排会把**真主会话也排掉**。 # 只按**显式标记**排除"棒":标题带 `[协作]`(那是本机制给执行棒定下的前缀,见 SKILL.md 命名)。 sc = _scan_mains(st, live) # 🔴 与 `resolve_mains()` **共用同一遍扫描**(⛔ 不各扫各的=防两边漂移) named, cand = sc["named"], sc["cand"] # 优先认**显式标了 `主控`** 的那条 —— 它比"最近活动"可信得多(用户自己标的,不是猜的) # 🔴 2026-10-01:但**必须活着**(`_pick_live`)—— 不活的"显式标记"会把投递卡死在 `main-not-live`。 _nm = _pick_live(named, live) if _nm: return {"sid": _nm, "source": "prefix:主控", "switched_from": reg if (reg and reg != _nm) else ""} _cd = _pick_live(cand, live) if _cd: return {"sid": _cd, "source": "workspace", "switched_from": reg if (reg and reg != _cd) else ""} return {"sid": "", "source": ("stale-roles" if reg else "no-register"), "switched_from": ""} def _follow_main(st: dict, rm: dict) -> None: """主会话**换了** ⇒ 跟随 + **显式告警**(⛔ 不静默)。""" old, new = str(rm.get("switched_from") or ""), str(rm.get("sid") or "") if not new: return try: roles = st.setdefault("roles", {}) if old and str(roles.get(old)) == "main": roles[old] = "worker" roles[new] = "main" except Exception: pass log("主会话变更:%s -> %s(依据 %s)⇒ 已跟随" % (old[:8] or "?", new[:8], rm.get("source"))) need_user("主会话从 %s 换到了 %s(依据:%s)—— 本程序**已自动跟随**并把后续通知投给新主会话;" "若判断有误,请纠正登记(`roles` / `--declare`)。" % (old[:8] or "(无)", new[:8], rm.get("source"))) def _main_state(st: dict) -> str: """主会话**当前状态**(`working` ⇒ 正在执行)。取不到 ⇒ 空串(⛔ 不猜)。""" sid = _main_sid(st) try: con = _db() if sid: row = con.execute("select status from sessions where id=?", (sid,)).fetchone() else: row = con.execute("select status from sessions where status='working' limit 1").fetchone() con.close() return str(((row or [""])[0]) or "").lower() except Exception as e: log("main_state 取失败 %s" % e) return "" WAKER_PREFIX = "[唤醒]" """🔴 2026-10-01 用户定:**唤醒会话**用**专属名字前缀** `[唤醒]` 认。 判「都空闲」时必须把它排除 —— 它自己就是被叫醒的那条会话, 不排除 ⇒ 它用自己的存在否掉「都空闲」⇒ **永远不叫**(自指死结)。""" def _busy_sessions() -> list: """当前 `status='working'` 的会话 `[(id, title), …]`;读不到 ⇒ `[]`(⛔ 不猜)。""" try: con = _db() rows = con.execute( "select id, title from sessions where status='working'").fetchall() con.close() return [(str(r[0] or ""), str(r[1] or "")) for r in rows] except Exception as e: log("busy_sessions 取失败 %s" % e) return [] def _anyone_busy(rows: list) -> bool: """🔴 **除唤醒会话(和判断者自己)外,还有没有会话在跑**。用户 2026-10-01 原话: 「**把自己排除不就行了,通过会话名称前缀区分**,还有**也不用他自己判断, 可以通过代码判断**」 两道排除(互补,都要): · `SELF_SID` —— **精确**:程序跑在某个会话里(钩子/会话内)时,那就是「自己」; · `WAKER_PREFIX` —— **按名字**:程序在会话外跑(常驻投递)时拿不到 `SELF_SID`, 只能认名字(唤醒会话前缀 `[唤醒]`)。 ⚠️ 判据由**代码**做,⛔ 不让会话自己去查 —— 会话自查必然把自己算进去 ⇒ 条件①永不成立。`rows` 由调用方一次性取好(⛔ 别在循环里反复查库)。 """ for sid, title in rows: if SELF_SID and sid == SELF_SID: continue # 判断者自己(精确) if str(title or "").strip().startswith(WAKER_PREFIX): continue # 唤醒会话(按名字) return True return False def reconcile(st: dict) -> dict: """启动对账(收编)—— 治「守护启动前已有协作会话在跑」:那些棒没进过台账 ⇒ 投递看不见 ⇒ 主会话以为没人在跑 ⇒ 可能重复派活。 收编:宿主库 working 会话(排除观察者自己)若台账没有 ⇒ 建条目 sess:<前8位>,state=running, _source=启动对账收编;线留空并标「待确认」(不从标题猜线)。 僵尸:台账里 running 但除自己外无人跑、也无 IN_PROGRESS ⇒ 标「有阻碍 ⇒ 待主会话重派」。 不投递、不改别人文件;幂等。 """ out = {"adopted": [], "zombie": [], "skip": ""} tk = _load_tasks() try: con = _db() allrun = [(str(r[0] or ""), str(r[1] or "")) for r in con.execute("select id, coalesce(nullif(custom_title,''),title,'') " "from sessions where status='working'")] inprog = int(con.execute("select count(*) from automation_runs where status='IN_PROGRESS'").fetchone()[0]) con.close() except Exception as e: out["skip"] = "读宿主库失败:%s" % e return out me = (os.environ.get("CODEBUDDY_SESSION_ID") or "")[:8] others = [(s, ti) for s, ti in allrun if not (me and s[:8] == me)] for sid, title in others: key = "sess:" + sid[:8] if key in tk: continue tk[key] = {"state": "running", "by": (title[:40] or sid[:8]), "t": time.time(), "title": title[:80], "line": "", "_source": "启动对账收编(队列开启前就已在跑)", "_line": "待确认(不从标题猜线)"} out["adopted"].append(key) if not others and not inprog: for k, v in list(tk.items()): if str((v or {}).get("state")) == "running": nv = dict(v or {}) nv["state"] = "blocked" nv["block_reason"] = "执行者已消失(除观察者外无在跑会话、无 IN_PROGRESS 运行)⇒ 待主会话重派" nv["t"] = time.time() tk[k] = nv out["zombie"].append(k) if out["adopted"] or out["zombie"]: _save_tasks(tk) log("reconcile adopted=%s zombie=%s" % (out["adopted"], out["zombie"])) return out def supervise(st: dict, deliver: bool = True, mutate: bool = True) -> dict: """🔴🔴 **2026-10-03:投递段与唤醒段真删除**(用户口径「没用了就删除」)。 🔴 **删掉的是什么**(⛔ 不是“标成已退役”,是真删代码路径; 下面四条即被删段的完整清单 —— **这就是原文的全部内容**,⛔ 无需再找备份: ① ①′ 待反馈序列(`notify_pending` 排队)—— 已删 ② ②③ 单条 + 握手(`notify_awaiting`/`notified`/`hb_since`)—— 已删 ③ ④ 唤醒(四条件合取 + 内容哈希限流 + 5 分钟桶)—— 已删 ④ `_deliver_str()` 的两个调用点(`上报·单条` / `上报·唤醒`)—— 已删 ⇒ **本函数现在只做一件事:读队列、报个数**(`n` 供看板 `notify_n` 读)。 🔴 **为什么能真删**(实测取证,⛔ 非推断): · 收件人(唤醒会话/跟进会话)已于 2026-10-02 按用户口径**整套退役** ⇒ `follow_for_topic()` 短路成 `why="follow-retired"`、`sid=""`(`L3385`) ⇒ `_deliver_str()` **恒在「没人能收」那一支返回失败** ⇒ 每次调用都白跑。 · 实测后果(2026-10-03 06:5x 探针):`--supervise`(pid 11036)**每 20 秒**试投一次、 每 20 秒失败一次;日志 `投递未成(follow-retired)` 累到 **1263 行且仍在 +1**; `NEED-USER.md` 每轮被重写;`notify_pending` 堆了 **4 条全是 `done`** 的件 (`S8=done` 从 23:55 堵在队首到 06:52 永不出队)。 · ⛔ **本函数不是死代码**(`--once` 投影轮、`--supervise` 两条链都在调它) ⇒ 只能删**投递分支**、必须留**读队列分支**。 🔴 **保留什么**(都不是投递): · 读 `tasks.json` 并报 `n` —— `board.py` 的 `notify_n` 读它(`L893`)。 · `q_sig` 队列指纹(变更时刻)—— 看板/自测靠它看队列有没有动过。 · `TO_MAIN.md` **主动删掉**:内容是"队列有变化,请主会话处理",而机制**不再给主会话 推送**(主会话只由用户触发,2026-10-01 用户口径)⇒ 留着就是过期噪声。 ⚠️ **零调用点、但函数本体刻意保留**(⛔ 别顺手删): `_deliver_str()` / `follow_for_topic()` / `wake_round()` —— `selftest.py` 直接 import 并**真调用**它们做单测(`L448`/`L481`/`L556` 等)⇒ 删函数体 = `NameError` 崩自测。 与 `follow_for_topic()` 内部的处置同源:**入口退役,本体留存**。 🔴 与用户口径「**协作程序接收和处理队列**」的关系(本条为准,2026-10-03): · **接收** = `--report` 写 `tasks.json`(`task_report()`)—— **原样保留,仍是活的**。 · **处理** = 建排期派任务会话(`maybe_spawn_check_agent()`,由 `--supervise` 循环 每 2 轮判一次,四道闸在函数内)—— **原样保留,仍是活的**。 · **被删的只是"把队列变化推给某个会话"这一步** —— 它没有收件人,留着就是空转。 ⚠️ `deliver` / `mutate` 两个参数**只为兼容现有三处调用点的签名**(`--once` 传 `deliver=False, mutate=False`)⇒ 函数体**不再读它们**(投影与推进的差别已随投递删除)。 """ t = _load_tasks() now = time.time() # 🔴 一次性丢弃退役前堆积的待反馈项(⛔ 不清的话队首永远堵着一件投不出去的 `done`)。 _stale = list(st.get("notify_pending") or []) if _stale: log("投递段已删除 ⇒ 丢弃堆积的待反馈项 %d 条:%s" % (len(_stale), _stale[:6])) if mutate: st["notify_pending"] = [] for _k in ("notify_awaiting", "notified", "hb_since", "hb_since_wake"): st.pop(_k, None) sig = "|".join("%s=%s" % (k, (v or {}).get("state")) for k, v in sorted(t.items())) if mutate and sig != str(st.get("q_sig") or ""): st["q_sig"] = sig st["q_transition_at"] = now # 🔴 投影文件同步退役:它的正文是"队列有变化,请主会话处理",而机制不再给主会话推送 # ⇒ 留着就是过期噪声(实测 06:52 那份还写着 `S8 -> done`)⇒ 删。 # 🔴🔴 **必须"一辈子只删一次"**(2026-10-03 实测踩到,⛔ 不是推断): # 宿主有 **SafeDelete 批量删除护栏**(按"本轮删除动作"计数,达阈值 50 即要求确认并**拒绝**) # ⇒ 写成"每轮 `if TO_MAIN.exists(): unlink()`",第一轮删成功后 `exists()` 为 False 本该停, # 但**投影轮(`mutate=False`)与常驻轮(`mutate=True`)是两个进程各跑一遍**, # 且 `exists()`→`unlink()` 之间有窗口 ⇒ 实测 **172 次删除动作** ⇒ **常驻被系统终止(rc=1)**。 # ⚠️ 这与 P0 记录过的 `wake.lock` 那次是**同一个坑**("每轮 unlink"撞批量删除护栏)。 # ⇒ 判据落成**状态位**:删成功(或已不存在)就置 `to_main_retired`,此后**再不碰这个文件**。 if mutate and not st.get("to_main_retired"): try: if TO_MAIN.exists(): TO_MAIN.unlink() st["to_main_retired"] = True log("投递段已删除 ⇒ 已清理退役的 TO-MAIN.md(此后不再触碰,⛔ 避免撞 SafeDelete 批量删除护栏)") except Exception as e: log("TO-MAIN.md 清理失败(⛔ 不重试,避免撞删除护栏):%s" % e) by = {"pending": 0, "running": 0, "done": 0, "blocked": 0} for _v in t.values(): _s = str((_v or {}).get("state") or "") if _s in by: by[_s] += 1 return {"n": len(t), "by": by, "awaiting": "", "sent": "", "phase": "", "notice": "", "deliver": {"skipped": "retired-20261003"}, "goals_open": bool(goals_open())} # ── 视图 ──────────────────────────────────────────────────── def render(d: dict, V: dict, H: dict, T: dict, tk: list) -> None: Q_ = queue_view(T, d) # 🔴 严格队列(一次一件 · 原子取件) sh = [] for s in d["sessions"]: a = s["age"] sh.append("- `%s` %-36s %s · %s · %s" % ( s["id"], s["name"], "🟢 活跃" if a < 5 else ("🟡 可能停了" if a < 30 else "⚪ 闲"), ("%.0f 分钟前" % a) if a < 1e8 else "—", s["tag"])) # 🔴 2026-10-01:**退场必须说出口**(同族红线:读到了却不说 ⇒ 读者会以为那些棒凭空消失了)。 # 口径:本列表**只摊"在跑的 + 近 `session_live_min` 分钟内动过的"**;其余收起、只报数。 _h = d.get("sessions_hidden") or {} _hid = ("- 已从本列表收起 %d 条过期会话(显式退场 %d + 超 %d 分钟未动 %d)" % (_h.get("retired", 0) + _h.get("stale", 0), _h.get("retired", 0), int(_h.get("live_min") or 0), _h.get("stale", 0)) + " —— ⛔ 不是删除(宿主库里原样在),明细见 `retired.json`。" + "接续棒收尾时应执行 `collabd.py --retire self` 主动退场。") \ if (_h.get("retired") or _h.get("stale")) else "" # ★ 2026-10-01 改(用户:看板与注入正文同族,排版必须一致): # 本块原为 `- ` **碎片流**(一行一个半句、`·`/`;`/`⇒` 硬拼),用户原话「**非常不利于阅读**」。 # ⇒ 统一改「段落排版」:`##` 小标题 + **每个无序段落以圆点开头、一句话说清一个事实** # (复杂信息放句末括号内),⛔ 不再把多个事实用 `;` 串成一行。 md = "\n".join([ "# 协作实时状态", "", "每 %ss 覆写一次,由常驻程序生成,不需要任何会话在跑。" % C["interval"], "", "## 概览", "- 快照生成于 %s。" % time.strftime("%Y-%m-%d %H:%M:%S"), "- 本轮反馈新增 %d 条,水位到 %s(其中真成果 %d 条、新排期 %d 条、在跑 %d 个)。" % (len(V["fresh"]), V["wm"], len(V["done"]), len(V["newp"]), len(V["cur"])), "- 推进判定:%s。" % V["verdict"], "- 真空:%s。" % ("超过 %.0f 分钟需求未完成且无人执行,需主会话接管" % V["vdur"] if (V["vacuum"] and V["vdur"] >= float(C["vacuum_min"])) else ("真空中已 %.0f 分钟" % V["vdur"] if V["vacuum"] else "无")), "- 体检:%s。" % ("跳过(忙)" if H["skipped"] else ("通过" if not H["issues"] else "%d 项待看" % len(H["issues"]))), "- 告警:%s。" % (V["alert"] or "无"), "- 进程:pid %d,单例 :%s,零令牌、只读、不派活。" % (os.getpid(), C["singleton_port"]), "", "## 🎯 服务探针", "- 前置端口 `%s`:%s。" % (C["shim_port"] or "(未配置)", "通" if V["up"] else "不通"), "", "## 🧭 任务图", ("- 可派未派(浪费):%s,有能干的活却没人接。" % "、".join("%s(%s)" % (r[0], r[2]) for r in T["ready"])) if T.get("waste") else ("- 可派:%s。" % "、".join("%s(%s)" % (r[0], r[2]) for r in T["ready"]) if T.get("ready") else "- 可派:无(防干等)。"), *["- 等待:%s(等前置 %s)。" % (w[0], "、".join(w[3] or [])) for w in (T.get("waiting") or [])[:6]], ("- 关键路径:%s。" % " → ".join(T["critical"])) if T.get("critical") else "- 关键路径:未配置。", "", "## 📋 严格队列", "- 队首(下一个该做):%s。" % ("%s %s(线:%s,%s)" % ( Q_["head"]["id"], Q_["head"]["title"], Q_["head"]["line"], "在关键路径上游" if Q_["head"]["prio"] == 0 else "非关键路径") if Q_.get("head") else "无可派"), "- 正在做:%s。" % ("、".join("%s→%s" % (k, v) for k, v in (Q_.get("doing") or {}).items()) or "无人"), "- 待办:%d 件%s。" % (Q_.get("pending", 0), "(同时在做多件,可能打架)" if Q_.get("conflict") else ""), "- 取件规矩:只取队首,用 `mkdir claims/` 原子取件,做完删掉 claim 出队,卡死 %d 分钟自动回退。" % (DOING_TTL // 60), "", "## ✅ 真成果", *(["- `[%s]` %s · %s" % (r["status"], r["aid"], r["title"][:140] or "(空)") for r in V["done"][:5]] or ["- 无。"]), "", "## 🟡 接续任务(只是计划,不是成果)", *(["- %s · %s" % (str(r[0])[:8], (r[1] or "")[:46]) for r in V["newp"][:6]] or ["- 无。"]), "", "## 🖥 会话状态", ("- 持锁:%s。" % "、".join(h[:30] for h in d["locks"])) if d["locks"] else "- 无锁。", "", *(sh or ["- 无会话。"]), *([_hid] if _hid else []), "", "## 🧭 靶点", *(tk or ["- 未配置。"]), "", "## 🫀 在跑的棒(口径 = `sessions.status='working'`,不含本会话)", *(["- `%s` %s" % (w["id"][:8], w["name"]) for w in d["working"]] or ["- 无(没有任何会话在做正事%s)。" % (",只有本会话这个观察者在跑" if d.get("self_working") else "")]), "", "> 本程序承担「派活之外的全部功能」,不派活(派活须由会话做)。", "", ]) LIVE.parent.mkdir(parents=True, exist_ok=True) LIVE.write_text(md, encoding="utf-8") # ★ 2026-10-01 改:摘要改「段落排版」(见 `digest_text` docstring)—— # 原实现把 `verdict` 与 `goal_line()` **原样拼成 `- ` 碎片**,用户反馈「非常不利于阅读」。 (INBOX / "digest.md").write_text(digest_text(d, V, T), encoding="utf-8") def _dur_txt(m: float) -> str: """分钟数 → 人读的时长文本(供摘要段落使用)。""" return ("%.0f 分钟" % m) if m < 90 else ("%.1f 小时" % (m / 60.0)) def _plain_goal() -> str: """目标行去掉装饰前缀(`🎯 围绕目标:`),供「标签:段落」排版复用。""" g = goal_line() for pre in ("🎯 围绕目标:", "🎯 "): if g.startswith(pre): return g[len(pre):] return g def digest_text(d: dict, V: dict, T: dict) -> str: """会话侧的「机械摘要」—— **段落排版**(2026-10-01 改)。 🔴 用户原话:「会话的反馈信息排版 **非常不利于阅读**,改为**段落排版**」,并给出目标形态: **一行 header + 其后每行「标签:一段话」**。 ⛔ 因此这里**不用 `- ` 列表符号**、⛔ **不用 `;`/`⇒` 串联短句** —— 那是给程序看的形态,不是给人看的。 🔴 为什么值得改:本文件是**每轮钩子注入给每个会话**的正文 ⇒ 排版差=每个会话每轮都要 额外花注意力去拆句,是**全平台共用的阅读成本**(同 §二十九「无成本反馈」那类隐形成本)。 ⚠️ 事实来源:全部取自 `V` / `T` 的**结构化字段**,⛔ 不对 `V["verdict"]` 做字符串反解 (反解会随措辞改动而静默失效)。`verdict` 原串仍保留,供 `realtime.md` 与通知使用。 """ if V["done"]: st = "本轮有 %d 条任务真正做出了结论。" % len(V["done"]) elif V["newp"]: st = "本轮只排了 %d 条接续任务,还没有出现新成果,排期不等于成果。" % len(V["newp"]) elif d["working"]: st = "有 %d 个会话正在跑,暂时没有新成果,已经 %s没有进展。" % ( len(d["working"]), _dur_txt(V["idle"])) else: st = "没有任何会话在跑,而且已经 %s没有出新成果。" % _dur_txt(V["idle"]) if V.get("zero_sched"): st += "未来 1 小时内也没有任何排期,只要没人主动开口,这里就会一直静默下去。" if V.get("probe") == "rec": st += "服务探针刚从不通恢复成通,但那是服务层的变化,不算成果、也不会清零停滞计时。" elif V.get("probe") == "lost": st += "服务探针从通变成了不通,服务层有变化。" rd = T.get("ready") or [] if T.get("waste"): rdy = "有能干的活却没有任何棒在跑,现在就该派:" + "、".join( "%s(%s)" % (r[0], r[2]) for r in rd) + "。" elif rd: rdy = "可以派下一棒:" + "、".join("%s(%s)" % (r[0], r[2]) for r in rd) + "。" else: rdy = "没有可派的任务。" wt = T.get("waiting") or [] if wt: wtx = "".join("%s 在等 %s。" % (w[0], "、".join(w[3] or []) or "前置条件") for w in wt[:6]) else: wtx = "没有任务在等前置条件。" cp = T.get("critical") or [] gl = _plain_goal() return "\n".join([ "# 机械摘要", "", "状态:%s" % st, "目标:%s" % (gl if gl.endswith(("。", ")", "`")) else gl + "。"), "可派:%s" % rdy, "等待:%s" % wtx, "关键路径:%s" % ("%s。" % " → ".join(cp) if cp else "尚未配置。"), "", ]) def _sig_head(alert: str) -> str: """信号文件的 header 行(原先写在标题里,且带 `**` 与 `+` 等排版噪声)。""" return str(alert or "").replace("**", "").strip() def signals(V: dict, T: dict) -> None: def w(p, txt): try: p.write_text(txt, encoding="utf-8") except Exception: pass def rm(p): try: p.unlink() except Exception: pass # ★ 2026-10-01 改(用户:会话的反馈信息「非常不利于阅读」): # 三个信号文件与 `digest.md` **同族、同被注入给会话** ⇒ 一律改「段落排版」: # **一行 header + 其后每行「标签:一段话」**;⛔ 不用 `- ` 列表、⛔ 不用 `1. 2. 3.` 步骤碎片。 if V["alert"]: w(STALL, "# ⚠️ %s\n\n" "发生时间:%s\n" "判定依据:程序按「有没有新成果、有没有棒在跑、未来有没有排期」三条自动判出上面这条告警。\n" "该谁做:本程序不派活。请主会话抢一把细粒度域锁,读完摘要与任务图后按缺口派下一棒(属白名单「派活」)。\n" % (_sig_head(V["alert"]), time.strftime("%Y-%m-%d %H:%M:%S"))) else: rm(STALL) if V["vacuum"] and V["vdur"] >= float(C["vacuum_min"]): w(VACUUM, "# 🔴 真空:需求还没做完,却没有任何会话在执行\n\n" "已持续:%.0f 分钟\n" "该谁做:主会话抢域锁,读摘要与任务图判断缺口,然后派下一棒;或者自己做最靠前的那一步。" "做完写记忆、释锁、停。\n" % V["vdur"]) else: rm(VACUUM) if T.get("waste"): w(READY, "# 🔴 可派未派(浪费):有能干的活,却没有任何棒在跑\n\n" "现在就能派:%s\n" "该谁做:按任务图优先关键路径派活,抢细粒度域锁,禁用整工作区粗域。\n" % ";".join("%s %s(线:%s)" % (r[0], r[1], r[2]) for r in T["ready"])) else: rm(READY) def one_round(st: dict) -> dict: d = fetch() up = probe_port() V = verdicts(d, up, st) T = taskgraph(V["cur"], V["busy"]) H = health(d, V) if not H["skipped"] and time.time() - float(st.get("health_at") or 0) > float(C["health_every"]): st["health_at"] = time.time() try: # 🔴 2026-10-02(S11):`notes`(到点后跑完的**延迟**条)也要落盘 —— # ⛔ 只落 issues 会让「补跑」彻底无痕 ⇒ 下次又有人把延迟当哑火查一遍。 # 🔴 2026-10-03(S12):**原子替换写盘**(临时文件 + `os.replace`)—— # 去掉 busy 早退后体检会在别的会话运行时也写,⛔ 不能再靠「忙时别写」躲半截文件。 _body = ("# 机制体检\n\n- %s\n- 执行时机:%s\n\n## 结果:%s\n%s\n%s\n" % (time.strftime("%Y-%m-%d %H:%M:%S"), "有会话在跑也照跑(2026-10-03 起;⛔ 不再因 busy 早退)" if V["busy"] else "空闲", "✅ 通过" if not H["issues"] else "⚠️ %d 项" % len(H["issues"]), "\n".join("- %s" % i for i in H["issues"]) or "- 无异常", ("\n## 到点后跑完(补跑 · ⛔ 不是哑火)\n" + "\n".join("- %s" % n for n in H.get("notes") or [])) if H.get("notes") else "")) _tmp = HEALTH.with_name(HEALTH.name + ".tmp") _tmp.write_text(_body, encoding="utf-8") os.replace(str(_tmp), str(HEALTH)) except Exception: pass tk_ok, tk = targets() render(d, V, H, T, tk) # 🔧 **兼容别名**:旧引用(`CODEBUDDY.md §1.5 D⑤`/SOP)读 `advance.md`。由**同一份内容**产出, # ⛔ 不是第二真相(同源同内容,仅供旧读者过渡)。 try: (INBOX / "advance.md").write_text( "# 机械推进快照(与 digest.md 同源)\n\n" + (INBOX / "digest.md").read_text(encoding="utf-8"), encoding="utf-8") except Exception: pass signals(V, T) # ⑪ 读队列/写通知 —— 🔴 **纯投影**(`mutate=False`):⛔ 绝不推进队列状态。 # **投递 + 推进队列 = 「监督程序」的唯一职责**(原 `--tick`,2026-10-05 已删)。 # ⚠️ 2026-09-30 修:旧版这里 `deliver=False` 但**仍然消费** `notify_pending`/`notified` # ⇒ 钩子先跑 `--once`(节流 3 分钟,谁发话都会跑)**把待反馈项吃掉却不投递**, # 紧接着那一轮便看到空队列 ⇒ **通知永远发不出去**(="程序在跑、主会话没收到")。 # 🔴🔴 2026-10-03:**投递段已整体退役** ⇒ 这行现在只是"读队列报个数"(`n` 供看板 `notify_n`)。 # 旧文「⚠️ 副作用提醒:只起协作程序、不起投递 ⇒ 没人投递」**已于本棒作废** —— # 现在的机制里**根本没有"投递"这一环**(收件人已退役)⇒ 起不 起常驻都不影响它。 st["queue_info"] = supervise(st, deliver=False, mutate=False) # 🔴 2026-09-29 职责纠正(用户定案:"协作程序怎么会投递呢,应该是只维护协作队列,让投递来读"): # ⇒ 常驻程序只做:读库/判定/信号文件/看板/台账投影。 # ⚠️ 2026-10-03:`wake_round()`(2026-09-29 已停用)与 `wake_info` 键一并**退役** —— # 唤醒路径整体删除后已无人产出新值 ⇒ 这里只把旧值原样带走(⛔ 不清零,避免看板读数跳变)。 wi = st.get("wake_info") or {} # 🔴 已退役字段:仅透传旧值,不再有新写入方 st.update({"rowid": V["wm"], "aids": V["cur"], "last_progress_at": V["pa"], "vacuum_since": V["vs"], "up": up, "wake_info": wi}) try: STATE.write_text(json.dumps(st, ensure_ascii=False), encoding="utf-8") except Exception: pass return st def main() -> int: # 🔴 未知参数 ⇒ **拒绝并退出**(⛔ 不许落进常驻模式)—— # 实测踩过:误传一个未处理的参数(`--reqs`)会**静默变成常驻进程**,很难发现。 # 🔴 2026-10-05:`--tick` **已从本表移除**(机制删除,见 `main()` 上方注释)。 # ⛔ 留着它 ⇒ 传 `--tick` 会被当合法参数、落进"无匹配分支"⇒ **静默退出**,极难排查。 _KNOWN = {"--where", "--once", "--supervise", "--ready-next", "--report", "--reqs", "--declare", "--state", "--by", "--artifact", "--reason", "--line", "--role", "--name", "--topic", "--reconcile", "--retire", "--why", "--ensure", "--gap", "--json", # 🔴 2026-10-02 用户拍板方案 A:目标三态 + 检查会话自主建排期 "--set-life", "--spawn-check", "--check-status", "--check-why", "--dry-run", # 🔴 2026-10-02 用户拍板「协作会话必须独立域运行」 "--domain-status", "--domain-block", "--domain-check", "--domain-suggest", "--ensure-goal-dir"} _bad = [a for a in sys.argv[1:] if a.startswith("--") and a not in _KNOWN] if _bad: print("未知参数:%s\n用法:collabd.py [--once | --supervise | --ensure | --reqs | " "--report --state pending|running|done|blocked [--by ...] [--artifact ...] [--reason ...] | " "--ready-next | --declare [--role main|worker] [--topic ...] | " "--retire self|<会话id> [--by <接棒id>] [--why ...] | --gap [--json] | --where]" "\n⚠️ ⛔ 不带参数 = 常驻模式(应由守护程序拉起)" % " ".join(_bad)) return 2 if "--where" in sys.argv: print("collabd.py =", Path(__file__).resolve()) print("workspace =", WS, "\nLIVE =", LIVE, "\nTG =", TG, "\nINBOX =", INBOX) print("配置来源 =", CFG_USED or "⚠️ 未找到(在用 DEFAULTS)") return 0# 🔴 2026-10-02 补:原来这里**缺 return** ⇒ 打印完继续往下走 # ⇒ 落进常驻模式(无参数分支)⇒ `--where` **永久挂住**(实测 rc=124 超时)。 # ⚠️ 对照验证:改动前的备份同样是 rc=124 ⇒ 属既有缺陷、⛔ 非本次引入。 # ── 🔴🔴 目标三态 + 检查会话(2026-10-02 用户拍板方案 A)──────────────────── if "--set-life" in sys.argv: v = "" for i, a in enumerate(sys.argv): if a == "--set-life" and i + 1 < len(sys.argv) and not sys.argv[i + 1].startswith("--"): v = sys.argv[i + 1] by = "" for i, a in enumerate(sys.argv): if a == "--by" and i + 1 < len(sys.argv): by = sys.argv[i + 1] why = "" for i, a in enumerate(sys.argv): if a == "--check-why" and i + 1 < len(sys.argv): why = sys.argv[i + 1] print(goal_life_set(v, by=by, why=why)) return 0 if "--check-status" in sys.argv: life = goal_life() pend = queue_pending() idle = 0.0 lp = _last_progress_ts() if lp: idle = (time.time() - lp) / 60.0 print("目标状态 = %s|队列未完成 = %d|静默 = %.1f 分钟|%s" % ( life, pend, idle, "会话全结束" if _all_sessions_idle() else "**还有会话在跑**")) print("已建检查会话 = %d 棒" % _check_stamp().get("round", 0)) return 0 # ── 🔴🔴 独立域(2026-10-02 用户拍板)────────────────────────────────── def _argv_val(flag: str) -> str: for i, a in enumerate(sys.argv): if a == flag and i + 1 < len(sys.argv) and not sys.argv[i + 1].startswith("--"): return sys.argv[i + 1] return "" if "--ensure-goal-dir" in sys.argv: _r = _ensure_goal_dir() if not _r.get("ok"): print("❌ %s" % (_r.get("err") or "建目标目录失败")) return 1 print("ℹ️ 状态文档已在位(⛔ 未覆盖)" if _r.get("skipped") else "✅ 已建目标目录 + 落状态文档骨架") print("目标目录:%s" % _r.get("dir", "")) print("状态文档:%s" % _r.get("doc", "")) # 🔴 2026-10-05:把「顺手登记主会话」的结果**说出来**(⛔ 不静默 —— 用户要能看到谁被记成了主会话) if _r.get("main_reg"): print("主会话登记:%s" % _r["main_reg"]) # 🔴 2026-10-05:`execution_doc` 对齐结果也要**说出来**(⛔ 不静默 —— 静默对齐= # 用户以为登记值本来就是对的,下次再漂还是查不出原因)。见 P0-86。 if _r.get("doc_sync"): print("状态文档登记:%s" % _r["doc_sync"]) return 0 if "--domain-status" in sys.argv: r = ds_domain_report() print("工作区根 = %s" % r["root"]) print("域目录根 = %s/<域名>(🔴 直接占工作区下的**第一层**目录;" "⛔ 别套domains/ 公共父目录,那样所有域会算出同一个键)" % r["root"]) if r["unreadable"]: print("🔴 锁表读不到 ⇒ **fail-safe 判全部不可派**(%s)" % r["locks"].get("__unreadable__")) elif not r["locks"]: print("当前**无人持域锁** ⇒ 任意空闲域名可派") else: print("在册域锁(%d 条):" % len(r["locks"])) for k, v in sorted(r["locks"].items()): print(" · %-46s ← %s" % (k, v)) print("锚点词表与lock-guard-hook.py %s" % ("一致 ✅" if r["anchors_match"] else "⚠️ **不一致**(`scripts/skills` vs `07-scripts/08-skills`)")) return 0 if "--domain-suggest" in sys.argv: print(suggest_domain_name(_argv_val("--domain-suggest"), _argv_val("--domain-check"))) return 0 if "--domain-block" in sys.argv: _dn = _argv_val("--domain-block") if not _dn: print("用法:collabd.py --domain-block <域名> # 打印独立域门禁块(供嵌进 prompt)") return 2 print(domain_gate_block(_dn)) return 0 if "--domain-check" in sys.argv: _dn = _argv_val("--domain-check") if not _dn: print("用法:collabd.py --domain-check <域名> # 判这个域能不能派") return 2 _r = check_domain_free(_dn) print("域目录 = %s" % ds_domain_dir(_dn)) print("域键 = %s" % _r["key"]) print("判定 = %s|%s" % ("✅ 可派" if _r["ok"] else "⛔ 不可派", _r["why"])) return 0 if _r["ok"] else 1 if "--spawn-check" in sys.argv: why = "" for i, a in enumerate(sys.argv): if a == "--check-why" and i + 1 < len(sys.argv) and not sys.argv[i + 1].startswith("--"): why = sys.argv[i + 1] if "--dry-run" in sys.argv: life = goal_life() pend = queue_pending() lp = _last_progress_ts() idle = ((time.time() - lp) / 60.0) if lp else -1 print("【试算·不落库】目标=%s|队列未完成=%d|静默=%.1f 分钟|会话全结束=%s" % ( life, pend, idle, _all_sessions_idle())) # 🔴🔴 试算必须与 `maybe_spawn_check_agent()` 的闸**逐条对齐**(2026-10-04): # · `sessions-ended`(队列非空)⇒ **不受目标状态限制**; # · `queue-empty` ⇒ 须目标「进行中」+ 静默 ≥20 分钟。 # ⛔ 这里若仍写"必须进行中"⇒ **试算说不会建、实际会建**(判据自相矛盾)。 _why_now = why or "queue-empty" _will = False if _all_sessions_idle(): if _why_now == "sessions-ended": _will = pend > 0 else: _will = (life == GOAL_LIFE_RUN and pend <= 0 and idle >= CHECK_IDLE_MIN) print(" ⇒ 会建:%s(按 reason=%s 判)" % ("是" if _will else "否", _why_now)) return 0 r = maybe_spawn_check_agent(why or "queue-empty") print("已建检查会话排期:%s(%s 触发,%s 到点)" % ( r["name"], r["reason"] if "reason" in r else why or "queue-empty", r["fire_at"]) if r else "未建(闸门未过:状态非进行中 / 还有会话在跑 / 未静默 %d 分钟 / 同名在册)" % CHECK_IDLE_MIN) return 0 return 0 # 🔴🔴 **找不到使用方的部署配置 ⇒ 拒跑(fail-closed)** # 为什么必须拒:不配配置时 `workspace` 会回落到 **cwd**,而 cwd 常常就是**技能目录** # ⇒ 会在技能里长出 `tmp/supervise-inbox/`、`_collabd.log` 等**使用方的产物** # (2026-09-30 实测:跑一次 `--where` 就在技能里生成了 `tmp/supervise-inbox/_collabd.log`)。 # ⇒ 宁可**什么都不做并说明原因**,⛔ 也不把技能目录当使用方用。 if CFG_MISSING: print("⛔ 未找到部署配置,**拒跑**(避免把技能目录当工作区用、在里面长出运行产物)。\n" " 查找顺序:① 环境变量 `COLLABD_CONFIG` ② `" "/.workbuddy/collab/collabd.config.json`。\n" " 使用方请把配置放在 ② 那个位置(或由钩子/启动器传 ①)。\n" " 只想看路径 ⇒ 用 `--where`。") return 2 INBOX.mkdir(parents=True, exist_ok=True) st = {} try: st = json.loads(STATE.read_text(encoding="utf-8")) except Exception: pass # 🔴 2026-10-02 加:**会话缺口体检 + 拉起方案**(“没有相关会话就自动拉起”的判据面)。 # ⛔ 本函数仍是**只读体检**:不投递、不改队列。 # ⚠️ 至于「建排期」—— 技术上**本脚本已能自己建**(直写 `automations` 表,宿主调度器 # ≈30s 内拾取;硬闸=`next_run_at` 须为有限数值 + `scheduled_at` 须为 ISO 字符串, # 见上面 ⑫ 的注释)。**这里仍不建是设计取舍、不是能力限制**:建排期=对外承诺一串 # 会话,属授权面,统一由授权闸 `hooks/wb-result-hook.py::_authorized_now()` 把关。 # ⚠️ 钩子在 `UserPromptSubmit` 里调它(`--json`),把结论注入会话 ⇒ 由会话建排期。 if "--gap" in sys.argv: if "--json" in sys.argv: print(json.dumps(session_gap(), ensure_ascii=False, indent=2)) else: print(gap_report(session_gap())) return 0 if "--report" in sys.argv: # 任务会话**上报**状态(执行中/执行完毕) def _arg(k, dv=""): return sys.argv[sys.argv.index(k) + 1] if (k in sys.argv and sys.argv.index(k) + 1 < len(sys.argv)) else dv rc = task_report(_arg("--report"), _arg("--state", "running"), _arg("--by"), _arg("--artifact"), _arg("--reason"), _arg("--line")) if rc == 0: # 上报**只写台账**(⛔ 不投递)—— # 🔴 2026-09-29 职责纠正:投递归投递;常驻程序上报后**只是把状态落盘**, # 由**常驻的投递**在它自己的轮次里读到变化并投递(这样才有"单条+握手"的顺序)。 try: STATE.write_text(json.dumps(st, ensure_ascii=False), encoding="utf-8") except Exception: pass return rc if "--declare" in sys.argv: # 会话**声明自己的角色**(sid 自动从环境取,⛔ 不用手填) def _d(k, dv=""): return sys.argv[sys.argv.index(k) + 1] if (k in sys.argv and sys.argv.index(k) + 1 < len(sys.argv)) else dv _role = _d("--role", "worker").strip().lower() if _role not in ("main", "worker"): print("--role 只能是 main / worker") return 2 _sid = os.environ.get("CODEBUDDY_SESSION_ID") or "" _key = _sid or ("name:" + _d("--name", "?")) st["roles"] = dict(st.get("roles") or {}) # 🔴🔴 2026-10-01 修(**`--declare` 原来是"加"不是"换",于是那个逃生口根本不好使**): # 背景:P0-11 的正解① 就是「补登记 `--declare --role main`」——⛔ 零代码改动、立刻可用。 # 实测**它不好使**:`_main_sid()` 取的是 `roles` 里**第一条** `main`,而 Python dict # **对已存在的键赋值不改位置** ⇒ 老那条(实测 `f8a792ab`)**永远排在前面** # ⇒ 新登记的主会话永远轮不到;再叠上 `_deliver_str` 的**粗判不传活会话** # (`resolve_mains(st, [])` ⇒ `_pick_live` 在 `live` 为空时回退 `c[0]`) # ⇒ 每轮都先认下那条**已哑的旧主会话**、直接 `target-deaf` 返回, # **走不到后面"拿锁后用活网关列表重算"那一步** ⇒ 逃生口形同虚设。 # ⇒ 语义改成「**声明自己为主会话 ⇒ 其它登记为 main 的一律降级为 worker**」 # (与用户定则一致:**一个工作区一条主会话**)+ **明确说出口**(⛔ 不静默改别人的角色)。 # ⚠️ 多任务类别的"每类别一条主会话"走的是**标题 `主控` 前缀**(`main_by_topic` 解析), # ⛔ 不依赖这里 —— 所以降级不会破坏多类别部署。 # 🔴 2026-10-05:**改成调 `register_main_session()`**(⛔ 不再就地写一遍)—— # `_ensure_goal_dir()` 也要它(创建目标时顺手登记)⇒ **一份实现两处用**, # ⛔ 避免"逃生口与自动登记两套语义漂移"(同族 P0:同一条规则两份实现)。 _topic = _d("--topic", "").strip() _demoted = [] if _role == "main": _reg = register_main_session(st, _key, topic=_topic, why="`--declare --role main`") _demoted = _reg["demoted"] else: st["roles"][_key] = "worker" if _topic: st["topics"] = dict(st.get("topics") or {}) st["topics"][_key] = _topic try: STATE.write_text(json.dumps(st, ensure_ascii=False), encoding="utf-8") except Exception: pass print("OK 已声明角色:%s = %s%s%s" % (_key[:8], _role, (",主题 = %s" % _topic) if _topic else "(⚠️ 未给 --topic ⇒ 二级前缀缺失)", "" if _sid else "(⛔ 环境里取不到会话 id ⇒ 用名字作键)")) if _demoted: print(" ⇒ 原「main」已降级为 worker:%s(一个工作区一条主会话;" "每类别各有主会话请看标题 `主控` 前缀,不受影响)" % "、".join(x[:8] for x in _demoted)) return 0 if "--retire" in sys.argv: # 🔴 会话**退场**(接续协议的必做一步) def _r(k, dv=""): return sys.argv[sys.argv.index(k) + 1] if (k in sys.argv and sys.argv.index(k) + 1 < len(sys.argv)) else dv _who = _r("--retire", "self").strip() # 🔴 `self` ⇒ 用**环境里的会话 id**(⛔ 不用手填 —— 手填一定会填错,且错了没人发现)。 if _who.lower() in ("self", "me", "自己", ""): _who = os.environ.get("CODEBUDDY_SESSION_ID") or "" return retire_add(_who, _r("--by"), _r("--why")) if "--ready-next" in sys.argv: # 🔴 收尾确认(替代"盲等 5~8 分钟") _r = ready_next(st) if _r["ok"]: print("✅ 可以排下一棒(上一棒已收尾)⇒ 排期建议 = 现在 + 30~60 秒") else: print("⛔ 先别排,原因:") for w in _r["why"]: print(" - %s" % w) return 0 if "--reconcile" in sys.argv: _r = reconcile(st) print("OK 启动对账:收编 %d 条(%s);僵尸 %d 条(%s)" % (len(_r["adopted"]), "、".join(_r["adopted"]) or "-", len(_r["zombie"]), "、".join(_r["zombie"]) or "-")) if _r.get("skip"): print(" WARN: " + _r["skip"]) return 0 if "--reqs" in sys.argv: # 打印**需求台账**(四态;常驻程序持有) _t = _load_tasks() _n = {"pending": "待执行", "running": "执行中", "done": "已完成", "blocked": "有阻碍"} if not _t: print("(需求台账为空 —— 待办可能还在任务图里,尚未并入)") for _k, _v in sorted(_t.items()): _s = str((_v or {}).get("state") or "?") print("- %-16s %-6s %s%s%s" % (_k, _n.get(_s, _s), ("线 %s|" % _v.get("line")) if _v.get("line") else "", ("执行者 %s " % _v.get("by")) if _v.get("by") else "", ("|阻碍:%s" % _v.get("block_reason")) if _v.get("block_reason") else "")) return 0 if "--supervise" in sys.argv: # 🔴🔴 2026-10-03 **本常驻的职责已收窄**(投递退役后的口径): # **唯一职责=检查会话建排期**(`maybe_spawn_check_agent()`,每 2 轮判一次)+ 写心跳。 # ⛔ **不再是"投递时钟"** —— 投递与唤醒两段已从 `supervise()` 里真删除 # (`supervise()` 现在只读队列报个数,详见其 docstring)。 # ⇒ 留它的理由**只剩一条**:它是「队列静默/会话全结束 ⇒ 建检查会话」这条腿的**唯一载体** # (⛔ 检查会话必须靠排期开,⛔ 开不了别的通道)—— 删了这条腿就断。 # 🔴 2026-10-02(S8)单例判据:**别人已经在跑**(pid 活 + 心跳新鲜)⇒ 本实例让位退出。 # ⛔ 不靠"锁文件在不在"(那是 P0-6 的老毛病),⛔ 也不靠端口。 # 🔴🔴 2026-10-05 加强(用户逐字:「**一个工作区只能有一个常驻。启动的时候必须先判断有没有**」): # ① 判据升级 —— `supervise_alive()` 现在**含身份核验**(`_pid_is_collabd`): # pid 会复用 ⇒ 光看"号还活着"会被骗(详见 `_pid_is_collabd` 的 docstring)。 # ② **补一道互斥窗口** —— 光有"启动时判一次"挡不住**两条几乎同时启动**: # 两条都在对方写心跳**之前**读到"无活常驻" ⇒ **各起一条**(实测最怕这种)。 # ✅ 做法:先抢位写 `SUP_PID`(`os.replace` 原子),**再 sleep 一小段回验**; # 若发现真有另一条在跑 ⇒ **本实例让位退出**(⛔ 不双写状态文件)。 _ok0, _det0 = supervise_alive() if _ok0 and read_heartbeat().get("pid") != os.getpid(): log("已有活的常驻(%s)⇒ 本实例退出(幂等,⛔ 不双写)" % _det0) return 0 log("supervise loop start pid=%d" % os.getpid()) try: SUP_PID.parent.mkdir(parents=True, exist_ok=True) # 🔴 原子抢位:写临时文件再 `os.replace`(⛔ 不用文件锁 —— 本机实测会卡死, # 见 `~/.workbuddy/MEMORY.md` 的「不要把 OS 文件锁当并发方案」)。 _tmp_pid = SUP_PID.with_suffix(".pid.tmp") _tmp_pid.write_text(str(os.getpid()), encoding="utf-8") os.replace(str(_tmp_pid), str(SUP_PID)) except Exception: pass # 🔴 互斥窗口:给"同时启动的另一条"留出写心跳的时间,再回验一次 try: time.sleep(1.5) _okm, _detm = supervise_alive() _other = read_heartbeat().get("pid") if _okm and _other and int(_other) != os.getpid(): log("互斥窗口回验:已有活的常驻(%s)⇒ 本实例让位退出(⛔ 防双起)" % _detm) return 0 except Exception: pass _rd = 0 while True: _rd += 1 if _guard_says_stop(): # 守护停了 ⇒ 优雅退出 log("守护已停 ⇒ 常驻(检查会话建排期)优雅退出") return 0 try: _qi = supervise(st) st["queue_info"] = _qi STATE.write_text(json.dumps(st, ensure_ascii=False), encoding="utf-8") # 🔴 心跳与节拍(2026-10-02 S8 加)——「常驻到底在不在」的**唯一机读判据**; # ⛔ 别拿 `_tick.stamp`/日志当证据(那些轮次是宿主钩子写的)。 # ⚠️ 2026-10-03:`wake` 键**已删**(它取的是 `queue_info.wake_info`, # 而那个字段属**已退役的唤醒路径**,`supervise()` 不再产出)⇒ 心跳少一格。 write_heartbeat(_rd, {"queue_n": (_qi or {}).get("n")}) beat_log("round=%d pid=%d queue_n=%s" % (_rd, os.getpid(), (_qi or {}).get("n"))) # 🗑️ 2026-10-01 删:原「前置探针(设备侧 worker + 垫片)」两块调用 —— # 理由见 `check_frontline`/`alert_frontline` 删除处(项目串越层 + 报的是已退役的线 + 用户明令删)。 except Exception as e: log("supervise loop err %s" % e) try: write_heartbeat(_rd, {"last_err": str(e)[:200]}) except Exception: pass # 🔴🔴 2026-10-02 用户拍板方案 A:**检查会话建排期**(唯一建排期的地方在此)。 # 两条触发源(用户第①②条): # ① **任务会话全部结束** ⇒ 一次性读队列 → 建检查会话整体检查 # ② **队列为空** ⇒ 静默 ≥20 分钟 → 建检查会话核对目标状态 #⛔ 四道闸全在 `maybe_spawn_check_agent()` 里(目标须「进行中」+会话全结束+ # 静默时长+同名去抖);⛔ 建排期**只一条**,⛔ 不在循环里连建。 try: if _rd % 2 == 0: # 🔴 每 2 轮判一次(周期 30s ⇒≈60s) # 🔴🔴 2026-10-05 **两条腿都在常驻里判**(用户逐字:「检查程序 常驻 自己不会判断吗, # 非要什么 tick once 去触发?」)⇒ `--tick` 删除后,`sessions-ended` # 由本循环承担(⛔ 原先只挂在 `--tick`,删了它就永远建不出"结果检查"会话)。 # 判序:先看**队列非空**(=有活没人干,最急)⇒ 再看**队列空+静默**。 # ⛔ 两者互斥(`maybe_spawn_check_agent` 内闸③各自校验),不会重复建。 _r = None if queue_pending() > 0: _r = maybe_spawn_check_agent("sessions-ended") else: _r = maybe_spawn_check_agent("queue-empty") if _r: write_heartbeat(_rd, {"check_agent": _r["name"], "check_fire_at": _r["fire_at"]}) except Exception as e: log("spawn_check err %s" % e) # 🔴🔴 2026-10-05 **park 指纹探针搬进常驻**(原先只在 `--tick` 里跑,删 tick 即断)。 # 它**不依赖投递**(判据=`parkInQueue` + `hasWaiter=false`)⇒ AI 侧修不了, # 只能**发现并告诉用户点哪一下**;⛔ 删掉就少一个"宿主消息卡住"的告警源。 try: if _rd % 20 == 0: # 🔴 节流:20 轮 ≈ 每 5 分钟(原 PARK_GAP 同量级) _tick_park_probe() except Exception as _e: log("park 探针(常驻内)异常:%r" % (_e,)) # 🔴🔴 2026-10-03 **跨区自愈接线**(用户 10-03 报障:别区常驻停了没人管)。 # ⚠️ **放在建检查会话之后、sleep 之前**:⛔ 别让它挡着本区自己的活。 # ⚠️ 整体 try:别区出问题⛔不许打断本区主循环(fail-safe=只管自己)。 try: if PEER_SWEEP_EVERY > 0 and _rd % PEER_SWEEP_EVERY == 0: _ps = peer_supervise_sweep() if _ps: log(_ps) write_heartbeat(_rd, {"peer_sweep": _ps[:200]}) except Exception as e: log("peer_sweep err %s" % e) time.sleep(float(C.get("supervise_interval") or 30)) if "--ensure" in sys.argv: # 🔴 常驻**续命**(幂等):不在就起,已在即返回(供钩子/会话/排期调) # 判据与起法见 `ensure_supervise()`;`--supervise` 的载体形态不再唯一依赖"容器会话活着"。 _r = ensure_supervise("cli", verify=True) # 🔴🔴 2026-10-04(用户原话:「**我需要的是各工作区会话 完成目标时 自己能建立 常驻程序**」) # **就地起(本进程 fork)的致命局限**:它落在**本进程所在的作业对象**里 # ⇒ 会话树一被回收(用户收工)⇒ 常驻一起死 ⇒ 表现为「有些区能常驻、有些不能」。 # 唯一合格载体=**计划任务**(父链根在 `svchost -s Schedule`,与会话树无关 —— 已实测)。 # ⇒ 所以**在这里补一步自我供给**:就地起失败时,**转去建本区的计划任务**。 # ⚠️ 为什么是"失败才建"而不是"总是建": # ① 建任务=在宿主机器上装长期自启项 ⇒ **只在真的需要时做**(省得每次提交都动系统); # ② 就地起成功时(同一会话内)它够用,⛔ 不必立刻升级; # ③ 一旦失败 ⇒ 正说明"这种起法在本场景下撑不住" ⇒ 那才需要载体。 # ⚠️ ⛔ 不代用户开目标:本路径只在**已有 `goal.json`** 时才会走到(`--ensure` 的前置)。 if not _r.get("ok"): try: _esc = _escalate_to_keeper(_r.get("detail") or "") if _esc: _r["keeper"] = _esc log("常驻自我供给(计划任务):%s" % _esc) except Exception as _e: log("常驻自我供给异常:%r" % (_e,)) print(json.dumps(_r, ensure_ascii=False)) return 0 if _r.get("ok") else 1 if "--once" in sys.argv: if goal_paused(): # 🔴 已停 ⇒ 只写一行"已停"(⛔ 不产告警) paused_round(st) print("once: paused (goal.run != active) => only one line, no alerts") return 0 if not goals_open(): # 🔴 2026-10-01 加:**目标三路全过 ⇒ 终态** paused_round(st, why="目标三路全过") print("once: goal complete (三路全过) => only one line, no alerts") return 0 st.pop("paused", None) one_round(st) return 0 try: # 单例 g = socket.socket() g.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 0) g.bind(("127.0.0.1", int(C["singleton_port"]))) g.listen(1) except Exception as e: log("singleton bind failed (%s) => another instance runs" % e) return 0 log("collabd start pid=%d ws=%s" % (os.getpid(), WS)) n = 0 while True: if _guard_says_stop(): # 守护停了 ⇒ 优雅退出(⛔ 不硬杀、不成孤儿) log("守护已停 ⇒ 常驻程序优雅退出") break n += 1 try: st = one_round(st) # ⛔ **业务自愈已从协作机制移出**(2026-09-29 用户定案:「协作机制就是协作机制, # 手机控制 workbuddy 是另一回事」)⇒ 本程序只做 队列/判定/通知,⛔ 不探业务端口、 # ⛔ 不拉业务客户端。若手机接入线需要自愈 ⇒ 由**该线自己的件**做。 except Exception as e: log("round %d err %s" % (n, e)) if n % 30 == 0: log("alive rounds=%d" % n) time.sleep(float(C["interval"])) if __name__ == "__main__": try: sys.exit(main()) except KeyboardInterrupt: sys.exit(0) except Exception as e: log("fatal %s" % e) sys.exit(1) # 🔴 不再 exit 0:**rc=0 必须是真成功**(今天栽过一次"假绿"—— # 异常被吞、rc=0、而产物根本没刷新,全链路静默失灵)