dsh-task-gate
Verifieddsh-task-gate · v0.5.2 · MIT
Task gate for the DeepSeek Harness: every conversation must end by declaring complete, wait, or blocked; undeclared stops are nudged, and abnormal sessions are revived after a host restart.
Install
dsh plugin add dsh-task-gate Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Published to npm without a public repository. Inspect the package contents before installing.
Tags
Readme
dsh-task-gate
English | 中文
DeepSeek Harness 的任务闸门:每一轮对话都必须以声明收尾。没声明的停止会被判定为异常中断,插件主动把它接回去;声明了、或者是你自己手动停的,它一概不打扰。
它解决什么问题
代理会在三种情况下安静地停下:
- 上游断了 —— 网络抖动、API 报错,模型没说完就没了;
- 本机重启 —— 编译把内存打爆、断电、被强杀,会话停在半截;
- 等东西 —— 派了子代理或起了后台命令,主代理停下来等,但等多久没人管。
人不在键盘前时,这三种都意味着任务卡死且无人知晓。这个插件给每个对话装上三个工具,让"结束"从隐式变成显式。
三个工具
| 工具 | 语义 | 什么时候用 |
|---|---|---|
Aurorix_complete |
任务真的结束 | 全部交付并验证完。不是暂停——在等机器用 wait,只有用户能定用 blocked |
Aurorix_wait |
暂停,等机器进度 | 子代理、后台命令、长构建、下载 —— 会自己完成的事 |
Aurorix_blocked |
阻塞,必须用户决策 | 只有用户能定的问题 |
三个都带 Aurorix_ 前缀,避免和预训练里的常见工具名、以及其它插件撞名。
判定规则
一轮对话结束时只有三种结局:
├─ 已声明 complete → 静默 wait → 按间隔询问 blocked → 静默等人
├─ 自然停止 你手动停的(回复中途停 / 消息刚发出没回复就停 / 点停止)
│ → 不监控、不催促、重启不复活
└─ 异常停止 既没声明、也不是你停的
→ 按间隔催促,直到模型再次回复
计数怎么算:三个状态同为一回事,都是"本轮声明位"。模型一回复就清零,进入下一轮监控。所以上限统计的是"连续多少轮完全没有模型回复"——它保护的是僵尸会话:网络彻底断了、会话僵死,不会永远每 30 秒对着空气发消息。
重启后谁能复活:只有"异常停止"。你手动停掉的会话永远不会被复活(这条承诺由 verdict() 里「人为停止优先于长期状态」的顺序落实;第三轮审计发现过一处违背并已修)——这是刻意的。
等待态什么时候结束
wait 是持续处境:声明一次就跨轮保持,模型在后续检查里正常回一句话也算数,不必每次重调工具。它只在三种情况下结束:
| 结束条件 | 怎么发生 |
|---|---|
| 模型发出新声明 | 改成 complete 或 blocked。complete 是终局,不跨轮保持 |
| 你回到对话里 | 你发消息那一刻,"我在等机器"不再自动成立 |
| 它等的那件事真的完成了 | 插件订阅后台作业的 settled 事件,作业(子代理、后台命令)一落定就立刻唤醒,并在那一刻结束等待处境 |
第三条值得单独说:插件是发唤醒消息的那一方,所以"等待结束"对它是已知事实,不是猜测。唤醒消息带独立标签 <task_gate_wait_settled>,状态折叠据此清空处境——否则模型被唤醒、干了活、又忘了声明就停下时,这一轮会被当成"还在等"来收尾,拿到 5 分钟一档的温和检查(而不是未声明该有的 30 秒一档),措辞还在描述一个已经结束的暂停。
一个刻意保留的边界:模型是在例行检查里自己发现任务完成(不是靠落定唤醒)、接着干活、又忘了声明时,仍走 5 分钟档。没有去猜"干了别的活就说明不等了",因为那会误伤"真在等、只是顺手查了一下状态"的会话——把正确情形推进 30 秒催促。最坏多等 5 分钟,而例行检查正文里就写着"已经完成 → 调用 Aurorix_complete",会自我纠正。
配置
全部在 DSH 设置页的插件表单里改,改完即时生效,无需重启。
| 键 | 默认 | 说明 |
|---|---|---|
enabled |
true |
总开关 |
scope |
all |
all 覆盖主代理与子代理;roots 只管主代理 |
nudgeDelaySec |
30 |
异常停止后首次催促延迟(秒) |
nudgeIntervalSec |
30 |
未回复时的催促间隔(秒) |
maxNudge |
50 |
连续催促上限 |
waitIntervalSec |
300 |
暂停态询问间隔(秒,默认 5 分钟) |
maxWaitPolls |
20 |
暂停态连续询问上限 |
preferJobSettled |
true |
后台任务一结束就立刻唤醒,不必等满间隔 |
blockedRemindSec |
0 |
阻塞态提醒间隔,0 = 不提醒 |
maxBlockedReminds |
0 |
阻塞态提醒上限 |
maxUndeclaredTurns |
0 |
可选保险:连续 N 轮无声明就收敛,0 = 关闭 |
resumeOnBoot |
true |
重启后是否复活异常会话 |
bootResumeGraceSec |
20 |
启动宽限期,避开启动抢占 |
bootResumeMaxSessions |
3 |
单次启动最多复活几个 |
bootResumeMaxAgeMinutes |
720 |
只复活最近多久活跃过的 |
bootResumeMinIdleSec |
120 |
改动距今不足此时长的会话视为可能仍活着,跳过 |
bootResumeIncludeSubagents |
false |
是否也复活子代理会话;实测半截轮绝大多数在子代理,默认关 |
bootResumeScanLimit |
15 |
最多深入读多少个候选(每个约 0.5 秒,异步进行) |
三个工具是无参数的单触发信号
Aurorix_complete() 任务确实做完并验证过了
Aurorix_wait() 在等机器(子代理、后台命令、构建)
Aurorix_blocked() 只有用户能决定
都不带参数。 交付说明、在等什么、卡在哪里——这些写在模型回复的正文里给用户看,而不是写进工具参数。参数是写给工具系统看的,用户在界面上看到的是一张工具调用卡片,不是可读的说明。让模型既写参数又写正文,等于同一件事说两遍,而它往往只写一处。
调用之后模型继续写正文,以正文结束这一轮。 插件刻意不调用 exec.concludeTurn()——官方对它的定义是"带 concludesTurn 的工具结果会让回合在该步立即结束"。用了它,模型就没机会写终稿,回合会以一张工具卡片收尾。声明本身已经作为会话事件落盘,折叠时能看到,不需要靠运行时信号提前关门。
这两点都有测试钉住,避免以后被"顺手优化"改回去。
它到底对模型说了什么
完整清单见 PROMPTS.md:每一处模型可见文本都原文列出,按模型看到它的时机排列(工具说明 → 运行时注入 → 重启复活注入 → 工具返回文本)。
该文件由 tools/gen-prompts-doc.mjs 从真实渲染函数生成,并有测试防止其与代码漂移。任何一条措辞改动都会让测试失败,直到文档重新生成。
独立审计修掉的缺陷
三轮审计(两个高智商模型 + 两个高速模型 + 主代理自审)后修掉的确认缺陷,每条都有回归测试:
| 缺陷 | 后果 | 修法 |
|---|---|---|
工具拒绝返回 {accepted:false} |
框架只把抛出记为失败,返回值不是。折叠读到工具名就记下"已声明完成"——拒绝信息发出去了,闸门却自己关了,且完全静默 | 拒绝一律抛异常 |
| boot 固定折叠尾部 400 事件 | standing(等待/阻塞)是跨轮字段,声明滚出窗口就丢失。实测 1810 事件日志:完整判为 wait-restart,截到尾部 400 却判为 undeclared-completed |
窗口逐级扩展到含最近声明;实在找不到就用中性理由 |
| 坏数值配置变忙循环 | Number('abc') → NaN → NaN * 1000 被平台当 0ms → 监视器以事件循环最快速度空转 |
运行时有限值防线,回退默认并留痕 |
| 模型选择只认投影 | modelSelection 是缓存,投影丢失时 resolveSelection 得到空值,可救的会话被静默跳过 |
从日志的 request/header 兜底 |
stateOf 静默 fail-open |
投影读取失败被吞成"没有声明",与"服务不可用"无法区分,该会话静默失去监控 | 降级但仍留痕(按会话去重防刷屏) |
| 复活后的监视定时器未纳管 | 只被创建、没进 timer 表,agent/disposed 取消不掉 |
与其他定时器统一登记 |
| 定时器回调无异常边界 | 回调在调用者栈外执行,抛出的异常会逃进平台,该 agent 静默失去监控 | 加边界并留痕 |
sessions.flush() 浮空 Promise |
该 API 是 async 且会 reject(后端注释明写 "reject loudly"),同步 try/catch 接不住,未处理 rejection 会让 Node 默认崩掉宿主进程 | Promise.resolve(...).catch(...) 收口 |
tool/result 有两种真实形状 |
实测语料:本 harness 用 message.toolCallId,外部导入的用 message.content[0].toolCallId。只读一种会让后者的 callId 恒为 undefined,每一次声明都看不见 |
readToolOutcome() 同时读两种 |
pendingCalls 留孤儿 |
框架调度失败路径抛错且不补结果,真实 checkpoint 里已有残留 | 轮界清理 |
投影定义缺 stateSchema |
冷读路径 def.stateSchema.parse(row.val) 无条件调用且无 try/catch。缺它不止是"没校验"——而是抛 TypeError,外层 catch 会把 todos/goal/plan/subagent 的缓存一起丢掉重折 |
补自包含校验器 |
| 人为停止被长期状态覆盖 | 等待态下按 Stop 仍被轮询,重启后还被当"等待被打断"复活,违背 README 自己的承诺 | 人为停止判断提到声明开关之前 |
maxBlockedReminds=0 语义矛盾 |
同一段代码把它当"不限"(渲染处)又当"立即放弃"(守卫处),用户只打开提醒间隔时一条都收不到 | 统一为「0 = 不限」 |
| goal 未 armed 时也让路 | agent/created 会把 activation 强制为 disarmed,重新武装只由人触发。所以重启后 active goal 其实没人驱动,此时让路会把会话停成没有 followup 也没有监控的空壳 |
只在 activation === 'armed' 时让路 |
| Windows 上活体闸门失效 | path.join 产出 \,手工切 / 得 -1,整条路径被当 session id |
改用 basename() |
| peer 范围没跟上 DSH 升级(0.4.3) | DSH 升到 0.2.0-rc.2 后,@deepseek-ai/dsh-llm 只声明到 ^0.1.7-rc.1,插件被判不兼容并拒绝装载;连带表现为「插件包不包含任何组件」且设置面板消失(两者同源:没有活跃 fiber) |
改为 >=0.1.1-rc.1 <0.3.0-0 |
| 等待处境不会随"结果到达"结束(0.5.0) | standing 只在"新声明"或"人类说话"时改变,而插件自己发出"已经完成"唤醒消息这件事没被折叠层记录。于是模型被唤醒、干了活、忘了声明就停下时,turn/end 仍按 wait 收尾:拿到 5 分钟一档的温和检查(而非未声明该有的 30 秒一档),措辞还在描述一个已经结束的暂停 |
唤醒消息改用独立标签,折叠层据此清空 wait 处境;STATE_VERSION 4→5 |
同时撤销三条不成立的审计结论(详见 REJECTED.md):注册 disposer 未收口、timer 跨卸载残留、异步竞态双注入——前两条因为 cordis 的注册与定时器都已自动绑定 context 生命周期,第三条因为定时器回调全程同步、JS 单线程下不存在交错窗口。
完成闸门是硬的,不是劝的
模型判断"我做完了"这件事本身不可靠,所以插件不只靠文字要求,而是读模型自己的待办清单。
DSH 的待办工具每次写入都会在会话日志里留下一条整表快照,插件把它折进状态。于是:
模型的待办清单里还有 pending / in_progress 的项
→ Aurorix_complete 直接拒绝,并把未完成项原样列回去
模型没法"说"自己完成了,因为它自己写下的清单就在那里。这把"我做完了吧"变成了"我列的清单是不是都划掉了"。
拦截后用行动路径收口,而不是空洞警告:
Refused: your todo list still has 2 open item(s), so the task is not finished.
Open items:
- [in_progress] run the suite
- [pending] write the docs
Finish them and mark them `completed` with todo_write, then call this again.
If the list is simply out of date, update it first so it reflects reality.
If you are actually waiting on a machine, call Aurorix_wait; if only the user can
decide, call Aurorix_blocked.
另外两条也是硬规则:工具是无参数的纯信号(说明写在回复正文里,不写进工具调用),而催促消息会把未完成项念给模型听。
为什么旧版会"没做完就收工"
前一版的工具说明里有两处设计失误,值得记下来:
- 一个无条件逃生门。说明里写着"或者你在等用户读/答"——任何刚发完消息的回合都能这么宣称,想停的模型直接从这里溜走。
- 把完成说成了让催促闭嘴的办法。说明里写"它会一直给你发消息,直到你声明点什么",等于告诉模型:调
complete就没人烦你了。当时催促消息的结尾还有一句"如果任务其实已经完成,直接调complete附一行摘要——这样就能干净收尾"。
于是最省力的出路变成了"先声明完成,再说"。现在的写法把未声明停止描述成"你被唤醒,并且被期待继续",让继续做事成为默认路径。
五道防假阳性(为什么它不会乱拉起你的老会话)
插件要判断"谁该被拉起",而"没声明"这个信号本身在历史数据上是不可用的。实测 436 个真实会话得到的三条硬事实决定了下面全部设计:
- 320 / 326 个正常结束的会话,最后一轮根本没有工具调用 —— 它们早于本插件存在。若按"没声明就是异常"处理,首次启动会把几百个几天前就结束的对话全部唤醒。 → 因此引入管辖判定:只有当会话确实曾受本插件管辖时才认异常,证据是日志里出现过本插件注入的消息,或该会话的请求头工具清单里出现过三个闸门工具之一。两者皆无 = 不归我管 = 不动。
- 以
session/end-seed结尾的会话,前一个事件几乎总是turn/end(2026-09-30 复测 445 个会话:46 个 end-seed 里 43 个如此,3 个不是)。它是恢复/继承边界标记,不是收尾声明。 → 因此判定不看末事件,而是向前回溯到最近的turn/start或turn/end。这一条同时让"半截轮"(回溯到turn/start却没有配对turn/end)变得可检测。 - 真半截轮绝大多数是子代理(2026-09-30 复测 445 会话:18 个半截轮里 16 个子代理、2 个根会话),且都是死在模型请求在途。默认值
bootResumeIncludeSubagents=false依然合理——两个根会话里有一个是当时正在跑的本会话,正是bootResumeMinIdleSec要挡的。 → 因此默认排除子代理(可用bootResumeIncludeSubagents打开)。复活子代理基本是噪声,它们的工作由父代理覆盖。
另外两道:
- 活体闸门:改动时间在
bootResumeMinIdleSec(默认 120 秒)以内的会话视为可能仍活着,跳过。这个方向是安全的(偏向不动)。 - 半截轮免检:进程被杀就是被杀,与装了什么插件无关,所以"半截轮"是唯一不需要管辖判定的阳性证据。
保守方向的依据是基率:326 个正常完成 vs 约 11 个真异常(2.6%),人类手动停止只占 10~17 个(2.4%~4.1%),而 aborted/parent(32 个)是它的三倍多。任何宽松规则都会立刻引入 60 个以上假阳性。
启动扫描的成本
实测数据(436 会话 / 2.27 GiB):
list()拿全部头部约 600ms(含创建时间、工作目录、来源、父会话)- 但单会话
open()约 510ms,瓶颈在 open 而不是数据量 - 读完整个库约 4 分钟,绝不可阻塞启动
因此启动扫描是异步的:延迟 bootResumeGraceSec 秒启动,先按头部与目录时间预筛,再把候选压到十几个逐个判读,最多复活 bootResumeMaxSessions 个。读取策略上有一个必须说清的事实:JSONL 后端不返回 eventCount(stat() 只给 header/revision/sizeBytes),而 handle.read(offset, length) 按事件偏移取,没有总数就无从计算尾部偏移。所以实际行为是:
- 后端能报事件总数时 → 两级读取:先尾部 400 事件,像异常才加宽,窗口逐级扩展到含最近一次声明为止;
- 后端不报总数时(当前 profile 即如此)→ 一次性读全档。
后者是刻意的,不是退化:日志是单个压缩流,任何一次读取都要整份解压,所以「先读一次拿总数、再读尾部」是两次解压,比读一次更慢。读全档还顺带让判定拿到完整上下文(尾部与请求头都在),管辖与处境不会因窗口截断而误判。代价由 bootResumeScanLimit(默认 15 个候选)封顶。
顺带一提:约 40% 的老会话是 v0 格式,open() 会抛格式不支持错误。插件捕获后跳过,不影响新会话。
工作方式
- 零外部状态。每个会话的闸位是它自己日志的纯函数折叠,由 DSH 的投影注册表持久化到
~/.dsh/storages/session_projcache/。插件不写任何状态文件,所以没有状态可损坏。 - 不新增会话事件类型。未识别的类型会让整份日志拒绝重建,因此状态只从内置事件派生。
- 不自己发明锁。重启复活时跨进程互斥交给 DSH 的会话写租约(flock):活进程持有时
resume会抛SessionAlreadyOwnedError,插件放手;崩溃进程的锁由内核自动释放。 - 不自己解析日志。会话文件是多帧 Zstandard,Node 原生只能解第一帧;一律走官方读服务。
- 不抢人的话。用户一开口,挂起的催促立刻取消;
agent/pre-step处还有一道竞态栅栏,人类消息优先。 - 给官方 goal 驱动让路。会话有 active goal 时交给
dsh-goal-round-driver,避免两个驱动器重复烧轮次。
安装
dsh plugin add dsh-task-gate
或从源码目录安装:
dsh plugin add /path/to/dsh-task-gate
装完需要重启 DSH 才生效。 插件在启动时挂载;运行中的进程不会热加载已经装好的新插件。
卸载:
dsh plugin remove dsh-task-gate
卸载后无需清理——插件不写任何自己的文件。
装好后启动日志里会有一行确认:
[dsh-task-gate] plugin loaded; tools Aurorix_complete, Aurorix_wait, Aurorix_blocked registered
没有这行就说明没加载成功。
兼容的 DSH 版本
0.4.3 起声明 @deepseek-ai/dsh-llm: >=0.1.1-rc.1 <0.3.0-0,覆盖 0.1.x 与 0.2.x 两代运行时(插件唯一用到的 dsh-llm 接口 createUserMessage 两代一致,用到的 tools/sessionProjections/timer/agents/sessions 五个服务两代均存在)。
DSH 判定兼容性的方式是拿运行时版本号去比对插件 peerDependencies 里所有 @deepseek-ai/dsh* 声明,任一条不满足就拒绝装载该插件。所以升级 DSH 后如果看到类似这样的提示:
[email protected] 与 DSH <版本> 不兼容(要求 @deepseek-ai/dsh-llm ...)
先 dsh plugin add dsh-task-gate@latest 升级到最新版;最新版仍报不兼容就说明你跑的是更新的 DSH 世代,请提 issue。
被拒绝装载的插件会同时出现两种表现,它们是一个原因:插件管理器里该插件的「包含的组件」为空,并且它的设置面板不出现——因为拒载的插件没有活跃的组件实例。
设置面板在哪里
插件管理器页面 → 展开 dsh-task-gate 那一行 → 设置表单就渲染在该条目内部(不是一个独立的顶级「设置」页)。全部 19 项都是即时生效的可编辑字段,改动无需重启。
开发
npm test
零依赖单测,覆盖声明判定、自然/异常停止判定、授权检测、工具契约、启动解码与防假阳性判据。
集成测试驱动 apply() 本身(调度器、注入链路、竞态栅栏、工具闸门),指向已安装的副本运行,因此它同时验证发布文件集是完整的:
node test/integration.mjs /path/to/profile/node_modules/dsh-task-gate/lib/index.js
许可
MIT
English
A task gate for the DeepSeek Harness: every conversation must end by declaring an outcome. A stop without a declaration is treated as an abnormal interruption and the plugin nudges it back to life. A declared stop, or one you made yourself, is left completely alone.
The three tools
| Tool | Meaning | When to call |
|---|---|---|
Aurorix_complete |
the task is finished | All work delivered and verified; or you are waiting for the user to read/answer (asking the user is a finished turn, not a pause) |
Aurorix_wait |
paused, waiting on machine progress | Subagents, background commands, long builds, downloads |
Aurorix_blocked |
blocked, only the user can decide | Refused once the user granted full autonomy |
The rules
A closed turn has exactly three outcomes: declared (complete → silent, wait → polled, blocked → silent), natural stop (you pressed stop, mid-reply or before the model answered → never touched), or abnormal stop (neither declared nor human-stopped → nudged until the model replies).
All three declarations share one "this turn's declaration" slot: as soon as the model replies, everything clears and the next round is monitored fresh. The counters therefore measure consecutive rounds with no model reply at all, protecting against zombie sessions rather than against a talkative model.
On restart, only abnormal stops are revived. Sessions you stopped by hand are never revived.
Why it will not wake up your old conversations
Three measured facts on a real 436-session corpus drive every safeguard:
- 320 of 326 normally-completed sessions had no tool call at all in their final turn. Reviving on "ended without declaring" would wake hundreds of long-finished conversations, so a session must show evidence of being under this plugin's gate before an undeclared stop counts as an anomaly.
- 50 sessions end with
session/end-seed, whose preceding event is always aturn/end(minutes to days earlier). It is a recovery boundary, not an ending, so the verdict walks back to the nearestturn/startorturn/endinstead of reading the last event. This is also what makes a half turn detectable. - All 8 genuine half turns belonged to subagents, so subagents are excluded by default.
Two more: a session touched within 120s is treated as possibly live and skipped, and a half turn needs no jurisdiction test because a crash is a crash.
Install
dsh plugin add dsh-task-gate
Restart DSH afterwards: the plugin mounts at startup and a running process will not pick up a newly installed plugin. Confirm it loaded by looking for the [dsh-task-gate] plugin loaded; banner in the startup log.
The completion gate is enforced, not merely requested
A model's own judgment that it is finished is unreliable, so the plugin reads the model's todo list. DSH writes a whole-list snapshot to the session log on every todo update, and the gate folds it into state:
the model's todo list still has pending / in_progress items
-> Aurorix_complete is refused, with the open items listed back
"I'm done" now has to survive contact with "here is what I said I still had to do". Two earlier wording defects are worth recording, because they caused premature completion in practice: the description offered "or you are waiting for the user to read/answer something" as an unconditional second reason to finish (any turn that ended with a message could claim it), and it explained that the harness keeps messaging until you declare something — which taught exactly one lesson, that declaring complete makes it stop.
Design notes
Zero external state, no new session event types, no hand-rolled locks (the kernel's flock on the session write lease is the mutual exclusion), no hand-rolled log parsing (the log is multi-frame Zstandard), human input always wins, and the official goal-round driver takes precedence when it owns a session.
MIT licensed.