Skip to content
Developer Preview

Survive the host dying under you

Hosts die. A crash, an out-of-memory kill, a stray signal from an operator — none of them ask your app first. This page shows you how to build a client that survives it: what to read at the handshake, what the SDK tells you when the process is gone, and how the answer forks on one word — durable or ephemeral.

The one idea to take away: read the session durability profile at the handshake, and branch on the discharge — never on your own guess. A durable session’s state outlives its host and reconciles when you resume. An ephemeral session’s state dies with the host, and the SDK hands you back exactly what was in flight so you can tell your user the truth.

The host declares its durability in the initialize result. Read it through readSessionDurability the moment you connect — after the host dies is too late to ask it:

import { MuseClient, readSessionDurability } from "@muse-code/sdk";
const client = await MuseClient.spawn({
museBin: "/usr/local/bin/muse",
args: ["serve"],
clientInfo: { name: "my-app", version: "1.0.0" },
});
const profile = readSessionDurability(client.initializeResult);

The profile has three readings, and the third is the one to get right:

  • Absent means durable. The member is optional only so adding it was backward compatible; a host that omits it is not ephemeral.
  • "ephemeral" means nothing survives the host. Plan for the discharge below.
  • A value you do not recognize guarantees nothing. Do not write value === "ephemeral" ? ephemeral : durable — that silently grants a durability promise to a value your SDK has never heard of. The conservative read is “assume nothing survives this host”, and readSessionDurability encodes exactly that: it returns { kind: "unrecognized" }, which the discharge treats like ephemeral.

You learn about a death two ways: the process exit (client.exit resolves with the SDK’s classification of how the host ended) and the transport going quiet. Report each one to your sessions with Session.hostExited — reporting the same death twice is not just safe, it is required, because the second report settles anything the first one’s drain left open.

hostExited returns a discharge, and its kind is your whole branch:

const exit = await client.exit;
const discharge = session.hostExited(exit);
switch (discharge.kind) {
case "notADeath":
// An orderly shutdown. Nothing was lost and the session is still usable.
break;
case "durableDeath":
// The host died, the state survived. Live turn waits have rejected with
// MuseHostDiedError; spawn a fresh host and resume the session.
break;
case "discharged":
// An ephemeral host died and took the session with it. What was in
// flight is in discharge.terminalUnknownItems and
// discharge.retiredCommands — show it, and do not reattach.
break;
}

A clean exit is notADeath on purpose: closing the host yourself is not a death, and neither is the host draining cleanly on end-of-input. Only an abnormal end — a crash, a kill signal, an error exit — discharges anything.

The durable branch: waiters reject, then you resume

Section titled “The durable branch: waiters reject, then you resume”

On a durable host’s abnormal death, the SDK touches nothing it observed: the items and pending commands are left exactly as the wire last reported them, because their real terminals arrive when the session resumes. What it does do is reject every live turn wait with MuseHostDiedError — a wait on a dead host would otherwise hang forever:

try {
const outcome = await session.turn(turnId).completed;
} catch (error) {
if (error instanceof MuseHostDiedError) {
// error.exit tells you how the host ended. Time to respawn and resume.
}
}

Recovery is a fresh spawn plus resumeSession with the same session id:

const fresh = await MuseClient.spawn({
museBin: "/usr/local/bin/muse",
args: ["serve"],
clientInfo: { name: "my-app", version: "1.0.0" },
});
const resumed = await fresh.resumeSession({ sessionId, excludeItems: false });

The tested recipe behind this page proves that end to end: it kills a live release-built host with SIGKILL mid-session, watches the classification come back as a crash with the signal named, sees the registered turn wait reject with MuseHostDiedError, and then resumes the same session on a second host and reads the surviving state back.

The ephemeral branch: take the discharge and tell the truth

Section titled “The ephemeral branch: take the discharge and tell the truth”

An ephemeral host’s death discharges the session in full, and every member of the discharge exists so your UI does not have to invent anything:

  • terminalUnknownItems — the items that were still in progress. They are annotated terminal-unknown, never marked failed or cancelled, and no completion is synthesized: the honest rendering is “we do not know how this ended”.
  • retiredCommands — the commands you had submitted that the server never wove into the view. Each carries the input you submitted, so you can show the user what was in flight. Deliberately, none of them offers a composer restore: the client cannot know whether the work ran, and inviting a one-keystroke resubmit is inviting a double execution.

After the discharge the session refuses to lie on your behalf: further events are not folded, new submissions throw MuseSessionDiscardedError, a late-registered turn wait settles immediately with a terminalUnknown outcome instead of hanging, and resuming the session is withheld — there is nothing on the other end to reattach to.

Do not dedupe death notifications. Report the process exit and the transport EOF through hostExited; the SDK latches the first, replays it on the second, and uses the repeat to settle waits the drain opened in between.

Do not treat an unrecognized durability value as durable. The enum is open so a third value can land one day; “assume nothing survives” is the read that stays correct when it does.

Do not resubmit a retired command because it looks unfinished. Terminal-unknown means unknown — the work may have run to completion on the host’s side of the crash.

You need Node 20 or newer, an installed muse binary, and the SDK your own application depends on — npm install @muse-code/sdk. The recipe programs live in the SDK repository:

Terminal window
git clone https://github.com/meta-models/muse-code-sdk
cd muse-code-sdk
npm ci && npm run build --workspace @muse-code/sdk
MUSE_BIN=$(command -v muse) \
npm run recipes --workspace @muse-code/sdk-cookbook -- --only survive-the-host-dying

The runnable source for this page is published in full, comments and all, as the survive-the-host-dying example. It is the source that runs, not a retelling of it: it is executed end to end on every change to this area, so this page cannot drift from what actually runs.

Two pieces of that source are harness plumbing, not client guidance, and the source says so where they appear: the recipe launches its killable host through a tiny shell wrapper that prints the host’s pid (an external death does not come through the SDK, so the journey needs a pid to deliver one), and it spawns hosts with MUSE_EXPERIMENTAL_SDK_ENABLED set explicitly. muse serve now runs by default; the variable survives as an off-switch, and the harness pins the run open so an operator’s off in the environment cannot silently change what this page proves. Neither is advice for your application — build against muse serve as a supported command, and let your hosts die on their own.