Skip to content

Commit a762e86

Browse files
committed
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
1 parent 222bae2 commit a762e86

3 files changed

Lines changed: 86 additions & 6 deletions

File tree

src/pending-journal.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -169,10 +169,18 @@ export async function clearPendingMemories(
169169

170170
store.entries = store.entries.filter(entry => {
171171
if (!keys.has(memoryKey(entry))) return true;
172-
if (!options.ownerSessionID) return false;
173-
if (entry.pendingOwnerSessionID === options.ownerSessionID) return false;
174-
if (options.clearUnowned && !entry.pendingOwnerSessionID) return false;
175-
return true;
172+
173+
if (options.ownerSessionID) {
174+
if (entry.pendingOwnerSessionID === options.ownerSessionID) return false;
175+
if (options.clearUnowned && !entry.pendingOwnerSessionID) return false;
176+
return true;
177+
}
178+
179+
if (options.clearUnowned) {
180+
return Boolean(entry.pendingOwnerSessionID);
181+
}
182+
183+
return false;
176184
});
177185
return store;
178186
});
@@ -182,7 +190,7 @@ export async function recordPromotionRejections(
182190
root: string,
183191
keys: Set<string>,
184192
reason: string,
185-
options: { ownerSessionID?: string } = {},
193+
options: { ownerSessionID?: string; includeUnownedOnly?: boolean } = {},
186194
): Promise<Set<string>> {
187195
const exhausted = new Set<string>();
188196
if (keys.size === 0) return exhausted;
@@ -195,6 +203,7 @@ export async function recordPromotionRejections(
195203
const key = memoryKey(entry);
196204
if (!keys.has(key)) return entry;
197205
if (options.ownerSessionID && entry.pendingOwnerSessionID !== options.ownerSessionID) return entry;
206+
if (!options.ownerSessionID && options.includeUnownedOnly && entry.pendingOwnerSessionID) return entry;
198207

199208
const promotionAttempts = (entry.promotionAttempts ?? 0) + 1;
200209
const max = entry.source === "manual"

src/plugin.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,10 @@ export const MemoryV2Plugin: Plugin = async (input) => {
347347
directory,
348348
accounting.retryableRejectedKeys,
349349
"rejected_capacity",
350-
{ ownerSessionID: sessionID },
350+
{
351+
ownerSessionID: sessionID,
352+
includeUnownedOnly: !sessionID,
353+
},
351354
);
352355

353356
const sessionRemovalKeys = new Set([

tests/pending-journal.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,35 @@ describe("pending journal retention", () => {
283283
assert.deepEqual(loaded.entries.map(entry => entry.pendingOwnerSessionID), ["session-b"]);
284284
});
285285

286+
it("global unowned clear keeps owned entries with the same key", async () => {
287+
const now = new Date().toISOString();
288+
const unowned: LongTermMemoryEntry = {
289+
id: "clear-unowned",
290+
type: "feedback",
291+
text: "Prefer scoped cleanup.",
292+
source: "explicit",
293+
confidence: 1,
294+
status: "active",
295+
createdAt: now,
296+
updatedAt: now,
297+
};
298+
const owned: LongTermMemoryEntry = {
299+
...unowned,
300+
id: "clear-owned",
301+
pendingOwnerSessionID: "session-owned",
302+
};
303+
304+
await appendPendingMemories(testDir, [unowned, owned]);
305+
306+
await clearPendingMemories(testDir, new Set([memoryKey(unowned)]), {
307+
clearUnowned: true,
308+
});
309+
310+
const loaded = await loadPendingJournal(testDir);
311+
assert.deepEqual(loaded.entries.map(entry => entry.id), ["clear-owned"]);
312+
assert.equal(loaded.entries[0].pendingOwnerSessionID, "session-owned");
313+
});
314+
286315
it("retains same-key pending entries owned by different sessions", async () => {
287316
const now = new Date().toISOString();
288317
await appendPendingMemories(testDir, [
@@ -369,6 +398,45 @@ describe("pending journal retention", () => {
369398
assert.deepEqual(loaded.entries.map(entry => entry.pendingOwnerSessionID), ["session-b"]);
370399
});
371400

401+
it("global unowned rejection exhausts only unowned entries with the same key", async () => {
402+
const now = new Date().toISOString();
403+
const unowned: LongTermMemoryEntry = {
404+
id: "reject-unowned",
405+
type: "reference",
406+
text: "Capacity rejected unowned reference.",
407+
source: "explicit",
408+
confidence: 0.1,
409+
status: "active",
410+
createdAt: now,
411+
updatedAt: now,
412+
promotionAttempts: PROMOTION_RETRY_LIMITS.maxExplicitAttempts - 1,
413+
};
414+
const owned: LongTermMemoryEntry = {
415+
...unowned,
416+
id: "reject-owned",
417+
pendingOwnerSessionID: "session-owned",
418+
promotionAttempts: undefined,
419+
};
420+
await appendPendingMemories(testDir, [unowned, owned]);
421+
422+
const exhausted = await recordPromotionRejections(
423+
testDir,
424+
new Set([memoryKey(unowned)]),
425+
"rejected_capacity",
426+
{ includeUnownedOnly: true },
427+
);
428+
429+
assert.deepEqual([...exhausted], [memoryKey(unowned)]);
430+
const loaded = await loadPendingJournal(testDir);
431+
assert.deepEqual(loaded.entries.map(entry => entry.id), ["reject-owned"]);
432+
assert.equal(
433+
loaded.entries[0].promotionAttempts,
434+
undefined,
435+
"owned same-key entry must not be mutated by global unowned rejection",
436+
);
437+
assert.equal(loaded.entries[0].lastPromotionFailureReason, undefined);
438+
});
439+
372440
it("drops invalid timestamp entries for every source as corruption safety", async () => {
373441
await savePendingJournal(testDir, {
374442
version: 1,

0 commit comments

Comments
 (0)