Skip to content

Commit 95b4468

Browse files
Apply PR #18579: effectify Bus service: migrate to Effect PubSub + InstanceState
2 parents a697297 + 211e765 commit 95b4468

37 files changed

Lines changed: 1076 additions & 495 deletions

packages/opencode/AGENTS.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,14 @@ See `specs/effect-migration.md` for the compact pattern reference and examples.
3131
- Use `Schema.Defect` instead of `unknown` for defect-like causes.
3232
- In `Effect.gen` / `Effect.fn`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches.
3333

34-
## Runtime vs Instances
34+
## Runtime vs InstanceState
3535

36-
- Use the shared runtime for process-wide services with one lifecycle for the whole app.
37-
- Use `src/effect/instances.ts` for per-directory or per-project services that need `InstanceContext`, per-instance state, or per-instance cleanup.
38-
- If two open directories should not share one copy of the service, it belongs in `Instances`.
39-
- Instance-scoped services should read context from `InstanceContext`, not `Instance.*` globals.
36+
- Use `makeRuntime` (from `src/effect/run-service.ts`) for all services. It returns `{ runPromise, runFork, runCallback }` backed by a shared `memoMap` that deduplicates layers.
37+
- Use `InstanceState` (from `src/effect/instance-state.ts`) for per-directory or per-project state that needs per-instance cleanup. It uses `ScopedCache` keyed by directory — each open project gets its own state, automatically cleaned up on disposal.
38+
- If two open directories should not share one copy of the service, it needs `InstanceState`.
39+
- Do the work directly in the `InstanceState.make` closure — `ScopedCache` handles run-once semantics. Don't add fibers, `ensure()` callbacks, or `started` flags on top.
40+
- Use `Effect.addFinalizer` or `Effect.acquireRelease` inside the `InstanceState.make` closure for cleanup (subscriptions, process teardown, etc.).
41+
- Use `Effect.forkScoped` inside the closure for background stream consumers — the fiber is interrupted when the instance is disposed.
4042

4143
## Preferred Effect services
4244

@@ -51,7 +53,7 @@ See `specs/effect-migration.md` for the compact pattern reference and examples.
5153

5254
`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and restores it synchronously when called.
5355

54-
Use it for native addon callbacks (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish`, `Instance.state()`, or anything that reads `Instance.directory`.
56+
Use it for native addon callbacks (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish` or anything that reads `Instance.directory`.
5557

5658
You do not need it for `setTimeout`, `Promise.then`, `EventEmitter.on`, or Effect fibers.
5759

packages/opencode/specs/effect-migration.md

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Practical reference for new and migrated Effect code in `packages/opencode`.
66

77
Use `InstanceState` (from `src/effect/instance-state.ts`) for services that need per-directory state, per-instance cleanup, or project-bound background work. InstanceState uses a `ScopedCache` keyed by directory, so each open project gets its own copy of the state that is automatically cleaned up on disposal.
88

9-
Use `makeRunPromise` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`.
9+
Use `makeRuntime` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`. Returns `{ runPromise, runFork, runCallback }`.
1010

1111
- Global services (no per-directory state): Account, Auth, Installation, Truncate
1212
- Instance-scoped (per-directory state via InstanceState): File, FileTime, FileWatcher, Format, Permission, Question, Skill, Snapshot, Vcs, ProviderAuth
@@ -46,7 +46,7 @@ export namespace Foo {
4646
export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
4747

4848
// Per-service runtime (inside the namespace)
49-
const runPromise = makeRunPromise(Service, defaultLayer)
49+
const { runPromise } = makeRuntime(Service, defaultLayer)
5050

5151
// Async facade functions
5252
export async function get(id: FooID) {
@@ -79,29 +79,34 @@ See `Auth.ZodInfo` for the canonical example.
7979

8080
The `InstanceState.make` init callback receives a `Scope`, so you can use `Effect.acquireRelease`, `Effect.addFinalizer`, and `Effect.forkScoped` inside it. Resources acquired this way are automatically cleaned up when the instance is disposed or invalidated by `ScopedCache`. This makes it the right place for:
8181

82-
- **Subscriptions**: Use `Effect.acquireRelease` to subscribe and auto-unsubscribe:
82+
- **Subscriptions**: Yield `Bus.Service` at the layer level, then use `Stream` + `forkScoped` inside the init closure. The fiber is automatically interrupted when the instance scope closes:
8383

8484
```ts
85-
const cache =
86-
yield *
87-
InstanceState.make<State>(
88-
Effect.fn("Foo.state")(function* (ctx) {
89-
// ... load state ...
90-
91-
yield* Effect.acquireRelease(
92-
Effect.sync(() =>
93-
Bus.subscribeAll((event) => {
94-
/* handle */
95-
}),
96-
),
97-
(unsub) => Effect.sync(unsub),
85+
const bus = yield* Bus.Service
86+
87+
const cache = yield* InstanceState.make<State>(
88+
Effect.fn("Foo.state")(function* (ctx) {
89+
// ... load state ...
90+
91+
yield* bus
92+
.subscribeAll()
93+
.pipe(
94+
Stream.runForEach((event) => Effect.sync(() => { /* handle */ })),
95+
Effect.forkScoped,
9896
)
9997

100-
return {
101-
/* state */
102-
}
103-
}),
104-
)
98+
return { /* state */ }
99+
}),
100+
)
101+
```
102+
103+
- **Resource cleanup**: Use `Effect.acquireRelease` or `Effect.addFinalizer` for resources that need teardown (native watchers, process handles, etc.):
104+
105+
```ts
106+
yield* Effect.acquireRelease(
107+
Effect.sync(() => nativeAddon.watch(dir)),
108+
(watcher) => Effect.sync(() => watcher.close()),
109+
)
105110
```
106111

107112
- **Background fibers**: Use `Effect.forkScoped` — the fiber is interrupted on disposal.
@@ -165,7 +170,7 @@ Still open and likely worth migrating:
165170
- [x] `ToolRegistry`
166171
- [ ] `Pty`
167172
- [ ] `Worktree`
168-
- [ ] `Bus`
173+
- [x] `Bus`
169174
- [x] `Command`
170175
- [ ] `Config`
171176
- [ ] `Session`

packages/opencode/src/account/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
22
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
33

4-
import { makeRunPromise } from "@/effect/run-service"
4+
import { makeRuntime } from "@/effect/run-service"
55
import { withTransientReadRetry } from "@/util/effect-http-client"
66
import { AccountRepo, type AccountRow } from "./repo"
77
import {
@@ -379,7 +379,7 @@ export namespace Account {
379379

380380
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
381381

382-
export const runPromise = makeRunPromise(Service, defaultLayer)
382+
export const { runPromise } = makeRuntime(Service, defaultLayer)
383383

384384
export async function active(): Promise<Info | undefined> {
385385
return Option.getOrUndefined(await runPromise((service) => service.active()))

packages/opencode/src/auth/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import path from "path"
22
import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
3-
import { makeRunPromise } from "@/effect/run-service"
3+
import { makeRuntime } from "@/effect/run-service"
44
import { zod } from "@/util/effect-zod"
55
import { Global } from "../global"
66
import { Filesystem } from "../util/filesystem"
@@ -95,7 +95,7 @@ export namespace Auth {
9595
}),
9696
)
9797

98-
const runPromise = makeRunPromise(Service, layer)
98+
const { runPromise } = makeRuntime(Service, layer)
9999

100100
export async function get(providerID: string) {
101101
return runPromise((service) => service.get(providerID))

0 commit comments

Comments
 (0)