diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index fe2301651f..661e9795eb 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -150,31 +150,44 @@ export type LocationError = LayerNode.Error export function buildLocationServiceMap( replacements: LayerNode.Replacements = [], ): Layer.Layer { + // Structural Equal is own-key-set sensitive, so `{ directory }` (schema-decoded + // payloads omit optional keys) and `{ directory, workspaceID: undefined }` are + // different RcMap keys. The RcMap caches by the raw key before the build + // callback runs, so canonicalize at the map boundary to the key-present shape. + const canonical = (ref: Location.Ref) => Location.Ref.make({ directory: ref.directory, workspaceID: ref.workspaceID }) return Layer.effect( LocationServiceMap.Service, - LayerMap.make( - (ref: Location.Ref) => { - const startedAt = performance.now() - const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]]) - // Apply replacements during hoist, not afterward: replacements can - // introduce new tagged dependencies (Location.boundNode depends on - // Project), and the hoist walk is the only pass that can still slice - // those back out. - const location = LayerNode.hoist(locationServices, Node.tags.values.global, allReplacements) + Effect.map( + LayerMap.make( + (ref: Location.Ref) => { + const startedAt = performance.now() + const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]]) + // Apply replacements during hoist, not afterward: replacements can + // introduce new tagged dependencies (Location.boundNode depends on + // Project), and the hoist walk is the only pass that can still slice + // those back out. + const location = LayerNode.hoist(locationServices, Node.tags.values.global, allReplacements) - return LayerNode.compile(location.node).pipe( - Layer.fresh, - Layer.tap(() => - Effect.logInfo("location services booted", { - directory: ref.directory, - workspaceID: ref.workspaceID, - durationMs: Math.round(performance.now() - startedAt), - }), - ), - Layer.provide(LayerNode.compile(location.hoisted)), - ) - }, - { idleTimeToLive: "60 minutes" }, + return LayerNode.compile(location.node).pipe( + Layer.fresh, + Layer.tap(() => + Effect.logInfo("location services booted", { + directory: ref.directory, + workspaceID: ref.workspaceID, + durationMs: Math.round(performance.now() - startedAt), + }), + ), + Layer.provide(LayerNode.compile(location.hoisted)), + ) + }, + { idleTimeToLive: "60 minutes" }, + ), + (inner) => ({ + ...inner, + get: (ref: Location.Ref) => inner.get(canonical(ref)), + contextEffect: (ref: Location.Ref) => inner.contextEffect(canonical(ref)), + invalidate: (ref: Location.Ref) => inner.invalidate(canonical(ref)), + }), ), ) } diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index e9eb2be4d9..f2b8d004a1 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -3,7 +3,7 @@ import path from "path" import { describe, expect } from "bun:test" import { Config } from "@opencode-ai/schema/config" import { Plugin } from "@opencode-ai/schema/plugin" -import { Context, DateTime, Effect, Equal, Hash, Schema, Stream } from "effect" +import { Context, DateTime, Effect, Equal, Hash, RcMap, Schema, Stream } from "effect" import { define } from "@opencode-ai/plugin/v2/effect" import { AgentV2 } from "@opencode-ai/core/agent" import { Catalog } from "@opencode-ai/core/catalog" @@ -208,6 +208,36 @@ describe("LocationServiceMap", () => { ), ) + it.live("normalizes ref key shapes to one cached location graph", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.scoped( + Effect.gen(function* () { + const locations = yield* LocationServiceMap.Service + const directory = AbsolutePath.make(dir.path) + const absent = Location.Ref.make({ directory }) + const present = Location.Ref.make({ directory, workspaceID: undefined }) + // The two shapes are not structurally Equal: own-key sets differ. + expect(Object.keys(absent)).toEqual(["directory"]) + expect(Object.keys(present)).toEqual(["directory", "workspaceID"]) + expect(Equal.equals(absent, present)).toBe(false) + + const first = yield* locations.contextEffect(absent) + expect(yield* locations.contextEffect(present)).toBe(first) + expect(Array.from(yield* RcMap.keys(locations.rcMap))).toHaveLength(1) + + // Invalidating with the shape opposite to the one that booted must evict. + yield* locations.invalidate(present) + expect(Array.from(yield* RcMap.keys(locations.rcMap))).toHaveLength(0) + }), + ), + ), + ), + ) + it.live("isolates catalog state by location", () => Effect.acquireRelease( Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),