Back to home

Outblock

dsh-headless-resumable

Resumable drop-in replacement for the dsh (DeepSeek Harness) headless runner: deterministic session id, restore-and-continue, and a SIGTERM handler that settles the turn before exiting

Stars
0
Language
JavaScript
Created
Aug 16, 2026
Updated
Aug 16, 2026

Introduction

@outblock/dsh-headless-resumable

A resumable drop-in replacement for the dsh --profile headless runner.

The problem

@deepseek-ai/dsh-headless mints its session identity inside run():

agents.create({ sessionId: SessionId(`session-${randomUUID()}`), … })

and dsh --profile headless accepts no flag beyond --help. So an unattended run is unaddressable: its transcript lands on disk under an id nobody wrote down, and nothing can ask for it back. A process that dies — a Fly deploy SIGTERMs in-flight work on every deploy, and an agent build can run for hours — takes the whole run with it.

Everything needed to fix that is already in dsh core. ctx.agents.resume() is published API, the JSONL persistence backend is mounted in the headless bundle already, and its crash-repair path is specified and (see below) works. The only missing piece was a runner that reaches them.

What it is

The shipped headless bundle patch is a declarative entry list, so this is a replacement, not a fork: the overlay disables the stock headless-runner row and inserts this package in its place. It depends on dsh's published packages; it patches nothing and vendors nothing.

Not on npm — install it from git, which puts it at the same path (npm derives the folder from package.json's name, verified rather than assumed):

npm i github:Outblock/dsh-headless-resumable
dsh --profile headless --patch node_modules/@outblock/dsh-headless-resumable/cordis.patch.yml "<the brief>"
enveffect
DSH_RUN_SESSION_IDdeterministic session identity. Required to ever resume. Omitted → session-<uuid>, i.e. stock behaviour.
DSH_RUN_RESUME=1restore that session's persisted log and CONTINUE it
DSH_RUN_CONTINUATIONwhat to say to a resumed session (has a default)
DSH_RUN_MARKERpath for a terminal-outcome JSON marker
DSH_RUN_SETTLE_MSSIGTERM settle budget, default 20000

Every one of those is an ordinary plugin config key (sessionId, resume, continuation, markerPath, settleTimeoutMs); the environment mapping lives in the patch file, so a caller who prefers literals can write them there instead. Configuration is environment-driven rather than flag-driven because the shipped headless-startup provider parses exactly one positional and --help — extending the CLI would mean forking a second package for nothing.

Exit codes are the interface

A supervisor decides re-queue vs. abandon from these alone.

codemeaning
0the turn completed
1the turn ended not-completed, or the driver threw
17resume was asked for and the session could not be loaded
18resume with no sessionId
75SIGTERM/SIGINT settled the turn; session flushed and resumable (EX_TEMPFAIL)

17 is distinct because it is the one failure where retrying the same command changes nothing.

The three rules

  1. A failed resume never falls back to fresh. ENOENT, an unrepairable tail, a format-version mismatch — each exits 17. A fallback here spends a second full build and reports success: indistinguishable from a slow build, and double the bill. Loud failure is recoverable by a human in a minute.
  2. A resumed run gets a continuation, not the brief again. dsh replays the stored session, so the brief is already the first thing in it; sending it again reads as a fresh instruction to do what the last forty turns just did.
  3. SIGTERM settles the turn before exiting. dsh's launcher installs its own SIGTERM handler that force-exits 0 after a 5s disposal budget — an interrupted 90-minute build reporting success. This runner takes that handler over at mount time (Node dispatches signal listeners in registration order, and the launcher's is registered first, so displacing it from inside our own handler would be too late), then cancels the turn with keepInbox, waits for quiescence under a bounded timeout, flushes, writes the marker, and exits through the same ctx.appExit the happy path uses so the tree still disposes in order.

Crash consistency — measured, not assumed

Against @deepseek-ai/dsh 0.1.0-rc.6 on deepseek-v4-flash (the headless bundle's default), on a task with one counted external side effect per step:

  • SIGTERM mid-tool-call: exits 75, marker interrupted/flushed: true, resume continues the same session directory, on-disk seq continues the stored log, and no completed step is repeated.
  • SIGKILL mid-tool-call, 3/3 randomised rounds: same result. The killed log's last seq and the resumed pick-up were 322→327, 285→289, 336→340 — the gap is the backend's synthetic closers over the torn tail.

Also proven once on a real 38-minute agent build (deepseek-v4-pro, SIGTERM at 10 minutes): one session directory throughout, seq 44496 → 44498 → 164406, deliverable produced and its own validation gate passed. The work done before the kill was not redone — the input documents were read 5 times before it and 0 times after, and the resumed agent's first act was to inspect what it had already written.

The persistence backend's own contract (its README) is that every durable append is fsynced, an incomplete trailing frame is truncated and re-encoded with synthetic tool/step/turn closers, an interrupted tool call is replayed to the model as TOOL_OUTCOME_UNKNOWN, and a defect at or before the last committed turn/end is corruption and rejects. This runner relies on that contract and adds nothing to it.

Operational notes

  • One live writer per session. The persistence backend coordinates appends only inside the owning process. A supervisor must be sure the previous process is dead before relaunching with the same sessionId — a re-queue that races a still-draining container is the one way to corrupt a log that this runner cannot defend against.
  • Resume must run from the same cwd. The transcript lives under a project directory derived from the normalized cwd, so a resume launched elsewhere looks like ENOENT and exits 17 (loudly, but for a misleading reason).
  • DSH_HOME must outlive the container. Everything above is moot if the sessions root is on ephemeral disk; that is what the id was for.
  • The settle budget must fit inside the supervisor's kill timeout. The default 20000 wants a kill timeout of ~30s so the tree disposal that follows it also fits. Fly's default is 5s and Kubernetes' is 30s; under a short one, SIGKILL arrives first and the run resumes through torn-tail repair instead — which works, but pays a repaired tool call for it.

Testing

test/kill-resume.mjs is the proof: it launches the real binary, kills it at a randomised point, relaunches with resume, and asserts on outcomes — the task completes, the session directory is the same one, the on-disk seq continues, and the pre-kill side effects are not redone. It reads no filename or event shape from a constant; it finds the transcript by walking what the writer left. Nothing is mocked across the persistence boundary, because a fixture built by the reader can encode the same wrong assumption on both sides — which is exactly how we once shipped a resume that never worked once in its life while its suite stayed green.

DEEPSEEK_API_KEY=… node test/kill-resume.mjs --signal both --rounds 3

It drives a real model, so it costs a few cents and is not a CI-shaped test.