1
0
Эх сурвалжийг харах

fix(core): normalize Location.Ref keys in LocationServiceMap (#35757)

Kit Langton 1 сар өмнө
parent
commit
b2ce195bda

+ 35 - 22
packages/core/src/location-services.ts

@@ -150,31 +150,44 @@ export type LocationError = LayerNode.Error<typeof locationServices>
 export function buildLocationServiceMap(
   replacements: LayerNode.Replacements = [],
 ): Layer.Layer<LocationServiceMap.Service> {
+  // 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)),
+      }),
     ),
   )
 }

+ 31 - 1
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()])),