Skip to content

fix(cli): bridge Instance.current ALS in effectCmd handlers (regression from #25522)#25546

Merged
kitlangton merged 3 commits intodevfrom
kit/effectcmd-als-bridge-fix
May 3, 2026
Merged

fix(cli): bridge Instance.current ALS in effectCmd handlers (regression from #25522)#25546
kitlangton merged 3 commits intodevfrom
kit/effectcmd-als-bridge-fix

Conversation

@kitlangton
Copy link
Copy Markdown
Contributor

Summary

Fixes a latent breakage introduced in PR #25522 (the original effectCmd conversion of github.ts). Any handler that does yield* Effect.promise(async () => { ... await AppRuntime.runPromise(svc.method()) ... }) loses the outer InstanceRef for the inner Effect run.

Root cause

Effect's fiber context propagates only through Effect's own scheduling. Inside an async callback after the first await, Fiber.getCurrent() returns undefined, so attach() in run-service.ts can't recover the outer fiber's InstanceRef. The inner Effect then runs with InstanceRef defaulting to undefined, and InstanceState.context falls through to Instance.current ALS — which the new effectCmd was no longer setting (the legacy bootstrap() path used to via WithInstance.provide).

End result: services that yield InstanceState.context (e.g. Session.create, SessionShare.share, SessionPrompt.prompt, Agent.get) die with LocalContext.NotFound when invoked from inside an Effect.promise(async) block in any effectCmd handler. This broke opencode github run, which exercises exactly that pattern.

The bug was caught by a review-agent audit of an unrelated stage-4 PR (#25539). Empirically confirmed with a focused test (see below).

Fix

In effectCmd, split load and body into two AppRuntime.runPromise calls so the body can run inside Instance.restore(ctx, ...). Node's AsyncLocalStorage persists across awaits, so any nested AppRuntime.runPromise inside an async closure now recovers the instance via attach()'s ALS fallback. Restores pre-#25522 behavior without giving up the Effect-context InstanceRef on the synchronous path.

// before
await AppRuntime.runPromise(
  InstanceStore.Service.use((store) =>
    store.provide({ directory }, Effect.gen(function* () { ... })),
  ),
)

// after
const { store, ctx } = await AppRuntime.runPromise(
  InstanceStore.Service.use((store) => store.load({ directory }).pipe(Effect.map((ctx) => ({ store, ctx })))),
)
await Instance.restore(ctx, () =>
  AppRuntime.runPromise(
    opts.handler(args).pipe(Effect.provideService(InstanceRef, ctx), Effect.ensuring(store.dispose(ctx))),
  ),
)

Regression test

test/cli/effect-cmd-instance-als.test.ts reproduces the exact pattern: store.load → Effect.promise(async => { runPromise inside }) and asserts that Instance.current is reachable from the inner async closure. Replacing the Instance.restore(...) wrap with a bare Effect.runPromise(inner) causes the test to fail (manually verified) — proves the fix is load-bearing.

Test plan

  • bun run typecheck clean
  • bun run test test/cli/effect-cmd-instance-als.test.ts — 1 pass; verified the test fails without the restore wrap
  • bun run test test/project/instance.test.ts test/cli/ — 148 pass

PR #25522 (the original effectCmd conversion of github.ts) introduced a
latent breakage: any handler that yields `Effect.promise(async () => {
... await AppRuntime.runPromise(svc.method()) ... })` would lose the
outer `InstanceRef` for the inner Effect run.

Effect's fiber context propagates only through Effect's own scheduling.
Inside an `async` callback after the first `await`, `Fiber.getCurrent()`
returns undefined, so `attach()` in run-service.ts can't recover the
outer fiber's `InstanceRef`. The inner Effect runs with `InstanceRef`
defaulting to undefined, then `InstanceState.context` falls through to
`Instance.current` ALS — which the new `effectCmd` was no longer setting
(the legacy `bootstrap()` path used to via `WithInstance.provide`).

End result: services that yield `InstanceState.context` (e.g.
`Session.create`, `SessionShare.share`, `SessionPrompt.prompt`,
`Agent.get`) would die with `LocalContext.NotFound` when invoked from
inside an `Effect.promise(async)` block in any effectCmd handler. This
broke `opencode github run`, which exercises exactly that pattern.

Fix: in effectCmd, split load and body into two AppRuntime.runPromise
calls so the body can run inside `Instance.restore(ctx, ...)`. Node ALS
persists across awaits, so any nested AppRuntime.runPromise inside an
async closure now recovers the instance via `attach()`'s ALS fallback.
This restores the pre-#25522 `bootstrap()` behavior without giving up
the Effect-context InstanceRef on the synchronous path.

Adds a regression test that reproduces the bug (fails without the
restore wrap, passes with it).
kitlangton added 2 commits May 3, 2026 00:13
- Wrap the body run in try/finally so store.dispose(ctx) still fires if
  the JS-side `Instance.restore`/`pipe` chain throws synchronously
  before the inner runPromise constructs the effect.
- Rewrite the regression test to use the existing `provideTestInstance`
  fixture (which already encodes the load → restore → dispose triple)
  instead of hand-rolling the noopBootstrap layer + InstanceStore plumbing.
@kitlangton kitlangton merged commit 2df8eda into dev May 3, 2026
10 checks passed
@kitlangton kitlangton deleted the kit/effectcmd-als-bridge-fix branch May 3, 2026 04:24
oleksii-honchar pushed a commit to oleksii-honchar/better-opencode that referenced this pull request May 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant