dsh-task-gate
已验证dsh-task-gate · v0.6.9 · MIT · Web 界面
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.
安装
dsh plugin add dsh-task-gate 用 dsh --profile default --dump-config 确认 layer 已生效 —— 参见安装指南。
源码
发布到 npm 但没有公开仓库。安装前请检查包内容。
标签
说明文档
dsh-task-gate
English | 中文
DeepSeek Harness 的任务闸门:每一轮对话都必须以声明收尾。没声明的停止会被判定为异常中断,插件主动把它接回去;声明了、或者是你自己手动停的,它一概不打扰。
它解决什么问题
代理会在三种情况下安静地停下:
- 上游断了 —— 网络抖动、API 报错,模型没说完就没了;
- 本机重启 —— 编译把内存打爆、断电、被强杀,会话停在半截;
- 等东西 —— 派了子代理或起了后台命令,主代理停下来等,但等多久没人管。
人不在键盘前时,这三种都意味着任务卡死且无人知晓。这个插件给每个对话装上四个工具,并在会话顶部提供一个只读监控面板,让"结束"从隐式变成显式。
四个工具
| 工具 | 语义 | 什么时候用 |
|---|---|---|
Aurorix_complete |
任务真的结束 | 全部交付并验证完。不是暂停——在等机器用 wait,只有用户能定用 blocked |
Aurorix_wait |
暂停,等机器进度 | 子代理、后台命令、长构建、下载 —— 会自己完成的事 |
Aurorix_blocked |
阻塞,必须用户决策 | 只有用户能定的问题 |
Aurorix_cancel_wait |
取消当前等待态 | 已确认不应继续按机器等待处理时;只在当前确实处于 wait 时可用 |
四个都带 Aurorix_ 前缀,避免和预训练里的常见工具名、以及其它插件撞名。
判定规则
一轮对话结束时只有三种结局:
├─ 已声明 complete → 静默 wait → 按间隔询问 blocked → 静默等人
├─ 自然停止 你手动停的(回复中途停 / 消息刚发出没回复就停 / 点停止)
│ → 不监控、不催促、重启不复活
└─ 异常停止 既没声明、也不是你停的
→ 按间隔催促,直到模型再次回复
计数怎么算:三个声明状态同为一回事,都是"本轮声明位"。模型一回复就清零,进入下一轮监控。所以上限统计的是"连续多少轮完全没有模型回复"——它保护的是僵尸会话:网络彻底断了、会话僵死,不会永远每 30 秒对着空气发消息。
重启后谁能复活:只有"异常停止"。你手动停掉的会话永远不会被复活(这条承诺由 verdict() 里「人为停止优先于长期状态」的顺序落实;第三轮审计发现过一处违背并已修)——这是刻意的。
等待态什么时候结束
wait 是持续处境:声明一次就跨轮保持,模型在后续检查里正常回一句话也算数,不必每次重调工具。它只在三种情况下结束:
| 结束条件 | 怎么发生 |
|---|---|
| 模型发出新声明 | 改成 complete 或 blocked。complete 是终局,不跨轮保持 |
| 你回到对话里 | 你发消息那一刻,"我在等机器"不再自动成立 |
| 它等的那件事真的完成了 | 插件订阅后台作业的 settled 事件,作业(子代理、后台命令)一落定就立刻唤醒,并在那一刻结束等待处境 |
第三条有一个前提,值得单独说清楚:Aurorix_wait 是无参数信号,插件无法知道你在等哪一个作业。它能核实的只有一件事——那是不是你唯一还在跑的作业。所以只有在"那是最后一个"时,插件才会说"你等的那件事已完成"并结束处境;如果还有别的在跑,它会明确告诉你"其中一个完成了、还有别的仍在运行",处境保持不变。核实不了(拿不到作业注册表)时同样按保守方向处理:宁可少清一次,不要错清一次。唤醒消息带独立标签 <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() 只有用户能决定
Aurorix_cancel_wait() 取消当前等待态,随后按未声明收尾处理
都不带参数。 交付说明、在等什么、卡在哪里、为什么取消等待——这些写在模型回复的正文里给用户看,而不是写进工具参数。参数是写给工具系统看的,用户在界面上看到的是一张工具调用卡片,不是可读的说明。让模型既写参数又写正文,等于同一件事说两遍,而它往往只写一处。
调用之后模型继续写正文,以正文结束这一轮。 插件刻意不调用 exec.concludeTurn()——官方对它的定义是"带 concludesTurn 的工具结果会让回合在该步立即结束"。用了它,模型就没机会写终稿,回合会以一张工具卡片收尾。声明本身已经作为会话事件落盘,折叠时能看到,不需要靠运行时信号提前关门。
这两点都有测试钉住,避免以后被"顺手优化"改回去。
右上角的面板
会话头部右侧的工具区里会多一个「Task gate面板」按钮(排在 Export Chat 左边),点开是一个只读的小面板。
按钮的样式是照着旁边那个 Export Chat 按钮逐条对齐的,不是"看着差不多":胶囊圆角 18px、1px 的 border-l2 描边、透明底色、13px 字、32px 高、悬停用同一个 interactive-bg-hover 别名。两边的声明在测试里钉住,改回旧值会让测试变红——第一版我用了 10px 圆角和填充底色来"模仿",结果在它旁边看起来像另一种控件。
面板本身则故意不是这个样式,而是一块多彩的监控面板(状态色区分空闲/催促/等待/阻塞/完成,顶部一条渐变色带),这是你单独提的要求。
| 显示项 | 含义 |
|---|---|
| 当前状态 | 空闲 / 异常催促 / 等待机器 / 等待用户 / 已完成 / 已停止 |
| 下一步 | 宿主接下来打算做什么:不提醒、短催促、等待轮询、阻塞提醒 |
| 预计剩余 | 距离下一次提醒还有多久(估算值,见下) |
| 判定依据 | 为什么是这个状态,例如 undeclared-completed、cancelled-wait |
| 未回复提醒 | 已经催过几次而模型一个字都没回 |
| 异常收尾 | 连续多少轮没有任何声明的收尾 |
这个面板只看不改。 它读的是插件投影出来的只读数据,不会替你改任何状态——想结束一个等待态,得让模型自己调 Aurorix_cancel_wait。
"预计剩余"是估算,不是宿主的真实定时器。 面板拿"最近一次事件的时间"加上"当前档位的间隔"算出来。真正的 setTimeout 句柄是宿主运行时对象,它不进投影——投影必须能从会话日志重建(这也是它可缓存、可跨重启的前提),而定时器句柄重建不出来。所以这里写的是"预计"。
有两处刻意保留的偏差,宁可写在文档里也不假装精确:
- 模型正在跑这一回合时,宿主会先把提醒压住(绝不能把催促塞进正在产出的回答里),回合结束后立刻重新挂表。投影看不到"agent 此刻在跑"(视图只有会话状态加实时配置,不含会话身份),所以这段时间面板仍在显示倒计时。提醒本身没有丢,只是晚一点到——这一点由集成测试钉住:运行中不挂表,回合一结束必然补挂并真的发出。
- 倒计时按事件时间起算,而宿主挂表用的是它自己那一刻的时钟,两者可能差一个事件写入的间隔。
当宿主已经不再挂定时器时,面板不再倒数,并且会分清到底是哪一种:
| 面板显示 | 含义 |
|---|---|
| 监控已关闭 | enabled = false,整个插件被关掉了 |
| 当前不需要提醒 | 已声明完成、或人为停止——本来就不需要提醒 |
| 暂无提醒 | 这一档没有配置间隔(例如 blockedRemindSec = 0) |
| 已达上限,不再提醒 | 催促次数真的用完了 |
这四种情况以前共用一句"已达上限",于是一个"已声明完成"的会话会被告知一个它从没碰到的上限。这个"是否还会提醒"的判断来自宿主投影的 scheduled 字段,用的是与调度器同一份状态和同一批实时配置,不是面板自己猜的。
按 Esc 关闭面板。
面板只看不改,所以它没有"取消等待""别再催了"这类按钮——停催只有两个正当出口:模型声明,或者你发消息。
它到底对模型说了什么
完整清单见 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 |
投影读取失败被吞成"没有声明",与"服务不可用"无法区分,该会话静默失去监控 | 降级但仍留痕(按会话去重防刷屏) |
| 投影未注册也被当成健康空态(0.6.0) | 真实 registry 在该 key 未注册时返回 undefined(注册没跑到、或插件 fiber 掉了),而 ?? init() 把它折成"这个会话什么要做的都没有":监视器静默丢掉该会话,完成闸门还会读到空待办清单而放行 Aurorix_complete |
undefined 与抛错走同一条留痕降级路径,并补了注册缺失回归 |
| 语言包注册没进生命周期(0.6.0) | 浏览器半的 ctx.locale.register 裸调用看着没问题,但它返回的清理函数被丢弃:卸载时泄漏,重复激活(HMR)时会抛 locale namespace already has locale,可能把整个槽位条目炸掉 |
包进 ctx.effect(...),并在 test/client.test.mjs 里用 vm 真跑一遍浏览器半代码把它钉住 |
| 面板显示一个永远不会到期的倒计时(0.6.0) | 面板原来只看"有没有间隔",于是催促次数已达上限(宿主不再挂表)时,界面上仍在倒数——用户等一个不会发生的提醒 | 投影公开 scheduled,与宿主调度器同源;为假时显示"已达上限,不再提醒" |
| 改了上限却在途那张表照样发(0.6.0,方向相反) | 上限在挂表时被抄进闭包,回调复查用的也是那份旧值。于是把 maxNudge 从 50 调到 1 后,面板(读实时配置)立刻显示"已达上限,不再提醒",而已经挂上的那条到时照样发了出去——界面说停了,实际没停 |
到点时重新读配置;上限的读取收敛成一个 limitOf(action),挂表前与开火前走同一条路径。回归测试用"回退成抄快照"的变异验证过会变红 |
| 一个会话被说成碰到了它从没碰到的上限(0.6.0) | scheduled=false 一律打印"已达上限",于是"已声明完成""人为停止""这一档没配间隔"全被说成上限用完 |
四种原因各自显示:监控已关闭 / 当前不需要提醒 / 暂无提醒 / 已达上限 |
| 客户端测试有两条假绿(0.6.0) | 断言 /idle/ 与 /taskGatePanel/ 命中的是 <style> 里三千多字符的 CSS 类名,而不是渲染出来的界面;同时按钮点击、Esc、秒表三条路径零覆盖(桩里的 useState 直接返预设值、useEffect 是空函数) |
重写测试:真 useState(带重渲染)、真 useEffect(带依赖比较与清理)、断言时跳过 <style>、可按 class 定位节点读 props。17 条用例;14 个定点变异全部被杀掉 |
| 按钮"模仿"了邻居而不是对齐邻居(0.6.2) | 需求写的是"和旁边的 export chat 按钮一样的样式",我按印象配了一套相近值(10px 圆角、填充底色、12px 字),没去读邻居真正用了什么。结果它落在 Export Chat 旁边时像是另一种控件——而且因为两边都不报错,没有任何测试会发现这件事 | 去读邻居包的 CSS 体,逐条对齐到 12 项声明全部相同;加一条测试钉住决定按钮族的那些声明(圆角/边框/底色/字号/行高/内边距/悬停别名),三个定点变异(圆角、字号、底色各自回退)全部变红 |
| 会话年龄过滤器降级时不留任何痕迹(0.6.3) | 重启对账靠目录时间戳筛"最近还在写入的会话"。根目录读不出来时整张表变空,调用方逐条回退到会话头部里的创建时间。这个回退方向是安全的(大不了少复活一个,绝不会错复活),但它和健康状态从外面看完全一样,而且粗时间戳会改变候选排序、进而改变哪些会话先被扫描 | 加 onDegrade 上报:根目录不可读、某个项目目录不可读各报一次并带上原始错误。健康路径保持安静,以免信号失去意义。测试覆盖"根不可读必报一次""健康时一声不吭""项目不可读时其余项目照常扫描",三个定点变异全部变红 |
| 测试往系统临时目录里漏水(0.6.3) | test/boot.test.mjs 与 test/integration.mjs 各自 mkdtemp 建临时会话树,从不清理。从 9 月 30 日起累积了 1008 + 124 个目录(约 16MB),每跑一次测试就多一份 |
两处都改为把创建的根目录记进集合,用 process.on('exit') 统一回收(exit 而非测试末尾,才能覆盖失败或中断的那一次——那正是最容易留残渣的时候)。存量按逐个核对过绝对路径、模板、类型后清理 |
| 复活后的监视定时器未纳管 | 只被创建、没进 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 |
| 落定断言越界(0.5.1) | 0.5.0 的改动建立在"插件是发唤醒消息的一方,所以等待结束对它是已知事实"上。但 Aurorix_wait 无参数、jobs 订阅是 owner 级,所以这句话只对单作业成立:同一 agent 有两个作业时,任一个先落定都会发出"你等的那件事已完成"这条假消息,并据此清空等待处境,把模型如实回一句"还在等"判成未声明异常——正是本文件自己认定为必须避免的误伤 |
注入前核实该 agent 是否还有别的作业在跑;有则改发中性变体且不清处境。查不到注册表时同样保守(要求 othersRunning === false 才敢断言落定),并给落定变体补回"继续等"出口 |
| 落定标记可被 TODO 文本伪造(0.5.1) | 判据原用任意位置子串查找,而催促会原样回显 TODO 条目:一条内容里含该标记的 TODO 就能把普通催促变成"等待已结束"信号,在模型自己的文字上清掉等待处境 | 改为锚定开头匹配,并加抗伪造回归 |
| 落定注入失败后不重排定时器(0.5.1) | settled 分支先取消定时器再注入;注入失败时没有补排,留下一个无人监控的等待会话(只能等某个后续状态事件碰巧重新武装它) | 注入失败即显式补排 |
同时撤销三条不成立的审计结论(详见 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, Aurorix_cancel_wait registered
没有这行就说明没加载成功。
面板是插件的浏览器半(client.js),从 0.6.0 起随包发布。它不需要额外安装步骤,但和其他浏览器半一样:装完必须重启 DSH,运行中的页面不会自己长出一个新按钮。
兼容的 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 plugin exposes four model tools and a read-only session-header monitor panel.
The four 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 | Questions only the user can answer |
Aurorix_cancel_wait |
cancel the current wait state | When the session should no longer be treated as waiting on machine progress; accepted only while actually waiting |
All four names use the Aurorix_ prefix to avoid collisions with common pretrained tool names and other plugins.
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 four 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 monitor panel
A read-only "Task gate panel" button sits in the conversation header, left of Export Chat. It shows the current state, the next scheduled action, an estimated time to that action, the verdict reason, and the two counters. It never changes state — ending a wait stays the model's job via Aurorix_cancel_wait, and there is deliberately no "stop nudging" button.
The remaining time is an estimate, not the host's real timer: a setTimeout handle is runtime state that cannot be rebuilt from the session log, so the panel derives it from the last event time plus the active interval. When the host has stopped arming timers at all (a limit is reached, or the mode is off), the panel says so instead of counting down to a deadline that will never arrive.
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.