dsh-stall-sentinel
Lightweight stall watchdog for DeepSeek Harness - auto-wires subprocess spawn, reminds + forensics on stall, never kills.
- Stars
- 0
- Language
- JavaScript
- Created
- Sep 3, 2026
- Updated
- Sep 3, 2026
Introduction
dsh-stall-sentinel
把 longtask-stall-guard skill 重写为轻量级 DeepSeek Harness profile-bundle 看门狗插件。
纯 JS Cordis apply(ctx),零额外运行时依赖(只用 node:* 内置),参考 dsh-stall-guard
的思路(真静默才处置、JSONL + 状态路由、绝不杀进行中任务)但从零实现、刻意保持极简。
一句话判定模型
每个被监控目标,在一个 timer 触发下只做一次聚合取证:一条只读 PowerShell 命令在约 1s 窗口内对进程与日志各采样两次,一次返回 CPU 增量 + 日志 size/mtime 增量 + 阶段哨兵 + 日志尾。 判卡死的充要条件是两个连续采样窗口都满足:
- (a) 进度冻结(日志字节数不变、mtime 不变、无阶段哨兵),且
- (b) CPU 增量 ≈ 0(死锁/半开网络)或 ≈ 打满却仍无进度(忙循环)。
任一信号缺失/失败 → 判 unknown,继续等 + 设下一检查点。处置动作唯一 = 提醒 + 取证报告,
绝不 kill / terminate / 重试。
安装即生效(生产者自动接线,SGF-01)
默认安装后不需要任何外部 guard.watch() 调用即开始监控。 插件在装载时把
ctx.subprocess.spawn 包一层(卸载时恢复原方法):每个 collect 模式的 spawn 都会拿到
真实 pid,用它返回的 offset 游标式 collected 阅读器(readFrom(offset) 非消费,
不会偷 owner 的输出)当主进度信号,spawn 即自动进入 watch 集。
这直接覆盖了走 ctx.subprocess 的长任务:bash 工具、后台 pwsh(dsh-pwsh-local 的
start()/run() 都经 ctx.subprocess.spawn)、以及通过它们启动的 npm install/构建。
拿的是 spawn 时点的精确 pid,因此无需脆弱、易误匹配的「label→进程表反查 pid」。
目录
dsh-stall-sentinel/
package.json # dsh.bundle.patch 声明 + main/exports,零 dependencies
cordis.patch.yml # 顶层 YAML 数组:insert 一行 id=stall-sentinel
lib/
index.js # Cordis 壳:apply、spawn 自动接线、生命周期/事件/JSONL
core.js # 纯逻辑:探针脚本、stream 采样、判定引擎、JSONL 轮转(零 cordis 依赖)
test/
core.test.mjs # node --test 单元测试(纯逻辑,含 SGF-02/03)
smoke.mjs # 进程内冒烟(core 判定/采样/轮转 + SGF-02/03/04)
auto-watch.smoke.mjs # mock ctx 的 spawn 自动接线集成冒烟(SGF-01,无 PowerShell)
probe-fd.smoke.mjs # SGF-04:fd-stdio 探针捕获 in-vivo 回归(无 Node pipe spawn)
e2e.mjs # 完整环境端到端:真实忙循环 → 自动入园 → stalled 告警
README.md
README.en.md
CHANGELOG.md
CONTRIBUTING.md
LICENSE
.github/workflows/check.yml # CI:check + test + pack:check(ubuntu/windows)
安装(二选一)
(A) bundle 包(可分发) — 本目录即一个 dsh.bundle.patch 包:
dsh plugin --profile <profile> add <path-or-link-to-this-dir>(reconcile 会自动把解析出dsh.bundle.patch的依赖追加进dsh.profile.bundles);或用link:本地依赖。- 确保它排在
@deepseek-ai/dsh-base之后(顺序不关键,插件对缺失服务 fail-open)。
(B) 本地相对路径插件(最快) — 在 profile 的 cordis.patch.yml 里:
- insert:
- id: stall-sentinel
name: './plugins/dsh-stall-sentinel/lib/index.js'
config: { enabled: true }
配置(全部可选,默认值见 cordis.patch.yml / lib/core.js)
| 键 | 默认 | 含义 |
|---|---|---|
enabled | true | false 直接禁用 |
intervalMs | 60000 | 单一 timer 的检查点间隔(每个目标每 tick 一次聚合取证) |
probeSleepMs | 1000 | 单条探针内部两次采样的窗口(决定 CPU/日志增量的时间口径) |
stalledWindowsRequired | 2 | 连续候选窗口数门槛;内部强制 ≥2,配置写 1 也会被夹回 2 |
cpuIdleDeltaMax | 0.05 | CPU 增量 ≤ 此值(秒/窗口)判「≈0 空闲」 |
cpuSaturatedDeltaMin | 0.8 | CPU 增量 ≥ 此值(秒/窗口)判「打满」 |
tailLines | 20 | 取证报告里的日志尾行数 |
logDir / jsonlPath / statusPath | $DSH_HOME/stall-sentinel/… | 事件 JSONL 与状态快照落盘位置 |
minJobDurationMs | 1000 | 短于此的 background job 不做存活跟踪 |
minProbeAgeMs | 10000 | 自动接线的 spawn 目标存活满此毫秒才开始采样(短命令 0 探针开销) |
jsonlMaxBytes | 1048576 | events.jsonl 超过此大小轮转到 events.jsonl.old(SGF-03) |
使用方式:注册要看的子进程
插件通过 ctx.provide('stall-sentinel', api) 暴露 loopback 服务,任何宿主内插件/诊断代码都能:
const guard = ctx.get('stall-sentinel');
const { taskId } = guard.watch({
taskId: 'my-build', // 可选;缺省自动分配 watch-N
label: 'pnpm install',
pid: child.pid, // 必须有正整数 pid
logPath: 'C:\\agent\\install.log', // 进度信号源(大小/mtime 增量)
marker: 'added', // 可选阶段哨兵,命中即视为有进展
});
guard.getStatus(); // 返回整个监控快照
guard.list(); // 当前 watch 集(按 taskId 隔离)
guard.unwatch(taskId);
⚠️ 没有 logPath 的目标会永久 unknown(继续等,绝不告警)——没有进度信号就无法区分
慢与卡死。这也正是 skill 的「先声明进度信号」协议。
覆盖边界(诚实声明)
- subprocess 长任务(bash 工具 / 后台 pwsh / 经它们启动的 npm):通过上述 spawn 自动接线, 默认覆盖,具备 pid + 非消费输出两个信号,可产生 stalled 告警。
ctx.jobs里的纯 job 记录(尤其subagent型,或不经ctx.subprocess的 job):JobSnapshot不暴露 pid、也不暴露输出,且ctx.jobs.read(id).text是共享游标的消费型读 (会偷走 owning agent 的输出),因此看门狗绝不调用read(),对这类 job 只做 存活/终态跟踪(onJobDone/onJobsChanged/list(),记job-started/job-settled), 其「两信号」取证退化为unknown(继续等,绝不臆造告警)。后台 pwsh/bash 的卡死已由上层ctx.subprocess.spawn自动接线覆盖,故此退化只影响无子进程暴露的 job。ctx.shell层:ShellProcess.readOutput()是消费型、且ShellProcess无 pid,故不 在 shell 表面读取;其底层ctx.subprocess.spawn已被自动接线,等价覆盖而无需消费输出。download_url:若其传输走ctx.subprocess或可被 producer 以guard.watch({ pid, logPath })注册(下载目标文件 mtime/size 即进度信号)则覆盖;若它是 harness 进程内的纯流式实现、不经过 subprocess seam,则 v1 自动接线看不到它,这是明确的 收缩边界——可在 README/交付声明中如实说明,不做跨 seam 的臆造。
要在上面任一收缩场景获得强判定,producer 显式 guard.watch({ pid, logPath, marker, label })
即可(见下)。
可靠性契约(硬验收线,已写进代码与测试)
- 绝不杀进程:全代码无
kill()/terminate()/taskkill/Stop-Process针对被监控目标。 唯一可能被结束的是只读取证探针本身(Get-Process/Get-Item/Get-Content)在自身timeout(probeSleepMs+15s)超时后由execFile回收,与目标任务无关。 - 防误报:首采样只建基线;连续两窗口「进度冻结 + CPU 空闲/打满」才告警;日志尚未落盘 →
unknown(防慢启动误报);CPU 处于中间值 →waiting清 streak(防 I/O 等待/正常计算误报)。 - 采样失败降级:取证失败/无权限/无日志/无 CPU →
unknown,设下一检查点,绝不升级成告警。 - 生命周期零泄漏:timer、
onJobDone/onJobsChanged监听、ctx.provide、subprocess.spawn 包装全部走ctx.effect()返回的 disposer,卸载时逆序清理并把 spawn 方法恢复原样。 - 每任务隔离:
Map<taskId, target>独立状态;exited/pid-reused/job 终态/不可见即删除; auto-wired spawn 在其handle.done结算时自清理(短命令不写事件、不残留)。 - JSONL 写失败降级:写盘异常不抛穿,落
console.error(stderr) 兜底,绝不打断监控主循环; 且按jsonlMaxBytes轮转到events.jsonl.old(SGF-03),避免无界追加。 - 自保:
apply不抛;决策异常被 tick 内 try/catch 吞掉;所有外部调用 best-effort。 - 基线预置(SGF-02):
guard.watch注册时stat日志文件预置prevSourceExists(stream 目标同理恒为已存在),已存在的日志不再空耗一个「出现」窗口——冻结目标从第 1 窗即开始计数, 2 个 tick 即告警而非 3 个。
loopback 状态路由(自建原语,无内建 loopback API)
ctx.provide('stall-sentinel', api)— 发布监控状态/控制面。ctx.emit('stall-sentinel/event', event)与ctx.emit('stall-sentinel/stalled', event)— 宿主内订阅回路。- JSONL 事件文件(
events.jsonl)— 每行{ts, taskId, verdict, action, signals, …}。 status.json快照(原子写)— 当前 watch 集 + 最近判定 + 最近事件。
事件类型:plugin-mounted / target-added / spawn-settled / job-started / job-settled /
window-frozen / window-cleared / stalled / recovered / sampling-unknown / exited。
检验
node --check lib/index.js && node --check lib/core.js # 语法
node --test # 单元测试(完整环境)
node test/smoke.mjs # 进程内冒烟(含 SGF-02/03/04,受限沙箱也可)
node test/auto-watch.smoke.mjs # spawn 自动接线集成冒烟(mock ctx,受限沙箱也可)
node test/probe-fd.smoke.mjs # SGF-04:fd-stdio 探针 in-vivo 回归
node test/e2e.mjs # 端到端:真实忙循环→自动入园→stalled(完整环境)
SGF-04 说明:runProbe 用 spawn(..., { stdio: ['ignore', fdOut, fdErr] }) 把探针 stdout 直写
每个调用一个临时文件 → readFileSync → parseProbeOutput → 删除临时目录,捕获的是同一份聚合
JSON。这条路径在受限沙箱(禁 Node pipe spawn)与真实宿主都可用,因此取证过程可 in-vivo 闭环验证;
pipe 的 execFile 仅作启动失败 fallback,两条都失败 → resolve(null) = unknown。超时语义不变:
超时只杀探针进程,绝不触目标任务。
端到端证明(SGF-01 验收):node test/e2e.mjs 用一个真实的 node -e "while(1){}" 忙循环经
自动接线的 spawn 入园,在零外部 guard.watch() 下由真实 PowerShell 聚合探针在两窗口内产出
stalled(mode=busy-loop)并写入 events.jsonl。该脚本会只 kill 它自己起的测试忙循环。
(受限沙箱因 child_process 管道 spawn EPERM 无法跑 e2e;这是沙箱边界,不是插件缺陷。)