edusrez/dsh-smart-restart

edusrez/dsh-smart-restart

DSH 重启后唤醒主 agent,让被打断的工作自动接着做;提供一个重启工具,以及可选的探针在启动异常时提前中止并告警。

‍ 开发与运行时agent-toolsdeepseek-harnessdsh

dsh-smart-restart

English | 中文

dsh-smart-restart demo — real-time restart

A real-time restart: the agent restarts the service (canary pre-flight passing, Canary: passed — restarting…), and the plugin's boot notification brings the very same session right back — the conversation continues automatically, no user prompt needed.

A DeepSeek Harness (DSH) host plugin that keeps the main agent aware of service restarts — without the user having to prompt it. On every boot it detects that a new process has taken over, wakes the target agent with a short "Smart-restart" notice (boot time, previous boot, downtime). New in v0.2.0 it added the smart_restart tool to restart DSH itself and return the notice to the exact session that asked; new in v0.3.0 it auto-detects when the service was stopped while an agent session was active, so even a plain systemctl restart the agent ran notifies that session at boot; new in v0.4.0 it can validate the launch first with an optional canary pre-restart gate that aborts the restart when an ephemeral boot fails. New in v0.5.0 it auto-detects the systemd unit from /proc/self/cgroup, so the tool and the canary work with zero config on systemd-managed installs.

Table of contents

Overview

A long-lived DSH instance restarts for many reasons: the agent installs or reconfigures a plugin and triggers a reboot, the host machine reboots, or the service is restarted under systemd. After any such restart the agent is up and running again, but it has no idea that anything happened — the previous session is gone. Today, the only way to get the agent to pick back up is for the user to tell it, usually with something like "you restarted".

dsh-smart-restart closes that gap. It detects the restart itself and wakes the main agent at boot with a short, self-contained notice describing what happened, so the agent decides whether to resume interrupted work, note the downtime, or simply acknowledge — no user prompt needed.

New in v0.3.0, the plugin also covers the unplanned restart of an active agent. It tracks the last active session and, on shutdown (SIGTERM/SIGINT), persists a shutdown-notice.json; on boot it pins the notice back to that session (when it was active within shutdownGraceMs). So a plain systemctl restart the agent ran — or a restart that happened while an agent was mid-task — is auto-notified without the user prompting. v0.2.0 let the main agent cause restarts: the smart_restart tool records the calling session and restarts DSH through systemd, returning the notice to that exact session.

  • Zero prompting — the user never has to tell the agent "you restarted".
  • Self-restart — the main agent can restart DSH itself and resume its task automatically afterwards.
  • Automatic wake — an idle main agent is woken and handed the notice on its own.
  • Targeted delivery — the post-restart notice returns to the session that requested it; otherwise the target config decides where it lands.
  • Read-before-edit guard — smart_restart reads the LIVE agent registry and refuses to restart while other sessions are mid-turn (unless force: true), so a blind interruption is structurally impossible.
  • Interrupted-head resume — heads whose turns were cut by a restart each get an automatic resume notice at boot, so the organization never hangs idle post-restart without knowing.
  • Precise context — the notice carries the boot time, the previous boot time, the downtime, and an optional reason.
  • Canary safety — an optional pre-restart gate boots an ephemeral DSH instance and aborts the restart when it fails (see below).
  • Self-contained — a single host bundle; nothing to run, no external service.

How it works

On every boot the plugin:

  1. Reads the previous marker — a durable marker.json ({lastBootAt, pid, dshVersion?}) stored under <DSH_HOME>/<stateDir>/.
  2. Detects a restart — a previous marker with a different pid than the current process.pid means a new process has started, i.e. the service restarted; downtime is now − previous lastBootAt, clamped to ≥ 0.
  3. Writes a new marker immediately, so the next boot can be compared against this one.
  4. Checks for a pending notice — if the smart_restart tool left a pending-notice.json before the previous restart, this boot pins delivery to that exact session (highest priority; the file is consumed once read).
  5. Checks for a shutdown notice — if there was no pending notice, the plugin reads shutdown-notice.json (written by the previous process on SIGTERM/SIGINT, recording the last active session). If that session was active within shutdownGraceMs, this boot pins delivery to it (second priority); otherwise the pin is skipped and delivery falls back to target. The file is consumed once read.
  6. Delivers the notice — once per boot via the source-agnostic agent/session-start hook (a pinned session is matched regardless of publication source, so a session that resumes with source: 'resume' is caught too), backed by a bounded poll (750ms tick, ~15s cap) that picks up a pinned session which resumes lazily late.

There are three delivery paths, depending on how the restart happened:

(a) Agent-initiated restart (smart_restart tool)

The main agent calls smart_restart(reason?). The tool:

  • validates the configured restartUnit token,
  • persists the pending notice synchronously (calling session + optional reason) to pending-notice.json under the state dir, before any spawn so it survives the imminent service kill,
  • spawns a detached setsid bash process (a ~1s delay lets the tool's response be written) that runs systemctl restart <unit> and survives this process being killed by systemd,
  • returns {ok: true, restarting: true, ...}.

On the next boot, step 4 above reads the pending notice, pins it to the calling session (highest priority), and the notice returns there — regardless of source — so the agent that asked resumes its interrupted task.

(b) Smart shutdown auto-detection (plain restart while an agent was active)

If there is no pending notice, the plugin looks for a shutdown-notice.json. This file is written synchronously by the previous process on SIGTERM/SIGINT, recording the last active session (tracked on agent/session-start and agent/pre-step) and its timestamp. On boot the plugin pins the notice to that session only if it was active within shutdownGraceMs of shutdown (recent activity ⇒ the user restarted while the agent was mid-task, so auto-notify). If the session was idle well before shutdown (the user probably restarted while idle), the pin is skipped and delivery falls back to target.

This is what makes a plain systemctl restart that the agent ran — or a restart that happened while an agent was active — auto-notify that session at boot, with no user prompt and no need to have called smart_restart.

Since v0.6.0 (unreleased — see CHANGELOG.md) the boot also auto-notifies the interrupted heads: every session of the recorded interrupted set that matches ignoredSessionPrefixes (Deepartments head-<postId> heads by default) receives its OWN resume notice once it comes live at boot — so after a restart that cut an active head turn, the organization is never left hanging in idle with no notice (fb-46). Heads are still never pinned as the single-session last-active target (the pin and the shutdownTarget gate keep ignoring head-*, preserving the v0.3.1 anti-spurious-notice behavior); only a head whose turn was genuinely cut is notified.

(c) External restart (systemd, host reboot, dev tools) — no active session

There is no pending notice and no usable shutdown notice (either none was written, or the last activity predates shutdownGraceMs). The boot falls back to the target config (primary | all | <session-id>) to decide which agent(s) get the notice. The source === 'startup' gate applies only to this non-pinned path.

Restart vs. HMR semantics

The detection is intentionally precise:

SituationDetected as a restart?
New OS process, previous marker existsYes — service (re)started.
Same OS process (in-process HMR / hot-reload)No — ignored.
First-ever boot (no marker)No — nothing to compare against.
Marker present but stale/corrupt lastBootAtYes — downtime reported as 0.

Only a genuinely new process counts as a restart. An in-process hot-reload keeps the same pid, so it is not treated as a service restart. Delivery happens once per boot: a deliveredIds set plus a primary guard (for the primary target) prevent the startup event and the fallback poll from double-sending.

Notice

The default notice (English) reads:

Smart-restart: the DSH service restarted at <iso>. Previous boot: <iso> (downtime ~2m 9s). If a task was in progress, resume it; otherwise reply with a one-line acknowledgment.

When a reason was recorded by smart_restart, it is appended (… reason: <reason>.) before the resume instruction. The previous-boot/downtime segment is omitted when no previous boot time is known, and the whole text is fully customizable via the notice config (see below).

Since v0.6.0 (unreleased) the interrupted list is sealed per session. The list is composed by session, never by post, so a seat whose session rotated can legitimately appear twice — on 2026-09-22 the live notice carried both the then-current head-quality-head-a352f359-… and its rotated-out predecessor head-quality-head-9e1cc8b2-…, rendered identically as (main), which made it impossible to tell the seat's holder from the outgoing session. Each entry now carries a seat seal resolved from the catalog:

LabelMeaning
[seat <postId>]This session is the current session of that catalog row.
[seat <postId> superseded by <sessionId>]This session is no longer the seat's holder; the named session superseded it.
[seat <postId> (retired post)]The row that lists this session is retired.
[not a catalog incumbent]No row of the measured catalog accounts for this session.

The seal is additive, never a gate. A session that is no longer the incumbent is still notified and still delivered to — its write is made attributable, not blocked. (Blocking it would have destroyed the only channel that carried state across a rotated-out quality-head's incarnations — fb-2418/fb-2419.) The same seal is appended to the boot log's interrupted sessions at shutdown: and interrupted heads (resume recipients): lines.

Both catalog documents are consulted — posts.json (<postId> rows) and hosts.json (host-<id> rows). Resolving against posts.json alone would mis-seal the live host session as "not a catalog incumbent". When neither document can be read, no seal is produced at all and the labels keep their pre-v0.6.0 shape: "could not measure the catalog" is never rendered as the positive claim "this session is not a member".

The smart_restart tool

Registered via ctx.tools.register in apply (so it is available to agent sessions) when toolEnabled is true (the default). It lets the main agent restart DSH itself through systemd.

Parameters

ParamTypeRequiredDescription
reasonstringnoOptional human-readable note, e.g. "installed dshmarket in stable+dev". Included in the post-restart notice.
causestringnoOptional intentional-restart family — one of canary, deploy or dshmarket (the FB-234 GRACE set). Before the kill the tool writes <runtimeStateDir>/restart-reason.json atomically (tmp + rename), so the next boot is treated as an intentional restart — the health sidecar's crash streak never rises over it and the restart registry attributes the row to this cause. Use it for an intentional restart of a HEALTHY process; omit it for any other restart (no marker — the usual crash semantics). A canary-gated call implies canary. (Unreleased, v0.6.0.)
canarybooleannoOptional canary pre-restart validation for THIS call: boots an ephemeral DSH instance and aborts the restart on failure (see Canary pre-restart validation). Overrides the configured canary for this call.
forcebooleannoExplicit override of the read-before-edit guard: restart even when OTHER sessions are mid-turn. The tool refuses (and returns the in-flight session list) unless this is true; the override and the list are ALWAYS visible in the log. Use only after explicitly confirming the in-flight work is safe to interrupt. WINS over wait: with force: true the tool never waits.
waitbooleannoDEFER the restart instead of refusing it: re-read the LIVE agent registry (the same in-process snapshot the guard uses — it sends no message and wakes nobody) until every OTHER session is idle, then restart. Bounded by waitMaxMs; once the cap is spent the tool returns the SAME loud in-flight refusal and states that the wait expired (nothing is spawned, nothing is persisted).
waitMaxMsnumbernoHard cap in milliseconds on a wait: true deferral. Default 120000 (2 minutes); an absent or invalid value falls back to the default, so a wait is never unbounded. Ignored without wait: true. It caps the waiting: the wait at the guard AND, when a canary gate runs, its post-canary re-check draw from the same budget — the re-check gets only what is left, so waitedMs (the accumulated guard + re-check wait) can never last 2x this cap. The canary window itself is charged against this budget while any is left; when it alone spends the budget, the re-check receives a bounded grace = the cap minus what the guard actually waited (the guard's unspent share, which is exactly what the window consumed), so the total waiting still never exceeds this cap. The canary window is NOT capped by this value: it keeps its own measured budget, because a short waitMaxMs must never blind the launch-token capture (fb-2309).

Behavior

  • Validates the configured restartUnit — a single systemd unit token (/^[A-Za-z0-9_.@-]+$/, no spaces/slashes) to prevent shell injection into the detached command.
  • Fails fast when restartUnit is not configured and auto-detection from /proc/self/cgroup finds no unit either (ok: false, error restartUnit not configured) rather than guessing a unit — an empty restartUnit is auto-detected first, and only a failed detection produces that error.
  • Refuses to restart while other sessions are mid-turn (read-before-edit guard, see below) — the live registry check runs before anything is persisted or spawned, and runs AGAIN after a canary pass, right before the spawn.
  • Persists pending-notice.json synchronously (before any spawn) so it survives the service kill and targets the restarting session.
  • Restarts via a detached setsid bash process (sleep 1 && systemctl restart <unit>) that outlives this process, then unrefs it.
  • Returns {ok: true, restarting: true, sessionId, reason} on success, or {ok: false, restarting: false, error} when it fails (a guard-blocked refusal also carries inFlight: string[]).

Read-before-edit guard

Since v0.6.0 (unreleased), smart_restart implements the read-before-edit pattern (fb-168): BEFORE persisting the pending notice or spawning the restart, the tool reads the live agent registry inside the DSH process — ctx.agents, a session whose status === 'running' has an active turn in flight (the same in-process signal Deepartments derives its "running" state from, and the marker of the exact work a restart would cut). This registry is updated synchronously by the harness on every agent/status transition, so the check can never go stale the way file-based state (e.g. a previously taken posts.json snapshot) could.

  • If any session other than the calling session is mid-turn, the tool returns {ok: false, restarting: false, error: 'refusing to restart: N other session(s) mid-turn (…)' , inFlight: [<ids>]} — the caller is excluded because it restarts itself intentionally and is pinned for resume.
  • An explicit force: true is the only way through; the override and the in-flight list are always written to the log ([smart-restart] smart_restart: FORCE override — restarting with N other session(s) mid-turn: …).
  • Alternatively, wait: true inverts the refusal into a bounded deferral: the tool re-reads the SAME live registry snapshot in a sleep loop (default poll 1s) until every other session is idle, then restarts — it creates no turns and wakes nobody. The wait is capped by waitMaxMs (default 120000); at the cap the tool returns the same refusal with waitTimedOut: true and an error stating that the wait expired, so a spent wait is never confused with the immediate block. The deferral runs at the guard point, BEFORE the calling-session flush and BEFORE the canary gate, and force: true always wins.
  • waitMaxMs is ONE budget for the WAITING. The canary window is exactly when a session starts a turn again (the window closes while it is being checked), so the post-canary re-check does not get a fresh cap: it runs the same waitForIdle with max(0, waitMaxMs − spent), where spent is the deferral window since the wait opened (the guard-stage wait + the calling-session flush + the canary window). waitedMs in the result is the accumulated wait of both stages (guard + re-check), so the published figure is the real input of that arithmetic.
  • The grace (the canary window is not capped by waitMaxMs). The window keeps its OWN measured budget: truncating the capture to a short waitMaxMs would leave it blind — no launch token, no cookie, a 401 on an auth-wall tree, and the canary would abort the restart of a healthy tree (the fb-2309 class). So when the canary window ALONE spends the wait budget (0ms left, stated as such in the log), the re-check does not get a zero-length wait: it disposes of a bounded grace = max(0, waitMaxMs − waited-at-the-guard) — the guard's UNSPENT share, which is exactly what the window consumed — announced in its own log line. Two honest facts, never one lie: the budget IS spent, and the grace is bounded. The invariant is about the waiting: guard + re-check ≤ waitMaxMs always (the normal path yields guard + (cap − spent), the grace path yields guard + (cap − guard) = cap). With a zero grace (the guard itself spent the cap) the behavior is unchanged: it decides on the CURRENT state and, with sessions still mid-turn, refuses immediately — never a zero-length wait. Either the restart happens, or there is a loud refusal — never a wait spent in silence.
  • The check runs at call entry and again after a canary pass (the canary window can be tens of seconds — the final gate reflects the current state, not the state at call entry).
  • A blind interruption is thus structurally impossible: the tool cannot spawn while other agents are running unless the caller explicitly confirms it.

Intended agent flow

install/change a plugin
  → call smart_restart(reason)   # e.g. "installed dshmarket in stable+dev"
  → DSH restarts (detached, ~1s)
  → after boot, the notice returns to THIS session (pinned)
  → the task continues automatically — no user prompt needed

Safety notes

  • The unit token is validated against a strict regex to block shell injection through restartUnit into the detached shell command.
  • The tool refuses to interrupt other sessions' live turns unless force: true is passed explicitly — a blind restart while agents are mid-turn is structurally impossible.
  • The tool targets a systemd-managed DSH install (setsid / systemctl); it does not apply to a bare process without a systemd unit.

Canary pre-restart validation

New in v0.4.0. Optional, opt-in, and fully generic — it works on any DSH install: restartUnit is the only tool config you may need to set (auto-detected since v0.5.0 when empty on systemd installs).

When enabled, the smart_restart tool can validate the launch before anything is persisted or restarted: it boots an ephemeral DSH instance from the same binary and profile as the systemd unit, verifies it starts, and only then proceeds with the real systemctl restart. A failed canary aborts the restart — no pending notice is written, nothing is spawned, and the calling session is alerted live (same plugin-source notice channel).

What it does, step by step