From 4c48b8b9dfa7bab8bd0bdd4bf76ad5b82dfd7de9 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sun, 23 Aug 2026 10:19:24 -0700 Subject: [PATCH] fix(memory): preserve retries across replacements --- packages/junior-memory/src/create.ts | 87 +++++++++----------- packages/junior-memory/tests/storage.test.ts | 46 ++++++++++- 2 files changed, 83 insertions(+), 50 deletions(-) diff --git a/packages/junior-memory/src/create.ts b/packages/junior-memory/src/create.ts index c3475501fd..856f8aa5a2 100644 --- a/packages/junior-memory/src/create.ts +++ b/packages/junior-memory/src/create.ts @@ -11,7 +11,6 @@ import { eq, gt, inArray, - isNotNull, isNull, or, sql, @@ -237,13 +236,14 @@ type IdempotencyMatch = { outcome: "created" | "duplicate"; }; +/** Resolve same-scope replacement links or fail on broken retry history. */ async function findByIdempotencyKey(args: { db: MemoryDb; idempotencyKey: string; nowMs: number; scope: ResolvedMemoryScope; }): Promise { - const activeRows = await args.db + const histories = await args.db .select() .from(juniorMemoryMemories) .where( @@ -252,30 +252,6 @@ async function findByIdempotencyKey(args: { eq(juniorMemoryMemories.scopeKey, args.scope.scopeKey), eq(juniorMemoryMemories.idempotencyKey, args.idempotencyKey), isNull(juniorMemoryMemories.archivedAtMs), - isNull(juniorMemoryMemories.supersededAtMs), - isNull(juniorMemoryMemories.supersededById), - or( - isNull(juniorMemoryMemories.expiresAtMs), - gt(juniorMemoryMemories.expiresAtMs, args.nowMs), - ), - ), - ) - .limit(1); - if (activeRows[0]) { - return { memory: parseMemoryRow(activeRows[0]), outcome: "created" }; - } - - const aliases = await args.db - .select({ supersededById: juniorMemoryMemories.supersededById }) - .from(juniorMemoryMemories) - .where( - and( - eq(juniorMemoryMemories.scope, args.scope.scope), - eq(juniorMemoryMemories.scopeKey, args.scope.scopeKey), - eq(juniorMemoryMemories.idempotencyKey, args.idempotencyKey), - isNull(juniorMemoryMemories.archivedAtMs), - isNotNull(juniorMemoryMemories.supersededAtMs), - isNotNull(juniorMemoryMemories.supersededById), or( isNull(juniorMemoryMemories.expiresAtMs), gt(juniorMemoryMemories.expiresAtMs, args.nowMs), @@ -286,30 +262,47 @@ async function findByIdempotencyKey(args: { desc(juniorMemoryMemories.createdAtMs), asc(juniorMemoryMemories.id), ); - for (const alias of aliases) { - if (!alias.supersededById) continue; - const rows = await args.db - .select() - .from(juniorMemoryMemories) - .where( - and( - eq(juniorMemoryMemories.id, alias.supersededById), - eq(juniorMemoryMemories.scope, args.scope.scope), - eq(juniorMemoryMemories.scopeKey, args.scope.scopeKey), - isNull(juniorMemoryMemories.archivedAtMs), - isNull(juniorMemoryMemories.supersededAtMs), - isNull(juniorMemoryMemories.supersededById), - or( - isNull(juniorMemoryMemories.expiresAtMs), - gt(juniorMemoryMemories.expiresAtMs, args.nowMs), + const active = histories.find( + (row) => row.supersededAtMs === null && row.supersededById === null, + ); + if (active) { + return { memory: parseMemoryRow(active), outcome: "created" }; + } + + for (const history of histories) { + if (history.supersededAtMs === null || !history.supersededById) continue; + const visited = new Set(); + let id: string | undefined = history.supersededById; + while (id && !visited.has(id)) { + const targetId: string = id; + visited.add(targetId); + const [row]: (typeof juniorMemoryMemories.$inferSelect)[] = await args.db + .select() + .from(juniorMemoryMemories) + .where( + and( + eq(juniorMemoryMemories.id, targetId), + eq(juniorMemoryMemories.scope, args.scope.scope), + eq(juniorMemoryMemories.scopeKey, args.scope.scopeKey), + isNull(juniorMemoryMemories.archivedAtMs), + or( + isNull(juniorMemoryMemories.expiresAtMs), + gt(juniorMemoryMemories.expiresAtMs, args.nowMs), + ), ), - ), - ) - .limit(1); - if (rows[0]) { - return { memory: parseMemoryRow(rows[0]), outcome: "duplicate" }; + ) + .limit(1); + if (!row) break; + if (row.supersededAtMs === null && row.supersededById === null) { + return { memory: parseMemoryRow(row), outcome: "duplicate" }; + } + if (row.supersededAtMs === null || row.supersededById === null) break; + id = row.supersededById; } } + if (histories.length > 0) { + throw new Error("Memory idempotency conflict did not resolve."); + } return undefined; } diff --git a/packages/junior-memory/tests/storage.test.ts b/packages/junior-memory/tests/storage.test.ts index 59d4f5a9f5..1f44810478 100644 --- a/packages/junior-memory/tests/storage.test.ts +++ b/packages/junior-memory/tests/storage.test.ts @@ -4600,16 +4600,18 @@ INSERT INTO junior_memory_memories ( } }, 15_000); - it("supersedes old actor preferences when adjudication is confident", async () => { + it("supersedes old User preferences when adjudication is confident", async () => { const fixture = await createMemoryFixture(); try { let nowMs = TEST_NOW_MS; const oldContent = "Prefers Python for automation scripts."; const newContent = "Prefers TypeScript for automation scripts."; + const finalContent = "Prefers Rust for automation scripts."; const vectors: Record = { [oldContent]: unitEmbedding(0), [newContent]: cosineEmbedding(0.98), + [finalContent]: cosineEmbedding(0.97), }; const unrelatedContents = Array.from( { length: 12 }, @@ -4626,12 +4628,21 @@ INSERT INTO junior_memory_memories ( supersessionDecider: { adjudicateSupersession(input) { preferenceAdjudicationCalls.push(input); - if (input.candidate.content !== newContent) { + let replacedContent: string | undefined; + if (input.candidate.content === newContent) { + replacedContent = oldContent; + } else if (input.candidate.content === finalContent) { + replacedContent = newContent; + } + const replaced = input.existingMemories.find( + (memory) => memory.content === replacedContent, + ); + if (!replaced) { return { decision: "distinct" }; } return { decision: "supersedes_old", - supersededIds: [input.existingMemories[0].id], + supersededIds: [replaced.id], }; }, }, @@ -4716,6 +4727,35 @@ INSERT INTO junior_memory_memories ( created: false, memory: { id: newMemory.memory.id }, }); + + nowMs = TEST_NOW_MS + 21; + const finalMemory = await createUserMemory(store, { + content: finalContent, + kind: "preference", + idempotencyKey: "memory-test:supersession-final", + }); + nowMs = TEST_NOW_MS + 22; + await expect( + createUserMemory(store, { + content: oldContent, + kind: "preference", + idempotencyKey: "memory-test:supersession-old", + }), + ).resolves.toMatchObject({ + created: false, + memory: { id: finalMemory.memory.id }, + }); + await expect( + memoryDb(fixture) + .select() + .from(memorySqlSchema.juniorMemoryMemories) + .where(eq(memorySqlSchema.juniorMemoryMemories.content, oldContent)), + ).resolves.toEqual([ + expect.objectContaining({ + id: oldMemory.memory.id, + supersededById: newMemory.memory.id, + }), + ]); } finally { await fixture.close(); }