fix(core): notify reloads immediately and debounce at bursty sources (#46843)

This commit is contained in:
Kit Langton
2026-09-02 13:53:55 -04:00
committed by GitHub
Unverified
parent bbc44310dc
commit b45882ec87
12 changed files with 80 additions and 147 deletions
@@ -103,7 +103,11 @@ export const Plugin = define({
(effect, ..._args: [file?: string]) => lock.withPermit(effect),
)
yield* Stream.fromPubSub(changes).pipe(
// Editor saves arrive as bursts of watcher events; settle before rescanning once. Subscribe
// before debouncing so no update slips through while the debounce starts its pull.
const updates = yield* PubSub.subscribe(changes)
yield* Stream.fromSubscription(updates).pipe(
Stream.debounce("100 millis"),
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(discovery.reload()))),
Effect.forkScoped({ startImmediately: true }),
)
+5 -1
View File
@@ -171,7 +171,11 @@ export const Plugin = define({
(effect, ..._args: [file?: string]) => lock.withPermit(effect),
)
yield* Stream.fromPubSub(changes).pipe(
// Editor saves arrive as bursts of watcher events; settle before rescanning once. Subscribe
// before debouncing so no update slips through while the debounce starts its pull.
const updates = yield* PubSub.subscribe(changes)
yield* Stream.fromSubscription(updates).pipe(
Stream.debounce("100 millis"),
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(ctx.skill.reload()))),
Effect.forkScoped({ startImmediately: true }),
)
+23 -50
View File
@@ -1,6 +1,6 @@
export * as State from "./state.js"
import { Cause, Clock, Context, Deferred, Effect, Exit, Fiber, Scope } from "effect"
import { Cause, Context, Effect, Exit, Fiber, Scope } from "effect"
/**
* A synchronous, replayable edit to the current domain state.
@@ -23,7 +23,7 @@ export type Transform<DraftApi> = (
transform: TransformCallback<DraftApi>,
) => Effect.Effect<Registration, never, Scope.Scope>
/** Invalidates the current value after captured inputs change and coalesces notifications. */
/** Invalidates the current value after captured inputs change and notifies like a registration would. */
export type Reload = () => Effect.Effect<void>
export interface Transformable<DraftApi> {
@@ -40,17 +40,12 @@ type Batch = {
const CurrentBatch = Context.Reference<Batch | undefined>("@opencode/State/CurrentBatch", {
defaultValue: () => undefined,
})
const reloadDebounce = 500
/** Coalesces notifications until the effect completes. Reads inside stay fresh; nothing is rolled back. */
export function batch<A, E, R>(effect: Effect.Effect<A, E, R>) {
return run(effect, false)
}
/**
* Runs the effect as shutdown: States changed inside it close permanently and never notify again,
* including debounced reloads already waiting.
*/
/** Runs the effect as shutdown: States changed inside it close permanently and never notify again. */
export function shutdown<A, E, R>(effect: Effect.Effect<A, E, R>) {
return run(effect, true)
}
@@ -100,9 +95,9 @@ export interface Options<State, DraftApi> {
/** Wraps mutable state in a domain-specific draft API. */
readonly draft: MakeDraft<State, DraftApi>
/**
* Observes the freshly rebuilt value outside the read path. Batched changes
* notify at batch completion; reloads debounce notifications. Resource
* reconciliation owns its execution scope and coordination.
* Observes the freshly rebuilt value outside the read path. Every registration, disposal, or
* reload notifies once it is applied; a batch coalesces them into one notification at its end.
* Resource reconciliation owns its execution scope and coordination.
*/
readonly notify?: (state: State) => Effect.Effect<void>
}
@@ -119,9 +114,7 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
let state = options.initial()
const transforms: { run: TransformCallback<DraftApi> }[] = []
let dirty = false
let requestedAt = 0
let closed = false
let pending: Deferred.Deferred<void> | undefined
const get = () => {
if (closed || !dirty) return state
@@ -141,42 +134,22 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
if (options.notify) yield* options.notify(value)
}).pipe(Effect.withSpan("State.notify"))
const changed = (debounce: boolean) =>
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
if (closed) return
dirty = true
const batch = yield* CurrentBatch
if (batch?.active) {
if (batch.shutdown) {
closed = true
return
}
batch.notifications.add(notify)
const changed = Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
if (closed) return
dirty = true
const batch = yield* CurrentBatch
if (batch?.active) {
if (batch.shutdown) {
closed = true
return
}
if (!debounce) {
yield* restore(notify)
return
}
const clock = yield* Clock.Clock
requestedAt = clock.currentTimeMillisUnsafe()
if (pending) return yield* restore(Deferred.await(pending))
const done = Deferred.makeUnsafe<void>()
pending = done
yield* Effect.gen(function* () {
do {
const remaining = requestedAt + reloadDebounce - clock.currentTimeMillisUnsafe()
if (remaining > 0) yield* Effect.sleep(remaining)
} while (clock.currentTimeMillisUnsafe() < requestedAt + reloadDebounce)
// Observers can request and await another reload without joining their own notification.
pending = undefined
yield* notify.pipe(Deferred.into(done))
}).pipe(Effect.forkDetach)
yield* restore(Deferred.await(done))
}),
)
batch.notifications.add(notify)
return
}
yield* restore(notify)
}),
)
return {
get,
@@ -191,16 +164,16 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
const index = transforms.indexOf(transform)
if (index < 0) return Effect.void
transforms.splice(index, 1)
return changed(false)
return changed
}),
)
transforms.push(transform)
yield* Scope.addFinalizer(scope, dispose)
yield* changed(false)
yield* changed
return { dispose }
}),
)
}),
reload: () => changed(true),
reload: () => changed,
}
}
+11 -3
View File
@@ -2,7 +2,7 @@ export * as McpTool from "./mcp.js"
import { ToolFailure } from "@opencode-ai/ai"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { Context, Effect, Fiber, type JsonSchema, Layer, Semaphore, Stream } from "effect"
import { Context, Effect, Fiber, type JsonSchema, Layer, PubSub, Semaphore, Stream } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../bus.js"
@@ -123,9 +123,17 @@ export const layer = Layer.effect(
}),
)
// Servers announce tools in bursts and each read loads the whole catalog, so settle and refresh
// once. The bus subscription stays eager; only the already-open sliding subscription is debounced.
const changes = yield* PubSub.sliding<void>(1)
yield* bus.subscribe(McpEvent.ToolsChanged).pipe(
// Each read loads the whole catalog, so queued notifications need only one refresh.
Stream.runForEachArray(() => reconcile),
Stream.runForEach(() => PubSub.publish(changes, undefined)),
Effect.forkScoped({ startImmediately: true }),
)
const updates = yield* PubSub.subscribe(changes)
yield* Stream.fromSubscription(updates).pipe(
Stream.debounce("100 millis"),
Stream.runForEach(() => reconcile),
Effect.forkScoped({ startImmediately: true }),
)
return Service.of({ flush: Effect.asVoid(Fiber.await(initial)) })
+1 -4
View File
@@ -1,7 +1,6 @@
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
import { TestClock } from "effect/testing"
import { Agent } from "@opencode-ai/core/agent"
import { Bus } from "@opencode-ai/core/bus"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -102,9 +101,7 @@ describe("Agent", () => {
)
description = "New description"
hidden = false
const reload = yield* agent.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
yield* agent.reload()
expect(yield* agent.get(id)).toMatchObject({ description: "New description", hidden: false })
}),
+1 -4
View File
@@ -2,7 +2,6 @@ import { describe, expect } from "bun:test"
import { LanguageModel } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
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"
@@ -342,9 +341,7 @@ describe("Catalog", () => {
expect((yield* catalog.model.default())?.id).toBe(old)
configured = false
const reload = yield* catalog.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
yield* catalog.reload()
expect((yield* catalog.model.default())?.id).toBe(newest)
}),
)
+4 -5
View File
@@ -12,11 +12,10 @@ const tick = Effect.gen(function* () {
})
/**
* Drives every pending TestClock timer to completion: stream debounces and
* State's 500ms reload debounce register their sleeps from separate fiber hops
* that a single adjust can miss, so the loop alternates real-macrotask settles
* with adjusts until the condition holds. Extra adjusts are harmless when
* nothing is pending.
* Drives every pending TestClock timer to completion: stream debounces register
* their sleeps from separate fiber hops that a single adjust can miss, so the
* loop alternates real-macrotask settles with adjusts until the condition
* holds. Extra adjusts are harmless when nothing is pending.
*/
export const advance = Effect.fnUntraced(function* (condition: () => boolean) {
for (let attempt = 0; attempt < 100; attempt++) {
+6 -8
View File
@@ -56,6 +56,7 @@ import { TestClock } from "effect/testing"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { ExitCode, makeHandle, ProcessId } from "effect/unstable/process/ChildProcessSpawner"
import { Image } from "@opencode-ai/core/image"
import { advance, drain } from "./lib/clock"
import { testEffect } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
import { location } from "./fixture/location"
@@ -1635,10 +1636,7 @@ shutdownIt.effect("discards in-flight and queued MCP notifications after its lay
const release = yield* Deferred.make<void>()
const root = yield* Scope.make()
yield* Effect.addFinalizer(() =>
Deferred.succeed(release, undefined).pipe(
Effect.andThen(State.shutdown(Scope.close(root, Exit.void))),
Effect.andThen(TestClock.adjust("500 millis")),
),
Deferred.succeed(release, undefined).pipe(Effect.andThen(State.shutdown(Scope.close(root, Exit.void)))),
)
const context = yield* Layer.buildWithScope(Mcp.layer(), root)
const service = Context.get(context, Mcp.Service)
@@ -1669,11 +1667,9 @@ shutdownIt.effect("discards in-flight and queued MCP notifications after its lay
source.url = "https://example.com/first"
source.added = true
const first = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Deferred.await(entered)
source.url = "https://example.com/second"
const second = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
const shutdown = yield* State.shutdown(Scope.close(root, Exit.void)).pipe(
Effect.forkChild({ startImmediately: true }),
@@ -1899,9 +1895,11 @@ testEffect(Layer.empty).effect("coalesces queued MCP tool notifications after in
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["demo_read_1", "execute"])
yield* bus.publish(McpEvent.ToolsChanged, { server: "demo" })
yield* TestClock.adjust("250 millis")
yield* advance(() => reads >= 2)
expect(reads).toBe(2)
yield* Effect.forEach(Array.from({ length: 20 }), () => bus.publish(McpEvent.ToolsChanged, { server: "demo" }))
yield* TestClock.adjust("2 seconds")
yield* advance(() => reads >= 3)
yield* drain
expect(reads).toBe(3)
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["demo_read_3", "execute"])
}).pipe(
+15 -41
View File
@@ -1,7 +1,6 @@
import { describe, expect } from "bun:test"
import { State } from "@opencode-ai/core/state"
import { Cause, Deferred, Effect, Exit, Fiber, Scope } from "effect"
import { TestClock } from "effect/testing"
import { it } from "./lib/effect"
describe("State", () => {
@@ -67,9 +66,7 @@ describe("State", () => {
expect(state.get().values).toEqual(["first"])
value = "second"
const reload = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
yield* state.reload()
expect(state.get().values).toEqual(["second"])
}),
)
@@ -131,7 +128,7 @@ describe("State", () => {
}),
)
it.effect("discards teardown rebuilds and pending reloads while still running cleanup", () =>
it.effect("discards reloads and disposals after shutdown while still running cleanup", () =>
Effect.gen(function* () {
let finalized = 0
let disposed = 0
@@ -148,14 +145,10 @@ describe("State", () => {
const registration = yield* state.transform((draft) => draft.add("value")).pipe(Scope.provide(scope))
expect(finalized).toBe(1)
const pending = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("250 millis")
yield* State.shutdown(Scope.close(scope, Exit.void))
expect(disposed).toBe(1)
expect(finalized).toBe(1)
yield* TestClock.adjust("500 millis")
yield* Fiber.join(pending)
yield* registration.dispose
yield* state.reload()
expect(finalized).toBe(1)
@@ -189,7 +182,7 @@ describe("State", () => {
}),
)
it.effect("debounces reload bursts", () =>
it.effect("notifies once per reload without waiting", () =>
Effect.gen(function* () {
let finalized = 0
const state = State.create({
@@ -202,16 +195,11 @@ describe("State", () => {
})
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)
// Under TestClock nothing time-based can complete, so returning proves no debounce is involved.
yield* state.reload()
expect(finalized).toBe(1)
yield* state.reload()
expect(finalized).toBe(2)
}),
)
})
@@ -412,7 +400,7 @@ describe("State rebuild", () => {
}),
)
it.effect("invalidates on reload and reads new inputs before notification", () =>
it.effect("invalidates on reload and reads new inputs", () =>
Effect.gen(function* () {
let source = 1
let calls = 0
@@ -428,13 +416,9 @@ describe("State rebuild", () => {
})
notifications = 0
source = 2
const reload = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* state.reload()
expect(state.get().value).toBe(2)
expect(calls).toBe(2)
expect(notifications).toBe(0)
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
expect(calls).toBe(2)
expect(notifications).toBe(1)
yield* State.batch(
@@ -622,7 +606,7 @@ describe("State notification boundaries", () => {
}),
)
it.effect("allows a debounced observer to await another reload", () =>
it.effect("allows an observer to await a nested reload", () =>
Effect.gen(function* () {
let source = 1
let reloadAgain = false
@@ -642,9 +626,7 @@ describe("State notification boundaries", () => {
yield* state.transform((draft) => (draft.value = source))
source = 2
reloadAgain = true
const reload = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("1 second")
yield* Fiber.join(reload)
yield* state.reload()
expect(observed).toEqual([1, 2, 3])
}),
)
@@ -680,7 +662,7 @@ describe("State notification boundaries", () => {
}),
)
it.effect("keeps cancelled reload callers independent and shares notification results", () =>
it.effect("surfaces a failed reload notification to its caller and recovers on the next reload", () =>
Effect.gen(function* () {
let fail = false
let notifications = 0
@@ -696,18 +678,10 @@ describe("State notification boundaries", () => {
yield* state.transform(() => {})
notifications = 0
fail = true
const cancelled = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
const first = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
const second = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* Fiber.interrupt(cancelled)
yield* TestClock.adjust("500 millis")
const exits = yield* Fiber.awaitAll([first, second])
fail = false
expect(exits.every(Exit.isFailure)).toBe(true)
expect(Exit.isFailure(yield* state.reload().pipe(Effect.exit))).toBe(true)
expect(notifications).toBe(1)
const recovered = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(recovered)
fail = false
yield* state.reload()
expect(notifications).toBe(2)
}),
)
+6 -19
View File
@@ -10,7 +10,6 @@ import { Tool } from "@opencode-ai/core/tool"
import type { Info } from "@opencode-ai/schema/tool"
import { codeModeListings, executeTool, toolDefinitions } from "./lib/tool"
import { Deferred, Effect, Exit, Fiber, Layer, Logger, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
import { TestClock } from "effect/testing"
import { z } from "zod"
import { testEffect } from "./lib/effect"
@@ -116,9 +115,7 @@ describe("Tool", () => {
expect((yield* executeTool(service, call("acme_echo"))).output).toEqual({ text: "original updated" })
text = "refreshed"
const reload = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
yield* service.reload()
const refreshed = yield* service.snapshot()
expect(refreshed.definitions[0]?.description).toBe("Updated")
expect(refreshed.codeModeCatalog?.tools).toEqual([])
@@ -204,24 +201,18 @@ describe("Tool", () => {
const tool = { ...constant("first"), name: "echo", options: { codemode: false } }
source = [tool]
const first = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(first)
yield* service.reload()
const advertised = yield* service.snapshot()
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
tool.execute = constant("second").execute
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
const second = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(second)
yield* service.reload()
expect((yield* executeTool(service, call("echo"))).output).toEqual({ text: "second" })
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
source = []
const removed = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(removed)
yield* service.reload()
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
}),
@@ -292,9 +283,7 @@ describe("Tool", () => {
expect((yield* executeTool(service, call("echo_tool"))).output).toEqual({ text: "overlay" })
source = [...source, { ...constant("collision"), name: "echo_tool", options: { codemode: false } }]
const collision = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(collision)
yield* service.reload()
expect((yield* executeTool(service, call("echo_tool"))).output).toEqual({ text: "collision" })
yield* registration.dispose
@@ -302,9 +291,7 @@ describe("Tool", () => {
yield* service.transform((draft) => source.forEach((tool) => draft.add(tool)))
source = [{ ...constant("invalid"), name: "", options: { codemode: false } }]
const invalid = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(invalid)
yield* service.reload()
expect((yield* executeTool(service, call("echo_tool"))).output).toEqual({ text: "base" })
}),
)
+1 -6
View File
@@ -296,10 +296,7 @@ describe("Vcs", () => {
const release = yield* Deferred.make<void>()
const root = yield* Scope.make()
yield* Effect.addFinalizer(() =>
Deferred.succeed(release, undefined).pipe(
Effect.andThen(State.shutdown(Scope.close(root, Exit.void))),
Effect.andThen(TestClock.adjust("500 millis")),
),
Deferred.succeed(release, undefined).pipe(Effect.andThen(State.shutdown(Scope.close(root, Exit.void)))),
)
const context = yield* Layer.buildWithScope(
LayerNode.compile(Vcs.node, { replacements: [Bus.node.replace(Layer.succeed(Bus.Service, bus)), here] }),
@@ -343,11 +340,9 @@ describe("Vcs", () => {
block = true
const first = yield* vcs.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Deferred.await(entered)
branch = "late"
const second = yield* vcs.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
expect(reads).toEqual(["initial", "initial"])
expect(first.pollUnsafe()).toBeUndefined()
expect(second.pollUnsafe()).toBeUndefined()
+2 -5
View File
@@ -1,6 +1,5 @@
import { describe, expect } from "bun:test"
import { Effect, Exit, Fiber, Scope } from "effect"
import { TestClock } from "effect/testing"
import { Effect, Exit, Scope } from "effect"
import { KV } from "@opencode-ai/core/kv"
import { WebSearch } from "@opencode-ai/core/websearch"
import { testEffect } from "./lib/effect"
@@ -108,9 +107,7 @@ describe("WebSearch", () => {
expect((yield* websearch.default())?.id).toBe(exa.providerID)
source.providerID = parallel.providerID
const reload = yield* websearch.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
yield* websearch.reload()
expect((yield* websearch.default())?.id).toBe(parallel.providerID)
}),
)