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>"
| env | effect |
|---|---|
DSH_RUN_SESSION_ID | deterministic session identity. Required to ever resume. Omitted → session-<uuid>, i.e. stock behaviour. |
DSH_RUN_RESUME=1 | restore that session's persisted log and CONTINUE it |
DSH_RUN_CONTINUATION | what to say to a resumed session (has a default) |
DSH_RUN_MARKER | path for a terminal-outcome JSON marker |
DSH_RUN_SETTLE_MS | SIGTERM 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.
| code | meaning |
|---|---|
0 | the turn completed |
1 | the turn ended not-completed, or the driver threw |
17 | resume was asked for and the session could not be loaded |
18 | resume with no sessionId |
75 | SIGTERM/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
- 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. - 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.
- 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 samectx.appExitthe 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, markerinterrupted/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_HOMEmust 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
20000wants 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.