diff --git a/src/v2/runtime.test.ts b/src/v2/runtime.test.ts index bb05a5a..489bff5 100644 --- a/src/v2/runtime.test.ts +++ b/src/v2/runtime.test.ts @@ -9,6 +9,7 @@ import { buildTranscriptTurns, buildV2RecallDirective, mergeTurns, + setupV2, SUPERMEMORY_RECALL_TOOL_NAME, SUPERMEMORY_TOOL_NAME, V2Runtime, @@ -464,3 +465,97 @@ describe("OpenCode 2 runtime", () => { h.runtime.cleanup(); }); }); + +function ownershipContext(directory: string): V2Context { + const registration = { dispose: async () => undefined }; + return { + location: { directory }, + tool: { + transform: async () => registration, + hook: async () => registration, + reload: async () => undefined, + list: async () => [], + }, + session: { + hook: async () => registration, + get: async ({ sessionID }: { sessionID: string }) => ({ + id: sessionID, + location: { directory }, + }), + context: async () => [], + }, + permission: { + hook: async () => registration, + }, + rpc: { + register: async () => ({ + ...registration, + events: { emit: async () => undefined }, + }), + }, + event: { + subscribe: () => ({ + [Symbol.asyncIterator]: () => ({ + next: () => new Promise>(() => undefined), + }), + }), + }, + } as unknown as V2Context; +} + +function ownershipDeps(): Partial { + return { + configured: true, + config: { + recallMode: "direct", + injectProfile: false, + captureEveryNTurns: 1, + compactionEnabled: false, + keywordPatterns: [], + maxProjectMemories: 10, + } as V2RuntimeDependencies["config"], + memoryClient: { + searchMemories: async () => ({ success: true, results: [] }), + addMemory: async () => ({ success: true, id: "mem_1" }), + } as unknown as V2RuntimeDependencies["memoryClient"], + executeTool: executeSupermemoryTool, + resolveTags: () => tags, + logger: () => undefined, + checkUpdate: async () => ({ + currentVersion: "2.0.15", + latestVersion: "2.0.15", + updateCommand: "bunx opencode-supermemory@latest install", + }), + }; +} + +function ownerFor(directory: string): { generation: number } | undefined { + return ( + globalThis as unknown as Record< + symbol, + { generation: number } | undefined + > + )[Symbol.for(`opencode-supermemory.v2.owner:${directory}`)]; +} + +describe("OpenCode 2 runtime ownership", () => { + test("each project directory owns its runtime; hot reload replaces only the same directory", async () => { + const cleanupA1 = await setupV2(ownershipContext("/repo-a"), ownershipDeps()); + const cleanupB1 = await setupV2(ownershipContext("/repo-b"), ownershipDeps()); + + expect(ownerFor("/repo-a")?.generation).toBe(1); + expect(ownerFor("/repo-b")?.generation).toBe(1); + + // A same-directory hot reload replaces the previous generation... + const cleanupA2 = await setupV2(ownershipContext("/repo-a"), ownershipDeps()); + expect(ownerFor("/repo-a")?.generation).toBe(2); + // ...but a different directory never disposes another directory's runtime. + expect(ownerFor("/repo-b")?.generation).toBe(1); + + cleanupA2(); + cleanupA1(); + cleanupB1(); + expect(ownerFor("/repo-a")).toBeUndefined(); + expect(ownerFor("/repo-b")).toBeUndefined(); + }); +}); diff --git a/src/v2/runtime.ts b/src/v2/runtime.ts index 2fee56b..9b51f25 100644 --- a/src/v2/runtime.ts +++ b/src/v2/runtime.ts @@ -1255,7 +1255,9 @@ export class V2Runtime { } } -const OWNER_KEY = Symbol.for("opencode-supermemory.v2.owner"); +function ownerKeyFor(directory: string | undefined): symbol { + return Symbol.for(`opencode-supermemory.v2.owner:${directory ?? "global"}`); +} interface GlobalOwner { generation: number; @@ -1267,14 +1269,18 @@ function ownerRegistry(): Record { } /** - * Starts the OpenCode 2 runtime. Only one instance is active per process, so a - * hot-reloaded plugin replaces (and cleans up) the previous generation. + * Starts the OpenCode 2 runtime. The OpenCode server instantiates the plugin + * once per project directory in the same process, so ownership is scoped per + * directory: a hot-reloaded plugin replaces (and cleans up) the previous + * generation of the same directory only, while other directories keep their + * runtime active. */ export async function setupV2( ctx: V2Context, options?: Partial, ): Promise<() => void> { const registry = ownerRegistry(); + const OWNER_KEY = ownerKeyFor(ctx.location?.directory); const previous = registry[OWNER_KEY]; previous?.cleanup();