From 6f8b9421479fc83617c633f333fa0d328076cb4f Mon Sep 17 00:00:00 2001 From: dondetir Date: Wed, 22 Jul 2026 18:43:39 -0500 Subject: [PATCH] fix(core): retry failed location boot instead of caching the failure for the idle TTL --- packages/core/src/location-services.ts | 36 ++++++++++++--- packages/core/test/location-layer.test.ts | 56 ++++++++++++++++++++++- 2 files changed, 83 insertions(+), 9 deletions(-) diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 7be9fefe3fbe..cb18a193d5d5 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -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" @@ -107,16 +107,30 @@ export type LocationError = LayerNode.Error export function buildLocationServiceMap( replacements: LayerNode.Replacements = [], + options?: { bootFailureRetry?: Duration.Input }, ): 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 }) + 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 } = {} + const inner = yield* LayerMap.make( (ref: Location.Ref) => { const startedAt = performance.now() const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]]) @@ -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)), - }), - ), + } + }), ) } diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 44a572f138d5..ad3961de89d0 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -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" @@ -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])), ) @@ -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) + }), + ), + ), + ) })