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你看呢
   这类不带选项的待定清单,一律按待拍板项写:问题+说明+各候选优缺点+倾向)
This commit is contained in:
admin committed 2026-10-06 22:27:03 +08:00
1 parent 19101acd65
commit 64dd82073b
21 files changed
+8444 -4523

No files matched your search

+210 -64
View File
@@ -710,45 +710,17 @@ def ensure_supervise(why: str = "", verify: bool = True) -> dict:
# ── 🔴🔴 常驻**自我供给**:就地起撑不住时,转去建本区的计划任务 ──────────────
def _find_keeper_tpl():
"""找 `start-supervise.ps1.tpl`(⛔ 返回 None = 找不到)。**两种布局都要认。**
# 🔴🔴 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 历史里翻回来。
🔴🔴 2026-10-04 修(**副本里必红的 bug**,实测踩到):
本函数原来写死 `Path(__file__).resolve().parent.parent / "assets"`,
在**技能目录**里没问题(`<pkg>/scripts/collabd.py` ⇒ `.parent.parent` = `<pkg>`),
但**工作区副本**是 `.workbuddy/collab/collabd.py` ⇒ `.parent.parent` =
`<WS>/.workbuddy` ⇒ 去找 `<WS>/.workbuddy/assets/`(**不存在**)
⇒ 每个工作区的「自我建立常驻」**必然失败**,而报错还写着"缺模板"
—— 看着像仓库少文件,其实只是**算错了包根**。
(实测:vibe-product 副本返回「⛔ 缺模板」;而它旁边
`.workbuddy/skills/session-mechanism/assets/` 里模板**好好在**。)
认三处(按优先级):
① 同目录的 `<pkg>/assets/`(技能目录/包树里的 scripts/ 布局)
② 从 `WS` 推的 **技能副本** `<WS>/.workbuddy/skills/session-mechanism/assets/`
—— 副本形态的正解(deploy 只分发 `collabd.py`,模板跟着技能副本走)
③ 代码默认的技能目录 `<CFGDIR>/skills/session-mechanism/assets/`
—— 前两处都缺时的兜底(⛔ 不写死作者机器路径)
"""
_cands = []
try:
_cands.append(Path(__file__).resolve().parent.parent / "assets" / "start-supervise.ps1.tpl")
except Exception:
pass
try:
_cands.append(Path(WS) / ".workbuddy" / "skills" / "session-mechanism"
/ "assets" / "start-supervise.ps1.tpl")
except Exception:
pass
try:
_cfg = Path(os.environ.get("CODEBUDDY_CONFIG_DIR") or (Path.home() / ".workbuddy"))
_cands.append(_cfg / "skills" / "session-mechanism" / "assets" / "start-supervise.ps1.tpl")
except Exception:
pass
for _c in _cands:
if _c.is_file():
return _c
return None
def _escalate_to_keeper(detail: str = "") -> str:
@@ -1414,11 +1386,11 @@ def queue_view(T: dict, d: dict | None = None) -> dict:
" ⚠️ 旧版这行原写「SessionEnd 会自动放行」—— **那是一句假话**,照着做就会卡住)。\n"
"7. 🔴 **收尾=自己判本线缺口并接上**(顶层设计 §4「收尾自判」· 定稿 §4.2 必含条款):\n"
" ⚠️ **派活命名规范**:新棒的自动化名必须用**两级前缀** `[协作]-[<类别>]-<具体>`\n"
" ⚠️ **派活命名规范**:新棒的自动化名必须用**两级前缀** `[执行]-[<类别>]-<具体>`\n"
" —— **第 2 级要带方括号**,值取 `goal.json` 的 **`topics`(任务类别)**,⛔ **不是 `short`**。\n"
" ⚠️ 漏方括号(写成 `[协作]-<类别>-…`)**仍能归属**(归属判据是「角色+类别**子串**」,\n"
" ⚠️ 漏方括号(写成 `[执行]-<类别>-…`)**仍能归属**(归属判据是「角色+类别**子串**」,\n"
" 2026-10-01 放宽),但 **`parse_session_name().ok=False`** ⇒ 报「没按约定命名」。\n"
" ⛔ 真会**静默漏管**的是**值写错**:用 `short`/昵称(`[协作]-本机协作-…`)⇒ 标题里\n"
" ⛔ 真会**静默漏管**的是**值写错**:用 `short`/昵称(`[执行]-本机协作-…`)⇒ 标题里\n"
" 根本没有类别名 ⇒ `_in_project()` 认不出 ⇒ 看板不列它、`--ready-next` 也不算它在跑。\n"
" 🔴 **接续退位(2026-10-01 加)**:你**建出下一棒之后**立刻执行一次\n"
" `collabd.py --retire self --by <接棒会话id> --why 接续`\n"
@@ -1759,10 +1731,18 @@ EXEC_DOC_NAME = "目标执行状态.md"
# + `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 非法字符,⛔ 防拼出打不开的路径。
@@ -1772,7 +1752,50 @@ def goal_dir_name(title: str, short: str = "") -> str:
# ⚠️ 空格也去掉:目录名带空格 ⇒ 命令行里处处要加引号,⛔ 任务会话最容易在这里踩空。
t = re.sub(r"\s+", "", t)[:20].strip(" .-") or "未命名目标"
h = hashlib.sha1(str(title or t).encode("utf-8")).hexdigest()[:6]
return "%s%s-%s" % (_GOAL_DIR_PREFIX, t, h)
# 🔴 单一真源:父层只在这一个地方拼,⛔ 别在调用方各拼一遍(同族 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:
@@ -1866,7 +1889,7 @@ def _ensure_goal_dir() -> dict:
_doc.write_text(
"# 目标执行状态\n\n"
"> 🔴 本文件=**检查会话判断目标状态的唯一依据**(⛔ 别再去工作区翻文件)。\n"
"> 由**目标检查会话**(`[协作]-[目标检查]-…`)在核对后更新。\n\n"
"> 由**目标检查会话**(`[执行]-[目标检查]-…`)在核对后更新。\n\n"
"## 二、验收判据(值是中文写法:`过|…`/`🔴 不过|…`)\n"
"(⛔ 暂无判据 ⇒ 目标状态**判不出来**,不许当成已完成)\n\n"
"## 三、结论\n"
@@ -1877,6 +1900,46 @@ def _ensure_goal_dir() -> dict:
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` 由宿主注入)⇒ ⛔ 不需要用户每次开口声明。
@@ -1911,7 +1974,9 @@ def _ensure_goal_dir() -> dict:
def goal_dir_rel() -> str:
"""🔴 目标目录的**相对工作区**路径(真源=`goal.json` 的 `title`/`short`,⛔ 不猜)。
⚠️ 读不到 `goal.json` ⇒ 回落成 `目标-未命名目标-<空标题哈希>`(**确定性**)
形态=`执行会话/目标-<简称或标题前缀>-<短哈希>`(2026-10-05 用户定案加 `执行会话/` 一层)。
⚠️ 读不到 `goal.json` ⇒ 回落成 `执行会话/目标-未命名目标-<空标题哈希>`(**确定性**)
⇒ ⛔ 不会每次调用都变个名(那会让产物散成一堆目录)。
⚠️ **不 mkdir**:⛔ 定位与建目录分开(读路径的代码⛔ 不该有副作用)。
"""
@@ -1941,11 +2006,13 @@ def goal_dir_rel() -> str:
# | 文档 | 谁写 | 什么时候写 | 谁读 |
# |---|---|---|---|
# | `<目标目录>/目标执行状态.md` | **目标检查会话** | 核对完目标状态后 | 下一个检查会话/人 |
# | `<目标目录>/<棒次>_<事项>_<日期>.md` | **任务会话(协作棒)自己** | 本轮做完时(=`--report done` 之前) | 检查会话照 `artifact` 读 |
# | `<目标目录>/<棒次>_<事项>_<日期>.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 = (
@@ -2176,6 +2243,14 @@ def task_report(tid: str, state: str, by: str = "", artifact: str = "", reason:
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:
@@ -3130,7 +3205,7 @@ def _in_project(sid: str, title: str, main_sid, short) -> bool:
# ⇒ 看板画空框「暂无协作会话」,而那条会话**正在 working**。
# 本函数还有**第二个受害者**(更严重,因为它会改行为而不只是画错):
# `ready_next()` 的判据①「除主会话外没有别的会话在 working」走的**就是本函数**
# ⇒ 在跑的协作棒判 `False` ⇒ 落进 `ignored_others` 而不是 `working_others`
# ⇒ 在跑的执行棒判 `False` ⇒ 落进 `ignored_others` 而不是 `working_others`
# ⇒ `--ready-next` 会**说"可以排下一棒"** —— 正是用户 2026-09-30 描述的那个病:
# 「5-8 应该是之前出现**前面还没执行完 就开始下一棒了**」。
# ⇒ 判据统一成**两条并列**(任一命中即算):
@@ -3259,7 +3334,7 @@ def goal_line() -> str:
🔴 2026-09-30 改:显示**任务类别清单**(`goal.topics`)而不是单个 `short` ——
用户要求「主会话根据任务**自动梳理任务类别**,通过**协作会话名称前缀**区分具体执行会话」
⇒ 这一行是**每轮钩子注入**给主会话的,必须把"本工作区里有哪几个类别"直接摆出来,
主会话才好据此给新棒命名(`[协作]-<类别>-<具体>`)并解析"这活属于哪一类"。
主会话才好据此给新棒命名(`[执行]-<类别>-<具体>`)并解析"这活属于哪一类"。
"""
g = load_goal()
if not g:
@@ -3655,6 +3730,49 @@ def local_user_id() -> str:
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:
@@ -3926,6 +4044,8 @@ def create_check_schedule(name: str, prompt: str, delay_s: int = 90,
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)
c = sqlite3.connect(_wb_db(), timeout=15)
try:
c.execute("PRAGMA busy_timeout=8000")
@@ -3938,13 +4058,15 @@ def create_check_schedule(name: str, prompt: str, delay_s: int = 90,
(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,
"space-bunny", 0, "[]", "[]", local_user_id(), "confirmed"),
_model, _thinking, "[]", "[]", local_user_id(), "confirmed"),
)
c.commit()
finally:
c.close()
log("已建检查会话排期 name=%s id=%s fire=+%ds" % (name, aid[:8], delay_s))
return {"id": aid, "name": name, "fire_at": fire.strftime("%H:%M:%S"), "delay_s": delay_s}
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}
# 已建的检查会话排期(去抖 + 看板读数)
@@ -4060,8 +4182,8 @@ CHECK_COOLDOWN_S = 30 * 60
# 🔴 域门禁 + 硬约束这尾节**两类完全相同** —— ⛔ 但**逐字抄两遍**:
# ⛔ 不抽成一个变量再拼装(一旦共用,又会"改一处漏另一处")。
_CHECK_TAIL_A = """
## %s、🔴🔴 派协作棒时**必须带上独立域门禁**
- 每条新协作棒都要有**自己的域目录**:`<工作区>/<域名>/` —— 🔴⛔ **直接占工作区下的第一层目录**,
## %s、🔴🔴 派执行棒时**必须带上独立域门禁**
- 每条新执行棒都要有**自己的域目录**:`<工作区>/<域名>/` —— 🔴⛔ **直接占工作区下的第一层目录**,
**⛔ 不许套 `domains/` 这种公共父目录**(实测那样所有子目录会算出**同一个域键** ⇒ 并行直接失效)。
- 🔴 **这两条命令的本体是 `collabd.py`,不是工作区目录**(2026-10-03 实测:
写 `python "<工作区目录>" --domain-status` 会报 `can't find '__main__' module`
@@ -4142,7 +4264,7 @@ CHECK_PROMPT_RESULT = (
"4. 🔴 `%s` —— **目标**。⛔ **只读这四个字段**,别全文件翻:\n"
" · `lifecycle` = 目标生命周期(`进行中`/`已完成`/`等待`)\n"
" · `acceptance_state` = **验收判据**(🔴 判断目标达成与否就看它)\n"
" · `topics` = 任务类别(派棒时放进 `[协作]-<类别>-…`)\n"
" · `topics` = 任务类别(派棒时放进 `[执行]-<类别>-…`)\n"
" · `title` = 目标标题\n"
"5. `python \"%s\" --domain-status` —— **看域占用**(派棒前必跑,⛔ 跳过就等于没门禁)\n"
"\n"
@@ -4157,7 +4279,7 @@ CHECK_PROMPT_RESULT = (
" - `blocked`:读它的 `block_reason`;能解 ⇒ 派一条任务会话去解;\n"
" ⛔ 解不了 ⇒ **写明阻碍并停手**,⛔ 别硬派一条注定干等的活;\n"
" - 还 `pending`/`running` 但**没有会话在跑** ⇒ 它**没被认领** ⇒ 派一条接手。\n"
"2. **派活**:能派 ⇒ 建一条 `[协作]-<类别>-<具体>` 排期接手。\n"
"2. **派活**:能派 ⇒ 建一条 `[执行]-<类别>-<具体>` 排期接手。\n"
"3. ⛔ **本轮你没有「改目标状态」的出口** —— 那是「目标检查会话」的活。\n"
" 即使你判断目标其实已经做完了,也**只写明「疑似已完成」并停手**,⛔ 不要自己去改 `lifecycle`。\n"
+ _CHECK_TAIL_A + _CHECK_TAIL_B
@@ -4186,7 +4308,7 @@ CHECK_PROMPT_GOAL = (
" · `execution_doc` = 🔴🔴 **目标执行状态文档的路径**(下面第 5 项)\n"
" · `lifecycle` = 目标生命周期(本轮**你有权改它**,见三.3)\n"
" · `acceptance_state` = 验收判据的**机读副本**(⚠️ 可能与文档不同步 ⇒ **以文档为准**)\n"
" · `topics` = 任务类别(派棒时放进 `[协作]-<类别>-…`)\n"
" · `topics` = 任务类别(派棒时放进 `[执行]-<类别>-…`)\n"
"5. 🔴🔴 `%s` —— **目标执行状态文档(本轮判断目标状态的唯一依据)**\n"
" ⛔ **别再去工作区里翻文件找目标状态**(用户 2026-10-03 实测:到处找信息\n"
" 会被宿主判成**重复执行、要求人工确认**)。**读这一份就够了。**\n"
@@ -4203,7 +4325,7 @@ CHECK_PROMPT_GOAL = (
" 前两路只说明「活干完了吗」,⛔ **不等于**「目标达成了吗」。\n"
" ⚠️ 判据值是**中文写法**:`过|…`/`🔴 不过|…`/`待重验(…)` —— ⛔ 别拿「不等于 `pass`」判未过。\n"
" ⚠️ 一条**有效判据都没有**(只有 `_` 开头的说明行)⇒ **判不出来,⛔ 不许当成已完成**。\n"
"2. **没做完** ⇒ 建一条 `[协作]-<类别>-<具体>` 排期接着做。\n"
"2. **没做完** ⇒ 建一条 `[执行]-<类别>-<具体>` 排期接着做。\n"
"3. **做完了** ⇒ 用 `%s --set-life 已完成 --by \"<你的会话名>\"` 把目标状态改成「已完成」,\n"
" ⛔ 不留「进行中」空转。\n"
" 🔴 **2026-10-03 修(实测坐实)**:早前这里写的是 `goalctl.py set-life已完成`,⛔ **该命令根本不存在** ——\n"
@@ -4593,7 +4715,7 @@ def maybe_spawn_check_agent(reason: str) -> dict |None:
# 写成 `[协作]-结果检查-…`(第二段无括号)⇒ `topic=""`、`ok=False` ⇒ 类别丢失。
# 现网两种正确形态可对照:`[协作]-[机制排查与修复]-S13 …`(协作)、
# `主控 · 协作机制 · …`(主会话,走另一条 `main-prefix` 分支)。
# 检查会话是**任务会话**(它自己会派协作棒)⇒ 角色=`协作`、类别=检查种类。
# 检查会话是**任务会话**(它自己会派执行棒)⇒ 角色=`执行`、类别=检查种类。
# ⚠️ 类别用 `CHECK_KINDS[reason][1]`(`结果检查`/`目标检查`)⇒ 两种在标题上就分得开,
# ⛔ 不合并成一个 `[协作]-[检查]-…`(那样看板/台账分不出是哪一种检查)。
# 🔴🔴 2026-10-03 11:1x **改用独立角色前缀 `[检查]`**(用户纠正逐字:
@@ -4712,7 +4834,7 @@ def is_check_agent(name: str) -> bool:
"""🔴 标题是不是一条**检查会话**(**协作程序**建的那种)。
🔴 **为什么它必须是 worker、且必须认得出来**(2026-10-03 10:2x 实测):
检查会话由**协作程序**建(`maybe_spawn_check_agent()`),它会**自己派协作棒** ⇒ 它是干活的那条。
检查会话由**常驻程序**建(`maybe_spawn_check_agent()`),它会**自己派执行棒** ⇒ 它是干活的那条。
但 10:1x 之前建的都用了**旧式光杆名**(`结果检查-…`,无方括号)⇒ 解析层两个分支都不命中
⇒ `role=""` ⇒ `_scan_mains()` 的排除元组只排 `"worker"` ⇒ **`""` 通过**
⇒ 它被**收进主会话候选** ⇒ 看板 `role` 显示**「主会话」**(实测 `44b547d4` 就是)。
@@ -4807,7 +4929,7 @@ def parse_session_name(name: str) -> dict:
# 🆕 ④ 主控前缀(实测现役主会话标题就是 `主控 · 协作机制 · …`)
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**(用户报障连带查出的真缺陷)。
# ⚠️ **位置必须在「无方括号」这个早退分支里**(我第一版误加在下面的方括号分支
@@ -4859,10 +4981,17 @@ def parse_session_name(name: str) -> dict:
# ⇒ 删掉映射 ⇒ 它们全部解析成 `""` ⇒ **看板画不出、派活漏管**,且**不可逆**。
# ⇒ **三个前缀同映射到 `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:
# 🆕 ③ `[<类别>] 接续 · …` —— 第一对方括号里装的是**任务类别**(⛔ 不是角色):
@@ -5007,7 +5136,7 @@ def _topic_in_title(title: str, topics=None) -> str:
"""标题里出现的**任务类别**(🔴 **最长优先** —— 防止"唤醒机制"被"机制"这种短名抢走)。
⚠️ 判据是**子串**(不要求 `[...]` 包裹)—— 因为用户给主会话的命名是口语式的
(「主控 · 唤醒机制」没有方括号),而协作棒是严格的 `[协作]-[类别]-<具体>`。
(「主控 · 唤醒机制」没有方括号),而执行棒是严格的 `[执行]-[类别]-<具体>`。
两种写法都要能认出来 ⇒ 统一用"标题里出现该类别名"。
⛔ 都不命中 ⇒ 空串(="这条会话没标类别" ⇒ 调用方按**默认类别**处理,⛔ 不猜)。
"""
@@ -5047,7 +5176,7 @@ def _scan_mains(st: dict, live=None) -> dict:
🔴 2026-10-01 加参数 `live`(各网关口的活会话集合):**活着的「主控」排在不活的「主控」前面**
(⛔ 传 None ⇒ 行为与旧版逐字一致,`selftest` 的离线用例不受影响)。
⛔ **排除全部"协作侧"命名**(🔴 2026-10-01 扩):`[协作]-…`(协作棒)、`[唤醒]-…`(唤醒会话)、
⛔ **排除全部"协作侧"命名**(🔴 2026-10-01 扩):`[执行]-…`(执行棒)、`[唤醒]-…`(唤醒会话)、
以及**接续会话**(`[<类别>] 接续 · …` / `接续棒:…`)—— 三者都是**干活的棒**,⛔ 不是主会话候选。
⚠️ 旧版只排 `[协作]` ⇒ **接续会话会被收成主会话候选**(实测:`[唤醒机制] 接续 · …` 又被
`_topic_in_title()` 认出类别 ⇒ 直接变成"该类别的主会话")⇒ 通知投给它自己、真主会话被架空。
@@ -5381,7 +5510,20 @@ 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 "")
_zh = {"follow": "跟进", "worker": "协作", "waker": "唤醒"}[role]
# 🔴🔴 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)
@@ -5553,7 +5695,7 @@ def resolve_main(st: dict, live_sids=None) -> dict:
本函数把"主会话"从**登记出来的**改成**解析出来的**:
① 登记为 main 且**此刻确实活着** ⇒ 认(登记有效)
② 登记失效(不在活会话里)⇒ 按**工作区**解析:`cwd == 本工作区` 且**不是协作棒**(标题不带 `[协作]`)
② 登记失效(不在活会话里)⇒ 按**工作区**解析:`cwd == 本工作区` 且**不是执行棒**(标题不带 `[执行]`)
⇒ 取最近活动的那条(用户 2026-09-30:「以**一个工作区**为主会话的工作区」)
③ 还是解析不出 ⇒ `sid=""` ⇒ **调用方⛔ 不许盲投**,必须明确报"找不到主会话"
@returns `{sid, source, switched_from}`;`switched_from` 非空=**换了**(调用方须跟随+告警)
@@ -5587,7 +5729,7 @@ def resolve_main(st: dict, live_sids=None) -> dict:
# ⚠️ **不能加 `status='working'`**:主会话在两轮之间是空闲(不是 working)⇒ 加了就**永远漏掉它**。
# ⚠️ **也不能按 `is_background_automation` 排**(2026-09-30 实测踩到):**接续会话本身就是自动化起的**
# —— 连当前这位主会话 `fe146dd9` 都是 `is_background_automation=1` ⇒ 那么排会把**真主会话也排掉**。
# 只按**显式标记**排除"棒":标题带 `[协作]`(那是本机制给协作棒定下的前缀,见 SKILL.md 命名)。
# 只按**显式标记**排除"棒":标题带 `[协作]`(那是本机制给执行棒定下的前缀,见 SKILL.md 命名)。
sc = _scan_mains(st, live) # 🔴 与 `resolve_mains()` **共用同一遍扫描**(⛔ 不各扫各的=防两边漂移)
named, cand = sc["named"], sc["cand"]
# 优先认**显式标了 `主控`** 的那条 —— 它比"最近活动"可信得多(用户自己标的,不是猜的)
@@ -6132,6 +6274,10 @@ def main() -> int:
# 🔴 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()