Skip to content

fix(state): review follow-ups from #142/#149 — idempotency ordering, head verification, replay across migrations - #171

Merged
ScriptedAlchemy merged 1 commit into
mainfrom
fix/review-state-drivers
Sep 1, 2026
Merged

fix(state): review follow-ups from #142/#149 — idempotency ordering, head verification, replay across migrations#171
ScriptedAlchemy merged 1 commit into
mainfrom
fix/review-state-drivers

Conversation

@ScriptedAlchemy

Copy link
Copy Markdown
Owner

Summary

Unresolved Codex review findings on #142 (state kernel) and #149 (sqlite driver), each re-verified against current main after the #154 Effect-native rewrite:

  • Idempotency before the reducer (both drivers, P1): dispatch validated the payload and ran the reducer before the committed-key lookup, so a legitimate retry could fail reducer-failure (e.g. a remove event retried after the item was removed) instead of replaying. Payload validation and canonicalization now precede the key lookup; the reducer runs only on the append path.
  • Replay across migrations (P1): replay resolved the committed result by exact-revision journal replay, which migrations deliberately rebase (revision-unavailable). Every journal record now stores its post-commit state (sqlite event rows use the existing nullable state column — no format bump), and migrations run the same chain over stored results, so a pre-deployment retry still returns its committed result, migrated to the current version. Legacy event rows fall back to journal replay.
  • Head verification on open (sqlite, P1): open now checks journal continuity (expectConsistentJournal; a deleted intermediate row fails closed even when the final revision survives) and verifies the materialized head against journal replay, so a schema-valid hand-edited head fails closed as corrupt. With a pending migration (old-version records can't replay under current definitions), the head is verified against the last stored post-commit state.
  • Full-id filename hashing (sqlite, P2): sanitizedFileName suffixed the hex encoding of the id's first six bytes; ids sharing a sanitized prefix (e.g. abcdef/a vs abcdef-a) collided onto one file. The suffix is now a sha-256 of the complete id.
  • Sparse arrays rejected (P2): isJsonSafe used Array.prototype.every, which skips holes, so a sparse array canonicalized like a dense one under permissive schemas. Holes now fail closed as not JSON-safe.
  • The feat(state): #98 v1 kernel contract, memory driver, conformance suite (PR-1) #142 README finding (absent sqlite driver) is stale — feat(state): #98 v1 node:sqlite workspace-durable driver + example migration (PR-2) #149 shipped @agent-bundle/runtime/state/sqlite and the export exists; no change needed.

The conformance suite pins the corrected semantics for both drivers (and any future external driver): a committed key replays without re-running the reducer, replay survives migrations, and prefix-colliding definition ids stay isolated.

Test plan

  • rstest scoped: state-conformance, state-kernel, state-sqlite, state-packaging — 75 passed
  • Cross-process sqlite proofs (integration config, full build): 2 passed
  • New sqlite corruption tests: schema-valid head disagreeing with replay; missing intermediate journal row
  • pnpm typecheck, pnpm lint clean
  • pnpm eval:spot passed

…head verification, replay across migrations

Codex review follow-ups on the state kernel and drivers, re-verified
against the Effect-native internals from #154:

- Both drivers consult the idempotency key before running the reducer
  (#142/#149 P1): a committed key replays its stored result even when the
  reducer would now fail against the current head. Payload validation and
  canonicalization moved ahead of the key lookup; the reducer runs only on
  the append path.
- Committed results replay across migrations (#142 P1): every journal
  record stores its post-commit state (event rows too, in the sqlite
  driver's existing nullable column), and migrations run the same chain
  over the stored results, so replay no longer depends on exact-revision
  history that migrations rebase. Legacy event rows without a stored state
  fall back to journal replay.
- The sqlite driver verifies storage on open (#149 P1): journal continuity
  via expectConsistentJournal (a deleted intermediate row fails closed) and
  the materialized head against journal replay (a schema-valid hand-edited
  head fails closed); with a pending migration the head is checked against
  the last stored post-commit state instead.
- sanitizedFileName hashes the complete definition id (sha-256) instead of
  hex-encoding its first six bytes (#149 P2), so ids sharing a sanitized
  prefix get isolated database files.
- isJsonSafe rejects sparse arrays (#142 P2): holes no longer canonicalize
  like dense arrays under permissive schemas.

The conformance suite pins the corrected semantics for both drivers:
replay-before-reduce, replay across migrations, and prefix-colliding id
isolation; sqlite corruption tests cover the two new fail-closed opens.
@changeset-bot

changeset-bot Bot commented Sep 1, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0fb7fe1

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@agent-bundle/runtime Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-01T18:54:58.946236Z 0fb7fe1 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@ScriptedAlchemy
ScriptedAlchemy merged commit 48cdcd2 into main Sep 1, 2026
4 of 9 checks passed
@ScriptedAlchemy
ScriptedAlchemy deleted the fix/review-state-drivers branch September 1, 2026 18:51

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0fb7fe19bc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines 229 to +230
const sanitizedFileName = (definitionId: string): string =>
`${definitionId.replace(/[^a-zA-Z0-9._-]+/gu, '-')}-${Buffer.from(definitionId, 'utf8').toString('hex').slice(0, 12)}.sqlite`;
`${definitionId.replace(/[^a-zA-Z0-9._-]+/gu, '-')}-${createHash('sha256').update(definitionId, 'utf8').digest('hex').slice(0, 16)}.sqlite`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep previous root-mode databases discoverable

When a durable driver is configured with root, upgrading changes the derived filename for every existing definition ID, not only IDs that previously collided. The next open() therefore creates a new empty database under the SHA-based name while the prior database remains at the old hex-based path, making all persisted state appear lost. Retain the old mapping as a migration/discovery fallback before creating the new file.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #201 (merged as ae7722c). Root-mode opens now discover the pre-#171 legacy filename (hex(id)[0:12] suffix, verified against the shipped naming in #149) and adopt it by rename — including -wal/-shm sidecars, renamed before the main file so un-checkpointed WAL commits survive — with a graceful fallback when a concurrent opener wins the adoption race. Regression test opens a legacy-named DB (with a live WAL) and asserts full data retention.

Comment on lines +684 to +685
for (const row of rows) {
if (row.state === null) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Backfill legacy event results before rebasing history

When a database was written by the previous implementation, event rows have state = NULL; opening it with a newer definition skips those rows here and then appends the migration baseline. A later retry of one of those event keys enters #committedState's journal fallback, but #replayTo rejects its pre-migration revision as revision-unavailable, so the advertised pre-deployment retry behavior still fails for existing databases. These legacy results need to be recovered before history is rebased, or the migration must preserve another compatible replay path.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #201 (merged as ae7722c). Committed results now live in a dedicated result_state column (added via ALTER TABLE on legacy DBs). During schema migration, legacy event rows with NULL stored state are no longer skipped — their post-commit results are reconstructed by sequential journal replay (definition.reduce from initial, threading prior stored results) before migrating, so idempotent replay keeps working for those keys. Regression test: a legacy DB with NULL-state event rows replays both keys correctly after migration.

Comment on lines +686 to +687
const rawState = parseStoredJson(definition.id, 'state', row.revision, row.state);
updateState.run(canonicalJson(runStateMigrations(definition, meta.schema_version, rawState)), row.revision);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve reset inputs while migrating committed results

This update rewrites the state column for reset rows as well as event-result rows, but for a reset row that column is also the original commit input used by canonicalCommitInput. After any schema-compatible migration that transforms the value, retrying the identical reset seed compares against the transformed row and returns idempotency-conflict rather than replaying, unlike the memory driver which keeps the record and migrated result separately. Store the migrated result separately from the reset input or otherwise preserve the original dedupe identity.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #201 (merged as ae7722c). Migration now rewrites only the result_state column; the state column (the commit input — e.g. reset seeds) is preserved verbatim for retry comparison. Regression test asserts a legacy reset row keeps its original seed bytes on disk while its idempotent retry returns the migrated result.

Comment on lines +325 to +329
for (const [key, entry] of internals.keys) {
internals.keys.set(key, {
record: entry.record,
state: runStateMigrations(definition, fromVersion, entry.state),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make in-memory result migration atomic

If migration succeeds for the head but throws for a later historical committed result, this loop has already replaced earlier map entries in place, and the migration record was already appended. The rejected open() consequently leaves the process store partially migrated; retrying with a corrected migration appends another record at the same revision and migrates some entries twice. Build the migrated key map and record without mutating internals, then swap all fields only after every migration succeeds.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #201 (merged as ae7722c). migrateOpenStore now builds the migrated keys map fully before swapping it in; the journal/head/definition updates also happen only after every per-key migration succeeded, so a throwing migration leaves the store exactly as it was. Regression test: a migration that throws on one historical result leaves reads, change feeds, and idempotent replay intact, and a later successful migration still works.

ScriptedAlchemy added a commit that referenced this pull request Sep 1, 2026
Rebased survivors of the stranded deslop/wave-3.5 commit (5a8723423)
onto current main. Applied:

- finalizers: sqlite connection close and Flight reader cancel no longer
  mask the original failure when teardown itself throws
- state drivers: shared pending-open tracker replaces the verbatim
  trackPendingOpen/close-drain duplication; drop runPromise(Effect.fail)
  ceremony in favor of direct rejections
- boundaries: remove the unused runSyncExit export from both seams, the
  dead ScopedEffectRuntime E type parameter from the rsc-runtime copy
  (matching the dev-seam copy), and the redundant string ternary in
  toDevError/toRuntimeError
- delete dead epoch-lease-registry.ts (zero importers; #161 rewrote the
  same concept in epoch-store.ts); dedupe boundRenderEventStream through
  emitBoundRenderEvent; trim migration-narration comments

Dropped as superseded: the reconciler progress-queue rework (#172
rebuilt that path with a demand-bounded design), the dev-seam trim of
interruptWhenAborted/runPromiseExit (#164 fixed and kept them with
tests), the sqlite #commit self-rewrite (#171 rewrote #commit), and the
lint-plugin inlining (#164 expanded the plugin around those helpers).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant