Files
admin c76f5be36c session-mechanism:接上「遇阻碍 ⇒ 置阻碍 ⇒ 机制暂停」+ 授权检查会话自解除孤儿锁
用户 10-09 两条令:
①「如果 工作区 项目机制 对应的会话异常 导致未解锁的情况下,可以授权 检查会话自解除孤儿锁」
②「其他类型 阻碍 遇到阻碍修改目标状态 暂停机制 让用户决策,不是有这个规则吗」

起因(contentm_agent 实测):一把域锁的持有者会话被 terminated、锁遗留未释放 ⇒ 目标第 4 步 blocked;
R9 一律禁删锁 ⇒ 检查会话连开 11 棒(约 5 小时)每棒都撞同一面墙、零派活,每 30 分钟一棒。
拆开看是三条腿互相锁死:闸③「队列非空」对一条 blocked 恒真;闸① 2026-10-04 收窄时把
sessions-ended 整条腿摘了出去(置了「阻碍」也照样建);结果检查 prompt 又明写「你没有改目标状态的出口」
⇒ 看得见的没权、有权的(目标检查要队列为空)看不见。

改动
- 新增 scripts/lock/orphan-lock.py:孤儿锁「判定 + 解除」的唯一执行面。
  判据查宿主库 sessions.status(只读 SQL):working 一律不动;terminated/error/archived 可解除;
  completed 须静默 >=30 分钟;查不到 sid / 库读不到一律不动(fail-closed)。
- collabd.py
  · 结果检查 prompt 开「一个」状态出口:--set-life 阻碍 --by "<会话名>" --check-why "<一句现状>"
    ("为什么"的旗标是 --check-why,不是 --why —— 后者是 --retire 的,写错会被静默忽略);
    同时把「写明阻碍」的两个出口钉死:台账 --report ... --state blocked --reason +
    NEED-USER.md 里明确喊「需用户介入」。
  · 闸① 改两档:queue-empty 原样(非「进行中」不建);sessions-ended「只拦『阻碍』」,
    「已完成」仍放行(不把 2026-10-04 修好的病搬回来)。docstring 五道闸 -> 六道闸。
  · 两条检查 prompt 都加了孤儿锁判定钩子(判「持有者已退出」才可自解除;判「活着」一律不动)。
- R9 条文加「唯一例外」:工作区 CODEBUDDY.md §3(定义处)、references/rules.md 新增 §10、SKILL.md,
  以及技能包与文档库两份 handoff-guard.sh 的「抢域锁失败」提示。
- selftest.py:新增两个用例(孤儿锁 16 项,含「working 不许动」的变异对照;阻碍档 6 项,
  含「已完成仍要建」的变异对照),并把写在两处的同一判据收成一处(改一处漏一处的实测教训)。
  全套 PASS 108 / FAIL 0。
- references/manifest.md:按「改完包必跑」重算(77 份文件,语法失败 0)。

不在本仓(各自仓内提交):工作区 CODEBUDDY.md、文档库 CODEBUDDY.md 与 07-scripts/handoff-guard.sh。
2026-10-09 07:24:14 +08:00

6766 lines
432 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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)`;
心跳=`<WS>/.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` 秒)**,两样都成立才算在跑。
# ⚠️ 心跳落点 = `<WS>/.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 是不是本区常驻"的比对基准
🔴 这个基准**本身可能就是错的**:若当前跑的是**技能目录那份**
(`<skills>/session-mechanism/scripts/collabd.py`,P0-57 形态),
那么"当前这份"=错的 ⇒ 三处一起把错的当成正宗 ⇒ **自我强化**:
· ② 续命时又起一条**技能目录那份** ⇒ 日志实测
`常驻续命:新起 --supervise pid=69464(原因:pid 12672 在、心跳新鲜,但**身份对不上**)`
—— 本区副本(12672)明明活得好好的,只因**比对基准被技能目录那份顶着**
⇒ 判"身份对不上" ⇒ **再拉一条** ⇒ 无限繁殖(用户看到的"越创建越多")。
· ③ 判据反向失真:真的本区常驻被判"不是我"。
· ① 重铺 ps1 时把正确的本区路径覆盖成技能目录 ⇒ 错一次回不来。
✅ 正解=**发布物优先**:只要 `<WS>/.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`=**副本目录**(`<ws>/.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/<id>/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/<id>` 成功者得)
② **同线互斥、跨线并行** —— 队列里标 `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/<id>`**(见写进 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/<id>` ⇒ **建不成就是别人取走了** ⇒ ⛔ 换队首/等待",
"2. 取到后写 `claims/<id>/holder`(内容 `<会话名>@<线>@<完整会话id>`)⇒ 「谁在做」的唯一权威;**第 3 段别省**(持有人失活 ⇒ 程序立即出队;⛔ 不写只能等超时兜底)",
"3. **做完 → 删掉 `claims/<id>`**(出队)⇒ 下一个才可能成为队首(⛔ 不删 ⇒ 该线一直被占)",
"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:<port> LISTENING` 候选(1024<p<65535),最多 limit 个。"""
try:
# 🔴 2026-09-29 修(用户:"一会弹出来一会弹出来的,影响我操作电脑"):
# `netstat` 是**控制台程序** —— 不加 CREATE_NO_WINDOW 就**每次都闪一个黑窗**
# (本函数每轮都被调 ⇒ 10~30 秒闪一次)。⇒ 所有子进程一律**不显窗**。
r = subprocess.run(["netstat", "-ano"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
creationflags=0x08000000, # CREATE_NO_WINDOW
timeout=40, errors="replace")
out = r.stdout or ""
except Exception as e:
log("wake: netstat err %s" % e)
return []
ports = []
for ln in out.splitlines():
if "LISTENING" not in ln:
continue
m = re.search(r"127\.0\.0\.1:(\d+)\b", ln)
if not m:
continue
p = int(m.group(1))
if 1024 < p < 65535 and p not in ports:
ports.append(p)
return ports[:limit]
def discover_gateways() -> 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<Y>\d{4})/(?P<M>\d{1,2})/(?P<D>\d{1,2}) (?P<h>\d{2}):(?P<m>\d{2}):(?P<s>\d{2})(?:\.(?P<ms>\d+))?\]"
r".*?\[SessionRunStateMachine\]"
r".*?sessionId=(?P<sid>[0-9a-fA-F-]{36}).*?\bbusy=(?P<busy>true|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<to>[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<Y>\d{4})-(?P<M>\d{1,2})-(?P<D>\d{1,2})T(?P<h>\d{2}):(?P<m>\d{2}):(?P<s>\d{2})(?:\.(?P<ms>\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/<sid>.log`。
🔴🔴 **为什么要有第二个源**(2026-10-04 `vibe-product` 实测):工作区日志
`<ws>__<hash>.log` 的**候选集**可能整个为空 —— `_log_dirs()` 靠 `WS` 反推工作区名,
`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** ⇒ 直接拼 `<sid>.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/<sid>.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 <goalId>` 显式声明(⚠️ 尚未接线,留待需要时)
⛔ **不命中 ⇒ 不算本项目**。
🔴 **⛔ 刻意不用 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 "<COLLABD_PY>" --domain-status`
② 要门禁块:`python "<COLLABD_PY>" --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 "<COLLABD_PY>" --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 <id> --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/<id>` 原子取件,做完删掉 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 <id> --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` ② `<COLLABD_WORKSPACE 或 cwd>"
"/.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、而产物根本没刷新,全链路静默失灵)