Skip to content

Commit 83a407f

Browse files
committed
refactor(config): move disposal to lifecycle boundaries
1 parent de0321b commit 83a407f

18 files changed

Lines changed: 509 additions & 107 deletions

File tree

Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
# Config Lifecycle Plan
2+
3+
## Goal
4+
5+
Remove instance disposal from `Config` so config loading/writing stays a pure config concern and runtime lifecycle invalidation happens at the caller/orchestration boundary.
6+
7+
This specifically removes the need for `Config` to import or lazily import `InstanceRuntime`.
8+
9+
## Current Coupling
10+
11+
`src/config/config.ts` currently does three separate things:
12+
13+
1. Load and cache global config.
14+
2. Load, merge, and write project/global config files.
15+
3. Dispose instances when config changes.
16+
17+
The third responsibility is the problem.
18+
19+
Current disposal paths:
20+
21+
1. `Config.update(config)` writes project `config.json`, then disposes the active instance unless `options.dispose === false`.
22+
2. `Config.updateGlobal(config)` writes global config, then calls `Config.invalidate()` if the file changed.
23+
3. `Config.invalidate(wait)` invalidates the global config cache, disposes all instances, and emits a global disposed event.
24+
25+
## Desired Ownership
26+
27+
`Config` should own:
28+
29+
1. Reading config files.
30+
2. Parsing and merging config.
31+
3. Writing project/global config files.
32+
4. Invalidating only its own global config cache.
33+
34+
Callers should own:
35+
36+
1. Disposing the current instance after a project config update.
37+
2. Disposing all instances after a global config update or explicit reload.
38+
3. Emitting server/global lifecycle events after disposal.
39+
40+
## Concrete API Changes
41+
42+
### `src/config/config.ts`
43+
44+
1. Remove `loadInstanceRuntime()`.
45+
2. Remove `InstanceRuntime`/`InstanceStore`/lifecycle imports from config.
46+
3. Change `Interface.update` from:
47+
48+
```ts
49+
readonly update: (config: Info, options?: { dispose?: boolean }) => Effect.Effect<void>
50+
```
51+
52+
to:
53+
54+
```ts
55+
readonly update: (config: Info) => Effect.Effect<void>
56+
```
57+
58+
4. Change `Config.update` implementation to only write the project `config.json`.
59+
5. Change `Interface.invalidate` to a config-only cache invalidation method, or rename it for clarity.
60+
61+
Preferred final shape:
62+
63+
```ts
64+
readonly invalidate: () => Effect.Effect<void>
65+
```
66+
67+
`invalidate()` should only run `invalidateGlobal`.
68+
69+
6. Change `Config.updateGlobal` to write global config, invalidate only config-global cache when changed, and return whether the file changed.
70+
71+
Preferred final shape:
72+
73+
```ts
74+
readonly updateGlobal: (config: Info) => Effect.Effect<{ info: Info; changed: boolean }>
75+
```
76+
77+
Implementation detail:
78+
79+
```ts
80+
if (changed) yield* invalidate()
81+
return { info: next, changed }
82+
```
83+
84+
Public API routes should still return only `result.info`; `changed` is for lifecycle orchestration only.
85+
86+
## Caller Updates
87+
88+
### Legacy instance config route
89+
90+
File: `src/server/routes/instance/config.ts`
91+
92+
Current:
93+
94+
```ts
95+
const cfg = yield* Config.Service
96+
yield* cfg.update(config)
97+
return config
98+
```
99+
100+
Change to:
101+
102+
```ts
103+
const cfg = yield* Config.Service
104+
yield* cfg.update(config)
105+
const store = yield* InstanceStore.Service
106+
yield* store.dispose(Instance.current)
107+
return config
108+
```
109+
110+
Imports needed:
111+
112+
```ts
113+
import { Instance } from "@/project/instance"
114+
import { InstanceStore } from "@/project/instance-store"
115+
```
116+
117+
Rationale: this route is an instance-scoped orchestration boundary, so it should own the instance disposal after writing project config.
118+
119+
### Effect HttpApi instance config route
120+
121+
File: `src/server/routes/instance/httpapi/handlers/config.ts`
122+
123+
Current:
124+
125+
```ts
126+
yield* configSvc.update(ctx.payload, { dispose: false })
127+
yield* markInstanceForDisposal(yield* InstanceState.context)
128+
return ctx.payload
129+
```
130+
131+
Change to:
132+
133+
```ts
134+
yield* configSvc.update(ctx.payload)
135+
yield* markInstanceForDisposal(yield* InstanceState.context)
136+
return ctx.payload
137+
```
138+
139+
Rationale: this route already has correct ownership. It writes config first, then delegates disposal to HttpApi lifecycle middleware.
140+
141+
### Legacy global config route
142+
143+
File: `src/server/routes/global.ts`
144+
145+
Current:
146+
147+
```ts
148+
const next = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.updateGlobal(config)))
149+
return c.json(next)
150+
```
151+
152+
Change to run config write, then if the file changed, schedule the same dispose-all/global-disposed side effect that `Config.invalidate(false)` currently schedules.
153+
154+
Important behavior to preserve:
155+
156+
1. Do not dispose instances when the serialized global config did not change.
157+
2. Do not make the HTTP response wait for instance disposal. Current `updateGlobal -> invalidate()` schedules disposal asynchronously when `wait` is omitted.
158+
159+
Preferred implementation shape:
160+
161+
```ts
162+
const result = await AppRuntime.runPromise(
163+
Effect.gen(function* () {
164+
const cfg = yield* Config.Service
165+
return yield* cfg.updateGlobal(config)
166+
}),
167+
)
168+
if (result.changed) void AppRuntime.runPromise(disposeAllInstancesAndEmitGlobalDisposed).catch(() => undefined)
169+
return c.json(result.info)
170+
```
171+
172+
Imports needed:
173+
174+
```ts
175+
import { Effect } from "effect"
176+
import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle"
177+
```
178+
179+
`src/server/routes/global.ts` already defines `GlobalDisposedEvent`; move that event definition to the shared helper module or re-export it from there so `/dispose`, legacy global config update, and HttpApi global config update use one event source.
180+
181+
### Effect HttpApi global config route
182+
183+
File: `src/server/routes/instance/httpapi/handlers/global.ts`
184+
185+
Current:
186+
187+
```ts
188+
return yield* config.updateGlobal(ctx.payload)
189+
```
190+
191+
Change to preserve the existing changed-only and async-disposal semantics:
192+
193+
```ts
194+
const result = yield* config.updateGlobal(ctx.payload)
195+
if (result.changed) bridge.fork(disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true }))
196+
return result.info
197+
```
198+
199+
Imports needed:
200+
201+
```ts
202+
import { EffectBridge } from "@/effect/bridge"
203+
import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle"
204+
```
205+
206+
Also yield a stable bridge at handler construction:
207+
208+
```ts
209+
const bridge = yield* EffectBridge.make()
210+
```
211+
212+
Do not use `Effect.forkScoped` for this fire-and-forget disposal; the request scope can close before disposal finishes.
213+
214+
`src/server/routes/instance/httpapi/handlers/global.ts` already yields `InstanceStore.Service` for `/dispose`. Keep `/dispose` strict, or use the shared helper with `swallowErrors: false` so explicit disposal failures still surface.
215+
216+
### TUI worker reload
217+
218+
File: `src/cli/cmd/tui/worker.ts`
219+
220+
Current:
221+
222+
```ts
223+
await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.invalidate(true)))
224+
```
225+
226+
Change to:
227+
228+
```ts
229+
await AppRuntime.runPromise(
230+
Effect.gen(function* () {
231+
const cfg = yield* Config.Service
232+
const store = yield* InstanceStore.Service
233+
yield* cfg.invalidate()
234+
yield* store.disposeAll()
235+
}),
236+
)
237+
```
238+
239+
Imports needed:
240+
241+
```ts
242+
import { Effect } from "effect"
243+
import { InstanceStore } from "@/project/instance-store"
244+
```
245+
246+
No global disposed event is required here unless existing TUI behavior depends on it. The current worker path only calls `Config.invalidate(true)` and does not directly interact with server event streams.
247+
248+
## Helper Extraction
249+
250+
If both global routes need identical "dispose all and emit global disposed" behavior, extract a helper outside `Config`.
251+
252+
Preferred location:
253+
254+
`src/server/global-lifecycle.ts`
255+
256+
Suggested helper:
257+
258+
```ts
259+
export const emitGlobalDisposed = Effect.sync(() =>
260+
GlobalBus.emit("event", {
261+
directory: "global",
262+
payload: {
263+
type: Event.Disposed.type,
264+
properties: {},
265+
},
266+
}),
267+
)
268+
269+
export const disposeAllInstancesAndEmitGlobalDisposed = Effect.fn("Server.disposeAllInstancesAndEmitGlobalDisposed")(function* (options?: {
270+
swallowErrors?: boolean
271+
}) {
272+
const store = yield* InstanceStore.Service
273+
const dispose = store.disposeAll()
274+
yield* (options?.swallowErrors ? dispose.pipe(Effect.catch(() => Effect.void)) : dispose)
275+
yield* emitGlobalDisposed
276+
})
277+
```
278+
279+
Use this helper only from server/global route code and explicit reload/dispose orchestration. Do not import it into `Config`.
280+
281+
Use `swallowErrors: true` only for paths that previously swallowed disposal errors, such as config invalidation. Keep explicit `/dispose` strict by omitting `swallowErrors`.
282+
283+
## Tests To Update
284+
285+
### Config tests
286+
287+
File: `test/config/config.test.ts`
288+
289+
1. `save(...)`, `saveGlobal(...)`, and `clear(...)` helpers should still run against `Config.layer` only.
290+
2. They should not need `InstanceRuntime`, `InstanceStore`, or no-op lifecycle mocks.
291+
3. Existing config tests should continue to pass because config no longer disposes instances internally.
292+
293+
### TUI config tests
294+
295+
File: `test/config/tui.test.ts`
296+
297+
1. The `clear` helper currently calls `Config.invalidate` through `AppRuntime`.
298+
2. After `invalidate()` is config-only, this is fine and should not dispose instances.
299+
300+
### Route behavior tests
301+
302+
Add or update focused tests for lifecycle ownership:
303+
304+
1. Legacy instance config route disposes only the active instance after project config update.
305+
2. HttpApi instance config route still marks the active instance for disposal after project config update.
306+
3. Legacy global config route disposes all instances after global config update.
307+
4. HttpApi global config route disposes all instances after global config update.
308+
5. Global config routes do not dispose instances when the config write is a no-op.
309+
310+
Prefer existing route tests if they already cover config update behavior. Do not add broad integration tests unless necessary.
311+
312+
Suggested new focused files if no existing test has the right harness:
313+
314+
1. `test/server/global-config.test.ts` for legacy Hono global config update lifecycle.
315+
2. `test/server/httpapi-global-config.test.ts` for Effect HttpApi global config update lifecycle.
316+
317+
## Verification Commands
318+
319+
Run from `packages/opencode`:
320+
321+
```bash
322+
bun typecheck
323+
bun run test test/config/config.test.ts test/config/tui.test.ts
324+
bun run test test/server/httpapi-config.test.ts test/server/httpapi-instance-context.test.ts test/server/httpapi-bridge.test.ts
325+
bun run test test/server/global-config.test.ts test/server/httpapi-global-config.test.ts
326+
bun run test test/project/instance-bootstrap-regression.test.ts test/agent/plugin-agent-regression.test.ts test/project/instance.test.ts
327+
env -u OPENCODE_EXPERIMENTAL_WORKSPACES bun run test
328+
```
329+
330+
## Non-Goals
331+
332+
1. Do not remove `InstanceRuntime` entirely in this change. It is still needed for legacy Promise/ALS callers.
333+
2. Do not change `InstanceStore` bootstrap ownership.
334+
3. Do not change config parsing/merging semantics.
335+
4. Do not make `Config.layer` depend on `InstanceStore.Service`.
336+
337+
## Expected End State
338+
339+
1. `src/config/config.ts` has no import or dynamic import of `InstanceRuntime`, `InstanceStore`, or server lifecycle helpers.
340+
2. `Config.update` and `Config.updateGlobal` only write config and invalidate config-owned caches.
341+
3. Instance disposal is visible at route/worker orchestration boundaries.
342+
4. Tests that exercise config parsing/writing no longer need special lifecycle stubs.

packages/opencode/src/cli/cmd/tui/worker.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import { writeHeapSnapshot } from "node:v8"
1212
import { Heap } from "@/cli/heap"
1313
import { AppRuntime } from "@/effect/app-runtime"
1414
import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process"
15+
import { Effect } from "effect"
16+
import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle"
1517

1618
ensureProcessMetadata("worker")
1719

@@ -83,7 +85,13 @@ export const rpc = {
8385
})
8486
},
8587
async reload() {
86-
await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.invalidate(true)))
88+
await AppRuntime.runPromise(
89+
Effect.gen(function* () {
90+
const cfg = yield* Config.Service
91+
yield* cfg.invalidate()
92+
yield* disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true })
93+
}),
94+
)
8795
},
8896
async shutdown() {
8997
Log.Default.info("worker shutting down")

0 commit comments

Comments
 (0)