|
| 1 | +# Instance context migration |
| 2 | + |
| 3 | +Practical plan for retiring the promise-backed / ALS-backed `Instance` helper in `src/project/instance.ts` and moving instance selection fully into Effect-provided scope. |
| 4 | + |
| 5 | +## Goal |
| 6 | + |
| 7 | +End state: |
| 8 | + |
| 9 | +- request, CLI, TUI, and tool entrypoints shift into an instance through Effect, not `Instance.provide(...)` |
| 10 | +- Effect code reads the current instance from `InstanceRef` or its eventual replacement, not from ALS-backed sync getters |
| 11 | +- per-directory boot, caching, and disposal are scoped Effect resources, not a module-level `Map<string, Promise<InstanceContext>>` |
| 12 | +- ALS remains only as a temporary bridge for native callback APIs that fire outside the Effect fiber tree |
| 13 | + |
| 14 | +## Current split |
| 15 | + |
| 16 | +Today `src/project/instance.ts` still owns two separate concerns: |
| 17 | + |
| 18 | +- ambient current-instance context through `LocalContext` / `AsyncLocalStorage` |
| 19 | +- per-directory boot and deduplication through `cache: Map<string, Promise<InstanceContext>>` |
| 20 | + |
| 21 | +At the same time, the Effect side already exists: |
| 22 | + |
| 23 | +- `src/effect/instance-ref.ts` provides `InstanceRef` and `WorkspaceRef` |
| 24 | +- `src/effect/run-service.ts` already attaches those refs when a runtime starts inside an active instance ALS context |
| 25 | +- `src/effect/instance-state.ts` already prefers `InstanceRef` and only falls back to ALS when needed |
| 26 | + |
| 27 | +That means the migration is not "invent instance context in Effect". The migration is "stop relying on the legacy helper as the primary source of truth". |
| 28 | + |
| 29 | +## End state shape |
| 30 | + |
| 31 | +Near-term target shape: |
| 32 | + |
| 33 | +```ts |
| 34 | +InstanceScope.with({ directory, workspaceID }, effect) |
| 35 | +``` |
| 36 | + |
| 37 | +Responsibilities of `InstanceScope.with(...)`: |
| 38 | + |
| 39 | +- resolve `directory`, `project`, and `worktree` |
| 40 | +- acquire or reuse the scoped per-directory instance environment |
| 41 | +- provide `InstanceRef` and `WorkspaceRef` |
| 42 | +- run the caller's Effect inside that environment |
| 43 | + |
| 44 | +Code inside the boundary should then do one of these: |
| 45 | + |
| 46 | +```ts |
| 47 | +const ctx = yield * InstanceState.context |
| 48 | +const dir = yield * InstanceState.directory |
| 49 | +``` |
| 50 | + |
| 51 | +Long-term, once `InstanceState` itself is replaced by keyed layers / `LayerMap`, those reads can move to an `InstanceContext` service without changing the outer migration order. |
| 52 | + |
| 53 | +## Migration phases |
| 54 | + |
| 55 | +### Phase 1: stop expanding the legacy surface |
| 56 | + |
| 57 | +Rules for all new code: |
| 58 | + |
| 59 | +- do not add new `Instance.directory`, `Instance.worktree`, `Instance.project`, or `Instance.current` reads inside Effect code |
| 60 | +- do not add new `Instance.provide(...)` boundaries unless there is no Effect-native seam yet |
| 61 | +- use `InstanceState.context`, `InstanceState.directory`, or an explicit `ctx` parameter inside Effect code |
| 62 | + |
| 63 | +Success condition: |
| 64 | + |
| 65 | +- the file inventory below only shrinks from here |
| 66 | + |
| 67 | +### Phase 2: remove direct sync getter reads from Effect services |
| 68 | + |
| 69 | +Convert Effect services first, before replacing the top-level boundary. These modules already run inside Effect and mostly need `yield* InstanceState.context` or a yielded `ctx` instead of ambient sync access. |
| 70 | + |
| 71 | +Primary batch, highest payoff: |
| 72 | + |
| 73 | +- `src/file/index.ts` |
| 74 | +- `src/lsp/server.ts` |
| 75 | +- `src/worktree/index.ts` |
| 76 | +- `src/file/watcher.ts` |
| 77 | +- `src/format/formatter.ts` |
| 78 | +- `src/session/index.ts` |
| 79 | +- `src/project/vcs.ts` |
| 80 | + |
| 81 | +Mechanical replacement rule: |
| 82 | + |
| 83 | +- `Instance.directory` -> `ctx.directory` or `yield* InstanceState.directory` |
| 84 | +- `Instance.worktree` -> `ctx.worktree` |
| 85 | +- `Instance.project` -> `ctx.project` |
| 86 | + |
| 87 | +Do not thread strings manually through every public method if the service already has access to Effect context. |
| 88 | + |
| 89 | +### Phase 3: convert entry boundaries to provide instance refs directly |
| 90 | + |
| 91 | +After the service bodies stop assuming ALS, move the top-level boundaries to shift into Effect explicitly. |
| 92 | + |
| 93 | +Main boundaries: |
| 94 | + |
| 95 | +- HTTP server middleware and experimental `HttpApi` entrypoints |
| 96 | +- CLI commands |
| 97 | +- TUI worker / attach / thread entrypoints |
| 98 | +- tool execution entrypoints |
| 99 | + |
| 100 | +These boundaries should become Effect-native wrappers that: |
| 101 | + |
| 102 | +- decode directory / workspace inputs |
| 103 | +- resolve the instance context once |
| 104 | +- provide `InstanceRef` and `WorkspaceRef` |
| 105 | +- run the requested Effect |
| 106 | + |
| 107 | +At that point `Instance.provide(...)` becomes a legacy adapter instead of the normal code path. |
| 108 | + |
| 109 | +### Phase 4: replace promise boot cache with scoped instance runtime |
| 110 | + |
| 111 | +Once boundaries and services both rely on Effect context, replace the module-level promise cache in `src/project/instance.ts`. |
| 112 | + |
| 113 | +Target replacement: |
| 114 | + |
| 115 | +- keyed scoped runtime or keyed layer acquisition for each directory |
| 116 | +- reuse via `ScopedCache`, `LayerMap`, or another keyed Effect resource manager |
| 117 | +- cleanup performed by scope finalizers instead of `disposeAll()` iterating a Promise map |
| 118 | + |
| 119 | +This phase should absorb the current responsibilities of: |
| 120 | + |
| 121 | +- `cache` in `src/project/instance.ts` |
| 122 | +- `boot(...)` |
| 123 | +- most of `disposeInstance(...)` |
| 124 | +- manual `reload(...)` / `disposeAll()` fan-out logic |
| 125 | + |
| 126 | +### Phase 5: shrink ALS to callback bridges only |
| 127 | + |
| 128 | +Keep ALS only where a library invokes callbacks outside the Effect fiber tree and we still need to call code that reads instance context synchronously. |
| 129 | + |
| 130 | +Known bridge cases today: |
| 131 | + |
| 132 | +- `src/file/watcher.ts` |
| 133 | +- `src/session/llm.ts` |
| 134 | +- some LSP and plugin callback paths |
| 135 | + |
| 136 | +If those libraries become fully wrapped in Effect services, the remaining `Instance.bind(...)` uses can disappear too. |
| 137 | + |
| 138 | +### Phase 6: delete the legacy sync API |
| 139 | + |
| 140 | +Only after earlier phases land: |
| 141 | + |
| 142 | +- remove broad use of `Instance.current`, `Instance.directory`, `Instance.worktree`, `Instance.project` |
| 143 | +- reduce `src/project/instance.ts` to a thin compatibility shim or delete it entirely |
| 144 | +- remove the ALS fallback from `InstanceState.context` |
| 145 | + |
| 146 | +## Inventory of direct legacy usage |
| 147 | + |
| 148 | +Direct legacy usage means any source file that still calls one of: |
| 149 | + |
| 150 | +- `Instance.current` |
| 151 | +- `Instance.directory` |
| 152 | +- `Instance.worktree` |
| 153 | +- `Instance.project` |
| 154 | +- `Instance.provide(...)` |
| 155 | +- `Instance.bind(...)` |
| 156 | +- `Instance.restore(...)` |
| 157 | +- `Instance.reload(...)` |
| 158 | +- `Instance.dispose()` / `Instance.disposeAll()` |
| 159 | + |
| 160 | +Current total: `54` files in `packages/opencode/src`. |
| 161 | + |
| 162 | +### Core bridge and plumbing |
| 163 | + |
| 164 | +These files define or adapt the current bridge. They should change last, after callers have moved. |
| 165 | + |
| 166 | +- `src/project/instance.ts` |
| 167 | +- `src/effect/run-service.ts` |
| 168 | +- `src/effect/instance-state.ts` |
| 169 | +- `src/project/bootstrap.ts` |
| 170 | +- `src/config/config.ts` |
| 171 | + |
| 172 | +Migration rule: |
| 173 | + |
| 174 | +- keep these as compatibility glue until the outer boundaries and inner services stop depending on ALS |
| 175 | + |
| 176 | +### HTTP and server boundaries |
| 177 | + |
| 178 | +These are the current request-entry seams that still create or consume instance context through the legacy helper. |
| 179 | + |
| 180 | +- `src/server/instance/middleware.ts` |
| 181 | +- `src/server/instance/index.ts` |
| 182 | +- `src/server/instance/project.ts` |
| 183 | +- `src/server/instance/workspace.ts` |
| 184 | +- `src/server/instance/file.ts` |
| 185 | +- `src/server/instance/experimental.ts` |
| 186 | +- `src/server/instance/global.ts` |
| 187 | + |
| 188 | +Migration rule: |
| 189 | + |
| 190 | +- move these to explicit Effect entrypoints that provide `InstanceRef` / `WorkspaceRef` |
| 191 | +- do not move these first; first reduce the number of downstream handlers and services that still expect ambient ALS |
| 192 | + |
| 193 | +### CLI and TUI boundaries |
| 194 | + |
| 195 | +These commands still enter an instance through `Instance.provide(...)` or read sync getters directly. |
| 196 | + |
| 197 | +- `src/cli/bootstrap.ts` |
| 198 | +- `src/cli/cmd/agent.ts` |
| 199 | +- `src/cli/cmd/debug/agent.ts` |
| 200 | +- `src/cli/cmd/debug/ripgrep.ts` |
| 201 | +- `src/cli/cmd/github.ts` |
| 202 | +- `src/cli/cmd/import.ts` |
| 203 | +- `src/cli/cmd/mcp.ts` |
| 204 | +- `src/cli/cmd/models.ts` |
| 205 | +- `src/cli/cmd/plug.ts` |
| 206 | +- `src/cli/cmd/pr.ts` |
| 207 | +- `src/cli/cmd/providers.ts` |
| 208 | +- `src/cli/cmd/stats.ts` |
| 209 | +- `src/cli/cmd/tui/attach.ts` |
| 210 | +- `src/cli/cmd/tui/plugin/runtime.ts` |
| 211 | +- `src/cli/cmd/tui/thread.ts` |
| 212 | +- `src/cli/cmd/tui/worker.ts` |
| 213 | + |
| 214 | +Migration rule: |
| 215 | + |
| 216 | +- converge these on one shared `withInstance(...)` Effect entry helper instead of open-coded `Instance.provide(...)` |
| 217 | +- after that helper is proven, inline the legacy implementation behind an Effect-native scope provider |
| 218 | + |
| 219 | +### Tool boundary code |
| 220 | + |
| 221 | +These tools mostly use direct getters for path resolution and repo-relative display logic. |
| 222 | + |
| 223 | +- `src/tool/apply_patch.ts` |
| 224 | +- `src/tool/bash.ts` |
| 225 | +- `src/tool/edit.ts` |
| 226 | +- `src/tool/lsp.ts` |
| 227 | +- `src/tool/multiedit.ts` |
| 228 | +- `src/tool/plan.ts` |
| 229 | +- `src/tool/read.ts` |
| 230 | +- `src/tool/write.ts` |
| 231 | + |
| 232 | +Migration rule: |
| 233 | + |
| 234 | +- expose the current instance as an explicit Effect dependency for tool execution |
| 235 | +- keep path logic local; avoid introducing another global singleton for tool state |
| 236 | + |
| 237 | +### Effect services still reading ambient instance state |
| 238 | + |
| 239 | +These modules are already the best near-term migration targets because they are in Effect code but still read sync getters from the legacy helper. |
| 240 | + |
| 241 | +- `src/agent/agent.ts` |
| 242 | +- `src/config/tui-migrate.ts` |
| 243 | +- `src/file/index.ts` |
| 244 | +- `src/file/watcher.ts` |
| 245 | +- `src/format/formatter.ts` |
| 246 | +- `src/lsp/client.ts` |
| 247 | +- `src/lsp/index.ts` |
| 248 | +- `src/lsp/server.ts` |
| 249 | +- `src/mcp/index.ts` |
| 250 | +- `src/project/vcs.ts` |
| 251 | +- `src/provider/provider.ts` |
| 252 | +- `src/pty/index.ts` |
| 253 | +- `src/session/index.ts` |
| 254 | +- `src/session/instruction.ts` |
| 255 | +- `src/session/llm.ts` |
| 256 | +- `src/session/system.ts` |
| 257 | +- `src/sync/index.ts` |
| 258 | +- `src/worktree/index.ts` |
| 259 | + |
| 260 | +Migration rule: |
| 261 | + |
| 262 | +- replace direct getter reads with `yield* InstanceState.context` or a yielded `ctx` |
| 263 | +- isolate `Instance.bind(...)` callers and convert only the truly callback-driven edges to bridge mode |
| 264 | + |
| 265 | +### Highest-churn hotspots |
| 266 | + |
| 267 | +Current highest direct-usage counts by file: |
| 268 | + |
| 269 | +- `src/file/index.ts` - `18` |
| 270 | +- `src/lsp/server.ts` - `14` |
| 271 | +- `src/worktree/index.ts` - `12` |
| 272 | +- `src/file/watcher.ts` - `9` |
| 273 | +- `src/cli/cmd/mcp.ts` - `8` |
| 274 | +- `src/format/formatter.ts` - `8` |
| 275 | +- `src/tool/apply_patch.ts` - `8` |
| 276 | +- `src/cli/cmd/github.ts` - `7` |
| 277 | + |
| 278 | +These files should drive the first measurable burn-down. |
| 279 | + |
| 280 | +## Recommended implementation order |
| 281 | + |
| 282 | +1. Migrate direct getter reads inside Effect services, starting with `file`, `lsp`, `worktree`, `format`, and `session`. |
| 283 | +2. Add one shared Effect-native boundary helper for CLI / tool / HTTP entrypoints so we stop open-coding `Instance.provide(...)`. |
| 284 | +3. Move experimental `HttpApi` entrypoints to that helper so the new server stack proves the pattern. |
| 285 | +4. Convert remaining CLI and tool boundaries. |
| 286 | +5. Replace the promise cache with a keyed scoped runtime or keyed layer map. |
| 287 | +6. Delete ALS fallback paths once only callback bridges still depend on them. |
| 288 | + |
| 289 | +## Definition of done |
| 290 | + |
| 291 | +This migration is done when all of the following are true: |
| 292 | + |
| 293 | +- new requests and commands enter an instance by providing Effect context, not ALS |
| 294 | +- Effect services no longer read `Instance.directory`, `Instance.worktree`, `Instance.project`, or `Instance.current` |
| 295 | +- `Instance.provide(...)` is gone from normal request / CLI / tool execution |
| 296 | +- per-directory boot and disposal are handled by scoped Effect resources |
| 297 | +- `Instance.bind(...)` is either gone or confined to a tiny set of native callback adapters |
| 298 | + |
| 299 | +## Tracker and worktree |
| 300 | + |
| 301 | +Active tracker items: |
| 302 | + |
| 303 | +- `lh7l73` - overall `HttpApi` migration |
| 304 | +- `yobwlk` - remove direct `Instance.*` reads inside Effect services |
| 305 | +- `7irl1e` - replace `InstanceState` / legacy instance caching with keyed Effect layers |
| 306 | + |
| 307 | +Dedicated worktree for this transition: |
| 308 | + |
| 309 | +- path: `/Users/kit/code/open-source/opencode-worktrees/instance-effect-shift` |
| 310 | +- branch: `kit/instance-effect-shift` |
0 commit comments