From a762e863d15e2ebfe9c78d77241245c0bedd94c3 Mon Sep 17 00:00:00 2001 From: Ralph Chang Date: Tue, 28 Apr 2026 12:27:46 +0800 Subject: [PATCH] fix: owner scope in global unowned promotion Problem: clearPendingMemories() and recordPromotionRejections() would incorrectly clear or mutate owned entries during global unowned promotion. Fixes: 1. clearPendingMemories() now respects owner/unowned scope: - global clearUnowned only clears unowned same-key entries - owned same-key entries are preserved - explicit global clear-all-by-key fallback still works 2. recordPromotionRejections() now has includeUnownedOnly option: - global unowned rejection only increments/exhausts unowned entries - owned same-key entries are preserved 3. Added regression tests: - global unowned clear keeps owned same-key entries - global unowned rejection only exhausts unowned same-key entries Tests: 182 pass, 0 fail --- src/pending-journal.ts | 19 +++++++--- src/plugin.ts | 5 ++- tests/pending-journal.test.ts | 68 +++++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 6 deletions(-) diff --git a/src/pending-journal.ts b/src/pending-journal.ts index b89e2d8..07a5ad9 100644 --- a/src/pending-journal.ts +++ b/src/pending-journal.ts @@ -169,10 +169,18 @@ export async function clearPendingMemories( store.entries = store.entries.filter(entry => { if (!keys.has(memoryKey(entry))) return true; - if (!options.ownerSessionID) return false; - if (entry.pendingOwnerSessionID === options.ownerSessionID) return false; - if (options.clearUnowned && !entry.pendingOwnerSessionID) return false; - return true; + + if (options.ownerSessionID) { + if (entry.pendingOwnerSessionID === options.ownerSessionID) return false; + if (options.clearUnowned && !entry.pendingOwnerSessionID) return false; + return true; + } + + if (options.clearUnowned) { + return Boolean(entry.pendingOwnerSessionID); + } + + return false; }); return store; }); @@ -182,7 +190,7 @@ export async function recordPromotionRejections( root: string, keys: Set, reason: string, - options: { ownerSessionID?: string } = {}, + options: { ownerSessionID?: string; includeUnownedOnly?: boolean } = {}, ): Promise> { const exhausted = new Set(); if (keys.size === 0) return exhausted; @@ -195,6 +203,7 @@ export async function recordPromotionRejections( const key = memoryKey(entry); if (!keys.has(key)) return entry; if (options.ownerSessionID && entry.pendingOwnerSessionID !== options.ownerSessionID) return entry; + if (!options.ownerSessionID && options.includeUnownedOnly && entry.pendingOwnerSessionID) return entry; const promotionAttempts = (entry.promotionAttempts ?? 0) + 1; const max = entry.source === "manual" diff --git a/src/plugin.ts b/src/plugin.ts index 20aa48c..ebe86e9 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -347,7 +347,10 @@ export const MemoryV2Plugin: Plugin = async (input) => { directory, accounting.retryableRejectedKeys, "rejected_capacity", - { ownerSessionID: sessionID }, + { + ownerSessionID: sessionID, + includeUnownedOnly: !sessionID, + }, ); const sessionRemovalKeys = new Set([ diff --git a/tests/pending-journal.test.ts b/tests/pending-journal.test.ts index 782717e..0ec8b1c 100644 --- a/tests/pending-journal.test.ts +++ b/tests/pending-journal.test.ts @@ -283,6 +283,35 @@ describe("pending journal retention", () => { assert.deepEqual(loaded.entries.map(entry => entry.pendingOwnerSessionID), ["session-b"]); }); + it("global unowned clear keeps owned entries with the same key", async () => { + const now = new Date().toISOString(); + const unowned: LongTermMemoryEntry = { + id: "clear-unowned", + type: "feedback", + text: "Prefer scoped cleanup.", + source: "explicit", + confidence: 1, + status: "active", + createdAt: now, + updatedAt: now, + }; + const owned: LongTermMemoryEntry = { + ...unowned, + id: "clear-owned", + pendingOwnerSessionID: "session-owned", + }; + + await appendPendingMemories(testDir, [unowned, owned]); + + await clearPendingMemories(testDir, new Set([memoryKey(unowned)]), { + clearUnowned: true, + }); + + const loaded = await loadPendingJournal(testDir); + assert.deepEqual(loaded.entries.map(entry => entry.id), ["clear-owned"]); + assert.equal(loaded.entries[0].pendingOwnerSessionID, "session-owned"); + }); + it("retains same-key pending entries owned by different sessions", async () => { const now = new Date().toISOString(); await appendPendingMemories(testDir, [ @@ -369,6 +398,45 @@ describe("pending journal retention", () => { assert.deepEqual(loaded.entries.map(entry => entry.pendingOwnerSessionID), ["session-b"]); }); + it("global unowned rejection exhausts only unowned entries with the same key", async () => { + const now = new Date().toISOString(); + const unowned: LongTermMemoryEntry = { + id: "reject-unowned", + type: "reference", + text: "Capacity rejected unowned reference.", + source: "explicit", + confidence: 0.1, + status: "active", + createdAt: now, + updatedAt: now, + promotionAttempts: PROMOTION_RETRY_LIMITS.maxExplicitAttempts - 1, + }; + const owned: LongTermMemoryEntry = { + ...unowned, + id: "reject-owned", + pendingOwnerSessionID: "session-owned", + promotionAttempts: undefined, + }; + await appendPendingMemories(testDir, [unowned, owned]); + + const exhausted = await recordPromotionRejections( + testDir, + new Set([memoryKey(unowned)]), + "rejected_capacity", + { includeUnownedOnly: true }, + ); + + assert.deepEqual([...exhausted], [memoryKey(unowned)]); + const loaded = await loadPendingJournal(testDir); + assert.deepEqual(loaded.entries.map(entry => entry.id), ["reject-owned"]); + assert.equal( + loaded.entries[0].promotionAttempts, + undefined, + "owned same-key entry must not be mutated by global unowned rejection", + ); + assert.equal(loaded.entries[0].lastPromotionFailureReason, undefined); + }); + it("drops invalid timestamp entries for every source as corruption safety", async () => { await savePendingJournal(testDir, { version: 1,