dsh-web
zhu1090093659/dsh-webDeepSeek Harness(DSH)Web 插件聚合生态包 · 一切皆插件,创意工坊分发(dsh-market.com)
DSH plugin: safe restart execution for DeepSeek Harness — request validation, checkpoint gating, restart locking, graceful shutdown, crash-loop breaking and an external supervisor. It never decides when to restart.
Safe restart execution for DeepSeek Harness — it decides how to restart safely, never whether one is warranted.
On load it publishes its restart adapter on the harness context as ctx.healthScheduler,
which is where dsh-health-scheduler
looks for it — so a profile that lists both bundles connects without a glue script.
English | 中文
dsh-restart receives a restart request, validates it, takes an exclusive lock, gates on a checkpoint, writes a checksummed ticket, asks the host for a graceful shutdown, and lets an external supervisor observe the exit and relaunch. It contains no health policy, no temperature thresholds, no time-of-day scheduling and no task-queue access. Its worst-case failure is "automatic restart unavailable" — never "DS-Hns unavailable".
The boundary is the product. Everything in the right-hand column belongs to a different component, and nothing in it may be pulled back into this one.
| Responsibility | dsh-restart | Health Scheduler | Supervisor | DS-Hns Core |
|---|---|---|---|---|
| Sense pressure (CPU, memory, thermal, runtime) | no | yes | no | no |
| Judge whether a restart is warranted | no | yes | no | no |
| Schedule maintenance windows | no | yes | no | no |
| Accept and validate a restart request | yes | submits | no | no |
| Enforce the exclusive restart lock | yes | no | no | no |
| Enforce cooldowns and duplicate suppression | yes | no | no | no |
| Gate on a checkpoint / safe point | yes (asks, believes only the answer) | no | no | yes (produces it) |
| Save and restore task state | no | no | no | yes |
| Write the restart ticket | yes | no | no | no |
| Request a graceful shutdown | yes (through a port) | no | no | owns its own exit |
| Observe the exit and relaunch | no | no | yes | no |
| Break a crash loop / enter safe mode | no | no | yes | no |
| Read or modify the task queue | no | no | no | yes |
| Reboot the operating system | gated three ways, and wired (see Safety notes) | requests it | no | no |
Read that table as three seams: dsh-health-scheduler decides, dsh-restart executes, the supervisor relaunches. Each can be absent without breaking the others.
dsh-restart ships as a DSH bundle: package.json declares
dsh.bundle.patch = ./cordis.patch.yml, and that patch inserts exactly one plugin row.
dsh plugin --profile web add D:\dsh-plugin-develop\dsh-restart
dsh --profile web --dump-config | Select-String dsh-restart
The first command appends the package to the profile's dsh.profile.bundles; the second proves the composed profile actually mentions it. Do not treat the install as done until --dump-config shows the plugin: a bundle that fails to resolve leaves the profile looking installed.
dsh plugin --profile web add dsh-restart
# or, from a packed artifact
npm pack
dsh plugin --profile web add .\dsh-restart-0.1.0.tgz
These are the recommended paths. The published package contains lib/, src/, bin/,
cordis.patch.yml, this README and the license, so no build step runs on the installing
machine.
dsh plugin --profile web add github:dsh-restart/dsh-restart
A git install runs the package's prepare/prepack script, which is
npm run build — arbitrary code execution at install time, gated by your package
manager:
prepare for git dependencies unless the dependency is allowed
to run scripts; a profile that installs with --ignore-scripts (or a managed profile
that predates an allowBuilds entry for this package) gets lib/ missing.onlyBuiltDependencies / allowBuilds in
the profile's pnpm-workspace.yaml (or .npmrc) before it will run prepare.lib/ is already built and shipped.If a git install loads and then reports no lib/index.js, that is this caveat and not a
plugin bug: run npm run build in the checkout, or install from npm/tarball instead.
pwsh -File scripts/install.ps1 # register, verify, smoke test
pwsh -File scripts/install.ps1 -WhatIf # print every action, change nothing
pwsh -File scripts/install.ps1 -SkipSupervisor # register only
install.ps1 registers the plugin, verifies the profile with --dump-config, optionally launches the supervisor detached, and smoke-tests node bin/supervisor.mjs --help. It is idempotent: it asks the profile whether the plugin is already registered before adding it, and it stops rather than adding a second registration on a guess. See docs/operations.md.
The supervisor is a separate, long-lived process. It is the only thing that can relaunch DS-Hns after it exits, and it must outlive the process it watches, so it is started detached:
node bin/supervisor.mjs --state "$env:DSH_HOME\restart" -- node dsh.js --profile web
Usage:
node bin/supervisor.mjs [options] [-- <launch command>]
Options:
--state <dir> directory holding ticket.json, heartbeat.json and ledger.json
(default: %DSH_HOME%\restart, else ./.dsh-restart)
--pid <pid> watch this pid instead of the parent process
--tick-ms <ms> poll interval (default: supervisor.pollIntervalMs)
--max-ticks <n> stop after n ticks (default: run until a terminal state)
--terminate-after-verify exit after a relaunch is verified, instead of resuming
-h, --help show this message
Why it must be detached. The supervisor's job is to be alive when DS-Hns is not. A
child process of DS-Hns would die with it, which is precisely when it is needed. Start it
detached, with Start-Process -WindowStyle Hidden or the Task Scheduler, and give it its
own log file.
Why -- <launch command> matters. Without --, the supervisor derives the relaunch
command from its own argv, which is bin/supervisor.mjs — not DS-Hns. Always pass --
in a deployment, or set supervisor.launchCommand in the configuration.
What it writes, all inside --state <dir>:
| File | Written by | Contents |
|---|---|---|
ticket.json | plugin | the checksummed restart ticket (see docs/protocol.md) |
heartbeat.json | supervisor | schemaVersion, supervisorPid, watchedPid, state, timestamp, sequence |
ledger.json | supervisor | uncleanStarts, safeMode, safeModeReason, safeModeAt, relaunches |
supervisor.log | supervisor | one JSON object per line, mirrored to stderr |
restart-attempts.jsonl | plugin | the append-only audit log; rotated to .bak at storage.maxLogBytes |
Exit codes (verified against the real binary):
| Code | Meaning | What an operator should do |
|---|---|---|
0 | the run reached a terminal state (supervisor_stopped, e.g. TICK_LIMIT, VERIFIED) | nothing |
1 | fatal: an unknown option or an unhandled error | read the stack trace on stderr; fix the command line |
2 | the state directory could not be created | fix the path or its permissions |
3 | safe mode: the crash-loop breaker is tripped, or the run ended in CRASH_LOOP | a human must look at the machine; see recovering from safe mode |
Exit code 3 is deliberately distinct: a wrapper script or the Task Scheduler can tell "a human must look at this" apart from "it worked".
requester (dsh-health-scheduler / dsh-cli / operator)
│ RestartRequest
▼
validateShape ──► INVALID_REQUEST
▼
duplicate ledger ──► DUPLICATE_REQUEST_ID
▼
validateRequest ──► DISABLED · UNKNOWN_SOURCE · MODE_NOT_ALLOWED
│ SYSTEM_REBOOT_NOT_PERMITTED · CRASH_LOOP
│ RESTART_IN_FLIGHT · COOLDOWN_ACTIVE
│ CHECKPOINT_FAILED · SUPERVISOR_ABSENT
▼
lock.transition(REQUESTED) ──► RESTART_IN_FLIGHT (illegal edge)
▼
lock CHECKPOINTING · gate.prepare() ──► CHECKPOINT_FAILED · CHECKPOINT_REQUIRED
▼
write ticket.json (atomic, checksummed) ──► TICKET_WRITE_FAILED
▼
lock SHUTTING_DOWN · shutdown.requestShutdown(ticketId)
│ └──► SHUTDOWN_PORT_UNAVAILABLE
▼
accepted:true ─ the process is expected to exit now
▼
── process boundary ──
▼
supervisor: MONITORING → WAITING_FOR_EXIT → RELAUNCHING
→ WAITING_FOR_HEARTBEAT → VERIFIED → MONITORING
The plugin's part ends at accepted: true. Everything after the process boundary is the supervisor's, and it is driven by the ticket on disk rather than by anything held in memory — which is what lets the whole thing survive the restart it is performing.
reason in a RestartResponse is one of these. state is rejected when the request
never started, failed when it started and was aborted. All strings below are the real
detail values.
| Code | Trigger | Fix |
|---|---|---|
INVALID_REQUEST | the request is not an object; a required field is missing, empty, oversized or contains control characters; mode is not application/system; checkpointRequired is not a boolean; priority is unknown; a field has the wrong type | send the shape in docs/protocol.md; requestId ≤ 128, source ≤ 64, reasonCode ≤ 64, reasonSummary ≤ 500 characters |
INVALID_REQUEST (priority) | priority is valid but not listed in allowedPriorities | add it to allowedPriorities, or use an accepted priority |
DISABLED | enabled: false | enabled: true, or scripts/enable.ps1 |
UNKNOWN_SOURCE | source is not in allowedSources | add the requester to allowedSources |
MODE_NOT_ALLOWED | the mode's own enabled is false (systemRestart.enabled is false by default) | enable that mode — and, for system, also allowSystemReboot |
SYSTEM_REBOOT_NOT_PERMITTED | allowSystemReboot is false, or the request omits acknowledgeSystemReboot | both gates must pass: configuration and the request's own acknowledgement |
CRASH_LOOP | the crash-loop breaker has disabled automatic restart | fix the underlying crash, then clear the breaker (scripts/enable.ps1) |
DUPLICATE_REQUEST_ID | the same requestId replayed with different content | use a new requestId; an identical replay returns the first answer instead and is not a refusal |
RESTART_IN_FLIGHT | the lock is not IDLE, or the lock refused the transition | wait for the in-flight restart, or let it fail; never queue a second one |
COOLDOWN_ACTIVE | a restart of this mode happened less than minIntervalMs ago | wait out cooldowns.<mode>.remainingMs, or change the configured bound deliberately |
CHECKPOINT_FAILED | no checkpoint port is bound and checkpointRequired is true; the port threw; the port did not answer inside safety.shutdownTimeoutMs | bind a checkpoint port (applyRestart option checkpoint), or set checkpointRequired: false on requests that genuinely do not need one |
CHECKPOINT_REQUIRED | the harness answered that it is not safe to restart (e.g. git_commit_in_progress) and the request required a checkpoint | wait for the safe point; do not force it |
SUPERVISOR_ABSENT | no heartbeat inside supervisor.heartbeatTimeoutMs | start the supervisor; or set safety.allowRestartWithoutSupervisor: true and accept that exiting is then not restarting |
SHUTDOWN_PORT_UNAVAILABLE | the shutdown port returned false, or threw | wire a lifecycle into applyRestart, or accept that a graceful restart is impossible |
TICKET_WRITE_FAILED | the ticket could not be published on disk | fix the state directory's permissions or free space; the ticket is deleted and the lock released before the refusal is returned |
Two more machine-readable codes appear in the audit log but never as a reason:
ACCEPTED (the attempt was accepted) and CANCELLED (the requester cancelled it).
validateShape runs before everything, so a malformed request never reaches the cooldown
ledger, the duplicate map or the code that writes files. Then, in order:
enabled → allowedSources → allowedPriorities → mode enabled → system gates →
crash-loop breaker → duplicate id → lock → cooldown → checkpoint port → supervisor
presence. Cheap structural refusals come first; the refusal an operator sees is the first
one that applies, not a summary of all of them.
The design document's six cases, and what this code actually guarantees for each. Full detail, including what an operator sees and how to recover, is in docs/failure-modes.md.
| # | Case | Guaranteed behaviour |
|---|---|---|
| 1 | The plugin crashes | DS-Hns keeps running. The plugin never owns the app's lifecycle: it has no timers that exit the process, and a throw inside apply cannot prevent the host from booting. A restart that was mid-flight leaves a ticket; the next process's reconcileAfterRestart() deletes it and reports it. |
| 2 | The supervisor crashes | DS-Hns keeps running. The heartbeat goes stale, supervisor.present becomes false within heartbeatTimeoutMs, and every later request is refused with SUPERVISOR_ABSENT. Automatic relaunch is lost; graceful restart is still refused rather than downgraded to a shutdown. |
| 3 | Checkpoint failure | The restart is aborted, never downgraded. An unsafe answer, an incomplete checkpoint, a thrown port, a timeout and an unbound port all produce CHECKPOINT_FAILED/CHECKPOINT_REQUIRED, the ticket is deleted and the lock is released. Task state is not touched by this plugin at all. |
| 4 | Shutdown hangs | Implemented, with a bounded wait. The plugin returns accepted: true after the host accepts the shutdown request and never waits for its own exit, so it cannot hang. The supervisor's WAITING_FOR_EXIT state has no deadline, so a hung shutdown is waited on indefinitely; safety.allowForceTerminate and the design's "record a dirty restart" are not implemented. See Not implemented yet. |
| 5 | No heartbeat after a restart | The supervisor counts an unclean start when the relaunched pid is not alive after relaunchTimeoutMs, relaunches again, and after crashLoopLimit failures inside crashLoopWindowMs trips the breaker and enters safe mode (exit code 3). Retries are bounded by the breaker, not by a backoff curve. |
| 6 | Request storm | Deduplicated by requestId (an identical replay returns the first answer), serialised by the exclusive lock, and rate-limited by the per-mode cooldown. Priority never bypasses any of the three. |
Every key is optional; the values below are the shipped defaults, and each is documented
with the risk of changing it in cordis.patch.yml. Types and effects
in full: docs/operations.md.
| Key | Type | Default | Effect |
|---|---|---|---|
enabled | boolean | true | master switch; false refuses every request with DISABLED |
applicationRestart.enabled | boolean | true | whether application restarts may happen |
applicationRestart.minIntervalMs | number | 1200000 | enforced cooldown between application restarts (20 min) |
systemRestart.enabled | boolean | false | whether the system mode may be used |
systemRestart.minIntervalMs | number | 3600000 | enforced cooldown between system restarts (60 min) |
allowedSources | string[] | dsh-health-scheduler, dsh-cli, operator | who may submit; an empty list is a ConfigError |
allowedPriorities | string[] | low, normal, high, emergency | accepted priority vocabulary |
allowSystemReboot | boolean | false | second gate for mode: system; a request must also acknowledge |
safety.checkpointRequired | boolean | true | whether a requested checkpoint must succeed |
safety.duplicateSuppression | boolean | true | whether a replayed requestId returns the previous answer |
safety.crashLoopLimit | number | 3 | unclean starts inside the window that trip the breaker |
safety.crashLoopWindowMs | number | 600000 | rolling breaker window (10 min) |
safety.safeModeOnLoop | boolean | true | whether tripping the breaker also enters safe mode |
safety.shutdownTimeoutMs | number | 90000 | shutdown budget, also used as the checkpoint budget |
safety.allowForceTerminate | boolean | false | declared, validated, never consulted by this release |
safety.allowRestartWithoutSupervisor | boolean | false | whether to exit with nobody to relaunch |
supervisor.heartbeatIntervalMs | number | 5000 | supervisor heartbeat period |
supervisor.heartbeatTimeoutMs | number | 30000 | age after which the supervisor counts as absent |
supervisor.relaunchTimeoutMs | number | 90000 | time a relaunched pid has to be alive |
supervisor.launchCommand | string[] | null | null | relaunch command; null means "reuse the supervisor's argv" |
supervisor.launchArgs | string[] | [] | extra arguments appended to the relaunch |
supervisor.launchCwd | string | null | null | working directory for the relaunch |
supervisor.pollIntervalMs | number | 1000 | pid poll interval |
supervisor.ticketTtlMs | number | 600000 | how long a pending ticket stays valid |
supervisor.detach | boolean | true | whether the supervisor runs detached — declared and validated, read only by spawnSupervisor(), which this package never calls: see the divergence note below. Only the uncalled spawnSupervisor() consults it |
storage.directory | string | null | null | audit-log directory; null = the state directory |
storage.maxLogBytes | number | 4194304 | audit log rotation threshold |
storage.maxRecentAttempts | number | 25 | attempts kept in memory for status |
knownReasonCodes | string[] | nine codes | codes accepted without complaint; unknown ones are logged, not refused |
Invalid documents are refused at load with a dotted path, and the plugin continues on the defaults rather than failing the host's boot. For example:
dsh-restart config: supervisor.heartbeatTimeoutMs must exceed supervisor.heartbeatIntervalMs (60000), received 30000
dsh-restart config: allowedSources must list at least one source; an empty list would refuse every request, including an operator request
Three tools are registered when the profile has a tool runtime. They are a convenience
over the same service the API exposes — a tool call bypasses no check: it goes through
validateShape, validateRequest, the lock, the cooldown and the checkpoint gate exactly
like any other requester, and it is attributed to the dsh-cli source.
restart_statusRead-only. No parameters. Real output, trimmed:
{
"timestamp": "2026-06-01T09:05:00.000Z",
"enabled": true,
"lock": "IDLE",
"can_restart": true,
"can_restart_reason": "OK",
"active": null,
"cooldowns": {
"application": { "nextAllowedAt": null, "remainingMs": 0, "minimumIntervalMs": 1200000 },
"system": { "nextAllowedAt": null, "remainingMs": 0, "minimumIntervalMs": 3600000 }
},
"crash_loop": { "tripped": false, "failuresInWindow": 0, "limit": 3, "windowMs": 600000, "trippedAt": null, "reason": null },
"supervisor": { "present": true, "lastSeenAt": "2026-06-01T09:04:58.000Z", "ageMs": 0 },
"capabilities": {
"applicationRestart": true,
"systemRestart": false,
"checkpointPort": true,
"shutdownPort": true,
"supervisorWatch": true
},
"recent": []
}
ageMs is the age of the last beat. TicketStore.heartbeatAgeMs() prefers the timestamp the supervisor wrote inside heartbeat.json and falls back to the file's mtime only when that timestamp is missing or unparseable, so lastSeenAt and ageMs normally describe the same instant. A supervisor that stopped beating leaves both frozen, so it reads as absent within heartbeatTimeoutMs — the honest reading.
restart_request| Parameter | Type | Required | Notes |
|---|---|---|---|
mode | application | system | yes | scope |
reason_code | string | yes | recorded, never parsed (e.g. RUNTIME_PRESSURE) |
reason_summary | string | yes | one line, recorded verbatim in the audit log |
checkpoint_required | boolean | no | defaults to true |
priority | low | normal | high | emergency | no | defaults to normal |
acknowledge_system_reboot | boolean | no | required for mode: "system" |
Accepted:
{
"accepted": true,
"state": "shutting_down",
"detail": "application restart accepted: the host is shutting down and the supervisor will relaunch it",
"requestId": "tool-1780000000000-482913",
"ticketId": "application-1780304400000-1-e2efb725"
}
Refused, with the reason reported verbatim:
{
"accepted": false,
"state": "rejected",
"reason": "SUPERVISOR_ABSENT",
"detail": "no supervisor heartbeat was seen; set safety.allowRestartWithoutSupervisor = true to restart anyway",
"requestId": "tool-1780000000000-482913"
}
restart_cancel| Parameter | Type | Required |
|---|---|---|
request_id | string | yes |
{
"cancelled": true,
"detail": "request tool-1780000000000-482913 cancelled; no restart will happen"
}
Cancellation is only possible while the request is still pending: not after the shutdown
has taken effect (relaunching/verifying), and not once the process that accepted it is
gone. When nothing is pending the tool says so rather than failing:
{
"cancelled": false,
"detail": "request tool-1780000000000-482913 is not pending (it may have completed, been refused, or never existed)"
}