用户 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。
6766 lines
432 KiB
Python
6766 lines
432 KiB
Python
# -*- 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、而产物根本没刷新,全链路静默失灵)
|