Lifecycle
Open, suspend, resume, fork, reset and delete Shardflux workspaces: states, idle suspend and parking, waiting, and what survives each transition.
States and operations
A workspace's state (the API's observed_state) is one of:
| State | Meaning |
|---|---|
creating, starting |
The first open or a restart is booting it. |
running |
It runs, and tool calls are served. |
suspending |
Its memory, processes and disk are being checkpointed. Tool calls wait. |
suspended |
The checkpoint is sealed and the VM is gone. No compute; the disk still counts as storage. The operation's result.durable says when the checkpoint is also in durable storage (Durable storage). |
resuming |
The checkpoint is being restored. |
forking |
A fork's copy is being created (the new workspace's state). |
failed |
A start failed. The workspace's failure says why: the error of the operation that failed it (code, message, retryable, details). Opening the key again starts it again. |
deleting, deleted |
It is being or has been deleted. |
A running workspace that nobody uses is parked by its host. That is not a state:
it stays running. A file-first workspace is running from its first open until it is deleted.
Every change of state is an operation with a kind (open, suspend, resume, fork, snapshot, reset,
delete) and a state of its own: queued, capacity_pending, running, then succeeded, failed or canceled. A
workspace runs one lifecycle operation at a time; a second one is refused with 409 conflict
(details.reason: operation_in_progress), except that opening or resuming joins a start already in progress.
Shardflux also keeps every workspace well placed on its own: it moves a running workspace to another host while it
keeps running (an operation of kind move), and applies upgrades in the background. This work leaves the workspace
as it is. Its state, pending_reason and startup stay unchanged, and the work shows only as the workspace's
active_operation, with that operation's own state. Operations you request are accepted while it is active and go
first, unless a move is already finishing. A workspace that placement restarts instead of moving shows an upgrade
operation with input.reason: host_drain_cold_start: it restarts only when no agent turn, command or tool call is in
progress, its files and home stay on its disk, and memory and running processes start fresh, as after a reboot.
Requested or finished
Lifecycle calls return as soon as the change is requested, or, with wait, once it has finished:
| Call | Returns when | Returns |
|---|---|---|
await workspace.suspend() |
The suspend is requested (usually queued; the workspace still runs) |
The operation |
await workspace.suspend({ wait: true }) |
The suspend has finished; workspace.state is suspended |
The succeeded operation |
ws.suspend() (Python) |
The suspend is requested | The operation |
ws.suspend(wait=True) (Python) |
The suspend has finished; ws.state is suspended |
The succeeded operation |
shard ws suspend <key> |
The suspend is requested | Prints the operation |
shard ws suspend <key> --wait |
The suspend has finished | Prints the operation |
await workspace.suspend({ durable: true }) (0.12.0+) |
The suspend has finished and its checkpoint is in durable storage | The succeeded operation, result.durable: true |
ws.suspend(durable=True) (Python 0.8.0+) |
The same | The succeeded operation |
shard ws suspend <key> --durable (CLI 0.7.0+) |
The same | Prints the operation |
The same applies to resume, snapshot, delete, reset and close. open() waits by default. fork waits by
default in Python and takes { wait: true } in TypeScript.
Open, create or reconnect
open is the one call you need for most of the lifecycle. It creates the workspace for a new key, returns a running
one, resumes a suspended one, and joins a start already in progress. It never resets anything. See
What opening a key does.
Suspend a workspace
await workspace.suspend({ wait: true }); // memory and processes are checkpointed; compute stopsws.suspend(wait=True)A suspend pauses the VM, seals its memory and disk on its host, and releases the CPU and memory. It returns as soon as the workspace is sealed, typically in a few hundred milliseconds, and the copy to durable storage follows right away, typically within a few seconds. See Durable storage.
Durable storage
The finished suspend operation says where its checkpoint is:
{
"durable": false,
"suspend_path": "local_commit",
"durability": {
"state": "pending",
"checkpoint_id": "01a0f11c-3f30-72d5-9311-02d770904b92",
"generation_id": "g-0001-4dfc6088",
"local_commit_at": "2026-09-30T06:59:15.384696Z",
"durable_by": "2026-09-30T07:00:15.384696Z"
}
}When the copy commits, the same operation's result becomes durable: true with durability.state: "durable",
durable_at and local_commit_to_durable_ms. Measured from the suspend's return to the durable commit (p50): 435 ms
for a 1 GiB workspace, 864 ms for 8 GiB.
durability field |
Meaning |
|---|---|
state |
pending (the copy is being written), durable (it committed) or lost (see below). |
checkpoint_id, generation_id |
The checkpoint being copied. |
local_commit_at |
When the suspend completed on its host. |
durable_by |
When the copy is expected by. |
durable_at, local_commit_to_durable_ms |
When the copy committed, and how long after the suspend. |
overdue_at |
Set once durable_by passed with the copy still pending. The copy keeps going; nothing changes for your code. |
While the copy is pending, the workspace behaves as suspended in every way. A resume, a tool call that wakes it, or an
open of its key restores it from its host's copy (resume_path: "local_cache", with durability.state: "pending" in
the resume's result), and reads of its files are answered from that copy. A resume in the first moments after the
suspend, while its memory is still being written, continues the same VM in place (resume_path: "thaw"). Other lifecycle operations on it, such as a
snapshot, a fork or the next suspend, start once the copy commits.
A suspend whose result says durable: true, or has no durable field, was stored durably before it completed. Most code needs nothing more.
When yours must know the checkpoint is durable, for example before a job reports that its state is saved, wait for it:
suspend({ durable: true }) or cloud.workspaces.waitForDurable(operation) (TypeScript 0.12.0+),
suspend(durable=True) or wait_for_durable(operation) (Python 0.8.0+), shard ws suspend <key> --durable (CLI
0.7.0+) or workspace_suspend with durable: true (MCP server 0.6.0+).
A fork of a running workspace carries durable and durability the same way: the new workspace starts from the
source's sealed capture on the same host, and the capture's durable copy follows.
If the host is lost before the copy commits. The pending checkpoint's durability.state becomes lost (with
reason, for example host_gone, and recovery_point_checkpoint_id), and the workspace's recovery point stays its
previous durable checkpoint. The next resume restores that checkpoint, and its result says so in lost_suspend:
lost_suspend field |
Meaning |
|---|---|
checkpoint_id, generation_id |
The suspend that was not kept. |
reason |
Why, for example host_gone. |
suspended_at |
When that suspend captured the workspace. |
restored_checkpoint_id |
The earlier checkpoint this resume restored. |
state_as_of |
The restored state is as of this time. |
With no earlier durable checkpoint, the resume fails with checkpoint_not_found. A wait for the durable copy ends
with DurabilityLostError in both SDKs (Errors).
Session workspaces cannot be suspended (409, details.reason: session_lifetime).
Resume a workspace
You rarely need to resume explicitly:
- Opening the key resumes a suspended workspace.
- A tool call wakes it. An exec, file or other tool call on a suspended workspace resumes it (or joins the resume
already running), then runs. A call made during a suspend or resume waits for it to finish. The call never runs
twice. The SDKs bound the wait per call (default 120 seconds,
transitionTimeoutMsin TypeScript,transition_timeoutin Python) and follow a resume queued for capacity until its deadline (TypeScript 0.22.0+, Python 0.18.0+); the CLI uses--wake-timeout(0.16.0+: without it, the same) and the MCP serverSHARDFLUX_WAKE_TIMEOUT_MS. - The wake is one request (
@shardflux/sdk0.9.0+,shardflux0.5.0+ for Python, CLI 0.5.0+, MCP server 0.4.0+). The client sends one resume that the API holds until the workspace runs, and the answer carries a tool token for that client, so the refused call is retried at once. An explicitresume({ wait: true }),resume(wait=True)orshard ws resume --waitis the same single request. Earlier versions wait for the resume operation and then fetch a token. - Reads do not wake it. Reading, listing, stating and searching files of a suspended workspace are answered from
its disk while a host still holds it, without a resume (
@shardflux/sdk0.9.0+,shardflux0.5.0+, CLI 0.5.0+, MCP server 0.4.0+). See Search and edit files. - Following an exec's output never wakes a workspace, so a suspend you asked for is respected.
await workspace.resume({ wait: true }); // explicit
await workspace.wake(); // resume if needed; resolves once it runs
const cell = workspace.cell({ wake: null }); // opt out: calls fail with workspace_not_running insteadws.resume(wait=True)
ws.wake() # True if it resumed or waited, False if it was already running
cell = ws.cell(wake=None) # opt outA resume is admitted like a new start, so plan limits apply (see Pricing and limits).
Fork a workspace
A fork creates a new workspace under a new key from the current state of a running or suspended one: a copy of its disk and memory, restored into an independent VM with its own network identity. The source keeps running, or stays suspended.
const { workspace: copy } = await workspace.fork({ key: 'customer-42/experiment' }, { wait: true });copy = ws.fork("customer-42/experiment") # waits until the copy runsshard ws fork customer-42/main customer-42/experiment --wait- The new key must be unused, including by deleted workspaces (
409,details.reason: key_in_use). - The fork keeps the source's template version, secret bindings, text inputs and egress policy override, so it starts
under the same outbound rules as its source. It gets the source's caps unless you pass
caps. - A fork is persistent unless you pass
lifetime: 'session'. Forking is how you keep a session workspace's state. - A fork is a new start: plan limits apply to it as to an open.
- Software activation can depend on the VM's context. A Unity license accepted on the source is not proof that the fork's Editor is activated: complete the activation in the fork before Editor, rendering or gameplay checks.
Snapshot a workspace
workspace.snapshot({ label }) (TypeScript), ws.snapshot(label=...) (Python) records a checkpoint of a running or
suspended workspace without stopping it. While a running workspace is captured, reads work and writes wait. The API
has no call to list or restore snapshots yet; to branch from a state, fork.
Reset a workspace
reset wipes every change a workspace made and restarts it on its template version. It keeps the key, id, template
version, caps, secret bindings and volume attachments. Running processes end. It works only on layered workspaces
(workspace.diskLayout === 'layered'; others get 409, details.reason: legacy_disk_layout). The previous state is
kept as a recovery point for 7 days.
await workspace.reset({ wait: true });shard ws reset customer-42/main --yes --waitA suspended workspace stays suspended and starts blank on its next resume.
Delete a workspace
await workspace.delete({ wait: true });shard ws delete customer-42/main --yesTool access ends at once and the workspace's storage is cleaned up. Deletion cannot be undone, and a persistent workspace's key becomes reusable once deletion finishes, creating a new workspace ID. The MCP server does not offer deletion to agents; use the SDK, the CLI or the console.
A session workspace ends with close() (shard ws close <key>), which deletes it; its key then opens a new
workspace.
Keep only what you use
Keep a workspace per customer, chat or task and let retention remove the idle ones automatically. On open, pass
retention: { delete_after_idle_days: 14 } for a persistent workspace. Set a new policy with
ws.setRetention({ delete_after_idle_days: 30 }), or ws.setRetention(null) to follow the project default.
cloud.workspace(key, { template, retention }) accepts the same policy. In Python, use retention={"delete_after_idle_days": 14}
and ws.set_retention(...). The CLI has shard ws open KEY --template TEMPLATE --delete-after-idle 14d and
shard ws retention KEY 14d|off.
A project default can target workspaces by their labels:
cloud.projects.setRetention(projectId, { delete_after_idle_days: 14, labels: { app: 'chat' } }).
All selector labels match exactly, and a workspace's own policy wins. cloud.projects.getRetention(projectId)
reads the default; setting it to null clears it. The workspace's retention shows its effective days and
projected delete_at, or null with no policy. Calls, resumes, file operations and port requests refresh activity;
ongoing work postpones deletion. Retention accepts 1 through 3650 days. Omit it to keep your workspaces until you
delete them. Deletion frees their storage through normal cleanup, and a deleted key creates a new workspace ID.
Automatic suspend when idle
A persistent workspace suspends itself when it has been idle for its idle timeout. Nothing is killed: the suspend keeps memory and processes like any other. Once its host parks an idle workspace, it no longer counts toward workspaces working at once and its RAM GiB-hours drop to 256 MiB and then to none, but a workspace that its processes keep busy keeps working, and uses the memory it holds, until it suspends. The shorter the idle tail after its last work, the less a busy workspace costs.
What keeps a workspace awake:
- a tool call (exec, files, terminal input, processes, git, browser);
- an attached exec output stream or terminal (a terminal while someone types or it prints);
- a command started through exec, until it ends or its timeout passes (1 hour when it has none);
- a keepalive (
POST /v1/workspaces/{id}/keepaliveon the workspace's cell endpoint, for up to 12 hours); - a request to a port URL, until its response ends, and an open WebSocket on one. The idle timeout counts from the end of the last request. A request postpones a suspend when idle request and never cancels it.
Start a long job with a timeout_ms (timeoutMs in TypeScript) that covers its whole run, and it keeps the workspace
awake until it finishes.
A detached process, a dev server nobody is attached to, or CPU use alone does not keep it awake. Such processes continue after the resume.
Idle policy. The default policy is adaptive: each workspace learns its own timeout, from 10 seconds to 4 hours.
- It learns from the workspace's idle periods, every pause of 5 seconds or more between one activity and the next. Pauses inside its usual active hours and outside them are learned separately.
- Short pauses, such as an agent thinking between tool calls, are not worth a suspend and a resume, so the timeout outlasts them. When a workspace is woken soon after an automatic suspend, its next timeouts get longer (a suspend you asked for never has this effect).
- Until the workspace has 8 idle periods of its own, its history is blended with that of its template, else its organization, else all workspaces. With no history at all, the timeout is 5 minutes.
- The work signals above always win: the policy only decides how long an idle workspace waits.
- When its host needs room to wake another workspace, an idle workspace under the
adaptivepolicy may be suspended sooner (its suspend operation hasinput.reason: host_capacity); this does not shorten its learned timeout. A workspace withnever, or afixedtimeout not reached yet, may be moved to another host instead, and keeps running.
The other policies are never and fixed:<seconds> (60 to 604800). The workspace view reports under idle the
policy, the current timeout (timeout_seconds), what it is based on (basis, for example learned from 37 idle periods (active-hours), template prior or default), when it would suspend (next_eligible_at) and a pending
suspend when idle request (suspend_request).
Set it with the HTTP API; the SDKs have no dedicated method yet, so use their request() passthrough:
await cloud.request('PUT', `/v1/workspaces/${workspace.id}/idle-policy`, { json: { idle_policy: 'fixed:600' } });sf.request("PUT", f"/v1/workspaces/{ws.id}/idle-policy", json={"idle_policy": "never"})null clears the workspace's own policy, so the template default (else adaptive) applies. Session workspaces have
no idle policy; they end after their idle timeout (10 minutes unless the template sets one).
Suspend when idle
Send hint() at turn start without awaiting it, while the model thinks; request suspendWhenIdle({ afterSeconds: 0 })
at turn end. 0 frees RAM about 2 seconds after the turn; the next turn resumes, hidden while the model thinks.
60 keeps the workspace warm for a minute and bills that minute. TypeScript 0.16.0+ wraps both calls in
ref.turn(async (ref) => { ... }) (also workspace.turn()); Python 0.12.0+ uses with ref.turn(): ... or
async with ref.turn(): .... Both schedule suspension when the body settles, including on error.
Your code knows when an agent's turn ends. Instead of waiting for the idle timeout, ask for a suspend once the workspace has been idle for a short time. The request is stored with the workspace, so your process does not have to stay around for the suspend.
await workspace.suspendWhenIdle({ afterSeconds: 0 }); // 0 to 3600
workspace.suspendRequest; // { requested_at, after_seconds, not_before }, or null
await workspace.cancelSuspendWhenIdle(); // idempotentws.suspend_when_idle(after_seconds=0) # 0 to 3600
ws.suspend_request # SuspendRequest(requested_at, after_seconds, not_before), or None
ws.cancel_suspend_when_idle() # idempotentshard ws suspend customer-42/main --when-idle 1m # 0 to 1h
shard ws suspend customer-42/main --cancel-when-idle- The workspace is suspended once it has been idle for
after_seconds, counted from the later of its last work and the request.not_beforeis the earliest suspend. after_seconds: 0suspends it as soon as it is idle, a few seconds after its last work (--when-idle 0needs@shardflux/cli0.6.1+,after_seconds: 0inworkspace_suspendthe MCP server 0.5.1+).- A command still running, an attached exec output stream or terminal (a terminal while someone types or it
prints), or a keepalive postpones the suspend until
after_secondsafter it ends. The request stays. - The next tool call on the workspace (the next turn) or a resume cancels the request. Asking again replaces it.
- It applies under every idle policy,
neverincluded, and never delays a suspend the policy would do sooner. - If a suspend is already in progress, the call returns that operation and records nothing.
- When the suspend is due, the host checks atomically that the workspace is still idle. If a tool call arrived after
the request or a stream is attached, the suspend operation ends
canceledwitherror.code: "workspace_active": nothing changed and the workspace keeps running. It is not a failure; the SDKs'waitUntilReady()(TypeScript 0.12.0+) andwait_until_ready()(Python 0.8.0+) and tool-call wakes treat it as running. - A session workspace is refused (
409,details.reason: session_lifetime), and so is a workspace that is not running (not_running).
It is in @shardflux/sdk 0.10.0+ (cloud.workspaces.suspendWhenIdle(id, { afterSeconds }) by id), shardflux
0.6.0+ on PyPI (sf.workspaces.suspend_when_idle(workspace_id, after_seconds=0)), @shardflux/cli 0.5.1+ and
the MCP server 0.4.1+ (workspace_suspend with after_seconds). Over HTTP it is
POST /v1/workspaces/{id}/suspend-when-idle with {"after_seconds": 0}, and DELETE on the same path cancels it.
The workspace view shows a pending request as idle.suspend_request. Give your agent workspace
tools shows the call at the end of an agent loop.
Idle running workspaces are parked
Long before its idle timeout, a running workspace that nobody is using is parked by its host: after a few seconds
without activity its VM is paused and most of its memory is compressed in place, in the host's memory. Parking is not a
lifecycle state and needs nothing from you. The workspace stays running, keeps its files, memory and processes, and
the API answers as before. The next tool call wakes it first and then runs, a few milliseconds later.
- What keeps it resident: a tool call in progress, an attached exec output stream or terminal (a terminal while someone types or it prints), a command started through exec until it ends, a keepalive, a request to a port URL in progress or an open WebSocket on one, and processes that keep using CPU or the network.
- Background processes that wait (an idle dev server, a file watcher, a shell) are paused with the workspace and continue at the next wake. Timers inside the workspace fire late, never early, and the clock is right after the wake.
- Requests to a port URL wake it. A request to a port URL wakes a parked workspace inside the
request, and resumes a suspended one, then reaches your server. Other network traffic does not: a process that
waits for data from a connection it opened is paused too, and a remote peer that expects a prompt answer may time
out. A keepalive (
POST /v1/workspaces/{id}/keepaliveon the cell endpoint) keeps the workspace resident, and wakes it if it is parked. - Billing follows parking. A parked workspace is running, and it does not count toward workspaces working at once: only working workspaces do. Its RAM GiB-hours follow what the host holds: the memory it holds while it works (its floor or what its processes use, whichever is more), and while it is parked at most 256 MiB plus the compressed size of the rest (what each allowance measures). CPU hours count the CPU time its processes use, which is next to none while it is parked. A workspace whose processes keep using CPU or the network stays resident and uses the memory it holds until it suspends. Parking neither delays nor replaces the automatic suspend.
- Reads may not wake it. Reading, listing, stating and searching files of a parked workspace can be answered from
its disk (
X-Served-From: disk) without waking it. - A wake can wait for room. When the host needs room to restore the workspace, it makes it: idle workspaces on
that host are suspended or moved to another host, and more capacity is added when that is not enough. The tool call
waits meanwhile, in line with other wakes, for 25 to 50 seconds depending on the call
(limits). One still waiting then is answered
503 service_unavailable(details.reason: host_capacity) withRetry-After; when a restore fails, the answer iswake_failed(the saved state is intact). Both are retryable, and nothing was executed. The SDKs retryhost_capacityfor any call, commands and terminals included, for up to 2 minutes (admissionTimeoutMs,admission_timeout; TypeScript 0.22.0+, Python 0.18.0+), andwake_failedfor reads, searches and calls with anIdempotency-Key. When your organization already has its plan's workspaces working at once, the call waits for one to stop working and may be refused with429 quota_exceeded, which the SDKs retry the same way.
Wake hint
A wake hint tells the host that a tool call is coming, so a parked workspace starts waking before the call arrives. The agent tools of both SDKs (TypeScript 0.9.0+, Python 0.6.0+) and the MCP server (0.4.0+) send one when each tool call starts, so an agent loop built on them needs no extra code. To start the wake even earlier, send it yourself as soon as your model starts writing a tool call:
void workspace.hint().catch(() => {}); // @shardflux/sdk 0.9.0+: returns at oncews.hint() # shardflux 0.5.0+: returns at once- A hint is cheap and never waits. For a suspended workspace it starts the resume in the background (TypeScript
result.wake, a promise; PythonWakeHint.wake, aFuture);hint({ wake: null })/hint(wake=False)only reports. - The agent tools send no hint for
read_file,list_filesandsearch_files, which a sleeping workspace answers from its disk.workspaceTools(ws, { hint: false })/workspace_tools(ws, hint=False)turns their hint off, for example when you send your own. - Over HTTP,
POST /v1/workspaces/{id}/wake-hinton the cell endpoint answers202with theresidencythe host found:resident,frozen(paused),hibernated(saved to the host's disk) orrestoring. A suspended workspace answers409 workspace_not_running: resume it through the API. A hint that no tool call follows within 60 seconds is dropped. A hint is not tool activity and does not keep the workspace from its idle suspend.
What survives each transition
Files and disk, and memory and running processes, are listed separately.
| Transition | Files and disk | Memory and running processes | Also |
|---|---|---|---|
| Open the same key again | Kept | Kept | Nothing is reset. |
| Parked while idle, then woken by a tool call | Kept | Kept: processes are paused and continue | The workspace stays running; while it is parked it does not count toward workspaces working at once. RAM GiB-hours: at most 256 MiB plus the compressed rest while parked. Timers fire late. |
| Suspend (by you, when idle, or at a compute allowance or spend cap), then resume | Kept: every file write acknowledged before the suspend began, and installed packages. Immutable paths show the template's newest version | Kept: every process with its PID, memory, open files, working directory and environment, including detached processes, dev servers and exec or terminal sessions; listening sockets and loopback connections | Your connections to exec output and terminals end; reattach by offset (the SDK does). Remote peers may close connections while the workspace sleeps. GPU apps keep their Vulkan state; CUDA and NVENC objects made before the suspend are released: calls on them return CUDA_ERROR_CONTEXT_IS_DESTROYED or NV_ENC_ERR_DEVICE_NOT_EXIST, and the app creates new ones. |
| Resume after a platform runtime change (rare, see below) | Kept: as of the suspend | Ended: the workspace boots from its saved disk, as after a reboot | The resume result says resume_path: "cold_boot", memory_restored: false, cold_boot_reason: "runtime_changed". Start dev servers and background jobs again. |
| The workspace's host fails, then its next use | Kept: as on its disk when the host failed | Ended: the workspace boots from its disk, as after a reboot | It is suspended until its next tool call, open or resume. The result says cold_boot_reason: "host_lost" (see below). |
| Fork | Copied into the new workspace | Copied into the new workspace, as an independent VM with a new network identity | The source is unchanged. Shared volumes are attached, not copied: both see the same data. CUDA, NVDEC and NVENC objects are released in both, as at a suspend. |
| Snapshot | Unchanged | Unchanged | Writes wait during the capture. |
| Reset (layered workspaces) | Wiped back to the template | Ended | Key, id, template version, caps, secrets and volumes are kept; the old state is a recovery point for 7 days. |
Session ends (close() or idle timeout) |
Deleted | Ended | The key opens a new, empty workspace. |
| Delete | Deleted | Ended | The key can open a new workspace ID after deletion finishes. |
A start fails with capacity_unavailable |
Unchanged | Unchanged | Nothing was started; a suspended workspace stays suspended. |
A resume restores memory and disk from one checkpoint. Shared volumes are outside checkpoints: a suspend keeps the attachment, and the resume mounts the volume's current contents.
Immutable paths
A template's immutable paths show its newest published version, and they move to a newer one only at a start or a resume:
| Transition | Immutable paths show |
|---|---|
| A boot: the first open, a reset, a resume that boots | The template's newest published version |
| Resume from suspend | The template's newest published version. A resume never waits for an update: while the newest version is still being prepared for that workspace, it resumes on the version it shows and moves up at a later resume. |
| Running, parked and woken by a tool call | Unchanged, for as long as the workspace runs |
| Fork | The source's version; the fork moves up at its own next resume |
| Draft and test instance | The draft's or version's own files, writable |
A process that has a file open when the version changes keeps reading the version it opened; files it opens
afterwards, and new processes, see the new version. Everything else on the disk is kept as the table above says.
workspace.template.immutable_version is the version a workspace shows.
When a resume boots instead
A memory checkpoint restores on the VM runtime that saved it (kernel, CPU family and Firecracker snapshot format), and we keep that runtime stable so suspended workspaces resume with their memory. After a runtime change, such as a security update, a workspace's next resume boots it from its saved disk instead:
- its files are exactly as they were at the suspend;
- its memory and processes are not restored: every process starts fresh, as after a reboot, and the template's startup runs its boot steps;
- the resume operation's result says so:
resume_path: "cold_boot",memory_restored: falseandcold_boot_reason: "runtime_changed". Every resume result hasmemory_restored(truewhen memory came back).
A workspace whose host failed boots from its disk the same way, with cold_boot_reason: "host_lost"
(If a workspace's host fails).
The clients surface it: workspace.lastTiming.server.memoryRestored and coldBootReason in TypeScript
(@shardflux/sdk 0.11.0+), memory_restored and cold_boot_reason in Python (shardflux 0.7.0+), a note
on stderr from the CLI (0.6.0+) and a notice the MCP server (0.5.0+) hands the agent, so it can restart what it
was running.
If a workspace's host fails
If the host a workspace runs on fails or is replaced, the workspace becomes suspended. Its next tool call, an open of
its key or a resume brings it back, exactly as for any suspended workspace: there is nothing to call.
- It boots from its disk. Its files are kept as they were on its disk; running processes and memory are not, so
every process starts fresh and the template's
bootstart commands and services run, as after a reboot. The result saysresume_path: "cold_boot",memory_restored: false,cold_boot_reason: "host_lost"andhost_lost: {detected_at, restored_from: "disk"}. - If its disk cannot be recovered, it is restored from its newest checkpoint instead:
host_lostsaysrestored_from: "checkpoint"withrestored_checkpoint_idandstate_as_of. Changes made afterstate_as_ofare not in it. With neither a disk nor a checkpoint, the resume fails withworkspace_storage_unavailable(Errors). - A suspend completes. A suspend that meets the failed host succeeds with the workspace
suspended, and its result hashost_lost: {detected_at}. - Fork and snapshot follow the resume. Until the workspace has run again, a fork of it or a snapshot is refused
with
resume_required. Resume it (or open its key) first, so the copy has its newest files.
What to do: nothing. Start again what you ran by hand, such as dev servers and background jobs; the template's start
commands and services run by themselves. The clients surface host_lost: hostLostOf(op) and
workspace.lastTiming.server.hostLost in TypeScript (@shardflux/sdk 0.13.1+), host_lost_of(op) and
ws.last_timing.server.host_lost in Python (shardflux 0.9.1+), a note on stderr from the CLI (0.8.1+) and a
notice from the MCP server (0.7.1+).
Wait for an operation to finish
wait takes options: { timeoutMs, signal, onProgress } in TypeScript (default 5 minutes), timeout= in seconds in
Python (default 300). When the time runs out, the SDK throws OperationTimeoutError, but the operation continues on
the server. A start that is queued for capacity (capacity_pending) when the time runs out is followed until its
deadline instead, so it is seen through to the end (TypeScript 0.22.0+, Python 0.18.0+; untilCapacityDeadline: false or until_capacity_deadline=False stops at the timeout). OperationTimeoutError then carries the start's
queuePosition / queue_position. Wait for it again:
import { OperationFailedError, OperationTimeoutError } from '@shardflux/sdk';
try {
await workspace.resume({ wait: { timeoutMs: 60_000 } });
} catch (err) {
if (err instanceof OperationTimeoutError) {
await cloud.workspaces.waitForOperation(err.operationId); // keep waiting
} else if (err instanceof OperationFailedError && err.retryable) {
// err.errorCode === 'capacity_unavailable': nothing changed; retry.
} else {
throw err;
}
}from shardflux import OperationFailedError, OperationTimeoutError
try:
ws.resume(wait=True, timeout=60)
except OperationTimeoutError as err:
sf.workspaces.wait_for_operation(err.operation_id)
except OperationFailedError as err:
if not err.retryable:
raise
# err.error_code == "capacity_unavailable": nothing changed; retry.From the command line: shard operations wait <operation id>.
A queued open, resume or fork is capacity_pending, in line (error.details.queue_position, served in the order the
starts were requested) with a deadline (deadline_at) 15 minutes after it was created. While new capacity is starting for it, the deadline moves to 5
minutes after that capacity is expected to be ready (pool.expected_ready_by), at most an hour after creation. While
the workspace's files are being moved to new storage (state_reason: preserving_files), the deadline is an hour after
creation. Past the deadline, or after 2 minutes when no more capacity can be added right now, the operation fails with
capacity_unavailable and retryable: true (with details.retry_after_seconds in the second case, and at once when
the start queue is full): nothing was started, and a suspended workspace stays suspended with its state, so the same request can simply be sent again.
A failed operation throws OperationFailedError (Python: raises) with errorCode / error_code and retryable.
Treat unknown error codes as generic errors: show the message and use retryable.
Requests during transitions
| Request | While suspending | While suspended | While resuming |
|---|---|---|---|
| Tool call | Waits (the SDKs retry workspace_busy) |
Wakes the workspace (SDKs, CLI, MCP); reads of files may be answered from its disk instead | Waits for the resume |
| Resume or open | 409 conflict (operation_in_progress) |
Resumes | Joins the resume |
| Suspend | Joins the suspend | 409 conflict (not_running) |
409 conflict (operation_in_progress) |
A refused call was never executed, so retrying it is safe. A command that is running when a suspend begins is frozen with the VM and continues after the resume; its output stays readable by offset.
Timings
Every open, wake and waited lifecycle call is timed. formatTiming(workspace.lastTiming!) (TypeScript),
format_timing(ws.last_timing) (Python) and --timing (CLI) print where the time went:
resume 413 ms, succeeded (workspace 01a0ead6-92e5-7d19-ab7a-f565bf4676cb, operation 01a0ead6-bd61-737a-ae88-66e2d01a6e25)
client: request 218 ms → queued 195 ms
server: queued 51 ms, ran 290 ms, total 341 ms; resume from local_cache, boot to ready 211 ms, host disk 0 ms, load 6 ms, ready 62 ms, total 211 ms, after_restore 36 ms
outside the server: 72 msThis resume brought the workspace back from its host's local cache, memory and running processes included,
in 341 ms on the server and 413 ms end to end. outside the server is network and polling time between you and the API. Watch the phases live with
onProgress (TypeScript) or on_progress (Python).
Recoverable failures and conditional suspend
failed describes a failed lifecycle attempt, not deletion. Resume/auto-wake can retry the existing ID through the same start path as reopening its key. A failed recovery still raises its operation error; retries are bounded.
Use suspendWhenIdle({ afterSeconds: 2 }) (or 0 for as soon as idle) instead of reading /idle and then suspending. At capture time a host fence checks and blocks tool admission atomically; running exec sessions and accepted keepalives are rechecked under that fence. A request that loses the race to work is canceled before capture and leaves the workspace running. An immediate suspend() retains its explicit full-state behavior.
Upgrade while keeping your files
An upgrade brings a long-lived workspace to the current supported layout and template base through one cold start.
Files, installed package files and home stay on the workspace disk. Memory and running processes restart. Available
in TypeScript 0.16.0+, Python 0.12.0+, the CLI 0.11.0+ (shard ws upgrade) and MCP 0.10.0+ (workspace_upgrade).
await ws.upgrade({ at: 'next_resume' });
// The next open, or the next resume once the upgrade is prepared, applies it before your first tool call.Use at: 'now' with wait: true to apply it immediately. ws.upgradeAvailable shows the available target, and
ws.upgradePending shows a scheduled one. A scheduled operation reports applied: next_resume; the applying start
reports cold_boot_reason: upgraded and memory_restored: false. Shardflux prepares a scheduled upgrade in the
background, next to the workspace's disk, so resuming never waits for it: a resume applies the upgrade once it is
prepared and otherwise continues the workspace from memory, reports upgrade_deferred, and keeps the upgrade pending
for a following resume. When a suspended workspace is quiet, Shardflux also applies its prepared upgrade ahead of time:
an upgrade operation boots it on the new base and a suspend operation captures it within seconds, so the next
resume restores memory on the new base. Start commands and services of an organization template run at that next
resume, not in between. A scheduled upgrade never ends running work: a resume that finds the workspace still in memory
on its host continues it in place (resume_path: thaw), and the upgrade stays pending for the next resume. Layered workspaces refresh their base and guest
with your changes taking precedence. Legacy disks upgrade memory while retaining their base and guest.
A workspace whose upgrade start did not complete keeps its disk and its target; upgrade({ at: 'now' }) applies it,
and targets the newer version instead when one was published since.
Tunnel reconnection
A persistent workspace retains tunnel replay through explicit suspend/resume and live move while its client remains open. Each output stream retains up to 1 MiB in guest RAM, with 64 MiB shared by the workspace's tunnel streams. When that budget is in use, additional tunnels open without replay; exec/PTY status labels report shardflux.tunnel_replay=disabled:memory_budget. Reconnect these connections after a lifecycle interruption.
If the tunnel replay owner restarts or its cursor has left the retained window, existing TCP peers reset. TypeScript SDK 0.27.3+, Python 0.24.0+ and CLI 0.24.1+ replace reverse listeners automatically for fresh connections. A forward connection, including SSH, closes so the application can establish a new connection. The cell refusal carries details.reason=tunnel_replay_unavailable; replaying the old byte cursor cannot recover the connection.