zhiheng-zhang-Mera/dsh-health-scheduler

dsh-health-scheduler

DSH plugin: device/runtime health monitoring, restart-pressure scoring, maintenance scheduling and action decisions for DeepSeek Harness. It never restarts anything itself.

未分類deepseek-harnessdshhealth

dsh-health-scheduler

English | 中文

Health monitoring, restart-pressure scoring, maintenance scheduling and action decisions for DeepSeek Harness — it never restarts anything itself.

dsh-health-scheduler watches a DS-Hns machine, reduces everything it can measure to a single restart_pressure number between 0 and 100, decides what should happen next, and then asks somebody else to do it. Levels 1 and 2 of its action ladder are handed to a worker-control adapter; levels 3 and 4 are requests posted to the separate dsh-restart bundle, which owns restart execution.

The pipeline is one direction only:

providers -> normalization -> rolling windows -> trend -> pressure -> policy
          -> maintenance scheduler -> action adapters (worker control | restart request)

What it is / What it is not

ConcernOwnerThis plugin's role
Sensing device and runtime healthHealth SchedulerOwns it. Providers sample, normalize and retain history.
Judging how bad things areHealth SchedulerOwns it. The pressure model and the policy engine live here.
Scheduling maintenanceHealth SchedulerOwns it. Window, target, deferral budget, safe point.
Reducing load (THROTTLE, PAUSE_NEW_WORK)Health SchedulerRequests it through the worker-control adapter.
Actually restarting anythingdsh-restartDoes not own it. Emits a request and reads the answer.
Restart locks, rate limits, checkpoints, graceful shutdowndsh-restartNot here.
Relaunching after a crash, crash-loop breakerSupervisorNot here.
Task state, checkpoints, resumeDS-Hns CoreNot here. The safe-point query is a question, not storage.
A second Mega CorenobodyExplicitly out of scope.

Three promises follow from that table, and they are load-bearing:

  1. No restart execution. There is no taskkill, no reboot, no process kill, no child_process call that could reach the operating system's restart path. The only execFile in the codebase runs a configured telemetry probe (src/providers/sources.ts).
  2. Missing telemetry is unknown, never healthy. A dimension with no data scores null, and its nominal weight is redistributed over the dimensions that do have data.
  3. A single point sample never drives a high-risk action. Every scored metric passes a sustain gate, every metric has a rolling window behind it, and the two highest rungs of the ladder are additionally debounced, gated on the maintenance window and gated on a safe point.

Install

dsh plugin --profile <name> <pnpm args> forwards its remaining arguments to pnpm inside the profile directory and then reconciles the profile's bundle list, so an installed package that declares dsh.bundle joins the layer stack automatically. This package declares dsh.bundle.patch: ./cordis.patch.yml.

From a local checkout of this repository:

# Windows PowerShell, from the checkout directory
dsh plugin --profile web add <path-to-checkout>\dsh-health-scheduler
# any platform, from the checkout directory (a bare "." is anchored to your cwd)
cd dsh-health-scheduler
dsh plugin --profile web add .

From a package name or a tarball:

dsh plugin --profile web add dsh-health-scheduler
dsh plugin --profile web add ./dsh-health-scheduler-0.1.0.tgz

What the bundle contributes

cordis.patch.yml inserts one row, id: health-scheduler, whose config block restates the whole balanced preset with comments. The layer is applied after every earlier bundle and before your own profile's cordis.patch.yml, so your overrides win.

A patch replaces the targeted row's whole config; it is not a deep merge. If you override providerOptions in your profile patch, copy the entire nested object you want, not just the one leaf you are changing. The plugin's own configuration resolution is a deep merge over the selected preset, so a document you pass to the plugin directly behaves the way you would expect — it is the profile patch layer that replaces.

plugin/manifest.json describes the same contract in machine-readable form: id, kind, entry module, the install command and patch path, required and optional services, the settings namespace, the three tool names, the six event names, the action levels this plugin applies versus the ones it only requests, and what degrades when dsh-restart or worker control is absent.

Verify before booting

--dump-config prints the composed profile tree without booting it. Use it to confirm that the plugin row is present and that its configuration is what you wrote:

dsh --profile web --dump-config

--dump-default-config prints the same tree without your user layer and without any --patch overlays, which makes it the fastest way to see what this plugin contributes.

Boot

dsh --profile web

dsh web is a hardcoded alias for --profile web. A successful load logs one line:

health-scheduler: monitoring started (7 providers, interval 15000 ms, preset balanced)

The git-install caveat

A git-hosted plugin builds on install through its prepare script, and pnpm blocks that build until you allow it. When dsh plugin ... add git+https://… fails, the CLI prints the exact key pnpm wants; add it under allowBuilds in <profile directory>/pnpm-workspace.yaml and re-run the same command.

This plugin ships "prepack": "npm run build" and lib/ is gitignored, so:

  • Installing from npm or a tarball needs no build permission. The published tarball contains the built lib/ produced by the prepack hook.
  • Installing from a git URL always needs the allowBuilds entry, because the checkout has no lib/ and prepack must run tsc to create it.
  • Installing from a local path behaves like a tarball when you have already run npm run build, and needs that build otherwise.

Quick start

The plugin works with no configuration at all — the balanced preset is the default and every leaf in it is a default, not a law. A minimal useful document is one line:

// profile package.json -> dsh.profile, or the plugin's settings namespace
{ "preset": "balanced" }

A more realistic first configuration turns on the scheduled maintenance window and points the hardware provider at a thermal helper:

# $DSH_HOME/profiles/web/cordis.patch.yml
- id: health-scheduler
  name: dsh-health-scheduler
  config:
    preset: balanced
    maintenance:
      enabled: true
      targetTime: '04:00'
      windowStart: '03:30'
      windowEnd: '05:00'
      maxDeferMs: 3600000
      urgentOverridePressure: 92
      safePointRequired: true
    providerOptions:
      hardware:
        helperCommand: ['powershell', '-NoProfile', '-File', 'C:\\dsh\\gpu-temp.ps1']
        helperTimeoutMs: 5000
      statsFile:
        paths: ['C:\\dsh\\telemetry\\metrics.json']
        staleAfterMs: 120000

After a boot the plugin logs

health-scheduler: monitoring started (7 providers, interval 15000 ms, preset balanced)

and on demand the operator (or the model) reads the report, which begins like this:

Restart Pressure: 21 / 100
State: THROTTLED
Primary Cause: gpu_usage=95.00 pp scores 89/100, held 1260s
Telemetry coverage: 60%
Unknown dimensions: runtime, worker, computer_use_ui (not scored as healthy)
Maintenance: next window 03:30-05:00
Safe point: no safe-point source registered; readiness unknown
Capabilities: restart=unavailable, worker-control=unavailable

Every number there is traceable to a metric. There is no sentence in the output that is not backed by a measurement — "the model thought it should restart" is not a possible reason code.

How it works

1. Providers sample; nothing else touches the outside world

All health data enters through a HealthProvider. The pressure model and the policy engine are forbidden from reaching for NVML, LibreHardwareMonitor, HWiNFO, a Windows API, worker internals or Electron internals. A provider that cannot measure a metric omits the key; absence is the only way to say "unknown", and it is deliberately not spelled 0.

2. Normalization rejects, clamps and sorts

normalizeSample turns a raw sample into the canonical vocabulary. A reading that is not a finite number, or that is outside the metric's hard physical bound, is dropped and reported as a violation. A ratio sent as a percentage (1.0 < v <= 1.5) is clamped to 1 with a violation attached rather than silently accepted. The surviving bag is sorted by metric name so snapshots are stable.

3. Rolling windows, not points

Every metric keeps raw samples for windows.rawMs (30 minutes, raised to the longest statistics window if that is longer), one mean/max/min bucket per windows.aggregateBucketMs (5 minutes) retained for windows.aggregateRetentionMs (24 hours), and per-day summaries retained for windows.dailyRetentionMs (14 days). Statistics are computed over 5 min / 30 min / 2 h / 6 h windows. Memory is bounded by construction: the store does not grow with uptime.

4. Trend analysis with a polarity

A leak is not a value, it is a slope. TrendAnalyzer reports a slope only when there are at least trend.minSamples samples spread over at least trend.minSpanMs, and only trusts it when R² ≥ trend.minRSquared. The metric registry owns polarity, so a rising GPU temperature is worsening and a falling recovery_rate is too.

5. Six dimensions, one number

Each metric ramps linearly from its warn endpoint (0 points) to its critical endpoint (100 points), with the registry's polarity deciding which end is which. A dimension's score blends its weighted mean with its worst member at WORST_WEIGHT = 0.5, so one critical metric is not averaged away by five calm ones. The six dimension scores are then combined with the configured weights.

6. Missing telemetry is unknown, and coverage says so

A dimension with no known metric scores null and is listed in unknownDimensions. Its nominal weight is renormalized over the dimensions that do have data, and the renormalized share actually backed by telemetry is published as coverage. A pressure reading at 40 % coverage can never be mistaken for a full-confidence one, and every decision record carries coverage_NNpct in its reason list.

7. Anti-flapping is not optional

Six independent mechanisms keep the plugin from becoming a source of churn:

MechanismDefaultWhat it stops
Sustain gateper metric, e.g. 60 s for gpu_temp_cA 30-second spike scoring at all.
Hysteresise.g. throttle enters at 55, exits at 4555 -> throttle, 54 -> normal, 56 -> throttle.
Debounce2 consecutive evaluationsA single evaluation escalating to a high-risk level.
Dwell120 sAny transition below restart level faster than the dwell time.
minRepeatActionMs10 minRe-issuing the same action sooner than allowed.
Three cooldowns5 / 30 / 60 minDecision storms, including from a failing adapter.

The cooldown starts when an adapter is actually invoked — including when it refuses or throws. tests/scenarios.test.js drives a permanently critical machine for 30 minutes at a 15-second tick with a throwing restart adapter and asserts that at most three attempts reach the adapter instead of one per tick.

8. Policy, then adapters

The policy engine emits at most one action per tick and never touches a process. Levels 1 and 2 go to WorkerControlAdapter; levels 3 and 4 become a RestartRequest for RestartAdapter. Every applied action produces exactly one audit record with its pressure, coverage, named drivers, reason codes and the adapter's answer.

Model-facing tools

Three tools are registered when the profile has a tool runtime. All three are read-only: none of them exercises a capability.

health_status

Reports the current health of the DS-Hns runtime. Parameters: section (full | pressure | maintenance | providers, optional, defaults to full).

health_status({ "section": "pressure" })

Restart Pressure: 21 / 100
State: THROTTLED
Coverage: 60%
Primary cause: gpu_usage=95.00 pp scores 89/100, held 1260s
  [0.3] gpu_usage_critical: gpu_usage=95.00 pp scores 89/100, held 1260s
  [0.2] cpu_usage_critical: cpu_usage=90.00 pp scores 71/100, held 1260s
  [0.2] gpu_temp_c_critical: gpu_temp_c=88.00 °C scores 71/100, held 1260s

The full section is the whole report: pressure, state, primary cause, coverage, unknown dimensions, maintenance summary, safe-point summary, capability states, a per-dimension table, drivers, worsening trends, provider status, memory lines, the last five decisions and the tick's warnings. The maintenance and providers sections are single-topic views of the same snapshot.

health_history

Returns rolling-window statistics for one canonical metric.

ParameterTypeRequiredMeaning
metricstringyesA canonical metric name, e.g. gpu_temp_c.
window_minutesnumbernoRestrict output to windows at or below this size.

An unknown metric name throws with the full canonical list in the message.

{
  "metric": "process_rss_bytes",
  "windows": [
    {
      "window_minutes": 30,
      "count": 120,
      "mean": 2540000000,
      "p95": 2870000000,
      "max": 2900000000,
      "latest": 2880000000,
      "slope_per_hour": 1200000000,
      "r_squared": 0.9821,
      "consecutive_ms": 0,
      "span_ms": 1785000
    }
  ]
}

consecutive_ms is the sustain-gate duration: how long the metric has held its current band. The full payload also carries min, earliest and median per window.

health_policy

Explains the decision policy. Parameters: action (explain | config | decisions, optional, defaults to explain).

health_policy({ "action": "explain" })

Action ladder (enter/exit pressure):
  1 THROTTLE              enter >= 55, exit <= 45
  2 PAUSE_NEW_WORK        enter >= 70, exit <= 60
  3 REQUEST_APP_RESTART   enter >= 80, exit <= 68
  4 REQUEST_SYSTEM_REBOOT enter >= 95, exit <= 85

Levels 1 and 2 are applied by this plugin through the worker-control adapter.
Levels 3 and 4 are *requests* handed to dsh-restart, which owns restart execution.
A restart request is additionally gated by the maintenance window and by getMaintenanceReadiness().

Dimension weights: time=0.15, thermal=0.2, memory=0.25, runtime=0.15, worker=0.15, computer_use_ui=0.1
Weights are renormalized over the dimensions that actually have telemetry; the fraction that does is reported as coverage.

config returns the resolved numbers as JSON (preset, thresholds, weights, cooldowns, anti_flap, maintenance, windows_ms, sampling). decisions returns the last 20 audit records as JSON.

Configuration

Configuration is a partial document. It is deep-merged onto the selected preset, so every leaf you omit comes from the preset; arrays replace rather than merge. The plugin registers the settings namespace health-scheduler when the profile provides a settings service, and runs from the bundle patch alone when it does not.

One caveat belongs here rather than in the configuration reference: the profile patch layer is not a deep merge. A cordis.patch.yml row replaces the targeted row's whole config, so an override that restates only part of a nested object loses the rest. The deep merge described in this section applies to the document the plugin actually receives. See What the bundle contributes.

The complete key-by-key reference, including the stats-file format and the command-probe format, is in docs/configuration.md.

GroupKeysDefault
Masterenabled, presettrue, balanced
samplingintervalMs, trendIntervalMs, summaryIntervalMs, providerBackoffMs, providerBackoffMaxMs15 s, 60 s, 300 s, 30 s, 600 s
windowsrawMs, windowsMs, aggregateBucketMs, aggregateRetentionMs, dailyRetentionMs30 min, [5m,30m,2h,6h], 5 min, 24 h, 14 d
trendminSamples, minSpanMs, minRSquared3, 5 min, 0.5
weightstime, thermal, memory, runtime, worker, computer_use_ui0.15 / 0.20 / 0.25 / 0.15 / 0.15 / 0.10
thresholdsone {enter, exit} band per action55/45, 70/60, 80/68, 95/85
metricsper canonical metric: band, weight, sustainMs, trendPointsPerHour, trendCapsee docs/metrics.md
cooldownsthrottleMs, maintenanceMs, escalationMs5 min, 30 min, 60 min
throttleconcurrencyLimit, concurrencyFactornull, 0.5
maintenanceenabled, targetTime, windowStart, windowEnd, maxDeferMs, urgentOverridePressure, allowAppRestart, safePointRequiredfalse, 04:00, 03:30, 05:00, 60 min, 92, true, true
antiFlapminStateDwellMs, minRepeatActionMs, debounceEvaluations120 s, 10 min, 2
resilienceproviderFailureLimit, providerRetryAfterBackoff, reportDegradedCapability3, true, true
storageenabled, directory, maxLogBytes, maxRecentDecisionstrue, null, 4 MiB, 50
ProvidersdisabledProviders, providerOptions[], see docs

A configuration that cannot be acted on is rejected loudly. resolveConfig throws a ConfigError naming the dotted path; the plugin's apply catches it, logs it and continues on the balanced preset rather than failing the boot.

How much history you get

Three horizons, all bounded, all configurable:

HorizonWhereDefaultAnswers
Raw sampleswindows.rawMs30 min, raised to the longest windowsMs entryPercentiles, slopes, consecutive-band duration.
Aggregate bucketswindows.aggregateRetentionMs, aggregateBucketMs24 h, 5 min bucketsLong-horizon movement without keeping raw points.
Daily summarieswindows.dailyRetentionMs14 days"Was last Tuesday worse than today?" — one {count, mean, max, min} per metric per local day.

Daily summaries are rolled up from the aggregate buckets on demand, so their cost is proportional to the number of buckets, not to uptime. They are published on every HealthSnapshot as dailySummaries and in the JSON payload as daily_summaries, and they are frozen at local midnight boundaries so a day is comparable across a DST change.

Presets

All three presets start from the same neutral balanced document and differ in exactly four places. PRESET_SCALES in src/core/presets.ts is the whole story:

conservative: { bands: 0.85, cooldown: 1.5, maintenance: 0.85 }
balanced:     { bands: 1,    cooldown: 1,   maintenance: 1 }
aggressive:   { bands: 1.15, cooldown: 0.7, maintenance: 1.3 }

scalePreset multiplies every ladder enter and exit by bands, so hysteresis keeps its proportion instead of flattening; it multiplies the three cooldowns by cooldown; and it multiplies maxDeferMs, minStateDwellMs and minRepeatActionMs by maintenance. urgentOverridePressure is divided by bands, so a lower-pressure machine treats pressure as urgent sooner. Scaled thresholds are clamped into 1 … 99, so no rung can be scaled out of reach.

Fieldconservativebalancedaggressive
thresholds.throttle enter / exit47 / 3855 / 4563 / 52
thresholds.pause_new_work enter / exit60 / 5170 / 6081 / 69
thresholds.request_app_restart enter / exit68 / 5880 / 6892 / 78
thresholds.request_system_reboot enter / exit81 / 7295 / 8599 / 98
cooldowns.throttleMs7.5 min5 min3.5 min
cooldowns.maintenanceMs45 min30 min21 min
cooldowns.escalationMs90 min60 min42 min
maintenance.maxDeferMs51 min60 min78 min
maintenance.urgentOverridePressure1089280
antiFlap.minStateDwellMs102 s120 s156 s
antiFlap.minRepeatActionMs8.5 min10 min13 min

Note that a preset preserves the relative width of every hysteresis band rather than flattening it, and entries are capped at 99 so no rung can be scaled out of reach: a pressure above 100 does not exist, so an entry of 109 would silently delete that rung. On aggressive the REQUEST_SYSTEM_REBOOT rung needs a perfectly saturated model (entry 99, exit 98), which is exactly the "only under real duress" behaviour that preset is meant to have. The metric table itself — every band, sustainMs and trend point — is identical in all three presets.

The three presets are also shipped as JSON under presets/ for diffing, together with a JSON Schema for a whole configuration document.

Capabilities and degradation

The plugin degrades; it does not fail. Each of these is a normal, well-typed outcome.

SituationWhat happens
dsh-restart is not installed, or reports itself unusableUnavailableRestartAdapter reports capability: 'unavailable'. Monitoring and throttling continue. A restart decision is downgraded to PAUSE_NEW_WORK with reason restart_capability_unavailable. A dsh-restart whose configuration disables every restart mode reports unavailable too, rather than accepting requests it could never carry out.
No worker-control service is boundUnavailableWorkerControlAdapter reports unavailable. A THROTTLE attempt returns applied: false with a detail explaining it; the tick continues.
A provider throwsThat provider only is disabled for an exponential backoff (providerBackoffMs * 2^steps, capped at providerBackoffMaxMs, starting once consecutive failures reach providerFailureLimit). Its metrics simply stop arriving and become unknown.
A provider returns degraded: trueThe metrics it did measure stay authoritative; the note is surfaced in the snapshot's warnings.
A sensor does not existThe metric key is omitted. The dimension may become unknown, coverage drops, and the reason list says so.
No stats file exists yet / it is staleNothing is reported and a detail names the paths that were looked for; a stale file's contents are refused and the sample is degraded with the staleness detail.
A configured helper command fails or times outThe probe error is reported as a degraded sample; already-measured metrics are kept.
The decision log is not writableDecisionLog counts the failure and exposes it via lastError; the scheduler keeps ticking.
No safe-point source is registeredfoldReadiness returns safe: null. With safePointRequired: true a restart request is blocked with reason safe_point_unknown — an unanswered question is not a yes. A source that throws or hangs contributes an unknown reading after a 1-second per-source budget.
The settings service or tool runtime is absentA warning is logged and the plugin runs from the bundle patch alone, or registers no tools. A ConfigError from a bad user document is logged and the balanced preset is used instead of failing the boot.

Paired with dsh-restart