Back to home@aokamoaki

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
GitHub repo

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-localstart()/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 包:

  1. dsh plugin --profile <profile> add <path-or-link-to-this-dir>(reconcile 会自动把解析出 dsh.bundle.patch 的依赖追加进 dsh.profile.bundles);或用 link: 本地依赖。
  2. 确保它排在 @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

默认含义
enabledtruefalse 直接禁用
intervalMs60000单一 timer 的检查点间隔(每个目标每 tick 一次聚合取证)
probeSleepMs1000单条探针内部两次采样的窗口(决定 CPU/日志增量的时间口径)
stalledWindowsRequired2连续候选窗口数门槛;内部强制 ≥2,配置写 1 也会被夹回 2
cpuIdleDeltaMax0.05CPU 增量 ≤ 此值(秒/窗口)判「≈0 空闲」
cpuSaturatedDeltaMin0.8CPU 增量 ≥ 此值(秒/窗口)判「打满」
tailLines20取证报告里的日志尾行数
logDir / jsonlPath / statusPath$DSH_HOME/stall-sentinel/…事件 JSONL 与状态快照落盘位置
minJobDurationMs1000短于此的 background job 不做存活跟踪
minProbeAgeMs10000自动接线的 spawn 目标存活满此毫秒才开始采样(短命令 0 探针开销)
jsonlMaxBytes1048576events.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.shellShellProcess.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 }) 即可(见下)。

可靠性契约(硬验收线,已写进代码与测试)

  1. 绝不杀进程:全代码无 kill()/terminate()/taskkill/Stop-Process 针对被监控目标。 唯一可能被结束的是只读取证探针本身Get-Process/Get-Item/Get-Content)在自身 timeout(probeSleepMs+15s)超时后由 execFile 回收,与目标任务无关。
  2. 防误报:首采样只建基线;连续两窗口「进度冻结 + CPU 空闲/打满」才告警;日志尚未落盘 → unknown(防慢启动误报);CPU 处于中间值 → waiting 清 streak(防 I/O 等待/正常计算误报)。
  3. 采样失败降级:取证失败/无权限/无日志/无 CPU → unknown,设下一检查点,绝不升级成告警。
  4. 生命周期零泄漏:timer、onJobDone/onJobsChanged 监听、ctx.providesubprocess.spawn 包装全部走 ctx.effect() 返回的 disposer,卸载时逆序清理并把 spawn 方法恢复原样。
  5. 每任务隔离Map<taskId, target> 独立状态;exited/pid-reused/job 终态/不可见即删除; auto-wired spawn 在其 handle.done 结算时自清理(短命令不写事件、不残留)。
  6. JSONL 写失败降级:写盘异常不抛穿,落 console.error(stderr) 兜底,绝不打断监控主循环; 且按 jsonlMaxBytes 轮转到 events.jsonl.old(SGF-03),避免无界追加。
  7. 自保apply 不抛;决策异常被 tick 内 try/catch 吞掉;所有外部调用 best-effort。
  8. 基线预置(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 说明runProbespawn(..., { stdio: ['ignore', fdOut, fdErr] }) 把探针 stdout 直写 每个调用一个临时文件 → readFileSyncparseProbeOutput → 删除临时目录,捕获的是同一份聚合 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;这是沙箱边界,不是插件缺陷。)