Skip to content

TurnStore backend divergence: duplicate (conversationId, turnIndex) append throws on SQLite, silently dups on InMemory #9

Description

@drwhofan2k18-pixel

Bug — cross-backend contract divergence (S49 TurnStore)

The two TurnStore backends disagree on what happens when appendTurn is called with a (conversationId, turnIndex) coordinate that already exists.

Behavior

  • SqliteTurnStore — the turns table has UNIQUE(conversation_id, turn_index) (src/store/turns/schema.ts:47) and appendTurn does a raw INSERT (src/store/turns/sqlite-store.ts:242) with no pre-check or ON CONFLICT handling. A duplicate coordinate throws UNIQUE constraint failed: turns.conversation_id, turns.turn_index (ERR_SQLITE_ERROR), uncaught — it propagates to the host and leaks the SQL abstraction.
  • InMemoryTurnStoreappendTurn (src/store/turns/memory-store.ts:200) has no uniqueness check; it generates a fresh id and pushes to the conv index, so it silently stores a second row for the same coordinate.

Notably, InMemoryTurnStore.appendRecall does dedup by (turn_id, checkpoint_id) — so the inconsistency is internal to the same backend.

Impact

  • A host can't rely on consistent behavior from the contract interface.
  • Tests using the in-memory backend (the fast path) won't catch a duplicate-coordinate bug that throws in production on SQLite.
  • The SQLite throw is an unhandled ERR_SQLITE_ERROR — a leaky abstraction at the extension seam (extensions/mega-turn-store.ts:recordTurnWrite). The handler wraps it in try/catch (non-fatal to the agent loop), but the turn is silently lost.

Reproduction (verified against v0.11.0)

// SqliteTurnStore: throws on the 2nd append
const s = new SqliteTurnStore({ stateDir, inMemory: true });
const cid = s.ensureConversationId("sess");
s.appendTurn({ conversationId: cid, sessionId: "sess", turnIndex: 0, role: "user", endedAt: 1 });
s.appendTurn({ conversationId: cid, sessionId: "sess", turnIndex: 0, role: "user", endedAt: 1 });
// → throws ERR_SQLITE_ERROR: UNIQUE constraint failed

// InMemoryTurnStore: silently dups
const m = new InMemoryTurnStore({ stateDir, inMemory: true });
const cid2 = m.ensureConversationId("sess");
m.appendTurn({ conversationId: cid2, sessionId: "sess", turnIndex: 0, role: "user", endedAt: 1 });
m.appendTurn({ conversationId: cid2, sessionId: "sess", turnIndex: 0, role: "user", endedAt: 1 });
// → no throw; query() now returns 2 rows for turnIndex 0

Suggested fix

Pick one contract and enforce it in both backends + the shared compliance suite (contract-compliance.test.ts):

  • (preferred) Both throw a typed error (e.g. a DuplicateTurnError) rather than the raw ERR_SQLITE_ERROR, OR
  • Both upsert / return the existing TurnId for a duplicate coordinate (idempotent append).

Either way, the two backends must agree, and the compliance suite should assert it so the divergence can't silently regress.

Environment

  • Verified on tag v0.11.0 (commit 293d7d1).
  • Found via an adversarial burn-test pass over the S49 turn platform.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions