Skip to content
dsh.fish
Bundle

dsh-safe-restart

dsh-safe-restart

Source
McKayGONG
Updated
Updated 2 days ago

Readme

# dsh-safe-restart

A macOS-only DSH host plugin that safely restarts the DSH process from inside
an Agent session. It terminates only the exact DSH listener process through a
detached helper, lets the browser reconnect through DSH's native
`ConnectionController` (no manual refresh), resumes the original persisted
session, and starts **exactly one** automatic continuation turn to verify the
restart and continue the interrupted task.

The plugin registers a single Agent tool, `restart_dsh`. There is no Web
button and no settings UI.

> Design: [`docs/plans/2026-08-22-dsh-safe-restart-design.md`](docs/plans/2026-08-22-dsh-safe-restart-design.md)

## What it does

- **One Agent tool: `restart_dsh`** — schedules a safe restart of the current
  DSH host, bound to the invoking Agent's current session.
- **macOS only** — every other platform is rejected before any side effect.
- **Verified on DSH `0.1.0-rc.8`** — the recovery coordinator relies on host
  internals that are still moving in the rc cycle (`agent/created`,
  `ctx.agents.resume` setup, `installModelSelection`). End-to-end restart +
  auto-continuation was verified on `0.1.0-rc.8` (standalone profile, macOS).
  The declared range is `>=0.1.0-rc.8 <0.2.0`; anything outside it is untested
  and may need adapter work.
  **After any DSH upgrade, re-verify:** `pnpm test`, then one real
  `restart_dsh` round — confirm PID change, HTTP 200, journal `consumed`, and a
  continuation turn whose `turn/end` reason is `completed` (not `error`).
- **Detached helper** — the journal write is durable first, then a helper
  process is spawned detached and unref'd; it survives host termination,
  replaces the DSH process, and records the outcome.
- **Native browser reconnect** — after the host comes back, the existing Web
  client reconnects through DSH's built-in connection/resync loop. No manual
  page refresh and no "continue" message are needed.
- **One automatic continuation turn** — the same persisted session resumes
  with its preset and model selection, and exactly one plugin-sourced
  continuation message is delivered per restart.
- **Interrupted streams are not resumed** — the exact token stream that was
  running when the host dropped is *not* continued. DSH closes that turn as
  interrupted; recovery starts a **new turn** in the same session.

## Tool schema

```jsonc
restart_dsh({
  "continue_after_restart": true,   // boolean, default true
  "continuation": "optional instruction" // string, optional, max 2000 chars
})
```

| Field | Type | Default | Meaning |
| --- | --- | --- | --- |
| `continue_after_restart` | boolean | `true` | Whether to deliver one automatic continuation turn after recovery. Set `false` for a pure restart with no continuation. |
| `continuation` | string (optional) | — | Task-specific instruction for the single continuation turn (max 2000 chars). When omitted, the default instruction is: *"Verify that DSH recovered successfully, then continue the pre-restart task using the current session and durable workspace state."* |

The target session is always the invoking Agent's current session; a caller
cannot name another session. The tool returns:

```jsonc
{ "restartId": "<uuid>", "scheduled": true, "continueAfterRestart": true }
```

## Installation

Install from the plugin repository root:

```bash
dsh plugin --profile standalone add /path/to/dsh-safe-restart
```

The plugin activates on the **next DSH start** — `restart_dsh` is not loaded
until then. Use an already-validated external restart path (e.g. a codex /
safe-restart script that stops and relaunches DSH and verifies HTTP 200) for
that one activation restart, then verify `http://127.0.0.1:<port>` returns
HTTP 200 before continuing.

## Usage

Ask the Agent to call `restart_dsh`. With the default
`continue_after_restart: true`, what the user sees is:

1. The host connection drops briefly (the tool response may be cut short).
2. The browser reconnects automatically through DSH's native
   `ConnectionController` — no manual refresh.
3. The original persisted session reappears with its preset and model.
4. Exactly one new assistant turn appears, carrying the restart ID marker
   `[restartId: <uuid>]`, verifying recovery and continuing the task.

A restart with `"continue_after_restart": false` performs the same safe
restart and browser recovery but adds no continuation turn.

## Safety

- **Never `killall`** — the helper terminates only the recorded PID, and only
  after verifying its exact command line against the launch identity recorded
  at schedule time (the current Node executable plus the DSH entry argument).
  An unrelated or unknown process is never touched.
- **One active restart** — a second `restart_dsh` while a record is in flight
  is rejected (`restart already active`).
- **60-second cooldown** — a restart within 60 s of the most recent one is
  rejected (`restart in cooldown`).
- **30-second health timeout** — after spawning the replacement, the helper
  polls the health endpoint for at most 30 s (PID exit and port release use a
  12 s budget).
- **No retry loop** — each `restartId` is executed at most once; a record that
  is not `scheduled` is refused, and a failed restart is never retried.
- **Exactly-once delivery** — before delivering the continuation, the
  coordinator scans the session's durable log for the restart ID marker; a
  previous delivery that crashed before the `consumed` transition is detected
  and consumed without re-delivering. An ambiguous window becomes
  `delivery-uncertain` instead of a blind retry.
- **No secrets persisted** — the journal and log carry no credentials and no
  environment values.
- **Interrupted token stream is not resumed** — the continuation is a fresh
  turn in the same session, never a replay of the interrupted generation.

## Storage

All durable state lives under `~/.dsh/storages/dsh-safe-restart/`:

| Path | Purpose |
| --- | --- |
| `active.json` | The single in-flight restart record (atomic writes: temp file → fsync → rename). |
| `.lock` | Exclusive restart lock, created with `open(..., 'wx')`; one active restart at a time. |
| `history/<restartId>.json` | Terminal records, archived when a record reaches a terminal phase. |
| `restart.log` | Timestamped, non-secret lifecycle lines: restartId, old/new PID, port-release result, HTTP result, terminal phase. |

### Phases

| Phase | Meaning |
| --- | --- |
| `scheduled` | The tool request was persisted; the detached helper has not claimed it yet. |
| `restarting` | The helper validated and terminated the old PID, spawned the replacement, and is waiting for health. |
| `ready` | The new host answered HTTP 200; `newPid` and `readyAt` are set. Waiting for the resumed session's Agent. |
| `delivering` | The continuation message was created and handed to the Agent; awaiting durable confirmation. |
| `consumed` | Terminal: exactly one continuation delivered (or, for `continueAfterRestart=false`, none) and archived. |
| `failed` | Terminal: the restart failed with a bounded, non-secret `error` message; never retried. |
| `delivery-uncertain` | Terminal: the continuation window was ambiguous (e.g. the host died between `delivering` and confirmation). Not blindly redelivered. |

### Troubleshooting

- **`failed` record** — check `restart.log` and the record's `error` field
  (e.g. PID validation failure, port not released, health timeout). The record
  is already archived; no action is required for future restarts.
- **`delivery-uncertain` record** — the continuation may or may not have been
  delivered. Check the session's durable log for the marker
  `[restartId: <uuid>]`:
  - marker present → the message was delivered; the record is archived, done.
  - marker absent → it was not delivered and will not be redelivered; decide
    whether to re-run the task manually.
- **Stale lockless `scheduled` record** — if the helper crashed before it
  could claim the record (or the claim failed), a `scheduled` record with no
  helper running and no lock can be left behind. Recovery polls it for a
  bounded window and then deliberately leaves it for inspection. It blocks new
  restarts (`restart already active`).
- **Ready but no Agent (blocks subsequent restarts)** — if a record reaches
  `ready` but the matching session's Agent never reappears (for example the
  session was deleted or the browser tab was never reopened), `active.json`
  stays in `ready` and **every later `restart_dsh` is rejected** until the
  record is resolved.

  **Remedy (manual, after inspection):**
  1. Inspect `~/.dsh/storages/dsh-safe-restart/active.json` — note its
     `restartId` and `phase` — and the latest lines of
     `~/.dsh/storages/dsh-safe-restart/restart.log` to see what happened.
  2. If the continuation marker `[restartId: <restartId>]` already exists in
     the session's durable log, the continuation was delivered; archive the
     record as `consumed`: move `active.json` to
     `history/<restartId>.json` with `"phase": "consumed"` and remove
     `active.json`.
  3. If the continuation was never delivered and never will be, archive the
     record with a terminal phase instead (`"failed"` or
     `"delivery-uncertain"`), then remove `active.json`.
  4. Check `~/.dsh/storages/dsh-safe-restart/.lock` — it records the owning
     `restartId`. Remove the lock **only** when its `restartId` matches the
     archived record; never delete a lock owned by a different restart.
  5. Leave `history/<restartId>.json` and `restart.log` in place — they are
     the audit trail. The next `restart_dsh` is then allowed again.

## Verification script

`scripts/verify-real-restart.mjs` is an external, read-only evidence collector
that runs **outside** the DSH process (plain Node ESM, zero dependencies). It
never performs a restart and never touches the live listener. It reads the
journal and probes the HTTP endpoint, then reports one JSON object:

```jsonc
{
  "oldPid": 111,            // replaced DSH PID, from the selected record
  "newPid": 222,            // healthy replacement PID (null when unknown)
  "pidChanged": true,       // newPid is set and differs from oldPid
  "http200": true,          // live GET http://127.0.0.1:<port>/ returned 200
  "phase": "consumed",      // lifecycle phase of the selected record
  "continuationCount": 1    // consumed records that delivered a continuation
}
```

```bash
node scripts/verify-real-restart.mjs --check-latest        # latest history record, default port 3081
node scripts/verify-real-restart.mjs --journal /path/to/journal --port 3000
node scripts/verify-real-restart.mjs --journal /tmp/fixture --port 0 --dry-run   # detection only, no HTTP
```

`--dry-run` prints the detection logic (listener PID via `lsof -ti tcp:<port>`
and a journal contents summary) without probing HTTP, so it can be exercised
without a live restart. It exits nonzero on malformed journal JSON, an
unusable listener detection, or unexpected errors. `continuationCount` counts
consumed records whose `continueAfterRestart` is `true` — per the design each
such record delivered exactly one continuation turn; pure-restart records
(`false`) delivered none and are not counted.

## Uninstall

Remove the plugin with the remove counterpart of the install command, e.g.:

```bash
dsh plugin --profile standalone remove dsh-safe-restart
```

then restart DSH once so the tool is dropped. If you also want to remove the
durable state, delete `~/.dsh/storages/dsh-safe-restart/` after confirming no
restart is in flight (no `active.json`).

## Recovery limitation

If the browser tab was closed during the restart, simply reopen it: DSH's
native reconnect restores the **same persisted session** (same preset, model,
and history). The continuation is delivered exactly once regardless of how
many times the tab reconnects.

Install

dsh plugin --profile web add github:McKayGONG/dsh-safe-restart

Profile: web

  • This package builds from source on install. pnpm will ask you to allow its build script — that is permission to run the package’s code on your machine, outside the agent sandbox. Only allow sources you trust.
  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source