Skip to content

A commit resolves its own outcome ​

This guide covers the release in which every built-in state store answers a commit's transport failure with the commit's real outcome, instead of handing the caller a raw driver error. It changes what a commit throws on @helix-agents/store-postgres, @helix-agents/store-redis, @helix-agents/store-cloudflare (D1) and @helix-agents/runtime-cloudflare (the Durable Object's DO-SQLite store), and adds two error classes to @helix-agents/core. Design: the spec.

Why ​

A commit (appendMessages with a commit, truncateMessages, promoteStaging, saveStateAndPromoteStaging, commitState) can fail after its write reached the database, with the reply lost on the way back. Before this release the caller could not tell that apart from a write that never landed:

  • Postgres and Redis threw the raw driver error. A caller that repeated the commit with the same expectedVersion got StaleStateError for its own landed write, and re-planned a step it had already committed.
  • Redis could also run one commit twice: ioredis re-sends an unanswered command after a reconnect (autoResendUnfulfilledCommands, on by default). A re-sent append wrote its rows twice; a re-sent version-pinned commit answered StaleStateError for its own write.
  • D1 polled the session's version after a failed saveStateAndPromoteStaging batch only, and reported StaleStateError for a batch that had landed; its other commits threw D1StateError, and a failed read before the batch escaped untranslated.

What changed ​

After a transport failure a commit answers exactly one of:

OutcomeAnswer
appliedthe commit's normal value, as if the reply had arrived
lost a raceStaleStateError (the session's version moved past expectedVersion)
not appliedCommitNotAppliedError (transport_error, retryable; the failure is cause)
unknownCommitOutcomeUnknownError (transport_error, not retryable)

The store reads back the commit's own checkpoint to decide. "Unknown" means that read failed too: the commit may have landed, so re-read the session before repeating it. A typed refusal (StaleStateError, RunSupersededError, RunStartRejectedError, AgentAlreadyRunningError, CommitStoppedError, a state_* error, a corrupt-row error) is answered as before.

StoreA commit's transport failure, beforeNow
Postgresthe raw pg / Neon errorthe outcome above
Redisthe raw ioredis error; a re-sent EVAL applied twice or answered STALEthe outcome above; a re-sent EVAL answers its first run
D1D1StateError, or StaleStateError for a batch that landedthe outcome above
DO-SQLiteDOStateError (and saveStateAndPromoteStaging's error raw)CommitNotAppliedError (nothing can be lost in-process)

Non-commit operations (saveState, loadState, the run and checkpoint reads, …) still throw what they threw.

The runtimes ​

  • Core. classifyEntryFailure classes CommitOutcomeUnknownError transient, and isPermanentStoreError is false for it: the next attempt re-reads. So a Durable Object wake re-arms instead of ending a run whose commit may have landed. CommitNotAppliedError is an ordinary retryable transport_error.
  • Temporal. An activity hands CommitNotAppliedError to the workflow as a retryable failure that keeps its code, and CommitOutcomeUnknownError as a non-retryable one: its step commit is not landed-aware yet, so a blind retry could report StaleStateError for a step that landed (FU-TEMPORAL-COMMITSTEP-LANDED-AWARE).
  • DBOS. A run-start entry step re-attempts on CommitOutcomeUnknownError; its already-committed path then decides.
  • HTTP. An unknown outcome is non-retryable, so typedErrorResponse answers it 500 on every route, whatever the route's "input committed" read says. Its body is { error, code: 'transport_error' }, and error starts with core COMMIT_OUTCOME_UNKNOWN_HINT (Commit outcome unknown; re-read the session, do not resend), placed first so the 256-character cut on HTTP messages never drops it. A client re-reads the session (its snapshot) and never re-sends the request. A commit that was not applied is a 503 until the entry's input committed, as for any retryable error.

Breaking: @helix-agents/runtime-cloudflare (major) ​

DOStateStore's five commits throw core CommitNotAppliedError where they threw DOStateError (and saveStateAndPromoteStaging threw the raw error): instanceof DOStateError around a commit no longer matches a failed commit. Match it with isCommitNotAppliedError (or by its transport_error code); it is retryable, and the store wrote nothing. DOStateError still wraps the DO store's other operations.

What to do ​

  • Code that catches a store error by class around a commit: a transport failure now arrives as CommitNotAppliedError / CommitOutcomeUnknownError (use isCommitNotAppliedError / isCommitOutcomeUnknownError, which match by name), never D1StateError / DOStateError or a raw driver error. Repeat a commit that was not applied; re-read the session after an unknown outcome.
  • A custom store should resolve its commits the same way. Run commitOutcomeOperationTests (part of stateStoreContractTests) with rawHooks.armCommitTransport, which arms a fault on the client you pass the store (see Custom stores). An in-process store sets rawHooks.noLostResponse (and noPreReadFault if it cannot fail a read on demand) to the reason.
  • Redis: no configuration change. Re-sent commands are now safe, so leave autoResendUnfulfilledCommands on.

Released under the MIT License.