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
expectedVersiongotStaleStateErrorfor 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 answeredStaleStateErrorfor its own write. - D1 polled the session's version after a failed
saveStateAndPromoteStagingbatch only, and reportedStaleStateErrorfor a batch that had landed; its other commits threwD1StateError, and a failed read before the batch escaped untranslated.
What changed
After a transport failure a commit answers exactly one of:
| Outcome | Answer |
|---|---|
| applied | the commit's normal value, as if the reply had arrived |
| lost a race | StaleStateError (the session's version moved past expectedVersion) |
| not applied | CommitNotAppliedError (transport_error, retryable; the failure is cause) |
| unknown | CommitOutcomeUnknownError (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.
| Store | A commit's transport failure, before | Now |
|---|---|---|
| Postgres | the raw pg / Neon error | the outcome above |
| Redis | the raw ioredis error; a re-sent EVAL applied twice or answered STALE | the outcome above; a re-sent EVAL answers its first run |
| D1 | D1StateError, or StaleStateError for a batch that landed | the outcome above |
| DO-SQLite | DOStateError (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.
classifyEntryFailureclassesCommitOutcomeUnknownErrortransient, andisPermanentStoreErroris 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.CommitNotAppliedErroris an ordinary retryabletransport_error. - Temporal. An activity hands
CommitNotAppliedErrorto the workflow as a retryable failure that keeps its code, andCommitOutcomeUnknownErroras a non-retryable one: its step commit is not landed-aware yet, so a blind retry could reportStaleStateErrorfor 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
typedErrorResponseanswers it500on every route, whatever the route's "input committed" read says. Its body is{ error, code: 'transport_error' }, anderrorstarts with coreCOMMIT_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 a503until 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(useisCommitNotAppliedError/isCommitOutcomeUnknownError, which match byname), neverD1StateError/DOStateErroror 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 ofstateStoreContractTests) withrawHooks.armCommitTransport, which arms a fault on the client you pass the store (see Custom stores). An in-process store setsrawHooks.noLostResponse(andnoPreReadFaultif it cannot fail a read on demand) to the reason. - Redis: no configuration change. Re-sent commands are now safe, so leave
autoResendUnfulfilledCommandson.