Skip to content

Commit 4a710e4

Browse files
committed
fix(core): await plugin readiness
1 parent d2c866b commit 4a710e4

5 files changed

Lines changed: 98 additions & 10 deletions

File tree

packages/core/src/plugin.ts

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export * as PluginV2 from "./plugin"
22

3-
import { Context, Effect, Exit, Layer, Schema, Scope } from "effect"
3+
import { Context, Deferred, Effect, Exit, Layer, Schema, Scope } from "effect"
44
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
55
import { AgentV2 } from "./agent"
66
import { AISDK } from "./aisdk"
@@ -29,6 +29,7 @@ export const Event = {
2929
export interface Interface {
3030
readonly add: (id: ID, effect: Plugin["effect"]) => Effect.Effect<void>
3131
readonly remove: (id: ID) => Effect.Effect<void>
32+
readonly wait: (id: ID) => Effect.Effect<void>
3233
}
3334

3435
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Plugin") {}
@@ -41,13 +42,18 @@ export const layer = Layer.effect(
4142
const scope = yield* Scope.make()
4243
const active = new Map<ID, Scope.Closeable>()
4344
const loading = new Set<ID>()
45+
const waiters = new Map<ID, Set<Deferred.Deferred<void>>>()
46+
const failures = new Map<ID, Exit.Exit<void, never>>()
4447
let host: Parameters<Plugin["effect"]>[0]
4548

4649
const add = Effect.fn("Plugin.add")(function* (id: ID, effect: Plugin["effect"]) {
4750
if (loading.has(id)) return yield* Effect.die(`Plugin load cycle detected for ${id}`)
4851

4952
yield* locks.withLock(id)(
50-
Effect.sync(() => loading.add(id)).pipe(
53+
Effect.sync(() => {
54+
loading.add(id)
55+
failures.delete(id)
56+
}).pipe(
5157
Effect.andThen(
5258
State.batch(
5359
Effect.gen(function* () {
@@ -61,11 +67,22 @@ export const layer = Layer.effect(
6167
Effect.withSpan("Plugin.load", { attributes: { "plugin.id": id } }),
6268
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
6369
)
64-
active.set(id, child)
6570
yield* events.publish(Event.Added, { id })
71+
active.set(id, child)
72+
yield* Effect.forEach(waiters.get(id) ?? [], (waiter) => Deferred.succeed(waiter, undefined), {
73+
discard: true,
74+
})
75+
waiters.delete(id)
6676
}),
6777
),
6878
),
79+
Effect.onExit((exit) => {
80+
if (Exit.isSuccess(exit)) return Effect.void
81+
failures.set(id, exit)
82+
return Effect.forEach(waiters.get(id) ?? [], (waiter) => Deferred.done(waiter, exit), {
83+
discard: true,
84+
}).pipe(Effect.ensuring(Effect.sync(() => waiters.delete(id))))
85+
}),
6986
Effect.ensuring(Effect.sync(() => loading.delete(id))),
7087
),
7188
)
@@ -79,12 +96,41 @@ export const layer = Layer.effect(
7996
Effect.gen(function* () {
8097
const current = active.get(id)
8198
active.delete(id)
99+
failures.delete(id)
82100
if (current) yield* Scope.close(current, Exit.void).pipe(Effect.ignore)
83101
}),
84102
),
85103
)
86104
})
87105

106+
const wait = Effect.fn("Plugin.wait")(function* (id: ID) {
107+
const waiter = yield* Deferred.make<void>()
108+
const pending = yield* locks.withLock(id)(
109+
Effect.sync(() => {
110+
if (active.has(id)) return false
111+
const failure = failures.get(id)
112+
if (failure) return failure
113+
const current = waiters.get(id) ?? new Set()
114+
current.add(waiter)
115+
waiters.set(id, current)
116+
return true
117+
}),
118+
)
119+
if (!pending) return
120+
if (typeof pending !== "boolean") return yield* pending
121+
yield* Deferred.await(waiter).pipe(
122+
Effect.ensuring(
123+
locks.withLock(id)(
124+
Effect.sync(() => {
125+
const current = waiters.get(id)
126+
current?.delete(waiter)
127+
if (current?.size === 0) waiters.delete(id)
128+
}),
129+
),
130+
),
131+
)
132+
})
133+
88134
yield* Effect.addFinalizer((exit) =>
89135
Effect.gen(function* () {
90136
active.clear()
@@ -95,6 +141,7 @@ export const layer = Layer.effect(
95141
const service = Service.of({
96142
add,
97143
remove,
144+
wait,
98145
})
99146
host = yield* PluginHost.make(service)
100147
return service

packages/core/test/plugin.test.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect } from "bun:test"
2-
import { Effect } from "effect"
2+
import { Effect, Exit, Fiber } from "effect"
33
import { define } from "@opencode-ai/plugin/v2/effect"
44
import { AgentV2 } from "@opencode-ai/core/agent"
55
import { PluginV2 } from "@opencode-ai/core/plugin"
@@ -9,6 +9,34 @@ import { PluginTestLayer } from "./plugin/fixture"
99
const it = testEffect(PluginTestLayer)
1010

1111
describe("PluginV2", () => {
12+
it.effect("waits for a plugin and returns immediately once active", () =>
13+
Effect.gen(function* () {
14+
const plugins = yield* PluginV2.Service
15+
const id = PluginV2.ID.make("waited")
16+
const waiting = yield* plugins.wait(id).pipe(Effect.forkChild)
17+
18+
yield* plugins.add(id, () => Effect.void)
19+
yield* Fiber.join(waiting)
20+
yield* plugins.wait(id)
21+
}),
22+
)
23+
24+
it.effect("propagates plugin activation defects to waiters", () =>
25+
Effect.gen(function* () {
26+
const plugins = yield* PluginV2.Service
27+
const id = PluginV2.ID.make("failed")
28+
const waiting = yield* plugins.wait(id).pipe(Effect.exit, Effect.forkChild)
29+
30+
const added = yield* plugins.add(id, () => Effect.die("boom")).pipe(Effect.exit)
31+
const pending = yield* Fiber.join(waiting)
32+
const later = yield* plugins.wait(id).pipe(Effect.exit)
33+
34+
expect(Exit.isFailure(added)).toBe(true)
35+
expect(Exit.isFailure(pending)).toBe(true)
36+
expect(Exit.isFailure(later)).toBe(true)
37+
}),
38+
)
39+
1240
it.effect("adds, replaces, and removes plugins", () =>
1341
Effect.gen(function* () {
1442
const plugins = yield* PluginV2.Service

packages/opencode/src/agent/agent.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { ModelV2 } from "@opencode-ai/core/model"
3030
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
3131
import { Reference } from "@opencode-ai/core/reference"
3232
import { Location } from "@opencode-ai/core/location"
33+
import { PluginV2 } from "@opencode-ai/core/plugin"
3334

3435
export const Info = Schema.Struct({
3536
name: Schema.String,
@@ -99,6 +100,7 @@ export const layer = Layer.effect(
99100
const cfg = yield* config.get()
100101
const skillDirs = yield* skill.dirs()
101102
const referenceDirs = yield* Effect.gen(function* () {
103+
yield* (yield* PluginV2.Service).wait(PluginV2.ID.make("core/config-reference"))
102104
return (yield* (yield* Reference.Service).list()).map((reference) => reference.path)
103105
}).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) }))))
104106
const whitelistedDirs = [

packages/opencode/src/session/system.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
2020
import { Location } from "@opencode-ai/core/location"
2121
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
2222
import { Reference } from "@opencode-ai/core/reference"
23+
import { PluginV2 } from "@opencode-ai/core/plugin"
2324

2425
export function provider(model: Provider.Model) {
2526
if (model.api.id.includes("gpt-4") || model.api.id.includes("o1") || model.api.id.includes("o3"))
@@ -54,6 +55,7 @@ export const layer = Layer.effect(
5455
environment: Effect.fn("SystemPrompt.environment")(function* (model: Provider.Model) {
5556
const ctx = yield* InstanceState.context
5657
const references = yield* Effect.gen(function* () {
58+
yield* (yield* PluginV2.Service).wait(PluginV2.ID.make("core/config-reference"))
5759
return (yield* (yield* Reference.Service).list()).filter((reference) => reference.description !== undefined)
5860
}).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) }))))
5961
return [

packages/opencode/test/server/httpapi-reference.test.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { Server } from "../../src/server/server"
44
import { Global } from "@opencode-ai/core/global"
55
import { resetDatabase } from "../fixture/db"
66
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
7+
import { Effect } from "effect"
8+
import { pollWithTimeout } from "../lib/effect"
79

810
afterEach(async () => {
911
await disposeAllInstances()
@@ -24,12 +26,19 @@ describe("reference HttpApi", () => {
2426
},
2527
})
2628

27-
const response = await Server.Default().app.request("/api/reference", {
28-
headers: { "x-opencode-directory": tmp.path },
29-
})
30-
31-
expect(response.status).toBe(200)
32-
const body = await response.json()
29+
const body = await Effect.runPromise(
30+
pollWithTimeout(
31+
Effect.promise(async () => {
32+
const response = await Server.Default().app.request("/api/reference", {
33+
headers: { "x-opencode-directory": tmp.path },
34+
})
35+
expect(response.status).toBe(200)
36+
const body = await response.json()
37+
return body.data.length === 0 ? undefined : body
38+
}),
39+
"references were not loaded",
40+
),
41+
)
3342
expect(body).toMatchObject({ location: { directory: tmp.path } })
3443
expect(body.data).toEqual([
3544
{

0 commit comments

Comments
 (0)