use Effect.cached for global config, remove resetGlobal

- Replace module-level _cachedGlobal with Effect.cached inside
  the layer — concurrent getGlobal() callers share the same fiber
  instead of racing
- Remove resetGlobal() facade — tests use invalidate() instead
- Document Effect.cached pattern in AGENTS.md and effect-migration.md
- Mark Config as done in migration checklist
This commit is contained in:
Kit Langton
2026-03-25 15:35:52 -04:00
parent ddb95646ec
commit d9cefe21f8
4 changed files with 36 additions and 13 deletions

View File

@@ -47,6 +47,10 @@ See `specs/effect-migration.md` for the compact pattern reference and examples.
- Prefer `Path.Path`, `Config`, `Clock`, and `DateTime` when those concerns are already inside Effect code.
- For background loops or scheduled tasks, use `Effect.repeat` or `Effect.schedule` with `Effect.forkScoped` in the layer definition.
## Effect.cached for deduplication
Use `Effect.cached` when multiple concurrent callers should share a single in-flight computation rather than storing `Fiber | undefined` or `Promise | undefined` manually. See `specs/effect-migration.md` for the full pattern.
## Instance.bind — ALS for native callbacks
`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and restores it synchronously when called.

View File

@@ -109,6 +109,31 @@ const cache =
The key insight: don't split init into a separate method with a `started` flag. Put everything in the `InstanceState.make` closure and let `ScopedCache` handle the run-once semantics.
## Effect.cached for deduplication
Use `Effect.cached` when multiple concurrent callers should share a single in-flight computation. It memoizes the result and deduplicates concurrent fibers — second caller joins the first caller's fiber instead of starting a new one.
```ts
// Inside the layer — yield* to initialize the memo
let cached = yield* Effect.cached(loadExpensive())
const get = Effect.fn("Foo.get")(function* () {
return yield* cached // concurrent callers share the same fiber
})
// To invalidate: swap in a fresh memo
const invalidate = Effect.fn("Foo.invalidate")(function* () {
cached = yield* Effect.cached(loadExpensive())
})
```
Prefer `Effect.cached` over these patterns:
- Storing a `Fiber.Fiber | undefined` with manual check-and-fork (e.g. `file/index.ts` `ensure`)
- Storing a `Promise<void>` task for deduplication (e.g. `skill/index.ts` `ensure`)
- `let cached: X | undefined` with check-and-load (races when two callers see `undefined` before either resolves)
`Effect.cached` handles the run-once + concurrent-join semantics automatically. For invalidatable caches, reassign with `yield* Effect.cached(...)` — the old memo is discarded.
## Scheduled Tasks
For loops or periodic work, use `Effect.repeat` or `Effect.schedule` with `Effect.forkScoped` in the layer definition.
@@ -167,7 +192,7 @@ Still open and likely worth migrating:
- [x] `Worktree`
- [ ] `Bus`
- [x] `Command`
- [ ] `Config`
- [x] `Config`
- [ ] `Session`
- [ ] `SessionProcessor`
- [ ] `SessionPrompt`

View File

@@ -1136,8 +1136,6 @@ export namespace Config {
}),
)
let _cachedGlobal: Info | undefined
export const layer: Layer.Layer<Service, never, AppFileSystem.Service> = Layer.effect(
Service,
Effect.gen(function* () {
@@ -1243,13 +1241,13 @@ export namespace Config {
)
}
_cachedGlobal = result
return result
})
let cachedGlobal = yield* Effect.cached(loadGlobal())
const getGlobal = Effect.fn("Config.getGlobal")(function* () {
if (_cachedGlobal) return _cachedGlobal
return yield* loadGlobal()
return yield* cachedGlobal
})
const loadInstanceState = Effect.fnUntraced(function* (ctx: InstanceContext) {
@@ -1453,7 +1451,7 @@ export namespace Config {
})
const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) {
_cachedGlobal = undefined
cachedGlobal = yield* Effect.cached(loadGlobal())
const task = Instance.disposeAll()
.catch(() => undefined)
.finally(() =>
@@ -1513,10 +1511,6 @@ export namespace Config {
return runPromise((svc) => svc.getGlobal())
}
export function resetGlobal() {
_cachedGlobal = undefined
}
export async function update(config: Info) {
return runPromise((svc) => svc.update(config))
}

View File

@@ -34,7 +34,7 @@ async function check(map: (dir: string) => string) {
await using tmp = await tmpdir({ git: true, config: { snapshot: true } })
const prev = Global.Path.config
;(Global.Path as { config: string }).config = globalTmp.path
Config.resetGlobal()
await Config.invalidate()
try {
await writeConfig(globalTmp.path, {
$schema: "https://opencode.ai/config.json",
@@ -52,7 +52,7 @@ async function check(map: (dir: string) => string) {
} finally {
await Instance.disposeAll()
;(Global.Path as { config: string }).config = prev
Config.resetGlobal()
await Config.invalidate()
}
}