diff --git a/CHANGELOG-v1.1.0.md b/CHANGELOG-v1.1.0.md index b5f8ff31..c1b6d487 100644 --- a/CHANGELOG-v1.1.0.md +++ b/CHANGELOG-v1.1.0.md @@ -225,3 +225,13 @@ extractMaxChars?: number; // 送入 LLM 的最大字符数(默认 8000) | 去重逻辑 | 仅在 `smartExtraction: true` 时生效 | | 已有数据 | 旧记忆正常读取,新记忆额外携带 L0/L1/L2 元数据 | | 配置 | 全部新增配置项均有默认值,零配置即可使用 | + +--- + +## 已知限制 (Known Limitations) + +### MR2: Legacy Combined Reflection Rows + +PR #522 修复的 `isOwnedByAgent` derived ownership 仅适用于有明确 `itemKind` 字段的 row。 + +Legacy rows(存储为 combined `reflection` type,无 `itemKind` 字段)仍会走 `owner === "main"` fallback,会跨 agent 泄漏。这是预期行为,文档记录之。 diff --git a/index.ts b/index.ts index 4baf40f9..7d073d7e 100644 --- a/index.ts +++ b/index.ts @@ -1615,10 +1615,14 @@ const pluginVersion = getPluginVersion(); // Plugin Definition // ============================================================================ -// WeakSet keyed by API instance — each distinct API object tracks its own initialized state. -// Using WeakSet instead of a module-level boolean avoids the "second register() call skips -// hook/tool registration for the new API instance" regression that rwmjhb identified. -const _registeredApis = new WeakSet(); +// Map keyed by API instance — each distinct API object tracks its own initialized state. +// Using Map instead of WeakSet allows tracking per-instance state AND enables clear() for reset. +const _registeredApis = new Map(); + +// Helper for tests to check registered APIs +export function _getRegisteredApisForTest(): Map { + return _registeredApis; +} const memoryLanceDBProPlugin = { id: "memory-lancedb-pro", @@ -1633,10 +1637,13 @@ const memoryLanceDBProPlugin = { api.logger.debug?.("memory-lancedb-pro: register() called again — skipping re-init (idempotent)"); return; } - _registeredApis.add(api); - // Parse and validate configuration - const config = parsePluginConfig(api.pluginConfig); + // Claim registration BEFORE init to close the re-entry race window (F2 fix) + _registeredApis.set(api, true); + + try { + // Parse and validate configuration + const config = parsePluginConfig(api.pluginConfig); const resolvedDbPath = api.resolvePath(config.dbPath || getDefaultDbPath()); @@ -3014,6 +3021,12 @@ const memoryLanceDBProPlugin = { ); } + // F2 fix: handle init failure - rollback the registration claim + } catch (err) { + _registeredApis.delete(api); + throw err; + } + // ======================================================================== // Integrated Memory Reflection (reflection) // ======================================================================== @@ -4054,10 +4067,8 @@ export function parsePluginConfig(value: unknown): PluginConfig { * @public */ export function resetRegistration() { - // Note: WeakSets cannot be cleared by design. In test scenarios where the - // same process reloads the module, a fresh module state means a new WeakSet. - // For hot-reload scenarios, the module is re-imported fresh. - // (WeakSet.clear() does not exist, so we do nothing here.) + // Clear the Map to allow re-registration after failure or hot-reload + _registeredApis.clear(); } export default memoryLanceDBProPlugin; diff --git a/src/reflection-store.ts b/src/reflection-store.ts index 38da5ce7..7dcc91c8 100644 --- a/src/reflection-store.ts +++ b/src/reflection-store.ts @@ -353,6 +353,9 @@ function buildDerivedCandidates( const lines = sanitizeInjectableReflectionLines(toStringArray(metadata.derived)); if (lines.length === 0) return []; + const owner = typeof metadata.agentId === "string" ? metadata.agentId.trim() : ""; + if (owner === "main") return []; + const defaults = { midpointDays: REFLECTION_DERIVE_LOGISTIC_MIDPOINT_DAYS, k: REFLECTION_DERIVE_LOGISTIC_K, @@ -428,6 +431,21 @@ function isReflectionMetadataType(type: unknown): boolean { function isOwnedByAgent(metadata: Record, agentId: string): boolean { const owner = typeof metadata.agentId === "string" ? metadata.agentId.trim() : ""; + + // itemKind 只存在於 memory-reflection-item 類型 + // legacy (memory-reflection) 和 mapped (memory-reflection-mapped) 都沒有 itemKind + // 因此 undefined !== "derived",會走原本的 main fallback(維持相容) + const itemKind = metadata.itemKind; + + // 如果是 derived 項目(memory-reflection-item):不做 main fallback, + // 且 derived 不允許空白 owner(空白 owner 的 derived 應完全不可見,防止洩漏) + // itemKind 必須是 string type,否則會錯誤進入 derived 分支(null/undefined/number 等應走 legacy fallback) + if (typeof itemKind === "string" && itemKind === "derived") { + if (!owner) return false; + return owner === agentId; + } + + // invariant / legacy / mapped:允許空白 owner 可見,維持原本的 main fallback if (!owner) return true; return owner === agentId || owner === "main"; } diff --git a/test/isOwnedByAgent.test.mjs b/test/isOwnedByAgent.test.mjs new file mode 100644 index 00000000..6e9a8e3b --- /dev/null +++ b/test/isOwnedByAgent.test.mjs @@ -0,0 +1,66 @@ +// isOwnedByAgent unit tests — Issue #448 fix verification +import { describe, it } from "node:test"; +import assert from "node:assert"; + +// 從 reflection-store.ts 直接拷貝 isOwnedByAgent 函數(隔離測試) +function isOwnedByAgent(metadata, agentId) { + const owner = typeof metadata.agentId === "string" ? metadata.agentId.trim() : ""; + + const itemKind = metadata.itemKind; + + // derived:不做 main fallback,空白 owner → 完全不可見 + if (itemKind === "derived") { + if (!owner) return false; + return owner === agentId; + } + + // invariant / legacy / mapped:維持原本的 main fallback + if (!owner) return true; + return owner === agentId || owner === "main"; +} + +describe("isOwnedByAgent — derived ownership fix (Issue #448)", () => { + // === Must Fix 3: 缺少 derived 分支測試 === + describe("itemKind === 'derived'", () => { + it("main's derived → main 可見", () => { + assert.strictEqual(isOwnedByAgent({ itemKind: "derived", agentId: "main" }, "main"), true); + }); + it("main's derived → sub-agent 不可見(核心 bug fix)", () => { + assert.strictEqual(isOwnedByAgent({ itemKind: "derived", agentId: "main" }, "sub-agent-A"), false); + }); + it("agent-x's derived → agent-x 可見", () => { + assert.strictEqual(isOwnedByAgent({ itemKind: "derived", agentId: "agent-x" }, "agent-x"), true); + }); + it("agent-x's derived → agent-y 不可見", () => { + assert.strictEqual(isOwnedByAgent({ itemKind: "derived", agentId: "agent-x" }, "agent-y"), false); + }); + it("derived + 空白 owner → 完全不可見(防呆)", () => { + assert.strictEqual(isOwnedByAgent({ itemKind: "derived", agentId: "" }, "main"), false); + assert.strictEqual(isOwnedByAgent({ itemKind: "derived", agentId: "" }, "sub-agent"), false); + }); + }); + + describe("itemKind === 'invariant'(維持 fallback)", () => { + it("main's invariant → sub-agent 可見", () => { + assert.strictEqual(isOwnedByAgent({ itemKind: "invariant", agentId: "main" }, "sub-agent-A"), true); + }); + it("agent-x's invariant → agent-x 可見", () => { + assert.strictEqual(isOwnedByAgent({ itemKind: "invariant", agentId: "agent-x" }, "agent-x"), true); + }); + it("agent-x's invariant → agent-y 不可見", () => { + assert.strictEqual(isOwnedByAgent({ itemKind: "invariant", agentId: "agent-x" }, "agent-y"), false); + }); + }); + + describe("legacy / mapped(無 itemKind,維持 fallback)", () => { + it("main legacy → sub-agent 可見", () => { + assert.strictEqual(isOwnedByAgent({ agentId: "main" }, "sub-agent-A"), true); + }); + it("agent-x legacy → agent-x 可見", () => { + assert.strictEqual(isOwnedByAgent({ agentId: "agent-x" }, "agent-x"), true); + }); + it("agent-x legacy → agent-y 不可見", () => { + assert.strictEqual(isOwnedByAgent({ agentId: "agent-x" }, "agent-y"), false); + }); + }); +});