Compare commits

...
Author SHA1 Message Date
Shoubhit Dash 5e567a18ce fix(core): refresh git references on daily activity 2026-08-27 20:32:59 +05:30
8 changed files with 437 additions and 165 deletions
+23 -10
View File
@@ -36,6 +36,8 @@ type Draft = {
export interface Interface extends State.Transformable<Draft> {
readonly list: () => Effect.Effect<Info[]>
/** Schedules daily refresh checks in the Location scope without waiting for Git. */
readonly refresh: () => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Reference") {}
@@ -48,6 +50,25 @@ const layer = Layer.effect(
const cache = yield* RepositoryCache.Service
const scope = yield* Scope.Scope
const materialized = new Map<string, Info>()
const refresh = Effect.fn("Reference.refresh")(function* () {
yield* Effect.forEach(
Array.from(materialized.values()),
(reference) =>
Effect.gen(function* () {
if (reference.source.type !== "git") return
yield* cache.ensure({
reference: Repository.parseRemote(reference.source.repository),
branch: reference.source.branch,
refresh: "daily",
})
}).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to materialize reference", { name: reference.name, cause }),
),
),
{ concurrency: 4, discard: true },
).pipe(Effect.forkIn(scope), Effect.asVoid)
})
const state = State.create<Data, Draft>({
name: "reference",
initial: () => ({ sources: new Map() }),
@@ -92,17 +113,8 @@ const layer = Layer.effect(
source,
}),
)
yield* cache.ensure({ reference: repository, branch: source.branch, refresh: true }).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to materialize reference", {
name,
repository: source.repository,
cause,
}),
),
Effect.forkIn(scope),
)
}
yield* refresh()
yield* bus.publish(Reference.Event.Updated, {})
}),
})
@@ -110,6 +122,7 @@ const layer = Layer.effect(
return Service.of({
transform: state.transform,
reload: state.reload,
refresh,
list: Effect.fn("Reference.list")(function* () {
return Array.from(materialized.values())
}),
+116 -102
View File
@@ -6,7 +6,7 @@
* observe the checkout move underneath them.
*/
import path from "path"
import { Context, Effect, Layer, Schema } from "effect"
import { Clock, Context, Duration, Effect, Layer, Option, Schema } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Git } from "./git.js"
import { Global } from "@opencode-ai/util/global"
@@ -14,6 +14,13 @@ import { Repository } from "./repository.js"
import { AbsolutePath } from "./schema.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { EffectFlock } from "@opencode-ai/util/effect-flock"
import { KV } from "./kv.js"
const Refresh = Schema.Struct({
attemptedAt: Schema.Number,
refreshedAt: Schema.optionalKey(Schema.Number),
})
const refreshInterval = Duration.toMillis(Duration.days(1))
export type Result = {
readonly repository: string
@@ -27,7 +34,8 @@ export type Result = {
export type EnsureInput = {
readonly reference: Repository.RemoteReference
readonly refresh?: boolean
/** `daily` throttles existing checkouts; `true` forces a refresh. */
readonly refresh?: boolean | "daily"
readonly branch?: string
}
@@ -105,122 +113,128 @@ export const validateBranch = Effect.fn("RepositoryCache.validateBranch")(functi
})
})
const layer: Layer.Layer<Service, never, FSUtil.Service | Git.Service | EffectFlock.Service | Global.Service> =
Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const flock = yield* EffectFlock.Service
const global = yield* Global.Service
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const flock = yield* EffectFlock.Service
const global = yield* Global.Service
const kv = yield* KV.Service
return Service.of({
ensure: Effect.fn("RepositoryCache.ensure")(function* (input) {
if (input.branch) yield* validateBranch(input.branch)
return Service.of({
ensure: Effect.fn("RepositoryCache.ensure")(function* (input) {
if (input.branch) yield* validateBranch(input.branch)
const repository = input.reference.label
const localPath = Repository.cachePath(global.repos, input.reference, input.branch)
const cloneTarget = Repository.parse(input.reference.remote) ?? input.reference
const repository = input.reference.label
const localPath = Repository.cachePath(global.repos, input.reference, input.branch)
const key = `repository-cache:${localPath}`
const cloneTarget = Repository.parse(input.reference.remote) ?? input.reference
return yield* flock
.withLock(
Effect.gen(function* () {
yield* cacheOperation(fs.ensureDir(path.dirname(localPath)), "ensure cache directory", localPath)
return yield* flock
.withLock(
Effect.gen(function* () {
yield* cacheOperation(fs.ensureDir(path.dirname(localPath)), "ensure cache directory", localPath)
const existing = yield* git.repo.discover(AbsolutePath.make(localPath))
const origin = existing ? yield* git.remote.get(existing) : undefined
const originReference = origin ? Repository.parse(origin) : undefined
// Discovery walks upward, so an enclosing repository with a
// matching origin could masquerade as the cache entry; reuse
// requires the checkout to live exactly at the cache path.
const worktree = existing ? yield* fs.resolve(localPath) : undefined
const reuse = Boolean(
existing &&
existing.worktree === worktree &&
originReference &&
Repository.same(originReference, cloneTarget),
)
if (!reuse && (yield* fs.existsSafe(localPath))) {
yield* cacheOperation(fs.remove(localPath, { recursive: true }), "remove stale cache", localPath)
}
const existing = yield* git.repo.discover(AbsolutePath.make(localPath))
const origin = existing ? yield* git.remote.get(existing) : undefined
const originReference = origin ? Repository.parse(origin) : undefined
// Discovery walks upward, so an enclosing repository with a
// matching origin could masquerade as the cache entry; reuse
// requires the checkout to live exactly at the cache path.
const worktree = existing ? yield* fs.resolve(localPath) : undefined
const reuse = Boolean(
existing &&
existing.worktree === worktree &&
originReference &&
Repository.same(originReference, cloneTarget),
)
if (!reuse && (yield* fs.existsSafe(localPath))) {
yield* cacheOperation(fs.remove(localPath, { recursive: true }), "remove stale cache", localPath)
}
const status = !reuse
? ("cloned" as const)
: input.refresh
? ("refreshed" as const)
: ("cached" as const)
const now = yield* Clock.currentTimeMillis
const previous = Option.getOrUndefined(Schema.decodeUnknownOption(Refresh)(yield* kv.get(key)))
const refresh =
input.refresh === "daily" ? !previous || now - previous.attemptedAt >= refreshInterval : input.refresh
const status = !reuse ? ("cloned" as const) : refresh ? ("refreshed" as const) : ("cached" as const)
if (status === "cloned") {
yield* git.repo
.clone({
remote: input.reference.remote,
directory: AbsolutePath.make(localPath),
branch: input.branch,
})
.pipe(Effect.mapError((error) => new CloneFailedError({ repository, message: error.message })))
}
// Record attempts before network work so offline/auth failures don't retry on every prompt.
if (status !== "cached") yield* kv.set(key, { ...previous, attemptedAt: now })
if (status === "refreshed") {
if (!existing)
return yield* new FetchFailedError({ repository, message: "Repository is unavailable" })
if (status === "cloned") {
yield* git.repo
.clone({
remote: input.reference.remote,
directory: AbsolutePath.make(localPath),
branch: input.branch,
})
.pipe(Effect.mapError((error) => new CloneFailedError({ repository, message: error.message })))
}
if (status === "refreshed") {
if (!existing) return yield* new FetchFailedError({ repository, message: "Repository is unavailable" })
yield* git.sync
.fetchRemotes(existing)
.pipe(Effect.mapError((error) => new FetchFailedError({ repository, message: error.message })))
if (input.branch) {
yield* git.sync
.fetchRemotes(existing)
.fetchBranch(existing, { branch: input.branch })
.pipe(Effect.mapError((error) => new FetchFailedError({ repository, message: error.message })))
if (input.branch) {
yield* git.sync
.fetchBranch(existing, { branch: input.branch })
.pipe(Effect.mapError((error) => new FetchFailedError({ repository, message: error.message })))
}
// Checking out the tracked ref before resetting keeps the
// checkout self-healing even if it was left on another
// branch.
const branch = input.branch ?? (yield* git.history.defaultRemoteBranch(existing))
if (branch) {
yield* git.sync
.checkoutRemoteBranch(existing, { branch })
.pipe(
Effect.mapError(
(error) => new CheckoutFailedError({ repository, branch, message: error.message }),
),
)
}
const target = branch ?? (yield* git.history.branch(existing))
yield* git.sync
.resetHard(existing, target ? `origin/${target}` : "HEAD")
.pipe(Effect.mapError((error) => new ResetFailedError({ repository, message: error.message })))
}
const checkout = yield* git.repo.discover(AbsolutePath.make(localPath))
// Checking out the tracked ref before resetting keeps the
// checkout self-healing even if it was left on another
// branch.
const branch = input.branch ?? (yield* git.history.defaultRemoteBranch(existing))
if (branch) {
yield* git.sync
.checkoutRemoteBranch(existing, { branch })
.pipe(
Effect.mapError(
(error) => new CheckoutFailedError({ repository, branch, message: error.message }),
),
)
}
return {
repository,
host: input.reference.host,
remote: input.reference.remote,
localPath,
status,
head: checkout ? yield* git.history.head(checkout) : undefined,
branch: checkout ? yield* git.history.branch(checkout) : undefined,
} satisfies Result
}),
`repository-cache:${localPath}`,
)
.pipe(
Effect.mapError((error) =>
isError(error) ? error : new LockFailedError({ localPath, message: errorMessage(error) }),
),
)
}),
})
}),
)
const target = branch ?? (yield* git.history.branch(existing))
yield* git.sync
.resetHard(existing, target ? `origin/${target}` : "HEAD")
.pipe(Effect.mapError((error) => new ResetFailedError({ repository, message: error.message })))
}
if (status !== "cached")
yield* kv.set(key, { attemptedAt: now, refreshedAt: yield* Clock.currentTimeMillis })
const checkout = yield* git.repo.discover(AbsolutePath.make(localPath))
return {
repository,
host: input.reference.host,
remote: input.reference.remote,
localPath,
status,
head: checkout ? yield* git.history.head(checkout) : undefined,
branch: checkout ? yield* git.history.branch(checkout) : undefined,
} satisfies Result
}),
key,
)
.pipe(
Effect.mapError((error) =>
isError(error) ? error : new LockFailedError({ localPath, message: errorMessage(error) }),
),
)
}),
})
}),
)
export const node = makeGlobalNode({
service: Service,
layer,
deps: [EffectFlock.node, FSUtil.node, Git.node, Global.node],
deps: [EffectFlock.node, FSUtil.node, Git.node, Global.node, KV.node],
})
function errorMessage(error: unknown) {
+5 -1
View File
@@ -56,6 +56,7 @@ import { fileURLToPath } from "url"
import { SessionEnvironment } from "./session/environment.js"
import { SessionHistory } from "./session/history.js"
import { InstructionEntry } from "./session/instruction-entry.js"
import { Reference } from "./reference.js"
// get project -> project.locations
//
@@ -660,11 +661,14 @@ const layer = Layer.effect(
)
// Commit a staged revert only after preparation succeeds, before admitting new work.
if (session.revert) yield* SessionRevert.commit(session).pipe(Effect.provideService(Bus.Service, bus))
return yield* SessionInbox.admit(db, bus, {
const admitted = yield* SessionInbox.admit(db, bus, {
id: messageID,
sessionID: session.id,
item,
})
const references = yield* Reference.Service.pipe(Effect.provide(locations.get(session.location)))
yield* references.refresh()
return admitted
}).pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionInbox.LifecycleConflict
@@ -2,17 +2,19 @@ import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import type { LocationServices } from "@opencode-ai/core/location-services"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service"
import { Reference } from "@opencode-ai/core/reference"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Effect, Layer, LayerMap } from "effect"
// Plain-prompt unit fixtures use virtual directories and need only the admission hook services.
// Plain-prompt unit fixtures use virtual directories without configured references.
export const promptLocationLayer = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
() =>
Layer.merge(
Layer.mergeAll(
LayerNode.compile(PluginHooks.node),
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
Layer.mock(Reference.Service, { refresh: () => Effect.void }),
) as Layer.Layer<LocationServices>,
),
)
+106 -3
View File
@@ -2,19 +2,121 @@ import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { Effect, Layer } from "effect"
import { Clock, Duration, Effect, Layer, Schema } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { Repository } from "@opencode-ai/core/repository"
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
import { branch, git, gitRemote } from "./fixture/git"
import { Database } from "@opencode-ai/core/database/database"
import { KV } from "@opencode-ai/core/kv"
import { branch, commit, git, gitRemote } from "./fixture/git"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(Layer.empty)
describe("RepositoryCache", () => {
it.live("persists the daily throttle across cache recreation and serializes competing refreshes", () =>
withRemote((fixture) =>
Effect.gen(function* () {
const initial = yield* Effect.gen(function* () {
const cache = yield* RepositoryCache.Service
return yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })
}).pipe(Effect.provide(cacheLayer(fixture.root)))
expect(initial.status).toBe("cloned")
yield* Effect.promise(() => commit(fixture.source, "two\n", "advance main"))
yield* Effect.gen(function* () {
const cache = yield* RepositoryCache.Service
const kv = yield* KV.Service
expect((yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })).status).toBe("cached")
expect(yield* read(path.join(initial.localPath, "README.md"))).toBe("one\n")
const yesterday = (yield* Clock.currentTimeMillis) - Duration.toMillis(Duration.days(1))
yield* kv.set(`repository-cache:${initial.localPath}`, { attemptedAt: yesterday, refreshedAt: yesterday })
}).pipe(Effect.provide(cacheLayer(fixture.root)))
const results = yield* Effect.all(
[0, 1].map(() =>
Effect.gen(function* () {
const cache = yield* RepositoryCache.Service
return yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })
}).pipe(Effect.provide(cacheLayer(fixture.root))),
),
{ concurrency: "unbounded" },
)
expect(results.map((result) => result.status).toSorted()).toEqual(["cached", "refreshed"])
expect(yield* read(path.join(initial.localPath, "README.md"))).toBe("two\n")
// A missing checkout must be recreated even when the persisted timestamp is recent.
yield* Effect.promise(() => fs.rm(initial.localPath, { recursive: true }))
yield* Effect.gen(function* () {
const cache = yield* RepositoryCache.Service
expect((yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })).status).toBe("cloned")
}).pipe(Effect.provide(cacheLayer(fixture.root)))
expect(yield* read(path.join(initial.localPath, "README.md"))).toBe("two\n")
}),
),
)
it.live("throttles failed refresh attempts without marking them successful", () =>
withRemote((fixture) =>
Effect.gen(function* () {
const cache = yield* RepositoryCache.Service
const kv = yield* KV.Service
const initial = yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })
const key = `repository-cache:${initial.localPath}`
const yesterday = (yield* Clock.currentTimeMillis) - Duration.toMillis(Duration.days(1))
yield* kv.set(key, { attemptedAt: yesterday, refreshedAt: yesterday })
yield* Effect.promise(() =>
fs.rename(path.join(fixture.root, "origin.git"), path.join(fixture.root, "offline.git")),
)
const error = yield* Effect.flip(cache.ensure({ reference: fixture.reference, refresh: "daily" }))
expect(error).toBeInstanceOf(RepositoryCache.FetchFailedError)
const stored = yield* kv.get(key)
const stamp = Schema.decodeUnknownSync(
Schema.Struct({ attemptedAt: Schema.Number, refreshedAt: Schema.Number }),
)(stored)
expect(stamp.attemptedAt).toBeGreaterThan(yesterday)
expect(stamp.refreshedAt).toBe(yesterday)
expect((yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })).status).toBe("cached")
expect(yield* kv.get(key)).toEqual(stored)
expect(yield* read(path.join(initial.localPath, "README.md"))).toBe("one\n")
yield* Effect.promise(async () => {
await fs.rename(path.join(fixture.root, "offline.git"), path.join(fixture.root, "origin.git"))
await commit(fixture.source, "two\n", "advance main")
})
yield* kv.set(key, { attemptedAt: yesterday, refreshedAt: yesterday })
expect((yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })).status).toBe("refreshed")
expect(yield* read(path.join(initial.localPath, "README.md"))).toBe("two\n")
expect(yield* kv.get(key)).not.toEqual(stored)
}).pipe(Effect.provide(cacheLayer(fixture.root))),
),
)
it.live("refreshes existing untracked checkouts and keeps branch freshness independent", () =>
withRemote((fixture) =>
Effect.gen(function* () {
const cache = yield* RepositoryCache.Service
const kv = yield* KV.Service
yield* Effect.promise(() => branch(fixture.source, "feature", "feature\n"))
const main = yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })
const feature = yield* cache.ensure({ reference: fixture.reference, branch: "feature", refresh: "daily" })
yield* kv.remove(`repository-cache:${feature.localPath}`)
yield* Effect.promise(() => commit(fixture.source, "new feature\n", "advance feature"))
expect((yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })).status).toBe("cached")
expect(
(yield* cache.ensure({ reference: fixture.reference, branch: "feature", refresh: "daily" })).status,
).toBe("refreshed")
expect(yield* read(path.join(main.localPath, "README.md"))).toBe("one\n")
expect(yield* read(path.join(feature.localPath, "README.md"))).toBe("new feature\n")
}).pipe(Effect.provide(cacheLayer(fixture.root))),
),
)
it.live("replaces a stale cache directory before cloning", () =>
withRemote((fixture) =>
Effect.gen(function* () {
@@ -120,8 +222,9 @@ describe("RepositoryCache", () => {
})
function cacheLayer(root: string) {
return AppNodeBuilder.build(RepositoryCache.node, [
return AppNodeBuilder.build(LayerNode.group([RepositoryCache.node, KV.node]), [
[Global.node, Global.layerWith({ state: path.join(root, "state"), repos: path.join(root, "repos") })],
[Database.node, Database.configured({ path: path.join(root, "cache.sqlite") })],
])
}
+159 -38
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Fiber, Layer, LayerMap, Schema, Stream } from "effect"
import { mkdtemp, rm } from "fs/promises"
import { Clock, DateTime, Duration, Effect, Fiber, Layer, LayerMap, Queue, Schema, Stream } from "effect"
import { mkdir, mkdtemp, rm, symlink } from "fs/promises"
import { tmpdir } from "os"
import path from "path"
import { pathToFileURL } from "url"
@@ -31,6 +31,12 @@ import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { testEffect } from "./lib/effect"
import { Reference } from "@opencode-ai/core/reference"
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
import { Global } from "@opencode-ai/util/global"
import { EffectFlock } from "@opencode-ai/util/effect-flock"
import { KV } from "@opencode-ai/core/kv"
import { gitRemote, git, commit } from "./fixture/git"
const executionCalls: Session.ID[] = []
const interruptCalls: Session.ID[] = []
@@ -58,48 +64,51 @@ const execution = Layer.succeed(
awaitIdle: () => Effect.void,
}),
)
const locations = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
() =>
// These operations resolve Location services lazily and must wait for plugin-projected state.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
Layer.unwrap(
Effect.sync(() => {
let ready = false
return Layer.mergeAll(
LayerNode.compile(PluginHooks.node),
Layer.mock(Image.Service, {
normalize: (_resource, content) =>
ready
? Effect.succeed(content.content.length > 5 * 1024 * 1024 ? { ...content, content: "AA==" } : content)
: Effect.die(new Error("Image service used before plugins were ready")),
}),
Layer.mock(Snapshot.Service, {
capture: () =>
ready ? Effect.undefined : Effect.die(new Error("Snapshot used before plugins were ready")),
restore: () =>
ready ? Effect.void : Effect.die(new Error("Snapshot used before plugins were ready")),
}),
Layer.succeed(
PluginSupervisor.Service,
PluginSupervisor.Service.of({ flush: Effect.sync(() => (ready = true)) }),
),
)
}),
) as unknown as Layer.Layer<LocationServices>,
),
)
const it = testEffect(
const locations = (references: Layer.Layer<Reference.Service>) =>
Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
() =>
// These operations resolve Location services lazily and must wait for plugin-projected state.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
Layer.unwrap(
Effect.sync(() => {
let ready = false
return Layer.mergeAll(
references,
LayerNode.compile(PluginHooks.node),
Layer.mock(Image.Service, {
normalize: (_resource, content) =>
ready
? Effect.succeed(
content.content.length > 5 * 1024 * 1024 ? { ...content, content: "AA==" } : content,
)
: Effect.die(new Error("Image service used before plugins were ready")),
}),
Layer.mock(Snapshot.Service, {
capture: () =>
ready ? Effect.undefined : Effect.die(new Error("Snapshot used before plugins were ready")),
restore: () => (ready ? Effect.void : Effect.die(new Error("Snapshot used before plugins were ready"))),
}),
Layer.succeed(
PluginSupervisor.Service,
PluginSupervisor.Service.of({ flush: Effect.sync(() => (ready = true)) }),
),
)
}),
) as unknown as Layer.Layer<LocationServices>,
),
)
const sessionLayer = (references = Layer.mock(Reference.Service, { refresh: () => Effect.void })) =>
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[Bus.node, Bus.configured({ persist: true })],
[SessionExecution.node, execution],
[LocationServiceMap.node, locations],
[LocationServiceMap.node, locations(references)],
],
),
)
)
const it = testEffect(sessionLayer())
const sessionID = Session.ID.make("ses_prompt_test")
const messageID = SessionMessage.ID.create()
@@ -170,6 +179,118 @@ const assistantRow = (id: SessionMessage.ID, seq: number) => {
}
describe("Session.prompt", () => {
it.live("refreshes stale references after admission without blocking the prompt (#45562)", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
Effect.promise(() => mkdtemp(path.join(tmpdir(), "reference-refresh-"))),
(root) => Effect.promise(() => rm(root, { recursive: true, force: true })),
)
const fixture = yield* Effect.promise(() => gitRemote(root))
yield* Effect.promise(async () => {
await mkdir(path.join(root, "owner"))
await symlink(path.join(root, "origin.git"), path.join(root, "owner", "repo.git"))
})
const previous = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = pathToFileURL(root + "/").href
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
if (previous === undefined) delete process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
else process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = previous
}),
)
yield* Effect.gen(function* () {
const cache = yield* RepositoryCache.Service
const kv = yield* KV.Service
const flock = yield* EffectFlock.Service
const completed = yield* Queue.unbounded<void>()
yield* Effect.gen(function* () {
const references = yield* Reference.Service
yield* references.transform((editor) =>
editor.add("example", Reference.GitSource.make({ type: "git", repository: "owner/repo", branch: "main" })),
)
yield* Queue.take(completed).pipe(Effect.timeout("5 seconds"))
const initial = (yield* references.list())[0]
expect(yield* Effect.promise(() => Bun.file(path.join(initial.path, "README.md")).text())).toBe("one\n")
yield* Effect.promise(async () => {
await Bun.write(path.join(fixture.source, "new-file.txt"), "new\n")
await git(fixture.source, "add", "new-file.txt")
await commit(fixture.source, "two\n", "advance main")
})
yield* Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const database = yield* Database.Service
const attach = () =>
session.prompt({
sessionID,
text: "Inspect @example",
files: [
{ uri: pathToFileURL(initial.path).href, name: initial.name },
{ uri: pathToFileURL(path.join(initial.path, "README.md")).href, name: "README.md" },
],
resume: false,
})
// A same-day prompt and config reload must keep the existing checkout.
const cached = yield* attach()
yield* Queue.take(completed).pipe(Effect.timeout("2 seconds"))
yield* references.reload()
yield* Queue.take(completed).pipe(Effect.timeout("2 seconds"))
expect(cached.payload.files?.map((file) => Buffer.from(file.data, "base64").toString("utf8"))).toEqual([
".git/\nREADME.md",
"one\n",
])
expect(yield* Effect.promise(() => Bun.file(path.join(initial.path, "README.md")).text())).toBe("one\n")
const key = `repository-cache:${initial.path}`
const yesterday = (yield* Clock.currentTimeMillis) - Duration.toMillis(Duration.days(1))
yield* kv.set(key, { attemptedAt: yesterday, refreshedAt: yesterday })
const admitted = yield* Effect.gen(function* () {
// Hold the checkout lock to prove admission doesn't wait for Git.
yield* flock.acquire(key)
const message = yield* attach().pipe(Effect.scoped, Effect.timeout("2 seconds"))
expect(yield* SessionInbox.find(database.db, message.id)).toBeDefined()
expect(message.payload.files?.map((file) => Buffer.from(file.data, "base64").toString("utf8"))).toEqual([
".git/\nREADME.md",
"one\n",
])
return message
}).pipe(Effect.scoped)
// The background refresh survives the submitting request's scope.
yield* Queue.take(completed).pipe(Effect.timeout("5 seconds"))
const reloaded = yield* attach()
expect(reloaded.payload.files?.map((file) => Buffer.from(file.data, "base64").toString("utf8"))).toEqual([
".git/\nnew-file.txt\nREADME.md",
"two\n",
])
expect(
(yield* session.prompt({ sessionID, id: admitted.id, text: "retry", resume: false })).payload,
).toEqual(admitted.payload)
}).pipe(Effect.provide(sessionLayer(Layer.succeed(Reference.Service, references)).pipe(Layer.fresh)))
}).pipe(
Effect.provide(
AppNodeBuilder.build(Reference.node, [
[Global.node, Global.layerWith({ state: path.join(root, "state"), repos: path.join(root, "repos") })],
[
RepositoryCache.node,
Layer.succeed(RepositoryCache.Service, {
ensure: (input) => cache.ensure(input).pipe(Effect.tap(() => Queue.offer(completed, undefined))),
}),
],
]),
),
)
}).pipe(
Effect.scoped,
Effect.provide(
AppNodeBuilder.build(LayerNode.group([RepositoryCache.node, KV.node, EffectFlock.node]), [
[Global.node, Global.layerWith({ state: path.join(root, "state"), repos: path.join(root, "repos") })],
]),
),
)
}),
)
it.effect("exposes the execution registry", () =>
Effect.gen(function* () {
activeSessions.add(sessionID)
+2
View File
@@ -19,6 +19,7 @@ import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { Skill } from "@opencode-ai/core/skill"
import { Reference } from "@opencode-ai/core/reference"
import { testEffect } from "./lib/effect"
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
@@ -33,6 +34,7 @@ const info = Skill.Info.make({
content: "Use Effect",
})
const skills = Layer.mergeAll(
Layer.mock(Reference.Service, { refresh: () => Effect.void }),
LayerNode.compile(PluginHooks.node),
Layer.mock(Skill.Service, {
get: (id) => Effect.succeed(id === info.id ? info : undefined),
+22 -9
View File
@@ -98,23 +98,36 @@ Git references also support shorthand:
### Cloning and storage
OpenCode normalizes a remote and stores one checkout under its global data
directory at `opencode/repos/<host>/<repository-path>`. On a typical Linux
OpenCode normalizes a remote and stores one checkout per remote and branch
under its global data directory. Without an explicit branch, the checkout is
stored at `opencode/repos/<host>/<repository-path>`. On a typical Linux
installation, for example, `Effect-TS/effect` is stored at:
```text
~/.local/share/opencode/repos/github.com/Effect-TS/effect
```
Missing repositories are cloned. Existing checkouts are fetched and reset to
the requested branch, or to the remote default branch when `branch` is omitted.
Materialization runs asynchronously when references load or reload, so a new
reference can appear before its checkout is ready. Clone and refresh failures
are logged and do not stop other references from loading.
An explicit branch adds an encoded `@<branch>` suffix to the checkout path.
Missing repositories are cloned. When references load or reload, and after a
user prompt is admitted in their Location, OpenCode checks them in the
background. Existing checkouts are eligible for one automatic refresh attempt
every 24 hours. A refresh fetches and resets to the requested branch, or to the
remote default branch when `branch` is omitted.
Refresh timestamps persist across service restarts and are shared by Locations
using the same checkout. Failed refresh attempts are logged and remain subject
to the 24-hour limit. There is no periodic polling while a Location is unused.
Prompts do not wait for background refreshes. An attachment can therefore
contain older content even if a later tool read sees the updated checkout.
Initial cloning is also asynchronous, so a new reference can appear before its
checkout is ready. Clone and refresh failures do not stop other references
from loading.
<Callout type="warning">
The cache has one checkout per normalized remote, not one per branch. Do not configure the same repository at multiple
branches; only one branch can be exposed. Avoid editing cached checkouts because a refresh resets them.
Cached checkouts are shared and can update while an agent is using them. Avoid editing cached checkouts because a
refresh resets them.
</Callout>
## Description and visibility