Session lifecycle
States, transitions, timeouts, pause and resume, and the patterns that keep long-running agents cheap.
A session is the billable, schedulable unit. Its state machine is the contract between the caller, the control plane and the node.
States
| State | Billed | Meaning |
|---|---|---|
pending | No | Admitted and queued; normally under 10 ms |
provisioning | No | Claiming a pool instance or cold starting. Not billed — the start is the platform's cost, not the customer's |
running | Yes | Executing. tierN.session_s accrues every second |
paused | Storage only | Memory and disk retained, no CPU. Billed as paused_gib_month |
snapshotting | Yes | Still running; the snapshot is taken live |
stopping | Yes, until the final meter | Draining, optional final snapshot, teardown |
stopped | No | Terminal, clean. Caller asked for it, or an idle timeout fired |
failed | No | Terminal, unclean. Host failure, start failure, policy kill, OOM |
expired | No | Terminal. Maximum lifetime reached |
A cold start is a platform failure to provision a warm instance, not a customer cost. Billing
begins when the session reports running. This is deliberate — otherwise the platform would be
paid more for being slower.
Transitions and who drives them
| Transition | Driver | Notes |
|---|---|---|
→ pending | api-gateway after admission | Concurrency slot reserved here |
pending → provisioning | placer | Placement decision recorded |
provisioning → running | wbnode health probe | Webhook session.running |
provisioning → failed | wbnode or deadline | Provisioning deadline is 3× the tier's cold p95 |
running → paused | Caller, POST :pause | Tiers 2–4 only |
paused → running | Caller, POST :resume | Resume from memory, typically faster than a cold start |
running → snapshotting → running | Caller, POST :snapshot | Live, brief guest pause for memory consistency |
running → stopping | Caller DELETE, idle timeout, max lifetime, eviction, policy kill, spend cap | Reason recorded and surfaced |
stopping → stopped / failed | wbnode | Final usage flush, slot released, pool refilled |
Timeouts
Two independent timers, both enforced on the node so a control-plane outage cannot extend them.
| Timer | Default | Range | Resets on |
|---|---|---|---|
idle_timeout_s | 300 | 30 – 7200 | Any run, exec, filesystem call, stream frame, or browser/desktop action |
max_lifetime_s | 3600 | 60 – 86400 (tier dependent) | Never |
Idle timeout exists because the most common cost incident is a session nobody deleted. Maximum
lifetime exists because the second most common is a session that never goes idle — a while true
loop, a daemon the agent started, a watcher process.
session.idle_warning fired at 80% of idle_timeout_s session.lifetime_warning fired at 90% of max_lifetime_s
Both are webhooks and both are also observable on the session object, so a caller can extend
deliberately (PATCH /sessions/{id} with a new timeout, within plan limits) rather than being
surprised.
Pause and resume
Pause freezes the guest: memory is retained on the host, vCPUs stop, the disk stays attached. The session keeps its ID, its volume, its exposed ports and its policy.
| Pause → resume | Snapshot → restore | |
|---|---|---|
| Resume latency | 150 ms – 1 s | Tier's warm-claim latency plus snapshot read |
| Holds host capacity | Yes — memory stays resident | No |
| Cost while idle | paused_gib_month on memory and disk | snapshot_gib_month, far cheaper |
| Survives host maintenance | No | Yes |
| Maximum duration | 1 hour, then auto-stopped | Unbounded |
| Same session ID | Yes | No, a new session |
Use pause for a gap of seconds to minutes where resume latency matters: waiting on a human approval, waiting on a model call. Use snapshot for anything longer.
Patterns
Pre-warm during inference
The single most effective latency trick. Create the session as soon as you know the agent will need one, in parallel with the model call that decides what to do with it.
const [plan, session] = await Promise.all([
model.plan(userRequest),
wb.sessions.create({ tier: 2, template: 'tpl_dev_polyglot' }),
])
// provisioning overlapped the inference; the session is usually already running
await wb.sessions.exec(session.id, { command: plan.command })
Snapshot between turns
Replaces holding a session open across a multi-turn conversation.
// end of turn
const snap = await wb.sessions.snapshot(session.id, { includeMemory: true })
await wb.sessions.delete(session.id)
await store.set(conversationId, snap.id)
// start of the next turn, minutes or days later
const session = await wb.sessions.create({ tier: 2, restoreFrom: await store.get(conversationId) })
An hour of held tier 2 session time costs roughly three orders of magnitude more than an hour of snapshot storage for the same workspace.
One session, many runs
An agent loop against one workspace should be one session.
const session = await wb.sessions.create({ tier: 2, idleTimeoutS: 120 })
try {
for (const step of plan.steps) {
const run = await wb.runs.create({ sessionId: session.id, command: step.command })
if (run.exitCode !== 0) break
}
} finally {
await wb.sessions.delete(session.id) // always, even on throw
}
The finally is not optional. Without it, a thrown exception leaves a session billing until its
idle timeout.
Ephemeral isolation per call
The opposite pattern, correct when state must not leak: a one-shot run per call, each with a fresh filesystem. Evaluation harnesses and untrusted multi-tenant code execution want this.
const results = await Promise.all(
candidates.map((c) => wb.runs.create({ tier: 1, runtime: 'python3.12', code: c.code })),
)
Failure semantics
| Failure | State | Billing | Webhook | Recovery |
|---|---|---|---|---|
| Cold start exceeded the deadline | failed | None | session.failed, reason: provision_timeout | Retry; the platform treats it as an availability-SLO event |
| Host died while running | failed | Stops at the last usage event | session.failed, reason: host_failure | Recreate, from a snapshot if one exists |
| Guest OOM | failed | Up to the kill | session.failed, reason: oom | Recreate with more memory |
| Policy violation | failed | Up to the kill | session.failed, reason: policy_violation, with the rule | Fix the policy or the agent |
| Spend cap breached | stopping → stopped | Up to the stop | quota.exceeded | Raise the cap or add credits |
| Maximum lifetime reached | expired | Up to expiry | session.expired | Snapshot earlier next time |
| Client disconnected | Unchanged | Continues | None | Sessions outlive their connections by design |
That last row catches people out: closing the stream does not stop the session. Only DELETE, a
timeout or a failure does.
Session object
{
"id": "sess_2kQ9xwR4",
"object": "session",
"tier": 2,
"state": "running",
"project_id": "proj_7fTn21",
"end_user_id": "eu_customer_4821",
"template": "tpl_dev_polyglot@sha256:9f3c…",
"region": "bom1",
"zone": "bom1-b",
"resources": { "vcpu": 2, "memory_mib": 4096, "disk_gib": 20, "size_class": "m" },
"volume_id": null,
"policy_id": "pol_build_tools@7",
"idle_timeout_s": 300,
"max_lifetime_s": 3600,
"started_at": "2026-10-06T09:14:22.118Z",
"expires_at": "2026-10-06T10:14:22.118Z",
"last_activity_at": "2026-10-06T09:21:04.882Z",
"cold_start": false,
"connect": {
"exec_url": "https://api.workspaces.build/v1/sessions/sess_2kQ9xwR4/exec",
"pty_url": "wss://connect.workspaces.build/v1/sessions/sess_2kQ9xwR4/pty"
},
"usage_to_date": {
"session_s": 402,
"cpu_ms": 118430,
"mem_gib_ms": 1646592,
"egress_bytes": 33294112
}
}
usage_to_date is a live estimate from the node's last flush, typically under 15 seconds stale. The
billed figure is whatever the metering pipeline settles on; see metering model.