Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 29 additions & 7 deletions packages/core/src/location-services.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Effect, Layer, LayerMap } from "effect"
import { Cause, Duration, Effect, Layer, LayerMap } from "effect"
import { AgentV2 } from "./agent"
import { AISDK } from "./aisdk"
import { Catalog } from "./catalog"
Expand Down Expand Up @@ -107,16 +107,30 @@ export type LocationError = LayerNode.Error<typeof locationServices>

export function buildLocationServiceMap(
replacements: LayerNode.Replacements = [],
options?: { bootFailureRetry?: Duration.Input },
): 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 })
const bootFailureRetry = Duration.fromInputUnsafe(options?.bootFailureRetry ?? "5 seconds")
return Layer.effect(
LocationServiceMap.Service,
Effect.map(
LayerMap.make(
Effect.gen(function* () {
const scope = yield* Effect.scope
// The RcMap caches a failed boot exactly like a successful one, for the
// full idleTimeToLive, and nothing in production invalidates it: one
// transient boot error would poison the location for an hour. Evict the
// failed entry after a short delay instead. The delay matters: a failed
// boot still publishes agent/command/catalog.updated, clients refetch on
// those, and an immediate eviction would let a persistently failing
// location rebuild in a hot loop. The lookup runs once per cached entry,
// so exactly one eviction is scheduled per failed boot. Eviction is best
// effort: invalidate no-ops while the entry is referenced, and the entry
// then falls back to the idleTimeToLive.
const evict: { current?: (ref: Location.Ref) => Effect.Effect<void> } = {}
const inner = yield* LayerMap.make(
(ref: Location.Ref) => {
const startedAt = performance.now()
const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]])
Expand All @@ -136,17 +150,25 @@ export function buildLocationServiceMap(
}),
),
Layer.provide(LayerNode.compile(location.hoisted)),
Layer.tapCause((cause) => {
if (Cause.hasInterruptsOnly(cause)) return Effect.void
return Effect.suspend(() => evict.current!(ref)).pipe(
Effect.delay(bootFailureRetry),
Effect.forkIn(scope),
)
}),
)
},
{ idleTimeToLive: "60 minutes" },
),
(inner) => ({
)
evict.current = (ref) => inner.invalidate(ref)
return {
...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)),
}),
),
}
}),
)
}

Expand Down
56 changes: 54 additions & 2 deletions packages/core/test/location-layer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import { describe, expect } from "bun:test"
import { Config } from "@opencode-ai/schema/config"
import { Plugin } from "@opencode-ai/schema/plugin"
import { Money } from "@opencode-ai/schema/money"
import { DateTime, Deferred, Effect, Equal, Fiber, Hash, RcMap, Schema, Stream } from "effect"
import { DateTime, Deferred, Effect, Equal, Exit, Fiber, Hash, Layer, RcMap, Schema, Stream } from "effect"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { buildLocationServiceMap, LocationServiceMap } from "@opencode-ai/core/location-services"
import { Vcs } from "@opencode-ai/core/vcs"
import { Location } from "@opencode-ai/core/location"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
Expand All @@ -30,6 +32,31 @@ import { Reference } from "../src/reference"
import { ToolRegistry } from "../src/tool/registry"

const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, LocationServiceMap.node])))

const bootAttempts = { count: 0 }
const vcsFailOnceNode = makeLocationNode({
service: Vcs.Service,
layer: Layer.suspend(() => {
bootAttempts.count++
return bootAttempts.count === 1
? Layer.effect(Vcs.Service, Effect.die(new Error("transient boot failure")))
: Layer.succeed(
Vcs.Service,
Vcs.Service.of({ status: () => Effect.succeed([]), diff: () => Effect.succeed([]) }),
)
}),
deps: [],
})
const retryMapNode = makeGlobalNode({
service: LocationServiceMap.Service,
layer: buildLocationServiceMap([[Vcs.node, vcsFailOnceNode]], { bootFailureRetry: "250 millis" }),
deps: [],
})
const itRetry = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, LocationServiceMap.node]), [
[LocationServiceMap.node, retryMapNode],
]),
)
const itWithSdk = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SdkPlugins.node, LocationServiceMap.node])),
)
Expand Down Expand Up @@ -749,4 +776,29 @@ describe("LocationServiceMap", () => {
),
),
)

itRetry.live("retries a failed location boot after the retry delay", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const ref = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
const boot = locations.contextEffect(ref).pipe(Effect.asVoid, Effect.scoped, Effect.exit)

expect(Exit.isFailure(yield* boot)).toBe(true)
expect(bootAttempts.count).toBe(1)
// Within the retry window the cached failure replays without a rebuild.
expect(Exit.isFailure(yield* boot)).toBe(true)
expect(bootAttempts.count).toBe(1)

yield* Effect.sleep("1 second")
expect(Exit.isSuccess(yield* boot)).toBe(true)
expect(bootAttempts.count).toBe(2)
}),
),
),
)
})
Loading