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.
InMemoryTurnStore — appendTurn (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.
Bug — cross-backend contract divergence (S49 TurnStore)
The two
TurnStorebackends disagree on what happens whenappendTurnis called with a(conversationId, turnIndex)coordinate that already exists.Behavior
SqliteTurnStore— theturnstable hasUNIQUE(conversation_id, turn_index)(src/store/turns/schema.ts:47) andappendTurndoes a rawINSERT(src/store/turns/sqlite-store.ts:242) with no pre-check orON CONFLICThandling. A duplicate coordinate throwsUNIQUE constraint failed: turns.conversation_id, turns.turn_index(ERR_SQLITE_ERROR), uncaught — it propagates to the host and leaks the SQL abstraction.InMemoryTurnStore—appendTurn(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.appendRecalldoes dedup by(turn_id, checkpoint_id)— so the inconsistency is internal to the same backend.Impact
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)
Suggested fix
Pick one contract and enforce it in both backends + the shared compliance suite (
contract-compliance.test.ts):DuplicateTurnError) rather than the rawERR_SQLITE_ERROR, OREither way, the two backends must agree, and the compliance suite should assert it so the divergence can't silently regress.
Environment
v0.11.0(commit293d7d1).