Files
admin 64dd82073b session-mechanism: 修复钩子静默失效 + 3 处判据缺陷;禁「变相征询」
1) stop-dialog-guard: session_budget() 早退路径返回 2 值、末尾返回 3 值,调用方按 3 值解包
   ⇒ transcript > 64 MiB 时每轮 ValueError。因 fail-open(异常仍 exit 0),
   宿主零报错、install.py --verify 只判 rc=0 ⇒ 假绿;实测 86 条 EXCEPTION,
   死掉的是整条(水位/收口、接续机制起点、预算告警、门禁自检、路径自检)。

2) session-rules-check 三处判据:
   · hook_reg  按旧文件名找 ⇒ 合并成 prompt-guards.py 后每轮假红 ⇒ 改为一组可接受名
   · snap_sync 拿 mtime 当内容判据 ⇒ 连续 4 天假红 ⇒ 改为复用抽取器本体比对内容
               (变异对照:截断快照能报 fail,非恒绿)
   · mem_ptr   只查全局技能根 ⇒ 工作区自带技能被判悬空 ⇒ 改查「全局 ∪ 工作区」

3) pitfalls 新增 P0-95(改判据必须重跑变异对照;fail-open + 只看 rc=0 = 假绿温床)

4) 回复排版核心块新增「变相征询同样禁止」(先只报不动/等你发话/我倾向X你看呢
   这类不带选项的待定清单,一律按待拍板项写:问题+说明+各候选优缺点+倾向)
2026-10-06 22:27:03 +08:00

878 lines
53 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""goalctl —— 「**一个需求**」的**统一启停控制台**(🔴 属于本工作区,⛔ 不属于技能)。
由来(用户 2026-09-30):
> 「使用协作skill后 某个需求 统一管理 各种对应任务和程序的启停,包括协作程序等」
## 一个需求到底牵扯哪些"可启停的东西"(本机实测四层,⛔ 别多列)
| 层 | 物 | 谁能改 |
|---|---|---|
| ① **排期层(⛔ 2026-10-01 废弃:不用自动任务当闹钟)** | `automations` 表(唤醒 A/B、各线唤醒) | ⛔ **只有会话能改**(`automation_update` 工具)——脚本只读+打印待办 |
| ② **程序层** | `collabd.config.json` 的 `wake_enable`(常驻程序**投递总闸**) | ✅ 脚本可改(配置现成消费者:`collabd.py` 启动即读) |
| ③ **总开关层** | `goal.json` 的 `run` 段(`active`/`paused`) | ✅ 脚本可改(唤醒第 0 步读它;⛔ `paused` ⇒ 不许自启、不许派活) |
| ④ **服务层** | 看板 `board.py --serve`/守护 `guard`/网关唤醒 | ✅ 复用现成 `stop-collab.py`(⛔ 不重写) |
## 三条硬约束(决定了为什么长这样)
1. 🔴 **钩子是全局注册、跨项目共用的**(`<配置目录>/settings.json`,里面还混着
其它工作区的 `decision_bridge.py`)⇒ ⛔ **不能靠装卸钩子来启停某个需求**(会连累所有项目)
⇒ 只能「**开关文件 + 入口早退**」。所以本控制台不碰钩子注册面。
2. 🔴 **排期只能由会话改**(脚本改 `workbuddy.db` 的 `automations` 表=改活库+宿主是否重读未验)
⇒ 脚本**只打印**该做的两步,由会话执行。⚠️ 降级有保障:即便排期照旧触发,
唤醒第 0 步读到 `run=paused` 会**立刻退出**(一次空跑,不派活、不改文件)。
3. 🔴 **⛔ 不 kill 任何会话/worker**;⛔ 不重启宿主;⛔ 不改全局配置。停服务一律走
「写标志让对端优雅退出」(`guard.stop`)或停自建看板实例。
## 用法
python goalctl.py # 状态台(默认,只读,⛔ 不动任何东西)
python goalctl.py declare --title "…" [--why "…"] [--topics "A,B"] [--kpi "V1=pass"] [--yes]
# 🔴 **登记"调用本技能时在对话里说明的目标"**
# (用户 2026-10-01:「目标是**通过对话在调用 会话协作skill时说明的,不是固定的**」
# ⇒ ⛔ 技能/脚本不许预设目标与任务类别,⛔ 也不许从目录名猜)
python goalctl.py wake # 🔴 **统一唤醒入口**:拨一下闹钟 ⇒ 常驻程序判条件并投递
python goalctl.py stop # 干跑:列出"停"会做什么
python goalctl.py stop --yes # 真停:① run=paused ② wake_enable=false ③ 调 stop-collab --yes
python goalctl.py start "<目标名>" --yes # 真启:① run=active ② wake_enable=true
python goalctl.py start --yes # 同(不改标题)
## 🔴 `wake` —— 「手动补拨一次投递轮」(⛔ 投递目标=**跟进会话**,不是主会话)
⚠️ **2026-10-01 用户定案**:投递的**主路径=常驻 `collabd --supervise`**;
⛔ 不再用「自动任务当闹钟」(用户原话「**定时任务的方案已经废弃了**」)。
⇒ 本入口**保留、但不再是主路径** —— 它的现行作用是**手动补拨一次投递轮**(排查/兜底用)。
🔴 **2026-10-01 口径订正:目标从「主会话」改指「跟进会话」** —— 用户原话
「**换新会话 唤醒的是 跟进会话,主会话只能是用户触发**」⇒ 下面这张图的终点是**跟进会话**。
常驻投递(`--supervise` · 时钟本体)/ 手拨(`goalctl wake`)
└─ collabd --tick(常驻程序的**一次性投递轮**)
└─ 常驻程序 = **唯一的投递出口**(判条件 + reply 投递)
├─ 条件不符/刚投过/**跟进会话在忙** ⇒ **静默**(零打扰,零 token)
├─ 投成(http 2xx) ⇒ **跟进会话**被唤醒 ⇒ 本轮就此结束
└─ 没人能收(`no-follow-session` / `follow-not-live` / `target-deaf`)
⇒ **喊用户**(写 `NEED-USER.md`),⛔ **不降级**、⛔ 不盲投
(`no-live-session` 例外:整机一条活会话都没有 ⇒ 才降级为"自己干")
🔴 **判读三分**(脚本已内建,别自己猜):
· 「投成 / 目标在忙 / 刚投过 / 已停」⇒ **结束**(⛔ 不抢锁、⛔ 不派活、⛔ 不起会话)
· 「没人能收」 ⇒ **需人看**(⛔ 不降级 —— 缺的是**跟进会话**,不是"没人干活")
· 「一条活会话都没有」 ⇒ 降级自己干(**此时才抢域锁**)
⚠️ `target-busy` **不是**"没投出去",是"**跟进会话已经在跑**" ⇒ ⛔ **绝不能降级**(降级=同一件事干两遍)。
⚠️ 任何写动作前**先备份**(`.bak-goalctl-<日期>/`);⛔ 脚本从不删文件。
"""
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 json
import os
import re
import shutil
import sqlite3
import subprocess
import sys
import time
from pathlib import Path
HERE = Path(__file__).resolve().parent
def _resolve_ws() -> Path:
"""🔴 工作区解析(2026-10-01 修)。
⛔ **别把按 `__file__` 推层级当默认** —— 实测它推出来是**技能包的上级目录**
(`.../.workbuddy/skills`)⇒ 不带环境变量手跑时**静默**读写错工作区
(读数全「(无)」、还会在技能目录下建 `tmp/supervise-inbox/`)。
⚠️ 这与本项目已定的红线同族:「⛔ 别用 `dirname`/`__file__` 推工作区」。
顺序:`DSH_COLLAB_WS`(显式)→ `DSH_WS_ROOT`(`roots.env` 注入)→ 当前目录(含 `.workbuddy/collab/`)。
三路都不中 ⇒ 才回落到旧行为,并且**打醒目告警**(⛔ 不许静默)。
"""
for _k in ("DSH_COLLAB_WS", "DSH_WS_ROOT"):
_v = os.environ.get(_k)
if _v:
return Path(_v)
_c = Path.cwd()
if (_c / ".workbuddy" / "collab").is_dir():
return _c
sys.stderr.write(
"⚠️ goalctl:拿不到工作区(`DSH_COLLAB_WS`/`DSH_WS_ROOT` 都空,当前目录也没有 "
"`.workbuddy/collab/`)⇒ 回落到 `%s`(技能包上级目录,⚠️ 大概不是你想要的)\n"
% HERE.parent.parent)
return HERE.parent.parent
WS = _resolve_ws()
def _resolve_cfg() -> Path:
"""🔴🔴 部署配置解析(2026-10-06 修)—— ⛔ **不许写死成技能目录**。
**病根**:原为 `CFG = HERE / "collabd.config.json"`(`HERE` = **技能包目录**),
而按定则「**技能就是技能、程序就是程序,谁用产生的文件放在他自己那里**」,
**技能目录里⛔ 不放生产配置** ⇒ 该文件恒不存在 ⇒ `_load(CFG,{})` 恒返回 `{}`。
**症状(不报错、不崩溃,只是判定那一路永远读不到)**:
· `taskgraph` 取默认 `INBOX/taskgraph.json` ⇒ 目标换了图没换时**照旧踩**
(vibe-product 2026-10-06 实测:配置里已改指 proto-board,而 `goals_open()`
仍报 `bad=['任务图读不到(taskgraph.json)']` —— 即**改了配置不生效**);
· 其余读 `CFG` 的命令(`wake_enable`/`targets` / 写回配置)**全部作用在一个不存在的文件上**。
✅ 与 `collabd._cfg_candidates()` **同款三级**(⛔ 不新造顺序,否则又是一处口径分叉):
① 环境变量 `COLLABD_CONFIG`(钩子/启动器显式指定,首选)
② `<工作区>/.workbuddy/collab/collabd.config.json`(使用方**标准落点**)
③ 都不在 ⇒ 回落到旧的 `HERE/collabd.config.json` **并打醒目告警**(⛔ 不静默)。
"""
_env = os.environ.get("COLLABD_CONFIG")
if _env and Path(_env).is_file():
return Path(_env)
_std = WS / ".workbuddy" / "collab" / "collabd.config.json"
if _std.is_file():
return _std
_legacy = HERE / "collabd.config.json"
sys.stderr.write(
"⚠️ goalctl:没找到部署配置(`COLLABD_CONFIG` 未设、`%s` 不存在)"
"⇒ 回落到 `%s`\n" % (_std, _legacy))
return _legacy
CFG = _resolve_cfg()
INBOX = WS / "tmp" / "supervise-inbox"
GOAL, TASKS = INBOX / "goal.json", INBOX / "tasks.json"
# 🔴🔴 2026-10-05 **历史目标归档目录**(用户口径「一个工作区 历史的旧的目标会有很多个」)。
# 判据:`goal.json` = **唯一当前目标**(同时只有一个);换目标时**旧的必须移到这里**,
# ⛔ 不许原地覆盖丢掉(本目录此前**代码零实现** —— 那份 `机制自检-20261004.json`
# 是手工造的 ⇒ 换目标 = 旧目标消失,只留用户手写的 `_前身` 一行字)。
GOALS_DIR = INBOX / "goals"
STOPPER = HERE / "stop-collab.py"
HOSTDB = Path(os.environ.get("CODEBUDDY_CONFIG_DIR") or os.path.expanduser("~/.workbuddy")) / "workbuddy.db"
BAK = WS / ".workbuddy" / "collab" / ("bak-goalctl-%s" % time.strftime("%Y%m%d"))
ARGS = [a for a in sys.argv[1:] if not a.startswith("-")]
DO = "--yes" in sys.argv
# 🔴🔴 2026-10-05 **「用户已确认切换目标」的显式开关**(用户口径:换目标**需要用户确认**)。
# 判据:`--switch-goal` = **人**说"同意放弃当前目标";`--yes` = 只是"干跑转真写"。
# ⛔ **两者不许合并** —— 合并就等于把"确认"降级成"顺手加个 yes",闸门失效。
SWITCH_OK = "--switch-goal" in sys.argv
CMD = (ARGS[0] if ARGS else "status")
NAME = (ARGS[1] if len(ARGS) > 1 else "")
def _norm(s) -> str:
return str(s or "").replace("\\", "/").strip().lower().rstrip("/")
def _load(p: Path, default=None):
try:
return json.loads(p.read_text(encoding="utf-8"))
except Exception:
return default
def _rp(rel: str, default: Path) -> Path:
"""🔴 与 `collabd.py:_rp` 同规则:**配置里的相对路径按工作区解析**。
⚠️ 踩过的坑(2026-09-30 本棒):任务图在配置里是 `交付物/任务图.json`,
而 **`tmp/supervise-inbox/taskgraph.json` 根本不存在** —— 照默认点读会静默读到空
⇒ `goals_open()` 少一路 ⇒ **可能误判"已完成"**。"""
if not rel:
return default
p = Path(rel)
return p if p.is_absolute() else (WS / p)
def _backup(p: Path) -> str:
if not p.is_file():
return ""
BAK.mkdir(parents=True, exist_ok=True)
dst = BAK / p.name
shutil.copy2(p, dst)
return str(dst)
def _cells(v) -> list:
"""automations.cwds 可能是 JSON 数组、也可能是逗号串。"""
if isinstance(v, list):
return [_norm(x) for x in v]
s = str(v or "").strip()
if s.startswith("["):
try:
return [_norm(x) for x in json.loads(s)]
except Exception:
pass
return [_norm(x) for x in s.split(",") if x.strip()]
# ── 各层读数 ───────────────────────────────────────────────────────────
def read_goal() -> dict:
g = _load(GOAL, {}) or {}
return g if isinstance(g, dict) else {}
def read_automations() -> list:
"""只读宿主库 ⇒ 本工作区相关的排期。⛔ 不写、不开事务。"""
if not HOSTDB.is_file():
return []
try:
con = sqlite3.connect("file:%s?mode=ro" % HOSTDB.as_posix(), uri=True, timeout=5)
con.execute("PRAGMA busy_timeout=3000")
cols = [d[1] for d in con.execute("PRAGMA table_info(automations)")]
rows = list(con.execute("select * from automations"))
con.close()
except Exception as e:
return [{"_err": str(e)}]
out = []
for r in rows:
d = dict(zip(cols, r))
if d.get("deleted_at"):
continue # ⚠️ 删除是**软删**:判"还在不在跑"要看这列
cells = _cells(d.get("cwds"))
if cells and _norm(WS) not in cells:
continue
out.append({
"id": str(d.get("id") or ""), "name": str(d.get("name") or ""),
"status": str(d.get("status") or ""), "rrule": str(d.get("rrule") or ""),
"next": d.get("next_run_at"), "kind": str(d.get("schedule_type") or ""),
})
return out
def _acc_is_pass(v) -> bool:
"""🔴 一条验收判据「算不算过」—— 🔴 **本文件唯一实现,且⛔ 不自带词表**。
🔴🔴 **2026-10-06 立**(vibe-product 实测踩到,属"判据分叉"第 N 次复发):
**病根**:`board.py::acc_is_pass` 自称「**全库唯一实现**」,并在注释里点明
三处判据(`board.py` / `collabd.py::_acc_is_pass` / `board.html::accIsPass`)
**必须同款**。🔴 **实际是四处** —— 本文件 `goals_open()` 里那句
`if str(acc[k]) != "pass":` **被漏掉了**,既不归一化、也不复用真身。
**症状(不报错、不崩溃,只少说一句真话)**:本机制的存值**真源就是中文**
(`过(PID 3552 …)`/`已过|…`/`达(实测 7 个…)`)⇒ 死板比 `"pass"` ⇒
**已通过的验收被全部计入 why** ⇒ 恒判「未完成」。
实测(vibe-product 2026-10-06):四条验收**全过**(`V1=pass` 已归一化存值),
而 `goalctl.py status` 仍会因历史中文值报「未完成」;用户原话
「**为什么完成了还说没完成**」。⇒ 与 `collabd._acc_is_pass()` 的沿革逐字同族。
**正解=复用真身,⛔ 不复制词表**(复制就是下一次漂的来源):
首选 `collabd._acc_is_pass()`(它自己又首选 `board.acc_is_pass()`);
拿不到(副本被单独拷走 / 循环导入)⇒ **fail-closed 声明式兜底**:
用与 `board.py::ACC_PASS_WORDS` **同款**的白名单判,并在 stderr 留一行。
⛔ **绝不静默返回 True/False** —— 那会把"代码没同步"伪装成"判据没过/过了"。
"""
try:
import sys as _sys
_sd = str(HERE)
if _sd not in _sys.path:
_sys.path.insert(0, _sd)
import collabd as _cd # ⚠️ 同目录 ⇒ 无副作用
return bool(_cd._acc_is_pass(v))
except Exception as _e:
try:
sys.stderr.write("⚠️ goalctl._acc_is_pass: 拿不到 collabd(%s)⇒ 走兜底白名单\n" % _e)
except Exception:
pass
# ── fail-safe 兜底:与 `board.py::ACC_PASS_WORDS` / `ACC_ADV_PREFIX` **同款**
# (⛔ 改了那边必须改这里 —— 两处都列出来,免得只改词表漏掉副词)
s = str(v or "").strip()
if not s:
return False # 空 ⇒ 判不出来 ⇒ ⛔ 不算过(fail-closed)
_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
# 🔴 剥"完成副词"再比(与 `board.py::acc_is_pass` 同款)。
# ⚠️ 否定副词(未/不/没/非/待)**不在**表里 ⇒ 剥不动 ⇒ 仍 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
def goals_open() -> dict:
"""目标是否仍未完成 —— 三路并集(与 `collabd.goals_open()` 同判据,⛔ 不缩水)。
⚠️ **一路读不到不许当成"已完成"** —— 只把它标成 `读不到`,避免"误判完成"(源码里的
铁证:任务图 N14=done 而它判的正是「V1 未过」⇒ 只看"活干完没"会错)。"""
why, bad, t = [], [], _load(TASKS, {}) or {}
for k, v in (t if isinstance(t, dict) else {}).items():
if str((v or {}).get("state") or "") != "done":
why.append("台账 %s=%s" % (k, (v or {}).get("state")))
tg = _rp(str((_load(CFG, {}) or {}).get("taskgraph") or ""), INBOX / "taskgraph.json")
g = _load(tg, None)
if not isinstance(g, dict):
bad.append("任务图读不到(%s)" % tg.name)
else:
# 🔴 2026-10-01 加(与 `collabd.taskgraph` 同口径)—— **台账优先**:
# 任务图**无任何代码回写**(纯手维护),会话「上报完成」只写 `tasks.json`
# ⇒ 只认图会让「早已做完的件」永久卡住目标判定(实测 M5)。
for n in (g.get("nodes") or []):
if str(n.get("status") or "") == "done":
continue
if str(((t.get(n.get("id")) or {}).get("state")) or "") == "done":
continue # 台账已 done ⇒ 视同 done
why.append("任务图 %s" % n.get("id"))
acc = (read_goal().get("acceptance_state") or {})
eff = [k for k in acc if not str(k).startswith("_")] # 🔴 **有效项**=键不以 `_` 开头
for k in eff:
# 🔴🔴 2026-10-06 **改走真身**(原为 `str(acc[k]) != "pass"`,死板比字面值)——
# 存值真源是**中文**(`过(…)`/`已过|…`/`达(…)`)⇒ 死板比 ⇒ 已通过的
# 验收被计入 why ⇒ 恒判「未完成」。详见 `_acc_is_pass()` 的 docstring。
if not _acc_is_pass(acc[k]):
why.append("验收 %s=%s" % (k, acc[k]))
if not eff:
# 🔴 2026-09-30 修(**"读不到"被当成了"全过"**):判据是"**有没有有效项**",
# ⛔ 不是"字典空不空" —— 旧写法 `if not acc` 在"只有 `_说明`/`_更新` 说明行"时
# **永不触发** ⇒ 控制台显示"三路全过"(**实测踩到**,同一族:不崩溃,只是少说一句话)。
bad.append("无有效验收判据(只有说明行)")
return {"open": bool(why) or bool(bad), "why": why[:6], "bad": bad}
def stamp_age(p: Path) -> str:
if not p.is_file():
return "无"
d = time.time() - p.stat().st_mtime
return "%.0f 分钟前" % (d / 60) if d < 7200 else "%.1f 小时前" % (d / 3600)
def run_stopper(dry: bool) -> str:
if not STOPPER.is_file():
return "(没找到 stop-collab.py)"
cmd = [sys.executable, str(STOPPER)] + ([] if dry else ["--yes"])
r = subprocess.run(cmd, capture_output=True, text=True, timeout=180,
errors="replace", cwd=str(HERE),
creationflags=0x08000000) # CREATE_NO_WINDOW:⛔ 闪窗
return (r.stdout or "") + (("\n[stderr] " + r.stderr) if r.stderr else "")
# ── 状态台 ─────────────────────────────────────────────────────────────
def status() -> int:
g, cfg, opens = read_goal(), _load(CFG, {}) or {}, goals_open()
autos = read_automations()
print("═══ 需求控制台 · %s ═══" % time.strftime("%Y-%m-%d %H:%M"))
print("工作区 = %s" % WS)
print()
run = str(g.get("run") or ("active" if g else "?"))
print("【总开关】run = %s %s" % (run, ("" if run != "?" else "(goal.json 里还没有 run 段)")))
print(" 目标 = %s" % (g.get("title") or "⚠️ 未声明任务目标"))
# 🔴 2026-09-30 改三态(⛔ 旧写法把"**判不出来**"和"三路全过"混成一句 ⇒ 会误报"已达成了")
if opens["why"]:
_st = "**未完成**"
elif opens["bad"]:
_st = "⚠️ **判不出来**(⛔ 不因此判完成)"
else:
_st = "三路全过"
print(" 目标状态 = %s%s%s" % (_st,
("(%s)" % ";".join(opens["why"])) if opens["why"] else "",
(" ⚠️ %s" % ";".join(opens["bad"])) if opens["bad"] else ""))
t = _load(TASKS, {}) or {}
cnt = {}
for v in (t if isinstance(t, dict) else {}).values():
k = str((v or {}).get("state") or "?")
cnt[k] = cnt.get(k, 0) + 1
print(" 队列 = %s" % (cnt or "空"))
print()
print("【② 程序闸】wake_enable = %s" % cfg.get("wake_enable"))
print(" 钩子链最后唤起 = tick %s / once %s" % (
stamp_age(INBOX / "_tick.stamp"), stamp_age(INBOX / "collabd-once.stamp")))
print()
rec = [a for a in autos if a.get("kind") == "recurring"]
onc = [a for a in autos if a.get("kind") != "recurring" and not a.get("_err")]
err = [a for a in autos if a.get("_err")]
print("【① 排期层】周期排期 %d 条(**这才是启停对象**)/一次性棒 %d 条(已耗,⛔ 不是启停对象)"
% (len(rec), len(onc)))
if err:
print(" ⚠️ 读宿主库失败:%s" % err[0]["_err"])
for a in rec:
st = a["status"]
mark = "🟢" if st == "ACTIVE" else "⚪"
nx = a.get("next")
try:
nx = time.strftime("%m-%d %H:%M", time.localtime(int(nx) / (1000 if int(nx) > 10 ** 11 else 1))) if nx else "—"
except Exception:
nx = "—"
print(" %s %s %-9s next=%s %s" % (mark, a["id"][:8], st, nx, a["name"][:44]))
if not rec and not err:
print(" (没有周期排期 ⇒ 无人值守的「叫醒」通道当前是关着的)")
print()
print("【④ 服务层】")
print(run_stopper(dry=True).rstrip())
print()
on = (run == "active") and bool(cfg.get("wake_enable"))
print("⇒ 一句话:这个需求现在是 **%s**" % ("开着" if on else "停着"))
print("⇒ 下一步:%s" % ("按需求推进(有缺口就派棒)" if on else "没有人在推进;要开就 goalctl start --yes,要派活就直接派"))
return 0
# ── 启停 ───────────────────────────────────────────────────────────────
def _write_json(p: Path, d: dict) -> None:
b = _backup(p)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(json.dumps(d, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
if b:
print(" (备份 → %s)" % b)
def _automation_todo(want: str) -> None:
# 🔴 2026-10-01 修:原来只认名字含「唤醒轮」的排期 —— 而排期命名口径已改为
# `[唤醒]-<类别>-<具体>`(见 collabd.config.json `_默认形态`)⇒ 老判据**一条都找不到**,
# 于是 `stop`/`start` 永远打印「没找到名为「唤醒轮」的排期」(**判据跟着旧名字漂了**)。
# ⇒ 两道一起认:旧的「唤醒轮」+ 新的 `[唤醒]` 前缀。
autos = [a for a in read_automations()
if ("唤醒" in a.get("name", "") or a.get("name", "").startswith("[唤醒]"))]
print(" ⚠️ 排期层**只能由会话改**(⛔ 脚本不写活库)。请对本工作区的唤醒类排期执行:")
if not autos:
print(" (没找到「唤醒轮」或 `[唤醒]-…` 排期 —— 可能已删,或改名了)")
for a in autos:
if a.get("status") == want:
print(" ✓ %s 已是 %s,跳过" % (a["id"][:8], want))
continue
print(" automation_update mode=update id=%s status=%s ← %s"
% (a["id"], want, a["name"][:34]))
print(" 💡 降级有保障:即便漏改,唤醒第 0 步读到 run=%s 也会立刻退出。"
% ("paused" if want == "PAUSED" else "active"))
def stop() -> int:
print("═══ 需求控制台 · 停(%s)═══" % ("真做" if DO else "dry-run"))
g = read_goal()
if DO:
g["run"], g["run_at"] = "paused", time.strftime("%Y-%m-%dT%H:%M")
g["run_by"] = "goalctl stop"
print("① 总开关:goal.json run → paused")
_write_json(GOAL, g)
else:
print("① 总开关:[dry-run] 会写 goal.json run=paused")
cfg = _load(CFG, {}) or {}
if DO:
cfg["wake_enable"] = False
print("② 程序闸:collabd.config.json wake_enable → false")
_write_json(CFG, cfg)
else:
print("② 程序闸:[dry-run] 会写 wake_enable=false")
print("③ 服务层:%s" % ("调 stop-collab.py --yes" if DO else "[dry-run] 会调 stop-collab.py --yes"))
out = run_stopper(dry=not DO)
print("\n".join(" " + x for x in out.rstrip().splitlines()))
print("④ 排期层:")
_automation_todo("PAUSED")
print(" 🔴 停机**再起**时:常驻/看板都会**跑启动时那份代码**(⛔ 不热加载)⇒")
print(" ① 看板必须 `--takeover`(Windows 同端口可**静默并存**,⛔ 否则看到的是**旧进程**画的那张图);")
print(" ② 常驻(`collabd --supervise`)必须先停掉旧 PID 再起,⛔ 别以为重起过就是新代码。")
print(" ⇒ 依据 `references/pitfalls.md` P0-17 / P0-9。")
print("\n⇒ %s" % ("已停。⛔ 未动任何别的工作区/别的需求。" if DO else "这是干跑;要真停请加 --yes"))
return 0
def start() -> int:
print("═══ 需求控制台 · 启(%s)═══" % ("真做" if DO else "dry-run"))
g = read_goal()
if not g.get("title"):
print("⛔ goal.json 里没有 title ⇒ 机制不知道围绕什么跑。请先声明目标(不会帮你编一个)。")
return 2
if NAME and NAME != g.get("title"):
print(" (把目标名改成:%s)" % NAME)
if DO:
g["run"], g["run_at"], g["run_by"] = "active", time.strftime("%Y-%m-%dT%H:%M"), "goalctl start"
if NAME:
g["title"] = NAME
print("① 总开关:goal.json run → active")
_write_json(GOAL, g)
else:
print("① 总开关:[dry-run] 会写 goal.json run=active")
cfg = _load(CFG, {}) or {}
if DO:
cfg["wake_enable"] = True
print("② 程序闸:collabd.config.json wake_enable → true")
_write_json(CFG, cfg)
else:
print("② 程序闸:[dry-run] 会写 wake_enable=true")
print("③ 服务层:⛔ 不自启任何常驻(常驻在本机已被判**走不通**:detached 活不过工具调用边界)")
print(" ⚠️ 但**看板是要看的**(用户 2026-10-01:「我说的看板是**实时动态看板**」)——")
print(" 脚本起不了会话后台任务,请在**会话里**跑这一行(本机唯一可行的起法):")
_bp = (Path(Path(_collabd()).parent) / "board.py") if str(_collabd()) else (Path("<技能>") / "scripts" / "board.py")
print(" COLLABD_CONFIG=%s DSH_COLLAB_WS=%s \\" % (str(CFG).replace("\\", "/"), str(WS).replace("\\", "/")))
print(" <python> \"%s\" --serve 8788 --takeover > %s/tmp/board-serve.out.log 2>&1"
% (str(_bp).replace("\\", "/"), str(WS).replace("\\", "/")))
print(" (`--takeover` ⇒ 换代码重启的正规姿势;单实例护栏:已有看板时会拒绝裸起)")
print(" 🔴 常驻(`collabd --supervise`)同理会**跑启动时那份代码** ⇒ 改完代码必须**先停旧 PID 再重起**,")
print(" ⛔ 它不是热加载的(现场:22:06 起的那份跑的是 22:06 内存里的旧代码,日志里还在打已删的探针)。见 P0-17。")
print("④ 排期层:")
_automation_todo("ACTIVE")
print(" 🔴 相位校准:两条唤醒 nextRunAt 应相差约 30 分钟;不对就把其中一条 rrule 重送。")
print("\n⇒ %s" % ("已启。⛔ 未动任何别的工作区/别的需求。" if DO else "这是干跑;要真启请加 --yes"))
return 0
def _opt(name: str, dv: str = "") -> str:
"""读 `--name value` 或 `--name=value`(本脚本只用这两种形态,⛔ 不引 argparse)。"""
a = sys.argv[1:]
for i, x in enumerate(a):
if x == "--" + name:
return a[i + 1] if i + 1 < len(a) else dv
if x.startswith("--" + name + "="):
return x.split("=", 1)[1]
return dv
def _items(s: str) -> list:
"""按逗号切(中英文逗号都吃),**保留原样大小写/空白**(⛔ 不用 `_norm` —— 那是给路径用的)。"""
return [x.strip() for x in str(s or "").replace(",", ",").split(",") if x.strip()]
_SURE = "\x00" # 哨兵:区分「没给这个参数」与「给了空串(=显式清空)」
def declare() -> int:
"""🔴 **把"调用本技能时在对话里说明的目标"登记下来** —— 目标不是固定配置。
用户 2026-10-01 原话(订正我上一轮的做法):
> 「目标是 **通过对话在调用 会话协作skill时说明的,不是固定的**」
我上一轮做的**错事**:把 `goal.json.topics` 按**工作区里的目录名/接续入口**填了 4 类,
还拿去请用户"拍板定死"。⇒ 那是**猜**,不是**说明**。
⇒ 本命令是"说明"的**唯一落点**;⛔ 技能侧不许预设类别、⛔ 不许从文件名推。
用法:
python goalctl.py declare --title "…" [--why "…"] [--topics "A,B"] [--kpi "V1=过;V2=未过"]
python goalctl.py declare --title "…" --topics "" # 显式清空类别(回到单类别回落)
⛔ 默认 **dry-run**;真写加 `--yes`(与 stop/start 同一个语义开关)。
🔴 `--kpi` 的**分隔符**(2026-10-01 22:3x 实测订正,⛔ 别再搞反):
实现是 `str(kpi).replace(";", ";").split(";")` ⇒ **先把中文 `;` 换成 `;` 再切**
⇒ **ASCII `;` 与中文 `;` 都是分隔符**,**两者都不能出现在条目内容里**。
⚠️ 我原先记的是"中文 `;` 会被当内容拼进去" —— **正好是反的**。实测写
`V1=过(… 被杀);_collabd.log 节拍 …` ⇒ 后半截被切成**独立的一条没有 `=` 的判据**、
**静默跳过**(只在 stderr 上印一行「没有 `=` ⇒ 跳过」)⇒ **落库的是被截断的值**,
而且**看起来"已登记"**(rc=0)—— 属于「看着成功、其实半丢」那一族。
✅ **条目内要断句**:用 `,` / `、` / `。` / `——`(这些**不是**分隔符,随便用)。
✅ ⚠️ **两个参数规则不同、别混**:`--topics` 走 `_items()` ⇒ **按逗号切**(中英文逗号都吃);
`--kpi` 走上面那条 ⇒ **按分号切**。
✅ 稳妥姿势:先 `declare`(dry-run,**不加 `--yes`**)跑一遍,**读回显里「验收判据:…」那一行**,
确认条目数与内容都完整,再补 `--yes` 真写。
三条不许静默的规矩:
· `--title` **必填** —— ⛔ 本脚本**不替你编目标**;
· `--topics` 省略 ⇒ **不动**(⛔ 不拿旧值凑数);给了 ⇒ 整表替换,并记 `topics_declared_at/by`;
· `--kpi` 省略 ⇒ **不动** `acceptance_state`;给了 ⇒ 整表重写,且**会点明丢掉了哪些说明行**。
"""
# ⚠️ **没给 `--title` ⇒ 保持现有标题**(⛔ 不回落成 `NAME`=工作区名)。
# 🔴 2026-10-05 实测踩到:旧写法 `_opt("title") or NAME` 在"只想补 `--why`/`--kpi`"
# (即**同一目标在细化**,标题根本没打算动)时,会把标题**悄悄改成工作区名**
# ⇒ 触发换目标闸 ⇒ **把一个纯细化动作拦成"未确认换目标"**(放大成事故)。
# 判据:`--title` 是**显式动作**;没给就是"不动它"(与 `--topics`/`--kpi` 同一规矩)。
# ⚠️ 只有**一个目标都没有**(首次声明)时,才允许回落到 `NAME`。
_t_given = _opt("title", _SURE)
title = (_t_given if _t_given is not _SURE else "") or str(read_goal().get("title") or "") or NAME
why = _opt("why", _SURE)
topics = _opt("topics", _SURE)
kpi = _opt("kpi", _SURE)
by = _opt("by", "对话(调用 session-mechanism 时说明)")
print("═══ 需求控制台 · 声明目标(%s)═══" % ("真做" if DO else "dry-run"))
if not title:
print("⛔ 缺 `--title`。⛔ 本脚本**不替你编目标** —— 目标是**在对话里说明**的。")
print(" 例:python goalctl.py declare --title \"<一句话说清要做成什么>\" --yes")
return 2
g = read_goal()
old_title = str(g.get("title") or "")
print(" 现有目标:%s" % (old_title or "(无)"))
print(" 目标改为:%s" % title)
# 🔴🔴🔴 2026-10-05 **换目标的确认闸 + 旧目标自动归档**(用户口径,逐字:
# 「**一个工作区 同时只能执行一个目标,如果要切换目标,需要用户确认,
# 然后切换和关注检查切换后的目标**」)。
# 🔴 **改前取证**:`declare` **直接覆盖 `title`**、**从不归档旧目标**
# (全文搜 `goals/` 零命中)⇒ 换目标 = 旧目标当场消失,只剩人手写的 `_前身` 一行。
# ⇒ 补两道:
# ① **确认闸**:标题变了 ⇒ **默认拒绝**,必须带 `--switch-goal` 明示"用户已确认切换"
# (⛔ 与 `--yes` 分开:`--yes` 是"干跑转真写",⛔ 不是"用户同意换目标")。
# ② **归档**:确认后把旧 `goal.json` **整份**移到 `goals/<旧标题>__<时间>.json`,
# 并在新目标里留 `_前身目标` 指向归档件(⛔ 不靠人手写)。
# ⚠️ 只改措辞/补 why/补 kpi(标题**没变**)⇒ ⛔ **不触发本闸**(那是同一目标在细化)。
_is_switch = bool(old_title) and old_title != title
if _is_switch and not SWITCH_OK:
print("\n⛔ **这是「切换目标」,需要用户确认 —— 已拒绝写入。**")
print(" 当前目标:%s" % old_title)
print(" 拟换为 :%s" % title)
print()
print(" 用户口径(2026-10-05 定案,逐字):")
print(" 「一个工作区 同时只能执行一个目标,如果要切换目标,**需要用户确认**,")
print(" 然后切换和关注检查切换后的目标」")
print()
print(" ⇒ 请**先向用户确认**「要放弃当前目标、换成新的吗」;")
print(" 得到确认后,在原命令上补 `--switch-goal` 重跑:")
print(" python goalctl.py declare --title \"%s\" --switch-goal --yes" % title[:40])
print(" ⚠️ `--switch-goal` ⛔ **不等于** `--yes` —— 前者是「用户同意换目标」,")
print(" 后者只是「干跑转真写」。⛔ 不确认就带上它 = 伪造用户授权。")
print("\n⇒ 本轮**一个字节都没写**。")
return 3
if _is_switch:
print(" 🔴 换目标已确认(`--switch-goal`)⇒ 旧目标将**整份归档**到 `%s`" % GOALS_DIR)
new = dict(g)
new["title"] = title
# 🔴🔴🔴 2026-10-05 **换目标 ⇒ 自动把 `lifecycle` 复位成「进行中」**(用户定案:
# 「一个工作区 同时只能执行一个目标,如果要切换目标,需要用户确认,**然后切换和关注检查
# 切换后的目标**」)。
# **改前事实**:`declare` **从不碰 `lifecycle`**(全文只在上文末尾打一句
# 「别忘了同步 `lifecycle`(⛔ `declare` 不碰它)」让人手补)。
# **为什么必须自动做**(不是"顺手"):
# ① 旧目标做完了 ⇒ `lifecycle` 停在「已完成」⇒ 换到新目标后**机制照旧不拉**
# (判据见 `collabd.py`:`life != 进行中` 时只在"队列还有非 done 件"才拉)
# ⇒ **新目标没人检查** —— 与用户"切换后要关注检查"的要求**方向相反**。
# ② 靠人手补=**又一条"要记得"的待办**(同族:用户 2026-10-05 原话「最好是记录会话,
# 这样就不用每次会话都去做声明了」——凡"要人记"的都会忘)。
# ⚠️ **不是破坏性动作**:旧 `goal.json` **整份归档**(下面紧跟的归档步),旧值可回查;
# 且只在**已确认换目标**(`--switch-goal`)时做,⛔ 纯细化(只改措辞/补 why/kpi)不触发。
# ⚠️ **只动 `lifecycle` 三件套**,⛔ 不碰 `acceptance_state`(那是上一条目标的判据,
# 另有静默陷阱告警处理;清它会毁证据 —— 见下面那段 `--kpi` 警告)。
if _is_switch:
_old_life = str(g.get("lifecycle") or "").strip()
new["lifecycle"] = "进行中"
new["lifecycle_at"] = time.strftime("%Y-%m-%dT%H:%M:%S")
new["lifecycle_by"] = "goalctl declare --switch-goal(换目标自动复位)"
new["lifecycle_why"] = ("换目标 ⇒ 复位(改前=%s);旧目标整份归档,可在归档件里回查"
% (_old_life or "未声明"))
print(" 🔴 `lifecycle` 自动复位:%s ⇒ 进行中" % (_old_life or "(未声明)"))
print(" 理由:不复位 ⇒ 新目标**不被检查**(机制只在『进行中』时才拉检查会话)")
# 🔴 2026-10-01 加(**静默陷阱告警**):换目标名却不给 `--kpi` ⇒ 上一条目标的验收判据
# 会**原样留着** ⇒ 新目标可能**一声明就被判「全过」**、机制完全不排队不投递(实测踩到:
# 新目标零件零判据,常驻程序却报「目标三路全过」,digest 写「没有可推进的活」)。
# ⛔ 这里**只警告、不擅自清**(清了会让"只想改目标措辞"的人丢掉整表验收判据 —— 那是破坏性动作)。
if (g.get("title") or "") != title and kpi is _SURE:
_oldacc = [k for k in (g.get("acceptance_state") or {}) if not str(k).startswith("_")]
if _oldacc:
print(" 🔴 **注意(静默陷阱)**:你没给 `--kpi` ⇒ 上一条目标的验收判据会**原样留下** ——")
print(" 现存判据:%s" % "、".join(_oldacc))
print(" ⚠️ 新目标可能**一声明就被判『全过』**、机制不排队不投递(判据不绑目标)。")
print(" 要换判据:加 `--kpi \"V1=过;V2=未过\"`(留空表也请显式给 `--kpi \"\"`)。")
if why is not _SURE:
new["why"] = why
print(" 为什么 :%s" % (why or "(清空)"))
if topics is not _SURE:
ts = _items(topics)
print(" 任务类别:%s" % (("、".join(ts)) if ts else "(清空 ⇒ 回落目标简称,单类别)"))
if "_" in topics:
print(" ⚠️ 以 `_` 开头的类别名会被当『说明行』忽略 —— 类别名请取正常词")
new["topics"] = ts
new["topics_declared_at"] = time.strftime("%Y-%m-%dT%H:%M")
new["topics_declared_by"] = by
if kpi is not _SURE:
acc, dropped = {}, []
for seg in [x for x in str(kpi).replace(";", ";").split(";") if x.strip()]:
if "=" not in seg:
print(" ⚠️ 判据 `%s` 没有 `=` ⇒ 跳过(要 `名字=值`;值可写 `pass`,"
"也可写中文 `过`/`已过`/`达` —— 判定层会归一化,⛔ 不是只认 `pass`)"
% seg.strip())
continue
k, v = seg.split("=", 1)
acc[k.strip()] = v.strip()
for k in (g.get("acceptance_state") or {}):
if str(k).startswith("_"):
dropped.append(k)
if dropped:
print(" ⚠️ 重写验收会**丢掉历史说明行**:%s(如需保留,请把它们写进新表)" % "、".join(dropped))
new["acceptance_state"] = acc
_acc_s = "、".join("%s=%s" % (k, v) for k, v in acc.items()) or "(一条都没有 ⇒ 会显式判『未声明』)"
print(" 验收判据:%s" % _acc_s)
if not DO:
print("\n⇒ 这是干跑(⛔ 一个字节都没写)。要真写:末尾加 `--yes`")
if _is_switch:
print(" ⚠️ 本命令是**换目标** ⇒ 真写时还需要 `--switch-goal`(确认闸已过则不必再加)")
return 0
# 🔴🔴 2026-10-05 **旧目标整份归档**(换目标时;⛔ 不许原地覆盖丢掉)。
# 落点:`tmp/supervise-inbox/goals/<旧标题≤40字>__<YYYYMMDD-HHMM>.json`
# ⛔ 文件名净化:去掉 `/ \ : * ? " < > |`(Windows 非法字符)+ 折叠空白。
if _is_switch:
try:
GOALS_DIR.mkdir(parents=True, exist_ok=True)
_safe = re.sub(r'[\\/:*?"<>|\r\n\t]+', "-", old_title).strip()[:40] or "untitled"
_dst = GOALS_DIR / ("%s__%s.json" % (_safe, time.strftime("%Y%m%d-%H%M")))
_n = 1
while _dst.exists():
_dst = GOALS_DIR / ("%s__%s_%d.json" % (_safe, time.strftime("%Y%m%d-%H%M"), _n))
_n += 1
_dst.write_text(json.dumps(g, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8", newline="\n")
new["_前身目标"] = ("%s(已于 %s 换目标时归档至 goals/%s)"
% (old_title, time.strftime("%Y-%m-%dT%H:%M"), _dst.name))
print(" ✅ 旧目标已归档:goals/%s(%d 字节)" % (_dst.name, _dst.stat().st_size))
except Exception as e:
# ⚠️ 归档失败 ⇒ **不阻断换目标**(用户已确认),但必须**喊出来**(⛔ 不许静默)
print(" 🔴 **旧目标归档失败**:%s" % e)
print(" ⚠️ 旧目标**未被保存** —— 若还要它,请先手工备份 `%s`" % GOAL)
new["declared_at"] = time.strftime("%Y-%m-%dT%H:%M")
new["declared_by"] = by
_write_json(GOAL, new)
print("\n⇒ 已登记(落点 `%s`)。" % GOAL)
if _is_switch:
print(" 🔴 **切换完成** —— 从现在起,机制**关注和检查的是新目标**:%s" % title[:60])
print(" ✅ `lifecycle` 已**自动复位为「进行中」**(2026-10-05 起 `declare` 自己管它,⛔ 不用手补)")
print(" ⚠️ 若新目标其实**还没开工**,再手动改成别的:")
print(" python collabd.py --set-life 等待 --by \"主会话\"")
else:
print(" 🔴 目标**不是固定的**:下次调用技能时在对话里再说一遍 ⇒ 用 `declare` 覆盖即可。")
return 0
# ── 统一唤醒入口 ───────────────────────────────────────────────────────
def _collabd() -> Path:
"""常驻程序本体(**技能里的通用代码**,靠 `COLLABD_CONFIG` 指向本工作区配置)。"""
base = Path(os.environ.get("CODEBUDDY_CONFIG_DIR") or os.path.expanduser("~/.workbuddy"))
for p in (Path(os.environ.get("COLLABD_SCRIPT") or ""),
# 🔴 2026-10-01:原 `multi-session-collab` 已并入 `session-mechanism`(本包即主技能)
# ⇒ **新落点优先**;旧落点只作**过渡兜底**(⛔ 别删 —— 包未装/被移走时不至于
# 静默返回空 ⇒ 唤醒入口静默停摆。那正是本文件 docstring 记过的原事故)。
base / "skills" / "session-mechanism" / "scripts" / "collabd.py",
base / "skills" / "multi-session-collab" / "scripts" / "collabd.py"):
if p and p.is_file():
return p
return Path()
# `. deliver=` 字段的读法(值来自 `_deliver_str` / `supervise`)
# ⚠️ 本表必须与 `collabd._deliver_str()` 实际写出的 `skipped=` 值**逐字同款**。⛔ 漏登记不是"少一句话":
# 它会落进下面的兜底分支、被**静默当成「降级自己干」** —— 实测 2026-10-01:`no-follow-session`/
# `follow-not-live`/`target-deaf` 三个新值**一个都没登记** ⇒ 打印一句「未登记的跳过原因」然后**抢锁去干活**。
_WHY = {
"target-busy": ("结束", "⏸ **跟进会话正在执行** ⇒ 不必打扰,⛔ 绝不能降级(降级=同一件事干两遍)"),
"same-item": ("结束", "✓ 同样内容刚投过 ⇒ 不重复"),
"too-soon": ("结束", "✓ 距上次投递太近 ⇒ 不重复(去抖)"),
"locked": ("结束", "→ 另一进程正在投 ⇒ 让给它"),
"disabled": ("结束", "⛔ `wake_enable=false` ⇒ 本需求**已停**(要开:goalctl start --yes)"),
"no-token": ("异常", "🔴 拿不到网关口令 —— 说明**没跑在宿主进程树内**,当前架构下不该发生"),
# 🔴 2026-10-01 新增三类:**没人能收**(缺的是「跟进会话」,⛔ 不是「没人干活」)⇒ 喊用户,⛔ 不降级
"no-follow-session": ("异常", "🔴 本工作区**一条 `[跟进]-<类别>-…` 都没有** ⇒ 上报没人收"
"(常驻程序已写 `NEED-USER.md`)⇒ 按开工清单建一条跟进会话"),
"follow-not-live": ("异常", "🔴 那条跟进会话**此刻不在活会话里**(已关/已换)⇒ 程序拒绝盲投"
"(常驻程序已写 `NEED-USER.md`)"),
# 🔴🔴 2026-10-02 两类改造:**跟进会话整套退役**(`follow_for_topic()` 已短路成这一档)。
# ⛔ 标「需人看」而非「异常」:⛔ **不许再引导去"建跟进会话"**(那条路已不存在),
# ⛔ 也不许降级去抢锁自己干(那会变成"没人派活就自己干",掩盖了"这条没人接"的真状态)。
# 🔴🔴 2026-10-03 **投递段已真删** ⇒ 这一档**生产侧已无来源**(`supervise()` 不再调
# `_deliver_str()`)⇒ 只在**单测**与**将来重建收件人**时才可能读到它。
# ⛔ 保留登记(⛔ 不删):它是"当年这条判据存在过"的机读痕迹,删了将来无法分辨
# 「从没这档」与「把这档删了」。**登记文案按投递退役后的口径改**(旧文案里
# "别等上报"隐含"还有上报可等",⛔ 现在是**根本没有上报**)。
"follow-retired": ("需人看", "⚪ **投递链路已无收件人**(2026-10-02 退役唤醒/跟进;2026-10-03 投递段已真删)"
"⇒ ⛔ **不存在「等通知」这条路**了:要落这件事,**显式建一条任务会话**去读队列干活"),
"target-deaf": ("异常", "🔴 目标跟进会话**已哑**(诊断日志撞 ~10 MiB ⇒ 界面不再刷新)⇒ 投了也看不见,"
"须把它换到一条新会话"),
"no-live-session": ("降级", "🔴 **整机没有任何活会话** ⇒ 没有可投的对象(只有这一档才降级:自己就是那条活的)"),
"no-gateway": ("异常", "🔴 没扫到网关口"),
# ⛔ 下面两个只剩在**已停用**的 `wake_round()`(2026-09-29)里作死代码 ——
# `resolve_main()` 现在**只服务看板读数**,⛔ 不再是投递判读 ⇒ 见到它们就说明**有代码在走旧路径**。
"main-not-live": ("异常", "⚠️ 旧路径的值(主会话不在活会话里)—— 现行投递⛔ 不产出它,请查谁在走旧路径"),
"no-main-session": ("异常", "⚠️ 旧路径的值(解析不出主会话)—— 现行投递⛔ 不产出它,请查谁在走旧路径"),
}
def wake() -> int:
print("═══ 拨闹钟 · 常驻程序判条件并投递(%s)═══" % time.strftime("%H:%M:%S"))
if str((_load(CFG, {}) or {}).get("wake_enable")).lower() == "false":
print("⛔ 程序闸已关(wake_enable=false)⇒ 不投递,直接结束。要开:goalctl start --yes")
return 0
cb = _collabd()
if not cb.is_file():
print("⛔ 找不到常驻程序 collabd.py(⛔ 不猜路径;可用 COLLABD_SCRIPT 指定)")
return 3
env = dict(os.environ)
env["COLLABD_CONFIG"] = str(CFG)
try:
r = subprocess.run([sys.executable, str(cb), "--tick"], cwd=str(WS), env=env,
capture_output=True, text=True, timeout=180, errors="replace",
creationflags=0x08000000) # CREATE_NO_WINDOW:⛔ 闪窗
except Exception as e:
print("⛔ 调常驻程序失败:%s" % e)
return 3
out = (r.stdout or "") + (("\n" + r.stderr) if r.stderr else "")
line = next((l for l in out.splitlines() if l.startswith("tick:")), "")
if not line:
print("⚠️ 常驻程序没给出 `tick:` 结论(rc=%s)⇒ 按「没投出去」处理(⛔ 不假装成功)" % r.returncode)
print("\n".join(" " + x for x in out.strip().splitlines()[:8]))
return 1
f = dict(x.split("=", 1) for x in line[6:].split(" ") if "=" in x)
dv = f.get("deliver", "-")
print(" 常驻程序结论:%s" % line)
print()
if dv.startswith("http"):
code = dv.split("=", 1)[-1]
print("✅ 判定:**已投递**(http %s)⇒ 主会话已被唤醒。" % code)
print("⇒ 本轮**就此结束**:⛔ 不抢域锁、⛔ 不派活、⛔ 不起新会话(唤醒已由常驻程序完成)。")
return 0
if dv == "-":
print("✓ 判定:**没有需要投的东西**(队列无新变化 / 无停滞)⇒ 静默。")
print("⇒ 本轮**就此结束**(这就是「符合条件时才唤」的否定面 —— 零打扰)。")
return 0
# ⚠️ 兜底=**需人看**,⛔ 不是降级:判读表漏登记时**绝不许静默抢锁去干活**(2026-10-01 实测踩过)。
kind, why = _WHY.get(dv, ("异常", "🔴 **未登记的跳过原因 `%s`** —— 投递判读表漏登记(⛔ 不是「没人干活」)" % dv))
print("◻ 判定:**未投出**(%s)—— %s" % (dv, why))
if kind == "结束":
print("⇒ 本轮**就此结束**:⛔ 不抢域锁、⛔ 不派活。")
return 0
if kind == "异常":
print("⇒ 🔴 **需人看**:这不是正常路径(详见 tmp/supervise-inbox/NEED-USER.md)。本轮结束。")
return 2
print("⇒ **降级**:没有可唤醒的对象 ⇒ 你(本次自动任务起的会话)**就是那个会话**。")
print(" 请抢域锁后按状态表干活:")
print(" bash D:/github/dsh_shenxian/dsh-server-docs/07-scripts/handoff-guard.sh "
"--claim-exec \"<你的会话名>\" --domains ai1net-dsh-server")
return 10
if __name__ == "__main__":
if CMD in ("status", "st", "s"):
sys.exit(status())
if CMD in ("wake", "wk", "tick"):
sys.exit(wake())
if CMD in ("stop", "down"):
sys.exit(stop())
if CMD in ("start", "up"):
sys.exit(start())
if CMD in ("declare", "set", "goal"):
sys.exit(declare())
print(__doc__.split("## 用法")[1].split("⚠️")[0].strip())
sys.exit(0)