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
This commit is contained in:
Ralph Chang
2026-04-28 12:27:46 +08:00
parent 222bae28de
commit a762e863d1
3 changed files with 86 additions and 6 deletions
+14 -5
View File
@@ -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<string>,
reason: string,
options: { ownerSessionID?: string } = {},
options: { ownerSessionID?: string; includeUnownedOnly?: boolean } = {},
): Promise<Set<string>> {
const exhausted = new Set<string>();
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"
+4 -1
View File
@@ -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([
+68
View File
@@ -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,