派活触发句收工闸:说了就必须把执行会话拉起来(实测栽过,补机器抓手)

一、起因(用户令逐字)
「重点问题是 会话接收到 调用执行会话完成目标 没有去调用对应技能去执行」。
实测会话 b232218f(vibe-product):用户 01:32:00 / 01:33:46 两次说触发句,
到 01:36:27 才建出排期 —— 中间那一轮 AI 只在回复里解释口径,没拉起执行会话。
根因:机制里**只有 "注入提醒"**,⛔ 没有任何机器抓手检查"到底做没做"。

二、改法(发现 → 拦住,两步)
1. scripts/hooks/skill-load-guard.py:命中**派活族**触发句时写一条待办
   <工作区>/.workbuddy/.dispatch-pending(会话 id / 时间戳 / 命中词)。
   ⚠️ 写在**冷却判定之前** —— 实测 01:33:46 那条最明确的触发句正被 300 s 冷却吞掉,
     写在冷却之后就等于"最该拦的那一条反而没有标记"。
   ⚠️ 比对前**去掉空白**:用户写的是「应该使用 执行会话的技能去完成工作」(中间有空格)。
   ⛔ 不把裸词「执行会话」算进派活族(太宽,讨论机制时也出现)⇒ 误报面保持最小。
2. scripts/hooks/stop-dialog-guard.py:新增收工前闸 —— 同会话 + 超 90 s 宽限 +
   宿主库该区此后**没有**新建排期 ⇒ 返回 {"continue": false} 拦一次,逼它先建排期。
   ⚠️ 必须放在**自作用域判定之前**(那条作用域只认本工作区,而栽的会话在 vibe-product)。
   ⛔ 三条安全底线:① 全程 fail-open(读不到库/解析失败/任何异常 ⇒ 一律放行);
     ② 只拦一次(复用本文件 10 分钟限流);③ 待办 30 分钟过期,⛔ 不长期纠缠。

三、判据(新增自测用例,5 项)
selftest.py::t_dispatch_gate —— 派活句留待办(含带空格写法)/非派活句不留/
超宽限且未建排期则拦/宽限内放行。三条断言都确定性,⛔ 不依赖现网存量。

验收:selftest.py rc=0 PASS 100 / FAIL 0(改前 99);
两组外部变异对照(发现侧 3 项、收工闸 4 项)全过;
另实测"10 分钟限流"有效(同一会话重复构造不再重复拦)。
This commit is contained in:
admin committed 2026-10-07 01:47:38 +08:00
1 parent f2691e9368
commit 9a665039a9
4 files changed
+164 -4

No files matched your search

+4 -4
View File
@@ -1,6 +1,6 @@
# manifest · 包内文件清单
> 生成方式:逐文件 `compile()` / `json.loads` + md5 | **最近一次全量重算:2026-10-07 01:26 (移入随包子技能 karpathy-output-ladder(全局 skills 原件已移出)+ 索引登记)**
> 生成方式:逐文件 `compile()` / `json.loads` + md5 | **最近一次全量重算:2026-10-07 01:47 (派活触发句收工闸:触发句留待办 + Stop 拦一次(含新判据))**
> ⚠️ **2026-10-05 局部增量**:`references/pitfalls.md`(P0-77 拆条 + P0-73/P0-77 压缩)与
> `scripts/goalctl.py`(`--switch-goal` 确认闸 + 旧目标归档)两行的 md5/大小已按当天实测值更新;**其余行仍是 10-04 基线**。
> ⛔ 本表**不含** `install.log`(运行日志)与 `references/manifest.md`(自引用,写完即失真)。
@@ -165,8 +165,8 @@
| `scripts/hooks/prompt-guards.py` | 9332 | `0aa1d1fdd2bf5a40cda2e60d7a785534` | ok |
| `scripts/hooks/reply-style-guard.py` | 11040 | `7e271b342f91e2e98cbc03631b7e68f3` | ok |
| `scripts/hooks/session-log-guard.py` | 24636 | `4dd13b24d541d1afc7b4874dba6bb1eb` | ok |
| `scripts/hooks/skill-load-guard.py` | 24390 | `942bf35a6b58a3634d1baa6717a06a5c` | ok |
| `scripts/hooks/stop-dialog-guard.py` | 41513 | `50374b192b6eba2bdb257846f06fde90` | ok |
| `scripts/hooks/skill-load-guard.py` | 26499 | `f04b4ffdcdbd7f292bad83cbd77e0a46` | ok |
| `scripts/hooks/stop-dialog-guard.py` | 46083 | `fab654dfacfeb6253fc751707bebcac6` | ok |
| `scripts/hooks/supervise-ensure-hook.py` | 14308 | `dc6f62584a8384baba0a1f2460828013` | ok |
| `scripts/hooks/wb-result-hook.py` | 37199 | `2d4f694acd031222bf03b98a859b6e90` | ok |
| `scripts/init_workspace.py` | 24042 | `dda53acd24bcc66d509bbdab3ebc0488` | ok |
@@ -177,7 +177,7 @@
| `scripts/lock/op-lock.sh` | 4690 | `1a31eda3642e23693a65e1519860c744` | — |
| `scripts/lock/preflight-lock.sh` | 8673 | `e985ec1cb853fae4c351cd03b171355d` | — |
| `scripts/mut_run.py` | 13323 | `3bffc12e1e57d5efc743bac55e584b62` | ok |
| `scripts/selftest.py` | 481274 | `995f1ec20fb14bacb10a2c38f114d9e2` | ok |
| `scripts/selftest.py` | 484110 | `f9125fcc8ba2751c41f1fe77afd51739` | ok |
| `scripts/session-rules-check.py` | 44412 | `b2b468992d80421106316eaf451e2477` | ok |
| `scripts/stop-collab.py` | 10462 | `d8043bedd23b52b6642aaf7ec8b8c980` | ok |
| `scripts/supervise-launch.py` | 2503 | `f416a2f197e5524a8859c1d227ba1d05` | ok |
@@ -111,6 +111,14 @@ STATE_REL = os.path.join('.workbuddy', '.skill-load-guard.state')
DISABLE_REL = os.path.join('.workbuddy', 'skill-guard.disabled')
COOLDOWN = 300 # 同会话注入冷却(秒)
# 🔴🔴 2026-10-07 加「派活待办」:命中**派活族**触发句 ⇒ 留一条机器可读的待办,
# 交给 `stop-dialog-guard.py` 在**收工前拦一次**。
# 起因(用户令逐字):「**重点问题是 会话接收到 调用执行会话完成目标 没有去调用对应技能去执行**」——
# 本钩子原来**只注入提醒**,AI 照不照做没人管;实测会话 `b232218f` 里用户连说三遍,一次都没拉起执行会话。
DISPATCH_REL = os.path.join('.workbuddy', '.dispatch-pending')
_DISPATCH_WORDS = ('使用执行会话', '使用任务会话', '执行会话完成', '任务会话完成',
'调用执行会话', '调用任务会话', '派任务会话', '建任务会话')
# 用户「点名方法 / 点名规则」的词表 —— 命中任一即注入。
# ── 分组 1:点名「决策方法」(2026-09-16 立,最稳的一条)─────────────────────
# ── 分组 2:点名「通用作业规则」(2026-09-22 用户拍板 B 后新增)─────────────────
@@ -233,6 +241,27 @@ def _log(workdir, line):
pass
def _mark_dispatch(workdir, sid, prompt, hits):
"""命中「派活族」触发句 ⇒ 写一条待办(TSV:会话 id / 时间戳 / 命中词)。
⚠️ 调用点必须在**冷却判定之前** —— 实测 01:33:46 那条最明确的触发句正被 300 s 冷却吞掉,
写在冷却之后就等于"最该拦的那一条反而没有标记"。
⚠️ 比对前**去掉空白**:实测用户写的是「**应该使用 执行会话的技能去完成工作**」(中间有空格),
按原样比就漏了。⛔ 但**不把裸词「执行会话」算进派活族**(它太宽,讨论机制时也会出现)⇒ 误报面保持最小。
⛔ 全程 fail-open:任何异常只吞掉,绝不影响本钩子的正常注入。
"""
try:
seg = re.sub(r'\s+', '', prompt or '')
if not any(w in seg for w in _DISPATCH_WORDS):
return
with io.open(os.path.join(_norm_path(workdir), DISPATCH_REL),
'w', encoding='utf-8', newline='\n') as f:
f.write('%s\t%.3f\t%s\n' % (sid, time.time(), ','.join(hits)))
_log(workdir, 'DISPATCH_PENDING sid=%s hits=%s' % (sid[:8], ','.join(hits)))
except Exception:
pass
def _cooling(workdir, sid):
"""同一会话 COOLDOWN 秒内不重复注入。"""
try:
@@ -287,6 +316,9 @@ def main():
if not hits:
return
# 🔴 先留待办(⛔ 必须在冷却之前,见 `_mark_dispatch` 注释)
_mark_dispatch(workdir, sid, prompt, hits)
if _cooling(workdir, sid):
return
@@ -644,6 +644,72 @@ def user_prompt_mode(payload):
'additionalContext': ctx}})
# ── 🔴🔴 2026-10-07 「派活没收工」闸 ──────────────────────────────────────────
# 起因(用户令逐字):「**重点问题是 会话接收到 调用执行会话完成目标 没有去调用对应技能去执行**」。
# 实测(会话 `b232218f`,vibe-product):用户 01:32:00 / 01:33:46 两次说了触发句,
# 直到 01:36:27 才建出排期 —— 中间那一轮 AI 只在回复里解释口径,**没拉起执行会话**。
# 形态:`skill-load-guard.py` 命中**派活族**时写 `<ws>/.workbuddy/.dispatch-pending`
# (会话 id / 时间戳 / 命中词),本闸在**收工前**核对一次 ——
# **同会话 + 未过期 + 宿主库里没有该区在标记之后新建的排期** ⇒ 拦一次,逼它把会话拉起来。
# ⛔ 三条安全底线(一条都不许省):
# ① **全程 fail-open** —— 读不到库 / 解析失败 / 任何异常 ⇒ 一律放行(⛔ 钩子自身故障不许挡住用户);
# ② **只拦一次** —— 复用本文件的 10 分钟限流(`_rate_limited`);
# ③ **待办 30 分钟过期** ⇒ ⛔ 不长期纠缠。
DISPATCH_REL = os.path.join('.workbuddy', '.dispatch-pending')
DISPATCH_GRACE = 90 # 命中后给 90 秒去建排期(⛔ 不拦"刚说完、还没轮到")
DISPATCH_EXPIRE = 1800 # 待办最长存活 30 分钟
DISPATCH_REASON = (
'⛔ **收工前自检:你收到的是「派活」触发句,但这一轮没有建任何排期。**\n'
'用户说的是「使用执行会话完成目标 / 调用执行会话完成目标」—— **这句话本身就是授权**,\n'
'要的动作是**把执行会话拉起来**(建排期),⛔ 不是在线程里自己代做、也不是回一句解释。\n'
'请先把排期建出来(`once` 或 `recurring`,prompt 自包含、运行时不问用户),再收工;'
'确实建不了,就在回复里写明卡在哪一步。'
)
def _dispatch_gate(payload):
"""见上面那段注释。返回 True = 已发出阻拦 ⇒ 调用方**直接 return**。"""
try:
ws = str(payload.get('cwd') or '') or \
(os.environ.get('CODEBUDDY_PROJECT_DIR') or os.environ.get('DSH_WORKSPACE') or '')
if not ws:
return False
p = os.path.join(_norm_path(ws), DISPATCH_REL)
if not os.path.isfile(p):
return False
parts = (io.open(p, encoding='utf-8').read().strip().split('\t') + ['', '', ''])[:3]
sid_m, ts_s, hits = parts
sid_now = str(payload.get('session_id') or '')
if not sid_now or sid_m != sid_now: # ⛔ 不是本会话的待办 ⇒ 不碰
return False
ts = float(ts_s or 0)
age = time.time() - ts
if age > DISPATCH_EXPIRE: # 过期 ⇒ 作废,⛔ 不纠缠
os.remove(p)
return False
if age < DISPATCH_GRACE: # 刚说完、还没轮到 ⇒ 放行
return False
import sqlite3
cfg = (os.environ.get('CODEBUDDY_CONFIG_DIR')
or os.path.join(os.path.expanduser('~'), '.workbuddy'))
con = sqlite3.connect('file:%s?mode=ro' % os.path.join(cfg, 'workbuddy.db').replace('\\', '/'),
uri=True, timeout=5)
n = con.execute('select count(*) from automations where created_at > ? and cwds like ?',
(int(ts * 1000), '%' + _norm_path(ws).replace('\\', '/') + '%')).fetchone()[0]
con.close()
if n: # 已经建过排期 ⇒ 待办销账
os.remove(p)
return False
except Exception:
return False # ⛔ fail-open
root = _norm_path(ws)
if _rate_limited(root, sid_now): # 同一会话 10 分钟最多拦 1 次
return False
log(root, 'dispatch-gate 命中:触发句已说但未建排期(hits=%s|待办 %.0fs)' % (hits, age))
_emit({'continue': False, 'reason': DISPATCH_REASON})
return True
def main():
raw = _read_stdin_text() # ⚠️ 必须走 buffer:`-E` 下 sys.stdin 是 cp936(见 _read_stdin_text 注释)
payload = None
@@ -658,6 +724,11 @@ def main():
if (payload.get('hook_event_name') or '') == 'UserPromptSubmit': # 第二方案分派
return user_prompt_mode(payload)
tp = str(payload.get('transcript_path') or '')
# 🔴🔴 2026-10-07 「派活没收工」闸 —— **必须放在自作用域判定之前**:
# 下面那条作用域只认本工作区,而实测栽的那条会话在 `vibe-product` ⇒ 放在后面就永远拦不到。
# ⛔ 它只在"本工作区确实有本会话的派活待办"时才可能生效,其余情形一次都不触发。
if _dispatch_gate(payload):
return
if not _in_scope(tp): # 作用域外 → 放行
return
if os.environ.get('DSH_STOP_GUARD_OFF'): # 急停(环境变量)→ 放行
+57
View File
@@ -7238,5 +7238,62 @@ def main() -> int:
return 0 if nfail == 0 else 1
@case("⛔ 派活触发句:说了就必须把执行会话拉起来(收工前闸)")
def t_dispatch_gate():
"""🔴 2026-10-07 立。
用户令逐字:「**重点问题是 会话接收到 调用执行会话完成目标 没有去调用对应技能去执行**」。
实测(会话 `b232218f` · vibe-product):用户 01:32:00 / 01:33:46 两次说触发句,
直到 01:36:27 才建出排期 —— 中间那一轮 AI 只在回复里解释口径,**没拉起执行会话**。
守三样(都确定性,⛔ 不依赖现网存量):
① 派活族触发句 ⇒ `skill-load-guard.py` 留待办(含**带空格**的写法 —— 实测用户就是这么写的);
② 非派活发言 ⇒ ⛔ 不留(⛔ 别把裸词「执行会话」当派活,那会误报);
③ 收工闸:同会话 + 超 90 s 宽限 + 该区此后**没有**新建排期 ⇒ 拦(`continue:false`)。
"""
import json as _j
import subprocess as _sp
import tempfile as _tf
import time as _t
gate = HERE / "hooks" / "stop-dialog-guard.py"
mark = HERE / "hooks" / "skill-load-guard.py"
sid = "selftest-gate-%d" % os.getpid()
tmp = _tf.mkdtemp(prefix="dsh-gate-")
mk = os.path.join(tmp, ".workbuddy", ".dispatch-pending")
os.makedirs(os.path.dirname(mk), exist_ok=True)
def _feed(script, payload):
return _sp.run([sys.executable, str(script)],
input=_j.dumps(payload).encode("utf-8"),
stdout=_sp.PIPE, stderr=_sp.PIPE, timeout=60
).stdout.decode("utf-8", "replace")
def _mark(ts):
with open(mk, "w", encoding="utf-8", newline="\n") as f:
f.write("%s\t%.3f\t使用执行会话\n" % (sid, ts))
r = []
for tag, prompt in (("直连写法", "调用执行会话完成目标:按 1-2-3 步骤做"),
("带空格写法", "应该使用 执行会话的技能去完成工作")):
if os.path.exists(mk):
os.remove(mk)
_feed(mark, {"prompt": prompt, "session_id": sid, "cwd": tmp})
r.append(("派活句留待办(%s)" % tag, os.path.isfile(mk)))
if os.path.exists(mk):
os.remove(mk)
_feed(mark, {"prompt": "重点是技能规则和机制也要和这几个文档匹配",
"session_id": sid, "cwd": tmp})
r.append(("非派活句⛔ 不留待办", not os.path.isfile(mk)))
pl = {"session_id": sid, "cwd": tmp, "stop_hook_active": False,
"transcript_path": os.path.join(tmp, "x.jsonl")}
_mark(_t.time() - 200)
r.append(("超 90 s 宽限 + 该区未建排期 ⇒ 拦", '"continue": false' in _feed(gate, pl)))
_mark(_t.time())
r.append(("宽限内(刚说完)⇒ 放行", '"continue": false' not in _feed(gate, pl)))
return r
if __name__ == "__main__":
sys.exit(main())