π¦ dsh-phoenix
August 31, 2026 Β· View on GitHub
Never-interrupt, resumable lifecycle for DeepSeek Harness (dsh)
dsh-phoenix is a persistent DeepSeek Harness host plugin that turns "plugin update β restart dsh" from a disruptive break into a graceful, seamless, resumable loop β so a running task finishes, the browser keeps up, and a long-running objective resumes and keeps evolving across restarts.

English Β· δΈζη
Why you need it
DeepSeek Harness is "everything is a plugin," and a dsh Web is a single long-running process managed by systemd. That creates three painful realities:
| Pain | What dsh-phoenix does |
|---|---|
| Updating a plugin means hard-restarting dsh, cutting off whatever is running | Gracefully restarts β waits until no agent is turning, so the current task finishes before the reboot. |
| After a backend restart the browser page drops its connection and freezes ("stopped") | Auto-reconnects β a tiny injected heartbeat reloads the page the moment the backend returns. |
| A restart destroys in-memory goal state, so an objective cannot continue across restarts | Re-arms the goal β after the reboot it re-activates a disarmed goal so a long-running objective resumes and keeps evolving. |
β¨ Features
1. Graceful restart β reboots only when it's safe
- Detects a plugin (re)activation through the dsh plugin tools (
cordis_run) and does not restart immediately. - Checks every live agent (including sub-agents): if any is
running, it defers and re-checks every few seconds, restarting only when the agent goes idle (a 5-minute cap prevents a never-restart). - Runs the restart through
systemd-run --useras an independent transient unit, so even the process being restarted survives until the reboot completes. - React only to
cordis_runβ client bundle source edits that dsh's own HMR reloads are notcordis_runoperations, so they do not trigger a dsh-phoenix reboot.
2. Client auto-reconnect β the page keeps up with the backend
- Registers a
/__dsh_healthendpoint that returns a per-boot token. - Injects a zero-dependency heartbeat into the served index: every few seconds it polls
/__dsh_health; when the token changes (the backend restarted), the page reloads itself. - No more "frozen / stopped" page. Backend restarts become invisible.
3. Cross-restart goal re-arm β a task keeps evolving
- Goals are durable (session-log backed), but dsh disarms an active goal on every restart, so automatic continuation stops.
dsh-phoenixreads a persistent checkpoint (a JSON state file); when it sayspendingResume: true, it finds the live agent's active + disarmed goal and callsgoals.resume()to re-arm it.- Re-armed, the harness's goal round driver continues driving the next iteration.
𧬠The whole point: a self-evolving loop
Combine the three and you get a cross-restart autonomous evolution loop (reproducible steps in docs/VERIFY.md):
read checkpoint β decide next step β test / modify the plugin
β write checkpoint (pendingResume) β graceful restart
β dsh boots β dsh-phoenix re-arms the goal β next round β¦ until done
The checkpoint is the loop's durable memory; the goal is its driver; the re-arm hook is its resurrection point.
π How this is different
| Project | Focus | dsh-phoenix adds |
|---|---|---|
| dsh-doctor | Self-healing startup: recover from plugin-induced boot failures, doctor runs, stuck-turn detection | Idle-aware graceful restart (never interrupt a running task), client auto-reconnect, goal continuation |
| dsh-daemon | Register dsh web as an auto-start self-healing background service | The resumable, self-evolving lifecycle on top of the process that's already there |
dsh-doctor and dsh-daemon heal the launch; dsh-phoenix makes the lifetime graceful, connected, and resumable. They are complementary β dsh-phoenix sits comfortably on top of a dsh-daemon-managed process.
Complement, not a competitor β two axes, one leak-proof layering
"phoenix" and "hot" often read as rivals β both are "about plugin updates," both so a change takes effect. That reading mistakes the system. A dsh process has two independent axes, and each family owns one.
Axis 1 Β· the composition (what dsh is made of)
dsh is a Cordis composition: a tree of plugin rows produced by diffing patch layers. It is applied at boot but not frozen β rows can be added, removed or swapped live through the loader's diff mechanism. dsh's own HMR (cordis-plugin-hmr) already hot-reloads this, but it deliberately ignores node_modules, so upgrading an already-installed plugin package still needs a restart β that is exactly the gap the hot family fills.
The hot family owns this axis. When you dsh plugin add/remove/update a bundle from the CLI, dsh-hot-installer watches the profile manifest (dsh.profile.bundles) and dsh-hot-reload watches pnpm-lock.yaml. They drive the same boot-time mount path live: resolve the package, read its dsh.bundle.patch, inject the rows into the running tree, re-import the new module (cache invalidation + fiber re-instantiation). On failure they roll back to the working version and flag "a restart is needed." They never touch the process β they never restart dsh.
Axis 2 Β· the process lifecycle
Some things are not composition rows; they live at the process boundary: the systemd user unit and its cgroup, the HTTP/WebSocket server, the in-memory goal activation, the browser's live connection. None of these hot-swap. When a reset crosses them, the honest action is a restart β and that restart is what dsh-phoenix owns.
dsh-phoenix makes it graceful (idle-aware β it waits until no agent is turning), connected (an injected heartbeat reloads the browser when the boot token changes), and resumable (it re-arms a disarmed goal so a long-running objective continues). It also runs the reboot via systemd-run --user as a detached transient unit, so the process being killed never drags the restart sequence down with it.
The boundary falls out of the two axes
They are disjoint because they trigger on different change paths, and each is the correct tool for the axis it owns:
| Change path | Axis | Handler | dsh-phoenix restart? |
|---|---|---|---|
dsh plugin add/remove/update (CLI, installed bundle) | composition | hot family β live mount / reload | No |
Plugin tree changed via the dsh cordis tools (cordis_run) | composition (runtime, agent-driven) | dsh-phoenix | Yes, gracefully |
A hot swap fails (bad import / apply throws) | composition | hot family rolls back and flags "restart needed" | dsh-phoenix makes that restart safe |
So it is not "who wins the same change" but prevent at the source, catch the leak: the hot family eliminates the avoidable restarts at the change entry; dsh-phoenix is the layer beneath that guarantees the remaining, genuine restarts never interrupt work, drop the browser, or kill an in-flight objective. That is a classic layered-systems separation, not a rivalry.
Note
This is a statement about current behavior, not a guarantee. Today dsh-hot-installer / dsh-hot-reload watch the profile manifest and lockfile and do not react to cordis_run; dsh-phoenix watches tools/result and does not watch the manifest. If either side later widens its trigger, re-check this table β the boundary is behavioral, not architectural.
π§ Durable lifecycle & safe-restart semantics
The restart β recovery β resume path is an explicit, durable state machine. State is persisted to DSH_PHOENIX_STATE_FILE atomically (.tmp + rename), so it survives a crash at any point, and a bad/missing checkpoint can never trigger a spurious resume.
States
IDLE ββrestart requestedβββΆ DEFERRED ββsafe point / deadlineβββΆ RESTARTING ββboot / crashβββΆ RECOVERING ββresumeβββΆ RUNNING
β² β
βββββββββββββββββββββββββββββββ new request (coalesced while in-flight) ββββββββββββββββββββββββββββββββββββββββ
| State | Meaning |
|---|---|
idle | no pending restart |
deferred | restart requested but an agent is running; waiting for a safe point |
restarting | the reboot is scheduled (systemd-run) |
recovering | booted after a restart/crash; bounded, idempotent resume of the recorded goal |
running | settled; no stale pending restart |
Transitions & invariants
idle/runningβ restart requested βdeferred(busy) orrestarting(idle); a newgenerationis minted.deferredβ safe point βrestarting; hard safety deadline βrestarting(forced, logged).restartingβ boot / crash βrecovering.recoveringβ resume, at most once βrunning.
Invariants
RESTARTING: no second restart may be scheduled (requests coalesce).RECOVERING: resume is attempted only for the recordedgeneration; a stalegoalId(no matching live goal) is invalidated, never resumed.RUNNING: no stalependingResumemay remain active.
Resume semantics β at-most-once per generation
pendingResume is not a loose boolean; the checkpoint also records generation, goalId and resumeAttempt. Before calling goals.resume() the plugin durably increments resumeAttempt (atomic write). If the process crashes mid-resume, the next boot sees resumeAttempt >= maxResumeAttempts (default 1) and does not resume again β so a goal is resumed at-most-once per generation. There is no infinite restart/resume loop: an in-flight restart is coalesced, and a failed/exhausted resume settles to running and clears pendingResume.
Safety deadline, not "restart now regardless"
The defer timeout is a deadline with escalation, not an unconditional timer:
deferred (agent busy)
ββ soft deadline β log a WARNING (agent still busy)
ββ hard deadline β if policy is 'auto', force the restart (logged)
if policy is 'wait', keep waiting (no forced restart)
DSH_PHOENIX_DEFER_POLICY=auto (default) preserves the protection against infinite defer; wait removes forced restarts (you accept a possibly-long defer). Phoenix cannot distinguish "agent busy" from "agent is in a critical section" β DSH exposes no such signal β so busy is treated as unsafe-to-restart, and the deadline is the safety valve.
Acceptance answers
- Crash at every point? The durable state survives; on boot a mid-cycle state (
deferred/restarting/recovering) moves torecoveringand settles idempotently. - Same goal resumed twice? No β at-most-once per
generation(resumeAttemptincremented durably before the call). - Stale checkpoint triggers a resume? No β a missing/corrupt checkpoint collapses to
idle; agoalIdthat matches no live goal is invalidated, not resumed. - Repeated update events β repeated restarts? No β in-flight requests coalesce; duplicate events produce one restart.
- Infinite restart/resume loop? No β in-flight coalescing, resume-attempt cap, and settle-to-
runningon failure/exhaustion. - Defer deadline expires while busy? Soft β warning; hard β force (if
auto) or keep waiting (ifwait), all logged. - Busy vs critical section? Phoenix cannot tell (DSH exposes no critical-section signal); it treats busy as unsafe and bounds it with the safety deadline.
- Transitions deterministic & testable? Yes β the state machine is pure functions; see
tests/(17 tests incl. crash/stale/duplicate/failure injection).
π¦ Requirements
Warning
The graceful-restart feature requires dsh to run as a systemd --user service (default unit dsh-web). On other setups β macOS, containers without systemd, or pnpm run dev:web β the restart is the one feature that cannot work. On those, either set DSH_PHOENIX_RESTART_CMD to a command that restarts your dsh process, or accept that only client auto-reconnect and goal re-arm are active. The plugin detects this at startup and logs graceful restart DISABLED so it never silently fails.
- A DeepSeek Harness
dshinstallation whose Web runs as a systemd --user service (default unitdsh-web). - Node
>= 22. - Zero external dependencies β the plugin uses only dsh's own runtime services (
timer,webServer,agents,goals,shell).
π Installation
dsh-phoenix is an official dsh bundle (it declares dsh.bundle), so it installs with the standard tooling.
From npm (recommended)
dsh plugin --profile <profile> add dsh-phoenix
From git / tarball
# git (needs a prepare build + allowBuilds on pnpm >= 10)
dsh plugin --profile <profile> add github:StvLi/dsh-phoenix
# tarball
pnpm pack && dsh plugin --profile <profile> add ./dsh-phoenix-0.1.0.tgz
Then verify the layer and start:
dsh --profile <profile> --dump-config # expect a "# == dsh-phoenix" layer
dsh --profile <profile>
Once installed, the row is injected automatically (see cordis.patch.yml):
- insert:
- id: dsh-phoenix
name: dsh-phoenix
βοΈ Configuration
All knobs are environment variables with safe defaults β no configuration file required.
| Env | Default | Purpose |
|---|---|---|
DSH_PHOENIX_UNIT | dsh-web | systemd user unit to restart |
DSH_PHOENIX_DELAY | 8 | seconds between stop and start |
DSH_PHOENIX_ARMING_MS | 5000 | ignore signals for N ms after load (prevents self-trigger) |
DSH_PHOENIX_DEBOUNCE_MS | 3000 | collapse burst signals into one reboot |
DSH_PHOENIX_DEFER_POLL_MS | 3000 | idle re-check interval while deferring |
DSH_PHOENIX_DEFER_SOFT_MS | 300000 | soft safety deadline β log a warning at this point, keep waiting |
DSH_PHOENIX_DEFER_HARD_MS | 900000 | hard safety deadline β force the restart (if policy is auto) |
DSH_PHOENIX_DEFER_POLICY | auto | auto (force at hard deadline) or wait (never force; may defer indefinitely) |
DSH_PHOENIX_HEALTH_MS | 4000 | browser heartbeat interval |
DSH_PHOENIX_RESTART_CMD | (empty) | custom restart command override for non-systemd deployments (see the Requirements warning) |
DSH_PHOENIX_REARM_MS | 8000 | boot delay before the lifecycle recovery/resume check |
DSH_PHOENIX_MAX_RESUME_ATTEMPTS | 1 | resume attempts per generation (1 = at-most-once) |
DSH_PHOENIX_STATE_FILE | (empty) | path to the durable lifecycle checkpoint; empty disables the lifecycle |
π§ How it works
- Detect β subscribes to
tools/resultand reacts to a plugin (re)activation (cordis_run). - Defer β if any agent is
running, hold the restart and re-check until idle (or the cap). - Reboot β
systemd-run --userschedulesstop β sleep β start, decoupled from the dsh cgroup. - Reconnect β the health endpoint + injected heartbeat reload the page when the boot token changes.
- Resume β on boot, reads the checkpoint; if
pendingResume, re-arms the disarmed goal viagoals.resume().
Everything logs [dsh-phoenix] to the dsh journal for easy inspection.
β Verify it works
The claims in this README are backed by a reproducible checklist in
docs/VERIFY.md and a unit-test suite (npm test, 10
tests). Quick start:
npm test # 17 tests: state machine transitions, resume at-most-once, stale/corrupt checkpoint, coalescing, defer escalation
# after a real plugin update, watch the journal:
journalctl --user -u dsh-web -f | grep dsh-phoenix
# you should see "deferring (agent busy)" then, at idle, "executing deferred restart"
# and, after the reboot, "re-armed goal after resume (rev=N)"
curl http://127.0.0.1:3080/__dsh_health should return {"token":"β¦"}.
π License
MIT Β© Steven P.LI