mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-07-17 12:56:42 +02:00
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:
+14
-5
@@ -169,10 +169,18 @@ export async function clearPendingMemories(
|
|||||||
|
|
||||||
store.entries = store.entries.filter(entry => {
|
store.entries = store.entries.filter(entry => {
|
||||||
if (!keys.has(memoryKey(entry))) return true;
|
if (!keys.has(memoryKey(entry))) return true;
|
||||||
if (!options.ownerSessionID) return false;
|
|
||||||
if (entry.pendingOwnerSessionID === options.ownerSessionID) return false;
|
if (options.ownerSessionID) {
|
||||||
if (options.clearUnowned && !entry.pendingOwnerSessionID) return false;
|
if (entry.pendingOwnerSessionID === options.ownerSessionID) return false;
|
||||||
return true;
|
if (options.clearUnowned && !entry.pendingOwnerSessionID) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.clearUnowned) {
|
||||||
|
return Boolean(entry.pendingOwnerSessionID);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
});
|
});
|
||||||
return store;
|
return store;
|
||||||
});
|
});
|
||||||
@@ -182,7 +190,7 @@ export async function recordPromotionRejections(
|
|||||||
root: string,
|
root: string,
|
||||||
keys: Set<string>,
|
keys: Set<string>,
|
||||||
reason: string,
|
reason: string,
|
||||||
options: { ownerSessionID?: string } = {},
|
options: { ownerSessionID?: string; includeUnownedOnly?: boolean } = {},
|
||||||
): Promise<Set<string>> {
|
): Promise<Set<string>> {
|
||||||
const exhausted = new Set<string>();
|
const exhausted = new Set<string>();
|
||||||
if (keys.size === 0) return exhausted;
|
if (keys.size === 0) return exhausted;
|
||||||
@@ -195,6 +203,7 @@ export async function recordPromotionRejections(
|
|||||||
const key = memoryKey(entry);
|
const key = memoryKey(entry);
|
||||||
if (!keys.has(key)) return entry;
|
if (!keys.has(key)) return entry;
|
||||||
if (options.ownerSessionID && entry.pendingOwnerSessionID !== options.ownerSessionID) 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 promotionAttempts = (entry.promotionAttempts ?? 0) + 1;
|
||||||
const max = entry.source === "manual"
|
const max = entry.source === "manual"
|
||||||
|
|||||||
+4
-1
@@ -347,7 +347,10 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
|||||||
directory,
|
directory,
|
||||||
accounting.retryableRejectedKeys,
|
accounting.retryableRejectedKeys,
|
||||||
"rejected_capacity",
|
"rejected_capacity",
|
||||||
{ ownerSessionID: sessionID },
|
{
|
||||||
|
ownerSessionID: sessionID,
|
||||||
|
includeUnownedOnly: !sessionID,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const sessionRemovalKeys = new Set([
|
const sessionRemovalKeys = new Set([
|
||||||
|
|||||||
@@ -283,6 +283,35 @@ describe("pending journal retention", () => {
|
|||||||
assert.deepEqual(loaded.entries.map(entry => entry.pendingOwnerSessionID), ["session-b"]);
|
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 () => {
|
it("retains same-key pending entries owned by different sessions", async () => {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
await appendPendingMemories(testDir, [
|
await appendPendingMemories(testDir, [
|
||||||
@@ -369,6 +398,45 @@ describe("pending journal retention", () => {
|
|||||||
assert.deepEqual(loaded.entries.map(entry => entry.pendingOwnerSessionID), ["session-b"]);
|
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 () => {
|
it("drops invalid timestamp entries for every source as corruption safety", async () => {
|
||||||
await savePendingJournal(testDir, {
|
await savePendingJournal(testDir, {
|
||||||
version: 1,
|
version: 1,
|
||||||
|
|||||||
Reference in New Issue
Block a user