State Stores
This guide covers the SessionStateStore interface and the contracts every implementation must satisfy. For end-user custom-state patterns (defining schemas, reading/writing state inside tools), see State Management.
Overview
The state store is the single durable surface for an agent's session data, and the place where the framework decides who may write it:
- every HITL pause writes its full continuation context into
SessionState.suspensionContext, so the next process to wake up on the session can pick up where the previous one left off, even across restarts and machine moves; - every change a snapshot or a rewind can see is a commit that writes a checkpoint and moves the session's latest-checkpoint pointer;
- every run starts with a run-start commit, the single gate that decides which of several concurrent entries runs, and every later write of a run is fenced on that run still being current.
Every in-tree store (memory, Redis, Postgres, D1, the Durable Object store) implements the whole contract atomically. A custom store must do the same: the runtimes refuse a store that is missing the run and commit methods (see Custom stores).
Commits and the run model
Breaking for custom stores
appendMessages and truncateMessages take a required commit argument, commitState, renewRunLease, setRunOwnerUnknownSince, abandonRunStart and listRunCheckpoints (an indexed read of one run's checkpoints) are new, and saveState no longer writes checkpoints. See the upgrade guide for migration detail. RM-133 adds the required listRunCheckpointMetas: the same run's checkpoints without their state (a store that has nothing cheaper can project its full ones with core's checkpointMetaOf).
Commits
A commit atomically writes its rows or state and a checkpoint { id, kind, runId, messageCount, streamSequence, stepCount, state, keepCount }, and moves the session's latest-checkpoint pointer to it. messageCount is the live row count after the write; streamSequence is the stream head at the commit (core resolveCommitSequence, or NO_STREAM_SEQUENCE (-1) when no stream manager is wired; a head read that throws fails the commit with state_checkpoint_sequence_unavailable instead of recording 0).
| Method | kind | Fenced on |
|---|---|---|
appendMessages(s, rows, { streamSequence, runId, startRun }) | input | the run-start rules (below) |
appendMessages(s, rows, { streamSequence, runId }) | append | runId is the current live run |
saveStateAndPromoteStaging / promoteStaging | step | runId is the current live run |
commitState(s, state, commit, { expectedVersion }) | state | runId is the current live run |
truncateMessages(s, keepCount, commit) | truncate | runId is the current live run |
cloneSession(from, to, options) | clone | none (runId: null, streamSequence: -1) |
commitState is the terminal-state commit (K5): a runtime writes it on every terminal exit, after the agent's terminal hooks, carrying the terminal status and error. A truncate commit also sets the session's latestTruncateCheckpointId.
These are not commits:
saveStatenever writes a checkpoint and never moves the pointer. It preserves the storedcheckpointId/checkpointedAt/latestTruncateCheckpointIdwhatever the state it is given carries.createCheckpointwrites a checkpoint row (withkind,runIdandkeepCountnull) but never moves the pointer.
getLatestCheckpoint returns the checkpoint the session's pointer names (the last commit's), never the highest-stepCount row. Rows written before this release read back with kind: null and runId: null.
Other contract changes a custom store must follow:
promoteStaging/saveStateAndPromoteStagingtake the commit'sstreamSequenceandrunId(required inPromoteStagingOptionsand in the checkpoint meta), fence on that run and stamp it on thestepcheckpoint.Checkpoint.kind,runIdandkeepCountare required fields (nullable). Parse a storedkindwith coreparseStoredCommitKind:nullis a legacy row, and an unknown value throwsUnknownCommitKindErrorrather than being read as legacy.- A dangling pointer (the session names a checkpoint that does not exist) is never answered from the highest-
stepCountrow (Redis used to fall back). Redis throws a typedstate_checkpoint_not_found; a custom store should do the same. - A corrupt message row makes
getMessagesthrowCorruptMessageRowError(with the row's sequence) instead of skipping the row. The runtimes report it asstate_history_incompletewithreason: 'corrupt_row'. cloneSessionwith afromCheckpointIdvalidates it first: an unknown id throwsstate_checkpoint_not_found, another session'sstate_checkpoint_foreign, and nothing is written (D1 used to clone the whole log on a missing checkpoint).- A clone's execution state never comes from the source's live row (RM-72). Build it with core
cloneStateFromCheckpoint(checkpoint)(status,customState,stepCountandforcedCompletionfrom the checkpoint, plus itsoutput/error/errorDetailat acompletedorfailedcheckpoint) orcloneStateWithoutCheckpoint(sourceLive)(a message-index or whole-log clone: the livecustomStateandstepCount,forcedCompletionfromcloneStateWithoutCheckpoint(sourceLive, boundaryForcedCompletion)'s second argument (a whole-log clone: the source's latest committed checkpoint's, never the live row; a message-index clone:undefined, pending FU-CLONE-INDEX-BOUNDARY-FORCED-PHASE),status: 'active', nothing else inherited), and write theclonecheckpoint from the same row. The built-in stores used to copy the source's live status, output, errors and forced phase (Redis reset them and wrotestepCount0). CallcloneStateFromCheckpointbefore any write: it throwsInvalidStoredStateErrornaming the checkpoint when the checkpoint's recorded status is no status (a corrupt row), and the clone must then write no target (contract caseclone.checkpoint-corrupt-status). A checkpoint that records no status clonesactive, and one with nocustomState(a hand-madecreateCheckpointrow) returnscustomState: undefined: keep the source's livecustomStatethen. - The session's staging-checkpoint marker is removed (
saveStateno longer checkpoints).
The run-start commit
appendMessages with commit.startRun starts a run. The rows may be empty. In one transaction, on the store's own clock (core applyRunStartRules implements the decision):
- Idempotency. The same
runIdwith an equalstartDigest(rows, startRun)writes nothing; a different digest throwsRunStartRejectedError('idempotency_mismatch'). ArunIdrecorded byabandonRunStartthrowsRunStartRejectedError('entry_abandoned'). - CAS on
startRun.expectCurrentRunId. A mismatch throwsRunStartConflictError('expected_mismatch')when the current run is live, elseRunStartRejectedError('expected_mismatch_idle'). The current run's status must also bestartRun.expectCurrentRunStatus, elseRunStartRejectedError('expected_run_status_changed'). - Owner guard. A live current run is supersedable only by the same
ownerToken, a lapsed or absent lease (ownerless: { kind: 'lease', ttlMs }), orownerless: { kind: 'asserted' }(the runtime proved its ownerless predicate). OtherwiseRunStartConflictError('run_active')— unless the store verifies afinishCurrentfor that run (step 4: its terminalstatecommit landed), which skips the guard. Then the session status the store read in the same transaction must be instartRun.entryStatuses, elseRunStartRejectedError('entry_status_changed'); pass that status toapplyRunStartRulesassessionStatus. - Supersede. Every other live run becomes
superseded(its lease cleared). WithstartRun.finishCurrent, a current run whose terminalstatecommit already landed (the store's own reads verify it) is finished with the given status instead, even while its lease is valid. - Rewind. With
startRun.truncateTobelow the live count, the rows from it on are deleted and atruncatecheckpoint attributed to the new run is written. Companion ref. WithstartRun.resumedCompanionRef: { parentSessionId }(the run resumes an interrupted persistent companion, CP-103 A1), the parent's sub-session ref for this session becomesstatus: 'running',completionDelivered: falsein the same transaction. A missing parent or ref throwsRunStartRejectedError('resumed_companion_ref_missing')and writes nothing. A store that cannot hold the parent's ref and the child's run in one atomic step must refuse the field typed (DO-SQLite throwsUnsupportedOperationError), never write the run without it. The field is not part ofstartDigest. - Create. The run (
status: 'running',turn,startSequence,startMessageCount,startUIMessageCount,startDigest,ownerToken, and in lease modeleaseUntil = storeNow + ttlMs) becomes the session's current run; the rows and aninputcheckpoint are written.startRun.pendingRestampre-stamps (or drops) the session's pending client-tool calls in the same transaction.
A rejected run-start writes nothing. See Distributed Coordination for how runtimes use it.
The fence
- Commits. Every commit that carries a
runIdthrowsRunSupersededErrorand writes nothing unlessrunIdis the session's current live run (runningorsuspended_*). saveState(s, state, { fenceRunId })applies the same check.saveState(s, state, { fenceIdle: { runId } })instead requires the current run to still berunId(null= none) and not live. Both run in the same atomic step as the version CAS. UnfencedsaveStateis for admin tools and tests.updateRunStatusis sticky: only a live run may change status; acompleted,failed,interruptedorsupersededrun throwsRunSupersededError— except for the status it already has, an idempotent no-op that writes nothing (use coredecideRunStatusTransition, which returns'noop'then). A move to any status other thanrunningclears the run'sleaseUntil. A live status (running,suspended_*) never sets or changescompletedAt, even when the caller passes one; a terminal status sets it (the caller's value, else the store's now). StoreendSequenceonly when the caller passes it (keep the existing value otherwise); never derive it, for example from a message sequence. The contract scenariorun.live-status-no-completedAt(part ofstateStoreContractTests) checks it.getRun(runId)must return a run with its current status to every caller as soon as its run-start commit has returned, even wheregetCurrentRunmay lag (a replicated store).
A commit's outcome after a transport failure
A commit (every method in the Commits table but cloneSession) can fail after its write reached the database, with the reply lost on the way back. The caller must then not repeat it blindly: the commit may have landed. So a commit resolves its own outcome before it answers, and a transport failure (a dropped connection, a timeout, a failed read before the write) is never handed to the caller raw. It answers exactly one of:
| Outcome | Answer | What the caller does |
|---|---|---|
| applied | the commit's normal value, as if the reply had arrived | nothing more |
| lost a race | StaleStateError (the session's version moved past expectedVersion) | re-read and re-plan, as for any lost CAS |
| not applied | CommitNotAppliedError (transport_error, retryable, the failure as cause) | repeat the commit unchanged |
| unknown | CommitOutcomeUnknownError (transport_error, not retryable) | re-read the session before deciding; never repeat blindly |
The store decides by reading back the commit's OWN checkpoint (written in the same atomic step as the rest of the commit), never the session's pointer: a later commit by another writer can move the pointer past it. When that read-back fails too, the answer is "unknown", never "not applied". A failure before the write was sent wrote nothing, so it is "not applied" (or "lost a race" when the pinned version has moved). A typed refusal (StaleStateError, RunSupersededError, RunStartRejectedError, AgentAlreadyRunningError, CommitStoppedError, a state_* error, a corrupt-row error) is answered as it is.
How the built-in stores do it:
- Postgres resolves only the errors its driver threw; anything else passes through. It knows the checkpoint id its transaction wrote, so a lost
COMMITreply is read back by that id. - Redis records the commit's reply on its checkpoint in the same script call, so the read-back returns exactly what the lost reply said. A script call the client re-sends after a reconnect (ioredis
autoResendUnfulfilledCommands) finds that record and answers it without writing again. - D1 resolves a failed read before the batch, and a failed batch, inside its commit loop (the read-back retries for up to about 0.8 s).
- DO-SQLite and memory commit in-process: a reply cannot be lost. A DO-SQLite commit that fails rolls back and answers
CommitNotAppliedError.
The runtimes treat "unknown" as never permanent: core classifyEntryFailure classes it transient (the next attempt re-reads), and isPermanentStoreError is false for it. An HTTP route answers it with a non-retryable 500, so a client re-reads instead of re-sending.
Run-lease and owner methods
| Method | Contract |
|---|---|
renewRunLease(s, runId, ttlMs) | CAS: only while runId is the current running run, sets leaseUntil = storeNow + ttlMs (store clock) and returns it; else RunSupersededError |
setRunOwnerUnknownSince(s, runId, since | null) | Cloudflare Workflows' owner-unknown timestamp: set-if-null with a number, clear with null; returns the stored value |
abandonRunStart(s, runId) | Atomic with every run-start commit: returns 'committed' if the run exists (writes nothing), else records a tombstone and returns 'abandoned'. Idempotent; invisible to every run read |
"Store clock" means Postgres now(), Redis TIME, and the process clock for memory, D1 and the Durable Object store.
Custom stores
Every executor checks for the run and commit methods at construction and throws when one is missing, instead of silently skipping run bookkeeping. All of them require getCurrentRun, getRun, listRuns, updateRunStatus and commitState; runtime-js also requires renewRunLease, and Cloudflare Workflows setRunOwnerUnknownSince. isSessionStateStore checks the session, message and state basics plus createRun, updateRunStatus, getCurrentRun, listRuns, renewRunLease and commitState. Implement the whole interface: the other new methods (abandonRunStart in particular) are called at run time by the runtimes that need them.
Run the shared contract suites from @helix-agents/core/testing against your store. stateStoreContractTests({ name, createStore, clearStore, rawHooks, resumedCompanionRef }) runs every suite below; they are also exported one by one. Pass resumedCompanionRef: 'refused' only if your store cannot apply the companion ref atomically (the suite then checks it refuses typed and writes nothing):
| Suite | Covers |
|---|---|
commitCheckpointOperationTests | every commit kind writes its checkpoint atomically and moves the pointer |
runStartOperationTests | the run-start rules, idempotency, rewind, supersede, the companion ref (commit.start-companion-ref-atomic), fault atomicity and lease renewal |
fenceOperationTests | the superseded-run fence on commits, saveState and run status |
checkpointOperationTests | checkpoint reads, listing and getLatestCheckpoint by pointer |
commitOutcomeOperationTests | every commit's outcome after a transport failure: applied, not applied, unknown, a failed pre-read, a lost race |
rawHooks.armFault (fault injection inside the atomic write) and rawHooks.insertLegacyCheckpoint (a kind: null row) enable the cases the public API cannot set up; without them those cases are skipped with a written reason. rawHooks.armCommitTransport arms one transport fault on the store's client right before a commit (phase: 'pre-read' | 'write', mode: 'fail-before' | 'drop-response' | 'drop-response-and-fail-read-back'); wrap the client you pass the store, as the built-in runners do. An in-process store sets rawHooks.noLostResponse (and, if it cannot fail a read on demand, rawHooks.noPreReadFault) to the reason, and those cases skip with it. rawHooks.sessionResidue lets the deleteSession cascade case check that nothing is left for a deleted session: discover the places from your store's own schema or keyspace (not a list of what your deleteSession deletes), so a table you add later is covered too. For a custom stream manager, run streamManagerContractTests, which includes the claimStream compare-and-set (RF.claim.cas) and waitForLive (WL.*) cases. Give it a manager with any fallback poll switched off: the contract awaits each wake with no time bound, so a reader that relies on a poll instead of the write's or the transition's own wake would pass for the wrong reason.
What changed in v7
The v7 stateless-suspension release changed the store contract as follows. If you maintain a custom SessionStateStore implementation or operate sessions at the SQL level, the v7 changes that matter:
- New required method:
saveStateAndPromoteStaging. Atomic write-and-promote that replaces the v6 two-call dance. - Forward-only schema migrations added to all in-tree stores — Postgres V5, D1 V8, DO SQLite V4. They add a
suspension_contextcolumn and indexes onpendingClientToolCallsfor efficient expiration queries. See Storage migrations. compareAndSetStatusreturn shape. Old:Promise<boolean>. New: discriminated{ ok: true; newVersion } | { ok: false; currentStatus; currentVersion }. The single most-commonly-tripped v7 breaking change.- New
SessionStatefields —suspendedAwaitingChildren,suspendedStepId,tracingContext,expiresAt. Custom stores must persist all of them, even if the columns store JSON. expiredSessionCleanup— operator-driven helper for reaping sessions whoseexpiresAtis in the past.
If you are upgrading from v6, read the v6 to v7 migration guide end-to-end before deploying. The rest of this page describes the v7 model.
saveStateAndPromoteStaging
SessionStateStore.saveStateAndPromoteStaging(sessionId, state, appendMessages, { stepId, stepCount, streamSequence, runId }, options) atomically:
- Persists the full
SessionState(custom state, suspensionContext, all the v7 fields) and appends the step's messages. - Promotes the step's staged writes into the canonical state.
- Writes a
stepcheckpoint and moves the latest-checkpoint pointer. - Bumps the session version.
It is fenced on runId, refuses a stale options.expectedVersion with StaleStateError, and with options.refuseIfStopped refuses the commit (CommitStoppedError, state_commit_stopped) when the run has a durable stop record (WA MR2). A refused call writes nothing. It is the canonical write path used by the run loop after every step. The previous v6 flow — saveStaging followed by a separate commitStaging call — has a small window where a crash leaves staging written but unpromoted. The atomic primitive closes that window.
Implementing in a custom store
If you maintain a third-party SessionStateStore, you MUST implement saveStateAndPromoteStaging atomically — run both writes inside a single transaction (Postgres) or compare-and-swap (Redis/DO).
Earlier versions exported a defaultSaveStateAndPromoteStaging() helper from @helix-agents/core that performed the legacy two-call flow (non-atomic). That helper was removed in P3.R3-BC-FALLBACK: a sequential appendMessages → saveState → promoteStaging opens a small window where a crash between calls leaves staging written but unpromoted, which is exactly the corruption the atomic primitive was added to prevent. All five in-tree stores (memory, redis, postgres, D1, DO) implement the atomic version; custom stores must do the same.
createSession and appendMessages contracts
Every store must meet these two requirements, and the shared contract suite (stateStoreContractTests from @helix-agents/core/testing) checks both:
createSessionis visible whole or not at all. Two concurrent calls for the same id produce exactly one winner, and the others throwSession already exists: <id>. A concurrentloadStatesees eithernullor the complete initial session, never a partially written one. Write the claim and every initial field in one atomic operation: a singleINSERTin SQL, or one Lua script in Redis (RM-50).appendMessageson a missing session throws and writes nothing. Afterwards there is still no session, no partial row, and no orphan message list for a latercreateSessionof the same id to inherit.
compareAndSetStatus returns an object
The status-CAS API changed in v7 to surface what the store saw, not just whether the swap succeeded:
// v6
const ok = await store.compareAndSetStatus(sessionId, ['active'], 'paused');
if (ok) { ... }
// v7
const result = await store.compareAndSetStatus(
sessionId,
['active'],
'paused',
);
if (result.ok) {
console.log('promoted to version', result.newVersion);
} else {
console.log(
'lost CAS — store is at',
result.currentStatus,
'version',
result.currentVersion,
);
}Every call site in your codebase must update. The lossy boolean form is gone.
New SessionState fields
v7 adds four fields to SessionState. Custom stores that serialize state must round-trip all of them.
| Field | Type | Purpose |
|---|---|---|
suspensionContext | SuspensionContext | undefined | Continuation context for HITL pauses. Read on resume to restore loop. |
suspendedAwaitingChildren | SuspendedChildWait[] | undefined | Per-child waits for cascading sub-agent suspensions. |
suspendedStepId | string | undefined | The step ID a suspended_step_partial outcome is anchored to. |
tracingContext | TracingContext | undefined | Persisted Langfuse / OTel trace IDs so resume continues the same trace. |
expiresAt | number | undefined | Epoch ms TTL — read by expiredSessionCleanup. |
In Postgres, all four serialize into the state JSONB column (no schema migration needed beyond V5's suspension_context column for indexing). In D1 / DO SQLite they live in the state TEXT column.
Deleting a session
deleteSession(sessionId) deletes the session and its whole child tree: state, messages, runs, checkpoints, sub-session refs, staging, interrupt flags and abandoned run starts of every descendant (plus usage rows, on the stores that own usage: Postgres and D1).
The edge rule. A session's children are the sessions named by its local sub-session refs, plus the sessions whose parentSessionId is that session, minus any session it names through a remote ref. Both halves are needed: some runtimes write the parentSessionId before the ref (a child that has not finished yet), and Temporal writes the ref before the parent id. The walk is recursive and cycle-safe. Core exports the rule as sessionTreeChildren and the walk as collectSessionTree.
Remote children are never deleted. SubSessionRef.location is 'remote' for a child that runs in another service. That child is excluded even when it shares the store and carries your parentSessionId. Every runtime writes the remote ref before it starts the remote child, and a failed write fails the call, so a remote child never exists without it. A failed read of the parent's refs before that write fails the call too (a retryable transport_error). The exclusion lives on the parent's refs, so it holds while the parent exists: a deleteSession repeated after the parent was fully deleted (for example after a reply lost on a dropped connection) follows parentSessionId alone and deletes a remote child that shares the store and names it (FU-DELETE-SESSION-REMOTE-CHILD-AFTER-PARENT-GONE).
A retry finishes an interrupted delete. The walk starts from sessionId even when that session no longer exists, and a store that deletes one session at a time deletes the root last (on Redis, each session's hash, index entries and refs go together, after its other keys), so calling deleteSession again after a delete that did not finish picks up whatever the first call left and still excludes remote children.
A live run is undefined. Deleting a tree that has a live run may leave a child that was created while the delete ran. Wait for the run to end, or interrupt it, first.
The Durable Object store refuses. DOStateStore.deleteSession throws a HelixError with code: 'framework_not_supported' (non-retryable). Each session is its own Durable Object and its children are sibling objects, so a cascade could not be atomic.
After a delete, executing the same session id again runs its children on fresh sessions (runtime-js, the Cloudflare Workflows runtime and Temporal). DBOS differs: see Sub-Agents. Per-store mechanics are in the storage pages: memory, Redis, Postgres and Cloudflare D1. A custom store implements the same rule and runs the contract's cascade cases (skip.deleteSession: '<reason>' opts out of every case that deletes a session, and the reason is printed in the skipped case); see the upgrade guide.
Storage migrations
Every in-tree SQL store ships forward-only migrations. Apply migrations before rolling new code; new code reading old data is fine, but old code reading new data is undefined behavior.
Postgres migrations may run from several processes at once (app instances or workers starting together): PostgresMigrations.run() needs PostgreSQL 12 or newer, and one run migrates at a time through a lease row (<prefix>migration_lease) while the others wait for it. Table prefixes are limited to 34 characters. See Concurrent migrators.
Commits and run-start migrations (this release)
| Package | Migrations | Adds |
|---|---|---|
@helix-agents/store-postgres | V14, V15 | V14: checkpoints kind / run_id / keep_count; runs lease_until / start_digest / owner_token / owner_unknown_since; states current_run_id / latest_truncate_checkpoint_id. V15: runs entry_step_count / input_row_count / parent_session_id / parent_tool_call_id; the __agents_abandoned_run_starts tombstone table |
@helix-agents/store-cloudflare (D1) | V17, V18 | the same columns (V17) and the same run columns plus the tombstone table (V18) |
@helix-agents/runtime-cloudflare (DO SQLite) | 11, 12 | the same columns (11) and the same run columns plus an abandoned_run_starts table (12) |
@helix-agents/store-redis | (none) | new hash fields, written by the updated Lua scripts. The old saveState could change a session's status without moving its idx:sessions:status:<status> entry, so listSessions({ status }) can list a session under its old status. rebuildIndexes() adds missing entries but never removes one: delete the [<keyPrefix>:]idx:sessions:status:* sorted sets, then run rebuildIndexes() once (see the deploy checklist) |
@helix-agents/store-memory | (none) | in-memory |
Every new column is nullable, so legacy rows read back as "not recorded": a checkpoint written before the upgrade has kind: null and runId: null, and a session whose latest checkpoint is such a row is served in legacy mode until its first new commit (see the upgrade guide). Drain in-flight runs before deploying; see the upgrade guide's deploy notes.
Delete cascade migrations (RM-64)
| Package | Migrations | Adds |
|---|---|---|
@helix-agents/store-postgres | V17 | __agents_sub_session_refs.location |
@helix-agents/store-cloudflare (D1) | V21 | a partial index on __agents_states(parent_session_id); __agents_sub_session_refs.location |
@helix-agents/runtime-cloudflare (DO SQLite) | 15 | sub_session_refs.location |
@helix-agents/store-redis | (none) | a location hash field on each ref; the parent index now follows parentSessionId on every state write |
@helix-agents/store-memory | (none) | in-memory |
StepWrites migrations (v0.7)
| Package | Migration | Notes |
|---|---|---|
@helix-agents/store-postgres | V10 | StepWrites unification: replaces patches/merge_changes with writes. TRUNCATEs staging (ephemeral). |
@helix-agents/store-cloudflare (D1) | V13 | StepWrites unification: same shape change as Postgres V10 for D1 staging table. |
@helix-agents/runtime-cloudflare (DO) | V7 | StepWrites unification: same shape change for DO SQLite staging table. |
@helix-agents/store-redis | (none) | Lua scripts updated in-place; schemaless — no migration table entry. |
@helix-agents/store-memory | (none) | In-memory; no migration needed. |
Checking the applied version
Postgres and D1:
SELECT version FROM __agents_migrations
ORDER BY version DESC
LIMIT 1;Postgres should show 15 or higher; D1 should show 18 or higher.
Durable Objects (DO SQLite):
DO uses its own schema_info key-value table (not __agents_migrations). Check the schema version from within the DO process:
// Inside the DO (e.g. in a debug handler or health endpoint):
const rows = this.storage.sql.exec("SELECT value FROM schema_info WHERE key = 'version'").toArray();
console.log('DO schema version:', rows[0]?.value);
// Should be '12' or higher.Or, if you have direct SQLite access to the DO's storage:
SELECT value FROM schema_info WHERE key = 'version';The returned value should be 12 or higher.
Forward-only (Phase B / StepWrites migration)
Phase B's schema migrations (Postgres V10, D1 V13, DO V7) are forward-only. They TRUNCATE the ephemeral staging table and DROP the patches/merge_changes columns. In-flight steps at migration time are recovered by checkpoint replay.
Rolling deploys are NOT safe for this migration — it's a flag-day:
- If migrations apply first: old workers fail on
stageChanges(columnwritesmissing in old code's INSERT). - If migrations apply after: new workers fail on
stageChanges(columnspatches/merge_changesalready dropped).
Apply migrations during a brief maintenance window or via a full cutover.
Forward-only (v7 stateless suspension)
Rolling back from v7 to v6 after applying the v7 migrations (Postgres V5, D1 V8, DO V4) is unsafe by default. Sessions paused under v7 carry suspension context that v6 does not know how to read; resuming them under v6 silently loses the context. See the migration guide's rollback semantics for the recovery procedure.
Operator-driven session cleanup
v7 adds expiredSessionCleanup to @helix-agents/agent-server — a helper for reaping sessions whose expiresAt is in the past. The helper:
- Pages through
stateStore.listSessions()(configurable page size, default 200). - Loads each session and checks
expiresAt. - For each expired non-terminal session: enumerates owned workspace snapshots via the matching
WorkspaceProvider'ssnapshot.list/snapshot.deletecapability and deletes them (closes the R2 cost- amplification gap). - CAS's the session status to
'failed'with reason'session_expired'. Per-session failures (load errors, snapshot errors, CAS conflicts) are logged but do not abort the loop.
The framework does not run this automatically. Wire it into a scheduled job (cron, Cloudflare Alarm, k8s CronJob).
import { expiredSessionCleanup } from '@helix-agents/agent-server';
// Cloudflare Alarm handler
export default {
async scheduled(_event, env, _ctx) {
const summary = await expiredSessionCleanup({
stateStore,
workspaceProviders, // Map<providerId, WorkspaceProvider>
logger: consoleLogger,
});
console.log('cleanup summary', summary);
},
};The returned summary ({ detected, marked, alreadyTerminal, snapshotsDeleted, errors }) gives operators a per-run observability handle.
See also
- State Management — defining and using custom state
- Checkpoints — how snapshots layer on top of state
- Distributed Coordination — how runtimes use the run-start commit, the fence and the JS run lease
- Snapshot and checkpoint truth upgrade guide
- v6 to v7 migration guide
- Storage Overview — reference for each store implementation