fix(core): debounce state reloads

This commit is contained in:
Dax Raad 2026-07-16 13:34:29 -04:00
parent ad8e6b1fb6
commit 65a42fd549
4 changed files with 80 additions and 8 deletions

View file

@ -1,6 +1,6 @@
export * as State from "./state"
import { Context, Effect, Scope, Semaphore } from "effect"
import { Clock, Context, Deferred, Effect, Scope, Semaphore } from "effect"
/**
* A replayable transform applied to a draft during reload.
@ -34,6 +34,7 @@ type Batch = {
const CurrentBatch = Context.Reference<Batch | undefined>("@opencode/State/CurrentBatch", {
defaultValue: () => undefined,
})
const reloadDebounce = 500
export function batch<A, E, R>(effect: Effect.Effect<A, E, R>) {
return Effect.gen(function* () {
@ -73,6 +74,10 @@ export interface Interface<State, DraftApi> extends Transformable<DraftApi> {
export function create<State, DraftApi>(options: Options<State, DraftApi>): Interface<State, DraftApi> {
let state = options.initial()
let transforms: { run: TransformCallback<DraftApi> }[] = []
let generation = 0
let requestedAt = 0
let running = false
let waiters: { generation: number; done: Deferred.Deferred<void> }[] = []
const semaphore = Semaphore.makeUnsafe(1)
const commit = Effect.fn("State.commit")(function* (next: State) {
@ -97,7 +102,39 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
yield* commit(next)
})
const reload = () => semaphore.withPermit(materialize())
const materializeReload = () => semaphore.withPermit(materialize())
const rebuild = (): Effect.Effect<void> =>
Effect.gen(function* () {
const clock = yield* Clock.Clock
const remaining = requestedAt + reloadDebounce - clock.currentTimeMillisUnsafe()
if (remaining > 0) yield* Effect.sleep(remaining)
if (clock.currentTimeMillisUnsafe() < requestedAt + reloadDebounce) return yield* rebuild()
const target = generation
const exit = yield* materializeReload().pipe(Effect.exit)
const completed = waiters.filter((waiter) => waiter.generation <= target)
waiters = waiters.filter((waiter) => waiter.generation > target)
yield* Effect.forEach(completed, (waiter) => Deferred.done(waiter.done, exit), {
concurrency: "unbounded",
discard: true,
})
if (generation > target) return yield* rebuild()
running = false
})
const reload = Effect.fnUntraced(function* () {
const done = Deferred.makeUnsafe<void>()
const clock = yield* Clock.Clock
generation++
requestedAt = clock.currentTimeMillisUnsafe()
waiters.push({ generation, done })
if (!running) {
running = true
yield* rebuild().pipe(Effect.forkDetach)
}
return yield* Deferred.await(done)
})
const result: Interface<State, DraftApi> = {
get: () => state,
@ -117,7 +154,7 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
return Effect.gen(function* () {
const batch = yield* CurrentBatch
if (batch?.active) {
batch.reloads.add(reload)
batch.reloads.add(materializeReload)
return
}
yield* materialize()
@ -132,8 +169,8 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
)
yield* Scope.addFinalizer(scope, dispose)
const batch = yield* CurrentBatch
if (batch?.active) batch.reloads.add(reload)
else yield* reload()
if (batch?.active) batch.reloads.add(materializeReload)
else yield* materializeReload()
return { dispose }
}),
)

View file

@ -1,5 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
import { TestClock } from "effect/testing"
import { AgentV2 } from "@opencode-ai/core/agent"
import { EventV2 } from "@opencode-ai/core/event"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@ -76,7 +77,9 @@ describe("AgentV2", () => {
)
description = "New description"
hidden = false
yield* agent.reload()
const reload = yield* agent.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
expect(yield* agent.get(id)).toMatchObject({ description: "New description", hidden: false })
}),

View file

@ -1,6 +1,7 @@
import { describe, expect } from "bun:test"
import { Money } from "@opencode-ai/schema/money"
import { Effect, Fiber, Layer, Stream } from "effect"
import { TestClock } from "effect/testing"
import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration"
import { Credential } from "@opencode-ai/core/credential"
@ -261,7 +262,9 @@ describe("CatalogV2", () => {
expect((yield* catalog.model.default())?.id).toBe(old)
configured = false
yield* catalog.reload()
const reload = yield* catalog.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
expect((yield* catalog.model.default())?.id).toBe(newest)
}),
)

View file

@ -1,6 +1,7 @@
import { describe, expect } from "bun:test"
import { State } from "@opencode-ai/core/state"
import { Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
import { TestClock } from "effect/testing"
import { testEffect } from "./lib/effect"
const it = testEffect(Layer.empty)
@ -51,7 +52,9 @@ describe("State", () => {
expect(state.get().values).toEqual(["first"])
value = "second"
yield* state.reload()
const reload = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
expect(state.get().values).toEqual(["second"])
}),
)
@ -112,4 +115,30 @@ describe("State", () => {
expect(finalized).toBe(2)
}),
)
it.effect("debounces reload bursts", () =>
Effect.gen(function* () {
let finalized = 0
const state = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
finalize: () => Effect.sync(() => finalized++),
})
yield* state.transform((draft) => {
draft.add("value")
})
finalized = 0
const first = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("250 millis")
const second = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("499 millis")
expect(finalized).toBe(0)
yield* TestClock.adjust("1 millis")
yield* Fiber.join(first)
yield* Fiber.join(second)
expect(finalized).toBe(1)
}),
)
})