Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 40 additions & 47 deletions packages/junior-memory/src/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import {
eq,
gt,
inArray,
isNotNull,
isNull,
or,
sql,
Expand Down Expand Up @@ -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<IdempotencyMatch | undefined> {
const activeRows = await args.db
const histories = await args.db
.select()
.from(juniorMemoryMemories)
.where(
Expand All @@ -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),
Expand All @@ -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<string>();
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Expiry breaks replacement chain walk

Medium Severity

The replacement walk applies the unexpired predicate to every hop, not only the final active memory. Superseded rows can keep a past expiresAtMs, so a healthy tip is never reached. Retries then hit the new fail-closed error instead of returning that active memory.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d811ea5. Configure here.

}
}
if (histories.length > 0) {
throw new Error("Memory idempotency conflict did not resolve.");
}
return undefined;
}

Expand Down
46 changes: 43 additions & 3 deletions packages/junior-memory/tests/storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number[]> = {
[oldContent]: unitEmbedding(0),
[newContent]: cosineEmbedding(0.98),
[finalContent]: cosineEmbedding(0.97),
};
const unrelatedContents = Array.from(
{ length: 12 },
Expand All @@ -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],
};
},
},
Expand Down Expand Up @@ -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();
}
Expand Down