fix(core): retry recreated locations (#44005)

This commit is contained in:
Kit Langton
2026-08-21 18:50:38 -04:00
committed by GitHub
parent 9e9eac81e3
commit 2c8e2a2b28
5 changed files with 90 additions and 5 deletions
+2 -1
View File
@@ -1,4 +1,5 @@
import { Effect, Layer, LayerMap } from "effect"
import { existsSync } from "fs"
import path from "path"
import { Agent } from "./agent.js"
import { AISDK } from "./aisdk.js"
@@ -146,7 +147,7 @@ export function buildLocationServiceMap(
Layer.provide(LayerNode.compile(location.hoisted)),
)
},
{ idleTimeToLive: "60 minutes" },
{ idleTimeToLive: (ref) => (existsSync(ref.directory) ? "60 minutes" : 0) },
),
(inner) => ({
...inner,
+21 -3
View File
@@ -1,7 +1,7 @@
export * as Session from "./session.js"
export * from "./session/schema.js"
import { Effect, Layer, Schema, Context, RcMap, Stream, Scope } from "effect"
import { Cause, Effect, Layer, Schema, Context, RcMap, Stream, Scope } from "effect"
import { ListAnchor } from "@opencode-ai/schema/session"
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
import { Project } from "./project.js"
@@ -156,6 +156,11 @@ export class DestinationNotDirectoryError extends Schema.TaggedError<Destination
"Session.DestinationNotDirectoryError",
{ directory: AbsolutePath },
) {}
export class DestinationUnavailableError extends Schema.TaggedError<DestinationUnavailableError>()(
"Session.DestinationUnavailableError",
{ directory: AbsolutePath },
) {}
export const MessageNotFoundError = SessionRevert.MessageNotFoundError
export type MessageNotFoundError = SessionRevert.MessageNotFoundError
@@ -218,7 +223,10 @@ export interface Interface {
directory: AbsolutePath
workspaceID?: Location.Ref["workspaceID"]
delivery?: SessionInbox.Delivery
}) => Effect.Effect<void, NotFoundError | DestinationNotFoundError | DestinationNotDirectoryError>
}) => Effect.Effect<
void,
NotFoundError | DestinationNotFoundError | DestinationNotDirectoryError | DestinationUnavailableError
>
readonly prompt: (input: {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
@@ -772,12 +780,22 @@ const layer = Layer.effect(
if (!info) return yield* new DestinationNotFoundError({ directory })
if (info.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
const project = yield* projects.resolve(directory)
yield* persistProject(project)
const payload: SessionInbox.MovePayload = {
location: Location.Ref.make({ directory, workspaceID: input.workspaceID }),
projectID: project.id,
subpath: RelativePath.make(path.relative(project.directory, directory).replaceAll("\\", "/")),
}
yield* Location.Service.pipe(
Effect.provide(locations.get(payload.location)),
Effect.scoped,
Effect.catchCause((cause) => {
if (Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause)
return Effect.logWarning("session move destination unavailable", { directory, cause }).pipe(
Effect.andThen(Effect.fail(new DestinationUnavailableError({ directory }))),
)
}),
)
yield* persistProject(project)
const item = SessionInbox.Item.make({
type: "move",
payload,
+22
View File
@@ -43,6 +43,28 @@ const itWithSdk = testEffect(
)
describe("LocationServiceMap", () => {
it.live("retries a location after its missing directory is recreated", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const directory = path.join(dir.path, "recreated")
const ref = Location.Ref.make({ directory: AbsolutePath.make(directory) })
const first = yield* Location.Service.pipe(Effect.provide(locations.get(ref)), Effect.scoped, Effect.exit)
expect(first._tag).toBe("Failure")
yield* Effect.promise(() => fs.mkdir(directory))
const location = yield* Location.Service.pipe(Effect.provide(locations.get(ref)), Effect.scoped)
expect(location.directory).toBe(ref.directory)
}),
),
),
)
itWithSdk.live("preserves embedded SDK plugins after Location eviction", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
+42 -1
View File
@@ -1,12 +1,14 @@
import { describe, expect } from "bun:test"
import path from "path"
import { mkdir, rm } from "fs/promises"
import { Effect } from "effect"
import { Effect, Layer, LayerMap } from "effect"
import { Worktree } from "@opencode-ai/schema/worktree"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import type { LocationServices } from "@opencode-ai/core/location-services"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
@@ -28,8 +30,47 @@ const it = testEffect(
],
),
)
const unavailableLocations = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
() => Layer.effectDiscard(Effect.fail(new Error("broken location"))) as unknown as Layer.Layer<LocationServices>,
),
)
const itWithUnavailableDestination = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[Project.node, globalProjectLayer],
[SessionExecution.node, SessionExecution.noopLayer],
[LocationServiceMap.node, unavailableLocations],
],
),
)
describe("Session.move", () => {
itWithUnavailableDestination.effect("rejects an unavailable destination before admitting the move", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const session = yield* Session.Service
const source = AbsolutePath.make(path.join(tmp.path, "source"))
const destination = AbsolutePath.make(path.join(tmp.path, "destination"))
yield* Effect.promise(() => Promise.all([mkdir(source), mkdir(destination)]))
const created = yield* session.create({ location: Location.Ref.make({ directory: source }) })
const error = yield* session.move({ sessionID: created.id, directory: destination }).pipe(Effect.flip)
expect(error).toEqual(new Session.DestinationUnavailableError({ directory: destination }))
expect((yield* session.get(created.id)).location.directory).toBe(source)
expect(yield* session.inbox(created.id)).toEqual([])
}),
),
),
)
it.effect("applies a move immediately when the source directory no longer exists", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
+3
View File
@@ -240,6 +240,9 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
Effect.catchTag("Session.DestinationNotDirectoryError", (error) =>
Effect.fail(new InvalidRequestError({ message: `Not a directory: ${error.directory}` })),
),
Effect.catchTag("Session.DestinationUnavailableError", (error) =>
Effect.fail(new InvalidRequestError({ message: `Directory is unavailable: ${error.directory}` })),
),
)
return HttpApiSchema.NoContent.make()
}),