Skip to content

fix: Ralph loops ignore approved reviews and always run all iterations#109

Merged
roninjin10 merged 1 commit intocodeplaneapp:mainfrom
patcito:fix/ralph-boolean-coercion
Mar 18, 2026
Merged

fix: Ralph loops ignore approved reviews and always run all iterations#109
roninjin10 merged 1 commit intocodeplaneapp:mainfrom
patcito:fix/ralph-boolean-coercion

Conversation

@patcito
Copy link
Contributor

@patcito patcito commented Mar 18, 2026

Problem

When using Ralph loops with a review step, the loop keeps running all maxIterations even after the review approves. For example, if you have:

<Ralph
  until={ctx.latest(reviewResult, "review1")?.approved === true}
  maxIterations={3}
>
  <Sequence>
    <Task id="fix1" ... />
    <Task id="review1" ... />
  </Sequence>
</Ralph>

The review task approves on iteration 0, but iterations 1 and 2 still run — wasting time and compute on redundant fix+review cycles that just confirm "everything is already fixed."

Root cause

bun:sqlite returns raw INTEGER 0/1 for columns that drizzle declares with { mode: "boolean" }. In JavaScript, 1 === true is false. So ctx.latest(reviewResult, "review1")?.approved === true always evaluates to false, and the until condition is never satisfied.

Fix

In loadOutputs (src/db/snapshot.ts), after reading rows from the database, detect boolean-mode columns and coerce 0/1 to proper JS false/true.

Test plan

  • Verify Ralph loop stops after first approved review instead of running all iterations
  • Run existing test suite: bun run test

…Outputs

bun:sqlite does not reliably honor drizzle's { mode: "boolean" } mapping,
returning raw INTEGER 0/1 instead of JS true/false. This causes strict
equality checks like `ctx.latest(schema, nodeId)?.approved === true` to
fail (since 1 === true is false in JS), which prevents Ralph loop `until`
conditions from ever being satisfied.

After loading output rows via drizzle, detect boolean-mode columns and
coerce their values to proper JS booleans.
@patcito patcito changed the title fix: coerce SQLite INTEGER to JS boolean in loadOutputs fix: Ralph loops ignore approved reviews and always run all iterations Mar 18, 2026
@patcito
Copy link
Contributor Author

patcito commented Mar 18, 2026

Worth noting: zodToCreateTableSQL.ts maps z.number(), z.float(), and z.boolean() all to plain INTEGER. Numbers are less likely to break in practice since 2 === 2 works, but if someone uses z.number() for a float value, SQLite's INTEGER column would truncate it. Might be worth mapping z.number()/z.float() to REAL instead of INTEGER in zodToCreateTableSQL.ts.

@patcito
Copy link
Contributor Author

patcito commented Mar 18, 2026

The CI tsc --noEmit failures are all pre-existing — builder.ts, pi-plugin/extension.ts, and test files. None are related to the snapshot.ts change in this PR. The same errors exist on main.

@roninjin10
Copy link
Contributor

Bug Reproduction Results

I wrote a dedicated test file (tests/boolean-coercion.test.ts) to reproduce the reported bug. Here's what I found:

Finding: drizzle 0.45.1 already coerces boolean-mode columns

On our current stack (drizzle-orm@0.45.1 + bun:sqlite), drizzle's db.select().from(table) already coerces integer({ mode: "boolean" }) columns to proper JS booleans. The loadOutputsEffect function in snapshot.ts uses this drizzle query path, so the coercion happens at the ORM layer before our code sees the rows.

However, raw bun:sqlite queries do NOT coerce — they return integer 0/1. The tests confirm this gap:

✓ raw sqlite returns integer 1, not boolean true
✓ raw sqlite returns integer 0, not boolean false

So the bug described in this PR is real for any code path that bypasses drizzle's mapper (e.g. raw $client.query() calls), but loadOutputs specifically goes through drizzle and currently works correctly on 0.45.1.

Test Coverage Added (10 tests, all passing)

Minimal unit tests (raw bun:sqlite gap):

  • raw sqlite returns integer 1, not boolean true — confirms the root cause exists at the driver level
  • raw sqlite returns integer 0, not boolean false — same for falsy case

loadOutputs boolean coercion:

  • approved=1 returns JS true via loadOutputs — verifies typeof === "boolean" and === true
  • approved=0 returns JS false via loadOutputs — verifies typeof === "boolean" and === false

Type fidelity for non-boolean columns (ensures the fix doesn't over-coerce):

  • integer column returns number with strict equalityscore === 42 works, score === true is false
  • text column returns string with strict equalitycomment === "lgtm" works
  • null values remain null — nulls aren't coerced
  • iteration (integer) preserves strict number equality — regular integers aren't touched

Ralph loop integration test:

  • ctx.latest().approved === true works after loadOutputs — full simulation of the until condition
  • ctx.latest().approved === true across multiple iterations picks latest — multi-iteration scenario

Full test suite: 773 pass, 0 fail

The existing test suite continues to pass with the new tests included.

Assessment

The explicit coercion in this PR's getBooleanColumnKeys + coerceBooleanColumns functions acts as a defense-in-depth layer — it ensures correctness even if:

  1. A future drizzle version changes its coercion behavior
  2. Someone adds a code path that uses raw queries
  3. The db object passed to loadOutputs isn't a full drizzle instance

The fix is safe (the added tests confirm non-boolean types like integers, strings, and nulls are not affected), so this is a reasonable hardening even though drizzle 0.45.1 currently handles it.

@roninjin10 roninjin10 merged commit 9364476 into codeplaneapp:main Mar 18, 2026
1 of 2 checks passed
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.

2 participants